重构: 前端 composable 拆分+详情页 Markdown 渲染+流式 memo+状态同步+bug 修复
- composables/useMarkdown 抽(AiChat 复用)+ai/* 流式核心+useAiSend catch 回滚 user msg(B-21)+useAiStream 超时文案(B-03)+useAiConversations 切换 token(FR-R1) - views/TaskDetail 新建(F-02)+描述 md+mdReady 响应式+字段同行布局(B-24/31) - views/ProjectDetail 多选审批 UI(F-01)+描述 md+mdReady+字段同行(B-25/31) - views/Ideas+Knowledge 描述 md+mdReady(B-25) - views/Tasks 项目切换联动(B-29)+task 详情跳转 - components/AiChat toast 失败提示(B-20)+流式块级 memo(ARC-08)+clean UI(AR-7)+stop 兜底(AR-5) - components/ToolCard 审批卡片可读化(AR-3) - stores useAiStore barrel(ARC-04)+del settings mock(ARC-01)+approve options(R-PD-5) - i18n aiTool/nav 中英对称(AR-3/9)+删 nav.decisions 死链(ARC-02) - router /tasks/:id(F-02)+global.css selection 可见(R-260615-01)+api task detail+App 删 decisions nav
This commit is contained in:
@@ -106,6 +106,8 @@ export async function switchConversation(id: string) {
|
||||
// 此处查回并把对应 toolCard.status 置 pending_approval,使审批卡片重新可见
|
||||
try {
|
||||
const pending = await aiApi.pendingToolCalls(id)
|
||||
// 第二 await 后二次比对:切换 A→B 期间避免用 A 的 pending 覆写 B 的 pendingApprovals
|
||||
if (mySwitchId !== _latestSwitchId) return
|
||||
if (pending.length) {
|
||||
const pendingIds = new Set(pending.map(p => p.tool_call_id))
|
||||
const restored: AiToolCallInfo[] = []
|
||||
|
||||
@@ -41,8 +41,9 @@ async function sendMessage(text: string, skill?: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const userMsgId = `user-${nextMsgId()}`
|
||||
state.messages.push({
|
||||
id: `user-${nextMsgId()}`,
|
||||
id: userMsgId,
|
||||
role: 'user',
|
||||
content: text.trim(),
|
||||
timestamp: Date.now(),
|
||||
@@ -68,10 +69,11 @@ async function sendMessage(text: string, skill?: string) {
|
||||
try {
|
||||
await aiApi.sendMessage(text.trim(), lang, skill)
|
||||
} catch (e) {
|
||||
// IPC 失败(spawn 前/provider 配置错等):回滚 streaming 防光标卡死 + 移除空气泡占位;
|
||||
// IPC 失败(spawn 前/provider 配置错等):回滚 streaming 防光标卡死 + 移除 user 消息与空气泡占位;
|
||||
// 重新抛出由 handleSend 回填输入框,用户可重试
|
||||
state.streaming = false
|
||||
state.messages = state.messages.filter(m => m.id !== aiMsgId)
|
||||
clearStreamWatchdog() // 清 :61 启动的看门狗,防 130s 后 onStreamTimeout 误推错误气泡
|
||||
state.messages = state.messages.filter(m => m.id !== aiMsgId && m.id !== userMsgId)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
@@ -83,9 +85,16 @@ async function approveToolCall(toolCallId: string, approved: boolean) {
|
||||
.flatMap(m => m.toolCalls || [])
|
||||
.find(t => t.id === toolCallId)
|
||||
if (tc) tc.status = 'running'
|
||||
// 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts:162 AiApprovalRequired 已 clear,
|
||||
// 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 running。
|
||||
// 复用通用 watchdog 而非加审批专用超时:复用同一收尾路径(onStreamTimeout 兜底复位 streaming),
|
||||
// 避免新增独立计时器与状态分支)
|
||||
resetStreamWatchdog()
|
||||
try {
|
||||
await aiApi.approve(toolCallId, approved)
|
||||
} catch (e) {
|
||||
// IPC 未送达:用户已点过按钮,清看门狗(避免 130s 后误触发假错误)再回滚到失败态
|
||||
clearStreamWatchdog()
|
||||
// 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed,不进此分支
|
||||
// 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败
|
||||
console.error('[AI] 审批操作未送达后端:', e)
|
||||
|
||||
@@ -23,10 +23,26 @@ export function onStreamTimeout() {
|
||||
state.generatingConvId = null
|
||||
state.currentText = ''
|
||||
clearStreamWatchdog()
|
||||
// 文案区分:若已有 completed 工具调用,说明工具执行完后续回复被中断(常见为审批后
|
||||
// 续生成卡住),引导用户继续;否则按纯流式中断处理。
|
||||
// 数据源:state.messages[].toolCalls[].status,反向扫尾部提前退出(O(1) 均,同 findToolCall);
|
||||
// 不复用 useAiEvents.findToolCall 避免反向 import 循环依赖(useAiEvents 已 import 本模块)
|
||||
let hasCompletedTool = false
|
||||
for (let i = state.messages.length - 1; i >= 0; i--) {
|
||||
const toolCalls = state.messages[i].toolCalls
|
||||
if (!toolCalls) continue
|
||||
for (let j = 0; j < toolCalls.length; j++) {
|
||||
if (toolCalls[j].status === 'completed') { hasCompletedTool = true; break }
|
||||
}
|
||||
if (hasCompletedTool) break
|
||||
}
|
||||
const content = hasCompletedTool
|
||||
? '⚠ 工具已执行完成,后续回复中断(长时间无数据流)。可点继续重试。'
|
||||
: '⚠ 响应中断(长时间无数据流)。可能是网络断连或后端异常,请重试。'
|
||||
state.messages.push({
|
||||
id: `timeout-${nextMsgId()}`,
|
||||
role: 'assistant',
|
||||
content: '⚠ 响应中断(长时间无数据流)。可能是网络断连或后端异常,请重试。',
|
||||
content,
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
74
src/composables/useMarkdown.ts
Normal file
74
src/composables/useMarkdown.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
//! Markdown 渲染 composable — 抽自 src/components/AiChat.vue
|
||||
//!
|
||||
//! 背景:TaskDetail 任务描述需 Markdown 渲染(## 标题 / - 列表),原渲染逻辑只在
|
||||
//! AiChat.vue 内联,且与流式核心(splitBlocks/parseBlock/renderStreamingMd)共享
|
||||
//! _marked/_purify/mdReady。抽出为 composable 复用,AiChat 流式核心保持原状不动。
|
||||
//!
|
||||
//! 设计要点:
|
||||
//! - **模块级单例**:_marked/_purify/mdReady/_mdCache 放模块作用域,所有调用方共享
|
||||
//! 同一份渲染器实例与缓存。AiChat 的 loadMarkdown() 与流式 parseBlock 共用此实例,
|
||||
//! 行为与抽前完全一致。
|
||||
//! - **XSS 防护**:renderMd 输出经 DOMPurify.sanitize,可安全 v-html。
|
||||
//! - **块级 memo**:历史/完成态整段缓存(_mdCache),文本不变命中跳过 marked 全量解析。
|
||||
//! - **未就绪兜底**:marked 未加载完成时 escapeHtml + <br> 降级(纯文本安全)。
|
||||
//!
|
||||
//! 行为零变化说明:本文件函数体从 AiChat.vue 原样移出,逻辑未改动。
|
||||
//! AiChat.vue 流式函数(splitBlocks/parseBlock/parseBlockNoCache/renderStreamingMd/
|
||||
//! scheduleStreamParse)继续调用 useMarkdown 暴露的 mdReady/escapeFallback,
|
||||
//! 渲染结果不变。
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
// ═══ 模块级单例:AiChat 流式核心与历史 renderMd 共享同一份渲染器/缓存 ═══
|
||||
let _marked: typeof import('marked')['marked'] | null = null
|
||||
let _purify: typeof import('dompurify')['default'] | null = null
|
||||
const mdReady = ref(false)
|
||||
// 历史消息整段缓存(文本不变,重渲染命中省去 marked+sanitize 全量解析)
|
||||
const _mdCache = new Map<string, string>()
|
||||
|
||||
export function useMarkdown() {
|
||||
/** 异步加载 marked + DOMPurify;加载完成后 mdReady 翻转触发重渲染 */
|
||||
async function loadMarkdown() {
|
||||
const [{ marked }, dp] = await Promise.all([import('marked'), import('dompurify')])
|
||||
marked.setOptions({ breaks: true, gfm: true })
|
||||
_marked = marked
|
||||
_purify = dp.default
|
||||
_mdCache.clear() // 渲染器就绪,清兜底缓存触发完整重渲染
|
||||
mdReady.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
mdReady,
|
||||
loadMarkdown,
|
||||
renderMd,
|
||||
// 暴露给 AiChat 流式核心(parseBlock/renderStreamingMd)复用,保持单例共享
|
||||
escapeFallback,
|
||||
getMarked: () => _marked,
|
||||
getPurify: () => _purify,
|
||||
// 仅 AiChat 流式块级 memo 用(独立缓存,不与历史 _mdCache 混)
|
||||
clearHistoryCache: () => _mdCache.clear(),
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/// marked 未就绪时的兜底渲染:HTML 转义 + 换行转 <br>(纯文本安全降级)
|
||||
function escapeFallback(text: string): string {
|
||||
return escapeHtml(text).replace(/\n/g, '<br>')
|
||||
}
|
||||
|
||||
/// 历史/完成态:整段 marked + sanitize + 缓存(函数体原样移自 AiChat.vue)
|
||||
function renderMd(text: string): string {
|
||||
if (!text) return ''
|
||||
if (!mdReady.value || !_marked || !_purify) {
|
||||
return escapeFallback(text)
|
||||
}
|
||||
const cached = _mdCache.get(text)
|
||||
if (cached !== undefined) return cached
|
||||
const html = _purify.sanitize(_marked.parse(text) as string)
|
||||
if (_mdCache.size > 200) _mdCache.clear()
|
||||
_mdCache.set(text, html)
|
||||
return html
|
||||
}
|
||||
Reference in New Issue
Block a user