feat(frontend): polish chat suggestions, citations, and drawers

- unify suggested-question styling across chat entry points
- keep citation markers inline with their preceding list content
- prevent manual editor viewport shifts and synchronize drawer motion
- align embedded preview and editor drawers in the body stacking context
This commit is contained in:
wizardchen
2026-06-17 22:38:07 +08:00
committed by lyingbug
parent e0d3c1dafd
commit 4e2d568149
9 changed files with 305 additions and 313 deletions
+1
View File
@@ -37,6 +37,7 @@ data/files/
data/weknora.db
data/weknora.db-wal
data/weknora.db-shm
skills-lock.json
web/
@@ -1,6 +1,6 @@
<template>
<t-drawer :visible="visible" :header="title || $t('embedPublish.preview')" size="720px" :footer="false"
:z-index="2600" class="embed-preview-drawer" @close="emit('update:visible', false)">
:z-index="2600" attach="body" class="embed-preview-drawer" @close="emit('update:visible', false)">
<div class="preview-body">
<template v-if="mode === 'iframe'">
<p class="preview-hint">{{ $t('embedPublish.previewIframeHint') }}</p>
@@ -0,0 +1,197 @@
@suggested-ease: cubic-bezier(0.16, 1, 0.3, 1);
.suggested-questions-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 16px 12px;
max-width: 720px;
margin: 0 auto;
width: 100%;
min-height: 0;
&.has-questions {
min-height: 64px;
}
}
.suggested-questions-inner {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.suggested-questions-title-row {
margin-bottom: 8px;
}
.suggested-questions-caption {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
padding: 0;
line-height: 1;
}
.suggested-questions-title {
font-size: 12px;
color: var(--td-text-color-placeholder);
font-weight: 400;
letter-spacing: 0.02em;
}
.suggested-questions-refresh {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--td-text-color-placeholder);
cursor: pointer;
transition: color 0.2s @suggested-ease, background 0.2s @suggested-ease;
:deep(.t-icon) {
font-size: 12px;
}
&:hover:not(:disabled) {
color: var(--td-text-color-secondary);
background: var(--td-bg-color-secondarycontainer);
}
&:active:not(:disabled) {
transform: scale(0.94);
}
&:disabled {
cursor: default;
opacity: 0.7;
}
}
.sq-refresh-spin {
animation: sq-refresh-rotate 0.8s linear infinite;
}
@keyframes sq-refresh-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.suggested-questions-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
width: 100%;
}
.suggested-question-card {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid var(--td-component-stroke);
background: transparent;
cursor: pointer;
transition:
background 0.2s @suggested-ease,
border-color 0.2s @suggested-ease,
transform 0.15s @suggested-ease;
&:not(.sq-card-skeleton):hover {
background: var(--td-bg-color-secondarycontainer);
border-color: var(--td-component-border);
}
&:not(.sq-card-skeleton):active {
transform: scale(0.98);
background: var(--td-bg-color-secondarycontainer-active);
}
&.sq-card-skeleton {
pointer-events: none;
cursor: default;
flex-shrink: 0;
border-color: transparent;
background: var(--td-bg-color-secondarycontainer);
:deep(.t-skeleton) {
width: 100%;
}
:deep(.t-skeleton__row) {
margin: 0;
}
:deep(.t-skeleton__col) {
border-radius: 3px;
}
&:nth-child(1) {
width: 132px;
}
&:nth-child(2) {
width: 168px;
}
&:nth-child(3) {
width: 116px;
}
&:nth-child(4) {
width: 152px;
}
&:nth-child(5) {
width: 124px;
}
&:nth-child(6) {
width: 144px;
}
}
}
.suggested-question-text {
font-size: 13px;
line-height: 1.5;
color: var(--td-text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 0.2s @suggested-ease;
}
.suggested-question-card:not(.sq-card-skeleton):hover .suggested-question-text {
color: var(--td-text-color-primary);
}
.suggested-question-badge {
flex-shrink: 0;
padding: 0 4px;
border-radius: 3px;
border: 1px solid var(--td-component-stroke);
background: transparent;
font-size: 10px;
font-weight: 500;
line-height: 1.6;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--td-text-color-placeholder);
}
@@ -150,7 +150,11 @@ const setSelectionRange = (start: number, end: number) => {
if (!textarea || activeTab.value !== 'edit') {
return
}
textarea.focus()
// Initialization can finish while the drawer is still sliding in. A plain
// focus() makes the browser scroll the transformed textarea into view,
// which intermittently shifts the drawer away from the right edge for a
// frame. Keep keyboard focus without letting it move the viewport.
textarea.focus({ preventScroll: true })
textarea.setSelectionRange(start, end)
})
}
@@ -735,7 +739,6 @@ onBeforeUnmount(() => {
: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')"
@@ -1111,5 +1114,3 @@ onBeforeUnmount(() => {
color: var(--td-text-color-placeholder);
}
</style>
@@ -1,14 +1,14 @@
<template>
<teleport to="body">
<div v-if="drawerVisible && resizable" class="setting-drawer-resize-handle"
:class="{ 'setting-drawer-resize-handle--active': drawerResizing }" :style="{ right: `${drawerWidthPx}px` }"
:class="{ 'setting-drawer-resize-handle--active': drawerResizing }"
:style="{ right: `${drawerWidthPx}px`, '--setting-drawer-travel': `${drawerWidthPx}px` }"
role="separator" aria-orientation="vertical" @mousedown.prevent="onResizeStart">
<div class="setting-drawer-resize-line" />
</div>
</teleport>
<t-drawer v-model:visible="drawerVisible" :size="effectiveWidth" :z-index="2500" placement="right"
:attach="attach"
:destroy-on-close="destroyOnClose"
attach="body" destroy-on-close
:class="['setting-drawer', { 'setting-drawer--resizing': drawerResizing }]">
<!--
Custom header. We replace TDesign's default header so we can put a leading
@@ -88,23 +88,6 @@ 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>(), {
@@ -119,9 +102,7 @@ const props = withDefaults(defineProps<Props>(), {
confirmDisabled: false,
confirmText: '',
cancelText: '',
hideFooter: false,
destroyOnClose: true,
attach: 'body'
hideFooter: false
})
const emit = defineEmits<{
@@ -441,6 +422,20 @@ const handleCancel = () => {
display: flex;
align-items: center;
justify-content: center;
/* The handle is teleported separately from TDesign's sliding panel. Move it
along the same path instead of letting it flash at the panel's final left
edge while the drawer is still entering from the right. */
animation: setting-drawer-resize-handle-in 0.28s cubic-bezier(0.38, 0, 0.24, 1) both;
}
@keyframes setting-drawer-resize-handle-in {
from {
transform: translateX(var(--setting-drawer-travel));
}
to {
transform: translateX(0);
}
}
.setting-drawer-resize-line {
@@ -167,10 +167,37 @@ test('renderChatMarkdown inlines consecutive citation tags across newlines', ()
assert.doesNotMatch(html, /<\/p>\s*<p>\s*<span class="citation citation-kb"/)
})
test('joinCitationTagsToPreviousLine keeps citations on a new line after a list', () => {
const tag = '<kb doc="doc.pdf" chunk_id="1" />'
const input = `1. first\n2. second\n\n${tag}`
assert.equal(joinCitationTagsToPreviousLine(input), `1. first\n2. second\n\n${tag}`)
test('joinCitationTagsToPreviousLine appends an indented citation to the preceding list item', () => {
const tag = '<kb doc="阅读之星全国青少年阅读风采展示活动.pdf" chunk_id="chunk-1" />'
const input = [
'#### 5️⃣ 阅读之星培养基地',
'- 每个组别冠亚季军及前十强所在的学校,将获得 **"阅读之星培养基地"** 奖牌',
'',
` ${tag}`,
].join('\n')
assert.equal(
joinCitationTagsToPreviousLine(input),
[
'#### 5️⃣ 阅读之星培养基地',
`- 每个组别冠亚季军及前十强所在的学校,将获得 **"阅读之星培养基地"** 奖牌 ${tag}`,
].join('\n'),
)
})
test('renderChatMarkdown renders a citation after a list item inline in that item', () => {
const renderer = createChatMarkdownRenderer({
imageRenderer: ({ href, text }) => `<img src="${href}" alt="${text}">`,
isValidImageUrl: () => true,
})
const tag = '<kb doc="阅读之星全国青少年阅读风采展示活动.pdf" chunk_id="chunk-1" />'
const html = renderChatMarkdown(`- 培养基地奖牌\n\n ${tag}`, {
renderer,
escapeMarkdown: (text) => text,
sanitizeHtml: (value) => value,
})
assert.match(html, /<li>培养基地奖牌 <span class="citation citation-kb"/)
assert.doesNotMatch(html, /<\/ul>\s*<p>\s*<span class="citation citation-kb"/)
})
test('joinCitationTagsToPreviousLine does not merge citations onto fenced code closing delimiter', () => {
+6 -7
View File
@@ -224,16 +224,15 @@ 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).
// Blank lines before citations: join to the previous content. Fenced-code
// delimiters are the only exception because ``` / ~~~ 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)
if (isListItem || isFencedCodeDelimiterLine(lastLine)) {
const lastLine = before.split('\n').filter((line: string) => line.trim()).pop() || ''
if (isFencedCodeDelimiterLine(lastLine)) {
return `\n\n${kbStart}`
}
return ` ${kbStart}`
return ` ${kbStart.trimStart()}`
})
// Single newline before citation when it follows text or another citation (not after a blank line)
@@ -243,7 +242,7 @@ export function joinCitationTagsToPreviousLine(content: string): string {
if (isFencedCodeDelimiterLine(beforePart)) {
return match
}
return `${beforePart} ${kbStart}`
return `${beforePart} ${kbStart.trimStart()}`
},
)
+16 -140
View File
@@ -26,7 +26,7 @@
<div v-if="suggestedQuestionsLoading && suggestedQuestions.length === 0"
class="suggested-questions-inner">
<div class="suggested-questions-title"><t-skeleton animation="gradient"
:row-col="[{ width: '120px', height: '18px' }]" /></div>
:row-col="[{ width: '120px', height: '14px' }]" /></div>
<div class="suggested-questions-grid">
<div v-for="n in 6" :key="'sq-skel-' + n" class="suggested-question-card sq-card-skeleton">
<t-skeleton animation="gradient"
@@ -37,12 +37,17 @@
<transition v-else appear name="sq-fade">
<div v-if="suggestedQuestions.length > 0" class="suggested-questions-inner">
<div class="suggested-questions-title-row">
<div class="suggested-questions-title">{{ t('chat.suggestedQuestions') }}</div>
<t-button variant="text" shape="square" size="small" class="suggested-questions-refresh"
:loading="suggestedQuestionsLoading" :title="t('chat.refreshSuggestedQuestions')"
@click="fetchSuggestedQuestions">
<template #icon><t-icon name="refresh" /></template>
</t-button>
<p class="suggested-questions-caption">
<span class="suggested-questions-title">{{ t('chat.suggestedQuestions') }}</span>
<button type="button" class="suggested-questions-refresh"
:disabled="suggestedQuestionsLoading"
:title="t('chat.refreshSuggestedQuestions')"
:aria-label="t('chat.refreshSuggestedQuestions')"
@click="fetchSuggestedQuestions">
<t-icon :name="suggestedQuestionsLoading ? 'loading' : 'refresh'"
:class="{ 'sq-refresh-spin': suggestedQuestionsLoading }" />
</button>
</p>
</div>
<div class="suggested-questions-grid">
<div v-for="(item, index) in suggestedQuestions" :key="item.question"
@@ -1078,152 +1083,23 @@ onBeforeRouteUpdate((to, from, next) => {
}
}
.suggested-questions-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 16px 16px;
max-width: 800px;
margin: 0 auto;
width: 100%;
min-height: 0;
transition: min-height 0.3s ease;
@import '../../components/css/suggested-questions.less';
&.has-questions {
min-height: 80px;
}
.suggested-questions-container {
transition: min-height 0.3s @suggested-ease;
}
.suggested-questions-inner {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
animation: contentFadeIn 0.3s ease-out;
}
.sq-fade-enter-active,
.sq-fade-leave-active {
transition: opacity 0.25s ease;
transition: opacity 0.25s @suggested-ease;
}
.sq-fade-enter-from,
.sq-fade-leave-to {
opacity: 0;
}
.suggested-questions-title-row {
display: flex;
align-items: center;
gap: 2px;
margin-bottom: 16px;
}
.suggested-questions-title {
font-size: 14px;
color: var(--td-text-color-secondary);
margin-bottom: 0;
font-weight: 500;
}
.suggested-questions-refresh {
flex-shrink: 0;
color: var(--td-text-color-secondary);
&:hover:not(.t-is-disabled) {
color: var(--td-text-color-primary);
}
}
.suggested-questions-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
width: 100%;
}
.suggested-question-card {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 16px;
border-radius: 20px;
border: 1px solid var(--td-component-stroke);
background: var(--td-bg-color-container);
cursor: pointer;
transition: all 0.2s ease;
max-width: 100%;
&.sq-card-skeleton {
pointer-events: none;
flex-shrink: 0;
border-color: transparent;
background: var(--td-bg-color-secondarycontainer);
:deep(.t-skeleton) {
width: 100%;
}
:deep(.t-skeleton__row) {
margin: 0;
}
:deep(.t-skeleton__col) {
border-radius: 4px;
}
&:nth-child(1) {
width: 132px;
}
&:nth-child(2) {
width: 168px;
}
&:nth-child(3) {
width: 116px;
}
&:nth-child(4) {
width: 152px;
}
&:nth-child(5) {
width: 124px;
}
&:nth-child(6) {
width: 144px;
}
}
&:not(.sq-card-skeleton):hover {
border-color: var(--td-brand-color);
background: var(--td-brand-color-light);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
}
.suggested-question-text {
font-size: 13px;
color: var(--td-text-color-primary);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.suggested-question-badge {
font-size: 10px;
padding: 1px 5px;
border-radius: 4px;
flex-shrink: 0;
font-weight: 500;
&.faq {
background: var(--td-success-color-1);
color: var(--td-success-color);
}
}
</style>
+30 -134
View File
@@ -9,7 +9,7 @@
<!-- 骨架屏占位 -->
<div v-if="sqLoading && suggestedQuestions.length === 0" class="suggested-questions-inner">
<div class="suggested-questions-title"><t-skeleton animation="gradient"
:row-col="[{ width: '120px', height: '18px' }]" /></div>
:row-col="[{ width: '120px', height: '14px' }]" /></div>
<div class="suggested-questions-grid">
<div v-for="n in 6" :key="'sq-skel-' + n" class="suggested-question-card sq-card-skeleton">
<t-skeleton animation="gradient"
@@ -21,12 +21,15 @@
@after-leave="onAfterLeave" @enter="onEnter" @after-enter="onQuestionsEntered">
<div v-if="suggestedQuestions.length > 0" :key="sqRenderKey" class="suggested-questions-inner">
<div class="suggested-questions-title-row">
<div class="suggested-questions-title">{{ $t('chat.suggestedQuestions') }}</div>
<t-button variant="text" shape="square" size="small" class="suggested-questions-refresh"
:loading="sqLoading" :title="$t('chat.refreshSuggestedQuestions')"
@click="fetchSuggestedQuestions">
<template #icon><t-icon name="refresh" /></template>
</t-button>
<p class="suggested-questions-caption">
<span class="suggested-questions-title">{{ $t('chat.suggestedQuestions') }}</span>
<button type="button" class="suggested-questions-refresh" :disabled="sqLoading"
:title="$t('chat.refreshSuggestedQuestions')"
:aria-label="$t('chat.refreshSuggestedQuestions')" @click="fetchSuggestedQuestions">
<t-icon :name="sqLoading ? 'loading' : 'refresh'"
:class="{ 'sq-refresh-spin': sqLoading }" />
</button>
</p>
</div>
<div class="suggested-questions-grid">
<div v-for="(item, index) in suggestedQuestions" :key="item.question"
@@ -251,6 +254,7 @@ const handleKBEditorSuccess = (kbId: string) => {
align-items: center;
width: 100%;
max-width: 800px;
gap: 24px;
:deep(.answers-input) {
position: static;
@@ -265,7 +269,7 @@ const handleKBEditorSuccess = (kbId: string) => {
font-size: 28px;
font-weight: 600;
align-items: center;
margin-bottom: 30px;
margin-bottom: 0;
.icon {
display: flex;
@@ -285,15 +289,7 @@ const handleKBEditorSuccess = (kbId: string) => {
}
}
.suggested-questions-container {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 24px;
width: 100%;
max-width: 800px;
transition: height 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
@import '../../components/css/suggested-questions.less';
@keyframes skeletonFadeIn {
from {
@@ -305,18 +301,19 @@ const handleKBEditorSuccess = (kbId: string) => {
}
}
.suggested-questions-container {
max-width: 800px;
margin: 0;
padding: 0 16px;
transition: height 0.35s @suggested-ease;
}
.suggested-questions-inner {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
animation: skeletonFadeIn 0.3s ease-out;
}
// 容器整体过渡:淡入 + 轻微上滑
.sq-slide-fade-enter-active {
transition: opacity 0.35s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
transition: opacity 0.35s @suggested-ease, transform 0.35s @suggested-ease;
}
.sq-slide-fade-leave-active {
@@ -334,99 +331,18 @@ const handleKBEditorSuccess = (kbId: string) => {
transform: translateY(-4px);
}
.suggested-questions-title-row {
display: flex;
align-items: center;
gap: 2px;
margin-bottom: 12px;
}
.suggested-questions-title {
font-size: 14px;
color: var(--td-text-color-secondary);
margin-bottom: 0;
font-weight: 500;
}
.suggested-questions-refresh {
flex-shrink: 0;
color: var(--td-text-color-secondary);
&:hover:not(.t-is-disabled) {
color: var(--td-text-color-primary);
}
}
.suggested-questions-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
width: 100%;
}
.suggested-question-card {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 16px;
border-radius: 20px;
border: 1px solid var(--td-component-stroke);
background: var(--td-bg-color-container);
cursor: pointer;
max-width: 100%;
opacity: 0;
transform: translateY(8px) scale(0.97);
transition: opacity 0.35s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.35s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.2s ease,
background 0.2s ease,
box-shadow 0.2s ease;
transition:
opacity 0.35s @suggested-ease,
transform 0.35s @suggested-ease,
background 0.2s @suggested-ease,
border-color 0.2s @suggested-ease;
&.sq-card-skeleton {
opacity: 1;
transform: none;
cursor: default;
pointer-events: none;
flex-shrink: 0;
border-color: transparent;
background: var(--td-bg-color-secondarycontainer);
:deep(.t-skeleton) {
width: 100%;
}
:deep(.t-skeleton__row) {
margin: 0;
}
:deep(.t-skeleton__col) {
border-radius: 4px;
}
&:nth-child(1) {
width: 132px;
}
&:nth-child(2) {
width: 168px;
}
&:nth-child(3) {
width: 116px;
}
&:nth-child(4) {
width: 152px;
}
&:nth-child(5) {
width: 124px;
}
&:nth-child(6) {
width: 144px;
}
}
&.sq-card-visible {
@@ -434,32 +350,12 @@ const handleKBEditorSuccess = (kbId: string) => {
transform: translateY(0) scale(1);
}
&:not(.sq-card-skeleton):hover {
border-color: var(--td-brand-color);
background: var(--td-brand-color-light);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
&:not(.sq-card-skeleton):active {
transform: scale(0.98);
}
}
.suggested-question-text {
font-size: 13px;
color: var(--td-text-color-primary);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.suggested-question-badge {
font-size: 10px;
padding: 1px 5px;
border-radius: 4px;
flex-shrink: 0;
font-weight: 500;
&.faq {
background: var(--td-success-color-1);
color: var(--td-success-color);
&.sq-card-visible:active {
transform: scale(0.98);
}
}