Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/documentCollection/SegmenterDoc.vue
陈子默 6c491bd893 feat: 完善文档分块参数配置
- 支持自定义正则匹配内容保留开关及配置持久化

- 约束分块重叠长度始终小于分块长度

- 补充中英文交互文案
2026-08-10 23:57:56 +08:00

645 lines
15 KiB
Vue

<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { $t } from '@easyflow/locales';
import {
ElButton,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElMessage,
ElOption,
ElSelect,
ElSlider,
ElSwitch,
} from 'element-plus';
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 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 PreviewItem {
analysis?: {
normalizedContent?: string;
recommendedStrategyLabel?: string;
};
chunks?: ChunkItem[];
fileName: string;
normalizedContent?: string;
pageNo?: number;
pageSize?: number;
previewSessionId: string;
strategyCode?: string;
strategyLabel?: string;
totalChunks?: number;
totalWarnings?: number;
}
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: '',
retainRegexMatch: false,
rowsPerChunk: 10,
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);
const previewPageSize = 20;
let previewDebounceTimer: null | ReturnType<typeof setTimeout> = null;
let previewSequence = 0;
const strategyOptions = [
{
label: $t('documentCollection.splitterDoc.autoStrategy'),
value: 'AUTO',
},
{
label: $t('documentCollection.splitterDoc.markdownSection'),
value: 'MARKDOWN_SECTION',
},
{
label: $t('documentCollection.splitterDoc.outlineSection'),
value: 'OUTLINE_SECTION',
},
{
label: $t('documentCollection.splitterDoc.qaPair'),
value: 'QA_PAIR',
},
{
label: $t('documentCollection.splitterDoc.paragraphLength'),
value: 'PARAGRAPH_LENGTH',
},
{
label: $t('documentCollection.splitterDoc.customRegex'),
value: 'CUSTOM_REGEX',
},
];
const fileExt = computed(
() =>
String(props.documentTitle || '')
.split('.')
.pop()
?.toLowerCase() || '',
);
const isPptx = computed(() => fileExt.value === 'pptx');
const isXlsx = computed(() => fileExt.value === 'xlsx');
const isCsv = computed(() => fileExt.value === 'csv');
const isTabular = computed(() => isXlsx.value || isCsv.value);
const showStrategySelector = computed(() => !isPptx.value && !isTabular.value);
const mdLevels = [1, 2, 3, 4, 5, 6];
const showLengthSettings = (strategyCode?: string) =>
['AUTO', 'MARKDOWN_SECTION', 'OUTLINE_SECTION', 'PARAGRAPH_LENGTH'].includes(
strategyCode || '',
);
const showOverlapSettings = (strategyCode?: string) =>
['AUTO', 'PARAGRAPH_LENGTH'].includes(strategyCode || '');
const maxOverlapSize = computed(() =>
Math.max(0, Number(formState.chunkSize || 0) - 1),
);
const clearPreviewTimer = () => {
if (!previewDebounceTimer) {
return;
}
clearTimeout(previewDebounceTimer);
previewDebounceTimer = null;
};
const resetPreviewState = () => {
previewItems.value = [];
currentPreviewSessionId.value = '';
previewError.value = '';
};
const buildStrategyConfig = () => {
if (isPptx.value) {
return {
strategyCode: 'OFFICE_PPTX_PAGE',
};
}
if (isTabular.value) {
return {
rowsPerChunk: formState.rowsPerChunk,
strategyCode: isCsv.value ? 'TABLE_ROW' : 'OFFICE_XLSX_ROW_WINDOW',
};
}
return {
...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,
pageNo: 1,
pageSize: previewPageSize,
},
);
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 loadPreviewPage = async (pageNo: number) => {
if (
!activeDocumentId.value ||
!currentPreviewSessionId.value ||
previewLoading.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,
knowledgeId: props.knowledgeId,
pageNo,
pageSize: previewPageSize,
previewSessionId: currentPreviewSessionId.value,
},
);
if (requestSequence !== previewSequence) {
return;
}
previewItems.value = normalizePreviewItems(
(res.data?.items || []) as PreviewItem[],
);
} catch (error: any) {
if (requestSequence !== previewSequence) {
return;
}
const message =
error?.message || $t('documentCollection.importDoc.previewRequestFailed');
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.documentId,
(value) => {
activeDocumentId.value = value || '';
previewSequence += 1;
clearPreviewTimer();
Object.assign(formState, createDefaultFormState());
if (isPptx.value) {
formState.strategyCode = 'OFFICE_PPTX_PAGE';
}
if (isTabular.value) {
formState.strategyCode = isCsv.value
? 'TABLE_ROW'
: 'OFFICE_XLSX_ROW_WINDOW';
formState.rowsPerChunk = 10;
}
resetPreviewState();
if (activeDocumentId.value) {
schedulePreviewGeneration();
}
},
{ immediate: true },
);
watch(
() => formState.chunkSize,
() => {
if (formState.overlapSize > maxOverlapSize.value) {
formState.overlapSize = maxOverlapSize.value;
}
},
);
watch(
formState,
() => {
if (!activeDocumentId.value) {
return;
}
schedulePreviewGeneration();
},
{ deep: true },
);
</script>
<template>
<div class="workbench">
<ElForm :model="formState" label-position="top" class="workbench__form">
<div class="workbench__form-grid">
<ElFormItem
v-if="showStrategySelector"
: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>
<ElFormItem
v-if="isTabular"
label="每多少行分一块"
class="workbench__form-full"
>
<ElInputNumber
v-model="formState.rowsPerChunk"
:min="1"
:max="200"
:step="1"
class="workbench__rows-input"
/>
</ElFormItem>
<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="Math.min(512, maxOverlapSize)"
: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>
<ElFormItem
v-if="formState.strategyCode === 'CUSTOM_REGEX'"
:label="$t('documentCollection.splitterDoc.regexMatchHandling')"
class="workbench__form-full"
>
<ElSwitch
v-model="formState.retainRegexMatch"
:active-text="$t('documentCollection.splitterDoc.retainRegexMatch')"
:inactive-text="
$t('documentCollection.splitterDoc.discardRegexMatch')
"
/>
</ElFormItem>
</div>
</ElForm>
<div class="workbench__actions">
<ElButton :disabled="startLoading" @click="handleCancel">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
:disabled="previewLoading || !currentPreviewSessionId"
:loading="startLoading"
@click="startIndex"
>
{{ $t('button.startIndex') }}
</ElButton>
</div>
<section class="workbench__content">
<SplitterDocPreview
:loading="previewLoading"
:preview-items="previewItems"
@page-change="loadPreviewPage"
@preview-session-change="handlePreviewSessionChange"
/>
</section>
<div v-if="previewError" class="workbench__error">
{{ previewError }}
</div>
</div>
</template>
<style scoped>
.workbench {
display: flex;
flex-direction: column;
gap: 20px;
min-height: 100%;
padding: 4px 4px 0;
}
.workbench__form {
padding: 0;
background: transparent;
}
.workbench__form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 18px;
}
.workbench__form-full {
grid-column: 1 / -1;
}
.workbench__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
padding-bottom: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.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: var(--el-color-danger-light-9);
background: color-mix(in srgb, var(--el-color-danger-light-9) 88%, white);
border: 1px solid var(--el-color-danger-light-8);
border: 1px solid color-mix(in srgb, var(--el-color-danger) 14%, white);
border-radius: 16px;
}
:deep(.el-form-item__label) {
padding-bottom: 6px;
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
:deep(.workbench__form .el-input__wrapper),
:deep(.workbench__form .el-select__wrapper) {
box-shadow: 0 0 0 1px rgb(15 23 42 / 7%) inset;
}
.workbench__rows-input {
width: 100%;
}
:deep(.workbench__form .el-slider__runway) {
background: rgb(15 23 42 / 8%);
}
@media (max-width: 1180px) {
.workbench__content {
min-height: 520px;
}
}
@media (max-width: 768px) {
.workbench {
padding: 0;
}
.workbench__header,
.workbench__form-grid {
grid-template-columns: 1fr;
}
.workbench__form,
.workbench__content {
min-height: auto;
}
}
@supports not (color: color-mix(in srgb, red, blue)) {
.workbench__error {
border-color: var(--el-color-danger-light-8);
}
}
</style>