From c007a897cfafd3c356ef3d3d56ff67d488a855df Mon Sep 17 00:00:00 2001 From: Anky Date: Fri, 26 Jun 2026 00:18:48 +0800 Subject: [PATCH] feat: support multi-tag for document knowledge (#1790) * feat: support multi-tag for document knowledge - Add knowledge_tag_relations join table for many-to-many tag-knowledge mapping - Migrate existing single tag_id data to the new table - Update Knowledge struct, filter, payload types (TagID -> Tags/TagIDs) - Rewrite repository layer: SetKnowledgeTags, GetKnowledgeTags, ListIDsByTagIDs - Update tag count references to query knowledge_tag_relations - Update service/handler layers for multi-tag create/list/batch-update - Frontend: multi-select tag sidebar, multi-tag chip display, tag toggle in popup - FAQ tags remain single-tag model (unchanged) * feat(knowledge): tag edit dialog with dynamic overflow and inline creation - Add TagEditDialog component with search, multi-select, and confirm - Replace inline t-popup tag selector with dialog-based editing - Tags overflow with +N badge, visible count adapts to container width via ResizeObserver - Date/card layout: fixed date width, tags flex to fill remaining space - Add persistent new-tag input bar in dialog (Enter to create, auto-select) - Tag creation emits callback to refresh parent tag list immediately - Apply same overflow + dialog trigger to both card view and list view - Add i18n keys: tagEditDialogTitle, tagEditSearch, tagSelectedCount, tagOverflowTip, tagNewPlaceholder * fix: exclude soft-deleted knowledge from tag reference counts CountReferences, BatchCountReferences, and DeleteUnusedTags now JOIN with knowledges table to filter out soft-deleted records when counting knowledge_tag_relations. Previously they queried knowledge_tag_relations directly without the deleted_at check, causing stale tag counts after knowledge deletion. * fix: refresh tag counts after async knowledge deletion completes Add a second delayed tag list refresh (2.5s) after tag deletion to capture updated reference counts for remaining tags. The original immediate refresh handles removing the deleted tag from the list, while this delayed refresh ensures other tags' counts are correct after the async deletion finishes. --------- Co-authored-by: lyingbug --- frontend/src/api/knowledge-base/index.ts | 22 +- .../components/manual-knowledge-editor.vue | 10 +- frontend/src/hooks/useKnowledgeBase.ts | 8 +- frontend/src/i18n/locales/en-US.ts | 5 + frontend/src/i18n/locales/ko-KR.ts | 5 + frontend/src/i18n/locales/ru-RU.ts | 5 + frontend/src/i18n/locales/zh-CN.ts | 5 + frontend/src/stores/ui.ts | 15 +- frontend/src/stores/uploadConfirm.ts | 2 +- .../src/views/knowledge/KnowledgeBase.vue | 336 +++++++++++++---- .../knowledge/components/DocumentListView.vue | 158 +++++++- .../knowledge/components/TagEditDialog.vue | 350 ++++++++++++++++++ internal/application/repository/knowledge.go | 24 +- .../application/repository/knowledge_tag.go | 88 +++++ internal/application/repository/tag.go | 16 +- .../application/service/datasource_service.go | 14 +- internal/application/service/knowledge.go | 112 ++++-- .../service/knowledge_clone_move.go | 2 - .../application/service/knowledge_create.go | 44 ++- internal/application/service/tag.go | 2 +- internal/handler/knowledge.go | 42 ++- internal/im/service.go | 2 +- internal/types/interfaces/knowledge.go | 20 +- internal/types/knowledge.go | 10 +- internal/types/tag.go | 13 + .../000063_knowledge_multi_tags.down.sql | 25 ++ .../000063_knowledge_multi_tags.up.sql | 31 ++ 27 files changed, 1176 insertions(+), 190 deletions(-) create mode 100644 frontend/src/views/knowledge/components/TagEditDialog.vue create mode 100644 internal/application/repository/knowledge_tag.go create mode 100644 migrations/versioned/000063_knowledge_multi_tags.down.sql create mode 100644 migrations/versioned/000063_knowledge_multi_tags.up.sql diff --git a/frontend/src/api/knowledge-base/index.ts b/frontend/src/api/knowledge-base/index.ts index c12e1661..fcc72d30 100644 --- a/frontend/src/api/knowledge-base/index.ts +++ b/frontend/src/api/knowledge-base/index.ts @@ -160,12 +160,12 @@ export function togglePinKnowledgeBase(id: string) { } // 知识文件 API(基于具体知识库) -// data.tag_id: 可选,指定知识所属的分类ID +// data.tag_ids: 可选,指定知识所属的多个分类ID export function uploadKnowledgeFile( kbId: string, data: { file: File - tag_id?: string + tag_ids?: string[] fileName?: string process_config?: KnowledgeProcessOverrides | string [key: string]: any @@ -176,7 +176,9 @@ export function uploadKnowledgeFile( Object.keys(data).forEach(key => { const value = data[key]; if (value === undefined) return; - if (key === 'process_config' && value && typeof value !== 'string') { + if (key === 'tag_ids' && Array.isArray(value)) { + formData.append(key, value.join(',')); + } else if (key === 'process_config' && value && typeof value !== 'string') { formData.append(key, JSON.stringify(value)); } else { formData.append(key, value); @@ -186,23 +188,23 @@ export function uploadKnowledgeFile( } // 从URL创建知识 -// data.tag_id: 可选,指定知识所属的分类ID +// data.tag_ids: 可选,指定知识所属的多个分类ID export function createKnowledgeFromURL( kbId: string, - data: { url: string; enable_multimodel?: boolean; tag_id?: string; process_config?: KnowledgeProcessOverrides }, + data: { url: string; enable_multimodel?: boolean; tag_ids?: string[]; process_config?: KnowledgeProcessOverrides }, ) { return post(`/api/v1/knowledge-bases/${kbId}/knowledge/url`, data); } // 手工创建知识 -// data.tag_id: 可选,指定知识所属的分类ID +// data.tag_ids: 可选,指定知识所属的分类ID export function createManualKnowledge( kbId: string, data: { title: string content: string status: string - tag_id?: string + tag_ids?: string[] process_config?: KnowledgeProcessOverrides }, ) { @@ -214,7 +216,7 @@ export function listKnowledgeFiles( params: { page: number; page_size: number; - tag_id?: string; + tag_ids?: string; keyword?: string; file_type?: string; parse_status?: string; @@ -226,7 +228,7 @@ export function listKnowledgeFiles( const query = new URLSearchParams(); query.append('page', String(params.page)); query.append('page_size', String(params.page_size)); - if (params.tag_id) query.append('tag_id', params.tag_id); + if (params.tag_ids) query.append('tag_ids', params.tag_ids); if (params.keyword) query.append('keyword', params.keyword); if (params.file_type) query.append('file_type', params.file_type); if (params.parse_status) query.append('parse_status', params.parse_status); @@ -331,7 +333,7 @@ export function deleteKnowledgeBaseTag(kbId: string, tagSeqId: number, params?: return del(`/api/v1/knowledge-bases/${kbId}/tags/${tagSeqId}${forceQuery}`); } -export function updateKnowledgeTagBatch(data: { updates: Record }) { +export function updateKnowledgeTagBatch(data: { updates: Record }) { return put(`/api/v1/knowledge/tags`, data); } diff --git a/frontend/src/components/manual-knowledge-editor.vue b/frontend/src/components/manual-knowledge-editor.vue index fdc57a32..84a14773 100644 --- a/frontend/src/components/manual-knowledge-editor.vue +++ b/frontend/src/components/manual-knowledge-editor.vue @@ -611,20 +611,20 @@ const handleSave = async (targetStatus: ManualStatus) => { saving.value = true savingAction.value = targetStatus try { - const tagIdToUpload = uiStore.selectedTagId !== '__untagged__' ? uiStore.selectedTagId : undefined + const tagIdsToUpload = uiStore.selectedTagIds.length > 0 ? [...uiStore.selectedTagIds] : undefined const payload: { title: string content: string status: string - tag_id?: string + tag_ids?: string[] process_config?: KnowledgeProcessOverrides } = { title: form.title.trim(), content: form.content, status: targetStatus, } - if (tagIdToUpload) { - payload.tag_id = tagIdToUpload + if (tagIdsToUpload && tagIdsToUpload.length > 0) { + payload.tag_ids = tagIdsToUpload } if (targetStatus === 'publish') { @@ -649,7 +649,7 @@ const handleSave = async (targetStatus: ManualStatus) => { knowledgeId: knowledgeId.value || undefined, title: payload.title, content: payload.content, - tagId: tagIdToUpload, + tagIds: tagIdsToUpload, }, }) payload.process_config = confirmResult.processConfig diff --git a/frontend/src/hooks/useKnowledgeBase.ts b/frontend/src/hooks/useKnowledgeBase.ts index eaff2a6c..eda85145 100644 --- a/frontend/src/hooks/useKnowledgeBase.ts +++ b/frontend/src/hooks/useKnowledgeBase.ts @@ -42,7 +42,7 @@ export default function (knowledgeBaseId?: string) { query: { page: number; page_size: number; - tag_id?: string; + tag_ids?: string; keyword?: string; file_type?: string; parse_status?: string; @@ -155,9 +155,9 @@ export default function (knowledgeBaseId?: string) { // 获取当前选中的分类ID const uiStore = useUIStore(); - const tagIdToUpload = uiStore.selectedTagId !== '__untagged__' ? uiStore.selectedTagId : undefined; - - uploadKnowledgeFile(currentKbId, { file, tag_id: tagIdToUpload }) + const tagIdsToUpload = uiStore.selectedTagIds.length > 0 ? [...uiStore.selectedTagIds] : undefined; + + uploadKnowledgeFile(currentKbId, { file, tag_ids: tagIdsToUpload }) .then((result: any) => { if (result.success) { MessagePlugin.info(t('knowledgeBase.uploadSuccess')); diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 81245c9c..17398172 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -370,6 +370,11 @@ export default { publishToWeb: 'Publish to Web', documentCategoryTitle: 'Document Categories', tagUpdateSuccess: 'Tag updated successfully', + tagEditDialogTitle: 'Edit Tags — {name}', + tagEditSearch: 'Search tags...', + tagSelectedCount: '{count} selected', + tagOverflowTip: 'Click to edit tags', + tagNewPlaceholder: 'New tag name, press Enter to add', category: 'Category', faqCategoryTitle: 'FAQ Categories', untagged: 'Uncategorized', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index 16993a61..c21cafb0 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -372,6 +372,11 @@ export default { untagged: "미분류", tagClearAction: "분류 해제", tagUpdateSuccess: "태그 업데이트 성공", + tagEditDialogTitle: '태그 편집 — {name}', + tagEditSearch: '태그 검색...', + tagSelectedCount: '{count}개 선택됨', + tagOverflowTip: '클릭하여 태그 편집', + tagNewPlaceholder: '새 태그 이름 입력, Enter로 추가', tagSearchTooltip: "태그 검색", category: "분류", tagCreateAction: "태그 생성", diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index c5209934..d5d83a1e 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -313,6 +313,11 @@ export default { tagClearAction: 'Снять категорию', tagSearchTooltip: 'Поиск тегов', tagUpdateSuccess: 'Тег успешно обновлен', + tagEditDialogTitle: 'Редактировать теги — {name}', + tagEditSearch: 'Поиск тегов...', + tagSelectedCount: 'Выбрано: {count}', + tagOverflowTip: 'Нажмите, чтобы изменить теги', + tagNewPlaceholder: 'Название нового тега, Enter для добавления', category: 'Категория', tagCreateAction: 'Создать тег', tagSearchPlaceholder: 'Введите название тега', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 578a9160..c27e7af3 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -373,6 +373,11 @@ export default { untagged: "未分类", tagClearAction: "取消分类", tagUpdateSuccess: "标签更新成功", + tagEditDialogTitle: '编辑标签 — {name}', + tagEditSearch: "搜索标签...", + tagSelectedCount: "已选 {count} 个标签", + tagOverflowTip: "点击编辑标签", + tagNewPlaceholder: "输入新标签名称,回车添加", tagSearchTooltip: "搜索标签", category: "分类", tagCreateAction: "新建标签", diff --git a/frontend/src/stores/ui.ts b/frontend/src/stores/ui.ts index 3c3b15f0..49cc08d5 100644 --- a/frontend/src/stores/ui.ts +++ b/frontend/src/stores/ui.ts @@ -8,7 +8,7 @@ export const useUIStore = defineStore('ui', { currentKBId: null as string | null, kbEditorType: 'document' as 'document' | 'faq', // 当前选中的分类ID,用于文件上传时传递 - selectedTagId: '__untagged__' as string, + selectedTagIds: [] as string[], kbEditorInitialSection: null as string | null, settingsInitialSection: null as string | null, settingsInitialSubSection: null as string | null, @@ -107,8 +107,17 @@ export const useUIStore = defineStore('ui', { }, // 设置当前选中的分类ID - setSelectedTagId(tagId: string) { - this.selectedTagId = tagId + toggleSelectedTagId(tagId: string) { + const idx = this.selectedTagIds.indexOf(tagId) + if (idx >= 0) { + this.selectedTagIds.splice(idx, 1) + } else { + this.selectedTagIds.push(tagId) + } + }, + + clearSelectedTagIds() { + this.selectedTagIds = [] }, toggleSidebar() { diff --git a/frontend/src/stores/uploadConfirm.ts b/frontend/src/stores/uploadConfirm.ts index 1a298bab..c8837466 100644 --- a/frontend/src/stores/uploadConfirm.ts +++ b/frontend/src/stores/uploadConfirm.ts @@ -8,7 +8,7 @@ export interface UploadConfirmManualSource { knowledgeId?: string title: string content: string - tagId?: string + tagIds?: string[] } export interface UploadConfirmReparseSource { diff --git a/frontend/src/views/knowledge/KnowledgeBase.vue b/frontend/src/views/knowledge/KnowledgeBase.vue index 1e901b67..94a0caaa 100644 --- a/frontend/src/views/knowledge/KnowledgeBase.vue +++ b/frontend/src/views/knowledge/KnowledgeBase.vue @@ -45,6 +45,7 @@ import FAQEntryManager from './components/FAQEntryManager.vue'; import DocumentListView from './components/DocumentListView.vue'; import DocumentBatchBar from './components/DocumentBatchBar.vue'; import KbUploadSourceDropdown from './components/KbUploadSourceDropdown.vue'; +import TagEditDialog from './components/TagEditDialog.vue'; import type { KnowledgeProcessOverrides } from '@/types/knowledgeProcess'; import { useUploadConfirmStore, type UploadConfirmResult } from '@/stores/uploadConfirm'; import WikiBrowser from './wiki/WikiBrowser.vue'; @@ -490,7 +491,7 @@ const confirmBatchReparse = async () => { } }; -const selectedTagId = ref(''); +const selectedTagIds = ref([]); const tagList = ref([]); const tagLoading = ref(false); const tagSearchQuery = ref(''); @@ -556,7 +557,7 @@ const disableFutureDate = { after: new Date(new Date().setHours(23, 59, 59, 999) const filterParams = computed(() => { const [start, end] = updatedTimeRange.value || []; return { - tag_id: selectedTagId.value || undefined, + tag_ids: selectedTagIds.value.length > 0 ? selectedTagIds.value.join(',') : undefined, keyword: docSearchKeyword.value ? docSearchKeyword.value.trim() : undefined, file_type: selectedFileType.value || undefined, parse_status: selectedParseStatus.value || undefined, @@ -568,10 +569,23 @@ const filterParams = computed(() => { type TagInputInstance = ComponentPublicInstance<{ focus: () => void; select: () => void }>; const onPickTag = (item: any, tagId: string | number) => { if (item) item.isTagPopup = false; - const currentId = item?.tag_id ? String(item.tag_id) : ''; - const nextId = tagId ? String(tagId) : ''; - if (currentId === nextId) return; - handleKnowledgeTagChange(item.id, nextId); + const currentTags = item?.tags || []; + const tagIdStr = tagId ? String(tagId) : ''; + let updatedTags: string[]; + if (!tagIdStr) { + // Clear all tags + updatedTags = []; + } else { + const idx = currentTags.findIndex((t: any) => t.id === tagIdStr); + if (idx >= 0) { + // Remove tag + updatedTags = currentTags.filter((t: any) => t.id !== tagIdStr).map((t: any) => t.id); + } else { + // Add tag + updatedTags = [...currentTags.map((t: any) => t.id), tagIdStr]; + } + } + handleKnowledgeTagChange(item.id, updatedTags); }; const tagMap = computed>(() => { const map: Record = {}; @@ -605,6 +619,74 @@ const newTagName = ref(''); const editingTagId = ref(null); const editingTagName = ref(''); const editingTagSubmitting = ref(false); +// 标签编辑弹窗 +const tagEditDialogVisible = ref(false); +const tagEditTarget = ref(null); + +// 动态标签可见数量(根据卡片容器宽度自适应) +const TAG_EST_WIDTH = 82; // 单个标签估算宽度(含间距) +const TAG_OVERFLOW_MIN = 32; // +N 徽章预留宽度 +const tagVisibleLimit = reactive>({}); +const tagItemTotalMap = new Map(); +let tagChipsRO: ResizeObserver | null = null; + +function setupTagChipsObserver(el: Element | null, itemId: string, totalCount: number) { + if (!el) return; + const htmlEl = el as HTMLElement; + htmlEl.dataset.tagItemId = itemId; + tagItemTotalMap.set(itemId, totalCount); + if (!tagChipsRO) { + tagChipsRO = new ResizeObserver((entries) => { + for (const entry of entries) { + const target = entry.target as HTMLElement; + const id = target.dataset.tagItemId; + if (!id) continue; + const w = entry.contentRect.width; + const total = tagItemTotalMap.get(id) ?? 0; + if (total <= 0) { tagVisibleLimit[id] = 99; continue; } + // 计算能容纳几个标签:总宽 - 溢出徽章预留,除以单标签宽 + const maxFit = Math.floor((w - TAG_OVERFLOW_MIN) / TAG_EST_WIDTH); + const limit = Math.max(1, Math.min(maxFit, total)); + tagVisibleLimit[id] = limit >= total ? 99 : limit; + } + }); + } + tagChipsRO.observe(htmlEl); + // 初始计算 + requestAnimationFrame(() => { + const w = htmlEl.clientWidth; + const total = totalCount; + if (total <= 0) { tagVisibleLimit[itemId] = 99; return; } + const maxFit = Math.floor((w - TAG_OVERFLOW_MIN) / TAG_EST_WIDTH); + const limit = Math.max(1, Math.min(maxFit, total)); + tagVisibleLimit[itemId] = limit >= total ? 99 : limit; + }); +} + +function getTagLimit(itemId: string): number { + return tagVisibleLimit[itemId] ?? 99; +} + +function hasTagOverflow(itemId: string, total: number): boolean { + const limit = tagVisibleLimit[itemId] ?? 99; + return total > limit; +} + +function getOverflowCount(itemId: string, total: number): number { + const limit = tagVisibleLimit[itemId] ?? 99; + return Math.max(0, total - limit); +} + +function openTagEditDialog(item: KnowledgeCard) { + tagEditTarget.value = item; + tagEditDialogVisible.value = true; +} + +function onTagEditConfirm(tagIds: string[]) { + if (tagEditTarget.value) { + handleKnowledgeTagChange(tagEditTarget.value.id, tagIds); + } +} const getPageSize = () => { const viewportHeight = window.innerHeight || document.documentElement.clientHeight; const itemHeight = 148; @@ -731,10 +813,11 @@ const loadTags = async (kbIdValue: string, reset = false) => { } }; -const handleTagFilterChange = (value: string) => { - selectedTagId.value = value; - // 同步更新 store 中的 selectedTagId,供 menu.vue 上传时使用 - uiStore.setSelectedTagId(value); +const handleTagFilterChange = (tagIds: string[]) => { + selectedTagIds.value = tagIds; + // 同步更新 store 中的 selectedTagIds,供 menu.vue 上传时使用 + uiStore.clearSelectedTagIds(); + tagIds.forEach(id => uiStore.toggleSelectedTagId(id)); resetPage(); loadKnowledgeFiles(kbId.value); }; @@ -748,11 +831,14 @@ const handleTagRowClick = (tagId: string) => { editingTagId.value = null; editingTagName.value = ''; } - if (selectedTagId.value === tagId) { - handleTagFilterChange(''); - return; + // Toggle selection + const idx = selectedTagIds.value.indexOf(tagId); + if (idx >= 0) { + selectedTagIds.value.splice(idx, 1); + } else { + selectedTagIds.value.push(tagId); } - handleTagFilterChange(tagId); + handleTagFilterChange([...selectedTagIds.value]); }; const startCreateTag = () => { @@ -862,28 +948,30 @@ const confirmDeleteTag = (tag: any) => { deleteKnowledgeBaseTag(kbId.value, tag.seq_id, { force: true }) .then(() => { MessagePlugin.success(t('knowledgeBase.tagDeleteSuccess')); - if (selectedTagId.value === tag.id) { + if (selectedTagIds.value.includes(tag.id)) { // Reset to show all entries when current tag is deleted - selectedTagId.value = ''; - handleTagFilterChange(''); + selectedTagIds.value = selectedTagIds.value.filter(id => id !== tag.id); + handleTagFilterChange([...selectedTagIds.value]); } loadTags(kbId.value, true); - // 由于后端是异步删除文档,延迟刷新以确保看到最新数据 + // 由于后端是异步删除文档,延迟刷新文档列表 setTimeout(() => { resetPage(); // Reset page counter when reloading files after tag deletion loadKnowledgeFiles(kbId.value); - }, 500); + }, 800); + // 再次延迟刷新标签列表,确保异步删除完成后其他标签的数量正确更新 + setTimeout(() => { + loadTags(kbId.value, true); + }, 2500); }) .catch((error: any) => { MessagePlugin.error(error?.message || t('common.operationFailed')); }); }; -const handleKnowledgeTagChange = async (knowledgeId: string, tagValue: string) => { +const handleKnowledgeTagChange = async (knowledgeId: string, tagIds: string[]) => { try { - // Pass the tag value directly (empty string means no tag) - const tagIdToUpdate = tagValue || null; - await updateKnowledgeTagBatch({ updates: { [knowledgeId]: tagIdToUpdate } }); + await updateKnowledgeTagBatch({ updates: { [knowledgeId]: tagIds } }); MessagePlugin.success(t('knowledgeBase.tagUpdateSuccess')); resetPage(); // Reset page counter to 1 when reloading files after tag change loadKnowledgeFiles(kbId.value); @@ -906,9 +994,10 @@ const loadKnowledgeBaseInfo = async (targetKbId: string, force = false) => { if (!isCurrentKb(targetKbId)) return; kbInfo.value = data; - selectedTagId.value = ''; + selectedTagIds.value = []; + uiStore.clearSelectedTagIds(); // 重置store中的标签选择状态,避免上传文档时自动带上之前选择的标签 - uiStore.setSelectedTagId(''); + uiStore.clearSelectedTagIds(); if (!isFAQ.value) { docListLoading.value = true; loadKnowledgeFiles(targetKbId); @@ -988,14 +1077,14 @@ watch(() => kbId.value, (newKbId, oldKbId) => { resetPage(); tagSearchQuery.value = ''; tagPage.value = 1; - uiStore.setSelectedTagId(''); + uiStore.clearSelectedTagIds(); } loadKnowledgeBaseInfo(newKbId); }, { immediate: true }); -watch(selectedTagId, (newVal, oldVal) => { +watch(selectedTagIds, (newVal, oldVal) => { if (oldVal === undefined) return - if (newVal !== oldVal && kbId.value) { + if (kbId.value) { loadKnowledgeFiles(kbId.value); } }); @@ -1173,7 +1262,7 @@ type KnowledgeCard = { isMore?: boolean; metadata?: any; error_message?: string; - tag_id?: string; + tags?: Array<{ id: string; name: string; color?: string }>; }; // needsStatusPolling decides whether a card row is still "in flight" // enough that the doc list should keep refreshing it. Keep in sync with @@ -1530,7 +1619,7 @@ const executeUploadBatch = async ( return { successCount: 0, failCount: files.length }; } - const tagIdToUpload = selectedTagId.value !== '__untagged__' ? selectedTagId.value : undefined; + const tagIdsToUpload = selectedTagIds.value.length > 0 ? [...selectedTagIds.value] : undefined; let successCount = 0; let failCount = 0; const totalCount = files.length; @@ -1543,10 +1632,10 @@ const executeUploadBatch = async ( try { const uploadData: { file: File - tag_id?: string + tag_ids?: string[] fileName?: string process_config?: KnowledgeProcessOverrides - } = { file, tag_id: tagIdToUpload }; + } = { file, tag_ids: tagIdsToUpload }; const fileName = getFolderUploadFileName(file); if (fileName) uploadData.fileName = fileName; @@ -1602,11 +1691,11 @@ const executeUrlImport = async (url: string, processConfig?: KnowledgeProcessOve return; } - const tagIdToUpload = selectedTagId.value !== '__untagged__' ? selectedTagId.value : undefined; + const tagIdsToUpload = selectedTagIds.value.length > 0 ? [...selectedTagIds.value] : undefined; try { const responseData: any = await createKnowledgeFromURL(targetKbId, { url, - tag_id: tagIdToUpload, + tag_ids: tagIdsToUpload, process_config: processConfig, }); window.dispatchEvent(new CustomEvent('knowledgeFileUploaded', { @@ -2010,7 +2099,7 @@ const handleListAction = ( // Clear selection on filter/tag/kb change to avoid acting on hidden items. watch( - [selectedTagId, docSearchKeyword, selectedFileType, selectedParseStatus, selectedSource, updatedTimeRange, kbId], + [selectedTagIds, docSearchKeyword, selectedFileType, selectedParseStatus, selectedSource, updatedTimeRange, kbId], () => { clearSelection(); }, @@ -2223,7 +2312,7 @@ async function createNewSession(value: string): Promise {