Commit 4d23f5068d5e29461c135f670a03f97d92564d26

Authored by yibo
1 parent d967ac0d

feat(agent-chat): 打字机平滑 + 打断并发送 + 串台加固回归测试(40 号文档)

排查「前端刷新不及时/没有打字机效果」发现现状已是 SSE,不需要换 WebSocket,
真正缺的是三件独立的事:

- 打字机平滑:后端把 ACP chunk 按 10 字符切片再发 SSE,前端用定时器队列
  (agentChatEvents.js 的 createTypewriterQueue)匀速吐出,抹平网络突发。
- 打断并发送:执行中解锁输入框,新增 ⤴ 按钮与 ⏹ Stop 视觉区分;stop 端点
  改为轮询确认 agentChatLocks 真正释放(3 秒预算)再返回 cleaned 字段,
  前端据此决定直接发送还是走"强制发送"兜底。顺手修了一个真实并发漏洞:
  同一 agentId + 同一 convId 的并发请求过去完全没有闸门,只是一直靠前端
  textarea 的 disabled 属性侥幸没被触发。
- 串台加固:把 handleEvent 分发逻辑抽成不依赖组件实例的纯函数
  (agentChatEvents.js),补上 9b1c3f1 那条不变式的回归测试。

已用真实 Claude CLI 会话(非 mock)在浏览器里验证打字机效果、打断并发送
全流程、以及纯 Stop 无回归。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDtmdnE6pay9BhwZDAKK6M
docs/todo/system-design/40-agent-chat-streaming-interrupt.md 0 → 100644
  1 +# 40-agent-chat-streaming-interrupt. Agent Chat 打字机平滑 + 打断-排队 + 串台加固
  2 +
  3 +> **日期**: 2026-08-30
  4 +> **状态**: 📝 需求文档,讨论中,尚未实现
  5 +> **触发**: 用户反馈"前端刷新不及时、没有打字机效果",怀疑要不要把传输层从 HTTP 换成 WebSocket
  6 +> 来解决打字机效果、多 Agent/多聊天切换、执行中无法打断这几个问题。经排查现状代码 +
  7 +> 对照 [hermes-webui](file:///Users/yibo/Documents/lemonAI/hermes-webui)、
  8 +> [hermes-agent](file:///Users/yibo/Documents/lemonAI/hermes-agent)、
  9 +> [deepseek-harness](file:///Users/yibo/Documents/lemonAI/deepseek-harness) 三个参考实现,
  10 +> 结论是**传输层不是瓶颈,不需要引入 WebSocket**——三个参考项目全部走 SSE/HTTP 流式,
  11 +> 没有一个用 WebSocket。真正的差距是另外三件独立的事,本文档逐一给方案。
  12 +
  13 +---
  14 +
  15 +## 起因
  16 +
  17 +现状排查发现:LemonBoss 的 Agent Chat **已经是 SSE 流式**,不是普通 HTTP 一问一答——
  18 +`web/src/views/AgentChat.vue:941` 用 `fetch().body.getReader()` 逐行解析
  19 +`event:`/`data:`,后端 `server/routes/agent-chat.js:2542`(`POST /:agentId/chat`)用
  20 +`setupSSE(res)` 把 Claude CLI 子进程吐出的 ACP `agent_message_chunk` 事件实时转发给前端。
  21 +多聊天"串台"(切换 Agent 时旧流写串到新 Agent 状态里)已经在 `9b1c3f1` 修复:每个 Agent
  22 +一份独立的 `chatStates` reactive 状态 + `requestAgentId` 快照,事件处理函数(`handleEvent`,
  23 +`AgentChat.vue:991`)全程用 `isCurrent()` 校验"这条流是不是还对应当前选中的 Agent"。
  24 +
  25 +对照 hermes-webui(`api/gateway_chat.py` 的 `_stream_writeback_is_current`)和
  26 +deepseek-harness(`interruptByParent`:中止当前 turn → 新消息进 FIFO → 排队执行),
  27 +确认这套"per-stream id + is-current 校验"就是业界通用解法,LemonBoss 已经用对了模式,
  28 +不需要换传输层来重新解决一遍。
  29 +
  30 +真正剩下的三个独立问题:
  31 +
  32 +1. **打字机不流畅**——不是传输问题,是 ACP chunk 粒度太粗(Claude CLI 一次吐一大段,不是
  33 + 逐字/逐 token),SSE 本身能把这段文本秒级送达前端,但前端拿到手就是一大坨,没有"逐字吐出"
  34 + 的观感。
  35 +2. **执行中无法打断并接上新消息**——现状是"先 Stop 再发"两步操作,且发送框在
  36 + `chatState.sending` 为真时整个被 `disabled`(`AgentChat.vue:192`),物理上打不了字。
  37 + deepseek-harness 有专门的打断-排队状态机,LemonBoss 没有。
  38 +3. **串台已修,但没有回归测试兜底**——`9b1c3f1` 是一次性修复,`isCurrent()` 校验散落在
  39 + `handleEvent` 十几个分支里,没有测试盯着,将来改动容易在某个分支漏掉校验又退回去。
  40 +
  41 +---
  42 +
  43 +## 一、现状代码地图(后续设计都基于这份)
  44 +
  45 +| 组件 | 位置 | 现状行为 |
  46 +|------|------|----------|
  47 +| 前端发送/接收 | `AgentChat.vue:908-969`(`handleSend`) | `fetch()` 开流,`st.sending=true` 期间 textarea `disabled`(`:192`),流结束才 `sending=false` |
  48 +| 前端事件分发 | `AgentChat.vue:991-1042`(`handleEvent`) | 按 `requestAgentId === agentId.value` 做 `isCurrent()` 校验,非当前 Agent 的流只落状态不刷 UI |
  49 +| 后端 SSE 入口 | `agent-chat.js:2542`(`POST /:agentId/chat`) | 每次请求 `while(loopCount<10)` 循环喂 prompt 给 ACP 协议,`onUpdate` 把 `agent_message_chunk` 原样透传成一条 SSE `agent_output` 事件 |
  50 +| 后端并发闸门 | `agent-chat.js:2552-2568`(`agentChatLocks`,键是 `agentId`) | 同一 Agent、**不同** convId 并发请求直接 `429` 拒绝;同一 Agent、**相同** convId 的并发请求**不会**被这段拒绝——第二个请求会直接覆盖锁往下跑,等于两个循环同时喂同一个 ACP 会话。目前靠前端 `disabled` 挡住,服务端没有闸门(见下文风险①) |
  51 +| 停止端点 | `agent-chat.js:1505`(`POST /:agentId/chat/:convId/stop`) | 调用 `closeSession(key, reason)`(`agent-chat.js:840`),即 `entry.protocol.disconnect()` 杀掉常驻 CLI 子进程,是"硬中止",不接排队 |
  52 +| 锁释放 | `agent-chat.js:2880`(`finally` 块) | 请求 handler 退出时统一 `agentChatLocks.delete(agentId)` + `res.end()`;中止后原请求是否会立刻走到这里,取决于 `protocol.disconnect()` 是否让 handler 内部 `await` 的 ACP 调用抛错——**需要在实现阶段用真实 CLI 验证一次**(见风险②) |
  53 +
  54 +---
  55 +
  56 +## 二、方案一:打字机平滑(两端都做)
  57 +
  58 +**根因**:ACP `agent_message_chunk` 的粒度由 Claude CLI 决定,一次可能带几十到几百字符,
  59 +SSE 转发是逐 chunk 转发(`agent-chat.js:2628` `sendSSE('agent_output', ...)`),前端收到
  60 +就是这一整段直接拼进 `st.streamContent`,没有二次拆分,所以观感是"一坨一坨蹦出来"而不是
  61 +打字机。
  62 +
  63 +### 2.1 后端:转发前按字符数切片
  64 +
  65 +在 `agent-chat.js:2624` 的 `onUpdate` 里,把 `update.content.text` 按固定步长(比如 8-12
  66 +字符一片)拆成多条 `sendSSE('agent_output', ...)`,而不是一次性整段发送。这是唯一需要碰
  67 +后端的点,改动局限在这一个回调函数内,不影响日志落盘(`logMessageChunk` 仍然整段写,只是
  68 +发给前端的部分被拆片)。
  69 +
  70 +### 2.2 前端:渲染节流缓冲
  71 +
  72 +在 `handleEvent` 收到 `agent_output` 时(`AgentChat.vue:1015`),不直接把整段 `data.message`
  73 +拼进 `st.streamContent`,改成推进一个逐字符渲染队列,用 `requestAnimationFrame` 或
  74 +`setInterval` 按固定速率(比如 30-50 字符/秒)从队列取字符追加显示。这样即使后端某次真的
  75 +一次性吐了一大段(比如网络抖动攒批),前端也能把它"抹平"成匀速的打字机效果,不依赖后端切片
  76 +的粒度是否稳定。
  77 +
  78 +两端都做的原因:后端切片解决"数据到达的最小单位太粗",前端缓冲解决"到达节奏不均匀"(网络
  79 +延迟、CLI 输出突发)——只做一端在极端情况下(比如后端切了片但网络一次性把好几个 SSE 事件
  80 +一起送到)还是会看起来一卡一卡。
  81 +
  82 +---
  83 +
  84 +## 三、方案二:打断-排队状态机
  85 +
  86 +### 3.1 交互设计
  87 +
  88 +保留现有 **Stop 按钮**(`AgentChat.vue:193`,⏹)不变——纯中止,点了就是杀会话,不接后续。
  89 +
  90 +新增:**执行中解锁输入框**,允许在 `chatState.sending === true` 时继续打字。此时发送按钮
  91 +的语义从"发送"变成"**打断并发送**",两个操作在视觉上要能一眼区分开——用户分不清点了哪个,
  92 +后果是"以为消息接上了,其实这轮直接被丢掉":
  93 +
  94 +- **Stop 按钮**(`AgentChat.vue:193`,⏹):图标/文案不变,补一句 tooltip「仅停止,不发送
  95 + 新消息」。
  96 +- **发送按钮在 sending 态下**:换一个视觉上明显不同的图标/配色(暖色调,区别于平时发送用的
  97 + 主色),tooltip 改为「发送并打断当前回复」。
  98 +- **打断成功后**,在对话记录里插入一条不打断阅读的**分隔标记**(细线 + 小字,类似
  99 + "— 已打断上一条回复 —"),让用户回看聊天记录时能看懂"上一条为什么没说完"——这是
  100 + ChatGPT / Claude.ai 自己在"stop and continue"场景下的通用做法。
  101 +
  102 +点击"打断并发送"后的具体流程:
  103 +
  104 +1. 前端把发送按钮切到"打断中…"状态,锁住重复点击(防止手速快连点两次触发两轮打断);
  105 +2. 调用现有 `stop` 端点(复用 `agent-chat.js:1505`,不新增中止路由,但要改内部实现,见
  106 + 3.1.1);
  107 +3. 根据 `stop` 返回的 `cleaned` 字段决定下一步(见 3.1.1),确认可以安全发送后,把这条新
  108 + 消息按**正常发送流程**投出去,带**同一个 convId**,接续同一个对话历史,并在发送成功后
  109 + 插入上面说的分隔标记。
  110 +
  111 +这本质是"打断并立即顶替",但工程实现上做成一个**深度为 1 的排队**(先确认上一个 turn 真的
  112 +已经退出,再发新的),不是前端拿到点击事件就立刻并发发第二个请求——原因见风险①:
  113 +服务端目前对"同一 convId 并发"没有闸门,真并发会导致两个循环同时写同一个 ACP 会话。
  114 +
  115 +#### 3.1.1 `stop` 端点改造:后端确认清理完成,而不是前端猜时间
  116 +
  117 +现状 `stop` 端点(`agent-chat.js:1505-1512`)调用 `closeSession` 后立刻同步返回,不代表
  118 +原来那个 `/chat` 请求的 handler 已经真正退出(风险②)。改造成:
  119 +
  120 +- `stop` 端点内部在调用 `closeSession` 之后,轮询等待 `agentChatLocks` 里对应 `agentId`
  121 + 的锁被清掉(即原请求走到 `agent-chat.js:2880` 的 `finally`),**预算 3 秒**(`closeSession`
  122 + 杀子进程本身是毫秒级,慢的是原请求 `await` 被异常唤醒到走完 `finally` 这段,3 秒是给日志
  123 + flush 这类边缘情况留的余量,正常路径应该用不到)。
  124 +- 3 秒内确认清理完成 → 返回 `{ success: true, cleaned: true }`,前端立刻发新消息,用户基本
  125 + 感觉不到延迟。
  126 +- 3 秒仍未确认(极端情况) → 返回 `{ success: true, cleaned: false }`(尽力而为,不算失败),
  127 + 前端**不自动强发**(会撞回风险①的并发洞),而是在输入框附近给一条不遮挡输入的提示
  128 + 「上一条还在收尾…」+ 一个「强制发送」按钮,把"要不要冒这个小概率风险"的决定权交还给用户,
  129 + 而不是系统默默赌一把。
  130 +
  131 +### 3.2 需要新增/改动的点
  132 +
  133 +| 位置 | 改动 |
  134 +|------|------|
  135 +| `AgentChat.vue:192` | textarea 的 `:disabled="chatState.sending"` 改成允许 sending 态下继续输入;`autoResize`/`handleKeydown` 不受影响 |
  136 +| `AgentChat.vue:194` | 发送按钮增加"sending 态下点击 = 打断并发送"分支,UI 上给一个过渡态(如按钮文案/图标切换) |
  137 +| `AgentChat.vue:handleSend`(`:908`) | 抽出"等待上一个 turn 收尾"的等待逻辑,供打断分支复用 |
  138 +| `agent-chat.js:2552-2568`(`agentChatLocks`) | **补上同 convId 并发闸门**(风险①,见下)——无论是否做打断功能,这都是一个已经存在、只是暂时没被前端触发到的并发 bug,建议顺带修 |
  139 +| `agent-chat.js:1505`(stop 端点) | 改成内部轮询确认清理完成再返回,加 `cleaned: true/false` 字段,见 3.1.1 |
  140 +
  141 +### 3.3 风险 / 需要在实现阶段验证的点
  142 +
  143 +1. **同 convId 并发无闸门(现存 bug,非本次引入)**:`agent-chat.js:2554` 的判断是
  144 + `existingLock.convId !== convId` 才拒绝,同 convId 的第二个请求会直接覆盖锁往下跑。
  145 + 目前靠前端 `disabled` 属性挡住,从没被服务端真正堵过。一旦解锁输入框,这条路径变得
  146 + 可达,必须先补服务端闸门,否则用户手速快一点就能让两个循环同时喂同一个 ACP 会话(未定义
  147 + 行为,轻则消息乱序,重则 CLI 子进程状态损坏)。
  148 +2. **中止后原请求是否会及时退出,还未验证**:`closeSession` 只是 `protocol.disconnect()`,
  149 + 原来那个还挂着 `await` 的 `POST /chat` 请求 handler 会不会因此立刻抛错走到
  150 + `finally`(`:2880`)释放锁、结束响应,取决于 `ClaudeCliProtocol` 底层对子进程异常退出的
  151 + 处理方式。**已拍板的应对方案见 3.1.1**(`stop` 端点内部轮询 + 3 秒预算 + `cleaned` 字段
  152 + + 前端超时兜底按钮);实现阶段仍需要用一次真实 Claude CLI 会话验证「中止后原请求平均/最坏
  153 + 多久会真正退出」,如果实测远超 3 秒(比如需要等底层网络超时),要回来调整这个预算数字,
  154 + 而不是改设计本身。
  155 +
  156 +---
  157 +
  158 +## 四、方案三:串台加固(回归测试)
  159 +
  160 +`9b1c3f1` 的修复方式是在十几个 `handleEvent` 分支里散落地加 `if (isCurrent) ...`,容易在
  161 +未来新增分支时漏掉。建议补一个**前端组件级测试**(而不是走完整 E2E),思路:
  162 +
  163 +- 模拟两个 Agent 的 `chatStates`,对 Agent A 发起一次"慢"流(用假的 SSE 事件序列,中间插
  164 + 延时),在流还没结束时把 `agentId.value` 切到 Agent B;
  165 +- 断言 Agent A 后续到达的事件(`thinking` / `agent_output` / `tool_call` 等每一种 SSE
  166 + 事件类型都要覆盖到)只更新了 `chatStates.get('A')`,不影响当前显示(Agent B 的视图);
  167 +- 反向断言:切回 Agent A 时,之前"憋住"没刷 UI 的内容应该已经正确落在 `chatStates.get('A')`
  168 + 里,切回来就能看到完整历史,不丢消息。
  169 +
  170 +这条测试的价值不是证明现在没 bug(`9b1c3f1` 已经手工验证过),而是**卡住以后的回归**——
  171 +后续如果给 `handleEvent` 加新的 SSE 事件类型,忘了包 `isCurrent()` 判断,测试会红。
  172 +
  173 +---
  174 +
  175 +## 五、不做的事(明确排除)
  176 +
  177 +- **不引入 WebSocket**。三个参考项目和 LemonBoss 现状都证明 SSE 足够,问题不在传输层。
  178 + 真要引入 WebSocket 的场景是"没有聊天窗口打开时也要收到 Agent 的后台推送"(比如心跳异常
  179 + 主动弹通知),这是一个不同的需求,不属于本文档范围,需要的话应该另开文档讨论。
  180 +- **不做"多条消息同时排队"的完整 FIFO**。deepseek-harness 那套是为多 Agent
  181 + parent-child 场景设计的,LemonBoss 这里是单用户单会话打断,深度 1("打断当前的,接上
  182 + 新的")已经覆盖需求,做完整队列是过度设计。
  183 +
  184 +---
  185 +
  186 +## 六、实施顺序建议
  187 +
  188 +三个方案彼此独立,互不阻塞,建议顺序(从风险低/收益快到风险高):
  189 +
  190 +1. **方案三**(串台回归测试)——纯新增测试,零风险,先把安全网铺好。
  191 +2. **方案一**(打字机平滑)——只碰渲染逻辑和一个转发回调,不改并发/状态机,风险最低。
  192 +3. **方案二**(打断-排队)——涉及并发闸门(风险①必须先修)和状态机改造,且风险②需要真实
  193 + CLI 验证,建议放最后,且实现前先用一次真实 Claude CLI 会话验证"中止后原请求多久会退出"。
  194 +
  195 +## 七、已拍板的设计决策
  196 +
  197 +以下两点原本是未决问题,已确认,写在这里避免以后重新讨论一遍:
  198 +
  199 +1. **超时策略**:由后端 `stop` 端点内部确认清理完成(轮询 `agentChatLocks`),预算 **3 秒**;
  200 + 超时不代表失败,返回 `cleaned: false`,前端不自动强发,展示「上一条还在收尾…」+ 手动
  201 + 「强制发送」按钮,风险由用户显式承担,不做静默重试。详见 3.1.1。
  202 +2. **UI 区分**:Stop 按钮和"sending 态下的发送按钮"必须视觉上可区分(图标/配色 + 不同
  203 + tooltip),打断成功后在对话记录里插入一条分隔标记,便于事后回看。详见 3.1。
  204 +
  205 +本文档目前没有遗留的未决问题;进入实现阶段后如果冒出新的(比如风险②实测的具体数字要调整
  206 +预算),回来更新这一节。
... ...
server/routes/agent-chat.js
... ... @@ -1502,13 +1502,18 @@ router.delete(&#39;/:agentId/conversations/:convId&#39;, (req, res) =&gt; {
1502 1502 });
1503 1503  
1504 1504 /** 停止当前正在生成的任务 */
1505   -router.post('/:agentId/chat/:convId/stop', (req, res) => {
  1505 +// 40 号文档方案二(3.1.1):中止后不能立刻同步返回——原来那个 /chat 请求的 handler
  1506 +// 是否已经走到 finally(释放 agentChatLocks、res.end())还不确定,"打断并发送"要用这个
  1507 +// 信号判断能不能安全发第二条。这里轮询等最多 3 秒,超时不算失败,只是 cleaned:false,
  1508 +// 交给前端决定要不要"强制发送"。
  1509 +router.post('/:agentId/chat/:convId/stop', async (req, res) => {
1506 1510 const { agentId, convId } = req.params;
1507 1511 const key = `${agentId}:${convId}`;
1508   - if (closeSession(key, '用户手动中断')) {
1509   - return res.json({ success: true, message: 'Agent 已中断' });
  1512 + if (!closeSession(key, '用户手动中断')) {
  1513 + return res.json({ success: false, message: '未找到运行中的 Agent 进程' });
1510 1514 }
1511   - res.json({ success: false, message: '未找到运行中的 Agent 进程' });
  1515 + const cleaned = await waitForLockRelease(agentId);
  1516 + res.json({ success: true, message: 'Agent 已中断', cleaned });
1512 1517 });
1513 1518  
1514 1519 /** 读取 bootstrap file */
... ... @@ -2537,7 +2542,17 @@ router.get(&#39;/:agentId/uploads/:convId/:filename&#39;, (req, res) =&gt; {
2537 2542 // 同一 convId 的多轮对话不受限制(ACP 进程复用)
2538 2543 // 32 号文档方案 B:这把锁挪到了共享模块 agent-session-lock.js,company.js 的招聘装配
2539 2544 // 重试逻辑也要用同一把——两条路径各自维护一份的话,各自都以为自己没有并发。
2540   -const { agentChatLocks, AGENT_LOCK_TIMEOUT } = require('../src/core/agent-session-lock');
  2545 +const { agentChatLocks, AGENT_LOCK_TIMEOUT, waitForLockRelease } = require('../src/core/agent-session-lock');
  2546 +
  2547 +// 40 号文档方案一:Claude CLI 一次 agent_message_chunk 可能带几十到几百字符,SSE 原样
  2548 +// 转发就是"一坨一坨蹦出来",没有打字机效果。这里按固定步长切成小片再各发一条 SSE 事件——
  2549 +// 只影响推给前端的粒度,`logMessageChunk` 落盘的仍是整段,不受影响。
  2550 +const TYPEWRITER_CHUNK_SIZE = 10;
  2551 +function sliceForTypewriter(text, size = TYPEWRITER_CHUNK_SIZE) {
  2552 + const pieces = [];
  2553 + for (let i = 0; i < text.length; i += size) pieces.push(text.slice(i, i + size));
  2554 + return pieces;
  2555 +}
2541 2556  
2542 2557 router.post('/:agentId/chat', async (req, res) => {
2543 2558 const { message, conversationId: reqConvId } = req.body;
... ... @@ -2549,16 +2564,21 @@ router.post(&#39;/:agentId/chat&#39;, async (req, res) =&gt; {
2549 2564  
2550 2565 const convId = reqConvId || `conv-${Date.now().toString(36)}`;
2551 2566  
2552   - // 🔒 并发互斥检查:同一 Agent 不同 convId 的并发请求被拒绝
  2567 + // 🔒 并发互斥检查:同一 Agent 的并发请求被拒绝——无论 convId 是否相同。
  2568 + // 40 号文档风险①:这里原来只挡"不同 convId",同一 convId 的第二个请求会直接
  2569 + // 覆盖锁往下跑,等于两个循环同时喂同一个 ACP 会话(未定义行为)。此前一直靠前端
  2570 + // textarea 的 disabled 属性挡住,从没被服务端真正堵过;"打断并发送"要解锁输入框,
  2571 + // 这条路径就变得可达了,必须在这里把闸门补上。
2553 2572 const existingLock = agentChatLocks.get(agentId);
2554   - if (existingLock && existingLock.convId !== convId) {
  2573 + if (existingLock) {
2555 2574 const elapsed = Date.now() - existingLock.startedAt;
2556 2575 if (elapsed < AGENT_LOCK_TIMEOUT) {
2557 2576 console.log(`[AgentChat] 🔒 拒绝 ${agentId} 并发请求 — 已有活跃会话 ${existingLock.convId} (${(elapsed / 1000).toFixed(0)}s)`);
2558 2577 return res.status(429).json({
2559 2578 error: `Agent ${agentId} 正在执行另一个会话 (${existingLock.convId}),请等待完成`,
2560 2579 activeConvId: existingLock.convId,
2561   - elapsedMs: elapsed
  2580 + elapsedMs: elapsed,
  2581 + sameConversation: existingLock.convId === convId,
2562 2582 });
2563 2583 }
2564 2584 // 超时释放
... ... @@ -2625,7 +2645,9 @@ router.post(&#39;/:agentId/chat&#39;, async (req, res) =&gt; {
2625 2645 console.log(`[SSE-DEBUG] 📤 agent_output (${update.content.text.length} chars)`);
2626 2646 fullOutput += update.content.text;
2627 2647 logMessageChunk(agentId, convId, update.content.text);
2628   - sendSSE('agent_output', { agent: agentId, message: update.content.text });
  2648 + for (const piece of sliceForTypewriter(update.content.text)) {
  2649 + sendSSE('agent_output', { agent: agentId, message: piece });
  2650 + }
2629 2651 } else if (update?.sessionUpdate === 'agent_thought_chunk' && update.content?.text) {
2630 2652 logMessageChunk(agentId, convId, update.content.text);
2631 2653 sendSSE('thinking', { message: update.content.text });
... ...
server/src/core/agent-session-lock.js
... ... @@ -14,4 +14,21 @@
14 14 const agentChatLocks = new Map(); // agentId → { convId, startedAt }
15 15 const AGENT_LOCK_TIMEOUT = 30 * 60 * 1000; // 30 分钟安全阀,与原实现一致
16 16  
17   -module.exports = { agentChatLocks, AGENT_LOCK_TIMEOUT };
  17 +/**
  18 + * 40 号文档「打断-排队」用:中止一个 turn 之后,轮询等这把锁真正被清掉
  19 + * (即原请求的 handler 走到 finally),而不是前端凭感觉睡一个固定时长。
  20 + * 查不到锁本身也算"已清空"——用于 stop 端点在锁已经因为别的原因释放时仍能返回 cleaned:true。
  21 + */
  22 +function waitForLockRelease(agentId, budgetMs = 3000, pollMs = 100) {
  23 + return new Promise((resolve) => {
  24 + const deadline = Date.now() + budgetMs;
  25 + const check = () => {
  26 + if (!agentChatLocks.has(agentId)) return resolve(true);
  27 + if (Date.now() >= deadline) return resolve(false);
  28 + setTimeout(check, pollMs);
  29 + };
  30 + check();
  31 + });
  32 +}
  33 +
  34 +module.exports = { agentChatLocks, AGENT_LOCK_TIMEOUT, waitForLockRelease };
... ...
server/tests/unit/agent-chat-interrupt.test.js 0 → 100644
  1 +/**
  2 + * 40 号文档方案二的两条后端回归:
  3 + *
  4 + * 1. 风险①:同一 agentId + 同一 convId 的并发请求过去完全没有闸门(`agentChatLocks` 的判断
  5 + * 只挡"不同 convId"),一直靠前端 textarea 的 disabled 属性挡住。"打断并发送"要解锁输入框,
  6 + * 这条路径就变得可达——两个循环同时喂同一个 ACP 会话是未定义行为。
  7 + * 2. `stop` 端点现在要等锁真正释放(或 3 秒预算到期)才返回,`cleaned` 字段供前端判断
  8 + * 能不能安全发第二条。
  9 + */
  10 +process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
  11 +process.env.AGENT_SESSION_IDLE_MS = '60000';
  12 +
  13 +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
  14 +import fs from 'fs';
  15 +import path from 'path';
  16 +import express from 'express';
  17 +import { createRequire } from 'node:module';
  18 +
  19 +const require_ = createRequire(import.meta.url);
  20 +const ROOT = path.resolve(__dirname, '../../..');
  21 +const AGENT = 'zz-chat-interrupt';
  22 +const BODY = path.join(ROOT, 'system', 'agents', AGENT);
  23 +
  24 +function clearRuntimeState() {
  25 + for (const d of ['conversations', '_global', 'memory']) {
  26 + fs.rmSync(path.join(BODY, d), { recursive: true, force: true });
  27 + }
  28 +}
  29 +
  30 +describe('agent-chat · 打断-排队(40 号文档)', () => {
  31 + let base, srv;
  32 +
  33 + beforeAll(async () => {
  34 + clearRuntimeState();
  35 + fs.mkdirSync(BODY, { recursive: true });
  36 + fs.writeFileSync(path.join(BODY, 'config.json'), JSON.stringify({ id: AGENT, type: 'skill', name: AGENT, status: 'active' }, null, 2));
  37 + fs.writeFileSync(path.join(BODY, 'identity.md'), `# ${AGENT}\n`);
  38 + fs.writeFileSync(path.join(BODY, 'agent.md'), '# 打断-排队单测用的常驻本体\n');
  39 +
  40 + const app = express();
  41 + app.use(express.json());
  42 + app.use('/api/agent', require_('../../routes/agent-chat'));
  43 + await new Promise((r) => { srv = app.listen(0, '127.0.0.1', () => { base = `http://127.0.0.1:${srv.address().port}`; r(); }); });
  44 + });
  45 +
  46 + afterAll(async () => {
  47 + if (srv) await new Promise((r) => srv.close(r));
  48 + clearRuntimeState();
  49 + });
  50 +
  51 + afterEach(() => vi.restoreAllMocks());
  52 +
  53 + const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  54 +
  55 + it('同一 convId 的第二个请求在第一个还没跑完时会被 429 拒绝,不会两个循环同时跑', async () => {
  56 + const conv = 'race-same-conv';
  57 + const { ClaudeCliProtocol } = require_('@mas/acp-core');
  58 + const original = ClaudeCliProtocol.prototype.sendPrompt;
  59 + // 拖住第一个请求,制造一个真实的并发窗口
  60 + vi.spyOn(ClaudeCliProtocol.prototype, 'sendPrompt').mockImplementation(function (...args) {
  61 + return sleep(300).then(() => original.apply(this, args));
  62 + });
  63 +
  64 + const first = fetch(`${base}/api/agent/${AGENT}/chat`, {
  65 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  66 + body: JSON.stringify({ message: 'first', conversationId: conv }),
  67 + });
  68 + await sleep(50); // 确保 first 已经进了 handler、拿到了锁
  69 + const second = await fetch(`${base}/api/agent/${AGENT}/chat`, {
  70 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  71 + body: JSON.stringify({ message: 'second', conversationId: conv }),
  72 + });
  73 +
  74 + expect(second.status).toBe(429);
  75 + const body = await second.json();
  76 + expect(body.sameConversation).toBe(true);
  77 +
  78 + const firstRes = await first;
  79 + await firstRes.text(); // 读完流,等 finally 跑完
  80 + });
  81 +
  82 + it('stop 之后:锁在预算内释放,返回 cleaned:true', async () => {
  83 + const conv = 'stop-cleaned';
  84 + const chatDone = fetch(`${base}/api/agent/${AGENT}/chat`, {
  85 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  86 + body: JSON.stringify({ message: 'ping', conversationId: conv }),
  87 + }).then(r => r.text());
  88 +
  89 + await sleep(50); // 等它先跑起来、拿到锁
  90 + const stopRes = await fetch(`${base}/api/agent/${AGENT}/chat/${conv}/stop`, { method: 'POST' });
  91 + const stopBody = await stopRes.json();
  92 + expect(stopBody.success).toBe(true);
  93 + expect(stopBody.cleaned).toBe(true);
  94 +
  95 + await chatDone;
  96 + });
  97 +
  98 + it('stop 之后立刻发同一个 convId 的新消息不会被 429 拒绝(cleaned 确认过锁已释放)', async () => {
  99 + const conv = 'stop-then-resend';
  100 + const chatDone = fetch(`${base}/api/agent/${AGENT}/chat`, {
  101 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  102 + body: JSON.stringify({ message: 'ping', conversationId: conv }),
  103 + }).then(r => r.text());
  104 +
  105 + await sleep(50);
  106 + const stopRes = await fetch(`${base}/api/agent/${AGENT}/chat/${conv}/stop`, { method: 'POST' });
  107 + const { cleaned } = await stopRes.json();
  108 + expect(cleaned).toBe(true);
  109 +
  110 + const resendRes = await fetch(`${base}/api/agent/${AGENT}/chat`, {
  111 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  112 + body: JSON.stringify({ message: 'resend', conversationId: conv }),
  113 + });
  114 + expect(resendRes.status).toBe(200);
  115 + await resendRes.text();
  116 + await chatDone;
  117 + });
  118 +});
... ...
system/agents/zz-chat-interrupt/agent.md 0 → 100644
  1 +# 打断-排队单测用的常驻本体
... ...
system/agents/zz-chat-interrupt/config.json 0 → 100644
  1 +{
  2 + "id": "zz-chat-interrupt",
  3 + "type": "skill",
  4 + "name": "zz-chat-interrupt",
  5 + "status": "active"
  6 +}
0 7 \ No newline at end of file
... ...
system/agents/zz-chat-interrupt/identity.md 0 → 100644
  1 +# zz-chat-interrupt
... ...
vitest.config.js
... ... @@ -4,7 +4,7 @@ export default defineConfig({
4 4 test: {
5 5 globals: true,
6 6 environment: 'node',
7   - include: ['src/**/*.test.js', 'server/src/tests/**/*.test.js', 'server/tests/**/*.test.js', 'packages/**/*.test.js'],
  7 + include: ['src/**/*.test.js', 'server/src/tests/**/*.test.js', 'server/tests/**/*.test.js', 'packages/**/*.test.js', 'web/src/**/*.test.js'],
8 8 coverage: {
9 9 provider: 'v8',
10 10 reporter: ['text', 'html'],
... ...
web/src/composables/agentChatEvents.js 0 → 100644
  1 +/**
  2 + * 从 AgentChat.vue 抽出来的两块纯逻辑:
  3 + *
  4 + * 1. `createAgentChatEventHandler` —— SSE 事件分发器。只认 requestAgentId 是否等于
  5 + * 调用时刻"当前选中的 agent",据此决定要不要触碰全局态(路由/对话列表/DOM 滚动);
  6 + * 传入的 `st`(每个 agent 一份的状态)永远无条件更新,不受这个判断影响——这样切走
  7 + * 了不会打断/污染,切回来能看到完整的后台进度(9b1c3f1 修的就是这条不变式)。
  8 + * 抽成不依赖组件实例的纯函数,是为了能在不挂载整个 Vue 组件的前提下对它做回归测试
  9 + * (40 号文档方案三)。
  10 + *
  11 + * 2. `createTypewriterQueue` / `flushTypewriter` —— 打字机渲染缓冲(40 号文档方案一)。
  12 + * 后端已经把大段文本切成小片再发 SSE,这里再加一层匀速吐出,抹平网络到达节奏不均匀
  13 + * (突发多个 SSE 事件挤在一起)的情况。
  14 + */
  15 +
  16 +const TYPEWRITER_TICK_MS = 20;
  17 +const TYPEWRITER_CHARS_PER_TICK = 2;
  18 +
  19 +/** 把新到的文本接到 st 的打字机队列尾部,并确保有一个定时器在匀速吐出 */
  20 +export function createTypewriterQueue({ tickMs = TYPEWRITER_TICK_MS, charsPerTick = TYPEWRITER_CHARS_PER_TICK } = {}) {
  21 + return function pushChunk(st, text) {
  22 + if (!text) return;
  23 + st._typeQueue = (st._typeQueue || '') + text;
  24 + if (st._typeTimer) return; // 已经在吐了,接上队尾即可
  25 + st._typeTimer = setInterval(() => {
  26 + if (!st._typeQueue) {
  27 + clearInterval(st._typeTimer);
  28 + st._typeTimer = null;
  29 + return;
  30 + }
  31 + st.streamContent += st._typeQueue.slice(0, charsPerTick);
  32 + st._typeQueue = st._typeQueue.slice(charsPerTick);
  33 + }, tickMs);
  34 + };
  35 +}
  36 +
  37 +/** 立刻把队列里剩下的字符吐光,用在"这轮已经结束/被打断,不用再演匀速"的时刻 */
  38 +export function flushTypewriter(st) {
  39 + if (st._typeTimer) {
  40 + clearInterval(st._typeTimer);
  41 + st._typeTimer = null;
  42 + }
  43 + if (st._typeQueue) {
  44 + st.streamContent += st._typeQueue;
  45 + st._typeQueue = '';
  46 + }
  47 +}
  48 +
  49 +/**
  50 + * @param {() => string} getCurrentAgentId 读取"用户当前选中的 agent id"
  51 + * @param {(requestAgentId: string, data: object, st: object) => void} onNewConversation
  52 + * 仅在 isCurrent 时调用:处理路由跳转 + 对话列表插入这类全局副作用
  53 + * @param {() => void} scrollToBottom
  54 + * @param {() => void} scrollAcpLog
  55 + * @param {() => void} scrollSubLogs
  56 + * @param {(st: object, text: string) => void} [pushChunk] 打字机队列的入队函数,默认新建一个
  57 + */
  58 +export function createAgentChatEventHandler({
  59 + getCurrentAgentId,
  60 + onNewConversation,
  61 + scrollToBottom,
  62 + scrollAcpLog,
  63 + scrollSubLogs,
  64 + pushChunk = createTypewriterQueue(),
  65 +}) {
  66 + return function handleEvent(st, requestAgentId, type, data) {
  67 + const isCurrent = getCurrentAgentId() === requestAgentId;
  68 + switch (type) {
  69 + case 'session':
  70 + if (data.conversationId && !st.activeConvId) {
  71 + st.activeConvId = data.conversationId;
  72 + if (isCurrent) onNewConversation(requestAgentId, data, st);
  73 + }
  74 + break;
  75 + case 'thinking':
  76 + if (data.message && !data.message.startsWith('🏗️')) {
  77 + st.thinkingContent += data.message;
  78 + if (isCurrent) scrollToBottom();
  79 + } else {
  80 + st.thinkingText = data.message || '思考中...';
  81 + }
  82 + break;
  83 + case 'agent_output':
  84 + st.streaming = true;
  85 + pushChunk(st, data.message || '');
  86 + if (isCurrent) scrollToBottom();
  87 + break;
  88 + case 'tool_call':
  89 + st.thinkingText = data.message || `🔧 ${data.tool}`;
  90 + st.acpLog.push({ type: 'tool', icon: '🔧', text: `${data.tool}`, ts: Date.now() });
  91 + if (isCurrent) scrollAcpLog();
  92 + break;
  93 + case 'tool_input':
  94 + st.acpLog.push({ type: 'input', icon: '📥', text: data.input?.slice(0, 300) || '', ts: Date.now() });
  95 + if (isCurrent) scrollAcpLog();
  96 + break;
  97 + case 'tool_result':
  98 + st.acpLog.push({ type: 'result', icon: '📤', text: data.result?.slice(0, 300) || '', ts: Date.now() });
  99 + if (isCurrent) scrollAcpLog();
  100 + break;
  101 + case 'session_cost':
  102 + st.acpLog.push({ type: 'cost', icon: '💰', text: `$${data.cost?.toFixed(4)} (${((data.duration || 0) / 1000).toFixed(1)}s)`, ts: Date.now() });
  103 + break;
  104 + case 'acp_raw':
  105 + if (data.method && !data.method.includes('content_block_delta') && !data.method.includes('message_start')) {
  106 + st.acpLog.push({ type: 'raw', icon: '⚡', text: data.method, ts: Date.now() });
  107 + if (isCurrent) scrollAcpLog();
  108 + }
  109 + break;
  110 + case 'sub_agent':
  111 + if (!st.subAgents[data.callId]) st.subAgents[data.callId] = { agent: data.agent, status: 'running', content: '', collapsed: false };
  112 + if (data.event === 'chunk' && data.chunk) {
  113 + st.subAgents[data.callId].content += data.chunk;
  114 + if (isCurrent) scrollSubLogs();
  115 + } else if (data.event === 'done') st.subAgents[data.callId].status = data.status;
  116 + else if (data.event === 'start') st.subAgents[data.callId].status = 'running';
  117 + if (isCurrent) scrollToBottom();
  118 + break;
  119 + case 'error':
  120 + st.messages.push({ role: 'assistant', content: `❌ ${data.message}`, timestamp: new Date().toISOString() });
  121 + st.acpLog.push({ type: 'error', icon: '🚨', text: data.message, ts: Date.now() });
  122 + st.thinkingText = '';
  123 + st.thinkingContent = '';
  124 + if (isCurrent) {
  125 + scrollToBottom();
  126 + scrollAcpLog();
  127 + }
  128 + break;
  129 + }
  130 + };
  131 +}
... ...
web/src/composables/agentChatEvents.test.js 0 → 100644
  1 +/**
  2 + * 回归测试:9b1c3f1 修的"多聊天串台"不变式(40 号文档方案三)。
  3 + *
  4 + * 不变式:handleEvent 收到的每一条事件都要无条件写进它自己的 st(每个 agent 一份),
  5 + * 但只有在事件所属的 requestAgentId 仍等于"用户当前选中的 agent"时,才允许触碰
  6 + * 全局副作用(路由跳转 / 对话列表 / DOM 滚动)。这条测试不需要挂载整个 Vue 组件——
  7 + * handleEvent 已经被抽成不依赖组件实例的纯函数(agentChatEvents.js),直接喂事件、
  8 + * 断言 st 内容和副作用回调的调用次数即可。
  9 + */
  10 +import { describe, it, expect, vi } from 'vitest';
  11 +import { createAgentChatEventHandler, createTypewriterQueue, flushTypewriter } from './agentChatEvents.js';
  12 +
  13 +function makeState() {
  14 + return {
  15 + messages: [], activeConvId: null,
  16 + sending: false, streaming: false, streamContent: '',
  17 + subAgents: {}, subLogList: [], acpLog: [],
  18 + thinkingText: '', thinkingContent: '', showThinking: true,
  19 + };
  20 +}
  21 +
  22 +describe('agentChatEvents · 串台加固', () => {
  23 + it('切走之后,旧 agent 的事件继续更新它自己的 st,但不再触发全局副作用', () => {
  24 + let current = 'agent-A';
  25 + const scrollToBottom = vi.fn();
  26 + const scrollAcpLog = vi.fn();
  27 + const onNewConversation = vi.fn();
  28 + const handleEvent = createAgentChatEventHandler({
  29 + getCurrentAgentId: () => current,
  30 + onNewConversation,
  31 + scrollToBottom,
  32 + scrollAcpLog,
  33 + scrollSubLogs: vi.fn(),
  34 + pushChunk: (st, text) => { st.streamContent += text; }, // 跳过打字机定时器,测同步内容
  35 + });
  36 +
  37 + const stA = makeState();
  38 + handleEvent(stA, 'agent-A', 'session', { conversationId: 'conv-A' });
  39 + handleEvent(stA, 'agent-A', 'agent_output', { message: '你好' });
  40 + expect(scrollToBottom).toHaveBeenCalledTimes(1); // 只有 agent_output 会调 scrollToBottom
  41 + expect(onNewConversation).toHaveBeenCalledTimes(1); // session 走的是 onNewConversation
  42 +
  43 + // 用户切到另一个 agent
  44 + current = 'agent-B';
  45 + scrollToBottom.mockClear();
  46 + scrollAcpLog.mockClear();
  47 +
  48 + handleEvent(stA, 'agent-A', 'agent_output', { message: ',世界' });
  49 + handleEvent(stA, 'agent-A', 'tool_call', { tool: 'bash' });
  50 +
  51 + // st 照常被写:内容没丢
  52 + expect(stA.streamContent).toBe('你好,世界');
  53 + expect(stA.acpLog).toHaveLength(1);
  54 + // 但不再是当前 agent,全局副作用一次都不该触发
  55 + expect(scrollToBottom).not.toHaveBeenCalled();
  56 + expect(scrollAcpLog).not.toHaveBeenCalled();
  57 +
  58 + // 切回来之后,新事件恢复触发副作用,且历史内容完好
  59 + current = 'agent-A';
  60 + handleEvent(stA, 'agent-A', 'tool_call', { tool: 'grep' });
  61 + expect(scrollAcpLog).toHaveBeenCalledTimes(1);
  62 + expect(stA.acpLog).toHaveLength(2);
  63 + });
  64 +
  65 + it('两个 agent 同时在跑,事件不会互相串台到对方的 st 里', () => {
  66 + let current = 'agent-A';
  67 + const handleEvent = createAgentChatEventHandler({
  68 + getCurrentAgentId: () => current,
  69 + onNewConversation: vi.fn(),
  70 + scrollToBottom: vi.fn(),
  71 + scrollAcpLog: vi.fn(),
  72 + scrollSubLogs: vi.fn(),
  73 + pushChunk: (st, text) => { st.streamContent += text; },
  74 + });
  75 +
  76 + const stA = makeState();
  77 + const stB = makeState();
  78 + // 交替喂 A、B 两条流的事件,模拟后台并发
  79 + handleEvent(stA, 'agent-A', 'agent_output', { message: 'A1' });
  80 + handleEvent(stB, 'agent-B', 'agent_output', { message: 'B1' });
  81 + current = 'agent-B'; // 用户切到 B
  82 + handleEvent(stA, 'agent-A', 'agent_output', { message: 'A2' });
  83 + handleEvent(stB, 'agent-B', 'agent_output', { message: 'B2' });
  84 +
  85 + expect(stA.streamContent).toBe('A1A2');
  86 + expect(stB.streamContent).toBe('B1B2');
  87 + });
  88 +});
  89 +
  90 +describe('agentChatEvents · 打字机渲染缓冲', () => {
  91 + it('文本按固定节奏匀速吐出,而不是一次性整段落地', () => {
  92 + vi.useFakeTimers();
  93 + try {
  94 + const st = makeState();
  95 + const pushChunk = createTypewriterQueue({ tickMs: 10, charsPerTick: 2 });
  96 + pushChunk(st, 'abcdef');
  97 +
  98 + expect(st.streamContent).toBe(''); // 入队即刻不落地,等定时器吐
  99 + vi.advanceTimersByTime(10);
  100 + expect(st.streamContent).toBe('ab');
  101 + vi.advanceTimersByTime(10);
  102 + expect(st.streamContent).toBe('abcd');
  103 + vi.advanceTimersByTime(10);
  104 + expect(st.streamContent).toBe('abcdef');
  105 + vi.advanceTimersByTime(10); // 队列空了,定时器该自己停
  106 + expect(st._typeTimer).toBeNull();
  107 + } finally {
  108 + vi.useRealTimers();
  109 + }
  110 + });
  111 +
  112 + it('flushTypewriter 立刻吐光剩余队列(打断/结束时用)', () => {
  113 + vi.useFakeTimers();
  114 + try {
  115 + const st = makeState();
  116 + const pushChunk = createTypewriterQueue({ tickMs: 10, charsPerTick: 1 });
  117 + pushChunk(st, 'hello');
  118 + vi.advanceTimersByTime(10); // 只吐了 1 个字符
  119 + expect(st.streamContent).toBe('h');
  120 +
  121 + flushTypewriter(st);
  122 + expect(st.streamContent).toBe('hello');
  123 + expect(st._typeTimer).toBeNull();
  124 + expect(st._typeQueue).toBe('');
  125 + } finally {
  126 + vi.useRealTimers();
  127 + }
  128 + });
  129 +});
... ...
web/src/views/AgentChat.vue
... ... @@ -128,24 +128,27 @@
128 128 </div>
129 129 </div>
130 130 <div v-else ref="messageListRef" class="message-list">
131   - <div v-for="(msg, idx) in chatState.messages" :key="idx" class="msg-row" :class="`msg-${msg.role}`">
132   - <div class="msg-avatar" :class="`avatar-${msg.role}`">{{ msg.role === 'user' ? '👤' : (agentInfo.emoji || '🏗️') }}</div>
133   - <div class="msg-body">
134   - <div class="msg-header">
135   - <span class="msg-role">{{ msg.role === 'user' ? '你' : agentInfo.name }}</span>
136   - <span class="msg-time">{{ formatMsgTime(msg.timestamp) }}</span>
137   - </div>
138   - <div class="msg-content" v-html="renderMarkdown(msg.content)"></div>
139   - <!-- Attachment cards -->
140   - <div v-if="msg.attachments && msg.attachments.length" class="msg-attachments">
141   - <div v-for="att in msg.attachments" :key="att.name" class="msg-attachment-card">
142   - <span class="att-icon">{{ att.type === 'image' ? '🖼️' : '📄' }}</span>
143   - <span class="att-name">{{ att.name }}</span>
144   - <span class="att-size">{{ formatFileSize(att.size) }}</span>
  131 + <template v-for="(msg, idx) in chatState.messages" :key="idx">
  132 + <div v-if="msg.role === 'divider'" class="msg-divider"><span>{{ msg.content }}</span></div>
  133 + <div v-else class="msg-row" :class="`msg-${msg.role}`">
  134 + <div class="msg-avatar" :class="`avatar-${msg.role}`">{{ msg.role === 'user' ? '👤' : (agentInfo.emoji || '🏗️') }}</div>
  135 + <div class="msg-body">
  136 + <div class="msg-header">
  137 + <span class="msg-role">{{ msg.role === 'user' ? '你' : agentInfo.name }}</span>
  138 + <span class="msg-time">{{ formatMsgTime(msg.timestamp) }}</span>
  139 + </div>
  140 + <div class="msg-content" v-html="renderMarkdown(msg.content)"></div>
  141 + <!-- Attachment cards -->
  142 + <div v-if="msg.attachments && msg.attachments.length" class="msg-attachments">
  143 + <div v-for="att in msg.attachments" :key="att.name" class="msg-attachment-card">
  144 + <span class="att-icon">{{ att.type === 'image' ? '🖼️' : '📄' }}</span>
  145 + <span class="att-name">{{ att.name }}</span>
  146 + <span class="att-size">{{ formatFileSize(att.size) }}</span>
  147 + </div>
145 148 </div>
146 149 </div>
147 150 </div>
148   - </div>
  151 + </template>
149 152 <div v-if="chatState.streaming" class="msg-row msg-assistant streaming">
150 153 <div class="msg-avatar avatar-assistant">{{ agentInfo.emoji || '🏗️' }}</div>
151 154 <div class="msg-body">
... ... @@ -186,11 +189,17 @@
186 189 <span v-for="f in uploadedFiles.slice(0, 3)" :key="f.name" class="uploaded-file-tag">{{ f.name }}</span>
187 190 <span v-if="uploadedFiles.length > 3" class="uploaded-file-tag more">+{{ uploadedFiles.length - 3 }}</span>
188 191 </div>
  192 + <!-- sending 态下 stop 迟迟没确认清理完成时的兜底提示(40 号文档方案二) -->
  193 + <div v-if="chatState.pendingForceSend" class="force-send-bar">
  194 + <span>上一条还在收尾…</span>
  195 + <button class="force-send-btn" @click="handleForceSend">强制发送</button>
  196 + </div>
189 197 <div class="input-box" :class="{ focused: inputFocused }">
190 198 <button class="attach-btn" @click="triggerFileInput" title="上传文件">📎</button>
191 199 <input type="file" ref="fileInputRef" multiple style="display:none" @change="handleFileSelect" />
192   - <textarea ref="inputRef" v-model="inputText" :disabled="chatState.sending" :placeholder="chatState.sending ? '等待回复中...' : '跟 ' + agentInfo.name + ' 对话...'" class="msg-textarea" rows="1" @keydown="handleKeydown" @focus="inputFocused = true" @blur="inputFocused = false" @input="autoResize" @paste="handlePaste"></textarea>
193   - <button v-if="chatState.sending" class="send-btn stop-btn" @click="handleStop">⏹</button>
  200 + <textarea ref="inputRef" v-model="inputText" :placeholder="chatState.sending ? '输入新消息可打断当前回复...' : '跟 ' + agentInfo.name + ' 对话...'" class="msg-textarea" rows="1" @keydown="handleKeydown" @focus="inputFocused = true" @blur="inputFocused = false" @input="autoResize" @paste="handlePaste"></textarea>
  201 + <button v-if="chatState.sending" class="send-btn stop-btn" title="仅停止,不发送新消息" @click="handleStop">⏹</button>
  202 + <button v-if="chatState.sending" class="send-btn interrupt-btn" :class="{ ready: inputText.trim() || pendingFiles.length }" :disabled="(!inputText.trim() && !pendingFiles.length) || chatState.interrupting" title="发送并打断当前回复" @click="handleInterruptAndSend">⤴</button>
194 203 <button v-else class="send-btn" :class="{ ready: (inputText.trim() || pendingFiles.length) && !chatState.sending }" :disabled="!inputText.trim() && !pendingFiles.length" @click="handleSend">➤</button>
195 204 </div>
196 205 <div class="input-hint">Shift+Enter 换行 · {{ chatState.messages.length }} 条消息<template v-if="pendingFiles.length"> · {{ pendingFiles.length }} 个待上传文件</template><template v-if="uploadedFiles.length"> · 📎 {{ uploadedFiles.length }} 个附件</template></div>
... ... @@ -360,6 +369,7 @@ import { useRoute, useRouter } from &#39;vue-router&#39;
360 369 import AppNav from '@/components/AppNav.vue'
361 370 import FileNode from '@/components/FileNode.vue'
362 371 import { marked } from 'marked'
  372 +import { createAgentChatEventHandler, flushTypewriter } from '@/composables/agentChatEvents.js'
363 373  
364 374 const route = useRoute()
365 375 const router = useRouter()
... ... @@ -463,6 +473,7 @@ function ensureChatState(id) {
463 473 sending: false, streaming: false, streamContent: '',
464 474 subAgents: {}, subLogList: [], acpLog: [],
465 475 thinkingText: '🏗️ 启动中...', thinkingContent: '', showThinking: true,
  476 + interrupting: false, pendingForceSend: false,
466 477 })
467 478 }
468 479 return chatStates.get(id)
... ... @@ -958,6 +969,7 @@ async function handleSend() {
958 969 }
959 970 }
960 971 }
  972 + flushTypewriter(st) // 流已经读完,剩下排队的字符没必要再演匀速,直接吐光再拼进消息
961 973 if (st.streamContent) {
962 974 st.messages.push({ role: 'assistant', content: st.streamContent, timestamp: new Date().toISOString() })
963 975 }
... ... @@ -981,70 +993,86 @@ async function handleStop() {
981 993 const st = chatState.value
982 994 if (!st.activeConvId) return
983 995 try { await fetch(`/api/agent/${agentId.value}/chat/${st.activeConvId}/stop`, { method: 'POST' }) } catch(e) { console.error('Stop error:', e) }
  996 + flushTypewriter(st)
984 997 st.sending = false; st.streaming = false
985 998 st.messages.push({ role: 'assistant', content: `*[已终止]*`, timestamp: new Date().toISOString() })
986 999 scrollToBottom()
987 1000 }
988 1001  
  1002 +// 40 号文档方案二:执行中把已生成的部分内容 + 一条分隔标记落进历史,再当作一轮全新对话
  1003 +// 重置 sending 相关状态——不依赖"旧的 fetch 循环自己发现流已结束"这件事的时序,
  1004 +// 保证接下来立刻调用 handleSend() 是安全的(旧循环之后自己走到 finally 时,
  1005 +// st.streamContent 已经是空的,不会重复 push)。
  1006 +function finalizeInterruptedTurn(st) {
  1007 + flushTypewriter(st)
  1008 + if (st.streamContent) {
  1009 + st.messages.push({ role: 'assistant', content: st.streamContent, timestamp: new Date().toISOString() })
  1010 + }
  1011 + st.messages.push({ role: 'divider', content: '已打断上一条回复', timestamp: new Date().toISOString() })
  1012 + st.sending = false; st.streaming = false; st.streamContent = ''; st.thinkingContent = ''
  1013 +}
  1014 +
  1015 +// sending 态下点发送 = "打断并发送":先中止当前 turn,服务端确认清理完成(cleaned)后
  1016 +// 再把这条新消息接上;服务端 3 秒还没确认的话不自动强发(避免撞上同 convId 并发的坑),
  1017 +// 交给用户自己点"强制发送"。
  1018 +async function handleInterruptAndSend() {
  1019 + const st = chatState.value
  1020 + if (!inputText.value.trim() && pendingFiles.value.length === 0) return
  1021 + if (!st.activeConvId) return handleSend()
  1022 +
  1023 + st.interrupting = true
  1024 + let cleaned = false
  1025 + try {
  1026 + const r = await fetch(`/api/agent/${agentId.value}/chat/${st.activeConvId}/stop`, { method: 'POST' })
  1027 + const data = await r.json().catch(() => ({}))
  1028 + cleaned = !!data.cleaned
  1029 + } catch (e) {
  1030 + console.error('Interrupt error:', e)
  1031 + }
  1032 + st.interrupting = false
  1033 +
  1034 + if (!cleaned) { st.pendingForceSend = true; return }
  1035 + finalizeInterruptedTurn(st)
  1036 + await handleSend()
  1037 +}
  1038 +
  1039 +function handleForceSend() {
  1040 + const st = chatState.value
  1041 + st.pendingForceSend = false
  1042 + finalizeInterruptedTurn(st)
  1043 + handleSend()
  1044 +}
  1045 +
989 1046 // st/requestAgentId 是 handleSend 传下来的快照;只有 requestAgentId 仍是当前选中 agent 时
990 1047 // 才动 DOM(滚动)/URL/全局对话列表——避免把某个后台 agent 的进度"带"到当前正看的页面上。
991   -function handleEvent(st, requestAgentId, type, data) {
992   - const isCurrent = agentId.value === requestAgentId
993   - switch (type) {
994   - case 'session':
995   - if (data.conversationId && !st.activeConvId) {
996   - st.activeConvId = data.conversationId
997   - if (isCurrent) {
998   - if (route.params.id) {
999   - router.replace({ params: { id: route.params.id, agentId: requestAgentId, convId: data.conversationId } })
1000   - } else {
1001   - router.replace({ params: { agentId: requestAgentId, convId: data.conversationId } })
1002   - }
1003   - const existsInList = conversations.value.some(c => (c.conversationId || c.id) === data.conversationId)
1004   - if (!existsInList) {
1005   - conversations.value.unshift({ conversationId: data.conversationId, title: st.messages.find(m => m.role === 'user')?.content?.slice(0, 50) || '新对话', updatedAt: new Date().toISOString(), messageCount: 1, status: 'running' })
1006   - }
1007   - }
1008   - }
1009   - break
1010   - case 'thinking':
1011   - if (data.message && !data.message.startsWith('🏗️')) { st.thinkingContent += data.message; if (isCurrent) scrollToBottom() }
1012   - else { st.thinkingText = data.message || '思考中...' }
1013   - break
1014   - case 'agent_output':
1015   - st.streaming = true; st.streamContent += (data.message || ''); if (isCurrent) scrollToBottom(); break
1016   - case 'tool_call':
1017   - st.thinkingText = data.message || `🔧 ${data.tool}`
1018   - st.acpLog.push({ type: 'tool', icon: '🔧', text: `${data.tool}`, ts: Date.now() }); if (isCurrent) scrollAcpLog(); break
1019   - case 'tool_input':
1020   - st.acpLog.push({ type: 'input', icon: '📥', text: data.input?.slice(0, 300) || '', ts: Date.now() }); if (isCurrent) scrollAcpLog(); break
1021   - case 'tool_result':
1022   - st.acpLog.push({ type: 'result', icon: '📤', text: data.result?.slice(0, 300) || '', ts: Date.now() }); if (isCurrent) scrollAcpLog(); break
1023   - case 'session_cost':
1024   - st.acpLog.push({ type: 'cost', icon: '💰', text: `$${data.cost?.toFixed(4)} (${((data.duration||0)/1000).toFixed(1)}s)`, ts: Date.now() }); break
1025   - case 'acp_raw':
1026   - if (data.method && !data.method.includes('content_block_delta') && !data.method.includes('message_start')) {
1027   - st.acpLog.push({ type: 'raw', icon: '⚡', text: data.method, ts: Date.now() }); if (isCurrent) scrollAcpLog()
1028   - }
1029   - break
1030   - case 'sub_agent':
1031   - if (!st.subAgents[data.callId]) st.subAgents[data.callId] = { agent: data.agent, status: 'running', content: '', collapsed: false }
1032   - if (data.event === 'chunk' && data.chunk) {
1033   - st.subAgents[data.callId].content += data.chunk
1034   - if (isCurrent) setTimeout(() => { document.querySelectorAll('.sub-log-content').forEach(el => el.scrollTop = el.scrollHeight) }, 50)
1035   - } else if (data.event === 'done') st.subAgents[data.callId].status = data.status
1036   - else if (data.event === 'start') st.subAgents[data.callId].status = 'running'
1037   - if (isCurrent) scrollToBottom(); break
1038   - case 'error':
1039   - st.messages.push({ role: 'assistant', content: `❌ ${data.message}`, timestamp: new Date().toISOString() })
1040   - st.acpLog.push({ type: 'error', icon: '🚨', text: data.message, ts: Date.now() })
1041   - st.thinkingText = ''; st.thinkingContent = ''
1042   - if (isCurrent) { scrollToBottom(); scrollAcpLog() }
1043   - break
  1048 +// 分发逻辑本体已抽到 composables/agentChatEvents.js(40 号文档方案三,便于单独做回归测试)。
  1049 +function scrollSubLogs() {
  1050 + setTimeout(() => { document.querySelectorAll('.sub-log-content').forEach(el => el.scrollTop = el.scrollHeight) }, 50)
  1051 +}
  1052 +const handleEvent = createAgentChatEventHandler({
  1053 + getCurrentAgentId: () => agentId.value,
  1054 + scrollToBottom,
  1055 + scrollAcpLog,
  1056 + scrollSubLogs,
  1057 + onNewConversation(requestAgentId, data, st) {
  1058 + if (route.params.id) {
  1059 + router.replace({ params: { id: route.params.id, agentId: requestAgentId, convId: data.conversationId } })
  1060 + } else {
  1061 + router.replace({ params: { agentId: requestAgentId, convId: data.conversationId } })
  1062 + }
  1063 + const existsInList = conversations.value.some(c => (c.conversationId || c.id) === data.conversationId)
  1064 + if (!existsInList) {
  1065 + conversations.value.unshift({ conversationId: data.conversationId, title: st.messages.find(m => m.role === 'user')?.content?.slice(0, 50) || '新对话', updatedAt: new Date().toISOString(), messageCount: 1, status: 'running' })
  1066 + }
  1067 + },
  1068 +})
  1069 +
  1070 +function handleKeydown(e) {
  1071 + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
  1072 + e.preventDefault()
  1073 + chatState.value.sending ? handleInterruptAndSend() : handleSend()
1044 1074 }
1045 1075 }
1046   -
1047   -function handleKeydown(e) { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); handleSend() } }
1048 1076 function scrollAcpLog() { nextTick(() => { const el = document.querySelector('.acp-log-stream'); if (el) el.scrollTop = el.scrollHeight }) }
1049 1077 function autoResize() { const el = inputRef.value; if (!el) return; el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 160) + 'px' }
1050 1078 function scrollToBottom() { nextTick(() => { if (messageListRef.value) messageListRef.value.scrollTop = messageListRef.value.scrollHeight }) }
... ... @@ -1305,6 +1333,14 @@ onMounted(async () =&gt; {
1305 1333 .send-btn.ready { background: linear-gradient(135deg,var(--accent),var(--accent-light)); color: #fff; box-shadow: 0 3px 10px rgba(16,185,129,0.3); }
1306 1334 .send-btn.ready:hover { transform: scale(1.05); }
1307 1335 .input-hint { margin-top: 2px; font-size: 9px; color: var(--text-faint); text-align: center; }
  1336 +/* sending 态下:Stop(纯中止)与 interrupt-btn(打断并发送)视觉上要能一眼区分开 */
  1337 +.interrupt-btn.ready { background: linear-gradient(135deg,#f59e0b,#fbbf24); color: #fff; box-shadow: 0 3px 10px rgba(245,158,11,0.35); }
  1338 +.interrupt-btn.ready:hover { transform: scale(1.05); }
  1339 +.force-send-bar { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; padding: 6px 10px; font-size: 11px; color: var(--text-muted); background: var(--accent-bg); border: 1px solid var(--border-active); border-radius: 7px; }
  1340 +.force-send-btn { border: none; border-radius: 6px; padding: 4px 10px; font-size: 11px; cursor: pointer; background: #f59e0b; color: #fff; }
  1341 +.force-send-btn:hover { opacity: 0.9; }
  1342 +.msg-divider { display: flex; align-items: center; gap: 8px; margin: 10px 0; font-size: 10px; color: var(--text-faint); }
  1343 +.msg-divider::before, .msg-divider::after { content: ''; flex: 1; height: 1px; background: var(--border-primary); }
1308 1344  
1309 1345 /* ═══ 第4列: ACP / 预览 ═══ */
1310 1346 .acp-panel {
... ...