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/roundtable-planning/components/RecommendAgentsDialog.tsx b/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx index 8f5ba050..7877bcfa 100644 --- a/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx +++ b/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx @@ -449,7 +449,9 @@ export function RecommendAgentsDialog({ 让 UI 焦点转到下方推荐卡片;用户手动切换后不再自动收起(尊重偏好)。 ============================================================ */} @@ -576,22 +578,25 @@ export function RecommendAgentsDialog({ * 「模型生成内容」整段的可折叠外壳。 * * 默认行为: - * - 流式期间(`streaming=true`)默认展开,让用户看到实时生成 - * - 流式结束(切到 `ready`/`fallback`/`error`)后延迟 1s 自动收起一次, + * - 「正在加载候选」+「正在生成推荐」期间(`isLoading=true`)始终保持展开, + * 让用户看到内部 spinner / 实时生成内容,不会在中途被自动收起。 + * - 推荐完成(切到 `ready`/`fallback`/`error`)后延迟 1s 自动收起一次, * 把焦点让给下方推荐卡片 * - 用户**手动**点击 toggle 后,`userTouchedRef` 置 true,后续不再自动改变 * - * 收起态 header 上显示状态徽标(「正在生成 / 已生成 / 已使用兜底 / 推荐失败」), - * 让用户不用展开就知道当前阶段。 + * 收起态 header 上显示: + * - 加载中/生成中 → 旋转 spinner 图标(替代静态 ✨),即便折叠也能看到在加载 + * - 完成态 → 静态 ✨ + 状态徽标(已生成 / 已使用兜底 / 推荐失败) */ const MODEL_OUTPUT_AUTO_CLOSE_DELAY_MS = 1000; function CollapsibleModelOutput({ - streaming, + isLoading, phase, children, }: { - streaming: boolean; + /** True 时面板保持展开 + header 显示 spinner;涵盖加载候选 + 流式生成。 */ + isLoading: boolean; phase: DialogPhase; children: React.ReactNode; }) { @@ -599,16 +604,19 @@ function CollapsibleModelOutput({ const userTouchedRef = useRef(false); const hasAutoClosedRef = useRef(false); - // 流式开始 / 进行中 → 默认展开(只在用户没动过时生效)。 - // 流式结束 → 延迟自动收起一次(仅一次)。 + // 加载中 → 保持展开(用户没手动动过的前提下)。 + // 加载完成 → 延迟自动收起一次(仅一次),让出焦点给下方推荐卡片。 + // 关键区分:idle / loading_agents 早期阶段以前会被 1s 计时器误关—— + // 现在只有真正进入「已完成/兜底/失败」三种终态才触发自动收起。 useEffect(() => { if (userTouchedRef.current) return; - if (streaming) { + if (isLoading) { setOpen(true); hasAutoClosedRef.current = false; return; } - if (!hasAutoClosedRef.current && open) { + const isCompleted = phase === "ready" || phase === "fallback" || phase === "error"; + if (isCompleted && !hasAutoClosedRef.current && open) { const timer = setTimeout(() => { setOpen(false); hasAutoClosedRef.current = true; @@ -616,12 +624,12 @@ function CollapsibleModelOutput({ return () => clearTimeout(timer); } return undefined; - }, [streaming, open]); + }, [isLoading, phase, open]); const statusBadge = (() => { switch (phase) { case "loading_agents": - return { label: "加载中", className: "bg-slate-100 text-slate-600" }; + return { label: "加载候选", className: "bg-slate-100 text-slate-600" }; case "recommending": return { label: "正在生成", className: "bg-purple-50 text-purple-700 border border-purple-200" }; case "ready": @@ -645,12 +653,11 @@ function CollapsibleModelOutput({ }} className="w-full flex items-center gap-2 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider hover:bg-muted/40 transition-colors cursor-pointer" > - + {isLoading ? ( + + ) : ( + + )} 模型生成内容 {statusBadge && ( ( // 这是修「鼠标拖滚动条没出现按钮」的关键:之前依赖 wheel/touchmove, // 但鼠标拖滚动条**只触发 scroll 事件**,wheel/touchmove 完全不会触发。 const programmaticScrollRef = useRef(false); + // 自动跟随 effect 和 onScroll 共享的"上次观察到的 scrollTop"。 + // 用来在 effect 里区分两种 distance 暴涨的情况: + // - currentScrollTop < lastObservedScrollTopRef → 用户主动向上滚 → 退出跟随 + // - currentScrollTop 不变,但 scrollHeight 暴涨(如澄清卡片渲染) → 继续跟随 + const lastObservedScrollTopRef = useRef(0); useEffect(() => { const el = ref.current; if (!el) return; let prevScrollTop = el.scrollTop; + lastObservedScrollTopRef.current = prevScrollTop; const onScroll = () => { const currentScrollTop = el.scrollTop; const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight; @@ -50,6 +56,7 @@ export function useAutoScroll( // 这是我们刚 scrollTop=scrollHeight 触发的回调,跳过,但仍刷新 prev 用于下次比较。 programmaticScrollRef.current = false; prevScrollTop = currentScrollTop; + lastObservedScrollTopRef.current = currentScrollTop; // 程序滚动后通常落在底部,把 atBottom 维持 true。 if (!atBottomRef.current && isAtBottom) { atBottomRef.current = true; @@ -67,12 +74,12 @@ export function useAutoScroll( // 用户自己又滚回底部 → 恢复自动跟随 atBottomRef.current = true; setAtBottom(true); - } else if (!isAtBottom && atBottomRef.current) { - // 兜底:scrollTop 没明显减少但距底部已超阈值(如内容增长而用户没动) - atBottomRef.current = false; - setAtBottom(false); } + // **不再**因为「scrollTop 没明显减少但 distance > threshold」就退出自动跟随。 + // 这种情况只可能由内容突然增长触发(如澄清卡片一帧渲染 200+ px), + // 此时用户并没有滚走,应当继续跟随,由下面的 effect 把视口拉到新底部。 prevScrollTop = currentScrollTop; + lastObservedScrollTopRef.current = currentScrollTop; }; el.addEventListener("scroll", onScroll, { passive: true }); // 初次测量 @@ -83,24 +90,34 @@ export function useAutoScroll( }, [ref, threshold]); // 自动跟随 effect。用 instant 同步赋值,避免 smooth 动画与用户拖动叠加打架。 - // 在赋值前 **实时检查** scrollTop 位置(不依赖 atBottomRef),防止 ref 滞后于 - // 用户刚刚的拖动事件 —— 这是修「拖动期间又被自动滚拽回」的关键。 + // 关键:区分「用户向上滚」(scrollTop 变小) 与「内容增长」(scrollTop 不变、 + // scrollHeight 变大)。仅前者算作用户离开,后者继续自动跟随 —— + // 否则像澄清卡片这种一帧渲染 200+ px 的场景会被误判成"用户滚走了"。 useEffect(() => { const el = ref.current; if (!el) return; - // 实时位置检测:即便 ref 还没翻成 false,只要当前 scrollTop 离底部超过阈值, - // 说明用户已经离开,绝不自动跟随。 - const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - if (distanceFromBottom > threshold) { - // 顺手把 ref/state 同步到位,防止下次 onScroll 还没跑就被 effect 再次拽 + const currentScrollTop = el.scrollTop; + const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight; + // 1px 容忍滚动条像素抖动 / 浏览器自身的次像素回弹。 + const userScrolledUp = currentScrollTop < lastObservedScrollTopRef.current - 1; + if (userScrolledUp && distanceFromBottom > threshold) { + // 用户主动向上滚 → 退出自动跟随 (兜底 ref 滞后于刚刚的拖动事件)。 if (atBottomRef.current) { atBottomRef.current = false; setAtBottom(false); } + lastObservedScrollTopRef.current = currentScrollTop; return; } + if (!atBottomRef.current) { + // 之前已退出自动跟随,不要因为内容增长又把用户拽回去。 + lastObservedScrollTopRef.current = currentScrollTop; + return; + } + // 在底部 (或仅是内容刚增长) → 同步拉到新底部。 programmaticScrollRef.current = true; el.scrollTop = el.scrollHeight; + lastObservedScrollTopRef.current = el.scrollTop; // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); diff --git a/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts b/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts index 2f195f9b..fc637a3b 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts @@ -257,10 +257,17 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe triggerToast("⚠️ 任务理解智能体尚未就绪,请稍候"); return; } - if (isIntentSending) { + // 重入保护用 ref 而不是 state:isIntentSending 是 React state, + // 同一 event-loop tick 内闭包还看到旧值。极速双击 / 输入法回车连击 + // 会两次都通过 state 检查,结果发两次答案、跑两路 stream, + // 回来两张几乎一样的澄清卡——这正是用户报告的"同样选项又出现一次"。 + // intentAborterRef 在 stream 完成 / abort 后会被清空,正好用来标记"在途"。 + if (intentAborterRef.current) { triggerToast("⏳ 智能体正在回复,请稍候"); return; } + const aborter = new AbortController(); + intentAborterRef.current = aborter; setPendingIntentClarificationMsgId(null); @@ -300,8 +307,6 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe ]); setIsIntentSending(true); - const aborter = new AbortController(); - intentAborterRef.current = aborter; try { const res = await streamIntent({ threadId: intentThreadId, @@ -440,7 +445,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe setIsIntentSending(false); } }, - [intentThreadId, isIntentSending, triggerToast], + [intentThreadId, triggerToast], ); const submitIntentClarification = useCallback( diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py index a63cd706..f4bd40df 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py +++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py @@ -42,51 +42,87 @@ INTENT_AGENT_DESCRIPTION = ( # 改动请同步两处或以本文件为准 + 删除磁盘旧目录让下一次请求重建。 INTENT_AGENT_SOUL = """# 任务理解智能体 -你是多智能体圆桌研讨 **Step 1** 的「任务理解专员」。本提示词定义了 Step 1 的完整流程与边界;若管理员为你配置了 Skill,可结合 Skill 补充领域话术或检查清单,但**不得偏离**下文的工作流、`ask_clarification` 用法与 `[INTENT_READY]` 结束协议。 +你是多智能体圆桌研讨 **Step 1** 的「意图理解专员」。你的**唯一职责**是理解用户输入背后的意图,把它结构化成 `{objective, constraints, assumptions}`,交给 Step 2 圆桌处理。 + +**你不是任务过滤器,不是看门人。** 用户输入什么(规划性需求、事实性问题、知识查询、创作请求、咨询、闲聊、模糊想法……),你都要尝试去**理解**,而不是拒绝。 --- ## 1. 你的目标 -通过 **2–5 轮**简短对话,把用户模糊的需求整理成结构化输入,供 Step 2 圆桌使用: +把用户的任何输入整理成结构化意图,供 Step 2 圆桌使用: -| 字段 | 含义 | -|------|------| -| `objective` | 一句话任务目标 | -| `constraints` | 硬约束列表(预算/时间/合规/范围等,至少 1 条) | -| `assumptions` | 你替用户做的合理假设(可空数组) | +| 字段 | 含义 | 是否必填 | +|------|------|----------| +| `objective` | 一句话描述用户想做/想知道的事 | ✅ 必填 | +| `constraints` | 用户明确给出的硬性约束(预算/时间/合规/范围/风格等) | ⚠️ 可空数组,**不要凭空编造** | +| `assumptions` | 你替用户做的合理补充假设 | ⚠️ 可空数组 | -**优先级**:目标 > 硬约束 > 软偏好。已经清楚的信息不要再问。 +**核心原则**: +- 用户已经说清楚的,**直接汇总,不要追问**。 +- 用户问一个事实/查询/咨询类问题,**这就是一个有效的 objective**,直接结构化即可,不需要约束。 +- 只有当**信息严重不足以让 Step 2 开展工作**时,才追问。 --- ## 2. 工作流(必须遵守) ``` -用户描述任务 +用户输入 ↓ -信息够不够? - ├─ 不够 / 有歧义 → 调用 ask_clarification(可带 options 卡片) - ↓ -用户回复(可能是「我选择:…」或自由文本) - ↓ -重复 2–5 轮 - ↓ -信息充分 → 自然语言汇总 + [INTENT_READY] + JSON - ↓ -停止追问(本轮及之后都不要再 ask_clarification) +能不能用一句话写出 objective? + ├─ 能 → 直接汇总输出 [INTENT_READY] + JSON,结束 + └─ 不能(完全模糊、多种理解、关键决策点缺失) + → 调用 ask_clarification(一次只问一个最关键的点) + ↓ + 用户回复后再判断;最多 2-3 轮就要收敛 ``` **关键规则**: -- 澄清阶段 **只** 用 `ask_clarification` 提问;不要用纯文本空问「还有什么补充吗」。 -- 一旦输出 `[INTENT_READY]`,**同一轮对话立即结束**,不要再追加追问或工具调用。 -- **不做方案、不给技术建议、不写文件、不联网、不派活**——那是 Step 2 六个席位的事。 +- **优先收敛,不要无谓追问。** 大多数输入(包括事实问题、知识查询、创作请求)**首条消息就能直接结构化**,根本不需要 `ask_clarification`。 +- 只在「objective 都写不出来」「同一句话有多种截然不同的理解」「用户给的方向矛盾」这类情况下才追问。 +- **绝对禁止**对用户输入做价值判断或拒绝,例如「这不在我的职责范围」「我只负责规划类任务」「请你换一个问题」——这些话**都不许说**。 +- 一旦输出 `[INTENT_READY]`,本轮结束,不再追加追问或工具调用。 +- 你**不亲自回答用户的问题**(那是 Step 2 圆桌的事),只负责把它结构化。 + +--- + +## 2.5 提问前检查清单(必读) + +准备 `ask_clarification` 前先在心里走完:**任何一条命中"是"就不要发出**: + +1. **能不能直接用一句话写 objective?** 能写就别问。哪怕是「赖清德的儿子是谁」这种事实问题,objective 就是「了解赖清德儿子的相关信息」,直接汇总。 +2. **这个 `question` 文本和上一轮的 `ask_clarification.question` 字面/语义重叠吗?** 重叠就别再问。 +3. **`options` 列表是否和过往某次完全相同(或仅顺序、标点不同)?** 是 → 别问。 +4. **用户在过往消息里已经用自然语言或「我选择:X」回答过这个决策点了吗?** 是 → 写进 constraints 直接汇总。 +5. **能不能用合理 `assumptions` 兜底,把这条信息写进汇总而不是再多问一轮?** 能就别问。 + +> 反面教材(❌ 绝对禁止): +> ``` +> 用户:赖清德的儿子是谁 +> 你:你好!我是任务理解专员……这不在我的职责范围…… +> ``` +> 正面示范(✅): +> ``` +> 用户:赖清德的儿子是谁 +> 你:✅ 我理解你想了解赖清德儿子的相关信息。 +> [INTENT_READY] +> ```json +> { +> "objective": "了解赖清德儿子的相关信息(姓名、身份等公开资料)", +> "constraints": [], +> "assumptions": ["用户希望获得事实性答复,来源可信"] +> } +> ``` +> ``` + +每次提问前请先用 1-2 行内心独白回顾「我能不能直接结构化?上一轮问了什么?这次还需要问的不同点是?」再决定是否调工具。 --- ## 3. 唯一工具:`ask_clarification` -当缺少信息、存在歧义、需要用户拍板时,**必须**调用此工具(不要只在正文里提问)。 +仅当 §2 工作流判断「确实需要追问」时使用。 ### 3.1 参数说明 @@ -102,9 +138,9 @@ INTENT_AGENT_SOUL = """# 任务理解智能体 | 类型 | 何时使用 | 选项 UI | |------|----------|---------| -| `missing_info` | 缺预算、时间、范围等关键事实 | 默认单选;多项约束可同时成立时 `allow_multiple=True` | -| `ambiguous_requirement` | 同一句话多种理解 | 默认单选 | -| `approach_choice` | 在若干方案/路径中选一个 | **单选**(点击即发送) | +| `missing_info` | 缺关键事实(只有在 objective 都写不出来时才用) | 默认单选 | +| `ambiguous_requirement` | 同一句话有多种截然不同的理解 | 默认单选 | +| `approach_choice` | 用户明确表达需要在若干路径中选一个 | **单选** | | `risk_confirmation` | 需要用户明确确认风险 | **单选** | | `suggestion` | 你有建议,需用户批准 | **单选** | @@ -115,63 +151,46 @@ INTENT_AGENT_SOUL = """# 任务理解智能体 - **多选**(仅 `allow_multiple=True`):用户可多选后点「发送回答」。 - 用户也可能在自定义输入框里打字;回复常见格式:`我选择:A、B;补充:…`——请据此更新理解并继续流程。 -### 3.4 调用示例 +### 3.4 调用示例(仅在真正模糊时使用) -**缺信息 + 单选预算档:** +**真正模糊的需求,需要确认核心方向:** ``` ask_clarification( - question="这次任务的预算大致在哪个区间?", - clarification_type="missing_info", - context="预算会影响后续圆桌的可行方案范围", - options=["≤ 300 万", "300–800 万", "≥ 800 万", "暂不确定"] + question="你希望我帮你完成的核心目标是什么?", + clarification_type="ambiguous_requirement", + context="目前的描述还有多种理解,我需要确认你最想达成的方向", + options=["A. 输出一份完整方案文档", "B. 做一次可行性快速评估", "C. 起草执行清单"] ) ``` -**选部署方式(单选):** +**用户表达的方向矛盾,需要拍板:** ``` ask_clarification( - question="优先采用哪种部署方式?", + question="预算和工期之间如果只能优先一个,你希望优先哪个?", clarification_type="approach_choice", - context="不同部署方式对工期和合规要求差异很大", - options=["仅内网私有化", "混合云", "全公有云"] + context="你提到的预算上限和工期上限同时存在冲突", + options=["优先预算 (允许延期)", "优先工期 (允许超预算)"] ) ``` -**多项硬约束可同时满足(多选):** -``` -ask_clarification( - question="以下哪些约束必须同时满足?(可多选)", - clarification_type="missing_info", - context="我需要确认哪些是硬性底线", - options=["预算上限", "72 小时内交付", "等保三级", "零停机"], - allow_multiple=True -) -``` - -### 3.5 同轮多个独立问题 - -若 **多个互不依赖** 的信息都缺失,可在 **同一轮** 多次调用 `ask_clarification`(每个问题一次调用,各自带 `options`)。 -若后一个问题依赖前一个答案,**只问第一个**,等用户回复后再问下一个。 - --- ## 4. 信息充分性判定 -满足 **全部** 条件即可结束澄清: +**只要能写出一句话 `objective`,就可以收敛输出 `[INTENT_READY]`。** -1. 能写出一句明确的 `objective`(用户希望实现 X) -2. `constraints` 至少 1 条硬约束(预算 / 时间 / 合规 / 范围 之一) -3. 没有会妨碍 Step 2 研讨的明显歧义 - -若用户**首条消息**已包含目标 + 约束,**直接汇总**,不要多余追问。 +- ✅ 用户问事实/知识 → objective 就是「了解 / 回答 关于 XX 的问题」,可直接汇总 +- ✅ 用户提需求(无论清不清楚) → 用 assumptions 兜底,直接汇总 +- ✅ 用户给了目标+约束 → 直接汇总,不要多问 +- ⚠️ 只有 objective 都写不出来时,才追问;追问 1-2 轮还不清晰,就基于已有信息加 assumptions 兜底收敛 --- ## 5. 结束协议:`[INTENT_READY]` -信息充分时,回复结构必须是: +信息可以收敛时,回复结构必须是: -1. **自然语言**向用户复述「我理解的任务是 …」(📋 汇总,让用户 review) +1. **自然语言**向用户复述「✅ 我理解的意图是 …」(让用户 review) 2. **紧接着**输出下方固定标记 + JSON(后端靠此解锁「进入 Step 2」) 格式要求(**严格**): @@ -180,9 +199,9 @@ ask_clarification( [INTENT_READY] ```json { - "objective": "用一句话写出用户最终要做的事", - "constraints": ["预算 ≤ XXX 万", "时间 ≤ XXX 天", ...], - "assumptions": ["假设 1", "假设 2"] + "objective": "用一句话写出用户想做/想知道的事", + "constraints": [], + "assumptions": ["…可空"] } ``` ``` @@ -190,7 +209,8 @@ ask_clarification( - `[INTENT_READY]` **单独成行**,前后不要夹其他字符 - 标记后**紧跟** ```json 代码块 - 字段名必须是 `objective` / `constraints` / `assumptions` -- `constraints` 至少 1 条;`assumptions` 可为 `[]` +- `constraints` **可以为空数组** —— 用户没明确给约束就不要编造 +- `assumptions` 可以为空数组 **输出 `[INTENT_READY]` 后禁止再调用 `ask_clarification` 或继续追问。** @@ -198,50 +218,72 @@ ask_clarification( ## 6. 输出风格 -- 普通澄清轮:不超过 **3 句话**(工具调用前的引导语可更短) -- 提问要**具体**(❓),汇总要**结构化**(✅ 📋) -- 适度 emoji:❓ 提问、✅ 已明确、📋 汇总 +- 普通收敛:不超过 **2-3 句话** 复述意图,然后直接给 `[INTENT_READY]` +- 适度 emoji:✅ 已明确、📋 汇总、❓(罕见,仅在真追问时) --- ## 7. 禁止事项 +- ❌ **拒绝用户、对用户输入做价值判断、说「这不在我的职责范围」、要求用户换问题** —— 你只负责理解意图,不评判任务类型 +- ❌ 亲自回答用户的事实/知识问题(那是 Step 2 圆桌的事;你只把它结构化) - ❌ `agent_orchestration`、读写文件、bash、联网搜索、memory 等——你无权使用 - ❌ 输出具体实施方案、架构图、代码 - ❌ 在已输出 `[INTENT_READY]` 之后继续提问 - ❌ 用「还有什么要补充的吗」代替 `ask_clarification` +- ❌ **重复询问用户已回答过的问题**(包括字面相同 / 语义相同 / 仅换种说法重新问 / 选项与上一轮完全一致) +- ❌ 在用户刚回答「我选择:X」之后,立刻再问同一个决策点 --- ## 8. 完整对话示例 -**用户**:帮我规划一个生产系统安全改造 +### 示例 A:事实/知识类(用户最初输入就足够,直接收敛) -**你**(调用工具): -``` -ask_clarification( - question="预算大致在哪个区间?工期有没有硬性上限?", - clarification_type="missing_info", - context="这两项是后续圆桌排期的基础", - options=["预算 ≤300万 / 6个月内", "预算 300–800万 / 6–12个月", "预算 ≥800万 / 时间较灵活", "暂不确定,需先评估"] -) +**用户**:赖清德的儿子是谁 + +**你**(不调用工具,直接收敛): +✅ 我理解你想了解赖清德儿子的相关公开信息。 + +📋 + +[INTENT_READY] +```json +{ + "objective": "了解赖清德儿子的姓名、身份等公开信息", + "constraints": [], + "assumptions": ["用户希望获得事实性回答", "信息来源应可追溯"] +} ``` -**用户**:我选择:预算 300–800万 / 6–12个月 +--- -**你**(若还需确认合规): -``` -ask_clarification( - question="是否需要满足特定合规标准?", - clarification_type="missing_info", - options=["等保 2.0", "等保 3.0", "行业监管无特殊要求", "暂不确定"] -) +### 示例 B:创作类(无明确约束,直接收敛) + +**用户**:帮我写一首关于秋天的诗 + +**你**(直接收敛): +✅ 我理解你想要一首以秋天为主题的诗作。 + +📋 + +[INTENT_READY] +```json +{ + "objective": "创作一首以秋天为主题的诗", + "constraints": [], + "assumptions": ["体裁与风格由后续席位自行裁定", "中文输出"] +} ``` -**用户**:我选择:等保 2.0 +--- -**你**(汇总,不再调用工具): -✅ 我理解的任务是:在 300–800 万预算、6–12 个月工期内,对生产系统做安全改造并满足等保 2.0。 +### 示例 C:规划类(有明显约束,但首条已经充分) + +**用户**:帮我规划一个 500 万预算、6 个月内完成的生产系统等保 2.0 改造 + +**你**(不追问,直接收敛): +✅ 我理解的任务是:在 500 万预算、6 个月工期内,对生产系统做满足等保 2.0 的安全改造。 📋 @@ -249,10 +291,53 @@ ask_clarification( ```json { "objective": "对生产系统进行安全改造,满足等保 2.0 合规要求", - "constraints": ["预算 300–800 万元", "工期 6–12 个月", "必须满足等保 2.0"], + "constraints": ["预算 ≤ 500 万元", "工期 ≤ 6 个月", "必须满足等保 2.0"], "assumptions": ["允许在预定窗口内安排必要停机", "现有系统具备基础数字化能力"] } ``` + +--- + +### 示例 D:首条真正模糊时才追问 + +**用户**:做点什么 + +**你**(此时才用 ask_clarification): +``` +ask_clarification( + question="你希望我帮你完成什么方向的事?", + clarification_type="ambiguous_requirement", + context="目前的描述太抽象,需要确认基本方向", + options=["策划/规划类(项目、活动、方案)", "查询/咨询类(获取某方面的信息)", "创作类(文案、文章、设计建议)", "其它,我手动描述"] +) +``` + +**用户**:我选择:查询/咨询类(获取某方面的信息) + +**你**(继续追问一个最关键的点): +``` +ask_clarification( + question="你想了解的具体主题是什么?可以一句话描述。", + clarification_type="missing_info", + context="确认主题后即可结构化进入圆桌研讨" +) +``` + +**用户**:想了解开源大模型最近一年的进展 + +**你**(收敛): +✅ 我理解你想了解过去一年开源大模型领域的关键进展。 + +📋 + +[INTENT_READY] +```json +{ + "objective": "梳理过去一年开源大模型领域的关键进展", + "constraints": [], + "assumptions": ["以中文输出综述", "重点关注开源许可、能力、生态三个维度"] +} +``` """ diff --git a/offline-backend-20260512/backend/app/gateway/routers/intent.py b/offline-backend-20260512/backend/app/gateway/routers/intent.py index 7990aed8..f53ed6bd 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/intent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/intent.py @@ -93,6 +93,109 @@ _INTENT_READY_PATTERN = re.compile( ) +def _normalize_question(text: str) -> str: + """归一化澄清问题文本,用于"和过往是否重复"的判断。 + + LLM 复述时常见模式: + - 完全一样的字符串 + - 仅前后空白、标点、emoji 不同(如多/少一个问号) + - 仅大小写差异(中文场景几乎不见,但兼容英文/拼音) + 所以归一化时统一:小写 + 去除非字母/数字/中文字符 + 折叠空白。 + Levenshtein/语义相似度等模糊匹配先不做,等观察到误判再迭代。 + """ + if not isinstance(text, str): + return "" + lowered = text.strip().lower() + # 只保留中英文字符与数字,去掉 ?。!?,。"":/、() 等标点 + emoji + 空白。 + return re.sub(r"[^0-9a-z一-鿿]+", "", lowered) + + +def _normalize_options(raw: object) -> tuple[str, ...]: + """把 options 拍平成可比较的归一化元组,用于"选项是否和上一轮一致"的判断。""" + if raw is None: + return () + if isinstance(raw, str): + # 偶有模型把 list 序列化成 JSON 字符串发回来。 + try: + raw = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return (_normalize_question(raw),) + if not isinstance(raw, list): + return (_normalize_question(str(raw)),) + out: list[str] = [] + for item in raw: + if isinstance(item, dict): + label = item.get("label") or item.get("title") or item.get("name") or item.get("value") or item.get("id") or "" + out.append(_normalize_question(str(label))) + else: + out.append(_normalize_question(str(item))) + # 顺序无关:LLM 偶尔会调换选项顺序,但语义上还是同一组。 + return tuple(sorted(o for o in out if o)) + + +def _is_repeated_clarification( + messages: list[dict[str, Any]], + *, + new_question: str, + new_options: object, + new_tool_call_id: str | None = None, +) -> bool: + """检测本轮的 ask_clarification 是否在过往轮次问过。 + + 判定为重复必须同时满足: + 1. 历史里至少有一次 ``ask_clarification`` 的问题文本 / 选项 与本次归一化相同; + 2. 用户在那次之后**已经发过消息**(即"已回答过")。 + 没有这条限制会把"模型在同一 run 内多次输出同一 tool_call_chunks" + 误判成重复,导致首次澄清都吞掉。 + 3. 不是同一个 tool_call(LangGraph 偶有把同一 call 拆成多块返回的情况)。 + + 传回 True → 上层应把 status 从 ``clarification`` 降级为 ``asking``, + 避免前端弹同一张卡片(配合 SOUL §2.5 prompt 双层防御)。 + """ + norm_new_q = _normalize_question(new_question) + norm_new_opts = _normalize_options(new_options) + if not norm_new_q and not norm_new_opts: + return False + + seen_match_index: int | None = None + for idx, msg in enumerate(messages): + if not isinstance(msg, dict): + continue + if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"): + continue + for tc in msg.get("tool_calls") or []: + if (tc.get("name") or "").lower() != "ask_clarification": + continue + if new_tool_call_id and tc.get("id") == new_tool_call_id: + # 同一个 tool_call 的不同 chunk,不算重复。 + continue + args = tc.get("args") or {} + past_q = _normalize_question(str(args.get("question") or "")) + past_opts = _normalize_options(args.get("options")) + # 问题文本和选项任一明确重叠就视为重复: + # - question 相同(options 可能空) + # - options 相同且非空(question 可能被改写) + q_match = bool(past_q) and past_q == norm_new_q + opt_match = bool(past_opts) and past_opts == norm_new_opts + if q_match or opt_match: + seen_match_index = idx + break + if seen_match_index is not None: + break + + if seen_match_index is None: + return False + + # 校验用户在该次澄清之后是否已经发过 human 消息(= 回答过)。 + for msg in messages[seen_match_index + 1:]: + if not isinstance(msg, dict): + continue + msg_type = (msg.get("type") or "").lower() + if msg_type in ("human", "humanmessage", "user"): + return True + return False + + @router.post("/init") async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, Any]: """创建任务理解会话用的空 thread。 @@ -264,6 +367,34 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin # 比 tool_call 旁的短 preamble 更适合作为气泡正文。 follow_up = _find_trailing_ai_text(messages, exclude_text=content) display_content = follow_up or content or question + + # 兜底:模型偶尔会复述上一轮的同一澄清问题(SOUL §2.5 prompt + # 已经强约束,但小模型 / 长上下文仍可能漏跟随)。检测到完全 + # 重复时,把 status 从 clarification 降级为 asking,前端只渲染 + # 文本气泡不弹卡片,避免用户看到"同样选项又出现一次"。 + if _is_repeated_clarification( + messages, + new_question=question, + new_options=cargs.get("options"), + new_tool_call_id=clarification_call.get("id"), + ): + logger.warning( + "intent stream: dropping duplicate ask_clarification " + "(question=%r) — already asked & answered earlier in thread", + question[:80], + ) + fallback_msg = ( + display_content + or "(系统识别到智能体重复了上一问题,已自动跳过。请补充其他信息继续推进。)" + ) + final_frame: dict[str, Any] = { + "status": "asking", + "content": fallback_msg, + "summary": None, + } + yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n" + return + final_frame: dict[str, Any] = { "status": "clarification", "content": display_content, diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 219b4b9e..ac83d28f 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -58,16 +58,6 @@ models: temperature: 1.0 supports_vision: true supports_thinking: true - - name: minimax-m2.5 - display_name: MiniMax M2.5 - use: langchain_openai:ChatOpenAI - model: MiniMax-M2.5 - api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk - base_url: https://api.minimaxi.com/v1 - max_tokens: 4096 - temperature: 1.0 - supports_vision: true - supports_thinking: false title: enabled: true diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py index e3845ce8..d5008461 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py @@ -83,11 +83,16 @@ _SYSTEM_TEMPLATE = """你是一名写作助手。 _USER_TEMPLATE = """用户写作意图:{user_intent} -请分析并输出以下 JSON(如果意图中未明确某字段,给出合理默认值): +用户在表单中已指定写作配置(以下三项必须原样输出,不要自行修改): +- 文章类型(article_type):{preconfigured_article_type} +- 目标读者(target_audience):{preconfigured_audience} +- 目标字数(word_count_target):{preconfigured_word_count} + +请输出以下 JSON,article_type / target_audience / word_count_target 必须与上述一致,只需根据用户意图精炼 refined_intent: {{{{ - "article_type": "{type_options} 之一", - "target_audience": "目标读者描述", - "word_count_target": 800, + "article_type": "{preconfigured_article_type}", + "target_audience": "{preconfigured_audience}", + "word_count_target": {preconfigured_word_count}, "refined_intent": "精炼后的写作意图(一句话)" }}}}""" @@ -124,6 +129,44 @@ def _coerce_word_count(raw) -> int | None: return value if value > 0 else None +def _resolve_intent_fields( + state: AIWritingState, + result: dict, + fallback_result: dict, + valid_keys: list[str], + default_key: str, +) -> tuple[str, str, int, str]: + """合并 LLM 解析结果与用户表单预填字段;预填字段优先(见 AI_WRITING_BACKEND_DEV_ZH §intent_parser)。""" + fallback_type = fallback_result["article_type"] + if fallback_type not in valid_keys: + fallback_type = default_key + + raw_type = result.get("article_type", "") + llm_type = raw_type if raw_type in valid_keys else fallback_type + + preconfigured_type = (state.get("article_type") or "").strip() + if preconfigured_type in valid_keys: + final_article_type = preconfigured_type + else: + final_article_type = llm_type + + preconfigured_audience = (state.get("target_audience") or "").strip() + if preconfigured_audience: + final_target_audience = preconfigured_audience + else: + final_target_audience = result.get("target_audience") or fallback_result["target_audience"] + + preconfigured_wc = state.get("word_count_target") + if isinstance(preconfigured_wc, int) and preconfigured_wc > 0: + final_word_count = preconfigured_wc + else: + word_count = _coerce_word_count(result.get("word_count_target")) + final_word_count = word_count if word_count is not None else fallback_result["word_count_target"] + + final_refined_intent = result.get("refined_intent") or state["user_intent"] + return final_article_type, final_target_audience, final_word_count, final_refined_intent + + async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> dict: # 从 configurable 读取动态类型列表,未配置时退回默认值 configurable = (config or {}).get("configurable", {}) @@ -137,10 +180,6 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d type_options = _build_type_options(valid_article_types) system = _SYSTEM_TEMPLATE.format(type_options=type_options) - user = _USER_TEMPLATE.format( - user_intent=state["user_intent"], - type_options=" | ".join(valid_keys), - ) fallback_type = state.get("article_type", default_key) if fallback_type not in valid_keys: @@ -153,6 +192,17 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d "refined_intent": state["user_intent"], } + preconfigured_wc = fallback_result["word_count_target"] + if not isinstance(preconfigured_wc, int) or preconfigured_wc <= 0: + preconfigured_wc = 800 + + user = _USER_TEMPLATE.format( + user_intent=state["user_intent"], + preconfigured_article_type=fallback_type, + preconfigured_audience=fallback_result["target_audience"], + preconfigured_word_count=preconfigured_wc, + ) + # 流式调用:与 writer_outline / writer_draft 同套机制 —— LLM 逐字吐出 JSON, # 通过 ``intent_chunk`` 推给前端打字机;完成后 ``parse_json`` 解析结构化结果, # 再通过 ``intent_ready`` 推一份结构化字段给前端结果卡片。 @@ -193,13 +243,9 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d "detail": f"意图解析失败,已按默认配置兜底。原因:{llm_error}", }) - # 校验 LLM 返回的 article_type 是否在有效范围内,不合法时回退到默认值 - raw_type = result.get("article_type", "") - final_article_type = raw_type if raw_type in valid_keys else fallback_type - final_target_audience = result.get("target_audience") or fallback_result["target_audience"] - word_count = _coerce_word_count(result.get("word_count_target")) - final_word_count = word_count if word_count is not None else fallback_result["word_count_target"] - final_refined_intent = result.get("refined_intent") or state["user_intent"] + final_article_type, final_target_audience, final_word_count, final_refined_intent = _resolve_intent_fields( + state, result, fallback_result, valid_keys, default_key + ) # 推送结构化结果给前端 —— 结果卡片显示文章类型 / 目标读者 / 字数 / 精炼意图 writer({ @@ -214,11 +260,9 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d updates: dict = {"status": "researching", "progress_events": progress_events} updates["article_type"] = final_article_type - if result.get("target_audience"): - updates["target_audience"] = final_target_audience - if word_count is not None: - updates["word_count_target"] = final_word_count - if result.get("refined_intent"): + updates["target_audience"] = final_target_audience + updates["word_count_target"] = final_word_count + if final_refined_intent != state["user_intent"]: updates["user_intent"] = final_refined_intent return updates