agentChatEvents.js
5.52 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
/**
* 从 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;
}
};
}