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({