fix(knowledge): harden batch reparse UX and async processing
Move batch reparse work to an asynq task to avoid HTTP timeouts, add i18n for all locales, guard in-flight documents, and prevent concurrent delete/reparse.
This commit is contained in:
@@ -482,10 +482,10 @@ export function knowledgeSemanticSearch(data: {
|
||||
return post('/api/v1/knowledge-search', data);
|
||||
}
|
||||
|
||||
export function batchReparseKnowledge(kbId: string, ids: string[], processConfig?: any) {
|
||||
export function batchReparseKnowledge(kbId: string, ids: string[], processConfig?: KnowledgeProcessOverrides) {
|
||||
return post(`/api/v1/knowledge/batch-reparse`, {
|
||||
kb_id: kbId,
|
||||
ids,
|
||||
process_config: processConfig
|
||||
process_config: processConfig,
|
||||
});
|
||||
}
|
||||
@@ -575,6 +575,11 @@ export default {
|
||||
confirmBatchDeleteDocument: 'Delete {count} selected documents? This action cannot be undone.',
|
||||
batchDeleteSuccess: 'Deleted {count} documents',
|
||||
batchDeleteFailed: 'Batch delete failed',
|
||||
confirmBatchReparseDocument: 'Rebuild {count} selected documents? Existing content will be cleared and each document will be re-parsed.',
|
||||
confirmBatchReparse: 'Confirm and reparse',
|
||||
batchReparseSuccess: 'Submitted {count} rebuild tasks',
|
||||
batchReparseFailed: 'Batch rebuild failed',
|
||||
batchReparseSkippedInFlight: 'Skipped {count} document(s) still being parsed',
|
||||
statusCompleted: 'Completed',
|
||||
statusProcessing: 'Processing',
|
||||
statusFinalizing: 'Optimizing',
|
||||
|
||||
@@ -578,6 +578,11 @@ export default {
|
||||
confirmBatchDeleteDocument: "선택한 {count}개 문서를 삭제하시겠습니까? 삭제 후 복구할 수 없습니다.",
|
||||
batchDeleteSuccess: "{count}개 문서가 삭제되었습니다",
|
||||
batchDeleteFailed: "일괄 삭제 실패",
|
||||
confirmBatchReparseDocument: "선택한 {count}개 문서를 재구축하시겠습니까? 기존 내용이 삭제되고 각 문서가 다시 파싱됩니다.",
|
||||
confirmBatchReparse: "확인 후 재파싱",
|
||||
batchReparseSuccess: "재구축 작업 {count}개가 제출되었습니다",
|
||||
batchReparseFailed: "일괄 재구축 실패",
|
||||
batchReparseSkippedInFlight: "파싱 중인 문서 {count}개를 건너뛰었습니다",
|
||||
statusCompleted: "완료",
|
||||
statusProcessing: "처리 중",
|
||||
statusFinalizing: "최적화 중",
|
||||
|
||||
@@ -500,6 +500,11 @@ export default {
|
||||
confirmBatchDeleteDocument: 'Удалить {count} выбранных документов? Это действие нельзя отменить.',
|
||||
batchDeleteSuccess: 'Удалено документов: {count}',
|
||||
batchDeleteFailed: 'Ошибка пакетного удаления',
|
||||
confirmBatchReparseDocument: 'Пересобрать {count} выбранных документов? Существующее содержимое будет удалено, и каждый документ будет обработан заново.',
|
||||
confirmBatchReparse: 'Подтвердить и обработать заново',
|
||||
batchReparseSuccess: 'Отправлено задач пересборки: {count}',
|
||||
batchReparseFailed: 'Ошибка пакетной пересборки',
|
||||
batchReparseSkippedInFlight: 'Пропущено документов, которые ещё обрабатываются: {count}',
|
||||
statusCompleted: 'Завершено',
|
||||
statusProcessing: 'Обработка',
|
||||
statusFinalizing: 'Оптимизация',
|
||||
|
||||
@@ -576,6 +576,11 @@ export default {
|
||||
confirmBatchDeleteDocument: "确认删除选中的 {count} 个文档?删除后将无法恢复。",
|
||||
batchDeleteSuccess: "成功删除 {count} 个文档",
|
||||
batchDeleteFailed: "批量删除失败",
|
||||
confirmBatchReparseDocument: "确认重建选中的 {count} 个文档?现有内容将被清除并重新解析。",
|
||||
confirmBatchReparse: "确认并重新解析",
|
||||
batchReparseSuccess: "已提交 {count} 个重建任务",
|
||||
batchReparseFailed: "批量重建失败",
|
||||
batchReparseSkippedInFlight: "已跳过 {count} 个正在解析中的文档",
|
||||
statusCompleted: "已完成",
|
||||
statusProcessing: "解析中",
|
||||
statusFinalizing: "优化中",
|
||||
|
||||
@@ -61,7 +61,7 @@ import { formatFileSize } from '@/utils/files';
|
||||
import { useMarqueeSelect } from '@/hooks/useMarqueeSelect';
|
||||
import type { ParserEngineInfo } from '@/api/system';
|
||||
const route = useRoute();
|
||||
const { t, te } = useI18n();
|
||||
const { t } = useI18n();
|
||||
const kbId = computed(() => (route.params as any).kbId as string || '');
|
||||
const kbInfo = ref<any>(null);
|
||||
const uploadSourceRef = ref<InstanceType<typeof KbUploadSourceDropdown> | null>(null);
|
||||
@@ -419,28 +419,34 @@ const batchDeleting = ref(false);
|
||||
const batchReparsing = ref(false);
|
||||
|
||||
const confirmBatchReparse = async () => {
|
||||
if (batchReparsing.value || selectedIds.value.size === 0) return;
|
||||
const ids = Array.from(selectedIds.value);
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
if (res.partial && res.failed_ids && res.failed_ids.length > 0) {
|
||||
MessagePlugin.warning(`部分重构任务提交成功,失败 ${res.failed_ids.length} 个`);
|
||||
} else {
|
||||
const successMsg = te('knowledgeBase.batchReparseSuccess')
|
||||
? t('knowledgeBase.batchReparseSuccess', { count: ids.length })
|
||||
: `已提交 ${ids.length} 个重构任务`;
|
||||
MessagePlugin.success(successMsg);
|
||||
}
|
||||
MessagePlugin.success(t('knowledgeBase.batchReparseSuccess', { count: ids.length }));
|
||||
clearSelection();
|
||||
batchMode.value = false;
|
||||
loadKnowledgeFiles(kbId.value);
|
||||
scheduleWikiStatusProbes();
|
||||
} else {
|
||||
MessagePlugin.error(res?.message || '批量重建失败');
|
||||
MessagePlugin.error(res?.message || t('knowledgeBase.batchReparseFailed'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.message || '批量重建失败');
|
||||
MessagePlugin.error(e?.message || t('knowledgeBase.batchReparseFailed'));
|
||||
} finally {
|
||||
batchReparsing.value = false;
|
||||
}
|
||||
@@ -1897,7 +1903,7 @@ const onCardClick = (item: KnowledgeCard) => {
|
||||
};
|
||||
|
||||
const confirmBatchDelete = async () => {
|
||||
if (batchDeleting.value || selectedIds.value.size === 0) return;
|
||||
if (batchDeleting.value || batchReparsing.value || selectedIds.value.size === 0) return;
|
||||
const ids = Array.from(selectedIds.value);
|
||||
const deletedIdSet = new Set(ids);
|
||||
batchDeleting.value = true;
|
||||
@@ -2149,8 +2155,7 @@ async function createNewSession(value: string): Promise<void> {
|
||||
<span class="tag-hash-icon">#</span>
|
||||
<div class="tag-edit-input">
|
||||
<t-input ref="newTagInputRef" v-model="newTagName" size="small" :maxlength="40"
|
||||
:placeholder="$t('knowledgeBase.tagNamePlaceholder')"
|
||||
@enter="submitCreateTag"
|
||||
:placeholder="$t('knowledgeBase.tagNamePlaceholder')" @enter="submitCreateTag"
|
||||
@keydown="(_v, ctx) => { if (ctx?.e?.key === 'Escape') { ctx.e.stopPropagation(); ctx.e.preventDefault(); cancelCreateTag() } }" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -2267,31 +2272,19 @@ async function createNewSession(value: string): Promise<void> {
|
||||
</t-tooltip>
|
||||
</div>
|
||||
<div v-if="canEdit" class="doc-filter-actions">
|
||||
<KbUploadSourceDropdown
|
||||
ref="uploadSourceRef"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
:supported-file-types="[...supportedFileTypes]"
|
||||
include-manual
|
||||
trigger-icon="file-add"
|
||||
trigger-class="content-bar-icon-btn"
|
||||
data-guide="kb-detail-add-doc"
|
||||
:tooltip="t('knowledgeBase.addDocument')"
|
||||
placement="bottom-right"
|
||||
@files="handleUploadSourceFiles"
|
||||
@url="handleUploadSourceUrl"
|
||||
@manual="handleManualCreate"
|
||||
/>
|
||||
<KbUploadSourceDropdown ref="uploadSourceRef" :accept-file-types="acceptFileTypes"
|
||||
:supported-file-types="[...supportedFileTypes]" include-manual trigger-icon="file-add"
|
||||
trigger-class="content-bar-icon-btn" data-guide="kb-detail-add-doc"
|
||||
:tooltip="t('knowledgeBase.addDocument')" placement="bottom-right" @files="handleUploadSourceFiles"
|
||||
@url="handleUploadSourceUrl" @manual="handleManualCreate" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-scroll-container" :class="{ 'is-empty': !cardList.length && !docListLoading, 'is-marquee-active': docMarqueeVisible }" ref="knowledgeScroll"
|
||||
@scroll="handleScroll" @mousedown="onDocMarqueeMouseDown">
|
||||
<div
|
||||
v-if="docMarqueeVisible"
|
||||
class="doc-marquee-box"
|
||||
<div class="doc-scroll-container"
|
||||
:class="{ 'is-empty': !cardList.length && !docListLoading, 'is-marquee-active': docMarqueeVisible }"
|
||||
ref="knowledgeScroll" @scroll="handleScroll" @mousedown="onDocMarqueeMouseDown">
|
||||
<div v-if="docMarqueeVisible" class="doc-marquee-box"
|
||||
:class="{ 'is-add': docMarqueeMode === 'add', 'is-subtract': docMarqueeMode === 'subtract' }"
|
||||
:style="docMarqueeBoxStyle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
:style="docMarqueeBoxStyle" aria-hidden="true" />
|
||||
<!-- 文档骨架屏 -->
|
||||
<div v-if="docListLoading && cardList.length === 0" class="doc-card-list doc-card-list-animated">
|
||||
<div v-for="n in 8" :key="'doc-skel-' + n" class="knowledge-card knowledge-card-skeleton">
|
||||
@@ -2313,10 +2306,9 @@ async function createNewSession(value: string): Promise<void> {
|
||||
<!-- 现有文档卡片 -->
|
||||
<div class="knowledge-card"
|
||||
:class="{ 'is-selected': selectedIds.has(item.id), 'batch-mode': batchMode }"
|
||||
:data-select-id="item.id"
|
||||
v-for="(item, index) in cardList" :key="item.id" @click="onCardClick(item)"
|
||||
@mouseenter="onCardMouseEnter($event, item)" @mousemove="onCardMouseMove($event)"
|
||||
@mouseleave="onCardMouseLeave">
|
||||
:data-select-id="item.id" v-for="(item, index) in cardList" :key="item.id"
|
||||
@click="onCardClick(item)" @mouseenter="onCardMouseEnter($event, item)"
|
||||
@mousemove="onCardMouseMove($event)" @mouseleave="onCardMouseLeave">
|
||||
<div class="card-content">
|
||||
<div class="card-content-nav">
|
||||
<div v-if="canEdit && batchMode" class="card-nav-check" @click.stop>
|
||||
@@ -2327,8 +2319,7 @@ async function createNewSession(value: string): Promise<void> {
|
||||
<span class="card-content-title" :title="item.file_name">{{ item.file_name }}</span>
|
||||
<t-popup v-if="canEdit" v-model="item.isMore" overlayClassName="card-more"
|
||||
:on-visible-change="(v: boolean) => onCardMoreVisibleChange(v, item)" trigger="click"
|
||||
destroy-on-close
|
||||
placement="bottom-right">
|
||||
destroy-on-close placement="bottom-right">
|
||||
<div variant="outline" class="more-wrap" @click.stop="openMore(index)"
|
||||
:class="[moreIndex == index ? 'active-more' : '']">
|
||||
<img class="more-icon" src="@/assets/img/more.png" alt="" />
|
||||
@@ -2346,31 +2337,20 @@ async function createNewSession(value: string): Promise<void> {
|
||||
<t-icon class="icon" name="chart-bar" />
|
||||
<span>{{ t('knowledgeStages.viewTrace') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isParseInFlight(item.parse_status)"
|
||||
class="card-menu-item"
|
||||
@click.stop="onReparseMenuClick(index, item)"
|
||||
>
|
||||
<div v-if="isParseInFlight(item.parse_status)" class="card-menu-item"
|
||||
@click.stop="onReparseMenuClick(index, item)">
|
||||
<t-icon class="icon" name="refresh" />
|
||||
<span>{{ t('knowledgeBase.rebuildDocument') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="card-menu-item"
|
||||
@click.stop="confirmRebuildKnowledge(index, item)"
|
||||
>
|
||||
<div v-else class="card-menu-item" @click.stop="confirmRebuildKnowledge(index, item)">
|
||||
<t-icon class="icon" name="refresh" />
|
||||
<span>{{ t('knowledgeBase.rebuildDocument') }}</span>
|
||||
</div>
|
||||
<t-popconfirm
|
||||
v-if="isParseInFlight(item.parse_status)"
|
||||
theme="warning"
|
||||
<t-popconfirm v-if="isParseInFlight(item.parse_status)" theme="warning"
|
||||
:content="t('knowledgeBase.cancelParseConfirmBody', { title: item.file_name || item.title || item.id })"
|
||||
:confirm-btn="{ content: t('knowledgeBase.cancelParse'), theme: 'danger' }"
|
||||
:cancel-btn="{ content: t('common.cancel') }"
|
||||
placement="left"
|
||||
@confirm="confirmCancelParseKnowledge(item)"
|
||||
>
|
||||
:cancel-btn="{ content: t('common.cancel') }" placement="left"
|
||||
@confirm="confirmCancelParseKnowledge(item)">
|
||||
<div class="card-menu-item danger" @click.stop>
|
||||
<t-icon class="icon" name="close-circle" />
|
||||
<span>{{ t('knowledgeBase.cancelParse') }}</span>
|
||||
@@ -2386,14 +2366,11 @@ async function createNewSession(value: string): Promise<void> {
|
||||
<t-icon class="icon" name="queue" />
|
||||
<span>{{ t('menu.batchManage') }}</span>
|
||||
</div>
|
||||
<t-popconfirm
|
||||
theme="warning"
|
||||
<t-popconfirm theme="warning"
|
||||
:content="t('knowledgeBase.confirmDeleteDocument', { fileName: item.file_name || '' })"
|
||||
:confirm-btn="{ content: t('knowledgeBase.confirmDelete'), theme: 'danger' }"
|
||||
:cancel-btn="{ content: t('common.cancel') }"
|
||||
placement="left"
|
||||
@confirm="confirmDeleteKnowledge(index, item)"
|
||||
>
|
||||
:cancel-btn="{ content: t('common.cancel') }" placement="left"
|
||||
@confirm="confirmDeleteKnowledge(index, item)">
|
||||
<div class="card-menu-item danger" @click.stop>
|
||||
<t-icon class="icon" name="delete" />
|
||||
<span>{{ t('knowledgeBase.deleteDocument') }}</span>
|
||||
@@ -2441,7 +2418,7 @@ async function createNewSession(value: string): Promise<void> {
|
||||
<div class="move-mode-text">
|
||||
<span class="move-mode-label">{{ t('knowledgeBase.moveModeReuseVectors') }}</span>
|
||||
<span class="move-mode-desc">{{ t('knowledgeBase.moveModeReuseVectorsDesc')
|
||||
}}</span>
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="move-mode-item" :class="{ active: moveMode === 'reparse' }"
|
||||
@@ -2464,57 +2441,33 @@ async function createNewSession(value: string): Promise<void> {
|
||||
</template>
|
||||
</t-popup>
|
||||
</div>
|
||||
<div
|
||||
v-if="isParseInFlight(item.parse_status)"
|
||||
class="card-analyze card-analyze-trace"
|
||||
>
|
||||
<div v-if="isParseInFlight(item.parse_status)" class="card-analyze card-analyze-trace">
|
||||
<t-icon name="loading" class="card-analyze-loading"></t-icon>
|
||||
<span
|
||||
class="card-analyze-txt card-analyze-trace-link"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="t('knowledgeStages.viewTrace')"
|
||||
@click.stop="handleViewTrace(index, item)"
|
||||
<span class="card-analyze-txt card-analyze-trace-link" role="button" tabindex="0"
|
||||
:title="t('knowledgeStages.viewTrace')" @click.stop="handleViewTrace(index, item)"
|
||||
@keydown.enter.stop="handleViewTrace(index, item)"
|
||||
@keydown.space.prevent.stop="handleViewTrace(index, item)"
|
||||
>{{ inFlightCardStatusText(item) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="card-analyze-trace-btn"
|
||||
:title="t('knowledgeStages.viewTrace')"
|
||||
:aria-label="t('knowledgeStages.viewTrace')"
|
||||
@click.stop="handleViewTrace(index, item)"
|
||||
>
|
||||
@keydown.space.prevent.stop="handleViewTrace(index, item)">{{
|
||||
inFlightCardStatusText(item) }}</span>
|
||||
<button type="button" class="card-analyze-trace-btn" :title="t('knowledgeStages.viewTrace')"
|
||||
:aria-label="t('knowledgeStages.viewTrace')" @click.stop="handleViewTrace(index, item)">
|
||||
<t-icon name="chart-line" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.parse_status === 'failed'"
|
||||
class="card-analyze failure card-analyze-trace"
|
||||
>
|
||||
<div v-else-if="item.parse_status === 'failed'" class="card-analyze failure card-analyze-trace">
|
||||
<t-icon name="close-circle" class="card-analyze-loading failure"></t-icon>
|
||||
<span
|
||||
class="card-analyze-txt failure card-analyze-trace-link"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="t('knowledgeStages.viewTrace')"
|
||||
@click.stop="handleViewTrace(index, item)"
|
||||
<span class="card-analyze-txt failure card-analyze-trace-link" role="button" tabindex="0"
|
||||
:title="t('knowledgeStages.viewTrace')" @click.stop="handleViewTrace(index, item)"
|
||||
@keydown.enter.stop="handleViewTrace(index, item)"
|
||||
@keydown.space.prevent.stop="handleViewTrace(index, item)"
|
||||
>{{ t('knowledgeBase.parsingFailed') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="card-analyze-trace-btn"
|
||||
:title="t('knowledgeStages.viewTrace')"
|
||||
:aria-label="t('knowledgeStages.viewTrace')"
|
||||
@click.stop="handleViewTrace(index, item)"
|
||||
>
|
||||
@keydown.space.prevent.stop="handleViewTrace(index, item)">{{
|
||||
t('knowledgeBase.parsingFailed') }}</span>
|
||||
<button type="button" class="card-analyze-trace-btn" :title="t('knowledgeStages.viewTrace')"
|
||||
:aria-label="t('knowledgeStages.viewTrace')" @click.stop="handleViewTrace(index, item)">
|
||||
<t-icon name="chart-bar" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="item.parse_status === 'draft'" class="card-draft">
|
||||
<t-tag size="small" theme="warning" variant="light-outline">{{ t('knowledgeBase.draft')
|
||||
}}</t-tag>
|
||||
}}</t-tag>
|
||||
<span class="card-draft-tip">{{ t('knowledgeBase.draftTip') }}</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -2578,9 +2531,7 @@ async function createNewSession(value: string): Promise<void> {
|
||||
:style="{ left: cardPopoverPos.x + 'px', top: cardPopoverPos.y + 'px' }">
|
||||
<template v-if="hoveredCardItem">
|
||||
<div class="card-popover-title">{{ hoveredCardItem.file_name }}</div>
|
||||
<div
|
||||
v-if="isParseInFlight(hoveredCardItem.parse_status)"
|
||||
class="card-popover-status parsing">
|
||||
<div v-if="isParseInFlight(hoveredCardItem.parse_status)" class="card-popover-status parsing">
|
||||
<KnowledgeProcessingTimeline :knowledge-id="hoveredCardItem.id"
|
||||
:parse-status="hoveredCardItem.parse_status" :auto-poll="false" :compact="true" />
|
||||
</div>
|
||||
@@ -2611,12 +2562,12 @@ async function createNewSession(value: string): Promise<void> {
|
||||
<div class="card-popover-meta">
|
||||
<span class="card-popover-time">{{ t('knowledgeBase.updatedAt') }}:{{
|
||||
formatDocTime(hoveredCardItem.updated_at)
|
||||
}}</span>
|
||||
}}</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>
|
||||
<span class="card-popover-type">{{ getKnowledgeType(hoveredCardItem) }}</span>
|
||||
</div>
|
||||
<div class="card-popover-hint">{{ t('knowledgeBase.clickToViewFull') }}</div>
|
||||
@@ -2637,9 +2588,9 @@ async function createNewSession(value: string): Promise<void> {
|
||||
</template>
|
||||
</div>
|
||||
<div class="doc-batch-bar-anchor" v-show="batchMode || selectedIds.size > 0">
|
||||
<DocumentBatchBar :count="selectedIds.size" :loading="batchDeleting || batchReparsing"
|
||||
:visible="batchMode || selectedIds.size > 0" @cancel="handleBatchCancel"
|
||||
@delete="confirmBatchDelete" @reparse="confirmBatchReparse" />
|
||||
<DocumentBatchBar :count="selectedIds.size" :delete-loading="batchDeleting"
|
||||
:reparse-loading="batchReparsing" :visible="batchMode || selectedIds.size > 0"
|
||||
@cancel="handleBatchCancel" @delete="confirmBatchDelete" @reparse="confirmBatchReparse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps<{
|
||||
count: number;
|
||||
loading?: boolean;
|
||||
deleteLoading?: boolean;
|
||||
reparseLoading?: boolean;
|
||||
// When true the bar stays visible even with 0 selections, so users can exit
|
||||
// batch mode from here without selecting anything first.
|
||||
visible?: boolean;
|
||||
@@ -15,68 +16,36 @@ const emit = defineEmits<{
|
||||
(e: 'reparse'): void;
|
||||
}>();
|
||||
|
||||
const { t, te } = useI18n();
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition name="batch-bar-fade">
|
||||
<div
|
||||
v-if="visible || count > 0"
|
||||
class="doc-batch-bar"
|
||||
role="region"
|
||||
:aria-label="t('knowledgeBase.selectedCount', { count })"
|
||||
>
|
||||
<div v-if="visible || count > 0" class="doc-batch-bar" role="region"
|
||||
:aria-label="t('knowledgeBase.selectedCount', { count })">
|
||||
<div class="batch-bar-inner">
|
||||
<div class="batch-bar-left">
|
||||
<span class="batch-bar-count">{{ t('knowledgeBase.selectedCount', { count }) }}</span>
|
||||
<t-button
|
||||
variant="text"
|
||||
theme="default"
|
||||
size="small"
|
||||
class="batch-bar-clear"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
<t-button variant="text" theme="default" size="small" class="batch-bar-clear" @click="emit('cancel')">
|
||||
{{ t('knowledgeBase.clearSelection') }}
|
||||
</t-button>
|
||||
</div>
|
||||
<div class="batch-bar-actions">
|
||||
<t-popconfirm
|
||||
theme="warning"
|
||||
:content="te('knowledgeBase.overviewDescReparse') ? t('knowledgeBase.overviewDescReparse') : '确认后现有内容将被清除并重新解析该文档'"
|
||||
:confirm-btn="{ content: te('knowledgeBase.confirmReparse') ? t('knowledgeBase.confirmReparse') : '确认并重新解析', theme: 'warning' }"
|
||||
:cancel-btn="{ content: t('common.cancel') }"
|
||||
placement="top"
|
||||
@confirm="emit('reparse')"
|
||||
>
|
||||
<t-button
|
||||
theme="default"
|
||||
variant="outline"
|
||||
size="small"
|
||||
:disabled="count === 0"
|
||||
:loading="loading"
|
||||
@click.stop
|
||||
>
|
||||
<t-popconfirm theme="warning" :content="t('knowledgeBase.confirmBatchReparseDocument', { count })"
|
||||
:confirm-btn="{ content: t('knowledgeBase.confirmBatchReparse'), theme: 'warning' }"
|
||||
:cancel-btn="{ content: t('common.cancel') }" placement="top" @confirm="emit('reparse')">
|
||||
<t-button theme="default" variant="outline" size="small"
|
||||
:disabled="count === 0 || deleteLoading || reparseLoading" :loading="reparseLoading" @click.stop>
|
||||
<template #icon><t-icon name="refresh" size="14px" /></template>
|
||||
{{ t('knowledgeBase.rebuildDocument') }}
|
||||
</t-button>
|
||||
</t-popconfirm>
|
||||
|
||||
<t-popconfirm
|
||||
theme="warning"
|
||||
:content="t('knowledgeBase.confirmBatchDeleteDocument', { count })"
|
||||
<t-popconfirm theme="warning" :content="t('knowledgeBase.confirmBatchDeleteDocument', { count })"
|
||||
:confirm-btn="{ content: t('knowledgeBase.confirmDelete'), theme: 'danger' }"
|
||||
:cancel-btn="{ content: t('common.cancel') }"
|
||||
placement="top"
|
||||
@confirm="emit('delete')"
|
||||
>
|
||||
<t-button
|
||||
theme="danger"
|
||||
variant="outline"
|
||||
size="small"
|
||||
:disabled="count === 0"
|
||||
:loading="loading"
|
||||
@click.stop
|
||||
>
|
||||
:cancel-btn="{ content: t('common.cancel') }" placement="top" @confirm="emit('delete')">
|
||||
<t-button theme="danger" variant="outline" size="small"
|
||||
:disabled="count === 0 || deleteLoading || reparseLoading" :loading="deleteLoading" @click.stop>
|
||||
<template #icon><t-icon name="delete" size="14px" /></template>
|
||||
{{ t('knowledgeBase.batchDelete') }}
|
||||
</t-button>
|
||||
|
||||
@@ -3377,3 +3377,38 @@ func (s *knowledgeService) enqueueImageMultimodalTasks(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessKnowledgeListReparse handles Asynq knowledge list reparse tasks.
|
||||
func (s *knowledgeService) ProcessKnowledgeListReparse(ctx context.Context, t *asynq.Task) error {
|
||||
var payload types.KnowledgeListReparsePayload
|
||||
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
|
||||
logger.Errorf(ctx, "Failed to unmarshal knowledge list reparse payload: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "Processing knowledge list reparse task for %d knowledge items", len(payload.KnowledgeIDs))
|
||||
|
||||
tenant, err := s.tenantRepo.GetTenantByID(ctx, payload.TenantID)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to get tenant %d: %v", payload.TenantID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, types.TenantIDContextKey, payload.TenantID)
|
||||
ctx = context.WithValue(ctx, types.TenantInfoContextKey, tenant)
|
||||
|
||||
var failed int
|
||||
for _, id := range payload.KnowledgeIDs {
|
||||
if _, err := s.ReparseKnowledge(ctx, id, payload.ProcessConfig); err != nil {
|
||||
logger.Errorf(ctx, "Failed to reparse knowledge %s: %v", id, err)
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
logger.Warnf(ctx, "Knowledge list reparse completed with %d failures out of %d", failed, len(payload.KnowledgeIDs))
|
||||
}
|
||||
logger.Infof(ctx, "Knowledge list reparse task finished: %d submitted, %d failed",
|
||||
len(payload.KnowledgeIDs)-failed, failed)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -226,6 +226,30 @@ func (h *KnowledgeHandler) enqueueKnowledgeListDelete(
|
||||
return info.ID, nil
|
||||
}
|
||||
|
||||
// enqueueKnowledgeListReparse enqueues an async batch-reparse task for the
|
||||
// given knowledge IDs and returns the asynq task ID.
|
||||
func (h *KnowledgeHandler) enqueueKnowledgeListReparse(
|
||||
ctx context.Context, tenantID uint64, ids []string, processConfig *types.KnowledgeProcessOverrides,
|
||||
) (string, error) {
|
||||
payload := types.KnowledgeListReparsePayload{
|
||||
TenantID: tenantID,
|
||||
KnowledgeIDs: ids,
|
||||
ProcessConfig: processConfig,
|
||||
}
|
||||
langfuse.InjectTracing(ctx, &payload)
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
task := asynq.NewTask(types.TypeKnowledgeListReparse, payloadBytes,
|
||||
asynq.Queue("low"), asynq.MaxRetry(3))
|
||||
info, err := h.asynqClient.Enqueue(task)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("enqueue task: %w", err)
|
||||
}
|
||||
return info.ID, nil
|
||||
}
|
||||
|
||||
// CreateKnowledgeFromFile godoc
|
||||
// @Summary 从文件创建知识
|
||||
// @Description 上传文件并创建知识条目
|
||||
@@ -2258,27 +2282,31 @@ func (h *KnowledgeHandler) BatchReparseKnowledge(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var ids []string
|
||||
seen := make(map[string]struct{})
|
||||
for _, id := range req.IDs {
|
||||
if id != "" {
|
||||
if _, ok := seen[id]; !ok {
|
||||
ids = append(ids, id)
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
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
|
||||
}
|
||||
if len(ids) > 200 {
|
||||
c.Error(errors.NewBadRequestError("the number of IDs exceeds the maximum batch size of 200"))
|
||||
const maxBatch = 200
|
||||
if len(ids) > maxBatch {
|
||||
c.Error(errors.NewBadRequestError(fmt.Sprintf("too many ids (max %d per batch)", maxBatch)))
|
||||
return
|
||||
}
|
||||
|
||||
_, _, effectiveTenantID, permission, err := h.validateKnowledgeBaseAccessWithKBID(c, req.KBID)
|
||||
_, kbID, effectiveTenantID, permission, err := h.validateKnowledgeBaseAccessWithKBID(c, req.KBID)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
@@ -2288,45 +2316,42 @@ func (h *KnowledgeHandler) BatchReparseKnowledge(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID)
|
||||
knows, err := h.kgService.GetKnowledgeBatch(ctx, effectiveTenantID, ids)
|
||||
|
||||
knowledgeList, err := h.kgService.GetKnowledgeBatch(ctx, effectiveTenantID, ids)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "failed to get knowledge batch, kb_id: %s, size: %d, err: %v", req.KBID, len(ids), err)
|
||||
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(knows) != len(ids) {
|
||||
if len(knowledgeList) != len(ids) {
|
||||
c.Error(errors.NewBadRequestError("some knowledge entries were not found"))
|
||||
return
|
||||
}
|
||||
|
||||
for _, know := range knows {
|
||||
if know.KnowledgeBaseID != req.KBID {
|
||||
c.Error(errors.NewBadRequestError("some knowledge entries do not belong to the specified knowledge base"))
|
||||
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
|
||||
}
|
||||
}
|
||||
var failedIDs []string
|
||||
for _, id := range ids {
|
||||
_, err := h.kgService.ReparseKnowledge(ctx, id, req.ProcessConfig)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to reparse knowledge %s: %v", id, err)
|
||||
failedIDs = append(failedIDs, id)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if len(failedIDs) > 0 {
|
||||
c.JSON(200, gin.H{
|
||||
"success": len(failedIDs) < len(ids),
|
||||
"partial": len(failedIDs) < len(ids),
|
||||
"message": "Batch reparse completed with some failures",
|
||||
"failed_ids": failedIDs,
|
||||
})
|
||||
taskID, err := h.enqueueKnowledgeListReparse(ctx, effectiveTenantID, 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
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
logger.Infof(ctx, "Batch knowledge reparse task enqueued: %s, kb_id: %s, count: %d",
|
||||
taskID, secutils.SanitizeForLog(kbID), len(ids))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Batch reparse task submitted successfully",
|
||||
"message": "Batch reparse task submitted",
|
||||
"data": gin.H{
|
||||
"task_id": taskID,
|
||||
"reparse_count": len(ids),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ func RegisterSyncHandlers(params SyncTaskParams) {
|
||||
params.Executor.RegisterHandler(types.TypeKBClone, params.KnowledgeService.ProcessKBClone)
|
||||
params.Executor.RegisterHandler(types.TypeKnowledgeMove, params.KnowledgeService.ProcessKnowledgeMove)
|
||||
params.Executor.RegisterHandler(types.TypeKnowledgeListDelete, params.KnowledgeService.ProcessKnowledgeListDelete)
|
||||
params.Executor.RegisterHandler(types.TypeKnowledgeListReparse, params.KnowledgeService.ProcessKnowledgeListReparse)
|
||||
params.Executor.RegisterHandler(types.TypeIndexDelete, params.TagService.ProcessIndexDelete)
|
||||
params.Executor.RegisterHandler(types.TypeKBDelete, params.KnowledgeBaseService.ProcessKBDelete)
|
||||
params.Executor.RegisterHandler(types.TypeImageMultimodal, params.ImageMultimodal.Handle)
|
||||
|
||||
@@ -205,6 +205,9 @@ func RunAsynqServer(params AsynqTaskParams) *asynq.ServeMux {
|
||||
// Register knowledge list delete handler
|
||||
mux.HandleFunc(types.TypeKnowledgeListDelete, params.KnowledgeService.ProcessKnowledgeListDelete)
|
||||
|
||||
// Register knowledge list reparse handler
|
||||
mux.HandleFunc(types.TypeKnowledgeListReparse, params.KnowledgeService.ProcessKnowledgeListReparse)
|
||||
|
||||
// Register index delete handler
|
||||
mux.HandleFunc(types.TypeIndexDelete, params.TagService.ProcessIndexDelete)
|
||||
|
||||
|
||||
@@ -174,6 +174,8 @@ type KnowledgeService interface {
|
||||
ProcessKnowledgeMove(ctx context.Context, t *asynq.Task) error
|
||||
// ProcessKnowledgeListDelete handles Asynq knowledge list delete tasks
|
||||
ProcessKnowledgeListDelete(ctx context.Context, t *asynq.Task) error
|
||||
// ProcessKnowledgeListReparse handles Asynq knowledge list reparse tasks
|
||||
ProcessKnowledgeListReparse(ctx context.Context, t *asynq.Task) error
|
||||
// GetKBCloneProgress retrieves the progress of a knowledge base clone task
|
||||
GetKBCloneProgress(ctx context.Context, taskID string) (*types.KBCloneProgress, error)
|
||||
// SaveKBCloneProgress saves the progress of a knowledge base clone task
|
||||
|
||||
@@ -33,6 +33,7 @@ const (
|
||||
TypeIndexDelete = "index:delete" // 索引删除任务
|
||||
TypeKBDelete = "kb:delete" // 知识库删除任务
|
||||
TypeKnowledgeListDelete = "knowledge:list_delete" // 批量删除知识任务
|
||||
TypeKnowledgeListReparse = "knowledge:list_reparse" // 批量重解析知识任务
|
||||
TypeKnowledgeMove = "knowledge:move" // 知识移动任务
|
||||
TypeDataTableSummary = "datatable:summary" // 表格摘要任务
|
||||
TypeImageMultimodal = "image:multimodal" // 图片多模态处理任务(OCR + VLM Caption)
|
||||
@@ -201,6 +202,14 @@ type KnowledgeListDeletePayload struct {
|
||||
KnowledgeIDs []string `json:"knowledge_ids"`
|
||||
}
|
||||
|
||||
// KnowledgeListReparsePayload represents the batch knowledge reparse task payload
|
||||
type KnowledgeListReparsePayload struct {
|
||||
TracingContext
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
KnowledgeIDs []string `json:"knowledge_ids"`
|
||||
ProcessConfig *KnowledgeProcessOverrides `json:"process_config,omitempty"`
|
||||
}
|
||||
|
||||
// KnowledgeMovePayload represents the knowledge move task payload
|
||||
type KnowledgeMovePayload struct {
|
||||
TracingContext
|
||||
|
||||
Reference in New Issue
Block a user