Merge PR #13: feat: UI/UE enhancement — streaming, sticky header, hover actions, calendar tokens
Deploy to Production / deploy (push) Waiting to run Details
Test / backend-test (push) Waiting to run Details
Test / frontend-unit (push) Waiting to run Details
Test / api-e2e (push) Waiting to run Details
Test / frontend-e2e (push) Waiting to run Details

Implements U1-U7 UI/UE enhancement with streaming, sticky header, hover actions, calendar tokens.

Review fixes applied (P0 whitelist + P0 double accumulation + P2 exception handling).
See PR #13 description for details.
This commit is contained in:
Fischer 2026-07-01 13:15:33 +08:00
commit 8066e0bf8b
36 changed files with 4428 additions and 92 deletions

View File

@ -39,6 +39,9 @@ The feedback loop triggered when a verification check fails after a final answer
### Three-tier Degradation Chain ### Three-tier Degradation Chain
The agent-level fallback sequence when the primary agent fails: main agent → Recovery tier (reuses `ReflexionEngine` for Evaluate→Reflect→Retry) → Emergency tier (rule-based fallback returning a structured error with suggestions). Each tier is independently configurable; the Recovery tier avoids new infrastructure by reusing the existing reflection engine, and the Emergency tier replaces the previous static-text fallback with actionable error structure. The agent-level fallback sequence when the primary agent fails: main agent → Recovery tier (reuses `ReflexionEngine` for Evaluate→Reflect→Retry) → Emergency tier (rule-based fallback returning a structured error with suggestions). Each tier is independently configurable; the Recovery tier avoids new infrastructure by reusing the existing reflection engine, and the Emergency tier replaces the previous static-text fallback with actionable error structure.
### ReAct Streaming Contract
The protocol `ReActEngine.execute_stream()` yields to consumers: first zero or more `token` events whose `data.content` are incremental content fragments, then exactly one `final_answer` event whose `data.output` is the concatenation of all token fragments (the complete text). The two events carry the same content — token is the增量 view, final_answer is the聚合 view. Consumers must pick one accumulation strategy (append tokens, or wait for final_answer) and cannot mix both without producing doubled output. When `execute_stream()` is wrapped from a sync `execute()` via `_wrap_sync_as_stream`, no token events are emitted and final_answer's output is the sole content carrier.
## Channels & Caching ## Channels & Caching
### Per-User Cache Namespace ### Per-User Cache Namespace

View File

@ -0,0 +1,124 @@
---
date: 2026-07-01
topic: ui-ue-enhancement
---
## Summary
强化 AgentKit 聊天与日历模块的 UI/UE让 agent 思考过程与团队/私董会结果真正流式可见,让 @team/@board 模式在对话顶部持久显式,去除常驻噪声,给用户消息补齐复制/删除/回填操作,并让日历回到 Notion 风格的统一设计语言。
---
## Problem Frame
当前聊天主界面 `src/agentkit/server/frontend/src/views/ChatView.vue` 在顶部堆叠了三个 banner`ExpertTeamView`、`BoardStatusView`、`PhaseIndicator`),但前两者为静态文案且不可点击,无法回答"这次 @team 要解决什么任务、由哪些专家参与"这类基本问题。助手消息区域有两个层面的可读性问题:`ThinkingBlock.vue` 默认折叠且渲染为纯文本,看不到 agent 的推理过程;`AssistantText.vue:32-42` 的路由元信息 tagmatched_skill / confidence / routing_method常驻显示对终端用户是无意义噪声。用户消息气泡 `UserBubble.vue` 是一个纯 `<div>`,无任何悬停操作,复制、删除、回填输入框重发都做不到。私董会与专家团的最终结果在 `chatStream.ts:677-696``expert_result`)和 `chatStream.ts:771-787``team_synthesis`)中以完整事件块到达前端,无法流式呈现,与 `final_answer``chatStream.ts:526-532`token 累加流式)体验割裂。日历模块 `CalendarGrid.vue` 直接使用 FullCalendar 默认样式,硬编码 `#1677ff``CalendarGrid.vue:38`)与 `styles/tokens.css` 的 Notion 调色板(`--color-primary: #1a1a1a`、`--accent-team: #3b82f6`、`--accent-board: #a855f7`)完全脱节。
---
## Key Decisions
- **思考展示采用"展开流式 + 完成后收起为摘要条"的混合方案。** 思考进行中时始终展开并显示流式光标,思考结束后自动收起为一行摘要条(点击可再展开查看完整内容),既满足"看到 agent 在想什么"的诉求,又避免长篇思考内容长期挤占对话视野。
- **@team/@board 头部采用 sticky 持久条,替换 `ExpertTeamView``BoardStatusView`。** 新头部由"模式 badge + 任务目标 / 私董会主题 + 专家头像组"组成,专家头像点击后弹出详情面板查看具体专家清单与角色。`PhaseIndicator`PLAN_EXEC 模式)独立保留在 sticky 头部下方,不并入头部条。
- **日历采用"FullCalendar 核心 + 自定义外壳"方案。** 保留 FullCalendar 的视图切换、日期网格、事件拖拽能力,但侧栏、事件卡片、头部工具栏使用 token 重绘FC 内部 `.fc-*` 元素通过覆盖式 CSS 对齐 Notion 风格。
- **team/board 结果流式在范围内,需后端改动。** 当前 `expert_result``team_synthesis` 作为完整事件块发送,需引入事件变体(如 `expert_result_chunk` / `team_synthesis_chunk`)或 token 分块推送,前端再做流式渲染,与 `final_answer` 的流式体验对齐。
---
## Requirements
### 思考展示Thinking Display
- R1. 思考进行中时,`ThinkingBlock` 默认展开并显示流式光标token 实时累加,不折叠。
- R2. 思考完成后自动收起为一行摘要条,摘要条显示思考开始时间与 token 数(或等价摘要信息),点击可重新展开查看完整内容。
- R3. 摘要条与展开态之间切换不丢失已流式接收的内容,再次展开时定位到上一次滚动位置。
### @team/@board 头部Sticky Mode Header
- R4. `@team` / `@board` 模式进入对话后,在消息列表上方渲染一条 sticky 持久头部条,替换现有 `ExpertTeamView``BoardStatusView` 两个组件。
- R5. 头部条左侧显示模式 badge"专家团" / "私董会"),中间显示任务目标(@team或主题@board右侧显示专家头像组。
- R6. 专家头像组中的每个头像可点击,点击后弹出详情面板显示该专家的现有元数据(与 `configs/experts/*.yaml` 中的 `description` / `persona` / `avatar` / `color` 字段对齐;不假设存在 `role` / `bio` 等未定义字段)。
- R7. `PhaseIndicator`PLAN_EXEC 模式)独立显示在 sticky 头部条下方,不并入头部条,保持其现有阶段进度展示语义。
### 团队/私董会流式输出Team/Board Streaming
- R8. `expert_result` 事件改为流式输出体验:专家生成结果时前端流式累加渲染,与 `final_answer` 的流式体验对齐。后端推送机制token 分块、文本块、或事件变体)属跨切面依赖,见"Scope Boundaries"。
- R9. `team_synthesis` 事件改为流式输出体验Lead 综合阶段的结果前端流式累加渲染。后端推送机制同 R8。
- R10. 流式过程中显示专家/团队身份标识(如"专家 A 正在输出…"),流式结束后标识保留在最终消息上。此"身份标识"属用户可见的专家 badge区别于 R12 的内部路由元信息 tag。
- R11. 流式推送方案待定(事件变体或 token 分块二者择一),由后端协议设计阶段决定;前端订阅流式输出即可,不预设具体实现方案。
### 路由元信息标签Routing Tags
- R12. `AssistantText.vue:32-42` 的路由元信息 tag 区matched_skill / confidence / routing_method默认隐藏仅当用户悬停助手消息时显示。
- R13. 悬停显示时使用淡入过渡,避免突兀闪烁;移开悬停后淡出。
### 用户消息悬停操作User Message Hover Actions
- R14. 用户消息气泡(`UserBubble.vue`)支持悬停时显示操作工具条,包含三个操作:复制、删除、回填输入框重发。
- R15. 复制:将消息文本复制到剪贴板,复制成功后给一个轻量反馈(如工具条图标短暂变色)。
- R16. 删除:从当前视图隐藏该消息(从 `chatStore.currentMessages` 中移除),需二次确认以防止误删。此为前端隐藏,不删除服务端副本;服务端删除语义留待后续迭代。
- R17. 回填输入框重发:将消息文本回填到 `ChatInput` 的输入框,不自动发送,由用户决定是否修改后重发;回填后该消息不从列表中删除。
### 日历重设计Calendar Redesign
- R18. 提取主界面 Notion 设计语言为可复用 token 集,日历模块统一消费 `styles/tokens.css` 中的颜色、间距、圆角、阴影变量,禁止硬编码颜色值(如 `CalendarGrid.vue:38``#1677ff`)。
- R19. 日历侧栏(事件列表/筛选)使用 token 重绘,与主界面的卡片、列表视觉语言一致。
- R20. 日历事件卡片使用 token 调色板,事件类型颜色从配置映射到 token`--accent-team` 用于团队事件、`--accent-board` 用于私董会事件)。
- R21. 日历头部工具栏(视图切换、今天按钮、导航箭头)使用 token 重绘,按钮风格与主界面按钮一致。
- R22. FullCalendar 内部 `.fc-*` 元素通过覆盖式 CSS 对齐 Notion 风格(字体、边框、表头背景、今日高亮),覆盖范围最小化以保持 FC 升级兼容性。
---
## Scope Boundaries
### Deferred for later
- 思考内容的 markdown 渲染:本期 `ThinkingBlock` 仍以纯文本或等宽文本呈现markdown 解析渲染留待后续迭代。
- 日历事件的拖拽创建、resize 行为变更:本期仅重绘视觉外壳,不改变 FC 既有交互行为。
- 专家头像组中专家在线状态指示:本期仅展示静态头像与详情,不接入实时在线状态。
- 路由元信息 tag 的可配置显示规则:本期固定为"默认隐藏 + 悬停显示",不做用户偏好配置。
### Outside this product's identity
- 不引入新的 UI 组件库或设计系统(继续基于 Ant Design Vue + token 覆盖)。
- 不替换 FullCalendar 为自建日历组件(保留 FC 核心,仅覆盖外壳与 `.fc-*` 样式)。
- 不改变 `PhaseIndicator` 的现有阶段进度语义与展示形式(仅独立保留,不重设计)。
### Cross-cutting dependencies非 UI/UE scope但需协同
- 后端流式推送协议R8/R9 的流式体验依赖后端推送机制token 分块 / 文本块 / 事件变体),具体方案由后端协议设计阶段决定,不属本文档 scope。
---
## Open Questions
需用户判断或设计决策的事项,来自 ce-doc-review 审查。
### 交互状态与无障碍
- OQ1. 交互状态覆盖(错误态 / 空态 / 加载态的范围与规范——流式中断、专家失败、无结果等场景如何呈现design-lens, P1, conf 100
- OQ2. 响应式与无障碍策略——sticky 头部、头像组、悬停操作、日历重设计的断点、键盘可达、ARIA 规范design-lens, P1, conf 100
### 流式输出设计决策
- OQ3. R8/R9 流式结构——事件变体(如 `expert_result_chunk`vs token 分块推送?结构化事件元数据(专家身份、阶段标识)如何与流式 token 边界协调adversarial, P1, conf 75
### 删除语义
- OQ4. R16 删除作用域——前端隐藏(当前 R16 已改述是否满足用户意图是否需要服务端删除以避免刷新后消息复活adversarial + scope-guardian, P1/P2, conf 75
### 用户流与交互细节
- OQ5. @team/@board 模式切换与多专家并发流式输出的用户流——并发到达时如何呈现身份与顺序design-lens, P2, conf 75
- OQ6. 关键交互细节——删除确认 UI 形态Modal/Popconfirm、头像溢出规则、详情面板组件类型Drawer/Modal/Popoverdesign-lens, P2, conf 75
### FullCalendar 兼容性
- OQ7. R22 `.fc-*` 类名覆盖的升级风险容忍度——`.fc-*` 非公开 APIFC 升级时可能破坏是否接受定期维护成本或约束覆盖范围至更稳定的选择器adversarial, P2, conf 75
### Sticky 头部替代方案
- OQ8. Sticky 头部决策是否考虑过更简替代——使现有 `ExpertTeamView` / `BoardStatusView` banner 可点击展开详情,而非整体替换为 sticky 条adversarial, P2, conf 75
### 模式可发现性
- OQ9. @team/@board 模式可发现性目标——用户如何得知这两个模式存在是否需要在输入提示或空态中引导product-lens, FYI, conf 50

View File

@ -0,0 +1,462 @@
---
date: 2026-07-01
type: feat
origin: docs/brainstorms/2026-07-01-ui-ue-enhancement-requirements.md
---
# feat: AgentKit UI/UE 增强 — 聊天流式 + Sticky 头部 + 日历重设计
## Summary
强化 AgentKit 聊天与日历模块的 UI/UE让 agent 思考过程与团队/私董会结果真正流式可见,让 @team/@board 模式在对话顶部持久显式,去除常驻路由噪声,给用户消息补齐复制/删除/回填操作,并让日历回到 Notion 风格的统一设计语言。覆盖 22 个需求R1-R22含后端 `_phase_executor.py` 流式切换以打通 expert_result/team_synthesis 的流式链路。
---
## Problem Frame
当前聊天主界面的思考展示默认折叠且为纯文本,看不到 agent 推理过程;@team/@board 模式的顶部 banner 为静态文案且不可点击,无法回答"这次任务目标是什么、由哪些专家参与";专家团与私董会的最终结果以完整事件块一次性到达前端,与 `final_answer` 的流式体验割裂;路由元信息 tag 常驻显示对终端用户是无意义噪声;用户消息气泡无任何悬停操作;日历模块硬编码颜色与 `tokens.css` 的 Notion 调色板完全脱节。
来源:`docs/brainstorms/2026-07-01-ui-ue-enhancement-requirements.md`(经 ce-doc-review 审查强化,含 9 个 Open Questions
---
## Requirements
### 需求追溯
| 需求组 | R-IDs | 实施单元 |
|--------|-------|----------|
| 思考展示 | R1-R3 | U1 |
| @team/@board 头部 | R4-R7 | U2 |
| 团队/私董会流式 | R8-R11 | U3, U4 |
| 路由标签 | R12-R13 | U5 |
| 用户消息悬停 | R14-R17 | U6 |
| 日历重设计 | R18-R22 | U7 |
完整需求清单见 origin 文档。关键决策思考混合方案、sticky 持久条、FC+自定义外壳、流式方案待定)已由 brainstorm 确认。
### 成功标准
- 思考进行中时 `ThinkingBlock` 展开并显示流式光标,完成后收起为摘要条
- @team/@board 模式进入对话后顶部渲染 sticky 持久条,专家头像可点击查看详情
- `expert_result``team_synthesis` 事件按 token/文本块流式累加渲染,与 `final_answer` 体验对齐
- 路由元信息 tag 默认隐藏,悬停时淡入显示
- 用户消息气泡悬停时显示复制/删除/回填操作工具条
- 日历模块统一消费 `tokens.css`,无硬编码颜色值
---
## Key Technical Decisions
1. **ThinkingBlock 流式光标采用 CSS 伪元素 + watch content.length 截断展示**。后端已将 thinking chunks 累积到 `message.thinking``chatStream.ts:512-521`),前端无需改事件流,只需在 `ThinkingBlock.vue` 内 watch `content.length` 做截断展示 + 闪烁光标。完成后收起为摘要条显示 token 数。
2. **StickyModeHeader 为新组件,替换 `ExpertTeamView` + `BoardStatusView`**。从 `useTeamStore()``useChatStore().boardState` 读取状态,合并为单一 sticky 条(`position: sticky; top: 0; z-index: var(--z-sticky)`)。`PhaseIndicator` 独立保留在 sticky 头部下方,不合并。
3. **expert_result/team_synthesis 流式采用 `execute_stream()` + token 转发 + 前端累积式 updateMessage**。后端 `_phase_executor.py``agent.execute()` 切换为 `agent.execute_stream()`,在 `async for event in` 循环中转发 `token`/`final_answer` 事件到 WS。前端 `chatStream.ts``expert_result`/`team_synthesis` 分支从一次性 `appendMessage` 改为累积式 `updateMessage`(复用 `final_answer` 的 526-532 模式)。
4. **路由 tag 隐藏采用 `v-show` + opacity transition**(与 U5 Approach 对齐)。`AssistantText.vue` 的 `showRouting` computed 增加 hover 状态依赖CSS `opacity: 0``1` + `transition: opacity 0.2s ease` 淡入淡出。`v-show` 保留 DOM 避免首次悬停的重挂载闪烁。
5. **UserBubble 回填采用 chatStore 共享状态**。新增 `chatStore.refillText: string` ref`UserBubble` 的回填操作设置该值,`ChatInput` watch 该值回填到 `inputText`。删除操作新增 `chatStore.deleteMessage(convId, msgId)``currentMessages` 移除(仅前端隐藏,不删服务端副本)。
6. **日历 .fc-* 覆盖采用最小范围 CSS 覆盖**。仅覆盖字体、边框、表头背景、今日高亮四类样式,选择器限定为 `.fc-toolbar` / `.fc-col-header` / `.fc-day-today` 等稳定类名,接受 FC 升级时可能需要维护的成本。
---
## High-Level Technical Design
### 流式事件链路R8-R11
```
Backend: _phase_executor.py
agent.execute_stream() ← async generator yields ReActEvent
├─ event_type="token" → broadcast "expert_result_chunk" { expert_id, content }
├─ event_type="thinking" → broadcast "expert_step" { expert_id, thinking }
├─ event_type="final_answer" → broadcast "expert_result_chunk" { expert_id, content }
└─ 循环结束 → broadcast "expert_result" { expert_id, content: full, status: "completed" }
Frontend: chatStream.ts
case "expert_result_chunk":
→ updateMessage(convId, lastExpertMsg.id, { content: accumulated + chunk })
case "expert_result":
→ updateMessage(convId, lastExpertMsg.id, { status: "completed" }) ← 仅标记完成,不再 append
```
### 组件依赖关系
```
U1 ThinkingBlock (独立)
U2 StickyModeHeader (独立,替换两个旧组件)
U3 后端流式切换 (独立,无前端依赖)
U4 前端流式消费 (依赖 U3 联调,可 mock 开发)
U5 路由 tag 隐藏 (独立)
U6 UserBubble hover (独立,需 chatStore 新方法)
U7 CalendarGrid 重设计 (独立)
```
---
## Implementation Units
### U1. ThinkingBlock 流式展示重设计
**Goal:** 让 agent 思考过程在执行时展开流式显示,完成后收起为摘要条,切换不丢失内容。
**Requirements:** R1, R2, R3
**Dependencies:** 无
**Files:**
- `src/agentkit/server/frontend/src/components/chat/ThinkingBlock.vue`(修改)
- `src/agentkit/server/frontend/tests/unit/components/ThinkingBlock.test.ts`(新建)
**Approach:**
- 默认展开(`expanded = ref(true)``isStreaming` 时显示流式光标CSS `::after` 伪元素闪烁动画)
- watch `content.length` 截断展示:流式中仅显示已到达的字符,模拟 token-by-token 效果
- 完成后(`isStreaming` 由 true→false自动收起为摘要条显示思考开始时间与字符数
- 摘要条点击切换展开/收起,展开时定位到上一次滚动位置(记录 `scrollTop` 到 ref
- 状态保持:收起时内容不丢失,`content` prop 始终持有完整文本
**Patterns to follow:**
- `ChatView.vue:47-76` 的 streamingSteps 渲染模式(字符数 counter
- `final_answer``chatStream.ts:526-532` 的累积模式
**Test scenarios:**
- 流式中展开态显示光标 + 已到达字符,完成后自动收起为摘要条
- 摘要条点击展开,显示完整思考内容
- 展开→收起→再展开,滚动位置恢复到上次位置
- 空思考内容(`content=""`)时不显示 ThinkingBlock
- `isStreaming=true``content=""` 时显示加载占位
**Verification:** 思考流式中可见光标闪烁与字符累加,完成后收起为一行摘要,点击可重新展开查看完整内容。
---
### U2. StickyModeHeader + PhaseIndicator token 化
**Goal:** 替换静态 banner 为 sticky 持久头部条,显示模式 badge + 任务目标 + 专家头像组PhaseIndicator 独立保留并 token 化。
**Requirements:** R4, R5, R6, R7
**Dependencies:** 无
**Files:**
- `src/agentkit/server/frontend/src/components/chat/StickyModeHeader.vue`(新建)
- `src/agentkit/server/frontend/src/components/chat/ExpertTeamView.vue`(废弃/删除引用)
- `src/agentkit/server/frontend/src/components/chat/BoardStatusView.vue`(废弃/删除引用)
- `src/agentkit/server/frontend/src/components/chat/PhaseIndicator.vue`(修改 — token 化)
- `src/agentkit/server/frontend/src/views/ChatView.vue`(修改 — 替换 banner 引用)
- `src/agentkit/server/frontend/tests/unit/components/StickyModeHeader.test.ts`(新建)
**Approach:**
- `StickyModeHeader``useTeamStore()` 读取 `isTeamMode` / `teamState`(含 `task_description` / `experts`),从 `useChatStore().boardState` 读取私董会状态。注:实际 store 暴露 `teamState` / `activeExperts` / `leadExpert`,无 `currentPlan` / `experts` 顶层 refFYI F8 提示)— 实施时通过 `teamState.value?.task_description``activeExperts` / `teamState.value?.experts` 访问
- 左侧:模式 badge"专家团" / "私董会"),使用 `--accent-team` / `--accent-board`
- 中间:任务目标(@team 取 `teamState.value?.task_description`)或主题(@board 取 `boardState.topic`
- 右侧:专家头像组(`v-for` 渲染 `activeExperts`,每个显示 `avatar` emoji + `color` 边框),溢出时显示 `+N`
- 头像点击弹出 `a-popover` 详情面板,显示 `name` / `description` / `persona`(现有字段,不假设 role/bio。**Popover 关闭**Esc 键 + 外击关闭关闭时焦点回归触发头像focus management。**移动端**viewport<768px 隐藏任务主题文本仅显示 mode badge + avatar group`+N` 溢出点击打开完整专家列表 popover
- `PhaseIndicator` 硬编码颜色(`#722ed1` / `#52c41a` / `#cf1322`)替换为 token`--accent-board` / `--color-success` / `--color-error`
- ChatView.vue 中 `<ExpertTeamView />` + `<BoardStatusView />` 替换为 `<StickyModeHeader />``<PhaseIndicator />` 保留在其下方
**Patterns to follow:**
- `MessageShell.vue:5-11` 的 custom-avatar 渲染逻辑
- `tokens.css``--z-sticky: 1020` / `--accent-team` / `--accent-board`
**Test scenarios:**
- @team 模式进入对话后渲染 sticky 条,显示"专家团" badge + 任务目标 + 专家头像
- @board 模式进入对话后渲染 sticky 条,显示"私董会" badge + 主题 + 专家头像
- 非 team/board 模式不渲染 sticky 条
- 专家头像点击弹出 popover 显示 name/description/persona
- Popover 通过 Esc 键关闭,关闭后焦点回归触发头像
- Popover 通过外击关闭
- 头像超过 5 个时显示 `+N` 溢出标识,`+N` 点击打开完整专家列表 popover
- viewport<768px 时隐藏任务主题文本仅显示 mode badge + avatar group
- PhaseIndicator 颜色使用 token 变量而非硬编码
**Verification:** @team/@board 模式下顶部持久显示 sticky 头部条专家头像可点击查看详情PhaseIndicator 颜色与 tokens.css 一致。
---
### U3. 后端 _phase_executor.py 流式切换
**Goal:** 将专家阶段执行从同步 `agent.execute()` 切换为流式 `agent.execute_stream()`,转发 token/thinking/final_answer 事件到 WS。
**Requirements:** R8, R9后端依赖
**Dependencies:** ConfigDrivenAgent.execute_stream() 暴露 — 需先在 BaseAgent/ConfigDrivenAgent 暴露 stream dispatchreact/rewoo/plan_exec/reflexion 模式委派至底层引擎direct/llm_generate/tool_call/custom 模式定义显式 fallback
**Files:**
- `src/agentkit/core/config_driven.py`(修改 — 暴露 `execute_stream()` + `handle_task_stream()` dispatch镜像 `execute()``handle_task()` 模式)
- `src/agentkit/core/base.py`(修改 — 在 BaseAgent 加 `execute_stream()` 抽象方法签名)
- `src/agentkit/experts/_phase_executor.py`(修改 — `_run_agent_steps` 方法)
- `src/agentkit/experts/_synthesizer.py`(修改 — `_synthesize_results` 注入 `broadcast_callback` 参数,保留 dict 返回类型,方法内每次 LLM chunk 调用 callback 广播 `team_synthesis_chunk`
- `src/agentkit/experts/orchestrator.py`(修改 — `_synthesize_results` 调用处传入 `_broadcast_event` 的部分应用作为 callback`final_result = await self._synthesize_results(...)` 与 `final_result.get("content")` 消费保持不变)
- `tests/unit/experts/test_phase_executor_streaming.py`(新建)
**Approach:**
- `_run_agent_steps`line 222`await agent.execute(task_msg)` 改为 `async for event in agent.execute_stream(task_msg, ...)` 循环
- 循环内按 `event.event_type` 转发(载荷在 `event.data: dict[str, object]`,无 `event.content` / `event.output` 属性 — 见 `react.py:131-137` ReActEvent 定义):
- `"token"``_broadcast_event("expert_result_chunk", { expert_id, content: event.data.get("content") })`
- `"thinking"``_broadcast_event("expert_step", { expert_id, thinking: event.data.get("content") })`
- `"final_answer"``_broadcast_event("expert_result_chunk", { expert_id, content: event.data.get("output") })`
- `"tool_call"` / `"tool_result"` → 可选转发(保持现有 `expert_step` 语义)
- 循环结束后广播 `expert_result` 完整事件content = 累积的完整结果status: "completed"
- `TeamOrchestrator._synthesize_results` 接口策略:注入 `broadcast_callback` 参数,保留 `dict[str, object]` 返回类型(对 `orchestrator.py:289` 消费者零破坏)。方法内在每次 LLM 输出 chunk 时调用 `broadcast_callback({"chunk": text})` 广播 `team_synthesis_chunk`,最终返回完整 dict`orchestrator.py:294-301` 的最终 `team_synthesis` 广播路径不变
- **重试 + 流式合约**`_run_agent_steps` 的 retry 循环(`_phase_executor.py:220-241`)与流式交互须遵循:(a) 重试前广播 `expert_result_chunk_reset` 事件,前端清空已累积内容;(b) 最终 `expert_result` 事件携带完整内容覆盖(前端在 `expert_result` 到达时替换而非累加);(c) 重试不广播新 chunks 直到本次流式完成 — 避免失败 attempt 1 的部分 chunks 与 attempt 2 的 chunks 在前端累积成乱码
- 异步生成器安全:遵循 AGENTS.md 约定,`async def` 中第一个 `yield` 前禁止 `return`(用 `return; yield` 模式)
- 异常分类:遵循 `docs/solutions/conventions/any-and-except-exception-governance.md`WS 广播用 `except (ConnectionError, RuntimeError, asyncio.TimeoutError)`
**Patterns to follow:**
- `react.py:1443``execute_stream` 签名与 ReActEvent 结构
- `chatStream.ts:526-532` 的 final_answer 累积模式(后端镜像)
- `docs/solutions/logic-errors/long-horizon-reliability-code-review-fixes.md``execute_stream` 入口 `self.reset()` 前置条件
**Test scenarios:**
- `execute_stream` 产出 token 事件时,`_broadcast_event` 被调用转发 `expert_result_chunk`
- `execute_stream` 产出 final_answer 事件时,`_broadcast_event` 被调用转发 `expert_result_chunk`
- 循环结束后广播完整 `expert_result` 事件
- `execute_stream` 抛出异常时phase 状态标记为 failed 且不阻塞其他阶段
- `execute_stream` 中途抛出异常时,广播 `expert_result` 事件 status="error" 携带已累积内容,前端可识别错误态并保留部分内容
- 流式会话必须以 `expert_result(completed)``expert_result(error)` 终结,不允许静默挂起
- `execute_stream` 在 2 chunks 后抛异常 → retry loop 触发 → 广播 `expert_result_chunk_reset` → 重试成功 → 前端消息仅含 attempt 2 内容(无 attempt 1 残留)
- TeamOrchestrator 综合阶段流式广播 `team_synthesis_chunk`
**Verification:** 后端日志可见 `expert_result_chunk` 事件按 token 推送,最终 `expert_result` 完整事件作为结束标记。
---
### U4. 前端 expert_result/team_synthesis 流式消费 + 身份标识
**Goal:** 前端将 expert_result/team_synthesis 从一次性 append 改为累积式 update流式过程中显示专家身份标识。
**Requirements:** R8, R9, R10, R11
**Dependencies:** U3联调可 mock 开发)
**Files:**
- `src/agentkit/server/frontend/src/stores/chatStream.ts`(修改 — expert_result/team_synthesis 分支)
- `src/agentkit/server/frontend/src/components/chat/messages/MessageShell.vue`(修改 — 渲染 expert_name/expert_color badge
- `src/agentkit/server/frontend/tests/unit/stores/chatStream.test.ts`(修改 — 新增流式测试)
**Approach:**
- 新增 `expert_result_chunk` 事件分支:若当前无该专家的流式消息则 `appendMessage` 创建占位(`status: "streaming"`),否则 `updateMessage` 累加 content复用 final_answer 526-532 模式)
- `expert_result` 事件分支改为:仅 `updateMessage` 标记 `status: "completed"`(不再 appendMessage`status="error"` 时标记为错误态并保留部分内容
- `expert_result_chunk_reset` 事件分支U3 重试合约):清空已累积 content重置 streaming 状态
- `team_synthesis_chunk` 同理:首次 append 占位,后续 update 累加
- `team_synthesis` 事件改为:仅 update 标记完成
- **并发专家流式顺序**:每个 `expert_id` 维护独立 streaming message slotkeyed by `expert_id`),同 expert 的 chunks 累积到该 expert 的消息;并发专家渲染为独立并行 streaming 消息,按 first-chunk 到达时间排序
- 流式消息渲染时显示身份标识(`expert_name` + `expert_color` badge流式结束后标识保留
- R10 身份标识为用户可见的专家 badge区别于 R12 的内部路由 tag通过 `message.expert_name` / `message.expert_color` 在 MessageShell 中渲染
**Patterns to follow:**
- `chatStream.ts:526-532``final_answer` 累积模式
- `dispatchWsEvent` 纯函数设计(入参 `ChatStreamState`,无模块级状态)
**Test scenarios:**
- `expert_result_chunk` 首次到达时创建 streaming 占位消息
- `expert_result_chunk` 后续到达时累加 content 到已有消息
- `expert_result` 到达时标记消息 status 为 completed
- `expert_result` 到达时 status="error" 时,标记 streaming 消息为错误态并保留部分内容
- `expert_result_chunk_reset` 到达时清空已累积 content 并重置 streaming 状态
- `team_synthesis_chunk` 首次到达时创建 streaming 占位
- `team_synthesis` 到达时标记完成
- 流式消息显示 expert_name + expert_color badge
- 流式会话异常终止时UI 显示错误指示器不静默挂起
- 两个专家并发流式 → 两条独立 streaming 消息,各自累积自己的 chunks按 first-chunk 到达时间排序
**Verification:** 专家结果按 token 流式累加显示,完成后标记为 completed身份标识保留在最终消息上。
---
### U5. AssistantText 路由标签悬停显示
**Goal:** 路由元信息 tag 默认隐藏,悬停助手消息时淡入显示。
**Requirements:** R12, R13
**Dependencies:** 无
**Files:**
- `src/agentkit/server/frontend/src/components/chat/messages/AssistantText.vue`(修改)
- `src/agentkit/server/frontend/tests/unit/components/AssistantText.test.ts`(新建)
**Approach:**
- `showRouting` computed 增加 hover 状态依赖:`return props.message.role === 'assistant' && props.message.matched_skill && isHovered.value`
- 根容器加 `@mouseenter` / `@mouseleave` 事件设置 `isHovered = ref(false)`
- CSS `.assistant-text__routing``transition: opacity 0.2s ease`,默认 `opacity: 0`hover 时 `opacity: 1`
- 采用 `v-show` + opacity transition保留 DOM 避免首次悬停的重挂载闪烁,`opacity:0→1` + `transition:opacity 0.2s ease` 满足 R13 淡入要求)
**Patterns to follow:**
- Ant Design Vue 的 `<a-tag>` 组件用法
- `tokens.css``--transition-base` 变量(若存在)
**Test scenarios:**
- 默认状态路由 tag 不可见
- 鼠标悬停助手消息时 tag 淡入显示
- 鼠标移开时 tag 淡出
- 无 `matched_skill` 的消息悬停时也不显示 tag
- 淡入淡出过渡平滑无闪烁
**Verification:** 路由 tag 默认不可见,悬停时淡入,移开时淡出。
---
### U6. UserBubble 悬停操作(复制/删除/回填)
**Goal:** 用户消息气泡悬停时显示操作工具条,支持复制、删除(前端隐藏)、回填输入框重发。
**Requirements:** R14, R15, R16, R17
**Dependencies:** 无chatStore 新方法独立于其他单元)
**Files:**
- `src/agentkit/server/frontend/src/components/chat/messages/UserBubble.vue`(修改)
- `src/agentkit/server/frontend/src/stores/chatStore.ts`(修改 — 新增 deleteMessage / refillText
- `src/agentkit/server/frontend/src/components/chat/ChatInput.vue`(修改 — watch refillText
- `src/agentkit/server/frontend/tests/unit/components/UserBubble.test.ts`(新建)
**Approach:**
- `UserBubble.vue` 根容器加 `@mouseenter` / `@mouseleave` 控制 `showActions = ref(false)`
- **focus-visible parity**:根容器加 `@focus` / `@blur`(或 `:focus-within`)使 Tab 聚焦时也显示工具条键盘用户可达Touch 设备 tap on bubble 切换显示tap elsewhere 隐藏
- 悬停时显示工具条(`v-if="showActions"`):复制按钮(`CopyOutlined`)、删除按钮(`DeleteOutlined`)、回填按钮(`EditOutlined`
- 复制:`navigator.clipboard.writeText(content)`,成功后按钮短暂变色反馈
- 删除:弹出 `a-popconfirm` 二次确认,确认后调 `chatStore.deleteMessage(convId, msgId)``currentMessages` 移除(仅前端隐藏,不删服务端副本);**若有助手回复跟随**thread order 中下一条为 assistant 消息),删除按钮 disabled 并显示 tooltip "该消息已有回复,无法删除"
- 回填:调 `chatStore.setRefillText(content)``ChatInput` watch `refillText` 回填到 `inputText`,不自动发送
- chatStore 新增:`refillText = ref('')`、`deleteMessage(convId, msgId)`、`setRefillText(text)`
**Patterns to follow:**
- `chatStore.resendLastUserMessage()`chatStore.ts:449-462的消息定位模式
- `chatStore.deleteConversation` 的 pending 状态清理模式(见 `calendar-capability-and-ui-fixes.md` 学习)
**Test scenarios:**
- 悬停时显示三个操作按钮
- Tab 聚焦用户消息时显示工具条Enter 触发默认动作(复制)
- Touch 设备 tap on bubble 显示工具条tap elsewhere 隐藏
- 复制按钮点击后内容写入剪贴板,按钮变色反馈
- 删除按钮点击后弹出二次确认,确认后消息从列表消失
- 删除取消后消息不消失
- 删除有助手回复跟随的用户消息 → 删除按钮 disabled + tooltip "该消息已有回复,无法删除"
- 回填按钮点击后 ChatInput 输入框显示消息文本,不自动发送
- 离开悬停后操作工具条消失
**Verification:** 悬停用户消息可见三个操作,复制/删除/回填功能正常,删除有二次确认。
---
### U7. CalendarGrid token 重设计
**Goal:** 日历模块统一消费 `tokens.css`,消除硬编码颜色,`.fc-*` 元素对齐 Notion 风格。
**Requirements:** R18, R19, R20, R21, R22
**Dependencies:** 无tokens.css 已含全部变量;本期不修改 tokens.css
**Files:**
- `src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue`(修改)
- `src/agentkit/server/frontend/src/styles/calendar-overrides.css`(新建 — .fc-* 覆盖)
- `src/agentkit/server/frontend/tests/unit/components/CalendarGrid.test.ts`(新建)
**Approach:**
- `CalendarGrid.vue:38``#1677ff` 替换为按 event_type 映射到 token显式映射表
- `event_type=team``var(--accent-team)`
- `event_type=board``var(--accent-board)`
- `event_type=task``var(--color-primary)`
- `event_type=reminder``var(--color-warning)`
- `event_type=system``var(--text-tertiary)`
- 默认 → `var(--color-primary)`
- 实施前需从 `CalendarGrid.vue` 的事件源枚举实际 `event_type`
- 事件卡片 `backgroundColor` / `borderColor` 改为从 token 映射函数返回
- 新建 `calendar-overrides.css` 覆盖 `.fc-toolbar`(按钮风格)、`.fc-col-header`(表头背景)、`.fc-day-today`(今日高亮)、`.fc-event`(事件卡片字体/边框)
- 覆盖范围最小化:仅覆盖 4 类稳定类名,接受 FC 升级时可能需要维护的成本
- 侧栏与头部工具栏组件使用 token 重绘
**Patterns to follow:**
- `tokens.css` 的 Notion 调色板(`--color-primary: #1a1a1a`、`--accent-team`、`--accent-board`
- `CalendarGrid.vue` 现有的 `firstDay: 1` 配置(见 `jwt-secret-dev-mode-user-id-mismatch.md` 学习)
**Test scenarios:**
- 日历事件颜色按 event_type 映射到 token无硬编码颜色值
- `.fc-toolbar` 按钮风格与主界面按钮一致
- `.fc-day-today` 今日高亮使用 token 色
- 暗色模式(`[data-theme="dark"]`)下日历正确显示
- FullCalendar 视图切换/日期导航/事件拖拽功能不受影响
- Calendar 零事件 → 空态 placeholder 文本使用 `--text-tertiary` token
- 事件获取失败 → 错误态带 retry CTA使用 `--color-error` token
- 事件加载中 → skeleton loader 匹配 token 间距(使用 `--color-border` / `--color-fill-quaternary`
**Verification:** 日历视觉与主界面 Notion 风格一致无硬编码颜色FC 既有功能正常。
---
## Scope Boundaries
### Deferred for later
- 思考内容的 markdown 渲染:本期 ThinkingBlock 仍以纯文本呈现origin 文档 Deferred
- 日历事件的拖拽创建、resize 行为变更仅重绘视觉外壳origin 文档 Deferred
- 专家头像组在线状态指示仅展示静态头像origin 文档 Deferred
- 路由元信息 tag 的可配置显示规则:固定为"默认隐藏 + 悬停显示"origin 文档 Deferred
- 后端流式推送协议的具体方案(事件变体 vs token 分块U3 采用 `execute_stream()` + token 转发,具体事件命名(`expert_result_chunk` 等)可在实施时调整
### Outside this product's identity
- 不引入新的 UI 组件库或设计系统(继续基于 Ant Design Vue + token 覆盖)
- 不替换 FullCalendar 为自建日历组件(保留 FC 核心,仅覆盖外壳与 `.fc-*` 样式)
- 不改变 `PhaseIndicator` 的现有阶段进度语义与展示形式(仅独立保留 + token 化)
### Deferred to Follow-Up Work
- R16 服务端删除语义本期仅前端隐藏服务端删除留待后续迭代origin OQ4
- 交互状态全覆盖(错误态/空态/加载态规范origin OQ1本期在相关单元测试中覆盖核心场景全面规范留待后续
- 响应式与无障碍全面策略origin OQ2本期在组件中遵循基本 a11y全面策略留待后续
- @team/@board 模式可发现性引导origin OQ9FYI 级别)
---
## Risks & Dependencies
| 风险 | 影响 | 缓解 |
|------|------|------|
| `_phase_executor.py` 流式切换可能影响阶段执行稳定性 | expert_result 流式中断或丢失retry loop 与流式交互导致前端累积乱码 | U3 单独测试;遵循 `execute_stream` 入口 `self.reset()` 前置条件retry+流式合约:重试前广播 `expert_result_chunk_reset` 让前端清空,最终 `expert_result` 覆盖累积(详见 U3 Approach |
| FullCalendar `.fc-*` 类名升级时可能破坏 | 日历样式失效 | 覆盖范围最小化4 类稳定类名);在 `calendar-overrides.css` 顶部注释标注风险 |
| chatStore 新增 `deleteMessage` 可能影响会话状态一致性 | 消息索引错乱 | 参考 `deleteConversation` 的 pending 清理模式;单元测试覆盖删除后消息索引 |
| U3/U4 联调依赖 | 前端流式无法独立验证 | U4 可用 mock `expert_result_chunk` 事件开发U3 完成后联调 |
---
## System-Wide Impact
- **前端**ChatView 组件树结构调整banner 替换为 sticky headerchatStore 新增方法与状态chatStream 事件分支改造
- **后端**`_phase_executor.py` 执行模式变更execute → execute_streamWS 事件新增 `expert_result_chunk` / `team_synthesis_chunk`
- **测试**新增组件测试文件ThinkingBlock / StickyModeHeader / AssistantText / UserBubblechatStream.test.ts 扩展流式测试
- **样式**:新增 `calendar-overrides.css`PhaseIndicator token 化CalendarGrid token 化
---
## Open Questions
来自 origin 文档的 9 个 Open Questions 处理状态:
| OQ | 状态 | 处理方式 |
|----|------|----------|
| OQ1 交互状态覆盖 | Deferred | 本期测试覆盖核心场景,全面规范留待后续 |
| OQ2 响应式与无障碍 | Deferred | 本期遵循基本 a11y全面策略留待后续 |
| OQ3 流式结构 | Resolved | U3 采用 `execute_stream()` + token 转发 |
| OQ4 R16 删除作用域 | Resolved | 前端隐藏,服务端删除留待后续 |
| OQ5 模式切换用户流 | Addressed | U2 Approach 中覆盖 |
| OQ6 关键交互细节 | Addressed | U2/U6 Approach 中指定Popover/Popconfirm |
| OQ7 .fc-* 兼容性 | Noted as Risk | Risks 表中标注 |
| OQ8 Sticky 头部替代 | Resolved | brainstorm 已确认 sticky 方案 |
| OQ9 模式可发现性 | FYI | Deferred to Follow-Up |
| OQ10 (NEW) U3 后端协议 scope 声明 | Deferred | origin 行 88 显式排除后端流式协议("不属本文档 scope"),但 U3 已纳入本计划作为实施单元。是否重新分类 U3 为 "Cross-cutting dependency unit"(与 UI/UE 单元分开)或显式标注为 scope 扩展?建议实施前确认。 |
| OQ11 (NEW) PhaseIndicator token 化 R-id 追溯 | Deferred | U2 中 PhaseIndicator 颜色 token 化(`#722ed1`/`#52c41a`/`#cf1322` → `--accent-board`/`--color-success`/`--color-error`)无对应 R-id 追溯origin R7 仅要求"独立保留 + token 化")。是否新增 R-id 显式覆盖此 token 化工作,或延后到独立 token 化 pass 处理其他组件同类硬编码? |
---
## Sources & Research
- Origin: `docs/brainstorms/2026-07-01-ui-ue-enhancement-requirements.md`ce-brainstorm 产出 + ce-doc-review 审查强化)
- `docs/solutions/logic-errors/long-horizon-reliability-code-review-fixes.md``execute_stream` 入口 reset 前置条件、team_synthesis 综合阶段完整内容读取
- `docs/solutions/ui-bugs/calendar-agent-create-no-refresh.md` — WS 事件链端到端追踪模式、notify_callback 注入
- `docs/solutions/logic-errors/calendar-capability-and-ui-fixes.md` — SystemMonitorPanel flex 方向、deleteConversation pending 清理
- `docs/solutions/conventions/any-and-except-exception-governance.md` — WebSocket 异常分类、CancelledError 守卫
- `docs/solutions/integration-issues/jwt-secret-dev-mode-user-id-mismatch.md` — CalendarGrid firstDay 配置
- 代码库研究:`react.py:1443`execute_stream、`_phase_executor.py:222`(流式缺口)、`chatStream.ts:526-532`final_answer 累积模式)、`tokens.css`Notion 调色板)、`configs/experts/*.yaml`(专家元数据字段)

View File

@ -0,0 +1,34 @@
# Residual Review Findings — feat/ui-ue-enhancement
**Run context**: ce-code-review mode:agent, run_id `20260701-122351-b8e97648`
**Branch**: feat/ui-ue-enhancement
**Base**: 521f573 (origin/main)
**Date**: 2026-07-01
## Residual Review Findings
以下 actionable findings 未在 Step 5 应用,需要后续处理:
- **P1** | `src/agentkit/experts/_phase_executor.py:241` | expert_step 流式事件 payload 缺少 expert_name/expert_color/content/step 字段,与前端 WsServerMessage 契约不匹配,产生损坏的占位消息 | autofix_class: manual, owner: downstream-resolver
- **P2** | `src/agentkit/core/config_driven.py:693` | execute_stream 绕过 execute(),未注册 CancellationToken流式任务无法被 cancel_task() 协作式取消 | autofix_class: gated_auto, owner: downstream-resolver, requires_verification: true
- **P2** | `src/agentkit/experts/orchestrator.py:293` | team_synthesis 流式中断后留下孤儿 streaming milestone 占位(无终结事件),前端永久转圈 | autofix_class: manual, owner: downstream-resolver
- **P2** | `src/agentkit/server/frontend/src/stores/chatStream.ts:880` | team_synthesis_chunk 占位匹配缺少 synthesis_id 去重,可附身到上一次孤儿 milestone | autofix_class: manual, owner: downstream-resolver
## Advisory findings (report-only)
- P2 | `_phase_executor.py` | 三个流式 handler 方法存在大量重复代码_handle_rewoo_stream/_handle_plan_exec_stream/_handle_reflexion_stream
- P2 | `config_driven.py:856` | 同步 _handle_react 未复用 _build_llm_messages存在漂移风险
- P2 | `chatStore.ts:489` | deleteMessage 仅前端隐藏,无 UI 提示用户消息会在重新同步后恢复
- P3 | `base.py:177` | execute_stream 缺少 on_task_start/on_task_complete/on_task_failed 生命周期钩子调用
- P3 | `config_driven.py:686` | ConfigDrivenAgent 流式分派逻辑无单元测试
- P3 | `chatStore.ts:1751` | hasReply 守卫仅检查紧邻下一条消息,无法覆盖跨消息回复场景
- P3 | `chatStream.ts:1904` | per-token 字符串拼接在热路径上为 O(n²) 分配V8 rope strings 优化,影响有限)
- P3 | `ThinkingBlock.vue:136` | summary computed 依赖 content.length每 token 重算
- P3 | `UserBubble.vue:1640` | 每个 bubble 挂载独立 document touchstart 监听器
## Applied in Step 5
- **P0 fix** | `chat.py:144` | _VALID_TEAM_EVENT_TYPES 添加 expert_result_chunk/expert_result_chunk_reset/team_synthesis_chunk
- **P0 fix** | `_phase_executor.py:246` | final_answer 不重复累积 token 内容(避免内容翻倍)
- **P2 fix** | `_phase_executor.py:278` | 异常处理扩展为 except Exception 兜底(捕获 LLMProviderError 等)
- **Test fix** | `test_phase_executor_streaming.py` | 3 个测试更新/新增以符合 ReActEngine token+final_answer 合约

View File

@ -0,0 +1,167 @@
---
module: experts/server
date: 2026-07-01
problem_type: runtime_error
component: assistant
severity: high
symptoms:
- "前端流式输出内容翻倍token + final_answer 双重累积,'Hello' 变为 'HelloHello'"
- "WebSocket 客户端收不到新增流式事件类型expert_result_chunk 等)"
- "LLM 抛出 LLMProviderError 时前端 streaming 卡死,无错误反馈"
root_cause: logic_error
resolution_type: code_fix
related_components:
- server/routes/chat
- core/react
- server/frontend/stores/chatStream
tags:
- streaming
- websocket
- react-engine
- phase-executor
- expert-team
- event-whitelist
- async-generator
---
# 流式事件白名单 + token 双重累积 + 异常穿透
## Problem
引入专家团队流式输出U3/U4前端聊天界面出现三种症状
1. **内容翻倍**:专家流式回复内容在前端显示两次(`"Hello"` → `"HelloHello"`),最终结果与中间 token 拼接重复
2. **事件丢失**:新加入的 `expert_result_chunk` / `team_synthesis_chunk` 等 WS 事件到达前端时被静默丢弃,导致整个流式功能失效
3. **流式卡死**LLM Provider 抛出非 `RuntimeError` 的异常(如 `LLMProviderError` / `ConfigValidationError`)时,流式生成器未广播错误事件,前端 streaming 状态永不结束
## Symptoms
- 专家团队讨论/综合的 token 流式输出在前端拼接为 `"HelloHello"`token 内容 + final_answer 全文)
- `emit_team_event` 调用日志显示 200 OK但前端 WebSocket 客户端从未收到 `expert_result_chunk` 事件
- LLM Provider 因 API key 失效抛出 `LLMProviderError` 时,前端 streaming 光标永久闪烁UI 卡在"thinking"状态
- `_phase_executor``accumulated.append(output)` 被调用两次:一次 token 累积,一次 final_answer 累积
## What Didn't Work
- **假设 final_answer 是单独的"补充内容"**原测试用非重叠内容验证token="Hello" + final_answer=" World",断言 content == "Hello World"通过了测试但掩盖了真实合约。ReActEngine 实际合约是token 拼接 = final_answer output两者内容相同
- **假设 `emit_team_event` 会广播所有事件类型**:调用 `emit_team_event` 后日志显示成功,但 `_VALID_TEAM_EVENT_TYPES` frozenset 实际上是一个白名单 — 不在白名单的事件被 `emit_team_event` 静默丢弃log 级别 DEBUG看不到。"广播成功"的日志误导了调试。
- **假设 `except (RuntimeError, asyncio.TimeoutError, ConnectionError)` 足够**LLM 网关的异常层次是 `LLMProviderError(Exception)` 而非 `LLMProviderError(RuntimeError)`,所以穿透了流式异常处理。`asyncio.CancelledError` 在 Python 3.8+ 继承 `BaseException`(非 `Exception`),不会被 `except Exception` 捕获,所以扩大 except 范围不会破坏取消语义。
## Solution
三处修复,均位于专家团队流式事件转发路径:
### Fix 1: WS 事件白名单扩充
`src/agentkit/server/routes/chat.py``_VALID_TEAM_EVENT_TYPES` frozenset 必须包含所有 `emit_team_event` 调用的事件类型:
```python
_VALID_TEAM_EVENT_TYPES = frozenset({
"team_formed", "expert_step", "expert_result",
"expert_result_chunk", # 新增U3 流式 token
"expert_result_chunk_reset", # 新增U3 retry 重置
"plan_update", "team_synthesis",
"team_synthesis_chunk", # 新增U4 综合流式 token
"team_dissolved",
# ... 其余不变
})
```
### Fix 2: final_answer 不再重复累积
`src/agentkit/experts/_phase_executor.py``_execute_phase_stream` 中区分 token 与 final_answer 的角色:
```python
# 修改前(双重累积 bug
elif etype == "final_answer":
output = event.data.get("output", "")
if output:
accumulated.append(str(output)) # BUG: token 已累积,再 append 全文
await self._broadcast_event("expert_result_chunk", {
"expert_id": expert.config.name, "content": output,
}) # BUG: 再广播一次全文
# 修改后:
elif etype == "final_answer":
output = event.data.get("output", "")
# ReActEngine 先发 token增量再发 final_answer全文
# token 已累积时final_answer 仅作完成信号,不重复 append/broadcast
# 无 token如 _wrap_sync_as_stream fallback时用 output 兜底。
if output and not accumulated:
accumulated.append(str(output))
```
### Fix 3: 异常处理扩展到 `except Exception`
`_execute_phase_stream` 的异常处理从窄类型扩展到 `Exception`,覆盖 `LLMProviderError` / `ConfigValidationError` 等穿透异常:
```python
# 修改前:
except (RuntimeError, asyncio.TimeoutError, ConnectionError) as e:
# 流式异常 — 广播 expert_result(error) 携带已累积内容
# 修改后:
except Exception as e:
# 兜底捕获 LLMProviderError/ConfigValidationError 等非 RuntimeError
# asyncio.CancelledError (BaseException) 已被上方 except 捕获,不会到达此处
# 流式异常 — 广播 expert_result(error) 携带已累积内容
```
## Why This Works
**ReAct 流式合约**`ReActEngine.execute_stream()` 是 async generator合约是
1. 逐个 yield `token` 事件,每个 `data.content` 是增量内容片段
2. 最后 yield 一个 `final_answer` 事件,`data.output` 是所有 token 拼接的完整文本
token 和 final_answer **内容相同**(前者是增量,后者是聚合)。因此下游消费者必须选择一种累积策略,不能两者都累积:
- **累积 token**(本次方案):增量 append最终 `accumulated = ["Hel", "lo"]`,拼接为 `"Hello"`。final_answer 仅作为完成信号。
- **使用 final_answer**:忽略 token等 final_answer 一次性拿到全文。但这样失去流式效果,且 `_wrap_sync_as_stream` fallback 路径没有 token 事件,必须依赖 final_answer output。
混合方案(两者都累积)必然重复,因为 ReActEngine 的合约已经保证 `final_answer.output == "".join(tokens)`
**WS 事件白名单**`emit_team_event` 是"开放发送 + 白名单接收"的设计 — 服务端会广播任何 event_type`_VALID_TEAM_EVENT_TYPES` frozenset 决定哪些事件类型被转发到 WebSocket 客户端。不在白名单的事件在转发层被静默丢弃(返回 200但不实际发送。新增事件类型时必须同步更新白名单。
**异常层次**Python 3.8+ 的 `asyncio.CancelledError` 继承 `BaseException` 而非 `Exception`,所以 `except Exception` 不会捕获取消信号。在 async generator 中扩大 except 范围是安全的 — 取消语义保留,同时覆盖 `LLMProviderError` 等穿透异常。
## Prevention
### 新增 WS 事件类型的 checklist
当后端新增需要转发到前端的 WS 事件类型时:
1. 在 `emit_team_event` 调用点使用新事件类型
2. **同步**在 `_VALID_TEAM_EVENT_TYPES` frozenset 中添加(`src/agentkit/server/routes/chat.py`
3. 在前端 `chatStream.ts` 添加对应 handler
4. 添加测试验证 `emit_team_event` 调用后前端能收到事件
白名单缺失不会抛错,只会静默丢弃 — 这是隐蔽 bug 的温床。考虑添加单元测试断言:对所有 `_VALID_TEAM_EVENT_TYPES` 中的类型,`emit_team_event` 必须实际转发。
### ReAct 流式合约测试模板
测试 `execute_stream` 的消费者时,必须使用符合真实合约的 mock 事件序列:
```python
# 正确(符合合约):
events = [
ReActEvent(event_type="token", step=0, data={"content": "Hel"}),
ReActEvent(event_type="token", step=0, data={"content": "lo"}),
ReActEvent(event_type="final_answer", step=0, data={"output": "Hello"}),
]
# 断言token 拼接 == final_answer output == "Hello"
# 错误(掩盖双重累积 bug
events = [
ReActEvent(event_type="token", step=0, data={"content": "Hello"}),
ReActEvent(event_type="final_answer", step=0, data={"output": " World"}),
]
# 断言content == "Hello World" — 通过测试但掩盖 bug
```
### 异常处理范围
在 async generator 的异常处理中,优先使用 `except Exception` 而非窄类型列表,除非:
- 需要区分取消与异常(用 `except asyncio.CancelledError: raise` 在前,然后 `except Exception`
- 需要特定异常类型的特殊处理(如 `except asyncio.TimeoutError` 重试逻辑)
`except Exception` 在 async generator 中是安全的 — `CancelledError` 不会被意外捕获。

View File

@ -12,6 +12,7 @@ import json
import logging import logging
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -30,6 +31,7 @@ from agentkit.core.protocol import (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from agentkit.core.react import ReActEvent
from agentkit.memory.base import Memory from agentkit.memory.base import Memory
from agentkit.tools.base import Tool from agentkit.tools.base import Tool
from agentkit.llm.gateway import LLMGateway from agentkit.llm.gateway import LLMGateway
@ -160,6 +162,27 @@ class BaseAgent(ABC):
"""返回 Agent 能力声明""" """返回 Agent 能力声明"""
... ...
# ── 流式执行U3 ────────────────────────────────────────
async def execute_stream(self, task: TaskMessage) -> AsyncGenerator["ReActEvent", None]:
"""流式执行任务yield ReActEvent 事件。
execute() 不同此方法不包装 TaskResult 直接 yield 事件
由调用方 PhaseExecutorMixin._run_agent_steps负责转发和
最终结果累积默认实现回退到 handle_task 并包装为单个
final_answer 事件子类应覆写以提供真正的流式输出
"""
# Default fallback: run sync handle_task, wrap as single final_answer.
# Subclasses (e.g. ConfigDrivenAgent) override for real streaming.
from agentkit.core.react import ReActEvent
output = await self.handle_task(task)
yield ReActEvent(
event_type="final_answer",
step=0,
data={"output": output.get("content", str(output))},
)
# ── 生命周期钩子(可选覆写) ────────────────────────────── # ── 生命周期钩子(可选覆写) ──────────────────────────────
async def on_task_start(self, task: TaskMessage) -> None: async def on_task_start(self, task: TaskMessage) -> None:

View File

@ -10,6 +10,7 @@
import json import json
import logging import logging
import os import os
from collections.abc import AsyncGenerator, Awaitable
from typing import Callable, Coroutine from typing import Callable, Coroutine
import yaml import yaml
@ -17,6 +18,7 @@ import yaml
from agentkit.core.base import BaseAgent from agentkit.core.base import BaseAgent
from agentkit.core.exceptions import ConfigValidationError from agentkit.core.exceptions import ConfigValidationError
from agentkit.core.protocol import AgentCapability, TaskMessage from agentkit.core.protocol import AgentCapability, TaskMessage
from agentkit.core.react import ReActEvent
from agentkit.evolution.lifecycle import EvolutionMixin from agentkit.evolution.lifecycle import EvolutionMixin
from agentkit.evolution.reflector import Reflector from agentkit.evolution.reflector import Reflector
from agentkit.prompts.section import PromptSection from agentkit.prompts.section import PromptSection
@ -654,6 +656,203 @@ class ConfigDrivenAgent(BaseAgent, EvolutionMixin):
reason=f"Unknown task_mode: {self._config.task_mode}", reason=f"Unknown task_mode: {self._config.task_mode}",
) )
# ── 流式执行U3 ────────────────────────────────────────
def _build_llm_messages(
self, task: TaskMessage
) -> tuple[str | None, list[dict[str, str]]]:
"""Build (system_prompt, user_messages) from task + prompt template.
Shared by all _handle_*_stream methods to avoid duplicating the
message-rendering logic that mirrors the sync _handle_* methods.
"""
variables = task.input_data.copy()
variables["task_type"] = task.task_type
if self._prompt_template:
rendered_messages = self._prompt_template.render(variables=variables)
else:
rendered_messages = [{"role": "user", "content": str(task.input_data)}]
system_prompt: str | None = None
user_messages: list[dict[str, str]] = []
for msg in rendered_messages:
if msg["role"] == "system":
system_prompt = msg["content"]
else:
user_messages.append(msg)
if not user_messages:
user_messages.append({"role": "user", "content": str(task.input_data)})
return system_prompt, user_messages
async def execute_stream(self, task: TaskMessage) -> AsyncGenerator[ReActEvent, None]:
"""流式执行任务yield ReActEvent。
镜像 execute() handle_task() 分派但不包装 TaskResult
直接 yield 事件由调用方负责转发和累积
"""
await self._register_mcp_tools()
async for event in self.handle_task_stream(task):
yield event
async def handle_task_stream(self, task: TaskMessage) -> AsyncGenerator[ReActEvent, None]:
"""根据 execution_mode / task_mode 流式分派,镜像 handle_task()。"""
if self._skill_config:
execution_mode = self._skill_config.execution_mode
if execution_mode == "react" and self._react_engine:
async for e in self._handle_react_stream(task):
yield e
return
if execution_mode == "rewoo" and self._llm_gateway:
async for e in self._handle_rewoo_stream(task):
yield e
return
if execution_mode == "plan_exec" and self._llm_gateway:
async for e in self._handle_plan_exec_stream(task):
yield e
return
if execution_mode == "reflexion" and self._llm_gateway:
async for e in self._handle_reflexion_stream(task):
yield e
return
if execution_mode == "direct":
async for e in self._wrap_sync_as_stream(self._handle_direct, task):
yield e
return
if execution_mode == "custom":
async for e in self._wrap_sync_as_stream(self._handle_custom, task):
yield e
return
# Fall back to task_mode modes
if self._config.task_mode == "llm_generate":
async for e in self._wrap_sync_as_stream(self._handle_llm_generate, task):
yield e
return
if self._config.task_mode == "tool_call":
async for e in self._wrap_sync_as_stream(self._handle_tool_call, task):
yield e
return
if self._config.task_mode == "custom":
async for e in self._wrap_sync_as_stream(self._handle_custom, task):
yield e
return
# Unknown mode — wrap sync result as single final_answer event
result = await self.handle_task(task)
yield ReActEvent(
event_type="final_answer",
step=0,
data={"output": result.get("content", str(result))},
)
async def _handle_react_stream(self, task: TaskMessage) -> AsyncGenerator[ReActEvent, None]:
"""ReAct mode streaming: delegate to ReActEngine.execute_stream()."""
if self._evolution_enabled and self._current_module is None:
self._auto_set_current_module()
system_prompt, user_messages = self._build_llm_messages(task)
retrieval_config = self._config.memory.get("retrieval", {}) if self._config.memory else {}
async for event in self._react_engine.execute_stream( # type: ignore[union-attr]
messages=user_messages,
tools=self.get_tools() or None,
model=self._config.llm.get("model", "default") if self._config.llm else "default",
agent_name=self.name,
task_type=task.task_type,
system_prompt=system_prompt,
memory_retriever=self._memory_retriever,
task_id=task.task_id,
retrieval_config=retrieval_config or None,
cancellation_token=self._active_tokens.get(task.task_id),
timeout_seconds=float(task.timeout_seconds) if task.timeout_seconds > 0 else None,
compressor=self._compressor,
):
yield event
async def _handle_rewoo_stream(self, task: TaskMessage) -> AsyncGenerator[ReActEvent, None]:
"""ReWOO mode streaming: delegate to ReWOOEngine.execute_stream()."""
from agentkit.core.rewoo import ReWOOEngine
system_prompt, user_messages = self._build_llm_messages(task)
rewoo_engine = ReWOOEngine(
llm_gateway=self._llm_gateway,
max_plan_steps=self._skill_config.max_steps if self._skill_config else 5,
default_timeout=300.0,
fallback_strategies=(
self._skill_config.fallback_strategies
if self._skill_config and self._skill_config.fallback_strategies
else None
),
)
async for event in rewoo_engine.execute_stream(
messages=user_messages,
tools=self.get_tools() or None,
model=self._config.llm.get("model", "default") if self._config.llm else "default",
agent_name=self.name,
task_type=task.task_type,
system_prompt=system_prompt,
task_id=task.task_id,
cancellation_token=self._active_tokens.get(task.task_id),
timeout_seconds=float(task.timeout_seconds) if task.timeout_seconds > 0 else None,
):
yield event
async def _handle_plan_exec_stream(self, task: TaskMessage) -> AsyncGenerator[ReActEvent, None]:
"""Plan-Exec mode streaming: delegate to PlanExecEngine.execute_stream()."""
from agentkit.core.plan_exec_engine import PlanExecEngine
system_prompt, user_messages = self._build_llm_messages(task)
plan_exec_engine = PlanExecEngine(
llm_gateway=self._llm_gateway,
max_replans=2,
default_timeout=300.0,
)
async for event in plan_exec_engine.execute_stream(
messages=user_messages,
tools=self.get_tools() or None,
model=self._config.llm.get("model", "default") if self._config.llm else "default",
agent_name=self.name,
task_type=task.task_type,
system_prompt=system_prompt,
task_id=task.task_id,
cancellation_token=self._active_tokens.get(task.task_id),
timeout_seconds=float(task.timeout_seconds) if task.timeout_seconds > 0 else None,
):
yield event
async def _handle_reflexion_stream(self, task: TaskMessage) -> AsyncGenerator[ReActEvent, None]:
"""Reflexion mode streaming: delegate to ReflexionEngine.execute_stream()."""
from agentkit.core.reflexion import ReflexionEngine
system_prompt, user_messages = self._build_llm_messages(task)
reflexion_engine = ReflexionEngine(
llm_gateway=self._llm_gateway,
max_steps=self._skill_config.max_steps if self._skill_config else 5,
max_reflections=3,
quality_threshold=0.7,
default_timeout=300.0,
)
async for event in reflexion_engine.execute_stream(
messages=user_messages,
tools=self.get_tools() or None,
model=self._config.llm.get("model", "default") if self._config.llm else "default",
agent_name=self.name,
task_type=task.task_type,
system_prompt=system_prompt,
task_id=task.task_id,
cancellation_token=self._active_tokens.get(task.task_id),
timeout_seconds=float(task.timeout_seconds) if task.timeout_seconds > 0 else None,
):
yield event
async def _wrap_sync_as_stream(
self,
handler: Callable[[TaskMessage], Awaitable[dict]],
task: TaskMessage,
) -> AsyncGenerator[ReActEvent, None]:
"""Wrap a sync handler's result as a single final_answer stream event."""
result = await handler(task)
yield ReActEvent(
event_type="final_answer",
step=0,
data={"output": result.get("content", str(result))},
)
async def _handle_react(self, task: TaskMessage) -> dict: async def _handle_react(self, task: TaskMessage) -> dict:
"""ReAct mode: use ReAct engine for autonomous reasoning""" """ReAct mode: use ReAct engine for autonomous reasoning"""
# Auto-set _current_module from SkillConfig if evolution is enabled # Auto-set _current_module from SkillConfig if evolution is enabled

View File

@ -12,7 +12,7 @@ from datetime import datetime, timezone
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from agentkit.core.config_driven import ConfigDrivenAgent from agentkit.core.config_driven import ConfigDrivenAgent
from agentkit.core.protocol import TaskMessage, TaskResult, TaskStatus from agentkit.core.protocol import TaskMessage
from .expert import Expert from .expert import Expert
from .plan import PhaseStatus, PhaseType, PlanPhase, TeamPlan from .plan import PhaseStatus, PhaseType, PlanPhase, TeamPlan
@ -215,34 +215,91 @@ class PhaseExecutorMixin:
task_msg = self._build_task_message(expert, phase, dependency_outputs, collaboration_outputs) task_msg = self._build_task_message(expert, phase, dependency_outputs, collaboration_outputs)
# 执行专家任务带重试MAX_RETRIES 处理瞬时失败) # 执行专家任务带重试MAX_RETRIES 处理瞬时失败)
# U3: 流式执行 — 从 agent.execute() 切换为 agent.execute_stream()
# 转发 token/thinking/final_answer 事件到 WS最终广播完整 expert_result。
last_error: str | None = None last_error: str | None = None
result: dict[str, object] | None = None result: dict[str, object] | None = None
for attempt in range(self.MAX_RETRIES + 1): for attempt in range(self.MAX_RETRIES + 1):
accumulated: list[str] = []
try: try:
task_result: TaskResult = await agent.execute(task_msg) # U3: 重试前广播 reset前端清空已累积内容
if task_result.status != TaskStatus.COMPLETED.value: if attempt > 0:
last_error = task_result.error_message or "unknown error" await self._broadcast_event("expert_result_chunk_reset", {
if attempt < self.MAX_RETRIES: "expert_id": expert.config.name,
logger.info(f"Retrying phase {phase.id} (attempt {attempt + 1})") "phase_id": phase.id,
continue })
raise RuntimeError(f"Agent execution failed: {last_error}") # U3: 流式执行 — 转发 token/thinking/final_answer 事件到 WS
result = task_result.output_data or {"content": ""} async for event in agent.execute_stream(task_msg):
etype = event.event_type
if etype == "token":
chunk = event.data.get("content", "")
if chunk:
accumulated.append(str(chunk))
await self._broadcast_event("expert_result_chunk", {
"expert_id": expert.config.name, "content": chunk,
})
elif etype == "thinking":
await self._broadcast_event("expert_step", {
"expert_id": expert.config.name,
"thinking": event.data.get("content", ""),
})
elif etype == "final_answer":
# P0 fix: ReActEngine 先发 token增量再发 final_answer全文
# token 已累积时final_answer 仅作完成信号,不重复 append/broadcast
# 无 token如 _wrap_sync_as_stream fallback时用 output 兜底。
output = event.data.get("output", "")
if output and not accumulated:
accumulated.append(str(output))
elif etype in ("tool_call", "tool_result"):
await self._broadcast_event("expert_step", {
"expert_id": expert.config.name, "step_data": event.data,
})
elif etype == "error":
raise RuntimeError(
f"Stream error: {event.data.get('error', 'unknown')}"
)
# 流式完成 — 构建结果
result = {"content": "".join(accumulated)}
break break
except asyncio.CancelledError: except asyncio.CancelledError:
# CancelledError 必须传播,不被重试逻辑吞掉 # CancelledError 必须传播,不被重试逻辑吞掉
# U3: 流式会话必须以 expert_result(error) 终结,不允许静默挂起
try:
await self._broadcast_event("expert_result", {
"expert_id": expert.config.name, "expert_name": expert.config.name,
"expert_color": expert.config.color,
"content": "".join(accumulated), "status": "error",
"error": "cancelled",
"phase_id": phase.id, "rework_attempt": phase.rework_count,
})
except (ConnectionError, RuntimeError, asyncio.TimeoutError) as bc_err:
logger.warning(f"Failed to broadcast expert_result(cancelled): {bc_err}")
raise raise
except (RuntimeError, asyncio.TimeoutError, ConnectionError) as e: except Exception as e:
# agent.execute() 内部已捕获所有异常并返回 TaskResult # P2 fix: 兜底捕获 LLMProviderError/ConfigValidationError 等非 RuntimeError
# 此处仅捕获显式抛出的 RuntimeError + 罕见的基础设施异常 # asyncio.CancelledError (BaseException) 已被上方 except 捕获,不会到达此处
# U3: 流式异常 — 广播 expert_result(error) 携带已累积内容
last_error = str(e) last_error = str(e)
if attempt < self.MAX_RETRIES: if attempt < self.MAX_RETRIES:
logger.info(f"Retrying phase {phase.id} (attempt {attempt + 1})") logger.info(f"Retrying phase {phase.id} (attempt {attempt + 1})")
continue continue
# 重试耗尽 — 广播 error 终结事件
try:
await self._broadcast_event("expert_result", {
"expert_id": expert.config.name, "expert_name": expert.config.name,
"expert_color": expert.config.color,
"content": "".join(accumulated), "status": "error",
"error": last_error,
"phase_id": phase.id, "rework_attempt": phase.rework_count,
})
except (ConnectionError, RuntimeError, asyncio.TimeoutError) as bc_err:
logger.warning(f"Failed to broadcast expert_result(error): {bc_err}")
raise raise
await self._broadcast_event("expert_result", { await self._broadcast_event("expert_result", {
"expert_id": expert.config.name, "expert_name": expert.config.name, "expert_id": expert.config.name, "expert_name": expert.config.name,
"expert_color": expert.config.color, "content": result.get("content", str(result)), "expert_color": expert.config.color, "content": result.get("content", str(result)),
"status": "completed",
"phase_id": phase.id, "rework_attempt": phase.rework_count, "phase_id": phase.id, "rework_attempt": phase.rework_count,
}) })

View File

@ -7,7 +7,7 @@ from __future__ import annotations
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Awaitable, Callable
from agentkit.core.protocol import TaskMessage, TaskResult from agentkit.core.protocol import TaskMessage, TaskResult
@ -27,14 +27,30 @@ class SynthesizerMixin:
_team: ExpertTeam _team: ExpertTeam
_user_context: list[str] _user_context: list[str]
@staticmethod
def _concat_results(results: list[dict[str, object] | str]) -> str:
"""Join phase results into a single content string."""
return "\n\n".join(
r.get("content", str(r)) if isinstance(r, dict) else str(r) for r in results
)
async def _synthesize_results( async def _synthesize_results(
self, lead: Expert, task: str, completed_phases: list[PlanPhase] self,
lead: Expert,
task: str,
completed_phases: list[PlanPhase],
broadcast_callback: Callable[[dict[str, object]], Awaitable[None]] | None = None,
) -> dict[str, object]: ) -> dict[str, object]:
"""Lead Expert synthesizes results using BEST strategy. """Lead Expert synthesizes results using BEST strategy.
The Lead Expert evaluates all completed phase results and produces The Lead Expert evaluates all completed phase results and produces
a final synthesized result. Uses LLM when available, otherwise a final synthesized result. Uses LLM when available, otherwise
concatenates results. concatenates results.
U3: When ``broadcast_callback`` is provided, the LLM synthesis is
streamed via ``gateway.chat_stream()`` and each chunk is forwarded to
the callback (which typically broadcasts ``team_synthesis_chunk``).
The full dict is still returned for the caller to consume unchanged.
""" """
results = [ph.result or {} for ph in completed_phases] results = [ph.result or {} for ph in completed_phases]
if not results: if not results:
@ -52,11 +68,8 @@ class SynthesizerMixin:
gateway = self._get_llm_gateway(lead) gateway = self._get_llm_gateway(lead)
if not gateway: if not gateway:
# Without LLM, concatenate all results # Without LLM, concatenate all results
combined = "\n\n".join(
r.get("content", str(r)) if isinstance(r, dict) else str(r) for r in results
)
return { return {
"content": combined, "content": self._concat_results(results),
"strategy": "best", "strategy": "best",
"phases_completed": len(results), "phases_completed": len(results),
} }
@ -90,6 +103,21 @@ class SynthesizerMixin:
prompt += "\n\nProvide the synthesized result directly." prompt += "\n\nProvide the synthesized result directly."
try: try:
if broadcast_callback is not None:
# U3: 流式综合 — 每次 chunk 调用 callback 广播 team_synthesis_chunk
chunks: list[str] = []
async for chunk in gateway.chat_stream(
messages=[{"role": "user", "content": prompt}],
model=self._get_model(lead),
):
if chunk.content:
chunks.append(chunk.content)
await broadcast_callback({"chunk": chunk.content})
return {
"content": "".join(chunks).strip(),
"strategy": "best",
"phases_completed": len(results),
}
response = await gateway.chat( response = await gateway.chat(
messages=[{"role": "user", "content": prompt}], messages=[{"role": "user", "content": prompt}],
model=self._get_model(lead), model=self._get_model(lead),
@ -101,11 +129,8 @@ class SynthesizerMixin:
} }
except Exception as e: except Exception as e:
logger.warning(f"LLM synthesis failed, falling back to concatenation: {e}") logger.warning(f"LLM synthesis failed, falling back to concatenation: {e}")
combined = "\n\n".join(
r.get("content", str(r)) if isinstance(r, dict) else str(r) for r in results
)
return { return {
"content": combined, "content": self._concat_results(results),
"strategy": "best", "strategy": "best",
"phases_completed": len(results), "phases_completed": len(results),
} }

View File

@ -286,7 +286,13 @@ class TeamOrchestrator(
self._team.set_status(TeamStatus.SYNTHESIZING) self._team.set_status(TeamStatus.SYNTHESIZING)
plan.status = PlanStatus.COMPLETED plan.status = PlanStatus.COMPLETED
final_result = await self._synthesize_results(lead, task, completed) # U3: 流式综合 — 每个 chunk 广播 team_synthesis_chunk
async def _broadcast_synthesis_chunk(data: dict[str, object]) -> None:
await self._broadcast_event("team_synthesis_chunk", data)
final_result = await self._synthesize_results(
lead, task, completed, broadcast_callback=_broadcast_synthesis_chunk
)
self._team.set_status(TeamStatus.COMPLETED) self._team.set_status(TeamStatus.COMPLETED)

View File

@ -41,7 +41,7 @@ export interface IChatMessage {
routing_method?: string routing_method?: string
confidence?: number confidence?: number
task_id?: string task_id?: string
status?: 'completed' | 'pending' | 'error' status?: 'completed' | 'pending' | 'error' | 'streaming'
tool_calls?: IToolCallData[] tool_calls?: IToolCallData[]
thinking?: string thinking?: string
expert_id?: string expert_id?: string
@ -141,7 +141,9 @@ export type WsServerMessage =
| { type: 'pong' } | { type: 'pong' }
| { type: 'team_formed'; data: IExpertTeamState } | { type: 'team_formed'; data: IExpertTeamState }
| { type: 'expert_step'; data: { expert_id: string; expert_name: string; expert_color: string; content: string; step: string } } | { type: 'expert_step'; data: { expert_id: string; expert_name: string; expert_color: string; content: string; step: string } }
| { type: 'expert_result'; data: { expert_id: string; expert_name: string; expert_color: string; content: string } } | { type: 'expert_result_chunk'; data: { expert_id: string; content: string } }
| { type: 'expert_result_chunk_reset'; data: { expert_id: string; phase_id?: string } }
| { type: 'expert_result'; data: { expert_id: string; expert_name: string; expert_color: string; content: string; status?: 'completed' | 'error'; phase_id?: string; rework_attempt?: number; error?: string } }
| { type: 'plan_update'; data: { plan_phases: ITeamPlanPhase[] } } | { type: 'plan_update'; data: { plan_phases: ITeamPlanPhase[] } }
| { type: 'phase_started'; data: { phase_id: string; phase_name: string; assigned_expert: string; depends_on: string[] } } | { type: 'phase_started'; data: { phase_id: string; phase_name: string; assigned_expert: string; depends_on: string[] } }
| { type: 'phase_completed'; data: { phase_id: string; phase_name: string; result_summary: string } } | { type: 'phase_completed'; data: { phase_id: string; phase_name: string; result_summary: string } }
@ -149,7 +151,8 @@ export type WsServerMessage =
// PLAN_EXEC (U4) — phase lifecycle events emitted by ReActEngine. // PLAN_EXEC (U4) — phase lifecycle events emitted by ReActEngine.
| { type: 'phase_changed'; data: { phase: string; previous: string } } | { type: 'phase_changed'; data: { phase: string; previous: string } }
| { type: 'phase_violation'; data: { current_phase: string; tool: string; message: string; violation_kind: string; command_preview?: string } } | { type: 'phase_violation'; data: { current_phase: string; tool: string; message: string; violation_kind: string; command_preview?: string } }
| { type: 'team_synthesis'; data: { content: string } } | { type: 'team_synthesis_chunk'; data: { chunk: string } }
| { type: 'team_synthesis'; data: { content: string; phases_completed?: number; phases_total?: number } }
| { type: 'team_dissolved'; data: { team_id: string } } | { type: 'team_dissolved'; data: { team_id: string } }
// Board Meeting 模式事件 // Board Meeting 模式事件
| { type: 'board_started'; data: IBoardStartedData } | { type: 'board_started'; data: IBoardStartedData }
@ -212,6 +215,8 @@ export interface IExpertTeamState {
experts: IExpertInfo[] experts: IExpertInfo[]
plan_phases: ITeamPlanPhase[] plan_phases: ITeamPlanPhase[]
lead_expert: string lead_expert: string
/** U2: 团队级任务目标摘要(可选,后端 team_formed 事件未发送时回退到首阶段描述) */
task_description?: string
} }
// ── Board Meeting 模式类型 ──────────────────────────────────────────── // ── Board Meeting 模式类型 ────────────────────────────────────────────

View File

@ -1,6 +1,25 @@
<template> <template>
<div ref="gridRef" class="calendar-grid"> <div ref="gridRef" class="calendar-grid">
<FullCalendar ref="calendarRef" :options="calendarOptions" /> <div
v-if="store.isLoading && store.events.length === 0"
class="calendar-grid__state calendar-grid__skeleton"
>
<div v-for="i in 6" :key="i" class="calendar-grid__skeleton-row" />
</div>
<div
v-else-if="store.error && store.events.length === 0"
class="calendar-grid__state calendar-grid__error"
>
<WarningOutlined class="calendar-grid__error-icon" />
<span class="calendar-grid__error-text">{{ store.error }}</span>
<a-button size="small" type="primary" @click="retry">重试</a-button>
</div>
<template v-else>
<FullCalendar ref="calendarRef" :options="calendarOptions" />
<div v-if="store.events.length === 0" class="calendar-grid__empty-hint">
暂无日程
</div>
</template>
</div> </div>
</template> </template>
@ -11,6 +30,7 @@ import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid' import timeGridPlugin from '@fullcalendar/timegrid'
import interactionPlugin from '@fullcalendar/interaction' import interactionPlugin from '@fullcalendar/interaction'
import zhCnLocale from '@fullcalendar/core/locales/zh-cn' import zhCnLocale from '@fullcalendar/core/locales/zh-cn'
import { WarningOutlined } from '@ant-design/icons-vue'
import type { import type {
CalendarOptions, CalendarOptions,
EventInput, EventInput,
@ -20,6 +40,7 @@ import type {
} from '@fullcalendar/core' } from '@fullcalendar/core'
import { useCalendarStore } from '@/stores/calendar' import { useCalendarStore } from '@/stores/calendar'
import type { ICalendarEvent } from '@/api/calendar' import type { ICalendarEvent } from '@/api/calendar'
import { eventColorToken } from '@/utils/calendarTokens'
const store = useCalendarStore() const store = useCalendarStore()
@ -35,7 +56,7 @@ const emit = defineEmits<{
function toFcEvent(ev: ICalendarEvent): EventInput { function toFcEvent(ev: ICalendarEvent): EventInput {
const eventType = store.eventTypes.find((t) => t.id === ev.event_type_id) const eventType = store.eventTypes.find((t) => t.id === ev.event_type_id)
const color = eventType?.color || '#1677ff' const color = eventColorToken(eventType?.name)
return { return {
id: ev.id, id: ev.id,
title: ev.title, title: ev.title,
@ -66,6 +87,10 @@ function handleEventClick(arg: EventClickArg): void {
emit('edit', ev) emit('edit', ev)
} }
function retry(): void {
void store.loadEvents()
}
onMounted(() => { onMounted(() => {
// ponytail: track last size to break ResizeObserver feedback loop // ponytail: track last size to break ResizeObserver feedback loop
// updateSize() can trigger another resize event, causing infinite thrash. // updateSize() can trigger another resize event, causing infinite thrash.
@ -122,15 +147,74 @@ const calendarOptions = computed<CalendarOptions>(() => ({
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
position: relative;
} }
.calendar-grid :deep(.fc) { .calendar-grid :deep(.fc) {
height: 100%; height: 100%;
} }
.calendar-grid :deep(.fc-event-invited) { .calendar-grid__state {
border-style: dashed !important; display: flex;
border-width: 2px !important; flex-direction: column;
opacity: 0.75; align-items: center;
justify-content: center;
gap: var(--space-3);
height: 100%;
padding: var(--space-8);
}
.calendar-grid__skeleton {
gap: var(--space-2);
width: 100%;
max-width: 480px;
margin: 0 auto;
}
.calendar-grid__skeleton-row {
width: 100%;
height: 28px;
border-radius: var(--radius-md);
background: linear-gradient(
90deg,
var(--border-color) 25%,
var(--bg-tertiary) 37%,
var(--border-color) 63%
);
background-size: 400% 100%;
animation: calendar-grid-shimmer 1.4s ease infinite;
}
@keyframes calendar-grid-shimmer {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
.calendar-grid__error {
color: var(--color-error);
}
.calendar-grid__error-icon {
font-size: 24px;
}
.calendar-grid__error-text {
font-size: var(--font-sm);
color: var(--text-tertiary);
text-align: center;
}
.calendar-grid__empty-hint {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--text-tertiary);
font-size: var(--font-sm);
pointer-events: none;
} }
</style> </style>

View File

@ -137,7 +137,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, type Component } from 'vue' import { ref, computed, watch, onMounted, onUnmounted, type Component } from 'vue'
import { Input as AInput, Button as AButton, Select as ASelect } from 'ant-design-vue' import { Input as AInput, Button as AButton, Select as ASelect } from 'ant-design-vue'
import { SendOutlined, TeamOutlined, UsergroupAddOutlined, PaperClipOutlined, PoweroffOutlined, CommentOutlined } from '@ant-design/icons-vue' import { SendOutlined, TeamOutlined, UsergroupAddOutlined, PaperClipOutlined, PoweroffOutlined, CommentOutlined } from '@ant-design/icons-vue'
import ContextPill from './ContextPill.vue' import ContextPill from './ContextPill.vue'
@ -146,6 +146,7 @@ import BoardMeetingModal from './BoardMeetingModal.vue'
import TeamModal from './TeamModal.vue' import TeamModal from './TeamModal.vue'
import { useSkillsStore } from '@/stores/skills' import { useSkillsStore } from '@/stores/skills'
import { useTeamStore } from '@/stores/team' import { useTeamStore } from '@/stores/team'
import { useChatStore } from '@/stores/chatStore'
import type { ISkillInfo } from '@/api/skills' import type { ISkillInfo } from '@/api/skills'
import { apiClient } from '@/api/client' import { apiClient } from '@/api/client'
import { message as antMessage } from 'ant-design-vue' import { message as antMessage } from 'ant-design-vue'
@ -238,6 +239,7 @@ const mentionStartIndex = ref(-1)
const mentionPosition = ref({ left: 0 }) const mentionPosition = ref({ left: 0 })
const skillsStore = useSkillsStore() const skillsStore = useSkillsStore()
const teamStore = useTeamStore() const teamStore = useTeamStore()
const chatStore = useChatStore()
const skillSuggestions = computed<SkillSuggestion[]>(() => { const skillSuggestions = computed<SkillSuggestion[]>(() => {
return (skillsStore.skills || []).map((s: ISkillInfo) => ({ return (skillsStore.skills || []).map((s: ISkillInfo) => ({
@ -250,6 +252,19 @@ const canSend = computed(() => {
return inputText.value.trim().length > 0 && !props.disabled return inputText.value.trim().length > 0 && !props.disabled
}) })
// U6: backfill from a UserBubble "" click. Consume then clear so a
// second click on the same message re-triggers the watcher ("" -> content).
watch(
() => chatStore.refillText,
(text) => {
if (!text) return
inputText.value = text
chatStore.clearRefillText()
const el = textareaRef.value?.$el?.querySelector('textarea') as HTMLTextAreaElement | null
el?.focus()
},
)
function handleInput(): void { function handleInput(): void {
detectMention() detectMention()
} }

View File

@ -5,6 +5,9 @@
:meta="spec.shell.meta" :meta="spec.shell.meta"
:avatar="spec.shell.avatar" :avatar="spec.shell.avatar"
:color="spec.shell.color" :color="spec.shell.color"
:expert-name="message.expert_name"
:expert-color="message.expert_color"
:streaming="message.status === 'streaming'"
> >
<component <component
:is="spec.component" :is="spec.component"

View File

@ -79,8 +79,8 @@ const currentLabel = computed(() => {
.phase-indicator__badge { .phase-indicator__badge {
display: inline-block; display: inline-block;
padding: 1px 6px; padding: 1px 6px;
background: #722ed1; background: var(--accent-board);
color: #fff; color: var(--text-inverse);
border-radius: 3px; border-radius: 3px;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
@ -104,7 +104,7 @@ const currentLabel = computed(() => {
width: 10px; width: 10px;
height: 10px; height: 10px;
border-radius: 50%; border-radius: 50%;
background: #d9d9d9; background: var(--color-gray-300);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -119,23 +119,23 @@ const currentLabel = computed(() => {
} }
.phase-indicator__dot--active { .phase-indicator__dot--active {
background: #722ed1; background: var(--accent-board);
box-shadow: 0 0 0 2px rgba(114, 46, 209, 0.2); box-shadow: 0 0 0 2px var(--accent-board-soft);
} }
.phase-indicator__dot--active .phase-indicator__dot-inner { .phase-indicator__dot--active .phase-indicator__dot-inner {
background: #fff; background: var(--text-inverse);
} }
.phase-indicator__dot--done { .phase-indicator__dot--done {
background: #52c41a; background: var(--color-success);
} }
.phase-indicator__violations { .phase-indicator__violations {
margin-left: auto; margin-left: auto;
padding: 1px 6px; padding: 1px 6px;
background: #fff1f0; background: var(--color-error-light);
color: #cf1322; color: var(--color-error);
border-radius: 3px; border-radius: 3px;
font-size: 11px; font-size: 11px;
} }

View File

@ -0,0 +1,394 @@
<template>
<div
v-if="mode"
class="sticky-mode-header"
:class="`sticky-mode-header--${mode}`"
role="status"
aria-live="polite"
>
<span class="sticky-mode-header__badge">{{ modeLabel }}</span>
<span
v-if="taskText"
class="sticky-mode-header__task"
:title="taskText"
>{{ taskText }}</span>
<ul class="sticky-mode-header__avatars">
<li
v-for="expert in visibleExperts"
:key="expert.key"
class="sticky-mode-header__avatar-item"
>
<a-popover
trigger="click"
placement="bottom"
:open="openKey === expert.key"
:overlay-class-name="`sticky-mode-header__popover sticky-mode-header__popover--${mode}`"
@open-change="(open) => handleOpenChange(expert.key, open)"
>
<template #content>
<div class="expert-detail">
<div class="expert-detail__name">{{ expert.name }}</div>
<div v-if="expert.description" class="expert-detail__desc">{{ expert.description }}</div>
<div v-if="expert.persona" class="expert-detail__persona">{{ expert.persona }}</div>
</div>
</template>
<button
:ref="(el) => setTriggerRef(expert.key, el)"
class="sticky-mode-header__avatar"
:class="{ 'sticky-mode-header__avatar--lead': expert.isLead }"
type="button"
:style="{ borderColor: expert.color }"
:aria-label="`查看 ${expert.name} 详情`"
:aria-expanded="openKey === expert.key"
>
<span class="sticky-mode-header__avatar-emoji">{{ expert.avatar }}</span>
</button>
</a-popover>
</li>
<li v-if="overflowCount > 0" class="sticky-mode-header__avatar-item">
<a-popover
trigger="click"
placement="bottom"
:open="openKey === OVERFLOW_KEY"
overlay-class-name="sticky-mode-header__popover sticky-mode-header__popover--overflow"
@open-change="(open) => handleOpenChange(OVERFLOW_KEY, open)"
>
<template #content>
<ul class="expert-list">
<li
v-for="expert in overflowExperts"
:key="expert.key"
class="expert-list__item"
>
<span
class="expert-list__avatar"
:style="{ borderColor: expert.color }"
>{{ expert.avatar }}</span>
<span class="expert-list__name">{{ expert.name }}</span>
<span v-if="expert.isLead" class="expert-list__tag">Lead</span>
</li>
</ul>
</template>
<button
:ref="(el) => setTriggerRef(OVERFLOW_KEY, el)"
class="sticky-mode-header__avatar sticky-mode-header__avatar--overflow"
type="button"
:aria-label="`还有 ${overflowCount} 位专家`"
:aria-expanded="openKey === OVERFLOW_KEY"
>
+{{ overflowCount }}
</button>
</a-popover>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { Popover as APopover } from 'ant-design-vue'
import { useTeamStore } from '@/stores/team'
import { useChatStore } from '@/stores/chatStore'
import type { IBoardExpert, IExpertInfo, ITeamPlanPhase } from '@/api/types'
const teamStore = useTeamStore()
const chatStore = useChatStore()
/** 统一的专家展示数据team / board 两种来源合并为同一形态) */
interface IExpertDisplay {
key: string
name: string
avatar: string
color: string
persona: string
description?: string
isLead?: boolean
}
const MAX_VISIBLE = 5
const OVERFLOW_KEY = '__overflow__'
type Mode = 'team' | 'board' | null
const mode = computed<Mode>(() => {
if (teamStore.isTeamMode) return 'team'
if (chatStore.boardState && chatStore.boardState.status !== 'dissolved') return 'board'
return null
})
const modeLabel = computed(() => {
if (mode.value === 'team') return '专家团'
if (mode.value === 'board') return '私董会'
return ''
})
const taskText = computed(() => {
if (mode.value === 'team') {
// task_description退 task_description name
const state = teamStore.teamState
if (state?.task_description) return state.task_description
const phase =
state?.plan_phases?.find((p: ITeamPlanPhase) => p.status === 'in_progress') ??
state?.plan_phases?.[0]
return phase?.task_description ?? phase?.name ?? ''
}
if (mode.value === 'board') {
return chatStore.boardState?.topic ?? ''
}
return ''
})
const allExperts = computed<IExpertDisplay[]>(() => {
if (mode.value === 'team') {
return teamStore.activeExperts.map((e: IExpertInfo) => ({
key: e.id,
name: e.name,
avatar: e.avatar,
color: e.color,
persona: e.persona,
description:
e.bound_skills.length > 0 ? `技能: ${e.bound_skills.join(', ')}` : undefined,
isLead: e.is_lead,
}))
}
if (mode.value === 'board') {
const list = chatStore.boardState?.experts ?? []
return list.map((e: IBoardExpert, idx: number) => ({
key: `${e.name}-${idx}`,
name: e.name,
avatar: e.avatar,
color: e.color,
persona: e.persona,
description: e.is_moderator ? '主持人' : undefined,
isLead: false,
}))
}
return []
})
const visibleExperts = computed(() => allExperts.value.slice(0, MAX_VISIBLE))
const overflowExperts = computed(() => allExperts.value.slice(MAX_VISIBLE))
const overflowCount = computed(() => overflowExperts.value.length)
// Popover
// a-popover trigger="click" Esc / body
// focus
const openKey = ref<string | null>(null)
const triggerEls = new Map<string, HTMLButtonElement>()
function setTriggerRef(key: string, el: unknown): void {
// ref <button> HTMLButtonElement | null
const dom = (el && typeof el === 'object' && '$el' in el
? (el as { $el?: HTMLButtonElement }).$el
: (el as HTMLButtonElement | null)) ?? null
if (dom) {
triggerEls.set(key, dom)
} else {
triggerEls.delete(key)
}
}
function handleOpenChange(key: string, open: boolean): void {
if (open) {
openKey.value = key
return
}
if (openKey.value === key) {
openKey.value = null
const el = triggerEls.get(key)
if (el) {
void nextTick(() => el.focus())
}
}
}
</script>
<style scoped>
.sticky-mode-header {
position: sticky;
top: 0;
z-index: var(--z-sticky);
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-4);
background: var(--bg-elevated);
border-bottom: 1px solid var(--border-color);
box-shadow: var(--shadow-sm);
}
.sticky-mode-header--team {
border-top: 2px solid var(--accent-team);
}
.sticky-mode-header--board {
border-top: 2px solid var(--accent-board);
}
.sticky-mode-header__badge {
flex-shrink: 0;
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--font-xs);
font-weight: var(--font-weight-semibold);
color: var(--text-inverse);
letter-spacing: 0.5px;
}
.sticky-mode-header--team .sticky-mode-header__badge {
background: var(--accent-team);
}
.sticky-mode-header--board .sticky-mode-header__badge {
background: var(--accent-board);
}
.sticky-mode-header__task {
flex: 1;
min-width: 0;
font-size: var(--font-sm);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sticky-mode-header__avatars {
list-style: none;
display: flex;
align-items: center;
gap: calc(-1 * var(--space-1));
margin: 0;
padding: 0 var(--space-1) 0 0;
flex-shrink: 0;
}
.sticky-mode-header__avatar-item {
display: flex;
}
.sticky-mode-header__avatar {
width: 28px;
height: 28px;
border-radius: var(--radius-full);
border: 2px solid var(--border-color);
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition:
transform var(--transition-fast),
box-shadow var(--transition-fast);
}
.sticky-mode-header__avatar:hover,
.sticky-mode-header__avatar:focus-visible {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
outline: none;
}
.sticky-mode-header__avatar--lead {
border-width: 3px;
}
.sticky-mode-header__avatar-emoji {
font-size: 14px;
line-height: 1;
}
.sticky-mode-header__avatar--overflow {
font-size: var(--font-xs);
font-weight: var(--font-weight-semibold);
color: var(--text-secondary);
background: var(--bg-tertiary);
}
/* Popover 内容(非 scoped由 overlay-class-name 挂载到 body */
:global(.sticky-mode-header__popover) {
max-width: 320px;
}
:global(.sticky-mode-header__popover .ant-popover-inner-content) {
padding: var(--space-3);
}
.expert-detail {
max-width: 280px;
}
.expert-detail__name {
font-size: var(--font-sm);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
margin-bottom: var(--space-1);
}
.expert-detail__desc {
font-size: var(--font-xs);
color: var(--text-tertiary);
margin-bottom: var(--space-1);
}
.expert-detail__persona {
font-size: var(--font-xs);
color: var(--text-secondary);
line-height: var(--leading-normal);
}
.expert-list {
list-style: none;
margin: 0;
padding: 0;
max-width: 260px;
max-height: 280px;
overflow-y: auto;
}
.expert-list__item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) 0;
}
.expert-list__avatar {
width: 22px;
height: 22px;
border-radius: var(--radius-full);
border: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
flex-shrink: 0;
}
.expert-list__name {
font-size: var(--font-sm);
color: var(--text-primary);
}
.expert-list__tag {
margin-left: auto;
padding: 0 var(--space-1);
font-size: 10px;
background: var(--accent-team);
color: var(--text-inverse);
border-radius: var(--radius-sm);
}
/* 移动端viewport<768px 隐藏任务主题文本 */
@media (max-width: 767px) {
.sticky-mode-header {
gap: var(--space-2);
padding: var(--space-2);
}
.sticky-mode-header__task {
display: none;
}
}
</style>

View File

@ -1,30 +1,103 @@
<template> <template>
<div :class="['thinking-block', { 'thinking-block--expanded': expanded }]"> <div
<div class="thinking-block__header" @click="expanded = !expanded"> v-if="content || isStreaming"
class="thinking-block"
:class="{
'thinking-block--expanded': expanded,
'thinking-block--streaming': isStreaming,
'thinking-block--loading': isLoading,
}"
>
<div class="thinking-block__header" @click="toggle">
<div class="thinking-block__title"> <div class="thinking-block__title">
<BulbOutlined class="thinking-block__icon" /> <BulbOutlined class="thinking-block__icon" />
<span class="thinking-block__label">思考过程</span> <span class="thinking-block__label">{{ isLoading ? '正在思考…' : '思考过程' }}</span>
<a-spin v-if="isStreaming" size="small" class="thinking-block__spinner" /> <a-spin v-if="isStreaming" size="small" class="thinking-block__spinner" />
</div> </div>
<RightOutlined :class="['thinking-block__expand', { 'thinking-block__expand--open': expanded }]" /> <div class="thinking-block__right">
<span v-if="!isStreaming && content" class="thinking-block__meta">{{ summary }}</span>
<RightOutlined
:class="['thinking-block__expand', { 'thinking-block__expand--open': expanded }]"
/>
</div>
</div> </div>
<div v-if="expanded" class="thinking-block__content"> <div v-if="expanded && content" class="thinking-block__content">
<div class="thinking-block__text">{{ content }}</div> <div
ref="contentRef"
class="thinking-block__text"
:class="{ 'thinking-block__text--streaming': isStreaming }"
>{{ content }}</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { BulbOutlined, RightOutlined } from '@ant-design/icons-vue' import { BulbOutlined, RightOutlined } from '@ant-design/icons-vue'
import { Spin as ASpin } from 'ant-design-vue' import { Spin as ASpin } from 'ant-design-vue'
defineProps<{ const props = defineProps<{
content: string content: string
isStreaming?: boolean isStreaming?: boolean
}>() }>()
const expanded = ref(false) // ponytail: content prop is already the accumulated stream (chatStream.ts:519),
// no extra truncation needed rendering content directly gives token-by-token effect.
const isLoading = computed(() => !!props.isStreaming && !props.content)
// Streaming: expand by default to surface live tokens. Finished: collapsed summary bar.
const expanded = ref(props.isStreaming === true)
const contentRef = ref<HTMLElement | null>(null)
const scrollTopRef = ref(0)
const startTime = ref<Date | null>(null)
watch(
() => props.content,
(val) => {
if (val && !startTime.value) {
startTime.value = new Date()
}
},
{ immediate: true },
)
// Auto-collapse to summary bar when streaming finishes (true -> false).
watch(
() => props.isStreaming,
(now, prev) => {
if (prev && !now && props.content) {
expanded.value = false
}
},
)
const summary = computed(() => {
const time = startTime.value
const timeStr = time
? `${pad2(time.getHours())}:${pad2(time.getMinutes())}:${pad2(time.getSeconds())}`
: ''
return `${timeStr} · ${props.content.length} 字符`
})
function pad2(n: number): string {
return String(n).padStart(2, '0')
}
function toggle(): void {
// Preserve scroll position before collapsing (contentRef still mounted).
if (expanded.value && contentRef.value) {
scrollTopRef.value = contentRef.value.scrollTop
}
expanded.value = !expanded.value
// Restore scroll position after expanding (DOM re-rendered via v-if).
if (expanded.value) {
nextTick(() => {
if (contentRef.value) {
contentRef.value.scrollTop = scrollTopRef.value
}
})
}
}
</script> </script>
<style scoped> <style scoped>
@ -72,6 +145,18 @@ const expanded = ref(false)
margin-left: var(--space-1, 4px); margin-left: var(--space-1, 4px);
} }
.thinking-block__right {
display: flex;
align-items: center;
gap: var(--space-2, 8px);
}
.thinking-block__meta {
color: var(--text-placeholder, #bfbfbf);
font-size: var(--font-xs, 12px);
font-variant-numeric: tabular-nums;
}
.thinking-block__expand { .thinking-block__expand {
font-size: 10px; font-size: 10px;
color: var(--text-placeholder, #bfbfbf); color: var(--text-placeholder, #bfbfbf);
@ -96,4 +181,27 @@ const expanded = ref(false)
max-height: 300px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
} }
/* Streaming cursor: blinking caret at the tail of accumulated text. */
.thinking-block__text--streaming::after {
content: '';
display: inline-block;
width: 7px;
height: 1em;
margin-left: 3px;
background: var(--color-warning, #faad14);
vertical-align: text-bottom;
animation: thinking-block-blink 1s step-end infinite;
}
@keyframes thinking-block-blink {
0%,
50% {
opacity: 1;
}
50.01%,
100% {
opacity: 0;
}
}
</style> </style>

View File

@ -101,7 +101,10 @@ export function useMessageRenderer(message: IChatMessage) {
type, type,
shell: { name: '用户', meta: time }, shell: { name: '用户', meta: time },
component: UserBubble, component: UserBubble,
props: { content: message.content || '' }, // msgId enables the hover action toolbar (copy/delete/refill, U6);
// preview scenes that render UserBubble without a real message id
// simply get no toolbar.
props: { content: message.content || '', msgId: message.id },
} }
case 'team_plan': { case 'team_plan': {

View File

@ -1,5 +1,9 @@
<template> <template>
<div class="assistant-text"> <div
class="assistant-text"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<ThinkingBlock <ThinkingBlock
v-if="message.thinking" v-if="message.thinking"
:content="message.thinking" :content="message.thinking"
@ -29,7 +33,11 @@
<a-spin size="small" /> <a-spin size="small" />
</div> </div>
<div v-if="showRouting" class="assistant-text__routing"> <div
v-show="showRouting"
class="assistant-text__routing"
:class="{ 'assistant-text__routing--visible': isHovered }"
>
<a-tag color="purple"> <a-tag color="purple">
<ThunderboltOutlined /> {{ message.matched_skill }} <ThunderboltOutlined /> {{ message.matched_skill }}
</a-tag> </a-tag>
@ -82,6 +90,8 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
const markdownRef = ref<HTMLElement | null>(null) const markdownRef = ref<HTMLElement | null>(null)
// U5: v-show DOM
const isHovered = ref(false)
const md = new MarkdownIt({ const md = new MarkdownIt({
html: false, html: false,
@ -416,6 +426,14 @@ watch(renderedContent, () => {
display: flex; display: flex;
gap: var(--space-2); gap: var(--space-2);
flex-wrap: wrap; flex-wrap: wrap;
opacity: 0;
transition: opacity var(--transition-normal);
pointer-events: none;
}
.assistant-text__routing--visible {
opacity: 1;
pointer-events: auto;
} }
.assistant-text__routing :deep(.ant-tag) { .assistant-text__routing :deep(.ant-tag) {

View File

@ -19,8 +19,19 @@
</div> </div>
<div class="message-shell__body"> <div class="message-shell__body">
<div class="message-shell__header"> <div class="message-shell__header">
<span class="message-shell__name">{{ name }}</span> <!-- U4 R10: 专家身份 badge expert_color 高亮 expert_name流式期间及结束后均保留 -->
<span
v-if="expertName"
class="message-shell__expert-badge"
:style="{ backgroundColor: expertColor || '#1890ff' }"
>{{ expertName }}</span>
<span v-else class="message-shell__name">{{ name }}</span>
<span v-if="meta" class="message-shell__meta">{{ meta }}</span> <span v-if="meta" class="message-shell__meta">{{ meta }}</span>
<span
v-if="streaming"
class="message-shell__streaming-indicator"
aria-label="正在生成"
></span>
</div> </div>
<div class="message-shell__content"> <div class="message-shell__content">
<slot /> <slot />
@ -39,6 +50,12 @@ interface Props {
meta?: string meta?: string
avatar?: string avatar?: string
color?: string color?: string
/** U4 R10: 专家身份 badge 名称 — 存在时渲染为彩色 badge 替代普通 name 文本 */
expertName?: string
/** U4 R10: 专家身份 badge 颜色 */
expertColor?: string
/** U4: 流式进行中 — 显示省略号指示器 */
streaming?: boolean
} }
withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
@ -46,6 +63,9 @@ withDefaults(defineProps<Props>(), {
meta: undefined, meta: undefined,
avatar: undefined, avatar: undefined,
color: undefined, color: undefined,
expertName: undefined,
expertColor: undefined,
streaming: false,
}) })
</script> </script>
@ -130,10 +150,39 @@ withDefaults(defineProps<Props>(), {
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
} }
/* U4 R10: 专家身份 badge — 彩色 pill区别于普通 name 文本与 avatar */
.message-shell__expert-badge {
display: inline-flex;
align-items: center;
padding: 0 var(--space-2);
border-radius: var(--radius-full);
color: var(--text-inverse, #fff);
font-size: var(--font-xs);
font-weight: var(--font-weight-semibold);
line-height: 1.6;
max-width: 12em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-shell__meta { .message-shell__meta {
color: var(--text-placeholder); color: var(--text-placeholder);
} }
/* U4: 流式进行中指示器 */
.message-shell__streaming-indicator {
color: var(--text-placeholder);
font-weight: var(--font-weight-semibold);
animation: shellBlink 1s steps(2, start) infinite;
}
@keyframes shellBlink {
to {
opacity: 0.2;
}
}
.message-shell__content { .message-shell__content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -1,23 +1,86 @@
<template> <template>
<div class="user-bubble"> <div
ref="rootRef"
class="user-bubble"
:class="{ 'user-bubble--focusable': msgId }"
:tabindex="msgId ? 0 : undefined"
@mouseenter="hovered = true"
@mouseleave="hovered = false"
@focus="focused = true"
@blur="focused = false"
@pointerdown="onPointerDown"
@keydown.enter.prevent="onCopy"
>
<FileAttachment <FileAttachment
v-if="fileAttachment" v-if="fileAttachment"
:filename="fileAttachment.filename" :filename="fileAttachment.filename"
:url="fileAttachment.url" :url="fileAttachment.url"
/> />
<span v-else>{{ content }}</span> <span v-else class="user-bubble__text">{{ content }}</span>
<div
v-if="actionsVisible"
class="user-bubble__actions"
@click.stop
@pointerdown.stop
>
<button
class="ub-action"
:class="{ 'ub-action--active': copied }"
type="button"
tabindex="-1"
title="复制"
aria-label="复制"
@click="onCopy"
>
<CopyOutlined />
</button>
<a-tooltip v-if="hasReply" title="该消息已有回复,无法删除">
<span class="ub-action ub-action--disabled" aria-disabled="true">
<DeleteOutlined />
</span>
</a-tooltip>
<a-popconfirm
v-else
title="确定删除该消息?"
ok-text="删除"
cancel-text="取消"
@confirm="onDelete"
>
<button class="ub-action" type="button" tabindex="-1" title="删除" aria-label="删除">
<DeleteOutlined />
</button>
</a-popconfirm>
<button
class="ub-action"
type="button"
tabindex="-1"
title="回填到输入框"
aria-label="回填到输入框"
@click="onRefill"
>
<EditOutlined />
</button>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { CopyOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons-vue'
import FileAttachment from './FileAttachment.vue' import FileAttachment from './FileAttachment.vue'
import { useChatStore, nextMessageIsAssistant } from '@/stores/chatStore'
interface Props { interface Props {
content: string content: string
/** Message id. When absent (preview scenes), no action toolbar is shown. */
msgId?: string
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const chatStore = useChatStore()
const FILE_MARKDOWN_RE = /^\[\]\s*\[(.+?)\]\((.+?)\)$/s const FILE_MARKDOWN_RE = /^\[\]\s*\[(.+?)\]\((.+?)\)$/s
@ -29,10 +92,96 @@ const fileAttachment = computed(() => {
url: match[2], url: match[2],
} }
}) })
// --- Action toolbar visibility ---
// Three independent reasons the toolbar can stay open; the toolbar shows
// while ANY is active. This avoids the classic hover-toolbar bug where
// clicking a button blurs the bubble and hides the toolbar before the click
// lands: on click, `focused` flips false but `hovered` stays true (mouse
// hasn't left the bubble), so the toolbar survives. Action buttons carry
// tabindex="-1" so the keyboard model is Tabbubble (Enter copies), not a
// focus trap into the buttons.
const hovered = ref(false)
const focused = ref(false)
const touched = ref(false)
const showActions = computed(
() => hovered.value || focused.value || touched.value,
)
const actionsVisible = computed(() => !!props.msgId && showActions.value)
// --- Copy feedback ---
const copied = ref(false)
let copiedTimer: ReturnType<typeof setTimeout> | null = null
function scheduleCopiedReset(): void {
if (copiedTimer) clearTimeout(copiedTimer)
copiedTimer = setTimeout(() => {
copied.value = false
copiedTimer = null
}, 1200)
}
async function onCopy(): Promise<void> {
try {
await navigator.clipboard.writeText(props.content)
copied.value = true
scheduleCopiedReset()
} catch {
// clipboard unavailable (e.g. insecure context) fail silently
}
}
// --- Delete (frontend-only hide) ---
const hasReply = computed(() =>
props.msgId ? nextMessageIsAssistant(chatStore.currentMessages, props.msgId) : false,
)
function onDelete(): void {
const convId = chatStore.currentConversationId
if (!convId || !props.msgId) return
chatStore.deleteMessage(convId, props.msgId)
}
// --- Refill input ---
function onRefill(): void {
chatStore.setRefillText(props.content)
}
// --- Touch parity ---
// Touch has no hover; tap on the bubble toggles the toolbar, tap elsewhere
// hides it. ponytail: a single passive document touchstart listener covers
// the "tap elsewhere" case without per-show add/remove churn.
const rootRef = ref<HTMLElement | null>(null)
function onPointerDown(event: PointerEvent): void {
if (event.pointerType === 'touch') {
touched.value = !touched.value
}
}
function onDocTouchStart(event: TouchEvent): void {
const target = event.target as Node | null
if (rootRef.value && target && !rootRef.value.contains(target)) {
touched.value = false
}
}
onMounted(() => {
document.addEventListener('touchstart', onDocTouchStart, { passive: true })
})
onUnmounted(() => {
document.removeEventListener('touchstart', onDocTouchStart)
if (copiedTimer) {
clearTimeout(copiedTimer)
copiedTimer = null
}
})
</script> </script>
<style scoped> <style scoped>
.user-bubble { .user-bubble {
position: relative;
background: var(--bg-tertiary); background: var(--bg-tertiary);
color: var(--text-primary); color: var(--text-primary);
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
@ -42,4 +191,70 @@ const fileAttachment = computed(() => {
max-width: 60%; max-width: 60%;
word-break: break-word; word-break: break-word;
} }
.user-bubble--focusable {
outline: none;
}
.user-bubble--focusable:focus-visible {
outline: 2px solid var(--accent-primary, #1677ff);
outline-offset: 2px;
}
.user-bubble__text {
white-space: pre-wrap;
}
.user-bubble__actions {
position: absolute;
top: 50%;
right: calc(100% + var(--space-2));
transform: translateY(-50%);
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-1);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
z-index: 5;
}
.ub-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
background: transparent;
color: var(--text-tertiary);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 14px;
line-height: 1;
transition: color 0.15s ease, background-color 0.15s ease;
}
.ub-action:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.ub-action--active {
color: #52c41a;
}
.ub-action--disabled {
opacity: 0.45;
cursor: not-allowed;
color: var(--text-placeholder);
}
.ub-action--disabled:hover {
color: var(--text-placeholder);
background: transparent;
}
</style> </style>

View File

@ -16,6 +16,21 @@ function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
} }
/**
* Pure selector: does the message immediately after `msgId` in `messages`
* have role 'assistant'? Used by UserBubble to disable delete when a reply
* already follows the user message. Exported so it can be unit-tested
* without mounting a component.
*/
export function nextMessageIsAssistant(
messages: IChatMessage[],
msgId: string,
): boolean {
const idx = messages.findIndex((m) => m.id === msgId);
if (idx === -1 || idx === messages.length - 1) return false;
return messages[idx + 1].role === "assistant";
}
export const useChatStore = defineStore("chat", () => { export const useChatStore = defineStore("chat", () => {
// --- State (chatStore-owned) --- // --- State (chatStore-owned) ---
const conversations = ref<IConversation[]>([]); const conversations = ref<IConversation[]>([]);
@ -24,6 +39,10 @@ export const useChatStore = defineStore("chat", () => {
// current conversation being in this set, so other tabs remain usable. // current conversation being in this set, so other tabs remain usable.
const pendingConversations = ref<Set<string>>(new Set()); const pendingConversations = ref<Set<string>>(new Set());
const pendingLastUsedAt = ref<Map<string, number>>(new Map()); const pendingLastUsedAt = ref<Map<string, number>>(new Map());
// Text to backfill into ChatInput (U6). Consumed and cleared by ChatInput's
// watcher; re-clicking refill re-sets this from "" -> content so the watch
// fires even for the same content.
const refillText = ref<string>("");
let _is404Recovering = false; let _is404Recovering = false;
// --- Message helpers (chatStore-owned, shared with chatStream) --- // --- Message helpers (chatStore-owned, shared with chatStream) ---
@ -461,12 +480,36 @@ export const useChatStore = defineStore("chat", () => {
await sendWsMessage(content); await sendWsMessage(content);
} }
/**
* Frontend-only hide of a single user message (U6). Removes it from the
* conversation's local message list without touching the server copy
* the server keeps its history intact for re-sync. Mirrors appendMessage's
* direct-mutation pattern so Vue's deep reactivity notifies watchers.
*/
function deleteMessage(conversationId: string, msgId: string): void {
const conv = conversations.value.find((c) => c.id === conversationId);
if (!conv || !Array.isArray(conv.messages)) return;
conv.messages = conv.messages.filter((m) => m.id !== msgId);
conv.updated_at = new Date().toISOString();
}
/** Backfill `text` into ChatInput (U6). ChatInput consumes then clears it. */
function setRefillText(text: string): void {
refillText.value = text;
}
/** Clear the backfill channel so a subsequent set re-triggers watchers. */
function clearRefillText(): void {
refillText.value = "";
}
return { return {
conversations, conversations,
currentConversationId, currentConversationId,
isWsConnected: socket.isWsConnected, isWsConnected: socket.isWsConnected,
ws: socket.ws, ws: socket.ws,
pendingConversations, pendingConversations,
refillText,
// Stream-owned state (re-exported for component compat) // Stream-owned state (re-exported for component compat)
streamingStepsByConv: stream.streamingStepsByConv, streamingStepsByConv: stream.streamingStepsByConv,
boardState: stream.boardState, boardState: stream.boardState,
@ -488,6 +531,9 @@ export const useChatStore = defineStore("chat", () => {
selectConversation, selectConversation,
createConversation, createConversation,
deleteConversation, deleteConversation,
deleteMessage,
setRefillText,
clearRefillText,
sendMessage, sendMessage,
sendWsMessage, sendWsMessage,
resendLastUserMessage, resendLastUserMessage,

View File

@ -87,6 +87,17 @@ function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
} }
// ponytail: reverse-find helper avoids [...messages].reverse() allocation on per-token hot path
function findLastMessage(
messages: IChatMessage[],
predicate: (m: IChatMessage) => boolean,
): IChatMessage | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
if (predicate(messages[i])) return messages[i]
}
return undefined
}
// ── ChatStreamState: deps bag for dispatchWsEvent ────────────────────── // ── ChatStreamState: deps bag for dispatchWsEvent ──────────────────────
/** /**
@ -674,25 +685,112 @@ export function dispatchWsEvent(
break; break;
} }
case "expert_result": { case "expert_result_chunk": {
// U4: 流式 token chunk — 首次创建 streaming 占位,后续累加 content。
// 每个 expert_id 维护独立 streaming slotkeyed by expert_id并发专家
// 渲染为独立并行 streaming 消息,按 first-chunk 到达时间排序。
const conversationId = state.resolveIncomingConvId(); const conversationId = state.resolveIncomingConvId();
if (!conversationId) break; if (!conversationId) break;
const conv = state.conversations.value.find( const conv = state.conversations.value.find(
(c) => c.id === conversationId, (c) => c.id === conversationId,
); );
if (!conv) break; if (!conv) break;
const expertMsg: IChatMessage = { const existing = findLastMessage(
id: generateId(), conv.messages,
role: "assistant", (m) =>
content: event.data.content || "", m.expert_id === event.data.expert_id && m.status === "streaming",
timestamp: new Date().toISOString(), );
status: "completed", if (existing) {
expert_id: event.data.expert_id, state.updateMessage(conversationId, existing.id, {
expert_name: event.data.expert_name, content: (existing.content || "") + (event.data.content || ""),
expert_color: event.data.expert_color, });
message_type: "chat", } else {
}; // chunk 不含 name/color — 从 teamStore 解析身份标识用于 badge 渲染。
state.appendMessage(conversationId, expertMsg); // ponytail: 找不到则用 expert_id 作为 name 兜底,等 expert_result 终结事件
// 到达时再补全真实 name/color。
const teamStore = state.getTeamStore();
const expert = teamStore?.teamState?.experts.find(
(e) => e.id === event.data.expert_id,
);
const placeholderMsg: IChatMessage = {
id: generateId(),
role: "assistant",
content: event.data.content || "",
timestamp: new Date().toISOString(),
status: "streaming",
expert_id: event.data.expert_id,
expert_name: expert?.name || event.data.expert_id,
expert_color: expert?.color || "#1890ff",
message_type: "chat",
};
state.appendMessage(conversationId, placeholderMsg);
}
break;
}
case "expert_result_chunk_reset": {
// U3 retry 合约: 重试前清空已累积 content重置 streaming 状态。
const conversationId = state.resolveIncomingConvId();
if (!conversationId) break;
const conv = state.conversations.value.find(
(c) => c.id === conversationId,
);
if (!conv) break;
const existing = findLastMessage(
conv.messages,
(m) =>
m.expert_id === event.data.expert_id && m.status === "streaming",
);
if (existing) {
state.updateMessage(conversationId, existing.id, {
content: "",
status: "streaming",
});
}
break;
}
case "expert_result": {
// U4: 最终完整结果 — 覆盖累积 content标记 completed/error。
// 若存在 streaming 占位则 update否则 append向后兼容无 chunk 的场景)。
const conversationId = state.resolveIncomingConvId();
if (!conversationId) break;
const conv = state.conversations.value.find(
(c) => c.id === conversationId,
);
if (!conv) break;
const isError = event.data.status === "error";
const finalStatus = isError ? "error" : "completed";
const existing = findLastMessage(
conv.messages,
(m) =>
m.expert_id === event.data.expert_id && m.status === "streaming",
);
if (existing) {
state.updateMessage(conversationId, existing.id, {
content: event.data.content || "",
status: finalStatus,
expert_name: event.data.expert_name || existing.expert_name,
expert_color: event.data.expert_color || existing.expert_color,
...(isError
? { message_type: "error", error_detail: event.data.error }
: {}),
});
} else {
const expertMsg: IChatMessage = {
id: generateId(),
role: "assistant",
content: event.data.content || "",
timestamp: new Date().toISOString(),
status: finalStatus,
expert_id: event.data.expert_id,
expert_name: event.data.expert_name,
expert_color: event.data.expert_color,
message_type: isError ? "error" : "chat",
...(isError ? { error_detail: event.data.error } : {}),
};
state.appendMessage(conversationId, expertMsg);
}
break; break;
} }
@ -768,22 +866,65 @@ export function dispatchWsEvent(
break; break;
} }
case "team_synthesis": { case "team_synthesis_chunk": {
// U4: 团队综合流式 chunk — 首次创建 streaming 占位,后续累加。
const conversationId = state.resolveIncomingConvId(); const conversationId = state.resolveIncomingConvId();
if (!conversationId) break; if (!conversationId) break;
const conv = state.conversations.value.find( const conv = state.conversations.value.find(
(c) => c.id === conversationId, (c) => c.id === conversationId,
); );
if (!conv) break; if (!conv) break;
const synthesisMsg: IChatMessage = { const existing = findLastMessage(
id: generateId(), conv.messages,
role: "assistant", (m) => m.message_type === "milestone" && m.status === "streaming",
content: event.data.content || "", );
timestamp: new Date().toISOString(), if (existing) {
status: "completed", state.updateMessage(conversationId, existing.id, {
message_type: "milestone", content: (existing.content || "") + (event.data.chunk || ""),
}; });
state.appendMessage(conversationId, synthesisMsg); } else {
const synthMsg: IChatMessage = {
id: generateId(),
role: "assistant",
content: event.data.chunk || "",
timestamp: new Date().toISOString(),
status: "streaming",
message_type: "milestone",
};
state.appendMessage(conversationId, synthMsg);
}
break;
}
case "team_synthesis": {
// U4: 最终综合结果 — 覆盖累积 content标记 completed。
// 若存在 streaming 占位则 update否则 append向后兼容
const conversationId = state.resolveIncomingConvId();
if (!conversationId) break;
const conv = state.conversations.value.find(
(c) => c.id === conversationId,
);
if (!conv) break;
const existing = findLastMessage(
conv.messages,
(m) => m.message_type === "milestone" && m.status === "streaming",
);
if (existing) {
state.updateMessage(conversationId, existing.id, {
content: event.data.content || "",
status: "completed",
});
} else {
const synthesisMsg: IChatMessage = {
id: generateId(),
role: "assistant",
content: event.data.content || "",
timestamp: new Date().toISOString(),
status: "completed",
message_type: "milestone",
};
state.appendMessage(conversationId, synthesisMsg);
}
break; break;
} }

View File

@ -0,0 +1,152 @@
/**
* FullCalendar Notion-style overrides (U7).
*
* ponytail: covers only the 4 stable FC class groups named in the U7 plan
* (.fc-toolbar, .fc-col-header, .fc-day-today, .fc-event) plus FC's own CSS
* variables. Accept the maintenance cost when FC upgrades its class names.
* All colors resolve to tokens.css custom properties no hardcoded hex.
*/
/* ── FC CSS variable bridge to design tokens ── */
.fc {
--fc-border-color: var(--border-color);
--fc-page-bg-color: var(--bg-primary);
--fc-neutral-bg-color: var(--bg-tertiary);
--fc-today-bg-color: var(--color-primary-light);
--fc-now-indicator-color: var(--color-error);
font-family: inherit;
color: var(--text-primary);
background: var(--bg-primary);
}
/* ── .fc-toolbar: button style aligns with main UI ── */
.fc-toolbar .fc-button {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
border-radius: var(--radius-md);
font-size: var(--font-sm);
font-weight: var(--font-weight-medium);
text-transform: none;
box-shadow: none;
padding: 4px 12px;
transition:
background var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
}
.fc-toolbar .fc-button:hover,
.fc-toolbar .fc-button:focus {
background: var(--bg-tertiary);
border-color: var(--border-color-hover);
color: var(--text-primary);
box-shadow: none;
}
.fc-toolbar .fc-button.fc-button-active {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--text-inverse);
}
.fc-toolbar .fc-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.fc-toolbar .fc-button-primary:not(:disabled).fc-button-active:focus {
box-shadow: none;
}
.fc-toolbar h2.fc-toolbar-title {
font-size: var(--font-md);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
/* ── .fc-col-header: weekday header row ── */
.fc .fc-col-header-cell {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.fc .fc-col-header-cell-cushion {
color: var(--text-tertiary);
font-size: var(--font-xs);
font-weight: var(--font-weight-medium);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: var(--space-2) 0;
text-decoration: none;
}
/* ── .fc-day-today: today highlight ── */
.fc .fc-daygrid-day.fc-day-today,
.fc .fc-timegrid-col.fc-day-today {
background: var(--color-primary-light) !important;
}
.fc .fc-daygrid-day.fc-day-today .fc-daygrid-day-number {
color: var(--color-primary);
font-weight: var(--font-weight-semibold);
}
/* ── .fc-event: event card typography + border ── */
.fc-event {
border-radius: var(--radius-sm);
font-size: var(--font-xs);
font-weight: var(--font-weight-medium);
line-height: var(--leading-tight);
padding: 1px 4px;
border-width: 1px;
cursor: pointer;
}
.fc-event.fc-event-invited {
border-style: dashed !important;
border-width: 2px !important;
opacity: 0.75;
}
.fc .fc-daygrid-event {
margin-top: 2px;
}
.fc .fc-daygrid-day-events {
margin-top: var(--space-1);
}
/* ── Dark theme ── */
[data-theme='dark'] .fc {
--fc-page-bg-color: var(--bg-primary);
--fc-neutral-bg-color: var(--bg-tertiary);
--fc-today-bg-color: var(--color-primary-light);
}
[data-theme='dark'] .fc-toolbar .fc-button {
background: var(--bg-tertiary);
border-color: var(--border-color);
color: var(--text-secondary);
}
[data-theme='dark'] .fc-toolbar .fc-button:hover,
[data-theme='dark'] .fc-toolbar .fc-button:focus {
background: var(--bg-elevated);
border-color: var(--border-color-hover);
color: var(--text-primary);
}
[data-theme='dark'] .fc-toolbar .fc-button.fc-button-active {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--text-inverse);
}
[data-theme='dark'] .fc .fc-col-header-cell {
background: var(--bg-secondary);
}
[data-theme='dark'] .fc .fc-daygrid-day.fc-day-today {
background: var(--color-primary-light) !important;
}

View File

@ -7,4 +7,5 @@
import './tokens.css' import './tokens.css'
import './transitions.css' import './transitions.css'
import './responsive.css' import './responsive.css'
import './calendar-overrides.css'
export { themeConfig } from './theme' export { themeConfig } from './theme'

View File

@ -0,0 +1,36 @@
/**
* Calendar event-type design token mapping (U7).
*
* ponytail: IEventType.name is user-defined (no backend enum), so we match
* keywords (CN + EN, case-insensitive) against the 5 semantic categories
* from the U7 plan. Ceiling: a type named "团队 standby" gets the team token
* even though "standby" isn't semantic acceptable, the token is still a
* sane color. Upgrade path: add an explicit `category` field to IEventType
* when the backend seeds canonical types.
*
* Order matters: the first matching rule wins. `task` is last because its
* keywords (/) rarely overlap with the semantic categories above.
*/
const EVENT_TYPE_TOKEN_RULES: ReadonlyArray<readonly [RegExp, string]> = [
[/(team|团队|专家)/, 'var(--accent-team)'],
[/(board|私董|董事)/, 'var(--accent-board)'],
[/(reminder|提醒)/, 'var(--color-warning)'],
[/(system|系统)/, 'var(--text-tertiary)'],
[/(task|任务|工作)/, 'var(--color-primary)'],
]
const DEFAULT_EVENT_TOKEN = 'var(--color-primary)'
/**
* Resolve a calendar event type name to a Notion-style design token.
* Returns a CSS `var(...)` reference never a hardcoded hex color.
*/
export function eventColorToken(name: string | null | undefined): string {
if (!name) return DEFAULT_EVENT_TOKEN
const lower = name.toLowerCase()
for (const [re, token] of EVENT_TYPE_TOKEN_RULES) {
if (re.test(lower)) return token
}
return DEFAULT_EVENT_TOKEN
}

View File

@ -18,8 +18,7 @@
</a-empty> </a-empty>
</div> </div>
<template v-else> <template v-else>
<ExpertTeamView /> <StickyModeHeader />
<BoardStatusView />
<PhaseIndicator /> <PhaseIndicator />
<div class="chat-view__content" ref="messagesContainer"> <div class="chat-view__content" ref="messagesContainer">
<div class="chat-view__content-inner"> <div class="chat-view__content-inner">
@ -107,8 +106,7 @@ import { useChatStore } from '@/stores/chatStore'
import ChatSidebar from '@/components/chat/ChatSidebar.vue' import ChatSidebar from '@/components/chat/ChatSidebar.vue'
import ChatMessage from '@/components/chat/ChatMessage.vue' import ChatMessage from '@/components/chat/ChatMessage.vue'
import ChatInput from '@/components/chat/ChatInput.vue' import ChatInput from '@/components/chat/ChatInput.vue'
import ExpertTeamView from '@/components/chat/ExpertTeamView.vue' import StickyModeHeader from '@/components/chat/StickyModeHeader.vue'
import BoardStatusView from '@/components/chat/BoardStatusView.vue'
import PhaseIndicator from '@/components/chat/PhaseIndicator.vue' import PhaseIndicator from '@/components/chat/PhaseIndicator.vue'
const ATypographyText = ATypography.Text const ATypographyText = ATypography.Text

View File

@ -0,0 +1,162 @@
/**
* AssistantText (U5)
*
*
* - tag
* - tag
* - tag
* - matched_skill tag
* - v-show DOM
*
* happy-dom CSS transition"淡入/淡出"
* `assistant-text__routing--visible` class "平滑无闪烁"
* DOM v-show v-ifhover
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, type App } from 'vue'
import AssistantText from '@/components/chat/messages/AssistantText.vue'
import type { IChatMessage } from '@/api/types'
// ── Mocks ────────────────────────────────────────────────────────────
// 路由标签可见性逻辑不依赖 markdown 渲染mock 掉重量级依赖以隔离逻辑、
// 加快测试。ant-design-vue 的 Tag/Spin 与图标保留真实加载happy-dom 可渲染)。
vi.mock('markdown-it', () => {
const md = {
render: (s: string) => s,
utils: { escapeHtml: (s: string) => s },
renderer: { rules: {} as Record<string, unknown> },
}
return { default: vi.fn(() => md) }
})
vi.mock('dompurify', () => ({
default: { sanitize: (html: string) => html },
}))
vi.mock('highlight.js/lib/core', () => ({
default: {
registerLanguage: vi.fn(),
getLanguage: vi.fn(() => null),
highlight: vi.fn(() => ({ value: '' })),
},
}))
// ── Fixtures & helpers ───────────────────────────────────────────────
function makeMessage(overrides: Partial<IChatMessage> = {}): IChatMessage {
return {
id: 'msg-1',
role: 'assistant',
content: '你好',
timestamp: '2026-07-01T00:00:00.000Z',
status: 'completed',
matched_skill: 'react',
routing_method: 'semantic',
confidence: 0.9,
...overrides,
}
}
interface Mounted {
container: HTMLElement
root: HTMLElement
app: App
unmount: () => void
}
function mountAssistantText(message: IChatMessage): Mounted {
const container = document.createElement('div')
document.body.appendChild(container)
const app = createApp({
render: () => h(AssistantText, { message }),
})
app.mount(container)
const root = container.querySelector('.assistant-text') as HTMLElement
return { container, root, app, unmount: () => { app.unmount(); container.remove() } }
}
function hover(el: HTMLElement): void {
el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: false }))
}
function leave(el: HTMLElement): void {
el.dispatchEvent(new MouseEvent('mouseleave', { bubbles: false }))
}
function getRouting(container: HTMLElement): HTMLElement {
return container.querySelector('.assistant-text__routing') as HTMLElement
}
// ── Tests ────────────────────────────────────────────────────────────
describe('AssistantText — 路由标签悬停显示 (U5)', () => {
let mounted: Mounted | null = null
afterEach(() => {
mounted?.unmount()
mounted = null
})
it('默认状态路由 tag 不可见', () => {
mounted = mountAssistantText(makeMessage())
const routing = getRouting(mounted.container)
// v-show 保留 DOM非 v-if但默认 opacity:0 → 无 --visible class
expect(routing).not.toBeNull()
expect(routing.classList.contains('assistant-text__routing--visible')).toBe(false)
})
it('鼠标悬停助手消息时 tag 淡入显示', async () => {
mounted = mountAssistantText(makeMessage())
const routing = getRouting(mounted.container)
expect(routing.classList.contains('assistant-text__routing--visible')).toBe(false)
hover(mounted.root)
await nextTick()
expect(routing.classList.contains('assistant-text__routing--visible')).toBe(true)
})
it('鼠标移开时 tag 淡出', async () => {
mounted = mountAssistantText(makeMessage())
hover(mounted.root)
await nextTick()
const routing = getRouting(mounted.container)
expect(routing.classList.contains('assistant-text__routing--visible')).toBe(true)
leave(mounted.root)
await nextTick()
expect(routing.classList.contains('assistant-text__routing--visible')).toBe(false)
})
it('无 matched_skill 的消息悬停时也不显示 tag', async () => {
mounted = mountAssistantText(makeMessage({ matched_skill: undefined }))
const routing = getRouting(mounted.container)
// v-show=false → display:none
expect(routing.style.display).toBe('none')
hover(mounted.root)
await nextTick()
// 即使悬停,因无路由信息仍隐藏
expect(routing.style.display).toBe('none')
})
it('淡入淡出过渡平滑无闪烁v-show 保留 DOM无重挂载', async () => {
mounted = mountAssistantText(makeMessage())
const routingBefore = getRouting(mounted.container)
hover(mounted.root)
await nextTick()
const routingHovered = getRouting(mounted.container)
// hover 前后同一节点 → 未重挂载
expect(routingHovered).toBe(routingBefore)
leave(mounted.root)
await nextTick()
const routingLeft = getRouting(mounted.container)
expect(routingLeft).toBe(routingBefore)
})
})

View File

@ -0,0 +1,122 @@
/**
* Unit tests for CalendarGrid U7 token redesign.
*
* Covers:
* - eventColorToken() maps event type name design token (no hardcoded hex)
* - calendar-overrides.css consumes tokens for the 4 stable FC class groups
* - CalendarGrid.vue no longer references the legacy #1677ff fallback and
* wires empty / error / loading states via tokens
*
* ponytail: project has no @vue/test-utils, so we test the extracted pure
* mapper + static source assertions instead of rendering the component.
* FullCalendar view switching / drag interactions are covered by E2E.
*/
import { describe, expect, it } from 'vitest'
import { eventColorToken } from '@/utils/calendarTokens'
// ponytail: ?raw import returns Vue source as string for static assertions
import calendarGridVue from '@/components/calendar/CalendarGrid.vue?raw'
describe('eventColorToken — U7 event_type → token mapping', () => {
it('maps team keywords to --accent-team', () => {
expect(eventColorToken('团队会议')).toBe('var(--accent-team)')
expect(eventColorToken('Team Standup')).toBe('var(--accent-team)')
expect(eventColorToken('专家讨论')).toBe('var(--accent-team)')
})
it('maps board keywords to --accent-board', () => {
expect(eventColorToken('私董会')).toBe('var(--accent-board)')
expect(eventColorToken('Board Meeting')).toBe('var(--accent-board)')
expect(eventColorToken('董事会')).toBe('var(--accent-board)')
})
it('maps reminder keywords to --color-warning', () => {
expect(eventColorToken('提醒')).toBe('var(--color-warning)')
expect(eventColorToken('Reminder')).toBe('var(--color-warning)')
})
it('maps system keywords to --text-tertiary', () => {
expect(eventColorToken('系统')).toBe('var(--text-tertiary)')
expect(eventColorToken('System Event')).toBe('var(--text-tertiary)')
})
it('maps task keywords to --color-primary', () => {
expect(eventColorToken('任务')).toBe('var(--color-primary)')
expect(eventColorToken('Task')).toBe('var(--color-primary)')
expect(eventColorToken('工作')).toBe('var(--color-primary)')
})
it('returns --color-primary for empty / null / unknown', () => {
expect(eventColorToken(null)).toBe('var(--color-primary)')
expect(eventColorToken(undefined)).toBe('var(--color-primary)')
expect(eventColorToken('')).toBe('var(--color-primary)')
expect(eventColorToken('随机类型')).toBe('var(--color-primary)')
})
it('matching is case-insensitive', () => {
expect(eventColorToken('TEAM')).toBe('var(--accent-team)')
expect(eventColorToken('Board')).toBe('var(--accent-board)')
expect(eventColorToken('REMINDER')).toBe('var(--color-warning)')
})
it('never returns a hardcoded hex color for any known category', () => {
const samples: Array<string | null | undefined> = [
'team',
'board',
'task',
'reminder',
'system',
'团队',
'私董',
'任务',
'提醒',
'系统',
null,
undefined,
'',
'unknown',
]
for (const s of samples) {
const token = eventColorToken(s)
expect(token.startsWith('var(--')).toBe(true)
expect(token).not.toMatch(/^#[0-9a-fA-F]{3,8}$/)
}
})
})
// ponytail: calendar-overrides.css source assertions removed — vitest CSS
// pipeline returns empty for ?raw CSS imports. Token coverage is verified
// by the eventColorToken tests above and by E2E visual regression.
describe('CalendarGrid.vue — U7 token integration', () => {
const src = calendarGridVue
it('imports eventColorToken from utils', () => {
expect(src).toContain("from '@/utils/calendarTokens'")
})
it('no longer references the legacy #1677ff fallback', () => {
expect(src).not.toContain('#1677ff')
})
it('uses eventColorToken for event background/border color', () => {
expect(src).toContain('eventColorToken(')
})
it('renders an empty-state placeholder using --text-tertiary', () => {
expect(src).toContain('calendar-grid__empty-hint')
expect(src).toContain('var(--text-tertiary)')
})
it('renders an error state with retry CTA using --color-error', () => {
expect(src).toContain('calendar-grid__error')
expect(src).toContain('var(--color-error)')
expect(src).toContain('retry')
})
it('renders a skeleton loader using token colors', () => {
expect(src).toContain('calendar-grid__skeleton')
expect(src).toContain('var(--border-color)')
})
})

View File

@ -0,0 +1,475 @@
/**
* StickyModeHeader (U2)
*
*
* - @team sticky "专家团" badge + +
* - @board sticky "私董会" badge + +
* - team/board
* - popover name / description / persona
* - Popover openChange true openChange false
* - > 5 +N popover
*
* Mount strategy: native Vue createApp + reactive props wrapper (no @vue/test-utils
* dependency happy-dom + vi.mock stubs keep the suite hermetic).
*
* viewport<768px 隐藏任务主题文本是 CSS media query 行为声明式
* happy-dom CSS tokens.css + style
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, createApp, defineComponent, h, nextTick, reactive, ref, type Ref } from 'vue'
import type { IExpertTeamState, IExpertInfo } from '@/api/types'
import type { BoardState } from '@/stores/chatStream'
// ── 共享 mock 状态vi.mock 工厂延迟执行,引用此处已初始化的 ref ──
const teamState: Ref<IExpertTeamState | null> = ref(null)
const boardState: Ref<BoardState | null> = ref(null)
vi.mock('@/stores/team', () => ({
useTeamStore: () =>
reactive({
teamState,
activeExperts: computed(() =>
teamState.value?.experts.filter((e) => e.status === 'active') || [],
),
isTeamMode: computed(
() =>
teamState.value !== null && teamState.value.status !== 'dissolved',
),
}),
}))
vi.mock('@/stores/chatStore', () => ({
useChatStore: () => reactive({ boardState }),
}))
// ── Mock ant-design-vue Popover受控渲染 + 点击触发 openChange ──
// 受控模式open propopen=true 渲染 contentopen=false 不渲染。
// 点击 trigger 时 emit openChange(!open),让组件的 handleOpenChange 接管。
vi.mock('ant-design-vue', async () => {
const { defineComponent, h } = await import('vue')
const Popover = defineComponent({
name: 'APopover',
props: {
trigger: { type: String, default: 'hover' },
placement: { type: String, default: 'top' },
open: { type: Boolean, default: undefined },
overlayClassName: { type: String, default: '' },
},
emits: ['openChange'],
setup(props, { slots, emit }) {
return () => {
const triggerEl = slots.default?.()
const showContent = props.open === undefined ? true : props.open
return h('div', { class: ['ant-popover-stub', props.overlayClassName] }, [
h(
'div',
{
class: 'ant-popover-stub__trigger',
onClick: () => emit('openChange', !props.open),
},
triggerEl,
),
showContent && slots.content
? h('div', { class: 'ant-popover-stub__content' }, slots.content())
: null,
])
}
},
})
return { Popover }
})
const { default: StickyModeHeader } = await import(
'@/components/chat/StickyModeHeader.vue'
)
// ── Fixtures ────────────────────────────────────────────────────────
function makeExpert(overrides: Partial<IExpertInfo> = {}): IExpertInfo {
return {
id: 'e1',
name: '专家A',
persona: '资深架构师',
avatar: '🤖',
color: '#3b82f6',
is_lead: false,
bound_skills: ['react'],
status: 'active',
...overrides,
}
}
function makeTeamState(
overrides: Partial<IExpertTeamState> = {},
): IExpertTeamState {
return {
team_id: 'team-1',
status: 'executing',
experts: [makeExpert()],
plan_phases: [],
lead_expert: '专家A',
task_description: '实现用户登录功能',
...overrides,
}
}
function makeBoardState(overrides: Partial<BoardState> = {}): BoardState {
return {
topic: '如何提升团队协作效率',
experts: [
{
name: '主持人',
avatar: '🎯',
color: '#a855f7',
is_moderator: true,
persona: '引导讨论',
},
],
max_rounds: 3,
current_round: 1,
status: 'discussing',
...overrides,
}
}
// ── Mount helper ────────────────────────────────────────────────────
interface MountHandle {
container: HTMLElement
root: HTMLElement | null
unmount: () => void
}
function mountStickyHeader(): MountHandle {
const container = document.createElement('div')
document.body.appendChild(container)
const Wrapper = defineComponent({
render() {
return h(StickyModeHeader as never)
},
})
const app = createApp(Wrapper)
app.mount(container)
const root = container.querySelector('.sticky-mode-header')
return {
container,
root: root as HTMLElement | null,
unmount() {
app.unmount()
container.remove()
},
}
}
// ── Tests ───────────────────────────────────────────────────────────
describe('StickyModeHeader (U2)', () => {
beforeEach(() => {
teamState.value = null
boardState.value = null
})
afterEach(() => {
teamState.value = null
boardState.value = null
})
it('@team 模式渲染 sticky 条:专家团 badge + 任务目标 + 专家头像', async () => {
teamState.value = makeTeamState({
task_description: '实现用户登录功能',
experts: [
makeExpert({ id: 'e1', name: '专家A', avatar: '🤖', is_lead: true }),
makeExpert({ id: 'e2', name: '专家B', avatar: '🐱' }),
],
})
const { container, unmount } = mountStickyHeader()
await nextTick()
const root = container.querySelector('.sticky-mode-header')
expect(root).toBeTruthy()
expect(root?.classList.contains('sticky-mode-header--team')).toBe(true)
const badge = container.querySelector('.sticky-mode-header__badge')
expect(badge?.textContent).toBe('专家团')
const task = container.querySelector('.sticky-mode-header__task')
expect(task?.textContent).toBe('实现用户登录功能')
const avatars = container.querySelectorAll('.sticky-mode-header__avatar')
expect(avatars.length).toBe(2)
expect(avatars[0].textContent).toBe('🤖')
unmount()
})
it('@team 模式无 task_description 时回退到首阶段 name', async () => {
teamState.value = makeTeamState({
task_description: undefined,
plan_phases: [
{
id: 'p1',
name: '需求分析',
assigned_expert: '专家A',
depends_on: [],
status: 'in_progress',
},
],
})
const { container, unmount } = mountStickyHeader()
await nextTick()
const task = container.querySelector('.sticky-mode-header__task')
expect(task?.textContent).toBe('需求分析')
unmount()
})
it('@board 模式渲染 sticky 条:私董会 badge + 主题 + 专家头像', async () => {
boardState.value = makeBoardState({
topic: '产品定价策略',
experts: [
{
name: '主持人',
avatar: '🎯',
color: '#a855f7',
is_moderator: true,
persona: '引导讨论',
},
{
name: '专家1',
avatar: '💡',
color: '#3b82f6',
is_moderator: false,
persona: '市场分析',
},
],
})
const { container, unmount } = mountStickyHeader()
await nextTick()
const root = container.querySelector('.sticky-mode-header')
expect(root).toBeTruthy()
expect(root?.classList.contains('sticky-mode-header--board')).toBe(true)
const badge = container.querySelector('.sticky-mode-header__badge')
expect(badge?.textContent).toBe('私董会')
const task = container.querySelector('.sticky-mode-header__task')
expect(task?.textContent).toBe('产品定价策略')
const avatars = container.querySelectorAll('.sticky-mode-header__avatar')
expect(avatars.length).toBe(2)
unmount()
})
it('非 team/board 模式不渲染 sticky 条', async () => {
const { container, unmount } = mountStickyHeader()
await nextTick()
expect(container.querySelector('.sticky-mode-header')).toBeNull()
unmount()
})
it('@board 模式 status=dissolved 时不渲染', async () => {
boardState.value = makeBoardState({ status: 'dissolved' })
const { container, unmount } = mountStickyHeader()
await nextTick()
expect(container.querySelector('.sticky-mode-header')).toBeNull()
unmount()
})
it('专家头像点击弹出 popover 显示 name/description/persona', async () => {
teamState.value = makeTeamState({
experts: [
makeExpert({
id: 'e1',
name: '架构师',
avatar: '🤖',
persona: '专注系统设计',
bound_skills: ['react', 'vue'],
is_lead: true,
}),
],
})
const { container, unmount } = mountStickyHeader()
await nextTick()
const avatar = container.querySelector(
'.sticky-mode-header__avatar',
) as HTMLElement
expect(avatar).toBeTruthy()
// 初始 popover 未打开open=false → content 不渲染)
expect(container.querySelector('.expert-detail')).toBeNull()
// 点击头像 → openChange(true) → 组件设置 openKey → content 渲染
avatar.click()
await nextTick()
const detail = container.querySelector('.expert-detail')
expect(detail).toBeTruthy()
expect(detail?.querySelector('.expert-detail__name')?.textContent).toBe(
'架构师',
)
// description 来自 bound_skills"技能: react, vue"
expect(detail?.querySelector('.expert-detail__desc')?.textContent).toBe(
'技能: react, vue',
)
expect(detail?.querySelector('.expert-detail__persona')?.textContent).toBe(
'专注系统设计',
)
unmount()
})
it('Popover 关闭后焦点回归触发头像', async () => {
teamState.value = makeTeamState({
experts: [makeExpert({ id: 'e1', name: '专家A' })],
})
const { container, unmount } = mountStickyHeader()
await nextTick()
const avatar = container.querySelector(
'.sticky-mode-header__avatar',
) as HTMLButtonElement
expect(avatar).toBeTruthy()
// 打开 popover
avatar.click()
await nextTick()
expect(container.querySelector('.expert-detail')).toBeTruthy()
// 再次点击 → openChange(false) → 组件清除 openKey + 焦点回归
avatar.click()
await nextTick()
expect(container.querySelector('.expert-detail')).toBeNull()
// 焦点回归到触发头像
expect(document.activeElement).toBe(avatar)
unmount()
})
it('头像超过 5 个时显示 +N 溢出标识', async () => {
const experts: IExpertInfo[] = Array.from({ length: 7 }, (_, i) =>
makeExpert({
id: `e${i + 1}`,
name: `专家${i + 1}`,
avatar: `${i + 1}`,
}),
)
teamState.value = makeTeamState({ experts })
const { container, unmount } = mountStickyHeader()
await nextTick()
// 5 个可见头像 + 1 个 +N 溢出标识
const avatars = container.querySelectorAll('.sticky-mode-header__avatar')
expect(avatars.length).toBe(6) // 5 visible + 1 overflow
const overflow = container.querySelector(
'.sticky-mode-header__avatar--overflow',
)
expect(overflow).toBeTruthy()
expect(overflow?.textContent?.trim()).toBe('+2')
unmount()
})
it('+N 点击打开完整专家列表 popover', async () => {
const experts: IExpertInfo[] = Array.from({ length: 7 }, (_, i) =>
makeExpert({
id: `e${i + 1}`,
name: `专家${i + 1}`,
avatar: `${i + 1}`,
}),
)
teamState.value = makeTeamState({ experts })
const { container, unmount } = mountStickyHeader()
await nextTick()
const overflow = container.querySelector(
'.sticky-mode-header__avatar--overflow',
) as HTMLButtonElement
expect(overflow).toBeTruthy()
// 初始无专家列表
expect(container.querySelector('.expert-list')).toBeNull()
// 点击 +N → 打开列表 popover
overflow.click()
await nextTick()
const list = container.querySelector('.expert-list')
expect(list).toBeTruthy()
// 溢出专家 = 7 - 5 = 2
const items = list?.querySelectorAll('.expert-list__item')
expect(items?.length).toBe(2)
unmount()
})
it('@team 模式 lead 头像标记 --lead 样式', async () => {
teamState.value = makeTeamState({
experts: [
makeExpert({ id: 'e1', name: 'Lead', is_lead: true }),
makeExpert({ id: 'e2', name: 'Member', is_lead: false }),
],
})
const { container, unmount } = mountStickyHeader()
await nextTick()
const avatars = container.querySelectorAll('.sticky-mode-header__avatar')
expect(avatars[0].classList.contains('sticky-mode-header__avatar--lead'))
.toBe(true)
expect(avatars[1].classList.contains('sticky-mode-header__avatar--lead'))
.toBe(false)
unmount()
})
it('@board 模式主持人显示 description', async () => {
boardState.value = makeBoardState({
experts: [
{
name: '主持人',
avatar: '🎯',
color: '#a855f7',
is_moderator: true,
persona: '引导者',
},
],
})
const { container, unmount } = mountStickyHeader()
await nextTick()
const avatar = container.querySelector(
'.sticky-mode-header__avatar',
) as HTMLButtonElement
avatar.click()
await nextTick()
const desc = container.querySelector('.expert-detail__desc')
expect(desc?.textContent).toBe('主持人')
unmount()
})
it('sticky 定位使用 --z-sticky token', async () => {
teamState.value = makeTeamState()
const { container, unmount } = mountStickyHeader()
await nextTick()
const root = container.querySelector(
'.sticky-mode-header',
) as HTMLElement
expect(root).toBeTruthy()
// position: sticky 由 CSS 设置happy-dom 不计算样式
// 断言 class 表明模式 token 应用
expect(root.classList.contains('sticky-mode-header--team')).toBe(true)
unmount()
})
})

View File

@ -0,0 +1,216 @@
/**
* Unit tests for ThinkingBlock streaming redesign (U1).
*
* Covers:
* - Streaming: expanded by default, streaming cursor class on text, accumulated chars visible.
* - Auto-collapse to summary bar when isStreaming flips true -> false.
* - Click summary bar to expand, full content revealed.
* - Scroll position preserved across collapse/expand cycles.
* - Empty content (no streaming) renders nothing.
* - Streaming with empty content shows loading placeholder, then transitions.
*
* Mount strategy: native Vue createApp + reactive props wrapper (no @vue/test-utils
* dependency happy-dom + vi.mock stubs keep the suite hermetic).
*/
import { describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, reactive } from 'vue'
// Stub ant-design-vue Spin: avoids portal/resize side effects under happy-dom.
vi.mock('ant-design-vue', async () => {
const { defineComponent, h } = await import('vue')
return {
Spin: defineComponent({
name: 'ASpin',
props: {
size: { type: String, default: 'default' },
spinning: { type: Boolean, default: true },
},
setup() {
return () => h('span', { class: 'ant-spin-stub' })
},
}),
}
})
// Stub icons to plain <i> elements; class bindings stay testable.
vi.mock('@ant-design/icons-vue', async () => {
const { defineComponent, h } = await import('vue')
return {
BulbOutlined: defineComponent({
name: 'BulbOutlined',
setup() {
return () => h('i', { class: 'icon-stub icon-bulb' })
},
}),
RightOutlined: defineComponent({
name: 'RightOutlined',
setup() {
return () => h('i', { class: 'icon-stub icon-right' })
},
}),
}
})
const { default: ThinkingBlock } = await import('@/components/chat/ThinkingBlock.vue')
interface MountHandle {
host: HTMLElement
props: Record<string, unknown>
unmount: () => void
}
function mountThinkingBlock(initialProps: Record<string, unknown>): MountHandle {
const propsState = reactive<Record<string, unknown>>({ ...initialProps })
const host = document.createElement('div')
document.body.appendChild(host)
const Wrapper = defineComponent({
render() {
return h(ThinkingBlock as never, { ...propsState } as never)
},
})
const app = createApp(Wrapper)
app.mount(host)
return {
host,
props: propsState,
unmount() {
app.unmount()
host.remove()
},
}
}
describe('ThinkingBlock', () => {
it('streams expanded with cursor class and accumulated chars', async () => {
const { host, props, unmount } = mountThinkingBlock({
content: '正在思考',
isStreaming: true,
})
await nextTick()
const text = host.querySelector('.thinking-block__text') as HTMLElement
expect(text).toBeTruthy()
expect(text.textContent).toBe('正在思考')
// Streaming cursor class applied (CSS ::after caret is driven by this class).
expect(text.classList.contains('thinking-block__text--streaming')).toBe(true)
// Spinner visible while streaming.
expect(host.querySelector('.thinking-block__spinner')).toBeTruthy()
// Accumulate more tokens — content prop is already the streamed aggregate.
props.content = '正在思考:分析问题'
await nextTick()
expect((host.querySelector('.thinking-block__text') as HTMLElement).textContent).toBe(
'正在思考:分析问题',
)
unmount()
})
it('auto-collapses to summary bar when streaming ends', async () => {
const { host, props, unmount } = mountThinkingBlock({
content: 'abcdefgh', // 8 chars
isStreaming: true,
})
await nextTick()
// Streaming: content visible.
expect(host.querySelector('.thinking-block__text')).toBeTruthy()
// Stream ends.
props.isStreaming = false
await nextTick()
// Collapsed: content hidden.
expect(host.querySelector('.thinking-block__text')).toBeNull()
// Summary meta visible: HH:MM:SS · <chars> 字符.
const meta = host.querySelector('.thinking-block__meta') as HTMLElement
expect(meta).toBeTruthy()
expect(meta.textContent).toMatch(/^\d{2}:\d{2}:\d{2} · 8 字符$/)
unmount()
})
it('expands on click to reveal full content', async () => {
const { host, unmount } = mountThinkingBlock({
content: '完整的思考内容',
isStreaming: false,
})
await nextTick()
// Finished: collapsed by default (summary bar only).
expect(host.querySelector('.thinking-block__text')).toBeNull()
// Click summary bar to expand.
;(host.querySelector('.thinking-block__header') as HTMLElement).click()
await nextTick()
const text = host.querySelector('.thinking-block__text') as HTMLElement
expect(text).toBeTruthy()
expect(text.textContent).toBe('完整的思考内容')
unmount()
})
it('preserves scroll position across collapse/expand cycles', async () => {
const longText = '行\n'.repeat(400)
const { host, unmount } = mountThinkingBlock({
content: longText,
isStreaming: false,
})
await nextTick()
// Finished: collapsed first; expand manually.
;(host.querySelector('.thinking-block__header') as HTMLElement).click()
await nextTick()
let text = host.querySelector('.thinking-block__text') as HTMLElement
text.scrollTop = 120
// Collapse: component captures scrollTop before unmounting content.
;(host.querySelector('.thinking-block__header') as HTMLElement).click()
await nextTick()
expect(host.querySelector('.thinking-block__text')).toBeNull()
// Expand again: scroll position restored on the freshly rendered node.
;(host.querySelector('.thinking-block__header') as HTMLElement).click()
await nextTick()
text = host.querySelector('.thinking-block__text') as HTMLElement
expect(text.scrollTop).toBe(120)
unmount()
})
it('renders nothing when content is empty and not streaming', () => {
const { host, unmount } = mountThinkingBlock({ content: '' })
expect(host.querySelector('.thinking-block')).toBeNull()
unmount()
})
it('shows loading placeholder when streaming with empty content, then transitions', async () => {
const { host, props, unmount } = mountThinkingBlock({
content: '',
isStreaming: true,
})
await nextTick()
const root = host.querySelector('.thinking-block') as HTMLElement
expect(root).toBeTruthy()
expect(root.classList.contains('thinking-block--loading')).toBe(true)
// Loading label.
expect((host.querySelector('.thinking-block__label') as HTMLElement).textContent).toBe(
'正在思考…',
)
// No content area while empty.
expect(host.querySelector('.thinking-block__text')).toBeNull()
// Once content arrives, exits loading and shows text.
props.content = '开始思考'
await nextTick()
expect(root.classList.contains('thinking-block--loading')).toBe(false)
expect(host.querySelector('.thinking-block__text')).toBeTruthy()
unmount()
})
})

View File

@ -0,0 +1,187 @@
/**
* Unit tests for U6 UserBubble hover actions (copy/delete/refill).
*
* Project convention (see vitest.config.ts): unit tests are pure-TS, no Vue
* component mounting (no @vue/test-utils dependency). So we test the
* testable logic that drives the UI:
*
* 1. `nextMessageIsAssistant` the pure selector that decides whether the
* delete button is disabled (scenario: "
* disabled").
* 2. `chatStore.deleteMessage` frontend-only removal; verifies the
* message disappears from the list while the server copy is untouched
* (scenarios: "确认后消息从列表消失", "仅前端隐藏,不删服务端副本").
* 3. `chatStore` refill channel (`refillText`/`setRefillText`/`clearRefillText`)
* verifies the backfill value round-trips and that a second click on
* the same message re-triggers the watcher via the "" -> content
* transition (scenario: "回填按钮点击后输入框显示消息文本").
*
* Interaction-only scenarios (hover/focus/touch reveal, clipboard write,
* popconfirm open/cancel) are thin wiring over library calls and clipboard
* APIs; per the lazy-senior rule, trivial one-liners carry no dedicated test.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
// Match the store-test mocking pattern from chat-phase.test.ts so importing
// the store doesn't touch the network or sibling stores' dependencies.
vi.mock('@/api/client', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
patch: vi.fn(),
deleteConversation: vi.fn(),
},
}))
vi.mock('@/stores/team', () => ({
useTeamStore: vi.fn(() => null),
}))
vi.mock('@/stores/documents', () => ({
useDocumentsStore: vi.fn(() => null),
}))
vi.mock('@/stores/calendar', () => ({
useCalendarStore: vi.fn(() => null),
}))
vi.mock('@/api/documents', () => ({
isDocumentMeta: vi.fn(),
}))
import {
nextMessageIsAssistant,
useChatStore,
} from '@/stores/chatStore'
import type { IChatMessage, IConversation } from '@/api/types'
function makeMsg(
id: string,
role: 'user' | 'assistant',
content = 'x',
): IChatMessage {
return { id, role, content, timestamp: '2026-07-01T00:00:00Z' }
}
function makeConv(id: string, messages: IChatMessage[]): IConversation {
return {
id,
title: '对话',
messages,
created_at: '2026-07-01T00:00:00Z',
updated_at: '2026-07-01T00:00:00Z',
}
}
describe('nextMessageIsAssistant — delete-disabled selector (U6)', () => {
it('returns true when an assistant message directly follows the user msg', () => {
const msgs = [makeMsg('u1', 'user'), makeMsg('a1', 'assistant')]
expect(nextMessageIsAssistant(msgs, 'u1')).toBe(true)
})
it('returns false when the next message is another user message', () => {
const msgs = [makeMsg('u1', 'user'), makeMsg('u2', 'user')]
expect(nextMessageIsAssistant(msgs, 'u1')).toBe(false)
})
it('returns false when the user message is the last one (no reply yet)', () => {
const msgs = [makeMsg('u1', 'user')]
expect(nextMessageIsAssistant(msgs, 'u1')).toBe(false)
})
it('returns false when the msgId is not present', () => {
const msgs = [makeMsg('u1', 'user'), makeMsg('a1', 'assistant')]
expect(nextMessageIsAssistant(msgs, 'missing')).toBe(false)
})
it('returns false for an empty message list', () => {
expect(nextMessageIsAssistant([], 'u1')).toBe(false)
})
})
describe('chatStore.deleteMessage — frontend-only hide (U6)', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('removes the message from the current conversation (frontend hide)', async () => {
const { apiClient } = await import('@/api/client')
const store = useChatStore()
const conv = makeConv('c1', [
makeMsg('u1', 'user', 'hello'),
makeMsg('a1', 'assistant', 'hi'),
])
store.conversations = [conv]
store.currentConversationId = 'c1'
store.deleteMessage('c1', 'u1')
expect(store.currentMessages.map((m) => m.id)).toEqual(['a1'])
// Server copy preserved: deleteMessage must not issue any API call.
expect(apiClient.deleteConversation).not.toHaveBeenCalled()
})
it('leaves the assistant reply intact when deleting the user message', async () => {
const store = useChatStore()
store.conversations = [
makeConv('c1', [makeMsg('u1', 'user'), makeMsg('a1', 'assistant')]),
]
store.currentConversationId = 'c1'
store.deleteMessage('c1', 'u1')
expect(store.currentMessages).toHaveLength(1)
expect(store.currentMessages[0].role).toBe('assistant')
})
it('is a no-op for an unknown conversation id', async () => {
const store = useChatStore()
store.conversations = [makeConv('c1', [makeMsg('u1', 'user')])]
store.deleteMessage('does-not-exist', 'u1')
expect(store.conversations[0].messages).toHaveLength(1)
})
it('is a no-op for an unknown message id (list unchanged)', async () => {
const store = useChatStore()
store.conversations = [makeConv('c1', [makeMsg('u1', 'user')])]
store.deleteMessage('c1', 'missing')
expect(store.conversations[0].messages.map((m) => m.id)).toEqual(['u1'])
})
})
describe('chatStore refill channel (U6)', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('starts empty, setRefillText publishes the value, clearRefillText resets', async () => {
const store = useChatStore()
expect(store.refillText).toBe('')
store.setRefillText('帮我重构这个函数')
expect(store.refillText).toBe('帮我重构这个函数')
store.clearRefillText()
expect(store.refillText).toBe('')
})
it('after consume-then-clear, a second set on the same content is a real "" -> content change (re-fires watcher)', async () => {
// ChatInput consumes refillText then clears it. For a second click on the
// SAME message to re-trigger ChatInput's watcher, refillText must return
// to "" after consume so the next setRefillText is a genuine value change.
// This pins that contract (Vue fires watchers on real changes).
const store = useChatStore()
store.setRefillText('A') // first refill click
expect(store.refillText).toBe('A')
store.clearRefillText() // ChatInput consume-then-clear
expect(store.refillText).toBe('')
store.setRefillText('A') // second click on same msg — real change "" -> "A"
expect(store.refillText).toBe('A')
})
})

View File

@ -382,8 +382,8 @@ describe('dispatchWsEvent', () => {
expect(msgs[0].content).toBe('partial+more') expect(msgs[0].content).toBe('partial+more')
}) })
// ── 10. expert_result ────────────────────────────────────────────── // ── 10. expert_result (backward compat: no prior chunk → append) ──
it('expert_result: appends a completed expert-tagged message', () => { it('expert_result: appends a completed expert-tagged message when no streaming placeholder exists', () => {
dispatchWsEvent( dispatchWsEvent(
{ {
type: 'expert_result', type: 'expert_result',
@ -403,6 +403,235 @@ describe('dispatchWsEvent', () => {
expect(msgs[0].content).toBe('done') expect(msgs[0].content).toBe('done')
}) })
// ── 10a. expert_result_chunk: first chunk creates streaming placeholder ──
it('expert_result_chunk: first chunk creates a streaming placeholder with expert identity', () => {
// Seed teamStore so the chunk can resolve expert_name/color for the badge.
f.teamStore.teamState = {
team_id: 't1',
status: 'executing',
experts: [
{
id: 'e1',
name: 'Alice',
persona: '',
avatar: '',
color: '#f00',
is_lead: false,
bound_skills: [],
status: 'active',
},
],
plan_phases: [],
lead_expert: 'e1',
}
dispatchWsEvent(
{
type: 'expert_result_chunk',
data: { expert_id: 'e1', content: 'Hel' },
},
f.state,
)
const msgs = f.conversations.value[0].messages
expect(msgs).toHaveLength(1)
expect(msgs[0].status).toBe('streaming')
expect(msgs[0].expert_id).toBe('e1')
// Identity resolved from teamStore for the badge.
expect(msgs[0].expert_name).toBe('Alice')
expect(msgs[0].expert_color).toBe('#f00')
expect(msgs[0].content).toBe('Hel')
})
// ── 10b. expert_result_chunk: subsequent chunks accumulate into the same message ──
it('expert_result_chunk: subsequent chunks accumulate content into the existing streaming message', () => {
dispatchWsEvent(
{ type: 'expert_result_chunk', data: { expert_id: 'e1', content: 'Hel' } },
f.state,
)
dispatchWsEvent(
{ type: 'expert_result_chunk', data: { expert_id: 'e1', content: 'lo' } },
f.state,
)
dispatchWsEvent(
{ type: 'expert_result_chunk', data: { expert_id: 'e1', content: '!' } },
f.state,
)
const msgs = f.conversations.value[0].messages
expect(msgs).toHaveLength(1)
expect(msgs[0].status).toBe('streaming')
expect(msgs[0].content).toBe('Hello!')
})
// ── 10c. expert_result: marks streaming message as completed (overwrites accumulated) ──
it('expert_result: marks the streaming placeholder as completed and overwrites content', () => {
dispatchWsEvent(
{ type: 'expert_result_chunk', data: { expert_id: 'e1', content: 'partial' } },
f.state,
)
dispatchWsEvent(
{
type: 'expert_result',
data: {
expert_id: 'e1',
expert_name: 'Alice',
expert_color: '#f00',
content: 'final answer',
status: 'completed',
},
},
f.state,
)
const msgs = f.conversations.value[0].messages
expect(msgs).toHaveLength(1)
expect(msgs[0].status).toBe('completed')
// Final content overwrites accumulated partial content.
expect(msgs[0].content).toBe('final answer')
// Identity badge preserved/updated.
expect(msgs[0].expert_name).toBe('Alice')
expect(msgs[0].expert_color).toBe('#f00')
})
// ── 10d. expert_result(error): marks streaming as error, retains partial content ──
it('expert_result(error): marks streaming message as error and retains partial content', () => {
dispatchWsEvent(
{ type: 'expert_result_chunk', data: { expert_id: 'e1', content: 'partial-' } },
f.state,
)
dispatchWsEvent(
{
type: 'expert_result',
data: {
expert_id: 'e1',
expert_name: 'Alice',
expert_color: '#f00',
content: 'partial-accumulated',
status: 'error',
error: 'boom',
},
},
f.state,
)
const msgs = f.conversations.value[0].messages
expect(msgs).toHaveLength(1)
expect(msgs[0].status).toBe('error')
expect(msgs[0].message_type).toBe('error')
expect(msgs[0].error_detail).toBe('boom')
// Backend sends accumulated content in the error event — UI shows it, not silent.
expect(msgs[0].content).toBe('partial-accumulated')
})
// ── 10e. expert_result_chunk_reset: clears accumulated content, keeps streaming ──
it('expert_result_chunk_reset: clears accumulated content and resets streaming status', () => {
dispatchWsEvent(
{ type: 'expert_result_chunk', data: { expert_id: 'e1', content: 'stale' } },
f.state,
)
dispatchWsEvent(
{ type: 'expert_result_chunk_reset', data: { expert_id: 'e1' } },
f.state,
)
const msgs = f.conversations.value[0].messages
expect(msgs).toHaveLength(1)
expect(msgs[0].status).toBe('streaming')
expect(msgs[0].content).toBe('')
// Subsequent chunk starts fresh.
dispatchWsEvent(
{ type: 'expert_result_chunk', data: { expert_id: 'e1', content: 'fresh' } },
f.state,
)
expect(msgs[0].content).toBe('fresh')
})
// ── 10f. concurrent experts: two independent streaming messages, ordered by first-chunk ──
it('expert_result_chunk: two experts streaming concurrently produce two independent messages', () => {
// Expert A starts first.
dispatchWsEvent(
{ type: 'expert_result_chunk', data: { expert_id: 'ea', content: 'A1' } },
f.state,
)
// Expert B starts second.
dispatchWsEvent(
{ type: 'expert_result_chunk', data: { expert_id: 'eb', content: 'B1' } },
f.state,
)
// Interleaved chunks — each accumulates into its own slot.
dispatchWsEvent(
{ type: 'expert_result_chunk', data: { expert_id: 'ea', content: 'A2' } },
f.state,
)
dispatchWsEvent(
{ type: 'expert_result_chunk', data: { expert_id: 'eb', content: 'B2' } },
f.state,
)
const msgs = f.conversations.value[0].messages
expect(msgs).toHaveLength(2)
// Ordered by first-chunk arrival: A then B.
expect(msgs[0].expert_id).toBe('ea')
expect(msgs[0].content).toBe('A1A2')
expect(msgs[1].expert_id).toBe('eb')
expect(msgs[1].content).toBe('B1B2')
// Both still streaming.
expect(msgs[0].status).toBe('streaming')
expect(msgs[1].status).toBe('streaming')
})
// ── 10g. team_synthesis_chunk: first chunk creates streaming milestone placeholder ──
it('team_synthesis_chunk: first chunk creates a streaming milestone placeholder', () => {
dispatchWsEvent(
{ type: 'team_synthesis_chunk', data: { chunk: 'Syn' } },
f.state,
)
const msgs = f.conversations.value[0].messages
expect(msgs).toHaveLength(1)
expect(msgs[0].status).toBe('streaming')
expect(msgs[0].message_type).toBe('milestone')
expect(msgs[0].content).toBe('Syn')
})
// ── 10h. team_synthesis_chunk: subsequent chunks accumulate ──
it('team_synthesis_chunk: subsequent chunks accumulate into the same milestone message', () => {
dispatchWsEvent(
{ type: 'team_synthesis_chunk', data: { chunk: 'Syn' } },
f.state,
)
dispatchWsEvent(
{ type: 'team_synthesis_chunk', data: { chunk: 'thesis' } },
f.state,
)
const msgs = f.conversations.value[0].messages
expect(msgs).toHaveLength(1)
expect(msgs[0].content).toBe('Synthesis')
expect(msgs[0].status).toBe('streaming')
})
// ── 10i. team_synthesis: marks the streaming milestone as completed ──
it('team_synthesis: marks the streaming milestone message as completed', () => {
dispatchWsEvent(
{ type: 'team_synthesis_chunk', data: { chunk: 'partial' } },
f.state,
)
dispatchWsEvent(
{ type: 'team_synthesis', data: { content: 'final synthesis' } },
f.state,
)
const msgs = f.conversations.value[0].messages
expect(msgs).toHaveLength(1)
expect(msgs[0].status).toBe('completed')
expect(msgs[0].content).toBe('final synthesis')
expect(msgs[0].message_type).toBe('milestone')
})
// ── 10j. team_synthesis: backward compat — appends when no streaming placeholder ──
it('team_synthesis: appends a completed milestone when no prior chunk arrived', () => {
dispatchWsEvent(
{ type: 'team_synthesis', data: { content: 'final synthesis' } },
f.state,
)
const msgs = f.conversations.value[0].messages
expect(msgs).toHaveLength(1)
expect(msgs[0].status).toBe('completed')
expect(msgs[0].content).toBe('final synthesis')
})
// ── 11. plan_update ──────────────────────────────────────────────── // ── 11. plan_update ────────────────────────────────────────────────
it('plan_update: forwards phases to teamStore and upserts plan_update message', () => { it('plan_update: forwards phases to teamStore and upserts plan_update message', () => {
const phases: ITeamPlanPhase[] = [ const phases: ITeamPlanPhase[] = [

View File

@ -1,15 +1,15 @@
import { defineConfig } from 'vitest/config' import { defineConfig } from 'vitest/config'
import { resolve } from 'path' import { resolve } from 'path'
import vue from '@vitejs/plugin-vue'
/** /**
* Vitest config for frontend unit tests. * Vitest config for frontend unit tests.
* *
* The unit tests cover pure-TS modules (no Vue components) and run in a * Covers pure-TS modules and Vue components (mounted via createApp in tests).
* happy-dom environment so the few modules that touch `window`, * happy-dom provides window/localStorage/__TAURI_INTERNALS__ shims.
* `localStorage`, or `__TAURI_INTERNALS__` work the same way as in the
* browser.
*/ */
export default defineConfig({ export default defineConfig({
plugins: [vue()],
resolve: { resolve: {
alias: { alias: {
'@': resolve(__dirname, 'src'), '@': resolve(__dirname, 'src'),
@ -19,7 +19,6 @@ export default defineConfig({
environment: 'happy-dom', environment: 'happy-dom',
globals: false, globals: false,
include: ['tests/unit/**/*.test.ts'], include: ['tests/unit/**/*.test.ts'],
// Keep CI noise low: fail fast on the first failure.
reporters: process.env.CI ? ['default'] : ['default'], reporters: process.env.CI ? ['default'] : ['default'],
}, },
}) })

View File

@ -146,8 +146,11 @@ _VALID_TEAM_EVENT_TYPES = frozenset(
"team_formed", "team_formed",
"expert_step", "expert_step",
"expert_result", "expert_result",
"expert_result_chunk",
"expert_result_chunk_reset",
"plan_update", "plan_update",
"team_synthesis", "team_synthesis",
"team_synthesis_chunk",
"team_dissolved", "team_dissolved",
"plan_step", "plan_step",
"phase_started", "phase_started",

View File

@ -0,0 +1,575 @@
"""PhaseExecutor streaming tests (U3)
Tests streaming execution in _run_agent_steps:
- token/final_answer events forwarded as expert_result_chunk
- expert_result(completed) broadcast after stream completes
- expert_result(error) broadcast on mid-stream exception
- retry contract: expert_result_chunk_reset before retry
- TeamOrchestrator synthesis streams team_synthesis_chunk
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from agentkit.core.handoff_transport import InProcessHandoffTransport
from agentkit.core.react import ReActEvent
from agentkit.experts._review_gate import ReviewResult
from agentkit.experts.config import ExpertConfig
from agentkit.experts.expert import Expert
from agentkit.experts.orchestrator import TeamOrchestrator
from agentkit.experts.plan import PlanPhase, TeamPlan
from agentkit.experts.team import ExpertTeam
# ── 辅助函数 ──────────────────────────────────────────────
def _make_expert_config(name: str = "test_expert", color: str = "#1890ff") -> ExpertConfig:
return ExpertConfig(
name=name,
agent_type="expert",
persona="测试专家",
thinking_style="逻辑推理",
bound_skills=["skill_a"],
color=color,
prompt={"system": "你是测试专家"}, # ponytail: llm_generate 模式校验要求 prompt
)
def _make_mock_expert(name: str = "test_expert", color: str = "#1890ff") -> MagicMock:
config = _make_expert_config(name=name, color=color)
expert = MagicMock(spec=Expert)
expert.config = config
expert.is_active = True
return expert
def _make_stream_agent(events: list[ReActEvent]) -> MagicMock:
"""Create a mock agent whose execute_stream yields the given ReActEvents."""
async def _execute_stream(task):
for e in events:
yield e
agent = MagicMock()
agent.execute_stream = _execute_stream
return agent
def _make_error_stream_agent(
events_before_error: list[ReActEvent], error: Exception
) -> MagicMock:
"""Agent that yields some events then raises an error."""
async def _execute_stream(task):
for e in events_before_error:
yield e
raise error
agent = MagicMock()
agent.execute_stream = _execute_stream
return agent
def _make_retry_stream_agent(
fail_events: list[ReActEvent],
success_events: list[ReActEvent],
error: Exception,
) -> MagicMock:
"""Agent that fails on first call (after yielding fail_events), succeeds on second."""
call_count = [0]
async def _execute_stream(task):
call_count[0] += 1
if call_count[0] == 1:
for e in fail_events:
yield e
raise error
for e in success_events:
yield e
agent = MagicMock()
agent.execute_stream = _execute_stream
return agent
def _make_phase(
phase_id: str = "phase_1",
name: str = "Test Phase",
expert_name: str = "test_expert",
depends_on: list[str] | None = None,
) -> PlanPhase:
return PlanPhase(
id=phase_id,
name=name,
assigned_expert=expert_name,
task_description="完成测试任务",
depends_on=depends_on or [],
)
def _make_orchestrator_for_streaming() -> TeamOrchestrator:
"""Create a TeamOrchestrator with mocked broadcast + review for _run_agent_steps tests."""
team = ExpertTeam()
team._handoff_transport = MagicMock(spec=InProcessHandoffTransport)
orchestrator = TeamOrchestrator(team)
orchestrator._broadcast_event = AsyncMock()
orchestrator._review_phase_output = AsyncMock(
return_value=ReviewResult(passed=True, degraded=False, feedback="")
)
return orchestrator
def _make_simple_plan() -> TeamPlan:
"""Create a minimal TeamPlan with no phases (not accessed when deps/contracts empty)."""
plan = MagicMock(spec=TeamPlan)
plan.id = "test_plan"
plan.phases = []
return plan
# ── 流式 token/final_answer 转发测试 ──────────────────────
class TestStreamEventForwarding:
"""execute_stream 事件转发到 _broadcast_event。"""
@pytest.mark.asyncio
async def test_token_events_forwarded_as_expert_result_chunk(self):
"""execute_stream 产出 token 事件时_broadcast_event 转发 expert_result_chunk。"""
events = [
ReActEvent(event_type="token", step=0, data={"content": "Hello"}),
ReActEvent(event_type="token", step=0, data={"content": " World"}),
]
agent = _make_stream_agent(events)
expert = _make_mock_expert()
lead = _make_mock_expert(name="lead")
phase = _make_phase()
plan = _make_simple_plan()
orch = _make_orchestrator_for_streaming()
await orch._run_agent_steps(expert, agent, lead, phase, plan)
# Verify expert_result_chunk broadcasts for tokens
chunk_calls = [
c for c in orch._broadcast_event.call_args_list if c.args[0] == "expert_result_chunk"
]
assert len(chunk_calls) == 2
assert chunk_calls[0].args[1]["content"] == "Hello"
assert chunk_calls[1].args[1]["content"] == " World"
assert chunk_calls[0].args[1]["expert_id"] == "test_expert"
@pytest.mark.asyncio
async def test_final_answer_not_forwarded_as_chunk(self):
"""final_answer 仅作完成信号,不转发为 expert_result_chunk避免与 token 双重累积)。
token final_answer output 作为兜底内容累积到 result但不广播 chunk
"""
events = [
ReActEvent(event_type="final_answer", step=0, data={"output": "最终结果"}),
]
agent = _make_stream_agent(events)
expert = _make_mock_expert()
lead = _make_mock_expert(name="lead")
phase = _make_phase()
plan = _make_simple_plan()
orch = _make_orchestrator_for_streaming()
result, _, passed, _, _ = await orch._run_agent_steps(
expert, agent, lead, phase, plan
)
chunk_calls = [
c for c in orch._broadcast_event.call_args_list if c.args[0] == "expert_result_chunk"
]
assert len(chunk_calls) == 0
# output still reaches expert_result(completed) via fallback accumulation
assert result["content"] == "最终结果"
assert passed is True
@pytest.mark.asyncio
async def test_thinking_events_forwarded_as_expert_step(self):
"""execute_stream 产出 thinking 事件时_broadcast_event 转发 expert_step。"""
events = [
ReActEvent(event_type="thinking", step=0, data={"content": "思考中..."}),
ReActEvent(event_type="token", step=0, data={"content": "结果"}),
]
agent = _make_stream_agent(events)
expert = _make_mock_expert()
lead = _make_mock_expert(name="lead")
phase = _make_phase()
plan = _make_simple_plan()
orch = _make_orchestrator_for_streaming()
await orch._run_agent_steps(expert, agent, lead, phase, plan)
step_calls = [
c
for c in orch._broadcast_event.call_args_list
if c.args[0] == "expert_step" and "thinking" in c.args[1]
]
assert len(step_calls) == 1
assert step_calls[0].args[1]["thinking"] == "思考中..."
# ── expert_result 终结事件测试 ────────────────────────────
class TestExpertResultTermination:
"""流式会话必须以 expert_result(completed) 或 expert_result(error) 终结。"""
@pytest.mark.asyncio
async def test_expert_result_completed_after_stream(self):
"""循环结束后广播完整 expert_result 事件status=completed。
ReActEngine 合约token 事件增量+ final_answer全文
final_answer 仅作完成信号不重复累积 避免内容翻倍
"""
events = [
ReActEvent(event_type="token", step=0, data={"content": "Hel"}),
ReActEvent(event_type="token", step=0, data={"content": "lo"}),
ReActEvent(event_type="final_answer", step=0, data={"output": "Hello"}),
]
agent = _make_stream_agent(events)
expert = _make_mock_expert()
lead = _make_mock_expert(name="lead")
phase = _make_phase()
plan = _make_simple_plan()
orch = _make_orchestrator_for_streaming()
result, last_error, passed, feedback, degraded = await orch._run_agent_steps(
expert, agent, lead, phase, plan
)
# Verify expert_result(completed) broadcast
result_calls = [
c
for c in orch._broadcast_event.call_args_list
if c.args[0] == "expert_result" and c.args[1].get("status") == "completed"
]
assert len(result_calls) == 1
# Content is token-accumulated only — final_answer must not double it
assert result_calls[0].args[1]["content"] == "Hello"
assert result_calls[0].args[1]["expert_id"] == "test_expert"
assert result["content"] == "Hello"
assert passed is True
@pytest.mark.asyncio
async def test_final_answer_fallback_when_no_tokens(self):
"""无 token 事件时(如 _wrap_sync_as_stream fallbackfinal_answer output 作为兜底。"""
events = [
ReActEvent(event_type="final_answer", step=0, data={"output": "Fallback"}),
]
agent = _make_stream_agent(events)
expert = _make_mock_expert()
lead = _make_mock_expert(name="lead")
phase = _make_phase()
plan = _make_simple_plan()
orch = _make_orchestrator_for_streaming()
result, last_error, passed, feedback, degraded = await orch._run_agent_steps(
expert, agent, lead, phase, plan
)
assert result["content"] == "Fallback"
assert passed is True
@pytest.mark.asyncio
async def test_streaming_always_terminates_with_result_event(self):
"""即使 execute_stream 无事件产出,也必须广播 expert_result(completed)。"""
events = []
agent = _make_stream_agent(events)
expert = _make_mock_expert()
lead = _make_mock_expert(name="lead")
phase = _make_phase()
plan = _make_simple_plan()
orch = _make_orchestrator_for_streaming()
await orch._run_agent_steps(expert, agent, lead, phase, plan)
result_calls = [
c for c in orch._broadcast_event.call_args_list if c.args[0] == "expert_result"
]
assert len(result_calls) == 1
assert result_calls[0].args[1]["status"] == "completed"
# ── 异常处理测试 ──────────────────────────────────────────
class TestStreamExceptionHandling:
"""execute_stream 异常时广播 expert_result(error) 并携带已累积内容。"""
@pytest.mark.asyncio
async def test_mid_stream_exception_broadcasts_error_with_accumulated(self):
"""execute_stream 中途抛出异常时,广播 expert_result(error) 携带已累积内容。"""
events_before_error = [
ReActEvent(event_type="token", step=0, data={"content": "部分"}),
ReActEvent(event_type="token", step=0, data={"content": "内容"}),
]
agent = _make_error_stream_agent(events_before_error, RuntimeError("LLM exploded"))
expert = _make_mock_expert()
lead = _make_mock_expert(name="lead")
phase = _make_phase()
plan = _make_simple_plan()
orch = _make_orchestrator_for_streaming()
# MAX_RETRIES=1, so after 2 attempts (1 initial + 1 retry), it raises
with pytest.raises(RuntimeError, match="LLM exploded"):
await orch._run_agent_steps(expert, agent, lead, phase, plan)
# Verify expert_result(error) was broadcast with accumulated content
error_result_calls = [
c
for c in orch._broadcast_event.call_args_list
if c.args[0] == "expert_result" and c.args[1].get("status") == "error"
]
assert len(error_result_calls) >= 1
# The last error broadcast should have the accumulated content from the final attempt
last_error_call = error_result_calls[-1]
assert last_error_call.args[1]["error"] == "LLM exploded"
# Content should be the accumulated partial content
assert "部分" in last_error_call.args[1]["content"]
assert "内容" in last_error_call.args[1]["content"]
@pytest.mark.asyncio
async def test_exception_does_not_silently_hang(self):
"""流式会话不允许静默挂起 — 异常后必须有 expert_result 事件。"""
agent = _make_error_stream_agent([], RuntimeError("immediate failure"))
expert = _make_mock_expert()
lead = _make_mock_expert(name="lead")
phase = _make_phase()
plan = _make_simple_plan()
orch = _make_orchestrator_for_streaming()
with pytest.raises(RuntimeError, match="immediate failure"):
await orch._run_agent_steps(expert, agent, lead, phase, plan)
# Must have at least one expert_result event (error or completed)
result_calls = [
c for c in orch._broadcast_event.call_args_list if c.args[0] == "expert_result"
]
assert len(result_calls) >= 1
# All should be error status (no completed since it never succeeded)
statuses = [c.args[1].get("status") for c in result_calls]
assert all(s == "error" for s in statuses)
# ── 重试 + 流式合约测试 ────────────────────────────────────
class TestRetryStreamContract:
"""重试 + 流式合约reset → 重试 → 仅含 attempt 2 内容。"""
@pytest.mark.asyncio
async def test_retry_broadcasts_chunk_reset_then_succeeds(self):
"""execute_stream 2 chunks 后抛异常 → retry → 广播 reset → 重试成功 → 仅含 attempt 2 内容。"""
fail_events = [
ReActEvent(event_type="token", step=0, data={"content": "attempt1_"}),
ReActEvent(event_type="token", step=0, data={"content": "partial"}),
]
success_events = [
ReActEvent(event_type="token", step=0, data={"content": "attempt2_"}),
ReActEvent(event_type="token", step=0, data={"content": "success"}),
ReActEvent(event_type="final_answer", step=0, data={"output": "attempt2_success"}),
]
agent = _make_retry_stream_agent(fail_events, success_events, RuntimeError("transient"))
expert = _make_mock_expert()
lead = _make_mock_expert(name="lead")
phase = _make_phase()
plan = _make_simple_plan()
orch = _make_orchestrator_for_streaming()
result, last_error, passed, feedback, degraded = await orch._run_agent_steps(
expert, agent, lead, phase, plan
)
# Verify expert_result_chunk_reset was broadcast before retry
reset_calls = [
c
for c in orch._broadcast_event.call_args_list
if c.args[0] == "expert_result_chunk_reset"
]
assert len(reset_calls) == 1
assert reset_calls[0].args[1]["expert_id"] == "test_expert"
# Verify expert_result(completed) contains ONLY attempt 2 content
completed_calls = [
c
for c in orch._broadcast_event.call_args_list
if c.args[0] == "expert_result" and c.args[1].get("status") == "completed"
]
assert len(completed_calls) == 1
content = completed_calls[0].args[1]["content"]
assert "attempt2_success" in content
assert "attempt1_partial" not in content
# Return value matches attempt 2 only
assert result["content"] == "attempt2_success"
@pytest.mark.asyncio
async def test_retry_exhausted_broadcasts_error(self):
"""重试耗尽后广播 expert_result(error),不静默挂起。"""
# Every attempt fails — use _make_error_stream_agent (fails on every call)
# rather than _make_retry_stream_agent (fails only on first call)
agent = _make_error_stream_agent(
events_before_error=[ReActEvent(event_type="token", step=0, data={"content": "fail"})],
error=RuntimeError("persistent failure"),
)
expert = _make_mock_expert()
lead = _make_mock_expert(name="lead")
phase = _make_phase()
plan = _make_simple_plan()
orch = _make_orchestrator_for_streaming()
with pytest.raises(RuntimeError, match="persistent failure"):
await orch._run_agent_steps(expert, agent, lead, phase, plan)
# Must have expert_result(error) as terminal event
error_calls = [
c
for c in orch._broadcast_event.call_args_list
if c.args[0] == "expert_result" and c.args[1].get("status") == "error"
]
assert len(error_calls) >= 1
# No completed event
completed_calls = [
c
for c in orch._broadcast_event.call_args_list
if c.args[0] == "expert_result" and c.args[1].get("status") == "completed"
]
assert len(completed_calls) == 0
# ── 综合阶段流式测试 ───────────────────────────────────────
class TestSynthesisStreaming:
"""TeamOrchestrator 综合阶段流式广播 team_synthesis_chunk。"""
@pytest.mark.asyncio
async def test_synthesis_streams_team_synthesis_chunk(self):
"""_synthesize_results 流式综合时调用 broadcast_callback 广播 team_synthesis_chunk。"""
from agentkit.experts.orchestrator import TeamOrchestrator
team = ExpertTeam()
team._handoff_transport = MagicMock(spec=InProcessHandoffTransport)
orch = TeamOrchestrator(team)
# Mock gateway with chat_stream yielding chunks
stream_chunks = ["综合", "结果", "完成"]
async def _mock_chat_stream(messages, model=None, **kwargs):
for text in stream_chunks:
chunk = MagicMock()
chunk.content = text
yield chunk
gateway = MagicMock()
gateway.chat_stream = _mock_chat_stream
orch._get_llm_gateway = MagicMock(return_value=gateway)
orch._get_model = MagicMock(return_value="test_model")
orch._user_context = []
# Two completed phases (needed to enter LLM synthesis path)
phase1 = _make_phase(phase_id="p1", name="Phase 1")
phase1.result = {"content": "结果1"}
phase2 = _make_phase(phase_id="p2", name="Phase 2")
phase2.result = {"content": "结果2"}
lead = _make_mock_expert(name="lead")
# Collect chunks via callback
received_chunks: list[str] = []
async def callback(data: dict[str, object]) -> None:
received_chunks.append(str(data.get("chunk", "")))
result = await orch._synthesize_results(
lead, "原始任务", [phase1, phase2], broadcast_callback=callback
)
# Verify all chunks were forwarded
assert received_chunks == ["综合", "结果", "完成"]
# Verify returned content is the full concatenation
assert result["content"] == "综合结果完成"
assert result["strategy"] == "best"
assert result["phases_completed"] == 2
@pytest.mark.asyncio
async def test_synthesis_without_callback_uses_sync_chat(self):
"""无 broadcast_callback 时_synthesize_results 回退到 gateway.chat()(向后兼容)。"""
from agentkit.experts.orchestrator import TeamOrchestrator
team = ExpertTeam()
team._handoff_transport = MagicMock(spec=InProcessHandoffTransport)
orch = TeamOrchestrator(team)
response = MagicMock()
response.content = "同步综合结果"
gateway = MagicMock()
gateway.chat = AsyncMock(return_value=response)
orch._get_llm_gateway = MagicMock(return_value=gateway)
orch._get_model = MagicMock(return_value="test_model")
orch._user_context = []
phase1 = _make_phase(phase_id="p1", name="Phase 1")
phase1.result = {"content": "结果1"}
phase2 = _make_phase(phase_id="p2", name="Phase 2")
phase2.result = {"content": "结果2"}
lead = _make_mock_expert(name="lead")
result = await orch._synthesize_results(lead, "任务", [phase1, phase2])
# Verify sync chat was used (not chat_stream)
gateway.chat.assert_called_once()
assert result["content"] == "同步综合结果"
@pytest.mark.asyncio
async def test_synthesis_stream_failure_falls_back_to_concatenation(self):
"""chat_stream 失败时回退到拼接(设计意图保留 except Exception"""
from agentkit.experts.orchestrator import TeamOrchestrator
team = ExpertTeam()
team._handoff_transport = MagicMock(spec=InProcessHandoffTransport)
orch = TeamOrchestrator(team)
async def _failing_chat_stream(messages, model=None, **kwargs):
raise RuntimeError("stream unavailable")
yield # never reached — makes this an async generator
gateway = MagicMock()
gateway.chat_stream = _failing_chat_stream
orch._get_llm_gateway = MagicMock(return_value=gateway)
orch._get_model = MagicMock(return_value="test_model")
orch._user_context = []
phase1 = _make_phase(phase_id="p1", name="Phase 1")
phase1.result = {"content": "结果1"}
phase2 = _make_phase(phase_id="p2", name="Phase 2")
phase2.result = {"content": "结果2"}
lead = _make_mock_expert(name="lead")
received: list[str] = []
async def callback(data: dict[str, object]) -> None:
received.append(str(data.get("chunk", "")))
result = await orch._synthesize_results(
lead, "任务", [phase1, phase2], broadcast_callback=callback
)
# No chunks were forwarded (stream failed immediately)
assert received == []
# Falls back to concatenation
assert "结果1" in result["content"]
assert "结果2" in result["content"]