Merge branch 'master-nj' into 'master'

feat: 支持会商 Markdown 产物提交到任务过程,并补充 Angular 对接文档

See merge request knowledge-base-bj/deerflow-code!26
This commit is contained in:
董宏凯 committed 2026-08-19 12:38:26 +00:00
commit c69fde22bd
102 files changed
+11338 -6113

No files matched your search

+50 -49
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,22 +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
# 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
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)
@@ -108,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
@@ -140,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=
@@ -12,6 +12,12 @@ http://localhost:5173/#/page/workspace/agents/roundtable-structure/chats/new
本文覆盖:**登录拿 Token → 列模型/切模型 → 建会话/存聊天记录 → 流式对话 → 思考内容 → 步骤条(含创建脚本/执行脚本/写入文件及全部工具)→ 用户协助卡片 → 消息列表渲染 → 停止/重连/上传/产物**。
**不包含**智能体配置(创建/编辑智能体、技能绑定等)。
路径约定(均相对本仓库根目录 `deerflow-server/`):
- 前端:`frontend-web/src/…`
- 后端:`offline-backend-20260512/backend/…`(下文若写 `app/`、`deerflow/`,都在这个 backend 目录下)
- 每节末尾有 **参考代码**;文末第 20 节是总索引。
---
## 0. 先看这一页
@@ -45,6 +51,17 @@ Content-Type: application/json
用 Bearer **不必**再带 CSRF。不要用 cookie 登录(`/login/local` 只把 JWT 写进 HttpOnly cookie,不适合 Angular 跨域 API 客户端)。
**参考代码**
| 说明 | 路径 |
|------|------|
| 前端两个 Base URL | `frontend-web/src/core/config/index.ts`(`getBackendBaseURL` / `getLangGraphBaseURL`) |
| 环境变量注入 | `frontend-web/src/env.ts` |
| LangGraph SDK 客户端(自动带 Bearer) | `frontend-web/src/core/api/api-client.ts` |
| Gateway fetch 自动带 Authorization | `frontend-web/src/core/api/fetch-client.ts` |
| CSRF:有 Bearer 则跳过 | `offline-backend-20260512/backend/app/gateway/csrf_middleware.py` |
| 登录接口白名单 | `offline-backend-20260512/backend/app/gateway/auth_middleware.py` |
---
## 1. 架构与端到端流程
@@ -77,6 +94,19 @@ sequenceDiagram
2. `context.agent_id = "roundtable-structure"`
3. 会话 `metadata.agent_id = "roundtable-structure"`(历史列表按此过滤)
**参考代码**
| 说明 | 路径 |
|------|------|
| 路由挂载 AgentChatPage | `frontend-web/src/pages/WorkspaceRoutes.tsx`(`agents/:agent_id/chats/:thread_id`) |
| 对话页:`assistantId` + `context.agent_id` | `frontend-web/src/pages/AgentChatPage.tsx` |
| 聊天气泡壳 | `frontend-web/src/components/workspace/chats/chat-box.tsx` |
| 发消息时写入 metadata.agent_id | `frontend-web/src/core/threads/hooks.ts`(`onCreated` / `thread.submit`) |
| 非内置 assistant_id → agent_id | `offline-backend-20260512/backend/app/gateway/services.py`(`build_run_config`) |
| Lead Agent 工厂 | `offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py` |
| 智能体种子(id / 中文名 / 技能) | `offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/config.yaml` |
| 智能体 SOUL | `offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/SOUL.md` |
---
## 2. CORS / 跨域(Angular 必读)
@@ -94,6 +124,8 @@ GATEWAY_CORS_ORIGINS=http://localhost:4200,http://127.0.0.1:4200,https://your-an
或开发期临时 `GATEWAY_CORS_ALLOW_ALL=1`(勿用于生产)。
**参考代码**:`offline-backend-20260512/backend/app/gateway/app.py`(搜 `GATEWAY_CORS_ORIGINS` / `local_vite_origins` / `cors_expose_headers`)。回归:`offline-backend-20260512/backend/tests/test_taskcop_cors.py`。
浏览器跨域读 SSE 还需要暴露这些响应头(Gateway 已配):
- `Content-Location`(从中解析 `run_id`,取消/重连要用)
@@ -210,6 +242,20 @@ function authHeader(): HttpHeaders {
}
```
**参考代码**
| 说明 | 路径 |
|------|------|
| 前端登录 API | `frontend-web/src/core/auth/api.ts`(`loginByUsername` / `loginByToken`) |
| Token 存取、拼 Bearer | `frontend-web/src/core/auth/index.ts`(`getAuthorizationHeaderValue` / `setStoredAuth`) |
| 类型 `LoginByUsernameResponse` | `frontend-web/src/core/auth/index.ts` |
| 登录页(含 token 换票) | `frontend-web/src/pages/LoginPage.tsx` |
| 后端登录路由 | `offline-backend-20260512/backend/app/gateway/routers/auth.py`(`/login/username`、`/login/token`、`/me`) |
| JWT 签发 | `offline-backend-20260512/backend/app/gateway/auth/jwt.py` |
| 请求里解析 cookie / Bearer | `offline-backend-20260512/backend/app/gateway/deps.py`(`get_current_user`) |
| 登录配置(口令门 / token_login) | `offline-backend-20260512/backend/config.yaml` → `auth_login`;schema:`deerflow/config/login_config.py` |
| 登录契约说明 | `offline-backend-20260512/backend/docs/AUTH_LOGIN.md` |
---
## 4. 模型列表与切换
@@ -266,6 +312,20 @@ Authorization: Bearer <jwt>
每轮都可以换模型;换了只影响 **下一轮**,不会改写历史。
**参考代码**
| 说明 | 路径 |
|------|------|
| 前端拉模型 | `frontend-web/src/core/models/api.ts`、`frontend-web/src/core/models/types.ts` |
| 输入框模型下拉 + 档位 | `frontend-web/src/components/workspace/input-box.tsx`(`InputMode`、`ModelSelector`) |
| 档位写入 context | `frontend-web/src/core/threads/hooks.ts`(`thread.submit` 的 `context`:`thinking_enabled` / `is_plan_mode` / `subagent_enabled`) |
| 本地记住所选模型 | `frontend-web/src/core/settings/local.ts`(`deerflow.thread-model.{threadId}`) |
| 后端模型列表 | `offline-backend-20260512/backend/app/gateway/routers/models.py` |
| context 白名单 | `offline-backend-20260512/backend/app/gateway/services.py`(`_CONTEXT_CONFIGURABLE_KEYS`) |
| 本轮实际用哪个模型 | `offline-backend-20260512/backend/app/gateway/services.py`(`_resolve_effective_run_model_name`) |
| 创建 ChatModel、关思考 | `offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py` |
| 模型配置源 | `offline-backend-20260512/backend/config.yaml` → `models[]` |
---
## 5. 会话(Thread)= 聊天记录的存储单位
@@ -393,6 +453,20 @@ DELETE {LG}/threads/{thread_id}
`status`:`idle` | `busy` | `interrupted` | `error`
正在跑时为 `busy`。同一会话再发会被 `multitask_strategy: "reject"` 打回 **HTTP 409**。
**参考代码**
| 说明 | 路径 |
|------|------|
| 线程类型 / context 类型 | `frontend-web/src/core/threads/types.ts` |
| 列表、标题、路由 | `frontend-web/src/core/threads/utils.ts`(`titleOfThread` / `pathOfThread`) |
| 分页搜索 | `frontend-web/src/core/threads/hooks.ts`(`useThreadsPage`) |
| 智能体最近对话 | `frontend-web/src/components/workspace/agents/agent-recent-chats.tsx` |
| 工作区会话列表 UI | `frontend-web/src/components/workspace/workspace-nav-chat-list.tsx` |
| 建/搜/改/删/state/history | `offline-backend-20260512/backend/app/gateway/routers/threads.py` |
| ThreadResponse / Search 模型 | 同上文件内 `ThreadResponse`、`ThreadCreateRequest`、`ThreadSearchRequest` |
| 删除时清本地目录 | `frontend-web/src/core/threads/hooks.ts`(delete 后再调 Gateway DELETE) |
| 409 中文文案 | `offline-backend-20260512/backend/app/gateway/services.py`(`create_or_reject` / RunManager) |
---
## 6. 发起智能体对话(核心)
@@ -511,6 +585,21 @@ Last-Event-ID: <上一帧 id>
心跳是注释行:`: heartbeat`,忽略即可。
**参考代码**
| 说明 | 路径 |
|------|------|
| 前端 `useStream` + `thread.submit` | `frontend-web/src/core/threads/hooks.ts`(`useThreadStream`、构造 `messages` / `context`) |
| SDK 包装 | `frontend-web/src/core/api/api-client.ts` |
| stream_mode 规范化 | `frontend-web/src/core/api/stream-mode.ts` |
| 停止:`thread.stop` / `runs.cancel` | `frontend-web/src/core/threads/hooks.ts`(`stopThreadRun`) |
| 输入框停止按钮 | `frontend-web/src/components/workspace/input-box.tsx` |
| 后端 stream / cancel / join | `offline-backend-20260512/backend/app/gateway/routers/thread_runs.py`(`RunCreateRequest`、`stream_run`、`cancel_run`、`join_run`) |
| 真正起跑 | `offline-backend-20260512/backend/app/gateway/services.py`(`start_run`) |
| 后台跑图 + 发 SSE | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/worker.py`(`run_agent`) |
| Run 生命周期 | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py` |
| 流式设计说明 | `offline-backend-20260512/backend/docs/STREAMING.md` |
---
## 7. SSE 事件协议(Angular 必须自己解析 POST 流)
@@ -655,6 +744,18 @@ async function streamRun(
}
```
**参考代码**
| 说明 | 路径 |
|------|------|
| SSE 组帧(`event:` / `data:` / `id:`) | `offline-backend-20260512/backend/app/gateway/services.py`(`format_sse`、`sse_consumer`) |
| 心跳 / 订阅 | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/stream_bridge.py` |
| 首帧 `event: metadata` | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/worker.py`(`bridge.publish(..., "metadata", {run_id, thread_id})`) |
| `messages-tuple` → 内部 `messages` | 同上 `run_agent`;SSE 事件名 `_lg_mode_to_sse_event` |
| chunk 序列化成 `[chunk, meta]` | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/serialization.py`(`serialize_messages_tuple` / `serialize_channel_values`) |
| 前端消费:onCustomEvent / onUpdateEvent | `frontend-web/src/core/threads/hooks.ts` |
| 协议说明 | `offline-backend-20260512/backend/docs/STREAMING.md` |
---
## 8. 消息数据结构(历史 + 实时共用)
@@ -707,6 +808,14 @@ async function streamRun(
| `ai` 有 `tool_calls` 或仅有思考 | **不单独当最终气泡**,进入步骤条 |
| `tool` | **几乎不单独成气泡**,并进步骤条 / 协助卡片 |
**参考代码**
| 说明 | 路径 |
|------|------|
| 分组、抽文本、抽思考、隐藏消息 | `frontend-web/src/core/messages/utils.ts`(`groupMessages` / `extractContentFromMessage` / `extractReasoningContentFromMessage` / `isHiddenFromUIMessage`) |
| 检索结果识别 | `frontend-web/src/core/messages/search-results.ts` |
| 序列化后的 message dict | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/serialization.py` |
---
## 9. 消息列表如何分组渲染
@@ -765,6 +874,20 @@ async function streamRun(
`busy` 且还没有最终 `assistant` 气泡时,在列表底部显示跳动的「正在生成」。
上传文件未完成时可插一条 mock AI:`additional_kwargs.element === "task"`,文案「文件上传中,请稍候...」。
**参考代码**
| 说明 | 路径 |
|------|------|
| 列表入口、分组渲染 | `frontend-web/src/components/workspace/messages/message-list.tsx` |
| 单条用户/助手气泡 | `frontend-web/src/components/workspace/messages/message-list-item.tsx` |
| Markdown | `frontend-web/src/components/workspace/messages/markdown-content.tsx` |
| 气泡/会话滚动容器 | `frontend-web/src/components/ai-elements/message.tsx`、`conversation.tsx` |
| 跳动「正在生成」 | `frontend-web/src/components/workspace/streaming-indicator.tsx` |
| 技能来源展示 | `frontend-web/src/core/skill-display/runtime.ts` |
| 导出 | `frontend-web/src/core/threads/export.ts`;Word:`frontend-web/src/components/workspace/messages/download-word-button.tsx` |
| 删除消息 | `frontend-web/src/components/workspace/messages/delete-message-button.tsx` |
| 问题跳转 | `frontend-web/src/components/workspace/question-navigator.tsx` |
---
## 10. 思考内容如何对接
@@ -809,6 +932,17 @@ function splitThink(content: string): { answer: string; thinking: string | null
`thinking_enabled: false` 时模型不应再吐思考;若仍出现 `<think>`,继续剥掉以免露标签。
**参考代码**
| 说明 | 路径 |
|------|------|
| 抽思考 / 剥 `<think>`(含未闭合) | `frontend-web/src/core/messages/utils.ts`(`extractReasoningContentFromMessage`、`splitInlineReasoning`) |
| 步骤条里的思考步 | `frontend-web/src/components/workspace/messages/message-group.tsx`(`convertToSteps` 的 `reasoning`) |
| 独立思考折叠块 | `frontend-web/src/components/ai-elements/reasoning.tsx` |
| 文案「思考」 | `frontend-web/src/core/i18n/locales/zh-CN.ts`(`t.common.thinking`) |
| 模型思考流字段 | `offline-backend-20260512/backend/packages/harness/deerflow/models/vllm_provider.py` |
| 关思考注入 extra_body | `offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py` |
---
## 11. 步骤条(Chain of Thought)— 请做全
@@ -925,6 +1059,23 @@ interface WebFetchArgs { url: string }
字段名不统一(`content` / `page_content` / `snippet` / `m_title` / `name`),展示时做并集兜底。
**参考代码**
| 说明 | 路径 |
|------|------|
| **步骤条主实现(必看)** | `frontend-web/src/components/workspace/messages/message-group.tsx`(`convertToSteps`、按 `name` 分支、`ArtifactWriteToolCall`) |
| ChainOfThought 组件 | `frontend-web/src/components/ai-elements/chain-of-thought.tsx` |
| 中文底稿 | `frontend-web/src/core/i18n/locales/zh-CN.ts` → `toolCalls`;类型 `frontend-web/src/core/i18n/locales/types.ts` |
| 工具一句话说明 | `frontend-web/src/core/tools/utils.ts`(`explainToolCall`) |
| 检索结果卡片 | `frontend-web/src/core/messages/search-results.ts` |
| 写文件实时预览 | `frontend-web/src/core/artifacts/streaming-write-preview.ts` |
| bash / ls / read / write / glob / grep | `offline-backend-20260512/backend/packages/harness/deerflow/sandbox/tools.py`(参数都要求先填 `description`) |
| 工具装配(含 MCP、内置) | `offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py` |
| present_files | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/present_file_tool.py` |
| skill_list / skill_view / search_skills | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/skill_tools.py` |
| 启用哪些工具 | `offline-backend-20260512/backend/config.yaml` → `tools[]` |
| 会商页步骤条(对照用,对话页以 message-group 为准) | `frontend-web/src/roundtable-planning/lib/step-display.tsx` |
---
## 12. 用户协助卡片(ask_clarification)
@@ -1007,6 +1158,17 @@ input.messages = [{ type: "human", content: "<上面拼好的文本>" }]
不要调 `/state` 的 resume,也不要自己造 ToolMessage。
**参考代码**
| 说明 | 路径 |
|------|------|
| 卡片 UI、拼「我选择:」、多卡合并提交 | `frontend-web/src/components/workspace/messages/message-list.tsx`(`ClarificationCard`、`handleSubmitAllClarifications`) |
| 识别 tool 消息 | `frontend-web/src/core/messages/utils.ts`(`isClarificationToolMessage`) |
| 工具定义 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_tool.py` |
| 截住工具、写 `additional_kwargs.clarification`、goto END | `offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py`(`_build_payload`) |
| `allow_multiple` 解析 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_utils.py` |
| 提交后又走同一套 sendMessage | `frontend-web/src/pages/AgentChatPage.tsx`、`frontend-web/src/core/threads/hooks.ts`(**不是** Command resume) |
---
## 13. 其它卡片与面板
@@ -1056,6 +1218,18 @@ interface Todo { content?: string; status?: 'pending' | 'in_progress' | 'complet
`setup_agent` / `setup_writing` 带 approval kwargs。普通 `roundtable-structure` 对话 **不会出现**,可忽略。
Deep Research 的 `setup_research_approval` 同理。
**参考代码**
| 说明 | 路径 |
|------|------|
| present_files 分组渲染 | `frontend-web/src/components/workspace/messages/message-list.tsx`(`assistant:present-files`) |
| 产物 API | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` |
| 产物前端 | `frontend-web/src/components/workspace/artifacts/`(如 `artifact-file-detail.tsx`) |
| 子任务卡 | `frontend-web/src/components/workspace/messages/subtask-card.tsx` |
| `task` 工具 / 子代理 | `offline-backend-20260512/backend/packages/harness/deerflow/subagents/` |
| Todo 面板 | `frontend-web/src/components/workspace/todo-list.tsx`;类型 `frontend-web/src/core/todos/types.ts` |
| write_todos 中间件装配 | `offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py`(`_build_middlewares`) |
---
## 14. 标题、追问、错误态
@@ -1095,6 +1269,18 @@ POST {GATEWAY}/api/threads/{thread_id}/suggestions
| 上下文压缩 | `custom.context_compacting` | 「正在压缩上下文…」 |
| 会话不属于当前用户 | 403/404 | 回到新建 |
**参考代码**
| 说明 | 路径 |
|------|------|
| 标题展示 | `frontend-web/src/components/workspace/thread-title.tsx` |
| 列表用 title | `frontend-web/src/core/threads/utils.ts`(`titleOfThread`) |
| 临时标题 + LLM 标题 | `offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py` |
| 追问 API 调用 | `frontend-web/src/components/workspace/input-box.tsx`(`/api/threads/${threadId}/suggestions`) |
| 追问后端 | `offline-backend-20260512/backend/app/gateway/routers/suggestions.py` |
| 409 toast | `frontend-web/src/core/threads/hooks.ts`(`isActiveRunConflictError` / `onError`) |
| 压缩 toast | `offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py`(`custom.context_compacting`) |
---
## 15. 文件上传(用户侧)
@@ -1138,6 +1324,16 @@ PDF/PPT/Excel/Word 会转成 Markdown 一并放入 uploads。
限额:`GET /api/threads/{thread_id}/uploads/limits`。
列表/删除:`GET …/uploads/list`,`DELETE …/uploads/{filename}`。
**参考代码**
| 说明 | 路径 |
|------|------|
| 前端上传 | `frontend-web/src/components/workspace/input-box.tsx`;预览 `frontend-web/src/components/workspace/messages/uploaded-file-preview-dialog.tsx` |
| 后端上传路由 | `offline-backend-20260512/backend/app/gateway/routers/uploads.py` |
| 目录与虚拟路径 | `offline-backend-20260512/backend/packages/harness/deerflow/uploads/manager.py` |
| 文档转 Markdown | `offline-backend-20260512/backend/packages/harness/deerflow/utils/file_conversion.py` |
| 说明文档 | `offline-backend-20260512/backend/docs/FILE_UPLOAD.md` |
---
## 16. 推荐的页面结构(对标 AgentChatPage)
@@ -1162,6 +1358,20 @@ PDF/PPT/Excel/Word 会转成 Markdown 一并放入 uploads。
`roundtable-structure` 是「结构化抽取」智能体:给它方案报告或席位交付,产出 flow-json 流程图数据,常伴随写文件 + 展示文件。不要当成多智能体会商编排页(`/roundtable` 是另一套 API)。
**参考代码**
| 说明 | 路径 |
|------|------|
| 对话页整页 | `frontend-web/src/pages/AgentChatPage.tsx` |
| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
| ChatBox(标题栏 + 列表 + 输入) | `frontend-web/src/components/workspace/chats/chat-box.tsx` |
| 输入框 | `frontend-web/src/components/workspace/input-box.tsx` |
| 左栏最近会话 | `frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx`、`agent-recent-chats.tsx` |
| 欢迎/推荐问题 | `frontend-web/src/components/workspace/agent-welcome.tsx`、`agents/agent-question-chips.tsx` |
| 智能体种子 | `offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/` |
| 会商编排(不要和本页搞混) | `frontend-web/src/roundtable-planning/`、`offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` |
| iframe 嵌现网 UI(备选) | `frontend-web/docs/embed-iframe-chat.md` |
---
## 17. 最小对接顺序(验收清单)
@@ -1240,22 +1450,95 @@ Base:`{GATEWAY} = http://host:8001`,LangGraph `{LG} = {GATEWAY}/api`。
---
## 20. 参考实现位置(本仓库)
## 20. 参考实现总索引
| 主题 | 文件 |
路径均相对仓库根 `deerflow-server/`。后端根目录 = `offline-backend-20260512/backend/`。
各节正文末尾也有对应表;这里按主题汇总,加粗的是对接时优先打开的文件。
### 20.1 前端:页面与 UI
| 主题 | 路径 |
|------|------|
| 对话页 | `frontend-web/src/pages/AgentChatPage.tsx` |
| 发消息 / SSE | `frontend-web/src/core/threads/hooks.ts` |
| 消息分组 | `frontend-web/src/core/messages/utils.ts` |
| 步骤条 | `frontend-web/src/components/workspace/messages/message-group.tsx` |
| 协助卡片 | `frontend-web/src/components/workspace/messages/message-list.tsx` |
| 中文步骤文案 | `frontend-web/src/core/i18n/locales/zh-CN.ts` → `toolCalls` |
| 登录 | `frontend-web/src/core/auth/api.ts` |
| 模型 | `GET /api/models` ← `app/gateway/routers/models.py` |
| 会话 / 状态 | `app/gateway/routers/threads.py` |
| 流式 | `app/gateway/routers/thread_runs.py` |
| SSE 帧 | `app/gateway/services.py` `format_sse` |
| 协助卡 payload | `deerflow/agents/middlewares/clarification_middleware.py` |
| 沙箱工具 description | `deerflow/sandbox/tools.py`(bash / write_file / …) |
| **智能体对话页** | `frontend-web/src/pages/AgentChatPage.tsx` |
| 路由 `agents/:agent_id/chats/:thread_id` | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
| 对话壳(标题 / 列表 / 输入 / 产物入口) | `frontend-web/src/components/workspace/chats/chat-box.tsx` |
| **消息列表 + 协助卡片** | `frontend-web/src/components/workspace/messages/message-list.tsx` |
| 用户/助手气泡 | `frontend-web/src/components/workspace/messages/message-list-item.tsx` |
| **步骤条(创建脚本/执行脚本/写入文件等)** | `frontend-web/src/components/workspace/messages/message-group.tsx` |
| Markdown | `frontend-web/src/components/workspace/messages/markdown-content.tsx` |
| 子任务卡 | `frontend-web/src/components/workspace/messages/subtask-card.tsx` |
| 思考折叠块 | `frontend-web/src/components/ai-elements/reasoning.tsx` |
| ChainOfThought 控件 | `frontend-web/src/components/ai-elements/chain-of-thought.tsx` |
| 输入框(模型、档位、发送、停止、追问、上传) | `frontend-web/src/components/workspace/input-box.tsx` |
| Todo 条 | `frontend-web/src/components/workspace/todo-list.tsx` |
| 会话标题 | `frontend-web/src/components/workspace/thread-title.tsx` |
| 正在生成 | `frontend-web/src/components/workspace/streaming-indicator.tsx` |
| 会话列表 UI | `frontend-web/src/components/workspace/workspace-nav-chat-list.tsx` |
| 该智能体最近对话 | `frontend-web/src/components/workspace/agents/agent-recent-chats.tsx` |
| 左栏 | `frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx` |
| 产物预览 | `frontend-web/src/components/workspace/artifacts/` |
| 中文步骤文案 `toolCalls.*` | `frontend-web/src/core/i18n/locales/zh-CN.ts` |
智能体种子:`offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/`(`id: roundtable-structure`,`name: 结构化抽取`)。
### 20.2 前端:协议与状态
| 主题 | 路径 |
|------|------|
| **发消息 / useStream / cancel / 409** | `frontend-web/src/core/threads/hooks.ts` |
| 线程类型 | `frontend-web/src/core/threads/types.ts` |
| 标题与路由 | `frontend-web/src/core/threads/utils.ts` |
| **消息分组、思考、隐藏** | `frontend-web/src/core/messages/utils.ts` |
| 检索结果归一化 | `frontend-web/src/core/messages/search-results.ts` |
| **登录 API** | `frontend-web/src/core/auth/api.ts` |
| Bearer 存取 | `frontend-web/src/core/auth/index.ts` |
| 模型 API / 类型 | `frontend-web/src/core/models/api.ts`、`frontend-web/src/core/models/types.ts` |
| LangGraph Client | `frontend-web/src/core/api/api-client.ts` |
| Gateway fetch + 401 | `frontend-web/src/core/api/fetch-client.ts` |
| Base URL | `frontend-web/src/core/config/index.ts`、`frontend-web/src/env.ts` |
| 写文件流式预览 | `frontend-web/src/core/artifacts/streaming-write-preview.ts` |
| 工具一句话 | `frontend-web/src/core/tools/utils.ts` |
### 20.3 后端:HTTP
| 主题 | 路径 |
|------|------|
| **登录 /me** | `offline-backend-20260512/backend/app/gateway/routers/auth.py` |
| JWT | `offline-backend-20260512/backend/app/gateway/auth/jwt.py` |
| **CORS** | `offline-backend-20260512/backend/app/gateway/app.py` |
| 鉴权中间件 | `offline-backend-20260512/backend/app/gateway/auth_middleware.py` |
| **模型列表** | `offline-backend-20260512/backend/app/gateway/routers/models.py` |
| **会话 CRUD / search / state** | `offline-backend-20260512/backend/app/gateway/routers/threads.py` |
| **runs/stream / cancel / join** | `offline-backend-20260512/backend/app/gateway/routers/thread_runs.py` |
| start_run、SSE、context 白名单 | `offline-backend-20260512/backend/app/gateway/services.py` |
| 上传 | `offline-backend-20260512/backend/app/gateway/routers/uploads.py` |
| 产物 | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` |
| 追问 | `offline-backend-20260512/backend/app/gateway/routers/suggestions.py` |
| 简化开放聊天(不要用来复刻本页) | `offline-backend-20260512/backend/app/gateway/routers/open_chat.py` |
### 20.4 后端:运行时与工具
| 主题 | 路径 |
|------|------|
| **跑图 + 发 SSE 事件** | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/worker.py` |
| RunManager | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py` |
| StreamBridge | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/stream_bridge.py` |
| 消息序列化 | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/serialization.py` |
| Lead Agent | `offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py` |
| **协助卡中间件** | `offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py` |
| 协助工具 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_tool.py` |
| **bash / write_file 等(description)** | `offline-backend-20260512/backend/packages/harness/deerflow/sandbox/tools.py` |
| 工具总装 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py` |
| present_files | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/present_file_tool.py` |
| 标题 | `offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py` |
| 模型工厂 | `offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py` |
### 20.5 配置、种子、文档
| 主题 | 路径 |
|------|------|
| **roundtable-structure 种子** | `offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/config.yaml` |
| 同上 SOUL | `offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/SOUL.md` |
| 模型 / 工具开关 | `offline-backend-20260512/backend/config.yaml` |
| 流式协议 | `offline-backend-20260512/backend/docs/STREAMING.md` |
| 登录契约 | `offline-backend-20260512/backend/docs/AUTH_LOGIN.md` |
| 上传 | `offline-backend-20260512/backend/docs/FILE_UPLOAD.md` |
| iframe 嵌入备选 | `frontend-web/docs/embed-iframe-chat.md` |
+10 -1
View File
@@ -52,7 +52,11 @@ window.__CM_RUNTIME_CONFIG__ = {
// 8BF 嵌入页专用任务系统接口。部署到其他系统时只需修改这一项,
// 不影响原多智能体会商使用的任务详情接口。
VITE_8BF_TASK_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm",
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",
@@ -134,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,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";
@@ -235,7 +235,7 @@ export function MessageGroup({
);
}
function ToolCall({
function ToolCall({
id,
messageId,
name,
@@ -257,45 +257,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,
@@ -308,19 +308,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} />
)}
@@ -437,18 +437,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;
@@ -509,161 +509,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 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;
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;
}
@@ -766,7 +769,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) => (
@@ -6,10 +6,10 @@ import {
ExternalLinkIcon,
FileIcon,
Loader2Icon,
PencilIcon,
SearchIcon,
SparklesIcon,
} from 'lucide-react';
PencilIcon,
SearchIcon,
SparklesIcon,
} from 'lucide-react';
import {
memo,
useCallback,
@@ -17,10 +17,10 @@ import {
useEffect,
useMemo,
useState,
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
type ReactNode,
} from 'react';
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
type ReactNode,
} from 'react';
import rehypeKatex from 'rehype-katex';
import { Loader } from '@/components/ai-elements/loader';
@@ -695,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
@@ -724,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
@@ -776,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
@@ -840,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 max-w-5xl">
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
<CollapsibleTrigger asChild>
<button
type="button"
@@ -946,17 +958,17 @@ function MultiRoundRagContent({
isLoading: boolean;
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
const [open, setOpen] = useState(!trace.isComplete);
useEffect(() => {
// Keep the active turn expanded so each new retrieval is visible as it
// arrives, then match GeneralQA by collapsing the completed trace. The
// header still carries the total round count for quick scanning.
setOpen(!trace.isComplete);
}, [trace.isComplete]);
}) {
const [open, setOpen] = useState(!trace.isComplete);
useEffect(() => {
// Keep the active turn expanded so each new retrieval is visible as it
// arrives, then match GeneralQA by collapsing the completed trace. The
// header still carries the total round count for quick scanning.
setOpen(!trace.isComplete);
}, [trace.isComplete]);
return (
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<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"
@@ -970,42 +982,43 @@ function MultiRoundRagContent({
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
)}
<div>
<div className="text-sm font-medium">多轮检索思考过程</div>
<div className="text-muted-foreground text-xs">
{trace.isComplete
? `共 ${trace.rounds.length} 轮推理与检索,可展开查看细节`
: '正在分析问题并检索资料'}
</div>
<div className="text-sm font-medium">多轮检索思考过程</div>
<div className="text-muted-foreground text-xs">
{trace.isComplete
? `共 ${trace.rounds.length} 轮推理与检索,可展开查看细节`
: '正在分析问题并检索资料'}
</div>
</div>
</div>
<ChevronDownIcon
className={cn('text-muted-foreground size-4 transition-transform', !open && '-rotate-90')}
/>
</button>
{open && (
<div className="border-border/60 space-y-3 border-t px-4 py-3">
{trace.rounds.map((round, index) => (
<div
key={round.id}
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}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
</div>
))}
</div>
{open && (
<div className="border-border/60 space-y-3 border-t px-4 py-3">
{trace.rounds.map((round, index) => (
<div
key={round.id}
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>
)}
</section>
<section className="border-border/70 rounded-xl border p-4">
@@ -1030,240 +1043,249 @@ function MultiRoundRagContent({
);
}
function MultiRoundRagRoundCard({
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]);
return (
<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" />}
>
<MarkdownContent
content={round.thought}
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>
)}
</MultiRoundRagDetailSection>
)}
</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>
);
}
}) {
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 (
<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" />}
>
<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>
)}
</MultiRoundRagDetailSection>
)}
</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>
);
}
function resolveSingleRoundRagSourceUrl(
source: RuntimeDisplaySource | null,
@@ -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 {
@@ -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;
@@ -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);
}
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();
@@ -73,6 +73,8 @@ export function createEightBfFallbackTask(taskId: string): EightBfTask {
}
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",
@@ -89,11 +91,31 @@ function externalApiBase(): string {
);
}
/** 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。
@@ -122,6 +144,66 @@ async function fetchExternalTaskSystem(
});
}
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;
@@ -1,25 +1,42 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useSearchParams } from "react-router-dom";
import {
Bot,
ChevronDown,
ChevronLeft,
Loader2,
Search,
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>;
@@ -31,6 +48,35 @@ export interface EightBfStep1PanelProps extends 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;
}
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({
@@ -41,6 +87,11 @@ export function EightBfStep1Panel({
modelOptions,
onSelectedModelChange,
intentReady,
step2Dialogues = [],
step2ThreadIds = null,
step2CoordinatorName = null,
step2SeatStubMessages,
step3Summary = null,
...step1Props
}: EightBfStep1PanelProps) {
const [rightPanelOpen, setRightPanelOpen] = useState(true);
@@ -82,6 +133,7 @@ export function EightBfStep1Panel({
>
分析结果
</button>
<EightBfRoundtableBackButton />
</div>
}
rightSidebar={
@@ -96,10 +148,14 @@ export function EightBfStep1Panel({
<EightBfAgentWorkspace
tab={rightTab}
chain={chain}
intentReady={intentReady}
canConfigureChain={canConfigureChain}
onConfigureChain={onConfigureChain}
onCollapse={() => setRightPanelOpen(false)}
step2Dialogues={step2Dialogues}
step2ThreadIds={step2ThreadIds}
step2CoordinatorName={step2CoordinatorName}
step2SeatStubMessages={step2SeatStubMessages}
step3Summary={step3Summary}
/>
</div>
</aside>
@@ -123,14 +179,27 @@ 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>
);
@@ -223,22 +292,44 @@ export function EightBfHeaderChain({ chainTitle }: { chainTitle?: string }) {
function EightBfAgentWorkspace({
tab,
chain,
intentReady,
canConfigureChain,
onConfigureChain,
onCollapse,
step2Dialogues,
step2ThreadIds,
step2CoordinatorName,
step2SeatStubMessages,
step3Summary,
}: {
tab: "agents" | "results";
chain: BusinessChain | null;
intentReady: object | 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)) {
@@ -250,13 +341,22 @@ function EightBfAgentWorkspace({
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>
<span>分析成果列表</span>
<button
type="button"
className="position-roundtable-intent-config-collapse-btn"
@@ -268,8 +368,20 @@ function EightBfAgentWorkspace({
</button>
</div>
<div className="position-roundtable-intent-analysis-results-scroll eightbf-roundtable-intent-results-scroll">
<EightBfIntentResults intentReady={intentReady} />
<AnalysisResultCardList
items={analysisItems}
emptyTitle="暂无交付产物"
emptyDescription="完成第二步研讨与第三步方案总结并生成 Markdown 后,产物会显示在这里。"
padded={false}
onOpen={setDetailItem}
/>
</div>
<AnalysisResultDetailDialog
item={detailItem}
onOpenChange={(open) => {
if (!open) setDetailItem(null);
}}
/>
</section>
);
}
@@ -304,7 +416,7 @@ function EightBfAgentWorkspace({
onAskQuestion={() => toast.info("请在中间对话区向任务理解智能体发起问题")}
showRecentChats={false}
avatarSrc={agentConfigLogo}
positionName={chain?.title || "8BF 业务链"}
positionName={positionNameById(selectedSeat?.position_id)}
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
skillsTitle="技能列表"
showCommonQuestions
@@ -320,11 +432,8 @@ function EightBfAgentWorkspace({
return (
<section className="eightbf-roundtable__agent-list-workspace">
<header>
<div>
<h3>智能体配置</h3>
<p>{chain?.title ? `业务链「${chain.title}」已配置的智能体` : "请先配置 8BF 业务链"}</p>
</div>
<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}>
@@ -341,27 +450,41 @@ function EightBfAgentWorkspace({
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
</div>
</header>
<div className="eightbf-roundtable__agent-list-search">
<Search className="size-3.5" />
<span>业务链智能体</span>
<span>{seatAgents.length} 个</span>
</div>
<div className="eightbf-roundtable__agent-list-scroll">
<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-list-loading"><Loader2 className="size-4 animate-spin" /> 正在加载智能体…</div>
) : seats.length === 0 ? (
<div className="eightbf-roundtable__agent-list-empty">当前业务链未配置智能体</div>
<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>
) : (
seatAgents.map(({ seat, agent }, index) => (
<EightBfAgentRow
key={seat.agent_id}
seat={seat}
agent={agent}
index={index}
onSelect={() => setSelectedAgentId(seat.agent_id)}
/>
))
<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>
@@ -371,45 +494,27 @@ function EightBfAgentWorkspace({
function EightBfAgentRow({
seat,
agent,
index,
onSelect,
}: {
seat: ChainSeat;
agent: Agent | null;
index: number;
onSelect: () => void;
}) {
const name = agent?.name || seat.name || "未命名智能体";
const description = agent?.description || seat.description || "查看该智能体的模型、技能与常用问题配置";
const description = agent?.description || seat.description || "智能体助手";
const avatarText = Array.from(name.trim() || "?")[0]?.toUpperCase() || "?";
return (
<button type="button" className="eightbf-roundtable__agent-list-row" onClick={onSelect}>
<span className="eightbf-roundtable__agent-list-avatar" aria-hidden="true"><Bot /></span>
<span className="min-w-0 flex-1 text-left">
<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>
<small>{description}</small>
<p>{description}</p>
</span>
<span className="eightbf-roundtable__agent-list-index">{String(index + 1).padStart(2, "0")}</span>
<i className="eightbf-roundtable__agent-selector-bar" aria-hidden="true" />
</button>
);
}
function EightBfIntentResults({ intentReady }: { intentReady: object | null }) {
const sections = intentSections(intentReady);
if (sections.length === 0) {
return <p className="eightbf-roundtable__intent-results-empty">完成意图识别后,将在这里展示任务意图与关键约束。</p>;
}
return (
<div className="eightbf-roundtable__intent-results-list">
{sections.map((section) => (
<section key={section.label}>
<h3>{section.label}</h3>
<ul>{section.items.map((item) => <li key={item}>{item}</li>)}</ul>
</section>
))}
</div>
);
}
function intentSections(intentReady: object | null): Array<{ label: string; items: string[] }> {
const source = intentReady && typeof intentReady === "object" ? intentReady as IntentRecord : null;
if (!source) return [];
@@ -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;
}
@@ -15,13 +15,17 @@ import {
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,
@@ -41,6 +45,7 @@ const BUSINESS_CODE = "8BF";
* 不引用岗位会商的页面、会话或接口。
*/
export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) {
useEightBfParentReturnBridge();
const auth = getStoredAuth();
const [searchParams] = useSearchParams();
const taskId = useMemo(
@@ -79,6 +84,15 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
() => buildEightBfTaskContext(task, taskDictionaries),
[task, taskDictionaries],
);
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
() => taskId ? {
taskId,
position: 0,
createProcessReport: createEightBfTaskProcessReport,
updateProcessReport: updateEightBfTaskProcessReport,
} : undefined,
[taskId],
);
useEffect(() => {
if (!taskId) {
@@ -254,6 +268,8 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
hideHeaderSettings
newTaskLabel="新建会话"
hideDefaultStep1RightSidebar
step2HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 多智能体会商`}
step3HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 方案总结`}
renderStep1={(props) => (
<EightBfStep1Panel
{...props}
@@ -262,15 +278,17 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
onConfigureChain={openChainSettings}
/>
)}
renderStep1LeftSidebar={({ intentReady }) => (
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={
@@ -278,6 +296,8 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
chain={configuredChain}
loading={isLoading}
embedded
submittedSeatKeys={submittedSeatKeys}
onSeatClick={onSeatClick}
className="position-roundtable-intent-delivery-flow eightbf-roundtable__business-chain-flow"
/>
}
@@ -305,6 +325,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
/>
}
rootClassName="eightbf-roundtable"
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
/>
);
}
@@ -17,6 +17,25 @@
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); }
@@ -178,17 +197,6 @@
flex-direction: column;
background: #032650;
}
.eightbf-roundtable .eightbf-roundtable__agent-list-workspace > header {
display: flex;
min-height: 64px;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid rgb(43 143 254 / 35%);
}
.eightbf-roundtable .eightbf-roundtable__agent-list-workspace h3 { margin: 0; color: #fff; font-size: 17px; font-weight: 500; }
.eightbf-roundtable .eightbf-roundtable__agent-list-workspace p { margin: 4px 0 0; color: #aaceff; font-size: 11px; line-height: 1.45; }
.eightbf-roundtable .eightbf-roundtable__configure-chain-button {
display: inline-flex;
height: 28px;
@@ -202,45 +210,170 @@
font-size: 11px;
}
.eightbf-roundtable .eightbf-roundtable__configure-chain-button:hover { background: rgb(0 98 217 / 40%); }
.eightbf-roundtable .eightbf-roundtable__agent-list-search {
.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: 7px;
margin: 12px;
border: 1px solid rgb(43 143 254 / 36%);
border-radius: 4px;
background: rgb(0 12 28 / 20%);
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;
padding: 8px 9px;
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-list-search span:last-child { margin-left: auto; border-radius: 999px; background: #0b386d; padding: 1px 6px; color: #83c1ff; font-size: 10px; }
.eightbf-roundtable .eightbf-roundtable__agent-list-scroll { min-height: 0; flex: 1; overflow-y: auto; padding: 0 12px 12px; }
.eightbf-roundtable .eightbf-roundtable__agent-list-loading,
.eightbf-roundtable .eightbf-roundtable__agent-list-empty { display: flex; min-height: 100px; align-items: center; justify-content: center; gap: 7px; color: #8aa0c4; font-size: 12px; text-align: center; }
.eightbf-roundtable .eightbf-roundtable__agent-list-row {
display: flex;
width: 100%;
align-items: center;
gap: 10px;
border-bottom: 1px solid rgb(43 143 254 / 21%);
padding: 11px 2px;
color: inherit;
text-align: left;
transition: background-color .16s ease, transform .16s ease;
.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-list-row:hover { background: rgb(0 98 217 / 13%); transform: translateX(2px); }
.eightbf-roundtable .eightbf-roundtable__agent-list-avatar { display: inline-flex; width: 34px; height: 34px; flex: 0 0 auto; align-items: center; justify-content: center; border: 1px solid #007e82; border-radius: 7px; background: #00306a; color: #73c8ff; }
.eightbf-roundtable .eightbf-roundtable__agent-list-avatar svg { width: 18px; height: 18px; }
.eightbf-roundtable .eightbf-roundtable__agent-list-row strong,
.eightbf-roundtable .eightbf-roundtable__agent-list-row small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.eightbf-roundtable .eightbf-roundtable__agent-list-row strong { color: #fff; font-size: 13px; font-weight: 600; }
.eightbf-roundtable .eightbf-roundtable__agent-list-row small { margin-top: 3px; color: #aaceff; font-size: 11px; }
.eightbf-roundtable .eightbf-roundtable__agent-list-index { color: #75b7f5; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; }
.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 .eightbf-roundtable__intent-results-scroll { padding: 12px; }
.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; }
@@ -65,6 +65,62 @@
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 {
@@ -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"),
};
+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>
@@ -40,7 +40,6 @@ import {
MessageSquareText,
PanelRightOpen,
RotateCcw,
SlidersHorizontal,
Sparkles,
Telescope,
Trash2,
@@ -126,6 +125,7 @@ import {
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";
@@ -1764,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)}>
@@ -2654,6 +2660,18 @@ function AdvancedResearchSettingsDialog({
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
@@ -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: "标准",
@@ -473,6 +474,12 @@ export function ReportConfigCard({
onChange={(value) => update("customOutline", value)}
/>
</div>
<div className="flex justify-end sm:col-span-2">
<WorkflowConfigButton
appliedContent={options.customOutline}
onApply={(value) => update("customOutline", value)}
/>
</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
type="checkbox"
@@ -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}
/>
</>
);
}
@@ -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;
@@ -34,6 +40,14 @@ export interface PositionBusinessChainFlowProps {
* 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[] {
@@ -51,13 +65,31 @@ 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
@@ -112,14 +144,26 @@ export function PositionBusinessChainFlow({
</div>
) : (
<>
<BusinessChainCanvas chain={chain} embedded={embedded} 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>
@@ -133,11 +177,15 @@ 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 });
@@ -175,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);
@@ -212,11 +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}
/>
);
})}
@@ -277,6 +328,7 @@ function FlowNode({
completed = false,
width,
compact = false,
onClick,
}: {
title: string;
description: string;
@@ -284,25 +336,27 @@ function FlowNode({
completed?: boolean;
width: number;
compact?: boolean;
onClick?: () => void;
}) {
const StatusIcon = completed ? CheckCircle2 : Circle;
return (
<article
className={cn(
"relative overflow-hidden rounded-xl border border-slate-200 bg-white p-3 pr-10 shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0a2d58]",
compact && "rounded-lg",
)}
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}
@@ -310,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助手-全量-业务链条-取消发布"
@@ -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";
@@ -50,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,
@@ -87,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;
@@ -96,10 +96,13 @@ 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;
@@ -150,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;
@@ -160,14 +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,
@@ -199,7 +204,6 @@ export function Step2Panel(props: Step2PanelProps) {
setAgentConfigs,
setSelectedAgentId,
onPauseRoundtable,
onResumeRoundtable,
onHandleInjectToRoundtable,
onSubmitClarification,
onSavePersona,
@@ -207,6 +211,7 @@ export function Step2Panel(props: Step2PanelProps) {
onBackgroundSuspend,
isViewingHistory,
onTriggerToast,
taskArtifactSubmission,
} = props;
const step2ScrollRef = useRef<HTMLDivElement>(null);
@@ -376,9 +381,6 @@ export function Step2Panel(props: Step2PanelProps) {
prevArtifactsOpenRef.current = artifactsOpenForFlow;
}, [artifactsOpenForFlow]);
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
const [pauseFlash, setPauseFlash] = useState(false);
// 干预消息携带的本地附件(点「干预并发送」时连同指令一起上传)。
const [injectFiles, setInjectFiles] = useState<File[]>([]);
@@ -401,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>();
@@ -415,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>
@@ -556,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">
@@ -592,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">
@@ -648,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 */}
@@ -741,11 +760,11 @@ export function Step2Panel(props: Step2PanelProps) {
onAddFiles={addInjectFiles}
onRemoveFile={removeInjectFile}
charactersLeft={Math.max(0, 500 - customAgentInput.length)}
isSending={false}
canSend
isSending={isStep2Responding}
canSend={customAgentInput.trim().length > 0 || injectFiles.length > 0}
pendingClarification={false}
onSend={handleInject}
onStop={() => undefined}
onStop={onPauseRoundtable}
onFollowUp={() => undefined}
onSkip={() => undefined}
onEnterRoundtable={() => undefined}
@@ -808,8 +827,18 @@ 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>
);
}
@@ -950,6 +979,7 @@ export interface Step2MessageProps {
) => void;
/** 点击气泡下方 `present_files` 文件卡时打开沙箱(走真实 HTTP 路径)。 */
onFileCardClick?: (path: string) => void;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}
export function Step2Message({
@@ -960,6 +990,7 @@ export function Step2Message({
resolveAgentName,
onArtifactStepClick,
onFileCardClick,
taskArtifactSubmission,
}: Step2MessageProps) {
const isInjected = msg.sender === "用户 (插话干预)";
const isStreaming = msg.streaming === true;
@@ -1038,6 +1069,8 @@ export function Step2Message({
files={presentedFiles}
onClick={onFileCardClick}
disabled={!msg.agentThreadId}
threadId={msg.agentThreadId}
taskArtifactSubmission={taskArtifactSubmission}
/>
)}
@@ -1103,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">
@@ -1116,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>
@@ -1149,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>
);
}
@@ -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[];
}
@@ -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 模式底部「快捷跳转」栏
@@ -314,6 +328,9 @@ export default function RoundtablePlanningPage({
renderStep2LeftSidebarHeader,
/** 第一阶段使用内嵌右栏时,隐藏通用的模型/摘要/澄清记录栏。 */
hideDefaultStep1RightSidebar = false,
step2HeaderTitle,
step2HeaderRightSlot,
step3HeaderTitle,
/** 独立业务页的工作区品牌区,取代通用步骤导航。 */
headerLeftSlot,
/** 放在历史记录后面的独立业务操作或状态。 */
@@ -324,6 +341,8 @@ export default function RoundtablePlanningPage({
newTaskLabel,
/** 独立业务页可隐藏通用的设置入口。 */
hideHeaderSettings = false,
/** 可选的外部任务过程产物提交适配器(8BF 使用,通用圆桌保持不显示)。 */
taskArtifactSubmissionConfig,
}: {
compactChrome?: boolean;
lockedChainId?: string | null;
@@ -339,18 +358,35 @@ export default function RoundtablePlanningPage({
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;
renderStep1LeftSidebar?: (context: { intentReady: object | null }) => 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 常驻层)。
@@ -464,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 时)回显历史草稿,这段时间
// 页面只有一句欢迎语、问答还没真正开始,线程创建慢时像卡住。下面这个标志用来在超时后
@@ -718,6 +754,64 @@ export default function RoundtablePlanningPage({
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 = {
@@ -1263,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 () => {
@@ -1523,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,
@@ -1535,6 +1629,7 @@ export default function RoundtablePlanningPage({
coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null,
chainLevels,
autoFlow: autoSummaryFlow,
flowEnabled: false,
savedSummary: step3Report?.summaryReport ?? null,
onSummarySaved: handleSummarySaved,
ingestAvailable,
@@ -2193,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" />
@@ -2346,7 +2450,9 @@ export default function RoundtablePlanningPage({
setSidebarExpanded={setStep2SidebarExpanded}
/>
<main
data-step2-sidebar-expanded={currentStep === 2 ? String(step2SidebarExpanded) : undefined}
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 */}
@@ -2374,7 +2480,7 @@ export default function RoundtablePlanningPage({
lockBusinessChain={lockBusinessChain}
entryRailBusinessChainSlot={entryRailBusinessChainSlot}
entryRailTaskCardSlot={entryRailTaskCardSlot}
renderStep1LeftSidebar={renderStep1LeftSidebar}
renderStep1LeftSidebar={renderStep1LeftSidebarWithDeliveries}
renderStep2LeftSidebarHeader={renderStep2LeftSidebarHeader}
/>
@@ -2389,6 +2495,11 @@ export default function RoundtablePlanningPage({
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={
@@ -2409,9 +2520,10 @@ export default function RoundtablePlanningPage({
{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}
@@ -2439,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}
@@ -2460,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}
@@ -2489,6 +2603,7 @@ export default function RoundtablePlanningPage({
onReset={handleStartNewTask}
openArtifactPreview={step2.openArtifactPreview}
seatStubMessages={step2.seatStubMessages}
taskArtifactSubmission={taskArtifactSubmission}
/>
)}
</section>
@@ -2901,7 +3016,12 @@ interface LeftSidebarProps {
/** 第一阶段由独立业务页提供的自定义任务卡片。 */
entryRailTaskCardSlot?: ReactNode;
/** 独立业务页替换第一步左侧工作区;仅提供已解析的意图状态。 */
renderStep1LeftSidebar?: (context: { intentReady: object | null }) => ReactNode;
renderStep1LeftSidebar?: (context: {
intentReady: object | null;
onCollapse?: () => void;
submittedSeatKeys?: string[];
onSeatClick?: (seat: ChainSeat) => void;
}) => ReactNode;
/** 独立业务页在第二步左栏顶部复用任务与意图区。 */
renderStep2LeftSidebarHeader?: (context: {
intentReady: object | null;
@@ -2920,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;
@@ -2970,9 +3098,38 @@ 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
@@ -2982,7 +3139,7 @@ function LeftSidebar({
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 && (
@@ -2991,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>
@@ -3239,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(() => {
@@ -3718,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",
@@ -3829,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",
)}
@@ -4216,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,
)}
>
@@ -4353,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] }),
}),
};
}
@@ -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 };
}
@@ -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
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -64,15 +64,20 @@ class AgentResponse(BaseModel):
description="入库模板名 (external knowledge-ingest templateName). When set, markdown artifacts produced in this agent's chats can be published to the external knowledge-ingest API.",
)
seat_skill_directive: bool | None = Field(
seat_skill_directive: bool | None = Field(
default=None,
description="会商席位「技能识别强化」开关 (config.yaml). True = 本智能体作为会商席位时强化技能识别 (与业务链条同名开关取 OR). None/False = 关 (默认).",
)
llmwiki_knowledge_base_ids: list[str] | None = Field(
default=None,
description="DeerFlow LLMWiki mapping ids this agent may offer for conversation retrieval.",
)
)
job_responsibilities: str | None = Field(
default=None,
description="岗位职责 (config.yaml). Shown above 常用问题 in the agent configuration panel. None/empty = not set.",
)
llmwiki_knowledge_base_ids: list[str] | None = Field(
default=None,
description="DeerFlow LLMWiki mapping ids this agent may offer for conversation retrieval.",
)
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
@@ -125,12 +130,13 @@ class AgentCreateRequest(BaseModel):
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
common_questions: list[CommonQuestion] | None = Field(default=None, description="Recommended questions (常用问题) persisted to config.yaml. Each item is {title, prompt}; legacy strings are coerced.")
template_name: str | None = Field(default=None, description="入库模板名 persisted to config.yaml. Enables publishing markdown artifacts to the external knowledge-ingest API.")
seat_skill_directive: bool | None = Field(default=None, description="会商席位「技能识别强化」开关 persisted to config.yaml. None/False = off (默认).")
llmwiki_knowledge_base_ids: list[str] | None = Field(
default=None,
max_length=100,
description="Allowed DeerFlow LLMWiki mapping ids persisted to config.yaml.",
)
seat_skill_directive: bool | None = Field(default=None, description="会商席位「技能识别强化」开关 persisted to config.yaml. None/False = off (默认).")
job_responsibilities: str | None = Field(default=None, description="岗位职责 persisted to config.yaml. Empty/None = not set.")
llmwiki_knowledge_base_ids: list[str] | None = Field(
default=None,
max_length=100,
description="Allowed DeerFlow LLMWiki mapping ids persisted to config.yaml.",
)
class AgentUpdateRequest(BaseModel):
@@ -147,12 +153,13 @@ class AgentUpdateRequest(BaseModel):
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
common_questions: list[CommonQuestion] | None = Field(default=None, description="Updated recommended questions (常用问题, config.yaml). Each item is {title, prompt}; legacy strings are coerced.")
template_name: str | None = Field(default=None, description="Updated 入库模板名 (config.yaml). Empty string clears it.")
seat_skill_directive: bool | None = Field(default=None, description="Updated 会商席位「技能识别强化」开关 (config.yaml).")
llmwiki_knowledge_base_ids: list[str] | None = Field(
default=None,
max_length=100,
description="Updated allowed DeerFlow LLMWiki mapping ids (config.yaml).",
)
seat_skill_directive: bool | None = Field(default=None, description="Updated 会商席位「技能识别强化」开关 (config.yaml).")
job_responsibilities: str | None = Field(default=None, description="Updated 岗位职责 (config.yaml). Empty string clears it.")
llmwiki_knowledge_base_ids: list[str] | None = Field(
default=None,
max_length=100,
description="Updated allowed DeerFlow LLMWiki mapping ids (config.yaml).",
)
class AgentSelectorResponse(BaseModel):
@@ -193,38 +200,38 @@ def _current_user_id(request: Request) -> str:
return get_effective_user_id()
def _is_admin_user(request: Request) -> bool:
def _is_admin_user(request: Request) -> bool:
"""Return True when the request is authenticated as a system admin."""
user = getattr(request.state, "user", None)
return getattr(user, "system_role", None) == "admin"
async def _validate_llmwiki_bindings(
request: Request,
mapping_ids: list[str] | None,
) -> list[str] | None:
"""Normalize bindings and require read access for the editing actor."""
if mapping_ids is None:
return None
normalized = list(dict.fromkeys(item.strip() for item in mapping_ids if item.strip()))
if not normalized:
return []
store = getattr(request.app.state, "llmwiki_store", None)
if store is None:
raise HTTPException(status_code=503, detail="LLMWiki metadata store is unavailable")
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
for mapping_id in normalized:
row = await store.get_authorized(
mapping_id,
user_id,
write=False,
is_admin=is_admin,
)
if row is None:
raise HTTPException(status_code=422, detail=f"LLMWiki knowledge base '{mapping_id}' is unavailable")
return normalized
return getattr(user, "system_role", None) == "admin"
async def _validate_llmwiki_bindings(
request: Request,
mapping_ids: list[str] | None,
) -> list[str] | None:
"""Normalize bindings and require read access for the editing actor."""
if mapping_ids is None:
return None
normalized = list(dict.fromkeys(item.strip() for item in mapping_ids if item.strip()))
if not normalized:
return []
store = getattr(request.app.state, "llmwiki_store", None)
if store is None:
raise HTTPException(status_code=503, detail="LLMWiki metadata store is unavailable")
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
for mapping_id in normalized:
row = await store.get_authorized(
mapping_id,
user_id,
write=False,
is_admin=is_admin,
)
if row is None:
raise HTTPException(status_code=422, detail=f"LLMWiki knowledge base '{mapping_id}' is unavailable")
return normalized
def _email_local_part(email: str | None) -> str | None:
@@ -276,7 +283,7 @@ def _validate_storage_id(agent_id: str) -> str:
raise HTTPException(status_code=422, detail=str(exc)) from exc
def _write_agent_config(
def _write_agent_config(
agent_id: str,
*,
name: str,
@@ -286,9 +293,10 @@ def _write_agent_config(
tool_groups: list[str] | None = None,
common_questions: list[CommonQuestion] | None = None,
template_name: str | None = None,
seat_skill_directive: bool | None = None,
llmwiki_knowledge_base_ids: list[str] | None = None,
) -> None:
seat_skill_directive: bool | None = None,
job_responsibilities: str | None = None,
llmwiki_knowledge_base_ids: list[str] | None = None,
) -> None:
agent_dir = get_paths().agent_dir(agent_id)
agent_dir.mkdir(parents=True, exist_ok=True)
config_file = agent_dir / "config.yaml"
@@ -317,19 +325,30 @@ def _write_agent_config(
if template_name is not None and template_name.strip():
config_data["template_name"] = template_name.strip()
# 会商席位技能识别强化开关:仅当显式设置时写入(None = 不写, 由 AgentConfig 默认为关)。
if seat_skill_directive is not None:
config_data["seat_skill_directive"] = bool(seat_skill_directive)
if llmwiki_knowledge_base_ids is not None:
config_data["llmwiki_knowledge_base_ids"] = list(
dict.fromkeys(item.strip() for item in llmwiki_knowledge_base_ids if item.strip())
)
if seat_skill_directive is not None:
config_data["seat_skill_directive"] = bool(seat_skill_directive)
if job_responsibilities is not None and job_responsibilities.strip():
config_data["job_responsibilities"] = job_responsibilities.strip()
if llmwiki_knowledge_base_ids is not None:
config_data["llmwiki_knowledge_base_ids"] = list(
dict.fromkeys(item.strip() for item in llmwiki_knowledge_base_ids if item.strip())
)
with open(config_file, "w", encoding="utf-8") as f:
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
def _load_config_extras(agent_id: str) -> dict[str, Any]:
"""Read skills/model/tool_groups/common_questions from the agent's config.yaml, tolerant of missing files."""
empty = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None, "llmwiki_knowledge_base_ids": None}
empty = {
"skills": None,
"model": None,
"tool_groups": None,
"common_questions": None,
"template_name": None,
"seat_skill_directive": None,
"job_responsibilities": None,
"llmwiki_knowledge_base_ids": None,
}
try:
cfg = load_agent_config(agent_id)
except (FileNotFoundError, ValueError):
@@ -342,8 +361,9 @@ def _load_config_extras(agent_id: str) -> dict[str, Any]:
"tool_groups": cfg.tool_groups,
"common_questions": cfg.common_questions,
"template_name": cfg.template_name,
"seat_skill_directive": cfg.seat_skill_directive,
"llmwiki_knowledge_base_ids": cfg.llmwiki_knowledge_base_ids,
"seat_skill_directive": cfg.seat_skill_directive,
"job_responsibilities": cfg.job_responsibilities,
"llmwiki_knowledge_base_ids": cfg.llmwiki_knowledge_base_ids,
}
@@ -504,7 +524,16 @@ def _record_to_response(
can happen lazily when the agent is actually opened.
"""
soul: str | None = None
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None, "llmwiki_knowledge_base_ids": None}
extras: dict[str, Any] = {
"skills": None,
"model": None,
"tool_groups": None,
"common_questions": None,
"template_name": None,
"seat_skill_directive": None,
"job_responsibilities": None,
"llmwiki_knowledge_base_ids": None,
}
if include_extras:
_ensure_agent_dir_from_record(record)
extras = _load_config_extras(record["id"])
@@ -528,8 +557,9 @@ def _record_to_response(
skills=extras["skills"],
common_questions=extras.get("common_questions"),
template_name=extras.get("template_name"),
seat_skill_directive=extras.get("seat_skill_directive"),
llmwiki_knowledge_base_ids=extras.get("llmwiki_knowledge_base_ids"),
seat_skill_directive=extras.get("seat_skill_directive"),
job_responsibilities=extras.get("job_responsibilities"),
llmwiki_knowledge_base_ids=extras.get("llmwiki_knowledge_base_ids"),
featured_order=record.get("featured_order"),
)
@@ -830,7 +860,7 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
summary="Create Agent",
description="Create a new user-owned agent. The display name can be Chinese and may duplicate existing names.",
)
async def create_agent_endpoint(request: AgentCreateRequest, http_request: Request) -> AgentResponse:
async def create_agent_endpoint(request: AgentCreateRequest, http_request: Request) -> AgentResponse:
_require_agents_api_enabled()
name = _validate_display_name(request.name)
store = get_agent_store(http_request)
@@ -839,13 +869,13 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
agent_id = _validate_storage_id(request.id)
if await store.get_any(agent_id) is not None:
raise HTTPException(status_code=409, detail=f"Agent id '{agent_id}' already exists")
else:
agent_id = uuid4().hex
agent_dir = get_paths().agent_dir(agent_id)
llmwiki_ids = await _validate_llmwiki_bindings(
http_request,
request.llmwiki_knowledge_base_ids,
)
else:
agent_id = uuid4().hex
agent_dir = get_paths().agent_dir(agent_id)
llmwiki_ids = await _validate_llmwiki_bindings(
http_request,
request.llmwiki_knowledge_base_ids,
)
try:
if agent_dir.exists():
@@ -860,8 +890,9 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
tool_groups=request.tool_groups,
common_questions=request.common_questions,
template_name=request.template_name,
seat_skill_directive=request.seat_skill_directive,
llmwiki_knowledge_base_ids=llmwiki_ids,
seat_skill_directive=request.seat_skill_directive,
job_responsibilities=request.job_responsibilities,
llmwiki_knowledge_base_ids=llmwiki_ids,
)
_write_agent_soul(agent_id, request.soul)
@@ -942,14 +973,15 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"]
next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"]
next_template_name = request.template_name if "template_name" in fields_set else existing_extras["template_name"]
next_seat_skill_directive = request.seat_skill_directive if "seat_skill_directive" in fields_set else existing_extras["seat_skill_directive"]
next_llmwiki_ids = (
await _validate_llmwiki_bindings(http_request, request.llmwiki_knowledge_base_ids)
if "llmwiki_knowledge_base_ids" in fields_set
else existing_extras["llmwiki_knowledge_base_ids"]
)
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name", "seat_skill_directive", "llmwiki_knowledge_base_ids"))
next_seat_skill_directive = request.seat_skill_directive if "seat_skill_directive" in fields_set else existing_extras["seat_skill_directive"]
next_job_responsibilities = request.job_responsibilities if "job_responsibilities" in fields_set else existing_extras["job_responsibilities"]
next_llmwiki_ids = (
await _validate_llmwiki_bindings(http_request, request.llmwiki_knowledge_base_ids)
if "llmwiki_knowledge_base_ids" in fields_set
else existing_extras["llmwiki_knowledge_base_ids"]
)
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name", "seat_skill_directive", "job_responsibilities", "llmwiki_knowledge_base_ids"))
if config_dirty:
_write_agent_config(
agent_id,
@@ -960,8 +992,9 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
tool_groups=next_tool_groups,
common_questions=next_common_questions,
template_name=next_template_name,
seat_skill_directive=next_seat_skill_directive,
llmwiki_knowledge_base_ids=next_llmwiki_ids,
seat_skill_directive=next_seat_skill_directive,
job_responsibilities=next_job_responsibilities,
llmwiki_knowledge_base_ids=next_llmwiki_ids,
)
# Keep the listing cache in lock-step with config.yaml, but only
# when the cached fields were actually touched — a partial update
@@ -36,6 +36,27 @@ EDITABLE_TEXT_SUFFIXES = {
".log",
}
_VIRTUAL_USER_DATA_PREFIX = "mnt/user-data"
def coerce_artifact_virtual_path(path: str) -> str:
"""Normalize an artifacts URL path to a sandbox virtual path.
Streaming write previews may request a bare filename such as
``未命名文件.md`` before the model has emitted a real
``/mnt/user-data/outputs/...`` path. Mapping a single path segment into
the thread outputs directory turns that into a normal 404 (file not written
yet) instead of HTTP 400 ``Path must start with /mnt/user-data``.
"""
stripped = path.strip().lstrip("/").replace("\\", "/")
if not stripped:
raise HTTPException(status_code=400, detail="Path must start with /mnt/user-data")
if stripped == _VIRTUAL_USER_DATA_PREFIX or stripped.startswith(_VIRTUAL_USER_DATA_PREFIX + "/"):
return stripped
if "/" in stripped or stripped in {".", ".."}:
raise HTTPException(status_code=400, detail="Path must start with /mnt/user-data")
return f"{_VIRTUAL_USER_DATA_PREFIX}/outputs/{stripped}"
class ArtifactUpdateRequest(BaseModel):
content: str
@@ -198,6 +219,7 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
- Download file: `/api/threads/abc123/artifacts/mnt/user-data/outputs/data.csv?download=true`
- Active web content such as `.html`, `.xhtml`, and `.svg` artifacts is always downloaded
"""
path = coerce_artifact_virtual_path(path)
# Check if this is a request for a file inside a .skill archive (e.g., xxx.skill/SKILL.md)
if ".skill/" in path:
# Split the path at ".skill/" to get the ZIP file path and internal path
@@ -285,6 +307,7 @@ async def update_artifact(
if ".skill/" in path:
raise HTTPException(status_code=400, detail="Skill archive contents are read-only.")
path = coerce_artifact_virtual_path(path)
actual_path = await aresolve_thread_virtual_path(thread_id, path)
logger.info(f"Updating artifact path: thread_id={thread_id}, requested_path={path}, actual_path={actual_path}")
@@ -1,8 +1,10 @@
"""Compact/sidebar-summary menu override API.
Routes (prefix ``/api/compact-menu-overrides``):
GET "" list all compact-menu overrides
PUT "" replace the entire override set (admin)
GET "" list compact-menu overrides.
``menu_type`` omitted / empty → type A (original legacy data).
PUT "" replace that type's override set (admin).
``menu_type`` omitted / empty → type A, does not wipe type B.
"""
from __future__ import annotations
@@ -10,11 +12,15 @@ from __future__ import annotations
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user, get_optional_user_from_request
from deerflow.persistence.compact_menu_overrides import CompactMenuOverrideStore
from deerflow.persistence.compact_menu_overrides import (
CompactMenuOverrideStore,
DEFAULT_COMPACT_MENU_TYPE,
normalize_compact_menu_type,
)
router = APIRouter(prefix="/api/compact-menu-overrides", tags=["compact-menu-overrides"])
@@ -27,15 +33,22 @@ class CompactMenuOverride(BaseModel):
class CompactMenuOverrideResponse(CompactMenuOverride):
menu_type: str = DEFAULT_COMPACT_MENU_TYPE
updated_by: str | None = None
updated_at: datetime | str | None = None
class CompactMenuOverrideListResponse(BaseModel):
menu_type: str = DEFAULT_COMPACT_MENU_TYPE
overrides: list[CompactMenuOverrideResponse]
class CompactMenuOverrideReplaceRequest(BaseModel):
menu_type: str | None = Field(
default=None,
max_length=32,
description="A or B. Omitted/empty = A (original compact-menu set).",
)
overrides: list[CompactMenuOverride] = Field(default_factory=list)
@@ -55,10 +68,21 @@ async def _require_admin(request: Request) -> None:
@router.get("", response_model=CompactMenuOverrideListResponse)
async def list_compact_menu_overrides(request: Request) -> CompactMenuOverrideListResponse:
async def list_compact_menu_overrides(
request: Request,
menu_type: str | None = Query(
default=None,
max_length=32,
description="A or B. Omitted/empty = A, returning the original compact-menu rows.",
),
) -> CompactMenuOverrideListResponse:
t = normalize_compact_menu_type(menu_type)
store = _get_store(request)
rows = await store.list_overrides()
return CompactMenuOverrideListResponse(overrides=[CompactMenuOverrideResponse(**r) for r in rows])
rows = await store.list_overrides(t)
return CompactMenuOverrideListResponse(
menu_type=t,
overrides=[CompactMenuOverrideResponse(**r) for r in rows],
)
@router.put("", response_model=CompactMenuOverrideListResponse)
@@ -67,10 +91,15 @@ async def replace_compact_menu_overrides(
body: CompactMenuOverrideReplaceRequest,
) -> CompactMenuOverrideListResponse:
await _require_admin(request)
t = normalize_compact_menu_type(body.menu_type)
store = _get_store(request)
user_id = await get_current_user(request)
rows = await store.replace_all(
[o.model_dump() for o in body.overrides],
menu_type=t,
updated_by=user_id,
)
return CompactMenuOverrideListResponse(overrides=[CompactMenuOverrideResponse(**r) for r in rows])
return CompactMenuOverrideListResponse(
menu_type=t,
overrides=[CompactMenuOverrideResponse(**r) for r in rows],
)
@@ -0,0 +1,168 @@
"""CRUD API for deep-research report structures (报告结构 / 工作流配置).
The deep-research workbench's 高级配置 → 工作流配置 dialog renders a card per
row; picking a card applies ``content`` to the research config's
``custom_outline``. Global / shared — every account sees the same cards.
Routes (prefix ``/api/report-structures``):
GET "" list (optional ``enabled`` / ``title`` filters)
POST "" create (title / name / content; enabled forced True)
GET "/{id}" get one
PUT "/{id}" update title / name / content
PUT "/{id}/enabled" toggle 启用/停用
DELETE "/{id}" delete
The first list call seeds the two built-in outlines (通用分析结构 /
递进式论证结构) when the table has never held a row — see
``deerflow.persistence.report_structures.defaults``.
"""
from __future__ import annotations
import logging
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_optional_user_from_request
from deerflow.persistence.report_structures import ReportStructureStore
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/report-structures", tags=["report-structures"])
# ── schemas ──────────────────────────────────────────────────────────────
class ReportStructureResponse(BaseModel):
id: str
title: str
name: str
content: str
enabled: bool = True
created_by: str | None = None
created_at: str | None = None
updated_at: str | None = None
class ReportStructureListResponse(BaseModel):
structures: list[ReportStructureResponse]
class ReportStructureCreateRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=256)
name: str = Field(default="", max_length=256)
content: str = Field(..., min_length=1, max_length=20_000)
class ReportStructureUpdateRequest(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=256)
name: str | None = Field(default=None, max_length=256)
content: str | None = Field(default=None, min_length=1, max_length=20_000)
class ReportStructureEnabledRequest(BaseModel):
enabled: bool
# ── helpers ──────────────────────────────────────────────────────────────
def _get_store(request: Request) -> ReportStructureStore:
store = getattr(request.app.state, "report_structure_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Report structure store not available")
return store
async def _current_username(request: Request) -> str | None:
"""创建用户名:邮箱(本部署的账号即邮箱),无认证时为 None。"""
user = await get_optional_user_from_request(request)
if user is None:
return None
return str(user.email) or str(user.id)
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=ReportStructureListResponse)
async def list_report_structures(
request: Request,
enabled: bool | None = Query(default=None, description="启用/停用筛选;缺省=全部"),
title: str | None = Query(default=None, max_length=256, description="标题模糊筛选(大小写不敏感)"),
) -> ReportStructureListResponse:
store = _get_store(request)
await store.ensure_defaults()
rows = await store.list_structures()
if enabled is not None:
rows = [r for r in rows if bool(r.get("enabled", True)) is enabled]
if title:
needle = title.strip().lower()
if needle:
rows = [r for r in rows if needle in str(r.get("title", "")).lower()]
return ReportStructureListResponse(structures=[ReportStructureResponse(**r) for r in rows])
@router.post("", response_model=ReportStructureResponse, status_code=201)
async def create_report_structure(request: Request, body: ReportStructureCreateRequest) -> ReportStructureResponse:
store = _get_store(request)
row = await store.create_structure(
{
"id": str(uuid4()),
"title": body.title.strip(),
"name": body.name.strip(),
"content": body.content.strip(),
"created_by": await _current_username(request),
}
)
return ReportStructureResponse(**row)
@router.get("/{structure_id}", response_model=ReportStructureResponse)
async def get_report_structure(request: Request, structure_id: str) -> ReportStructureResponse:
store = _get_store(request)
row = await store.get_structure(structure_id)
if row is None:
raise HTTPException(status_code=404, detail="报告结构不存在")
return ReportStructureResponse(**row)
@router.put("/{structure_id}", response_model=ReportStructureResponse)
async def update_report_structure(
request: Request, structure_id: str, body: ReportStructureUpdateRequest
) -> ReportStructureResponse:
store = _get_store(request)
data: dict = {}
if body.title is not None:
data["title"] = body.title.strip()
if body.name is not None:
data["name"] = body.name.strip()
if body.content is not None:
data["content"] = body.content.strip()
if not data:
raise HTTPException(status_code=400, detail="没有需要更新的字段")
row = await store.update_structure(structure_id, data)
if row is None:
raise HTTPException(status_code=404, detail="报告结构不存在")
return ReportStructureResponse(**row)
@router.put("/{structure_id}/enabled", response_model=ReportStructureResponse)
async def set_report_structure_enabled(
request: Request, structure_id: str, body: ReportStructureEnabledRequest
) -> ReportStructureResponse:
store = _get_store(request)
row = await store.set_enabled(structure_id, body.enabled)
if row is None:
raise HTTPException(status_code=404, detail="报告结构不存在")
return ReportStructureResponse(**row)
@router.delete("/{structure_id}", status_code=204)
async def delete_report_structure(request: Request, structure_id: str) -> None:
store = _get_store(request)
deleted = await store.delete_structure(structure_id)
if not deleted:
raise HTTPException(status_code=404, detail="报告结构不存在")
@@ -52,6 +52,7 @@ class ArtifactSubmissionRequest(BaseModel):
task_id: StrictStr | None = Field(default=None, max_length=128, validation_alias=AliasChoices("taskId", "task_id"))
submitted: bool
register_only: bool = Field(default=False, validation_alias=AliasChoices("registerOnly", "register_only"))
submission_id: StrictStr | None = Field(default=None, max_length=64, validation_alias=AliasChoices("submissionId", "submission_id"))
task_delivery_id: StrictStr | None = Field(default=None, max_length=255, validation_alias=AliasChoices("taskDeliveryId", "task_delivery_id"))
expected_version: int | None = Field(default=None, ge=0, validation_alias=AliasChoices("expectedVersion", "expected_version"))
@@ -200,6 +201,27 @@ async def set_artifact_submission(
"submission": _response(conflict.row).model_dump(by_alias=True, exclude_none=False),
},
) from conflict
elif body.register_only:
if not artifact["thread_id"]:
raise HTTPException(status_code=422, detail="artifact.threadId is required when registering an artifact")
try:
row = await store.register(
path_task_id,
artifact,
task_delivery_id=body.task_delivery_id,
actor_id=actor_id,
expected_version=body.expected_version,
)
except SubmissionVersionConflict as conflict:
raise HTTPException(
status_code=409,
detail={
"code": "version_conflict",
"message": "登记产物版本冲突:另一写入已更新,请刷新后重试。",
"current_version": conflict.row.get("version", 0),
"submission": _response(conflict.row).model_dump(by_alias=True, exclude_none=False),
},
) from conflict
else:
try:
row = await store.cancel(
@@ -13,8 +13,9 @@ Routes (prefix ``/api/roundtable-chains``):
DELETE "/{chain_id}" delete
管理员权限:``system_role == "admin"`` 的用户(``_is_admin``)可 list ``scope=all``
(所有账号的全部链条),并可 get / 编辑 / 发布 / 删除**任意** owner 的链条;非管理员
(所有账号的全部链条),并可 get / 编辑 / 发布 / 启停 / 删除**任意** owner 的链条;非管理员
仍仅限本人链条(``scope=all`` 退化为 ``mine``)。编辑/删除不转移链条归属(``user_id`` 不变)。
``enabled`` 默认 True:停用后管理列表仍可见,但选链弹窗 / 公共入口不再选用。
"""
from __future__ import annotations
@@ -90,6 +91,8 @@ class ChainResponse(BaseModel):
gather_first: bool = False
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
is_public: bool = False
# 启停状态:True = 启用(可选入研讨);False = 停用。缺省 True。
enabled: bool = True
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
is_mine: bool = True
# 配置者(owner)的 user_id 及解析后的可读用户名(email)。owner_name 由路由层
@@ -116,6 +119,7 @@ class ChainCreateRequest(BaseModel):
coordinator_prompt: str | None = Field(default=None, max_length=4000)
seat_mode: str | None = Field(default=None, max_length=16)
gather_first: bool = False
enabled: bool = True
class ChainUpdateRequest(BaseModel):
@@ -131,6 +135,8 @@ class ChainUpdateRequest(BaseModel):
gather_first: bool | None = None
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
is_public: bool | None = None
# 启停开关(True 启用 / False 停用);仅 owner / 管理员可改。omit 不改。
enabled: bool | None = None
# ── helpers ──────────────────────────────────────────────────────────────
@@ -329,6 +335,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
"coordinator_prompt": body.coordinator_prompt,
"seat_mode": body.seat_mode,
"gather_first": body.gather_first,
"enabled": body.enabled,
},
)
await _enrich_owner_names([row])
@@ -33,6 +33,18 @@ concurrency:
fail_open: true
models:
- name: deepseek-v4-flash
display_name: deepseek-v4-flash
use: langchain_openai:ChatOpenAI
model: deepseek-v4-flash
api_key: sk-o2iNlFgqK4wE3EZlDTJ9nbt3xJnaaAHgn49O4GFAC8GfLIsA
base_url: https://ai.hzfzlz.com/v1
request_timeout: 30.0
max_retries: 2
stream_usage: true
supports_thinking: false
supports_reasoning_effort: false
supports_vision: false
- name: deepseek-chat
display_name: DeepSeek Chat
use: langchain_openai:ChatOpenAI
@@ -7,7 +7,7 @@ import threading
from typing import Any
import yaml
from pydantic import BaseModel, field_validator, model_validator
from pydantic import BaseModel, field_validator, model_validator
from deerflow.config.paths import get_paths
@@ -82,7 +82,7 @@ class CommonQuestion(BaseModel):
return value
class AgentConfig(BaseModel):
class AgentConfig(BaseModel):
"""Configuration for a custom agent."""
id: str | None = None
@@ -108,20 +108,23 @@ class AgentConfig(BaseModel):
# 拼进本轮任务,避免弱模型无视系统提示里的技能块。None/False = 关(默认);True = 开。
# 与「业务链条配置」里的同名开关取 OR:任一打开即对该席位生效。见
# ``app/gateway/roundtable_seat_skills.py``。
seat_skill_directive: bool | None = None
# DeerFlow mapping ids this agent may offer in its new-conversation
# knowledge selector. WeKnora ids and credentials never enter agent files.
llmwiki_knowledge_base_ids: list[str] | None = None
@field_validator("llmwiki_knowledge_base_ids")
@classmethod
def _normalize_llmwiki_ids(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return None
normalized = list(dict.fromkeys(str(item).strip() for item in value if str(item).strip()))
if len(normalized) > 100:
raise ValueError("An agent can bind at most 100 LLMWiki knowledge bases")
return normalized
seat_skill_directive: bool | None = None
# 岗位职责:会商智能体配置面板展示用描述。Display/UX metadata only —
# the runtime never reads this. None/empty = not set.
job_responsibilities: str | None = None
# DeerFlow mapping ids this agent may offer in its new-conversation
# knowledge selector. WeKnora ids and credentials never enter agent files.
llmwiki_knowledge_base_ids: list[str] | None = None
@field_validator("llmwiki_knowledge_base_ids")
@classmethod
def _normalize_llmwiki_ids(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return None
normalized = list(dict.fromkeys(str(item).strip() for item in value if str(item).strip()))
if len(normalized) > 100:
raise ValueError("An agent can bind at most 100 LLMWiki knowledge bases")
return normalized
def load_agent_config(agent_id: str | None) -> AgentConfig | None:
@@ -3,16 +3,24 @@
from __future__ import annotations
from deerflow.persistence.compact_menu_overrides.base import CompactMenuOverrideStore
from deerflow.persistence.compact_menu_overrides.keys import (
ALLOWED_COMPACT_MENU_TYPES,
DEFAULT_COMPACT_MENU_TYPE,
normalize_compact_menu_type,
)
from deerflow.persistence.compact_menu_overrides.memory import MemoryCompactMenuOverrideStore
from deerflow.persistence.compact_menu_overrides.model import CompactMenuOverrideRow
from deerflow.persistence.compact_menu_overrides.sql import CompactMenuOverrideRepository
__all__ = [
"ALLOWED_COMPACT_MENU_TYPES",
"CompactMenuOverrideRepository",
"CompactMenuOverrideRow",
"CompactMenuOverrideStore",
"DEFAULT_COMPACT_MENU_TYPE",
"MemoryCompactMenuOverrideStore",
"make_compact_menu_override_store",
"normalize_compact_menu_type",
]
@@ -8,11 +8,17 @@ from typing import Any
class CompactMenuOverrideStore(ABC):
@abstractmethod
async def list_overrides(self) -> list[dict[str, Any]]:
"""Return every stored compact-menu override row."""
async def list_overrides(self, menu_type: str | None = None) -> list[dict[str, Any]]:
"""Return compact-menu override rows for one type (default A)."""
raise NotImplementedError
@abstractmethod
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
"""Atomically replace the entire compact-menu override set."""
async def replace_all(
self,
rows: list[dict[str, Any]],
*,
menu_type: str | None = None,
updated_by: str | None = None,
) -> list[dict[str, Any]]:
"""Atomically replace the compact-menu override set for one type."""
raise NotImplementedError
@@ -0,0 +1,47 @@
"""Compact-menu type helpers.
Two website deployments can keep independent compact-sidebar layouts.
Type ``A`` is the historical default (existing rows stay as-is). Type ``B``
is stored under a prefixed primary key so it never collides with type A on
databases that still use ``node_id`` as the sole PK.
"""
from __future__ import annotations
DEFAULT_COMPACT_MENU_TYPE = "A"
ALLOWED_COMPACT_MENU_TYPES = ("A", "B")
_STORAGE_SEP = "::"
_TYPE_ALIASES = {
"A": "A",
"1": "A",
"DEFAULT": "A",
"B": "B",
"2": "B",
"ALT": "B",
}
def normalize_compact_menu_type(value: str | None) -> str:
raw = (value or "").strip().upper()
return _TYPE_ALIASES.get(raw, DEFAULT_COMPACT_MENU_TYPE)
def storage_node_id(menu_type: str, node_id: str) -> str:
"""PK value that stays unique across types on a node_id-only primary key."""
t = normalize_compact_menu_type(menu_type)
nid = str(node_id)
if t == DEFAULT_COMPACT_MENU_TYPE:
prefix = f"{t}{_STORAGE_SEP}"
return nid[len(prefix) :] if nid.startswith(prefix) else nid
prefix = f"{t}{_STORAGE_SEP}"
return nid if nid.startswith(prefix) else f"{prefix}{nid}"
def public_node_id(stored: str, menu_type: str | None = None) -> str:
"""Strip the type prefix so API callers always see the real menu node id."""
t = normalize_compact_menu_type(menu_type)
nid = str(stored)
prefix = f"{t}{_STORAGE_SEP}"
if nid.startswith(prefix):
return nid[len(prefix) :]
return nid
@@ -6,12 +6,19 @@ from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.compact_menu_overrides.base import CompactMenuOverrideStore
from deerflow.persistence.compact_menu_overrides.keys import (
normalize_compact_menu_type,
public_node_id,
storage_node_id,
)
def _normalize(data: dict[str, Any], updated_by: str | None) -> dict[str, Any]:
def _normalize(data: dict[str, Any], menu_type: str, updated_by: str | None) -> dict[str, Any]:
placement = str(data.get("placement") or "sidebar")
node_id = public_node_id(str(data["node_id"]), menu_type)
return {
"node_id": str(data["node_id"]),
"node_id": storage_node_id(menu_type, node_id),
"menu_type": menu_type,
"placement": placement if placement in {"sidebar", "settings"} else "sidebar",
"sort_order": int(data.get("sort_order", 0)),
"label": (data.get("label") or None),
@@ -20,18 +27,38 @@ def _normalize(data: dict[str, Any], updated_by: str | None) -> dict[str, Any]:
}
def _public(row: dict[str, Any]) -> dict[str, Any]:
menu_type = normalize_compact_menu_type(row.get("menu_type"))
out = dict(row)
out["menu_type"] = menu_type
out["node_id"] = public_node_id(str(row.get("node_id") or ""), menu_type)
return out
class MemoryCompactMenuOverrideStore(CompactMenuOverrideStore):
def __init__(self) -> None:
self._rows: dict[str, dict[str, Any]] = {}
async def list_overrides(self) -> list[dict[str, Any]]:
return [dict(r) for r in sorted(self._rows.values(), key=lambda r: r.get("sort_order", 0))]
async def list_overrides(self, menu_type: str | None = None) -> list[dict[str, Any]]:
t = normalize_compact_menu_type(menu_type)
rows = [r for r in self._rows.values() if normalize_compact_menu_type(r.get("menu_type")) == t]
return [_public(r) for r in sorted(rows, key=lambda r: r.get("sort_order", 0))]
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
self._rows = {}
async def replace_all(
self,
rows: list[dict[str, Any]],
*,
menu_type: str | None = None,
updated_by: str | None = None,
) -> list[dict[str, Any]]:
t = normalize_compact_menu_type(menu_type)
self._rows = {
k: v for k, v in self._rows.items() if normalize_compact_menu_type(v.get("menu_type")) != t
}
for r in rows:
node_id = r.get("node_id")
if not node_id:
continue
self._rows[str(node_id)] = _normalize(r, updated_by)
return [dict(r) for r in self._rows.values()]
stored = _normalize(r, t, updated_by)
self._rows[str(stored["node_id"])] = stored
return await self.list_overrides(t)
@@ -8,7 +8,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Integer, String
from sqlalchemy import Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
@@ -17,8 +17,10 @@ from deerflow.persistence.types import BeijingDateTime
class CompactMenuOverrideRow(Base):
__tablename__ = "compact_menu_overrides"
__table_args__ = (Index("ix_compact_menu_overrides_menu_type", "menu_type"),)
node_id: Mapped[str] = mapped_column(String(128), primary_key=True)
menu_type: Mapped[str] = mapped_column(String(32), default="A", server_default="A")
placement: Mapped[str] = mapped_column(String(32), default="sidebar")
sort_order: Mapped[int] = mapped_column(Integer, default=0)
label: Mapped[str | None] = mapped_column(String(255))
@@ -5,25 +5,36 @@ from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import delete, select
from sqlalchemy import delete, or_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.compact_menu_overrides.base import CompactMenuOverrideStore
from deerflow.persistence.compact_menu_overrides.keys import (
DEFAULT_COMPACT_MENU_TYPE,
normalize_compact_menu_type,
public_node_id,
storage_node_id,
)
from deerflow.persistence.compact_menu_overrides.model import CompactMenuOverrideRow
def _to_dict(row: CompactMenuOverrideRow) -> dict[str, Any]:
data = row.to_dict()
menu_type = normalize_compact_menu_type(data.get("menu_type"))
data["menu_type"] = menu_type
data["node_id"] = public_node_id(str(data.get("node_id") or ""), menu_type)
value = data.get("updated_at")
if isinstance(value, datetime):
data["updated_at"] = value.isoformat()
return data
def _row_from(data: dict[str, Any], updated_by: str | None) -> CompactMenuOverrideRow:
def _row_from(data: dict[str, Any], menu_type: str, updated_by: str | None) -> CompactMenuOverrideRow:
placement = str(data.get("placement") or "sidebar")
node_id = str(data["node_id"])
return CompactMenuOverrideRow(
node_id=str(data["node_id"]),
node_id=storage_node_id(menu_type, node_id),
menu_type=menu_type,
placement=placement if placement in {"sidebar", "settings"} else "sidebar",
sort_order=int(data.get("sort_order", 0)),
label=(data.get("label") or None),
@@ -35,23 +46,42 @@ class CompactMenuOverrideRepository(CompactMenuOverrideStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_overrides(self) -> list[dict[str, Any]]:
async def list_overrides(self, menu_type: str | None = None) -> list[dict[str, Any]]:
t = normalize_compact_menu_type(menu_type)
stmt = select(CompactMenuOverrideRow).order_by(CompactMenuOverrideRow.sort_order.asc())
stmt = stmt.where(_menu_type_filter(t))
async with self._sf() as session:
result = await session.execute(stmt)
return [_to_dict(row) for row in result.scalars()]
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
async def replace_all(
self,
rows: list[dict[str, Any]],
*,
menu_type: str | None = None,
updated_by: str | None = None,
) -> list[dict[str, Any]]:
t = normalize_compact_menu_type(menu_type)
by_id: dict[str, dict[str, Any]] = {}
for r in rows:
node_id = r.get("node_id")
if node_id:
by_id[str(node_id)] = r
by_id[public_node_id(str(node_id), t)] = r
async with self._sf() as session:
await session.execute(delete(CompactMenuOverrideRow))
new_rows = [_row_from(r, updated_by) for r in by_id.values()]
await session.execute(delete(CompactMenuOverrideRow).where(_menu_type_filter(t)))
new_rows = [_row_from(r, t, updated_by) for r in by_id.values()]
session.add_all(new_rows)
await session.commit()
for row in new_rows:
await session.refresh(row)
return [_to_dict(row) for row in new_rows]
def _menu_type_filter(menu_type: str):
if menu_type == DEFAULT_COMPACT_MENU_TYPE:
return or_(
CompactMenuOverrideRow.menu_type == menu_type,
CompactMenuOverrideRow.menu_type.is_(None),
CompactMenuOverrideRow.menu_type == "",
)
return CompactMenuOverrideRow.menu_type == menu_type
@@ -0,0 +1,52 @@
"""Add compact_menu_overrides.menu_type (简介模式菜单分套).
Two website deployments keep independent compact/sidebar-summary left menus.
Type ``A`` is the historical default; type ``B`` is stored under a prefixed
``node_id`` so it never collides on databases that still use ``node_id`` as
the sole primary key.
ORM: ``CompactMenuOverrideRow.menu_type = String(32), NOT NULL, default A``.
``create_all`` gives new DBs the column; existing DBs are patched by this
revision and by startup ``_ensure_orm_columns_sync``.
Revision ID: 20260819_01
Revises: 20260816_01
Create Date: 2026-08-19
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260819_01"
down_revision = "20260816_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
if _has_table("compact_menu_overrides") and not _has_column("compact_menu_overrides", "menu_type"):
op.add_column(
"compact_menu_overrides",
sa.Column("menu_type", sa.String(length=32), nullable=False, server_default="A"),
)
def downgrade() -> None:
if _has_column("compact_menu_overrides", "menu_type"):
op.drop_column("compact_menu_overrides", "menu_type")
@@ -0,0 +1,51 @@
"""Add enabled column to roundtable_chains.
Per-chain 启停 switch: True = 可在选链弹窗 / 公共入口里选用;False = 停用(管理列表
仍可见,但不可选入研讨)。Default True → existing chains stay enabled. BOOLEAN NOT
NULL DEFAULT 1, matching ``RoundtableChainRow.enabled``.
``create_all`` adds the column for brand-new DBs; the startup ``_ensure_orm_columns_sync``
sweep adds it to already-existing tables (sqlite/mysql/postgres). This revision keeps the
alembic graph consistent with the rest of the chain columns and documents the change.
Revision ID: 20260819_02
Revises: 20260819_01
Create Date: 2026-08-19
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260819_02"
down_revision = "20260819_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
if _has_table("roundtable_chains") and not _has_column("roundtable_chains", "enabled"):
op.add_column(
"roundtable_chains",
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
)
def downgrade() -> None:
if _has_column("roundtable_chains", "enabled"):
op.drop_column("roundtable_chains", "enabled")
@@ -0,0 +1,22 @@
"""Deep-research report-structure persistence (报告结构 / 工作流配置)."""
from __future__ import annotations
from deerflow.persistence.report_structures.base import ReportStructureStore
from deerflow.persistence.report_structures.memory import MemoryReportStructureStore
from deerflow.persistence.report_structures.model import ReportStructureRow
from deerflow.persistence.report_structures.sql import ReportStructureRepository
__all__ = [
"MemoryReportStructureStore",
"ReportStructureRepository",
"ReportStructureRow",
"ReportStructureStore",
"make_report_structure_store",
]
def make_report_structure_store(session_factory):
if session_factory is None:
return MemoryReportStructureStore()
return ReportStructureRepository(session_factory)
@@ -0,0 +1,64 @@
"""Abstract report-structure persistence API (报告结构 / 工作流配置).
Global / shared store: no ``user_id`` scoping — the deep-research 工作流配置
dialog shows the same cards to every account. ``created_by`` is a display-only
audit field (创建用户名). Rows are managed per-item (create / update /
toggle-enabled / delete); ``ensure_defaults`` lazily seeds the two built-in
outlines the first time the table is empty so a fresh deployment renders the
same 通用分析结构 / 递进式论证结构 cards the frontend used to hardcode.
"""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from typing import Any
from deerflow.persistence.report_structures.defaults import DEFAULT_REPORT_STRUCTURE_ROWS
logger = logging.getLogger(__name__)
class ReportStructureStore(ABC):
@abstractmethod
async def list_structures(self) -> list[dict[str, Any]]:
"""Return every stored structure, most recently modified first."""
raise NotImplementedError
@abstractmethod
async def get_structure(self, structure_id: str) -> dict[str, Any] | None:
raise NotImplementedError
@abstractmethod
async def create_structure(self, data: dict[str, Any]) -> dict[str, Any]:
"""Insert ``{id, title, name, content, created_by}`` (enabled forced True)."""
raise NotImplementedError
@abstractmethod
async def update_structure(self, structure_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
"""Update any of ``title`` / ``name`` / ``content``. None when missing."""
raise NotImplementedError
@abstractmethod
async def set_enabled(self, structure_id: str, enabled: bool) -> dict[str, Any] | None:
"""Flip 启用/停用 (also bumps updated_at). None when missing."""
raise NotImplementedError
@abstractmethod
async def delete_structure(self, structure_id: str) -> bool:
raise NotImplementedError
@abstractmethod
async def count(self) -> int:
raise NotImplementedError
async def ensure_defaults(self) -> None:
"""Seed the built-in outlines when the table has never held a row.
Concurrent first reads can race; seed ids are fixed so a losing insert
fails on the primary key and is simply skipped.
"""
if await self.count() > 0:
return
for row in DEFAULT_REPORT_STRUCTURE_ROWS:
await self.create_structure(dict(row))
@@ -0,0 +1,269 @@
"""深度研究「报告结构」默认种子(新机器部署开箱即有的内置结构库)。
前两套(通用分析结构 / 递进式论证结构)逐字提取自前端
``frontend-web/src/pages/deep-research/ReportConfigCard.tsx`` 的
``DEFAULT_REPORT_OUTLINE`` / ``PROGRESSIVE_REPORT_OUTLINE``;其余三套为本仓库
补充的常用研究结构(快速简报 / 事件分析 / 对象调查)。
表为空(全新部署、从未写过数据)时 ``ensure_defaults`` 会把全部内置结构插入
``report_structures``;已有数据的库不会追加。若调优了某套结构,请同步更新
本文件,否则新机器补建出来的还是旧版。
"""
from __future__ import annotations
GENERAL_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 段完整分析,每段围绕一个明确观点展开,采用“事实/证据 → 分析 → 影响或小结”的顺序。章节可根据研究主题合并、增删或调整,但不要为了套模板而编造不适用的主体、数据、案例或引用。"""
PROGRESSIVE_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 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。"""
BRIEF_OUTLINE = """# 报告标题
# 核心结论
用 3–5 条要点直接给出最重要的结论与判断,每条一两句话并可回溯到后文依据;先说结论,再列支撑。
# 关键事实
## 事实清单
按重要性列出已核验的关键事实、数据与出处;不确定的信息单独注明置信度。
## 时间与背景
用最短的篇幅交代理解结论所需的事件顺序与背景,不展开细节。
# 风险与不确定性
列出可能推翻或削弱上述结论的因素、证据缺口和正在变化中的变量。
# 建议行动
按优先级给出可执行的建议或下一步关注点,说明适用前提与预期作用;材料不足时明确说明证据尚不支持。
# 参考资料
仅列出本简报实际使用的来源,并与正文引用保持一致。
> 写作要求:全文控制在较短篇幅(建议 800–1500 字),信息密度优先;不写套话与背景铺陈,所有论断要么有来源支撑,要么明确标注为分析判断。"""
EVENT_OUTLINE = """# 报告标题
# 引言
用 1–2 段说明事件的性质、发生时间地点、当前状态与本报告要回答的核心问题。
# 一、事件概述
## 1.1 事件是什么
客观描述发生了什么:主体、行为、规模、直接结果;只写已核验事实。
## 1.2 各方说法
并列当事方、官方与媒体的不同表述,注明各自的信息来源与立场。
# 二、时间线还原
## 2.1 起因与前情
梳理事件发生的背景、前置条件与诱发节点。
## 2.2 关键节点
按时间顺序还原事件演进的关键节点,每个节点注明时间与来源;存在争议的时间点单独标注。
## 2.3 当前状态
说明事件目前的进展、处置情况与未决事项。
# 三、原因与责任分析
## 3.1 直接原因
分析触发事件的直接因素与机制。
## 3.2 深层原因
从制度、管理、技术、环境等维度分析更深层的促成条件。
## 3.3 责任与归因
基于事实分析各方责任,区分已认定的结论与仍在调查的部分。
# 四、影响与反应
## 4.1 直接影响
评估事件对当事方与相关群体已造成的影响。
## 4.2 各方反应与后续动作
梳理官方处置、当事方回应、舆论反应与衍生事件。
## 4.3 潜在延伸影响
分析可能的中长期影响与传导路径,注明判断依据。
# 五、结论与后续关注
## 5.1 核心结论
收束事件性质、原因与影响的核心判断。
## 5.2 待验证问题与观察点
列出尚未证实的关键问题与值得持续跟踪的信号。
# 参考资料
仅列出本报告实际使用的来源,并与正文引用保持一致。
> 写作要求:严格区分“已证实事实—各方声称—分析推断”三个层次并分别标注;时间线每个节点须有来源;对争议点呈现多方说法,不单方采信。"""
PROFILE_OUTLINE = """# 报告标题
# 引言
说明调查对象的界定范围(个人、组织或产品)、研究目的与资料边界(公开信息的时间范围与覆盖面)。
# 一、基本情况
## 1.1 基本信息与背景
梳理对象的公认基本信息与来历,注明信息时效。
## 1.2 发展沿革
按时间梳理关键成长节点、转折与现状。
# 二、能力与表现
## 2.1 核心能力或竞争力
分析对象的核心能力、资源禀赋或产品力,给出可观察的证据。
## 2.2 代表性成果与表现
列举代表性成果、业绩或事件表现,并评估其含金量与可持续性。
## 2.3 短板与争议
并列材料中的短板、批评与争议点,说明各方依据。
# 三、关系网络与生态
## 3.1 关联方与合作关系
梳理重要的合作关系、关联方与利益结构。
## 3.2 立场与倾向
基于言行记录分析对象的立场、风格与决策倾向。
# 四、风险评估
## 4.1 已知风险
汇总已暴露的风险点、负面记录与可靠性问题。
## 4.2 潜在风险与信息缺口
指出资料不足导致的盲区与需要进一步核实的问题。
# 五、综合评估
## 5.1 画像结论
用几句话给出对象的综合画像与可信度评估。
## 5.2 交往或合作建议
给出与对象打交道的注意事项与建议,说明前提。
# 参考资料
仅列出本报告实际使用的来源,并与正文引用保持一致。
> 写作要求:只依据公开可核验信息,猜测与推断必须明确标注;负面信息须注明来源与时间,区分“事实—评价—传闻”;信息不足时如实说明,不为了完整而编造。"""
# 固定 id:并发首查时重复插入会撞主键而被吞掉,不会产生重复行。
DEFAULT_REPORT_STRUCTURE_ROWS: list[dict] = [
{
"id": "seed-report-outline-general",
"title": "通用分析结构",
"name": "通用分析结构",
"content": GENERAL_OUTLINE,
"created_by": "系统内置",
},
{
"id": "seed-report-outline-progressive",
"title": "递进式论证结构",
"name": "递进式论证结构",
"content": PROGRESSIVE_OUTLINE,
"created_by": "系统内置",
},
{
"id": "seed-report-outline-brief",
"title": "快速简报结构",
"name": "快速简报结构",
"content": BRIEF_OUTLINE,
"created_by": "系统内置",
},
{
"id": "seed-report-outline-event",
"title": "事件分析结构",
"name": "事件分析结构",
"content": EVENT_OUTLINE,
"created_by": "系统内置",
},
{
"id": "seed-report-outline-profile",
"title": "对象调查结构",
"name": "对象调查结构",
"content": PROFILE_OUTLINE,
"created_by": "系统内置",
},
]
@@ -0,0 +1,66 @@
"""In-memory report-structure store (auth-less tests / no-DB fallback)."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.report_structures.base import ReportStructureStore
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def _sort_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return sorted(rows, key=lambda r: (r.get("updated_at") or "", r.get("created_at") or ""), reverse=True)
class MemoryReportStructureStore(ReportStructureStore):
def __init__(self) -> None:
self._rows: dict[str, dict[str, Any]] = {}
async def list_structures(self) -> list[dict[str, Any]]:
return _sort_rows(list(self._rows.values()))
async def get_structure(self, structure_id: str) -> dict[str, Any] | None:
row = self._rows.get(structure_id)
return dict(row) if row else None
async def create_structure(self, data: dict[str, Any]) -> dict[str, Any]:
row = {
"id": str(data["id"]),
"title": str(data.get("title") or ""),
"name": str(data.get("name") or ""),
"content": str(data.get("content") or ""),
"enabled": True,
"created_by": data.get("created_by"),
"created_at": _now_iso(),
"updated_at": _now_iso(),
}
self._rows[row["id"]] = row
return dict(row)
async def update_structure(self, structure_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
row = self._rows.get(structure_id)
if row is None:
return None
for key in ("title", "name", "content"):
if key in data:
row[key] = str(data[key])
row["updated_at"] = _now_iso()
return dict(row)
async def set_enabled(self, structure_id: str, enabled: bool) -> dict[str, Any] | None:
row = self._rows.get(structure_id)
if row is None:
return None
row["enabled"] = bool(enabled)
row["updated_at"] = _now_iso()
return dict(row)
async def delete_structure(self, structure_id: str) -> bool:
return self._rows.pop(structure_id, None) is not None
async def count(self) -> int:
return len(self._rows)
@@ -0,0 +1,45 @@
"""ORM model for deep-research report structures (报告结构 / 工作流配置).
Global / shared — every account sees the same structure cards in the deep
research workbench's 工作流配置 dialog. Each row is one reusable report
structure the user can pick to fill ``custom_outline``:
- ``title`` — 标题, the card's primary label and the list's 标题筛选 target.
- ``name`` — 名称, a secondary display label.
- ``content`` — 接口内容, the Markdown outline text applied to the research
config's ``custom_outline`` when the card is selected.
- ``enabled`` — 启用/停用; creation always defaults to True (the caller cannot
choose it) and a dedicated toggle flips it later.
- ``created_by`` — 创建用户名 (audit-only display field).
Fresh deployments get the two built-in outlines (通用分析结构 / 递进式论证结构)
seeded by ``ensure_defaults`` — see ``defaults.py``.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class ReportStructureRow(Base):
__tablename__ = "report_structures"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
title: Mapped[str] = mapped_column(String(256), nullable=False)
name: Mapped[str] = mapped_column(String(256), default="", nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
# ``server_default`` 必须显式声明:否则在已存在该表但缺列的旧库上,启动期
# ``_ensure_orm_columns_sync`` 会跳过「NOT NULL 且无 server_default」的列(见
# engine.py),该列永远补不上 → 查询报 Unknown column → 500。
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="1")
created_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
)
@@ -0,0 +1,108 @@
"""SQLAlchemy-backed report-structure store (global / shared)."""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import delete, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.report_structures.base import ReportStructureStore
from deerflow.persistence.report_structures.model import ReportStructureRow
logger = logging.getLogger(__name__)
def _to_dict(row: ReportStructureRow) -> dict[str, Any]:
data = row.to_dict()
for key in ("created_at", "updated_at"):
value = data.get(key)
if isinstance(value, datetime):
data[key] = value.isoformat()
return data
class ReportStructureRepository(ReportStructureStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_structures(self) -> list[dict[str, Any]]:
stmt = select(ReportStructureRow).order_by(
ReportStructureRow.updated_at.desc(), ReportStructureRow.created_at.asc()
)
async with self._sf() as session:
result = await session.execute(stmt)
return [_to_dict(row) for row in result.scalars()]
async def get_structure(self, structure_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ReportStructureRow, structure_id)
return _to_dict(row) if row is not None else None
async def create_structure(self, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
row = ReportStructureRow(
id=str(data["id"]),
title=str(data.get("title") or ""),
name=str(data.get("name") or ""),
content=str(data.get("content") or ""),
# 创建永远默认启用(前端不提供该项填写)。
enabled=True,
created_by=data.get("created_by"),
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return _to_dict(row)
async def update_structure(self, structure_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ReportStructureRow, structure_id)
if row is None:
return None
for key in ("title", "name", "content"):
if key in data:
setattr(row, key, str(data[key]))
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _to_dict(row)
async def set_enabled(self, structure_id: str, enabled: bool) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ReportStructureRow, structure_id)
if row is None:
return None
row.enabled = bool(enabled)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _to_dict(row)
async def delete_structure(self, structure_id: str) -> bool:
async with self._sf() as session:
result = await session.execute(delete(ReportStructureRow).where(ReportStructureRow.id == structure_id))
await session.commit()
return bool(result.rowcount)
async def count(self) -> int:
async with self._sf() as session:
return int(await session.scalar(select(func.count()).select_from(ReportStructureRow)) or 0)
async def ensure_defaults(self) -> None:
"""Seed when empty; a concurrent first-read loses on the fixed PK silently."""
if await self.count() > 0:
return
from deerflow.persistence.report_structures.defaults import DEFAULT_REPORT_STRUCTURE_ROWS
for row in DEFAULT_REPORT_STRUCTURE_ROWS:
try:
await self.create_structure(dict(row))
except IntegrityError:
logger.debug("report-structure default '%s' already inserted by a concurrent request", row["id"])
@@ -37,6 +37,23 @@ class RoundtableArtifactSubmissionStore(ABC):
"""
raise NotImplementedError
@abstractmethod
async def register(
self,
task_id: str,
artifact: dict[str, Any],
*,
task_delivery_id: str | None = None,
actor_id: str | None = None,
expected_version: int | None = None,
) -> dict[str, Any]:
"""Persist a generated-but-not-yet-submitted artifact receipt.
This lets the UI retain the external task-process-report id across a
refresh before the user explicitly submits the Markdown artifact.
"""
raise NotImplementedError
@abstractmethod
async def cancel(
self,
@@ -109,6 +109,63 @@ class MemoryRoundtableArtifactSubmissionStore(RoundtableArtifactSubmissionStore)
)
return deepcopy(row)
async def register(
self,
task_id: str,
artifact: dict[str, Any],
*,
task_delivery_id: str | None = None,
actor_id: str | None = None,
expected_version: int | None = None,
) -> dict[str, Any]:
"""Save an external receipt while keeping the artifact unsubmitted."""
from deerflow.persistence.roundtable_artifact_submissions.sql import SubmissionVersionConflict
key = submission_key(task_id, artifact)
row = self._rows.get((task_id, key))
values = _artifact_values(artifact)
if row is None:
now = _now()
row = {
"id": uuid4().hex,
"task_id": task_id,
"idempotency_key": key,
**values,
"task_delivery_id": task_delivery_id,
"submitted": False,
"version": 0,
"submitted_at": None,
"cancelled_at": None,
"created_by": actor_id,
"updated_by": actor_id,
"submitted_by": None,
"cancelled_by": None,
"created_at": now,
"updated_at": now,
}
self._rows[(task_id, key)] = row
return deepcopy(row)
if expected_version is not None and row.get("version", 0) != expected_version:
raise SubmissionVersionConflict(deepcopy(row))
changed = (
row.get("task_delivery_id") != task_delivery_id
or any(row.get(name) != value for name, value in values.items())
)
if changed:
now = _now()
row.update(values)
row.update(
{
"task_delivery_id": task_delivery_id,
"updated_by": actor_id,
"updated_at": now,
"version": row.get("version", 0) + 1,
}
)
return deepcopy(row)
async def cancel(
self,
task_id: str,
@@ -88,6 +88,22 @@ def _requires_submission_update(
)
def _requires_registration_update(
row: RoundtableArtifactSubmissionRow,
artifact: dict[str, Any],
task_delivery_id: str | None,
) -> bool:
"""Compare receipt metadata without treating ``submitted=False`` as a change."""
return (
row.task_delivery_id != task_delivery_id
or row.session_id != artifact.get("session_id")
or row.thread_id != artifact.get("thread_id")
or row.node_key != artifact.get("node_key")
or row.artifact_name != artifact.get("name")
or row.mime_type != artifact.get("mime_type")
)
class RoundtableArtifactSubmissionRepository(RoundtableArtifactSubmissionStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@@ -181,6 +197,70 @@ class RoundtableArtifactSubmissionRepository(RoundtableArtifactSubmissionStore):
raise RuntimeError("submit: retry loop exhausted (unexpected)")
async def register(
self,
task_id: str,
artifact: dict[str, Any],
*,
task_delivery_id: str | None = None,
actor_id: str | None = None,
expected_version: int | None = None,
) -> dict[str, Any]:
"""Persist an external process-report receipt before user submission."""
key = submission_key(task_id, artifact)
for _attempt in range(2):
async with self._sf() as session:
result = await session.execute(
select(RoundtableArtifactSubmissionRow)
.where(
RoundtableArtifactSubmissionRow.task_id == task_id,
RoundtableArtifactSubmissionRow.idempotency_key == key,
)
.with_for_update()
)
row = result.scalars().first()
now = datetime.now(UTC)
if row is None:
row = RoundtableArtifactSubmissionRow(
id=uuid4().hex,
task_id=task_id,
idempotency_key=key,
task_delivery_id=task_delivery_id,
submitted=False,
created_by=actor_id,
updated_by=actor_id,
created_at=now,
updated_at=now,
version=0,
)
_apply_artifact(row, artifact)
session.add(row)
try:
await session.commit()
except IntegrityError:
await session.rollback()
continue
await session.refresh(row)
return _to_dict(row)
if expected_version is not None and row.version != expected_version:
raise SubmissionVersionConflict(_to_dict(row))
if not _requires_registration_update(row, artifact, task_delivery_id):
return _to_dict(row)
_apply_artifact(row, artifact)
row.task_delivery_id = task_delivery_id
row.updated_by = actor_id
row.updated_at = now
row.version = row.version + 1
await session.commit()
await session.refresh(row)
return _to_dict(row)
raise RuntimeError("register: retry loop exhausted (unexpected)")
async def cancel(
self,
task_id: str,
@@ -63,6 +63,12 @@ class RoundtableChainRow(Base):
is_public: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="0", index=True
)
# 启停开关:True = 启用(可在选链弹窗 / 公共入口里选用);False = 停用(管理列表仍可见,
# 但不可选入研讨)。默认启用,存量链条补列后也为 True。``server_default`` 必须显式声明,
# 否则既有表上启动期 ``_ensure_orm_columns_sync`` 会跳过「NOT NULL 且无 server_default」的列。
enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="1"
)
created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
)
@@ -62,6 +62,8 @@ class RoundtableChainRepository:
"gather_first": bool(getattr(row, "gather_first", False)),
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
"is_public": bool(getattr(row, "is_public", False)),
# 启停状态:缺省 True(存量行 / 补列前视为启用)。
"enabled": bool(getattr(row, "enabled", True)),
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
# 配置者(owner)的 user_id;路由层据此解析成可读用户名(owner_name)。
"owner_id": row.user_id,
@@ -122,6 +124,7 @@ class RoundtableChainRepository:
seat_mode=(data.get("seat_mode") or None),
gather_first=bool(data.get("gather_first") or False),
is_public=bool(data.get("is_public") or False),
enabled=True if data.get("enabled") is None else bool(data.get("enabled")),
created_at=now,
updated_at=now,
)
@@ -145,6 +148,7 @@ class RoundtableChainRepository:
seat_mode=_UNSET,
gather_first=_UNSET,
is_public=_UNSET,
enabled=_UNSET,
is_admin: bool = False,
) -> dict[str, Any] | None:
async with self._sf() as session:
@@ -170,6 +174,8 @@ class RoundtableChainRepository:
row.gather_first = bool(gather_first)
if is_public is not _UNSET and is_public is not None:
row.is_public = bool(is_public)
if enabled is not _UNSET and enabled is not None:
row.enabled = bool(enabled)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
@@ -94,6 +94,11 @@ CALL _add_column_if_missing('roundtable_chains', 'seat_mode', 'VARCHAR(16) NULL
-- ORM: RoundtableChainRow.gather_first = Boolean, NOT NULL, server_default '0'。
CALL _add_column_if_missing('roundtable_chains', 'gather_first', 'TINYINT(1) NOT NULL DEFAULT 0');
-- enabled: 业务链条启停开关,对应 migration 20260819_02。True=启用(可选入研讨);
-- False=停用(管理列表仍可见)。默认启用,存量行补列后也为 True。
-- ORM: RoundtableChainRow.enabled = Boolean, NOT NULL, server_default '1'。
CALL _add_column_if_missing('roundtable_chains', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1');
-- ---- 岗位 (positions) 订阅系统,对应 migration 20260616_01 -------------------
-- positions / position_tag_bindings 是【新表】,由 create_all 自动建,无需补列。
-- 以下是给【已存在的表】补的列:
@@ -142,6 +147,12 @@ CALL _add_column_if_missing('users', 'approved', 'TINYINT(1) NOT NULL DEFAULT 1'
-- ORM: UserRow.login_password = String(255), nullable。
CALL _add_column_if_missing('users', 'login_password', 'VARCHAR(255) NULL DEFAULT NULL');
-- ---- compact_menu_overrides.menu_type: 简介模式菜单分套(A/B),对应 migration 20260819_01 ----
-- 两套网页部署各自一份左侧简介菜单。存量行视为类型 A;类型 B 的 node_id 带 B:: 前缀,
-- 以免在仍以 node_id 为单列主键的旧库上撞主键。
-- ORM: CompactMenuOverrideRow.menu_type = String(32), NOT NULL, default A, server_default A。
CALL _add_column_if_missing('compact_menu_overrides', 'menu_type', "VARCHAR(32) NOT NULL DEFAULT 'A'");
-- ---- 清理临时存储过程 --------------------------------------------------------
DROP PROCEDURE IF EXISTS _add_column_if_missing;
@@ -205,5 +216,7 @@ SELECT table_name, column_name
OR (table_name = 'roundtable_drafts' AND column_name = 'task_id')
OR (table_name = 'roundtable_jobs' AND column_name = 'task_id')
OR (table_name = 'position_roundtable_sessions' AND column_name = 'task_scoped')
OR (table_name = 'task_buttons' AND column_name IN ('append_auth', 'auth_token_param', 'auth_name_param')))
OR (table_name = 'task_buttons' AND column_name IN ('append_auth', 'auth_token_param', 'auth_name_param'))
OR (table_name = 'compact_menu_overrides' AND column_name = 'menu_type')
OR (table_name = 'roundtable_chains' AND column_name = 'enabled'))
ORDER BY table_name, column_name;
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more