feat: 支持知识库导入 PPTX 与 XLSX 文档
- 打通 Office 文档桥接解析、解析进度承接与图片引用改写 - 落地 PPTX 按页分块、XLSX 行窗口分块以及预览与检索渲染闭环
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js';
|
||||
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import { Delete, MoreFilled } from '@element-plus/icons-vue';
|
||||
import { Delete, EditPen, MoreFilled } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElDropdown,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
ElDropdownMenu,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElIcon,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
@@ -21,6 +23,10 @@ import {
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import {
|
||||
markdownRenderProps,
|
||||
resolveMarkdownContent,
|
||||
} from '#/views/ai/documentCollection/markdown-content';
|
||||
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -122,6 +128,85 @@ const form = ref({
|
||||
id: '',
|
||||
content: '',
|
||||
});
|
||||
|
||||
const getChunkOptions = (row: any) => row?.options || {};
|
||||
|
||||
const getMarkdown = (row: any) =>
|
||||
resolveMarkdownContent(getChunkOptions(row)?.renderMarkdown || row?.content);
|
||||
|
||||
const isExcelChunk = (row: any) => {
|
||||
const options = getChunkOptions(row);
|
||||
const sourceFileExt = String(
|
||||
options?.sourceFileExt || options?.['splitter.sourceFileExt'] || '',
|
||||
).toLowerCase();
|
||||
|
||||
return Boolean(
|
||||
sourceFileExt === 'xlsx' ||
|
||||
options?.sheetName ||
|
||||
options?.rowStart ||
|
||||
options?.rowEnd,
|
||||
);
|
||||
};
|
||||
|
||||
const shouldUseExcelChunkCards = (pageList: any[] = []) =>
|
||||
pageList.length > 0 && pageList.every((row) => isExcelChunk(row));
|
||||
|
||||
const getSheetName = (row: any) =>
|
||||
String(getChunkOptions(row)?.sheetName || '');
|
||||
|
||||
const getRowStart = (row: any) => {
|
||||
const rowStart = Number(getChunkOptions(row)?.rowStart || 0);
|
||||
return Math.max(rowStart, 0);
|
||||
};
|
||||
|
||||
const getRowEnd = (row: any) => {
|
||||
const rowEnd = Number(getChunkOptions(row)?.rowEnd || 0);
|
||||
return Math.max(rowEnd, 0);
|
||||
};
|
||||
|
||||
const getRowRangeLabel = (row: any) => {
|
||||
const rowStart = getRowStart(row);
|
||||
const rowEnd = getRowEnd(row);
|
||||
if (rowStart > 0 && rowEnd > 0) {
|
||||
return rowStart === rowEnd
|
||||
? `第 ${rowStart} 行`
|
||||
: `第 ${rowStart}-${rowEnd} 行`;
|
||||
}
|
||||
if (rowStart > 0) {
|
||||
return `第 ${rowStart} 行起`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const getChunkTitle = (row: any) => {
|
||||
const options = getChunkOptions(row);
|
||||
if (options?.sourceLabel) {
|
||||
return String(options.sourceLabel);
|
||||
}
|
||||
const sheetName = getSheetName(row);
|
||||
const rowRangeLabel = getRowRangeLabel(row);
|
||||
if (sheetName && rowRangeLabel) {
|
||||
return `${sheetName} · ${rowRangeLabel}`;
|
||||
}
|
||||
return sheetName || row?.id || '-';
|
||||
};
|
||||
|
||||
const getChunkIndexLabel = (row: any) => {
|
||||
const sorting = Number(row?.sorting || 0);
|
||||
if (sorting <= 0) {
|
||||
return '';
|
||||
}
|
||||
return String(sorting).padStart(2, '0');
|
||||
};
|
||||
|
||||
const getChunkHeaderLabel = (row: any) => {
|
||||
const chunkIndexLabel = getChunkIndexLabel(row);
|
||||
const chunkTitle = getChunkTitle(row);
|
||||
if (chunkIndexLabel) {
|
||||
return `分块 ${chunkIndexLabel} · ${chunkTitle}`;
|
||||
}
|
||||
return chunkTitle;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -136,12 +221,76 @@ const form = ref({
|
||||
:extra-query-params="queryParams"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<ElTable :data="pageList" style="width: 100%" size="large">
|
||||
<div v-if="shouldUseExcelChunkCards(pageList)" class="chunk-board">
|
||||
<article v-for="row in pageList" :key="row.id" class="chunk-card">
|
||||
<div v-if="props.manageable" class="chunk-card__toolbar">
|
||||
<ElButton
|
||||
circle
|
||||
text
|
||||
type="primary"
|
||||
class="chunk-card__action"
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
<ElIcon><EditPen /></ElIcon>
|
||||
</ElButton>
|
||||
<ElDropdown>
|
||||
<ElButton
|
||||
circle
|
||||
text
|
||||
class="chunk-card__action chunk-card__action--ghost"
|
||||
>
|
||||
<ElIcon><MoreFilled /></ElIcon>
|
||||
</ElButton>
|
||||
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem @click="handleDelete(row)">
|
||||
<ElButton link type="danger" :icon="Delete">
|
||||
{{ $t('button.delete') }}
|
||||
</ElButton>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
|
||||
<div class="chunk-card__header">
|
||||
<div class="chunk-card__eyebrow">
|
||||
<span class="chunk-card__eyebrow-dot"></span>
|
||||
<span>{{ getChunkHeaderLabel(row) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="getMarkdown(row)" class="chunk-card__content">
|
||||
<div class="chunk-rich-content chunk-rich-content--card">
|
||||
<ElXMarkdown
|
||||
:markdown="getMarkdown(row)"
|
||||
:allow-html="markdownRenderProps.allowHtml"
|
||||
:sanitize="markdownRenderProps.sanitize"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="chunk-table__empty">-</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<ElTable v-else :data="pageList" style="width: 100%" size="large">
|
||||
<ElTableColumn
|
||||
prop="content"
|
||||
:label="$t('documentCollection.content')"
|
||||
min-width="240"
|
||||
/>
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div v-if="getMarkdown(row)" class="chunk-rich-content">
|
||||
<ElXMarkdown
|
||||
:markdown="getMarkdown(row)"
|
||||
:allow-html="markdownRenderProps.allowHtml"
|
||||
:sanitize="markdownRenderProps.sanitize"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="chunk-table__empty">-</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
v-if="props.manageable"
|
||||
:label="$t('common.handle')"
|
||||
@@ -198,4 +347,251 @@ const form = ref({
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.chunk-board {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.chunk-card {
|
||||
position: relative;
|
||||
padding: 20px 20px 18px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--el-color-primary-light-9) 80%, white) 0%,
|
||||
var(--el-fill-color-blank) 38%
|
||||
);
|
||||
border: 1px solid color-mix(in srgb, var(--el-border-color-light) 78%, white);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 18px 40px rgb(15 23 42 / 6%);
|
||||
}
|
||||
|
||||
.chunk-card::before {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 4px;
|
||||
content: '';
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--el-color-primary),
|
||||
color-mix(in srgb, var(--el-color-primary) 44%, white)
|
||||
);
|
||||
}
|
||||
|
||||
.chunk-card__toolbar {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chunk-card__action {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
color: var(--el-color-primary);
|
||||
background: color-mix(in srgb, var(--el-color-primary-light-9) 66%, white);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--el-color-primary-light-8) 72%, white);
|
||||
box-shadow: 0 8px 18px rgb(37 99 235 / 10%);
|
||||
}
|
||||
|
||||
.chunk-card__action--ghost {
|
||||
color: var(--el-text-color-secondary);
|
||||
background: rgb(255 255 255 / 86%);
|
||||
border-color: color-mix(in srgb, var(--el-border-color-light) 86%, white);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.chunk-card__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding-right: 92px;
|
||||
}
|
||||
|
||||
.chunk-card__eyebrow {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.chunk-card__eyebrow-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--el-color-primary);
|
||||
box-shadow: 0 0 0 4px
|
||||
color-mix(in srgb, var(--el-color-primary-light-8) 50%, transparent);
|
||||
}
|
||||
|
||||
.chunk-card__content {
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.chunk-rich-content {
|
||||
min-width: 0;
|
||||
padding: 4px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.72;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.chunk-rich-content--card {
|
||||
padding: 14px 16px;
|
||||
overflow-x: auto;
|
||||
background: rgb(255 255 255 / 82%);
|
||||
border: 1px solid rgb(15 23 42 / 6%);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.chunk-rich-content :deep(.markdown-body) {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chunk-rich-content :deep(.markdown-body > :first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chunk-rich-content :deep(.markdown-body > :last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chunk-rich-content :deep(*) {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.chunk-rich-content :deep(p) {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.chunk-rich-content :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chunk-rich-content :deep(h1),
|
||||
.chunk-rich-content :deep(h2),
|
||||
.chunk-rich-content :deep(h3),
|
||||
.chunk-rich-content :deep(h4),
|
||||
.chunk-rich-content :deep(h5),
|
||||
.chunk-rich-content :deep(h6) {
|
||||
margin: 14px 0 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.chunk-rich-content :deep(h1:first-child),
|
||||
.chunk-rich-content :deep(h2:first-child),
|
||||
.chunk-rich-content :deep(h3:first-child),
|
||||
.chunk-rich-content :deep(h4:first-child),
|
||||
.chunk-rich-content :deep(h5:first-child),
|
||||
.chunk-rich-content :deep(h6:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chunk-rich-content :deep(ul),
|
||||
.chunk-rich-content :deep(ol) {
|
||||
padding-left: 20px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.chunk-rich-content :deep(li + li) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.chunk-rich-content :deep(a) {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.chunk-rich-content :deep(img) {
|
||||
display: block;
|
||||
max-width: min(100%, 560px);
|
||||
height: auto;
|
||||
margin: 12px 0;
|
||||
border: 1px solid rgb(15 23 42 / 8%);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 24px rgb(15 23 42 / 8%);
|
||||
}
|
||||
|
||||
.chunk-rich-content :deep(table) {
|
||||
width: 100%;
|
||||
margin: 12px 0;
|
||||
overflow: hidden;
|
||||
border-collapse: collapse;
|
||||
background: rgb(255 255 255 / 92%);
|
||||
border: 1px solid rgb(15 23 42 / 8%);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.chunk-rich-content :deep(th),
|
||||
.chunk-rich-content :deep(td) {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
border: 1px solid rgb(15 23 42 / 8%);
|
||||
}
|
||||
|
||||
.chunk-rich-content :deep(th) {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
background: rgb(37 99 235 / 4%);
|
||||
}
|
||||
|
||||
.chunk-rich-content :deep(pre) {
|
||||
max-width: 100%;
|
||||
padding: 12px 14px;
|
||||
overflow: auto;
|
||||
background: rgb(15 23 42 / 4%);
|
||||
border: 1px solid rgb(15 23 42 / 6%);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.chunk-rich-content :deep(blockquote) {
|
||||
padding-left: 12px;
|
||||
margin: 12px 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
border-left: 3px solid rgb(37 99 235 / 24%);
|
||||
}
|
||||
|
||||
.chunk-table__empty {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.chunk-card {
|
||||
padding: 18px 16px 16px;
|
||||
}
|
||||
|
||||
.chunk-card__toolbar {
|
||||
position: static;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.chunk-card__header {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.chunk-rich-content--card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.chunk-card__eyebrow {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -40,6 +40,8 @@ interface DocumentStatusPayload {
|
||||
failedChunks?: number;
|
||||
knowledgeId?: number | string;
|
||||
lastTaskError?: string;
|
||||
parseCurrentStage?: string;
|
||||
parseStatusMessage?: string;
|
||||
processStatus?: string;
|
||||
progressPercent?: number;
|
||||
taskModifiedAt?: string;
|
||||
@@ -154,11 +156,24 @@ const statusMetaMap: Record<
|
||||
},
|
||||
};
|
||||
|
||||
const defaultStatusMeta: {
|
||||
icon: Component;
|
||||
toneClass: string;
|
||||
} = statusMetaMap.UPLOADED!;
|
||||
|
||||
const getStatusLabel = (status?: string) =>
|
||||
$t(`documentCollection.taskStatus.${status || 'UPLOADED'}`);
|
||||
|
||||
const getStatusMeta = (status?: string) =>
|
||||
statusMetaMap[status || 'UPLOADED'] || statusMetaMap.UPLOADED;
|
||||
const getStatusMeta = (
|
||||
status?: string,
|
||||
): {
|
||||
icon: Component;
|
||||
toneClass: string;
|
||||
} => statusMetaMap[status || 'UPLOADED'] ?? defaultStatusMeta;
|
||||
|
||||
const getStatusToneClass = (status?: string) => getStatusMeta(status).toneClass;
|
||||
|
||||
const getStatusIcon = (status?: string) => getStatusMeta(status).icon;
|
||||
|
||||
const getChunkCount = (row: any) => {
|
||||
const totalChunks = Number(row.totalChunks || 0);
|
||||
@@ -171,12 +186,28 @@ const getChunkCount = (row: any) => {
|
||||
const getProgressText = (row: any) => {
|
||||
const completed = Number(row.completedChunks || 0);
|
||||
const total = Number(row.totalChunks || 0);
|
||||
if (row.processStatus === 'PARSING') {
|
||||
return `${Number(row.progressPercent || 0)}%`;
|
||||
}
|
||||
if (total <= 0) {
|
||||
return `${Number(row.progressPercent || 0)}%`;
|
||||
}
|
||||
return `${Number(row.progressPercent || 0)}% · ${completed}/${total}`;
|
||||
};
|
||||
|
||||
const parseStageLabels: Record<string, string> = {
|
||||
assembling: '汇总中',
|
||||
extracting: '提取中',
|
||||
ocr: 'OCR 中',
|
||||
preparing: '准备中',
|
||||
queued: '排队中',
|
||||
};
|
||||
|
||||
const getProcessingHint = (row: any) =>
|
||||
row.parseStatusMessage ||
|
||||
parseStageLabels[row.parseCurrentStage || ''] ||
|
||||
'';
|
||||
|
||||
const clearReconnectTimer = () => {
|
||||
if (!reconnectTimer) {
|
||||
return;
|
||||
@@ -211,6 +242,8 @@ const patchDocumentRow = (payload: DocumentStatusPayload) => {
|
||||
completedChunks: payload.completedChunks,
|
||||
failedChunks: payload.failedChunks,
|
||||
lastTaskError: payload.lastTaskError,
|
||||
parseCurrentStage: payload.parseCurrentStage,
|
||||
parseStatusMessage: payload.parseStatusMessage,
|
||||
processStatus: payload.processStatus,
|
||||
progressPercent: payload.progressPercent,
|
||||
taskModifiedAt: payload.taskModifiedAt,
|
||||
@@ -529,7 +562,7 @@ watch(
|
||||
<div class="status-cell">
|
||||
<div
|
||||
class="status-pill"
|
||||
:class="getStatusMeta(row.processStatus).toneClass"
|
||||
:class="getStatusToneClass(row.processStatus)"
|
||||
>
|
||||
<span class="status-pill__icon-shell">
|
||||
<ElIcon
|
||||
@@ -540,7 +573,7 @@ watch(
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<component :is="getStatusMeta(row.processStatus).icon" />
|
||||
<component :is="getStatusIcon(row.processStatus)" />
|
||||
</ElIcon>
|
||||
</span>
|
||||
<span class="status-pill__label">
|
||||
@@ -548,7 +581,10 @@ watch(
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="row.processStatus === 'INDEXING'"
|
||||
v-if="
|
||||
row.processStatus === 'INDEXING' ||
|
||||
row.processStatus === 'PARSING'
|
||||
"
|
||||
class="status-progress"
|
||||
>
|
||||
<ElProgress
|
||||
@@ -558,6 +594,12 @@ watch(
|
||||
<span class="status-progress__text">
|
||||
{{ getProgressText(row) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.processStatus === 'PARSING' && getProcessingHint(row)"
|
||||
class="status-progress__hint"
|
||||
>
|
||||
{{ getProcessingHint(row) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="row.lastTaskError"
|
||||
@@ -663,6 +705,12 @@ watch(
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.status-progress__hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
max-width: 176px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -21,6 +21,8 @@ type RetrievalMode = 'HYBRID' | 'KEYWORD' | 'VECTOR';
|
||||
interface SearchResultItem {
|
||||
sorting: number;
|
||||
content: string;
|
||||
renderMarkdown?: string;
|
||||
sourceFileName?: string;
|
||||
score?: number;
|
||||
hitSource?: 'BOTH' | 'KEYWORD' | 'VECTOR';
|
||||
vectorScore?: number;
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import { Document } from '@element-plus/icons-vue';
|
||||
import { ElButton, ElEmpty, ElIcon, ElTag } from 'element-plus';
|
||||
|
||||
import {
|
||||
markdownRenderProps,
|
||||
resolveMarkdownContent,
|
||||
} from '#/views/ai/documentCollection/markdown-content';
|
||||
|
||||
type RetrievalMode = 'HYBRID' | 'KEYWORD' | 'VECTOR';
|
||||
type HitSource = 'BOTH' | 'KEYWORD' | 'VECTOR';
|
||||
|
||||
interface PreviewItem {
|
||||
sorting: number | string;
|
||||
content: string;
|
||||
renderMarkdown?: string;
|
||||
sourceFileName?: string;
|
||||
score?: number | string;
|
||||
hitSource?: HitSource;
|
||||
}
|
||||
@@ -42,12 +52,12 @@ const props = defineProps({
|
||||
default: false,
|
||||
},
|
||||
onCancel: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
type: Function as PropType<() => void>,
|
||||
default: () => undefined,
|
||||
},
|
||||
onConfirm: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
type: Function as PropType<() => void>,
|
||||
default: () => undefined,
|
||||
},
|
||||
isSearching: {
|
||||
type: Boolean,
|
||||
@@ -100,18 +110,31 @@ const resolveHitSourceType = (hitSource?: HitSource) => {
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const normalizePreviewContent = (content?: string) => {
|
||||
if (!content) {
|
||||
return '';
|
||||
const resolvePreviewMarkdown = (item: PreviewItem) =>
|
||||
resolveMarkdownContent(item.renderMarkdown || item.content);
|
||||
|
||||
const resolveScoreLine = (item: PreviewItem) => {
|
||||
const pieces: string[] = [];
|
||||
if (
|
||||
!props.hideScore &&
|
||||
item.score !== undefined &&
|
||||
item.score !== null &&
|
||||
`${item.score}` !== ''
|
||||
) {
|
||||
pieces.push(`${$t('documentCollection.similarityScore')}: ${item.score}`);
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof DOMParser !== 'undefined') {
|
||||
const doc = new DOMParser().parseFromString(content, 'text/html');
|
||||
return (doc.body.textContent || '').replaceAll(/\n\s*\n/g, '\n').trim();
|
||||
if (item.sourceFileName) {
|
||||
pieces.push(`来源: ${item.sourceFileName}`);
|
||||
}
|
||||
return content
|
||||
.replaceAll(/<[^>]+>/g, ' ')
|
||||
.replaceAll(/\s+/g, ' ')
|
||||
.trim();
|
||||
return pieces.join(' · ');
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
props.onCancel?.();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
props.onConfirm?.();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
@@ -149,8 +172,8 @@ defineExpose({
|
||||
<div class="segment-badge">
|
||||
{{ item.sorting ?? index + 1 }}
|
||||
</div>
|
||||
<div v-if="!hideScore" class="score-text">
|
||||
{{ $t('documentCollection.similarityScore') }}: {{ item.score }}
|
||||
<div v-if="resolveScoreLine(item)" class="score-text">
|
||||
{{ resolveScoreLine(item) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -174,8 +197,18 @@ defineExpose({
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-desc">
|
||||
{{ normalizePreviewContent(item.content) }}
|
||||
<div
|
||||
v-if="resolvePreviewMarkdown(item)"
|
||||
class="content-desc content-desc--markdown"
|
||||
>
|
||||
<ElXMarkdown
|
||||
:markdown="resolvePreviewMarkdown(item)"
|
||||
:allow-html="markdownRenderProps.allowHtml"
|
||||
:sanitize="markdownRenderProps.sanitize"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="content-desc">
|
||||
{{ item.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,17 +226,17 @@ defineExpose({
|
||||
<div class="action-buttons">
|
||||
<ElButton
|
||||
:style="{ minWidth: '100px', height: '36px' }"
|
||||
@click="onCancel"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ $t('documentCollection.actions.confirmImport') }}
|
||||
{{ $t('documentCollection.actions.cancelImport') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:style="{ minWidth: '100px', height: '36px' }"
|
||||
:loading="disabledConfirm"
|
||||
@click="onConfirm"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ $t('documentCollection.actions.cancelImport') }}
|
||||
{{ $t('documentCollection.actions.confirmImport') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -276,12 +309,26 @@ defineExpose({
|
||||
padding: 14px 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: rgb(248 250 252 / 90%);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.content-desc--markdown :deep(.markdown-body) {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-desc--markdown :deep(.markdown-body > :first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.content-desc--markdown :deep(.markdown-body > :last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.score-text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
@@ -85,7 +86,7 @@ const createDefaultFormState = () => ({
|
||||
mdSplitterLevel: 2,
|
||||
overlapSize: 128,
|
||||
regex: '',
|
||||
rowsPerChunk: 1,
|
||||
rowsPerChunk: 10,
|
||||
strategyCode: 'AUTO',
|
||||
});
|
||||
|
||||
@@ -126,6 +127,17 @@ const strategyOptions = [
|
||||
},
|
||||
];
|
||||
|
||||
const fileExt = computed(() =>
|
||||
String(props.documentTitle || '')
|
||||
.split('.')
|
||||
.pop()
|
||||
?.toLowerCase() || '',
|
||||
);
|
||||
|
||||
const isPptx = computed(() => fileExt.value === 'pptx');
|
||||
const isXlsx = computed(() => fileExt.value === 'xlsx');
|
||||
const showStrategySelector = computed(() => !isPptx.value && !isXlsx.value);
|
||||
|
||||
const mdLevels = [1, 2, 3, 4, 5, 6];
|
||||
|
||||
const showLengthSettings = (strategyCode?: string) =>
|
||||
@@ -150,9 +162,22 @@ const resetPreviewState = () => {
|
||||
previewError.value = '';
|
||||
};
|
||||
|
||||
const buildStrategyConfig = () => ({
|
||||
...formState,
|
||||
});
|
||||
const buildStrategyConfig = () => {
|
||||
if (isPptx.value) {
|
||||
return {
|
||||
strategyCode: 'OFFICE_PPTX_PAGE',
|
||||
};
|
||||
}
|
||||
if (isXlsx.value) {
|
||||
return {
|
||||
rowsPerChunk: formState.rowsPerChunk,
|
||||
strategyCode: 'OFFICE_XLSX_ROW_WINDOW',
|
||||
};
|
||||
}
|
||||
return {
|
||||
...formState,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeSourceRanges = (ranges?: SourceRange[]) =>
|
||||
Array.isArray(ranges)
|
||||
@@ -292,6 +317,13 @@ watch(
|
||||
previewSequence += 1;
|
||||
clearPreviewTimer();
|
||||
Object.assign(formState, createDefaultFormState());
|
||||
if (isPptx.value) {
|
||||
formState.strategyCode = 'OFFICE_PPTX_PAGE';
|
||||
}
|
||||
if (isXlsx.value) {
|
||||
formState.strategyCode = 'OFFICE_XLSX_ROW_WINDOW';
|
||||
formState.rowsPerChunk = 10;
|
||||
}
|
||||
resetPreviewState();
|
||||
if (activeDocumentId.value) {
|
||||
schedulePreviewGeneration();
|
||||
@@ -317,6 +349,7 @@ watch(
|
||||
<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"
|
||||
>
|
||||
@@ -330,6 +363,20 @@ watch(
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem
|
||||
v-if="isXlsx"
|
||||
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')"
|
||||
@@ -478,6 +525,10 @@ watch(
|
||||
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%);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import { ElEmpty, ElSkeleton, ElTag } from 'element-plus';
|
||||
|
||||
import {
|
||||
markdownRenderProps,
|
||||
resolveMarkdownContent,
|
||||
} from '#/views/ai/documentCollection/markdown-content';
|
||||
|
||||
interface SourceRange {
|
||||
end: number;
|
||||
start: number;
|
||||
@@ -21,6 +27,7 @@ interface ChunkItem {
|
||||
partNo?: number;
|
||||
partTotal?: number;
|
||||
question?: string;
|
||||
renderMarkdown?: string;
|
||||
sourceLabel?: string;
|
||||
sourceRanges?: SourceRange[];
|
||||
tokenEstimate?: number;
|
||||
@@ -180,15 +187,38 @@ const isActiveChunk = (chunk: ChunkItem) =>
|
||||
<div v-if="chunk.chunkType === 'qa_pair'" class="qa-block">
|
||||
<div class="qa-block__item">
|
||||
<span class="qa-block__label">Q</span>
|
||||
<span class="qa-block__text">{{ chunk.question }}</span>
|
||||
<div class="qa-block__text">
|
||||
<ElXMarkdown
|
||||
:markdown="resolveMarkdownContent(chunk.question)"
|
||||
:allow-html="markdownRenderProps.allowHtml"
|
||||
:sanitize="markdownRenderProps.sanitize"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qa-block__item">
|
||||
<span class="qa-block__label">A</span>
|
||||
<span class="qa-block__text">{{ chunk.answer }}</span>
|
||||
<div class="qa-block__text">
|
||||
<ElXMarkdown
|
||||
:markdown="resolveMarkdownContent(chunk.answer)"
|
||||
:allow-html="markdownRenderProps.allowHtml"
|
||||
:sanitize="markdownRenderProps.sanitize"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre class="chunk-card__content">{{ chunk.content }}</pre>
|
||||
<div
|
||||
v-if="resolveMarkdownContent(chunk.renderMarkdown || chunk.content)"
|
||||
class="chunk-card__content chunk-card__content--markdown"
|
||||
>
|
||||
<ElXMarkdown
|
||||
:markdown="
|
||||
resolveMarkdownContent(chunk.renderMarkdown || chunk.content)
|
||||
"
|
||||
:allow-html="markdownRenderProps.allowHtml"
|
||||
:sanitize="markdownRenderProps.sanitize"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="chunk.warnings && chunk.warnings.length > 0"
|
||||
@@ -342,14 +372,129 @@ const isActiveChunk = (chunk: ChunkItem) =>
|
||||
|
||||
.chunk-card__content {
|
||||
margin: 14px 0 0;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
color: var(--el-text-color-regular);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chunk-card__content--markdown {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chunk-card__content--markdown :deep(.markdown-body) {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chunk-card__content--markdown :deep(.markdown-body > :first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chunk-card__content--markdown :deep(.markdown-body > :last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chunk-card__content--markdown :deep(*) {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.chunk-card__content--markdown :deep(p) {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.chunk-card__content--markdown :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chunk-card__content--markdown :deep(h1),
|
||||
.chunk-card__content--markdown :deep(h2),
|
||||
.chunk-card__content--markdown :deep(h3),
|
||||
.chunk-card__content--markdown :deep(h4),
|
||||
.chunk-card__content--markdown :deep(h5),
|
||||
.chunk-card__content--markdown :deep(h6) {
|
||||
margin: 14px 0 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.chunk-card__content--markdown :deep(h1:first-child),
|
||||
.chunk-card__content--markdown :deep(h2:first-child),
|
||||
.chunk-card__content--markdown :deep(h3:first-child),
|
||||
.chunk-card__content--markdown :deep(h4:first-child),
|
||||
.chunk-card__content--markdown :deep(h5:first-child),
|
||||
.chunk-card__content--markdown :deep(h6:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chunk-card__content--markdown :deep(ul),
|
||||
.chunk-card__content--markdown :deep(ol) {
|
||||
padding-left: 20px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.chunk-card__content--markdown :deep(li + li) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.chunk-card__content--markdown :deep(a) {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.chunk-card__content--markdown :deep(img) {
|
||||
display: block;
|
||||
max-width: min(100%, 520px);
|
||||
height: auto;
|
||||
margin: 12px 0;
|
||||
border: 1px solid rgb(15 23 42 / 8%);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 24px rgb(15 23 42 / 8%);
|
||||
}
|
||||
|
||||
.chunk-card__content--markdown :deep(table) {
|
||||
width: 100%;
|
||||
margin: 12px 0;
|
||||
overflow: hidden;
|
||||
border-collapse: collapse;
|
||||
background: rgb(255 255 255 / 92%);
|
||||
border: 1px solid rgb(15 23 42 / 8%);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.chunk-card__content--markdown :deep(th),
|
||||
.chunk-card__content--markdown :deep(td) {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
border: 1px solid rgb(15 23 42 / 8%);
|
||||
}
|
||||
|
||||
.chunk-card__content--markdown :deep(th) {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
background: rgb(37 99 235 / 4%);
|
||||
}
|
||||
|
||||
.chunk-card__content--markdown :deep(pre) {
|
||||
max-width: 100%;
|
||||
padding: 12px 14px;
|
||||
overflow: auto;
|
||||
background: rgb(15 23 42 / 4%);
|
||||
border: 1px solid rgb(15 23 42 / 6%);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.chunk-card__content--markdown :deep(blockquote) {
|
||||
padding-left: 12px;
|
||||
margin: 12px 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
border-left: 3px solid rgb(37 99 235 / 24%);
|
||||
}
|
||||
|
||||
.chunk-card__warnings {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -387,10 +532,32 @@ const isActiveChunk = (chunk: ChunkItem) =>
|
||||
}
|
||||
|
||||
.qa-block__text {
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-regular);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.qa-block__text :deep(.markdown-body) {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.qa-block__text :deep(.markdown-body > :first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.qa-block__text :deep(.markdown-body > :last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.qa-block__text :deep(img) {
|
||||
display: block;
|
||||
max-width: min(100%, 420px);
|
||||
height: auto;
|
||||
margin: 10px 0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
const ESCAPED_TABLE_HTML_TAG_PATTERN =
|
||||
/<\/?(?:table|thead|tbody|tfoot|tr|th|td|caption|colgroup|col)\b/i;
|
||||
|
||||
/**
|
||||
* 将知识库分块内容规整为可直接交给 Markdown 组件的文本。
|
||||
* 这里额外兼容被转义的 HTML 片段,例如 `<table>...</table>`。
|
||||
*/
|
||||
export const resolveMarkdownContent = (content?: string) => {
|
||||
const markdown = String(content || '').trim();
|
||||
if (!markdown) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (ESCAPED_TABLE_HTML_TAG_PATTERN.test(markdown)) {
|
||||
return decodeHtmlEntities(markdown);
|
||||
}
|
||||
|
||||
return markdown;
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一控制 Markdown 中原生 HTML 的开启策略。
|
||||
*/
|
||||
export const markdownRenderProps = {
|
||||
allowHtml: true,
|
||||
sanitize: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 解码后端可能返回的 HTML 实体,便于 Markdown 组件继续处理原生标签。
|
||||
*/
|
||||
function decodeHtmlEntities(content: string) {
|
||||
if (typeof window === 'undefined' || window.DOMParser === undefined) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const parser = new window.DOMParser();
|
||||
const doc = parser.parseFromString(content, 'text/html');
|
||||
return doc.documentElement.textContent || content;
|
||||
}
|
||||
Reference in New Issue
Block a user