feat: UI/UE enhancement — streaming, sticky header, hover actions, calendar tokens #13
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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` 是一个纯 `<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/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)
|
||||||
|
|
@ -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 中 `<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 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 的 `<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 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`(专家元数据字段)
|
||||||
|
|
@ -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 合约
|
||||||
|
|
@ -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` 不会被意外捕获。
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 模式类型 ────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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': {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 Tab→bubble (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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 slot(keyed 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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-if,hover 前后同一节点)验证。
|
||||||
|
*/
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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 prop):open=true 渲染 content;open=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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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[] = [
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 fallback),final_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"]
|
||||||
Loading…
Reference in New Issue