fix(frontend): 完善Markdown与Chat事件渲染

This commit is contained in:
2026-08-29 23:59:01 +08:00
parent e5f803c364
commit 5aa0026bb9
7 changed files with 106 additions and 19 deletions
+1
View File
@@ -23,6 +23,7 @@
"@milkdown/vue": "^7.22.0",
"@vueuse/core": "^14.0.0",
"codemirror": "^6.0.0",
"dompurify": "^3.4.14",
"marked": "^15.0.0",
"pinia": "^4.0.0",
"vue": "^3.5.0",
+5 -9
View File
@@ -47,6 +47,9 @@ importers:
codemirror:
specifier: ^6.0.0
version: 6.0.2
dompurify:
specifier: ^3.4.14
version: 3.4.14
marked:
specifier: ^15.0.0
version: 15.0.12
@@ -829,11 +832,6 @@ packages:
'@volar/typescript@2.4.15':
resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
'@vue-macros/common@3.1.4':
resolution: {integrity: sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==}
@@ -2608,13 +2606,11 @@ snapshots:
'@volar/source-map@2.4.15': {}
'@volar/typescript@2.4.15(typescript@5.9.3)':
'@volar/typescript@2.4.15':
dependencies:
'@volar/language-core': 2.4.15
path-browserify: 1.0.1
vscode-uri: 3.2.0
optionalDependencies:
typescript: 5.9.3
'@vue-macros/common@3.1.4(vue@3.5.41(typescript@5.9.3))':
dependencies:
@@ -3652,7 +3648,7 @@ snapshots:
vue-tsc@2.2.12(typescript@5.9.3):
dependencies:
'@volar/typescript': 2.4.15(typescript@5.9.3)
'@volar/typescript': 2.4.15
'@vue/language-core': 2.2.12(typescript@5.9.3)
typescript: 5.9.3
+3
View File
@@ -75,6 +75,8 @@ export interface ChatMessage {
created_at: string
citations?: Citation[]
tool_calls?: ToolCall[]
thinking?: string
usage?: TokenUsage
}
export interface Citation {
@@ -99,6 +101,7 @@ export type ModelEventType =
| 'ToolCallDelta'
| 'ToolCallEnd'
| 'Usage'
| 'Citation'
| 'Error'
| 'Done'
+18 -1
View File
@@ -7,6 +7,7 @@ import { useEditorStore } from '@/stores/editor'
import { useProviderStore } from '@/stores/provider'
import { useSkillStore } from '@/stores/skill'
import { useWorkspaceStore } from '@/stores/workspace'
import { renderMarkdown } from '@/utils/markdown'
const chatStore = useChatStore()
const providerStore = useProviderStore()
@@ -67,13 +68,17 @@ async function openCitation(citation: Citation) {
<article v-for="message in chatStore.messages" :key="message.message_id" class="message" :class="message.role">
<div class="avatar">{{ message.role === 'user' ? '你' : 'AI' }}</div>
<div class="message-body">
<div class="message-content">{{ message.content || (chatStore.isStreaming ? '正在思考…' : '') }}</div>
<details v-if="message.thinking" class="thinking"><summary>思考过程</summary><p>{{ message.thinking }}</p></details>
<div v-if="message.content" class="message-content markdown-content" v-html="renderMarkdown(message.content)" />
<div v-else-if="chatStore.isStreaming" class="message-content">正在思考</div>
<div v-if="message.tool_calls?.length" class="tool-calls"><div v-for="call in message.tool_calls" :key="call.tool_call_id" class="item-card"><span class="badge info">{{ call.status }}</span><strong>{{ call.name }}</strong><pre>{{ JSON.stringify(call.parameters, null, 2) }}</pre></div></div>
<div v-if="message.citations?.length" class="citations">
<button v-for="(citation, index) in message.citations" :key="citation.block_id" class="citation-card" @click="openCitation(citation)">
<span class="badge info">{{ index + 1 }}</span><span><strong>{{ citation.heading_path || citation.file_path }}</strong><small>{{ citation.content }}</small></span>
</button>
</div>
<time>{{ new Date(message.created_at).toLocaleTimeString() }}</time>
<small v-if="message.usage" class="usage">Token {{ message.usage.total_tokens }}输入 {{ message.usage.input_tokens }} / 输出 {{ message.usage.output_tokens }}</small>
</div>
</article>
</main>
@@ -99,6 +104,18 @@ async function openCitation(citation: Citation) {
.avatar { display: grid; place-items: center; width: 34px; height: 34px; border-radius: var(--radius-full); background: var(--color-background-tertiary); font-weight: 700; }
.assistant .avatar { background: var(--color-accent-soft); color: var(--color-accent-primary); }
.message-content { white-space: pre-wrap; line-height: var(--line-height-relaxed); }
.markdown-content { white-space: normal; user-select: text; }
.markdown-content :deep(p), .markdown-content :deep(ul), .markdown-content :deep(ol), .markdown-content :deep(pre), .markdown-content :deep(blockquote) { margin: .65em 0; }
.markdown-content :deep(h1), .markdown-content :deep(h2), .markdown-content :deep(h3) { margin: 1em 0 .5em; line-height: var(--line-height-tight); }
.markdown-content :deep(ul) { padding-left: 1.5em; list-style: disc; }.markdown-content :deep(ol) { padding-left: 1.5em; list-style: decimal; }
.markdown-content :deep(pre) { overflow: auto; padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-background-secondary); }
.markdown-content :deep(code) { padding: .1em .3em; border-radius: var(--radius-sm); background: var(--color-background-tertiary); font-family: var(--font-ui-mono); }.markdown-content :deep(pre code) { padding: 0; background: transparent; }
.markdown-content :deep(blockquote) { padding-left: 1em; border-left: 3px solid var(--color-accent-primary); color: var(--color-text-secondary); }
.markdown-content :deep(table) { width: 100%; margin: .65em 0; border-collapse: collapse; }.markdown-content :deep(th), .markdown-content :deep(td) { padding: .45em .65em; border: 1px solid var(--color-border-default); text-align: left; }
.markdown-content :deep(img) { max-width: 100%; }.markdown-content :deep(hr) { margin: 1em 0; border: 0; border-top: 1px solid var(--color-border-default); }
.thinking { margin-bottom: var(--space-sm); color: var(--color-text-secondary); }.thinking p { margin-top: var(--space-sm); white-space: pre-wrap; }
.tool-calls { display: grid; gap: var(--space-sm); margin-top: var(--space-md); }.tool-calls .item-card { display: grid; gap: var(--space-xs); }.tool-calls pre { overflow: auto; font-size: var(--font-size-xs); }
.usage { display: block; margin-top: var(--space-xs); color: var(--color-text-tertiary); }
.message time { display: block; margin-top: var(--space-sm); color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.citations { display: grid; gap: var(--space-sm); margin-top: var(--space-md); }
.citation-card { display: flex; align-items: flex-start; gap: var(--space-sm); padding: var(--space-sm); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); text-align: left; }
+33 -9
View File
@@ -1,18 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useEditorStore } from '@/stores/editor'
import { useSettingsStore } from '@/stores/settings'
import { renderMarkdown } from '@/utils/markdown'
const editorStore = useEditorStore()
const settingsStore = useSettingsStore()
const renderedContent = computed(() => renderMarkdown(editorStore.content))
function updateContent(event: Event) {
editorStore.updateContent((event.target as HTMLTextAreaElement).value)
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
}
</script>
<template>
<textarea
class="editor-pane"
:class="editorStore.mode"
:value="editorStore.content"
:spellcheck="editorStore.mode === 'wysiwyg'"
:aria-label="editorStore.mode === 'source' ? 'Markdown 源码编辑器' : 'Markdown 写作编辑器'"
@input="editorStore.updateContent(($event.target as HTMLTextAreaElement).value); editorStore.scheduleAutoSave()"
/>
<div v-if="editorStore.mode === 'wysiwyg'" class="writing-layout">
<textarea class="editor-pane writing-input" :value="editorStore.content" :spellcheck="settingsStore.spellCheck"
aria-label="Markdown 写作编辑器" @input="updateContent" />
<article class="markdown-preview" aria-label="Markdown 实时预览" v-html="renderedContent" />
</div>
<textarea v-else class="editor-pane source" :value="editorStore.content" :spellcheck="false"
aria-label="Markdown 源码编辑器" @input="updateContent" />
</template>
<style scoped>
@@ -30,6 +39,21 @@ const editorStore = useEditorStore()
line-height: 1.7;
user-select: text;
}
.editor-pane.wysiwyg { max-width: 920px; margin: 0 auto; padding: var(--space-3xl) clamp(var(--space-xl), 8vw, 80px); font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); }
.writing-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); flex: 1; min-height: 0; }
.writing-input { border-right: 1px solid var(--color-border-default); font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); }
.markdown-preview { width: min(100%, var(--editor-line-width, 80ch)); overflow: auto; margin: 0 auto; padding: var(--space-3xl) var(--space-xl); font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); user-select: text; }
.markdown-preview :deep(h1), .markdown-preview :deep(h2), .markdown-preview :deep(h3) { margin: 1.4em 0 .6em; line-height: var(--line-height-tight); color: var(--color-text-primary); }
.markdown-preview :deep(h1:first-child), .markdown-preview :deep(h2:first-child) { margin-top: 0; }
.markdown-preview :deep(p), .markdown-preview :deep(ul), .markdown-preview :deep(ol), .markdown-preview :deep(blockquote), .markdown-preview :deep(pre), .markdown-preview :deep(table) { margin: .8em 0; }
.markdown-preview :deep(ul), .markdown-preview :deep(ol) { padding-left: 1.6em; }
.markdown-preview :deep(ul) { list-style: disc; }.markdown-preview :deep(ol) { list-style: decimal; }
.markdown-preview :deep(blockquote) { padding-left: 1em; border-left: 3px solid var(--color-accent-primary); color: var(--color-text-secondary); }
.markdown-preview :deep(code) { padding: .15em .35em; border-radius: var(--radius-sm); background: var(--color-background-tertiary); font-family: var(--font-editor-mono); }
.markdown-preview :deep(pre) { overflow: auto; padding: var(--space-lg); border-radius: var(--radius-md); background: var(--color-background-secondary); }
.markdown-preview :deep(pre code) { padding: 0; background: transparent; }
.markdown-preview :deep(table) { width: 100%; border-collapse: collapse; }.markdown-preview :deep(th), .markdown-preview :deep(td) { padding: .5em .7em; border: 1px solid var(--color-border-default); text-align: left; }
.markdown-preview :deep(img) { max-width: 100%; }.markdown-preview :deep(a) { color: var(--color-text-link); }
.markdown-preview :deep(hr) { margin: 1.5em 0; border: 0; border-top: 1px solid var(--color-border-default); }
.editor-pane.source { font-family: var(--font-editor-mono); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); }
@media (max-width: 900px) { .writing-layout { grid-template-columns: 1fr; grid-template-rows: minmax(220px, 1fr) minmax(220px, 1fr); overflow: auto; }.writing-input { min-height: 220px; border-right: 0; border-bottom: 1px solid var(--color-border-default); }.markdown-preview { min-height: 220px; } }
</style>
+37
View File
@@ -78,6 +78,38 @@ export const useChatStore = defineStore('chat', () => {
}, {
onEvent(event) {
if (event.event === 'TextDelta') aiMsg.content += String(event.data.text ?? '')
if (event.event === 'ThinkingDelta') aiMsg.thinking = `${aiMsg.thinking ?? ''}${String(event.data.text ?? '')}`
if (event.event === 'ToolCallStart') {
aiMsg.tool_calls?.push({
tool_call_id: String(event.data.tool_call_id ?? ''),
name: String(event.data.name ?? 'unknown'),
parameters: (event.data.arguments ?? {}) as Record<string, unknown>,
status: 'running',
})
}
if (event.event === 'ToolCallDelta') {
const call = aiMsg.tool_calls?.find((item) => item.tool_call_id === event.data.tool_call_id)
if (call && event.data.arguments && typeof event.data.arguments === 'object') {
Object.assign(call.parameters, event.data.arguments)
}
}
if (event.event === 'ToolCallEnd') {
const call = aiMsg.tool_calls?.find((item) => item.tool_call_id === event.data.tool_call_id)
if (call) call.status = 'completed'
}
if (event.event === 'Usage') {
const input = Number(event.data.input_tokens ?? 0)
const output = Number(event.data.output_tokens ?? 0)
aiMsg.usage = { input_tokens: input, output_tokens: output, total_tokens: input + output }
}
if (event.event === 'Citation') {
aiMsg.citations?.push({
note_id: String(event.data.note_id ?? ''), block_id: String(event.data.block_id ?? ''),
file_path: String(event.data.file_path ?? ''),
heading_path: Array.isArray(event.data.heading_path) ? event.data.heading_path.join(' / ') : String(event.data.heading_path ?? ''),
content: String(event.data.content ?? event.data.snippet ?? ''),
})
}
if (event.event === 'Error') aiMsg.content += `\n\n生成失败:${String(event.data.message ?? '未知错误')}`
},
onError(error) {
@@ -86,6 +118,11 @@ export const useChatStore = defineStore('chat', () => {
sseClient = null
},
onDone() {
const conversation = conversations.value.find((item) => item.conversation_id === conversationId)
if (conversation) {
conversation.message_count = messages.value.length
conversation.updated_at = new Date().toISOString()
}
isStreaming.value = false
sseClient = null
},
+9
View File
@@ -0,0 +1,9 @@
import DOMPurify from 'dompurify'
import { marked } from 'marked'
marked.setOptions({ gfm: true, breaks: true })
export function renderMarkdown(source: string): string {
const html = marked.parse(source, { async: false }) as string
return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } })
}