/** * 从 AgentChat.vue 抽出来的两块纯逻辑: * * 1. `createAgentChatEventHandler` —— SSE 事件分发器。只认 requestAgentId 是否等于 * 调用时刻"当前选中的 agent",据此决定要不要触碰全局态(路由/对话列表/DOM 滚动); * 传入的 `st`(每个 agent 一份的状态)永远无条件更新,不受这个判断影响——这样切走 * 了不会打断/污染,切回来能看到完整的后台进度(9b1c3f1 修的就是这条不变式)。 * 抽成不依赖组件实例的纯函数,是为了能在不挂载整个 Vue 组件的前提下对它做回归测试 * (40 号文档方案三)。 * * 2. `createTypewriterQueue` / `flushTypewriter` —— 打字机渲染缓冲(40 号文档方案一)。 * 后端已经把大段文本切成小片再发 SSE,这里再加一层匀速吐出,抹平网络到达节奏不均匀 * (突发多个 SSE 事件挤在一起)的情况。 */ const TYPEWRITER_TICK_MS = 20; const TYPEWRITER_CHARS_PER_TICK = 2; /** 把新到的文本接到 st 的打字机队列尾部,并确保有一个定时器在匀速吐出 */ export function createTypewriterQueue({ tickMs = TYPEWRITER_TICK_MS, charsPerTick = TYPEWRITER_CHARS_PER_TICK } = {}) { return function pushChunk(st, text) { if (!text) return; st._typeQueue = (st._typeQueue || '') + text; if (st._typeTimer) return; // 已经在吐了,接上队尾即可 st._typeTimer = setInterval(() => { if (!st._typeQueue) { clearInterval(st._typeTimer); st._typeTimer = null; return; } st.streamContent += st._typeQueue.slice(0, charsPerTick); st._typeQueue = st._typeQueue.slice(charsPerTick); }, tickMs); }; } /** 立刻把队列里剩下的字符吐光,用在"这轮已经结束/被打断,不用再演匀速"的时刻 */ export function flushTypewriter(st) { if (st._typeTimer) { clearInterval(st._typeTimer); st._typeTimer = null; } if (st._typeQueue) { st.streamContent += st._typeQueue; st._typeQueue = ''; } } /** * @param {() => string} getCurrentAgentId 读取"用户当前选中的 agent id" * @param {(requestAgentId: string, data: object, st: object) => void} onNewConversation * 仅在 isCurrent 时调用:处理路由跳转 + 对话列表插入这类全局副作用 * @param {() => void} scrollToBottom * @param {() => void} scrollAcpLog * @param {() => void} scrollSubLogs * @param {(st: object, text: string) => void} [pushChunk] 打字机队列的入队函数,默认新建一个 */ export function createAgentChatEventHandler({ getCurrentAgentId, onNewConversation, scrollToBottom, scrollAcpLog, scrollSubLogs, pushChunk = createTypewriterQueue(), }) { return function handleEvent(st, requestAgentId, type, data) { const isCurrent = getCurrentAgentId() === requestAgentId; switch (type) { case 'session': if (data.conversationId && !st.activeConvId) { st.activeConvId = data.conversationId; if (isCurrent) onNewConversation(requestAgentId, data, st); } break; case 'thinking': if (data.message && !data.message.startsWith('🏗️')) { st.thinkingContent += data.message; if (isCurrent) scrollToBottom(); } else { st.thinkingText = data.message || '思考中...'; } break; case 'agent_output': st.streaming = true; pushChunk(st, data.message || ''); if (isCurrent) scrollToBottom(); break; case 'tool_call': st.thinkingText = data.message || `🔧 ${data.tool}`; st.acpLog.push({ type: 'tool', icon: '🔧', text: `${data.tool}`, ts: Date.now() }); if (isCurrent) scrollAcpLog(); break; case 'tool_input': st.acpLog.push({ type: 'input', icon: '📥', text: data.input?.slice(0, 300) || '', ts: Date.now() }); if (isCurrent) scrollAcpLog(); break; case 'tool_result': st.acpLog.push({ type: 'result', icon: '📤', text: data.result?.slice(0, 300) || '', ts: Date.now() }); if (isCurrent) scrollAcpLog(); break; case 'session_cost': st.acpLog.push({ type: 'cost', icon: '💰', text: `$${data.cost?.toFixed(4)} (${((data.duration || 0) / 1000).toFixed(1)}s)`, ts: Date.now() }); break; case 'acp_raw': if (data.method && !data.method.includes('content_block_delta') && !data.method.includes('message_start')) { st.acpLog.push({ type: 'raw', icon: '⚡', text: data.method, ts: Date.now() }); if (isCurrent) scrollAcpLog(); } break; case 'sub_agent': if (!st.subAgents[data.callId]) st.subAgents[data.callId] = { agent: data.agent, status: 'running', content: '', collapsed: false }; if (data.event === 'chunk' && data.chunk) { st.subAgents[data.callId].content += data.chunk; if (isCurrent) scrollSubLogs(); } else if (data.event === 'done') st.subAgents[data.callId].status = data.status; else if (data.event === 'start') st.subAgents[data.callId].status = 'running'; if (isCurrent) scrollToBottom(); break; case 'error': st.messages.push({ role: 'assistant', content: `❌ ${data.message}`, timestamp: new Date().toISOString() }); st.acpLog.push({ type: 'error', icon: '🚨', text: data.message, ts: Date.now() }); st.thinkingText = ''; st.thinkingContent = ''; if (isCurrent) { scrollToBottom(); scrollAcpLog(); } break; } }; }