feat: 支持知识库导入 PPTX 与 XLSX 文档

- 打通 Office 文档桥接解析、解析进度承接与图片引用改写

- 落地 PPTX 按页分块、XLSX 行窗口分块以及预览与检索渲染闭环
This commit is contained in:
2026-04-18 13:01:17 +08:00
parent ad67ba85ad
commit 4130381658
28 changed files with 2876 additions and 120 deletions

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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%);
}

View File

@@ -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) {

View File

@@ -0,0 +1,40 @@
const ESCAPED_TABLE_HTML_TAG_PATTERN =
/&lt;\/?(?:table|thead|tbody|tfoot|tr|th|td|caption|colgroup|col)\b/i;
/**
* 将知识库分块内容规整为可直接交给 Markdown 组件的文本。
* 这里额外兼容被转义的 HTML 片段,例如 `&lt;table&gt;...&lt;/table&gt;`。
*/
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;
}