diff --git a/AGENTS.md b/AGENTS.md index bdab561a..9d937b47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,7 +102,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/` - **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js) - **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management//{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations stay isolated even though they share one taskId; **xdfx** passes a third `actionId` so it filters by `metadata.actionId` too, partitioning chat records per 行动 — `useChatsBase` derives it from `TaskContext.partitionByAction`) + 新建对话; the `…/chats` index (`TaskFxIndexResolver`) opens the latest task thread or 新建. The right `:thread_id` host differs by business: **rwfx** → `TaskFxChat` (`AgentChatPage taskGate`) = full workspace (non-`lite`): 目的树闸门 panel + 清除记录 (placed under 新建对话); **xdfx** → also `TaskFxChat` (same 目的树闸门 query+display, same `purpose_detail_url`) but workspace passed `lite`; **3qfx** → `TaskBindChat` (`AgentChatPage taskBind`, `lite`) = 1:N list only, no gate, and 新建对话 **auto-sends** the task opening text (`taskAutoSendText`; gate businesses send it via the gate's 开始问答 instead). **rwfx auto-starts the first Q&A when the 任务目的 query returns empty** (`TaskPurposePanel autoStartWhenEmpty`, threaded from the rwfx `TaskFxChat` only). The 目的树闸门 (`TaskPurposePanel`) defaults **collapsed** and now keeps only 弹框查看 + 开始问答; the former jump buttons (编辑/下一步/3q详情/切换三情/弹框查看) all moved to the **configurable bottom 快捷跳转 bar** (`TaskJumpBar`, red 红底白字 buttons, right-aligned) driven by 按钮管理 — see the 按钮管理 bullet. `TaskPurposeDialog` (exported from `TaskPurposePanel`) backs both the gate zoom and the bottom bar's `purpose`-type button. `chatBasePath` keeps the post-send URL rewrite + new-chat button in the task-management context. Backend config `task_deeplink` (`config.yaml`, surfaced by `GET /api/public/config/task-deeplink`, mirrored in `core/auth/task-deeplink-config.ts`) adds `action_test`/`action_detail_url`/`detail_3q_url` + `mock.action_detail`. Any failure (no consumer URL / fetch error / unmapped goPath / unresolvable action id / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`, `core/auth/task-purpose.ts`, `core/auth/task-deeplink-config.ts`, `core/threads/use-task-threads.ts`. -- **agentfx 任务深链(新系统 iframe 嵌入的智能体问答,3qfx 单智能体 lite 模式的独立复制)**: goPath=`agentfx`(`${loginBase}?password=…&taskId=…&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue`)。与 3qfx 的差异:①任务详情不走外部 consumer,改调**本系统 Gateway** `GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=`(`apiFetch` 带刚登录的 DeerFlow 态;任务数据来自 TaskCOP 任务表,与任务列表/报告导入同一份);②**固定使用内置智能体** `agentfx-analyst`(不走业务映射);③路由 `task-management/plan-task-agentfx/agent/:agent_id/chats`,页面为独立复制的 `AgentFxWorkspace.tsx`(`AgentFxWorkspace`/`AgentFxIndexResolver`/`AgentFxChat` —— 左「当前任务」卡 + 1:N 对话列表 + 新建对话,`AgentChatPage taskBind` 自动发开场白;无 TaskJumpBar/清除记录/闸门/xdfx 行动卡)。模块 `core/auth/agentfx-deeplink.ts`(`isAgentfxGoPath`/`fetchAgentfxTaskDetail`/`resolveAgentfxDeeplinkTarget`);任务详情整包仍存共享键 `login.copTaskDetail`(按标签页隔离),`AgentChatPage` 的 taskBind 自恢复对 agentfx 生效。`LoginPage` 在既有深链分支前用 `isAgentfxGoPath` 分流。**内置「任务研判报告助手」**:后端 seeder `app/gateway/routers/_agentfx_seed.py` + `_agentfx_seed_assets/`(config.yaml + SOUL.md,目录存在不覆盖;app.py lifespan 在 `_sync_legacy_agents` 之前调用,自动进 agents 表 user_id IS NULL)——SOUL 规定工作流「搜集信息(`web_search`,**首步不可跳过**)→ `write_file` 生成 `/mnt/user-data/outputs/report.md` + `task-reports.json`(14 个 categoryType 的 `{contentJson, categoryType}` 数组,contentJson 为 JSON 字符串)→ 聊天总结 → 引导入库」,硬性约束:categoryType 逐字用 14 个全称(禁缩写自造如「DQ关键事件」)、JSON 直接写汉字禁 `\uXXXX` 转义、每类必须有实质内容不许空数组/纯占位、文件一律用 `/mnt/user-data/...` 虚拟路径(防宿主路径泄漏进 tool args)。**检索依赖 `config.yaml tools.web_search`(DDG,enabled 已开;内网需换 configurable_search 时改回并填 endpoint)**。任务工作区输入框在 `taskCtxOn` 时隐藏「写作/记忆/参考文献」(InputBox `hideContextTools` prop)与「知识空间」(不传 knowledgeSelector);消息流里 write_file 文件卡片只显示文件名(`artifactDisplayName` 收敛宿主/虚拟路径)。**入库两条路径**:(a) **前端协助卡** —— `AgentChatPage`(taskBind 且非 taskGate)检测线程 artifacts 出现 `task-reports.json`(按文件名后缀匹配,兼容本地沙箱记录的宿主路径)且流式结束时,向 displayThread 尾部注入 `additional_kwargs.task_report_import_approval` 虚拟 ai 消息(`core/messages/utils.ts` 分组 `assistant:task-report-import` + `MessageList` 的 `taskReportImportSlot` 插槽渲染 `TaskReportImportCard`),点「开始入库」前端校验(`strategy-components/lib/agentfx-report-schema.ts`,14 类 + contentJson 合法 JSON + **\u 转义归一化还原为汉字**,与技能脚本同规则)后 `importTaskReport`(`strategy-components/api/task-reports.ts`)POST 导入接口整体替换;任务已有详情**不抑制**卡片(入库即整体替换,卡片文案说明覆盖语义),导入成功按 thread 记 sessionStorage(刷新不重复询问)、「暂不入库」为会话内记忆;(b) **入库技能** `task-report-import`(seeder 一并复制到 `skills/public/`,public 类技能 extensions_config 缺省即启用)—— 用户聊天里说「入库」时智能体按 SKILL.md 用 bash 跑 `scripts/task_report_import.py`(纯标准库:同样的校验规则 + 转义归一化;**默认关闭 SSL 证书校验** `ssl._create_unverified_context` + 忽略代理,适配内网自签证书;鉴权 `--token`/env `AGENTFX_IMPORT_TOKEN`→`DEERFLOW_AUTH_TOKEN`→`TOOLS_TOKEN` 优先,缺省经公共端点 `POST /api/parallel-agents/auth/token` 以 `agentfx-import` 用户现签 token——内网既有鉴权旁路;gateway 地址 env `AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:{DEER_FLOW_GATEWAY_PORT}`);`--strict` 可要求 14 类齐全,默认允许部分导入并在 stdout JSON 的 `missing_categories` 里列缺。入库成功后任务 TaskCOP 状态自动置 25(已完成分析)。**入库成功通知宿主**(iframe 嵌入场景):两条路径成功后都会 `window.top.postMessage({source: "magent-web", type: "task-report:saved", taskId: String(taskId)}, "*")`(`notifyTaskReportSaved`,`agentfx-report-schema.ts`)——卡片路径在 `TaskReportImportCard` 导入成功回调里直发;技能路径由 `AgentChatPage` 从消息流检测 `task_report_import.py` 的成功 stdout(`"success": true` + `task_id`)代发——**仅在回合结束后检测**(`!thread.isLoading` 才扫描;流式过程中 messages 每增量全量扫描会卡死页面),只看线程内**最后一次**成功入库(新一次入库触发新通知;刷新旧对话按 thread 记 sessionStorage 已通知的消息 id 不重复发)。 +- **agentfx 任务深链(新系统 iframe 嵌入的智能体问答,3qfx 单智能体 lite 模式的独立复制)**: goPath=`agentfx`(`${loginBase}?password=…&taskId=…&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue`)。与 3qfx 的差异:①任务详情不走外部 consumer,改调**本系统 Gateway** `GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=`(`apiFetch` 带刚登录的 DeerFlow 态;任务数据来自 TaskCOP 任务表,与任务列表/报告导入同一份);②**固定使用内置智能体** `agentfx-analyst`(不走业务映射);③路由 `task-management/plan-task-agentfx/agent/:agent_id/chats`,页面为独立复制的 `AgentFxWorkspace.tsx`(`AgentFxWorkspace`/`AgentFxIndexResolver`/`AgentFxChat` —— 左「当前任务」卡 + 1:N 对话列表 + 新建对话,`AgentChatPage taskBind` 自动发开场白;无 TaskJumpBar/清除记录/闸门/xdfx 行动卡)。模块 `core/auth/agentfx-deeplink.ts`(`isAgentfxGoPath`/`fetchAgentfxTaskDetail`/`resolveAgentfxDeeplinkTarget`);任务详情整包仍存共享键 `login.copTaskDetail`(按标签页隔离),`AgentChatPage` 的 taskBind 自恢复对 agentfx 生效。`LoginPage` 在既有深链分支前用 `isAgentfxGoPath` 分流。**内置「任务研判报告助手」**:后端 seeder `app/gateway/routers/_agentfx_seed.py` + `_agentfx_seed_assets/`(config.yaml + SOUL.md,目录存在不覆盖;app.py lifespan 在 `_sync_legacy_agents` 之前调用,自动进 agents 表 user_id IS NULL)——SOUL 规定工作流「搜集信息(`web_search`,**首步不可跳过**)→ 每次检索 JSON 原样落盘 `/mnt/user-data/workspace/hits/qN.json` → `task-report-{enemy,our,env,judge}` 技能函数转换成 14 类 `task-reports.json`(模型禁止手写该 JSON)→ `write_file` 生成 `/mnt/user-data/outputs/report.md` → 聊天总结 → 引导入库」,硬性约束:categoryType 逐字用 14 个全称(禁缩写自造如「DQ关键事件」)、JSON 直接写汉字禁 `\uXXXX` 转义、每类必须有实质内容不许空数组/纯占位、文件一律用 `/mnt/user-data/...` 虚拟路径(防宿主路径泄漏进 tool args)。**检索依赖 `config.yaml tools.web_search`(DDG,enabled 已开;内网需换 configurable_search 时改回并填 endpoint)**。任务工作区输入框在 `taskCtxOn` 时隐藏「写作/记忆/参考文献」(InputBox `hideContextTools` prop)与「知识空间」(不传 knowledgeSelector);消息流里 write_file 文件卡片只显示文件名(`artifactDisplayName` 收敛宿主/虚拟路径)。**入库两条路径**:(a) **前端协助卡** —— `AgentChatPage`(taskBind 且非 taskGate)检测线程 artifacts 出现 `task-reports.json`(按文件名后缀匹配,兼容本地沙箱记录的宿主路径)且流式结束时,向 displayThread 尾部注入 `additional_kwargs.task_report_import_approval` 虚拟 ai 消息(`core/messages/utils.ts` 分组 `assistant:task-report-import` + `MessageList` 的 `taskReportImportSlot` 插槽渲染 `TaskReportImportCard`),点「开始入库」前端校验(`strategy-components/lib/agentfx-report-schema.ts`,14 类 + contentJson 合法 JSON + **\u 转义归一化还原为汉字**,与技能脚本同规则)后 `importTaskReport`(`strategy-components/api/task-reports.ts`)POST 导入接口整体替换;任务已有详情**不抑制**卡片(入库即整体替换,卡片文案说明覆盖语义),导入成功按 thread 记 sessionStorage(刷新不重复询问)、「暂不入库」为会话内记忆;(b) **入库技能** `task-report-import`(seeder 一并复制到 `skills/public/`,public 类技能 extensions_config 缺省即启用)—— 用户聊天里说「入库」时智能体按 SKILL.md 用 bash 跑 `scripts/task_report_import.py`(纯标准库:同样的校验规则 + 转义归一化;**默认关闭 SSL 证书校验** `ssl._create_unverified_context` + 忽略代理,适配内网自签证书;鉴权 `--token`/env `AGENTFX_IMPORT_TOKEN`→`DEERFLOW_AUTH_TOKEN`→`TOOLS_TOKEN` 优先,缺省经公共端点 `POST /api/parallel-agents/auth/token` 以 `agentfx-import` 用户现签 token——内网既有鉴权旁路;gateway 地址 env `AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:{DEER_FLOW_GATEWAY_PORT}`);`--strict` 可要求 14 类齐全,默认允许部分导入并在 stdout JSON 的 `missing_categories` 里列缺。入库成功后任务 TaskCOP 状态自动置 25(已完成分析)。**入库成功通知宿主**(iframe 嵌入场景):两条路径成功后都会 `window.top.postMessage({source: "magent-web", type: "task-report:saved", taskId: String(taskId)}, "*")`(`notifyTaskReportSaved`,`agentfx-report-schema.ts`)——卡片路径在 `TaskReportImportCard` 导入成功回调里直发;技能路径由 `AgentChatPage` 从消息流检测 `task_report_import.py` 的成功 stdout(`"success": true` + `task_id`)代发——**仅在回合结束后检测**(`!thread.isLoading` 才扫描;流式过程中 messages 每增量全量扫描会卡死页面),只看线程内**最后一次**成功入库(新一次入库触发新通知;刷新旧对话按 thread 记 sessionStorage 已通知的消息 id 不重复发)。 - **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。 ### Configuration diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index 19fad244..bde7a5bc 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -24,7 +24,6 @@ import { getBackendBaseURL } from "@/core/config"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag"; -import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag"; import { useSkills } from "@/core/skills/hooks"; import type { SkillDisplayConfig } from "@/core/skills/type"; import { @@ -123,9 +122,8 @@ const ChatBox: React.FC<{ // batches through DeerFlow's backend APIs. const hasFrontendRag = useMemo( () => - (thread.values.messages ?? []).some( - (message) => - isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message), + (thread.values.messages ?? []).some((message) => + isSingleRoundRagMessage(message), ), [thread.values.messages], ); @@ -973,8 +971,7 @@ function buildTurnReferenceBatches( // GeneralQA's single-RAG stream sends `docs` before the answer. Make // those cards available immediately, while keeping normal streaming turns // on their existing "wait for an answer" behavior. - const isFrontendRagTurn = - isSingleRoundRagMessage(assistant) || isMultiRoundRagMessage(assistant); + const isFrontendRagTurn = isSingleRoundRagMessage(assistant); if (nextHumanIndex === -1 && isLoading && !isFrontendRagTurn) { continue; } @@ -1025,7 +1022,7 @@ function findFrontendRagAssistant( const message = messages[index]; if ( message?.type === "ai" && - (isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message)) + isSingleRoundRagMessage(message) ) { return message; } 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 dc548ff1..a0eb0506 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -7,8 +7,6 @@ import { FileIcon, Loader2Icon, PencilIcon, - SearchIcon, - SparklesIcon, } from 'lucide-react'; import { memo, @@ -77,7 +75,6 @@ import { humanMessagePlugins } from '@/core/streamdown'; import { getMultiRoundRagTrace, isMultiRoundRagMessage, - type MultiRoundRagRound, } from '@/core/threads/use-multi-round-rag'; import { cn } from '@/lib/utils'; @@ -90,6 +87,7 @@ import { DeleteMessageButton } from './delete-message-button'; import { DownloadWordButton } from './download-word-button'; import { MarkdownContent, type MarkdownContentProps } from './markdown-content'; import { MessageTokenUsage } from './message-token-usage'; +import { MultiRoundRagAssistantBody } from './multi-round-rag-message-list'; type EmbeddedPageContext = { title?: string; @@ -159,9 +157,9 @@ export function MessageListItem({ className, fitRowToContent = false, anchorId, - message, - questionMarkdown, - isLoading, + message, + questionMarkdown, + isLoading, threadId, tokenUsageEnabled = false, displaySources = [], @@ -178,9 +176,9 @@ export function MessageListItem({ * message. `scroll-mt` keeps the target clear of the overlay header. */ anchorId?: string; - message: Message; - /** Nearest preceding user question, used by the current-Q&A Word export. */ - questionMarkdown?: string; + message: Message; + /** Nearest preceding user question, used by the current-Q&A Word export. */ + questionMarkdown?: string; isLoading?: boolean; threadId: string; tokenUsageEnabled?: boolean; @@ -330,12 +328,12 @@ export function MessageListItem({
{messageMarkdown && ( - + )} {canSaveAssistantEdit && ( @@ -380,12 +378,12 @@ function HumanMessageBlock({ messageId?: string; text: string; pageContext?: EmbeddedPageContext | null; - filesList: React.ReactNode; + filesList: ReactNode; messageTime: { label: string; full: string } | null; components: MarkdownContentProps["components"]; onEditResubmit?: (text: string) => void; onEditSave?: (messageId: string, text: string) => void | Promise; - deleteButton?: React.ReactNode; + deleteButton?: ReactNode; }) { const { t } = useI18n(); const [editing, setEditing] = useState(false); @@ -629,9 +627,7 @@ function MessageContent_({ href={href} {...props} onReferenceClick={ - isSingleRoundRag || isMultiRoundRag - ? handleCitationClick - : undefined + isSingleRoundRag ? handleCitationClick : undefined } /> ); @@ -658,7 +654,6 @@ function MessageContent_({ }), [ handleCitationClick, - isMultiRoundRag, isSingleRoundRag, resolveArtifact, threadId, @@ -697,9 +692,12 @@ function MessageContent_({ ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : ''; } + if (isMultiRoundRag) { + return stripIncompleteCitationMarker(rawContent ?? ''); + } const withCitations = applyInlineCitations(rawContent ?? '', displaySources); return stripIncompleteCitationMarker(withCitations); - }, [rawContent, isHuman, displaySources]); + }, [rawContent, isHuman, isMultiRoundRag, displaySources]); const markdownRehypePlugins = useMemo( () => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]], [rehypePlugins], @@ -734,7 +732,7 @@ function MessageContent_({ } // Reasoning-only AI message (no main response content yet) - if (!isHuman && reasoningContent && !rawContent && !multiRoundTrace) { + if (!isHuman && reasoningContent && !rawContent && !isMultiRoundRag) { return ( @@ -755,7 +753,11 @@ function MessageContent_({ // it exists server-side (real, non-optimistic id). Deleting it cascades to // its answer on the backend. const canDelete = - !!onMessageDeleted && !!message.id && !message.id.startsWith('opt-'); + !!onMessageDeleted && + !!message.id && + !message.id.startsWith('opt-') && + !isMultiRoundRag && + !isSingleRoundRag; return ( {filesList}
- {multiRoundTrace ? ( - ) : ( )}
- {(isSingleRoundRag || isMultiRoundRag) && displaySources.length > 0 && ( + {isSingleRoundRag && displaySources.length > 0 && ( ) : null}
- {(isSingleRoundRag || isMultiRoundRag) && ( + {isSingleRoundRag && ( { @@ -833,11 +835,9 @@ function MessageContent_({ } /** - * 单轮/多轮 RAG 专属的「引用来源」折叠面板。 + * 单轮 RAG 专属的「引用来源」折叠面板。 * - * 参考 GeneralQA 的引用来源区:默认折叠,列出全部参考资料,点击任一条打开详情 - * 弹窗(复用 SingleRoundRagCitationDialog)。这样即使模型正文里没有 `[1]` 编号, - * 用户也能看到本次回答依据的资料。 + * 多轮 RAG 的检索卡片已经画在思考过程里,正文下面不再重复一份来源列表。 */ function FrontendRagSourcesPanel({ sources, @@ -941,349 +941,6 @@ export function SingleRoundRagCitationDialog({ ); } -function MultiRoundRagContent({ - trace, - content, - displaySources, - isLoading, - rehypePlugins, - components, -}: { - trace: NonNullable>; - content: string; - displaySources: RuntimeDisplaySource[]; - isLoading: boolean; - rehypePlugins: MarkdownContentProps['rehypePlugins']; - components: MarkdownContentProps['components']; -}) { - const [open, setOpen] = useState(!trace.isComplete); - useEffect(() => { - // Keep the active turn expanded so each new retrieval is visible as it - // arrives, then match GeneralQA by collapsing the completed trace. The - // header still carries the total round count for quick scanning. - setOpen(!trace.isComplete); - }, [trace.isComplete]); - - return ( -
-
- - {open && ( -
- {trace.rounds.map((round, index) => ( -
- {index < trace.rounds.length - 1 && ( - - )} - - -
- ))} -
- )} -
-
-
最终回答
- {content ? ( - - ) : ( -
- - 正在生成最终回答… -
- )} -
-
- ); -} - -function MultiRoundRagRoundCard({ - index, - round, - displaySources, - isLoading, - rehypePlugins, - components, -}: { - index: number; - round: MultiRoundRagRound; - displaySources: RuntimeDisplaySource[]; - isLoading: boolean; - rehypePlugins: MarkdownContentProps['rehypePlugins']; - components: MarkdownContentProps['components']; -}) { - const [open, setOpen] = useState(!round.query || round.isSearching); - useEffect(() => { - // A newly created round stays expanded to expose its thought and loading - // query. Once that query has returned, retain only the round header until - // the user asks to inspect the completed detail again. - setOpen(!round.query || round.isSearching); - }, [round.id, round.isSearching, round.query]); - const thoughtContent = useMemo( - () => - applyInlineCitations(round.thought, displaySources, { - appendSummary: false, - }), - [displaySources, round.thought], - ); - - return ( - -
- - - - -
- {round.thought && ( - } - > - - - )} - {round.body && ( -
-
本轮正文
- -
- )} - {(round.query || round.isSearching) && ( -
- {round.isSearching ? ( - - ) : ( - - )} - 检索词 - - {round.query || '正在生成检索词…'} - -
- )} - {(round.query || round.isSearching || round.retrievals.length > 0) && ( - } - suffix={{round.retrievals.length} 条} - > - {round.isSearching ? ( -
- - 正在召回相关内容… -
- ) : ( -
- {round.retrievals.length > 0 ? ( - round.retrievals.map((item, resultIndex) => ( - - )) - ) : ( -

- 本轮未召回相关资料。 -

- )} -
- )} -
- )} -
-
-
-
- ); -} - -function MultiRoundRagDetailSection({ - title, - icon, - suffix, - defaultOpen = true, - children, -}: { - title: string; - icon: ReactNode; - suffix?: ReactNode; - defaultOpen?: boolean; - children: ReactNode; -}) { - const [open, setOpen] = useState(defaultOpen); - useEffect(() => setOpen(defaultOpen), [defaultOpen]); - - return ( - -
- - - - - {children} - -
-
- ); -} - -function MultiRoundRagResultCard({ - item, - index, -}: { - item: MultiRoundRagRound['retrievals'][number]; - index: number; -}) { - const [open, setOpen] = useState(index === 1); - const sourceMeta = [item.source, item.time].filter(Boolean).join(' · '); - - return ( - -
- - - - -

- {item.content || '暂无资料摘要'} -

- {(sourceMeta || item.url) && ( -
- {sourceMeta && {sourceMeta}} - {item.url && ( - - 查看原文 - - - )} -
- )} -
-
-
- ); -} - function resolveSingleRoundRagSourceUrl( source: RuntimeDisplaySource | null, ): string | undefined { diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index db7f772f..aee530cf 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -321,11 +321,7 @@ export function MessageList({ isThreadRagModeLocked(threadId) || knowledgeReferencesEnabled; const hasSingleRoundRag = useMemo( - () => - messages.some( - (message) => - isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message), - ), + () => messages.some((message) => isSingleRoundRagMessage(message)), [messages], ); const referencesEnabled = ragModeEnabled || hasSingleRoundRag; @@ -340,7 +336,7 @@ export function MessageList({ // frontend tool-message scan below comes up empty (e.g. after a reload). const referenceBatches = useContext(ReferenceBatchesContext); - // 单轮/多轮 RAG 的参考资料存放在一条 hide_from_ui 的 tool 消息里(紧挨在 + // 单轮 RAG 的参考资料存放在一条 hide_from_ui 的 tool 消息里(紧挨在 // assistant 消息之前)。allMessages 会把 hide_from_ui 消息过滤掉,导致通用 // 的 collectRuntimeDisplaySources 扫描不到它们。这里直接在原始消息流里按 // assistant 消息提取其对应的参考资料,保证 `[1]` 等编号能解析成可点击引用。 @@ -351,7 +347,7 @@ export function MessageList({ const msg = raw[i]; if (!msg || msg.type !== "ai" || !msg.id) continue; const kwargs = msg.additional_kwargs; - if (!kwargs || (kwargs.single_round_rag !== true && kwargs.multi_round_rag !== true)) { + if (!kwargs || kwargs.single_round_rag !== true) { continue; } let sources: RuntimeDisplaySource[] = []; @@ -362,19 +358,13 @@ export function MessageList({ if (prev.type === "human" || prev.type === "ai") break; if (prev.type !== "tool") continue; const prevKwargs = prev.additional_kwargs; - if ( - !prevKwargs || - (prevKwargs.single_round_rag !== true && prevKwargs.multi_round_rag !== true) - ) { + if (!prevKwargs || prevKwargs.single_round_rag !== true) { continue; } const results = extractFrontendRagResults(prev.content); if (results && results.length > 0) { const toolName = String( - (prev as { name?: unknown }).name ?? - (prevKwargs.multi_round_rag === true - ? "multi_round_rag_references" - : "knowledge_base_search"), + (prev as { name?: unknown }).name ?? "knowledge_base_search", ); sources = results.map((item, index) => toRuntimeDisplaySource(item, index + 1, toolName), @@ -390,6 +380,7 @@ export function MessageList({ const resolveDisplaySources = useCallback( (msg: Message | undefined): RuntimeDisplaySource[] => { if (!referencesEnabled || !msg || msg.type !== "ai") return []; + if (isMultiRoundRagMessage(msg)) return []; const batchSources = referenceBatches.find((batch) => batch.assistant_message_id === msg.id) ?.sources ?? []; diff --git a/frontend-web/src/components/workspace/messages/multi-round-rag-message-list.tsx b/frontend-web/src/components/workspace/messages/multi-round-rag-message-list.tsx new file mode 100644 index 00000000..7f5397ce --- /dev/null +++ b/frontend-web/src/components/workspace/messages/multi-round-rag-message-list.tsx @@ -0,0 +1,472 @@ +import { + BookOpenIcon, + ChevronDownIcon, + FileTextIcon, + Loader2Icon, + SearchIcon, + SparklesIcon, +} from "lucide-react"; +import { useEffect, useState } from "react"; + +import { + Conversation, + ConversationContent, + ConversationScrollButton, +} from "@/components/ai-elements/conversation"; +import { + Message as AIElementMessage, + MessageContent as AIElementMessageContent, +} from "@/components/ai-elements/message"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; +import type { MessageResponseProps } from "@/components/ai-elements/message"; +import type { + MultiRoundRagChatMessage, + MultiRoundRagRetrieval, + MultiRoundRagRound, + MultiRoundRagTrace, +} from "@/core/threads/use-multi-round-rag"; +import { cn } from "@/lib/utils"; + +import { MarkdownContent } from "./markdown-content"; + +export function MultiRoundRagMessageList({ + className, + messages, + isLoading, + paddingBottom = 160, +}: { + className?: string; + messages: MultiRoundRagChatMessage[]; + isLoading: boolean; + paddingBottom?: number; +}) { + const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); + + return ( + + + {messages.map((message, index) => { + const isLastAssistant = + message.role === "assistant" && index === messages.length - 1; + return ( + + {message.role === "user" ? ( + +

+ {message.content} +

+
+ ) : ( + + + + )} +
+ ); + })} +
+ + + + ); +} + +export function MultiRoundRagAssistantBody({ + content, + thinkingTrace, + isLoading, + rehypePlugins, +}: { + content: string; + thinkingTrace?: MultiRoundRagTrace | null; + isLoading: boolean; + rehypePlugins: MessageResponseProps["rehypePlugins"]; +}) { + const [selected, setSelected] = useState( + null, + ); + const trace = thinkingTrace; + const answer = trace?.finalAnswerMarkdown || content; + + return ( + <> + {trace && ( + + )} +
+
最终回答
+ {answer ? ( + + ) : ( +
+ + 正在生成思考过程与检索内容... +
+ )} +
+ { + if (!open) setSelected(null); + }} + /> + + ); +} + +function MultiRoundRagTraceView({ + trace, + isLoading, + rehypePlugins, + onSelectRetrieval, +}: { + trace: MultiRoundRagTrace; + isLoading: boolean; + rehypePlugins: MessageResponseProps["rehypePlugins"]; + onSelectRetrieval: (item: MultiRoundRagRetrieval) => void; +}) { + const [open, setOpen] = useState(!trace.thinkingComplete); + useEffect(() => { + setOpen(!trace.thinkingComplete); + }, [trace.thinkingComplete]); + + return ( + + + + + + {trace.rounds.map((round, index) => ( +
+ {index < trace.rounds.length - 1 && ( + + )} + + +
+ ))} +
+
+ ); +} + +function MultiRoundRagRoundCard({ + index, + round, + isLoading, + rehypePlugins, + onSelectRetrieval, +}: { + index: number; + round: MultiRoundRagRound; + isLoading: boolean; + rehypePlugins: MessageResponseProps["rehypePlugins"]; + onSelectRetrieval: (item: MultiRoundRagRetrieval) => void; +}) { + const searching = Boolean(round.queryLoading); + const [open, setOpen] = useState(true); + useEffect(() => { + setOpen(searching || !round.query); + }, [round.id, round.query, searching]); + + return ( + +
+ + + + + {round.thoughtMarkdown && ( +
+
+ + 思考内容 +
+ +
+ )} + {round.queryShow && (round.query || searching) && ( +
+ {searching ? ( + + ) : ( + + )} + + {round.queryTitle || "检索词"} + + + {round.query || "正在生成检索词…"} + +
+ )} + {round.queryShow && ( +
+
+ + 检索内容 + + {round.retrievals.length} 条 + +
+ {searching ? ( +
+ + 正在召回相关内容... +
+ ) : ( + + )} +
+ )} + {(round.extractInfo || round.extractContent || round.extractLoading) && ( +
+
+ + 阅读内容 + {round.extractLoading && } +
+ {round.extractInfo && ( +

{round.extractInfo}

+ )} + {round.extractContent && ( +

{round.extractContent}

+ )} +
+ )} + {round.textidContent && ( +
+ {round.textidContent} +
+ )} +
+
+
+ ); +} + +function RetrievalCardList({ + items, + onSelect, +}: { + items: MultiRoundRagRetrieval[]; + onSelect: (item: MultiRoundRagRetrieval) => void; +}) { + if (items.length === 0) { + return

本轮未召回相关资料。

; + } + return ( +
+ {items.map((item, index) => ( + + ))} +
+ ); +} + +function RetrievalCard({ + item, + index, + defaultOpen, + onSelect, +}: { + item: MultiRoundRagRetrieval; + index: number; + defaultOpen: boolean; + onSelect: (item: MultiRoundRagRetrieval) => void; +}) { + const [open, setOpen] = useState(defaultOpen); + const meta = retrievalMeta(item); + + if (item.type === "user_behavior") { + return ( +
+ {index}. + {item.title} +
+ ); + } + + return ( + +
+ + + + + + +
+
+ ); +} + +function retrievalMeta(item: MultiRoundRagRetrieval): string { + if (item.type === "more") return `时间:${item.time || "-"}`; + if (item.type === "task") return `关键词:${item.keywords || "-"}`; + if (item.type === "hx") return `平台:${item.platform || "-"} 粉丝数:${item.fans ?? "-"}`; + if ( + item.type === "taiwan_news" || + item.type === "world_news" || + item.type === "messaage_comment" || + item.type === "america_news" + ) { + return `平台:${item.platform || "-"}${item.comment_count ? ` 评论数:${item.comment_count}` : ""}`; + } + if (item.type === "get_time_data") return `时间:${item.time || "-"} 热度:${item.hot_num ?? "-"}`; + return [item.source, item.time].filter(Boolean).join(" · "); +} + +function RetrievalDetailDialog({ + item, + onOpenChange, +}: { + item: MultiRoundRagRetrieval | null; + onOpenChange: (open: boolean) => void; +}) { + const content = item?.content; + return ( + + + + {item?.title || "引用资料"} + + {item?.time && 时间:{item.time}} + {item?.source && 来源:{item.source}} + + +
+ {content || "暂无引用内容"} +
+
+
+ ); +} diff --git a/frontend-web/src/core/auth/agentfx-deeplink.ts b/frontend-web/src/core/auth/agentfx-deeplink.ts index dfc94eaa..d8cc523a 100644 --- a/frontend-web/src/core/auth/agentfx-deeplink.ts +++ b/frontend-web/src/core/auth/agentfx-deeplink.ts @@ -7,7 +7,7 @@ * ``GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=``(带 DeerFlow 登录态,任务数据 * 来自 TaskCOP 任务表,即任务列表 / 报告导入用的同一份)。 * 2. **固定使用内置智能体** ``agentfx-analyst``(后端 seeder ``_agentfx_seed.py`` 启动时补建: - * 搜集信息 → 生成 report.md + task-reports.json(14 类)→ 总结 → 前端「入库」协助卡 / + * 搜集信息 → hits 落盘 → task-report-* 技能函数转换 14 类 task-reports.json → 写 report.md → 总结 → 前端「入库」协助卡 / * task-report-import 技能入库),不走「配置管理」业务映射。 * 3. 路由挂在 ``plan-task-agentfx`` 前缀下,页面代码见 [[AgentFxWorkspace]]。 * diff --git a/frontend-web/src/core/threads/use-multi-round-rag.ts b/frontend-web/src/core/threads/use-multi-round-rag.ts index 805bee50..09b11942 100644 --- a/frontend-web/src/core/threads/use-multi-round-rag.ts +++ b/frontend-web/src/core/threads/use-multi-round-rag.ts @@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { readRuntimeEnv } from "@/env"; import { createClientId } from "@/lib/client-id"; -const STORAGE_PREFIX = "workspace.multi-round-rag."; +const STORAGE_PREFIX = "workspace.multi-round-rag.v2."; export type MultiRoundRagRetrieval = { id?: string; @@ -14,38 +14,56 @@ export type MultiRoundRagRetrieval = { time?: string; recUuid?: string; url?: string; - /** Original tool payload, retained so a future renderer can add more fields - * without losing data received from the multi-round service. */ - raw?: Record; + type?: string; + keywords?: string; + taskDirection?: string; + platform?: string; + fans?: number; + comment_count?: number; + message_id?: string; + hot_num?: number; }; export type MultiRoundRagRound = { id: string; title: string; - thought: string; - /** Model text emitted for this round, before its retrieval results. */ - body: string; + thoughtMarkdown: string; + queryTitle?: string; query: string; - isSearching: boolean; + queryLoading?: boolean; + queryShow: boolean; + textidContent?: string; + extractInfo?: string; + extractContent?: string; + extractLoading?: boolean; retrievals: MultiRoundRagRetrieval[]; }; export type MultiRoundRagTrace = { + title?: string; rounds: MultiRoundRagRound[]; - answer: string; - isComplete: boolean; + finalAnswerMarkdown: string; + thinkingComplete?: boolean; }; -type MultiRoundRagSource = { - id?: string; +export type MultiRoundRagSource = { title: string; type: string; snippet: string; - content: string; source?: string; time?: string; url?: string; - raw: Record; + content?: string; + recUuid?: string; +}; + +export type MultiRoundRagChatMessage = { + id: string; + role: "user" | "assistant"; + content: string; + timestamp: string; + thinkingTrace?: MultiRoundRagTrace; + sources?: MultiRoundRagSource[]; }; type MultiRoundEvent = { @@ -57,18 +75,25 @@ function storageKey(threadId: string) { return `${STORAGE_PREFIX}${threadId}`; } -function readStoredMessages(threadId: string): Message[] { +function readStoredMessages(threadId: string): MultiRoundRagChatMessage[] { if (typeof window === "undefined" || !threadId) return []; try { const raw = window.sessionStorage.getItem(storageKey(threadId)); const parsed: unknown = raw ? JSON.parse(raw) : []; - return Array.isArray(parsed) ? (parsed as Message[]) : []; + if (!Array.isArray(parsed)) return []; + const messages = (parsed as Array) + .filter((message) => !message.isDemo) + .map(({ isDemo: _isDemo, ...message }) => message); + if (messages.length !== parsed.length) { + writeStoredMessages(threadId, messages); + } + return messages; } catch { return []; } } -function writeStoredMessages(threadId: string, messages: Message[]) { +function writeStoredMessages(threadId: string, messages: MultiRoundRagChatMessage[]) { if (typeof window === "undefined" || !threadId) return; try { window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages)); @@ -91,7 +116,6 @@ function asRecord(value: unknown): Record | null { : null; } -/** Values in the deployed tools are occasionally numeric IDs, not strings. */ function asText(value: unknown): string { if (typeof value === "string") return value; if (typeof value === "number" && Number.isFinite(value)) return String(value); @@ -107,168 +131,38 @@ function parseJson(value: unknown): unknown { } } -function parseEventLine(line: string): MultiRoundEvent | null { - const trimmed = line.trim(); - if (!trimmed.startsWith("data:")) return null; - const payload = trimmed.slice("data:".length).trim(); - if (!payload || payload === "[DONE]") return { event: "done" }; - try { - const parsed = parseJson(payload); - return asRecord(parsed) as MultiRoundEvent | null; - } catch { - return null; - } +function normalizeToolName(name: string) { + return name.trim().replace(/-/g, "_"); } -function initialTrace(): MultiRoundRagTrace { +function platformTypeChange(platform: string) { + if (platform.includes("fb")) return "facebook"; + if (platform.includes("x")) return "x"; + if (platform.includes("in")) return "instagram"; + if (platform.includes("re")) return "reddit"; + if (platform.includes("th")) return "threads"; + return platform || "facebook"; +} + +function createEmptyTrace(): MultiRoundRagTrace { return { + title: "多轮检索思考过程", rounds: [ { id: createId("multi-rag-round"), title: "正在准备检索", - thought: "", - body: "", + thoughtMarkdown: "", query: "", - isSearching: true, + queryLoading: true, + queryShow: true, retrievals: [], }, ], - answer: "", - isComplete: false, + thinkingComplete: false, + finalAnswerMarkdown: "", }; } -function latestRound(trace: MultiRoundRagTrace): MultiRoundRagRound { - const current = trace.rounds.at(-1); - if (current) return current; - const next = initialTrace().rounds[0]!; - trace.rounds.push(next); - return next; -} - -function addThinkingRound(trace: MultiRoundRagTrace, thought: string) { - const current = latestRound(trace); - if (current.title === "正在准备检索" && !current.thought && !current.query) { - current.title = "思考过程"; - current.thought = thought; - return current; - } - current.isSearching = false; - const next = { - id: createId("multi-rag-round"), - title: "思考过程", - thought, - body: "", - query: "", - isSearching: true, - retrievals: [], - }; - trace.rounds.push(next); - return next; -} - -function appendRoundBody(trace: MultiRoundRagTrace, body: string) { - if (!body) return; - const current = latestRound(trace); - // Stored traces created before `body` was introduced remain readable. - current.body = `${current.body ?? ""}${body}`; -} - -function firstArray(values: unknown[]): unknown[] { - for (const value of values) { - const parsed = parseJson(value); - if (Array.isArray(parsed)) return parsed; - } - return []; -} - -function toRecordList(value: unknown): Record[] { - const parsed = parseJson(value); - const root = asRecord(parsed); - const list = Array.isArray(parsed) - ? parsed - : firstArray([ - root?.results, - root?.organic, - root?.data, - root?.items, - root?.list, - root?.documents, - ]); - return list.flatMap((item) => { - if (typeof item === "string") return [{ content: item }]; - const record = asRecord(item); - return record ? [record] : []; - }); -} - -/** - * GeneralQA's service fans out to several business search tools. Normalising - * all of their result shapes here prevents a successful tool call from being - * silently absent just because it did not use `results` or `organic`. - */ -function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] { - const list = toRecordList(value); - return list.flatMap((item, index) => { - const recUuid = asText(item.recUuid ?? item.rec_uuid ?? item.uuid); - const url = asText(item.url ?? item.link ?? item.href); - const title = asText( - item.title ?? item.m_title ?? item.taskName ?? item.user_name ?? item.name ?? item.user_id, - ); - const content = asText( - item.content ?? - item.m_content ?? - item.m_conent ?? - item.snippet ?? - item.text ?? - item.overview ?? - item.page_content ?? - item.description ?? - item.summary, - ); - return [ - { - id: asText(item.id) || recUuid || undefined, - title: title || `检索资料 ${index + 1}`, - content: content || (url && !title ? url : ""), - source: asText(item.source ?? item.source1 ?? item.m_source ?? item.platform) || undefined, - time: asText(item.publish_time ?? item.m_publish ?? item.m_publicsh ?? item.date ?? item.time) || undefined, - recUuid: recUuid || undefined, - url: /^https?:\/\//i.test(url) ? url : undefined, - raw: item, - }, - ]; - }); -} - -function mapReferences(value: unknown): MultiRoundRagSource[] { - const parsed = parseJson(value); - const record = asRecord(parsed); - const list = Array.isArray(parsed) - ? parsed - : firstArray([record?.references, record?.results, record?.sources, record?.documents]); - return list.flatMap((item, index) => { - const source = asRecord(item); - if (!source) return []; - const content = asText(source.content ?? source.m_content ?? source.m_conent ?? source.snippet ?? source.text); - const rawUrl = asText(source.url ?? source.link ?? source.href); - const recUuid = asText(source.recUuid ?? source.rec_uuid ?? source.uuid); - return [ - { - id: asText(source.id) || recUuid || undefined, - title: asText(source.title ?? source.m_title ?? source.taskName ?? source.user_name) || `参考资料 ${index + 1}`, - type: asText(source.type) || "参考资料", - snippet: asText(source.snippet) || content, - content, - source: asText(source.source ?? source.source1 ?? source.m_source ?? source.platform) || undefined, - time: asText(source.time ?? source.m_publish ?? source.m_publicsh ?? source.publish_time ?? source.date) || undefined, - url: /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined, - raw: source, - }, - ]; - }); -} - function cloneTrace(trace: MultiRoundRagTrace): MultiRoundRagTrace { return { ...trace, @@ -279,62 +173,350 @@ function cloneTrace(trace: MultiRoundRagTrace): MultiRoundRagTrace { }; } -function toolTitle(toolName: string): string { +function lastRound(trace: MultiRoundRagTrace): MultiRoundRagRound { + const current = trace.rounds.at(-1); + if (current) return current; + const next = createEmptyTrace().rounds[0]!; + trace.rounds.push(next); + return next; +} + +function parseEventLine(line: string): MultiRoundEvent | null { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) return null; + const payload = trimmed.replace(/^data:\s?/, "").trim(); + if (!payload || payload === "[DONE]") return { event: "done" }; + try { + const parsed = parseJson(payload); + return asRecord(parsed) as MultiRoundEvent | null; + } catch { + return null; + } +} + +function mapReferenceToSource(item: unknown, index: number): MultiRoundRagSource { + const source = asRecord(item) ?? {}; + const content = asText(source.content ?? source.m_content ?? source.snippet ?? source.text); + const rawUrl = asText(source.url ?? source.link ?? source.href); + const recUuid = asText(source.recUuid ?? source.rec_uuid ?? source.uuid); + return { + title: asText(source.title ?? source.m_title) || `参考资料 ${index + 1}`, + type: asText(source.type) || "参考资料", + snippet: asText(source.snippet) || content, + content, + source: asText(source.source ?? source.source1 ?? source.m_source ?? source.platform) || undefined, + time: asText(source.time ?? source.m_publish ?? source.publish_time ?? source.date) || undefined, + url: /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined, + recUuid: recUuid || undefined, + }; +} + +function parseResult(raw: unknown): T | null { + const parsed = parseJson(raw); + return parsed == null ? null : (parsed as T); +} + +function extractInfoText(result: unknown): string { + const parsed = parseJson(result); + if (typeof parsed === "string") return parsed; + const record = asRecord(parsed); + return asText( + record?.content ?? + record?.text ?? + record?.extracted ?? + record?.info ?? + record?.summary ?? + record?.result, + ); +} + +/** + * GeneralQA `handleToolCall` 的独立实现:按 tool_name 把 SSE 工具事件填进最近一轮 trace。 + * `show_text` / `show-text` 负责思考与最终回答;检索类工具先写检索词,再把 result 渲染成卡片。 + */ +export function applyMultiRoundToolCall(trace: MultiRoundRagTrace, toolValue: unknown) { + const tool = asRecord(toolValue); + if (!tool) return; + const toolName = normalizeToolName(asText(tool.tool_name ?? tool.name)); + const input = + asRecord(parseJson(tool.tool_input ?? tool.input ?? tool.arguments)) ?? {}; + const round = lastRound(trace); + switch (toolName) { - case "local_search": - return "本地知识检索"; + case "show_text": { + const chatText = asText(input.text ?? input.content); + if (chatText.includes("")) { + const thinkContent = chatText.replace("", "").replace("", "").trim(); + if (trace.rounds[0]?.title !== "正在准备检索") { + round.queryLoading = false; + round.queryShow = round.retrievals.length > 0; + trace.rounds.push({ + id: createId("multi-rag-round"), + title: "思考中", + thoughtMarkdown: thinkContent, + query: "", + queryLoading: true, + queryShow: true, + retrievals: [], + }); + } else { + trace.rounds[0]!.title = "思考过程"; + trace.rounds[0]!.thoughtMarkdown = thinkContent; + } + } else { + round.queryLoading = false; + trace.finalAnswerMarkdown += chatText; + trace.thinkingComplete = true; + } + return; + } case "tavily_search": - return "联网检索"; - case "google_search": - case "getTaskLikeTop": - return "思考过程"; - case "search_user_profile": - return "靶标平台画像库"; + case "local_search": { + if (input.result) { + const result = parseResult<{ + query?: string; + results?: Array<{ title?: string; content?: string; url?: string }>; + }>(input.result); + round.query = result?.query || round.query; + round.queryLoading = false; + round.title = toolName === "local_search" ? "本地知识检索" : "联网检索"; + round.retrievals = (result?.results || []).map((item, idx) => ({ + id: `${toolName}-${idx}`, + title: item.title || "未命名结果", + content: item.content || "", + source: item.url || "", + url: item.url, + })); + } + return; + } + case "google_search": { + if (input.result) { + const result = parseResult<{ + organic?: Array<{ + title?: string; + content?: string; + url?: string; + recUuid?: string; + publish_time?: string; + }>; + }>(input.result); + round.queryLoading = false; + round.title = "思考过程"; + round.retrievals = (result?.organic || []).map((item) => ({ + id: item.recUuid, + title: item.title || "未命名结果", + content: item.content || "", + source: item.recUuid || "", + time: item.publish_time, + recUuid: item.recUuid, + url: item.url, + type: "more", + })); + } else if (asText(input.q)) { + round.query = asText(input.q); + round.queryTitle = "检索词"; + round.queryShow = true; + round.queryLoading = true; + } + return; + } + case "getTaskLikeTop": { + if (input.result) { + const result = parseResult<{ + data?: Array<{ + taskName?: string; + overview?: string; + id?: number; + keywords?: string; + taskDirection?: string; + taskStartDate?: string; + taskEndDate?: string; + }>; + }>(input.result); + round.queryLoading = false; + round.title = "思考过程"; + round.retrievals = (result?.data || []).map((item) => ({ + id: String(item.id ?? ""), + title: item.taskName || "未命名结果", + content: item.overview || "", + keywords: item.keywords, + taskDirection: item.taskDirection, + time: `${item.taskStartDate ?? ""}-${item.taskEndDate ?? ""}`, + type: "task", + })); + } else if (asText(input.keyword)) { + round.query = asText(input.keyword); + round.queryShow = true; + round.queryLoading = true; + } + return; + } + case "search_user_profile": { + round.title = "靶标平台画像库"; + if (input.result) { + const result = parseResult< + Array<{ + user_name?: string; + user_id?: string; + page_content?: string; + 平台?: string; + 粉丝数?: number; + }> + >(input.result); + round.queryLoading = false; + round.retrievals = (result || []).map((item) => ({ + id: item.user_id, + title: item.user_name || item.user_id || "-", + content: item.page_content || "", + platform: platformTypeChange(asText(item.平台)), + fans: item.粉丝数 || 0, + type: "hx", + })); + } else if (asText(input.keyword) || asText(input.query) || asText(input.user_fig)) { + round.queryTitle = "检索词"; + round.query = asText(input.keyword) || asText(input.query) || asText(input.user_fig); + round.queryShow = true; + round.queryLoading = true; + } + return; + } case "get_taiwan_news": case "search_keyword_taiwan_news": - return "台湾热点新闻"; case "get_world_news": - return "世界热点新闻"; - case "get_root_messaage_comment": - return "获取该消息的全部评论"; - case "search_user_behavior": - return "查询该用户的历史行为"; case "get_america_news": - return "美国热点新闻"; - case "get_time_data": - return "态势新闻搜索"; - case "get_textid_content": - return "获取新闻详细内容"; + case "get_root_messaage_comment": { + const titles: Record = { + get_taiwan_news: "台湾热点新闻", + search_keyword_taiwan_news: "台湾热点新闻", + get_world_news: "世界热点新闻", + get_america_news: "美国热点新闻", + get_root_messaage_comment: "获取该消息的全部评论", + }; + const types: Record = { + get_taiwan_news: "taiwan_news", + search_keyword_taiwan_news: "taiwan_news", + get_world_news: "world_news", + get_america_news: "america_news", + get_root_messaage_comment: "messaage_comment", + }; + round.title = titles[toolName] ?? toolName; + if (input.result) { + const result = parseResult< + Array<{ + user_name?: string; + user_id?: string; + content?: string; + publish_time?: string; + message_id?: string; + platform?: string; + comment_count?: number; + }> + >(input.result); + round.queryLoading = false; + round.retrievals = (result || []).map((item) => ({ + id: item.user_id, + title: item.user_name || item.user_id || "-", + content: item.content || "", + platform: platformTypeChange(asText(item.platform)), + comment_count: item.comment_count, + message_id: item.message_id, + time: item.publish_time, + type: types[toolName], + })); + } else if (asText(input.keyword) || asText(input.query) || asText(input.platform)) { + round.queryTitle = + toolName === "get_america_news" + ? "查询美国热点新闻" + : toolName === "get_world_news" + ? "搜索世界新闻" + : toolName === "get_root_messaage_comment" + ? "查找平台" + : "搜索台湾新闻"; + round.query = + asText(input.keyword) || + asText(input.query) || + platformTypeChange(asText(input.platform)); + round.queryShow = true; + round.queryLoading = true; + } + return; + } + case "search_user_behavior": { + round.title = "查询该用户的历史行为"; + if (input.result) { + const result = parseResult(input.result) ?? []; + round.queryLoading = false; + round.retrievals = result.slice(0, 10).map((item) => ({ + id: asText(item), + title: asText(item), + content: asText(item), + type: "user_behavior", + })); + } else if (asText(input.keyword) || asText(input.query) || asText(input.platform)) { + round.queryTitle = "查找平台"; + round.query = + asText(input.keyword) || + asText(input.query) || + platformTypeChange(asText(input.platform)); + round.queryShow = true; + round.queryLoading = true; + } + return; + } + case "get_time_data": { + round.title = "态势新闻搜索"; + if (input.result) { + const result = parseResult< + Array<{ title?: string; textid?: string; hot_num?: number; date?: string; content?: string }> + >(input.result); + round.queryLoading = false; + round.queryTitle = "查询态势新闻"; + round.retrievals = (result || []).map((item) => ({ + id: item.textid, + title: item.title || "未命名结果", + content: item.content || "", + time: item.date, + hot_num: item.hot_num, + type: "get_time_data", + })); + } else if (asText(input.keyword) || asText(input.query) || asText(input.s) || asText(input.e)) { + round.query = [asText(input.s), asText(input.e)].filter(Boolean).join(" - "); + round.queryShow = true; + round.queryLoading = true; + } + return; + } + case "get_textid_content": { + round.title = "获取新闻详细内容"; + if (input.result) { + const result = parseResult<{ content?: string }>(input.result); + round.queryLoading = false; + round.textidContent = asText(result?.content); + } + return; + } + case "scrape_and_extract_info": { + const info = asText(input.info_to_extract ?? input.infoToExtract); + if (info) { + round.extractInfo = info; + round.extractLoading = !input.result; + } + if (input.result) { + round.extractLoading = false; + round.extractContent = extractInfoText(input.result); + } + return; + } default: - return toolName || "检索过程"; + return; } } -function isSearchTool(toolName: string): boolean { - return [ - "local_search", - "tavily_search", - "google_search", - "getTaskLikeTop", - "search_user_profile", - "get_taiwan_news", - "search_keyword_taiwan_news", - "get_world_news", - "get_root_messaage_comment", - "search_user_behavior", - "get_america_news", - "get_time_data", - ].includes(toolName); -} - -function queryFromToolInput(input: Record, result: unknown): string { - const resultRecord = asRecord(parseJson(result)); - const direct = asText(input.q ?? input.query ?? input.keyword ?? input.user_fig); - if (direct) return direct; - if (input.s || input.e) { - return [asText(input.s), asText(input.e)].filter(Boolean).join(" - "); - } - return asText(resultRecord?.query ?? resultRecord?.q); +function consumeToolCallPayload(trace: MultiRoundRagTrace, payload: unknown) { + const parsed = parseJson(payload); + const tools = Array.isArray(parsed) ? parsed : [parsed]; + for (const tool of tools) applyMultiRoundToolCall(trace, tool); } function textFromEventData(value: unknown): string { @@ -344,29 +526,98 @@ function textFromEventData(value: unknown): string { return asText(record?.text ?? record?.content ?? record?.message ?? record?.answer ?? record?.output); } -function isSingleRoundRagMessage(message: Message) { - return message.additional_kwargs?.single_round_rag === true; +function applyStreamEvent( + trace: MultiRoundRagTrace, + event: MultiRoundEvent, + onSources?: (sources: MultiRoundRagSource[]) => void, + onError?: (message: string) => void, +) { + const eventType = asText(event.event); + const data = event.data; + if (eventType === "tool_call") { + consumeToolCallPayload(trace, data); + } else if (eventType === "message") { + const text = textFromEventData(data); + if (text) { + lastRound(trace).queryLoading = false; + trace.finalAnswerMarkdown += text; + trace.thinkingComplete = true; + } + } else if (eventType === "references") { + const record = asRecord(parseJson(data)); + const refs = Array.isArray(record?.references) + ? record.references + : Array.isArray(parseJson(data)) + ? (parseJson(data) as unknown[]) + : []; + onSources?.(refs.map(mapReferenceToSource)); + } else if (eventType === "error") { + const message = textFromEventData(data) || "多轮 RAG 服务返回错误。"; + onError?.(message); + if (!trace.finalAnswerMarkdown) trace.finalAnswerMarkdown = message; + finishTrace(trace); + } else if (eventType === "done") { + const finalText = textFromEventData(data); + if (finalText && !trace.finalAnswerMarkdown) trace.finalAnswerMarkdown += finalText; + finishTrace(trace); + } } -export function isMultiRoundRagMessage(message: Message): boolean { - return message.additional_kwargs?.multi_round_rag === true; +function finishTrace(trace: MultiRoundRagTrace) { + trace.thinkingComplete = true; + for (const round of trace.rounds) { + round.queryLoading = false; + round.extractLoading = false; + if (round.retrievals.length > 0) round.queryShow = true; + } } -export function getMultiRoundRagTrace( - message: Message, -): MultiRoundRagTrace | null { - const trace = message.additional_kwargs?.multi_round_rag_trace; - return asRecord(trace) - ? (trace as unknown as MultiRoundRagTrace) - : null; +async function* readMultiRoundSse( + baseUrl: string, + question: string, + signal: AbortSignal, +): AsyncGenerator { + const response = await fetch(`${baseUrl.replace(/\/$/, "")}/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal, + body: JSON.stringify({ prompt: question }), + }); + if (!response.ok) { + throw new Error(`多轮 RAG 服务请求失败(${response.status})。`); + } + if (!response.body) { + throw new Error("多轮 RAG 服务未返回流式响应。"); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ""; + for (const line of lines) { + const event = parseEventLine(line); + if (event) yield event; + } + } + buffer += decoder.decode(); + for (const line of buffer.split(/\r?\n/)) { + const event = parseEventLine(line); + if (event) yield event; + } } /** * Frontend adapter for GeneralQA's multi-round endpoint. It keeps the external - * SSE protocol outside DeerFlow and stores only its UI messages in sessionStorage. + * SSE protocol outside DeerFlow and stores only this mode's own chat messages. */ export function useMultiRoundRagMessages(threadId: string) { - const [messages, setMessages] = useState(() => readStoredMessages(threadId)); + const [messages, setMessages] = useState(() => + readStoredMessages(threadId), + ); const messagesRef = useRef(messages); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -386,7 +637,7 @@ export function useMultiRoundRagMessages(threadId: string) { useEffect(() => () => controllerRef.current?.abort(), []); const updateMessages = useCallback( - (targetThreadId: string, updater: (current: Message[]) => Message[]) => { + (targetThreadId: string, updater: (current: MultiRoundRagChatMessage[]) => MultiRoundRagChatMessage[]) => { setMessages((current) => { const next = updater(current); messagesRef.current = next; @@ -399,306 +650,82 @@ export function useMultiRoundRagMessages(threadId: string) { const stop = useCallback(() => controllerRef.current?.abort(), []); - const submit = useCallback( - async ({ targetThreadId, query }: { targetThreadId: string; query: string }) => { - if (isLoading) return; + const runWithEvents = useCallback( + async ({ + targetThreadId, + query, + createEvents, + }: { + targetThreadId: string; + query: string; + createEvents: (signal: AbortSignal) => AsyncIterable; + }) => { const question = query.trim(); if (!question) return; const humanId = createId("multi-rag-human"); const assistantId = createId("multi-rag-ai"); - const sourceId = createId("multi-rag-sources"); - const trace = initialTrace(); + const trace = createEmptyTrace(); let sources: MultiRoundRagSource[] = []; - const clientTs = new Date().toISOString(); - const userMessage: Message = { + const timestamp = new Date().toISOString(); + const userMessage: MultiRoundRagChatMessage = { id: humanId, - type: "human", - content: [{ type: "text", text: question }], - additional_kwargs: { client_ts: clientTs, multi_round_rag: true }, + role: "user", + content: question, + timestamp, }; - const assistantMessage: Message = { + const assistantMessage: MultiRoundRagChatMessage = { id: assistantId, - type: "ai", + role: "assistant", content: "", - additional_kwargs: { - multi_round_rag: true, - multi_round_rag_trace: trace, - }, + timestamp, + thinkingTrace: cloneTrace(trace), }; - updateMessages(targetThreadId, (current) => [ - ...current, - userMessage, - assistantMessage, - ]); + + updateMessages(targetThreadId, (current) => [...current, userMessage, assistantMessage]); setError(null); setIsLoading(true); const controller = new AbortController(); controllerRef.current = controller; + const refreshAssistant = () => { const traceSnapshot = cloneTrace(trace); - updateMessages(targetThreadId, (current) => { - const next = current.filter((message) => message.id !== sourceId); - const assistantIndex = next.findIndex((message) => message.id === assistantId); - if (assistantIndex < 0) return current; - next[assistantIndex] = { - ...next[assistantIndex], - content: traceSnapshot.answer, - additional_kwargs: { - ...next[assistantIndex]!.additional_kwargs, - multi_round_rag: true, - multi_round_rag_trace: traceSnapshot, - reasoning_content: traceSnapshot.rounds - .map((round) => round.thought) - .filter(Boolean) - .join("\n\n") || null, - }, - } as Message; - if (sources.length > 0) { - next.splice(assistantIndex, 0, { - id: sourceId, - type: "tool", - name: "multi_round_rag_references", - content: { results: sources }, - additional_kwargs: { - hide_from_ui: true, - multi_round_rag: true, - }, - } as unknown as Message); - } - return next; - }); + updateMessages(targetThreadId, (current) => + current.map((message) => + message.id === assistantId + ? { + ...message, + content: traceSnapshot.finalAnswerMarkdown, + thinkingTrace: traceSnapshot, + sources: sources.length > 0 ? sources : message.sources, + } + : message, + ), + ); }; try { - const baseUrl = readRuntimeEnv("VITE_AIPI_GENERATE_CHAT_URL") || ""; - if (!baseUrl) { - throw new Error("未配置 VITE_AIPI_GENERATE_CHAT_URL,无法发起多轮 RAG 问答。"); - } - const response = await fetch(`${baseUrl.replace(/\/$/, "")}/generate`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - signal: controller.signal, - body: JSON.stringify({ prompt: question }), - }); - if (!response.ok) { - throw new Error(`多轮 RAG 服务请求失败(${response.status})。`); - } - if (!response.body) { - throw new Error("多轮 RAG 服务未返回流式响应。"); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let activeThoughtRound: MultiRoundRagRound | null = null; - let activeThoughtTailIsAnswer = false; - let answerFromShowText = false; - - const appendAnswer = (text: string, allowCompleteSnapshot = false) => { - if (!text) return; - // Some gateways emit one complete `message` after a `show_text` - // answer. Restrict snapshot detection to that case and to a - // meaningful length: adjacent streaming chunks can legitimately - // contain the same character, and must never be discarded. - if ( - allowCompleteSnapshot && - trace.answer.length >= 16 && - text.startsWith(trace.answer) - ) { - trace.answer = text; - return; - } - trace.answer += text; - }; - - const consumeShowText = (text: string, tailIsAnswer = false) => { - if (!text) return; - if (activeThoughtRound) { - const endIndex = text.indexOf(""); - if (endIndex < 0) { - activeThoughtRound.thought += text; - return; - } - activeThoughtRound.thought += text.slice(0, endIndex); - const roundBody = text.slice(endIndex + "".length).trim(); - if (roundBody) { - if (activeThoughtTailIsAnswer) { - appendAnswer(roundBody); - answerFromShowText = false; - trace.isComplete = true; - } else { - appendRoundBody(trace, roundBody); - } - } - activeThoughtRound = null; - activeThoughtTailIsAnswer = false; - return; - } - - const thinkStart = text.indexOf(""); - if (thinkStart < 0) { - const current = latestRound(trace); - current.isSearching = false; - appendAnswer(text); - answerFromShowText = true; - trace.isComplete = true; - return; - } - - const prefix = text.slice(0, thinkStart).trim(); - if (prefix) appendRoundBody(trace, prefix); - - // A full … envelope is normally delivered as one - // tool call. Keep the unfinished variant open as well: production - // proxies are allowed to split an SSE frame at any byte boundary. - const thinkEnd = text.indexOf("", thinkStart + "".length); - const thought = text - .slice(thinkStart + "".length, thinkEnd < 0 ? undefined : thinkEnd) - .trim(); - const thoughtRound = addThinkingRound(trace, thought); - if (thinkEnd < 0) { - activeThoughtRound = thoughtRound; - activeThoughtTailIsAnswer = tailIsAnswer; - return; - } - const roundBody = text.slice(thinkEnd + "".length).trim(); - if (roundBody) { - if (tailIsAnswer) { - appendAnswer(roundBody); - answerFromShowText = false; - trace.isComplete = true; - } else { - appendRoundBody(trace, roundBody); - } - } - }; - - const consumeToolCall = (payload: unknown) => { - const parsedPayload = parseJson(payload); - const tools = Array.isArray(parsedPayload) ? parsedPayload : [parsedPayload]; - for (const toolValue of tools) { - const tool = asRecord(toolValue); - if (!tool) continue; - const toolName = asText(tool.tool_name ?? tool.name); - const input = asRecord(parseJson(tool.tool_input ?? tool.input ?? tool.arguments)) ?? {}; - const current = latestRound(trace); - - if (toolName === "show_text") { - consumeShowText(asText(input.text ?? input.content)); - continue; - } - - if (toolName === "get_textid_content") { - current.title = toolTitle(toolName); - const article = asRecord(parseJson(input.result)); - const body = asText(article?.content ?? article?.text ?? input.content ?? input.result); - if (body) appendRoundBody(trace, body); - current.isSearching = false; - continue; - } - - const hasResult = input.result !== undefined; - const query = queryFromToolInput(input, input.result); - if (isSearchTool(toolName) || hasResult) { - current.title = toolTitle(toolName); - current.query = query || current.query; - if (!hasResult && isSearchTool(toolName)) { - // GeneralQA can receive a model text event before the search - // tool announces its query. The query starts a new visible - // loading phase, so restore the round/trace state here and - // keep the query badge spinning until its result arrives. - current.isSearching = true; - trace.isComplete = false; - } else if (hasResult) { - const retrievals = mapRetrievals(input.result); - // An empty search response is still a completed response; it - // must replace a previous list instead of leaving stale cards. - current.retrievals = retrievals; - if (retrievals.length === 0) { - const fallbackBody = textFromEventData(input.result); - if (fallbackBody && fallbackBody !== current.query) { - appendRoundBody(trace, fallbackBody); - } - } - current.isSearching = false; - } - } - } - }; - - const finishTrace = () => { - trace.isComplete = true; - activeThoughtRound = null; - activeThoughtTailIsAnswer = false; - for (const round of trace.rounds) round.isSearching = false; - }; - - const consumeEvent = (event: MultiRoundEvent) => { - const eventType = asText(event.event); - const data = event.data; - if (eventType === "tool_call") { - consumeToolCall(data); - } else if (eventType === "message") { - const text = textFromEventData(data); - if (text) { - if (activeThoughtRound || /<\/?think>/i.test(text)) { - // Most deployments put thoughts in `show_text`, but this - // fallback also preserves a compatible gateway that emits - // `` in normal message chunks. - consumeShowText(text, true); - } else { - latestRound(trace).isSearching = false; - appendAnswer(text, answerFromShowText); - answerFromShowText = false; - trace.isComplete = true; - } - } - } else if (eventType === "references") { - sources = mapReferences(data); - } else if (eventType === "error") { - const message = textFromEventData(data) || "多轮 RAG 服务返回错误。"; - setError(message); - if (!trace.answer) trace.answer = message; - finishTrace(); - } else if (eventType === "done") { - // A few compatible gateway versions include the whole final - // answer in `done`; render it when no `message` chunk was sent. - const finalText = textFromEventData(data); - if (finalText && !trace.answer) appendAnswer(finalText); - finishTrace(); - } + for await (const event of createEvents(controller.signal)) { + if (controller.signal.aborted) throw new DOMException("Aborted", "AbortError"); + applyStreamEvent( + trace, + event, + (nextSources) => { + sources = nextSources; + }, + setError, + ); refreshAssistant(); - }; - - const consumeLines = (flush = false) => { - const lines = buffer.split(/\r?\n/); - buffer = flush ? "" : (lines.pop() ?? ""); - for (const line of lines) { - const event = parseEventLine(line); - if (event) consumeEvent(event); - } - }; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - consumeLines(); } - buffer += decoder.decode(); - consumeLines(true); - finishTrace(); + finishTrace(trace); refreshAssistant(); } catch (caught) { - trace.isComplete = true; - for (const round of trace.rounds) round.isSearching = false; + finishTrace(trace); if (!isAbortError(caught)) { - const message = - caught instanceof Error ? caught.message : "多轮 RAG 问答失败。"; + const message = caught instanceof Error ? caught.message : "多轮 RAG 问答失败。"; setError(message); - if (!trace.answer) trace.answer = message; + if (!trace.finalAnswerMarkdown) trace.finalAnswerMarkdown = message; } refreshAssistant(); } finally { @@ -706,12 +733,91 @@ export function useMultiRoundRagMessages(threadId: string) { setIsLoading(false); } }, - [isLoading, updateMessages], + [updateMessages], + ); + + const submit = useCallback( + async ({ targetThreadId, query }: { targetThreadId: string; query: string }) => { + if (isLoading) return; + const question = query.trim(); + if (!question) return; + + const baseUrl = readRuntimeEnv("VITE_AIPI_GENERATE_CHAT_URL") || ""; + if (!baseUrl) { + throw new Error("未配置多轮 RAG 服务地址(VITE_AIPI_GENERATE_CHAT_URL)。"); + } + + await runWithEvents({ + targetThreadId, + query: question, + createEvents: (signal) => readMultiRoundSse(baseUrl, question, signal), + }); + }, + [isLoading, runWithEvents], ); return { messages, isLoading, error, submit, stop }; } -export function isFrontendRagMessage(message: Message): boolean { - return isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message); +export function toLangGraphMessages(messages: MultiRoundRagChatMessage[]): Message[] { + return messages.map((message) => { + if (message.role === "user") { + return { + id: message.id, + type: "human", + content: [{ type: "text", text: message.content }], + additional_kwargs: { + client_ts: message.timestamp, + multi_round_rag: true, + }, + } as Message; + } + return { + id: message.id, + type: "ai", + content: message.content, + additional_kwargs: { + client_ts: message.timestamp, + multi_round_rag: true, + multi_round_rag_trace: message.thinkingTrace, + }, + } as Message; + }); +} + +function messageTimestamp(message: Message, fallback: number) { + const raw = message.additional_kwargs?.client_ts; + if (typeof raw === "number" && Number.isFinite(raw)) return raw; + if (typeof raw === "string") { + const parsed = Date.parse(raw); + if (!Number.isNaN(parsed)) return parsed; + } + return fallback; +} + +export function mergeMessagesByTime(base: Message[], extra: Message[]): Message[] { + if (extra.length === 0) return base; + if (base.length === 0) return extra; + const stamp = (messages: Message[], src: number) => { + let last = 0; + return messages.map((msg, idx) => { + const t = messageTimestamp(msg, last); + last = t; + return { msg, t, src, idx }; + }); + }; + return [...stamp(base, 0), ...stamp(extra, 1)] + .sort((a, b) => a.t - b.t || a.src - b.src || a.idx - b.idx) + .map((item) => item.msg); +} + +export function isMultiRoundRagMessage(message: { additional_kwargs?: Record }): boolean { + return message.additional_kwargs?.multi_round_rag === true; +} + +export function getMultiRoundRagTrace( + message: { additional_kwargs?: Record }, +): MultiRoundRagTrace | null { + const trace = message.additional_kwargs?.multi_round_rag_trace; + return trace && typeof trace === "object" ? (trace as MultiRoundRagTrace) : null; } diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index 4878610a..6e17b22d 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -70,7 +70,7 @@ import { clearResearchReportStream } from "@/core/threads/use-research-report-st import { resolveWritingModeReportPath } from "@/core/threads/writing-mode"; import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit"; import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag"; -import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag"; +import { useMultiRoundRagMessages, mergeMessagesByTime, toLangGraphMessages } from "@/core/threads/use-multi-round-rag"; import { useLayoutMode } from "@/core/system-settings/hooks"; import { useRouteBase } from "@/core/route-base"; import { textOfMessage } from "@/core/threads/utils"; @@ -332,19 +332,19 @@ export default function ChatPage() { const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread); const displayThread = useMemo(() => { const base = researchDisplayThread; + const ragMessages = toLangGraphMessages(multiRoundRag.messages); if ( singleRoundRag.messages.length === 0 && - multiRoundRag.messages.length === 0 && + ragMessages.length === 0 && !singleRoundRag.isLoading && !multiRoundRag.isLoading ) { return base; } - const messages = [ - ...base.messages, - ...singleRoundRag.messages, - ...multiRoundRag.messages, - ]; + const messages = mergeMessagesByTime( + [...base.messages, ...singleRoundRag.messages], + ragMessages, + ); return { ...base, values: { ...base.values, messages }, @@ -355,9 +355,9 @@ export default function ChatPage() { }, [ multiRoundRag.isLoading, multiRoundRag.messages, + researchDisplayThread, singleRoundRag.isLoading, singleRoundRag.messages, - researchDisplayThread, ]); const writingArtifactPath = useMemo( @@ -876,7 +876,7 @@ export default function ChatPage() { status={ thread.error ? "error" - : displayThread.isLoading + : displayThread.isLoading || multiRoundRag.isLoading ? "streaming" : "ready" } diff --git a/frontend-web/src/report-collaboration/api/mock.ts b/frontend-web/src/report-collaboration/api/mock.ts index 387f58b7..bd0e9885 100644 --- a/frontend-web/src/report-collaboration/api/mock.ts +++ b/frontend-web/src/report-collaboration/api/mock.ts @@ -487,6 +487,7 @@ function runResearchAgent(store: MockStore, script: ResearchScript, baseDelay: n setNodeStatus(store, script.nodeId, "completed"); agentRun.status = "completed"; agentRun.ended_at = now(); + emit(store, "session.snapshot", { agent_runs: [{ ...agentRun }] }); const artifactId = store.artifactIds.at(-1); if (artifactId) emit(store, "artifact.validated", { artifact_id: artifactId }); }); @@ -543,6 +544,7 @@ function executePlan(store: MockStore, plan: ReportPlanCandidate): void { later(store, 300, () => { setNodeStatus(store, "n-req", "completed"); coordRun.status = "completed"; + emit(store, "session.snapshot", { agent_runs: [{ ...coordRun }] }); }); let cursor = 4200; diff --git a/frontend-web/src/report-collaboration/components/AgentStageSelector.tsx b/frontend-web/src/report-collaboration/components/AgentStageSelector.tsx index 0c5f9b47..982a747c 100644 --- a/frontend-web/src/report-collaboration/components/AgentStageSelector.tsx +++ b/frontend-web/src/report-collaboration/components/AgentStageSelector.tsx @@ -1,58 +1,53 @@ "use client"; import { memo } from "react"; -import { - AlertTriangle, - Check, - Circle, - Loader2, - MessagesSquare, - X, -} from "lucide-react"; +import { Check, Loader2, X } from "lucide-react"; import type { AgentRun } from "@/report-collaboration/api/types"; -import { cn } from "@/lib/utils"; +import { roleIcon } from "@/report-collaboration/components/ReportFlowNode"; interface AgentStageSelectorProps { - /** 并行阶段标签(协调者首位);空数组时不渲染。 */ + /** 当前并行波次成员;空数组时不渲染。 */ tabs: AgentRun[]; phaseLabel?: string | null; focusedAgentRunId: string | null; - onFocus: (agentRunId: string | null) => void; + onFocus: (agentRunId: string) => void; /** 正在流式输出的成员。 */ streamingAgentRunIds: Set; /** 请求用户协助(待回复)的成员。 */ awaitingAgentRunIds: Set; } -function statusIcon(agentRun: AgentRun, streaming: boolean, awaiting: boolean) { - if (awaiting) { - return ; - } - if (agentRun.status === "completed") { - return ; - } - if (agentRun.status === "failed" || agentRun.status === "cancelled") { - return ; - } - if (agentRun.status === "running" || streaming) { - return ; - } - return ; +function avatarStatus( + agentRun: AgentRun, + streaming: boolean, + awaiting: boolean, +): "running" | "awaiting" | "done" | "error" | "queued" { + if (awaiting) return "awaiting"; + if (agentRun.status === "failed" || agentRun.status === "cancelled") return "error"; + if (agentRun.status === "completed") return "done"; + if (agentRun.status === "running" || streaming) return "running"; + return "queued"; } -function statusText(agentRun: AgentRun, streaming: boolean, awaiting: boolean): string { - if (awaiting) return "待回复"; - if (agentRun.status === "completed") return "已完成"; - if (agentRun.status === "failed") return "失败"; - if (agentRun.status === "cancelled") return "已取消"; - if (agentRun.status === "running" || streaming) return "进行中"; - return "排队中"; +function statusLabel(status: ReturnType, displayName: string): string { + const suffix = + status === "running" + ? "进行中" + : status === "awaiting" + ? "待回复" + : status === "done" + ? "已完成" + : status === "error" + ? "失败" + : "排队中"; + return `${displayName}(${suffix})`; } /** - * 并行阶段成员选择器:只切换同一消息流的聚焦范围,不创建第二套执行视图。 - * 用户手动切换后保持选择;未聚焦成员的事件继续接收并持久更新。 + * 并行波次头像条(对齐会商第二步 sandbox): + * 只展示当前同时执行的多个智能体;点头像切换消息列表到该成员的执行过程。 + * 未选中成员的事件继续接收并持久更新。 */ export const AgentStageSelector = memo(function AgentStageSelector({ tabs, @@ -63,41 +58,56 @@ export const AgentStageSelector = memo(function AgentStageSelector({ awaitingAgentRunIds, }: AgentStageSelectorProps) { if (tabs.length === 0) return null; - const awaitingCount = tabs.filter((tab) => awaitingAgentRunIds.has(tab.agent_run_id)).length; - const doneCount = tabs.filter((tab) => tab.status === "completed").length; + const focused = focusedAgentRunId ?? tabs[0]?.agent_run_id; + const runningCount = tabs.filter((tab) => { + const status = avatarStatus( + tab, + streamingAgentRunIds.has(tab.agent_run_id), + awaitingAgentRunIds.has(tab.agent_run_id), + ); + return status === "running" || status === "awaiting"; + }).length; return (
-
- - {phaseLabel ?? "并行协作"} - - {doneCount}/{tabs.length} - - {awaitingCount > 0 && {awaitingCount}} +
+ {phaseLabel ?? `并行 ${tabs.length} 席`} + {runningCount > 0 && {runningCount} 进行中} +
+
+ {tabs.map((tab) => { + const streaming = streamingAgentRunIds.has(tab.agent_run_id); + const awaiting = awaitingAgentRunIds.has(tab.agent_run_id); + const status = avatarStatus(tab, streaming, awaiting); + const active = focused === tab.agent_run_id; + return ( + + ); + })}
- {tabs.map((tab) => { - const streaming = streamingAgentRunIds.has(tab.agent_run_id); - const awaiting = awaitingAgentRunIds.has(tab.agent_run_id); - const active = (focusedAgentRunId ?? tabs[0]?.agent_run_id) === tab.agent_run_id; - return ( - - ); - })}
); }); diff --git a/frontend-web/src/report-collaboration/components/NodeInspectorDrawer.tsx b/frontend-web/src/report-collaboration/components/NodeInspectorDrawer.tsx index 068e5aa5..656f9749 100644 --- a/frontend-web/src/report-collaboration/components/NodeInspectorDrawer.tsx +++ b/frontend-web/src/report-collaboration/components/NodeInspectorDrawer.tsx @@ -1,22 +1,41 @@ "use client"; -import { useEffect, useState } from "react"; -import { ArrowDown, ArrowUp, Ban, Hammer, Target, X } from "lucide-react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { Ban, ChevronDown, Target, X } from "lucide-react"; import { getReportCollaborationDriver } from "@/report-collaboration/api/driver"; import type { NodeContractDetail } from "@/report-collaboration/api/types"; +import { roleIcon, type ReportFlowNodeKind } from "@/report-collaboration/components/ReportFlowNode"; + +export interface NodeInspectorFallback { + label: string; + status: string; + role?: string | null; + angle?: string | null; + kind?: ReportFlowNodeKind; + mission?: string | null; + input_artifact_types?: string[]; + output_artifact_type?: string | null; + allowed_tools?: string[]; + acceptance_criteria?: string[]; + upstream?: string[]; + downstream?: string[]; +} interface NodeInspectorDrawerProps { open: boolean; nodeId: string | null; runId: string | null; - /** 节点基础信息(画布侧已有,用于头部展示,不发起结果请求)。 */ - fallback?: { label: string; status: string } | null; + /** 节点基础信息(画布侧已有,预览方案时也能填抽屉,不发起结果请求)。 */ + fallback?: NodeInspectorFallback | null; onClose: () => void; /** 「在对话中对此节点提出意见」:关闭抽屉 + 聚焦 Composer + 绑定目标。 */ onComment: (nodeId: string, nodeName: string) => void; } +const SECTION_KEYS = ["mission", "input", "output", "tools", "graph"] as const; +type SectionKey = (typeof SECTION_KEYS)[number]; + /** * 只读节点说明抽屉:覆盖在画布右侧,不占第三栏。 * 只读取节点合同(职责/输入/产出/工具/验收),不请求节点执行结果; @@ -33,6 +52,13 @@ export function NodeInspectorDrawer({ const [contract, setContract] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [openSections, setOpenSections] = useState>({ + mission: true, + input: true, + output: true, + tools: true, + graph: true, + }); useEffect(() => { if (!open || !nodeId || !runId) { @@ -59,135 +85,182 @@ export function NodeInspectorDrawer({ }; }, [open, nodeId, runId]); + const view = useMemo(() => { + return { + label: contract?.label ?? fallback?.label ?? nodeId ?? "", + status: contract?.status ?? fallback?.status ?? "planned", + role: contract?.role ?? fallback?.role ?? null, + angle: contract?.angle ?? fallback?.angle ?? null, + kind: fallback?.kind ?? "agent", + mission: contract?.mission ?? fallback?.mission ?? null, + input_artifact_types: contract?.input_artifact_types ?? fallback?.input_artifact_types ?? [], + output_artifact_type: contract?.output_artifact_type ?? fallback?.output_artifact_type ?? null, + allowed_tools: contract?.allowed_tools ?? fallback?.allowed_tools ?? [], + acceptance_criteria: contract?.acceptance_criteria ?? fallback?.acceptance_criteria ?? [], + upstream: contract?.upstream ?? fallback?.upstream ?? [], + downstream: contract?.downstream ?? fallback?.downstream ?? [], + forbidden: contract?.forbidden ?? [], + max_attempts: contract?.max_attempts ?? null, + time_budget_seconds: contract?.time_budget_seconds ?? null, + token_budget: contract?.token_budget ?? null, + source_policy: contract?.source_policy ?? null, + }; + }, [contract, fallback, nodeId]); + if (!open || !nodeId) return null; - const label = contract?.label ?? fallback?.label ?? nodeId; - const status = contract?.status ?? fallback?.status ?? "planned"; + const hasBody = Boolean(contract) || Boolean(fallback?.mission) || Boolean(fallback?.label); + const showLoading = loading && !hasBody; + + const toggle = (key: SectionKey) => { + setOpenSections((current) => ({ ...current, [key]: !current[key] })); + }; return ( - <> -
-
-
- -

- 重跑、补充或纠偏通过对话完成(例如「重新检索」「重新分析」);抽屉不直接发起变更。 -

-
- - +
+ +

+ 重跑、补充或纠偏通过对话完成(例如「重新检索」「重新分析」);抽屉不直接发起变更。 +

+
+ ); } +function DrawerSection({ + title, + open, + onToggle, + children, +}: { + title: string; + open: boolean; + onToggle: () => void; + children: ReactNode; +}) { + return ( +
+ + {open ?
{children}
: null} +
+ ); +} + +function shortArtifact(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return value; + return trimmed.includes(":") ? trimmed.slice(trimmed.lastIndexOf(":") + 1) : trimmed; +} + function statusText(status: string): string { const map: Record = { planned: "规划中", diff --git a/frontend-web/src/report-collaboration/components/ReportConversationPanel.tsx b/frontend-web/src/report-collaboration/components/ReportConversationPanel.tsx index 56dd51ae..4186d06a 100644 --- a/frontend-web/src/report-collaboration/components/ReportConversationPanel.tsx +++ b/frontend-web/src/report-collaboration/components/ReportConversationPanel.tsx @@ -19,6 +19,8 @@ interface ReportConversationPanelProps { composer: React.ReactNode; loading: boolean; emptyHint?: React.ReactNode; + /** 并行切换成员时重挂消息列表,避免沿用上一成员的滚动位置。 */ + listKey?: string; } /** @@ -33,6 +35,7 @@ export function ReportConversationPanel({ composer, loading, emptyHint, + listKey, }: ReportConversationPanelProps) { const hasMessages = thread.messages.length > 0; @@ -51,6 +54,7 @@ export function ReportConversationPanel({ return ( ); - }, [businessCardSlot, emptyHint, hasMessages, loading, sessionId, thread]); + }, [businessCardSlot, emptyHint, hasMessages, listKey, loading, sessionId, thread]); return ( diff --git a/frontend-web/src/report-collaboration/components/ReportFlowCanvas.tsx b/frontend-web/src/report-collaboration/components/ReportFlowCanvas.tsx index 3d569657..ba6e30f0 100644 --- a/frontend-web/src/report-collaboration/components/ReportFlowCanvas.tsx +++ b/frontend-web/src/report-collaboration/components/ReportFlowCanvas.tsx @@ -2,12 +2,12 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { - Background, - BackgroundVariant, Controls, MiniMap, + Position, ReactFlow, ReactFlowProvider, + type DefaultEdgeOptions, type Edge, type Node, type NodeTypes, @@ -23,11 +23,21 @@ import type { import { CollaborationFlowNode, type ReportFlowNodeDatum, + type ReportFlowNodeKind, } from "@/report-collaboration/components/ReportFlowNode"; const NODE_TYPES: NodeTypes = { collaboration: CollaborationFlowNode }; -const COLUMN_GAP = 280; -const ROW_GAP = 130; +/** 左到右 rank 间距(节点左缘到下一列左缘),对齐 Coze 整理布局的横向留白。 */ +const COLUMN_GAP = 360; +/** 并行节点高度估算(去掉输入/输出后的卡片高度),用于重心对齐。 */ +const NODE_HEIGHT = 128; +/** 同层并行节点之间的垂直间隙,对齐 Coze Dagre nodesep 观感。 */ +const NODE_GAP = 52; + +const DEFAULT_EDGE_OPTIONS: DefaultEdgeOptions = { + type: "default", + style: { stroke: "var(--rc-flow-edge)", strokeWidth: 2 }, +}; interface ReportFlowCanvasProps { plan: ReportPlanCandidate | null; @@ -38,15 +48,16 @@ interface ReportFlowCanvasProps { emptyHint?: string; } -/** 分层自动布局:depth = 最长依赖链长度;同层纵向排列。 */ +/** 分层 + 重心对齐:同层并行节点绕父节点垂直中心对称,对齐 Coze「整理节点」Dagre LR。 */ function layoutPlan(plan: ReportPlanCandidate): Map { const depthById = new Map(); - const nodeIds = new Set(plan.nodes.map((node) => node.id)); + const nodeById = new Map(plan.nodes.map((node) => [node.id, node])); + const nodeIds = new Set(nodeById.keys()); const visit = (nodeId: string, seen: Set): number => { if (depthById.has(nodeId)) return depthById.get(nodeId)!; - if (seen.has(nodeId)) return 0; // 防御环 + if (seen.has(nodeId)) return 0; seen.add(nodeId); - const node = plan.nodes.find((candidate) => candidate.id === nodeId); + const node = nodeById.get(nodeId); const depth = node && node.depends_on.length > 0 ? Math.max(...node.depends_on.filter((dep) => nodeIds.has(dep)).map((dep) => visit(dep, seen))) + 1 @@ -62,11 +73,30 @@ function layoutPlan(plan: ReportPlanCandidate): Map(); - for (const [depth, ids] of layers) { - ids.forEach((id, index) => { - positions.set(id, { x: depth * COLUMN_GAP, y: index * ROW_GAP }); + const yById = new Map(); + const spacing = NODE_HEIGHT + NODE_GAP; + const depths = [...layers.keys()].sort((a, b) => a - b); + for (const depth of depths) { + const ids = layers.get(depth) ?? []; + const desired = ids.map((id) => { + const node = nodeById.get(id); + const parents = (node?.depends_on ?? []).filter((dep) => yById.has(dep)); + if (parents.length === 0) return 0; + return parents.reduce((sum, parentId) => sum + (yById.get(parentId) ?? 0), 0) / parents.length; }); + const packed = ids.map((_, index) => index * spacing); + const packedMean = packed.reduce((sum, value) => sum + value, 0) / Math.max(packed.length, 1); + const desiredMean = desired.reduce((sum, value) => sum + value, 0) / Math.max(desired.length, 1); + const shift = desiredMean - packedMean; + ids.forEach((id, index) => { + yById.set(id, packed[index] + shift); + }); + } + + const positions = new Map(); + for (const node of plan.nodes) { + const depth = depthById.get(node.id) ?? 0; + positions.set(node.id, { x: depth * COLUMN_GAP, y: yById.get(node.id) ?? 0 }); } return positions; } @@ -93,8 +123,11 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint const nodes: Node[] = useMemo(() => { if (!plan) return []; void draggedVersion; + const outgoing = new Set(plan.edges.map((edge) => edge.source)); return plan.nodes.map((node) => { const state = nodeStates[node.id]; + const kind: ReportFlowNodeKind = + node.depends_on.length === 0 ? "start" : outgoing.has(node.id) ? "agent" : "end"; const datum: ReportFlowNodeDatum = { nodeId: node.id, label: node.label, @@ -103,6 +136,8 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint status: state?.status ?? "planned", attempt: state?.attempt ?? 0, selected: selectedNodeId === node.id, + kind, + tools: node.allowed_tools ?? [], }; const dragged = draggedRef.current.get(node.id); const position = dragged ?? autoPositions.get(node.id) ?? { x: 0, y: 0 }; @@ -112,6 +147,8 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint position, data: datum, draggable: true, + sourcePosition: Position.Right, + targetPosition: Position.Left, }; }); }, [plan, nodeStates, selectedNodeId, autoPositions, draggedVersion]); @@ -126,9 +163,13 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint source: edge.source, target: edge.target, label: edge.label ?? undefined, + type: "default", animated: false, - className: active ? "rc-edge-active" : undefined, - style: { stroke: "var(--rc-border-strong)", strokeWidth: 1.5 }, + className: active ? "rc-edge-active" : "rc-edge", + style: { + stroke: active ? "var(--rc-running)" : "var(--rc-flow-edge)", + strokeWidth: 2, + }, }; }); }, [plan, nodeStates]); @@ -144,9 +185,11 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint return ( onSelectNode(node.id)} onPaneClick={() => onSelectNode(null)} onNodeDragStop={(_, node) => { @@ -156,22 +199,21 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint onNodeDoubleClick={(_, node) => onSelectNode(node.id)} fitView fitViewOptions={{ padding: 0.18 }} - minZoom={0.25} + minZoom={0.55} maxZoom={1.75} nodesConnectable={false} elementsSelectable proOptions={{ hideAttribution: true }} aria-label="协作流程画布" > - - + "var(--rc-border-strong)"} - maskColor="rgba(247, 247, 252, 0.7)" + className="rc-flow-minimap" + nodeColor={() => "var(--rc-flow-edge)"} + maskColor="var(--rc-flow-minimap-mask)" /> ); diff --git a/frontend-web/src/report-collaboration/components/ReportFlowNode.tsx b/frontend-web/src/report-collaboration/components/ReportFlowNode.tsx index a0183e15..8e651ba0 100644 --- a/frontend-web/src/report-collaboration/components/ReportFlowNode.tsx +++ b/frontend-web/src/report-collaboration/components/ReportFlowNode.tsx @@ -1,19 +1,30 @@ "use client"; -import { memo } from "react"; +import { memo, type ReactNode } from "react"; import { Handle, Position, type NodeProps } from "@xyflow/react"; import { AlertTriangle, + Bot, Check, + CircleArrowRight, CircleDashed, + FileCheck, + Flag, + LineChart, Loader2, + PenLine, RefreshCw, + Search, + ShieldCheck, ShieldQuestion, + Users, X, } from "lucide-react"; import type { CollaborationNodeStatus } from "@/report-collaboration/api/types"; +export type ReportFlowNodeKind = "start" | "end" | "agent"; + /** 画布自定义节点数据(由 ReportFlowCanvas 组装)。 */ export interface ReportFlowNodeDatum { nodeId: string; @@ -23,6 +34,8 @@ export interface ReportFlowNodeDatum { status: CollaborationNodeStatus; attempt: number; selected: boolean; + kind: ReportFlowNodeKind; + tools: string[]; /** xyflow Node.data 需要 Record 兼容的索引签名。 */ [key: string]: unknown; } @@ -31,7 +44,7 @@ export type ReportFlowNodeType = "collaboration"; const STATUS_META: Record< CollaborationNodeStatus, - { text: string; color: string; icon: React.ReactNode } + { text: string; color: string; icon: ReactNode } > = { planned: { text: "规划中", color: "var(--rc-text-secondary)", icon: }, queued: { text: "排队中", color: "var(--rc-text-secondary)", icon: }, @@ -47,33 +60,89 @@ const STATUS_META: Record< interrupted: { text: "已中断", color: "var(--rc-waiting)", icon: }, }; +export function roleIcon(roleName: string | null | undefined, kind: ReportFlowNodeKind = "agent"): ReactNode { + if (kind === "start") return ; + if (kind === "end") return ; + switch (roleName) { + case "协调者": + return ; + case "研究员": + return ; + case "核验员": + return ; + case "分析员": + return ; + case "撰写员": + return ; + case "审稿员": + return ; + default: + return ; + } +} + +function FlowPill({ children }: { children: ReactNode }) { + return {children}; +} + +function FieldRow({ label, children }: { label: string; children: ReactNode }) { + return ( + <> +
{label}
+
{children}
+ + ); +} + function CollaborationFlowNodeComponent({ data }: NodeProps) { const node = data as unknown as ReportFlowNodeDatum; const meta = STATUS_META[node.status] ?? STATUS_META.planned; + const statusText = node.attempt > 1 ? `${meta.text} · 第 ${node.attempt} 次` : meta.text; + return (
- -
{node.label}
- {(node.roleName || node.angle) && ( -
- {[node.roleName, node.angle].filter(Boolean).join(" · ")} -
- )} -
- {meta.icon} - - {meta.text} - {node.attempt > 1 ? ` · 第 ${node.attempt} 次` : ""} + +
+ + {roleIcon(node.roleName, node.kind)} + {node.label}
- +
+ {node.roleName ? ( + + {node.roleName} + + ) : null} + {node.angle ? ( + + {node.angle} + + ) : null} + {node.tools.length > 0 ? ( + + {node.tools.map((tool) => ( + {tool} + ))} + + ) : null} + + + {meta.icon} + {statusText} + + +
+
); } diff --git a/frontend-web/src/report-collaboration/hooks/useReportCollaborationMessages.ts b/frontend-web/src/report-collaboration/hooks/useReportCollaborationMessages.ts index 5312d070..cfa6f134 100644 --- a/frontend-web/src/report-collaboration/hooks/useReportCollaborationMessages.ts +++ b/frontend-web/src/report-collaboration/hooks/useReportCollaborationMessages.ts @@ -1,7 +1,7 @@ /** * 消息组装 hook:状态 → 聚焦过滤 → LangGraph Message[] → BaseStream 外观。 * - * - 并行聚焦只做客户端过滤(共享 human + 目标成员消息),不重拉不改写。 + * - 并行执行中只显示当前成员;结束后把各成员收成普通聊天记录,不重拉不改写。 * - 虚拟业务卡(方案组 / 命令确认 / 报告入口)由页面以 extraCards 传入, * 经 MessageList 的 businessCardSlot 渲染。 */ @@ -18,7 +18,7 @@ import { buildBaseStreamFacade, toLangGraphMessages, } from "@/report-collaboration/state/message-adapter"; -import { selectMessagesForAgentRun, selectOrderedVisibleMessages } from "@/report-collaboration/state/selectors"; +import { selectConversationMessages, selectOrderedVisibleMessages } from "@/report-collaboration/state/selectors"; export interface UseReportCollaborationMessagesResult { visibleMessages: CollaborationMessage[]; @@ -37,11 +37,12 @@ export function useReportCollaborationMessages( ): UseReportCollaborationMessagesResult { return useMemo(() => { const visibleMessages = selectOrderedVisibleMessages(state); - const focusedMessages = selectMessagesForAgentRun(visibleMessages, focusedAgentRunId); + const focusedMessages = selectConversationMessages(state, focusedAgentRunId); const base = toLangGraphMessages(focusedMessages); const displayMessages = extraCards.length > 0 ? [...base, ...extraCards] : base; + const streamSource = focusedAgentRunId ? focusedMessages : visibleMessages; const streaming = - visibleMessages.some((message) => !message.completed_at && message.role !== "human") || + streamSource.some((message) => !message.completed_at && message.role !== "human") || extraLoading; const displayThread = buildBaseStreamFacade(displayMessages, streaming); return { visibleMessages, focusedMessages, displayMessages, displayThread, streaming }; diff --git a/frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx b/frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx index fa87b8c3..8590aa4f 100644 --- a/frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx +++ b/frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx @@ -41,6 +41,7 @@ import { selectParallelMembers, selectPendingConfirmationCommands, selectPlanForCanvas, + selectPreferredParallelFocus, selectStreamingAgentRunIds, } from "@/report-collaboration/state/selectors"; import { buildBaseStreamFacade } from "@/report-collaboration/state/message-adapter"; @@ -165,7 +166,14 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin const agentTabs = useMemo(() => selectAgentTabs(state), [state]); const [focusedAgentRunId, setFocusedAgentRunId] = useState(null); const [focusPinned, setFocusPinned] = useState(false); - const effectiveFocus = agentTabs.length > 0 ? (focusedAgentRunId ?? agentTabs[0].agent_run_id) : null; + const tabIds = useMemo(() => new Set(agentTabs.map((tab) => tab.agent_run_id)), [agentTabs]); + const effectiveFocus = + agentTabs.length === 0 + ? null + : focusedAgentRunId && tabIds.has(focusedAgentRunId) + ? focusedAgentRunId + : agentTabs[0].agent_run_id; + const focusedTab = agentTabs.find((tab) => tab.agent_run_id === effectiveFocus) ?? null; const { previewPlanId, previewPlan, effectivePlanId } = useReportPlanSelection( state.plans, @@ -208,6 +216,18 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin // eslint-disable-next-line react-hooks/exhaustive-deps }, [session.actionError]); + // 并行波次:默认落到正在执行的成员;点头像/画布后保持该成员,直到波次结束。 + useEffect(() => { + if (agentTabs.length === 0) { + if (focusedAgentRunId) setFocusedAgentRunId(null); + if (focusPinned) setFocusPinned(false); + return; + } + if (focusedAgentRunId && tabIds.has(focusedAgentRunId)) return; + const next = selectPreferredParallelFocus(agentTabs); + if (next) setFocusedAgentRunId(next); + }, [agentTabs, focusPinned, focusedAgentRunId, tabIds]); + // 虚拟业务卡:方案组(已出方案且 run 未开始)/ 命令确认 / 报告入口。 // 采用方案后卡片保留,「开始协作」按钮随选定态启用;run 开始后消失。 const runStarted = Boolean(state.run && state.run.plan_id); @@ -271,30 +291,30 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin (nodeId: string | null) => { setSelectedNodeId(nodeId); setDrawerOpen(Boolean(nodeId)); - // 打开并行成员节点时同步聚焦对应标签(用户发送前先看到该成员的真实对话)。 + // 点并行智能体节点时,消息列表切到该成员的执行过程(与头像条同一套聚焦)。 if (nodeId) { const member = findMemberOfNode(nodeId); - if (member) { + if (member && tabIds.has(member.agent_run_id)) { setFocusedAgentRunId(member.agent_run_id); setFocusPinned(true); } } }, - [findMemberOfNode], + [findMemberOfNode, tabIds], ); const handleNodeComment = useCallback( (nodeId: string, nodeName: string) => { const member = findMemberOfNode(nodeId); setComposerTarget({ nodeId, nodeName, agentRunId: member?.agent_run_id ?? null }); - if (member) { + if (member && tabIds.has(member.agent_run_id)) { setFocusedAgentRunId(member.agent_run_id); setFocusPinned(true); } setDrawerOpen(false); setFocusSignal((signal) => signal + 1); }, - [findMemberOfNode], + [findMemberOfNode, tabIds], ); const handleRestore = useCallback( @@ -363,10 +383,26 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin const runActive = isRunActive(state.run?.status); const headVersion = selectHeadReportVersion(state); const nodeStates = selectNodeStatusMap(state); + const selectedPlanNode = canvasPlan?.nodes.find((node) => node.id === selectedNodeId) ?? null; + const outgoingIds = new Set(canvasPlan?.edges.filter((edge) => edge.source === selectedNodeId).map((edge) => edge.target) ?? []); const drawerFallback = selectedNodeId ? { - label: state.nodes[selectedNodeId]?.label ?? canvasPlan?.nodes.find((n) => n.id === selectedNodeId)?.label ?? selectedNodeId, + label: state.nodes[selectedNodeId]?.label ?? selectedPlanNode?.label ?? selectedNodeId, status: state.nodes[selectedNodeId]?.status ?? "planned", + role: state.nodes[selectedNodeId]?.role_display_name ?? selectedPlanNode?.role_display_name ?? null, + angle: state.nodes[selectedNodeId]?.angle ?? selectedPlanNode?.angle ?? null, + kind: (selectedPlanNode && selectedPlanNode.depends_on.length === 0 + ? "start" + : outgoingIds.size > 0 + ? "agent" + : "end") as const, + mission: selectedPlanNode?.mission ?? null, + input_artifact_types: selectedPlanNode?.input_artifact_types ?? [], + output_artifact_type: selectedPlanNode?.output_artifact_type ?? null, + allowed_tools: selectedPlanNode?.allowed_tools ?? [], + acceptance_criteria: selectedPlanNode?.acceptance_criteria ?? [], + upstream: selectedPlanNode?.depends_on ?? [], + downstream: canvasPlan?.edges.filter((edge) => edge.source === selectedNodeId).map((edge) => edge.target) ?? [], } : null; @@ -434,10 +470,12 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin sessionId={sessionId} thread={displayThread} businessCardSlot={businessCardSlot} + listKey={effectiveFocus ?? "timeline"} loading={session.snapshotLoading} stageSelector={ { setFocusedAgentRunId(agentRunId); @@ -447,6 +485,11 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin awaitingAgentRunIds={awaitingIds} /> } + emptyHint={ + effectiveFocus + ? `${focusedTab?.display_name ?? "智能体"}正在执行,输出会出现在这里` + : undefined + } composer={ { - if (message.role === "human") return true; - return !message.agent_run_id || message.agent_run_id === focusedAgentRunId; - }); + return messages.filter((message) => message.agent_run_id === focusedAgentRunId); } function isCoordinator(agentRun: AgentRun): boolean { return /coord/i.test(agentRun.role) || /协调/.test(agentRun.display_name); } -/** 并行阶段成员(不含协调者);同一 phase 下 >= 2 个成员时显示选择器。 */ -export function selectParallelMembers(state: CollaborationState): AgentRun[] { - const runs = Object.values(state.agentRuns); - const members = runs.filter((run) => !isCoordinator(run)); - if (members.length < 2) return []; - // 以最新 phase 聚合(并行阶段成员共享 phase_id)。 - const latestPhase = members - .map((run) => run.phase_id ?? "") - .sort() - .at(-1); - const phaseMembers = members.filter((run) => (run.phase_id ?? "") === latestPhase); - return phaseMembers.length >= 2 ? phaseMembers : []; +const IN_FLIGHT_AGENT_STATUS = new Set(["pending", "running", "waiting_input"]); +const IN_FLIGHT_NODE_STATUS = new Set([ + "queued", + "running", + "validating", + "awaiting_input", + "cancel_requested", + "reopened", +]); +const TERMINAL_NODE_STATUS = new Set(["completed", "failed", "cancelled", "superseded", "interrupted"]); +const TERMINAL_AGENT_STATUS = new Set(["completed", "failed", "cancelled"]); + +function nodeForAgent(state: CollaborationState, agentRun: AgentRun): CollaborationNodeState | null { + const nodeRunId = agentRun.node_run_id; + if (!nodeRunId) return null; + for (const node of Object.values(state.nodes)) { + if (nodeRunId === node.node_id || nodeRunId.startsWith(`${node.node_id}-`)) { + return node; + } + } + return null; } -/** 选择器标签:协调者在首位,后跟并行成员(仅并行阶段非空时使用)。 */ +/** 该成员是否仍在执行:节点终态优先于 agentRun.status(画布完成即结束)。 */ +export function isAgentStillExecuting(state: CollaborationState, agentRun: AgentRun): boolean { + const node = nodeForAgent(state, agentRun); + if (node) { + if (TERMINAL_NODE_STATUS.has(node.status)) return false; + if (IN_FLIGHT_NODE_STATUS.has(node.status)) return true; + } + if (TERMINAL_AGENT_STATUS.has(agentRun.status)) return false; + return IN_FLIGHT_AGENT_STATUS.has(agentRun.status); +} + +function membersByPhase(state: CollaborationState): Map { + const byPhase = new Map(); + for (const member of Object.values(state.agentRuns)) { + if (isCoordinator(member)) continue; + const key = member.phase_id ?? ""; + const list = byPhase.get(key); + if (list) list.push(member); + else byPhase.set(key, [member]); + } + return byPhase; +} + +function latestStartedAt(members: AgentRun[]): string { + return [...members].map((run) => run.started_at ?? "").sort().at(-1) ?? ""; +} + +/** + * 当前正在并行执行的那一波成员(不含协调者)。 + * 同一 phase 下 ≥2 个成员且至少一人仍在执行时才返回;波次结束后回到空数组, + * 消息列表按会商第二步收成普通聊天记录。 + */ +export function selectParallelMembers(state: CollaborationState): AgentRun[] { + const waves = [...membersByPhase(state).entries()] + .map(([phaseId, list]) => ({ phaseId, list })) + .filter(({ list }) => list.length >= 2 && list.some((member) => isAgentStillExecuting(state, member))); + if (waves.length === 0) return []; + const currentPhase = state.run?.current_phase ?? null; + const chosen = + waves.find((wave) => wave.phaseId === currentPhase) ?? + [...waves].sort((a, b) => latestStartedAt(b.list).localeCompare(latestStartedAt(a.list)))[0]; + return [...chosen.list].sort((a, b) => (a.started_at ?? "").localeCompare(b.started_at ?? "")); +} + +/** 头像条:仅当前并行波次的执行成员(不含协调者)。 */ export function selectAgentTabs(state: CollaborationState): AgentRun[] { - const members = selectParallelMembers(state); - if (members.length === 0) return []; - const coordinator = - Object.values(state.agentRuns).find(isCoordinator) ?? null; - const byStart = (a: AgentRun, b: AgentRun) => - (a.started_at ?? "").localeCompare(b.started_at ?? ""); - return [...(coordinator ? [coordinator] : []), ...[...members].sort(byStart)]; + return selectParallelMembers(state); +} + +/** 波次刚出现或当前聚焦已失效时,优先落到正在输出的成员。 */ +export function selectPreferredParallelFocus( + members: AgentRun[], + streamingIds: Set = new Set(), +): string | null { + if (members.length === 0) return null; + const preferred = + members.find((member) => member.status === "running" || streamingIds.has(member.agent_run_id)) ?? + members.find((member) => member.status === "waiting_input") ?? + members[0]; + return preferred.agent_run_id; +} + +/** + * 并行结束后把同一波次各成员的消息收成连续块,好让 MessageList + * 把每个智能体当成一条普通聊天记录(对齐会商第二步收起并行后的列表)。 + */ +export function stitchParallelAgentBlocks( + messages: CollaborationMessage[], + state: CollaborationState, +): CollaborationMessage[] { + const phases = [...membersByPhase(state).entries()].filter(([, list]) => list.length >= 2); + if (phases.length === 0) return messages; + + const phaseOfAgent = new Map(); + const agentsOfPhase = new Map(); + for (const [phaseId, list] of phases) { + const ordered = [...list].sort((a, b) => (a.started_at ?? "").localeCompare(b.started_at ?? "")); + agentsOfPhase.set(phaseId, ordered); + for (const member of ordered) { + phaseOfAgent.set(member.agent_run_id, phaseId); + } + } + + const flushed = new Set(); + const result: CollaborationMessage[] = []; + for (const message of messages) { + const phaseId = message.agent_run_id ? phaseOfAgent.get(message.agent_run_id) : undefined; + if (!phaseId) { + result.push(message); + continue; + } + if (flushed.has(phaseId)) continue; + flushed.add(phaseId); + for (const member of agentsOfPhase.get(phaseId) ?? []) { + for (const item of messages) { + if (item.agent_run_id === member.agent_run_id) result.push(item); + } + } + } + return result; +} + +/** 会话时间线:并行执行中按成员过滤;结束后拼成普通聊天记录。 */ +export function selectConversationMessages( + state: CollaborationState, + focusedAgentRunId: string | null, +): CollaborationMessage[] { + const visible = selectOrderedVisibleMessages(state); + if (focusedAgentRunId) return selectMessagesForAgentRun(visible, focusedAgentRunId); + return stitchParallelAgentBlocks(visible, state); } /** 画布用计划:预览 > 已选定 > 最新提案组第一张。 */ diff --git a/frontend-web/src/report-collaboration/styles/report-collaboration.css b/frontend-web/src/report-collaboration/styles/report-collaboration.css index 0aa662fe..40ef7fad 100644 --- a/frontend-web/src/report-collaboration/styles/report-collaboration.css +++ b/frontend-web/src/report-collaboration/styles/report-collaboration.css @@ -12,7 +12,7 @@ /* Chrome 109 兼容:不使用 color-mix,透明度变体用 rgba / legacy rgb 变量表达。 */ --rc-bg-page: var(--background, #f7f7fc); --rc-bg-panel: var(--card, #fcfcff); - --rc-bg-canvas: var(--background, #f7f7fc); + --rc-bg-canvas: #f7f8f9; --rc-bg-secondary: var(--muted, #f0f0f7); --rc-border: var(--border, rgba(82, 100, 154, 0.13)); --rc-border-strong: var(--border, rgba(82, 100, 154, 0.13)); @@ -28,6 +28,22 @@ --rc-panel-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.02); --rc-card-radius: 9px; --rc-panel-radius: 12px; + /* Coze Studio 画布皮肤(节点/连线/端口/点阵),不改交互语义 */ + --rc-flow-edge: #abb0f5; + --rc-flow-port: #4d53e8; + --rc-flow-port-idle: #9197f1; + --rc-flow-accent: #4d53e8; + --rc-flow-accent-rgb: 77 83 232; + --rc-flow-dot: #c8ccd0; + --rc-flow-dot-size: 1.5px; + --rc-flow-dot-gap: 26px; + --rc-flow-minimap-mask: rgba(247, 248, 249, 0.72); + --rc-pill-input-bg: #fff1e8; + --rc-pill-input-fg: #c2410c; + --rc-pill-output-bg: #eee9ff; + --rc-pill-output-fg: #4d53e8; + --rc-pill-neutral-bg: var(--rc-bg-secondary); + --rc-pill-neutral-fg: var(--rc-text-primary); display: flex; height: 100%; @@ -90,8 +106,10 @@ flex: 1; min-width: 0; height: 100%; - background: var(--rc-bg-canvas); overflow: hidden; + background-color: var(--rc-bg-canvas); + background-image: radial-gradient(var(--rc-flow-dot) var(--rc-flow-dot-size), transparent var(--rc-flow-dot-size)); + background-size: var(--rc-flow-dot-gap) var(--rc-flow-dot-gap); } /* 分隔条:宽热区 + 4px 视觉把手,键盘可聚焦 */ @@ -277,80 +295,275 @@ cursor: not-allowed; } -/* ── 并行成员选择器 ───────────────────────────────────────────────────── */ +/* ── 并行成员头像条(对齐会商第二步)─────────────────────────────────── */ .rc-agent-tabs { display: flex; align-items: center; - gap: 6px; - padding: 8px 10px 0; + gap: 10px; + padding: 8px 12px; overflow-x: auto; scrollbar-width: thin; -} - -.rc-agent-tab { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - border-radius: 999px; - border: 1px solid var(--rc-border); + border-bottom: 1px solid var(--rc-border); background: var(--rc-bg-panel); - font-size: 12px; - color: var(--rc-text-primary); - cursor: pointer; + flex-shrink: 0; +} + +.rc-agent-strip-meta { + display: flex; + flex-shrink: 0; + align-items: baseline; + gap: 4px; + font-size: 10px; + font-weight: 700; + color: var(--rc-text-secondary); white-space: nowrap; - transition: border-color 0.16s ease, background 0.16s ease; } -.rc-agent-tab:hover { - border-color: var(--rc-accent); -} - -.rc-agent-tab[data-active="true"] { - border-color: var(--rc-accent); - background: var(--rc-accent-soft); +.rc-agent-strip-running { color: var(--rc-accent); font-weight: 600; } -/* ── 画布节点 ─────────────────────────────────────────────────────────── */ +.rc-agent-avatars { + display: flex; + align-items: center; + gap: 8px; +} + +.rc-agent-avatar { + position: relative; + flex-shrink: 0; + width: 32px; + height: 32px; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + border-radius: 999px; + box-shadow: 0 0 0 1px transparent; + transition: box-shadow 0.16s ease, opacity 0.16s ease; +} + +.rc-agent-avatar:hover { + box-shadow: 0 0 0 2px var(--rc-border); +} + +.rc-agent-avatar[data-active="true"] { + box-shadow: 0 0 0 2px var(--rc-accent); +} + +.rc-agent-avatar[data-active="false"] .rc-agent-avatar-face, +.rc-agent-avatar:not([data-active="true"]) .rc-agent-avatar-face { + opacity: 0.82; +} + +.rc-agent-avatar-ping { + position: absolute; + inset: 0; + border-radius: 999px; + background: rgb(var(--rc-accent-rgb) / 28%); + animation: rc-avatar-ping 1.4s cubic-bezier(0, 0, 0.2, 1) infinite; +} + +.rc-agent-avatar-face { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 999px; + color: #fff; + background: var(--rc-flow-accent); + box-shadow: inset 0 0 0 1px rgba(6, 7, 9, 0.08); +} + +.rc-agent-avatar-face svg { + width: 16px; + height: 16px; +} + +.rc-agent-avatar[data-role="研究员"] .rc-agent-avatar-face { + background: #3b82f6; +} + +.rc-agent-avatar[data-role="核验员"] .rc-agent-avatar-face { + background: #0d9488; +} + +.rc-agent-avatar[data-role="分析员"] .rc-agent-avatar-face { + background: #7c3aed; +} + +.rc-agent-avatar[data-role="撰写员"] .rc-agent-avatar-face { + background: #ea580c; +} + +.rc-agent-avatar[data-role="审稿员"] .rc-agent-avatar-face { + background: #16a34a; +} + +.rc-agent-avatar-badge { + position: absolute; + right: -1px; + bottom: -1px; + display: flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border-radius: 999px; + border: 1px solid #fff; + color: #fff; + box-shadow: 0 1px 2px rgba(15, 21, 40, 0.18); + background: var(--rc-text-secondary); +} + +.rc-agent-avatar-badge[data-status="running"] { + background: var(--rc-accent); +} + +.rc-agent-avatar-badge[data-status="awaiting"] { + background: var(--rc-waiting); +} + +.rc-agent-avatar-badge[data-status="done"] { + background: var(--rc-success); +} + +.rc-agent-avatar-badge[data-status="error"] { + background: var(--rc-danger); +} + +.rc-agent-avatar-badge[data-status="queued"] { + display: none; +} + +.rc-agent-avatar-dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: #fff; +} + +@keyframes rc-avatar-ping { + 75%, + 100% { + transform: scale(1.55); + opacity: 0; + } +} + +/* ── 画布(Coze 点阵 + 节点卡 + 贝塞尔连线)──────────────────────────── */ + +.rc-canvas-panel .rc-flow, +.rc-canvas-panel .react-flow { + background: transparent; +} + +.rc-canvas-panel .react-flow__background { + display: none; +} + +.rc-canvas-panel .react-flow__node, +.rc-canvas-panel .react-flow__node.selected { + padding: 0; + background: transparent; + border: none; + box-shadow: none; + outline: none; +} + +.rc-canvas-panel .react-flow__edge-path { + stroke-linecap: round; +} + +.rc-canvas-panel .react-flow__edge-text { + font-size: 11px; + fill: var(--rc-text-secondary); +} + +.rc-canvas-panel .react-flow__controls { + display: flex; + gap: 2px; + padding: 4px; + border: 1px solid var(--rc-border); + border-radius: 8px; + box-shadow: var(--rc-panel-shadow); + background: var(--rc-bg-panel); + overflow: hidden; +} + +.rc-canvas-panel .react-flow__controls-button { + width: 28px; + height: 28px; + border: none; + border-bottom: none; + background: transparent; + fill: var(--rc-text-secondary); +} + +.rc-canvas-panel .react-flow__controls-button:hover { + background: var(--rc-bg-secondary); +} + +.rc-flow-minimap { + border: 1px solid var(--rc-border) !important; + border-radius: 8px !important; + overflow: hidden; + box-shadow: var(--rc-panel-shadow); + background: var(--rc-bg-panel) !important; +} .rc-flow-node { display: flex; flex-direction: column; - gap: 4px; - min-width: 168px; - max-width: 216px; - padding: 8px 10px; - border-radius: var(--rc-card-radius); - border: 1.5px solid var(--rc-border-strong); + width: 280px; background: var(--rc-bg-panel); + border: 1px solid var(--rc-border); + border-radius: 8px; box-shadow: var(--rc-panel-shadow); font-size: 12px; cursor: pointer; + overflow: visible; transition: border-color 0.16s ease, box-shadow 0.16s ease; } .rc-flow-node:hover { - border-color: var(--rc-accent); + border-color: var(--rc-flow-accent); +} + +.rc-flow-node[data-selected="true"] { + border-color: var(--rc-flow-accent); +} + +.rc-flow-node[data-status="queued"] { + outline: 2px solid var(--rc-text-secondary); } .rc-flow-node[data-status="running"], .rc-flow-node[data-status="validating"] { - border-color: var(--rc-running); -} - -.rc-flow-node[data-status="awaiting_input"] { - border-color: var(--rc-waiting); + outline: 2px solid var(--rc-running); + outline-offset: 2px; + box-shadow: + 0 0 0 5px rgba(37, 99, 235, 0.14), + 0 4px 14px rgba(37, 99, 235, 0.18); + animation: rc-node-running-pulse 1.25s ease-in-out infinite; } .rc-flow-node[data-status="completed"] { - border-color: var(--rc-success); + outline: 2px solid var(--rc-success); } .rc-flow-node[data-status="failed"] { - border-color: var(--rc-danger); + outline: 2px solid var(--rc-danger); +} + +.rc-flow-node[data-status="awaiting_input"] { + outline: 2px solid var(--rc-waiting); + outline-offset: 2px; + box-shadow: 0 0 0 5px rgba(217, 119, 6, 0.14); } .rc-flow-node[data-status="superseded"], @@ -358,62 +571,193 @@ opacity: 0.62; } -.rc-flow-node[data-selected="true"] { - box-shadow: 0 0 0 2px rgb(var(--rc-accent-rgb) / 26%); +.rc-flow-node-header { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 12px 12px 8px; + background: var(--rc-bg-panel); + border-radius: 8px 8px 0 0; +} + +.rc-flow-node[data-kind="start"] .rc-flow-node-header, +.rc-flow-node[data-kind="end"] .rc-flow-node-header { + background: linear-gradient(180deg, rgb(var(--rc-flow-accent-rgb) / 12%) 0%, var(--rc-bg-panel) 100%); +} + +.rc-flow-node-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 24px; + height: 24px; + border-radius: 4px; + background: var(--rc-flow-accent); + color: #fff; + box-shadow: inset 0 0 0 1px rgba(6, 7, 9, 0.08); +} + +.rc-flow-node-icon svg { + width: 14px; + height: 14px; +} + +.rc-flow-node[data-role="研究员"] .rc-flow-node-icon { + background: #3b82f6; +} + +.rc-flow-node[data-role="核验员"] .rc-flow-node-icon { + background: #0d9488; +} + +.rc-flow-node[data-role="分析员"] .rc-flow-node-icon { + background: #7c3aed; +} + +.rc-flow-node[data-role="撰写员"] .rc-flow-node-icon { + background: #ea580c; +} + +.rc-flow-node[data-role="审稿员"] .rc-flow-node-icon { + background: #16a34a; } .rc-flow-node-title { - font-weight: 600; - line-height: 1.3; + flex: 1; + min-width: 0; + font-size: 16px; + font-weight: 500; + line-height: 24px; + color: var(--rc-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.rc-flow-node-sub { +.rc-flow-node-body { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 6px 8px; + width: 100%; + padding: 0 12px 12px; + align-items: center; +} + +.rc-flow-node-field-label { + font-size: 12px; + font-weight: 500; + line-height: 16px; color: var(--rc-text-secondary); - font-size: 11px; - line-height: 1.3; + text-align: right; + white-space: nowrap; +} + +.rc-flow-node-field-value { + display: flex; + flex-wrap: wrap; + gap: 4px; + min-width: 0; + font-size: 12px; + color: var(--rc-text-primary); +} + +.rc-flow-pill { + display: inline-flex; + align-items: center; + max-width: 100%; + height: 20px; + padding: 0 7px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + line-height: 20px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rc-flow-pill-input { + background: var(--rc-pill-input-bg); + color: var(--rc-pill-input-fg); +} + +.rc-flow-pill-output { + background: var(--rc-pill-output-bg); + color: var(--rc-pill-output-fg); +} + +.rc-flow-pill-neutral { + background: var(--rc-pill-neutral-bg); + color: var(--rc-pill-neutral-fg); } .rc-flow-node-status { display: inline-flex; align-items: center; gap: 4px; - font-size: 11px; + font-size: 12px; + font-weight: 500; } -/* 活动连线低强度流动 */ -.rc-edge-active { +.rc-flow-handle { + width: 8px !important; + height: 8px !important; + background: var(--rc-flow-port-idle) !important; + border: 2px solid var(--rc-bg-panel) !important; + border-radius: 50% !important; +} + +.rc-flow-node:hover .rc-flow-handle { + background: var(--rc-flow-port) !important; +} + +/* 活动连线低强度流动(对齐 Coze processing-line) */ +.rc-edge .react-flow__edge-path { + stroke: var(--rc-flow-edge); +} + +.rc-edge-active .react-flow__edge-path { stroke: var(--rc-running); - stroke-dasharray: 6 4; - animation: rc-edge-flow 1.2s linear infinite; + stroke-dasharray: 5; + animation: rc-edge-flow 0.5s linear infinite; } @keyframes rc-edge-flow { - to { - stroke-dashoffset: -10; + from { + stroke-dashoffset: 10; } } -/* ── 节点说明抽屉(覆盖画布,不占第三栏)─────────────────────────────── */ - -.rc-drawer-mask { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.2); - z-index: 20; +@keyframes rc-node-running-pulse { + 0%, + 100% { + box-shadow: + 0 0 0 4px rgba(37, 99, 235, 0.1), + 0 4px 12px rgba(37, 99, 235, 0.16); + } + 50% { + box-shadow: + 0 0 0 8px rgba(37, 99, 235, 0.04), + 0 6px 18px rgba(37, 99, 235, 0.24); + } } +/* ── 节点说明抽屉(Coze 右侧 NodeForm 贴边白面板)─────────────────────── */ + .rc-drawer { position: absolute; top: 8px; right: 8px; bottom: 8px; - width: min(420px, calc(100% - 16px)); + width: min(440px, calc(100% - 16px)); display: flex; flex-direction: column; - border-radius: 10px; + border-radius: 8px; border: 1px solid var(--rc-border); background: var(--rc-bg-panel); - box-shadow: -8px 0 24px rgba(0, 0, 0, 0.12); + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.08); z-index: 21; overflow: hidden; animation: rc-drawer-in 0.18s ease-out; @@ -430,8 +774,62 @@ } } +.rc-drawer-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 12px 10px; + border-bottom: 1px solid var(--rc-border); + background: linear-gradient(180deg, rgb(var(--rc-flow-accent-rgb) / 8%) 0%, var(--rc-bg-panel) 100%); + flex-shrink: 0; +} + +.rc-drawer-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rc-drawer-subtitle { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 2px; + font-size: 12px; + line-height: 16px; + color: var(--rc-text-secondary); +} + +.rc-drawer-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--rc-text-secondary); + cursor: pointer; + flex-shrink: 0; +} + +.rc-drawer-icon-btn:hover { + background: var(--rc-bg-secondary); + color: var(--rc-text-primary); +} + +.rc-drawer-body { + flex: 1; + overflow-y: auto; + min-height: 0; +} + .rc-drawer-section { - padding: 12px; + padding: 12px 16px; border-bottom: 1px solid var(--rc-border); } @@ -439,11 +837,68 @@ border-bottom: none; } -.rc-drawer-label { - font-size: 11px; - font-weight: 600; +.rc-drawer-section-toggle { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + padding: 0; + border: none; + background: transparent; + font-size: 14px; + font-weight: 500; + line-height: 22px; + color: var(--rc-text-primary); + cursor: pointer; + text-align: left; +} + +.rc-drawer-chevron { + width: 16px; + height: 16px; + flex-shrink: 0; color: var(--rc-text-secondary); - margin-bottom: 4px; + transform: rotate(-90deg); + transition: transform 0.16s ease; +} + +.rc-drawer-section[data-open="true"] .rc-drawer-chevron { + transform: rotate(0deg); +} + +.rc-drawer-section-body { + margin-top: 8px; +} + +.rc-drawer-text { + font-size: 12px; + line-height: 20px; + color: var(--rc-text-primary); +} + +.rc-drawer-list { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; + font-size: 12px; + line-height: 20px; +} + +.rc-drawer-list li { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.rc-drawer-footer { + padding: 12px 16px; + border-top: 1px solid var(--rc-border); + flex-shrink: 0; + background: var(--rc-bg-panel); } /* ── Composer ─────────────────────────────────────────────────────────── */ @@ -593,7 +1048,11 @@ /* ── 无障碍:减少动效 ─────────────────────────────────────────────────── */ @media (prefers-reduced-motion: reduce) { - .rc-edge-active { + .rc-edge-active .react-flow__edge-path { + animation: none; + } + .rc-flow-node[data-status="running"], + .rc-flow-node[data-status="validating"] { animation: none; } .rc-drawer { @@ -602,11 +1061,25 @@ .rc-spin { animation-duration: 2.4s; } + .rc-agent-avatar-ping { + animation: none; + } .rc-resizer::after, .rc-btn, .rc-plan-card, - .rc-agent-tab, + .rc-agent-avatar, .rc-flow-node { transition: none; } } + +.dark .rc-root { + --rc-bg-canvas: #1c2128; + --rc-flow-edge: #7b82ee; + --rc-flow-dot: rgba(255, 255, 255, 0.22); + --rc-flow-minimap-mask: rgba(28, 33, 40, 0.72); + --rc-pill-input-bg: rgba(194, 65, 12, 0.22); + --rc-pill-input-fg: #fdba74; + --rc-pill-output-bg: rgba(77, 83, 232, 0.28); + --rc-pill-output-fg: #c7c9ff; +} diff --git a/frontend-web/src/report-collaboration/tests/message-adapter.test.ts b/frontend-web/src/report-collaboration/tests/message-adapter.test.ts index 2be614f5..8b441dd0 100644 --- a/frontend-web/src/report-collaboration/tests/message-adapter.test.ts +++ b/frontend-web/src/report-collaboration/tests/message-adapter.test.ts @@ -8,8 +8,9 @@ import { buildBaseStreamFacade, toLangGraphMessages, } from "../state/message-adapter"; -import { selectMessagesForAgentRun } from "../state/selectors"; -import type { CollaborationMessage } from "../api/types"; +import { selectMessagesForAgentRun, selectAgentTabs, selectParallelMembers, selectConversationMessages, stitchParallelAgentBlocks } from "../state/selectors"; +import { createInitialCollaborationState } from "../state/event-reducer"; +import type { AgentRun, CollaborationMessage } from "../api/types"; function human(id: string, content: string): CollaborationMessage { return { message_id: id, role: "human", content, visibility: "visible", created_at: "t", completed_at: "t" }; @@ -57,7 +58,7 @@ test("一对一映射:human/ai/tool 保持原文与归属标记", () => { assert.equal(kwargs?.agentRunId, "a_brand"); }); -test("并行聚焦:共享 human + 无归属 + 目标成员,其余成员被过滤", () => { +test("并行聚焦:只保留目标成员自己的消息,不混入 human / 协调者 / 其它成员", () => { const messages = [ human("h1", "开始"), ai("c1", null, "我先分派任务"), @@ -67,12 +68,120 @@ test("并行聚焦:共享 human + 无归属 + 目标成员,其余成员被 const focusedBrand = selectMessagesForAgentRun(messages, "a_brand"); assert.deepEqual( focusedBrand.map((message) => message.message_id), - ["h1", "c1", "b1"], + ["b1"], ); // null = 全量视图(单成员 / 协调者阶段) assert.equal(selectMessagesForAgentRun(messages, null).length, 4); }); +function agentRun(partial: Partial & Pick): AgentRun { + return { + role: "研究员", + status: "running", + phase_id: "phase_parallel_research", + started_at: "2026-09-03T00:00:00Z", + ...partial, + }; +} + +test("并行头像条:同一 phase 且至少两人仍在执行时才出现,不含协调者", () => { + const state = createInitialCollaborationState("s1"); + state.agentRuns = { + coord: agentRun({ + agent_run_id: "coord", + display_name: "协调者", + role: "协调者", + phase_id: "phase_coordinator", + status: "running", + }), + market: agentRun({ agent_run_id: "a_market", display_name: "市场分析师", started_at: "t1" }), + brand: agentRun({ agent_run_id: "a_brand", display_name: "品牌竞争分析师", started_at: "t2" }), + }; + const tabs = selectAgentTabs(state); + assert.deepEqual( + tabs.map((tab) => tab.agent_run_id), + ["a_market", "a_brand"], + ); + assert.equal(selectParallelMembers(state).length, 2); +}); + +test("并行波次全部结束后头像条消失,消息列表恢复全量", () => { + const state = createInitialCollaborationState("s1"); + state.agentRuns = { + market: agentRun({ + agent_run_id: "a_market", + display_name: "市场分析师", + status: "completed", + }), + brand: agentRun({ + agent_run_id: "a_brand", + display_name: "品牌竞争分析师", + status: "completed", + }), + }; + assert.deepEqual(selectAgentTabs(state), []); +}); + +test("画布节点完成后即使 agentRun 仍为 running,也收起并行头像条", () => { + const state = createInitialCollaborationState("s1"); + state.agentRuns = { + market: agentRun({ + agent_run_id: "a_market", + display_name: "市场分析师", + node_run_id: "n-market-attempt1", + }), + brand: agentRun({ + agent_run_id: "a_brand", + display_name: "品牌竞争分析师", + node_run_id: "n-brand-attempt1", + }), + }; + state.nodes = { + "n-market": { + node_id: "n-market", + label: "市场", + status: "completed", + attempt: 1, + attempts: [], + }, + "n-brand": { + node_id: "n-brand", + label: "品牌", + status: "completed", + attempt: 1, + attempts: [], + }, + }; + assert.deepEqual(selectAgentTabs(state), []); +}); + +test("并行结束后各成员消息收成连续聊天记录,不交叉穿插", () => { + const state = createInitialCollaborationState("s1"); + state.agentRuns = { + market: agentRun({ agent_run_id: "a_market", display_name: "市场分析师", started_at: "t1", status: "completed" }), + brand: agentRun({ agent_run_id: "a_brand", display_name: "品牌竞争分析师", started_at: "t2", status: "completed" }), + }; + const messages = [ + human("h1", "开始"), + ai("c1", null, "我先分派任务"), + ai("m1", "a_market", "市场检索中"), + ai("b1", "a_brand", "品牌检索中"), + ai("m2", "a_market", "市场规模结论"), + ai("b2", "a_brand", "品牌竞争结论"), + ai("c2", null, "开始核验"), + ]; + assert.deepEqual( + stitchParallelAgentBlocks(messages, state).map((message) => message.message_id), + ["h1", "c1", "m1", "m2", "b1", "b2", "c2"], + ); + state.messageOrder = messages.map((message) => message.message_id); + state.messagesById = Object.fromEntries(messages.map((message) => [message.message_id, message])); + assert.deepEqual( + selectConversationMessages(state, null).map((message) => message.message_id), + ["h1", "c1", "m1", "m2", "b1", "b2", "c2"], + ); +}); + test("业务卡虚拟消息经 groupMessages 识别为 assistant:business-card(集中式扩展)", () => { const card = buildCommandConfirmCard({ command_id: "cmd1", diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index 030fd623..680a5db8 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -1175,7 +1175,8 @@ export default function BusinessChainEditorPage() { {/* 右侧操作 */}
- {/* 发布:未发布 → 弹窗选「确认审核 / 直接发布」;审核中 → 撤回;已发布 → 收回。 */} + {/* 发布:未发布 → 弹窗(管理员可选「直接发布 / 确认审核」,普通用户提交审核); + 审核中 → 撤回;已发布 → 收回。 */} {currentChainId && (currentChainMine || isAdmin) && ( - + {isAdmin && ( + + )} diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainListPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainListPage.tsx index 9c92baea..45eff2d2 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainListPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainListPage.tsx @@ -65,7 +65,14 @@ import { useRouteNavigate } from "@/core/route-base"; import { cn } from "@/lib/utils"; import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree"; -import { approveChainReview, deleteChain, listChains, setChainPublic, type ChainScope } from "../api/chains"; +import { + approveChainReview, + deleteChain, + listChains, + setChainPublic, + submitChainForReview, + type ChainScope, +} from "../api/chains"; import { chainTagClasses, type BusinessChain } from "../lib/business-chain"; import { getAvatarTypeFor } from "../lib/roundtable-constants"; @@ -163,26 +170,70 @@ export default function BusinessChainListPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // ── 操作:发布 / 取消发布 ──────────────────────────────────────────────── - // 「待审核」筛选里 = 管理员审核发布(is_public=true 并清除待审核标记); - // 其它筛选里 = 常规发布 / 取消发布。 + // ── 操作:发布 / 取消发布 / 提交审核 / 审核发布 ────────────────────────── + // 发布为公共(is_public=true)仅管理员可执行: + // - 「待审核」筛选里 = 管理员审核发布(is_public=true 并清除待审核标记); + // - 管理员在其它筛选里点待审核链条的发布 = 同样走审核发布; + // - 管理员对普通链条 = 常规发布 / 取消发布。 + // 普通用户:未发布链条的发布入口 = 提交审核(等待管理员发布,不能自行发布); + // 已发布链条可取消发布(收回为私有)。 const handleTogglePublic = useCallback( async (chain: BusinessChain) => { + // 审核发布:待审核筛选(仅管理员可达)或管理员点待审核链条。 + if (scope === "pending" || (isAdmin && chain.pendingReview)) { + setPublishingId(chain.id); + try { + const updated = await approveChainReview(chain.id); + if (updated) { + await refresh(scope); + await loadStats(); + toast.success(`「${chain.title || "未命名链条"}」审核通过并已发布到公共选用库`); + } else { + toast.error("链条已不存在,可能被删除"); + await refresh(scope); + } + } catch (err) { + toast.error(`操作失败:${err instanceof Error ? err.message : String(err)}`); + } finally { + setPublishingId(null); + } + return; + } + // 普通用户的未发布链条:提交审核,不能直接发布。 + if (!isAdmin && !chain.isPublic) { + if (chain.pendingReview) { + toast.info("该链条审核中,请等待管理员发布"); + return; + } + setPublishingId(chain.id); + try { + const updated = await submitChainForReview(chain.id); + if (updated) { + await refresh(scope); + await loadStats(); + toast.success(`「${chain.title || "未命名链条"}」已提交审核,等待管理员发布`); + } else { + toast.error("链条已不存在,可能被删除"); + await refresh(scope); + } + } catch (err) { + toast.error(`提交审核失败:${err instanceof Error ? err.message : String(err)}`); + } finally { + setPublishingId(null); + } + return; + } + // 其余(管理员发布/取消发布,普通用户取消发布)走常规开关。 setPublishingId(chain.id); try { - const updated = - scope === "pending" - ? await approveChainReview(chain.id) - : await setChainPublic(chain.id, !chain.isPublic); + const updated = await setChainPublic(chain.id, !chain.isPublic); if (updated) { await refresh(scope); await loadStats(); toast.success( - scope === "pending" - ? `「${chain.title || "未命名链条"}」审核通过并已发布到公共选用库` - : chain.isPublic - ? `已取消发布「${chain.title || "未命名链条"}」` - : `「${chain.title || "未命名链条"}」已发布到公共选用库`, + chain.isPublic + ? `已取消发布「${chain.title || "未命名链条"}」` + : `「${chain.title || "未命名链条"}」已发布到公共选用库`, ); } else { toast.error("链条已不存在,可能被删除"); @@ -194,7 +245,7 @@ export default function BusinessChainListPage() { setPublishingId(null); } }, - [refresh, scope, loadStats], + [refresh, scope, loadStats, isAdmin], ); // ── 操作:删除(确认弹窗)───────────────────────────────────────────────── @@ -463,6 +514,7 @@ export default function BusinessChainListPage() { key={chain.id} chain={chain} canManage={isAdmin || chain.isMine !== false} + isAdmin={isAdmin} publishing={publishingId === chain.id} onSettings={() => goConfigure(chain.id)} onTogglePublic={() => handleTogglePublic(chain)} @@ -652,6 +704,7 @@ function EmptyState({ function ChainCard({ chain, canManage, + isAdmin, publishing, onSettings, onTogglePublic, @@ -660,6 +713,8 @@ function ChainCard({ chain: BusinessChain; /** 能否编辑 / 发布 / 删除(本人链条 或 管理员)。 */ canManage: boolean; + /** 当前登录用户是否管理员:发布语义不同(管理员=直接发布/审核发布,普通=提交审核)。 */ + isAdmin: boolean; publishing: boolean; onSettings: () => void; onTogglePublic: () => void; @@ -722,14 +777,26 @@ function ChainCard({
- {/* 操作按钮:本人链条或管理员可发布 / 设置 / 删除(普通用户看别人的链条只读浏览)。 */} + {/* 操作按钮:本人链条或管理员可发布 / 设置 / 删除(普通用户看别人的链条只读浏览)。 + 发布图标语义:管理员=发布/审核发布/取消发布;普通用户=提交审核(未发布)/ + 取消发布(已发布)/ 审核中(点击仅提示)。 */} {canManage && (