feat(frontend): improve manual knowledge editor and chat citation rendering

- Replace manual knowledge editor dialog with SettingDrawer and refine toolbar/footer layout
- Add draft status resolution for manual knowledge items in the knowledge base
- Fix citation placement near fenced code blocks and add regression tests
- Update ChatRequestInfoButton icon and chat message/citation styles
- Add i18n strings for manual knowledge editor features
This commit is contained in:
wizardchen
2026-06-17 20:50:42 +08:00
committed by lyingbug
parent 4c5379df9c
commit e0d3c1dafd
15 changed files with 396 additions and 269 deletions
@@ -14,7 +14,7 @@
shape="round"
:title="$t('chat.requestInfoTitle')"
>
<t-icon name="bug" />
<t-icon name="info-circle" />
</t-button>
<template #content>
<div class="chat-request-card" @click.stop>
@@ -49,7 +49,7 @@
:deep(.citation-kb) {
background: color-mix(in srgb, var(--td-text-color-primary) 4%, transparent);
color: color-mix(in srgb, var(--td-text-color-secondary) 86%, var(--td-text-color-primary));
color: color-mix(in srgb, var(--td-text-color-primary) 86%, var(--td-text-color-primary));
border: 0;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--td-text-color-primary) 10%, transparent);
cursor: pointer;
@@ -1,21 +1,26 @@
.answer-toolbar {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
margin-top: 8px;
min-height: 32px;
gap: 4px;
margin-top: 6px;
margin-left: -7px;
min-height: 30px;
color: var(--td-text-color-primary);
:deep(.t-button) {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: auto;
width: auto;
border: 1px solid var(--td-component-stroke);
border-radius: 6px;
background: var(--td-bg-color-container);
width: 30px;
height: 30px;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--td-text-color-secondary);
transition: all 0.2s ease;
transition: background-color 0.15s ease, color 0.15s ease;
.t-button__content {
display: inline-flex !important;
@@ -34,20 +39,28 @@
.t-icon {
display: inline-flex !important;
visibility: visible !important;
opacity: 1 !important;
opacity: 1;
align-items: center;
justify-content: center;
font-size: 16px;
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--td-text-color-secondary);
color: inherit;
}
.t-icon svg {
display: block !important;
width: 16px;
height: 16px;
stroke-linecap: round;
stroke-linejoin: round;
}
.t-icon svg [stroke]:not([stroke='none']) {
stroke-width: 1.2;
stroke-linecap: round;
stroke-linejoin: round;
}
.t-button__text > :not(.t-icon) {
@@ -55,19 +68,21 @@
}
&:hover:not(:disabled) {
background: rgba(7, 192, 95, 0.08);
border-color: rgba(7, 192, 95, 0.3);
color: var(--td-brand-color);
background: var(--td-bg-color-container-hover);
color: var(--td-text-color-primary);
.t-icon {
color: var(--td-brand-color);
opacity: 1;
color: inherit;
}
}
&:active:not(:disabled) {
background: rgba(7, 192, 95, 0.12);
border-color: rgba(7, 192, 95, 0.4);
transform: translateY(0.5px);
background: var(--td-bg-color-container-active);
.t-icon {
opacity: 1;
}
}
}
}
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import SettingDrawer from '@/components/settings/SettingDrawer.vue'
import { marked } from 'marked'
import { MessagePlugin } from 'tdesign-vue-next'
import { useUIStore } from '@/stores/ui'
@@ -32,6 +33,25 @@ interface KnowledgeDetailResponse {
type ManualStatus = 'draft' | 'publish'
/** Derive editor status from metadata + parse_status (parse pipeline wins when indexed or in flight). */
const resolveManualKnowledgeStatus = (
metaStatus: ManualStatus | undefined,
parseStatus?: string,
): ManualStatus => {
if (!parseStatus || parseStatus === 'draft') {
return metaStatus === 'publish' ? 'publish' : 'draft'
}
if (
parseStatus === 'completed' ||
parseStatus === 'pending' ||
parseStatus === 'processing' ||
parseStatus === 'finalizing'
) {
return 'publish'
}
return metaStatus === 'publish' ? 'publish' : 'draft'
}
const uiStore = useUIStore()
const uploadConfirmStore = useUploadConfirmStore()
const organizationStore = useOrganizationStore()
@@ -359,11 +379,6 @@ const toolbarGroups = computed<ToolbarGroup[]>(() => [
const isPreviewMode = computed(() => activeTab.value === 'preview')
const viewToggleIcon = computed(() => (isPreviewMode.value ? 'edit' : 'view-module'))
const viewToggleTooltip = computed(() =>
isPreviewMode.value
? t('manualEditor.view.toggleToEdit')
: t('manualEditor.view.toggleToPreview'),
)
const viewToggleLabel = computed(() =>
isPreviewMode.value ? t('manualEditor.view.editLabel') : t('manualEditor.view.previewLabel'),
)
@@ -507,7 +522,7 @@ const loadKnowledgeContent = async () => {
uiStore.manualEditorInitialTitle ||
''
form.content = meta?.content || uiStore.manualEditorInitialContent || ''
form.status = meta?.status || (data.parse_status === 'completed' ? 'publish' : 'draft')
form.status = resolveManualKnowledgeStatus(meta?.status, data.parse_status)
if (meta?.updatedAt) {
lastUpdatedAt.value = meta.updatedAt
}
@@ -711,193 +726,223 @@ onBeforeUnmount(() => {
</script>
<template>
<t-dialog
v-model:visible="visible"
:header="dialogTitle"
:closeBtn="true"
:footer="false"
width="880px"
top="5%"
class="manual-knowledge-editor"
destroy-on-close
<SettingDrawer
:visible="visible"
:title="dialogTitle"
:description="$t('manualEditor.description')"
icon="edit-1"
width="760px"
:min-width="560"
:max-width="1280"
storage-key="setting-drawer:width:manual-markdown-editor"
:destroy-on-close="false"
:hide-footer="!initialLoaded"
:confirm-loading="saving && savingAction === 'publish'"
:confirm-text="$t('manualEditor.actions.publish')"
:cancel-text="$t('manualEditor.actions.cancel')"
@update:visible="(v: boolean) => { visible = v }"
@confirm="handleSave('publish')"
>
<div class="editor-body" v-if="initialLoaded">
<div class="form-row">
<label class="form-label">{{ $t('manualEditor.form.knowledgeBaseLabel') }}</label>
<t-select
v-model="form.kbId"
:disabled="kbDisabled"
:loading="kbLoading"
:options="kbOptions"
:placeholder="$t('manualEditor.form.knowledgeBasePlaceholder')"
:popup-props="{ attach: 'body' }"
>
<template #empty>
<div style="padding: 20px; text-align: center; color: var(--td-text-color-placeholder);">
{{ $t('manualEditor.noDocumentKnowledgeBases') }}
<template #footer-left>
<t-button
variant="text"
theme="primary"
class="toggle-view-btn"
@click="toggleEditorView"
>
<template #icon><t-icon :name="viewToggleIcon" /></template>
{{ viewToggleLabel }}
</t-button>
<t-button
variant="outline"
theme="default"
@click="handleSave('draft')"
:loading="saving && savingAction === 'draft'"
>
{{ $t('manualEditor.actions.saveDraft') }}
</t-button>
</template>
<div class="manual-editor" v-if="initialLoaded">
<section class="setting-drawer__section">
<h4 class="setting-drawer__section-title">{{ $t('manualEditor.section.basic') }}</h4>
<div class="form-item">
<label class="form-label required">{{ $t('manualEditor.form.titleLabel') }}</label>
<t-input
v-model="form.title"
maxlength="100"
:placeholder="$t('manualEditor.form.titlePlaceholder')"
showLimitNumber
/>
</div>
<div class="form-item">
<label class="form-label required">{{ $t('manualEditor.form.knowledgeBaseLabel') }}</label>
<div class="kb-row">
<t-select
v-model="form.kbId"
:disabled="kbDisabled"
:loading="kbLoading"
:options="kbOptions"
:placeholder="$t('manualEditor.form.knowledgeBasePlaceholder')"
:popup-props="{ attach: 'body', zIndex: 2600 }"
>
<template #empty>
<div style="padding: 20px; text-align: center; color: var(--td-text-color-placeholder);">
{{ $t('manualEditor.noDocumentKnowledgeBases') }}
</div>
</template>
</t-select>
<div class="status-row" v-if="mode === 'edit'">
<t-tag size="small" theme="warning" variant="light" v-if="form.status === 'draft'">
{{ $t('manualEditor.status.draftTag') }}
</t-tag>
<t-tag size="small" theme="success" variant="light" v-else>
{{ $t('manualEditor.status.publishedTag') }}
</t-tag>
</div>
</template>
</t-select>
</div>
</div>
<p v-if="lastUpdatedText" class="form-desc">{{ lastUpdatedText }}</p>
</div>
</section>
<div class="form-row">
<label class="form-label">{{ $t('manualEditor.form.titleLabel') }}</label>
<t-input
v-model="form.title"
maxlength="100"
:placeholder="$t('manualEditor.form.titlePlaceholder')"
showLimitNumber
/>
</div>
<section class="setting-drawer__section editor-section">
<h4 class="setting-drawer__section-title">{{ $t('manualEditor.section.content') }}</h4>
<div class="status-row" v-if="mode === 'edit'">
<t-tag theme="warning" v-if="form.status === 'draft'">{{ $t('manualEditor.status.draftTag') }}</t-tag>
<t-tag theme="success" v-else>{{ $t('manualEditor.status.publishedTag') }}</t-tag>
<span v-if="lastUpdatedText" class="status-timestamp">{{ lastUpdatedText }}</span>
</div>
<div class="editor-toolbar">
<template v-for="(group, groupIndex) in toolbarGroups" :key="group.key">
<div class="toolbar-group">
<template v-for="btn in group.buttons" :key="btn.key">
<t-tooltip :content="btn.tooltip" placement="top">
<button
type="button"
class="toolbar-btn"
:class="`btn-${btn.key}`"
@mousedown.prevent
@click="handleToolbarAction(btn.action)"
>
<t-icon :name="btn.icon" size="18px" />
</button>
</t-tooltip>
<div class="editor-area">
<div class="editor-toolbar">
<template v-for="(group, groupIndex) in toolbarGroups" :key="group.key">
<div class="toolbar-group">
<template v-for="btn in group.buttons" :key="btn.key">
<t-tooltip :content="btn.tooltip" placement="top">
<button
type="button"
class="toolbar-btn"
:class="`btn-${btn.key}`"
@mousedown.prevent
@click="handleToolbarAction(btn.action)"
>
<t-icon :name="btn.icon" size="18px" />
</button>
</t-tooltip>
</template>
</div>
<div
v-if="groupIndex < toolbarGroups.length - 1"
class="toolbar-divider"
></div>
</template>
</div>
<div
v-if="groupIndex < toolbarGroups.length - 1"
class="toolbar-divider"
></div>
</template>
</div>
<div class="editor-area">
<div class="editor-pane" v-show="activeTab === 'edit'">
<t-textarea
ref="textareaComponent"
v-if="!contentLoading"
v-model="form.content"
:placeholder="$t('manualEditor.form.contentPlaceholder')"
:autosize="{ minRows: 16, maxRows: 24 }"
/>
<div v-else class="loading-placeholder">
<t-loading size="small" :text="$t('manualEditor.loading.content')" />
<div class="editor-pane" v-show="activeTab === 'edit'">
<t-textarea
ref="textareaComponent"
v-if="!contentLoading"
v-model="form.content"
:placeholder="$t('manualEditor.form.contentPlaceholder')"
class="editor-textarea"
/>
<div v-else class="loading-placeholder">
<t-loading size="small" :text="$t('manualEditor.loading.content')" />
</div>
</div>
<div class="editor-pane editor-pane--preview" v-show="activeTab === 'preview'">
<div class="preview-container" v-html="previewHTML" />
</div>
</div>
<div class="editor-pane" v-show="activeTab === 'preview'">
<div class="preview-container" v-html="previewHTML" />
</div>
</div>
<div class="dialog-footer">
<div class="footer-left">
<t-button variant="outline" theme="default" @click="handleClose">
{{ $t('manualEditor.actions.cancel') }}
</t-button>
</div>
<div class="footer-right">
<t-tooltip :content="viewToggleTooltip" placement="top">
<t-button
variant="outline"
theme="default"
class="toggle-view-btn"
:class="{ active: isPreviewMode }"
@click="toggleEditorView"
>
<t-icon :name="viewToggleIcon" size="16px" />
<span>{{ viewToggleLabel }}</span>
</t-button>
</t-tooltip>
<t-button
variant="outline"
theme="default"
@click="handleSave('draft')"
:loading="saving && savingAction === 'draft'"
>
{{ $t('manualEditor.actions.saveDraft') }}
</t-button>
<t-button
theme="primary"
@click="handleSave('publish')"
:loading="saving && savingAction === 'publish'"
>
{{ $t('manualEditor.actions.publish') }}
</t-button>
</div>
</div>
</section>
</div>
<div v-else class="loading-wrapper">
<t-loading size="medium" :text="$t('manualEditor.loading.preparing')" />
</div>
</t-dialog>
</SettingDrawer>
</template>
<style scoped lang="less">
.manual-knowledge-editor {
:deep(.t-dialog__body) {
padding: 20px 24px 12px;
max-height: 80vh;
overflow-y: auto;
}
}
.editor-body {
/* 复用模型管理同款 SettingDrawer:分组 section / header 图标 / footer 按钮 / 拖拽调宽。
这里只负责本编辑器特有的内容样式。内容内联渲染(无 teleport),scoped 生效。 */
.manual-editor {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-row {
.form-item {
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
}
.form-label {
font-size: 14px;
font-size: 13px;
font-weight: 500;
color: var(--td-text-color-primary);
&.required::after {
content: '*';
margin-left: 4px;
color: var(--td-error-color);
}
}
.form-desc {
margin: 2px 0 0;
font-size: 12px;
color: var(--td-text-color-placeholder);
}
.kb-row {
display: flex;
align-items: center;
gap: 12px;
:deep(.t-select) {
flex: 1;
min-width: 0;
}
}
.status-row {
display: flex;
align-items: center;
gap: 12px;
gap: 6px;
flex-shrink: 0;
white-space: nowrap;
}
/* 内容分组:让编辑区占满,无需依赖父级 flex 链路,直接用视口高度,稳健 */
.editor-section {
flex: 1;
min-height: 0;
}
.editor-toolbar {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-stroke);
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
gap: 6px;
padding: 6px 8px;
background: var(--td-bg-color-secondarycontainer);
border-bottom: 1px solid var(--td-component-stroke);
overflow-x: auto;
flex-shrink: 0;
&::-webkit-scrollbar {
height: 0;
}
}
.toolbar-group {
display: flex;
align-items: center;
gap: 4px;
gap: 2px;
}
.toolbar-divider {
width: 1px;
height: 24px;
background: var(--td-bg-color-secondarycontainer);
margin: 0 2px;
height: 18px;
background: var(--td-component-stroke);
margin: 0 4px;
}
.toolbar-btn {
@@ -950,90 +995,69 @@ onBeforeUnmount(() => {
transform: translateY(0.5px);
}
:deep(.toggle-view-btn) {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 16px;
height: 32px;
line-height: 32px;
transition: all 0.18s ease;
}
.editor-area {
/* 抽屉为整屏高,减去 header/footer/基本信息分组的大致高度,
让编辑区占据剩余空间且不必撑满父级 flex 链路。 */
height: calc(100vh - 360px);
min-height: 280px;
display: flex;
flex-direction: column;
border: 1px solid var(--td-component-stroke);
border-radius: 8px;
overflow: hidden;
background: var(--td-bg-color-container);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
:deep(.toggle-view-btn .t-button__content) {
display: inline-flex;
align-items: center;
gap: 6px;
}
:deep(.toggle-view-btn .t-button__text) {
display: inline-flex;
align-items: center;
gap: 6px;
}
:deep(.toggle-view-btn .t-icon) {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 16px;
width: 16px;
height: 16px;
vertical-align: middle;
}
:deep(.toggle-view-btn .t-icon svg) {
display: block;
width: 16px;
height: 16px;
vertical-align: middle;
}
:deep(.toggle-view-btn .t-button__text > span:not(.t-icon)) {
font-size: 13px;
line-height: 1.5;
vertical-align: middle;
}
:deep(.toggle-view-btn.active),
:deep(.toggle-view-btn:hover) {
background: rgba(7, 192, 95, 0.12) !important;
color: var(--td-brand-color-active) !important;
border-color: rgba(7, 192, 95, 0.4) !important;
.t-icon {
color: var(--td-brand-color-active);
&:focus-within {
border-color: var(--td-brand-color);
box-shadow: 0 0 0 2px rgba(7, 192, 95, 0.1);
}
}
.status-timestamp {
font-size: 12px;
color: var(--td-text-color-disabled);
}
.editor-area {
.editor-pane {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.editor-pane {
padding: 0;
overflow: hidden;
background: var(--td-bg-color-container);
}
:deep(.t-textarea__inner) {
font-family: var(--app-font-family-mono);
line-height: 1.6;
:deep(.editor-textarea) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
.t-textarea__inner {
flex: 1;
height: 100% !important;
resize: none;
border: none;
border-radius: 0;
padding: 14px 16px;
font-family: var(--app-font-family-mono);
font-size: 14px;
line-height: 1.7;
background: var(--td-bg-color-container);
&:focus {
box-shadow: none;
}
}
}
.editor-pane--preview {
background: var(--td-bg-color-container);
}
.preview-container {
min-height: 300px;
max-height: 520px;
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
background: var(--td-bg-color-secondarycontainer);
background: var(--td-bg-color-container);
font-size: 14px;
line-height: 1.7;
color: var(--td-text-color-primary);
@@ -1073,24 +1097,14 @@ onBeforeUnmount(() => {
}
}
.dialog-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
.footer-right {
display: flex;
gap: 16px;
}
.loading-wrapper,
.loading-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
flex: 1;
min-height: 280px;
padding: 20px;
}
.empty-preview {
@@ -1098,11 +1112,4 @@ onBeforeUnmount(() => {
}
</style>
<style lang="less">
// 全局样式:确保 select 下拉列表在 dialog 之上
.t-popup {
z-index: 2600 !important;
}
</style>
+14 -6
View File
@@ -155,7 +155,11 @@
</div>
</div>
<div v-for="subitem in bucket.items" :key="subitem.id"
class="submenu_item_p session-chat-row session-folder-item">
class="submenu_item_p session-chat-row session-folder-item"
:class="{
'session-chat-row--active': !batchMode && subitem.path === currentSecondpath,
'session-chat-row--selected': batchMode && batchSelectedIds.includes(subitem.id),
}">
<div class="session-list-row session-list-row--flat">
<div class="session-list-row__body">
<SessionSidebarRow :item="subitem" :batch-mode="batchMode"
@@ -187,7 +191,11 @@
</span>
</div>
<div v-for="subitem in group.items" :key="subitem.id"
class="submenu_item_p session-chat-row">
class="submenu_item_p session-chat-row"
:class="{
'session-chat-row--active': !batchMode && subitem.path === currentSecondpath,
'session-chat-row--selected': batchMode && batchSelectedIds.includes(subitem.id),
}">
<div class="session-list-row session-list-row--flat">
<div class="session-list-row__body">
<SessionSidebarRow :item="subitem" :batch-mode="batchMode"
@@ -1691,15 +1699,15 @@ const onDragHandleMouseDown = (e: MouseEvent) => {
}
}
&.session-chat-row:has(:deep(.submenu_item_active)) .session-list-row {
background: var(--td-brand-color-light);
&.session-chat-row--active .session-list-row {
background: var(--td-bg-color-container-hover);
:deep(.submenu_item) {
color: var(--td-brand-color);
}
:deep(.menu-more) {
color: var(--td-brand-color);
color: var(--td-text-color-primary);
}
:deep(.menu-more-wrap) {
@@ -1707,7 +1715,7 @@ const onDragHandleMouseDown = (e: MouseEvent) => {
}
}
&.session-chat-row:has(:deep(.submenu_item_selected)) .session-list-row {
&.session-chat-row--selected .session-list-row {
background: rgba(7, 192, 95, 0.05);
}
}
@@ -6,7 +6,9 @@
<div class="setting-drawer-resize-line" />
</div>
</teleport>
<t-drawer v-model:visible="drawerVisible" :size="effectiveWidth" :z-index="2500" placement="right" destroy-on-close
<t-drawer v-model:visible="drawerVisible" :size="effectiveWidth" :z-index="2500" placement="right"
:attach="attach"
:destroy-on-close="destroyOnClose"
:class="['setting-drawer', { 'setting-drawer--resizing': drawerResizing }]">
<!--
Custom header. We replace TDesign's default header so we can put a leading
@@ -86,6 +88,23 @@ interface Props {
confirmText?: string
cancelText?: string
hideFooter?: boolean
/**
* Whether to destroy the drawer body content on close. Defaults to true to
* match historical behavior. Heavy editors (e.g. the markdown knowledge
* editor) pass false so the drawer panel persists across opens — this avoids
* the panel being removed/re-inserted on every open, which can cause a
* one-frame layout flicker as it slides in.
*/
destroyOnClose?: boolean
/**
* Where to render the drawer. Defaults to 'body' so the drawer escapes the
* app root, which carries `transform: translateZ(0)` for GPU compositing.
* A transformed ancestor becomes the containing block for `position: fixed`,
* which mis-anchors the overlay (it briefly appears mid-screen then snaps to
* the edge). Teleporting to <body> keeps it fixed to the viewport, matching
* the resize handle that is already teleported to body.
*/
attach?: string
}
const props = withDefaults(defineProps<Props>(), {
@@ -100,7 +119,9 @@ const props = withDefaults(defineProps<Props>(), {
confirmDisabled: false,
confirmText: '',
cancelText: '',
hideFooter: false
hideFooter: false,
destroyOnClose: true,
attach: 'body'
})
const emit = defineEmits<{
+5
View File
@@ -2052,6 +2052,11 @@ export default {
edit: 'Edit Markdown Knowledge',
create: 'Create Markdown Knowledge'
},
description: 'Write knowledge in Markdown with live preview',
section: {
basic: 'Basic Info',
content: 'Content'
},
labels: {
currentKnowledgeBase: 'Current knowledge base'
},
+5
View File
@@ -2979,6 +2979,11 @@ export default {
edit: "Markdown 지식 편집",
create: "온라인 Markdown 지식 편집",
},
description: "Markdown으로 지식을 작성하고 실시간 미리보기 지원",
section: {
basic: "기본 정보",
content: "지식 내용",
},
labels: {
currentKnowledgeBase: "현재 지식베이스",
},
+5
View File
@@ -4065,6 +4065,11 @@ export default {
edit: 'Редактировать Markdown-знание',
create: 'Создать Markdown-знание'
},
description: 'Пишите знания в Markdown с предпросмотром в реальном времени',
section: {
basic: 'Основная информация',
content: 'Содержимое'
},
labels: {
currentKnowledgeBase: 'Текущая база знаний'
},
+5
View File
@@ -2996,6 +2996,11 @@ export default {
edit: "编辑 Markdown 知识",
create: "在线编辑 Markdown 知识",
},
description: "使用 Markdown 编写知识内容,支持实时预览",
section: {
basic: "基本信息",
content: "知识内容",
},
labels: {
currentKnowledgeBase: "当前知识库",
},
@@ -173,6 +173,32 @@ test('joinCitationTagsToPreviousLine keeps citations on a new line after a list'
assert.equal(joinCitationTagsToPreviousLine(input), `1. first\n2. second\n\n${tag}`)
})
test('joinCitationTagsToPreviousLine does not merge citations onto fenced code closing delimiter', () => {
const tag = '<kb doc="guide.pdf" chunk_id="1" />'
const input = '```bash\nunzip setup.zip\n```\n\n' + tag
assert.equal(joinCitationTagsToPreviousLine(input), '```bash\nunzip setup.zip\n```\n\n' + tag)
})
test('renderChatMarkdown keeps fenced code blocks closed when citations follow', () => {
const renderer = createChatMarkdownRenderer({
imageRenderer: ({ href, text }) => `<img src="${href}" alt="${text}">`,
isValidImageUrl: () => true,
})
const tag = '<kb doc="guide.pdf" chunk_id="1" />'
const html = renderChatMarkdown(
['```bash', 'unzip setup.zip', '```', '', tag, '', '#### Next step'].join('\n'),
{
renderer,
escapeMarkdown: (text) => text,
sanitizeHtml: (html) => html,
},
)
assert.doesNotMatch(html, /#### Next step/)
assert.match(html, /<h4>Next step<\/h4>/)
assert.equal((html.match(/<pre>/g) || []).length, 1)
})
test('collapseStandaloneCitationParagraphs merges citations across empty paragraphs', () => {
const html = '<p>Steps:</p><p></p><p><span class="citation citation-kb" data-chunk-id="x">doc</span></p>'
const out = collapseStandaloneCitationParagraphs(html)
+18 -2
View File
@@ -201,6 +201,13 @@ export function restoreCitationHtmlPlaceholders(html: string, htmlSnippets: stri
return html.replace(HTML_PLACEHOLDER_RE, (_match, idx) => htmlSnippets[Number(idx)] || '')
}
/** Opening/closing fence for GFM fenced code blocks (up to 3 spaces indent). */
const FENCED_CODE_DELIMITER_RE = /^ {0,3}(`{3,}|~{3,})(\s*\S.*)?\s*$/
function isFencedCodeDelimiterLine(line: string): boolean {
return FENCED_CODE_DELIMITER_RE.test(line)
}
/** Collapse newlines around <kb/> / <web/> so marked keeps citations inline. */
export function joinCitationTagsToPreviousLine(content: string): string {
if (!content) return content
@@ -218,17 +225,26 @@ export function joinCitationTagsToPreviousLine(content: string): string {
}
// Blank lines before citations: join to previous prose, but keep a break after lists
// or fenced-code delimiters (``` / ~~~ must stay on their own line).
result = result.replace(/\n[ \t]*\n+([ \t]*<(?:kb|web)\b)/gi, (match, kbStart, offset, full) => {
const before = full.slice(0, offset)
const lastLine = before.split('\n').filter((line) => line.trim()).pop() || ''
const isListItem = /^\s*(\d+\.|[-*+])\s+\S/.test(lastLine)
return isListItem ? `\n\n${kbStart}` : ` ${kbStart}`
if (isListItem || isFencedCodeDelimiterLine(lastLine)) {
return `\n\n${kbStart}`
}
return ` ${kbStart}`
})
// Single newline before citation when it follows text or another citation (not after a blank line)
result = result.replace(
/(?<!\n)(<(?:kb|web)\b[^>]*?\s*\/?>|[ \t]*\S[^\n]*?)\n([ \t]*<(?:kb|web)\b)/g,
'$1 $2',
(match, beforePart: string, kbStart: string) => {
if (isFencedCodeDelimiterLine(beforePart)) {
return match
}
return `${beforePart} ${kbStart}`
},
)
return result
@@ -269,7 +269,7 @@
</t-button>
<t-button size="small" variant="outline" shape="round" @click.stop="handleAddToKnowledge(event)"
:title="$t('agent.addToKnowledgeBase')">
<t-icon name="add" />
<t-icon name="bookmark-add" />
</t-button>
<t-tooltip v-if="event.is_fallback" :content="$t('chat.fallbackHint')" placement="top">
<t-button size="small" variant="outline" shape="round" class="fallback-icon-btn">
@@ -50,7 +50,7 @@
</t-button>
<t-button size="small" variant="outline" shape="round" @click.stop="handleAddToKnowledge"
:title="$t('agent.addToKnowledgeBase')">
<t-icon name="add" />
<t-icon name="bookmark-add" />
</t-button>
<!-- Fallback 提示图标 -->
<t-tooltip v-if="session.is_fallback" :content="$t('chat.fallbackHint')" placement="top">
+18 -4
View File
@@ -1843,13 +1843,27 @@ const {
},
});
const onCardClick = (item: any) => {
const isManualDraftKnowledge = (item: KnowledgeCard) =>
item.type === 'manual' && item.parse_status === 'draft';
const openKnowledgeItem = (item: KnowledgeCard) => {
if (shouldSuppressDocClick()) return;
if (canEdit.value && isManualDraftKnowledge(item)) {
const index = cardList.value.findIndex((c) => c.id === item.id);
if (index >= 0) {
handleManualEdit(index, item);
return;
}
}
openCardDetails(item);
};
const onCardClick = (item: KnowledgeCard) => {
if (batchMode.value) {
onCardGridCheckboxChange(item.id, !selectedIds.value.has(item.id));
} else {
openCardDetails(item);
return;
}
openKnowledgeItem(item);
};
const confirmBatchDelete = async () => {
@@ -2582,7 +2596,7 @@ async function createNewSession(value: string): Promise<void> {
</template>
<template v-else-if="cardList.length && viewMode === 'list'">
<DocumentListView :items="cardList" :selected-ids="selectedIds" :tag-list="tagList"
:can-edit="canEdit" @open="(item: any) => { if (!shouldSuppressDocClick()) openCardDetails(item); }" @toggle-row="toggleSelectRow"
:can-edit="canEdit" @open="(item: any) => openKnowledgeItem(item)" @toggle-row="toggleSelectRow"
@toggle-all="toggleSelectAll"
@action="(action: any, item: any) => handleListAction(action, item)" />
</template>