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:
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
@@ -2979,6 +2979,11 @@ export default {
|
||||
edit: "Markdown 지식 편집",
|
||||
create: "온라인 Markdown 지식 편집",
|
||||
},
|
||||
description: "Markdown으로 지식을 작성하고 실시간 미리보기 지원",
|
||||
section: {
|
||||
basic: "기본 정보",
|
||||
content: "지식 내용",
|
||||
},
|
||||
labels: {
|
||||
currentKnowledgeBase: "현재 지식베이스",
|
||||
},
|
||||
|
||||
@@ -4065,6 +4065,11 @@ export default {
|
||||
edit: 'Редактировать Markdown-знание',
|
||||
create: 'Создать Markdown-знание'
|
||||
},
|
||||
description: 'Пишите знания в Markdown с предпросмотром в реальном времени',
|
||||
section: {
|
||||
basic: 'Основная информация',
|
||||
content: 'Содержимое'
|
||||
},
|
||||
labels: {
|
||||
currentKnowledgeBase: 'Текущая база знаний'
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user