diff --git a/CONCEPTS.md b/CONCEPTS.md
index cc9d4c2..6406058 100644
--- a/CONCEPTS.md
+++ b/CONCEPTS.md
@@ -39,6 +39,9 @@ The feedback loop triggered when a verification check fails after a final answer
### 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.
+### 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
### Per-User Cache Namespace
diff --git a/docs/brainstorms/2026-07-01-ui-ue-enhancement-requirements.md b/docs/brainstorms/2026-07-01-ui-ue-enhancement-requirements.md
new file mode 100644
index 0000000..c330a32
--- /dev/null
+++ b/docs/brainstorms/2026-07-01-ui-ue-enhancement-requirements.md
@@ -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` 的路由元信息 tag(matched_skill / confidence / routing_method)常驻显示,对终端用户是无意义噪声。用户消息气泡 `UserBubble.vue` 是一个纯 `
`,无任何悬停操作,复制、删除、回填输入框重发都做不到。私董会与专家团的最终结果在 `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/Popover)?(design-lens, P2, conf 75)
+
+### FullCalendar 兼容性
+
+- OQ7. R22 `.fc-*` 类名覆盖的升级风险容忍度——`.fc-*` 非公开 API,FC 升级时可能破坏;是否接受定期维护成本,或约束覆盖范围至更稳定的选择器?(adversarial, P2, conf 75)
+
+### Sticky 头部替代方案
+
+- OQ8. Sticky 头部决策是否考虑过更简替代——使现有 `ExpertTeamView` / `BoardStatusView` banner 可点击展开详情,而非整体替换为 sticky 条?(adversarial, P2, conf 75)
+
+### 模式可发现性
+
+- OQ9. @team/@board 模式可发现性目标——用户如何得知这两个模式存在?是否需要在输入提示或空态中引导?(product-lens, FYI, conf 50)
diff --git a/docs/plans/2026-07-01-001-feat-ui-ue-enhancement-plan.md b/docs/plans/2026-07-01-001-feat-ui-ue-enhancement-plan.md
new file mode 100644
index 0000000..296bb98
--- /dev/null
+++ b/docs/plans/2026-07-01-001-feat-ui-ue-enhancement-plan.md
@@ -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` 顶层 ref(FYI 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 中 `
` + `
` 替换为 `
`,`
` 保留在其下方
+
+**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 dispatch(react/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 slot(keyed 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 的 `
` 组件用法
+- `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 OQ9(FYI 级别)
+
+---
+
+## 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 header);chatStore 新增方法与状态;chatStream 事件分支改造
+- **后端**:`_phase_executor.py` 执行模式变更(execute → execute_stream);WS 事件新增 `expert_result_chunk` / `team_synthesis_chunk`
+- **测试**:新增组件测试文件(ThinkingBlock / StickyModeHeader / AssistantText / UserBubble);chatStream.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`(专家元数据字段)
diff --git a/docs/residual-review-findings/feat-ui-ue-enhancement.md b/docs/residual-review-findings/feat-ui-ue-enhancement.md
new file mode 100644
index 0000000..308fa0d
--- /dev/null
+++ b/docs/residual-review-findings/feat-ui-ue-enhancement.md
@@ -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 合约
diff --git a/docs/solutions/runtime-errors/streaming-event-whitelist-and-accumulation.md b/docs/solutions/runtime-errors/streaming-event-whitelist-and-accumulation.md
new file mode 100644
index 0000000..0277fec
--- /dev/null
+++ b/docs/solutions/runtime-errors/streaming-event-whitelist-and-accumulation.md
@@ -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` 不会被意外捕获。
diff --git a/src/agentkit/core/base.py b/src/agentkit/core/base.py
index fe715cf..75c6629 100644
--- a/src/agentkit/core/base.py
+++ b/src/agentkit/core/base.py
@@ -12,6 +12,7 @@ import json
import logging
import time
from abc import ABC, abstractmethod
+from collections.abc import AsyncGenerator
from datetime import datetime, timezone
from typing import TYPE_CHECKING
@@ -30,6 +31,7 @@ from agentkit.core.protocol import (
)
if TYPE_CHECKING:
+ from agentkit.core.react import ReActEvent
from agentkit.memory.base import Memory
from agentkit.tools.base import Tool
from agentkit.llm.gateway import LLMGateway
@@ -160,6 +162,27 @@ class BaseAgent(ABC):
"""返回 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:
diff --git a/src/agentkit/core/config_driven.py b/src/agentkit/core/config_driven.py
index 92f2c94..5db680f 100644
--- a/src/agentkit/core/config_driven.py
+++ b/src/agentkit/core/config_driven.py
@@ -10,6 +10,7 @@
import json
import logging
import os
+from collections.abc import AsyncGenerator, Awaitable
from typing import Callable, Coroutine
import yaml
@@ -17,6 +18,7 @@ import yaml
from agentkit.core.base import BaseAgent
from agentkit.core.exceptions import ConfigValidationError
from agentkit.core.protocol import AgentCapability, TaskMessage
+from agentkit.core.react import ReActEvent
from agentkit.evolution.lifecycle import EvolutionMixin
from agentkit.evolution.reflector import Reflector
from agentkit.prompts.section import PromptSection
@@ -654,6 +656,203 @@ class ConfigDrivenAgent(BaseAgent, EvolutionMixin):
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:
"""ReAct mode: use ReAct engine for autonomous reasoning"""
# Auto-set _current_module from SkillConfig if evolution is enabled
diff --git a/src/agentkit/experts/_phase_executor.py b/src/agentkit/experts/_phase_executor.py
index dbdc1bd..5e64d53 100644
--- a/src/agentkit/experts/_phase_executor.py
+++ b/src/agentkit/experts/_phase_executor.py
@@ -12,7 +12,7 @@ from datetime import datetime, timezone
from typing import TYPE_CHECKING
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 .plan import PhaseStatus, PhaseType, PlanPhase, TeamPlan
@@ -215,34 +215,91 @@ class PhaseExecutorMixin:
task_msg = self._build_task_message(expert, phase, dependency_outputs, collaboration_outputs)
# 执行专家任务(带重试,MAX_RETRIES 处理瞬时失败)
+ # U3: 流式执行 — 从 agent.execute() 切换为 agent.execute_stream(),
+ # 转发 token/thinking/final_answer 事件到 WS,最终广播完整 expert_result。
last_error: str | None = None
result: dict[str, object] | None = None
for attempt in range(self.MAX_RETRIES + 1):
+ accumulated: list[str] = []
try:
- task_result: TaskResult = await agent.execute(task_msg)
- if task_result.status != TaskStatus.COMPLETED.value:
- last_error = task_result.error_message or "unknown error"
- if attempt < self.MAX_RETRIES:
- logger.info(f"Retrying phase {phase.id} (attempt {attempt + 1})")
- continue
- raise RuntimeError(f"Agent execution failed: {last_error}")
- result = task_result.output_data or {"content": ""}
+ # U3: 重试前广播 reset,前端清空已累积内容
+ if attempt > 0:
+ await self._broadcast_event("expert_result_chunk_reset", {
+ "expert_id": expert.config.name,
+ "phase_id": phase.id,
+ })
+ # U3: 流式执行 — 转发 token/thinking/final_answer 事件到 WS
+ 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
except asyncio.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
- except (RuntimeError, asyncio.TimeoutError, ConnectionError) as e:
- # agent.execute() 内部已捕获所有异常并返回 TaskResult,
- # 此处仅捕获显式抛出的 RuntimeError + 罕见的基础设施异常
+ except Exception as e:
+ # P2 fix: 兜底捕获 LLMProviderError/ConfigValidationError 等非 RuntimeError
+ # asyncio.CancelledError (BaseException) 已被上方 except 捕获,不会到达此处
+ # U3: 流式异常 — 广播 expert_result(error) 携带已累积内容
last_error = str(e)
if attempt < self.MAX_RETRIES:
logger.info(f"Retrying phase {phase.id} (attempt {attempt + 1})")
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
await self._broadcast_event("expert_result", {
"expert_id": expert.config.name, "expert_name": expert.config.name,
"expert_color": expert.config.color, "content": result.get("content", str(result)),
+ "status": "completed",
"phase_id": phase.id, "rework_attempt": phase.rework_count,
})
diff --git a/src/agentkit/experts/_synthesizer.py b/src/agentkit/experts/_synthesizer.py
index 0a6a2c9..78e65b7 100644
--- a/src/agentkit/experts/_synthesizer.py
+++ b/src/agentkit/experts/_synthesizer.py
@@ -7,7 +7,7 @@ from __future__ import annotations
import logging
from datetime import datetime, timezone
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Awaitable, Callable
from agentkit.core.protocol import TaskMessage, TaskResult
@@ -27,14 +27,30 @@ class SynthesizerMixin:
_team: ExpertTeam
_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(
- 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]:
"""Lead Expert synthesizes results using BEST strategy.
The Lead Expert evaluates all completed phase results and produces
a final synthesized result. Uses LLM when available, otherwise
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]
if not results:
@@ -52,11 +68,8 @@ class SynthesizerMixin:
gateway = self._get_llm_gateway(lead)
if not gateway:
# 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 {
- "content": combined,
+ "content": self._concat_results(results),
"strategy": "best",
"phases_completed": len(results),
}
@@ -90,6 +103,21 @@ class SynthesizerMixin:
prompt += "\n\nProvide the synthesized result directly."
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(
messages=[{"role": "user", "content": prompt}],
model=self._get_model(lead),
@@ -101,11 +129,8 @@ class SynthesizerMixin:
}
except Exception as 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 {
- "content": combined,
+ "content": self._concat_results(results),
"strategy": "best",
"phases_completed": len(results),
}
diff --git a/src/agentkit/experts/orchestrator.py b/src/agentkit/experts/orchestrator.py
index 7202594..77b605a 100644
--- a/src/agentkit/experts/orchestrator.py
+++ b/src/agentkit/experts/orchestrator.py
@@ -286,7 +286,13 @@ class TeamOrchestrator(
self._team.set_status(TeamStatus.SYNTHESIZING)
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)
diff --git a/src/agentkit/server/frontend/src/api/types.ts b/src/agentkit/server/frontend/src/api/types.ts
index 9ff3421..8975dd5 100644
--- a/src/agentkit/server/frontend/src/api/types.ts
+++ b/src/agentkit/server/frontend/src/api/types.ts
@@ -41,7 +41,7 @@ export interface IChatMessage {
routing_method?: string
confidence?: number
task_id?: string
- status?: 'completed' | 'pending' | 'error'
+ status?: 'completed' | 'pending' | 'error' | 'streaming'
tool_calls?: IToolCallData[]
thinking?: string
expert_id?: string
@@ -141,7 +141,9 @@ export type WsServerMessage =
| { type: 'pong' }
| { type: 'team_formed'; data: IExpertTeamState }
| { 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: '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 } }
@@ -149,7 +151,8 @@ export type WsServerMessage =
// PLAN_EXEC (U4) — phase lifecycle events emitted by ReActEngine.
| { 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: '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 } }
// Board Meeting 模式事件
| { type: 'board_started'; data: IBoardStartedData }
@@ -212,6 +215,8 @@ export interface IExpertTeamState {
experts: IExpertInfo[]
plan_phases: ITeamPlanPhase[]
lead_expert: string
+ /** U2: 团队级任务目标摘要(可选,后端 team_formed 事件未发送时回退到首阶段描述) */
+ task_description?: string
}
// ── Board Meeting 模式类型 ────────────────────────────────────────────
diff --git a/src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue b/src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue
index 264099c..f768e4e 100644
--- a/src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue
+++ b/src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue
@@ -1,6 +1,25 @@
-
+
+
+
+
{{ store.error }}
+
重试
+
+
+
+
+ 暂无日程
+
+
@@ -11,6 +30,7 @@ import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import interactionPlugin from '@fullcalendar/interaction'
import zhCnLocale from '@fullcalendar/core/locales/zh-cn'
+import { WarningOutlined } from '@ant-design/icons-vue'
import type {
CalendarOptions,
EventInput,
@@ -20,6 +40,7 @@ import type {
} from '@fullcalendar/core'
import { useCalendarStore } from '@/stores/calendar'
import type { ICalendarEvent } from '@/api/calendar'
+import { eventColorToken } from '@/utils/calendarTokens'
const store = useCalendarStore()
@@ -35,7 +56,7 @@ const emit = defineEmits<{
function toFcEvent(ev: ICalendarEvent): EventInput {
const eventType = store.eventTypes.find((t) => t.id === ev.event_type_id)
- const color = eventType?.color || '#1677ff'
+ const color = eventColorToken(eventType?.name)
return {
id: ev.id,
title: ev.title,
@@ -66,6 +87,10 @@ function handleEventClick(arg: EventClickArg): void {
emit('edit', ev)
}
+function retry(): void {
+ void store.loadEvents()
+}
+
onMounted(() => {
// ponytail: track last size to break ResizeObserver feedback loop —
// updateSize() can trigger another resize event, causing infinite thrash.
@@ -122,15 +147,74 @@ const calendarOptions = computed(() => ({
flex: 1;
min-height: 0;
overflow: hidden;
+ position: relative;
}
.calendar-grid :deep(.fc) {
height: 100%;
}
-.calendar-grid :deep(.fc-event-invited) {
- border-style: dashed !important;
- border-width: 2px !important;
- opacity: 0.75;
+.calendar-grid__state {
+ display: flex;
+ flex-direction: column;
+ 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;
}
diff --git a/src/agentkit/server/frontend/src/components/chat/ChatInput.vue b/src/agentkit/server/frontend/src/components/chat/ChatInput.vue
index 63743bc..a84d260 100644
--- a/src/agentkit/server/frontend/src/components/chat/ChatInput.vue
+++ b/src/agentkit/server/frontend/src/components/chat/ChatInput.vue
@@ -137,7 +137,7 @@
+
+
diff --git a/src/agentkit/server/frontend/src/components/chat/ThinkingBlock.vue b/src/agentkit/server/frontend/src/components/chat/ThinkingBlock.vue
index ad7c6ee..2689e38 100644
--- a/src/agentkit/server/frontend/src/components/chat/ThinkingBlock.vue
+++ b/src/agentkit/server/frontend/src/components/chat/ThinkingBlock.vue
@@ -1,30 +1,103 @@
-
-