diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..e02ee52c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "PowerShell(uv --version)", + "PowerShell(netstat -ano)", + "PowerShell(Get-NetTCPConnection -LocalPort 8001 -ErrorAction SilentlyContinue)", + "PowerShell(Get-Process -Id 20560 -ErrorAction SilentlyContinue | Select-Object Id,ProcessName,Path,StartTime; try { \\(Invoke-WebRequest -Uri \"http://127.0.0.1:8001/health\" -UseBasicParsing -TimeoutSec 5\\).Content } catch { \"health check failed: $_\" })" + ] + } +} 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/roundtable-step2-sandbox-progress.md b/frontend-web/docs/roundtable-step2-sandbox-progress.md new file mode 100644 index 00000000..d3fd7fed --- /dev/null +++ b/frontend-web/docs/roundtable-step2-sandbox-progress.md @@ -0,0 +1,207 @@ +# 圆桌 Step 2 沙箱预览 · 开发进度 + +> 最后更新:2026-05-27 +> 关联文档:`multi-agent-frontend-dev.md`(总览)、`multi-agent-backend-dev.md`(后端)、`multi-agent-api.md`(接口) +> 对照参考:主聊天 `/page/workspace/chats/:threadId`(`ChatBox` + `ArtifactsProvider` + `message-group.tsx`) + +--- + +## 1. 目标 + +在 Step 2「多智能体圆桌研讨」中,当子智能体调用 `write_file` / `str_replace` / `present_files` 时,**行为与主聊天一致**: + +| 能力 | 主聊天 | Step 2 目标 | +|------|--------|-------------| +| `write_file` 流式期间自动打开右侧沙箱 | ✅ | ✅ 虚拟 URL | +| 步骤卡里点击文件路径打开沙箱 | ✅ | ✅ | +| 消息下方文件卡片(`present_files`)点击打开 | ✅ | ✅ `PresentFilesRow` | +| 沙箱打开时收起 workspace 左侧栏 | ✅(`ArtifactsProvider.select`) | ✅ Step 2 左栏联动 | +| 60/40 对话 \| 产物预览分栏 | ✅ `ChatBox` | ✅ `Step2SandboxLayout` | +| 流式 MD 从 tool_call.args.content 预览 | ✅ 虚拟 URL + stub messages | ✅ `seatStubMessages`(双通道:`onPhaseChange` + `onUpstreamFrame`) | +| 落盘后 HTTP 拉取真实文件 | ✅ artifacts API | ✅ `present_files` 卡片走 HTTP | +| 草稿 reload 后历史文件可预览 | ✅ | ✅ `seatStubMessages` 一并入草稿 | + +--- + +## 2. 已完成 + +### 2.1 Step 2 布局调整 + +| 项 | 状态 | 说明 | +|----|------|------| +| 推理模型下拉 | ✅ | 从右侧栏迁至 Step 2 顶栏「多智能体圆桌会商中心」右侧 | +| 意图理解 | ✅ | 移至左侧栏顶部,支持展开/收起;收起时仅显示「目标」 | +| 任务概览 | ✅ | 放在「人工参与」下方 | +| 右侧栏 Step 2/3 隐藏 | ✅ | `RightSidebar` 仅在 `currentStep === 1` 渲染 | +| 左侧栏整体收起 | ✅ | 宽度 56px;展开按钮在顶部;研讨中角色 ring + ping 动画 | +| 沙箱打开 → 左栏收起 | ✅ | `Step2SandboxSidebarSync` 监听 `useArtifacts().open` | + +**主要文件**:`RoundtablePlanningPage.tsx`(`step2SidebarExpanded`、`LeftSidebar`、`Step2SandboxSidebarSync`) + +### 2.2 沙箱基础设施(Phase 1) + +| 项 | 状态 | 说明 | +|----|------|------| +| 路由包 `ChatRuntime` | ✅ | `WorkspaceRoutes.tsx` → `roundtable/planning` 外包 `ArtifactsProvider` | +| 60/40 分栏壳 | ✅ | `Step2SandboxLayout.tsx`,对齐 `chat-box.tsx` | +| `ArtifactFileDetail` / `ArtifactFileList` | ✅ | 嵌在右栏 ResizablePanel | +| `ThreadContext` stub | ✅ | 最小 `BaseStream` stub,供 `useArtifactContent` 工作 | + +### 2.3 沙箱打开逻辑(Phase 2) + +| 项 | 状态 | 说明 | +|----|------|------| +| `write_file` 自动打开 | ✅ | `handleArtifactToolPhase` 在 `tool_calling` + 完整 `args.path` 时触发 | +| 步骤卡点击打开 | ✅ | `MessageStepsCard` → `openArtifactFromStep` | +| 虚拟 URL 机制 | ✅ | `write-file:?message_id=...&tool_call_id=...`(对齐主聊天) | +| Stub messages 注入 | ✅ | `seatStubMessages` + `upsertSeatToolCall` → `loadArtifactContentFromToolCall` | +| 重置清理 | ✅ | `resetStep2State` 清空 artifacts / stub / deselect | +| `present_files` 步骤点击 | ✅ | 走真实路径 `openArtifactPreview`(HTTP API) | + +### 2.4 上游帧兜底注入(Phase 3) + +| 项 | 状态 | 说明 | +|----|------|------| +| `onPhaseChange` 主通道 | ✅ | `tool_calling` 阶段每帧把完整 `args` 喂进 stub;首次出现 `path` 即触发自动打开 | +| `onUpstreamFrame` 兜底通道 | ✅ | `handleUpstreamFrame` 扫描 messages-tuple 帧的 `tool_calls[]`,对 `write_file`/`str_replace` 持续 upsert `args.content`;覆盖 phase 已切走但 tool_calls 又回包的边界 | +| 双通道幂等 | ✅ | `upsertSeatToolCall` 按 `(toolName, path)` 去重,两个通道并发写入安全 | + +### 2.5 `present_files` 文件卡(Phase 4) + +| 项 | 状态 | 说明 | +|----|------|------| +| 气泡下方文件卡行 | ✅ | `Step2Message` → `collectPresentedFiles` → `PresentFilesRow` | +| 卡片样式 | ✅ | 文件名 + 扩展名徽章(FILE_EXT_LABELS),最小宽度 30/最大 50(hover 边框亮色) | +| 点击 → HTTP 预览 | ✅ | `openArtifactPreview(agentThreadId, path)`,与步骤卡 `present_files` 走同一通道 | +| 席位 thread 缺失保护 | ✅ | 没有 `agentThreadId` 时 disabled + tooltip | +| 同气泡多次 present_files | ✅ | `filepaths` 顺序去重,全部聚合到一行卡片 | + +### 2.6 草稿持久化(Phase 5) + +| 项 | 状态 | 说明 | +|----|------|------| +| `seatStubMessages` 进 `Step2Snapshot` | ✅ | `useStep2Orchestration.ts` Step2Snapshot 新增字段 | +| `seatStubMessages` 进 `DraftStep2Snapshot` | ✅ | `drafts.ts` 加 optional 字段,老草稿 reload 时回退到 `{}` | +| `hydrateFromDraft` 还原 stub | ✅ | 同步写 `seatStubMessagesRef` + state,加载草稿后点击历史 write_file 步骤可直接预览 | +| `getStep2Snapshot` 透出 stub | ✅ | `RoundtablePlanningPage.tsx` 给 `useDraftPersistence` 的 getter 加上该字段 | + +**主要文件**: + +``` +frontend-web/src/pages/WorkspaceRoutes.tsx +frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx +frontend-web/src/roundtable-planning/components/Step2Panel.tsx +frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx +frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +frontend-web/src/roundtable-planning/utils/drafts.ts +``` + +### 2.7 与主聊天对齐的核心数据流 + +``` +special seat SSE (streamMultiAgent) + ├── onPhaseChange("tool_calling", "write_file", args) + │ ↓ + │ upsertSeatToolCall(seatThreadId, "write_file", args) + │ → seatStubMessages[seatThreadId] = [{ type:"ai", tool_calls:[{ id, name, args }] }] + │ ↓ + │ buildWriteFileVirtualUrl(path, messageId, toolCallId) + │ → "write-file:/mnt/user-data/outputs/foo.md?message_id=...&tool_call_id=..." + │ ↓ + │ openSeatVirtualArtifact(seatThreadId, virtualUrl, auto=true) + │ → selectArtifact(virtualUrl) + setArtifactsOpen(true) + │ + └── onUpstreamFrame(frame) ← 兜底通道 + ↓ + handleUpstreamFrame —— 持续 upsert tool_calls 里完整的 args(不打开沙箱) + +Step2SandboxLayout + ThreadContext.thread.messages = seatStubMessages[activeThreadId] + ArtifactFileDetail(filepath=virtualUrl, threadId=seatThreadId) + ↓ + useArtifactContent → loadArtifactContentFromToolCall + → 从 thread.messages 读 tool_call.args.content(不发 HTTP) +``` + +**为何不用真实 path 直接预览**:流式期间文件可能尚未落盘,直接调 `/api/threads/:id/artifacts/...` 会返回 `404 {"detail":"Not Found"}`。主聊天同样用虚拟 URL 绕过此问题。 + +--- + +## 3. 未完成 / 待验证 + +### 3.1 功能缺口(按优先级) + +| 优先级 | 项 | 状态 | 说明 | +|--------|----|------|------| +| P0 | 端到端回归 | ⚠️ 待验证 | 虚拟 URL + 双通道 + 文件卡 + 草稿持久化全链路尚未在浏览器里跑一遍 | +| P2 | `str_replace` 流式编辑预览 | ❌ | 主聊天 `applyPendingTextEdit` 依赖完整 message 链;stub 未模拟 tool result / 多轮 str_replace 累加 | +| P2 | 沙箱 `artifacts[]` 列表与虚拟 URL 混排 | ❌ | 虚拟 URL 不写入 `setArtifacts([])`;多文件切换、文件名下拉体验未完全对齐主聊天 | +| P3 | Step 3 与沙箱衔接 | ❌ | Step 3 仍用 `HighFidelityReport` + `listSessionArtifacts`;与 Step 2 沙箱选中态无联动 | +| P3 | 文档同步 | ❌ | `multi-agent-frontend-dev.md` 仍描述旧布局(右侧栏模型选择器等),需更新 § 布局 + 沙箱章节 | + +### 3.2 已知风险 / 边界 + +1. **打开时机**:仅在 SSE 进入 `tool_calling` 且 `tool_calls[0].args` 为完整对象时触发自动打开(`multi-agent.ts` 刻意忽略 `tool_call_chunks` 的拼接 args)。若模型只发 chunks、迟迟不发完整 tool_calls,自动打开会延迟到 args 完整那一帧——与主聊天一致。 +2. **多 write_file 同气泡**:`upsertSeatToolCall` 按 `(toolName, path)` 幂等;同 path 多次 write 会更新 args,不会新建多条 stub tool_call。 +3. **leader 席位**:当前仅 special 流绑定 `agentThreadId` 并接 `onUpstreamFrame`;总控若直接 write_file,需单独接 thread id 与帧回调。 +4. **`present_files` 文件大小**:流式期间 backend 尚未给出真实大小;卡片只显示扩展名徽章,不显示 size。落盘后用户点开走 HTTP API 自然能看到完整内容。 +5. **调试日志**:控制台 `[roundtable-sandbox]` 前缀;生产环境可考虑加 `window.__ROUNDTABLE_SANDBOX_DEBUG__` 门控。 + +--- + +## 4. 分阶段计划(原始规划 vs 现状) + +| 阶段 | 内容 | 状态 | +|------|------|------| +| Phase 1 | `ChatRuntime` + 分栏壳 + 侧栏联动 | ✅ 完成 | +| Phase 2 | `write_file` 自动/点击打开 + 虚拟 URL 预览 | ✅ 完成 | +| Phase 3 | `onUpstreamFrame` 注入完整 LangGraph messages | ✅ 完成(双通道兜底) | +| Phase 4 | `present_files` 卡片 + Step 3 文件区衔接 | ⚠️ 卡片完成;Step 3 衔接遗留 | +| Phase 5 | 草稿 stub 持久化 + 文档更新 | ✅ stub 完成;本文档同步更新 | + +--- + +## 5. 测试清单 + +在 `/page/workspace/roundtable/planning` Step 2 下逐项勾选: + +- [ ] 子智能体 `write_file` 进入 `tool_calling` 后,右侧沙箱 **自动** 打开(约 100ms 延迟,对齐主聊天) +- [ ] 沙箱标题为文件名(如 `sample-paper.md`),**不是** `{"detail":"Not Found"}` +- [ ] 流式期间 MD 内容随 `args.content` 更新(双通道:phase 切换帧 + 兜底帧) +- [ ] 点击 ChainOfThought 步骤「写入文件」/ 路径 chip → 手动打开同一文件 +- [ ] `present_files` 后气泡下方出现文件卡(带扩展名徽章),点击 → HTTP 预览 +- [ ] 沙箱打开时左侧「意图理解」栏 **自动收起** +- [ ] 关闭沙箱(X)后对话区恢复全宽 +- [ ] 「新建任务」后 artifacts / 沙箱 / `seatStubMessages` 全部清空 +- [ ] 「保存草稿」→「加载草稿」后,点击历史 `write_file` 步骤可立即预览(stub 已还原) +- [ ] 与主聊天 `/page/workspace/chats/:threadId` 对比:同一 write_file 行为一致 + +**调试**:DevTools Console 过滤 `[roundtable-sandbox]` 或 `[stream:special:...]`。 + +--- + +## 6. 关键代码索引 + +| 用途 | 路径 | +|------|------| +| 主聊天 write_file 虚拟 URL + 自动打开 | `src/components/workspace/messages/message-group.tsx` | +| 主聊天 `RichFileCard`(present_files) | `src/components/workspace/messages/message-list-item.tsx` | +| 主聊天 60/40 分栏 | `src/components/workspace/chats/chat-box.tsx` | +| 虚拟 URL 内容加载 | `src/core/artifacts/loader.ts` → `loadArtifactContentFromToolCall` | +| Artifacts 上下文 | `src/components/workspace/artifacts/context.tsx` | +| Step 2 编排 + 沙箱状态 + 草稿快照 | `src/roundtable-planning/hooks/useStep2Orchestration.ts` | +| Step 2 沙箱布局 | `src/roundtable-planning/components/Step2SandboxLayout.tsx` | +| Step 2 文件卡 + 步骤渲染 | `src/roundtable-planning/components/Step2Panel.tsx` | +| SSE phase 推断 + `onUpstreamFrame` | `src/roundtable-planning/api/multi-agent.ts` → `setPhase` | +| 主页面侧栏 + 联动 | `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | +| 草稿持久化 | `src/roundtable-planning/utils/drafts.ts`、`hooks/useDraftPersistence.ts` | + +--- + +## 7. 下一步建议(给接续开发者) + +1. **先跑通 P0 测试清单**,确认 Phase 3/4/5 改造后无回归;重点观察双通道并发写入 stub 时 args 是否被旧帧覆盖(理论上 LangGraph 完整 tool_calls 是单调累积的,应该没问题)。 +2. **Phase 4 收尾**:把 Step 3 的 `listSessionArtifacts` / `HighFidelityReport` 接进沙箱选中态——Step 3 进入时复用 Step 2 的 `ArtifactsProvider`,让用户在 Step 3 也能预览 Step 2 留下的产物。 +3. **Phase 2 边界**:`str_replace` 多轮编辑的 stub 模拟(追加 tool result 消息让 `applyPendingTextEdit` 能 replay edit 序列),优先级低。 +4. **更新** `multi-agent-frontend-dev.md`:Step 2 布局、沙箱架构、`seatStubMessages` 数据流,避免与本文档重复——可在总览文档加一节「见 `roundtable-step2-sandbox-progress.md`」。 diff --git a/frontend-web/docs/前端开发.md b/frontend-web/docs/前端开发.md new file mode 100644 index 00000000..502f4bb8 --- /dev/null +++ b/frontend-web/docs/前端开发.md @@ -0,0 +1,1165 @@ +# 前端开发文档(frontend-web) + +> 本文档面向参与 `frontend-web/` 开发的工程师,覆盖工程结构、技术栈、环境与启动、路由与页面、状态体系、与后端 LangGraph 的实时流式交互、上传/产物(Artifacts)、模型与设置、鉴权、主题与国际化、构建与发布等所有关键开发点,力求从零到一帮助新成员快速上手。 + +--- + +## 1. 概览 + +- 项目名:`deer-flow-frontend-vite`(package.json) +- 类型:单页应用(SPA),React 19 + TypeScript 5 + Vite 6 +- 包管理器:pnpm(`packageManager: pnpm@10.26.2`) +- 路由:`react-router-dom` v7(采用 HashRouter) +- 状态:TanStack Query(服务端状态)+ React Context(流式会话/Artifacts/Subtasks/PromptInput)+ `localStorage`/`sessionStorage`(用户偏好与流式恢复) +- UI 体系:TailwindCSS v4 + Radix UI 原语 + shadcn 风格自封装(`src/components/ui/`)+ TDesign(少量页面)+ Lucide 图标 +- LLM 流式交互:[`@langchain/langgraph-sdk`](https://www.npmjs.com/package/@langchain/langgraph-sdk),与后端 Gateway 上的 LangGraph 运行时(默认 `lead_agent`)建立 SSE 连接 +- 渲染:`react-markdown` + `streamdown` + `rehype-*` + `shiki/react-syntax-highlighter` + `katex` +- 富文本:`@tiptap/*`(用于 Open Canvas 写作) +- 编辑器:`@uiw/react-codemirror`(代码片段编辑) +- 流程图:`@xyflow/react`(节点连线类组件) + +> 这是一个由 Next.js 历史代码迁移而来的 Vite 工程:保留 `next/link`、`next/navigation`、`use client` 等遗留写法,并通过 [src/shims/](src/shims/) 提供运行时兼容。新开发中可以继续按 Next.js 习惯写 `useRouter()/useParams()/usePathname()`,由 shim 转译为 react-router 实现。 + +--- + +## 2. 目录结构 + +``` +frontend-web/ +├─ index.html 入口模板(含黑/白主题快速注入脚本) +├─ vite.config.ts Vite 配置(alias + sourceIdentifier 插件) +├─ tsconfig.json TS 编译选项 +├─ postcss.config.js PostCSS + tailwindcss v4 +├─ .env / .env.development / .env.production 后端地址等环境变量 +├─ docs/ 前端文档(本目录) +└─ src/ + ├─ main.tsx React 入口(StrictMode + 全局样式) + ├─ App.tsx 根组件:HashRouter + 顶层 Provider 嵌套 + ├─ env.ts 读 import.meta.env(兼容旧 NEXT_PUBLIC_ 命名) + ├─ styles/globals.css 全局 + Tailwind 入口 + ├─ shims/ next/link、next/navigation 的 react-router 适配 + ├─ pages/ 路由页面(HashRouter 下的所有 Route 元素) + │ ├─ PageRoutes.tsx /page/* 一级布局:左侧 PageSidebar + Header + │ ├─ WorkspaceRoutes.tsx /page/workspace/* 二级路由(聊天/Agents/Skills/Memory/Admin…) + │ ├─ StrategyRoutes.tsx /page/strategy/* 策略页 + │ ├─ RoundtableRoutes.tsx /page/roundtable/* + │ ├─ ChatPage.tsx 主聊天页(绑定 lead_agent) + │ ├─ AgentChatPage.tsx 基于自定义 Agent 的会话 + │ ├─ ScheduledTasksPage.tsx 定时任务 + │ ├─ ScheduledTaskRunDetailPage.tsx + │ ├─ PublicSharePage.tsx 免登录分享只读 + │ ├─ PublicScheduledTaskPage.tsx + │ ├─ LoginPage.tsx 用户名直登(开发/内网用) + │ └─ ... + ├─ core/ 「领域核心」: API 客户端、领域 hooks、类型 + │ ├─ api/ langgraph-sdk client + 鉴权 fetch + │ ├─ auth/ 访问令牌存取(localStorage) + │ ├─ config/ 后端/LangGraph URL 解析 + │ ├─ threads/ 会话流(核心 useThreadStream) + │ ├─ uploads/ 线程上传/校验/解析 + │ ├─ settings/ 本地偏好与线程级模型覆盖 + │ ├─ tasks/ 子任务 SubtaskContext + │ ├─ models/ 可用模型 + 用量开关 + │ ├─ agents/ 自定义 Agent CRUD + 选择器 + │ ├─ skills/、memory/、tags/ 其他领域 + │ ├─ scheduled-tasks/ 定时任务 + │ ├─ artifacts/ 产物(代码/文档)读取 + │ ├─ recommended-questions/、notifications/、admin/、curator/、tool-metrics/、llm-metrics/ + │ ├─ note/ 轻应用(笔记)API(独立后端 VITE_NOTE_API_BASE_URL) + │ ├─ i18n/ 语言上下文、词条、检测 + │ ├─ rehype/、streamdown/、tools/、messages/、page-layout/、route-base.tsx + │ └─ utils/ uuid、json、files、markdown、datetime + ├─ contexts/ 全局 React 上下文(Chat/Theme) + ├─ components/ + │ ├─ ui/ shadcn 风格基础组件(button、dialog、sidebar、resizable…) + │ ├─ ai-elements/ AI 业务组件(prompt-input、conversation、message、plan、reasoning…) + │ ├─ workspace/ 工作台模块(chats、messages、artifacts、settings、admin、agents…) + │ ├─ landing/ 营销页(Landing) + │ ├─ query-client-provider.tsx TanStack Query 容器 + │ └─ theme-provider.tsx next-themes 容器 + ├─ open-canvas/ 开放画布/AI 写作(独立路由:/page/canvas/*) + ├─ roundtable-planning/ 圆桌规划(独立路由:/page/roundtable/*) + ├─ strategy-components/ 策略相关组件 + Header + ├─ hooks/ 通用工具 hooks(debounce、async、localStorage…) + └─ lib/ 通用工具:cn、desensitize、ime +``` + +--- + +## 3. 启动、构建、脚本 + +### 3.1 NPM Scripts([package.json](../package.json)) + +| 脚本 | 命令 | 说明 | +|---|---|---| +| `pnpm dev` | `vite` | 启动开发服务器(默认端口 5173;项目实际通过仓库根脚本统一为 5174) | +| `pnpm build` | `node --max-old-space-size=8192 vite build` | 生产构建(提升 Node 内存阈值,避免大型依赖 OOM) | +| `pnpm preview` | `vite preview` | 本地预览 dist 构建产物 | +| `pnpm typecheck` | `tsc --noEmit` | 仅做 TS 类型检查,不输出文件 | + +### 3.2 仓库根脚本 + +`scripts/start-frontend.sh`(与后端联动)会自动注入 `VITE_BACKEND_BASE_URL=http://127.0.0.1:8001` 与 `VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api`,并将日志/PID 写入 `.runtime/`。详细命令见根目录 [CLAUDE.md](../../CLAUDE.md): + +```bash +./scripts/start-all.sh # 同时启动 backend + frontend +./scripts/stop-all.sh +./scripts/restart-all.sh +./scripts/status.sh +``` + +### 3.3 Vite 别名([vite.config.ts](../vite.config.ts)) + +| 别名 | 真实路径 | +|---|---| +| `@/` | `src/` | +| `@vite/` | `src/` | +| `@/env` | `src/env.ts` | +| `next/link` | `src/shims/next-link.tsx` | +| `next/navigation` | `src/shims/next-navigation.ts` | + +工程同时配置了 `vite-plugin-source-identifier`:构建产物会带源文件标记,便于线上排错对应到源文件。 + +`base: "./"` 表示打包后使用相对路径,方便部署到任意子路径或本地 file:// 调试。 + +### 3.4 浏览器与构建目标 + +- TS `target: ES2022`、`module: ESNext`、`moduleResolution: Bundler` +- `useDefineForClassFields: true`、`isolatedModules: true`、`jsx: react-jsx` +- 严格模式开启,但 `noImplicitAny: false` + +--- + +## 4. 环境变量 + +入口在 [src/env.ts](../src/env.ts),所有公共变量使用 `VITE_` 前缀,运行时通过 `import.meta.env` 读取。出于兼容旧 Next.js 代码,导出的对象保留 `NEXT_PUBLIC_` 命名: + +```ts +env.NEXT_PUBLIC_BACKEND_BASE_URL // VITE_BACKEND_BASE_URL +env.NEXT_PUBLIC_LANGGRAPH_BASE_URL // VITE_LANGGRAPH_BASE_URL +env.NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL // 画布独立 LangGraph(可选,默认回退主地址) +env.NEXT_PUBLIC_NOTE_API_BASE_URL // 轻应用「笔记」API +env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY // 静态演示模式(禁用上传/发送) +env.GITHUB_OAUTH_TOKEN // 备用:landing 中拉取仓库信息 +``` + +`.env` 文件加载优先级(Vite 标准): + +``` +.env.development.local > .env.development > .env (开发) +.env.production.local > .env.production > .env (生产) +``` + +个人本机覆盖请使用 `.env.development.local`(已 gitignore)。 + +`getBackendBaseURL()` / `getLangGraphBaseURL()` / `getCanvasLangGraphBaseURL()` 均在 [src/core/config/index.ts](../src/core/config/index.ts) 中,会基于当前 `window.location.origin` 兜底为同源 `/api/langgraph`。 + +--- + +## 5. 渲染入口与 Provider 体系 + +### 5.1 启动栈 + +``` +ReactDOM.createRoot(#root) +└─ + └─ + └─ + └─ + ├─ // next-themes,attribute=class,存储键 "strategy-theme" + │ ├─ // 语言上下文(locale) + │ │ └─ // 自定义主题(与 next-themes 协作) + │ │ └─ // QA 历史(localStorage: qa-chat-history) + │ │ └─ // TanStack Query + │ │ └─ ... +``` + +`index.html` 头部内联了一段脚本,会在 React 启动前根据 `localStorage["strategy-theme"]` 决定是否给 `` 添加 `dark` 类,避免暗色主题闪烁。 + +### 5.2 路由 + +外层([App.tsx](../src/App.tsx)): + +``` +/ -> Redirect to /login +/login LoginPage +/login/:username LoginPage(按用户名直登) +/landing LandingPage +/share/:shareCode PublicSharePage(免登录只读) +/public/scheduled-tasks/:taskId PublicScheduledTaskPage(免登录) +/page/* PageRoutes(含侧边栏与 Header) +* NotFoundPage +``` + +`/page/*` 内([PageRoutes.tsx](../src/pages/PageRoutes.tsx)): + +``` +/page/workspace/* -> WorkspaceRoutes(默认) +/page/strategy/* -> StrategyRoutes +/page/canvas/* -> open-canvas/routes/OpenCanvasRoutes +/page/roundtable/* -> RoundtableRoutes +``` + +`/page/workspace/*`([WorkspaceRoutes.tsx](../src/pages/WorkspaceRoutes.tsx)): + +``` +chats -> Navigate to chats/new +chats/:thread_id -> +agents -> AgentsPage +agents/new -> NewAgentPage +agents/:agent_id/chats/:thread_id -> AgentChatPage +skills -> SkillsPage +skills/evolution -> SkillEvolutionPage +memory -> MemoryPage +ai-writing -> AIWritingPage(来自 open-canvas) +roundtable/planning -> RoundtablePlanningPage +scheduled/tasks -> ScheduledTasksPage +scheduled/runs/:run_id -> ScheduledTaskRunDetailPage +admin -> AdminUserHubPage (system_role=admin) +admin/scheduled-tasks -> AdminScheduledTasksPage (admin) +admin/tags -> TagsPage (admin) +settings/recommended-questions (admin) +settings/prompt-prefix (admin) +settings/llm-metrics (admin) +``` + +`WorkspaceRoutes` 通过 `` 提供基址上下文,让兼容自 Next.js 的 `useRouter().push("/page/workspace/...")` 在不同的二级路由下都能映射到正确的 base。 + +> **重要约定**:使用 HashRouter,URL 形如 `/#/page/workspace/chats/new`。这是为了便于在静态文件部署(包括打包后 `file://`)下使用。新建一条聊天时,前端会用 `history.replaceState(...)` 直接改写 hash,而不让 router 重渲染整棵子树 —— 这一点在 [ChatPage.tsx:75-79](../src/pages/ChatPage.tsx#L75-L79) 中体现,依赖了 `useThreadChat()` 的特殊兜底逻辑(避免 useParams 把 `new` 误判为真实 thread_id)。 + +### 5.3 Workspace 双层布局 + +- `` 提供 `` + `` + `` + `` + ``,并通过 CSS 变量 `--page-sidebar-width` 把内层侧栏紧贴外层 PageSidebar。 +- 对于「聊天类」路由 (`chats/:thread_id`、`agents/.../chats/:thread_id`),额外包一层 ``: + +```tsx + + + + {children} + + + +``` + +这三个上下文只在聊天会话内生效,避免污染列表/管理类页面。 + +--- + +## 6. 鉴权 + +### 6.1 登录流程 + +[src/pages/LoginPage.tsx](../src/pages/LoginPage.tsx) 通过 `loginByUsername(username)` 直接向后端发起登录(默认 `guest`),返回结构存在 [src/core/auth/index.ts](../src/core/auth/index.ts): + +```ts +interface LoginByUsernameResponse { + access_token: string; + token_type: string; // 默认 "Bearer" + expires_in: number; + user_id: string; + email: string; + system_role: string; // "admin" 控制管理员菜单 + needs_setup: boolean; + created: boolean; +} +``` + +存储键:`localStorage["deerflow.auth"]`。 + +工具方法: +- `getStoredAuth()` / `setStoredAuth(auth)` / `clearStoredAuth()` +- `getAccessToken()` —— 返回 token 字符串 +- `getAuthorizationHeaderValue()` —— 返回 `"Bearer xxx"`,缺失时 undefined + +### 6.2 鉴权注入 + +所有出站请求都通过两条路径之一注入 Token: + +1. **裸 fetch** —— 使用 [src/core/api/fetch-client.ts](../src/core/api/fetch-client.ts) 提供的 `apiFetch(input, init)`,自动在 headers 上加 `Authorization`。 +2. **LangGraph SDK** —— [src/core/api/api-client.ts](../src/core/api/api-client.ts) 在 `new LangGraphClient` 的 `onRequest` 钩子里塞 `Authorization`,并对 `runs.stream / runs.joinStream` 做 `sanitizeRunStreamOptions` 包装(见 `core/api/stream-mode.ts`),确保流式参数合法。 + +> **务必使用 `apiFetch` 而不是裸 `fetch`**,否则鉴权头不会带上,私有接口会 401。 + +### 6.3 路由级守护 + +WorkspaceRoutes 内的 `/admin/*` 与 `/settings/*` 路由在元素层面做了判定: + +```tsx +getStoredAuth()?.system_role === "admin" + ? ... + : +``` + +非管理员被静默重定向到聊天页。 + +--- + +## 7. 与后端的通信 + +### 7.1 三种入口 + +| 通道 | 函数/客户端 | 用途 | +|---|---|---| +| LangGraph SDK | `getAPIClient(isMock?)` | LangGraph runtime:`threads.search/get/update/delete`,`runs.stream/joinStream` | +| HTTP fetch | `apiFetch(url, init)` | Gateway 业务接口(Agents/Memory/Skills/Tags/Uploads/Scheduled-tasks…) | +| 独立服务 | `getNoteApiBaseURL()` | 轻应用「笔记」服务 | + +### 7.2 LangGraph 客户端缓存 + +`getAPIClient(isMock?)` 通过模块级 `Map` 进行客户端实例缓存(`default` / `mock`),避免重复构造。 + +### 7.3 流式聊天的核心:`useThreadStream` + +定义于 [src/core/threads/hooks.ts](../src/core/threads/hooks.ts)。这是「整套前端的心脏」,所有聊天页都基于它: + +```ts +const [thread, sendMessage, isUploading] = useThreadStream({ + threadId, + context, // LocalSettings["context"] + isMock, + assistantId: "lead_agent", + onStart(threadId) { /* 第一次创建会话时回调,用于改写 URL */ }, + onFinish(state) { /* 流式收束,浏览器后台时弹通知 */ }, + onToolEnd(evt) { /* 工具调用结束钩子 */ }, +}); +``` + +关键能力: + +1. **断线重连**:通过 `sessionStorage["lg:stream:"]` 存储正在进行的 run_id,刷新页面后 SDK 自动 `joinStream` 续接。`normalizeStoredRunId()` 容错处理历史 URL/带 query 的情况。 +2. **乐观更新**:发送即在本地 messages 后追加临时 `human` 消息;当文件正在上传时还追加占位 `ai` 消息("uploadingFiles");服务端有响应(messages 数量增加)后自动清空。 +3. **首条消息特例**:仅当线程已持久化时立即触发 `onStart`;新会话需要等到 SDK `onCreated(meta.thread_id)`,确保 URL 同步用的是真正的服务端 thread_id。 +4. **Context 装配**:每次提交时把 `thinking_enabled / is_plan_mode / subagent_enabled / reasoning_effort / memory_injection_enabled / thread_id` 等基于「四种 mode」推导: + +| mode | thinking_enabled | is_plan_mode | subagent_enabled | reasoning_effort | +|---|---|---|---|---| +| `flash` | false | false | false | undefined | +| `thinking` | true | false | false | low | +| `pro` | true | true | false | medium | +| `ultra` | true | true | true | high | + +5. **附件上传**:先 `uploadFiles(threadId, files)` 上传到 Gateway `/api/threads/:threadId/uploads`,然后将 `path/filename/size` 写入 `messages[0].additional_kwargs.files`,与 lead_agent 后端约定一致。 +6. **TanStack Query 失效**:流式完成或新建会话时调用 `queryClient.invalidateQueries({ queryKey: ["threads", "search"] })`,让侧边栏「最近会话」自动刷新。 +7. **错误降级**:`onError` 弹 sonner toast;`getStreamErrorMessage(error)` 兜底解析对象/嵌套字段。 + +### 7.4 重要回调事件 + +| SDK 事件 | 处理 | +|---|---| +| `onCreated(meta)` | 新线程创建:触发 `onStart`、把 agent_id 写入 thread metadata(非 mock) | +| `onLangChainEvent` | 监听 `on_tool_end` 并外抛 `onToolEnd` | +| `onUpdateEvent` | 若 `update.title` 出现,则更新 TanStack Query 缓存里的 thread 标题 | +| `onCustomEvent` | `task_running`:更新 SubtaskContext;`llm_retry`:弹 toast 提示 | +| `onError` | 清空乐观消息,弹 toast | +| `onFinish` | 触发外部 `onFinish` + 刷新 threads.search | + +### 7.5 其他领域 hook 约定 + +所有领域目录(`agents/`、`skills/`、`memory/`、`tags/`、`scheduled-tasks/`、`notifications/`、`admin/`、`curator/`、`llm-metrics/`、`tool-metrics/`、`system-settings/`、`thread-shares/`、`recommended-questions/`)遵循统一模板: + +``` +core// + ├─ api.ts 底层调用(fetch + apiFetch) + ├─ hooks.ts useQuery/useMutation 包装 + ├─ types.ts 接口类型 + └─ index.ts re-export +``` + +写新领域时也按此模板维持一致。 + +--- + +## 8. 全局状态与本地存储 + +### 8.1 React Context(运行时状态) + +| Context | 文件 | 作用域 | 状态 | +|---|---|---|---| +| `ChatContext` | [contexts/ChatContext.tsx](../src/contexts/ChatContext.tsx) | App 顶层 | 通用 QA 消息历史(localStorage: `qa-chat-history`) | +| `ThemeContext` | `contexts/ThemeContext.tsx` | App 顶层 | 主题,与 next-themes 协作 | +| `I18nContext` | [core/i18n/context.tsx](../src/core/i18n/context.tsx) | App 顶层 | 当前语言(Cookie: `locale`) | +| `SubtaskContext` | [core/tasks/context.tsx](../src/core/tasks/context.tsx) | ChatRuntime | 子任务最新 message | +| `ArtifactsContext` | [components/workspace/artifacts/context.tsx](../src/components/workspace/artifacts/context.tsx) | ChatRuntime | 当前会话产物列表与选中 | +| `PromptInputContext` | [components/ai-elements/prompt-input.tsx](../src/components/ai-elements/prompt-input.tsx) | ChatRuntime | 输入框附件/状态 | +| `RouteBaseContext` | [core/route-base.tsx](../src/core/route-base.tsx) | Workspace/Strategy/Roundtable | 给 `useRouter` shim 提供 base prefix | +| `ThreadContext` | `components/workspace/messages/context.ts` | 每个聊天页内 | 共享 `thread` 与 `isMock` 给 MessageList 等子组件 | + +### 8.2 LocalSettings(用户偏好) + +[core/settings/local.ts](../src/core/settings/local.ts) + [core/settings/store.ts](../src/core/settings/store.ts) + [core/settings/hooks.ts](../src/core/settings/hooks.ts): + +```ts +LocalSettings = { + notification: { enabled: boolean }, + display: { streamSpeedMeterEnabled: boolean }, + context: { + model_name?: string, + mode?: "flash" | "thinking" | "pro" | "ultra", + reasoning_effort?: "minimal"|"low"|"medium"|"high", + writing_mode?: boolean, + memory_injection_enabled?: boolean, + agent_id?: string, + } +} +``` + +- 持久化:`localStorage["deerflow.local-settings"]` +- 线程级模型覆盖:`localStorage["deerflow.thread-model."]`,通过 `useThreadSettings(threadId)` 自动合并 +- 实现:自定义的「订阅 + storage 事件」store,借助 `useSyncExternalStore` 让多标签页/多组件同步 + +### 8.3 sessionStorage + +| Key | 写入位置 | 说明 | +|---|---|---| +| `lg:stream:` | useThreadStream(SDK 内部) | 当前 run_id,用于 reconnect | +| `pendingAgentMessage` | ChatPage 提交时(含 agentId) | 跳转 AgentChatPage 后续发首条消息 | + +--- + +## 9. 组件层次 + +### 9.1 `src/components/ui/` + +shadcn 风格的「无业务」基础组件:`button`、`badge`、`avatar`、`card`、`carousel`、`command`、`dialog`、`dropdown-menu`、`hover-card`、`input`、`input-group`、`item`、`progress`、`resizable`、`scroll-area`、`select`、`separator`、`sidebar`、`skeleton`、`sonner`、`switch`、`tabs`、`textarea`、`toggle`、`toggle-group`、`tooltip` 等。 + +特色组件: +- `aurora-text` / `flickering-grid` / `magic-bento` / `spotlight-card` / `shine-border` / `confetti-button` —— Landing 视觉效果 +- `terminal` —— 终端样式 +- `number-ticker` / `word-rotate` —— 动画文本(基于 `motion`) + +### 9.2 `src/components/ai-elements/` + +AI 业务可复用组件,是聊天页的「乐高」: + +| 组件 | 用途 | +|---|---| +| `prompt-input` | 输入区域容器、附件、提交按钮、模式选择 | +| `conversation` | 消息流容器(自动滚动到底,依赖 `use-stick-to-bottom`) | +| `message` / `message-content` | 消息气泡 | +| `reasoning` | 折叠的「思考」区段 | +| `plan` | Plan-Mode 任务计划 | +| `chain-of-thought` | 步骤链路 | +| `task` | 单步任务展示 | +| `checkpoint` | 流程节点 | +| `sources` | 来源引用列表 | +| `suggestion` | 建议追问/快捷动作 | +| `code-block` | 代码块(shiki/syntax highlighter) | +| `model-selector` | 模型下拉 | +| `web-preview` / `image` | 网页/图片预览 | +| `canvas` / `node` / `edge` / `controls` | xyflow 流程图原语 | +| `loader` / `shimmer` / `toolbar` / `panel` / `queue` | 辅助 UI | +| `open-in-chat` | 「在聊天中打开」入口 | + +### 9.3 `src/components/workspace/` + +工作台模块,承载 90% 业务页面: + +``` +workspace/ +├─ workspace-sidebar.tsx 右栏导航 +├─ workspace-header.tsx 顶部 +├─ workspace-nav-menu.tsx +├─ workspace-nav-chat-list.tsx 最近会话 +├─ workspace-container.tsx 业务包裹器 +├─ command-palette.tsx ⌘K +├─ thread-title.tsx +├─ welcome.tsx / agent-welcome.tsx +├─ todo-list.tsx +├─ token-usage-indicator.tsx +├─ streaming-indicator.tsx +├─ input-box.tsx 基于 PromptInput 的实际输入框(模式、Agent、附件) +├─ export-trigger.tsx 导出会话 docx/md +├─ thread-share-dialogs.tsx 创建/管理只读分享链接 +├─ recent-chat-list.tsx / recent-writing-list.tsx +├─ chats/ +│ ├─ chat-box.tsx 左右双栏 (聊天 + Artifacts),react-resizable-panels +│ ├─ use-thread-chat.ts 解析 :thread_id(new -> uuid) +│ └─ use-chat-mode.ts 特定 mode hook +├─ messages/ +│ ├─ message-list.tsx 渲染所有消息、groupMessages +│ ├─ message-group.tsx +│ ├─ message-list-item.tsx +│ ├─ markdown-content.tsx react-markdown + rehype-katex + rehype-raw + remark-gfm + remark-math +│ ├─ subtask-card.tsx +│ ├─ message-token-usage.tsx +│ ├─ use-message-metrics.ts +│ ├─ download-word-button.tsx +│ ├─ context.ts ThreadContext +│ └─ skeleton.tsx +├─ artifacts/ +│ ├─ context.tsx ArtifactsContext + Provider +│ ├─ artifact-file-list.tsx +│ ├─ artifact-file-detail.tsx +│ └─ artifact-trigger.tsx 顶部「打开 Artifacts」按钮 +├─ citations/ 引用链接渲染 +├─ settings/ 设置弹窗与子页(appearance/account/skill/memory/tool/about) +├─ admin/ 管理员页(user-management / tag-management / admin-user-hub / tool-metrics / memory) +├─ agents/ Agent gallery、selector、manage dialog +├─ skills/ skill-detail-dialog、skill-evolution-panel +├─ scheduled-tasks/ schedule-builder +├─ tags/ tag-picker-dialog、tag-chip、tag-search-bar、tag-colors +└─ ... +``` + +### 9.4 `src/open-canvas/` + +AI 写作 / 画布编辑器(基于 `@tiptap/*`): + +- `routes/OpenCanvasRoutes.tsx` 二级路由 +- `pages/`:CanvasHomePage / CanvasThreadPage / AIWritingPage / ArticleTypesConfigPage +- `editor/TiptapEditor.tsx` + `TiptapExtensions.ts` + `AiHighlightExtension.ts` +- `contexts/`:Canvas 自有的 Stream/Thread/Graph/UI Context(与主聊天页解耦) +- `hooks/`:`useCanvasStream`、`useCanvasArtifact`、`useCanvasThread`、`useWritingRewrite`、`useDocumentOutline` 等 +- `api/`:自己封装 `/canvas/runs` `/canvas/threads` `/rewrite` `/writing-export` `/ai-writing-sessions` +- 独立 LangGraph 服务地址通过 `VITE_CANVAS_LANGGRAPH_BASE_URL` 配置;未配置则与主聊天同源(仅 assistantId 不同) + +### 9.5 `src/roundtable-planning/` + +圆桌规划页(多 Agent 协同生成方案): + +- `pages/RoundtablePlanningPage.tsx` 主页 +- `components/`:Step1/Step2/Step3 面板、MessageBubble、PersonaDrawer、HighFidelityReport、RecommendAgentsDialog +- `hooks/`:`useStep1Intent`、`useStep2Orchestration`、`useAutoScroll`、`useDraftPersistence` +- `api/`:intent / multi-agent / recommend +- `lib/`:clarification 规则、reasoning 工具、step-display +- 自带 `styles/roundtable-planning.css` + +### 9.6 `src/strategy-components/` + +策略类页面(含 QA 中心 / Agent QA / Collaborative QA / MopeChat),其中部分仍保留 `.jsx` 旧文件。新增功能优先迁移到 `.tsx`。 + +--- + +## 10. 样式与主题 + +### 10.1 TailwindCSS v4 + +- `postcss.config.js` 引入 `@tailwindcss/postcss` +- 入口样式:[src/styles/globals.css](../src/styles/globals.css) +- 配合 `tw-animate-css` 提供常用动效 +- `cn(...)` 工具([src/lib/utils.ts](../src/lib/utils.ts))= `twMerge(clsx(...))`,是合并 className 的「事实标准」 + +### 10.2 主题 + +- 顶层用 [next-themes](https://github.com/pacocoursey/next-themes),`attribute="class"`,`storageKey="strategy-theme"`,默认 `light`,`/landing` 强制 light +- 自定义 `ThemeProvider`(`contexts/ThemeContext.tsx`)暴露 `theme` 给少量组件做白底 sidebar 等微调(见 [workspace-sidebar.tsx:27](../src/components/workspace/workspace-sidebar.tsx#L27)) +- `index.html` 早期注入脚本读取 storage,避免 FOUC + +### 10.3 暗色样式 + +- `.dark` 类位于 ``,Tailwind v4 通过 `darkMode: class`(约定)匹配 + +--- + +## 11. 国际化(i18n) + +[src/core/i18n/](../src/core/i18n/): + +- `locale.ts` 提供 `detectLocale()`,按 navigator 推断 +- `translations.ts` / `locales/index.ts` 汇总词条 +- `context.tsx` 暴露 `I18nProvider` + `useI18nContext()` +- `hooks.ts` 提供 `useI18n()`,返回 `{ t, locale, setLocale }` +- 切换语言写 Cookie(`locale=xxx`),下次访问加载对应词条 + +业务里固定写法: + +```tsx +const { t } = useI18n(); +return {t.common.loading}; +``` + +--- + +## 12. 路由层的 Next.js 兼容 + +[src/shims/next-navigation.ts](../src/shims/next-navigation.ts) 通过 react-router-dom 实现了: + +- `useRouter()`:`push/replace/back/forward/refresh/prefetch`(其中 push/replace 会将旧 base `/page/workspace` 替换成 `RouteBaseContext` 值) +- `usePathname()`:`useLocation().pathname` +- `useParams()`:原样转发 +- `useSearchParams()`:只返回 `searchParams`(注意:**不是 tuple**,与 Next.js 习惯一致) + +[src/shims/next-link.tsx](../src/shims/next-link.tsx) 把 `` 桥接到 ``/``。 + +> 新代码可继续使用 `next/link`、`next/navigation`,但请避免依赖 SSR/数据获取等 Next.js 高阶特性。 + +--- + +## 13. 渲染与 Markdown + +[src/components/workspace/messages/markdown-content.tsx](../src/components/workspace/messages/markdown-content.tsx) 使用 `react-markdown` + 以下插件: + +- `remark-gfm` —— GFM 表格、删除线 +- `remark-math` + `rehype-katex` —— 数学公式 +- `rehype-raw` —— 渲染内嵌 HTML +- [core/rehype](../src/core/rehype/index.ts) 中的 `useRehypeSplitWordsIntoSpans()`:流式追加时按词包 ``,配合 CSS 渐入动画 +- [core/streamdown](../src/core/streamdown/index.ts) —— 流式 Markdown 解析(`streamdown` 包) +- 代码块用 `shiki`(高亮)+ `react-syntax-highlighter`(兜底) + +引用渲染:[components/workspace/citations/citation-link.tsx](../src/components/workspace/citations/citation-link.tsx) 和 `artifact-link.tsx` 将 `[[uuid]]` 风格引用转成可悬浮的链接卡片。 + +--- + +## 14. 上传与产物(Artifacts) + +### 14.1 上传 + +- `uploadFiles(threadId, files)`:POST 到 `/api/threads/:threadId/uploads`(FormData,`files` 字段重复多份) +- 大小/类型/数量校验:[core/uploads/file-validation.ts](../src/core/uploads/file-validation.ts) +- PromptInput 附件解析:[core/uploads/prompt-input-files.ts](../src/core/uploads/prompt-input-files.ts) 提供 `promptInputFilePartToFile()`,把组件 UI Part 转为 `File` +- 失败容错:`useThreadStream` 中若有任意 file 转换失败,会抛出 toast 并清掉乐观消息 + +### 14.2 产物 + +- 服务端在 `thread.values.artifacts: string[]` 里报路径 +- `ArtifactsProvider`([components/workspace/artifacts/context.tsx](../src/components/workspace/artifacts/context.tsx))做三层状态:所有产物、选中产物、面板开关 +- `ChatBox` 用 `react-resizable-panels` 拉出右侧 40% 面板,展示 `ArtifactFileList` / `ArtifactFileDetail` +- 选中 Markdown 产物时,「写作模式」会把 `writing_artifact_path` 注入 context([ChatPage.tsx:157-159](../src/pages/ChatPage.tsx#L157-L159)),后端 PromptPrefix/Writing 中间件会接管 + +--- + +## 15. 模型/模式/Token 用量 + +### 15.1 useModels + +[core/models/hooks.ts](../src/core/models/hooks.ts): + +```ts +const { models, tokenUsageEnabled, isLoading } = useModels(); +``` + +- `loadModels()` 调 Gateway `/api/models`(具体见 `core/models/api.ts`) +- `tokenUsageEnabled` 控制顶部 `` 是否显示 + +### 15.2 模式 (mode) + +`flash | thinking | pro | ultra` 四档(见 §7.3 表),由 `` 中的 mode 切换器修改 `LocalSettings.context.mode`。`ModeHoverGuide` 提供悬浮说明。 + +### 15.3 Token 用量 + +- 顶部全局:[components/workspace/token-usage-indicator.tsx](../src/components/workspace/token-usage-indicator.tsx) 累计当前线程 +- 单条消息:[components/workspace/messages/message-token-usage.tsx](../src/components/workspace/messages/message-token-usage.tsx) + `use-message-metrics.ts`,依赖 `tokenlens` 包 + +--- + +## 16. 通知、快捷键、命令面板 + +- 浏览器通知:[core/notification/hooks.ts](../src/core/notification/hooks.ts) 提供 `useNotification().showNotification(title, opts)`,在 `document.hidden || !hasFocus()` 时调用 +- 全局快捷键:[hooks/use-global-shortcuts.ts](../src/hooks/use-global-shortcuts.ts),统一注册到 `window keydown`,过滤掉 input/textarea/contentEditable,但 `⌘K` 例外(用于唤起命令面板) +- 命令面板:[components/workspace/command-palette.tsx](../src/components/workspace/command-palette.tsx)(基于 `cmdk`) + +--- + +## 17. 定时任务、分享 + +### 17.1 定时任务 + +- [core/scheduled-tasks/](../src/core/scheduled-tasks/) 提供 cron / one-shot 任务的 CRUD 与运行历史 +- [components/workspace/scheduled-tasks/schedule-builder.tsx](../src/components/workspace/scheduled-tasks/schedule-builder.tsx) 是创建/编辑 UI +- 详情页 `ScheduledTaskRunDetailPage` 复用 MessageList 渲染历史一次运行 +- `markdown-docx.ts` 用 `docx` 包导出 Word + +### 17.2 只读分享 + +- 创建分享:[components/workspace/thread-share-dialogs.tsx](../src/components/workspace/thread-share-dialogs.tsx) + `core/thread-shares/` +- 只读页 `PublicSharePage`(路由 `/share/:shareCode`)/ `PublicScheduledTaskPage`(`/public/scheduled-tasks/:taskId`),不需要登录 +- 后端会用 share token 做权限校验,前端通过 `apiFetch` 自动带 + +--- + +## 18. 调试与测试 + +### 18.1 类型检查 + +```bash +pnpm typecheck +``` + +工程没有内置 ESLint/Prettier 配置(依赖 IDE/CI),但有少量 `// eslint-disable-next-line react-hooks/exhaustive-deps` 注释提示 dependencies 故意省略。 + +### 18.2 Mock 模式 + +URL 加 `?mock=true` 时,`useThreadChat()` 设 `isMock=true`,`getAPIClient(true)` 使用 `mock` 客户端,`getLangGraphBaseURL(true)` 回退到 `${origin}/mock/api`,便于离线复现 UI(需要后端或本地 mock server 配合)。 + +### 18.3 演示模式 + +`VITE_STATIC_WEBSITE_ONLY=true` 时: + +- 输入框 disabled,提示「演示模式不可用」 +- 自动展开 Artifacts 面板并选中第一个产物 +- 用于把构建产物嵌入营销页 / 静态主页 + +### 18.4 在浏览器调试 SSE + +LangGraph SDK 走标准 SSE。开发期通过 Chrome DevTools `Network -> EventSource` 可以直接查看 frames。失败原因(如 422)可在 sonner toast 看到 `getStreamErrorMessage()` 的解析结果。 + +--- + +## 19. 常见开发任务指南 + +### 19.1 新增一个聊天页内的子组件 + +1. 在 `src/components/workspace/` 下建目录与 `index.ts` +2. 通过 `useThread()`(来自 `messages/context.ts`)拿到当前 `thread` +3. 业务展示用 `` / `` 现有组件,避免自行 Markdown 解析 +4. 若依赖产物,使用 `useArtifacts()` + +### 19.2 新增一个 LangGraph Assistant(自定义 Agent) + +1. 后端注册新的 graph +2. 前端 `core/agents/api.ts` 增 CRUD 接口;`hooks.ts` 增 `useAgents/useAgent/useCreateAgent` +3. 路由:复用 `AgentChatPage`,URL `/page/workspace/agents/:agent_id/chats/:thread_id` +4. 提交时只需把 `assistantId` 传给 `useThreadStream`(参考 AgentChatPage 中传 `agent_id` 给后端 graph 的方式) + +### 19.3 新增一个领域 API + +1. 在 `core//` 建 `api.ts/hooks.ts/types.ts/index.ts` +2. `api.ts` 函数全部使用 `apiFetch(`${getBackendBaseURL()}/api/...`)` +3. `hooks.ts` 用 `useQuery({ queryKey: ["", ...] })` / `useMutation({ onSuccess: () => invalidateQueries })` +4. 在使用方 import `useXxx` 即可 + +### 19.4 新增一个全局快捷键 + +```tsx +useGlobalShortcuts([ + { key: "k", meta: true, action: openCommandPalette }, + { key: "/", meta: false, shift: true, action: showHelp }, +]); +``` + +### 19.5 修改本地偏好 + +```tsx +const [settings, setSettings] = useLocalSettings(); +setSettings("display", { streamSpeedMeterEnabled: false }); +``` + +线程级: + +```tsx +const [settings, setSettings] = useThreadSettings(threadId); +setSettings("context", { model_name: "gpt-5" }); // 仅当前线程 +``` + +### 19.6 新增 i18n 词条 + +1. 在 [core/i18n/translations.ts](../src/core/i18n/translations.ts) 与 `locales/` 下补充 +2. `useI18n().t.xxx.yyy` 使用 +3. 不要硬编码中英文文案到组件里 + +--- + +## 20. 性能与稳定性约定 + +- **TanStack Query**:键统一以「领域 + 操作 + params」组合(如 `["threads", "search", params]`、`["agents", id]`);mutate 之后 invalidate;写时支持 `setQueriesData` 立即生效更新缓存(参考 `useDeleteThread`/`useRenameThread`) +- **流式断线续连**:保持 `useThreadStream` 的 `reconnectOnMount` 启用(已默认);切换线程时会 reset `runMetadataStorageRef` 以确保不串 +- **乐观更新**:发送 message 立即出现 human 气泡,避免空白等待;附件场景额外提供 uploadingFiles 占位 +- **避免重复发送**:`sendInFlightRef` 在 `useThreadStream` 里做单飞锁 +- **不阻塞主线程**:长文 markdown 拆词、渲染时分批;`use-stick-to-bottom` 自动暂停跟随 +- **大依赖懒加载**:建议对低频路由(如 `Open Canvas`、`RoundtablePlanning`)使用 `React.lazy` + `Suspense`(当前已大致按模块切分,文件级可继续优化) +- **构建内存**:`pnpm build` 已 `--max-old-space-size=8192`,新增大依赖请关注 dist 体积与构建时长 + +--- + +## 21. 部署 + +- 默认构建产物 `dist/`,因 `base: "./"` 可直接置于任何静态文件路径 +- HashRouter 不需要后端 fallback,但建议把所有未匹配请求回退到 `index.html`,便于直接访问深链 +- 后端地址通过环境变量在 build-time 注入(Vite 把 `VITE_*` 内联到 bundle 中),不同环境请构建不同产物或使用窗口注入的方式 +- 内网部署时常配合 Nginx 反代 `/api -> 后端 Gateway`、`/api/langgraph -> LangGraph Server`,让前端使用同源相对路径无需额外 CORS + +--- + +## 22. 已知历史包袱与注意事项 + +1. **HashRouter + replaceState**:新建线程时 `history.replaceState` 直接改写 hash,不再触发 router 渲染。若以后改用 BrowserRouter,需要重写此处(参考 [ChatPage.tsx:75-79](../src/pages/ChatPage.tsx#L75-L79) 与 [chats/use-thread-chat.ts](../src/components/workspace/chats/use-thread-chat.ts) 的兜底) +2. **`"use client"` 指令**:迁移自 Next.js,在 Vite 中无意义但保留可读性,新文件可省略 +3. **Next.js shim**:尽量使用 `useRouteNavigate()`([core/route-base.tsx](../src/core/route-base.tsx))代替原始的 `useRouter().push("/page/workspace/...")`,因为后者依赖 base 替换,与你所在的路由模块强相关 +4. **混合 jsx/tsx**:`strategy-components/**` 内仍有 `.jsx`,新增请直接用 `.tsx` 并补全类型 +5. **TDesign**:仅在少量页面被使用,避免与 shadcn UI 风格混用;优先复用 `components/ui/` +6. **Open Canvas / Roundtable**:各自有独立的 Stream/Thread Context 与 API;不要直接在它们里复用主聊天的 `useThreadStream`,反之亦然 +7. **生产模式默认是 light,但 `index.html` 早期脚本默认 dark**:如果改默认主题,要同时改 [App.tsx:29](../src/App.tsx#L29) 的 `defaultTheme` 和 [index.html](../index.html) 的脚本 + +--- + +## 23. 速查表 + +| 我想… | 去哪里看 / 怎么写 | +|---|---| +| 改后端地址 | `.env.development[.local]` 中的 `VITE_BACKEND_BASE_URL` / `VITE_LANGGRAPH_BASE_URL` | +| 新增路由 | `pages/WorkspaceRoutes.tsx`(工作台)或 `pages/PageRoutes.tsx`(一级) | +| 发起 GET/POST | `apiFetch(`${getBackendBaseURL()}/api/...`, { method, body })` | +| 发起 SSE 聊天 | `useThreadStream({ threadId, context, ... })` | +| 跨标签同步偏好 | `useLocalSettings()` / `useThreadSettings(threadId)` | +| 弹 toast | `import { toast } from "sonner"; toast.success("done")` | +| 弹浏览器通知 | `useNotification().showNotification(title, { body })` | +| 国际化 | `const { t } = useI18n(); t.common.loading` | +| 合并 className | `cn("base", cond && "x-y")` | +| 获取登录态 | `getStoredAuth()` / `getAccessToken()` | +| 命令面板 | `⌘K` 唤起 `` | + +--- + +## 24. 后续建议 + +- **统一目录命名**:`workspace/messages/`、`workspace/artifacts/`、`workspace/chats/` 现都用 kebab-case 文件夹 + kebab-case 文件名;建议在新增文件时延续此约定 +- **抽离配置常量**:与服务端契约相关的硬编码(如 `assistantId = "lead_agent"`)建议集中到 `core/config/` +- **测试**:当前缺单测;优先为 `core/threads/hooks.ts` 的 `normalizeStoredRunId` 等纯函数补 vitest +- **包体积**:landing 视觉特效(`ogl`、`gsap`、`canvas-confetti`、`embla-carousel`)较重,可考虑路由级 code-split +- **lint/format**:建议引入 ESLint + Prettier + lint-staged,统一团队风格 + +--- + +## 25. 页面清单(Page Catalog) + +> 本节按一级路径分组,列出 [src/pages/](../src/pages/)、[src/open-canvas/pages/](../src/open-canvas/pages/) 与 [src/roundtable-planning/pages/](../src/roundtable-planning/pages/) 下的每一个页面:**做什么** + **挂载在哪里** + **关键依赖与组件**。 + +### 25.1 顶层路由(无 `/page` 前缀) + +#### LandingPage —— `/landing` + +- 文件:[LandingPage.tsx](../src/pages/LandingPage.tsx) +- 作用:营销/介绍主页,深色背景;与登录后的工作台无关 +- 强制 `light` 主题被 [App.tsx:23](../src/App.tsx#L23) 关掉,单独使用 `bg-[#0a0a0a]` +- 包含组件: + - [Header](../src/components/landing/header.tsx)、[Footer](../src/components/landing/footer.tsx) + - [Hero](../src/components/landing/hero.tsx) —— 首屏 Slogan + CTA + - [CaseStudySection](../src/components/landing/sections/case-study-section.tsx) —— 案例 + - [SkillsSection](../src/components/landing/sections/skills-section.tsx) + [ProgressiveSkillsAnimation](../src/components/landing/progressive-skills-animation.tsx) + - [SandboxSection](../src/components/landing/sections/sandbox-section.tsx) + [Terminal](../src/components/ui/terminal.tsx) + - [WhatsNewSection](../src/components/landing/sections/whats-new-section.tsx) + [PostList](../src/components/landing/post-list.tsx) + - [CommunitySection](../src/components/landing/sections/community-section.tsx) +- 视觉特效依赖:`aurora-text`、`flickering-grid`、`magic-bento`、`spotlight-card`、`shine-border`、`confetti-button`、`number-ticker`、`word-rotate`、`gsap`、`ogl`、`canvas-confetti`、`embla-carousel` + +#### LoginPage —— `/login`、`/login/:username` + +- 文件:[LoginPage.tsx](../src/pages/LoginPage.tsx) +- 作用:以用户名直登(默认 `guest`),从 query 解析 `pageSidebarEnabled` 偏好,登录成功后写入 `localStorage["deerflow.auth"]` 并跳到 `/page/workspace/chats/new` +- 关键依赖:[loginByUsername](../src/core/auth/api.ts)、[setStoredAuth](../src/core/auth/index.ts)、[persistSidebarEnabled](../src/core/page-layout/sidebar-enabled.ts) +- UI:极简卡片(Tailwind),仅在 `error` 时显示失败原因 + +#### PublicSharePage —— `/share/:shareCode` + +- 文件:[PublicSharePage.tsx](../src/pages/PublicSharePage.tsx) +- 作用:通过分享码免登录查看一段会话快照(含产物面板) +- 数据:[getPublicShareSnapshot](../src/core/thread-shares/api.ts) +- 内部布局:自建左右双栏(不依赖 WorkspaceLayout) + - 左:[MessageList](../src/components/workspace/messages/message-list.tsx) + - 右:[ArtifactFileDetail](../src/components/workspace/artifacts/artifact-file-detail.tsx)(点击文件后才显示) +- 自带 `` + `` + ``,用快照模拟 `BaseStream` + +#### PublicScheduledTaskPage —— `/public/scheduled-tasks/:taskId` + +- 文件:[PublicScheduledTaskPage.tsx](../src/pages/PublicScheduledTaskPage.tsx) +- 作用:免登录查看「已发布」的定时任务详情、最近运行结果、可下载文件 +- 包含组件:`Card`、`Tabs`、`Dialog`、`Tooltip`、`Badge`、[MarkdownContent](../src/components/workspace/messages/markdown-content.tsx)、[downloadMarkdownAsDocx](../src/core/scheduled-tasks/markdown-docx.ts) + +#### NotFoundPage —— `*` + +- 文件:[NotFoundPage.tsx](../src/pages/NotFoundPage.tsx) +- 作用:404 兜底,给一个 "Open workspace" 链接回到 `/page/workspace/chats/new` + +### 25.2 `/page/workspace/*`(工作台主区) + +> 所有页面外层套 ``(侧栏 + 内容区)。聊天类页面再多一层 ``(SubtasksProvider/ArtifactsProvider/PromptInputProvider)。 + +#### ChatsPage —— `/page/workspace/chats` + +- 文件:[ChatsPage.tsx](../src/pages/ChatsPage.tsx) +- 作用:会话列表与搜索;可触发「导入聊天记录」对话框 +- 数据:[useThreads()](../src/core/threads/hooks.ts) —— 过滤掉 `thread_type === "scheduler"` +- 包含组件: + - [WorkspaceContainer/Header/Body](../src/components/workspace/workspace-container.tsx) + - `Input`(搜索)、`Button`、`ScrollArea`、`Link` + - [ImportConversationsDialog](../src/components/workspace/thread-share-dialogs.tsx) +- 工具:`titleOfThread`、`usePathOfThread`、`formatTimeAgo`、`desensitize` + +#### ChatPage —— `/page/workspace/chats/:thread_id` + +- 文件:[ChatPage.tsx](../src/pages/ChatPage.tsx) +- 作用:主聊天页(绑定 `assistantId="lead_agent"`),支持新会话与已有会话;首条消息时若选中了 Agent,会跳转到 AgentChatPage +- Hook:[useThreadStream](../src/core/threads/hooks.ts)、[useThreadChat](../src/components/workspace/chats/use-thread-chat.ts)、[useThreadSettings](../src/core/settings/hooks.ts)、[useModels](../src/core/models/hooks.ts)、[useArtifacts](../src/components/workspace/artifacts/context.tsx)、[usePromptPrefixSettings](../src/core/system-settings/hooks.ts) +- 主要组件: + - [ChatBox](../src/components/workspace/chats/chat-box.tsx)(左聊天 / 右产物 双栏) + - [MessageList](../src/components/workspace/messages/message-list.tsx) + - [InputBox](../src/components/workspace/input-box.tsx) + [AgentSelector](../src/components/workspace/agents/agent-selector.tsx) + - [ThreadTitle](../src/components/workspace/thread-title.tsx) + - [TodoList](../src/components/workspace/todo-list.tsx) + - [TokenUsageIndicator](../src/components/workspace/token-usage-indicator.tsx) + - [ExportTrigger](../src/components/workspace/export-trigger.tsx)、[ArtifactTrigger](../src/components/workspace/artifacts/artifact-trigger.tsx) + - [Welcome](../src/components/workspace/welcome.tsx)(落地态欢迎) + +#### AgentChatPage —— `/page/workspace/agents/:agent_id/chats/:thread_id` + +- 文件:[AgentChatPage.tsx](../src/pages/AgentChatPage.tsx) +- 作用:与自定义 Agent 对话。`assistantId=agent_id`,context 注入 `agent_id`。会消费 `sessionStorage["pendingAgentMessage"]` 来自动发送 ChatPage 转交的首条消息 +- Hook:与 ChatPage 类似,但额外 `useAgent(agent_id)` +- 关键组件:与 ChatPage 同一套;顶栏额外渲染 Agent 名称 Badge;落地态使用 [AgentWelcome](../src/components/workspace/agent-welcome.tsx) + +#### AgentsPage —— `/page/workspace/agents` + +- 文件:[AgentsPage.tsx](../src/pages/AgentsPage.tsx)(薄壳,直接渲染 [AgentGallery](../src/components/workspace/agents/agent-gallery.tsx)) +- 作用:Agent 画廊;含分类、标签、特色、搜索、最近使用 +- 子组件:[AgentCard](../src/components/workspace/agents/agent-card.tsx)、[AgentManageDialog](../src/components/workspace/agents/agent-manage-dialog.tsx)、[TagSearchBar](../src/components/workspace/tags/tag-search-bar.tsx)、[TagPickerDialog](../src/components/workspace/tags/tag-picker-dialog.tsx)、[TagChipList](../src/components/workspace/tags/tag-chip.tsx) + +#### NewAgentPage —— `/page/workspace/agents/new` + +- 文件:[NewAgentPage.tsx](../src/pages/NewAgentPage.tsx) +- 作用:以「对话」的方式引导用户创建 Agent —— 复用 lead_agent 的 `is_bootstrap=true` 模式 + `SetupAgentApprovalMiddleware` 中拦截的 `setup_agent` 工具调用,让 LLM 给出 setup_agent 调用并由用户点「批准」最终落库 +- Hook:[useThreadStream](../src/core/threads/hooks.ts) + [useSkills](../src/core/skills/hooks.ts) + [useModels](../src/core/models/hooks.ts) + [checkAgentName/createAgent/updateAgent](../src/core/agents/api.ts) +- 组件: + - 左:表单(名称/描述/灵魂 prompt/模型/技能多选) + - 右:`` + ``(带 SetupAgentApprovalPayload 审批 UI) + - [SkillDetailDialog](../src/components/workspace/skills/skill-detail-dialog.tsx) + - 包了 `` + `` 与 ChatPage 同源 + +#### ScheduledTasksPage —— `/page/workspace/scheduled/tasks` + +- 文件:[ScheduledTasksPage.tsx](../src/pages/ScheduledTasksPage.tsx) +- 作用:当前用户的定时任务列表 + Cron 配置 + 已发布任务/可订阅、运行历史快览 +- Hook:[useScheduledTasks](../src/core/scheduled-tasks/hooks.ts)、`useScheduledTaskRuns`、`useScheduledTaskActions`、`usePublishedScheduledTasks`、`useScheduledTaskDeliveryProfile`、[useAgents](../src/core/agents/index.ts) +- 关键组件: + - [WorkspaceContainer/Header/Body](../src/components/workspace/workspace-container.tsx) + - [ScheduleBuilder](../src/components/workspace/scheduled-tasks/schedule-builder.tsx) + `buildCron/parseCron` + - [TagChipList](../src/components/workspace/tags/tag-chip.tsx) + [TagPickerDialog](../src/components/workspace/tags/tag-picker-dialog.tsx) + [TagSearchBar](../src/components/workspace/tags/tag-search-bar.tsx) + - `Card / Dialog / Tabs / Select / Switch / Textarea / Badge / Button` + +#### ScheduledTaskRunDetailPage —— `/page/workspace/scheduled/runs/:run_id` + +- 文件:[ScheduledTaskRunDetailPage.tsx](../src/pages/ScheduledTaskRunDetailPage.tsx) +- 作用:单次定时任务运行的详情;展示消息时间线、产物文件、状态、可重命名/删除/导出 docx +- Hook:[useScheduledTaskRun](../src/core/scheduled-tasks/hooks.ts)、`useScheduledTaskRunMessages`、`useScheduledTaskActions`、`getScheduledTaskRunFileContent`、`updateScheduledTaskRunFile`、`downloadScheduledTaskRunFile` +- 组件:`Tabs`、`Card`、[MarkdownContent](../src/components/workspace/messages/markdown-content.tsx)、`Textarea`(编辑结果文件) + +#### ScheduledChatPage(未在路由中显式注册,但留作入口跳转) + +- 文件:[ScheduledChatPage.tsx](../src/pages/ScheduledChatPage.tsx) +- 作用:从 `useSchedulerThread()` 拿到「调度器助手」线程 id 后立即 `Navigate` 到 `/page/workspace/chats/`,相当于一个智能跳板 +- 组件:[WorkspaceContainer/Body](../src/components/workspace/workspace-container.tsx) + Loading 提示文案 + +#### SkillsPage —— `/page/workspace/skills` + +- 文件:[SkillsPage.tsx](../src/pages/SkillsPage.tsx)(薄壳)→ [SkillSettingsPage](../src/components/workspace/settings/skill-settings-page.tsx) +- 作用:技能开关 + MCP Server 编辑入口 +- 子组件:[McpServerEditDialog](../src/components/workspace/settings/mcp-server-edit-dialog.tsx)、[SkillStateSheet](../src/components/workspace/settings/skill-state-sheet.tsx) + +#### SkillEvolutionPage —— `/page/workspace/skills/evolution` + +- 文件:[SkillEvolutionPage.tsx](../src/pages/SkillEvolutionPage.tsx)(薄壳)→ [SkillEvolutionPanel](../src/components/workspace/skills/skill-evolution-panel.tsx) +- 作用:技能演化(实验/进化曲线)展示 + +#### MemoryPage —— `/page/workspace/memory` + +- 文件:[MemoryPage.tsx](../src/pages/MemoryPage.tsx)(薄壳)→ [MemorySettingsPage](../src/components/workspace/settings/memory-settings-page.tsx) +- 作用:当前用户的记忆桶(事实/偏好/项目笔记)管理 +- 依赖 [core/memory/api.ts](../src/core/memory/api.ts) + `hooks.ts` + +#### AIWritingPage —— `/page/workspace/ai-writing` + +- 文件:[open-canvas/pages/AIWritingPage.tsx](../src/open-canvas/pages/AIWritingPage.tsx)(带 `hideAdvancedFields`) +- 作用:AI 写作主界面(左草稿/中聊天/右时间线),WorkspaceLayout 内嵌入 +- 见 §25.4 详解 + +#### RoundtablePlanningPage —— `/page/workspace/roundtable/planning` + +- 文件:[roundtable-planning/pages/RoundtablePlanningPage.tsx](../src/roundtable-planning/pages/RoundtablePlanningPage.tsx) +- 作用:圆桌规划三步流程(任务理解 → 编排 → 高保真报告) +- 见 §25.5 详解 + +### 25.3 管理员页(`/page/workspace/admin/*` 与 `settings/*`) + +> 路由元素层做了 `getStoredAuth()?.system_role === "admin"` 判断,非管理员被重定向到 `chats/new`。 + +#### AdminUserHubPage —— `/page/workspace/admin` + +- 文件:[AdminUserHubPage.tsx](../src/pages/AdminUserHubPage.tsx)(薄壳)→ [AdminUserHub](../src/components/workspace/admin/admin-user-hub.tsx) +- 作用:统一的「用户中心」聚合页(用户列表、活动、记忆查看),替代旧的多分支管理路由 +- 旧路径 `admin/users` / `admin/user-activity` / `admin/memory` 均 `Navigate` 到此页 + +#### AdminScheduledTasksPage —— `/page/workspace/admin/scheduled-tasks` + +- 文件:[AdminScheduledTasksPage.tsx](../src/pages/AdminScheduledTasksPage.tsx) +- 作用:全平台定时任务总览、暂停/恢复/编辑/删除/导出 +- Hook:[useAllScheduledTasks](../src/core/scheduled-tasks/hooks.ts)、`useAdminScheduledTaskActions`、`useAdminScheduledTaskRuns` +- 组件:[WorkspaceContainer/Header/Body](../src/components/workspace/workspace-container.tsx)、[ScheduleBuilder](../src/components/workspace/scheduled-tasks/schedule-builder.tsx)、`Card`、`Dialog`、`Badge`、`Input`、`Textarea` + +#### AdminUserActivityPage(路由已并入 AdminUserHub,但文件保留) + +- 文件:[AdminUserActivityPage.tsx](../src/pages/AdminUserActivityPage.tsx) +- 作用:按用户钻取其线程与消息(独立窗口模式) +- Hook:[useAllUsers](../src/core/auth/hooks.ts)、[useUserMemory / useUserThreadMessages / useUserThreads](../src/core/admin/hooks.ts) +- 组件:`Tabs`、`Input`、`ScrollArea`、`Badge`、左右两栏布局 + +#### AdminMemoryPage(已并入 Hub) + +- 文件:[AdminMemoryPage.tsx](../src/pages/AdminMemoryPage.tsx)(薄壳)→ [admin-memory-page.tsx](../src/components/workspace/admin/admin-memory-page.tsx) + +#### UsersPage(已并入 Hub) + +- 文件:[UsersPage.tsx](../src/pages/UsersPage.tsx)(薄壳)→ [UserManagementPage](../src/components/workspace/admin/user-management-page.tsx) +- 作用:账号管理(系统角色变更、密码重置等) + +#### TagsPage —— `/page/workspace/admin/tags` + +- 文件:[TagsPage.tsx](../src/pages/TagsPage.tsx)(薄壳)→ [TagManagementPage](../src/components/workspace/admin/tag-management-page.tsx) +- 作用:全局标签 CRUD(颜色、可见性、排序) + +#### LlmMetricsPage —— `/page/workspace/settings/llm-metrics` + +- 文件:[LlmMetricsPage.tsx](../src/pages/LlmMetricsPage.tsx) +- 作用:LLM 调用计量与工具计量 +- Hook:[useLlmMetrics](../src/core/llm-metrics/hooks.ts)、`downloadLlmMetricsExcel` +- 组件:`Tabs`、`Input`、`Select`、`Badge`、[ToolMetricsPanel](../src/components/workspace/admin/tool-metrics-panel.tsx) + +#### RecommendedQuestionsPage —— `/page/workspace/settings/recommended-questions` + +- 文件:[RecommendedQuestionsPage.tsx](../src/pages/RecommendedQuestionsPage.tsx) +- 作用:管理欢迎页/新会话落地的「推荐问题」(增删改、拖拽排序) +- Hook:[useRecommendedQuestions / useCreateRecommendedQuestion / useUpdateRecommendedQuestion / useDeleteRecommendedQuestion / useReorderRecommendedQuestions](../src/core/recommended-questions/hooks.ts) +- 组件:`Dialog`、`Input`、`Textarea`、`Button` + 自定义拖拽(HTML5 DnD) + +#### PromptPrefixSettingsPage —— `/page/workspace/settings/prompt-prefix` + +- 文件:[PromptPrefixSettingsPage.tsx](../src/pages/PromptPrefixSettingsPage.tsx) +- 作用:全局「前缀模式」开关、开关名称、prompt 内容;用户在新会话首轮可勾选附加该 prefix +- Hook:[usePromptPrefixSettings / useUpdatePromptPrefixSettings](../src/core/system-settings/hooks.ts) +- 组件:`Switch`、`Input`、`Textarea`、`Button` + +### 25.4 `/page/canvas/*`(开放画布 / AI 写作) + +> 由 [OpenCanvasRoutes.tsx](../src/open-canvas/routes/OpenCanvasRoutes.tsx) 注册,不在 WorkspaceLayout 内(直接挂在 PageRoutes 内容区)。 + +#### CanvasHomePage —— `/page/canvas` + +- 文件:[open-canvas/pages/CanvasHomePage.tsx](../src/open-canvas/pages/CanvasHomePage.tsx) +- 作用:画布首页,选择「新建文本文档」或「新建代码」→ 调 [createCanvasThread](../src/open-canvas/api/threads.ts) → 跳转到 `:thread_id` +- 组件:`Button` + Lucide 图标(`FileText/Code2/Sparkles/PenLine`) + +#### CanvasThreadPage —— `/page/canvas/:thread_id` + +- 文件:[open-canvas/pages/CanvasThreadPage.tsx](../src/open-canvas/pages/CanvasThreadPage.tsx) +- 作用:画布工作区,左右分栏:聊天 + 富文本/代码编辑 +- Provider 栈:[CanvasThreadProvider](../src/open-canvas/contexts/CanvasThreadContext.tsx) → [CanvasGraphProvider](../src/open-canvas/contexts/CanvasGraphContext.tsx) → [CanvasUIProvider](../src/open-canvas/contexts/CanvasUIContext.tsx) +- 子组件:[CanvasShell](../src/open-canvas/components/layout/CanvasShell.tsx) + - [CanvasHeader](../src/open-canvas/components/layout/CanvasHeader.tsx) + - [CanvasResizableLayout](../src/open-canvas/components/layout/CanvasResizableLayout.tsx) + - [CanvasChatPanel](../src/open-canvas/components/chat/CanvasChatPanel.tsx) + - [CanvasArtifactPanel](../src/open-canvas/components/artifact/CanvasArtifactPanel.tsx) + 各 renderer: + - [TextArtifactRenderer](../src/open-canvas/components/artifact/TextArtifactRenderer.tsx) + - [CodeArtifactRenderer](../src/open-canvas/components/artifact/CodeArtifactRenderer.tsx) + - [TiptapEditor](../src/open-canvas/editor/TiptapEditor.tsx) + [WritingBubbleMenu](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) / [WritingParagraphMenu](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx) / [WritingAiMenu](../src/open-canvas/components/artifact/WritingAiMenu.tsx) / [WritingToolbar](../src/open-canvas/components/artifact/WritingToolbar.tsx) / [WritingRewritePreview](../src/open-canvas/components/artifact/WritingRewritePreview.tsx) + - [CanvasQuickActionBar](../src/open-canvas/components/artifact/CanvasQuickActionBar.tsx) / [CanvasArtifactVersionNav](../src/open-canvas/components/artifact/CanvasArtifactVersionNav.tsx) / [CanvasEmptyState](../src/open-canvas/components/artifact/CanvasEmptyState.tsx) + - [CanvasModelSelector](../src/open-canvas/components/selectors/CanvasModelSelector.tsx) +- Hook:[useCanvasStream](../src/open-canvas/hooks/useCanvasStream.ts)、`useCanvasArtifact`、`useCanvasThread`、`useCanvasQuickActions`、`useCanvasSelection`、`useWritingEditor`、`useDocumentOutline`、`useWritingRewrite` +- 与主聊天的隔离:使用 Canvas 自有的 LangGraph base URL(可选)、独立 thread/run API、独立编辑器扩展([AiHighlightExtension](../src/open-canvas/editor/AiHighlightExtension.ts)) + +#### AIWritingPage —— `/page/canvas/ai-writing` 与 `/page/workspace/ai-writing` + +- 文件:[open-canvas/pages/AIWritingPage.tsx](../src/open-canvas/pages/AIWritingPage.tsx) +- 作用:受控的「AI 写作流程」 —— 启动写作 → SSE 进度 → 用户干预(intervention)→ 继续 → 评审 → 导出 docx +- 子组件: + - [AIWritingPageHeader](../src/open-canvas/components/ai-writing/AIWritingPageHeader.tsx) + - [AIWritingForm](../src/open-canvas/components/ai-writing/AIWritingForm.tsx) + - [AIWritingDraftPanel](../src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx) + [AIWritingPanel](../src/open-canvas/components/ai-writing/AIWritingPanel.tsx) + - [AIWritingTimeline](../src/open-canvas/components/ai-writing/AIWritingTimeline.tsx) + [TimelineStep](../src/open-canvas/components/ai-writing/TimelineStep.tsx) + - [AIWritingHistoryDropdown](../src/open-canvas/components/ai-writing/AIWritingHistoryDropdown.tsx) + [AIWritingHistoryView](../src/open-canvas/components/ai-writing/AIWritingHistoryView.tsx) + - [IntentStreamingView](../src/open-canvas/components/ai-writing/IntentStreamingView.tsx) / [IntentResultCard](../src/open-canvas/components/ai-writing/IntentResultCard.tsx) + - [OutlineStreamingView](../src/open-canvas/components/ai-writing/OutlineStreamingView.tsx) / [OutlineCard](../src/open-canvas/components/ai-writing/OutlineCard.tsx) / [OutlineSection](../src/open-canvas/components/ai-writing/OutlineSection.tsx) + - [DraftStreamingView](../src/open-canvas/components/ai-writing/DraftStreamingView.tsx) + - [ReviewStreamingView](../src/open-canvas/components/ai-writing/ReviewStreamingView.tsx) / [ReviewCard](../src/open-canvas/components/ai-writing/ReviewCard.tsx) / [ScoreBar](../src/open-canvas/components/ai-writing/ScoreBar.tsx) / [IssueList](../src/open-canvas/components/ai-writing/IssueList.tsx) + - [InterventionCard](../src/open-canvas/components/ai-writing/InterventionCard.tsx) / [CompletedInterventionCard](../src/open-canvas/components/ai-writing/CompletedInterventionCard.tsx) / [RevisionSummaryCard](../src/open-canvas/components/ai-writing/RevisionSummaryCard.tsx) + - [MaterialsToolbar](../src/open-canvas/components/ai-writing/MaterialsToolbar.tsx) / [MaterialsCard](../src/open-canvas/components/ai-writing/MaterialsCard.tsx) / [MaterialItem](../src/open-canvas/components/ai-writing/MaterialItem.tsx) + - [DraftOutlineSidebar](../src/open-canvas/components/artifact/DraftOutlineSidebar.tsx) / [WritingSkeletonCard](../src/open-canvas/components/ai-writing/WritingSkeletonCard.tsx) / [CollapseTransition](../src/open-canvas/components/ai-writing/CollapseTransition.tsx) +- Hook:[useAIWritingSessions](../src/open-canvas/hooks/useAIWritingSessions.ts)、[useAIWritingStream](../src/open-canvas/hooks/useAIWritingStream.ts)、[useArticleTypes](../src/open-canvas/hooks/useArticleTypes.ts)、[useWritingRewrite](../src/open-canvas/hooks/useWritingRewrite.ts)、[useDocumentOutline](../src/open-canvas/hooks/useDocumentOutline.ts) +- API:[ai-writing-sessions.ts](../src/open-canvas/api/ai-writing-sessions.ts)、[rewrite.ts](../src/open-canvas/api/rewrite.ts)、[writing-export.ts](../src/open-canvas/api/writing-export.ts)、[ai-writing-article-types.ts](../src/open-canvas/api/ai-writing-article-types.ts) +- 文件头有大段 Docker 内网部署排错指南(transcript 500 / resume 400 / 会话丢失),新成员部署前请先读 + +#### ArticleTypesConfigPage —— `/page/canvas/article-types` + +- 文件:[open-canvas/pages/ArticleTypesConfigPage.tsx](../src/open-canvas/pages/ArticleTypesConfigPage.tsx) +- 作用:AI 写作「文章类型」配置(标题/描述/默认 prompt 等) +- Hook:[useArticleTypes](../src/open-canvas/hooks/useArticleTypes.ts) +- 组件:`Button`、`Separator`、`Dialog` + 自定义 input/textarea 样式常量 + +### 25.5 `/page/roundtable/*`(圆桌规划) + +#### RoundtablePlanningPage —— `/page/roundtable/planning` + +- 文件:[roundtable-planning/pages/RoundtablePlanningPage.tsx](../src/roundtable-planning/pages/RoundtablePlanningPage.tsx) +- 作用:三步式多 Agent 圆桌规划(任务理解 → 编排 → 高保真报告) +- Hook: + - [useStep1Intent](../src/roundtable-planning/hooks/useStep1Intent.ts) —— 任务理解、INTENT_READY、澄清卡 + - [useStep2Orchestration](../src/roundtable-planning/hooks/useStep2Orchestration.ts) —— 编排循环、对话流、Persona 覆盖、澄清回复 + - [useDraftPersistence](../src/roundtable-planning/hooks/useDraftPersistence.ts) —— 本地草稿(自动保存/手动/加载/重命名/删除) + - [useAutoScroll](../src/roundtable-planning/hooks/useAutoScroll.ts) +- 组件: + - [Step1Panel](../src/roundtable-planning/components/Step1Panel.tsx) / [Step2Panel](../src/roundtable-planning/components/Step2Panel.tsx) / [Step3Panel](../src/roundtable-planning/components/Step3Panel.tsx) + - [PersonaDrawer](../src/roundtable-planning/components/PersonaDrawer.tsx) + - [Avatars](../src/roundtable-planning/components/Avatars.tsx)(含 BUILTIN_AGENT_META) + - [MessageBubble](../src/roundtable-planning/components/MessageBubble.tsx) / [MessageStepsCard](../src/roundtable-planning/components/MessageStepsCard.tsx) + - [IntentClarificationCard](../src/roundtable-planning/components/IntentClarificationCard.tsx) + - [RecommendAgentsDialog](../src/roundtable-planning/components/RecommendAgentsDialog.tsx) + - [HighFidelityReport](../src/roundtable-planning/components/HighFidelityReport.tsx) —— Step3 报告呈现 + - [ArtifactPreviewModal](../src/roundtable-planning/components/ArtifactPreviewModal.tsx) + - [ScrollToBottomButton](../src/roundtable-planning/components/ScrollToBottomButton.tsx) +- API:[intent.ts](../src/roundtable-planning/api/intent.ts) / [multi-agent.ts](../src/roundtable-planning/api/multi-agent.ts) / [recommend.ts](../src/roundtable-planning/api/recommend.ts) +- 工具:[clarification.ts](../src/roundtable-planning/lib/clarification.ts)、[step-display.tsx](../src/roundtable-planning/lib/step-display.tsx)、[reasoning.ts](../src/roundtable-planning/lib/reasoning.ts)、[roundtable-constants.ts](../src/roundtable-planning/lib/roundtable-constants.ts)、[drafts.ts](../src/roundtable-planning/utils/drafts.ts) +- 注意(来自文件头注释): + 1. special 顺序派活,**不要**改回 `Promise.all` + 2. `step1InitGenRef / step2InitRef` 闸门用于规避 StrictMode 双 mount 副作用 + 3. 离开 Step 2 不销毁 threadIds,允许返回继续看 + 4. 草稿自动保存仅在 1→2、2→3 前进时触发 + 5. Persona 抽屉改的 model 不立即重启编排,下一轮才生效 + 6. 默认 `availableModels[0].name`,Persona 覆盖更高优先级 + +### 25.6 `/page/strategy/*`(策略路由) + +> [StrategyRoutes.tsx](../src/pages/StrategyRoutes.tsx) 复用了 WorkspaceRoutes 内大多数页面,**不带** WorkspaceLayout/PageSidebar,由 SidebarProvider 单独维持。`` 让 `useRouter` 跳转保持在策略前缀下。 + +- `qa/general` → [GeneralQA](../src/strategy-components/components/qa/GeneralQA.tsx)(综合问答中心) + - 子模块:[AgentQA](../src/strategy-components/components/qa/AgentQA.tsx)、[CollaborativeQA](../src/strategy-components/components/qa/CollaborativeQA.tsx)、[MopeChat](../src/strategy-components/components/qa/MopeChat.tsx)、[QA](../src/strategy-components/components/qa/QA.tsx)、[QACenterPage](../src/strategy-components/components/qa/QACenterPage.tsx)、[QACommon](../src/strategy-components/components/qa/QACommon.tsx)、[QALayout](../src/strategy-components/components/qa/QALayout.tsx)、[QATypeSwitch](../src/strategy-components/components/qa/QATypeSwitch.tsx)、[Template6BEditor](../src/strategy-components/components/qa/Template6BEditor.tsx)、[TemplateEditor](../src/strategy-components/components/qa/TemplateEditor.tsx)、[GeneralQAConfigSidebar](../src/strategy-components/components/qa/GeneralQAConfigSidebar.tsx)、[GeneralQAHeader](../src/strategy-components/components/qa/GeneralQAHeader.tsx) + - 子组件:[AgentCard](../src/strategy-components/components/qa/components/AgentCard.tsx)、[ConfigPanel](../src/strategy-components/components/qa/components/ConfigPanel.tsx)、[FinalReport](../src/strategy-components/components/qa/components/FinalReport.tsx)、[MarkdownContent](../src/strategy-components/components/qa/components/MarkdownContent.tsx)、[ProgressSidebar](../src/strategy-components/components/qa/components/ProgressSidebar.tsx)、[ScenarioConfigPage](../src/strategy-components/components/qa/components/ScenarioConfigPage.tsx)、[Sidebar](../src/strategy-components/components/qa/components/Sidebar.tsx)、[Timeline](../src/strategy-components/components/qa/components/Timeline.tsx) +- `light-app/new-note` → [LightAppNewNotePage.tsx](../src/pages/LightAppNewNotePage.tsx) + - 作用:轻应用「新建笔记」入口;调用独立的笔记服务 `VITE_NOTE_API_BASE_URL`,使用 [useNoteAuth](../src/hooks/useNoteAuth.ts) 完成笔记服务自有的鉴权 + - API:[createSearchSpace](../src/core/note/api.ts) +- `light-app/notes` → [LightAppNoteListPage.tsx](../src/pages/LightAppNoteListPage.tsx) + - 作用:笔记列表,支持网格/列表视图切换 + - API:[listSearchSpaces](../src/core/note/api.ts) +- `light-app/note-qa` → [LightAppNoteQAPage.tsx](../src/pages/LightAppNoteQAPage.tsx) + - 作用:基于笔记的提问入口(UI 雏形,详细问答尚在开发) +- 其余路径(chats / agents / skills / memory / scheduled / settings / admin)**直接复用** WorkspaceRoutes 中的页面文件,仅 base 不同 + +### 25.7 路由与页面对照速查 + +| 一级路径 | 进入路由文件 | 主要页面文件 | +|---|---|---| +| `/login`、`/login/:username` | App.tsx | LoginPage.tsx | +| `/landing` | App.tsx | LandingPage.tsx | +| `/share/:shareCode` | App.tsx | PublicSharePage.tsx | +| `/public/scheduled-tasks/:taskId` | App.tsx | PublicScheduledTaskPage.tsx | +| `/page/workspace/*` | PageRoutes.tsx → WorkspaceRoutes.tsx | ChatsPage / ChatPage / AgentsPage / NewAgentPage / AgentChatPage / SkillsPage / SkillEvolutionPage / MemoryPage / ScheduledTasksPage / ScheduledTaskRunDetailPage / AdminUserHubPage / AdminScheduledTasksPage / TagsPage / RecommendedQuestionsPage / PromptPrefixSettingsPage / LlmMetricsPage | +| `/page/strategy/*` | PageRoutes.tsx → StrategyRoutes.tsx | GeneralQA + LightApp* + 复用 Workspace 页面 | +| `/page/canvas/*` | PageRoutes.tsx → open-canvas/routes/OpenCanvasRoutes.tsx | CanvasHomePage / CanvasThreadPage / AIWritingPage / ArticleTypesConfigPage | +| `/page/roundtable/*` | PageRoutes.tsx → RoundtableRoutes.tsx | RoundtablePlanningPage | +| `*` | App.tsx | NotFoundPage | + +### 25.8 "薄壳页面"与"重逻辑页面" + +| 页面 | 类型 | 真正实现位置 | +|---|---|---| +| AgentsPage | 薄壳 | components/workspace/agents/agent-gallery.tsx | +| AdminUserHubPage | 薄壳 | components/workspace/admin/admin-user-hub.tsx | +| AdminMemoryPage | 薄壳 | components/workspace/admin/admin-memory-page.tsx | +| UsersPage | 薄壳 | components/workspace/admin/user-management-page.tsx | +| TagsPage | 薄壳 | components/workspace/admin/tag-management-page.tsx | +| SkillsPage | 薄壳 | components/workspace/settings/skill-settings-page.tsx | +| SkillEvolutionPage | 薄壳 | components/workspace/skills/skill-evolution-panel.tsx | +| MemoryPage | 薄壳 | components/workspace/settings/memory-settings-page.tsx | +| 其余 | 重逻辑 | 直接在 pages/ 内实现 | + +> 维护建议:薄壳页面只负责 layout/insets,业务逻辑请始终下沉到 `components/workspace//` 下方便复用与测试。 + +--- + +> 本文档与代码同步演进。若发现内容与实现不符,请优先以代码为准并更新本文。 diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index 23924482..950ed302 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -82,6 +82,7 @@ export function MessageListItem({ threadId, tokenUsageEnabled = false, displaySources = [], + extraRehypePlugins, }: { className?: string; message: Message; @@ -89,6 +90,7 @@ export function MessageListItem({ threadId: string; tokenUsageEnabled?: boolean; displaySources?: RuntimeDisplaySource[]; + extraRehypePlugins?: ReturnType; }) { const isHuman = message.type === "human"; const messageMarkdown = @@ -107,6 +109,7 @@ export function MessageListItem({ threadId={threadId} tokenUsageEnabled={tokenUsageEnabled} displaySources={displaySources} + extraRehypePlugins={extraRehypePlugins} /> {!isLoading && ( ; }) { - const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); + const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); + const rehypePlugins = useMemo( + () => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins), + [baseRehypePlugins, extraRehypePlugins], + ); const isHuman = message.type === "human"; const resolveArtifact = useArtifactResolver(threadId); const components = useMemo( @@ -184,13 +193,13 @@ function MessageContent_({ img: (props: ImgHTMLAttributes) => ( ), - a: ({ href, ...props }: AnchorHTMLAttributes) => { - if (href?.startsWith("#reference-")) { - return ; - } - if (typeof props.children === "string" && /^citation:/i.test(props.children)) { - return ; - } + a: ({ href, ...props }: AnchorHTMLAttributes) => { + if (href?.startsWith("#reference-")) { + return ; + } + if (typeof props.children === "string" && /^citation:/i.test(props.children)) { + return ; + } if (href?.startsWith("/mnt/")) { const url = resolveArtifact(href); return ( @@ -291,21 +300,21 @@ function MessageContent_({ ); } - return ( - - {filesList} -
- -
- + {filesList} +
+ +
+
diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 0523c307..7b0a9a7a 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -1,5 +1,5 @@ -import type { Message } from "@langchain/langgraph-sdk"; -import type { BaseStream } from "@langchain/langgraph-sdk/react"; +import type { Message } from "@langchain/langgraph-sdk"; +import type { BaseStream } from "@langchain/langgraph-sdk/react"; import { CheckIcon, SendIcon, XIcon } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; @@ -9,7 +9,7 @@ import { } from "@/components/ai-elements/conversation"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; -import { useI18n } from "@/core/i18n/hooks"; +import { useI18n } from "@/core/i18n/hooks"; import { extractContentFromMessage, extractPresentFilesFromMessage, @@ -20,14 +20,14 @@ import { hasReasoning, hasToolCalls, } from "@/core/messages/utils"; -import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; -import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; -import { useSkills } from "@/core/skills/hooks"; -import { - buildSkillDisplayMap, - collectRuntimeDisplaySources, -} from "@/core/skill-display/runtime"; -import type { Subtask } from "@/core/tasks"; +import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; +import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; +import { useSkills } from "@/core/skills/hooks"; +import { + buildSkillDisplayMap, + collectRuntimeDisplaySources, +} from "@/core/skill-display/runtime"; +import type { Subtask } from "@/core/tasks"; import { useUpdateSubtask } from "@/core/tasks/context"; import type { AgentThreadState } from "@/core/threads"; import { cn } from "@/lib/utils"; @@ -55,6 +55,7 @@ export function MessageList({ thread, paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, tokenUsageEnabled = false, + extraRehypePlugins, onClarificationSubmit, onSetupAgentApprove, onSetupAgentReject, @@ -64,22 +65,26 @@ export function MessageList({ thread: BaseStream; paddingBottom?: number; tokenUsageEnabled?: boolean; + extraRehypePlugins?: ReturnType; onClarificationSubmit?: (answer: string) => void | Promise; onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise; onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise; }) { - const { t } = useI18n(); - const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); - const updateSubtask = useUpdateSubtask(); - const messages = thread.messages; - const [settings] = useLocalSettings(); - const ragModeEnabled = - settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); - const { skills } = useSkills(); - const skillDisplayMap = useMemo( - () => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), - [ragModeEnabled, skills], - ); + const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); + const rehypePlugins = useMemo( + () => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins), + [baseRehypePlugins, extraRehypePlugins], + ); + const updateSubtask = useUpdateSubtask(); + const messages = thread.messages; + const [settings] = useLocalSettings(); + const ragModeEnabled = + settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); + const { skills } = useSkills(); + const skillDisplayMap = useMemo( + () => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), + [ragModeEnabled, skills], + ); // Track which clarification messages are still awaiting an answer. // Pending = clarification tool messages that appear after the last human message @@ -197,29 +202,30 @@ export function MessageList({ {groupMessages(messages, (group) => { - if (group.type === "human" || group.type === "assistant") { - return group.messages.map((msg) => { - const displaySources = - ragModeEnabled && - group.type === "assistant" && - msg.type === "ai" - ? collectRuntimeDisplaySources(messages, skillDisplayMap, { - afterMessageId: findPreviousHumanMessageId(messages, msg.id), - beforeMessageId: msg.id, - includeBeforeMessage: true, - }) - : []; - return ( - - ); - }); + if (group.type === "human" || group.type === "assistant") { + return group.messages.map((msg) => { + const displaySources = + ragModeEnabled && + group.type === "assistant" && + msg.type === "ai" + ? collectRuntimeDisplaySources(messages, skillDisplayMap, { + afterMessageId: findPreviousHumanMessageId(messages, msg.id), + beforeMessageId: msg.id, + includeBeforeMessage: true, + }) + : []; + return ( + + ); + }); } else if (group.type === "assistant:clarification") { const message = group.messages[0]; if (message && hasContent(message)) { @@ -454,33 +460,33 @@ type ClarificationSelection = { custom: string; }; -export type SetupAgentApprovalPayload = { - name?: string; - description?: string; - soul?: string; - skills?: string[]; -}; - -function findPreviousHumanMessageId( - messages: Message[], - beforeMessageId?: string, -): string | undefined { - const beforeIndex = - beforeMessageId === undefined - ? messages.length - : messages.findIndex((message) => message.id === beforeMessageId); - const end = beforeIndex < 0 ? messages.length : beforeIndex; - for (let index = end - 1; index >= 0; index--) { - const message = messages[index]; - if (message?.type === "human") { - return message.id; - } - } - return undefined; -} - -function getSetupAgentApprovalPayload( - message: unknown, +export type SetupAgentApprovalPayload = { + name?: string; + description?: string; + soul?: string; + skills?: string[]; +}; + +function findPreviousHumanMessageId( + messages: Message[], + beforeMessageId?: string, +): string | undefined { + const beforeIndex = + beforeMessageId === undefined + ? messages.length + : messages.findIndex((message) => message.id === beforeMessageId); + const end = beforeIndex < 0 ? messages.length : beforeIndex; + for (let index = end - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.type === "human") { + return message.id; + } + } + return undefined; +} + +function getSetupAgentApprovalPayload( + message: unknown, ): SetupAgentApprovalPayload | null { if (typeof message !== "object" || message === null) { return null; diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index 7b45abe7..2fca2ddf 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -17,6 +17,7 @@ import { useI18n } from "@/core/i18n/hooks"; import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react"; import { SidebarCollapseButton } from "./workspace-header"; +import { WorkspaceNavStudio } from "./workspace-nav-studio"; import { cn } from "@/lib/utils"; // 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标 @@ -54,6 +55,7 @@ export function WorkspaceNavChatList({ }); }; return ( + <> @@ -163,5 +165,7 @@ export function WorkspaceNavChatList({ )} + {!showCollapseTrigger && } + ); } diff --git a/frontend-web/src/components/workspace/workspace-nav-studio.tsx b/frontend-web/src/components/workspace/workspace-nav-studio.tsx new file mode 100644 index 00000000..e546c39e --- /dev/null +++ b/frontend-web/src/components/workspace/workspace-nav-studio.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useState } from "react"; +import { BookOpen, ChevronDown, Loader2, Plus } from "lucide-react"; +import { Link, useLocation } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; + +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { useStudioNotebooks } from "@/core/studio/hooks"; +import { createSearchSpace } from "@/core/studio/api"; +import { getStoredAuth } from "@/core/auth"; + +const NAV_ITEM_CLASS = + "text-sidebar-foreground/80 hover:text-primary rounded-xl h-9 px-3 transition-colors hover:bg-primary/10 " + + "data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " + + "dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25 dark:data-[active=true]:text-primary-foreground"; + +const SIDEBAR_PREVIEW_COUNT = 5; + +export function WorkspaceNavStudio() { + const { notebooks, isLoading, isError } = useStudioNotebooks(); + const { pathname } = useLocation(); + const queryClient = useQueryClient(); + + const [open, setOpen] = useState(true); + const [dialogOpen, setDialogOpen] = useState(false); + const [name, setName] = useState(""); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(""); + + const handleCreate = async () => { + const trimmed = name.trim(); + if (!trimmed) return; + setCreating(true); + setError(""); + try { + const email = getStoredAuth()?.email ?? ""; + const token = queryClient.getQueryData(["studio", "token", email]); + if (!token) throw new Error("未登录或 token 已过期,请刷新页面"); + await createSearchSpace(trimmed, token); + setDialogOpen(false); + setName(""); + await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] }); + } catch (e) { + setError(e instanceof Error ? e.message : "创建失败"); + } finally { + setCreating(false); + } + }; + + if (isError || (!isLoading && notebooks.length === 0)) { + return null; + } + + const preview = notebooks.slice(0, SIDEBAR_PREVIEW_COUNT); + + return ( + <> + + + + + 工作室 + + + + + + + {/* 新建笔记本 */} + + { setDialogOpen(true); setError(""); setName(""); }} + > + + 新建笔记本 + + + + {/* 笔记本列表(最多展示 SIDEBAR_PREVIEW_COUNT 条) */} + {isLoading + ? Array.from({ length: 3 }).map((_, i) => ( + +
+ + )) + : preview.map((nb) => ( + + + + + {nb.name} + + + + ))} + + {/* 所有笔记本 */} + {!isLoading && notebooks.length > 0 && ( + + + + ··· + 所有笔记本 + + + + )} + + + + + + !creating && setDialogOpen(v)}> + + + 新建笔记本 + +
+ setName(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }} + placeholder="笔记本名称" + disabled={creating} + className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50" + /> + {error &&

{error}

} +
+ + +
+
+
+
+ + ); +} diff --git a/frontend-web/src/core/config/index.ts b/frontend-web/src/core/config/index.ts index 822d9869..9727e6f6 100644 --- a/frontend-web/src/core/config/index.ts +++ b/frontend-web/src/core/config/index.ts @@ -69,6 +69,14 @@ export function getLangGraphBaseURL(isMock?: boolean) { } } +/** 笔记本外部系统(Studio)API 基础地址,通过 VITE_NOTEBOOK_API_BASE_URL 配置。 */ +export function getNotebookApiBaseURL(): string { + if (env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL) { + return env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL.replace(/\/+$/, ""); + } + return ""; +} + /** 轻应用「新建笔记」系列接口的基础地址(与 DeerFlow Gateway 分离)。 */ export function getNoteApiBaseURL(): string { if (env.NEXT_PUBLIC_NOTE_API_BASE_URL) { diff --git a/frontend-web/src/core/rehype/index.ts b/frontend-web/src/core/rehype/index.ts index e79ac39a..14d72b11 100644 --- a/frontend-web/src/core/rehype/index.ts +++ b/frontend-web/src/core/rehype/index.ts @@ -3,6 +3,55 @@ import { useMemo } from "react"; import { visit } from "unist-util-visit"; import type { BuildVisitor } from "unist-util-visit"; +const CITE_RE = /\[\[cite:(\d+)\]\]/g; + +export function rehypeCitations(sources: Array<{ id: number; title: string }>) { + const sourceMap = new Map(sources.map((s) => [s.id, s.title])); + + return (tree: Root) => { + visit(tree, "text", (node, index, parent) => { + if (!CITE_RE.test(node.value)) return; + CITE_RE.lastIndex = 0; + + const newNodes: ElementContent[] = []; + const citeCounter = new Map(); + let citeIndex = 0; + let last = 0; + let match: RegExpExecArray | null; + + while ((match = CITE_RE.exec(node.value)) !== null) { + if (match.index > last) { + newNodes.push({ type: "text", value: node.value.slice(last, match.index) }); + } + const docId = Number(match[1]); + if (!citeCounter.has(docId)) citeCounter.set(docId, ++citeIndex); + const num = citeCounter.get(docId)!; + const title = sourceMap.get(docId) ?? `来源 ${docId}`; + + newNodes.push({ + type: "element", + tagName: "sup", + properties: { + title, + "data-chunk-id": String(docId), + style: + "display:inline-flex;align-items:center;justify-content:center;width:1rem;height:1rem;border-radius:9999px;background:hsl(var(--primary));color:hsl(var(--primary-foreground));font-size:10px;font-weight:700;margin-left:1px;cursor:pointer;vertical-align:super;", + }, + children: [{ type: "text", value: String(num) }], + }); + last = match.index + match[0].length; + } + if (last < node.value.length) { + newNodes.push({ type: "text", value: node.value.slice(last) }); + } + if (parent && index !== undefined && newNodes.length > 0) { + (parent.children as ElementContent[]).splice(index, 1, ...newNodes); + return index + newNodes.length; + } + }); + }; +} + const CJK_TEXT_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; diff --git a/frontend-web/src/core/studio/api.ts b/frontend-web/src/core/studio/api.ts new file mode 100644 index 00000000..16e8d90a --- /dev/null +++ b/frontend-web/src/core/studio/api.ts @@ -0,0 +1,94 @@ +import { getNotebookApiBaseURL } from "@/core/config"; +import type { SourceItem, StudioNotebook } from "./types"; + +function base(): string { + return getNotebookApiBaseURL(); +} + +export async function fetchStudioToken(email: string): Promise { + const apiBase = base(); + if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置"); + + const res = await fetch( + `${apiBase}/api/getTokenByEmail?email=${encodeURIComponent(email)}`, + ); + if (!res.ok) throw new Error(`获取 Token 失败:${res.status}`); + + const data = (await res.json()) as { token?: string } | string; + const token = typeof data === "string" ? data : data?.token; + if (!token) throw new Error("接口未返回有效 token"); + return token; +} + +export async function fetchStudioNotebooks(token: string): Promise { + const apiBase = base(); + if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置"); + + const res = await fetch(`${apiBase}/api/v1/searchspaces`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`获取笔记本列表失败:${res.status}`); + return res.json() as Promise; +} + +export async function createSearchSpace( + name: string, + token: string, +): Promise { + const apiBase = base(); + if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置"); + + const res = await fetch(`${apiBase}/api/v1/searchspaces`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ name, description: "", citations_enabled: true }), + }); + if (!res.ok) throw new Error(`创建失败:${res.status}`); +} + +export interface ChunkRead { + id: number; + content: string; + document_id: number; + created_at: string; +} + +export interface DocumentWithChunks { + id: number; + title: string; + document_type: string; + file_type: string; + chunks: ChunkRead[]; +} + +export async function fetchDocumentByChunk( + chunkId: number, + token: string, +): Promise { + const apiBase = base(); + if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置"); + + const res = await fetch(`${apiBase}/api/v1/documents/by-chunk/${chunkId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`获取文档失败:${res.status}`); + return res.json() as Promise; +} + +export async function fetchNotebookSources( + notebookId: number, + token: string, +): Promise { + const apiBase = base(); + if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置"); + + const res = await fetch( + `${apiBase}/api/v1/source/search-space/${notebookId}?limit=100`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + if (!res.ok) throw new Error(`获取来源失败:${res.status}`); + return res.json() as Promise; +} diff --git a/frontend-web/src/core/studio/hooks.ts b/frontend-web/src/core/studio/hooks.ts new file mode 100644 index 00000000..bb767664 --- /dev/null +++ b/frontend-web/src/core/studio/hooks.ts @@ -0,0 +1,146 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useStream } from "@langchain/langgraph-sdk/react"; + +import { getStoredAuth } from "@/core/auth"; +import { getAPIClient } from "@/core/api"; +import { fetchNotebookSources, fetchStudioNotebooks, fetchStudioToken } from "./api"; +import type { AgentThreadState } from "@/core/threads/types"; +import type { SourceItem, StudioNotebook } from "./types"; + +export function useStudioNotebooks(): { + notebooks: StudioNotebook[]; + isLoading: boolean; + isError: boolean; +} { + const email = getStoredAuth()?.email ?? ""; + + const tokenQuery = useQuery({ + queryKey: ["studio", "token", email], + queryFn: () => fetchStudioToken(email), + enabled: Boolean(email), + staleTime: 5 * 60 * 1000, + retry: 1, + }); + + const notebooksQuery = useQuery({ + queryKey: ["studio", "notebooks", tokenQuery.data], + queryFn: () => fetchStudioNotebooks(tokenQuery.data!), + enabled: Boolean(tokenQuery.data), + staleTime: 60 * 1000, + retry: 1, + }); + + return { + notebooks: (notebooksQuery.data ?? []).filter((nb) => nb.is_owner), + isLoading: tokenQuery.isLoading || notebooksQuery.isLoading, + isError: tokenQuery.isError || notebooksQuery.isError, + }; +} + +export function useNotebookThreads(notebookId: number | null) { + const client = getAPIClient(); + return useQuery({ + queryKey: ["notebook-threads", notebookId], + queryFn: () => + client.threads.search({ + metadata: { thread_type: "notebook", note_id: notebookId }, + limit: 20, + sortBy: "updated_at", + sortOrder: "desc", + }), + enabled: notebookId !== null, + staleTime: 30 * 1000, + refetchOnWindowFocus: false, + }); +} + +export function useNotebookChat( + notebookId: number, + sources: SourceItem[], + modelName?: string, + initialThreadId?: string, +) { + const [threadId, setThreadId] = useState(initialThreadId); + + const email = getStoredAuth()?.email ?? ""; + const { data: token } = useQuery({ + queryKey: ["studio", "token", email], + queryFn: () => fetchStudioToken(email), + enabled: Boolean(email), + staleTime: 5 * 60 * 1000, + retry: 1, + }); + + const thread = useStream({ + client: getAPIClient(), + assistantId: "lead_agent", + threadId, + fetchStateHistory: { limit: 1 }, + onCreated(meta) { + setThreadId(meta.thread_id); + void getAPIClient() + .threads.update(meta.thread_id, { + metadata: { thread_type: "notebook", note_id: notebookId }, + }) + .catch(() => ({})); + }, + }); + + const send = async (text: string, selectedSourceIds: number[]) => { + if (thread.isLoading || !token) return; + const sourceIds = + selectedSourceIds.length > 0 + ? selectedSourceIds + : sources.map((s) => s.id).filter(Boolean); + + await thread.submit( + { messages: [{ type: "human", content: text }] }, + { + threadId, + streamSubgraphs: true, + config: { + configurable: { + notebook_search_space_id: notebookId, + notebook_source_ids: sourceIds, + notebook_token: token, + ...(modelName ? { model_name: modelName } : {}), + }, + }, + }, + ); + }; + + return { thread, messages: thread.messages, isStreaming: thread.isLoading, send, threadId, setThreadId }; +} + +export function useNotebookSources(notebookId: number | null): { + sources: SourceItem[]; + isLoading: boolean; + isError: boolean; +} { + const email = getStoredAuth()?.email ?? ""; + + // 复用与 useStudioNotebooks 相同的 token query key,命中缓存不重复请求 + const tokenQuery = useQuery({ + queryKey: ["studio", "token", email], + queryFn: () => fetchStudioToken(email), + enabled: Boolean(email), + staleTime: 5 * 60 * 1000, + retry: 1, + }); + + const sourcesQuery = useQuery({ + queryKey: ["studio", "sources", notebookId], + queryFn: () => fetchNotebookSources(notebookId!, tokenQuery.data!), + enabled: notebookId !== null && Boolean(tokenQuery.data), + staleTime: 60 * 1000, + retry: 1, + }); + + return { + sources: sourcesQuery.data ?? [], + isLoading: tokenQuery.isLoading || sourcesQuery.isLoading, + isError: tokenQuery.isError || sourcesQuery.isError, + }; +} diff --git a/frontend-web/src/core/studio/types.ts b/frontend-web/src/core/studio/types.ts new file mode 100644 index 00000000..beb79b50 --- /dev/null +++ b/frontend-web/src/core/studio/types.ts @@ -0,0 +1,36 @@ +export interface StudioTokenResponse { + token: string; +} + +export interface DocumentRead { + id: number; + title: string; + document_type: string; + file_type: string | null; + created_at: string | null; +} + +export interface SourceItem { + id: number; + search_space_id: number; + source_type: string; + title: string | null; + status: string | null; + created_at: string; + document: DocumentRead | null; +} + +export interface StudioNotebook { + id: number; + name: string; + description: string; + user_id: string; + owner_email: string; + member_count: number; + source_count: number; + is_shared: boolean; + is_owner: boolean; + membership_role: string; + created_at: string; + avatar_url: string | null; +} diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index e1687970..f8f95307 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -694,7 +694,9 @@ export function useThreads( offset += response.length; } - return threads; + return threads.filter( + (t) => (t.metadata as Record | null)?.thread_type !== "notebook", + ); }, refetchOnWindowFocus: false, }); diff --git a/frontend-web/src/env.ts b/frontend-web/src/env.ts index 5e1d7c01..10b6603c 100644 --- a/frontend-web/src/env.ts +++ b/frontend-web/src/env.ts @@ -6,6 +6,7 @@ function readEnv( | "VITE_LANGGRAPH_BASE_URL" | "VITE_CANVAS_LANGGRAPH_BASE_URL" | "VITE_NOTE_API_BASE_URL" + | "VITE_NOTEBOOK_API_BASE_URL" | "VITE_STATIC_WEBSITE_ONLY" | "VITE_GITHUB_OAUTH_TOKEN", ): EnvValue { @@ -20,6 +21,7 @@ export const env = { // routed (e.g. a separate canvas-agent server). Falls back to the main URL. NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL: readEnv("VITE_CANVAS_LANGGRAPH_BASE_URL"), NEXT_PUBLIC_NOTE_API_BASE_URL: readEnv("VITE_NOTE_API_BASE_URL"), + NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readEnv("VITE_NOTEBOOK_API_BASE_URL"), NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"), GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"), }; 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/pages/StudioNotebookDetailPage.tsx b/frontend-web/src/pages/StudioNotebookDetailPage.tsx new file mode 100644 index 00000000..d0e944ba --- /dev/null +++ b/frontend-web/src/pages/StudioNotebookDetailPage.tsx @@ -0,0 +1,494 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useParams } from "react-router-dom"; +import { + AlignLeft, + ArrowLeft, + CloudUpload, + Files, + Globe, + Loader2, + Plus, + Send, + X, +} from "lucide-react"; + +import { useQueryClient } from "@tanstack/react-query"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useNotebookChat, useNotebookSources, useNotebookThreads, useStudioNotebooks } from "@/core/studio/hooks"; +import { fetchDocumentByChunk } from "@/core/studio/api"; +import type { DocumentWithChunks } from "@/core/studio/api"; +import type { SourceItem } from "@/core/studio/types"; +import { getStoredAuth } from "@/core/auth"; +import { useModels } from "@/core/models/hooks"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { MessageList } from "@/components/workspace/messages/message-list"; +import { rehypeCitations } from "@/core/rehype"; + +// ── 来源卡片图标 ──────────────────────────────────────── +function SourceIcon({ item }: { item: SourceItem }) { + const fileType = item.document?.file_type?.toLowerCase(); + if (item.source_type === "FILE" && fileType === "pdf") { + return ( + + PDF + + ); + } + if (item.source_type === "KNOWLEDGE_CONNECTOR") return ; + if (item.source_type === "NOTE") return ; + return ; +} + +// ── 单个来源卡片 ──────────────────────────────────────── +function SourceCard({ + item, + selected, + onToggle, +}: { + item: SourceItem; + selected: boolean; + onToggle: (id: number) => void; +}) { + const title = item.title ?? item.document?.title ?? "未命名"; + const isProcessing = item.status === "processing"; + + return ( +
onToggle(item.id)} + className={`group relative flex h-32 w-full cursor-pointer flex-col justify-between rounded-2xl p-4 ring-1 transition-colors ${ + selected + ? "bg-primary/10 ring-primary" + : "bg-muted ring-border/50 hover:bg-muted/70" + }`} + > +
+ {isProcessing ? ( + + ) : ( + + )} +
+

{title}

+
+ {title} +
+
+ ); +} + +// ── 来源骨架屏 ────────────────────────────────────────── +function SourceCardSkeleton() { + return ( +
+
+
+
+ ); +} + +// ── 来源 Dialog ───────────────────────────────────────── +function SourcesDialog({ + open, + onClose, + notebookName, + sources, + isLoadingSources, + selectedSourceIds, + onToggle, +}: { + open: boolean; + onClose: () => void; + notebookName: string; + sources: SourceItem[]; + isLoadingSources: boolean; + selectedSourceIds: number[]; + onToggle: (id: number) => void; +}) { + return ( + !v && onClose()}> + + +
+ 来源 +

+ {notebookName} · 选中的来源将用于 AI 回答(不选默认全部) +

+
+ +
+ +
+
+ {[ + { icon: Plus, label: "上传文件" }, + { icon: CloudUpload, label: "从云端硬盘添加" }, + { icon: Globe, label: "添加网站" }, + { icon: AlignLeft, label: "复制的文字" }, + ].map(({ icon: Icon, label }) => ( + + ))} +
+ +
+
+ {isLoadingSources + ? Array.from({ length: 8 }).map((_, i) => ) + : sources.map((src) => ( + + ))} +
+ {!isLoadingSources && sources.length === 0 && ( +

暂无来源,请添加文件。

+ )} +
+
+
+
+ ); +} + +// ── 主页面 ────────────────────────────────────────────── +export default function StudioNotebookDetailPage() { + const { notebookId } = useParams<{ notebookId: string }>(); + const { notebooks, isLoading } = useStudioNotebooks(); + const { models } = useModels(); + const queryClient = useQueryClient(); + const [sourcesOpen, setSourcesOpen] = useState(false); + const [selectedSourceIds, setSelectedSourceIds] = useState([]); + const [selectedModel, setSelectedModel] = useState(""); + const [input, setInput] = useState(""); + // null = 显示历史线程列表;string = 显示该线程的聊天 + const [activeThreadId, setActiveThreadId] = useState(null); + const [citationChunkId, setCitationChunkId] = useState(null); + const [citationDoc, setCitationDoc] = useState(null); + const [citationLoading, setCitationLoading] = useState(false); + + useEffect(() => { + if (!selectedModel && models.length > 0) { + setSelectedModel(models[0]!.name); + } + }, [models, selectedModel]); + + const notebook = notebooks.find((nb) => nb.id === Number(notebookId)); + + const { sources, isLoading: isLoadingSources } = useNotebookSources(notebook?.id ?? null); + const citationRehypePlugins = useMemo(() => { + const mapped = sources.map((s) => ({ + id: s.id, + title: s.title ?? (s.document as { title?: string } | undefined)?.title ?? String(s.id), + })); + return [() => rehypeCitations(mapped)] as ReturnType; + }, [sources]); + const { data: notebookThreads, refetch: refetchThreads } = useNotebookThreads(notebook?.id ?? null); + const { thread, threadId: chatThreadId, isStreaming, send, setThreadId } = useNotebookChat( + notebook?.id ?? 0, + sources, + selectedModel || undefined, + activeThreadId ?? undefined, + ); + + const handleToggleSource = (id: number) => { + setSelectedSourceIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + ); + }; + + const handleSend = async (e: React.SyntheticEvent) => { + e.preventDefault(); + const text = input.trim(); + if (!text || isStreaming) return; + setInput(""); + // 发送后切换到聊天视图(activeThreadId 由 onCreated 回调设置) + await send(text, selectedSourceIds); + }; + + const handleBackToList = () => { + setActiveThreadId(null); + setThreadId(undefined); + void refetchThreads(); + }; + + if (isLoading) { + return ( +
+
+
+
+
+ ); + } + + if (!notebook) { + return ( +
+

笔记本不存在

+
+ ); + } + + const hasMessages = (thread.messages ?? []).some( + (m) => m.type === "human" || m.type === "ai", + ); + + // 聊天视图:有 activeThreadId 或已有消息 + const isChatView = activeThreadId !== null || hasMessages; + + return ( +
+ {/* 背景渐变(列表视图时显示) */} + {!isChatView && ( +
+ )} + + {/* 聊天视图:顶部栏 */} + {isChatView && ( +
+
+ +
+ {notebook.avatar_url ? ( + + ) : ( + + )} +
+ {notebook.name} +
+ +
+ )} + + {/* 列表视图:笔记本标题 + 历史线程列表 */} + {!isChatView && ( +
+
+ {notebook.avatar_url ? ( + + ) : ( + + )} +
+

+ {notebook.name} +

+ + + {/* 历史线程列表 */} + {notebookThreads && notebookThreads.length > 0 && ( +
+

历史对话

+ {notebookThreads.map((t) => { + const values = t.values as Record | undefined; + const msgs = values?.messages as Array<{ type: string; content: unknown }> | undefined; + const firstHuman = msgs?.find((m) => m.type === "human"); + const rawContent = firstHuman?.content; + const preview = + typeof rawContent === "string" + ? rawContent.slice(0, 60) + : Array.isArray(rawContent) + ? String((rawContent[0] as { text?: string })?.text ?? "").slice(0, 60) + : "对话"; + const title = (values?.title as string | undefined) || preview || "对话"; + const time = new Date(t.updated_at ?? "").toLocaleString("zh-CN", { + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + return ( + + ); + })} +
+ )} +
+ )} + + {/* 聊天视图:消息列表 */} + {isChatView && ( +
{ + const sup = (e.target as HTMLElement).closest("[data-chunk-id]"); + if (!sup) return; + const chunkId = Number(sup.dataset.chunkId); + if (!chunkId) return; + setCitationChunkId(chunkId); + setCitationDoc(null); + setCitationLoading(true); + const email = getStoredAuth()?.email ?? ""; + const tok = queryClient.getQueryData(["studio", "token", email]) ?? ""; + void fetchDocumentByChunk(chunkId, tok) + .then((doc) => setCitationDoc(doc)) + .finally(() => setCitationLoading(false)); + }} + > + +
+ )} + + {/* 输入框区域 */} +
+
{ void handleSend(e); }} + className="mx-auto flex w-[560px] items-center gap-3 rounded-2xl bg-card px-5 py-4 shadow-sm ring-1 ring-border" + > + + {models.length > 0 && ( + + )} + setInput(e.target.value)} + placeholder="问问 AI..." + disabled={isStreaming} + className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50" + /> + +
+
+ + {/* 引用来源弹窗 */} + { if (!v) setCitationChunkId(null); }}> + + + 引用来源 + + {citationLoading && ( +
+ +
+ )} + {!citationLoading && citationDoc && ( +
+
+ + {citationDoc.file_type || citationDoc.document_type} + + {citationDoc.title} +
+ {citationDoc.chunks.find((c) => c.id === citationChunkId) && ( +
+ {citationDoc.chunks.find((c) => c.id === citationChunkId)!.content} +
+ )} +
+ )} + {!citationLoading && !citationDoc && ( +

加载失败,请重试。

+ )} +
+
+ + {/* 来源 Dialog */} + setSourcesOpen(false)} + notebookName={notebook.name} + sources={sources} + isLoadingSources={isLoadingSources} + selectedSourceIds={selectedSourceIds} + onToggle={handleToggleSource} + /> +
+ ); +} diff --git a/frontend-web/src/pages/StudioNotebooksPage.tsx b/frontend-web/src/pages/StudioNotebooksPage.tsx new file mode 100644 index 00000000..00bb0dcb --- /dev/null +++ b/frontend-web/src/pages/StudioNotebooksPage.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { Files, Loader2, Plus } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { useStudioNotebooks } from "@/core/studio/hooks"; +import { createSearchSpace } from "@/core/studio/api"; +import { getStoredAuth } from "@/core/auth"; +import type { StudioNotebook } from "@/core/studio/types"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; + +function NotebookCardSkeleton() { + return ( +
+
+
+
+
+
+
+ ); +} + +function NotebookCard({ nb }: { nb: StudioNotebook }) { + return ( + +
+
+ {nb.avatar_url ? ( + + ) : ( + + )} +
+
+
+ + {nb.name} + + {nb.source_count} 个来源 +
+ + ); +} + +export default function StudioNotebooksPage() { + const { notebooks, isLoading, isError } = useStudioNotebooks(); + const queryClient = useQueryClient(); + const [dialogOpen, setDialogOpen] = useState(false); + const [name, setName] = useState(""); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(""); + + const handleCreate = async () => { + const trimmed = name.trim(); + if (!trimmed) return; + setCreating(true); + setError(""); + try { + const email = getStoredAuth()?.email ?? ""; + const token = queryClient.getQueryData(["studio", "token", email]); + if (!token) throw new Error("未登录或 token 已过期,请刷新页面"); + await createSearchSpace(trimmed, token); + setDialogOpen(false); + setName(""); + // 刷新笔记本列表 + await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] }); + } catch (e) { + setError(e instanceof Error ? e.message : "创建失败"); + } finally { + setCreating(false); + } + }; + + return ( +
+
+

笔记本

+ +
+ + {isError && ( +

加载笔记本失败,请稍后重试。

+ )} + +
+ {isLoading + ? Array.from({ length: 8 }).map((_, i) => ) + : notebooks.map((nb) => )} +
+ + !creating && setDialogOpen(v)}> + + + 新建笔记本 + +
+ setName(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }} + placeholder="笔记本名称" + disabled={creating} + className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50" + /> + {error &&

{error}

} +
+ + +
+
+
+
+
+ ); +} diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 8ad09805..9c111b62 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -36,6 +36,8 @@ import AdminUserHubPage from "./AdminUserHubPage"; import TagsPage from "./TagsPage"; import RecommendedQuestionsPage from "./RecommendedQuestionsPage"; import AIWritingPage from "@/open-canvas/pages/AIWritingPage"; +import StudioNotebooksPage from "./StudioNotebooksPage"; +import StudioNotebookDetailPage from "./StudioNotebookDetailPage"; import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage"; import "@/roundtable-planning/styles/roundtable-planning.css"; @@ -192,6 +194,24 @@ export default function WorkspaceRoutes({ } /> + + + + } + /> + + + + + + } + /> } @@ -200,7 +220,9 @@ export default function WorkspaceRoutes({ path="roundtable/planning" element={ - + + + } /> 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/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..faffcc91 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx @@ -0,0 +1,203 @@ +/** + * 模型生成过程的「步骤卡」 —— 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; + /** 点击 write_file / present_files 等产物相关步骤时打开沙箱。 */ + onArtifactStepClick?: (step: BubbleStep) => void; +} + +/** 工具调用阶段的 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, + onArtifactStepClick, +}: 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, + onArtifactStepClick, +}: { + step: BubbleStep; + status: "complete" | "active"; + onArtifactStepClick?: (step: BubbleStep) => void; +}) { + const display = getStepDisplay(step.key, step.label, step.args); + const argNode = renderStepArgs(step.key, step.args); + const [, toolName] = step.key.split(":"); + const isArtifactStep = + toolName === "write_file" || + toolName === "str_replace" || + toolName === "present_files"; + + return ( + onArtifactStepClick(step) + : undefined + } + label={ + + {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 指令 +

    +
    +
    + +
    + +