agentChatEvents.js
6.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
/**
* 从 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;
}
};
}