feat: 重构知识库文档导入任务化流程

- 新增上传建单、异步解析、分块处理与异步向量化闭环

- 收口分享页权限、完成态检索过滤与 SSE 局部状态刷新
This commit is contained in:
2026-04-15 19:27:22 +08:00
parent a41b50959e
commit 2689adfa40
56 changed files with 6376 additions and 1060 deletions

View File

@@ -1,56 +1,103 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import { reactive, ref, watch } from 'vue';
import { $t } from '@easyflow/locales';
import {
ElAlert,
ElCard,
ElCol,
ElButton,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElOption,
ElRow,
ElSelect,
ElSlider,
ElTag,
} from 'element-plus';
interface StrategyConfig {
chunkSize?: number;
mdSplitterLevel?: number;
overlapSize?: number;
regex?: string;
rowsPerChunk?: number;
strategyCode?: string;
import { api } from '#/api/request';
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
import SplitterDocPreview from '#/views/ai/documentCollection/SplitterDocPreview.vue';
interface SourceRange {
end: number;
start: number;
}
interface StrategyCandidate {
score?: number;
strategyCode: string;
strategyLabel: string;
interface ChunkItem {
answer?: string;
charCount?: number;
chunkId?: string;
chunkType?: string;
content?: string;
headingPath?: string[];
options?: Record<string, any>;
partNo?: number;
partTotal?: number;
question?: string;
sourceLabel?: string;
sourceRanges?: SourceRange[];
tokenEstimate?: number;
warnings?: string[];
}
interface AnalysisResult {
candidateStrategies?: StrategyCandidate[];
confidence?: number;
reasons?: string[];
recommendedStrategyCode?: string;
recommendedStrategyLabel?: string;
recommendedStructureType?: string;
}
interface AnalyzeItem {
analysis?: AnalysisResult;
interface PreviewItem {
analysis?: {
normalizedContent?: string;
recommendedStrategyLabel?: string;
};
chunks?: ChunkItem[];
fileName: string;
filePath: string;
strategyConfig?: StrategyConfig;
normalizedContent?: string;
previewSessionId: string;
strategyCode?: string;
strategyLabel?: string;
totalChunks?: number;
totalWarnings?: number;
}
const props = defineProps<{
analysisItems?: AnalyzeItem[];
}>();
const props = defineProps({
documentId: {
type: String,
required: true,
},
documentTitle: {
type: String,
default: '',
},
endpointPrefix: {
type: String,
default: '',
},
knowledgeId: {
type: String,
required: true,
},
requestClient: {
type: Object as any,
default: () => api,
},
});
const emits = defineEmits(['cancel', 'started']);
const createDefaultFormState = () => ({
chunkSize: 512,
mdSplitterLevel: 2,
overlapSize: 128,
regex: '',
rowsPerChunk: 1,
strategyCode: 'AUTO',
});
const formState = reactive(createDefaultFormState());
const previewItems = ref<PreviewItem[]>([]);
const currentPreviewSessionId = ref('');
const activeDocumentId = ref(props.documentId || '');
const previewError = ref('');
const previewLoading = ref(false);
const startLoading = ref(false);
let previewDebounceTimer: null | ReturnType<typeof setTimeout> = null;
let previewSequence = 0;
const strategyOptions = [
{
@@ -81,313 +128,379 @@ const strategyOptions = [
const mdLevels = [1, 2, 3, 4, 5, 6];
const formMap = reactive<Record<string, StrategyConfig>>({});
const showLengthSettings = (strategyCode?: string) =>
['AUTO', 'MARKDOWN_SECTION', 'OUTLINE_SECTION', 'PARAGRAPH_LENGTH'].includes(
strategyCode || '',
);
function createDefaultStrategyConfig(item?: AnalyzeItem): StrategyConfig {
return {
chunkSize: item?.strategyConfig?.chunkSize ?? 512,
mdSplitterLevel: item?.strategyConfig?.mdSplitterLevel ?? 2,
overlapSize: item?.strategyConfig?.overlapSize ?? 128,
regex: item?.strategyConfig?.regex ?? '',
rowsPerChunk: item?.strategyConfig?.rowsPerChunk ?? 1,
strategyCode:
item?.strategyConfig?.strategyCode ||
item?.analysis?.recommendedStrategyCode ||
'AUTO',
};
}
const showOverlapSettings = (strategyCode?: string) =>
['AUTO', 'PARAGRAPH_LENGTH'].includes(strategyCode || '');
function getStrategyForm(filePath: string, item?: AnalyzeItem): StrategyConfig {
if (!formMap[filePath]) {
formMap[filePath] = createDefaultStrategyConfig(item);
const clearPreviewTimer = () => {
if (!previewDebounceTimer) {
return;
}
return formMap[filePath]!;
}
clearTimeout(previewDebounceTimer);
previewDebounceTimer = null;
};
const resetPreviewState = () => {
previewItems.value = [];
currentPreviewSessionId.value = '';
previewError.value = '';
};
const buildStrategyConfig = () => ({
...formState,
});
const normalizeSourceRanges = (ranges?: SourceRange[]) =>
Array.isArray(ranges)
? ranges.filter(
(item) =>
Number.isFinite(item?.start) &&
Number.isFinite(item?.end) &&
Number(item.end) > Number(item.start),
)
: [];
const normalizePreviewItems = (items: PreviewItem[]) =>
(items || []).map((item) => ({
...item,
normalizedContent:
item.normalizedContent || item.analysis?.normalizedContent || '',
strategyLabel:
item.strategyLabel || item.analysis?.recommendedStrategyLabel || '',
totalChunks:
Number(item.totalChunks || 0) > 0
? item.totalChunks
: (item.chunks || []).length,
chunks: (item.chunks || []).map((chunk) => ({
...chunk,
sourceRanges: normalizeSourceRanges(
chunk.sourceRanges ||
(Array.isArray(chunk.options?.sourceRanges)
? chunk.options?.sourceRanges
: []),
),
})),
}));
const generatePreview = async () => {
if (!activeDocumentId.value) {
return;
}
const requestSequence = ++previewSequence;
previewLoading.value = true;
previewError.value = '';
try {
const res = await props.requestClient.post(
buildKnowledgePath(
props.endpointPrefix,
'/api/v1/document/import/task/preview',
),
{
documentId: activeDocumentId.value,
files: [
{
strategyConfig: buildStrategyConfig(),
},
],
knowledgeId: props.knowledgeId,
},
);
if (requestSequence !== previewSequence) {
return;
}
const items = normalizePreviewItems(
(res.data?.items || []) as PreviewItem[],
);
previewItems.value = items;
currentPreviewSessionId.value = items[0]?.previewSessionId || '';
previewError.value = '';
} catch (error: any) {
if (requestSequence !== previewSequence) {
return;
}
const message =
error?.message || $t('documentCollection.importDoc.previewRequestFailed');
previewItems.value = [];
currentPreviewSessionId.value = '';
previewError.value = message;
ElMessage.error(message);
} finally {
if (requestSequence === previewSequence) {
previewLoading.value = false;
}
}
};
const schedulePreviewGeneration = () => {
if (!activeDocumentId.value) {
return;
}
clearPreviewTimer();
resetPreviewState();
previewDebounceTimer = setTimeout(() => {
previewDebounceTimer = null;
void generatePreview();
}, 320);
};
const handlePreviewSessionChange = (previewSessionId: string) => {
currentPreviewSessionId.value = previewSessionId;
};
const handleCancel = () => {
previewSequence += 1;
clearPreviewTimer();
previewLoading.value = false;
emits('cancel');
};
const startIndex = async () => {
if (!currentPreviewSessionId.value) {
ElMessage.warning($t('documentCollection.importDoc.previewEmpty'));
return;
}
startLoading.value = true;
try {
const res = await props.requestClient.post(
buildKnowledgePath(
props.endpointPrefix,
'/api/v1/document/import/task/startIndex',
),
{
documentId: activeDocumentId.value,
knowledgeId: props.knowledgeId,
previewSessionId: currentPreviewSessionId.value,
},
);
if (res.errorCode === 0) {
ElMessage.success($t('documentCollection.importDoc.indexQueued'));
emits('started');
}
} finally {
startLoading.value = false;
}
};
watch(
() => props.analysisItems,
(items) => {
for (const item of items || []) {
formMap[item.filePath] = createDefaultStrategyConfig(item);
() => props.documentId,
(value) => {
activeDocumentId.value = value || '';
previewSequence += 1;
clearPreviewTimer();
Object.assign(formState, createDefaultFormState());
resetPreviewState();
if (activeDocumentId.value) {
schedulePreviewGeneration();
}
},
{ immediate: true },
);
const items = computed(() => props.analysisItems ?? []);
defineExpose({
getPreviewRequestItems() {
return items.value.map((item) => ({
fileName: item.fileName,
filePath: item.filePath,
strategyConfig: {
...getStrategyForm(item.filePath, item),
},
}));
watch(
formState,
() => {
if (!activeDocumentId.value) {
return;
}
schedulePreviewGeneration();
},
});
function showLengthSettings(strategyCode?: string) {
return [
'AUTO',
'MARKDOWN_SECTION',
'OUTLINE_SECTION',
'PARAGRAPH_LENGTH',
].includes(strategyCode || '');
}
{ deep: true },
);
</script>
<template>
<div class="strategy-container">
<ElAlert
:title="$t('documentCollection.importDoc.analysisTip')"
type="info"
:closable="false"
class="strategy-tip"
/>
<div class="workbench">
<ElForm :model="formState" label-position="top" class="workbench__form">
<div class="workbench__form-grid">
<ElFormItem
:label="$t('documentCollection.importDoc.strategySelection')"
class="workbench__form-full"
>
<ElSelect v-model="formState.strategyCode" class="w-full">
<ElOption
v-for="option in strategyOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<div class="strategy-list">
<ElCard
v-for="item in items"
:key="item.filePath"
class="strategy-card"
shadow="never"
<ElFormItem
v-if="showLengthSettings(formState.strategyCode)"
:label="$t('documentCollection.splitterDoc.chunkSize')"
:class="
showOverlapSettings(formState.strategyCode)
? ''
: 'workbench__form-full'
"
>
<ElSlider
v-model="formState.chunkSize"
:max="2048"
:min="128"
show-input
/>
</ElFormItem>
<ElFormItem
v-if="showOverlapSettings(formState.strategyCode)"
:label="$t('documentCollection.splitterDoc.overlapSize')"
>
<ElSlider
v-model="formState.overlapSize"
:max="512"
:min="0"
show-input
/>
</ElFormItem>
<ElFormItem
v-if="formState.strategyCode === 'MARKDOWN_SECTION'"
:label="$t('documentCollection.splitterDoc.mdSplitterLevel')"
class="workbench__form-full"
>
<ElSelect v-model="formState.mdSplitterLevel" class="w-full">
<ElOption
v-for="level in mdLevels"
:key="level"
:label="'#'.repeat(level)"
:value="level"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="formState.strategyCode === 'CUSTOM_REGEX'"
:label="$t('documentCollection.splitterDoc.regex')"
class="workbench__form-full"
>
<ElInput v-model="formState.regex" />
</ElFormItem>
</div>
</ElForm>
<section class="workbench__content">
<SplitterDocPreview
:loading="previewLoading"
:preview-items="previewItems"
@preview-session-change="handlePreviewSessionChange"
/>
</section>
<div v-if="previewError" class="workbench__error">
{{ previewError }}
</div>
<div class="workbench__footer">
<ElButton :disabled="startLoading" @click="handleCancel">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
:disabled="previewLoading || !currentPreviewSessionId"
:loading="startLoading"
@click="startIndex"
>
<div class="strategy-card__header">
<div>
<div class="strategy-card__title">{{ item.fileName }}</div>
<div class="strategy-card__meta">
{{ item.analysis?.recommendedStructureType || '-' }}
</div>
</div>
<div class="strategy-card__badges">
<ElTag type="success" effect="plain">
{{
item.analysis?.recommendedStrategyLabel ||
$t('documentCollection.splitterDoc.autoStrategy')
}}
</ElTag>
<ElTag effect="plain">
{{ $t('documentCollection.importDoc.confidence') }}
{{ item.analysis?.confidence ?? 0 }}
</ElTag>
</div>
</div>
<ElRow :gutter="16" class="strategy-card__content">
<ElCol :span="12">
<div class="strategy-block">
<div class="strategy-block__label">
{{ $t('documentCollection.importDoc.recommendReason') }}
</div>
<ul class="strategy-reason-list">
<li
v-for="reason in item.analysis?.reasons || []"
:key="reason"
class="strategy-reason-list__item"
>
{{ reason }}
</li>
</ul>
</div>
<div class="strategy-block">
<div class="strategy-block__label">
{{ $t('documentCollection.importDoc.candidateStrategies') }}
</div>
<div class="strategy-candidate-list">
<ElTag
v-for="candidate in item.analysis?.candidateStrategies || []"
:key="candidate.strategyCode"
effect="plain"
>
{{ candidate.strategyLabel }} / {{ candidate.score }}
</ElTag>
</div>
</div>
</ElCol>
<ElCol :span="12">
<ElForm
:model="getStrategyForm(item.filePath, item)"
label-position="top"
class="strategy-form"
>
<ElFormItem
:label="$t('documentCollection.importDoc.strategySelection')"
>
<ElSelect
v-model="getStrategyForm(item.filePath, item).strategyCode"
class="w-full"
>
<ElOption
v-for="option in strategyOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="
showLengthSettings(
getStrategyForm(item.filePath, item).strategyCode,
)
"
:label="$t('documentCollection.splitterDoc.chunkSize')"
>
<ElSlider
v-model="getStrategyForm(item.filePath, item).chunkSize"
:max="2048"
:min="128"
show-input
/>
</ElFormItem>
<ElFormItem
v-if="
getStrategyForm(item.filePath, item).strategyCode ===
'PARAGRAPH_LENGTH' ||
getStrategyForm(item.filePath, item).strategyCode === 'AUTO'
"
:label="$t('documentCollection.splitterDoc.overlapSize')"
>
<ElSlider
v-model="getStrategyForm(item.filePath, item).overlapSize"
:max="512"
:min="0"
show-input
/>
</ElFormItem>
<ElFormItem
v-if="
getStrategyForm(item.filePath, item).strategyCode ===
'MARKDOWN_SECTION'
"
:label="$t('documentCollection.splitterDoc.mdSplitterLevel')"
>
<ElSelect
v-model="getStrategyForm(item.filePath, item).mdSplitterLevel"
class="w-full"
>
<ElOption
v-for="level in mdLevels"
:key="level"
:label="'#'.repeat(level)"
:value="level"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="
getStrategyForm(item.filePath, item).strategyCode ===
'CUSTOM_REGEX'
"
:label="$t('documentCollection.splitterDoc.regex')"
>
<ElInput v-model="getStrategyForm(item.filePath, item).regex" />
</ElFormItem>
</ElForm>
</ElCol>
</ElRow>
</ElCard>
{{ $t('button.startIndex') }}
</ElButton>
</div>
</div>
</template>
<style scoped>
.strategy-container {
.workbench {
display: flex;
flex-direction: column;
gap: 16px;
gap: 20px;
min-height: 100%;
padding: 4px 4px 0;
}
.strategy-tip {
border-radius: 12px;
.workbench__form {
padding: 0;
background: transparent;
}
.strategy-list {
display: flex;
flex-direction: column;
gap: 16px;
.workbench__form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 18px;
}
.strategy-card {
border: 1px solid var(--el-border-color-light);
.workbench__form-full {
grid-column: 1 / -1;
}
.workbench__content {
min-height: 620px;
}
.workbench__error {
padding: 14px 16px;
font-size: 13px;
line-height: 1.7;
color: var(--el-color-danger-dark-2);
background: color-mix(in srgb, var(--el-color-danger-light-9) 88%, white);
border: 1px solid color-mix(in srgb, var(--el-color-danger) 14%, white);
border-radius: 16px;
}
.strategy-card__header {
.workbench__footer {
position: sticky;
bottom: 0;
display: flex;
gap: 16px;
justify-content: space-between;
padding-bottom: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
justify-content: flex-end;
gap: 12px;
padding: 16px 0 8px;
margin-top: auto;
background: linear-gradient(
180deg,
rgb(246 248 251 / 0%) 0%,
rgb(246 248 251 / 82%) 30%,
rgb(246 248 251 / 100%) 100%
);
}
.strategy-card__title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.strategy-card__meta {
margin-top: 4px;
:deep(.el-form-item__label) {
padding-bottom: 6px;
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.strategy-card__badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
:deep(.workbench__form .el-input__wrapper),
:deep(.workbench__form .el-select__wrapper) {
box-shadow: 0 0 0 1px rgb(15 23 42 / 7%) inset;
}
.strategy-card__content {
margin-top: 16px;
:deep(.workbench__form .el-slider__runway) {
background: rgb(15 23 42 / 8%);
}
.strategy-block {
display: flex;
flex-direction: column;
gap: 10px;
@media (max-width: 1180px) {
.workbench__content {
min-height: 520px;
}
}
.strategy-block + .strategy-block {
margin-top: 16px;
}
@media (max-width: 768px) {
.workbench {
padding: 0;
}
.strategy-block__label {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.workbench__header,
.workbench__form-grid {
grid-template-columns: 1fr;
}
.strategy-reason-list {
padding-left: 18px;
margin: 0;
line-height: 1.7;
color: var(--el-text-color-regular);
}
.strategy-reason-list__item {
margin: 0;
}
.strategy-candidate-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.strategy-form {
padding: 16px;
background: var(--el-fill-color-light);
border-radius: 12px;
.workbench__form,
.workbench__content {
min-height: auto;
}
}
</style>