feat: 增强知识库分块策略流程

- 增加导入分析预览提交与预览态缓存键

- 支持知识库分块策略配置与分块预览

- 重构知识库导入与确认导入前端流程
This commit is contained in:
2026-03-29 17:27:12 +08:00
parent 22ceabff96
commit b6213d0933
11 changed files with 2078 additions and 600 deletions

View File

@@ -1,99 +1,141 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { computed } from 'vue';
import { $t } from '@easyflow/locales';
import { ElTable, ElTableColumn, ElTag } from 'element-plus';
import {
ElCard,
ElDescriptions,
ElDescriptionsItem,
ElEmpty,
ElTable,
ElTableColumn,
ElTag,
} from 'element-plus';
import { api } from '#/api/request';
const props = defineProps({
filesList: {
default: () => [],
type: Array<any>,
},
splitterParams: {
default: () => {},
type: Object,
},
});
const emit = defineEmits(['loadingFinish']);
const route = useRoute();
const knowledgeIdRef = ref<string>((route.query.id as string) || '');
const localFilesList = ref<any[]>([]);
watch(
() => props.filesList,
(newVal) => {
localFilesList.value = [...newVal];
},
{ immediate: true },
);
defineExpose({
handleSave() {
localFilesList.value.forEach((file, index) => {
localFilesList.value[index].progressUpload = 'loading';
saveDoc(file.filePath, 'saveText', file.fileName, index);
});
},
});
function saveDoc(
filePath: string,
operation: string,
fileOriginName: string,
index: number,
) {
api
.post('/api/v1/document/saveText', {
filePath,
operation,
knowledgeId: knowledgeIdRef.value,
fileOriginName,
...props.splitterParams,
})
.then((res) => {
if (res.errorCode === 0) {
localFilesList.value[index].progressUpload = 'success';
emit('loadingFinish');
}
/* if (index === localFilesList.value.length - 1) {
emit('loadingFinish');
}*/
});
interface PreviewItem {
fileName: string;
previewSessionId: string;
totalChunks?: number;
}
interface CommitResultItem {
chunkCount?: number;
fileName?: string;
reason?: string;
success?: boolean;
}
const props = defineProps<{
commitResults?: CommitResultItem[];
loading?: boolean;
previewItems?: PreviewItem[];
}>();
const summary = computed(() => {
const results = props.commitResults ?? [];
const successCount = results.filter((item) => item.success).length;
const errorCount = results.length - successCount;
let totalCount = 0;
if (results.length > 0) {
totalCount = results.length;
} else if (props.previewItems && props.previewItems.length > 0) {
totalCount = props.previewItems.length;
}
return {
errorCount,
successCount,
totalCount,
};
});
</script>
<template>
<div class="import-doc-file-list">
<ElTable :data="localFilesList" size="large" style="width: 100%">
<div class="confirm-shell">
<ElCard shadow="never" class="confirm-card">
<ElDescriptions :column="3" border>
<ElDescriptionsItem
:label="$t('documentCollection.faq.import.totalCount')"
>
{{ summary.totalCount }}
</ElDescriptionsItem>
<ElDescriptionsItem
:label="$t('documentCollection.faq.import.successCount')"
>
{{ summary.successCount }}
</ElDescriptionsItem>
<ElDescriptionsItem
:label="$t('documentCollection.faq.import.errorCount')"
>
{{ summary.errorCount }}
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<ElEmpty
v-if="!previewItems || previewItems.length === 0"
:description="$t('documentCollection.importDoc.resultEmpty')"
/>
<ElTable
v-else
:data="
commitResults && commitResults.length > 0 ? commitResults : previewItems
"
size="large"
>
<ElTableColumn
prop="fileName"
:label="$t('documentCollection.importDoc.fileName')"
width="250"
min-width="260"
/>
<ElTableColumn
prop="progressUpload"
:label="$t('documentCollection.splitterDoc.uploadStatus')"
prop="chunkCount"
:label="$t('documentCollection.importDoc.chunkCount')"
width="120"
>
<template #default="{ row }">
<ElTag type="success" v-if="row.progressUpload === 'success'">
{{ row.chunkCount ?? row.totalChunks ?? '-' }}
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('documentCollection.splitterDoc.uploadStatus')"
width="140"
>
<template #default="{ row }">
<ElTag v-if="row.success === true" type="success" effect="plain">
{{ $t('documentCollection.splitterDoc.completed') }}
</ElTag>
<ElTag type="primary" v-else>
{{ $t('documentCollection.splitterDoc.pendingUpload') }}
<ElTag v-else-if="row.success === false" type="danger" effect="plain">
{{ $t('documentCollection.importDoc.importFailed') }}
</ElTag>
<ElTag v-else type="info" effect="plain">
{{
loading
? $t('documentCollection.splitterDoc.uploading')
: $t('documentCollection.splitterDoc.pendingUpload')
}}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn
prop="reason"
:label="$t('documentCollection.faq.import.reason')"
min-width="280"
/>
</ElTable>
</div>
</template>
<style scoped>
.import-doc-file-list {
width: 100%;
.confirm-shell {
display: flex;
flex-direction: column;
gap: 16px;
}
.confirm-card {
border: 1px solid var(--el-border-color-light);
border-radius: 16px;
}
</style>

View File

@@ -1,189 +1,215 @@
<script setup lang="ts">
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { $t } from '@easyflow/locales';
import { Back } from '@element-plus/icons-vue';
import {
ElButton,
ElMessage,
ElPagination,
ElStep,
ElSteps,
} from 'element-plus';
import { ElButton, ElMessage, ElStep, ElSteps } from 'element-plus';
import { api } from '#/api/request';
import ComfirmImportDocument from '#/views/ai/documentCollection/ComfirmImportDocument.vue';
import ImportKnowledgeFileContainer from '#/views/ai/documentCollection/ImportKnowledgeFileContainer.vue';
import SegmenterDoc from '#/views/ai/documentCollection/SegmenterDoc.vue';
import SplitterDocPreview from '#/views/ai/documentCollection/SplitterDocPreview.vue';
interface UploadFileItem {
fileName: string;
filePath: string;
}
interface AnalyzeItem {
fileName: string;
filePath: string;
strategyConfig: Record<string, any>;
}
interface PreviewItem {
fileName: string;
previewSessionId: string;
totalChunks?: number;
}
const emits = defineEmits(['importBack']);
const back = () => {
emits('importBack');
};
const files = ref([]);
const splitterParams = ref({});
const route = useRoute();
const knowledgeId = computed(() => (route.query.id as string) || '');
const fileUploadRef = ref<InstanceType<typeof ImportKnowledgeFileContainer>>();
const segmenterDocRef = ref<InstanceType<typeof SegmenterDoc>>();
const activeStep = ref(0);
const fileUploadRef = ref();
const confirmImportRef = ref();
const segmenterDocRef = ref();
const pagination = ref({
pageSize: 10,
currentPage: 1,
total: 0,
});
const goToNextStep = () => {
const files = ref<UploadFileItem[]>([]);
const analysisItems = ref<AnalyzeItem[]>([]);
const previewItems = ref<PreviewItem[]>([]);
const commitResults = ref<any[]>([]);
const analyzing = ref(false);
const previewing = ref(false);
const committing = ref(false);
const canGoPrevious = computed(() => activeStep.value > 0 && !committing.value);
function back() {
emits('importBack');
}
function getUploadedFiles() {
return fileUploadRef.value?.getFilesData?.() || [];
}
async function goToNextStep() {
if (activeStep.value === 0) {
if (fileUploadRef.value.getFilesData().length === 0) {
const currentFiles = getUploadedFiles();
if (currentFiles.length === 0) {
ElMessage.error($t('message.uploadFileFirst'));
return;
}
files.value = fileUploadRef.value.getFilesData();
files.value = currentFiles;
await runAnalyze();
activeStep.value = 1;
return;
}
if (activeStep.value === 1 && segmenterDocRef.value) {
splitterParams.value = segmenterDocRef.value.getSplitterFormValues();
if (activeStep.value === 1) {
await runPreview();
activeStep.value = 2;
return;
}
if (activeStep.value === 2) {
activeStep.value = 3;
}
}
function goToPreviousStep() {
if (!canGoPrevious.value) {
return;
}
activeStep.value += 1;
};
const goToPreviousStep = () => {
activeStep.value -= 1;
};
const handleSizeChange = (val: number) => {
pagination.value.pageSize = val;
};
const handleCurrentChange = (val: number) => {
pagination.value.currentPage = val;
};
const handleTotalUpdate = (newTotal: number) => {
pagination.value.total = newTotal; // 同步到父组件的 pagination.total
};
const loadingSave = ref(false);
const confirmImport = () => {
loadingSave.value = true;
// 确认导入
confirmImportRef.value.handleSave();
};
const finishImport = () => {
loadingSave.value = false;
ElMessage.success($t('documentCollection.splitterDoc.importSuccess'));
emits('importBack');
};
}
async function runAnalyze() {
analyzing.value = true;
try {
const res = await api.post('/api/v1/document/import/analyze', {
files: files.value.map((item) => ({
fileName: item.fileName,
filePath: item.filePath,
})),
knowledgeId: knowledgeId.value,
});
analysisItems.value = res.data?.items || [];
} finally {
analyzing.value = false;
}
}
async function runPreview() {
const previewRequestItems =
segmenterDocRef.value?.getPreviewRequestItems?.() || [];
if (previewRequestItems.length === 0) {
ElMessage.error($t('documentCollection.importDoc.previewEmpty'));
return;
}
previewing.value = true;
try {
const res = await api.post('/api/v1/document/import/preview', {
files: previewRequestItems,
knowledgeId: knowledgeId.value,
});
previewItems.value = res.data?.items || [];
commitResults.value = [];
} finally {
previewing.value = false;
}
}
async function confirmImport() {
if (previewItems.value.length === 0) {
ElMessage.error($t('documentCollection.importDoc.previewEmpty'));
return;
}
committing.value = true;
try {
const res = await api.post('/api/v1/document/import/commit', {
knowledgeId: knowledgeId.value,
previewSessionIds: previewItems.value.map(
(item) => item.previewSessionId,
),
});
commitResults.value = res.data?.results || [];
if ((res.data?.errorCount || 0) === 0) {
ElMessage.success($t('documentCollection.splitterDoc.importSuccess'));
}
} finally {
committing.value = false;
}
}
</script>
<template>
<div class="imp-doc-kno-container">
<div class="imp-doc-header">
<ElButton @click="back" :icon="Back">
<ElButton :icon="Back" @click="back">
{{ $t('button.back') }}
</ElButton>
</div>
<div class="imp-doc-kno-content">
<div class="rounded-lg bg-[var(--table-header-bg-color)] py-5">
<div class="step-card">
<ElSteps :active="activeStep" align-center>
<ElStep>
<template #icon>
<div class="flex items-center gap-2">
<div class="h-8 w-8 rounded-full bg-[var(--step-item-bg)]">
<span class="text-accent-foreground text-sm/8">1</span>
</div>
<span class="text-base">{{
$t('documentCollection.importDoc.fileUpload')
}}</span>
</div>
</template>
</ElStep>
<ElStep>
<template #icon>
<div class="flex items-center gap-2">
<div class="h-8 w-8 rounded-full bg-[var(--step-item-bg)]">
<span class="text-accent-foreground text-sm/8">2</span>
</div>
<span class="text-base">{{
$t('documentCollection.importDoc.parameterSettings')
}}</span>
</div>
</template>
</ElStep>
<ElStep>
<template #icon>
<div class="flex items-center gap-2">
<div class="h-8 w-8 rounded-full bg-[var(--step-item-bg)]">
<span class="text-accent-foreground text-sm/8">3</span>
</div>
<span class="text-base">{{
$t('documentCollection.importDoc.segmentedPreview')
}}</span>
</div>
</template>
</ElStep>
<ElStep>
<template #icon>
<div class="flex items-center gap-2">
<div class="h-8 w-8 rounded-full bg-[var(--step-item-bg)]">
<span class="text-accent-foreground text-sm/8">4</span>
</div>
<span class="text-base">{{
$t('documentCollection.importDoc.confirmImport')
}}</span>
</div>
</template>
</ElStep>
<ElStep :title="$t('documentCollection.importDoc.fileUpload')" />
<ElStep
:title="$t('documentCollection.importDoc.strategyAnalysis')"
/>
<ElStep
:title="$t('documentCollection.importDoc.segmentedPreview')"
/>
<ElStep :title="$t('documentCollection.importDoc.confirmImport')" />
</ElSteps>
</div>
<div style="margin-top: 20px">
<!-- 文件上传导入-->
<div class="knw-file-upload" v-if="activeStep === 0">
<ImportKnowledgeFileContainer ref="fileUploadRef" />
</div>
<!-- 分割参数设置-->
<div class="knw-file-splitter" v-if="activeStep === 1">
<SegmenterDoc ref="segmenterDocRef" />
</div>
<!-- 分割预览-->
<div class="knw-file-preview" v-if="activeStep === 2">
<SplitterDocPreview
:flies-list="files"
:splitter-params="splitterParams"
:page-number="pagination.currentPage"
:page-size="pagination.pageSize"
@update-total="handleTotalUpdate"
/>
</div>
<!-- 确认导入-->
<div class="knw-file-confirm" v-if="activeStep === 3">
<ComfirmImportDocument
:splitter-params="splitterParams"
:files-list="files"
ref="confirmImportRef"
@loading-finish="finishImport"
/>
</div>
</div>
</div>
<div style="height: 40px"></div>
<div class="imp-doc-footer">
<div v-if="activeStep === 2" class="imp-doc-page-container">
<ElPagination
:page-sizes="[10, 20]"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
<div class="step-body">
<ImportKnowledgeFileContainer
v-if="activeStep === 0"
ref="fileUploadRef"
/>
<SegmenterDoc
v-else-if="activeStep === 1"
ref="segmenterDocRef"
:analysis-items="analysisItems"
/>
<SplitterDocPreview
v-else-if="activeStep === 2"
:preview-items="previewItems"
/>
<ComfirmImportDocument
v-else
:preview-items="previewItems"
:commit-results="commitResults"
:loading="committing"
/>
</div>
<ElButton @click="goToPreviousStep" type="primary" v-if="activeStep >= 1">
</div>
<div class="imp-doc-footer">
<ElButton v-if="canGoPrevious" @click="goToPreviousStep">
{{ $t('button.previousStep') }}
</ElButton>
<ElButton @click="goToNextStep" type="primary" v-if="activeStep < 3">
<ElButton
v-if="activeStep < 3"
type="primary"
:loading="analyzing || previewing"
@click="goToNextStep"
>
{{ $t('button.nextStep') }}
</ElButton>
<ElButton
@click="confirmImport"
v-else
type="primary"
v-if="activeStep === 3"
:loading="loadingSave"
:disabled="loadingSave"
:loading="committing"
:disabled="committing"
@click="confirmImport"
>
{{ $t('button.startImport') }}
</ElButton>
@@ -194,60 +220,41 @@ const finishImport = () => {
<style scoped>
.imp-doc-kno-container {
position: relative;
height: 100%;
background-color: var(--el-bg-color);
border-radius: 12px;
padding: 20px;
display: flex;
height: 100%;
flex-direction: column;
padding: 24px;
border-radius: 16px;
background: var(--el-bg-color);
}
.imp-doc-kno-content {
flex: 1;
padding-top: 20px;
overflow: auto;
}
.imp-doc-footer {
position: absolute;
bottom: 20px;
right: 20px;
display: flex;
height: 40px;
background-color: var(--el-bg-color);
align-items: center;
justify-content: flex-end;
}
.knw-file-preview {
flex: 1;
flex-direction: column;
gap: 20px;
padding-top: 16px;
overflow: auto;
}
.imp-doc-page-container {
margin-right: 12px;
}
.knw-file-confirm {
width: 100%;
}
:deep(.el-step__head) {
--step-item-bg: rgba(0, 0, 0, 0.06);
--step-item-solid-bg: rgba(0, 0, 0, 0.15);
--accent-foreground: rgba(0, 0, 0, 0.45);
.step-card {
padding: 20px 24px;
border: 1px solid var(--el-border-color-light);
border-radius: 16px;
background: var(--el-fill-color-blank);
}
:deep(.el-step__head:where(.dark, .dark *)) {
--step-item-bg: var(--el-text-color-placeholder);
--step-item-solid-bg: var(--el-text-color-placeholder);
--accent-foreground: var(--primary-foreground);
.step-body {
flex: 1;
padding-bottom: 72px;
}
:deep(.el-step__head.is-finish) {
--step-item-bg: hsl(var(--primary));
--step-item-solid-bg: hsl(var(--primary));
--accent-foreground: var(--primary-foreground);
}
:deep(.el-step__icon.is-icon) {
width: 120px;
background-color: var(--table-header-bg-color);
}
:deep(.el-step__line) {
background-color: var(--step-item-solid-bg);
.imp-doc-footer {
position: absolute;
right: 24px;
bottom: 24px;
display: flex;
gap: 12px;
align-items: center;
}
</style>

View File

@@ -20,7 +20,7 @@ const fileData = ref<FileInfo[]>([]);
const filesPath = ref([]);
defineExpose({
getFilesData() {
return fileData.value;
return fileData.value.filter((item) => item.filePath);
},
});
function handleSuccess(response: any) {

View File

@@ -1,189 +1,373 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { computed, reactive, watch } from 'vue';
import { $t } from '@easyflow/locales';
import {
ElAlert,
ElCard,
ElCol,
ElForm,
ElFormItem,
ElInput,
ElOption,
ElRow,
ElSelect,
ElSlider,
ElTag,
} from 'element-plus';
const formRef = ref();
const form = reactive({
fileType: 'doc',
splitterName: 'SimpleDocumentSplitter',
chunkSize: 512,
overlapSize: 128,
regex: '',
rowsPerChunk: 0,
mdSplitterLevel: 1,
});
const fileTypes = [
interface StrategyConfig {
chunkSize?: number;
mdSplitterLevel?: number;
overlapSize?: number;
regex?: string;
rowsPerChunk?: number;
strategyCode?: string;
}
interface StrategyCandidate {
score?: number;
strategyCode: string;
strategyLabel: string;
}
interface AnalysisResult {
candidateStrategies?: StrategyCandidate[];
confidence?: number;
reasons?: string[];
recommendedStrategyCode?: string;
recommendedStrategyLabel?: string;
recommendedStructureType?: string;
}
interface AnalyzeItem {
analysis?: AnalysisResult;
fileName: string;
filePath: string;
strategyConfig?: StrategyConfig;
}
const props = defineProps<{
analysisItems?: AnalyzeItem[];
}>();
const strategyOptions = [
{
label: $t('documentCollection.splitterDoc.document'),
value: 'doc',
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 splitterNames = [
{
label: $t('documentCollection.splitterDoc.simpleDocumentSplitter'),
value: 'SimpleDocumentSplitter',
const mdLevels = [1, 2, 3, 4, 5, 6];
const formMap = reactive<Record<string, StrategyConfig>>({});
watch(
() => props.analysisItems,
(items) => {
for (const item of items || []) {
formMap[item.filePath] = {
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',
};
}
},
{
label: $t('documentCollection.splitterDoc.simpleTokenizeSplitter'),
value: 'SimpleTokenizeSplitter',
},
{
label: $t('documentCollection.splitterDoc.regexDocumentSplitter'),
value: 'RegexDocumentSplitter',
},
{
label: $t('documentCollection.splitterDoc.markdownHeaderSplitter'),
value: 'MarkdownHeaderSplitter',
},
];
const mdSplitterLevel = [
{
label: '#',
value: 1,
},
{
label: '##',
value: 2,
},
{
label: '###',
value: 3,
},
{
label: '####',
value: 4,
},
{
label: '#####',
value: 5,
},
{
label: '######',
value: 6,
},
];
const rules = {
name: [
{ required: true, message: 'Please input Activity name', trigger: 'blur' },
],
region: [
{
required: true,
message: 'Please select Activity zone',
trigger: 'change',
},
],
};
{ immediate: true },
);
const items = computed(() => props.analysisItems ?? []);
defineExpose({
getSplitterFormValues() {
return form;
getPreviewRequestItems() {
return items.value.map((item) => ({
fileName: item.fileName,
filePath: item.filePath,
strategyConfig: {
...formMap[item.filePath],
},
}));
},
});
function showLengthSettings(strategyCode?: string) {
return [
'AUTO',
'MARKDOWN_SECTION',
'OUTLINE_SECTION',
'PARAGRAPH_LENGTH',
].includes(strategyCode || '');
}
</script>
<template>
<div class="splitter-doc-container">
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-width="auto"
class="custom-form"
>
<ElFormItem
:label="$t('documentCollection.splitterDoc.fileType')"
prop="fileType"
<div class="strategy-container">
<ElAlert
:title="$t('documentCollection.importDoc.analysisTip')"
type="info"
:closable="false"
class="strategy-tip"
/>
<div class="strategy-list">
<ElCard
v-for="item in items"
:key="item.filePath"
class="strategy-card"
shadow="never"
>
<ElSelect v-model="form.fileType">
<ElOption
v-for="item in fileTypes"
:key="item.value"
v-bind="item"
:label="item.label"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
:label="$t('documentCollection.splitterDoc.splitterName')"
prop="splitterName"
>
<ElSelect v-model="form.splitterName">
<ElOption
v-for="item in splitterNames"
:key="item.value"
v-bind="item"
:label="item.label"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
:label="$t('documentCollection.splitterDoc.chunkSize')"
v-if="
form.splitterName === 'SimpleDocumentSplitter' ||
form.splitterName === 'SimpleTokenizeSplitter'
"
prop="chunkSize"
>
<ElSlider v-model="form.chunkSize" show-input :max="2048" />
</ElFormItem>
<ElFormItem
:label="$t('documentCollection.splitterDoc.overlapSize')"
v-if="
form.splitterName === 'SimpleDocumentSplitter' ||
form.splitterName === 'SimpleTokenizeSplitter'
"
prop="overlapSize"
>
<ElSlider v-model="form.overlapSize" show-input :max="2048" />
</ElFormItem>
<ElFormItem
:label="$t('documentCollection.splitterDoc.regex')"
prop="regex"
v-if="form.splitterName === 'RegexDocumentSplitter'"
>
<ElInput v-model="form.regex" />
</ElFormItem>
<ElFormItem
v-if="form.splitterName === 'MarkdownHeaderSplitter'"
:label="$t('documentCollection.splitterDoc.mdSplitterLevel')"
prop="splitterName"
>
<ElSelect v-model="form.mdSplitterLevel">
<ElOption
v-for="item in mdSplitterLevel"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
</ElForm>
<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="formMap[item.filePath]"
label-position="top"
class="strategy-form"
>
<ElFormItem
:label="$t('documentCollection.importDoc.strategySelection')"
>
<ElSelect
v-model="formMap[item.filePath].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(formMap[item.filePath].strategyCode)"
:label="$t('documentCollection.splitterDoc.chunkSize')"
>
<ElSlider
v-model="formMap[item.filePath].chunkSize"
:max="2048"
:min="128"
show-input
/>
</ElFormItem>
<ElFormItem
v-if="
formMap[item.filePath].strategyCode === 'PARAGRAPH_LENGTH' ||
formMap[item.filePath].strategyCode === 'AUTO'
"
:label="$t('documentCollection.splitterDoc.overlapSize')"
>
<ElSlider
v-model="formMap[item.filePath].overlapSize"
:max="512"
:min="0"
show-input
/>
</ElFormItem>
<ElFormItem
v-if="
formMap[item.filePath].strategyCode === 'MARKDOWN_SECTION'
"
:label="$t('documentCollection.splitterDoc.mdSplitterLevel')"
>
<ElSelect
v-model="formMap[item.filePath].mdSplitterLevel"
class="w-full"
>
<ElOption
v-for="level in mdLevels"
:key="level"
:label="'#'.repeat(level)"
:value="level"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="formMap[item.filePath].strategyCode === 'CUSTOM_REGEX'"
:label="$t('documentCollection.splitterDoc.regex')"
>
<ElInput v-model="formMap[item.filePath].regex" />
</ElFormItem>
</ElForm>
</ElCol>
</ElRow>
</ElCard>
</div>
</div>
</template>
<style scoped>
.splitter-doc-container {
height: 100%;
width: 100%;
align-items: center;
.strategy-container {
display: flex;
justify-content: center;
flex-direction: column;
gap: 16px;
}
.custom-form {
width: 500px;
.strategy-tip {
border-radius: 12px;
}
.custom-form :deep(.el-input),
.custom-form :deep(.ElSelect) {
width: 100%;
.strategy-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.strategy-card {
border: 1px solid var(--el-border-color-light);
border-radius: 16px;
}
.strategy-card__header {
display: flex;
justify-content: space-between;
gap: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.strategy-card__title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.strategy-card__meta {
margin-top: 4px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.strategy-card__badges {
display: flex;
gap: 8px;
align-items: flex-start;
flex-wrap: wrap;
}
.strategy-card__content {
margin-top: 16px;
}
.strategy-block {
display: flex;
flex-direction: column;
gap: 10px;
}
.strategy-block + .strategy-block {
margin-top: 16px;
}
.strategy-block__label {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.strategy-reason-list {
margin: 0;
padding-left: 18px;
color: var(--el-text-color-regular);
line-height: 1.7;
}
.strategy-reason-list__item {
margin: 0;
}
.strategy-candidate-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.strategy-form {
padding: 16px;
border-radius: 12px;
background: var(--el-fill-color-light);
}
</style>

View File

@@ -1,168 +1,286 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { computed, ref, watch } from 'vue';
import { api } from '#/api/request';
import CategoryPanel from '#/components/categoryPanel/CategoryPanel.vue';
import PreviewSearchKnowledge from '#/views/ai/documentCollection/PreviewSearchKnowledge.vue';
import { $t } from '@easyflow/locales';
export interface FileInfo {
filePath: string;
import {
ElAlert,
ElDescriptions,
ElDescriptionsItem,
ElEmpty,
ElTabPane,
ElTabs,
ElTag,
} from 'element-plus';
interface ChunkItem {
answer?: string;
charCount?: number;
chunkId?: string;
chunkType?: string;
content?: string;
headingPath?: string[];
partNo?: number;
partTotal?: number;
question?: string;
sourceLabel?: string;
tokenEstimate?: number;
warnings?: string[];
}
interface PreviewItem {
analysis?: {
confidence?: number;
recommendedStructureType?: string;
};
chunks?: ChunkItem[];
fileName: string;
previewSessionId: string;
strategyLabel?: string;
totalChunks?: number;
totalWarnings?: number;
}
const props = defineProps({
pageNumber: {
default: 1,
type: Number,
},
pageSize: {
default: 10,
type: Number,
},
knowledgeId: {
default: '',
type: String,
},
fliesList: {
default: () => [],
type: Array<FileInfo>,
},
splitterParams: {
default: () => {},
type: Object,
},
});
const emit = defineEmits(['updateTotal']);
const documentList = ref<any[]>([]);
const route = useRoute();
defineExpose({
getFilesData() {
return documentList.value.length;
},
});
const knowledgeIdRef = ref<string>((route.query.id as string) || '');
const selectedCategory = ref<any>();
watch(
() => props.pageNumber,
(newVal) => {
if (selectedCategory.value) {
splitterDocPreview(
newVal,
props.pageSize,
selectedCategory.value.value,
'textSplit',
selectedCategory.value.label,
);
} else {
splitterDocPreview(
newVal,
props.pageSize,
props.fliesList[0]!.filePath,
'textSplit',
props.fliesList[0]!.fileName,
);
}
},
const props = defineProps<{
previewItems?: PreviewItem[];
}>();
const activeFile = ref('');
const previewItems = computed(() => props.previewItems ?? []);
const currentPreview = computed(
() =>
previewItems.value.find(
(item) => item.previewSessionId === activeFile.value,
) || previewItems.value[0],
);
watch(
() => props.pageSize,
(newVal) => {
if (selectedCategory.value) {
splitterDocPreview(
props.pageNumber,
newVal,
selectedCategory.value.value,
'textSplit',
selectedCategory.value.label,
);
} else {
splitterDocPreview(
props.pageNumber,
newVal,
props.fliesList[0]!.filePath,
'textSplit',
props.fliesList[0]!.fileName,
);
previewItems,
(items) => {
if (items.length === 0) {
activeFile.value = '';
return;
}
if (!items.some((item) => item.previewSessionId === activeFile.value)) {
activeFile.value = items[0]?.previewSessionId || '';
}
},
{ immediate: true },
);
function splitterDocPreview(
pageNumber: number,
pageSize: number,
filePath: string,
operation: string,
fileOriginName: string,
) {
api
.post('/api/v1/document/textSplit', {
pageNumber,
pageSize,
filePath,
operation,
knowledgeId: knowledgeIdRef.value,
fileOriginName,
...props.splitterParams,
})
.then((res) => {
if (res.errorCode === 0) {
documentList.value = res.data.previewData;
emit('updateTotal', res.data.total);
}
});
}
onMounted(() => {
if (props.fliesList.length === 0) {
return;
}
splitterDocPreview(
props.pageNumber,
props.pageSize,
props.fliesList[0]!.filePath,
'textSplit',
props.fliesList[0]!.fileName,
);
});
const changeCategory = (category: any) => {
selectedCategory.value = category;
splitterDocPreview(
props.pageNumber,
props.pageSize,
category.value,
'textSplit',
category.label,
);
};
</script>
<template>
<div class="splitter-doc-container">
<div>
<CategoryPanel
:categories="fliesList"
title-key="fileName"
:need-hide-collapse="true"
:expand-width="200"
value-key="filePath"
:default-selected-category="fliesList[0]!.filePath"
@click="changeCategory"
/>
</div>
<div class="preview-shell">
<ElAlert
:title="$t('documentCollection.importDoc.previewTip')"
type="info"
:closable="false"
class="preview-alert"
/>
<div class="preview-container">
<PreviewSearchKnowledge :data="documentList" :hide-score="true" />
<ElEmpty
v-if="previewItems.length === 0"
:description="$t('documentCollection.importDoc.previewEmpty')"
/>
<div v-else class="preview-panel">
<ElTabs v-model="activeFile" class="preview-tabs">
<ElTabPane
v-for="item in previewItems"
:key="item.previewSessionId"
:label="item.fileName"
:name="item.previewSessionId"
/>
</ElTabs>
<div v-if="currentPreview" class="preview-detail">
<ElDescriptions :column="4" border class="preview-summary">
<ElDescriptionsItem :label="$t('documentCollection.fileName')">
{{ currentPreview.fileName }}
</ElDescriptionsItem>
<ElDescriptionsItem
:label="$t('documentCollection.importDoc.strategySelection')"
>
{{ currentPreview.strategyLabel || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('documentCollection.total')">
{{ currentPreview.totalChunks || 0 }}
</ElDescriptionsItem>
<ElDescriptionsItem
:label="$t('documentCollection.importDoc.warningCount')"
>
{{ currentPreview.totalWarnings || 0 }}
</ElDescriptionsItem>
</ElDescriptions>
<div class="chunk-list">
<div
v-for="chunk in currentPreview.chunks || []"
:key="chunk.chunkId"
class="chunk-card"
>
<div class="chunk-card__header">
<div>
<div class="chunk-card__title">
{{ chunk.sourceLabel || chunk.chunkId }}
</div>
<div
v-if="chunk.headingPath && chunk.headingPath.length > 0"
class="chunk-card__path"
>
{{ chunk.headingPath.join(' / ') }}
</div>
</div>
<div class="chunk-card__meta">
<ElTag effect="plain">{{ chunk.chunkType || '-' }}</ElTag>
<ElTag effect="plain">
{{ chunk.charCount || 0 }} / {{ chunk.tokenEstimate || 0 }}
</ElTag>
<ElTag
v-if="(chunk.partTotal || 1) > 1"
type="warning"
effect="plain"
>
{{ chunk.partNo }}/{{ chunk.partTotal }}
</ElTag>
</div>
</div>
<div v-if="chunk.chunkType === 'qa_pair'" class="qa-block">
<div class="qa-block__item">
<span class="qa-block__label">Q</span>
<span>{{ chunk.question }}</span>
</div>
<div class="qa-block__item">
<span class="qa-block__label">A</span>
<span>{{ chunk.answer }}</span>
</div>
</div>
<pre class="chunk-card__content">{{ chunk.content }}</pre>
<div
v-if="chunk.warnings && chunk.warnings.length > 0"
class="chunk-card__warnings"
>
<ElTag
v-for="warning in chunk.warnings"
:key="warning"
type="warning"
effect="plain"
>
{{ warning }}
</ElTag>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.splitter-doc-container {
height: 100%;
.preview-shell {
display: flex;
flex-direction: column;
gap: 16px;
}
.preview-container {
flex: 1;
overflow: scroll;
.preview-alert {
border-radius: 12px;
}
.preview-panel {
padding: 20px;
border: 1px solid var(--el-border-color-light);
border-radius: 16px;
background: var(--el-bg-color);
}
.preview-summary {
margin-bottom: 20px;
}
.chunk-list {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 560px;
overflow: auto;
}
.chunk-card {
padding: 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 14px;
background: var(--el-fill-color-blank);
}
.chunk-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.chunk-card__title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.chunk-card__path {
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.chunk-card__meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chunk-card__content {
margin: 16px 0 0;
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
line-height: 1.7;
color: var(--el-text-color-regular);
}
.chunk-card__warnings {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.qa-block {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
padding: 12px;
border-radius: 12px;
background: var(--el-fill-color-light);
}
.qa-block__item {
display: flex;
gap: 8px;
line-height: 1.6;
}
.qa-block__label {
display: inline-flex;
width: 22px;
justify-content: center;
border-radius: 999px;
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font-weight: 600;
}
</style>