Commit 4d23f5068d5e29461c135f670a03f97d92564d26
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
Showing
11 changed files
with
750 additions
and
83 deletions
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('/:agentId/conversations/:convId', (req, res) => { |
| 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('/:agentId/uploads/:convId/:filename', (req, res) => { |
| 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('/:agentId/chat', async (req, res) => { |
| 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('/:agentId/chat', async (req, res) => { |
| 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
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 'vue-router' |
| 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 () => { |
| 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 { | ... | ... |