diff --git a/.gitignore b/.gitignore index d204664e..b28712b3 100644 --- a/.gitignore +++ b/.gitignore @@ -125,25 +125,28 @@ offline-backend-20260512/backend/tests/* # Independent enterprise-research persistence, HTTP and race regressions. !offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py # Position-roundtable per-turn write/present cap (weak-model duplicate artifacts). -!offline-backend-20260512/backend/tests/test_position_artifact_cap.py -# Roundtable coordinator/seat capability isolation regressions. -!offline-backend-20260512/backend/tests/test_roundtable_role_boundary.py -# Workflow Studio: schemas / definitions / engine / run-lifecycle regressions +!offline-backend-20260512/backend/tests/test_position_artifact_cap.py +# Roundtable coordinator/seat capability isolation regressions. +!offline-backend-20260512/backend/tests/test_roundtable_role_boundary.py +# Workflow Studio: schemas / definitions / engine / run-lifecycle regressions # plus the concurrency remediation suite (atomic resume, draft CAS, publish # counter, event seq) — required in CI per docs/WORKFLOW_STUDIO_BACKEND_REMEDIATION_ZH.md P1-06. !offline-backend-20260512/backend/tests/test_workflow_schemas.py !offline-backend-20260512/backend/tests/test_workflow_definitions.py -!offline-backend-20260512/backend/tests/test_workflow_engine.py -!offline-backend-20260512/backend/tests/test_workflow_runs.py -!offline-backend-20260512/backend/tests/test_workflow_concurrency.py -!offline-backend-20260512/backend/tests/test_workflow_schema_rebuild.py -# Writing-mode first-report pipeline enforcement regressions. +!offline-backend-20260512/backend/tests/test_workflow_engine.py +!offline-backend-20260512/backend/tests/test_workflow_runs.py +!offline-backend-20260512/backend/tests/test_workflow_concurrency.py +!offline-backend-20260512/backend/tests/test_workflow_schema_rebuild.py +# Writing-mode first-report pipeline enforcement regressions. !offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py !offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py # LLM call timeout retry classification regression coverage. !offline-backend-20260512/backend/tests/test_llm_error_handling_middleware.py # Writing-mode pipeline conversation-material regressions. !offline-backend-20260512/backend/tests/test_deep_research_report_tool.py +# Report collaboration (RC-BE-000–018) regression coverage. +!offline-backend-20260512/backend/tests/test_report_collaboration_*.py +!offline-backend-20260512/backend/tests/test_ensure_orm_columns_sync.py deploy .history diff --git a/AGENTS.md b/AGENTS.md index bdab561a..64d55405 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,13 +96,13 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide..finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:` 窗口事件强制重放(忽略已看过记忆)。 - **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. - **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 -- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. +- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id, or action id when `idKind:"action"` — same selector as url buttons, xdfx only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url **or host** button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. - **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` - **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - **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-build`(group `all`)技能函数转换成 14 类 `task-reports.json`(模型禁止手写该 JSON;分页技能 `task-report-{enemy,our,env,judge}` 仅用于事后补跑单页;五者都是共享引擎 `task-report-lib/convert_lib.py` 的瘦 CLI,`resolve_hit_files` 对虚拟绝对路径读空的情况自动回退到相对写法并在 `warnings` 里列出尝试过的路径,免得智能体自己写脚本排查)→ `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/browser-extension/deer-web-clipper/background.js b/browser-extension/deer-web-clipper/background.js index 104380f7..686b9660 100644 --- a/browser-extension/deer-web-clipper/background.js +++ b/browser-extension/deer-web-clipper/background.js @@ -5,6 +5,9 @@ const LEGACY_LOCAL_API_BASE_URL = "http://localhost:8001"; const LEGACY_LOCAL_WEB_BASE_URL = "http://localhost:5174"; let pollTimer = null; let isPolling = false; +let startupPromise = null; + +const POLLING_SCHEDULE_SETTING_KEYS = new Set(["taskPollingEnabled", "taskPollIntervalSeconds"]); function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -12,6 +15,14 @@ function delay(ms) { async function getSettings() { const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS); + const discovered = await chrome.storage.local.get({ + discoveredApiBaseUrl: "", + discoveredWebBaseUrl: "", + }); + if (settings.autoDiscoverServiceUrl) { + settings.apiBaseUrl = discovered.discoveredApiBaseUrl || settings.apiBaseUrl; + settings.webBaseUrl = discovered.discoveredWebBaseUrl || settings.webBaseUrl; + } const updates = {}; if (!settings.apiBaseUrl || settings.apiBaseUrl === LEGACY_LOCAL_API_BASE_URL) { updates.apiBaseUrl = DEER_CLIPPER_DEFAULTS.apiBaseUrl; @@ -69,10 +80,7 @@ function apiCandidatesForWebBase(webBase, currentApiBase) { } }; - add(currentApiBase); add(webBase); - add(DEER_CLIPPER_DEFAULTS.apiBaseUrl); - add(LEGACY_LOCAL_API_BASE_URL); try { const url = new URL(normalizeServiceBase(webBase)); @@ -85,6 +93,10 @@ function apiCandidatesForWebBase(webBase, currentApiBase) { // Ignore invalid candidates. } + add(currentApiBase); + add(DEER_CLIPPER_DEFAULTS.apiBaseUrl); + add(LEGACY_LOCAL_API_BASE_URL); + return candidates; } @@ -93,6 +105,12 @@ async function browserContextStatus(apiBaseUrl) { const response = await fetch(`${apiBaseUrl}/api/browser-context/status`, { credentials: "include", }); + // The gateway may enforce authentication globally before this otherwise + // public discovery endpoint is reached. A 401/403 still proves that this + // candidate is the zncm API paired with the open frontend tab. + if (response.status === 401 || response.status === 403) { + return { enabled: true, authenticationRequired: true }; + } if (!response.ok) { return null; } @@ -201,9 +219,12 @@ async function discoverServiceBases(settings, force = false) { settings.webBaseUrl !== next.webBaseUrl || settings.lastServiceSyncSource !== next.lastServiceSyncSource ) { - await chrome.storage.sync.set({ - apiBaseUrl: next.apiBaseUrl, - webBaseUrl: next.webBaseUrl, + // Discovered endpoints are runtime state, not user-authored preferences. + // Keep them in storage.local so Chrome sync write quotas cannot break task + // polling. The options page may still persist explicit user choices. + await chrome.storage.local.set({ + discoveredApiBaseUrl: next.apiBaseUrl, + discoveredWebBaseUrl: next.webBaseUrl, lastServiceSyncAt: next.lastServiceSyncAt, lastServiceSyncSource: next.lastServiceSyncSource, }); @@ -826,19 +847,50 @@ async function pollOnce() { } } -async function startup() { +async function initializePolling() { let settings = await getSettings(); - settings = await discoverServiceBases(settings); - settings = await syncAuthToken(settings); + try { + settings = await discoverServiceBases(settings); + } catch (error) { + // Service discovery persists diagnostic metadata in storage.sync. Chrome can + // temporarily reject those writes after its quota is reached; polling must + // still start with the last known settings. + console.warn("zncm service discovery failed during startup", error); + } + try { + settings = await syncAuthToken(settings); + } catch (error) { + // A failed status/token write must not prevent alarms from being installed. + // pollOnce() will retry token synchronization before claiming each task. + console.warn("zncm token sync failed during startup", error); + } await refreshAlarm(settings); await scheduleNextPoll(1000); } -chrome.runtime.onInstalled.addListener(() => void startup()); -chrome.runtime.onStartup.addListener(() => void startup()); +function startup() { + if (!startupPromise) { + startupPromise = initializePolling().finally(() => { + startupPromise = null; + }); + } + return startupPromise; +} + +async function restartPollingSchedule() { + const settings = await getSettings(); + await refreshAlarm(settings); + await scheduleNextPoll(0); +} + +chrome.runtime.onInstalled.addListener(() => void startup().catch((error) => console.warn("zncm startup failed", error))); +chrome.runtime.onStartup.addListener(() => void startup().catch((error) => console.warn("zncm startup failed", error))); chrome.storage.onChanged.addListener((changes, areaName) => { - if (areaName === "sync") { - void startup(); + if ( + areaName === "sync" && + Object.keys(changes || {}).some((key) => POLLING_SCHEDULE_SETTING_KEYS.has(key)) + ) { + void restartPollingSchedule().catch((error) => console.warn("zncm polling reschedule failed", error)); } }); chrome.alarms.onAlarm.addListener((alarm) => { @@ -902,4 +954,4 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; }); -void startup(); +void startup().catch((error) => console.warn("zncm startup failed", error)); diff --git a/browser-extension/deer-web-clipper/background.test.cjs b/browser-extension/deer-web-clipper/background.test.cjs new file mode 100644 index 00000000..e1462ff8 --- /dev/null +++ b/browser-extension/deer-web-clipper/background.test.cjs @@ -0,0 +1,130 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +const BACKGROUND_PATH = path.join(__dirname, "background.js"); + +function createHarness() { + let storageChangedListener; + let alarmCreateCount = 0; + const settings = { + apiBaseUrl: "http://localhost:8001", + webBaseUrl: "http://localhost:5174", + autoDiscoverServiceUrl: false, + accessToken: "", + tokenSyncEnabled: false, + taskPollingEnabled: true, + workerId: "test-worker", + taskPollIntervalSeconds: 5, + }; + + const chrome = { + alarms: { + clear: async () => {}, + create: async () => { + alarmCreateCount += 1; + }, + onAlarm: { addListener: () => {} }, + }, + cookies: { get: async () => null }, + runtime: { + onInstalled: { addListener: () => {} }, + onMessage: { addListener: () => {} }, + onStartup: { addListener: () => {} }, + }, + storage: { + local: { + get: async (defaults) => ({ ...defaults }), + set: async () => {}, + }, + session: { get: async () => ({ actSessions: {} }), set: async () => {} }, + sync: { + get: async () => ({ ...settings }), + set: async (updates) => Object.assign(settings, updates), + }, + onChanged: { + addListener: (listener) => { + storageChangedListener = listener; + }, + }, + }, + tabs: { query: async () => [] }, + }; + + const context = { + chrome, + console, + fetch: async () => { + throw new Error("offline test"); + }, + importScripts: () => {}, + setTimeout: () => 1, + clearTimeout: () => {}, + URL, + DEER_CLIPPER_DEFAULTS: settings, + deerNormalizeBaseUrl: (value) => String(value || "").replace(/\/+$/, ""), + deerMakeWorkerId: () => "test-worker", + deerAuthHeaders: () => ({}), + deerExtractAuthTokenFromPage: () => ({ token: "" }), + }; + context.globalThis = context; + vm.runInNewContext(fs.readFileSync(BACKGROUND_PATH, "utf8"), context, { + filename: BACKGROUND_PATH, + }); + + return { + emitStorageChange(changes) { + storageChangedListener(changes, "sync"); + }, + getAlarmCreateCount: () => alarmCreateCount, + resetAlarmCreateCount: () => { + alarmCreateCount = 0; + }, + getApiCandidates: (webBase, currentApiBase) => + vm.runInContext(`apiCandidatesForWebBase(${JSON.stringify(webBase)}, ${JSON.stringify(currentApiBase)})`, context), + }; +} + +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +test("self-written sync metadata does not restart browser task polling", async () => { + const harness = createHarness(); + await settle(); + harness.resetAlarmCreateCount(); + + harness.emitStorageChange({ + lastTokenSyncAt: { oldValue: "before", newValue: "after" }, + }); + await settle(); + + assert.equal(harness.getAlarmCreateCount(), 0); +}); + +test("polling configuration changes restart browser task polling", async () => { + const harness = createHarness(); + await settle(); + harness.resetAlarmCreateCount(); + + harness.emitStorageChange({ + taskPollingEnabled: { oldValue: false, newValue: true }, + }); + await settle(); + + assert.equal(harness.getAlarmCreateCount(), 1); +}); + +test("service discovery prefers the API paired with the open zncm tab", async () => { + const harness = createHarness(); + + assert.deepEqual( + Array.from(harness.getApiCandidates("http://localhost:5174", "http://47.94.209.59:2026")), + [ + "http://localhost:5174", + "http://localhost:8001", + "http://47.94.209.59:2026", + "http://localhost:8001", + ].filter((value, index, values) => values.indexOf(value) === index), + ); +}); diff --git a/browser-extension/deer-web-clipper/content.js b/browser-extension/deer-web-clipper/content.js index 1c387461..abb388bc 100644 --- a/browser-extension/deer-web-clipper/content.js +++ b/browser-extension/deer-web-clipper/content.js @@ -7,6 +7,8 @@ const HOST_ID = "zncm-page-assistant-host"; const state = { enabled: true, + taskPollingEnabled: true, + webBaseUrl: "", open: false, busy: false, threadId: "", @@ -709,20 +711,58 @@ async function loadSettings() { const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS); state.enabled = Boolean(settings.pageAssistantEnabled); + state.taskPollingEnabled = Boolean(settings.taskPollingEnabled); + state.webBaseUrl = deerNormalizeBaseUrl(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl); ensureWidget(); } chrome.storage.onChanged.addListener((changes, areaName) => { - if (areaName !== "sync" || !changes.pageAssistantEnabled) { + if (areaName !== "sync") { return; } - state.enabled = Boolean(changes.pageAssistantEnabled.newValue); - ensureWidget(); - if (state.enabled) { - setStatus("页面问答框已开启。"); + if (changes.pageAssistantEnabled) { + state.enabled = Boolean(changes.pageAssistantEnabled.newValue); + ensureWidget(); + if (state.enabled) { + setStatus("页面问答框已开启。"); + } + } + if (changes.taskPollingEnabled) { + state.taskPollingEnabled = Boolean(changes.taskPollingEnabled.newValue); + } + if (changes.webBaseUrl) { + state.webBaseUrl = deerNormalizeBaseUrl(changes.webBaseUrl.newValue || DEER_CLIPPER_DEFAULTS.webBaseUrl); } }); + function isZncmPage() { + const appPath = `${location.pathname}${location.hash}`.toLowerCase(); + if ( + appPath.includes("/page/workspace") || + appPath.includes("/chats/") || + appPath.includes("#/login") + ) { + return true; + } + try { + const configured = new URL(state.webBaseUrl); + return configured.origin === location.origin; + } catch { + return false; + } + } + + // MV3 service workers may be suspended between their short polling timers, + // while chrome.alarms cannot reliably run more often than every 30 seconds. + // A visible zncm tab provides a cheap wake-up signal so browser_act requests + // are normally claimed within the backend/model timeout window. + setInterval(() => { + if (!state.taskPollingEnabled || !isZncmPage()) { + return; + } + chrome.runtime.sendMessage({ type: "zncm-poll-now" }).catch(() => {}); + }, 3000); + setInterval(() => { if (!state.enabled || !ui.pageTitle) { return; diff --git a/docs/WIKI_ROUNDTRIP_ACCEPTANCE_20260903.md b/docs/WIKI_ROUNDTRIP_ACCEPTANCE_20260903.md index 69d8a887..21d1cde2 100644 --- a/docs/WIKI_ROUNDTRIP_ACCEPTANCE_20260903.md +++ b/docs/WIKI_ROUNDTRIP_ACCEPTANCE_20260903.md @@ -14,7 +14,7 @@ WeKnora;没有把业务代码发布到服务器,没有提交 Git,也没有 - 实现:FastEmbed / ONNX Runtime CPU,官方 `BAAI/bge-m3` 权重。 - 配置名:`bge-embedding-m3`,1024 维,每批 2 条,2 个 CPU 推理线程。 -- 运行依赖:后端 `local-embedding` extra;当前本地虚拟环境已安装。 +- 运行依赖:FastEmbed 随标准后端依赖安装;模型必须提前放在离线目录中,仍按需加载,不影响主服务启动。 - 权重:约 2.3 GB,持久缓存 `C:\Users\ji\.cache\deerflow\embeddings`,不进入业务仓库。 - 配置:`llmwiki.local_wiki_index.embedding.provider: local`;可用 `local_model_path` 指定离线权重目录,详见后端 README。 @@ -101,15 +101,15 @@ WeKnora;没有把业务代码发布到服务器,没有提交 Git,也没有 多来源事实冲突合并验收,不能视为这一历史需求已全面完成。 4. 重复上传仍会保留独立来源记录。向量导出已消除旧版本重复,但上传身份幂等、部分远端 写入失败后的无损重试、跨实例图片资源打包仍需独立验证。 -5. 自动沉淀挂在本系统成功向量化完成后;未新增文件监听或定时远程写入。直接在外部 - WeKnora 修改的内容仍需通过本系统同步/向量化入口接入。 +5. 当前版本已补充自动增量链路:服务启动立即补扫,之后默认每 30 秒扫描全部普通 + 知识库;Wiki 抽取完成后无需手工点击向量化,并自动以来源快照覆盖同步到知识梳理总库。 6. 回流当前只允许新建空 Wiki 普通库,避免覆盖已有页面。普通库现有页面列表的超大规模 分页与助手问答的最终生成回答不属于这轮通过结论。 ## 自动化检查 -- 后端相关测试:46 passed(assistant repository、Wiki sync、package roundtrip、技能扫描、 - 技能关系仓储、知识库管理)。 +- 后端相关测试:118 passed(assistant repository、Wiki sync、package roundtrip、 + WeKnora 接口、向量检索与知识库管理)。 - 修改的 Python 功能文件 Ruff 检查通过;app.py 既有启动顺序导致的 E402 不在此次改动中处理。 - `git diff --check` 通过(只有仓库换行提示)。 - 前端完整 `pnpm typecheck` 未通过:57 个既有/本轮范围外错误,涉及 nextra 缺失、Markdown diff --git a/docs/agentscope-多智能体报告协作工作台-前端实施计划.md b/docs/agentscope-多智能体报告协作工作台-前端实施计划.md index 757c2dc6..55f3543e 100644 --- a/docs/agentscope-多智能体报告协作工作台-前端实施计划.md +++ b/docs/agentscope-多智能体报告协作工作台-前端实施计划.md @@ -1,13 +1,25 @@ # AgentScope 2.0 多智能体报告协作工作台——前端实施计划 -> 文档状态:待评审、尚未开始编码 -> 编写日期:2026-09-02 +> 文档状态:前端代码已落地、契约冻结;联调等待后端运行中干预与报告改写 +> 编写日期:2026-09-02;进度更新日期:2026-09-06 > 目标项目:`F:\react01\deeflow-code\deerflow-server\frontend-web` > 视觉与交互参考:`F:\react01\coze-frontend\frontend\apps\coze-studio` > 参考页面:`http://localhost:3000/work_flow?space_id=1&workflow_id=wf_88cb9f4c6642` > 配套后端计划:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md` > 本文只规划新增页面,不修改现有多智能体会商页面,不复用旧 Workflow Studio 执行状态。 +## 0. 当前进度 + +`frontend-web/src/report-collaboration/` 已实现两栏工作台、会话/方案/run API 客户端、mock driver、SSE reducer、`MessageList` 适配、方案卡、画布、节点抽屉、Composer、报告预览与历史。`RC-FE-001`~`013` 的页面骨架与契约层视为完成。 + +后端已通规划前路径(澄清 → 出方案 → 选定 → `queued` run),见后端计划 §0。前端真实执行体验仍受阻: + +- `GET /runs/{id}/stream` 已挂载(`RC-BE-011`);dispatcher/租约已落地(`RC-BE-012`),默认 `worker_enabled=false` 仍不自动领取 queued run。 +- 报告改写 / 恢复接口已由后端 `RC-BE-015` 挂载。 +- 质量角色已由 `QualityRoleKernel` 跑通(`RC-BE-013`);真实 AgentScope 模型/`web_search` 仍待后续票。 + +联调顺序:先打开 `report_collaboration.enabled` 验证规划前 REST 与 `GET /runs/{id}/stream` 重放;画布自动执行需同时打开 `worker_enabled`(默认关)。 + ## 1. 前端目标 新增独立的「报告协作」工作台。用户可以通过对话提出任意报告需求,查看系统生成的多个协作方案,点击方案卡片在流程图中预览协作结构,确认后观察多智能体执行,并在全过程通过统一对话框提出修改意见。 @@ -662,6 +674,8 @@ POST /api/report-collaboration/sessions/{session_id}/report-rewrites/{id}/appl ## 9. 前端开发顺序 +页面与契约层已完成(代码在 `frontend-web/src/report-collaboration/`): + 1. `RC-FE-001`:视觉截图与行为基线。 2. `RC-FE-002`:路由、菜单和左对话/右画布两栏空壳。 3. `RC-FE-003`:页面 token、弹窗、抽屉和响应式。 @@ -678,15 +692,17 @@ POST /api/report-collaboration/sessions/{session_id}/report-rewrites/{id}/appl 依赖规则: -- `RC-FE-004` 必须等待前后端契约冻结。 -- 页面骨架和视觉可与后端 BE-0~BE-3 并行。 -- 真实 SSE 联调必须等待后端 durable event store。 -- 报告版本操作必须等待后端候选应用语义完成。 +- 契约已冻结为 `src/report-collaboration/api/types.ts`;后端 `RC-BE-001` 已对齐。 +- 真实 SSE 联调:后端 `RC-BE-011` 已挂载 `GET /runs/{id}/stream`;默认无后台循环时流只会重放已入库事件。 +- 报告版本操作已接后端 `RC-BE-015`(改写候选 / apply / restore)。 +- 执行画布实时状态依赖打开 `worker_enabled`;质量角色内核已在 `RC-BE-013` 落地,真实成员见 `RC-BE-017`。 --- ## 10. 前端完成定义 +页面与 mock 联调路径已落地。真实后端可联调 `GET /runs/{id}/stream`、领取/恢复(`RC-BE-012`,默认不挂 worker)、质量角色(`RC-BE-013`)以及报告改写/恢复(`RC-BE-015`)。此前以 mock driver 覆盖交互,不以「前端未编码」计未完成。 + 前端只有同时满足以下条件才算完成: 1. 页面结构与 Coze 参考页面逐状态验收通过。 diff --git a/docs/agentscope-多智能体报告协作工作台-前端对接指南.md b/docs/agentscope-多智能体报告协作工作台-前端对接指南.md new file mode 100644 index 00000000..efef7c5f --- /dev/null +++ b/docs/agentscope-多智能体报告协作工作台-前端对接指南.md @@ -0,0 +1,481 @@ +# 报告协作工作台 — 前端对接指南 + +> **适用版本:** RC-BE-000~018 已落地(2026-09-06) +> **配套文档:** +> - [前端实施计划](./agentscope-多智能体报告协作工作台-前端实施计划.md) +> - [后端实施计划](./agentscope-多智能体报告协作工作台-后端实施计划.md) +> - 模块 README:`frontend-web/src/report-collaboration/README.md` + +本文说明**前端如何对接真实后端**:环境配置、API 契约、用户流程、状态机、错误处理与联调验收。前端 API 客户端与页面已实现,通常**无需再写一套 HTTP 层**。 + +--- + +## 1. 结论速览 + +| 问题 | 答案 | +|------|------| +| 前端还要写接口吗? | **不用**。`src/report-collaboration/api/` 已封装 REST + SSE,页面通过 `getReportCollaborationDriver()` 调用。 | +| 为什么调不通? | 最常见:`config.yaml` 里 `report_collaboration.enabled: false` → 全部 **503**;或 `worker_enabled: false` → run 不入队执行。 | +| Mock 和真后端怎么切? | **Mock 已移除**。前端永远请求真实后端(`deerflow.rc-mock` / `VITE_RC_MOCK` 开关已删除);联调需 `enabled: true`。 | +| 字段会和 MySQL 对不上吗? | 前端只认 JSON 契约(`api/types.ts`),不直连表。缺列表现为后端 500/SQL 错误,部署后需**重启 Gateway** 跑 `create_all` + 列同步。 | +| 从哪进页面? | `/page/workspace/report-collaboration`(新会话)或 `/page/workspace/report-collaboration/:sessionId`。侧边栏入口默认注释,可直接 URL 访问。 | + +--- + +## 2. 架构:前端已有什么 + +``` +ReportCollaborationPage + └─ useReportCollaborationSession(唯一数据入口) + ├─ driver.getSnapshot() ← REST 冷启动 + ├─ driver.openRunStream() ← SSE 实时事件 + ├─ driver.sendMessage / createCommand / startRun … + └─ event-reducer ← 事件 → UI 状态 + +ReportConversationPanel + └─ MessageList(message-adapter 映射 CollaborationMessage → LangGraph Message) +``` + +### 2.1 目录结构 + +```text +frontend-web/src/report-collaboration/ + api/ + types.ts # 冻结契约(snake_case,与后端 Pydantic 一致) + client.ts # rcFetch:鉴权、幂等键、乐观锁、错误映射 + sessions.ts # 会话 CRUD、消息 + plans.ts # 方案请求 / 列表 / 选定 + runs.ts # Run、SSE、命令、节点合同 + reports.ts # 报告版本、改写、恢复 + driver.ts # 真实后端统一门面(Mock 驱动已移除) + state/ + event-normalizer.ts + event-reducer.ts + selectors.ts + message-adapter.ts + hooks/ + useReportCollaborationSession.ts # 快照 → SSE → reducer → 动作 + useReportCollaborationStream.ts # 断线重连、after_seq 续接 + useReportCollaborationMessages.ts + components/ … + pages/ReportCollaborationPage.tsx +``` + +### 2.2 事实源原则 + +- **冷启动:** `GET /sessions/{id}` 返回 `SessionSnapshot`(session、messages、plans、run、nodes、agent_runs、commands、report_versions、last_seq)。 +- **运行中:** `GET /runs/{id}/stream?after_seq=N` 订阅 durable SSE。 +- **断线 / 序号缺口:** `GET /runs/{id}/events?after_seq=N` REST 补拉。 +- **澄清 / 规划阶段(尚无 run):** 每 2.5s 轮询快照(后端事件主要在 run 流上)。 + +纯展示状态(预览方案、聚焦成员、面板宽度)**不**写入服务器,仅存前端。 + +--- + +## 3. 后端启用与部署 + +### 3.1 必改配置 + +文件:`offline-backend-20260512/backend/config.yaml` + +```yaml +report_collaboration: + enabled: false # 联调必须改为 true + worker_enabled: false # 要看成员执行必须 true + planner_model: null # 建议填可用模型名 + coordinator_model: null + research_model: null + writer_model: null + reviewer_model: null + # … token_budget、max_concurrent_runs_per_user 等见同文件 +``` + +| 配置 | 现象 | +|------|------| +| `enabled: false` | 所有路由 **503**,`code: REPORT_COLLABORATION_DISABLED` | +| `enabled: true`,`worker_enabled: false` | REST/SSE 可用,run 入队但不自动执行(测 API 可用,无成员流式输出) | +| 两者 `true` + 模型配置完整 | 完整联调 | + +### 3.2 MySQL / 表结构 + +生产环境 Alembic 自动升级默认关闭,启动时走 `create_all` + `_ensure_orm_columns_sync`。 + +- 迁移脚本(可选手动跑):`20260905_01`、`20260906_01`、`20260907_01` +- ORM:`packages/harness/deerflow/persistence/report_collaboration/model.py`(13 张表) +- **部署后务必重启 Gateway**,观察日志是否有 `Failed to auto-add column report_collaboration_*` + +已有 RC-003 时代旧表会通过列同步补上 `template_snapshot_json`、`budget_snapshot_json`、`usage_json`、`budget_exhausted_reason` 等可空列,并创建 `rewrite_proposals`、`audits`。 + +### 3.3 前端环境变量 + +| 变量 | 说明 | +|------|------| +| `VITE_BACKEND_BASE_URL` | Gateway 地址,默认 `http://127.0.0.1:8001` | + +请求经 `apiFetch`,自动携带登录 JWT(`credentials: "include"`)。**必须先登录 DeerFlow**。 + +--- + +## 4. API 基址与请求约定 + +### 4.1 Base URL + +```text +{VITE_BACKEND_BASE_URL}/api/report-collaboration +``` + +代码常量:`REPORT_COLLABORATION_BASE()`(`api/client.ts`)。 + +### 4.2 请求头 + +| Header | 何时携带 | 说明 | +|--------|----------|------| +| `Authorization` | 全部 | 由 `apiFetch` 注入 | +| `X-Idempotency-Key` | 写操作 | 格式 `rc-{uuid}`;重复提交返回首次结果 | +| `X-Expected-Revision` | 选方案、开 run、改会话等 | 会话 `requirement_revision` 或方案 `revision` | +| `Accept: text/event-stream` | SSE | `openRunStream` | + +### 4.3 错误响应格式 + +```json +{ + "code": "REPORT_COLLABORATION_DISABLED", + "message": "报告协作工作台未启用", + "detail": { } +} +``` + +前端映射(`api/client.ts`): + +| HTTP | 异常类 | 典型场景 | +|------|--------|----------| +| 409 | `ConflictError` | revision 过期、active run 冲突 | +| 422 | `ValidationError` | 参数/语义错误、缺幂等键 | +| 429 | `RateLimitError` | 预算耗尽、每用户并发 run 上限 | +| 5xx | `ServerError` | 可带同一幂等键重试 | +| 0 | `NetworkError` | fetch 失败 | + +### 4.4 完整路由清单(与前端 client 对齐) + +| 方法 | 路径 | 前端模块 | +|------|------|----------| +| POST | `/sessions` | `sessions.createSession` | +| GET | `/sessions` | `sessions.listSessions` | +| GET | `/sessions/{id}` | `sessions.getSessionSnapshot` | +| PATCH | `/sessions/{id}` | `sessions.updateSession` | +| DELETE | `/sessions/{id}` | `sessions.deleteSession` | +| GET | `/sessions/{id}/messages` | `sessions.listMessages` | +| POST | `/sessions/{id}/messages` | `sessions.sendMessage` | +| POST | `/sessions/{id}/plan-requests` | `plans.requestPlans` | +| GET | `/sessions/{id}/plans` | `plans.listPlans` | +| POST | `/sessions/{id}/plans/{planId}/select` | `plans.selectPlan` | +| POST | `/sessions/{id}/runs` | `runs.startRun` | +| GET | `/sessions/{id}/reports` | `reports.listReportVersions` | +| POST | `/sessions/{id}/report-rewrites` | `reports.createRewrite` | +| POST | `/sessions/{id}/report-rewrites/{id}/apply` | `reports.applyRewrite` | +| POST | `/sessions/{id}/report-versions/{id}/restore` | `reports.restoreReportVersion` | +| GET | `/runs/{id}` | `runs.getRun` | +| GET | `/runs/{id}/events` | `runs.listRunEvents` | +| GET | `/runs/{id}/stream` | `runs.openRunStream` | +| POST | `/runs/{id}/cancel` | `runs.cancelRun` | +| POST | `/runs/{id}/commands` | `runs.createCommand` | +| POST | `/runs/{id}/commands/{id}/confirm` | `runs.confirmCommand` | +| POST | `/runs/{id}/commands/{id}/cancel` | `runs.cancelCommand` | +| GET | `/runs/{id}/nodes/{nodeId}/contract` | `runs.getNodeContract` | +| GET | `/runs/{id}/artifacts` | (后端已暴露,前端按需接) | +| GET | `/runs/{id}/sources` | (后端已暴露,前端按需接) | + +--- + +## 5. 用户流程与 API 时序 + +```mermaid +sequenceDiagram + participant U as 用户 + participant FE as 前端 RC + participant API as Gateway + participant W as Worker + + U->>FE: 输入需求 + FE->>API: POST /sessions + FE->>API: POST /sessions/{id}/messages + API-->>FE: 澄清(快照 messages) + U->>FE: 回答澄清 + FE->>API: POST /messages + FE->>API: POST /plan-requests + API-->>FE: 候选方案(快照 plans) + U->>FE: 采用方案 + FE->>API: POST /plans/{id}/select + U->>FE: 开始协作 + FE->>API: POST /runs + FE->>API: GET /runs/{id}/stream + W-->>API: 持久化事件 + API-->>FE: SSE + U->>FE: 运行中干预 + FE->>API: POST /commands → confirm +``` + +### 5.1 阶段 0:进入页面 + +| 路由 | 行为 | +|------|------| +| `/page/workspace/report-collaboration` | 空态;`POST /sessions` 后跳转带 `sessionId` | +| `/page/workspace/report-collaboration/:sessionId` | 加载快照;有 `location.state.rcInitialMessage` 时自动发首条消息 | + +路由注册:`frontend-web/src/pages/WorkspaceRoutes.tsx`。 +侧边栏入口已在 `workspace-nav-chat-list.tsx` 中放开(「报告协作」项),也可直接访问 URL。 + +### 5.2 阶段 1:需求澄清(无 run) + +```http +POST /api/report-collaboration/sessions +POST /api/report-collaboration/sessions/{sessionId}/messages +Content-Type: application/json + +{ "text": "请写一份…", "target_node_id": null, "agent_run_id": null } +``` + +- `useReportCollaborationSession.send()`:当 `session.status` 不是 `running` / `awaiting_input` / `reviewing` 时走 `sendMessage`。 +- 会话状态:`empty` → `clarifying` → `planning` → `proposal_ready`。 +- 澄清问题以 `CollaborationMessage` 进入时间线;业务卡通过 `MessageList` 的 `businessCardSlot` 渲染(`additional_kwargs.report_collaboration_card`)。 + +### 5.3 阶段 2:候选方案 + +```http +POST /api/report-collaboration/sessions/{sessionId}/plan-requests +POST /api/report-collaboration/sessions/{sessionId}/plans/{planId}/select +``` + +- **预览方案**(`previewPlanId`):纯前端,不调 API。 +- **采用方案**:`select` 写 `selected_plan_id`,**不**创建 run。 +- 409:方案 revision 过期 → 前端 `refreshSnapshot`,提示用户重选。 +- 右栏 `ReportFlowCanvas` 使用 `PlanNode` / `PlanEdge` 渲染 DAG。 + +### 5.4 阶段 3:开始执行与 SSE + +```http +POST /api/report-collaboration/sessions/{sessionId}/runs +{ "plan_id": "plan-xxx" } + +GET /api/report-collaboration/runs/{runId}/stream?after_seq=0 +Accept: text/event-stream +``` + +SSE 每条 `data:` 为 JSON: + +```typescript +interface CollaborationEvent { + eventId: string; + sessionId: string; + runId?: string | null; + seq: number; + type: CollaborationEventType; + timestamp: string; + data: unknown; +} +``` + +常用 `type`(完整列表见 `api/types.ts`): + +| 类型 | 含义 | +|------|------| +| `message.created` / `message.delta` / `message.completed` | 对话镜像流式 | +| `tool_call.*` / `tool_result.*` | 工具调用与返回 | +| `team_message.*` | 成员 / 协调者广播 | +| `plan.proposed` / `plan.selected` | 方案生命周期 | +| `run.status.changed` | Run 状态 | +| `node.status.changed` / `node.progress` | 节点执行 | +| `command.confirmation.required` | 需用户确认的干预 | +| `report.version.created` | 报告版本落盘 | +| `heartbeat` | 保活 | + +### 5.5 阶段 4:运行中干预 + +```http +POST /api/report-collaboration/runs/{runId}/commands +POST /api/report-collaboration/runs/{runId}/commands/{commandId}/confirm +POST /api/report-collaboration/runs/{runId}/commands/{commandId}/cancel +POST /api/report-collaboration/runs/{runId}/cancel +``` + +- Run 活跃时 `send()` 自动走 `createCommand`(而非 `sendMessage`)。 +- 可从节点抽屉 / `AgentStageSelector` 传入 `target_node_id`、`agent_run_id`。 +- `InterventionCard` 展示 `confirmation_required` 命令;确认后调 `confirmCommand`。 + +`CommandIntent` 枚举(后端分类,前端展示 reason/impact):`answer_question`、`update_requirements`、`research_again`、`rewrite_section`、`rerun_node`、`replan`、`cancel` 等。 + +### 5.6 阶段 5:报告版本与改写 + +```http +GET /api/report-collaboration/sessions/{sessionId}/reports +POST /api/report-collaboration/sessions/{sessionId}/report-rewrites +POST /api/report-collaboration/sessions/{sessionId}/report-rewrites/{rewriteId}/apply +POST /api/report-collaboration/sessions/{sessionId}/report-versions/{versionId}/restore +``` + +- `createRewrite` 只生成候选,不直接覆盖正式版本。 +- `applyRewrite` / `restoreReportVersion` 产生新的 head 版本,历史保留。 +- `ReportPreviewDialog` 展示 `ReportVersion.markdown`。 + +--- + +## 6. 前端状态机(接入方须知) + +**不要**在页面里用零散 REST 字段拼 UI 状态。统一路径: + +```text +SessionSnapshot → applySessionSnapshot() +CollaborationEvent[] → normalizeCollaborationEvent() → applyCollaborationEvents() +→ selectors(derivePagePhase、selectPlanForCanvas、…) +``` + +### 6.1 核心 Selector + +| Selector | 用途 | +|----------|------| +| `derivePagePhase` | Composer 占位、方案卡、空态 | +| `selectPlanForCanvas` | 画布 DAG(含预览方案) | +| `selectAgentTabs` | 并行成员 Tab | +| `selectNodeStatusMap` | 节点颜色 / 状态 | +| `selectPendingConfirmationCommands` | 干预确认卡 | +| `selectHeadReportVersion` | 报告预览入口 | +| `selectStreamingAgentRunIds` | 流式成员指示 | + +### 6.2 SSE 连接策略(`useReportCollaborationStream`) + +- Run 非终态时连接;`completed` / `failed` / `cancelled` 后关流并 REST 最终补拉。 +- 断线:指数退避重连(1s~30s),`after_seq` 取 reducer 内 `lastSeq`。 +- `hasSeqGap`:暂停 live 应用,先 `listRunEvents` 回填。 + +### 6.3 乐观消息 + +用户发送时插入 `metadata.optimistic: true` 的 pending 消息;服务端镜像带相同 `idempotency_key` 时 reducer 原地替换。 + +--- + +## 7. 消息与 MessageList 复用 + +`useReportCollaborationMessages` 将 `CollaborationMessage[]` 转为 `@langchain/langgraph-sdk` 的 `Message[]`,并构造合成 `BaseStream` 供 `MessageList` 消费。 + +| 来源 | 渲染 | +|------|------| +| `human` / `ai` 文本 | 普通气泡 | +| `tool_calls` + `tool` | 现有工具卡 | +| `team_message` 事件 | 带 `name` 的成员发言 | +| 业务卡 | `businessCardSlot`(方案组、命令确认、报告可用) | + +分组键:`core/messages/utils.ts` 中 `assistant:task-report-import` 同级扩展 `assistant:business-card`,判据 `additional_kwargs.report_collaboration_card`。 + +### 7.1 在其他页面嵌入 RC(最小集) + +1. `useReportCollaborationSession(sessionId)` +2. `ThreadContext.Provider` + `MessageList` + `businessCardSlot` +3. 引入 `report-collaboration/styles/report-collaboration.css` + +--- + +## 8. 单测 + +Mock 驱动(原 `api/mock.ts`)与 `deerflow.rc-mock` / `VITE_RC_MOCK` 开关已移除,前端只请求真实后端;本地演示请直接联调后端。 + +单测: + +```bash +cd frontend-web +pnpm test:report-collaboration +``` + +--- + +## 9. 联调验收清单 + +### 9.1 准备 + +- [ ] `report_collaboration.enabled: true` +- [ ] `report_collaboration.worker_enabled: true` +- [ ] 五个 `*_model` 已配置且可用 +- [ ] Gateway 已重启(MySQL 表/列就绪) +- [ ] 前端已登录(JWT 有效) + +### 9.2 探活 + +```bash +curl -s -H "Authorization: Bearer " \ + http://127.0.0.1:8001/api/report-collaboration/sessions +``` + +期望:**200** + `{ "sessions": [...] }`,而非 503。 + +### 9.3 页面流程(浏览器 Network) + +1. 打开 `/#/page/workspace/report-collaboration` +2. 输入需求 → `POST .../sessions` **200** +3. 跳转 → `GET .../sessions/{id}` **200**,响应含 `SessionSnapshot` +4. 首条消息 → `POST .../messages` **200** +5. 请求方案 → `POST .../plan-requests` **200** +6. 采用方案 → `POST .../plans/{id}/select` **200** +7. 开始协作 → `POST .../runs` **200** +8. `GET .../runs/{id}/stream` 持续收到 SSE(`message.delta`、`node.status.changed` 等) +9. 刷新页面 → 消息与 `last_seq` 续接正确 +10. 运行中输入「重新检索」→ `POST .../commands` → 确认卡 → `confirm` **200** + +### 9.4 常见失败 + +| 现象 | 原因 | 处理 | +|------|------|------| +| 全部 503 | `enabled: false` | 改配置并重启 | +| run 一直 queued | `worker_enabled: false` | 改为 true | +| 409 选方案 | revision 过期 | 刷新后重选(前端已自动 refresh) | +| 429 | 预算/并发上限 | 调大配置或取消其他 run | +| 500 + SQL | 表缺列 | 重启 Gateway,查列同步日志 | + +--- + +## 10. 契约与类型维护 + +- **单一事实源:** `frontend-web/src/report-collaboration/api/types.ts` +- **后端镜像:** `offline-backend-20260512/backend/app/report_collaboration/contracts/` +- 修改字段须同步两份实施计划与本指南,并跑前后端相关测试。 + +`SessionSnapshot` 形状(摘要): + +```typescript +interface SessionSnapshot { + session: ReportSession; + messages: CollaborationMessage[]; + plans: ReportPlanCandidate[]; + run: ReportRun | null; + nodes: CollaborationNodeState[]; + agent_runs: AgentRun[]; + commands: CollaborationCommand[]; + report_versions: ReportVersion[]; + last_seq: number; +} +``` + +`ReportRun` 可选字段(RC-BE-016):`usage`、`budget_exhausted_reason`。 + +--- + +## 11. 后续工作(非对接阻塞) + +| 项 | 说明 | +|----|------| +| RC-BE-019 | 灰度发布与离线部署文档 | +| 侧边栏入口 | 已放开(`workspace-nav-chat-list.tsx` 的「报告协作」项) | +| iframe 嵌入 | RC 页尚未专门适配 `embed=1`,可参考现有深链模式扩展 | +| 真人盲评 | RC-BE-018 为确定性评测集,上线前仍需计划 §11.4 人工评审 | + +--- + +## 12. 相关代码索引 + +| 用途 | 路径 | +|------|------| +| API 客户端 | `frontend-web/src/report-collaboration/api/` | +| 会话 Hook | `frontend-web/src/report-collaboration/hooks/useReportCollaborationSession.ts` | +| 页面 | `frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx` | +| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` | +| 后端路由 | `offline-backend-20260512/backend/app/gateway/routers/report_collaboration.py` | +| 配置 | `offline-backend-20260512/backend/config.yaml` → `report_collaboration` | +| 持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_collaboration/` | diff --git a/docs/agentscope-多智能体报告协作工作台-后端实施计划.md b/docs/agentscope-多智能体报告协作工作台-后端实施计划.md index 685d6cec..435ff195 100644 --- a/docs/agentscope-多智能体报告协作工作台-后端实施计划.md +++ b/docs/agentscope-多智能体报告协作工作台-后端实施计划.md @@ -1,11 +1,44 @@ # AgentScope 2.0 多智能体报告协作工作台——后端实施计划 -> 文档状态:待评审、尚未开始编码 -> 编写日期:2026-09-02 +> 文档状态:实施中(`RC-BE-000`~`018` 已完成;下一票 `RC-BE-019`) +> 编写日期:2026-09-05;进度更新日期:2026-09-06 > 目标项目:`F:\react01\deeflow-code\deerflow-server\offline-backend-20260512\backend` > 协作框架:[AgentScope 2.0](https://github.com/agentscope-ai/agentscope) -> 配套前端计划:`docs/agentscope-多智能体报告协作工作台-前端实施计划.md` +> 前端契约来源:`frontend-web/src/report-collaboration/api/types.ts`、`api/mock.ts`、`state/event-reducer.ts`(前端已完成,契约冻结) +> 尖峰基线:`offline-backend-20260512/backend/docs/AGENTSCOPE_BASELINE.md` > 本文只规划新增的报告协作后端,不修改现有多智能体会商,不使用旧 Workflow Studio DAG 调度器。 +> 下文 §8 的 `BE-n` 与 §10 的 `RC-BE-00n` 是同一批工单的两种编号;进度以 `RC-BE-*` 为准。 + +## 0. 当前进度 + +已打通 **建会话 → 澄清需求 → 生成 2~3 套候选方案 → 选定 → 创建 `queued` run(冻结模型 + 模板 + 预算)**,以及 **TaskLedger 波次调度 + 受控成员 / 真实 AgentScope 内核 + 主张级 QualityGate + 正式报告版本/改写 + 产品 durable SSE + 租约 dispatcher/恢复 + 运行期自然语言干预 + owner/预算/审计**。主开关默认关闭:`config.yaml → report_collaboration.enabled` / `worker_enabled` 均为 `false`。 + +运行期 IntentRouter 的歧义兜底已可调用冻结的 coordinator 模型,但明确命令走高精度规则;该智能体无工具且不能调度。`POST /runs` 仍只入队。`worker_enabled` 为 false 时 lifespan **不**挂载后台循环;测试用 `dispatch_once()` / `WaveExecutor` 驱动。`GET /runs/{id}/stream` 可重放/尾随已持久化事件。生产 worker 在双开时注入 `AgentScopeMemberKernel`。20 项质量评测已在 `RC-BE-018` 落地(确定性打分,不跑真实模型)。 + +| 工单 | 状态 | 代码落点 / 缺口 | +|---|---|---| +| `RC-BE-000` | 条件完成 | `agentscope_runtime/event_adapter.py`。`agentscope==2.0.5` 仅为 optional extra,**未写入 `uv.lock`**(等纵向 POC)。 | +| `RC-BE-001` | 完成 | `app/report_collaboration/contracts/` + `execution/completion.py`。 | +| `RC-BE-002` | 完成 | `deerflow.config.report_collaboration_config`;默认双关。 | +| `RC-BE-003` | 完成 | `deerflow.persistence.report_collaboration/`,迁移 `20260905_01`。 | +| `RC-BE-004` | 完成 | `/api/report-collaboration`。改写 / apply / restore 见 `015`。SSE 见 `011`。 | +| `RC-BE-005` | 完成 | `requirement/`:规划前澄清;「直接开始」记默认假设;澄清完成 ≠ 出方案。 | +| `RC-BE-006` | 完成 | `planning/`:目录投影(无 SOUL/密钥)+ 三策略方案;select 与 start-run 独立。 | +| `RC-BE-007` | 完成 | `DeerFlowChatModelAdapter` + `freeze_plan_models`;缺模型/能力不匹配在 `POST /runs` 前 422。 | +| `RC-BE-008` | 完成 | `permission_adapter.py` + `tool_adapter.py`:角色 × 工具矩阵、注入扫描。 | +| `RC-BE-009` | 完成 | `execution/ledger.py` + `member_runner.py`:TaskLedger 按依赖波次分派;AgentScope/协调者建议不能选下一节点;空输出/非法 JSON 进入新 attempt;超限失败不完成整个 run。内核目前为 `ScriptedMemberKernel`(单测),真实模型/web_search 仍待接。 | +| `RC-BE-010` | 完成 | `execution/quality_gate.py` + `ArtifactBoard.supersede_previous`:冲突数字/缺口径拒绝;未核验或争议主张不能进分析/章节;审稿必须映射到节点或章节;覆盖矩阵缺口拒绝。Ledger 先 schema 再质检,通过才 `validated`。 | +| `RC-BE-011` | 完成 | `execution/live_hub.py`:run-scoped Live Hub、`PublishingReportCollaborationStore`(先入库再发布)、`after_seq`/`Last-Event-ID` 重放、心跳、token delta 合帧、SSE 断开不取消 run。`GET /runs/{id}/stream` 已挂载。报告正式版本见 `015`。 | +| `RC-BE-012` | 完成 | store `list_claimable` / `renew_lease` / `finalize_cancel`;`execution/{dispatcher,executor,recovery}.py`:原子 claim、心跳续租、两阶段取消、过期租约接管。恢复保留已完成节点,遗留 in-flight 标 `interrupted`/`superseded` 并开新 `planned` attempt。晚到成果 `validation_status=superseded`,不改终态。`start()` 仅在 `worker_enabled` 时挂循环;默认双关。 | +| `RC-BE-013` | 完成 | `execution/quality_roles.py`:Verifier/Analyst/Writer/Reviewer/Reviser 作为受控成员(确定性内核,非 AgentScope Team)。核验标记过期/跨包冲突;缺失角度只重开检索链;审稿 `revise` 只重开目标写作节点 + 审稿自身。`TaskResult.additional_drafts` 允许多条 VerificationResult。脚本内核仍供单测;生产 worker 见 `017`。 | +| `RC-BE-014` | 完成 | `interventions/`:运行期 IntentRouterAgent(规则优先、模型兜底且无工具)+ 确定性 ImpactAnalyzer + command CAS/safe-point。支持节点/agent 归属校验、角度定向、确认闸门、等待边界、overlay、新 attempt、成果失效及晚到结果隔离。 | +| `RC-BE-015` | 完成 | `reporting/`:run 开始冻结模板;`ReportFinalizer` 在审稿通过且 lint 后落正式版本并发 `report.version.created`;改写候选 / apply / restore 已挂路由,不再 503。 | +| `RC-BE-016` | 完成 | owner 隔离、跨会话幂等拒绝、`sanitize_for_user` + 审计落库、每用户 active run 上限、每 run 预算 fail-closed。 | +| `RC-BE-017` | 完成 | `AgentScopeMemberKernel` 真实 `reply_stream` + 工具 + 结构化成果;生产 worker 注入该内核;冒烟可跳过未安装的 `agentscope`。并发交错/租约恢复仍由 `012` 测试覆盖。 | +| `RC-BE-018` | 完成 | `app/report_collaboration/eval/`:20 项标注任务(政策/市场/企业/事件/对比各 4)、确定性指标、金标/失败实例、会商盲评对照、20 次固定任务可靠性计数。`cite_required` 与当前默认 knobs 为通过评测的推荐画像。真实模型盲评仍属上线决策,不阻塞本票。 | +| `RC-BE-019` | 未开始 | 灰度与离线发版。 | + +当前可联调路径:打开 `report_collaboration.enabled` 后,规划前轮询 `GET /sessions/{id}` 即可看到澄清消息和方案卡;`POST /runs` 只入队;`GET /runs/{id}/stream` 重放已入库事件。默认 **不**自动领取 queued run(`worker_enabled=false`)。测试可调 `dispatch_once()`。 ## 1. 后端目标 @@ -14,7 +47,7 @@ 1. 理解任意报告需求并在必要时澄清。 2. 从当前用户可见的智能体、技能、模型和资料源中筛选能力。 3. 生成 2~3 个真实有差异的候选协作计划。 -4. 将选定计划实例化为 AgentScope leader-worker 团队。 +4. 将选定计划实例化为受 `TaskLedger` 约束的 AgentScope 任务成员,而非让自由会商自行决定流程。 5. 通过结构化成果物完成检索、核验、分析、综合、写作、审稿和返工。 6. 接受全过程自然语言干预,并只重开受影响的任务和下游成果。 7. 使用数据库租约、durable events、幂等命令和版本化报告保证恢复与一致性。 @@ -23,11 +56,27 @@ 框架能提供协作机制,不能单独保证报告质量。质量由证据合同、来源核验、独立审稿、定向返工、模板约束和真实评测集共同保证。 +### 1.1 前端冻结契约:后端必须适配,不要求前端回退 + +已完成的双栏前端以 `SessionSnapshot` 和 run-scoped durable SSE 为唯一事实来源;后端实现不得另造一套摘要卡或节点结果接口来补足过程。 + +| 前端已实现行为 | 后端适配要求 | +|---|---| +| run 创建前轮询 `GET /sessions/{id}` | 澄清、需求修订、候选方案与选择结果必须同步写入完整快照;不能只存在于内存任务。 | +| run 后以 `seq` 重放/订阅 SSE | 每个 run 的事件先持久化、再发布,`seq` 严格单调且可用 `after_seq` 补拉。 | +| 并行成员横向阶段卡 | 同一并行波次使用相同 `phase_id`;`node_run_id` 固定为 `${node_id}-attempt{attempt}`,不可使用前端无法反查节点的随机值。 | +| 复用 `MessageList` 展示过程 | assistant、TeamSay、工具调用、工具结果与流式增量均写入 `report_collaboration_messages` 并按原消息协议推送。 | +| 成员状态来自局部快照合并 | 前端没有 `agent_run.*` 事件;成员开始、等待输入、完成、失败、取消时必须发 `session.snapshot: {agent_runs:[...], run?:...}`。 | +| 报告在对话流中渐显 | 写作阶段发 `report.delta`;质量门和版本落库完成后才发 `report.version.created`。 | +| 节点抽屉只读 | `GET /runs/{id}/nodes/{node_id}/contract` 只返回职责合同,永不返回执行结果正文。 | + +每个写操作读取前端发送的 `X-Idempotency-Key` 与 `X-Expected-Revision`;用户消息的服务端镜像必须将同一幂等键放入 `metadata.idempotency_key`,以替换前端乐观消息而不重复显示。 + --- ## 2. 关键架构决策 -### 2.1 使用 AgentScope Core,不采用演示服务作为事实源 +### 2.1 AgentScope 用作任务执行内核,不把 Agent Team 自主会商当作调度器 AgentScope 官方提供 Event System、Agent Team、任务规划和 Agent Service 示例,但团队可靠性与多进程/分布式服务仍在持续完善: @@ -36,13 +85,16 @@ AgentScope 官方提供 Event System、Agent Team、任务规划和 Agent Servic - [Agent Team 可靠性跟踪](https://github.com/agentscope-ai/agentscope/issues/1802) - [多进程与分布式能力跟踪](https://github.com/agentscope-ai/agentscope/issues/1722) -因此采用: +因此采用下列职责分界: -- AgentScope Core:Agent、Team、Tool、Event、Permission。 +- AgentScope Core:单个受控任务成员的模型调用、流式事件、工具调用、权限封装与结构化输出修复。 +- DeerFlow `TaskLedger`:计划实例化、依赖拓扑、并发波次、输入成果选择、状态迁移、重试、租约、取消、命令安全点与业务终态。 +- DeerFlow `ArtifactBoard` / `QualityGate`:成果物事实源、验收、失效传播、引用约束和报告版本准入。 +- `Coordinator`:受限的规划/协调智能体,只能提出 `PlanProposal`、`TaskAssignment`、`RepairRequest` 和 `ClarificationRequest`;它不能自行创建任意图、改变数据库或宣告节点完成。 +- AgentScope TeamSay:允许作为可见的协作交接文本,但不是任务分派、完成判定或数据依赖的事实来源。 - DeerFlow:身份、权限、配置、数据库、消息、命令、作业、租约、SSE、成果、文件、报告版本和审计。 -- AgentScope Event 既驱动运行状态,也通过无损 adapter 转为 DeerFlow 领域事件和 LangChain 兼容消息;其用户可见内容不能在 adapter 中被压缩为进度摘要。 -- 不运行 AgentScope `examples/web_ui`。 -- 不把 AgentScope Session、MessageBus 或内存对象作为恢复依据。 +- AgentScope Event 通过无损 adapter 转为 DeerFlow 领域事件和 LangChain 兼容消息;用户可见内容不能在 adapter 中压缩为进度摘要。 +- 不运行 AgentScope `examples/web_ui`,不使用 AgentScope Session、MessageBus 或内存对象作为恢复/重放依据,也不使用其 session SSE 作为产品 SSE。 - 节点公开接口只返回职责合同、图关系和状态,不返回节点实际输出;实际阶段消息、工具过程和成果仍从会话消息流以原生消息、工具步骤、引用和报告文件卡的形式呈现。 ### 2.2 新功能独立,不进入旧运行内核 @@ -63,7 +115,7 @@ AgentScope 官方提供 Event System、Agent Team、任务规划和 Agent Servic - 展示当前状态和返工范围。 - 供用户理解和选择方案。 -真正调度由 `Coordinator + TaskLedger + ArtifactBoard + QualityGate` 完成。协调者只能从服务端允许的动作中选择,不能返回任意 Python、任意图或数据库操作。 +真正调度由 `TaskLedger + ArtifactBoard + QualityGate` 完成。协调者只能提出服务端可验证的建议;`TaskLedger` 才能执行分派、依赖解锁、重试和终态迁移,不能返回任意 Python、任意图或数据库操作。 ### 2.4 数据库是唯一事实源 @@ -89,12 +141,12 @@ flowchart LR API --> DISP[Lease Dispatcher] DISP --> EXEC[Report Collaboration Executor] - EXEC --> AS[AgentScope Core] - AS --> LEAD[Coordinator] - AS --> TEAM[Research/Verify/Analyze/Write/Review Team] - EXEC --> LEDGER[Task Ledger] EXEC --> BOARD[Typed Artifact Board] + LEDGER --> COORD[Constrained Coordinator] + LEDGER --> AS[AgentScope Core Task Workers] + COORD --> LEDGER + AS --> TEAM[Research / Verify / Analyze / Write / Review] EXEC --> MODEL[DeerFlow Model Adapter] EXEC --> TOOL[Allowlisted Tool Adapter] @@ -290,6 +342,59 @@ completed -> reopened -> queued - 期望成果物落库、Schema 通过、验收器通过后才能完成。 - 返工创建新 attempt,保留旧 attempt 和旧成果。 +### 5.6 `TaskLedger`:智能体协作的确定性边界 + +每个计划节点被实例化为一条可审计的 ledger task;并行只是多个 ready task 位于同一波次,不是让多个智能体在群聊中自行决定谁做什么。 + +```text +TaskAssignment + node_id / node_run_id / phase_id / attempt + requirement_revision + allowed_input_artifact_ids[] + expected_artifact_type + acceptance_criteria + agent_profile_snapshot + allowed_tools + budgets + command_overlay_revision + +TaskResult + artifact_draft | clarification_request | repair_request | failure + cited_artifact_ids[] + source_ids[] +``` + +调度规则: + +1. `PlanAssembler` 将角色建议转为固定节点、边、成果合同和质量门;只允许无环主图。 +2. `TaskLedger` 仅在依赖的成果物均为 `validated`、未 supersede 且预算可用时使节点 ready。 +3. 每个成员只读 requirement revision、自己的 `TaskAssignment` 与允许的已验证上游成果;不能把其他成员的原始上下文、工具返回或内部 prompt 当作输入。 +4. 并行节点共享 `phase_id`,但各自有独立 `agent_run_id`;串行节点用独立 `phase_id`。成员的 `node_run_id` 一律为 `${node_id}-attempt{attempt}`。 +5. 只有 `TaskResult` 转为成果物并通过 `QualityGate` 后,ledger 才解锁下游;TeamSay 与普通文本没有解锁能力。 +6. 修复/返工创建新 attempt;旧 attempt 及其消息留存但被标为 `superseded`,不得重新进入下游。 + +### 5.7 用户命令的约束化协议 + +每句话先持久化为用户消息和 `CollaborationCommand`,再由独立的**意图理解智能体(`IntentRouterAgent`)**进行两段式判定:规则/上下文高精度路由优先,必要时调用模型并输出受 Pydantic 约束的 `IntentDecision`。它负责理解,不负责调度或执行。 + +同一能力有两种受控入口: + +| 时机 | `IntentRouterAgent` 的工作 | 后续处理 | +|---|---|---| +| 尚未开始 run | 提取主题、目标、受众、范围、时间、角度、模板和来源约束;判断真正缺失的信息 | `RequirementResolver` 写 requirement revision;`PlanController` 生成候选计划。 | +| run 正在执行或已有报告 | 结合当前选中的 node/agent、等待澄清、成果血缘和报告章节,识别用户是在回答、补角度、重检索、重分析、重跑、改章节、润色、查询还是改规划 | `ImpactAnalyzer` 计算实际影响;`TaskLedger` 在确认和安全点后执行。 | + +输入包含用户文本、session/run 状态、最新 requirement、当前 `target_node_id` / `agent_run_id`、awaiting clarification、当前报告版本和可见成果摘要;不得输入其他成员的内部 prompt、隐藏推理或无权访问的资料。 + +```text +IntentDecision + intent / confidence / normalized_instruction + scope: conversation | node | artifact_lineage | section | plan + requested_target_ids[] / requirement_patch? + requires_confirmation / cost_level / reason +``` + +服务端必须验证 `target_node_id`、`agent_run_id` 属于当前 session/run,再解析到 node 和成果物血缘;不可相信客户端传入的任意 ID。`ImpactAnalyzer` 产出实际 `impact_nodes`、失效成果和新 attempt 计划,用户所见的命令状态由它和 `TaskLedger` 写入,而非由协调者文本决定。 + +`IntentRouterAgent` 的置信度不足、目标不唯一、涉及 requirement/plan revision、预计成本为 high 或影响多个并行分支时,必须返回 `requires_confirmation=true`;只有用户确认后 `TaskLedger` 才可执行。对于处于 `awaiting_input` 的节点,明确回答优先分类为 `answer_question`,只解锁该节点的依赖链。 + --- ## 6. 数据库设计 @@ -300,9 +405,9 @@ completed -> reopened -> queued | `report_collaboration_messages` | 对话原文镜像 | message_id、role、content、tool_calls_json、tool_call_id、agent_run_id、node_run_id、phase_id、visibility、metadata、command_id、created_at、completed_at | | `report_collaboration_plans` | 候选及修订 | proposal_group_id、revision、status、graph_json、validation_json | | `report_collaboration_runs` | 正式执行 | plan_id、status、lease_owner、lease_until、cancel_requested、next_event_seq | -| `report_collaboration_node_runs` | 节点尝试 | node_id、attempt、status、contract_json、agent_snapshot、error、started_at、ended_at | +| `report_collaboration_node_runs` | 节点尝试/ledger task | node_id、attempt、`node_run_id=${node_id}-attempt{attempt}`、status、contract_json、input_artifact_ids、command_overlay_revision、agent_snapshot、error、started_at、ended_at | | `report_collaboration_agent_runs` | 实际团队成员实例 | agent_run_id、node_run_id、phase_id、role、display_name、avatar_key、status、agent_snapshot、started_at、ended_at | -| `report_collaboration_artifacts` | 成果物 | artifact_type、producer_node_run_id、schema_version、content_json、validation_status | +| `report_collaboration_artifacts` | 成果物及血缘 | artifact_type、producer_node_run_id、requirement_revision、schema_version、content_json、validation_status、superseded_by | | `report_collaboration_events` | durable SSE | run_id、seq、event_type、payload、created_at | | `report_collaboration_commands` | 用户命令 | command_id、idempotency_key、intent、scope、status、expected_revision | | `report_collaboration_report_versions` | 报告版本 | version、parent_version_id、markdown、source_index、change_summary | @@ -316,6 +421,7 @@ completed -> reopened -> queued - 事件 seq 在 run 行内原子分配。 - 用户消息、协调者消息、成员 TeamSay、流式回复、工具调用和工具返回使用稳定 `message_id` 与 timeline/event seq;同一 `agentRunId` 内严格保持因果顺序,保证 `MessageList` 能以原生消息重建全过程。 - 并行成员的消息分别归属 `agent_run_id`,同一事件流仍全量持久化;前端选择标签只能过滤/聚焦,不能影响事件写入或消息顺序。 +- 成员状态的 event 投影使用 `session.snapshot` 中的增量 `agent_runs[]` 合并;不可自定义未被前端处理的 `agent_run.*` 事件。 - 删除 session 显式删除子资源,不依赖 SQLite cascade pragma。 - 写事务在 commit 前完成必要序列化。 - 不使用跨连接 post-commit refresh。 @@ -346,6 +452,7 @@ GET /api/report-collaboration/runs/{run_id}/events?after_seq={seq} GET /api/report-collaboration/runs/{run_id}/stream?after_seq={seq} POST /api/report-collaboration/runs/{run_id}/commands POST /api/report-collaboration/runs/{run_id}/commands/{command_id}/confirm +POST /api/report-collaboration/runs/{run_id}/commands/{command_id}/cancel POST /api/report-collaboration/runs/{run_id}/cancel GET /api/report-collaboration/runs/{run_id}/nodes/{node_id}/contract @@ -357,10 +464,17 @@ POST /api/report-collaboration/sessions/{session_id}/report-rewrites/{id}/appl POST /api/report-collaboration/sessions/{session_id}/report-versions/{id}/restore ``` -写请求必须支持: +`GET /sessions/{session_id}` 必须返回完整 `SessionSnapshot`: -- `idempotencyKey` -- `expectedRevision` +```text +session / messages / plans / run / nodes / agent_runs / commands / +report_versions / last_seq +``` + +写请求必须读取并回显: + +- `X-Idempotency-Key`(服务端唯一去重键;用户消息镜像写入 `metadata.idempotency_key`) +- `X-Expected-Revision`(陈旧修改返回 409,不静默覆盖) 计划请求和 run 请求只创建 durable command/job,不在请求生命周期中等待完整模型输出。 @@ -437,10 +551,11 @@ heartbeat - 历史与实时使用相同 envelope。 - 支持 `Last-Event-ID` 和 `after_seq`。 -- token delta 可 live-only 合帧,但需周期性保存可恢复 checkpoint。 -- 不能每 token 写数据库。 +- 高频 token/tool 参数增量按 50~250ms 或大小阈值合帧为**已持久化** `message.delta` / `tool_call.delta` / `tool_result.delta`;不能每 token 写数据库,也不能仅 live-only 后丢失重放。 +- 每一批增量与消息最新正文同事务写入,再向 Live Hub 发布;断线时能通过 `after_seq` 重放,快照也能读到最新 checkpoint。 - 状态、成果、命令和报告版本必须 durable。 - 读取事件暂时失败时保持连接并发送可恢复 warning。 +- `session.snapshot` 可以携带完整快照,也可以携带 `run`、`nodes`、`agent_runs` 的增量集合;成员状态变化必须用此事件投影。 `message.*` / `team_message.*` 的 `data` 必须携带 LangChain 兼容的消息字段和归属,而非仅有摘要: @@ -466,26 +581,33 @@ heartbeat 工具调用先在同一条 `ai` 消息的 `tool_calls` 上创建/追加参数 delta;返回值随后以 `type: "tool"`、相同 `tool_call_id` 的独立消息传输。允许用户查看的正文、参数和返回必须保持原文以及原始增量边界;只有统一脱敏器标记的字段可替换,替换理由写入 metadata。每个成员在开始生成时先发送 `message.created`,因此即使首 token 尚未到达,前端也能立即显示该成员正在回复,而非静默等待。 +`reasoning_delta` 仅可承载经策略批准的、面向用户的工作说明(如“正在比对统计口径”),不允许桥接模型隐藏推理链、系统 prompt 或内部审稿思路;如模型未提供安全的公开工作说明,省略字段,过程可见性由 TeamSay、工具步骤、来源与普通 assistant 文本承担。 + --- ## 8. 分步实施计划 +工单状态见 §0。本节正文仍是验收标准,不因部分完成而删减。 + ### BE-0:AgentScope 兼容性尖峰 +**状态:条件完成(`RC-BE-000`)。** 事件适配与 dict/真实事件单测已有;真实双模型流式与 `uv.lock` 仍属后续 POC。20 项标注评测与 20 次固定任务统计已在 `RC-BE-018` 落地。 + 先写实验和测试,不接前端,不建正式业务表。 验证: -1. 当前 Python 3.12 与稳定 AgentScope 2.x 兼容。 -2. 固定精确版本和 commit,不使用浮动 `main`。 +1. 当前 Python 3.12 与候选 `agentscope==2.0.5` 兼容;POC 通过后才将精确版本写入 lock,不能使用浮动 `main`。 +2. 验证单任务 worker 所需的 Agent、Tool、Event、Permission API;Agent Team 的自主调度与 Session SSE 不属于本功能的正确性依赖。 3. `uv lock` 后检查 Pydantic、SQLAlchemy、FastAPI、OpenAI SDK 冲突。 4. 使用当前至少两个模型完成流式回复。 -5. leader + 2 workers 完成任务分配、TeamSay、并发和结构化输出。 +5. `TaskLedger` 分派 2 个并行 worker,完成可见 TeamSay、并发和结构化输出。 6. 主动取消、超时、工具异常和成员异常行为可控。 7. AgentScope Event 可稳定关联 agent、task、message、tool call,并能一对一写出 LangChain 兼容的 assistant/tool 消息。 8. 内存状态丢失后可由 DeerFlow task ledger/artifact 重建。 9. 连续运行 20 次固定任务,统计早停、空输出、非法 JSON、事件缺失。 -10. 从首个 `REPLY_START`、TeamSay、tool-call start、tool result 到 token delta 的首个可见事件均在指定时限内到达 adapter;并行两个成员交错输出时不串 message ID 或 tool call ID。 +10. 从首个 `REPLY_START`、TeamSay、tool-call start、tool result 到 token delta 的首个可见事件均在指定时限内到达 adapter;并行两个成员交错输出时不串 message ID 或 tool call ID,并能按前端既定 `phase_id` / `node_run_id` 聚焦。 +11. 人为注入流式中断、未闭合 tool call、等待用户输入与恢复场景;adapter 必须补发终态 tool result,不能留下永久 loading 的工具步骤。 交付: @@ -502,6 +624,8 @@ Go 条件: ### BE-1:配置、依赖和总开关 +**状态:完成(`RC-BE-002`)。** `enabled` / `worker_enabled` 默认 `false`。 + 新增 `config.yaml -> report_collaboration`: ```text @@ -538,6 +662,8 @@ cost_budget ### BE-2:领域合同和状态机 +**状态:完成(`RC-BE-001`)。** + 完成 Pydantic 合同: - requirement。 @@ -560,6 +686,8 @@ cost_budget ### BE-3:迁移和持久化 Store +**状态:完成(`RC-BE-003`)。** 表前缀 `report_collaboration_*`,迁移 `20260905_01`。 + 任务: 1. 创建数据库迁移。 @@ -579,15 +707,19 @@ cost_budget ### BE-4:会话、消息和计划 API 骨架 +**状态:骨架完成(`RC-BE-004`)+ SSE 已挂载(`RC-BE-011`)+ 改写/恢复已挂载(`RC-BE-015`)。** REST 会话/消息/方案/run 已挂载;`GET /runs/{id}/stream` 为 durable SSE。 + 任务: - session CRUD 和用户隔离。 +- `GET /sessions/{id}` 一次返回前端 `SessionSnapshot` 的全部字段;run 前的澄清/规划阶段由该快照在 2.5 秒轮询内可见。 - message 持久化:完整保存用户可见的 assistant、TeamSay、`tool_calls` 与 `tool` 返回,并保留 `agent_run_id`/`node_run_id`/`phase_id`;不只保存阶段摘要。 - plan request 入队。 - plan list/select。 - run create/query。 - OpenAPI 响应模型。 - 路由注册顺序和统一错误模型。 +- 按 `frontend-web/src/report-collaboration/api/types.ts` 建立契约测试:字段名保持 snake_case,枚举、事件类型、命令确认/取消端点和 revision 头不可漂移。 安全: @@ -597,14 +729,17 @@ cost_budget ### BE-5:需求解析和澄清 -`RequirementResolver`: +**状态:完成(`RC-BE-005`)。** 规则路径;尚未接规划用 LLM。澄清完成不会自动 `plan-requests`。 + +`IntentRouterAgent`(规划前模式)与 `RequirementResolver`: 1. 读取会话消息和最新 requirement snapshot。 -2. 提取主题、目标、受众、范围、角度、模板和来源限制。 -3. 判断是否缺少影响方案选择的必要信息。 -4. 必要时产生一个或多个独立 clarification。 -5. 用户回答后创建新 requirement revision。 -6. 达到澄清上限后使用明确假设继续,不能无限追问。 +2. `IntentRouterAgent` 输出结构化需求提取、澄清判定或“可直接规划”判定;不得自行建图或启动任务。 +3. `RequirementResolver` 校验并写入主题、目标、受众、范围、角度、模板和来源限制。 +4. 判断是否缺少真正影响方案选择的必要信息。 +5. 必要时产生一个或多个独立 clarification。 +6. 用户回答后创建新 requirement revision。 +7. 达到澄清上限后使用明确假设继续,不能无限追问。 验收: @@ -614,6 +749,8 @@ cost_budget ### BE-6:智能体目录、筛选和候选计划 +**状态:完成(`RC-BE-006`)。** 选择器为规则重排,未接规划模型。`POST /plan-requests` 在 `worker_enabled=false` 时于请求内同步生成方案(非 LLM)。 + 五阶段: 1. `AgentCatalogProjection`:读取用户可见且启用的 agent、技能和工具元数据。 @@ -638,6 +775,8 @@ cost_budget ### BE-7:AgentScope 模型适配 +**状态:完成(`RC-BE-007`)。** 适配器鸭子类型对齐 `ChatModelBase.__call__`;运行前冻结快照。真实 Provider 冒烟仍属 `RC-BE-009` / `017`。 + 目标:让 AgentScope 使用 DeerFlow 配置的模型,不再建立第二套 Provider 配置。 任务: @@ -657,13 +796,15 @@ cost_budget ### BE-8:工具与权限适配 +**状态:完成(`RC-BE-008`)。** + 第一版允许: - Web 检索和正文获取。 - 用户有权限的知识库/Wiki 检索。 - 明确批准的只读技能/MCP。 - 成果物板读写工具。 -- AgentScope 团队通信与任务工具。 +- 用户可见交接(TeamSay)与受控成果物/澄清工具。 第一版默认禁止: @@ -678,7 +819,7 @@ cost_budget - Researcher 不能发布报告。 - Writer 不能把未核验证据改成已核验。 - Reviewer 只能输出 ReviewDecision,不能覆盖报告。 -- Coordinator 只能通过受控工具创建任务、分派、等待、返工和建议完成。 +- Coordinator 只能提出受控的计划、澄清与修复建议;实际创建任务、分派、等待、返工和完成由 `TaskLedger` 服务执行。 - 所有成员获得本次 run 的最小权限快照。 验收: @@ -688,7 +829,9 @@ cost_budget - 网页 prompt injection 不能提升权限。 - AgentScope 工具异常被转换为 node/tool event,不泄露内部堆栈给用户。 -### BE-9:AgentScope Team 运行器 +### BE-9:受控任务成员运行器(不采用自治 Team 调度) + +**状态:完成(`RC-BE-009` 脚本内核 + `RC-BE-013` 质量角色内核)。** `TaskLedger` + `WaveExecutor` + `QualityRoleKernel`。真实 AgentScope `reply_stream` / web_search 仍待接。 基础角色: @@ -706,18 +849,21 @@ cost_budget - 计划决定 Researcher/Analyst 数量和角度。 - 服务器限制团队人数、并发、attempt、返工和预算。 - 每个成员上下文只包含当前任务、需求快照和必要上游成果。 -- Coordinator 不直接操作数据库,只调用 TaskLedger/ArtifactBoard 工具。 +- `Coordinator` 只接收结构化成果摘要、质量失败与用户命令影响,输出候选计划/修复建议;它不拥有自由消息总线或数据库权限。 +- `TaskLedger` 依据已验证成果、预算与并发限额逐波创建 AgentScope worker;AgentScope 不选择下一节点,也不决定任务完成。 +- 每个 worker 只能调用 `submit_artifact`、`request_clarification`、`request_repair`、允许的资料工具和可见 TeamSay;所有改变运行状态的调用都由服务端重新校验。 - 任务完成由 QualityGate 判定,不接受文本声明。 事件映射: - AgentScope `REPLY_*` → 以原始文本 chunk 创建/更新协调者或成员的稳定 assistant 消息,保留 `agentRunId`;不得等待 reply 完成后再合成为进度摘要。 -- AgentScope TeamSay → 以成员身份创建/更新普通 assistant 消息,保留完整用户可见交接内容与因果顺序。 +- AgentScope TeamSay → 以成员身份创建/更新普通 assistant 消息,保留完整用户可见交接内容与因果顺序;它不会改变 ledger 状态。 - `MODEL_CALL_*` → 内部遥测,默认不下发原始 prompt。 - `TOOL_CALL_*` → 原生 `ai.tool_calls` 参数事件;对应工具返回 → 原生 `tool` 消息和相同 `tool_call_id`。两者在 SSE 和持久化中一一对应,供现有 `MessageGroup` 直接渲染。 -- team task/update → node/task ledger 状态。 +- `TaskLedger` 的分派/校验/重试/取消结果 → node/task ledger 状态;AgentScope 仅上报可验证的 TaskResult。 - 成果工具 → artifact created/validated/rejected。 - 每个用户可见阶段开始、进展、完成、等待、返工和失败都必须同步产生稳定的消息或状态事件,供左侧 DeerFlow `MessageList` 按原始消息顺序展示;不能要求前端读取节点结果后自行拼装过程,也不能用聚合卡替换 AgentScope 的实时可见消息。 +- agent run 的开始、等待输入、完成、失败和取消,紧邻节点状态事件发 `session.snapshot` 增量,至少包括相应的 `agent_runs[]` 和当前 `run.current_phase`。 验收: @@ -727,6 +873,8 @@ cost_budget ### BE-10:成果物板与质量门 +**状态:完成(`RC-BE-010`)。** `QualityGate` 为确定性规则(无二次模型调用)。模板冻结快照与正式 `ReportVersion` 落库已在 `RC-BE-015` 落地。 + `ArtifactBoard`: - 按 run/node/attempt/type 保存成果。 @@ -736,21 +884,26 @@ cost_budget `QualityGate`: -- EvidenceBundle:来源数量、日期、URL/文档 ID、摘录和去重。 -- VerificationResult:支持与反对证据、冲突、时效。 -- AngleAnalysis:结论必须引用验证过的 evidence ID。 -- ReportSectionDraft:关键事实和数字必须有引用。 -- ReviewDecision:问题必须有目标、严重度和修复要求。 -- ReportVersion:模板覆盖、引用完整、章节一致、正式版本已持久化。 +- `CoverageMatrix`:把需求中的每个必选角度/关键问题映射到证据目标、分析节点和模板章节;计划和最终版本均不可漏项。 +- `EvidenceBundle`:按**主张级**保存 `claim_id`、来源 URL/文档 ID、发布/获取时间、原文摘录、事实或推断标签、适用范围、数值单位/口径、可信度与去重结果。 +- `VerificationResult`:对关键主张给出支持与反对证据、冲突、时效、来源独立性和可用结论;数字及重要事实必须经此门。 +- `AngleAnalysis`:结论只能引用验证过的 evidence ID,必须分别标示事实、推断、假设、局限和资料缺口。 +- `ReportSectionDraft`:关键事实和数字必须有 evidence ID;章节的主张与引用一一可追溯。 +- `CitationTemplateLinter`:校验模板章节、必答问题、来源格式、数字/日期/实体一致性、不可用或过期来源和未引用的关键事实。 +- `ReviewDecision`:独立于 Writer 上下文运行,问题必须有目标、严重度、证据和修复要求;不能笼统地要求“整体再优化”。 +- `ReportVersion`:覆盖矩阵、引用、模板、章节衔接、结论一致性和正式版本持久化全部通过才可创建。 验收: - 无成果、空成果和不合 Schema 的成果不能完成节点。 - 未验证来源不能直接进入最终事实陈述。 - Reviewer 的返工目标可映射到具体节点和章节。 +- 同一事实出现冲突或同一数字缺少口径时,QualityGate 必须拒绝或要求定向补证,不能由 Writer 擅自选择一个答案。 ### BE-11:后台 Dispatcher、租约和恢复 +**状态:完成(`RC-BE-012`)。** `worker_enabled` 默认仍为 false:Gateway lifespan 只有 `enabled` **且** `worker_enabled` 同时为真才 `dispatcher.start()`。测试走 `dispatch_once()` / `executor.execute()`,不要依赖后台循环。 + 复用现有成熟模式: - active-dedupe key。 @@ -780,6 +933,8 @@ cost_budget ### BE-12:SSE 和 Live Hub +**状态:完成(`RC-BE-011`)。** `ConversationReplica` 已实现协议镜像;产品 SSE 经 `execution/live_hub.py` 挂载。报告正式版本写入已在 `RC-BE-015` 落地。 + 任务: - 实现 event repository。 @@ -790,6 +945,7 @@ cost_budget - 将高频 token delta 合帧。 - 建立 `ConversationReplica`:将 AgentScope 用户可见消息、TeamSay、assistant delta、`tool_calls`、`tool` 返回、协助请求、报告文件和领域状态写成可重放的统一消息事件。它是协议镜像而不是摘要生成器。 - 对话镜像完整保留用户有权查看的 TeamSay、原始工具参数和返回;不包含 chain-of-thought、系统/内部 prompt、密钥、权限不可见内容和异常堆栈。脱敏在入库/下发前一次执行,不能由前端自行推断。 +- 对前端已支持的 `reasoning_delta`,仅推送由本系统生成或模型明确允许公开的短工作说明;它不等同于原始思维链,也不得用作质量门、调度或审计的唯一依据。 - `agentRunId` 是并行消息流的稳定归属。Live Hub 始终发布所有成员事件;前端标签切换只在本地选择消息,不启动第二条运行流,也不取消未聚焦成员的 delta。 验收: @@ -799,10 +955,15 @@ cost_budget - 终态前报告正式版本已写入并可读。 - SSE 断开不取消后台 run。 - 仅依靠会话快照和事件重放即可重建每个成员的完整原生消息、工具过程和左侧聚焦视图,不需要调用节点结果接口。 +- 在模型/工具中断、取消和接管时,所有已开始的 tool call 都有对应的 completed tool result(成功、失败或 interrupted),不会让复用的 `MessageList` 永久显示执行中。 ### BE-13:全过程命令与影响分析 -`IntentRouter` 输入: +**状态:完成(`RC-BE-014`)。** `app/report_collaboration/interventions/` 已实现运行期 IntentRouterAgent、ImpactAnalyzer 和 RuntimeCommandService;SQL/memory store 通过 `patch_command(..., expected_statuses=...)` 对命令执行权做 CAS。置信度阈值由 `report_collaboration.intent_confirmation_threshold` 配置(默认 `0.8`)。 + +实现边界:本票负责识别、授权、影响计算、确认、定向失效、overlay 和安全重排队。`rewrite_section` / `polish_report` 已能严格保留研究链并只返工写作/审校链;replacement candidate、正式版本 apply/restore 已在 `RC-BE-015` 落地。`add_angle` 优先使用用户定向的研究分支,否则按冻结计划顺序确定性选择一个研究分支,只接入其必要下游;无研究支路立即 `rejected`。`rerun_node` 没有合法目标时不得扩成整图。需求补丁只在返工成功后写入。卡住的 `executing` 可在接管或租约超时后收回。`command.cancelled` 进入 SSE。运行中 `POST /sessions/{id}/messages` 返回 409。 + +`IntentRouterAgent`(运行中干预模式)输入: - 用户文本。 - session/run 状态。 @@ -810,7 +971,7 @@ cost_budget - 当前选中节点或章节上下文。 - 后端动作白名单。 -输出: +先由规则层处理明确的回答、取消和节点/章节显式指令;其余由 `IntentRouterAgent` 输出: ```text intent @@ -823,6 +984,8 @@ cost_level reason ``` +模型输出必须通过 Pydantic 校验,并经服务端 target 授权与置信度/成本闸门后才创建可执行 command;`IntentRouterAgent` 不可调用分派、取消、成果写入或报告版本工具。 + 命令类型: - `answer_question` @@ -839,11 +1002,15 @@ reason `ImpactAnalyzer`: -- 重新检索:失效指定 EvidenceBundle 和引用它的下游。 -- 重新分析:保留 evidence,失效分析和对应章节。 -- 章节重写:保留研究/分析,只生成 replacement candidate。 -- 语气润色:创建新报告版本,不失效事实成果。 -- 全局改向:新建 plan revision,确认后创建 run branch。 +- `answer_question`:只解析相应的 clarification;仅解锁依赖该答案的节点,其他并行节点继续。 +- `research_again`:为指定节点/角度追加或替换 EvidenceBundle,失效其验证、分析、综合和受影响章节,不误伤无关角度。 +- `reanalyze`:保留 validated evidence,只失效指定分析、综合、写作和审校分支。 +- `add_angle`:在当前冻结计划中选择一个明确目标或确定性的研究支路承接新角度,再只失效其必要下游;无可用支路时拒绝执行。运行中不暗改前端已选择的 DAG,真正新增画布节点只发生在后续重新规划产生的新 plan revision 中。 +- `rerun_node`:以新 attempt 重跑指定节点及必要下游,旧结果保留为 superseded。 +- `rewrite_section`:保留研究/分析,只生成 replacement candidate 和该章节审校;用户 apply 后才产生新版本。 +- `polish_report`:不重检索;在当前正式版本上创建新版本,并再次执行模板/引用 lint。 +- `update_requirements` / `replan`:均先展示整图影响与高成本确认;前者写入新的 requirement revision 后重开冻结计划,后者重开当前计划全部节点。生成新候选 plan/run branch 的交互留在后续规划版本能力,不能在旧 run 的 SSE 内偷偷切换 run。 +- `ask_report`:只读取当前已验证成果或报告版本,不改变任务图。 Safe-point: @@ -851,15 +1018,19 @@ Safe-point: - 不可立即取消的 attempt 继续到边界,command 状态为 `accepted_waiting_boundary`。 - 晚到输出可保存但标记 superseded,不能进入下游。 - 每条 command 有分类、影响范围、状态和结果。 +- command overlay 仅在模型调用之间、工具返回后或节点提交成果前生效;在不可中断工具期间不能伪称已经完成修改。 验收: - 低置信度“改一下”必须追问。 - 相同幂等键不重复创建返工任务。 - 运行中命令不能造成 completed/cancelled 状态竞争。 +- 用户针对画布/成员卡的定向修改只影响解析出的节点和成果血缘,不能被协调者扩大为整场重跑。 ### BE-14:报告模板、写作、审稿和版本 +**状态:完成(`RC-BE-015`)。** 改写 / apply / restore 已挂载;正式版本在质量门通过后落库。 + 复用 `/api/report-structures`,run 开始时冻结模板快照: - 章节层级。 @@ -871,25 +1042,25 @@ Safe-point: 写作规则: -- Writer 只能使用 validated artifacts。 -- 关键判断绑定 evidence ID。 -- 无法证实的内容明确标为分析、假设或局限。 -- 每章获得确定 evidence set。 -- 完整报告执行引用、模板、衔接和结论一致性检查。 -- Reviewer 输出结构化 ReviewDecision。 -- Reviser 只修改指定问题。 +- Writer 只能使用冻结模板、`CoverageMatrix` 和 validated artifacts,禁止携带 Web/知识库工具或原始检索上下文进入写作提示词。 +- 关键判断绑定 evidence ID;无法证实的内容明确标为分析、假设或局限。 +- 每章获得确定 evidence set、字数目标和必答问题,输出章节草稿及主张—引用映射。 +- 完整报告先执行 CitationTemplateLinter,再由独立 Reviewer 进行事实、逻辑、受众适配和结论审校。 +- Reviewer 输出结构化 ReviewDecision;Reviser 只修改指定问题,并由同一质量门复验。 - 达到返工上限后报告未解决问题,不无限循环。 版本规则: - 流式 Markdown 是临时草稿,不覆盖当前正式版本。 -- 正式版本落库并确认后 run 才能 completed。 +- 正式版本须在所有质量门通过后落库,随后才发 `report.version.created` 并令 run completed。 - 章节改写生成 replacement candidate。 - 用户 apply 后创建新版本。 - restore 也创建新头版本,不破坏历史。 ### BE-15:安全、审计和预算 +**状态:完成(`RC-BE-016`)。** 快照与 SSE 走统一脱敏;资源 owner、跨会话幂等、预算上限与审计已落地。 + - 所有资源校验 owner 或明确授权。 - agent snapshot 移除密钥和不可见 prompt。 - 工具参数/结果完整镜像到用户消息流;只有密钥、令牌、隐私字段、越权内容和明确的异常堆栈由统一脱敏器替换并记录原因。不能以大小截断或摘要替代用户有权查看的返回;超大返回以可重放 chunk 传输和按需折叠展示,正文仍可完整读取。 @@ -897,10 +1068,12 @@ Safe-point: - 每用户 active run 上限。 - 每 run 团队、并发、检索、来源正文、模型调用、token、费用和总时长上限。 - 审计 agent 选择、工具调用、来源、用户命令、人工确认和报告版本。 -- 不持久化、不下发原始 chain-of-thought、系统/内部 prompt 或模型推理 token;这不影响用户可见 assistant 文本、TeamSay、工具调用与工具返回的完整镜像。 +- 不持久化、不下发原始 chain-of-thought、系统/内部 prompt 或模型隐藏推理 token;前端可显示的 `reasoning_delta` 必须是经过独立策略筛选的公开工作说明。这不影响用户可见 assistant 文本、TeamSay、工具调用与工具返回的完整镜像。 ### BE-16:测试与质量评测 +**状态:完成(`RC-BE-018`)。** 合同 / 适配器 / 规划 / 需求 / SSE / lease / 20 项标注评测集已有。打分器不调用模型;金标必须过门,会商风失败实例必须可复现。 + 单元测试: - Pydantic 合同。 @@ -912,6 +1085,8 @@ Safe-point: - AgentScope event adapter。 - 同一 `message_id` 的 token delta、同一 `tool_call_id` 的参数/结果 delta 以及脱敏字段替换;断线重放后消息正文不得被摘要化或重复。 - 两个 `agentRunId` 并行交错输出、其中一员等待用户协助时,消息归属、工具配对、状态和命令目标均正确。 +- `session.snapshot` 增量 `agent_runs[]` 能驱动前端阶段卡从 pending → running → waiting_input → terminal;`node_run_id` 与画布节点前缀匹配。 +- 流式中断后每个已创建 tool call 都有终态 tool result,`MessageList` 不留下旋转步骤。 集成测试: @@ -925,7 +1100,7 @@ Safe-point: 真实 AgentScope 冒烟: - 至少两个已配置模型。 -- leader + 多 worker。 +- ledger 驱动的多 worker(不依赖 AgentScope Team 自动分派)。 - 并发工具和 TeamSay。 - 取消、超时、异常成员和结果修复。 @@ -934,19 +1109,24 @@ Safe-point: - 不少于 20 个真实任务。 - 覆盖政策影响、市场趋势、企业研究、事件研判、对比分析。 - 每个任务记录必选角度、关键事实、禁止编造项、模板和评审规则。 -- 与现有多智能体会商进行盲评。 -- 指标:事实准确、来源质量、角度覆盖、逻辑一致、格式遵循、可修改性、耗时和成本。 +- 与现有多智能体会商进行盲评,并保留每项失败的可复现实例。 +- 指标:事实准确、来源质量、关键主张引用覆盖、角度覆盖、逻辑一致、模板遵循、定向修改正确率、耗时和成本。 +- 上线前为每个任务标注关键主张/数字、可接受来源、必选角度和模板约束;不能只以主观“文章更像报告”判定成功。 --- ## 9. 首个纵向 POC +POC-1 的规则路径已通(三策略差异方案、select ≠ start)。**POC-2 脚本闭环已通**(研究员 → 撰写员,协调者建议不调度)。**POC-3 质量角色已通**(核验 / 分析 / 审稿定向返工,确定性内核)。真实检索与 AgentScope 成员仍待接。**POC-4 未开始**。 + 固定任务: > 撰写一份新能源汽车主要品牌市场趋势分析报告,覆盖 2024 年至今的市场份额、价格策略、技术路线、海外进展、政策环境和未来 12~24 个月风险与机会,面向企业战略决策人员,要求所有关键数字给出来源。 ### POC-1:候选计划 +**状态:规则路径已通(`RC-BE-006`)。** 三策略差异方案、select ≠ start;尚未接规划 LLM。 + - 生成 3 种真实有差异的方案。 - 每个方案只引用有效 agent/tool/template。 - 每个节点有职责、输入、输出和验收条件。 @@ -954,6 +1134,8 @@ Safe-point: ### POC-2:三角色闭环 +**状态:脚本路径已通(`RC-BE-009`),SSE 重放已挂载(`RC-BE-011`)。** 真实 Web 检索仍待工具实装接入运行器。 + 角色:Coordinator、Researcher、Writer。 - 完成真实 Web 检索。 @@ -964,6 +1146,8 @@ Safe-point: ### POC-3:质量角色 +**状态:完成(`RC-BE-010` 质检 + `RC-BE-013` 受控成员 + `RC-BE-017` 真实内核)。** `QualityRoleKernel` 跑通 Verifier / Analyst / Writer / Reviewer / Reviser;过期与跨包冲突写入 `VerificationResult`;缺失角度只重开检索链;审稿失败只重开目标写作节点。生产 worker 使用 `AgentScopeMemberKernel`。 + 加入 Verifier、Analyst、Reviewer、Reviser。 - 过期资料被识别。 @@ -973,6 +1157,8 @@ Safe-point: ### POC-4:全过程干预 +**状态:命令与 safe-point 路径完成(`RC-BE-014`)。** 增加角度、指定角度重检索、章节重写/润色的定向影响已覆盖;运行中旧 attempt 在边界后 supersede,晚到成果不能进入下游。正式报告 replacement/version 展示已在 `RC-BE-015` 落地。 + 必须演示: 1. “增加供应链与原材料风险角度”。 @@ -986,28 +1172,36 @@ Safe-point: ## 10. 后端开发顺序 -1. `RC-BE-000`:AgentScope 版本与兼容性尖峰。 +已完成: + +1. `RC-BE-000`:AgentScope 版本与兼容性尖峰(条件 Go;未写入 `uv.lock`)。 2. `RC-BE-001`:领域合同和状态机。 3. `RC-BE-002`:配置、依赖和 feature flag。 4. `RC-BE-003`:数据库迁移和 stores。 -5. `RC-BE-004`:session/message/plan API。 +5. `RC-BE-004`:session/message/plan API(改写 / 恢复见 `015`)。 6. `RC-BE-005`:需求解析与澄清。 7. `RC-BE-006`:agent catalog、selector、plan assembler/validator。 8. `RC-BE-007`:AgentScope model adapter。 9. `RC-BE-008`:tool/permission adapter。 -10. `RC-BE-009`:三角色 team POC。 -11. `RC-BE-010`:task ledger、artifact board、quality gate。 +10. `RC-BE-009`:三角色 team POC(脚本内核 + TaskLedger 波次)。 +11. `RC-BE-010`:artifact board 主张级质量门(Coverage / 核验 / 审稿)。 12. `RC-BE-011`:durable event、原生消息镜像、SSE 和 live hub。 13. `RC-BE-012`:dispatcher、executor、lease、cancel、recovery。 14. `RC-BE-013`:Verifier/Analyst/Reviewer/Reviser。 -15. `RC-BE-014`:intent router、impact analyzer、safe-point commands。 -16. `RC-BE-015`:report structures、report versions、rewrite candidates。 -17. `RC-BE-016`:安全、审计、预算、监控。 -18. `RC-BE-017`:并发、故障恢复和真实 AgentScope 测试。 -19. `RC-BE-018`:20 项质量评测和参数调优。 + +15. `RC-BE-014`:intent router、impact analyzer、safe-point commands(已完成)。 +16. `RC-BE-015`:report structures、report versions、rewrite candidates(已完成)。 +17. `RC-BE-016`:安全、审计、预算、监控(已完成)。 +18. `RC-BE-017`:并发、故障恢复和真实 AgentScope 测试(已完成)。 +19. `RC-BE-018`:20 项质量评测和参数调优(已完成)。 + +下一票起: + 20. `RC-BE-019`:灰度与离线发布接入。 -前后端共同依赖:在 `RC-BE-001` 后冻结 OpenAPI、event envelope 和共享枚举,前端再开始真实 API 层开发。 +编号说明:§8 的 `BE-n` 与上表 `RC-BE-00n` 在 **008 之后不完全一一对应**(例如 §8 `BE-11` dispatcher ≈ `RC-BE-012`,`BE-12` SSE ≈ `RC-BE-011`)。排期与代码以 `RC-BE-*` 为准。 + +前后端共同依赖:OpenAPI / event envelope / 共享枚举已按 `frontend-web/src/report-collaboration/api/types.ts` 冻结;前端页面与 mock 已完成。真实 SSE 可联调 `GET /runs/{id}/stream`(`RC-BE-011`);领取/恢复见 `RC-BE-012`;质量角色见 `RC-BE-013`;运行中自然语言命令见 `RC-BE-014`。正式报告改写候选与版本 apply/restore 已在 `RC-BE-015` 挂载。生产默认仍不挂后台循环(`worker_enabled=false`)。 --- @@ -1082,7 +1276,7 @@ TeamSay 不作为事实来源;报告事实只能引用 validated EvidenceBundl 后端只有同时满足以下条件才算完成: -1. 使用 AgentScope 2.0 Core 组织真实 leader-worker 团队。 +1. 使用 AgentScope 2.0 Core 执行真实的受控任务成员,且由 DeerFlow `TaskLedger` 而非自治 Team 调度协作。 2. 不调用旧 Workflow Studio DAG scheduler。 3. 不改变现有多智能体会商行为。 4. DeerFlow 数据库是状态唯一事实源。 diff --git a/frontend-web/.env.development b/frontend-web/.env.development index b78f859a..5077f8d4 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=true VITE_BACKEND_BASE_URL=http://127.0.0.1:8001 VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api +# 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。 +VITE_USE_OFFICIAL_DEERFLOW_ENTRY=true + # 轻应用「新建笔记」系列接口 VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 # CMZS后端接口 diff --git a/frontend-web/.env.example b/frontend-web/.env.example index 415c1a07..554254f4 100644 --- a/frontend-web/.env.example +++ b/frontend-web/.env.example @@ -60,3 +60,9 @@ # 不填或留空时按类型 A,继续使用原来的简介菜单数据。 # VITE_COMPACT_MENU_TYPE=A # VITE_GITHUB_OAUTH_TOKEN= + +# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)--- +# true 时,普通登录后进入同一 Vite 应用中隔离的 /official/*。 +# 已指定 redirect 或 taskId/goPath 的登录仍保持原来的优先级。 +# 仅 .env.development 默认 true;其余环境保持 false。 +# VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false diff --git a/frontend-web/.env.network b/frontend-web/.env.network index 9f770d29..a7da4f8a 100644 --- a/frontend-web/.env.network +++ b/frontend-web/.env.network @@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=false VITE_BACKEND_BASE_URL=http://10.19.200.5:2026 VITE_LANGGRAPH_BASE_URL=http://10.19.200.5:2026/api +# 内网环境保持当前定制版默认入口。 +VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false + # 轻应用「新建笔记」系列接口 VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 # CMZS后端接口 diff --git a/frontend-web/.env.production b/frontend-web/.env.production index a096e3b5..43085943 100644 --- a/frontend-web/.env.production +++ b/frontend-web/.env.production @@ -14,6 +14,9 @@ VITE_MOCK_SQ_REPORT=false VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api +# 线上保持当前定制版默认入口。 +VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false + # 轻应用「新建笔记」系列接口 VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api # CMZS后端接口 diff --git a/frontend-web/.env.sit b/frontend-web/.env.sit index 31dda6ed..67d0332b 100644 --- a/frontend-web/.env.sit +++ b/frontend-web/.env.sit @@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=false VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api +# SIT 环境保持当前定制版默认入口。 +VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false + # 轻应用「新建笔记」系列接口 VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api # CMZS后端接口 diff --git a/frontend-web/package.json b/frontend-web/package.json index fb189ccb..28755b89 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -11,6 +11,7 @@ "build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network", "preview": "vite preview", "typecheck": "tsc --noEmit", + "test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs", "check:chrome109-css": "node scripts/check-chrome109-css.mjs", "check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs" }, diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 7cdb7fa7..679263a3 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -45,6 +45,7 @@ import { } from "@/position-roundtable/demo/position-roundtable-demo-config"; import { ChatRuntime } from "./pages/WorkspaceRoutes"; import { NotFoundPage } from "./pages/NotFoundPage"; +import OfficialDeerFlowEntry from "./pages/OfficialDeerFlowEntry"; const PUBLIC_EMBED_PREFIXES = [ "/share/", @@ -203,6 +204,7 @@ export function App() { path="/roundtable-dashboard" element={} /> + } /> } /> diff --git a/frontend-web/src/assets/deer.svg b/frontend-web/src/assets/deer.svg new file mode 100644 index 00000000..9bbfb291 --- /dev/null +++ b/frontend-web/src/assets/deer.svg @@ -0,0 +1,6 @@ + + + + diff --git a/frontend-web/src/components/landing/hero.tsx b/frontend-web/src/components/landing/hero.tsx index afa80e20..de049ec2 100644 --- a/frontend-web/src/components/landing/hero.tsx +++ b/frontend-web/src/components/landing/hero.tsx @@ -3,6 +3,7 @@ import { ChevronRightIcon } from "lucide-react"; import Link from "next/link"; +import deerMaskUrl from "@/assets/deer.svg"; import { Button } from "@/components/ui/button"; import { FlickeringGrid } from "@/components/ui/flickering-grid"; import Galaxy from "@/components/ui/galaxy"; @@ -29,7 +30,11 @@ export function Hero({ className }: { className?: string }) { /> menuItemContainsPath(item, pathname); @@ -270,6 +270,12 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) { 模型配置 )} + {isAdmin && ( + openSettings("encoder")}> + + 本地编码器 + + )} {isAdmin && ( navigate("/page/strategy/settings/model-concurrency")} @@ -585,6 +591,12 @@ function LegacyAccordionSidebar({ 模型配置 )} + {isAdmin && ( + openSettings("encoder")}> + + 本地编码器 + + )} {isAdmin && ( navigate("/page/strategy/admin/login-whitelist")}> diff --git a/frontend-web/src/components/workspace/appearance-controls.tsx b/frontend-web/src/components/workspace/appearance-controls.tsx index a8ff1097..77864f64 100644 --- a/frontend-web/src/components/workspace/appearance-controls.tsx +++ b/frontend-web/src/components/workspace/appearance-controls.tsx @@ -223,22 +223,19 @@ const LAYOUT_OPTIONS = [ * 界面模式(全量 / 简洁)下拉切换。换肤旁并排,图标 + 下拉: * - 简洁模式 ChatPage 顶栏(全局顶栏被隐藏)。 * - 全量模式 Header 右上角。 - * 用户未显式设置过界面模式时(mode === undefined),当前态按调用方所在的实际界面 - * 兜底——`fallback` 指明这一回退值(Header 传 "full",ChatPage 传 "compact")。 + * 当前态为有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认)—— + * useLayoutMode 总会返回具体值,不再有 undefined 兜底。 */ export function LayoutModeSwitcher({ tone, - fallback = "compact", navigateTo = "/page/workspace/chats/new", }: { tone: AppearanceControlTone; - fallback?: "full" | "compact"; navigateTo?: string | null; }) { const [mode, setLayoutMode] = useLayoutMode(); const navigate = useNavigate(); - const current = mode ?? fallback; - const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon; + const TriggerIcon = mode === "full" ? PanelLeftIcon : LayoutIcon; if (env.FORCE_COLLAPSED_SIDEBAR || isHideChromeActive()) return null; @@ -262,7 +259,7 @@ export function LayoutModeSwitcher({ {LAYOUT_OPTIONS.map((option) => { const Icon = option.icon; - const active = current === option.id; + const active = mode === option.id; return ( )} - {(artifactContentPending || streamingWriteContentPending) && viewMode === "code" && ( -
-
)} - {isCodeFile && viewMode === "code" && !artifactContentPending && !streamingWriteContentPending && !artifactContentUnavailable && ( + {isCodeFile && viewMode === "code" && !artifactContentPending && !streamingWriteContentPending && !artifactContentUnavailable && (
(); const [mermaidSvg, setMermaidSvg] = useState(null); const [mermaidError, setMermaidError] = useState(null); + const markdownPreviewRef = useRef(null); + const mermaidChartRef = useRef(null); + useStreamdownMermaidPngDownload(markdownPreviewRef); const researchSourcesQuery = useResearchSources( language === "markdown" ? (researchSessionId ?? null) : null, ); @@ -2135,6 +2139,7 @@ export function ArtifactFilePreview({ if (language === "markdown") { return (
{mermaidSvg ? ( -
+
+
+ + +
+
+
) : mermaidError ? (
diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index a598c16a..705bdcbd 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -25,7 +25,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 { @@ -130,9 +129,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], ); @@ -1007,8 +1005,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; } @@ -1059,7 +1056,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/knowledge-product-nav.tsx b/frontend-web/src/components/workspace/knowledge-product-nav.tsx index 96af3aca..97b191cc 100644 --- a/frontend-web/src/components/workspace/knowledge-product-nav.tsx +++ b/frontend-web/src/components/workspace/knowledge-product-nav.tsx @@ -6,13 +6,22 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { getStoredAuth } from "@/core/auth"; +import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki"; import { loadKnowledgeBaseProductVersion, + loadOrdinaryKnowledgeLanding, + saveOrdinaryKnowledgeLanding, + type OrdinaryKnowledgeLanding, type KnowledgeBaseProductVersion, updateKnowledgeBaseProductVersion, } from "@/core/system-settings/api"; export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const; +const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const; + +export function useOrdinaryKnowledgeLanding() { + return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 }); +} export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal"; export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`; @@ -25,6 +34,14 @@ export function KnowledgeProductNav() { const navigate = useNavigate(); const client = useQueryClient(); const version = useKnowledgeBaseProductVersion(); + const landing = useOrdinaryKnowledgeLanding(); + const saveLanding = useMutation({ + mutationFn: saveOrdinaryKnowledgeLanding, + onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); }, + onError: (error) => toast.error(error.message), + }); + const runtime = useLlmWikiRuntime(); + const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim()); const isAdmin = getStoredAuth()?.system_role === "admin"; const save = useMutation({ mutationFn: updateKnowledgeBaseProductVersion, @@ -36,15 +53,20 @@ export function KnowledgeProductNav() { const assistant = location.pathname.includes("/knowledge/assistant"); return ( -
- {isAdmin && ( -
+
+ 普通知识库默认页面 + 默认产品版本 setReviewMode(value as typeof reviewMode)}> - - - 高置信自动通过 - 不阻塞写入 - - +
+ 第三步:提交后可离开本页。归纳、Wiki 生成、向量化及同步到“知识梳理”总库均在后台完成。
-
@@ -228,22 +239,26 @@ export function SkillKnowledgePanel() {
-

归纳任务

后台执行、失败可单项重试

+

我的归纳任务

任务会自动刷新,失败项可直接重试

{(jobs.data ?? []).map((job) => { - const done = job.success_count + job.partial_failed_count + job.failed_count; + const countedDone = job.success_count + job.partial_failed_count + job.failed_count; + const itemDone = (job.items ?? []).filter((item) => + ["completed", "partial_failed", "failed", "canceled"].includes(item.status), + ).length; + const done = Math.max(countedDone, itemDone); const progress = job.total_count ? Math.round((done / job.total_count) * 100) : 0; return (
-
{job.status}{done}/{job.total_count} 项{new Date(job.created_at).toLocaleString()}
+
{STATUS_LABELS[job.status] || job.status}{done}/{job.total_count} 项{new Date(job.created_at).toLocaleString()}
{job.items?.map((item) => (
{item.skill_name} - {PHASE_LABELS[item.phase] || item.phase} + {PHASE_LABELS[item.phase] || STATUS_LABELS[item.status] || item.phase} {item.progress}% {item.status === "failed" && }
@@ -256,8 +271,8 @@ export function SkillKnowledgePanel() {
-
当前有效绑定:{bindings.data?.length ?? 0};同名同类型实体按来源贡献合并。
-
{bindings.data?.map((binding) =>
{binding.skill_name}→ {binding.target_name}{binding.status}{binding.remote_business_key}
)}
+
已归纳关联:{bindings.data?.length ?? 0}
+
{bindings.data?.map((binding) =>
{binding.skill_name}已归纳到 {binding.target_name}{STATUS_LABELS[binding.status] || binding.status}{adminMode && }
)}
diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index 888c79e5..bcd222ed 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -14,7 +14,7 @@ import { SidebarMenuItem, } from "@/components/ui/sidebar"; import { useI18n } from "@/core/i18n/hooks"; -import { BookOpen, BotIcon, BrainIcon, FileSearch, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react"; +import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react"; import { KnowledgeRagToggle } from "./knowledge-rag-toggle"; import { SidebarCollapseButton } from "./workspace-header"; @@ -108,6 +108,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [ match: (pathname) => pathname.startsWith("/page/workspace/deep-research"), }, // { + // id: "workspace-report-collaboration", + // label: "报告协作", + // icon: FileText, + // path: "/page/workspace/report-collaboration", + // match: (pathname) => pathname.startsWith("/page/workspace/report-collaboration"), + // }, + // { // id: "workspace-enterprise-research", // label: "企业深度研究", // icon: FileSearch, diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx index d3f48f8e..57c71201 100644 --- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx @@ -83,7 +83,7 @@ export function WorkspaceNavMenu() { const isAdmin = getStoredAuth()?.system_role === "admin"; const [settingsOpen, setSettingsOpen] = useState(false); const [settingsDefaultSection, setSettingsDefaultSection] = useState< - "appearance" | "memory" | "tools" | "about" + "appearance" | "memory" | "tools" | "encoder" | "about" >("appearance"); const [notificationOpen, setNotificationOpen] = useState(false); const [mounted, setMounted] = useState(false); @@ -189,6 +189,17 @@ export function WorkspaceNavMenu() { {t.common.settings} + {isAdmin && ( + { + setSettingsDefaultSection("encoder"); + setSettingsOpen(true); + }} + > + + 本地编码器 + + )} {isAdmin && ( @@ -254,14 +265,6 @@ export function WorkspaceNavMenu() { 技能管理 )} - {isAdmin && ( - navigate("/page/workspace/admin/skills?tab=knowledge")} - > - - 技能到知识库管理 - - )} {isAdmin && ( navigate("/page/workspace/admin/models")} diff --git a/frontend-web/src/core/auth/agentfx-deeplink.ts b/frontend-web/src/core/auth/agentfx-deeplink.ts index dfc94eaa..e6f2b8e3 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-build 一条命令转换成 14 类 task-reports.json → 写 report.md → 总结 → 前端「入库」协助卡 / * task-report-import 技能入库),不走「配置管理」业务映射。 * 3. 路由挂在 ``plan-task-agentfx`` 前缀下,页面代码见 [[AgentFxWorkspace]]。 * diff --git a/frontend-web/src/core/auth/task-buttons.ts b/frontend-web/src/core/auth/task-buttons.ts index 4246bb43..0bc52306 100644 --- a/frontend-web/src/core/auth/task-buttons.ts +++ b/frontend-web/src/core/auth/task-buttons.ts @@ -109,8 +109,8 @@ export interface TaskButton { /** 追加的查询参数名(如 ``id`` / ``task_id``);空则用 ``id``。 */ idParam: string; /** - * ``url`` 追加 id 时取任务id 还是行动id。仅 xdfx(行动分析)二者不同:深链原始 ``?taskId=`` - * 实为行动id,任务id 由 action 接口解析。其它业务两者相同。 + * ``url`` / ``host`` 追加 id 时取任务id 还是行动id。仅 xdfx(行动分析)二者不同:深链原始 + * ``?taskId=`` 实为行动id,任务id 由 action 接口解析。其它业务两者相同。 */ idKind: TaskButtonIdKind; /** ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口。仅 ``url`` 类型有意义。 */ @@ -327,7 +327,8 @@ export interface RunTaskButtonCtx { * 执行一个跳转按钮: * - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。 * - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。 - * - ``host``:向 ``window.parent`` 发 ``HOST_NAVIGATE``,由宿主系统跳转(仅 iframe 嵌入时生效)。 + * - ``host``:向 ``window.parent`` 发 ``HOST_NAVIGATE``,由宿主系统跳转(仅 iframe 嵌入时生效; + * 追加 id 时同样按 ``idKind`` 取任务id / 行动id)。 */ export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise { const fail = (m: string) => ctx.onError?.(m); @@ -355,9 +356,11 @@ export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Pro } const queryParams: Record = {}; if (btn.appendTaskId) { - const id = parseHostTaskId(ctx.taskId); + // 行动分析(xdfx)可选发行动id;与 url 外链同规则:行动id 缺失时回退任务id。 + const raw = btn.idKind === "action" ? ctx.actionId || ctx.taskId : ctx.taskId; + const id = parseHostTaskId(raw); if (id === null) { - fail("任务id不是数字,无法跳转宿主系统"); + fail(`${btn.idKind === "action" ? "行动id" : "任务id"}不是数字,无法跳转宿主系统`); return; } queryParams[(btn.idParam || "id").trim() || "id"] = id; diff --git a/frontend-web/src/core/messages/utils-group.test.ts b/frontend-web/src/core/messages/utils-group.test.ts index 8d2e212d..e1e3f179 100644 --- a/frontend-web/src/core/messages/utils-group.test.ts +++ b/frontend-web/src/core/messages/utils-group.test.ts @@ -111,6 +111,28 @@ test("a lone deep-research progress bar still opens its own group", () => { assert.deepEqual(types, ["assistant:processing"]); }); +test("multi-round RAG assistant rows render even while the final answer is still empty", () => { + const types = groupTypes([ + { type: "human", id: "h1", content: "检索一下", additional_kwargs: { multi_round_rag: true } } as Message, + { + type: "ai", + id: "a1", + content: "", + additional_kwargs: { + multi_round_rag: true, + multi_round_rag_trace: { + title: "多轮检索思考过程", + rounds: [{ id: "r1", title: "思考过程", thoughtMarkdown: "正在拆解问题", query: "", queryShow: true, retrievals: [] }], + finalAnswerMarkdown: "", + thinkingComplete: false, + }, + }, + } as Message, + ]); + + assert.deepEqual(types, ["human", "assistant"]); +}); + test("tool messages still attach to an open processing group", () => { const types = groupTypes([ { diff --git a/frontend-web/src/core/messages/utils.ts b/frontend-web/src/core/messages/utils.ts index 5e3369b8..c971b8a2 100644 --- a/frontend-web/src/core/messages/utils.ts +++ b/frontend-web/src/core/messages/utils.ts @@ -28,6 +28,8 @@ interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant: interface AssistantTaskReportImportGroup extends GenericMessageGroup<"assistant:task-report-import"> {} +interface AssistantBusinessCardGroup extends GenericMessageGroup<"assistant:business-card"> {} + interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {} type MessageGroup = @@ -41,6 +43,7 @@ type MessageGroup = | AssistantSetupReportStructureApprovalGroup | AssistantResearchApprovalGroup | AssistantTaskReportImportGroup + | AssistantBusinessCardGroup | AssistantSubagentGroup; export function groupMessages( @@ -65,7 +68,8 @@ export function groupMessages( last.type !== "assistant:setup-agent-approval" && last.type !== "assistant:setup-writing-approval" && last.type !== "assistant:setup-report-structure-approval" && - last.type !== "assistant:research-approval" + last.type !== "assistant:research-approval" && + last.type !== "assistant:business-card" ) { return last; } @@ -137,6 +141,16 @@ export function groupMessages( } if (message.type === "ai") { + // Multi-round RAG keeps rounds/retrievals on additional_kwargs while the + // visible answer is still empty. The default rule below only opens an + // assistant bubble when hasContent() is true, which would swallow the + // live thinking cards until the final answer arrives (or forever, if the + // turn is think/search-only). Force a normal assistant row so + // MessageListItem can render MultiRoundRagAssistantBody immediately. + if (message.additional_kwargs?.multi_round_rag === true) { + groups.push({ id: message.id, type: "assistant", messages: [message] }); + continue; + } // Deep-research interaction card: virtual ai messages injected by the // workbench (additional_kwargs.setup_research_approval) render through // the host-provided slot. Writing phases use ordinary virtual tool calls @@ -159,6 +173,17 @@ export function groupMessages( }); continue; } + // 通用业务卡(报告协作方案组/命令确认/报告入口等):虚拟 ai 消息 + // (additional_kwargs.report_collaboration_card),由宿主注入并经 + // businessCardSlot 渲染;不传插槽的页面零渲染、零行为变化。 + if (hasBusinessCard(message)) { + groups.push({ + id: message.id, + type: "assistant:business-card", + messages: [message], + }); + continue; + } if (hasPresentFiles(message)) { groups.push({ id: message.id, @@ -487,6 +512,19 @@ export function hasTaskReportImportApproval(message: Message) { ); } +/** + * 通用业务卡(报告协作等页面):宿主注入的虚拟 ai 消息 + * (additional_kwargs.report_collaboration_card),经 MessageList 的 + * businessCardSlot 渲染。集中在此识别,业务卡 payload 结构由各页面自定义。 + */ +export function hasBusinessCard(message: Message) { + return ( + message.type === "ai" && + typeof message.additional_kwargs?.report_collaboration_card === "object" && + message.additional_kwargs.report_collaboration_card !== null + ); +} + export function extractPresentFilesFromMessage(message: Message) { if (message.type !== "ai" || !hasPresentFiles(message)) { return []; diff --git a/frontend-web/src/core/official-deerflow/routes.ts b/frontend-web/src/core/official-deerflow/routes.ts new file mode 100644 index 00000000..f684f189 --- /dev/null +++ b/frontend-web/src/core/official-deerflow/routes.ts @@ -0,0 +1,13 @@ +/** + * Hash-router entry used by the customized Vite application for the isolated + * official-style UI. Keeping this prefix distinct from `/page/*` prevents its + * routes and mock state from altering the customized application. + */ +export const OFFICIAL_DEERFLOW_ROUTE_PREFIX = "/official"; + +/** The official-style workspace home shown after a normal login. */ +export const OFFICIAL_DEERFLOW_HOME_ROUTE = + `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`; + +/** Existing customized application's normal post-login destination. */ +export const CUSTOMIZED_DEERFLOW_HOME_ROUTE = "/page/workspace/chats/new"; diff --git a/frontend-web/src/core/scheduled-tasks/markdown-docx.ts b/frontend-web/src/core/scheduled-tasks/markdown-docx.ts index 4116c66d..0ae977bd 100644 --- a/frontend-web/src/core/scheduled-tasks/markdown-docx.ts +++ b/frontend-web/src/core/scheduled-tasks/markdown-docx.ts @@ -10,6 +10,7 @@ import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; import { OptType, writeYLog } from "@/core/log/y-log"; +import { replaceMermaidFencesForWordExport } from "@/core/utils/mermaid"; const DOCX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; @@ -38,13 +39,15 @@ export async function markdownToDocxBlob( const source = markdown?.trim(); if (!source) throw new Error("文档内容为空"); + const prepared = await replaceMermaidFencesForWordExport(source); const endpoint = `${getBackendBaseURL()}/api/writing/export/docx`; const response = await apiFetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: title?.trim() || undefined, - markdown: source, + markdown: prepared.markdown, + embedded_images: prepared.embeddedImages, }), }); if (!response.ok) throw await responseError(response); diff --git a/frontend-web/src/core/streamdown/index.ts b/frontend-web/src/core/streamdown/index.ts index 2b034c65..f15b987d 100644 --- a/frontend-web/src/core/streamdown/index.ts +++ b/frontend-web/src/core/streamdown/index.ts @@ -1 +1,2 @@ export * from "./plugins"; +export * from "./mermaid-png-download"; diff --git a/frontend-web/src/core/streamdown/mermaid-png-download.ts b/frontend-web/src/core/streamdown/mermaid-png-download.ts new file mode 100644 index 00000000..33eddc51 --- /dev/null +++ b/frontend-web/src/core/streamdown/mermaid-png-download.ts @@ -0,0 +1,56 @@ +import { useEffect, type RefObject } from "react"; +import { toast } from "sonner"; + +import { downloadSvgElementAsPng } from "@/core/utils/mermaid"; + +function findStreamdownMermaidDownloadBlock(target: EventTarget | null): HTMLElement | null { + if (!(target instanceof Element)) return null; + const button = target.closest("button"); + if (!(button instanceof HTMLButtonElement)) return null; + if (button.getAttribute("title") !== "Download file") return null; + const block = button.closest('[data-streamdown="mermaid-block"]'); + return block instanceof HTMLElement ? block : null; +} + +function findRenderedMermaidChartSvg(block: HTMLElement): SVGSVGElement | null { + const fromChart = block.querySelector('[aria-label="Mermaid chart"] svg, [role="img"] svg'); + if (fromChart instanceof SVGSVGElement && !fromChart.closest("button")) { + return fromChart; + } + for (const svg of block.querySelectorAll("svg")) { + if (svg instanceof SVGSVGElement && !svg.closest("button")) { + return svg; + } + } + return null; +} + +/** Intercept Streamdown mermaid download buttons and save a PNG of the chart. */ +export function useStreamdownMermaidPngDownload(rootRef: RefObject) { + useEffect(() => { + const onClickCapture = (event: MouseEvent) => { + const root = rootRef.current; + if (!root || !root.contains(event.target as Node)) return; + const block = findStreamdownMermaidDownloadBlock(event.target); + if (!block) return; + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + + const svg = findRenderedMermaidChartSvg(block); + if (!svg) { + toast.error("流程图尚未渲染完成,请稍后再下载"); + return; + } + + void downloadSvgElementAsPng(svg, "flowchart.png").catch((error) => { + console.error("Failed to download mermaid image:", error); + toast.error(error instanceof Error ? error.message : "下载流程图失败"); + }); + }; + + document.addEventListener("click", onClickCapture, true); + return () => document.removeEventListener("click", onClickCapture, true); + }, [rootRef]); +} diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index b12342cb..e6f18342 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -11,6 +11,22 @@ export interface PromptPrefixSettings { export type KnowledgeBaseProductVersion = "v1" | "v2"; +export type OrdinaryKnowledgeLanding = "wiki" | "weknora"; + +export async function loadOrdinaryKnowledgeLanding(): Promise { + const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`); + if (!response.ok) throw new Error("无法读取普通知识库默认页面设置"); + return ((await response.json()) as { mode: OrdinaryKnowledgeLanding }).mode; +} + +export async function saveOrdinaryKnowledgeLanding(mode: OrdinaryKnowledgeLanding): Promise { + const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`, { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode }), + }); + if (!response.ok) throw new Error("保存失败,仅管理员可配置普通知识库默认页面"); + return ((await response.json()) as { mode: OrdinaryKnowledgeLanding }).mode; +} + export async function loadKnowledgeBaseProductVersion(): Promise { const response = await apiFetch( `${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`, diff --git a/frontend-web/src/core/system-settings/hooks.ts b/frontend-web/src/core/system-settings/hooks.ts index 5ec98457..8e51b828 100644 --- a/frontend-web/src/core/system-settings/hooks.ts +++ b/frontend-web/src/core/system-settings/hooks.ts @@ -164,19 +164,15 @@ export function useViewMode( /** * Effective interface layout mode (full chrome vs compact). A user's own choice * (persisted in local settings) wins; otherwise the admin-configured system - * default applies; otherwise `undefined` (callers fall back to the session-level - * `sidebarEnabled` flag set by portal / yUserId login). `setMode` persists the - * user's override locally. + * default applies; otherwise the built-in default is compact (简洁模式). + * `setMode` persists the user's override locally. */ -export function useLayoutMode(): [ - LayoutMode | undefined, - (mode: LayoutMode) => void, -] { +export function useLayoutMode(): [LayoutMode, (mode: LayoutMode) => void] { const [settings, setSettings] = useLocalSettings(); const { data: appearance } = useAppearanceSettings(); const userOverride = settings.display.layoutMode; const adminDefault = appearance?.default_layout_mode; - const mode = userOverride ?? adminDefault; + const mode: LayoutMode = userOverride ?? adminDefault ?? "compact"; const setMode = (next: LayoutMode) => setSettings("display", { layoutMode: next }); return [mode, setMode]; 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..5a3d63c2 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,166 +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 createEmptyRound(title: string, thoughtMarkdown = ""): MultiRoundRagRound { return { - rounds: [ - { - id: createId("multi-rag-round"), - title: "正在准备检索", - thought: "", - body: "", - query: "", - isSearching: true, - retrievals: [], - }, - ], - answer: "", - isComplete: false, - }; -} - -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: "", + title, + thoughtMarkdown, query: "", - isSearching: true, + queryLoading: false, + queryShow: false, 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 createEmptyTrace(): MultiRoundRagTrace { + return { + title: "多轮检索思考过程", + rounds: [createEmptyRound("正在准备检索")], + thinkingComplete: false, + finalAnswerMarkdown: "", + }; } function cloneTrace(trace: MultiRoundRagTrace): MultiRoundRagTrace { @@ -279,62 +175,472 @@ 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 eventsFromLine(line: string): MultiRoundEvent[] { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith(":")) return []; + let payload = trimmed; + if (/^data:\s?/i.test(trimmed)) { + payload = trimmed.replace(/^data:\s?/i, "").trim(); + } else if (/^(id|event|retry):/i.test(trimmed)) { + return []; + } + if (!payload) return []; + if (payload === "[DONE]") return [{ event: "done" }]; + if (payload.startsWith("{") || payload.startsWith("[")) { + try { + return expandStreamEvents(JSON.parse(payload)); + } catch { + return []; + } + } + return expandStreamEvents(payload); +} + +function asStreamEvent(value: unknown): MultiRoundEvent | null { + const parsed = parseJson(value); + if (Array.isArray(parsed)) { + return null; + } + const record = asRecord(parsed); + if (!record) { + if (typeof parsed === "string" && parsed.trim() && !/^[{[]/.test(parsed.trim())) { + return { event: "message", data: parsed }; + } + return null; + } + const eventName = asText(record.event ?? record.type ?? record.event_type); + if (eventName) { + return { + event: eventName, + data: record.data !== undefined ? record.data : (record.payload ?? record), + }; + } + if (record.tool_name || record.name) { + return { event: "tool_call", data: record }; + } + return record; +} + +function expandStreamEvents(value: unknown): MultiRoundEvent[] { + const parsed = parseJson(value); + if (Array.isArray(parsed)) { + return parsed.flatMap((item) => expandStreamEvents(item)); + } + const event = asStreamEvent(parsed); + return event ? [event] : []; +} + +function extractJsonObjects(raw: string): { objects: string[]; rest: string } { + const objects: string[] = []; + let depth = 0; + let stringQuote: '"' | "'" | null = null; + let escaped = false; + let start = -1; + for (let index = 0; index < raw.length; index += 1) { + const char = raw[index]!; + if (stringQuote) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === stringQuote) stringQuote = null; + continue; + } + if (char === '"' || char === "'") { + stringQuote = char; + continue; + } + if (char === "{") { + if (depth === 0) start = index; + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0 && start !== -1) { + objects.push(raw.slice(start, index + 1)); + start = -1; + } + } + } + return depth > 0 && start !== -1 + ? { objects, rest: raw.slice(start) } + : { objects, rest: "" }; +} + +function pullStreamEvents(buffer: string): { events: MultiRoundEvent[]; rest: string } { + const events: MultiRoundEvent[] = []; + const lines = buffer.split(/\r?\n/); + const restLine = lines.pop() ?? ""; + for (const line of lines) { + events.push(...eventsFromLine(line)); + } + if (events.length > 0) { + return { events, rest: restLine }; + } + const extracted = extractJsonObjects(buffer); + for (const jsonText of extracted.objects) { + events.push(...expandStreamEvents(parseJson(jsonText))); + } + return { events, rest: extracted.rest || restLine }; +} + +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, + ); +} + +function mergeThink(existing: string, incoming: string) { + if (!incoming) return existing; + if (!existing) return incoming; + if (incoming.startsWith(existing)) return incoming; + if (existing.endsWith(incoming)) return existing; + return `${existing}${incoming}`; +} + +function extractThinkContent(chatText: string) { + const closed = chatText.match(/([\s\S]*?)<\/think>/i); + if (closed?.[1] != null) return closed[1].trim(); + const open = chatText.match(/([\s\S]*)$/i); + if (open?.[1] != null) return open[1].trim(); + return chatText.replace(/<\/?think>/gi, "").trim(); +} + +/** + * 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) return; + if (chatText.includes("")) { + const thinkContent = extractThinkContent(chatText); + const firstRound = trace.rounds[0]; + const stillOnFirstThink = + firstRound?.title === "正在准备检索" || + ((!round.query && round.retrievals.length === 0) && + (round.title === "思考过程" || round.title === "思考中" || round.title === "正在准备检索")); + if (stillOnFirstThink && firstRound) { + firstRound.title = "思考过程"; + firstRound.thoughtMarkdown = mergeThink(firstRound.thoughtMarkdown, thinkContent); + } else if (!round.query && round.retrievals.length === 0) { + round.title = round.title === "正在准备检索" ? "思考过程" : round.title; + round.thoughtMarkdown = mergeThink(round.thoughtMarkdown, thinkContent); + } else { + round.queryLoading = false; + round.queryShow = Boolean(round.query) || round.retrievals.length > 0; + trace.rounds.push(createEmptyRound("思考中", 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(" - "); +function consumeToolCallPayload(trace: MultiRoundRagTrace, payload: unknown) { + const parsed = parseJson(payload); + if (Array.isArray(parsed)) { + for (const tool of parsed) applyMultiRoundToolCall(trace, tool); + return; } - return asText(resultRecord?.query ?? resultRecord?.q); + const record = asRecord(parsed); + if (record?.tool_name || record?.name) { + applyMultiRoundToolCall(trace, record); + return; + } + if (record?.data != null) { + consumeToolCallPayload(trace, record.data); + return; + } + applyMultiRoundToolCall(trace, parsed); } function textFromEventData(value: unknown): string { @@ -344,35 +650,119 @@ 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 = normalizeToolName(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); + } else if (asRecord(data)?.tool_name || asRecord(parseJson(data))?.tool_name) { + consumeToolCallPayload(trace, data); + } } -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", + Accept: "text/event-stream, application/json, text/plain", + }, + 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 pulled = pullStreamEvents(buffer); + buffer = pulled.rest; + for (const event of pulled.events) { + yield event; + if (normalizeToolName(asText(event.event)) === "done") return; + } + } + buffer += decoder.decode(); + const pulled = pullStreamEvents(`${buffer}\n`); + for (const event of pulled.events) { + yield event; + } + if (pulled.rest.trim()) { + for (const event of [ + ...eventsFromLine(pulled.rest), + ...expandStreamEvents(parseJson(pulled.rest)), + ]) { + 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); const controllerRef = useRef(null); + const inFlightThreadIdRef = useRef(null); useEffect(() => { + if (inFlightThreadIdRef.current === threadId) return; const stored = readStoredMessages(threadId); messagesRef.current = stored; setMessages(stored); @@ -386,7 +776,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,319 +789,179 @@ export function useMultiRoundRagMessages(threadId: string) { const stop = useCallback(() => controllerRef.current?.abort(), []); + 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 trace = createEmptyTrace(); + let sources: MultiRoundRagSource[] = []; + const timestamp = new Date().toISOString(); + const userMessage: MultiRoundRagChatMessage = { + id: humanId, + role: "user", + content: question, + timestamp, + }; + const assistantMessage: MultiRoundRagChatMessage = { + id: assistantId, + role: "assistant", + content: "", + timestamp, + thinkingTrace: cloneTrace(trace), + }; + + updateMessages(targetThreadId, (current) => [...current, userMessage, assistantMessage]); + inFlightThreadIdRef.current = targetThreadId; + setError(null); + setIsLoading(true); + + const controller = new AbortController(); + controllerRef.current = controller; + + const refreshAssistant = () => { + const traceSnapshot = cloneTrace(trace); + updateMessages(targetThreadId, (current) => + current.map((message) => + message.id === assistantId + ? { + ...message, + content: traceSnapshot.finalAnswerMarkdown, + thinkingTrace: traceSnapshot, + sources: sources.length > 0 ? sources : message.sources, + } + : message, + ), + ); + }; + + try { + 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(); + if (normalizeToolName(asText(event.event)) === "done") break; + } + finishTrace(trace); + refreshAssistant(); + } catch (caught) { + finishTrace(trace); + if (!isAbortError(caught)) { + const message = caught instanceof Error ? caught.message : "多轮 RAG 问答失败。"; + setError(message); + if (!trace.finalAnswerMarkdown) trace.finalAnswerMarkdown = message; + } + refreshAssistant(); + } finally { + if (inFlightThreadIdRef.current === targetThreadId) { + inFlightThreadIdRef.current = null; + } + if (controllerRef.current === controller) controllerRef.current = null; + setIsLoading(false); + } + }, + [updateMessages], + ); + const submit = useCallback( async ({ targetThreadId, query }: { targetThreadId: string; query: string }) => { if (isLoading) return; 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(); - let sources: MultiRoundRagSource[] = []; - const clientTs = new Date().toISOString(); - const userMessage: Message = { - id: humanId, - type: "human", - content: [{ type: "text", text: question }], - additional_kwargs: { client_ts: clientTs, multi_round_rag: true }, - }; - const assistantMessage: Message = { - id: assistantId, - type: "ai", - content: "", - additional_kwargs: { - multi_round_rag: true, - multi_round_rag_trace: trace, - }, - }; - 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; - }); - }; - - 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(); - } - 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(); - refreshAssistant(); - } catch (caught) { - trace.isComplete = true; - for (const round of trace.rounds) round.isSearching = false; - if (!isAbortError(caught)) { - const message = - caught instanceof Error ? caught.message : "多轮 RAG 问答失败。"; - setError(message); - if (!trace.answer) trace.answer = message; - } - refreshAssistant(); - } finally { - if (controllerRef.current === controller) controllerRef.current = null; - setIsLoading(false); + 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, updateMessages], + [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/core/utils/mermaid-sanitize.test.ts b/frontend-web/src/core/utils/mermaid-sanitize.test.ts index ea7099d2..e0be6809 100644 --- a/frontend-web/src/core/utils/mermaid-sanitize.test.ts +++ b/frontend-web/src/core/utils/mermaid-sanitize.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { sanitizeMermaidSource } from "./mermaid"; +import { findMermaidFenceMatches, getMermaidImageFileName, sanitizeMermaidSource } from "./mermaid"; test("inserts a missing arrow before a bare edge label", () => { const input = "E --> G|价值输出 V_s| H[PPO 损失]"; @@ -69,3 +69,17 @@ test("repairs the DAC-PPO architecture diagram class block", () => { assert.doesNotMatch(output, /class,/); assert.doesNotMatch(output, /input,module/); }); + +test("turns mermaid source filenames into png downloads", () => { + assert.equal(getMermaidImageFileName("outputs/图2.mmd"), "图2.png"); + assert.equal(getMermaidImageFileName("chart.mermaid", "png"), "chart.png"); + assert.equal(getMermaidImageFileName("flowchart.png"), "flowchart.png"); +}); + +test("finds mermaid fences for Word image replacement", () => { + const markdown = "前文\n```mermaid\nflowchart TB\nA-->B\n```\n后文\n```mmd\ngraph LR\nC-->D\n```"; + const fences = findMermaidFenceMatches(markdown); + assert.equal(fences.length, 2); + assert.equal(fences[0].source, "flowchart TB\nA-->B"); + assert.equal(fences[1].source, "graph LR\nC-->D"); +}); diff --git a/frontend-web/src/core/utils/mermaid.ts b/frontend-web/src/core/utils/mermaid.ts index 9891b3ed..a7f059c8 100644 --- a/frontend-web/src/core/utils/mermaid.ts +++ b/frontend-web/src/core/utils/mermaid.ts @@ -96,12 +96,209 @@ function sanitizeMermaidClassStatements(source: string): string { ); } +const MERMAID_FILE_EXT = /\.(mmd|mermaid)$/i; + export function isMermaidFile(filepath: string): boolean { - return /\.mmd$/i.test(filepath); + return MERMAID_FILE_EXT.test(filepath); } -export function getMermaidImageFileName(filepath: string, extension = "svg"): string { - return getFileName(filepath).replace(/\.mmd$/i, `.${extension}`); +export function getMermaidImageFileName(filepath: string, extension = "png"): string { + const name = getFileName(filepath) || "mermaid-diagram"; + if (MERMAID_FILE_EXT.test(name)) { + return name.replace(MERMAID_FILE_EXT, `.${extension}`); + } + if (/\.(svg|png|jpe?g|webp)$/i.test(name)) { + return name.replace(/\.(svg|png|jpe?g|webp)$/i, `.${extension}`); + } + return `${name}.${extension}`; +} + +function triggerBlobDownload(blob: Blob, filename: string): void { + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = objectUrl; + a.download = filename; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + a.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000); +} + +function readPositiveNumber(value: string | null): number | null { + if (!value) return null; + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function getSvgExportSize(svg: SVGSVGElement): { width: number; height: number } { + const viewBox = svg.viewBox?.baseVal; + if (viewBox && viewBox.width > 0 && viewBox.height > 0) { + return { width: viewBox.width, height: viewBox.height }; + } + const attrWidth = readPositiveNumber(svg.getAttribute("width")); + const attrHeight = readPositiveNumber(svg.getAttribute("height")); + if (attrWidth && attrHeight) { + return { width: attrWidth, height: attrHeight }; + } + try { + const box = svg.getBBox(); + if (box.width > 0 && box.height > 0) { + return { width: box.width, height: box.height }; + } + } catch { + // Detached SVGs may throw on getBBox(). + } + return { + width: Math.max(1, svg.clientWidth), + height: Math.max(1, svg.clientHeight), + }; +} + +function serializeSvg(svg: SVGSVGElement): string { + const clone = svg.cloneNode(true) as SVGSVGElement; + if (!clone.getAttribute("xmlns")) { + clone.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + } + if (!clone.getAttribute("xmlns:xlink")) { + clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink"); + } + return new XMLSerializer().serializeToString(clone); +} + +export async function renderSvgElementToPngBlob(svg: SVGSVGElement): Promise { + const { width, height } = getSvgExportSize(svg); + const scale = 2; + const markup = serializeSvg(svg); + const blob = new Blob([markup], { type: "image/svg+xml;charset=utf-8" }); + const objectUrl = URL.createObjectURL(blob); + + try { + const image = new Image(); + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error("流程图转图片失败")); + image.src = objectUrl; + }); + + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.ceil(width * scale)); + canvas.height = Math.max(1, Math.ceil(height * scale)); + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("无法创建画布"); + } + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, canvas.width, canvas.height); + context.drawImage(image, 0, 0, canvas.width, canvas.height); + + const png = await new Promise((resolve, reject) => { + canvas.toBlob( + (result) => (result ? resolve(result) : reject(new Error("生成 PNG 失败"))), + "image/png", + ); + }); + return png; + } finally { + URL.revokeObjectURL(objectUrl); + } +} + +export async function downloadSvgElementAsPng( + svg: SVGSVGElement, + filename: string, +): Promise { + const png = await renderSvgElementToPngBlob(svg); + triggerBlobDownload(png, getMermaidImageFileName(filename, "png")); +} + +function blobToBase64(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = String(reader.result || ""); + const comma = result.indexOf(","); + resolve(comma >= 0 ? result.slice(comma + 1) : result); + }; + reader.onerror = () => reject(new Error("图片编码失败")); + reader.readAsDataURL(blob); + }); +} + +const MERMAID_FENCE_RE = /```(?:mermaid|mmd)[^\n]*\r?\n([\s\S]*?)```/gi; + +export function findMermaidFenceMatches(markdown: string): Array<{ + index: number; + length: number; + source: string; +}> { + const matches: Array<{ index: number; length: number; source: string }> = []; + const matcher = new RegExp(MERMAID_FENCE_RE.source, MERMAID_FENCE_RE.flags); + let match: RegExpExecArray | null; + while ((match = matcher.exec(markdown)) !== null) { + matches.push({ + index: match.index, + length: match[0].length, + source: (match[1] ?? "").trim(), + }); + } + return matches; +} + +export async function renderMermaidToPngBlob(source: string): Promise { + const markup = await renderMermaidToSvg(source); + const host = document.createElement("div"); + host.style.position = "fixed"; + host.style.left = "-10000px"; + host.style.top = "-10000px"; + host.style.pointerEvents = "none"; + host.style.opacity = "0"; + host.innerHTML = markup; + document.body.appendChild(host); + try { + const svg = host.querySelector("svg"); + if (!(svg instanceof SVGSVGElement)) { + throw new Error("无法生成流程图图片"); + } + return await renderSvgElementToPngBlob(svg); + } finally { + host.remove(); + } +} + +export async function replaceMermaidFencesForWordExport(markdown: string): Promise<{ + markdown: string; + embeddedImages: Record; +}> { + const fences = findMermaidFenceMatches(markdown); + if (fences.length === 0) { + return { markdown, embeddedImages: {} }; + } + + const embeddedImages: Record = {}; + const replacements: Array<{ index: number; length: number; text: string }> = []; + + for (const [offset, fence] of fences.entries()) { + if (!fence.source) continue; + try { + const png = await renderMermaidToPngBlob(fence.source); + const id = `mermaid-${offset + 1}`; + embeddedImages[id] = await blobToBase64(png); + replacements.push({ + index: fence.index, + length: fence.length, + text: `\n![流程图](embedded:${id})\n`, + }); + } catch (error) { + console.error("Failed to render mermaid for Word export:", error); + } + } + + let next = markdown; + for (const piece of replacements.sort((left, right) => right.index - left.index)) { + next = `${next.slice(0, piece.index)}${piece.text}${next.slice(piece.index + piece.length)}`; + } + return { markdown: next, embeddedImages }; } async function getMermaidModule() { @@ -152,15 +349,6 @@ export async function openMermaidSvgInNewTab(source: string): Promise { } export async function downloadMermaidSvgFile(source: string, filepath: string): Promise { - const svg = await renderMermaidToSvg(source); - const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" }); - const objectUrl = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = objectUrl; - a.download = getMermaidImageFileName(filepath); - a.rel = "noopener"; - document.body.appendChild(a); - a.click(); - a.remove(); - window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000); + const png = await renderMermaidToPngBlob(source); + triggerBlobDownload(png, getMermaidImageFileName(filepath, "png")); } diff --git a/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx b/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx index 62d06b13..ef351e87 100644 --- a/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx +++ b/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx @@ -31,6 +31,7 @@ import { syncPositionOptionsFromRoles, } from "@/position-roundtable/lib/positions"; import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts"; +import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent"; import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent"; import "@/position-roundtable/styles/intent-understanding.css"; import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel"; @@ -96,6 +97,8 @@ export function EightBfStep1Panel({ }: EightBfStep1PanelProps) { const [rightPanelOpen, setRightPanelOpen] = useState(true); const [rightTab, setRightTab] = useState<"agents" | "results">("agents"); + // 第一步内置任务理解智能体:其「常用问题」显示在输入框左上角,点击直接发送。 + const { agent: intentAgent } = useAgent(EIGHTBF_INTENT_AGENT_ID); const openPanel = (tab: "agents" | "results") => { if (rightPanelOpen && rightTab === tab) { @@ -167,6 +170,8 @@ export function EightBfStep1Panel({ modelOptions={modelOptions} selectedModel={selectedModel} onSelectedModelChange={onSelectedModelChange} + commonQuestions={intentAgent?.common_questions ?? null} + sendCommonQuestionOnClick /> )} intentReady={intentReady} @@ -331,8 +336,26 @@ function EightBfAgentWorkspace({ summaryReport: step3Summary, }); + // 第一步绑定的内置任务理解智能体:不在业务链席位里,固定置顶展示, + // 供管理员在本面板查看 / 配置其提示词与常用问题。 + const intentAgent = useMemo( + () => agents.find((agent) => agent.id === EIGHTBF_INTENT_AGENT_ID) ?? null, + [agents], + ); + const intentAgentMatchesKeyword = useMemo(() => { + const keyword = agentKeyword.trim().toLowerCase(); + if (!keyword) return true; + const name = (intentAgent?.name || "8BF 任务理解").toLowerCase(); + const description = (intentAgent?.description || "").toLowerCase(); + return name.includes(keyword) || description.includes(keyword); + }, [agentKeyword, intentAgent]); + useEffect(() => { - if (selectedAgentId && !seats.some((seat) => seat.agent_id === selectedAgentId)) { + if ( + selectedAgentId && + selectedAgentId !== EIGHTBF_INTENT_AGENT_ID && + !seats.some((seat) => seat.agent_id === selectedAgentId) + ) { setSelectedAgentId(null); } }, [seats, selectedAgentId]); @@ -412,7 +435,12 @@ function EightBfAgentWorkspace({ ) : ( toast.info("请在中间对话区向任务理解智能体发起问题")} showRecentChats={false} avatarSrc={agentConfigLogo} @@ -468,7 +496,7 @@ function EightBfAgentWorkspace({ {isLoading && seats.length > 0 ? (
正在加载智能体…
- ) : filteredSeatAgents.length === 0 ? ( + ) : filteredSeatAgents.length === 0 && !intentAgentMatchesKeyword ? (

{seatAgents.length ? "没有符合条件的智能体" : "当前岗位尚未配置智能体"}

{!seatAgents.length && canConfigureChain && ( @@ -477,6 +505,13 @@ function EightBfAgentWorkspace({
) : (
+ {intentAgentMatchesKeyword && ( + setSelectedAgentId(EIGHTBF_INTENT_AGENT_ID)} + /> + )} {filteredSeatAgents.map(({ seat, agent }) => ( ; +} + +/** 智能体列表卡片:席位智能体与置顶的内置任务理解智能体共用同一展示结构。 */ +function EightBfAgentCard({ + name, + description, + onSelect, +}: { + name: string; + description: string; + onSelect: () => void; +}) { const avatarText = firstAgentAvatarChar(name); return (
@@ -189,12 +183,6 @@ export default function AdminSkillsPage() { > - - - diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 16228c29..e0f73872 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -12,6 +12,7 @@ import { useParams, useRouter } from "next/navigation"; import { useLocation } from "react-router-dom"; import { useRouteBase } from "@/core/route-base"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { Button } from "@/components/ui/button"; @@ -25,6 +26,7 @@ import { } from "@/components/workspace/agents/agent-guide"; import { AgentWelcome } from "@/components/workspace/agent-welcome"; import { ArtifactTrigger } from "@/components/workspace/artifacts"; +import { listThreadArtifacts } from "@/core/artifacts/listing"; import { ChatBox, useThreadChat } from "@/components/workspace/chats"; import { ExportTrigger } from "@/components/workspace/export-trigger"; import { InputBox } from "@/components/workspace/input-box"; @@ -98,6 +100,14 @@ const PAGE_REPORT_DESIGNER_AGENT_ID = "page-report-designer"; const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000; const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000; +function isTaskReportsJsonPath(path: string | undefined): boolean { + const normalized = (path ?? "").replace(/\\/g, "/"); + return ( + normalized === "task-reports.json" || + normalized.endsWith("/task-reports.json") + ); +} + function readPendingKnowledgeSelection(agentId: string | undefined): string[] | null { if (typeof window === "undefined" || !agentId) return null; try { @@ -557,9 +567,11 @@ export default function AgentChatPage({ // ── agentfx「入库」协助卡 ────────────────────────────────────────────────── // 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且 // 本轮流式结束后,向消息流尾部注入「是否入库」卡片。本地沙箱下 artifacts 可能 - // 记录宿主真实路径,按文件名匹配(虚拟/宿主路径都命中)。任务已有详情**不抑制** - // 卡片(入库是整体替换,由卡片文案说明覆盖语义);已导入按 thread 记忆在 - // sessionStorage(刷新后不再重复询问)、「暂不入库」为本次会话内记忆。 + // 记录宿主真实路径,按文件名匹配(虚拟/宿主路径都命中)。转换脚本已函数合并 + // 写出该文件,即使模型中断、没跑 present_files,也再扫一遍 outputs 目录。 + // 任务已有详情**不抑制**卡片(入库是整体替换,由卡片文案说明覆盖语义); + // 已导入按 thread 记忆在 sessionStorage(刷新后不再重复询问)、 + // 「暂不入库」为本次会话内记忆。 const reportImportSessionKey = threadId ? `agentfx.reportImport.done.${threadId}` : ""; @@ -572,9 +584,19 @@ export default function AgentChatPage({ } }); const [reportImportDismissed, setReportImportDismissed] = useState(false); - const reportJsonArtifactReady = (thread.values.artifacts ?? []).some((path) => - path.replace(/\\/g, "/").endsWith("/task-reports.json"), - ); + const listedReportJsonQuery = useQuery({ + queryKey: ["agentfx-task-reports", threadId], + queryFn: ({ signal }) => listThreadArtifacts(threadId, signal), + enabled: Boolean( + taskBind && !taskGate && threadId && threadId !== "new" && !thread.isLoading, + ), + retry: false, + }); + const reportJsonArtifactReady = + (thread.values.artifacts ?? []).some(isTaskReportsJsonPath) || + (listedReportJsonQuery.data?.files ?? []).some((file) => + isTaskReportsJsonPath(file.path) || file.name === "task-reports.json", + ); const showReportImportCard = taskBind && !taskGate && diff --git a/frontend-web/src/pages/AssistantKnowledgePage.tsx b/frontend-web/src/pages/AssistantKnowledgePage.tsx index 55a6d2ea..611027ac 100644 --- a/frontend-web/src/pages/AssistantKnowledgePage.tsx +++ b/frontend-web/src/pages/AssistantKnowledgePage.tsx @@ -1,11 +1,14 @@ +import { userFacingKnowledgeMessage } from "@/lib/knowledge-message"; import { ArrowLeft, BookOpenText, Database, + Edit3, Loader2, Plus, RefreshCw, Search, + Trash2, Upload, } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -17,12 +20,14 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, + DialogDescription, + DialogFooter, DialogHeader, DialogTitle, - DialogFooter, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; import { KnowledgeProductNav } from "@/components/workspace/knowledge-product-nav"; import { getStoredAuth } from "@/core/auth"; import { buildAppHashUrl } from "@/core/llmwiki/source-links"; @@ -33,6 +38,7 @@ import { useAssistantKnowledgeBases, useAssistantKnowledgeSources, useAssistantWikiPages, + type AssistantKnowledgeBase, } from "@/strategy-components/api/assistant-knowledge"; import { WikiLibraryReader, @@ -53,6 +59,8 @@ export default function AssistantKnowledgePage() { const client = useQueryClient(); const [tab, setTab] = useState("wiki"); const [createOpen, setCreateOpen] = useState(false); + const [editingBase, setEditingBase] = + useState(null); const [name, setName] = useState(""); const [query, setQuery] = useState(""); const [searching, setSearching] = useState(false); @@ -60,6 +68,7 @@ export default function AssistantKnowledgePage() { ReturnType > | null>(null); const upload = useRef(null); + const documentUpload = useRef(null); const base = bases.data?.find((b) => b.id === baseId); const libraryPages = useMemo( () => @@ -124,6 +133,25 @@ export default function AssistantKnowledgePage() { }; const refresh = () => void client.invalidateQueries({ queryKey: ["assistant-knowledge"] }); + const removeBase = async (target: AssistantKnowledgeBase) => { + if (target.is_global) { + toast.error("知识梳理总库承担全库沉淀,不能删除"); + return; + } + if ( + !window.confirm( + `确定删除助手知识库“${target.name}”吗?库内 Wiki、向量、实体、关系和导入记录都会删除。`, + ) + ) + return; + try { + await actions.deleteBase.mutateAsync(target.id); + if (baseId === target.id) setParams({}); + toast.success("助手知识库已删除"); + } catch (error) { + toast.error((error as Error).message); + } + }; return (
@@ -133,8 +161,7 @@ export default function AssistantKnowledgePage() {

助手知识库

- 浏览分类 - Wiki、关联知识与来源。普通知识库向量化完成后自动沉淀到知识梳理。 + 选择或新建知识库,上传文档后自动生成分类 Wiki、关联知识和向量索引。可独立使用,也可导入 普通知识库数据包;各库知识汇入知识梳理总库。

{isAdmin && ( @@ -150,34 +177,69 @@ export default function AssistantKnowledgePage() { )}
{bases.data?.map((b) => ( -
-

- {b.name} -

-

- {b.is_global - ? "汇聚普通知识库的分析成果,保留分类、标签和知识关联。" - : b.description || - "独立的 Wiki 知识空间,支持导入、导出与向量检索。"} -

-
- {b.page_count || 0} Wiki - {b.source_count || 0} 来源 - {b.relation_count || 0} 关系 -
- +

+ {b.name} +

+

+ {b.description || + (b.is_global + ? "汇聚普通知识库的分析成果,保留分类、标签和知识关联。" + : "独立的 Wiki 知识空间,支持导入、导出与向量检索。")} +

+
+ {b.page_count || 0} Wiki + {b.source_count || 0} 来源 + {b.relation_count || 0} 关系 +
+ + {isAdmin && ( +
+ + {!b.is_global && ( + + )} + {b.is_global && ( + + 系统大库不可删除 + + )} +
+ )} +
))}
{!bases.isLoading && !bases.data?.length && isAdmin && ( @@ -222,6 +284,35 @@ export default function AssistantKnowledgePage() { 刷新 + {isAdmin && ( + + )} + {isAdmin && base && !base.is_global && ( + + )} + {isAdmin && ( + + )} {isAdmin && ( )}
+ { + const files = Array.from(event.target.files || []); + event.target.value = ""; + setTab("transfer"); + for (const file of files) { + if (file.size > 100 * 1024 * 1024) { toast.error(`${file.name} 超过 100 MB,请拆分后上传`); continue; } + try { + await actions.uploadDocument.mutateAsync({ file, assistantBaseId: baseId }); + toast.success(`${file.name} 已上传,后台正在解析、生成 Wiki 并向量化`); + } catch (error) { toast.error(`${file.name}:${(error as Error).message}`); } + } + }} /> Wiki({base?.page_count || 0}) - 向量检索 + 知识检索 来源与同步 {isAdmin && ( 导入 / 导出记录 @@ -274,6 +378,7 @@ export default function AssistantKnowledgePage() {
o.mapping_id)?.mapping_id } + initiallyCollapseTree /> @@ -310,26 +416,36 @@ export default function AssistantKnowledgePage() { <> {searchResult.retrieval_mode === "vector" - ? "向量检索 · 余弦相似度" + ? "知识正文 / 片段 · 向量检索" : searchResult.retrieval_mode === "empty" ? "没有匹配结果" - : "关键词检索(暂无匹配模型的向量)"} + : "知识正文 / 片段 · 关键词检索(暂无匹配模型的向量)"}
{searchResult.results.map((p) => ( ))} @@ -345,7 +461,8 @@ export default function AssistantKnowledgePage() {

上次同步:{new Date(s.updated_at).toLocaleString()}

- {isAdmin && ( + {s.source_type === "file" &&

本地上传文档 · 处理进度及重试请查看导入记录

} + {isAdmin && s.source_type === "weknora" && ( + )}
))}
@@ -425,6 +553,111 @@ export default function AssistantKnowledgePage() { + {editingBase && ( + setEditingBase(null)} + onSave={async (input) => { + try { + await actions.updateBase.mutateAsync({ + id: editingBase.id, + ...input, + }); + setEditingBase(null); + toast.success("助手知识库信息已保存"); + } catch (error) { + toast.error((error as Error).message); + } + }} + /> + )}
); } + +function EditAssistantBaseDialog({ + base, + pending, + onClose, + onSave, +}: { + base: AssistantKnowledgeBase; + pending: boolean; + onClose: () => void; + onSave: (input: { name: string; description: string }) => Promise; +}) { + const [name, setName] = useState(base.name); + const [description, setDescription] = useState(base.description || ""); + + return ( + { + if (!open && !pending) onClose(); + }} + > + +
{ + event.preventDefault(); + if (!pending && name.trim()) { + void onSave({ + name: name.trim(), + description: description.trim(), + }); + } + }} + > + + 编辑助手知识库 + + 修改知识库名称和描述。知识内容、向量和关联关系不会受到影响。 + + +
+
+ + setName(event.target.value)} + maxLength={255} + disabled={pending} + required + /> +
+
+ +