重构: 前端God文件拆分(StreamRenderer+EmptyState+MentionPopover+EnrichmentPanel)

- useStreamRenderer.ts: 流式Markdown块级渲染提取为composable(220行)

- EmptyState.vue: 空状态独立组件(无provider引导+示例问题)

- MentionPopover.vue: @实体联想浮层独立组件

- EnrichmentPanel.vue: @项目enrichment预览面板独立组件

- MessageList.vue: 1386→1148行, ChatInput.vue: 1155→1007行

- AiChat进度条: completedTools计数器(AiToolCallCompleted+1,AiAgentRound重置)

- i18n: 进度条文案加已完成工具数(中英文)
This commit is contained in:
lxy
2026-07-02 10:11:15 +08:00
parent 54ffc868b2
commit acf0ed091d
12 changed files with 728 additions and 463 deletions
+48 -286
View File
@@ -18,10 +18,11 @@ import { ref, computed, nextTick, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { useMarkdown } from '../../composables/useMarkdown'
import { useAppSettingsStore } from '../../stores/appSettings'
import { useMessageScroll } from '../../composables/ai/useMessageScroll'
import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
import ToolCardList from '../ToolCardList.vue'
import MessageItem from './MessageItem.vue'
import EmptyState from './EmptyState.vue'
import { formatRelative, formatDate } from '../../utils/time'
import type { AiMessage } from '../../api/types'
@@ -43,183 +44,16 @@ const { t } = useI18n()
// ═══ Markdown 渲染(ARC-260615-08:自研块级 memo,借鉴方案D流式核心) ═══
// B-23:渲染基础设施(_marked/_purify/mdReady/_mdCache/loadMarkdown/escapeFallback/
// renderMd)抽至 composables/useMarkdown(模块级单例),与 TaskDetail/AiChat 共享。
// 流式核心(splitBlocks/parseBlock/renderStreamingBlocks/scheduleStreamParse)保留在本组件,
// 经 getMarked()/getPurify()/mdReady/escapeFallback 复用同一份渲染器实例,行为零变化。
const {
mdReady,
loadMarkdown,
renderMd,
escapeFallback,
getMarked,
getPurify,
} = useMarkdown()
// 流式核心(splitBlocks/parseBlock/renderStreamingBlocks/scheduleStreamParse)抽至
// composables/ai/useStreamRenderer(本组件经 opts 注入 saveSelection/restoreSelection/
// isFollowingBottom/scrollToBottom 回调),行为零变化。
//
// useStreamRenderer 的初始化延后到 saveSelection/restoreSelection 定义之后(下方),
// 因 composable 的 opts 依赖这两个函数。mdReady/renderMd/escapeFallback/streamingBlocks/
// scheduleStreamParse/renderStreamingBlocks/clearStreamingState 均由 composable 返回。
const { loadMarkdown } = useMarkdown()
// ═══ AR-1 流式 Markdown 渲染开关(保守方案 B 的可回退开关) ═══
// appSettings key `df-ai-streaming-md`,默认 true(开):流式走块级 memo + rAF 节流的
// Markdown 分块渲染(代码块/列表/标题流式过程中就有格式)。关(false):回退纯文本短路
// (escapeFallback,每 delta 全文转义,<br> 换行)——即 AR-1 修复前的原行为,供性能敏感/
// 极端掉帧场景降级。读走 appSettings 缓存(响应式,Settings 改动即时生效)。
const appSettings = useAppSettingsStore()
const streamingMdEnabled = computed(() => appSettings.get<boolean>('df-ai-streaming-md', true))
// _marked/_purify 走 composable 单例(getMarked/getPurify 在 loadMarkdown 后才非 null);
// 流式 parse 经 getter 取最新引用,行为零变化。
// 块级 memo:单块文本 → html(流式时已完成块命中跳过,O(全文)→O(末块),借鉴方案D/业界主流机制2)
const _blockCache = new Map<string, string>()
const BLOCK_CACHE_LIMIT = 300
// 流式渲染状态:rAF 节流(多 delta 合并一帧,60fps 封顶防主线程阻塞掉帧)
// 分块渲染:已完成块 DOM 稳定不重建 → 选文字保持(UX-2025-01)
const streamingBlocks = ref<StreamBlock[]>([])
let rafId: number | null = null
let lastStreamText = ''
/// 切块:代码围栏(```...``` 或 3+ 反引号 + 行首可选空格 + info 字符串)整体一块
/// (跨双换行不切),非代码段按双换行切段。
/// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。
///
/// CR-260615-04:旧实现用正则 /```[^\n]*\n[\s\S]*?(?:```|$)/ 切块,与 marked 围栏规则
/// 不一致——不要求行首、固定 3 反引号,导致行中裸三反引号 / 4+ 反引号嵌套围栏切错,
/// 前块缓存固化错误 html。改用 marked.lexer()(经 getMarked() 取 marked 实例)做围栏
/// 感知切块:lexer 把代码围栏识别为单个 type==='code' token(.raw 含整段围栏),
/// 段落/标题等其余 token 的 .raw 按 \n\n 切段。lexer 与 parse 用同一份 marked 实例 +
/// 同一份围栏规则,切块边界与渲染边界一致。
function splitBlocks(text: string): string[] {
const marked = getMarked()
const blocks: string[] = []
// marked 未就绪时 splitBlocks 不应被调用(renderStreamingBlocks 已 mdReady 守卫),
// 此处兜底返回原文,不引入正则。
if (!marked) return [text]
const tokens = marked.lexer(text)
for (const tok of tokens) {
if (!tok || typeof (tok as { raw?: string }).raw !== 'string') continue
const raw = (tok as { raw: string }).raw
if ((tok as { type: string }).type === 'code') {
// 代码围栏 token:整段一块(含行首可选空格 + 3+ 反引号 + info 字符串)
if (raw.trim()) blocks.push(raw)
continue
}
// 非代码 token(paragraph/space/heading/list/blockquote/...):
// 按 \n\n 切段(段落内单换行不切,inline code 自然保留在段内)
for (const b of raw.split(/\n{2,}/)) if (b.trim()) blocks.push(b)
}
return blocks.length ? blocks : [text]
}
/// 末块不缓存(可能不完整,下次 delta 变),每次重 parse 处理未闭合 token
function parseBlockNoCache(block: string): string {
// _marked/_purify 在 loadMarkdown 后才就绪;此处仅在 mdReady 后被流式分支调用。
// UX-260617-25:防御性 null check —— renderStreamingBlocks 顶部已有 mdReady/getMarked/
// getPurify 守卫,正常路径 marked/purify 必非 null。但 marked.use(highlightCode) 注入的
// renderer 在动态 import 后仍可能抛异常(hljs 注册竞态/未知语言)→ 此前直接 marked!.parse()
// 会让异常冒泡到 rAF 回调中断流式,表现为生成卡死。这里 null 兜底降级 escapeFallback,
// parse 抛错时 try/catch 同样降级,保证流式不中断。
const marked = getMarked()
const purify = getPurify()
if (!marked || !purify) return escapeFallback(block)
try {
return purify.sanitize(marked.parse(block) as string)
} catch {
return escapeFallback(block)
}
}
/// 块级 memo parse:已完成块缓存命中,O(末块)
function parseBlock(block: string): string {
const cached = _blockCache.get(block)
if (cached !== undefined) return cached
const html = parseBlockNoCache(block)
if (_blockCache.size >= BLOCK_CACHE_LIMIT) _blockCache.delete(_blockCache.keys().next().value!)
_blockCache.set(block, html)
return html
}
/// 流式渲染:切块 + 块级 memo(前块缓存/末块重 parse)
/// 返回 { html, key } 数组:key 用 block 文本 hash(已完成块稳定/末块递增),
/// 模板 v-for + v-html 分块渲染,已完成块 DOM 不重建 → 选文字保持。
interface StreamBlock {
html: string
key: string // 已完成块=block文本hash(稳定), 末块='tail-'+递增序号(每次变)
}
function renderStreamingBlocks(text: string): StreamBlock[] {
if (!text) return []
// AR-1 流式 MD 开关关 → 回退纯文本短路(escapeFallback 全文转义 + <br>,原行为)
// mdReady 未就绪也走纯文本兜底(marked 尚未加载完成)。
if (!streamingMdEnabled.value || !mdReady.value || !getMarked() || !getPurify()) {
return [{ html: escapeFallback(text), key: 'fallback' }]
}
const blocks = splitBlocks(text)
const n = blocks.length
let tailSeq = 0 // 末块递增序号,确保末块 key 每次不同触发更新
return blocks.map((b, i) => {
const isTail = i === n - 1
// AR-1 代码块降级:末块若是未闭合的代码围栏(```/~~~ 数为奇数,说明代码块还没结束),
// 不走 marked.parse(未闭合围栏 → marked 推断为代码块 + hljs 对不完整代码高亮,
// 流式过程中每 delta 重 parse 会闪烁/错乱)。降级为纯文本(转义围栏原文),AiCompleted
// 后整段 renderMd 会做完整代码块渲染(此时围栏已闭合)。已完成块不受影响(围栏已闭合)。
const degrade = isTail && isUnclosedCodeFence(b)
const html = degrade
? escapeFallback(b)
: isTail ? parseBlockNoCache(b) : parseBlock(b)
// 已完成块用文本做 key(DOM 稳定不重建);末块用递增序号(每帧更新)
const key = isTail ? `tail-${++tailSeq}` : `b-${simpleHash(b)}`
return { html, key }
})
}
/// 末块未闭合代码围栏检测:统计行首(可选 ≤3 空格)的 ``` / ~~~ 围栏开/闭数量,
/// 奇数 = 有未闭合的代码块(流式还在写入该代码块)。仅末块调用,前块必已闭合(splitBlocks
/// 已把完整 code token 切成独立块)。对齐 marked 围栏规则(行首 ≤3 空格 + 3+ 反引号/波浪)。
function isUnclosedCodeFence(block: string): boolean {
let fenceCount = 0
const lines = block.split('\n')
for (const line of lines) {
// 行首 ≤3 空格 + 3+ 反引号或波浪(marked 围栏开关判定)
const m = /^[ ]{0,3}(`{3,}|~{3,})/.exec(line)
if (m) fenceCount++
}
return fenceCount % 2 === 1
}
/// 轻量字符串哈希:用于 block key 稳定性(非加密,仅避免长文本做 key)
function simpleHash(s: string): number {
let h = 5381
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
return h >>> 0
}
/// rAF 节流:同帧多 delta 仅排队一次,帧内取最新 lastStreamText parse
function scheduleStreamParse(text: string) {
lastStreamText = text
if (rafId !== null) return
rafId = requestAnimationFrame(() => {
rafId = null
// UX-2025-01: 末块 innerHTML 每帧替换,保存选区
saveSelection()
streamingBlocks.value = renderStreamingBlocks(lastStreamText)
nextTick(() => {
restoreSelection()
// B-260618-24: rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。
// 治 onContentChange 的 nextTick(scrollToBottom) 抢跑旧 scrollHeight(微任务先于 rAF,
// 滚到渲染前高度)致"差一截"。
if (isFollowingBottom.value) scrollToBottom()
})
})
}
/// template 绑定入口:最后AI消息流式时用 streamingBlocks(分块v-for,已完成块DOM稳定),
/// 否则 renderMd(完成/历史,整段string给v-html)
function renderContent(msg: AiMessage): string {
// 流式:由模板直接读 streamingBlocks(v-for 分块),此处不参与
return renderMd(msg.content)
}
// ── UX-2025-20: 空状态示例问题 ──
// 示例问题卡片(点击自动填入并发送);文案走 i18n key 数组,模板按 key 翻译。
const examplePrompts = [
'aiChat.examplePrompt1',
'aiChat.examplePrompt2',
'aiChat.examplePrompt3',
'aiChat.examplePrompt4',
] as const
// ── UX-2025-20: 空状态示例问题(examplePrompts 已随 EmptyState.vue 提取) ──
const messagesContainer = ref<HTMLDivElement>()
// 工具卡片列表(子组件 ToolCardList 自治折叠态,父级仅经 expose 调 collapseInactive 收起旧卡)
@@ -285,6 +119,24 @@ const {
onContentChange,
setFollowing,
} = useMessageScroll(messagesContainer)
// ═══ 流式 Markdown 块级 memo 渲染(抽至 composables/ai/useStreamRenderer) ═══
// opts 注入 saveSelection/restoreSelection(function 声明,提升可用,定义在下方)、
// isFollowingBottom/scrollToBottom(来自上方 useMessageScroll)。
const {
mdReady,
renderMd,
streamingBlocks,
scheduleStreamParse,
clearStreamingState,
cancelPendingRaf,
} = useStreamRenderer({
saveSelection,
restoreSelection,
isFollowingBottom,
scrollToBottom,
})
watch(() => store.state.messages.length, onContentChange)
// SW-260618-07: currentText → onContentChange 合并到下方 scheduleStreamParse watch(同源 currentText,单 callback 顺序执行)
@@ -308,8 +160,7 @@ watch(() => store.state.currentText, (text) => {
// 残留上一轮 → 新气泡 isLastAi 命中(:1017)渲染残留分块 → 与上一条回复重叠堆叠。
// 在 currentText 归零处清,精确覆盖"新轮切换"与"生成结束"两个时机。
if (!text) {
streamingBlocks.value = []
lastStreamText = ''
clearStreamingState()
}
onContentChange()
}
@@ -320,7 +171,9 @@ watch(mdReady, (ready) => {
}
})
watch(() => store.state.streaming, (s) => {
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
// 原行为:两分支均先取消 pending rAF(streaming 中途切换帧防泄漏);s===true 不清
// streamingBlocks(依赖 currentText watch 的 !text 分支按新轮时机清,BUG-260624-01)。
cancelPendingRaf()
if (s) {
// UX-260616-03: 新一轮生成开始 → 收起上一轮已完成的旧工具卡,聚焦当前生成内容。
// 仅当前视图正在生成时触发(isGenerating(activeConversationId)),
@@ -332,8 +185,7 @@ watch(() => store.state.streaming, (s) => {
} else {
// UX-2025-01: 流式→完成切换,模板从 v-for 分块切到 v-html 整段,DOM 重建
saveSelection()
streamingBlocks.value = []
lastStreamText = ''
clearStreamingState()
nextTick(() => restoreSelection())
}
})
@@ -724,16 +576,16 @@ watch(() => store.state.activeConversationId, () => {
// 切到正在生成的 conv / 分离窗口挂载。比靠 currentText 副作用间接触发更精确,不依赖
// switchConversation 内部语句顺序(对齐 no-patch-groundwork:状态变更点收敛,非渲染侧补丁)。
if (streamingBlocks.value.length) {
streamingBlocks.value = []
lastStreamText = ''
clearStreamingState()
} else {
cancelPendingRaf()
}
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
})
// 流式 rAF 清理:组件卸载时若仍有 pending rAF(streaming 中途切走/关窗),取消防泄漏。
// streaming 翻 false 的 watch 已清 rafId,此为中途卸载兜底。
onBeforeUnmount(() => {
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
cancelPendingRaf()
})
// Markdown 预热(父原 loadMarkdown 在 onMounted 调,子组件同样幂等——useMarkdown 单例,
@@ -749,37 +601,13 @@ defineExpose({
<template>
<!-- Messages -->
<div class="ai-messages" ref="messagesContainer" @scroll="onMessagesScroll(() => collapseAllToolLists(buildActiveToolIds(store.state.messages)))">
<!-- 空状态 (UX-2025-20: 示例问题卡片 + provider 引导跳 Settings) -->
<div class="ai-empty" v-if="store.state.messages.length === 0 && !store.state.streaming">
<template v-if="store.state.providers.length === 0">
<div class="ai-empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-warning)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<p class="ai-empty-text">{{ $t('aiChat.emptyNoProvider') }}</p>
<p class="ai-empty-sub">{{ $t('aiChat.emptyNoProviderHint') }}</p>
<!-- Settings:嵌入模式用 router.push;分离窗口无 Settings 路由,降级 toast 提示去主窗口 -->
<button class="ai-empty-action" @click="emit('go-to-settings')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
<span>{{ $t('aiChat.goToSettings') }}</span>
</button>
</template>
<template v-else>
<div class="ai-empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-text-dim)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
</div>
<p class="ai-empty-text">{{ $t('aiChat.emptyTitle') }}</p>
<p class="ai-empty-sub">{{ $t('aiChat.emptyHint') }}</p>
<!-- UX-2025-20: 示例问题卡片(点击自动填入并发送) -->
<div class="ai-empty-prompts">
<button
v-for="(prompt, i) in examplePrompts"
:key="i"
class="ai-empty-prompt"
@click="emit('send-example-prompt', prompt)"
>{{ $t(prompt) }}</button>
</div>
</template>
</div>
<!-- 空状态 (UX-2025-20: 示例问题卡片 + provider 引导跳 Settings) 提取至 EmptyState.vue -->
<EmptyState
v-if="store.state.messages.length === 0 && !store.state.streaming"
:has-provider="store.state.providers.length > 0"
@send-example-prompt="(i18nKey) => emit('send-example-prompt', i18nKey)"
@go-to-settings="emit('go-to-settings')"
/>
<!-- 消息列表(F-15 阶段2:按 renderItems 扁平渲染 — 折叠段 emit 'sep' 项,
正常消息 + 展开的折叠段内消息 emit 'msg' 项。连续 archived_segment/compressed
@@ -830,7 +658,7 @@ defineExpose({
<!-- F-15:压缩摘要 system 消息置顶显示(compressed 原文照常展开不折叠,见 messageSegments) -->
<div v-if="item.msg.role === 'system'" class="ai-msg-system">
<span class="ai-msg-system-label">{{ $t('aiChat.compressedSummaryLabel') }}</span>
<div class="ai-msg-system-summary ai-md" v-html="renderContent(item.msg)"></div>
<div class="ai-msg-system-summary ai-md" v-html="renderMd(item.msg.content)"></div>
</div>
<!-- 用户消息(提取至 MessageItem.vue) -->
<MessageItem
@@ -864,7 +692,7 @@ defineExpose({
<span v-if="isViewingGenerating" class="ai-cursor"></span>
</template>
<!-- 完成/历史:整段v-html(不变) -->
<div v-else v-html="renderContent(item.msg)"></div>
<div v-else v-html="renderMd(item.msg.content)"></div>
<button
v-if="item.msg.content"
class="ai-copy-btn ai-copy-btn--ai"
@@ -985,73 +813,7 @@ defineExpose({
.back-to-bottom-enter-from,
.back-to-bottom-leave-to { opacity: 0; transform: translateY(8px); }
/* -- Empty State -- */
.ai-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0.5;
}
.ai-empty-icon { margin-bottom: 4px; }
.ai-empty-text {
font-size: 14px;
font-weight: 500;
color: var(--df-text-secondary);
}
.ai-empty-sub {
font-size: 12px;
color: var(--df-text-dim);
text-align: center;
line-height: 1.5;
}
/* UX-2025-20: 无 provider 引导 — 跳 Settings 按钮(克制:边框线 + accent 文字,不喧宾) */
.ai-empty-action {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 6px;
padding: 4px 12px;
border: 0.5px solid var(--df-accent);
border-radius: var(--df-radius);
background: var(--df-accent-bg);
color: var(--df-accent);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: filter 0.15s var(--df-ease);
}
.ai-empty-action:hover { filter: brightness(1.08); }
/* UX-2025-20 §7.3: 示例问题卡片网格(2 列,轻量;复用 .ai-* 卡片风格) */
.ai-empty-prompts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
width: 100%;
max-width: 360px;
margin-top: 10px;
}
.ai-empty-prompt {
padding: 8px 10px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
background: var(--df-bg-card);
color: var(--df-text-secondary);
font-size: 11.5px;
line-height: 1.4;
text-align: left;
cursor: pointer;
opacity: 0.85;
transition: all 0.15s var(--df-ease);
}
.ai-empty-prompt:hover {
border-color: var(--df-accent);
color: var(--df-text);
opacity: 1;
background: color-mix(in srgb, var(--df-accent) 8%, var(--df-bg-card));
}
/* -- Empty State 样式已随 EmptyState.vue 提取 -- */
/* -- Message Layout -- */
.ai-msg-user {