merge: integrate master-nj RAG updates

This commit is contained in:
fengxiangdebaozi committed 2026-08-19 20:46:37 +08:00
commit bc49ef6027
165 files changed
+18909 -6425

No files matched your search

+50 -43
View File
@@ -1,10 +1,10 @@
# Workspace/editor
.vscode/*
!.vscode/launch.json
!.vscode/tasks.json
!.vscode/extensions.json
!.vscode/settings.json
frontend-web/.vscode/
.vscode/*
!.vscode/launch.json
!.vscode/tasks.json
!.vscode/extensions.json
!.vscode/settings.json
frontend-web/.vscode/
node_modules/
# Claude Code local-only settings (per-user permissions, not shared)
.claude/settings.local.json
@@ -56,9 +56,9 @@ offline-backend-20260505/bundle/start_backend.sh
offline-backend-20260505/bundle/stop_backend.sh
# offline-backend-20260512 runtime artifacts (large files, local config, data)
offline-backend-20260512/.env.example
offline-backend-20260512/.env
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
offline-backend-20260512/.env.example
offline-backend-20260512/.env
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
offline-backend-20260512/SHA256SUMS
offline-backend-20260512/config.yaml.example
offline-backend-20260512/data/
@@ -66,14 +66,14 @@ offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
offline-backend-20260512/extensions_config.json
offline-backend-20260512/skills/
offline-backend-20260512/wheelhouse/
offline-backend-20260512/backend/.deer-flow/data/*.db
offline-backend-20260512/backend/.deer-flow/data/*.db-*
offline-backend-20260512/backend/.deer-flow/*
offline-backend-20260512/backend/.env
offline-backend-20260512/backend/.env.local
offline-backend-20260512/start_backend.sh
offline-backend-20260512/stop_backend.sh
offline-backend-20260512/check_backend.sh
offline-backend-20260512/backend/.deer-flow/data/*.db
offline-backend-20260512/backend/.deer-flow/data/*.db-*
offline-backend-20260512/backend/.deer-flow/*
offline-backend-20260512/backend/.env
offline-backend-20260512/backend/.env.local
offline-backend-20260512/start_backend.sh
offline-backend-20260512/stop_backend.sh
offline-backend-20260512/check_backend.sh
hermes-agent-main/
@@ -84,16 +84,23 @@ offline-backend-20260512/backend/.deer-flow/*
# Per-user skill_evolution overrides — runtime data written by the API
offline-backend-20260512/backend/skill_evolution.json
skills/
hermes-agent-main/
skills/
hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
# Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py
deploy
offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
# Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py
# Deep-research reliability regression coverage.
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py
!offline-backend-20260512/backend/tests/test_deep_research_start_job.py
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
deploy
.history
# Cloned third-party repos (local only)
@@ -102,22 +109,22 @@ git-clone/
# Repo-root offline dependency pack (local only)
/dist/
# Frontend offline pack working directories
frontend-web/.offline-pack-build/
frontend-web/.offline-extract-test/
frontend-web-node_modules-offline-*.tar.gz
# Local dependency caches and backend scratch archives
/.pnpm-store/
/offline-backend-20260512/backend/backend*.zip
# Local offline bundles and scratch tooling
/*.tar.gz
/offline-backend-20260512/*.zip
/tmp/
.codegraph
codegraph.mdc
# Frontend offline pack working directories
frontend-web/.offline-pack-build/
frontend-web/.offline-extract-test/
frontend-web-node_modules-offline-*.tar.gz
# Local dependency caches and backend scratch archives
/.pnpm-store/
/offline-backend-20260512/backend/backend*.zip
# Local offline bundles and scratch tooling
/*.tar.gz
/offline-backend-20260512/*.zip
/tmp/
.codegraph
codegraph.mdc
docs/aidev-docs
docs/backups
@@ -134,4 +141,4 @@ offline-backend-20260512/backend/uv.lock
__pycache__/
# misc
.DS_Store
.DS_Store
+1 -1
View File
@@ -81,7 +81,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all).
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`.
- **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答直连外部 AG-UI 网关(POST SSE),不经过 LangGraph,会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`),核心模块 `src/core/sentiment-agent/`。
- **敏感词管理 (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/`。
+1 -1
View File
@@ -82,7 +82,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API.
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all).
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`.
- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`.
- **敏感词管理 (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/`。
+3
View File
@@ -56,4 +56,7 @@
# VITE_STATIC_WEBSITE_ONLY=
# 部署级紧凑导航:true 时固定收起工作台左侧栏,并隐藏换肤与全量/简洁模式切换。
# VITE_FORCE_COLLAPSED_SIDEBAR=false
# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。
# VITE_COMPACT_MENU_TYPE=A
# VITE_GITHUB_OAUTH_TOKEN=
File diff suppressed because it is too large. Load diff
+16 -3
View File
@@ -32,10 +32,10 @@ window.__CM_RUNTIME_CONFIG__ = {
VITE_BRAND_WELCOME_DARK_URL: "",
// DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。
// VITE_BACKEND_BASE_URL: "http://10.19.200.5:2026",
// VITE_BACKEND_BASE_URL: "https://ch1.b.uat.4.cn/deerflow",
// LangGraph 流式对话接口地址:普通问答、智能体对话等流式能力使用。
// VITE_LANGGRAPH_BASE_URL: "http://10.19.200.5:2026/api",
// VITE_LANGGRAPH_BASE_URL: "https://ch1.b.uat.4.cn/deerflow/api",
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
@@ -50,6 +50,14 @@ window.__CM_RUNTIME_CONFIG__ = {
// CMZS 后端接口:任务管理、任务分析、三情分析、行动分析等业务接口。
VITE_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm",
// 8BF 嵌入页专用任务系统接口。部署到其他系统时只需修改这一项,
// 不影响原多智能体会商使用的任务详情接口。
VITE_8BF_TASK_API_BASE_URL: "https://ch1.b.uat.4.cn/zl_consumer",
// 8BF 会商页右上角「返回」按钮的跳转地址(当前窗口 window.location.href)。
// 部署后直接改 dist/runtime-config.js 即可,无需重新打包。留空则点击时提示未配置。
VITE_8BF_ROUNDTABLE_BACK_URL: "",
// 佳来模型对话接口。
VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt",
@@ -90,7 +98,7 @@ window.__CM_RUNTIME_CONFIG__ = {
VITE_AIPI_FRONTEND_URL: "http://47.88.25.99:3000",
// Consumer 接口:深链任务详情 cop-task-detail、研判情报报告 selectSqReport 等使用。
VITE_AIPI_CONSUMER_URL: "http://10.19.200.5:8002/consumer",
VITE_AIPI_CONSUMER_URL: "https://ch1.b.uat.4.cn/consumer",
// A 系统跳转地址:人物、组织、事件等列表项打开链接使用。
VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain",
@@ -130,6 +138,11 @@ window.__CM_RUNTIME_CONFIG__ = {
// 隐藏系统 Logo、换肤按钮以及“全量/简洁模式”切换。修改后刷新页面即可生效。
VITE_FORCE_COLLAPSED_SIDEBAR: "false",
// 简介模式左侧菜单类型:与菜单管理页「简介模式」里的类型 A / B 对应。
// 两套网页部署时分别填 "A" 或 "B"。不填、留空、或旧配置没有这一项,都按类型 A,
// 继续读取原来的简介菜单数据,无需改旧页面。
VITE_COMPACT_MENU_TYPE: "A",
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
VITE_GITHUB_OAUTH_TOKEN: "",
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="12" viewBox="0 0 14 12" fill="none" aria-hidden="true">
<path d="M13 6H1.5M1.5 6L6.2 1.4M1.5 6L6.2 10.6" stroke="#A2E1F9" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

@@ -165,7 +165,7 @@ export const ReasoningTrigger = memo(
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string;
children: ReactNode;
};
export const ReasoningContent = memo(
@@ -178,7 +178,11 @@ export const ReasoningContent = memo(
)}
{...props}
>
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
{typeof children === "string" ? (
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
) : (
children
)}
</CollapsibleContent>
),
);
@@ -0,0 +1,104 @@
import { FileOutput } from "lucide-react";
import { toast } from "sonner";
import responsibilityIcon from "@/assets/position-roundtable/责任岗位.svg";
import resultTimeIcon from "@/assets/position-roundtable/时间.svg";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import "./analysis-results.css";
import type { AnalysisResultCardItem } from "./types";
function formatResultTime(value: string | undefined, fallback: string): string {
if (!value) return fallback;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
}
export function AnalysisResultCardList({
items,
emptyTitle = "暂无交付产物",
emptyDescription = "第二步研讨与第三步总结生成 Markdown 后,产物会显示在这里。",
padded = true,
onOpen,
}: {
items: AnalysisResultCardItem[];
emptyTitle?: string;
emptyDescription?: string;
padded?: boolean;
onOpen: (item: AnalysisResultCardItem) => void;
}) {
if (items.length === 0) {
return (
<div className="position-roundtable-analysis-results-empty flex h-full min-h-[260px] flex-col items-center justify-center p-6 text-center">
<FileOutput className="mb-3 size-8 text-muted-foreground/50" />
<p className="text-sm font-semibold">{emptyTitle}</p>
<p className="mt-1 max-w-[240px] text-xs leading-5 text-muted-foreground">
{emptyDescription}
</p>
</div>
);
}
return (
<div className={cn("analysis-result-card-list", padded && "p-[15px]")}>
{items.map((item, index) => {
const displayedTime = formatResultTime(
item.updatedAt,
item.fallbackTime || "刚刚",
);
const displayedProductName = item.delivered ? item.productName : "未交付";
return (
<button
key={item.id}
type="button"
className="position-roundtable-analysis-result-card"
onClick={() => {
if (!item.delivered) {
toast.info(
item.unavailableMessage ||
`「${desensitize(item.agentName)}」尚未交付可预览的 Markdown 文件。`,
);
return;
}
onOpen(item);
}}
>
<div className="position-roundtable-analysis-result-card-body">
<div
className={cn(
"position-roundtable-analysis-result-tag",
`position-roundtable-analysis-result-tag--${(index % 6) + 1}`,
)}
aria-label={desensitize(item.agentName)}
>
<span>{desensitize(item.agentName)}</span>
</div>
<p className="position-roundtable-analysis-result-product">
{desensitize(displayedProductName)}
</p>
<footer className="position-roundtable-analysis-result-meta">
<span>
<img src={responsibilityIcon} alt="" aria-hidden="true" />
{desensitize(item.positionName)}
</span>
<i aria-hidden="true" />
<span>
<img src={resultTimeIcon} alt="" aria-hidden="true" />
{displayedTime}
</span>
</footer>
</div>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,141 @@
import { useQuery } from "@tanstack/react-query";
import { FileOutput, Loader2 } from "lucide-react";
import type { ReactNode } from "react";
import { ArtifactFilePreview } from "@/components/workspace/artifacts";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { loadArtifactContent } from "@/core/artifacts/loader";
import { desensitize } from "@/lib/desensitize";
import { Button as TButton } from "tdesign-react";
import type { AnalysisResultCardItem } from "./types";
function isArtifactErrorContent(content: string | null | undefined): boolean {
const trimmed = content?.trim();
if (!trimmed || !trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return false;
}
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
const detail = parsed.detail ?? parsed.message ?? parsed.error;
return typeof detail === "string" && detail.trim().length > 0;
} catch {
return false;
}
}
function fallbackContent(item: AnalysisResultCardItem | null): string {
return (
item?.artifactContent?.trim() ||
item?.summary?.trim() ||
"暂无产物正文"
);
}
export function AnalysisResultDetailDialog({
item,
onOpenChange,
extraBody,
footerStart,
}: {
item: AnalysisResultCardItem | null;
onOpenChange: (open: boolean) => void;
extraBody?: ReactNode;
footerStart?: ReactNode;
}) {
const artifactThreadId = item?.artifactThreadId || null;
const artifactPathValue = item?.artifactPath || "";
const artifactContentQuery = useQuery({
queryKey: [
"analysis-results",
"deliverable-artifact-content",
artifactThreadId,
artifactPathValue,
],
queryFn: async () => {
if (!artifactThreadId || !artifactPathValue) return "";
const result = await loadArtifactContent({
filepath: artifactPathValue,
threadId: artifactThreadId,
isMock: false,
});
return result.content;
},
enabled: Boolean(item && artifactThreadId && artifactPathValue),
staleTime: 30_000,
});
const loadedArtifactContent =
typeof artifactContentQuery.data === "string" ? artifactContentQuery.data : "";
const loadedArtifactIsError = isArtifactErrorContent(loadedArtifactContent);
const artifactContent =
loadedArtifactContent.trim() && !loadedArtifactIsError ? loadedArtifactContent : null;
const displayedContent = artifactContent || fallbackContent(item);
const artifactLoadFailed =
(artifactContentQuery.isError || loadedArtifactIsError) &&
!item?.artifactContent?.trim();
return (
<Dialog open={Boolean(item)} onOpenChange={onOpenChange}>
<DialogContent
overlayClassName="!z-[6000]"
className="!z-[6001] flex max-h-[86vh] w-[900px] !max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0"
>
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
<DialogTitle className="flex items-center gap-2 text-base">
<FileOutput className="size-4 text-primary" />
交付产物详情
</DialogTitle>
</DialogHeader>
{!item ? null : (
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="min-w-0">
<p className="text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
{desensitize(item.productName)}
</p>
<p className="mt-1 truncate text-xs text-muted-foreground">
{desensitize(item.agentName)} · {desensitize(item.positionName)}
</p>
</div>
{extraBody}
<div className="mt-4 rounded-xl border border-slate-200 bg-white p-3 dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/65">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-xs font-bold text-slate-700 dark:text-[#e8edf5]">产物内容</p>
</div>
<div className="h-[52vh] min-h-[320px] overflow-hidden rounded-lg border border-slate-100 bg-slate-50/60 dark:border-[#2b8ffe]/20 dark:bg-[#021d3d]/70">
{artifactContentQuery.isLoading ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground dark:text-[#8aa0c4]">
<Loader2 className="size-4 animate-spin" />
正在加载 Markdown 文件内容…
</div>
) : (
<ArtifactFilePreview
content={displayedContent}
language="markdown"
threadId={artifactThreadId ?? ""}
/>
)}
</div>
{artifactLoadFailed && (
<p className="mt-2 text-[11px] text-amber-600 dark:text-amber-300">
Markdown 文件内容加载失败,当前展示的是已保存正文。
</p>
)}
</div>
</div>
)}
<DialogFooter className="shrink-0 border-t px-5 py-3 dark:border-[#2b8ffe]/25">
{footerStart}
<TButton variant="outline" onClick={() => onOpenChange(false)}>
关闭
</TButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,130 @@
.analysis-result-card-list {
display: grid;
gap: 10px;
min-width: 0;
max-width: 100%;
}
.position-roundtable-analysis-result-card {
position: relative;
display: block;
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 102px;
box-sizing: border-box;
padding: 40px 12px 12px;
border: 1px solid rgba(85, 166, 254, 0.1);
border-radius: 4px;
background: rgba(0, 98, 217, 0.1);
color: #ffffff;
text-align: left;
cursor: pointer;
transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
}
.position-roundtable-analysis-result-card:hover,
.position-roundtable-analysis-result-card:focus-visible {
border-color: #0062d9;
background: rgba(0, 98, 217, 0.3);
box-shadow: 0 4px 14px rgba(0, 44, 110, 0.18);
outline: none;
}
.position-roundtable-analysis-result-card-body {
position: relative;
min-width: 0;
max-width: 100%;
}
.position-roundtable-analysis-result-tag {
position: absolute;
top: -40px;
left: -12px;
display: inline-flex;
width: fit-content;
max-width: calc(100% + 12px);
height: 26px;
align-items: center;
box-sizing: border-box;
padding: 0 22px 0 12px;
overflow: hidden;
border-radius: 4px 0 0 0;
background: rgba(212, 120, 0, 0.5);
color: #ffffff;
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
font-size: 16px;
line-height: normal;
white-space: nowrap;
clip-path: polygon(0 0, 100% 0, calc(100% - 14px) 100%, 0 100%);
}
.position-roundtable-analysis-result-tag--1 { background: rgba(212, 120, 0, 0.5); }
.position-roundtable-analysis-result-tag--2 { background: rgba(207, 19, 34, 0.5); }
.position-roundtable-analysis-result-tag--3 { background: rgba(29, 171, 123, 0.5); }
.position-roundtable-analysis-result-tag--4 { background: rgba(132, 85, 230, 0.5); }
.position-roundtable-analysis-result-tag--5 { background: rgba(201, 181, 0, 0.5); }
.position-roundtable-analysis-result-tag--6 { background: rgba(69, 195, 243, 0.5); }
.position-roundtable-analysis-result-tag span {
position: relative;
z-index: 1;
box-sizing: border-box;
min-width: 0;
max-width: 100%;
overflow: hidden;
padding: 0;
text-overflow: ellipsis;
}
.position-roundtable-analysis-result-product {
margin: 0;
overflow: hidden;
color: #ffffff;
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
font-size: 18px;
font-weight: 500;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.position-roundtable-analysis-result-meta {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
margin-top: 6px;
color: #80b7ff;
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
font-size: 16px;
font-weight: 300;
line-height: 24px;
}
.position-roundtable-analysis-result-meta span {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.position-roundtable-analysis-result-meta img {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.position-roundtable-analysis-result-meta i {
width: 1px;
height: 14px;
flex: 0 0 auto;
background: rgba(128, 183, 255, 0.45);
}
.position-roundtable-analysis-results-empty {
color: #80b7ff;
}
@@ -0,0 +1,3 @@
export { AnalysisResultCardList } from "./AnalysisResultCardList";
export { AnalysisResultDetailDialog } from "./AnalysisResultDetailDialog";
export type { AnalysisResultCardItem } from "./types";
@@ -0,0 +1,17 @@
export interface AnalysisResultCardItem {
id: string;
agentName: string;
productName: string;
positionName: string;
updatedAt?: string;
/** Shown when `updatedAt` is missing. */
fallbackTime?: string;
/** Clickable preview is available. */
delivered: boolean;
artifactPath?: string;
artifactMimeType?: string | null;
artifactThreadId?: string | null;
artifactContent?: string | null;
summary?: string;
unavailableMessage?: string;
}
@@ -2,6 +2,7 @@
import {
BotIcon,
BriefcaseBusinessIcon,
CheckIcon,
DatabaseIcon,
GlobeIcon,
@@ -83,6 +84,7 @@ interface AgentDraft {
/** 会商席位「技能识别强化」开关。 */
seatSkillDirective: boolean;
knowledgeBaseIds: string[];
jobResponsibilities: string;
}
function draftFromAgent(agent: Agent): AgentDraft {
@@ -97,6 +99,7 @@ function draftFromAgent(agent: Agent): AgentDraft {
templateName: agent.template_name ?? "",
seatSkillDirective: agent.seat_skill_directive ?? false,
knowledgeBaseIds: agent.llmwiki_knowledge_base_ids ?? [],
jobResponsibilities: agent.job_responsibilities ?? "",
};
}
@@ -338,6 +341,8 @@ export function AgentDetailPanel({
request.template_name = draft.templateName.trim();
if (draft.seatSkillDirective !== (agent.seat_skill_directive ?? false))
request.seat_skill_directive = draft.seatSkillDirective;
if (draft.jobResponsibilities.trim() !== (agent.job_responsibilities ?? ""))
request.job_responsibilities = draft.jobResponsibilities.trim();
if (
JSON.stringify(draft.knowledgeBaseIds) !==
JSON.stringify(agent.llmwiki_knowledge_base_ids ?? [])
@@ -720,6 +725,39 @@ export function AgentDetailPanel({
</section>
{/* ── 岗位职责 ───────────────────────────────────────────────────── */}
<Section
icon={<BriefcaseBusinessIcon className="size-3.5" />}
title="岗位职责"
>
{editing && draft ? (
<Textarea
value={draft.jobResponsibilities}
onChange={(e) =>
setDraft({ ...draft, jobResponsibilities: e.currentTarget.value })
}
placeholder="描述该智能体对应岗位的职责…"
className="min-h-20 text-sm"
/>
) : agent.job_responsibilities?.trim() ? (
<p className="text-muted-foreground text-sm leading-relaxed whitespace-pre-wrap">
{desensitize(agent.job_responsibilities)}
</p>
) : canEdit ? (
<button
type="button"
onClick={beginEdit}
className="text-muted-foreground hover:text-foreground hover:border-border w-full rounded-lg border border-dashed px-3 py-4 text-center text-xs transition-colors"
>
<PlusIcon className="mr-1 inline size-3.5 align-[-2px]" />
添加岗位职责
</button>
) : (
<p className="text-muted-foreground/60 text-sm">暂无岗位职责</p>
)}
</Section>
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
{(showCommonQuestions || editing || questions.length > 0 || canEdit) && (
<Section
File diff suppressed because it is too large. Load diff
@@ -52,11 +52,17 @@ interface ArtifactsProviderProps {
children: ReactNode;
}
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifactsState] = useState<string[]>([]);
const setArtifacts = useCallback((next: string[]) => {
setArtifactsState(Array.isArray(next) ? next : []);
}, []);
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifactsState] = useState<string[]>([]);
const setArtifacts = useCallback((next: string[]) => {
const normalized = Array.isArray(next) ? next : [];
setArtifactsState((previous) =>
previous.length === normalized.length &&
previous.every((artifact, index) => artifact === normalized[index])
? previous
: normalized,
);
}, []);
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
const [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
@@ -65,18 +71,24 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
);
const [autoOpen, setAutoOpen] = useState(true);
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
const autoOpenRef = useRef(autoOpen);
const sidebar = useSidebarSafe();
const select = useCallback(
const select = useCallback(
(artifact: string, autoSelect = false) => {
setSelectedArtifact(artifact);
setSelectedArtifactKey(artifactPreviewKey(artifact));
const previewKey = artifactPreviewKey(artifact);
selectedArtifactKeyRef.current = previewKey;
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
sidebar?.setOpen(false);
}
if (!autoSelect) {
setAutoSelect(false);
}
if (!autoSelect) {
setAutoSelect((previous) => (previous ? false : previous));
}
},
[sidebar],
);
@@ -86,11 +98,13 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
if (suppressedAutoOpenKeyRef.current === previewKey) {
return false;
}
setSelectedArtifact(artifact);
setSelectedArtifactKey(previewKey);
setAutoSelect(true);
setAutoOpen(true);
setOpenState(true);
selectedArtifactKeyRef.current = previewKey;
autoOpenRef.current = true;
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
setAutoSelect((previous) => (previous ? previous : true));
setAutoOpen((previous) => (previous ? previous : true));
setOpenState((previous) => (previous ? previous : true));
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
sidebar?.setOpen(false);
}
@@ -99,13 +113,26 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
[sidebar],
);
const deselect = useCallback(() => {
setSelectedArtifact(null);
setSelectedArtifactKey(null);
setAutoSelect(true);
setOpenState(false);
const deselect = useCallback(() => {
selectedArtifactKeyRef.current = null;
setSelectedArtifact((previous) => (previous === null ? previous : null));
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
setAutoSelect((previous) => (previous ? previous : true));
setOpenState((previous) => (previous ? false : previous));
suppressedAutoOpenKeyRef.current = null;
}, []);
const setOpen = useCallback((isOpen: boolean) => {
if (!isOpen && autoOpenRef.current) {
autoOpenRef.current = false;
setAutoOpen((previous) => (previous ? false : previous));
setAutoSelect((previous) => (previous ? false : previous));
}
if (!isOpen) {
suppressedAutoOpenKeyRef.current = selectedArtifactKeyRef.current;
}
setOpenState((previous) => (previous === isOpen ? previous : isOpen));
}, []);
const value: ArtifactsContextType = {
artifacts,
@@ -114,16 +141,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
open,
autoOpen,
autoSelect,
setOpen: (isOpen: boolean) => {
if (!isOpen && autoOpen) {
setAutoOpen(false);
setAutoSelect(false);
}
if (!isOpen) {
suppressedAutoOpenKeyRef.current = selectedArtifactKey;
}
setOpenState(isOpen);
},
setOpen,
selectedArtifact,
selectedArtifactKey,
@@ -1,6 +1,6 @@
"use client";
import { SparklesIcon } from "lucide-react";
import { ImagePlusIcon, SparklesIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -20,6 +20,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
@@ -51,16 +52,22 @@ export function DocumentRewriteDialog({
onOpenChange,
fileName,
charCount,
imageGenerationAvailable = false,
maxGeneratedImages = 4,
onStart,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
fileName: string;
charCount: number;
imageGenerationAvailable?: boolean;
maxGeneratedImages?: number;
onStart: (input: {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
generateImages: boolean;
maxGeneratedImages: number;
}) => void;
}) {
const [instruction, setInstruction] = useState(
@@ -68,11 +75,15 @@ export function DocumentRewriteDialog({
);
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
const [modelName, setModelName] = useState<string | null>(null);
const [generateImages, setGenerateImages] = useState(false);
const [imageCount, setImageCount] = useState("2");
useEffect(() => {
if (!open) return;
setStyle("formal_analysis");
setModelName(null);
setGenerateImages(false);
setImageCount("2");
}, [open]);
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
@@ -136,6 +147,48 @@ export function DocumentRewriteDialog({
</label>
</div>
{imageGenerationAvailable && (
<div className="rounded-lg border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-4">
<label className="flex min-w-0 cursor-pointer items-start gap-3" htmlFor="document-rewrite-images">
<span className="bg-primary/10 text-primary mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
<ImagePlusIcon className="size-4" />
</span>
<span className="grid gap-0.5">
<span className="text-sm font-medium">改写后补充配图</span>
<span className="text-muted-foreground text-xs leading-5">
根据章节生成并保存为当前沙箱的本地图片,不使用外部图片链接。
</span>
</span>
</label>
<Switch
aria-label="改写后补充配图"
checked={generateImages}
id="document-rewrite-images"
onCheckedChange={setGenerateImages}
/>
</div>
{generateImages && (
<div className="mt-3 flex items-center justify-between gap-3 border-t pt-3">
<span className="text-muted-foreground text-xs">每次会额外消耗图片模型额度</span>
<label className="flex items-center gap-2 text-sm">
配图数量
<Select value={imageCount} onValueChange={setImageCount}>
<SelectTrigger className="h-8 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: Math.min(Math.max(maxGeneratedImages, 1), 4) }, (_, index) => index + 1).map((count) => (
<SelectItem key={count} value={String(count)}>{count} 张</SelectItem>
))}
</SelectContent>
</Select>
</label>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
@@ -144,7 +197,16 @@ export function DocumentRewriteDialog({
disabled={!canStart}
onClick={() => {
if (!canStart) return;
onStart({ instruction: instruction.trim(), style, modelName });
onStart({
instruction: instruction.trim(),
style,
modelName,
generateImages,
maxGeneratedImages: Math.min(
Math.max(Number(imageCount) || 1, 1),
Math.max(Math.min(maxGeneratedImages, 4), 1),
),
});
}}
>
<SparklesIcon />
@@ -793,8 +793,9 @@ function buildTurnReferenceBatches(
nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id,
includeBeforeMessage: false,
}).filter((source) => source.mode !== "none");
// Single-round RAG sends sources before the complete answer. Expose those
// sources immediately while preserving the normal streaming-turn behavior.
// 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);
if (nextHumanIndex === -1 && isLoading && !isFrontendRagTurn) {
@@ -2,15 +2,22 @@
import {
CheckCircle2Icon,
ChevronDownIcon,
FilePenLineIcon,
LightbulbIcon,
Loader2Icon,
Undo2Icon,
XCircleIcon,
} from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { apiFetch } from "@/core/api/fetch-client";
import {
DOCUMENT_REWRITE_UNDONE_EVENT,
@@ -26,6 +33,7 @@ const STEPS = [
{ key: "snapshot", label: "固定原文版本" },
{ key: "requirements", label: "理解改写要求" },
{ key: "writing", label: "流式重写 Markdown" },
{ key: "illustrations", label: "生成文档配图" },
{ key: "validation", label: "校验文档" },
{ key: "comparison", label: "对比原文并生成说明" },
] as const;
@@ -38,15 +46,17 @@ function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
return 1;
case "writing":
return 2;
case "validation":
case "illustrating":
return 3;
case "validation":
return 4;
case "committing":
case "completed":
return 5;
return 6;
case "conflict":
case "error":
case "cancelled":
return 4;
return 5;
default:
return -1;
}
@@ -56,6 +66,42 @@ function isTerminal(stage: DocumentRewritePresentation["stage"]) {
return ["completed", "conflict", "error", "cancelled"].includes(stage);
}
function RewriteThinkingTrace({ text, active }: { text: string; active: boolean }) {
const [open, setOpen] = useState(active);
useEffect(() => {
if (active && text) setOpen(true);
}, [active, text]);
if (!text.trim()) return null;
return (
<Collapsible className="mt-2 max-w-3xl" onOpenChange={setOpen} open={open}>
<CollapsibleTrigger asChild>
<Button
aria-label={open ? "收起模型思考" : "展开模型思考"}
className="h-7 gap-1.5 px-2 text-xs"
size="sm"
type="button"
variant="ghost"
>
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
<span>模型思考</span>
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
<ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
</p>
<pre className="bg-muted/45 text-muted-foreground mt-1 max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words">
{text}
</pre>
</CollapsibleContent>
</Collapsible>
);
}
export function DocumentRewriteTimeline({
item,
}: {
@@ -64,6 +110,9 @@ export function DocumentRewriteTimeline({
const current = stepPosition(item.stage);
const failed = ["conflict", "error", "cancelled"].includes(item.stage);
const completed = item.stage === "completed";
const thinkingStep = ["snapshot", "requirements"].includes(item.stage)
? "requirements"
: "writing";
const presentation = useDocumentRewritePresentation();
const [undoing, setUndoing] = useState(false);
const [diffOpen, setDiffOpen] = useState(false);
@@ -148,7 +197,13 @@ export function DocumentRewriteTimeline({
<div className="space-y-3 px-5 py-4">
{STEPS.map((step, index) => {
const active = !isTerminal(item.stage) && index === current;
const done = completed || index < current;
const illustrationSkipped = step.key === "illustrations" && !item.generateImages;
const illustrationFailed =
step.key === "illustrations" &&
item.generateImages &&
completed &&
item.generatedImageCount === 0;
const done = !illustrationSkipped && !illustrationFailed && (completed || index < current);
return (
<div className="flex items-start gap-3" key={step.key}>
<div
@@ -200,6 +255,30 @@ export function DocumentRewriteTimeline({
正在写入右侧沙箱,已生成 {item.writtenCharCount.toLocaleString()} 字。
</p>
)}
{step.key === "illustrations" && (
illustrationSkipped ? (
<p className="text-muted-foreground mt-0.5 text-xs">
本次未启用配图,保留原文已有图片与链接。
</p>
) : active ? (
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
{item.illustrationTarget
? `正在为「${item.illustrationTarget}」生成配图,已完成 ${item.generatedImageCount}/${item.maxGeneratedImages} 张。`
: `正在准备 ${item.maxGeneratedImages} 张文档配图…`}
</p>
) : item.generatedImageCount > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs">
已生成并保存 {item.generatedImageCount} 张本地配图。
</p>
) : (
<p className="text-muted-foreground mt-0.5 text-xs">
未成功生成配图,文字改写结果仍已保留。
</p>
)
)}
{step.key === thinkingStep && (
<RewriteThinkingTrace active={active} text={item.thinkingText} />
)}
{step.key === "validation" && item.warnings.length > 0 && (
<p className="mt-0.5 text-xs text-amber-700 dark:text-amber-300">
{item.warnings.join(";")}
@@ -228,6 +307,11 @@ export function DocumentRewriteTimeline({
本次优化:{item.summary.requestedOptimizations.join("、")}。
</p>
) : null}
{item.summary.requestedImageCount ? (
<p className="text-muted-foreground">
文档配图:请求 {item.summary.requestedImageCount} 张,已保存 {item.summary.generatedImageCount ?? 0} 张本地图片。
</p>
) : null}
{item.summary.headingChanges && (
item.summary.headingChanges.reordered ||
item.summary.headingChanges.added.length > 0 ||
@@ -1,21 +1,21 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
BookOpenTextIcon,
CheckCircle2Icon,
ChevronDownIcon,
ChevronUp,
BookOpenTextIcon,
CheckCircle2Icon,
ChevronDownIcon,
ChevronUp,
FolderOpenIcon,
GlobeIcon,
LightbulbIcon,
ListTodoIcon,
Loader2Icon,
LightbulbIcon,
ListTodoIcon,
Loader2Icon,
MessageCircleQuestionMarkIcon,
NotebookPenIcon,
SearchIcon,
SquareTerminalIcon,
WrenchIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
ChainOfThought,
@@ -31,20 +31,20 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
import { useI18n } from "@/core/i18n/hooks";
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
import { useI18n } from "@/core/i18n/hooks";
import {
normalizeSearchResults,
resolveSearchQuery,
@@ -58,13 +58,13 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import {
buildSkillDisplayMap,
findDisplayConfigForTool,
isDisplaySamplePayload,
normalizeDefaultDisplaySources,
shouldUseDefaultCitationMode,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
buildSkillDisplayMap,
findDisplayConfigForTool,
isDisplaySamplePayload,
normalizeDefaultDisplaySources,
shouldUseDefaultCitationMode,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env";
@@ -238,7 +238,7 @@ export function MessageGroup({
);
}
function ToolCall({
function ToolCall({
id,
messageId,
name,
@@ -260,45 +260,45 @@ function ToolCall({
ragModeEnabled?: boolean;
}) {
const { t } = useI18n();
const displayMatch =
result !== undefined && isDisplaySamplePayload(result)
? null
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
null);
const displayMatch =
result !== undefined && isDisplaySamplePayload(result)
? null
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
null);
const displayItems =
displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))
: [];
if (displayItems.length > 0) {
if (displayItems.length > 0) {
return (
<ChainOfThoughtStep
key={id}
label={
displayMatch?.display.mode === "citation" || !displayMatch
? "检索参考来源"
: `获取 ${displayItems.length} 条结果`
}
label={
displayMatch?.display.mode === "citation" || !displayMatch
? "检索参考来源"
: `获取 ${displayItems.length} 条结果`
}
icon={SearchIcon}
>
<DisplayResultsList results={displayItems} />
</ChainOfThoughtStep>
);
}
// Deep Research uses a virtual tool call for each durable writing phase.
// Rendering it here keeps its lifecycle in the very same ChainOfThought
// component as ordinary Q&A retrieval and sandbox tool calls.
if (name === "deep_research_progress") {
return <DeepResearchProgressStep id={id} args={args} />;
}
const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
}
// Deep Research uses a virtual tool call for each durable writing phase.
// Rendering it here keeps its lifecycle in the very same ChainOfThought
// component as ordinary Q&A retrieval and sandbox tool calls.
if (name === "deep_research_progress") {
return <DeepResearchProgressStep id={id} args={args} />;
}
const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
@@ -311,19 +311,19 @@ function ToolCall({
</ChainOfThoughtStep>
);
}
if (name === "web_search" || searchItems.length > 0) {
const query = resolveSearchQuery(args, result);
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
if (query) {
label =
name === "web_search"
? `知识库检索 "${query}"`
: t.toolCalls.searchFor(query);
} else if (name === "web_search") {
label = "知识库检索";
}
return (
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
if (name === "web_search" || searchItems.length > 0) {
const query = resolveSearchQuery(args, result);
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
if (query) {
label =
name === "web_search"
? `知识库检索 "${query}"`
: t.toolCalls.searchFor(query);
} else if (name === "web_search") {
label = "知识库检索";
}
return (
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
{searchItems.length > 0 && (
<WebSearchResultsList results={searchItems} />
)}
@@ -440,18 +440,18 @@ function ToolCall({
if (!description) {
description = t.toolCalls.writeFile;
}
return (
<ArtifactWriteToolCall
id={id}
messageId={messageId}
name={name}
args={args}
result={result}
description={description}
isLast={isLast}
isLoading={isLoading}
/>
);
return (
<ArtifactWriteToolCall
id={id}
messageId={messageId}
name={name}
args={args}
result={result}
description={description}
isLast={isLast}
isLoading={isLoading}
/>
);
} else if (name === "bash") {
const description: string | undefined = (args as { description: string })
?.description;
@@ -512,129 +512,164 @@ function ToolCall({
icon={WrenchIcon}
></ChainOfThoughtStep>
);
}
}
function DeepResearchProgressStep({
id,
args,
}: {
id?: string;
args: Record<string, unknown>;
}) {
const label = typeof args.label === "string" && args.label.trim()
? args.label
: "正在撰写研究报告";
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
const active = args.status !== "complete";
return (
<ChainOfThoughtStep
key={id}
status={active ? "active" : "complete"}
icon={active
? <Loader2Icon className="text-primary size-4 animate-spin" />
: <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />}
label={
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span>{label}</span>
<span className={active ? "text-primary text-xs" : "text-muted-foreground text-xs"}>
{active ? "进行中" : "已完成"}
</span>
</span>
}
>
{detail && (
<div className="text-muted-foreground text-xs leading-5">
{detail}
</div>
)}
</ChainOfThoughtStep>
);
}
function ArtifactWriteToolCall({
id,
messageId,
name,
args,
result,
description,
isLast,
isLoading,
}: {
id?: string;
messageId?: string;
name: "write_file" | "str_replace";
args: Record<string, unknown>;
result?: string | Record<string, unknown>;
description: string;
isLast: boolean;
isLoading: boolean;
}) {
const artifacts = useArtifactsOptional();
const openStreamedArtifact = artifacts?.openStreamedArtifact;
const rawPath = typeof args.path === "string" ? args.path : "";
const content = typeof args.content === "string" ? args.content : "";
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
const hasUsablePath =
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
const sourcePath = hasUsablePath
? normalizedPath
: name === "write_file" && content.trim()
? provisionalWriteFilePath(content)
: null;
const previewKey = messageId && id ? `${messageId}:${id}` : null;
const artifactPath =
sourcePath && name === "write_file" && messageId && id
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
: sourcePath;
useEffect(() => {
if (
!openStreamedArtifact ||
!artifactPath ||
!previewKey ||
!isLoading ||
!isLast ||
result !== undefined
) {
return;
}
openStreamedArtifact(artifactPath, previewKey);
}, [
artifactPath,
isLast,
isLoading,
openStreamedArtifact,
previewKey,
result,
]);
return (
<ChainOfThoughtStep
key={id}
className={cn(artifactPath && "cursor-pointer")}
label={description}
icon={NotebookPenIcon}
onClick={() => {
if (!artifactPath || !artifacts) return;
artifacts.select(artifactPath);
artifacts.setOpen(true);
}}
>
{(normalizedPath || sourcePath) && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{normalizedPath || sourcePath}
</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
}
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
}
}
function DeepResearchProgressStep({
id,
args,
}: {
id?: string;
args: Record<string, unknown>;
}) {
const label = typeof args.label === "string" && args.label.trim()
? args.label
: "正在撰写研究报告";
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
const thinking = typeof args.thinking === "string" ? args.thinking.trim() : "";
const active = args.status !== "complete";
const [thinkingOpen, setThinkingOpen] = useState(false);
useEffect(() => {
if (active && thinking) setThinkingOpen(true);
}, [active, thinking]);
return (
<ChainOfThoughtStep
key={id}
status={active ? "active" : "complete"}
icon={active
? <Loader2Icon className="text-primary size-4 animate-spin" />
: <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />}
label={
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span>{label}</span>
<span className={active ? "text-primary text-xs" : "text-muted-foreground text-xs"}>
{active ? "进行中" : "已完成"}
</span>
</span>
}
>
{detail && (
<div className="text-muted-foreground text-xs leading-5">
{detail}
</div>
)}
{thinking && (
<Collapsible className="mt-2" onOpenChange={setThinkingOpen} open={thinkingOpen}>
<CollapsibleTrigger asChild>
<Button
aria-label={thinkingOpen ? "收起模型思考" : "展开模型思考"}
className="h-7 gap-1.5 px-2 text-xs"
size="sm"
type="button"
variant="ghost"
>
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
<span>模型思考</span>
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
<ChevronDownIcon className={cn("size-3.5 transition-transform", thinkingOpen && "rotate-180")} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
</p>
<pre className="bg-muted/45 text-muted-foreground mt-1 max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words">
{thinking}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</ChainOfThoughtStep>
);
}
function ArtifactWriteToolCall({
id,
messageId,
name,
args,
result,
description,
isLast,
isLoading,
}: {
id?: string;
messageId?: string;
name: "write_file" | "str_replace";
args: Record<string, unknown>;
result?: string | Record<string, unknown>;
description: string;
isLast: boolean;
isLoading: boolean;
}) {
const artifacts = useArtifactsOptional();
const openStreamedArtifact = artifacts?.openStreamedArtifact;
const rawPath = typeof args.path === "string" ? args.path : "";
const content = typeof args.content === "string" ? args.content : "";
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
const hasUsablePath =
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
const sourcePath = hasUsablePath
? normalizedPath
: name === "write_file" && content.trim()
? provisionalWriteFilePath(content)
: null;
const previewKey = messageId && id ? `${messageId}:${id}` : null;
// Never select a bare provisional name like `未命名文件.md` — the artifacts
// API only serves `/mnt/user-data/...` paths and would 400. Wait until the
// tool call has ids so we can open a write-file: memory preview instead.
const artifactPath =
sourcePath && name === "write_file" && messageId && id
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
: null;
useEffect(() => {
if (
!openStreamedArtifact ||
!artifactPath ||
!previewKey ||
!isLoading ||
!isLast ||
result !== undefined
) {
return;
}
openStreamedArtifact(artifactPath, previewKey);
}, [
artifactPath,
isLast,
isLoading,
openStreamedArtifact,
previewKey,
result,
]);
return (
<ChainOfThoughtStep
key={id}
className={cn(artifactPath && "cursor-pointer")}
label={description}
icon={NotebookPenIcon}
onClick={() => {
if (!artifactPath || !artifacts) return;
artifacts.select(artifactPath);
artifacts.setOpen(true);
}}
>
{(normalizedPath || sourcePath) && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{normalizedPath || sourcePath}
</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
}
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
type: T;
}
@@ -737,7 +772,7 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
open && "rotate-180",
)}
/>
<span>{results.length} 条真实返回结果</span>
<span>{results.length} 条真实返回结果</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 flex flex-col gap-2">
{results.map((item) => (
@@ -8,6 +8,7 @@ import {
Loader2Icon,
PencilIcon,
SearchIcon,
SparklesIcon,
} from 'lucide-react';
import {
memo,
@@ -18,6 +19,7 @@ import {
useState,
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
type ReactNode,
} from 'react';
import rehypeKatex from 'rehype-katex';
@@ -693,6 +695,17 @@ function MessageContent_({
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
return stripIncompleteCitationMarker(withCitations);
}, [rawContent, isHuman, displaySources]);
const reasoningToDisplay = useMemo(() => {
if (!reasoningContent) return '';
const withCitations = applyInlineCitations(reasoningContent, displaySources, {
appendSummary: false,
});
return stripIncompleteCitationMarker(withCitations);
}, [reasoningContent, displaySources]);
const markdownRehypePlugins = useMemo(
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
[rehypePlugins],
);
const parsedHumanContent = useMemo(
() =>
isHuman
@@ -722,23 +735,6 @@ function MessageContent_({
);
}
// Reasoning-only AI message (no main response content yet)
if (!isHuman && reasoningContent && !rawContent && !multiRoundTrace) {
return (
<AIElementMessageContent className={className}>
<Reasoning isStreaming={isLoading}>
<ReasoningTrigger />
<ReasoningContent>{reasoningContent}</ReasoningContent>
</Reasoning>
<MessageTokenUsage
enabled={tokenUsageEnabled}
isLoading={isLoading}
message={message}
/>
</AIElementMessageContent>
);
}
if (isHuman) {
// Only the user's own question carries a delete affordance, and only once
// it exists server-side (real, non-optimistic id). Deleting it cascades to
@@ -774,25 +770,43 @@ function MessageContent_({
<AIElementMessageContent className={className}>
{filesList}
<div data-reference-batch-id={message.id} className="contents">
{reasoningToDisplay && !multiRoundTrace && (
<Reasoning isStreaming={isLoading && !contentToDisplay}>
<ReasoningTrigger />
<ReasoningContent>
{isSingleRoundRag || displaySources.length > 0 ? (
<MarkdownContent
content={reasoningToDisplay}
isLoading={isLoading}
rehypePlugins={markdownRehypePlugins}
components={components}
className="text-muted-foreground text-sm"
/>
) : (
reasoningToDisplay
)}
</ReasoningContent>
</Reasoning>
)}
{multiRoundTrace ? (
<MultiRoundRagContent
trace={multiRoundTrace}
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
rehypePlugins={markdownRehypePlugins}
components={components}
/>
) : (
) : contentToDisplay ? (
<AssistantDisplayContent
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
renderSkillDisplayBlocks={false}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
rehypePlugins={markdownRehypePlugins}
components={components}
/>
)}
) : null}
</div>
{(isSingleRoundRag || isMultiRoundRag) && displaySources.length > 0 && (
<FrontendRagSourcesPanel
@@ -838,7 +852,7 @@ function FrontendRagSourcesPanel({
const [open, setOpen] = useState(false);
if (sources.length === 0) return null;
return (
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full">
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
<CollapsibleTrigger asChild>
<button
type="button"
@@ -947,11 +961,14 @@ function MultiRoundRagContent({
}) {
const [open, setOpen] = useState(!trace.isComplete);
useEffect(() => {
if (!trace.isComplete) setOpen(true);
// Keep the active turn expanded so each new retrieval is visible as it
// arrives, then match GeneralQA by collapsing the completed trace. The
// header still carries the total round count for quick scanning.
setOpen(!trace.isComplete);
}, [trace.isComplete]);
return (
<div className="space-y-3">
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<section className="border-border/70 overflow-hidden rounded-xl border bg-muted/20">
<button
type="button"
@@ -965,10 +982,10 @@ function MultiRoundRagContent({
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
)}
<div>
<div className="text-sm font-medium">多轮检索过程</div>
<div className="text-sm font-medium">多轮检索思考过程</div>
<div className="text-muted-foreground text-xs">
{trace.isComplete
? `已完成 ${trace.rounds.length} 轮检索`
? `共 ${trace.rounds.length} 轮推理与检索,可展开查看细节`
: '正在分析问题并检索资料'}
</div>
</div>
@@ -980,14 +997,26 @@ function MultiRoundRagContent({
{open && (
<div className="border-border/60 space-y-3 border-t px-4 py-3">
{trace.rounds.map((round, index) => (
<MultiRoundRagRoundCard
<div
key={round.id}
index={index + 1}
round={round}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
className={cn(
'relative pl-5',
index < trace.rounds.length - 1 && 'pb-1',
)}
>
{index < trace.rounds.length - 1 && (
<span className="bg-border absolute bottom-0 left-[7px] top-8 w-px" />
)}
<span className="border-primary bg-background absolute left-0 top-4 size-3.5 rounded-full border-2" />
<MultiRoundRagRoundCard
index={index + 1}
round={round}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
</div>
))}
</div>
)}
@@ -1017,85 +1046,244 @@ function MultiRoundRagContent({
function MultiRoundRagRoundCard({
index,
round,
displaySources,
isLoading,
rehypePlugins,
components,
}: {
index: number;
round: MultiRoundRagRound;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
const [open, setOpen] = useState(!round.query || round.isSearching);
useEffect(() => {
// A newly created round stays expanded to expose its thought and loading
// query. Once that query has returned, retain only the round header until
// the user asks to inspect the completed detail again.
setOpen(!round.query || round.isSearching);
}, [round.id, round.isSearching, round.query]);
const thoughtContent = useMemo(
() =>
applyInlineCitations(round.thought, displaySources, {
appendSummary: false,
}),
[displaySources, round.thought],
);
return (
<div className="border-border/60 rounded-lg border bg-background/70 p-3">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs">
{index}
</span>
<span>{round.title}</span>
{round.isSearching && <Loader2Icon className="text-primary size-3.5 animate-spin" />}
</div>
{round.thought && (
<section className="border-border/50 bg-muted/30 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">思考内容</div>
<MarkdownContent
content={round.thought}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-muted-foreground text-sm"
/>
</section>
)}
{round.body && (
<section className="border-border/50 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
<MarkdownContent
content={round.body}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-sm"
/>
</section>
)}
{round.query && (
<div className="border-primary/20 bg-primary/5 text-primary mt-2 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs">
<SearchIcon className="size-3 shrink-0" />
<span className="truncate">{round.query}</span>
</div>
)}
{round.retrievals.length > 0 && (
<section className="border-border/50 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">
检索内容 · {round.retrievals.length} 条
</div>
<div className="space-y-2">
{round.retrievals.map((item, resultIndex) => (
<details
key={`${item.id ?? item.title}-${resultIndex}`}
className="border-border/60 rounded-md border px-3 py-2"
open={resultIndex === 0}
<Collapsible open={open} onOpenChange={setOpen}>
<article className="border-border/60 bg-background/80 overflow-hidden rounded-xl border shadow-sm">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2.5 text-left text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
{index}
</span>
<span className="min-w-0 flex-1 truncate">{round.title}</span>
{round.isSearching ? (
<span className="bg-primary/10 text-primary inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<Loader2Icon className="size-3 motion-reduce:animate-none animate-spin" />
检索中
</span>
) : (
<span className="text-muted-foreground shrink-0 text-[11px]">已完成</span>
)}
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3">
<div className="pt-3">
{round.thought && (
<MultiRoundRagDetailSection
title="思考内容"
icon={<SparklesIcon className="text-primary size-4" />}
>
<summary className="cursor-pointer list-none text-sm font-medium">
<span className="mr-2 text-xs text-muted-foreground">{resultIndex + 1}.</span>
{item.title}
</summary>
<div className="text-muted-foreground mt-2 text-sm leading-6 whitespace-pre-wrap">
{item.content || '暂无资料摘要'}
</div>
{(item.source || item.time) && (
<div className="text-muted-foreground mt-2 text-xs">
{[item.source, item.time].filter(Boolean).join(' · ')}
<MarkdownContent
content={thoughtContent}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-muted-foreground text-sm"
/>
</MultiRoundRagDetailSection>
)}
{round.body && (
<section className="border-border/50 mt-3 rounded-lg border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
<MarkdownContent
content={round.body}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-sm"
/>
</section>
)}
{(round.query || round.isSearching) && (
<div
className={cn(
'border-primary/20 bg-primary/5 text-primary mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1.5 text-xs',
round.isSearching && 'animate-pulse motion-reduce:animate-none',
)}
aria-live={round.isSearching ? 'polite' : undefined}
>
{round.isSearching ? (
<Loader2Icon className="size-3.5 shrink-0 motion-reduce:animate-none animate-spin" />
) : (
<SearchIcon className="size-3.5 shrink-0" />
)}
<span className="shrink-0 text-muted-foreground">检索词</span>
<span className="min-w-0 truncate font-medium">
{round.query || '正在生成检索词…'}
</span>
</div>
)}
{(round.query || round.isSearching || round.retrievals.length > 0) && (
<MultiRoundRagDetailSection
defaultOpen={!round.isSearching}
title="检索内容"
icon={<FileIcon className="text-primary size-3.5" />}
suffix={<span className="text-muted-foreground text-xs">{round.retrievals.length} 条</span>}
>
{round.isSearching ? (
<div className="text-muted-foreground flex items-center gap-2 py-1 text-sm">
<Loader2Icon className="text-primary size-4 motion-reduce:animate-none animate-spin" />
正在召回相关内容…
</div>
) : (
<div className="space-y-2">
{round.retrievals.length > 0 ? (
round.retrievals.map((item, resultIndex) => (
<MultiRoundRagResultCard
key={`${item.id ?? item.title}-${resultIndex}`}
item={item}
index={resultIndex + 1}
/>
))
) : (
<p className="text-muted-foreground py-1 text-sm">
本轮未召回相关资料。
</p>
)}
</div>
)}
</details>
))}
</MultiRoundRagDetailSection>
)}
</div>
</section>
)}
</div>
</CollapsibleContent>
</article>
</Collapsible>
);
}
function MultiRoundRagDetailSection({
title,
icon,
suffix,
defaultOpen = true,
children,
}: {
title: string;
icon: ReactNode;
suffix?: ReactNode;
defaultOpen?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
useEffect(() => setOpen(defaultOpen), [defaultOpen]);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<section className="border-border/50 bg-muted/20 mt-3 overflow-hidden rounded-lg border">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
{icon}
<span className="min-w-0 flex-1 text-sm font-medium">{title}</span>
{suffix}
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
{children}
</CollapsibleContent>
</section>
</Collapsible>
);
}
function MultiRoundRagResultCard({
item,
index,
}: {
item: MultiRoundRagRound['retrievals'][number];
index: number;
}) {
const [open, setOpen] = useState(index === 1);
const sourceMeta = [item.source, item.time].filter(Boolean).join(' · ');
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className="border-border/60 hover:border-primary/30 bg-background rounded-lg border transition-colors">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-semibold">
{index}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={item.title}>
{item.title}
</span>
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
<p className="text-muted-foreground text-sm leading-6 whitespace-pre-wrap">
{item.content || '暂无资料摘要'}
</p>
{(sourceMeta || item.url) && (
<div className="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
{sourceMeta && <span>{sourceMeta}</span>}
{item.url && (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
查看原文
<ExternalLinkIcon className="size-3" />
</a>
)}
</div>
)}
</CollapsibleContent>
</div>
</Collapsible>
);
}
@@ -255,15 +255,15 @@ export function MessageList({
const [settings] = useLocalSettings();
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
const hasSingleRoundRag = useMemo(
const hasSingleRoundRag = useMemo(
() =>
messages.some(
(message) =>
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
),
[messages],
);
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
[messages],
);
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
@@ -436,11 +436,12 @@ export function MessageList({
className={cn("flex size-full flex-col justify-center", className)}
>
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
contentClassName,
)}
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg)",
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
contentClassName,
)}
>
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => {
@@ -54,6 +54,7 @@ import type {
CompactShortcutRouteParam,
} from "@/core/system-settings/api";
import { cn } from "@/lib/utils";
import { createClientId } from "@/lib/client-id";
import {
APP_THEME_COLORS,
LOGIN_SOURCE_OPTIONS,
@@ -77,10 +78,7 @@ const languageOptions: { value: Locale; label: string }[] = [
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue";
function makeShortcutId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return createClientId("shortcut");
}
function newShortcutParam(): CompactShortcutRouteParam {
@@ -43,6 +43,7 @@ import {
customThemeTextContrast,
type CustomThemeAdvancedKey,
} from "@/core/theme/custom-theme";
import { createClientId } from "@/lib/client-id";
import { cn } from "@/lib/utils";
const MAX_CUSTOM_THEMES = 12;
@@ -127,10 +128,7 @@ const ADVANCED_COLOR_FIELDS: Array<{
];
function makeThemeId(): `custom-theme-${string}` {
const suffix =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "").toLowerCase()
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
const suffix = createClientId().replace(/[^a-z0-9-]/gi, "").toLowerCase();
return `custom-theme-${suffix}`;
}
@@ -10,6 +10,7 @@ import {
DatabaseZapIcon,
GaugeIcon,
HistoryIcon,
ListTreeIcon,
MessageCircleQuestionIcon,
ScrollTextIcon,
Settings2Icon,
@@ -226,6 +227,16 @@ export function WorkspaceNavMenu() {
技能管理
</DropdownMenuItem>
)}
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口(管理员):
简洁模式没有全局侧边栏,这里放一份保证两种模式都能进菜单管理。 */}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/menu-management")}
>
<ListTreeIcon />
菜单管理
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
+14 -8
View File
@@ -61,9 +61,11 @@ export interface Agent {
/** 会商席位「技能识别强化」开关。作为会商席位运行时,是否把本智能体配置的技能
* (名称 + SKILL.md 路径 + 必须先 read_file 的硬指令) 拼进本轮任务以补偿弱模型。
* 与业务链条同名开关取 OR。null/false = 关 (默认)。 */
seat_skill_directive?: boolean | null;
/** DeerFlow LLMWiki mapping ids this agent allows conversations to select. */
llmwiki_knowledge_base_ids?: string[] | null;
seat_skill_directive?: boolean | null;
/** 岗位职责。会商智能体配置面板展示在常用问题上方。空/缺省 = 未填写。 */
job_responsibilities?: string | null;
/** DeerFlow LLMWiki mapping ids this agent allows conversations to select. */
llmwiki_knowledge_base_ids?: string[] | null;
tags?: Tag[];
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
@@ -98,8 +100,10 @@ export interface CreateAgentRequest {
common_questions?: AgentQuestion[] | null;
template_name?: string | null;
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null;
llmwiki_knowledge_base_ids?: string[] | null;
seat_skill_directive?: boolean | null;
/** 岗位职责;缺省/空 = 未填写。 */
job_responsibilities?: string | null;
llmwiki_knowledge_base_ids?: string[] | null;
}
export interface UpdateAgentRequest {
@@ -116,6 +120,8 @@ export interface UpdateAgentRequest {
/** 入库模板名;传空字符串表示清除。 */
template_name?: string | null;
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null;
llmwiki_knowledge_base_ids?: string[] | null;
}
seat_skill_directive?: boolean | null;
/** 岗位职责;传空字符串表示清除。 */
job_responsibilities?: string | null;
llmwiki_knowledge_base_ids?: string[] | null;
}
+2 -1
View File
@@ -6,6 +6,7 @@ import { useThread } from "@/components/workspace/messages/context";
import { isImageFile } from "../utils/files";
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
import { isArtifactApiPath } from "./streaming-write-preview";
import { updateArtifactContent } from "./api";
function isWriteFileStreamingUrl(filepath: string): boolean {
@@ -101,7 +102,7 @@ export function useArtifactContent({
queryFn: () => {
return loadArtifactContent({ filepath, threadId, isMock, publicShareCode });
},
enabled,
enabled: Boolean(enabled) && !useWriteFileStreaming && isArtifactApiPath(filepath),
refetchInterval: thread.isLoading ? 1000 : false,
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
staleTime: 5 * 60 * 1000,
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isArtifactApiPath,
isProvisionalStreamingArtifactPath,
provisionalWriteFilePath,
} from "./streaming-write-preview";
test("provisionalWriteFilePath uses html only for html documents", () => {
assert.equal(provisionalWriteFilePath("# title"), "未命名文件.md");
assert.equal(provisionalWriteFilePath("<!DOCTYPE html><html>"), "未命名文件.html");
assert.equal(provisionalWriteFilePath("<html lang='zh'>"), "未命名文件.html");
});
test("isProvisionalStreamingArtifactPath matches the streaming placeholder names", () => {
assert.equal(isProvisionalStreamingArtifactPath("未命名文件.md"), true);
assert.equal(isProvisionalStreamingArtifactPath("/未命名文件.html"), true);
assert.equal(isProvisionalStreamingArtifactPath("mnt/user-data/outputs/report.md"), false);
});
test("isArtifactApiPath rejects write-file URLs and placeholder names", () => {
assert.equal(isArtifactApiPath("未命名文件.md"), false);
assert.equal(
isArtifactApiPath(
"write-file:未命名文件.md?message_id=m1&tool_call_id=t1&language=markdown",
),
false,
);
assert.equal(isArtifactApiPath("mnt/user-data/outputs/report.md"), true);
assert.equal(isArtifactApiPath("/mnt/user-data/outputs/report.md"), true);
});
@@ -23,6 +23,11 @@ export function hasCompleteArtifactExtension(path: string): boolean {
return /\.[a-z0-9]{2,12}$/i.test(filename);
}
export const PROVISIONAL_WRITE_FILE_NAMES = new Set([
"未命名文件.md",
"未命名文件.html",
]);
export function provisionalWriteFilePath(content: string): string {
const head = content.slice(0, 256).trimStart().toLowerCase();
if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
@@ -31,6 +36,29 @@ export function provisionalWriteFilePath(content: string): string {
return "未命名文件.md";
}
export function isProvisionalStreamingArtifactPath(path: string): boolean {
const pathname =
normalizeStreamingArtifactPath(path).split(/[?#]/, 1)[0] ?? "";
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
return PROVISIONAL_WRITE_FILE_NAMES.has(filename);
}
/** Paths the artifacts HTTP API can actually serve (sandbox virtual paths). */
export function isArtifactApiPath(path: string): boolean {
if (!path || path.startsWith("write-file:")) {
return false;
}
if (isProvisionalStreamingArtifactPath(path)) {
return false;
}
const normalized = path.replace(/^\/+/, "").replace(/\\/g, "/");
return (
normalized === "mnt/user-data" ||
normalized.startsWith("mnt/user-data/") ||
normalized.startsWith("mnt/skills/")
);
}
export function languageOfStreamingArtifact(
path: string,
): StreamingArtifactLanguage | null {
@@ -6,6 +6,7 @@ import type {
DocumentRewriteStage,
} from "@/open-canvas/hooks/useDocumentRewrite";
import type { DocumentRewriteSummary } from "@/open-canvas/api/rewrite";
import { createClientId } from "@/lib/client-id";
export type DocumentRewritePresentation = {
id: string;
@@ -19,11 +20,17 @@ export type DocumentRewritePresentation = {
instruction: string;
style: string;
modelName: string | null;
generateImages: boolean;
maxGeneratedImages: number;
generatedImageCount: number;
illustrationTarget: string;
stage: DocumentRewriteStage;
originalCharCount: number;
writtenCharCount: number;
/** Short model-generated plan, streamed while it understands the request. */
requirementsPlan: string;
/** Ephemeral thought trace. Never included in the Markdown draft or persisted job state. */
thinkingText: string;
warnings: string[];
summary: DocumentRewriteSummary | null;
/** Kept in-memory for the line-level compare dialog; backend job hydration refills it. */
@@ -42,9 +49,14 @@ type CreatePresentationInput = Omit<
DocumentRewritePresentation,
| "id"
| "stage"
| "generateImages"
| "maxGeneratedImages"
| "generatedImageCount"
| "illustrationTarget"
| "originalCharCount"
| "writtenCharCount"
| "requirementsPlan"
| "thinkingText"
| "warnings"
| "summary"
| "originalContent"
@@ -59,6 +71,8 @@ type CreatePresentationInput = Omit<
originalContent?: string;
rewrittenContent?: string | null;
requirementsPlan?: string;
generateImages?: boolean;
maxGeneratedImages?: number;
};
type DocumentRewritePresentationContextValue = {
@@ -74,10 +88,7 @@ const DocumentRewritePresentationContext =
createContext<DocumentRewritePresentationContextValue | null>(null);
function createId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return "drw-ui-" + crypto.randomUUID();
}
return "drw-ui-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
return createClientId("drw-ui");
}
export function DocumentRewritePresentationProvider({
@@ -96,9 +107,14 @@ export function DocumentRewritePresentationProvider({
...input,
id,
stage: "snapshot",
generateImages: input.generateImages === true,
maxGeneratedImages: Math.min(Math.max(input.maxGeneratedImages ?? 2, 1), 4),
generatedImageCount: 0,
illustrationTarget: "",
originalCharCount: 0,
writtenCharCount: 0,
requirementsPlan: input.requirementsPlan ?? "",
thinkingText: "",
warnings: [],
summary: null,
originalContent: input.originalContent ?? "",
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyCompactMenuOverrides, isDefaultCompactMenuType, normalizeCompactMenuType } from "./compact-menu-overrides";
import type { MenuItem } from "./sidebar-menu";
const I = (() => null) as unknown as MenuItem["icon"];
test("normalizeCompactMenuType defaults to A", () => {
assert.equal(normalizeCompactMenuType(undefined), "A");
assert.equal(normalizeCompactMenuType(""), "A");
assert.equal(normalizeCompactMenuType("default"), "A");
assert.equal(normalizeCompactMenuType("b"), "B");
assert.equal(normalizeCompactMenuType("2"), "B");
assert.equal(normalizeCompactMenuType("alt"), "B");
assert.equal(isDefaultCompactMenuType(undefined), true);
assert.equal(isDefaultCompactMenuType(""), true);
assert.equal(isDefaultCompactMenuType("B"), false);
});
test("applyCompactMenuOverrides splits sidebar vs settings", () => {
const items: MenuItem[] = [
{ id: "a", label: "对话", icon: I, path: "/a" },
{ id: "b", label: "智能体", icon: I, path: "/b" },
];
const applied = applyCompactMenuOverrides(items, [
{ nodeId: "b", placement: "settings", sortOrder: 0, label: "智能体设置" },
]);
assert.deepEqual(
applied.sidebarItems.map((item) => item.id),
["a"],
);
assert.equal(applied.settingsItems[0]?.id, "b");
assert.equal(applied.settingsItems[0]?.label, "智能体设置");
});
@@ -1,7 +1,41 @@
import type { MenuItem } from "./sidebar-menu";
import { readRuntimeEnv } from "@/env";
export type CompactMenuPlacement = "sidebar" | "settings";
export const COMPACT_MENU_TYPES = ["A", "B"] as const;
export type CompactMenuType = (typeof COMPACT_MENU_TYPES)[number];
export const DEFAULT_COMPACT_MENU_TYPE: CompactMenuType = "A";
const TYPE_ALIASES: Record<string, CompactMenuType> = {
A: "A",
"1": "A",
DEFAULT: "A",
B: "B",
"2": "B",
ALT: "B",
};
export function normalizeCompactMenuType(value?: string | null): CompactMenuType {
const raw = (value ?? "").trim().toUpperCase();
return TYPE_ALIASES[raw] ?? DEFAULT_COMPACT_MENU_TYPE;
}
export function isDefaultCompactMenuType(value?: string | null): boolean {
return normalizeCompactMenuType(value) === DEFAULT_COMPACT_MENU_TYPE;
}
/** Compact-menu variant for this website, from runtime-config.js.
* Missing / empty config is type A, which loads the original compact menu. */
export function getRuntimeCompactMenuType(): CompactMenuType {
return normalizeCompactMenuType(readRuntimeEnv("VITE_COMPACT_MENU_TYPE"));
}
export const COMPACT_MENU_TYPE_LABELS: Record<CompactMenuType, string> = {
A: "类型 A",
B: "类型 B",
};
export interface CompactMenuOverride {
nodeId: string;
placement: CompactMenuPlacement;
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { runSentimentAgent, toAgUiMessages } from "./api";
import { createClientId } from "@/lib/client-id";
const STORAGE_PREFIX = "workspace.sentiment-agent.";
@@ -53,9 +54,9 @@ export function useSentimentAgentChat(threadId: string) {
const question = query.trim();
if (!question) return;
const assistantId = `sentiment-ai-${crypto.randomUUID()}`;
const assistantId = createClientId("sentiment-ai");
const userMessage: Message = {
id: `sentiment-human-${crypto.randomUUID()}`,
id: createClientId("sentiment-human"),
type: "human",
content: [{ type: "text", text: question }],
additional_kwargs: {
@@ -115,7 +116,7 @@ export function useSentimentAgentChat(threadId: string) {
try {
await runSentimentAgent({
threadId,
runId: `run-${crypto.randomUUID()}`,
runId: createClientId("run"),
messages: history,
signal: controller.signal,
handlers: {
@@ -82,3 +82,14 @@ test("accepts spaced and full-width square brackets", () => {
assert.equal(out.includes(citation(1)), true);
assert.equal(out.includes(citation(2)), true);
});
test("can skip the trailing source summary", () => {
const withSummary = applyInlineCitations("没有编号。", makeSources(2));
assert.equal(withSummary.includes("参考来源"), true);
const withoutSummary = applyInlineCitations("没有编号。", makeSources(2), {
appendSummary: false,
});
assert.equal(withoutSummary, "没有编号。");
assert.equal(withoutSummary.includes("#reference-"), false);
});
@@ -509,6 +509,7 @@ export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceI
export function applyInlineCitations(
content: string,
sources: RuntimeDisplaySource[],
options?: { appendSummary?: boolean },
): string {
if (!content || sources.length === 0) {
return content;
@@ -563,6 +564,9 @@ export function applyInlineCitations(
if (replaced || hasCitationMarkdown(next)) {
return next;
}
if (options?.appendSummary === false) {
return next;
}
return appendCitationSummary(next, referenceSources);
}
@@ -0,0 +1,90 @@
/**
* Some OpenAI-compatible reasoning models write ``<think>...</think>`` into
* the ordinary text stream rather than a dedicated reasoning field. Markdown
* sandboxes must never render those fragments as document content. Callers
* can separately render the returned thinking part in a labelled UI trace.
*
* The filter is stateful because either tag can be split across arbitrary SSE
* deltas. Hold only a possible tag suffix, so confirmed Markdown continues
* to appear with the normal token-streaming cadence.
*/
export class InlineThinkTagFilter {
private static readonly OPEN_TAG = "<think>";
private static readonly CLOSE_TAG = "</think>";
private insideThink = false;
private pending = "";
private possibleTagPrefixSize(content: string, tag: string) {
const lowered = content.toLowerCase();
for (let size = Math.min(content.length, tag.length - 1); size > 0; size -= 1) {
if (lowered.slice(-size) === tag.slice(0, size)) return size;
}
return 0;
}
pushParts(chunk: string): { text: string; thinking: string } {
if (!chunk) return { text: "", thinking: "" };
let remaining = this.pending + chunk;
this.pending = "";
let visible = "";
let thinking = "";
while (remaining) {
const tag = this.insideThink
? InlineThinkTagFilter.CLOSE_TAG
: InlineThinkTagFilter.OPEN_TAG;
const tagIndex = remaining.toLowerCase().indexOf(tag);
if (tagIndex >= 0) {
if (this.insideThink) thinking += remaining.slice(0, tagIndex);
else visible += remaining.slice(0, tagIndex);
remaining = remaining.slice(tagIndex + tag.length);
this.insideThink = !this.insideThink;
continue;
}
const suffixSize = this.possibleTagPrefixSize(remaining, tag);
const confirmed = suffixSize ? remaining.slice(0, -suffixSize) : remaining;
if (this.insideThink) thinking += confirmed;
else visible += confirmed;
this.pending = suffixSize ? remaining.slice(-suffixSize) : "";
break;
}
return { text: visible, thinking };
}
push(chunk: string) {
return this.pushParts(chunk).text;
}
/** Flush a completed stream into its visible/thinking channels. */
finishParts(): { text: string; thinking: string } {
if (this.insideThink) {
const thinking = this.pending;
this.insideThink = false;
this.pending = "";
return { text: "", thinking };
}
const tail = this.pending;
this.pending = "";
return { text: tail, thinking: "" };
}
finish() {
return this.finishParts().text;
}
/** Replace a rendered checkpoint while retaining the correct tag state. */
replace(content: string) {
this.insideThink = false;
this.pending = "";
return this.push(content);
}
}
export function stripInlineThinkTags(content: string) {
const filter = new InlineThinkTagFilter();
return filter.push(content) + filter.finish();
}
File diff suppressed because it is too large. Load diff
@@ -62,6 +62,37 @@ export function isThreadNotAccessibleError(message: string): boolean {
);
}
export function isAbortLikeError(error: unknown): boolean {
if (error instanceof DOMException && error.name === "AbortError") {
return true;
}
if (error instanceof Error && error.name === "AbortError") {
return true;
}
const message = getStreamErrorMessage(error).toLowerCase();
return (
message.includes("aborterror") ||
message.includes("the operation was aborted") ||
message.includes("bodystreambuffer")
);
}
export function isTransientStreamTransportError(message: string): boolean {
const lowered = message.toLowerCase();
return (
/failed to fetch/i.test(message) ||
lowered.includes("networkerror") ||
lowered.includes("network error") ||
lowered.includes("load failed") ||
lowered.includes("err_connection") ||
lowered.includes("err_incomplete") ||
lowered.includes("err_network") ||
lowered.includes("econnreset") ||
lowered.includes("econnaborted") ||
/fetch failed/i.test(message)
);
}
/** Build a label for the *current* logged-in identity (email → user_id → 未知). */
export function buildCurrentUserLabel(): string {
const auth = getStoredAuth();
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { readRuntimeEnv } from "@/env";
import { createClientId } from "@/lib/client-id";
const STORAGE_PREFIX = "workspace.multi-round-rag.";
@@ -13,6 +14,9 @@ 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<string, unknown>;
};
export type MultiRoundRagRound = {
@@ -74,7 +78,7 @@ function writeStoredMessages(threadId: string, messages: Message[]) {
}
function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`;
return createClientId(prefix);
}
function isAbortError(error: unknown) {
@@ -87,8 +91,11 @@ function asRecord(value: unknown): Record<string, unknown> | null {
: null;
}
function asString(value: unknown): string {
return typeof value === "string" ? value : "";
/** 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);
return "";
}
function parseJson(value: unknown): unknown {
@@ -103,8 +110,11 @@ 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 {
return JSON.parse(trimmed.slice("data:".length).trim()) as MultiRoundEvent;
const parsed = parseJson(payload);
return asRecord(parsed) as MultiRoundEvent | null;
} catch {
return null;
}
@@ -141,10 +151,10 @@ function addThinkingRound(trace: MultiRoundRagTrace, thought: string) {
if (current.title === "正在准备检索" && !current.thought && !current.query) {
current.title = "思考过程";
current.thought = thought;
return;
return current;
}
current.isSearching = false;
trace.rounds.push({
const next = {
id: createId("multi-rag-round"),
title: "思考过程",
thought,
@@ -152,7 +162,9 @@ function addThinkingRound(trace: MultiRoundRagTrace, thought: string) {
query: "",
isSearching: true,
retrievals: [],
});
};
trace.rounds.push(next);
return next;
}
function appendRoundBody(trace: MultiRoundRagTrace, body: string) {
@@ -162,49 +174,94 @@ function appendRoundBody(trace: MultiRoundRagTrace, body: string) {
current.body = `${current.body ?? ""}${body}`;
}
function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] {
const root = asRecord(parseJson(value));
const list =
(Array.isArray(root?.results) && root.results) ||
(Array.isArray(root?.organic) && root.organic) ||
[];
return list.flatMap((item, index) => {
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<string, unknown>[] {
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);
if (!record) return [];
const recUuid = asString(record.recUuid ?? record.rec_uuid);
const url = asString(record.url ?? record.link ?? record.href);
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: asString(record.id) || recUuid || undefined,
title: asString(record.title) || `检索资料 ${index + 1}`,
content: asString(record.content ?? record.snippet ?? record.text),
source: asString(record.source ?? record.source1 ?? url) || undefined,
time: asString(record.publish_time ?? record.m_publish ?? record.date) || undefined,
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 record = asRecord(value);
const list = Array.isArray(record?.references) ? record.references : [];
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 = asString(source.content ?? source.snippet);
const rawUrl = asString(source.url ?? source.link ?? source.href);
const recUuid = asString(source.recUuid ?? source.rec_uuid ?? source.uuid);
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: asString(source.id) || recUuid || undefined,
title: asString(source.title) || `参考资料 ${index + 1}`,
type: asString(source.type) || "参考资料",
snippet: asString(source.snippet) || content,
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: asString(source.source ?? source.source1) || undefined,
time: asString(source.time ?? source.m_publish ?? source.publish_time) || undefined,
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,
},
@@ -222,6 +279,71 @@ function cloneTrace(trace: MultiRoundRagTrace): MultiRoundRagTrace {
};
}
function toolTitle(toolName: string): string {
switch (toolName) {
case "local_search":
return "本地知识检索";
case "tavily_search":
return "联网检索";
case "google_search":
case "getTaskLikeTop":
return "思考过程";
case "search_user_profile":
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 "获取新闻详细内容";
default:
return toolName || "检索过程";
}
}
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<string, unknown>, result: unknown): string {
const resultRecord = asRecord(parseJson(result));
const direct = asText(input.q ?? input.query ?? input.keyword ?? input.user_fig);
if (direct) return direct;
if (input.s || input.e) {
return [asText(input.s), asText(input.e)].filter(Boolean).join(" - ");
}
return asText(resultRecord?.query ?? resultRecord?.q);
}
function textFromEventData(value: unknown): string {
const parsed = parseJson(value);
if (typeof parsed === "string") return parsed;
const record = asRecord(parsed);
return asText(record?.text ?? record?.content ?? record?.message ?? record?.answer ?? record?.output);
}
function isSingleRoundRagMessage(message: Message) {
return message.additional_kwargs?.single_round_rag === true;
}
@@ -370,92 +492,204 @@ export function useMultiRoundRagMessages(threadId: string) {
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("</think>");
if (endIndex < 0) {
activeThoughtRound.thought += text;
return;
}
activeThoughtRound.thought += text.slice(0, endIndex);
const roundBody = text.slice(endIndex + "</think>".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("<think>");
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 <think>…</think> 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("</think>", thinkStart + "<think>".length);
const thought = text
.slice(thinkStart + "<think>".length, thinkEnd < 0 ? undefined : thinkEnd)
.trim();
const thoughtRound = addThinkingRound(trace, thought);
if (thinkEnd < 0) {
activeThoughtRound = thoughtRound;
activeThoughtTailIsAnswer = tailIsAnswer;
return;
}
const roundBody = text.slice(thinkEnd + "</think>".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
// `<think>` 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 });
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? "";
for (const line of lines) {
const event = parseEventLine(line);
if (!event) continue;
const eventType = asString(event.event);
const data = event.data;
if (eventType === "tool_call") {
const tool = asRecord(data);
const toolName = asString(tool?.tool_name);
const input = asRecord(tool?.tool_input) ?? {};
const current = latestRound(trace);
if (toolName === "show_text") {
const text = asString(input.text);
if (text.includes("<think>")) {
const thinkEnd = text.indexOf("</think>");
addThinkingRound(
trace,
(thinkEnd >= 0
? text.slice(0, thinkEnd + "</think>".length)
: text
)
.replace(/<\/?think>/g, "")
.trim(),
);
if (thinkEnd >= 0) {
const roundBody = text
.slice(thinkEnd + "</think>".length)
.trim();
if (roundBody) {
appendRoundBody(trace, roundBody);
}
}
} else {
// A non-thinking `show_text` immediately after a thought is
// this round's narrative; the terminal `message` event is
// still rendered only as the final answer below all rounds.
current.isSearching = false;
trace.answer += text;
trace.isComplete = true;
}
} else if (
toolName === "local_search" ||
toolName === "tavily_search" ||
toolName === "google_search"
) {
const queryData = asRecord(parseJson(input.result));
current.title =
toolName === "local_search" ? "本地知识检索" : "联网检索";
current.query =
asString(input.q ?? input.query) ||
asString(queryData?.query) ||
current.query;
const retrievals = mapRetrievals(input.result);
if (retrievals.length > 0) current.retrievals = retrievals;
current.isSearching = false;
} else if (toolName === "get_textid_content") {
const article = asRecord(parseJson(input.result));
const body =
asString(article?.content ?? article?.text) ||
asString(input.content);
if (body) appendRoundBody(trace, body);
current.isSearching = false;
}
refreshAssistant();
} else if (eventType === "message" && typeof data === "string") {
latestRound(trace).isSearching = false;
trace.answer += data;
trace.isComplete = true;
refreshAssistant();
} else if (eventType === "references") {
sources = mapReferences(data);
refreshAssistant();
} else if (eventType === "done") {
trace.isComplete = true;
for (const round of trace.rounds) round.isSearching = false;
refreshAssistant();
}
}
consumeLines();
}
trace.isComplete = true;
for (const round of trace.rounds) round.isSearching = false;
buffer += decoder.decode();
consumeLines(true);
finishTrace();
refreshAssistant();
} catch (caught) {
trace.isComplete = true;
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { readRuntimeEnv } from "@/env";
import { createClientId } from "@/lib/client-id";
const STORAGE_PREFIX = "workspace.single-round-rag.";
@@ -216,6 +217,8 @@ function toRagSource(
content,
source,
time: readString(record.m_publish, record.m_publicsh, record.time),
// GeneralQA uses recUuid to open the knowledge-detail page. Prefer that
// stable internal record link, then fall back to a backend-provided URL.
url:
buildKnowledgeDetailUrl(recUuid) ??
(rawUrl && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined),
@@ -227,7 +230,7 @@ function toRagSource(
* A single-RAG stream is a concatenation of source envelopes, for example:
* `{docs:["..."], recUuid:"...", m_title:"...", m_content:"..."}`.
* The root-level metadata is the source; `docs` merely marks the envelope and
* supplies a fallback body. This mirrors GeneralQA's per-chunk processing.
* supplies a fallback body. This mirrors GeneralQA's per-chunk processing.
*/
function normalizeSources(chunk: KnowledgeBaseChunk): RagSource[] {
const hasRootMetadata = [
@@ -275,7 +278,7 @@ function mergeSources(current: RagSource[], incoming: RagSource[]): RagSource[]
}
function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`;
return createClientId(prefix);
}
function isAbortError(error: unknown) {
@@ -0,0 +1,392 @@
import { getBackendBaseURL } from "@/core/config";
import { readRuntimeEnv } from "@/env";
export interface EightBfTask {
id: string;
externalTaskId: string;
title: string;
description: string;
direction: string;
overview: string;
priority: string;
taskPriority: string;
targetDirection: string;
taskStatus: string;
source: string;
raw: Record<string, unknown> | null;
}
export interface EightBfDictionaryOption {
label: string;
value: string;
alias?: string;
statusTone?: string;
}
export interface EightBfTaskDictionaries {
priority: EightBfDictionaryOption[];
direction: EightBfDictionaryOption[];
status: EightBfDictionaryOption[];
}
export interface EightBfTaskContextPayload {
kind: "eightbf_roundtable_task";
usage: string;
task_id: string;
task: {
id: string;
title: string;
description: string;
direction: string;
overview: string;
priority: string;
task_priority: string;
target_direction: string;
task_status: string;
};
task_tags: {
priority: string;
direction: string;
status: string;
};
}
/**
* 外部任务系统在离线部署或本地联调时可能不可用。此回退仅用于页面展示与
* 意图上下文,保持与岗位会商示例一致,避免左上任务卡退化成接口错误文本。
*/
export function createEightBfFallbackTask(taskId: string): EightBfTask {
return {
id: taskId,
externalTaskId: taskId,
title: "暑期活动",
description: "围绕暑期活动的游泳夏令营开展宣传,结合线上线下渠道吸引目标人群参与,持续提升活动影响力。",
direction: "暑期活动",
overview: "面向家长群体传播暑期游泳夏令营活动信息,提升品牌曝光与触达声量。",
priority: "urgent",
taskPriority: "urgent",
targetDirection: "summer_activity",
taskStatus: "system_created",
source: "本系统创建",
raw: null,
};
}
const TASK_DETAIL_PATH = "/task/{taskId}";
const PROCESS_REPORT_ADD_PATH = "/task/process-report/add";
const PROCESS_REPORT_PATH = "/task/process-report/{id}";
const DICTIONARY_TYPES = {
priority: "task_priority",
direction: "task_direction",
status: "task_status",
} as const;
function externalApiBase(): string {
// The position-roundtable Vue app calls this task system through
// VITE_API_BASE_URL (usually the /zncm gateway), not DeerFlow's own API.
return (
readRuntimeEnv("VITE_8BF_TASK_API_BASE_URL")?.replace(/\/+$/, "") ||
readRuntimeEnv("VITE_API_BASE_URL")?.replace(/\/+$/, "") ||
getBackendBaseURL().replace(/\/+$/, "")
);
}
/** 8BF 会商页右上角「返回」按钮的跳转地址,来自 runtime-config.js。 */
export function getEightBfRoundtableBackUrl(): string {
return readRuntimeEnv("VITE_8BF_ROUNDTABLE_BACK_URL") ?? "";
}
function externalUrl(path: string): string {
if (/^https?:\/\//i.test(path)) return path;
return `${externalApiBase()}${path.startsWith("/") ? path : `/${path}`}`;
}
export interface EightBfTaskProcessReportInput {
taskId: string;
agentId: string;
agentName: string;
content: string;
/** 与岗位会商一致:测试分析岗的过程产物位置为 0。 */
position: number;
reportName: string;
submitStatus: 0 | 1;
}
export interface EightBfTaskProcessReportReceipt {
id: string;
}
/**
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
* 两套 token 的用途不同,不能直接复用 `apiFetch` 的默认鉴权头;否则
* 任务详情/字典接口可能返回 401,页面只会看到空任务卡。
*
* 这里仍保留 include credentials,与原 Vue 请求保持一致;同时不走
* `apiFetch` 的 401 自动清理逻辑,避免外部任务系统的鉴权失败误登出
* DeerFlow 当前用户。
*/
async function fetchExternalTaskSystem(
path: string,
init: RequestInit = {},
): Promise<Response> {
const headers = new Headers(init.headers);
if (!headers.has("Authorization")) {
headers.set("Authorization", "Bearer admin");
}
if (!headers.has("Accept")) {
headers.set("Accept", "application/json");
}
return fetch(externalUrl(path), {
...init,
credentials: init.credentials ?? "include",
headers,
});
}
async function externalTaskError(response: Response, fallback: string): Promise<Error> {
const text = await response.text().catch(() => "");
try {
const payload = JSON.parse(text) as { message?: unknown; msg?: unknown; detail?: unknown };
const detail = firstText(payload.message, payload.msg, payload.detail);
if (detail) return new Error(`${fallback}(${response.status}):${detail}`);
} catch {
// Keep the HTTP status when this deployment returns a non-JSON error body.
}
return new Error(`${fallback}(${response.status})`);
}
function findProcessReportId(payload: unknown): string {
if (!payload || typeof payload !== "object") return "";
const source = payload as Record<string, unknown>;
const direct = firstText(
source.id,
source.processReportId,
source.process_report_id,
source.reportId,
);
if (direct) return direct;
return findProcessReportId(source.data);
}
/**
* Create the external task system's process report in its unsubmitted state.
* The local gateway stores the returned id so a refresh can later submit the
* same report instead of creating a duplicate one.
*/
export async function createEightBfTaskProcessReport(
input: EightBfTaskProcessReportInput,
): Promise<EightBfTaskProcessReportReceipt> {
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
const id = findProcessReportId(await response.json().catch(() => null));
if (!id) throw new Error("过程产物登记成功,但接口未返回报告 ID");
return { id };
}
/** Submit an already-registered process report to the external task system. */
export async function updateEightBfTaskProcessReport(
reportId: string,
input: EightBfTaskProcessReportInput,
): Promise<void> {
const response = await fetchExternalTaskSystem(
PROCESS_REPORT_PATH.replace("{id}", encodeURIComponent(reportId)),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
},
);
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
}
function firstText(...values: unknown[]): string {
for (const value of values) {
if (value === undefined || value === null) continue;
const text = String(value).trim();
if (text) return text;
}
return "";
}
function unwrapData(payload: unknown): Record<string, unknown> {
let current: unknown = payload;
// Task services in different deployments wrap the object as either
// { data: task } or { data: { data: task } }.
for (let index = 0; index < 3; index += 1) {
if (!current || typeof current !== "object" || Array.isArray(current)) break;
const next = (current as { data?: unknown }).data;
if (!next || typeof next !== "object" || Array.isArray(next)) break;
current = next;
}
return current && typeof current === "object" && !Array.isArray(current)
? (current as Record<string, unknown>)
: {};
}
export function normalizeEightBfTask(
raw: Record<string, unknown> | null | undefined,
fallbackTaskId = "",
): EightBfTask {
const source = raw ?? {};
const id = firstText(
source.id,
source.taskId,
source.externalTaskId,
source.external_task_id,
fallbackTaskId,
);
return {
id,
externalTaskId: firstText(source.externalTaskId, source.external_task_id, id),
title: firstText(source.title, source.taskName, source.task_name, source.name),
description: firstText(
source.description,
source.taskDescription,
source.task_description,
source.taskContent,
source.task_content,
source.content,
),
direction: firstText(
source.direction,
source.taskDirection,
source.task_direction,
),
overview: firstText(source.overview, source.summary),
priority: firstText(source.priority, source.urgency),
taskPriority: firstText(
source.taskPriority,
source.task_priority,
source.priority,
source.urgency,
),
targetDirection: firstText(
source.targetDirection,
source.target_direction,
source.direction,
source.taskDirection,
source.task_direction,
),
taskStatus: firstText(source.taskStatus, source.task_status, source.status),
source: "测试分析岗任务详情接口",
raw: source,
};
}
function dictionaryRows(payload: unknown): unknown[] {
if (Array.isArray(payload)) return payload;
if (payload && typeof payload === "object") {
const directData = (payload as { data?: unknown }).data;
if (Array.isArray(directData)) return directData;
if (directData && typeof directData === "object") {
const nested = directData as { rows?: unknown; list?: unknown };
if (Array.isArray(nested.rows)) return nested.rows;
if (Array.isArray(nested.list)) return nested.list;
}
}
const data = unwrapData(payload);
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.list)) return data.list;
return [];
}
function normalizeDictionary(payload: unknown): EightBfDictionaryOption[] {
return dictionaryRows(payload)
.map((item) => {
if (!item || typeof item !== "object") return null;
const row = item as Record<string, unknown>;
const label = firstText(row.dictLabel, row.label);
const value = firstText(row.dictValue, row.dict_value, row.value);
if (!label || !value) return null;
return {
label,
value,
alias: firstText(row.alias) || undefined,
statusTone: firstText(row.statusTone, row.status_tone) || undefined,
} as EightBfDictionaryOption;
})
.filter((item): item is EightBfDictionaryOption => item !== null);
}
export async function fetchEightBfTask(taskId: string): Promise<EightBfTask> {
const encodedId = encodeURIComponent(taskId);
const path = TASK_DETAIL_PATH.replaceAll("{taskId}", encodedId);
const response = await fetchExternalTaskSystem(path, { method: "GET" });
if (!response.ok) {
throw new Error(`任务详情接口请求失败(${response.status})`);
}
const payload = (await response.json()) as unknown;
const task = normalizeEightBfTask(unwrapData(payload), taskId);
// Keep the external route id as the stable scope even if a deployment's
// response includes a legacy/internal numeric id.
return { ...task, id: taskId, externalTaskId: taskId };
}
export async function fetchEightBfTaskDictionaries(): Promise<EightBfTaskDictionaries> {
const entries = await Promise.all(
(Object.entries(DICTIONARY_TYPES) as Array<[
keyof EightBfTaskDictionaries,
string,
]>).map(async ([key, path]) => {
try {
const response = await fetchExternalTaskSystem(
`/system/dict/data/type/${path}`,
{ method: "GET" },
);
if (!response.ok) throw new Error(`字典接口请求失败(${response.status})`);
return [key, normalizeDictionary(await response.json())] as const;
} catch (error) {
// A dictionary failure must not hide the task card; the card falls back
// to the raw task codes and the detail request remains usable.
console.warn(`[8bf] ${key} 字典加载失败`, error);
return [key, []] as const;
}
}),
);
return Object.fromEntries(entries) as EightBfTaskDictionaries;
}
export function dictionaryText(
options: EightBfDictionaryOption[] | undefined,
value: unknown,
preferredField: "label" | "alias" = "label",
): string {
const raw = firstText(value);
if (!raw) return "";
const match = options?.find((option) => option.value === raw);
return firstText(match?.[preferredField], match?.label, raw);
}
export function buildEightBfTaskContext(
task: EightBfTask | null,
dictionaries: EightBfTaskDictionaries,
): EightBfTaskContextPayload | null {
if (!task?.id) return null;
return {
kind: "eightbf_roundtable_task",
usage:
"这是 8BF 页面左上角任务卡片中的任务信息。请直接基于这些信息理解用户问题,不要要求用户重复提供已经存在的任务名称、任务描述、任务方向或任务状态。",
task_id: task.id,
task: {
id: task.id,
title: task.title,
description: task.description,
direction: task.direction,
overview: task.overview,
priority: task.priority,
task_priority: task.taskPriority,
target_direction: task.targetDirection,
task_status: task.taskStatus,
},
task_tags: {
priority: dictionaryText(dictionaries.priority, task.taskPriority),
direction: dictionaryText(dictionaries.direction, task.targetDirection, "alias"),
status: dictionaryText(dictionaries.status, task.taskStatus),
},
};
}
@@ -0,0 +1,556 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useSearchParams } from "react-router-dom";
import {
ChevronDown,
ChevronLeft,
Loader2,
Settings2,
UserRoundCog,
} from "lucide-react";
import { toast } from "sonner";
import {
AnalysisResultCardList,
AnalysisResultDetailDialog,
type AnalysisResultCardItem,
} from "@/components/analysis-results";
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
import { useAgent, useAgents, type Agent } from "@/core/agents";
import backArrowIcon from "@/assets/eightbf-roundtable/back-arrow.svg";
import agentConfigLogo from "@/assets/position-roundtable/agent-config-logo.svg";
import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg";
import doubleArrowDecoration from "@/assets/position-roundtable/double-arrow-decoration.svg";
import {
parentReturnRoute,
requestEightBfParentReturn,
} from "@/eightbf-roundtable/lib/parent-return";
import { useEightBfAnalysisResults } from "@/eightbf-roundtable/lib/collect-md-artifacts";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
import {
positionNameById,
syncPositionOptionsFromRoles,
} from "@/position-roundtable/lib/positions";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import "@/position-roundtable/styles/intent-understanding.css";
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
import { usePositionRoles } from "@/strategy-components/api/position-roles";
import { cn } from "@/lib/utils";
type IntentRecord = Record<string, unknown>;
export interface EightBfStep1PanelProps extends Step1PanelProps {
chain: BusinessChain | null;
canConfigureChain: boolean;
onConfigureChain: () => void;
selectedModel: string;
modelOptions: Array<{ label: string; value: string }>;
onSelectedModelChange: (model: string) => void;
step2Dialogues?: unknown[];
step2ThreadIds?: ThreadIdMap | null;
step2CoordinatorName?: string | null;
step2SeatStubMessages?: Record<string, unknown[]>;
step3Summary?: DraftStep3SummarySnapshot | null;
}
export function EightBfRoundtableBackButton() {
const [searchParams] = useSearchParams();
const handleBack = () => {
const route = parentReturnRoute(
searchParams.get("parentReturnTo")
?? searchParams.get("parentRoute")
?? searchParams.get("returnTo")
?? "/list",
);
if (requestEightBfParentReturn(route)) return;
toast.warning("尚未配置返回地址,请在 runtime-config.js 填写 VITE_8BF_ROUNDTABLE_BACK_URL");
};
return (
<button
type="button"
className="eightbf-roundtable__back-button"
onClick={handleBack}
>
<img src={backArrowIcon} alt="" />
返回
</button>
);
}
export function EightBfStep1Panel({
chain,
canConfigureChain,
onConfigureChain,
selectedModel,
modelOptions,
onSelectedModelChange,
intentReady,
step2Dialogues = [],
step2ThreadIds = null,
step2CoordinatorName = null,
step2SeatStubMessages,
step3Summary = null,
...step1Props
}: EightBfStep1PanelProps) {
const [rightPanelOpen, setRightPanelOpen] = useState(true);
const [rightTab, setRightTab] = useState<"agents" | "results">("agents");
const openPanel = (tab: "agents" | "results") => {
if (rightPanelOpen && rightTab === tab) {
setRightPanelOpen(false);
return;
}
setRightTab(tab);
setRightPanelOpen(true);
};
return (
<div className="position-roundtable-intent-page eightbf-roundtable-intent-page contents">
<Step1Panel
{...step1Props}
rootClassName="position-roundtable-intent-panel eightbf-roundtable-intent-panel [&_#save-draft-btn]:hidden [&_#skip-question-btn]:hidden [&_#suggest-followup-btn]:hidden"
headerLeftSlot={
<div className="position-roundtable-intent-header-title">
<span aria-hidden="true" />
<h2>{chain?.title || "8BF 业务链"} · 意图识别</h2>
</div>
}
headerRightSlot={
<div className="position-roundtable-intent-header-actions">
<button
type="button"
className={cn(rightPanelOpen && rightTab === "agents" && "is-selected")}
onClick={() => openPanel("agents")}
>
智能体配置
</button>
<button
type="button"
className={cn(rightPanelOpen && rightTab === "results" && "is-selected")}
onClick={() => openPanel("results")}
>
分析结果
</button>
<EightBfRoundtableBackButton />
</div>
}
rightSidebar={
<aside
aria-hidden={!rightPanelOpen || undefined}
className={cn(
"position-roundtable-intent-config-pane eightbf-roundtable-intent-config-pane",
rightPanelOpen ? "is-open" : "is-closed",
)}
>
<div className="position-roundtable-intent-config-pane-inner">
<EightBfAgentWorkspace
tab={rightTab}
chain={chain}
canConfigureChain={canConfigureChain}
onConfigureChain={onConfigureChain}
onCollapse={() => setRightPanelOpen(false)}
step2Dialogues={step2Dialogues}
step2ThreadIds={step2ThreadIds}
step2CoordinatorName={step2CoordinatorName}
step2SeatStubMessages={step2SeatStubMessages}
step3Summary={step3Summary}
/>
</div>
</aside>
}
hideQuestionCount
inputArea={(inputProps) => (
<PositionIntentInput
{...inputProps}
modelOptions={modelOptions}
selectedModel={selectedModel}
onSelectedModelChange={onSelectedModelChange}
/>
)}
intentReady={intentReady}
/>
</div>
);
}
export function EightBfStep1Sidebar({
taskCard,
businessChain,
intentReady,
onCollapse,
}: {
taskCard?: ReactNode;
businessChain?: ReactNode;
intentReady: object | null;
onCollapse?: () => void;
}) {
return (
<div className="eightbf-roundtable__step1-sidebar position-roundtable-entry-sidebar">
{taskCard}
{onCollapse && !taskCard && (
<button
type="button"
className="position-roundtable-intent-config-collapse-btn ml-auto"
onClick={onCollapse}
aria-label="收起左侧面板"
title="收起左侧面板"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
)}
<EightBfIntentDelivery intentReady={intentReady} businessChain={businessChain} />
</div>
);
}
/** 第一、二步共用的意图结果面板,保持完全相同的展示结构和样式。 */
export function EightBfIntentDelivery({
intentReady,
businessChain,
}: {
intentReady: object | null;
businessChain?: ReactNode;
}) {
const sections = intentSections(intentReady);
return (
<section className="eightbf-roundtable__intent-delivery-sidebar position-roundtable-intent-delivery-sidebar" aria-labelledby="eightbf-intent-sidebar-title">
{sections.length > 0 && (
<div className="position-roundtable-intent-delivery-result">
<header className="position-roundtable-intent-delivery-header">
<span aria-hidden="true" />
<h2 id="eightbf-intent-sidebar-title">意图理解结果</h2>
</header>
<div className="position-roundtable-intent-delivery-sections">
{sections.map((section) => (
<article
key={section.label}
className={cn(
"position-roundtable-intent-delivery-section",
intentSectionTone(section.label),
)}
>
<h3>{section.label}</h3>
<ul>
{section.items.map((item) => <li key={item}>{item}</li>)}
</ul>
</article>
))}
</div>
</div>
)}
{businessChain}
</section>
);
}
/** 第二步不需要第一步的整列占位,只复用其任务卡和意图面板。 */
export function EightBfStep2SidebarHeader({
taskCard,
intentReady,
}: {
taskCard?: ReactNode;
intentReady: object | null;
}) {
return (
<div className="eightbf-roundtable__step2-sidebar-header">
{taskCard}
<EightBfIntentDelivery intentReady={intentReady} />
</div>
);
}
export function EightBfHeaderBrand({ chainTitle }: { chainTitle?: string }) {
return (
<div className="eightbf-roundtable__header-brand">
<span className="eightbf-roundtable__header-brand-icon" aria-hidden="true">
<UserRoundCog />
</span>
<div className="min-w-0">
<h1>8BF 多智能体会商</h1>
<p>按业务链承接任务、对话与分析产物</p>
</div>
<span className="eightbf-roundtable__header-status">
<span aria-hidden="true">⌁</span>
{chainTitle ? "业务链已启动" : "等待业务链配置"}
</span>
</div>
);
}
export function EightBfHeaderChain({ chainTitle }: { chainTitle?: string }) {
return (
<span className="eightbf-roundtable__header-chain" title={chainTitle || "尚未配置业务链"}>
<span>当前业务链</span>
<strong>{chainTitle || "暂未配置"}</strong>
<ChevronDown aria-hidden="true" />
</span>
);
}
function EightBfAgentWorkspace({
tab,
chain,
canConfigureChain,
onConfigureChain,
onCollapse,
step2Dialogues,
step2ThreadIds,
step2CoordinatorName,
step2SeatStubMessages,
step3Summary,
}: {
tab: "agents" | "results";
chain: BusinessChain | null;
canConfigureChain: boolean;
onConfigureChain: () => void;
onCollapse: () => void;
step2Dialogues: unknown[];
step2ThreadIds: ThreadIdMap | null;
step2CoordinatorName: string | null;
step2SeatStubMessages?: Record<string, unknown[]>;
step3Summary: DraftStep3SummarySnapshot | null;
}) {
const { agents, isLoading } = useAgents();
const { data: positionRoles = [] } = usePositionRoles();
useEffect(() => {
syncPositionOptionsFromRoles(positionRoles);
}, [positionRoles]);
const seats = chain?.seats ?? [];
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [agentKeyword, setAgentKeyword] = useState("");
const { agent: selectedAgent, isLoading: selectedAgentLoading } = useAgent(selectedAgentId);
const [detailItem, setDetailItem] = useState<AnalysisResultCardItem | null>(null);
const analysisItems = useEightBfAnalysisResults({
chain,
dialogues: step2Dialogues,
threadIds: step2ThreadIds,
coordinatorName: step2CoordinatorName,
seatStubMessages: step2SeatStubMessages,
summaryReport: step3Summary,
});
useEffect(() => {
if (selectedAgentId && !seats.some((seat) => seat.agent_id === selectedAgentId)) {
setSelectedAgentId(null);
}
}, [seats, selectedAgentId]);
const seatAgents = useMemo(() => {
const agentById = new Map(agents.map((agent) => [agent.id, agent]));
return seats.map((seat) => ({ seat, agent: agentById.get(seat.agent_id) ?? null }));
}, [agents, seats]);
const filteredSeatAgents = useMemo(() => {
const keyword = agentKeyword.trim().toLowerCase();
if (!keyword) return seatAgents;
return seatAgents.filter(({ seat, agent }) => {
const name = (agent?.name || seat.name || "").toLowerCase();
const description = (agent?.description || seat.description || "").toLowerCase();
return name.includes(keyword) || description.includes(keyword);
});
}, [agentKeyword, seatAgents]);
const selectedSeat = seats.find((seat) => seat.agent_id === selectedAgentId) ?? null;
if (tab === "results") {
return (
<section className="position-roundtable-intent-analysis-results eightbf-roundtable-intent-analysis-results">
<div className="position-roundtable-intent-analysis-header">
<span>分析成果列表</span>
<button
type="button"
className="position-roundtable-intent-config-collapse-btn"
onClick={onCollapse}
aria-label="收起分析结果"
title="收起分析结果"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
</div>
<div className="position-roundtable-intent-analysis-results-scroll eightbf-roundtable-intent-results-scroll">
<AnalysisResultCardList
items={analysisItems}
emptyTitle="暂无交付产物"
emptyDescription="完成第二步研讨与第三步方案总结并生成 Markdown 后,产物会显示在这里。"
padded={false}
onOpen={setDetailItem}
/>
</div>
<AnalysisResultDetailDialog
item={detailItem}
onOpenChange={(open) => {
if (!open) setDetailItem(null);
}}
/>
</section>
);
}
if (selectedAgentId) {
return (
<div className="position-roundtable-intent-config-workspace">
<div className="eightbf-roundtable__agent-detail-topbar">
<button type="button" onClick={() => setSelectedAgentId(null)}>
<ChevronLeft className="size-4" />
智能体列表
</button>
<button
type="button"
className="position-roundtable-intent-config-collapse-btn"
onClick={onCollapse}
aria-label="收起智能体配置"
title="收起智能体配置"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
</div>
<div className="position-roundtable-agent-detail position-roundtable-agent-detail--special h-[calc(100%-48px)] min-h-0">
{selectedAgentLoading ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-[#aaceff]">
<Loader2 className="size-4 animate-spin" /> 正在读取智能体配置…
</div>
) : (
<AgentDetailPanel
agent={selectedAgent}
agentName={selectedAgent?.name ?? selectedSeat?.name ?? ""}
onAskQuestion={() => toast.info("请在中间对话区向任务理解智能体发起问题")}
showRecentChats={false}
avatarSrc={agentConfigLogo}
positionName={positionNameById(selectedSeat?.position_id)}
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
skillsTitle="技能列表"
showCommonQuestions
panelTitle="智能体配置"
embedded
hideStatusBadges
/>
)}
</div>
</div>
);
}
return (
<section className="eightbf-roundtable__agent-list-workspace">
<div className="position-roundtable-intent-analysis-header">
<span>智能体配置</span>
<div className="flex items-center gap-1.5">
{canConfigureChain && (
<button type="button" className="eightbf-roundtable__configure-chain-button" onClick={onConfigureChain}>
<Settings2 className="size-3.5" /> 配置业务链
</button>
)}
<button
type="button"
className="position-roundtable-intent-config-collapse-btn"
onClick={onCollapse}
aria-label="收起智能体配置"
title="收起智能体配置"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
</div>
</div>
<div className="eightbf-roundtable__agent-selector">
<header className="eightbf-roundtable__agent-selector-head">
<div className="eightbf-roundtable__agent-selector-title">
<b>当前岗位智能体</b>
<i>{seatAgents.length}</i>
</div>
<input
type="text"
value={agentKeyword}
onChange={(event) => setAgentKeyword(event.target.value)}
placeholder="搜索名称或说明"
className="eightbf-roundtable__agent-selector-search"
/>
</header>
{isLoading && seats.length > 0 ? (
<div className="eightbf-roundtable__agent-selector-empty">正在加载智能体…</div>
) : filteredSeatAgents.length === 0 ? (
<div className="eightbf-roundtable__agent-selector-empty">
<p>{seatAgents.length ? "没有符合条件的智能体" : "当前岗位尚未配置智能体"}</p>
{!seatAgents.length && canConfigureChain && (
<button type="button" onClick={onConfigureChain}>前往业务链配置</button>
)}
</div>
) : (
<div className="eightbf-roundtable__agent-selector-list">
{filteredSeatAgents.map(({ seat, agent }) => (
<EightBfAgentRow
key={seat.agent_id}
seat={seat}
agent={agent}
onSelect={() => setSelectedAgentId(seat.agent_id)}
/>
))}
</div>
)}
</div>
</section>
);
}
function EightBfAgentRow({
seat,
agent,
onSelect,
}: {
seat: ChainSeat;
agent: Agent | null;
onSelect: () => void;
}) {
const name = agent?.name || seat.name || "未命名智能体";
const description = agent?.description || seat.description || "智能体助手";
const avatarText = Array.from(name.trim() || "?")[0]?.toUpperCase() || "?";
return (
<button type="button" className="eightbf-roundtable__agent-selector-card" onClick={onSelect}>
<span className="eightbf-roundtable__agent-selector-avatar" aria-hidden="true">{avatarText}</span>
<span className="eightbf-roundtable__agent-selector-main">
<strong>{name}</strong>
<p>{description}</p>
</span>
<i className="eightbf-roundtable__agent-selector-bar" aria-hidden="true" />
</button>
);
}
function intentSections(intentReady: object | null): Array<{ label: string; items: string[] }> {
const source = intentReady && typeof intentReady === "object" ? intentReady as IntentRecord : null;
if (!source) return [];
const entries = [
["核心目标", arrayValue(source.coreGoals, source.objective)],
["风险提示", arrayValue(source.riskWarnings, source.constraints)],
["关键要点", arrayValue(source.keyPoints, source.assumptions)],
["战略意义", arrayValue(source.strategicSignificance)],
] as const;
return entries
.filter(([, items]) => items.length > 0)
.map(([label, items]) => ({ label, items }));
}
function intentSectionTone(label: string) {
switch (label) {
case "核心目标":
return "position-roundtable-intent-delivery-section--goals";
case "风险提示":
return "position-roundtable-intent-delivery-section--constraints";
case "关键要点":
return "position-roundtable-intent-delivery-section--purpose";
case "战略意义":
return "position-roundtable-intent-delivery-section--significance";
default:
return "position-roundtable-intent-delivery-section--significance";
}
}
function arrayValue(...values: unknown[]): string[] {
for (const value of values) {
if (Array.isArray(value)) {
const items = value.map((item) => String(item).trim()).filter(Boolean);
if (items.length > 0) return items;
}
if (typeof value === "string" && value.trim()) return [value.trim()];
}
return [];
}
@@ -0,0 +1,97 @@
import { cn } from "@/lib/utils";
import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg";
import {
dictionaryText,
type EightBfDictionaryOption,
type EightBfTask,
} from "@/eightbf-roundtable/api/task-context";
interface EightBfTaskCardProps {
task: EightBfTask | null;
dictionaries: {
priority: EightBfDictionaryOption[];
direction: EightBfDictionaryOption[];
status: EightBfDictionaryOption[];
};
loading?: boolean;
error?: string | null;
/** 第二步左栏使用,与“智能体配置”同款图标收起整列。 */
onCollapse?: () => void;
}
export function EightBfTaskCard({
task,
dictionaries,
loading = false,
error,
onCollapse,
}: EightBfTaskCardProps) {
const priority = dictionaryText(dictionaries.priority, task?.taskPriority);
const direction = dictionaryText(dictionaries.direction, task?.targetDirection, "alias");
const status = dictionaryText(dictionaries.status, task?.taskStatus);
// 外部任务接口不可用时,卡片使用与岗位会商相同的示例任务;标签也必须
// 保持可读,而不能暴露内部状态码。
const tags = task?.raw === null
? ["紧急", "本系统创建", "暑期活动"]
: [priority, status, direction].filter(Boolean);
return (
<section
className="position-roundtable-task-card eightbf-roundtable__task-card group relative shrink-0 overflow-hidden rounded-[4px] border border-[#ECE7E4] bg-white p-5 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[rgba(0,110,254,0.5)] dark:bg-[#032650] dark:shadow-none"
aria-labelledby="eightbf-task-card-title"
>
<div className="position-roundtable-task-card__content relative z-10">
{onCollapse && (
<button
type="button"
className="position-roundtable-intent-config-collapse-btn absolute right-0 top-0"
onClick={onCollapse}
aria-label="收起左侧面板"
title="收起左侧面板"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
)}
<h2
id="eightbf-task-card-title"
title={task?.title || task?.id || "等待任务信息"}
className="pr-8 text-[18px] font-medium leading-7 text-slate-900 dark:text-white"
>
<span className="line-clamp-1">{task?.title || (task?.id ? `任务 ${task.id}` : "等待任务信息")}</span>
</h2>
{tags.length > 0 && (
<div
data-task-tags
className="mt-3 flex flex-wrap gap-2 [&>span]:inline-flex [&>span]:min-h-7 [&>span]:items-center [&>span]:!leading-5 [&>span:first-child]:!border-[#cf1322] [&>span:nth-child(2)]:!shadow-none"
aria-label="任务标签"
>
{tags.map((tag, index) => (
<span
key={tag}
className={[
"rounded-[2px] border px-2 py-0.5 text-center text-[16px] font-light",
index === 0 && "border-[#cf1322] bg-[rgba(207,19,34,0.5)] leading-4 text-white",
index === 1 && "border-[#4bcdff] bg-[rgba(75,205,255,0.3)] leading-7 text-[#4bcdff] shadow-[0_0_9px_rgba(252,131,131,0.27)]",
index > 1 && "border-[#a2e1f9] bg-[rgba(162,225,249,0.3)] leading-[18px] text-[#a2e1f9]",
].filter(Boolean).join(" ")}
>
{tag}
</span>
))}
</div>
)}
<p
data-task-description
className={cn(
"mt-3 line-clamp-3 whitespace-pre-wrap text-sm leading-[22px] text-slate-600 dark:text-[#96bbf8]",
!task?.description && "is-empty",
)}
>
{task?.description || task?.overview || (loading ? "正在读取任务详情…" : "暂无任务描述")}
</p>
{error && <p className="eightbf-roundtable__task-card-error" role="status">{error}</p>}
</div>
</section>
);
}
@@ -0,0 +1,343 @@
import { useEffect, useMemo, useState } from "react";
import type { AnalysisResultCardItem } from "@/components/analysis-results";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import {
listSessionArtifacts,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
const MD_PATH_RE = /\.md$/i;
const SUMMARY_OUTPUT_PATH = "/mnt/user-data/outputs/方案总结报告.md";
type DialogueLike = {
sender?: string;
agentThreadId?: string;
time?: string;
steps?: Array<{ key?: string; args?: Record<string, unknown> }>;
};
interface MdFileHit {
path: string;
name: string;
threadId: string | null;
content: string | null;
updatedAt?: string;
agentName: string;
order?: number;
}
function isMarkdownPath(path: string): boolean {
return MD_PATH_RE.test(path.split("/").pop() ?? path);
}
function filenameOf(path: string): string {
return path.split("/").filter(Boolean).at(-1) || path;
}
function asString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function fileKey(threadId: string | null, path: string): string {
return `${threadId ?? ""}::${path}`;
}
function collectPathsFromSteps(
steps: DialogueLike["steps"],
): Array<{ path: string; content: string | null }> {
if (!Array.isArray(steps) || steps.length === 0) return [];
const out: Array<{ path: string; content: string | null }> = [];
const seen = new Set<string>();
const add = (path: string, content: string | null) => {
if (!path || seen.has(path) || !isMarkdownPath(path)) return;
seen.add(path);
out.push({ path, content });
};
for (const step of steps) {
if (step.key === "tool_calling:present_files") {
const fps = step.args?.filepaths;
if (Array.isArray(fps)) {
for (const fp of fps) add(asString(fp), null);
}
continue;
}
if (step.key === "tool_calling:write_file" || step.key === "tool_calling:str_replace") {
add(asString(step.args?.path), asString(step.args?.content) || null);
}
}
return out;
}
function collectFromDialogues(dialogues: unknown[]): MdFileHit[] {
const hits: MdFileHit[] = [];
let order = 0;
for (const raw of dialogues) {
if (!raw || typeof raw !== "object") continue;
const dialogue = raw as DialogueLike;
const sender = asString(dialogue.sender) || "智能体";
const threadId = asString(dialogue.agentThreadId) || null;
for (const file of collectPathsFromSteps(dialogue.steps)) {
hits.push({
path: file.path,
name: filenameOf(file.path),
threadId,
content: file.content,
updatedAt: asString(dialogue.time) || undefined,
agentName: sender,
order: order++,
});
}
}
return hits;
}
function collectFromStubs(stubs: Record<string, unknown[]> | undefined): MdFileHit[] {
const hits: MdFileHit[] = [];
for (const [threadId, messages] of Object.entries(stubs ?? {})) {
if (!Array.isArray(messages)) continue;
for (const message of messages) {
if (!message || typeof message !== "object") continue;
const calls = (message as { tool_calls?: unknown }).tool_calls;
if (!Array.isArray(calls)) continue;
for (const call of calls) {
if (!call || typeof call !== "object") continue;
const tool = call as { name?: unknown; args?: Record<string, unknown> };
if (asString(tool.name) !== "write_file") continue;
const path = asString(tool.args?.path);
if (!path || !isMarkdownPath(path)) continue;
hits.push({
path,
name: filenameOf(path),
threadId,
content: asString(tool.args?.content) || null,
agentName: "",
});
}
}
}
return hits;
}
function mergeHits(groups: MdFileHit[][]): Map<string, MdFileHit> {
const merged = new Map<string, MdFileHit>();
for (const group of groups) {
for (const hit of group) {
const key = fileKey(hit.threadId, hit.path);
const prev = merged.get(key);
if (!prev) {
merged.set(key, hit);
continue;
}
merged.set(key, {
path: hit.path || prev.path,
name: hit.name || prev.name,
threadId: hit.threadId || prev.threadId,
content: hit.content || prev.content,
updatedAt: hit.updatedAt || prev.updatedAt,
agentName: prev.agentName || hit.agentName,
order: Math.max(prev.order ?? 0, hit.order ?? 0),
});
}
}
return merged;
}
function latestHit(hits: MdFileHit[]): MdFileHit | null {
if (hits.length === 0) return null;
return hits.reduce((latest, hit) => {
const latestTime = Date.parse(latest.updatedAt ?? "") || 0;
const hitTime = Date.parse(hit.updatedAt ?? "") || 0;
if (hitTime !== latestTime) return hitTime > latestTime ? hit : latest;
return (hit.order ?? 0) >= (latest.order ?? 0) ? hit : latest;
});
}
function pendingSeatCard(seat: BusinessChain["seats"][number]): AnalysisResultCardItem {
const agentName = seat.name?.trim() || seat.agent_id;
return {
id: `eightbf:seat:${seat.agent_id}:pending`,
agentName,
productName: `待交付:${agentName} Markdown报告`,
positionName: agentName,
delivered: false,
fallbackTime: "待完成",
unavailableMessage: `「${agentName}」尚未交付可预览的 Markdown 文件。完成第二步研讨后,这里会直接展示产物。`,
};
}
function hitToCard(
hit: MdFileHit,
positionName: string,
id: string,
): AnalysisResultCardItem {
return {
id,
agentName: hit.agentName || positionName,
productName: hit.name,
positionName,
updatedAt: hit.updatedAt,
fallbackTime: "刚刚",
delivered: true,
artifactPath: hit.path,
artifactMimeType: "text/markdown",
artifactThreadId: hit.threadId,
artifactContent: hit.content,
};
}
export function collectEightBfAnalysisResults({
chain,
dialogues,
threadIds,
coordinatorName,
seatStubMessages,
summaryReport,
remoteFiles = [],
}: {
chain: BusinessChain | null;
dialogues: unknown[];
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
seatStubMessages?: Record<string, unknown[]>;
summaryReport: DraftStep3SummarySnapshot | null;
remoteFiles?: MdFileHit[];
}): AnalysisResultCardItem[] {
const merged = mergeHits([
collectFromDialogues(dialogues),
collectFromStubs(seatStubMessages),
]);
const seats = chain?.seats ?? [];
const coordinator = coordinatorName?.trim() || "";
const items: AnalysisResultCardItem[] = [];
const threadIdOfSeat = (seat: BusinessChain["seats"][number]): string | null => {
const byId = threadIds?.[seat.agent_id];
if (byId) return byId;
const byName = Object.entries(threadIds ?? {}).find(
([name]) => name.toLowerCase() === seat.agent_id.toLowerCase() || name === seat.name,
)?.[1];
return byName ?? null;
};
for (const seat of seats) {
if (coordinator && (seat.name === coordinator || seat.agent_id === coordinator)) continue;
const seatThreadId = threadIdOfSeat(seat);
const agentName = seat.name?.trim() || seat.agent_id;
const seatHits = [...merged.values()].filter((hit) => {
if (seatThreadId && hit.threadId === seatThreadId) return true;
return hit.agentName === agentName || hit.agentName === seat.agent_id;
});
const latest = latestHit(seatHits) ?? latestHit(
remoteFiles.filter((hit) => Boolean(seatThreadId) && hit.threadId === seatThreadId),
);
if (!latest) {
items.push(pendingSeatCard(seat));
continue;
}
const enriched = latest.content
? latest
: remoteFiles.find((hit) =>
hit.threadId === latest.threadId &&
(hit.path === latest.path || hit.name === latest.name),
) ?? latest;
items.push(hitToCard(enriched, agentName, `eightbf:seat:${seat.agent_id}:latest`));
}
const deliveredSeatCount = items.filter((item) => item.delivered).length;
if (summaryReport?.md.trim()) {
items.push({
id: "eightbf:summary",
agentName: "方案总结智能体",
productName: "方案总结报告.md",
positionName: "方案总结",
updatedAt: summaryReport.generatedAt,
fallbackTime: "刚刚",
delivered: true,
artifactPath: SUMMARY_OUTPUT_PATH,
artifactMimeType: "text/markdown",
artifactThreadId: summaryReport.threadId ?? null,
artifactContent: summaryReport.md,
summary: summaryReport.closing ?? summaryReport.md,
});
} else if (deliveredSeatCount > 0) {
items.push({
id: "eightbf:summary:pending",
agentName: "方案总结智能体",
productName: "待生成:方案总结报告.md",
positionName: "方案总结",
delivered: false,
fallbackTime: "待完成",
unavailableMessage: "第三步方案总结尚未生成 Markdown 报告。完成总结后,这里会直接展示产物。",
});
}
return items;
}
export function useEightBfAnalysisResults(input: {
chain: BusinessChain | null;
dialogues: unknown[];
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
seatStubMessages?: Record<string, unknown[]>;
summaryReport: DraftStep3SummarySnapshot | null;
}): AnalysisResultCardItem[] {
const [remoteFiles, setRemoteFiles] = useState<MdFileHit[]>([]);
const threadEntries = useMemo(
() => Object.entries(input.threadIds ?? {}).filter(([, threadId]) => Boolean(threadId)),
[input.threadIds],
);
useEffect(() => {
if (threadEntries.length === 0) {
setRemoteFiles([]);
return;
}
const abort = new AbortController();
let cancelled = false;
void Promise.all(
threadEntries.map(async ([agentName, threadId]) => {
try {
const res = await listSessionArtifacts(threadId, { signal: abort.signal });
return res.files
.filter((file) => isMarkdownPath(file.path) || isMarkdownPath(file.name))
.map((file) => ({
path: file.path,
name: file.name || filenameOf(file.path),
threadId,
content: null,
updatedAt: file.modifiedAt,
agentName,
}));
} catch {
return [] as MdFileHit[];
}
}),
).then((groups) => {
if (!cancelled) setRemoteFiles(groups.flat());
});
return () => {
cancelled = true;
abort.abort();
};
}, [threadEntries]);
return useMemo(
() =>
collectEightBfAnalysisResults({
...input,
remoteFiles,
}),
[
input.chain,
input.coordinatorName,
input.dialogues,
input.seatStubMessages,
input.summaryReport,
input.threadIds,
remoteFiles,
],
);
}
@@ -0,0 +1,128 @@
import { useEffect } from "react";
import { emitWujieEvent, isWujieEnv } from "@/core/embed/wujie";
import { getEightBfRoundtableBackUrl } from "@/eightbf-roundtable/api/task-context";
export const EIGHTBF_PARENT_RETURN_SOURCE = "deerflow-8bf-roundtable";
export const EIGHTBF_PARENT_RETURN_TYPE = "deerflow:8bf-roundtable:return";
export const EIGHTBF_BRIDGE_HOST_SOURCE = "code-zk-roundtable-host";
export const EIGHTBF_BRIDGE_INIT_TYPE = "code-zk:8bf-roundtable-bridge:init";
export const EIGHTBF_BRIDGE_READY_TYPE = "deerflow:8bf-roundtable:bridge-ready";
export interface EightBfParentReturnMessage {
source: typeof EIGHTBF_PARENT_RETURN_SOURCE;
type: typeof EIGHTBF_PARENT_RETURN_TYPE;
payload: {
route: string | null;
fallback: "history-back";
};
}
type EightBfBridgeInitMessage = {
source: typeof EIGHTBF_BRIDGE_HOST_SOURCE;
type: typeof EIGHTBF_BRIDGE_INIT_TYPE;
};
/**
* `parentReturnTo` belongs to the embedding application. Keep it a route/hash
* path so same-origin fallback cannot become an open redirect.
*/
export function parentReturnRoute(value: string | null | undefined): string | null {
const route = value?.trim();
if (!route || route.length > 2048) return null;
if ((!route.startsWith("/") && !route.startsWith("#")) || route.startsWith("//") || route.includes("\\")) {
return null;
}
return route;
}
function isInIframe(): boolean {
if (typeof window === "undefined") return false;
try {
return window.parent !== window;
} catch {
return true;
}
}
const embeddingBridgePort = { current: null as MessagePort | null };
/**
* Accept a MessagePort from code-zk. Wujie can lift this iframe into the
* outer host document, so `window.parent` is no longer code-zk. The port
* still talks to the Vue page that created the channel.
*/
export function useEightBfParentReturnBridge(): void {
useEffect(() => {
const receiveBridgePort = (event: MessageEvent<EightBfBridgeInitMessage>) => {
const message = event.data;
const bridgePort = event.ports?.[0];
if (
message?.source !== EIGHTBF_BRIDGE_HOST_SOURCE
|| message?.type !== EIGHTBF_BRIDGE_INIT_TYPE
|| !bridgePort
) {
return;
}
embeddingBridgePort.current?.close();
embeddingBridgePort.current = bridgePort;
bridgePort.start?.();
bridgePort.postMessage({
source: EIGHTBF_PARENT_RETURN_SOURCE,
type: EIGHTBF_BRIDGE_READY_TYPE,
});
};
window.addEventListener("message", receiveBridgePort);
return () => {
window.removeEventListener("message", receiveBridgePort);
embeddingBridgePort.current?.close();
embeddingBridgePort.current = null;
};
}, []);
}
/**
* Ask the embedding Vue host to leave this iframe.
*
* Topology: Wujie host → code-zk → DeerFlow iframe. Wujie may reparent the
* iframe so native `window.parent.postMessage` never reaches code-zk.
* MessageChannel is the channel that survives that. Native postMessage is
* only the fallback for a plain iframe.
*/
export function requestEightBfParentReturn(route: string | null): boolean {
const message: EightBfParentReturnMessage = {
source: EIGHTBF_PARENT_RETURN_SOURCE,
type: EIGHTBF_PARENT_RETURN_TYPE,
payload: { route, fallback: "history-back" },
};
if (typeof window === "undefined") return false;
const bridgePort = embeddingBridgePort.current;
if (bridgePort) {
try {
bridgePort.postMessage(message);
return true;
} catch {
// Host unmounted/reloaded. Fall through to native bridges.
}
}
if (isInIframe()) {
window.parent.postMessage(message, "*");
return true;
}
if (isWujieEnv()) {
emitWujieEvent("8bf-roundtable:return", message.payload);
return true;
}
const url = getEightBfRoundtableBackUrl().trim();
if (url) {
window.location.href = url;
return true;
}
return false;
}
@@ -1,11 +1,31 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Loader2, Save, Settings2, X } from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { Loader2, Save, X } from "lucide-react";
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard";
import {
EightBfStep1Panel,
EightBfStep1Sidebar,
EightBfStep2SidebarHeader,
} from "@/eightbf-roundtable/components/EightBfStep1Workspace";
import {
buildEightBfTaskContext,
createEightBfFallbackTask,
fetchEightBfTask,
fetchEightBfTaskDictionaries,
normalizeEightBfTask,
createEightBfTaskProcessReport,
updateEightBfTaskProcessReport,
type EightBfTask,
type EightBfTaskDictionaries,
} from "@/eightbf-roundtable/api/task-context";
import type { TaskArtifactSubmissionConfig } from "@/roundtable-planning/lib/task-artifact-submission";
import { PositionBusinessChainFlow } from "@/position-roundtable/components/PositionBusinessChainFlow";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import { listChains } from "@/roundtable-planning/api/chains";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
import { useEightBfParentReturnBridge } from "@/eightbf-roundtable/lib/parent-return";
import {
listBusinessMappings,
updateBusinessMapping,
@@ -25,11 +45,30 @@ const BUSINESS_CODE = "8BF";
* 不引用岗位会商的页面、会话或接口。
*/
export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) {
useEightBfParentReturnBridge();
const auth = getStoredAuth();
const [searchParams] = useSearchParams();
const taskId = useMemo(
() => searchParams.get("taskId")?.trim() || "",
[searchParams],
);
// 8BF 公共业务链由指定账号维护,不依赖外部透传的管理员角色。
// 与敏感词管理使用同一可信账户展示名规则(登录邮箱 @ 前缀)。
const canConfigureChain = getAccountDisplayName(auth).toLowerCase() === "lqq";
const storageTaskId = useMemo(() => `8bf:${auth?.user_id || "anonymous"}`, [auth?.user_id]);
const storageTaskId = useMemo(
() => `8bf:${auth?.user_id || "anonymous"}:${taskId || "anonymous"}`,
[auth?.user_id, taskId],
);
const [task, setTask] = useState<EightBfTask | null>(() => (
taskId ? normalizeEightBfTask({}, taskId) : null
));
const [taskDictionaries, setTaskDictionaries] = useState<EightBfTaskDictionaries>({
priority: [],
direction: [],
status: [],
});
const [taskLoading, setTaskLoading] = useState(Boolean(taskId));
const [taskError, setTaskError] = useState<string | null>(null);
const [mapping, setMapping] = useState<BusinessMapping | null>(null);
const [chains, setChains] = useState<BusinessChain[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -41,6 +80,64 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
() => chains.find((chain) => chain.id === mapping?.chainId) ?? null,
[chains, mapping?.chainId],
);
const taskContext = useMemo(
() => buildEightBfTaskContext(task, taskDictionaries),
[task, taskDictionaries],
);
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
() => taskId ? {
taskId,
position: 0,
createProcessReport: createEightBfTaskProcessReport,
updateProcessReport: updateEightBfTaskProcessReport,
} : undefined,
[taskId],
);
useEffect(() => {
if (!taskId) {
setTask(null);
setTaskError(null);
setTaskLoading(false);
setTaskDictionaries({ priority: [], direction: [], status: [] });
return;
}
let cancelled = false;
setTaskLoading(true);
setTaskError(null);
setTask(normalizeEightBfTask({}, taskId));
let pendingRequests = 2;
const markRequestFinished = () => {
pendingRequests -= 1;
if (!cancelled && pendingRequests === 0) setTaskLoading(false);
};
// Keep detail and dictionary requests independent: a missing dictionary
// must not prevent the task card or task context from being available.
void fetchEightBfTask(taskId)
.then((nextTask) => {
if (!cancelled) setTask(nextTask);
})
.catch((error) => {
if (cancelled) return;
console.warn("[8bf] 任务详情请求失败,使用页面示例任务", error);
setTask(createEightBfFallbackTask(taskId));
setTaskError(null);
})
.finally(markRequestFinished);
void fetchEightBfTaskDictionaries()
.then((nextDictionaries) => {
if (!cancelled) setTaskDictionaries(nextDictionaries);
})
.catch(() => {
// fetchEightBfTaskDictionaries already degrades each dictionary to an
// empty list; this guard protects future adapter changes.
})
.finally(markRequestFinished);
return () => {
cancelled = true;
};
}, [taskId]);
const loadConfiguration = useCallback(async () => {
setIsLoading(true);
@@ -94,18 +191,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
}
}, [canConfigureChain, chains, selectedChainId]);
const openChainSettings = useCallback(() => {
if (canConfigureChain) setIsSettingsOpen(true);
}, [canConfigureChain]);
const toolbarSlot = (
<>
{canConfigureChain && (
<button
type="button"
className="eightbf-roundtable__chain-settings-button"
onClick={() => setIsSettingsOpen(true)}
>
<Settings2 className="h-3.5 w-3.5" />
配置业务链
</button>
)}
{canConfigureChain && isSettingsOpen && (
<div className="eightbf-roundtable__settings-mask" role="presentation">
<section
@@ -162,7 +253,70 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
lockedChainId={mapping?.chainId ?? null}
lockBusinessChain
storageTaskId={storageTaskId}
taskContext={taskContext as Record<string, unknown> | null}
entryRailTaskId={taskId || null}
entryRailTaskCardSlot={taskId ? (
<EightBfTaskCard
task={task}
dictionaries={taskDictionaries}
loading={taskLoading}
error={taskError}
/>
) : undefined}
toolbarSlot={toolbarSlot}
hideHeaderSaveDraft
hideHeaderSettings
newTaskLabel="新建会话"
hideDefaultStep1RightSidebar
step2HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 多智能体会商`}
step3HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 方案总结`}
renderStep1={(props) => (
<EightBfStep1Panel
{...props}
chain={configuredChain}
canConfigureChain={canConfigureChain}
onConfigureChain={openChainSettings}
/>
)}
renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => (
<EightBfStep1Sidebar
intentReady={intentReady}
onCollapse={onCollapse}
taskCard={taskId ? (
<EightBfTaskCard
task={task}
dictionaries={taskDictionaries}
loading={taskLoading}
error={taskError}
onCollapse={onCollapse}
/>
) : undefined}
businessChain={
<PositionBusinessChainFlow
chain={configuredChain}
loading={isLoading}
embedded
submittedSeatKeys={submittedSeatKeys}
onSeatClick={onSeatClick}
className="position-roundtable-intent-delivery-flow eightbf-roundtable__business-chain-flow"
/>
}
/>
)}
renderStep2LeftSidebarHeader={({ intentReady, onCollapse }) => (
<EightBfStep2SidebarHeader
intentReady={intentReady}
taskCard={taskId ? (
<EightBfTaskCard
task={task}
dictionaries={taskDictionaries}
loading={taskLoading}
error={taskError}
onCollapse={onCollapse}
/>
) : undefined}
/>
)}
entryRailBusinessChainSlot={
<PositionBusinessChainFlow
chain={configuredChain}
@@ -171,6 +325,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
/>
}
rootClassName="eightbf-roundtable"
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
/>
);
}
@@ -17,14 +17,404 @@
font-size: 12px;
}
.eightbf-roundtable .position-roundtable-intent-header-actions button:nth-child(2) {
width: 100px;
}
.eightbf-roundtable .position-roundtable-intent-header-actions .eightbf-roundtable__back-button {
width: 86.04px;
height: 28px;
gap: 4px;
padding: 0 8px;
background: linear-gradient(109.46deg, rgba(0, 98, 217, 0.59) 3.83%, rgba(0, 98, 217, 0.3) 98.23%);
border-radius: 20px;
}
.eightbf-roundtable .position-roundtable-intent-header-actions .eightbf-roundtable__back-button img {
width: 14px;
height: 12px;
flex: 0 0 auto;
}
.eightbf-roundtable .eightbf-roundtable__chain-status-label { color: #0f5f56; font-weight: 700; }
.eightbf-roundtable .eightbf-roundtable__chain-status-value { color: #134e4a; font-weight: 600; }
.eightbf-roundtable .eightbf-roundtable__configured-icon { color: var(--eightbf-accent); }
/* 8BF 左上业务链区域:复用岗位会商的纵向节点、点阵画布与放大查看交互。 */
.eightbf-roundtable .eightbf-roundtable__business-chain-flow {
height: 500px;
min-height: 460px;
height: 430px;
min-height: 430px;
}
/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */
/* 8BF 外部 taskId:左上任务卡。仅挂在 8BF 根节点下,避免改动原会商任务卡。 */
.eightbf-roundtable .eightbf-roundtable__task-card {
flex: 0 0 auto;
min-width: 0;
}
.eightbf-roundtable .eightbf-roundtable__task-card-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.eightbf-roundtable .eightbf-roundtable__task-card-tags {
margin-top: 12px;
}
.eightbf-roundtable .eightbf-roundtable__task-card-tags span {
border-radius: 2px;
}
.eightbf-roundtable .eightbf-roundtable__task-card-description {
margin: 12px 0 0;
}
.eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #94a3b8; }
.eightbf-roundtable .eightbf-roundtable__task-card-error { margin: 8px 0 0; color: #fca5a5; font-size: 12px; line-height: 1.5; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #8fa8c5; }
/*
* 8BF 第一阶段直接采用「岗位多智能体会商」意图工作台的三栏尺寸和深蓝表面。
* 专属类只改变 8BF;岗位会商原有页面继续由 intent-understanding.css 管理。
*/
.eightbf-roundtable .eightbf-roundtable__header-brand {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 12px;
}
.eightbf-roundtable .eightbf-roundtable__header-brand-icon {
display: inline-flex;
width: 42px;
height: 42px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: 14px;
background: rgb(43 143 254 / 16%);
color: #2b8ffe;
}
.eightbf-roundtable .eightbf-roundtable__header-brand-icon svg { width: 23px; height: 23px; }
.eightbf-roundtable .eightbf-roundtable__header-brand h1 { margin: 0; color: #1e293b; font-size: 16px; font-weight: 700; }
.eightbf-roundtable .eightbf-roundtable__header-brand p { margin: 2px 0 0; color: #64748b; font-size: 12px; }
.eightbf-roundtable .eightbf-roundtable__header-status {
display: inline-flex;
align-items: center;
gap: 5px;
margin-left: 8px;
border: 1px solid rgb(16 185 129 / 30%);
border-radius: 999px;
background: rgb(16 185 129 / 8%);
color: #047857;
padding: 5px 10px;
font-size: 12px;
font-weight: 700;
}
.eightbf-roundtable .eightbf-roundtable__header-chain {
display: inline-flex;
min-width: 230px;
max-width: 360px;
align-items: center;
gap: 8px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
color: #64748b;
padding: 8px 10px;
font-size: 12px;
}
.eightbf-roundtable .eightbf-roundtable__header-chain strong { min-width: 0; flex: 1; overflow: hidden; color: #334155; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.eightbf-roundtable .eightbf-roundtable__header-chain svg { width: 16px; height: 16px; flex: 0 0 auto; }
/* 参考工作台的直角圆角层级:内容面、消息区与输入框均为 4px。 */
.eightbf-roundtable #middle-viewport { border-radius: 4px; }
.eightbf-roundtable .position-roundtable-intent-panel > div:first-child,
.eightbf-roundtable .position-roundtable-intent-composer-frame,
.eightbf-roundtable .position-roundtable-intent-config-pane { border-radius: 4px; }
.eightbf-roundtable .position-roundtable-intent-panel > div:first-child { border-radius: 4px 4px 0 0; }
.eightbf-roundtable .eightbf-roundtable__step1-sidebar {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 12px;
overflow: hidden;
padding-right: 0;
}
/* 8BF 首步左栏不允许横向滚动;流程图的横向超出由画布自身裁切。 */
.eightbf-roundtable[data-roundtable-step="1"] #left-sidebar,
.eightbf-roundtable .eightbf-roundtable__step1-sidebar,
.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar {
overflow-x: hidden !important;
}
.eightbf-roundtable[data-roundtable-step="1"] #left-sidebar::-webkit-scrollbar:horizontal,
.eightbf-roundtable .eightbf-roundtable__step1-sidebar::-webkit-scrollbar:horizontal,
.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar::-webkit-scrollbar:horizontal {
display: none;
height: 0;
}
.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar {
min-height: 0;
overflow-y: auto;
}
.eightbf-roundtable .eightbf-roundtable__intent-sidebar-empty {
margin: 0;
padding: 12px 16px 18px;
color: #aaceff;
font-size: 14px;
line-height: 22px;
}
/* The flow is embedded into the intent surface: only its own header line and
dotted canvas are visible. The surrounding intent panel owns the border. */
.eightbf-roundtable .position-roundtable-intent-delivery-flow {
/* Reference flow has horizontal gutters. Reduce the width by those gutters
so the right-side magnifier is never clipped by the left-rail crop. */
width: calc(100% - 24px) !important;
height: 580px !important;
min-height: 580px !important;
flex-basis: 580px !important;
}
.eightbf-roundtable .position-roundtable-intent-delivery-flow > div:first-child {
margin: 0 0 12px !important;
}
.eightbf-roundtable .eightbf-roundtable__agent-list-workspace {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
background: #032650;
}
.eightbf-roundtable .eightbf-roundtable__configure-chain-button {
display: inline-flex;
height: 28px;
align-items: center;
gap: 4px;
border: 1px solid rgb(115 200 255 / 52%);
border-radius: 4px;
background: rgb(0 98 217 / 22%);
color: #dceaff;
padding: 0 8px;
font-size: 11px;
}
.eightbf-roundtable .eightbf-roundtable__configure-chain-button:hover { background: rgb(0 98 217 / 40%); }
.eightbf-roundtable .eightbf-roundtable__agent-selector {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 12px;
overflow: hidden;
padding: 16px;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-head {
display: flex;
flex-direction: column;
gap: 8px;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-title {
display: inline-flex;
align-items: center;
gap: 8px;
color: #fff;
font-size: 14px;
font-weight: 500;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-title b { font-weight: 500; }
.eightbf-roundtable .eightbf-roundtable__agent-selector-title i {
min-width: 18px;
padding: 0 5px;
border-radius: 9px;
color: #aaceff;
background: rgb(21 66 128 / 58%);
font-size: 12px;
font-style: normal;
line-height: 18px;
text-align: center;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-search {
width: 100%;
box-sizing: border-box;
padding: 6px 10px;
border: 1px solid rgb(43 143 254 / 40%);
border-radius: 4px;
color: #fff;
background: rgb(0 12 28 / 40%);
font-size: 13px;
outline: none;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-search::placeholder { color: rgb(255 255 255 / 40%); }
.eightbf-roundtable .eightbf-roundtable__agent-selector-search:focus { border-color: #45c3f3; }
.eightbf-roundtable .eightbf-roundtable__agent-selector-list {
display: grid;
min-height: 0;
flex: 1;
align-content: start;
gap: 8px;
overflow-x: hidden;
overflow-y: auto;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-card {
position: relative;
display: grid;
width: 100%;
grid-template-columns: 40px 1fr;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border: 1px solid rgb(43 143 254 / 20%);
border-radius: 8px;
color: inherit;
font: inherit;
text-align: left;
background: rgb(0 98 217 / 8%);
cursor: pointer;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-card:hover {
border-color: rgb(43 143 254 / 60%);
background: rgb(0 98 217 / 22%);
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-card:hover .eightbf-roundtable__agent-selector-bar {
background: #45c3f3;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-avatar {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border-radius: 8px;
color: #fff;
background: linear-gradient(135deg, #1675d1, #06447f);
font-size: 16px;
font-weight: 700;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-main strong {
overflow: hidden;
color: #fff;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 15px;
font-weight: 700;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-main p {
margin: 0;
overflow: hidden;
color: #9ebfe2;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-bar {
position: absolute;
top: 50%;
right: 6px;
width: 6px;
height: 32px;
border-radius: 3px;
background: transparent;
transform: translateY(-50%);
transition: background 0.15s ease;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-empty {
display: flex;
min-height: 200px;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 32px 16px;
color: rgb(255 255 255 / 60%);
font-size: 13px;
text-align: center;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-empty p { margin: 0; }
.eightbf-roundtable .eightbf-roundtable__agent-selector-empty button {
padding: 6px 16px;
border: 1px solid rgb(43 143 254 / 60%);
border-radius: 4px;
color: #fff;
background: rgb(0 98 217 / 18%);
cursor: pointer;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-empty button:hover {
background: rgb(0 98 217 / 32%);
}
.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar { display: flex; height: 48px; align-items: center; justify-content: space-between; border-bottom: 1px solid rgb(43 143 254 / 35%); padding: 0 12px; }
.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar > button:first-child { display: inline-flex; align-items: center; gap: 2px; color: #aaceff; font-size: 12px; }
.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar > button:first-child:hover { color: #fff; }
.eightbf-roundtable .position-roundtable-intent-config-pane-inner {
width: 100% !important;
max-width: 100%;
min-width: 0;
overflow: hidden;
}
.eightbf-roundtable .eightbf-roundtable-intent-results-scroll {
min-width: 0;
max-width: 100%;
overflow-x: hidden;
padding: 10px;
}
.eightbf-roundtable .eightbf-roundtable__intent-results-empty { margin: 0; color: #aaceff; font-size: 13px; line-height: 1.8; }
.eightbf-roundtable .eightbf-roundtable__intent-results-list { display: grid; gap: 10px; }
.eightbf-roundtable .eightbf-roundtable__intent-results-list section { border: 1px solid rgb(43 143 254 / 32%); border-radius: 4px; background: rgb(0 12 28 / 20%); padding: 12px; }
.eightbf-roundtable .eightbf-roundtable__intent-results-list h3 { margin: 0 0 8px; color: #a2e1f9; font-size: 14px; font-weight: 500; }
.eightbf-roundtable .eightbf-roundtable__intent-results-list ul { margin: 0; padding-left: 17px; color: #dceaff; font-size: 12px; line-height: 1.8; }
/* Screenshot-equivalent dark workbench shell. */
.dark .eightbf-roundtable { background: #061b3b; color: #e7f0ff; }
.dark .eightbf-roundtable > header { min-height: 64px; border-color: rgb(43 143 254 / 45%); background: #032650; box-shadow: none; }
.dark .eightbf-roundtable .eightbf-roundtable__header-brand h1 { color: #e7f0ff; }
.dark .eightbf-roundtable .eightbf-roundtable__header-brand p { color: #8aa0c4; }
.dark .eightbf-roundtable .eightbf-roundtable__header-status { border-color: rgb(101 214 198 / 45%); background: rgb(15 118 110 / 24%); color: #9ef0df; }
.dark .eightbf-roundtable .eightbf-roundtable__header-chain { border-color: rgb(143 196 255 / 44%); background: #082b57; color: #aaceff; }
.dark .eightbf-roundtable .eightbf-roundtable__header-chain strong { color: #e7f0ff; }
.dark .eightbf-roundtable #new-task-btn { border: 1px solid rgb(115 200 255 / 45%); border-radius: 4px; background: transparent; color: #e7f0ff; box-shadow: none; }
.dark .eightbf-roundtable #new-task-btn:hover { background: rgb(0 98 217 / 24%); }
.dark .eightbf-roundtable[data-roundtable-step="1"] > main { gap: 0; padding: 12px 0; }
.dark .eightbf-roundtable[data-roundtable-step="1"] #middle-viewport { border-color: rgb(43 143 254 / 50%); border-radius: 4px; background: #032650; box-shadow: none; }
.dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar {
width: 374px;
overflow: hidden;
border: 0;
border-radius: 0;
background: transparent;
padding: 0 12px;
}
.dark .eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar {
border-color: rgb(0 110 254 / 50%);
background: #032650;
}
@media (max-width: 1280px) {
.eightbf-roundtable .eightbf-roundtable__header-chain { min-width: 180px; max-width: 220px; }
.dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar { width: 326px; }
.eightbf-roundtable .position-roundtable-intent-config-pane { --prt-intent-config-width: 320px; }
}
@media (max-width: 1024px) {
.dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar { width: 294px; }
.eightbf-roundtable .eightbf-roundtable__header-status,
.eightbf-roundtable .eightbf-roundtable__header-brand p { display: none; }
.eightbf-roundtable .eightbf-roundtable__header-chain { display: none; }
.eightbf-roundtable .position-roundtable-intent-config-pane { --prt-intent-config-width: min(360px, 42vw); }
}
@@ -14,4 +14,123 @@
.eightbf-roundtable .eightbf-roundtable__chain-settings-button:hover { background: #115e59; }
/* 可在此继续覆盖会商消息、席位卡片、流程节点及展开按钮的样式。 */
/* 展开时工作区紧贴左栏;收起时恢复通用的 16px 间距,避免窄栏贴着内容区。 */
.dark .eightbf-roundtable[data-roundtable-step="2"] > main[data-step2-sidebar-expanded="true"] {
gap: 0;
}
.dark .eightbf-roundtable[data-roundtable-step="2"] > main[data-step2-sidebar-expanded="false"] {
gap: 10px;
}
/* 第二步左栏顶部直接复用第一步任务卡和意图理解区,但不复用第一步整列的
flex: 1 行为;内容高度随意图实际内容收拢,剩余内容交给左栏统一滚动。 */
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable__step2-sidebar-header {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 12px;
min-height: 0;
overflow: visible;
}
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable__step2-sidebar-header
.eightbf-roundtable__intent-delivery-sidebar {
flex: 0 0 auto;
overflow: visible;
}
/* 角色面板与列表均依内容撑开,再交由左栏的单一滚动容器承载;否则取消
内层滚动后,角色项会从固定高度的父面板溢出并覆盖下方内容。 */
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .eightbf-roundtable__step2-sidebar-header + div {
flex: 0 0 auto !important;
min-height: 0 !important;
}
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .custom-scrollbar {
flex: 0 0 auto;
overflow: visible;
padding-right: 0;
}
/* 8BF 第二步沿用第一步的 4px 面板语言,避免通用会商的 rounded-2xl 混入。 */
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .rounded-2xl {
border-radius: 4px !important;
}
/* 展开态与第一步左栏保持同一宽度和内边距。注意不能无条件锁定宽度,否则会
覆盖收起态的 w-[56px],使顶部图标按钮看起来无效。 */
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] {
width: 374px;
padding: 0 12px;
}
/* 收起态也要预留与展开态一致的左右边距。此前 56px 宽度叠加内边距后,图标卡只剩
约 36px 可用空间,导致图标与边框被挤变形。80px 壳体保留 56px 的完整点击区域。 */
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="false"] {
width: 80px;
padding: 0 12px;
}
/* 第二步左侧各级内容卡沿用 8BF 的 4px 圆角;头像、状态徽标仍保持圆形。 */
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .rounded-2xl,
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .eightbf-roundtable__step2-sidebar-header > .rounded-2xl,
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .roundtable-step2-role-row,
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .roundtable-step2-human-row,
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .roundtable-step2-run-card {
border-radius: 4px !important;
}
.dark .eightbf-roundtable[data-roundtable-step="2"] > main {
padding: 12px 0;
}
.dark .eightbf-roundtable[data-roundtable-step="2"] #middle-viewport {
border-color: rgb(43 143 254 / 50%);
border-radius: 4px;
background: #032650;
box-shadow: none;
}
.eightbf-roundtable .eightbf-roundtable-step2-chat {
min-height: 0;
background: #032650;
}
.eightbf-roundtable .eightbf-roundtable-step2-log {
background: #032650;
border: 0;
box-shadow: none;
}
.eightbf-roundtable .eightbf-roundtable-step2-chat .position-roundtable-intent-header-actions .eightbf-roundtable__suspend-button {
width: 100px;
}
.eightbf-roundtable .eightbf-roundtable-step2-chat .relative.flex-1 > .shrink-0 {
background: #032650;
border-color: rgb(43 143 254 / 28%);
}
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-log .text-slate-700,
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-log .text-slate-500 {
color: #aaceff;
}
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-log .text-slate-400 {
color: #8aa0c4;
}
/* 第二步输入区直接复用第一步 PositionIntentInput;共识动作也使用其
#enter-roundtable-btn 规则,保证和「确认意图并启动业务链」完全同款。 */
.dark .eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-input {
flex: 0 0 auto;
}
@media (max-width: 1280px) {
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { width: 326px; }
}
@media (max-width: 1024px) {
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { width: 294px; }
}
@@ -76,4 +76,70 @@
background: #21958a;
}
/* 可在此继续覆盖第 3 步报告、流程图与放大弹窗的样式。 */
/* 第三步:8BF 左栏与第一步相同,对话区去掉原外层圆角白框,沿用第一、二步深蓝面板。 */
.dark .eightbf-roundtable[data-roundtable-step="3"] > main {
padding: 12px 0;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] > main[data-step2-sidebar-expanded="true"] {
gap: 0;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] > main[data-step2-sidebar-expanded="false"] {
gap: 10px;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] #middle-viewport {
border-color: rgb(43 143 254 / 50%);
border-radius: 4px;
background: #032650;
box-shadow: none;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="true"] {
width: 374px;
padding: 0 12px;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="false"] {
width: 80px;
padding: 0 12px;
}
.eightbf-roundtable .eightbf-roundtable-step3-chat {
min-height: 0;
background: #032650;
}
.eightbf-roundtable .eightbf-roundtable-step3-log {
background: #032650;
border: 0;
box-shadow: none;
}
.eightbf-roundtable .eightbf-roundtable-step3-chat .position-roundtable-intent-header-actions button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-log .text-slate-700,
.eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-log .text-slate-500 {
color: #aaceff;
}
.eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-log .text-slate-400 {
color: #8aa0c4;
}
/* PositionIntentInput is the shared Step 1/2 composer; Step 3 uses the exact same frame. */
.dark .eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-input {
flex: 0 0 auto;
}
@media (max-width: 1280px) {
.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="true"] { width: 326px; }
}
@media (max-width: 1024px) {
.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="true"] { width: 294px; }
}
+5
View File
@@ -41,5 +41,10 @@ export const env = {
* flag so operations can switch the shell without rebuilding the frontend.
*/
FORCE_COLLAPSED_SIDEBAR: readRuntimeFlag("VITE_FORCE_COLLAPSED_SIDEBAR"),
/**
* Compact-menu variant for this website. Pair with the admin 简介模式
* type (A / B) so two deployments can show different left-nav items.
*/
COMPACT_MENU_TYPE: readRuntimeEnv("VITE_COMPACT_MENU_TYPE"),
GITHUB_OAUTH_TOKEN: readRuntimeEnv("VITE_GITHUB_OAUTH_TOKEN"),
};
+27
View File
@@ -0,0 +1,27 @@
/**
* Generate a client-only id without assuming a secure browser context.
*
* `crypto.randomUUID()` is unavailable in several browsers when the app is
* opened directly through an `http://<public-ip>:<port>` address. Request and
* UI ids need uniqueness, not a security token, so a timestamp/random fallback
* keeps those flows usable until the deployment is served over HTTPS.
*/
export function createClientId(prefix?: string): string {
const cryptoApi = typeof globalThis === "object" ? globalThis.crypto : undefined;
let id: string | undefined;
if (typeof cryptoApi?.randomUUID === "function") {
id = cryptoApi.randomUUID();
} else if (typeof cryptoApi?.getRandomValues === "function") {
try {
const values = new Uint32Array(4);
cryptoApi.getRandomValues(values);
id = Array.from(values, (value) => value.toString(16).padStart(8, "0")).join("");
} catch {
// Some restricted browser contexts expose crypto but reject random data.
}
}
id ??= `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
return prefix ? `${prefix}-${id}` : id;
}
@@ -119,6 +119,10 @@ export interface DocumentRewriteRequest {
instruction: string;
style?: DocumentRewriteStyle;
modelName?: string;
/** Generate locally saved illustration assets after the prose rewrite. */
generateImages?: boolean;
/** Bounded server-side to 1–4. Ignored unless generateImages is true. */
maxGeneratedImages?: number;
expectedHash?: string;
}
@@ -140,6 +144,8 @@ export interface DocumentRewriteSummary {
};
requestedOptimizations?: string[];
validationWarnings?: string[];
generatedImageCount?: number;
requestedImageCount?: number;
}
export type DocumentRewriteStreamEvent =
@@ -156,6 +162,15 @@ export type DocumentRewriteStreamEvent =
| { type: "rewrite_delta"; delta: string; charCount: number }
/** Durable full-draft recovery point used after an SSE reconnect. */
| { type: "rewrite_checkpoint"; content: string; charCount: number }
| { type: "illustration_started"; section: string; position: number; total: number }
| {
type: "illustration_generated";
section: string;
position: number;
total: number;
generatedCount?: number;
}
| { type: "illustration_completed"; generatedCount: number; requestedCount: number; warnings: string[] }
| { type: "validation_started" }
| { type: "validation_completed"; passed: boolean; warnings: string[] }
| { type: "comparison_ready"; summary: DocumentRewriteSummary }
@@ -177,6 +192,8 @@ export interface DurableDocumentRewriteJob {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
generateImages: boolean;
maxGeneratedImages: number;
originalContent: string;
};
}
@@ -229,6 +246,30 @@ async function readDocumentRewriteEvent(
content: String(parsed.content ?? ""),
charCount: Number(parsed.charCount ?? 0),
};
case "illustration_started":
return {
type: "illustration_started",
section: String(parsed.section ?? ""),
position: Number(parsed.position ?? 0),
total: Number(parsed.total ?? 0),
};
case "illustration_generated":
return {
type: "illustration_generated",
section: String(parsed.section ?? ""),
position: Number(parsed.position ?? 0),
total: Number(parsed.total ?? 0),
generatedCount: Number(parsed.generatedCount ?? parsed.position ?? 0),
};
case "illustration_completed":
return {
type: "illustration_completed",
generatedCount: Number(parsed.generatedCount ?? 0),
requestedCount: Number(parsed.requestedCount ?? 0),
warnings: Array.isArray(parsed.warnings)
? parsed.warnings.filter((item): item is string => typeof item === "string")
: [],
};
case "validation_started":
return { type: "validation_started" };
case "validation_completed":
@@ -362,6 +403,8 @@ export async function listDurableDocumentRewrites(
instruction: String(request.instruction ?? ""),
style: (request.style ?? "professional") as DocumentRewriteStyle,
modelName: typeof request.modelName === "string" ? request.modelName : null,
generateImages: request.generateImages === true,
maxGeneratedImages: Number(request.maxGeneratedImages ?? 2),
originalContent: String(request.originalContent ?? ""),
},
}];
@@ -9,12 +9,14 @@ import {
cancelDurableDocumentRewrite,
streamDocumentRewrite,
} from "../api/rewrite";
import { InlineThinkTagFilter, stripInlineThinkTags } from "@/core/streamdown/inline-think";
export type DocumentRewriteStage =
| "idle"
| "snapshot"
| "requirements"
| "writing"
| "illustrating"
| "validation"
| "committing"
| "completed"
@@ -27,6 +29,8 @@ export interface DocumentRewriteRun {
draft: string;
/** Live, compact execution plan produced before Markdown generation starts. */
requirementsPlan: string;
/** Ephemeral model reasoning, deliberately kept out of the Markdown draft. */
thinkingText: string;
originalCharCount: number;
writtenCharCount: number;
fileName: string;
@@ -41,6 +45,7 @@ const INITIAL_RUN: DocumentRewriteRun = {
stage: "idle",
draft: "",
requirementsPlan: "",
thinkingText: "",
originalCharCount: 0,
writtenCharCount: 0,
fileName: "",
@@ -67,6 +72,10 @@ export function useDocumentRewrite() {
const frameRef = useRef<number | null>(null);
const pendingRequirementsDeltaRef = useRef("");
const requirementsFrameRef = useRef<number | null>(null);
const pendingThinkingRef = useRef("");
const thinkingFrameRef = useRef<number | null>(null);
const draftThinkFilterRef = useRef(new InlineThinkTagFilter());
const requirementsThinkFilterRef = useRef(new InlineThinkTagFilter());
const flushDelta = useCallback(() => {
if (frameRef.current !== null) {
@@ -86,27 +95,6 @@ export function useDocumentRewrite() {
}));
}, []);
const enqueueDelta = useCallback((delta: string, writtenCharCount: number) => {
if (!delta) return;
pendingDeltaRef.current += delta;
pendingWrittenCharCountRef.current = writtenCharCount;
if (frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
const pending = pendingDeltaRef.current;
const pendingWrittenCharCount = pendingWrittenCharCountRef.current;
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
if (!pending) return;
setRun((previous) => ({
...previous,
stage: "writing",
draft: previous.draft + pending,
...(pendingWrittenCharCount !== null ? { writtenCharCount: pendingWrittenCharCount } : {}),
}));
});
}, []);
const flushRequirements = useCallback(() => {
if (requirementsFrameRef.current !== null) {
cancelAnimationFrame(requirementsFrameRef.current);
@@ -124,9 +112,35 @@ export function useDocumentRewrite() {
}));
}, []);
const enqueueRequirementsDelta = useCallback((delta: string) => {
const flushThinking = useCallback(() => {
if (thinkingFrameRef.current !== null) {
cancelAnimationFrame(thinkingFrameRef.current);
thinkingFrameRef.current = null;
}
const delta = pendingThinkingRef.current;
pendingThinkingRef.current = "";
if (!delta) return;
pendingRequirementsDeltaRef.current += delta;
setRun((previous) => ({ ...previous, thinkingText: previous.thinkingText + delta }));
}, []);
const enqueueThinking = useCallback((delta: string) => {
if (!delta) return;
pendingThinkingRef.current += delta;
if (thinkingFrameRef.current !== null) return;
thinkingFrameRef.current = requestAnimationFrame(() => {
thinkingFrameRef.current = null;
const pending = pendingThinkingRef.current;
pendingThinkingRef.current = "";
if (!pending) return;
setRun((previous) => ({ ...previous, thinkingText: previous.thinkingText + pending }));
});
}, []);
const enqueueRequirementsDelta = useCallback((delta: string) => {
const { text: visibleDelta, thinking } = requirementsThinkFilterRef.current.pushParts(delta);
enqueueThinking(thinking);
if (!visibleDelta) return;
pendingRequirementsDeltaRef.current += visibleDelta;
if (requirementsFrameRef.current !== null) return;
requirementsFrameRef.current = requestAnimationFrame(() => {
requirementsFrameRef.current = null;
@@ -141,7 +155,30 @@ export function useDocumentRewrite() {
requirementsPlan: previous.requirementsPlan + pending,
}));
});
}, []);
}, [enqueueThinking]);
const enqueueDelta = useCallback((delta: string, writtenCharCount: number) => {
const { text: visibleDelta, thinking } = draftThinkFilterRef.current.pushParts(delta);
enqueueThinking(thinking);
if (!visibleDelta) return;
pendingDeltaRef.current += visibleDelta;
pendingWrittenCharCountRef.current = writtenCharCount;
if (frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
const pending = pendingDeltaRef.current;
const pendingWrittenCharCount = pendingWrittenCharCountRef.current;
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
if (!pending) return;
setRun((previous) => ({
...previous,
stage: "writing",
draft: previous.draft + pending,
...(pendingWrittenCharCount !== null ? { writtenCharCount: pendingWrittenCharCount } : {}),
}));
});
}, [enqueueThinking]);
const cancel = useCallback(() => {
controllerRef.current?.abort();
@@ -155,12 +192,13 @@ export function useDocumentRewrite() {
}
flushDelta();
flushRequirements();
flushThinking();
setRun((previous) =>
previous.stage === "completed"
? previous
: { ...previous, stage: "cancelled", error: "已停止全文改写,原文件未被修改。" },
);
}, [flushDelta, flushRequirements]);
}, [flushDelta, flushRequirements, flushThinking]);
const reset = useCallback(() => {
controllerRef.current?.abort();
@@ -169,6 +207,9 @@ export function useDocumentRewrite() {
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
pendingRequirementsDeltaRef.current = "";
pendingThinkingRef.current = "";
draftThinkFilterRef.current = new InlineThinkTagFilter();
requirementsThinkFilterRef.current = new InlineThinkTagFilter();
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
@@ -177,6 +218,10 @@ export function useDocumentRewrite() {
cancelAnimationFrame(requirementsFrameRef.current);
requirementsFrameRef.current = null;
}
if (thinkingFrameRef.current !== null) {
cancelAnimationFrame(thinkingFrameRef.current);
thinkingFrameRef.current = null;
}
setRun(INITIAL_RUN);
}, []);
@@ -202,6 +247,9 @@ export function useDocumentRewrite() {
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
pendingRequirementsDeltaRef.current = "";
pendingThinkingRef.current = "";
draftThinkFilterRef.current = new InlineThinkTagFilter();
requirementsThinkFilterRef.current = new InlineThinkTagFilter();
setRun({
...INITIAL_RUN,
stage: "snapshot",
@@ -236,28 +284,36 @@ export function useDocumentRewrite() {
case "requirements_delta":
enqueueRequirementsDelta(event.delta);
break;
case "thinking":
enqueueThinking(event.chunk);
break;
case "requirements_checkpoint":
flushRequirements();
setRun((previous) => ({
...previous,
stage: "requirements",
requirementsPlan: event.content,
requirementsPlan: requirementsThinkFilterRef.current.replace(event.content),
}));
break;
case "requirements_completed":
flushRequirements();
setRun((previous) => ({
...previous,
stage: "requirements",
requirementsPlan: event.content,
}));
{
const requirementsPlan =
requirementsThinkFilterRef.current.replace(event.content) +
requirementsThinkFilterRef.current.finish();
setRun((previous) => ({
...previous,
stage: "requirements",
requirementsPlan,
}));
}
break;
case "requirements_resolved":
flushRequirements();
setRun((previous) => ({
...previous,
stage: "writing",
...(event.plan ? { requirementsPlan: event.plan } : {}),
...(event.plan ? { requirementsPlan: stripInlineThinkTags(event.plan) } : {}),
}));
break;
case "rewrite_delta":
@@ -276,13 +332,20 @@ export function useDocumentRewrite() {
setRun((previous) => ({
...previous,
stage: "writing",
draft: event.content,
writtenCharCount: event.charCount,
draft: draftThinkFilterRef.current.replace(event.content),
writtenCharCount: stripInlineThinkTags(event.content).replace(/\s/g, "").length,
}));
break;
case "illustration_started":
case "illustration_generated":
case "illustration_completed":
flushDelta();
setRun((previous) => ({ ...previous, stage: "illustrating" }));
break;
case "validation_started":
flushDelta();
flushRequirements();
flushThinking();
setRun((previous) => ({ ...previous, stage: "validation" }));
break;
case "validation_completed":
@@ -300,17 +363,23 @@ export function useDocumentRewrite() {
case "committed":
flushDelta();
flushRequirements();
setRun((previous) => ({
...previous,
draft: event.content,
writtenCharCount: event.content.replace(/\s/g, "").length,
versionId: event.versionId ?? previous.versionId,
}));
options?.onCommitted?.(event.content);
flushThinking();
{
const committedContent =
draftThinkFilterRef.current.replace(event.content) + draftThinkFilterRef.current.finish();
setRun((previous) => ({
...previous,
draft: committedContent,
writtenCharCount: committedContent.replace(/\s/g, "").length,
versionId: event.versionId ?? previous.versionId,
}));
options?.onCommitted?.(committedContent);
}
break;
case "done":
flushDelta();
flushRequirements();
flushThinking();
receivedTerminalEvent = true;
setRun((previous) => ({
...previous,
@@ -321,6 +390,7 @@ export function useDocumentRewrite() {
case "conflict":
flushDelta();
flushRequirements();
flushThinking();
receivedTerminalEvent = true;
setRun((previous) => ({
...previous,
@@ -331,12 +401,14 @@ export function useDocumentRewrite() {
case "cancelled":
flushDelta();
flushRequirements();
flushThinking();
receivedTerminalEvent = true;
setRun((previous) => ({ ...previous, stage: "cancelled", error: event.message }));
return;
case "error":
flushDelta();
flushRequirements();
flushThinking();
receivedTerminalEvent = true;
setRun((previous) => ({
...previous,
@@ -351,6 +423,7 @@ export function useDocumentRewrite() {
if (!controller.signal.aborted && !receivedTerminalEvent) {
flushDelta();
flushRequirements();
flushThinking();
setRun((previous) => ({
...previous,
stage: "error",
@@ -361,6 +434,7 @@ export function useDocumentRewrite() {
if (controller.signal.aborted) return;
flushDelta();
flushRequirements();
flushThinking();
setRun((previous) => ({
...previous,
stage: "error",
@@ -372,7 +446,7 @@ export function useDocumentRewrite() {
}
}
},
[enqueueDelta, enqueueRequirementsDelta, flushDelta, flushRequirements],
[enqueueDelta, enqueueRequirementsDelta, enqueueThinking, flushDelta, flushRequirements, flushThinking],
);
useEffect(
@@ -380,13 +454,14 @@ export function useDocumentRewrite() {
controllerRef.current?.abort();
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
if (requirementsFrameRef.current !== null) cancelAnimationFrame(requirementsFrameRef.current);
if (thinkingFrameRef.current !== null) cancelAnimationFrame(thinkingFrameRef.current);
},
[],
);
return {
run,
isActive: ["snapshot", "requirements", "writing", "validation", "committing"].includes(run.stage),
isActive: ["snapshot", "requirements", "writing", "illustrating", "validation", "committing"].includes(run.stage),
start,
cancel,
reset,
+2 -2
View File
@@ -773,9 +773,9 @@ export default function ChatPage() {
!compactLanding &&
"-translate-y-[calc(50vh-96px)]",
compactLanding
? "max-w-(--container-width-xl)"
? "max-w-[calc(var(--container-width-xl)+150px)]"
: isLandingState
? "max-w-(--container-width-md)"
? "max-w-[calc(var(--container-width-md)+150px)]"
: "max-w-(--container-width-lg)",
)}
>
+22 -13
View File
@@ -78,9 +78,11 @@ import {
type MenuOverride,
type MergedMenuNode,
} from "@/core/page-layout/menu-overrides";
import type {
CompactMenuOverride,
CompactMenuPlacement,
import {
COMPACT_MENU_TYPE_LABELS,
getRuntimeCompactMenuType,
type CompactMenuOverride,
type CompactMenuPlacement,
} from "@/core/page-layout/compact-menu-overrides";
import { sidebarMenuItems, type MenuItem } from "@/core/page-layout/sidebar-menu";
import { injectLightApps } from "@/core/page-layout/use-sidebar-menu";
@@ -506,7 +508,7 @@ export default function MenuManagementPage() {
() => [
{
id: "workspace-compact-nav",
label: "简介模式菜单",
label: "简洁模式菜单",
icon: Settings,
children: WORKSPACE_COMPACT_NAV_ITEMS,
},
@@ -775,7 +777,7 @@ export default function MenuManagementPage() {
mode === "compact" ? "bg-red-600 text-white shadow-sm" : "text-slate-500 hover:text-slate-800"
}`}
>
简介模式
简洁模式
</button>
</div>
);
@@ -975,8 +977,11 @@ export default function MenuManagementPage() {
}
function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
const { data: overrides = [], isLoading } = useCompactMenuOverrides();
const { save } = useCompactMenuOverrideActions();
const runtimeType = getRuntimeCompactMenuType();
// 不再提供 A/B 切换:固定编辑当前网页(runtime-config.js VITE_COMPACT_MENU_TYPE)使用的那套。
const menuType = runtimeType;
const { data: overrides = [], isLoading } = useCompactMenuOverrides(menuType);
const { save } = useCompactMenuOverrideActions(menuType);
const initialRows = useMemo(() => compactRowsFromTree(baseTree, overrides), [baseTree, overrides]);
const [rows, setRows] = useState<CompactRow[]>(initialRows);
const [loadedKey, setLoadedKey] = useState("");
@@ -988,8 +993,9 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
(top.children ?? []).map((child) => [top.id, child.id, child.label]),
),
overrides,
menuType,
}),
[baseTree, overrides],
[baseTree, overrides, menuType],
);
useEffect(() => {
if (sourceKey !== loadedKey) {
@@ -1031,14 +1037,14 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
const handleSave = async () => {
try {
await save.mutateAsync(currentOverrides);
toast.success("简介菜单配置已保存");
toast.success(`简洁菜单配置已保存(${COMPACT_MENU_TYPE_LABELS[menuType]})`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存失败");
}
};
const handleReset = () => {
if (!confirm("确定要重置简介模式菜单为默认布局吗?保存后将清除简介模式自定义配置。")) return;
if (!confirm(`确定要重置${COMPACT_MENU_TYPE_LABELS[menuType]}的简洁模式菜单为默认布局吗?保存后将清除该类型的自定义配置。`)) return;
setRows(compactRowsFromTree(baseTree, []));
};
@@ -1053,10 +1059,10 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
<div>
<span className="mb-3 inline-flex select-none items-center gap-1.5 rounded-full border border-red-100 bg-red-50 px-3 py-1 text-xs font-semibold text-red-600">
<Sparkles className="size-3" />
仅简介模式生效
仅简洁模式生效
</span>
<h1 className="flex items-center gap-3 text-2xl font-bold tracking-tight text-slate-800 md:text-3xl">
简介菜单管理
简洁菜单管理
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-0.5 font-mono text-xs font-medium text-slate-500">
{rows.length} 个项目
</span>
@@ -1084,7 +1090,10 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
</div>
<p className="flex max-w-2xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600">
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span>这里配置的是简介模式工作区左侧菜单:可改显示名称、调整顺序,或迁移到「设置和更多」下拉中;全量模式菜单不受影响。</span>
<span>
这里配置的是简洁模式工作区左侧菜单,保存后仅对当前网页使用的
{COMPACT_MENU_TYPE_LABELS[runtimeType]}生效(两套部署各改各的);全量模式菜单不受影响。
</span>
</p>
</div>
</div>
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useAgentSelector } from "@/core/agents";
import { createClientId } from "@/lib/client-id";
import {
type DomainAgentMapping,
normalizeDomainInput,
@@ -20,10 +21,7 @@ import {
function makeRow(): DomainAgentMapping {
return {
id:
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
id: createClientId(),
domain: "",
agentId: "",
enabled: true,
@@ -40,7 +40,6 @@ import {
MessageSquareText,
PanelRightOpen,
RotateCcw,
SlidersHorizontal,
Sparkles,
Telescope,
Trash2,
@@ -123,8 +122,10 @@ import {
advancedOptionsFromConfig,
buildReportCardConfig,
ReportConfigCard,
ReportOutlineEditor,
} from "./ReportConfigCard";
import { ResearchSandboxPanel } from "./ResearchSandboxPanel";
import { WorkflowConfigButton } from "./WorkflowConfigDialog";
import { REPORT_OUTPUT_PATH, useReportSandbox } from "./useReportSandbox";
const COLLECTOR_AGENT_ID = "deep-research-collector";
@@ -183,6 +184,8 @@ type WorkbenchRun = {
phase: string;
planText: string;
planReasoning: string;
/** Live-only model thinking while report Markdown is being streamed. */
reportThinking: string;
report: string;
reportTargets: Record<string, { content: string; version: number }>;
reportVersion: number;
@@ -201,6 +204,7 @@ const EMPTY_RUN: WorkbenchRun = {
phase: "initializing",
planText: "",
planReasoning: "",
reportThinking: "",
report: "",
reportTargets: {},
reportVersion: 1,
@@ -418,6 +422,10 @@ function DeepResearchWorkbenchInner() {
presentationId: null,
plan: "",
});
const mirroredReportRewriteThinkingRef = useRef<{ presentationId: string | null; thinking: string }>({
presentationId: null,
thinking: "",
});
const sessions = useResearchSessions();
const sessionQuery = useResearchSession(selectedId);
@@ -428,6 +436,10 @@ function DeepResearchWorkbenchInner() {
const [localSettings] = useLocalSettings();
const { models, isLoading: modelsLoading } = useModels();
const seenEventKeys = useRef(new Set<string>());
// Live provider deltas can legitimately repeat the same text (including a
// single space). They have no durable sequence, so never deduplicate them
// by payload or a thought token could disappear from the visible trace.
const liveEventSerial = useRef(0);
const replayedTraceJobs = useRef(new Set<string>());
const pendingReportEvents = useRef<ProgressEvent[]>([]);
const reportAnimationFrame = useRef<number | null>(null);
@@ -679,6 +691,16 @@ function DeepResearchWorkbenchInner() {
});
}, [reportDocumentRewrite.run.requirementsPlan, rewritePresentation]);
useEffect(() => {
const presentationId = reportRewritePresentationId.current;
const thinking = reportDocumentRewrite.run.thinkingText;
if (!presentationId || !rewritePresentation || !thinking) return;
const previous = mirroredReportRewriteThinkingRef.current;
if (previous.presentationId === presentationId && previous.thinking === thinking) return;
mirroredReportRewriteThinkingRef.current = { presentationId, thinking };
rewritePresentation.update(presentationId, { thinkingText: thinking });
}, [reportDocumentRewrite.run.thinkingText, rewritePresentation]);
useEffect(() => {
const handleRewriteUndo = (event: Event) => {
const detail = (event as CustomEvent<{
@@ -854,6 +876,9 @@ function DeepResearchWorkbenchInner() {
phase: session?.status === "completed" ? "done" : run.phase,
materialCount,
error: run.error,
thinking: run.reportThinking,
events: run.events,
requestedImageCount: requestedReportImageCount(session?.config),
}),
} as Injected);
}
@@ -870,6 +895,7 @@ function DeepResearchWorkbenchInner() {
? buildCompletedReportMessage({
sourceCount: materialCount,
summaryText: run.summaryText,
imageCount: countEmbeddedReportImages(session?.reportMarkdown),
})
: buildStreamingSummaryMessage(run.summaryText),
// MessageList 会将该标记转为既有的 assistant 流式渲染状态,避免摘要只在结束后才显示。
@@ -950,7 +976,9 @@ function DeepResearchWorkbenchInner() {
pendingFollowUp,
followUpStream,
run.error,
run.events,
run.phase,
run.reportThinking,
run.summaryText,
session,
streamJobId,
@@ -980,6 +1008,7 @@ function DeepResearchWorkbenchInner() {
if (replayingTerminalTrace && replayedTraceJobs.current.has(streamJobId)) return;
if (replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId);
seenEventKeys.current = new Set();
liveEventSerial.current = 0;
pendingReportEvents.current = [];
setRun({ ...EMPTY_RUN, jobId: streamJobId });
// 进行中的写作仍自动打开沙箱;历史完成记录只回放步骤与摘要,等待用户点击
@@ -997,6 +1026,7 @@ function DeepResearchWorkbenchInner() {
let reportTargets = current.reportTargets;
let planText = current.planText;
let planReasoning = current.planReasoning;
let reportThinking = current.reportThinking;
let summaryText = current.summaryText;
let phase = current.phase;
for (const event of queued) {
@@ -1009,6 +1039,10 @@ function DeepResearchWorkbenchInner() {
planReasoning += typeof event.payload.delta === "string" ? event.payload.delta : "";
continue;
}
if (event.type === "report_thinking") {
reportThinking += typeof event.payload.delta === "string" ? event.payload.delta : "";
continue;
}
if (event.type === "summary_delta") {
summaryText += typeof event.payload.delta === "string" ? event.payload.delta : "";
continue;
@@ -1020,6 +1054,7 @@ function DeepResearchWorkbenchInner() {
if (event.type === "report_reset") {
report = "";
reportTargets = {};
reportThinking = "";
reportVersion = typeof event.payload.reportVersion === "number" ? event.payload.reportVersion : reportVersion + 1;
continue;
}
@@ -1035,7 +1070,7 @@ function DeepResearchWorkbenchInner() {
reportVersion = Math.max(reportVersion, version);
}
}
return { ...current, report, reportTargets, reportVersion, phase, planText, planReasoning, summaryText };
return { ...current, report, reportTargets, reportVersion, phase, planText, planReasoning, reportThinking, summaryText };
});
};
@@ -1043,12 +1078,15 @@ function DeepResearchWorkbenchInner() {
streamJobId,
{
onEvent: (event) => {
const key = eventKey(event);
const key = event.seq === 0 && !event.liveId
? `live-arrival:${++liveEventSerial.current}`
: eventKey(event);
if (seenEventKeys.current.has(key)) return;
seenEventKeys.current.add(key);
if (
event.type === "plan_delta" ||
event.type === "plan_reasoning" ||
event.type === "report_thinking" ||
event.type === "report_delta" ||
event.type === "report_chunk" ||
event.type === "report_reset" ||
@@ -1521,6 +1559,10 @@ function DeepResearchWorkbenchInner() {
case "config":
return (
<ReportConfigCard
// A new durable config-card message is a new editing session. Do
// not remount for ordinary collector/event refreshes: that would
// discard the user's in-progress “生成配图” toggle.
key={`${session.id}:${payload.messageId ?? "report-config"}`}
availableModes={availableModes}
defaultMode={session.mode}
defaultOptions={advancedOptionsFromConfig(session.config)}
@@ -1722,9 +1764,15 @@ function DeepResearchWorkbenchInner() {
<ImagePlus className="h-3.5 w-3.5" />配图
</button>
)}
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setSettingsOpen(true)} disabled={Boolean(isFollowUp) || jobWriting}>
<SlidersHorizontal className="mr-1.5 h-3.5 w-3.5" />高级配置
</Button>
<WorkflowConfigButton
variant="ghost"
className="h-7 px-2 text-xs"
disabled={Boolean(isFollowUp) || jobWriting}
appliedContent={advancedOptions.customOutline}
onApply={(content) =>
setAdvancedOptions((current) => ({ ...current, customOutline: content }))
}
/>
</div>
</div>
<PromptInput onSubmit={({ text }) => handleComposerSubmit(text)}>
@@ -1842,6 +1890,7 @@ type WritingProgressToolCall = {
label: string;
detail: string;
status: "complete" | "active";
thinking?: string;
};
};
@@ -1854,12 +1903,23 @@ function buildWritingProgressToolCalls({
phase,
materialCount,
error,
thinking,
events,
requestedImageCount,
}: {
phase: string;
materialCount: number;
error: string | null;
thinking: string;
events: ProgressEvent[];
requestedImageCount: number;
}): WritingProgressToolCall[] {
const rank = chatPhaseRank(phase);
const imageSnapshot = buildTraceSnapshot(events);
const imageStageVisible = requestedImageCount > 0 || imageSnapshot.imageTotal > 0 || imageSnapshot.images.length > 0;
const imageTotal = imageSnapshot.imageTotal || requestedImageCount;
const generatedImages = imageSnapshot.images.filter((image) => image.status === "completed").length;
const failedImages = imageSnapshot.images.filter((image) => image.status === "failed").length;
const stages = [
{
id: "harvest",
@@ -1879,6 +1939,18 @@ function buildWritingProgressToolCalls({
boundary: chatPhaseRank("reviewing"),
detail: "正在逐字写入右侧沙箱中的 report.md",
},
...(imageStageVisible ? [{
id: "illustrate",
label: "生成报告配图",
boundary: chatPhaseRank("exporting"),
detail: generatedImages > 0
? `已生成 ${generatedImages} 张报告配图并嵌入 report.md${failedImages ? `;另有 ${failedImages} 张未生成,文字报告已保留` : ""}`
: failedImages > 0
? `配图未生成,已保留完整文字报告${imageTotal ? `(已处理 ${failedImages}/${imageTotal} 张)` : ""}`
: imageTotal > 0
? `正在生成报告配图,已处理 ${generatedImages + failedImages}/${imageTotal} 张`
: "正在为报告关键章节生成配图",
}] : []),
{
id: "summary",
label: "生成报告摘要",
@@ -1901,6 +1973,7 @@ function buildWritingProgressToolCalls({
label: stage.label,
detail,
status: complete ? "complete" : "active",
...(stage.id === "write" && thinking.trim() ? { thinking } : {}),
},
};
});
@@ -1910,14 +1983,19 @@ function buildWritingProgressToolCalls({
function buildCompletedReportMessage({
sourceCount,
summaryText,
imageCount,
}: {
sourceCount: number;
summaryText: string;
imageCount: number;
}): string {
const parts = [
"## 研究报告已完成",
`已基于 ${sourceCount} 条资料完成报告,正文已在右侧沙箱中打开;可通过沙箱工具栏下载 Markdown 或 Word。`,
];
if (imageCount > 0) {
parts.push(`已生成 ${imageCount} 张报告配图,并嵌入正文对应章节。`);
}
if (summaryText.trim()) {
parts.push(`### 报告摘要\n\n${summaryText.trim()}`);
}
@@ -2104,6 +2182,7 @@ function ResearchConversation({
const hasTrace = active || events.length > 0;
const streamedSourceCount = events.reduce((count, event) => count + (event.type === "source_added" ? 1 : 0), 0);
const liveSourceCount = Math.max(sourceCount, streamedSourceCount);
const requestedImageCount = requestedReportImageCount(session.config);
return (
<div className="space-y-6">
<UserBubble content={session.query} />
@@ -2120,6 +2199,7 @@ function ResearchConversation({
error={streamError}
onOpenSandbox={onOpenSandbox}
active={active}
requestedImageCount={requestedImageCount}
/>
</AssistantTurn>
)}
@@ -2169,6 +2249,15 @@ type TraceSource = {
snippet?: string;
};
type TraceImage = {
position: number;
section: string;
status: "generating" | "completed" | "failed";
name?: string;
alt?: string;
error?: string;
};
type TraceSnapshot = {
groups: { title: string; queries: string[] }[];
subtopics: string[];
@@ -2179,6 +2268,9 @@ type TraceSnapshot = {
curationReason?: string;
compressedSourceCount?: number;
reportCompleted: boolean;
imageTotal: number;
images: TraceImage[];
imagePhaseFinished: boolean;
warnings: string[];
};
@@ -2198,17 +2290,30 @@ function numberValue(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function countEmbeddedReportImages(markdown?: string): number {
return (markdown?.match(/!\[[^\]]*\]\(deep-research:\/\/[^)\s]+\)/g) ?? []).length;
}
function requestedReportImageCount(config?: Record<string, unknown>): number {
if (config?.generate_images !== true) return 0;
const configured = numberValue(config.max_generated_images);
return Math.max(1, Math.min(4, Math.floor(configured ?? 2)));
}
function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
const groupQueries = new Map<string, string[]>();
const subtopics: string[] = [];
const queryMap = new Map<string, TraceQuery>();
const sourceMap = new Map<string, TraceSource>();
const imageMap = new Map<number, TraceImage>();
const selectedIds = new Set<string>();
const removedIds = new Set<string>();
const warnings: string[] = [];
let curationReason: string | undefined;
let compressedSourceCount: number | undefined;
let reportCompleted = false;
let imageTotal = 0;
let imagePhaseFinished = false;
const addQuery = (query: string, group = "研究课题") => {
const normalized = query.trim();
@@ -2223,6 +2328,11 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
for (const event of events) {
const payload = event.payload ?? {};
if (event.type === "phase_changed") {
const from = traceString(payload.from);
const to = traceString(payload.to);
if (from === "illustrating" || to === "exporting") imagePhaseFinished = true;
}
if (event.type === "plan_created") {
const plan = traceRecord(payload.plan);
for (const subtopic of traceStrings(payload.subtopics).concat(traceStrings(plan.subtopics))) {
@@ -2292,9 +2402,39 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
reportCompleted = true;
continue;
}
if (event.type === "image_generation_started" || event.type === "image_generated") {
const position = Math.max(1, Math.floor(numberValue(payload.position) ?? imageMap.size + 1));
const total = numberValue(payload.total);
if (total !== undefined) imageTotal = Math.max(imageTotal, Math.floor(total));
imageTotal = Math.max(imageTotal, position);
const prior = imageMap.get(position);
imageMap.set(position, {
position,
section: traceString(payload.section) ?? prior?.section ?? `第 ${position} 张配图`,
status: event.type === "image_generated" ? "completed" : prior?.status === "completed" ? "completed" : "generating",
name: traceString(payload.name) ?? prior?.name,
alt: traceString(payload.alt) ?? prior?.alt,
});
continue;
}
if (event.type === "warning" || event.type === "error") {
const message = traceString(payload.message) ?? traceString(payload.detail);
if (message && !warnings.includes(message)) warnings.push(message);
if (traceString(payload.code) === "image_generation_failed") {
const position = Math.max(1, Math.floor(numberValue(payload.position) ?? imageMap.size + 1));
const total = numberValue(payload.total);
if (total !== undefined) imageTotal = Math.max(imageTotal, Math.floor(total));
imageTotal = Math.max(imageTotal, position);
const prior = imageMap.get(position);
imageMap.set(position, {
position,
section: traceString(payload.section) ?? prior?.section ?? `第 ${position} 张配图`,
status: "failed",
name: prior?.name,
alt: prior?.alt,
error: message ?? "报告配图生成失败",
});
}
}
}
@@ -2308,12 +2448,15 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
curationReason,
compressedSourceCount,
reportCompleted,
imageTotal,
images: [...imageMap.values()].sort((left, right) => left.position - right.position),
imagePhaseFinished,
warnings,
};
}
function phaseAtOrAfter(phase: string, boundary: string): boolean {
const order = ["initializing", "planning", "collecting", "curating", "compressing", "deepening", "writing", "reviewing", "illustrating", "exporting", "done"];
const order = ["initializing", "planning", "collecting", "curating", "compressing", "deepening", "writing", "summarizing", "reviewing", "illustrating", "exporting", "done"];
return Math.max(0, order.indexOf(phase)) >= Math.max(0, order.indexOf(boundary));
}
@@ -2345,7 +2488,7 @@ function ReasoningStream({ text }: { text: string }) {
);
}
const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, planReasoning, sourceCount, reportStarted, error, onOpenSandbox, active }: { events: ProgressEvent[]; phase: string; planText: string; planReasoning: string; sourceCount: number; reportStarted: boolean; error: string | null; onOpenSandbox: () => void; active: boolean }) {
const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, planReasoning, sourceCount, reportStarted, error, onOpenSandbox, active, requestedImageCount }: { events: ProgressEvent[]; phase: string; planText: string; planReasoning: string; sourceCount: number; reportStarted: boolean; error: string | null; onOpenSandbox: () => void; active: boolean; requestedImageCount: number }) {
const [expanded, setExpanded] = useState(true);
const snapshot = useMemo(() => buildTraceSnapshot(events), [events]);
const sourcesById = useMemo(() => new Map(snapshot.sources.map((source) => [source.id, source])), [snapshot.sources]);
@@ -2358,6 +2501,14 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla
const curatingDone = phaseAtOrAfter(phase, "writing") || snapshot.selectedIds.size > 0;
const writingDone = snapshot.reportCompleted || phase === "done";
const streamedQueries = planningDone ? [] : parseStreamedQueries(planText);
const imageStageVisible = requestedImageCount > 0 || snapshot.imageTotal > 0 || snapshot.images.length > 0;
const imageTotal = snapshot.imageTotal || requestedImageCount;
const completedImages = snapshot.images.filter((image) => image.status === "completed").length;
const failedImages = snapshot.images.filter((image) => image.status === "failed").length;
const processedImages = completedImages + failedImages;
const imageDone = imageStageVisible && (snapshot.imagePhaseFinished || phaseAtOrAfter(phase, "exporting") || (!active && phase === "done"));
const imageCurrent = imageStageVisible && !imageDone && (phase === "illustrating" || snapshot.images.some((image) => image.status === "generating"));
const imageProgress = imageTotal ? Math.min(100, Math.round((processedImages / imageTotal) * 100)) : 0;
return (
<div className="overflow-hidden rounded-2xl rounded-tl-md border border-border bg-card shadow-sm">
@@ -2390,6 +2541,12 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla
<button type="button" onClick={onOpenSandbox} className="inline-flex items-center gap-1.5 rounded-md bg-primary/8 px-2.5 py-1.5 text-xs font-medium text-primary hover:bg-primary/12"><FileText className="h-3.5 w-3.5" />{writingDone ? "查看已保存的 report.md" : "打开正在写入的 report.md"}<ChevronRight className="h-3.5 w-3.5" /></button>
</TraceStage>
{imageStageVisible && <TraceStage number={5} title="生成报告配图" detail={imageDone ? completedImages > 0 ? `已生成 ${completedImages} 张配图并嵌入 report.md${failedImages ? `;另有 ${failedImages} 张未生成,文字报告已保留` : ""}。` : "配图未生成,已保留完整文字报告。" : imageCurrent ? `正在生成报告配图,已处理 ${processedImages}/${imageTotal || "?"} 张。` : "报告写完后会为关键章节生成并嵌入配图。"} done={imageDone} current={imageCurrent}>
{imageTotal > 0 && <div className="mb-3 h-2 overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-primary transition-[width] duration-300" style={{ width: `${imageDone ? 100 : imageProgress}%` }} /></div>}
{snapshot.images.length > 0 ? <div className="space-y-2">{snapshot.images.map((image) => <div key={image.position} className="flex min-w-0 items-center gap-2 rounded-lg border border-border/70 bg-muted/[0.12] px-3 py-2"><ImagePlus className={`h-3.5 w-3.5 shrink-0 ${image.status === "completed" ? "text-emerald-600" : image.status === "failed" ? "text-amber-600" : "animate-pulse text-primary"}`} /><div className="min-w-0 flex-1"><p className="truncate text-xs font-medium">第 {image.position} 张 · {image.section}</p><p className="mt-0.5 text-[11px] text-muted-foreground">{image.status === "completed" ? `${image.name ?? "已保存并嵌入报告"}` : image.status === "failed" ? image.error ?? "生成失败,已保留文字报告" : "正在调用图片模型…"}</p></div><span className={`shrink-0 text-[11px] ${image.status === "completed" ? "text-emerald-700 dark:text-emerald-300" : image.status === "failed" ? "text-amber-700 dark:text-amber-300" : "text-primary"}`}>{image.status === "completed" ? "已生成" : image.status === "failed" ? "未生成" : "生成中"}</span></div>)}</div> : <div className="rounded-lg border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">{imageCurrent ? "正在准备第一张报告配图…" : `最多生成 ${imageTotal || requestedImageCount} 张,并在完成后插入对应章节。`}</div>}
{completedImages > 0 && <button type="button" onClick={onOpenSandbox} className="mt-3 inline-flex items-center gap-1.5 rounded-md bg-primary/8 px-2.5 py-1.5 text-xs font-medium text-primary hover:bg-primary/12"><ImagePlus className="h-3.5 w-3.5" />查看已嵌入报告的配图<ChevronRight className="h-3.5 w-3.5" /></button>}
</TraceStage>}
{snapshot.warnings.length > 0 && <div className="rounded-lg bg-amber-500/10 px-3 py-2.5 text-xs leading-5 text-amber-800 dark:text-amber-200"><p className="font-medium">过程提示</p>{snapshot.warnings.map((warning) => <p key={warning} className="mt-1">{warning}</p>)}</div>}
{error && <p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">实时连接提示:{error}。研究仍会继续,页面会从已保存的事件记录恢复。</p>}
</div>}
@@ -2398,7 +2555,8 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla
});
function CompletionSummary({ session, sourceCount, onOpenSandbox, onExport }: { session: DeepResearchSession; sourceCount: number; onOpenSandbox: () => void; onExport: (format: "word" | "markdown" | "html") => void }) {
return <div className="rounded-2xl rounded-tl-md border border-emerald-500/20 bg-card p-4 shadow-sm"><div className="flex items-start gap-2"><div className="mt-0.5 rounded-full bg-emerald-500/12 p-1 text-emerald-600"><CheckCircle2 className="h-3.5 w-3.5" /></div><div><p className="text-sm font-medium">研究报告已完成</p><p className="mt-1 text-sm leading-6 text-muted-foreground">已基于 {sourceCount || session.sourceCount} 条资料完成报告,并保留来源供后续追问。报告正文在右侧沙箱中打开。</p></div></div><div className="mt-4 flex flex-wrap gap-2"><Button size="sm" onClick={onOpenSandbox}><FolderOpen className="mr-1.5 h-3.5 w-3.5" />查看报告</Button><Button size="sm" variant="outline" onClick={() => onExport("word")}><FileDown className="mr-1.5 h-3.5 w-3.5" />Word</Button><Button size="sm" variant="outline" onClick={() => onExport("markdown")}><FileCode2 className="mr-1.5 h-3.5 w-3.5" />Markdown</Button><Button size="sm" variant="outline" onClick={() => onExport("html")}><FileType2 className="mr-1.5 h-3.5 w-3.5" />HTML</Button></div></div>;
const imageCount = countEmbeddedReportImages(session.reportMarkdown);
return <div className="rounded-2xl rounded-tl-md border border-emerald-500/20 bg-card p-4 shadow-sm"><div className="flex items-start gap-2"><div className="mt-0.5 rounded-full bg-emerald-500/12 p-1 text-emerald-600"><CheckCircle2 className="h-3.5 w-3.5" /></div><div><p className="text-sm font-medium">研究报告已完成</p><p className="mt-1 text-sm leading-6 text-muted-foreground">已基于 {sourceCount || session.sourceCount} 条资料完成报告,并保留来源供后续追问。报告正文在右侧沙箱中打开。</p>{imageCount > 0 && <p className="mt-1.5 flex items-center gap-1.5 text-sm leading-6 text-emerald-700 dark:text-emerald-300"><ImagePlus className="h-3.5 w-3.5" />已生成 {imageCount} 张报告配图,已嵌入正文对应章节。</p>}</div></div><div className="mt-4 flex flex-wrap gap-2"><Button size="sm" onClick={onOpenSandbox}><FolderOpen className="mr-1.5 h-3.5 w-3.5" />查看报告</Button><Button size="sm" variant="outline" onClick={() => onExport("word")}><FileDown className="mr-1.5 h-3.5 w-3.5" />Word</Button><Button size="sm" variant="outline" onClick={() => onExport("markdown")}><FileCode2 className="mr-1.5 h-3.5 w-3.5" />Markdown</Button><Button size="sm" variant="outline" onClick={() => onExport("html")}><FileType2 className="mr-1.5 h-3.5 w-3.5" />HTML</Button></div></div>;
}
function PlanReviewCard({ plan, submitting, onSubmit }: { plan: Record<string, unknown> | undefined; submitting: boolean; onSubmit: (action: "approve" | "revise", feedback?: string) => void }) {
@@ -2409,8 +2567,144 @@ function PlanReviewCard({ plan, submitting, onSubmit }: { plan: Record<string, u
// ── advanced settings dialog ─────────────────────────────────────────────────
function AdvancedResearchSettingsDialog({ open, onOpenChange, mode, options, classicMode, onClassicModeChange, imageGenerationAvailable, imageLimit, onChange }: { open: boolean; onOpenChange: (open: boolean) => void; mode: ResearchMode; options: AdvancedResearchOptions; classicMode: boolean; onClassicModeChange: (value: boolean) => void; imageGenerationAvailable: boolean; imageLimit: number; onChange: <K extends keyof AdvancedResearchOptions>(key: K, value: AdvancedResearchOptions[K]) => void }) {
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent className="max-h-[88vh] max-w-3xl overflow-y-auto"><DialogHeader><DialogTitle>高级研究配置</DialogTitle><DialogDescription>本次设置会作为下一次研究的默认值;对话式收集的最终选项在报告配置卡片中确认。</DialogDescription></DialogHeader><div className="rounded-xl border border-border p-3"><label className="flex cursor-pointer gap-3"><input type="checkbox" checked={classicMode} onChange={(event) => onClassicModeChange(event.target.checked)} className="mt-0.5 accent-primary" /><span><span className="block text-sm font-medium">经典模式</span><span className="mt-1 block text-xs leading-5 text-muted-foreground">回退到旧全流程:输入框直接选模式并自动开始研究,不经过收集对话与报告配置卡片。仅影响下一次新建的研究。</span></span></label></div><div className="grid gap-5 py-2 sm:grid-cols-2"><SettingSelect label="输出语言" value={options.language} onValueChange={(value) => onChange("language", value as AdvancedResearchOptions["language"])} options={[{ value: "zh-CN", label: "中文" }, { value: "en-US", label: "English" }]} /><SettingSelect label="报告语气" value={options.tone} onValueChange={(value) => onChange("tone", value as ResearchTone)} options={[{ value: "objective", label: "客观" }, { value: "formal", label: "正式" }, { value: "analytical", label: "分析" }, { value: "persuasive", label: "说服" }]} /><NumberSetting label="单次查询结果" hint="1–20 条" value={options.maxSearchResults} onChange={(value) => onChange("maxSearchResults", value)} /><NumberSetting label="子问题轮数" hint="1–10 轮" value={options.maxIterations} onChange={(value) => onChange("maxIterations", value)} /><NumberSetting label="最大并发" hint="1–8 路" value={options.maxConcurrency} onChange={(value) => onChange("maxConcurrency", value)} /><NumberSetting label="上下文词数" hint="500–120000" value={options.maxContextWords} onChange={(value) => onChange("maxContextWords", value)} />{(mode === "detailed" || mode === "multi_agent") && <NumberSetting label="最多子主题" hint="1–20 个" value={options.maxSubtopics} onChange={(value) => onChange("maxSubtopics", value)} />}{mode === "deep" && <><NumberSetting label="深挖广度" hint="1–8" value={options.deepBreadth} onChange={(value) => onChange("deepBreadth", value)} /><NumberSetting label="深挖层级" hint="1–4" value={options.deepDepth} onChange={(value) => onChange("deepDepth", value)} /></>}{mode === "multi_agent" && <NumberSetting label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(value) => onChange("maxRevisionLoops", value)} />}</div><div className="space-y-2"><label htmlFor="research-outline" className="text-sm font-medium">自定义报告结构</label><Textarea id="research-outline" value={options.customOutline} onChange={(event) => onChange("customOutline", event.target.value)} className="min-h-28 resize-y" placeholder={"例如:\n# 执行摘要\n## 市场与背景\n## 机会与风险"} /><p className="text-xs text-muted-foreground">支持 Markdown 标题或编号列表,会作为报告写作结构要求。</p></div><div className="grid gap-3 sm:grid-cols-2"><label className="flex cursor-pointer gap-3 rounded-xl border p-3"><input type="checkbox" checked={options.thinkingEnabled} onChange={(event) => onChange("thinkingEnabled", event.target.checked)} className="mt-0.5 accent-primary" /><span><span className="block text-sm font-medium">启用模型深度思考</span><span className="mt-1 block text-xs leading-5 text-muted-foreground">仅在当前系统默认模型支持时生效,会增加响应时延与模型消耗。</span></span></label>{imageGenerationAvailable && <div className="rounded-xl border p-3"><NumberSetting label="最多报告配图" hint={`1–${imageLimit} 张`} value={options.maxGeneratedImages} onChange={(value) => onChange("maxGeneratedImages", value)} /></div>}</div><DialogFooter><Button onClick={() => onOpenChange(false)}>保存配置</Button></DialogFooter></DialogContent></Dialog>;
function AdvancedResearchSettingsDialog({
open,
onOpenChange,
mode,
options,
classicMode,
onClassicModeChange,
imageGenerationAvailable,
imageLimit,
onChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: ResearchMode;
options: AdvancedResearchOptions;
classicMode: boolean;
onClassicModeChange: (value: boolean) => void;
imageGenerationAvailable: boolean;
imageLimit: number;
onChange: <K extends keyof AdvancedResearchOptions>(key: K, value: AdvancedResearchOptions[K]) => void;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[88vh] w-[calc(100vw-2rem)] max-w-none overflow-y-auto sm:w-[600px] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>高级研究配置</DialogTitle>
<DialogDescription>
本次设置会作为下一次研究的默认值;对话式收集的最终选项在报告配置卡片中确认。
</DialogDescription>
</DialogHeader>
<div className="rounded-xl border border-border p-3">
<label className="flex cursor-pointer gap-3">
<input
type="checkbox"
checked={classicMode}
onChange={(event) => onClassicModeChange(event.target.checked)}
className="mt-0.5 accent-primary"
/>
<span>
<span className="block text-sm font-medium">经典模式</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
回退到旧全流程:输入框直接选模式并自动开始研究,不经过收集对话与报告配置卡片。仅影响下一次新建的研究。
</span>
</span>
</label>
</div>
<div className="grid gap-5 py-2 sm:grid-cols-2">
<SettingSelect
label="输出语言"
value={options.language}
onValueChange={(value) => onChange("language", value as AdvancedResearchOptions["language"])}
options={[
{ value: "zh-CN", label: "中文" },
{ value: "en-US", label: "English" },
]}
/>
<SettingSelect
label="报告语气"
value={options.tone}
onValueChange={(value) => onChange("tone", value as ResearchTone)}
options={[
{ value: "objective", label: "客观" },
{ value: "formal", label: "正式" },
{ value: "analytical", label: "分析" },
{ value: "persuasive", label: "说服" },
]}
/>
<NumberSetting label="单次查询结果" hint="1–20 条" value={options.maxSearchResults} onChange={(value) => onChange("maxSearchResults", value)} />
<NumberSetting label="子问题轮数" hint="1–10 轮" value={options.maxIterations} onChange={(value) => onChange("maxIterations", value)} />
<NumberSetting label="最大并发" hint="1–8 路" value={options.maxConcurrency} onChange={(value) => onChange("maxConcurrency", value)} />
<NumberSetting label="上下文词数" hint="500–120000" value={options.maxContextWords} onChange={(value) => onChange("maxContextWords", value)} />
{(mode === "detailed" || mode === "multi_agent") && (
<NumberSetting label="最多子主题" hint="1–20 个" value={options.maxSubtopics} onChange={(value) => onChange("maxSubtopics", value)} />
)}
{mode === "deep" && (
<>
<NumberSetting label="深挖广度" hint="1–8" value={options.deepBreadth} onChange={(value) => onChange("deepBreadth", value)} />
<NumberSetting label="深挖层级" hint="1–4" value={options.deepDepth} onChange={(value) => onChange("deepDepth", value)} />
</>
)}
{mode === "multi_agent" && (
<NumberSetting label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(value) => onChange("maxRevisionLoops", value)} />
)}
</div>
<ReportOutlineEditor
id="research-outline"
value={options.customOutline}
onChange={(value) => onChange("customOutline", value)}
/>
{/* 工作流配置:报告结构库(后台 /api/report-structures),选中卡片即填充上方报告结构 */}
<div className="flex items-center justify-between gap-3 rounded-xl border border-border p-3">
<span className="text-xs leading-5 text-muted-foreground">
<span className="block text-sm font-medium text-foreground">报告结构库</span>
在「工作流配置」中管理并选择报告结构;选中卡片后自动填充到上方报告结构。
</span>
<WorkflowConfigButton
appliedContent={options.customOutline}
onApply={(content) => onChange("customOutline", content)}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex cursor-pointer gap-3 rounded-xl border p-3">
<input
type="checkbox"
checked={options.thinkingEnabled}
onChange={(event) => onChange("thinkingEnabled", event.target.checked)}
className="mt-0.5 accent-primary"
/>
<span>
<span className="block text-sm font-medium">启用模型深度思考</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
仅在当前系统默认模型支持时生效,会增加响应时延与模型消耗。
</span>
</span>
</label>
{imageGenerationAvailable && (
<div className="rounded-xl border p-3">
<NumberSetting
label="最多报告配图"
hint={`1–${imageLimit} 张`}
value={options.maxGeneratedImages}
onChange={(value) => onChange("maxGeneratedImages", value)}
/>
</div>
)}
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(false)}>保存配置</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function NumberSetting({ label, hint, value, onChange }: { label: string; hint: string; value: string; onChange: (value: string) => void }) { return <label className="block"><span className="mb-2 block text-sm font-medium">{label}</span><Input value={value} inputMode="numeric" onChange={(event) => onChange(event.target.value)} /><span className="mt-1 block text-xs text-muted-foreground">{hint}</span></label>; }
@@ -8,7 +8,7 @@
* 以 chat 入口启动报告写作 job —— 保留旧输入框全部能力,改造收敛在这一张卡片。
*/
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import {
ChevronRight,
ImagePlus,
@@ -32,6 +32,7 @@ import {
type ResearchMode,
type ResearchTone,
} from "@/strategy-components/api/deep-research";
import { WorkflowConfigButton } from "./WorkflowConfigDialog";
const MODE_LABELS: Record<ResearchMode, string> = {
basic: "标准",
@@ -65,6 +66,198 @@ export type AdvancedResearchOptions = {
customOutline: string;
};
/**
* A deliberately domain-neutral structure which gives lightweight models a
* dependable writing scaffold without forcing market-analysis terminology on
* every research topic. Users can edit or replace it in the advanced dialog.
*/
export const DEFAULT_REPORT_OUTLINE = `# 报告标题
# 引言
用 1–2 段说明研究对象、提出本议题的背景、研究范围及资料边界;简要交代全文的分析路径,不单独拆分小节。
# 一、研究对象与事实基础
## 1.1 概念、范围与背景
界定研究对象、关键概念、时间和空间范围;说明读者理解问题所需的背景。
## 1.2 已有事实与证据
按时间或逻辑顺序梳理已知事实、数据、案例或材料发现;优先使用可核验的来源。
## 1.3 当前状态与主要特征
归纳研究对象目前呈现的状态、关键变化、共性与差异;没有数据支撑时明确说明。
# 二、问题形成与作用机制
## 2.1 直接原因与触发条件
分析近期事件、条件变化或关键节点如何推动问题出现或演变。
## 2.2 深层影响因素
从制度、技术、资源、行为、环境或历史条件中选择适用维度,分析长期影响。
## 2.3 因果关系与相互作用
说明不同因素如何共同作用;区分来源已证实的事实、分析判断和仍待验证的推断。
# 三、影响、问题与不同观点
## 3.1 对研究对象的直接影响
从结果、效率、质量、风险、成本、体验或其他适用维度,分析当前影响。
## 3.2 对相关主体与外部环境的影响
说明影响如何传导到相关群体、组织、地区、系统或后续事项;不适用的维度可省略。
## 3.3 主要争议与解释差异
并列材料支持的不同观点、解释或结论,并说明各自的证据基础与局限。
# 四、比较、趋势与不确定性
## 4.1 可比案例或横向比较
在材料允许时选择历史案例、不同地区、不同方案或不同阶段进行比较,说明异同与启示。
## 4.2 发展趋势与关键变量
识别后续可能变化、决定性变量、观察指标和潜在转折点;不把预测写成既成事实。
## 4.3 风险情景与不确定性
说明不同情景的触发条件、可能影响和证据不足之处;对没有依据的推测明确标注。
# 五、结论、建议与后续关注
## 5.1 核心结论
用编号或分点方式收束全文;每条结论都应能回溯到正文中的事实和分析。
## 5.2 可行建议或应对方向
仅在研究材料支持时提出建议;说明适用对象、前提条件、优先级和预期作用。
## 5.3 资料局限与后续关注
列出证据缺口、数据口径差异、尚不能下结论的问题,以及值得持续跟踪的事项。
# 参考资料
仅列出本报告实际使用的来源,并与正文引用保持一致。
> 写作要求:引言保持简洁;每个二级小节按材料充分程度写 2–5 段完整分析,每段围绕一个明确观点展开,采用“事实/证据 → 分析 → 影响或小结”的顺序。章节可根据研究主题合并、增删或调整,但不要为了套模板而编造不适用的主体、数据、案例或引用。`;
/**
* A causal, chapter-to-chapter alternative for topics where the report should
* read as one continuous argument rather than a set of parallel dimensions.
* Each chapter explicitly takes the previous chapter's conclusion as input.
*/
export const PROGRESSIVE_REPORT_OUTLINE = `# 报告标题
# 引言
用 1–2 段说明研究对象、核心问题、研究范围和资料边界;简要说明本文将按“事实基线 → 成因机制 → 影响判断 → 应对方向 → 结论”的递进逻辑展开。
# 一、事实基线:问题是什么
## 1.1 概念界定与研究范围
明确关键概念、时间/对象边界和判断口径,避免后续讨论混用概念。
## 1.2 关键事实、数据与证据
按时间或逻辑顺序列出已经发生的事实、可核验数据、案例和来源。
## 1.3 基线判断与待解问题
基于前两节归纳当前状态、主要矛盾和需要解释的核心问题。
> 本章产出:后续分析必须以本章已经确认的事实和问题为基础,不得在后文补写没有来源支撑的新事实。
# 二、形成机制:为什么会这样
## 2.1 直接原因与触发条件
从第一章识别的问题出发,分析近期事件、条件变化或关键节点如何导致当前结果。
## 2.2 深层条件与关键约束
结合第一章的证据,说明制度、技术、资源、行为、环境或历史条件等长期影响。
## 2.3 因果链条与关键变量
把前两节串成清晰的“条件 → 机制 → 结果”链条,标出决定性变量及仍待验证的环节。
> 本章产出:第三章只能沿用本章已说明的机制评估影响,不能把相关性直接写成因果关系。
# 三、结果与影响:会带来什么
## 3.1 直接结果与短期影响
依据第二章的因果链条,分析对研究对象最直接、最先出现的影响。
## 3.2 传导效应与中长期影响
继续说明影响如何传导至相关主体、外部环境或后续事项;不适用的维度可省略。
## 3.3 权衡、分歧与边界
比较不同影响之间的取舍、材料支持的不同观点,以及结论适用的边界。
> 本章产出:第四章的趋势判断、情景和建议应以本章已评估的影响、风险和权衡为依据。
# 四、趋势与应对:接下来怎么办
## 4.1 未来走势与观察指标
从第三章的影响出发,识别可能变化、关键观察指标和潜在转折点;不把预测写成既成事实。
## 4.2 可选方案与适用条件
针对第三章发现的问题和风险,提出可行的应对方向、前提条件与优先级。
## 4.3 风险情景与验证重点
说明不同情景的触发条件、可能后果,以及下一步需要补充验证的数据或材料。
> 本章产出:第五章只综合前四章已经建立的事实、机制、影响和建议,不新增独立论点。
# 五、综合结论与后续关注
## 5.1 核心结论
按“事实基线 → 原因机制 → 主要影响 → 应对方向”逐项收束,每条结论都可回溯到前文证据。
## 5.2 局限与后续研究
列出资料缺口、数据口径差异、尚不能下结论的问题和后续跟踪清单。
# 参考资料
仅列出本报告实际使用的来源,并与正文引用保持一致。
> 写作要求:除引言外,每个二级小节写 2–5 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。`;
export type ReportOutlineTemplate = "general" | "progressive" | "custom";
const REPORT_OUTLINE_TEMPLATE_OPTIONS: Array<{ value: ReportOutlineTemplate; label: string }> = [
{ value: "general", label: "通用分析结构" },
{ value: "progressive", label: "递进式论证结构" },
{ value: "custom", label: "自定义结构" },
];
function matchingOutlineTemplate(outline: string): ReportOutlineTemplate {
const normalized = outline.trim();
if (normalized === DEFAULT_REPORT_OUTLINE.trim()) return "general";
if (normalized === PROGRESSIVE_REPORT_OUTLINE.trim()) return "progressive";
return "custom";
}
/** Shared by the initial settings dialog and the write-confirmation card. */
export function ReportOutlineEditor({
id,
value,
onChange,
}: {
id: string;
value: string;
onChange: (value: string) => void;
}) {
const [customSelected, setCustomSelected] = useState(false);
const template = customSelected ? "custom" : matchingOutlineTemplate(value);
const selectTemplate = (next: ReportOutlineTemplate) => {
if (next === "custom") {
setCustomSelected(true);
// “自定义” is an intentional blank starting point. The two presets are
// still available as one-click scaffolds whenever the user wants them.
onChange("");
return;
}
setCustomSelected(false);
onChange(next === "general" ? DEFAULT_REPORT_OUTLINE : PROGRESSIVE_REPORT_OUTLINE);
};
return (
<div className="space-y-2">
<label htmlFor={`${id}-template`} className="block text-xs font-medium">
报告结构模板
</label>
<Select value={template} onValueChange={(next) => selectTemplate(next as ReportOutlineTemplate)}>
<SelectTrigger id={`${id}-template`} className="h-9 text-sm" aria-label="报告结构模板">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_OUTLINE_TEMPLATE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
<label htmlFor={id} className="block pt-1 text-xs font-medium">
报告结构(可编辑)
</label>
<Textarea
id={id}
value={value}
onChange={(event) => {
setCustomSelected(true);
onChange(event.target.value);
}}
maxLength={4000}
className="min-h-28 resize-y text-xs"
placeholder={"例如:\n# 引言\n# 一、问题与事实\n## 1.1 范围与依据"}
/>
<p className="text-[10px] leading-4 text-muted-foreground">
选择预设模板会替换下方内容;选择“自定义”会清空后从头编辑。支持 Markdown 标题或编号列表。
</p>
</div>
);
}
export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
language: "zh-CN",
tone: "objective",
@@ -78,7 +271,7 @@ export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
maxRevisionLoops: "2",
maxGeneratedImages: "2",
thinkingEnabled: false,
customOutline: "",
customOutline: DEFAULT_REPORT_OUTLINE,
};
/** Seed the card's editable options from the frozen session config snapshot. */
@@ -100,7 +293,9 @@ export function advancedOptionsFromConfig(config?: Record<string, unknown>): Adv
maxRevisionLoops: numberish("max_revision_loops", DEFAULT_ADVANCED_OPTIONS.maxRevisionLoops),
maxGeneratedImages: numberish("max_generated_images", DEFAULT_ADVANCED_OPTIONS.maxGeneratedImages),
thinkingEnabled: config?.thinking_enabled === true,
customOutline: typeof config?.custom_outline === "string" ? config.custom_outline : "",
customOutline: typeof config?.custom_outline === "string" && config.custom_outline.trim()
? config.custom_outline
: DEFAULT_REPORT_OUTLINE,
};
}
@@ -174,11 +369,6 @@ export function ReportConfigCard({
const [generateImages, setGenerateImages] = useState(defaultGenerateImages);
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => {
setOptions(defaultOptions);
setGenerateImages(defaultGenerateImages);
}, [defaultOptions, defaultGenerateImages]);
const modes = useMemo(
() => (availableModes.length ? availableModes : (["basic", "quick", "detailed", "deep", "multi_agent"] as ResearchMode[])),
[availableModes],
@@ -278,15 +468,17 @@ export function ReportConfigCard({
<CardNumber label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(v) => update("maxRevisionLoops", v)} />
)}
<div className="sm:col-span-2">
<span className="mb-1.5 block text-xs font-medium">自定义报告结构</span>
<Textarea
<ReportOutlineEditor
id="report-card-outline"
value={options.customOutline}
onChange={(event) => update("customOutline", event.target.value)}
maxLength={4000}
className="min-h-20 resize-y text-xs"
placeholder={"例如:\n# 执行摘要\n## 市场与背景\n## 机会与风险\n## 建议"}
onChange={(value) => update("customOutline", value)}
/>
</div>
<div className="flex justify-end sm:col-span-2">
<WorkflowConfigButton
appliedContent={options.customOutline}
onApply={(value) => update("customOutline", value)}
/>
<p className="mt-1 text-[10px] text-muted-foreground">支持 Markdown 标题或编号列表,作为报告写作结构要求。</p>
</div>
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4 sm:col-span-2">
<input
@@ -10,11 +10,12 @@
* 切预览 + 脉冲、复制/另存、展示层脱敏)。
*/
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { FileTextIcon, XIcon } from "lucide-react";
import { FileTextIcon, Loader2Icon, SparklesIcon, XIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { ArtifactAction } from "@/components/ai-elements/artifact";
import { Button } from "@/components/ui/button";
import {
ResizableHandle,
@@ -136,18 +137,6 @@ export function ResearchSandboxPanel({
<div className="relative h-full bg-[#fffdfa] p-4">
{selectedArtifact && previewThreadId ? (
<>
{onDocumentRewriteStart && (
<div className="absolute top-7 right-36 z-30">
<Button
size="sm"
variant="outline"
disabled={documentRewriteActive}
onClick={() => setRewriteDialogOpen(true)}
>
{documentRewriteActive ? "正在改写" : "AI 全文改写"}
</Button>
</div>
)}
{documentRewriteActive && onCancelDocumentRewrite && (
<div className="absolute right-36 bottom-7 z-30">
<Button size="sm" variant="outline" onClick={onCancelDocumentRewrite}>
@@ -159,8 +148,24 @@ export function ResearchSandboxPanel({
className="size-full"
filepath={selectedArtifact}
threadId={previewThreadId}
researchSessionId={previewThreadId}
onClose={handleArtifactsClose}
onDownloadClick={onDownload}
toolbarActions={
onDocumentRewriteStart ? (
<ArtifactAction
icon={documentRewriteActive ? Loader2Icon : SparklesIcon}
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
tooltip={
documentRewriteActive
? "正在生成临时草稿,原报告尚未被覆盖"
: "使用 AI 重写完整 report.md"
}
disabled={documentRewriteActive}
onClick={() => setRewriteDialogOpen(true)}
/>
) : null
}
/>
</>
) : (
@@ -0,0 +1,549 @@
"use client";
/**
* 工作流配置(报告结构库)弹窗 + 触发按钮。
*
* 深度研究「高级配置」区的人口:按钮打开大弹窗,弹窗内两视图——
* - 列表视图:报告结构卡片网格(后台 /api/report-structures,全局共享,
* 全新部署自动播种「通用分析结构 / 递进式论证结构」),支持 启用/停用 筛选
* 与 标题 筛选;卡片上可 switch 启停、编辑、删除(二次确认),并显示
* 最后修改时间与创建用户名;新增时不填状态(服务端强制默认启用)。
* - 详情视图:点击卡片即「选择」该结构(内容回填到高级配置的报告结构
* custom_outline),弹窗切换为展示 标题 + 接口内容,右上角「返回」回列表。
*/
import { useEffect, useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import {
ChevronLeft,
Loader2,
Pencil,
Plus,
Search,
Trash2,
Workflow,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
type ReportStructure,
type ReportStructureStatusFilter,
useReportStructures,
useReportStructureMutations,
} from "@/strategy-components/api/report-structures";
// ── shared helpers ───────────────────────────────────────────────────────────
function formatDateTime(iso: string | null): string {
if (!iso) return "—";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/** 创建用户名:本部署账号是邮箱,取 @ 前缀(与个人信息页「用户名」一致)。 */
function creatorLabel(structure: ReportStructure): string {
if (!structure.createdBy) return "—";
return structure.createdBy.split("@")[0] || structure.createdBy;
}
// ── main dialog ──────────────────────────────────────────────────────────────
export function WorkflowConfigDialog({
open,
onOpenChange,
/** 当前已应用的报告结构内容(高级配置的 custom_outline),用于高亮「当前使用」。 */
appliedContent,
/** 点击卡片选择结构时回填内容。 */
onApply,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
appliedContent?: string;
onApply?: (content: string) => void;
}) {
const [statusFilter, setStatusFilter] = useState<ReportStructureStatusFilter>("all");
const [titleFilter, setTitleFilter] = useState("");
const [detailId, setDetailId] = useState<string | null>(null);
const [editorTarget, setEditorTarget] = useState<ReportStructure | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ReportStructure | null>(null);
// 启用/停用筛选走服务端;标题筛选在本地做(即时、免抖动)。
const listQuery = useReportStructures({ status: statusFilter });
const mutations = useReportStructureMutations();
// 重新打开时回到列表视图。
useEffect(() => {
if (open) setDetailId(null);
}, [open]);
const structures = listQuery.data ?? [];
const visible = useMemo(() => {
const needle = titleFilter.trim().toLowerCase();
if (!needle) return structures;
return structures.filter((s) => s.title.toLowerCase().includes(needle));
}, [structures, titleFilter]);
const appliedStructureId = useMemo(() => {
const trimmed = (appliedContent ?? "").trim();
if (!trimmed) return null;
return structures.find((s) => s.content.trim() === trimmed)?.id ?? null;
}, [appliedContent, structures]);
const detail = useMemo(
() => structures.find((s) => s.id === detailId) ?? null,
[structures, detailId],
);
const handleSelect = (structure: ReportStructure) => {
onApply?.(structure.content);
setDetailId(structure.id);
toast.success(`已选择报告结构「${structure.title}」,并填充到报告结构配置`);
};
const handleToggleEnabled = (structure: ReportStructure, enabled: boolean) => {
mutations.toggleEnabled.mutate(
{ id: structure.id, enabled },
{
onSuccess: () => toast.success(`「${structure.title}」已${enabled ? "启用" : "停用"}`),
onError: (err) => toast.error(err instanceof Error ? err.message : "操作失败"),
},
);
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await mutations.remove.mutateAsync(deleteTarget.id);
toast.success(`已删除「${deleteTarget.title}」`);
if (detailId === deleteTarget.id) setDetailId(null);
setDeleteTarget(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : "删除失败");
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="flex h-[85vh] max-h-[85vh] w-[calc(100vw-3rem)] max-w-5xl flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl"
>
{/* header:列表视图=标题+筛选+新增;详情视图=结构标题,右上角「返回」 */}
{!detail ? (
<div className="shrink-0 border-b border-border px-5 py-4">
<div className="flex flex-wrap items-center gap-3">
<div className="min-w-0 flex-1">
<h2 className="text-base font-semibold">工作流配置 · 报告结构</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
点击卡片选择报告结构(自动填充到报告结构配置);可启停、编辑、删除。
</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
aria-label="关闭"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<div className="relative min-w-0 flex-1 sm:max-w-xs">
<Search className="pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={titleFilter}
onChange={(event) => setTitleFilter(event.target.value)}
placeholder="按标题筛选"
className="h-8 pl-8 text-xs"
/>
</div>
<Select
value={statusFilter}
onValueChange={(value) => setStatusFilter(value as ReportStructureStatusFilter)}
>
<SelectTrigger className="h-8 w-28 shrink-0 text-xs" aria-label="启用状态筛选">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="enabled">已启用</SelectItem>
<SelectItem value="disabled">已停用</SelectItem>
</SelectContent>
</Select>
<div className="ml-auto flex shrink-0 items-center gap-2">
<Badge variant="outline" className="h-6 text-[10px]">{visible.length} 套</Badge>
<Button
size="sm"
className="h-8"
onClick={() => {
setEditorTarget(null);
setEditorOpen(true);
}}
>
<Plus className="mr-1 h-3.5 w-3.5" />新增结构
</Button>
</div>
</div>
</div>
) : (
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-4">
<div className="flex min-w-0 items-center gap-2">
<Badge variant="outline" className="shrink-0">报告结构</Badge>
<h2 className="truncate text-base font-semibold">{detail.title}</h2>
{detail.id === appliedStructureId && (
<Badge className="shrink-0">当前使用</Badge>
)}
{!detail.enabled && <Badge variant="secondary" className="shrink-0">已停用</Badge>}
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setDetailId(null)}>
<ChevronLeft className="mr-1 h-4 w-4" />返回
</Button>
</div>
)}
{/* body */}
{!detail ? (
<ScrollArea className="min-h-0 flex-1">
<div className="p-5">
{listQuery.isLoading ? (
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />加载中…
</div>
) : listQuery.isError ? (
<div className="py-16 text-center text-sm text-destructive">
{(listQuery.error as Error)?.message || "加载报告结构失败"}
</div>
) : visible.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-16 text-center text-sm text-muted-foreground">
<Workflow className="h-10 w-10 text-muted-foreground/30" />
{titleFilter || statusFilter !== "all" ? "没有符合条件的报告结构" : "暂无报告结构,点击右上角「新增结构」创建"}
</div>
) : (
<div className="grid gap-3 md:grid-cols-2">
{visible.map((structure) => (
<StructureCard
key={structure.id}
structure={structure}
applied={structure.id === appliedStructureId}
onOpen={() => handleSelect(structure)}
onEdit={(event) => {
event.stopPropagation();
setEditorTarget(structure);
setEditorOpen(true);
}}
onDelete={(event) => {
event.stopPropagation();
setDeleteTarget(structure);
}}
onToggleEnabled={(enabled) => handleToggleEnabled(structure, enabled)}
toggling={mutations.toggleEnabled.isPending}
/>
))}
</div>
)}
</div>
</ScrollArea>
) : (
<ScrollArea className="min-h-0 flex-1">
<div className="mx-auto max-w-3xl p-5">
{detail.name && (
<p className="mb-1 text-xs text-muted-foreground">描述:{detail.name}</p>
)}
<p className="mb-4 text-xs text-muted-foreground">
最后修改:{formatDateTime(detail.updatedAt)} · 创建人:{creatorLabel(detail)}
</p>
<article className="prose prose-sm dark:prose-invert max-w-none rounded-xl border border-border bg-card p-5">
<ReactMarkdown components={{ img: () => null }}>{detail.content}</ReactMarkdown>
</article>
</div>
</ScrollArea>
)}
</DialogContent>
</Dialog>
{/* 新增 / 编辑弹窗(嵌套) */}
<StructureEditorDialog
open={editorOpen}
onOpenChange={setEditorOpen}
target={editorTarget}
onSubmit={async (values) => {
try {
if (editorTarget) {
await mutations.update.mutateAsync({ id: editorTarget.id, patch: values });
toast.success("报告结构已保存");
} else {
await mutations.create.mutateAsync(values);
toast.success("已新增报告结构(默认启用)");
}
setEditorOpen(false);
setEditorTarget(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存失败");
}
}}
submitting={mutations.create.isPending || mutations.update.isPending}
/>
{/* 删除二次确认(嵌套) */}
<Dialog open={deleteTarget !== null} onOpenChange={(next) => !next && setDeleteTarget(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>删除报告结构</DialogTitle>
<DialogDescription>
确认删除「{deleteTarget?.title}」?删除后不可恢复。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={mutations.remove.isPending}>
取消
</Button>
<Button variant="destructive" onClick={() => void handleDelete()} disabled={mutations.remove.isPending}>
{mutations.remove.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
确认删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
// ── structure card ───────────────────────────────────────────────────────────
function StructureCard({
structure,
applied,
onOpen,
onEdit,
onDelete,
onToggleEnabled,
toggling,
}: {
structure: ReportStructure;
applied: boolean;
onOpen: () => void;
onEdit: (event: React.MouseEvent) => void;
onDelete: (event: React.MouseEvent) => void;
onToggleEnabled: (enabled: boolean) => void;
toggling: boolean;
}) {
return (
<div
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpen();
}
}}
className={`group cursor-pointer rounded-xl border p-4 text-left transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring ${
applied
? "border-primary/50 bg-primary/5"
: "border-border bg-card hover:border-primary/30 hover:bg-accent/40"
}`}
>
<div className="mb-1.5 flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="truncate text-sm font-semibold">{structure.title}</span>
{applied && <Badge className="h-5 shrink-0 text-[10px]">当前使用</Badge>}
<Badge variant={structure.enabled ? "default" : "outline"} className={`h-5 shrink-0 text-[10px] ${structure.enabled ? "" : "text-muted-foreground"}`}>
{structure.enabled ? "启用" : "停用"}
</Badge>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
title="编辑"
aria-label={`编辑 ${structure.title}`}
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={onEdit}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
title="删除"
aria-label={`删除 ${structure.title}`}
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
{structure.name && <p className="mb-2 truncate text-xs text-muted-foreground">{structure.name}</p>}
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-2.5">
<div className="min-w-0 text-[11px] leading-4 text-muted-foreground">
<p className="truncate">最后修改:{formatDateTime(structure.updatedAt)}</p>
<p className="truncate">创建人:{creatorLabel(structure)}</p>
</div>
<div
className="flex shrink-0 items-center gap-1.5"
onClick={(event) => event.stopPropagation()}
role="presentation"
>
<span className="text-[11px] text-muted-foreground">{structure.enabled ? "启用" : "停用"}</span>
<Switch
checked={structure.enabled}
disabled={toggling}
aria-label={`${structure.enabled ? "停用" : "启用"} ${structure.title}`}
onCheckedChange={onToggleEnabled}
/>
</div>
</div>
</div>
);
}
// ── editor dialog(新增 / 编辑共用;状态不允许填写,创建默认启用) ─────────────
function StructureEditorDialog({
open,
onOpenChange,
target,
onSubmit,
submitting,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
target: ReportStructure | null;
onSubmit: (values: { title: string; name: string; content: string }) => Promise<void>;
submitting: boolean;
}) {
const [title, setTitle] = useState("");
const [name, setName] = useState("");
const [content, setContent] = useState("");
useEffect(() => {
if (open) {
setTitle(target?.title ?? "");
setName(target?.name ?? "");
setContent(target?.content ?? "");
}
}, [open, target]);
const valid = title.trim().length > 0 && content.trim().length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] w-[calc(100vw-2rem)] max-w-2xl overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{target ? "编辑报告结构" : "新增报告结构"}</DialogTitle>
<DialogDescription>
{target ? "修改后将刷新「最后修改时间」。" : "新增的报告结构默认为启用状态。"}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<label className="block">
<span className="mb-1.5 block text-sm font-medium">标题</span>
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
maxLength={256}
placeholder="例如:行业研究通用结构"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">描述</span>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={256}
placeholder="可选,一句话描述这套结构的适用场景"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">接口内容</span>
<Textarea
value={content}
onChange={(event) => setContent(event.target.value)}
maxLength={20000}
placeholder={"报告结构正文,支持 Markdown 标题,例如:\n# 引言\n# 一、事实与背景\n## 1.1 研究范围"}
className="min-h-[260px] resize-y font-mono text-xs"
/>
<span className="mt-1 block text-[11px] text-muted-foreground">
选中卡片时,该内容会填充到高级配置的「报告结构」。
</span>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
取消
</Button>
<Button disabled={!valid || submitting} onClick={() => void onSubmit({ title: title.trim(), name: name.trim(), content: content })}>
{submitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── trigger button(自带弹窗开合状态) ────────────────────────────────────────
export function WorkflowConfigButton({
appliedContent,
onApply,
className,
variant = "outline",
size = "sm",
disabled = false,
}: {
appliedContent?: string;
onApply: (content: string) => void;
className?: string;
variant?: React.ComponentProps<typeof Button>["variant"];
size?: React.ComponentProps<typeof Button>["size"];
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
return (
<>
<Button variant={variant} size={size} className={className} disabled={disabled} onClick={() => setOpen(true)}>
<Workflow className="mr-1.5 h-3.5 w-3.5" />
工作流配置
</Button>
<WorkflowConfigDialog
open={open}
onOpenChange={setOpen}
appliedContent={appliedContent}
onApply={onApply}
/>
</>
);
}
@@ -20,6 +20,7 @@ import {
artifactPreviewKey,
useArtifacts,
} from "@/components/workspace/artifacts";
import { stripInlineThinkTags } from "@/core/streamdown/inline-think";
/** 报告在沙箱中的固定虚拟路径(与 runner 实际写入的 artifacts 路径一致)。 */
export const REPORT_OUTPUT_PATH = "/mnt/user-data/outputs/report.md";
@@ -72,7 +73,12 @@ export function useReportSandbox({ threadKey, report, writing }: UseReportSandbo
setStubMessage((prev) => {
const prevArgs = prev?.tool_calls?.[0]?.args as { content?: unknown } | undefined;
const prevContent = typeof prevArgs?.content === "string" ? prevArgs.content : "";
const content = report.trim() ? report : prevContent;
// Backstop the report sandbox against providers that expose reasoning
// through raw `<think>` tags in ordinary text chunks. The backend
// filters them at the source; this also protects historical/replayed
// streams generated by an older service.
const visibleReport = stripInlineThinkTags(report);
const content = visibleReport.trim() ? visibleReport : prevContent;
return {
id: messageId,
type: "ai",
@@ -6,6 +6,7 @@
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { createClientId } from "@/lib/client-id";
/**
* 节点状态由岗位会商服务推进;前端只据此决定可否发送、展示进度,
@@ -147,10 +148,7 @@ const BASE = () => `${getBackendBaseURL()}${PRIMARY_API_PATH}`;
* (不会重复 +revision / 重复解锁下游 / 重复驳回)。每次新的用户操作则取新键。
*/
function newCommandId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
return createClientId().replace(/-/g, "");
}
/**
@@ -4,8 +4,8 @@
* 页面无关:8BF 等独立会商页可以直接复用岗位会商的纵向流程图视觉与交互,
* 不会读取或写入岗位会商的会话、岗位节点状态。
*/
import { useRef, useState } from "react";
import { CheckCircle2, Circle, Search } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Check, Search } from "lucide-react";
import {
Dialog,
@@ -13,8 +13,14 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import {
positionNameById,
syncPositionOptionsFromRoles,
} from "@/position-roundtable/lib/positions";
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
import { usePositionRoles } from "@/strategy-components/api/position-roles";
const NODE_COLORS = ["#4f7df3", "#a778f5", "#65b4e8", "#f0a245", "#e675a8"];
const FLOW_NODE_WIDTH = 184;
@@ -29,6 +35,19 @@ export interface PositionBusinessChainFlowProps {
chain: BusinessChain | null;
loading?: boolean;
className?: string;
/**
* Render inside the Step 1 intent-delivery surface. The host owns the
* border and background; this flow keeps only its title row and canvas.
*/
embedded?: boolean;
/**
* Identity keys of seats whose Markdown artifact has been submitted.
* Matched against `seat.agent_id` and `seat.name` (case-insensitive).
* Green filled check = submitted only, same as 岗位会商 `is-delivered`.
*/
submittedSeatKeys?: readonly string[];
/** Click a seat node. Host opens the deliverable dialog when a submission exists. */
onSeatClick?: (seat: ChainSeat) => void;
}
function getFlowStages(chain: BusinessChain): FlowStage[] {
@@ -46,37 +65,70 @@ function getFlowStages(chain: BusinessChain): FlowStage[] {
return chain.seats.map((seat) => ({ goal: null, seats: [seat] }));
}
function isSeatSubmitted(seat: ChainSeat, submittedSeatKeys: ReadonlySet<string>): boolean {
return submittedSeatKeys.has(seat.agent_id.trim().toLowerCase())
|| submittedSeatKeys.has((seat.name ?? "").trim().toLowerCase());
}
export function PositionBusinessChainFlow({
chain,
loading = false,
className,
embedded = false,
submittedSeatKeys,
onSeatClick,
}: PositionBusinessChainFlowProps) {
const { data: positionRoles = [] } = usePositionRoles();
useEffect(() => {
syncPositionOptionsFromRoles(positionRoles);
}, [positionRoles]);
const [dialogOpen, setDialogOpen] = useState(false);
const submittedKeys = new Set(
(submittedSeatKeys ?? []).map((key) => key.trim().toLowerCase()).filter(Boolean),
);
const handleSeatClick = (seat: ChainSeat) => {
onSeatClick?.(seat);
setDialogOpen(false);
};
return (
<section
className={cn(
"position-business-chain-flow flex h-full min-h-[460px] w-full flex-col overflow-hidden rounded-2xl border border-[#ece7e4] bg-white p-3 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/55 dark:shadow-none",
embedded && "min-h-0 rounded-none border-0 bg-transparent p-0 shadow-none",
className,
)}
>
<div className="mb-3 flex shrink-0 items-center justify-between gap-2 border-b border-slate-200 pb-3 dark:border-[#2b8ffe]/40">
<div
className={cn(
"mb-3 flex shrink-0 items-center justify-between gap-2 border-b border-slate-200 pb-3 dark:border-[#2b8ffe]/40",
embedded && "relative mb-4 h-10 border-slate-200 pb-0 dark:border-[#2b8ffe]/60",
)}
>
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden="true"
className="h-6 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167deg,#f15959_0%,#cc1925_100%)] dark:bg-[linear-gradient(167deg,#a2e1f9_0%,#45c3f3_100%)]"
className={cn(
"h-6 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167deg,#f15959_0%,#cc1925_100%)] dark:bg-[linear-gradient(167deg,#a2e1f9_0%,#45c3f3_100%)]",
embedded && "h-6",
)}
/>
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-slate-900 dark:text-white">业务链条</h2>
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
{chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
</p>
<h2 className={cn("truncate text-base font-semibold text-slate-900 dark:text-white", embedded && "text-lg font-[550] dark:text-white")}>业务链条</h2>
{!embedded && (
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
{chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
</p>
)}
</div>
</div>
{chain && (
<button
type="button"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-[#f0c8c8] bg-[#fff7f7] text-[#cc1925] transition-colors hover:border-[#cc1925] hover:bg-[#fff0f0] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cc1925]/30 dark:border-[#2b8ffe]/45 dark:bg-[#082d5f] dark:text-[#8fc4ff]"
className={cn(
"flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-[#f0c8c8] bg-[#fff7f7] text-[#cc1925] transition-colors hover:border-[#cc1925] hover:bg-[#fff0f0] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cc1925]/30 dark:border-[#2b8ffe]/45 dark:bg-[#082d5f] dark:text-[#8fc4ff]",
embedded && "h-7 w-7 rounded-[4px]",
)}
title="查看完整流程图"
aria-label="查看完整流程图"
onClick={() => setDialogOpen(true)}
@@ -92,14 +144,26 @@ export function PositionBusinessChainFlow({
</div>
) : (
<>
<BusinessChainCanvas chain={chain} className="min-h-0 flex-1" />
<BusinessChainCanvas
chain={chain}
embedded={embedded}
submittedKeys={submittedKeys}
onSeatClick={onSeatClick ? handleSeatClick : undefined}
className="min-h-0 flex-1"
/>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="flex h-[86vh] w-[96vw] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
<DialogTitle className="text-base font-semibold">{chain.title} · 业务链条流程图</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 p-4">
<BusinessChainCanvas chain={chain} expanded className="h-full" />
<BusinessChainCanvas
chain={chain}
expanded
submittedKeys={submittedKeys}
onSeatClick={onSeatClick ? handleSeatClick : undefined}
className="h-full"
/>
</div>
</DialogContent>
</Dialog>
@@ -112,18 +176,24 @@ export function PositionBusinessChainFlow({
function BusinessChainCanvas({
chain,
expanded = false,
embedded = false,
submittedKeys,
onSeatClick,
className,
}: {
chain: BusinessChain;
expanded?: boolean;
embedded?: boolean;
submittedKeys: ReadonlySet<string>;
onSeatClick?: (seat: ChainSeat) => void;
className?: string;
}) {
const [view, setView] = useState({ tx: 0, ty: 0 });
const [panning, setPanning] = useState(false);
const dragRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
const stages = getFlowStages(chain);
const nodeWidth = expanded ? 250 : FLOW_NODE_WIDTH;
const nodeGap = expanded ? 18 : FLOW_NODE_GAP;
const nodeWidth = expanded ? 250 : embedded ? 158 : FLOW_NODE_WIDTH;
const nodeGap = expanded ? 18 : embedded ? 10 : FLOW_NODE_GAP;
let colorIndex = 0;
const endPan = (event: React.PointerEvent<HTMLDivElement>) => {
@@ -141,6 +211,7 @@ function BusinessChainCanvas({
<div
className={cn(
"relative overflow-hidden rounded-lg border border-slate-100 bg-slate-50/70 touch-none dark:border-[#2b8ffe]/25 dark:bg-[#032650]/75",
embedded && "rounded-[4px]",
panning ? "cursor-grabbing" : "cursor-grab",
className,
)}
@@ -152,6 +223,7 @@ function BusinessChainCanvas({
onDoubleClick={() => setView({ tx: 0, ty: 0 })}
onPointerDown={(event) => {
if (event.button !== 0) return;
if ((event.target as HTMLElement | null)?.closest?.(".pr-flow-card")) return;
dragRef.current = { x: event.clientX, y: event.clientY, tx: view.tx, ty: view.ty };
setPanning(true);
event.currentTarget.setPointerCapture(event.pointerId);
@@ -189,10 +261,13 @@ function BusinessChainCanvas({
return (
<FlowNode
key={seat.agent_id}
title={seat.name || seat.agent_id}
description={stage.goal?.trim() || seat.description?.trim() || "业务链席位"}
title={desensitize(seat.name || seat.agent_id)}
description={desensitize(positionNameById(seat.position_id))}
accent={accent}
width={nodeWidth}
compact={embedded}
completed={isSeatSubmitted(seat, submittedKeys)}
onClick={onSeatClick ? () => onSeatClick(seat) : undefined}
/>
);
})}
@@ -252,28 +327,36 @@ function FlowNode({
accent,
completed = false,
width,
compact = false,
onClick,
}: {
title: string;
description: string;
accent: string;
completed?: boolean;
width: number;
compact?: boolean;
onClick?: () => void;
}) {
const StatusIcon = completed ? CheckCircle2 : Circle;
return (
<article
className="relative overflow-hidden rounded-xl border border-slate-200 bg-white p-3 pr-10 shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0a2d58]"
style={{ width, borderLeftWidth: 5, borderLeftColor: accent }}
>
const className = cn(
"pr-flow-card relative overflow-hidden rounded-xl border border-slate-200 bg-white p-3 pr-10 text-left font-[inherit] shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0a2d58]",
compact && "rounded-lg",
completed && "border-[#36b987]/60",
onClick && "cursor-pointer transition hover:-translate-y-0.5 hover:shadow-md",
);
const style = { width, borderLeftWidth: 5, borderLeftColor: accent };
const body = (
<>
<span
className={cn(
"absolute right-2 top-2 flex size-6 items-center justify-center rounded-full border bg-white shadow-sm dark:bg-[#0d3768]",
"absolute right-2 top-2 grid size-5 place-items-center rounded-full",
completed
? "border-emerald-500 text-emerald-500"
: "border-slate-300 text-slate-400 dark:border-[#8aa0c4] dark:text-[#8aa0c4]",
? "border-0 bg-[#36b987] text-white"
: "border-2 border-[#78a7e8] bg-transparent text-white",
)}
title={completed ? "已完成" : "待提交"}
>
<StatusIcon className="size-3.5" strokeWidth={2.5} />
{completed ? <Check className="size-3" strokeWidth={3} /> : null}
</span>
<p className="truncate text-sm font-extrabold leading-5 text-slate-900 dark:text-[#e8edf5]" title={title}>
{title}
@@ -281,6 +364,25 @@ function FlowNode({
<p className="mt-0.5 truncate text-xs font-semibold leading-4" style={{ color: accent }} title={description}>
{description}
</p>
</>
);
if (onClick) {
return (
<button
type="button"
className={className}
style={style}
title={completed ? "已完成 · 点击查看产物" : "点击查看产物"}
onClick={onClick}
onPointerDown={(event) => event.stopPropagation()}
>
{body}
</button>
);
}
return (
<article className={className} style={style}>
{body}
</article>
);
}
@@ -8,7 +8,6 @@ import {
Sparkles,
X,
} from "lucide-react";
import { Select as TSelect } from "tdesign-react";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { normalizeAgentQuestions, type AgentQuestion } from "@/core/agents";
@@ -27,6 +26,8 @@ interface PositionIntentInputProps extends Step1InputAreaProps {
selectedModel?: string;
onSelectedModelChange?: (model: string) => void;
commonQuestions?: (string | AgentQuestion)[] | null;
/** 仅保留上传、模型选择和发送的紧凑输入区(用于会商第 3 步)。 */
hideUtilityActions?: boolean;
}
/** Keep attachment metadata presentation local to this position-only composer. */
@@ -67,6 +68,7 @@ export function PositionIntentInput({
selectedModel,
onSelectedModelChange,
commonQuestions,
hideUtilityActions = false,
}: PositionIntentInputProps) {
// Files are owned by Step1Panel. This ref only opens the native picker and
// forwards its selection through onAddFiles, so replacement layouts do not
@@ -76,6 +78,12 @@ export function PositionIntentInput({
const normalizedCommonQuestions = normalizeAgentQuestions(commonQuestions);
const visibleCommonQuestions = normalizedCommonQuestions.slice(0, 2);
const extraCommonQuestions = normalizedCommonQuestions.slice(2);
// 这里的模型列表会随着会商流的状态频繁刷新。TDesign Select 会在 options 的引用
// 每次变化时执行一次内部同步;流式收尾阶段恰好会形成 effect 更新循环并打断整页。
// 原生 select 是受控且无内部同步状态,三个步骤共用这个输入框后也能保持稳定。
const modelValue = modelOptions.some((option) => option.value === selectedModel)
? selectedModel
: (modelOptions[0]?.value ?? "");
const pickCommonQuestion = (question: AgentQuestion) => {
onValueChange(question.prompt);
@@ -242,16 +250,19 @@ export function PositionIntentInput({
title="鍒囨崲鎰忓浘鐞嗚В妯″瀷"
>
<Sparkles className="size-3.5 shrink-0 text-[#73c8ff]" />
<TSelect
size="small"
aria-label="鍒囨崲鎰忓浘鐞嗚В妯″瀷"
value={selectedModel}
options={modelOptions}
<select
aria-label="切换模型"
value={modelValue}
disabled={isSending}
onChange={(value) => onSelectedModelChange?.(String(value))}
className="w-[150px] shrink-0 [&_.t-input]:!h-7 [&_.t-input]:!rounded-[4px] [&_.t-input]:!border-0 [&_.t-input]:!bg-transparent [&_.t-input]:!px-1 [&_.t-input]:!text-xs [&_.t-input]:!font-bold [&_.t-input]:!text-[#dceaff] [&_.t-input]:!shadow-none"
popupProps={{ overlayClassName: "z-[80]" }}
/>
onChange={(event) => onSelectedModelChange?.(event.target.value)}
className="h-7 w-[150px] shrink-0 cursor-pointer appearance-none bg-transparent px-1 pr-5 text-xs font-bold text-[#dceaff] outline-none disabled:cursor-not-allowed disabled:opacity-60"
>
{modelOptions.map((option) => (
<option key={option.value} value={option.value} className="bg-[#0b3168] text-[#dceaff]">
{option.label}
</option>
))}
</select>
</div>
)}
<button
@@ -271,25 +282,29 @@ export function PositionIntentInput({
<span className="sr-only">{isSending ? "暂停生成" : "发送"}</span>
</button>
<button
id="suggest-followup-btn"
type="button"
onClick={onFollowUp}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
>
<Repeat className="size-3 text-slate-500" />
继续追问
</button>
{!hideUtilityActions && (
<>
<button
id="suggest-followup-btn"
type="button"
onClick={onFollowUp}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
>
<Repeat className="size-3 text-slate-500" />
继续追问
</button>
<button
id="skip-question-btn"
type="button"
onClick={onSkip}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
>
<X className="size-3 text-slate-500" strokeWidth={2.5} />
跳过此项
</button>
<button
id="skip-question-btn"
type="button"
onClick={onSkip}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
>
<X className="size-3 text-slate-500" strokeWidth={2.5} />
跳过此项
</button>
</>
)}
{footerAction ??
(!hideEnterRoundtable && (
@@ -309,15 +324,17 @@ export function PositionIntentInput({
{enterButtonLabel}
</button>
))}
<button
id="save-draft-btn"
type="button"
onClick={onManualSave}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
>
<Save className="size-3 text-slate-500" />
保存草稿
</button>
{!hideUtilityActions && (
<button
id="save-draft-btn"
type="button"
onClick={onManualSave}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
>
<Save className="size-3 text-slate-500" />
保存草稿
</button>
)}
</div>
</div>
</div>
@@ -88,3 +88,31 @@ export function resolvePositionId(
export const positionNameById = (positionId: string | null | undefined): string =>
runtimePositionOptions.find((position) => position.id === positionId)?.name ?? "未分配岗位";
/** Sync the flowchart/agent-config name lookup from the live 岗位角色 directory. */
export function syncPositionOptionsFromRoles(
roles: ReadonlyArray<{
id: string;
name: string;
description: string;
roleType?: string;
enabled?: boolean;
isBuiltin?: boolean;
sortOrder?: number;
}>,
): void {
if (!roles.length) return;
setRuntimePositionOptions(
[...roles]
.sort((left, right) => (left.sortOrder ?? 0) - (right.sortOrder ?? 0))
.map((role) => ({
id: role.id,
name: role.name,
description: role.description,
isIntentPosition: role.roleType === "intent",
enabled: role.enabled,
isBuiltin: role.isBuiltin,
sortOrder: role.sortOrder,
})),
);
}
@@ -48,6 +48,7 @@ import {
} from "tdesign-react";
import { toast } from "sonner";
import { AnalysisResultCardList, type AnalysisResultCardItem } from "@/components/analysis-results";
import { agentColor } from "@/components/workspace/agent-icon-color";
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
import { useAgentModuleSidebarData } from "@/components/workspace/agents/agent-module-sidebar";
@@ -91,14 +92,6 @@ import askQuestionIcon from "@/assets/position-roundtable/ask-question.svg";
import agentConfigLogo from "@/assets/position-roundtable/agent-config-logo.svg";
import doubleArrowDecoration from "@/assets/position-roundtable/double-arrow-decoration.svg";
import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg";
import analysisTag1 from "@/assets/position-roundtable/标签1.svg";
import analysisTag2 from "@/assets/position-roundtable/标签2.svg";
import analysisTag3 from "@/assets/position-roundtable/标签3.svg";
import analysisTag4 from "@/assets/position-roundtable/标签4.svg";
import analysisTag5 from "@/assets/position-roundtable/标签5.svg";
import analysisTag6 from "@/assets/position-roundtable/标签6.svg";
import responsibilityIcon from "@/assets/position-roundtable/责任岗位.svg";
import resultTimeIcon from "@/assets/position-roundtable/时间.svg";
import { deleteChain, listChains } from "@/roundtable-planning/api/chains";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
@@ -5961,15 +5954,6 @@ interface ResultItem {
artifactContent?: string | null;
}
const ANALYSIS_RESULT_TAG_ICONS = [
analysisTag1,
analysisTag2,
analysisTag3,
analysisTag4,
analysisTag5,
analysisTag6,
] as const;
function canOpenDeliverable(item: ResultItem): boolean {
return Boolean(
item.status === "done" &&
@@ -6407,6 +6391,25 @@ function AnalysisResults({
),
[kindFilter, nodeFilter, positionFilter, resultItems],
);
const visibleCardItems = useMemo<AnalysisResultCardItem[]>(
() =>
visibleItems.map((item) => ({
id: item.id,
agentName: item.agentName,
productName: item.productName,
positionName: resultPositionName(item.positionId),
updatedAt: item.updatedAt,
fallbackTime: intentCompletedAt || "刚刚",
delivered: canOpenDeliverable(item),
artifactPath: item.artifactPath,
artifactMimeType: item.artifactMimeType,
artifactThreadId: item.artifactThreadId,
artifactContent: item.artifactContent,
summary: item.summary,
unavailableMessage: deliverableUnavailableMessage(item),
})),
[intentCompletedAt, visibleItems],
);
const openDetail = useCallback(
(item: ResultItem) => {
@@ -6462,13 +6465,12 @@ function AnalysisResults({
if (resultItems.length === 0) {
return (
<div className="position-roundtable-analysis-results-empty flex h-full min-h-[260px] flex-col items-center justify-center p-6 text-center">
<FileOutput className="mb-3 size-8 text-muted-foreground/50" />
<p className="text-sm font-semibold">暂无交付产物</p>
<p className="mt-1 max-w-[240px] text-xs leading-5 text-muted-foreground">
情报分析岗启动业务链后,各岗位待交付产物会在这里排队展示。
</p>
</div>
<AnalysisResultCardList
items={[]}
emptyTitle="暂无交付产物"
emptyDescription="情报分析岗启动业务链后,各岗位待交付产物会在这里排队展示。"
onOpen={() => {}}
/>
);
}
@@ -6547,67 +6549,14 @@ function AnalysisResults({
当前筛选条件下没有结果
</div>
) : (
visibleItems.map((item, index) => {
const displayedTime = item.updatedAt
? new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(item.updatedAt))
: intentCompletedAt || "刚刚";
const hasDeliveredArtifact = Boolean(item.artifactPath || item.artifactContent?.trim());
const displayedProductName =
hasDeliveredArtifact || (item.kind !== "delivery" && item.status === "done")
? item.productName
: "未交付";
const contents = (
<div className="position-roundtable-analysis-result-card-body">
<div className="position-roundtable-analysis-result-tag" aria-label={desensitize(item.agentName)}>
<img
src={ANALYSIS_RESULT_TAG_ICONS[index % ANALYSIS_RESULT_TAG_ICONS.length]}
alt=""
aria-hidden="true"
/>
<span>{desensitize(item.agentName)}</span>
</div>
<p className="position-roundtable-analysis-result-product">
{desensitize(displayedProductName)}
</p>
<footer className="position-roundtable-analysis-result-meta">
<span>
<img src={responsibilityIcon} alt="" aria-hidden="true" />
{resultPositionName(item.positionId)}
</span>
<i aria-hidden="true" />
<span>
<img src={resultTimeIcon} alt="" aria-hidden="true" />
{displayedTime}
</span>
</footer>
</div>
);
return item.node ? (
<button
key={item.id}
type="button"
onClick={() => openDetail(item)}
className="position-roundtable-analysis-result-card"
>
{contents}
</button>
) : (
<button
key={item.id}
type="button"
onClick={() => openDetail(item)}
className="position-roundtable-analysis-result-card"
>
{contents}
</button>
);
})
<AnalysisResultCardList
items={visibleCardItems}
padded={false}
onOpen={(card) => {
const original = resultItems.find((item) => item.id === card.id);
if (original) openDetail(original);
}}
/>
)}
</div>
@@ -1108,8 +1108,10 @@
}
.position-roundtable-intent-analysis-results-scroll {
min-width: 0;
min-height: 0;
flex: 1 1 auto;
overflow-x: hidden;
overflow-y: auto;
}
@@ -1147,26 +1149,32 @@
top: -40px;
left: -12px;
display: inline-flex;
width: max-content;
min-width: 95px;
max-width: calc(100% - 12px);
width: fit-content;
max-width: calc(100% + 12px);
height: 26px;
align-items: center;
overflow: hidden;
box-sizing: border-box;
padding: 0 22px 0 12px;
overflow: hidden;
border-radius: 4px 0 0 0;
background: rgba(212, 120, 0, 0.5);
color: #ffffff;
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
font-size: 16px;
line-height: normal;
white-space: nowrap;
clip-path: polygon(0 0, 100% 0, calc(100% - 14px) 100%, 0 100%);
}
.position-roundtable-analysis-result-tag--1 { background: rgba(212, 120, 0, 0.5); }
.position-roundtable-analysis-result-tag--2 { background: rgba(207, 19, 34, 0.5); }
.position-roundtable-analysis-result-tag--3 { background: rgba(29, 171, 123, 0.5); }
.position-roundtable-analysis-result-tag--4 { background: rgba(132, 85, 230, 0.5); }
.position-roundtable-analysis-result-tag--5 { background: rgba(201, 181, 0, 0.5); }
.position-roundtable-analysis-result-tag--6 { background: rgba(69, 195, 243, 0.5); }
.position-roundtable-analysis-result-tag img {
position: absolute;
inset: 0 auto auto 0;
z-index: 0;
width: 100%;
height: 26px;
display: none;
}
.position-roundtable-analysis-result-tag span {
@@ -1176,7 +1184,7 @@
min-width: 0;
max-width: 100%;
overflow: hidden;
padding: 0 12px;
padding: 0;
text-overflow: ellipsis;
}
@@ -0,0 +1,74 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export interface RoundtableArtifactReference {
sessionId?: string | null;
threadId: string;
nodeKey?: string | null;
sourceKey: string;
path: string;
name: string;
mimeType?: string | null;
revision: number;
}
export interface RoundtableArtifactSubmissionRecord {
id: string;
taskId: string;
version: number;
submitted: boolean;
submittedAt?: string | null;
cancelledAt?: string | null;
taskDeliveryId?: string | null;
artifact: RoundtableArtifactReference;
}
function endpoint(taskId: string): string {
return `${getBackendBaseURL()}/api/roundtable/tasks/${encodeURIComponent(taskId)}/artifacts`;
}
async function readError(response: Response, fallback: string): Promise<Error> {
const body = await response.text().catch(() => "");
try {
const parsed = JSON.parse(body) as { detail?: unknown; message?: unknown };
const detail = typeof parsed.detail === "string"
? parsed.detail
: typeof parsed.message === "string"
? parsed.message
: "";
if (detail) return new Error(detail);
} catch {
// The status fallback below is more useful than an opaque HTML response.
}
return new Error(`${fallback}(${response.status})`);
}
export async function listRoundtableArtifactSubmissions(
taskId: string,
submitted: boolean,
): Promise<RoundtableArtifactSubmissionRecord[]> {
const response = await apiFetch(`${endpoint(taskId)}?submitted=${submitted}`, { method: "GET" });
if (!response.ok) throw await readError(response, "读取产物提交状态失败");
const payload = await response.json() as { items?: RoundtableArtifactSubmissionRecord[] };
return Array.isArray(payload.items) ? payload.items : [];
}
export async function saveRoundtableArtifactSubmission(
taskId: string,
body: {
submitted: boolean;
registerOnly?: boolean;
submissionId?: string;
taskDeliveryId?: string;
expectedVersion?: number;
artifact: RoundtableArtifactReference;
},
): Promise<RoundtableArtifactSubmissionRecord> {
const response = await apiFetch(`${endpoint(taskId)}/submission`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskId, ...body }),
});
if (!response.ok) throw await readError(response, "保存产物提交状态失败");
return response.json() as Promise<RoundtableArtifactSubmissionRecord>;
}
@@ -53,6 +53,8 @@ export interface ChainUpdatePayload {
gatherFirst?: boolean;
/** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */
isPublic?: boolean;
/** 启停开关:true 启用 / false 停用;omit 不改。仅 owner / 管理员生效。 */
enabled?: boolean;
}
/**
@@ -88,6 +90,7 @@ interface ServerChain {
seat_mode?: string | null;
gather_first?: boolean;
is_public?: boolean;
enabled?: boolean;
is_mine?: boolean;
owner_name?: string | null;
created_at: string | null;
@@ -106,6 +109,7 @@ function toChain(s: ServerChain): BusinessChain {
seatMode: (s.seat_mode as SeatMode | null) ?? undefined,
gatherFirst: s.gather_first ?? false,
isPublic: s.is_public ?? false,
enabled: s.enabled ?? true,
// 后端缺省时按「我的」处理(向后兼容旧响应)。
isMine: s.is_mine ?? true,
ownerName: s.owner_name ?? undefined,
@@ -141,6 +145,7 @@ function fromUpdate(p: ChainUpdatePayload) {
if (p.seatMode !== undefined) out.seat_mode = p.seatMode;
if (p.gatherFirst !== undefined) out.gather_first = p.gatherFirst;
if (p.isPublic !== undefined) out.is_public = p.isPublic;
if (p.enabled !== undefined) out.enabled = p.enabled;
return out;
}
@@ -171,6 +176,11 @@ export async function setChainPublic(id: string, isPublic: boolean): Promise<Bus
return updateChain(id, { isPublic });
}
/** 启用 / 停用一条链条(仅 owner / 管理员生效)。返回更新后的链条,404 返回 null。 */
export async function setChainEnabled(id: string, enabled: boolean): Promise<BusinessChain | null> {
return updateChain(id, { enabled });
}
/** Fetch one chain. Returns null on 404. */
export async function getChain(id: string): Promise<BusinessChain | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
@@ -214,10 +224,22 @@ export async function updateChain(id: string, patch: ChainUpdatePayload): Promis
if (res.status === 404) return null;
const data = await readJson<ServerChain>(res, "update chain");
const chain = toChain(data);
const enabledToggle = patch.enabled === true || patch.enabled === false;
writeYLog({
optType: patch.isPublic === true ? OptType.enabled : patch.isPublic === false ? OptType.disabled : OptType.edit,
optDetail:
patch.isPublic === true
optType: enabledToggle
? patch.enabled
? OptType.enabled
: OptType.disabled
: patch.isPublic === true
? OptType.enabled
: patch.isPublic === false
? OptType.disabled
: OptType.edit,
optDetail: enabledToggle
? patch.enabled
? "CM助手-全量-业务链条-启用"
: "CM助手-全量-业务链条-停用"
: patch.isPublic === true
? "CM助手-全量-业务链条-发布"
: patch.isPublic === false
? "CM助手-全量-业务链条-取消发布"
@@ -459,7 +459,14 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
// Clarifications collected live from `type:"tool"` frames, deduped by
// tool_call_id, then reconciled against the complete terminal list.
const seenClarificationIds = new Set<string>();
// A clarification tool call is a hard pause in the Step 1 conversation.
// Some model providers keep emitting text (and can even produce an
// INTENT_READY terminal frame) after the tool result. That text belongs to
// a continuation the user has not approved yet, so never let it replace the
// assistance card in the current turn.
let awaitingUserClarification = false;
const collectClarification = (payload: ClarificationPayload, toolCallId: string) => {
awaitingUserClarification = true;
const dedupeKey = toolCallId || `idx-${result.clarifications.length}`;
if (seenClarificationIds.has(dedupeKey)) return;
seenClarificationIds.add(dedupeKey);
@@ -501,6 +508,25 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
}
if (isFinalIntentFrame(parsed)) {
// Reconcile any terminal clarification cards, but preserve the first
// tool-call pause as the authoritative state for this turn. In
// particular, do not accept a later `done` frame / summary before the
// user has answered the displayed questions.
if (awaitingUserClarification) {
if (parsed.status === "clarification") {
const terminalCards = Array.isArray(parsed.clarifications)
? parsed.clarifications
.map(parseTerminalClarification)
.filter((item): item is { payload: ClarificationPayload; toolCallId: string } => item !== null)
: [];
if (terminalCards.length > 0) {
terminalCards.forEach((card, index) => {
collectClarification(card.payload, card.toolCallId || `terminal-${index}`);
});
}
}
continue;
}
result.status = parsed.status;
if (typeof parsed.content === "string") result.content = parsed.content;
if (parsed.summary && typeof parsed.summary === "object") {
@@ -548,6 +574,11 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
continue;
}
// Do not show follow-up thinking / generated summary text while the
// user-assistance card is waiting for an answer. This also prevents a
// later AI delta from hiding the card through an [INTENT_READY] block.
if (awaitingUserClarification) continue;
// Phase inference from LangGraph messages-tuple frames. Tool calls
// (typically ask_clarification) flip phase to tool_calling; AI text
// chunks flip to streaming. Everything else leaves phase alone.
@@ -592,5 +623,11 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
}
}
if (awaitingUserClarification && result.clarifications.length > 0) {
result.status = "clarification";
result.summary = null;
result.clarification = result.clarifications[0] ?? null;
}
return result;
}
@@ -584,18 +584,55 @@ export async function streamMultiAgent(
// Inferred SSE phase + a tiny helper so we only fire onPhaseChange when the
// value actually changes (the reader loop sees many frames per phase).
//
// `write_file` 的 `content`、`str_replace` 的 old_str/new_str 会在每个
// tool_call chunk 中持续增长。它们属于实时预览数据,已经由 onUpstreamFrame
// 写入 stub;若用它们判断 phase 变化,会在单次工具调用里反复触发 React 状态
// 更新,严重时会形成 Maximum update depth loop。因此这里只比较工具的身份入参。
const phaseArgsSignature = (args?: Record<string, unknown>) => {
if (!args) return "";
const streamedPayloadKeys = new Set([
"content",
"old_str",
"new_str",
"patch",
"diff",
]);
try {
return JSON.stringify(
Object.fromEntries(
Object.entries(args)
.filter(([key]) => !streamedPayloadKeys.has(key))
.sort(([left], [right]) => left.localeCompare(right)),
),
);
} catch {
return "[unserializable]";
}
};
let currentPhase: StreamPhase = "connecting";
let currentPhaseName = "";
let currentPhaseArgsSignature = "";
req.onPhaseChange?.("connecting");
const setPhase = (
next: StreamPhase,
name?: string,
args?: Record<string, unknown>,
) => {
// 同一个 phase + 同一个 toolName 才认为是"重复",此时不再回调上层;
// 但 args 单独是新的(如同一工具被多次 call 携带不同入参)时也不回调 —— 上层
// appendStepIfNew 用 `phase:toolName` 做 key 去重,本来也不会因 args 变化产生新步骤。
if (next === currentPhase && !name) return;
const nextName = name ?? "";
const nextArgsSignature = phaseArgsSignature(args);
// 同 phase、同工具、同身份入参的帧只是流式重复帧,不通知 UI。保留 path/filepaths
// 等身份变化,确保工具参数从不完整变完整时仍可自动打开沙箱。
if (
next === currentPhase &&
nextName === currentPhaseName &&
nextArgsSignature === currentPhaseArgsSignature
) {
return;
}
currentPhase = next;
currentPhaseName = nextName;
currentPhaseArgsSignature = nextArgsSignature;
req.onPhaseChange?.(next, name, args);
};
@@ -9,9 +9,10 @@
* 卡片席位名优先用实时 agent 数据(`listAgents()`,与 `BusinessChainPicker` 一致),
* agent 改名后此弹窗也能同步显示;解析不到才回退到落库快照(`seat.name`)。
*/
import { Fragment, useCallback, useEffect, useState } from "react";
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
import {
ArrowRightIcon,
ClockIcon,
GlobeIcon,
LayersIcon,
Loader2Icon,
@@ -28,14 +29,15 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { getStoredAuth } from "@/core/auth";
import { listAgents } from "@/core/agents";
import { listChains, setChainPublic, type ChainScope } from "../api/chains";
import { chainTagClasses, type BusinessChain } from "../lib/business-chain";
import { listChains, setChainEnabled, setChainPublic, type ChainScope } from "../api/chains";
import { chainTagClasses, isChainEnabled, type BusinessChain } from "../lib/business-chain";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
export interface BusinessChainListDialogProps {
@@ -57,6 +59,8 @@ export interface BusinessChainListBodyProps {
emptyHint?: string;
}
export type EnabledFilter = "all" | "enabled" | "disabled";
/**
* 业务链条列表主体(筛选 tab + 滚动卡片列表)。
*
@@ -76,7 +80,10 @@ export function BusinessChainListBody({
const isAdmin = getStoredAuth()?.system_role === "admin";
// 筛选:我的 / 公共(已发布)/(管理员)全部。
const [scope, setScope] = useState<ChainScope>("mine");
// 启停筛选:全部 / 启用 / 停用。默认全部,关掉开关后卡片仍留在当前列表。
const [enabledFilter, setEnabledFilter] = useState<EnabledFilter>("all");
const [publishingId, setPublishingId] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null);
// 实时名称表(id 小写 → 最新 name):用于把席位的落库快照名刷新成最新(agent 改名同步)。
const [nameById, setNameById] = useState<Map<string, string>>(new Map());
@@ -137,8 +144,12 @@ export function BusinessChainListBody({
async (chain: BusinessChain) => {
setPublishingId(chain.id);
try {
await setChainPublic(chain.id, !chain.isPublic);
await refresh(scope);
const updated = await setChainPublic(chain.id, !chain.isPublic);
if (updated) {
setChains((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
} else {
await refresh(scope);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -148,25 +159,69 @@ export function BusinessChainListBody({
[refresh, scope],
);
const handleToggleEnabled = useCallback(
async (chain: BusinessChain) => {
setTogglingId(chain.id);
try {
const updated = await setChainEnabled(chain.id, !isChainEnabled(chain));
if (updated) {
setChains((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
} else {
await refresh(scope);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setTogglingId(null);
}
},
[refresh, scope],
);
const visibleChains = useMemo(() => {
if (enabledFilter === "enabled") return chains.filter((c) => isChainEnabled(c));
if (enabledFilter === "disabled") return chains.filter((c) => !isChainEnabled(c));
return chains;
}, [chains, enabledFilter]);
return (
<>
{/* 筛选:我的 / 公共 /(管理员)全部 */}
<div className="shrink-0 flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
{((isAdmin ? ["mine", "public", "all"] : ["mine", "public"]) as ChainScope[]).map((s) => (
<button
key={s}
type="button"
onClick={() => setScope(s)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition",
scope === s
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{s === "mine" ? "我的" : s === "public" ? "公共" : "全部"}
</button>
))}
{/* 筛选:我的 / 公共 /(管理员)全部 + 全部 / 启用 / 停用 */}
<div className="flex shrink-0 flex-wrap items-center gap-2">
<div className="flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
{((isAdmin ? ["mine", "public", "all"] : ["mine", "public"]) as ChainScope[]).map((s) => (
<button
key={s}
type="button"
onClick={() => setScope(s)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition",
scope === s
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{s === "mine" ? "我的" : s === "public" ? "公共" : "全部"}
</button>
))}
</div>
<div className="flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
{(["all", "enabled", "disabled"] as EnabledFilter[]).map((f) => (
<button
key={f}
type="button"
onClick={() => setEnabledFilter(f)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition",
enabledFilter === f
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{f === "all" ? "全部状态" : f === "enabled" ? "启用" : "停用"}
</button>
))}
</div>
</div>
<div className="custom-scrollbar -mr-1 min-h-0 flex-1 overflow-y-auto py-3 pr-1">
@@ -179,30 +234,40 @@ export function BusinessChainListBody({
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
加载业务链条失败:{error}
</div>
) : chains.length === 0 ? (
) : visibleChains.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
<LayersIcon className="size-10 text-primary/40" />
<div className="text-sm font-semibold">
{scope === "public" ? "还没有公共链条" : "还没有业务链条"}
{enabledFilter !== "all"
? enabledFilter === "enabled"
? "没有启用的业务链条"
: "没有停用的业务链条"
: scope === "public"
? "还没有公共链条"
: "还没有业务链条"}
</div>
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
{scope === "public"
? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的」里发布一条试试。"
: emptyHint ??
"业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。"}
{enabledFilter !== "all"
? "试试切换「全部状态」,或到卡片上用开关切换启停。"
: scope === "public"
? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的」里发布一条试试。"
: emptyHint ??
"业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。"}
</p>
</div>
) : (
<div className="space-y-3.5">
{chains.map((chain) => (
{visibleChains.map((chain) => (
<ChainRow
key={chain.id}
chain={chain}
canManage={isAdmin || chain.isMine !== false}
nameById={nameById}
publishing={publishingId === chain.id}
toggling={togglingId === chain.id}
onSettings={() => onConfigure(chain.id)}
onTogglePublic={() => handleTogglePublic(chain)}
onToggleEnabled={() => handleToggleEnabled(chain)}
/>
))}
</div>
@@ -252,22 +317,33 @@ function ChainRow({
canManage,
nameById,
publishing,
toggling,
onSettings,
onTogglePublic,
onToggleEnabled,
}: {
chain: BusinessChain;
/** 能否编辑 / 发布(本人链条 或 管理员)。 */
/** 能否编辑 / 发布 / 启停(本人链条 或 管理员)。 */
canManage: boolean;
nameById: Map<string, string>;
publishing: boolean;
toggling: boolean;
onSettings: () => void;
onTogglePublic: () => void;
onToggleEnabled: () => void;
}) {
// 配置者用户名:后端返回 email,展示时取 @ 前的本地部分更像「用户名」。
const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "";
const enabled = isChainEnabled(chain);
const updatedLabel = formatUpdatedAt(chain.updatedAt);
return (
<div className="rounded-2xl border border-border bg-card p-5 transition-colors hover:border-primary/40">
{/* 标题行 + 公共徽标 + 发布/设置按钮 */}
<div
className={cn(
"rounded-2xl border border-border bg-card p-5 transition-colors hover:border-primary/40",
!enabled && "opacity-70",
)}
>
{/* 标题行 + 公共/停用徽标 + 启停开关 / 发布 / 设置按钮 */}
<div className="flex items-center gap-2.5">
<div className="truncate text-base font-semibold">{chain.title || "未命名链条"}</div>
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
@@ -279,7 +355,26 @@ function ChainRow({
公共
</span>
)}
{!enabled && (
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
已停用
</span>
)}
<div className="ml-auto flex shrink-0 items-center gap-1.5">
{/* 启停:本人的链条或管理员可操作。 */}
{canManage && (
<label className="flex items-center gap-1.5 pr-1 text-xs text-muted-foreground">
<Switch
checked={enabled}
disabled={toggling}
onCheckedChange={() => onToggleEnabled()}
aria-label={enabled ? "停用该业务链条" : "启用该业务链条"}
/>
<span className={cn("min-w-[2em]", enabled ? "text-foreground" : "text-muted-foreground")}>
{enabled ? "启用" : "停用"}
</span>
</label>
)}
{/* 发布 / 取消发布:本人的链条或管理员可操作。 */}
{canManage && (
<Button
@@ -312,13 +407,20 @@ function ChainRow({
</div>
</div>
{ownerName && (
<div
className="mt-1.5 flex items-center gap-1 text-xs text-muted-foreground"
title={chain.ownerName}
>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">配置人:{ownerName}</span>
{(ownerName || updatedLabel) && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{ownerName && (
<div className="flex items-center gap-1" title={chain.ownerName}>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">配置人:{ownerName}</span>
</div>
)}
{updatedLabel && (
<div className="flex items-center gap-1" title={chain.updatedAt}>
<ClockIcon className="size-3.5 shrink-0" />
<span>最后修改:{updatedLabel}</span>
</div>
)}
</div>
)}
@@ -401,3 +503,21 @@ function ChainSeatsFlow({
</div>
);
}
/** 后端 `updated_at` 形如 `2026-08-19T17:50:00+08:00`;展示到分钟。 */
function formatUpdatedAt(iso: string | undefined): string {
if (!iso) return "";
const date = new Date(iso);
if (!Number.isNaN(date.getTime())) {
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
const s = iso.replace("T", " ");
return s.length >= 16 ? s.slice(0, 16) : s;
}
@@ -34,6 +34,7 @@ import { listChains, type ChainScope } from "../api/chains";
import {
CHAIN_MIN_SEATS,
chainTagClasses,
isChainEnabled,
type BusinessChain,
type SeatSelectionResult,
} from "../lib/business-chain";
@@ -77,7 +78,7 @@ export function BusinessChainPicker({
listAgents().catch(() => null), // agent 列表失败不阻断链条展示
]);
if (cancelled) return;
setChains(chainList);
setChains(chainList.filter(isChainEnabled));
if (agents) {
const candidates = filterRecommendCandidates(agents);
setExistingIds(new Set(candidates.map((c) => c.agent_id)));
@@ -32,6 +32,7 @@ import {
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton";
import { desensitize } from "@/lib/desensitize";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import {
@@ -46,6 +47,7 @@ import type {
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
import type { ReportIntent, UseStep3ReportReturn } from "@/roundtable-planning/hooks/useStep3Report";
import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
interface SeatDelivery {
/** Display name of the seat (e.g. "情报收集"). */
@@ -57,6 +59,8 @@ interface SeatDelivery {
}
interface HighFidelityReportProps {
useStep1Chrome?: boolean;
headerTitle?: string;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
/** The most recent leader-agent text — rendered as the final consensus. */
@@ -93,6 +97,7 @@ interface HighFidelityReportProps {
onReset: () => void;
openArtifactPreview: (threadId: string, path: string) => void;
seatStubMessages: Record<string, unknown[]>;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}
interface SeatArtifacts {
@@ -117,6 +122,8 @@ function formatGeneratedAt(d: Date): string {
}
export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
useStep1Chrome = false,
headerTitle,
threadIds,
coordinatorName,
consensusText,
@@ -139,6 +146,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
onReset,
openArtifactPreview,
seatStubMessages,
taskArtifactSubmission,
}) => {
// 「结果绘制」模式:把现有报告内容收起到左栏,右侧由方案可视化智能体绘制图表 HTML。
const consensusDocTitle = useMemo(() => {
@@ -148,8 +156,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
return `圆桌总控结论-${short}`;
}, [intentReady?.objective]);
// 第 3 步**主页** = 总结报告(进第三步默认自动跑「方案总结报告智能体」)。
// 用户点「最终方案展示」切到静态页(summaryMode=false),再从静态页可进「结果绘制」HTML。
// 第 3 步只保留「总结报告」工作台;旧的最终方案展示 / HTML 绘制入口不再开放。
const [summaryMode, setSummaryMode] = useState(true);
const [drawMode, setDrawMode] = useState(false);
// 文件沙箱(2026-06-13 改内联):点文件卡片在右侧滑入沙箱面板、左侧席位栏收起
@@ -430,6 +437,8 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
if (summaryMode) {
return (
<Step3SummaryMode
useStep1Chrome={useStep1Chrome}
headerTitle={headerTitle}
threadIds={threadIds}
coordinatorName={coordinatorName}
consensusText={consensusText}
@@ -439,12 +448,11 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
intentReady={intentReady}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
coordinatorPrompt={coordinatorPrompt}
savedSummary={savedSummary}
summary={summaryFlow}
onViewStatic={() => setSummaryMode(false)}
taskArtifactSubmission={taskArtifactSubmission}
/>
);
}
@@ -467,6 +475,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
report={reportFlow}
dashboard={dashboardFlow}
onBack={() => setDrawMode(false)}
taskArtifactSubmission={taskArtifactSubmission}
/>
);
}
@@ -917,6 +926,27 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
</div>
<div className="flex items-center gap-1 shrink-0">
<Eye className="w-3.5 h-3.5 text-slate-300 transition group-hover:text-primary dark:text-muted-foreground" />
{seat.threadId && (
<TaskArtifactSubmitButton
compact
controller={taskArtifactSubmission}
target={taskArtifactSubmission?.targetFor({
threadId: seat.threadId,
path: f.path,
name: f.name,
mimeType: f.mimeType,
agentId: seat.key,
agentName: seat.displayName,
}) ?? {
threadId: seat.threadId,
path: f.path,
name: f.name,
mimeType: f.mimeType,
agentId: seat.key,
agentName: seat.displayName,
}}
/>
)}
<button
type="button"
onClick={(e) => {
@@ -1011,6 +1041,24 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
threadId={fileSandboxTarget.threadId}
onClose={handleCloseFileSandbox}
allowVirtualMarkdownEdit
headerLeadingActionBare
headerLeadingAction={(
<TaskArtifactSubmitButton
compact
controller={taskArtifactSubmission}
target={taskArtifactSubmission?.targetFor({
threadId: fileSandboxTarget.threadId,
path: fileSandboxTarget.file.path,
name: fileSandboxTarget.file.name,
mimeType: fileSandboxTarget.file.mimeType,
}) ?? {
threadId: fileSandboxTarget.threadId,
path: fileSandboxTarget.file.path,
name: fileSandboxTarget.file.name,
mimeType: fileSandboxTarget.file.mimeType,
}}
/>
)}
/>
</ThreadContext.Provider>
)}
@@ -16,7 +16,7 @@
*
* 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
Cpu,
FileIcon,
@@ -24,8 +24,6 @@ import {
Loader2,
Network,
Paperclip,
Pause,
Play,
Sparkles,
X,
} from "lucide-react";
@@ -41,6 +39,7 @@ function formatFileSize(bytes: number): string {
const SHOW_BACKGROUND_SUSPEND = true;
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
@@ -49,6 +48,7 @@ import { DialogueSpeedMeter } from "@/roundtable-planning/components/DialogueSpe
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton";
import { getAvatar } from "@/roundtable-planning/components/Avatars";
import {
MarkdownContent,
@@ -86,6 +86,7 @@ import { desensitize } from "@/lib/desensitize";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
interface ModelOption {
name: string;
@@ -93,10 +94,15 @@ interface ModelOption {
}
export interface Step2PanelProps {
/** 独立业务页需要与第一步完全相同的输入组件(8BF 专用,默认不改变原会商)。 */
useStep1InputAppearance?: boolean;
/** 8BF 第二步对话区标题,与第一步「业务链 · 意图识别」同一套顶栏。 */
headerTitle?: string;
/** 8BF 第二步顶栏右侧附加操作(如返回)。 */
headerRightSlot?: ReactNode;
// ─── State ────────────────────────────────────────────────────
step2RoundtableDialogues: Step2Dialogue[];
isInitializing: boolean;
isRoundTableRunning: boolean;
isOrchestrating: boolean;
pendingClarification: ClarificationPayload | null;
clarificationDraft: string;
@@ -147,7 +153,6 @@ export interface Step2PanelProps {
setSelectedAgentId: React.Dispatch<React.SetStateAction<number | null>>;
// ─── Handlers ─────────────────────────────────────────────────
onPauseRoundtable: () => void;
onResumeRoundtable: () => void;
onHandleInjectToRoundtable: (files?: File[]) => void;
onSubmitClarification: (answer: string) => void;
onSavePersona: (id: number) => void;
@@ -157,13 +162,17 @@ export interface Step2PanelProps {
/** 是否在「查看历史记录」(从草稿/后台作业加载)—— 为 true 时隐藏「后台挂起」按钮。 */
isViewingHistory?: boolean;
onTriggerToast: (msg: string) => void;
/** 任务深链页面注入的外部过程产物提交控制器。 */
taskArtifactSubmission?: TaskArtifactSubmissionController;
}
export function Step2Panel(props: Step2PanelProps) {
const {
useStep1InputAppearance = false,
headerTitle,
headerRightSlot,
step2RoundtableDialogues,
isInitializing,
isRoundTableRunning,
isOrchestrating,
pendingClarification,
clarificationDraft,
@@ -195,7 +204,6 @@ export function Step2Panel(props: Step2PanelProps) {
setAgentConfigs,
setSelectedAgentId,
onPauseRoundtable,
onResumeRoundtable,
onHandleInjectToRoundtable,
onSubmitClarification,
onSavePersona,
@@ -203,6 +211,7 @@ export function Step2Panel(props: Step2PanelProps) {
onBackgroundSuspend,
isViewingHistory,
onTriggerToast,
taskArtifactSubmission,
} = props;
const step2ScrollRef = useRef<HTMLDivElement>(null);
@@ -372,23 +381,17 @@ export function Step2Panel(props: Step2PanelProps) {
prevArtifactsOpenRef.current = artifactsOpenForFlow;
}, [artifactsOpenForFlow]);
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
const [pauseFlash, setPauseFlash] = useState(false);
// 干预消息携带的本地附件(点「干预并发送」时连同指令一起上传)。
const [injectFiles, setInjectFiles] = useState<File[]>([]);
const injectFileInputRef = useRef<HTMLInputElement>(null);
const handlePickInjectFiles = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(e.target.files ?? []);
const addInjectFiles = useCallback(
(picked: File[]) => {
if (picked.length > 0) {
setInjectFiles((prev) => {
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
});
}
e.target.value = "";
},
[],
);
@@ -400,13 +403,14 @@ export function Step2Panel(props: Step2PanelProps) {
const handleInject = useCallback(() => {
onHandleInjectToRoundtable(injectFiles.length > 0 ? injectFiles : undefined);
setInjectFiles([]);
setPauseFlash(true);
window.setTimeout(() => setPauseFlash(false), 1600);
}, [onHandleInjectToRoundtable, injectFiles]);
const isSandboxStreaming =
isOrchestrating ||
step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true);
// 顶部「会商运行」状态在等待用户输入时仍可能保持 true,不能据此把发送键误显示成暂停。
// 仅在初始化或实际流式输出时,沿用第一步输入框的暂停态。
const isStep2Responding = isInitializing || isSandboxStreaming;
// 顶部标题栏整片脱敏(消息列表正文不在此容器,保持原文)。
const headerDzRef = useDesensitizeSubtree<HTMLDivElement>();
@@ -414,78 +418,82 @@ export function Step2Panel(props: Step2PanelProps) {
const chatBody = (
<>
{/* Headline */}
<div ref={headerDzRef} className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm">
<div className="flex items-center gap-2">
<Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} />
<div>
<h3 className="text-sm font-extrabold text-slate-900">多智能体圆桌会商中心</h3>
<p className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400 font-medium">
分布式领域专家实时动态博弈与纳什协商收敛中
</p>
<div
ref={headerDzRef}
className={
useStep1InputAppearance
? "shrink-0 flex items-center justify-between gap-3"
: "shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm"
}
>
{useStep1InputAppearance ? (
<div className="position-roundtable-intent-header-title">
<span aria-hidden="true" />
<h2>{headerTitle || "多智能体会商"}</h2>
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
<select
id="step2-global-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: "切换全局推理模型"
}
className="max-w-[220px] bg-slate-50/50 border border-slate-200 rounded-xl px-3 py-1.5 text-xs font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name
? `${m.display_name} (${m.name})`
: m.name}
</option>
))}
</select>
{!isRoundTableRunning ? (
<button
type="button"
onClick={onResumeRoundtable}
className={`bg-emerald-100 hover:bg-emerald-200 text-emerald-800 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-emerald-200/40 shadow-sm ${
pauseFlash
? "ring-2 ring-emerald-400 ring-offset-1 animate-pulse"
: ""
}`}
) : (
<div className="flex items-center gap-2">
<Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} />
<div>
<h3 className="text-sm font-extrabold text-slate-900">多智能体圆桌会商中心</h3>
<p className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400 font-medium">
分布式领域专家实时动态博弈与纳什协商收敛中
</p>
</div>
</div>
)}
<div
className={
useStep1InputAppearance
? "position-roundtable-intent-header-actions"
: "flex items-center gap-2.5 shrink-0"
}
>
{!useStep1InputAppearance && (
<select
id="step2-global-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: "切换全局推理模型"
}
className="max-w-[220px] bg-slate-50/50 border border-slate-200 rounded-xl px-3 py-1.5 text-xs font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
<Play className="w-3.5 h-3.5 fill-emerald-800" />
恢复会商
</button>
) : (
<button
type="button"
onClick={onPauseRoundtable}
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-amber-200/40 shadow-sm"
>
<Pause className="w-3.5 h-3.5" />
暂停会商
</button>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name
? `${m.display_name} (${m.name})`
: m.name}
</option>
))}
</select>
)}
{/* 后台挂起:放在暂停/恢复按钮右侧。查看历史记录时隐藏(isViewingHistory),
{/* 后台挂起:查看历史记录时隐藏(isViewingHistory),
重新从第一步进入第二步研讨时(isViewingHistory 为假)正常显示。 */}
{SHOW_BACKGROUND_SUSPEND && !isViewingHistory && (
<button
type="button"
onClick={onBackgroundSuspend}
title="把当前研讨转入后台自动跑到结果绘制完成;可离开页面,进度在历史记录里查看"
className="bg-slate-100 hover:bg-slate-200 text-slate-700 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-slate-200/60 shadow-sm dark:bg-secondary dark:text-foreground dark:border-border"
className={
useStep1InputAppearance
? "eightbf-roundtable__suspend-button"
: "bg-slate-100 hover:bg-slate-200 text-slate-700 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-slate-200/60 shadow-sm dark:bg-secondary dark:text-foreground dark:border-border"
}
>
<Network className="w-3.5 h-3.5" />
{!useStep1InputAppearance && <Network className="w-3.5 h-3.5" />}
后台挂起
</button>
)}
{headerRightSlot}
</div>
</div>
@@ -555,7 +563,12 @@ export function Step2Panel(props: Step2PanelProps) {
// 另一轴会被计算成 auto,任何子元素溢出一两像素都会让整列横向可滚 → 鼠标移到右侧
// 竖滚条滑块时极易误触发横向拖动、抓不住滑块。宽内容(代码块/表格)在各自气泡内
// 已有 max-w-full + overflow-x-auto 自带横滚,故此处裁掉横向不影响查看。
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-6 space-y-4 bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner"
className={cn(
"flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-6 space-y-4",
useStep1InputAppearance
? "eightbf-roundtable-step2-log"
: "bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner",
)}
>
{isInitializing && step2RoundtableDialogues.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-slate-500">
@@ -591,6 +604,7 @@ export function Step2Panel(props: Step2PanelProps) {
onFileCardClick={(path) => {
if (msg.agentThreadId) openArtifactPreview(msg.agentThreadId, path);
}}
taskArtifactSubmission={taskArtifactSubmission}
/>
) : (
<div className="py-6 text-center text-xs text-slate-400">
@@ -647,13 +661,19 @@ export function Step2Panel(props: Step2PanelProps) {
openArtifactPreview(msg.agentThreadId, path);
}
}}
taskArtifactSubmission={taskArtifactSubmission}
/>
))
)}
</>
)}
</div>
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
{!step2AtBottom && (
<ScrollToBottomButton
onClick={scrollStep2ToBottom}
className="!bottom-1/2 translate-y-1/2"
/>
)}
</div>
{/* Clarification card */}
@@ -730,92 +750,71 @@ export function Step2Panel(props: Step2PanelProps) {
</div>
)}
{/* Console + intervention bar */}
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex flex-col gap-2">
{/* 已选附件预览(点「干预并发送」时随指令上传) */}
{injectFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{injectFiles.map((file, idx) => (
<span
key={`${file.name}-${file.size}-${idx}`}
className="inline-flex items-center gap-1.5 bg-white border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
{useStep1InputAppearance ? (
/* 8BF 直接复用第一步 PositionIntentInput 的 DOM、图标和深蓝框体;仅替换成会商干预的状态与动作。 */
<div className="position-roundtable-intent-page eightbf-roundtable-step2-input [&_#suggest-followup-btn]:hidden [&_#skip-question-btn]:hidden [&_#save-draft-btn]:hidden">
<PositionIntentInput
value={customAgentInput}
onValueChange={setCustomAgentInput}
attachedFiles={injectFiles}
onAddFiles={addInjectFiles}
onRemoveFile={removeInjectFile}
charactersLeft={Math.max(0, 500 - customAgentInput.length)}
isSending={isStep2Responding}
canSend={customAgentInput.trim().length > 0 || injectFiles.length > 0}
pendingClarification={false}
onSend={handleInject}
onStop={onPauseRoundtable}
onFollowUp={() => undefined}
onSkip={() => undefined}
onEnterRoundtable={() => undefined}
enterButtonLabel=""
intentReady={null}
hideEnterRoundtable
onManualSave={() => undefined}
onTriggerToast={() => undefined}
modelOptions={availableModels.map((model) => ({
label: model.display_name && model.display_name !== model.name
? `${model.display_name} (${model.name})`
: model.name,
value: model.name,
}))}
selectedModel={selectedModel}
onSelectedModelChange={setSelectedModel}
footerAction={(
/* 与第一步「确认意图并启动业务链」复用相同的稳定 id 和样式规则。 */
<button
id="enter-roundtable-btn"
type="button"
onClick={onProceedToStep3}
disabled={!hasConsensus}
aria-disabled={!hasConsensus}
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
className={cn(
"flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] px-3 text-xs font-black transition active:scale-[0.98] shadow-md shadow-emerald-500/10",
hasConsensus
? "cursor-pointer bg-emerald-600 text-white hover:bg-emerald-700"
: "cursor-not-allowed bg-emerald-600/40 text-white",
)}
>
<FileIcon className="w-3 h-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-slate-400">
{formatFileSize(file.size)}
</span>
<button
type="button"
onClick={() => removeInjectFile(idx)}
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
title="移除"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-end gap-3">
<AutoGrowTextarea
value={customAgentInput}
onChange={(e) => setCustomAgentInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleInject();
}
}}
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预...(Enter 发送 / Shift+Enter 换行)"
className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs leading-relaxed focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium"
maxHeight={280}
/>
<input
ref={injectFileInputRef}
type="file"
multiple
className="hidden"
onChange={handlePickInjectFiles}
/>
<button
type="button"
onClick={() => injectFileInputRef.current?.click()}
title="上传参考文件(席位/总控将读取文件内容辅助研讨)"
className="bg-white border border-slate-200 hover:bg-slate-50 text-slate-500 hover:text-primary p-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95"
>
<Paperclip className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleInject}
title="记录干预指令并立即恢复会商执行"
className="bg-primary hover:bg-[var(--brand-900)] text-white text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-primary/10"
>
干预并发送
</button>
<button
type="button"
onClick={onProceedToStep3}
disabled={!hasConsensus}
aria-disabled={!hasConsensus}
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
className={`text-xs font-black px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 shadow-md shadow-emerald-500/10 ${
hasConsensus
? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
: "bg-emerald-600/40 text-white cursor-not-allowed"
}`}
>
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
达成共识生成全案成果 &gt;
</button>
<Sparkles className="size-3.5 text-emerald-100" />
达成共识生成全案成果 &gt;
</button>
)}
/>
</div>
</div>
) : (
<Step2LegacyComposer
value={customAgentInput}
onValueChange={setCustomAgentInput}
files={injectFiles}
onAddFiles={addInjectFiles}
onRemoveFile={removeInjectFile}
onSend={handleInject}
hasConsensus={hasConsensus}
onProceed={onProceedToStep3}
/>
)}
</>
);
@@ -828,12 +827,129 @@ export function Step2Panel(props: Step2PanelProps) {
activeThreadId={activeArtifactThreadId}
isStreaming={isSandboxStreaming}
seatMessages={activeSeatMessages}
taskArtifactSubmission={taskArtifactSubmission}
>
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">{chatBody}</div>
<div
className={cn(
"flex h-full min-h-0 flex-col",
useStep1InputAppearance
? "position-roundtable-intent-panel eightbf-roundtable-intent-panel eightbf-roundtable-step2-chat"
: "bg-slate-50/20",
)}
>
{chatBody}
</div>
</Step2SandboxLayout>
);
}
/** 原通用会商的输入区。8BF 使用第一步复用组件时,其他会商页面保持此既有结构。 */
function Step2LegacyComposer({
value,
onValueChange,
files,
onAddFiles,
onRemoveFile,
onSend,
hasConsensus,
onProceed,
}: {
value: string;
onValueChange: React.Dispatch<React.SetStateAction<string>>;
files: File[];
onAddFiles: (files: File[]) => void;
onRemoveFile: (index: number) => void;
onSend: () => void;
hasConsensus: boolean;
onProceed: () => void;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<div className="shrink-0 flex flex-col gap-2 border-t border-slate-100 bg-slate-50 p-4">
{files.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{files.map((file, index) => (
<span
key={`${file.name}-${file.size}-${index}`}
className="inline-flex max-w-[200px] items-center gap-1.5 rounded-lg border border-slate-200 bg-white py-1 pl-2 pr-1 text-[11px] text-slate-600"
>
<FileIcon className="size-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>{file.name}</span>
<span className="shrink-0 text-slate-400">{formatFileSize(file.size)}</span>
<button
type="button"
onClick={() => onRemoveFile(index)}
className="shrink-0 rounded p-0.5 text-slate-400 transition hover:bg-slate-200 hover:text-slate-600"
title="移除"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-end gap-3">
<AutoGrowTextarea
value={value}
onChange={(event) => onValueChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSend();
}
}}
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预...(Enter 发送 / Shift+Enter 换行)"
className="min-h-[48px] flex-1 rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-xs font-medium leading-relaxed focus:border-primary focus:outline-none focus:ring-1 focus:ring-[var(--brand-200)]/50"
maxHeight={280}
/>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
onAddFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
title="上传参考文件(席位/总控将读取文件内容辅助研讨)"
className="shrink-0 cursor-pointer rounded-xl border border-slate-200 bg-white p-2.5 text-slate-500 transition hover:bg-slate-50 hover:text-primary active:scale-95"
>
<Paperclip className="size-4" />
</button>
<button
type="button"
onClick={onSend}
title="记录干预指令并立即恢复会商执行"
className="shrink-0 cursor-pointer rounded-xl bg-primary px-4 py-2.5 text-xs font-bold text-white shadow-md shadow-primary/10 transition hover:bg-[var(--brand-900)] active:scale-95"
>
干预并发送
</button>
<button
type="button"
onClick={onProceed}
disabled={!hasConsensus}
aria-disabled={!hasConsensus}
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
className={cn(
"flex shrink-0 items-center gap-1 rounded-xl px-4 py-2.5 text-xs font-black shadow-md shadow-emerald-500/10 transition active:scale-95",
hasConsensus
? "cursor-pointer bg-emerald-600 text-white hover:bg-emerald-700"
: "cursor-not-allowed bg-emerald-600/40 text-white",
)}
>
<Sparkles className="size-3.5 text-emerald-100" />
达成共识生成全案成果 &gt;
</button>
</div>
</div>
);
}
/**
* 单条 Step 2 对话气泡。包含:
* - ChainOfThought 折叠卡(若 msg.steps 非空)
@@ -863,6 +979,7 @@ export interface Step2MessageProps {
) => void;
/** 点击气泡下方 `present_files` 文件卡时打开沙箱(走真实 HTTP 路径)。 */
onFileCardClick?: (path: string) => void;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}
export function Step2Message({
@@ -873,6 +990,7 @@ export function Step2Message({
resolveAgentName,
onArtifactStepClick,
onFileCardClick,
taskArtifactSubmission,
}: Step2MessageProps) {
const isInjected = msg.sender === "用户 (插话干预)";
const isStreaming = msg.streaming === true;
@@ -951,6 +1069,8 @@ export function Step2Message({
files={presentedFiles}
onClick={onFileCardClick}
disabled={!msg.agentThreadId}
threadId={msg.agentThreadId}
taskArtifactSubmission={taskArtifactSubmission}
/>
)}
@@ -1016,10 +1136,14 @@ function PresentFilesRow({
files,
onClick,
disabled,
threadId,
taskArtifactSubmission,
}: {
files: string[];
onClick?: (path: string) => void;
disabled: boolean;
threadId?: string;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}) {
return (
<div className="mt-2 flex min-w-0 flex-wrap gap-2">
@@ -1029,6 +1153,12 @@ function PresentFilesRow({
path={path}
disabled={disabled || !onClick}
onClick={() => onClick?.(path)}
submitButton={threadId ? (
<TaskArtifactSubmitButton
controller={taskArtifactSubmission}
target={taskArtifactSubmission?.targetFor({ threadId, path }) ?? { threadId, path }}
/>
) : null}
/>
))}
</div>
@@ -1062,37 +1192,41 @@ function PresentFileCard({
path,
disabled,
onClick,
submitButton,
}: {
path: string;
disabled: boolean;
onClick: () => void;
submitButton?: ReactNode;
}) {
const filename = path.split("/").pop() ?? path;
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
const typeLabel = FILE_EXT_LABELS[ext] ?? (ext.toUpperCase() || "FILE");
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={disabled ? "席位线程未就绪,暂无法预览" : path}
className="box-border flex w-[12.5rem] max-w-full min-w-0 flex-col gap-1 overflow-hidden rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-primary/50 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
>
<div className="flex min-w-0 items-start gap-2">
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span
className="roundtable-chat-message min-w-0 flex-1 truncate font-medium text-slate-700"
title={filename}
>
{filename}
</span>
</div>
<div className="box-border flex w-[12.5rem] max-w-full min-w-0 flex-col gap-1 overflow-hidden rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-primary/50 hover:bg-slate-50">
<button
type="button"
onClick={onClick}
disabled={disabled}
title={disabled ? "席位线程未就绪,暂无法预览" : path}
className="flex min-w-0 flex-col gap-1 text-left disabled:cursor-not-allowed disabled:opacity-60"
>
<div className="flex min-w-0 items-start gap-2">
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span
className="roundtable-chat-message min-w-0 flex-1 truncate font-medium text-slate-700"
title={filename}
>
{filename}
</span>
</div>
</button>
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="roundtable-chat-meta rounded bg-slate-100 px-1.5 py-0.5 font-mono font-semibold text-slate-500">
{typeLabel}
</span>
<span className="roundtable-chat-meta text-slate-400">点击预览</span>
{submitButton}
</div>
</button>
</div>
);
}
@@ -24,9 +24,47 @@ import {
import { ThreadContext } from "@/components/workspace/messages/context";
import type { AgentThreadState } from "@/core/threads";
import { cn } from "@/lib/utils";
import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton";
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
const CLOSE_MODE = { chat: 100, artifacts: 0 };
const OPEN_MODE = { chat: 60, artifacts: 40 };
/** 从 write_file 虚拟地址反查流式工具调用中的正文,供任务产物提交直接使用。 */
function virtualMarkdownContent(
artifact: string | null,
messages: unknown[] | undefined,
): string | undefined {
if (!artifact?.startsWith("write-file:") || !messages?.length) return undefined;
try {
const url = new URL(artifact);
const toolCallId = url.searchParams.get("tool_call_id");
const path = decodeURIComponent(url.pathname).trim();
for (const message of messages) {
if (!message || typeof message !== "object") continue;
const toolCalls = (message as { tool_calls?: unknown }).tool_calls;
if (!Array.isArray(toolCalls)) continue;
for (const call of toolCalls) {
if (!call || typeof call !== "object") continue;
const toolCall = call as {
id?: unknown;
name?: unknown;
args?: { path?: unknown; content?: unknown };
};
if (toolCall.name !== "write_file") continue;
if (toolCallId && toolCall.id !== toolCallId) continue;
if (!toolCallId && toolCall.args?.path !== path) continue;
return typeof toolCall.args?.content === "string"
? toolCall.args.content
: undefined;
}
}
} catch {
// 虚拟路径解析失败时交给提交层走它原有的真实文件读取兜底。
}
return undefined;
}
interface Step2SandboxLayoutProps {
children?: React.ReactNode;
/** 当前预览文件所属的席位 thread id。 */
@@ -48,6 +86,8 @@ interface Step2SandboxLayoutProps {
* 左侧消息列表保持可见;置空即恢复沙箱(文件预览/列表)。
*/
sidePanel?: React.ReactNode;
/** 任务深链页面的 Markdown 过程产物提交能力。 */
taskArtifactSubmission?: TaskArtifactSubmissionController;
}
export function Step2SandboxLayout({
@@ -58,6 +98,7 @@ export function Step2SandboxLayout({
artifactsFill = false,
onArtifactsClose,
sidePanel,
taskArtifactSubmission,
}: Step2SandboxLayoutProps) {
const layoutRef = useRef<GroupImperativeHandle>(null);
const {
@@ -68,6 +109,10 @@ export function Step2SandboxLayout({
deselect,
} = useArtifacts();
const artifacts = rawArtifacts ?? [];
const selectedArtifactContent = useMemo(
() => virtualMarkdownContent(selectedArtifact, seatMessages),
[selectedArtifact, seatMessages],
);
const artifactPanelOpen = artifactsOpen;
// sidePanel(流程图等)非空时即使沙箱没开也要把右栏撑开。
@@ -115,9 +160,20 @@ export function Step2SandboxLayout({
filepath={selectedArtifact}
threadId={previewThreadId}
onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined}
allowVirtualMarkdownEdit
// 会商「消息列表沙箱」按需求不脱敏产物内容(用户要拿原始文件)。
disableDesensitize
headerLeadingActionBare
headerLeadingAction={(
<TaskArtifactSubmitButton
compact
controller={taskArtifactSubmission}
target={taskArtifactSubmission?.targetFor({
threadId: previewThreadId,
path: selectedArtifact,
content: selectedArtifactContent,
}) ?? { threadId: previewThreadId, path: selectedArtifact }}
/>
)}
/>
) : (
<div className="relative flex size-full justify-center">
@@ -20,8 +20,12 @@ import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import { stripReasoning } from "@/roundtable-planning/lib/reasoning";
import { useMemo } from "react";
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
export interface Step3PanelProps {
/** 8BF 第三步对话区与第一、二步共用深蓝顶栏;默认不改变原会商。 */
useStep1Chrome?: boolean;
headerTitle?: string;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
consensusText: string;
@@ -54,10 +58,13 @@ export interface Step3PanelProps {
/** Step 2 沙箱预览产出文件(与第二步相同)。 */
openArtifactPreview: (threadId: string, path: string) => void;
seatStubMessages: Record<string, unknown[]>;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}
export function Step3Panel(props: Step3PanelProps) {
const {
useStep1Chrome = false,
headerTitle,
threadIds,
coordinatorName,
consensusText,
@@ -80,6 +87,7 @@ export function Step3Panel(props: Step3PanelProps) {
onReset,
openArtifactPreview,
seatStubMessages,
taskArtifactSubmission,
} = props;
const seatDeliveries = useMemo(
@@ -105,6 +113,8 @@ export function Step3Panel(props: Step3PanelProps) {
return (
<HighFidelityReport
useStep1Chrome={useStep1Chrome}
headerTitle={headerTitle}
threadIds={threadIds}
coordinatorName={coordinatorName}
consensusText={consensusText}
@@ -127,6 +137,7 @@ export function Step3Panel(props: Step3PanelProps) {
onReset={onReset}
openArtifactPreview={openArtifactPreview}
seatStubMessages={seatStubMessages}
taskArtifactSubmission={taskArtifactSubmission}
/>
);
}
@@ -45,6 +45,7 @@ import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/Co
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton";
import { desensitize } from "@/lib/desensitize";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
@@ -57,6 +58,7 @@ import type {
ReportIntent,
UseStep3ReportReturn,
} from "@/roundtable-planning/hooks/useStep3Report";
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
interface SeatDelivery {
sender: string;
@@ -88,6 +90,7 @@ interface Step3ReportModeProps {
/** 页面层常驻的大屏多页流(模式二)。 */
dashboard: UseStep3DashboardReturn;
onBack: () => void;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}
type SourceModal = "consensus" | "seats" | "files" | null;
@@ -108,6 +111,7 @@ export function Step3ReportMode({
report,
dashboard,
onBack,
taskArtifactSubmission,
}: Step3ReportModeProps) {
const reportSeatDeliveries = useMemo(
() => seatDeliveries.map((d) => ({ sender: d.sender, content: d.content })),
@@ -376,6 +380,7 @@ export function Step3ReportMode({
activeThreadId={activeArtifactThreadId ?? reportThreadId}
isStreaming={isStreaming}
seatMessages={activeSeatMessages}
taskArtifactSubmission={taskArtifactSubmission}
>
<AgentProgressPanel
phase={phase}
@@ -390,6 +395,7 @@ export function Step3ReportMode({
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
taskArtifactSubmission={taskArtifactSubmission}
/>
</Step2SandboxLayout>
</section>
@@ -428,6 +434,7 @@ export function Step3ReportMode({
threadIds={threadIds}
coordinatorName={coordinatorName}
seatNameMap={seatNameMap}
taskArtifactSubmission={taskArtifactSubmission}
onClose={() => setModal(null)}
/>
)}
@@ -513,6 +520,7 @@ function AgentProgressPanel({
availableModels,
modelsLoading,
setSelectedModel,
taskArtifactSubmission,
}: {
phase: UseStep3ReportReturn["phase"];
dialogues: UseStep3ReportReturn["dialogues"];
@@ -526,6 +534,7 @@ function AgentProgressPanel({
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}) {
const busy = phase === "creating" || phase === "streaming";
// 只有「正在建线程」时锁输入(线程没建好没法插话);绘制中允许输入并发送——
@@ -610,6 +619,7 @@ function AgentProgressPanel({
onFileCardClick={(path) => {
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
}}
taskArtifactSubmission={taskArtifactSubmission}
/>
))}
@@ -822,11 +832,13 @@ function SeatFilesModal({
threadIds,
coordinatorName,
seatNameMap,
taskArtifactSubmission,
onClose,
}: {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
seatNameMap: Record<string, string>;
taskArtifactSubmission?: TaskArtifactSubmissionController;
onClose: () => void;
}) {
const seatEntries = useMemo(() => {
@@ -932,6 +944,25 @@ function SeatFilesModal({
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-mono text-slate-400">{formatBytes(f.sizeBytes)}</span>
<TaskArtifactSubmitButton
compact
controller={taskArtifactSubmission}
target={taskArtifactSubmission?.targetFor({
threadId: seat.threadId,
path: f.path,
name: f.name,
mimeType: f.mimeType,
agentId: seat.agentName,
agentName: seat.displayName,
}) ?? {
threadId: seat.threadId,
path: f.path,
name: f.name,
mimeType: f.mimeType,
agentId: seat.agentName,
agentName: seat.displayName,
}}
/>
<button
type="button"
onClick={() => handlePreview(seat.threadId, f)}
@@ -9,33 +9,27 @@
* 布局对齐 Step3ReportMode(HTML 绘制):左栏研讨原料源卡 + 右栏沙箱。两者并存、
* 互不影响(report 画 HTML 看板,summary 写 Markdown 报告 + 问答)。
*/
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
ArrowRight,
CheckCircle,
Database,
Download,
Eye,
FileText,
Loader2,
PanelLeftClose,
PanelLeftOpen,
Paperclip,
Pause,
RefreshCw,
SendHorizontal,
Sparkles,
Workflow,
X,
} from "lucide-react";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { useArtifacts } from "@/components/workspace/artifacts";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
import {
buildArtifactDownloadUrl,
listSessionArtifacts,
@@ -44,15 +38,10 @@ import {
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
import {
detectFlowIntent,
extractFlowGraph,
FLOW_FENCE_OPEN,
} from "@/roundtable-planning/lib/flow-graph";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton";
import { desensitize } from "@/lib/desensitize";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
@@ -61,6 +50,7 @@ import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import { useStep3Summary, type UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
interface SeatDelivery {
sender: string;
@@ -69,6 +59,8 @@ interface SeatDelivery {
}
interface Step3SummaryModeProps {
useStep1Chrome?: boolean;
headerTitle?: string;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
consensusText: string;
@@ -78,7 +70,6 @@ interface Step3SummaryModeProps {
intentReady: ReportIntent | null;
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
/** 业务链写给总控的编排提示 —— 注入总结智能体作为最高优先级口径。 */
coordinatorPrompt?: string | null;
@@ -86,13 +77,14 @@ interface Step3SummaryModeProps {
savedSummary?: DraftStep3SummarySnapshot | null;
/** 页面层常驻的总结生成流(2026-06-13 提升:切走不断流,进入本视图续看)。 */
summary: UseStep3SummaryReturn;
/** 切到「最终方案展示」静态页(次要入口)。 */
onViewStatic: () => void;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}
type SourceModal = "consensus" | "seats" | "files" | null;
export function Step3SummaryMode({
useStep1Chrome = false,
headerTitle,
threadIds,
coordinatorName,
consensusText,
@@ -102,12 +94,11 @@ export function Step3SummaryMode({
intentReady,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
coordinatorPrompt,
savedSummary,
summary,
onViewStatic,
taskArtifactSubmission,
}: Step3SummaryModeProps) {
void coordinatorPrompt; // 选项已随 hook 提升到页面层,这里仅保留 prop 兼容
@@ -140,42 +131,7 @@ export function Step3SummaryMode({
const { open: artifactsOpen } = useArtifacts();
// ── 结构化流程图(Phase 1:后端「抽取+校验」)────────────────────────────
// 数据来源优先级:
// · flowPhase==="done" → 用后端校验后的结构化图 summary.flowGraph 绘制;
// · 抽取/校验进行中 → 不画(用户看对话气泡里实时滚动的 JSON + 状态条),等校验完成再绘制;
// · idle/error → 回退到对话气泡里解析 flow-json(手动 flow 续问路径 / 旧逻辑兜底)。
const flowGraph = useMemo(() => {
if (summary.flowPhase === "done" && summary.flowGraph) {
return { graph: summary.flowGraph, streaming: false };
}
if (summary.flowPhase === "extracting" || summary.flowPhase === "validating") {
return null;
}
for (let i = dialogues.length - 1; i >= 0; i--) {
const d = dialogues[i];
if (d.avatarType === "human") continue;
const content = d.content ?? "";
if (!content.includes(FLOW_FENCE_OPEN)) continue;
const graph = extractFlowGraph(content);
if (graph) return { graph, streaming: d.streaming === true };
}
return null;
}, [dialogues, summary.flowPhase, summary.flowGraph]);
const [flowView, setFlowView] = useState(false);
const flowAutoOpenedRef = useRef(false);
useEffect(() => {
if (!flowGraph || flowGraph.graph.nodes.length === 0) return;
if (flowAutoOpenedRef.current) return;
flowAutoOpenedRef.current = true;
// taskId/深链模式:结构化输出是本模式的主交付 → 流程图一出现就自动聚焦画布
// (即便此前 md 沙箱已自动打开,也切到流程图)。
// 普通模式:没开沙箱才自动打开;沙箱开着不抢占,只露出切换按钮。
if (summary.autoFlow || !artifactsOpen) setFlowView(true);
}, [flowGraph, artifactsOpen, summary.autoFlow]);
// 沙箱里已写出的 md 报告(流程图画布上提供「MD 报告」切回按钮的锚点)。
// 沙箱里已写出的 Markdown 总结报告。
const mdArtifact = useMemo(() => {
for (const [threadId, msgs] of Object.entries(seatStubMessages)) {
const msg = msgs?.[0];
@@ -196,25 +152,16 @@ export function Step3SummaryMode({
return null;
}, [seatStubMessages]);
// 用户在输入框里明确要求结构化输出/流程图 → 立即切到流程图画布(旧图先顶着,
// 新 flow-json 流出来就增量重绘;首次还没有图时画布在数据到达后自动渲染)。
const handleAsk = (message: string, files?: File[]) => {
if (detectFlowIntent(message)) {
flowAutoOpenedRef.current = true;
setFlowView(true);
}
ask(message, files);
};
const handleAsk = (message: string, files?: File[]) => ask(message, files);
// 流程图抽取/校验的过程(思考 `<think>` + 文字说明 + flow-json 代码块)全部原样交给
// Step2Message 渲染:它用 splitInlineReasoning 把思考折叠成思考块、其余按 markdown 渲染
// (flow-json 显示为代码块)。用户要求显示完整过程、不只显示代码块,故不再 strip。
// 报告生成过程(思考、文字说明、文件写入步骤)交由 Step2Message 原样渲染,
// 保持与第二步一致的流式过程可见性。
const displayDialogues = dialogues;
const [leftCollapsed, setLeftCollapsed] = useState(false);
// 右侧面板(沙箱预览或流程图画布)露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
// 右侧沙箱露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
// 初值固定为 false:这样即使「进入/续看时沙箱已是打开态」(rightPanelVisible 一上来就为 true),
// 首个 effect 也能识别 false→true 的边沿并收起左栏(修此前已打开时左栏不收起的问题)。
const rightPanelVisible = artifactsOpen || (!!flowGraph && flowView);
const rightPanelVisible = artifactsOpen;
const prevRightPanelVisibleRef = useRef(false);
useEffect(() => {
if (rightPanelVisible !== prevRightPanelVisibleRef.current) {
@@ -265,46 +212,42 @@ export function Step3SummaryMode({
return (
<div className="flex h-full min-h-0 flex-col text-slate-800">
{/* 顶栏 */}
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3.5 flex-wrap">
<div className="flex items-center gap-2.5">
<Sparkles className="w-5 h-5 text-primary" />
<h3 className="text-sm font-black text-slate-900 tracking-tight">方案总结 · 报告</h3>
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-primary bg-[var(--brand-200)]/60 border border-red-200/50 px-2 py-0.5 rounded-full">
{(phase === "creating" || phase === "streaming") && (
<Loader2 className="w-3 h-3 animate-spin" />
)}
{phase === "done" && <CheckCircle className="w-3 h-3 text-emerald-600" />}
{statusLabel}
</span>
{/* 顶栏。8BF 改走对话区内的第一步同款顶栏,这里不再套一层。 */}
{!useStep1Chrome && (
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3.5 flex-wrap">
<div className="flex items-center gap-2.5">
<Sparkles className="w-5 h-5 text-primary" />
<h3 className="text-sm font-black text-slate-900 tracking-tight">方案总结 · 报告</h3>
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-primary bg-[var(--brand-200)]/60 border border-red-200/50 px-2 py-0.5 rounded-full">
{(phase === "creating" || phase === "streaming") && (
<Loader2 className="w-3 h-3 animate-spin" />
)}
{phase === "done" && <CheckCircle className="w-3 h-3 text-emerald-600" />}
{statusLabel}
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => redraw()}
disabled={phase === "creating" || phase === "streaming"}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新生成
</button>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => redraw()}
disabled={phase === "creating" || phase === "streaming"}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新生成
</button>
<button
type="button"
onClick={onViewStatic}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
最终方案展示
<ArrowRight className="w-3.5 h-3.5 text-slate-500" />
</button>
</div>
</div>
)}
{/* 主体:左源卡 + 右沙箱 */}
<div className="flex-1 min-h-0 flex gap-3 p-3">
{/* 主体:左源卡 + 右沙箱。8BF 左栏改用第一步任务/意图区,这里只留对话面板。 */}
<div className={cn("flex-1 min-h-0 flex", !useStep1Chrome && "gap-3 p-3")}>
{!useStep1Chrome && (
<aside
data-step3-source-panel
className={cn(
"shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5 transition-[width] duration-200 ease-out",
leftCollapsed ? "w-[52px]" : "w-[280px]",
"eightbf-roundtable__step3-source-panel shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5 transition-[width] duration-200 ease-out",
leftCollapsed ? "w-[80px]" : "w-[280px]",
)}
>
{leftCollapsed ? (
@@ -314,7 +257,7 @@ export function Step3SummaryMode({
onClick={() => setLeftCollapsed(false)}
title="展开研讨原料"
aria-expanded={false}
className="w-full shrink-0 flex h-10 items-center justify-center rounded-2xl border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
className="w-full shrink-0 flex h-10 items-center justify-center rounded-[4px] border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
>
<PanelLeftOpen className="h-4 w-4" />
</button>
@@ -347,7 +290,7 @@ export function Step3SummaryMode({
onClick={() => setLeftCollapsed(true)}
title="收起研讨原料"
aria-expanded
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors"
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-[4px] border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</button>
@@ -375,57 +318,37 @@ export function Step3SummaryMode({
onClick={() => setModal("files")}
/>
<div className="mt-1 rounded-2xl border border-dashed border-slate-200 bg-slate-50/60 p-3 text-[11px] leading-relaxed text-slate-400">
<div className="mt-1 rounded-[4px] border border-dashed border-slate-200 bg-slate-50/60 p-3 text-[11px] leading-relaxed text-slate-400">
右侧由「方案总结报告智能体」据这些原料撰写 Markdown 总结报告,可在下方输入框继续提修改意见或提问。
</div>
</>
)}
</aside>
)}
{/* 右栏:智能体进度 + 沙箱(复用第 2 步)。流程图画布放在右侧沙箱位
(sidePanel 插槽),消息列表保持可见,可与 md 沙箱预览互相切换。 */}
<section className="relative flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
{/* 右栏:智能体进度 + Markdown 沙箱(复用第 2 步)。 */}
<section
data-step3-report-panel
className={cn(
"relative flex-1 min-w-0 overflow-hidden",
!useStep1Chrome &&
"eightbf-roundtable__step3-report-panel rounded-2xl border border-slate-200/60 bg-white",
)}
>
<Step2SandboxLayout
activeThreadId={activeArtifactThreadId ?? summaryThreadId}
isStreaming={isStreaming}
seatMessages={activeSeatMessages}
sidePanel={
flowGraph && flowView ? (
<FlowGraphPanel
data={flowGraph.graph}
streaming={flowGraph.streaming}
onClose={() => setFlowView(false)}
onSwitchToReport={
mdArtifact
? () => {
setFlowView(false);
openArtifactPreview(mdArtifact.threadId, mdArtifact.path);
}
: undefined
}
onChange={summary.setFlowGraph}
/>
) : undefined
}
taskArtifactSubmission={taskArtifactSubmission}
>
<AgentProgressPanel
useStep1Chrome={useStep1Chrome}
headerTitle={headerTitle}
phase={phase}
dialogues={displayDialogues}
error={error}
busy={busy}
mdAvailable={!!mdArtifact}
flowPhase={summary.flowPhase}
onRegenerateFlow={summary.regenerateFlow}
pendingClarification={summary.pendingFlowClarification}
onResolveClarification={summary.resolveFlowClarification}
ingestAvailable={summary.ingestAvailable}
ingestStatus={summary.ingestStatus}
pendingIngestClarification={summary.pendingIngestClarification}
onResolveIngestChoice={summary.resolveIngestChoice}
onResolveIngestClarification={summary.resolveIngestClarification}
pendingFlowForIngest={summary.pendingFlowForIngest}
onResolveFlowForIngest={summary.resolveFlowForIngest}
onManualIngest={summary.runIngest}
onRetry={redraw}
onAsk={handleAsk}
onPause={pause}
@@ -433,31 +356,11 @@ export function Step3SummaryMode({
onArtifactPreview={openArtifactPreview}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
taskArtifactSubmission={taskArtifactSubmission}
/>
</Step2SandboxLayout>
{/* 流程图入口按钮(有结构化数据且画布未开时显示;沙箱开着时即「切换」按钮) */}
{flowGraph && !flowView && (
<button
type="button"
onClick={() => setFlowView(true)}
title={artifactsOpen ? "切换到流程图画布" : "打开流程图画布"}
className="absolute bottom-4 right-4 z-20 inline-flex items-center gap-1.5 rounded-full border border-primary/30 bg-white px-3.5 py-2 text-[11px] font-bold text-primary shadow-lg transition hover:bg-primary/5"
>
{flowGraph.streaming ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Workflow className="h-3.5 w-3.5" />
)}
流程图
<span className="rounded-full bg-primary/10 px-1.5 font-mono text-[10px]">
{flowGraph.graph.nodes.length}
</span>
</button>
)}
</section>
</div>
@@ -491,6 +394,7 @@ export function Step3SummaryMode({
threadIds={threadIds}
coordinatorName={coordinatorName}
seatNameMap={seatNameMap}
taskArtifactSubmission={taskArtifactSubmission}
onClose={() => setModal(null)}
/>
)}
@@ -520,7 +424,7 @@ function SourceCard({
type="button"
onClick={onClick}
disabled={disabled}
className="text-left bg-white border border-slate-200/60 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:border-primary/40 hover:shadow-[0_4px_14px_rgba(0,0,0,0.04)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-slate-200/60"
className="text-left bg-white border border-slate-200/60 rounded-[4px] p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:border-primary/40 hover:shadow-[0_4px_14px_rgba(0,0,0,0.04)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-slate-200/60"
>
<div className="flex items-center gap-2 mb-1.5">
{icon}
@@ -553,7 +457,7 @@ function CollapsedSourceCard({
title={title}
disabled={disabled}
onClick={onClick}
className="w-full shrink-0 flex items-center justify-center rounded-2xl border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30 disabled:opacity-40 disabled:cursor-not-allowed"
className="w-full shrink-0 flex items-center justify-center rounded-[4px] border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30 disabled:opacity-40 disabled:cursor-not-allowed"
>
{icon}
</button>
@@ -563,23 +467,13 @@ function CollapsedSourceCard({
// ── 右栏:智能体进度面板(沙箱的 60% 区域)+ 底部对话输入框 ──────────────────
function AgentProgressPanel({
useStep1Chrome = false,
headerTitle,
phase,
dialogues,
error,
busy,
mdAvailable,
flowPhase,
onRegenerateFlow,
pendingClarification,
onResolveClarification,
ingestAvailable,
ingestStatus,
pendingIngestClarification,
onResolveIngestChoice,
onResolveIngestClarification,
pendingFlowForIngest,
onResolveFlowForIngest,
onManualIngest,
onRetry,
onAsk,
onPause,
@@ -587,32 +481,17 @@ function AgentProgressPanel({
onArtifactPreview,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
taskArtifactSubmission,
}: {
useStep1Chrome?: boolean;
headerTitle?: string;
phase: UseStep3SummaryReturn["phase"];
dialogues: UseStep3SummaryReturn["dialogues"];
error: string | null;
busy: boolean;
/** 沙箱里是否已有 md 报告 —— 决定提示语(纯流程图轮没有 .md 可预览/下载)。 */
/** 沙箱里是否已有 Markdown 总结报告。 */
mdAvailable: boolean;
/** 后端流程图抽取阶段(Phase 1)。 */
flowPhase: UseStep3SummaryReturn["flowPhase"];
/** 仅重新生成流程图(模型挂了切模型后重试)。 */
onRegenerateFlow: () => void;
/** 意图模糊待澄清的原始消息(非空 → 展示选项让用户定)。 */
pendingClarification: string | null;
onResolveClarification: UseStep3SummaryReturn["resolveFlowClarification"];
/** 入库技能是否可用(决定入库询问卡 + 手动入库按钮是否出现)。 */
ingestAvailable: boolean;
ingestStatus: UseStep3SummaryReturn["ingestStatus"];
pendingIngestClarification: string | null;
onResolveIngestChoice: UseStep3SummaryReturn["resolveIngestChoice"];
onResolveIngestClarification: UseStep3SummaryReturn["resolveIngestClarification"];
/** 入库时尚无流程图 → 展示「是否先生成流程图」协助。 */
pendingFlowForIngest: boolean;
onResolveFlowForIngest: UseStep3SummaryReturn["resolveFlowForIngest"];
onManualIngest: UseStep3SummaryReturn["runIngest"];
onRetry: () => void;
onAsk: (message: string, files?: File[]) => void;
onPause: () => void;
@@ -620,13 +499,13 @@ function AgentProgressPanel({
onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"];
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
// 新一轮气泡出现(如:md 报告完成后自动续跑的结构化输出轮、用户发问)→ 强制滚到底,
// 让用户看到新阶段(修「调用结构化输出时不自动下滑」)。仅在气泡**条数**增加时触发,
// 新一轮气泡出现(报告生成或用户续问)→ 强制滚到底,
// 让用户看到新阶段。仅在气泡**条数**增加时触发,
// 流式增量内容仍由 useAutoScroll 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。
const prevDialogueCountRef = useRef(dialogues.length);
useEffect(() => {
@@ -642,22 +521,18 @@ function AgentProgressPanel({
const [askText, setAskText] = useState("");
// 输入框携带的本地附件(点发送时连同指令一起上传到总结线程)。
const [askFiles, setAskFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const pickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(e.target.files ?? []);
const addAskFiles = (picked: File[]) => {
if (picked.length > 0) {
setAskFiles((prev) => {
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
});
}
e.target.value = "";
};
const removeFile = (idx: number) => setAskFiles((prev) => prev.filter((_, i) => i !== idx));
const handleAskSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const submitAsk = () => {
const text = askText.trim();
const hasFiles = askFiles.length > 0;
if (!text && !hasFiles) return; // 允许「随时」发送(含生成中);空内容不发
@@ -667,49 +542,53 @@ function AgentProgressPanel({
};
return (
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">
<div className="shrink-0 flex items-center justify-between gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
报
<div
className={cn(
"flex h-full min-h-0 flex-col",
useStep1Chrome
? "position-roundtable-intent-panel eightbf-roundtable-intent-panel eightbf-roundtable-step3-chat"
: "bg-slate-50/20",
)}
>
{useStep1Chrome ? (
<div className="shrink-0 flex items-center justify-between gap-3">
<div className="position-roundtable-intent-header-title">
<span aria-hidden="true" />
<h2>{headerTitle || "方案总结"}</h2>
</div>
<div className="leading-tight min-w-0">
<div className="text-xs font-bold text-slate-800 truncate">方案总结报告智能体</div>
<div className="text-[10px] text-slate-400 truncate">据各席位成果撰写 Markdown 总结报告</div>
<div className="position-roundtable-intent-header-actions">
<button
type="button"
onClick={onRetry}
disabled={phase === "creating" || phase === "streaming"}
>
重新生成
</button>
</div>
</div>
<select
id="step3-summary-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={busy || modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: busy
? "撰写中不可切换;完成后或重新生成时生效"
: "切换撰写使用的推理模型(下次生成生效)"
}
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name
? `${m.display_name} (${m.name})`
: m.name}
</option>
))}
</select>
</div>
) : (
<div className="shrink-0 flex items-center gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
报
</div>
<div className="leading-tight min-w-0">
<div className="text-xs font-bold text-slate-800 truncate">方案总结报告智能体</div>
<div className="text-[10px] text-slate-400 truncate">据各席位成果撰写 Markdown 总结报告</div>
</div>
</div>
</div>
)}
{/* 消息列表 —— 与第 2 步同款气泡(流式正文 + 工具步骤卡 + 文件卡)。 */}
<div className="relative flex-1 min-h-0 flex flex-col">
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-4 space-y-4 bg-white">
<div className="relative flex-1 min-h-0">
<div
ref={scrollRef}
className={cn(
"size-full overflow-y-auto overflow-x-hidden p-4 space-y-4",
useStep1Chrome ? "eightbf-roundtable-step3-log" : "bg-white",
)}
>
{phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
@@ -726,15 +605,14 @@ function AgentProgressPanel({
onFileCardClick={(path) => {
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
}}
taskArtifactSubmission={taskArtifactSubmission}
/>
))}
{busy && (
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
<span className="bg-primary size-1.5 animate-pulse rounded-full" />
{mdAvailable
? "报告正文实时输出,右侧沙箱同步预览 Markdown"
: "报告正文实时输出;输出结构化数据时流程图画布同步绘制"}
{mdAvailable ? "报告正文实时输出,右侧沙箱同步预览 Markdown" : "正在撰写 Markdown 总结报告"}
</div>
)}
@@ -753,243 +631,54 @@ function AgentProgressPanel({
</div>
)}
{/* 后端流程图「抽取+校验」状态条(Phase 1)。 */}
{(flowPhase === "extracting" || flowPhase === "validating") && (
<div className="inline-flex items-center gap-2 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-bold text-blue-600">
<Loader2 className="w-3 h-3 animate-spin" />
{flowPhase === "extracting" ? "结构化流程图抽取中(JSON 实时输出)…" : "流程图校验 / 修复中…"}
</div>
)}
{flowPhase === "error" && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<div className="font-bold mb-1">流程图生成失败</div>
<div className="leading-relaxed">模型可能不稳定。可在上方切换模型后重试。</div>
<button
type="button"
onClick={onRegenerateFlow}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-amber-800 underline hover:text-amber-900"
>
<RefreshCw className="w-3 h-3" />
重新生成流程图
</button>
</div>
)}
{phase === "done" && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" />
{mdAvailable
? "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告 / 流程图或提问。"
: "已生成方案总结,流程图可在画布查看;可在下方继续修改总结 / 流程图或提问。"}
? "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告或提问。"
: "已生成方案总结,可在下方继续修改报告或提问。"}
</div>
)}
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
{/* 意图模糊 → 让用户自己定(不自作主张) */}
{pendingClarification && (
<div className="shrink-0 border-t border-amber-100 bg-amber-50/60 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-amber-800">
没太理解你的意思,你是想——
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "regenerate_report", t: "重新生成报告" },
{ k: "modify_flow", t: "修改流程图" },
{ k: "regenerate_flow", t: "重新生成流程图" },
{ k: "qa", t: "只是提问" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveClarification(o.k)}
className="rounded-lg border border-amber-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-amber-800 transition hover:bg-amber-100"
>
{o.t}
</button>
))}
</div>
</div>
{!atBottom && (
<ScrollToBottomButton
onClick={scrollToBottom}
className="!bottom-1/2 translate-y-1/2"
/>
)}
</div>
{/* 流程图已生成 → 询问是否入库(入库技能可用时)。 */}
{ingestAvailable && ingestStatus === "asking" && (
<div className="shrink-0 border-t border-emerald-100 bg-emerald-50/60 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-emerald-800">
流程图已生成,是否进行数据入库?
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "now", t: "现在入库" },
{ k: "later", t: "稍后入库" },
{ k: "never", t: "不入库" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveIngestChoice(o.k)}
className="rounded-lg border border-emerald-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-emerald-800 transition hover:bg-emerald-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
{/* 入库时尚无流程图 → 协助:数据入库以流程图为核心内容,先问是否生成流程图。 */}
{ingestAvailable && pendingFlowForIngest && (
<div className="shrink-0 border-t border-amber-100 bg-amber-50/70 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-amber-800">
当前还没有生成流程图,数据入库需要流程图。是否先生成流程图?
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "generate", t: "生成流程图" },
{ k: "cancel", t: "暂不入库" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveFlowForIngest(o.k)}
className="rounded-lg border border-amber-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-amber-800 transition hover:bg-amber-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
{/* 「稍后入库」自由文字意图模糊 → 让用户在入库语境下自己定。 */}
{ingestAvailable && pendingIngestClarification && (
<div className="shrink-0 border-t border-emerald-100 bg-emerald-50/60 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-emerald-800">
没太理解你的入库意思,你是想——
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "start_ingest", t: "开始入库" },
{ k: "add_data", t: "补充内容后再入库" },
{ k: "other", t: "只是提问 / 改报告" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveIngestClarification(o.k)}
className="rounded-lg border border-emerald-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-emerald-800 transition hover:bg-emerald-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
<form onSubmit={handleAskSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
{/* 已选附件预览(发送时随指令上传到总结线程,供智能体读取) */}
{askFiles.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{askFiles.map((file, idx) => (
<span
key={`${file.name}-${file.size}-${idx}`}
className="inline-flex items-center gap-1.5 bg-white border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
>
<FileText className="w-3 h-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-slate-400">{formatBytes(file.size)}</span>
<button
type="button"
onClick={() => removeFile(idx)}
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
title="移除"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
<AutoGrowTextarea
maxHeight={224}
value={askText}
onChange={(e) => setAskText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
rows={1}
placeholder={
busy
? "生成中也可随时插话干预:发送将打断当前轮并据此继续…"
: "继续对话:改报告(如「执行路线写细些」)/ 改流程图(如「总结成结构化输出」「流程图加上验收环节」)/ 提问"
}
className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none"
/>
{/* 手动入库(入库技能可用、且已过流程图询问阶段时显示):直接在总结线程跑入库技能。 */}
{ingestAvailable &&
ingestStatus !== "asking" &&
ingestStatus !== "running" &&
ingestStatus !== "idle" && (
<button
type="button"
onClick={() => onManualIngest()}
title="进行数据入库(由结构化输出智能体调用入库技能,过程见消息列表步骤条)"
className="mb-0.5 flex h-8 shrink-0 items-center gap-1 rounded-xl border border-emerald-200 bg-emerald-50 px-2.5 text-[11px] font-bold text-emerald-700 transition hover:bg-emerald-100 active:scale-95"
>
<Database className="h-3.5 w-3.5" />
数据入库
</button>
)}
{/* 上传文件(参考第一/二步:本地选取,发送时上传到总结线程) */}
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={pickFiles} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
title="上传参考文件(智能体将读取文件内容辅助总结/修改)"
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white border border-slate-200 text-slate-500 hover:text-primary hover:bg-slate-50 transition active:scale-95"
>
<Paperclip className="h-4 w-4" />
</button>
{/* 暂停(仅生成中显示) */}
{busy && (
<button
type="button"
onClick={onPause}
title="暂停当前生成"
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-amber-100 border border-amber-200/60 text-amber-700 hover:bg-amber-200 transition active:scale-95"
>
<Pause className="h-4 w-4" />
</button>
)}
{/* 发送(随时可发:生成中发送=打断并续问) */}
<button
type="submit"
disabled={askText.trim().length === 0 && askFiles.length === 0}
title={busy ? "打断当前轮并发送" : "发送(智能体自行判断改报告 or 回答)"}
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
>
<SendHorizontal className="h-4 w-4" />
</button>
</div>
</form>
<div className="position-roundtable-intent-page eightbf-roundtable-step3-input shrink-0">
<PositionIntentInput
value={askText}
onValueChange={setAskText}
attachedFiles={askFiles}
onAddFiles={addAskFiles}
onRemoveFile={removeFile}
charactersLeft={Math.max(0, 500 - askText.length)}
isSending={busy}
canSend={askText.trim().length > 0 || askFiles.length > 0}
pendingClarification={false}
onSend={submitAsk}
onStop={onPause}
onFollowUp={() => undefined}
onSkip={() => undefined}
hideUtilityActions
onEnterRoundtable={() => undefined}
enterButtonLabel=""
intentReady={null}
hideEnterRoundtable
onManualSave={() => undefined}
onTriggerToast={() => undefined}
modelOptions={availableModels.map((model) => ({
label: model.display_name && model.display_name !== model.name
? `${model.display_name} (${model.name})`
: model.name,
value: model.name,
}))}
selectedModel={selectedModel}
onSelectedModelChange={setSelectedModel}
/>
</div>
</div>
);
@@ -1106,11 +795,13 @@ function SeatFilesModal({
threadIds,
coordinatorName,
seatNameMap,
taskArtifactSubmission,
onClose,
}: {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
seatNameMap: Record<string, string>;
taskArtifactSubmission?: TaskArtifactSubmissionController;
onClose: () => void;
}) {
const seatEntries = useMemo(() => {
@@ -1216,6 +907,25 @@ function SeatFilesModal({
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-mono text-slate-400">{formatBytes(f.sizeBytes)}</span>
<TaskArtifactSubmitButton
compact
controller={taskArtifactSubmission}
target={taskArtifactSubmission?.targetFor({
threadId: seat.threadId,
path: f.path,
name: f.name,
mimeType: f.mimeType,
agentId: seat.agentName,
agentName: seat.displayName,
}) ?? {
threadId: seat.threadId,
path: f.path,
name: f.name,
mimeType: f.mimeType,
agentId: seat.agentName,
agentName: seat.displayName,
}}
/>
<button
type="button"
onClick={() => handlePreview(seat.threadId, f)}
@@ -0,0 +1,51 @@
import { Check, Loader2, UploadCloud } from "lucide-react";
import { cn } from "@/lib/utils";
import type { TaskArtifactSubmissionController, TaskArtifactTarget } from "@/roundtable-planning/lib/task-artifact-submission";
export function TaskArtifactSubmitButton({
controller,
target,
compact = false,
className,
}: {
controller?: TaskArtifactSubmissionController;
target: TaskArtifactTarget;
compact?: boolean;
className?: string;
}) {
if (!controller?.enabled || !controller.isMarkdown(target)) return null;
const state = controller.stateFor(target);
const error = controller.errorFor(target);
const isSubmitted = state === "submitted";
const isSubmitting = state === "submitting";
const label = isSubmitted ? "已提交" : isSubmitting ? "提交中…" : "提交";
const title = error || (isSubmitted ? "该 Markdown 产物已提交到任务过程" : "提交 Markdown 过程产物");
return (
<button
type="button"
disabled={isSubmitting || isSubmitted}
aria-label={`${target.name || target.path}:${label}`}
title={title}
onClick={(event) => {
event.stopPropagation();
void controller.submit(target).catch(() => undefined);
}}
className={cn(
"inline-flex shrink-0 items-center justify-center gap-1 rounded-md border px-2 py-1 text-[10px] font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 disabled:cursor-not-allowed",
isSubmitted
? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-300"
: error
? "border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-300"
: "border-primary/35 bg-primary/10 text-primary hover:bg-primary/15",
// 沙箱头部的其他分段切换器外框为 40px 高,提交入口与之对齐。
compact && "h-10 px-3 text-xs",
className,
)}
>
{isSubmitting ? <Loader2 className="size-3 animate-spin" /> : isSubmitted ? <Check className="size-3" /> : <UploadCloud className="size-3" />}
<span>{label}</span>
</button>
);
}
@@ -24,6 +24,7 @@ import {
type IntentSummary,
} from "@/roundtable-planning/api/intent";
import { uploadFiles } from "@/core/uploads/api";
import { createClientId } from "@/lib/client-id";
import {
clarificationKey,
formatIntentClarificationAnswer,
@@ -449,9 +450,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
// Phase 4 单飞幂等键:每个回合一个稳定 id。后端据此对 (threadId, clientTurnId)
// 跨 worker 单飞——重复 / 双击命中既有回合即幂等返回,杜绝并发下双发意图。
const clientTurnId =
(typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID()) ||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const clientTurnId = createClientId();
const res = await streamIntent({
threadId: intentThreadId,
@@ -507,6 +507,14 @@ export function useStep2Orchestration(
Record<string, StubAiMessage[]>
>({});
const seatStubMessagesRef = useRef<Record<string, StubAiMessage[]>>({});
// write_file 的 tool args 会在一个 SSE response 里高频增长。ref 始终保留最新全文,
// 供自动打开 / 最终收口等同步逻辑立即读取;但不要逐帧把整份 stub 写进 React state。
// 后者会重建沙箱 ThreadContext 并让 ArtifactFileDetail 的预览 effect 连锁执行,
// 在「继续会商 + 正在写文件」的高密度帧下曾触发 Maximum update depth exceeded。
const pendingSeatStubMessagesRef = useRef<
Record<string, StubAiMessage[]> | null
>(null);
const seatStubFlushTimerRef = useRef<number | null>(null);
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(
DEFAULT_SELECTED_AGENTS,
);
@@ -552,6 +560,11 @@ export function useStep2Orchestration(
new Map(),
);
const parallelFlushTimerRef = useRef<number | null>(null);
// 非并行的 leader / 单席位流同样不能逐 token 直接 setState。SSE reader 可能在一次
// 浏览器任务里吐出大量已缓冲帧;逐帧 setState 会与消息区的 effect 连锁,最终触发
// React 的 Maximum update depth 保护。先累积到 ref,再以固定节流频率提交到界面。
const directStreamDeltaRef = useRef<Map<number, string>>(new Map());
const directStreamFlushTimerRef = useRef<number | null>(null);
// flush 时要读「当前选中席位」与「批次 bubbleId↔agentId 映射」—— 打到 ref 避免
// 把 flush 函数绑进会频繁变化的 state 依赖。
const selectedParallelSeatsRef = useRef<string[]>([]);
@@ -684,6 +697,57 @@ export function useStep2Orchestration(
return ids[agentName] ?? ids[agentName.toLowerCase()];
}, []);
/**
* 把写文件预览的 stub 以约 12fps 合并交给 React。
*
* `seatStubMessagesRef` 已在调用处同步更新,故这里的延迟只影响右侧 Markdown
* 重绘频率,不影响随后读取最新 path/content、也不影响自动打开沙箱。
*/
const flushSeatStubMessages = useCallback(() => {
seatStubFlushTimerRef.current = null;
const pending = pendingSeatStubMessagesRef.current;
if (!pending) return;
pendingSeatStubMessagesRef.current = null;
setSeatStubMessages((previous) => (previous === pending ? previous : pending));
}, []);
const scheduleSeatStubMessagesFlush = useCallback(() => {
if (seatStubFlushTimerRef.current !== null) return;
seatStubFlushTimerRef.current = window.setTimeout(
flushSeatStubMessages,
80,
);
}, [flushSeatStubMessages]);
/** 清掉未提交的流式预览,并立即发布一个确定的快照(草稿恢复 / 重置时使用)。 */
const replaceSeatStubMessages = useCallback(
(next: Record<string, StubAiMessage[]>) => {
pendingSeatStubMessagesRef.current = null;
if (seatStubFlushTimerRef.current !== null) {
window.clearTimeout(seatStubFlushTimerRef.current);
seatStubFlushTimerRef.current = null;
}
seatStubMessagesRef.current = next;
setSeatStubMessages((previous) => (previous === next ? previous : next));
},
[],
);
const resetSeatStubMessages = useCallback(() => {
replaceSeatStubMessages({});
}, [replaceSeatStubMessages]);
// 卸载时清理挂起的预览 flush,避免已离开的页面仍收到一次 setState。
useEffect(
() => () => {
if (seatStubFlushTimerRef.current !== null) {
window.clearTimeout(seatStubFlushTimerRef.current);
seatStubFlushTimerRef.current = null;
}
},
[],
);
/**
* 把工具调用幂等地写进 seat 的 stub ai message —— **同 (toolName, path)** 视为同一次
* 调用,后续帧只更新 args(LangGraph 流式期间会多次发送完整 tool_calls 覆盖 args)。
@@ -734,10 +798,11 @@ export function useStep2Orchestration(
[seatThreadId]: [nextAiMsg],
};
seatStubMessagesRef.current = nextMap;
setSeatStubMessages(nextMap);
pendingSeatStubMessagesRef.current = nextMap;
scheduleSeatStubMessagesFlush();
return { messageId: nextAiMsg.id, toolCallId };
},
[],
[scheduleSeatStubMessagesFlush],
);
/**
@@ -1026,12 +1091,6 @@ export function useStep2Orchestration(
args,
);
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
if (typeof window !== "undefined") {
console.debug(
"[roundtable-sandbox] auto-open from phase",
{ toolName, path, seatThreadId, virtualUrl },
);
}
openSeatVirtualArtifact(seatThreadId, virtualUrl, true);
},
[
@@ -1272,9 +1331,15 @@ export function useStep2Orchestration(
const setDialogueConfidence = useCallback(
(id: number, confidence: string) => {
setStep2RoundtableDialogues((prev) =>
prev.map((d) => (d.id === id ? { ...d, confidence } : d)),
);
setStep2RoundtableDialogues((prev) => {
const index = prev.findIndex((dialogue) => dialogue.id === id);
// onPhaseChange 可在一个 SSE read 内收到多帧相同的 phase。状态文字没有变化
// 时必须返回原数组;否则 React 会把无意义的新数组当成一次嵌套更新。
if (index < 0 || prev[index].confidence === confidence) return prev;
const next = [...prev];
next[index] = { ...prev[index], confidence };
return next;
});
},
[],
);
@@ -1286,30 +1351,39 @@ export function useStep2Orchestration(
key: string,
args?: Record<string, unknown>,
) => {
setStep2RoundtableDialogues((prev) =>
prev.map((d) => {
if (d.id !== bubbleId) return d;
const steps: Step2Dialogue["steps"] = d.steps ?? [];
const last = steps[steps.length - 1];
if (last && last.key === key) {
// 同 phase:tool 重复回调 —— 流式累积期 args 会持续长大(present_files
// 多文件分批、write_file 内容增长),拿最新 args 覆盖,不要早早定死。
if (args && args !== last.args) {
const next = [...steps];
next[next.length - 1] = { ...last, args };
return { ...d, steps: next };
}
return d;
}
return {
...d,
setStep2RoundtableDialogues((prev) => {
const dialogueIndex = prev.findIndex((dialogue) => dialogue.id === bubbleId);
if (dialogueIndex < 0) return prev;
const dialogue = prev[dialogueIndex];
const steps: Step2Dialogue["steps"] = dialogue.steps ?? [];
const last = steps[steps.length - 1];
let nextDialogue: Step2Dialogue;
if (last && last.key === key) {
// 同一个工具调用在流式期间会反复带来增量入参。步骤卡只需在「首次拿到
// 可见参数」时补一次;文件正文由 upstream frame 同步给沙箱,不能随每个
// content chunk 更新对话 state。
const hasVisibleArgs = (value?: Record<string, unknown>) =>
!!value && Object.keys(value).some((argKey) => argKey !== "content");
if (hasVisibleArgs(last.args) || !hasVisibleArgs(args)) return prev;
const nextSteps = [...steps];
nextSteps[nextSteps.length - 1] = { ...last, args };
nextDialogue = { ...dialogue, steps: nextSteps };
} else {
nextDialogue = {
...dialogue,
steps: [
...steps,
{ id: `${bubbleId}-${steps.length}`, key, label, ts: nowTime(), args },
],
};
}),
);
}
const next = [...prev];
next[dialogueIndex] = nextDialogue;
return next;
});
},
[],
);
@@ -1342,20 +1416,45 @@ export function useStep2Orchestration(
[],
);
/** 将非并行流的已积累增量一次性写入 React state(约 20fps)。 */
const flushDirectStreamDeltas = useCallback(() => {
directStreamFlushTimerRef.current = null;
const pending = directStreamDeltaRef.current;
if (pending.size === 0) return;
directStreamDeltaRef.current = new Map();
setStep2RoundtableDialogues((prev) => {
let changed = false;
const next = prev.map((dialogue) => {
const delta = pending.get(dialogue.id);
if (!delta) return dialogue;
changed = true;
return { ...dialogue, content: (dialogue.content ?? "") + delta };
});
return changed ? next : prev;
});
}, []);
const scheduleDirectStreamFlush = useCallback(() => {
if (directStreamFlushTimerRef.current !== null) return;
directStreamFlushTimerRef.current = window.setTimeout(flushDirectStreamDeltas, 50);
}, [flushDirectStreamDeltas]);
const appendToDialogue = useCallback((id: number, deltaText: string) => {
if (!deltaText) return;
setStep2RoundtableDialogues((prev) =>
prev.map((d) =>
d.id === id ? { ...d, content: (d.content ?? "") + deltaText } : d,
),
);
}, []);
const pending = directStreamDeltaRef.current;
pending.set(id, (pending.get(id) ?? "") + deltaText);
scheduleDirectStreamFlush();
}, [scheduleDirectStreamFlush]);
/** 整段替换某气泡正文(§6 flush:把 ref 里累积的全文一次性挂上去)。 */
const setDialogueContent = useCallback((id: number, fullText: string) => {
setStep2RoundtableDialogues((prev) =>
prev.map((d) => (d.id === id ? { ...d, content: fullText } : d)),
);
setStep2RoundtableDialogues((prev) => {
const index = prev.findIndex((dialogue) => dialogue.id === id);
if (index < 0 || prev[index].content === fullText) return prev;
const next = [...prev];
next[index] = { ...prev[index], content: fullText };
return next;
});
}, []);
/**
@@ -1372,6 +1471,7 @@ export function useStep2Orchestration(
const who = info.agentName || "智能体";
triggerToast(`「${info.failedModel}」模型${info.reason},已自动切换「${info.nextModel}」继续`);
if (isParallel) parallelStreamRef.current.delete(bubbleId);
directStreamDeltaRef.current.delete(bubbleId);
const systemBubble: Step2Dialogue = {
id: Date.now() + Math.random(),
sender: "系统提示",
@@ -1440,8 +1540,17 @@ export function useStep2Orchestration(
}
}, []);
const resetDirectStreamDeltas = useCallback(() => {
directStreamDeltaRef.current = new Map();
if (directStreamFlushTimerRef.current !== null) {
window.clearTimeout(directStreamFlushTimerRef.current);
directStreamFlushTimerRef.current = null;
}
}, []);
// 卸载兜底:清掉可能挂起的 flush 定时器。
useEffect(() => resetParallelStreams, [resetParallelStreams]);
useEffect(() => resetDirectStreamDeltas, [resetDirectStreamDeltas]);
// 选中变化 → 立刻把 ref 里已累积的全文挂给**新选中**的席位(切头像即显全部输出,
// 证明流没丢、只是之前没渲染,§6.5)。
@@ -1508,21 +1617,40 @@ export function useStep2Orchestration(
const finalizeDialogue = useCallback(
(id: number, finalContent: string, confidence?: string) => {
setStep2RoundtableDialogues((prev) =>
prev.map((d) => {
// 最终结果已经是完整文本,丢弃尚未 flush 的 token 缓冲,避免收尾后旧增量反写。
directStreamDeltaRef.current.delete(id);
if (
directStreamDeltaRef.current.size === 0 &&
directStreamFlushTimerRef.current !== null
) {
window.clearTimeout(directStreamFlushTimerRef.current);
directStreamFlushTimerRef.current = null;
}
setStep2RoundtableDialogues((prev) => {
let changed = false;
const next = prev.map((d) => {
if (d.id !== id) return d;
const next =
finalContent && finalContent.trim().length > 0
? finalContent
: d.content || "(无文本输出)";
if (
d.content === next &&
d.confidence === (confidence ?? d.confidence) &&
d.streaming === false
) {
return d;
}
changed = true;
return {
...d,
content: next,
confidence: confidence ?? d.confidence,
streaming: false,
};
}),
);
});
return changed ? next : prev;
});
},
[],
);
@@ -2552,7 +2680,7 @@ export function useStep2Orchestration(
seatSkillDirective: seatSkillDirectivesRef.current[agentId] ?? false,
signal: subAborter.signal,
// §6:并行席位走 ref 全量累积 + 节流 flush(只渲染选中的);单节点
// stage 实际串行,1 路流直接 setState 不卡,沿用 appendToDialogue。
// stage 也经 appendToDialogue 的节流缓冲,避免高频 SSE 帧嵌套更新。
onModelSwitch: makeModelSwitchHandler(bubbleId, isParallel),
onTextDelta: (text) =>
isParallel ? ingestParallelDelta(bubbleId, text) : appendToDialogue(bubbleId, text),
@@ -3182,6 +3310,7 @@ export function useStep2Orchestration(
// 需要 ref 立刻可读到作业的真实线程,否则读到旧值导致接管失败。
threadIdsRef.current = snapshot.threadIds;
coordinatorNameRef.current = snapshot.coordinatorName;
resetDirectStreamDeltas();
// 历史快照是**静止记录**:任何残留「流式/加载中」的气泡(用户中途点了「停止」,
// 或草稿在流式期间被自动保存)都强制收尾为静止态。否则用户点聊天记录回看时,这些
// 气泡仍显示「调度中 / 等待响应 / 正在输出…」+ 加载光标,让人误以为还在进行(其实早已结束)。
@@ -3203,15 +3332,14 @@ export function useStep2Orchestration(
setConsensusPercentage(snapshot.consensusPercentage);
setBudgetLimit(snapshot.budgetLimit);
const restoredStubs = snapshot.seatStubMessages ?? {};
seatStubMessagesRef.current = restoredStubs;
setSeatStubMessages(restoredStubs);
replaceSeatStubMessages(restoredStubs);
// 草稿里历史 write_file 的虚拟 URL 与当前会话无关 —— 清空守卫,允许新 run 触发自动打开。
hasAutoOpenedRef.current = new Set();
// 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上,
// 调用方需要的话再 resetInitGate()。
step2InitRef.current = true;
},
[],
[replaceSeatStubMessages, resetDirectStreamDeltas],
);
// 「查看=恢复流程」:后台作业 SSE 帧 → 第二步实时画面(只更新展示数据,不动
@@ -3225,13 +3353,12 @@ export function useStep2Orchestration(
lastLeaderContent: string;
}) => {
setStep2RoundtableDialogues(frame.dialogues);
seatStubMessagesRef.current = frame.seatStubMessages;
setSeatStubMessages(frame.seatStubMessages);
replaceSeatStubMessages(frame.seatStubMessages);
setConsensusPercentage(frame.consensusPercentage);
setHasConsensus(frame.hasConsensus);
if (frame.lastLeaderContent) setLastLeaderContent(frame.lastLeaderContent);
},
[],
[replaceSeatStubMessages],
);
// 「后台挂起接管」:取消后端作业 + hydrateFromDraft(恢复作业真实线程/协调名/对话)后,
@@ -3279,11 +3406,11 @@ export function useStep2Orchestration(
setSelectedParallelSeats([]);
setDagNodeStates({});
resetParallelStreams();
resetDirectStreamDeltas();
setActiveArtifactThreadId(null);
artifactPathsRef.current = [];
setArtifacts([]);
seatStubMessagesRef.current = {};
setSeatStubMessages({});
resetSeatStubMessages();
hasAutoOpenedRef.current = new Set();
pendingInjectPromptRef.current = null;
dagPlanExecutedRef.current = false;
@@ -3292,7 +3419,14 @@ export function useStep2Orchestration(
gatheredDeliveriesRef.current = [];
deselectArtifact();
step2InitRef.current = false;
}, [abortAllInFlight, deselectArtifact, setArtifacts, resetParallelStreams]);
}, [
abortAllInFlight,
deselectArtifact,
setArtifacts,
resetParallelStreams,
resetDirectStreamDeltas,
resetSeatStubMessages,
]);
return {
selectedAgents,
@@ -16,16 +16,13 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import { apiFetch } from "@/core/api";
import { uploadFiles } from "@/core/uploads/api";
import type {
DraftStep3SummarySnapshot,
} from "@/roundtable-planning/api/drafts";
import {
buildArtifactDownloadUrl,
initStructureThread,
initSummaryThread,
listSessionArtifacts,
streamMultiAgent,
type StreamMultiAgentRequest,
type StreamPhase,
@@ -78,6 +75,8 @@ interface UseStep3SummaryOptions {
* 普通手动会商为 false —— 结构化输出仍按需(用户输入框打「流程图」才出)。
*/
autoFlow?: boolean;
/** 第三步是否允许流程图分支。当前报告工作台只交付 Markdown,默认关闭。 */
flowEnabled?: boolean;
/** 草稿里已存的上次总结结果 —— 有则进入时恢复展示,不重新生成。 */
savedSummary?: DraftStep3SummarySnapshot | null;
/** 一次总结成功完成时回调(传出 md/thread/model/时间/收尾语),供上层存草稿。 */
@@ -485,6 +484,7 @@ export function useStep3Summary(
coordinatorPrompt,
chainLevels,
autoFlow,
flowEnabled = false,
savedSummary,
onSummarySaved,
ingestAvailable,
@@ -573,6 +573,10 @@ export function useStep3Summary(
>(null);
const stubRef = useRef<Record<string, StubAiMessage[]>>({});
// write_file 会在流式过程中多次带回完整 tool_calls。stub 保持同步更新供预览读取,
// React 状态则节流发布,避免每一个工具帧都触发沙箱和消息列表的同步重渲染。
const pendingStubMessagesRef = useRef<Record<string, StubAiMessage[]> | null>(null);
const stubFlushTimerRef = useRef<number | null>(null);
const runningRef = useRef(false);
const aborterRef = useRef<AbortController | null>(null);
// 真线程(init 后或恢复时已知的后端 thread id)—— ask() 续问时复用它,保留报告上下文。
@@ -589,6 +593,47 @@ export function useStep3Summary(
// run() 的「自动出图」与用户「跳过报告直接生成流程图」的手动出图竞态,各自建卡片 → 双「调度中」。
const flowAborterRef = useRef<AbortController | null>(null);
const activeFlowBubbleRef = useRef<number | null>(null);
const autoArtifactTimerRef = useRef<number | null>(null);
/** 将写文件预览以约 12fps 合并发布给 React,读取 stub 的流逻辑始终使用最新 ref。 */
const flushStubMessages = useCallback(() => {
stubFlushTimerRef.current = null;
const pending = pendingStubMessagesRef.current;
if (!pending) return;
pendingStubMessagesRef.current = null;
setSeatStubMessages((previous) => (previous === pending ? previous : pending));
}, []);
const scheduleStubMessagesFlush = useCallback(() => {
if (stubFlushTimerRef.current !== null) return;
stubFlushTimerRef.current = window.setTimeout(flushStubMessages, 80);
}, [flushStubMessages]);
/** 重置/恢复草稿时立即发布确定快照,同时取消未提交的流式预览。 */
const replaceStubMessages = useCallback(
(next: Record<string, StubAiMessage[]>) => {
pendingStubMessagesRef.current = null;
if (stubFlushTimerRef.current !== null) {
window.clearTimeout(stubFlushTimerRef.current);
stubFlushTimerRef.current = null;
}
stubRef.current = next;
setSeatStubMessages((previous) => (previous === next ? previous : next));
},
[],
);
useEffect(
() => () => {
if (stubFlushTimerRef.current !== null) {
window.clearTimeout(stubFlushTimerRef.current);
}
if (autoArtifactTimerRef.current !== null) {
window.clearTimeout(autoArtifactTimerRef.current);
}
},
[],
);
// ── 对话气泡三段式(append → delta → finalize)+ 工具步骤(与第 2 步同款)──
const appendStreamingDialogue = useCallback(
@@ -683,12 +728,15 @@ export function useStep3Summary(
const steps: Step2Dialogue["steps"] = d.steps ?? [];
const last = steps[steps.length - 1];
if (last && last.key === key) {
if (args && args !== last.args) {
const next = [...steps];
next[next.length - 1] = { ...last, args };
return { ...d, steps: next };
}
return d;
// write_file 的流式工具帧会反复带着新的 content 对象。步骤卡只需要
// 首次拿到可见参数(如 path)时补充一次,正文已经由 stub 节流同步给沙箱。
// 不能为每个 content chunk 更新对话 state,否则会造成嵌套更新风暴。
const hasVisibleArgs = (value?: Record<string, unknown>) =>
!!value && Object.keys(value).some((argKey) => argKey !== "content");
if (hasVisibleArgs(last.args) || !hasVisibleArgs(args)) return d;
const next = [...steps];
next[next.length - 1] = { ...last, args };
return { ...d, steps: next };
}
return {
...d,
@@ -762,14 +810,15 @@ export function useStep3Summary(
const nextMsg: StubAiMessage = { ...base, tool_calls: nextToolCalls };
const nextMap = { ...stubRef.current, [threadId]: [nextMsg] };
stubRef.current = nextMap;
setSeatStubMessages(nextMap);
pendingStubMessagesRef.current = nextMap;
scheduleStubMessagesFlush();
// 记住当前报告全文(用于续问写底 / 存草稿)。
if (typeof storeArgs.content === "string" && storeArgs.content.trim()) {
currentMdRef.current = storeArgs.content;
}
return { messageId: nextMsg.id, toolCallId };
},
[],
[scheduleStubMessagesFlush],
);
const buildWriteFileVirtualUrl = useCallback(
@@ -816,14 +865,36 @@ export function useStep3Summary(
const autoOpenArtifact = useCallback(
(threadId: string, virtualUrl: string) => {
lastAutoThreadRef.current = threadId;
if (!previewEnabledRef.current) {
// 视图不在前台:只记录,不抢沙箱(回到视图时 setPreviewEnabled 再接管)。
lastAutoOpenedUrlRef.current = virtualUrl;
lastAutoThreadRef.current = threadId;
return;
}
if (lastAutoOpenedUrlRef.current === virtualUrl) return;
openArtifact(threadId, virtualUrl);
if (
lastAutoOpenedUrlRef.current === virtualUrl &&
lastAutoThreadRef.current === threadId
) {
return;
}
// onPhaseChange 在 SSE 回调栈中触发。延后一帧再打开沙箱,避免工具帧与
// ArtifactProvider/Markdown 预览的状态更新嵌套在同一次 React 更新里。
lastAutoOpenedUrlRef.current = virtualUrl;
lastAutoThreadRef.current = threadId;
if (autoArtifactTimerRef.current !== null) {
window.clearTimeout(autoArtifactTimerRef.current);
}
autoArtifactTimerRef.current = window.setTimeout(() => {
autoArtifactTimerRef.current = null;
if (
!previewEnabledRef.current ||
lastAutoOpenedUrlRef.current !== virtualUrl ||
lastAutoThreadRef.current !== threadId
) {
return;
}
openArtifact(threadId, virtualUrl);
}, 80);
},
[openArtifact],
);
@@ -953,61 +1024,24 @@ export function useStep3Summary(
[upsertStub, buildWriteFileVirtualUrl, openArtifact, openArtifactPreview],
);
const openRealArtifactFallback = useCallback(
async (threadId: string) => {
if (!previewEnabledRef.current) return; // 不在前台不抢沙箱
try {
const res = await listSessionArtifacts(threadId);
const file = res.files.find((f) => /\.md$/i.test(f.name)) ?? res.files[0];
if (!file) return;
setActiveArtifactThreadId(threadId);
setArtifacts([file.path]);
selectArtifact(file.path, false);
setArtifactsOpen(true);
} catch {
/* 列文件失败就保持当前状态 */
}
},
[setArtifacts, selectArtifact, setArtifactsOpen],
);
// 收尾对账:以沙箱真实 md 文件为准回灌 stub,拿到完整 Markdown。
// `force` 用于 ask():增量改后文件可能等长/更短,长度守卫会挡掉改动,故强制以真实文件为准。
// 收尾时直接使用流式 write_file 捕获的 Markdown。当前任务沙箱的文件列表与下载
// 端点不是同一套存储:列表返回的 /mnt/... 路径会被预览器拿去请求 /artifacts/mnt/...
// 并稳定返回 404。此处若再尝试“真实文件对账”,既拿不到更完整的内容,还会触发
// 预览器的失败重试和状态更新循环。write_file 的 stub 在每个上游帧同步更新,因此它
// 是本步骤报告的唯一预览/保存来源。
const reconcileStubFromRealFile = useCallback(
async (
threadId: string,
opts?: { signal?: AbortSignal; force?: boolean },
_opts?: { signal?: AbortSignal; force?: boolean },
): Promise<string> => {
const writeTc = stubRef.current[threadId]?.[0]?.tool_calls.find(
(tc) => tc.name === "write_file",
);
const stubPath =
typeof (writeTc?.args as { path?: unknown })?.path === "string"
? ((writeTc!.args as { path?: string }).path as string)
: SUMMARY_OUTPUT_PATH;
const stubContent =
typeof (writeTc?.args as { content?: unknown })?.content === "string"
? ((writeTc!.args as { content?: string }).content as string)
: "";
try {
const list = await listSessionArtifacts(threadId, { signal: opts?.signal });
const mdFile = list.files.find((f) => /\.md$/i.test(f.name)) ?? null;
if (!mdFile) return stubContent;
const res = await apiFetch(buildArtifactDownloadUrl(threadId, mdFile.path), {
signal: opts?.signal,
});
if (!res.ok) return stubContent;
const real = await res.text();
if (real.trim() && (opts?.force || real.length > stubContent.length)) {
upsertStub(threadId, "write_file", { path: stubPath, content: real });
return real;
}
} catch {
/* 拉取失败退回 stub 内容 */
}
return stubContent;
return typeof (writeTc?.args as { content?: unknown })?.content === "string"
? ((writeTc!.args as { content?: string }).content as string)
: "";
},
[upsertStub],
[],
);
const abort = useCallback(() => {
@@ -1112,11 +1146,8 @@ export function useStep3Summary(
});
if (aborter.signal.aborted) return;
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0;
// 流程图轮本就不产文件,不要去沙箱兜底翻文件(也避免把别的文件弹出来)。
if (!hasStubFile && !seedThreadId && !flowRound) await openRealArtifactFallback(threadId);
// 以真实 md 回灌 stub,拿到完整报告(ask 强制对账,防增量改后被长度守卫挡掉)。
// 以流式 write_file stub 收尾。不要把沙箱文件列表返回的真实路径塞给全局预览:
// 该路径在 artifacts 下载端点不存在,会导致 404 与预览循环。
const md = await reconcileStubFromRealFile(threadId, {
signal: aborter.signal,
force: forceReconcile,
@@ -1155,7 +1186,6 @@ export function useStep3Summary(
appendStepIfNew,
handleArtifactToolPhase,
handleUpstreamFrame,
openRealArtifactFallback,
reconcileStubFromRealFile,
finalizeDialogue,
seedStubWithCurrentMd,
@@ -1301,6 +1331,9 @@ export function useStep3Summary(
/** 仅重新生成流程图(不重跑 md 报告):模型挂了切模型后重试 / 手动重画。 */
const regenerateFlow = useCallback(() => {
// 第三步报告工作台已关闭流程图交付。历史草稿/旧按钮即使仍持有这个回调,
// 也不能在总结收尾后意外启动结构化抽取。
if (!flowEnabled) return;
if (!currentMdRef.current.trim() && seatDeliveries.length === 0) return;
aborterRef.current?.abort();
// 重画流程图 = 重新分析 → 先复位入库状态(隐藏旧图的入库询问/按钮),新图生成完会重新询问。
@@ -1308,7 +1341,7 @@ export function useStep3Summary(
const aborter = new AbortController();
aborterRef.current = aborter;
void runFlowExtract(aborter);
}, [runFlowExtract, seatDeliveries, resetIngestState]);
}, [flowEnabled, runFlowExtract, seatDeliveries, resetIngestState]);
/** Phase 3:画布手动改/删节点后回写图 + 持久化(序列化进草稿快照)。 */
const updateFlowGraph = useCallback(
@@ -1335,10 +1368,10 @@ export function useStep3Summary(
setPhase("creating");
setDialogues([]);
lastAutoOpenedUrlRef.current = null;
stubRef.current = {};
lastAutoThreadRef.current = null;
replaceStubMessages({});
currentMdRef.current = "";
liveThreadRef.current = null;
setSeatStubMessages({});
setFlowGraphState(null);
setFlowPhase("idle");
// 清空对话同时复位流程图串行化句柄(旧气泡已随 setDialogues([]) 移除,不能再被复用)。
@@ -1359,7 +1392,7 @@ export function useStep3Summary(
try {
// 编排提示里明确要求了结构化输出/流程图 → 首轮就走流程图模式(不写 md,直出 flow-json)。
// 但 autoFlow(taskId/深链)模式要「先 md 再结构化输出」,故首轮强制走 md(忽略 detectFlowIntent)。
const flowMode = !autoFlow && detectFlowIntent(coordinatorPrompt);
const flowMode = flowEnabled && !autoFlow && detectFlowIntent(coordinatorPrompt);
flowActiveRef.current = flowMode; // 流式结束后会按真实产物校正
const prompt = buildSummaryPrompt({
intentReady,
@@ -1381,6 +1414,7 @@ export function useStep3Summary(
// ask() 会先 abort() 本轮(aborterRef 被置空/换成新 aborter)→ 这里不再自动出图,
// 由 ask 的手动出图独占,避免 auto + manual 双卡片。
if (
flowEnabled &&
autoFlow &&
!aborter.signal.aborted &&
aborterRef.current === aborter &&
@@ -1404,11 +1438,13 @@ export function useStep3Summary(
consensusText,
coordinatorPrompt,
autoFlow,
flowEnabled,
appendStreamingDialogue,
finalizeDialogue,
streamOnce,
runFlowExtract,
resetIngestState,
replaceStubMessages,
]);
/**
@@ -1566,6 +1602,12 @@ export function useStep3Summary(
(choice: "generate" | "cancel") => {
setPendingFlowForIngest(false);
if (choice === "generate") {
if (!flowEnabled) {
// 老草稿可能遗留「先生成流程图再入库」的协助卡;报告模式不再提供这条链路。
setIngestStatus("later");
persistSummary();
return;
}
// 重置为 idle —— 流程图生成完成后 runFlowExtract 在 idle 时会自动询问入库。
setIngestStatus("idle");
regenerateFlow();
@@ -1660,6 +1702,11 @@ export function useStep3Summary(
await runIngest();
return;
}
// 当前第三步只维护 Markdown 报告。即使意图分类命中旧的流程图关键词,
// 也按“重写报告”处理,绝不启动流程图抽取请求。
if (!flowEnabled && (intent === "regenerate_flow" || intent === "modify_flow")) {
intent = "regenerate_report";
}
if (intent === "regenerate_flow" || intent === "modify_flow") {
// 重新/修改流程图 = 重新分析 → 先复位入库状态(隐藏旧图入库询问/按钮),
// runFlowExtract 出新图后会在 idle 时重新询问是否入库。
@@ -1809,6 +1856,7 @@ export function useStep3Summary(
runFlowExtract,
runIngest,
resetIngestState,
flowEnabled,
],
);
@@ -1902,7 +1950,7 @@ export function useStep3Summary(
// 合并:合成 stub 保住「恢复后预览」锚点,历史 stub 保住各气泡步骤卡的真线程预览。
const effectiveStub =
savedStubs && Object.keys(savedStubs).length ? { ...stub, ...savedStubs } : stub;
stubRef.current = effectiveStub;
replaceStubMessages(effectiveStub);
currentMdRef.current = snap.md;
liveThreadRef.current = realThreadId;
// 快照收尾语带 flow-json → 上次主产物是流程图,恢复后续问沿用流程图语境。
@@ -1919,7 +1967,6 @@ export function useStep3Summary(
lastAutoOpenedUrlRef.current = null;
lastAutoThreadRef.current = null;
}
setSeatStubMessages(effectiveStub);
setSummaryThreadId(synthThreadId);
setActiveArtifactThreadId(synthThreadId);
@@ -1962,7 +2009,7 @@ export function useStep3Summary(
// 写文件(live write_file 流式)时才自动打开;恢复历史时用户点气泡里的
// 「点击预览」步骤卡随时可开(stub 已种好)。
},
[abort, buildWriteFileVirtualUrl],
[abort, buildWriteFileVirtualUrl, replaceStubMessages],
);
const busy = phase === "creating" || phase === "streaming";
@@ -0,0 +1,215 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { apiFetch } from "@/core/api/fetch-client";
import {
listRoundtableArtifactSubmissions,
saveRoundtableArtifactSubmission,
type RoundtableArtifactReference,
type RoundtableArtifactSubmissionRecord,
} from "@/roundtable-planning/api/artifact-submissions";
import { buildArtifactDownloadUrl, type ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import type {
TaskArtifactSubmissionConfig,
TaskArtifactSubmissionController,
TaskArtifactSubmissionState,
TaskArtifactTarget,
TaskProcessReportPayload,
} from "@/roundtable-planning/lib/task-artifact-submission";
type StatusEntry = {
state: TaskArtifactSubmissionState;
error: string | null;
record?: RoundtableArtifactSubmissionRecord;
};
function normalizedPath(path: string): string {
const trimmed = path.trim();
if (!trimmed.startsWith("write-file:")) return trimmed;
try {
return decodeURIComponent(new URL(trimmed).pathname).trim();
} catch {
return trimmed;
}
}
function fileName(path: string): string {
const normalized = normalizedPath(path);
return normalized.split("/").filter(Boolean).pop() || normalized || "产物.md";
}
function isMarkdownTarget(target: TaskArtifactTarget): boolean {
return /\.(md|markdown)$/i.test(normalizedPath(target.path))
|| target.mimeType?.toLowerCase().includes("markdown") === true;
}
function recordKey(target: TaskArtifactTarget): string {
const source = target.agentId?.trim() || `thread:${target.threadId}`;
return `${source}\u001f${normalizedPath(target.path)}\u001f0`;
}
function payloadFrom(
config: TaskArtifactSubmissionConfig,
target: TaskArtifactTarget,
content: string,
submitStatus: 0 | 1,
): TaskProcessReportPayload {
const name = target.name?.trim() || fileName(target.path);
const agentId = target.agentId?.trim() || target.threadId;
return {
taskId: config.taskId,
agentId,
agentName: target.agentName?.trim() || agentId,
content,
position: config.position,
reportName: name,
submitStatus,
};
}
/**
* Bridges the task system's process-report API and DeerFlow's task-scoped
* submission ledger. The ledger is read on mount, so "已提交" survives reloads
* and is shared by all users opening the same task.
*/
export function useTaskArtifactSubmission(
config: TaskArtifactSubmissionConfig | undefined,
threadIds: ThreadIdMap | null,
agentNameToRole: Record<string, { displayName: string }> | undefined,
): TaskArtifactSubmissionController | undefined {
const [entries, setEntries] = useState<Record<string, StatusEntry>>({});
const entriesRef = useRef(entries);
entriesRef.current = entries;
const pendingRef = useRef(new Map<string, Promise<RoundtableArtifactSubmissionRecord | null>>());
const resolveTarget = useCallback((target: TaskArtifactTarget): TaskArtifactTarget => {
const normalized = normalizedPath(target.path);
const matchedAgentId = target.agentId || Object.entries(threadIds ?? {})
.find(([, threadId]) => threadId === target.threadId)?.[0];
const role = matchedAgentId ? agentNameToRole?.[matchedAgentId.toLowerCase()] : undefined;
return {
...target,
path: normalized,
name: target.name || fileName(normalized),
mimeType: target.mimeType || (isMarkdownTarget({ ...target, path: normalized }) ? "text/markdown" : null),
agentId: matchedAgentId || target.threadId,
agentName: target.agentName || role?.displayName || matchedAgentId || target.threadId,
};
}, [agentNameToRole, threadIds]);
useEffect(() => {
if (!config?.taskId) {
setEntries({});
return;
}
let cancelled = false;
void Promise.all([
listRoundtableArtifactSubmissions(config.taskId, true),
listRoundtableArtifactSubmissions(config.taskId, false),
]).then(([submitted, registered]) => {
if (cancelled) return;
const next: Record<string, StatusEntry> = {};
for (const record of [...submitted, ...registered]) {
const key = `${record.artifact.sourceKey}\u001f${normalizedPath(record.artifact.path)}\u001f${record.artifact.revision}`;
next[key] = {
state: record.submitted ? "submitted" : "idle",
error: null,
record,
};
}
setEntries(next);
}).catch((error) => {
// State recovery is additive. A transient read failure must not prevent a
// user from submitting a Markdown artifact from the current session.
console.warn("[roundtable] 读取任务产物提交状态失败", error);
});
return () => { cancelled = true; };
}, [config?.taskId]);
const submit = useCallback(async (rawTarget: TaskArtifactTarget) => {
if (!config || !isMarkdownTarget(rawTarget)) return null;
const target = resolveTarget(rawTarget);
const key = recordKey(target);
const existingPending = pendingRef.current.get(key);
if (existingPending) return existingPending;
const existing = entriesRef.current[key];
if (existing?.state === "submitted" && existing.record) return existing.record;
const operation = (async () => {
setEntries((current) => ({ ...current, [key]: { ...current[key], state: "submitting", error: null } }));
try {
let content = target.content;
if (content == null) {
const response = await apiFetch(buildArtifactDownloadUrl(target.threadId, target.path), { method: "GET" });
if (!response.ok) throw new Error(`读取 Markdown 产物失败(${response.status})`);
content = await response.text();
}
let record = entriesRef.current[key]?.record;
let reportId = record?.taskDeliveryId || undefined;
if (!reportId) {
const receipt = await config.createProcessReport(payloadFrom(config, target, content, 0));
reportId = receipt.id;
const artifact: RoundtableArtifactReference = {
threadId: target.threadId,
sourceKey: target.agentId || `thread:${target.threadId}`,
path: target.path,
name: target.name || fileName(target.path),
mimeType: target.mimeType || "text/markdown",
revision: 0,
};
record = await saveRoundtableArtifactSubmission(config.taskId, {
submitted: false,
registerOnly: true,
taskDeliveryId: reportId,
artifact,
});
}
await config.updateProcessReport(reportId, payloadFrom(config, target, content, 1));
const submitted = await saveRoundtableArtifactSubmission(config.taskId, {
submitted: true,
taskDeliveryId: reportId,
expectedVersion: record?.version,
artifact: {
threadId: target.threadId,
sourceKey: target.agentId || `thread:${target.threadId}`,
path: target.path,
name: target.name || fileName(target.path),
mimeType: target.mimeType || "text/markdown",
revision: 0,
},
});
setEntries((current) => ({ ...current, [key]: { state: "submitted", error: null, record: submitted } }));
return submitted;
} catch (error) {
const message = error instanceof Error ? error.message : "提交产物失败,请重试";
setEntries((current) => ({ ...current, [key]: { ...current[key], state: "error", error: message } }));
throw error;
} finally {
pendingRef.current.delete(key);
}
})();
pendingRef.current.set(key, operation);
return operation;
}, [config, resolveTarget]);
const submittedRecords = useMemo(
() => Object.values(entries)
.filter((entry) => entry.state === "submitted" && entry.record?.submitted)
.map((entry) => entry.record as RoundtableArtifactSubmissionRecord),
[entries],
);
return useMemo(() => {
if (!config) return undefined;
return {
enabled: true,
isMarkdown: isMarkdownTarget,
targetFor: resolveTarget,
stateFor: (target) => entries[recordKey(resolveTarget(target))]?.state || "idle",
errorFor: (target) => entries[recordKey(resolveTarget(target))]?.error || null,
submit,
submittedRecords,
} satisfies TaskArtifactSubmissionController;
}, [config, entries, resolveTarget, submit, submittedRecords]);
}
@@ -95,6 +95,11 @@ export interface BusinessChain {
gatherFirst?: boolean;
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
isPublic?: boolean;
/**
* 启停状态。缺省 / undefined = 启用(存量链条无该字段时按启用处理)。
* 停用后管理弹窗仍可见,选链弹窗 / 公共入口不再选用。
*/
enabled?: boolean;
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
isMine?: boolean;
/** 配置者(owner)的可读用户名(后端解析的 email);解析失败时为空。 */
@@ -151,6 +156,11 @@ export interface SeatSelectionResult {
export const CHAIN_MIN_SEATS = 2;
export const CHAIN_MAX_SEATS = 20;
/** 启停状态:缺省 / undefined 视为启用(存量链条无该字段)。 */
export function isChainEnabled(chain: Pick<BusinessChain, "enabled">): boolean {
return chain.enabled !== false;
}
/**
* 由一条业务链条 + 当前候选智能体,构造 `SeatSelectionResult`。
*
@@ -0,0 +1,92 @@
import type { RoundtableArtifactSubmissionRecord } from "@/roundtable-planning/api/artifact-submissions";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import type { ChainSeat } from "@/roundtable-planning/lib/business-chain";
import { getDisplayNameFor, type SelectedAgent } from "@/roundtable-planning/lib/roundtable-constants";
function norm(value: string | null | undefined): string {
return (value ?? "").trim().toLowerCase();
}
function sourceKeyOf(record: RoundtableArtifactSubmissionRecord): string {
const raw = (record.artifact.sourceKey ?? "").trim();
if (!raw || raw.toLowerCase().startsWith("thread:")) return "";
return raw;
}
/**
* Match a chain seat to a submitted Markdown artifact.
*
* Submissions are keyed by the graph agent name (`threadIds` key) or UUID;
* chain seats expose both `agent_id` and display `name`.
*/
export function findSeatSubmission(
seat: ChainSeat,
records: RoundtableArtifactSubmissionRecord[],
selectedAgents: SelectedAgent[],
threadIds: ThreadIdMap | null,
): RoundtableArtifactSubmissionRecord | null {
const selected = selectedAgents.find((agent) => agent.agent_id === seat.agent_id);
const aliases = new Set(
[
seat.agent_id,
seat.name,
selected?.agent_id,
selected?.name,
selected ? getDisplayNameFor(selected) : "",
]
.map(norm)
.filter(Boolean),
);
const hits = records.filter((record) => {
if (!record.submitted) return false;
const sourceKey = norm(sourceKeyOf(record));
if (sourceKey && aliases.has(sourceKey)) return true;
if (!threadIds) return false;
const owner = Object.entries(threadIds).find(([, id]) => id === record.artifact.threadId)?.[0];
if (owner && aliases.has(norm(owner))) return true;
for (const alias of aliases) {
const mapped = threadIds[alias];
if (mapped && mapped === record.artifact.threadId) return true;
}
const byName = threadIds[seat.name] ?? (selected ? threadIds[selected.name] : undefined);
return Boolean(byName && byName === record.artifact.threadId);
});
if (hits.length === 0) return null;
return hits
.slice()
.sort((a, b) => (b.submittedAt ?? "").localeCompare(a.submittedAt ?? ""))[0] ?? null;
}
/** Identity keys the flowchart can match against `seat.agent_id` / `seat.name`. */
export function submittedSeatKeysFor(
seats: ChainSeat[],
records: RoundtableArtifactSubmissionRecord[],
selectedAgents: SelectedAgent[],
threadIds: ThreadIdMap | null,
): string[] {
const keys = new Set<string>();
for (const record of records) {
if (!record.submitted) continue;
const sourceKey = sourceKeyOf(record);
if (sourceKey) keys.add(norm(sourceKey));
if (threadIds) {
const owner = Object.entries(threadIds).find(([, id]) => id === record.artifact.threadId)?.[0];
if (owner) keys.add(norm(owner));
}
}
for (const seat of seats) {
if (!findSeatSubmission(seat, records, selectedAgents, threadIds)) continue;
keys.add(norm(seat.agent_id));
if (seat.name) keys.add(norm(seat.name));
}
for (const agent of selectedAgents) {
const seat = { agent_id: agent.agent_id, name: agent.name };
if (!findSeatSubmission(seat, records, selectedAgents, threadIds)) continue;
keys.add(norm(agent.agent_id));
keys.add(norm(agent.name));
keys.add(norm(getDisplayNameFor(agent)));
}
return [...keys];
}
@@ -0,0 +1,42 @@
import type { RoundtableArtifactSubmissionRecord } from "@/roundtable-planning/api/artifact-submissions";
export interface TaskProcessReportPayload {
taskId: string;
agentId: string;
agentName: string;
content: string;
position: number;
reportName: string;
submitStatus: 0 | 1;
}
export interface TaskArtifactSubmissionConfig {
/** External task-system id, not the local roundtable draft storage key. */
taskId: string;
position: number;
createProcessReport: (payload: TaskProcessReportPayload) => Promise<{ id: string }>;
updateProcessReport: (reportId: string, payload: TaskProcessReportPayload) => Promise<void>;
}
export interface TaskArtifactTarget {
threadId: string;
path: string;
name?: string;
mimeType?: string | null;
agentId?: string;
agentName?: string;
content?: string;
}
export type TaskArtifactSubmissionState = "idle" | "submitting" | "submitted" | "error";
export interface TaskArtifactSubmissionController {
enabled: boolean;
isMarkdown: (target: TaskArtifactTarget) => boolean;
targetFor: (target: TaskArtifactTarget) => TaskArtifactTarget;
stateFor: (target: TaskArtifactTarget) => TaskArtifactSubmissionState;
errorFor: (target: TaskArtifactTarget) => string | null;
submit: (target: TaskArtifactTarget) => Promise<RoundtableArtifactSubmissionRecord | null>;
/** Task-scoped ledger rows that have been formally submitted. */
submittedRecords: RoundtableArtifactSubmissionRecord[];
}
@@ -94,7 +94,7 @@ import { flowNodeMeta, type FlowNodeType } from "@/roundtable-planning/lib/flow-
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
import { BusinessChainListDialog } from "@/roundtable-planning/components/BusinessChainListDialog";
import { DispatchChainJobModal } from "@/roundtable-planning/components/DispatchChainJobModal";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import { Step1Panel, type Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
import { readActionDetail, readCopTaskDetail } from "@/core/auth/task-deeplink";
import { Step2Panel } from "@/roundtable-planning/components/Step2Panel";
import { Step3Panel } from "@/roundtable-planning/components/Step3Panel";
@@ -102,7 +102,9 @@ import type {
DraftStep2RunSnapshot,
DraftStep2Snapshot,
DraftStep3Snapshot,
DraftStep3SummarySnapshot,
} from "@/roundtable-planning/api/drafts";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import {
useStep1Intent,
type InteractiveMessage,
@@ -132,7 +134,9 @@ import {
} from "@/roundtable-planning/lib/seat-filter";
import {
buildChainSeatSelection,
isChainEnabled,
type BusinessChain,
type ChainSeat,
type SeatSelectionResult,
} from "@/roundtable-planning/lib/business-chain";
import { DEFAULT_SEAT_MODE, type SeatMode } from "@/roundtable-planning/lib/seat-mode";
@@ -152,6 +156,16 @@ import { listAgents } from "@/core/agents";
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
import { emitWujieEvent, isWujieEnv, onWujieEvent, readWujieProps } from "@/core/embed/wujie";
import { collectRoundtableDeliverables } from "@/roundtable-planning/lib/roundtable-deliverables";
import { useTaskArtifactSubmission } from "@/roundtable-planning/hooks/useTaskArtifactSubmission";
import type { TaskArtifactSubmissionConfig } from "@/roundtable-planning/lib/task-artifact-submission";
import {
findSeatSubmission,
submittedSeatKeysFor,
} from "@/roundtable-planning/lib/match-seat-submission";
import {
AnalysisResultDetailDialog,
type AnalysisResultCardItem,
} from "@/components/analysis-results";
/**
* 业务码 → 任务工作区业务码(``TaskBusiness``),用于深链 taskId 模式底部「快捷跳转」栏
@@ -298,8 +312,37 @@ export default function RoundtablePlanningPage({
toolbarSlot,
/** 独立业务页在第一步左上角替换默认业务链选择区的内容。 */
entryRailBusinessChainSlot,
/** 独立业务页在第一步左上角提供自己的任务卡片;不触发原深链任务接口。 */
entryRailTaskCardSlot,
/** 独立业务页传给意图智能体的隐藏任务上下文。 */
taskContext,
/** 独立业务页的任务作用域,仅用于左栏记录筛选,不改变原深链逻辑。 */
entryRailTaskId,
/** 独立业务页的根节点样式类,避免修改原会商样式。 */
rootClassName,
/** 独立业务页替换第一步消息工作区的纯展示组件;状态和行为仍由本页统一拥有。 */
renderStep1,
/** 独立业务页替换第一步左侧入口栏。 */
renderStep1LeftSidebar,
/** 独立业务页在第二步左栏顶部复用自己的任务/意图展示。 */
renderStep2LeftSidebarHeader,
/** 第一阶段使用内嵌右栏时,隐藏通用的模型/摘要/澄清记录栏。 */
hideDefaultStep1RightSidebar = false,
step2HeaderTitle,
step2HeaderRightSlot,
step3HeaderTitle,
/** 独立业务页的工作区品牌区,取代通用步骤导航。 */
headerLeftSlot,
/** 放在历史记录后面的独立业务操作或状态。 */
headerActionAfterHistory,
/** 独立业务页可隐藏通用的“保存草稿”按钮。 */
hideHeaderSaveDraft = false,
/** 独立业务页可调整新建动作的展示文案,行为不变。 */
newTaskLabel,
/** 独立业务页可隐藏通用的设置入口。 */
hideHeaderSettings = false,
/** 可选的外部任务过程产物提交适配器(8BF 使用,通用圆桌保持不显示)。 */
taskArtifactSubmissionConfig,
}: {
compactChrome?: boolean;
lockedChainId?: string | null;
@@ -307,7 +350,43 @@ export default function RoundtablePlanningPage({
storageTaskId?: string | null;
toolbarSlot?: ReactNode;
entryRailBusinessChainSlot?: ReactNode;
entryRailTaskCardSlot?: ReactNode;
taskContext?: Record<string, unknown> | null;
entryRailTaskId?: string | null;
rootClassName?: string;
renderStep1?: (props: Step1PanelProps & {
selectedModel: string;
modelOptions: Array<{ label: string; value: string }>;
onSelectedModelChange: (model: string) => void;
step2Dialogues: unknown[];
step2ThreadIds: ThreadIdMap | null;
step2CoordinatorName: string | null;
step2SeatStubMessages: Record<string, unknown[]>;
step3Summary: DraftStep3SummarySnapshot | null;
}) => ReactNode;
renderStep1LeftSidebar?: (context: {
intentReady: object | null;
onCollapse?: () => void;
submittedSeatKeys?: string[];
onSeatClick?: (seat: ChainSeat) => void;
}) => ReactNode;
renderStep2LeftSidebarHeader?: (context: {
intentReady: object | null;
onCollapse: () => void;
}) => ReactNode;
hideDefaultStep1RightSidebar?: boolean;
/** 8BF 第二步对话区标题,与第一步顶栏同一套样式。 */
step2HeaderTitle?: string;
/** 8BF 第二步顶栏右侧附加操作(如返回)。 */
step2HeaderRightSlot?: ReactNode;
/** 8BF 第三步对话区标题,与第一、二步顶栏同一套样式。 */
step3HeaderTitle?: string;
headerLeftSlot?: ReactNode;
headerActionAfterHistory?: ReactNode;
hideHeaderSaveDraft?: boolean;
newTaskLabel?: string;
hideHeaderSettings?: boolean;
taskArtifactSubmissionConfig?: TaskArtifactSubmissionConfig;
}) {
const { theme } = useTheme();
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
@@ -315,6 +394,10 @@ export default function RoundtablePlanningPage({
const isDark = theme === "dark";
const navigate = useNavigate();
const location = useLocation();
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
useEffect(() => {
taskContextRef.current = taskContext ?? null;
}, [taskContext]);
/** 无界(wujie)嵌入抽屉:隐藏业务链条入口,设置按钮改作手动回传交付。 */
const inWujieEmbed = isWujieEnv();
@@ -331,6 +414,13 @@ export default function RoundtablePlanningPage({
const draftStorageTaskId = storageTaskId ?? recordTaskIdOf(prefillRef.current);
// 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。
const [autoChainResult, setAutoChainResult] = useState<SeatSelectionResult | null>(null);
// 固定业务链(8BF 等独立页面)的解析状态。不能只以 autoChainResult 是否为空判断:
// 它为空既可能是「还在拉取」,也可能是「业务链已配置但可用席位不足」。
// 若混为一谈,用户刚保存配置后立刻点击进入研讨时会被错误提示为「未配置业务链」。
const [lockedChainResolution, setLockedChainResolution] = useState<
"idle" | "loading" | "ready" | "unavailable"
>("idle");
const [lockedChainUnavailableReason, setLockedChainUnavailableReason] = useState<string | null>(null);
/** 无界直传 chainId 时,链条解析完成前禁止弹选链窗(避免 modal 布局抖动)。 */
const [autoChainLoading, setAutoChainLoading] = useState(
() =>
@@ -342,21 +432,57 @@ export default function RoundtablePlanningPage({
useEffect(() => {
if (!lockedChainId) {
setAutoChainResult(null);
setLockedChainResolution("idle");
setLockedChainUnavailableReason(null);
return;
}
let cancelled = false;
// 映射切换时先清掉旧链结果,避免异步请求尚未返回时误用上一条业务链进入研讨。
setAutoChainResult(null);
setLockedChainResolution("loading");
setLockedChainUnavailableReason(null);
setAutoChainLoading(true);
void Promise.all([getChain(lockedChainId), listAgents().catch(() => [])])
void Promise.all([getChain(lockedChainId), listAgents()])
.then(([chain, agents]) => {
if (cancelled || !chain) return;
const result = buildChainSeatSelection(chain, filterRecommendCandidates(agents));
if (!cancelled) setAutoChainResult(result);
if (cancelled) return;
if (!chain) {
setAutoChainResult(null);
setLockedChainResolution("unavailable");
setLockedChainUnavailableReason("已配置的业务链不存在或未发布,请重新选择一条已发布的业务链");
return;
}
const candidates = filterRecommendCandidates(agents);
const result = buildChainSeatSelection(chain, candidates);
if (!cancelled) {
setAutoChainResult(result);
setLockedChainResolution(result ? "ready" : "unavailable");
if (!result) {
const availableAgentIds = new Set(candidates.map((agent) => agent.agent_id));
const unavailableSeats = chain.seats
.filter((seat) => !availableAgentIds.has(seat.agent_id))
.map((seat) => seat.name || seat.agent_id);
setLockedChainUnavailableReason(
unavailableSeats.length > 0
? `业务链中的智能体「${unavailableSeats.join("、")}」尚未发布、已删除或当前账号无访问权限;请先将它们发布,并保证至少有 2 个可用智能体`
: "业务链至少需要 2 个可用智能体,请检查业务链席位配置",
);
}
}
})
.catch(() => {
if (!cancelled) setAutoChainResult(null);
if (!cancelled) {
setAutoChainResult(null);
setLockedChainResolution("unavailable");
setLockedChainUnavailableReason("读取已配置业务链或智能体失败,请刷新后重试");
}
})
.finally(() => {
if (!cancelled) setAutoChainLoading(false);
if (!cancelled) {
setAutoChainLoading(false);
// getChain 返回 null 时不会进入 then 内的 result 分支,同样标记为不可用。
setLockedChainResolution((current) => current === "loading" ? "unavailable" : current);
setLockedChainUnavailableReason((current) => current ?? "已配置的业务链暂不可用,请重新选择后重试");
}
});
return () => {
cancelled = true;
@@ -374,11 +500,11 @@ export default function RoundtablePlanningPage({
consensusEmittedRef.current = true;
}
}, [inWujieEmbed]);
// 带 taskId 时,先按任务回显最新草稿再决定是否 autoStart。用 state 而非 ref:
// 带存储键 / 深链 taskId 时,先回显最新草稿再决定是否 autoStart。用 state 而非 ref:
// 解析完成需触发重渲染让 C2 effect 重跑(否则 intentThreadId 先就绪会错过 autoStart)。
// 无 taskId 时初始即「已解析」,autoStart 照常。
// 无存储键时初始即「已解析」,autoStart 照常。8BF 走 storageTaskId,不依赖 prefill.taskId。
const [taskRestoreResolved, setTaskRestoreResolved] = useState<boolean>(
!prefillRef.current?.taskId,
!draftStorageTaskId,
);
// 深链/预填自动会话:进入页面后要先建意图线程、(带 taskId 时)回显历史草稿,这段时间
// 页面只有一句欢迎语、问答还没真正开始,线程创建慢时像卡住。下面这个标志用来在超时后
@@ -542,6 +668,38 @@ export default function RoundtablePlanningPage({
}>({ intentReady: null, interactiveMessages: [] });
const buildInitialIntent = useCallback((): string => {
const task = taskContextRef.current?.task as Record<string, unknown> | null | undefined;
const taskTags = taskContextRef.current?.task_tags as
| Record<string, unknown>
| undefined;
const taskValue = (value: unknown) =>
value === undefined || value === null ? "" : String(value).trim();
const taskLines = task
? [
["任务 ID", taskValue(task.id ?? taskContextRef.current?.task_id)],
["任务名称", taskValue(task.title)],
["任务描述", taskValue(task.description)],
["任务概览", taskValue(task.overview)],
["任务方向", taskValue(task.direction ?? task.target_direction)],
["优先级", taskValue(task.task_priority ?? task.priority)],
["任务状态", taskValue(task.task_status)],
["优先级标签", taskValue(taskTags?.priority)],
["方向标签", taskValue(taskTags?.direction)],
["状态标签", taskValue(taskTags?.status)],
]
.filter(([, value]) => value)
.map(([label, value]) => `${label}: ${value}`)
: [];
const taskCardContext = taskLines.length
? [
"【8BF 任务卡片上下文】",
...taskLines,
"请把以上任务卡片信息作为本轮研讨的既有背景,不要让用户重复提供。",
"",
].join("\n")
: "";
const withTaskCardContext = (value: string) =>
taskCardContext ? `${taskCardContext}${value}` : value;
const { intentReady, interactiveMessages } = step1StateRef.current;
if (intentReady) {
const parts: string[] = [intentReady.objective];
@@ -559,17 +717,17 @@ export default function RoundtablePlanningPage({
parts.push(
"用户已经过任务理解智能体澄清,以上信息可以直接作为研讨依据。请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨,不要再反问用户。",
);
return parts.join("\n");
return withTaskCardContext(parts.join("\n"));
}
const userTurns = interactiveMessages
.filter((m) => m.sender === "user")
.map((m) => m.content?.trim())
.filter((s): s is string => !!s);
if (userTurns.length === 0) {
return "用户尚未在第一步明确任务意图。请先调用 ask_clarification,简要询问目标、范围与硬性约束,再进入研讨。";
return withTaskCardContext("用户尚未在第一步明确任务意图。请先调用 ask_clarification,简要询问目标、范围与硬性约束,再进入研讨。");
}
const conversation = userTurns.map((t, i) => `用户#${i + 1}: ${t}`).join("\n");
return `${conversation}\n\n注意:用户尚未完成结构化的任务理解流程,以上是原始输入。若信息不足以开展研讨,请先调用 ask_clarification 询问关键约束,再开始派活。`;
return withTaskCardContext(`${conversation}\n\n注意:用户尚未完成结构化的任务理解流程,以上是原始输入。若信息不足以开展研讨,请先调用 ask_clarification 询问关键约束,再开始派活。`);
}, []);
const step2 = useStep2Orchestration({
@@ -593,8 +751,67 @@ export default function RoundtablePlanningPage({
currentStep,
selectedModel,
triggerToast,
taskContext,
});
const taskArtifactSubmission = useTaskArtifactSubmission(
taskArtifactSubmissionConfig,
step2.threadIds,
step2.agentNameToRole,
);
const [seatDeliverable, setSeatDeliverable] = useState<AnalysisResultCardItem | null>(null);
const submittedRecords = taskArtifactSubmission?.submittedRecords;
const submittedSeatKeys = useMemo(
() => submittedSeatKeysFor(
step2.selectedAgents.map((agent) => ({ agent_id: agent.agent_id, name: agent.name })),
submittedRecords ?? [],
step2.selectedAgents,
step2.threadIds,
),
[submittedRecords, step2.selectedAgents, step2.threadIds],
);
const handleChainSeatClick = useCallback((seat: ChainSeat) => {
const record = findSeatSubmission(
seat,
submittedRecords ?? [],
step2.selectedAgents,
step2.threadIds,
);
if (!record) {
triggerToast("该节点暂无可预览产物");
return;
}
setSeatDeliverable({
id: record.id,
agentName: seat.name || record.artifact.sourceKey,
productName: record.artifact.name,
positionName: seat.description?.trim() || autoChainResult?.chain?.title || "业务链席位",
delivered: true,
artifactPath: record.artifact.path,
artifactMimeType: record.artifact.mimeType,
artifactThreadId: record.artifact.threadId,
updatedAt: record.submittedAt ?? undefined,
});
}, [
autoChainResult?.chain?.title,
step2.selectedAgents,
step2.threadIds,
submittedRecords,
triggerToast,
]);
const renderStep1LeftSidebarWithDeliveries = useMemo(
() => (renderStep1LeftSidebar
? (context: { intentReady: object | null; onCollapse?: () => void }) =>
renderStep1LeftSidebar({
...context,
submittedSeatKeys,
onSeatClick: taskArtifactSubmission ? handleChainSeatClick : undefined,
})
: undefined),
[handleChainSeatClick, renderStep1LeftSidebar, submittedSeatKeys, taskArtifactSubmission],
);
// 同步 step1StateRef,让 buildInitialIntent / Persona / Step 3 都能读到最新值。
useEffect(() => {
step1StateRef.current = {
@@ -1140,11 +1357,11 @@ export default function RoundtablePlanningPage({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// C1.5(无界回显):带 taskId 时,挂载即按任务取最新草稿回显。命中 → 抑制 autoStart
// C1.5(无界 / 8BF 回显):带存储键时,挂载即取最新草稿回显。命中 → 抑制 autoStart
// (避免重复发首条消息);未命中 → 放行 autoStart 走全新任务。只跑一次。
useEffect(() => {
// 回显聊天记录按存储键(xdfx-A=行动id),与 useDraftPersistence 的 taskId 保持一致。
const tid = recordTaskIdOf(prefillRef.current);
// 回显聊天记录按存储键:8BF 用独立 `storageTaskId`;xdfx-A 按行动id;其它深链按真实 taskId。
const tid = draftStorageTaskId;
if (!tid) return;
let cancelled = false;
void (async () => {
@@ -1400,9 +1617,9 @@ export default function RoundtablePlanningPage({
.filter((g) => g.length > 0),
[orchestrationPlan],
);
// 第三步首次生成报告后即**自动**续跑结构化输出(流程图)——不再限 taskId 深链,
// 任何会商进第三步首次出报告都自动出流程图(用户 2026-06-17 要求)。
const autoSummaryFlow = true;
// 第三步的交付统一为 Markdown 总结报告。流程图不再作为本页的后续自动产物,
// 避免报告写完后额外触发一次结构化抽取请求。
const autoSummaryFlow = false;
const summaryFlow = useStep3Summary({
intentReady: step1.intentReady,
@@ -1412,6 +1629,7 @@ export default function RoundtablePlanningPage({
coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null,
chainLevels,
autoFlow: autoSummaryFlow,
flowEnabled: false,
savedSummary: step3Report?.summaryReport ?? null,
onSummarySaved: handleSummarySaved,
ingestAvailable,
@@ -1917,9 +2135,66 @@ export default function RoundtablePlanningPage({
triggerToast("追问生成就绪!");
}, [step1, triggerToast]);
// 独立业务页可只替换 Step 1 的外观;这里将通用状态/动作收敛为一份受控 props,
// 避免它们复制或分叉意图识别、附件上传、澄清和进入研讨的业务逻辑。
const step1PanelProps: Step1PanelProps = {
interactiveMessages: step1.interactiveMessages,
intentThreadId: step1.intentThreadId,
intentReady: step1.intentReady,
isIntentSending: step1.isIntentSending,
pendingIntentClarificationMsgId: step1.pendingIntentClarificationMsgId,
intentClarificationSelections: step1.intentClarificationSelections,
setIntentClarificationSelections: step1.setIntentClarificationSelections,
onSendMessage: (text, files) => void step1.sendIntentMessage(text, files),
onStopIntent: step1.stopIntentStream,
onSkipQuestion: handleSkipQuestion,
onFollowUp: handleAgentFollowUp,
onEnterRoundtable: () => {
if (!step1.intentReady) {
triggerToast("请先与任务理解智能体完成澄清,生成任务摘要后再进入");
return;
}
// 会商自动化串联(模块 C):若任务页带来的业务链已解析成功,跳过选链弹窗,
// 直接据该业务链进入 Step 2 研讨(用户无需再手选)。
if (autoChainResult) {
const result = autoChainResult;
void drafts.ensureDraft(1).finally(() => handleConfirmAgents(result));
return;
}
if (lockBusinessChain) {
triggerToast(
autoChainLoading || lockedChainResolution === "loading"
? "正在加载管理员配置的业务链,请稍候…"
: lockedChainResolution === "unavailable"
? lockedChainUnavailableReason ?? "已配置的业务链暂不可用,请确认至少保留 2 个可用智能体后重试"
: "管理员尚未配置可用的业务链,请联系管理员处理",
);
return;
}
if (
inWujieEmbed &&
autoChainLoading &&
(prefillRef.current?.chainId || prefillRef.current?.businessCode)
) {
triggerToast("正在加载业务链条,请稍候…");
return;
}
// 先把草稿落地拿到 draft_id,推荐历史才有归属;失败也照常打开弹窗
// (弹窗在无 draftId 时退化为「直接推荐、不存历史」)。
void drafts.ensureDraft(1).finally(() => setIsRecommendDialogOpen(true));
},
onManualSave: drafts.handleManualSave,
onTriggerToast: triggerToast,
submitIntentClarifications: step1.submitIntentClarifications,
};
// 定制 chrome 仅服务第一阶段;第二、三阶段继续保留原有步骤导航与操作,
// 避免独立业务页为了复刻第一步视觉而收窄后续流程的可达性。
const useCustomStep1Chrome = currentStep === 1 && Boolean(headerLeftSlot);
// ─── Render ──────────────────────────────────────────────
return (
<div
data-roundtable-step={currentStep}
className={cn(
"roundtable-planning theme-transition relative flex h-full min-h-0 flex-col bg-[#fef6f6] font-sans text-slate-800 selection:bg-red-200",
isDark && "roundtable-planning--dark",
@@ -2013,6 +2288,15 @@ export default function RoundtablePlanningPage({
}}
/>
{taskArtifactSubmission && (
<AnalysisResultDetailDialog
item={seatDeliverable}
onOpenChange={(open) => {
if (!open) setSeatDeliverable(null);
}}
/>
)}
{showToast && (
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 bg-primary text-white text-xs px-4 py-2.5 rounded-xl shadow-lg border border-primary/40 font-medium flex items-center gap-2 animate-bounce">
<Sparkles className="h-4 w-4 shrink-0" />
@@ -2022,13 +2306,15 @@ export default function RoundtablePlanningPage({
{/* HEADER */}
<header ref={headerDzRef} className="relative z-[40] flex shrink-0 items-center justify-between gap-4 border-b border-slate-100 bg-white px-6 py-3 shadow-[0_1px_3px_rgba(0,0,0,0.02)] dark:border-border dark:bg-card dark:shadow-none">
<StepNavigation
currentStep={currentStep}
intentReady={step1.intentReady}
hasConsensus={step2.hasConsensus}
onStepClick={(s) => setCurrentStep(s)}
triggerToast={triggerToast}
/>
{useCustomStep1Chrome ? headerLeftSlot : (
<StepNavigation
currentStep={currentStep}
intentReady={step1.intentReady}
hasConsensus={step2.hasConsensus}
onStepClick={(s) => setCurrentStep(s)}
triggerToast={triggerToast}
/>
)}
<div className="flex items-center gap-2">
{toolbarSlot}
@@ -2077,18 +2363,20 @@ export default function RoundtablePlanningPage({
className="bg-emerald-600 text-white hover:bg-emerald-700 text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all shadow-sm shadow-emerald-500/10 active:scale-95"
>
<Sparkles className="w-3.5 h-3.5" />
新建任务
{useCustomStep1Chrome ? (newTaskLabel ?? "新建任务") : "新建任务"}
</button>
)}
<button
id="save-draft-header-btn"
onClick={drafts.handleManualSave}
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<Save className="w-3.5 h-3.5 text-slate-500" />
保存草稿
</button>
{(!hideHeaderSaveDraft || !useCustomStep1Chrome) && (
<button
id="save-draft-header-btn"
onClick={drafts.handleManualSave}
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<Save className="w-3.5 h-3.5 text-slate-500" />
保存草稿
</button>
)}
{/* 「导入会商」按钮先隐藏(保留代码 + 弹窗逻辑,便于以后恢复)。 */}
{false && (
@@ -2126,22 +2414,26 @@ export default function RoundtablePlanningPage({
onCancelRename={drafts.handleCancelRename}
/>
<div className="h-6 w-[1px] bg-slate-200 mx-1" />
{useCustomStep1Chrome && headerActionAfterHistory}
<button
id="settings-btn"
onClick={handleSettingsClick}
title={
inWujieEmbed
? "回传各智能体交付结果给宿主"
: getStoredAuth()?.system_role === "admin"
? "会商诊断日志"
: "系统设置"
}
className="p-1.5 text-slate-400 hover:text-slate-600 rounded-lg hover:bg-slate-100 transition-colors dark:text-muted-foreground dark:hover:bg-accent dark:hover:text-foreground"
>
<Settings className="w-4 h-4" />
</button>
{(!hideHeaderSettings || !useCustomStep1Chrome) && <div className="h-6 w-[1px] bg-slate-200 mx-1" />}
{(!hideHeaderSettings || !useCustomStep1Chrome) && (
<button
id="settings-btn"
onClick={handleSettingsClick}
title={
inWujieEmbed
? "回传各智能体交付结果给宿主"
: getStoredAuth()?.system_role === "admin"
? "会商诊断日志"
: "系统设置"
}
className="p-1.5 text-slate-400 hover:text-slate-600 rounded-lg hover:bg-slate-100 transition-colors dark:text-muted-foreground dark:hover:bg-accent dark:hover:text-foreground"
>
<Settings className="w-4 h-4" />
</button>
)}
{compactChrome && (
<>
<ThemeSwitcher tone="content" />
@@ -2157,7 +2449,12 @@ export default function RoundtablePlanningPage({
sidebarExpanded={step2SidebarExpanded}
setSidebarExpanded={setStep2SidebarExpanded}
/>
<main className="relative z-0 flex-1 overflow-hidden p-4 flex gap-4 min-h-0">
<main
data-step2-sidebar-expanded={
currentStep === 2 || currentStep === 3 ? String(step2SidebarExpanded) : undefined
}
className="relative z-0 flex-1 overflow-hidden p-4 flex gap-4 min-h-0"
>
{/* LEFT SIDEBAR */}
<LeftSidebar
currentStep={currentStep}
@@ -2176,12 +2473,15 @@ export default function RoundtablePlanningPage({
onSelectChain={handleSelectChainFromSidebar}
onOpenDraft={drafts.handleLoadDraft}
activeDraftId={drafts.currentDraftId}
entryRailTaskId={recordTaskIdOf(prefillRef.current)}
entryRailTaskId={entryRailTaskId ?? recordTaskIdOf(prefillRef.current)}
historyTaskId={draftStorageTaskId}
entryRailBusinessCode={prefillRef.current?.businessCode ?? null}
lockedChainId={lockedChainId}
lockBusinessChain={lockBusinessChain}
entryRailBusinessChainSlot={entryRailBusinessChainSlot}
entryRailTaskCardSlot={entryRailTaskCardSlot}
renderStep1LeftSidebar={renderStep1LeftSidebarWithDeliveries}
renderStep2LeftSidebarHeader={renderStep2LeftSidebarHeader}
/>
{/* MIDDLE VIEWPORT */}
@@ -2190,7 +2490,18 @@ export default function RoundtablePlanningPage({
className="flex-1 flex flex-col min-w-0 bg-white border border-slate-200/50 rounded-2xl shadow-[0_4px_16px_rgba(0,0,0,0.015)] overflow-hidden"
>
{currentStep === 1 && (
<Step1Panel
renderStep1 ? renderStep1({
...step1PanelProps,
selectedModel,
modelOptions: availableModels.map((model) => ({ label: model.name, value: model.name })),
onSelectedModelChange: setSelectedModel,
step2Dialogues: step2.step2RoundtableDialogues,
step2ThreadIds: step2.threadIds,
step2CoordinatorName: step2.coordinatorName,
step2SeatStubMessages: step2.seatStubMessages,
step3Summary: step3Report?.summaryReport ?? null,
}) : <Step1Panel
{...step1PanelProps}
headerLeftSlot={
// 「原任务已有结果」(目的树预览)只在 rwfx-A(6BF)模式显示;xdfx/3qfx 不显示。
prefillRef.current?.taskId &&
@@ -2203,60 +2514,16 @@ export default function RoundtablePlanningPage({
/>
) : undefined
}
interactiveMessages={step1.interactiveMessages}
intentThreadId={step1.intentThreadId}
intentReady={step1.intentReady}
isIntentSending={step1.isIntentSending}
pendingIntentClarificationMsgId={step1.pendingIntentClarificationMsgId}
intentClarificationSelections={step1.intentClarificationSelections}
setIntentClarificationSelections={step1.setIntentClarificationSelections}
onSendMessage={(text, files) => void step1.sendIntentMessage(text, files)}
onStopIntent={step1.stopIntentStream}
onSkipQuestion={handleSkipQuestion}
onFollowUp={handleAgentFollowUp}
onEnterRoundtable={() => {
if (!step1.intentReady) {
triggerToast("请先与任务理解智能体完成澄清,生成任务摘要后再进入");
return;
}
// 会商自动化串联(模块 C):若任务页带来的业务链已解析成功,跳过选链弹窗,
// 直接据该业务链进入 Step 2 研讨(用户无需再手选)。
if (autoChainResult) {
const result = autoChainResult;
void drafts.ensureDraft(1).finally(() => handleConfirmAgents(result));
return;
}
if (lockBusinessChain) {
triggerToast(
autoChainLoading
? "正在加载管理员配置的业务链,请稍候…"
: "管理员尚未配置可用的业务链,请联系管理员处理",
);
return;
}
if (
inWujieEmbed &&
autoChainLoading &&
(prefillRef.current?.chainId || prefillRef.current?.businessCode)
) {
triggerToast("正在加载业务链条,请稍候…");
return;
}
// 先把草稿落地拿到 draft_id,推荐历史才有归属;失败也照常打开弹窗
// (弹窗在无 draftId 时退化为「直接推荐、不存历史」)。
void drafts.ensureDraft(1).finally(() => setIsRecommendDialogOpen(true));
}}
onManualSave={drafts.handleManualSave}
onTriggerToast={triggerToast}
submitIntentClarifications={step1.submitIntentClarifications}
/>
)}
{currentStep === 2 && (
<Step2Panel
useStep1InputAppearance={rootClassName?.includes("eightbf-roundtable") ?? false}
headerTitle={step2HeaderTitle}
headerRightSlot={step2HeaderRightSlot}
step2RoundtableDialogues={step2.step2RoundtableDialogues}
isInitializing={step2.isInitializing}
isRoundTableRunning={step2.isRoundTableRunning}
isOrchestrating={step2.isOrchestrating}
pendingClarification={step2.pendingClarification}
clarificationDraft={step2.clarificationDraft}
@@ -2284,7 +2551,6 @@ export default function RoundtablePlanningPage({
openArtifactFromStep={step2.openArtifactFromStep}
openArtifactPreview={step2.openArtifactPreview}
onPauseRoundtable={step2.pauseRoundtable}
onResumeRoundtable={step2.resumeRoundtable}
onHandleInjectToRoundtable={step2.handleInjectToRoundtable}
setClarificationDraft={step2.setClarificationDraft}
setCustomAgentInput={step2.setCustomAgentInput}
@@ -2305,11 +2571,14 @@ export default function RoundtablePlanningPage({
onBackgroundSuspend={handleBackgroundSuspend}
isViewingHistory={drafts.loadedFromDraftRef.current}
onTriggerToast={triggerToast}
taskArtifactSubmission={taskArtifactSubmission}
/>
)}
{currentStep === 3 && (
<Step3Panel
useStep1Chrome={rootClassName?.includes("eightbf-roundtable") ?? false}
headerTitle={step3HeaderTitle}
threadIds={step2.threadIds}
coordinatorName={step2.coordinatorName}
consensusText={step2.lastLeaderContent}
@@ -2334,12 +2603,13 @@ export default function RoundtablePlanningPage({
onReset={handleStartNewTask}
openArtifactPreview={step2.openArtifactPreview}
seatStubMessages={step2.seatStubMessages}
taskArtifactSubmission={taskArtifactSubmission}
/>
)}
</section>
{/* RIGHT SIDEBAR */}
<RightSidebar
{!hideDefaultStep1RightSidebar && <RightSidebar
currentStep={currentStep}
selectedModel={selectedModel}
setSelectedModel={setSelectedModel}
@@ -2349,7 +2619,7 @@ export default function RoundtablePlanningPage({
timelineEntries={step1.timelineEntries}
triggerToast={triggerToast}
onSaveIntent={handleSaveIntent}
/>
/>}
</main>
{/* 深链 taskId 模式:底部「快捷跳转」栏(与单智能体问答共用,按业务取按钮管理配置)。 */}
@@ -2743,6 +3013,20 @@ interface LeftSidebarProps {
lockedChainId?: string | null;
/** 第一阶段替换默认业务链选择区的自定义内容。 */
entryRailBusinessChainSlot?: ReactNode;
/** 第一阶段由独立业务页提供的自定义任务卡片。 */
entryRailTaskCardSlot?: ReactNode;
/** 独立业务页替换第一步左侧工作区;仅提供已解析的意图状态。 */
renderStep1LeftSidebar?: (context: {
intentReady: object | null;
onCollapse?: () => void;
submittedSeatKeys?: string[];
onSeatClick?: (seat: ChainSeat) => void;
}) => ReactNode;
/** 独立业务页在第二步左栏顶部复用任务与意图区。 */
renderStep2LeftSidebarHeader?: (context: {
intentReady: object | null;
onCollapse: () => void;
}) => ReactNode;
}
function Step2SandboxSidebarSync({
@@ -2756,13 +3040,21 @@ function Step2SandboxSidebarSync({
}) {
const { open } = useArtifacts();
const prevOpenRef = useRef(open);
const prevStepRef = useRef(currentStep);
useEffect(() => {
if (currentStep !== 2) {
const enteredCollapsibleStep =
(currentStep === 2 || currentStep === 3) &&
prevStepRef.current !== currentStep;
prevStepRef.current = currentStep;
if (currentStep !== 2 && currentStep !== 3) {
prevOpenRef.current = open;
return;
}
if (open && !prevOpenRef.current && sidebarExpanded) {
const openedNow = open && !prevOpenRef.current;
const landedWithSandbox = enteredCollapsibleStep && open;
if ((openedNow || landedWithSandbox) && sidebarExpanded) {
setSidebarExpanded(false);
}
prevOpenRef.current = open;
@@ -2794,6 +3086,9 @@ function LeftSidebar({
lockBusinessChain,
lockedChainId,
entryRailBusinessChainSlot,
entryRailTaskCardSlot,
renderStep1LeftSidebar,
renderStep2LeftSidebarHeader,
}: LeftSidebarProps) {
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
@@ -2803,18 +3098,48 @@ function LeftSidebar({
const toggleSeatFilter = (filter: SeatFilter) =>
setSeatFilter((cur) => (cur === filter ? null : filter));
// Step 3 隐藏左侧栏(原「参与与结论贡献」列),让中间「最终方案展示」自适应
// 撑满整行宽度(右侧栏在 Step !==1 时也已 return null)。
if (currentStep === 3) return null;
// Step 3:通用会商隐藏左栏以让方案展示撑满;8BF 复用第一步左栏,并和第二步一样可收起。
if (currentStep === 3) {
if (!renderStep1LeftSidebar) return null;
return (
<section
ref={dzRef}
id="left-sidebar"
data-sidebar-expanded={String(sidebarExpanded)}
className={cn(
"min-h-0 shrink-0 flex flex-col gap-3 pr-1 transition-[width] duration-200 ease-out overflow-y-auto",
sidebarExpanded ? "w-[280px]" : "w-[80px]",
)}
>
{!sidebarExpanded ? (
<button
type="button"
onClick={() => setSidebarExpanded(true)}
title="展开侧栏"
aria-expanded={false}
className="w-full shrink-0 flex h-10 items-center justify-center rounded-[4px] border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
>
<PanelLeftOpen className="h-4 w-4" />
</button>
) : (
renderStep1LeftSidebar({
intentReady,
onCollapse: () => setSidebarExpanded(false),
})
)}
</section>
);
}
return (
<section
ref={dzRef}
id="left-sidebar"
data-sidebar-expanded={isStep2 ? String(sidebarExpanded) : undefined}
className={cn(
"min-h-0 shrink-0 flex flex-col gap-3 pr-1 transition-[width] duration-200 ease-out",
isStep2 ? "overflow-y-auto" : "overflow-hidden",
isStep2 && !sidebarExpanded ? "w-[56px]" : "w-[280px]",
isStep2 && !sidebarExpanded ? "w-[80px]" : "w-[280px]",
)}
>
{isStep2 && !sidebarExpanded && (
@@ -2823,7 +3148,7 @@ function LeftSidebar({
onClick={() => setSidebarExpanded(true)}
title="展开侧栏"
aria-expanded={false}
className="w-full shrink-0 flex h-10 items-center justify-center rounded-2xl border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
className="w-full shrink-0 flex h-10 items-center justify-center rounded-[4px] border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
>
<PanelLeftOpen className="h-4 w-4" />
</button>
@@ -2832,15 +3157,20 @@ function LeftSidebar({
{currentStep === 2 ? (
sidebarExpanded ? (
<>
<IntentSummaryCard
intentReady={intentReady}
title="意图理解"
collapsible
sidebarToggle={{
expanded: sidebarExpanded,
onToggle: () => setSidebarExpanded((v) => !v),
}}
/>
{renderStep2LeftSidebarHeader?.({
intentReady,
onCollapse: () => setSidebarExpanded(false),
}) ?? (
<IntentSummaryCard
intentReady={intentReady}
title="意图理解"
collapsible
sidebarToggle={{
expanded: sidebarExpanded,
onToggle: () => setSidebarExpanded((v) => !v),
}}
/>
)}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[240px]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
@@ -2926,18 +3256,21 @@ function LeftSidebar({
)
) : (
// Step 1(落地,未开始研讨):左栏改为业务链 + 分析记录入口(需求 0616 #2)。
<RoundtableEntryRail
selectedChainId={selectedChainId}
onSelectChain={onSelectChain}
onOpenDraft={onOpenDraft}
activeDraftId={activeDraftId}
taskId={entryRailTaskId ?? null}
historyTaskId={historyTaskId ?? null}
businessCode={entryRailBusinessCode ?? null}
lockBusinessChain={lockBusinessChain}
lockedChainId={lockedChainId ?? null}
businessChainSlot={entryRailBusinessChainSlot}
/>
renderStep1LeftSidebar?.({ intentReady }) ?? (
<RoundtableEntryRail
selectedChainId={selectedChainId}
onSelectChain={onSelectChain}
onOpenDraft={onOpenDraft}
activeDraftId={activeDraftId}
taskId={entryRailTaskId ?? null}
historyTaskId={historyTaskId ?? null}
businessCode={entryRailBusinessCode ?? null}
lockBusinessChain={lockBusinessChain}
lockedChainId={lockedChainId ?? null}
businessChainSlot={entryRailBusinessChainSlot}
taskCardSlot={entryRailTaskCardSlot}
/>
)
)}
</section>
);
@@ -2992,6 +3325,7 @@ function RoundtableEntryRail({
lockedChainId,
lockBusinessChain = false,
businessChainSlot,
taskCardSlot,
}: {
selectedChainId: string | null;
onSelectChain: (chain: BusinessChain) => void;
@@ -3007,6 +3341,7 @@ function RoundtableEntryRail({
lockedChainId?: string | null;
lockBusinessChain?: boolean;
businessChainSlot?: ReactNode;
taskCardSlot?: ReactNode;
}) {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [chainSectionExpanded, setChainSectionExpanded] = useState(true);
@@ -3061,7 +3396,8 @@ function RoundtableEntryRail({
void listChains("public")
.then((all) => {
if (!cancelled) {
setChains(lockBusinessChain ? all.filter((chain) => chain.id === lockedChainId) : all);
const usable = all.filter(isChainEnabled);
setChains(lockBusinessChain ? usable.filter((chain) => chain.id === lockedChainId) : usable);
}
})
.catch(() => {
@@ -3093,7 +3429,12 @@ function RoundtableEntryRail({
)}
>
{/* 深链接(taskId)模式:左上角展示「当前任务」(rwfx/3qfx)或「当前行动」(xdfx),替代业务链选择。 */}
{deeplink ? (
{taskCardSlot ? (
<>
{taskCardSlot}
{lockBusinessChain && businessChainSlot}
</>
) : deeplink ? (
isXdfx ? (
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)]">
<div className="flex items-center gap-2 mb-3">
@@ -3535,7 +3876,7 @@ function Step2RunSwitcher({
type="button"
onClick={() => onSelect(run.id)}
className={cn(
"w-full rounded-xl border p-2.5 text-left transition-colors",
"roundtable-step2-run-card w-full rounded-xl border p-2.5 text-left transition-colors",
active
? "border-primary/40 bg-primary/5 text-slate-900"
: "border-slate-100 bg-slate-50/60 text-slate-600 hover:border-primary/20 hover:bg-white",
@@ -3646,7 +3987,7 @@ function HumanParticipationCard({
: undefined
}
className={cn(
"flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3 transition",
"roundtable-step2-human-row flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3 transition",
clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5",
active && "ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40",
)}
@@ -4033,7 +4374,7 @@ function CollapsedSidebarSection({
<div
title={title}
className={cn(
"w-full shrink-0 bg-white border border-slate-200/50 rounded-2xl p-2 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex flex-col items-center justify-center",
"w-full shrink-0 bg-white border border-slate-200/50 rounded-[4px] p-2 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex flex-col items-center justify-center",
className,
)}
>
@@ -4170,7 +4511,7 @@ function RoleRow({
: undefined
}
className={cn(
"flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition",
"roundtable-step2-role-row flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition",
isActive
? "bg-slate-50 border border-slate-100"
: isBootingUp
@@ -3,7 +3,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import type { CompactMenuOverride } from "@/core/page-layout/compact-menu-overrides";
import {
getRuntimeCompactMenuType,
isDefaultCompactMenuType,
type CompactMenuOverride,
type CompactMenuType,
} from "@/core/page-layout/compact-menu-overrides";
const BASE = () => `${getBackendBaseURL()}/api/compact-menu-overrides`;
@@ -12,6 +17,7 @@ interface ServerCompactOverride {
placement: "sidebar" | "settings";
sort_order: number;
label: string | null;
menu_type?: string | null;
}
function toOverride(s: ServerCompactOverride): CompactMenuOverride {
@@ -44,27 +50,40 @@ async function readJson<T>(res: Response, ctx: string): Promise<T> {
return (await res.json()) as T;
}
export async function listCompactMenuOverrides(): Promise<CompactMenuOverride[]> {
const res = await apiFetch(BASE(), { method: "GET" });
export async function listCompactMenuOverrides(
menuType: CompactMenuType = getRuntimeCompactMenuType(),
): Promise<CompactMenuOverride[]> {
// 旧页面 / 未配置 VITE_COMPACT_MENU_TYPE 时不带参数,后端按类型 A 返回原来的数据。
const url = isDefaultCompactMenuType(menuType)
? BASE()
: `${BASE()}?menu_type=${encodeURIComponent(menuType)}`;
const res = await apiFetch(url, { method: "GET" });
const data = await readJson<{ overrides: ServerCompactOverride[] }>(res, "list compact menu overrides");
return (data.overrides ?? []).map(toOverride);
}
export async function replaceCompactMenuOverrides(
overrides: CompactMenuOverride[],
menuType: CompactMenuType = getRuntimeCompactMenuType(),
): Promise<CompactMenuOverride[]> {
const body: { overrides: ServerCompactOverride[]; menu_type?: CompactMenuType } = {
overrides: overrides.map(toBody),
};
if (!isDefaultCompactMenuType(menuType)) {
body.menu_type = menuType;
}
const res = await apiFetch(BASE(), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ overrides: overrides.map(toBody) }),
body: JSON.stringify(body),
});
const data = await readJson<{ overrides: ServerCompactOverride[] }>(res, "save compact menu overrides");
const saved = (data.overrides ?? []).map(toOverride);
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-简介-菜单管理-保存",
optDetail: `CM助手-简介-菜单管理-保存-类型${menuType}`,
url: "/api/compact-menu-overrides",
reqParam: { overrides },
reqParam: { menuType, overrides },
returnParam: saved,
});
return saved;
@@ -72,21 +91,23 @@ export async function replaceCompactMenuOverrides(
const COMPACT_MENU_OVERRIDES_KEY = "compact-menu-overrides";
export function useCompactMenuOverrides() {
export function useCompactMenuOverrides(menuType?: CompactMenuType) {
const type = menuType ?? getRuntimeCompactMenuType();
return useQuery({
queryKey: [COMPACT_MENU_OVERRIDES_KEY],
queryFn: listCompactMenuOverrides,
queryKey: [COMPACT_MENU_OVERRIDES_KEY, type],
queryFn: () => listCompactMenuOverrides(type),
refetchOnWindowFocus: false,
staleTime: 60_000,
});
}
export function useCompactMenuOverrideActions() {
export function useCompactMenuOverrideActions(menuType?: CompactMenuType) {
const queryClient = useQueryClient();
const type = menuType ?? getRuntimeCompactMenuType();
return {
save: useMutation({
mutationFn: replaceCompactMenuOverrides,
onSuccess: () => queryClient.invalidateQueries({ queryKey: [COMPACT_MENU_OVERRIDES_KEY] }),
mutationFn: (overrides: CompactMenuOverride[]) => replaceCompactMenuOverrides(overrides, type),
onSuccess: () => queryClient.invalidateQueries({ queryKey: [COMPACT_MENU_OVERRIDES_KEY, type] }),
}),
};
}
@@ -17,6 +17,7 @@ import {
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { createClientId } from "@/lib/client-id";
import type {
DocumentRewriteSummary,
DocumentRewriteStreamEvent,
@@ -355,7 +356,7 @@ export async function startJob(
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
request_id: opts.requestId ?? crypto.randomUUID(),
request_id: opts.requestId ?? createClientId(),
entry: opts.entry ?? "legacy",
config: opts.config ?? null,
}),
@@ -0,0 +1,180 @@
/**
* 深度研究「工作流配置 / 报告结构」API 客户端 + React Query hooks。
*
* 后端:`/api/report-structures`(见 app/gateway/routers/report_structures.py)。
* 全局共享、无用户隔离;新增永远默认启用(服务端强制,不收 enabled 字段)。
* snake_case ⇄ camelCase 在此边界收敛。
*/
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export interface ReportStructure {
id: string;
/** 标题:卡片主标签,也是列表「标题筛选」的目标。 */
title: string;
/** 描述:一句话说明这套结构的适用场景(存储列沿用 name)。 */
name: string;
/** 接口内容:选中卡片后应用到报告结构(custom_outline)的 Markdown 文本。 */
content: string;
enabled: boolean;
/** 创建用户名(本部署为邮箱;系统内置种子为“系统内置”)。 */
createdBy: string | null;
createdAt: string | null;
/** 最后修改时间(编辑、启停都会刷新)。 */
updatedAt: string | null;
}
export type ReportStructureInput = {
title: string;
name: string;
content: string;
};
export type ReportStructurePatch = Partial<ReportStructureInput>;
export type ReportStructureStatusFilter = "all" | "enabled" | "disabled";
const BASE = () => `${getBackendBaseURL()}/api/report-structures`;
interface ServerStructure {
id: string;
title: string;
name: string;
content: string;
enabled: boolean;
created_by: string | null;
created_at: string | null;
updated_at: string | null;
}
function toStructure(s: ServerStructure): ReportStructure {
return {
id: s.id,
title: s.title,
name: s.name ?? "",
content: s.content,
enabled: s.enabled ?? true,
createdBy: s.created_by ?? null,
createdAt: s.created_at ?? null,
updatedAt: s.updated_at ?? null,
};
}
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const data = await res.json().catch(() => null as unknown);
const detail =
data && typeof data === "object" && "detail" in data
? String((data as { detail: unknown }).detail)
: `${ctx} failed (${res.status})`;
throw new Error(detail);
}
return (await res.json()) as T;
}
export async function listReportStructures(params: {
status?: ReportStructureStatusFilter;
title?: string;
} = {}): Promise<ReportStructure[]> {
const qs = new URLSearchParams();
if (params.status === "enabled") qs.set("enabled", "true");
if (params.status === "disabled") qs.set("enabled", "false");
const title = params.title?.trim();
if (title) qs.set("title", title);
const query = qs.toString();
const res = await apiFetch(`${BASE()}${query ? `?${query}` : ""}`, { method: "GET" });
const data = await readJson<{ structures: ServerStructure[] }>(res, "list report structures");
return (data.structures ?? []).map(toStructure);
}
export async function createReportStructure(input: ReportStructureInput): Promise<ReportStructure> {
const res = await apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: input.title, name: input.name, content: input.content }),
});
return toStructure(await readJson<ServerStructure>(res, "create report structure"));
}
export async function updateReportStructure(
id: string,
patch: ReportStructurePatch,
): Promise<ReportStructure> {
const body: Record<string, unknown> = {};
if (patch.title !== undefined) body.title = patch.title;
if (patch.name !== undefined) body.name = patch.name;
if (patch.content !== undefined) body.content = patch.content;
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return toStructure(await readJson<ServerStructure>(res, "update report structure"));
}
export async function setReportStructureEnabled(id: string, enabled: boolean): Promise<ReportStructure> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}/enabled`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
return toStructure(await readJson<ServerStructure>(res, "toggle report structure"));
}
export async function deleteReportStructure(id: string): Promise<void> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "DELETE" });
// 204 无响应体,不能再走 readJson 的 res.json()。
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { detail?: unknown } | null;
throw new Error(
data && typeof data.detail === "string" ? data.detail : `delete report structure failed (${res.status})`,
);
}
}
// ── React Query hooks(工作流配置弹窗内各视图共用缓存) ──────────────────────
const REPORT_STRUCTURES_KEY = "report-structures";
export function useReportStructures(params: { status?: ReportStructureStatusFilter; title?: string } = {}) {
return useQuery({
queryKey: [REPORT_STRUCTURES_KEY, "list", params.status ?? "all", params.title ?? ""],
queryFn: () => listReportStructures(params),
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
});
}
export function useReportStructureMutations() {
const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: [REPORT_STRUCTURES_KEY] });
const create = useMutation({
mutationFn: (input: ReportStructureInput) => createReportStructure(input),
onSuccess: invalidate,
});
const update = useMutation({
mutationFn: ({ id, patch }: { id: string; patch: ReportStructurePatch }) =>
updateReportStructure(id, patch),
onSuccess: invalidate,
});
const toggleEnabled = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
setReportStructureEnabled(id, enabled),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => deleteReportStructure(id),
onSuccess: invalidate,
});
return { create, update, toggleEnabled, remove };
}
@@ -0,0 +1,609 @@
# Offline backend template for DeerFlow
config_version: 8
log_level: info
# Gateway FastAPI 监听端口。圆桌三路由(intent / recommend / multi_agent)的同进程
# loopback 调用会拼 http://127.0.0.1:<port> 打到本机其它 endpoint,所以必须与
# 实际监听端口一致(scripts/start-all.sh、Makefile 默认 8001)。
# 部署机改了端口请同步改这里;也可设环境变量 DEER_FLOW_GATEWAY_PORT 临时覆盖。
gateway:
port: 8001
# Optional Redis-backed global concurrency coordination.
# Leave disabled to keep the existing in-process behavior.
# When enabled, Redis operations are short-timeout and fail-open by default:
# if Redis is slow or unavailable, user Q&A continues with local-only limits.
concurrency:
redis:
enabled: false
url: redis://127.0.0.1:6379/0
password: null
# Prefix with the deployment/system id because Redis may be shared by
# multiple projects. Keep this unique per system to avoid cross-project
# leases affecting each other.
namespace: cmzs_deerflow:concurrency
socket_connect_timeout_ms: 80
socket_timeout_ms: 80
acquire_timeout_ms: 120
operation_timeout_ms: 80
circuit_breaker_failures: 3
circuit_breaker_cooldown_seconds: 30
lease_ttl_seconds: 90
heartbeat_interval_seconds: 20
fail_open: true
models:
- name: deepseek-chat
display_name: DeepSeek Chat
use: langchain_openai:ChatOpenAI
model: deepseek-chat
api_key: $DEEPSEEK_CHAT_API_KEY
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: false
supports_thinking: true
- name: zai-org/GLM-5-FP8
display_name: zai-org/GLM-5-FP8
use: langchain_openai:ChatOpenAI
model: zai-org/GLM-5-FP8
api_key: $GLM5_FP8_API_KEY
base_url: https://api.us-west-2.modal.direct/v2
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
supports_reasoning_effort: false
- name: deepseek-ai/DeepSeek-V4-Flash
display_name: deepseek-ai/DeepSeek-V4-Flash
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-V4-Flash
api_key: $SILICONFLOW_API_KEY
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
when_thinking_disabled:
extra_body:
enable_thinking: false
- name: inclusionAI/Ling-flash-2.0
display_name: inclusionAI/Ling-flash-2.0
use: langchain_openai:ChatOpenAI
model: inclusionAI/Ling-flash-2.0
api_key: $SILICONFLOW_API_KEY
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-ai/DeepSeek-V4-Pro
display_name: deepseek-ai/DeepSeek-V4-Pro
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-V4-Pro
api_key: $SILICONFLOW_API_KEY
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: qwen3.6-flash
display_name: qwen3.6-flash
use: langchain_openai:ChatOpenAI
model: qwen3.6-flash
api_key: sk-sp-H.XDIPL.Zgvs.MEQCIBasaS2RPHwgNUpGVfFJeRaTp5UgmZ4Y2rCVGNtpwfD1AiBTFg3S-uE_gqVDnHgdyEhDwcfaVRLi92xviye80AZnkg
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
request_timeout: 30.0
max_retries: 2
stream_usage: true
supports_thinking: true
supports_reasoning_effort: false
supports_vision: false
- name: qwen3.6
display_name: qwen3.6
use: langchain_openai:ChatOpenAI
model: qwen3.6
api_key: sk-6AqP_UYpPYJKdclYWR-FSA
base_url: http://119.254.86.251:8001/v1
request_timeout: 30.0
max_retries: 2
stream_usage: true
supports_thinking: false
supports_reasoning_effort: false
supports_vision: false
# - name: deepseek-v4-flash
# display_name: DeepSeek V4 Flash
# use: langchain_openai:ChatOpenAI
# model: deepseek-v4-flash
# api_key: sk-3fee562f834a4c08bb0d471ef44cb445
# base_url: https://api.deepseek.com
# request_timeout: 600.0
# max_retries: 2
# max_tokens: 4096
# temperature: 1.0
# supports_vision: false
# supports_thinking: true
# - name: deepseek-v4-pro
# display_name: DeepSeek V4 Pro
# use: langchain_openai:ChatOpenAI
# model: deepseek-v4-pro
# api_key: sk-e1c22b7931dc446f8b67e57878edd105
# base_url: https://api.deepseek.com
# request_timeout: 600.0
# max_retries: 2
# max_tokens: 4096
# temperature: 1.0
# supports_vision: false
# supports_thinking: true
# - name: MiniMax-M2.7
# display_name: MiniMax-M2.7
# use: langchain_openai:ChatOpenAI
# model: MiniMax-M2.7
# api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
# base_url: https://api.minimaxi.com/v1
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# - name: glm-5.1
# display_name: glm-5.1
# use: langchain_openai:ChatOpenAI
# model: glm-5.1
# api_key: 75121e4bed7640d5b71ac95cf8b4310a.RA3GyRBAR3kv7VrR
# base_url: https://open.bigmodel.cn/api/paas/v4/
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# - name: deepseek-ai/DeepSeek-OCR
# display_name: deepseek-ai/DeepSeek-OCR
# use: langchain_openai:ChatOpenAI
# model: deepseek-ai/DeepSeek-OCR
# api_key: sk-beshfxfxfrllsczpqfrgjbugfbpzxgqgedbehceytpcvvtyb
# base_url: https://api.siliconflow.cn/v1/
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# - name: zai-org/GLM-5-FP8
# display_name: zai-org/GLM-5-FP8
# use: langchain_openai:ChatOpenAI
# model: zai-org/GLM-5-FP8
# api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
# base_url: https://api.us-west-2.modal.direct/v1
# request_timeout: 600.0
# max_retries: 2
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# - name: Qwen/Qwen3.6-27B
# display_name: Qwen/Qwen3.6-27B
# use: langchain_openai:ChatOpenAI
# model: Qwen/Qwen3.6-27B
# api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
# base_url: https://api.siliconflow.cn/v1/
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# SiliconFlow 混合推理模型关思考用「请求体顶层 enable_thinking: false」,
# 不认 chat_template_kwargs / thinking.type 那两种嵌套形状。声明这个后,
# 网页端「关闭思考」对本模型才生效(开放接口另由 force_disable_thinking 兜底)。
# 识图(图片识别)配置。
# - model_name:指定一个 models[] 中的模型名,作为“默认识图大模型”。view_image 工具会把
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
# 视觉,也能“看图”。请改成你实际用于识图的模型名。
# - 若留空 / 注释掉 model_name,则回退到主对话模型自身的视觉能力(需该模型 supports_vision: true)。
# - prompt:不带具体问题时使用的默认识图指令(可选)。
# - max_tokens:识图模型输出上限(可选,默认用该模型自身的配置)。
vision:
model_name: deepseek-ai/DeepSeek-OCR
# prompt: 请详细描述这张图片的内容。
# max_tokens: 2048
# 深度研究可选的报告配图。默认关闭;仅在部署方配置文生图模型后,前端才会展示开关。
# 使用 OpenAI 兼容的 POST {base_url}/images/generations 协议,并强制要求 b64_json
# 返回,以便将图片保存为当前研究会话的受鉴权产物,而不是引用会过期的公网 URL。
deep_research:
image_generation:
enabled: true
provider: openai_compatible
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation # 例如 https://api.openai.com/v1
api_key: sk-sp-H.XDIPL.Zgvs.MEQCIBasaS2RPHwgNUpGVfFJeRaTp5UgmZ4Y2rCVGNtpwfD1AiBTFg3S-uE_gqVDnHgdyEhDwcfaVRLi92xviye80AZnkg # 生产环境建议填 $DEEP_RESEARCH_IMAGE_API_KEY
model: wan2.7-image # 例如 gpt-image-1
size: 1024x1024
# quality: standard
timeout_seconds: 120
# browser_act 相关配置
browser_context:
enabled: true
actions_enabled: true
# 上传文件识别配置。
# - auto_convert_documents: true 时,上传的 PDF / Word / Excel / PPT 会在服务端自动转换为
# Markdown(.md),agent 通过 read_file / grep 读取其内容 —— 即“识别”文档内容。
# 纯文本类(txt / csv / md / json / 代码等)无需转换,agent 用 read_file 直接读取。
# 图片(png/jpg/webp)由 view_image + 上面的 vision 识图模型处理。
# - pdf_converter: auto(优先 pymupdf4llm,缺失时回退 markitdown)| pymupdf4llm | markitdown
uploads:
auto_convert_documents: true
pdf_converter: auto
title:
enabled: true
max_words: 6
max_chars: 60
prompt_template: |
请为下面这段对话生成一个简洁的标题,概括对话的核心主题。
要求:
- 标题必须使用简体中文
- 尽量精炼,不超过 {max_words} 个词
- 只返回标题本身,不要加引号、不要解释、结尾不要标点
User: {user_msg}
Assistant: {assistant_msg}
# 上下文压缩(对话摘要):长对话接近上下文上限时,把较早的消息总结成一段摘要,
# 只保留最近若干条原始消息,避免撑爆模型上下文窗口。
# - trigger:触发条件,可配多个,任意一个先满足就压缩(OR 关系)。真正决定何时
# 压缩的是 trigger(不是 keep)。type=tokens 按 token 总数;type=messages 按条数。
# - keep:压缩后保留多少最近的上下文(tokens / messages)。
# - trim_tokens_to_summarize:喂给摘要模型的消息最多裁到多少 token。
# 当前为「测试值(易触发)」:发到服务器后稍微聊几句就会触发,可在 Gateway 日志里
# grep「上下文压缩」/「context compaction」确认生效;确认后再调回生产值
# (trigger tokens 60000 / messages 80,keep tokens 16000)。
summarization:
# 开关已移到聊天框下方(参考文献下面)的「上下文压缩」按钮,默认关闭。
# 这里 enabled 仅作为非 Web 路径(渠道/定时任务)的后备开关;下面的 trigger/keep
# 等仅是调参,Web 端是否压缩由前端按钮(summarization_enabled)决定。
enabled: false
# model_name: deepseek-chat # 留空则用主对话模型
trigger:
- type: tokens
value: 120000
- type: messages
value: 160
keep:
type: tokens
value: 40000
trim_tokens_to_summarize: 8000
tool_groups:
- name: web
- name: file:read
- name: file:write
- name: bash
tools:
- name: web_search
group: web
# DuckDuckGo search — no API key required. To switch to the deployment's
# internal search service later, restore the configurable_search block
# preserved in the comments below (fill in the real values) and point
# `use` back to deerflow.community.configurable_search.tools:web_search_tool.
use: deerflow.community.ddg_search.tools:web_search_tool
enabled: true
max_results: 5
# ── configurable_search alternative (deployment-internal service) ──
# endpoint: "https://xxx.xxx.xxx/searchName"
# verify_ssl: false
# timeout: 30
# result_url_template: "https://xxx.xxx.xxx/mindId/xxx?recUuid={recUuid}"
# The runtime query always overwrites payload.query; all other fields are
# sent unchanged so deployment-specific request parameters stay in config.
# payload:
# query: ""
# xxx: "xxxx"
- name: web_fetch
group: web
use: deerflow.community.jina_ai.tools:web_fetch_tool
timeout: 15
- name: ls
group: file:read
use: deerflow.sandbox.tools:ls_tool
- name: read_file
group: file:read
use: deerflow.sandbox.tools:read_file_tool
- name: glob
group: file:read
use: deerflow.sandbox.tools:glob_tool
max_results: 200
- name: grep
group: file:read
use: deerflow.sandbox.tools:grep_tool
max_results: 100
- name: write_file
group: file:write
use: deerflow.sandbox.tools:write_file_tool
- name: str_replace
group: file:write
use: deerflow.sandbox.tools:str_replace_tool
- name: bash
group: bash
use: deerflow.sandbox.tools:bash_tool
# browser_act 相关配置
- name: browser_fetch_page
group: web
use: deerflow.tools.builtins.browser_context_tool:browser_fetch_page_tool
# browser_act 相关配置
- name: browser_act
group: web
use: deerflow.tools.builtins.browser_act_tool:browser_act_tool
sandbox:
use: deerflow.sandbox.local:LocalSandboxProvider
allow_host_bash: true
mounts:
- host_path: C:\Users\iie\Desktop\deerflow-new\offline-backend-20260512\backend\knowledge\knowledge_base
container_path: /mnt/knowledge
read_only: true
skills:
path: skills
security_scan:
enabled: false
skill_evolution:
enabled: true
curator:
enabled: true # 每 24 小时自动清理 + 合并 skill
interval_hours: 24
review_worker:
enabled: true # 被动复盘开关
min_tool_calls: 5 # 触发阈值:本轮至少 5 次工具调用,与 prompt 中"≥5 步"硬条件一致
max_messages: 60 # 传给 LLM 的最大消息条数
timeout_seconds: 120 # 单次 review 超时(秒)
model_name: # 留空则使用主模型
agents_api:
enabled: true
# 登录鉴权扩展:在基础登录之上叠加两种自定义模式。
auth_login:
# 用户名直登口令门:开关打开后,/login/<用户名> 直登必须再带 ?password=口令,
# 口令不对直接 401。password 留空则默认口令为 123ewq。仅作用于用户名直登。
username_login:
require_password: false
password: ''
# token 换登录:前端用 ?authToken=上游token 进入,后端调用 token_info_url
# (把该 token 直接拼成 Authorization: Bearer <token> 头)换出用户名,再登录;
# 用户名不在库里时自动注册。
token_login:
enabled: true
token_info_url: 'https://ch1.b.uat.4.cn/consumer/login/getTokenInfo'
# 已弃用:鉴权头现由前端传入的 token 拼成 "Bearer <token>",此项不再生效,保留仅兼容。
service_authorization: 'Bearer admin'
timeout_seconds: 10
# 上游是 HTTPS。UAT/内网常用自签或私有 CA 证书,默认 verify=true 会握手失败导致
# 永远 401。此处 uat 域名按自签处理,先关校验;生产请置 true 或配 ca_cert_path。
verify_ssl: false
ca_cert_path: '' # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验
# 离线测试映射:token -> 用户名。命中的 token 跳过 token_info_url 调用,直接当成
# 该用户登录——外网调不通 getTokenInfo 时用来本地全链路联调。生产留空即可(不生效)。
# 例:用 /login/任意?authToken=123ewq 进入,即以 admin 身份登录。
mock_tokens:
'123ewq': 'admin'
# 日志系统入口(仅管理员可见):右下角「设置」菜单中会多出一个「日志系统」入口,
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
database:
backend: sqlite
sqlite_dir: .deer-flow/data
pool_size: 20 # 基础连接池(postgres/mysql 生效;sqlite 忽略)
max_overflow: 20 # 高峰期可临时超出 pool_size 的额外连接数,真实上限 = pool_size + max_overflow
pool_timeout: 30 # 池满时等待空闲连接的秒数,超时报错
pool_recycle: 1800 # 连接回收周期(秒);防火墙/pgbouncer/MySQL wait_timeout 静默断连兜底。多 worker 部署:每 worker 上限 = pool_size + max_overflow,总数 × worker 数 < DB max_connections
echo_sql: false
checkpointer:
type: sqlite
connection_string: .deer-flow/data/deerflow.db
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
# 三层覆盖优先级(高->低):
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
memory:
enabled: true
version:
v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取)
# 'v2'(USER.md/MEMORY.md + 可选 Hindsight)
provider:
hindsight # 'builtin' | 'hindsight'(外部 provider 名)
# builtin 始终启用,这里只决定 external 选哪个
# (version=v1 时此字段忽略)
# V1 专属配置 —— version=v1 时生效
v1:
max_facts: 100 # 最多保留的事实条数,超出时驱逐最低置信度的事实
fact_confidence_threshold: 0.7 # 事实置信度阈值,低于此值不保存
debounce_seconds: 30 # 对话结束后延迟多少秒才触发 LLM 提取
model_name: '' # 用于事实提取的 LLM 模型名;空则继承全局默认模型
token_budget: 2000 # 注入系统提示词的 token 预算
builtin:
enabled: true
memory_char_limit: 2200
user_char_limit: 1375
deduplicate_on_load: true
hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务
mode: local_external # 'cloud' | 'local_embedded' | 'local_external'
api_url: http://47.88.25.99:18888
api_key:
'' # 部署 Hindsight 后填,或改成 $HINDSIGHT_API_KEY 并设同名环境变量
# 注意:写成 $VAR 但环境变量未设置会导致配置加载失败
bank_id_template: 'deerflow-user-{user_id}'
work_bank_id_template: 'deerflow-work-{user_id}-{agent_id}'
memory_mode: context # 'context' | 'tools' | 'hybrid'
bank_mission: '你是一个中文记忆助手,负责从对话中提炼和存储重要信息。所有记忆内容必须使用中文记录。'
bank_retain_mission: '请从以下对话中提炼重要信息,包括用户的偏好、需求、背景知识和关键事实。所有提炼的内容必须用中文表达。'
recall_budget: mid # 'low' | 'mid' | 'high'
recall_max_tokens: 4096
recall_max_input_chars: 800
auto_recall: true
auto_retain: true
retain_async: true
retain_every_n_turns: 1
retain_user_prefix: 'User'
retain_assistant_prefix: 'Assistant'
# background_extraction:预留功能,代码尚未实现 —— 仅设计文档存在。
# 待对应版本支持后,取消下面整段注释即可启用(漏记兜底)。
# background_extraction:
# enabled: false # 默认关闭,作为漏记兜底
# debounce_seconds: 30
# model_name: ~ # null = 用默认模型
# fact_confidence_threshold: 0.7
# max_facts_per_extraction: 10
# correction_detection: true
# reinforcement_detection: true
injection:
enabled: true
max_tokens: 2000
include_builtin: true
include_hindsight: true
context_tag: 'memory-context' # 包裹 Hindsight recall 的标签名
security:
scan_content: true # 注入/外渗模式扫描
block_invisible_unicode: true
# streaming_scrubber:预留字段,代码尚未实现 —— 待版本支持后取消注释。
# streaming_scrubber: true # SSE 流前清洗 <memory-context>
# AI 写作子系统 —— 素材收集专家专用配置。
# 注意:这三项必须嵌套在 ai_writing: 段下;写到顶层会被 _ai_writing_config()
# (app/gateway/routers/ai_writing.py:_ai_writing_config) 直接忽略。
ai_writing:
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
# 用「真实智能体运行时」跑配置技能(read SKILL.md + 全套工具 + 沙箱),让技能的真实
# 检索逻辑(调内网接口/MCP/读文件)真正执行,而不是塞进通用 web/ES 检索 provider。
# 内网技能必须开 true,否则技能会走通用 provider、连不上内网而报 ConnectError。
# 仅对非「知识库检索(knowledge-base-search)」的配置技能生效;失败回退直连检索。
researcher_skill_agent: true
# 最终兜底:当所有技能都没检索到素材时,直连内网知识库 ES 接口兜底检索(避免素材全空)。
# 配上你内网 ES 接口地址即启用;留空则不启用该兜底。
# 接口契约:POST {url} 体 {"query","knowledge_base_name","ts":"yes"} → [{"page_content","metadata":{m_title,content,recUuid,m_publish,source1}}]
intranet_search_url: "" # 例如 http://10.106.57.180:7863/knowledge_base/search_docs_es
intranet_knowledge_base: "samples" # 兜底检索用的知识库名
intranet_search_timeout: 300 # 内网检索超时时间(秒),代码层强制最低 300;需要更长可调大
# 内网接口诊断开关:开启后即使技能已检索成功,也照样把内网接口跑完并把执行结果
# (端点/每个检索词命中数/报错/原始响应片段)记到 material_package.intranet_debug 里,
# 便于在接口返回(devtools)排查内网接口到底通不通。生产建议关掉(会多跑一次内网)。
intranet_search_debug: false
# 四阶段「内置功能型智能体」开关(按阶段灰度)。true = 该阶段节点改为加载
# .deer-flow/agents/ai-writing-<stage>/ 的 SOUL.md + config(admin 可在智能体
# 管理页编辑人设/模型),false / 缺失 = 走旧的硬编码 prompt 路径。
# 改完保存即生效(config 按 mtime 热加载),新写作会话立即按新开关运行。
use_builtin_agents:
researcher: false # 素材收集专家(检索词生成 / 素材排序两个子任务)
outliner: false # 大纲规划师(大纲生成 / 按反馈调整)
writer: false # 作家(逐章正文写作,逐章流式与严格模式求助逻辑不变)
editor: false # 编辑(事实/逻辑/语言三维度审查)
# Optional independently deployed WeKnora integration. DeerFlow owns only
# these connection values; database/Redis/storage/parser/model settings stay
# in WeKnora's own docker-compose/.env/config files. A non-empty api_base_url
# enables WeKnora mode; leaving it empty preserves the existing LLMWiki iframe.
# admin_email/admin_password are used only by DeerFlow's server-side WeKnora
# detail-page iframe proxy. Prefer environment variables in production, e.g.
# admin_email: "$WEKNORA_ADMIN_EMAIL"
# admin_password: "$WEKNORA_ADMIN_PASSWORD"
llmwiki:
weknora:
api_base_url: "http://119.254.86.251:8088/deerflow-api"
web_base_url: "http://119.254.86.251:8088"
admin_email: "lqq@qq.com"
admin_password: "1qaz@WSX"
log_system:
enabled: true
url: 'https://your-log-system.example.com'
label: '日志系统'
task_deeplink:
# 线上接口未通时用假数据;三个开关互相独立。
test: true # 目的树查询走 mock.purpose_detail 假数据
delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完)
action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id)
# 开放接口 POST /api/open/3qfx/ask 按 taskId 拉取任务详情拼开场白;上游不可达时打开下面开关走 mock.cop_task_detail。
cop_task_detail_test: true
cop_task_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/cop-task-detail"
purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail"
# rwfx 与 xdfx 共用同一删除接口:rwfx 追加 ?taskId=(不传 actionId);xdfx 追加 ?actionId=(不传 taskId,直接不出现)。
delete_url: "https://ch1.b.uat.4.cn/bwjt/dropSixOrSevenData"
treemap_edit_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit"
treemap_next_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next"
# xdfx 深链传入的是「行动id」,先调该接口(追加 /{actionId})拿 data.taskId 解析出真正「任务id」。
action_detail_url: "https://ch1.b.uat.4.cn/consumer/plan/action"
# xdfx 删除前「行动历史是否存在」预检接口(追加 ?actionId=);地址待提供,留空则前端视作无历史、不阻断删除。
action_history_url: ""
# rwfx 目的树面板「3q详情」链接的目标页(地址待定,留空则前端提示未配置);跳转追加 ?id=任务id。
detail_3q_url: ""
mock:
# test=true 时原样返回给前端(结构同真实 getTaskPurposeDetail 返回)。
purpose_detail:
state: "200"
msg: "操作成功!"
data:
- purposeDto: { id: 5837, zzPurpose: "目的01", selectedFlag: 1, taskId: 3324, recDetailId: 4271 }
childrenList:
- actorDto: { id: 9101, taskId: 3324, purposeId: 5837, actorName: "行为体01", actorOrgan: "行为体01", recReason: "行为体01理由", selectedFlag: 1 }
childrenList:
- expectedBehaviorDto: { id: 6910, taskId: 3324, purposeId: 5837, behavior: "预期01", recReason: "预期01理由", selectedFlag: "1" }
childrenList:
- drivingFactorDto: { id: 7964, taskId: 3324, purposeId: 5837, drivingFactor: "预期01因素", selectedFlag: 1 }
childrenList:
- narrationDto: { id: 6500, purposeId: 5837, taskId: 3324, narration: "叙事01", recReason: "叙事01理由", selectedFlag: 1 }
childrenList: []
- actorDto: { id: 9102, taskId: 3324, purposeId: 5837, actorName: "行为体02", actorOrgan: "行为体02", recReason: "行为体02理由", selectedFlag: 1 }
childrenList:
- expectedBehaviorDto: { id: 6911, taskId: 3324, purposeId: 5837, behavior: "预期02", recReason: "预期02理由", selectedFlag: "1" }
childrenList:
- drivingFactorDto: { id: 7965, taskId: 3324, purposeId: 5837, drivingFactor: "预期02因素", selectedFlag: 1 }
childrenList:
- narrationDto: { id: 6501, purposeId: 5837, taskId: 3324, narration: "叙事02", recReason: "叙事02理由", selectedFlag: 1 }
childrenList: []
# delete_test=true 时原样返回给前端。改 success 测「成功覆盖 / 不可覆盖」两条分支。
delete:
message: "行动已经开始策划,不可覆盖"
success: true
# action_test=true 时原样返回给前端(结构同真实 /consumer/plan/action/{actionId} 返回)。
# 前端取 data.taskId 作为真正的「任务id」(xdfx 深链 ?taskId= 传入的是 data.id 这个行动id)。
action_detail:
state: "200"
msg: "操作成功!"
data:
id: 27354
purposeId: 2850
taskId: 2055
actionName: ""
actionStatus: 25
narration: ""
# cop_task_detail_test=true 时原样返回给开放接口 /api/open/3qfx/ask(结构同真实 cop-task-detail 返回)。
cop_task_detail:
data:
id: 2055
overview: "暑期文旅消费高峰期间的舆情态势综合研判与引导任务"
taskDirection: "正向引导 + 风险预警"
taskName: "任务01:暑期文旅舆情综合研判"
taskContent: "围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。"
+47 -2
View File
@@ -342,6 +342,7 @@ FastAPI application on port 8001 with health checks at `GET|HEAD /health`, `/api
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
| **Light Apps** (`/api/light-apps`) | Light-app registry (????? / ????), **global/shared** (no user scoping; `created_by`/`updated_by` audit-only). Two kinds: `window_open` (launched in a new tab from the ???? card grid) and `iframe` (embedded under a parent sidebar menu via `mount_location` and dynamically injected into the sidebar). Both carry `route_params` ? a list of `{key, paramType: login\|theme, source, customValue, themeValue}` descriptors the **frontend** resolves at launch time (login params read from `localStorage.userInfo`; theme params use a literal value) and appends to `url` as query string. `GET /` - list (filters `app_type`/`mount_location`/`enabled_only`/`search`); `GET /{app_id}` - read one (both open to any authenticated user); `POST /`, `PUT /{app_id}`, `DELETE /{app_id}` - **admin only**; iframe apps must carry a `mount_location` (400 otherwise); the unique `name` collides as HTTP 409. Backed by `deerflow.persistence.light_apps` (table `light_apps`, auto-created by `create_all`); store wired in `deps.py` as `app.state.light_app_store`. Tests: `tests/test_light_apps.py`. |
| **Menu Overrides** (`/api/menu-overrides`) | Sidebar-menu overrides (菜单管理), **global/shared** (no user scoping; `updated_by` audit-only). The static menu tree (icons/routes/`adminOnly`) lives in the **frontend** code (`core/page-layout/sidebar-menu.ts`); iframe light-apps are injected at render time. This router persists a per-node **overlay** the frontend merges onto that tree: `parent_id` (re-parent, moves the whole subtree; NULL = top level), `sort_order` (reorder among siblings), `label` (rename; NULL keeps code label), `disabled` (停用 — hides the node **and its subtree**). A node with no row renders exactly as code defines it, so new code menus / newly-registered iframe apps appear automatically and are immediately configurable. `GET /` - list all overrides (open to any authenticated user); `PUT /` - **admin only**, atomically **replaces the whole set** (`{overrides: [...]}`); rejects re-parenting that would exceed **3 levels** or form a cycle (`_validate_depth`, HTTP 400). Backed by `deerflow.persistence.menu_overrides` (table `menu_overrides`, auto-created by `create_all`); store wired in `deps.py` as `app.state.menu_override_store`. Frontend: merge logic `core/page-layout/menu-overrides.ts` (`applyMenuOverrides` in `use-sidebar-menu.ts`), client `strategy-components/api/menu-overrides.ts`, admin page `pages/MenuManagementPage.tsx` (@dnd-kit drag-reorder/re-parent + 「移动到」parent selector + ↑↓ arrows + 改名/停用), entry in the bottom-left settings dropdown (`components/page-sidebar.tsx`, admin-only). Tests: `tests/test_menu_overrides.py`, `frontend-web/src/core/page-layout/menu-overrides.test.ts`. |
| **Compact Menu Overrides** (`/api/compact-menu-overrides`) | Compact/sidebar-summary left-nav overlay (简介模式菜单), **global/shared**. Two independent sets keyed by `menu_type` (`A` default / `B`); saving one type does not wipe the other. Type B rows use a `B::` node_id prefix so they stay unique on databases that still use `node_id` as the sole PK. `GET ?menu_type=` lists one type (open); `PUT {menu_type, overrides}` admin-only replaces that type. Frontend: `MenuManagementPage` 简介模式 type switcher + `runtime-config.js` `VITE_COMPACT_MENU_TYPE`. Tests: `tests/test_compact_menu_overrides.py`. |
| **Sensitive Words** (`/api/sensitive-words`) | Sensitive-word / desensitize map (敏感词管理 / 脱敏词), **global/shared** (no user scoping; `updated_by` audit-only). Each row maps an original `word` → `replacement`, with `enabled` (停用 parks a word without deleting) + `sort_order`. The **frontend** ships a seed map in code (`src/lib/desensitize.ts` `SEED_DESENSITIZE_MAP`) used as the default on a fresh deployment; once an admin saves, this table is the source of truth and the frontend loads it at startup (`DesensitizeWordsLoader` → `replaceDesensitizeMap`, **enabled** rows only). `GET /` - list all words (open to any authenticated user — every page desensitizes); `PUT /` - **admin OR the `lqq` account** (`_require_manager`: `system_role == "admin"` or email `@`-prefix `== "lqq"`), atomically **replaces the whole set** (`{words: [...]}`, de-duped by `word`, last wins). Backed by `deerflow.persistence.sensitive_words` (table `sensitive_words`, PK `word` `String(191)`, auto-created by `create_all`); store wired in `deps.py` as `app.state.sensitive_word_store`. Frontend: client `strategy-components/api/sensitive-words.ts`, page `pages/SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, entry in the bottom-left settings dropdown — **gated on username `lqq`, not admin role**, present in both sidebar modes; 「导入种子词」 merges missing seed entries). Tests: `tests/test_sensitive_words.py`. |
| **Dashboard Sessions** (`/api/dashboard-sessions`) | 「大屏绘制智能体」独立页面会话,**按外部 `task_id` 存取,不按 user 分权**(open read + shared writes;`user_id` audit-only)。一个 task 一行(upsert):`transcript`(聊天记录 JSON)+ `report_json`(最新结构化数据,可解析 = 「有效结果」)。`GET /by-task/{task_id}` - 取该 task 会话(无则 404);`PUT /by-task/{task_id}` - upsert(`{transcript?, report_json?}`,只覆盖传入项)。Backed by `deerflow.persistence.dashboard_sessions`(table `dashboard_sessions`, auto-created by `create_all`,无迁移);store wired in `deps.py` as `app.state.dashboard_session_store`。前端独立页 `roundtable-planning/pages/DashboardAgentPage.tsx`(路由 `/page/strategy/dashboard-agent?taskId=`,侧边栏「问答管理 → 大屏绘制」)让智能体分析任务报告 JSON → report-json → 渲染 `DashboardApp`。Tests: `tests/test_dashboard_sessions.py`。 |
| **Task Reports** (`/api/task-reports`) | 任务级研判情报报告详情,**按 `task_id` 共享、不按 user 分权**(写入者只留 `updated_by` 审计)。`GET /by-task/{task_id}` 始终返回原 Consumer 兼容外壳 `{state:"200", msg:"操作成功!", data:[...]}`(未保存为 `data: []`);`POST /by-task/{task_id}` 首次保存,已有报告返回 409;`PUT /by-task/{task_id}` 原子替换完整 `data` 数组。`POST /import/by-task/{task_id}` 面向 TaskCOP 导入页,接收 `sq-report-mock.json` 兼容的 `data`/`records` 数组并完整覆盖目标任务;路径中的选定任务 id 优先于文件内的 `taskId`,避免误写。每一项严格是 `{id, taskId, createTime, updateTime, sessionId, contentJson, categoryType}`;`contentJson` 以 JSON **字符串**原样落在 `records_json`,避免破坏大屏按类目解析的输入格式。保存或导入非空 `data` 后,router 会通过 `app.state.taskcop_task_store.mark_analysis_completed` 将同 id 的 TaskCOP 任务升级至状态 `25`,使旧任务列表显示并筛选为“已完成”;普通保存时不存在匹配任务的报告仍可独立保存,导入则要求目标任务存在。Backed by `deerflow.persistence.task_reports`(table `task_reports`, auto-created by `create_all`);store wired in `deps.py` as `app.state.task_report_store`。前端客户端 `strategy-components/api/task-reports.ts`,`core/auth/dashboard-input.ts` 与 rwfx 3q 完成检查均直接读此接口,不再依赖 Consumer 或本地 sq-report mock。Tests: `tests/test_task_reports.py`。 |
@@ -481,6 +482,16 @@ Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊
### Position-collaboration foundation
### Roundtable business-chain enable/disable
`roundtable_chains.enabled` (BOOLEAN NOT NULL DEFAULT 1, migration `20260819_02`)
is the per-chain 启停 switch. New and existing chains default to **enabled**.
Owner or admin can flip it via `PUT /api/roundtable-chains/{id}` `{enabled}`.
Disabled chains stay visible in the management list (with an 启用/停用 filter)
and still show `updated_at`; the frontend hides them from the picker / public
entry rail so they cannot be selected into a roundtable. Tests:
`tests/test_roundtable_chain_stages.py` (`test_create_defaults_enabled_then_toggle`).
### Roundtable business-chain human validation
The existing `/api/roundtable-chains` `seats` JSON also accepts the optional
@@ -1147,6 +1158,10 @@ Automatic conversation summarization when approaching token limits:
the config flag. `config.summarization.enabled` now defaults to **false**.
- `config.yaml` `summarization` still supplies the **tuning**: trigger types
(tokens, messages, or fraction of max input) and the keep policy.
- A compressed request must retain the latest non-empty `HumanMessage`, even
when it has already been folded into the hidden summary. This keeps strict
OpenAI-compatible gateways from rejecting a tool-continuation call with
`No user query found in message`.
- Keeps recent messages while summarizing older ones.
Tests: `tests/test_summarization_toggle.py` (gating + whitelist),
@@ -1265,8 +1280,13 @@ the terminal `lastJobId` in `usage_snapshot` so a refreshed conversation page
can replay its trace from the event log. Detailed mode uses stable targets (`introduction`,
`section:N`, `conclusion`) because section writers are concurrent;
multi-agent revisions must emit `report_reset` before their next report
version. Do not emit raw model reasoning or one persisted event per provider
token. The Vite page owns Word export through
version. Do not persist raw model reasoning or one persisted event per provider
token. Models that expose thinking inside ordinary `<think>...</think>` text
must be split statefully before a report delta, checkpoint, or Markdown
projection is created; an optional live-only `report_thinking` frame can drive
the message-list thought trace, while the sandbox receives answer Markdown
only. The Vite
page owns Word export through
`frontend-web/src/core/scheduled-tasks/markdown-docx.ts`; do not add Python
document-conversion dependencies merely to export text.
All non-streaming Deep Research control-plane calls (`complete()` for query /
@@ -1284,6 +1304,31 @@ The per-user Deep Research concurrency guard counts only session statuses
consume a slot; otherwise saved drafts make `POST /sessions/{id}/jobs` return
429 permanently. Regression: `test_saved_draft_sessions_do_not_consume_research_concurrency`.
`POST /api/deep-research/sessions/{id}/jobs` is retry-safe under MySQL
read/write splitting. Deep Research session/job/event/source/message writes
must serialise their changed row while the write transaction is still open
(`flush` for inserts, select-after-update **before** commit), never through a
post-commit `session.refresh()` or a fresh pooled read. Replica lag otherwise
turns a committed job into a misleading 500, can make dispatcher `claim_job()`
lose its snapshot, or can fail report/follow-up persistence after the model has
already completed. Always nudge the dispatcher for both a newly created job and
an idempotently reused queued job; otherwise a retry after such an interrupted
response returns the old job but leaves it waiting for the periodic scan. The
SSE replay generator must catch temporary event/job-store reads, emit a
recoverable warning, and keep the connection open so the next poll can repair
the cursor instead of surfacing an ASGI `ExceptionGroup`. Regressions:
`test_deep_research_writes_never_depend_on_post_commit_refresh`,
`test_job_create_never_reads_back_after_commit`,
`test_retry_of_existing_queued_job_nudges_dispatcher`, and
`test_stream_stays_open_when_durable_replay_has_a_transient_failure`.
At ordinary report completion, persist and confirm the session's
`report_markdown` projection **before** marking the Job `completed`. Retry a
short transient projection failure; if it cannot be confirmed, let the job
become failed rather than reporting a report that vanishes on refresh.
`test_report_is_persisted_before_job_is_marked_completed` and
`test_report_projection_failure_never_marks_job_completed` cover the ordering.
The chat-collection frontend must keep report-writing progress in the existing
workspace `MessageList` / `MessageGroup` tool-step renderer: inject only
virtual `deep_research_progress` tool calls for the durable phases, keep the
+2 -2
View File
@@ -1,8 +1,8 @@
install:
uv sync
dev:
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload
dev:
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload --reload-exclude ".deer-flow/*" --reload-exclude ".runtime/*"
gateway:
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001
+16 -7
View File
@@ -151,7 +151,7 @@ FastAPI application providing REST endpoints for frontend integration:
| `GET /api/threads/{id}/uploads/list` | List uploaded files |
| `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
| `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts |
| `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. |
| `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Deep Research session/job/event/source/message writes serialise their rows in the writer transaction, rather than doing a post-commit ORM refresh or readback; this prevents MySQL read/write-splitting replica lag from failing “start writing”, dispatcher claim, report persistence, or follow-up history. A report is written to the session and confirmed before its Job becomes `completed`; an unconfirmed write is retried and never exposed as a false completion. Retrying an already-created queued job re-nudges the dispatcher, recovering an interrupted HTTP response without creating a second report. If progress-event persistence, durable replay, or live fan-out is briefly unavailable, the worker/stream logs the degradation and continues or reconnects rather than failing the whole job. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. |
| `POST /api/ai-writing/sample/extract` | Extract text from Word/PDF/Markdown/TXT samples for AI-writing imitation mode |
| `POST /cop/saveSuperiorTask` | TaskCOP compatibility API (DeerFlow Bearer/session authentication required): create a situation-overview task with a sequential four-digit id (`0001`, `0002`, ...) and return the legacy `{state, msg, data}` envelope |
| `POST /api/taskcop/import/tasks` | TaskCOP compatibility API (authenticated): import tasks from a JSON array or `tasks`/`records` envelope; every row's supplied `id`/`taskId` is authoritative and a duplicate id completely replaces the stored task |
@@ -180,6 +180,11 @@ the right-side `report.md` sandbox can write smoothly, while bounded,
persisted `report_chunk` events remain reconnect checkpoints. The durable log
is still authoritative: a browser reconnect, slow consumer, or another worker
falls back to `?after=<seq>` replay rather than starting a second model call.
Reasoning exposed by providers either through structured reasoning fields or
inline `<think>...</think>` text is separated from report deltas, checkpoints,
and the persisted Markdown projection. It may be forwarded as an ephemeral
live `report_thinking` frame for the message-list thought trace, but is never
persisted or written to the sandbox; only answer Markdown reaches the sandbox.
The short non-streaming control-plane calls used to plan queries, sections, and
reviews are independently capped at 45 seconds (maximum 120 seconds). If query
or section planning times out, the runner emits a visible recoverable warning
@@ -231,12 +236,16 @@ only applies to sessions with a `running` or `awaiting_input` durable job.
DeerFlow's stock configuration includes image recognition but no image-generation
provider. Deep Research therefore keeps illustrations disabled until an operator
fills `config.yaml -> deep_research.image_generation` with an
OpenAI-compatible image endpoint, API key, and model. When configured, the
capabilities endpoint enables the page's “生成报告配图” switch. A selected job
requests at most four base64 image results, writes them as protected
`research-image-*.png|jpg|webp` artifacts, injects `deep-research://` links into
the Markdown report, and emits image SSE events. A failed/unconfigured image
provider only emits a recoverable warning; the text report still completes.
image endpoint, API key, and model. `provider: openai_compatible` uses the
standard `/images/generations` protocol and requires `b64_json`; `provider:
dashscope_native` supports Qwen Image 3.0 and Wan 2.7 on DashScope / Token Plan
with the native multimodal-generation protocol. Native provider image URLs are
downloaded immediately, so both providers produce protected local artifacts.
When configured, the capabilities endpoint enables the page's “生成报告配图” switch.
A selected job writes at most four `research-image-*.png|jpg|webp` artifacts,
injects `deep-research://` links into the Markdown report, and emits image SSE
events. A failed/unconfigured image provider only emits a recoverable warning;
the text report still completes.
Completed reports also expose `GET /api/deep-research/sessions/{id}/messages`
and `POST /api/deep-research/sessions/{id}/chat`. Follow-up answers are grounded
File diff suppressed because it is too large. Load diff
@@ -44,6 +44,7 @@ from deerflow.agents.deep_research.types import (
DeepResearchResult,
)
from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.utils.stream_text import InlineThinkTagFilter
if TYPE_CHECKING:
from deerflow.config.app_config import AppConfig
@@ -61,6 +62,8 @@ WORKER_ID = f"w-{os.getpid()}-{_uuid.uuid4().hex[:8]}"
_LEASE_TTL_SECONDS = 60
_HEARTBEAT_INTERVAL = 20.0
_CANCEL_POLL_INTERVAL = 5.0
_REPORT_PROJECTION_RETRY_ATTEMPTS = 3
_REPORT_PROJECTION_RETRY_DELAY_SECONDS = 0.5
# Phase → progress% mapping for the job timeline.
_PHASE_PROGRESS: dict[str, int] = {
@@ -155,9 +158,7 @@ class RecordingEventSink:
elif type == "sources_curated" and self._source_store is not None:
for sid in (payload or {}).get("selectedIds", []):
try:
await self._source_store.set_selected(
sid, selected=True, reason="curated", user_id=self._user_id
)
await self._source_store.set_selected(sid, selected=True, reason="curated", user_id=self._user_id)
except Exception: # noqa: BLE001
pass
@@ -263,14 +264,8 @@ class DeepResearchJobExecutor:
cancel_token = CancellationToken()
main_task = asyncio.current_task()
heartbeat = (
asyncio.create_task(self._heartbeat(job_id, lease_owner, main_task))
if lease_owner
else None
)
watcher = asyncio.create_task(
self._watch_cancellation(job_id, main_task, lease_owner, cancel_token)
)
heartbeat = asyncio.create_task(self._heartbeat(job_id, lease_owner, main_task)) if lease_owner else None
watcher = asyncio.create_task(self._watch_cancellation(job_id, main_task, lease_owner, cancel_token))
try:
# Normal sandbox Markdown rewrites share the same durable lease,
@@ -388,9 +383,7 @@ class DeepResearchJobExecutor:
session_for_model.setdefault("config_snapshot", snapshot.get("config") or {})
if not report.strip() or not instruction:
await self._mark_document_rewrite_failed(
job_id, session_id, lease_owner, "INVALID_SNAPSHOT", "改写任务快照不完整,无法继续执行。"
)
await self._mark_document_rewrite_failed(job_id, session_id, lease_owner, "INVALID_SNAPSHOT", "改写任务快照不完整,无法继续执行。")
return
events = PersistingEventSink(
@@ -419,9 +412,7 @@ class DeepResearchJobExecutor:
phase="planning",
payload={"type": "requirements_started"},
)
await self._job_store.update_progress(
job_id, lease_owner=lease_owner, phase="planning", progress=20
)
await self._job_store.update_progress(job_id, lease_owner=lease_owner, phase="planning", progress=20)
requirement_parts: list[str] = []
# Persist the first fragment too: the HTTP client opens its SSE stream
# just after queuing the job, so this closes the tiny subscribe race
@@ -447,6 +438,14 @@ class DeepResearchJobExecutor:
)
last_requirements_checkpoint = time.monotonic()
async def on_requirement_thinking(delta: str) -> None:
if delta:
await events.publish_live(
"document_rewrite",
phase="planning",
payload={"type": "thinking", "chunk": delta},
)
requirements_fallback = False
try:
requirements_plan = await stream_full_report_rewrite_requirements(
@@ -456,6 +455,7 @@ class DeepResearchJobExecutor:
style=style_instruction,
model_name=model_name,
on_delta=on_requirement_delta,
on_reasoning=on_requirement_thinking,
)
except asyncio.CancelledError:
raise
@@ -490,9 +490,7 @@ class DeepResearchJobExecutor:
"plan": requirements_plan,
},
)
await self._job_store.update_progress(
job_id, lease_owner=lease_owner, phase="writing", progress=50
)
await self._job_store.update_progress(job_id, lease_owner=lease_owner, phase="writing", progress=50)
draft_parts: list[str] = []
last_checkpoint = time.monotonic()
@@ -527,6 +525,14 @@ class DeepResearchJobExecutor:
)
last_checkpoint = time.monotonic()
async def on_thinking(delta: str) -> None:
if delta:
await events.publish_live(
"document_rewrite",
phase="writing",
payload={"type": "thinking", "chunk": delta},
)
try:
rewritten, citation_ids, _usage = await stream_full_report_rewrite(
session=session_for_model,
@@ -536,6 +542,7 @@ class DeepResearchJobExecutor:
style=style,
model_name=model_name,
on_delta=on_delta,
on_reasoning=on_thinking,
requirements_plan=requirements_plan,
)
except asyncio.CancelledError:
@@ -558,9 +565,7 @@ class DeepResearchJobExecutor:
await events.emit("document_rewrite", phase="reviewing", payload={"type": "validation_started"})
validation_error = validate_markdown(rewritten)
if validation_error:
await self._mark_document_rewrite_failed(
job_id, session_id, lease_owner, "VALIDATION_FAILED", validation_error, events=events, stage="validation"
)
await self._mark_document_rewrite_failed(job_id, session_id, lease_owner, "VALIDATION_FAILED", validation_error, events=events, stage="validation")
return
before_headings = heading_count(report)
@@ -580,13 +585,9 @@ class DeepResearchJobExecutor:
validation_warnings=warnings,
)
summary["citationCount"] = len(citation_ids)
await events.emit(
"document_rewrite", phase="reviewing", payload={"type": "comparison_ready", **summary}
)
await events.emit("document_rewrite", phase="reviewing", payload={"type": "comparison_ready", **summary})
await events.emit("document_rewrite", phase="exporting", payload={"type": "commit_started"})
await self._job_store.update_progress(
job_id, lease_owner=lease_owner, phase="exporting", progress=90
)
await self._job_store.update_progress(job_id, lease_owner=lease_owner, phase="exporting", progress=90)
committed_hash = __import__("hashlib").sha256(rewritten.encode("utf-8")).hexdigest()
updated = await self._session_store.replace_report_if_unchanged(
@@ -641,9 +642,7 @@ class DeepResearchJobExecutor:
expected_report=rewritten,
report_markdown=report,
)
message = "保存改写版本失败,报告已恢复原文。" if rolled_back is not None else (
"保存改写版本失败,且报告随后被修改,未再覆盖。"
)
message = "保存改写版本失败,报告已恢复原文。" if rolled_back is not None else ("保存改写版本失败,且报告随后被修改,未再覆盖。")
await self._mark_document_rewrite_failed(
job_id,
session_id,
@@ -747,6 +746,11 @@ class DeepResearchJobExecutor:
instruction = str(snapshot.get("instruction") or "").strip()
style = str(snapshot.get("style") or "professional").strip()
model_name = str(snapshot.get("model_name") or "").strip() or None
generate_images = bool(snapshot.get("generate_images"))
try:
max_generated_images = min(max(int(snapshot.get("max_generated_images") or 2), 1), 4)
except (TypeError, ValueError):
max_generated_images = 2
async def fail(code: str, message: str, *, stage: str = "generation") -> None:
await events.emit(
@@ -806,13 +810,20 @@ class DeepResearchJobExecutor:
)
return
rewrite_source = original
if generate_images:
# Do not ask the writer to preserve a previous run's managed image
# blocks. A new illustration pass will create a fresh, local set;
# user-authored image links remain untouched.
from app.gateway.document_rewrite_illustrations import strip_managed_document_illustrations
rewrite_source = strip_managed_document_illustrations(original)
style_instruction = DOCUMENT_STYLE_INSTRUCTIONS.get(
style,
DOCUMENT_STYLE_INSTRUCTIONS["professional"],
)
resolved_name = model_name or (
self._app_config.models[0].name if self._app_config.models else None
)
resolved_name = model_name or (self._app_config.models[0].name if self._app_config.models else None)
model_cfg = self._app_config.get_model_config(resolved_name) if resolved_name else None
thinking_enabled = bool(model_cfg and getattr(model_cfg, "supports_thinking", False))
try:
@@ -847,12 +858,21 @@ class DeepResearchJobExecutor:
last_requirements_checkpoint = 0.0
requirements_fallback = False
try:
async def on_requirement_thinking(delta: str) -> None:
if delta:
await events.publish_live(
"document_rewrite",
phase="planning",
payload={"type": "thinking", "chunk": delta},
)
async for delta in astream_rewrite_requirements(
model,
file_name=actual_path.name,
instruction=instruction,
style_instruction=style_instruction,
original=original,
original=rewrite_source,
on_thinking=on_requirement_thinking,
):
requirement_parts.append(delta)
requirement_draft = "".join(requirement_parts)
@@ -907,7 +927,7 @@ class DeepResearchJobExecutor:
f"{requirements_plan}\n"
"【已确认的改写执行计划结束】\n\n"
"【原始 Markdown 开始】\n"
f"{original}\n"
f"{rewrite_source}\n"
"【原始 Markdown 结束】"
)
),
@@ -932,6 +952,7 @@ class DeepResearchJobExecutor:
draft_parts: list[str] = []
last_checkpoint = time.monotonic()
inline_think_filter = InlineThinkTagFilter()
try:
async for chunk in model.astream(messages, config={"run_name": "document_rewrite"}):
thinking_delta, text_delta = extract_stream_chunk_parts(chunk)
@@ -941,6 +962,13 @@ class DeepResearchJobExecutor:
phase="writing",
payload={"type": "thinking", "chunk": thinking_delta},
)
text_delta, inline_thinking = inline_think_filter.push_parts(text_delta)
if inline_thinking:
await events.publish_live(
"document_rewrite",
phase="writing",
payload={"type": "thinking", "chunk": inline_thinking},
)
if not text_delta:
continue
draft_parts.append(text_delta)
@@ -972,6 +1000,26 @@ class DeepResearchJobExecutor:
await fail(type(exc).__name__, str(exc))
return
tail, inline_thinking_tail = inline_think_filter.finish_parts()
if inline_thinking_tail:
await events.publish_live(
"document_rewrite",
phase="writing",
payload={"type": "thinking", "chunk": inline_thinking_tail},
)
if tail:
draft_parts.append(tail)
draft = "".join(draft_parts)
await events.publish_live(
"document_rewrite",
phase="writing",
payload={
"type": "rewrite_delta",
"delta": tail,
"charCount": visible_document_char_count(draft),
},
)
rewritten = "".join(draft_parts).strip()
await events.emit(
"document_rewrite",
@@ -982,6 +1030,148 @@ class DeepResearchJobExecutor:
"charCount": visible_document_char_count(rewritten),
},
)
# Reject an invalid text draft before spending image-generation quota.
validation_error = validate_markdown_document(rewritten)
if validation_error:
await fail("VALIDATION_FAILED", validation_error, stage="validation")
return
generated_illustrations = []
persisted_illustrations = []
illustration_warnings: list[str] = []
if generate_images:
from app.gateway.document_rewrite_illustrations import generate_document_illustrations
from deerflow.agents.deep_research.adapters.image import ImageGenerationSettings, build_image_provider
await self._job_store.update_progress(
job_id,
lease_owner=lease_owner,
phase="illustrating",
progress=88,
)
# Make the phase visible even when the configured provider is
# unavailable or every provider request fails before a result.
await events.emit(
"document_rewrite",
phase="illustrating",
payload={
"type": "illustration_started",
"section": "准备生成文档配图",
"position": 0,
"total": max_generated_images,
},
)
image_settings = ImageGenerationSettings.from_app_config()
if not image_settings.is_configured:
illustration_warnings.append("当前部署未配置图片生成服务,已跳过文档配图。")
else:
provider = build_image_provider()
async def on_image_started(section: str, position: int, total: int) -> None:
await events.emit(
"document_rewrite",
phase="illustrating",
payload={
"type": "illustration_started",
"section": section,
"position": position,
"total": total,
},
)
async def on_image_generated(image, total: int, generated_count: int) -> None:
await events.emit(
"document_rewrite",
phase="illustrating",
payload={
"type": "illustration_generated",
"section": image.section,
"position": image.position,
"total": total,
"generatedCount": generated_count,
},
)
generated_illustrations, generated_warnings = await generate_document_illustrations(
provider,
markdown=rewritten,
document_name=actual_path.name,
maximum=max_generated_images,
on_started=on_image_started,
on_generated=on_image_generated,
)
illustration_warnings.extend(generated_warnings)
# Do not save images if a manual edit has invalidated the frozen
# source version while image calls were in flight.
current_before_images = await asyncio.to_thread(actual_path.read_text, encoding="utf-8")
if content_hash(current_before_images) != base_hash:
message = "文件在生成配图期间已被修改,已保留原文件。"
await events.emit(
"document_rewrite",
phase="done",
payload={"type": "conflict", "message": message},
)
await self._job_store.update_progress(
job_id,
lease_owner=lease_owner,
status="completed",
phase="done",
progress=100,
result_snapshot={
"kind": "artifact_document_rewrite",
"status": "conflict",
"content": rewritten,
"originalContent": original,
"requirementsPlan": requirements_plan,
},
)
return
if generated_illustrations:
from app.gateway.document_rewrite_illustrations import (
insert_document_illustrations,
persist_document_illustrations,
)
try:
persisted_illustrations, persistence_warnings = await asyncio.to_thread(
persist_document_illustrations,
generated_illustrations,
markdown_path=actual_path,
virtual_markdown_path=virtual_path,
job_id=job_id,
)
illustration_warnings.extend(persistence_warnings)
rewritten = insert_document_illustrations(rewritten, persisted_illustrations)
await events.emit(
"document_rewrite",
phase="illustrating",
payload={
"type": "rewrite_checkpoint",
"content": rewritten,
"charCount": visible_document_char_count(rewritten),
},
)
except Exception: # noqa: BLE001
logger.exception("could not save artifact rewrite illustrations for %s", job_id)
illustration_warnings.append("配图保存失败,已完成文字改写但未插入图片。")
persisted_illustrations = []
else:
persisted_illustrations = []
await events.emit(
"document_rewrite",
phase="illustrating",
payload={
"type": "illustration_completed",
"generatedCount": len(persisted_illustrations),
"requestedCount": max_generated_images,
"warnings": illustration_warnings,
},
)
await events.emit("document_rewrite", phase="reviewing", payload={"type": "validation_started"})
validation_error = validate_markdown_document(rewritten)
if validation_error:
@@ -991,6 +1181,7 @@ class DeepResearchJobExecutor:
heading_count_before = sum(1 for line in original.splitlines() if line.lstrip().startswith("#"))
heading_count_after = sum(1 for line in rewritten.splitlines() if line.lstrip().startswith("#"))
warnings = ["标题数量发生变化,请在前后对比中确认结构。"] if heading_count_before != heading_count_after else []
warnings.extend(illustration_warnings)
summary = build_document_rewrite_summary(
source_display_name=actual_path.name,
instruction=instruction,
@@ -999,6 +1190,8 @@ class DeepResearchJobExecutor:
rewritten=rewritten,
validation_warnings=warnings,
)
summary["generatedImageCount"] = len(persisted_illustrations)
summary["requestedImageCount"] = max_generated_images if generate_images else 0
await events.emit(
"document_rewrite",
phase="reviewing",
@@ -1124,9 +1317,7 @@ class DeepResearchJobExecutor:
job_id=job_id,
on_event=self._live_publisher,
)
await events.emit(
"document_rewrite", phase="done", payload={"type": "error", "message": message, "stage": stage}
)
await events.emit("document_rewrite", phase="done", payload={"type": "error", "message": message, "stage": stage})
await self._job_store.update_progress(
job_id,
lease_owner=lease_owner,
@@ -1332,11 +1523,7 @@ class DeepResearchJobExecutor:
},
{
"role": "user",
"content": (
"请为以下研究报告写一段 150-250 字的摘要,使用"
f"{lang_note},一段纯文本,不要标题、不要列表、不要 Markdown 格式:\n\n"
f"{report_markdown[:24_000]}"
),
"content": (f"请为以下研究报告写一段 150-250 字的摘要,使用{lang_note},一段纯文本,不要标题、不要列表、不要 Markdown 格式:\n\n{report_markdown[:24_000]}"),
},
],
on_delta=on_delta,
@@ -1369,10 +1556,18 @@ class DeepResearchJobExecutor:
# ── terminal-state writers ──────────────────────────────────────────────
async def _mark_completed(
self, job_id: str, session_id: str, lease_owner: str | None, result: DeepResearchResult
) -> None:
# Job row → completed.
async def _mark_completed(self, job_id: str, session_id: str, lease_owner: str | None, result: DeepResearchResult) -> None:
# Persist the report projection before exposing a terminal completed
# job. The session row is the durable report itself; marking the job
# complete first and suppressing a session-write failure can otherwise
# make a successfully generated report disappear after refresh.
await self._persist_completed_report_projection(
job_id=job_id,
session_id=session_id,
result=result,
)
# Job row → completed only after the report is durable.
await self._job_store.update_progress(
job_id,
lease_owner=lease_owner,
@@ -1385,29 +1580,61 @@ class DeepResearchJobExecutor:
"plan": result.plan,
},
)
# Session row → completed + report.
source_count = len(result.source_ids)
with suppress(Exception): # noqa: BLE001
await self._session_store.update(
session_id,
status="completed",
report_markdown=result.report_markdown,
report_html=result.report_html,
plan_snapshot=result.plan,
# Keep the terminal job id alongside model usage. The
# frontend uses it only to replay the persisted research trace
# after a refresh; it does not affect billing or job state.
usage_snapshot={**(result.usage or {}), "lastJobId": job_id},
source_count=source_count,
active_job_id=None,
)
# Emit a terminal event for SSE consumers.
with suppress(Exception): # noqa: BLE001
await self._emit_terminal(job_id, session_id, "job_completed")
async def _mark_awaiting(
self, job_id: str, session_id: str, pause: ResearchAwaitingInput
async def _persist_completed_report_projection(
self,
*,
job_id: str,
session_id: str,
result: DeepResearchResult,
) -> None:
"""Durably save the report before publishing job completion.
A write can succeed while a short-lived MySQL proxy/readback hiccup
makes its caller fail. Retry the full owner-scoped update a few times;
if it still cannot be confirmed, propagate the error so ``_run`` marks
the job failed instead of advertising a report that cannot be reopened.
"""
last_error: Exception | None = None
for attempt in range(1, _REPORT_PROJECTION_RETRY_ATTEMPTS + 1):
try:
updated = await self._session_store.update(
session_id,
status="completed",
report_markdown=result.report_markdown,
report_html=result.report_html,
plan_snapshot=result.plan,
# Keep the terminal job id alongside model usage. The
# frontend uses it only to replay the persisted research
# trace after a refresh; it does not affect billing.
usage_snapshot={**(result.usage or {}), "lastJobId": job_id},
source_count=len(result.source_ids),
active_job_id=None,
)
if updated is not None:
return
last_error = RuntimeError(f"Deep Research session {session_id} no longer exists")
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
last_error = exc
if attempt < _REPORT_PROJECTION_RETRY_ATTEMPTS:
logger.warning(
"deep-research report projection failed for %s (attempt %s/%s); retrying",
job_id,
attempt,
_REPORT_PROJECTION_RETRY_ATTEMPTS,
exc_info=True,
)
await asyncio.sleep(_REPORT_PROJECTION_RETRY_DELAY_SECONDS * attempt)
raise RuntimeError(f"研究报告未能保存到会话 {session_id},任务不会标记为完成") from last_error
async def _mark_awaiting(self, job_id: str, session_id: str, pause: ResearchAwaitingInput) -> None:
"""Project a persisted plan-review interrupt onto the owning session."""
with suppress(Exception): # noqa: BLE001
await self._session_store.update(
@@ -1417,9 +1644,7 @@ class DeepResearchJobExecutor:
active_job_id=job_id,
)
async def _mark_failed(
self, job_id: str, session_id: str, lease_owner: str | None, code: str, message: str
) -> None:
async def _mark_failed(self, job_id: str, session_id: str, lease_owner: str | None, code: str, message: str) -> None:
await self._job_store.update_progress(
job_id,
lease_owner=lease_owner,
@@ -85,6 +85,7 @@ async def stream_full_report_rewrite(
style: str,
model_name: str | None,
on_delta: Callable[[str], Awaitable[None]],
on_reasoning: Callable[[str], Awaitable[None]] | None = None,
requirements_plan: str | None = None,
) -> tuple[str, list[str], dict[str, Any]]:
"""Generate a complete Markdown replacement without starting new research."""
@@ -126,6 +127,7 @@ async def stream_full_report_rewrite(
messages=messages,
operation="full_report_rewrite",
on_delta=on_delta,
on_reasoning=on_reasoning,
)
answer = result.text.strip() or report
answer, cited = _normalise_citations(answer, source_ids)
@@ -140,6 +142,7 @@ async def stream_full_report_rewrite_requirements(
style: str,
model_name: str | None,
on_delta: Callable[[str], Awaitable[None]],
on_reasoning: Callable[[str], Awaitable[None]] | None = None,
) -> str:
"""Stream a compact plan before rewriting a research report.
@@ -169,6 +172,7 @@ async def stream_full_report_rewrite_requirements(
],
operation="full_report_rewrite_requirements",
on_delta=on_delta,
on_reasoning=on_reasoning,
)
return result.text.strip()
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,263 @@
"""Safe, optional illustrations for a sandbox Markdown full rewrite.
The writer streams prose first. When requested, this module derives a small
set of heading-scoped illustration prompts, persists the returned bytes inside
the same user-owned artifact tree, then adds only local ``/mnt`` references to
the Markdown. Provider URLs never reach the browser or the saved document.
"""
from __future__ import annotations
import os
import re
import tempfile
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from deerflow.agents.deep_research.adapters.image import ImageGenerationError, ImageProvider
_HEADING_RE = re.compile(r"^##[ \t]+([^\r\n]+?)[ \t]*$", re.MULTILINE)
_TITLE_RE = re.compile(r"^#[ \t]+([^\r\n]+?)[ \t]*$", re.MULTILINE)
_START_MARKER = "<!-- deerflow:ai-rewrite-image:start -->"
_END_MARKER = "<!-- deerflow:ai-rewrite-image:end -->"
_MANAGED_IMAGE_BLOCK_RE = re.compile(
rf"\n?{re.escape(_START_MARKER)}.*?{re.escape(_END_MARKER)}\n?",
re.DOTALL,
)
_MIME_EXTENSIONS = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
}
@dataclass(frozen=True)
class DocumentIllustration:
"""A provider result that has not been exposed or persisted yet."""
section: str
position: int
data: bytes
mime_type: str
alt: str
@dataclass(frozen=True)
class PersistedDocumentIllustration:
"""A generated image with a virtual artifact path safe for Markdown."""
section: str
position: int
virtual_path: str
alt: str
def strip_managed_document_illustrations(markdown: str) -> str:
"""Remove only DeerFlow-generated image blocks before a fresh image pass."""
return _MANAGED_IMAGE_BLOCK_RE.sub("\n", markdown).strip()
def select_illustration_sections(markdown: str, maximum: int) -> list[str]:
"""Pick distinct H2 sections; fall back to the title for short documents."""
limit = max(0, min(maximum, 4))
if not limit:
return []
sections: list[str] = []
seen: set[str] = set()
for match in _HEADING_RE.finditer(markdown):
section = _clean_heading(match.group(1))
if section and section not in seen:
seen.add(section)
sections.append(section)
if len(sections) >= limit:
return sections
if sections:
return sections
title_match = _TITLE_RE.search(markdown)
title = _clean_heading(title_match.group(1)) if title_match else "文档主题"
return [title or "文档主题"]
async def generate_document_illustrations(
provider: ImageProvider,
*,
markdown: str,
document_name: str,
maximum: int,
on_started=None,
on_generated=None,
) -> tuple[list[DocumentIllustration], list[str]]:
"""Generate a bounded, sequential image set and keep failures recoverable."""
sections = select_illustration_sections(markdown, maximum)
title_match = _TITLE_RE.search(markdown)
document_title = _clean_heading(title_match.group(1)) if title_match else Path(document_name).stem
generated: list[DocumentIllustration] = []
warnings: list[str] = []
for position, section in enumerate(sections, 1):
if on_started is not None:
await on_started(section, position, len(sections))
try:
result = await provider.generate(
_illustration_prompt(document_title, section),
section_id=section,
)
data = result.get("data") if isinstance(result, dict) else None
if not isinstance(data, bytes) or not data:
raise ImageGenerationError("图片生成服务未返回可保存的图片数据")
mime_type = str(result.get("mime_type") or "image/png").lower()
if mime_type not in _MIME_EXTENSIONS:
mime_type = "image/png"
illustration = DocumentIllustration(
section=section,
position=position,
data=data,
mime_type=mime_type,
alt=_clean_alt(str(result.get("alt") or f"{section}配图")),
)
generated.append(illustration)
if on_generated is not None:
await on_generated(illustration, len(sections), len(generated))
except ImageGenerationError as exc:
warnings.append(f"“{section}”配图未生成:{exc}")
except Exception: # noqa: BLE001
# Generation remains best effort: an unavailable image provider
# must never discard an otherwise complete document rewrite.
warnings.append(f"“{section}”配图生成失败,已保留文字改写结果。")
return generated, warnings
def persist_document_illustrations(
illustrations: list[DocumentIllustration],
*,
markdown_path: Path,
virtual_markdown_path: str,
job_id: str,
) -> tuple[list[PersistedDocumentIllustration], list[str]]:
"""Best-effort persist each asset beside the rewritten Markdown file.
A single failed local write must not discard earlier successful images. In
particular, Windows development reloaders can briefly recreate an output
directory while a report is being written. The caller receives warnings
for the timeline, while any persisted image remains usable in the final
Markdown.
"""
if not illustrations:
return [], []
# Keep this name intentionally short. On Windows the local sandbox path
# already contains user/thread UUIDs; including a long report title plus a
# UUID-based temporary filename exceeded MAX_PATH (and surfaced as a
# misleading FileNotFoundError). Job ids make names unique across files,
# so a single short hidden directory per outputs folder is sufficient.
asset_directory_name = ".ai-images"
asset_directory = markdown_path.parent / asset_directory_name
virtual_parent = PurePosixPath(virtual_markdown_path).parent.as_posix().rstrip("/")
persisted: list[PersistedDocumentIllustration] = []
warnings: list[str] = []
for image in illustrations:
try:
extension = _MIME_EXTENSIONS[image.mime_type]
file_name = f"{job_id}-{image.position:02d}.{extension}"
_atomic_write_bytes(asset_directory / file_name, image.data)
persisted.append(
PersistedDocumentIllustration(
section=image.section,
position=image.position,
virtual_path=f"{virtual_parent}/{asset_directory_name}/{file_name}",
alt=image.alt,
)
)
except OSError:
warnings.append(f"“{image.section}”配图保存失败,已继续保留其他成功配图。")
return persisted, warnings
def insert_document_illustrations(
markdown: str,
illustrations: list[PersistedDocumentIllustration],
) -> str:
"""Insert fenced local-image blocks directly below their selected sections."""
amended = strip_managed_document_illustrations(markdown)
for image in illustrations:
marker = f"## {image.section}"
block = (
f"\n\n{_START_MARKER}\n"
f"![{image.alt}]({image.virtual_path})\n"
f"{_END_MARKER}\n"
)
if marker in amended:
amended = amended.replace(marker, f"{marker}{block}", 1)
else:
amended = f"{amended.rstrip()}{block}"
return amended.strip()
def _atomic_write_bytes(path: Path, content: bytes) -> None:
"""Write one image atomically with Windows-safe temporary path lengths."""
last_error: FileNotFoundError | None = None
for _attempt in range(3):
temporary: Path | None = None
try:
# A runtime cleanup can remove a freshly-created asset folder
# between image calls. Recreate it immediately before writing and
# retry this small local operation. NamedTemporaryFile deliberately
# keeps the temporary basename short: appending a report title,
# job id, and UUID can exceed Windows MAX_PATH before os.replace.
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="wb",
dir=path.parent,
prefix=".w-",
suffix=".tmp",
delete=False,
) as file:
temporary = Path(file.name)
file.write(content)
file.flush()
os.fsync(file.fileno())
os.replace(temporary, path)
return
except FileNotFoundError as exc:
last_error = exc
finally:
try:
if temporary is not None and temporary.exists():
temporary.unlink()
except OSError:
pass
if last_error is not None:
raise last_error
def _illustration_prompt(document_title: str, section: str) -> str:
return (
"为一份专业 Markdown 文档生成一张可嵌入的章节配图。"
f"文档主题:{_clean_heading(document_title)}。章节:{section}。"
"画面应信息性、简洁、与正文内容一致,可使用抽象示意、数据可视化风格或场景插画。"
"不要在图片中放置可读文字、品牌标志、水印或人物肖像;不得虚构具体数据或事实。"
)
def _clean_heading(value: str) -> str:
return " ".join(value.replace("\r", " ").replace("\n", " ").split())[:160]
def _clean_alt(value: str) -> str:
return _clean_heading(value).replace("[", "").replace("]", "") or "文档配图"
__all__ = [
"DocumentIllustration",
"PersistedDocumentIllustration",
"generate_document_illustrations",
"insert_document_illustrations",
"persist_document_illustrations",
"select_illustration_sections",
"strip_managed_document_illustrations",
]
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff. Show more