重构+新增: 前端God文件拆分+进度条completed计数器

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

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

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

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

- MessageList.vue: 1386→1148行(-238行)

- ChatInput.vue: 1155→1007行(-148行)

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

- i18n: 进度条文案加已完成工具数(中英文)

- vue-tsc + vite build 通过
This commit is contained in:
2026-07-02 01:44:42 +08:00
parent 54ffc868b2
commit 266744b695
12 changed files with 728 additions and 463 deletions

View File

@@ -282,6 +282,7 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
// Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息
flushCurrentText()
state.currentText = ''
state.completedTools = 0 // 新轮重置工具计数器
state.messages.push({
id: `ai-${nextMsgId()}` as MessageId,
role: 'assistant',
@@ -445,6 +446,7 @@ function handleToolEvent(event: AiChatEvent): boolean {
tc.status = 'completed'
tc.result = event.result
}
state.completedTools++ // 进度条计数器
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
clearToolSlowTimer(event.id)
// AE-2025-06: 工具结束(无论审批通过后执行还是被拒),清审批超时计时器

View File

@@ -0,0 +1,220 @@
//! 流式 Markdown 块级 memo 渲染 — 抽自 MessageList.vue(零行为变更)。
//!
//! 职责:流式 delta 的块级切块 + memo 缓存 + rAF 节流分块渲染。
//! - splitBlocks:marked.lexer() 围栏感知切块(代码块整段一块,非代码按双换行切段)
//! - parseBlock/parseBlockNoCache:已完成块缓存命中 O(末块),末块每次重 parse
//! - renderStreamingBlocks:切块 + memo,返回 { html, key } 数组供模板 v-for v-html
//! - scheduleStreamParse:rAF 节流,帧内取最新文本 parse + 选区保持 + 跟随滚底
//!
//! 与父组件(MessageList)的交互经回调注入:
//! - saveSelection/restoreSelection:UX-2025-01 选文字保持(v-html 替换 innerHTML 销毁选区)
//! - isFollowingBottom:跟随意图锁存(跟随中则 rAF 回调内滚到真实底部)
//! - scrollToBottom:回底操作
//!
//! 共享:useMarkdown 模块级单例(_marked/_purify/mdReady/escapeFallback/getMarked/getPurify),
//! 与 TaskDetail/AiChat/MessageList 同实例,渲染结果零变化。
import { ref, computed, nextTick, type Ref, type ComputedRef } from 'vue'
import { useMarkdown } from '../useMarkdown'
import { useAppSettingsStore } from '../../stores/appSettings'
/// 流式渲染返回的单块渲染结果(html + 模板 v-for key)
export interface StreamBlock {
html: string
/** 已完成块=block文本hash(稳定), 末块='tail-'+递增序号(每次变) */
key: string
}
export interface UseStreamRendererOptions {
/** UX-2025-01:保存当前文字选区(v-html 替换前调) */
saveSelection: () => void
/** UX-2025-01:恢复之前保存的选区(DOM 重建后调) */
restoreSelection: () => void
/** B-260618-24:跟随意图锁存(跟随中则 rAF 回调内滚到真实底部) */
isFollowingBottom: Ref<boolean> | ComputedRef<boolean>
/** 回到底部操作 */
scrollToBottom: () => void
}
export function useStreamRenderer(opts: UseStreamRendererOptions) {
const {
mdReady,
renderMd,
escapeFallback,
getMarked,
getPurify,
} = 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 不重建 → 选文字保持。
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 每帧替换,保存选区
opts.saveSelection()
streamingBlocks.value = renderStreamingBlocks(lastStreamText)
nextTick(() => {
opts.restoreSelection()
// B-260618-24: rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。
// 治 onContentChange 的 nextTick(scrollToBottom) 抢跑旧 scrollHeight(微任务先于 rAF,
// 滚到渲染前高度)致"差一截"。
if (opts.isFollowingBottom.value) opts.scrollToBottom()
})
})
}
/// 清理流式渲染状态(streaming 翻 false / 会话切换 / 卸载前调)
/// 取消 pending rAF + 清 streamingBlocks + 重置 lastStreamText。
function clearStreamingState(): void {
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
streamingBlocks.value = []
lastStreamText = ''
}
/// 仅取消 pending rAF(不清 streamingBlocks/lastStreamText)。
/// 用于 streaming 翻 true 时只取消上一帧 pending,保留 streamingBlocks(原行为)。
function cancelPendingRaf(): void {
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
}
return {
mdReady,
renderMd,
escapeFallback,
streamingBlocks,
scheduleStreamParse,
renderStreamingBlocks,
clearStreamingState,
cancelPendingRaf,
}
}