Skip to content

Commit 4adddb9

Browse files
committed
feat: 升级 AI 聊天组件为真正的流式 Markdown 渲染
主要变更: - 迁移从 marked 到 markstream-vue,专为流式场景优化 - 实现真正的流式节点解析(parseMarkdownToStructure) - 启用增量批次渲染模式(max-live-nodes=0) - 为用户消息和 AI 消息统一使用节点渲染 - 添加历史消息兼容逻辑(fallback 解析) - 移除 marked 依赖,清理 package.json 性能提升: - 实时节点解析,避免全量重渲染 - 平滑的打字机效果 - 内存可控,适合长对话 - 无闪烁,视觉体验更好 技术实现: - 使用 getMarkdown() 初始化解析器 - 流式响应中实时调用 parseMarkdownToStructure() - 模板使用 :nodes prop 替代 :content - 类型断言处理 TypeScript 类型不匹配
1 parent a763564 commit 4adddb9

4 files changed

Lines changed: 160 additions & 119 deletions

File tree

.vitepress/theme/ai-chat/components/AiChat.vue

Lines changed: 20 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
<script setup lang="ts">
22
import { ref, reactive, computed, watch, onMounted, nextTick } from 'vue'
33
import { useData, useRoute } from 'vitepress'
4-
import { marked } from 'marked'
4+
import MarkdownRender, { getMarkdown, parseMarkdownToStructure } from 'markstream-vue'
5+
import 'markstream-vue/index.css'
56
import type { Message, Session, Settings, UIState, ProviderId, PageContext } from '../types'
67
import { PROVIDERS } from '../types'
78
import { encrypt, decrypt, getEncryptionSecret } from '../services/crypto'
@@ -16,18 +17,15 @@ import {
1617
import { chat } from '../services/ai-provider'
1718
import { getMessages } from '../locales'
1819
19-
// Configure marked for safe rendering
20-
marked.setOptions({
21-
gfm: true,
22-
breaks: true,
23-
})
24-
2520
const { lang, page, frontmatter } = useData()
2621
const route = useRoute()
2722
2823
// i18n - auto switch based on VitePress locale
2924
const t = computed(() => getMessages(lang.value))
3025
26+
// Initialize markdown parser for streaming
27+
const markdownParser = getMarkdown()
28+
3129
// Get current page context for AI
3230
function getPageContext(): PageContext {
3331
const title = frontmatter.value?.title || page.value?.title || document.title
@@ -219,6 +217,8 @@ async function sendMessage() {
219217
role: 'user',
220218
content: inputMessage.value.trim(),
221219
createdAt: Date.now(),
220+
// Parse user message to nodes immediately
221+
nodes: parseMarkdownToStructure(inputMessage.value.trim(), markdownParser),
222222
}
223223
224224
session.messages.push(userMessage)
@@ -269,6 +269,8 @@ async function sendMessage() {
269269
}
270270
271271
assistantMessage.content += chunk.content
272+
// Real-time parse content to nodes for streaming rendering
273+
assistantMessage.nodes = parseMarkdownToStructure(assistantMessage.content, markdownParser)
272274
scrollToBottom()
273275
274276
if (chunk.done) {
@@ -315,15 +317,6 @@ function deleteMessage(messageId: string) {
315317
saveSessions(sessions.value)
316318
}
317319
318-
function renderMarkdown(content: string): string {
319-
if (!content) return ''
320-
try {
321-
return marked.parse(content) as string
322-
} catch {
323-
return content
324-
}
325-
}
326-
327320
// ============ Lifecycle ============
328321
329322
onMounted(() => {
@@ -590,7 +583,13 @@ watch(() => settings.provider, (newProvider) => {
590583
<div v-else-if="message.error" class="error-message">
591584
{{ message.error }}
592585
</div>
593-
<div v-else class="message-text markdown-body" v-html="renderMarkdown(message.content)"></div>
586+
<MarkdownRender
587+
v-else
588+
class="message-text"
589+
:nodes="(message.nodes || parseMarkdownToStructure(message.content, markdownParser)) as any"
590+
:max-live-nodes="0"
591+
custom-id="ai-chat"
592+
/>
594593
<button
595594
v-if="!message.loading"
596595
class="delete-message-btn"
@@ -1215,99 +1214,10 @@ watch(() => settings.provider, (newProvider) => {
12151214
color: var(--vp-c-danger-1);
12161215
}
12171216
1218-
/* Markdown content styles */
1219-
.markdown-body {
1220-
word-wrap: break-word;
1221-
}
1222-
1223-
.markdown-body :deep(p) {
1224-
margin: 0 0 8px;
1225-
}
1226-
1227-
.markdown-body :deep(p:last-child) {
1228-
margin-bottom: 0;
1229-
}
1230-
1231-
.markdown-body :deep(pre) {
1232-
background: var(--vp-c-bg-mute);
1233-
padding: 12px;
1234-
border-radius: 6px;
1235-
overflow-x: auto;
1236-
margin: 8px 0;
1237-
}
1238-
1239-
.markdown-body :deep(code) {
1240-
background: var(--vp-c-bg-mute);
1241-
padding: 2px 6px;
1242-
border-radius: 4px;
1243-
font-size: 13px;
1244-
font-family: var(--vp-font-family-mono);
1245-
}
1246-
1247-
.markdown-body :deep(pre code) {
1248-
background: transparent;
1249-
padding: 0;
1250-
}
1251-
1252-
.markdown-body :deep(ul),
1253-
.markdown-body :deep(ol) {
1254-
padding-left: 20px;
1255-
margin: 8px 0;
1256-
}
1257-
1258-
.markdown-body :deep(li) {
1259-
margin: 4px 0;
1260-
}
1261-
1262-
.markdown-body :deep(blockquote) {
1263-
border-left: 3px solid var(--vp-c-brand-1);
1264-
padding-left: 12px;
1265-
margin: 8px 0;
1266-
color: var(--vp-c-text-2);
1267-
}
1268-
1269-
.markdown-body :deep(h1),
1270-
.markdown-body :deep(h2),
1271-
.markdown-body :deep(h3),
1272-
.markdown-body :deep(h4) {
1273-
margin: 12px 0 8px;
1274-
font-weight: 600;
1275-
}
1276-
1277-
.markdown-body :deep(h1) { font-size: 1.4em; }
1278-
.markdown-body :deep(h2) { font-size: 1.2em; }
1279-
.markdown-body :deep(h3) { font-size: 1.1em; }
1280-
1281-
.markdown-body :deep(a) {
1282-
color: var(--vp-c-brand-1);
1283-
text-decoration: none;
1284-
}
1285-
1286-
.markdown-body :deep(a:hover) {
1287-
text-decoration: underline;
1288-
}
1289-
1290-
.markdown-body :deep(table) {
1291-
border-collapse: collapse;
1292-
width: 100%;
1293-
margin: 8px 0;
1294-
}
1295-
1296-
.markdown-body :deep(th),
1297-
.markdown-body :deep(td) {
1298-
border: 1px solid var(--vp-c-divider);
1299-
padding: 8px;
1300-
text-align: left;
1301-
}
1302-
1303-
.markdown-body :deep(th) {
1304-
background: var(--vp-c-bg-soft);
1305-
}
1306-
1307-
.markdown-body :deep(hr) {
1308-
border: none;
1309-
border-top: 1px solid var(--vp-c-divider);
1310-
margin: 12px 0;
1217+
/* Override markstream-vue styles for chat context */
1218+
.message-text :deep(.markstream-vue) {
1219+
font-size: 14px;
1220+
line-height: 1.6;
13111221
}
13121222
13131223
@media (max-width: 768px) {

.vitepress/theme/ai-chat/types/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* AI Chat Type Definitions
33
*/
44

5+
import type { ParsedNode } from 'markstream-vue'
6+
57
/** Message role types */
68
export type Role = 'user' | 'assistant' | 'system'
79

@@ -18,6 +20,8 @@ export interface Message {
1820
loading?: boolean
1921
/** Error message if failed */
2022
error?: string
23+
/** Parsed markdown nodes for streaming rendering */
24+
nodes?: ParsedNode[]
2125
}
2226

2327
/** Chat session */

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@
8585
"@giscus/vue": "^3.1.1",
8686
"@vite-pwa/vitepress": "^1.1.0",
8787
"feed": "^5.1.0",
88-
"marked": "^17.0.1",
88+
"markstream-vue": "0.0.3-beta.6",
8989
"pagefind": "^1.4.0",
9090
"sitemap": "^9.0.0",
9191
"vite-plugin-pwa": "^1.2.0",

0 commit comments

Comments
 (0)