Merge branch 'master-nj' into 'master'
feat: 支持会商 Markdown 产物提交到任务过程,并补充 Angular 对接文档 See merge request knowledge-base-bj/deerflow-code!26
This commit is contained in:
commit
c69fde22bd
102 files changed
+11338
-6113
No files matched your search
+50
-49
@@ -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
|
||||
@@ -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/`。
|
||||
|
||||
@@ -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/`。
|
||||
|
||||
@@ -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` |
|
||||
@@ -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")}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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)",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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="报告结构不存在")
|
||||
+22
@@ -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
|
||||
|
||||
+19
-16
@@ -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:
|
||||
|
||||
+8
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
+10
-4
@@ -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
|
||||
+47
@@ -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
|
||||
+35
-8
@@ -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)
|
||||
+3
-1
@@ -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))
|
||||
|
||||
+38
-8
@@ -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
|
||||
+52
@@ -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")
|
||||
+51
@@ -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")
|
||||
+22
@@ -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)
|
||||
+64
@@ -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))
|
||||
+269
@@ -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": "系统内置",
|
||||
},
|
||||
]
|
||||
+66
@@ -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)
|
||||
+45
@@ -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)
|
||||
)
|
||||
+108
@@ -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"])
|
||||
+17
@@ -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,
|
||||
|
||||
+57
@@ -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,
|
||||
|
||||
+80
@@ -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,
|
||||
|
||||
+6
@@ -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)
|
||||
)
|
||||
|
||||
+6
@@ -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
Reference in new issue
Block a user