mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-01 14:53:07 +08:00
feat: attachment_support
This commit is contained in:
@@ -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"])
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 답변이 일반 문서보다 우선됩니다',
|
||||
|
||||
@@ -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 над обычными документами',
|
||||
|
||||
@@ -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 答案优先于普通文档",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user