feat(ui): private board restrictions + scheme B assistant/user bubbles (#19)
Implements U1-U4 from plan docs/plans/2026-07-02-001-feat-private-board-restrictions-and-scheme-b-bubbles-plan.md U1: ChatInput @board button blocks existing-conversation board creation with modal U2: BoardBannerCard simplified to plain title + round meta U3: MessageShell assistant bubble (scheme B neutral grayscale) with F4-A card exclusion + G1 empty-bubble hide U4: UserBubble dark text bubble for plain text Code review fixes: P1 color token, P2 CARD_BEARING_TYPES error type, P2 expertColor dead code, P0/P1 bubbleUtils.ts + 42 tests Tests: 180/181 pass (1 pre-existing tauri-auth failure). Typecheck clean.
This commit is contained in:
commit
76c9c08756
|
|
@ -8,7 +8,7 @@ status: ready
|
|||
|
||||
## Summary
|
||||
|
||||
收尾私董会(@board)模块的 UI 细节与单会话状态约束,四处协同改动:(1) 在 `ChatInput.vue` 的"私董会"按钮 click 处拦截"当前会话已存在进行中的私董会"场景,弹 a-modal 提示并提供快捷新建会话按钮;(2) 将 `BoardBannerCard.vue` 从带边框/紫条/进度条/专家 chip 的重样式简化为单行标题+副标题;(3) 给 `MessageShell.vue` 中所有 `role === 'assistant'` 的消息内容添加方案 B 风格的浅灰圆角矩形气泡(F1-A 独立 token / F4-A 排除 conclusion / D4-方案1 `:empty` 隐藏);(4) 将 `UserBubble.vue` 普通文本消息改为 demo 中的深色右对齐气泡(`--color-primary` + `--text-inverse`),@board/@team 命令卡片保持现有浅色背景。改动范围仅限前端 Vue 组件与少量 store/type/token 文件,不动后端、不动方案 B 调色板、不动 StickyModeHeader 顶部条。
|
||||
收尾私董会(@board)模块的 UI 细节与单会话状态约束,四处协同改动:(1) 在 `ChatInput.vue` 的"私董会"按钮 click 处拦截"当前会话已存在进行中的私董会"场景,弹 a-modal 提示并提供快捷新建会话按钮;(2) 将 `BoardBannerCard.vue` 从带边框/紫条/进度条/专家 chip 的重样式简化为单行标题+副标题;(3) 给 `MessageShell.vue` 中所有 `role === 'assistant'` 的消息内容添加方案 B 风格的浅灰圆角矩形气泡(F1-A 独立 token / F4-A 排除所有 card-bearing 类型 / D4-方案1 `:empty` 隐藏);(4) 将 `UserBubble.vue` 普通文本消息改为 demo 中的深色右对齐气泡(`--color-primary` + `--text-inverse`),@board/@team 命令卡片保持现有浅色背景。改动范围仅限前端 Vue 组件与少量 store/type/token 文件,不动后端、不动方案 B 调色板、不动 StickyModeHeader 顶部条。
|
||||
|
||||
## Problem Frame
|
||||
|
||||
|
|
@ -38,9 +38,9 @@ status: ready
|
|||
- R9:`MessageShell.vue` 的 `.message-shell__content` 加 `background: var(--bg-message-bubble)` + `border-radius: var(--radius-md)` + `padding: var(--space-3) var(--space-4)` + `border: 1px solid var(--border-color)` + `color: var(--text-primary)`,仅 `role === 'assistant'` 时生效。**F1-A 决策**:引入独立 token `--bg-message-bubble`(light `#ffffff` / dark `#1f1f1f`),与 `--bg-secondary`(inline code/table 背景)解耦,避免气泡背景与代码/表格背景视觉冲突
|
||||
- R10:不使用 `!important`,不覆盖代码块/表格/路由 tag 样式
|
||||
- R11:不影响 `BoardRoundCard` 内 `AssistantText` 渲染
|
||||
- R12:私董会专家发言、普通 chat、@team 阶段、Debate 等都通过 `MessageShell + AssistantText` 统一获得气泡
|
||||
- R12:私董会专家发言、普通 chat 通过 `MessageShell + AssistantText` 统一获得气泡;@team 阶段使用 `TeamPlanCard`、Debate 使用 `DebateArgumentCard`/`DebateSummaryCard`/`DebateBannerCard` 等自带 card chrome 的组件(通过 `MessageShell` 渲染但不走 `AssistantText`),由 R14 排除气泡
|
||||
- R13:气泡宽度继承现有 `width: 100%; max-width: 100%`,不强制固定最大宽度
|
||||
- R14:**F4-A 决策**:气泡选择器排除 `board_conclusion` 类型——`BoardConclusionCard` 自带 card chrome(background/border/border-radius/shadow),保留其自身样式,不再被气泡包裹。实现方式:在 `MessageShell.vue` 通过 `messageType` prop 或 `:not(:has(.board-conclusion-card))` 选择器排除
|
||||
- R14:**F4-A 决策(Round 4 扩展)**:气泡选择器排除所有自带 card chrome 的 assistant 消息类型(共 9 种):`board_conclusion`(`BoardConclusionCard` → `.board-conclusion-card`,full chrome:bg + border + radius + shadow)、`team_plan`(`TeamPlanCard` → `.team-plan-card`,full chrome)、`debate_banner`(`DebateBannerCard` → `.debate-banner`,partial chrome:bg + left-border + radius)、`debate_argument`(`DebateArgumentCard` → `.debate-argument`,partial)、`debate_summary`(`DebateSummaryCard` → `.debate-summary`,partial)、`debate_resolved`(`DebateConclusionCard` → `.debate-conclusion`,partial + 4 变体 bg)、`collaboration_graph`(`CollaborationGraphCard` → `.collab-graph`,partial)、`review_result`(`ReviewResultCard` → `.review-card`,partial + 3 变体 bg)、`risk_flagged`(`RiskFlagCard` → `.risk-card`,partial)。**chrome 区分(Round 4 修正)**:仅 `BoardConclusionCard` + `TeamPlanCard` 有完整 chrome(bg + border + radius + shadow),其余 7 种只有 bg + left-border + radius(无 shadow、无 full border)。**已验证根 class 名(Round 4 R4-F1)**:注意 7 种 partial chrome 卡片 root class 名与组件名不匹配,不带 `-card` 后缀(`.debate-banner` / `.debate-argument` / `.debate-summary` / `.debate-conclusion` / `.collab-graph` / `.review-card` / `.risk-card`)。实现方式:在 `MessageShell.vue` 通过 `messageType` prop 或 `:has()` 选择器排除(具体机制依 G3 决策)。**第三种架构替代方案(Round 4 R4-A3)**:在 `MessageRenderSpec` 接口(`useMessageRenderer.ts:44-48`)新增 `bubble: boolean` 字段,在 renderer 层集中决策是否包裹气泡,避免 messageType prop 污染或 `:has()` 选择器脆弱性——见 Open Questions Round 4。**未来防护(Round 4 R4-A4)**:新增 card-bearing 类型时需手动加入 F4-A 排除列表,或采用上述 `bubble` 字段方案由 renderer 集中管理——见 Open Questions Round 4
|
||||
- R15:**D4-方案1 决策**:空 slot 内容(pre-stream thinking / tool-call-only)时气泡不渲染背景——通过 `:empty` 选择器隐藏 `background / border / padding`,仅显示 thinking dots。有内容流入后自动恢复气泡样式
|
||||
|
||||
**UserBubble 普通文本深色气泡(R16-R19)**:
|
||||
|
|
@ -55,10 +55,10 @@ status: ready
|
|||
- **私董会状态判定取 `discussing | concluding`** — `BoardState.status` 类型为 `"discussing" | "concluding" | "completed" | "dissolved"`(chatStream.ts:65 确认)。`completed | dissolved` 的旧私董会不阻塞新私董会发起,符合 origin R3 要求。
|
||||
- **"新建会话"按钮流程:`createConversation()` → `selectConversation(newId, true)`** — `createConversation()` 是同步函数(chatStore.ts:333 确认),自动设置 `currentConversationId` 但不加载服务端会话;后续 `selectConversation(newId, true)` 强制 reload 确保状态干净。不自动打开 `BoardMeetingModal`,由用户在新会话中再次点击"私董会"按钮继续(见 origin R2)。
|
||||
- **BoardBannerCard 简化为单行标题+副标题,不删除组件本身** — 保留 `BoardBannerCard.vue` 文件,仅重构 template + style。`useMessageRenderer.ts` 的 `board_started` 渲染路径不变(origin R8),保持 streaming 期间与 reload 后视觉一致。
|
||||
- **气泡样式挂在 `MessageShell.vue` 的 `.message-shell__content` 上,不挂在 `AssistantText.vue`** — `MessageShell` 是所有 assistant 消息的统一外壳,挂在这里可一次性覆盖 board_speech / board_summary / 普通聊天 / @team / Debate 等场景(origin R12),且不影响 `UserBubble.vue` 的 user 消息样式。**board_conclusion 例外**(F4-A):`BoardConclusionCard` 自带 card chrome,不被气泡包裹。
|
||||
- **气泡样式挂在 `MessageShell.vue` 的 `.message-shell__content` 上,不挂在 `AssistantText.vue`** — `MessageShell` 是所有 assistant 消息的统一外壳,挂在这里可一次性覆盖 board_speech / board_summary / 普通聊天 / @team / Debate 等场景(origin R12),且不影响 `UserBubble.vue` 的 user 消息样式。**card-bearing 类型例外(F4-A Round 4 扩展,共 9 种)**:`board_conclusion`/`team_plan`/`debate_banner`/`debate_argument`/`debate_summary`/`debate_resolved`/`collaboration_graph`/`review_result`/`risk_flagged` 渲染的 card 组件自带 card chrome(详见 R14),不被气泡包裹。
|
||||
- **气泡使用 CSS 变量,禁止硬编码** — 新增 `--bg-message-bubble: #ffffff` (light) / `#1f1f1f` (dark) 到 `tokens.css`,与 `--bg-secondary`(inline code/table 背景 `#fbfbfa`)解耦(F1-A 决策)。dark mode 自动切换。禁止硬编码 `#f3f4f6` / `#fbfbfa` / `#ffffff` 等值(project rules 硬约束)。
|
||||
- **`role === 'assistant'` 时启用气泡,`role === 'user'` 不启用** — 通过 `.message-shell--assistant .message-shell__content` 选择器限定,避免污染 user 消息的 `UserBubble` 样式。
|
||||
- **F4-A 排除 conclusion 类型** — `board_conclusion` 消息渲染的 `BoardConclusionCard` 自带 `background: var(--bg-primary)` + `border` + `border-radius: var(--radius-card)` + `box-shadow`,气泡选择器需排除该类型,避免"白卡片嵌套在白气泡里"的视觉冲突。实现方式:在 `MessageShell.vue` 增加 `messageType` prop(或 `:not(:has(.board-conclusion-card))` 选择器),conclusion 类型不加气泡样式。
|
||||
- **F4-A 排除所有 card-bearing 类型(Round 4 扩展,共 9 种)** — 排除 9 种 card-bearing assistant 消息类型:`board_conclusion`/`team_plan`/`debate_banner`/`debate_argument`/`debate_summary`/`debate_resolved`/`collaboration_graph`/`review_result`/`risk_flagged`。**chrome 区分(Round 4 修正)**:仅 `BoardConclusionCard`(`.board-conclusion-card`)+ `TeamPlanCard`(`.team-plan-card`)有完整 chrome(bg + border + radius + shadow);其余 7 种(`DebateBannerCard`→`.debate-banner`、`DebateArgumentCard`→`.debate-argument`、`DebateSummaryCard`→`.debate-summary`、`DebateConclusionCard`→`.debate-conclusion`、`CollaborationGraphCard`→`.collab-graph`、`ReviewResultCard`→`.review-card`、`RiskFlagCard`→`.risk-card`)只有 bg + left-border + radius(无 shadow、无 full border)。气泡选择器需排除这些类型,避免"card 嵌套在气泡里"的视觉冲突。实现方式:在 `MessageShell.vue` 增加 `messageType` prop(或 `:has()` 选择器),card-bearing 类型不加气泡样式。具体机制依 G3 决策。**第三种替代方案**:在 `MessageRenderSpec` 新增 `bubble: boolean` 字段由 renderer 集中决策(见 Open Questions Round 4 R4-A3)。**未来防护**:新增 card 类型需手动加入排除列表或采用 `bubble` 字段方案(见 Open Questions Round 4 R4-A4)。
|
||||
- **D4-方案1 `:empty` 隐藏空气泡** — 空 slot 内容(pre-stream thinking / tool-call-only)时通过 `:empty` 选择器隐藏 `background / border / padding`,仅显示 thinking dots 动画。有内容流入后自动恢复气泡样式,无需 JS 判断。
|
||||
- **U4 仅普通文本消息深色气泡,命令卡片保持浅色** — `UserBubble.vue` 同时渲染三种内容(普通文本 / @board|@team 命令卡片 / 文件附件),命令卡片含结构化信息(header + experts list),深色背景会降低可读性。通过 `isPlainText` computed + `.user-bubble--text` modifier 精确限定深色样式仅作用于普通文本,命令卡片和文件附件继承 `.user-bubble` 默认 `--bg-tertiary` 浅色背景。
|
||||
- **U4 用 `--color-primary` + `--text-inverse` 自动适配 dark mode** — `--color-primary` 在 light mode 为 `#1a1a1a`(深),dark mode 为 `#fbfbfa`(浅);`--text-inverse` 反之。user 气泡在 dark mode 下自动反转为浅色背景 + 深色文字,无需额外 dark mode 覆盖。
|
||||
|
|
@ -89,6 +89,7 @@ status: ready
|
|||
- `await chatStore.selectConversation(chatStore.currentConversationId!, true)`
|
||||
- 不设置 `showBoardModal.value = true`(让用户在新会话中再次点击)
|
||||
- "私董会"按钮 `@click` 改为 `handleBoardClick`
|
||||
- **焦点恢复(Round 4 R4-D3)**:a-modal 关闭时(无论"我知道了"还是"新建会话")需将焦点返回到触发按钮("私董会"按钮),符合 WAI-ARIA modal 对话模式。Ant Design Vue 的 `<a-modal>` 通过 `@cancel` / `@ok` 事件触发关闭,关闭后焦点默认落在 modal 本身;需在 `handleCreateNewConversationForBoard` 和"我知道了"按钮 click 后显式 `nextTick(() => boardButtonRef.value?.focus())`(或使用 autofocus 属性)。新建会话跳转后焦点自然落于新会话输入框,无需额外处理
|
||||
|
||||
**Patterns to follow:**
|
||||
- `ChatInput.vue:63-71` 的 `showTeamModal` 模式(v-model:open + ref + handleXxxSubmit)
|
||||
|
|
@ -164,7 +165,7 @@ status: ready
|
|||
|
||||
### U3. MessageShell 浅灰气泡(方案B)
|
||||
|
||||
**Goal:** 在 `MessageShell.vue` 的 `.message-shell__content` 上增加方案 B 风格的浅灰圆角矩形气泡样式,仅 `role === 'assistant'` 时生效,使用 CSS 变量绑定,dark mode 自动切换。**F1-A 决策**:气泡用独立 token `--bg-message-bubble`(与 inline code/table 的 `--bg-secondary` 解耦);**F4-A 决策**:排除 `board_conclusion` 类型;**D4-方案1 决策**:空内容通过 `:empty` 隐藏气泡。
|
||||
**Goal:** 在 `MessageShell.vue` 的 `.message-shell__content` 上增加方案 B 风格的浅灰圆角矩形气泡样式,仅 `role === 'assistant'` 时生效,使用 CSS 变量绑定,dark mode 自动切换。**F1-A 决策**:气泡用独立 token `--bg-message-bubble`(与 inline code/table 的 `--bg-secondary` 解耦);**F4-A 决策(Round 4 扩展,共 9 种)**:排除所有 card-bearing assistant 类型(`board_conclusion` / `team_plan` / `debate_banner` / `debate_argument` / `debate_summary` / `debate_resolved` / `collaboration_graph` / `review_result` / `risk_flagged`),这些类型由各自 card 组件自带 card chrome(2 种 full chrome + 7 种 partial chrome,详见 R14);**D4-方案1 决策**:空内容通过 `:empty` 隐藏气泡。
|
||||
|
||||
**Requirements:** R9, R10, R11, R12, R13, R14, R15
|
||||
|
||||
|
|
@ -186,7 +187,11 @@ status: ready
|
|||
padding: var(--space-3) var(--space-4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
/* F4-A: board_conclusion 类型不加气泡,保留 BoardConclusionCard 自身 card chrome */
|
||||
/* F4-A: 所有 card-bearing assistant 类型不加气泡(共 9 种,Round 4 扩展)。
|
||||
已验证根 class 名:`.board-conclusion-card` / `.team-plan-card`(full chrome,含 shadow) /
|
||||
`.debate-banner` / `.debate-argument` / `.debate-summary` / `.debate-conclusion` / `.collab-graph` / `.review-card` / `.risk-card`
|
||||
(7 种 partial chrome,仅 bg + left-border + radius,无 shadow 无 full border;注意 root class 名与组件名不匹配,不带 -card 后缀)。
|
||||
实现期依 G3 决策扩展选择器或 messageType prop 排除列表 */
|
||||
.message-shell--assistant.message-shell--conclusion .message-shell__content,
|
||||
.message-shell--assistant:has(.board-conclusion-card) .message-shell__content {
|
||||
background: transparent;
|
||||
|
|
@ -220,7 +225,7 @@ status: ready
|
|||
- 气泡内代码块:slot 内含 `<pre><code>` → 代码块背景 `--code-bg` 不被气泡背景覆盖(视觉可区分)
|
||||
- 气泡宽度:`.message-shell__content` 计算样式 `width: 100%`,`max-width: 100%`(跟随消息列宽)
|
||||
- Dark mode:切换到 dark mode → `.message-shell__content` 计算样式 `background-color: rgb(31, 31, 31)`(`--bg-message-bubble: #1f1f1f`)
|
||||
- **F4-A 排除 conclusion**:渲染 `<MessageShell role="assistant" messageType="board_conclusion">` + `BoardConclusionCard` slot → `.message-shell__content` 计算样式 `background-color: transparent`,`border: none`(不加气泡,`BoardConclusionCard` 自身 card chrome 保留)
|
||||
- **F4-A 排除 card-bearing 类型(Round 4 扩展,共 9 种)**:分别渲染 `<MessageShell role="assistant" messageType="board_conclusion|team_plan|debate_banner|debate_argument|debate_summary|debate_resolved|collaboration_graph|review_result|risk_flagged">` + 对应 card slot(`BoardConclusionCard` / `TeamPlanCard` / `DebateBannerCard` / `DebateArgumentCard` / `DebateSummaryCard` / `DebateConclusionCard` / `CollaborationGraphCard` / `ReviewResultCard` / `RiskFlagCard`)→ 每种类型 `.message-shell__content` 计算样式 `background-color: transparent`,`border: none`(不加气泡,card 自身 chrome 保留;2 种 full chrome + 7 种 partial chrome 详见 R14)
|
||||
- **D4-方案1 空内容隐藏**:渲染 `<MessageShell role="assistant">` + 空 slot → `.message-shell__content` 计算样式 `background-color: transparent`,`border: none`,`padding: 0`(`:empty` 选择器生效)
|
||||
- **D4-方案1 有内容恢复**:渲染 `<MessageShell role="assistant">` + 非空 slot → `:empty` 不匹配,气泡样式正常应用
|
||||
- 渲染契约:样式**不**使用 `!important`
|
||||
|
|
@ -230,6 +235,7 @@ status: ready
|
|||
- 启动 Tauri 客户端,发起普通 chat,assistant 消息应有浅灰(实际为 `#ffffff` 纯白,与 `#fbfbfa` 的 inline code 区分)圆角矩形气泡
|
||||
- 发起私董会,专家发言(board_speech)应有同款气泡
|
||||
- 发起私董会,结论消息(board_conclusion)应保留 `BoardConclusionCard` 自身 card 样式,**不**被气泡包裹
|
||||
- 同理验证 `team_plan` / `debate_banner` / `debate_argument` / `debate_summary` / `debate_resolved` / `collaboration_graph` / `review_result` / `risk_flagged` 消息保留各自 card chrome(`TeamPlanCard` / `DebateBannerCard` / `DebateArgumentCard` / `DebateSummaryCard` / `DebateConclusionCard` / `CollaborationGraphCard` / `ReviewResultCard` / `RiskFlagCard`),**不**被气泡包裹(F4-A Round 4 扩展,共 9 种)
|
||||
- 切换 dark mode,气泡背景应自动变为深色(`#1f1f1f`)
|
||||
- user 消息(右侧)应显示为 demo 中的深色气泡(`--color-primary` 背景 + `--text-inverse` 白字),@board/@team 命令卡片保持现有浅色背景(见 U4)
|
||||
- assistant 消息在 thinking 阶段(空内容)不应显示空气泡矩形,仅显示三点动画
|
||||
|
|
@ -330,7 +336,7 @@ status: ready
|
|||
|
||||
- **F1(feasibility,P1)— 已决策 F1-A**:U3 气泡背景与 inline code/table 背景冲突。**决策**:引入独立 token `--bg-message-bubble`(light `#ffffff` / dark `#1f1f1f`)区分气泡背景与 inline code/table 背景(保持 `--bg-secondary: #fbfbfa`)。已固化到 R9、U3 Approach、Key Technical Decisions。**⚠️ Round 2 G5 挑战**:`#ffffff` 与 `#fbfbfa` 色差 <1% 低于 JND,且 `#ffffff` 与 `--bg-primary` 相同导致气泡仅靠边框可见——见下方 G5。
|
||||
- **F3(feasibility,P2)— 已决策 F3-A**:U2 删除 `BoardBannerCard` 的 `experts` prop 后 `useMessageRenderer.ts:145` 的 fallthrough 风险。**决策**:在 U2 Files 列表中加 `useMessageRenderer.ts`,删除 `board_banner` case 中 `experts` prop 传参。已固化到 U2 Files、U2 Approach、U2 Test scenarios。
|
||||
- **F4(feasibility,P2)— 已决策 F4-A**:U3 气泡包裹 `BoardConclusionCard` 的嵌套冲突。**决策**:气泡选择器排除 `board_conclusion` 类型,`BoardConclusionCard` 保留自身 card chrome 不被气泡包裹。已固化到 R14、U3 Approach、U3 Test scenarios、Key Technical Decisions。**⚠️ Round 2 G2/G3 挑战**:`:has()` 事实错误 + `messageType` prop 选项需 ChatMessage.vue(不在 Files)——见下方 G2/G3。
|
||||
- **F4(feasibility,P2)— 已决策 F4-A**:U3 气泡包裹 `BoardConclusionCard` 的嵌套冲突。**决策**:气泡选择器排除 `board_conclusion` 类型,`BoardConclusionCard` 保留自身 card chrome 不被气泡包裹。已固化到 R14、U3 Approach、U3 Test scenarios、Key Technical Decisions。**⚠️ Round 2 G2/G3 挑战**:`:has()` 事实错误 + `messageType` prop 选项需 ChatMessage.vue(不在 Files)——见下方 G2/G3。**⚠️ Round 3 扩展**:F4-A 排除列表从 1 种扩展到 5 种(新增 `team_plan`/`debate_banner`/`debate_argument`/`debate_summary`)。**⚠️ Round 4 扩展(R4-A1)**:进一步扩展到 9 种(新增 `debate_resolved`/`collaboration_graph`/`review_result`/`risk_flagged`),并修正 chrome 描述(2 种 full chrome + 7 种 partial chrome)。详见 R14 与 Open Questions Round 4。
|
||||
- **C2(coherence,P2)— 已应用 safe_auto**:R7 表述已修订为"删除 `.board-banner-card` 的重样式(background/border/border-radius/box-shadow)及 `__bar / __chip` 等重样式类,保留 `.board-banner-card` 容器(仅 margin/padding)+ `__title / __meta` 最小样式"。
|
||||
- **D4(design-lens,P2)— 已决策 D4-方案1**:U3 空内容气泡表现。**决策**:通过 `:empty` 选择器隐藏 `background / border / padding`,仅显示 thinking dots。已固化到 R15、U3 Approach、U3 Test scenarios、Key Technical Decisions。**⚠️ Round 2 G1 推翻**:`:empty` 永不匹配(AssistantText 总渲染根 div)——见下方 G1,实现期必须替换方案。
|
||||
|
||||
|
|
@ -372,9 +378,35 @@ status: ready
|
|||
|
||||
- **D4-R2(design-lens,P2,conf 75)— U2 简化 banner 缺乏视觉分隔**:删除所有 chrome 后,两行纯文本 banner(font-md semibold + font-xs tertiary)可能融入消息流,失去"section divider"作用。BankOutlined 图标删除后无视觉锚点。**待选方案**:(a) 保留 2px 左边框 `--accent-board`(匹配私董会身份,克制);(b) 加小色点前缀;(c) 纯排版足够("私董会 —" 文本前缀即锚点)。
|
||||
|
||||
#### Round 3 复审新增(2026-07-03)
|
||||
|
||||
**P1 manual(origin scope 边界):**
|
||||
|
||||
- **R3-A1(scope-guardian,P1,conf 100)— U4 UserBubble 深色气泡超出 origin 范围**:origin `docs/brainstorms/2026-07-02-private-board-restrictions-and-scheme-b-bubbles-requirements.md` 的 Key Decisions 明确声明"role === 'user' 的用户消息气泡保留现有 UserBubble.vue 的右对齐独立样式,不加 AssistantText 风格的浅灰块"。但本 plan U4 改动 `UserBubble.vue`,将普通文本消息改为 `--color-primary` 深色背景 + `--text-inverse` 白字的深色气泡(R16-R19),属于 origin 之外的 scope creep。**待选方案**:(a) Drop U4 及 R16-R19,保持 origin 不变(U3 仍完成 assistant 气泡统一);(b) 保留 U4 但正式 amend origin requirements doc,补充"用户普通文本消息改为深色右对齐气泡"决策。建议 (b),因为 U4 视觉冲击显著、demo 已确认方向,但需走 origin amend 流程避免 plan/origin 漂移。
|
||||
|
||||
**P2 gated_auto(事实修正):**
|
||||
|
||||
- **R3-F2(feasibility,P2,conf 50)— selectConversation `force=true` 对 local 会话不生效**:plan U1 Approach 中关于"切换会话时通过 `selectConversation(force=true)` 强制重新加载消息"的描述有事实偏差。`chatStore.ts:233` 的守卫为 `!conv?.is_local && (force || ...)`,对 `is_local=true` 的会话(含新建会话)会跳过 reload 调用——状态干净实际由 `createConversation` 保证(新建即清空 boardState/selectedExpert 等)。**建议 fix**:在 U1 Approach / Key Technical Decisions 条目中明确归因——"切换会话时 boardState 清空由 `createConversation` 在新建会话时执行;对非 local 会话调用 `selectConversation` 时 `force=true` 仍生效"。无需修改代码,仅修正 plan 文档归因。
|
||||
|
||||
#### Round 4 复审新增(2026-07-03)
|
||||
|
||||
**已应用(auto-resolve 决策汇总):**
|
||||
|
||||
- **R4-C1(coherence,P1,conf 100,safe_auto applied)** — Lines 58 & 389 "board_conclusion 例外" 已更新为 "card-bearing 类型例外(F4-A Round 4 扩展)"
|
||||
- **R4-F1(feasibility,P2,conf 75,gated_auto applied)** — 已验证 9 种 card root class 名并添加到 R14、Key Technical Decisions F4-A、U3 CSS 注释(注意 7 种 partial chrome 卡片 root class 名不带 `-card` 后缀)
|
||||
- **R4-A1(adversarial,P1,conf 100,auto-resolve applied)** — F4-A 排除列表从 5 种扩展到 9 种(新增 `debate_resolved` / `collaboration_graph` / `review_result` / `risk_flagged`)。已更新 R14、Key Technical Decisions F4-A、U3 Goal、U3 CSS 注释、U3 Test scenarios、U3 Verification
|
||||
- **R4-D1(design-lens,P2,conf 100,auto-resolve applied)** — F4-A card chrome claim 已修正:仅 `BoardConclusionCard` + `TeamPlanCard` 有完整 chrome(bg + border + radius + shadow),其余 7 种只有 partial chrome(bg + left-border + radius,无 shadow 无 full border)。已更新 R14、Key Technical Decisions F4-A、U3 CSS 注释
|
||||
- **R4-D3(design-lens,P2,conf 75,auto-resolve applied)** — U1 Approach 新增焦点恢复说明:a-modal 关闭后焦点返回"私董会"按钮(WAI-ARIA modal 对话模式)
|
||||
|
||||
**P2 manual(待后续处理):**
|
||||
|
||||
- **R4-DA1(design-lens + adversarial cross-persona promote,P2,conf 100)— 7 种 partial chrome 卡片硬编码浅色,dark mode 下与 F4-A 排除气泡后视觉冲突加剧**:`DebateBannerCard`(`#f9f0ff`)、`DebateArgumentCard`(`#fafafa`)、`DebateSummaryCard`(`#fff7e6`)、`DebateConclusionCard`(4 变体 `#fff7e6`/`#f6ffed`/`#fffbe6`/`#f5f5f5`/`#fff2f0`)、`CollaborationGraphCard`(`#f0f8ff`)、`ReviewResultCard`(3 变体 `#fafafa`/`#f6ffed`/`#fffbe6`/`#fff2f0`)、`RiskFlagCard`(`#fff7e6`)均使用硬编码浅色背景。U3 气泡用 `--bg-message-bubble` token 自动适配 dark mode,但这些 card 被 F4-A 排除后,dark mode 下仍显示硬编码浅色背景,对比度刺眼。**这是 pre-existing 问题,超出 U3 scope**(U3 仅负责气泡,不负责修复 card 颜色)。**建议**:新建独立 unit(如 U5: Card Dark Mode Adaptation)将这 7 种 card 的硬编码颜色迁移到 CSS 变量,与 U3 并行但不阻塞。或暂记为 follow-up tech debt。
|
||||
- **R4-A3(adversarial,P2,conf 75)— F4-A 遗漏第三种架构替代方案**:当前 G3 决策在 `messageType` prop 与 `:has()` 选择器间二选一。存在第三种方案:在 `MessageRenderSpec` 接口(`useMessageRenderer.ts:44-48`)新增 `bubble: boolean` 字段,在 renderer 层(`ChatMessage.vue`)集中决策是否包裹气泡,card-bearing 类型在 spec 定义时设 `bubble: false`。**优势**:新增 card 类型时无需改 `MessageShell.vue` 排除列表,避免 R4-A4 的未来防护问题;spec 集中管理,单一数据源。**劣势**:需修改 `MessageRenderSpec` 接口和所有 spec 定义点(12+ 类型),改动面比 prop/selector 方案大。**建议**:实现期若 G3 二选一难以决断,可考虑此方案作为 tie-breaker;否则记为 future refactor 候选。
|
||||
- **R4-A4(adversarial,P2,conf 75)— F4-A 排除列表对未来新增 card 类型无防护**:当前 F4-A 排除列表硬编码 9 种类型,新增 card-bearing 类型时需手动加入 `MessageShell.vue` 排除列表(prop 或 selector 方案),否则新 card 会被气泡错误包裹。**与 R4-A3 关联**:采用 `MessageRenderSpec.bubble` 字段方案可从根本上解决(renderer 集中决策),但需更大重构。**建议**:若采用 G3 prop/selector 方案,需在 `useMessageRenderer.ts` 新增 card 类型时强制走 code review checklist 验证 F4-A 排除列表是否同步更新;或采用 R4-A3 的 `bubble` 字段方案一劳永逸。
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **前端用户**:所有 Tauri 客户端和 Web GUI 用户将看到 (a) 私董会按钮在已有私董会的会话中弹提示而非 modal;(b) 私董会开始消息显示为简洁两行文本;(c) 所有 assistant 消息有浅灰圆角气泡(`--bg-message-bubble: #ffffff`),board_conclusion 例外(保留自身 card);(d) thinking 阶段不显示空气泡。视觉变化明显但符合方案 B 整体取向。
|
||||
- **前端用户**:所有 Tauri 客户端和 Web GUI 用户将看到 (a) 私董会按钮在已有私董会的会话中弹提示而非 modal;(b) 私董会开始消息显示为简洁两行文本;(c) 所有 assistant 消息有浅灰圆角气泡(`--bg-message-bubble: #ffffff`),card-bearing 类型例外(保留自身 card);(d) thinking 阶段不显示空气泡。视觉变化明显但符合方案 B 整体取向。
|
||||
- **后端/运维**:无影响(不动后端、不动部署、不动配置)。
|
||||
- **其他团队**:无影响(仅前端 Vue 组件)。
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,243 @@
|
|||
---
|
||||
module: frontend/chat
|
||||
date: 2026-07-03
|
||||
problem_type: design_pattern
|
||||
component: frontend_stimulus
|
||||
severity: medium
|
||||
applies_when:
|
||||
- "Implementing message bubble styling for Vue chat components"
|
||||
- "Deciding between CSS :empty selector and JS computed for empty-content detection"
|
||||
- "Excluding card-bearing message types from bubble chrome"
|
||||
- "Making Vue computed properties unit-testable without @vue/test-utils"
|
||||
tags:
|
||||
- vue
|
||||
- css
|
||||
- message-bubble
|
||||
- testing
|
||||
- computed-properties
|
||||
- design-pattern
|
||||
related_components:
|
||||
- assistant
|
||||
- testing_framework
|
||||
---
|
||||
|
||||
# Message Bubble Empty-Content Detection & Card-Type Exclusion Pattern
|
||||
|
||||
## Context
|
||||
|
||||
When implementing Scheme B (neutral grayscale) assistant message bubbles in the
|
||||
chat UI, three interacting concerns surfaced that the naive CSS-only approach
|
||||
could not solve:
|
||||
|
||||
1. **Empty assistant bubbles**: Pre-stream and tool-call-only messages render
|
||||
an empty bubble rectangle — visually broken.
|
||||
2. **Card-bearing double chrome**: Assistant messages rendered as dedicated
|
||||
cards (ErrorCard, BoardConclusionCard, etc.) already carry their own
|
||||
background/border/padding. Wrapping them in an assistant bubble produces
|
||||
double chrome.
|
||||
3. **Testability**: Project convention uses pure-TS vitest tests
|
||||
(`tests/unit/**/*.test.ts`) without `@vue/test-utils`. Computed properties
|
||||
embedded in Vue SFCs are not directly unit-testable.
|
||||
|
||||
## Guidance
|
||||
|
||||
### 1. Never use `:empty` CSS selector for Vue component emptiness
|
||||
|
||||
Vue components always render a root element (even `<template><div></div></template>`),
|
||||
so `:empty` **never matches**. Use a JS computed property that checks the
|
||||
underlying data fields instead.
|
||||
|
||||
```vue
|
||||
<!-- MessageShell.vue -->
|
||||
<template>
|
||||
<div :class="[..., { 'message-shell--empty': isEmpty }]">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
isEmpty?: boolean // computed by parent, passed as prop
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-shell--assistant.message-shell--empty .message-shell__content {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
Parent computes emptiness from message data:
|
||||
|
||||
```ts
|
||||
// ChatMessage.vue
|
||||
const isBubbleEmpty = computed(() => isAssistantBubbleEmpty(props.message))
|
||||
```
|
||||
|
||||
### 2. Exclude card-bearing types via `messageType` prop, not `:has()` selector
|
||||
|
||||
Maintain a `Set<string>` of card-bearing message types and check membership via
|
||||
a computed. Pass `messageType` as a prop rather than using `:has()` CSS selector
|
||||
(better browser support, testable, no CSS selector complexity).
|
||||
|
||||
```ts
|
||||
// bubbleUtils.ts (pure function — testable)
|
||||
const CARD_BEARING_TYPES = new Set<string>([
|
||||
'board_conclusion', 'team_plan', 'debate_started', 'debate_argument',
|
||||
'debate_summary', 'debate_resolved', 'collaboration_graph',
|
||||
'review_result', 'risk_flagged', 'error',
|
||||
])
|
||||
|
||||
export function isCardBearingType(type?: string): boolean {
|
||||
return type ? CARD_BEARING_TYPES.has(type) : false
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- MessageShell.vue -->
|
||||
const isCardBearing = computed(() => isCardBearingType(props.messageType))
|
||||
```
|
||||
|
||||
```css
|
||||
/* F4-A: card-bearing types skip bubble chrome */
|
||||
.message-shell--assistant.message-shell--card .message-shell__content {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: `error` type must be in the exclusion set — `ErrorCard.vue` ships
|
||||
full chrome (background + border + border-radius + padding). Without exclusion,
|
||||
the assistant bubble wraps ErrorCard producing double-background regression.
|
||||
|
||||
### 3. Extract computed logic to pure functions for testability
|
||||
|
||||
When project convention avoids `@vue/test-utils`, extract computed logic to
|
||||
pure functions in a `helpers/` module. The Vue computed becomes a thin wrapper
|
||||
preserving reactivity:
|
||||
|
||||
```ts
|
||||
// bubbleUtils.ts
|
||||
export function isAssistantBubbleEmpty(message: IChatMessage): boolean {
|
||||
if (message.role !== 'assistant') return false
|
||||
return (
|
||||
!message.content &&
|
||||
!message.thinking &&
|
||||
(!message.tool_calls || message.tool_calls.length === 0)
|
||||
)
|
||||
}
|
||||
|
||||
// ChatMessage.vue — thin reactive wrapper
|
||||
import { isAssistantBubbleEmpty } from './helpers/bubbleUtils'
|
||||
const isBubbleEmpty = computed(() => isAssistantBubbleEmpty(props.message))
|
||||
```
|
||||
|
||||
This pattern lets you test the logic with plain vitest:
|
||||
|
||||
```ts
|
||||
// bubbleUtils.test.ts
|
||||
it('returns true for assistant with no content, no thinking, no tool_calls', () => {
|
||||
const msg = makeMsg({ role: 'assistant', content: '' })
|
||||
expect(isAssistantBubbleEmpty(msg)).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
## Why This Matters
|
||||
|
||||
- **`:empty` failure mode is silent**: CSS doesn't error, the selector just
|
||||
never matches. Empty bubbles render with full chrome — visually broken but
|
||||
not detectable without manual inspection. A P0 review finding.
|
||||
- **Double-chrome regression**: ErrorCard wrapping was missed in initial F4-A
|
||||
planning (only 9 types listed). The `error` type was added during code review
|
||||
(P2 finding) after spotting ErrorCard's full chrome.
|
||||
- **Test coverage of decisions**: G1 (`:empty` replacement) and F4-A
|
||||
(card exclusion) are P0 decisions in the plan. Without extracted pure
|
||||
functions, these decisions have no unit test coverage — only visual
|
||||
verification. Extracting to `bubbleUtils.ts` added 36 tests covering all
|
||||
edge cases.
|
||||
|
||||
## When to Apply
|
||||
|
||||
- **Any Vue component where `:empty` seems tempting**: Don't use it. Vue
|
||||
components render root elements. Use JS computed.
|
||||
- **Card-vs-bubble styling decisions**: When a component renders different
|
||||
"chrome" levels (full card vs. bubble vs. plain), use a `messageType` prop
|
||||
+ Set lookup. Avoid `:has()` — it couples styling to DOM structure.
|
||||
- **Computed properties with non-trivial logic**: If the logic is a P0/P1
|
||||
decision (G1, F4-A) or has edge cases (undefined fields, empty arrays),
|
||||
extract to a pure function. The Vue computed should be a one-line wrapper.
|
||||
- **Projects without `@vue/test-utils`**: Pure-function extraction is the
|
||||
primary path to computed-property test coverage.
|
||||
|
||||
## Examples
|
||||
|
||||
### Before (broken — `:empty` never matches)
|
||||
|
||||
```vue
|
||||
<!-- AssistantText always renders a root div, so :empty never matches -->
|
||||
<style scoped>
|
||||
.message-shell--assistant .message-shell__content:empty {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### After (working — JS computed via pure function)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<MessageShell :is-empty="isBubbleEmpty" :message-type="spec.type">
|
||||
<AssistantText :content="message.content" />
|
||||
</MessageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isAssistantBubbleEmpty } from './helpers/bubbleUtils'
|
||||
const isBubbleEmpty = computed(() => isAssistantBubbleEmpty(props.message))
|
||||
</script>
|
||||
```
|
||||
|
||||
### Card-bearing exclusion — complete type list
|
||||
|
||||
```ts
|
||||
// All 10 types that ship their own chrome and must skip the bubble:
|
||||
const CARD_BEARING_TYPES = new Set<string>([
|
||||
'board_conclusion', // BoardConclusionCard — full chrome
|
||||
'team_plan', // TeamPlanCard — full chrome
|
||||
'debate_started', // DebateBannerCard — full chrome (plan typo'd as debate_banner)
|
||||
'debate_argument', // DebateArgumentCard — partial chrome
|
||||
'debate_summary', // DebateSummaryCard — partial chrome
|
||||
'debate_resolved', // DebateResolvedCard — partial chrome
|
||||
'collaboration_graph', // CollaborationGraph — partial chrome
|
||||
'review_result', // ReviewResultCard — partial chrome
|
||||
'risk_flagged', // RiskFlaggedCard — partial chrome
|
||||
'error', // ErrorCard — full chrome (added in code review P2)
|
||||
])
|
||||
```
|
||||
|
||||
### Test file structure (pure-TS, no @vue/test-utils)
|
||||
|
||||
```ts
|
||||
// tests/unit/helpers/bubbleUtils.test.ts
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isCardBearingType, isAssistantBubbleEmpty } from '@/components/chat/helpers/bubbleUtils'
|
||||
import type { IChatMessage } from '@/api/types'
|
||||
|
||||
function makeMsg(overrides: Partial<IChatMessage> = {}): IChatMessage {
|
||||
return { id: 'm1', role: 'assistant', content: '', timestamp: '...', ...overrides }
|
||||
}
|
||||
|
||||
describe('isCardBearingType', () => {
|
||||
it.each(['board_conclusion', 'error', 'team_plan'])('returns true for %s', (type) => {
|
||||
expect(isCardBearingType(type)).toBe(true)
|
||||
})
|
||||
it('returns false for undefined', () => {
|
||||
expect(isCardBearingType(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
|
@ -70,9 +70,10 @@
|
|||
专家团
|
||||
</a-button>
|
||||
<a-button
|
||||
ref="boardButtonRef"
|
||||
size="small"
|
||||
:disabled="disabled"
|
||||
@click="showBoardModal = true"
|
||||
@click="handleBoardClick"
|
||||
class="chat-input__action-btn chat-input__action-btn--board"
|
||||
>
|
||||
<template #icon><TeamOutlined /></template>
|
||||
|
|
@ -125,6 +126,16 @@
|
|||
<span class="chat-input__hint">Enter 发送,Shift + Enter 换行</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-modal
|
||||
v-model:open="showBoardBlockModal"
|
||||
title="当前会话已存在私董会"
|
||||
ok-text="新建会话"
|
||||
cancel-text="我知道了"
|
||||
@ok="handleCreateNewConversationForBoard"
|
||||
@cancel="handleBoardBlockCancel"
|
||||
>
|
||||
<p>请新建会话来创建新的私董会</p>
|
||||
</a-modal>
|
||||
<BoardMeetingModal
|
||||
v-model:open="showBoardModal"
|
||||
@submit="handleBoardSubmit"
|
||||
|
|
@ -137,8 +148,8 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { ref, computed, watch, onMounted, onUnmounted, nextTick, type Component } from 'vue'
|
||||
import { Input as AInput, Button as AButton, Select as ASelect, Modal as AModal } from 'ant-design-vue'
|
||||
import { SendOutlined, TeamOutlined, UsergroupAddOutlined, PaperClipOutlined, PoweroffOutlined, CommentOutlined } from '@ant-design/icons-vue'
|
||||
import ContextPill from './ContextPill.vue'
|
||||
import MentionDropdown from './MentionDropdown.vue'
|
||||
|
|
@ -198,6 +209,8 @@ const availableModels = ref<ModelInfo[]>([])
|
|||
const modelsLoading = ref(false)
|
||||
const showBoardModal = ref(false)
|
||||
const showTeamModal = ref(false)
|
||||
const showBoardBlockModal = ref(false)
|
||||
const boardButtonRef = ref<InstanceType<typeof AButton> | null>(null)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const fileUploading = ref(false)
|
||||
const isDragOver = ref(false)
|
||||
|
|
@ -329,6 +342,32 @@ function handleStop(): void {
|
|||
emit('stop')
|
||||
}
|
||||
|
||||
function handleBoardClick(): void {
|
||||
// U1: 拦截"当前会话已存在进行中的私董会"场景
|
||||
const status = chatStore.boardState?.status
|
||||
if (status === 'discussing' || status === 'concluding') {
|
||||
showBoardBlockModal.value = true
|
||||
return
|
||||
}
|
||||
showBoardModal.value = true
|
||||
}
|
||||
|
||||
async function handleCreateNewConversationForBoard(): Promise<void> {
|
||||
showBoardBlockModal.value = false
|
||||
chatStore.createConversation()
|
||||
await chatStore.selectConversation(chatStore.currentConversationId!, true)
|
||||
// 不自动打开 BoardMeetingModal,由用户在新会话中再次点击"私董会"按钮继续
|
||||
}
|
||||
|
||||
function handleBoardBlockCancel(): void {
|
||||
// R4-D3: 焦点恢复 — a-modal 关闭后将焦点返回触发按钮(WAI-ARIA modal 对话模式)
|
||||
showBoardBlockModal.value = false
|
||||
nextTick(() => {
|
||||
const el = boardButtonRef.value?.$el as HTMLElement | undefined
|
||||
el?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function handleBoardSubmit(command: string): void {
|
||||
// The BoardMeetingModal constructs an @board:expert1,expert2 topic command.
|
||||
// Send it through the normal chat pipeline — the backend intercepts @board prefix.
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@
|
|||
:avatar="spec.shell.avatar"
|
||||
:color="spec.shell.color"
|
||||
:expert-name="message.expert_name"
|
||||
:expert-color="message.expert_color"
|
||||
:streaming="message.status === 'streaming'"
|
||||
:message-type="spec.type"
|
||||
:is-empty="isBubbleEmpty"
|
||||
>
|
||||
<component
|
||||
:is="spec.component"
|
||||
|
|
@ -21,6 +22,7 @@
|
|||
import MessageShell from './messages/MessageShell.vue'
|
||||
import { computed } from 'vue'
|
||||
import { useMessageRenderer } from './helpers/useMessageRenderer'
|
||||
import { isAssistantBubbleEmpty } from './helpers/bubbleUtils'
|
||||
import { useChatStore } from '@/stores/chatStore'
|
||||
import type { IChatMessage } from '@/api/types'
|
||||
|
||||
|
|
@ -41,6 +43,12 @@ const componentListeners = computed(() =>
|
|||
spec.value.type === 'error' ? { retry: handleRetry } : {}
|
||||
)
|
||||
|
||||
/**
|
||||
* U3 G1: 检测消息内容是否为空 (pre-stream / tool-call-only)。
|
||||
* 逻辑提取到 bubbleUtils.isAssistantBubbleEmpty 以支持单元测试。
|
||||
*/
|
||||
const isBubbleEmpty = computed(() => isAssistantBubbleEmpty(props.message))
|
||||
|
||||
function handleRetry(): void {
|
||||
if (chatStore.isLoading) return
|
||||
chatStore.resendLastUserMessage()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import type { IChatMessage } from '@/api/types'
|
||||
|
||||
/**
|
||||
* F4-A (Round 4 扩展): card-bearing assistant 类型排除气泡。
|
||||
* 这些类型由各自 card 组件自带 chrome (ErrorCard full chrome + 其余 partial chrome)。
|
||||
* 注: debate_started 对应 DebateBannerCard (plan 文档误记为 debate_banner)。
|
||||
* ce-code-review P2: 加入 'error' — ErrorCard.vue 自带 full chrome,
|
||||
* 不排除会导致 assistant 气泡嵌套 ErrorCard chrome 双层背景。
|
||||
*
|
||||
* 提取为纯函数以支持单元测试 (F4-A 是关键决策),避免 leaky abstraction
|
||||
* (ce-simplify-code P3) — MessageShell 通过函数调用而非暴露 Set。
|
||||
*/
|
||||
const CARD_BEARING_TYPES = new Set<string>([
|
||||
'board_conclusion',
|
||||
'team_plan',
|
||||
'debate_started',
|
||||
'debate_argument',
|
||||
'debate_summary',
|
||||
'debate_resolved',
|
||||
'collaboration_graph',
|
||||
'review_result',
|
||||
'risk_flagged',
|
||||
'error',
|
||||
])
|
||||
|
||||
export function isCardBearingType(type?: string): boolean {
|
||||
return type ? CARD_BEARING_TYPES.has(type) : false
|
||||
}
|
||||
|
||||
/**
|
||||
* U3 G1: 检测 assistant 消息内容是否为空 (pre-stream / tool-call-only)。
|
||||
* AssistantText 总渲染根 div,:empty 选择器永不匹配,需 JS 判断。
|
||||
* isEmpty = 无 content + 无 thinking + 无 tool_calls。
|
||||
*
|
||||
* 提取为纯函数以支持单元测试 (G1 是 P0 决策)。
|
||||
*/
|
||||
export function isAssistantBubbleEmpty(message: IChatMessage): boolean {
|
||||
if (message.role !== 'assistant') return false
|
||||
return (
|
||||
!message.content &&
|
||||
!message.thinking &&
|
||||
(!message.tool_calls || message.tool_calls.length === 0)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* U4: 判定用户消息是否为普通文本 (非文件附件且非 @board/@team 命令)。
|
||||
* 仅普通文本应用深色气泡,command card / file attachment 保持浅色背景。
|
||||
*
|
||||
* 提取为纯函数以支持单元测试 (U4 是关键功能)。
|
||||
*/
|
||||
const FILE_MARKDOWN_RE = /^\[文件\]\s*\[(.+?)\]\((.+?)\)$/s
|
||||
const COMMAND_RE = /^@(board|team)(?::([^\s]+))?\s+([\s\S]+)$/
|
||||
|
||||
export function isPlainUserText(content: string): boolean {
|
||||
return !FILE_MARKDOWN_RE.test(content) && !COMMAND_RE.test(content)
|
||||
}
|
||||
|
|
@ -135,14 +135,12 @@ export function useMessageRenderer(message: IChatMessage) {
|
|||
|
||||
case 'board_banner': {
|
||||
const data = message.board_started
|
||||
const experts = data?.experts ?? []
|
||||
return {
|
||||
type,
|
||||
shell: { name: '私董会', meta: time },
|
||||
component: BoardBannerCard,
|
||||
props: {
|
||||
topic: data?.topic || message.content || '未命名主题',
|
||||
experts,
|
||||
maxRounds: data?.max_rounds ?? 5,
|
||||
currentRound: message.board_round ?? 1,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,138 +1,37 @@
|
|||
<template>
|
||||
<div class="board-banner-card">
|
||||
<div class="board-banner-card__bar" />
|
||||
<div class="board-banner-card__body">
|
||||
<div class="board-banner-card__title">
|
||||
<BankOutlined class="board-banner-card__icon" />
|
||||
<span>私董会 — {{ topic }}</span>
|
||||
</div>
|
||||
<div class="board-banner-card__experts">
|
||||
<span
|
||||
v-for="(expert, idx) in experts"
|
||||
:key="`${expert.name}-${idx}`"
|
||||
class="board-banner-card__chip"
|
||||
:class="{ 'board-banner-card__chip--moderator': expert.is_moderator }"
|
||||
>
|
||||
<span class="board-banner-card__chip-avatar">{{ expert.avatar }}</span>
|
||||
<span>{{ expert.name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="board-banner-card__meta">
|
||||
<span>轮次:第 {{ currentRound }} / {{ maxRounds }} 轮</span>
|
||||
<div class="board-banner-card__progress">
|
||||
<div
|
||||
class="board-banner-card__progress-fill"
|
||||
:style="{ width: progressPercent + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="board-banner-card__title">私董会 — {{ topic }}</div>
|
||||
<div class="board-banner-card__meta">轮次:第 {{ currentRound }} / {{ maxRounds }} 轮</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { BankOutlined } from '@ant-design/icons-vue'
|
||||
import type { IBoardExpert } from '@/api/types'
|
||||
|
||||
interface Props {
|
||||
topic: string
|
||||
experts: IBoardExpert[]
|
||||
maxRounds: number
|
||||
currentRound?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
withDefaults(defineProps<Props>(), {
|
||||
currentRound: 1,
|
||||
})
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (props.maxRounds <= 0) return 0
|
||||
return Math.min((props.currentRound / props.maxRounds) * 100, 100)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.board-banner-card {
|
||||
width: 100%;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.board-banner-card__bar {
|
||||
height: 4px;
|
||||
background: var(--accent-board);
|
||||
}
|
||||
|
||||
.board-banner-card__body {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.board-banner-card__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.board-banner-card__icon {
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
.board-banner-card__experts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.board-banner-card__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: var(--accent-board-soft);
|
||||
color: var(--accent-board);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.board-banner-card__chip--moderator {
|
||||
background: var(--accent-board);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.board-banner-card__chip-avatar {
|
||||
font-size: 12px;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.board-banner-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.board-banner-card__progress {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.board-banner-card__progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-board);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
<template>
|
||||
<div class="message-shell" :class="[`message-shell--${role}`]">
|
||||
<div
|
||||
class="message-shell"
|
||||
:class="[
|
||||
`message-shell--${role}`,
|
||||
{ 'message-shell--card': isCardBearing, 'message-shell--empty': isEmpty },
|
||||
]"
|
||||
>
|
||||
<div class="message-shell__avatar">
|
||||
<slot name="avatar">
|
||||
<div
|
||||
|
|
@ -42,9 +48,11 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Avatar as AAvatar } from 'ant-design-vue'
|
||||
import { RobotOutlined, UserOutlined } from '@ant-design/icons-vue'
|
||||
import type { Component } from 'vue'
|
||||
import { isCardBearingType } from '../helpers/bubbleUtils'
|
||||
|
||||
interface Props {
|
||||
role: 'user' | 'assistant'
|
||||
|
|
@ -52,23 +60,28 @@ interface Props {
|
|||
meta?: string
|
||||
avatar?: string | Component
|
||||
color?: string
|
||||
/** U4 R10: 专家身份 badge 名称 — 存在时渲染为彩色 badge 替代普通 name 文本 */
|
||||
/** U4 R10: 专家身份 badge 名称 — 存在时渲染为粗体文本替代普通 name */
|
||||
expertName?: string
|
||||
/** U4 R10: 专家身份 badge 颜色 */
|
||||
expertColor?: string
|
||||
/** U4: 流式进行中 — 显示省略号指示器 */
|
||||
streaming?: boolean
|
||||
/** U3 F4-A: 消息类型 — card-bearing 类型不加气泡 (由 card 自带 chrome) */
|
||||
messageType?: string
|
||||
/** U3 G1: 消息内容为空 (pre-stream / tool-call-only) — 隐藏空气泡矩形 */
|
||||
isEmpty?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
name: undefined,
|
||||
meta: undefined,
|
||||
avatar: undefined,
|
||||
color: undefined,
|
||||
expertName: undefined,
|
||||
expertColor: undefined,
|
||||
streaming: false,
|
||||
messageType: undefined,
|
||||
isEmpty: false,
|
||||
})
|
||||
|
||||
const isCardBearing = computed(() => isCardBearingType(props.messageType))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -186,4 +199,33 @@ withDefaults(defineProps<Props>(), {
|
|||
.message-shell--user .message-shell__content {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* U3: assistant 浅灰圆角气泡 (方案B) — 仅 role=assistant 生效,user 不加气泡。
|
||||
F1-A: 用独立 token --bg-message-bubble (与 inline code/table 的 --bg-secondary 解耦)。
|
||||
不使用 !important,让 AssistantText 内部 pre/hljs/code/table 自然继承。 */
|
||||
.message-shell--assistant .message-shell__content {
|
||||
background: var(--bg-message-bubble);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* F4-A: card-bearing assistant 类型不加气泡 (共 9 种,Round 4 扩展)。
|
||||
这些类型由各自 card 组件自带 chrome (BoardConclusionCard/TeamPlanCard full chrome,
|
||||
其余 7 种 partial chrome),避免气泡嵌套冲突。 */
|
||||
.message-shell--assistant.message-shell--card .message-shell__content {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* G1: 空内容 (pre-stream / tool-call-only) 隐藏气泡矩形,仅显示 thinking dots。
|
||||
替代 Round 1 D4-方案1 的 :empty (AssistantText 总渲染根 div 导致 :empty 永不匹配)。 */
|
||||
.message-shell--assistant.message-shell--empty .message-shell__content {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
<div
|
||||
ref="rootRef"
|
||||
class="user-bubble"
|
||||
:class="{ 'user-bubble--focusable': msgId }"
|
||||
:class="{
|
||||
'user-bubble--focusable': msgId,
|
||||
'user-bubble--text': isPlainText,
|
||||
}"
|
||||
:tabindex="msgId ? 0 : undefined"
|
||||
@mouseenter="onBubbleMouseEnter"
|
||||
@mouseleave="onBubbleMouseLeave"
|
||||
|
|
@ -157,6 +160,14 @@ const fileAttachment = computed(() => {
|
|||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* U4: 普通文本消息判定 — 非 file attachment 且非 @board/@team command card。
|
||||
* 仅普通文本应用深色气泡,command card / file attachment 保持浅色 --bg-tertiary。
|
||||
* 核心正则逻辑提取到 bubbleUtils.isPlainUserText 以支持单元测试;
|
||||
* 此处保留对 fileAttachment/commandBubble computed 的引用以维持响应性。
|
||||
*/
|
||||
const isPlainText = computed(() => !fileAttachment.value && !commandBubble.value)
|
||||
|
||||
// --- 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
|
||||
|
|
@ -301,10 +312,20 @@ onUnmounted(() => {
|
|||
}
|
||||
|
||||
.user-bubble--focusable:focus-visible {
|
||||
outline: 2px solid var(--accent-primary, #1a1a1a);
|
||||
outline: 2px solid var(--color-primary, #1a1a1a);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* U4: 普通文本消息参考 demo 深色气泡 (--color-primary + --text-inverse)。
|
||||
command card / file attachment 保持 .user-bubble 默认 --bg-tertiary 浅色背景。
|
||||
--color-primary / --text-inverse 在 light/dark mode 下自动反转。 */
|
||||
.user-bubble--text {
|
||||
background: var(--color-primary);
|
||||
color: var(--text-inverse);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.user-bubble__text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
<MessageShell role="assistant" name="私董会" meta="11:00">
|
||||
<BoardBannerCard
|
||||
topic="是否将订单服务拆分为独立微服务"
|
||||
:experts="boardExperts"
|
||||
:max-rounds="3"
|
||||
:current-round="1"
|
||||
/>
|
||||
|
|
@ -41,13 +40,7 @@ import {
|
|||
BoardRoundCard,
|
||||
BoardConclusionCard,
|
||||
} from '@/components/chat/messages'
|
||||
import type { IChatMessage, IBoardConcludedData, IBoardExpert } from '@/api/types'
|
||||
|
||||
const boardExperts: IBoardExpert[] = [
|
||||
{ name: '架构师老张', avatar: '张', color: 'var(--accent-board)', is_moderator: false, persona: '架构' },
|
||||
{ name: '后端负责人小李', avatar: '李', color: 'var(--accent-board)', is_moderator: false, persona: '后端' },
|
||||
{ name: '主持人', avatar: '主', color: 'var(--accent-board)', is_moderator: true, persona: '主持' },
|
||||
]
|
||||
import type { IChatMessage, IBoardConcludedData } from '@/api/types'
|
||||
|
||||
const speech1: IChatMessage = {
|
||||
id: 's4-speech-1',
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@
|
|||
--bg-tertiary: #f7f7f5;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-code: #1e1e2e;
|
||||
/* 消息气泡背景,与 inline code/table 背景解耦 (F1-A) */
|
||||
--bg-message-bubble: #ffffff;
|
||||
|
||||
/* ── Foreground / Text ── */
|
||||
--text-primary: #1a1a1a;
|
||||
|
|
@ -224,6 +226,8 @@
|
|||
--bg-tertiary: #2a2a2a;
|
||||
--bg-elevated: #252525;
|
||||
--bg-code: #11111b;
|
||||
/* 消息气泡背景,与 inline code/table 背景解耦 (F1-A) */
|
||||
--bg-message-bubble: #1f1f1f;
|
||||
|
||||
/* ── Foreground / Text ── */
|
||||
--text-primary: #fbfbfa;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* Unit tests for BoardBannerCard (U2 — simplified banner).
|
||||
*
|
||||
* Covers the P1 testing gap flagged by ce-code-review. Verifies:
|
||||
* - Renders "私董会 — {topic}" title
|
||||
* - Renders "轮次:第 N / M 轮" meta
|
||||
* - currentRound defaults to 1 when not provided
|
||||
* - Custom currentRound renders correctly
|
||||
* - No icons, borders, purple bars, progress bars, or expert chips (U2 simplification)
|
||||
*
|
||||
* Mount strategy: native Vue createApp + h (no @vue/test-utils dependency),
|
||||
* consistent with AssistantText.test.ts / ThinkingBlock.test.ts pattern.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createApp, h, type App } from 'vue'
|
||||
import BoardBannerCard from '@/components/chat/messages/BoardBannerCard.vue'
|
||||
|
||||
interface Mounted {
|
||||
container: HTMLElement
|
||||
root: HTMLElement
|
||||
app: App
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
function mountBoardBannerCard(props: Record<string, unknown>): Mounted {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const app = createApp({
|
||||
render: () => h(BoardBannerCard as never, props as never),
|
||||
})
|
||||
app.mount(container)
|
||||
const root = container.querySelector('.board-banner-card') as HTMLElement
|
||||
return { container, root, app, unmount: () => { app.unmount(); container.remove() } }
|
||||
}
|
||||
|
||||
describe('BoardBannerCard — U2 simplified banner', () => {
|
||||
let mounted: Mounted | null = null
|
||||
|
||||
afterEach(() => {
|
||||
mounted?.unmount()
|
||||
mounted = null
|
||||
})
|
||||
|
||||
it('renders "私董会 — {topic}" as title', () => {
|
||||
mounted = mountBoardBannerCard({ topic: '技术选型讨论', maxRounds: 5 })
|
||||
const title = mounted.container.querySelector('.board-banner-card__title') as HTMLElement
|
||||
expect(title).toBeTruthy()
|
||||
expect(title.textContent).toBe('私董会 — 技术选型讨论')
|
||||
})
|
||||
|
||||
it('renders "轮次:第 N / M 轮" as meta', () => {
|
||||
mounted = mountBoardBannerCard({ topic: '架构评审', maxRounds: 3, currentRound: 2 })
|
||||
const meta = mounted.container.querySelector('.board-banner-card__meta') as HTMLElement
|
||||
expect(meta).toBeTruthy()
|
||||
expect(meta.textContent).toBe('轮次:第 2 / 3 轮')
|
||||
})
|
||||
|
||||
it('defaults currentRound to 1 when not provided', () => {
|
||||
mounted = mountBoardBannerCard({ topic: '新产品方向', maxRounds: 5 })
|
||||
const meta = mounted.container.querySelector('.board-banner-card__meta') as HTMLElement
|
||||
expect(meta.textContent).toBe('轮次:第 1 / 5 轮')
|
||||
})
|
||||
|
||||
it('renders only title and meta — no icons, bars, progress, or expert chips', () => {
|
||||
mounted = mountBoardBannerCard({ topic: '精简测试', maxRounds: 4, currentRound: 2 })
|
||||
// Only two child elements: __title and __meta
|
||||
const children = mounted.root.children
|
||||
expect(children.length).toBe(2)
|
||||
expect(children[0].className).toBe('board-banner-card__title')
|
||||
expect(children[1].className).toBe('board-banner-card__meta')
|
||||
// No icon elements, progress bars, or expert chips
|
||||
expect(mounted.root.querySelector('svg, .anticon, .board-banner-card__bar, .board-banner-card__progress, .board-banner-card__experts, .board-banner-card__chip')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles CJK topic correctly', () => {
|
||||
mounted = mountBoardBannerCard({ topic: '人工智能伦理边界探讨', maxRounds: 6 })
|
||||
const title = mounted.container.querySelector('.board-banner-card__title') as HTMLElement
|
||||
expect(title.textContent).toBe('私董会 — 人工智能伦理边界探讨')
|
||||
})
|
||||
|
||||
it('handles empty topic gracefully', () => {
|
||||
mounted = mountBoardBannerCard({ topic: '', maxRounds: 5 })
|
||||
const title = mounted.container.querySelector('.board-banner-card__title') as HTMLElement
|
||||
expect(title.textContent).toBe('私董会 — ')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* Unit tests for bubbleUtils — pure functions extracted from MessageShell /
|
||||
* ChatMessage / UserBubble to support testing of key decisions:
|
||||
*
|
||||
* - isCardBearingType (F4-A): card-bearing assistant types skip bubble chrome
|
||||
* - isAssistantBubbleEmpty (G1): empty content detection replaces :empty selector
|
||||
* - isPlainUserText (U4): plain text vs file-attachment / @board/@team command
|
||||
*
|
||||
* These cover the P0/P1 testing gaps flagged by ce-code-review: G1 is a P0
|
||||
* decision (:empty never matches), F4-A is a P0 regression guard (double
|
||||
* bubble), U4 is a P0 feature (dark bubble only for plain text).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isCardBearingType,
|
||||
isAssistantBubbleEmpty,
|
||||
isPlainUserText,
|
||||
} from '@/components/chat/helpers/bubbleUtils'
|
||||
import type { IChatMessage } from '@/api/types'
|
||||
|
||||
function makeMsg(overrides: Partial<IChatMessage> = {}): IChatMessage {
|
||||
return {
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: '2026-07-01T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ── isCardBearingType (F4-A) ────────────────────────────────────────
|
||||
|
||||
describe('isCardBearingType — F4-A card-bearing exclusion', () => {
|
||||
const cardBearingTypes = [
|
||||
'board_conclusion',
|
||||
'team_plan',
|
||||
'debate_started',
|
||||
'debate_argument',
|
||||
'debate_summary',
|
||||
'debate_resolved',
|
||||
'collaboration_graph',
|
||||
'review_result',
|
||||
'risk_flagged',
|
||||
'error',
|
||||
]
|
||||
|
||||
it.each(cardBearingTypes)('returns true for card-bearing type "%s"', (type) => {
|
||||
expect(isCardBearingType(type)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for plain assistant type', () => {
|
||||
expect(isCardBearingType('assistant')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for user type', () => {
|
||||
expect(isCardBearingType('user')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for milestone type (not card-bearing)', () => {
|
||||
expect(isCardBearingType('milestone')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for board_banner / board_speech / board_summary (partial chrome, no own bubble chrome)', () => {
|
||||
// These use BoardRoundCard which relies on the assistant bubble chrome,
|
||||
// so they must NOT be excluded.
|
||||
expect(isCardBearingType('board_banner')).toBe(false)
|
||||
expect(isCardBearingType('board_speech')).toBe(false)
|
||||
expect(isCardBearingType('board_summary')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for undefined type', () => {
|
||||
expect(isCardBearingType(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for empty string', () => {
|
||||
expect(isCardBearingType('')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for unknown type', () => {
|
||||
expect(isCardBearingType('unknown_type')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── isAssistantBubbleEmpty (G1) ─────────────────────────────────────
|
||||
|
||||
describe('isAssistantBubbleEmpty — G1 empty content detection', () => {
|
||||
it('returns true for assistant with no content, no thinking, no tool_calls', () => {
|
||||
const msg = makeMsg({ role: 'assistant', content: '' })
|
||||
expect(isAssistantBubbleEmpty(msg)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for assistant with undefined content and thinking', () => {
|
||||
const msg = makeMsg({ role: 'assistant', content: undefined, thinking: undefined })
|
||||
expect(isAssistantBubbleEmpty(msg)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for assistant with empty tool_calls array', () => {
|
||||
const msg = makeMsg({ role: 'assistant', content: '', tool_calls: [] })
|
||||
expect(isAssistantBubbleEmpty(msg)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for assistant with content', () => {
|
||||
const msg = makeMsg({ role: 'assistant', content: 'hello' })
|
||||
expect(isAssistantBubbleEmpty(msg)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for assistant with thinking content', () => {
|
||||
const msg = makeMsg({ role: 'assistant', content: '', thinking: '思考中' })
|
||||
expect(isAssistantBubbleEmpty(msg)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for assistant with non-empty tool_calls', () => {
|
||||
const msg = makeMsg({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [{ id: 't1', name: 'search', arguments: '{}' } as never],
|
||||
})
|
||||
expect(isAssistantBubbleEmpty(msg)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for user role even when content is empty', () => {
|
||||
const msg = makeMsg({ role: 'user', content: '' })
|
||||
expect(isAssistantBubbleEmpty(msg)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for user role with content', () => {
|
||||
const msg = makeMsg({ role: 'user', content: 'hi' })
|
||||
expect(isAssistantBubbleEmpty(msg)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── isPlainUserText (U4) ────────────────────────────────────────────
|
||||
|
||||
describe('isPlainUserText — U4 plain text detection', () => {
|
||||
it('returns true for plain text', () => {
|
||||
expect(isPlainUserText('你好,帮我重构这个函数')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for empty string (no command, no file)', () => {
|
||||
expect(isPlainUserText('')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for text with @ mention that is not board/team', () => {
|
||||
expect(isPlainUserText('@user hello')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for @board command', () => {
|
||||
expect(isPlainUserText('@board 讨论技术选型')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for @team command', () => {
|
||||
expect(isPlainUserText('@team 完成需求分析')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for @board with expert list', () => {
|
||||
expect(isPlainUserText('@board:expert1,expert2 讨论架构')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for @team with template', () => {
|
||||
expect(isPlainUserText('@team:dev_team 实现登录功能')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for @board with rounds param', () => {
|
||||
expect(isPlainUserText('@board rounds=3 讨论方案')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for file attachment markdown', () => {
|
||||
expect(isPlainUserText('[文件] [report.pdf](https://example.com/report.pdf)')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for text containing @board in the middle (not a prefix)', () => {
|
||||
// Only prefix @board/@team commands are recognized; mid-text @ is plain.
|
||||
expect(isPlainUserText('请帮我 @board 这个问题')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for multiline plain text', () => {
|
||||
expect(isPlainUserText('第一行\n第二行\n第三行')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -5,8 +5,8 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Fischer AgentKit</title>
|
||||
<script type="module" crossorigin src="/assets/index-CHN0ThS0.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DC_0XUg6.css">
|
||||
<script type="module" crossorigin src="/assets/index-N9Dybwcy.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BgFZbme0.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue