diff --git a/frontend/src/api/knowledge-base/index.ts b/frontend/src/api/knowledge-base/index.ts index 323d87c9a8..b889119af6 100644 --- a/frontend/src/api/knowledge-base/index.ts +++ b/frontend/src/api/knowledge-base/index.ts @@ -324,11 +324,17 @@ export function listKnowledgeFolders(kbId: string) { * are derived from the stored paths, so a path that does not exist yet is * created by this call. Only the grouping changes; documents are not re-parsed. */ -export function moveKnowledgeToFolder(kbId: string, ids: string[], folderPath: string) { +export function moveKnowledgeToFolder( + kbId: string, + ids: string[], + folderPath: string, + selection?: KnowledgeBatchSelectionPayload, +) { return post('/api/v1/knowledge/folder', { kb_id: kbId, - knowledge_ids: ids, + knowledge_ids: selection?.select_all ? undefined : ids, folder_path: folderPath, + ...selection, }); } @@ -357,7 +363,25 @@ export function reparseKnowledge(id: string, data?: { process_config?: Knowledge } export function cancelKnowledgeParse(id: string) { - return post(`/api/v1/knowledge/${id}/cancel-parse`); + // Cancel may scan large asynq queues; keep above the default 30s axios timeout. + return post(`/api/v1/knowledge/${id}/cancel-parse`, {}, { timeout: 120000 }); +} + +/** Batch cancel-parse. Supports explicit ids or select_all + filter. */ +export function batchCancelKnowledgeParse( + kbId: string, + ids: string[], + selection?: KnowledgeBatchSelectionPayload, +) { + return post( + `/api/v1/knowledge/batch-cancel-parse`, + { + kb_id: kbId, + ids: selection?.select_all ? undefined : ids, + ...selection, + }, + { timeout: 120000 }, + ); } export function getKnowledgeSpans(id: string, attempt?: number) { @@ -369,9 +393,51 @@ export function delKnowledgeDetails(id: string) { return del(`/api/v1/knowledge/${id}`); } +/** Shared select_all payload for batch knowledge mutations. */ +export type KnowledgeBatchFilterPayload = { + tag_ids?: string[]; + keyword?: string; + file_type?: string; + parse_status?: string; + source?: string; + start_time?: string; + end_time?: string; + folder_path?: string; + folder_recursive?: boolean; +}; + +export type KnowledgeBatchSelectionPayload = { + select_all?: boolean; + exclude_ids?: string[]; + filter?: KnowledgeBatchFilterPayload; +}; + // 批量删除(同一知识库内)。后端会校验所有 id 隶属于 kb_id 且具有编辑权限。 -export function batchDeleteKnowledge(kbId: string, ids: string[]) { - return post(`/api/v1/knowledge/batch-delete`, { kb_id: kbId, ids }); +export function batchDeleteKnowledge( + kbId: string, + ids: string[], + selection?: KnowledgeBatchSelectionPayload, +) { + return post(`/api/v1/knowledge/batch-delete`, { + kb_id: kbId, + ids: selection?.select_all ? undefined : ids, + ...selection, + }); +} + +// 批量重建(同一知识库内)。后端会校验归属与权限,跳过解析中/删除中的条目。 +export function batchReparseKnowledge( + kbId: string, + ids: string[], + processConfig?: KnowledgeProcessOverrides, + selection?: KnowledgeBatchSelectionPayload, +) { + return post(`/api/v1/knowledge/batch-reparse`, { + kb_id: kbId, + ids: selection?.select_all ? undefined : ids, + process_config: processConfig, + ...selection, + }); } export function downKnowledgeDetails(id: string) { @@ -475,7 +541,14 @@ export function deleteKnowledgeBaseTag(kbId: string, tagSeqId: number, params?: return del(`/api/v1/knowledge-bases/${kbId}/tags/${tagSeqId}${forceQuery}`); } -export function updateKnowledgeTagBatch(data: { updates: Record }) { +export function updateKnowledgeTagBatch(data: { + updates?: Record; + kb_id?: string; + tag_ids?: string[]; + select_all?: boolean; + exclude_ids?: string[]; + filter?: KnowledgeBatchFilterPayload; +}) { return put(`/api/v1/knowledge/tags`, data); } @@ -627,11 +700,3 @@ export function knowledgeSemanticSearch(data: { }) { return post('/api/v1/knowledge-search', data); } - -export function batchReparseKnowledge(kbId: string, ids: string[], processConfig?: KnowledgeProcessOverrides) { - return post(`/api/v1/knowledge/batch-reparse`, { - kb_id: kbId, - ids, - process_config: processConfig, - }); -} diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 8d740048b9..0212528c55 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -398,6 +398,7 @@ export default { newFolderHintRoot: 'Will be created under root', newFolderHintUnder: 'Will be created under “{folder}”', success: 'Moved {count} documents', + partial: 'Moved {succeeded} documents, {failed} failed', failed: 'Could not move the documents', duplicate: 'That folder already exists', }, @@ -594,6 +595,7 @@ export default { confirmBatchDeleteDocument: 'Delete {count} selected documents? This action cannot be undone.', batchDeleteSuccess: 'Deleted {count} documents', batchDeleteFailed: 'Batch delete failed', + batchDeletePartial: 'Deleted {succeeded} documents, {failed} failed', batchTag: 'Batch Tag', batchTagDialogHeading: 'Batch Tag', batchTagSubtitle: 'Set tags for {count} selected documents (will replace existing tags)', @@ -606,7 +608,15 @@ export default { confirmBatchReparse: 'Confirm and reparse', batchReparseSuccess: 'Submitted {count} rebuild tasks', batchReparseFailed: 'Batch rebuild failed', + batchReparsePartial: 'Submitted {succeeded} rebuild tasks, {failed} failed', batchReparseSkippedInFlight: 'Skipped {count} document(s) still being parsed', + confirmBatchCancelParseDocument: 'Stop parsing for {count} selected documents? Existing parsed content will be kept and can be rebuilt later.', + batchCancelParseNoInFlight: 'No selected documents are currently being parsed', + batchCancelParseSkippedNotInFlight: 'Skipped {count} document(s) not in parsing state', + batchCancelParseSubmitting: 'Stopping parsing for {count} document(s), please wait…', + batchCancelParseSuccess: 'Stopped parsing for {count} document(s)', + batchCancelParsePartial: 'Stopped {succeeded} task(s), {failed} failed to stop', + batchCancelParseFailed: 'Batch stop parsing failed', statusCompleted: 'Completed', statusProcessing: 'Processing', statusFinalizing: 'Optimizing', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index dcb7047e48..7e0bed3b15 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -5549,6 +5549,7 @@ export default { newFolderHintRoot: '루트 아래에 생성됩니다', newFolderHintUnder: '「{folder}」 아래에 생성됩니다', success: '문서 {count}개를 이동했습니다', + partial: '문서 {succeeded}개를 이동했고, {failed}개 이동에 실패했습니다', failed: '문서를 이동할 수 없습니다', duplicate: '이미 존재하는 폴더입니다', }, @@ -5745,6 +5746,7 @@ export default { confirmBatchDeleteDocument: '선택한 {count}개 문서를 삭제하시겠습니까? 삭제 후 복구할 수 없습니다.', batchDeleteSuccess: '{count}개 문서가 삭제되었습니다', batchDeleteFailed: '일괄 삭제 실패', + batchDeletePartial: '{succeeded}개 문서 삭제됨, {failed}개 실패', batchTag: '일괄 태그', batchTagDialogHeading: '일괄 태그 지정', batchTagSubtitle: '선택한 {count}개 문서에 태그를 일괄 설정합니다 (기존 태그는 대체됩니다)', @@ -5757,7 +5759,15 @@ export default { confirmBatchReparse: '확인 후 재파싱', batchReparseSuccess: '재구축 작업 {count}개가 제출되었습니다', batchReparseFailed: '일괄 재구축 실패', + batchReparsePartial: '재구축 작업 {succeeded}개 제출됨, {failed}개 실패', batchReparseSkippedInFlight: '파싱 중인 문서 {count}개를 건너뛰었습니다', + confirmBatchCancelParseDocument: '선택한 {count}개 문서의 파싱을 중지하시겠습니까? 이미 저장된 내용은 유지되며 이후 재구축할 수 있습니다.', + batchCancelParseNoInFlight: '선택한 문서 중 파싱 중인 작업이 없습니다', + batchCancelParseSkippedNotInFlight: '파싱 중이 아닌 문서 {count}개를 건너뛰었습니다', + batchCancelParseSubmitting: '{count}개 문서의 파싱을 중지하는 중…', + batchCancelParseSuccess: '{count}개 문서의 파싱을 중지했습니다', + batchCancelParsePartial: '{succeeded}개 파싱 작업을 중지했고, {failed}개 중지에 실패했습니다', + batchCancelParseFailed: '일괄 파싱 중지 실패', statusCompleted: '완료', statusProcessing: '처리 중', statusFinalizing: '최적화 중', diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index 3947a9b28c..5a235877da 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -5549,6 +5549,7 @@ export default { newFolderHintRoot: 'Будет создана в корне', newFolderHintUnder: 'Будет создана внутри «{folder}»', success: 'Перемещено документов: {count}', + partial: 'Перемещено документов: {succeeded}, не удалось: {failed}', failed: 'Не удалось переместить документы', duplicate: 'Такая папка уже существует', }, @@ -5745,6 +5746,7 @@ export default { confirmBatchDeleteDocument: 'Удалить {count} выбранных документов? Это действие нельзя отменить.', batchDeleteSuccess: 'Удалено документов: {count}', batchDeleteFailed: 'Ошибка пакетного удаления', + batchDeletePartial: 'Удалено документов: {succeeded}, не удалось: {failed}', batchTag: 'Пакетная метка', batchTagDialogHeading: 'Пакетное назначение меток', batchTagSubtitle: 'Установить метки для {count} выбранных документов (заменит существующие метки)', @@ -5757,7 +5759,15 @@ export default { confirmBatchReparse: 'Подтвердить и обработать заново', batchReparseSuccess: 'Отправлено задач пересборки: {count}', batchReparseFailed: 'Ошибка пакетной пересборки', + batchReparsePartial: 'Отправлено задач пересборки: {succeeded}, не удалось: {failed}', batchReparseSkippedInFlight: 'Пропущено документов, которые ещё обрабатываются: {count}', + confirmBatchCancelParseDocument: 'Остановить разбор для {count} выбранных документов? Уже записанное содержимое сохранится и его можно будет пересобрать позже.', + batchCancelParseNoInFlight: 'Среди выбранных документов нет задач в процессе разбора', + batchCancelParseSkippedNotInFlight: 'Пропущено документов не в состоянии разбора: {count}', + batchCancelParseSubmitting: 'Остановка разбора для {count} документ(ов), подождите…', + batchCancelParseSuccess: 'Остановлен разбор для {count} документ(ов)', + batchCancelParsePartial: 'Остановлено задач: {succeeded}, не удалось остановить: {failed}', + batchCancelParseFailed: 'Ошибка пакетной остановки разбора', statusCompleted: 'Завершено', statusProcessing: 'Обработка', statusFinalizing: 'Оптимизация', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 97f24f3f1e..7c1dff7b50 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -5551,6 +5551,7 @@ export default { newFolderHintRoot: '将在根目录下创建', newFolderHintUnder: '将在「{folder}」下创建', success: '已移动 {count} 个文档', + partial: '已移动 {succeeded} 个文档,{failed} 个移动失败', failed: '移动失败', duplicate: '该目录已存在', }, @@ -5747,6 +5748,7 @@ export default { confirmBatchDeleteDocument: '确认删除选中的 {count} 个文档?删除后将无法恢复。', batchDeleteSuccess: '成功删除 {count} 个文档', batchDeleteFailed: '批量删除失败', + batchDeletePartial: '已删除 {succeeded} 个文档,{failed} 个删除失败', batchTag: '批量打标签', batchTagDialogHeading: '批量打标签', batchTagSubtitle: '为选中的 {count} 个文档统一设置标签(将替换文档原有标签)', @@ -5759,7 +5761,15 @@ export default { confirmBatchReparse: '确认并重新解析', batchReparseSuccess: '已提交 {count} 个重建任务', batchReparseFailed: '批量重建失败', + batchReparsePartial: '已提交 {succeeded} 个重建任务,{failed} 个提交失败', batchReparseSkippedInFlight: '已跳过 {count} 个正在解析中的文档', + confirmBatchCancelParseDocument: '确认停止解析选中的 {count} 个文档?已写入内容会保留,后续可重新重建。', + batchCancelParseNoInFlight: '选中文档中没有正在解析的任务', + batchCancelParseSkippedNotInFlight: '已跳过 {count} 个非解析中的文档', + batchCancelParseSubmitting: '正在停止 {count} 个文档的解析,请稍候…', + batchCancelParseSuccess: '已停止 {count} 个文档的解析任务', + batchCancelParsePartial: '已停止 {succeeded} 个解析任务,{failed} 个停止失败', + batchCancelParseFailed: '批量停止解析失败', statusCompleted: '已完成', statusProcessing: '解析中', statusFinalizing: '优化中', diff --git a/frontend/src/views/knowledge/KnowledgeBase.vue b/frontend/src/views/knowledge/KnowledgeBase.vue index 1c11013297..de8f9013ac 100644 --- a/frontend/src/views/knowledge/KnowledgeBase.vue +++ b/frontend/src/views/knowledge/KnowledgeBase.vue @@ -31,6 +31,7 @@ import { createKnowledgeFromURL, reparseKnowledge, cancelKnowledgeParse, + batchCancelKnowledgeParse, batchDeleteKnowledge, batchReparseKnowledge, getKnowledgeSpans, @@ -39,6 +40,8 @@ import { moveKnowledgeToFolder, renameKnowledgeFolder, downKnowledgeDetails, + type KnowledgeBatchFilterPayload, + type KnowledgeBatchSelectionPayload, type KnowledgeFolderTree, } from "@/api/knowledge-base/index"; import { knowledgeSpansPayloadHasTrace } from '@/utils/knowledgeTrace'; @@ -435,9 +438,23 @@ watch(viewMode, (v) => { // Multi-select state — shared between grid and list views. // Vue 3.5 tracks Set#add/delete natively, so direct mutation is reactive. const selectedIds = ref>(new Set()); +/** explicit = user-picked IDs; all_matching = select_all by current list filter. */ +type SelectionMode = 'explicit' | 'all_matching'; +const selectionMode = ref('explicit'); +const selectAllFilters = ref(null); +const excludedIds = ref>(new Set()); +const selectAllLoading = ref(false); +const selectionCount = computed(() => { + if (selectionMode.value === 'all_matching') { + return Math.max(0, (total.value || 0) - excludedIds.value.size); + } + return selectedIds.value.size; +}); +const hasSelection = computed(() => selectionCount.value > 0); let lastSelectedIndex = -1; const batchDeleting = ref(false); const batchReparsing = ref(false); +const batchCancelParsing = ref(false); const batchTagging = ref(false); const batchTagDialogVisible = ref(false); const batchTagPreSelectedIds = computed(() => { @@ -494,33 +511,120 @@ const awaitBatchReparseReflection = async (ids: string[]) => { pendingReparseAck.value.clear(); }; -const confirmBatchReparse = async () => { - if (batchReparsing.value || batchDeleting.value || selectedIds.value.size === 0) return; - const allIds = Array.from(selectedIds.value); - const ids = allIds.filter((id) => { - const item = cardList.value.find((c) => c.id === id); - return !item || !isParseInFlight(item.parse_status); - }); - const skipped = allIds.length - ids.length; - if (ids.length === 0) { - MessagePlugin.info(t('knowledgeBase.rebuildInProgress')); - return; +const BATCH_REPARSE_MAX_IDS = 200; +const chunkIds = (ids: string[], size: number): string[][] => { + if (size <= 0) return [ids]; + const chunks: string[][] = []; + for (let i = 0; i < ids.length; i += size) { + chunks.push(ids.slice(i, i + size)); + } + return chunks; +}; + +const buildSelectAllFilterPayload = (params: Record = filterParams.value): KnowledgeBatchFilterPayload => { + const tagIds = typeof params.tag_ids === 'string' && params.tag_ids + ? params.tag_ids.split(',').map((s: string) => s.trim()).filter(Boolean) + : Array.isArray(params.tag_ids) ? params.tag_ids : undefined; + return { + tag_ids: tagIds?.length ? tagIds : undefined, + keyword: params.keyword || undefined, + file_type: params.file_type || undefined, + parse_status: params.parse_status || undefined, + source: params.source || undefined, + start_time: params.start_time || undefined, + end_time: params.end_time || undefined, + folder_path: params.folder_path ?? '', + folder_recursive: !!params.folder_recursive, + }; +}; + +const buildBatchSelectionPayload = (): KnowledgeBatchSelectionPayload | undefined => { + if (selectionMode.value !== 'all_matching' || !selectAllFilters.value) return undefined; + return { + select_all: true, + exclude_ids: Array.from(excludedIds.value), + filter: selectAllFilters.value, + }; +}; + +const applyOptimisticBatchCancel = (ids: string[]) => { + const idSet = new Set(ids); + for (const card of cardList.value) { + if (!idSet.has(card.id)) continue; + if (!isParseInFlight(card.parse_status)) continue; + card.parse_status = 'cancelled'; + card.error_message = '用户已取消解析'; } - if (skipped > 0) { - MessagePlugin.warning(t('knowledgeBase.batchReparseSkippedInFlight', { count: skipped })); +}; + +const confirmBatchReparse = async () => { + if (batchReparsing.value || batchDeleting.value || batchCancelParsing.value || !hasSelection.value) return; + const selection = buildBatchSelectionPayload(); + let ids: string[] = []; + let skipped = 0; + if (!selection) { + const allIds = Array.from(selectedIds.value); + ids = allIds.filter((id) => { + const item = cardList.value.find((c) => c.id === id); + return !item || !isParseInFlight(item.parse_status); + }); + skipped = allIds.length - ids.length; + if (ids.length === 0) { + MessagePlugin.info(t('knowledgeBase.rebuildInProgress')); + return; + } + if (skipped > 0) { + MessagePlugin.warning(t('knowledgeBase.batchReparseSkippedInFlight', { count: skipped })); + } } batchReparsing.value = true; try { - const res: any = await batchReparseKnowledge(kbId.value, ids); - if (res?.success) { - MessagePlugin.success(t('knowledgeBase.batchReparseSuccess', { count: ids.length })); - applyOptimisticBatchReparse(ids); + if (selection) { + const res: any = await batchReparseKnowledge(kbId.value, [], undefined, selection); + if (res?.success) { + const count = res?.data?.reparse_count ?? selectionCount.value; + MessagePlugin.success(t('knowledgeBase.batchReparseSuccess', { count })); + applyOptimisticBatchReparse(Array.from(selectedIds.value).filter((id) => { + const item = cardList.value.find((c) => c.id === id); + return !item || !isParseInFlight(item.parse_status); + })); + clearSelection(); + batchMode.value = false; + scheduleWikiStatusProbes(); + void loadKnowledgeFiles(kbId.value); + } else { + MessagePlugin.error(t('knowledgeBase.batchReparseFailed')); + } + return; + } + const chunks = chunkIds(ids, BATCH_REPARSE_MAX_IDS); + const settled = await Promise.allSettled( + chunks.map((group) => batchReparseKnowledge(kbId.value, group)), + ); + let submittedCount = 0; + let failedCount = 0; + const submittedIds: string[] = []; + settled.forEach((result, index) => { + const group = chunks[index]; + if (result.status === 'fulfilled' && (result.value as any)?.success) { + submittedCount += group.length; + submittedIds.push(...group); + } else { + failedCount += group.length; + } + }); + if (submittedCount > 0) { + MessagePlugin.success(t('knowledgeBase.batchReparseSuccess', { count: submittedCount })); + if (failedCount > 0) { + MessagePlugin.warning(t('knowledgeBase.batchReparsePartial', { succeeded: submittedCount, failed: failedCount })); + } + applyOptimisticBatchReparse(submittedIds); clearSelection(); batchMode.value = false; scheduleWikiStatusProbes(); - void awaitBatchReparseReflection(ids); + void awaitBatchReparseReflection(submittedIds); } else { - MessagePlugin.error(res?.message || t('knowledgeBase.batchReparseFailed')); + MessagePlugin.error(t('knowledgeBase.batchReparseFailed')); } } catch (e: any) { MessagePlugin.error(e?.message || t('knowledgeBase.batchReparseFailed')); @@ -529,6 +633,75 @@ const confirmBatchReparse = async () => { } }; +const confirmBatchCancelParse = async () => { + if (batchCancelParsing.value || batchDeleting.value || batchReparsing.value || !hasSelection.value) return; + const selection = buildBatchSelectionPayload(); + let ids: string[] = []; + let skippedKnownNotInFlight = 0; + if (!selection) { + const allIds = Array.from(selectedIds.value); + for (const id of allIds) { + const item = cardList.value.find((c) => c.id === id); + if (!item || isParseInFlight(item.parse_status)) { + ids.push(id); + } else { + skippedKnownNotInFlight++; + } + } + if (ids.length === 0) { + MessagePlugin.info(t('knowledgeBase.batchCancelParseNoInFlight')); + return; + } + if (skippedKnownNotInFlight > 0) { + MessagePlugin.warning(t('knowledgeBase.batchCancelParseSkippedNotInFlight', { count: skippedKnownNotInFlight })); + } + } + batchCancelParsing.value = true; + const submitCount = selection ? selectionCount.value : ids.length; + MessagePlugin.info(t('knowledgeBase.batchCancelParseSubmitting', { count: submitCount })); + try { + let cancelled = 0; + let failed = 0; + let skipped = 0; + if (selection) { + const res: any = await batchCancelKnowledgeParse(kbId.value, [], selection); + cancelled = res?.data?.cancelled ?? 0; + failed = res?.data?.failed ?? 0; + skipped = res?.data?.skipped ?? 0; + } else { + // Explicit mode still respects backend maxBatch=200. + const chunks = chunkIds(ids, BATCH_REPARSE_MAX_IDS); + for (const group of chunks) { + const res: any = await batchCancelKnowledgeParse(kbId.value, group); + cancelled += res?.data?.cancelled ?? 0; + failed += res?.data?.failed ?? 0; + skipped += res?.data?.skipped ?? 0; + } + } + if (cancelled > 0) { + MessagePlugin.success(t('knowledgeBase.batchCancelParseSuccess', { count: cancelled })); + applyOptimisticBatchCancel(Array.from(selectedIds.value)); + clearSelection(); + batchMode.value = false; + await loadKnowledgeFiles(kbId.value); + scheduleWikiStatusProbes(); + } + if (failed > 0 || (cancelled === 0 && skipped > 0 && !selection)) { + MessagePlugin.error(t('knowledgeBase.batchCancelParsePartial', { + succeeded: cancelled, + failed: failed || skipped, + })); + } + if (cancelled === 0 && failed === 0 && selection) { + MessagePlugin.info(t('knowledgeBase.batchCancelParseNoInFlight')); + } + } catch (e: any) { + MessagePlugin.error(e?.message || t('knowledgeBase.batchCancelParseFailed')); + } finally { + batchCancelParsing.value = false; + } +}; + const tagFilterPanelVisible = ref(false); const tagFilterTriggerHover = ref(false); const tagFilterCleared = ref(false); @@ -825,16 +998,55 @@ const folderOptions = computed(() => { return result; }); -const moveKnowledgeIntoFolder = async (ids: string[], folderPath: string) => { - if (!kbId.value || ids.length === 0) return; +// honorSelectAll: only batch-bar "move" should use select_all. Per-row move must +// stay id-scoped even while the table is in all_matching mode. +const moveKnowledgeIntoFolder = async ( + ids: string[], + folderPath: string, + options?: { honorSelectAll?: boolean }, +) => { + if (!kbId.value) return; + const selection = options?.honorSelectAll ? buildBatchSelectionPayload() : undefined; + if (!selection && ids.length === 0) return; try { - await moveKnowledgeToFolder(kbId.value, ids, folderPath); - MessagePlugin.success(t('knowledgeBase.moveToFolder.success', { count: ids.length })); - clearSelection(); - batchMode.value = false; - resetPage(); - await loadKnowledgeFiles(kbId.value); - await loadFolderTree(kbId.value); + if (selection) { + const res: any = await moveKnowledgeToFolder(kbId.value, [], folderPath, selection); + const count = res?.data?.moved_count ?? selectionCount.value; + MessagePlugin.success(t('knowledgeBase.moveToFolder.success', { count })); + clearSelection(); + batchMode.value = false; + resetPage(); + await loadKnowledgeFiles(kbId.value); + await loadFolderTree(kbId.value); + return; + } + const chunks = chunkIds(ids, BATCH_REPARSE_MAX_IDS); + const settled = await Promise.allSettled( + chunks.map((group) => moveKnowledgeToFolder(kbId.value, group, folderPath)), + ); + let succeeded = 0; + let failed = 0; + settled.forEach((result, index) => { + const group = chunks[index]; + if (result.status === 'fulfilled') { + succeeded += group.length; + } else { + failed += group.length; + } + }); + if (succeeded > 0) { + MessagePlugin.success(t('knowledgeBase.moveToFolder.success', { count: succeeded })); + if (failed > 0) { + MessagePlugin.warning(t('knowledgeBase.moveToFolder.partial', { succeeded, failed })); + } + clearSelection(); + batchMode.value = false; + resetPage(); + await loadKnowledgeFiles(kbId.value); + await loadFolderTree(kbId.value); + return; + } + MessagePlugin.error(t('knowledgeBase.moveToFolder.failed')); } catch (error: any) { MessagePlugin.error(error?.message || t('knowledgeBase.moveToFolder.failed')); } @@ -1983,17 +2195,29 @@ const syncDocumentSummaryState = (state: { id?: string; summary_status?: string; const toggleSelectRow = (id: string, checked: boolean, shiftKey?: boolean) => { const items = cardList.value || []; const idx = items.findIndex((i: KnowledgeCard) => i.id === id); + const applyOne = (targetId: string, on: boolean) => { + if (selectionMode.value === 'all_matching') { + if (on) { + excludedIds.value.delete(targetId); + selectedIds.value.add(targetId); + } else { + excludedIds.value.add(targetId); + selectedIds.value.delete(targetId); + } + return; + } + if (on) selectedIds.value.add(targetId); + else selectedIds.value.delete(targetId); + }; if (shiftKey && lastSelectedIndex >= 0 && idx >= 0) { const [s, e] = idx < lastSelectedIndex ? [idx, lastSelectedIndex] : [lastSelectedIndex, idx]; for (let i = s; i <= e; i++) { - if (checked) selectedIds.value.add(items[i].id); - else selectedIds.value.delete(items[i].id); + applyOne(items[i].id, checked); } } else { - if (checked) selectedIds.value.add(id); - else selectedIds.value.delete(id); + applyOne(id, checked); } lastSelectedIndex = idx; }; @@ -2003,15 +2227,25 @@ const onCardGridCheckboxChange = (id: string, checked: boolean, ctx?: { e?: Even toggleSelectRow(id, checked, !!me?.shiftKey); }; +// 全选:按当前列表筛选进入 all_matching,由后端 batch API 的 select_all 解析目标集合。 +// 不再前端翻页拉取全部 ID。取消勾选个别文档写入 exclude_ids。 const toggleSelectAll = (checked: boolean) => { - if (checked) { - for (const item of cardList.value || []) selectedIds.value.add(item.id); - } else { - for (const item of cardList.value || []) selectedIds.value.delete(item.id); + if (!checked) { + clearSelection(); + return; } + selectionMode.value = 'all_matching'; + selectAllFilters.value = buildSelectAllFilterPayload(); + excludedIds.value.clear(); + for (const item of cardList.value || []) selectedIds.value.add(item.id); + selectAllLoading.value = false; }; const clearSelection = () => { + selectionMode.value = 'explicit'; + selectAllFilters.value = null; + excludedIds.value.clear(); + selectAllLoading.value = false; selectedIds.value.clear(); lastSelectedIndex = -1; }; @@ -2032,7 +2266,7 @@ const handleBatchCancel = () => { // 切到卡片视图时,如果列表视图里已经勾选过文档,需要自动开启批量管理模式, // 否则卡片视图默认不渲染 checkbox,会看不到勾选态。 watch(viewMode, (mode) => { - if (mode === 'grid' && selectedIds.value.size > 0) { + if (mode === 'grid' && hasSelection.value) { batchMode.value = true; } }); @@ -2055,7 +2289,9 @@ const { itemSelector: '.knowledge-card[data-select-id], .doc-list-row[data-select-id]', selectedIds, getItemId: (el) => el.dataset.selectId || null, - enabled: computed(() => canEdit.value && !isFAQ.value && cardList.value.length > 0), + // Marquee mutates selectedIds directly; disable under select_all so exclude_ids stays consistent. + enabled: computed(() => + canEdit.value && !isFAQ.value && cardList.value.length > 0 && selectionMode.value === 'explicit'), onSelectionStart: () => { batchMode.value = true; }, @@ -2077,14 +2313,55 @@ const openKnowledgeItem = (item: KnowledgeCard) => { }; const confirmBatchDelete = async () => { - if (batchDeleting.value || batchReparsing.value || selectedIds.value.size === 0) return; - const ids = Array.from(selectedIds.value); - const deletedIdSet = new Set(ids); + if (batchDeleting.value || batchReparsing.value || batchCancelParsing.value || !hasSelection.value) return; + const selection = buildBatchSelectionPayload(); + const ids = selection ? [] : Array.from(selectedIds.value); batchDeleting.value = true; try { - const res: any = await batchDeleteKnowledge(kbId.value, ids); - if (res?.success) { - MessagePlugin.success(t('knowledgeBase.batchDeleteSuccess', { count: ids.length })); + if (selection) { + const res: any = await batchDeleteKnowledge(kbId.value, [], selection); + if (res?.success) { + const count = res?.data?.deleted_count ?? selectionCount.value; + MessagePlugin.success(t('knowledgeBase.batchDeleteSuccess', { count })); + clearSelection(); + batchMode.value = false; + resetPage(); + const maxPolls = 30; + const delayMs = 400; + for (let i = 0; i < maxPolls; i++) { + await loadKnowledgeFiles(kbId.value); + await new Promise((r) => setTimeout(r, delayMs)); + // select_all deletes are async; brief poll then refresh tags/tree + if (i >= 2) break; + } + loadTags(kbId.value, true); + void loadFolderTree(kbId.value); + } else { + MessagePlugin.error(t('knowledgeBase.batchDeleteFailed')); + } + return; + } + const chunks = chunkIds(ids, BATCH_REPARSE_MAX_IDS); + const settled = await Promise.allSettled( + chunks.map((group) => batchDeleteKnowledge(kbId.value, group)), + ); + let succeeded = 0; + let failed = 0; + const deletedIdSet = new Set(); + settled.forEach((result, index) => { + const group = chunks[index]; + if (result.status === 'fulfilled' && (result.value as any)?.success) { + succeeded += group.length; + group.forEach((id) => deletedIdSet.add(id)); + } else { + failed += group.length; + } + }); + if (succeeded > 0) { + MessagePlugin.success(t('knowledgeBase.batchDeleteSuccess', { count: succeeded })); + if (failed > 0) { + MessagePlugin.warning(t('knowledgeBase.batchDeletePartial', { succeeded, failed })); + } clearSelection(); batchMode.value = false; resetPage(); @@ -2100,7 +2377,7 @@ const confirmBatchDelete = async () => { loadTags(kbId.value, true); void loadFolderTree(kbId.value); } else { - MessagePlugin.error(res?.message || t('knowledgeBase.batchDeleteFailed')); + MessagePlugin.error(t('knowledgeBase.batchDeleteFailed')); } } catch (e: any) { MessagePlugin.error(e?.message || t('knowledgeBase.batchDeleteFailed')); @@ -2110,21 +2387,31 @@ const confirmBatchDelete = async () => { }; const handleBatchTag = () => { - if (batchDeleting.value || batchReparsing.value || batchTagging.value || selectedIds.value.size === 0) return; + if (batchDeleting.value || batchReparsing.value || batchCancelParsing.value || batchTagging.value || !hasSelection.value) return; batchTagDialogVisible.value = true; }; const onBatchTagConfirm = async (tagIds: string[]) => { - if (batchTagging.value || selectedIds.value.size === 0) return; - const ids = Array.from(selectedIds.value); - const updateMap: Record = {}; - for (const id of ids) { - updateMap[id] = tagIds; - } + if (batchTagging.value || !hasSelection.value) return; + const selection = buildBatchSelectionPayload(); batchTagging.value = true; try { - await updateKnowledgeTagBatch({ updates: updateMap }); - MessagePlugin.success(t('knowledgeBase.batchTagSuccess', { count: ids.length })); + if (selection) { + await updateKnowledgeTagBatch({ + kb_id: kbId.value, + tag_ids: tagIds, + ...selection, + }); + MessagePlugin.success(t('knowledgeBase.batchTagSuccess', { count: selectionCount.value })); + } else { + const ids = Array.from(selectedIds.value); + const updateMap: Record = {}; + for (const id of ids) { + updateMap[id] = tagIds; + } + await updateKnowledgeTagBatch({ updates: updateMap, kb_id: kbId.value }); + MessagePlugin.success(t('knowledgeBase.batchTagSuccess', { count: ids.length })); + } batchTagDialogVisible.value = false; clearSelection(); batchMode.value = false; @@ -2223,6 +2510,14 @@ watch(cardList, () => { if (moreIndex.value >= n) { moreIndex.value = -1; } + if (selectionMode.value === 'all_matching') { + // Newly loaded rows are selected unless explicitly excluded. + for (const item of items) { + if (!excludedIds.value.has(item.id)) selectedIds.value.add(item.id); + else selectedIds.value.delete(item.id); + } + return; + } if (selectedIds.value.size === 0) return; const visible = new Set(items.map((i: KnowledgeCard) => i.id)); for (const id of selectedIds.value) { @@ -2655,13 +2950,15 @@ async function createNewSession(value: string): Promise { -
- + + @move-to-folder="(path: string) => moveKnowledgeIntoFolder(Array.from(selectedIds), path, { honorSelectAll: true })" />
@@ -2697,7 +2994,7 @@ async function createNewSession(value: string): Promise { (); @@ -35,24 +39,36 @@ const folderPickerVisible = ref(false); :aria-label="t('knowledgeBase.selectedCount', { count })">
+ {{ t('knowledgeBase.selectedCount', { count }) }} {{ t('knowledgeBase.clearSelection') }}
+ + + + {{ t('knowledgeBase.cancelParse') }} + + + + :disabled="count === 0 || deleteLoading || reparseLoading || cancelParseLoading || tagLoading || selectAllLoading" :loading="reparseLoading" @click.stop> {{ t('knowledgeBase.rebuildDocument') }} {{ t('knowledgeBase.batchTag') }} @@ -61,7 +77,7 @@ const folderPickerVisible = ref(false); + :disabled="count === 0 || deleteLoading || reparseLoading || cancelParseLoading || tagLoading || selectAllLoading"> {{ t('knowledgeBase.moveToFolder.action') }} @@ -77,7 +93,7 @@ const folderPickerVisible = ref(false); :confirm-btn="{ content: t('knowledgeBase.confirmDelete'), theme: 'danger' }" :cancel-btn="{ content: t('common.cancel') }" placement="top" @confirm="emit('delete')"> + :disabled="count === 0 || deleteLoading || reparseLoading || cancelParseLoading || tagLoading || selectAllLoading" :loading="deleteLoading" @click.stop> {{ t('knowledgeBase.batchDelete') }} @@ -93,7 +109,7 @@ const folderPickerVisible = ref(false); position: relative; z-index: 5; width: 100%; - max-width: 560px; + max-width: 640px; margin: 0 auto; padding: 0 4px; box-sizing: border-box; @@ -126,6 +142,12 @@ const folderPickerVisible = ref(false); white-space: nowrap; } +.batch-bar-loading { + flex-shrink: 0; + display: inline-flex; + align-items: center; +} + .batch-bar-clear { flex-shrink: 0; padding: 0 6px !important; diff --git a/internal/application/repository/knowledge.go b/internal/application/repository/knowledge.go index c2989e8f2a..21b1c59d37 100644 --- a/internal/application/repository/knowledge.go +++ b/internal/application/repository/knowledge.go @@ -204,6 +204,27 @@ func (r *knowledgeRepository) ListPagedKnowledgeByKnowledgeBaseID( return knowledges, total, nil } +// ListKnowledgeIDsByFilter returns knowledge IDs matching the list filter without +// loading full rows. Callers that need a hard cap (e.g. select_all batch ops) +// must enforce it after this returns. +func (r *knowledgeRepository) ListKnowledgeIDsByFilter( + ctx context.Context, + tenantID uint64, + kbID string, + filter types.KnowledgeListFilter, +) ([]string, error) { + var ids []string + err := applyKnowledgeListFilter( + r.db.WithContext(ctx).Model(&types.Knowledge{}). + Where("tenant_id = ? AND knowledge_base_id = ?", tenantID, kbID), + filter, + ).Order("created_at DESC").Pluck("id", &ids).Error + if err != nil { + return nil, err + } + return ids, nil +} + // ListKnowledgeFolderCounts aggregates how many knowledge entries live directly // in each folder of a knowledge base. Rows mid-deletion are excluded so the // sidebar tree counts match the document list. diff --git a/internal/application/repository/knowledge_ids_by_filter_test.go b/internal/application/repository/knowledge_ids_by_filter_test.go new file mode 100644 index 0000000000..1d9980919c --- /dev/null +++ b/internal/application/repository/knowledge_ids_by_filter_test.go @@ -0,0 +1,35 @@ +package repository + +import ( + "context" + "testing" + + "github.com/Tencent/WeKnora/internal/types" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListKnowledgeIDsByFilter_MatchesListFilter(t *testing.T) { + db := setupKnowledgeTestDB(t) + repo := NewKnowledgeRepository(db).(*knowledgeRepository) + ctx := context.Background() + + const tenantID = uint64(1) + kbID := uuid.New().String() + + completedID := insertKnowledgeInKB(t, db, tenantID, kbID, "completed") + failedID := insertKnowledgeInKB(t, db, tenantID, kbID, "failed") + deletingID := insertKnowledgeInKB(t, db, tenantID, kbID, types.ParseStatusDeleting) + + ids, err := repo.ListKnowledgeIDsByFilter(ctx, tenantID, kbID, types.KnowledgeListFilter{}) + require.NoError(t, err) + assert.ElementsMatch(t, []string{completedID, failedID}, ids) + assert.NotContains(t, ids, deletingID) + + ids, err = repo.ListKnowledgeIDsByFilter(ctx, tenantID, kbID, types.KnowledgeListFilter{ + ParseStatus: "completed", + }) + require.NoError(t, err) + assert.Equal(t, []string{completedID}, ids) +} diff --git a/internal/application/service/knowledge.go b/internal/application/service/knowledge.go index 590a6b38ca..ac8e666c6f 100644 --- a/internal/application/service/knowledge.go +++ b/internal/application/service/knowledge.go @@ -565,6 +565,13 @@ func (s *knowledgeService) ListPagedKnowledgeByKnowledgeBaseID(ctx context.Conte return types.NewPageResult(total, page, knowledges), nil } +// ListKnowledgeIDsByFilter returns knowledge IDs matching the list filter. +func (s *knowledgeService) ListKnowledgeIDsByFilter( + ctx context.Context, kbID string, filter types.KnowledgeListFilter, +) ([]string, error) { + return s.repo.ListKnowledgeIDsByFilter(ctx, ctx.Value(types.TenantIDContextKey).(uint64), kbID, filter) +} + // ListKnowledgeFolderTree returns the folder hierarchy of a knowledge base with // per-folder document counts, derived from the folder_path stored on each // knowledge entry. diff --git a/internal/application/service/knowledge_process.go b/internal/application/service/knowledge_process.go index 97c3a4b187..f048e873e8 100644 --- a/internal/application/service/knowledge_process.go +++ b/internal/application/service/knowledge_process.go @@ -2786,6 +2786,39 @@ func (s *knowledgeService) CancelKnowledgeParse( return existing, nil } +// CancelKnowledgeParseBatch cancels parse for many knowledge IDs. Items that +// are already cancelled count as cancelled (idempotent). Terminal / deleting +// states count as skipped. Unexpected errors count as failed. +func (s *knowledgeService) CancelKnowledgeParseBatch( + ctx context.Context, knowledgeIDs []string, +) (*types.BatchCancelParseResult, error) { + result := &types.BatchCancelParseResult{} + for _, id := range knowledgeIDs { + id = strings.TrimSpace(id) + if id == "" { + continue + } + _, err := s.CancelKnowledgeParse(ctx, id) + if err == nil { + result.Cancelled++ + continue + } + if appErr, ok := werrors.IsAppError(err); ok { + switch appErr.Code { + case werrors.ErrBadRequest: + result.Skipped++ + case werrors.ErrNotFound: + result.Skipped++ + default: + result.Failed++ + } + continue + } + result.Failed++ + } + return result, nil +} + // dequeueKnowledgeTasks asks the task inspector to remove any queued // tasks for this knowledge and signal active workers to stop. Safe to // call when the inspector is a no-op (Lite mode). diff --git a/internal/handler/knowledge.go b/internal/handler/knowledge.go index 8bf2fe5aa2..84a4611248 100644 --- a/internal/handler/knowledge.go +++ b/internal/handler/knowledge.go @@ -1072,16 +1072,17 @@ func (h *KnowledgeHandler) ListKnowledgeFolders(c *gin.Context) { // MoveKnowledgeToFolderRequest is the body schema for POST /knowledge/folder. type MoveKnowledgeToFolderRequest struct { KBID string `json:"kb_id" binding:"required"` - IDs []string `json:"knowledge_ids" binding:"required"` + IDs []string `json:"knowledge_ids"` // FolderPath is the destination folder; the empty string is the knowledge // base top level. It is deliberately not `binding:"required"` so documents // can be moved back out of every folder. FolderPath string `json:"folder_path"` + knowledgeBatchSelection } // MoveKnowledgeToFolder godoc // @Summary 移动知识到文件夹 -// @Description 批量修改知识条目所属文件夹。文件夹由路径推导而来,因此目标路径不存在时会自动创建;空路径表示知识库顶层。仅调整归类,不会重新解析文档 +// @Description 批量修改知识条目所属文件夹。文件夹由路径推导而来,因此目标路径不存在时会自动创建;空路径表示知识库顶层。仅调整归类,不会重新解析文档。支持 select_all + filter 按当前列表筛选解析目标集合。 // @Tags 知识管理 // @Accept json // @Produce json @@ -1101,17 +1102,6 @@ func (h *KnowledgeHandler) MoveKnowledgeToFolder(c *gin.Context) { return } - ids := dedupeKnowledgeIDs(req.IDs) - if len(ids) == 0 { - c.Error(errors.NewBadRequestError("knowledge_ids cannot be empty")) - return - } - const maxBatch = 200 - if len(ids) > maxBatch { - c.Error(errors.NewBadRequestError(fmt.Sprintf("too many ids (max %d per batch)", maxBatch))) - return - } - kbID, effectiveTenantID, err := h.requireKnowledgeWriteAccess(c, req.KBID) if err != nil { c.Error(err) @@ -1119,29 +1109,35 @@ func (h *KnowledgeHandler) MoveKnowledgeToFolder(c *gin.Context) { } ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID) - // Guard against cross-KB moves: the service layer scopes by tenant, so the - // handler must confirm every entry belongs to the requested knowledge base. - if err := h.requireKnowledgeInKB(ctx, effectiveTenantID, kbID, ids); err != nil { + resolved, err := h.resolveBatchKnowledgeIDs( + ctx, effectiveTenantID, kbID, req.knowledgeBatchSelection, req.IDs, "knowledge_ids cannot be empty") + if err != nil { c.Error(err) return } - affected, err := h.kgService.MoveKnowledgeToFolder(ctx, kbID, ids, req.FolderPath) - if err != nil { - if appErr, ok := errors.IsAppError(err); ok { - c.Error(appErr) + var affected int64 + for _, chunk := range chunkStringIDs(resolved.IDs, maxBatchExplicitIDs) { + n, moveErr := h.kgService.MoveKnowledgeToFolder(ctx, kbID, chunk, req.FolderPath) + if moveErr != nil { + if appErr, ok := errors.IsAppError(moveErr); ok { + c.Error(appErr) + return + } + logger.ErrorWithFields(ctx, moveErr, nil) + c.Error(errors.NewInternalServerError(moveErr.Error())) return } - logger.ErrorWithFields(ctx, err, nil) - c.Error(errors.NewInternalServerError(err.Error())) - return + affected += n } c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ - "moved_count": affected, - "folder_path": types.NormalizeKnowledgeFolderPath(req.FolderPath), + "moved_count": affected, + "folder_path": types.NormalizeKnowledgeFolderPath(req.FolderPath), + "matched_count": resolved.MatchedCount, + "excluded_count": resolved.ExcludedCount, }, }) } @@ -1337,12 +1333,13 @@ func (h *KnowledgeHandler) DeleteKnowledge(c *gin.Context) { // BatchDeleteKnowledgeRequest is the body schema for POST /knowledge/batch-delete. type BatchDeleteKnowledgeRequest struct { KBID string `json:"kb_id" binding:"required"` - IDs []string `json:"ids" binding:"required"` + IDs []string `json:"ids"` + knowledgeBatchSelection } // BatchDeleteKnowledge godoc // @Summary 批量删除知识 -// @Description 按 ID 列表批量删除单个知识库下的多个知识条目 +// @Description 按 ID 列表或 select_all+filter 批量删除单个知识库下的多个知识条目 // @Tags 知识管理 // @Accept json // @Produce json @@ -1362,17 +1359,6 @@ func (h *KnowledgeHandler) BatchDeleteKnowledge(c *gin.Context) { return } - ids := dedupeKnowledgeIDs(req.IDs) - if len(ids) == 0 { - c.Error(errors.NewBadRequestError("ids cannot be empty")) - return - } - const maxBatch = 200 - if len(ids) > maxBatch { - c.Error(errors.NewBadRequestError(fmt.Sprintf("too many ids (max %d per batch)", maxBatch))) - return - } - // Validate KB access (editor or admin) using the kb_id from body. _, kbID, effectiveTenantID, permission, err := h.validateKnowledgeBaseAccessWithKBID(c, req.KBID) if err != nil { @@ -1389,44 +1375,31 @@ func (h *KnowledgeHandler) BatchDeleteKnowledge(c *gin.Context) { } ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID) - // Single batch fetch to validate that every id exists and belongs to the - // requested KB. The service-layer DeleteKnowledgeList only enforces tenant - // scope, not KB scope, so the handler must guard against cross-KB deletion. - knowledgeList, err := h.kgService.GetKnowledgeBatch(ctx, effectiveTenantID, ids) + resolved, err := h.resolveBatchKnowledgeIDs( + ctx, effectiveTenantID, kbID, req.knowledgeBatchSelection, req.IDs, "ids cannot be empty") if err != nil { - logger.ErrorWithFields(ctx, err, nil) - c.Error(errors.NewInternalServerError(err.Error())) - return - } - if len(knowledgeList) != len(ids) { - c.Error(errors.NewBadRequestError("One or more knowledge entries not found")) + c.Error(err) return } - for _, k := range knowledgeList { - if k.KnowledgeBaseID != kbID { - c.Error(errors.NewBadRequestError( - fmt.Sprintf("Knowledge %s does not belong to knowledge base %s", - secutils.SanitizeForLog(k.ID), secutils.SanitizeForLog(kbID)))) - return - } - } - taskID, err := h.enqueueKnowledgeListDelete(ctx, effectiveTenantID, ids) + taskID, err := h.enqueueKnowledgeListDelete(ctx, effectiveTenantID, resolved.IDs) if err != nil { logger.Errorf(ctx, "Failed to enqueue batch knowledge delete task: %v", err) c.Error(errors.NewInternalServerError("Failed to enqueue batch delete task")) return } - logger.Infof(ctx, "Batch knowledge delete task enqueued: %s, kb_id: %s, count: %d", - taskID, secutils.SanitizeForLog(kbID), len(ids)) + logger.Infof(ctx, "Batch knowledge delete task enqueued: %s, kb_id: %s, count: %d, select_all: %v", + taskID, secutils.SanitizeForLog(kbID), len(resolved.IDs), req.SelectAll) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Batch delete task submitted", "data": gin.H{ - "task_id": taskID, - "deleted_count": len(ids), + "task_id": taskID, + "deleted_count": len(resolved.IDs), + "matched_count": resolved.MatchedCount, + "excluded_count": resolved.ExcludedCount, }, }) } @@ -2047,18 +2020,92 @@ func (h *KnowledgeHandler) CancelKnowledgeParse(c *gin.Context) { }) } +// BatchCancelKnowledgeParseRequest is the body for POST /knowledge/batch-cancel-parse. +type BatchCancelKnowledgeParseRequest struct { + KBID string `json:"kb_id" binding:"required"` + IDs []string `json:"ids"` + knowledgeBatchSelection +} + +// BatchCancelKnowledgeParse godoc +// @Summary 批量取消知识解析 +// @Description 按 ID 列表或 select_all+filter 批量取消进行中的知识解析。部分失败不阻断整批。 +// @Tags 知识管理 +// @Accept json +// @Produce json +// @Param request body BatchCancelKnowledgeParseRequest true "批量取消请求" +// @Success 200 {object} map[string]interface{} "取消结果" +// @Failure 400 {object} errors.AppError "请求参数错误" +// @Failure 403 {object} errors.AppError "权限不足" +// @Security Bearer +// @Security ApiKeyAuth +// @Router /knowledge/batch-cancel-parse [post] +func (h *KnowledgeHandler) BatchCancelKnowledgeParse(c *gin.Context) { + ctx := c.Request.Context() + + var req BatchCancelKnowledgeParseRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.Error(errors.NewBadRequestError("Invalid request parameters: " + err.Error())) + return + } + + _, kbID, effectiveTenantID, permission, err := h.validateKnowledgeBaseAccessWithKBID(c, req.KBID) + if err != nil { + c.Error(err) + return + } + if permission != types.OrgRoleAdmin && permission != types.OrgRoleEditor { + c.Error(errors.NewForbiddenError("No permission to cancel knowledge parse")) + return + } + ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID) + + resolved, err := h.resolveBatchKnowledgeIDs( + ctx, effectiveTenantID, kbID, req.knowledgeBatchSelection, req.IDs, "ids cannot be empty") + if err != nil { + c.Error(err) + return + } + + result, err := h.kgService.CancelKnowledgeParseBatch(ctx, resolved.IDs) + if err != nil { + if appErr, ok := errors.IsAppError(err); ok { + c.Error(appErr) + return + } + logger.ErrorWithFields(ctx, err, nil) + c.Error(errors.NewInternalServerError(err.Error())) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Batch cancel parse finished", + "data": gin.H{ + "cancelled": result.Cancelled, + "skipped": result.Skipped, + "failed": result.Failed, + "matched_count": resolved.MatchedCount, + "excluded_count": resolved.ExcludedCount, + }, + }) +} + 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"` + KBID string `json:"kb_id"` // Required for select_all; optional for updates map + // TagIDs is the tag set to apply (replace semantics) when select_all=true. + TagIDs []string `json:"tag_ids"` + knowledgeBatchSelection } // UpdateKnowledgeTagBatch godoc // @Summary 批量更新知识标签 -// @Description 批量更新知识条目的标签。可选 kb_id:指定时按该知识库校验编辑权限并用于共享知识库的空间解析 +// @Description 批量更新知识条目的标签。可选 kb_id:指定时按该知识库校验编辑权限并用于共享知识库的空间解析。select_all=true 时按 filter 解析目标并以 tag_ids 覆盖写入。 // @Tags 知识管理 // @Accept json // @Produce json -// @Param request body object true "标签更新请求(updates 必填,kb_id 可选)" +// @Param request body object true "标签更新请求(updates 或 select_all+tag_ids)" // @Success 200 {object} map[string]interface{} "更新成功" // @Failure 400 {object} errors.AppError "请求参数错误" // @Security Bearer @@ -2081,10 +2128,18 @@ func (h *KnowledgeHandler) UpdateKnowledgeTagBatch(c *gin.Context) { c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error())) return } - // Resolve effective tenant and the authorized KB scope. + var authorizedKBID string - if kbID := secutils.SanitizeForLog(req.KBID); kbID != "" { - _, _, effID, permission, err := h.validateKnowledgeBaseAccessWithKBID(c, kbID) + var effectiveTenantID uint64 + var updates map[string][]string + var matchedCount, excludedCount int + + if req.SelectAll { + if strings.TrimSpace(req.KBID) == "" { + c.Error(errors.NewBadRequestError("kb_id is required when select_all is true")) + return + } + _, kbID, effID, permission, err := h.validateKnowledgeBaseAccessWithKBID(c, req.KBID) if err != nil { c.Error(err) return @@ -2094,34 +2149,90 @@ func (h *KnowledgeHandler) UpdateKnowledgeTagBatch(c *gin.Context) { return } authorizedKBID = kbID - ctx = context.WithValue(ctx, types.TenantIDContextKey, effID) - } else if len(req.Updates) > 0 { - // No kb_id: infer from first knowledge ID so shared-KB updates work without client sending kb_id - var firstKnowledgeID string - for id := range req.Updates { - firstKnowledgeID = id - break - } - if firstKnowledgeID != "" { - knowledge, effCtx, err := h.resolveKnowledgeAndValidateKBAccess(c, firstKnowledgeID, types.OrgRoleEditor) + effectiveTenantID = effID + ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID) + + resolved, err := h.resolveBatchKnowledgeIDs( + ctx, effectiveTenantID, kbID, req.knowledgeBatchSelection, nil, "ids cannot be empty") + if err != nil { + c.Error(err) + return + } + matchedCount = resolved.MatchedCount + excludedCount = resolved.ExcludedCount + tagIDs := dedupeKnowledgeIDs(req.TagIDs) + updates = make(map[string][]string, len(resolved.IDs)) + for _, id := range resolved.IDs { + updates[id] = tagIDs + } + } else { + if len(req.Updates) == 0 { + c.Error(errors.NewBadRequestError("updates cannot be empty")) + return + } + updates = req.Updates + if kbID := secutils.SanitizeForLog(req.KBID); kbID != "" { + _, _, effID, permission, err := h.validateKnowledgeBaseAccessWithKBID(c, kbID) if err != nil { c.Error(err) return } - authorizedKBID = knowledge.KnowledgeBaseID - ctx = effCtx + if permission != types.OrgRoleAdmin && permission != types.OrgRoleEditor { + c.Error(errors.NewForbiddenError("No permission to update knowledge tags")) + return + } + authorizedKBID = kbID + ctx = context.WithValue(ctx, types.TenantIDContextKey, effID) + } else if len(req.Updates) > 0 { + // No kb_id: infer from first knowledge ID so shared-KB updates work without client sending kb_id + var firstKnowledgeID string + for id := range req.Updates { + firstKnowledgeID = id + break + } + if firstKnowledgeID != "" { + knowledge, effCtx, err := h.resolveKnowledgeAndValidateKBAccess(c, firstKnowledgeID, types.OrgRoleEditor) + if err != nil { + c.Error(err) + return + } + authorizedKBID = knowledge.KnowledgeBaseID + ctx = effCtx + } } + matchedCount = len(updates) } - if err := h.kgService.UpdateKnowledgeTagBatch(ctx, authorizedKBID, req.Updates); err != nil { - logger.ErrorWithFields(ctx, err, nil) - c.Error(err) - return + + // Apply in chunks to avoid oversized in-memory maps / DB batches. + for _, chunk := range chunkStringIDs(keysOfStringSliceMap(updates), maxBatchExplicitIDs) { + partial := make(map[string][]string, len(chunk)) + for _, id := range chunk { + partial[id] = updates[id] + } + if err := h.kgService.UpdateKnowledgeTagBatch(ctx, authorizedKBID, partial); err != nil { + logger.ErrorWithFields(ctx, err, nil) + c.Error(err) + return + } } c.JSON(http.StatusOK, gin.H{ "success": true, + "data": gin.H{ + "updated_count": len(updates), + "matched_count": matchedCount, + "excluded_count": excludedCount, + }, }) } +func keysOfStringSliceMap(m map[string][]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + // UpdateImageInfo godoc // @Summary 更新图像信息 // @Description 更新知识分块的图像信息 @@ -2683,20 +2794,22 @@ func sliceContains(ss []string, target string) bool { return false } -type batchReparseKnowledgeRequest struct { +// BatchReparseKnowledgeRequest is the body schema for POST /knowledge/batch-reparse. +type BatchReparseKnowledgeRequest struct { KBID string `json:"kb_id" binding:"required"` - IDs []string `json:"ids" binding:"required"` + IDs []string `json:"ids"` ProcessConfig *types.KnowledgeProcessOverrides `json:"process_config,omitempty"` + knowledgeBatchSelection } // BatchReparseKnowledge godoc // @Summary 批量重新解析知识 -// @Description 按 ID 列表批量重新解析单个知识库下的多个知识条目 +// @Description 按 ID 列表或 select_all+filter 批量重新解析单个知识库下的多个知识条目 // @Tags 知识管理 // @Accept json // @Produce json -// @Param request body batchReparseKnowledgeRequest true "批量重解析请求" -// @Success 200 {object} map[string]interface{} "任务已提交" +// @Param request body BatchReparseKnowledgeRequest true "批量重建请求" +// @Success 200 {object} map[string]interface{} "重建任务已提交" // @Failure 400 {object} errors.AppError "请求参数错误" // @Failure 403 {object} errors.AppError "权限不足" // @Security Bearer @@ -2704,35 +2817,10 @@ type batchReparseKnowledgeRequest struct { // @Router /knowledge/batch-reparse [post] func (h *KnowledgeHandler) BatchReparseKnowledge(c *gin.Context) { ctx := c.Request.Context() - var req batchReparseKnowledgeRequest + var req BatchReparseKnowledgeRequest if err := c.ShouldBindJSON(&req); err != nil { - logger.Errorf(ctx, "failed to parse batch reparse knowledge request: %v", err) - c.Error(errors.NewBadRequestError("invalid batch reparse knowledge request parameters")) - return - } - - seen := make(map[string]struct{}, len(req.IDs)) - ids := make([]string, 0, len(req.IDs)) - for _, raw := range req.IDs { - id := strings.TrimSpace(raw) - if id == "" { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - ids = append(ids, id) - } - - if len(ids) == 0 { - c.Error(errors.NewBadRequestError("no knowledge IDs provided for batch reparse")) - return - } - const maxBatch = 200 - if len(ids) > maxBatch { - c.Error(errors.NewBadRequestError(fmt.Sprintf("too many ids (max %d per batch)", maxBatch))) + c.Error(errors.NewBadRequestError("Invalid request parameters: " + err.Error())) return } @@ -2742,46 +2830,36 @@ func (h *KnowledgeHandler) BatchReparseKnowledge(c *gin.Context) { return } if permission != types.OrgRoleAdmin && permission != types.OrgRoleEditor { - c.Error(errors.NewForbiddenError("no permission to reparse knowledge in this kb")) + c.Error(errors.NewForbiddenError("No permission to reparse knowledge")) return } ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID) - knowledgeList, err := h.kgService.GetKnowledgeBatch(ctx, effectiveTenantID, ids) + resolved, err := h.resolveBatchKnowledgeIDs( + ctx, effectiveTenantID, kbID, req.knowledgeBatchSelection, req.IDs, "ids cannot be empty") if err != nil { - logger.Errorf(ctx, "failed to get knowledge batch, kb_id: %s, size: %d, err: %v", kbID, len(ids), err) - c.Error(errors.NewInternalServerError("failed to get knowledge batch")) - return - } - if len(knowledgeList) != len(ids) { - c.Error(errors.NewBadRequestError("some knowledge entries were not found")) + c.Error(err) return } - for _, k := range knowledgeList { - if k.KnowledgeBaseID != kbID { - c.Error(errors.NewBadRequestError( - fmt.Sprintf("Knowledge %s does not belong to knowledge base %s", - secutils.SanitizeForLog(k.ID), secutils.SanitizeForLog(kbID)))) - return - } - } - taskID, err := h.enqueueKnowledgeListReparse(ctx, effectiveTenantID, ids, req.ProcessConfig) + taskID, err := h.enqueueKnowledgeListReparse(ctx, effectiveTenantID, resolved.IDs, req.ProcessConfig) if err != nil { logger.Errorf(ctx, "Failed to enqueue batch knowledge reparse task: %v", err) c.Error(errors.NewInternalServerError("Failed to enqueue batch reparse task")) return } - logger.Infof(ctx, "Batch knowledge reparse task enqueued: %s, kb_id: %s, count: %d", - taskID, secutils.SanitizeForLog(kbID), len(ids)) + logger.Infof(ctx, "Batch knowledge reparse task enqueued: %s, kb_id: %s, count: %d, select_all: %v", + taskID, secutils.SanitizeForLog(kbID), len(resolved.IDs), req.SelectAll) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Batch reparse task submitted", "data": gin.H{ - "task_id": taskID, - "reparse_count": len(ids), + "task_id": taskID, + "reparse_count": len(resolved.IDs), + "matched_count": resolved.MatchedCount, + "excluded_count": resolved.ExcludedCount, }, }) } diff --git a/internal/handler/knowledge_batch_selection.go b/internal/handler/knowledge_batch_selection.go new file mode 100644 index 0000000000..48d3f85b9a --- /dev/null +++ b/internal/handler/knowledge_batch_selection.go @@ -0,0 +1,162 @@ +package handler + +import ( + "context" + "fmt" + + "github.com/Tencent/WeKnora/internal/errors" + "github.com/Tencent/WeKnora/internal/types" +) + +const ( + // maxBatchExplicitIDs is the per-request ID cap for select_all=false paths. + maxBatchExplicitIDs = 200 + // maxSelectAllMatched is the safety cap for select_all=true after exclude. + maxSelectAllMatched = 10000 +) + +// knowledgeBatchFilter mirrors ListKnowledge query filters for select_all batch ops. +// FolderPath is a pointer so an empty string (knowledge-base root) can be +// distinguished from "no folder filter". +type knowledgeBatchFilter struct { + TagIDs []string `json:"tag_ids,omitempty"` + Keyword string `json:"keyword,omitempty"` + FileType string `json:"file_type,omitempty"` + ParseStatus string `json:"parse_status,omitempty"` + Source string `json:"source,omitempty"` + StartTime string `json:"start_time,omitempty"` + EndTime string `json:"end_time,omitempty"` + FolderPath *string `json:"folder_path,omitempty"` + FolderRecursive bool `json:"folder_recursive,omitempty"` +} + +// knowledgeBatchSelection is the shared select_all / exclude_ids / filter block. +type knowledgeBatchSelection struct { + SelectAll bool `json:"select_all"` + ExcludeIDs []string `json:"exclude_ids,omitempty"` + Filter knowledgeBatchFilter `json:"filter"` +} + +type resolvedBatchIDs struct { + IDs []string + MatchedCount int + ExcludedCount int +} + +func (f knowledgeBatchFilter) toListFilter() (types.KnowledgeListFilter, error) { + out := types.KnowledgeListFilter{ + TagIDs: dedupeKnowledgeIDs(f.TagIDs), + Keyword: f.Keyword, + FileType: f.FileType, + ParseStatus: f.ParseStatus, + Source: f.Source, + } + if f.StartTime != "" { + t, err := parseFilterTime(f.StartTime) + if err != nil { + return out, errors.NewBadRequestError("invalid start_time: " + err.Error()) + } + out.UpdatedFrom = t + } + if f.EndTime != "" { + t, err := parseFilterTime(f.EndTime) + if err != nil { + return out, errors.NewBadRequestError("invalid end_time: " + err.Error()) + } + out.UpdatedTo = t + } + if f.FolderPath != nil { + out.FolderPath = types.NormalizeKnowledgeFolderPath(*f.FolderPath) + out.FolderScope = types.FolderScopeExact + if f.FolderRecursive { + out.FolderScope = types.FolderScopeSubtree + } + } + return out, nil +} + +// resolveBatchKnowledgeIDs resolves the target ID set for batch write APIs. +// +// - select_all=false: explicitIDs are required, capped at maxBatchExplicitIDs, +// and verified to exist in the given knowledge base. +// - select_all=true: IDs are loaded from the DB via Filter, then exclude_ids +// are removed; capped at maxSelectAllMatched. Existence is implied by the +// filter query (no GetKnowledgeBatch round-trip). +func (h *KnowledgeHandler) resolveBatchKnowledgeIDs( + ctx context.Context, + tenantID uint64, + kbID string, + sel knowledgeBatchSelection, + explicitIDs []string, + emptyIDsMessage string, +) (*resolvedBatchIDs, error) { + if emptyIDsMessage == "" { + emptyIDsMessage = "ids cannot be empty" + } + + if !sel.SelectAll { + ids := dedupeKnowledgeIDs(explicitIDs) + if len(ids) == 0 { + return nil, errors.NewBadRequestError(emptyIDsMessage) + } + if len(ids) > maxBatchExplicitIDs { + return nil, errors.NewBadRequestError( + fmt.Sprintf("too many ids (max %d per batch)", maxBatchExplicitIDs)) + } + if err := h.requireKnowledgeInKB(ctx, tenantID, kbID, ids); err != nil { + return nil, err + } + return &resolvedBatchIDs{IDs: ids, MatchedCount: len(ids)}, nil + } + + listFilter, err := sel.Filter.toListFilter() + if err != nil { + return nil, err + } + matched, err := h.kgService.ListKnowledgeIDsByFilter(ctx, kbID, listFilter) + if err != nil { + return nil, errors.NewInternalServerError(err.Error()) + } + + exclude := make(map[string]struct{}, len(sel.ExcludeIDs)) + for _, id := range dedupeKnowledgeIDs(sel.ExcludeIDs) { + exclude[id] = struct{}{} + } + ids := make([]string, 0, len(matched)) + excludedCount := 0 + for _, id := range matched { + if _, ok := exclude[id]; ok { + excludedCount++ + continue + } + ids = append(ids, id) + } + if len(ids) == 0 { + return nil, errors.NewBadRequestError("no knowledge entries matched the selection") + } + if len(ids) > maxSelectAllMatched { + return nil, errors.NewBadRequestError( + fmt.Sprintf("too many matched ids (max %d for select_all); narrow the filter or use clear-contents", + maxSelectAllMatched)) + } + return &resolvedBatchIDs{ + IDs: ids, + MatchedCount: len(matched), + ExcludedCount: excludedCount, + }, nil +} + +func chunkStringIDs(ids []string, size int) [][]string { + if size <= 0 || len(ids) == 0 { + return [][]string{ids} + } + chunks := make([][]string, 0, (len(ids)+size-1)/size) + for i := 0; i < len(ids); i += size { + end := i + size + if end > len(ids) { + end = len(ids) + } + chunks = append(chunks, ids[i:end]) + } + return chunks +} diff --git a/internal/handler/knowledge_batch_selection_test.go b/internal/handler/knowledge_batch_selection_test.go new file mode 100644 index 0000000000..265a3f628c --- /dev/null +++ b/internal/handler/knowledge_batch_selection_test.go @@ -0,0 +1,141 @@ +package handler + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/Tencent/WeKnora/internal/errors" + "github.com/Tencent/WeKnora/internal/types" + "github.com/Tencent/WeKnora/internal/types/interfaces" +) + +type stubResolveKGService struct { + interfaces.KnowledgeService + + filterIDs []string + filterErr error + batchByID map[string]*types.Knowledge + lastFilter types.KnowledgeListFilter +} + +func (s *stubResolveKGService) ListKnowledgeIDsByFilter( + _ context.Context, _ string, filter types.KnowledgeListFilter, +) ([]string, error) { + s.lastFilter = filter + if s.filterErr != nil { + return nil, s.filterErr + } + out := make([]string, len(s.filterIDs)) + copy(out, s.filterIDs) + return out, nil +} + +func (s *stubResolveKGService) GetKnowledgeBatch( + _ context.Context, _ uint64, ids []string, +) ([]*types.Knowledge, error) { + out := make([]*types.Knowledge, 0, len(ids)) + for _, id := range ids { + if k, ok := s.batchByID[id]; ok { + out = append(out, k) + } + } + return out, nil +} + +func TestResolveBatchKnowledgeIDs_ExplicitMode(t *testing.T) { + kg := &stubResolveKGService{ + batchByID: map[string]*types.Knowledge{ + "a": {ID: "a", KnowledgeBaseID: "kb-1"}, + "b": {ID: "b", KnowledgeBaseID: "kb-1"}, + }, + } + h := &KnowledgeHandler{kgService: kg} + ctx := context.Background() + + resolved, err := h.resolveBatchKnowledgeIDs( + ctx, 1, "kb-1", knowledgeBatchSelection{}, []string{"a", "b", "a", " "}, "") + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got := strings.Join(resolved.IDs, ","); got != "a,b" { + t.Fatalf("ids=%q want a,b", got) + } + + tooMany := make([]string, maxBatchExplicitIDs+1) + for i := range tooMany { + tooMany[i] = fmt.Sprintf("id-%d", i) + } + _, err = h.resolveBatchKnowledgeIDs(ctx, 1, "kb-1", knowledgeBatchSelection{}, tooMany, "") + if err == nil { + t.Fatal("expected too-many error") + } + if appErr, ok := errors.IsAppError(err); !ok || appErr.Code != errors.ErrBadRequest { + t.Fatalf("want bad request, got %v", err) + } +} + +func TestResolveBatchKnowledgeIDs_SelectAllExclude(t *testing.T) { + kg := &stubResolveKGService{filterIDs: []string{"a", "b", "c"}} + h := &KnowledgeHandler{kgService: kg} + ctx := context.Background() + + folder := "docs" + resolved, err := h.resolveBatchKnowledgeIDs(ctx, 1, "kb-1", knowledgeBatchSelection{ + SelectAll: true, + ExcludeIDs: []string{"b", "b"}, + Filter: knowledgeBatchFilter{ + Keyword: "x", + FolderPath: &folder, + FolderRecursive: true, + }, + }, nil, "") + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got := strings.Join(resolved.IDs, ","); got != "a,c" { + t.Fatalf("ids=%q want a,c", got) + } + if resolved.MatchedCount != 3 || resolved.ExcludedCount != 1 { + t.Fatalf("matched=%d excluded=%d", resolved.MatchedCount, resolved.ExcludedCount) + } + if kg.lastFilter.Keyword != "x" || kg.lastFilter.FolderScope != types.FolderScopeSubtree { + t.Fatalf("filter not applied: %+v", kg.lastFilter) + } + + _, err = h.resolveBatchKnowledgeIDs(ctx, 1, "kb-1", knowledgeBatchSelection{ + SelectAll: true, + ExcludeIDs: []string{"a", "b", "c"}, + }, nil, "") + if err == nil { + t.Fatal("expected empty-match error") + } +} + +func TestResolveBatchKnowledgeIDs_SelectAllCap(t *testing.T) { + ids := make([]string, maxSelectAllMatched+1) + for i := range ids { + ids[i] = fmt.Sprintf("id-%d", i) + } + h := &KnowledgeHandler{kgService: &stubResolveKGService{filterIDs: ids}} + _, err := h.resolveBatchKnowledgeIDs(context.Background(), 1, "kb-1", knowledgeBatchSelection{ + SelectAll: true, + }, nil, "") + if err == nil { + t.Fatal("expected maxSelectAll error") + } + if appErr, ok := errors.IsAppError(err); !ok || !strings.Contains(appErr.Message, "select_all") { + t.Fatalf("want select_all cap error, got %v", err) + } +} + +func TestChunkStringIDs(t *testing.T) { + chunks := chunkStringIDs([]string{"a", "b", "c", "d", "e"}, 2) + if len(chunks) != 3 { + t.Fatalf("len=%d want 3", len(chunks)) + } + if strings.Join(chunks[2], ",") != "e" { + t.Fatalf("last chunk=%v", chunks[2]) + } +} diff --git a/internal/router/routes_knowledge.go b/internal/router/routes_knowledge.go index dc11aa4d98..cb13119947 100644 --- a/internal/router/routes_knowledge.go +++ b/internal/router/routes_knowledge.go @@ -128,6 +128,7 @@ func RegisterKnowledgeRoutes(r *gin.RouterGroup, handler *handler.KnowledgeHandl k.PUT("/tags", g.Contributor(), handler.UpdateKnowledgeTagBatch) k.POST("/batch-reparse", g.Contributor(), handler.BatchReparseKnowledge) k.POST("/batch-delete", g.Contributor(), handler.BatchDeleteKnowledge) + k.POST("/batch-cancel-parse", g.Contributor(), handler.BatchCancelKnowledgeParse) k.POST("/folder", g.Contributor(), handler.MoveKnowledgeToFolder) k.POST("/move", g.Contributor(), handler.MoveKnowledge) } diff --git a/internal/types/interfaces/knowledge.go b/internal/types/interfaces/knowledge.go index 064002e6f9..56d42ace60 100644 --- a/internal/types/interfaces/knowledge.go +++ b/internal/types/interfaces/knowledge.go @@ -84,6 +84,10 @@ type KnowledgeService interface { page *types.Pagination, filter types.KnowledgeListFilter, ) (*types.PageResult, error) + // ListKnowledgeIDsByFilter returns knowledge IDs under a knowledge base that + // match the same filter semantics as ListPagedKnowledgeByKnowledgeBaseID, + // without pagination. Used by select_all batch operations. + ListKnowledgeIDsByFilter(ctx context.Context, kbID string, filter types.KnowledgeListFilter) ([]string, error) // ListKnowledgeFolderTree returns the folder hierarchy derived from the // folder_path of every knowledge entry in a knowledge base, with per-folder // document counts. It powers the document sidebar tree. @@ -135,6 +139,10 @@ type KnowledgeService interface { // is already cancelled. Returns an error when the knowledge is in a // terminal state (completed / failed) or being deleted. CancelKnowledgeParse(ctx context.Context, knowledgeID string) (*types.Knowledge, error) + // CancelKnowledgeParseBatch cancels parse for many knowledge IDs. Per-item + // failures (e.g. already completed) are counted as skipped/failed and do + // not abort the whole batch. + CancelKnowledgeParseBatch(ctx context.Context, knowledgeIDs []string) (*types.BatchCancelParseResult, error) // CloneKnowledgeBase clones knowledge to another knowledge base. CloneKnowledgeBase(ctx context.Context, srcID, dstID string) error // UpdateImageInfo updates image information for a knowledge chunk. @@ -240,6 +248,9 @@ type KnowledgeRepository interface { ListPagedKnowledgeByKnowledgeBaseID(ctx context.Context, tenantID uint64, kbID string, page *types.Pagination, filter types.KnowledgeListFilter, ) ([]*types.Knowledge, int64, error) + // ListKnowledgeIDsByFilter returns only knowledge IDs matching the filter + // (same dimensions as ListPagedKnowledgeByKnowledgeBaseID, no pagination). + ListKnowledgeIDsByFilter(ctx context.Context, tenantID uint64, kbID string, filter types.KnowledgeListFilter) ([]string, error) UpdateKnowledge(ctx context.Context, knowledge *types.Knowledge) error // UpdateKnowledgeBatch updates knowledge items in batch UpdateKnowledgeBatch(ctx context.Context, knowledgeList []*types.Knowledge) error diff --git a/internal/types/knowledge.go b/internal/types/knowledge.go index feb69d3884..b5106f3ac1 100644 --- a/internal/types/knowledge.go +++ b/internal/types/knowledge.go @@ -118,6 +118,13 @@ type KnowledgeListFilter struct { FolderScope KnowledgeFolderScope } +// BatchCancelParseResult summarizes a batch cancel-parse run. +type BatchCancelParseResult struct { + Cancelled int `json:"cancelled"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` +} + // Knowledge represents a knowledge entity in the system. // It contains metadata about the knowledge source, its processing status, // and references to the physical file if applicable.