From f872a3fac631985319eabc29be09709b3a472344 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Wed, 1 Jul 2026 12:51:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20UI/UE=20enhancement=20=E2=80=94=20strea?= =?UTF-8?q?ming,=20sticky=20header,=20hover=20actions,=20calendar=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit U1 ThinkingBlock: streaming cursor + auto-collapse to summary bar U2 StickyModeHeader: new component replacing ExpertTeamView + BoardStatusView U3 Backend _phase_executor: execute_stream() with token/thinking/final_answer forwarding U4 Frontend chatStream: expert_result_chunk/team_synthesis_chunk token accumulation U5 AssistantText: routing tag hover fade-in U6 UserBubble: hover actions (copy/delete/refill) U7 CalendarGrid: token-based color redesign Review fixes (ce-code-review): - P0: _VALID_TEAM_EVENT_TYPES whitelist adds 3 new streaming event types - P0: final_answer no longer double-accumulates token content - P2: exception handling expanded to except Exception for LLMProviderError etc. Simplification (ce-simplify-code): - _synthesizer.py: O(n²) concat -> list+join, _concat_results extraction - config_driven.py: 4 duplicate _handle_*_stream -> _wrap_sync_as_stream - chatStream.ts: 5x [...messages].reverse().find() -> findLastMessage helper Tests: pytest 13/13, vitest 126/127 (1 baseline), typecheck pass, ruff clean --- ...26-07-01-ui-ue-enhancement-requirements.md | 124 ++++ ...6-07-01-001-feat-ui-ue-enhancement-plan.md | 462 ++++++++++++++ .../feat-ui-ue-enhancement.md | 34 ++ src/agentkit/core/base.py | 23 + src/agentkit/core/config_driven.py | 199 ++++++ src/agentkit/experts/_phase_executor.py | 81 ++- src/agentkit/experts/_synthesizer.py | 45 +- src/agentkit/experts/orchestrator.py | 8 +- src/agentkit/server/frontend/src/api/types.ts | 11 +- .../src/components/calendar/CalendarGrid.vue | 96 ++- .../src/components/chat/ChatInput.vue | 17 +- .../src/components/chat/ChatMessage.vue | 3 + .../src/components/chat/PhaseIndicator.vue | 18 +- .../src/components/chat/StickyModeHeader.vue | 394 ++++++++++++ .../src/components/chat/ThinkingBlock.vue | 126 +++- .../chat/helpers/useMessageRenderer.ts | 5 +- .../chat/messages/AssistantText.vue | 22 +- .../components/chat/messages/MessageShell.vue | 51 +- .../components/chat/messages/UserBubble.vue | 221 ++++++- .../server/frontend/src/stores/chatStore.ts | 46 ++ .../server/frontend/src/stores/chatStream.ts | 187 +++++- .../src/styles/calendar-overrides.css | 152 +++++ .../server/frontend/src/styles/index.ts | 1 + .../frontend/src/utils/calendarTokens.ts | 36 ++ .../server/frontend/src/views/ChatView.vue | 6 +- .../unit/components/AssistantText.test.ts | 162 +++++ .../unit/components/CalendarGrid.test.ts | 122 ++++ .../unit/components/StickyModeHeader.test.ts | 475 +++++++++++++++ .../unit/components/ThinkingBlock.test.ts | 216 +++++++ .../tests/unit/components/UserBubble.test.ts | 187 ++++++ .../tests/unit/stores/chatStream.test.ts | 233 ++++++- src/agentkit/server/frontend/vitest.config.ts | 9 +- src/agentkit/server/routes/chat.py | 3 + .../experts/test_phase_executor_streaming.py | 575 ++++++++++++++++++ 34 files changed, 4258 insertions(+), 92 deletions(-) create mode 100644 docs/brainstorms/2026-07-01-ui-ue-enhancement-requirements.md create mode 100644 docs/plans/2026-07-01-001-feat-ui-ue-enhancement-plan.md create mode 100644 docs/residual-review-findings/feat-ui-ue-enhancement.md create mode 100644 src/agentkit/server/frontend/src/components/chat/StickyModeHeader.vue create mode 100644 src/agentkit/server/frontend/src/styles/calendar-overrides.css create mode 100644 src/agentkit/server/frontend/src/utils/calendarTokens.ts create mode 100644 src/agentkit/server/frontend/tests/unit/components/AssistantText.test.ts create mode 100644 src/agentkit/server/frontend/tests/unit/components/CalendarGrid.test.ts create mode 100644 src/agentkit/server/frontend/tests/unit/components/StickyModeHeader.test.ts create mode 100644 src/agentkit/server/frontend/tests/unit/components/ThinkingBlock.test.ts create mode 100644 src/agentkit/server/frontend/tests/unit/components/UserBubble.test.ts create mode 100644 tests/unit/experts/test_phase_executor_streaming.py diff --git a/docs/brainstorms/2026-07-01-ui-ue-enhancement-requirements.md b/docs/brainstorms/2026-07-01-ui-ue-enhancement-requirements.md new file mode 100644 index 0000000..c330a32 --- /dev/null +++ b/docs/brainstorms/2026-07-01-ui-ue-enhancement-requirements.md @@ -0,0 +1,124 @@ +--- +date: 2026-07-01 +topic: ui-ue-enhancement +--- + +## Summary + +强化 AgentKit 聊天与日历模块的 UI/UE:让 agent 思考过程与团队/私董会结果真正流式可见,让 @team/@board 模式在对话顶部持久显式,去除常驻噪声,给用户消息补齐复制/删除/回填操作,并让日历回到 Notion 风格的统一设计语言。 + +--- + +## Problem Frame + +当前聊天主界面 `src/agentkit/server/frontend/src/views/ChatView.vue` 在顶部堆叠了三个 banner(`ExpertTeamView`、`BoardStatusView`、`PhaseIndicator`),但前两者为静态文案且不可点击,无法回答"这次 @team 要解决什么任务、由哪些专家参与"这类基本问题。助手消息区域有两个层面的可读性问题:`ThinkingBlock.vue` 默认折叠且渲染为纯文本,看不到 agent 的推理过程;`AssistantText.vue:32-42` 的路由元信息 tag(matched_skill / confidence / routing_method)常驻显示,对终端用户是无意义噪声。用户消息气泡 `UserBubble.vue` 是一个纯 `
`,无任何悬停操作,复制、删除、回填输入框重发都做不到。私董会与专家团的最终结果在 `chatStream.ts:677-696`(`expert_result`)和 `chatStream.ts:771-787`(`team_synthesis`)中以完整事件块到达前端,无法流式呈现,与 `final_answer`(`chatStream.ts:526-532`,token 累加流式)体验割裂。日历模块 `CalendarGrid.vue` 直接使用 FullCalendar 默认样式,硬编码 `#1677ff`(`CalendarGrid.vue:38`)与 `styles/tokens.css` 的 Notion 调色板(`--color-primary: #1a1a1a`、`--accent-team: #3b82f6`、`--accent-board: #a855f7`)完全脱节。 + +--- + +## Key Decisions + +- **思考展示采用"展开流式 + 完成后收起为摘要条"的混合方案。** 思考进行中时始终展开并显示流式光标,思考结束后自动收起为一行摘要条(点击可再展开查看完整内容),既满足"看到 agent 在想什么"的诉求,又避免长篇思考内容长期挤占对话视野。 +- **@team/@board 头部采用 sticky 持久条,替换 `ExpertTeamView` 与 `BoardStatusView`。** 新头部由"模式 badge + 任务目标 / 私董会主题 + 专家头像组"组成,专家头像点击后弹出详情面板查看具体专家清单与角色。`PhaseIndicator`(PLAN_EXEC 模式)独立保留在 sticky 头部下方,不并入头部条。 +- **日历采用"FullCalendar 核心 + 自定义外壳"方案。** 保留 FullCalendar 的视图切换、日期网格、事件拖拽能力,但侧栏、事件卡片、头部工具栏使用 token 重绘,FC 内部 `.fc-*` 元素通过覆盖式 CSS 对齐 Notion 风格。 +- **team/board 结果流式在范围内,需后端改动。** 当前 `expert_result` 与 `team_synthesis` 作为完整事件块发送,需引入事件变体(如 `expert_result_chunk` / `team_synthesis_chunk`)或 token 分块推送,前端再做流式渲染,与 `final_answer` 的流式体验对齐。 + +--- + +## Requirements + +### 思考展示(Thinking Display) + +- R1. 思考进行中时,`ThinkingBlock` 默认展开并显示流式光标,token 实时累加,不折叠。 +- R2. 思考完成后自动收起为一行摘要条,摘要条显示思考开始时间与 token 数(或等价摘要信息),点击可重新展开查看完整内容。 +- R3. 摘要条与展开态之间切换不丢失已流式接收的内容,再次展开时定位到上一次滚动位置。 + +### @team/@board 头部(Sticky Mode Header) + +- R4. `@team` / `@board` 模式进入对话后,在消息列表上方渲染一条 sticky 持久头部条,替换现有 `ExpertTeamView` 与 `BoardStatusView` 两个组件。 +- R5. 头部条左侧显示模式 badge("专家团" / "私董会"),中间显示任务目标(@team)或主题(@board),右侧显示专家头像组。 +- R6. 专家头像组中的每个头像可点击,点击后弹出详情面板显示该专家的现有元数据(与 `configs/experts/*.yaml` 中的 `description` / `persona` / `avatar` / `color` 字段对齐;不假设存在 `role` / `bio` 等未定义字段)。 +- R7. `PhaseIndicator`(PLAN_EXEC 模式)独立显示在 sticky 头部条下方,不并入头部条,保持其现有阶段进度展示语义。 + +### 团队/私董会流式输出(Team/Board Streaming) + +- R8. `expert_result` 事件改为流式输出体验:专家生成结果时前端流式累加渲染,与 `final_answer` 的流式体验对齐。后端推送机制(token 分块、文本块、或事件变体)属跨切面依赖,见"Scope Boundaries"。 +- R9. `team_synthesis` 事件改为流式输出体验:Lead 综合阶段的结果前端流式累加渲染。后端推送机制同 R8。 +- R10. 流式过程中显示专家/团队身份标识(如"专家 A 正在输出…"),流式结束后标识保留在最终消息上。此"身份标识"属用户可见的专家 badge,区别于 R12 的内部路由元信息 tag。 +- R11. 流式推送方案待定(事件变体或 token 分块二者择一),由后端协议设计阶段决定;前端订阅流式输出即可,不预设具体实现方案。 + +### 路由元信息标签(Routing Tags) + +- R12. `AssistantText.vue:32-42` 的路由元信息 tag 区(matched_skill / confidence / routing_method)默认隐藏,仅当用户悬停助手消息时显示。 +- R13. 悬停显示时使用淡入过渡,避免突兀闪烁;移开悬停后淡出。 + +### 用户消息悬停操作(User Message Hover Actions) + +- R14. 用户消息气泡(`UserBubble.vue`)支持悬停时显示操作工具条,包含三个操作:复制、删除、回填输入框重发。 +- R15. 复制:将消息文本复制到剪贴板,复制成功后给一个轻量反馈(如工具条图标短暂变色)。 +- R16. 删除:从当前视图隐藏该消息(从 `chatStore.currentMessages` 中移除),需二次确认以防止误删。此为前端隐藏,不删除服务端副本;服务端删除语义留待后续迭代。 +- R17. 回填输入框重发:将消息文本回填到 `ChatInput` 的输入框,不自动发送,由用户决定是否修改后重发;回填后该消息不从列表中删除。 + +### 日历重设计(Calendar Redesign) + +- R18. 提取主界面 Notion 设计语言为可复用 token 集,日历模块统一消费 `styles/tokens.css` 中的颜色、间距、圆角、阴影变量,禁止硬编码颜色值(如 `CalendarGrid.vue:38` 的 `#1677ff`)。 +- R19. 日历侧栏(事件列表/筛选)使用 token 重绘,与主界面的卡片、列表视觉语言一致。 +- R20. 日历事件卡片使用 token 调色板,事件类型颜色从配置映射到 token(如 `--accent-team` 用于团队事件、`--accent-board` 用于私董会事件)。 +- R21. 日历头部工具栏(视图切换、今天按钮、导航箭头)使用 token 重绘,按钮风格与主界面按钮一致。 +- R22. FullCalendar 内部 `.fc-*` 元素通过覆盖式 CSS 对齐 Notion 风格(字体、边框、表头背景、今日高亮),覆盖范围最小化以保持 FC 升级兼容性。 + +--- + +## Scope Boundaries + +### Deferred for later + +- 思考内容的 markdown 渲染:本期 `ThinkingBlock` 仍以纯文本或等宽文本呈现,markdown 解析渲染留待后续迭代。 +- 日历事件的拖拽创建、resize 行为变更:本期仅重绘视觉外壳,不改变 FC 既有交互行为。 +- 专家头像组中专家在线状态指示:本期仅展示静态头像与详情,不接入实时在线状态。 +- 路由元信息 tag 的可配置显示规则:本期固定为"默认隐藏 + 悬停显示",不做用户偏好配置。 + +### Outside this product's identity + +- 不引入新的 UI 组件库或设计系统(继续基于 Ant Design Vue + token 覆盖)。 +- 不替换 FullCalendar 为自建日历组件(保留 FC 核心,仅覆盖外壳与 `.fc-*` 样式)。 +- 不改变 `PhaseIndicator` 的现有阶段进度语义与展示形式(仅独立保留,不重设计)。 + +### Cross-cutting dependencies(非 UI/UE scope,但需协同) + +- 后端流式推送协议:R8/R9 的流式体验依赖后端推送机制(token 分块 / 文本块 / 事件变体),具体方案由后端协议设计阶段决定,不属本文档 scope。 + +--- + +## Open Questions + +需用户判断或设计决策的事项,来自 ce-doc-review 审查。 + +### 交互状态与无障碍 + +- OQ1. 交互状态覆盖(错误态 / 空态 / 加载态)的范围与规范——流式中断、专家失败、无结果等场景如何呈现?(design-lens, P1, conf 100) +- OQ2. 响应式与无障碍策略——sticky 头部、头像组、悬停操作、日历重设计的断点、键盘可达、ARIA 规范?(design-lens, P1, conf 100) + +### 流式输出设计决策 + +- OQ3. R8/R9 流式结构——事件变体(如 `expert_result_chunk`)vs token 分块推送?结构化事件元数据(专家身份、阶段标识)如何与流式 token 边界协调?(adversarial, P1, conf 75) + +### 删除语义 + +- OQ4. R16 删除作用域——前端隐藏(当前 R16 已改述)是否满足用户意图?是否需要服务端删除以避免刷新后消息复活?(adversarial + scope-guardian, P1/P2, conf 75) + +### 用户流与交互细节 + +- OQ5. @team/@board 模式切换与多专家并发流式输出的用户流——并发到达时如何呈现身份与顺序?(design-lens, P2, conf 75) +- OQ6. 关键交互细节——删除确认 UI 形态(Modal/Popconfirm)、头像溢出规则、详情面板组件类型(Drawer/Modal/Popover)?(design-lens, P2, conf 75) + +### FullCalendar 兼容性 + +- OQ7. R22 `.fc-*` 类名覆盖的升级风险容忍度——`.fc-*` 非公开 API,FC 升级时可能破坏;是否接受定期维护成本,或约束覆盖范围至更稳定的选择器?(adversarial, P2, conf 75) + +### Sticky 头部替代方案 + +- OQ8. Sticky 头部决策是否考虑过更简替代——使现有 `ExpertTeamView` / `BoardStatusView` banner 可点击展开详情,而非整体替换为 sticky 条?(adversarial, P2, conf 75) + +### 模式可发现性 + +- OQ9. @team/@board 模式可发现性目标——用户如何得知这两个模式存在?是否需要在输入提示或空态中引导?(product-lens, FYI, conf 50) diff --git a/docs/plans/2026-07-01-001-feat-ui-ue-enhancement-plan.md b/docs/plans/2026-07-01-001-feat-ui-ue-enhancement-plan.md new file mode 100644 index 0000000..296bb98 --- /dev/null +++ b/docs/plans/2026-07-01-001-feat-ui-ue-enhancement-plan.md @@ -0,0 +1,462 @@ +--- +date: 2026-07-01 +type: feat +origin: docs/brainstorms/2026-07-01-ui-ue-enhancement-requirements.md +--- + +# feat: AgentKit UI/UE 增强 — 聊天流式 + Sticky 头部 + 日历重设计 + +## Summary + +强化 AgentKit 聊天与日历模块的 UI/UE:让 agent 思考过程与团队/私董会结果真正流式可见,让 @team/@board 模式在对话顶部持久显式,去除常驻路由噪声,给用户消息补齐复制/删除/回填操作,并让日历回到 Notion 风格的统一设计语言。覆盖 22 个需求(R1-R22),含后端 `_phase_executor.py` 流式切换以打通 expert_result/team_synthesis 的流式链路。 + +--- + +## Problem Frame + +当前聊天主界面的思考展示默认折叠且为纯文本,看不到 agent 推理过程;@team/@board 模式的顶部 banner 为静态文案且不可点击,无法回答"这次任务目标是什么、由哪些专家参与";专家团与私董会的最终结果以完整事件块一次性到达前端,与 `final_answer` 的流式体验割裂;路由元信息 tag 常驻显示对终端用户是无意义噪声;用户消息气泡无任何悬停操作;日历模块硬编码颜色与 `tokens.css` 的 Notion 调色板完全脱节。 + +来源:`docs/brainstorms/2026-07-01-ui-ue-enhancement-requirements.md`(经 ce-doc-review 审查强化,含 9 个 Open Questions)。 + +--- + +## Requirements + +### 需求追溯 + +| 需求组 | R-IDs | 实施单元 | +|--------|-------|----------| +| 思考展示 | R1-R3 | U1 | +| @team/@board 头部 | R4-R7 | U2 | +| 团队/私董会流式 | R8-R11 | U3, U4 | +| 路由标签 | R12-R13 | U5 | +| 用户消息悬停 | R14-R17 | U6 | +| 日历重设计 | R18-R22 | U7 | + +完整需求清单见 origin 文档。关键决策(思考混合方案、sticky 持久条、FC+自定义外壳、流式方案待定)已由 brainstorm 确认。 + +### 成功标准 + +- 思考进行中时 `ThinkingBlock` 展开并显示流式光标,完成后收起为摘要条 +- @team/@board 模式进入对话后顶部渲染 sticky 持久条,专家头像可点击查看详情 +- `expert_result` 与 `team_synthesis` 事件按 token/文本块流式累加渲染,与 `final_answer` 体验对齐 +- 路由元信息 tag 默认隐藏,悬停时淡入显示 +- 用户消息气泡悬停时显示复制/删除/回填操作工具条 +- 日历模块统一消费 `tokens.css`,无硬编码颜色值 + +--- + +## Key Technical Decisions + +1. **ThinkingBlock 流式光标采用 CSS 伪元素 + watch content.length 截断展示**。后端已将 thinking chunks 累积到 `message.thinking`(`chatStream.ts:512-521`),前端无需改事件流,只需在 `ThinkingBlock.vue` 内 watch `content.length` 做截断展示 + 闪烁光标。完成后收起为摘要条显示 token 数。 + +2. **StickyModeHeader 为新组件,替换 `ExpertTeamView` + `BoardStatusView`**。从 `useTeamStore()` 和 `useChatStore().boardState` 读取状态,合并为单一 sticky 条(`position: sticky; top: 0; z-index: var(--z-sticky)`)。`PhaseIndicator` 独立保留在 sticky 头部下方,不合并。 + +3. **expert_result/team_synthesis 流式采用 `execute_stream()` + token 转发 + 前端累积式 updateMessage**。后端 `_phase_executor.py` 从 `agent.execute()` 切换为 `agent.execute_stream()`,在 `async for event in` 循环中转发 `token`/`final_answer` 事件到 WS。前端 `chatStream.ts` 的 `expert_result`/`team_synthesis` 分支从一次性 `appendMessage` 改为累积式 `updateMessage`(复用 `final_answer` 的 526-532 模式)。 + +4. **路由 tag 隐藏采用 `v-show` + opacity transition**(与 U5 Approach 对齐)。`AssistantText.vue` 的 `showRouting` computed 增加 hover 状态依赖,CSS `opacity: 0` → `1` + `transition: opacity 0.2s ease` 淡入淡出。`v-show` 保留 DOM 避免首次悬停的重挂载闪烁。 + +5. **UserBubble 回填采用 chatStore 共享状态**。新增 `chatStore.refillText: string` ref,`UserBubble` 的回填操作设置该值,`ChatInput` watch 该值回填到 `inputText`。删除操作新增 `chatStore.deleteMessage(convId, msgId)` 从 `currentMessages` 移除(仅前端隐藏,不删服务端副本)。 + +6. **日历 .fc-* 覆盖采用最小范围 CSS 覆盖**。仅覆盖字体、边框、表头背景、今日高亮四类样式,选择器限定为 `.fc-toolbar` / `.fc-col-header` / `.fc-day-today` 等稳定类名,接受 FC 升级时可能需要维护的成本。 + +--- + +## High-Level Technical Design + +### 流式事件链路(R8-R11) + +``` +Backend: _phase_executor.py + agent.execute_stream() ← async generator yields ReActEvent + ├─ event_type="token" → broadcast "expert_result_chunk" { expert_id, content } + ├─ event_type="thinking" → broadcast "expert_step" { expert_id, thinking } + ├─ event_type="final_answer" → broadcast "expert_result_chunk" { expert_id, content } + └─ 循环结束 → broadcast "expert_result" { expert_id, content: full, status: "completed" } + +Frontend: chatStream.ts + case "expert_result_chunk": + → updateMessage(convId, lastExpertMsg.id, { content: accumulated + chunk }) + case "expert_result": + → updateMessage(convId, lastExpertMsg.id, { status: "completed" }) ← 仅标记完成,不再 append +``` + +### 组件依赖关系 + +``` +U1 ThinkingBlock (独立) +U2 StickyModeHeader (独立,替换两个旧组件) +U3 后端流式切换 (独立,无前端依赖) +U4 前端流式消费 (依赖 U3 联调,可 mock 开发) +U5 路由 tag 隐藏 (独立) +U6 UserBubble hover (独立,需 chatStore 新方法) +U7 CalendarGrid 重设计 (独立) +``` + +--- + +## Implementation Units + +### U1. ThinkingBlock 流式展示重设计 + +**Goal:** 让 agent 思考过程在执行时展开流式显示,完成后收起为摘要条,切换不丢失内容。 + +**Requirements:** R1, R2, R3 + +**Dependencies:** 无 + +**Files:** +- `src/agentkit/server/frontend/src/components/chat/ThinkingBlock.vue`(修改) +- `src/agentkit/server/frontend/tests/unit/components/ThinkingBlock.test.ts`(新建) + +**Approach:** +- 默认展开(`expanded = ref(true)`),`isStreaming` 时显示流式光标(CSS `::after` 伪元素闪烁动画) +- watch `content.length` 截断展示:流式中仅显示已到达的字符,模拟 token-by-token 效果 +- 完成后(`isStreaming` 由 true→false)自动收起为摘要条,显示思考开始时间与字符数 +- 摘要条点击切换展开/收起,展开时定位到上一次滚动位置(记录 `scrollTop` 到 ref) +- 状态保持:收起时内容不丢失,`content` prop 始终持有完整文本 + +**Patterns to follow:** +- `ChatView.vue:47-76` 的 streamingSteps 渲染模式(字符数 counter) +- `final_answer` 在 `chatStream.ts:526-532` 的累积模式 + +**Test scenarios:** +- 流式中展开态显示光标 + 已到达字符,完成后自动收起为摘要条 +- 摘要条点击展开,显示完整思考内容 +- 展开→收起→再展开,滚动位置恢复到上次位置 +- 空思考内容(`content=""`)时不显示 ThinkingBlock +- `isStreaming=true` 但 `content=""` 时显示加载占位 + +**Verification:** 思考流式中可见光标闪烁与字符累加,完成后收起为一行摘要,点击可重新展开查看完整内容。 + +--- + +### U2. StickyModeHeader + PhaseIndicator token 化 + +**Goal:** 替换静态 banner 为 sticky 持久头部条,显示模式 badge + 任务目标 + 专家头像组;PhaseIndicator 独立保留并 token 化。 + +**Requirements:** R4, R5, R6, R7 + +**Dependencies:** 无 + +**Files:** +- `src/agentkit/server/frontend/src/components/chat/StickyModeHeader.vue`(新建) +- `src/agentkit/server/frontend/src/components/chat/ExpertTeamView.vue`(废弃/删除引用) +- `src/agentkit/server/frontend/src/components/chat/BoardStatusView.vue`(废弃/删除引用) +- `src/agentkit/server/frontend/src/components/chat/PhaseIndicator.vue`(修改 — token 化) +- `src/agentkit/server/frontend/src/views/ChatView.vue`(修改 — 替换 banner 引用) +- `src/agentkit/server/frontend/tests/unit/components/StickyModeHeader.test.ts`(新建) + +**Approach:** +- `StickyModeHeader` 从 `useTeamStore()` 读取 `isTeamMode` / `teamState`(含 `task_description` / `experts`),从 `useChatStore().boardState` 读取私董会状态。注:实际 store 暴露 `teamState` / `activeExperts` / `leadExpert`,无 `currentPlan` / `experts` 顶层 ref(FYI F8 提示)— 实施时通过 `teamState.value?.task_description` 与 `activeExperts` / `teamState.value?.experts` 访问 +- 左侧:模式 badge("专家团" / "私董会"),使用 `--accent-team` / `--accent-board` +- 中间:任务目标(@team 取 `teamState.value?.task_description`)或主题(@board 取 `boardState.topic`) +- 右侧:专家头像组(`v-for` 渲染 `activeExperts`,每个显示 `avatar` emoji + `color` 边框),溢出时显示 `+N` +- 头像点击弹出 `a-popover` 详情面板,显示 `name` / `description` / `persona`(现有字段,不假设 role/bio)。**Popover 关闭**:Esc 键 + 外击关闭;关闭时焦点回归触发头像(focus management)。**移动端**:viewport<768px 隐藏任务主题文本,仅显示 mode badge + avatar group;`+N` 溢出点击打开完整专家列表 popover +- `PhaseIndicator` 硬编码颜色(`#722ed1` / `#52c41a` / `#cf1322`)替换为 token(`--accent-board` / `--color-success` / `--color-error`) +- ChatView.vue 中 `` + `` 替换为 ``,`` 保留在其下方 + +**Patterns to follow:** +- `MessageShell.vue:5-11` 的 custom-avatar 渲染逻辑 +- `tokens.css` 的 `--z-sticky: 1020` / `--accent-team` / `--accent-board` + +**Test scenarios:** +- @team 模式进入对话后渲染 sticky 条,显示"专家团" badge + 任务目标 + 专家头像 +- @board 模式进入对话后渲染 sticky 条,显示"私董会" badge + 主题 + 专家头像 +- 非 team/board 模式不渲染 sticky 条 +- 专家头像点击弹出 popover 显示 name/description/persona +- Popover 通过 Esc 键关闭,关闭后焦点回归触发头像 +- Popover 通过外击关闭 +- 头像超过 5 个时显示 `+N` 溢出标识,`+N` 点击打开完整专家列表 popover +- viewport<768px 时隐藏任务主题文本,仅显示 mode badge + avatar group +- PhaseIndicator 颜色使用 token 变量而非硬编码 + +**Verification:** @team/@board 模式下顶部持久显示 sticky 头部条,专家头像可点击查看详情,PhaseIndicator 颜色与 tokens.css 一致。 + +--- + +### U3. 后端 _phase_executor.py 流式切换 + +**Goal:** 将专家阶段执行从同步 `agent.execute()` 切换为流式 `agent.execute_stream()`,转发 token/thinking/final_answer 事件到 WS。 + +**Requirements:** R8, R9(后端依赖) + +**Dependencies:** ConfigDrivenAgent.execute_stream() 暴露 — 需先在 BaseAgent/ConfigDrivenAgent 暴露 stream dispatch(react/rewoo/plan_exec/reflexion 模式委派至底层引擎;direct/llm_generate/tool_call/custom 模式定义显式 fallback) + +**Files:** +- `src/agentkit/core/config_driven.py`(修改 — 暴露 `execute_stream()` + `handle_task_stream()` dispatch,镜像 `execute()` → `handle_task()` 模式) +- `src/agentkit/core/base.py`(修改 — 在 BaseAgent 加 `execute_stream()` 抽象方法签名) +- `src/agentkit/experts/_phase_executor.py`(修改 — `_run_agent_steps` 方法) +- `src/agentkit/experts/_synthesizer.py`(修改 — `_synthesize_results` 注入 `broadcast_callback` 参数,保留 dict 返回类型,方法内每次 LLM chunk 调用 callback 广播 `team_synthesis_chunk`) +- `src/agentkit/experts/orchestrator.py`(修改 — `_synthesize_results` 调用处传入 `_broadcast_event` 的部分应用作为 callback;`final_result = await self._synthesize_results(...)` 与 `final_result.get("content")` 消费保持不变) +- `tests/unit/experts/test_phase_executor_streaming.py`(新建) + +**Approach:** +- `_run_agent_steps`(line 222)从 `await agent.execute(task_msg)` 改为 `async for event in agent.execute_stream(task_msg, ...)` 循环 +- 循环内按 `event.event_type` 转发(载荷在 `event.data: dict[str, object]`,无 `event.content` / `event.output` 属性 — 见 `react.py:131-137` ReActEvent 定义): + - `"token"` → `_broadcast_event("expert_result_chunk", { expert_id, content: event.data.get("content") })` + - `"thinking"` → `_broadcast_event("expert_step", { expert_id, thinking: event.data.get("content") })` + - `"final_answer"` → `_broadcast_event("expert_result_chunk", { expert_id, content: event.data.get("output") })` + - `"tool_call"` / `"tool_result"` → 可选转发(保持现有 `expert_step` 语义) +- 循环结束后广播 `expert_result` 完整事件(content = 累积的完整结果,status: "completed") +- `TeamOrchestrator._synthesize_results` 接口策略:注入 `broadcast_callback` 参数,保留 `dict[str, object]` 返回类型(对 `orchestrator.py:289` 消费者零破坏)。方法内在每次 LLM 输出 chunk 时调用 `broadcast_callback({"chunk": text})` 广播 `team_synthesis_chunk`,最终返回完整 dict;`orchestrator.py:294-301` 的最终 `team_synthesis` 广播路径不变 +- **重试 + 流式合约**:`_run_agent_steps` 的 retry 循环(`_phase_executor.py:220-241`)与流式交互须遵循:(a) 重试前广播 `expert_result_chunk_reset` 事件,前端清空已累积内容;(b) 最终 `expert_result` 事件携带完整内容覆盖(前端在 `expert_result` 到达时替换而非累加);(c) 重试不广播新 chunks 直到本次流式完成 — 避免失败 attempt 1 的部分 chunks 与 attempt 2 的 chunks 在前端累积成乱码 +- 异步生成器安全:遵循 AGENTS.md 约定,`async def` 中第一个 `yield` 前禁止 `return`(用 `return; yield` 模式) +- 异常分类:遵循 `docs/solutions/conventions/any-and-except-exception-governance.md`,WS 广播用 `except (ConnectionError, RuntimeError, asyncio.TimeoutError)` + +**Patterns to follow:** +- `react.py:1443` 的 `execute_stream` 签名与 ReActEvent 结构 +- `chatStream.ts:526-532` 的 final_answer 累积模式(后端镜像) +- `docs/solutions/logic-errors/long-horizon-reliability-code-review-fixes.md` 的 `execute_stream` 入口 `self.reset()` 前置条件 + +**Test scenarios:** +- `execute_stream` 产出 token 事件时,`_broadcast_event` 被调用转发 `expert_result_chunk` +- `execute_stream` 产出 final_answer 事件时,`_broadcast_event` 被调用转发 `expert_result_chunk` +- 循环结束后广播完整 `expert_result` 事件 +- `execute_stream` 抛出异常时,phase 状态标记为 failed 且不阻塞其他阶段 +- `execute_stream` 中途抛出异常时,广播 `expert_result` 事件 status="error" 携带已累积内容,前端可识别错误态并保留部分内容 +- 流式会话必须以 `expert_result(completed)` 或 `expert_result(error)` 终结,不允许静默挂起 +- `execute_stream` 在 2 chunks 后抛异常 → retry loop 触发 → 广播 `expert_result_chunk_reset` → 重试成功 → 前端消息仅含 attempt 2 内容(无 attempt 1 残留) +- TeamOrchestrator 综合阶段流式广播 `team_synthesis_chunk` + +**Verification:** 后端日志可见 `expert_result_chunk` 事件按 token 推送,最终 `expert_result` 完整事件作为结束标记。 + +--- + +### U4. 前端 expert_result/team_synthesis 流式消费 + 身份标识 + +**Goal:** 前端将 expert_result/team_synthesis 从一次性 append 改为累积式 update,流式过程中显示专家身份标识。 + +**Requirements:** R8, R9, R10, R11 + +**Dependencies:** U3(联调,可 mock 开发) + +**Files:** +- `src/agentkit/server/frontend/src/stores/chatStream.ts`(修改 — expert_result/team_synthesis 分支) +- `src/agentkit/server/frontend/src/components/chat/messages/MessageShell.vue`(修改 — 渲染 expert_name/expert_color badge) +- `src/agentkit/server/frontend/tests/unit/stores/chatStream.test.ts`(修改 — 新增流式测试) + +**Approach:** +- 新增 `expert_result_chunk` 事件分支:若当前无该专家的流式消息则 `appendMessage` 创建占位(`status: "streaming"`),否则 `updateMessage` 累加 content(复用 final_answer 526-532 模式) +- `expert_result` 事件分支改为:仅 `updateMessage` 标记 `status: "completed"`(不再 appendMessage);`status="error"` 时标记为错误态并保留部分内容 +- `expert_result_chunk_reset` 事件分支(U3 重试合约):清空已累积 content,重置 streaming 状态 +- `team_synthesis_chunk` 同理:首次 append 占位,后续 update 累加 +- `team_synthesis` 事件改为:仅 update 标记完成 +- **并发专家流式顺序**:每个 `expert_id` 维护独立 streaming message slot(keyed by `expert_id`),同 expert 的 chunks 累积到该 expert 的消息;并发专家渲染为独立并行 streaming 消息,按 first-chunk 到达时间排序 +- 流式消息渲染时显示身份标识(`expert_name` + `expert_color` badge),流式结束后标识保留 +- R10 身份标识为用户可见的专家 badge(区别于 R12 的内部路由 tag),通过 `message.expert_name` / `message.expert_color` 在 MessageShell 中渲染 + +**Patterns to follow:** +- `chatStream.ts:526-532` 的 `final_answer` 累积模式 +- `dispatchWsEvent` 纯函数设计(入参 `ChatStreamState`,无模块级状态) + +**Test scenarios:** +- `expert_result_chunk` 首次到达时创建 streaming 占位消息 +- `expert_result_chunk` 后续到达时累加 content 到已有消息 +- `expert_result` 到达时标记消息 status 为 completed +- `expert_result` 到达时 status="error" 时,标记 streaming 消息为错误态并保留部分内容 +- `expert_result_chunk_reset` 到达时清空已累积 content 并重置 streaming 状态 +- `team_synthesis_chunk` 首次到达时创建 streaming 占位 +- `team_synthesis` 到达时标记完成 +- 流式消息显示 expert_name + expert_color badge +- 流式会话异常终止时,UI 显示错误指示器不静默挂起 +- 两个专家并发流式 → 两条独立 streaming 消息,各自累积自己的 chunks,按 first-chunk 到达时间排序 + +**Verification:** 专家结果按 token 流式累加显示,完成后标记为 completed,身份标识保留在最终消息上。 + +--- + +### U5. AssistantText 路由标签悬停显示 + +**Goal:** 路由元信息 tag 默认隐藏,悬停助手消息时淡入显示。 + +**Requirements:** R12, R13 + +**Dependencies:** 无 + +**Files:** +- `src/agentkit/server/frontend/src/components/chat/messages/AssistantText.vue`(修改) +- `src/agentkit/server/frontend/tests/unit/components/AssistantText.test.ts`(新建) + +**Approach:** +- `showRouting` computed 增加 hover 状态依赖:`return props.message.role === 'assistant' && props.message.matched_skill && isHovered.value` +- 根容器加 `@mouseenter` / `@mouseleave` 事件设置 `isHovered = ref(false)` +- CSS `.assistant-text__routing` 加 `transition: opacity 0.2s ease`,默认 `opacity: 0`,hover 时 `opacity: 1` +- 采用 `v-show` + opacity transition(保留 DOM 避免首次悬停的重挂载闪烁,`opacity:0→1` + `transition:opacity 0.2s ease` 满足 R13 淡入要求) + +**Patterns to follow:** +- Ant Design Vue 的 `` 组件用法 +- `tokens.css` 的 `--transition-base` 变量(若存在) + +**Test scenarios:** +- 默认状态路由 tag 不可见 +- 鼠标悬停助手消息时 tag 淡入显示 +- 鼠标移开时 tag 淡出 +- 无 `matched_skill` 的消息悬停时也不显示 tag +- 淡入淡出过渡平滑无闪烁 + +**Verification:** 路由 tag 默认不可见,悬停时淡入,移开时淡出。 + +--- + +### U6. UserBubble 悬停操作(复制/删除/回填) + +**Goal:** 用户消息气泡悬停时显示操作工具条,支持复制、删除(前端隐藏)、回填输入框重发。 + +**Requirements:** R14, R15, R16, R17 + +**Dependencies:** 无(chatStore 新方法独立于其他单元) + +**Files:** +- `src/agentkit/server/frontend/src/components/chat/messages/UserBubble.vue`(修改) +- `src/agentkit/server/frontend/src/stores/chatStore.ts`(修改 — 新增 deleteMessage / refillText) +- `src/agentkit/server/frontend/src/components/chat/ChatInput.vue`(修改 — watch refillText) +- `src/agentkit/server/frontend/tests/unit/components/UserBubble.test.ts`(新建) + +**Approach:** +- `UserBubble.vue` 根容器加 `@mouseenter` / `@mouseleave` 控制 `showActions = ref(false)` +- **focus-visible parity**:根容器加 `@focus` / `@blur`(或 `:focus-within`)使 Tab 聚焦时也显示工具条(键盘用户可达);Touch 设备 tap on bubble 切换显示,tap elsewhere 隐藏 +- 悬停时显示工具条(`v-if="showActions"`):复制按钮(`CopyOutlined`)、删除按钮(`DeleteOutlined`)、回填按钮(`EditOutlined`) +- 复制:`navigator.clipboard.writeText(content)`,成功后按钮短暂变色反馈 +- 删除:弹出 `a-popconfirm` 二次确认,确认后调 `chatStore.deleteMessage(convId, msgId)` 从 `currentMessages` 移除(仅前端隐藏,不删服务端副本);**若有助手回复跟随**(thread order 中下一条为 assistant 消息),删除按钮 disabled 并显示 tooltip "该消息已有回复,无法删除" +- 回填:调 `chatStore.setRefillText(content)`,`ChatInput` watch `refillText` 回填到 `inputText`,不自动发送 +- chatStore 新增:`refillText = ref('')`、`deleteMessage(convId, msgId)`、`setRefillText(text)` + +**Patterns to follow:** +- `chatStore.resendLastUserMessage()`(chatStore.ts:449-462)的消息定位模式 +- `chatStore.deleteConversation` 的 pending 状态清理模式(见 `calendar-capability-and-ui-fixes.md` 学习) + +**Test scenarios:** +- 悬停时显示三个操作按钮 +- Tab 聚焦用户消息时显示工具条;Enter 触发默认动作(复制) +- Touch 设备 tap on bubble 显示工具条,tap elsewhere 隐藏 +- 复制按钮点击后内容写入剪贴板,按钮变色反馈 +- 删除按钮点击后弹出二次确认,确认后消息从列表消失 +- 删除取消后消息不消失 +- 删除有助手回复跟随的用户消息 → 删除按钮 disabled + tooltip "该消息已有回复,无法删除" +- 回填按钮点击后 ChatInput 输入框显示消息文本,不自动发送 +- 离开悬停后操作工具条消失 + +**Verification:** 悬停用户消息可见三个操作,复制/删除/回填功能正常,删除有二次确认。 + +--- + +### U7. CalendarGrid token 重设计 + +**Goal:** 日历模块统一消费 `tokens.css`,消除硬编码颜色,`.fc-*` 元素对齐 Notion 风格。 + +**Requirements:** R18, R19, R20, R21, R22 + +**Dependencies:** 无(tokens.css 已含全部变量;本期不修改 tokens.css) + +**Files:** +- `src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue`(修改) +- `src/agentkit/server/frontend/src/styles/calendar-overrides.css`(新建 — .fc-* 覆盖) +- `src/agentkit/server/frontend/tests/unit/components/CalendarGrid.test.ts`(新建) + +**Approach:** +- `CalendarGrid.vue:38` 的 `#1677ff` 替换为按 event_type 映射到 token(显式映射表): + - `event_type=team` → `var(--accent-team)` + - `event_type=board` → `var(--accent-board)` + - `event_type=task` → `var(--color-primary)` + - `event_type=reminder` → `var(--color-warning)` + - `event_type=system` → `var(--text-tertiary)` + - 默认 → `var(--color-primary)` + - 实施前需从 `CalendarGrid.vue` 的事件源枚举实际 `event_type` 值 +- 事件卡片 `backgroundColor` / `borderColor` 改为从 token 映射函数返回 +- 新建 `calendar-overrides.css` 覆盖 `.fc-toolbar`(按钮风格)、`.fc-col-header`(表头背景)、`.fc-day-today`(今日高亮)、`.fc-event`(事件卡片字体/边框) +- 覆盖范围最小化:仅覆盖 4 类稳定类名,接受 FC 升级时可能需要维护的成本 +- 侧栏与头部工具栏组件使用 token 重绘 + +**Patterns to follow:** +- `tokens.css` 的 Notion 调色板(`--color-primary: #1a1a1a`、`--accent-team`、`--accent-board`) +- `CalendarGrid.vue` 现有的 `firstDay: 1` 配置(见 `jwt-secret-dev-mode-user-id-mismatch.md` 学习) + +**Test scenarios:** +- 日历事件颜色按 event_type 映射到 token,无硬编码颜色值 +- `.fc-toolbar` 按钮风格与主界面按钮一致 +- `.fc-day-today` 今日高亮使用 token 色 +- 暗色模式(`[data-theme="dark"]`)下日历正确显示 +- FullCalendar 视图切换/日期导航/事件拖拽功能不受影响 +- Calendar 零事件 → 空态 placeholder 文本使用 `--text-tertiary` token +- 事件获取失败 → 错误态带 retry CTA(使用 `--color-error` token) +- 事件加载中 → skeleton loader 匹配 token 间距(使用 `--color-border` / `--color-fill-quaternary`) + +**Verification:** 日历视觉与主界面 Notion 风格一致,无硬编码颜色,FC 既有功能正常。 + +--- + +## Scope Boundaries + +### Deferred for later + +- 思考内容的 markdown 渲染:本期 ThinkingBlock 仍以纯文本呈现(origin 文档 Deferred) +- 日历事件的拖拽创建、resize 行为变更:仅重绘视觉外壳(origin 文档 Deferred) +- 专家头像组在线状态指示:仅展示静态头像(origin 文档 Deferred) +- 路由元信息 tag 的可配置显示规则:固定为"默认隐藏 + 悬停显示"(origin 文档 Deferred) +- 后端流式推送协议的具体方案(事件变体 vs token 分块):U3 采用 `execute_stream()` + token 转发,具体事件命名(`expert_result_chunk` 等)可在实施时调整 + +### Outside this product's identity + +- 不引入新的 UI 组件库或设计系统(继续基于 Ant Design Vue + token 覆盖) +- 不替换 FullCalendar 为自建日历组件(保留 FC 核心,仅覆盖外壳与 `.fc-*` 样式) +- 不改变 `PhaseIndicator` 的现有阶段进度语义与展示形式(仅独立保留 + token 化) + +### Deferred to Follow-Up Work + +- R16 服务端删除语义:本期仅前端隐藏,服务端删除留待后续迭代(origin OQ4) +- 交互状态全覆盖(错误态/空态/加载态规范):origin OQ1,本期在相关单元测试中覆盖核心场景,全面规范留待后续 +- 响应式与无障碍全面策略:origin OQ2,本期在组件中遵循基本 a11y,全面策略留待后续 +- @team/@board 模式可发现性引导:origin OQ9(FYI 级别) + +--- + +## Risks & Dependencies + +| 风险 | 影响 | 缓解 | +|------|------|------| +| `_phase_executor.py` 流式切换可能影响阶段执行稳定性 | expert_result 流式中断或丢失;retry loop 与流式交互导致前端累积乱码 | U3 单独测试;遵循 `execute_stream` 入口 `self.reset()` 前置条件;retry+流式合约:重试前广播 `expert_result_chunk_reset` 让前端清空,最终 `expert_result` 覆盖累积(详见 U3 Approach) | +| FullCalendar `.fc-*` 类名升级时可能破坏 | 日历样式失效 | 覆盖范围最小化(4 类稳定类名);在 `calendar-overrides.css` 顶部注释标注风险 | +| chatStore 新增 `deleteMessage` 可能影响会话状态一致性 | 消息索引错乱 | 参考 `deleteConversation` 的 pending 清理模式;单元测试覆盖删除后消息索引 | +| U3/U4 联调依赖 | 前端流式无法独立验证 | U4 可用 mock `expert_result_chunk` 事件开发;U3 完成后联调 | + +--- + +## System-Wide Impact + +- **前端**:ChatView 组件树结构调整(banner 替换为 sticky header);chatStore 新增方法与状态;chatStream 事件分支改造 +- **后端**:`_phase_executor.py` 执行模式变更(execute → execute_stream);WS 事件新增 `expert_result_chunk` / `team_synthesis_chunk` +- **测试**:新增组件测试文件(ThinkingBlock / StickyModeHeader / AssistantText / UserBubble);chatStream.test.ts 扩展流式测试 +- **样式**:新增 `calendar-overrides.css`;PhaseIndicator token 化;CalendarGrid token 化 + +--- + +## Open Questions + +来自 origin 文档的 9 个 Open Questions 处理状态: + +| OQ | 状态 | 处理方式 | +|----|------|----------| +| OQ1 交互状态覆盖 | Deferred | 本期测试覆盖核心场景,全面规范留待后续 | +| OQ2 响应式与无障碍 | Deferred | 本期遵循基本 a11y,全面策略留待后续 | +| OQ3 流式结构 | Resolved | U3 采用 `execute_stream()` + token 转发 | +| OQ4 R16 删除作用域 | Resolved | 前端隐藏,服务端删除留待后续 | +| OQ5 模式切换用户流 | Addressed | U2 Approach 中覆盖 | +| OQ6 关键交互细节 | Addressed | U2/U6 Approach 中指定(Popover/Popconfirm) | +| OQ7 .fc-* 兼容性 | Noted as Risk | Risks 表中标注 | +| OQ8 Sticky 头部替代 | Resolved | brainstorm 已确认 sticky 方案 | +| OQ9 模式可发现性 | FYI | Deferred to Follow-Up | +| OQ10 (NEW) U3 后端协议 scope 声明 | Deferred | origin 行 88 显式排除后端流式协议("不属本文档 scope"),但 U3 已纳入本计划作为实施单元。是否重新分类 U3 为 "Cross-cutting dependency unit"(与 UI/UE 单元分开)或显式标注为 scope 扩展?建议实施前确认。 | +| OQ11 (NEW) PhaseIndicator token 化 R-id 追溯 | Deferred | U2 中 PhaseIndicator 颜色 token 化(`#722ed1`/`#52c41a`/`#cf1322` → `--accent-board`/`--color-success`/`--color-error`)无对应 R-id 追溯(origin R7 仅要求"独立保留 + token 化")。是否新增 R-id 显式覆盖此 token 化工作,或延后到独立 token 化 pass 处理其他组件同类硬编码? | + +--- + +## Sources & Research + +- Origin: `docs/brainstorms/2026-07-01-ui-ue-enhancement-requirements.md`(ce-brainstorm 产出 + ce-doc-review 审查强化) +- `docs/solutions/logic-errors/long-horizon-reliability-code-review-fixes.md` — `execute_stream` 入口 reset 前置条件、team_synthesis 综合阶段完整内容读取 +- `docs/solutions/ui-bugs/calendar-agent-create-no-refresh.md` — WS 事件链端到端追踪模式、notify_callback 注入 +- `docs/solutions/logic-errors/calendar-capability-and-ui-fixes.md` — SystemMonitorPanel flex 方向、deleteConversation pending 清理 +- `docs/solutions/conventions/any-and-except-exception-governance.md` — WebSocket 异常分类、CancelledError 守卫 +- `docs/solutions/integration-issues/jwt-secret-dev-mode-user-id-mismatch.md` — CalendarGrid firstDay 配置 +- 代码库研究:`react.py:1443`(execute_stream)、`_phase_executor.py:222`(流式缺口)、`chatStream.ts:526-532`(final_answer 累积模式)、`tokens.css`(Notion 调色板)、`configs/experts/*.yaml`(专家元数据字段) diff --git a/docs/residual-review-findings/feat-ui-ue-enhancement.md b/docs/residual-review-findings/feat-ui-ue-enhancement.md new file mode 100644 index 0000000..308fa0d --- /dev/null +++ b/docs/residual-review-findings/feat-ui-ue-enhancement.md @@ -0,0 +1,34 @@ +# Residual Review Findings — feat/ui-ue-enhancement + +**Run context**: ce-code-review mode:agent, run_id `20260701-122351-b8e97648` +**Branch**: feat/ui-ue-enhancement +**Base**: 521f573 (origin/main) +**Date**: 2026-07-01 + +## Residual Review Findings + +以下 actionable findings 未在 Step 5 应用,需要后续处理: + +- **P1** | `src/agentkit/experts/_phase_executor.py:241` | expert_step 流式事件 payload 缺少 expert_name/expert_color/content/step 字段,与前端 WsServerMessage 契约不匹配,产生损坏的占位消息 | autofix_class: manual, owner: downstream-resolver +- **P2** | `src/agentkit/core/config_driven.py:693` | execute_stream 绕过 execute(),未注册 CancellationToken,流式任务无法被 cancel_task() 协作式取消 | autofix_class: gated_auto, owner: downstream-resolver, requires_verification: true +- **P2** | `src/agentkit/experts/orchestrator.py:293` | team_synthesis 流式中断后留下孤儿 streaming milestone 占位(无终结事件),前端永久转圈 | autofix_class: manual, owner: downstream-resolver +- **P2** | `src/agentkit/server/frontend/src/stores/chatStream.ts:880` | team_synthesis_chunk 占位匹配缺少 synthesis_id 去重,可附身到上一次孤儿 milestone | autofix_class: manual, owner: downstream-resolver + +## Advisory findings (report-only) + +- P2 | `_phase_executor.py` | 三个流式 handler 方法存在大量重复代码(_handle_rewoo_stream/_handle_plan_exec_stream/_handle_reflexion_stream) +- P2 | `config_driven.py:856` | 同步 _handle_react 未复用 _build_llm_messages,存在漂移风险 +- P2 | `chatStore.ts:489` | deleteMessage 仅前端隐藏,无 UI 提示用户消息会在重新同步后恢复 +- P3 | `base.py:177` | execute_stream 缺少 on_task_start/on_task_complete/on_task_failed 生命周期钩子调用 +- P3 | `config_driven.py:686` | ConfigDrivenAgent 流式分派逻辑无单元测试 +- P3 | `chatStore.ts:1751` | hasReply 守卫仅检查紧邻下一条消息,无法覆盖跨消息回复场景 +- P3 | `chatStream.ts:1904` | per-token 字符串拼接在热路径上为 O(n²) 分配(V8 rope strings 优化,影响有限) +- P3 | `ThinkingBlock.vue:136` | summary computed 依赖 content.length,每 token 重算 +- P3 | `UserBubble.vue:1640` | 每个 bubble 挂载独立 document touchstart 监听器 + +## Applied in Step 5 + +- **P0 fix** | `chat.py:144` | _VALID_TEAM_EVENT_TYPES 添加 expert_result_chunk/expert_result_chunk_reset/team_synthesis_chunk +- **P0 fix** | `_phase_executor.py:246` | final_answer 不重复累积 token 内容(避免内容翻倍) +- **P2 fix** | `_phase_executor.py:278` | 异常处理扩展为 except Exception 兜底(捕获 LLMProviderError 等) +- **Test fix** | `test_phase_executor_streaming.py` | 3 个测试更新/新增以符合 ReActEngine token+final_answer 合约 diff --git a/src/agentkit/core/base.py b/src/agentkit/core/base.py index fe715cf..75c6629 100644 --- a/src/agentkit/core/base.py +++ b/src/agentkit/core/base.py @@ -12,6 +12,7 @@ import json import logging import time from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator from datetime import datetime, timezone from typing import TYPE_CHECKING @@ -30,6 +31,7 @@ from agentkit.core.protocol import ( ) if TYPE_CHECKING: + from agentkit.core.react import ReActEvent from agentkit.memory.base import Memory from agentkit.tools.base import Tool from agentkit.llm.gateway import LLMGateway @@ -160,6 +162,27 @@ class BaseAgent(ABC): """返回 Agent 能力声明""" ... + # ── 流式执行(U3) ──────────────────────────────────────── + + async def execute_stream(self, task: TaskMessage) -> AsyncGenerator["ReActEvent", None]: + """流式执行任务,yield ReActEvent 事件。 + + 与 execute() 不同,此方法不包装 TaskResult — 直接 yield 事件, + 由调用方(如 PhaseExecutorMixin._run_agent_steps)负责转发和 + 最终结果累积。默认实现回退到 handle_task 并包装为单个 + final_answer 事件;子类应覆写以提供真正的流式输出。 + """ + # Default fallback: run sync handle_task, wrap as single final_answer. + # Subclasses (e.g. ConfigDrivenAgent) override for real streaming. + from agentkit.core.react import ReActEvent + + output = await self.handle_task(task) + yield ReActEvent( + event_type="final_answer", + step=0, + data={"output": output.get("content", str(output))}, + ) + # ── 生命周期钩子(可选覆写) ────────────────────────────── async def on_task_start(self, task: TaskMessage) -> None: diff --git a/src/agentkit/core/config_driven.py b/src/agentkit/core/config_driven.py index 92f2c94..5db680f 100644 --- a/src/agentkit/core/config_driven.py +++ b/src/agentkit/core/config_driven.py @@ -10,6 +10,7 @@ import json import logging import os +from collections.abc import AsyncGenerator, Awaitable from typing import Callable, Coroutine import yaml @@ -17,6 +18,7 @@ import yaml from agentkit.core.base import BaseAgent from agentkit.core.exceptions import ConfigValidationError from agentkit.core.protocol import AgentCapability, TaskMessage +from agentkit.core.react import ReActEvent from agentkit.evolution.lifecycle import EvolutionMixin from agentkit.evolution.reflector import Reflector from agentkit.prompts.section import PromptSection @@ -654,6 +656,203 @@ class ConfigDrivenAgent(BaseAgent, EvolutionMixin): reason=f"Unknown task_mode: {self._config.task_mode}", ) + # ── 流式执行(U3) ──────────────────────────────────────── + + def _build_llm_messages( + self, task: TaskMessage + ) -> tuple[str | None, list[dict[str, str]]]: + """Build (system_prompt, user_messages) from task + prompt template. + + Shared by all _handle_*_stream methods to avoid duplicating the + message-rendering logic that mirrors the sync _handle_* methods. + """ + variables = task.input_data.copy() + variables["task_type"] = task.task_type + if self._prompt_template: + rendered_messages = self._prompt_template.render(variables=variables) + else: + rendered_messages = [{"role": "user", "content": str(task.input_data)}] + system_prompt: str | None = None + user_messages: list[dict[str, str]] = [] + for msg in rendered_messages: + if msg["role"] == "system": + system_prompt = msg["content"] + else: + user_messages.append(msg) + if not user_messages: + user_messages.append({"role": "user", "content": str(task.input_data)}) + return system_prompt, user_messages + + async def execute_stream(self, task: TaskMessage) -> AsyncGenerator[ReActEvent, None]: + """流式执行任务,yield ReActEvent。 + + 镜像 execute() → handle_task() 分派,但不包装 TaskResult — + 直接 yield 事件,由调用方负责转发和累积。 + """ + await self._register_mcp_tools() + async for event in self.handle_task_stream(task): + yield event + + async def handle_task_stream(self, task: TaskMessage) -> AsyncGenerator[ReActEvent, None]: + """根据 execution_mode / task_mode 流式分派,镜像 handle_task()。""" + if self._skill_config: + execution_mode = self._skill_config.execution_mode + if execution_mode == "react" and self._react_engine: + async for e in self._handle_react_stream(task): + yield e + return + if execution_mode == "rewoo" and self._llm_gateway: + async for e in self._handle_rewoo_stream(task): + yield e + return + if execution_mode == "plan_exec" and self._llm_gateway: + async for e in self._handle_plan_exec_stream(task): + yield e + return + if execution_mode == "reflexion" and self._llm_gateway: + async for e in self._handle_reflexion_stream(task): + yield e + return + if execution_mode == "direct": + async for e in self._wrap_sync_as_stream(self._handle_direct, task): + yield e + return + if execution_mode == "custom": + async for e in self._wrap_sync_as_stream(self._handle_custom, task): + yield e + return + # Fall back to task_mode modes + if self._config.task_mode == "llm_generate": + async for e in self._wrap_sync_as_stream(self._handle_llm_generate, task): + yield e + return + if self._config.task_mode == "tool_call": + async for e in self._wrap_sync_as_stream(self._handle_tool_call, task): + yield e + return + if self._config.task_mode == "custom": + async for e in self._wrap_sync_as_stream(self._handle_custom, task): + yield e + return + # Unknown mode — wrap sync result as single final_answer event + result = await self.handle_task(task) + yield ReActEvent( + event_type="final_answer", + step=0, + data={"output": result.get("content", str(result))}, + ) + + async def _handle_react_stream(self, task: TaskMessage) -> AsyncGenerator[ReActEvent, None]: + """ReAct mode streaming: delegate to ReActEngine.execute_stream().""" + if self._evolution_enabled and self._current_module is None: + self._auto_set_current_module() + system_prompt, user_messages = self._build_llm_messages(task) + retrieval_config = self._config.memory.get("retrieval", {}) if self._config.memory else {} + async for event in self._react_engine.execute_stream( # type: ignore[union-attr] + messages=user_messages, + tools=self.get_tools() or None, + model=self._config.llm.get("model", "default") if self._config.llm else "default", + agent_name=self.name, + task_type=task.task_type, + system_prompt=system_prompt, + memory_retriever=self._memory_retriever, + task_id=task.task_id, + retrieval_config=retrieval_config or None, + cancellation_token=self._active_tokens.get(task.task_id), + timeout_seconds=float(task.timeout_seconds) if task.timeout_seconds > 0 else None, + compressor=self._compressor, + ): + yield event + + async def _handle_rewoo_stream(self, task: TaskMessage) -> AsyncGenerator[ReActEvent, None]: + """ReWOO mode streaming: delegate to ReWOOEngine.execute_stream().""" + from agentkit.core.rewoo import ReWOOEngine + + system_prompt, user_messages = self._build_llm_messages(task) + rewoo_engine = ReWOOEngine( + llm_gateway=self._llm_gateway, + max_plan_steps=self._skill_config.max_steps if self._skill_config else 5, + default_timeout=300.0, + fallback_strategies=( + self._skill_config.fallback_strategies + if self._skill_config and self._skill_config.fallback_strategies + else None + ), + ) + async for event in rewoo_engine.execute_stream( + messages=user_messages, + tools=self.get_tools() or None, + model=self._config.llm.get("model", "default") if self._config.llm else "default", + agent_name=self.name, + task_type=task.task_type, + system_prompt=system_prompt, + task_id=task.task_id, + cancellation_token=self._active_tokens.get(task.task_id), + timeout_seconds=float(task.timeout_seconds) if task.timeout_seconds > 0 else None, + ): + yield event + + async def _handle_plan_exec_stream(self, task: TaskMessage) -> AsyncGenerator[ReActEvent, None]: + """Plan-Exec mode streaming: delegate to PlanExecEngine.execute_stream().""" + from agentkit.core.plan_exec_engine import PlanExecEngine + + system_prompt, user_messages = self._build_llm_messages(task) + plan_exec_engine = PlanExecEngine( + llm_gateway=self._llm_gateway, + max_replans=2, + default_timeout=300.0, + ) + async for event in plan_exec_engine.execute_stream( + messages=user_messages, + tools=self.get_tools() or None, + model=self._config.llm.get("model", "default") if self._config.llm else "default", + agent_name=self.name, + task_type=task.task_type, + system_prompt=system_prompt, + task_id=task.task_id, + cancellation_token=self._active_tokens.get(task.task_id), + timeout_seconds=float(task.timeout_seconds) if task.timeout_seconds > 0 else None, + ): + yield event + + async def _handle_reflexion_stream(self, task: TaskMessage) -> AsyncGenerator[ReActEvent, None]: + """Reflexion mode streaming: delegate to ReflexionEngine.execute_stream().""" + from agentkit.core.reflexion import ReflexionEngine + + system_prompt, user_messages = self._build_llm_messages(task) + reflexion_engine = ReflexionEngine( + llm_gateway=self._llm_gateway, + max_steps=self._skill_config.max_steps if self._skill_config else 5, + max_reflections=3, + quality_threshold=0.7, + default_timeout=300.0, + ) + async for event in reflexion_engine.execute_stream( + messages=user_messages, + tools=self.get_tools() or None, + model=self._config.llm.get("model", "default") if self._config.llm else "default", + agent_name=self.name, + task_type=task.task_type, + system_prompt=system_prompt, + task_id=task.task_id, + cancellation_token=self._active_tokens.get(task.task_id), + timeout_seconds=float(task.timeout_seconds) if task.timeout_seconds > 0 else None, + ): + yield event + + async def _wrap_sync_as_stream( + self, + handler: Callable[[TaskMessage], Awaitable[dict]], + task: TaskMessage, + ) -> AsyncGenerator[ReActEvent, None]: + """Wrap a sync handler's result as a single final_answer stream event.""" + result = await handler(task) + yield ReActEvent( + event_type="final_answer", + step=0, + data={"output": result.get("content", str(result))}, + ) + async def _handle_react(self, task: TaskMessage) -> dict: """ReAct mode: use ReAct engine for autonomous reasoning""" # Auto-set _current_module from SkillConfig if evolution is enabled diff --git a/src/agentkit/experts/_phase_executor.py b/src/agentkit/experts/_phase_executor.py index dbdc1bd..5e64d53 100644 --- a/src/agentkit/experts/_phase_executor.py +++ b/src/agentkit/experts/_phase_executor.py @@ -12,7 +12,7 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING from agentkit.core.config_driven import ConfigDrivenAgent -from agentkit.core.protocol import TaskMessage, TaskResult, TaskStatus +from agentkit.core.protocol import TaskMessage from .expert import Expert from .plan import PhaseStatus, PhaseType, PlanPhase, TeamPlan @@ -215,34 +215,91 @@ class PhaseExecutorMixin: task_msg = self._build_task_message(expert, phase, dependency_outputs, collaboration_outputs) # 执行专家任务(带重试,MAX_RETRIES 处理瞬时失败) + # U3: 流式执行 — 从 agent.execute() 切换为 agent.execute_stream(), + # 转发 token/thinking/final_answer 事件到 WS,最终广播完整 expert_result。 last_error: str | None = None result: dict[str, object] | None = None for attempt in range(self.MAX_RETRIES + 1): + accumulated: list[str] = [] try: - task_result: TaskResult = await agent.execute(task_msg) - if task_result.status != TaskStatus.COMPLETED.value: - last_error = task_result.error_message or "unknown error" - if attempt < self.MAX_RETRIES: - logger.info(f"Retrying phase {phase.id} (attempt {attempt + 1})") - continue - raise RuntimeError(f"Agent execution failed: {last_error}") - result = task_result.output_data or {"content": ""} + # U3: 重试前广播 reset,前端清空已累积内容 + if attempt > 0: + await self._broadcast_event("expert_result_chunk_reset", { + "expert_id": expert.config.name, + "phase_id": phase.id, + }) + # U3: 流式执行 — 转发 token/thinking/final_answer 事件到 WS + async for event in agent.execute_stream(task_msg): + etype = event.event_type + if etype == "token": + chunk = event.data.get("content", "") + if chunk: + accumulated.append(str(chunk)) + await self._broadcast_event("expert_result_chunk", { + "expert_id": expert.config.name, "content": chunk, + }) + elif etype == "thinking": + await self._broadcast_event("expert_step", { + "expert_id": expert.config.name, + "thinking": event.data.get("content", ""), + }) + elif etype == "final_answer": + # P0 fix: ReActEngine 先发 token(增量)再发 final_answer(全文)。 + # token 已累积时,final_answer 仅作完成信号,不重复 append/broadcast; + # 无 token(如 _wrap_sync_as_stream fallback)时用 output 兜底。 + output = event.data.get("output", "") + if output and not accumulated: + accumulated.append(str(output)) + elif etype in ("tool_call", "tool_result"): + await self._broadcast_event("expert_step", { + "expert_id": expert.config.name, "step_data": event.data, + }) + elif etype == "error": + raise RuntimeError( + f"Stream error: {event.data.get('error', 'unknown')}" + ) + # 流式完成 — 构建结果 + result = {"content": "".join(accumulated)} break except asyncio.CancelledError: # CancelledError 必须传播,不被重试逻辑吞掉 + # U3: 流式会话必须以 expert_result(error) 终结,不允许静默挂起 + try: + await self._broadcast_event("expert_result", { + "expert_id": expert.config.name, "expert_name": expert.config.name, + "expert_color": expert.config.color, + "content": "".join(accumulated), "status": "error", + "error": "cancelled", + "phase_id": phase.id, "rework_attempt": phase.rework_count, + }) + except (ConnectionError, RuntimeError, asyncio.TimeoutError) as bc_err: + logger.warning(f"Failed to broadcast expert_result(cancelled): {bc_err}") raise - except (RuntimeError, asyncio.TimeoutError, ConnectionError) as e: - # agent.execute() 内部已捕获所有异常并返回 TaskResult, - # 此处仅捕获显式抛出的 RuntimeError + 罕见的基础设施异常 + except Exception as e: + # P2 fix: 兜底捕获 LLMProviderError/ConfigValidationError 等非 RuntimeError + # asyncio.CancelledError (BaseException) 已被上方 except 捕获,不会到达此处 + # U3: 流式异常 — 广播 expert_result(error) 携带已累积内容 last_error = str(e) if attempt < self.MAX_RETRIES: logger.info(f"Retrying phase {phase.id} (attempt {attempt + 1})") continue + # 重试耗尽 — 广播 error 终结事件 + try: + await self._broadcast_event("expert_result", { + "expert_id": expert.config.name, "expert_name": expert.config.name, + "expert_color": expert.config.color, + "content": "".join(accumulated), "status": "error", + "error": last_error, + "phase_id": phase.id, "rework_attempt": phase.rework_count, + }) + except (ConnectionError, RuntimeError, asyncio.TimeoutError) as bc_err: + logger.warning(f"Failed to broadcast expert_result(error): {bc_err}") raise await self._broadcast_event("expert_result", { "expert_id": expert.config.name, "expert_name": expert.config.name, "expert_color": expert.config.color, "content": result.get("content", str(result)), + "status": "completed", "phase_id": phase.id, "rework_attempt": phase.rework_count, }) diff --git a/src/agentkit/experts/_synthesizer.py b/src/agentkit/experts/_synthesizer.py index 0a6a2c9..78e65b7 100644 --- a/src/agentkit/experts/_synthesizer.py +++ b/src/agentkit/experts/_synthesizer.py @@ -7,7 +7,7 @@ from __future__ import annotations import logging from datetime import datetime, timezone -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Awaitable, Callable from agentkit.core.protocol import TaskMessage, TaskResult @@ -27,14 +27,30 @@ class SynthesizerMixin: _team: ExpertTeam _user_context: list[str] + @staticmethod + def _concat_results(results: list[dict[str, object] | str]) -> str: + """Join phase results into a single content string.""" + return "\n\n".join( + r.get("content", str(r)) if isinstance(r, dict) else str(r) for r in results + ) + async def _synthesize_results( - self, lead: Expert, task: str, completed_phases: list[PlanPhase] + self, + lead: Expert, + task: str, + completed_phases: list[PlanPhase], + broadcast_callback: Callable[[dict[str, object]], Awaitable[None]] | None = None, ) -> dict[str, object]: """Lead Expert synthesizes results using BEST strategy. The Lead Expert evaluates all completed phase results and produces a final synthesized result. Uses LLM when available, otherwise concatenates results. + + U3: When ``broadcast_callback`` is provided, the LLM synthesis is + streamed via ``gateway.chat_stream()`` and each chunk is forwarded to + the callback (which typically broadcasts ``team_synthesis_chunk``). + The full dict is still returned for the caller to consume unchanged. """ results = [ph.result or {} for ph in completed_phases] if not results: @@ -52,11 +68,8 @@ class SynthesizerMixin: gateway = self._get_llm_gateway(lead) if not gateway: # Without LLM, concatenate all results - combined = "\n\n".join( - r.get("content", str(r)) if isinstance(r, dict) else str(r) for r in results - ) return { - "content": combined, + "content": self._concat_results(results), "strategy": "best", "phases_completed": len(results), } @@ -90,6 +103,21 @@ class SynthesizerMixin: prompt += "\n\nProvide the synthesized result directly." try: + if broadcast_callback is not None: + # U3: 流式综合 — 每次 chunk 调用 callback 广播 team_synthesis_chunk + chunks: list[str] = [] + async for chunk in gateway.chat_stream( + messages=[{"role": "user", "content": prompt}], + model=self._get_model(lead), + ): + if chunk.content: + chunks.append(chunk.content) + await broadcast_callback({"chunk": chunk.content}) + return { + "content": "".join(chunks).strip(), + "strategy": "best", + "phases_completed": len(results), + } response = await gateway.chat( messages=[{"role": "user", "content": prompt}], model=self._get_model(lead), @@ -101,11 +129,8 @@ class SynthesizerMixin: } except Exception as e: logger.warning(f"LLM synthesis failed, falling back to concatenation: {e}") - combined = "\n\n".join( - r.get("content", str(r)) if isinstance(r, dict) else str(r) for r in results - ) return { - "content": combined, + "content": self._concat_results(results), "strategy": "best", "phases_completed": len(results), } diff --git a/src/agentkit/experts/orchestrator.py b/src/agentkit/experts/orchestrator.py index 7202594..77b605a 100644 --- a/src/agentkit/experts/orchestrator.py +++ b/src/agentkit/experts/orchestrator.py @@ -286,7 +286,13 @@ class TeamOrchestrator( self._team.set_status(TeamStatus.SYNTHESIZING) plan.status = PlanStatus.COMPLETED - final_result = await self._synthesize_results(lead, task, completed) + # U3: 流式综合 — 每个 chunk 广播 team_synthesis_chunk + async def _broadcast_synthesis_chunk(data: dict[str, object]) -> None: + await self._broadcast_event("team_synthesis_chunk", data) + + final_result = await self._synthesize_results( + lead, task, completed, broadcast_callback=_broadcast_synthesis_chunk + ) self._team.set_status(TeamStatus.COMPLETED) diff --git a/src/agentkit/server/frontend/src/api/types.ts b/src/agentkit/server/frontend/src/api/types.ts index 9ff3421..8975dd5 100644 --- a/src/agentkit/server/frontend/src/api/types.ts +++ b/src/agentkit/server/frontend/src/api/types.ts @@ -41,7 +41,7 @@ export interface IChatMessage { routing_method?: string confidence?: number task_id?: string - status?: 'completed' | 'pending' | 'error' + status?: 'completed' | 'pending' | 'error' | 'streaming' tool_calls?: IToolCallData[] thinking?: string expert_id?: string @@ -141,7 +141,9 @@ export type WsServerMessage = | { type: 'pong' } | { type: 'team_formed'; data: IExpertTeamState } | { type: 'expert_step'; data: { expert_id: string; expert_name: string; expert_color: string; content: string; step: string } } - | { type: 'expert_result'; data: { expert_id: string; expert_name: string; expert_color: string; content: string } } + | { type: 'expert_result_chunk'; data: { expert_id: string; content: string } } + | { type: 'expert_result_chunk_reset'; data: { expert_id: string; phase_id?: string } } + | { type: 'expert_result'; data: { expert_id: string; expert_name: string; expert_color: string; content: string; status?: 'completed' | 'error'; phase_id?: string; rework_attempt?: number; error?: string } } | { type: 'plan_update'; data: { plan_phases: ITeamPlanPhase[] } } | { type: 'phase_started'; data: { phase_id: string; phase_name: string; assigned_expert: string; depends_on: string[] } } | { type: 'phase_completed'; data: { phase_id: string; phase_name: string; result_summary: string } } @@ -149,7 +151,8 @@ export type WsServerMessage = // PLAN_EXEC (U4) — phase lifecycle events emitted by ReActEngine. | { type: 'phase_changed'; data: { phase: string; previous: string } } | { type: 'phase_violation'; data: { current_phase: string; tool: string; message: string; violation_kind: string; command_preview?: string } } - | { type: 'team_synthesis'; data: { content: string } } + | { type: 'team_synthesis_chunk'; data: { chunk: string } } + | { type: 'team_synthesis'; data: { content: string; phases_completed?: number; phases_total?: number } } | { type: 'team_dissolved'; data: { team_id: string } } // Board Meeting 模式事件 | { type: 'board_started'; data: IBoardStartedData } @@ -212,6 +215,8 @@ export interface IExpertTeamState { experts: IExpertInfo[] plan_phases: ITeamPlanPhase[] lead_expert: string + /** U2: 团队级任务目标摘要(可选,后端 team_formed 事件未发送时回退到首阶段描述) */ + task_description?: string } // ── Board Meeting 模式类型 ──────────────────────────────────────────── diff --git a/src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue b/src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue index 264099c..f768e4e 100644 --- a/src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue +++ b/src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue @@ -1,6 +1,25 @@ @@ -11,6 +30,7 @@ import dayGridPlugin from '@fullcalendar/daygrid' import timeGridPlugin from '@fullcalendar/timegrid' import interactionPlugin from '@fullcalendar/interaction' import zhCnLocale from '@fullcalendar/core/locales/zh-cn' +import { WarningOutlined } from '@ant-design/icons-vue' import type { CalendarOptions, EventInput, @@ -20,6 +40,7 @@ import type { } from '@fullcalendar/core' import { useCalendarStore } from '@/stores/calendar' import type { ICalendarEvent } from '@/api/calendar' +import { eventColorToken } from '@/utils/calendarTokens' const store = useCalendarStore() @@ -35,7 +56,7 @@ const emit = defineEmits<{ function toFcEvent(ev: ICalendarEvent): EventInput { const eventType = store.eventTypes.find((t) => t.id === ev.event_type_id) - const color = eventType?.color || '#1677ff' + const color = eventColorToken(eventType?.name) return { id: ev.id, title: ev.title, @@ -66,6 +87,10 @@ function handleEventClick(arg: EventClickArg): void { emit('edit', ev) } +function retry(): void { + void store.loadEvents() +} + onMounted(() => { // ponytail: track last size to break ResizeObserver feedback loop — // updateSize() can trigger another resize event, causing infinite thrash. @@ -122,15 +147,74 @@ const calendarOptions = computed(() => ({ flex: 1; min-height: 0; overflow: hidden; + position: relative; } .calendar-grid :deep(.fc) { height: 100%; } -.calendar-grid :deep(.fc-event-invited) { - border-style: dashed !important; - border-width: 2px !important; - opacity: 0.75; +.calendar-grid__state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-3); + height: 100%; + padding: var(--space-8); +} + +.calendar-grid__skeleton { + gap: var(--space-2); + width: 100%; + max-width: 480px; + margin: 0 auto; +} + +.calendar-grid__skeleton-row { + width: 100%; + height: 28px; + border-radius: var(--radius-md); + background: linear-gradient( + 90deg, + var(--border-color) 25%, + var(--bg-tertiary) 37%, + var(--border-color) 63% + ); + background-size: 400% 100%; + animation: calendar-grid-shimmer 1.4s ease infinite; +} + +@keyframes calendar-grid-shimmer { + 0% { + background-position: 100% 50%; + } + 100% { + background-position: 0 50%; + } +} + +.calendar-grid__error { + color: var(--color-error); +} + +.calendar-grid__error-icon { + font-size: 24px; +} + +.calendar-grid__error-text { + font-size: var(--font-sm); + color: var(--text-tertiary); + text-align: center; +} + +.calendar-grid__empty-hint { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--text-tertiary); + font-size: var(--font-sm); + pointer-events: none; } diff --git a/src/agentkit/server/frontend/src/components/chat/ChatInput.vue b/src/agentkit/server/frontend/src/components/chat/ChatInput.vue index 63743bc..a84d260 100644 --- a/src/agentkit/server/frontend/src/components/chat/ChatInput.vue +++ b/src/agentkit/server/frontend/src/components/chat/ChatInput.vue @@ -137,7 +137,7 @@ + + diff --git a/src/agentkit/server/frontend/src/components/chat/ThinkingBlock.vue b/src/agentkit/server/frontend/src/components/chat/ThinkingBlock.vue index ad7c6ee..2689e38 100644 --- a/src/agentkit/server/frontend/src/components/chat/ThinkingBlock.vue +++ b/src/agentkit/server/frontend/src/components/chat/ThinkingBlock.vue @@ -1,30 +1,103 @@ diff --git a/src/agentkit/server/frontend/src/components/chat/helpers/useMessageRenderer.ts b/src/agentkit/server/frontend/src/components/chat/helpers/useMessageRenderer.ts index 5dd951b..3abcc34 100644 --- a/src/agentkit/server/frontend/src/components/chat/helpers/useMessageRenderer.ts +++ b/src/agentkit/server/frontend/src/components/chat/helpers/useMessageRenderer.ts @@ -101,7 +101,10 @@ export function useMessageRenderer(message: IChatMessage) { type, shell: { name: '用户', meta: time }, 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': { diff --git a/src/agentkit/server/frontend/src/components/chat/messages/AssistantText.vue b/src/agentkit/server/frontend/src/components/chat/messages/AssistantText.vue index 20c6391..3883184 100644 --- a/src/agentkit/server/frontend/src/components/chat/messages/AssistantText.vue +++ b/src/agentkit/server/frontend/src/components/chat/messages/AssistantText.vue @@ -1,5 +1,9 @@