agentChatEvents.js 6.54 KB
/**
 * 从 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;
      // 429 排队等待改造:锁被占用时后端会先发这条,让"思考中"从通用文案变成
      // 能看出真实原因的排队提示,而不是干等到超时/开始输出都看不出区别。
      case 'queued': {
        const waitSec = Math.round((data.waitBudgetMs || 0) / 1000);
        st.thinkingText = `⏳ 排队等待中 — Agent 正在处理另一个会话,最多等待 ${waitSec} 秒`;
        if (isCurrent) scrollToBottom();
        break;
      }
      // 排队等到预算耗尽:这条路径的响应头已经按 200 SSE 发出去了,后端没法再改状态码,
      // 只能用这个专门的事件类型收尾——跟 'error' 分支一样直接落一条可见的助手消息。
      case 'queue_timeout':
        st.messages.push({ role: 'assistant', content: `⏳ ${data.error || 'Agent 排队等待超时,请稍后重试'}`, timestamp: new Date().toISOString() });
        st.thinkingText = '';
        st.thinkingContent = '';
        if (isCurrent) scrollToBottom();
        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;
    }
  };
}