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:
Anky
2026-06-26 00:18:48 +08:00
committed by GitHub
parent 5c0e97c12e
commit c007a897cf
27 changed files with 1176 additions and 190 deletions
+12 -10
View File
@@ -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
+4 -4
View File
@@ -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'));
+5
View File
@@ -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',
+5
View File
@@ -372,6 +372,11 @@ export default {
untagged: "미분류",
tagClearAction: "분류 해제",
tagUpdateSuccess: "태그 업데이트 성공",
tagEditDialogTitle: '태그 편집 — {name}',
tagEditSearch: '태그 검색...',
tagSelectedCount: '{count}개 선택됨',
tagOverflowTip: '클릭하여 태그 편집',
tagNewPlaceholder: '새 태그 이름 입력, Enter로 추가',
tagSearchTooltip: "태그 검색",
category: "분류",
tagCreateAction: "태그 생성",
+5
View File
@@ -313,6 +313,11 @@ export default {
tagClearAction: 'Снять категорию',
tagSearchTooltip: 'Поиск тегов',
tagUpdateSuccess: 'Тег успешно обновлен',
tagEditDialogTitle: 'Редактировать теги — {name}',
tagEditSearch: 'Поиск тегов...',
tagSelectedCount: 'Выбрано: {count}',
tagOverflowTip: 'Нажмите, чтобы изменить теги',
tagNewPlaceholder: 'Название нового тега, Enter для добавления',
category: 'Категория',
tagCreateAction: 'Создать тег',
tagSearchPlaceholder: 'Введите название тега',
+5
View File
@@ -373,6 +373,11 @@ export default {
untagged: "未分类",
tagClearAction: "取消分类",
tagUpdateSuccess: "标签更新成功",
tagEditDialogTitle: '编辑标签 — {name}',
tagEditSearch: "搜索标签...",
tagSelectedCount: "已选 {count} 个标签",
tagOverflowTip: "点击编辑标签",
tagNewPlaceholder: "输入新标签名称,回车添加",
tagSearchTooltip: "搜索标签",
category: "分类",
tagCreateAction: "新建标签",
+12 -3
View File
@@ -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() {
+1 -1
View File
@@ -8,7 +8,7 @@ export interface UploadConfirmManualSource {
knowledgeId?: string
title: string
content: string
tagId?: string
tagIds?: string[]
}
export interface UploadConfirmReparseSource {
+269 -67
View File
@@ -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>
+17 -7
View File
@@ -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
}
+9 -7
View File
@@ -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,
)
+75 -37
View File
@@ -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 {
+1 -1
View File
@@ -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("获取标签下的文档失败")
+28 -14
View File
@@ -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).
+1 -1
View File
@@ -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
+15 -5
View 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
}
+5 -5
View File
@@ -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"`
}
+13
View File
@@ -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 $$;