From 40c4a21a7e5bd6478d73e5f512dc8095182c8a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Fri, 9 Oct 2026 22:53:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20AI=20Chat=20=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E8=A7=86=E5=9B=BE:=20=E9=97=AA=E7=83=81=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D+=E9=AA=A8=E6=9E=B6=E7=BB=9F=E4=B8=80+=E8=BF=87?= =?UTF-8?q?=E7=A8=8B=E5=8D=A1=E9=99=8D=E7=BA=A7+=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=85=B3=E8=81=94=20chip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MessageList.vue: 1. 流式闪烁修复: _mdRenderKey 稳定化 + key 对齐, 消除流式重渲染抖动 2. 骨架统一: 过程步骤卡改用 trace 卡骨架(头部一行摘要 + 正文增量) 3. 过程卡视觉降级: 缩进收窄 + 紧贴结果, 弱化过程突出结论 4. 消息任务关联: TASK_REF_TOOLS 映射(advance_task/run_workflow) 从 toolCalls[].args 派生任务 chip, 零数据模型改动(单一事实源), 无任务工具 的消息不渲染 chip; 新增 .ai-trace-task-chip 样式 + go-to-task emit AiChat.vue: goToTask(taskId) 接参, router.push('/tasks?taskId=xxx') Tasks.vue: applyRouteTaskId 消费 query.taskId 自动开抽屉(onMounted 跨路由 + watch 同路由), 消费后 router.replace 清 query 保持 URL 干净 i18n zh/en: aiChat.goToTaskHint + ai.trace.taskRef 双语补键 验收: vue-tsc --noEmit EXIT 0 + build EXIT 0。 --- src/components/AiChat.vue | 11 +++ src/components/ai/MessageList.vue | 153 ++++++++++++++++++++++++++---- src/i18n/en/aiChat.ts | 9 +- src/i18n/zh-CN/aiChat.ts | 9 +- src/views/Tasks.vue | 20 +++- 5 files changed, 177 insertions(+), 25 deletions(-) diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index e5f8c7f..fa3e785 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -43,6 +43,7 @@ @start-edit="startEdit" @send-example-prompt="sendExamplePrompt" @go-to-settings="goToSettings" + @go-to-task="goToTask" @toast="(p) => showToast(p.msg, p.type)" /> @@ -211,6 +212,16 @@ function goToSettings() { router.push('/settings') } +/** 跳任务详情(消息任务 chip 点击):嵌入模式 router.push('/tasks?taskId=xxx') 由 Tasks.vue + * applyRouteTaskId 消费 query 自动开抽屉;分离窗口无 Tasks 路由,降级 toast */ +function goToTask(taskId: string) { + if (props.detached) { + showToast(t('aiChat.goToTaskHint'), 'warning') + return + } + router.push({ path: '/tasks', query: { taskId } }) +} + /** 点击示例问题卡片:委托 ChatInput 子组件发送(填入输入框 + handleSend 同款路径) */ function sendExamplePrompt(i18nKey: string) { chatInputRef.value?.sendExamplePrompt(i18nKey) diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue index 8771622..91f42d6 100644 --- a/src/components/ai/MessageList.vue +++ b/src/components/ai/MessageList.vue @@ -28,7 +28,8 @@ import ToolCardList from '../ToolCardList.vue' import MessageItem from './MessageItem.vue' import EmptyState from './EmptyState.vue' import { formatDate, formatAbsolute } from '../../utils/time' -import type { AiMessage } from '../../api/types' +import { taskApi } from '../../api' +import type { AiMessage, TaskRecord } from '../../api/types' defineProps<{ /** 当前正在编辑的消息 id(父持有,经 ChatInput 透传);末条 user 编辑按钮据此隐藏 */ @@ -39,12 +40,69 @@ const emit = defineEmits<{ (e: 'start-edit', msg: AiMessage): void (e: 'send-example-prompt', i18nKey: string): void (e: 'go-to-settings'): void + (e: 'go-to-task', taskId: string): void (e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void }>() const store = useAiStore() const { t } = useI18n() +// ── 消息任务关联(有的消息有,有的没):从 toolCalls[].args 派生 ── +// 数据源:AiToolCallInfo.args 已携带工具入参,任务工具的 task_id 就在其中 +// (advance_task 用 id / run_workflow 用 task_id,与 useToolCardHeader.TASK_ID_TOOL_ARG 同源)。 +// 不新增 AiMessage 字段:关联是「从已有 toolCalls 派生的计算属性」,单一事实源。 +// 任务标题懒加载全量任务缓存(taskApi.list 无参=全量,失败空数组占位防重试),复用 ChatInput 既有模式。 +const TASK_REF_TOOLS: Record = { + advance_task: 'id', + run_workflow: 'task_id', +} +const taskTitleCache = ref | null>(null) +async function ensureTaskTitles(): Promise { + if (taskTitleCache.value) return + try { + const list = await taskApi.list() + taskTitleCache.value = new Map(list.map((tk: TaskRecord) => [tk.id, tk.title])) + } catch (e) { + taskTitleCache.value = new Map() // 空占位防反复重试 + console.error('[MessageList] 加载任务标题失败:', e) + } +} +/** 从消息的 toolCalls 提取关联任务(无任务工具则返回空数组 = 该消息无任务关联) */ +function taskRefsOf(msg: AiMessage): { taskId: string; toolName: string }[] { + const refs: { taskId: string; toolName: string }[] = [] + for (const tc of msg.toolCalls ?? []) { + const key = TASK_REF_TOOLS[tc.name] + if (!key) continue + const args = tc.args as Record | undefined + const taskId = args?.[key] + if (typeof taskId === 'string' && taskId) refs.push({ taskId, toolName: tc.name }) + } + return refs +} +/** 任务标题(缓存命中用标题,未命中显短 id;缓存未就绪时触发懒加载) */ +function taskTitleOf(taskId: string): string { + if (!taskTitleCache.value) { void ensureTaskTitles(); return taskId.slice(0, 8) } + return taskTitleCache.value.get(taskId) ?? taskId.slice(0, 8) +} +/** 一轮轨迹内所有过程消息的任务关联去重(同一任务多次调用只留一个 chip) */ +function traceTaskRefs(msgs: AiMessage[]): { taskId: string; toolName: string }[] { + const seen = new Set() + const out: { taskId: string; toolName: string }[] = [] + for (const m of msgs) { + for (const ref of taskRefsOf(m)) { + if (seen.has(ref.taskId)) continue + seen.add(ref.taskId) + out.push(ref) + } + } + return out +} +/** 点击任务 chip 跳转任务详情:委托父组件(AiChat)走 router.push('/tasks'),detached 降级 toast。 + 与既有 go-to-settings 同模式——子组件不直接持 router,降级策略归父。 */ +function openTask(taskId: string): void { + emit('go-to-task', taskId) +} + // ═══ Markdown 渲染自研块级 memo,借鉴方案D流式核心) ═══ // 渲染基础设施(_marked/_purify/mdReady/_mdCache/loadMarkdown/escapeFallback/ // renderMd)抽至 composables/useMarkdown(模块级单例),与 TaskDetail/AiChat 共享。 @@ -644,6 +702,11 @@ function decorateCodeHtml(raw: string): string { } /** renderMd 包装:渲染 + 代码块装饰(双层 memo:renderMd 的 _mdCache + 本缓存,未变即零成本) */ function renderMdDecorated(raw: string): string { + // 闪烁修复(2026-10-09):读 _mdRenderKey 建立响应式依赖——mdReady 就绪后 forceUpdate() + // 递增此 ref 触发本函数重算(拿最新 marked 渲染),但**不再把 _mdRenderKey 放进 :key** + // (key 变化会让 Vue 销毁重建整个气泡 DOM,marked 冷加载完成瞬间即「回复闪一下」)。 + // 依赖放在渲染函数内:只更新 v-html 内容,气泡元素复用不重建。 + void _mdRenderKey.value return decorateCodeHtml(renderMd(raw)) } @@ -1014,7 +1077,10 @@ const renderItems = computed(() => { if (prev.length > 0) { pushTimeSepIfNeeded(items, msgs[0].timestamp) traceCount++ - items.push({ kind: 'step', key: 'step-' + prev[0].id, round: traceCount, stats: collectTurnStats(msgs) }) + // 闪烁修复(2026-10-09):step 项 key 与下方完成态 trace 项对齐(同为 trace-{首条id}), + // 使「执行中 step → 完成态 trace」切换时 Vue 复用同一 DOM 节点(key 稳定),不销毁重建。 + // 原 step-{id} 与 trace-{id} key 不同 → 完成瞬间整块轨迹 DOM 重建 → 闪烁。 + items.push({ kind: 'step', key: 'trace-' + prev[0].id, round: traceCount, stats: collectTurnStats(msgs) }) } else { pushTimeSepIfNeeded(items, cur.timestamp) } @@ -1427,13 +1493,22 @@ defineExpose({