feat: attachment_support

This commit is contained in:
MaoMengww
2026-04-10 12:49:20 +08:00
parent 69785ad0a3
commit aaee5f044a
26 changed files with 1029 additions and 19 deletions
+2
View File
@@ -39,6 +39,8 @@ export interface CustomAgentConfig {
image_upload_enabled?: boolean; // 是否启用图片上传(默认: false)
vlm_model_id?: string; // VLM模型ID(图片分析用)
image_storage_provider?: string; // 图片存储提供商
audio_upload_enabled?: boolean; // 是否启用音频上传/ASR转录(默认: false
asr_model_id?: string; // ASR模型ID(音频转录用)
// ===== 文件类型限制 =====
// 支持的文件类型(如 ["csv", "xlsx", "xls"]
+5 -1
View File
@@ -29,7 +29,7 @@ export function useStream() {
let renderTimer: number | null = null
// 启动流式请求
const startStream = async (params: { session_id: any; query: any; knowledge_base_ids?: string[]; knowledge_ids?: string[]; agent_enabled?: boolean; agent_id?: string; web_search_enabled?: boolean; enable_memory?: boolean; summary_model_id?: string; mcp_service_ids?: string[]; mentioned_items?: Array<{id: string; name: string; type: string; kb_type?: string}>; images?: Array<{data: string}>; method: string; url: string }) => {
const startStream = async (params: { session_id: any; query: any; knowledge_base_ids?: string[]; knowledge_ids?: string[]; agent_enabled?: boolean; agent_id?: string; web_search_enabled?: boolean; enable_memory?: boolean; summary_model_id?: string; mcp_service_ids?: string[]; mentioned_items?: Array<{id: string; name: string; type: string; kb_type?: string}>; images?: Array<{data: string}>; attachment_uploads?: Array<{data: string; file_name: string; file_size: number}>; method: string; url: string }) => {
// 重置状态
output.value = '';
error.value = null;
@@ -118,6 +118,10 @@ export function useStream() {
if (params.images !== undefined && params.images.length > 0) {
postBody.images = params.images;
}
// Include attachment_uploads if provided (documents, audio, etc.)
if (params.attachment_uploads !== undefined && params.attachment_uploads.length > 0) {
postBody.attachment_uploads = params.attachment_uploads;
}
postBody.channel = "web";
await fetchEventSource(url, {
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { MessagePlugin } from 'tdesign-vue-next';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
export interface AttachmentFile {
file: File;
id: string;
name: string;
size: number;
type: string;
preview?: string;
}
const props = defineProps<{
maxFiles?: number;
maxSize?: number; // in MB
disabled?: boolean;
}>();
const emit = defineEmits<{
(e: 'update:files', files: AttachmentFile[]): void;
(e: 'remove', id: string): void;
}>();
const attachments = ref<AttachmentFile[]>([]);
const fileInputRef = ref<HTMLInputElement>();
// Supported file types (matching backend)
const SUPPORTED_TYPES = [
// Documents
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
// Text
'.txt', '.md', '.csv', '.json', '.xml', '.html',
// Audio
'.mp3', '.wav', '.m4a', '.flac', '.ogg', '.aac',
];
const maxFiles = computed(() => props.maxFiles || 5);
const maxSize = computed(() => (props.maxSize || 20) * 1024 * 1024); // Convert MB to bytes
const triggerFileSelect = () => {
if (props.disabled) return;
fileInputRef.value?.click();
};
const handleFileSelect = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (!input.files) return;
await addFiles(Array.from(input.files));
input.value = ''; // Reset input
};
const addFiles = async (files: File[]) => {
if (props.disabled) return;
for (const file of files) {
// Check max files limit
if (attachments.value.length >= maxFiles.value) {
MessagePlugin.warning(t('chat.attachmentTooMany', { max: maxFiles.value }));
break;
}
// Check file size
if (file.size > maxSize.value) {
MessagePlugin.warning(t('chat.attachmentTooLarge', { name: file.name, max: props.maxSize || 20 }));
continue;
}
// Check file type
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
if (!SUPPORTED_TYPES.includes(ext)) {
MessagePlugin.warning(t('chat.attachmentTypeNotSupported', { name: file.name }));
continue;
}
const attachment: AttachmentFile = {
file,
id: `${Date.now()}-${Math.random()}`,
name: file.name,
size: file.size,
type: file.type || ext,
};
attachments.value.push(attachment);
}
emit('update:files', attachments.value);
};
const removeAttachment = (id: string) => {
const index = attachments.value.findIndex(a => a.id === id);
if (index !== -1) {
attachments.value.splice(index, 1);
emit('update:files', attachments.value);
emit('remove', id);
}
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
const getFileIcon = (fileName: string): string => {
const ext = fileName.split('.').pop()?.toLowerCase();
if (['pdf'].includes(ext || '')) return 'file-pdf';
if (['doc', 'docx'].includes(ext || '')) return 'file-word';
if (['xls', 'xlsx'].includes(ext || '')) return 'file-excel';
if (['ppt', 'pptx'].includes(ext || '')) return 'file-powerpoint';
if (['txt', 'md'].includes(ext || '')) return 'file-text';
if (['mp3', 'wav', 'm4a', 'flac', 'ogg', 'aac'].includes(ext || '')) return 'sound';
return 'file';
};
defineExpose({
attachments,
triggerFileSelect,
clear: () => {
attachments.value = [];
emit('update:files', []);
}
});
</script>
<template>
<div class="attachment-upload">
<!-- Hidden file input -->
<input
ref="fileInputRef"
type="file"
:accept="SUPPORTED_TYPES.join(',')"
multiple
style="display: none"
@change="handleFileSelect"
/>
<!-- Attachment list -->
<div v-if="attachments.length > 0" class="attachment-preview-bar">
<div
v-for="attachment in attachments"
:key="attachment.id"
class="attachment-preview-item"
>
<div class="attachment-preview-icon">
<t-icon :name="getFileIcon(attachment.name)" />
</div>
<div class="attachment-preview-name">{{ attachment.name }}</div>
<span class="attachment-preview-remove" @click="removeAttachment(attachment.id)" :aria-label="$t('common.remove')">×</span>
</div>
</div>
<!-- Upload button (shown in control bar) -->
<slot name="trigger" :trigger="triggerFileSelect" :count="attachments.length" />
</div>
</template>
<style scoped lang="less">
.attachment-upload {
width: 100%;
}
.attachment-preview-bar {
display: flex;
gap: 8px;
padding: 8px 12px 4px;
flex-wrap: wrap;
}
.attachment-preview-item {
position: relative;
width: 60px;
height: 60px;
border-radius: 8px;
border: 1px solid var(--td-border-level-1-color, #e7e7e7);
background: var(--td-bg-color-secondarycontainer, #f5f5f5);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
overflow: hidden;
cursor: default;
.attachment-preview-icon {
font-size: 22px;
color: var(--td-brand-color, #07C05F);
line-height: 1;
}
.attachment-preview-name {
font-size: 10px;
color: var(--td-text-color-secondary, #666);
text-align: center;
width: 100%;
padding: 0 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
box-sizing: border-box;
}
.attachment-preview-remove {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
cursor: pointer;
line-height: 1;
&:hover {
background: rgba(0, 0, 0, 0.7);
}
}
}
</style>
+84 -2
View File
@@ -18,6 +18,7 @@ import { listAgents, type CustomAgent, BUILTIN_QUICK_ANSWER_ID, BUILTIN_SMART_RE
import { getTenantWebSearchConfig } from '@/api/web-search';
import { getConversationConfig, updateConversationConfig, type ConversationConfig } from '@/api/system';
import { useI18n } from 'vue-i18n';
import AttachmentUpload, { type AttachmentFile } from './AttachmentUpload.vue';
const route = useRoute();
const router = useRouter();
@@ -35,6 +36,10 @@ const uploadedImages = ref<Array<{ file: File; preview: string }>>([]);
const imageInputRef = ref<HTMLInputElement>();
const imageUploading = ref(false);
// Attachment upload state
const attachmentUploadRef = ref<InstanceType<typeof AttachmentUpload>>();
const uploadedAttachments = ref<AttachmentFile[]>([]);
const handleImageSelect = (event: Event) => {
const input = event.target as HTMLInputElement;
if (!input.files) return;
@@ -1338,7 +1343,10 @@ watch([selectedKbIds, selectedFileIds], ([kbIds, fileIds]) => {
}
}, { deep: true });
const emit = defineEmits(['send-msg', 'stop-generation']);
const emit = defineEmits<{
(e: 'send-msg', query: string, modelId: string, mentionedItems: any[], imageFiles: File[], attachmentFiles: AttachmentFile[]): void;
(e: 'stop-generation'): void;
}>();
const createSession = async (val: string) => {
if (!val.trim()) {
@@ -1375,15 +1383,23 @@ const createSession = async (val: string) => {
kb_type: item.type === 'kb' ? (item.kbType || 'document') : undefined
}));
const imageFiles = uploadedImages.value.map(img => img.file);
const attachmentFiles = uploadedAttachments.value;
// Blur the textarea BEFORE emitting, so that when the parent navigates away
// and Vue unmounts this component, TDesign's blur handler won't fire on a
// detached DOM element (which causes getComputedStyle to throw).
const textarea = getTextareaEl();
if (textarea) textarea.blur();
emit('send-msg', val, selectedModelId.value, mentionedItems, imageFiles);
emit('send-msg', val, selectedModelId.value, mentionedItems, imageFiles, attachmentFiles);
// Clean up image previews
uploadedImages.value.forEach(img => URL.revokeObjectURL(img.preview));
uploadedImages.value = [];
// Clean up attachments
attachmentUploadRef.value?.clear();
uploadedAttachments.value = [];
clearvalue();
}
@@ -1854,6 +1870,15 @@ defineExpose({
<span class="image-preview-remove" @click="removeImage(idx)">×</span>
</div>
</div>
<!-- 附件列表区域 ( AttachmentUpload 组件渲染) -->
<AttachmentUpload
ref="attachmentUploadRef"
:max-files="5"
:max-size="20"
@update:files="uploadedAttachments = $event"
/>
<!-- 选中的知识库和文件标签显示在输入框内顶部 -->
<div v-if="allSelectedItems.length > 0" class="selected-tags-inline">
<span
@@ -2013,6 +2038,24 @@ defineExpose({
</div>
</t-tooltip>
<!-- 附件上传按钮 -->
<t-tooltip placement="top" theme="light" :popupProps="{ overlayClassName: 'input-field-tooltip' }">
<template #content>
<span>{{ uploadedAttachments.length > 0 ? $t('chat.attachmentWithCount', { count: uploadedAttachments.length }) : $t('chat.attachmentUploadTooltip') }}</span>
</template>
<div
class="control-btn attachment-upload-btn"
:class="{ 'active': uploadedAttachments.length > 0 }"
@click.stop="attachmentUploadRef?.triggerFileSelect()"
>
<!-- 回形针图标 -->
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="control-icon">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</svg>
<span v-if="uploadedAttachments.length > 0" class="attachment-count">{{ uploadedAttachments.length }}</span>
</div>
</t-tooltip>
<!-- @ 知识库/文件选择按钮 -->
<t-tooltip placement="top" theme="light" :popupProps="{ overlayClassName: 'input-field-tooltip' }">
<template #content>
@@ -2564,6 +2607,45 @@ const getImgSrc = (url: string) => {
}
}
/* Attachment upload */
.attachment-upload-btn {
width: 28px;
height: 28px;
padding: 0;
min-width: auto;
display: flex;
align-items: center;
justify-content: center;
position: relative;
color: var(--td-text-color-secondary, #666);
&:hover {
background: var(--td-bg-color-secondarycontainer-hover, #f0f0f0);
color: var(--td-text-color-primary, #333);
}
&.active {
background: rgba(16, 185, 129, 0.1);
color: #07C05F;
}
.attachment-count {
position: absolute;
top: -2px;
right: -2px;
background: #07C05F;
color: #fff;
font-size: 10px;
width: 14px;
height: 14px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
}
.image-preview-bar {
display: flex;
gap: 8px;
+12
View File
@@ -1864,6 +1864,11 @@ export default {
imageTooMany: 'Maximum 5 images allowed',
imageTypeSizeError: 'Only JPG/PNG/GIF/WEBP under 10MB supported',
imageUploadTooltip: 'Upload image (paste/drop supported)',
attachmentUploadTooltip: 'Upload attachment (documents, audio, etc.)',
attachmentWithCount: '{count} attachment(s) uploaded',
attachmentTooMany: 'Maximum {max} attachments allowed',
attachmentTooLarge: 'File {name} exceeds {max}MB limit',
attachmentTypeNotSupported: 'Unsupported file type: {name}',
},
tenant: {
title: 'Tenant Information',
@@ -3342,6 +3347,13 @@ export default {
notConfigured: 'Not Configured',
goStorageSettings: 'Go to Storage Settings',
},
audioUpload: {
label: 'Audio Upload',
desc: 'When enabled, users can upload audio files in conversations. The system will automatically transcribe them using the ASR model.',
asrModel: 'ASR Model',
asrModelDesc: 'Speech recognition model for audio transcription. If not configured, audio files will be passed as placeholders.',
asrModelPlaceholder: 'Select ASR Model',
},
faq: {
title: 'FAQ Priority Strategy',
tooltip: 'When the knowledge base contains FAQ (Q&A pairs), enable this strategy to prioritize FAQ answers over regular documents',
+12
View File
@@ -413,6 +413,11 @@ export default {
imageTooMany: "최대 5장까지 업로드 가능합니다",
imageTypeSizeError: "JPG/PNG/GIF/WEBP 형식만 지원하며, 단일 파일 10MB 이하",
imageUploadTooltip: "이미지 업로드 (붙여넣기/드래그 지원)",
attachmentUploadTooltip: "첨부 파일 업로드 (문서, 오디오 등)",
attachmentWithCount: "{count}개 파일 업로드됨",
attachmentTooMany: "최대 {max}개 파일까지 업로드 가능합니다",
attachmentTooLarge: "파일 {name}이(가) {max}MB 제한을 초과합니다",
attachmentTypeNotSupported: "지원하지 않는 파일 형식: {name}",
},
settings: {
title: "설정",
@@ -3393,6 +3398,13 @@ export default {
notConfigured: '미설정',
goStorageSettings: '저장소 설정으로 이동',
},
audioUpload: {
label: '음성 업로드',
desc: '활성화하면 사용자가 대화에서 오디오 파일을 업로드할 수 있으며, ASR 모델로 자동 변환됩니다',
asrModel: 'ASR 모델',
asrModelDesc: '음성 인식 모델입니다. 설정하지 않으면 오디오 파일이 플레이스홀더로 전달됩니다',
asrModelPlaceholder: 'ASR 모델 선택',
},
faq: {
title: 'FAQ 우선 전략',
tooltip: '지식베이스에 FAQ(질문-답변 쌍)가 포함된 경우, 이 전략을 활성화하면 FAQ 답변이 일반 문서보다 우선됩니다',
+12
View File
@@ -2032,6 +2032,11 @@ export default {
imageTooMany: 'Максимум 5 изображений',
imageTypeSizeError: 'Поддерживаются только JPG/PNG/GIF/WEBP до 10 МБ',
imageUploadTooltip: 'Загрузить изображение (вставка/перетаскивание)',
attachmentUploadTooltip: 'Загрузить вложение (документы, аудио и т.д.)',
attachmentWithCount: 'Загружено файлов: {count}',
attachmentTooMany: 'Максимум {max} файлов',
attachmentTooLarge: 'Файл {name} превышает лимит {max} МБ',
attachmentTypeNotSupported: 'Неподдерживаемый тип файла: {name}',
thinkingAlt: 'Обдумывание...',
deepThoughtCompleted: 'Глубокий анализ завершён',
deepThoughtAlt: 'Глубокий анализ'
@@ -3009,6 +3014,13 @@ export default {
notConfigured: 'Не настроено',
goStorageSettings: 'Перейти к настройкам хранилища'
},
audioUpload: {
label: 'Загрузка аудио',
desc: 'Позволяет пользователям загружать аудиофайлы в чате. Система автоматически транскрибирует их с помощью ASR-модели.',
asrModel: 'ASR-модель',
asrModelDesc: 'Модель распознавания речи. Если не настроена, аудиофайлы передаются как заглушки.',
asrModelPlaceholder: 'Выберите ASR-модель',
},
faq: {
title: 'Стратегия приоритета FAQ',
tooltip: 'Если база знаний содержит FAQ (пары вопрос-ответ), включите эту стратегию для приоритета ответов FAQ над обычными документами',
+12
View File
@@ -410,6 +410,11 @@ export default {
imageTooMany: "最多上传5张图片",
imageTypeSizeError: "仅支持 JPG/PNG/GIF/WEBP 格式,单张不超过 10MB",
imageUploadTooltip: "上传图片(支持粘贴/拖拽)",
attachmentUploadTooltip: "上传附件(文档、音频等)",
attachmentWithCount: "已上传 {count} 个附件",
attachmentTooMany: "最多上传 {max} 个附件",
attachmentTooLarge: "文件 {name} 超过 {max}MB 限制",
attachmentTypeNotSupported: "不支持的文件类型:{name}",
},
settings: {
title: "设置",
@@ -3341,6 +3346,13 @@ export default {
notConfigured: "未配置",
goStorageSettings: "去存储设置中配置",
},
audioUpload: {
label: "语音上传",
desc: "启用后用户可在对话中上传音频文件,系统将使用 ASR 模型自动转录为文字",
asrModel: "ASR 模型",
asrModelDesc: "用于音频转录的语音识别模型,未配置时音频文件将以占位符形式传递",
asrModelPlaceholder: "请选择 ASR 模型",
},
faq: {
title: "FAQ 优先策略",
tooltip: "当知识库中包含 FAQ(问答对)时,可以启用此策略让 FAQ 答案优先于普通文档",
+4 -1
View File
@@ -38,6 +38,7 @@ export const useMenuStore = defineStore('menuStore', () => {
const firstMentionedItems = ref<any[]>([])
const firstModelId = ref('')
const firstImageFiles = ref<any[]>([])
const firstAttachmentFiles = ref<any[]>([])
const prefillQuery = ref('')
const applyMenuTranslations = () => {
@@ -99,11 +100,12 @@ export const useMenuStore = defineStore('menuStore', () => {
isFirstSession.value = payload
}
const changeFirstQuery = (payload: string, mentionedItems: any[] = [], modelId: string = '', imageFiles: any[] = []) => {
const changeFirstQuery = (payload: string, mentionedItems: any[] = [], modelId: string = '', imageFiles: any[] = [], attachmentFiles: any[] = []) => {
firstQuery.value = payload
firstMentionedItems.value = mentionedItems
firstModelId.value = modelId
firstImageFiles.value = imageFiles
firstAttachmentFiles.value = attachmentFiles
}
const setPrefillQuery = (q: string) => {
@@ -123,6 +125,7 @@ export const useMenuStore = defineStore('menuStore', () => {
firstMentionedItems,
firstModelId,
firstImageFiles,
firstAttachmentFiles,
prefillQuery,
clearMenuArr,
updatemenuArr,
@@ -394,6 +394,35 @@
</a>
</div>
</div>
<!-- 音频上传开关 -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agentEditor.audioUpload.label') }}</label>
<p class="desc">{{ $t('agentEditor.audioUpload.desc') }}</p>
</div>
<div class="setting-control">
<t-switch v-model="formData.config.audio_upload_enabled" />
</div>
</div>
<!-- ASR模型音频上传启用时 -->
<div v-if="formData.config.audio_upload_enabled" class="setting-row">
<div class="setting-info">
<label>{{ $t('agentEditor.audioUpload.asrModel') }}</label>
<p class="desc">{{ $t('agentEditor.audioUpload.asrModelDesc') }}</p>
</div>
<div class="setting-control">
<ModelSelector
model-type="ASR"
:selected-model-id="formData.config.asr_model_id"
:all-models="allModels"
@update:selected-model-id="(val: string) => formData.config.asr_model_id = val"
@add-model="handleAddModel('asr')"
:placeholder="$t('agentEditor.audioUpload.asrModelPlaceholder')"
/>
</div>
</div>
</div>
</div>
@@ -27,6 +27,15 @@
@click="previewImage($event)"
/>
</div>
<!-- 显示上传的附件 -->
<div v-if="hasAttachments" class="user_attachments">
<div v-for="(att, idx) in props.attachments" :key="idx" class="user_attachment_card">
<div class="attachment_card_icon">
<t-icon :name="getAttachmentIcon(att.file_name || att.file_type)" />
</div>
<div class="attachment_card_name">{{ att.file_name }}</div>
</div>
</div>
<div class="user_msg">
{{ content }}
</div>
@@ -56,6 +65,11 @@ const props = defineProps({
required: false,
default: () => []
},
attachments: {
type: Array,
required: false,
default: () => []
},
channel: {
type: String,
required: false,
@@ -79,6 +93,25 @@ const channelClass = computed(() => props.channel ? `channel-${props.channel}` :
const containerRef = ref(null);
const hasImages = computed(() => props.images && props.images.length > 0);
const hasAttachments = computed(() => props.attachments && props.attachments.length > 0);
const getAttachmentIcon = (fileNameOrType) => {
const ext = (fileNameOrType || '').split('.').pop()?.toLowerCase();
if (['pdf'].includes(ext)) return 'file-pdf';
if (['doc', 'docx'].includes(ext)) return 'file-word';
if (['xls', 'xlsx'].includes(ext)) return 'file-excel';
if (['ppt', 'pptx'].includes(ext)) return 'file-powerpoint';
if (['txt', 'md'].includes(ext)) return 'file-text';
if (['mp3', 'wav', 'm4a', 'flac', 'ogg', 'aac'].includes(ext)) return 'sound';
return 'file';
};
const formatFileSize = (bytes) => {
if (!bytes) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
const hydrateImages = async () => {
await nextTick();
@@ -198,6 +231,47 @@ const closePreImg = () => {
max-width: 100%;
}
.user_attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
max-width: 100%;
}
.user_attachment_card {
width: 60px;
height: 60px;
border-radius: 8px;
border: 1px solid var(--td-border-level-1-color, #e7e7e7);
background: var(--td-bg-color-secondarycontainer, #f5f5f5);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
overflow: hidden;
cursor: default;
.attachment_card_icon {
font-size: 22px;
color: var(--td-brand-color, #07C05F);
line-height: 1;
}
.attachment_card_name {
font-size: 10px;
color: var(--td-text-color-secondary, #666);
text-align: center;
width: 100%;
padding: 0 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
box-sizing: border-box;
}
}
.user_image_thumb {
width: 120px;
height: 120px;
+39 -7
View File
@@ -23,7 +23,7 @@
</div>
<div v-for="(session, id) in messagesList" :key='id'>
<div v-if="session.role == 'user'">
<usermsg :content="session.content" :mentioned_items="session.mentioned_items" :images="session.images"></usermsg>
<usermsg :content="session.content" :mentioned_items="session.mentioned_items" :images="session.images" :attachments="session.attachments"></usermsg>
</div>
<div v-if="session.role == 'assistant'">
<botmsg :content="session.content" :session="session" :user-query="getUserQuery(id)" @scroll-bottom="scrollToBottom"
@@ -43,7 +43,7 @@
<div style="min-height: 115px; margin: 16px auto 4px;width: 100%;max-width: 800px;">
<InputField
ref="inputFieldRef"
@send-msg="(query, modelId, mentionedItems, imageFiles) => sendMsg(query, modelId, mentionedItems, imageFiles)"
@send-msg="(query, modelId, mentionedItems, imageFiles, attachmentFiles) => sendMsg(query, modelId, mentionedItems, imageFiles, attachmentFiles)"
@stop-generation="handleStopGeneration"
:isReplying="isReplying"
:sessionId="session_id"
@@ -82,7 +82,7 @@ const useSettingsStoreInstance = useSettingsStore();
const uiStore = useUIStore();
const { navigateToKnowledgeBaseList } = useKnowledgeBaseCreationNavigation();
const { t } = useI18n();
const { menuArr, isFirstSession, firstQuery, firstMentionedItems, firstModelId, firstImageFiles } = storeToRefs(usemenuStore);
const { menuArr, isFirstSession, firstQuery, firstMentionedItems, firstModelId, firstImageFiles, firstAttachmentFiles } = storeToRefs(usemenuStore);
const { output, onChunk, isStreaming, isLoading, error, startStream, stopStream } = useStream();
const route = useRoute();
const router = useRouter();
@@ -418,7 +418,7 @@ const handleStopGeneration = () => {
// API stop
};
const sendMsg = async (value, modelId = '', mentionedItems = [], imageFiles = []) => {
const sendMsg = async (value, modelId = '', mentionedItems = [], imageFiles = [], attachmentFiles = []) => {
userquery.value = value;
isReplying.value = true;
loading.value = true;
@@ -441,8 +441,39 @@ const sendMsg = async (value, modelId = '', mentionedItems = [], imageFiles = []
}
}
// Convert attachment files to base64 for backend processing
let attachmentUploads = [];
if (attachmentFiles && attachmentFiles.length > 0) {
try {
for (const attachment of attachmentFiles) {
const reader = new FileReader();
const base64Promise = new Promise((resolve, reject) => {
reader.onload = () => {
const result = reader.result;
// Extract base64 content (remove data:...;base64, prefix)
const base64 = result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(attachment.file);
});
const base64Data = await base64Promise;
attachmentUploads.push({
data: base64Data,
file_name: attachment.name,
file_size: attachment.size
});
}
} catch (e) {
console.error('[Attachment] Failed to read attachments:', e);
loading.value = false;
isReplying.value = false;
return;
}
}
// @
messagesList.push({ content: value, role: 'user', mentioned_items: mentionedItems, images: userImages, channel: 'web' });
messagesList.push({ content: value, role: 'user', mentioned_items: mentionedItems, images: userImages, attachments: attachmentFiles.map(a => ({ file_name: a.name, file_size: a.size, file_type: '.' + a.name.split('.').pop()?.toLowerCase() })), channel: 'web' });
scrollToBottom();
// Get agent mode status from settings store
@@ -492,6 +523,7 @@ const sendMsg = async (value, modelId = '', mentionedItems = [], imageFiles = []
mcp_service_ids: mcpServiceIds,
mentioned_items: mentionedItems,
images: imageAttachments.length > 0 ? imageAttachments : undefined,
attachment_uploads: attachmentUploads.length > 0 ? attachmentUploads : undefined,
query: value,
method: 'POST',
url: endpoint
@@ -1078,8 +1110,8 @@ onMounted(async () => {
checkmenuTitle(session_id.value)
if (firstQuery.value) {
scrollLock.value = true;
sendMsg(firstQuery.value, firstModelId.value || '', firstMentionedItems.value || [], firstImageFiles.value || []);
usemenuStore.changeFirstQuery('', [], '', []);
sendMsg(firstQuery.value, firstModelId.value || '', firstMentionedItems.value || [], firstImageFiles.value || [], firstAttachmentFiles.value || []);
usemenuStore.changeFirstQuery('', [], '', [], []);
} else {
scrollLock.value = false;
let data = {
+6 -6
View File
@@ -162,11 +162,11 @@ const handleSuggestedQuestionClick = (question: string) => {
inputFieldRef.value?.triggerSend(question);
};
const sendMsg = (value: string, modelId: string, mentionedItems: any[], imageFiles: any[] = []) => {
createNewSession(value, modelId, mentionedItems, imageFiles);
const sendMsg = (value: string, modelId: string, mentionedItems: any[], imageFiles: any[] = [], attachmentFiles: any[] = []) => {
createNewSession(value, modelId, mentionedItems, imageFiles, attachmentFiles);
}
async function createNewSession(value: string, modelId: string, mentionedItems: any[] = [], imageFiles: any[] = []) {
async function createNewSession(value: string, modelId: string, mentionedItems: any[] = [], imageFiles: any[] = [], attachmentFiles: any[] = []) {
const selectedKbs = settingsStore.settings.selectedKnowledgeBases || [];
const selectedFiles = settingsStore.settings.selectedFiles || [];
@@ -186,7 +186,7 @@ async function createNewSession(value: string, modelId: string, mentionedItems:
try {
const res = await createSessions(sessionData);
if (res.data && res.data.id) {
await navigateToSession(res.data.id, value, modelId, mentionedItems, imageFiles);
await navigateToSession(res.data.id, value, modelId, mentionedItems, imageFiles, attachmentFiles);
} else {
console.error('[createChat] Failed to create session');
MessagePlugin.error(t('createChat.messages.createFailed'));
@@ -197,7 +197,7 @@ async function createNewSession(value: string, modelId: string, mentionedItems:
}
}
const navigateToSession = async (sessionId: string, value: string, modelId: string, mentionedItems: any[], imageFiles: any[] = []) => {
const navigateToSession = async (sessionId: string, value: string, modelId: string, mentionedItems: any[], imageFiles: any[] = [], attachmentFiles: any[] = []) => {
const now = new Date().toISOString();
let obj = {
title: t('createChat.newSessionTitle'),
@@ -210,7 +210,7 @@ const navigateToSession = async (sessionId: string, value: string, modelId: stri
};
usemenuStore.updataMenuChildren(obj);
usemenuStore.changeIsFirstSession(true);
usemenuStore.changeFirstQuery(value, mentionedItems, modelId, imageFiles);
usemenuStore.changeFirstQuery(value, mentionedItems, modelId, imageFiles, attachmentFiles);
router.push(`/platform/chat/${sessionId}`);
}
@@ -95,6 +95,10 @@ func (p *PluginIntoChatMessage) OnEvent(ctx context.Context,
if chatManage.QuotedContext != "" {
userContent += "\n\n" + chatManage.QuotedContext
}
// Inject attachment content (documents, audio transcripts, etc.)
if len(chatManage.Attachments) > 0 {
userContent += chatManage.Attachments.BuildPrompt()
}
if tpl := chatManage.SummaryConfig.ContextTemplate; tpl != "" {
chatManage.UserContent = types.RenderPromptPlaceholders(tpl, types.PlaceholderValues{
@@ -180,6 +184,10 @@ func (p *PluginIntoChatMessage) OnEvent(ctx context.Context,
if chatManage.QuotedContext != "" {
userContent += "\n\n" + chatManage.QuotedContext
}
// Inject attachment content (documents, audio transcripts, etc.)
if len(chatManage.Attachments) > 0 {
userContent += chatManage.Attachments.BuildPrompt()
}
// Set formatted content back to chat management
chatManage.UserContent = userContent
@@ -121,6 +121,7 @@ func (s *sessionService) KnowledgeQA(
Images: req.ImageURLs,
VLMModelID: vlmModelID,
ChatModelSupportsVision: chatModelSupportsVision,
Attachments: req.Attachments,
Language: types.LanguageNameFromContext(ctx),
},
PipelineState: types.PipelineState{
@@ -154,6 +155,10 @@ func (s *sessionService) KnowledgeQA(
if req.QuotedContext != "" {
userContent += "\n\n" + req.QuotedContext
}
// Inject attachment content for pure-chat path (RAG path handles this in INTO_CHAT_MESSAGE).
if len(req.Attachments) > 0 {
userContent += req.Attachments.BuildPrompt()
}
chatManage.UserContent = userContent
pipeline = types.NewPipelineBuilder().
@@ -0,0 +1,320 @@
package session
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"fmt"
"path/filepath"
"strings"
"github.com/Tencent/WeKnora/internal/infrastructure/docparser"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/google/uuid"
)
const (
// maxTextFileLines is the line limit for inline text content; excess lines are truncated.
maxTextFileLines = 500
// textFileExtensions lists plain-text extensions handled by the line-based reader.
textFileExtensions = ".txt,.md,.markdown,.json,.xml,.yaml,.yml,.csv,.log"
)
// AttachmentProcessor saves uploaded file attachments and extracts their text content
// for injection into the LLM prompt.
type AttachmentProcessor struct {
fileService interfaces.FileService
documentReader interfaces.DocumentReader
imageResolver *docparser.ImageResolver
modelService interfaces.ModelService // used to obtain the ASR model
}
// NewAttachmentProcessor creates an AttachmentProcessor with the given dependencies.
func NewAttachmentProcessor(
fileService interfaces.FileService,
documentReader interfaces.DocumentReader,
imageResolver *docparser.ImageResolver,
modelService interfaces.ModelService,
) *AttachmentProcessor {
return &AttachmentProcessor{
fileService: fileService,
documentReader: documentReader,
imageResolver: imageResolver,
modelService: modelService,
}
}
// ProcessAttachment validates, saves, and extracts content from a single uploaded file.
// Content extraction is attempted for all supported types; errors are non-fatal (logged as warnings).
func (p *AttachmentProcessor) ProcessAttachment(
ctx context.Context,
data []byte,
fileName string,
fileSize int64,
tenantID uint64,
asrModelID string, // optional; enables audio transcription when set
) (*types.MessageAttachment, error) {
logger.Infof(ctx, "processing attachment: fileName=%s, fileSize=%d", secutils.SanitizeForLog(fileName), fileSize)
// Validate filename (injection / path-traversal checks)
safeFileName, isValid := secutils.ValidateInput(fileName)
if !isValid {
return nil, fmt.Errorf("invalid characters in file name")
}
baseName, err := secutils.SafeFileName(safeFileName)
if err != nil {
return nil, fmt.Errorf("unsafe file name: %w", err)
}
ext := strings.ToLower(filepath.Ext(baseName))
if ext == "" {
ext = ".txt"
}
if !isValidFileType(baseName) {
return nil, fmt.Errorf("unsupported file type: %s", ext)
}
uniqueFileName := fmt.Sprintf("attachment_%s%s", uuid.New().String()[:12], ext)
storageURL, err := p.fileService.SaveBytes(ctx, data, tenantID, uniqueFileName, false)
if err != nil {
return nil, fmt.Errorf("failed to save attachment: %w", err)
}
attachment := &types.MessageAttachment{
URL: storageURL,
FileName: baseName,
FileType: ext,
FileSize: fileSize,
}
// Extract text content based on file type; errors are non-fatal.
if p.isTextFile(ext) {
if err := p.processTextFile(ctx, data, attachment); err != nil {
logger.Warnf(ctx, "text file processing failed: %v", err)
}
} else if docparser.IsAudioFormat(ext) {
if err := p.processAudioFile(ctx, data, baseName, attachment, asrModelID); err != nil {
logger.Warnf(ctx, "audio transcription failed: %v, keeping placeholder", err)
}
} else if docparser.IsSimpleFormat(ext) {
if err := p.processWithDocParser(ctx, data, baseName, ext, attachment, tenantID); err != nil {
logger.Warnf(ctx, "SimpleFormatReader failed: %v", err)
}
} else {
if err := p.processWithDocumentReader(ctx, data, baseName, ext, attachment, tenantID); err != nil {
logger.Warnf(ctx, "DocumentReader failed: %v, keeping metadata only", err)
}
}
logger.Infof(ctx, "attachment processed: fileName=%s, truncated=%v, contentLen=%d",
secutils.SanitizeForLog(baseName), attachment.IsTruncated, len(attachment.Content))
return attachment, nil
}
// isTextFile reports whether ext is a plain-text extension handled line-by-line.
func (p *AttachmentProcessor) isTextFile(ext string) bool {
return strings.Contains(textFileExtensions, ext)
}
// processTextFile reads plain-text content line by line, truncating at maxTextFileLines.
func (p *AttachmentProcessor) processTextFile(ctx context.Context, data []byte, attachment *types.MessageAttachment) error {
scanner := bufio.NewScanner(bytes.NewReader(data))
var lines []string
lineCount := 0
for scanner.Scan() {
lineCount++
if lineCount <= maxTextFileLines {
lines = append(lines, scanner.Text())
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("failed to read file content: %w", err)
}
attachment.LineCount = lineCount
attachment.Content = strings.Join(lines, "\n")
if lineCount > maxTextFileLines {
attachment.IsTruncated = true
logger.Infof(ctx, "text file truncated: total=%d, kept=%d", lineCount, maxTextFileLines)
}
return nil
}
// processWithDocParser extracts content via SimpleFormatReader (md, csv, json, images, etc.).
func (p *AttachmentProcessor) processWithDocParser(
ctx context.Context,
data []byte,
fileName string,
fileType string,
attachment *types.MessageAttachment,
tenantID uint64,
) error {
reader := &docparser.SimpleFormatReader{}
result, err := reader.Read(ctx, &types.ReadRequest{
FileContent: data,
FileName: fileName,
FileType: fileType,
})
if err != nil {
return fmt.Errorf("SimpleFormatReader failed: %w", err)
}
// Resolve embedded image refs to storage URLs.
if len(result.ImageRefs) > 0 && p.imageResolver != nil {
updatedMarkdown, _, err := p.imageResolver.ResolveAndStore(ctx, result, p.fileService, tenantID)
if err != nil {
logger.Warnf(ctx, "image resolution failed: %v", err)
} else {
result.MarkdownContent = updatedMarkdown
}
}
p.applyLineTruncation(ctx, result.MarkdownContent, attachment)
return nil
}
// processAudioFile transcribes audio via ASR. Falls back to a placeholder when no ASR model is configured.
func (p *AttachmentProcessor) processAudioFile(
ctx context.Context,
data []byte,
fileName string,
attachment *types.MessageAttachment,
asrModelID string,
) error {
if asrModelID == "" || p.modelService == nil {
attachment.Content = fmt.Sprintf("[audio file: %s]", fileName)
logger.Infof(ctx, "no ASR model configured, keeping audio placeholder")
return nil
}
asrInstance, err := p.modelService.GetASRModel(ctx, asrModelID)
if err != nil {
return fmt.Errorf("failed to get ASR model: %w", err)
}
logger.Infof(ctx, "starting audio transcription: fileName=%s, size=%d", fileName, len(data))
transcript, err := asrInstance.Transcribe(ctx, data, fileName)
if err != nil {
return fmt.Errorf("audio transcription failed: %w", err)
}
p.applyLineTruncation(ctx, transcript, attachment)
logger.Infof(ctx, "audio transcription done: textLen=%d", len(transcript))
return nil
}
// processWithDocumentReader extracts content from complex formats (pdf, docx, xlsx, etc.).
func (p *AttachmentProcessor) processWithDocumentReader(
ctx context.Context,
data []byte,
fileName string,
fileType string,
attachment *types.MessageAttachment,
tenantID uint64,
) error {
if p.documentReader == nil {
return fmt.Errorf("DocumentReader not configured")
}
result, err := p.documentReader.Read(ctx, &types.ReadRequest{
FileContent: data,
FileName: fileName,
FileType: fileType,
})
if err != nil {
return fmt.Errorf("DocumentReader failed: %w", err)
}
// Resolve embedded image refs to storage URLs.
if len(result.ImageRefs) > 0 && p.imageResolver != nil {
updatedMarkdown, _, err := p.imageResolver.ResolveAndStore(ctx, result, p.fileService, tenantID)
if err != nil {
logger.Warnf(ctx, "image resolution failed: %v", err)
} else {
result.MarkdownContent = updatedMarkdown
}
}
p.applyLineTruncation(ctx, result.MarkdownContent, attachment)
return nil
}
// applyLineTruncation stores content into attachment, truncating at maxTextFileLines if needed.
func (p *AttachmentProcessor) applyLineTruncation(ctx context.Context, content string, attachment *types.MessageAttachment) {
lines := strings.Split(content, "\n")
lineCount := len(lines)
attachment.LineCount = lineCount
if lineCount > maxTextFileLines {
attachment.Content = strings.Join(lines[:maxTextFileLines], "\n")
attachment.IsTruncated = true
logger.Infof(ctx, "content truncated: total=%d, kept=%d", lineCount, maxTextFileLines)
} else {
attachment.Content = content
}
}
// isValidFileType reports whether fileName has a supported extension.
// Kept in sync with the frontend SUPPORTED_TYPES list in AttachmentUpload.vue.
func isValidFileType(fileName string) bool {
ext := strings.ToLower(filepath.Ext(fileName))
if ext == "" {
return false
}
ext = strings.TrimPrefix(ext, ".")
supportedTypes := []string{
// documents
"docx", "doc", "pdf", "ppt", "pptx",
// spreadsheets
"xlsx", "xls",
// text / markup
"md", "markdown", "txt", "csv", "json", "xml", "yaml", "yml", "log", "html",
// images
"jpg", "jpeg", "png", "gif", "bmp", "tiff", "webp",
// audio
"mp3", "wav", "m4a", "flac", "ogg", "aac",
}
for _, t := range supportedTypes {
if ext == t {
return true
}
}
return false
}
// DecodeBase64Attachment decodes a base64 attachment payload, stripping any data URI prefix.
// Tries Std, URL, RawStd, and RawURL encodings in order.
func DecodeBase64Attachment(data string) ([]byte, error) {
// Strip data URI prefix (e.g. "data:application/pdf;base64,")
if idx := strings.Index(data, ","); idx != -1 {
data = data[idx+1:]
}
data = strings.TrimSpace(data)
for _, enc := range []struct{ e *base64.Encoding }{
{base64.StdEncoding},
{base64.URLEncoding},
{base64.RawStdEncoding},
{base64.RawURLEncoding},
} {
if decoded, err := enc.e.DecodeString(data); err == nil {
return decoded, nil
}
}
return nil, fmt.Errorf("base64 decode failed: unrecognised encoding")
}
+10
View File
@@ -5,6 +5,7 @@ import (
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/infrastructure/docparser"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
@@ -24,6 +25,7 @@ type Handler struct {
agentShareService interfaces.AgentShareService // Service for resolving shared agents (KB scope in retrieval)
fileService interfaces.FileService // Service for file storage (image uploads)
modelService interfaces.ModelService // Service for model management (VLM access)
attachmentProcessor *AttachmentProcessor // Processor for file attachments
}
// NewHandler creates a new instance of Handler with all necessary dependencies
@@ -38,6 +40,8 @@ func NewHandler(
agentShareService interfaces.AgentShareService,
fileService interfaces.FileService,
modelService interfaces.ModelService,
documentReader interfaces.DocumentReader,
imageResolver *docparser.ImageResolver,
) *Handler {
return &Handler{
sessionService: sessionService,
@@ -50,6 +54,12 @@ func NewHandler(
agentShareService: agentShareService,
fileService: fileService,
modelService: modelService,
attachmentProcessor: NewAttachmentProcessor(
fileService,
documentReader,
imageResolver,
modelService,
),
}
}
+2 -1
View File
@@ -166,7 +166,7 @@ func createAgentQueryEvent(sessionID, assistantMessageID string) interfaces.Stre
}
// createUserMessage creates a user message and returns the created message.
func (h *Handler) createUserMessage(ctx context.Context, sessionID, query, requestID string, mentionedItems types.MentionedItems, images types.MessageImages, channel string) (*types.Message, error) {
func (h *Handler) createUserMessage(ctx context.Context, sessionID, query, requestID string, mentionedItems types.MentionedItems, images types.MessageImages, attachments types.MessageAttachments, channel string) (*types.Message, error) {
return h.messageService.CreateMessage(ctx, &types.Message{
SessionID: sessionID,
Role: "user",
@@ -176,6 +176,7 @@ func (h *Handler) createUserMessage(ctx context.Context, sessionID, query, reque
IsCompleted: true,
MentionedItems: mentionedItems,
Images: images,
Attachments: attachments,
Channel: channel,
})
}
+66 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"runtime"
"sync"
"time"
"github.com/Tencent/WeKnora/internal/errors"
@@ -37,6 +38,7 @@ type qaRequestContext struct {
images []ImageAttachment // Uploaded images with analysis text
userMessageID string // Created user message ID (populated after createUserMessage)
channel string // Source channel: "web", "api", "im", etc.
attachments types.MessageAttachments // Processed file attachments
}
// buildQARequest converts the qaRequestContext into a types.QARequest for service invocation.
@@ -55,6 +57,7 @@ func (rc *qaRequestContext) buildQARequest() *types.QARequest {
UserMessageID: rc.userMessageID,
WebSearchEnabled: rc.webSearchEnabled,
EnableMemory: rc.enableMemory,
Attachments: rc.attachments,
}
}
@@ -135,6 +138,67 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo
// - Normal pure-chat mode: runs in the async goroutine with progress events
}
// Process file attachments: decode and save to storage, extract content
var processedAttachments types.MessageAttachments
if len(request.AttachmentUploads) > 0 {
logger.Infof(ctx, "[%s] processing %d attachment(s)", logPrefix, len(request.AttachmentUploads))
maxSize := secutils.GetMaxFileSize()
for i, upload := range request.AttachmentUploads {
if upload.FileSize > maxSize {
return nil, nil, errors.NewBadRequestError(
fmt.Sprintf("attachment %d exceeds size limit of %dMB", i+1, secutils.GetMaxFileSizeMB()))
}
}
tenantID := c.GetUint64(types.TenantIDContextKey.String())
// Use ASR only when the agent has audio upload enabled.
asrModelID := ""
if customAgent != nil && customAgent.Config.AudioUploadEnabled && customAgent.Config.ASRModelID != "" {
asrModelID = customAgent.Config.ASRModelID
}
// Process all attachments concurrently.
processedAttachments = make(types.MessageAttachments, len(request.AttachmentUploads))
var wg sync.WaitGroup
errChan := make(chan error, len(request.AttachmentUploads))
for i, upload := range request.AttachmentUploads {
wg.Add(1)
go func(idx int, att AttachmentUpload) {
defer wg.Done()
data, err := DecodeBase64Attachment(att.Data)
if err != nil {
errChan <- fmt.Errorf("attachment %d decode failed: %w", idx+1, err)
return
}
processed, err := h.attachmentProcessor.ProcessAttachment(
ctx, data, att.FileName, att.FileSize, tenantID, asrModelID,
)
if err != nil {
errChan <- fmt.Errorf("attachment %d processing failed: %w", idx+1, err)
return
}
processedAttachments[idx] = *processed
}(i, upload)
}
wg.Wait()
close(errChan)
if len(errChan) > 0 {
err := <-errChan
logger.Errorf(ctx, "[%s] attachment processing failed: %v", logPrefix, err)
return nil, nil, errors.NewBadRequestError(fmt.Sprintf("attachment processing failed: %v", err))
}
logger.Infof(ctx, "[%s] all attachments processed", logPrefix)
}
// Build request context
reqCtx := &qaRequestContext{
ctx: ctx,
@@ -160,6 +224,7 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo
effectiveTenantID: effectiveTenantID,
images: request.Images,
channel: request.Channel,
attachments: processedAttachments,
}
return reqCtx, &request, nil
@@ -478,7 +543,7 @@ func (h *Handler) executeQA(reqCtx *qaRequestContext, mode qaMode, generateTitle
}
// Create user message
userMsg, err := h.createUserMessage(ctx, sessionID, reqCtx.query, reqCtx.requestID, reqCtx.mentionedItems, convertImageAttachments(reqCtx.images), reqCtx.channel)
userMsg, err := h.createUserMessage(ctx, sessionID, reqCtx.query, reqCtx.requestID, reqCtx.mentionedItems, convertImageAttachments(reqCtx.images), reqCtx.attachments, reqCtx.channel)
if err != nil {
reqCtx.c.Error(errors.NewInternalServerError(err.Error()))
return
+8
View File
@@ -49,9 +49,17 @@ type CreateKnowledgeQARequest struct {
DisableTitle bool `json:"disable_title"` // Whether to disable auto title generation
EnableMemory bool `json:"enable_memory"` // Whether memory feature is enabled for this request
Images []ImageAttachment `json:"images"` // Attached images for multimodal chat
AttachmentUploads []AttachmentUpload `json:"attachment_uploads,omitempty"` // Attached files (documents, audio, etc.)
Channel string `json:"channel"` // Source channel: "web", "api", "im", etc.
}
// AttachmentUpload represents a file attachment upload from the client
type AttachmentUpload struct {
Data string `json:"data"` // Base64-encoded file content
FileName string `json:"file_name"` // Original filename
FileSize int64 `json:"file_size"` // File size in bytes
}
// SearchKnowledgeRequest defines the request structure for searching knowledge without LLM summarization
type SearchKnowledgeRequest struct {
Query string `json:"query" binding:"required"` // Query text to search for
+4
View File
@@ -45,6 +45,9 @@ type PipelineRequest struct {
VLMModelID string `json:"-"`
ChatModelSupportsVision bool `json:"-"`
// File attachments support
Attachments MessageAttachments `json:"-"`
// Misc request-scoped config
TenantID uint64 `json:"-"`
WebSearchEnabled bool `json:"-"`
@@ -185,6 +188,7 @@ func (c *ChatManage) Clone() *ChatManage {
Images: append([]string(nil), c.Images...),
VLMModelID: c.VLMModelID,
ChatModelSupportsVision: c.ChatModelSupportsVision,
Attachments: append(MessageAttachments(nil), c.Attachments...),
TenantID: c.TenantID,
WebSearchEnabled: c.WebSearchEnabled,
WebSearchProviderID: c.WebSearchProviderID,
+4
View File
@@ -120,6 +120,10 @@ type CustomAgentConfig struct {
ImageUploadEnabled bool `yaml:"image_upload_enabled" json:"image_upload_enabled"`
// VLM model ID for image analysis (optional, falls back to tenant-level VLM)
VLMModelID string `yaml:"vlm_model_id" json:"vlm_model_id"`
// Whether audio upload (ASR transcription) is enabled for this agent (default: false)
AudioUploadEnabled bool `yaml:"audio_upload_enabled" json:"audio_upload_enabled"`
// ASR model ID for audio transcription (optional)
ASRModelID string `yaml:"asr_model_id" json:"asr_model_id"`
// Storage provider for image uploads: "local", "minio", "cos", "tos"
// Empty means use the global/tenant default provider.
ImageStorageProvider string `yaml:"image_storage_provider" json:"image_storage_provider"`
+80
View File
@@ -4,6 +4,8 @@ package types
import (
"database/sql/driver"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
@@ -64,6 +66,79 @@ func (m *MessageImages) Scan(value interface{}) error {
return json.Unmarshal(b, m)
}
// MessageAttachment represents a file attachment in a chat message
type MessageAttachment struct {
URL string `json:"url"` // Storage URL (provider://path)
FileName string `json:"file_name"` // Original filename
FileType string `json:"file_type"` // File extension (e.g., ".pdf", ".docx")
FileSize int64 `json:"file_size"` // File size in bytes
Content string `json:"content,omitempty"` // Extracted text content (for small text files)
IsTruncated bool `json:"is_truncated,omitempty"` // Whether content was truncated
LineCount int `json:"line_count,omitempty"` // Total line count (for text files)
}
// MessageAttachments is a slice of MessageAttachment for database storage
type MessageAttachments []MessageAttachment
// BuildPrompt returns a formatted prompt section for all attachments,
// injecting file metadata and extracted content into the LLM context.
func (attachments MessageAttachments) BuildPrompt() string {
if len(attachments) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("\n\n## 用户上传的附件\n\n")
for i, att := range attachments {
sb.WriteString(fmt.Sprintf("### 附件 %d: %s\n\n", i+1, att.FileName))
sb.WriteString(fmt.Sprintf("- **文件类型**: %s\n", att.FileType))
sb.WriteString(fmt.Sprintf("- **文件大小**: %.2f KB\n\n", float64(att.FileSize)/1024))
if att.Content != "" {
sb.WriteString("**文件内容**:\n\n")
sb.WriteString(att.Content)
sb.WriteString("\n\n")
if att.IsTruncated {
sb.WriteString(fmt.Sprintf("*注意: 此文件共有 %d 行,已截取前 500 行显示。*\n\n",
att.LineCount))
}
} else {
sb.WriteString("*此文件内容提取失败或不支持。*\n\n")
}
}
return sb.String()
}
// Value implements the driver.Valuer interface for database serialization
func (m MessageAttachments) Value() (driver.Value, error) {
if m == nil {
return json.Marshal([]MessageAttachment{})
}
return json.Marshal(m)
}
// Scan implements the sql.Scanner interface for database deserialization
func (m *MessageAttachments) Scan(value interface{}) error {
if value == nil {
*m = make(MessageAttachments, 0)
return nil
}
var b []byte
switch v := value.(type) {
case []byte:
b = v
case string:
b = []byte(v)
default:
*m = make(MessageAttachments, 0)
return nil
}
return json.Unmarshal(b, m)
}
// MentionedItems is a slice of MentionedItem for database storage
type MentionedItems []MentionedItem
@@ -119,6 +194,8 @@ type Message struct {
MentionedItems MentionedItems `json:"mentioned_items,omitempty" gorm:"type:jsonb,column:mentioned_items"`
// Attached images with OCR/Caption text (for user messages)
Images MessageImages `json:"images,omitempty" gorm:"type:jsonb;column:images"`
// Attached files (documents, audio, etc., for user messages)
Attachments MessageAttachments `json:"attachments,omitempty" gorm:"type:jsonb;column:attachments"`
// Whether message generation is complete
IsCompleted bool `json:"is_completed"`
// Whether this response is a fallback (no knowledge base match found)
@@ -194,6 +271,9 @@ func (m *Message) BeforeCreate(tx *gorm.DB) (err error) {
if m.Images == nil {
m.Images = make(MessageImages, 0)
}
if m.Attachments == nil {
m.Attachments = make(MessageAttachments, 0)
}
return nil
}
+1
View File
@@ -17,4 +17,5 @@ type QARequest struct {
WebSearchEnabled bool // Whether web search is enabled for this request
EnableMemory bool // Whether memory feature is enabled
QuotedContext string // Quoted message content from IM quote-reply (appended at LLM prompt stage, not used for retrieval)
Attachments MessageAttachments // File attachments (processed and ready for prompt injection)
}
@@ -0,0 +1 @@
ALTER TABLE messages DROP COLUMN IF EXISTS attachments;
@@ -0,0 +1 @@
ALTER TABLE messages ADD COLUMN IF NOT EXISTS attachments JSONB DEFAULT '[]'::jsonb;