diff --git a/frontend-web/docs/multi-agent-frontend-dev.md b/frontend-web/docs/multi-agent-frontend-dev.md index fe1c781d..88da48a8 100644 --- a/frontend-web/docs/multi-agent-frontend-dev.md +++ b/frontend-web/docs/multi-agent-frontend-dev.md @@ -8,7 +8,11 @@ ## 0. 速读:你需要先知道的事 -- **整页只有一个文件**:`RoundtablePlanningPage.tsx`(≈ 3 621 行)。三步 UI 通过 `currentStep ∈ {1,2,3}` 切换,不使用 React Router 子路由。 +- **三步 UI 通过 `currentStep ∈ {1,2,3}` 切换**,不使用 React Router 子路由。主页面 `RoundtablePlanningPage.tsx`(≈ 1 150 行)只负责组合 + 编排,业务状态全部下沉到三个 hook(见下面)。 +- **业务状态分三块**:`useStep1Intent`(任务理解 + 澄清)、`useStep2Orchestration`(编排循环 + 对话流 + Persona)、`useDraftPersistence`(草稿)。改 Step X 的逻辑请直接进对应 hook,不要回到主页面里写。 +- **消息气泡有共享件**:Step 1 / Step 2 的对话气泡共用 `MessageBubble.tsx` 里的 `MessageBubble` + `MessageLoadingPlaceholder` + `StreamingCursor` + `MarkdownContent`。改气泡通用样式(头像、header、loading、流式 cursor、Markdown 渲染配置)请改这里,不要在两个 Panel 各改一遍。 +- **步骤折叠卡与主聊天对齐**:思考过程 / 工具调用展示走 `MessageStepsCard`,**always-show-last-tool + 折叠 "查看其他 N 个步骤"** 的视觉与主聊天 `chats/new` 的 `MessageGroup` 一致。工具图标 / 文案映射在 `lib/step-display.tsx`,新增工具时与 `src/core/i18n/locales/zh-CN.ts` 的 `toolCalls` 字段对齐。 +- **`` 标签自动提取为「思考过程」**:`lib/reasoning.ts::splitInlineReasoning(text)` 把 `...`(含未闭合的流式中段)抽离成 `reasoning`,正文走 markdown;由 `MessageBubble.tsx::ReasoningBlock` 渲染一个折叠卡。Step 1 / Step 2 气泡和推荐弹窗的 rationale 都走这一套,与主聊天 `Reasoning` 元素的视觉对齐。 - **不使用 LangGraph SDK**:所有 API 都是自研的 `apiFetch` + `ReadableStream` SSE 解析(见 `api/intent.ts`、`api/recommend.ts`、`api/multi-agent.ts`)。 - **不写死模型名**:与 `/page/canvas/ai-writing` 同样的策略 —— 全局选中模型由 `selectedModel` state 维护,首次 `useModels()` 完成后初始化为 `availableModels[0].name`,顶栏「模型」下拉可切换。所有 API 调用统一传 `model: selectedModel || undefined`;后端缺省由 `lead_agent._resolve_model_name()` 回退到 `config.yaml` 的 `models[0]`。**内网部署只需在 `config.yaml` 配自家模型即可,前后端均无需改动**。 - **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。 @@ -44,29 +48,60 @@ ``` frontend-web/src/roundtable-planning/ -├── index.ts # 导出 RoundtablePlanningPage、HighFidelityReport +├── index.ts # 导出 RoundtablePlanningPage、HighFidelityReport ├── pages/ -│ └── RoundtablePlanningPage.tsx # 三步主页面 + 状态机 + 编排循环(≈3600 行) +│ └── RoundtablePlanningPage.tsx # 主页面:组合 hook + 顶栏/侧栏 + 三步路由(≈1 150 行) ├── api/ -│ ├── intent.ts # Step 1:initIntent、streamIntent -│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent -│ └── multi-agent.ts # Step 2/3:initMultiAgent、streamMultiAgent、listSessionArtifacts、buildArtifactDownloadUrl +│ ├── intent.ts # Step 1:initIntent、streamIntent +│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent +│ └── multi-agent.ts # Step 2/3:initMultiAgent、streamMultiAgent、listSessionArtifacts、buildArtifactDownloadUrl ├── components/ -│ ├── IntentClarificationCard.tsx # Step 1 选项卡片(单选/多选)+ formatIntentClarificationAnswer -│ ├── RecommendAgentsDialog.tsx # Step 1→2 推荐弹窗(含流式 rationale + 候选筛选) -│ ├── HighFidelityReport.tsx # Step 3 报告 + 文件清单 -│ ├── ArtifactPreviewModal.tsx # Markdown/纯文本/JSON/CSV/YAML 等文本类文件 in-app 预览 -│ └── ScrollToBottomButton.tsx # 对话区「回到底部」浮动按钮 +│ ├── Avatars.tsx # 所有 SVG 头像 + getAvatar 选择器 +│ ├── MessageBubble.tsx # Step 1/2 共享:消息壳 + loading 占位 + 流式 cursor + MD 配置 +│ ├── MessageStepsCard.tsx # Step 1/2 共享:工具调用 + 思考过程的折叠卡(对齐主聊天 MessageGroup) +│ ├── Step1Panel.tsx # Step 1 对话面板(不含状态,只读 props) +│ ├── Step2Panel.tsx # Step 2 主面板(圆桌对话流 + 澄清条 + 干预输入) +│ ├── Step3Panel.tsx # Step 3 薄壳,内部仍是 HighFidelityReport +│ ├── PersonaDrawer.tsx # Step 2 Persona 抽屉(System Prompt / Temp / Model) +│ ├── IntentClarificationCard.tsx # Step 1 选项卡片(单选/多选)+ formatIntentClarificationAnswer +│ ├── RecommendAgentsDialog.tsx # Step 1→2 推荐弹窗(含流式 rationale + 候选筛选) +│ ├── HighFidelityReport.tsx # Step 3 报告 + 文件清单 +│ ├── ArtifactPreviewModal.tsx # Markdown/纯文本/JSON/CSV/YAML 等文本类文件 in-app 预览 +│ └── ScrollToBottomButton.tsx # 对话区「回到底部」浮动按钮 ├── hooks/ -│ └── useAutoScroll.ts # 滚动跟随 + 用户上滑暂停跟随 +│ ├── useAutoScroll.ts # 滚动跟随 + 用户上滑暂停跟随 +│ ├── useStep1Intent.ts # Step 1 状态机:init、send、stream、abort、INTENT_READY 同步 +│ ├── useStep2Orchestration.ts # Step 2 状态机:initMultiAgent + 编排循环 + 对话流管理 + Persona +│ └── useDraftPersistence.ts # 草稿:自动保存(前进切换)/手动保存/加载/重命名/删除 ├── lib/ -│ └── clarification.ts # resolveAllowMultiple(单选/多选解析),与主聊天 ClarificationCard 对齐 +│ ├── clarification.ts # resolveAllowMultiple(单选/多选解析),与主聊天 ClarificationCard 对齐 +│ ├── reasoning.ts # splitInlineReasoning:从模型流式文本里提取 `...` 思考过程 +│ ├── roundtable-constants.ts # 类型(RoleEntry / SelectedAgent) + 常量 + 纯工具函数(buildRoleTemplates / splitIntentReadyBlock 等) +│ └── step-display.tsx # Step 2 ChainOfThought 折叠卡图标 + 文案 + 子节点渲染 ├── utils/ -│ └── drafts.ts # localStorage 草稿存档(list / upsert / get / delete / rename / deriveName) +│ └── drafts.ts # localStorage 草稿存档(list / upsert / get / delete / rename / deriveName) └── styles/ - └── roundtable-planning.css # 页面主题变量(浅红品牌色 + 深色覆盖) + └── roundtable-planning.css # 页面主题变量(浅红品牌色 + 深色覆盖) ``` +**模块依赖方向(自上而下)**: + +``` +pages/RoundtablePlanningPage + ├─ uses → hooks/useStep1Intent + ├─ uses → hooks/useStep2Orchestration + ├─ uses → hooks/useDraftPersistence (依赖 step1 / step2 的 getSnapshot + hydrate) + ├─ uses → components/Step{1,2,3}Panel (纯 props 驱动) + ├─ uses → components/RecommendAgentsDialog + └─ uses → components/Avatars + +hooks/useStep1Intent → api/intent +hooks/useStep2Orchestration → api/multi-agent + components/PersonaDrawer 的类型 +hooks/useDraftPersistence → utils/drafts + step1/step2 的 Snapshot 类型 +``` + +主页面通过 ref 桥接两个 hook 之间的依赖(`buildInitialIntent` 在 Step 2 init 时从 Step 1 状态读取),避免 hook 互相引用造成的循环导入。 + **路由注册**(`src/pages/WorkspaceRoutes.tsx`): ```tsx @@ -242,6 +277,16 @@ window.__MULTI_AGENT_DEBUG__ = true; // 默认开启;stream 帧计数 + ## 5. 主页面状态(`RoundtablePlanningPage`) +> **注意**:自 2026-05 重构后,绝大多数业务状态从主页面下沉到 3 个 hook。下面列出的 state 仍按"位置"分组以便对照原代码,实际所有者请看右列。 + +| 维度 | 所有者 | 暴露给主页面的方式 | +|------|--------|-------------------| +| `currentStep`、`showToast`、`selectedModel`、`roles`、`isRecommendDialogOpen` | 主页面 | useState | +| Step 1 全部 state + handler | `useStep1Intent` | hook 返回值 | +| Step 2 全部 state + handler + 编排循环 | `useStep2Orchestration` | hook 返回值 | +| 草稿(drafts / currentDraftId / 重命名 / 历史下拉) | `useDraftPersistence` | hook 返回值 | +| `loadedFromDraftRef` 共享 latch | `useDraftPersistence` 拥有,Step 2 init effect 通过 options 读 | hook 间共享 ref | + ### 5.1 步骤与门禁 - `currentStep`: `1 | 2 | 3` @@ -411,7 +456,10 @@ isOrchestrating = false - 打开时管线:`listAgents() → filterRecommendCandidates() → streamRecommend()` - 阶段 UI(`InternalState.phase`): - - `idle` → `loading_agents` → `recommending`(顶部 rationale 流;下方推荐卡 streaming=true)→ `ready` / `fallback` / `error` + - `idle` → `loading_agents` → `recommending`(顶部「模型生成内容」区流式;下方推荐卡 streaming=true)→ `ready` / `fallback` / `error` +- **布局分两段**: + - **顶部「模型生成内容」**:`RecommenderOutput` —— 状态行(`spinner + 调度推荐 / 已连接 / 正在生成`)+ `MessageStepsCard` 步骤卡 + `ReasoningBlock`(从 `` 拆出来)+ markdown rationale + 末尾 `StreamingCursor`。视觉与 Step 1/Step 2 气泡的内容区一致,参考主聊天 `chats/new`。 + - **底部「推荐参会智能体」**:候选卡片(`AgentCard`),分「推荐参会 / 其他候选」两组。 - 选择约束:`2 ≤ selected ≤ 8`;底部实时显示「至少再选 N 个」「已选 X / 8」 - 失败兜底:候选为空 → error;status==="asking" 或 picks 不足 2 → `fallback`,自动用 `FALLBACK_AGENT_IDS` 与候选池交集勾选 - 反竞态:内部 `generationRef`,每次 `runRecommendation` 自增,所有 setState 都校验 `myGen === generationRef.current`,避免「重新推荐」时旧 stream 写脏 state @@ -626,13 +674,27 @@ window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开) - 后端开放 per-agent override 接口或在 `streamMultiAgent` 新增 `prompt_override` / `temperature` 字段 - 前端:`agentConfigs[meta.speakerId]` 一并传入 -### 12.6 拆分页面 +### 12.6 拆分页面(已完成 · 2026-05) -当前刻意单文件维护三步。若拆分建议: +主页面早期是 4 000+ 行单文件,2026-05 重构按"hooks 抽逻辑、components 抽 UI"原则拆分: -- `useRoundtableStep1.ts` / `useRoundtableStep2.ts` — 状态与副作用 -- 保持 `api/*` 不变 -- 保持 `roundtable-planning.css` 主题变量 +- `hooks/useStep1Intent.ts` —— Step 1 全部状态机、init effect、INTENT_READY 同步 +- `hooks/useStep2Orchestration.ts` —— Step 2 状态、编排循环、对话流、Persona、澄清 +- `hooks/useDraftPersistence.ts` —— 草稿自动保存 / 手动保存 / 加载 / 重命名 / 删除 +- `components/Step{1,2,3}Panel.tsx` —— 纯 UI,只接收 props,不持有业务状态 +- `components/PersonaDrawer.tsx` —— Persona 抽屉 +- `components/Avatars.tsx` —— 所有 SVG 头像 + getAvatar +- `lib/roundtable-constants.ts` —— 常量、类型、纯工具函数(buildRoleTemplates 等) +- `lib/step-display.tsx` —— ChainOfThought 折叠卡的图标 + 文案 + 子节点渲染 + +**主页面只保留**:顶栏(StepNavigation / HistoryDropdown)、左右两侧栏(LeftSidebar / RightSidebar)、步骤路由、推荐弹窗,以及编排 hook 与跨步 handler(`handleConfirmAgents` / `handleStartNewTask`)。 + +继续重构请遵守: + +1. **不要在 Panel 组件里写 fetch / setState 业务流程** —— Panel 只渲染,事件透传到 hook。 +2. **hook 之间不要互相 import** —— 跨 hook 的数据通过主页面的 ref / callback 桥接(参考 `buildInitialIntent` 与 `loadedFromDraftRef` 的实现)。 +3. **草稿 snapshot 形状**(Step 1 的 `Step1Snapshot` / Step 2 的 `Step2Snapshot`)与 `utils/drafts.ts` 的 `DraftStep{1,2}Snapshot` 是同一份语义,改字段时两边都要改。 +4. **lint/typecheck**:重构后 `pnpm typecheck` 与 `pnpm build` 必须保持与重构前同样的 0 个新增错误(预存量 50 个 pre-existing 错误来自其它模块如 `next/link` shim,与本特性无关)。 ### 12.7 文档协同 @@ -648,7 +710,20 @@ window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开) | 资源 | 路径 | |------|------| -| 主页面 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | +| 主页面(组合 + 顶栏 + 侧栏) | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | +| Step 1 hook | `frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts` | +| Step 2 hook(含编排循环) | `frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts` | +| 草稿 hook | `frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts` | +| Step 1 面板 | `frontend-web/src/roundtable-planning/components/Step1Panel.tsx` | +| Step 2 面板 | `frontend-web/src/roundtable-planning/components/Step2Panel.tsx` | +| Step 3 面板 | `frontend-web/src/roundtable-planning/components/Step3Panel.tsx` | +| 消息气泡共享件 | `frontend-web/src/roundtable-planning/components/MessageBubble.tsx` | +| 步骤折叠卡(工具/思考) | `frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx` | +| `` 提取工具 | `frontend-web/src/roundtable-planning/lib/reasoning.ts` | +| Persona 抽屉 | `frontend-web/src/roundtable-planning/components/PersonaDrawer.tsx` | +| 头像集合 | `frontend-web/src/roundtable-planning/components/Avatars.tsx` | +| 常量/工具函数 | `frontend-web/src/roundtable-planning/lib/roundtable-constants.ts` | +| ChainOfThought 渲染 | `frontend-web/src/roundtable-planning/lib/step-display.tsx` | | Step 1 API | `frontend-web/src/roundtable-planning/api/intent.ts` | | 推荐 API | `frontend-web/src/roundtable-planning/api/recommend.ts` | | Step 2/3 API | `frontend-web/src/roundtable-planning/api/multi-agent.ts` | diff --git a/frontend-web/docs/ui修改-深蓝.md b/frontend-web/docs/ui修改-深蓝.md index a2d6a432..54fec496 100644 --- a/frontend-web/docs/ui修改-深蓝.md +++ b/frontend-web/docs/ui修改-深蓝.md @@ -1535,3 +1535,673 @@ Hover 弹出 Tooltip(沿用 §9.3.3 Tooltip 配色),承载完整说明文 --history-sep: #4A6491; } ``` + +--- + +## 15. 选择器(Select / Multi-Select / Cascader) + +> 对应设计稿「多项选择.png」。覆盖**多项选择**与**多项带搜索**两种主要变体(基础单选选择器同规格,仅触发器不渲染 Tag、面板项不带复选框;级联选择器原则相同,差异在面板分栏横向铺开)。配色全部从 §1.1.3 渐蓝系(ZS-002)与 §1.1.5 深蓝系(ZS-004)取,与 §14 搜索框完全对齐。 + +### 15.1 公共规格 + +#### 15.1.1 触发器(Trigger) + +触发器即「请选择 ▾」或「已选 Tag … ▾」那一行。尺寸、边框、状态机与 §14.1 输入框完全对齐: + +| 属性 | 取值 | 来源 | +|------|------|------| +| 高度 | `32px` | §14.1 | +| 圆角 | `2px` | §4.1 | +| 水平 padding | `--spacing-3` (12px) | §7.1 | +| 背景 | `#0C1836` (ZS-004-09) | — | +| 边框 | `1px solid #1A2F5A` (ZS-004-07) | — | +| 占位文字 "请选择" | `14px Regular`,`#6E88B0` (ZS-004-04) | — | +| 右侧箭头 `▾` | `12px`,`#6E88B0`,hover `#4096FF` | ZS-002-05 | + +边框状态机(默认 / hover / focus / 失效 / 错误)直接复用 §14.1.1。 + +#### 15.1.2 下拉面板(Panel) + +面板沿用 §14.4.4 联想下拉的容器规格,仅项内容不同: + +| 属性 | 取值 | +|------|------| +| 背景 | `#122347` (ZS-004-08) | +| 边框 | `1px solid #1A2F5A` (ZS-004-07) | +| 阴影 | `0 4px 16px rgba(6, 14, 36, 0.6)`(§3.1.4 三级阴影) | +| 圆角 | `2px` | +| 项高度 | `36px` | +| 项水平 padding | `--spacing-3` (12px) | +| 项字号 | `14px Regular`,`#C5CFE0` (ZS-004-02) | +| 面板宽度 | 与触发器等宽(搜索框、底部按钮均内嵌等宽) | +| 面板与触发器纵向间距 | `--spacing-1` (4px) | + +#### 15.1.3 项状态 + +| 状态 | 背景 | 文字 | +|------|------|------| +| 默认 | `transparent` | `#C5CFE0` (ZS-004-02) | +| Hover / 键盘焦点 | `rgba(22,119,255,0.16)` 或 `#1A2F5A` | `#FFFFFF` | +| 已选中 | `transparent`(依赖左侧复选框示态,不改行底色) | `#FFFFFF` | +| 禁用 | `transparent` | `#4A6491` (ZS-004-05) | + +点击行的任意位置等价于点击复选框(命中区 = 整行)。 + +--- + +### 15.2 多项选择 + +> 触发器内渲染**已选 Tag 列表**而不是单行文字;面板项左侧带**复选框**,多选不关闭面板。 + +``` +┌─────────────────────────────────────┐ +│ n1 × n2 × +1 × ▾ │ ← 触发器(已选 Tag) +└─────────────────────────────────────┘ +┌─────────────────────────────────────┐ ← 下拉面板 +│ ☑ n1 │ +│ ☐ n2 │ +│ ☐ n3 │ +│ ☐ n4 │ +│ ☐ n5 │ +├─────────────────────────────────────┤ +│ [+ 添加项] │ ← 可选:底部「添加」按钮(§15.2.4) +└─────────────────────────────────────┘ +``` + +#### 15.2.1 已选 Tag + +触发器内每个已选项渲染为一颗 Tag。**实心主色蓝底 + 白字**,右侧带关闭按钮 `×`。 + +| 元素 | 色值 / 规格 | 来源 | +|------|-------------|------| +| 背景 | `#1677FF` (ZS-002-06) | — | +| 文字 | `12px Regular`,`#FFFFFF` | — | +| 圆角 | `2px` | — | +| 内边距 | `2px 6px` | — | +| Tag 间距 | `--spacing-1` (4px) | §7.1 | +| 关闭按钮 `×` | `12px`,`rgba(255,255,255,0.72)`,hover `#FFFFFF` | — | +| 折叠展示 `+N ×` | 当选中超过宽度可容纳时,把剩余 N 项收成一颗 `+N ×` Tag。Hover 显示 Tooltip 列出剩余项名;点击 `×` 清空溢出选项 | — | + +#### 15.2.2 复选项 + +| 元素 | 色值 / 规格 | 来源 | +|------|-------------|------| +| 复选框尺寸 | `14px × 14px` | — | +| 复选框圆角 | `2px` | — | +| 未选边框 | `1px solid #6E88B0` (ZS-004-04) | — | +| 未选背景 | `transparent` | — | +| 已选填充 | `#1677FF` (ZS-002-06) | — | +| 已选勾 ✓ | `#FFFFFF`,描边 `2px` | — | +| 半选填充 | `#1677FF` | — | +| 半选指示 | 中央横线 `width 8px / height 2px / #FFFFFF` | — | +| Hover 边框 | `#4096FF` (ZS-002-05) | — | +| 禁用边框 | `#4A6491` (ZS-004-05) + 背景 `rgba(22,119,255,0.04)` | — | +| 复选框 → 文字间距 | `--spacing-2` (8px) | §7.1 | + +#### 15.2.3 全选模式(顶部「全部」节点) + +面板首行可选地渲染一个「全部」聚合节点,作用类似一次性勾 / 取消所有项: + +``` +┌─────────────────────────────────────┐ +│ ▢ 全部 │ ← 半选 / 未选 / 全选三态 +│ ☑ n1 │ +│ ☐ n2 │ +└─────────────────────────────────────┘ +``` + +| 子状态 | 复选框外观 | 触发动作 | +|--------|-----------|----------| +| 一个都没选 | 空心,边框 `#6E88B0` | 点击 → 勾上**全部**子项 | +| 部分选中 | 实心 `#1677FF` + 中央 `2px` 白色横线(半选) | 点击 → 勾上**全部**剩余未选 | +| 全部选中 | 实心 `#1677FF` + 白色勾 ✓ | 点击 → 清空全部 | + +文字 "全部" 与下方项同色 `#C5CFE0`,不加粗。 + +#### 15.2.4 底部「+ 添加项」按钮 + +面板底部可选地嵌入一颗虚线主色按钮,常用于「在已知池子里允许用户**就地新增**一项」(如标签、自定义分类): + +``` +┌─────────────────────────────────────┐ +│ [+ 正常] │ +└─────────────────────────────────────┘ +``` + +| 元素 | 色值 / 规格 | 来源 | +|------|-------------|------| +| 高度 | `32px`(与复选项行齐) | — | +| 边框 | `1px dashed #1677FF` (ZS-002-06) | — | +| 背景 | `transparent` | — | +| 文字 | `14px Regular`,`#4096FF` (ZS-002-05) | — | +| 前缀图标 `+` | `12px`,与文字同色 | — | +| Hover | 背景 `rgba(22,119,255,0.16)`,文字 `#1677FF` | ZS-002-05 → ZS-002-06 | +| Active | 背景 `rgba(22,119,255,0.24)`,文字 `#FFFFFF` | — | +| 与最后一条复选项纵向分隔 | 上方分割线 `1px solid #1A2F5A` + `--spacing-1` (4px) 间距 | §6.1 | + +点击行为由业务自行实现(如弹出输入框、内联编辑)。 + +--- + +### 15.3 多项带搜索 + +> 在 §15.2 基础上,**面板顶部嵌入一行搜索框**,输入时实时过滤复选项列表。其它结构(已选 Tag / 复选项 / 全选 / 底部添加)完全沿用 §15.2。 + +``` +┌─────────────────────────────────────┐ +│ n1 × +1 × ▾ │ +└─────────────────────────────────────┘ +┌─────────────────────────────────────┐ +│ [ 搜索 🔍 ] │ ← 嵌入式搜索框(§15.3.1) +├─────────────────────────────────────┤ +│ ▢ 全部 │ +│ ☑ n1 │ +│ ☐ n2 │ +│ ☐ n3 │ +└─────────────────────────────────────┘ +``` + +#### 15.3.1 嵌入式搜索框 + +搜索框尺寸采用 §14.1 公共规格的**小号**(高度 28px),保持与面板视觉上的"嵌入感": + +| 属性 | 取值 | +|------|------| +| 高度 | `28px`(小号) | +| 水平 padding | `--spacing-2` (8px) | +| 圆角 | `2px` | +| 背景 | `#0C1836` (ZS-004-09,比面板背景深一档) | +| 边框 | `1px solid #1A2F5A` (ZS-004-07) | +| 文字 | `14px Regular`,`#C5CFE0` | +| 占位符 | "搜索",`#6E88B0` | +| 右侧 🔍 / ⊗ 图标 | `14px`,`#6E88B0`,hover `#4096FF` | +| 搜索框 → 列表项纵向间距 | `--spacing-2` (8px) + 分割线 `1px solid #1A2F5A` | + +输入非空时右侧图标切换为 `⊗`(清空),点击清空输入并恢复完整列表。 + +#### 15.3.2 加载与空状态 + +异步获取选项(如远程搜索)时,列表区按以下规则渲染: + +| 状态 | 渲染 | +|------|------| +| 加载中 | 文字 "加载中…",单行居左,`14px Regular`,`#6E88B0`,行高 `32px` | +| 无匹配 | 文字 "无匹配项",同上 | +| 网络错误 | 文字 "加载失败,点击重试",`#FF7875` (ZS-001-04),可点击 | + +--- + +### 15.4 选用决策 + +| 场景 | 选择 | +|------|------| +| 候选池 ≤ 10 项,单值 | §15.1 公共规格 + 去掉复选框(基础单选) | +| 候选池 ≤ 10 项,多值 | §15.2 多项选择 | +| 候选池 > 10 项 / 不可枚举 | §15.3 多项带搜索 | +| 候选池有层级关系(如部门 / 区域) | 级联选择器:每级独立面板横向铺开,单级内部规格同 §15.1~15.3 | +| 允许用户**新增不在候选池**的项 | 在 §15.2 / §15.3 末尾启用 §15.2.4「+ 添加项」按钮 | + +--- + +### 15.5 CSS Token 参考 + +```css +:root { + /* 触发器 —— 复用 §14 搜索框 */ + --select-trigger-bg: var(--search-bg); /* #0C1836 */ + --select-trigger-border: var(--search-border); /* #1A2F5A */ + --select-trigger-placeholder: var(--search-placeholder); /* #6E88B0 */ + --select-trigger-arrow: #6E88B0; + --select-trigger-arrow-hover: #4096FF; + + /* 下拉面板 —— 复用 §14.4.4 联想 */ + --select-panel-bg: var(--suggest-bg); /* #122347 */ + --select-panel-border: #1A2F5A; + --select-item-text: var(--suggest-item-text); /* #C5CFE0 */ + --select-item-hover-bg: var(--suggest-item-hover); /* rgba(22,119,255,0.16) */ + --select-item-hover-text: var(--suggest-item-hover-text); /* #FFFFFF */ + --select-item-disabled: #4A6491; /* ZS-004-05 */ + + /* 已选 Tag */ + --select-tag-bg: #1677FF; /* ZS-002-06 */ + --select-tag-text: #FFFFFF; + --select-tag-close: rgba(255,255,255,0.72); + --select-tag-close-hover: #FFFFFF; + + /* 复选框 */ + --check-border: #6E88B0; /* ZS-004-04 */ + --check-border-hover: #4096FF; /* ZS-002-05 */ + --check-fill: #1677FF; /* ZS-002-06 */ + --check-mark: #FFFFFF; + --check-disabled-border: #4A6491; /* ZS-004-05 */ + --check-disabled-bg: rgba(22,119,255,0.04); + + /* 底部「+ 添加项」 */ + --select-add-border: #1677FF; /* ZS-002-06 */ + --select-add-text: #4096FF; /* ZS-002-05 */ + --select-add-hover-bg: rgba(22,119,255,0.16); + --select-add-hover-text: #1677FF; + --select-add-active-bg: rgba(22,119,255,0.24); + --select-add-active-text: #FFFFFF; + + /* 加载 / 错误 */ + --select-loading-text: #6E88B0; + --select-error-text: #FF7875; /* ZS-001-04 */ +} +``` + +--- + +## 16. 单选与多选(Radio / Checkbox) + +> 对应设计稿「多选框.png」。本章定义独立作为表单字段使用的**单选(Radio)**和**多选(Checkbox)**两类原子控件,包含**未选 / 已选 / 未选禁用 / 已选禁用**四种状态。§15.2.2「多项选择的复选项」中的复选框直接复用本章 §16.3 规格,本章是单一真源。 + +### 16.1 公共规格 + +| 属性 | 取值 | +|------|------| +| 控件尺寸 | `14px × 14px` | +| 控件描边宽度 | `1px` | +| 控件 → 文字间距 | `--spacing-2` (8px)(§7.1) | +| 文字字号 | `14px Regular` | +| 行高 | `20px`(点击命中区与文字垂直对齐) | +| 同一组**横向相邻**项的间距 | `--spacing-6` (24px)(§7.1) | +| 同一组**纵向相邻**项的间距 | `--spacing-3` (12px)(§7.1) | + +整行(控件 + 文字)任意位置可点击 = 等效切换该项状态。 + +--- + +### 16.2 单选(Radio) + +> 一组互斥选项中只能选中一个。视觉为圆形描边 + 内圆实心点。 + +``` +○ 未选 ● 已选 ○ 未选禁用 ● 已选禁用 + (整组降饱和) +``` + +#### 16.2.1 外观规格 + +| 状态 | 外环 | 内圆 | 文字 | +|------|------|------|------| +| 未选 | `1px solid #4096FF` (ZS-002-05) + 背景 `transparent` | — | `#C5CFE0` (ZS-004-02) | +| 未选 Hover | `1px solid #1677FF` (ZS-002-06) | — | `#FFFFFF` | +| 已选 | `1px solid #1677FF` (ZS-002-06) | 中心实心圆 `6px × 6px`,`#1677FF` | `#FFFFFF` | +| 已选 Focus | 外环外加 `0 0 0 2px rgba(22,119,255,0.16)` 焦点环 | — | `#FFFFFF` | +| 未选禁用 | `1px solid #4A6491` (ZS-004-05) + 背景 `rgba(22,119,255,0.04)` | — | `#4A6491` | +| 已选禁用 | `1px solid #4A6491` (ZS-004-05) | 中心实心圆 `6px × 6px`,`#4A6491` | `#4A6491` | + +外环与内圆均为正圆(`border-radius: 50%`)。 + +#### 16.2.2 与 label 组合使用 + +「基本使用-组合」场景:选中某 Radio 项后,**下方展开关联输入字段**(如 Antd 的 Form.Item 嵌入式组合)。 + +``` +● 已选 + + lable: [ ] + lable: [ ] +``` + +| 元素 | 规格 | +|------|------| +| Radio → 下方第一行 label 纵向间距 | `--spacing-3` (12px)(§7.1) | +| 嵌入字段左缩进 | 与 Radio 文字左边缘对齐(即外环宽 14px + 间距 8px = **22px 左缩进**) | +| 嵌入字段的 label 与 input | 沿用 §14.1 输入框规格 | +| 嵌入区分组背景 | 可选地用 `#1A2F5A` (ZS-004-07) 作浅色卡片底色,强化"属于该 Radio"的视觉归属 | + +--- + +### 16.3 多选(Checkbox) + +> 多个选项可同时选中,互不影响。视觉为方形描边 + 内填勾 ✓。 + +``` +☐ 未选 ☑ 已选 ☐ 未选禁用 ☑ 已选禁用 + (整组降饱和) +``` + +#### 16.3.1 外观规格 + +| 状态 | 边框 / 背景 | 勾 ✓ | 文字 | +|------|------------|------|------| +| 未选 | `1px solid #4096FF` (ZS-002-05) + 背景 `transparent` | — | `#C5CFE0` (ZS-004-02) | +| 未选 Hover | `1px solid #1677FF` (ZS-002-06) | — | `#FFFFFF` | +| 已选 | 实心填充 `#1677FF` (ZS-002-06) | `#FFFFFF`,描边 `2px` | `#FFFFFF` | +| 已选 Focus | 外加 `0 0 0 2px rgba(22,119,255,0.16)` 焦点环 | `#FFFFFF` | `#FFFFFF` | +| 半选 | 实心填充 `#1677FF` (ZS-002-06) | 中央横线 `8px × 2px`,`#FFFFFF` | `#FFFFFF` | +| 未选禁用 | `1px solid #4A6491` (ZS-004-05) + 背景 `rgba(22,119,255,0.04)` | — | `#4A6491` | +| 已选禁用 | 实心填充 `#4A6491` (ZS-004-05) | `#C5CFE0` 或 `rgba(255,255,255,0.4)` | `#4A6491` | + +复选框统一圆角 `2px`(§4.1)。 + +#### 16.3.2 与 label 组合使用 + +与 §16.2.2 完全一致 —— Checkbox 选中后下方展开嵌入式输入字段,缩进与间距规格相同(左缩进 22px、纵向间距 12px)。 + +``` +☑ 已选 + + lable: [ ] + lable: [ ] +``` + +--- + +### 16.4 状态对照表 + +| 状态 | Radio 外环 | Radio 内圆 | Checkbox 外框 | Checkbox 勾 | 文字 | +|------|-----------|-----------|--------------|------------|------| +| 未选 | `#4096FF` 描边 | — | `#4096FF` 描边 | — | `#C5CFE0` | +| Hover | `#1677FF` 描边 | — | `#1677FF` 描边 | — | `#FFFFFF` | +| 已选 | `#1677FF` 描边 | `#1677FF` 6px 圆 | `#1677FF` 填充 | `#FFFFFF` ✓ | `#FFFFFF` | +| 半选(仅 Checkbox) | — | — | `#1677FF` 填充 | `#FFFFFF` 横线 | `#FFFFFF` | +| Focus | + `0 0 0 2px rgba(22,119,255,0.16)` | — | + `0 0 0 2px rgba(22,119,255,0.16)` | — | `#FFFFFF` | +| 未选禁用 | `#4A6491` 描边 | — | `#4A6491` 描边 | — | `#4A6491` | +| 已选禁用 | `#4A6491` 描边 | `#4A6491` 6px 圆 | `#4A6491` 填充 | `#C5CFE0` ✓ | `#4A6491` | + +--- + +### 16.5 与 §15.2.2 的关系 + +§15.2.2「多项选择面板内的复选项」直接复用本章 §16.3 的复选框外观规格,**唯一差异**: + +- 在多项选择下拉面板里,**未选状态边框**是 `#6E88B0` (ZS-004-04,更"灰"的占位色),而非本章 §16.3 默认的 `#4096FF`(更"蓝"的强调色) +- 原因:下拉面板里项数多,所有复选框同时蓝色描边会过于喧闹;独立表单字段里数量少(通常 2–4 项),用强调色更醒目 + +如需统一也可以全面采用本章规格 —— 二选一即可,但**一个项目内部应保持一致**。 + +--- + +### 16.6 CSS Token 参考 + +```css +:root { + /* 公共 */ + --field-size: 14px; + --field-border-w: 1px; + --field-radius: 2px; /* 仅 Checkbox 用 */ + --field-text: #C5CFE0; /* ZS-004-02 */ + --field-text-hover: #FFFFFF; + --field-text-disabled: #4A6491; /* ZS-004-05 */ + --field-focus-ring: 0 0 0 2px rgba(22,119,255,0.16); + + /* Radio */ + --radio-border: #4096FF; /* ZS-002-05 */ + --radio-border-hover: #1677FF; /* ZS-002-06 */ + --radio-border-checked: #1677FF; + --radio-dot: #1677FF; /* 中心 6×6 实心 */ + --radio-border-disabled: #4A6491; /* ZS-004-05 */ + --radio-dot-disabled: #4A6491; + --radio-bg-disabled: rgba(22,119,255,0.04); + + /* Checkbox */ + --checkbox-border: #4096FF; /* ZS-002-05 */ + --checkbox-border-hover: #1677FF; /* ZS-002-06 */ + --checkbox-fill-checked: #1677FF; /* ZS-002-06 */ + --checkbox-mark: #FFFFFF; + --checkbox-border-disabled: #4A6491; /* ZS-004-05 */ + --checkbox-fill-disabled: #4A6491; + --checkbox-mark-disabled: #C5CFE0; + --checkbox-bg-disabled: rgba(22,119,255,0.04); + + /* 组合:嵌入式 label + 输入框(§16.2.2 / §16.3.2) */ + --field-group-indent: 22px; /* 14px 控件 + 8px 间距 */ + --field-group-gap-y: var(--spacing-3); /* 12px */ +} +``` + +--- + +## 17. 日期选择器(DatePicker) + +> 对应设计稿「日期时间筛选框.png」。覆盖**单日期选择**与**日期范围选择**两种场景。面板包含**左侧快捷模式**(按天 / 按周 / 按月 / 自定义)、**起止输入区**、**双月日历主体**、**底部确认按钮**四块。配色全部从 §1.1.3 渐蓝系(ZS-002)与 §1.1.5 深蓝系(ZS-004)取,与 §14 搜索框 / §15 选择器完全对齐。 + +### 17.1 触发器(Trigger) + +触发器即「2021-12-09 📅」那一行。规格与 §14.1 输入框完全一致: + +| 属性 | 取值 | 来源 | +|------|------|------| +| 高度 | `32px` | §14.1 | +| 圆角 | `2px` | §4.1 | +| 水平 padding | `--spacing-3` (12px) | §7.1 | +| 背景 | `#0C1836` (ZS-004-09) | — | +| 边框 | `1px solid #1A2F5A` (ZS-004-07) | — | +| 默认日期文字 | `14px Regular`,`#C5CFE0` (ZS-004-02);占位时 "请选择日期" 用 `#6E88B0` (ZS-004-04) | — | +| 右侧 📅 图标 | `14px`,`#6E88B0`,hover `#4096FF` | ZS-002-05 | + +边框状态机(默认 / hover / focus / 失效 / 错误)直接复用 §14.1.1。 + +**范围选择**触发器变体:宽度自动延展,内显示 `2021-12-09 至 2022-01-09`。 + +--- + +### 17.2 下拉面板(Panel) + +面板为**左 + 右**两栏组合: + +``` +┌──────────┬───────────────────────────────────────────────────────┐ +│ 按天 › │ [ 2021-12-09 ⊗ ] 至 [ 2021-12-09 ⊗ ] │ +│ 按周 › ├───────────────────────────────────────────────────────┤ +│ 按月 › │ « < 2021 年 12 月 > » « < 2022 年 1 月 > » │ +│ 自定义 │ 日 一 二 三 四 五 六 日 一 二 三 四 五 六 │ +│ │ 29 30 1 2 3 4 5 29 30 1 2 3 4 5 │ +│ │ 6 7 8 ⓿ 10 11 12 6 7 8 ⓿ 10 11 12 │ +│ │ 13 14 ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ │ +│ │ 20 ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ ▓▓ │ +│ │ 27 ▓▓ ▓▓ ▓▓ ▓▓ 1 2 3 4 5 6 7 8 9 │ +│ │ 3 4 5 6 7 8 9 …… │ +│ ├───────────────────────────────────────────────────────┤ +│ │ [确定] [取消] │ +└──────────┴───────────────────────────────────────────────────────┘ + ⓿ = 起止日(实心蓝圆) ▓▓ = 范围内(浅蓝底矩形) +``` + +#### 17.2.1 面板容器规格 + +| 属性 | 取值 | +|------|------| +| 背景 | `#122347` (ZS-004-08) | +| 边框 | `1px solid #1A2F5A` (ZS-004-07) | +| 阴影 | `0 4px 16px rgba(6, 14, 36, 0.6)`(§3.1.4 三级阴影) | +| 圆角 | `2px` | +| 与触发器纵向间距 | `--spacing-1` (4px) | +| 整体宽度 | 由左栏宽 `80px` + 右栏(双月并列)自适应得出 | +| 左右栏分隔线 | `1px solid #1A2F5A`(ZS-004-07) | + +--- + +### 17.3 左侧快捷模式(Sidebar) + +| 属性 | 取值 | +|------|------| +| 宽度 | `80px` | +| 项高度 | `36px` | +| 水平 padding | `--spacing-3` (12px) | +| 字号 | `14px Regular` | +| 右侧 `›` 图标(除"自定义"外) | `12px`,与文字同色 | + +#### 17.3.1 项状态 + +| 状态 | 背景 | 文字 | +|------|------|------| +| 默认 | `transparent` | `#C5CFE0` (ZS-004-02) | +| Hover | `rgba(22,119,255,0.16)` | `#FFFFFF` | +| 已选 | `#1677FF` (ZS-002-06) | `#FFFFFF` | + +#### 17.3.2 四种模式语义 + +| 模式 | 行为 | +|------|------| +| 按天 | 点击日期格 → 立即选中单日,关闭面板,触发器显示该日 | +| 按周 | hover 任意日期格 → **整周高亮**,点击 → 选中该周 | +| 按月 | 切换到月视图(日期格变成 12 个月名),点击 → 选中该月 | +| 自定义 | 默认显示双月日历,支持选中起止日做范围(详见 §17.5) | + +--- + +### 17.4 起止日期输入区(Range Inputs) + +仅在**自定义模式**下显示在右栏顶部: + +``` +[ 2021-12-09 ⊗ ] 至 [ 2021-12-09 ⊗ ] +``` + +| 元素 | 色值 / 规格 | 来源 | +|------|-------------|------| +| 输入框 | 沿用 §14.1 公共规格,宽度 `132px`,**只读**(点击日历格回填,不直接输入) | §14.1 | +| 中间 "至" 文字 | `14px Regular`,`#6E88B0` (ZS-004-04),左右 padding `--spacing-3` (12px) | — | +| 右侧 `⊗` 清空按钮 | `14px`,`#6E88B0`,hover `#4096FF` | ZS-002-05 | +| 输入区 → 日历主体纵向间距 | `--spacing-3` (12px) + 分割线 `1px solid #1A2F5A` | §6.1 | +| 整行右内 padding | `--spacing-4` (16px) | §7.1 | + +未选起 / 止时对应输入框留空显示占位 "开始日期" / "结束日期"(`#6E88B0`)。 + +--- + +### 17.5 双月日历主体(Calendar Grid) + +并列展示**当月**与**下月**两张日历,便于跨月选范围。 + +#### 17.5.1 标题栏 + +``` +« < 2021 年 12 月 > » +``` + +| 元素 | 色值 / 规格 | +|------|-------------| +| 年月文字 | `14px Medium`,`#C5CFE0` (ZS-004-02) | +| 翻页箭头 `‹ ›` | `14px`,`#6E88B0`,hover `#4096FF` | +| 翻年箭头 `« »` | `14px`,`#6E88B0`,hover `#4096FF` | +| 标题栏高度 | `36px` | +| 双月之间间距 | `--spacing-6` (24px)(§7.1) | + +行为:`«` `»` 翻年;`‹` `›` 翻月。左日历翻动时右日历同步前移一月,保持"双月连续"。 + +#### 17.5.2 星期表头 + +| 属性 | 取值 | +|------|------| +| 文字 | `日 一 二 三 四 五 六` | +| 字号 | `12px Regular` | +| 颜色 | `#6E88B0` (ZS-004-04) | +| 行高 | `28px` | + +#### 17.5.3 日期格规格 + +| 属性 | 取值 | +|------|------| +| 单元尺寸 | `28px × 28px`(点击命中区) | +| 数字字号 | `14px Regular` | +| 圆角 | `50%`(已选时实心圆) | +| 行间距 | `0`(紧凑排列) | + +#### 17.5.4 日期格状态 + +| 状态 | 背景 | 文字 | +|------|------|------| +| 当月普通 | `transparent` | `#C5CFE0` (ZS-004-02) | +| 上下月填充(如 12 月日历里显示的 11/29、11/30、1/1、1/2) | `transparent` | `#4A6491` (ZS-004-05) | +| 今天(无边框样式) | `transparent` + `1px solid #1677FF` | `#C5CFE0` | +| Hover | `rgba(22,119,255,0.16)` | `#FFFFFF` | +| 已选起止日 | 实心圆 `#1677FF` (ZS-002-06) | `#FFFFFF` | +| 范围内(起止之间) | `rgba(22,119,255,0.16)` 矩形(无圆角,左右相连成连续色带) | `#FFFFFF` | +| 范围内 + 端点 | 矩形底色 + 端点实心圆叠加(视觉上"圆形扣"在色带头尾) | `#FFFFFF` | +| 禁用(超出 min/max) | `transparent` | `#4A6491`,鼠标 `not-allowed` | + +> ⚠️ **范围色带的实现要点**:范围内日期格不能各自圆角,否则中间会出现空隙。推荐方案:每行整体作为容器,范围内的日期用同色底向左右各延伸 `4px` 模拟连续;端点处再叠加 `28×28` 实心圆。 + +--- + +### 17.6 底部「确定 / 取消」按钮 + +| 元素 | 规格 | 来源 | +|------|------|------| +| 区域高度 | `48px` | +| 顶部分割线 | `1px solid #1A2F5A` (ZS-004-07) | §6.1 | +| 右内 padding | `--spacing-4` (16px) | §7.1 | +| 按钮对齐 | 右对齐 | +| 「确定」 | 沿用 §5.2.1 主按钮(实心 `#1677FF`),尺寸"小号"`28px` | §5.2.1 | +| 「取消」 | 沿用 §5.2.2 默认按钮(描边 + 透明底),尺寸"小号"`28px` | §5.2.2 | +| 两按钮间距 | `--spacing-2` (8px) | §7.1 | + +行为: +- 「确定」 → 提交当前选择,关闭面板,触发器显示选中值 +- 「取消」 → 丢弃临时选择,关闭面板,触发器值不变 + +按 `Esc` 等价于「取消」;按 `Enter` 等价于「确定」(当选择合法时)。 + +--- + +### 17.7 选用决策与变体 + +| 场景 | 选择 | +|------|------| +| 单日期 | 触发器 + 单月日历(无左侧快捷栏,无起止输入区) | +| 日期范围(常见筛选) | 完整组合:左侧快捷栏 + 双月日历 + 起止输入区 | +| 仅按周 / 按月 / 按季度 | 左侧只保留对应模式项;右侧日历切换为相应粒度 | +| 带时间(小时 / 分) | 在 §17.4 起止输入区下方追加时分选择列(24h 滚轮,未在本节展开) | +| 多日期独立选(非连续) | 不推荐用本组件 —— 改用 §15 多项选择 + 日期值列表 | + +--- + +### 17.8 CSS Token 参考 + +```css +:root { + /* 触发器 —— 复用 §14 搜索框 */ + --date-trigger-bg: var(--search-bg); /* #0C1836 */ + --date-trigger-border: var(--search-border); /* #1A2F5A */ + --date-trigger-text: var(--search-text); /* #C5CFE0 */ + --date-trigger-placeholder: var(--search-placeholder); /* #6E88B0 */ + --date-trigger-icon: #6E88B0; + --date-trigger-icon-hover: #4096FF; /* ZS-002-05 */ + + /* 面板 —— 复用 §14.4.4 联想 */ + --date-panel-bg: var(--suggest-bg); /* #122347 */ + --date-panel-border: #1A2F5A; + --date-panel-divider: #1A2F5A; /* 左右栏 + 顶/底分割线 */ + + /* 左侧快捷栏 */ + --date-sidebar-w: 80px; + --date-sidebar-item-h: 36px; + --date-sidebar-text: #C5CFE0; /* ZS-004-02 */ + --date-sidebar-text-hover: #FFFFFF; + --date-sidebar-bg-hover: rgba(22,119,255,0.16); + --date-sidebar-bg-active: #1677FF; /* ZS-002-06 */ + --date-sidebar-text-active: #FFFFFF; + + /* 起止输入区 */ + --date-range-sep: #6E88B0; /* "至" 文字 */ + --date-range-clear: #6E88B0; + --date-range-clear-hover: #4096FF; + + /* 日历单元 */ + --date-cell-size: 28px; + --date-cell-text: #C5CFE0; /* 当月 */ + --date-cell-text-outside: #4A6491; /* 上下月填充, ZS-004-05 */ + --date-cell-text-disabled: #4A6491; + --date-cell-bg-hover: rgba(22,119,255,0.16); + --date-cell-text-hover: #FFFFFF; + --date-cell-bg-selected: #1677FF; /* 起止实心圆 */ + --date-cell-text-selected: #FFFFFF; + --date-cell-bg-in-range: rgba(22,119,255,0.16); /* 范围色带 */ + --date-cell-border-today: #1677FF; /* 今天 1px 边框 */ + + /* 标题栏 */ + --date-title-text: #C5CFE0; + --date-title-arrow: #6E88B0; + --date-title-arrow-hover: #4096FF; + --date-weekday-text: #6E88B0; /* 日一二…六 */ + + /* 底部按钮区 */ + --date-footer-h: 48px; + --date-footer-divider: #1A2F5A; +} +``` diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 1f7554d8..155c3f64 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -20,13 +20,13 @@ import { NotFoundPage } from "./pages/NotFoundPage"; function AppProviders({ children }: { children: React.ReactNode }) { const locale = useMemo(() => detectLocale(), []); const location = useLocation(); - const forcedTheme = location.pathname === "/landing" ? "dark" : undefined; + const forcedTheme = location.pathname === "/landing" ? "light" : undefined; return ( requestFormSubmit(), 0); }} > - {q.title} + {desensitize(q.title)} ))} diff --git a/frontend-web/src/contexts/ThemeContext.tsx b/frontend-web/src/contexts/ThemeContext.tsx index 55cda911..e2542bde 100644 --- a/frontend-web/src/contexts/ThemeContext.tsx +++ b/frontend-web/src/contexts/ThemeContext.tsx @@ -86,10 +86,10 @@ const STORAGE_KEY = 'strategy-theme'; function readPersistedTheme(): ThemeType { if (typeof window === 'undefined') { - return 'dark'; + return 'light'; } const saved = localStorage.getItem(STORAGE_KEY); - return saved === 'light' ? 'light' : 'dark'; + return saved === 'dark' ? 'dark' : 'light'; } // 主题上下文 @@ -125,7 +125,7 @@ export const ThemeProvider: React.FC = ({ children }) => { if (current !== 'light' && current !== 'dark') { const fallback = - legacy === 'light' || legacy === 'dark' ? legacy : 'dark'; + legacy === 'light' || legacy === 'dark' ? legacy : 'light'; setNextTheme(fallback); } else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) { setNextTheme(legacy); diff --git a/frontend-web/src/lib/desensitize.ts b/frontend-web/src/lib/desensitize.ts index 963da195..3778cc98 100644 --- a/frontend-web/src/lib/desensitize.ts +++ b/frontend-web/src/lib/desensitize.ts @@ -5,6 +5,17 @@ */ export const DESENSITIZE_MAP: Record = { 态势: "TS", + 滨海作战体系: "第一张网", + 滨海作战部队: "第一张网", + 'Taiwan Air Force Flight Units': "第二张网", + 台海空军飞行部队: "第二张网", + 空军: "KJ", + 海军: "HJ", + 陆军: "LJ", + 陆战队: "LZD", + 海巡: "HX", + 憲兵: "XB", + 資通電軍: "ZTDJ", 情报: "QB", 指挥: "ZH", 信息准备: "XX准备", @@ -30,6 +41,7 @@ export const DESENSITIZE_MAP: Record = { 军: "J", 美军: "MJ", 台军: "TJ", + }; /** diff --git a/frontend-web/src/open-canvas/components/ai-writing/OutlineSection.tsx b/frontend-web/src/open-canvas/components/ai-writing/OutlineSection.tsx index b4dfeeec..b4d2af8f 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/OutlineSection.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/OutlineSection.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { ChevronRight, Plus, X, GripVertical } from 'lucide-react'; import { cn } from '@/lib/utils'; -import type { OutlineSection as OutlineSectionType } from '../../types/ai-writing'; +import type { KeyPoint, OutlineSection as OutlineSectionType } from '../../types/ai-writing'; +import { normalizeKeyPoint } from '../../types/ai-writing'; import { CollapseTransition } from './CollapseTransition'; interface OutlineSectionProps { @@ -10,7 +11,7 @@ interface OutlineSectionProps { onChange: (updated: OutlineSectionType) => void; onRemove?: () => void; readOnly?: boolean; - // Drag-and-drop + // Drag-and-drop (章节级别) isDragging?: boolean; isOver?: boolean; onDragStart?: () => void; @@ -19,6 +20,14 @@ interface OutlineSectionProps { onDragEnd?: () => void; } +/** + * 章节卡片(Level 1)。内部渲染 keyPoints[] 为「小节子卡片」(Level 2),小节里再渲染 + * subPoints[] 为 bullet 列表(Level 3)。 + * + * 兼容旧 2 级大纲:section.keyPoints 可能是 (string | KeyPoint)[];进入组件前通过 + * normalizeKeyPoint 统一成 KeyPoint[],对外输出也一律是 KeyPoint[](自然升级,用户 + * 编辑过的旧 session 大纲会被持久化为新格式)。 + */ export function OutlineSection({ section, index, onChange, onRemove, readOnly, isDragging, isOver, onDragStart, onDragEnter, onDrop, onDragEnd, @@ -27,6 +36,13 @@ export function OutlineSection({ const [editingTitle, setEditingTitle] = useState(false); const [titleDraft, setTitleDraft] = useState(section.sectionTitle); + // 把 keyPoints 统一成 KeyPoint[];旧 string 元素会被 lift。useMemo 避免每次 render + // 都生成新引用导致子组件无谓重渲染。 + const keyPoints: KeyPoint[] = useMemo( + () => (section.keyPoints ?? []).map(normalizeKeyPoint), + [section.keyPoints], + ); + function commitTitle() { setEditingTitle(false); const trimmed = titleDraft.trim(); @@ -37,20 +53,23 @@ export function OutlineSection({ } } - function updateKeyPoint(i: number, value: string) { - const next = [...section.keyPoints]; - next[i] = value; + function updateKeyPoint(i: number, updated: KeyPoint) { + const next = [...keyPoints]; + next[i] = updated; onChange({ ...section, keyPoints: next }); } function removeKeyPoint(i: number) { - onChange({ ...section, keyPoints: section.keyPoints.filter((_, idx) => idx !== i) }); + onChange({ ...section, keyPoints: keyPoints.filter((_, idx) => idx !== i) }); } function addKeyPoint() { - onChange({ ...section, keyPoints: [...section.keyPoints, ''] }); + onChange({ ...section, keyPoints: [...keyPoints, { pointTitle: '', subPoints: [] }] }); } + // 子要点总数,显示在章节头给个粗略概览 + const totalSubPoints = keyPoints.reduce((sum, kp) => sum + (kp.subPoints?.length || 0), 0); + return (
)} - {section.keyPoints.length} 要点 + + {keyPoints.length} 小节{totalSubPoints > 0 ? ` · ${totalSubPoints} 子要点` : ''} + {!readOnly && onRemove && (
- {/* 要点列表 */} + {/* 小节列表 (Level 2) */} -
- {section.keyPoints.map((kp, i) => ( -
- • +
+ {keyPoints.map((kp, i) => ( + updateKeyPoint(i, updated)} + onRemove={readOnly ? undefined : () => removeKeyPoint(i)} + readOnly={readOnly} + /> + ))} + {!readOnly && ( + + )} +
+ +
+ ); +} + +// ─── 小节行(Level 2,内嵌 Level 3 子要点) ─────────────────────────────────────── +interface KeyPointRowProps { + keyPoint: KeyPoint; + onChange: (updated: KeyPoint) => void; + onRemove?: () => void; + readOnly?: boolean; +} + +function KeyPointRow({ keyPoint, onChange, onRemove, readOnly }: KeyPointRowProps) { + const [expanded, setExpanded] = useState(false); + const [editingTitle, setEditingTitle] = useState(false); + const [titleDraft, setTitleDraft] = useState(keyPoint.pointTitle); + + function commitTitle() { + setEditingTitle(false); + const trimmed = titleDraft.trim(); + if (trimmed !== keyPoint.pointTitle) { + onChange({ ...keyPoint, pointTitle: trimmed }); + } + } + + function updateSubPoint(i: number, value: string) { + const next = [...keyPoint.subPoints]; + next[i] = value; + onChange({ ...keyPoint, subPoints: next }); + } + + function removeSubPoint(i: number) { + onChange({ ...keyPoint, subPoints: keyPoint.subPoints.filter((_, idx) => idx !== i) }); + } + + function addSubPoint() { + onChange({ ...keyPoint, subPoints: [...keyPoint.subPoints, ''] }); + setExpanded(true); // 添加时自动展开,免得用户看不到刚加的输入框 + } + + return ( +
+ {/* 小节头 */} +
+ + + ### + + {editingTitle && !readOnly ? ( + setTitleDraft(e.target.value)} + onBlur={commitTitle} + onKeyDown={(e) => { + if (e.key === 'Enter') commitTitle(); + if (e.key === 'Escape') { setTitleDraft(keyPoint.pointTitle); setEditingTitle(false); } + }} + className="flex-1 bg-transparent border-b border-primary outline-none" + placeholder="小节标题" + /> + ) : ( + !readOnly && setEditingTitle(true)} + > + {keyPoint.pointTitle || (未命名小节)} + + )} + + + {keyPoint.subPoints.length > 0 ? `${keyPoint.subPoints.length} 子要点` : '无子要点'} + + {!readOnly && onRemove && ( + + )} +
+ + {/* 子要点 (Level 3) — 每个子要点会变成正文里的 #### 标题 + 一段独立论述 */} + +
+ {keyPoint.subPoints.map((sp, i) => ( +
+ {/* 用 #### 标记替代 bullet,提示这是「四级标题」而非简单列表项 */} + #### {readOnly ? ( - {kp} + {sp} ) : ( updateKeyPoint(i, e.target.value)} - placeholder="输入要点…" + value={sp} + onChange={(e) => updateSubPoint(i, e.target.value)} + placeholder="子要点标题(会展开成一段正文)" className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground" /> )} {!readOnly && ( - )} @@ -138,11 +286,11 @@ export function OutlineSection({ {!readOnly && ( )}
diff --git a/frontend-web/src/open-canvas/types/ai-writing.ts b/frontend-web/src/open-canvas/types/ai-writing.ts index 76fac065..e73e703c 100644 --- a/frontend-web/src/open-canvas/types/ai-writing.ts +++ b/frontend-web/src/open-canvas/types/ai-writing.ts @@ -13,10 +13,25 @@ export interface AIMaterial { } // ── 大纲 ───────────────────────────────────────────────────────────────────── +// 三级结构:章节(##) → 小节(###) → 子要点(bullet) +// AIOutline.sections[] = Level 1 章节(渲染为 ## 由后端注入) +// OutlineSection.keyPoints[] = Level 2 小节(渲染为 ###) +// KeyPoint.subPoints[] = Level 3 子要点(正文里的 bullet 列表) +// +// 历史兼容:旧版 keyPoints: string[](2 级)。后端 _parse_outline 已经在解析时把 +// 旧 string 自动 lift 成 KeyPoint;前端类型只保留新格式,但 UI 渲染层做 type +// guard 兼容,防止前端流式接收到旧大纲时崩溃。详见 normalizeKeyPoint() helper。 + +export interface KeyPoint { + pointTitle: string; // 小节标题(渲染为 ### 三级标题) + subPoints: string[]; // 子要点(可空;非空时在小节正文起始处以 bullet 列表呈现) +} export interface OutlineSection { sectionTitle: string; - keyPoints: string[]; + // 历史 session 可能是 string[](旧 2 级结构),用 normalizeKeyPoint 转换。 + // 新生成的大纲一律 KeyPoint[]。 + keyPoints: Array; materialIds: string[]; } @@ -25,6 +40,32 @@ export interface AIOutline { sections: OutlineSection[]; } +/** + * 把 keyPoints 单项规整成 KeyPoint。兼容三种输入: + * - 新版 KeyPoint object + * - 旧版 string (lift 为 { pointTitle: , subPoints: [] }) + * - 任何意外形态 → 空 KeyPoint(防止崩溃,不抛异常) + */ +export function normalizeKeyPoint(raw: unknown): KeyPoint { + if (typeof raw === 'string') { + return { pointTitle: raw, subPoints: [] }; + } + if (raw && typeof raw === 'object') { + const r = raw as Record; + const title = + typeof r.pointTitle === 'string' ? r.pointTitle : + typeof r.point_title === 'string' ? r.point_title : + typeof r.title === 'string' ? r.title : + ''; + const subsRaw = (r.subPoints ?? r.sub_points ?? []) as unknown; + const subPoints: string[] = Array.isArray(subsRaw) + ? subsRaw.map((s) => (typeof s === 'string' ? s : String(s ?? ''))).filter(Boolean) + : []; + return { pointTitle: title, subPoints }; + } + return { pointTitle: '', subPoints: [] }; +} + // ── 素材不足求助 ────────────────────────────────────────────────────────────── /** 严格模式下素材不足、待用户决策的章节 */ diff --git a/frontend-web/src/roundtable-planning/api/intent.ts b/frontend-web/src/roundtable-planning/api/intent.ts index 206c9335..d45eae3f 100644 --- a/frontend-web/src/roundtable-planning/api/intent.ts +++ b/frontend-web/src/roundtable-planning/api/intent.ts @@ -155,7 +155,11 @@ export interface StreamIntentRequest { * "正在输出" so users see what's happening during the inevitable LLM * first-token wait. */ - onPhaseChange?: (phase: IntentStreamPhase, toolName?: string) => void; + onPhaseChange?: ( + phase: IntentStreamPhase, + toolName?: string, + args?: Record, + ) => void; } export interface StreamIntentResult { @@ -269,10 +273,14 @@ export async function streamIntent(req: StreamIntentRequest): Promise { + const setPhase = ( + next: IntentStreamPhase, + toolName?: string, + args?: Record, + ) => { if (next === currentPhase && !toolName) return; currentPhase = next; - req.onPhaseChange?.(next, toolName); + req.onPhaseChange?.(next, toolName, args); }; const result: StreamIntentResult = { @@ -364,7 +372,14 @@ export async function streamIntent(req: StreamIntentRequest): Promise) : null; const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : ""; - setPhase("tool_calling", toolName); + // 把 tool args 一并透传给 UI;onPhaseChange 在 useStep1Intent 里 + // 会把 args 累积进 message.steps,让 MessageStepsCard 渲染路径 chip / + // 命令代码块等子节点(与主聊天 MessageGroup 视觉一致)。 + const toolArgs = + firstCall && typeof firstCall.args === "object" && firstCall.args !== null + ? (firstCall.args as Record) + : undefined; + setPhase("tool_calling", toolName, toolArgs); } else if ( (chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") && ((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0)) diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts index 3d61d6d7..7a46211e 100644 --- a/frontend-web/src/roundtable-planning/api/multi-agent.ts +++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts @@ -193,6 +193,13 @@ export interface StreamMultiAgentRequest { agentType: StreamAgentType; dAgentThreadId: ThreadIdMap; agentName: string; + /** + * 人类可读名称(中文/英文)。仅 special 走广播时使用 —— 后端把它拼进 + * 「子智能体X完成Y工作,交付内容为:...」里,这样总控 LLM 在下一轮看到自己 + * 历史里的交付摘要时能用"语义化的名字"识别,而不是 UUID 字符串(LLM 对 + * UUID 不友好,会导致重复派活)。后端缺失时 fallback 到 agentName。 + */ + displayName?: string; newMessage: string; model?: string; skillStopNames?: string[]; @@ -209,8 +216,14 @@ export interface StreamMultiAgentRequest { * Fires whenever the inferred SSE phase changes. The UI can use this to * show progressive status messages during the inevitable first-token wait. * `name` is the tool name when phase is `tool_calling` (or empty otherwise). + * `args` 是 tool_calling 阶段的工具入参(取自上游 LangGraph messages-tuple + * 帧的 tool_calls[0].args),供 UI 渲染路径 chip / 代码块等子节点。 */ - onPhaseChange?: (phase: StreamPhase, name?: string) => void; + onPhaseChange?: ( + phase: StreamPhase, + name?: string, + args?: Record, + ) => void; } export interface ClarificationPayload { @@ -327,6 +340,8 @@ export async function streamMultiAgent( agent_type: req.agentType, d_agent_thread_id: req.dAgentThreadId, agent_name: req.agentName, + // 仅 special 用到;后端缺失时 fallback 到 agent_name,旧前端也不破坏。 + display_name: req.displayName, new_message: req.newMessage, model: req.model, skill_stop_names: req.skillStopNames ?? [], @@ -366,10 +381,17 @@ export async function streamMultiAgent( // value actually changes (the reader loop sees many frames per phase). let currentPhase: StreamPhase = "connecting"; req.onPhaseChange?.("connecting"); - const setPhase = (next: StreamPhase, name?: string) => { + const setPhase = ( + next: StreamPhase, + name?: string, + args?: Record, + ) => { + // 同一个 phase + 同一个 toolName 才认为是"重复",此时不再回调上层; + // 但 args 单独是新的(如同一工具被多次 call 携带不同入参)时也不回调 —— 上层 + // appendStepIfNew 用 `phase:toolName` 做 key 去重,本来也不会因 args 变化产生新步骤。 if (next === currentPhase && !name) return; currentPhase = next; - req.onPhaseChange?.(next, name); + req.onPhaseChange?.(next, name, args); }; while (true) { @@ -426,13 +448,22 @@ export async function streamMultiAgent( setPhase("tool_result"); } else if (hasToolCall) { // Pick the most informative name we can find in either field. + // 优先用 tool_calls(完整对象,含解析好的 args),tool_call_chunks 的 args + // 通常是局部 JSON 字符串(LangGraph 流式期间逐字符发),没法直接用。 const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0 ? (toolCalls[0] as Record) : Array.isArray(toolCallChunks) && toolCallChunks.length > 0 ? (toolCallChunks[0] as Record) : null; const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : ""; - setPhase("tool_calling", toolName); + // 仅当来自 tool_calls(完整对象)时透传 args;tool_call_chunks 的 args 是 + // string 拼接中,不传上去给 UI,等下一个完整 chunk 再带。 + const fromCompleteToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0; + const args = + fromCompleteToolCalls && firstCall && typeof firstCall.args === "object" && firstCall.args !== null + ? (firstCall.args as Record) + : undefined; + setPhase("tool_calling", toolName, args); } else if ( (chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") && ((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0)) diff --git a/frontend-web/src/roundtable-planning/components/Avatars.tsx b/frontend-web/src/roundtable-planning/components/Avatars.tsx new file mode 100644 index 00000000..9bf46a58 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/Avatars.tsx @@ -0,0 +1,93 @@ +/** + * 圆桌规划用到的所有 SVG 头像。 + * + * 这些原本散落在主页面里,提到这里集中维护,便于 Step 1 / Step 2 / Step 3 + * 与 LeftSidebar、HighFidelityReport 复用。`getAvatar(type, size)` 根据 + * 字符串类型名(blue / purple / emerald / amber / pink / human / man)挑对应组件。 + */ + +export const Man1Avatar = ({ size = "h-8 w-8" }: { size?: string }) => ( + + + + + + + + + + + + + + + + + + +); + +export const RobotAvatar = ({ + bg = "bg-primary/10", + headColor = "#f5222d", + size = "h-8 w-8", +}: { + bg?: string; + headColor?: string; + size?: string; +}) => ( + + + + + + + + + + + + + +); + +export const BlueRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => ( + +); +export const PurpleRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => ( + +); +export const EmeraldRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => ( + +); +export const AmberRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => ( + +); +export const PinkRobotAvatar = ({ size = "h-8 w-8" }: { size?: string }) => ( + +); + +/** 根据字符串类型名挑对应头像。 */ +export function getAvatar(type: string, size: string = "h-8 w-8") { + switch (type) { + case "man": + case "human": + return ; + case "blue": + return ; + case "purple": + return ; + case "emerald": + return ; + case "amber": + return ; + case "pink": + return ; + default: + return ; + } +} diff --git a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx index 6c658f21..a7ac2f31 100644 --- a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx +++ b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx @@ -368,8 +368,8 @@ export const HighFidelityReport: React.FC = ({ )} - {/* Section 3: Generated files per seat */} -
+ {/* Section 3: Generated files per seat —— 暂时隐藏(无真实产出文件链路) */} +

生成的报告文件 diff --git a/frontend-web/src/roundtable-planning/components/MessageBubble.tsx b/frontend-web/src/roundtable-planning/components/MessageBubble.tsx new file mode 100644 index 00000000..92476072 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/MessageBubble.tsx @@ -0,0 +1,269 @@ +/** + * Step 1 / Step 2 消息列表共享的原子件。 + * + * 两步的消息渲染共用了:头像 + 发送人/徽标/时间的 header 行、流式空内容时的 + * spinner+跳点占位、流式中尾部闪烁竖线、以及 Markdown 紧凑间距渲染配置。 + * 把它们提到这里: + * - `MessageBubble` 单条消息壳(avatar + header + children slot) + * - `MessageLoadingPlaceholder` 流式但还没首字节时的 spinner + 跳点 + label + * - `StreamingCursor` 流式中末尾的小竖线动画 + * - `sharedMarkdownComponents` react-markdown 的紧凑渲染配置 + * + * 各步特有的内容(Step 1 INTENT_READY 摘要卡 / 澄清卡片;Step 2 ChainOfThought + * 折叠卡 + 「插话干预」红底容器)仍由对应 Panel 自己组合 —— 它们差异较大, + * 强行抽象成 props 反而难读。 + */ +import { useEffect, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { BrainIcon, ChevronDownIcon, Loader2 } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +export interface MessageBubbleProps { + /** 左侧头像。caller 决定是 `` / `` / `getAvatar(type)`。 */ + avatar: React.ReactNode; + /** 发送人显示名。 */ + senderName: string; + /** 发送人名字额外 className(例如 user 消息用 `text-primary`)。 */ + senderNameClassName?: string; + /** Header 右上小徽标:Step 1 的 phase 标签、Step 2 的 confidence 标签。 */ + badge?: string; + /** 时间字符串(`nowTime()` 输出)。 */ + time: string; + /** 外层容器额外 className —— Step 2「插话干预」需要红底, caller 传入。 */ + containerClassName?: string; + /** 消息正文区:由 caller 自由组合(MD / 摘要卡 / Steps 卡 / 澄清卡 / cursor 等)。 */ + children: React.ReactNode; +} + +export function MessageBubble({ + avatar, + senderName, + senderNameClassName, + badge, + time, + containerClassName, + children, +}: MessageBubbleProps) { + return ( +
+ {avatar} +
+
+
+ + {senderName} + + {badge && ( + + {badge} + + )} +
+ {time} +
+ {children} +
+
+ ); +} + +/** + * 流式中、但还没拿到任何 token 时的占位。spinner + label + 三个跳动点, + * 让用户感受到后端在工作而不是页面卡住。 + */ +export function MessageLoadingPlaceholder({ label }: { label?: string }) { + return ( +
+ + {label ?? "正在连接智能体..."} + + + + + +
+ ); +} + +/** 流式过程中末尾追的小竖线动画。 */ +export function StreamingCursor() { + return ; +} + +/** + * react-markdown 的紧凑间距配置 —— Step 1 / Step 2 / 主聊天默认气泡都用同一份。 + * 默认 block 边距(`margin: 1em`)在小行高 chat 气泡里太松,这里全压到 my-1。 + * 如果某步需要更多元素(table / blockquote / hr 等),不用单独 override,直接复用本表即可: + * react-markdown 只会按需启用对应组件,多写无害。 + */ +export const sharedMarkdownComponents = { + p: ({ children }: { children?: React.ReactNode }) => ( +

{children}

+ ), + ul: ({ children }: { children?: React.ReactNode }) => ( +
    {children}
+ ), + ol: ({ children }: { children?: React.ReactNode }) => ( +
    {children}
+ ), + li: ({ children }: { children?: React.ReactNode }) => ( +
  • {children}
  • + ), + h1: ({ children }: { children?: React.ReactNode }) => ( +

    {children}

    + ), + h2: ({ children }: { children?: React.ReactNode }) => ( +

    {children}

    + ), + h3: ({ children }: { children?: React.ReactNode }) => ( +

    {children}

    + ), + h4: ({ children }: { children?: React.ReactNode }) => ( +

    {children}

    + ), + strong: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + em: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + code: ({ + children, + className, + }: { + children?: React.ReactNode; + className?: string; + }) => { + const isBlock = /language-/.test(className ?? ""); + if (isBlock) { + return ( +
    +          {children}
    +        
    + ); + } + return ( + + {children} + + ); + }, + blockquote: ({ children }: { children?: React.ReactNode }) => ( +
    + {children} +
    + ), + a: ({ children, href }: { children?: React.ReactNode; href?: string }) => ( + + {children} + + ), + table: ({ children }: { children?: React.ReactNode }) => ( + {children}
    + ), + th: ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ), + td: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + hr: () =>
    , +}; + +/** + * 便捷封装:用 sharedMarkdownComponents + remarkGfm 渲染 markdown。 + * 大多数 caller 都不需要再覆盖任何 components,直接用这个就好。 + */ +export function MarkdownContent({ children }: { children: string }) { + return ( + + {children} + + ); +} + +/** + * 「思考过程」折叠卡。展示从 `...` 拆出的 reasoning 文本。 + * + * 视觉与交互对齐主聊天 `src/components/ai-elements/reasoning.tsx`: + * - 头部脑图标 + "正在思考..." / "思考完成 (Ns)" 文案 + * - 流式中默认展开,完成后自动延迟收起;用户点击切换后**不再自动收起**(尊重偏好) + * - 内容区灰底框,用 sharedMarkdownComponents 渲染 markdown + */ +export interface ReasoningBlockProps { + /** Reasoning 文本(已由 `splitInlineReasoning` 提取出来,不含 `` 标签)。 */ + children: string; + /** 是否仍在流式生成。决定头部 shimmer 与默认展开行为。 */ + streaming?: boolean; + /** 用于显示「思考用时」的可选时长(秒)。流式期间不显示。 */ + durationSec?: number; + className?: string; +} + +const REASONING_AUTO_CLOSE_DELAY_MS = 1000; + +export function ReasoningBlock({ + children, + streaming = false, + durationSec, + className, +}: ReasoningBlockProps) { + // open 初始值跟 streaming 同步:流式中默认展开方便观察思考过程,完成后会自动收起一次。 + const [open, setOpen] = useState(streaming); + const userTouchedRef = useRef(false); + const hasAutoClosedRef = useRef(false); + + // 流式开始时若用户没动过开关 → 自动展开;流式结束 → 延迟自动收起(仅一次)。 + useEffect(() => { + if (userTouchedRef.current) return; + if (streaming) { + setOpen(true); + hasAutoClosedRef.current = false; + return; + } + if (!hasAutoClosedRef.current && open) { + const timer = setTimeout(() => { + setOpen(false); + hasAutoClosedRef.current = true; + }, REASONING_AUTO_CLOSE_DELAY_MS); + return () => clearTimeout(timer); + } + return undefined; + }, [streaming, open]); + + const handleToggle = () => { + userTouchedRef.current = true; + setOpen((v) => !v); + }; + + const headerLabel = streaming + ? "正在思考..." + : durationSec !== undefined + ? `思考用时 ${durationSec} 秒` + : "思考过程"; + + return ( +
    + + {open && ( +
    + {children} +
    + )} +
    + ); +} diff --git a/frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx b/frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx new file mode 100644 index 00000000..e99f8330 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx @@ -0,0 +1,176 @@ +/** + * 模型生成过程的「步骤卡」 —— Step 1 / Step 2 共享。 + * + * 视觉与交互对齐主聊天 `chats/new` 的 `MessageGroup`(`src/components/workspace/messages/message-group.tsx`): + * 1) 顶部 "查看其他 N 个步骤" 折叠按钮 —— 隐藏除最后一个工具调用之外的所有步骤 + * 2) 最后一个工具调用步骤 **始终展开** 显示(图标 + 文案 + args 子节点) + * 3) 若末尾是 streaming/connecting 等非工具阶段,作为「正在思考」式标签放在卡底部 + * + * 数据来源:`Step2Dialogue.steps[]` / `InteractiveMessage.steps[]` —— 由对应 hook + * 在 `onPhaseChange` 回调里累积。每条 `step = { id, key:"phase:toolName", label, ts, args? }`。 + * + * 历史背景:之前的 `StepsCard` 用 "全收/全展" 的折叠模式 —— 流式时收起、点击全展开, + * 没法跟随主聊天的「凸显最后一个工具」体验。本组件改为「always-show-last + hide-prior」, + * 与主聊天观感一致。 + */ +import { useState } from "react"; +import { ChevronUp } from "lucide-react"; + +import { + ChainOfThought, + ChainOfThoughtContent, + ChainOfThoughtStep, +} from "@/components/ai-elements/chain-of-thought"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { + getStepDisplay, + renderStepArgs, +} from "@/roundtable-planning/lib/step-display"; + +/** 单条步骤 —— 与 Step2Dialogue.steps / InteractiveMessage.steps 同形。 */ +export interface BubbleStep { + id: string; + /** 形如 `tool_calling:web_search` / `streaming:` / `connecting:`。 */ + key: string; + /** 原始 fallback 文案(getStepDisplay 在没有工具名时回退到它)。 */ + label: string; + ts: string; + args?: Record; +} + +export interface MessageStepsCardProps { + steps: BubbleStep[]; + /** 是否流式中 —— 决定最后一步显示 `active` 还是 `complete`。 */ + streaming?: boolean; + className?: string; +} + +/** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */ +function isToolStep(step: BubbleStep): boolean { + return step.key.startsWith("tool_calling:") && step.key.length > "tool_calling:".length; +} + +export function MessageStepsCard({ steps, streaming, className }: MessageStepsCardProps) { + // 把步骤拆成「工具调用」与「思考过程」两类 —— 与主聊天 MessageGroup 的 `lastToolCallStep` / + // `lastReasoningStep` 划分对齐。 + const toolSteps = steps.filter(isToolStep); + const lastToolStep = toolSteps[toolSteps.length - 1]; + const priorToolSteps = + lastToolStep && toolSteps.length > 1 ? toolSteps.slice(0, -1) : []; + + // 末尾若是非工具步骤(streaming/connecting/tool_result),作为「思考中」尾标签。 + // 找到最后一个工具步骤之后的所有步骤;若没有工具步骤,则全部都算思考。 + const lastToolIdx = lastToolStep ? steps.lastIndexOf(lastToolStep) : -1; + const trailingReasoning = steps.slice(lastToolIdx + 1).filter((s) => !isToolStep(s)); + const lastReasoning = trailingReasoning[trailingReasoning.length - 1]; + + const [showAbove, setShowAbove] = useState(false); + + // 没有任何步骤 —— 不渲染卡(由 caller 决定是否还要其他占位)。 + if (steps.length === 0) return null; + + // 只有一种「思考」尾标签、没有任何工具调用 —— 直接渲染一行小灯泡,不用大卡。 + // 这样避免「调度中 / 已连接」这类瞬时阶段被搞成完整的折叠卡。 + if (!lastToolStep && lastReasoning) { + const display = getStepDisplay(lastReasoning.key, lastReasoning.label, lastReasoning.args); + const Icon = display.icon; + return ( +
    + + {display.label} + {lastReasoning.ts} +
    + ); + } + + return ( + + {/* 顶部:折叠/展开「其他 N 个步骤」。仅当真有 prior 步骤时显示。 */} + {priorToolSteps.length > 0 && ( + + )} + + {/* 中段:工具调用步骤。Last 工具始终展开;其它工具按 showAbove 控制。 */} + {lastToolStep && ( + + {showAbove && + priorToolSteps.map((s) => ( + + ))} + + + )} + + {/* 底部:streaming 阶段的「正在思考」标签。 */} + {lastReasoning && ( +
    + +
    + )} +
    + ); +} + +function ToolStep({ + step, + status, +}: { + step: BubbleStep; + status: "complete" | "active"; +}) { + const display = getStepDisplay(step.key, step.label, step.args); + const argNode = renderStepArgs(step.key, step.args); + return ( + + {display.label} + {step.ts} +
    + } + > + {argNode} + + ); +} + +function ReasoningStep({ step, streaming }: { step: BubbleStep; streaming: boolean }) { + const display = getStepDisplay(step.key, step.label, step.args); + const Icon = display.icon; + return ( +
    + + {display.label} + {step.ts} +
    + ); +} diff --git a/frontend-web/src/roundtable-planning/components/PersonaDrawer.tsx b/frontend-web/src/roundtable-planning/components/PersonaDrawer.tsx new file mode 100644 index 00000000..eb4780b4 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/PersonaDrawer.tsx @@ -0,0 +1,190 @@ +/** + * Persona 抽屉。 + * + * Step 2 圆桌可视化里点击智能体头像后展开。允许覆写该席位的: + * - System Prompt(本地记录,后端覆写未开放) + * - 推理模型(下一轮派活时由 `runOrchestration` 读 `agentConfigs[id].model`) + * - Temperature(本地记录) + * + * 「参数覆盖并重启动算研讨」按钮 = `onSave`,不会立即重启编排,只更新本地配置; + * 真正生效在该席位下一次被 leader 派活时。 + */ +import { X, Sparkles } from "lucide-react"; + +import { getAvatar } from "@/roundtable-planning/components/Avatars"; +import type { + AgentConfig, +} from "@/roundtable-planning/hooks/useStep2Orchestration"; +import type { + RoleEntry, +} from "@/roundtable-planning/lib/roundtable-constants"; + +interface ModelOption { + name: string; + display_name?: string; +} + +export interface PersonaDrawerProps { + seatId: number; + roles: RoleEntry[]; + agentConfigs: Record; + setAgentConfigs: React.Dispatch< + React.SetStateAction> + >; + selectedModel: string; + availableModels: ModelOption[]; + modelsLoading: boolean; + onClose: () => void; + onSave: (id: number) => void; +} + +/** 席位 id → 头像配色(与圆桌可视化里的硬编码保持一致)。 */ +function avatarTypeForSeat(seatId: number): string { + switch (seatId) { + case 2: + return "blue"; + case 3: + return "purple"; + case 4: + return "emerald"; + case 5: + return "human"; + case 6: + return "amber"; + case 7: + return "pink"; + default: + return "blue"; + } +} + +export function PersonaDrawer({ + seatId, + roles, + agentConfigs, + setAgentConfigs, + selectedModel, + availableModels, + modelsLoading, + onClose, + onSave, +}: PersonaDrawerProps) { + const cfg = agentConfigs[seatId]; + const roleName = roles.find((r) => r.id === seatId)?.name ?? "专家智能体"; + + return ( +
    + + +
    +
    +
    + {getAvatar(avatarTypeForSeat(seatId), "h-10 w-10")} +
    +
    +

    + {roleName} 人设专有策略调制 +

    + + 系统就绪 + +
    +

    + 配置此席位智能体的运行大语言模型及系统 System Prompt 指令 +

    +
    +
    + +
    + +