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 <xiayula2014@gmail.com>
This commit is contained in:
@@ -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<string, string | null> }) {
|
||||
export function updateKnowledgeTagBatch(data: { updates: Record<string, string[]> }) {
|
||||
return put(`/api/v1/knowledge/tags`, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -372,6 +372,11 @@ export default {
|
||||
untagged: "미분류",
|
||||
tagClearAction: "분류 해제",
|
||||
tagUpdateSuccess: "태그 업데이트 성공",
|
||||
tagEditDialogTitle: '태그 편집 — {name}',
|
||||
tagEditSearch: '태그 검색...',
|
||||
tagSelectedCount: '{count}개 선택됨',
|
||||
tagOverflowTip: '클릭하여 태그 편집',
|
||||
tagNewPlaceholder: '새 태그 이름 입력, Enter로 추가',
|
||||
tagSearchTooltip: "태그 검색",
|
||||
category: "분류",
|
||||
tagCreateAction: "태그 생성",
|
||||
|
||||
@@ -313,6 +313,11 @@ export default {
|
||||
tagClearAction: 'Снять категорию',
|
||||
tagSearchTooltip: 'Поиск тегов',
|
||||
tagUpdateSuccess: 'Тег успешно обновлен',
|
||||
tagEditDialogTitle: 'Редактировать теги — {name}',
|
||||
tagEditSearch: 'Поиск тегов...',
|
||||
tagSelectedCount: 'Выбрано: {count}',
|
||||
tagOverflowTip: 'Нажмите, чтобы изменить теги',
|
||||
tagNewPlaceholder: 'Название нового тега, Enter для добавления',
|
||||
category: 'Категория',
|
||||
tagCreateAction: 'Создать тег',
|
||||
tagSearchPlaceholder: 'Введите название тега',
|
||||
|
||||
@@ -373,6 +373,11 @@ export default {
|
||||
untagged: "未分类",
|
||||
tagClearAction: "取消分类",
|
||||
tagUpdateSuccess: "标签更新成功",
|
||||
tagEditDialogTitle: '编辑标签 — {name}',
|
||||
tagEditSearch: "搜索标签...",
|
||||
tagSelectedCount: "已选 {count} 个标签",
|
||||
tagOverflowTip: "点击编辑标签",
|
||||
tagNewPlaceholder: "输入新标签名称,回车添加",
|
||||
tagSearchTooltip: "搜索标签",
|
||||
category: "分类",
|
||||
tagCreateAction: "新建标签",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface UploadConfirmManualSource {
|
||||
knowledgeId?: string
|
||||
title: string
|
||||
content: string
|
||||
tagId?: string
|
||||
tagIds?: string[]
|
||||
}
|
||||
|
||||
export interface UploadConfirmReparseSource {
|
||||
|
||||
@@ -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<string>('');
|
||||
const selectedTagIds = ref<string[]>([]);
|
||||
const tagList = ref<any[]>([]);
|
||||
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<Record<string, any>>(() => {
|
||||
const map: Record<string, any> = {};
|
||||
@@ -605,6 +619,74 @@ const newTagName = ref('');
|
||||
const editingTagId = ref<string | null>(null);
|
||||
const editingTagName = ref('');
|
||||
const editingTagSubmitting = ref(false);
|
||||
// 标签编辑弹窗
|
||||
const tagEditDialogVisible = ref(false);
|
||||
const tagEditTarget = ref<KnowledgeCard | null>(null);
|
||||
|
||||
// 动态标签可见数量(根据卡片容器宽度自适应)
|
||||
const TAG_EST_WIDTH = 82; // 单个标签估算宽度(含间距)
|
||||
const TAG_OVERFLOW_MIN = 32; // +N 徽章预留宽度
|
||||
const tagVisibleLimit = reactive<Record<string, number>>({});
|
||||
const tagItemTotalMap = new Map<string, number>();
|
||||
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<void> {
|
||||
|
||||
<template v-if="filteredTags.length">
|
||||
<div v-for="tag in filteredTags" :key="tag.id" class="tag-list-item"
|
||||
:class="{ active: selectedTagId === tag.id, editing: editingTagId === tag.id }"
|
||||
:class="{ active: selectedTagIds.includes(tag.id), editing: editingTagId === tag.id }"
|
||||
@click="handleTagRowClick(tag.id)">
|
||||
<div class="tag-list-left">
|
||||
<span class="tag-hash-icon">#</span>
|
||||
@@ -2534,39 +2623,92 @@ async function createNewSession(value: string): Promise<void> {
|
||||
<span class="card-time">{{ formatDocTime(item.updated_at) }}</span>
|
||||
<div class="card-bottom-right">
|
||||
<div v-if="tagList.length" class="card-tag-selector" @click.stop>
|
||||
<t-popup v-if="canEdit" v-model="item.isTagPopup" trigger="click" placement="bottom-right"
|
||||
overlayClassName="card-tag-popup" destroy-on-close>
|
||||
<template #content>
|
||||
<div class="tag-popup-list">
|
||||
<div v-for="tag in tagList" :key="tag.id" class="tag-popup-item"
|
||||
:class="{ 'is-selected': String(item.tag_id) === String(tag.id) }"
|
||||
@click="onPickTag(item, tag.id)">
|
||||
<t-icon class="tag-popup-check"
|
||||
:class="{ visible: String(item.tag_id) === String(tag.id) }" name="check" />
|
||||
<span class="tag-popup-name">{{ tag.name }}</span>
|
||||
<!-- 可编辑模式:点击打开弹窗 -->
|
||||
<template v-if="canEdit">
|
||||
<template v-if="(item.tags || []).length > 0">
|
||||
<t-tooltip
|
||||
v-if="hasTagOverflow(item.id, (item.tags || []).length)"
|
||||
:content="(item.tags || []).map((t: any) => t.name).join(', ')"
|
||||
placement="top"
|
||||
>
|
||||
<div
|
||||
class="card-tag-chips"
|
||||
:ref="(el: any) => setupTagChipsObserver(el, item.id, (item.tags || []).length)"
|
||||
@click="openTagEditDialog(item)"
|
||||
>
|
||||
<t-tag
|
||||
v-for="tag in (item.tags || []).slice(0, getTagLimit(item.id))"
|
||||
:key="tag.id"
|
||||
size="small"
|
||||
variant="light-outline"
|
||||
class="card-tag-chip"
|
||||
>
|
||||
<span class="tag-text">{{ tag.name }}</span>
|
||||
</t-tag>
|
||||
<span class="card-tag-overflow">+{{ getOverflowCount(item.id, (item.tags || []).length) }}</span>
|
||||
</div>
|
||||
<template v-if="item.tag_id">
|
||||
<div class="tag-popup-divider"></div>
|
||||
<div class="tag-popup-item is-action" @click="onPickTag(item, '')">
|
||||
<t-icon class="tag-popup-check" name="close" />
|
||||
<span class="tag-popup-name">{{ t('knowledgeBase.tagClearAction') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</t-tooltip>
|
||||
<div
|
||||
v-else
|
||||
class="card-tag-chips"
|
||||
:ref="(el: any) => setupTagChipsObserver(el, item.id, (item.tags || []).length)"
|
||||
@click="openTagEditDialog(item)"
|
||||
>
|
||||
<t-tag
|
||||
v-for="tag in (item.tags || []).slice(0, getTagLimit(item.id))"
|
||||
:key="tag.id"
|
||||
size="small"
|
||||
variant="light-outline"
|
||||
class="card-tag-chip"
|
||||
>
|
||||
<span class="tag-text">{{ tag.name }}</span>
|
||||
</t-tag>
|
||||
</div>
|
||||
</template>
|
||||
<t-tag v-if="getTagName(item.tag_id)" size="small" variant="light-outline"
|
||||
class="card-tag-chip">
|
||||
<span class="tag-text">{{ getTagName(item.tag_id) }}</span>
|
||||
</t-tag>
|
||||
<span v-else class="card-tag-add">
|
||||
<span v-else class="card-tag-add" @click="openTagEditDialog(item)">
|
||||
<t-icon name="add" size="12px" />
|
||||
<span>{{ t('knowledgeBase.tagLabel') }}</span>
|
||||
</span>
|
||||
</t-popup>
|
||||
<t-tag v-else-if="getTagName(item.tag_id)" size="small" variant="light-outline"
|
||||
class="card-tag-chip">
|
||||
<span class="tag-text">{{ getTagName(item.tag_id) }}</span>
|
||||
</t-tag>
|
||||
</template>
|
||||
<!-- 只读模式 -->
|
||||
<template v-else-if="(item.tags || []).length > 0">
|
||||
<t-tooltip
|
||||
v-if="hasTagOverflow(item.id, (item.tags || []).length)"
|
||||
:content="(item.tags || []).map((t: any) => t.name).join(', ')"
|
||||
placement="top"
|
||||
>
|
||||
<div
|
||||
class="card-tag-chips"
|
||||
:ref="(el: any) => setupTagChipsObserver(el, item.id, (item.tags || []).length)"
|
||||
>
|
||||
<t-tag
|
||||
v-for="tag in (item.tags || []).slice(0, getTagLimit(item.id))"
|
||||
:key="tag.id"
|
||||
size="small"
|
||||
variant="light-outline"
|
||||
class="card-tag-chip"
|
||||
>
|
||||
<span class="tag-text">{{ tag.name }}</span>
|
||||
</t-tag>
|
||||
<span class="card-tag-overflow">+{{ getOverflowCount(item.id, (item.tags || []).length) }}</span>
|
||||
</div>
|
||||
</t-tooltip>
|
||||
<div
|
||||
v-else
|
||||
class="card-tag-chips"
|
||||
:ref="(el: any) => setupTagChipsObserver(el, item.id, (item.tags || []).length)"
|
||||
>
|
||||
<t-tag
|
||||
v-for="tag in (item.tags || []).slice(0, getTagLimit(item.id))"
|
||||
:key="tag.id"
|
||||
size="small"
|
||||
variant="light-outline"
|
||||
class="card-tag-chip"
|
||||
>
|
||||
<span class="tag-text">{{ tag.name }}</span>
|
||||
</t-tag>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="card-type">
|
||||
<span>{{ getKnowledgeType(item) }}</span>
|
||||
@@ -2615,9 +2757,9 @@ async function createNewSession(value: string): Promise<void> {
|
||||
}}</span>
|
||||
<span v-if="(hoveredCardItem as any).channel && (hoveredCardItem as any).channel !== 'web'"
|
||||
class="card-popover-channel">{{ getChannelLabel((hoveredCardItem as any).channel) }}</span>
|
||||
<span v-if="getTagName(hoveredCardItem.tag_id)" class="card-popover-tag">{{
|
||||
getTagName(hoveredCardItem.tag_id)
|
||||
}}</span>
|
||||
<span v-if="(hoveredCardItem as any).tags && (hoveredCardItem as any).tags.length > 0" class="card-popover-tag">{{
|
||||
(hoveredCardItem as any).tags.map((t: any) => t.name).join(', ')
|
||||
}}</span>
|
||||
<span class="card-popover-type">{{ getKnowledgeType(hoveredCardItem) }}</span>
|
||||
</div>
|
||||
<div class="card-popover-hint">{{ t('knowledgeBase.clickToViewFull') }}</div>
|
||||
@@ -2629,7 +2771,8 @@ async function createNewSession(value: string): Promise<void> {
|
||||
<DocumentListView :items="cardList" :selected-ids="selectedIds" :tag-list="tagList"
|
||||
:can-edit="canEdit" @open="(item: any) => openKnowledgeItem(item)" @toggle-row="toggleSelectRow"
|
||||
@toggle-all="toggleSelectAll"
|
||||
@action="(action: any, item: any) => handleListAction(action, item)" />
|
||||
@action="(action: any, item: any) => handleListAction(action, item)"
|
||||
@tag-edit="(item: any) => openTagEditDialog(item)" />
|
||||
</template>
|
||||
<template v-else-if="!docListLoading">
|
||||
<div class="doc-empty-state">
|
||||
@@ -2665,6 +2808,18 @@ async function createNewSession(value: string): Promise<void> {
|
||||
@update:visible="(val) => val ? null : uiStore.closeKBEditor()" @success="handleKBEditorSuccess" />
|
||||
|
||||
<ContextualGuide tour="kbDetail" :when="showKbDetailContextualGuide" />
|
||||
|
||||
<!-- 标签编辑弹窗 -->
|
||||
<TagEditDialog
|
||||
:visible="tagEditDialogVisible"
|
||||
:knowledge-name="tagEditTarget?.display_name || tagEditTarget?.file_name || tagEditTarget?.title || ''"
|
||||
:kb-id="kbId"
|
||||
:tag-list="tagList"
|
||||
:selected-tags="tagEditTarget?.tags || []"
|
||||
@update:visible="tagEditDialogVisible = $event"
|
||||
@confirm="onTagEditConfirm"
|
||||
@tag-created="loadTags(kbId, true)"
|
||||
/>
|
||||
</template>
|
||||
<style>
|
||||
/* 下拉菜单容器样式已统一至 @/assets/dropdown-menu.less */
|
||||
@@ -3528,6 +3683,36 @@ async function createNewSession(value: string): Promise<void> {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.card-tag-chips {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-tag-overflow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 18px;
|
||||
min-width: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--td-component-stroke);
|
||||
color: var(--td-text-color-placeholder);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--td-brand-color);
|
||||
color: var(--td-brand-color);
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.t-tag) {
|
||||
cursor: pointer;
|
||||
max-width: 120px;
|
||||
@@ -3585,9 +3770,13 @@ async function createNewSession(value: string): Promise<void> {
|
||||
|
||||
|
||||
.card-bottom-right {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.faq-manager-wrapper {
|
||||
@@ -4105,13 +4294,16 @@ async function createNewSession(value: string): Promise<void> {
|
||||
}
|
||||
|
||||
.card-time {
|
||||
flex-shrink: 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-family: var(--app-font-family);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-type {
|
||||
flex-shrink: 0;
|
||||
color: var(--td-text-color-placeholder);
|
||||
font-family: var(--app-font-family);
|
||||
font-size: 11px;
|
||||
@@ -4122,6 +4314,16 @@ async function createNewSession(value: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
.card-bottom-right {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.knowledge-card:hover {
|
||||
border-color: color-mix(in srgb, var(--td-component-stroke) 55%, var(--td-brand-color));
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatFileSize, getFileIcon } from '@/utils/files';
|
||||
|
||||
@@ -15,7 +15,7 @@ interface KnowledgeItem {
|
||||
file_type?: string;
|
||||
file_size?: number | string;
|
||||
type?: string;
|
||||
tag_id?: string | number;
|
||||
tags?: Tag[];
|
||||
parse_status?: string;
|
||||
summary_status?: string;
|
||||
updated_at?: string;
|
||||
@@ -38,10 +38,63 @@ const emit = defineEmits<{
|
||||
(e: 'toggle-row', id: string, checked: boolean, shiftKey: boolean): void;
|
||||
(e: 'toggle-all', checked: boolean): void;
|
||||
(e: 'action', action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'delete', item: KnowledgeItem): void;
|
||||
(e: 'tag-edit', item: KnowledgeItem): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// 动态标签可见数量(根据列宽自适应)
|
||||
const TAG_EST_WIDTH = 82;
|
||||
const TAG_OVERFLOW_MIN = 32;
|
||||
const tagVisibleLimit = reactive<Record<string, number>>({});
|
||||
const tagItemTotalMap = new Map<string, number>();
|
||||
let tagChipsRO: ResizeObserver | null = null;
|
||||
|
||||
function setupTagChipsObserver(el: Element | null, itemId: string, totalCount: number) {
|
||||
if (!el) return;
|
||||
const htmlEl = el as HTMLElement;
|
||||
htmlEl.dataset.listTagItemId = 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.listTagItemId;
|
||||
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);
|
||||
}
|
||||
|
||||
const tagMap = computed(() => {
|
||||
const map: Record<string, Tag> = {};
|
||||
for (const tag of props.tagList) map[String(tag.id)] = tag;
|
||||
@@ -248,10 +301,51 @@ const handleAction = (action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'de
|
||||
|
||||
|
||||
<div class="cell cell-tag">
|
||||
<t-tag v-if="getTagName(item.tag_id)" size="small" variant="light-outline" class="row-tag">
|
||||
{{ getTagName(item.tag_id) }}
|
||||
</t-tag>
|
||||
<span v-else class="row-muted">--</span>
|
||||
<template v-if="item.tags && item.tags.length > 0">
|
||||
<t-tooltip
|
||||
v-if="hasTagOverflow(item.id, item.tags.length)"
|
||||
:content="item.tags.map((t: any) => t.name).join(', ')"
|
||||
placement="top"
|
||||
>
|
||||
<div
|
||||
class="row-tag-chips"
|
||||
:ref="(el: any) => setupTagChipsObserver(el, item.id, item.tags.length)"
|
||||
:class="{ 'is-clickable': canEdit }"
|
||||
@click.stop="canEdit && emit('tag-edit', item)"
|
||||
>
|
||||
<t-tag
|
||||
v-for="tag in item.tags.slice(0, getTagLimit(item.id))"
|
||||
:key="tag.id"
|
||||
size="small"
|
||||
variant="light-outline"
|
||||
class="row-tag"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</t-tag>
|
||||
<span class="row-tag-overflow">+{{ getOverflowCount(item.id, item.tags.length) }}</span>
|
||||
</div>
|
||||
</t-tooltip>
|
||||
<div
|
||||
v-else
|
||||
class="row-tag-chips"
|
||||
:ref="(el: any) => setupTagChipsObserver(el, item.id, item.tags.length)"
|
||||
:class="{ 'is-clickable': canEdit }"
|
||||
@click.stop="canEdit && emit('tag-edit', item)"
|
||||
>
|
||||
<t-tag
|
||||
v-for="tag in item.tags.slice(0, getTagLimit(item.id))"
|
||||
:key="tag.id"
|
||||
size="small"
|
||||
variant="light-outline"
|
||||
class="row-tag"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</t-tag>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="row-tag-chips is-clickable" @click.stop="canEdit && emit('tag-edit', item)">
|
||||
<span class="row-tag-add">+ {{ t('knowledgeBase.tagLabel') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-source">
|
||||
@@ -597,6 +691,58 @@ const handleAction = (action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'de
|
||||
}
|
||||
}
|
||||
|
||||
.row-tag-chips {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
&.is-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.row-tag-overflow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--td-component-stroke);
|
||||
color: var(--td-text-color-placeholder);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--td-brand-color);
|
||||
color: var(--td-brand-color);
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
}
|
||||
}
|
||||
|
||||
.row-tag-add {
|
||||
font-size: 11px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
border: 1px dashed var(--td-component-stroke);
|
||||
border-radius: 999px;
|
||||
padding: 0 6px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--td-brand-color);
|
||||
color: var(--td-brand-color);
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border-style: solid;
|
||||
}
|
||||
}
|
||||
|
||||
.row-muted {
|
||||
color: var(--td-text-color-disabled, #bbb);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<t-dialog
|
||||
:visible="visible"
|
||||
:header="title"
|
||||
:footer="false"
|
||||
width="480px"
|
||||
:close-on-overlay-click="false"
|
||||
destroy-on-close
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- 搜索框 -->
|
||||
<div class="tag-edit-search">
|
||||
<t-input
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('knowledgeBase.tagEditSearch')"
|
||||
clearable
|
||||
size="medium"
|
||||
>
|
||||
<template #prefix-icon>
|
||||
<t-icon name="search" />
|
||||
</template>
|
||||
</t-input>
|
||||
</div>
|
||||
|
||||
<!-- 标签列表 -->
|
||||
<div class="tag-edit-list">
|
||||
<template v-if="filteredTags.length > 0">
|
||||
<div
|
||||
v-for="tag in filteredTags"
|
||||
:key="tag.id"
|
||||
class="tag-edit-item"
|
||||
:class="{ 'is-selected': selectedSet.has(tag.id) }"
|
||||
@click="toggleTag(tag.id)"
|
||||
>
|
||||
<t-checkbox
|
||||
:checked="selectedSet.has(tag.id)"
|
||||
:value="tag.id"
|
||||
@change="toggleTag(tag.id)"
|
||||
@click.stop
|
||||
/>
|
||||
<span class="tag-edit-name">{{ tag.name }}</span>
|
||||
<span v-if="tag.knowledge_count !== undefined" class="tag-edit-count">{{ tag.knowledge_count }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="tag-edit-empty">
|
||||
<template v-if="searchQuery.trim()">
|
||||
<p class="tag-edit-empty-text">{{ $t('knowledgeBase.tagEmptyResult') }}</p>
|
||||
<t-button
|
||||
variant="text"
|
||||
theme="primary"
|
||||
size="small"
|
||||
:loading="creatingTag"
|
||||
@click="handleCreateTag"
|
||||
>
|
||||
<t-icon name="add" />
|
||||
{{ $t('knowledgeBase.tagCreateAction') }} "{{ searchQuery.trim() }}"
|
||||
</t-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="tag-edit-empty-text">{{ $t('knowledgeBase.noTags') }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 新增标签栏 -->
|
||||
<div class="tag-edit-new-bar">
|
||||
<t-input
|
||||
v-model="newTagName"
|
||||
:placeholder="$t('knowledgeBase.tagNewPlaceholder')"
|
||||
size="small"
|
||||
clearable
|
||||
:disabled="creatingTag"
|
||||
@keydown.enter.prevent="handleAddNewTag"
|
||||
>
|
||||
<template #suffix>
|
||||
<t-button
|
||||
theme="primary"
|
||||
variant="text"
|
||||
size="small"
|
||||
:loading="creatingTag"
|
||||
:disabled="!newTagName.trim()"
|
||||
@click="handleAddNewTag"
|
||||
>
|
||||
<t-icon name="add" />
|
||||
</t-button>
|
||||
</template>
|
||||
</t-input>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<div class="tag-edit-footer">
|
||||
<div class="tag-edit-footer-left">
|
||||
<span class="tag-edit-selected-count">
|
||||
{{ $t('knowledgeBase.tagSelectedCount', { count: selectedSet.size }) }}
|
||||
</span>
|
||||
<t-button
|
||||
v-if="selectedSet.size > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
theme="default"
|
||||
@click="clearAll"
|
||||
>
|
||||
{{ $t('knowledgeBase.tagClearAction') }}
|
||||
</t-button>
|
||||
</div>
|
||||
<div class="tag-edit-footer-right">
|
||||
<t-button variant="outline" size="small" @click="handleClose">
|
||||
{{ $t('common.cancel') }}
|
||||
</t-button>
|
||||
<t-button theme="primary" size="small" :loading="saving" @click="handleConfirm">
|
||||
{{ $t('common.confirm') }}
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</t-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { createKnowledgeBaseTag } from '@/api/knowledge-base';
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
knowledge_count?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
knowledgeName: string;
|
||||
kbId: string;
|
||||
tagList: Tag[];
|
||||
selectedTags: Tag[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'confirm', tagIds: string[]): void;
|
||||
(e: 'tag-created'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const searchQuery = ref('');
|
||||
const selectedSet = ref<Set<string>>(new Set());
|
||||
const creatingTag = ref(false);
|
||||
const saving = ref(false);
|
||||
const newTagName = ref('');
|
||||
|
||||
const title = computed(() => {
|
||||
return t('knowledgeBase.tagEditDialogTitle', { name: props.knowledgeName });
|
||||
});
|
||||
|
||||
// 初始化已选标签
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
selectedSet.value = new Set(props.selectedTags.map((t) => t.id));
|
||||
searchQuery.value = '';
|
||||
newTagName.value = '';
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 过滤标签列表,按照关键字匹配,已选的排前面
|
||||
const filteredTags = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase();
|
||||
let list = props.tagList;
|
||||
if (query) {
|
||||
list = list.filter((tag) => (tag.name || '').toLowerCase().includes(query));
|
||||
}
|
||||
// 已选的排前面
|
||||
const selected: Tag[] = [];
|
||||
const unselected: Tag[] = [];
|
||||
for (const tag of list) {
|
||||
if (selectedSet.value.has(tag.id)) {
|
||||
selected.push(tag);
|
||||
} else {
|
||||
unselected.push(tag);
|
||||
}
|
||||
}
|
||||
return [...selected, ...unselected];
|
||||
});
|
||||
|
||||
function toggleTag(tagId: string) {
|
||||
const next = new Set(selectedSet.value);
|
||||
if (next.has(tagId)) {
|
||||
next.delete(tagId);
|
||||
} else {
|
||||
next.add(tagId);
|
||||
}
|
||||
selectedSet.value = next;
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
selectedSet.value = new Set();
|
||||
}
|
||||
|
||||
async function handleCreateTag() {
|
||||
const name = searchQuery.value.trim();
|
||||
if (!name) return;
|
||||
creatingTag.value = true;
|
||||
try {
|
||||
const res: any = await createKnowledgeBaseTag(props.kbId, { name });
|
||||
const newTag = res?.data || res;
|
||||
const next = new Set(selectedSet.value);
|
||||
next.add(newTag.id);
|
||||
selectedSet.value = next;
|
||||
searchQuery.value = '';
|
||||
emit('tag-created');
|
||||
MessagePlugin.success(t('knowledgeBase.tagCreateSuccess'));
|
||||
} catch (error: any) {
|
||||
MessagePlugin.error(error?.message || t('common.operationFailed'));
|
||||
} finally {
|
||||
creatingTag.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddNewTag() {
|
||||
const name = newTagName.value.trim();
|
||||
if (!name) return;
|
||||
// 检查是否已存在同名标签
|
||||
const exists = props.tagList.find((t) => t.name === name);
|
||||
if (exists) {
|
||||
// 已存在则直接选中
|
||||
const next = new Set(selectedSet.value);
|
||||
next.add(exists.id);
|
||||
selectedSet.value = next;
|
||||
newTagName.value = '';
|
||||
return;
|
||||
}
|
||||
creatingTag.value = true;
|
||||
try {
|
||||
const res: any = await createKnowledgeBaseTag(props.kbId, { name });
|
||||
const newTag = res?.data || res;
|
||||
const next = new Set(selectedSet.value);
|
||||
next.add(newTag.id);
|
||||
selectedSet.value = next;
|
||||
newTagName.value = '';
|
||||
emit('tag-created');
|
||||
MessagePlugin.success(t('knowledgeBase.tagCreateSuccess'));
|
||||
} catch (error: any) {
|
||||
MessagePlugin.error(error?.message || t('common.operationFailed'));
|
||||
} finally {
|
||||
creatingTag.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
saving.value = true;
|
||||
try {
|
||||
emit('confirm', Array.from(selectedSet.value));
|
||||
emit('update:visible', false);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:visible', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tag-edit-search {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tag-edit-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--td-component-stroke);
|
||||
border-radius: 6px;
|
||||
padding: 4px 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tag-edit-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.tag-edit-item:hover {
|
||||
background: var(--td-bg-color-container-hover);
|
||||
}
|
||||
|
||||
.tag-edit-item.is-selected {
|
||||
background: var(--td-brand-color-light);
|
||||
}
|
||||
|
||||
.tag-edit-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
.tag-edit-count {
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
|
||||
.tag-edit-empty {
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tag-edit-empty-text {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
|
||||
.tag-edit-new-bar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tag-edit-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tag-edit-footer-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tag-edit-selected-count {
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
.tag-edit-footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -98,8 +98,11 @@ func (r *knowledgeRepository) ListKnowledgeByKnowledgeBaseID(
|
||||
// KnowledgeListFilter to a GORM query. Tenant / knowledge base scoping must be
|
||||
// applied by the caller before invoking this helper.
|
||||
func applyKnowledgeListFilter(query *gorm.DB, filter types.KnowledgeListFilter) *gorm.DB {
|
||||
if filter.TagID != "" {
|
||||
query = query.Where("tag_id = ?", filter.TagID)
|
||||
if len(filter.TagIDs) > 0 {
|
||||
query = query.Where(
|
||||
"knowledges.id IN (SELECT knowledge_id FROM knowledge_tag_relations WHERE tag_id IN (?))",
|
||||
filter.TagIDs,
|
||||
)
|
||||
}
|
||||
if filter.Keyword != "" {
|
||||
escaped := escapeLikeKeyword(filter.Keyword)
|
||||
@@ -729,15 +732,22 @@ func (r *knowledgeRepository) SearchKnowledgeInScopes(
|
||||
return knowledges, hasMore, nil
|
||||
}
|
||||
|
||||
// ListIDsByTagID returns all knowledge IDs that have the specified tag ID
|
||||
func (r *knowledgeRepository) ListIDsByTagID(
|
||||
// ListIDsByTagIDs returns all knowledge IDs that have any of the specified tag IDs (OR semantics)
|
||||
func (r *knowledgeRepository) ListIDsByTagIDs(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
kbID, tagID string,
|
||||
kbID string,
|
||||
tagIDs []string,
|
||||
) ([]string, error) {
|
||||
if len(tagIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var ids []string
|
||||
err := r.db.WithContext(ctx).Model(&types.Knowledge{}).
|
||||
Where("tenant_id = ? AND knowledge_base_id = ? AND tag_id = ?", tenantID, kbID, tagID).
|
||||
Pluck("id", &ids).Error
|
||||
Joins("JOIN knowledge_tag_relations ktr ON knowledges.id = ktr.knowledge_id").
|
||||
Where("knowledges.tenant_id = ? AND knowledges.knowledge_base_id = ? AND ktr.tag_id IN (?)",
|
||||
tenantID, kbID, tagIDs).
|
||||
Distinct("knowledges.id").
|
||||
Pluck("knowledges.id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SetKnowledgeTags replaces all tags for a single knowledge entry.
|
||||
// It deletes existing relations and inserts new ones in a transaction.
|
||||
func (r *knowledgeRepository) SetKnowledgeTags(
|
||||
ctx context.Context,
|
||||
knowledgeID string,
|
||||
tagIDs []string,
|
||||
) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Delete all existing tag relations for this knowledge
|
||||
if err := tx.Where("knowledge_id = ?", knowledgeID).
|
||||
Delete(&types.KnowledgeTagRelation{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Insert new relations
|
||||
if len(tagIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
relations := make([]types.KnowledgeTagRelation, 0, len(tagIDs))
|
||||
for _, tagID := range tagIDs {
|
||||
if tagID == "" {
|
||||
continue
|
||||
}
|
||||
relations = append(relations, types.KnowledgeTagRelation{
|
||||
KnowledgeID: knowledgeID,
|
||||
TagID: tagID,
|
||||
CreatedAt: now,
|
||||
})
|
||||
}
|
||||
if len(relations) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Create(&relations).Error
|
||||
})
|
||||
}
|
||||
|
||||
// GetKnowledgeTags returns tags for multiple knowledge IDs.
|
||||
// The result is a map from knowledge ID to its tag list.
|
||||
func (r *knowledgeRepository) GetKnowledgeTags(
|
||||
ctx context.Context,
|
||||
knowledgeIDs []string,
|
||||
) (map[string][]*types.KnowledgeTag, error) {
|
||||
result := make(map[string][]*types.KnowledgeTag)
|
||||
if len(knowledgeIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Query relations and join with knowledge_tags to get full tag info
|
||||
type relationWithTag struct {
|
||||
KnowledgeID string `gorm:"column:knowledge_id"`
|
||||
types.KnowledgeTag
|
||||
}
|
||||
var rows []relationWithTag
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table("knowledge_tag_relations AS ktr").
|
||||
Select("ktr.knowledge_id, kt.id, kt.seq_id, kt.tenant_id, kt.knowledge_base_id, kt.name, kt.color, kt.sort_order, kt.created_at, kt.updated_at").
|
||||
Joins("JOIN knowledge_tags AS kt ON ktr.tag_id = kt.id").
|
||||
Where("ktr.knowledge_id IN (?)", knowledgeIDs).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
tag := row.KnowledgeTag
|
||||
result[row.KnowledgeID] = append(result[row.KnowledgeID], &tag)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteKnowledgeTagRelations deletes all tag relations for a knowledge entry.
|
||||
func (r *knowledgeRepository) DeleteKnowledgeTagRelations(
|
||||
ctx context.Context,
|
||||
knowledgeID string,
|
||||
) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("knowledge_id = ?", knowledgeID).
|
||||
Delete(&types.KnowledgeTagRelation{}).Error
|
||||
}
|
||||
@@ -150,8 +150,9 @@ func (r *knowledgeTagRepository) CountReferences(
|
||||
tagID string,
|
||||
) (knowledgeCount int64, chunkCount int64, err error) {
|
||||
if err = r.db.WithContext(ctx).
|
||||
Model(&types.Knowledge{}).
|
||||
Where("tenant_id = ? AND knowledge_base_id = ? AND tag_id = ?", tenantID, kbID, tagID).
|
||||
Table("knowledge_tag_relations AS ktr").
|
||||
Joins("JOIN knowledges AS k ON ktr.knowledge_id = k.id AND k.deleted_at IS NULL").
|
||||
Where("ktr.tag_id = ?", tagID).
|
||||
Count(&knowledgeCount).Error; err != nil {
|
||||
return
|
||||
}
|
||||
@@ -190,10 +191,11 @@ func (r *knowledgeTagRepository) BatchCountReferences(
|
||||
// Count knowledge references in a single query
|
||||
var knowledgeCounts []tagCountResult
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&types.Knowledge{}).
|
||||
Select("tag_id, COUNT(*) as count").
|
||||
Where("tenant_id = ? AND knowledge_base_id = ? AND tag_id IN (?)", tenantID, kbID, tagIDs).
|
||||
Group("tag_id").
|
||||
Table("knowledge_tag_relations AS ktr").
|
||||
Select("ktr.tag_id, COUNT(*) as count").
|
||||
Joins("JOIN knowledges AS k ON ktr.knowledge_id = k.id AND k.deleted_at IS NULL").
|
||||
Where("ktr.tag_id IN (?)", tagIDs).
|
||||
Group("ktr.tag_id").
|
||||
Find(&knowledgeCounts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -228,7 +230,7 @@ func (r *knowledgeTagRepository) DeleteUnusedTags(ctx context.Context, tenantID
|
||||
// Delete tags that have no references in both knowledges and chunks tables (excluding soft-deleted records)
|
||||
result := r.db.WithContext(ctx).
|
||||
Where("tenant_id = ? AND knowledge_base_id = ?", tenantID, kbID).
|
||||
Where("id NOT IN (SELECT DISTINCT tag_id FROM knowledges WHERE tenant_id = ? AND knowledge_base_id = ? AND tag_id IS NOT NULL AND tag_id != '' AND deleted_at IS NULL)", tenantID, kbID).
|
||||
Where("id NOT IN (SELECT DISTINCT ktr.tag_id FROM knowledge_tag_relations ktr JOIN knowledges k ON ktr.knowledge_id = k.id AND k.deleted_at IS NULL)").
|
||||
Where("id NOT IN (SELECT DISTINCT tag_id FROM chunks WHERE tenant_id = ? AND knowledge_base_id = ? AND tag_id IS NOT NULL AND tag_id != '' AND deleted_at IS NULL)", tenantID, kbID).
|
||||
Delete(&types.KnowledgeTag{})
|
||||
return result.RowsAffected, result.Error
|
||||
|
||||
@@ -696,13 +696,13 @@ func (s *DataSourceService) ProcessSync(ctx context.Context, task *asynq.Task) e
|
||||
ctx = context.WithValue(ctx, types.TenantInfoContextKey, tenant)
|
||||
|
||||
// Auto-tag: find or create a tag for this data source so synced items are easily identifiable
|
||||
autoTagID := ""
|
||||
autoTagIDs := []string{}
|
||||
autoTagName := ds.Name
|
||||
if autoTag, tagErr := s.tagService.FindOrCreateTagByName(ctx, ds.KnowledgeBaseID, autoTagName); tagErr != nil {
|
||||
logger.Warnf(ctx, "failed to find/create auto-tag %q: %v (proceeding without tag)", autoTagName, tagErr)
|
||||
} else if autoTag != nil {
|
||||
autoTagID = autoTag.ID
|
||||
logger.Infof(ctx, "using auto-tag %q (id=%s) for data source sync", autoTagName, autoTagID)
|
||||
autoTagIDs = append(autoTagIDs, autoTag.ID)
|
||||
logger.Infof(ctx, "using auto-tag %q (id=%s) for data source sync", autoTagName, autoTag.ID)
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
@@ -729,7 +729,7 @@ func (s *DataSourceService) ProcessSync(ctx context.Context, task *asynq.Task) e
|
||||
continue
|
||||
}
|
||||
|
||||
isUpdate, err := s.ingestItem(ctx, ds, &item, autoTagID)
|
||||
isUpdate, err := s.ingestItem(ctx, ds, &item, autoTagIDs)
|
||||
if err != nil {
|
||||
// Duplicate file/URL is not a failure — count as skipped
|
||||
var dupErr *types.DuplicateKnowledgeError
|
||||
@@ -886,7 +886,7 @@ func (s *DataSourceService) validateDataSourceConfig(ctx context.Context, ds *ty
|
||||
// - Has URL only → CreateKnowledgeFromURL (让 WeKnora 下载并解析)
|
||||
//
|
||||
// Returns (isUpdate, error) — isUpdate is true when an existing item was replaced.
|
||||
func (s *DataSourceService) ingestItem(ctx context.Context, ds *types.DataSource, item *types.FetchedItem, tagID string) (bool, error) {
|
||||
func (s *DataSourceService) ingestItem(ctx context.Context, ds *types.DataSource, item *types.FetchedItem, tagIDs []string) (bool, error) {
|
||||
channel := ds.Type // e.g. "feishu", "notion"
|
||||
|
||||
metadata := map[string]string{
|
||||
@@ -929,7 +929,7 @@ func (s *DataSourceService) ingestItem(ctx context.Context, ds *types.DataSource
|
||||
metadata,
|
||||
nil, // use KB default for multimodal
|
||||
item.FileName, // customFileName — must include extension for file-type validation
|
||||
tagID, // auto-tag from data source
|
||||
tagIDs, // auto-tag from data source
|
||||
channel,
|
||||
nil,
|
||||
)
|
||||
@@ -946,7 +946,7 @@ func (s *DataSourceService) ingestItem(ctx context.Context, ds *types.DataSource
|
||||
"", // auto-detect file type
|
||||
nil, // use KB default for multimodal
|
||||
item.Title,
|
||||
tagID, // auto-tag from data source
|
||||
tagIDs, // auto-tag from data source
|
||||
channel,
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -463,6 +463,14 @@ func (s *knowledgeService) GetKnowledgeByID(ctx context.Context, id string) (*ty
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load tags for this knowledge
|
||||
tagMap, err := s.repo.GetKnowledgeTags(ctx, []string{knowledge.ID})
|
||||
if err != nil {
|
||||
logger.Warnf(ctx, "Failed to load tags for knowledge %s: %v", knowledge.ID, err)
|
||||
} else if tags, ok := tagMap[knowledge.ID]; ok {
|
||||
knowledge.Tags = tags
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "Knowledge retrieved successfully, ID: %s, type: %s", knowledge.ID, knowledge.Type)
|
||||
return knowledge, nil
|
||||
}
|
||||
@@ -512,6 +520,25 @@ func (s *knowledgeService) ListPagedKnowledgeByKnowledgeBaseID(ctx context.Conte
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Batch load tags for all knowledge entries
|
||||
if len(knowledges) > 0 {
|
||||
ids := make([]string, len(knowledges))
|
||||
for i, k := range knowledges {
|
||||
ids[i] = k.ID
|
||||
}
|
||||
tagMap, err := s.repo.GetKnowledgeTags(ctx, ids)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to load tags for knowledge list: %v", err)
|
||||
// Non-fatal: continue without tags
|
||||
} else {
|
||||
for _, k := range knowledges {
|
||||
if tags, ok := tagMap[k.ID]; ok {
|
||||
k.Tags = tags
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return types.NewPageResult(total, page, knowledges), nil
|
||||
}
|
||||
|
||||
@@ -629,34 +656,44 @@ func (s *knowledgeService) GetKnowledgeBatchWithSharedAccess(ctx context.Context
|
||||
return ownList, nil
|
||||
}
|
||||
|
||||
// UpdateKnowledgeTag updates the tag assigned to a knowledge document.
|
||||
func (s *knowledgeService) UpdateKnowledgeTag(ctx context.Context, knowledgeID string, tagID *string) error {
|
||||
// SetKnowledgeTags replaces all tags for a single knowledge entry.
|
||||
func (s *knowledgeService) SetKnowledgeTags(ctx context.Context, knowledgeID string, tagIDs []string) error {
|
||||
return s.repo.SetKnowledgeTags(ctx, knowledgeID, tagIDs)
|
||||
}
|
||||
|
||||
// GetKnowledgeTags returns tags for multiple knowledge IDs.
|
||||
func (s *knowledgeService) GetKnowledgeTags(ctx context.Context, knowledgeIDs []string) (map[string][]*types.KnowledgeTag, error) {
|
||||
return s.repo.GetKnowledgeTags(ctx, knowledgeIDs)
|
||||
}
|
||||
|
||||
// UpdateKnowledgeTag updates the tags assigned to a knowledge document.
|
||||
func (s *knowledgeService) UpdateKnowledgeTag(ctx context.Context, knowledgeID string, tagIDs []string) error {
|
||||
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
|
||||
knowledge, err := s.repo.GetKnowledgeByID(ctx, tenantID, knowledgeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var resolvedTagID string
|
||||
if tagID != nil && *tagID != "" {
|
||||
tag, err := s.tagRepo.GetByID(ctx, tenantID, *tagID)
|
||||
if err != nil {
|
||||
return err
|
||||
// Validate all tag IDs
|
||||
if len(tagIDs) > 0 {
|
||||
for _, tagID := range tagIDs {
|
||||
tag, err := s.tagRepo.GetByID(ctx, tenantID, tagID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.KnowledgeBaseID != knowledge.KnowledgeBaseID {
|
||||
return werrors.NewBadRequestError("标签不属于当前知识库")
|
||||
}
|
||||
}
|
||||
if tag.KnowledgeBaseID != knowledge.KnowledgeBaseID {
|
||||
return werrors.NewBadRequestError("标签不属于当前知识库")
|
||||
}
|
||||
resolvedTagID = tag.ID
|
||||
}
|
||||
|
||||
knowledge.TagID = resolvedTagID
|
||||
return s.repo.UpdateKnowledge(ctx, knowledge)
|
||||
return s.repo.SetKnowledgeTags(ctx, knowledgeID, tagIDs)
|
||||
}
|
||||
|
||||
// UpdateKnowledgeTagBatch updates tags for document knowledge items in batch.
|
||||
// authorizedKBID restricts all updates to knowledge items belonging to this KB;
|
||||
// pass empty string to skip the check (caller must ensure authorization by other means).
|
||||
func (s *knowledgeService) UpdateKnowledgeTagBatch(ctx context.Context, authorizedKBID string, updates map[string]*string) error {
|
||||
func (s *knowledgeService) UpdateKnowledgeTagBatch(ctx context.Context, authorizedKBID string, updates map[string][]string) error {
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -692,22 +729,24 @@ func (s *knowledgeService) UpdateKnowledgeTagBatch(ctx context.Context, authoriz
|
||||
}
|
||||
}
|
||||
|
||||
// Build tag ID map for validation
|
||||
// Collect all unique tag IDs for validation
|
||||
tagIDSet := make(map[string]bool)
|
||||
for _, tagID := range updates {
|
||||
if tagID != nil && *tagID != "" {
|
||||
tagIDSet[*tagID] = true
|
||||
for _, tagIDs := range updates {
|
||||
for _, tagID := range tagIDs {
|
||||
if tagID != "" {
|
||||
tagIDSet[tagID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate all tags in batch
|
||||
// Validate all tags exist and belong to the correct KB
|
||||
tagMap := make(map[string]*types.KnowledgeTag)
|
||||
if len(tagIDSet) > 0 {
|
||||
tagIDs := make([]string, 0, len(tagIDSet))
|
||||
tagIDList := make([]string, 0, len(tagIDSet))
|
||||
for tagID := range tagIDSet {
|
||||
tagIDs = append(tagIDs, tagID)
|
||||
tagIDList = append(tagIDList, tagID)
|
||||
}
|
||||
for _, tagID := range tagIDs {
|
||||
for _, tagID := range tagIDList {
|
||||
tag, err := s.tagRepo.GetByID(ctx, tenantID, tagID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -716,32 +755,31 @@ func (s *knowledgeService) UpdateKnowledgeTagBatch(ctx context.Context, authoriz
|
||||
}
|
||||
}
|
||||
|
||||
// Update knowledge items
|
||||
knowledgeToUpdate := make([]*types.Knowledge, 0)
|
||||
// Validate tag ownership per knowledge
|
||||
for _, knowledge := range knowledgeList {
|
||||
tagID, exists := updates[knowledge.ID]
|
||||
tagIDs, exists := updates[knowledge.ID]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
var resolvedTagID string
|
||||
if tagID != nil && *tagID != "" {
|
||||
tag, ok := tagMap[*tagID]
|
||||
for _, tagID := range tagIDs {
|
||||
if tagID == "" {
|
||||
continue
|
||||
}
|
||||
tag, ok := tagMap[tagID]
|
||||
if !ok {
|
||||
return werrors.NewBadRequestError(fmt.Sprintf("标签 %s 不存在", *tagID))
|
||||
return werrors.NewBadRequestError(fmt.Sprintf("标签 %s 不存在", tagID))
|
||||
}
|
||||
if tag.KnowledgeBaseID != knowledge.KnowledgeBaseID {
|
||||
return werrors.NewBadRequestError(fmt.Sprintf("标签 %s 不属于知识库 %s", *tagID, knowledge.KnowledgeBaseID))
|
||||
return werrors.NewBadRequestError(fmt.Sprintf("标签 %s 不属于知识库 %s", tagID, knowledge.KnowledgeBaseID))
|
||||
}
|
||||
resolvedTagID = tag.ID
|
||||
}
|
||||
|
||||
knowledge.TagID = resolvedTagID
|
||||
knowledgeToUpdate = append(knowledgeToUpdate, knowledge)
|
||||
}
|
||||
|
||||
if len(knowledgeToUpdate) > 0 {
|
||||
return s.repo.UpdateKnowledgeBatch(ctx, knowledgeToUpdate)
|
||||
// Set tags for each knowledge
|
||||
for knowledgeID, tagIDs := range updates {
|
||||
if err := s.repo.SetKnowledgeTags(ctx, knowledgeID, tagIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1100,7 +1100,6 @@ func (s *knowledgeService) moveKnowledgeReuseVectors(
|
||||
|
||||
// 4. Update knowledge record
|
||||
knowledge.KnowledgeBaseID = targetKB.ID
|
||||
knowledge.TagID = "" // Clear tag since tags are KB-scoped
|
||||
knowledge.ParseStatus = types.ParseStatusCompleted
|
||||
knowledge.UpdatedAt = time.Now()
|
||||
if err := s.repo.UpdateKnowledge(ctx, knowledge); err != nil {
|
||||
@@ -1127,7 +1126,6 @@ func (s *knowledgeService) moveKnowledgeReparse(
|
||||
// 2. Update knowledge to belong to target KB
|
||||
knowledge.KnowledgeBaseID = targetKB.ID
|
||||
knowledge.EmbeddingModelID = targetKB.EmbeddingModelID
|
||||
knowledge.TagID = "" // Clear tag since tags are KB-scoped
|
||||
knowledge.ParseStatus = types.ParseStatusPending
|
||||
knowledge.EnableStatus = "disabled"
|
||||
knowledge.Description = ""
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
|
||||
// CreateKnowledgeFromFile creates a knowledge entry from an uploaded file
|
||||
func (s *knowledgeService) CreateKnowledgeFromFile(ctx context.Context,
|
||||
kbID string, file *multipart.FileHeader, metadata map[string]string, enableMultimodel *bool, customFileName string, tagID string, channel string,
|
||||
kbID string, file *multipart.FileHeader, metadata map[string]string, enableMultimodel *bool, customFileName string, tagIDs []string, channel string,
|
||||
processOverrides *types.KnowledgeProcessOverrides,
|
||||
) (*types.Knowledge, error) {
|
||||
logger.Info(ctx, "Start creating knowledge from file")
|
||||
@@ -188,7 +188,6 @@ func (s *knowledgeService) CreateKnowledgeFromFile(ctx context.Context,
|
||||
ID: uuid.New().String(),
|
||||
TenantID: tenantID,
|
||||
KnowledgeBaseID: kbID,
|
||||
TagID: tagID, // 设置分类ID,用于知识分类管理
|
||||
Type: "file",
|
||||
Channel: defaultChannel(channel),
|
||||
Title: safeFilename,
|
||||
@@ -231,6 +230,14 @@ func (s *knowledgeService) CreateKnowledgeFromFile(ctx context.Context,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set tag relations
|
||||
if len(tagIDs) > 0 {
|
||||
if err := s.repo.SetKnowledgeTags(ctx, knowledge.ID, tagIDs); err != nil {
|
||||
logger.Errorf(ctx, "Failed to set knowledge tags, knowledge ID: %s, error: %v", knowledge.ID, err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue document processing task to Asynq
|
||||
logger.Info(ctx, "Enqueuing document processing task to Asynq")
|
||||
enableMultimodelValue := eff.EnableMultimodel
|
||||
@@ -307,7 +314,7 @@ func isFileURL(rawURL, fileName, fileType string) bool {
|
||||
}
|
||||
|
||||
func (s *knowledgeService) CreateKnowledgeFromURL(ctx context.Context,
|
||||
kbID string, rawURL string, fileName string, fileType string, enableMultimodel *bool, title string, tagID string, channel string,
|
||||
kbID string, rawURL string, fileName string, fileType string, enableMultimodel *bool, title string, tagIDs []string, channel string,
|
||||
processOverrides *types.KnowledgeProcessOverrides,
|
||||
) (*types.Knowledge, error) {
|
||||
logger.Info(ctx, "Start creating knowledge from URL")
|
||||
@@ -316,7 +323,7 @@ func (s *knowledgeService) CreateKnowledgeFromURL(ctx context.Context,
|
||||
// Route to file_url logic when the URL points to a downloadable file
|
||||
if isFileURL(rawURL, fileName, fileType) {
|
||||
return s.createKnowledgeFromFileURL(
|
||||
ctx, kbID, rawURL, fileName, fileType, enableMultimodel, title, tagID, channel, processOverrides,
|
||||
ctx, kbID, rawURL, fileName, fileType, enableMultimodel, title, tagIDs, channel, processOverrides,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -396,7 +403,6 @@ func (s *knowledgeService) CreateKnowledgeFromURL(ctx context.Context,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
EmbeddingModelID: kb.EmbeddingModelID,
|
||||
TagID: tagID, // 设置分类ID,用于知识分类管理
|
||||
}
|
||||
|
||||
// Save knowledge record
|
||||
@@ -411,6 +417,14 @@ func (s *knowledgeService) CreateKnowledgeFromURL(ctx context.Context,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set tag relations
|
||||
if len(tagIDs) > 0 {
|
||||
if err := s.repo.SetKnowledgeTags(ctx, knowledge.ID, tagIDs); err != nil {
|
||||
logger.Errorf(ctx, "Failed to set knowledge tags, knowledge ID: %s, error: %v", knowledge.ID, err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue URL processing task to Asynq
|
||||
logger.Info(ctx, "Enqueuing URL processing task to Asynq")
|
||||
enableMultimodelValue := eff.EnableMultimodel
|
||||
@@ -512,7 +526,7 @@ func (s *knowledgeService) createKnowledgeFromFileURL(
|
||||
fileType string,
|
||||
enableMultimodel *bool,
|
||||
title string,
|
||||
tagID string,
|
||||
tagIDs []string,
|
||||
channel string,
|
||||
processOverrides *types.KnowledgeProcessOverrides,
|
||||
) (*types.Knowledge, error) {
|
||||
@@ -618,7 +632,6 @@ func (s *knowledgeService) createKnowledgeFromFileURL(
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
EmbeddingModelID: kb.EmbeddingModelID,
|
||||
TagID: tagID,
|
||||
}
|
||||
if knowledge.Title == "" {
|
||||
knowledge.Title = displayName
|
||||
@@ -644,6 +657,14 @@ func (s *knowledgeService) createKnowledgeFromFileURL(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set tag relations
|
||||
if len(tagIDs) > 0 {
|
||||
if err := s.repo.SetKnowledgeTags(ctx, knowledge.ID, tagIDs); err != nil {
|
||||
logger.Errorf(ctx, "Failed to set knowledge tags, knowledge ID: %s, error: %v", knowledge.ID, err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Build async task payload
|
||||
enableMultimodelValue := eff.EnableMultimodel
|
||||
enableQuestionGeneration := eff.QuestionGenerationConfig.Enabled
|
||||
@@ -769,7 +790,6 @@ func (s *knowledgeService) CreateKnowledgeFromManual(ctx context.Context,
|
||||
EmbeddingModelID: kb.EmbeddingModelID,
|
||||
FileName: fileName,
|
||||
FileType: types.KnowledgeTypeManual,
|
||||
TagID: payload.TagID, // 设置分类ID,用于知识分类管理
|
||||
}
|
||||
if err := knowledge.SetManualMetadata(meta); err != nil {
|
||||
logger.Errorf(ctx, "Failed to set manual metadata: %v", err)
|
||||
@@ -792,6 +812,14 @@ func (s *knowledgeService) CreateKnowledgeFromManual(ctx context.Context,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set tag relations
|
||||
if len(payload.TagIDs) > 0 {
|
||||
if err := s.repo.SetKnowledgeTags(ctx, knowledge.ID, payload.TagIDs); err != nil {
|
||||
logger.Errorf(ctx, "Failed to set knowledge tags, knowledge ID: %s, error: %v", knowledge.ID, err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if status == types.ManualKnowledgeStatusPublish {
|
||||
logger.Infof(ctx, "Manual knowledge created, enqueuing async processing task, ID: %s", knowledge.ID)
|
||||
if err := s.enqueueManualProcessing(ctx, knowledge, cleanContent, false); err != nil {
|
||||
|
||||
@@ -274,7 +274,7 @@ func (s *knowledgeTagService) DeleteTag(ctx context.Context, id string, force bo
|
||||
return nil
|
||||
}
|
||||
// Get all knowledge IDs under this tag
|
||||
knowledgeIDs, err := s.knowledgeRepo.ListIDsByTagID(ctx, tenantID, kb.ID, tag.ID)
|
||||
knowledgeIDs, err := s.knowledgeRepo.ListIDsByTagIDs(ctx, tenantID, kb.ID, []string{tag.ID})
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to list knowledge IDs by tag ID %s: %v", tag.ID, err)
|
||||
return werrors.NewInternalServerError("获取标签下的文档失败")
|
||||
|
||||
@@ -360,17 +360,13 @@ func (h *KnowledgeHandler) CreateKnowledgeFromFile(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分类ID(如果提供),用于知识分类管理
|
||||
tagID := c.PostForm("tag_id")
|
||||
// 过滤特殊值,空字符串或 "__untagged__" 表示未分类
|
||||
if tagID == "__untagged__" || tagID == "" {
|
||||
tagID = ""
|
||||
}
|
||||
// 获取分类ID列表(如果提供),逗号分隔,用于知识多标签分类管理
|
||||
tagIDs := parseCommaSeparatedTagIDs(c.PostForm("tag_ids"))
|
||||
|
||||
channel := c.PostForm("channel")
|
||||
|
||||
// Create knowledge entry from the file
|
||||
knowledge, err := h.kgService.CreateKnowledgeFromFile(ctx, kbID, file, metadata, enableMultimodel, customFileName, tagID, channel, processOverrides)
|
||||
knowledge, err := h.kgService.CreateKnowledgeFromFile(ctx, kbID, file, metadata, enableMultimodel, customFileName, tagIDs, channel, processOverrides)
|
||||
// Check for duplicate knowledge error
|
||||
if err != nil {
|
||||
if h.handleDuplicateKnowledgeError(c, err, knowledge, "file") {
|
||||
@@ -436,7 +432,7 @@ func (h *KnowledgeHandler) CreateKnowledgeFromURL(c *gin.Context) {
|
||||
FileType string `json:"file_type"`
|
||||
EnableMultimodel *bool `json:"enable_multimodel"`
|
||||
Title string `json:"title"`
|
||||
TagID string `json:"tag_id"`
|
||||
TagIDs []string `json:"tag_ids"`
|
||||
Channel string `json:"channel"`
|
||||
ProcessConfig *types.KnowledgeProcessOverrides `json:"process_config"`
|
||||
}
|
||||
@@ -467,7 +463,7 @@ func (h *KnowledgeHandler) CreateKnowledgeFromURL(c *gin.Context) {
|
||||
|
||||
// Create knowledge entry from the URL
|
||||
knowledge, err := h.kgService.CreateKnowledgeFromURL(
|
||||
ctx, kbID, req.URL, req.FileName, req.FileType, req.EnableMultimodel, req.Title, req.TagID, req.Channel, req.ProcessConfig,
|
||||
ctx, kbID, req.URL, req.FileName, req.FileType, req.EnableMultimodel, req.Title, req.TagIDs, req.Channel, req.ProcessConfig,
|
||||
)
|
||||
// Check for duplicate knowledge error
|
||||
if err != nil {
|
||||
@@ -861,7 +857,7 @@ func (h *KnowledgeHandler) ListKnowledge(c *gin.Context) {
|
||||
}
|
||||
|
||||
filter := types.KnowledgeListFilter{
|
||||
TagID: c.Query("tag_id"),
|
||||
TagIDs: parseCommaSeparatedTagIDs(c.Query("tag_ids")),
|
||||
Keyword: c.Query("keyword"),
|
||||
FileType: c.Query("file_type"),
|
||||
ParseStatus: c.Query("parse_status"),
|
||||
@@ -886,9 +882,9 @@ func (h *KnowledgeHandler) ListKnowledge(c *gin.Context) {
|
||||
|
||||
logger.Infof(
|
||||
ctx,
|
||||
"Retrieving knowledge list under knowledge base, kb_id=%s tag_id=%s keyword=%s file_type=%s parse_status=%s source=%s start_time=%s end_time=%s page=%d page_size=%d effectiveTenantID=%d",
|
||||
"Retrieving knowledge list under knowledge base, kb_id=%s tag_ids=%s keyword=%s file_type=%s parse_status=%s source=%s start_time=%s end_time=%s page=%d page_size=%d effectiveTenantID=%d",
|
||||
secutils.SanitizeForLog(kbID),
|
||||
secutils.SanitizeForLog(filter.TagID),
|
||||
secutils.SanitizeForLog(strings.Join(filter.TagIDs, ",")),
|
||||
secutils.SanitizeForLog(filter.Keyword),
|
||||
secutils.SanitizeForLog(filter.FileType),
|
||||
secutils.SanitizeForLog(filter.ParseStatus),
|
||||
@@ -1697,8 +1693,8 @@ func (h *KnowledgeHandler) CancelKnowledgeParse(c *gin.Context) {
|
||||
}
|
||||
|
||||
type knowledgeTagBatchRequest struct {
|
||||
Updates map[string]*string `json:"updates" binding:"required,min=1"`
|
||||
KBID string `json:"kb_id"` // Optional: scope to this KB (validates editor access and uses effective tenant for shared KB)
|
||||
Updates map[string][]string `json:"updates" binding:"required,min=1"`
|
||||
KBID string `json:"kb_id"` // Optional: scope to this KB (validates editor access and uses effective tenant for shared KB)
|
||||
}
|
||||
|
||||
// UpdateKnowledgeTagBatch godoc
|
||||
@@ -2224,6 +2220,24 @@ func resolveAgentAllowedKBIDs(agent *types.CustomAgent) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// parseCommaSeparatedTagIDs splits a comma-separated string of tag IDs and
|
||||
// filters out empty strings and the "__untagged__" sentinel value.
|
||||
func parseCommaSeparatedTagIDs(raw string) []string {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || p == "__untagged__" {
|
||||
continue
|
||||
}
|
||||
result = append(result, p)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// parseFilterTime parses a query-string timestamp accepted by knowledge list
|
||||
// filters. It supports RFC3339, RFC3339 with milliseconds, and the date-only
|
||||
// "2006-01-02" form (interpreted at start of day in the local timezone).
|
||||
|
||||
@@ -2717,7 +2717,7 @@ func (s *Service) processFileToKnowledgeBase(ctx context.Context, msg *IncomingM
|
||||
fh := newInMemoryFileHeader(fileName, content)
|
||||
|
||||
// Create knowledge entry via the knowledge service
|
||||
knowledge, err := s.knowledgeService.CreateKnowledgeFromFile(kbCtx, kbID, fh, nil, nil, "", "", imPlatformToChannel(channel.Platform), nil)
|
||||
knowledge, err := s.knowledgeService.CreateKnowledgeFromFile(kbCtx, kbID, fh, nil, nil, "", nil, imPlatformToChannel(channel.Platform), nil)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
// Check for duplicate file
|
||||
|
||||
@@ -20,7 +20,7 @@ type KnowledgeService interface {
|
||||
metadata map[string]string,
|
||||
enableMultimodel *bool,
|
||||
customFileName string,
|
||||
tagID string,
|
||||
tagIDs []string,
|
||||
channel string,
|
||||
processOverrides *types.KnowledgeProcessOverrides,
|
||||
) (*types.Knowledge, error)
|
||||
@@ -36,7 +36,7 @@ type KnowledgeService interface {
|
||||
fileType string,
|
||||
enableMultimodel *bool,
|
||||
title string,
|
||||
tagID string,
|
||||
tagIDs []string,
|
||||
channel string,
|
||||
processOverrides *types.KnowledgeProcessOverrides,
|
||||
) (*types.Knowledge, error)
|
||||
@@ -152,7 +152,11 @@ type KnowledgeService interface {
|
||||
// UpdateKnowledgeTagBatch updates tag for document knowledge items in batch.
|
||||
// authorizedKBID restricts all updates to knowledge items belonging to this KB;
|
||||
// pass empty string to skip (caller must ensure authorization by other means).
|
||||
UpdateKnowledgeTagBatch(ctx context.Context, authorizedKBID string, updates map[string]*string) error
|
||||
UpdateKnowledgeTagBatch(ctx context.Context, authorizedKBID string, updates map[string][]string) error
|
||||
// SetKnowledgeTags replaces all tags for a single knowledge entry.
|
||||
SetKnowledgeTags(ctx context.Context, knowledgeID string, tagIDs []string) error
|
||||
// GetKnowledgeTags returns tags for multiple knowledge IDs.
|
||||
GetKnowledgeTags(ctx context.Context, knowledgeIDs []string) (map[string][]*types.KnowledgeTag, error)
|
||||
// UpdateFAQEntryTagBatch updates tag for FAQ entries in batch.
|
||||
// Key: entry seq_id, Value: tag seq_id (nil to remove tag)
|
||||
UpdateFAQEntryTagBatch(ctx context.Context, kbID string, updates map[int64]*int64) error
|
||||
@@ -259,6 +263,12 @@ type KnowledgeRepository interface {
|
||||
FindByMetadataKey(ctx context.Context, tenantID uint64, kbID string, key string, value string) (*types.Knowledge, error)
|
||||
// SearchKnowledgeInScopes searches knowledge items by keyword within the given (tenant_id, kb_id) scopes (own + shared).
|
||||
SearchKnowledgeInScopes(ctx context.Context, scopes []types.KnowledgeSearchScope, keyword string, offset, limit int, fileTypes []string) ([]*types.Knowledge, bool, error)
|
||||
// ListIDsByTagID returns all knowledge IDs that have the specified tag ID.
|
||||
ListIDsByTagID(ctx context.Context, tenantID uint64, kbID, tagID string) ([]string, error)
|
||||
// ListIDsByTagIDs returns all knowledge IDs that have any of the specified tag IDs (OR semantics).
|
||||
ListIDsByTagIDs(ctx context.Context, tenantID uint64, kbID string, tagIDs []string) ([]string, error)
|
||||
// SetKnowledgeTags replaces all tags for a single knowledge entry (deletes old, inserts new).
|
||||
SetKnowledgeTags(ctx context.Context, knowledgeID string, tagIDs []string) error
|
||||
// GetKnowledgeTags returns tags for multiple knowledge IDs.
|
||||
GetKnowledgeTags(ctx context.Context, knowledgeIDs []string) (map[string][]*types.KnowledgeTag, error)
|
||||
// DeleteKnowledgeTagRelations deletes all tag relations for a knowledge entry.
|
||||
DeleteKnowledgeTagRelations(ctx context.Context, knowledgeID string) error
|
||||
}
|
||||
|
||||
@@ -88,8 +88,8 @@ const (
|
||||
// KnowledgeListFilter aggregates optional filters for listing knowledge entries
|
||||
// under a knowledge base. Empty / zero fields mean "no filter on that dimension".
|
||||
type KnowledgeListFilter struct {
|
||||
// TagID filters by tag_id when non-empty.
|
||||
TagID string
|
||||
// TagIDs filters by multiple tags (OR semantics: match any of the given tags).
|
||||
TagIDs []string
|
||||
// Keyword performs a LIKE match on file_name / title when non-empty.
|
||||
Keyword string
|
||||
// FileType filters by file_type, or by type for the special values "manual" / "url".
|
||||
@@ -116,8 +116,8 @@ type Knowledge struct {
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
// ID of the knowledge base
|
||||
KnowledgeBaseID string `json:"knowledge_base_id"`
|
||||
// Optional tag ID for categorization within a knowledge base
|
||||
TagID string `json:"tag_id" gorm:"type:varchar(36);index"`
|
||||
// Tags holds the tags associated with this knowledge (populated on query, not persisted directly).
|
||||
Tags []*KnowledgeTag `json:"tags" gorm:"-"`
|
||||
// Type of the knowledge
|
||||
Type string `json:"type"`
|
||||
// Title of the knowledge
|
||||
@@ -208,7 +208,7 @@ type ManualKnowledgePayload struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
TagID string `json:"tag_id"`
|
||||
TagIDs []string `json:"tag_ids"`
|
||||
Channel string `json:"channel"`
|
||||
ProcessConfig *KnowledgeProcessOverrides `json:"process_config,omitempty"`
|
||||
}
|
||||
|
||||
@@ -64,3 +64,16 @@ type TagReferenceCounts struct {
|
||||
KnowledgeCount int64
|
||||
ChunkCount int64
|
||||
}
|
||||
|
||||
// KnowledgeTagRelation represents a many-to-many association between
|
||||
// a document knowledge entry and a tag in the knowledge_tag_relations table.
|
||||
type KnowledgeTagRelation struct {
|
||||
KnowledgeID string `gorm:"type:varchar(36);primaryKey"`
|
||||
TagID string `gorm:"type:varchar(36);primaryKey"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
}
|
||||
|
||||
// TableName overrides the default table name.
|
||||
func (KnowledgeTagRelation) TableName() string {
|
||||
return "knowledge_tag_relations"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Migration: 000063_knowledge_multi_tags (rollback)
|
||||
-- Description: Restore single tag_id column and drop the join table.
|
||||
DO $$ BEGIN RAISE NOTICE '[Migration 000063 rollback] Restoring knowledges.tag_id from knowledge_tag_relations...'; END $$;
|
||||
|
||||
-- Restore the tag_id column
|
||||
ALTER TABLE knowledges ADD COLUMN IF NOT EXISTS tag_id VARCHAR(36);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledges_tag ON knowledges(tag_id);
|
||||
|
||||
-- Restore the first tag (by created_at) from the join table back to knowledges
|
||||
UPDATE knowledges k
|
||||
SET tag_id = (
|
||||
SELECT ktr.tag_id
|
||||
FROM knowledge_tag_relations ktr
|
||||
WHERE ktr.knowledge_id = k.id
|
||||
ORDER BY ktr.created_at ASC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM knowledge_tag_relations ktr WHERE ktr.knowledge_id = k.id
|
||||
);
|
||||
|
||||
-- Drop the join table
|
||||
DROP TABLE IF EXISTS knowledge_tag_relations;
|
||||
|
||||
DO $$ BEGIN RAISE NOTICE '[Migration 000063 rollback] knowledges.tag_id restored'; END $$;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Migration: 000063_knowledge_multi_tags
|
||||
-- Description: Add multi-tag support for document knowledge via a join table.
|
||||
DO $$ BEGIN RAISE NOTICE '[Migration 000063] Creating knowledge_tag_relations and migrating data...'; END $$;
|
||||
|
||||
-- Create the many-to-many join table
|
||||
CREATE TABLE IF NOT EXISTS knowledge_tag_relations (
|
||||
knowledge_id VARCHAR(36) NOT NULL,
|
||||
tag_id VARCHAR(36) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
PRIMARY KEY (knowledge_id, tag_id)
|
||||
);
|
||||
|
||||
-- Index: find all tag IDs for a given knowledge
|
||||
CREATE INDEX IF NOT EXISTS idx_ktr_knowledge
|
||||
ON knowledge_tag_relations(knowledge_id);
|
||||
|
||||
-- Index: find all knowledge IDs for a given tag
|
||||
CREATE INDEX IF NOT EXISTS idx_ktr_tag
|
||||
ON knowledge_tag_relations(tag_id);
|
||||
|
||||
-- Migrate existing single-tag data into the join table
|
||||
INSERT INTO knowledge_tag_relations (knowledge_id, tag_id, created_at)
|
||||
SELECT id, tag_id, updated_at
|
||||
FROM knowledges
|
||||
WHERE tag_id IS NOT NULL AND tag_id != '';
|
||||
|
||||
-- Drop the old index and column
|
||||
DROP INDEX IF EXISTS idx_knowledges_tag;
|
||||
ALTER TABLE knowledges DROP COLUMN IF EXISTS tag_id;
|
||||
|
||||
DO $$ BEGIN RAISE NOTICE '[Migration 000063] knowledge_tag_relations ready'; END $$;
|
||||
Reference in New Issue
Block a user