merge: integrate master-nj RAG updates
This commit is contained in:
commit
bc49ef6027
165 files changed
+18909
-6425
No files matched your search
+50
-43
@@ -1,10 +1,10 @@
|
||||
# Workspace/editor
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
frontend-web/.vscode/
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
frontend-web/.vscode/
|
||||
node_modules/
|
||||
# Claude Code local-only settings (per-user permissions, not shared)
|
||||
.claude/settings.local.json
|
||||
@@ -56,9 +56,9 @@ offline-backend-20260505/bundle/start_backend.sh
|
||||
offline-backend-20260505/bundle/stop_backend.sh
|
||||
|
||||
# offline-backend-20260512 runtime artifacts (large files, local config, data)
|
||||
offline-backend-20260512/.env.example
|
||||
offline-backend-20260512/.env
|
||||
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
|
||||
offline-backend-20260512/.env.example
|
||||
offline-backend-20260512/.env
|
||||
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
|
||||
offline-backend-20260512/SHA256SUMS
|
||||
offline-backend-20260512/config.yaml.example
|
||||
offline-backend-20260512/data/
|
||||
@@ -66,14 +66,14 @@ offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
|
||||
offline-backend-20260512/extensions_config.json
|
||||
offline-backend-20260512/skills/
|
||||
offline-backend-20260512/wheelhouse/
|
||||
offline-backend-20260512/backend/.deer-flow/data/*.db
|
||||
offline-backend-20260512/backend/.deer-flow/data/*.db-*
|
||||
offline-backend-20260512/backend/.deer-flow/*
|
||||
offline-backend-20260512/backend/.env
|
||||
offline-backend-20260512/backend/.env.local
|
||||
offline-backend-20260512/start_backend.sh
|
||||
offline-backend-20260512/stop_backend.sh
|
||||
offline-backend-20260512/check_backend.sh
|
||||
offline-backend-20260512/backend/.deer-flow/data/*.db
|
||||
offline-backend-20260512/backend/.deer-flow/data/*.db-*
|
||||
offline-backend-20260512/backend/.deer-flow/*
|
||||
offline-backend-20260512/backend/.env
|
||||
offline-backend-20260512/backend/.env.local
|
||||
offline-backend-20260512/start_backend.sh
|
||||
offline-backend-20260512/stop_backend.sh
|
||||
offline-backend-20260512/check_backend.sh
|
||||
|
||||
hermes-agent-main/
|
||||
|
||||
@@ -84,16 +84,23 @@ offline-backend-20260512/backend/.deer-flow/*
|
||||
|
||||
# Per-user skill_evolution overrides — runtime data written by the API
|
||||
offline-backend-20260512/backend/skill_evolution.json
|
||||
skills/
|
||||
hermes-agent-main/
|
||||
skills/
|
||||
hermes-agent-main/
|
||||
.gitignore
|
||||
offline-backend-20260512/backend/tests/*
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
||||
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
||||
# Keep the model configuration regression coverage versioned even though
|
||||
# this deployment repository otherwise ignores newly added backend tests.
|
||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
||||
deploy
|
||||
offline-backend-20260512/backend/tests/*
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
||||
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
||||
# Keep the model configuration regression coverage versioned even though
|
||||
# this deployment repository otherwise ignores newly added backend tests.
|
||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
||||
# Deep-research reliability regression coverage.
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_start_job.py
|
||||
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
|
||||
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
|
||||
deploy
|
||||
.history
|
||||
|
||||
# Cloned third-party repos (local only)
|
||||
@@ -102,22 +109,22 @@ git-clone/
|
||||
# Repo-root offline dependency pack (local only)
|
||||
/dist/
|
||||
|
||||
# Frontend offline pack working directories
|
||||
frontend-web/.offline-pack-build/
|
||||
frontend-web/.offline-extract-test/
|
||||
frontend-web-node_modules-offline-*.tar.gz
|
||||
|
||||
# Local dependency caches and backend scratch archives
|
||||
/.pnpm-store/
|
||||
/offline-backend-20260512/backend/backend*.zip
|
||||
|
||||
# Local offline bundles and scratch tooling
|
||||
/*.tar.gz
|
||||
/offline-backend-20260512/*.zip
|
||||
/tmp/
|
||||
|
||||
.codegraph
|
||||
codegraph.mdc
|
||||
# Frontend offline pack working directories
|
||||
frontend-web/.offline-pack-build/
|
||||
frontend-web/.offline-extract-test/
|
||||
frontend-web-node_modules-offline-*.tar.gz
|
||||
|
||||
# Local dependency caches and backend scratch archives
|
||||
/.pnpm-store/
|
||||
/offline-backend-20260512/backend/backend*.zip
|
||||
|
||||
# Local offline bundles and scratch tooling
|
||||
/*.tar.gz
|
||||
/offline-backend-20260512/*.zip
|
||||
/tmp/
|
||||
|
||||
.codegraph
|
||||
codegraph.mdc
|
||||
|
||||
docs/aidev-docs
|
||||
docs/backups
|
||||
@@ -134,4 +141,4 @@ offline-backend-20260512/backend/uv.lock
|
||||
__pycache__/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
@@ -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=
|
||||
File diff suppressed because it is too large.
Load diff
@@ -32,10 +32,10 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
VITE_BRAND_WELCOME_DARK_URL: "",
|
||||
|
||||
// DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。
|
||||
// VITE_BACKEND_BASE_URL: "http://10.19.200.5:2026",
|
||||
// VITE_BACKEND_BASE_URL: "https://ch1.b.uat.4.cn/deerflow",
|
||||
|
||||
// LangGraph 流式对话接口地址:普通问答、智能体对话等流式能力使用。
|
||||
// VITE_LANGGRAPH_BASE_URL: "http://10.19.200.5:2026/api",
|
||||
// VITE_LANGGRAPH_BASE_URL: "https://ch1.b.uat.4.cn/deerflow/api",
|
||||
|
||||
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
|
||||
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
|
||||
@@ -50,6 +50,14 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// CMZS 后端接口:任务管理、任务分析、三情分析、行动分析等业务接口。
|
||||
VITE_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm",
|
||||
|
||||
// 8BF 嵌入页专用任务系统接口。部署到其他系统时只需修改这一项,
|
||||
// 不影响原多智能体会商使用的任务详情接口。
|
||||
VITE_8BF_TASK_API_BASE_URL: "https://ch1.b.uat.4.cn/zl_consumer",
|
||||
|
||||
// 8BF 会商页右上角「返回」按钮的跳转地址(当前窗口 window.location.href)。
|
||||
// 部署后直接改 dist/runtime-config.js 即可,无需重新打包。留空则点击时提示未配置。
|
||||
VITE_8BF_ROUNDTABLE_BACK_URL: "",
|
||||
|
||||
// 佳来模型对话接口。
|
||||
VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt",
|
||||
|
||||
@@ -90,7 +98,7 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
VITE_AIPI_FRONTEND_URL: "http://47.88.25.99:3000",
|
||||
|
||||
// Consumer 接口:深链任务详情 cop-task-detail、研判情报报告 selectSqReport 等使用。
|
||||
VITE_AIPI_CONSUMER_URL: "http://10.19.200.5:8002/consumer",
|
||||
VITE_AIPI_CONSUMER_URL: "https://ch1.b.uat.4.cn/consumer",
|
||||
|
||||
// A 系统跳转地址:人物、组织、事件等列表项打开链接使用。
|
||||
VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain",
|
||||
@@ -130,6 +138,11 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// 隐藏系统 Logo、换肤按钮以及“全量/简洁模式”切换。修改后刷新页面即可生效。
|
||||
VITE_FORCE_COLLAPSED_SIDEBAR: "false",
|
||||
|
||||
// 简介模式左侧菜单类型:与菜单管理页「简介模式」里的类型 A / B 对应。
|
||||
// 两套网页部署时分别填 "A" 或 "B"。不填、留空、或旧配置没有这一项,都按类型 A,
|
||||
// 继续读取原来的简介菜单数据,无需改旧页面。
|
||||
VITE_COMPACT_MENU_TYPE: "A",
|
||||
|
||||
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
|
||||
VITE_GITHUB_OAUTH_TOKEN: "",
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="12" viewBox="0 0 14 12" fill="none" aria-hidden="true">
|
||||
<path d="M13 6H1.5M1.5 6L6.2 1.4M1.5 6L6.2 10.6" stroke="#A2E1F9" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 258 B |
@@ -165,7 +165,7 @@ export const ReasoningTrigger = memo(
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
@@ -178,7 +178,11 @@ export const ReasoningContent = memo(
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
|
||||
{typeof children === "string" ? (
|
||||
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { FileOutput } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import responsibilityIcon from "@/assets/position-roundtable/责任岗位.svg";
|
||||
import resultTimeIcon from "@/assets/position-roundtable/时间.svg";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import "./analysis-results.css";
|
||||
import type { AnalysisResultCardItem } from "./types";
|
||||
|
||||
function formatResultTime(value: string | undefined, fallback: string): string {
|
||||
if (!value) return fallback;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function AnalysisResultCardList({
|
||||
items,
|
||||
emptyTitle = "暂无交付产物",
|
||||
emptyDescription = "第二步研讨与第三步总结生成 Markdown 后,产物会显示在这里。",
|
||||
padded = true,
|
||||
onOpen,
|
||||
}: {
|
||||
items: AnalysisResultCardItem[];
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
padded?: boolean;
|
||||
onOpen: (item: AnalysisResultCardItem) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="position-roundtable-analysis-results-empty flex h-full min-h-[260px] flex-col items-center justify-center p-6 text-center">
|
||||
<FileOutput className="mb-3 size-8 text-muted-foreground/50" />
|
||||
<p className="text-sm font-semibold">{emptyTitle}</p>
|
||||
<p className="mt-1 max-w-[240px] text-xs leading-5 text-muted-foreground">
|
||||
{emptyDescription}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("analysis-result-card-list", padded && "p-[15px]")}>
|
||||
{items.map((item, index) => {
|
||||
const displayedTime = formatResultTime(
|
||||
item.updatedAt,
|
||||
item.fallbackTime || "刚刚",
|
||||
);
|
||||
const displayedProductName = item.delivered ? item.productName : "未交付";
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="position-roundtable-analysis-result-card"
|
||||
onClick={() => {
|
||||
if (!item.delivered) {
|
||||
toast.info(
|
||||
item.unavailableMessage ||
|
||||
`「${desensitize(item.agentName)}」尚未交付可预览的 Markdown 文件。`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
onOpen(item);
|
||||
}}
|
||||
>
|
||||
<div className="position-roundtable-analysis-result-card-body">
|
||||
<div
|
||||
className={cn(
|
||||
"position-roundtable-analysis-result-tag",
|
||||
`position-roundtable-analysis-result-tag--${(index % 6) + 1}`,
|
||||
)}
|
||||
aria-label={desensitize(item.agentName)}
|
||||
>
|
||||
<span>{desensitize(item.agentName)}</span>
|
||||
</div>
|
||||
<p className="position-roundtable-analysis-result-product">
|
||||
{desensitize(displayedProductName)}
|
||||
</p>
|
||||
<footer className="position-roundtable-analysis-result-meta">
|
||||
<span>
|
||||
<img src={responsibilityIcon} alt="" aria-hidden="true" />
|
||||
{desensitize(item.positionName)}
|
||||
</span>
|
||||
<i aria-hidden="true" />
|
||||
<span>
|
||||
<img src={resultTimeIcon} alt="" aria-hidden="true" />
|
||||
{displayedTime}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FileOutput, Loader2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { ArtifactFilePreview } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { loadArtifactContent } from "@/core/artifacts/loader";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { Button as TButton } from "tdesign-react";
|
||||
|
||||
import type { AnalysisResultCardItem } from "./types";
|
||||
|
||||
function isArtifactErrorContent(content: string | null | undefined): boolean {
|
||||
const trimmed = content?.trim();
|
||||
if (!trimmed || !trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
const detail = parsed.detail ?? parsed.message ?? parsed.error;
|
||||
return typeof detail === "string" && detail.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackContent(item: AnalysisResultCardItem | null): string {
|
||||
return (
|
||||
item?.artifactContent?.trim() ||
|
||||
item?.summary?.trim() ||
|
||||
"暂无产物正文"
|
||||
);
|
||||
}
|
||||
|
||||
export function AnalysisResultDetailDialog({
|
||||
item,
|
||||
onOpenChange,
|
||||
extraBody,
|
||||
footerStart,
|
||||
}: {
|
||||
item: AnalysisResultCardItem | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
extraBody?: ReactNode;
|
||||
footerStart?: ReactNode;
|
||||
}) {
|
||||
const artifactThreadId = item?.artifactThreadId || null;
|
||||
const artifactPathValue = item?.artifactPath || "";
|
||||
const artifactContentQuery = useQuery({
|
||||
queryKey: [
|
||||
"analysis-results",
|
||||
"deliverable-artifact-content",
|
||||
artifactThreadId,
|
||||
artifactPathValue,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!artifactThreadId || !artifactPathValue) return "";
|
||||
const result = await loadArtifactContent({
|
||||
filepath: artifactPathValue,
|
||||
threadId: artifactThreadId,
|
||||
isMock: false,
|
||||
});
|
||||
return result.content;
|
||||
},
|
||||
enabled: Boolean(item && artifactThreadId && artifactPathValue),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const loadedArtifactContent =
|
||||
typeof artifactContentQuery.data === "string" ? artifactContentQuery.data : "";
|
||||
const loadedArtifactIsError = isArtifactErrorContent(loadedArtifactContent);
|
||||
const artifactContent =
|
||||
loadedArtifactContent.trim() && !loadedArtifactIsError ? loadedArtifactContent : null;
|
||||
const displayedContent = artifactContent || fallbackContent(item);
|
||||
const artifactLoadFailed =
|
||||
(artifactContentQuery.isError || loadedArtifactIsError) &&
|
||||
!item?.artifactContent?.trim();
|
||||
|
||||
return (
|
||||
<Dialog open={Boolean(item)} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
overlayClassName="!z-[6000]"
|
||||
className="!z-[6001] flex max-h-[86vh] w-[900px] !max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0"
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<FileOutput className="size-4 text-primary" />
|
||||
交付产物详情
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!item ? null : (
|
||||
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
|
||||
{desensitize(item.productName)}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{desensitize(item.agentName)} · {desensitize(item.positionName)}
|
||||
</p>
|
||||
</div>
|
||||
{extraBody}
|
||||
<div className="mt-4 rounded-xl border border-slate-200 bg-white p-3 dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/65">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-bold text-slate-700 dark:text-[#e8edf5]">产物内容</p>
|
||||
</div>
|
||||
<div className="h-[52vh] min-h-[320px] overflow-hidden rounded-lg border border-slate-100 bg-slate-50/60 dark:border-[#2b8ffe]/20 dark:bg-[#021d3d]/70">
|
||||
{artifactContentQuery.isLoading ? (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground dark:text-[#8aa0c4]">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载 Markdown 文件内容…
|
||||
</div>
|
||||
) : (
|
||||
<ArtifactFilePreview
|
||||
content={displayedContent}
|
||||
language="markdown"
|
||||
threadId={artifactThreadId ?? ""}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{artifactLoadFailed && (
|
||||
<p className="mt-2 text-[11px] text-amber-600 dark:text-amber-300">
|
||||
Markdown 文件内容加载失败,当前展示的是已保存正文。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="shrink-0 border-t px-5 py-3 dark:border-[#2b8ffe]/25">
|
||||
{footerStart}
|
||||
<TButton variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</TButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
.analysis-result-card-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-card {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 102px;
|
||||
box-sizing: border-box;
|
||||
padding: 40px 12px 12px;
|
||||
border: 1px solid rgba(85, 166, 254, 0.1);
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 98, 217, 0.1);
|
||||
color: #ffffff;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-card:hover,
|
||||
.position-roundtable-analysis-result-card:focus-visible {
|
||||
border-color: #0062d9;
|
||||
background: rgba(0, 98, 217, 0.3);
|
||||
box-shadow: 0 4px 14px rgba(0, 44, 110, 0.18);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-card-body {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-tag {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: -12px;
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
max-width: calc(100% + 12px);
|
||||
height: 26px;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0 22px 0 12px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px 0 0 0;
|
||||
background: rgba(212, 120, 0, 0.5);
|
||||
color: #ffffff;
|
||||
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: normal;
|
||||
white-space: nowrap;
|
||||
clip-path: polygon(0 0, 100% 0, calc(100% - 14px) 100%, 0 100%);
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-tag--1 { background: rgba(212, 120, 0, 0.5); }
|
||||
.position-roundtable-analysis-result-tag--2 { background: rgba(207, 19, 34, 0.5); }
|
||||
.position-roundtable-analysis-result-tag--3 { background: rgba(29, 171, 123, 0.5); }
|
||||
.position-roundtable-analysis-result-tag--4 { background: rgba(132, 85, 230, 0.5); }
|
||||
.position-roundtable-analysis-result-tag--5 { background: rgba(201, 181, 0, 0.5); }
|
||||
.position-roundtable-analysis-result-tag--6 { background: rgba(69, 195, 243, 0.5); }
|
||||
|
||||
.position-roundtable-analysis-result-tag span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-product {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: #ffffff;
|
||||
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-meta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
color: #80b7ff;
|
||||
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-meta span {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-meta img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-meta i {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
background: rgba(128, 183, 255, 0.45);
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-results-empty {
|
||||
color: #80b7ff;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { AnalysisResultCardList } from "./AnalysisResultCardList";
|
||||
export { AnalysisResultDetailDialog } from "./AnalysisResultDetailDialog";
|
||||
export type { AnalysisResultCardItem } from "./types";
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface AnalysisResultCardItem {
|
||||
id: string;
|
||||
agentName: string;
|
||||
productName: string;
|
||||
positionName: string;
|
||||
updatedAt?: string;
|
||||
/** Shown when `updatedAt` is missing. */
|
||||
fallbackTime?: string;
|
||||
/** Clickable preview is available. */
|
||||
delivered: boolean;
|
||||
artifactPath?: string;
|
||||
artifactMimeType?: string | null;
|
||||
artifactThreadId?: string | null;
|
||||
artifactContent?: string | null;
|
||||
summary?: string;
|
||||
unavailableMessage?: string;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
BriefcaseBusinessIcon,
|
||||
CheckIcon,
|
||||
DatabaseIcon,
|
||||
GlobeIcon,
|
||||
@@ -83,6 +84,7 @@ interface AgentDraft {
|
||||
/** 会商席位「技能识别强化」开关。 */
|
||||
seatSkillDirective: boolean;
|
||||
knowledgeBaseIds: string[];
|
||||
jobResponsibilities: string;
|
||||
}
|
||||
|
||||
function draftFromAgent(agent: Agent): AgentDraft {
|
||||
@@ -97,6 +99,7 @@ function draftFromAgent(agent: Agent): AgentDraft {
|
||||
templateName: agent.template_name ?? "",
|
||||
seatSkillDirective: agent.seat_skill_directive ?? false,
|
||||
knowledgeBaseIds: agent.llmwiki_knowledge_base_ids ?? [],
|
||||
jobResponsibilities: agent.job_responsibilities ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -338,6 +341,8 @@ export function AgentDetailPanel({
|
||||
request.template_name = draft.templateName.trim();
|
||||
if (draft.seatSkillDirective !== (agent.seat_skill_directive ?? false))
|
||||
request.seat_skill_directive = draft.seatSkillDirective;
|
||||
if (draft.jobResponsibilities.trim() !== (agent.job_responsibilities ?? ""))
|
||||
request.job_responsibilities = draft.jobResponsibilities.trim();
|
||||
if (
|
||||
JSON.stringify(draft.knowledgeBaseIds) !==
|
||||
JSON.stringify(agent.llmwiki_knowledge_base_ids ?? [])
|
||||
@@ -720,6 +725,39 @@ export function AgentDetailPanel({
|
||||
</section>
|
||||
|
||||
|
||||
{/* ── 岗位职责 ───────────────────────────────────────────────────── */}
|
||||
<Section
|
||||
icon={<BriefcaseBusinessIcon className="size-3.5" />}
|
||||
title="岗位职责"
|
||||
>
|
||||
{editing && draft ? (
|
||||
<Textarea
|
||||
value={draft.jobResponsibilities}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, jobResponsibilities: e.currentTarget.value })
|
||||
}
|
||||
placeholder="描述该智能体对应岗位的职责…"
|
||||
className="min-h-20 text-sm"
|
||||
/>
|
||||
) : agent.job_responsibilities?.trim() ? (
|
||||
<p className="text-muted-foreground text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{desensitize(agent.job_responsibilities)}
|
||||
</p>
|
||||
) : canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={beginEdit}
|
||||
className="text-muted-foreground hover:text-foreground hover:border-border w-full rounded-lg border border-dashed px-3 py-4 text-center text-xs transition-colors"
|
||||
>
|
||||
<PlusIcon className="mr-1 inline size-3.5 align-[-2px]" />
|
||||
添加岗位职责
|
||||
</button>
|
||||
) : (
|
||||
<p className="text-muted-foreground/60 text-sm">暂无岗位职责</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
|
||||
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
|
||||
{(showCommonQuestions || editing || questions.length > 0 || canEdit) && (
|
||||
<Section
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -52,11 +52,17 @@ interface ArtifactsProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
const [artifacts, setArtifactsState] = useState<string[]>([]);
|
||||
const setArtifacts = useCallback((next: string[]) => {
|
||||
setArtifactsState(Array.isArray(next) ? next : []);
|
||||
}, []);
|
||||
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
const [artifacts, setArtifactsState] = useState<string[]>([]);
|
||||
const setArtifacts = useCallback((next: string[]) => {
|
||||
const normalized = Array.isArray(next) ? next : [];
|
||||
setArtifactsState((previous) =>
|
||||
previous.length === normalized.length &&
|
||||
previous.every((artifact, index) => artifact === normalized[index])
|
||||
? previous
|
||||
: normalized,
|
||||
);
|
||||
}, []);
|
||||
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
|
||||
const [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null);
|
||||
const [autoSelect, setAutoSelect] = useState(true);
|
||||
@@ -65,18 +71,24 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
);
|
||||
const [autoOpen, setAutoOpen] = useState(true);
|
||||
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
|
||||
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
|
||||
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
|
||||
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
|
||||
const autoOpenRef = useRef(autoOpen);
|
||||
const sidebar = useSidebarSafe();
|
||||
|
||||
const select = useCallback(
|
||||
const select = useCallback(
|
||||
(artifact: string, autoSelect = false) => {
|
||||
setSelectedArtifact(artifact);
|
||||
setSelectedArtifactKey(artifactPreviewKey(artifact));
|
||||
const previewKey = artifactPreviewKey(artifact);
|
||||
selectedArtifactKeyRef.current = previewKey;
|
||||
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
|
||||
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
|
||||
sidebar?.setOpen(false);
|
||||
}
|
||||
if (!autoSelect) {
|
||||
setAutoSelect(false);
|
||||
}
|
||||
if (!autoSelect) {
|
||||
setAutoSelect((previous) => (previous ? false : previous));
|
||||
}
|
||||
},
|
||||
[sidebar],
|
||||
);
|
||||
@@ -86,11 +98,13 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
if (suppressedAutoOpenKeyRef.current === previewKey) {
|
||||
return false;
|
||||
}
|
||||
setSelectedArtifact(artifact);
|
||||
setSelectedArtifactKey(previewKey);
|
||||
setAutoSelect(true);
|
||||
setAutoOpen(true);
|
||||
setOpenState(true);
|
||||
selectedArtifactKeyRef.current = previewKey;
|
||||
autoOpenRef.current = true;
|
||||
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
|
||||
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
|
||||
setAutoSelect((previous) => (previous ? previous : true));
|
||||
setAutoOpen((previous) => (previous ? previous : true));
|
||||
setOpenState((previous) => (previous ? previous : true));
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
|
||||
sidebar?.setOpen(false);
|
||||
}
|
||||
@@ -99,13 +113,26 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
[sidebar],
|
||||
);
|
||||
|
||||
const deselect = useCallback(() => {
|
||||
setSelectedArtifact(null);
|
||||
setSelectedArtifactKey(null);
|
||||
setAutoSelect(true);
|
||||
setOpenState(false);
|
||||
const deselect = useCallback(() => {
|
||||
selectedArtifactKeyRef.current = null;
|
||||
setSelectedArtifact((previous) => (previous === null ? previous : null));
|
||||
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
|
||||
setAutoSelect((previous) => (previous ? previous : true));
|
||||
setOpenState((previous) => (previous ? false : previous));
|
||||
suppressedAutoOpenKeyRef.current = null;
|
||||
}, []);
|
||||
|
||||
const setOpen = useCallback((isOpen: boolean) => {
|
||||
if (!isOpen && autoOpenRef.current) {
|
||||
autoOpenRef.current = false;
|
||||
setAutoOpen((previous) => (previous ? false : previous));
|
||||
setAutoSelect((previous) => (previous ? false : previous));
|
||||
}
|
||||
if (!isOpen) {
|
||||
suppressedAutoOpenKeyRef.current = selectedArtifactKeyRef.current;
|
||||
}
|
||||
setOpenState((previous) => (previous === isOpen ? previous : isOpen));
|
||||
}, []);
|
||||
|
||||
const value: ArtifactsContextType = {
|
||||
artifacts,
|
||||
@@ -114,16 +141,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
open,
|
||||
autoOpen,
|
||||
autoSelect,
|
||||
setOpen: (isOpen: boolean) => {
|
||||
if (!isOpen && autoOpen) {
|
||||
setAutoOpen(false);
|
||||
setAutoSelect(false);
|
||||
}
|
||||
if (!isOpen) {
|
||||
suppressedAutoOpenKeyRef.current = selectedArtifactKey;
|
||||
}
|
||||
setOpenState(isOpen);
|
||||
},
|
||||
setOpen,
|
||||
|
||||
selectedArtifact,
|
||||
selectedArtifactKey,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SparklesIcon } from "lucide-react";
|
||||
import { ImagePlusIcon, SparklesIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
|
||||
|
||||
@@ -51,16 +52,22 @@ export function DocumentRewriteDialog({
|
||||
onOpenChange,
|
||||
fileName,
|
||||
charCount,
|
||||
imageGenerationAvailable = false,
|
||||
maxGeneratedImages = 4,
|
||||
onStart,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
fileName: string;
|
||||
charCount: number;
|
||||
imageGenerationAvailable?: boolean;
|
||||
maxGeneratedImages?: number;
|
||||
onStart: (input: {
|
||||
instruction: string;
|
||||
style: DocumentRewriteStyle;
|
||||
modelName: string | null;
|
||||
generateImages: boolean;
|
||||
maxGeneratedImages: number;
|
||||
}) => void;
|
||||
}) {
|
||||
const [instruction, setInstruction] = useState(
|
||||
@@ -68,11 +75,15 @@ export function DocumentRewriteDialog({
|
||||
);
|
||||
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
|
||||
const [modelName, setModelName] = useState<string | null>(null);
|
||||
const [generateImages, setGenerateImages] = useState(false);
|
||||
const [imageCount, setImageCount] = useState("2");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setStyle("formal_analysis");
|
||||
setModelName(null);
|
||||
setGenerateImages(false);
|
||||
setImageCount("2");
|
||||
}, [open]);
|
||||
|
||||
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
|
||||
@@ -136,6 +147,48 @@ export function DocumentRewriteDialog({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{imageGenerationAvailable && (
|
||||
<div className="rounded-lg border bg-muted/20 p-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<label className="flex min-w-0 cursor-pointer items-start gap-3" htmlFor="document-rewrite-images">
|
||||
<span className="bg-primary/10 text-primary mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
|
||||
<ImagePlusIcon className="size-4" />
|
||||
</span>
|
||||
<span className="grid gap-0.5">
|
||||
<span className="text-sm font-medium">改写后补充配图</span>
|
||||
<span className="text-muted-foreground text-xs leading-5">
|
||||
根据章节生成并保存为当前沙箱的本地图片,不使用外部图片链接。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<Switch
|
||||
aria-label="改写后补充配图"
|
||||
checked={generateImages}
|
||||
id="document-rewrite-images"
|
||||
onCheckedChange={setGenerateImages}
|
||||
/>
|
||||
</div>
|
||||
{generateImages && (
|
||||
<div className="mt-3 flex items-center justify-between gap-3 border-t pt-3">
|
||||
<span className="text-muted-foreground text-xs">每次会额外消耗图片模型额度</span>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
配图数量
|
||||
<Select value={imageCount} onValueChange={setImageCount}>
|
||||
<SelectTrigger className="h-8 w-20 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: Math.min(Math.max(maxGeneratedImages, 1), 4) }, (_, index) => index + 1).map((count) => (
|
||||
<SelectItem key={count} value={String(count)}>{count} 张</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
@@ -144,7 +197,16 @@ export function DocumentRewriteDialog({
|
||||
disabled={!canStart}
|
||||
onClick={() => {
|
||||
if (!canStart) return;
|
||||
onStart({ instruction: instruction.trim(), style, modelName });
|
||||
onStart({
|
||||
instruction: instruction.trim(),
|
||||
style,
|
||||
modelName,
|
||||
generateImages,
|
||||
maxGeneratedImages: Math.min(
|
||||
Math.max(Number(imageCount) || 1, 1),
|
||||
Math.max(Math.min(maxGeneratedImages, 4), 1),
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SparklesIcon />
|
||||
|
||||
@@ -793,8 +793,9 @@ function buildTurnReferenceBatches(
|
||||
nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id,
|
||||
includeBeforeMessage: false,
|
||||
}).filter((source) => source.mode !== "none");
|
||||
// Single-round RAG sends sources before the complete answer. Expose those
|
||||
// sources immediately while preserving the normal streaming-turn behavior.
|
||||
// GeneralQA's single-RAG stream sends `docs` before the answer. Make
|
||||
// those cards available immediately, while keeping normal streaming turns
|
||||
// on their existing "wait for an answer" behavior.
|
||||
const isFrontendRagTurn =
|
||||
isSingleRoundRagMessage(assistant) || isMultiRoundRagMessage(assistant);
|
||||
if (nextHumanIndex === -1 && isLoading && !isFrontendRagTurn) {
|
||||
|
||||
@@ -2,15 +2,22 @@
|
||||
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
FilePenLineIcon,
|
||||
LightbulbIcon,
|
||||
Loader2Icon,
|
||||
Undo2Icon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import {
|
||||
DOCUMENT_REWRITE_UNDONE_EVENT,
|
||||
@@ -26,6 +33,7 @@ const STEPS = [
|
||||
{ key: "snapshot", label: "固定原文版本" },
|
||||
{ key: "requirements", label: "理解改写要求" },
|
||||
{ key: "writing", label: "流式重写 Markdown" },
|
||||
{ key: "illustrations", label: "生成文档配图" },
|
||||
{ key: "validation", label: "校验文档" },
|
||||
{ key: "comparison", label: "对比原文并生成说明" },
|
||||
] as const;
|
||||
@@ -38,15 +46,17 @@ function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
|
||||
return 1;
|
||||
case "writing":
|
||||
return 2;
|
||||
case "validation":
|
||||
case "illustrating":
|
||||
return 3;
|
||||
case "validation":
|
||||
return 4;
|
||||
case "committing":
|
||||
case "completed":
|
||||
return 5;
|
||||
return 6;
|
||||
case "conflict":
|
||||
case "error":
|
||||
case "cancelled":
|
||||
return 4;
|
||||
return 5;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
@@ -56,6 +66,42 @@ function isTerminal(stage: DocumentRewritePresentation["stage"]) {
|
||||
return ["completed", "conflict", "error", "cancelled"].includes(stage);
|
||||
}
|
||||
|
||||
function RewriteThinkingTrace({ text, active }: { text: string; active: boolean }) {
|
||||
const [open, setOpen] = useState(active);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && text) setOpen(true);
|
||||
}, [active, text]);
|
||||
|
||||
if (!text.trim()) return null;
|
||||
return (
|
||||
<Collapsible className="mt-2 max-w-3xl" onOpenChange={setOpen} open={open}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
aria-label={open ? "收起模型思考" : "展开模型思考"}
|
||||
className="h-7 gap-1.5 px-2 text-xs"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
|
||||
<span>模型思考</span>
|
||||
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
|
||||
<ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
|
||||
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
|
||||
</p>
|
||||
<pre className="bg-muted/45 text-muted-foreground mt-1 max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words">
|
||||
{text}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentRewriteTimeline({
|
||||
item,
|
||||
}: {
|
||||
@@ -64,6 +110,9 @@ export function DocumentRewriteTimeline({
|
||||
const current = stepPosition(item.stage);
|
||||
const failed = ["conflict", "error", "cancelled"].includes(item.stage);
|
||||
const completed = item.stage === "completed";
|
||||
const thinkingStep = ["snapshot", "requirements"].includes(item.stage)
|
||||
? "requirements"
|
||||
: "writing";
|
||||
const presentation = useDocumentRewritePresentation();
|
||||
const [undoing, setUndoing] = useState(false);
|
||||
const [diffOpen, setDiffOpen] = useState(false);
|
||||
@@ -148,7 +197,13 @@ export function DocumentRewriteTimeline({
|
||||
<div className="space-y-3 px-5 py-4">
|
||||
{STEPS.map((step, index) => {
|
||||
const active = !isTerminal(item.stage) && index === current;
|
||||
const done = completed || index < current;
|
||||
const illustrationSkipped = step.key === "illustrations" && !item.generateImages;
|
||||
const illustrationFailed =
|
||||
step.key === "illustrations" &&
|
||||
item.generateImages &&
|
||||
completed &&
|
||||
item.generatedImageCount === 0;
|
||||
const done = !illustrationSkipped && !illustrationFailed && (completed || index < current);
|
||||
return (
|
||||
<div className="flex items-start gap-3" key={step.key}>
|
||||
<div
|
||||
@@ -200,6 +255,30 @@ export function DocumentRewriteTimeline({
|
||||
正在写入右侧沙箱,已生成 {item.writtenCharCount.toLocaleString()} 字。
|
||||
</p>
|
||||
)}
|
||||
{step.key === "illustrations" && (
|
||||
illustrationSkipped ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
本次未启用配图,保留原文已有图片与链接。
|
||||
</p>
|
||||
) : active ? (
|
||||
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
|
||||
{item.illustrationTarget
|
||||
? `正在为「${item.illustrationTarget}」生成配图,已完成 ${item.generatedImageCount}/${item.maxGeneratedImages} 张。`
|
||||
: `正在准备 ${item.maxGeneratedImages} 张文档配图…`}
|
||||
</p>
|
||||
) : item.generatedImageCount > 0 ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
已生成并保存 {item.generatedImageCount} 张本地配图。
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
未成功生成配图,文字改写结果仍已保留。
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{step.key === thinkingStep && (
|
||||
<RewriteThinkingTrace active={active} text={item.thinkingText} />
|
||||
)}
|
||||
{step.key === "validation" && item.warnings.length > 0 && (
|
||||
<p className="mt-0.5 text-xs text-amber-700 dark:text-amber-300">
|
||||
{item.warnings.join(";")}
|
||||
@@ -228,6 +307,11 @@ export function DocumentRewriteTimeline({
|
||||
本次优化:{item.summary.requestedOptimizations.join("、")}。
|
||||
</p>
|
||||
) : null}
|
||||
{item.summary.requestedImageCount ? (
|
||||
<p className="text-muted-foreground">
|
||||
文档配图:请求 {item.summary.requestedImageCount} 张,已保存 {item.summary.generatedImageCount ?? 0} 张本地图片。
|
||||
</p>
|
||||
) : null}
|
||||
{item.summary.headingChanges && (
|
||||
item.summary.headingChanges.reordered ||
|
||||
item.summary.headingChanges.added.length > 0 ||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import {
|
||||
BookOpenTextIcon,
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronUp,
|
||||
BookOpenTextIcon,
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronUp,
|
||||
FolderOpenIcon,
|
||||
GlobeIcon,
|
||||
LightbulbIcon,
|
||||
ListTodoIcon,
|
||||
Loader2Icon,
|
||||
LightbulbIcon,
|
||||
ListTodoIcon,
|
||||
Loader2Icon,
|
||||
MessageCircleQuestionMarkIcon,
|
||||
NotebookPenIcon,
|
||||
SearchIcon,
|
||||
SquareTerminalIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
ChainOfThought,
|
||||
@@ -31,20 +31,20 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
buildWriteFileVirtualUrl,
|
||||
hasCompleteArtifactExtension,
|
||||
normalizeStreamingArtifactPath,
|
||||
provisionalWriteFilePath,
|
||||
} from "@/core/artifacts/streaming-write-preview";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
buildWriteFileVirtualUrl,
|
||||
hasCompleteArtifactExtension,
|
||||
normalizeStreamingArtifactPath,
|
||||
provisionalWriteFilePath,
|
||||
} from "@/core/artifacts/streaming-write-preview";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
normalizeSearchResults,
|
||||
resolveSearchQuery,
|
||||
@@ -58,13 +58,13 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
findDisplayConfigForTool,
|
||||
isDisplaySamplePayload,
|
||||
normalizeDefaultDisplaySources,
|
||||
shouldUseDefaultCitationMode,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
buildSkillDisplayMap,
|
||||
findDisplayConfigForTool,
|
||||
isDisplaySamplePayload,
|
||||
normalizeDefaultDisplaySources,
|
||||
shouldUseDefaultCitationMode,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
||||
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
|
||||
import { env } from "@/env";
|
||||
@@ -238,7 +238,7 @@ export function MessageGroup({
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCall({
|
||||
function ToolCall({
|
||||
id,
|
||||
messageId,
|
||||
name,
|
||||
@@ -260,45 +260,45 @@ function ToolCall({
|
||||
ragModeEnabled?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const displayMatch =
|
||||
result !== undefined && isDisplaySamplePayload(result)
|
||||
? null
|
||||
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
|
||||
null);
|
||||
const displayMatch =
|
||||
result !== undefined && isDisplaySamplePayload(result)
|
||||
? null
|
||||
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
|
||||
null);
|
||||
const displayItems =
|
||||
displayMatch && result !== undefined
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: item.displayMode ?? displayMatch.display.mode,
|
||||
}))
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: item.displayMode ?? displayMatch.display.mode,
|
||||
}))
|
||||
: [];
|
||||
if (displayItems.length > 0) {
|
||||
if (displayItems.length > 0) {
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
label={
|
||||
displayMatch?.display.mode === "citation" || !displayMatch
|
||||
? "检索参考来源"
|
||||
: `获取 ${displayItems.length} 条结果`
|
||||
}
|
||||
label={
|
||||
displayMatch?.display.mode === "citation" || !displayMatch
|
||||
? "检索参考来源"
|
||||
: `获取 ${displayItems.length} 条结果`
|
||||
}
|
||||
icon={SearchIcon}
|
||||
>
|
||||
<DisplayResultsList results={displayItems} />
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
// Deep Research uses a virtual tool call for each durable writing phase.
|
||||
// Rendering it here keeps its lifecycle in the very same ChainOfThought
|
||||
// component as ordinary Q&A retrieval and sandbox tool calls.
|
||||
if (name === "deep_research_progress") {
|
||||
return <DeepResearchProgressStep id={id} args={args} />;
|
||||
}
|
||||
|
||||
const searchItems = normalizeSearchResults(result);
|
||||
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
||||
}
|
||||
|
||||
// Deep Research uses a virtual tool call for each durable writing phase.
|
||||
// Rendering it here keeps its lifecycle in the very same ChainOfThought
|
||||
// component as ordinary Q&A retrieval and sandbox tool calls.
|
||||
if (name === "deep_research_progress") {
|
||||
return <DeepResearchProgressStep id={id} args={args} />;
|
||||
}
|
||||
|
||||
const searchItems = normalizeSearchResults(result);
|
||||
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
||||
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
@@ -311,19 +311,19 @@ function ToolCall({
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
if (name === "web_search" || searchItems.length > 0) {
|
||||
const query = resolveSearchQuery(args, result);
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||
if (query) {
|
||||
label =
|
||||
name === "web_search"
|
||||
? `知识库检索 "${query}"`
|
||||
: t.toolCalls.searchFor(query);
|
||||
} else if (name === "web_search") {
|
||||
label = "知识库检索";
|
||||
}
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
|
||||
if (name === "web_search" || searchItems.length > 0) {
|
||||
const query = resolveSearchQuery(args, result);
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||
if (query) {
|
||||
label =
|
||||
name === "web_search"
|
||||
? `知识库检索 "${query}"`
|
||||
: t.toolCalls.searchFor(query);
|
||||
} else if (name === "web_search") {
|
||||
label = "知识库检索";
|
||||
}
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
|
||||
{searchItems.length > 0 && (
|
||||
<WebSearchResultsList results={searchItems} />
|
||||
)}
|
||||
@@ -440,18 +440,18 @@ function ToolCall({
|
||||
if (!description) {
|
||||
description = t.toolCalls.writeFile;
|
||||
}
|
||||
return (
|
||||
<ArtifactWriteToolCall
|
||||
id={id}
|
||||
messageId={messageId}
|
||||
name={name}
|
||||
args={args}
|
||||
result={result}
|
||||
description={description}
|
||||
isLast={isLast}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<ArtifactWriteToolCall
|
||||
id={id}
|
||||
messageId={messageId}
|
||||
name={name}
|
||||
args={args}
|
||||
result={result}
|
||||
description={description}
|
||||
isLast={isLast}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
} else if (name === "bash") {
|
||||
const description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
@@ -512,129 +512,164 @@ function ToolCall({
|
||||
icon={WrenchIcon}
|
||||
></ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function DeepResearchProgressStep({
|
||||
id,
|
||||
args,
|
||||
}: {
|
||||
id?: string;
|
||||
args: Record<string, unknown>;
|
||||
}) {
|
||||
const label = typeof args.label === "string" && args.label.trim()
|
||||
? args.label
|
||||
: "正在撰写研究报告";
|
||||
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
|
||||
const active = args.status !== "complete";
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
status={active ? "active" : "complete"}
|
||||
icon={active
|
||||
? <Loader2Icon className="text-primary size-4 animate-spin" />
|
||||
: <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />}
|
||||
label={
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||
<span>{label}</span>
|
||||
<span className={active ? "text-primary text-xs" : "text-muted-foreground text-xs"}>
|
||||
{active ? "进行中" : "已完成"}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<div className="text-muted-foreground text-xs leading-5">
|
||||
{detail}
|
||||
</div>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactWriteToolCall({
|
||||
id,
|
||||
messageId,
|
||||
name,
|
||||
args,
|
||||
result,
|
||||
description,
|
||||
isLast,
|
||||
isLoading,
|
||||
}: {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
name: "write_file" | "str_replace";
|
||||
args: Record<string, unknown>;
|
||||
result?: string | Record<string, unknown>;
|
||||
description: string;
|
||||
isLast: boolean;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const artifacts = useArtifactsOptional();
|
||||
const openStreamedArtifact = artifacts?.openStreamedArtifact;
|
||||
const rawPath = typeof args.path === "string" ? args.path : "";
|
||||
const content = typeof args.content === "string" ? args.content : "";
|
||||
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
|
||||
const hasUsablePath =
|
||||
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
|
||||
const sourcePath = hasUsablePath
|
||||
? normalizedPath
|
||||
: name === "write_file" && content.trim()
|
||||
? provisionalWriteFilePath(content)
|
||||
: null;
|
||||
const previewKey = messageId && id ? `${messageId}:${id}` : null;
|
||||
const artifactPath =
|
||||
sourcePath && name === "write_file" && messageId && id
|
||||
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
|
||||
: sourcePath;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!openStreamedArtifact ||
|
||||
!artifactPath ||
|
||||
!previewKey ||
|
||||
!isLoading ||
|
||||
!isLast ||
|
||||
result !== undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
openStreamedArtifact(artifactPath, previewKey);
|
||||
}, [
|
||||
artifactPath,
|
||||
isLast,
|
||||
isLoading,
|
||||
openStreamedArtifact,
|
||||
previewKey,
|
||||
result,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
className={cn(artifactPath && "cursor-pointer")}
|
||||
label={description}
|
||||
icon={NotebookPenIcon}
|
||||
onClick={() => {
|
||||
if (!artifactPath || !artifacts) return;
|
||||
artifacts.select(artifactPath);
|
||||
artifacts.setOpen(true);
|
||||
}}
|
||||
>
|
||||
{(normalizedPath || sourcePath) && (
|
||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||
{normalizedPath || sourcePath}
|
||||
</ChainOfThoughtSearchResult>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
interface GenericCoTStep<T extends string = string> {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
}
|
||||
}
|
||||
|
||||
function DeepResearchProgressStep({
|
||||
id,
|
||||
args,
|
||||
}: {
|
||||
id?: string;
|
||||
args: Record<string, unknown>;
|
||||
}) {
|
||||
const label = typeof args.label === "string" && args.label.trim()
|
||||
? args.label
|
||||
: "正在撰写研究报告";
|
||||
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
|
||||
const thinking = typeof args.thinking === "string" ? args.thinking.trim() : "";
|
||||
const active = args.status !== "complete";
|
||||
const [thinkingOpen, setThinkingOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && thinking) setThinkingOpen(true);
|
||||
}, [active, thinking]);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
status={active ? "active" : "complete"}
|
||||
icon={active
|
||||
? <Loader2Icon className="text-primary size-4 animate-spin" />
|
||||
: <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />}
|
||||
label={
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||
<span>{label}</span>
|
||||
<span className={active ? "text-primary text-xs" : "text-muted-foreground text-xs"}>
|
||||
{active ? "进行中" : "已完成"}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<div className="text-muted-foreground text-xs leading-5">
|
||||
{detail}
|
||||
</div>
|
||||
)}
|
||||
{thinking && (
|
||||
<Collapsible className="mt-2" onOpenChange={setThinkingOpen} open={thinkingOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
aria-label={thinkingOpen ? "收起模型思考" : "展开模型思考"}
|
||||
className="h-7 gap-1.5 px-2 text-xs"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
|
||||
<span>模型思考</span>
|
||||
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
|
||||
<ChevronDownIcon className={cn("size-3.5 transition-transform", thinkingOpen && "rotate-180")} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
|
||||
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
|
||||
</p>
|
||||
<pre className="bg-muted/45 text-muted-foreground mt-1 max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words">
|
||||
{thinking}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactWriteToolCall({
|
||||
id,
|
||||
messageId,
|
||||
name,
|
||||
args,
|
||||
result,
|
||||
description,
|
||||
isLast,
|
||||
isLoading,
|
||||
}: {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
name: "write_file" | "str_replace";
|
||||
args: Record<string, unknown>;
|
||||
result?: string | Record<string, unknown>;
|
||||
description: string;
|
||||
isLast: boolean;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const artifacts = useArtifactsOptional();
|
||||
const openStreamedArtifact = artifacts?.openStreamedArtifact;
|
||||
const rawPath = typeof args.path === "string" ? args.path : "";
|
||||
const content = typeof args.content === "string" ? args.content : "";
|
||||
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
|
||||
const hasUsablePath =
|
||||
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
|
||||
const sourcePath = hasUsablePath
|
||||
? normalizedPath
|
||||
: name === "write_file" && content.trim()
|
||||
? provisionalWriteFilePath(content)
|
||||
: null;
|
||||
const previewKey = messageId && id ? `${messageId}:${id}` : null;
|
||||
// Never select a bare provisional name like `未命名文件.md` — the artifacts
|
||||
// API only serves `/mnt/user-data/...` paths and would 400. Wait until the
|
||||
// tool call has ids so we can open a write-file: memory preview instead.
|
||||
const artifactPath =
|
||||
sourcePath && name === "write_file" && messageId && id
|
||||
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!openStreamedArtifact ||
|
||||
!artifactPath ||
|
||||
!previewKey ||
|
||||
!isLoading ||
|
||||
!isLast ||
|
||||
result !== undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
openStreamedArtifact(artifactPath, previewKey);
|
||||
}, [
|
||||
artifactPath,
|
||||
isLast,
|
||||
isLoading,
|
||||
openStreamedArtifact,
|
||||
previewKey,
|
||||
result,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
className={cn(artifactPath && "cursor-pointer")}
|
||||
label={description}
|
||||
icon={NotebookPenIcon}
|
||||
onClick={() => {
|
||||
if (!artifactPath || !artifacts) return;
|
||||
artifacts.select(artifactPath);
|
||||
artifacts.setOpen(true);
|
||||
}}
|
||||
>
|
||||
{(normalizedPath || sourcePath) && (
|
||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||
{normalizedPath || sourcePath}
|
||||
</ChainOfThoughtSearchResult>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
interface GenericCoTStep<T extends string = string> {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
type: T;
|
||||
}
|
||||
|
||||
@@ -737,7 +772,7 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
<span>{results.length} 条真实返回结果</span>
|
||||
<span>{results.length} 条真实返回结果</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 flex flex-col gap-2">
|
||||
{results.map((item) => (
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Loader2Icon,
|
||||
PencilIcon,
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
memo,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
useState,
|
||||
type AnchorHTMLAttributes,
|
||||
type ImgHTMLAttributes,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
|
||||
@@ -693,6 +695,17 @@ function MessageContent_({
|
||||
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
|
||||
return stripIncompleteCitationMarker(withCitations);
|
||||
}, [rawContent, isHuman, displaySources]);
|
||||
const reasoningToDisplay = useMemo(() => {
|
||||
if (!reasoningContent) return '';
|
||||
const withCitations = applyInlineCitations(reasoningContent, displaySources, {
|
||||
appendSummary: false,
|
||||
});
|
||||
return stripIncompleteCitationMarker(withCitations);
|
||||
}, [reasoningContent, displaySources]);
|
||||
const markdownRehypePlugins = useMemo(
|
||||
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
|
||||
[rehypePlugins],
|
||||
);
|
||||
const parsedHumanContent = useMemo(
|
||||
() =>
|
||||
isHuman
|
||||
@@ -722,23 +735,6 @@ function MessageContent_({
|
||||
);
|
||||
}
|
||||
|
||||
// Reasoning-only AI message (no main response content yet)
|
||||
if (!isHuman && reasoningContent && !rawContent && !multiRoundTrace) {
|
||||
return (
|
||||
<AIElementMessageContent className={className}>
|
||||
<Reasoning isStreaming={isLoading}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{reasoningContent}</ReasoningContent>
|
||||
</Reasoning>
|
||||
<MessageTokenUsage
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={isLoading}
|
||||
message={message}
|
||||
/>
|
||||
</AIElementMessageContent>
|
||||
);
|
||||
}
|
||||
|
||||
if (isHuman) {
|
||||
// Only the user's own question carries a delete affordance, and only once
|
||||
// it exists server-side (real, non-optimistic id). Deleting it cascades to
|
||||
@@ -774,25 +770,43 @@ function MessageContent_({
|
||||
<AIElementMessageContent className={className}>
|
||||
{filesList}
|
||||
<div data-reference-batch-id={message.id} className="contents">
|
||||
{reasoningToDisplay && !multiRoundTrace && (
|
||||
<Reasoning isStreaming={isLoading && !contentToDisplay}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
{isSingleRoundRag || displaySources.length > 0 ? (
|
||||
<MarkdownContent
|
||||
content={reasoningToDisplay}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={markdownRehypePlugins}
|
||||
components={components}
|
||||
className="text-muted-foreground text-sm"
|
||||
/>
|
||||
) : (
|
||||
reasoningToDisplay
|
||||
)}
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
)}
|
||||
{multiRoundTrace ? (
|
||||
<MultiRoundRagContent
|
||||
trace={multiRoundTrace}
|
||||
content={contentToDisplay}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
|
||||
rehypePlugins={markdownRehypePlugins}
|
||||
components={components}
|
||||
/>
|
||||
) : (
|
||||
) : contentToDisplay ? (
|
||||
<AssistantDisplayContent
|
||||
content={contentToDisplay}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
renderSkillDisplayBlocks={false}
|
||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
|
||||
rehypePlugins={markdownRehypePlugins}
|
||||
components={components}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
{(isSingleRoundRag || isMultiRoundRag) && displaySources.length > 0 && (
|
||||
<FrontendRagSourcesPanel
|
||||
@@ -838,7 +852,7 @@ function FrontendRagSourcesPanel({
|
||||
const [open, setOpen] = useState(false);
|
||||
if (sources.length === 0) return null;
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full">
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -947,11 +961,14 @@ function MultiRoundRagContent({
|
||||
}) {
|
||||
const [open, setOpen] = useState(!trace.isComplete);
|
||||
useEffect(() => {
|
||||
if (!trace.isComplete) setOpen(true);
|
||||
// Keep the active turn expanded so each new retrieval is visible as it
|
||||
// arrives, then match GeneralQA by collapsing the completed trace. The
|
||||
// header still carries the total round count for quick scanning.
|
||||
setOpen(!trace.isComplete);
|
||||
}, [trace.isComplete]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
|
||||
<section className="border-border/70 overflow-hidden rounded-xl border bg-muted/20">
|
||||
<button
|
||||
type="button"
|
||||
@@ -965,10 +982,10 @@ function MultiRoundRagContent({
|
||||
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium">多轮检索过程</div>
|
||||
<div className="text-sm font-medium">多轮检索思考过程</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{trace.isComplete
|
||||
? `已完成 ${trace.rounds.length} 轮检索`
|
||||
? `共 ${trace.rounds.length} 轮推理与检索,可展开查看细节`
|
||||
: '正在分析问题并检索资料'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -980,14 +997,26 @@ function MultiRoundRagContent({
|
||||
{open && (
|
||||
<div className="border-border/60 space-y-3 border-t px-4 py-3">
|
||||
{trace.rounds.map((round, index) => (
|
||||
<MultiRoundRagRoundCard
|
||||
<div
|
||||
key={round.id}
|
||||
index={index + 1}
|
||||
round={round}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
/>
|
||||
className={cn(
|
||||
'relative pl-5',
|
||||
index < trace.rounds.length - 1 && 'pb-1',
|
||||
)}
|
||||
>
|
||||
{index < trace.rounds.length - 1 && (
|
||||
<span className="bg-border absolute bottom-0 left-[7px] top-8 w-px" />
|
||||
)}
|
||||
<span className="border-primary bg-background absolute left-0 top-4 size-3.5 rounded-full border-2" />
|
||||
<MultiRoundRagRoundCard
|
||||
index={index + 1}
|
||||
round={round}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -1017,85 +1046,244 @@ function MultiRoundRagContent({
|
||||
function MultiRoundRagRoundCard({
|
||||
index,
|
||||
round,
|
||||
displaySources,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
components,
|
||||
}: {
|
||||
index: number;
|
||||
round: MultiRoundRagRound;
|
||||
displaySources: RuntimeDisplaySource[];
|
||||
isLoading: boolean;
|
||||
rehypePlugins: MarkdownContentProps['rehypePlugins'];
|
||||
components: MarkdownContentProps['components'];
|
||||
}) {
|
||||
const [open, setOpen] = useState(!round.query || round.isSearching);
|
||||
useEffect(() => {
|
||||
// A newly created round stays expanded to expose its thought and loading
|
||||
// query. Once that query has returned, retain only the round header until
|
||||
// the user asks to inspect the completed detail again.
|
||||
setOpen(!round.query || round.isSearching);
|
||||
}, [round.id, round.isSearching, round.query]);
|
||||
const thoughtContent = useMemo(
|
||||
() =>
|
||||
applyInlineCitations(round.thought, displaySources, {
|
||||
appendSummary: false,
|
||||
}),
|
||||
[displaySources, round.thought],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-border/60 rounded-lg border bg-background/70 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs">
|
||||
{index}
|
||||
</span>
|
||||
<span>{round.title}</span>
|
||||
{round.isSearching && <Loader2Icon className="text-primary size-3.5 animate-spin" />}
|
||||
</div>
|
||||
{round.thought && (
|
||||
<section className="border-border/50 bg-muted/30 mt-3 rounded-md border p-3">
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">思考内容</div>
|
||||
<MarkdownContent
|
||||
content={round.thought}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
className="text-muted-foreground text-sm"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{round.body && (
|
||||
<section className="border-border/50 mt-3 rounded-md border p-3">
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
|
||||
<MarkdownContent
|
||||
content={round.body}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
className="text-sm"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{round.query && (
|
||||
<div className="border-primary/20 bg-primary/5 text-primary mt-2 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs">
|
||||
<SearchIcon className="size-3 shrink-0" />
|
||||
<span className="truncate">{round.query}</span>
|
||||
</div>
|
||||
)}
|
||||
{round.retrievals.length > 0 && (
|
||||
<section className="border-border/50 mt-3 rounded-md border p-3">
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">
|
||||
检索内容 · {round.retrievals.length} 条
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{round.retrievals.map((item, resultIndex) => (
|
||||
<details
|
||||
key={`${item.id ?? item.title}-${resultIndex}`}
|
||||
className="border-border/60 rounded-md border px-3 py-2"
|
||||
open={resultIndex === 0}
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<article className="border-border/60 bg-background/80 overflow-hidden rounded-xl border shadow-sm">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2.5 text-left text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
|
||||
>
|
||||
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
|
||||
{index}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{round.title}</span>
|
||||
{round.isSearching ? (
|
||||
<span className="bg-primary/10 text-primary inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
|
||||
<Loader2Icon className="size-3 motion-reduce:animate-none animate-spin" />
|
||||
检索中
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground shrink-0 text-[11px]">已完成</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
|
||||
!open && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border/50 border-t px-3 pb-3">
|
||||
<div className="pt-3">
|
||||
{round.thought && (
|
||||
<MultiRoundRagDetailSection
|
||||
title="思考内容"
|
||||
icon={<SparklesIcon className="text-primary size-4" />}
|
||||
>
|
||||
<summary className="cursor-pointer list-none text-sm font-medium">
|
||||
<span className="mr-2 text-xs text-muted-foreground">{resultIndex + 1}.</span>
|
||||
{item.title}
|
||||
</summary>
|
||||
<div className="text-muted-foreground mt-2 text-sm leading-6 whitespace-pre-wrap">
|
||||
{item.content || '暂无资料摘要'}
|
||||
</div>
|
||||
{(item.source || item.time) && (
|
||||
<div className="text-muted-foreground mt-2 text-xs">
|
||||
{[item.source, item.time].filter(Boolean).join(' · ')}
|
||||
<MarkdownContent
|
||||
content={thoughtContent}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
className="text-muted-foreground text-sm"
|
||||
/>
|
||||
</MultiRoundRagDetailSection>
|
||||
)}
|
||||
{round.body && (
|
||||
<section className="border-border/50 mt-3 rounded-lg border p-3">
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
|
||||
<MarkdownContent
|
||||
content={round.body}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
className="text-sm"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{(round.query || round.isSearching) && (
|
||||
<div
|
||||
className={cn(
|
||||
'border-primary/20 bg-primary/5 text-primary mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1.5 text-xs',
|
||||
round.isSearching && 'animate-pulse motion-reduce:animate-none',
|
||||
)}
|
||||
aria-live={round.isSearching ? 'polite' : undefined}
|
||||
>
|
||||
{round.isSearching ? (
|
||||
<Loader2Icon className="size-3.5 shrink-0 motion-reduce:animate-none animate-spin" />
|
||||
) : (
|
||||
<SearchIcon className="size-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="shrink-0 text-muted-foreground">检索词</span>
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
{round.query || '正在生成检索词…'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(round.query || round.isSearching || round.retrievals.length > 0) && (
|
||||
<MultiRoundRagDetailSection
|
||||
defaultOpen={!round.isSearching}
|
||||
title="检索内容"
|
||||
icon={<FileIcon className="text-primary size-3.5" />}
|
||||
suffix={<span className="text-muted-foreground text-xs">{round.retrievals.length} 条</span>}
|
||||
>
|
||||
{round.isSearching ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 py-1 text-sm">
|
||||
<Loader2Icon className="text-primary size-4 motion-reduce:animate-none animate-spin" />
|
||||
正在召回相关内容…
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{round.retrievals.length > 0 ? (
|
||||
round.retrievals.map((item, resultIndex) => (
|
||||
<MultiRoundRagResultCard
|
||||
key={`${item.id ?? item.title}-${resultIndex}`}
|
||||
item={item}
|
||||
index={resultIndex + 1}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-muted-foreground py-1 text-sm">
|
||||
本轮未召回相关资料。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
))}
|
||||
</MultiRoundRagDetailSection>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</article>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiRoundRagDetailSection({
|
||||
title,
|
||||
icon,
|
||||
suffix,
|
||||
defaultOpen = true,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
suffix?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
useEffect(() => setOpen(defaultOpen), [defaultOpen]);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<section className="border-border/50 bg-muted/20 mt-3 overflow-hidden rounded-lg border">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
|
||||
>
|
||||
{icon}
|
||||
<span className="min-w-0 flex-1 text-sm font-medium">{title}</span>
|
||||
{suffix}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
|
||||
!open && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</section>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiRoundRagResultCard({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: MultiRoundRagRound['retrievals'][number];
|
||||
index: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(index === 1);
|
||||
const sourceMeta = [item.source, item.time].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<div className="border-border/60 hover:border-primary/30 bg-background rounded-lg border transition-colors">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
|
||||
>
|
||||
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-semibold">
|
||||
{index}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={item.title}>
|
||||
{item.title}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
|
||||
!open && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
|
||||
<p className="text-muted-foreground text-sm leading-6 whitespace-pre-wrap">
|
||||
{item.content || '暂无资料摘要'}
|
||||
</p>
|
||||
{(sourceMeta || item.url) && (
|
||||
<div className="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
{sourceMeta && <span>{sourceMeta}</span>}
|
||||
{item.url && (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
查看原文
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -255,15 +255,15 @@ export function MessageList({
|
||||
const [settings] = useLocalSettings();
|
||||
const ragModeEnabled =
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
const hasSingleRoundRag = useMemo(
|
||||
const hasSingleRoundRag = useMemo(
|
||||
() =>
|
||||
messages.some(
|
||||
(message) =>
|
||||
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
|
||||
),
|
||||
[messages],
|
||||
);
|
||||
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
|
||||
[messages],
|
||||
);
|
||||
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
|
||||
const { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
@@ -436,11 +436,12 @@ export function MessageList({
|
||||
className={cn("flex size-full flex-col justify-center", className)}
|
||||
>
|
||||
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
|
||||
contentClassName,
|
||||
)}
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-(--container-width-lg)",
|
||||
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
||||
{groupMessages(messages, (group) => {
|
||||
|
||||
@@ -54,6 +54,7 @@ import type {
|
||||
CompactShortcutRouteParam,
|
||||
} from "@/core/system-settings/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
import {
|
||||
APP_THEME_COLORS,
|
||||
LOGIN_SOURCE_OPTIONS,
|
||||
@@ -77,10 +78,7 @@ const languageOptions: { value: Locale; label: string }[] = [
|
||||
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue";
|
||||
|
||||
function makeShortcutId() {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return createClientId("shortcut");
|
||||
}
|
||||
|
||||
function newShortcutParam(): CompactShortcutRouteParam {
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
customThemeTextContrast,
|
||||
type CustomThemeAdvancedKey,
|
||||
} from "@/core/theme/custom-theme";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MAX_CUSTOM_THEMES = 12;
|
||||
@@ -127,10 +128,7 @@ const ADVANCED_COLOR_FIELDS: Array<{
|
||||
];
|
||||
|
||||
function makeThemeId(): `custom-theme-${string}` {
|
||||
const suffix =
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "").toLowerCase()
|
||||
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
||||
const suffix = createClientId().replace(/[^a-z0-9-]/gi, "").toLowerCase();
|
||||
return `custom-theme-${suffix}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DatabaseZapIcon,
|
||||
GaugeIcon,
|
||||
HistoryIcon,
|
||||
ListTreeIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
ScrollTextIcon,
|
||||
Settings2Icon,
|
||||
@@ -226,6 +227,16 @@ export function WorkspaceNavMenu() {
|
||||
技能管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口(管理员):
|
||||
简洁模式没有全局侧边栏,这里放一份保证两种模式都能进菜单管理。 */}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/menu-management")}
|
||||
>
|
||||
<ListTreeIcon />
|
||||
菜单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
DocumentRewriteStage,
|
||||
} from "@/open-canvas/hooks/useDocumentRewrite";
|
||||
import type { DocumentRewriteSummary } from "@/open-canvas/api/rewrite";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
|
||||
export type DocumentRewritePresentation = {
|
||||
id: string;
|
||||
@@ -19,11 +20,17 @@ export type DocumentRewritePresentation = {
|
||||
instruction: string;
|
||||
style: string;
|
||||
modelName: string | null;
|
||||
generateImages: boolean;
|
||||
maxGeneratedImages: number;
|
||||
generatedImageCount: number;
|
||||
illustrationTarget: string;
|
||||
stage: DocumentRewriteStage;
|
||||
originalCharCount: number;
|
||||
writtenCharCount: number;
|
||||
/** Short model-generated plan, streamed while it understands the request. */
|
||||
requirementsPlan: string;
|
||||
/** Ephemeral thought trace. Never included in the Markdown draft or persisted job state. */
|
||||
thinkingText: string;
|
||||
warnings: string[];
|
||||
summary: DocumentRewriteSummary | null;
|
||||
/** Kept in-memory for the line-level compare dialog; backend job hydration refills it. */
|
||||
@@ -42,9 +49,14 @@ type CreatePresentationInput = Omit<
|
||||
DocumentRewritePresentation,
|
||||
| "id"
|
||||
| "stage"
|
||||
| "generateImages"
|
||||
| "maxGeneratedImages"
|
||||
| "generatedImageCount"
|
||||
| "illustrationTarget"
|
||||
| "originalCharCount"
|
||||
| "writtenCharCount"
|
||||
| "requirementsPlan"
|
||||
| "thinkingText"
|
||||
| "warnings"
|
||||
| "summary"
|
||||
| "originalContent"
|
||||
@@ -59,6 +71,8 @@ type CreatePresentationInput = Omit<
|
||||
originalContent?: string;
|
||||
rewrittenContent?: string | null;
|
||||
requirementsPlan?: string;
|
||||
generateImages?: boolean;
|
||||
maxGeneratedImages?: number;
|
||||
};
|
||||
|
||||
type DocumentRewritePresentationContextValue = {
|
||||
@@ -74,10 +88,7 @@ const DocumentRewritePresentationContext =
|
||||
createContext<DocumentRewritePresentationContextValue | null>(null);
|
||||
|
||||
function createId() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return "drw-ui-" + crypto.randomUUID();
|
||||
}
|
||||
return "drw-ui-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
|
||||
return createClientId("drw-ui");
|
||||
}
|
||||
|
||||
export function DocumentRewritePresentationProvider({
|
||||
@@ -96,9 +107,14 @@ export function DocumentRewritePresentationProvider({
|
||||
...input,
|
||||
id,
|
||||
stage: "snapshot",
|
||||
generateImages: input.generateImages === true,
|
||||
maxGeneratedImages: Math.min(Math.max(input.maxGeneratedImages ?? 2, 1), 4),
|
||||
generatedImageCount: 0,
|
||||
illustrationTarget: "",
|
||||
originalCharCount: 0,
|
||||
writtenCharCount: 0,
|
||||
requirementsPlan: input.requirementsPlan ?? "",
|
||||
thinkingText: "",
|
||||
warnings: [],
|
||||
summary: null,
|
||||
originalContent: input.originalContent ?? "",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { applyCompactMenuOverrides, isDefaultCompactMenuType, normalizeCompactMenuType } from "./compact-menu-overrides";
|
||||
import type { MenuItem } from "./sidebar-menu";
|
||||
|
||||
const I = (() => null) as unknown as MenuItem["icon"];
|
||||
|
||||
test("normalizeCompactMenuType defaults to A", () => {
|
||||
assert.equal(normalizeCompactMenuType(undefined), "A");
|
||||
assert.equal(normalizeCompactMenuType(""), "A");
|
||||
assert.equal(normalizeCompactMenuType("default"), "A");
|
||||
assert.equal(normalizeCompactMenuType("b"), "B");
|
||||
assert.equal(normalizeCompactMenuType("2"), "B");
|
||||
assert.equal(normalizeCompactMenuType("alt"), "B");
|
||||
assert.equal(isDefaultCompactMenuType(undefined), true);
|
||||
assert.equal(isDefaultCompactMenuType(""), true);
|
||||
assert.equal(isDefaultCompactMenuType("B"), false);
|
||||
});
|
||||
|
||||
test("applyCompactMenuOverrides splits sidebar vs settings", () => {
|
||||
const items: MenuItem[] = [
|
||||
{ id: "a", label: "对话", icon: I, path: "/a" },
|
||||
{ id: "b", label: "智能体", icon: I, path: "/b" },
|
||||
];
|
||||
const applied = applyCompactMenuOverrides(items, [
|
||||
{ nodeId: "b", placement: "settings", sortOrder: 0, label: "智能体设置" },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
applied.sidebarItems.map((item) => item.id),
|
||||
["a"],
|
||||
);
|
||||
assert.equal(applied.settingsItems[0]?.id, "b");
|
||||
assert.equal(applied.settingsItems[0]?.label, "智能体设置");
|
||||
});
|
||||
@@ -1,7 +1,41 @@
|
||||
import type { MenuItem } from "./sidebar-menu";
|
||||
import { readRuntimeEnv } from "@/env";
|
||||
|
||||
export type CompactMenuPlacement = "sidebar" | "settings";
|
||||
|
||||
export const COMPACT_MENU_TYPES = ["A", "B"] as const;
|
||||
export type CompactMenuType = (typeof COMPACT_MENU_TYPES)[number];
|
||||
export const DEFAULT_COMPACT_MENU_TYPE: CompactMenuType = "A";
|
||||
|
||||
const TYPE_ALIASES: Record<string, CompactMenuType> = {
|
||||
A: "A",
|
||||
"1": "A",
|
||||
DEFAULT: "A",
|
||||
B: "B",
|
||||
"2": "B",
|
||||
ALT: "B",
|
||||
};
|
||||
|
||||
export function normalizeCompactMenuType(value?: string | null): CompactMenuType {
|
||||
const raw = (value ?? "").trim().toUpperCase();
|
||||
return TYPE_ALIASES[raw] ?? DEFAULT_COMPACT_MENU_TYPE;
|
||||
}
|
||||
|
||||
export function isDefaultCompactMenuType(value?: string | null): boolean {
|
||||
return normalizeCompactMenuType(value) === DEFAULT_COMPACT_MENU_TYPE;
|
||||
}
|
||||
|
||||
/** Compact-menu variant for this website, from runtime-config.js.
|
||||
* Missing / empty config is type A, which loads the original compact menu. */
|
||||
export function getRuntimeCompactMenuType(): CompactMenuType {
|
||||
return normalizeCompactMenuType(readRuntimeEnv("VITE_COMPACT_MENU_TYPE"));
|
||||
}
|
||||
|
||||
export const COMPACT_MENU_TYPE_LABELS: Record<CompactMenuType, string> = {
|
||||
A: "类型 A",
|
||||
B: "类型 B",
|
||||
};
|
||||
|
||||
export interface CompactMenuOverride {
|
||||
nodeId: string;
|
||||
placement: CompactMenuPlacement;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { runSentimentAgent, toAgUiMessages } from "./api";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
|
||||
const STORAGE_PREFIX = "workspace.sentiment-agent.";
|
||||
|
||||
@@ -53,9 +54,9 @@ export function useSentimentAgentChat(threadId: string) {
|
||||
const question = query.trim();
|
||||
if (!question) return;
|
||||
|
||||
const assistantId = `sentiment-ai-${crypto.randomUUID()}`;
|
||||
const assistantId = createClientId("sentiment-ai");
|
||||
const userMessage: Message = {
|
||||
id: `sentiment-human-${crypto.randomUUID()}`,
|
||||
id: createClientId("sentiment-human"),
|
||||
type: "human",
|
||||
content: [{ type: "text", text: question }],
|
||||
additional_kwargs: {
|
||||
@@ -115,7 +116,7 @@ export function useSentimentAgentChat(threadId: string) {
|
||||
try {
|
||||
await runSentimentAgent({
|
||||
threadId,
|
||||
runId: `run-${crypto.randomUUID()}`,
|
||||
runId: createClientId("run"),
|
||||
messages: history,
|
||||
signal: controller.signal,
|
||||
handlers: {
|
||||
|
||||
@@ -82,3 +82,14 @@ test("accepts spaced and full-width square brackets", () => {
|
||||
assert.equal(out.includes(citation(1)), true);
|
||||
assert.equal(out.includes(citation(2)), true);
|
||||
});
|
||||
|
||||
test("can skip the trailing source summary", () => {
|
||||
const withSummary = applyInlineCitations("没有编号。", makeSources(2));
|
||||
assert.equal(withSummary.includes("参考来源"), true);
|
||||
|
||||
const withoutSummary = applyInlineCitations("没有编号。", makeSources(2), {
|
||||
appendSummary: false,
|
||||
});
|
||||
assert.equal(withoutSummary, "没有编号。");
|
||||
assert.equal(withoutSummary.includes("#reference-"), false);
|
||||
});
|
||||
@@ -509,6 +509,7 @@ export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceI
|
||||
export function applyInlineCitations(
|
||||
content: string,
|
||||
sources: RuntimeDisplaySource[],
|
||||
options?: { appendSummary?: boolean },
|
||||
): string {
|
||||
if (!content || sources.length === 0) {
|
||||
return content;
|
||||
@@ -563,6 +564,9 @@ export function applyInlineCitations(
|
||||
if (replaced || hasCitationMarkdown(next)) {
|
||||
return next;
|
||||
}
|
||||
if (options?.appendSummary === false) {
|
||||
return next;
|
||||
}
|
||||
return appendCitationSummary(next, referenceSources);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Some OpenAI-compatible reasoning models write ``<think>...</think>`` into
|
||||
* the ordinary text stream rather than a dedicated reasoning field. Markdown
|
||||
* sandboxes must never render those fragments as document content. Callers
|
||||
* can separately render the returned thinking part in a labelled UI trace.
|
||||
*
|
||||
* The filter is stateful because either tag can be split across arbitrary SSE
|
||||
* deltas. Hold only a possible tag suffix, so confirmed Markdown continues
|
||||
* to appear with the normal token-streaming cadence.
|
||||
*/
|
||||
export class InlineThinkTagFilter {
|
||||
private static readonly OPEN_TAG = "<think>";
|
||||
private static readonly CLOSE_TAG = "</think>";
|
||||
|
||||
private insideThink = false;
|
||||
private pending = "";
|
||||
|
||||
private possibleTagPrefixSize(content: string, tag: string) {
|
||||
const lowered = content.toLowerCase();
|
||||
for (let size = Math.min(content.length, tag.length - 1); size > 0; size -= 1) {
|
||||
if (lowered.slice(-size) === tag.slice(0, size)) return size;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
pushParts(chunk: string): { text: string; thinking: string } {
|
||||
if (!chunk) return { text: "", thinking: "" };
|
||||
|
||||
let remaining = this.pending + chunk;
|
||||
this.pending = "";
|
||||
let visible = "";
|
||||
let thinking = "";
|
||||
|
||||
while (remaining) {
|
||||
const tag = this.insideThink
|
||||
? InlineThinkTagFilter.CLOSE_TAG
|
||||
: InlineThinkTagFilter.OPEN_TAG;
|
||||
const tagIndex = remaining.toLowerCase().indexOf(tag);
|
||||
if (tagIndex >= 0) {
|
||||
if (this.insideThink) thinking += remaining.slice(0, tagIndex);
|
||||
else visible += remaining.slice(0, tagIndex);
|
||||
remaining = remaining.slice(tagIndex + tag.length);
|
||||
this.insideThink = !this.insideThink;
|
||||
continue;
|
||||
}
|
||||
|
||||
const suffixSize = this.possibleTagPrefixSize(remaining, tag);
|
||||
const confirmed = suffixSize ? remaining.slice(0, -suffixSize) : remaining;
|
||||
if (this.insideThink) thinking += confirmed;
|
||||
else visible += confirmed;
|
||||
this.pending = suffixSize ? remaining.slice(-suffixSize) : "";
|
||||
break;
|
||||
}
|
||||
|
||||
return { text: visible, thinking };
|
||||
}
|
||||
|
||||
push(chunk: string) {
|
||||
return this.pushParts(chunk).text;
|
||||
}
|
||||
|
||||
/** Flush a completed stream into its visible/thinking channels. */
|
||||
finishParts(): { text: string; thinking: string } {
|
||||
if (this.insideThink) {
|
||||
const thinking = this.pending;
|
||||
this.insideThink = false;
|
||||
this.pending = "";
|
||||
return { text: "", thinking };
|
||||
}
|
||||
const tail = this.pending;
|
||||
this.pending = "";
|
||||
return { text: tail, thinking: "" };
|
||||
}
|
||||
|
||||
finish() {
|
||||
return this.finishParts().text;
|
||||
}
|
||||
|
||||
/** Replace a rendered checkpoint while retaining the correct tag state. */
|
||||
replace(content: string) {
|
||||
this.insideThink = false;
|
||||
this.pending = "";
|
||||
return this.push(content);
|
||||
}
|
||||
}
|
||||
|
||||
export function stripInlineThinkTags(content: string) {
|
||||
const filter = new InlineThinkTagFilter();
|
||||
return filter.push(content) + filter.finish();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -62,6 +62,37 @@ export function isThreadNotAccessibleError(message: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function isAbortLikeError(error: unknown): boolean {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return true;
|
||||
}
|
||||
const message = getStreamErrorMessage(error).toLowerCase();
|
||||
return (
|
||||
message.includes("aborterror") ||
|
||||
message.includes("the operation was aborted") ||
|
||||
message.includes("bodystreambuffer")
|
||||
);
|
||||
}
|
||||
|
||||
export function isTransientStreamTransportError(message: string): boolean {
|
||||
const lowered = message.toLowerCase();
|
||||
return (
|
||||
/failed to fetch/i.test(message) ||
|
||||
lowered.includes("networkerror") ||
|
||||
lowered.includes("network error") ||
|
||||
lowered.includes("load failed") ||
|
||||
lowered.includes("err_connection") ||
|
||||
lowered.includes("err_incomplete") ||
|
||||
lowered.includes("err_network") ||
|
||||
lowered.includes("econnreset") ||
|
||||
lowered.includes("econnaborted") ||
|
||||
/fetch failed/i.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a label for the *current* logged-in identity (email → user_id → 未知). */
|
||||
export function buildCurrentUserLabel(): string {
|
||||
const auth = getStoredAuth();
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { readRuntimeEnv } from "@/env";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
|
||||
const STORAGE_PREFIX = "workspace.multi-round-rag.";
|
||||
|
||||
@@ -13,6 +14,9 @@ export type MultiRoundRagRetrieval = {
|
||||
time?: string;
|
||||
recUuid?: string;
|
||||
url?: string;
|
||||
/** Original tool payload, retained so a future renderer can add more fields
|
||||
* without losing data received from the multi-round service. */
|
||||
raw?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type MultiRoundRagRound = {
|
||||
@@ -74,7 +78,7 @@ function writeStoredMessages(threadId: string, messages: Message[]) {
|
||||
}
|
||||
|
||||
function createId(prefix: string) {
|
||||
return `${prefix}-${crypto.randomUUID()}`;
|
||||
return createClientId(prefix);
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown) {
|
||||
@@ -87,8 +91,11 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
/** Values in the deployed tools are occasionally numeric IDs, not strings. */
|
||||
function asText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseJson(value: unknown): unknown {
|
||||
@@ -103,8 +110,11 @@ function parseJson(value: unknown): unknown {
|
||||
function parseEventLine(line: string): MultiRoundEvent | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("data:")) return null;
|
||||
const payload = trimmed.slice("data:".length).trim();
|
||||
if (!payload || payload === "[DONE]") return { event: "done" };
|
||||
try {
|
||||
return JSON.parse(trimmed.slice("data:".length).trim()) as MultiRoundEvent;
|
||||
const parsed = parseJson(payload);
|
||||
return asRecord(parsed) as MultiRoundEvent | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -141,10 +151,10 @@ function addThinkingRound(trace: MultiRoundRagTrace, thought: string) {
|
||||
if (current.title === "正在准备检索" && !current.thought && !current.query) {
|
||||
current.title = "思考过程";
|
||||
current.thought = thought;
|
||||
return;
|
||||
return current;
|
||||
}
|
||||
current.isSearching = false;
|
||||
trace.rounds.push({
|
||||
const next = {
|
||||
id: createId("multi-rag-round"),
|
||||
title: "思考过程",
|
||||
thought,
|
||||
@@ -152,7 +162,9 @@ function addThinkingRound(trace: MultiRoundRagTrace, thought: string) {
|
||||
query: "",
|
||||
isSearching: true,
|
||||
retrievals: [],
|
||||
});
|
||||
};
|
||||
trace.rounds.push(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function appendRoundBody(trace: MultiRoundRagTrace, body: string) {
|
||||
@@ -162,49 +174,94 @@ function appendRoundBody(trace: MultiRoundRagTrace, body: string) {
|
||||
current.body = `${current.body ?? ""}${body}`;
|
||||
}
|
||||
|
||||
function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] {
|
||||
const root = asRecord(parseJson(value));
|
||||
const list =
|
||||
(Array.isArray(root?.results) && root.results) ||
|
||||
(Array.isArray(root?.organic) && root.organic) ||
|
||||
[];
|
||||
return list.flatMap((item, index) => {
|
||||
function firstArray(values: unknown[]): unknown[] {
|
||||
for (const value of values) {
|
||||
const parsed = parseJson(value);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function toRecordList(value: unknown): Record<string, unknown>[] {
|
||||
const parsed = parseJson(value);
|
||||
const root = asRecord(parsed);
|
||||
const list = Array.isArray(parsed)
|
||||
? parsed
|
||||
: firstArray([
|
||||
root?.results,
|
||||
root?.organic,
|
||||
root?.data,
|
||||
root?.items,
|
||||
root?.list,
|
||||
root?.documents,
|
||||
]);
|
||||
return list.flatMap((item) => {
|
||||
if (typeof item === "string") return [{ content: item }];
|
||||
const record = asRecord(item);
|
||||
if (!record) return [];
|
||||
const recUuid = asString(record.recUuid ?? record.rec_uuid);
|
||||
const url = asString(record.url ?? record.link ?? record.href);
|
||||
return record ? [record] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GeneralQA's service fans out to several business search tools. Normalising
|
||||
* all of their result shapes here prevents a successful tool call from being
|
||||
* silently absent just because it did not use `results` or `organic`.
|
||||
*/
|
||||
function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] {
|
||||
const list = toRecordList(value);
|
||||
return list.flatMap((item, index) => {
|
||||
const recUuid = asText(item.recUuid ?? item.rec_uuid ?? item.uuid);
|
||||
const url = asText(item.url ?? item.link ?? item.href);
|
||||
const title = asText(
|
||||
item.title ?? item.m_title ?? item.taskName ?? item.user_name ?? item.name ?? item.user_id,
|
||||
);
|
||||
const content = asText(
|
||||
item.content ??
|
||||
item.m_content ??
|
||||
item.m_conent ??
|
||||
item.snippet ??
|
||||
item.text ??
|
||||
item.overview ??
|
||||
item.page_content ??
|
||||
item.description ??
|
||||
item.summary,
|
||||
);
|
||||
return [
|
||||
{
|
||||
id: asString(record.id) || recUuid || undefined,
|
||||
title: asString(record.title) || `检索资料 ${index + 1}`,
|
||||
content: asString(record.content ?? record.snippet ?? record.text),
|
||||
source: asString(record.source ?? record.source1 ?? url) || undefined,
|
||||
time: asString(record.publish_time ?? record.m_publish ?? record.date) || undefined,
|
||||
id: asText(item.id) || recUuid || undefined,
|
||||
title: title || `检索资料 ${index + 1}`,
|
||||
content: content || (url && !title ? url : ""),
|
||||
source: asText(item.source ?? item.source1 ?? item.m_source ?? item.platform) || undefined,
|
||||
time: asText(item.publish_time ?? item.m_publish ?? item.m_publicsh ?? item.date ?? item.time) || undefined,
|
||||
recUuid: recUuid || undefined,
|
||||
url: /^https?:\/\//i.test(url) ? url : undefined,
|
||||
raw: item,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function mapReferences(value: unknown): MultiRoundRagSource[] {
|
||||
const record = asRecord(value);
|
||||
const list = Array.isArray(record?.references) ? record.references : [];
|
||||
const parsed = parseJson(value);
|
||||
const record = asRecord(parsed);
|
||||
const list = Array.isArray(parsed)
|
||||
? parsed
|
||||
: firstArray([record?.references, record?.results, record?.sources, record?.documents]);
|
||||
return list.flatMap((item, index) => {
|
||||
const source = asRecord(item);
|
||||
if (!source) return [];
|
||||
const content = asString(source.content ?? source.snippet);
|
||||
const rawUrl = asString(source.url ?? source.link ?? source.href);
|
||||
const recUuid = asString(source.recUuid ?? source.rec_uuid ?? source.uuid);
|
||||
const content = asText(source.content ?? source.m_content ?? source.m_conent ?? source.snippet ?? source.text);
|
||||
const rawUrl = asText(source.url ?? source.link ?? source.href);
|
||||
const recUuid = asText(source.recUuid ?? source.rec_uuid ?? source.uuid);
|
||||
return [
|
||||
{
|
||||
id: asString(source.id) || recUuid || undefined,
|
||||
title: asString(source.title) || `参考资料 ${index + 1}`,
|
||||
type: asString(source.type) || "参考资料",
|
||||
snippet: asString(source.snippet) || content,
|
||||
id: asText(source.id) || recUuid || undefined,
|
||||
title: asText(source.title ?? source.m_title ?? source.taskName ?? source.user_name) || `参考资料 ${index + 1}`,
|
||||
type: asText(source.type) || "参考资料",
|
||||
snippet: asText(source.snippet) || content,
|
||||
content,
|
||||
source: asString(source.source ?? source.source1) || undefined,
|
||||
time: asString(source.time ?? source.m_publish ?? source.publish_time) || undefined,
|
||||
source: asText(source.source ?? source.source1 ?? source.m_source ?? source.platform) || undefined,
|
||||
time: asText(source.time ?? source.m_publish ?? source.m_publicsh ?? source.publish_time ?? source.date) || undefined,
|
||||
url: /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined,
|
||||
raw: source,
|
||||
},
|
||||
@@ -222,6 +279,71 @@ function cloneTrace(trace: MultiRoundRagTrace): MultiRoundRagTrace {
|
||||
};
|
||||
}
|
||||
|
||||
function toolTitle(toolName: string): string {
|
||||
switch (toolName) {
|
||||
case "local_search":
|
||||
return "本地知识检索";
|
||||
case "tavily_search":
|
||||
return "联网检索";
|
||||
case "google_search":
|
||||
case "getTaskLikeTop":
|
||||
return "思考过程";
|
||||
case "search_user_profile":
|
||||
return "靶标平台画像库";
|
||||
case "get_taiwan_news":
|
||||
case "search_keyword_taiwan_news":
|
||||
return "台湾热点新闻";
|
||||
case "get_world_news":
|
||||
return "世界热点新闻";
|
||||
case "get_root_messaage_comment":
|
||||
return "获取该消息的全部评论";
|
||||
case "search_user_behavior":
|
||||
return "查询该用户的历史行为";
|
||||
case "get_america_news":
|
||||
return "美国热点新闻";
|
||||
case "get_time_data":
|
||||
return "态势新闻搜索";
|
||||
case "get_textid_content":
|
||||
return "获取新闻详细内容";
|
||||
default:
|
||||
return toolName || "检索过程";
|
||||
}
|
||||
}
|
||||
|
||||
function isSearchTool(toolName: string): boolean {
|
||||
return [
|
||||
"local_search",
|
||||
"tavily_search",
|
||||
"google_search",
|
||||
"getTaskLikeTop",
|
||||
"search_user_profile",
|
||||
"get_taiwan_news",
|
||||
"search_keyword_taiwan_news",
|
||||
"get_world_news",
|
||||
"get_root_messaage_comment",
|
||||
"search_user_behavior",
|
||||
"get_america_news",
|
||||
"get_time_data",
|
||||
].includes(toolName);
|
||||
}
|
||||
|
||||
function queryFromToolInput(input: Record<string, unknown>, result: unknown): string {
|
||||
const resultRecord = asRecord(parseJson(result));
|
||||
const direct = asText(input.q ?? input.query ?? input.keyword ?? input.user_fig);
|
||||
if (direct) return direct;
|
||||
if (input.s || input.e) {
|
||||
return [asText(input.s), asText(input.e)].filter(Boolean).join(" - ");
|
||||
}
|
||||
return asText(resultRecord?.query ?? resultRecord?.q);
|
||||
}
|
||||
|
||||
function textFromEventData(value: unknown): string {
|
||||
const parsed = parseJson(value);
|
||||
if (typeof parsed === "string") return parsed;
|
||||
const record = asRecord(parsed);
|
||||
return asText(record?.text ?? record?.content ?? record?.message ?? record?.answer ?? record?.output);
|
||||
}
|
||||
|
||||
function isSingleRoundRagMessage(message: Message) {
|
||||
return message.additional_kwargs?.single_round_rag === true;
|
||||
}
|
||||
@@ -370,92 +492,204 @@ export function useMultiRoundRagMessages(threadId: string) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let activeThoughtRound: MultiRoundRagRound | null = null;
|
||||
let activeThoughtTailIsAnswer = false;
|
||||
let answerFromShowText = false;
|
||||
|
||||
const appendAnswer = (text: string, allowCompleteSnapshot = false) => {
|
||||
if (!text) return;
|
||||
// Some gateways emit one complete `message` after a `show_text`
|
||||
// answer. Restrict snapshot detection to that case and to a
|
||||
// meaningful length: adjacent streaming chunks can legitimately
|
||||
// contain the same character, and must never be discarded.
|
||||
if (
|
||||
allowCompleteSnapshot &&
|
||||
trace.answer.length >= 16 &&
|
||||
text.startsWith(trace.answer)
|
||||
) {
|
||||
trace.answer = text;
|
||||
return;
|
||||
}
|
||||
trace.answer += text;
|
||||
};
|
||||
|
||||
const consumeShowText = (text: string, tailIsAnswer = false) => {
|
||||
if (!text) return;
|
||||
if (activeThoughtRound) {
|
||||
const endIndex = text.indexOf("</think>");
|
||||
if (endIndex < 0) {
|
||||
activeThoughtRound.thought += text;
|
||||
return;
|
||||
}
|
||||
activeThoughtRound.thought += text.slice(0, endIndex);
|
||||
const roundBody = text.slice(endIndex + "</think>".length).trim();
|
||||
if (roundBody) {
|
||||
if (activeThoughtTailIsAnswer) {
|
||||
appendAnswer(roundBody);
|
||||
answerFromShowText = false;
|
||||
trace.isComplete = true;
|
||||
} else {
|
||||
appendRoundBody(trace, roundBody);
|
||||
}
|
||||
}
|
||||
activeThoughtRound = null;
|
||||
activeThoughtTailIsAnswer = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const thinkStart = text.indexOf("<think>");
|
||||
if (thinkStart < 0) {
|
||||
const current = latestRound(trace);
|
||||
current.isSearching = false;
|
||||
appendAnswer(text);
|
||||
answerFromShowText = true;
|
||||
trace.isComplete = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const prefix = text.slice(0, thinkStart).trim();
|
||||
if (prefix) appendRoundBody(trace, prefix);
|
||||
|
||||
// A full <think>…</think> envelope is normally delivered as one
|
||||
// tool call. Keep the unfinished variant open as well: production
|
||||
// proxies are allowed to split an SSE frame at any byte boundary.
|
||||
const thinkEnd = text.indexOf("</think>", thinkStart + "<think>".length);
|
||||
const thought = text
|
||||
.slice(thinkStart + "<think>".length, thinkEnd < 0 ? undefined : thinkEnd)
|
||||
.trim();
|
||||
const thoughtRound = addThinkingRound(trace, thought);
|
||||
if (thinkEnd < 0) {
|
||||
activeThoughtRound = thoughtRound;
|
||||
activeThoughtTailIsAnswer = tailIsAnswer;
|
||||
return;
|
||||
}
|
||||
const roundBody = text.slice(thinkEnd + "</think>".length).trim();
|
||||
if (roundBody) {
|
||||
if (tailIsAnswer) {
|
||||
appendAnswer(roundBody);
|
||||
answerFromShowText = false;
|
||||
trace.isComplete = true;
|
||||
} else {
|
||||
appendRoundBody(trace, roundBody);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const consumeToolCall = (payload: unknown) => {
|
||||
const parsedPayload = parseJson(payload);
|
||||
const tools = Array.isArray(parsedPayload) ? parsedPayload : [parsedPayload];
|
||||
for (const toolValue of tools) {
|
||||
const tool = asRecord(toolValue);
|
||||
if (!tool) continue;
|
||||
const toolName = asText(tool.tool_name ?? tool.name);
|
||||
const input = asRecord(parseJson(tool.tool_input ?? tool.input ?? tool.arguments)) ?? {};
|
||||
const current = latestRound(trace);
|
||||
|
||||
if (toolName === "show_text") {
|
||||
consumeShowText(asText(input.text ?? input.content));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (toolName === "get_textid_content") {
|
||||
current.title = toolTitle(toolName);
|
||||
const article = asRecord(parseJson(input.result));
|
||||
const body = asText(article?.content ?? article?.text ?? input.content ?? input.result);
|
||||
if (body) appendRoundBody(trace, body);
|
||||
current.isSearching = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasResult = input.result !== undefined;
|
||||
const query = queryFromToolInput(input, input.result);
|
||||
if (isSearchTool(toolName) || hasResult) {
|
||||
current.title = toolTitle(toolName);
|
||||
current.query = query || current.query;
|
||||
if (!hasResult && isSearchTool(toolName)) {
|
||||
// GeneralQA can receive a model text event before the search
|
||||
// tool announces its query. The query starts a new visible
|
||||
// loading phase, so restore the round/trace state here and
|
||||
// keep the query badge spinning until its result arrives.
|
||||
current.isSearching = true;
|
||||
trace.isComplete = false;
|
||||
} else if (hasResult) {
|
||||
const retrievals = mapRetrievals(input.result);
|
||||
// An empty search response is still a completed response; it
|
||||
// must replace a previous list instead of leaving stale cards.
|
||||
current.retrievals = retrievals;
|
||||
if (retrievals.length === 0) {
|
||||
const fallbackBody = textFromEventData(input.result);
|
||||
if (fallbackBody && fallbackBody !== current.query) {
|
||||
appendRoundBody(trace, fallbackBody);
|
||||
}
|
||||
}
|
||||
current.isSearching = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const finishTrace = () => {
|
||||
trace.isComplete = true;
|
||||
activeThoughtRound = null;
|
||||
activeThoughtTailIsAnswer = false;
|
||||
for (const round of trace.rounds) round.isSearching = false;
|
||||
};
|
||||
|
||||
const consumeEvent = (event: MultiRoundEvent) => {
|
||||
const eventType = asText(event.event);
|
||||
const data = event.data;
|
||||
if (eventType === "tool_call") {
|
||||
consumeToolCall(data);
|
||||
} else if (eventType === "message") {
|
||||
const text = textFromEventData(data);
|
||||
if (text) {
|
||||
if (activeThoughtRound || /<\/?think>/i.test(text)) {
|
||||
// Most deployments put thoughts in `show_text`, but this
|
||||
// fallback also preserves a compatible gateway that emits
|
||||
// `<think>` in normal message chunks.
|
||||
consumeShowText(text, true);
|
||||
} else {
|
||||
latestRound(trace).isSearching = false;
|
||||
appendAnswer(text, answerFromShowText);
|
||||
answerFromShowText = false;
|
||||
trace.isComplete = true;
|
||||
}
|
||||
}
|
||||
} else if (eventType === "references") {
|
||||
sources = mapReferences(data);
|
||||
} else if (eventType === "error") {
|
||||
const message = textFromEventData(data) || "多轮 RAG 服务返回错误。";
|
||||
setError(message);
|
||||
if (!trace.answer) trace.answer = message;
|
||||
finishTrace();
|
||||
} else if (eventType === "done") {
|
||||
// A few compatible gateway versions include the whole final
|
||||
// answer in `done`; render it when no `message` chunk was sent.
|
||||
const finalText = textFromEventData(data);
|
||||
if (finalText && !trace.answer) appendAnswer(finalText);
|
||||
finishTrace();
|
||||
}
|
||||
refreshAssistant();
|
||||
};
|
||||
|
||||
const consumeLines = (flush = false) => {
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
buffer = flush ? "" : (lines.pop() ?? "");
|
||||
for (const line of lines) {
|
||||
const event = parseEventLine(line);
|
||||
if (event) consumeEvent(event);
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const event = parseEventLine(line);
|
||||
if (!event) continue;
|
||||
const eventType = asString(event.event);
|
||||
const data = event.data;
|
||||
if (eventType === "tool_call") {
|
||||
const tool = asRecord(data);
|
||||
const toolName = asString(tool?.tool_name);
|
||||
const input = asRecord(tool?.tool_input) ?? {};
|
||||
const current = latestRound(trace);
|
||||
if (toolName === "show_text") {
|
||||
const text = asString(input.text);
|
||||
if (text.includes("<think>")) {
|
||||
const thinkEnd = text.indexOf("</think>");
|
||||
addThinkingRound(
|
||||
trace,
|
||||
(thinkEnd >= 0
|
||||
? text.slice(0, thinkEnd + "</think>".length)
|
||||
: text
|
||||
)
|
||||
.replace(/<\/?think>/g, "")
|
||||
.trim(),
|
||||
);
|
||||
if (thinkEnd >= 0) {
|
||||
const roundBody = text
|
||||
.slice(thinkEnd + "</think>".length)
|
||||
.trim();
|
||||
if (roundBody) {
|
||||
appendRoundBody(trace, roundBody);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// A non-thinking `show_text` immediately after a thought is
|
||||
// this round's narrative; the terminal `message` event is
|
||||
// still rendered only as the final answer below all rounds.
|
||||
current.isSearching = false;
|
||||
trace.answer += text;
|
||||
trace.isComplete = true;
|
||||
}
|
||||
} else if (
|
||||
toolName === "local_search" ||
|
||||
toolName === "tavily_search" ||
|
||||
toolName === "google_search"
|
||||
) {
|
||||
const queryData = asRecord(parseJson(input.result));
|
||||
current.title =
|
||||
toolName === "local_search" ? "本地知识检索" : "联网检索";
|
||||
current.query =
|
||||
asString(input.q ?? input.query) ||
|
||||
asString(queryData?.query) ||
|
||||
current.query;
|
||||
const retrievals = mapRetrievals(input.result);
|
||||
if (retrievals.length > 0) current.retrievals = retrievals;
|
||||
current.isSearching = false;
|
||||
} else if (toolName === "get_textid_content") {
|
||||
const article = asRecord(parseJson(input.result));
|
||||
const body =
|
||||
asString(article?.content ?? article?.text) ||
|
||||
asString(input.content);
|
||||
if (body) appendRoundBody(trace, body);
|
||||
current.isSearching = false;
|
||||
}
|
||||
refreshAssistant();
|
||||
} else if (eventType === "message" && typeof data === "string") {
|
||||
latestRound(trace).isSearching = false;
|
||||
trace.answer += data;
|
||||
trace.isComplete = true;
|
||||
refreshAssistant();
|
||||
} else if (eventType === "references") {
|
||||
sources = mapReferences(data);
|
||||
refreshAssistant();
|
||||
} else if (eventType === "done") {
|
||||
trace.isComplete = true;
|
||||
for (const round of trace.rounds) round.isSearching = false;
|
||||
refreshAssistant();
|
||||
}
|
||||
}
|
||||
consumeLines();
|
||||
}
|
||||
trace.isComplete = true;
|
||||
for (const round of trace.rounds) round.isSearching = false;
|
||||
buffer += decoder.decode();
|
||||
consumeLines(true);
|
||||
finishTrace();
|
||||
refreshAssistant();
|
||||
} catch (caught) {
|
||||
trace.isComplete = true;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { readRuntimeEnv } from "@/env";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
|
||||
const STORAGE_PREFIX = "workspace.single-round-rag.";
|
||||
|
||||
@@ -216,6 +217,8 @@ function toRagSource(
|
||||
content,
|
||||
source,
|
||||
time: readString(record.m_publish, record.m_publicsh, record.time),
|
||||
// GeneralQA uses recUuid to open the knowledge-detail page. Prefer that
|
||||
// stable internal record link, then fall back to a backend-provided URL.
|
||||
url:
|
||||
buildKnowledgeDetailUrl(recUuid) ??
|
||||
(rawUrl && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined),
|
||||
@@ -227,7 +230,7 @@ function toRagSource(
|
||||
* A single-RAG stream is a concatenation of source envelopes, for example:
|
||||
* `{docs:["..."], recUuid:"...", m_title:"...", m_content:"..."}`.
|
||||
* The root-level metadata is the source; `docs` merely marks the envelope and
|
||||
* supplies a fallback body. This mirrors GeneralQA's per-chunk processing.
|
||||
* supplies a fallback body. This mirrors GeneralQA's per-chunk processing.
|
||||
*/
|
||||
function normalizeSources(chunk: KnowledgeBaseChunk): RagSource[] {
|
||||
const hasRootMetadata = [
|
||||
@@ -275,7 +278,7 @@ function mergeSources(current: RagSource[], incoming: RagSource[]): RagSource[]
|
||||
}
|
||||
|
||||
function createId(prefix: string) {
|
||||
return `${prefix}-${crypto.randomUUID()}`;
|
||||
return createClientId(prefix);
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown) {
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { readRuntimeEnv } from "@/env";
|
||||
|
||||
export interface EightBfTask {
|
||||
id: string;
|
||||
externalTaskId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
direction: string;
|
||||
overview: string;
|
||||
priority: string;
|
||||
taskPriority: string;
|
||||
targetDirection: string;
|
||||
taskStatus: string;
|
||||
source: string;
|
||||
raw: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface EightBfDictionaryOption {
|
||||
label: string;
|
||||
value: string;
|
||||
alias?: string;
|
||||
statusTone?: string;
|
||||
}
|
||||
|
||||
export interface EightBfTaskDictionaries {
|
||||
priority: EightBfDictionaryOption[];
|
||||
direction: EightBfDictionaryOption[];
|
||||
status: EightBfDictionaryOption[];
|
||||
}
|
||||
|
||||
export interface EightBfTaskContextPayload {
|
||||
kind: "eightbf_roundtable_task";
|
||||
usage: string;
|
||||
task_id: string;
|
||||
task: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
direction: string;
|
||||
overview: string;
|
||||
priority: string;
|
||||
task_priority: string;
|
||||
target_direction: string;
|
||||
task_status: string;
|
||||
};
|
||||
task_tags: {
|
||||
priority: string;
|
||||
direction: string;
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部任务系统在离线部署或本地联调时可能不可用。此回退仅用于页面展示与
|
||||
* 意图上下文,保持与岗位会商示例一致,避免左上任务卡退化成接口错误文本。
|
||||
*/
|
||||
export function createEightBfFallbackTask(taskId: string): EightBfTask {
|
||||
return {
|
||||
id: taskId,
|
||||
externalTaskId: taskId,
|
||||
title: "暑期活动",
|
||||
description: "围绕暑期活动的游泳夏令营开展宣传,结合线上线下渠道吸引目标人群参与,持续提升活动影响力。",
|
||||
direction: "暑期活动",
|
||||
overview: "面向家长群体传播暑期游泳夏令营活动信息,提升品牌曝光与触达声量。",
|
||||
priority: "urgent",
|
||||
taskPriority: "urgent",
|
||||
targetDirection: "summer_activity",
|
||||
taskStatus: "system_created",
|
||||
source: "本系统创建",
|
||||
raw: null,
|
||||
};
|
||||
}
|
||||
|
||||
const TASK_DETAIL_PATH = "/task/{taskId}";
|
||||
const PROCESS_REPORT_ADD_PATH = "/task/process-report/add";
|
||||
const PROCESS_REPORT_PATH = "/task/process-report/{id}";
|
||||
const DICTIONARY_TYPES = {
|
||||
priority: "task_priority",
|
||||
direction: "task_direction",
|
||||
status: "task_status",
|
||||
} as const;
|
||||
|
||||
function externalApiBase(): string {
|
||||
// The position-roundtable Vue app calls this task system through
|
||||
// VITE_API_BASE_URL (usually the /zncm gateway), not DeerFlow's own API.
|
||||
return (
|
||||
readRuntimeEnv("VITE_8BF_TASK_API_BASE_URL")?.replace(/\/+$/, "") ||
|
||||
readRuntimeEnv("VITE_API_BASE_URL")?.replace(/\/+$/, "") ||
|
||||
getBackendBaseURL().replace(/\/+$/, "")
|
||||
);
|
||||
}
|
||||
|
||||
/** 8BF 会商页右上角「返回」按钮的跳转地址,来自 runtime-config.js。 */
|
||||
export function getEightBfRoundtableBackUrl(): string {
|
||||
return readRuntimeEnv("VITE_8BF_ROUNDTABLE_BACK_URL") ?? "";
|
||||
}
|
||||
|
||||
function externalUrl(path: string): string {
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
return `${externalApiBase()}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
export interface EightBfTaskProcessReportInput {
|
||||
taskId: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
content: string;
|
||||
/** 与岗位会商一致:测试分析岗的过程产物位置为 0。 */
|
||||
position: number;
|
||||
reportName: string;
|
||||
submitStatus: 0 | 1;
|
||||
}
|
||||
|
||||
export interface EightBfTaskProcessReportReceipt {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
|
||||
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
|
||||
* 两套 token 的用途不同,不能直接复用 `apiFetch` 的默认鉴权头;否则
|
||||
* 任务详情/字典接口可能返回 401,页面只会看到空任务卡。
|
||||
*
|
||||
* 这里仍保留 include credentials,与原 Vue 请求保持一致;同时不走
|
||||
* `apiFetch` 的 401 自动清理逻辑,避免外部任务系统的鉴权失败误登出
|
||||
* DeerFlow 当前用户。
|
||||
*/
|
||||
async function fetchExternalTaskSystem(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (!headers.has("Authorization")) {
|
||||
headers.set("Authorization", "Bearer admin");
|
||||
}
|
||||
if (!headers.has("Accept")) {
|
||||
headers.set("Accept", "application/json");
|
||||
}
|
||||
return fetch(externalUrl(path), {
|
||||
...init,
|
||||
credentials: init.credentials ?? "include",
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
async function externalTaskError(response: Response, fallback: string): Promise<Error> {
|
||||
const text = await response.text().catch(() => "");
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: unknown; msg?: unknown; detail?: unknown };
|
||||
const detail = firstText(payload.message, payload.msg, payload.detail);
|
||||
if (detail) return new Error(`${fallback}(${response.status}):${detail}`);
|
||||
} catch {
|
||||
// Keep the HTTP status when this deployment returns a non-JSON error body.
|
||||
}
|
||||
return new Error(`${fallback}(${response.status})`);
|
||||
}
|
||||
|
||||
function findProcessReportId(payload: unknown): string {
|
||||
if (!payload || typeof payload !== "object") return "";
|
||||
const source = payload as Record<string, unknown>;
|
||||
const direct = firstText(
|
||||
source.id,
|
||||
source.processReportId,
|
||||
source.process_report_id,
|
||||
source.reportId,
|
||||
);
|
||||
if (direct) return direct;
|
||||
return findProcessReportId(source.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the external task system's process report in its unsubmitted state.
|
||||
* The local gateway stores the returned id so a refresh can later submit the
|
||||
* same report instead of creating a duplicate one.
|
||||
*/
|
||||
export async function createEightBfTaskProcessReport(
|
||||
input: EightBfTaskProcessReportInput,
|
||||
): Promise<EightBfTaskProcessReportReceipt> {
|
||||
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
|
||||
const id = findProcessReportId(await response.json().catch(() => null));
|
||||
if (!id) throw new Error("过程产物登记成功,但接口未返回报告 ID");
|
||||
return { id };
|
||||
}
|
||||
|
||||
/** Submit an already-registered process report to the external task system. */
|
||||
export async function updateEightBfTaskProcessReport(
|
||||
reportId: string,
|
||||
input: EightBfTaskProcessReportInput,
|
||||
): Promise<void> {
|
||||
const response = await fetchExternalTaskSystem(
|
||||
PROCESS_REPORT_PATH.replace("{id}", encodeURIComponent(reportId)),
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
|
||||
}
|
||||
|
||||
function firstText(...values: unknown[]): string {
|
||||
for (const value of values) {
|
||||
if (value === undefined || value === null) continue;
|
||||
const text = String(value).trim();
|
||||
if (text) return text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function unwrapData(payload: unknown): Record<string, unknown> {
|
||||
let current: unknown = payload;
|
||||
// Task services in different deployments wrap the object as either
|
||||
// { data: task } or { data: { data: task } }.
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (!current || typeof current !== "object" || Array.isArray(current)) break;
|
||||
const next = (current as { data?: unknown }).data;
|
||||
if (!next || typeof next !== "object" || Array.isArray(next)) break;
|
||||
current = next;
|
||||
}
|
||||
return current && typeof current === "object" && !Array.isArray(current)
|
||||
? (current as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
export function normalizeEightBfTask(
|
||||
raw: Record<string, unknown> | null | undefined,
|
||||
fallbackTaskId = "",
|
||||
): EightBfTask {
|
||||
const source = raw ?? {};
|
||||
const id = firstText(
|
||||
source.id,
|
||||
source.taskId,
|
||||
source.externalTaskId,
|
||||
source.external_task_id,
|
||||
fallbackTaskId,
|
||||
);
|
||||
return {
|
||||
id,
|
||||
externalTaskId: firstText(source.externalTaskId, source.external_task_id, id),
|
||||
title: firstText(source.title, source.taskName, source.task_name, source.name),
|
||||
description: firstText(
|
||||
source.description,
|
||||
source.taskDescription,
|
||||
source.task_description,
|
||||
source.taskContent,
|
||||
source.task_content,
|
||||
source.content,
|
||||
),
|
||||
direction: firstText(
|
||||
source.direction,
|
||||
source.taskDirection,
|
||||
source.task_direction,
|
||||
),
|
||||
overview: firstText(source.overview, source.summary),
|
||||
priority: firstText(source.priority, source.urgency),
|
||||
taskPriority: firstText(
|
||||
source.taskPriority,
|
||||
source.task_priority,
|
||||
source.priority,
|
||||
source.urgency,
|
||||
),
|
||||
targetDirection: firstText(
|
||||
source.targetDirection,
|
||||
source.target_direction,
|
||||
source.direction,
|
||||
source.taskDirection,
|
||||
source.task_direction,
|
||||
),
|
||||
taskStatus: firstText(source.taskStatus, source.task_status, source.status),
|
||||
source: "测试分析岗任务详情接口",
|
||||
raw: source,
|
||||
};
|
||||
}
|
||||
|
||||
function dictionaryRows(payload: unknown): unknown[] {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (payload && typeof payload === "object") {
|
||||
const directData = (payload as { data?: unknown }).data;
|
||||
if (Array.isArray(directData)) return directData;
|
||||
if (directData && typeof directData === "object") {
|
||||
const nested = directData as { rows?: unknown; list?: unknown };
|
||||
if (Array.isArray(nested.rows)) return nested.rows;
|
||||
if (Array.isArray(nested.list)) return nested.list;
|
||||
}
|
||||
}
|
||||
const data = unwrapData(payload);
|
||||
if (Array.isArray(data.rows)) return data.rows;
|
||||
if (Array.isArray(data.list)) return data.list;
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeDictionary(payload: unknown): EightBfDictionaryOption[] {
|
||||
return dictionaryRows(payload)
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const row = item as Record<string, unknown>;
|
||||
const label = firstText(row.dictLabel, row.label);
|
||||
const value = firstText(row.dictValue, row.dict_value, row.value);
|
||||
if (!label || !value) return null;
|
||||
return {
|
||||
label,
|
||||
value,
|
||||
alias: firstText(row.alias) || undefined,
|
||||
statusTone: firstText(row.statusTone, row.status_tone) || undefined,
|
||||
} as EightBfDictionaryOption;
|
||||
})
|
||||
.filter((item): item is EightBfDictionaryOption => item !== null);
|
||||
}
|
||||
|
||||
export async function fetchEightBfTask(taskId: string): Promise<EightBfTask> {
|
||||
const encodedId = encodeURIComponent(taskId);
|
||||
const path = TASK_DETAIL_PATH.replaceAll("{taskId}", encodedId);
|
||||
const response = await fetchExternalTaskSystem(path, { method: "GET" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`任务详情接口请求失败(${response.status})`);
|
||||
}
|
||||
const payload = (await response.json()) as unknown;
|
||||
const task = normalizeEightBfTask(unwrapData(payload), taskId);
|
||||
// Keep the external route id as the stable scope even if a deployment's
|
||||
// response includes a legacy/internal numeric id.
|
||||
return { ...task, id: taskId, externalTaskId: taskId };
|
||||
}
|
||||
|
||||
export async function fetchEightBfTaskDictionaries(): Promise<EightBfTaskDictionaries> {
|
||||
const entries = await Promise.all(
|
||||
(Object.entries(DICTIONARY_TYPES) as Array<[
|
||||
keyof EightBfTaskDictionaries,
|
||||
string,
|
||||
]>).map(async ([key, path]) => {
|
||||
try {
|
||||
const response = await fetchExternalTaskSystem(
|
||||
`/system/dict/data/type/${path}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
if (!response.ok) throw new Error(`字典接口请求失败(${response.status})`);
|
||||
return [key, normalizeDictionary(await response.json())] as const;
|
||||
} catch (error) {
|
||||
// A dictionary failure must not hide the task card; the card falls back
|
||||
// to the raw task codes and the detail request remains usable.
|
||||
console.warn(`[8bf] ${key} 字典加载失败`, error);
|
||||
return [key, []] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return Object.fromEntries(entries) as EightBfTaskDictionaries;
|
||||
}
|
||||
|
||||
export function dictionaryText(
|
||||
options: EightBfDictionaryOption[] | undefined,
|
||||
value: unknown,
|
||||
preferredField: "label" | "alias" = "label",
|
||||
): string {
|
||||
const raw = firstText(value);
|
||||
if (!raw) return "";
|
||||
const match = options?.find((option) => option.value === raw);
|
||||
return firstText(match?.[preferredField], match?.label, raw);
|
||||
}
|
||||
|
||||
export function buildEightBfTaskContext(
|
||||
task: EightBfTask | null,
|
||||
dictionaries: EightBfTaskDictionaries,
|
||||
): EightBfTaskContextPayload | null {
|
||||
if (!task?.id) return null;
|
||||
return {
|
||||
kind: "eightbf_roundtable_task",
|
||||
usage:
|
||||
"这是 8BF 页面左上角任务卡片中的任务信息。请直接基于这些信息理解用户问题,不要要求用户重复提供已经存在的任务名称、任务描述、任务方向或任务状态。",
|
||||
task_id: task.id,
|
||||
task: {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
direction: task.direction,
|
||||
overview: task.overview,
|
||||
priority: task.priority,
|
||||
task_priority: task.taskPriority,
|
||||
target_direction: task.targetDirection,
|
||||
task_status: task.taskStatus,
|
||||
},
|
||||
task_tags: {
|
||||
priority: dictionaryText(dictionaries.priority, task.taskPriority),
|
||||
direction: dictionaryText(dictionaries.direction, task.targetDirection, "alias"),
|
||||
status: dictionaryText(dictionaries.status, task.taskStatus),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
Loader2,
|
||||
Settings2,
|
||||
UserRoundCog,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
AnalysisResultCardList,
|
||||
AnalysisResultDetailDialog,
|
||||
type AnalysisResultCardItem,
|
||||
} from "@/components/analysis-results";
|
||||
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
|
||||
import { useAgent, useAgents, type Agent } from "@/core/agents";
|
||||
import backArrowIcon from "@/assets/eightbf-roundtable/back-arrow.svg";
|
||||
import agentConfigLogo from "@/assets/position-roundtable/agent-config-logo.svg";
|
||||
import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg";
|
||||
import doubleArrowDecoration from "@/assets/position-roundtable/double-arrow-decoration.svg";
|
||||
import {
|
||||
parentReturnRoute,
|
||||
requestEightBfParentReturn,
|
||||
} from "@/eightbf-roundtable/lib/parent-return";
|
||||
import { useEightBfAnalysisResults } from "@/eightbf-roundtable/lib/collect-md-artifacts";
|
||||
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
|
||||
import {
|
||||
positionNameById,
|
||||
syncPositionOptionsFromRoles,
|
||||
} from "@/position-roundtable/lib/positions";
|
||||
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
|
||||
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||
import "@/position-roundtable/styles/intent-understanding.css";
|
||||
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
|
||||
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
|
||||
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
|
||||
import { usePositionRoles } from "@/strategy-components/api/position-roles";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type IntentRecord = Record<string, unknown>;
|
||||
|
||||
export interface EightBfStep1PanelProps extends Step1PanelProps {
|
||||
chain: BusinessChain | null;
|
||||
canConfigureChain: boolean;
|
||||
onConfigureChain: () => void;
|
||||
selectedModel: string;
|
||||
modelOptions: Array<{ label: string; value: string }>;
|
||||
onSelectedModelChange: (model: string) => void;
|
||||
step2Dialogues?: unknown[];
|
||||
step2ThreadIds?: ThreadIdMap | null;
|
||||
step2CoordinatorName?: string | null;
|
||||
step2SeatStubMessages?: Record<string, unknown[]>;
|
||||
step3Summary?: DraftStep3SummarySnapshot | null;
|
||||
}
|
||||
|
||||
export function EightBfRoundtableBackButton() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const handleBack = () => {
|
||||
const route = parentReturnRoute(
|
||||
searchParams.get("parentReturnTo")
|
||||
?? searchParams.get("parentRoute")
|
||||
?? searchParams.get("returnTo")
|
||||
?? "/list",
|
||||
);
|
||||
if (requestEightBfParentReturn(route)) return;
|
||||
toast.warning("尚未配置返回地址,请在 runtime-config.js 填写 VITE_8BF_ROUNDTABLE_BACK_URL");
|
||||
};
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="eightbf-roundtable__back-button"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<img src={backArrowIcon} alt="" />
|
||||
返回
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function EightBfStep1Panel({
|
||||
chain,
|
||||
canConfigureChain,
|
||||
onConfigureChain,
|
||||
selectedModel,
|
||||
modelOptions,
|
||||
onSelectedModelChange,
|
||||
intentReady,
|
||||
step2Dialogues = [],
|
||||
step2ThreadIds = null,
|
||||
step2CoordinatorName = null,
|
||||
step2SeatStubMessages,
|
||||
step3Summary = null,
|
||||
...step1Props
|
||||
}: EightBfStep1PanelProps) {
|
||||
const [rightPanelOpen, setRightPanelOpen] = useState(true);
|
||||
const [rightTab, setRightTab] = useState<"agents" | "results">("agents");
|
||||
|
||||
const openPanel = (tab: "agents" | "results") => {
|
||||
if (rightPanelOpen && rightTab === tab) {
|
||||
setRightPanelOpen(false);
|
||||
return;
|
||||
}
|
||||
setRightTab(tab);
|
||||
setRightPanelOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="position-roundtable-intent-page eightbf-roundtable-intent-page contents">
|
||||
<Step1Panel
|
||||
{...step1Props}
|
||||
rootClassName="position-roundtable-intent-panel eightbf-roundtable-intent-panel [&_#save-draft-btn]:hidden [&_#skip-question-btn]:hidden [&_#suggest-followup-btn]:hidden"
|
||||
headerLeftSlot={
|
||||
<div className="position-roundtable-intent-header-title">
|
||||
<span aria-hidden="true" />
|
||||
<h2>{chain?.title || "8BF 业务链"} · 意图识别</h2>
|
||||
</div>
|
||||
}
|
||||
headerRightSlot={
|
||||
<div className="position-roundtable-intent-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(rightPanelOpen && rightTab === "agents" && "is-selected")}
|
||||
onClick={() => openPanel("agents")}
|
||||
>
|
||||
智能体配置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(rightPanelOpen && rightTab === "results" && "is-selected")}
|
||||
onClick={() => openPanel("results")}
|
||||
>
|
||||
分析结果
|
||||
</button>
|
||||
<EightBfRoundtableBackButton />
|
||||
</div>
|
||||
}
|
||||
rightSidebar={
|
||||
<aside
|
||||
aria-hidden={!rightPanelOpen || undefined}
|
||||
className={cn(
|
||||
"position-roundtable-intent-config-pane eightbf-roundtable-intent-config-pane",
|
||||
rightPanelOpen ? "is-open" : "is-closed",
|
||||
)}
|
||||
>
|
||||
<div className="position-roundtable-intent-config-pane-inner">
|
||||
<EightBfAgentWorkspace
|
||||
tab={rightTab}
|
||||
chain={chain}
|
||||
canConfigureChain={canConfigureChain}
|
||||
onConfigureChain={onConfigureChain}
|
||||
onCollapse={() => setRightPanelOpen(false)}
|
||||
step2Dialogues={step2Dialogues}
|
||||
step2ThreadIds={step2ThreadIds}
|
||||
step2CoordinatorName={step2CoordinatorName}
|
||||
step2SeatStubMessages={step2SeatStubMessages}
|
||||
step3Summary={step3Summary}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
hideQuestionCount
|
||||
inputArea={(inputProps) => (
|
||||
<PositionIntentInput
|
||||
{...inputProps}
|
||||
modelOptions={modelOptions}
|
||||
selectedModel={selectedModel}
|
||||
onSelectedModelChange={onSelectedModelChange}
|
||||
/>
|
||||
)}
|
||||
intentReady={intentReady}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EightBfStep1Sidebar({
|
||||
taskCard,
|
||||
businessChain,
|
||||
intentReady,
|
||||
onCollapse,
|
||||
}: {
|
||||
taskCard?: ReactNode;
|
||||
businessChain?: ReactNode;
|
||||
intentReady: object | null;
|
||||
onCollapse?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="eightbf-roundtable__step1-sidebar position-roundtable-entry-sidebar">
|
||||
{taskCard}
|
||||
{onCollapse && !taskCard && (
|
||||
<button
|
||||
type="button"
|
||||
className="position-roundtable-intent-config-collapse-btn ml-auto"
|
||||
onClick={onCollapse}
|
||||
aria-label="收起左侧面板"
|
||||
title="收起左侧面板"
|
||||
>
|
||||
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<EightBfIntentDelivery intentReady={intentReady} businessChain={businessChain} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 第一、二步共用的意图结果面板,保持完全相同的展示结构和样式。 */
|
||||
export function EightBfIntentDelivery({
|
||||
intentReady,
|
||||
businessChain,
|
||||
}: {
|
||||
intentReady: object | null;
|
||||
businessChain?: ReactNode;
|
||||
}) {
|
||||
const sections = intentSections(intentReady);
|
||||
return (
|
||||
<section className="eightbf-roundtable__intent-delivery-sidebar position-roundtable-intent-delivery-sidebar" aria-labelledby="eightbf-intent-sidebar-title">
|
||||
{sections.length > 0 && (
|
||||
<div className="position-roundtable-intent-delivery-result">
|
||||
<header className="position-roundtable-intent-delivery-header">
|
||||
<span aria-hidden="true" />
|
||||
<h2 id="eightbf-intent-sidebar-title">意图理解结果</h2>
|
||||
</header>
|
||||
<div className="position-roundtable-intent-delivery-sections">
|
||||
{sections.map((section) => (
|
||||
<article
|
||||
key={section.label}
|
||||
className={cn(
|
||||
"position-roundtable-intent-delivery-section",
|
||||
intentSectionTone(section.label),
|
||||
)}
|
||||
>
|
||||
<h3>{section.label}</h3>
|
||||
<ul>
|
||||
{section.items.map((item) => <li key={item}>{item}</li>)}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{businessChain}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 第二步不需要第一步的整列占位,只复用其任务卡和意图面板。 */
|
||||
export function EightBfStep2SidebarHeader({
|
||||
taskCard,
|
||||
intentReady,
|
||||
}: {
|
||||
taskCard?: ReactNode;
|
||||
intentReady: object | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="eightbf-roundtable__step2-sidebar-header">
|
||||
{taskCard}
|
||||
<EightBfIntentDelivery intentReady={intentReady} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EightBfHeaderBrand({ chainTitle }: { chainTitle?: string }) {
|
||||
return (
|
||||
<div className="eightbf-roundtable__header-brand">
|
||||
<span className="eightbf-roundtable__header-brand-icon" aria-hidden="true">
|
||||
<UserRoundCog />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h1>8BF 多智能体会商</h1>
|
||||
<p>按业务链承接任务、对话与分析产物</p>
|
||||
</div>
|
||||
<span className="eightbf-roundtable__header-status">
|
||||
<span aria-hidden="true">⌁</span>
|
||||
{chainTitle ? "业务链已启动" : "等待业务链配置"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EightBfHeaderChain({ chainTitle }: { chainTitle?: string }) {
|
||||
return (
|
||||
<span className="eightbf-roundtable__header-chain" title={chainTitle || "尚未配置业务链"}>
|
||||
<span>当前业务链</span>
|
||||
<strong>{chainTitle || "暂未配置"}</strong>
|
||||
<ChevronDown aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function EightBfAgentWorkspace({
|
||||
tab,
|
||||
chain,
|
||||
canConfigureChain,
|
||||
onConfigureChain,
|
||||
onCollapse,
|
||||
step2Dialogues,
|
||||
step2ThreadIds,
|
||||
step2CoordinatorName,
|
||||
step2SeatStubMessages,
|
||||
step3Summary,
|
||||
}: {
|
||||
tab: "agents" | "results";
|
||||
chain: BusinessChain | null;
|
||||
canConfigureChain: boolean;
|
||||
onConfigureChain: () => void;
|
||||
onCollapse: () => void;
|
||||
step2Dialogues: unknown[];
|
||||
step2ThreadIds: ThreadIdMap | null;
|
||||
step2CoordinatorName: string | null;
|
||||
step2SeatStubMessages?: Record<string, unknown[]>;
|
||||
step3Summary: DraftStep3SummarySnapshot | null;
|
||||
}) {
|
||||
const { agents, isLoading } = useAgents();
|
||||
const { data: positionRoles = [] } = usePositionRoles();
|
||||
useEffect(() => {
|
||||
syncPositionOptionsFromRoles(positionRoles);
|
||||
}, [positionRoles]);
|
||||
const seats = chain?.seats ?? [];
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [agentKeyword, setAgentKeyword] = useState("");
|
||||
const { agent: selectedAgent, isLoading: selectedAgentLoading } = useAgent(selectedAgentId);
|
||||
const [detailItem, setDetailItem] = useState<AnalysisResultCardItem | null>(null);
|
||||
const analysisItems = useEightBfAnalysisResults({
|
||||
chain,
|
||||
dialogues: step2Dialogues,
|
||||
threadIds: step2ThreadIds,
|
||||
coordinatorName: step2CoordinatorName,
|
||||
seatStubMessages: step2SeatStubMessages,
|
||||
summaryReport: step3Summary,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAgentId && !seats.some((seat) => seat.agent_id === selectedAgentId)) {
|
||||
setSelectedAgentId(null);
|
||||
}
|
||||
}, [seats, selectedAgentId]);
|
||||
|
||||
const seatAgents = useMemo(() => {
|
||||
const agentById = new Map(agents.map((agent) => [agent.id, agent]));
|
||||
return seats.map((seat) => ({ seat, agent: agentById.get(seat.agent_id) ?? null }));
|
||||
}, [agents, seats]);
|
||||
const filteredSeatAgents = useMemo(() => {
|
||||
const keyword = agentKeyword.trim().toLowerCase();
|
||||
if (!keyword) return seatAgents;
|
||||
return seatAgents.filter(({ seat, agent }) => {
|
||||
const name = (agent?.name || seat.name || "").toLowerCase();
|
||||
const description = (agent?.description || seat.description || "").toLowerCase();
|
||||
return name.includes(keyword) || description.includes(keyword);
|
||||
});
|
||||
}, [agentKeyword, seatAgents]);
|
||||
const selectedSeat = seats.find((seat) => seat.agent_id === selectedAgentId) ?? null;
|
||||
|
||||
if (tab === "results") {
|
||||
return (
|
||||
<section className="position-roundtable-intent-analysis-results eightbf-roundtable-intent-analysis-results">
|
||||
<div className="position-roundtable-intent-analysis-header">
|
||||
<span>分析成果列表</span>
|
||||
<button
|
||||
type="button"
|
||||
className="position-roundtable-intent-config-collapse-btn"
|
||||
onClick={onCollapse}
|
||||
aria-label="收起分析结果"
|
||||
title="收起分析结果"
|
||||
>
|
||||
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="position-roundtable-intent-analysis-results-scroll eightbf-roundtable-intent-results-scroll">
|
||||
<AnalysisResultCardList
|
||||
items={analysisItems}
|
||||
emptyTitle="暂无交付产物"
|
||||
emptyDescription="完成第二步研讨与第三步方案总结并生成 Markdown 后,产物会显示在这里。"
|
||||
padded={false}
|
||||
onOpen={setDetailItem}
|
||||
/>
|
||||
</div>
|
||||
<AnalysisResultDetailDialog
|
||||
item={detailItem}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDetailItem(null);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedAgentId) {
|
||||
return (
|
||||
<div className="position-roundtable-intent-config-workspace">
|
||||
<div className="eightbf-roundtable__agent-detail-topbar">
|
||||
<button type="button" onClick={() => setSelectedAgentId(null)}>
|
||||
<ChevronLeft className="size-4" />
|
||||
智能体列表
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="position-roundtable-intent-config-collapse-btn"
|
||||
onClick={onCollapse}
|
||||
aria-label="收起智能体配置"
|
||||
title="收起智能体配置"
|
||||
>
|
||||
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="position-roundtable-agent-detail position-roundtable-agent-detail--special h-[calc(100%-48px)] min-h-0">
|
||||
{selectedAgentLoading ? (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-[#aaceff]">
|
||||
<Loader2 className="size-4 animate-spin" /> 正在读取智能体配置…
|
||||
</div>
|
||||
) : (
|
||||
<AgentDetailPanel
|
||||
agent={selectedAgent}
|
||||
agentName={selectedAgent?.name ?? selectedSeat?.name ?? ""}
|
||||
onAskQuestion={() => toast.info("请在中间对话区向任务理解智能体发起问题")}
|
||||
showRecentChats={false}
|
||||
avatarSrc={agentConfigLogo}
|
||||
positionName={positionNameById(selectedSeat?.position_id)}
|
||||
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
|
||||
skillsTitle="技能列表"
|
||||
showCommonQuestions
|
||||
panelTitle="智能体配置"
|
||||
embedded
|
||||
hideStatusBadges
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="eightbf-roundtable__agent-list-workspace">
|
||||
<div className="position-roundtable-intent-analysis-header">
|
||||
<span>智能体配置</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{canConfigureChain && (
|
||||
<button type="button" className="eightbf-roundtable__configure-chain-button" onClick={onConfigureChain}>
|
||||
<Settings2 className="size-3.5" /> 配置业务链
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="position-roundtable-intent-config-collapse-btn"
|
||||
onClick={onCollapse}
|
||||
aria-label="收起智能体配置"
|
||||
title="收起智能体配置"
|
||||
>
|
||||
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="eightbf-roundtable__agent-selector">
|
||||
<header className="eightbf-roundtable__agent-selector-head">
|
||||
<div className="eightbf-roundtable__agent-selector-title">
|
||||
<b>当前岗位智能体</b>
|
||||
<i>{seatAgents.length}</i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={agentKeyword}
|
||||
onChange={(event) => setAgentKeyword(event.target.value)}
|
||||
placeholder="搜索名称或说明"
|
||||
className="eightbf-roundtable__agent-selector-search"
|
||||
/>
|
||||
</header>
|
||||
{isLoading && seats.length > 0 ? (
|
||||
<div className="eightbf-roundtable__agent-selector-empty">正在加载智能体…</div>
|
||||
) : filteredSeatAgents.length === 0 ? (
|
||||
<div className="eightbf-roundtable__agent-selector-empty">
|
||||
<p>{seatAgents.length ? "没有符合条件的智能体" : "当前岗位尚未配置智能体"}</p>
|
||||
{!seatAgents.length && canConfigureChain && (
|
||||
<button type="button" onClick={onConfigureChain}>前往业务链配置</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="eightbf-roundtable__agent-selector-list">
|
||||
{filteredSeatAgents.map(({ seat, agent }) => (
|
||||
<EightBfAgentRow
|
||||
key={seat.agent_id}
|
||||
seat={seat}
|
||||
agent={agent}
|
||||
onSelect={() => setSelectedAgentId(seat.agent_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EightBfAgentRow({
|
||||
seat,
|
||||
agent,
|
||||
onSelect,
|
||||
}: {
|
||||
seat: ChainSeat;
|
||||
agent: Agent | null;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const name = agent?.name || seat.name || "未命名智能体";
|
||||
const description = agent?.description || seat.description || "智能体助手";
|
||||
const avatarText = Array.from(name.trim() || "?")[0]?.toUpperCase() || "?";
|
||||
return (
|
||||
<button type="button" className="eightbf-roundtable__agent-selector-card" onClick={onSelect}>
|
||||
<span className="eightbf-roundtable__agent-selector-avatar" aria-hidden="true">{avatarText}</span>
|
||||
<span className="eightbf-roundtable__agent-selector-main">
|
||||
<strong>{name}</strong>
|
||||
<p>{description}</p>
|
||||
</span>
|
||||
<i className="eightbf-roundtable__agent-selector-bar" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function intentSections(intentReady: object | null): Array<{ label: string; items: string[] }> {
|
||||
const source = intentReady && typeof intentReady === "object" ? intentReady as IntentRecord : null;
|
||||
if (!source) return [];
|
||||
const entries = [
|
||||
["核心目标", arrayValue(source.coreGoals, source.objective)],
|
||||
["风险提示", arrayValue(source.riskWarnings, source.constraints)],
|
||||
["关键要点", arrayValue(source.keyPoints, source.assumptions)],
|
||||
["战略意义", arrayValue(source.strategicSignificance)],
|
||||
] as const;
|
||||
return entries
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([label, items]) => ({ label, items }));
|
||||
}
|
||||
|
||||
function intentSectionTone(label: string) {
|
||||
switch (label) {
|
||||
case "核心目标":
|
||||
return "position-roundtable-intent-delivery-section--goals";
|
||||
case "风险提示":
|
||||
return "position-roundtable-intent-delivery-section--constraints";
|
||||
case "关键要点":
|
||||
return "position-roundtable-intent-delivery-section--purpose";
|
||||
case "战略意义":
|
||||
return "position-roundtable-intent-delivery-section--significance";
|
||||
default:
|
||||
return "position-roundtable-intent-delivery-section--significance";
|
||||
}
|
||||
}
|
||||
|
||||
function arrayValue(...values: unknown[]): string[] {
|
||||
for (const value of values) {
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map((item) => String(item).trim()).filter(Boolean);
|
||||
if (items.length > 0) return items;
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) return [value.trim()];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg";
|
||||
import {
|
||||
dictionaryText,
|
||||
type EightBfDictionaryOption,
|
||||
type EightBfTask,
|
||||
} from "@/eightbf-roundtable/api/task-context";
|
||||
|
||||
interface EightBfTaskCardProps {
|
||||
task: EightBfTask | null;
|
||||
dictionaries: {
|
||||
priority: EightBfDictionaryOption[];
|
||||
direction: EightBfDictionaryOption[];
|
||||
status: EightBfDictionaryOption[];
|
||||
};
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
/** 第二步左栏使用,与“智能体配置”同款图标收起整列。 */
|
||||
onCollapse?: () => void;
|
||||
}
|
||||
|
||||
export function EightBfTaskCard({
|
||||
task,
|
||||
dictionaries,
|
||||
loading = false,
|
||||
error,
|
||||
onCollapse,
|
||||
}: EightBfTaskCardProps) {
|
||||
const priority = dictionaryText(dictionaries.priority, task?.taskPriority);
|
||||
const direction = dictionaryText(dictionaries.direction, task?.targetDirection, "alias");
|
||||
const status = dictionaryText(dictionaries.status, task?.taskStatus);
|
||||
// 外部任务接口不可用时,卡片使用与岗位会商相同的示例任务;标签也必须
|
||||
// 保持可读,而不能暴露内部状态码。
|
||||
const tags = task?.raw === null
|
||||
? ["紧急", "本系统创建", "暑期活动"]
|
||||
: [priority, status, direction].filter(Boolean);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="position-roundtable-task-card eightbf-roundtable__task-card group relative shrink-0 overflow-hidden rounded-[4px] border border-[#ECE7E4] bg-white p-5 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[rgba(0,110,254,0.5)] dark:bg-[#032650] dark:shadow-none"
|
||||
aria-labelledby="eightbf-task-card-title"
|
||||
>
|
||||
<div className="position-roundtable-task-card__content relative z-10">
|
||||
{onCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
className="position-roundtable-intent-config-collapse-btn absolute right-0 top-0"
|
||||
onClick={onCollapse}
|
||||
aria-label="收起左侧面板"
|
||||
title="收起左侧面板"
|
||||
>
|
||||
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<h2
|
||||
id="eightbf-task-card-title"
|
||||
title={task?.title || task?.id || "等待任务信息"}
|
||||
className="pr-8 text-[18px] font-medium leading-7 text-slate-900 dark:text-white"
|
||||
>
|
||||
<span className="line-clamp-1">{task?.title || (task?.id ? `任务 ${task.id}` : "等待任务信息")}</span>
|
||||
</h2>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div
|
||||
data-task-tags
|
||||
className="mt-3 flex flex-wrap gap-2 [&>span]:inline-flex [&>span]:min-h-7 [&>span]:items-center [&>span]:!leading-5 [&>span:first-child]:!border-[#cf1322] [&>span:nth-child(2)]:!shadow-none"
|
||||
aria-label="任务标签"
|
||||
>
|
||||
{tags.map((tag, index) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={[
|
||||
"rounded-[2px] border px-2 py-0.5 text-center text-[16px] font-light",
|
||||
index === 0 && "border-[#cf1322] bg-[rgba(207,19,34,0.5)] leading-4 text-white",
|
||||
index === 1 && "border-[#4bcdff] bg-[rgba(75,205,255,0.3)] leading-7 text-[#4bcdff] shadow-[0_0_9px_rgba(252,131,131,0.27)]",
|
||||
index > 1 && "border-[#a2e1f9] bg-[rgba(162,225,249,0.3)] leading-[18px] text-[#a2e1f9]",
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
data-task-description
|
||||
className={cn(
|
||||
"mt-3 line-clamp-3 whitespace-pre-wrap text-sm leading-[22px] text-slate-600 dark:text-[#96bbf8]",
|
||||
!task?.description && "is-empty",
|
||||
)}
|
||||
>
|
||||
{task?.description || task?.overview || (loading ? "正在读取任务详情…" : "暂无任务描述")}
|
||||
</p>
|
||||
{error && <p className="eightbf-roundtable__task-card-error" role="status">{error}</p>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { AnalysisResultCardItem } from "@/components/analysis-results";
|
||||
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
|
||||
import {
|
||||
listSessionArtifacts,
|
||||
type ThreadIdMap,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
|
||||
|
||||
const MD_PATH_RE = /\.md$/i;
|
||||
const SUMMARY_OUTPUT_PATH = "/mnt/user-data/outputs/方案总结报告.md";
|
||||
|
||||
type DialogueLike = {
|
||||
sender?: string;
|
||||
agentThreadId?: string;
|
||||
time?: string;
|
||||
steps?: Array<{ key?: string; args?: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
interface MdFileHit {
|
||||
path: string;
|
||||
name: string;
|
||||
threadId: string | null;
|
||||
content: string | null;
|
||||
updatedAt?: string;
|
||||
agentName: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
function isMarkdownPath(path: string): boolean {
|
||||
return MD_PATH_RE.test(path.split("/").pop() ?? path);
|
||||
}
|
||||
|
||||
function filenameOf(path: string): string {
|
||||
return path.split("/").filter(Boolean).at(-1) || path;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function fileKey(threadId: string | null, path: string): string {
|
||||
return `${threadId ?? ""}::${path}`;
|
||||
}
|
||||
|
||||
function collectPathsFromSteps(
|
||||
steps: DialogueLike["steps"],
|
||||
): Array<{ path: string; content: string | null }> {
|
||||
if (!Array.isArray(steps) || steps.length === 0) return [];
|
||||
const out: Array<{ path: string; content: string | null }> = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (path: string, content: string | null) => {
|
||||
if (!path || seen.has(path) || !isMarkdownPath(path)) return;
|
||||
seen.add(path);
|
||||
out.push({ path, content });
|
||||
};
|
||||
for (const step of steps) {
|
||||
if (step.key === "tool_calling:present_files") {
|
||||
const fps = step.args?.filepaths;
|
||||
if (Array.isArray(fps)) {
|
||||
for (const fp of fps) add(asString(fp), null);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (step.key === "tool_calling:write_file" || step.key === "tool_calling:str_replace") {
|
||||
add(asString(step.args?.path), asString(step.args?.content) || null);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectFromDialogues(dialogues: unknown[]): MdFileHit[] {
|
||||
const hits: MdFileHit[] = [];
|
||||
let order = 0;
|
||||
for (const raw of dialogues) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const dialogue = raw as DialogueLike;
|
||||
const sender = asString(dialogue.sender) || "智能体";
|
||||
const threadId = asString(dialogue.agentThreadId) || null;
|
||||
for (const file of collectPathsFromSteps(dialogue.steps)) {
|
||||
hits.push({
|
||||
path: file.path,
|
||||
name: filenameOf(file.path),
|
||||
threadId,
|
||||
content: file.content,
|
||||
updatedAt: asString(dialogue.time) || undefined,
|
||||
agentName: sender,
|
||||
order: order++,
|
||||
});
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
function collectFromStubs(stubs: Record<string, unknown[]> | undefined): MdFileHit[] {
|
||||
const hits: MdFileHit[] = [];
|
||||
for (const [threadId, messages] of Object.entries(stubs ?? {})) {
|
||||
if (!Array.isArray(messages)) continue;
|
||||
for (const message of messages) {
|
||||
if (!message || typeof message !== "object") continue;
|
||||
const calls = (message as { tool_calls?: unknown }).tool_calls;
|
||||
if (!Array.isArray(calls)) continue;
|
||||
for (const call of calls) {
|
||||
if (!call || typeof call !== "object") continue;
|
||||
const tool = call as { name?: unknown; args?: Record<string, unknown> };
|
||||
if (asString(tool.name) !== "write_file") continue;
|
||||
const path = asString(tool.args?.path);
|
||||
if (!path || !isMarkdownPath(path)) continue;
|
||||
hits.push({
|
||||
path,
|
||||
name: filenameOf(path),
|
||||
threadId,
|
||||
content: asString(tool.args?.content) || null,
|
||||
agentName: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
function mergeHits(groups: MdFileHit[][]): Map<string, MdFileHit> {
|
||||
const merged = new Map<string, MdFileHit>();
|
||||
for (const group of groups) {
|
||||
for (const hit of group) {
|
||||
const key = fileKey(hit.threadId, hit.path);
|
||||
const prev = merged.get(key);
|
||||
if (!prev) {
|
||||
merged.set(key, hit);
|
||||
continue;
|
||||
}
|
||||
merged.set(key, {
|
||||
path: hit.path || prev.path,
|
||||
name: hit.name || prev.name,
|
||||
threadId: hit.threadId || prev.threadId,
|
||||
content: hit.content || prev.content,
|
||||
updatedAt: hit.updatedAt || prev.updatedAt,
|
||||
agentName: prev.agentName || hit.agentName,
|
||||
order: Math.max(prev.order ?? 0, hit.order ?? 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function latestHit(hits: MdFileHit[]): MdFileHit | null {
|
||||
if (hits.length === 0) return null;
|
||||
return hits.reduce((latest, hit) => {
|
||||
const latestTime = Date.parse(latest.updatedAt ?? "") || 0;
|
||||
const hitTime = Date.parse(hit.updatedAt ?? "") || 0;
|
||||
if (hitTime !== latestTime) return hitTime > latestTime ? hit : latest;
|
||||
return (hit.order ?? 0) >= (latest.order ?? 0) ? hit : latest;
|
||||
});
|
||||
}
|
||||
|
||||
function pendingSeatCard(seat: BusinessChain["seats"][number]): AnalysisResultCardItem {
|
||||
const agentName = seat.name?.trim() || seat.agent_id;
|
||||
return {
|
||||
id: `eightbf:seat:${seat.agent_id}:pending`,
|
||||
agentName,
|
||||
productName: `待交付:${agentName} Markdown报告`,
|
||||
positionName: agentName,
|
||||
delivered: false,
|
||||
fallbackTime: "待完成",
|
||||
unavailableMessage: `「${agentName}」尚未交付可预览的 Markdown 文件。完成第二步研讨后,这里会直接展示产物。`,
|
||||
};
|
||||
}
|
||||
|
||||
function hitToCard(
|
||||
hit: MdFileHit,
|
||||
positionName: string,
|
||||
id: string,
|
||||
): AnalysisResultCardItem {
|
||||
return {
|
||||
id,
|
||||
agentName: hit.agentName || positionName,
|
||||
productName: hit.name,
|
||||
positionName,
|
||||
updatedAt: hit.updatedAt,
|
||||
fallbackTime: "刚刚",
|
||||
delivered: true,
|
||||
artifactPath: hit.path,
|
||||
artifactMimeType: "text/markdown",
|
||||
artifactThreadId: hit.threadId,
|
||||
artifactContent: hit.content,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectEightBfAnalysisResults({
|
||||
chain,
|
||||
dialogues,
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
seatStubMessages,
|
||||
summaryReport,
|
||||
remoteFiles = [],
|
||||
}: {
|
||||
chain: BusinessChain | null;
|
||||
dialogues: unknown[];
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
seatStubMessages?: Record<string, unknown[]>;
|
||||
summaryReport: DraftStep3SummarySnapshot | null;
|
||||
remoteFiles?: MdFileHit[];
|
||||
}): AnalysisResultCardItem[] {
|
||||
const merged = mergeHits([
|
||||
collectFromDialogues(dialogues),
|
||||
collectFromStubs(seatStubMessages),
|
||||
]);
|
||||
const seats = chain?.seats ?? [];
|
||||
const coordinator = coordinatorName?.trim() || "";
|
||||
const items: AnalysisResultCardItem[] = [];
|
||||
|
||||
const threadIdOfSeat = (seat: BusinessChain["seats"][number]): string | null => {
|
||||
const byId = threadIds?.[seat.agent_id];
|
||||
if (byId) return byId;
|
||||
const byName = Object.entries(threadIds ?? {}).find(
|
||||
([name]) => name.toLowerCase() === seat.agent_id.toLowerCase() || name === seat.name,
|
||||
)?.[1];
|
||||
return byName ?? null;
|
||||
};
|
||||
|
||||
for (const seat of seats) {
|
||||
if (coordinator && (seat.name === coordinator || seat.agent_id === coordinator)) continue;
|
||||
const seatThreadId = threadIdOfSeat(seat);
|
||||
const agentName = seat.name?.trim() || seat.agent_id;
|
||||
const seatHits = [...merged.values()].filter((hit) => {
|
||||
if (seatThreadId && hit.threadId === seatThreadId) return true;
|
||||
return hit.agentName === agentName || hit.agentName === seat.agent_id;
|
||||
});
|
||||
const latest = latestHit(seatHits) ?? latestHit(
|
||||
remoteFiles.filter((hit) => Boolean(seatThreadId) && hit.threadId === seatThreadId),
|
||||
);
|
||||
if (!latest) {
|
||||
items.push(pendingSeatCard(seat));
|
||||
continue;
|
||||
}
|
||||
const enriched = latest.content
|
||||
? latest
|
||||
: remoteFiles.find((hit) =>
|
||||
hit.threadId === latest.threadId &&
|
||||
(hit.path === latest.path || hit.name === latest.name),
|
||||
) ?? latest;
|
||||
items.push(hitToCard(enriched, agentName, `eightbf:seat:${seat.agent_id}:latest`));
|
||||
}
|
||||
|
||||
const deliveredSeatCount = items.filter((item) => item.delivered).length;
|
||||
if (summaryReport?.md.trim()) {
|
||||
items.push({
|
||||
id: "eightbf:summary",
|
||||
agentName: "方案总结智能体",
|
||||
productName: "方案总结报告.md",
|
||||
positionName: "方案总结",
|
||||
updatedAt: summaryReport.generatedAt,
|
||||
fallbackTime: "刚刚",
|
||||
delivered: true,
|
||||
artifactPath: SUMMARY_OUTPUT_PATH,
|
||||
artifactMimeType: "text/markdown",
|
||||
artifactThreadId: summaryReport.threadId ?? null,
|
||||
artifactContent: summaryReport.md,
|
||||
summary: summaryReport.closing ?? summaryReport.md,
|
||||
});
|
||||
} else if (deliveredSeatCount > 0) {
|
||||
items.push({
|
||||
id: "eightbf:summary:pending",
|
||||
agentName: "方案总结智能体",
|
||||
productName: "待生成:方案总结报告.md",
|
||||
positionName: "方案总结",
|
||||
delivered: false,
|
||||
fallbackTime: "待完成",
|
||||
unavailableMessage: "第三步方案总结尚未生成 Markdown 报告。完成总结后,这里会直接展示产物。",
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function useEightBfAnalysisResults(input: {
|
||||
chain: BusinessChain | null;
|
||||
dialogues: unknown[];
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
seatStubMessages?: Record<string, unknown[]>;
|
||||
summaryReport: DraftStep3SummarySnapshot | null;
|
||||
}): AnalysisResultCardItem[] {
|
||||
const [remoteFiles, setRemoteFiles] = useState<MdFileHit[]>([]);
|
||||
const threadEntries = useMemo(
|
||||
() => Object.entries(input.threadIds ?? {}).filter(([, threadId]) => Boolean(threadId)),
|
||||
[input.threadIds],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (threadEntries.length === 0) {
|
||||
setRemoteFiles([]);
|
||||
return;
|
||||
}
|
||||
const abort = new AbortController();
|
||||
let cancelled = false;
|
||||
void Promise.all(
|
||||
threadEntries.map(async ([agentName, threadId]) => {
|
||||
try {
|
||||
const res = await listSessionArtifacts(threadId, { signal: abort.signal });
|
||||
return res.files
|
||||
.filter((file) => isMarkdownPath(file.path) || isMarkdownPath(file.name))
|
||||
.map((file) => ({
|
||||
path: file.path,
|
||||
name: file.name || filenameOf(file.path),
|
||||
threadId,
|
||||
content: null,
|
||||
updatedAt: file.modifiedAt,
|
||||
agentName,
|
||||
}));
|
||||
} catch {
|
||||
return [] as MdFileHit[];
|
||||
}
|
||||
}),
|
||||
).then((groups) => {
|
||||
if (!cancelled) setRemoteFiles(groups.flat());
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
abort.abort();
|
||||
};
|
||||
}, [threadEntries]);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
collectEightBfAnalysisResults({
|
||||
...input,
|
||||
remoteFiles,
|
||||
}),
|
||||
[
|
||||
input.chain,
|
||||
input.coordinatorName,
|
||||
input.dialogues,
|
||||
input.seatStubMessages,
|
||||
input.summaryReport,
|
||||
input.threadIds,
|
||||
remoteFiles,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { emitWujieEvent, isWujieEnv } from "@/core/embed/wujie";
|
||||
import { getEightBfRoundtableBackUrl } from "@/eightbf-roundtable/api/task-context";
|
||||
|
||||
export const EIGHTBF_PARENT_RETURN_SOURCE = "deerflow-8bf-roundtable";
|
||||
export const EIGHTBF_PARENT_RETURN_TYPE = "deerflow:8bf-roundtable:return";
|
||||
export const EIGHTBF_BRIDGE_HOST_SOURCE = "code-zk-roundtable-host";
|
||||
export const EIGHTBF_BRIDGE_INIT_TYPE = "code-zk:8bf-roundtable-bridge:init";
|
||||
export const EIGHTBF_BRIDGE_READY_TYPE = "deerflow:8bf-roundtable:bridge-ready";
|
||||
|
||||
export interface EightBfParentReturnMessage {
|
||||
source: typeof EIGHTBF_PARENT_RETURN_SOURCE;
|
||||
type: typeof EIGHTBF_PARENT_RETURN_TYPE;
|
||||
payload: {
|
||||
route: string | null;
|
||||
fallback: "history-back";
|
||||
};
|
||||
}
|
||||
|
||||
type EightBfBridgeInitMessage = {
|
||||
source: typeof EIGHTBF_BRIDGE_HOST_SOURCE;
|
||||
type: typeof EIGHTBF_BRIDGE_INIT_TYPE;
|
||||
};
|
||||
|
||||
/**
|
||||
* `parentReturnTo` belongs to the embedding application. Keep it a route/hash
|
||||
* path so same-origin fallback cannot become an open redirect.
|
||||
*/
|
||||
export function parentReturnRoute(value: string | null | undefined): string | null {
|
||||
const route = value?.trim();
|
||||
if (!route || route.length > 2048) return null;
|
||||
if ((!route.startsWith("/") && !route.startsWith("#")) || route.startsWith("//") || route.includes("\\")) {
|
||||
return null;
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
function isInIframe(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.parent !== window;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const embeddingBridgePort = { current: null as MessagePort | null };
|
||||
|
||||
/**
|
||||
* Accept a MessagePort from code-zk. Wujie can lift this iframe into the
|
||||
* outer host document, so `window.parent` is no longer code-zk. The port
|
||||
* still talks to the Vue page that created the channel.
|
||||
*/
|
||||
export function useEightBfParentReturnBridge(): void {
|
||||
useEffect(() => {
|
||||
const receiveBridgePort = (event: MessageEvent<EightBfBridgeInitMessage>) => {
|
||||
const message = event.data;
|
||||
const bridgePort = event.ports?.[0];
|
||||
if (
|
||||
message?.source !== EIGHTBF_BRIDGE_HOST_SOURCE
|
||||
|| message?.type !== EIGHTBF_BRIDGE_INIT_TYPE
|
||||
|| !bridgePort
|
||||
) {
|
||||
return;
|
||||
}
|
||||
embeddingBridgePort.current?.close();
|
||||
embeddingBridgePort.current = bridgePort;
|
||||
bridgePort.start?.();
|
||||
bridgePort.postMessage({
|
||||
source: EIGHTBF_PARENT_RETURN_SOURCE,
|
||||
type: EIGHTBF_BRIDGE_READY_TYPE,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("message", receiveBridgePort);
|
||||
return () => {
|
||||
window.removeEventListener("message", receiveBridgePort);
|
||||
embeddingBridgePort.current?.close();
|
||||
embeddingBridgePort.current = null;
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the embedding Vue host to leave this iframe.
|
||||
*
|
||||
* Topology: Wujie host → code-zk → DeerFlow iframe. Wujie may reparent the
|
||||
* iframe so native `window.parent.postMessage` never reaches code-zk.
|
||||
* MessageChannel is the channel that survives that. Native postMessage is
|
||||
* only the fallback for a plain iframe.
|
||||
*/
|
||||
export function requestEightBfParentReturn(route: string | null): boolean {
|
||||
const message: EightBfParentReturnMessage = {
|
||||
source: EIGHTBF_PARENT_RETURN_SOURCE,
|
||||
type: EIGHTBF_PARENT_RETURN_TYPE,
|
||||
payload: { route, fallback: "history-back" },
|
||||
};
|
||||
|
||||
if (typeof window === "undefined") return false;
|
||||
|
||||
const bridgePort = embeddingBridgePort.current;
|
||||
if (bridgePort) {
|
||||
try {
|
||||
bridgePort.postMessage(message);
|
||||
return true;
|
||||
} catch {
|
||||
// Host unmounted/reloaded. Fall through to native bridges.
|
||||
}
|
||||
}
|
||||
|
||||
if (isInIframe()) {
|
||||
window.parent.postMessage(message, "*");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isWujieEnv()) {
|
||||
emitWujieEvent("8bf-roundtable:return", message.payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
const url = getEightBfRoundtableBackUrl().trim();
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,11 +1,31 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Loader2, Save, Settings2, X } from "lucide-react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Loader2, Save, X } from "lucide-react";
|
||||
|
||||
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||||
import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard";
|
||||
import {
|
||||
EightBfStep1Panel,
|
||||
EightBfStep1Sidebar,
|
||||
EightBfStep2SidebarHeader,
|
||||
} from "@/eightbf-roundtable/components/EightBfStep1Workspace";
|
||||
import {
|
||||
buildEightBfTaskContext,
|
||||
createEightBfFallbackTask,
|
||||
fetchEightBfTask,
|
||||
fetchEightBfTaskDictionaries,
|
||||
normalizeEightBfTask,
|
||||
createEightBfTaskProcessReport,
|
||||
updateEightBfTaskProcessReport,
|
||||
type EightBfTask,
|
||||
type EightBfTaskDictionaries,
|
||||
} from "@/eightbf-roundtable/api/task-context";
|
||||
import type { TaskArtifactSubmissionConfig } from "@/roundtable-planning/lib/task-artifact-submission";
|
||||
import { PositionBusinessChainFlow } from "@/position-roundtable/components/PositionBusinessChainFlow";
|
||||
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
|
||||
import { listChains } from "@/roundtable-planning/api/chains";
|
||||
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
|
||||
import { useEightBfParentReturnBridge } from "@/eightbf-roundtable/lib/parent-return";
|
||||
import {
|
||||
listBusinessMappings,
|
||||
updateBusinessMapping,
|
||||
@@ -25,11 +45,30 @@ const BUSINESS_CODE = "8BF";
|
||||
* 不引用岗位会商的页面、会话或接口。
|
||||
*/
|
||||
export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) {
|
||||
useEightBfParentReturnBridge();
|
||||
const auth = getStoredAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
const taskId = useMemo(
|
||||
() => searchParams.get("taskId")?.trim() || "",
|
||||
[searchParams],
|
||||
);
|
||||
// 8BF 公共业务链由指定账号维护,不依赖外部透传的管理员角色。
|
||||
// 与敏感词管理使用同一可信账户展示名规则(登录邮箱 @ 前缀)。
|
||||
const canConfigureChain = getAccountDisplayName(auth).toLowerCase() === "lqq";
|
||||
const storageTaskId = useMemo(() => `8bf:${auth?.user_id || "anonymous"}`, [auth?.user_id]);
|
||||
const storageTaskId = useMemo(
|
||||
() => `8bf:${auth?.user_id || "anonymous"}:${taskId || "anonymous"}`,
|
||||
[auth?.user_id, taskId],
|
||||
);
|
||||
const [task, setTask] = useState<EightBfTask | null>(() => (
|
||||
taskId ? normalizeEightBfTask({}, taskId) : null
|
||||
));
|
||||
const [taskDictionaries, setTaskDictionaries] = useState<EightBfTaskDictionaries>({
|
||||
priority: [],
|
||||
direction: [],
|
||||
status: [],
|
||||
});
|
||||
const [taskLoading, setTaskLoading] = useState(Boolean(taskId));
|
||||
const [taskError, setTaskError] = useState<string | null>(null);
|
||||
const [mapping, setMapping] = useState<BusinessMapping | null>(null);
|
||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -41,6 +80,64 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
||||
() => chains.find((chain) => chain.id === mapping?.chainId) ?? null,
|
||||
[chains, mapping?.chainId],
|
||||
);
|
||||
const taskContext = useMemo(
|
||||
() => buildEightBfTaskContext(task, taskDictionaries),
|
||||
[task, taskDictionaries],
|
||||
);
|
||||
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
|
||||
() => taskId ? {
|
||||
taskId,
|
||||
position: 0,
|
||||
createProcessReport: createEightBfTaskProcessReport,
|
||||
updateProcessReport: updateEightBfTaskProcessReport,
|
||||
} : undefined,
|
||||
[taskId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) {
|
||||
setTask(null);
|
||||
setTaskError(null);
|
||||
setTaskLoading(false);
|
||||
setTaskDictionaries({ priority: [], direction: [], status: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setTaskLoading(true);
|
||||
setTaskError(null);
|
||||
setTask(normalizeEightBfTask({}, taskId));
|
||||
let pendingRequests = 2;
|
||||
const markRequestFinished = () => {
|
||||
pendingRequests -= 1;
|
||||
if (!cancelled && pendingRequests === 0) setTaskLoading(false);
|
||||
};
|
||||
// Keep detail and dictionary requests independent: a missing dictionary
|
||||
// must not prevent the task card or task context from being available.
|
||||
void fetchEightBfTask(taskId)
|
||||
.then((nextTask) => {
|
||||
if (!cancelled) setTask(nextTask);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
console.warn("[8bf] 任务详情请求失败,使用页面示例任务", error);
|
||||
setTask(createEightBfFallbackTask(taskId));
|
||||
setTaskError(null);
|
||||
})
|
||||
.finally(markRequestFinished);
|
||||
void fetchEightBfTaskDictionaries()
|
||||
.then((nextDictionaries) => {
|
||||
if (!cancelled) setTaskDictionaries(nextDictionaries);
|
||||
})
|
||||
.catch(() => {
|
||||
// fetchEightBfTaskDictionaries already degrades each dictionary to an
|
||||
// empty list; this guard protects future adapter changes.
|
||||
})
|
||||
.finally(markRequestFinished);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
const loadConfiguration = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -94,18 +191,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
||||
}
|
||||
}, [canConfigureChain, chains, selectedChainId]);
|
||||
|
||||
const openChainSettings = useCallback(() => {
|
||||
if (canConfigureChain) setIsSettingsOpen(true);
|
||||
}, [canConfigureChain]);
|
||||
|
||||
const toolbarSlot = (
|
||||
<>
|
||||
{canConfigureChain && (
|
||||
<button
|
||||
type="button"
|
||||
className="eightbf-roundtable__chain-settings-button"
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
配置业务链
|
||||
</button>
|
||||
)}
|
||||
{canConfigureChain && isSettingsOpen && (
|
||||
<div className="eightbf-roundtable__settings-mask" role="presentation">
|
||||
<section
|
||||
@@ -162,7 +253,70 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
||||
lockedChainId={mapping?.chainId ?? null}
|
||||
lockBusinessChain
|
||||
storageTaskId={storageTaskId}
|
||||
taskContext={taskContext as Record<string, unknown> | null}
|
||||
entryRailTaskId={taskId || null}
|
||||
entryRailTaskCardSlot={taskId ? (
|
||||
<EightBfTaskCard
|
||||
task={task}
|
||||
dictionaries={taskDictionaries}
|
||||
loading={taskLoading}
|
||||
error={taskError}
|
||||
/>
|
||||
) : undefined}
|
||||
toolbarSlot={toolbarSlot}
|
||||
hideHeaderSaveDraft
|
||||
hideHeaderSettings
|
||||
newTaskLabel="新建会话"
|
||||
hideDefaultStep1RightSidebar
|
||||
step2HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 多智能体会商`}
|
||||
step3HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 方案总结`}
|
||||
renderStep1={(props) => (
|
||||
<EightBfStep1Panel
|
||||
{...props}
|
||||
chain={configuredChain}
|
||||
canConfigureChain={canConfigureChain}
|
||||
onConfigureChain={openChainSettings}
|
||||
/>
|
||||
)}
|
||||
renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => (
|
||||
<EightBfStep1Sidebar
|
||||
intentReady={intentReady}
|
||||
onCollapse={onCollapse}
|
||||
taskCard={taskId ? (
|
||||
<EightBfTaskCard
|
||||
task={task}
|
||||
dictionaries={taskDictionaries}
|
||||
loading={taskLoading}
|
||||
error={taskError}
|
||||
onCollapse={onCollapse}
|
||||
/>
|
||||
) : undefined}
|
||||
businessChain={
|
||||
<PositionBusinessChainFlow
|
||||
chain={configuredChain}
|
||||
loading={isLoading}
|
||||
embedded
|
||||
submittedSeatKeys={submittedSeatKeys}
|
||||
onSeatClick={onSeatClick}
|
||||
className="position-roundtable-intent-delivery-flow eightbf-roundtable__business-chain-flow"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
renderStep2LeftSidebarHeader={({ intentReady, onCollapse }) => (
|
||||
<EightBfStep2SidebarHeader
|
||||
intentReady={intentReady}
|
||||
taskCard={taskId ? (
|
||||
<EightBfTaskCard
|
||||
task={task}
|
||||
dictionaries={taskDictionaries}
|
||||
loading={taskLoading}
|
||||
error={taskError}
|
||||
onCollapse={onCollapse}
|
||||
/>
|
||||
) : undefined}
|
||||
/>
|
||||
)}
|
||||
entryRailBusinessChainSlot={
|
||||
<PositionBusinessChainFlow
|
||||
chain={configuredChain}
|
||||
@@ -171,6 +325,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
||||
/>
|
||||
}
|
||||
rootClassName="eightbf-roundtable"
|
||||
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -17,14 +17,404 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .position-roundtable-intent-header-actions button:nth-child(2) {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .position-roundtable-intent-header-actions .eightbf-roundtable__back-button {
|
||||
width: 86.04px;
|
||||
height: 28px;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
background: linear-gradient(109.46deg, rgba(0, 98, 217, 0.59) 3.83%, rgba(0, 98, 217, 0.3) 98.23%);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .position-roundtable-intent-header-actions .eightbf-roundtable__back-button img {
|
||||
width: 14px;
|
||||
height: 12px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable__chain-status-label { color: #0f5f56; font-weight: 700; }
|
||||
.eightbf-roundtable .eightbf-roundtable__chain-status-value { color: #134e4a; font-weight: 600; }
|
||||
.eightbf-roundtable .eightbf-roundtable__configured-icon { color: var(--eightbf-accent); }
|
||||
|
||||
/* 8BF 左上业务链区域:复用岗位会商的纵向节点、点阵画布与放大查看交互。 */
|
||||
.eightbf-roundtable .eightbf-roundtable__business-chain-flow {
|
||||
height: 500px;
|
||||
min-height: 460px;
|
||||
height: 430px;
|
||||
min-height: 430px;
|
||||
}
|
||||
|
||||
/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */
|
||||
/* 8BF 外部 taskId:左上任务卡。仅挂在 8BF 根节点下,避免改动原会商任务卡。 */
|
||||
.eightbf-roundtable .eightbf-roundtable__task-card {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable__task-card-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable__task-card-tags {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable__task-card-tags span {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable__task-card-description {
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #94a3b8; }
|
||||
.eightbf-roundtable .eightbf-roundtable__task-card-error { margin: 8px 0 0; color: #fca5a5; font-size: 12px; line-height: 1.5; }
|
||||
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #8fa8c5; }
|
||||
|
||||
/*
|
||||
* 8BF 第一阶段直接采用「岗位多智能体会商」意图工作台的三栏尺寸和深蓝表面。
|
||||
* 专属类只改变 8BF;岗位会商原有页面继续由 intent-understanding.css 管理。
|
||||
*/
|
||||
.eightbf-roundtable .eightbf-roundtable__header-brand {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable__header-brand-icon {
|
||||
display: inline-flex;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
background: rgb(43 143 254 / 16%);
|
||||
color: #2b8ffe;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable__header-brand-icon svg { width: 23px; height: 23px; }
|
||||
.eightbf-roundtable .eightbf-roundtable__header-brand h1 { margin: 0; color: #1e293b; font-size: 16px; font-weight: 700; }
|
||||
.eightbf-roundtable .eightbf-roundtable__header-brand p { margin: 2px 0 0; color: #64748b; font-size: 12px; }
|
||||
.eightbf-roundtable .eightbf-roundtable__header-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-left: 8px;
|
||||
border: 1px solid rgb(16 185 129 / 30%);
|
||||
border-radius: 999px;
|
||||
background: rgb(16 185 129 / 8%);
|
||||
color: #047857;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable__header-chain {
|
||||
display: inline-flex;
|
||||
min-width: 230px;
|
||||
max-width: 360px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__header-chain strong { min-width: 0; flex: 1; overflow: hidden; color: #334155; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.eightbf-roundtable .eightbf-roundtable__header-chain svg { width: 16px; height: 16px; flex: 0 0 auto; }
|
||||
|
||||
/* 参考工作台的直角圆角层级:内容面、消息区与输入框均为 4px。 */
|
||||
.eightbf-roundtable #middle-viewport { border-radius: 4px; }
|
||||
.eightbf-roundtable .position-roundtable-intent-panel > div:first-child,
|
||||
.eightbf-roundtable .position-roundtable-intent-composer-frame,
|
||||
.eightbf-roundtable .position-roundtable-intent-config-pane { border-radius: 4px; }
|
||||
.eightbf-roundtable .position-roundtable-intent-panel > div:first-child { border-radius: 4px 4px 0 0; }
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable__step1-sidebar {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* 8BF 首步左栏不允许横向滚动;流程图的横向超出由画布自身裁切。 */
|
||||
.eightbf-roundtable[data-roundtable-step="1"] #left-sidebar,
|
||||
.eightbf-roundtable .eightbf-roundtable__step1-sidebar,
|
||||
.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar {
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
.eightbf-roundtable[data-roundtable-step="1"] #left-sidebar::-webkit-scrollbar:horizontal,
|
||||
.eightbf-roundtable .eightbf-roundtable__step1-sidebar::-webkit-scrollbar:horizontal,
|
||||
.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar::-webkit-scrollbar:horizontal {
|
||||
display: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable__intent-sidebar-empty {
|
||||
margin: 0;
|
||||
padding: 12px 16px 18px;
|
||||
color: #aaceff;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
/* The flow is embedded into the intent surface: only its own header line and
|
||||
dotted canvas are visible. The surrounding intent panel owns the border. */
|
||||
.eightbf-roundtable .position-roundtable-intent-delivery-flow {
|
||||
/* Reference flow has horizontal gutters. Reduce the width by those gutters
|
||||
so the right-side magnifier is never clipped by the left-rail crop. */
|
||||
width: calc(100% - 24px) !important;
|
||||
height: 580px !important;
|
||||
min-height: 580px !important;
|
||||
flex-basis: 580px !important;
|
||||
}
|
||||
.eightbf-roundtable .position-roundtable-intent-delivery-flow > div:first-child {
|
||||
margin: 0 0 12px !important;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-list-workspace {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
background: #032650;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__configure-chain-button {
|
||||
display: inline-flex;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid rgb(115 200 255 / 52%);
|
||||
border-radius: 4px;
|
||||
background: rgb(0 98 217 / 22%);
|
||||
color: #dceaff;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__configure-chain-button:hover { background: rgb(0 98 217 / 40%); }
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
padding: 16px;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-title b { font-weight: 500; }
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-title i {
|
||||
min-width: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
color: #aaceff;
|
||||
background: rgb(21 66 128 / 58%);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-search {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgb(43 143 254 / 40%);
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
background: rgb(0 12 28 / 40%);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-search::placeholder { color: rgb(255 255 255 / 40%); }
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-search:focus { border-color: #45c3f3; }
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-list {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: 40px 1fr;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgb(43 143 254 / 20%);
|
||||
border-radius: 8px;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
background: rgb(0 98 217 / 8%);
|
||||
cursor: pointer;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-card:hover {
|
||||
border-color: rgb(43 143 254 / 60%);
|
||||
background: rgb(0 98 217 / 22%);
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-card:hover .eightbf-roundtable__agent-selector-bar {
|
||||
background: #45c3f3;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-avatar {
|
||||
display: grid;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #1675d1, #06447f);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-main strong {
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-main p {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: #9ebfe2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-bar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 6px;
|
||||
width: 6px;
|
||||
height: 32px;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
transform: translateY(-50%);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-empty {
|
||||
display: flex;
|
||||
min-height: 200px;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 32px 16px;
|
||||
color: rgb(255 255 255 / 60%);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-empty p { margin: 0; }
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-empty button {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid rgb(43 143 254 / 60%);
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
background: rgb(0 98 217 / 18%);
|
||||
cursor: pointer;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-empty button:hover {
|
||||
background: rgb(0 98 217 / 32%);
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar { display: flex; height: 48px; align-items: center; justify-content: space-between; border-bottom: 1px solid rgb(43 143 254 / 35%); padding: 0 12px; }
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar > button:first-child { display: inline-flex; align-items: center; gap: 2px; color: #aaceff; font-size: 12px; }
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar > button:first-child:hover { color: #fff; }
|
||||
.eightbf-roundtable .position-roundtable-intent-config-pane-inner {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable-intent-results-scroll {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
padding: 10px;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__intent-results-empty { margin: 0; color: #aaceff; font-size: 13px; line-height: 1.8; }
|
||||
.eightbf-roundtable .eightbf-roundtable__intent-results-list { display: grid; gap: 10px; }
|
||||
.eightbf-roundtable .eightbf-roundtable__intent-results-list section { border: 1px solid rgb(43 143 254 / 32%); border-radius: 4px; background: rgb(0 12 28 / 20%); padding: 12px; }
|
||||
.eightbf-roundtable .eightbf-roundtable__intent-results-list h3 { margin: 0 0 8px; color: #a2e1f9; font-size: 14px; font-weight: 500; }
|
||||
.eightbf-roundtable .eightbf-roundtable__intent-results-list ul { margin: 0; padding-left: 17px; color: #dceaff; font-size: 12px; line-height: 1.8; }
|
||||
|
||||
/* Screenshot-equivalent dark workbench shell. */
|
||||
.dark .eightbf-roundtable { background: #061b3b; color: #e7f0ff; }
|
||||
.dark .eightbf-roundtable > header { min-height: 64px; border-color: rgb(43 143 254 / 45%); background: #032650; box-shadow: none; }
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__header-brand h1 { color: #e7f0ff; }
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__header-brand p { color: #8aa0c4; }
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__header-status { border-color: rgb(101 214 198 / 45%); background: rgb(15 118 110 / 24%); color: #9ef0df; }
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__header-chain { border-color: rgb(143 196 255 / 44%); background: #082b57; color: #aaceff; }
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__header-chain strong { color: #e7f0ff; }
|
||||
.dark .eightbf-roundtable #new-task-btn { border: 1px solid rgb(115 200 255 / 45%); border-radius: 4px; background: transparent; color: #e7f0ff; box-shadow: none; }
|
||||
.dark .eightbf-roundtable #new-task-btn:hover { background: rgb(0 98 217 / 24%); }
|
||||
.dark .eightbf-roundtable[data-roundtable-step="1"] > main { gap: 0; padding: 12px 0; }
|
||||
.dark .eightbf-roundtable[data-roundtable-step="1"] #middle-viewport { border-color: rgb(43 143 254 / 50%); border-radius: 4px; background: #032650; box-shadow: none; }
|
||||
.dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar {
|
||||
width: 374px;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.dark .eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar {
|
||||
border-color: rgb(0 110 254 / 50%);
|
||||
background: #032650;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.eightbf-roundtable .eightbf-roundtable__header-chain { min-width: 180px; max-width: 220px; }
|
||||
.dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar { width: 326px; }
|
||||
.eightbf-roundtable .position-roundtable-intent-config-pane { --prt-intent-config-width: 320px; }
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar { width: 294px; }
|
||||
.eightbf-roundtable .eightbf-roundtable__header-status,
|
||||
.eightbf-roundtable .eightbf-roundtable__header-brand p { display: none; }
|
||||
.eightbf-roundtable .eightbf-roundtable__header-chain { display: none; }
|
||||
.eightbf-roundtable .position-roundtable-intent-config-pane { --prt-intent-config-width: min(360px, 42vw); }
|
||||
}
|
||||
@@ -14,4 +14,123 @@
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable__chain-settings-button:hover { background: #115e59; }
|
||||
|
||||
/* 可在此继续覆盖会商消息、席位卡片、流程节点及展开按钮的样式。 */
|
||||
/* 展开时工作区紧贴左栏;收起时恢复通用的 16px 间距,避免窄栏贴着内容区。 */
|
||||
.dark .eightbf-roundtable[data-roundtable-step="2"] > main[data-step2-sidebar-expanded="true"] {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.dark .eightbf-roundtable[data-roundtable-step="2"] > main[data-step2-sidebar-expanded="false"] {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 第二步左栏顶部直接复用第一步任务卡和意图理解区,但不复用第一步整列的
|
||||
flex: 1 行为;内容高度随意图实际内容收拢,剩余内容交给左栏统一滚动。 */
|
||||
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable__step2-sidebar-header {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable__step2-sidebar-header
|
||||
.eightbf-roundtable__intent-delivery-sidebar {
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* 角色面板与列表均依内容撑开,再交由左栏的单一滚动容器承载;否则取消
|
||||
内层滚动后,角色项会从固定高度的父面板溢出并覆盖下方内容。 */
|
||||
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .eightbf-roundtable__step2-sidebar-header + div {
|
||||
flex: 0 0 auto !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
|
||||
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .custom-scrollbar {
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* 8BF 第二步沿用第一步的 4px 面板语言,避免通用会商的 rounded-2xl 混入。 */
|
||||
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .rounded-2xl {
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
/* 展开态与第一步左栏保持同一宽度和内边距。注意不能无条件锁定宽度,否则会
|
||||
覆盖收起态的 w-[56px],使顶部图标按钮看起来无效。 */
|
||||
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] {
|
||||
width: 374px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* 收起态也要预留与展开态一致的左右边距。此前 56px 宽度叠加内边距后,图标卡只剩
|
||||
约 36px 可用空间,导致图标与边框被挤变形。80px 壳体保留 56px 的完整点击区域。 */
|
||||
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="false"] {
|
||||
width: 80px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* 第二步左侧各级内容卡沿用 8BF 的 4px 圆角;头像、状态徽标仍保持圆形。 */
|
||||
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .rounded-2xl,
|
||||
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .eightbf-roundtable__step2-sidebar-header > .rounded-2xl,
|
||||
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .roundtable-step2-role-row,
|
||||
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .roundtable-step2-human-row,
|
||||
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .roundtable-step2-run-card {
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.dark .eightbf-roundtable[data-roundtable-step="2"] > main {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.dark .eightbf-roundtable[data-roundtable-step="2"] #middle-viewport {
|
||||
border-color: rgb(43 143 254 / 50%);
|
||||
border-radius: 4px;
|
||||
background: #032650;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable-step2-chat {
|
||||
min-height: 0;
|
||||
background: #032650;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable-step2-log {
|
||||
background: #032650;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable-step2-chat .position-roundtable-intent-header-actions .eightbf-roundtable__suspend-button {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable-step2-chat .relative.flex-1 > .shrink-0 {
|
||||
background: #032650;
|
||||
border-color: rgb(43 143 254 / 28%);
|
||||
}
|
||||
|
||||
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-log .text-slate-700,
|
||||
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-log .text-slate-500 {
|
||||
color: #aaceff;
|
||||
}
|
||||
|
||||
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-log .text-slate-400 {
|
||||
color: #8aa0c4;
|
||||
}
|
||||
|
||||
/* 第二步输入区直接复用第一步 PositionIntentInput;共识动作也使用其
|
||||
#enter-roundtable-btn 规则,保证和「确认意图并启动业务链」完全同款。 */
|
||||
.dark .eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-input {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { width: 326px; }
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { width: 294px; }
|
||||
}
|
||||
@@ -76,4 +76,70 @@
|
||||
background: #21958a;
|
||||
}
|
||||
|
||||
/* 可在此继续覆盖第 3 步报告、流程图与放大弹窗的样式。 */
|
||||
/* 第三步:8BF 左栏与第一步相同,对话区去掉原外层圆角白框,沿用第一、二步深蓝面板。 */
|
||||
.dark .eightbf-roundtable[data-roundtable-step="3"] > main {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.dark .eightbf-roundtable[data-roundtable-step="3"] > main[data-step2-sidebar-expanded="true"] {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.dark .eightbf-roundtable[data-roundtable-step="3"] > main[data-step2-sidebar-expanded="false"] {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dark .eightbf-roundtable[data-roundtable-step="3"] #middle-viewport {
|
||||
border-color: rgb(43 143 254 / 50%);
|
||||
border-radius: 4px;
|
||||
background: #032650;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="true"] {
|
||||
width: 374px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="false"] {
|
||||
width: 80px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable-step3-chat {
|
||||
min-height: 0;
|
||||
background: #032650;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable-step3-log {
|
||||
background: #032650;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable-step3-chat .position-roundtable-intent-header-actions button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-log .text-slate-700,
|
||||
.eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-log .text-slate-500 {
|
||||
color: #aaceff;
|
||||
}
|
||||
|
||||
.eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-log .text-slate-400 {
|
||||
color: #8aa0c4;
|
||||
}
|
||||
|
||||
/* PositionIntentInput is the shared Step 1/2 composer; Step 3 uses the exact same frame. */
|
||||
.dark .eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-input {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="true"] { width: 326px; }
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="true"] { width: 294px; }
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Generate a client-only id without assuming a secure browser context.
|
||||
*
|
||||
* `crypto.randomUUID()` is unavailable in several browsers when the app is
|
||||
* opened directly through an `http://<public-ip>:<port>` address. Request and
|
||||
* UI ids need uniqueness, not a security token, so a timestamp/random fallback
|
||||
* keeps those flows usable until the deployment is served over HTTPS.
|
||||
*/
|
||||
export function createClientId(prefix?: string): string {
|
||||
const cryptoApi = typeof globalThis === "object" ? globalThis.crypto : undefined;
|
||||
let id: string | undefined;
|
||||
|
||||
if (typeof cryptoApi?.randomUUID === "function") {
|
||||
id = cryptoApi.randomUUID();
|
||||
} else if (typeof cryptoApi?.getRandomValues === "function") {
|
||||
try {
|
||||
const values = new Uint32Array(4);
|
||||
cryptoApi.getRandomValues(values);
|
||||
id = Array.from(values, (value) => value.toString(16).padStart(8, "0")).join("");
|
||||
} catch {
|
||||
// Some restricted browser contexts expose crypto but reject random data.
|
||||
}
|
||||
}
|
||||
|
||||
id ??= `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
|
||||
return prefix ? `${prefix}-${id}` : id;
|
||||
}
|
||||
@@ -119,6 +119,10 @@ export interface DocumentRewriteRequest {
|
||||
instruction: string;
|
||||
style?: DocumentRewriteStyle;
|
||||
modelName?: string;
|
||||
/** Generate locally saved illustration assets after the prose rewrite. */
|
||||
generateImages?: boolean;
|
||||
/** Bounded server-side to 1–4. Ignored unless generateImages is true. */
|
||||
maxGeneratedImages?: number;
|
||||
expectedHash?: string;
|
||||
}
|
||||
|
||||
@@ -140,6 +144,8 @@ export interface DocumentRewriteSummary {
|
||||
};
|
||||
requestedOptimizations?: string[];
|
||||
validationWarnings?: string[];
|
||||
generatedImageCount?: number;
|
||||
requestedImageCount?: number;
|
||||
}
|
||||
|
||||
export type DocumentRewriteStreamEvent =
|
||||
@@ -156,6 +162,15 @@ export type DocumentRewriteStreamEvent =
|
||||
| { type: "rewrite_delta"; delta: string; charCount: number }
|
||||
/** Durable full-draft recovery point used after an SSE reconnect. */
|
||||
| { type: "rewrite_checkpoint"; content: string; charCount: number }
|
||||
| { type: "illustration_started"; section: string; position: number; total: number }
|
||||
| {
|
||||
type: "illustration_generated";
|
||||
section: string;
|
||||
position: number;
|
||||
total: number;
|
||||
generatedCount?: number;
|
||||
}
|
||||
| { type: "illustration_completed"; generatedCount: number; requestedCount: number; warnings: string[] }
|
||||
| { type: "validation_started" }
|
||||
| { type: "validation_completed"; passed: boolean; warnings: string[] }
|
||||
| { type: "comparison_ready"; summary: DocumentRewriteSummary }
|
||||
@@ -177,6 +192,8 @@ export interface DurableDocumentRewriteJob {
|
||||
instruction: string;
|
||||
style: DocumentRewriteStyle;
|
||||
modelName: string | null;
|
||||
generateImages: boolean;
|
||||
maxGeneratedImages: number;
|
||||
originalContent: string;
|
||||
};
|
||||
}
|
||||
@@ -229,6 +246,30 @@ async function readDocumentRewriteEvent(
|
||||
content: String(parsed.content ?? ""),
|
||||
charCount: Number(parsed.charCount ?? 0),
|
||||
};
|
||||
case "illustration_started":
|
||||
return {
|
||||
type: "illustration_started",
|
||||
section: String(parsed.section ?? ""),
|
||||
position: Number(parsed.position ?? 0),
|
||||
total: Number(parsed.total ?? 0),
|
||||
};
|
||||
case "illustration_generated":
|
||||
return {
|
||||
type: "illustration_generated",
|
||||
section: String(parsed.section ?? ""),
|
||||
position: Number(parsed.position ?? 0),
|
||||
total: Number(parsed.total ?? 0),
|
||||
generatedCount: Number(parsed.generatedCount ?? parsed.position ?? 0),
|
||||
};
|
||||
case "illustration_completed":
|
||||
return {
|
||||
type: "illustration_completed",
|
||||
generatedCount: Number(parsed.generatedCount ?? 0),
|
||||
requestedCount: Number(parsed.requestedCount ?? 0),
|
||||
warnings: Array.isArray(parsed.warnings)
|
||||
? parsed.warnings.filter((item): item is string => typeof item === "string")
|
||||
: [],
|
||||
};
|
||||
case "validation_started":
|
||||
return { type: "validation_started" };
|
||||
case "validation_completed":
|
||||
@@ -362,6 +403,8 @@ export async function listDurableDocumentRewrites(
|
||||
instruction: String(request.instruction ?? ""),
|
||||
style: (request.style ?? "professional") as DocumentRewriteStyle,
|
||||
modelName: typeof request.modelName === "string" ? request.modelName : null,
|
||||
generateImages: request.generateImages === true,
|
||||
maxGeneratedImages: Number(request.maxGeneratedImages ?? 2),
|
||||
originalContent: String(request.originalContent ?? ""),
|
||||
},
|
||||
}];
|
||||
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
cancelDurableDocumentRewrite,
|
||||
streamDocumentRewrite,
|
||||
} from "../api/rewrite";
|
||||
import { InlineThinkTagFilter, stripInlineThinkTags } from "@/core/streamdown/inline-think";
|
||||
|
||||
export type DocumentRewriteStage =
|
||||
| "idle"
|
||||
| "snapshot"
|
||||
| "requirements"
|
||||
| "writing"
|
||||
| "illustrating"
|
||||
| "validation"
|
||||
| "committing"
|
||||
| "completed"
|
||||
@@ -27,6 +29,8 @@ export interface DocumentRewriteRun {
|
||||
draft: string;
|
||||
/** Live, compact execution plan produced before Markdown generation starts. */
|
||||
requirementsPlan: string;
|
||||
/** Ephemeral model reasoning, deliberately kept out of the Markdown draft. */
|
||||
thinkingText: string;
|
||||
originalCharCount: number;
|
||||
writtenCharCount: number;
|
||||
fileName: string;
|
||||
@@ -41,6 +45,7 @@ const INITIAL_RUN: DocumentRewriteRun = {
|
||||
stage: "idle",
|
||||
draft: "",
|
||||
requirementsPlan: "",
|
||||
thinkingText: "",
|
||||
originalCharCount: 0,
|
||||
writtenCharCount: 0,
|
||||
fileName: "",
|
||||
@@ -67,6 +72,10 @@ export function useDocumentRewrite() {
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const pendingRequirementsDeltaRef = useRef("");
|
||||
const requirementsFrameRef = useRef<number | null>(null);
|
||||
const pendingThinkingRef = useRef("");
|
||||
const thinkingFrameRef = useRef<number | null>(null);
|
||||
const draftThinkFilterRef = useRef(new InlineThinkTagFilter());
|
||||
const requirementsThinkFilterRef = useRef(new InlineThinkTagFilter());
|
||||
|
||||
const flushDelta = useCallback(() => {
|
||||
if (frameRef.current !== null) {
|
||||
@@ -86,27 +95,6 @@ export function useDocumentRewrite() {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const enqueueDelta = useCallback((delta: string, writtenCharCount: number) => {
|
||||
if (!delta) return;
|
||||
pendingDeltaRef.current += delta;
|
||||
pendingWrittenCharCountRef.current = writtenCharCount;
|
||||
if (frameRef.current !== null) return;
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = null;
|
||||
const pending = pendingDeltaRef.current;
|
||||
const pendingWrittenCharCount = pendingWrittenCharCountRef.current;
|
||||
pendingDeltaRef.current = "";
|
||||
pendingWrittenCharCountRef.current = null;
|
||||
if (!pending) return;
|
||||
setRun((previous) => ({
|
||||
...previous,
|
||||
stage: "writing",
|
||||
draft: previous.draft + pending,
|
||||
...(pendingWrittenCharCount !== null ? { writtenCharCount: pendingWrittenCharCount } : {}),
|
||||
}));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const flushRequirements = useCallback(() => {
|
||||
if (requirementsFrameRef.current !== null) {
|
||||
cancelAnimationFrame(requirementsFrameRef.current);
|
||||
@@ -124,9 +112,35 @@ export function useDocumentRewrite() {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const enqueueRequirementsDelta = useCallback((delta: string) => {
|
||||
const flushThinking = useCallback(() => {
|
||||
if (thinkingFrameRef.current !== null) {
|
||||
cancelAnimationFrame(thinkingFrameRef.current);
|
||||
thinkingFrameRef.current = null;
|
||||
}
|
||||
const delta = pendingThinkingRef.current;
|
||||
pendingThinkingRef.current = "";
|
||||
if (!delta) return;
|
||||
pendingRequirementsDeltaRef.current += delta;
|
||||
setRun((previous) => ({ ...previous, thinkingText: previous.thinkingText + delta }));
|
||||
}, []);
|
||||
|
||||
const enqueueThinking = useCallback((delta: string) => {
|
||||
if (!delta) return;
|
||||
pendingThinkingRef.current += delta;
|
||||
if (thinkingFrameRef.current !== null) return;
|
||||
thinkingFrameRef.current = requestAnimationFrame(() => {
|
||||
thinkingFrameRef.current = null;
|
||||
const pending = pendingThinkingRef.current;
|
||||
pendingThinkingRef.current = "";
|
||||
if (!pending) return;
|
||||
setRun((previous) => ({ ...previous, thinkingText: previous.thinkingText + pending }));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const enqueueRequirementsDelta = useCallback((delta: string) => {
|
||||
const { text: visibleDelta, thinking } = requirementsThinkFilterRef.current.pushParts(delta);
|
||||
enqueueThinking(thinking);
|
||||
if (!visibleDelta) return;
|
||||
pendingRequirementsDeltaRef.current += visibleDelta;
|
||||
if (requirementsFrameRef.current !== null) return;
|
||||
requirementsFrameRef.current = requestAnimationFrame(() => {
|
||||
requirementsFrameRef.current = null;
|
||||
@@ -141,7 +155,30 @@ export function useDocumentRewrite() {
|
||||
requirementsPlan: previous.requirementsPlan + pending,
|
||||
}));
|
||||
});
|
||||
}, []);
|
||||
}, [enqueueThinking]);
|
||||
|
||||
const enqueueDelta = useCallback((delta: string, writtenCharCount: number) => {
|
||||
const { text: visibleDelta, thinking } = draftThinkFilterRef.current.pushParts(delta);
|
||||
enqueueThinking(thinking);
|
||||
if (!visibleDelta) return;
|
||||
pendingDeltaRef.current += visibleDelta;
|
||||
pendingWrittenCharCountRef.current = writtenCharCount;
|
||||
if (frameRef.current !== null) return;
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = null;
|
||||
const pending = pendingDeltaRef.current;
|
||||
const pendingWrittenCharCount = pendingWrittenCharCountRef.current;
|
||||
pendingDeltaRef.current = "";
|
||||
pendingWrittenCharCountRef.current = null;
|
||||
if (!pending) return;
|
||||
setRun((previous) => ({
|
||||
...previous,
|
||||
stage: "writing",
|
||||
draft: previous.draft + pending,
|
||||
...(pendingWrittenCharCount !== null ? { writtenCharCount: pendingWrittenCharCount } : {}),
|
||||
}));
|
||||
});
|
||||
}, [enqueueThinking]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
controllerRef.current?.abort();
|
||||
@@ -155,12 +192,13 @@ export function useDocumentRewrite() {
|
||||
}
|
||||
flushDelta();
|
||||
flushRequirements();
|
||||
flushThinking();
|
||||
setRun((previous) =>
|
||||
previous.stage === "completed"
|
||||
? previous
|
||||
: { ...previous, stage: "cancelled", error: "已停止全文改写,原文件未被修改。" },
|
||||
);
|
||||
}, [flushDelta, flushRequirements]);
|
||||
}, [flushDelta, flushRequirements, flushThinking]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
controllerRef.current?.abort();
|
||||
@@ -169,6 +207,9 @@ export function useDocumentRewrite() {
|
||||
pendingDeltaRef.current = "";
|
||||
pendingWrittenCharCountRef.current = null;
|
||||
pendingRequirementsDeltaRef.current = "";
|
||||
pendingThinkingRef.current = "";
|
||||
draftThinkFilterRef.current = new InlineThinkTagFilter();
|
||||
requirementsThinkFilterRef.current = new InlineThinkTagFilter();
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
@@ -177,6 +218,10 @@ export function useDocumentRewrite() {
|
||||
cancelAnimationFrame(requirementsFrameRef.current);
|
||||
requirementsFrameRef.current = null;
|
||||
}
|
||||
if (thinkingFrameRef.current !== null) {
|
||||
cancelAnimationFrame(thinkingFrameRef.current);
|
||||
thinkingFrameRef.current = null;
|
||||
}
|
||||
setRun(INITIAL_RUN);
|
||||
}, []);
|
||||
|
||||
@@ -202,6 +247,9 @@ export function useDocumentRewrite() {
|
||||
pendingDeltaRef.current = "";
|
||||
pendingWrittenCharCountRef.current = null;
|
||||
pendingRequirementsDeltaRef.current = "";
|
||||
pendingThinkingRef.current = "";
|
||||
draftThinkFilterRef.current = new InlineThinkTagFilter();
|
||||
requirementsThinkFilterRef.current = new InlineThinkTagFilter();
|
||||
setRun({
|
||||
...INITIAL_RUN,
|
||||
stage: "snapshot",
|
||||
@@ -236,28 +284,36 @@ export function useDocumentRewrite() {
|
||||
case "requirements_delta":
|
||||
enqueueRequirementsDelta(event.delta);
|
||||
break;
|
||||
case "thinking":
|
||||
enqueueThinking(event.chunk);
|
||||
break;
|
||||
case "requirements_checkpoint":
|
||||
flushRequirements();
|
||||
setRun((previous) => ({
|
||||
...previous,
|
||||
stage: "requirements",
|
||||
requirementsPlan: event.content,
|
||||
requirementsPlan: requirementsThinkFilterRef.current.replace(event.content),
|
||||
}));
|
||||
break;
|
||||
case "requirements_completed":
|
||||
flushRequirements();
|
||||
setRun((previous) => ({
|
||||
...previous,
|
||||
stage: "requirements",
|
||||
requirementsPlan: event.content,
|
||||
}));
|
||||
{
|
||||
const requirementsPlan =
|
||||
requirementsThinkFilterRef.current.replace(event.content) +
|
||||
requirementsThinkFilterRef.current.finish();
|
||||
setRun((previous) => ({
|
||||
...previous,
|
||||
stage: "requirements",
|
||||
requirementsPlan,
|
||||
}));
|
||||
}
|
||||
break;
|
||||
case "requirements_resolved":
|
||||
flushRequirements();
|
||||
setRun((previous) => ({
|
||||
...previous,
|
||||
stage: "writing",
|
||||
...(event.plan ? { requirementsPlan: event.plan } : {}),
|
||||
...(event.plan ? { requirementsPlan: stripInlineThinkTags(event.plan) } : {}),
|
||||
}));
|
||||
break;
|
||||
case "rewrite_delta":
|
||||
@@ -276,13 +332,20 @@ export function useDocumentRewrite() {
|
||||
setRun((previous) => ({
|
||||
...previous,
|
||||
stage: "writing",
|
||||
draft: event.content,
|
||||
writtenCharCount: event.charCount,
|
||||
draft: draftThinkFilterRef.current.replace(event.content),
|
||||
writtenCharCount: stripInlineThinkTags(event.content).replace(/\s/g, "").length,
|
||||
}));
|
||||
break;
|
||||
case "illustration_started":
|
||||
case "illustration_generated":
|
||||
case "illustration_completed":
|
||||
flushDelta();
|
||||
setRun((previous) => ({ ...previous, stage: "illustrating" }));
|
||||
break;
|
||||
case "validation_started":
|
||||
flushDelta();
|
||||
flushRequirements();
|
||||
flushThinking();
|
||||
setRun((previous) => ({ ...previous, stage: "validation" }));
|
||||
break;
|
||||
case "validation_completed":
|
||||
@@ -300,17 +363,23 @@ export function useDocumentRewrite() {
|
||||
case "committed":
|
||||
flushDelta();
|
||||
flushRequirements();
|
||||
setRun((previous) => ({
|
||||
...previous,
|
||||
draft: event.content,
|
||||
writtenCharCount: event.content.replace(/\s/g, "").length,
|
||||
versionId: event.versionId ?? previous.versionId,
|
||||
}));
|
||||
options?.onCommitted?.(event.content);
|
||||
flushThinking();
|
||||
{
|
||||
const committedContent =
|
||||
draftThinkFilterRef.current.replace(event.content) + draftThinkFilterRef.current.finish();
|
||||
setRun((previous) => ({
|
||||
...previous,
|
||||
draft: committedContent,
|
||||
writtenCharCount: committedContent.replace(/\s/g, "").length,
|
||||
versionId: event.versionId ?? previous.versionId,
|
||||
}));
|
||||
options?.onCommitted?.(committedContent);
|
||||
}
|
||||
break;
|
||||
case "done":
|
||||
flushDelta();
|
||||
flushRequirements();
|
||||
flushThinking();
|
||||
receivedTerminalEvent = true;
|
||||
setRun((previous) => ({
|
||||
...previous,
|
||||
@@ -321,6 +390,7 @@ export function useDocumentRewrite() {
|
||||
case "conflict":
|
||||
flushDelta();
|
||||
flushRequirements();
|
||||
flushThinking();
|
||||
receivedTerminalEvent = true;
|
||||
setRun((previous) => ({
|
||||
...previous,
|
||||
@@ -331,12 +401,14 @@ export function useDocumentRewrite() {
|
||||
case "cancelled":
|
||||
flushDelta();
|
||||
flushRequirements();
|
||||
flushThinking();
|
||||
receivedTerminalEvent = true;
|
||||
setRun((previous) => ({ ...previous, stage: "cancelled", error: event.message }));
|
||||
return;
|
||||
case "error":
|
||||
flushDelta();
|
||||
flushRequirements();
|
||||
flushThinking();
|
||||
receivedTerminalEvent = true;
|
||||
setRun((previous) => ({
|
||||
...previous,
|
||||
@@ -351,6 +423,7 @@ export function useDocumentRewrite() {
|
||||
if (!controller.signal.aborted && !receivedTerminalEvent) {
|
||||
flushDelta();
|
||||
flushRequirements();
|
||||
flushThinking();
|
||||
setRun((previous) => ({
|
||||
...previous,
|
||||
stage: "error",
|
||||
@@ -361,6 +434,7 @@ export function useDocumentRewrite() {
|
||||
if (controller.signal.aborted) return;
|
||||
flushDelta();
|
||||
flushRequirements();
|
||||
flushThinking();
|
||||
setRun((previous) => ({
|
||||
...previous,
|
||||
stage: "error",
|
||||
@@ -372,7 +446,7 @@ export function useDocumentRewrite() {
|
||||
}
|
||||
}
|
||||
},
|
||||
[enqueueDelta, enqueueRequirementsDelta, flushDelta, flushRequirements],
|
||||
[enqueueDelta, enqueueRequirementsDelta, enqueueThinking, flushDelta, flushRequirements, flushThinking],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
@@ -380,13 +454,14 @@ export function useDocumentRewrite() {
|
||||
controllerRef.current?.abort();
|
||||
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
||||
if (requirementsFrameRef.current !== null) cancelAnimationFrame(requirementsFrameRef.current);
|
||||
if (thinkingFrameRef.current !== null) cancelAnimationFrame(thinkingFrameRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
run,
|
||||
isActive: ["snapshot", "requirements", "writing", "validation", "committing"].includes(run.stage),
|
||||
isActive: ["snapshot", "requirements", "writing", "illustrating", "validation", "committing"].includes(run.stage),
|
||||
start,
|
||||
cancel,
|
||||
reset,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
import {
|
||||
type DomainAgentMapping,
|
||||
normalizeDomainInput,
|
||||
@@ -20,10 +21,7 @@ import {
|
||||
|
||||
function makeRow(): DomainAgentMapping {
|
||||
return {
|
||||
id:
|
||||
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||
id: createClientId(),
|
||||
domain: "",
|
||||
agentId: "",
|
||||
enabled: true,
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
MessageSquareText,
|
||||
PanelRightOpen,
|
||||
RotateCcw,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Telescope,
|
||||
Trash2,
|
||||
@@ -123,8 +122,10 @@ import {
|
||||
advancedOptionsFromConfig,
|
||||
buildReportCardConfig,
|
||||
ReportConfigCard,
|
||||
ReportOutlineEditor,
|
||||
} from "./ReportConfigCard";
|
||||
import { ResearchSandboxPanel } from "./ResearchSandboxPanel";
|
||||
import { WorkflowConfigButton } from "./WorkflowConfigDialog";
|
||||
import { REPORT_OUTPUT_PATH, useReportSandbox } from "./useReportSandbox";
|
||||
|
||||
const COLLECTOR_AGENT_ID = "deep-research-collector";
|
||||
@@ -183,6 +184,8 @@ type WorkbenchRun = {
|
||||
phase: string;
|
||||
planText: string;
|
||||
planReasoning: string;
|
||||
/** Live-only model thinking while report Markdown is being streamed. */
|
||||
reportThinking: string;
|
||||
report: string;
|
||||
reportTargets: Record<string, { content: string; version: number }>;
|
||||
reportVersion: number;
|
||||
@@ -201,6 +204,7 @@ const EMPTY_RUN: WorkbenchRun = {
|
||||
phase: "initializing",
|
||||
planText: "",
|
||||
planReasoning: "",
|
||||
reportThinking: "",
|
||||
report: "",
|
||||
reportTargets: {},
|
||||
reportVersion: 1,
|
||||
@@ -418,6 +422,10 @@ function DeepResearchWorkbenchInner() {
|
||||
presentationId: null,
|
||||
plan: "",
|
||||
});
|
||||
const mirroredReportRewriteThinkingRef = useRef<{ presentationId: string | null; thinking: string }>({
|
||||
presentationId: null,
|
||||
thinking: "",
|
||||
});
|
||||
|
||||
const sessions = useResearchSessions();
|
||||
const sessionQuery = useResearchSession(selectedId);
|
||||
@@ -428,6 +436,10 @@ function DeepResearchWorkbenchInner() {
|
||||
const [localSettings] = useLocalSettings();
|
||||
const { models, isLoading: modelsLoading } = useModels();
|
||||
const seenEventKeys = useRef(new Set<string>());
|
||||
// Live provider deltas can legitimately repeat the same text (including a
|
||||
// single space). They have no durable sequence, so never deduplicate them
|
||||
// by payload or a thought token could disappear from the visible trace.
|
||||
const liveEventSerial = useRef(0);
|
||||
const replayedTraceJobs = useRef(new Set<string>());
|
||||
const pendingReportEvents = useRef<ProgressEvent[]>([]);
|
||||
const reportAnimationFrame = useRef<number | null>(null);
|
||||
@@ -679,6 +691,16 @@ function DeepResearchWorkbenchInner() {
|
||||
});
|
||||
}, [reportDocumentRewrite.run.requirementsPlan, rewritePresentation]);
|
||||
|
||||
useEffect(() => {
|
||||
const presentationId = reportRewritePresentationId.current;
|
||||
const thinking = reportDocumentRewrite.run.thinkingText;
|
||||
if (!presentationId || !rewritePresentation || !thinking) return;
|
||||
const previous = mirroredReportRewriteThinkingRef.current;
|
||||
if (previous.presentationId === presentationId && previous.thinking === thinking) return;
|
||||
mirroredReportRewriteThinkingRef.current = { presentationId, thinking };
|
||||
rewritePresentation.update(presentationId, { thinkingText: thinking });
|
||||
}, [reportDocumentRewrite.run.thinkingText, rewritePresentation]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRewriteUndo = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{
|
||||
@@ -854,6 +876,9 @@ function DeepResearchWorkbenchInner() {
|
||||
phase: session?.status === "completed" ? "done" : run.phase,
|
||||
materialCount,
|
||||
error: run.error,
|
||||
thinking: run.reportThinking,
|
||||
events: run.events,
|
||||
requestedImageCount: requestedReportImageCount(session?.config),
|
||||
}),
|
||||
} as Injected);
|
||||
}
|
||||
@@ -870,6 +895,7 @@ function DeepResearchWorkbenchInner() {
|
||||
? buildCompletedReportMessage({
|
||||
sourceCount: materialCount,
|
||||
summaryText: run.summaryText,
|
||||
imageCount: countEmbeddedReportImages(session?.reportMarkdown),
|
||||
})
|
||||
: buildStreamingSummaryMessage(run.summaryText),
|
||||
// MessageList 会将该标记转为既有的 assistant 流式渲染状态,避免摘要只在结束后才显示。
|
||||
@@ -950,7 +976,9 @@ function DeepResearchWorkbenchInner() {
|
||||
pendingFollowUp,
|
||||
followUpStream,
|
||||
run.error,
|
||||
run.events,
|
||||
run.phase,
|
||||
run.reportThinking,
|
||||
run.summaryText,
|
||||
session,
|
||||
streamJobId,
|
||||
@@ -980,6 +1008,7 @@ function DeepResearchWorkbenchInner() {
|
||||
if (replayingTerminalTrace && replayedTraceJobs.current.has(streamJobId)) return;
|
||||
if (replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId);
|
||||
seenEventKeys.current = new Set();
|
||||
liveEventSerial.current = 0;
|
||||
pendingReportEvents.current = [];
|
||||
setRun({ ...EMPTY_RUN, jobId: streamJobId });
|
||||
// 进行中的写作仍自动打开沙箱;历史完成记录只回放步骤与摘要,等待用户点击
|
||||
@@ -997,6 +1026,7 @@ function DeepResearchWorkbenchInner() {
|
||||
let reportTargets = current.reportTargets;
|
||||
let planText = current.planText;
|
||||
let planReasoning = current.planReasoning;
|
||||
let reportThinking = current.reportThinking;
|
||||
let summaryText = current.summaryText;
|
||||
let phase = current.phase;
|
||||
for (const event of queued) {
|
||||
@@ -1009,6 +1039,10 @@ function DeepResearchWorkbenchInner() {
|
||||
planReasoning += typeof event.payload.delta === "string" ? event.payload.delta : "";
|
||||
continue;
|
||||
}
|
||||
if (event.type === "report_thinking") {
|
||||
reportThinking += typeof event.payload.delta === "string" ? event.payload.delta : "";
|
||||
continue;
|
||||
}
|
||||
if (event.type === "summary_delta") {
|
||||
summaryText += typeof event.payload.delta === "string" ? event.payload.delta : "";
|
||||
continue;
|
||||
@@ -1020,6 +1054,7 @@ function DeepResearchWorkbenchInner() {
|
||||
if (event.type === "report_reset") {
|
||||
report = "";
|
||||
reportTargets = {};
|
||||
reportThinking = "";
|
||||
reportVersion = typeof event.payload.reportVersion === "number" ? event.payload.reportVersion : reportVersion + 1;
|
||||
continue;
|
||||
}
|
||||
@@ -1035,7 +1070,7 @@ function DeepResearchWorkbenchInner() {
|
||||
reportVersion = Math.max(reportVersion, version);
|
||||
}
|
||||
}
|
||||
return { ...current, report, reportTargets, reportVersion, phase, planText, planReasoning, summaryText };
|
||||
return { ...current, report, reportTargets, reportVersion, phase, planText, planReasoning, reportThinking, summaryText };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1043,12 +1078,15 @@ function DeepResearchWorkbenchInner() {
|
||||
streamJobId,
|
||||
{
|
||||
onEvent: (event) => {
|
||||
const key = eventKey(event);
|
||||
const key = event.seq === 0 && !event.liveId
|
||||
? `live-arrival:${++liveEventSerial.current}`
|
||||
: eventKey(event);
|
||||
if (seenEventKeys.current.has(key)) return;
|
||||
seenEventKeys.current.add(key);
|
||||
if (
|
||||
event.type === "plan_delta" ||
|
||||
event.type === "plan_reasoning" ||
|
||||
event.type === "report_thinking" ||
|
||||
event.type === "report_delta" ||
|
||||
event.type === "report_chunk" ||
|
||||
event.type === "report_reset" ||
|
||||
@@ -1521,6 +1559,10 @@ function DeepResearchWorkbenchInner() {
|
||||
case "config":
|
||||
return (
|
||||
<ReportConfigCard
|
||||
// A new durable config-card message is a new editing session. Do
|
||||
// not remount for ordinary collector/event refreshes: that would
|
||||
// discard the user's in-progress “生成配图” toggle.
|
||||
key={`${session.id}:${payload.messageId ?? "report-config"}`}
|
||||
availableModes={availableModes}
|
||||
defaultMode={session.mode}
|
||||
defaultOptions={advancedOptionsFromConfig(session.config)}
|
||||
@@ -1722,9 +1764,15 @@ function DeepResearchWorkbenchInner() {
|
||||
<ImagePlus className="h-3.5 w-3.5" />配图
|
||||
</button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setSettingsOpen(true)} disabled={Boolean(isFollowUp) || jobWriting}>
|
||||
<SlidersHorizontal className="mr-1.5 h-3.5 w-3.5" />高级配置
|
||||
</Button>
|
||||
<WorkflowConfigButton
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={Boolean(isFollowUp) || jobWriting}
|
||||
appliedContent={advancedOptions.customOutline}
|
||||
onApply={(content) =>
|
||||
setAdvancedOptions((current) => ({ ...current, customOutline: content }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<PromptInput onSubmit={({ text }) => handleComposerSubmit(text)}>
|
||||
@@ -1842,6 +1890,7 @@ type WritingProgressToolCall = {
|
||||
label: string;
|
||||
detail: string;
|
||||
status: "complete" | "active";
|
||||
thinking?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1854,12 +1903,23 @@ function buildWritingProgressToolCalls({
|
||||
phase,
|
||||
materialCount,
|
||||
error,
|
||||
thinking,
|
||||
events,
|
||||
requestedImageCount,
|
||||
}: {
|
||||
phase: string;
|
||||
materialCount: number;
|
||||
error: string | null;
|
||||
thinking: string;
|
||||
events: ProgressEvent[];
|
||||
requestedImageCount: number;
|
||||
}): WritingProgressToolCall[] {
|
||||
const rank = chatPhaseRank(phase);
|
||||
const imageSnapshot = buildTraceSnapshot(events);
|
||||
const imageStageVisible = requestedImageCount > 0 || imageSnapshot.imageTotal > 0 || imageSnapshot.images.length > 0;
|
||||
const imageTotal = imageSnapshot.imageTotal || requestedImageCount;
|
||||
const generatedImages = imageSnapshot.images.filter((image) => image.status === "completed").length;
|
||||
const failedImages = imageSnapshot.images.filter((image) => image.status === "failed").length;
|
||||
const stages = [
|
||||
{
|
||||
id: "harvest",
|
||||
@@ -1879,6 +1939,18 @@ function buildWritingProgressToolCalls({
|
||||
boundary: chatPhaseRank("reviewing"),
|
||||
detail: "正在逐字写入右侧沙箱中的 report.md",
|
||||
},
|
||||
...(imageStageVisible ? [{
|
||||
id: "illustrate",
|
||||
label: "生成报告配图",
|
||||
boundary: chatPhaseRank("exporting"),
|
||||
detail: generatedImages > 0
|
||||
? `已生成 ${generatedImages} 张报告配图并嵌入 report.md${failedImages ? `;另有 ${failedImages} 张未生成,文字报告已保留` : ""}`
|
||||
: failedImages > 0
|
||||
? `配图未生成,已保留完整文字报告${imageTotal ? `(已处理 ${failedImages}/${imageTotal} 张)` : ""}`
|
||||
: imageTotal > 0
|
||||
? `正在生成报告配图,已处理 ${generatedImages + failedImages}/${imageTotal} 张`
|
||||
: "正在为报告关键章节生成配图",
|
||||
}] : []),
|
||||
{
|
||||
id: "summary",
|
||||
label: "生成报告摘要",
|
||||
@@ -1901,6 +1973,7 @@ function buildWritingProgressToolCalls({
|
||||
label: stage.label,
|
||||
detail,
|
||||
status: complete ? "complete" : "active",
|
||||
...(stage.id === "write" && thinking.trim() ? { thinking } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1910,14 +1983,19 @@ function buildWritingProgressToolCalls({
|
||||
function buildCompletedReportMessage({
|
||||
sourceCount,
|
||||
summaryText,
|
||||
imageCount,
|
||||
}: {
|
||||
sourceCount: number;
|
||||
summaryText: string;
|
||||
imageCount: number;
|
||||
}): string {
|
||||
const parts = [
|
||||
"## 研究报告已完成",
|
||||
`已基于 ${sourceCount} 条资料完成报告,正文已在右侧沙箱中打开;可通过沙箱工具栏下载 Markdown 或 Word。`,
|
||||
];
|
||||
if (imageCount > 0) {
|
||||
parts.push(`已生成 ${imageCount} 张报告配图,并嵌入正文对应章节。`);
|
||||
}
|
||||
if (summaryText.trim()) {
|
||||
parts.push(`### 报告摘要\n\n${summaryText.trim()}`);
|
||||
}
|
||||
@@ -2104,6 +2182,7 @@ function ResearchConversation({
|
||||
const hasTrace = active || events.length > 0;
|
||||
const streamedSourceCount = events.reduce((count, event) => count + (event.type === "source_added" ? 1 : 0), 0);
|
||||
const liveSourceCount = Math.max(sourceCount, streamedSourceCount);
|
||||
const requestedImageCount = requestedReportImageCount(session.config);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<UserBubble content={session.query} />
|
||||
@@ -2120,6 +2199,7 @@ function ResearchConversation({
|
||||
error={streamError}
|
||||
onOpenSandbox={onOpenSandbox}
|
||||
active={active}
|
||||
requestedImageCount={requestedImageCount}
|
||||
/>
|
||||
</AssistantTurn>
|
||||
)}
|
||||
@@ -2169,6 +2249,15 @@ type TraceSource = {
|
||||
snippet?: string;
|
||||
};
|
||||
|
||||
type TraceImage = {
|
||||
position: number;
|
||||
section: string;
|
||||
status: "generating" | "completed" | "failed";
|
||||
name?: string;
|
||||
alt?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type TraceSnapshot = {
|
||||
groups: { title: string; queries: string[] }[];
|
||||
subtopics: string[];
|
||||
@@ -2179,6 +2268,9 @@ type TraceSnapshot = {
|
||||
curationReason?: string;
|
||||
compressedSourceCount?: number;
|
||||
reportCompleted: boolean;
|
||||
imageTotal: number;
|
||||
images: TraceImage[];
|
||||
imagePhaseFinished: boolean;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
@@ -2198,17 +2290,30 @@ function numberValue(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function countEmbeddedReportImages(markdown?: string): number {
|
||||
return (markdown?.match(/!\[[^\]]*\]\(deep-research:\/\/[^)\s]+\)/g) ?? []).length;
|
||||
}
|
||||
|
||||
function requestedReportImageCount(config?: Record<string, unknown>): number {
|
||||
if (config?.generate_images !== true) return 0;
|
||||
const configured = numberValue(config.max_generated_images);
|
||||
return Math.max(1, Math.min(4, Math.floor(configured ?? 2)));
|
||||
}
|
||||
|
||||
function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
|
||||
const groupQueries = new Map<string, string[]>();
|
||||
const subtopics: string[] = [];
|
||||
const queryMap = new Map<string, TraceQuery>();
|
||||
const sourceMap = new Map<string, TraceSource>();
|
||||
const imageMap = new Map<number, TraceImage>();
|
||||
const selectedIds = new Set<string>();
|
||||
const removedIds = new Set<string>();
|
||||
const warnings: string[] = [];
|
||||
let curationReason: string | undefined;
|
||||
let compressedSourceCount: number | undefined;
|
||||
let reportCompleted = false;
|
||||
let imageTotal = 0;
|
||||
let imagePhaseFinished = false;
|
||||
|
||||
const addQuery = (query: string, group = "研究课题") => {
|
||||
const normalized = query.trim();
|
||||
@@ -2223,6 +2328,11 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
|
||||
|
||||
for (const event of events) {
|
||||
const payload = event.payload ?? {};
|
||||
if (event.type === "phase_changed") {
|
||||
const from = traceString(payload.from);
|
||||
const to = traceString(payload.to);
|
||||
if (from === "illustrating" || to === "exporting") imagePhaseFinished = true;
|
||||
}
|
||||
if (event.type === "plan_created") {
|
||||
const plan = traceRecord(payload.plan);
|
||||
for (const subtopic of traceStrings(payload.subtopics).concat(traceStrings(plan.subtopics))) {
|
||||
@@ -2292,9 +2402,39 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
|
||||
reportCompleted = true;
|
||||
continue;
|
||||
}
|
||||
if (event.type === "image_generation_started" || event.type === "image_generated") {
|
||||
const position = Math.max(1, Math.floor(numberValue(payload.position) ?? imageMap.size + 1));
|
||||
const total = numberValue(payload.total);
|
||||
if (total !== undefined) imageTotal = Math.max(imageTotal, Math.floor(total));
|
||||
imageTotal = Math.max(imageTotal, position);
|
||||
const prior = imageMap.get(position);
|
||||
imageMap.set(position, {
|
||||
position,
|
||||
section: traceString(payload.section) ?? prior?.section ?? `第 ${position} 张配图`,
|
||||
status: event.type === "image_generated" ? "completed" : prior?.status === "completed" ? "completed" : "generating",
|
||||
name: traceString(payload.name) ?? prior?.name,
|
||||
alt: traceString(payload.alt) ?? prior?.alt,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (event.type === "warning" || event.type === "error") {
|
||||
const message = traceString(payload.message) ?? traceString(payload.detail);
|
||||
if (message && !warnings.includes(message)) warnings.push(message);
|
||||
if (traceString(payload.code) === "image_generation_failed") {
|
||||
const position = Math.max(1, Math.floor(numberValue(payload.position) ?? imageMap.size + 1));
|
||||
const total = numberValue(payload.total);
|
||||
if (total !== undefined) imageTotal = Math.max(imageTotal, Math.floor(total));
|
||||
imageTotal = Math.max(imageTotal, position);
|
||||
const prior = imageMap.get(position);
|
||||
imageMap.set(position, {
|
||||
position,
|
||||
section: traceString(payload.section) ?? prior?.section ?? `第 ${position} 张配图`,
|
||||
status: "failed",
|
||||
name: prior?.name,
|
||||
alt: prior?.alt,
|
||||
error: message ?? "报告配图生成失败",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2308,12 +2448,15 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
|
||||
curationReason,
|
||||
compressedSourceCount,
|
||||
reportCompleted,
|
||||
imageTotal,
|
||||
images: [...imageMap.values()].sort((left, right) => left.position - right.position),
|
||||
imagePhaseFinished,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function phaseAtOrAfter(phase: string, boundary: string): boolean {
|
||||
const order = ["initializing", "planning", "collecting", "curating", "compressing", "deepening", "writing", "reviewing", "illustrating", "exporting", "done"];
|
||||
const order = ["initializing", "planning", "collecting", "curating", "compressing", "deepening", "writing", "summarizing", "reviewing", "illustrating", "exporting", "done"];
|
||||
return Math.max(0, order.indexOf(phase)) >= Math.max(0, order.indexOf(boundary));
|
||||
}
|
||||
|
||||
@@ -2345,7 +2488,7 @@ function ReasoningStream({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, planReasoning, sourceCount, reportStarted, error, onOpenSandbox, active }: { events: ProgressEvent[]; phase: string; planText: string; planReasoning: string; sourceCount: number; reportStarted: boolean; error: string | null; onOpenSandbox: () => void; active: boolean }) {
|
||||
const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, planReasoning, sourceCount, reportStarted, error, onOpenSandbox, active, requestedImageCount }: { events: ProgressEvent[]; phase: string; planText: string; planReasoning: string; sourceCount: number; reportStarted: boolean; error: string | null; onOpenSandbox: () => void; active: boolean; requestedImageCount: number }) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const snapshot = useMemo(() => buildTraceSnapshot(events), [events]);
|
||||
const sourcesById = useMemo(() => new Map(snapshot.sources.map((source) => [source.id, source])), [snapshot.sources]);
|
||||
@@ -2358,6 +2501,14 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla
|
||||
const curatingDone = phaseAtOrAfter(phase, "writing") || snapshot.selectedIds.size > 0;
|
||||
const writingDone = snapshot.reportCompleted || phase === "done";
|
||||
const streamedQueries = planningDone ? [] : parseStreamedQueries(planText);
|
||||
const imageStageVisible = requestedImageCount > 0 || snapshot.imageTotal > 0 || snapshot.images.length > 0;
|
||||
const imageTotal = snapshot.imageTotal || requestedImageCount;
|
||||
const completedImages = snapshot.images.filter((image) => image.status === "completed").length;
|
||||
const failedImages = snapshot.images.filter((image) => image.status === "failed").length;
|
||||
const processedImages = completedImages + failedImages;
|
||||
const imageDone = imageStageVisible && (snapshot.imagePhaseFinished || phaseAtOrAfter(phase, "exporting") || (!active && phase === "done"));
|
||||
const imageCurrent = imageStageVisible && !imageDone && (phase === "illustrating" || snapshot.images.some((image) => image.status === "generating"));
|
||||
const imageProgress = imageTotal ? Math.min(100, Math.round((processedImages / imageTotal) * 100)) : 0;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl rounded-tl-md border border-border bg-card shadow-sm">
|
||||
@@ -2390,6 +2541,12 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla
|
||||
<button type="button" onClick={onOpenSandbox} className="inline-flex items-center gap-1.5 rounded-md bg-primary/8 px-2.5 py-1.5 text-xs font-medium text-primary hover:bg-primary/12"><FileText className="h-3.5 w-3.5" />{writingDone ? "查看已保存的 report.md" : "打开正在写入的 report.md"}<ChevronRight className="h-3.5 w-3.5" /></button>
|
||||
</TraceStage>
|
||||
|
||||
{imageStageVisible && <TraceStage number={5} title="生成报告配图" detail={imageDone ? completedImages > 0 ? `已生成 ${completedImages} 张配图并嵌入 report.md${failedImages ? `;另有 ${failedImages} 张未生成,文字报告已保留` : ""}。` : "配图未生成,已保留完整文字报告。" : imageCurrent ? `正在生成报告配图,已处理 ${processedImages}/${imageTotal || "?"} 张。` : "报告写完后会为关键章节生成并嵌入配图。"} done={imageDone} current={imageCurrent}>
|
||||
{imageTotal > 0 && <div className="mb-3 h-2 overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-primary transition-[width] duration-300" style={{ width: `${imageDone ? 100 : imageProgress}%` }} /></div>}
|
||||
{snapshot.images.length > 0 ? <div className="space-y-2">{snapshot.images.map((image) => <div key={image.position} className="flex min-w-0 items-center gap-2 rounded-lg border border-border/70 bg-muted/[0.12] px-3 py-2"><ImagePlus className={`h-3.5 w-3.5 shrink-0 ${image.status === "completed" ? "text-emerald-600" : image.status === "failed" ? "text-amber-600" : "animate-pulse text-primary"}`} /><div className="min-w-0 flex-1"><p className="truncate text-xs font-medium">第 {image.position} 张 · {image.section}</p><p className="mt-0.5 text-[11px] text-muted-foreground">{image.status === "completed" ? `${image.name ?? "已保存并嵌入报告"}` : image.status === "failed" ? image.error ?? "生成失败,已保留文字报告" : "正在调用图片模型…"}</p></div><span className={`shrink-0 text-[11px] ${image.status === "completed" ? "text-emerald-700 dark:text-emerald-300" : image.status === "failed" ? "text-amber-700 dark:text-amber-300" : "text-primary"}`}>{image.status === "completed" ? "已生成" : image.status === "failed" ? "未生成" : "生成中"}</span></div>)}</div> : <div className="rounded-lg border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">{imageCurrent ? "正在准备第一张报告配图…" : `最多生成 ${imageTotal || requestedImageCount} 张,并在完成后插入对应章节。`}</div>}
|
||||
{completedImages > 0 && <button type="button" onClick={onOpenSandbox} className="mt-3 inline-flex items-center gap-1.5 rounded-md bg-primary/8 px-2.5 py-1.5 text-xs font-medium text-primary hover:bg-primary/12"><ImagePlus className="h-3.5 w-3.5" />查看已嵌入报告的配图<ChevronRight className="h-3.5 w-3.5" /></button>}
|
||||
</TraceStage>}
|
||||
|
||||
{snapshot.warnings.length > 0 && <div className="rounded-lg bg-amber-500/10 px-3 py-2.5 text-xs leading-5 text-amber-800 dark:text-amber-200"><p className="font-medium">过程提示</p>{snapshot.warnings.map((warning) => <p key={warning} className="mt-1">{warning}</p>)}</div>}
|
||||
{error && <p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">实时连接提示:{error}。研究仍会继续,页面会从已保存的事件记录恢复。</p>}
|
||||
</div>}
|
||||
@@ -2398,7 +2555,8 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla
|
||||
});
|
||||
|
||||
function CompletionSummary({ session, sourceCount, onOpenSandbox, onExport }: { session: DeepResearchSession; sourceCount: number; onOpenSandbox: () => void; onExport: (format: "word" | "markdown" | "html") => void }) {
|
||||
return <div className="rounded-2xl rounded-tl-md border border-emerald-500/20 bg-card p-4 shadow-sm"><div className="flex items-start gap-2"><div className="mt-0.5 rounded-full bg-emerald-500/12 p-1 text-emerald-600"><CheckCircle2 className="h-3.5 w-3.5" /></div><div><p className="text-sm font-medium">研究报告已完成</p><p className="mt-1 text-sm leading-6 text-muted-foreground">已基于 {sourceCount || session.sourceCount} 条资料完成报告,并保留来源供后续追问。报告正文在右侧沙箱中打开。</p></div></div><div className="mt-4 flex flex-wrap gap-2"><Button size="sm" onClick={onOpenSandbox}><FolderOpen className="mr-1.5 h-3.5 w-3.5" />查看报告</Button><Button size="sm" variant="outline" onClick={() => onExport("word")}><FileDown className="mr-1.5 h-3.5 w-3.5" />Word</Button><Button size="sm" variant="outline" onClick={() => onExport("markdown")}><FileCode2 className="mr-1.5 h-3.5 w-3.5" />Markdown</Button><Button size="sm" variant="outline" onClick={() => onExport("html")}><FileType2 className="mr-1.5 h-3.5 w-3.5" />HTML</Button></div></div>;
|
||||
const imageCount = countEmbeddedReportImages(session.reportMarkdown);
|
||||
return <div className="rounded-2xl rounded-tl-md border border-emerald-500/20 bg-card p-4 shadow-sm"><div className="flex items-start gap-2"><div className="mt-0.5 rounded-full bg-emerald-500/12 p-1 text-emerald-600"><CheckCircle2 className="h-3.5 w-3.5" /></div><div><p className="text-sm font-medium">研究报告已完成</p><p className="mt-1 text-sm leading-6 text-muted-foreground">已基于 {sourceCount || session.sourceCount} 条资料完成报告,并保留来源供后续追问。报告正文在右侧沙箱中打开。</p>{imageCount > 0 && <p className="mt-1.5 flex items-center gap-1.5 text-sm leading-6 text-emerald-700 dark:text-emerald-300"><ImagePlus className="h-3.5 w-3.5" />已生成 {imageCount} 张报告配图,已嵌入正文对应章节。</p>}</div></div><div className="mt-4 flex flex-wrap gap-2"><Button size="sm" onClick={onOpenSandbox}><FolderOpen className="mr-1.5 h-3.5 w-3.5" />查看报告</Button><Button size="sm" variant="outline" onClick={() => onExport("word")}><FileDown className="mr-1.5 h-3.5 w-3.5" />Word</Button><Button size="sm" variant="outline" onClick={() => onExport("markdown")}><FileCode2 className="mr-1.5 h-3.5 w-3.5" />Markdown</Button><Button size="sm" variant="outline" onClick={() => onExport("html")}><FileType2 className="mr-1.5 h-3.5 w-3.5" />HTML</Button></div></div>;
|
||||
}
|
||||
|
||||
function PlanReviewCard({ plan, submitting, onSubmit }: { plan: Record<string, unknown> | undefined; submitting: boolean; onSubmit: (action: "approve" | "revise", feedback?: string) => void }) {
|
||||
@@ -2409,8 +2567,144 @@ function PlanReviewCard({ plan, submitting, onSubmit }: { plan: Record<string, u
|
||||
|
||||
// ── advanced settings dialog ─────────────────────────────────────────────────
|
||||
|
||||
function AdvancedResearchSettingsDialog({ open, onOpenChange, mode, options, classicMode, onClassicModeChange, imageGenerationAvailable, imageLimit, onChange }: { open: boolean; onOpenChange: (open: boolean) => void; mode: ResearchMode; options: AdvancedResearchOptions; classicMode: boolean; onClassicModeChange: (value: boolean) => void; imageGenerationAvailable: boolean; imageLimit: number; onChange: <K extends keyof AdvancedResearchOptions>(key: K, value: AdvancedResearchOptions[K]) => void }) {
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent className="max-h-[88vh] max-w-3xl overflow-y-auto"><DialogHeader><DialogTitle>高级研究配置</DialogTitle><DialogDescription>本次设置会作为下一次研究的默认值;对话式收集的最终选项在报告配置卡片中确认。</DialogDescription></DialogHeader><div className="rounded-xl border border-border p-3"><label className="flex cursor-pointer gap-3"><input type="checkbox" checked={classicMode} onChange={(event) => onClassicModeChange(event.target.checked)} className="mt-0.5 accent-primary" /><span><span className="block text-sm font-medium">经典模式</span><span className="mt-1 block text-xs leading-5 text-muted-foreground">回退到旧全流程:输入框直接选模式并自动开始研究,不经过收集对话与报告配置卡片。仅影响下一次新建的研究。</span></span></label></div><div className="grid gap-5 py-2 sm:grid-cols-2"><SettingSelect label="输出语言" value={options.language} onValueChange={(value) => onChange("language", value as AdvancedResearchOptions["language"])} options={[{ value: "zh-CN", label: "中文" }, { value: "en-US", label: "English" }]} /><SettingSelect label="报告语气" value={options.tone} onValueChange={(value) => onChange("tone", value as ResearchTone)} options={[{ value: "objective", label: "客观" }, { value: "formal", label: "正式" }, { value: "analytical", label: "分析" }, { value: "persuasive", label: "说服" }]} /><NumberSetting label="单次查询结果" hint="1–20 条" value={options.maxSearchResults} onChange={(value) => onChange("maxSearchResults", value)} /><NumberSetting label="子问题轮数" hint="1–10 轮" value={options.maxIterations} onChange={(value) => onChange("maxIterations", value)} /><NumberSetting label="最大并发" hint="1–8 路" value={options.maxConcurrency} onChange={(value) => onChange("maxConcurrency", value)} /><NumberSetting label="上下文词数" hint="500–120000" value={options.maxContextWords} onChange={(value) => onChange("maxContextWords", value)} />{(mode === "detailed" || mode === "multi_agent") && <NumberSetting label="最多子主题" hint="1–20 个" value={options.maxSubtopics} onChange={(value) => onChange("maxSubtopics", value)} />}{mode === "deep" && <><NumberSetting label="深挖广度" hint="1–8" value={options.deepBreadth} onChange={(value) => onChange("deepBreadth", value)} /><NumberSetting label="深挖层级" hint="1–4" value={options.deepDepth} onChange={(value) => onChange("deepDepth", value)} /></>}{mode === "multi_agent" && <NumberSetting label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(value) => onChange("maxRevisionLoops", value)} />}</div><div className="space-y-2"><label htmlFor="research-outline" className="text-sm font-medium">自定义报告结构</label><Textarea id="research-outline" value={options.customOutline} onChange={(event) => onChange("customOutline", event.target.value)} className="min-h-28 resize-y" placeholder={"例如:\n# 执行摘要\n## 市场与背景\n## 机会与风险"} /><p className="text-xs text-muted-foreground">支持 Markdown 标题或编号列表,会作为报告写作结构要求。</p></div><div className="grid gap-3 sm:grid-cols-2"><label className="flex cursor-pointer gap-3 rounded-xl border p-3"><input type="checkbox" checked={options.thinkingEnabled} onChange={(event) => onChange("thinkingEnabled", event.target.checked)} className="mt-0.5 accent-primary" /><span><span className="block text-sm font-medium">启用模型深度思考</span><span className="mt-1 block text-xs leading-5 text-muted-foreground">仅在当前系统默认模型支持时生效,会增加响应时延与模型消耗。</span></span></label>{imageGenerationAvailable && <div className="rounded-xl border p-3"><NumberSetting label="最多报告配图" hint={`1–${imageLimit} 张`} value={options.maxGeneratedImages} onChange={(value) => onChange("maxGeneratedImages", value)} /></div>}</div><DialogFooter><Button onClick={() => onOpenChange(false)}>保存配置</Button></DialogFooter></DialogContent></Dialog>;
|
||||
function AdvancedResearchSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mode,
|
||||
options,
|
||||
classicMode,
|
||||
onClassicModeChange,
|
||||
imageGenerationAvailable,
|
||||
imageLimit,
|
||||
onChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: ResearchMode;
|
||||
options: AdvancedResearchOptions;
|
||||
classicMode: boolean;
|
||||
onClassicModeChange: (value: boolean) => void;
|
||||
imageGenerationAvailable: boolean;
|
||||
imageLimit: number;
|
||||
onChange: <K extends keyof AdvancedResearchOptions>(key: K, value: AdvancedResearchOptions[K]) => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[88vh] w-[calc(100vw-2rem)] max-w-none overflow-y-auto sm:w-[600px] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>高级研究配置</DialogTitle>
|
||||
<DialogDescription>
|
||||
本次设置会作为下一次研究的默认值;对话式收集的最终选项在报告配置卡片中确认。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-xl border border-border p-3">
|
||||
<label className="flex cursor-pointer gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={classicMode}
|
||||
onChange={(event) => onClassicModeChange(event.target.checked)}
|
||||
className="mt-0.5 accent-primary"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-medium">经典模式</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
|
||||
回退到旧全流程:输入框直接选模式并自动开始研究,不经过收集对话与报告配置卡片。仅影响下一次新建的研究。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 py-2 sm:grid-cols-2">
|
||||
<SettingSelect
|
||||
label="输出语言"
|
||||
value={options.language}
|
||||
onValueChange={(value) => onChange("language", value as AdvancedResearchOptions["language"])}
|
||||
options={[
|
||||
{ value: "zh-CN", label: "中文" },
|
||||
{ value: "en-US", label: "English" },
|
||||
]}
|
||||
/>
|
||||
<SettingSelect
|
||||
label="报告语气"
|
||||
value={options.tone}
|
||||
onValueChange={(value) => onChange("tone", value as ResearchTone)}
|
||||
options={[
|
||||
{ value: "objective", label: "客观" },
|
||||
{ value: "formal", label: "正式" },
|
||||
{ value: "analytical", label: "分析" },
|
||||
{ value: "persuasive", label: "说服" },
|
||||
]}
|
||||
/>
|
||||
<NumberSetting label="单次查询结果" hint="1–20 条" value={options.maxSearchResults} onChange={(value) => onChange("maxSearchResults", value)} />
|
||||
<NumberSetting label="子问题轮数" hint="1–10 轮" value={options.maxIterations} onChange={(value) => onChange("maxIterations", value)} />
|
||||
<NumberSetting label="最大并发" hint="1–8 路" value={options.maxConcurrency} onChange={(value) => onChange("maxConcurrency", value)} />
|
||||
<NumberSetting label="上下文词数" hint="500–120000" value={options.maxContextWords} onChange={(value) => onChange("maxContextWords", value)} />
|
||||
{(mode === "detailed" || mode === "multi_agent") && (
|
||||
<NumberSetting label="最多子主题" hint="1–20 个" value={options.maxSubtopics} onChange={(value) => onChange("maxSubtopics", value)} />
|
||||
)}
|
||||
{mode === "deep" && (
|
||||
<>
|
||||
<NumberSetting label="深挖广度" hint="1–8" value={options.deepBreadth} onChange={(value) => onChange("deepBreadth", value)} />
|
||||
<NumberSetting label="深挖层级" hint="1–4" value={options.deepDepth} onChange={(value) => onChange("deepDepth", value)} />
|
||||
</>
|
||||
)}
|
||||
{mode === "multi_agent" && (
|
||||
<NumberSetting label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(value) => onChange("maxRevisionLoops", value)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ReportOutlineEditor
|
||||
id="research-outline"
|
||||
value={options.customOutline}
|
||||
onChange={(value) => onChange("customOutline", value)}
|
||||
/>
|
||||
|
||||
{/* 工作流配置:报告结构库(后台 /api/report-structures),选中卡片即填充上方报告结构 */}
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-border p-3">
|
||||
<span className="text-xs leading-5 text-muted-foreground">
|
||||
<span className="block text-sm font-medium text-foreground">报告结构库</span>
|
||||
在「工作流配置」中管理并选择报告结构;选中卡片后自动填充到上方报告结构。
|
||||
</span>
|
||||
<WorkflowConfigButton
|
||||
appliedContent={options.customOutline}
|
||||
onApply={(content) => onChange("customOutline", content)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="flex cursor-pointer gap-3 rounded-xl border p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.thinkingEnabled}
|
||||
onChange={(event) => onChange("thinkingEnabled", event.target.checked)}
|
||||
className="mt-0.5 accent-primary"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-medium">启用模型深度思考</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
|
||||
仅在当前系统默认模型支持时生效,会增加响应时延与模型消耗。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{imageGenerationAvailable && (
|
||||
<div className="rounded-xl border p-3">
|
||||
<NumberSetting
|
||||
label="最多报告配图"
|
||||
hint={`1–${imageLimit} 张`}
|
||||
value={options.maxGeneratedImages}
|
||||
onChange={(value) => onChange("maxGeneratedImages", value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>保存配置</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberSetting({ label, hint, value, onChange }: { label: string; hint: string; value: string; onChange: (value: string) => void }) { return <label className="block"><span className="mb-2 block text-sm font-medium">{label}</span><Input value={value} inputMode="numeric" onChange={(event) => onChange(event.target.value)} /><span className="mt-1 block text-xs text-muted-foreground">{hint}</span></label>; }
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 以 chat 入口启动报告写作 job —— 保留旧输入框全部能力,改造收敛在这一张卡片。
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ChevronRight,
|
||||
ImagePlus,
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type ResearchMode,
|
||||
type ResearchTone,
|
||||
} from "@/strategy-components/api/deep-research";
|
||||
import { WorkflowConfigButton } from "./WorkflowConfigDialog";
|
||||
|
||||
const MODE_LABELS: Record<ResearchMode, string> = {
|
||||
basic: "标准",
|
||||
@@ -65,6 +66,198 @@ export type AdvancedResearchOptions = {
|
||||
customOutline: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A deliberately domain-neutral structure which gives lightweight models a
|
||||
* dependable writing scaffold without forcing market-analysis terminology on
|
||||
* every research topic. Users can edit or replace it in the advanced dialog.
|
||||
*/
|
||||
export const DEFAULT_REPORT_OUTLINE = `# 报告标题
|
||||
|
||||
# 引言
|
||||
用 1–2 段说明研究对象、提出本议题的背景、研究范围及资料边界;简要交代全文的分析路径,不单独拆分小节。
|
||||
|
||||
# 一、研究对象与事实基础
|
||||
## 1.1 概念、范围与背景
|
||||
界定研究对象、关键概念、时间和空间范围;说明读者理解问题所需的背景。
|
||||
## 1.2 已有事实与证据
|
||||
按时间或逻辑顺序梳理已知事实、数据、案例或材料发现;优先使用可核验的来源。
|
||||
## 1.3 当前状态与主要特征
|
||||
归纳研究对象目前呈现的状态、关键变化、共性与差异;没有数据支撑时明确说明。
|
||||
|
||||
# 二、问题形成与作用机制
|
||||
## 2.1 直接原因与触发条件
|
||||
分析近期事件、条件变化或关键节点如何推动问题出现或演变。
|
||||
## 2.2 深层影响因素
|
||||
从制度、技术、资源、行为、环境或历史条件中选择适用维度,分析长期影响。
|
||||
## 2.3 因果关系与相互作用
|
||||
说明不同因素如何共同作用;区分来源已证实的事实、分析判断和仍待验证的推断。
|
||||
|
||||
# 三、影响、问题与不同观点
|
||||
## 3.1 对研究对象的直接影响
|
||||
从结果、效率、质量、风险、成本、体验或其他适用维度,分析当前影响。
|
||||
## 3.2 对相关主体与外部环境的影响
|
||||
说明影响如何传导到相关群体、组织、地区、系统或后续事项;不适用的维度可省略。
|
||||
## 3.3 主要争议与解释差异
|
||||
并列材料支持的不同观点、解释或结论,并说明各自的证据基础与局限。
|
||||
|
||||
# 四、比较、趋势与不确定性
|
||||
## 4.1 可比案例或横向比较
|
||||
在材料允许时选择历史案例、不同地区、不同方案或不同阶段进行比较,说明异同与启示。
|
||||
## 4.2 发展趋势与关键变量
|
||||
识别后续可能变化、决定性变量、观察指标和潜在转折点;不把预测写成既成事实。
|
||||
## 4.3 风险情景与不确定性
|
||||
说明不同情景的触发条件、可能影响和证据不足之处;对没有依据的推测明确标注。
|
||||
|
||||
# 五、结论、建议与后续关注
|
||||
## 5.1 核心结论
|
||||
用编号或分点方式收束全文;每条结论都应能回溯到正文中的事实和分析。
|
||||
## 5.2 可行建议或应对方向
|
||||
仅在研究材料支持时提出建议;说明适用对象、前提条件、优先级和预期作用。
|
||||
## 5.3 资料局限与后续关注
|
||||
列出证据缺口、数据口径差异、尚不能下结论的问题,以及值得持续跟踪的事项。
|
||||
|
||||
# 参考资料
|
||||
仅列出本报告实际使用的来源,并与正文引用保持一致。
|
||||
|
||||
> 写作要求:引言保持简洁;每个二级小节按材料充分程度写 2–5 段完整分析,每段围绕一个明确观点展开,采用“事实/证据 → 分析 → 影响或小结”的顺序。章节可根据研究主题合并、增删或调整,但不要为了套模板而编造不适用的主体、数据、案例或引用。`;
|
||||
|
||||
/**
|
||||
* A causal, chapter-to-chapter alternative for topics where the report should
|
||||
* read as one continuous argument rather than a set of parallel dimensions.
|
||||
* Each chapter explicitly takes the previous chapter's conclusion as input.
|
||||
*/
|
||||
export const PROGRESSIVE_REPORT_OUTLINE = `# 报告标题
|
||||
|
||||
# 引言
|
||||
用 1–2 段说明研究对象、核心问题、研究范围和资料边界;简要说明本文将按“事实基线 → 成因机制 → 影响判断 → 应对方向 → 结论”的递进逻辑展开。
|
||||
|
||||
# 一、事实基线:问题是什么
|
||||
## 1.1 概念界定与研究范围
|
||||
明确关键概念、时间/对象边界和判断口径,避免后续讨论混用概念。
|
||||
## 1.2 关键事实、数据与证据
|
||||
按时间或逻辑顺序列出已经发生的事实、可核验数据、案例和来源。
|
||||
## 1.3 基线判断与待解问题
|
||||
基于前两节归纳当前状态、主要矛盾和需要解释的核心问题。
|
||||
|
||||
> 本章产出:后续分析必须以本章已经确认的事实和问题为基础,不得在后文补写没有来源支撑的新事实。
|
||||
|
||||
# 二、形成机制:为什么会这样
|
||||
## 2.1 直接原因与触发条件
|
||||
从第一章识别的问题出发,分析近期事件、条件变化或关键节点如何导致当前结果。
|
||||
## 2.2 深层条件与关键约束
|
||||
结合第一章的证据,说明制度、技术、资源、行为、环境或历史条件等长期影响。
|
||||
## 2.3 因果链条与关键变量
|
||||
把前两节串成清晰的“条件 → 机制 → 结果”链条,标出决定性变量及仍待验证的环节。
|
||||
|
||||
> 本章产出:第三章只能沿用本章已说明的机制评估影响,不能把相关性直接写成因果关系。
|
||||
|
||||
# 三、结果与影响:会带来什么
|
||||
## 3.1 直接结果与短期影响
|
||||
依据第二章的因果链条,分析对研究对象最直接、最先出现的影响。
|
||||
## 3.2 传导效应与中长期影响
|
||||
继续说明影响如何传导至相关主体、外部环境或后续事项;不适用的维度可省略。
|
||||
## 3.3 权衡、分歧与边界
|
||||
比较不同影响之间的取舍、材料支持的不同观点,以及结论适用的边界。
|
||||
|
||||
> 本章产出:第四章的趋势判断、情景和建议应以本章已评估的影响、风险和权衡为依据。
|
||||
|
||||
# 四、趋势与应对:接下来怎么办
|
||||
## 4.1 未来走势与观察指标
|
||||
从第三章的影响出发,识别可能变化、关键观察指标和潜在转折点;不把预测写成既成事实。
|
||||
## 4.2 可选方案与适用条件
|
||||
针对第三章发现的问题和风险,提出可行的应对方向、前提条件与优先级。
|
||||
## 4.3 风险情景与验证重点
|
||||
说明不同情景的触发条件、可能后果,以及下一步需要补充验证的数据或材料。
|
||||
|
||||
> 本章产出:第五章只综合前四章已经建立的事实、机制、影响和建议,不新增独立论点。
|
||||
|
||||
# 五、综合结论与后续关注
|
||||
## 5.1 核心结论
|
||||
按“事实基线 → 原因机制 → 主要影响 → 应对方向”逐项收束,每条结论都可回溯到前文证据。
|
||||
## 5.2 局限与后续研究
|
||||
列出资料缺口、数据口径差异、尚不能下结论的问题和后续跟踪清单。
|
||||
|
||||
# 参考资料
|
||||
仅列出本报告实际使用的来源,并与正文引用保持一致。
|
||||
|
||||
> 写作要求:除引言外,每个二级小节写 2–5 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。`;
|
||||
|
||||
export type ReportOutlineTemplate = "general" | "progressive" | "custom";
|
||||
|
||||
const REPORT_OUTLINE_TEMPLATE_OPTIONS: Array<{ value: ReportOutlineTemplate; label: string }> = [
|
||||
{ value: "general", label: "通用分析结构" },
|
||||
{ value: "progressive", label: "递进式论证结构" },
|
||||
{ value: "custom", label: "自定义结构" },
|
||||
];
|
||||
|
||||
function matchingOutlineTemplate(outline: string): ReportOutlineTemplate {
|
||||
const normalized = outline.trim();
|
||||
if (normalized === DEFAULT_REPORT_OUTLINE.trim()) return "general";
|
||||
if (normalized === PROGRESSIVE_REPORT_OUTLINE.trim()) return "progressive";
|
||||
return "custom";
|
||||
}
|
||||
|
||||
/** Shared by the initial settings dialog and the write-confirmation card. */
|
||||
export function ReportOutlineEditor({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const [customSelected, setCustomSelected] = useState(false);
|
||||
const template = customSelected ? "custom" : matchingOutlineTemplate(value);
|
||||
|
||||
const selectTemplate = (next: ReportOutlineTemplate) => {
|
||||
if (next === "custom") {
|
||||
setCustomSelected(true);
|
||||
// “自定义” is an intentional blank starting point. The two presets are
|
||||
// still available as one-click scaffolds whenever the user wants them.
|
||||
onChange("");
|
||||
return;
|
||||
}
|
||||
setCustomSelected(false);
|
||||
onChange(next === "general" ? DEFAULT_REPORT_OUTLINE : PROGRESSIVE_REPORT_OUTLINE);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={`${id}-template`} className="block text-xs font-medium">
|
||||
报告结构模板
|
||||
</label>
|
||||
<Select value={template} onValueChange={(next) => selectTemplate(next as ReportOutlineTemplate)}>
|
||||
<SelectTrigger id={`${id}-template`} className="h-9 text-sm" aria-label="报告结构模板">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REPORT_OUTLINE_TEMPLATE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label htmlFor={id} className="block pt-1 text-xs font-medium">
|
||||
报告结构(可编辑)
|
||||
</label>
|
||||
<Textarea
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
setCustomSelected(true);
|
||||
onChange(event.target.value);
|
||||
}}
|
||||
maxLength={4000}
|
||||
className="min-h-28 resize-y text-xs"
|
||||
placeholder={"例如:\n# 引言\n# 一、问题与事实\n## 1.1 范围与依据"}
|
||||
/>
|
||||
<p className="text-[10px] leading-4 text-muted-foreground">
|
||||
选择预设模板会替换下方内容;选择“自定义”会清空后从头编辑。支持 Markdown 标题或编号列表。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
|
||||
language: "zh-CN",
|
||||
tone: "objective",
|
||||
@@ -78,7 +271,7 @@ export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
|
||||
maxRevisionLoops: "2",
|
||||
maxGeneratedImages: "2",
|
||||
thinkingEnabled: false,
|
||||
customOutline: "",
|
||||
customOutline: DEFAULT_REPORT_OUTLINE,
|
||||
};
|
||||
|
||||
/** Seed the card's editable options from the frozen session config snapshot. */
|
||||
@@ -100,7 +293,9 @@ export function advancedOptionsFromConfig(config?: Record<string, unknown>): Adv
|
||||
maxRevisionLoops: numberish("max_revision_loops", DEFAULT_ADVANCED_OPTIONS.maxRevisionLoops),
|
||||
maxGeneratedImages: numberish("max_generated_images", DEFAULT_ADVANCED_OPTIONS.maxGeneratedImages),
|
||||
thinkingEnabled: config?.thinking_enabled === true,
|
||||
customOutline: typeof config?.custom_outline === "string" ? config.custom_outline : "",
|
||||
customOutline: typeof config?.custom_outline === "string" && config.custom_outline.trim()
|
||||
? config.custom_outline
|
||||
: DEFAULT_REPORT_OUTLINE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,11 +369,6 @@ export function ReportConfigCard({
|
||||
const [generateImages, setGenerateImages] = useState(defaultGenerateImages);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setOptions(defaultOptions);
|
||||
setGenerateImages(defaultGenerateImages);
|
||||
}, [defaultOptions, defaultGenerateImages]);
|
||||
|
||||
const modes = useMemo(
|
||||
() => (availableModes.length ? availableModes : (["basic", "quick", "detailed", "deep", "multi_agent"] as ResearchMode[])),
|
||||
[availableModes],
|
||||
@@ -278,15 +468,17 @@ export function ReportConfigCard({
|
||||
<CardNumber label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(v) => update("maxRevisionLoops", v)} />
|
||||
)}
|
||||
<div className="sm:col-span-2">
|
||||
<span className="mb-1.5 block text-xs font-medium">自定义报告结构</span>
|
||||
<Textarea
|
||||
<ReportOutlineEditor
|
||||
id="report-card-outline"
|
||||
value={options.customOutline}
|
||||
onChange={(event) => update("customOutline", event.target.value)}
|
||||
maxLength={4000}
|
||||
className="min-h-20 resize-y text-xs"
|
||||
placeholder={"例如:\n# 执行摘要\n## 市场与背景\n## 机会与风险\n## 建议"}
|
||||
onChange={(value) => update("customOutline", value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end sm:col-span-2">
|
||||
<WorkflowConfigButton
|
||||
appliedContent={options.customOutline}
|
||||
onApply={(value) => update("customOutline", value)}
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">支持 Markdown 标题或编号列表,作为报告写作结构要求。</p>
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4 sm:col-span-2">
|
||||
<input
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
* 切预览 + 脉冲、复制/另存、展示层脱敏)。
|
||||
*/
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { FileTextIcon, XIcon } from "lucide-react";
|
||||
import { FileTextIcon, Loader2Icon, SparklesIcon, XIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { ArtifactAction } from "@/components/ai-elements/artifact";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ResizableHandle,
|
||||
@@ -136,18 +137,6 @@ export function ResearchSandboxPanel({
|
||||
<div className="relative h-full bg-[#fffdfa] p-4">
|
||||
{selectedArtifact && previewThreadId ? (
|
||||
<>
|
||||
{onDocumentRewriteStart && (
|
||||
<div className="absolute top-7 right-36 z-30">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={documentRewriteActive}
|
||||
onClick={() => setRewriteDialogOpen(true)}
|
||||
>
|
||||
{documentRewriteActive ? "正在改写" : "AI 全文改写"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{documentRewriteActive && onCancelDocumentRewrite && (
|
||||
<div className="absolute right-36 bottom-7 z-30">
|
||||
<Button size="sm" variant="outline" onClick={onCancelDocumentRewrite}>
|
||||
@@ -159,8 +148,24 @@ export function ResearchSandboxPanel({
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={previewThreadId}
|
||||
researchSessionId={previewThreadId}
|
||||
onClose={handleArtifactsClose}
|
||||
onDownloadClick={onDownload}
|
||||
toolbarActions={
|
||||
onDocumentRewriteStart ? (
|
||||
<ArtifactAction
|
||||
icon={documentRewriteActive ? Loader2Icon : SparklesIcon}
|
||||
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
|
||||
tooltip={
|
||||
documentRewriteActive
|
||||
? "正在生成临时草稿,原报告尚未被覆盖"
|
||||
: "使用 AI 重写完整 report.md"
|
||||
}
|
||||
disabled={documentRewriteActive}
|
||||
onClick={() => setRewriteDialogOpen(true)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 工作流配置(报告结构库)弹窗 + 触发按钮。
|
||||
*
|
||||
* 深度研究「高级配置」区的人口:按钮打开大弹窗,弹窗内两视图——
|
||||
* - 列表视图:报告结构卡片网格(后台 /api/report-structures,全局共享,
|
||||
* 全新部署自动播种「通用分析结构 / 递进式论证结构」),支持 启用/停用 筛选
|
||||
* 与 标题 筛选;卡片上可 switch 启停、编辑、删除(二次确认),并显示
|
||||
* 最后修改时间与创建用户名;新增时不填状态(服务端强制默认启用)。
|
||||
* - 详情视图:点击卡片即「选择」该结构(内容回填到高级配置的报告结构
|
||||
* custom_outline),弹窗切换为展示 标题 + 接口内容,右上角「返回」回列表。
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import {
|
||||
ChevronLeft,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Workflow,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
type ReportStructure,
|
||||
type ReportStructureStatusFilter,
|
||||
useReportStructures,
|
||||
useReportStructureMutations,
|
||||
} from "@/strategy-components/api/report-structures";
|
||||
|
||||
// ── shared helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function formatDateTime(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return date.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/** 创建用户名:本部署账号是邮箱,取 @ 前缀(与个人信息页「用户名」一致)。 */
|
||||
function creatorLabel(structure: ReportStructure): string {
|
||||
if (!structure.createdBy) return "—";
|
||||
return structure.createdBy.split("@")[0] || structure.createdBy;
|
||||
}
|
||||
|
||||
// ── main dialog ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function WorkflowConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
/** 当前已应用的报告结构内容(高级配置的 custom_outline),用于高亮「当前使用」。 */
|
||||
appliedContent,
|
||||
/** 点击卡片选择结构时回填内容。 */
|
||||
onApply,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
appliedContent?: string;
|
||||
onApply?: (content: string) => void;
|
||||
}) {
|
||||
const [statusFilter, setStatusFilter] = useState<ReportStructureStatusFilter>("all");
|
||||
const [titleFilter, setTitleFilter] = useState("");
|
||||
const [detailId, setDetailId] = useState<string | null>(null);
|
||||
const [editorTarget, setEditorTarget] = useState<ReportStructure | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ReportStructure | null>(null);
|
||||
|
||||
// 启用/停用筛选走服务端;标题筛选在本地做(即时、免抖动)。
|
||||
const listQuery = useReportStructures({ status: statusFilter });
|
||||
const mutations = useReportStructureMutations();
|
||||
|
||||
// 重新打开时回到列表视图。
|
||||
useEffect(() => {
|
||||
if (open) setDetailId(null);
|
||||
}, [open]);
|
||||
|
||||
const structures = listQuery.data ?? [];
|
||||
const visible = useMemo(() => {
|
||||
const needle = titleFilter.trim().toLowerCase();
|
||||
if (!needle) return structures;
|
||||
return structures.filter((s) => s.title.toLowerCase().includes(needle));
|
||||
}, [structures, titleFilter]);
|
||||
|
||||
const appliedStructureId = useMemo(() => {
|
||||
const trimmed = (appliedContent ?? "").trim();
|
||||
if (!trimmed) return null;
|
||||
return structures.find((s) => s.content.trim() === trimmed)?.id ?? null;
|
||||
}, [appliedContent, structures]);
|
||||
|
||||
const detail = useMemo(
|
||||
() => structures.find((s) => s.id === detailId) ?? null,
|
||||
[structures, detailId],
|
||||
);
|
||||
|
||||
const handleSelect = (structure: ReportStructure) => {
|
||||
onApply?.(structure.content);
|
||||
setDetailId(structure.id);
|
||||
toast.success(`已选择报告结构「${structure.title}」,并填充到报告结构配置`);
|
||||
};
|
||||
|
||||
const handleToggleEnabled = (structure: ReportStructure, enabled: boolean) => {
|
||||
mutations.toggleEnabled.mutate(
|
||||
{ id: structure.id, enabled },
|
||||
{
|
||||
onSuccess: () => toast.success(`「${structure.title}」已${enabled ? "启用" : "停用"}`),
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : "操作失败"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await mutations.remove.mutateAsync(deleteTarget.id);
|
||||
toast.success(`已删除「${deleteTarget.title}」`);
|
||||
if (detailId === deleteTarget.id) setDetailId(null);
|
||||
setDeleteTarget(null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex h-[85vh] max-h-[85vh] w-[calc(100vw-3rem)] max-w-5xl flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl"
|
||||
>
|
||||
{/* header:列表视图=标题+筛选+新增;详情视图=结构标题,右上角「返回」 */}
|
||||
{!detail ? (
|
||||
<div className="shrink-0 border-b border-border px-5 py-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-base font-semibold">工作流配置 · 报告结构</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
点击卡片选择报告结构(自动填充到报告结构配置);可启停、编辑、删除。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
aria-label="关闭"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-0 flex-1 sm:max-w-xs">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={titleFilter}
|
||||
onChange={(event) => setTitleFilter(event.target.value)}
|
||||
placeholder="按标题筛选"
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(value) => setStatusFilter(value as ReportStructureStatusFilter)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-28 shrink-0 text-xs" aria-label="启用状态筛选">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="enabled">已启用</SelectItem>
|
||||
<SelectItem value="disabled">已停用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<Badge variant="outline" className="h-6 text-[10px]">{visible.length} 套</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
setEditorTarget(null);
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />新增结构
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="outline" className="shrink-0">报告结构</Badge>
|
||||
<h2 className="truncate text-base font-semibold">{detail.title}</h2>
|
||||
{detail.id === appliedStructureId && (
|
||||
<Badge className="shrink-0">当前使用</Badge>
|
||||
)}
|
||||
{!detail.enabled && <Badge variant="secondary" className="shrink-0">已停用</Badge>}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setDetailId(null)}>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />返回
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* body */}
|
||||
{!detail ? (
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="p-5">
|
||||
{listQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />加载中…
|
||||
</div>
|
||||
) : listQuery.isError ? (
|
||||
<div className="py-16 text-center text-sm text-destructive">
|
||||
{(listQuery.error as Error)?.message || "加载报告结构失败"}
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-16 text-center text-sm text-muted-foreground">
|
||||
<Workflow className="h-10 w-10 text-muted-foreground/30" />
|
||||
{titleFilter || statusFilter !== "all" ? "没有符合条件的报告结构" : "暂无报告结构,点击右上角「新增结构」创建"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{visible.map((structure) => (
|
||||
<StructureCard
|
||||
key={structure.id}
|
||||
structure={structure}
|
||||
applied={structure.id === appliedStructureId}
|
||||
onOpen={() => handleSelect(structure)}
|
||||
onEdit={(event) => {
|
||||
event.stopPropagation();
|
||||
setEditorTarget(structure);
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
onDelete={(event) => {
|
||||
event.stopPropagation();
|
||||
setDeleteTarget(structure);
|
||||
}}
|
||||
onToggleEnabled={(enabled) => handleToggleEnabled(structure, enabled)}
|
||||
toggling={mutations.toggleEnabled.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="mx-auto max-w-3xl p-5">
|
||||
{detail.name && (
|
||||
<p className="mb-1 text-xs text-muted-foreground">描述:{detail.name}</p>
|
||||
)}
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
最后修改:{formatDateTime(detail.updatedAt)} · 创建人:{creatorLabel(detail)}
|
||||
</p>
|
||||
<article className="prose prose-sm dark:prose-invert max-w-none rounded-xl border border-border bg-card p-5">
|
||||
<ReactMarkdown components={{ img: () => null }}>{detail.content}</ReactMarkdown>
|
||||
</article>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 新增 / 编辑弹窗(嵌套) */}
|
||||
<StructureEditorDialog
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
target={editorTarget}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
if (editorTarget) {
|
||||
await mutations.update.mutateAsync({ id: editorTarget.id, patch: values });
|
||||
toast.success("报告结构已保存");
|
||||
} else {
|
||||
await mutations.create.mutateAsync(values);
|
||||
toast.success("已新增报告结构(默认启用)");
|
||||
}
|
||||
setEditorOpen(false);
|
||||
setEditorTarget(null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "保存失败");
|
||||
}
|
||||
}}
|
||||
submitting={mutations.create.isPending || mutations.update.isPending}
|
||||
/>
|
||||
|
||||
{/* 删除二次确认(嵌套) */}
|
||||
<Dialog open={deleteTarget !== null} onOpenChange={(next) => !next && setDeleteTarget(null)}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除报告结构</DialogTitle>
|
||||
<DialogDescription>
|
||||
确认删除「{deleteTarget?.title}」?删除后不可恢复。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={mutations.remove.isPending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => void handleDelete()} disabled={mutations.remove.isPending}>
|
||||
{mutations.remove.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── structure card ───────────────────────────────────────────────────────────
|
||||
|
||||
function StructureCard({
|
||||
structure,
|
||||
applied,
|
||||
onOpen,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleEnabled,
|
||||
toggling,
|
||||
}: {
|
||||
structure: ReportStructure;
|
||||
applied: boolean;
|
||||
onOpen: () => void;
|
||||
onEdit: (event: React.MouseEvent) => void;
|
||||
onDelete: (event: React.MouseEvent) => void;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
toggling: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
className={`group cursor-pointer rounded-xl border p-4 text-left transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
||||
applied
|
||||
? "border-primary/50 bg-primary/5"
|
||||
: "border-border bg-card hover:border-primary/30 hover:bg-accent/40"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1.5 flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="truncate text-sm font-semibold">{structure.title}</span>
|
||||
{applied && <Badge className="h-5 shrink-0 text-[10px]">当前使用</Badge>}
|
||||
<Badge variant={structure.enabled ? "default" : "outline"} className={`h-5 shrink-0 text-[10px] ${structure.enabled ? "" : "text-muted-foreground"}`}>
|
||||
{structure.enabled ? "启用" : "停用"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
title="编辑"
|
||||
aria-label={`编辑 ${structure.title}`}
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="删除"
|
||||
aria-label={`删除 ${structure.title}`}
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{structure.name && <p className="mb-2 truncate text-xs text-muted-foreground">{structure.name}</p>}
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-2.5">
|
||||
<div className="min-w-0 text-[11px] leading-4 text-muted-foreground">
|
||||
<p className="truncate">最后修改:{formatDateTime(structure.updatedAt)}</p>
|
||||
<p className="truncate">创建人:{creatorLabel(structure)}</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1.5"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<span className="text-[11px] text-muted-foreground">{structure.enabled ? "启用" : "停用"}</span>
|
||||
<Switch
|
||||
checked={structure.enabled}
|
||||
disabled={toggling}
|
||||
aria-label={`${structure.enabled ? "停用" : "启用"} ${structure.title}`}
|
||||
onCheckedChange={onToggleEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── editor dialog(新增 / 编辑共用;状态不允许填写,创建默认启用) ─────────────
|
||||
|
||||
function StructureEditorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
target,
|
||||
onSubmit,
|
||||
submitting,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
target: ReportStructure | null;
|
||||
onSubmit: (values: { title: string; name: string; content: string }) => Promise<void>;
|
||||
submitting: boolean;
|
||||
}) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTitle(target?.title ?? "");
|
||||
setName(target?.name ?? "");
|
||||
setContent(target?.content ?? "");
|
||||
}
|
||||
}, [open, target]);
|
||||
|
||||
const valid = title.trim().length > 0 && content.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] w-[calc(100vw-2rem)] max-w-2xl overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{target ? "编辑报告结构" : "新增报告结构"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{target ? "修改后将刷新「最后修改时间」。" : "新增的报告结构默认为启用状态。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium">标题</span>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
maxLength={256}
|
||||
placeholder="例如:行业研究通用结构"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium">描述</span>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
maxLength={256}
|
||||
placeholder="可选,一句话描述这套结构的适用场景"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium">接口内容</span>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
maxLength={20000}
|
||||
placeholder={"报告结构正文,支持 Markdown 标题,例如:\n# 引言\n# 一、事实与背景\n## 1.1 研究范围"}
|
||||
className="min-h-[260px] resize-y font-mono text-xs"
|
||||
/>
|
||||
<span className="mt-1 block text-[11px] text-muted-foreground">
|
||||
选中卡片时,该内容会填充到高级配置的「报告结构」。
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={!valid || submitting} onClick={() => void onSubmit({ title: title.trim(), name: name.trim(), content: content })}>
|
||||
{submitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ── trigger button(自带弹窗开合状态) ────────────────────────────────────────
|
||||
|
||||
export function WorkflowConfigButton({
|
||||
appliedContent,
|
||||
onApply,
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "sm",
|
||||
disabled = false,
|
||||
}: {
|
||||
appliedContent?: string;
|
||||
onApply: (content: string) => void;
|
||||
className?: string;
|
||||
variant?: React.ComponentProps<typeof Button>["variant"];
|
||||
size?: React.ComponentProps<typeof Button>["size"];
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button variant={variant} size={size} className={className} disabled={disabled} onClick={() => setOpen(true)}>
|
||||
<Workflow className="mr-1.5 h-3.5 w-3.5" />
|
||||
工作流配置
|
||||
</Button>
|
||||
<WorkflowConfigDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
appliedContent={appliedContent}
|
||||
onApply={onApply}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
artifactPreviewKey,
|
||||
useArtifacts,
|
||||
} from "@/components/workspace/artifacts";
|
||||
import { stripInlineThinkTags } from "@/core/streamdown/inline-think";
|
||||
|
||||
/** 报告在沙箱中的固定虚拟路径(与 runner 实际写入的 artifacts 路径一致)。 */
|
||||
export const REPORT_OUTPUT_PATH = "/mnt/user-data/outputs/report.md";
|
||||
@@ -72,7 +73,12 @@ export function useReportSandbox({ threadKey, report, writing }: UseReportSandbo
|
||||
setStubMessage((prev) => {
|
||||
const prevArgs = prev?.tool_calls?.[0]?.args as { content?: unknown } | undefined;
|
||||
const prevContent = typeof prevArgs?.content === "string" ? prevArgs.content : "";
|
||||
const content = report.trim() ? report : prevContent;
|
||||
// Backstop the report sandbox against providers that expose reasoning
|
||||
// through raw `<think>` tags in ordinary text chunks. The backend
|
||||
// filters them at the source; this also protects historical/replayed
|
||||
// streams generated by an older service.
|
||||
const visibleReport = stripInlineThinkTags(report);
|
||||
const content = visibleReport.trim() ? visibleReport : prevContent;
|
||||
return {
|
||||
id: messageId,
|
||||
type: "ai",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
|
||||
/**
|
||||
* 节点状态由岗位会商服务推进;前端只据此决定可否发送、展示进度,
|
||||
@@ -147,10 +148,7 @@ const BASE = () => `${getBackendBaseURL()}${PRIMARY_API_PATH}`;
|
||||
* (不会重复 +revision / 重复解锁下游 / 重复驳回)。每次新的用户操作则取新键。
|
||||
*/
|
||||
function newCommandId(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID().replace(/-/g, "");
|
||||
}
|
||||
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
|
||||
return createClientId().replace(/-/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* 页面无关:8BF 等独立会商页可以直接复用岗位会商的纵向流程图视觉与交互,
|
||||
* 不会读取或写入岗位会商的会话、岗位节点状态。
|
||||
*/
|
||||
import { useRef, useState } from "react";
|
||||
import { CheckCircle2, Circle, Search } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, Search } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
@@ -13,8 +13,14 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
positionNameById,
|
||||
syncPositionOptionsFromRoles,
|
||||
} from "@/position-roundtable/lib/positions";
|
||||
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
|
||||
import { usePositionRoles } from "@/strategy-components/api/position-roles";
|
||||
|
||||
const NODE_COLORS = ["#4f7df3", "#a778f5", "#65b4e8", "#f0a245", "#e675a8"];
|
||||
const FLOW_NODE_WIDTH = 184;
|
||||
@@ -29,6 +35,19 @@ export interface PositionBusinessChainFlowProps {
|
||||
chain: BusinessChain | null;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
/**
|
||||
* Render inside the Step 1 intent-delivery surface. The host owns the
|
||||
* border and background; this flow keeps only its title row and canvas.
|
||||
*/
|
||||
embedded?: boolean;
|
||||
/**
|
||||
* Identity keys of seats whose Markdown artifact has been submitted.
|
||||
* Matched against `seat.agent_id` and `seat.name` (case-insensitive).
|
||||
* Green filled check = submitted only, same as 岗位会商 `is-delivered`.
|
||||
*/
|
||||
submittedSeatKeys?: readonly string[];
|
||||
/** Click a seat node. Host opens the deliverable dialog when a submission exists. */
|
||||
onSeatClick?: (seat: ChainSeat) => void;
|
||||
}
|
||||
|
||||
function getFlowStages(chain: BusinessChain): FlowStage[] {
|
||||
@@ -46,37 +65,70 @@ function getFlowStages(chain: BusinessChain): FlowStage[] {
|
||||
return chain.seats.map((seat) => ({ goal: null, seats: [seat] }));
|
||||
}
|
||||
|
||||
function isSeatSubmitted(seat: ChainSeat, submittedSeatKeys: ReadonlySet<string>): boolean {
|
||||
return submittedSeatKeys.has(seat.agent_id.trim().toLowerCase())
|
||||
|| submittedSeatKeys.has((seat.name ?? "").trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function PositionBusinessChainFlow({
|
||||
chain,
|
||||
loading = false,
|
||||
className,
|
||||
embedded = false,
|
||||
submittedSeatKeys,
|
||||
onSeatClick,
|
||||
}: PositionBusinessChainFlowProps) {
|
||||
const { data: positionRoles = [] } = usePositionRoles();
|
||||
useEffect(() => {
|
||||
syncPositionOptionsFromRoles(positionRoles);
|
||||
}, [positionRoles]);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const submittedKeys = new Set(
|
||||
(submittedSeatKeys ?? []).map((key) => key.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
const handleSeatClick = (seat: ChainSeat) => {
|
||||
onSeatClick?.(seat);
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"position-business-chain-flow flex h-full min-h-[460px] w-full flex-col overflow-hidden rounded-2xl border border-[#ece7e4] bg-white p-3 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/55 dark:shadow-none",
|
||||
embedded && "min-h-0 rounded-none border-0 bg-transparent p-0 shadow-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-2 border-b border-slate-200 pb-3 dark:border-[#2b8ffe]/40">
|
||||
<div
|
||||
className={cn(
|
||||
"mb-3 flex shrink-0 items-center justify-between gap-2 border-b border-slate-200 pb-3 dark:border-[#2b8ffe]/40",
|
||||
embedded && "relative mb-4 h-10 border-slate-200 pb-0 dark:border-[#2b8ffe]/60",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-6 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167deg,#f15959_0%,#cc1925_100%)] dark:bg-[linear-gradient(167deg,#a2e1f9_0%,#45c3f3_100%)]"
|
||||
className={cn(
|
||||
"h-6 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167deg,#f15959_0%,#cc1925_100%)] dark:bg-[linear-gradient(167deg,#a2e1f9_0%,#45c3f3_100%)]",
|
||||
embedded && "h-6",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-base font-semibold text-slate-900 dark:text-white">业务链条</h2>
|
||||
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
|
||||
{chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
|
||||
</p>
|
||||
<h2 className={cn("truncate text-base font-semibold text-slate-900 dark:text-white", embedded && "text-lg font-[550] dark:text-white")}>业务链条</h2>
|
||||
{!embedded && (
|
||||
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
|
||||
{chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{chain && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-[#f0c8c8] bg-[#fff7f7] text-[#cc1925] transition-colors hover:border-[#cc1925] hover:bg-[#fff0f0] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cc1925]/30 dark:border-[#2b8ffe]/45 dark:bg-[#082d5f] dark:text-[#8fc4ff]"
|
||||
className={cn(
|
||||
"flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-[#f0c8c8] bg-[#fff7f7] text-[#cc1925] transition-colors hover:border-[#cc1925] hover:bg-[#fff0f0] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cc1925]/30 dark:border-[#2b8ffe]/45 dark:bg-[#082d5f] dark:text-[#8fc4ff]",
|
||||
embedded && "h-7 w-7 rounded-[4px]",
|
||||
)}
|
||||
title="查看完整流程图"
|
||||
aria-label="查看完整流程图"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
@@ -92,14 +144,26 @@ export function PositionBusinessChainFlow({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<BusinessChainCanvas chain={chain} className="min-h-0 flex-1" />
|
||||
<BusinessChainCanvas
|
||||
chain={chain}
|
||||
embedded={embedded}
|
||||
submittedKeys={submittedKeys}
|
||||
onSeatClick={onSeatClick ? handleSeatClick : undefined}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="flex h-[86vh] w-[96vw] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
|
||||
<DialogTitle className="text-base font-semibold">{chain.title} · 业务链条流程图</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 p-4">
|
||||
<BusinessChainCanvas chain={chain} expanded className="h-full" />
|
||||
<BusinessChainCanvas
|
||||
chain={chain}
|
||||
expanded
|
||||
submittedKeys={submittedKeys}
|
||||
onSeatClick={onSeatClick ? handleSeatClick : undefined}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -112,18 +176,24 @@ export function PositionBusinessChainFlow({
|
||||
function BusinessChainCanvas({
|
||||
chain,
|
||||
expanded = false,
|
||||
embedded = false,
|
||||
submittedKeys,
|
||||
onSeatClick,
|
||||
className,
|
||||
}: {
|
||||
chain: BusinessChain;
|
||||
expanded?: boolean;
|
||||
embedded?: boolean;
|
||||
submittedKeys: ReadonlySet<string>;
|
||||
onSeatClick?: (seat: ChainSeat) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const [view, setView] = useState({ tx: 0, ty: 0 });
|
||||
const [panning, setPanning] = useState(false);
|
||||
const dragRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
|
||||
const stages = getFlowStages(chain);
|
||||
const nodeWidth = expanded ? 250 : FLOW_NODE_WIDTH;
|
||||
const nodeGap = expanded ? 18 : FLOW_NODE_GAP;
|
||||
const nodeWidth = expanded ? 250 : embedded ? 158 : FLOW_NODE_WIDTH;
|
||||
const nodeGap = expanded ? 18 : embedded ? 10 : FLOW_NODE_GAP;
|
||||
let colorIndex = 0;
|
||||
|
||||
const endPan = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
@@ -141,6 +211,7 @@ function BusinessChainCanvas({
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-lg border border-slate-100 bg-slate-50/70 touch-none dark:border-[#2b8ffe]/25 dark:bg-[#032650]/75",
|
||||
embedded && "rounded-[4px]",
|
||||
panning ? "cursor-grabbing" : "cursor-grab",
|
||||
className,
|
||||
)}
|
||||
@@ -152,6 +223,7 @@ function BusinessChainCanvas({
|
||||
onDoubleClick={() => setView({ tx: 0, ty: 0 })}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
if ((event.target as HTMLElement | null)?.closest?.(".pr-flow-card")) return;
|
||||
dragRef.current = { x: event.clientX, y: event.clientY, tx: view.tx, ty: view.ty };
|
||||
setPanning(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
@@ -189,10 +261,13 @@ function BusinessChainCanvas({
|
||||
return (
|
||||
<FlowNode
|
||||
key={seat.agent_id}
|
||||
title={seat.name || seat.agent_id}
|
||||
description={stage.goal?.trim() || seat.description?.trim() || "业务链席位"}
|
||||
title={desensitize(seat.name || seat.agent_id)}
|
||||
description={desensitize(positionNameById(seat.position_id))}
|
||||
accent={accent}
|
||||
width={nodeWidth}
|
||||
compact={embedded}
|
||||
completed={isSeatSubmitted(seat, submittedKeys)}
|
||||
onClick={onSeatClick ? () => onSeatClick(seat) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -252,28 +327,36 @@ function FlowNode({
|
||||
accent,
|
||||
completed = false,
|
||||
width,
|
||||
compact = false,
|
||||
onClick,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
accent: string;
|
||||
completed?: boolean;
|
||||
width: number;
|
||||
compact?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const StatusIcon = completed ? CheckCircle2 : Circle;
|
||||
return (
|
||||
<article
|
||||
className="relative overflow-hidden rounded-xl border border-slate-200 bg-white p-3 pr-10 shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0a2d58]"
|
||||
style={{ width, borderLeftWidth: 5, borderLeftColor: accent }}
|
||||
>
|
||||
const className = cn(
|
||||
"pr-flow-card relative overflow-hidden rounded-xl border border-slate-200 bg-white p-3 pr-10 text-left font-[inherit] shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0a2d58]",
|
||||
compact && "rounded-lg",
|
||||
completed && "border-[#36b987]/60",
|
||||
onClick && "cursor-pointer transition hover:-translate-y-0.5 hover:shadow-md",
|
||||
);
|
||||
const style = { width, borderLeftWidth: 5, borderLeftColor: accent };
|
||||
const body = (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute right-2 top-2 flex size-6 items-center justify-center rounded-full border bg-white shadow-sm dark:bg-[#0d3768]",
|
||||
"absolute right-2 top-2 grid size-5 place-items-center rounded-full",
|
||||
completed
|
||||
? "border-emerald-500 text-emerald-500"
|
||||
: "border-slate-300 text-slate-400 dark:border-[#8aa0c4] dark:text-[#8aa0c4]",
|
||||
? "border-0 bg-[#36b987] text-white"
|
||||
: "border-2 border-[#78a7e8] bg-transparent text-white",
|
||||
)}
|
||||
title={completed ? "已完成" : "待提交"}
|
||||
>
|
||||
<StatusIcon className="size-3.5" strokeWidth={2.5} />
|
||||
{completed ? <Check className="size-3" strokeWidth={3} /> : null}
|
||||
</span>
|
||||
<p className="truncate text-sm font-extrabold leading-5 text-slate-900 dark:text-[#e8edf5]" title={title}>
|
||||
{title}
|
||||
@@ -281,6 +364,25 @@ function FlowNode({
|
||||
<p className="mt-0.5 truncate text-xs font-semibold leading-4" style={{ color: accent }} title={description}>
|
||||
{description}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
style={style}
|
||||
title={completed ? "已完成 · 点击查看产物" : "点击查看产物"}
|
||||
onClick={onClick}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<article className={className} style={style}>
|
||||
{body}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Select as TSelect } from "tdesign-react";
|
||||
|
||||
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
|
||||
import { normalizeAgentQuestions, type AgentQuestion } from "@/core/agents";
|
||||
@@ -27,6 +26,8 @@ interface PositionIntentInputProps extends Step1InputAreaProps {
|
||||
selectedModel?: string;
|
||||
onSelectedModelChange?: (model: string) => void;
|
||||
commonQuestions?: (string | AgentQuestion)[] | null;
|
||||
/** 仅保留上传、模型选择和发送的紧凑输入区(用于会商第 3 步)。 */
|
||||
hideUtilityActions?: boolean;
|
||||
}
|
||||
|
||||
/** Keep attachment metadata presentation local to this position-only composer. */
|
||||
@@ -67,6 +68,7 @@ export function PositionIntentInput({
|
||||
selectedModel,
|
||||
onSelectedModelChange,
|
||||
commonQuestions,
|
||||
hideUtilityActions = false,
|
||||
}: PositionIntentInputProps) {
|
||||
// Files are owned by Step1Panel. This ref only opens the native picker and
|
||||
// forwards its selection through onAddFiles, so replacement layouts do not
|
||||
@@ -76,6 +78,12 @@ export function PositionIntentInput({
|
||||
const normalizedCommonQuestions = normalizeAgentQuestions(commonQuestions);
|
||||
const visibleCommonQuestions = normalizedCommonQuestions.slice(0, 2);
|
||||
const extraCommonQuestions = normalizedCommonQuestions.slice(2);
|
||||
// 这里的模型列表会随着会商流的状态频繁刷新。TDesign Select 会在 options 的引用
|
||||
// 每次变化时执行一次内部同步;流式收尾阶段恰好会形成 effect 更新循环并打断整页。
|
||||
// 原生 select 是受控且无内部同步状态,三个步骤共用这个输入框后也能保持稳定。
|
||||
const modelValue = modelOptions.some((option) => option.value === selectedModel)
|
||||
? selectedModel
|
||||
: (modelOptions[0]?.value ?? "");
|
||||
|
||||
const pickCommonQuestion = (question: AgentQuestion) => {
|
||||
onValueChange(question.prompt);
|
||||
@@ -242,16 +250,19 @@ export function PositionIntentInput({
|
||||
title="鍒囨崲鎰忓浘鐞嗚В妯″瀷"
|
||||
>
|
||||
<Sparkles className="size-3.5 shrink-0 text-[#73c8ff]" />
|
||||
<TSelect
|
||||
size="small"
|
||||
aria-label="鍒囨崲鎰忓浘鐞嗚В妯″瀷"
|
||||
value={selectedModel}
|
||||
options={modelOptions}
|
||||
<select
|
||||
aria-label="切换模型"
|
||||
value={modelValue}
|
||||
disabled={isSending}
|
||||
onChange={(value) => onSelectedModelChange?.(String(value))}
|
||||
className="w-[150px] shrink-0 [&_.t-input]:!h-7 [&_.t-input]:!rounded-[4px] [&_.t-input]:!border-0 [&_.t-input]:!bg-transparent [&_.t-input]:!px-1 [&_.t-input]:!text-xs [&_.t-input]:!font-bold [&_.t-input]:!text-[#dceaff] [&_.t-input]:!shadow-none"
|
||||
popupProps={{ overlayClassName: "z-[80]" }}
|
||||
/>
|
||||
onChange={(event) => onSelectedModelChange?.(event.target.value)}
|
||||
className="h-7 w-[150px] shrink-0 cursor-pointer appearance-none bg-transparent px-1 pr-5 text-xs font-bold text-[#dceaff] outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{modelOptions.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-[#0b3168] text-[#dceaff]">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
@@ -271,25 +282,29 @@ export function PositionIntentInput({
|
||||
<span className="sr-only">{isSending ? "暂停生成" : "发送"}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="suggest-followup-btn"
|
||||
type="button"
|
||||
onClick={onFollowUp}
|
||||
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
|
||||
>
|
||||
<Repeat className="size-3 text-slate-500" />
|
||||
继续追问
|
||||
</button>
|
||||
{!hideUtilityActions && (
|
||||
<>
|
||||
<button
|
||||
id="suggest-followup-btn"
|
||||
type="button"
|
||||
onClick={onFollowUp}
|
||||
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
|
||||
>
|
||||
<Repeat className="size-3 text-slate-500" />
|
||||
继续追问
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="skip-question-btn"
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
|
||||
>
|
||||
<X className="size-3 text-slate-500" strokeWidth={2.5} />
|
||||
跳过此项
|
||||
</button>
|
||||
<button
|
||||
id="skip-question-btn"
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
|
||||
>
|
||||
<X className="size-3 text-slate-500" strokeWidth={2.5} />
|
||||
跳过此项
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{footerAction ??
|
||||
(!hideEnterRoundtable && (
|
||||
@@ -309,15 +324,17 @@ export function PositionIntentInput({
|
||||
{enterButtonLabel}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
id="save-draft-btn"
|
||||
type="button"
|
||||
onClick={onManualSave}
|
||||
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
|
||||
>
|
||||
<Save className="size-3 text-slate-500" />
|
||||
保存草稿
|
||||
</button>
|
||||
{!hideUtilityActions && (
|
||||
<button
|
||||
id="save-draft-btn"
|
||||
type="button"
|
||||
onClick={onManualSave}
|
||||
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
|
||||
>
|
||||
<Save className="size-3 text-slate-500" />
|
||||
保存草稿
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -88,3 +88,31 @@ export function resolvePositionId(
|
||||
|
||||
export const positionNameById = (positionId: string | null | undefined): string =>
|
||||
runtimePositionOptions.find((position) => position.id === positionId)?.name ?? "未分配岗位";
|
||||
|
||||
/** Sync the flowchart/agent-config name lookup from the live 岗位角色 directory. */
|
||||
export function syncPositionOptionsFromRoles(
|
||||
roles: ReadonlyArray<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
roleType?: string;
|
||||
enabled?: boolean;
|
||||
isBuiltin?: boolean;
|
||||
sortOrder?: number;
|
||||
}>,
|
||||
): void {
|
||||
if (!roles.length) return;
|
||||
setRuntimePositionOptions(
|
||||
[...roles]
|
||||
.sort((left, right) => (left.sortOrder ?? 0) - (right.sortOrder ?? 0))
|
||||
.map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
isIntentPosition: role.roleType === "intent",
|
||||
enabled: role.enabled,
|
||||
isBuiltin: role.isBuiltin,
|
||||
sortOrder: role.sortOrder,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from "tdesign-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { AnalysisResultCardList, type AnalysisResultCardItem } from "@/components/analysis-results";
|
||||
import { agentColor } from "@/components/workspace/agent-icon-color";
|
||||
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
|
||||
import { useAgentModuleSidebarData } from "@/components/workspace/agents/agent-module-sidebar";
|
||||
@@ -91,14 +92,6 @@ import askQuestionIcon from "@/assets/position-roundtable/ask-question.svg";
|
||||
import agentConfigLogo from "@/assets/position-roundtable/agent-config-logo.svg";
|
||||
import doubleArrowDecoration from "@/assets/position-roundtable/double-arrow-decoration.svg";
|
||||
import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg";
|
||||
import analysisTag1 from "@/assets/position-roundtable/标签1.svg";
|
||||
import analysisTag2 from "@/assets/position-roundtable/标签2.svg";
|
||||
import analysisTag3 from "@/assets/position-roundtable/标签3.svg";
|
||||
import analysisTag4 from "@/assets/position-roundtable/标签4.svg";
|
||||
import analysisTag5 from "@/assets/position-roundtable/标签5.svg";
|
||||
import analysisTag6 from "@/assets/position-roundtable/标签6.svg";
|
||||
import responsibilityIcon from "@/assets/position-roundtable/责任岗位.svg";
|
||||
import resultTimeIcon from "@/assets/position-roundtable/时间.svg";
|
||||
import { deleteChain, listChains } from "@/roundtable-planning/api/chains";
|
||||
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
|
||||
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
|
||||
@@ -5961,15 +5954,6 @@ interface ResultItem {
|
||||
artifactContent?: string | null;
|
||||
}
|
||||
|
||||
const ANALYSIS_RESULT_TAG_ICONS = [
|
||||
analysisTag1,
|
||||
analysisTag2,
|
||||
analysisTag3,
|
||||
analysisTag4,
|
||||
analysisTag5,
|
||||
analysisTag6,
|
||||
] as const;
|
||||
|
||||
function canOpenDeliverable(item: ResultItem): boolean {
|
||||
return Boolean(
|
||||
item.status === "done" &&
|
||||
@@ -6407,6 +6391,25 @@ function AnalysisResults({
|
||||
),
|
||||
[kindFilter, nodeFilter, positionFilter, resultItems],
|
||||
);
|
||||
const visibleCardItems = useMemo<AnalysisResultCardItem[]>(
|
||||
() =>
|
||||
visibleItems.map((item) => ({
|
||||
id: item.id,
|
||||
agentName: item.agentName,
|
||||
productName: item.productName,
|
||||
positionName: resultPositionName(item.positionId),
|
||||
updatedAt: item.updatedAt,
|
||||
fallbackTime: intentCompletedAt || "刚刚",
|
||||
delivered: canOpenDeliverable(item),
|
||||
artifactPath: item.artifactPath,
|
||||
artifactMimeType: item.artifactMimeType,
|
||||
artifactThreadId: item.artifactThreadId,
|
||||
artifactContent: item.artifactContent,
|
||||
summary: item.summary,
|
||||
unavailableMessage: deliverableUnavailableMessage(item),
|
||||
})),
|
||||
[intentCompletedAt, visibleItems],
|
||||
);
|
||||
|
||||
const openDetail = useCallback(
|
||||
(item: ResultItem) => {
|
||||
@@ -6462,13 +6465,12 @@ function AnalysisResults({
|
||||
|
||||
if (resultItems.length === 0) {
|
||||
return (
|
||||
<div className="position-roundtable-analysis-results-empty flex h-full min-h-[260px] flex-col items-center justify-center p-6 text-center">
|
||||
<FileOutput className="mb-3 size-8 text-muted-foreground/50" />
|
||||
<p className="text-sm font-semibold">暂无交付产物</p>
|
||||
<p className="mt-1 max-w-[240px] text-xs leading-5 text-muted-foreground">
|
||||
情报分析岗启动业务链后,各岗位待交付产物会在这里排队展示。
|
||||
</p>
|
||||
</div>
|
||||
<AnalysisResultCardList
|
||||
items={[]}
|
||||
emptyTitle="暂无交付产物"
|
||||
emptyDescription="情报分析岗启动业务链后,各岗位待交付产物会在这里排队展示。"
|
||||
onOpen={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6547,67 +6549,14 @@ function AnalysisResults({
|
||||
当前筛选条件下没有结果
|
||||
</div>
|
||||
) : (
|
||||
visibleItems.map((item, index) => {
|
||||
const displayedTime = item.updatedAt
|
||||
? new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date(item.updatedAt))
|
||||
: intentCompletedAt || "刚刚";
|
||||
const hasDeliveredArtifact = Boolean(item.artifactPath || item.artifactContent?.trim());
|
||||
const displayedProductName =
|
||||
hasDeliveredArtifact || (item.kind !== "delivery" && item.status === "done")
|
||||
? item.productName
|
||||
: "未交付";
|
||||
const contents = (
|
||||
<div className="position-roundtable-analysis-result-card-body">
|
||||
<div className="position-roundtable-analysis-result-tag" aria-label={desensitize(item.agentName)}>
|
||||
<img
|
||||
src={ANALYSIS_RESULT_TAG_ICONS[index % ANALYSIS_RESULT_TAG_ICONS.length]}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{desensitize(item.agentName)}</span>
|
||||
</div>
|
||||
<p className="position-roundtable-analysis-result-product">
|
||||
{desensitize(displayedProductName)}
|
||||
</p>
|
||||
<footer className="position-roundtable-analysis-result-meta">
|
||||
<span>
|
||||
<img src={responsibilityIcon} alt="" aria-hidden="true" />
|
||||
{resultPositionName(item.positionId)}
|
||||
</span>
|
||||
<i aria-hidden="true" />
|
||||
<span>
|
||||
<img src={resultTimeIcon} alt="" aria-hidden="true" />
|
||||
{displayedTime}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
return item.node ? (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => openDetail(item)}
|
||||
className="position-roundtable-analysis-result-card"
|
||||
>
|
||||
{contents}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => openDetail(item)}
|
||||
className="position-roundtable-analysis-result-card"
|
||||
>
|
||||
{contents}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
<AnalysisResultCardList
|
||||
items={visibleCardItems}
|
||||
padded={false}
|
||||
onOpen={(card) => {
|
||||
const original = resultItems.find((item) => item.id === card.id);
|
||||
if (original) openDetail(original);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1108,8 +1108,10 @@
|
||||
}
|
||||
|
||||
.position-roundtable-intent-analysis-results-scroll {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -1147,26 +1149,32 @@
|
||||
top: -40px;
|
||||
left: -12px;
|
||||
display: inline-flex;
|
||||
width: max-content;
|
||||
min-width: 95px;
|
||||
max-width: calc(100% - 12px);
|
||||
width: fit-content;
|
||||
max-width: calc(100% + 12px);
|
||||
height: 26px;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
padding: 0 22px 0 12px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px 0 0 0;
|
||||
background: rgba(212, 120, 0, 0.5);
|
||||
color: #ffffff;
|
||||
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: normal;
|
||||
white-space: nowrap;
|
||||
clip-path: polygon(0 0, 100% 0, calc(100% - 14px) 100%, 0 100%);
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-tag--1 { background: rgba(212, 120, 0, 0.5); }
|
||||
.position-roundtable-analysis-result-tag--2 { background: rgba(207, 19, 34, 0.5); }
|
||||
.position-roundtable-analysis-result-tag--3 { background: rgba(29, 171, 123, 0.5); }
|
||||
.position-roundtable-analysis-result-tag--4 { background: rgba(132, 85, 230, 0.5); }
|
||||
.position-roundtable-analysis-result-tag--5 { background: rgba(201, 181, 0, 0.5); }
|
||||
.position-roundtable-analysis-result-tag--6 { background: rgba(69, 195, 243, 0.5); }
|
||||
|
||||
.position-roundtable-analysis-result-tag img {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 26px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-tag span {
|
||||
@@ -1176,7 +1184,7 @@
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 0 12px;
|
||||
padding: 0;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface RoundtableArtifactReference {
|
||||
sessionId?: string | null;
|
||||
threadId: string;
|
||||
nodeKey?: string | null;
|
||||
sourceKey: string;
|
||||
path: string;
|
||||
name: string;
|
||||
mimeType?: string | null;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export interface RoundtableArtifactSubmissionRecord {
|
||||
id: string;
|
||||
taskId: string;
|
||||
version: number;
|
||||
submitted: boolean;
|
||||
submittedAt?: string | null;
|
||||
cancelledAt?: string | null;
|
||||
taskDeliveryId?: string | null;
|
||||
artifact: RoundtableArtifactReference;
|
||||
}
|
||||
|
||||
function endpoint(taskId: string): string {
|
||||
return `${getBackendBaseURL()}/api/roundtable/tasks/${encodeURIComponent(taskId)}/artifacts`;
|
||||
}
|
||||
|
||||
async function readError(response: Response, fallback: string): Promise<Error> {
|
||||
const body = await response.text().catch(() => "");
|
||||
try {
|
||||
const parsed = JSON.parse(body) as { detail?: unknown; message?: unknown };
|
||||
const detail = typeof parsed.detail === "string"
|
||||
? parsed.detail
|
||||
: typeof parsed.message === "string"
|
||||
? parsed.message
|
||||
: "";
|
||||
if (detail) return new Error(detail);
|
||||
} catch {
|
||||
// The status fallback below is more useful than an opaque HTML response.
|
||||
}
|
||||
return new Error(`${fallback}(${response.status})`);
|
||||
}
|
||||
|
||||
export async function listRoundtableArtifactSubmissions(
|
||||
taskId: string,
|
||||
submitted: boolean,
|
||||
): Promise<RoundtableArtifactSubmissionRecord[]> {
|
||||
const response = await apiFetch(`${endpoint(taskId)}?submitted=${submitted}`, { method: "GET" });
|
||||
if (!response.ok) throw await readError(response, "读取产物提交状态失败");
|
||||
const payload = await response.json() as { items?: RoundtableArtifactSubmissionRecord[] };
|
||||
return Array.isArray(payload.items) ? payload.items : [];
|
||||
}
|
||||
|
||||
export async function saveRoundtableArtifactSubmission(
|
||||
taskId: string,
|
||||
body: {
|
||||
submitted: boolean;
|
||||
registerOnly?: boolean;
|
||||
submissionId?: string;
|
||||
taskDeliveryId?: string;
|
||||
expectedVersion?: number;
|
||||
artifact: RoundtableArtifactReference;
|
||||
},
|
||||
): Promise<RoundtableArtifactSubmissionRecord> {
|
||||
const response = await apiFetch(`${endpoint(taskId)}/submission`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ taskId, ...body }),
|
||||
});
|
||||
if (!response.ok) throw await readError(response, "保存产物提交状态失败");
|
||||
return response.json() as Promise<RoundtableArtifactSubmissionRecord>;
|
||||
}
|
||||
@@ -53,6 +53,8 @@ export interface ChainUpdatePayload {
|
||||
gatherFirst?: boolean;
|
||||
/** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */
|
||||
isPublic?: boolean;
|
||||
/** 启停开关:true 启用 / false 停用;omit 不改。仅 owner / 管理员生效。 */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +90,7 @@ interface ServerChain {
|
||||
seat_mode?: string | null;
|
||||
gather_first?: boolean;
|
||||
is_public?: boolean;
|
||||
enabled?: boolean;
|
||||
is_mine?: boolean;
|
||||
owner_name?: string | null;
|
||||
created_at: string | null;
|
||||
@@ -106,6 +109,7 @@ function toChain(s: ServerChain): BusinessChain {
|
||||
seatMode: (s.seat_mode as SeatMode | null) ?? undefined,
|
||||
gatherFirst: s.gather_first ?? false,
|
||||
isPublic: s.is_public ?? false,
|
||||
enabled: s.enabled ?? true,
|
||||
// 后端缺省时按「我的」处理(向后兼容旧响应)。
|
||||
isMine: s.is_mine ?? true,
|
||||
ownerName: s.owner_name ?? undefined,
|
||||
@@ -141,6 +145,7 @@ function fromUpdate(p: ChainUpdatePayload) {
|
||||
if (p.seatMode !== undefined) out.seat_mode = p.seatMode;
|
||||
if (p.gatherFirst !== undefined) out.gather_first = p.gatherFirst;
|
||||
if (p.isPublic !== undefined) out.is_public = p.isPublic;
|
||||
if (p.enabled !== undefined) out.enabled = p.enabled;
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -171,6 +176,11 @@ export async function setChainPublic(id: string, isPublic: boolean): Promise<Bus
|
||||
return updateChain(id, { isPublic });
|
||||
}
|
||||
|
||||
/** 启用 / 停用一条链条(仅 owner / 管理员生效)。返回更新后的链条,404 返回 null。 */
|
||||
export async function setChainEnabled(id: string, enabled: boolean): Promise<BusinessChain | null> {
|
||||
return updateChain(id, { enabled });
|
||||
}
|
||||
|
||||
/** Fetch one chain. Returns null on 404. */
|
||||
export async function getChain(id: string): Promise<BusinessChain | null> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
|
||||
@@ -214,10 +224,22 @@ export async function updateChain(id: string, patch: ChainUpdatePayload): Promis
|
||||
if (res.status === 404) return null;
|
||||
const data = await readJson<ServerChain>(res, "update chain");
|
||||
const chain = toChain(data);
|
||||
const enabledToggle = patch.enabled === true || patch.enabled === false;
|
||||
writeYLog({
|
||||
optType: patch.isPublic === true ? OptType.enabled : patch.isPublic === false ? OptType.disabled : OptType.edit,
|
||||
optDetail:
|
||||
patch.isPublic === true
|
||||
optType: enabledToggle
|
||||
? patch.enabled
|
||||
? OptType.enabled
|
||||
: OptType.disabled
|
||||
: patch.isPublic === true
|
||||
? OptType.enabled
|
||||
: patch.isPublic === false
|
||||
? OptType.disabled
|
||||
: OptType.edit,
|
||||
optDetail: enabledToggle
|
||||
? patch.enabled
|
||||
? "CM助手-全量-业务链条-启用"
|
||||
: "CM助手-全量-业务链条-停用"
|
||||
: patch.isPublic === true
|
||||
? "CM助手-全量-业务链条-发布"
|
||||
: patch.isPublic === false
|
||||
? "CM助手-全量-业务链条-取消发布"
|
||||
|
||||
@@ -459,7 +459,14 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
// Clarifications collected live from `type:"tool"` frames, deduped by
|
||||
// tool_call_id, then reconciled against the complete terminal list.
|
||||
const seenClarificationIds = new Set<string>();
|
||||
// A clarification tool call is a hard pause in the Step 1 conversation.
|
||||
// Some model providers keep emitting text (and can even produce an
|
||||
// INTENT_READY terminal frame) after the tool result. That text belongs to
|
||||
// a continuation the user has not approved yet, so never let it replace the
|
||||
// assistance card in the current turn.
|
||||
let awaitingUserClarification = false;
|
||||
const collectClarification = (payload: ClarificationPayload, toolCallId: string) => {
|
||||
awaitingUserClarification = true;
|
||||
const dedupeKey = toolCallId || `idx-${result.clarifications.length}`;
|
||||
if (seenClarificationIds.has(dedupeKey)) return;
|
||||
seenClarificationIds.add(dedupeKey);
|
||||
@@ -501,6 +508,25 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
}
|
||||
|
||||
if (isFinalIntentFrame(parsed)) {
|
||||
// Reconcile any terminal clarification cards, but preserve the first
|
||||
// tool-call pause as the authoritative state for this turn. In
|
||||
// particular, do not accept a later `done` frame / summary before the
|
||||
// user has answered the displayed questions.
|
||||
if (awaitingUserClarification) {
|
||||
if (parsed.status === "clarification") {
|
||||
const terminalCards = Array.isArray(parsed.clarifications)
|
||||
? parsed.clarifications
|
||||
.map(parseTerminalClarification)
|
||||
.filter((item): item is { payload: ClarificationPayload; toolCallId: string } => item !== null)
|
||||
: [];
|
||||
if (terminalCards.length > 0) {
|
||||
terminalCards.forEach((card, index) => {
|
||||
collectClarification(card.payload, card.toolCallId || `terminal-${index}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
result.status = parsed.status;
|
||||
if (typeof parsed.content === "string") result.content = parsed.content;
|
||||
if (parsed.summary && typeof parsed.summary === "object") {
|
||||
@@ -548,6 +574,11 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
continue;
|
||||
}
|
||||
|
||||
// Do not show follow-up thinking / generated summary text while the
|
||||
// user-assistance card is waiting for an answer. This also prevents a
|
||||
// later AI delta from hiding the card through an [INTENT_READY] block.
|
||||
if (awaitingUserClarification) continue;
|
||||
|
||||
// Phase inference from LangGraph messages-tuple frames. Tool calls
|
||||
// (typically ask_clarification) flip phase to tool_calling; AI text
|
||||
// chunks flip to streaming. Everything else leaves phase alone.
|
||||
@@ -592,5 +623,11 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
}
|
||||
}
|
||||
|
||||
if (awaitingUserClarification && result.clarifications.length > 0) {
|
||||
result.status = "clarification";
|
||||
result.summary = null;
|
||||
result.clarification = result.clarifications[0] ?? null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -584,18 +584,55 @@ export async function streamMultiAgent(
|
||||
|
||||
// Inferred SSE phase + a tiny helper so we only fire onPhaseChange when the
|
||||
// value actually changes (the reader loop sees many frames per phase).
|
||||
//
|
||||
// `write_file` 的 `content`、`str_replace` 的 old_str/new_str 会在每个
|
||||
// tool_call chunk 中持续增长。它们属于实时预览数据,已经由 onUpstreamFrame
|
||||
// 写入 stub;若用它们判断 phase 变化,会在单次工具调用里反复触发 React 状态
|
||||
// 更新,严重时会形成 Maximum update depth loop。因此这里只比较工具的身份入参。
|
||||
const phaseArgsSignature = (args?: Record<string, unknown>) => {
|
||||
if (!args) return "";
|
||||
const streamedPayloadKeys = new Set([
|
||||
"content",
|
||||
"old_str",
|
||||
"new_str",
|
||||
"patch",
|
||||
"diff",
|
||||
]);
|
||||
try {
|
||||
return JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(args)
|
||||
.filter(([key]) => !streamedPayloadKeys.has(key))
|
||||
.sort(([left], [right]) => left.localeCompare(right)),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
return "[unserializable]";
|
||||
}
|
||||
};
|
||||
let currentPhase: StreamPhase = "connecting";
|
||||
let currentPhaseName = "";
|
||||
let currentPhaseArgsSignature = "";
|
||||
req.onPhaseChange?.("connecting");
|
||||
const setPhase = (
|
||||
next: StreamPhase,
|
||||
name?: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => {
|
||||
// 同一个 phase + 同一个 toolName 才认为是"重复",此时不再回调上层;
|
||||
// 但 args 单独是新的(如同一工具被多次 call 携带不同入参)时也不回调 —— 上层
|
||||
// appendStepIfNew 用 `phase:toolName` 做 key 去重,本来也不会因 args 变化产生新步骤。
|
||||
if (next === currentPhase && !name) return;
|
||||
const nextName = name ?? "";
|
||||
const nextArgsSignature = phaseArgsSignature(args);
|
||||
// 同 phase、同工具、同身份入参的帧只是流式重复帧,不通知 UI。保留 path/filepaths
|
||||
// 等身份变化,确保工具参数从不完整变完整时仍可自动打开沙箱。
|
||||
if (
|
||||
next === currentPhase &&
|
||||
nextName === currentPhaseName &&
|
||||
nextArgsSignature === currentPhaseArgsSignature
|
||||
) {
|
||||
return;
|
||||
}
|
||||
currentPhase = next;
|
||||
currentPhaseName = nextName;
|
||||
currentPhaseArgsSignature = nextArgsSignature;
|
||||
req.onPhaseChange?.(next, name, args);
|
||||
};
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
* 卡片席位名优先用实时 agent 数据(`listAgents()`,与 `BusinessChainPicker` 一致),
|
||||
* agent 改名后此弹窗也能同步显示;解析不到才回退到落库快照(`seat.name`)。
|
||||
*/
|
||||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
ClockIcon,
|
||||
GlobeIcon,
|
||||
LayersIcon,
|
||||
Loader2Icon,
|
||||
@@ -28,14 +29,15 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { listAgents } from "@/core/agents";
|
||||
|
||||
import { listChains, setChainPublic, type ChainScope } from "../api/chains";
|
||||
import { chainTagClasses, type BusinessChain } from "../lib/business-chain";
|
||||
import { listChains, setChainEnabled, setChainPublic, type ChainScope } from "../api/chains";
|
||||
import { chainTagClasses, isChainEnabled, type BusinessChain } from "../lib/business-chain";
|
||||
import { getAvatarTypeFor } from "../lib/roundtable-constants";
|
||||
|
||||
export interface BusinessChainListDialogProps {
|
||||
@@ -57,6 +59,8 @@ export interface BusinessChainListBodyProps {
|
||||
emptyHint?: string;
|
||||
}
|
||||
|
||||
export type EnabledFilter = "all" | "enabled" | "disabled";
|
||||
|
||||
/**
|
||||
* 业务链条列表主体(筛选 tab + 滚动卡片列表)。
|
||||
*
|
||||
@@ -76,7 +80,10 @@ export function BusinessChainListBody({
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
// 筛选:我的 / 公共(已发布)/(管理员)全部。
|
||||
const [scope, setScope] = useState<ChainScope>("mine");
|
||||
// 启停筛选:全部 / 启用 / 停用。默认全部,关掉开关后卡片仍留在当前列表。
|
||||
const [enabledFilter, setEnabledFilter] = useState<EnabledFilter>("all");
|
||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||
// 实时名称表(id 小写 → 最新 name):用于把席位的落库快照名刷新成最新(agent 改名同步)。
|
||||
const [nameById, setNameById] = useState<Map<string, string>>(new Map());
|
||||
|
||||
@@ -137,8 +144,12 @@ export function BusinessChainListBody({
|
||||
async (chain: BusinessChain) => {
|
||||
setPublishingId(chain.id);
|
||||
try {
|
||||
await setChainPublic(chain.id, !chain.isPublic);
|
||||
await refresh(scope);
|
||||
const updated = await setChainPublic(chain.id, !chain.isPublic);
|
||||
if (updated) {
|
||||
setChains((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
|
||||
} else {
|
||||
await refresh(scope);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -148,25 +159,69 @@ export function BusinessChainListBody({
|
||||
[refresh, scope],
|
||||
);
|
||||
|
||||
const handleToggleEnabled = useCallback(
|
||||
async (chain: BusinessChain) => {
|
||||
setTogglingId(chain.id);
|
||||
try {
|
||||
const updated = await setChainEnabled(chain.id, !isChainEnabled(chain));
|
||||
if (updated) {
|
||||
setChains((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
|
||||
} else {
|
||||
await refresh(scope);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
},
|
||||
[refresh, scope],
|
||||
);
|
||||
|
||||
const visibleChains = useMemo(() => {
|
||||
if (enabledFilter === "enabled") return chains.filter((c) => isChainEnabled(c));
|
||||
if (enabledFilter === "disabled") return chains.filter((c) => !isChainEnabled(c));
|
||||
return chains;
|
||||
}, [chains, enabledFilter]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 筛选:我的 / 公共 /(管理员)全部 */}
|
||||
<div className="shrink-0 flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
|
||||
{((isAdmin ? ["mine", "public", "all"] : ["mine", "public"]) as ChainScope[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setScope(s)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition",
|
||||
scope === s
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{s === "mine" ? "我的" : s === "public" ? "公共" : "全部"}
|
||||
</button>
|
||||
))}
|
||||
{/* 筛选:我的 / 公共 /(管理员)全部 + 全部 / 启用 / 停用 */}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
|
||||
{((isAdmin ? ["mine", "public", "all"] : ["mine", "public"]) as ChainScope[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setScope(s)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition",
|
||||
scope === s
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{s === "mine" ? "我的" : s === "public" ? "公共" : "全部"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
|
||||
{(["all", "enabled", "disabled"] as EnabledFilter[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => setEnabledFilter(f)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition",
|
||||
enabledFilter === f
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{f === "all" ? "全部状态" : f === "enabled" ? "启用" : "停用"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="custom-scrollbar -mr-1 min-h-0 flex-1 overflow-y-auto py-3 pr-1">
|
||||
@@ -179,30 +234,40 @@ export function BusinessChainListBody({
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
加载业务链条失败:{error}
|
||||
</div>
|
||||
) : chains.length === 0 ? (
|
||||
) : visibleChains.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||||
<LayersIcon className="size-10 text-primary/40" />
|
||||
<div className="text-sm font-semibold">
|
||||
{scope === "public" ? "还没有公共链条" : "还没有业务链条"}
|
||||
{enabledFilter !== "all"
|
||||
? enabledFilter === "enabled"
|
||||
? "没有启用的业务链条"
|
||||
: "没有停用的业务链条"
|
||||
: scope === "public"
|
||||
? "还没有公共链条"
|
||||
: "还没有业务链条"}
|
||||
</div>
|
||||
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
|
||||
{scope === "public"
|
||||
? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的」里发布一条试试。"
|
||||
: emptyHint ??
|
||||
"业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。"}
|
||||
{enabledFilter !== "all"
|
||||
? "试试切换「全部状态」,或到卡片上用开关切换启停。"
|
||||
: scope === "public"
|
||||
? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的」里发布一条试试。"
|
||||
: emptyHint ??
|
||||
"业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3.5">
|
||||
{chains.map((chain) => (
|
||||
{visibleChains.map((chain) => (
|
||||
<ChainRow
|
||||
key={chain.id}
|
||||
chain={chain}
|
||||
canManage={isAdmin || chain.isMine !== false}
|
||||
nameById={nameById}
|
||||
publishing={publishingId === chain.id}
|
||||
toggling={togglingId === chain.id}
|
||||
onSettings={() => onConfigure(chain.id)}
|
||||
onTogglePublic={() => handleTogglePublic(chain)}
|
||||
onToggleEnabled={() => handleToggleEnabled(chain)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -252,22 +317,33 @@ function ChainRow({
|
||||
canManage,
|
||||
nameById,
|
||||
publishing,
|
||||
toggling,
|
||||
onSettings,
|
||||
onTogglePublic,
|
||||
onToggleEnabled,
|
||||
}: {
|
||||
chain: BusinessChain;
|
||||
/** 能否编辑 / 发布(本人链条 或 管理员)。 */
|
||||
/** 能否编辑 / 发布 / 启停(本人链条 或 管理员)。 */
|
||||
canManage: boolean;
|
||||
nameById: Map<string, string>;
|
||||
publishing: boolean;
|
||||
toggling: boolean;
|
||||
onSettings: () => void;
|
||||
onTogglePublic: () => void;
|
||||
onToggleEnabled: () => void;
|
||||
}) {
|
||||
// 配置者用户名:后端返回 email,展示时取 @ 前的本地部分更像「用户名」。
|
||||
const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "";
|
||||
const enabled = isChainEnabled(chain);
|
||||
const updatedLabel = formatUpdatedAt(chain.updatedAt);
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-card p-5 transition-colors hover:border-primary/40">
|
||||
{/* 标题行 + 公共徽标 + 发布/设置按钮 */}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border border-border bg-card p-5 transition-colors hover:border-primary/40",
|
||||
!enabled && "opacity-70",
|
||||
)}
|
||||
>
|
||||
{/* 标题行 + 公共/停用徽标 + 启停开关 / 发布 / 设置按钮 */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="truncate text-base font-semibold">{chain.title || "未命名链条"}</div>
|
||||
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
|
||||
@@ -279,7 +355,26 @@ function ChainRow({
|
||||
公共
|
||||
</span>
|
||||
)}
|
||||
{!enabled && (
|
||||
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
已停用
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
{/* 启停:本人的链条或管理员可操作。 */}
|
||||
{canManage && (
|
||||
<label className="flex items-center gap-1.5 pr-1 text-xs text-muted-foreground">
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={toggling}
|
||||
onCheckedChange={() => onToggleEnabled()}
|
||||
aria-label={enabled ? "停用该业务链条" : "启用该业务链条"}
|
||||
/>
|
||||
<span className={cn("min-w-[2em]", enabled ? "text-foreground" : "text-muted-foreground")}>
|
||||
{enabled ? "启用" : "停用"}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{/* 发布 / 取消发布:本人的链条或管理员可操作。 */}
|
||||
{canManage && (
|
||||
<Button
|
||||
@@ -312,13 +407,20 @@ function ChainRow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ownerName && (
|
||||
<div
|
||||
className="mt-1.5 flex items-center gap-1 text-xs text-muted-foreground"
|
||||
title={chain.ownerName}
|
||||
>
|
||||
<UserIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">配置人:{ownerName}</span>
|
||||
{(ownerName || updatedLabel) && (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{ownerName && (
|
||||
<div className="flex items-center gap-1" title={chain.ownerName}>
|
||||
<UserIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">配置人:{ownerName}</span>
|
||||
</div>
|
||||
)}
|
||||
{updatedLabel && (
|
||||
<div className="flex items-center gap-1" title={chain.updatedAt}>
|
||||
<ClockIcon className="size-3.5 shrink-0" />
|
||||
<span>最后修改:{updatedLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -401,3 +503,21 @@ function ChainSeatsFlow({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 后端 `updated_at` 形如 `2026-08-19T17:50:00+08:00`;展示到分钟。 */
|
||||
function formatUpdatedAt(iso: string | undefined): string {
|
||||
if (!iso) return "";
|
||||
const date = new Date(iso);
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
return date.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
const s = iso.replace("T", " ");
|
||||
return s.length >= 16 ? s.slice(0, 16) : s;
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { listChains, type ChainScope } from "../api/chains";
|
||||
import {
|
||||
CHAIN_MIN_SEATS,
|
||||
chainTagClasses,
|
||||
isChainEnabled,
|
||||
type BusinessChain,
|
||||
type SeatSelectionResult,
|
||||
} from "../lib/business-chain";
|
||||
@@ -77,7 +78,7 @@ export function BusinessChainPicker({
|
||||
listAgents().catch(() => null), // agent 列表失败不阻断链条展示
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setChains(chainList);
|
||||
setChains(chainList.filter(isChainEnabled));
|
||||
if (agents) {
|
||||
const candidates = filterRecommendCandidates(agents);
|
||||
setExistingIds(new Set(candidates.map((c) => c.agent_id)));
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type ThreadIdMap,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
|
||||
import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
||||
import {
|
||||
@@ -46,6 +47,7 @@ import type {
|
||||
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
|
||||
import type { ReportIntent, UseStep3ReportReturn } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
|
||||
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
|
||||
|
||||
interface SeatDelivery {
|
||||
/** Display name of the seat (e.g. "情报收集"). */
|
||||
@@ -57,6 +59,8 @@ interface SeatDelivery {
|
||||
}
|
||||
|
||||
interface HighFidelityReportProps {
|
||||
useStep1Chrome?: boolean;
|
||||
headerTitle?: string;
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
/** The most recent leader-agent text — rendered as the final consensus. */
|
||||
@@ -93,6 +97,7 @@ interface HighFidelityReportProps {
|
||||
onReset: () => void;
|
||||
openArtifactPreview: (threadId: string, path: string) => void;
|
||||
seatStubMessages: Record<string, unknown[]>;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}
|
||||
|
||||
interface SeatArtifacts {
|
||||
@@ -117,6 +122,8 @@ function formatGeneratedAt(d: Date): string {
|
||||
}
|
||||
|
||||
export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
useStep1Chrome = false,
|
||||
headerTitle,
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
consensusText,
|
||||
@@ -139,6 +146,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
onReset,
|
||||
openArtifactPreview,
|
||||
seatStubMessages,
|
||||
taskArtifactSubmission,
|
||||
}) => {
|
||||
// 「结果绘制」模式:把现有报告内容收起到左栏,右侧由方案可视化智能体绘制图表 HTML。
|
||||
const consensusDocTitle = useMemo(() => {
|
||||
@@ -148,8 +156,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
return `圆桌总控结论-${short}`;
|
||||
}, [intentReady?.objective]);
|
||||
|
||||
// 第 3 步**主页** = 总结报告(进第三步默认自动跑「方案总结报告智能体」)。
|
||||
// 用户点「最终方案展示」切到静态页(summaryMode=false),再从静态页可进「结果绘制」HTML。
|
||||
// 第 3 步只保留「总结报告」工作台;旧的最终方案展示 / HTML 绘制入口不再开放。
|
||||
const [summaryMode, setSummaryMode] = useState(true);
|
||||
const [drawMode, setDrawMode] = useState(false);
|
||||
// 文件沙箱(2026-06-13 改内联):点文件卡片在右侧滑入沙箱面板、左侧席位栏收起
|
||||
@@ -430,6 +437,8 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
if (summaryMode) {
|
||||
return (
|
||||
<Step3SummaryMode
|
||||
useStep1Chrome={useStep1Chrome}
|
||||
headerTitle={headerTitle}
|
||||
threadIds={threadIds}
|
||||
coordinatorName={coordinatorName}
|
||||
consensusText={consensusText}
|
||||
@@ -439,12 +448,11 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
intentReady={intentReady}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
coordinatorPrompt={coordinatorPrompt}
|
||||
savedSummary={savedSummary}
|
||||
summary={summaryFlow}
|
||||
onViewStatic={() => setSummaryMode(false)}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -467,6 +475,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
report={reportFlow}
|
||||
dashboard={dashboardFlow}
|
||||
onBack={() => setDrawMode(false)}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -917,6 +926,27 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Eye className="w-3.5 h-3.5 text-slate-300 transition group-hover:text-primary dark:text-muted-foreground" />
|
||||
{seat.threadId && (
|
||||
<TaskArtifactSubmitButton
|
||||
compact
|
||||
controller={taskArtifactSubmission}
|
||||
target={taskArtifactSubmission?.targetFor({
|
||||
threadId: seat.threadId,
|
||||
path: f.path,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
agentId: seat.key,
|
||||
agentName: seat.displayName,
|
||||
}) ?? {
|
||||
threadId: seat.threadId,
|
||||
path: f.path,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
agentId: seat.key,
|
||||
agentName: seat.displayName,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
@@ -1011,6 +1041,24 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
threadId={fileSandboxTarget.threadId}
|
||||
onClose={handleCloseFileSandbox}
|
||||
allowVirtualMarkdownEdit
|
||||
headerLeadingActionBare
|
||||
headerLeadingAction={(
|
||||
<TaskArtifactSubmitButton
|
||||
compact
|
||||
controller={taskArtifactSubmission}
|
||||
target={taskArtifactSubmission?.targetFor({
|
||||
threadId: fileSandboxTarget.threadId,
|
||||
path: fileSandboxTarget.file.path,
|
||||
name: fileSandboxTarget.file.name,
|
||||
mimeType: fileSandboxTarget.file.mimeType,
|
||||
}) ?? {
|
||||
threadId: fileSandboxTarget.threadId,
|
||||
path: fileSandboxTarget.file.path,
|
||||
name: fileSandboxTarget.file.name,
|
||||
mimeType: fileSandboxTarget.file.mimeType,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</ThreadContext.Provider>
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*
|
||||
* 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Cpu,
|
||||
FileIcon,
|
||||
@@ -24,8 +24,6 @@ import {
|
||||
Loader2,
|
||||
Network,
|
||||
Paperclip,
|
||||
Pause,
|
||||
Play,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -41,6 +39,7 @@ function formatFileSize(bytes: number): string {
|
||||
const SHOW_BACKGROUND_SUSPEND = true;
|
||||
|
||||
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
|
||||
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
|
||||
@@ -49,6 +48,7 @@ import { DialogueSpeedMeter } from "@/roundtable-planning/components/DialogueSpe
|
||||
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
|
||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton";
|
||||
import { getAvatar } from "@/roundtable-planning/components/Avatars";
|
||||
import {
|
||||
MarkdownContent,
|
||||
@@ -86,6 +86,7 @@ import { desensitize } from "@/lib/desensitize";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
|
||||
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
|
||||
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
|
||||
|
||||
interface ModelOption {
|
||||
name: string;
|
||||
@@ -93,10 +94,15 @@ interface ModelOption {
|
||||
}
|
||||
|
||||
export interface Step2PanelProps {
|
||||
/** 独立业务页需要与第一步完全相同的输入组件(8BF 专用,默认不改变原会商)。 */
|
||||
useStep1InputAppearance?: boolean;
|
||||
/** 8BF 第二步对话区标题,与第一步「业务链 · 意图识别」同一套顶栏。 */
|
||||
headerTitle?: string;
|
||||
/** 8BF 第二步顶栏右侧附加操作(如返回)。 */
|
||||
headerRightSlot?: ReactNode;
|
||||
// ─── State ────────────────────────────────────────────────────
|
||||
step2RoundtableDialogues: Step2Dialogue[];
|
||||
isInitializing: boolean;
|
||||
isRoundTableRunning: boolean;
|
||||
isOrchestrating: boolean;
|
||||
pendingClarification: ClarificationPayload | null;
|
||||
clarificationDraft: string;
|
||||
@@ -147,7 +153,6 @@ export interface Step2PanelProps {
|
||||
setSelectedAgentId: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
// ─── Handlers ─────────────────────────────────────────────────
|
||||
onPauseRoundtable: () => void;
|
||||
onResumeRoundtable: () => void;
|
||||
onHandleInjectToRoundtable: (files?: File[]) => void;
|
||||
onSubmitClarification: (answer: string) => void;
|
||||
onSavePersona: (id: number) => void;
|
||||
@@ -157,13 +162,17 @@ export interface Step2PanelProps {
|
||||
/** 是否在「查看历史记录」(从草稿/后台作业加载)—— 为 true 时隐藏「后台挂起」按钮。 */
|
||||
isViewingHistory?: boolean;
|
||||
onTriggerToast: (msg: string) => void;
|
||||
/** 任务深链页面注入的外部过程产物提交控制器。 */
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}
|
||||
|
||||
export function Step2Panel(props: Step2PanelProps) {
|
||||
const {
|
||||
useStep1InputAppearance = false,
|
||||
headerTitle,
|
||||
headerRightSlot,
|
||||
step2RoundtableDialogues,
|
||||
isInitializing,
|
||||
isRoundTableRunning,
|
||||
isOrchestrating,
|
||||
pendingClarification,
|
||||
clarificationDraft,
|
||||
@@ -195,7 +204,6 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
setAgentConfigs,
|
||||
setSelectedAgentId,
|
||||
onPauseRoundtable,
|
||||
onResumeRoundtable,
|
||||
onHandleInjectToRoundtable,
|
||||
onSubmitClarification,
|
||||
onSavePersona,
|
||||
@@ -203,6 +211,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
onBackgroundSuspend,
|
||||
isViewingHistory,
|
||||
onTriggerToast,
|
||||
taskArtifactSubmission,
|
||||
} = props;
|
||||
|
||||
const step2ScrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -372,23 +381,17 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
prevArtifactsOpenRef.current = artifactsOpenForFlow;
|
||||
}, [artifactsOpenForFlow]);
|
||||
|
||||
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
|
||||
const [pauseFlash, setPauseFlash] = useState(false);
|
||||
|
||||
// 干预消息携带的本地附件(点「干预并发送」时连同指令一起上传)。
|
||||
const [injectFiles, setInjectFiles] = useState<File[]>([]);
|
||||
const injectFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handlePickInjectFiles = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = Array.from(e.target.files ?? []);
|
||||
const addInjectFiles = useCallback(
|
||||
(picked: File[]) => {
|
||||
if (picked.length > 0) {
|
||||
setInjectFiles((prev) => {
|
||||
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
|
||||
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
|
||||
});
|
||||
}
|
||||
e.target.value = "";
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -400,13 +403,14 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
const handleInject = useCallback(() => {
|
||||
onHandleInjectToRoundtable(injectFiles.length > 0 ? injectFiles : undefined);
|
||||
setInjectFiles([]);
|
||||
setPauseFlash(true);
|
||||
window.setTimeout(() => setPauseFlash(false), 1600);
|
||||
}, [onHandleInjectToRoundtable, injectFiles]);
|
||||
|
||||
const isSandboxStreaming =
|
||||
isOrchestrating ||
|
||||
step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true);
|
||||
// 顶部「会商运行」状态在等待用户输入时仍可能保持 true,不能据此把发送键误显示成暂停。
|
||||
// 仅在初始化或实际流式输出时,沿用第一步输入框的暂停态。
|
||||
const isStep2Responding = isInitializing || isSandboxStreaming;
|
||||
|
||||
// 顶部标题栏整片脱敏(消息列表正文不在此容器,保持原文)。
|
||||
const headerDzRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
@@ -414,78 +418,82 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
const chatBody = (
|
||||
<>
|
||||
{/* Headline */}
|
||||
<div ref={headerDzRef} className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} />
|
||||
<div>
|
||||
<h3 className="text-sm font-extrabold text-slate-900">多智能体圆桌会商中心</h3>
|
||||
<p className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400 font-medium">
|
||||
分布式领域专家实时动态博弈与纳什协商收敛中
|
||||
</p>
|
||||
<div
|
||||
ref={headerDzRef}
|
||||
className={
|
||||
useStep1InputAppearance
|
||||
? "shrink-0 flex items-center justify-between gap-3"
|
||||
: "shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm"
|
||||
}
|
||||
>
|
||||
{useStep1InputAppearance ? (
|
||||
<div className="position-roundtable-intent-header-title">
|
||||
<span aria-hidden="true" />
|
||||
<h2>{headerTitle || "多智能体会商"}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 shrink-0">
|
||||
<select
|
||||
id="step2-global-model-select"
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
disabled={modelsLoading || availableModels.length === 0}
|
||||
title={
|
||||
modelsLoading
|
||||
? "正在加载模型列表…"
|
||||
: availableModels.length === 0
|
||||
? "未在 config.yaml 中配置任何模型"
|
||||
: "切换全局推理模型"
|
||||
}
|
||||
className="max-w-[220px] bg-slate-50/50 border border-slate-200 rounded-xl px-3 py-1.5 text-xs font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
|
||||
>
|
||||
{availableModels.length === 0 && (
|
||||
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
|
||||
)}
|
||||
{availableModels.map((m) => (
|
||||
<option key={m.name} value={m.name}>
|
||||
{m.display_name && m.display_name !== m.name
|
||||
? `${m.display_name} (${m.name})`
|
||||
: m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!isRoundTableRunning ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResumeRoundtable}
|
||||
className={`bg-emerald-100 hover:bg-emerald-200 text-emerald-800 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-emerald-200/40 shadow-sm ${
|
||||
pauseFlash
|
||||
? "ring-2 ring-emerald-400 ring-offset-1 animate-pulse"
|
||||
: ""
|
||||
}`}
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} />
|
||||
<div>
|
||||
<h3 className="text-sm font-extrabold text-slate-900">多智能体圆桌会商中心</h3>
|
||||
<p className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400 font-medium">
|
||||
分布式领域专家实时动态博弈与纳什协商收敛中
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
useStep1InputAppearance
|
||||
? "position-roundtable-intent-header-actions"
|
||||
: "flex items-center gap-2.5 shrink-0"
|
||||
}
|
||||
>
|
||||
{!useStep1InputAppearance && (
|
||||
<select
|
||||
id="step2-global-model-select"
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
disabled={modelsLoading || availableModels.length === 0}
|
||||
title={
|
||||
modelsLoading
|
||||
? "正在加载模型列表…"
|
||||
: availableModels.length === 0
|
||||
? "未在 config.yaml 中配置任何模型"
|
||||
: "切换全局推理模型"
|
||||
}
|
||||
className="max-w-[220px] bg-slate-50/50 border border-slate-200 rounded-xl px-3 py-1.5 text-xs font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 fill-emerald-800" />
|
||||
恢复会商
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPauseRoundtable}
|
||||
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-amber-200/40 shadow-sm"
|
||||
>
|
||||
<Pause className="w-3.5 h-3.5" />
|
||||
暂停会商
|
||||
</button>
|
||||
{availableModels.length === 0 && (
|
||||
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
|
||||
)}
|
||||
{availableModels.map((m) => (
|
||||
<option key={m.name} value={m.name}>
|
||||
{m.display_name && m.display_name !== m.name
|
||||
? `${m.display_name} (${m.name})`
|
||||
: m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{/* 后台挂起:放在暂停/恢复按钮右侧。查看历史记录时隐藏(isViewingHistory),
|
||||
{/* 后台挂起:查看历史记录时隐藏(isViewingHistory),
|
||||
重新从第一步进入第二步研讨时(isViewingHistory 为假)正常显示。 */}
|
||||
{SHOW_BACKGROUND_SUSPEND && !isViewingHistory && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackgroundSuspend}
|
||||
title="把当前研讨转入后台自动跑到结果绘制完成;可离开页面,进度在历史记录里查看"
|
||||
className="bg-slate-100 hover:bg-slate-200 text-slate-700 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-slate-200/60 shadow-sm dark:bg-secondary dark:text-foreground dark:border-border"
|
||||
className={
|
||||
useStep1InputAppearance
|
||||
? "eightbf-roundtable__suspend-button"
|
||||
: "bg-slate-100 hover:bg-slate-200 text-slate-700 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-slate-200/60 shadow-sm dark:bg-secondary dark:text-foreground dark:border-border"
|
||||
}
|
||||
>
|
||||
<Network className="w-3.5 h-3.5" />
|
||||
{!useStep1InputAppearance && <Network className="w-3.5 h-3.5" />}
|
||||
后台挂起
|
||||
</button>
|
||||
)}
|
||||
{headerRightSlot}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -555,7 +563,12 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
// 另一轴会被计算成 auto,任何子元素溢出一两像素都会让整列横向可滚 → 鼠标移到右侧
|
||||
// 竖滚条滑块时极易误触发横向拖动、抓不住滑块。宽内容(代码块/表格)在各自气泡内
|
||||
// 已有 max-w-full + overflow-x-auto 自带横滚,故此处裁掉横向不影响查看。
|
||||
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-6 space-y-4 bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner"
|
||||
className={cn(
|
||||
"flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-6 space-y-4",
|
||||
useStep1InputAppearance
|
||||
? "eightbf-roundtable-step2-log"
|
||||
: "bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner",
|
||||
)}
|
||||
>
|
||||
{isInitializing && step2RoundtableDialogues.length === 0 && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-slate-500">
|
||||
@@ -591,6 +604,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
onFileCardClick={(path) => {
|
||||
if (msg.agentThreadId) openArtifactPreview(msg.agentThreadId, path);
|
||||
}}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-6 text-center text-xs text-slate-400">
|
||||
@@ -647,13 +661,19 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
openArtifactPreview(msg.agentThreadId, path);
|
||||
}
|
||||
}}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
|
||||
{!step2AtBottom && (
|
||||
<ScrollToBottomButton
|
||||
onClick={scrollStep2ToBottom}
|
||||
className="!bottom-1/2 translate-y-1/2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clarification card */}
|
||||
@@ -730,92 +750,71 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Console + intervention bar */}
|
||||
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex flex-col gap-2">
|
||||
{/* 已选附件预览(点「干预并发送」时随指令上传) */}
|
||||
{injectFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{injectFiles.map((file, idx) => (
|
||||
<span
|
||||
key={`${file.name}-${file.size}-${idx}`}
|
||||
className="inline-flex items-center gap-1.5 bg-white border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
|
||||
{useStep1InputAppearance ? (
|
||||
/* 8BF 直接复用第一步 PositionIntentInput 的 DOM、图标和深蓝框体;仅替换成会商干预的状态与动作。 */
|
||||
<div className="position-roundtable-intent-page eightbf-roundtable-step2-input [&_#suggest-followup-btn]:hidden [&_#skip-question-btn]:hidden [&_#save-draft-btn]:hidden">
|
||||
<PositionIntentInput
|
||||
value={customAgentInput}
|
||||
onValueChange={setCustomAgentInput}
|
||||
attachedFiles={injectFiles}
|
||||
onAddFiles={addInjectFiles}
|
||||
onRemoveFile={removeInjectFile}
|
||||
charactersLeft={Math.max(0, 500 - customAgentInput.length)}
|
||||
isSending={isStep2Responding}
|
||||
canSend={customAgentInput.trim().length > 0 || injectFiles.length > 0}
|
||||
pendingClarification={false}
|
||||
onSend={handleInject}
|
||||
onStop={onPauseRoundtable}
|
||||
onFollowUp={() => undefined}
|
||||
onSkip={() => undefined}
|
||||
onEnterRoundtable={() => undefined}
|
||||
enterButtonLabel=""
|
||||
intentReady={null}
|
||||
hideEnterRoundtable
|
||||
onManualSave={() => undefined}
|
||||
onTriggerToast={() => undefined}
|
||||
modelOptions={availableModels.map((model) => ({
|
||||
label: model.display_name && model.display_name !== model.name
|
||||
? `${model.display_name} (${model.name})`
|
||||
: model.name,
|
||||
value: model.name,
|
||||
}))}
|
||||
selectedModel={selectedModel}
|
||||
onSelectedModelChange={setSelectedModel}
|
||||
footerAction={(
|
||||
/* 与第一步「确认意图并启动业务链」复用相同的稳定 id 和样式规则。 */
|
||||
<button
|
||||
id="enter-roundtable-btn"
|
||||
type="button"
|
||||
onClick={onProceedToStep3}
|
||||
disabled={!hasConsensus}
|
||||
aria-disabled={!hasConsensus}
|
||||
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
|
||||
className={cn(
|
||||
"flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] px-3 text-xs font-black transition active:scale-[0.98] shadow-md shadow-emerald-500/10",
|
||||
hasConsensus
|
||||
? "cursor-pointer bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
: "cursor-not-allowed bg-emerald-600/40 text-white",
|
||||
)}
|
||||
>
|
||||
<FileIcon className="w-3 h-3 shrink-0 text-primary" />
|
||||
<span className="truncate font-medium" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-slate-400">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeInjectFile(idx)}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
|
||||
title="移除"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-3">
|
||||
<AutoGrowTextarea
|
||||
value={customAgentInput}
|
||||
onChange={(e) => setCustomAgentInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleInject();
|
||||
}
|
||||
}}
|
||||
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预...(Enter 发送 / Shift+Enter 换行)"
|
||||
className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs leading-relaxed focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium"
|
||||
maxHeight={280}
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={injectFileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handlePickInjectFiles}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => injectFileInputRef.current?.click()}
|
||||
title="上传参考文件(席位/总控将读取文件内容辅助研讨)"
|
||||
className="bg-white border border-slate-200 hover:bg-slate-50 text-slate-500 hover:text-primary p-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInject}
|
||||
title="记录干预指令并立即恢复会商执行"
|
||||
className="bg-primary hover:bg-[var(--brand-900)] text-white text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-primary/10"
|
||||
>
|
||||
干预并发送
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onProceedToStep3}
|
||||
disabled={!hasConsensus}
|
||||
aria-disabled={!hasConsensus}
|
||||
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
|
||||
className={`text-xs font-black px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 shadow-md shadow-emerald-500/10 ${
|
||||
hasConsensus
|
||||
? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
|
||||
: "bg-emerald-600/40 text-white cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
|
||||
达成共识生成全案成果 >
|
||||
</button>
|
||||
<Sparkles className="size-3.5 text-emerald-100" />
|
||||
达成共识生成全案成果 >
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Step2LegacyComposer
|
||||
value={customAgentInput}
|
||||
onValueChange={setCustomAgentInput}
|
||||
files={injectFiles}
|
||||
onAddFiles={addInjectFiles}
|
||||
onRemoveFile={removeInjectFile}
|
||||
onSend={handleInject}
|
||||
hasConsensus={hasConsensus}
|
||||
onProceed={onProceedToStep3}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -828,12 +827,129 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
activeThreadId={activeArtifactThreadId}
|
||||
isStreaming={isSandboxStreaming}
|
||||
seatMessages={activeSeatMessages}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">{chatBody}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full min-h-0 flex-col",
|
||||
useStep1InputAppearance
|
||||
? "position-roundtable-intent-panel eightbf-roundtable-intent-panel eightbf-roundtable-step2-chat"
|
||||
: "bg-slate-50/20",
|
||||
)}
|
||||
>
|
||||
{chatBody}
|
||||
</div>
|
||||
</Step2SandboxLayout>
|
||||
);
|
||||
}
|
||||
|
||||
/** 原通用会商的输入区。8BF 使用第一步复用组件时,其他会商页面保持此既有结构。 */
|
||||
function Step2LegacyComposer({
|
||||
value,
|
||||
onValueChange,
|
||||
files,
|
||||
onAddFiles,
|
||||
onRemoveFile,
|
||||
onSend,
|
||||
hasConsensus,
|
||||
onProceed,
|
||||
}: {
|
||||
value: string;
|
||||
onValueChange: React.Dispatch<React.SetStateAction<string>>;
|
||||
files: File[];
|
||||
onAddFiles: (files: File[]) => void;
|
||||
onRemoveFile: (index: number) => void;
|
||||
onSend: () => void;
|
||||
hasConsensus: boolean;
|
||||
onProceed: () => void;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<div className="shrink-0 flex flex-col gap-2 border-t border-slate-100 bg-slate-50 p-4">
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{files.map((file, index) => (
|
||||
<span
|
||||
key={`${file.name}-${file.size}-${index}`}
|
||||
className="inline-flex max-w-[200px] items-center gap-1.5 rounded-lg border border-slate-200 bg-white py-1 pl-2 pr-1 text-[11px] text-slate-600"
|
||||
>
|
||||
<FileIcon className="size-3 shrink-0 text-primary" />
|
||||
<span className="truncate font-medium" title={file.name}>{file.name}</span>
|
||||
<span className="shrink-0 text-slate-400">{formatFileSize(file.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveFile(index)}
|
||||
className="shrink-0 rounded p-0.5 text-slate-400 transition hover:bg-slate-200 hover:text-slate-600"
|
||||
title="移除"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-3">
|
||||
<AutoGrowTextarea
|
||||
value={value}
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
}}
|
||||
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预...(Enter 发送 / Shift+Enter 换行)"
|
||||
className="min-h-[48px] flex-1 rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-xs font-medium leading-relaxed focus:border-primary focus:outline-none focus:ring-1 focus:ring-[var(--brand-200)]/50"
|
||||
maxHeight={280}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
onAddFiles(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="上传参考文件(席位/总控将读取文件内容辅助研讨)"
|
||||
className="shrink-0 cursor-pointer rounded-xl border border-slate-200 bg-white p-2.5 text-slate-500 transition hover:bg-slate-50 hover:text-primary active:scale-95"
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSend}
|
||||
title="记录干预指令并立即恢复会商执行"
|
||||
className="shrink-0 cursor-pointer rounded-xl bg-primary px-4 py-2.5 text-xs font-bold text-white shadow-md shadow-primary/10 transition hover:bg-[var(--brand-900)] active:scale-95"
|
||||
>
|
||||
干预并发送
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onProceed}
|
||||
disabled={!hasConsensus}
|
||||
aria-disabled={!hasConsensus}
|
||||
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1 rounded-xl px-4 py-2.5 text-xs font-black shadow-md shadow-emerald-500/10 transition active:scale-95",
|
||||
hasConsensus
|
||||
? "cursor-pointer bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
: "cursor-not-allowed bg-emerald-600/40 text-white",
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-3.5 text-emerald-100" />
|
||||
达成共识生成全案成果 >
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条 Step 2 对话气泡。包含:
|
||||
* - ChainOfThought 折叠卡(若 msg.steps 非空)
|
||||
@@ -863,6 +979,7 @@ export interface Step2MessageProps {
|
||||
) => void;
|
||||
/** 点击气泡下方 `present_files` 文件卡时打开沙箱(走真实 HTTP 路径)。 */
|
||||
onFileCardClick?: (path: string) => void;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}
|
||||
|
||||
export function Step2Message({
|
||||
@@ -873,6 +990,7 @@ export function Step2Message({
|
||||
resolveAgentName,
|
||||
onArtifactStepClick,
|
||||
onFileCardClick,
|
||||
taskArtifactSubmission,
|
||||
}: Step2MessageProps) {
|
||||
const isInjected = msg.sender === "用户 (插话干预)";
|
||||
const isStreaming = msg.streaming === true;
|
||||
@@ -951,6 +1069,8 @@ export function Step2Message({
|
||||
files={presentedFiles}
|
||||
onClick={onFileCardClick}
|
||||
disabled={!msg.agentThreadId}
|
||||
threadId={msg.agentThreadId}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1016,10 +1136,14 @@ function PresentFilesRow({
|
||||
files,
|
||||
onClick,
|
||||
disabled,
|
||||
threadId,
|
||||
taskArtifactSubmission,
|
||||
}: {
|
||||
files: string[];
|
||||
onClick?: (path: string) => void;
|
||||
disabled: boolean;
|
||||
threadId?: string;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-2 flex min-w-0 flex-wrap gap-2">
|
||||
@@ -1029,6 +1153,12 @@ function PresentFilesRow({
|
||||
path={path}
|
||||
disabled={disabled || !onClick}
|
||||
onClick={() => onClick?.(path)}
|
||||
submitButton={threadId ? (
|
||||
<TaskArtifactSubmitButton
|
||||
controller={taskArtifactSubmission}
|
||||
target={taskArtifactSubmission?.targetFor({ threadId, path }) ?? { threadId, path }}
|
||||
/>
|
||||
) : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1062,37 +1192,41 @@ function PresentFileCard({
|
||||
path,
|
||||
disabled,
|
||||
onClick,
|
||||
submitButton,
|
||||
}: {
|
||||
path: string;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
submitButton?: ReactNode;
|
||||
}) {
|
||||
const filename = path.split("/").pop() ?? path;
|
||||
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
const typeLabel = FILE_EXT_LABELS[ext] ?? (ext.toUpperCase() || "FILE");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={disabled ? "席位线程未就绪,暂无法预览" : path}
|
||||
className="box-border flex w-[12.5rem] max-w-full min-w-0 flex-col gap-1 overflow-hidden rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-primary/50 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
|
||||
<span
|
||||
className="roundtable-chat-message min-w-0 flex-1 truncate font-medium text-slate-700"
|
||||
title={filename}
|
||||
>
|
||||
{filename}
|
||||
</span>
|
||||
</div>
|
||||
<div className="box-border flex w-[12.5rem] max-w-full min-w-0 flex-col gap-1 overflow-hidden rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-primary/50 hover:bg-slate-50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={disabled ? "席位线程未就绪,暂无法预览" : path}
|
||||
className="flex min-w-0 flex-col gap-1 text-left disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
|
||||
<span
|
||||
className="roundtable-chat-message min-w-0 flex-1 truncate font-medium text-slate-700"
|
||||
title={filename}
|
||||
>
|
||||
{filename}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<span className="roundtable-chat-meta rounded bg-slate-100 px-1.5 py-0.5 font-mono font-semibold text-slate-500">
|
||||
{typeLabel}
|
||||
</span>
|
||||
<span className="roundtable-chat-meta text-slate-400">点击预览</span>
|
||||
{submitButton}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,9 +24,47 @@ import {
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton";
|
||||
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
|
||||
|
||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||
|
||||
/** 从 write_file 虚拟地址反查流式工具调用中的正文,供任务产物提交直接使用。 */
|
||||
function virtualMarkdownContent(
|
||||
artifact: string | null,
|
||||
messages: unknown[] | undefined,
|
||||
): string | undefined {
|
||||
if (!artifact?.startsWith("write-file:") || !messages?.length) return undefined;
|
||||
try {
|
||||
const url = new URL(artifact);
|
||||
const toolCallId = url.searchParams.get("tool_call_id");
|
||||
const path = decodeURIComponent(url.pathname).trim();
|
||||
for (const message of messages) {
|
||||
if (!message || typeof message !== "object") continue;
|
||||
const toolCalls = (message as { tool_calls?: unknown }).tool_calls;
|
||||
if (!Array.isArray(toolCalls)) continue;
|
||||
for (const call of toolCalls) {
|
||||
if (!call || typeof call !== "object") continue;
|
||||
const toolCall = call as {
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
args?: { path?: unknown; content?: unknown };
|
||||
};
|
||||
if (toolCall.name !== "write_file") continue;
|
||||
if (toolCallId && toolCall.id !== toolCallId) continue;
|
||||
if (!toolCallId && toolCall.args?.path !== path) continue;
|
||||
return typeof toolCall.args?.content === "string"
|
||||
? toolCall.args.content
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 虚拟路径解析失败时交给提交层走它原有的真实文件读取兜底。
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface Step2SandboxLayoutProps {
|
||||
children?: React.ReactNode;
|
||||
/** 当前预览文件所属的席位 thread id。 */
|
||||
@@ -48,6 +86,8 @@ interface Step2SandboxLayoutProps {
|
||||
* 左侧消息列表保持可见;置空即恢复沙箱(文件预览/列表)。
|
||||
*/
|
||||
sidePanel?: React.ReactNode;
|
||||
/** 任务深链页面的 Markdown 过程产物提交能力。 */
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}
|
||||
|
||||
export function Step2SandboxLayout({
|
||||
@@ -58,6 +98,7 @@ export function Step2SandboxLayout({
|
||||
artifactsFill = false,
|
||||
onArtifactsClose,
|
||||
sidePanel,
|
||||
taskArtifactSubmission,
|
||||
}: Step2SandboxLayoutProps) {
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const {
|
||||
@@ -68,6 +109,10 @@ export function Step2SandboxLayout({
|
||||
deselect,
|
||||
} = useArtifacts();
|
||||
const artifacts = rawArtifacts ?? [];
|
||||
const selectedArtifactContent = useMemo(
|
||||
() => virtualMarkdownContent(selectedArtifact, seatMessages),
|
||||
[selectedArtifact, seatMessages],
|
||||
);
|
||||
|
||||
const artifactPanelOpen = artifactsOpen;
|
||||
// sidePanel(流程图等)非空时即使沙箱没开也要把右栏撑开。
|
||||
@@ -115,9 +160,20 @@ export function Step2SandboxLayout({
|
||||
filepath={selectedArtifact}
|
||||
threadId={previewThreadId}
|
||||
onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined}
|
||||
allowVirtualMarkdownEdit
|
||||
// 会商「消息列表沙箱」按需求不脱敏产物内容(用户要拿原始文件)。
|
||||
disableDesensitize
|
||||
headerLeadingActionBare
|
||||
headerLeadingAction={(
|
||||
<TaskArtifactSubmitButton
|
||||
compact
|
||||
controller={taskArtifactSubmission}
|
||||
target={taskArtifactSubmission?.targetFor({
|
||||
threadId: previewThreadId,
|
||||
path: selectedArtifact,
|
||||
content: selectedArtifactContent,
|
||||
}) ?? { threadId: previewThreadId, path: selectedArtifact }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
|
||||
@@ -20,8 +20,12 @@ import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3
|
||||
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||
import { stripReasoning } from "@/roundtable-planning/lib/reasoning";
|
||||
import { useMemo } from "react";
|
||||
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
|
||||
|
||||
export interface Step3PanelProps {
|
||||
/** 8BF 第三步对话区与第一、二步共用深蓝顶栏;默认不改变原会商。 */
|
||||
useStep1Chrome?: boolean;
|
||||
headerTitle?: string;
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
consensusText: string;
|
||||
@@ -54,10 +58,13 @@ export interface Step3PanelProps {
|
||||
/** Step 2 沙箱预览产出文件(与第二步相同)。 */
|
||||
openArtifactPreview: (threadId: string, path: string) => void;
|
||||
seatStubMessages: Record<string, unknown[]>;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}
|
||||
|
||||
export function Step3Panel(props: Step3PanelProps) {
|
||||
const {
|
||||
useStep1Chrome = false,
|
||||
headerTitle,
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
consensusText,
|
||||
@@ -80,6 +87,7 @@ export function Step3Panel(props: Step3PanelProps) {
|
||||
onReset,
|
||||
openArtifactPreview,
|
||||
seatStubMessages,
|
||||
taskArtifactSubmission,
|
||||
} = props;
|
||||
|
||||
const seatDeliveries = useMemo(
|
||||
@@ -105,6 +113,8 @@ export function Step3Panel(props: Step3PanelProps) {
|
||||
|
||||
return (
|
||||
<HighFidelityReport
|
||||
useStep1Chrome={useStep1Chrome}
|
||||
headerTitle={headerTitle}
|
||||
threadIds={threadIds}
|
||||
coordinatorName={coordinatorName}
|
||||
consensusText={consensusText}
|
||||
@@ -127,6 +137,7 @@ export function Step3Panel(props: Step3PanelProps) {
|
||||
onReset={onReset}
|
||||
openArtifactPreview={openArtifactPreview}
|
||||
seatStubMessages={seatStubMessages}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/Co
|
||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
|
||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
||||
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
||||
@@ -57,6 +58,7 @@ import type {
|
||||
ReportIntent,
|
||||
UseStep3ReportReturn,
|
||||
} from "@/roundtable-planning/hooks/useStep3Report";
|
||||
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
|
||||
|
||||
interface SeatDelivery {
|
||||
sender: string;
|
||||
@@ -88,6 +90,7 @@ interface Step3ReportModeProps {
|
||||
/** 页面层常驻的大屏多页流(模式二)。 */
|
||||
dashboard: UseStep3DashboardReturn;
|
||||
onBack: () => void;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}
|
||||
|
||||
type SourceModal = "consensus" | "seats" | "files" | null;
|
||||
@@ -108,6 +111,7 @@ export function Step3ReportMode({
|
||||
report,
|
||||
dashboard,
|
||||
onBack,
|
||||
taskArtifactSubmission,
|
||||
}: Step3ReportModeProps) {
|
||||
const reportSeatDeliveries = useMemo(
|
||||
() => seatDeliveries.map((d) => ({ sender: d.sender, content: d.content })),
|
||||
@@ -376,6 +380,7 @@ export function Step3ReportMode({
|
||||
activeThreadId={activeArtifactThreadId ?? reportThreadId}
|
||||
isStreaming={isStreaming}
|
||||
seatMessages={activeSeatMessages}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
>
|
||||
<AgentProgressPanel
|
||||
phase={phase}
|
||||
@@ -390,6 +395,7 @@ export function Step3ReportMode({
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
</Step2SandboxLayout>
|
||||
</section>
|
||||
@@ -428,6 +434,7 @@ export function Step3ReportMode({
|
||||
threadIds={threadIds}
|
||||
coordinatorName={coordinatorName}
|
||||
seatNameMap={seatNameMap}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
@@ -513,6 +520,7 @@ function AgentProgressPanel({
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
taskArtifactSubmission,
|
||||
}: {
|
||||
phase: UseStep3ReportReturn["phase"];
|
||||
dialogues: UseStep3ReportReturn["dialogues"];
|
||||
@@ -526,6 +534,7 @@ function AgentProgressPanel({
|
||||
availableModels: ReadonlyArray<ModelOption>;
|
||||
modelsLoading: boolean;
|
||||
setSelectedModel: (model: string) => void;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}) {
|
||||
const busy = phase === "creating" || phase === "streaming";
|
||||
// 只有「正在建线程」时锁输入(线程没建好没法插话);绘制中允许输入并发送——
|
||||
@@ -610,6 +619,7 @@ function AgentProgressPanel({
|
||||
onFileCardClick={(path) => {
|
||||
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
|
||||
}}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -822,11 +832,13 @@ function SeatFilesModal({
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
seatNameMap,
|
||||
taskArtifactSubmission,
|
||||
onClose,
|
||||
}: {
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
seatNameMap: Record<string, string>;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const seatEntries = useMemo(() => {
|
||||
@@ -932,6 +944,25 @@ function SeatFilesModal({
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[10px] font-mono text-slate-400">{formatBytes(f.sizeBytes)}</span>
|
||||
<TaskArtifactSubmitButton
|
||||
compact
|
||||
controller={taskArtifactSubmission}
|
||||
target={taskArtifactSubmission?.targetFor({
|
||||
threadId: seat.threadId,
|
||||
path: f.path,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
agentId: seat.agentName,
|
||||
agentName: seat.displayName,
|
||||
}) ?? {
|
||||
threadId: seat.threadId,
|
||||
path: f.path,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
agentId: seat.agentName,
|
||||
agentName: seat.displayName,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePreview(seat.threadId, f)}
|
||||
|
||||
@@ -9,33 +9,27 @@
|
||||
* 布局对齐 Step3ReportMode(HTML 绘制):左栏研讨原料源卡 + 右栏沙箱。两者并存、
|
||||
* 互不影响(report 画 HTML 看板,summary 写 Markdown 报告 + 问答)。
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle,
|
||||
Database,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Loader2,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Paperclip,
|
||||
Pause,
|
||||
RefreshCw,
|
||||
SendHorizontal,
|
||||
Sparkles,
|
||||
Workflow,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
|
||||
import {
|
||||
buildArtifactDownloadUrl,
|
||||
listSessionArtifacts,
|
||||
@@ -44,15 +38,10 @@ import {
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
|
||||
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
|
||||
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
|
||||
import {
|
||||
detectFlowIntent,
|
||||
extractFlowGraph,
|
||||
FLOW_FENCE_OPEN,
|
||||
} from "@/roundtable-planning/lib/flow-graph";
|
||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
|
||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
||||
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
|
||||
@@ -61,6 +50,7 @@ import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
|
||||
import { useStep3Summary, type UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
|
||||
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
|
||||
|
||||
interface SeatDelivery {
|
||||
sender: string;
|
||||
@@ -69,6 +59,8 @@ interface SeatDelivery {
|
||||
}
|
||||
|
||||
interface Step3SummaryModeProps {
|
||||
useStep1Chrome?: boolean;
|
||||
headerTitle?: string;
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
consensusText: string;
|
||||
@@ -78,7 +70,6 @@ interface Step3SummaryModeProps {
|
||||
intentReady: ReportIntent | null;
|
||||
selectedModel: string;
|
||||
availableModels: ReadonlyArray<ModelOption>;
|
||||
modelsLoading: boolean;
|
||||
setSelectedModel: (model: string) => void;
|
||||
/** 业务链写给总控的编排提示 —— 注入总结智能体作为最高优先级口径。 */
|
||||
coordinatorPrompt?: string | null;
|
||||
@@ -86,13 +77,14 @@ interface Step3SummaryModeProps {
|
||||
savedSummary?: DraftStep3SummarySnapshot | null;
|
||||
/** 页面层常驻的总结生成流(2026-06-13 提升:切走不断流,进入本视图续看)。 */
|
||||
summary: UseStep3SummaryReturn;
|
||||
/** 切到「最终方案展示」静态页(次要入口)。 */
|
||||
onViewStatic: () => void;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}
|
||||
|
||||
type SourceModal = "consensus" | "seats" | "files" | null;
|
||||
|
||||
export function Step3SummaryMode({
|
||||
useStep1Chrome = false,
|
||||
headerTitle,
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
consensusText,
|
||||
@@ -102,12 +94,11 @@ export function Step3SummaryMode({
|
||||
intentReady,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
coordinatorPrompt,
|
||||
savedSummary,
|
||||
summary,
|
||||
onViewStatic,
|
||||
taskArtifactSubmission,
|
||||
}: Step3SummaryModeProps) {
|
||||
void coordinatorPrompt; // 选项已随 hook 提升到页面层,这里仅保留 prop 兼容
|
||||
|
||||
@@ -140,42 +131,7 @@ export function Step3SummaryMode({
|
||||
|
||||
const { open: artifactsOpen } = useArtifacts();
|
||||
|
||||
// ── 结构化流程图(Phase 1:后端「抽取+校验」)────────────────────────────
|
||||
// 数据来源优先级:
|
||||
// · flowPhase==="done" → 用后端校验后的结构化图 summary.flowGraph 绘制;
|
||||
// · 抽取/校验进行中 → 不画(用户看对话气泡里实时滚动的 JSON + 状态条),等校验完成再绘制;
|
||||
// · idle/error → 回退到对话气泡里解析 flow-json(手动 flow 续问路径 / 旧逻辑兜底)。
|
||||
const flowGraph = useMemo(() => {
|
||||
if (summary.flowPhase === "done" && summary.flowGraph) {
|
||||
return { graph: summary.flowGraph, streaming: false };
|
||||
}
|
||||
if (summary.flowPhase === "extracting" || summary.flowPhase === "validating") {
|
||||
return null;
|
||||
}
|
||||
for (let i = dialogues.length - 1; i >= 0; i--) {
|
||||
const d = dialogues[i];
|
||||
if (d.avatarType === "human") continue;
|
||||
const content = d.content ?? "";
|
||||
if (!content.includes(FLOW_FENCE_OPEN)) continue;
|
||||
const graph = extractFlowGraph(content);
|
||||
if (graph) return { graph, streaming: d.streaming === true };
|
||||
}
|
||||
return null;
|
||||
}, [dialogues, summary.flowPhase, summary.flowGraph]);
|
||||
|
||||
const [flowView, setFlowView] = useState(false);
|
||||
const flowAutoOpenedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!flowGraph || flowGraph.graph.nodes.length === 0) return;
|
||||
if (flowAutoOpenedRef.current) return;
|
||||
flowAutoOpenedRef.current = true;
|
||||
// taskId/深链模式:结构化输出是本模式的主交付 → 流程图一出现就自动聚焦画布
|
||||
// (即便此前 md 沙箱已自动打开,也切到流程图)。
|
||||
// 普通模式:没开沙箱才自动打开;沙箱开着不抢占,只露出切换按钮。
|
||||
if (summary.autoFlow || !artifactsOpen) setFlowView(true);
|
||||
}, [flowGraph, artifactsOpen, summary.autoFlow]);
|
||||
|
||||
// 沙箱里已写出的 md 报告(流程图画布上提供「MD 报告」切回按钮的锚点)。
|
||||
// 沙箱里已写出的 Markdown 总结报告。
|
||||
const mdArtifact = useMemo(() => {
|
||||
for (const [threadId, msgs] of Object.entries(seatStubMessages)) {
|
||||
const msg = msgs?.[0];
|
||||
@@ -196,25 +152,16 @@ export function Step3SummaryMode({
|
||||
return null;
|
||||
}, [seatStubMessages]);
|
||||
|
||||
// 用户在输入框里明确要求结构化输出/流程图 → 立即切到流程图画布(旧图先顶着,
|
||||
// 新 flow-json 流出来就增量重绘;首次还没有图时画布在数据到达后自动渲染)。
|
||||
const handleAsk = (message: string, files?: File[]) => {
|
||||
if (detectFlowIntent(message)) {
|
||||
flowAutoOpenedRef.current = true;
|
||||
setFlowView(true);
|
||||
}
|
||||
ask(message, files);
|
||||
};
|
||||
const handleAsk = (message: string, files?: File[]) => ask(message, files);
|
||||
|
||||
// 流程图抽取/校验的过程(思考 `<think>` + 文字说明 + flow-json 代码块)全部原样交给
|
||||
// Step2Message 渲染:它用 splitInlineReasoning 把思考折叠成思考块、其余按 markdown 渲染
|
||||
// (flow-json 显示为代码块)。用户要求显示完整过程、不只显示代码块,故不再 strip。
|
||||
// 报告生成过程(思考、文字说明、文件写入步骤)交由 Step2Message 原样渲染,
|
||||
// 保持与第二步一致的流式过程可见性。
|
||||
const displayDialogues = dialogues;
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
// 右侧面板(沙箱预览或流程图画布)露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
|
||||
// 右侧沙箱露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
|
||||
// 初值固定为 false:这样即使「进入/续看时沙箱已是打开态」(rightPanelVisible 一上来就为 true),
|
||||
// 首个 effect 也能识别 false→true 的边沿并收起左栏(修此前已打开时左栏不收起的问题)。
|
||||
const rightPanelVisible = artifactsOpen || (!!flowGraph && flowView);
|
||||
const rightPanelVisible = artifactsOpen;
|
||||
const prevRightPanelVisibleRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (rightPanelVisible !== prevRightPanelVisibleRef.current) {
|
||||
@@ -265,46 +212,42 @@ export function Step3SummaryMode({
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col text-slate-800">
|
||||
{/* 顶栏 */}
|
||||
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3.5 flex-wrap">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-sm font-black text-slate-900 tracking-tight">方案总结 · 报告</h3>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-primary bg-[var(--brand-200)]/60 border border-red-200/50 px-2 py-0.5 rounded-full">
|
||||
{(phase === "creating" || phase === "streaming") && (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
)}
|
||||
{phase === "done" && <CheckCircle className="w-3 h-3 text-emerald-600" />}
|
||||
{statusLabel}
|
||||
</span>
|
||||
{/* 顶栏。8BF 改走对话区内的第一步同款顶栏,这里不再套一层。 */}
|
||||
{!useStep1Chrome && (
|
||||
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3.5 flex-wrap">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-sm font-black text-slate-900 tracking-tight">方案总结 · 报告</h3>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-primary bg-[var(--brand-200)]/60 border border-red-200/50 px-2 py-0.5 rounded-full">
|
||||
{(phase === "creating" || phase === "streaming") && (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
)}
|
||||
{phase === "done" && <CheckCircle className="w-3 h-3 text-emerald-600" />}
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => redraw()}
|
||||
disabled={phase === "creating" || phase === "streaming"}
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
|
||||
重新生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => redraw()}
|
||||
disabled={phase === "creating" || phase === "streaming"}
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
|
||||
重新生成
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewStatic}
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
最终方案展示
|
||||
<ArrowRight className="w-3.5 h-3.5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 主体:左源卡 + 右沙箱 */}
|
||||
<div className="flex-1 min-h-0 flex gap-3 p-3">
|
||||
{/* 主体:左源卡 + 右沙箱。8BF 左栏改用第一步任务/意图区,这里只留对话面板。 */}
|
||||
<div className={cn("flex-1 min-h-0 flex", !useStep1Chrome && "gap-3 p-3")}>
|
||||
{!useStep1Chrome && (
|
||||
<aside
|
||||
data-step3-source-panel
|
||||
className={cn(
|
||||
"shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5 transition-[width] duration-200 ease-out",
|
||||
leftCollapsed ? "w-[52px]" : "w-[280px]",
|
||||
"eightbf-roundtable__step3-source-panel shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5 transition-[width] duration-200 ease-out",
|
||||
leftCollapsed ? "w-[80px]" : "w-[280px]",
|
||||
)}
|
||||
>
|
||||
{leftCollapsed ? (
|
||||
@@ -314,7 +257,7 @@ export function Step3SummaryMode({
|
||||
onClick={() => setLeftCollapsed(false)}
|
||||
title="展开研讨原料"
|
||||
aria-expanded={false}
|
||||
className="w-full shrink-0 flex h-10 items-center justify-center rounded-2xl border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
|
||||
className="w-full shrink-0 flex h-10 items-center justify-center rounded-[4px] border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -347,7 +290,7 @@ export function Step3SummaryMode({
|
||||
onClick={() => setLeftCollapsed(true)}
|
||||
title="收起研讨原料"
|
||||
aria-expanded
|
||||
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors"
|
||||
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-[4px] border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors"
|
||||
>
|
||||
<PanelLeftClose className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -375,57 +318,37 @@ export function Step3SummaryMode({
|
||||
onClick={() => setModal("files")}
|
||||
/>
|
||||
|
||||
<div className="mt-1 rounded-2xl border border-dashed border-slate-200 bg-slate-50/60 p-3 text-[11px] leading-relaxed text-slate-400">
|
||||
<div className="mt-1 rounded-[4px] border border-dashed border-slate-200 bg-slate-50/60 p-3 text-[11px] leading-relaxed text-slate-400">
|
||||
右侧由「方案总结报告智能体」据这些原料撰写 Markdown 总结报告,可在下方输入框继续提修改意见或提问。
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* 右栏:智能体进度 + 沙箱(复用第 2 步)。流程图画布放在右侧沙箱位
|
||||
(sidePanel 插槽),消息列表保持可见,可与 md 沙箱预览互相切换。 */}
|
||||
<section className="relative flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
|
||||
{/* 右栏:智能体进度 + Markdown 沙箱(复用第 2 步)。 */}
|
||||
<section
|
||||
data-step3-report-panel
|
||||
className={cn(
|
||||
"relative flex-1 min-w-0 overflow-hidden",
|
||||
!useStep1Chrome &&
|
||||
"eightbf-roundtable__step3-report-panel rounded-2xl border border-slate-200/60 bg-white",
|
||||
)}
|
||||
>
|
||||
<Step2SandboxLayout
|
||||
activeThreadId={activeArtifactThreadId ?? summaryThreadId}
|
||||
isStreaming={isStreaming}
|
||||
seatMessages={activeSeatMessages}
|
||||
sidePanel={
|
||||
flowGraph && flowView ? (
|
||||
<FlowGraphPanel
|
||||
data={flowGraph.graph}
|
||||
streaming={flowGraph.streaming}
|
||||
onClose={() => setFlowView(false)}
|
||||
onSwitchToReport={
|
||||
mdArtifact
|
||||
? () => {
|
||||
setFlowView(false);
|
||||
openArtifactPreview(mdArtifact.threadId, mdArtifact.path);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onChange={summary.setFlowGraph}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
>
|
||||
<AgentProgressPanel
|
||||
useStep1Chrome={useStep1Chrome}
|
||||
headerTitle={headerTitle}
|
||||
phase={phase}
|
||||
dialogues={displayDialogues}
|
||||
error={error}
|
||||
busy={busy}
|
||||
mdAvailable={!!mdArtifact}
|
||||
flowPhase={summary.flowPhase}
|
||||
onRegenerateFlow={summary.regenerateFlow}
|
||||
pendingClarification={summary.pendingFlowClarification}
|
||||
onResolveClarification={summary.resolveFlowClarification}
|
||||
ingestAvailable={summary.ingestAvailable}
|
||||
ingestStatus={summary.ingestStatus}
|
||||
pendingIngestClarification={summary.pendingIngestClarification}
|
||||
onResolveIngestChoice={summary.resolveIngestChoice}
|
||||
onResolveIngestClarification={summary.resolveIngestClarification}
|
||||
pendingFlowForIngest={summary.pendingFlowForIngest}
|
||||
onResolveFlowForIngest={summary.resolveFlowForIngest}
|
||||
onManualIngest={summary.runIngest}
|
||||
onRetry={redraw}
|
||||
onAsk={handleAsk}
|
||||
onPause={pause}
|
||||
@@ -433,31 +356,11 @@ export function Step3SummaryMode({
|
||||
onArtifactPreview={openArtifactPreview}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
</Step2SandboxLayout>
|
||||
|
||||
{/* 流程图入口按钮(有结构化数据且画布未开时显示;沙箱开着时即「切换」按钮) */}
|
||||
{flowGraph && !flowView && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFlowView(true)}
|
||||
title={artifactsOpen ? "切换到流程图画布" : "打开流程图画布"}
|
||||
className="absolute bottom-4 right-4 z-20 inline-flex items-center gap-1.5 rounded-full border border-primary/30 bg-white px-3.5 py-2 text-[11px] font-bold text-primary shadow-lg transition hover:bg-primary/5"
|
||||
>
|
||||
{flowGraph.streaming ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Workflow className="h-3.5 w-3.5" />
|
||||
)}
|
||||
流程图
|
||||
<span className="rounded-full bg-primary/10 px-1.5 font-mono text-[10px]">
|
||||
{flowGraph.graph.nodes.length}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -491,6 +394,7 @@ export function Step3SummaryMode({
|
||||
threadIds={threadIds}
|
||||
coordinatorName={coordinatorName}
|
||||
seatNameMap={seatNameMap}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
@@ -520,7 +424,7 @@ function SourceCard({
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="text-left bg-white border border-slate-200/60 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:border-primary/40 hover:shadow-[0_4px_14px_rgba(0,0,0,0.04)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-slate-200/60"
|
||||
className="text-left bg-white border border-slate-200/60 rounded-[4px] p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:border-primary/40 hover:shadow-[0_4px_14px_rgba(0,0,0,0.04)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-slate-200/60"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
{icon}
|
||||
@@ -553,7 +457,7 @@ function CollapsedSourceCard({
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="w-full shrink-0 flex items-center justify-center rounded-2xl border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
className="w-full shrink-0 flex items-center justify-center rounded-[4px] border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
@@ -563,23 +467,13 @@ function CollapsedSourceCard({
|
||||
// ── 右栏:智能体进度面板(沙箱的 60% 区域)+ 底部对话输入框 ──────────────────
|
||||
|
||||
function AgentProgressPanel({
|
||||
useStep1Chrome = false,
|
||||
headerTitle,
|
||||
phase,
|
||||
dialogues,
|
||||
error,
|
||||
busy,
|
||||
mdAvailable,
|
||||
flowPhase,
|
||||
onRegenerateFlow,
|
||||
pendingClarification,
|
||||
onResolveClarification,
|
||||
ingestAvailable,
|
||||
ingestStatus,
|
||||
pendingIngestClarification,
|
||||
onResolveIngestChoice,
|
||||
onResolveIngestClarification,
|
||||
pendingFlowForIngest,
|
||||
onResolveFlowForIngest,
|
||||
onManualIngest,
|
||||
onRetry,
|
||||
onAsk,
|
||||
onPause,
|
||||
@@ -587,32 +481,17 @@ function AgentProgressPanel({
|
||||
onArtifactPreview,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
taskArtifactSubmission,
|
||||
}: {
|
||||
useStep1Chrome?: boolean;
|
||||
headerTitle?: string;
|
||||
phase: UseStep3SummaryReturn["phase"];
|
||||
dialogues: UseStep3SummaryReturn["dialogues"];
|
||||
error: string | null;
|
||||
busy: boolean;
|
||||
/** 沙箱里是否已有 md 报告 —— 决定提示语(纯流程图轮没有 .md 可预览/下载)。 */
|
||||
/** 沙箱里是否已有 Markdown 总结报告。 */
|
||||
mdAvailable: boolean;
|
||||
/** 后端流程图抽取阶段(Phase 1)。 */
|
||||
flowPhase: UseStep3SummaryReturn["flowPhase"];
|
||||
/** 仅重新生成流程图(模型挂了切模型后重试)。 */
|
||||
onRegenerateFlow: () => void;
|
||||
/** 意图模糊待澄清的原始消息(非空 → 展示选项让用户定)。 */
|
||||
pendingClarification: string | null;
|
||||
onResolveClarification: UseStep3SummaryReturn["resolveFlowClarification"];
|
||||
/** 入库技能是否可用(决定入库询问卡 + 手动入库按钮是否出现)。 */
|
||||
ingestAvailable: boolean;
|
||||
ingestStatus: UseStep3SummaryReturn["ingestStatus"];
|
||||
pendingIngestClarification: string | null;
|
||||
onResolveIngestChoice: UseStep3SummaryReturn["resolveIngestChoice"];
|
||||
onResolveIngestClarification: UseStep3SummaryReturn["resolveIngestClarification"];
|
||||
/** 入库时尚无流程图 → 展示「是否先生成流程图」协助。 */
|
||||
pendingFlowForIngest: boolean;
|
||||
onResolveFlowForIngest: UseStep3SummaryReturn["resolveFlowForIngest"];
|
||||
onManualIngest: UseStep3SummaryReturn["runIngest"];
|
||||
onRetry: () => void;
|
||||
onAsk: (message: string, files?: File[]) => void;
|
||||
onPause: () => void;
|
||||
@@ -620,13 +499,13 @@ function AgentProgressPanel({
|
||||
onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"];
|
||||
selectedModel: string;
|
||||
availableModels: ReadonlyArray<ModelOption>;
|
||||
modelsLoading: boolean;
|
||||
setSelectedModel: (model: string) => void;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
|
||||
// 新一轮气泡出现(如:md 报告完成后自动续跑的结构化输出轮、用户发问)→ 强制滚到底,
|
||||
// 让用户看到新阶段(修「调用结构化输出时不自动下滑」)。仅在气泡**条数**增加时触发,
|
||||
// 新一轮气泡出现(报告生成或用户续问)→ 强制滚到底,
|
||||
// 让用户看到新阶段。仅在气泡**条数**增加时触发,
|
||||
// 流式增量内容仍由 useAutoScroll 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。
|
||||
const prevDialogueCountRef = useRef(dialogues.length);
|
||||
useEffect(() => {
|
||||
@@ -642,22 +521,18 @@ function AgentProgressPanel({
|
||||
const [askText, setAskText] = useState("");
|
||||
// 输入框携带的本地附件(点发送时连同指令一起上传到总结线程)。
|
||||
const [askFiles, setAskFiles] = useState<File[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const pickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = Array.from(e.target.files ?? []);
|
||||
const addAskFiles = (picked: File[]) => {
|
||||
if (picked.length > 0) {
|
||||
setAskFiles((prev) => {
|
||||
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
|
||||
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
|
||||
});
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
const removeFile = (idx: number) => setAskFiles((prev) => prev.filter((_, i) => i !== idx));
|
||||
|
||||
const handleAskSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const submitAsk = () => {
|
||||
const text = askText.trim();
|
||||
const hasFiles = askFiles.length > 0;
|
||||
if (!text && !hasFiles) return; // 允许「随时」发送(含生成中);空内容不发
|
||||
@@ -667,49 +542,53 @@ function AgentProgressPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">
|
||||
<div className="shrink-0 flex items-center justify-between gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
|
||||
报
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full min-h-0 flex-col",
|
||||
useStep1Chrome
|
||||
? "position-roundtable-intent-panel eightbf-roundtable-intent-panel eightbf-roundtable-step3-chat"
|
||||
: "bg-slate-50/20",
|
||||
)}
|
||||
>
|
||||
{useStep1Chrome ? (
|
||||
<div className="shrink-0 flex items-center justify-between gap-3">
|
||||
<div className="position-roundtable-intent-header-title">
|
||||
<span aria-hidden="true" />
|
||||
<h2>{headerTitle || "方案总结"}</h2>
|
||||
</div>
|
||||
<div className="leading-tight min-w-0">
|
||||
<div className="text-xs font-bold text-slate-800 truncate">方案总结报告智能体</div>
|
||||
<div className="text-[10px] text-slate-400 truncate">据各席位成果撰写 Markdown 总结报告</div>
|
||||
<div className="position-roundtable-intent-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
disabled={phase === "creating" || phase === "streaming"}
|
||||
>
|
||||
重新生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
id="step3-summary-model-select"
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
disabled={busy || modelsLoading || availableModels.length === 0}
|
||||
title={
|
||||
modelsLoading
|
||||
? "正在加载模型列表…"
|
||||
: availableModels.length === 0
|
||||
? "未在 config.yaml 中配置任何模型"
|
||||
: busy
|
||||
? "撰写中不可切换;完成后或重新生成时生效"
|
||||
: "切换撰写使用的推理模型(下次生成生效)"
|
||||
}
|
||||
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
|
||||
>
|
||||
{availableModels.length === 0 && (
|
||||
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
|
||||
)}
|
||||
{availableModels.map((m) => (
|
||||
<option key={m.name} value={m.name}>
|
||||
{m.display_name && m.display_name !== m.name
|
||||
? `${m.display_name} (${m.name})`
|
||||
: m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="shrink-0 flex items-center gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
|
||||
报
|
||||
</div>
|
||||
<div className="leading-tight min-w-0">
|
||||
<div className="text-xs font-bold text-slate-800 truncate">方案总结报告智能体</div>
|
||||
<div className="text-[10px] text-slate-400 truncate">据各席位成果撰写 Markdown 总结报告</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 消息列表 —— 与第 2 步同款气泡(流式正文 + 工具步骤卡 + 文件卡)。 */}
|
||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-4 space-y-4 bg-white">
|
||||
<div className="relative flex-1 min-h-0">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
"size-full overflow-y-auto overflow-x-hidden p-4 space-y-4",
|
||||
useStep1Chrome ? "eightbf-roundtable-step3-log" : "bg-white",
|
||||
)}
|
||||
>
|
||||
{phase === "creating" && dialogues.length === 0 && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
|
||||
@@ -726,15 +605,14 @@ function AgentProgressPanel({
|
||||
onFileCardClick={(path) => {
|
||||
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
|
||||
}}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
))}
|
||||
|
||||
{busy && (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
|
||||
<span className="bg-primary size-1.5 animate-pulse rounded-full" />
|
||||
{mdAvailable
|
||||
? "报告正文实时输出,右侧沙箱同步预览 Markdown"
|
||||
: "报告正文实时输出;输出结构化数据时流程图画布同步绘制"}
|
||||
{mdAvailable ? "报告正文实时输出,右侧沙箱同步预览 Markdown" : "正在撰写 Markdown 总结报告"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -753,243 +631,54 @@ function AgentProgressPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 后端流程图「抽取+校验」状态条(Phase 1)。 */}
|
||||
{(flowPhase === "extracting" || flowPhase === "validating") && (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-bold text-blue-600">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
{flowPhase === "extracting" ? "结构化流程图抽取中(JSON 实时输出)…" : "流程图校验 / 修复中…"}
|
||||
</div>
|
||||
)}
|
||||
{flowPhase === "error" && (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
|
||||
<div className="font-bold mb-1">流程图生成失败</div>
|
||||
<div className="leading-relaxed">模型可能不稳定。可在上方切换模型后重试。</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerateFlow}
|
||||
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-amber-800 underline hover:text-amber-900"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
重新生成流程图
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "done" && (
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
{mdAvailable
|
||||
? "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告 / 流程图或提问。"
|
||||
: "已生成方案总结,流程图可在画布查看;可在下方继续修改总结 / 流程图或提问。"}
|
||||
? "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告或提问。"
|
||||
: "已生成方案总结,可在下方继续修改报告或提问。"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
|
||||
|
||||
{/* 意图模糊 → 让用户自己定(不自作主张) */}
|
||||
{pendingClarification && (
|
||||
<div className="shrink-0 border-t border-amber-100 bg-amber-50/60 px-3 py-2.5">
|
||||
<div className="mb-1.5 text-[11px] font-bold text-amber-800">
|
||||
没太理解你的意思,你是想——
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(
|
||||
[
|
||||
{ k: "regenerate_report", t: "重新生成报告" },
|
||||
{ k: "modify_flow", t: "修改流程图" },
|
||||
{ k: "regenerate_flow", t: "重新生成流程图" },
|
||||
{ k: "qa", t: "只是提问" },
|
||||
] as const
|
||||
).map((o) => (
|
||||
<button
|
||||
key={o.k}
|
||||
type="button"
|
||||
onClick={() => onResolveClarification(o.k)}
|
||||
className="rounded-lg border border-amber-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-amber-800 transition hover:bg-amber-100"
|
||||
>
|
||||
{o.t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!atBottom && (
|
||||
<ScrollToBottomButton
|
||||
onClick={scrollToBottom}
|
||||
className="!bottom-1/2 translate-y-1/2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 流程图已生成 → 询问是否入库(入库技能可用时)。 */}
|
||||
{ingestAvailable && ingestStatus === "asking" && (
|
||||
<div className="shrink-0 border-t border-emerald-100 bg-emerald-50/60 px-3 py-2.5">
|
||||
<div className="mb-1.5 text-[11px] font-bold text-emerald-800">
|
||||
流程图已生成,是否进行数据入库?
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(
|
||||
[
|
||||
{ k: "now", t: "现在入库" },
|
||||
{ k: "later", t: "稍后入库" },
|
||||
{ k: "never", t: "不入库" },
|
||||
] as const
|
||||
).map((o) => (
|
||||
<button
|
||||
key={o.k}
|
||||
type="button"
|
||||
onClick={() => onResolveIngestChoice(o.k)}
|
||||
className="rounded-lg border border-emerald-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-emerald-800 transition hover:bg-emerald-100"
|
||||
>
|
||||
{o.t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 入库时尚无流程图 → 协助:数据入库以流程图为核心内容,先问是否生成流程图。 */}
|
||||
{ingestAvailable && pendingFlowForIngest && (
|
||||
<div className="shrink-0 border-t border-amber-100 bg-amber-50/70 px-3 py-2.5">
|
||||
<div className="mb-1.5 text-[11px] font-bold text-amber-800">
|
||||
当前还没有生成流程图,数据入库需要流程图。是否先生成流程图?
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(
|
||||
[
|
||||
{ k: "generate", t: "生成流程图" },
|
||||
{ k: "cancel", t: "暂不入库" },
|
||||
] as const
|
||||
).map((o) => (
|
||||
<button
|
||||
key={o.k}
|
||||
type="button"
|
||||
onClick={() => onResolveFlowForIngest(o.k)}
|
||||
className="rounded-lg border border-amber-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-amber-800 transition hover:bg-amber-100"
|
||||
>
|
||||
{o.t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 「稍后入库」自由文字意图模糊 → 让用户在入库语境下自己定。 */}
|
||||
{ingestAvailable && pendingIngestClarification && (
|
||||
<div className="shrink-0 border-t border-emerald-100 bg-emerald-50/60 px-3 py-2.5">
|
||||
<div className="mb-1.5 text-[11px] font-bold text-emerald-800">
|
||||
没太理解你的入库意思,你是想——
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(
|
||||
[
|
||||
{ k: "start_ingest", t: "开始入库" },
|
||||
{ k: "add_data", t: "补充内容后再入库" },
|
||||
{ k: "other", t: "只是提问 / 改报告" },
|
||||
] as const
|
||||
).map((o) => (
|
||||
<button
|
||||
key={o.k}
|
||||
type="button"
|
||||
onClick={() => onResolveIngestClarification(o.k)}
|
||||
className="rounded-lg border border-emerald-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-emerald-800 transition hover:bg-emerald-100"
|
||||
>
|
||||
{o.t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAskSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
|
||||
{/* 已选附件预览(发送时随指令上传到总结线程,供智能体读取) */}
|
||||
{askFiles.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{askFiles.map((file, idx) => (
|
||||
<span
|
||||
key={`${file.name}-${file.size}-${idx}`}
|
||||
className="inline-flex items-center gap-1.5 bg-white border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
|
||||
>
|
||||
<FileText className="w-3 h-3 shrink-0 text-primary" />
|
||||
<span className="truncate font-medium" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-slate-400">{formatBytes(file.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(idx)}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
|
||||
title="移除"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
|
||||
<AutoGrowTextarea
|
||||
maxHeight={224}
|
||||
value={askText}
|
||||
onChange={(e) => setAskText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder={
|
||||
busy
|
||||
? "生成中也可随时插话干预:发送将打断当前轮并据此继续…"
|
||||
: "继续对话:改报告(如「执行路线写细些」)/ 改流程图(如「总结成结构化输出」「流程图加上验收环节」)/ 提问"
|
||||
}
|
||||
className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none"
|
||||
/>
|
||||
|
||||
{/* 手动入库(入库技能可用、且已过流程图询问阶段时显示):直接在总结线程跑入库技能。 */}
|
||||
{ingestAvailable &&
|
||||
ingestStatus !== "asking" &&
|
||||
ingestStatus !== "running" &&
|
||||
ingestStatus !== "idle" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onManualIngest()}
|
||||
title="进行数据入库(由结构化输出智能体调用入库技能,过程见消息列表步骤条)"
|
||||
className="mb-0.5 flex h-8 shrink-0 items-center gap-1 rounded-xl border border-emerald-200 bg-emerald-50 px-2.5 text-[11px] font-bold text-emerald-700 transition hover:bg-emerald-100 active:scale-95"
|
||||
>
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
数据入库
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 上传文件(参考第一/二步:本地选取,发送时上传到总结线程) */}
|
||||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={pickFiles} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="上传参考文件(智能体将读取文件内容辅助总结/修改)"
|
||||
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white border border-slate-200 text-slate-500 hover:text-primary hover:bg-slate-50 transition active:scale-95"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* 暂停(仅生成中显示) */}
|
||||
{busy && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPause}
|
||||
title="暂停当前生成"
|
||||
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-amber-100 border border-amber-200/60 text-amber-700 hover:bg-amber-200 transition active:scale-95"
|
||||
>
|
||||
<Pause className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 发送(随时可发:生成中发送=打断并续问) */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={askText.trim().length === 0 && askFiles.length === 0}
|
||||
title={busy ? "打断当前轮并发送" : "发送(智能体自行判断改报告 or 回答)"}
|
||||
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="position-roundtable-intent-page eightbf-roundtable-step3-input shrink-0">
|
||||
<PositionIntentInput
|
||||
value={askText}
|
||||
onValueChange={setAskText}
|
||||
attachedFiles={askFiles}
|
||||
onAddFiles={addAskFiles}
|
||||
onRemoveFile={removeFile}
|
||||
charactersLeft={Math.max(0, 500 - askText.length)}
|
||||
isSending={busy}
|
||||
canSend={askText.trim().length > 0 || askFiles.length > 0}
|
||||
pendingClarification={false}
|
||||
onSend={submitAsk}
|
||||
onStop={onPause}
|
||||
onFollowUp={() => undefined}
|
||||
onSkip={() => undefined}
|
||||
hideUtilityActions
|
||||
onEnterRoundtable={() => undefined}
|
||||
enterButtonLabel=""
|
||||
intentReady={null}
|
||||
hideEnterRoundtable
|
||||
onManualSave={() => undefined}
|
||||
onTriggerToast={() => undefined}
|
||||
modelOptions={availableModels.map((model) => ({
|
||||
label: model.display_name && model.display_name !== model.name
|
||||
? `${model.display_name} (${model.name})`
|
||||
: model.name,
|
||||
value: model.name,
|
||||
}))}
|
||||
selectedModel={selectedModel}
|
||||
onSelectedModelChange={setSelectedModel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1106,11 +795,13 @@ function SeatFilesModal({
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
seatNameMap,
|
||||
taskArtifactSubmission,
|
||||
onClose,
|
||||
}: {
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
seatNameMap: Record<string, string>;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const seatEntries = useMemo(() => {
|
||||
@@ -1216,6 +907,25 @@ function SeatFilesModal({
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[10px] font-mono text-slate-400">{formatBytes(f.sizeBytes)}</span>
|
||||
<TaskArtifactSubmitButton
|
||||
compact
|
||||
controller={taskArtifactSubmission}
|
||||
target={taskArtifactSubmission?.targetFor({
|
||||
threadId: seat.threadId,
|
||||
path: f.path,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
agentId: seat.agentName,
|
||||
agentName: seat.displayName,
|
||||
}) ?? {
|
||||
threadId: seat.threadId,
|
||||
path: f.path,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
agentId: seat.agentName,
|
||||
agentName: seat.displayName,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePreview(seat.threadId, f)}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Check, Loader2, UploadCloud } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TaskArtifactSubmissionController, TaskArtifactTarget } from "@/roundtable-planning/lib/task-artifact-submission";
|
||||
|
||||
export function TaskArtifactSubmitButton({
|
||||
controller,
|
||||
target,
|
||||
compact = false,
|
||||
className,
|
||||
}: {
|
||||
controller?: TaskArtifactSubmissionController;
|
||||
target: TaskArtifactTarget;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!controller?.enabled || !controller.isMarkdown(target)) return null;
|
||||
const state = controller.stateFor(target);
|
||||
const error = controller.errorFor(target);
|
||||
const isSubmitted = state === "submitted";
|
||||
const isSubmitting = state === "submitting";
|
||||
const label = isSubmitted ? "已提交" : isSubmitting ? "提交中…" : "提交";
|
||||
const title = error || (isSubmitted ? "该 Markdown 产物已提交到任务过程" : "提交 Markdown 过程产物");
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSubmitting || isSubmitted}
|
||||
aria-label={`${target.name || target.path}:${label}`}
|
||||
title={title}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void controller.submit(target).catch(() => undefined);
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center gap-1 rounded-md border px-2 py-1 text-[10px] font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 disabled:cursor-not-allowed",
|
||||
isSubmitted
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-300"
|
||||
: error
|
||||
? "border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-300"
|
||||
: "border-primary/35 bg-primary/10 text-primary hover:bg-primary/15",
|
||||
// 沙箱头部的其他分段切换器外框为 40px 高,提交入口与之对齐。
|
||||
compact && "h-10 px-3 text-xs",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="size-3 animate-spin" /> : isSubmitted ? <Check className="size-3" /> : <UploadCloud className="size-3" />}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type IntentSummary,
|
||||
} from "@/roundtable-planning/api/intent";
|
||||
import { uploadFiles } from "@/core/uploads/api";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
import {
|
||||
clarificationKey,
|
||||
formatIntentClarificationAnswer,
|
||||
@@ -449,9 +450,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
|
||||
// Phase 4 单飞幂等键:每个回合一个稳定 id。后端据此对 (threadId, clientTurnId)
|
||||
// 跨 worker 单飞——重复 / 双击命中既有回合即幂等返回,杜绝并发下双发意图。
|
||||
const clientTurnId =
|
||||
(typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID()) ||
|
||||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
const clientTurnId = createClientId();
|
||||
|
||||
const res = await streamIntent({
|
||||
threadId: intentThreadId,
|
||||
|
||||
@@ -507,6 +507,14 @@ export function useStep2Orchestration(
|
||||
Record<string, StubAiMessage[]>
|
||||
>({});
|
||||
const seatStubMessagesRef = useRef<Record<string, StubAiMessage[]>>({});
|
||||
// write_file 的 tool args 会在一个 SSE response 里高频增长。ref 始终保留最新全文,
|
||||
// 供自动打开 / 最终收口等同步逻辑立即读取;但不要逐帧把整份 stub 写进 React state。
|
||||
// 后者会重建沙箱 ThreadContext 并让 ArtifactFileDetail 的预览 effect 连锁执行,
|
||||
// 在「继续会商 + 正在写文件」的高密度帧下曾触发 Maximum update depth exceeded。
|
||||
const pendingSeatStubMessagesRef = useRef<
|
||||
Record<string, StubAiMessage[]> | null
|
||||
>(null);
|
||||
const seatStubFlushTimerRef = useRef<number | null>(null);
|
||||
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(
|
||||
DEFAULT_SELECTED_AGENTS,
|
||||
);
|
||||
@@ -552,6 +560,11 @@ export function useStep2Orchestration(
|
||||
new Map(),
|
||||
);
|
||||
const parallelFlushTimerRef = useRef<number | null>(null);
|
||||
// 非并行的 leader / 单席位流同样不能逐 token 直接 setState。SSE reader 可能在一次
|
||||
// 浏览器任务里吐出大量已缓冲帧;逐帧 setState 会与消息区的 effect 连锁,最终触发
|
||||
// React 的 Maximum update depth 保护。先累积到 ref,再以固定节流频率提交到界面。
|
||||
const directStreamDeltaRef = useRef<Map<number, string>>(new Map());
|
||||
const directStreamFlushTimerRef = useRef<number | null>(null);
|
||||
// flush 时要读「当前选中席位」与「批次 bubbleId↔agentId 映射」—— 打到 ref 避免
|
||||
// 把 flush 函数绑进会频繁变化的 state 依赖。
|
||||
const selectedParallelSeatsRef = useRef<string[]>([]);
|
||||
@@ -684,6 +697,57 @@ export function useStep2Orchestration(
|
||||
return ids[agentName] ?? ids[agentName.toLowerCase()];
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 把写文件预览的 stub 以约 12fps 合并交给 React。
|
||||
*
|
||||
* `seatStubMessagesRef` 已在调用处同步更新,故这里的延迟只影响右侧 Markdown
|
||||
* 重绘频率,不影响随后读取最新 path/content、也不影响自动打开沙箱。
|
||||
*/
|
||||
const flushSeatStubMessages = useCallback(() => {
|
||||
seatStubFlushTimerRef.current = null;
|
||||
const pending = pendingSeatStubMessagesRef.current;
|
||||
if (!pending) return;
|
||||
pendingSeatStubMessagesRef.current = null;
|
||||
setSeatStubMessages((previous) => (previous === pending ? previous : pending));
|
||||
}, []);
|
||||
|
||||
const scheduleSeatStubMessagesFlush = useCallback(() => {
|
||||
if (seatStubFlushTimerRef.current !== null) return;
|
||||
seatStubFlushTimerRef.current = window.setTimeout(
|
||||
flushSeatStubMessages,
|
||||
80,
|
||||
);
|
||||
}, [flushSeatStubMessages]);
|
||||
|
||||
/** 清掉未提交的流式预览,并立即发布一个确定的快照(草稿恢复 / 重置时使用)。 */
|
||||
const replaceSeatStubMessages = useCallback(
|
||||
(next: Record<string, StubAiMessage[]>) => {
|
||||
pendingSeatStubMessagesRef.current = null;
|
||||
if (seatStubFlushTimerRef.current !== null) {
|
||||
window.clearTimeout(seatStubFlushTimerRef.current);
|
||||
seatStubFlushTimerRef.current = null;
|
||||
}
|
||||
seatStubMessagesRef.current = next;
|
||||
setSeatStubMessages((previous) => (previous === next ? previous : next));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const resetSeatStubMessages = useCallback(() => {
|
||||
replaceSeatStubMessages({});
|
||||
}, [replaceSeatStubMessages]);
|
||||
|
||||
// 卸载时清理挂起的预览 flush,避免已离开的页面仍收到一次 setState。
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (seatStubFlushTimerRef.current !== null) {
|
||||
window.clearTimeout(seatStubFlushTimerRef.current);
|
||||
seatStubFlushTimerRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 把工具调用幂等地写进 seat 的 stub ai message —— **同 (toolName, path)** 视为同一次
|
||||
* 调用,后续帧只更新 args(LangGraph 流式期间会多次发送完整 tool_calls 覆盖 args)。
|
||||
@@ -734,10 +798,11 @@ export function useStep2Orchestration(
|
||||
[seatThreadId]: [nextAiMsg],
|
||||
};
|
||||
seatStubMessagesRef.current = nextMap;
|
||||
setSeatStubMessages(nextMap);
|
||||
pendingSeatStubMessagesRef.current = nextMap;
|
||||
scheduleSeatStubMessagesFlush();
|
||||
return { messageId: nextAiMsg.id, toolCallId };
|
||||
},
|
||||
[],
|
||||
[scheduleSeatStubMessagesFlush],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -1026,12 +1091,6 @@ export function useStep2Orchestration(
|
||||
args,
|
||||
);
|
||||
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
|
||||
if (typeof window !== "undefined") {
|
||||
console.debug(
|
||||
"[roundtable-sandbox] auto-open from phase",
|
||||
{ toolName, path, seatThreadId, virtualUrl },
|
||||
);
|
||||
}
|
||||
openSeatVirtualArtifact(seatThreadId, virtualUrl, true);
|
||||
},
|
||||
[
|
||||
@@ -1272,9 +1331,15 @@ export function useStep2Orchestration(
|
||||
|
||||
const setDialogueConfidence = useCallback(
|
||||
(id: number, confidence: string) => {
|
||||
setStep2RoundtableDialogues((prev) =>
|
||||
prev.map((d) => (d.id === id ? { ...d, confidence } : d)),
|
||||
);
|
||||
setStep2RoundtableDialogues((prev) => {
|
||||
const index = prev.findIndex((dialogue) => dialogue.id === id);
|
||||
// onPhaseChange 可在一个 SSE read 内收到多帧相同的 phase。状态文字没有变化
|
||||
// 时必须返回原数组;否则 React 会把无意义的新数组当成一次嵌套更新。
|
||||
if (index < 0 || prev[index].confidence === confidence) return prev;
|
||||
const next = [...prev];
|
||||
next[index] = { ...prev[index], confidence };
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -1286,30 +1351,39 @@ export function useStep2Orchestration(
|
||||
key: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => {
|
||||
setStep2RoundtableDialogues((prev) =>
|
||||
prev.map((d) => {
|
||||
if (d.id !== bubbleId) return d;
|
||||
const steps: Step2Dialogue["steps"] = d.steps ?? [];
|
||||
const last = steps[steps.length - 1];
|
||||
if (last && last.key === key) {
|
||||
// 同 phase:tool 重复回调 —— 流式累积期 args 会持续长大(present_files
|
||||
// 多文件分批、write_file 内容增长),拿最新 args 覆盖,不要早早定死。
|
||||
if (args && args !== last.args) {
|
||||
const next = [...steps];
|
||||
next[next.length - 1] = { ...last, args };
|
||||
return { ...d, steps: next };
|
||||
}
|
||||
return d;
|
||||
}
|
||||
return {
|
||||
...d,
|
||||
setStep2RoundtableDialogues((prev) => {
|
||||
const dialogueIndex = prev.findIndex((dialogue) => dialogue.id === bubbleId);
|
||||
if (dialogueIndex < 0) return prev;
|
||||
|
||||
const dialogue = prev[dialogueIndex];
|
||||
const steps: Step2Dialogue["steps"] = dialogue.steps ?? [];
|
||||
const last = steps[steps.length - 1];
|
||||
let nextDialogue: Step2Dialogue;
|
||||
|
||||
if (last && last.key === key) {
|
||||
// 同一个工具调用在流式期间会反复带来增量入参。步骤卡只需在「首次拿到
|
||||
// 可见参数」时补一次;文件正文由 upstream frame 同步给沙箱,不能随每个
|
||||
// content chunk 更新对话 state。
|
||||
const hasVisibleArgs = (value?: Record<string, unknown>) =>
|
||||
!!value && Object.keys(value).some((argKey) => argKey !== "content");
|
||||
if (hasVisibleArgs(last.args) || !hasVisibleArgs(args)) return prev;
|
||||
const nextSteps = [...steps];
|
||||
nextSteps[nextSteps.length - 1] = { ...last, args };
|
||||
nextDialogue = { ...dialogue, steps: nextSteps };
|
||||
} else {
|
||||
nextDialogue = {
|
||||
...dialogue,
|
||||
steps: [
|
||||
...steps,
|
||||
{ id: `${bubbleId}-${steps.length}`, key, label, ts: nowTime(), args },
|
||||
],
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const next = [...prev];
|
||||
next[dialogueIndex] = nextDialogue;
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -1342,20 +1416,45 @@ export function useStep2Orchestration(
|
||||
[],
|
||||
);
|
||||
|
||||
/** 将非并行流的已积累增量一次性写入 React state(约 20fps)。 */
|
||||
const flushDirectStreamDeltas = useCallback(() => {
|
||||
directStreamFlushTimerRef.current = null;
|
||||
const pending = directStreamDeltaRef.current;
|
||||
if (pending.size === 0) return;
|
||||
directStreamDeltaRef.current = new Map();
|
||||
setStep2RoundtableDialogues((prev) => {
|
||||
let changed = false;
|
||||
const next = prev.map((dialogue) => {
|
||||
const delta = pending.get(dialogue.id);
|
||||
if (!delta) return dialogue;
|
||||
changed = true;
|
||||
return { ...dialogue, content: (dialogue.content ?? "") + delta };
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const scheduleDirectStreamFlush = useCallback(() => {
|
||||
if (directStreamFlushTimerRef.current !== null) return;
|
||||
directStreamFlushTimerRef.current = window.setTimeout(flushDirectStreamDeltas, 50);
|
||||
}, [flushDirectStreamDeltas]);
|
||||
|
||||
const appendToDialogue = useCallback((id: number, deltaText: string) => {
|
||||
if (!deltaText) return;
|
||||
setStep2RoundtableDialogues((prev) =>
|
||||
prev.map((d) =>
|
||||
d.id === id ? { ...d, content: (d.content ?? "") + deltaText } : d,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
const pending = directStreamDeltaRef.current;
|
||||
pending.set(id, (pending.get(id) ?? "") + deltaText);
|
||||
scheduleDirectStreamFlush();
|
||||
}, [scheduleDirectStreamFlush]);
|
||||
|
||||
/** 整段替换某气泡正文(§6 flush:把 ref 里累积的全文一次性挂上去)。 */
|
||||
const setDialogueContent = useCallback((id: number, fullText: string) => {
|
||||
setStep2RoundtableDialogues((prev) =>
|
||||
prev.map((d) => (d.id === id ? { ...d, content: fullText } : d)),
|
||||
);
|
||||
setStep2RoundtableDialogues((prev) => {
|
||||
const index = prev.findIndex((dialogue) => dialogue.id === id);
|
||||
if (index < 0 || prev[index].content === fullText) return prev;
|
||||
const next = [...prev];
|
||||
next[index] = { ...prev[index], content: fullText };
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@@ -1372,6 +1471,7 @@ export function useStep2Orchestration(
|
||||
const who = info.agentName || "智能体";
|
||||
triggerToast(`「${info.failedModel}」模型${info.reason},已自动切换「${info.nextModel}」继续`);
|
||||
if (isParallel) parallelStreamRef.current.delete(bubbleId);
|
||||
directStreamDeltaRef.current.delete(bubbleId);
|
||||
const systemBubble: Step2Dialogue = {
|
||||
id: Date.now() + Math.random(),
|
||||
sender: "系统提示",
|
||||
@@ -1440,8 +1540,17 @@ export function useStep2Orchestration(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetDirectStreamDeltas = useCallback(() => {
|
||||
directStreamDeltaRef.current = new Map();
|
||||
if (directStreamFlushTimerRef.current !== null) {
|
||||
window.clearTimeout(directStreamFlushTimerRef.current);
|
||||
directStreamFlushTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 卸载兜底:清掉可能挂起的 flush 定时器。
|
||||
useEffect(() => resetParallelStreams, [resetParallelStreams]);
|
||||
useEffect(() => resetDirectStreamDeltas, [resetDirectStreamDeltas]);
|
||||
|
||||
// 选中变化 → 立刻把 ref 里已累积的全文挂给**新选中**的席位(切头像即显全部输出,
|
||||
// 证明流没丢、只是之前没渲染,§6.5)。
|
||||
@@ -1508,21 +1617,40 @@ export function useStep2Orchestration(
|
||||
|
||||
const finalizeDialogue = useCallback(
|
||||
(id: number, finalContent: string, confidence?: string) => {
|
||||
setStep2RoundtableDialogues((prev) =>
|
||||
prev.map((d) => {
|
||||
// 最终结果已经是完整文本,丢弃尚未 flush 的 token 缓冲,避免收尾后旧增量反写。
|
||||
directStreamDeltaRef.current.delete(id);
|
||||
if (
|
||||
directStreamDeltaRef.current.size === 0 &&
|
||||
directStreamFlushTimerRef.current !== null
|
||||
) {
|
||||
window.clearTimeout(directStreamFlushTimerRef.current);
|
||||
directStreamFlushTimerRef.current = null;
|
||||
}
|
||||
setStep2RoundtableDialogues((prev) => {
|
||||
let changed = false;
|
||||
const next = prev.map((d) => {
|
||||
if (d.id !== id) return d;
|
||||
const next =
|
||||
finalContent && finalContent.trim().length > 0
|
||||
? finalContent
|
||||
: d.content || "(无文本输出)";
|
||||
if (
|
||||
d.content === next &&
|
||||
d.confidence === (confidence ?? d.confidence) &&
|
||||
d.streaming === false
|
||||
) {
|
||||
return d;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...d,
|
||||
content: next,
|
||||
confidence: confidence ?? d.confidence,
|
||||
streaming: false,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -2552,7 +2680,7 @@ export function useStep2Orchestration(
|
||||
seatSkillDirective: seatSkillDirectivesRef.current[agentId] ?? false,
|
||||
signal: subAborter.signal,
|
||||
// §6:并行席位走 ref 全量累积 + 节流 flush(只渲染选中的);单节点
|
||||
// stage 实际串行,1 路流直接 setState 不卡,沿用 appendToDialogue。
|
||||
// stage 也经 appendToDialogue 的节流缓冲,避免高频 SSE 帧嵌套更新。
|
||||
onModelSwitch: makeModelSwitchHandler(bubbleId, isParallel),
|
||||
onTextDelta: (text) =>
|
||||
isParallel ? ingestParallelDelta(bubbleId, text) : appendToDialogue(bubbleId, text),
|
||||
@@ -3182,6 +3310,7 @@ export function useStep2Orchestration(
|
||||
// 需要 ref 立刻可读到作业的真实线程,否则读到旧值导致接管失败。
|
||||
threadIdsRef.current = snapshot.threadIds;
|
||||
coordinatorNameRef.current = snapshot.coordinatorName;
|
||||
resetDirectStreamDeltas();
|
||||
// 历史快照是**静止记录**:任何残留「流式/加载中」的气泡(用户中途点了「停止」,
|
||||
// 或草稿在流式期间被自动保存)都强制收尾为静止态。否则用户点聊天记录回看时,这些
|
||||
// 气泡仍显示「调度中 / 等待响应 / 正在输出…」+ 加载光标,让人误以为还在进行(其实早已结束)。
|
||||
@@ -3203,15 +3332,14 @@ export function useStep2Orchestration(
|
||||
setConsensusPercentage(snapshot.consensusPercentage);
|
||||
setBudgetLimit(snapshot.budgetLimit);
|
||||
const restoredStubs = snapshot.seatStubMessages ?? {};
|
||||
seatStubMessagesRef.current = restoredStubs;
|
||||
setSeatStubMessages(restoredStubs);
|
||||
replaceSeatStubMessages(restoredStubs);
|
||||
// 草稿里历史 write_file 的虚拟 URL 与当前会话无关 —— 清空守卫,允许新 run 触发自动打开。
|
||||
hasAutoOpenedRef.current = new Set();
|
||||
// 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上,
|
||||
// 调用方需要的话再 resetInitGate()。
|
||||
step2InitRef.current = true;
|
||||
},
|
||||
[],
|
||||
[replaceSeatStubMessages, resetDirectStreamDeltas],
|
||||
);
|
||||
|
||||
// 「查看=恢复流程」:后台作业 SSE 帧 → 第二步实时画面(只更新展示数据,不动
|
||||
@@ -3225,13 +3353,12 @@ export function useStep2Orchestration(
|
||||
lastLeaderContent: string;
|
||||
}) => {
|
||||
setStep2RoundtableDialogues(frame.dialogues);
|
||||
seatStubMessagesRef.current = frame.seatStubMessages;
|
||||
setSeatStubMessages(frame.seatStubMessages);
|
||||
replaceSeatStubMessages(frame.seatStubMessages);
|
||||
setConsensusPercentage(frame.consensusPercentage);
|
||||
setHasConsensus(frame.hasConsensus);
|
||||
if (frame.lastLeaderContent) setLastLeaderContent(frame.lastLeaderContent);
|
||||
},
|
||||
[],
|
||||
[replaceSeatStubMessages],
|
||||
);
|
||||
|
||||
// 「后台挂起接管」:取消后端作业 + hydrateFromDraft(恢复作业真实线程/协调名/对话)后,
|
||||
@@ -3279,11 +3406,11 @@ export function useStep2Orchestration(
|
||||
setSelectedParallelSeats([]);
|
||||
setDagNodeStates({});
|
||||
resetParallelStreams();
|
||||
resetDirectStreamDeltas();
|
||||
setActiveArtifactThreadId(null);
|
||||
artifactPathsRef.current = [];
|
||||
setArtifacts([]);
|
||||
seatStubMessagesRef.current = {};
|
||||
setSeatStubMessages({});
|
||||
resetSeatStubMessages();
|
||||
hasAutoOpenedRef.current = new Set();
|
||||
pendingInjectPromptRef.current = null;
|
||||
dagPlanExecutedRef.current = false;
|
||||
@@ -3292,7 +3419,14 @@ export function useStep2Orchestration(
|
||||
gatheredDeliveriesRef.current = [];
|
||||
deselectArtifact();
|
||||
step2InitRef.current = false;
|
||||
}, [abortAllInFlight, deselectArtifact, setArtifacts, resetParallelStreams]);
|
||||
}, [
|
||||
abortAllInFlight,
|
||||
deselectArtifact,
|
||||
setArtifacts,
|
||||
resetParallelStreams,
|
||||
resetDirectStreamDeltas,
|
||||
resetSeatStubMessages,
|
||||
]);
|
||||
|
||||
return {
|
||||
selectedAgents,
|
||||
|
||||
@@ -16,16 +16,13 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { uploadFiles } from "@/core/uploads/api";
|
||||
import type {
|
||||
DraftStep3SummarySnapshot,
|
||||
} from "@/roundtable-planning/api/drafts";
|
||||
import {
|
||||
buildArtifactDownloadUrl,
|
||||
initStructureThread,
|
||||
initSummaryThread,
|
||||
listSessionArtifacts,
|
||||
streamMultiAgent,
|
||||
type StreamMultiAgentRequest,
|
||||
type StreamPhase,
|
||||
@@ -78,6 +75,8 @@ interface UseStep3SummaryOptions {
|
||||
* 普通手动会商为 false —— 结构化输出仍按需(用户输入框打「流程图」才出)。
|
||||
*/
|
||||
autoFlow?: boolean;
|
||||
/** 第三步是否允许流程图分支。当前报告工作台只交付 Markdown,默认关闭。 */
|
||||
flowEnabled?: boolean;
|
||||
/** 草稿里已存的上次总结结果 —— 有则进入时恢复展示,不重新生成。 */
|
||||
savedSummary?: DraftStep3SummarySnapshot | null;
|
||||
/** 一次总结成功完成时回调(传出 md/thread/model/时间/收尾语),供上层存草稿。 */
|
||||
@@ -485,6 +484,7 @@ export function useStep3Summary(
|
||||
coordinatorPrompt,
|
||||
chainLevels,
|
||||
autoFlow,
|
||||
flowEnabled = false,
|
||||
savedSummary,
|
||||
onSummarySaved,
|
||||
ingestAvailable,
|
||||
@@ -573,6 +573,10 @@ export function useStep3Summary(
|
||||
>(null);
|
||||
|
||||
const stubRef = useRef<Record<string, StubAiMessage[]>>({});
|
||||
// write_file 会在流式过程中多次带回完整 tool_calls。stub 保持同步更新供预览读取,
|
||||
// React 状态则节流发布,避免每一个工具帧都触发沙箱和消息列表的同步重渲染。
|
||||
const pendingStubMessagesRef = useRef<Record<string, StubAiMessage[]> | null>(null);
|
||||
const stubFlushTimerRef = useRef<number | null>(null);
|
||||
const runningRef = useRef(false);
|
||||
const aborterRef = useRef<AbortController | null>(null);
|
||||
// 真线程(init 后或恢复时已知的后端 thread id)—— ask() 续问时复用它,保留报告上下文。
|
||||
@@ -589,6 +593,47 @@ export function useStep3Summary(
|
||||
// run() 的「自动出图」与用户「跳过报告直接生成流程图」的手动出图竞态,各自建卡片 → 双「调度中」。
|
||||
const flowAborterRef = useRef<AbortController | null>(null);
|
||||
const activeFlowBubbleRef = useRef<number | null>(null);
|
||||
const autoArtifactTimerRef = useRef<number | null>(null);
|
||||
|
||||
/** 将写文件预览以约 12fps 合并发布给 React,读取 stub 的流逻辑始终使用最新 ref。 */
|
||||
const flushStubMessages = useCallback(() => {
|
||||
stubFlushTimerRef.current = null;
|
||||
const pending = pendingStubMessagesRef.current;
|
||||
if (!pending) return;
|
||||
pendingStubMessagesRef.current = null;
|
||||
setSeatStubMessages((previous) => (previous === pending ? previous : pending));
|
||||
}, []);
|
||||
|
||||
const scheduleStubMessagesFlush = useCallback(() => {
|
||||
if (stubFlushTimerRef.current !== null) return;
|
||||
stubFlushTimerRef.current = window.setTimeout(flushStubMessages, 80);
|
||||
}, [flushStubMessages]);
|
||||
|
||||
/** 重置/恢复草稿时立即发布确定快照,同时取消未提交的流式预览。 */
|
||||
const replaceStubMessages = useCallback(
|
||||
(next: Record<string, StubAiMessage[]>) => {
|
||||
pendingStubMessagesRef.current = null;
|
||||
if (stubFlushTimerRef.current !== null) {
|
||||
window.clearTimeout(stubFlushTimerRef.current);
|
||||
stubFlushTimerRef.current = null;
|
||||
}
|
||||
stubRef.current = next;
|
||||
setSeatStubMessages((previous) => (previous === next ? previous : next));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (stubFlushTimerRef.current !== null) {
|
||||
window.clearTimeout(stubFlushTimerRef.current);
|
||||
}
|
||||
if (autoArtifactTimerRef.current !== null) {
|
||||
window.clearTimeout(autoArtifactTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ── 对话气泡三段式(append → delta → finalize)+ 工具步骤(与第 2 步同款)──
|
||||
const appendStreamingDialogue = useCallback(
|
||||
@@ -683,12 +728,15 @@ export function useStep3Summary(
|
||||
const steps: Step2Dialogue["steps"] = d.steps ?? [];
|
||||
const last = steps[steps.length - 1];
|
||||
if (last && last.key === key) {
|
||||
if (args && args !== last.args) {
|
||||
const next = [...steps];
|
||||
next[next.length - 1] = { ...last, args };
|
||||
return { ...d, steps: next };
|
||||
}
|
||||
return d;
|
||||
// write_file 的流式工具帧会反复带着新的 content 对象。步骤卡只需要
|
||||
// 首次拿到可见参数(如 path)时补充一次,正文已经由 stub 节流同步给沙箱。
|
||||
// 不能为每个 content chunk 更新对话 state,否则会造成嵌套更新风暴。
|
||||
const hasVisibleArgs = (value?: Record<string, unknown>) =>
|
||||
!!value && Object.keys(value).some((argKey) => argKey !== "content");
|
||||
if (hasVisibleArgs(last.args) || !hasVisibleArgs(args)) return d;
|
||||
const next = [...steps];
|
||||
next[next.length - 1] = { ...last, args };
|
||||
return { ...d, steps: next };
|
||||
}
|
||||
return {
|
||||
...d,
|
||||
@@ -762,14 +810,15 @@ export function useStep3Summary(
|
||||
const nextMsg: StubAiMessage = { ...base, tool_calls: nextToolCalls };
|
||||
const nextMap = { ...stubRef.current, [threadId]: [nextMsg] };
|
||||
stubRef.current = nextMap;
|
||||
setSeatStubMessages(nextMap);
|
||||
pendingStubMessagesRef.current = nextMap;
|
||||
scheduleStubMessagesFlush();
|
||||
// 记住当前报告全文(用于续问写底 / 存草稿)。
|
||||
if (typeof storeArgs.content === "string" && storeArgs.content.trim()) {
|
||||
currentMdRef.current = storeArgs.content;
|
||||
}
|
||||
return { messageId: nextMsg.id, toolCallId };
|
||||
},
|
||||
[],
|
||||
[scheduleStubMessagesFlush],
|
||||
);
|
||||
|
||||
const buildWriteFileVirtualUrl = useCallback(
|
||||
@@ -816,14 +865,36 @@ export function useStep3Summary(
|
||||
|
||||
const autoOpenArtifact = useCallback(
|
||||
(threadId: string, virtualUrl: string) => {
|
||||
lastAutoThreadRef.current = threadId;
|
||||
if (!previewEnabledRef.current) {
|
||||
// 视图不在前台:只记录,不抢沙箱(回到视图时 setPreviewEnabled 再接管)。
|
||||
lastAutoOpenedUrlRef.current = virtualUrl;
|
||||
lastAutoThreadRef.current = threadId;
|
||||
return;
|
||||
}
|
||||
if (lastAutoOpenedUrlRef.current === virtualUrl) return;
|
||||
openArtifact(threadId, virtualUrl);
|
||||
if (
|
||||
lastAutoOpenedUrlRef.current === virtualUrl &&
|
||||
lastAutoThreadRef.current === threadId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// onPhaseChange 在 SSE 回调栈中触发。延后一帧再打开沙箱,避免工具帧与
|
||||
// ArtifactProvider/Markdown 预览的状态更新嵌套在同一次 React 更新里。
|
||||
lastAutoOpenedUrlRef.current = virtualUrl;
|
||||
lastAutoThreadRef.current = threadId;
|
||||
if (autoArtifactTimerRef.current !== null) {
|
||||
window.clearTimeout(autoArtifactTimerRef.current);
|
||||
}
|
||||
autoArtifactTimerRef.current = window.setTimeout(() => {
|
||||
autoArtifactTimerRef.current = null;
|
||||
if (
|
||||
!previewEnabledRef.current ||
|
||||
lastAutoOpenedUrlRef.current !== virtualUrl ||
|
||||
lastAutoThreadRef.current !== threadId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
openArtifact(threadId, virtualUrl);
|
||||
}, 80);
|
||||
},
|
||||
[openArtifact],
|
||||
);
|
||||
@@ -953,61 +1024,24 @@ export function useStep3Summary(
|
||||
[upsertStub, buildWriteFileVirtualUrl, openArtifact, openArtifactPreview],
|
||||
);
|
||||
|
||||
const openRealArtifactFallback = useCallback(
|
||||
async (threadId: string) => {
|
||||
if (!previewEnabledRef.current) return; // 不在前台不抢沙箱
|
||||
try {
|
||||
const res = await listSessionArtifacts(threadId);
|
||||
const file = res.files.find((f) => /\.md$/i.test(f.name)) ?? res.files[0];
|
||||
if (!file) return;
|
||||
setActiveArtifactThreadId(threadId);
|
||||
setArtifacts([file.path]);
|
||||
selectArtifact(file.path, false);
|
||||
setArtifactsOpen(true);
|
||||
} catch {
|
||||
/* 列文件失败就保持当前状态 */
|
||||
}
|
||||
},
|
||||
[setArtifacts, selectArtifact, setArtifactsOpen],
|
||||
);
|
||||
|
||||
// 收尾对账:以沙箱真实 md 文件为准回灌 stub,拿到完整 Markdown。
|
||||
// `force` 用于 ask():增量改后文件可能等长/更短,长度守卫会挡掉改动,故强制以真实文件为准。
|
||||
// 收尾时直接使用流式 write_file 捕获的 Markdown。当前任务沙箱的文件列表与下载
|
||||
// 端点不是同一套存储:列表返回的 /mnt/... 路径会被预览器拿去请求 /artifacts/mnt/...
|
||||
// 并稳定返回 404。此处若再尝试“真实文件对账”,既拿不到更完整的内容,还会触发
|
||||
// 预览器的失败重试和状态更新循环。write_file 的 stub 在每个上游帧同步更新,因此它
|
||||
// 是本步骤报告的唯一预览/保存来源。
|
||||
const reconcileStubFromRealFile = useCallback(
|
||||
async (
|
||||
threadId: string,
|
||||
opts?: { signal?: AbortSignal; force?: boolean },
|
||||
_opts?: { signal?: AbortSignal; force?: boolean },
|
||||
): Promise<string> => {
|
||||
const writeTc = stubRef.current[threadId]?.[0]?.tool_calls.find(
|
||||
(tc) => tc.name === "write_file",
|
||||
);
|
||||
const stubPath =
|
||||
typeof (writeTc?.args as { path?: unknown })?.path === "string"
|
||||
? ((writeTc!.args as { path?: string }).path as string)
|
||||
: SUMMARY_OUTPUT_PATH;
|
||||
const stubContent =
|
||||
typeof (writeTc?.args as { content?: unknown })?.content === "string"
|
||||
? ((writeTc!.args as { content?: string }).content as string)
|
||||
: "";
|
||||
try {
|
||||
const list = await listSessionArtifacts(threadId, { signal: opts?.signal });
|
||||
const mdFile = list.files.find((f) => /\.md$/i.test(f.name)) ?? null;
|
||||
if (!mdFile) return stubContent;
|
||||
const res = await apiFetch(buildArtifactDownloadUrl(threadId, mdFile.path), {
|
||||
signal: opts?.signal,
|
||||
});
|
||||
if (!res.ok) return stubContent;
|
||||
const real = await res.text();
|
||||
if (real.trim() && (opts?.force || real.length > stubContent.length)) {
|
||||
upsertStub(threadId, "write_file", { path: stubPath, content: real });
|
||||
return real;
|
||||
}
|
||||
} catch {
|
||||
/* 拉取失败退回 stub 内容 */
|
||||
}
|
||||
return stubContent;
|
||||
return typeof (writeTc?.args as { content?: unknown })?.content === "string"
|
||||
? ((writeTc!.args as { content?: string }).content as string)
|
||||
: "";
|
||||
},
|
||||
[upsertStub],
|
||||
[],
|
||||
);
|
||||
|
||||
const abort = useCallback(() => {
|
||||
@@ -1112,11 +1146,8 @@ export function useStep3Summary(
|
||||
});
|
||||
if (aborter.signal.aborted) return;
|
||||
|
||||
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0;
|
||||
// 流程图轮本就不产文件,不要去沙箱兜底翻文件(也避免把别的文件弹出来)。
|
||||
if (!hasStubFile && !seedThreadId && !flowRound) await openRealArtifactFallback(threadId);
|
||||
|
||||
// 以真实 md 回灌 stub,拿到完整报告(ask 强制对账,防增量改后被长度守卫挡掉)。
|
||||
// 以流式 write_file stub 收尾。不要把沙箱文件列表返回的真实路径塞给全局预览:
|
||||
// 该路径在 artifacts 下载端点不存在,会导致 404 与预览循环。
|
||||
const md = await reconcileStubFromRealFile(threadId, {
|
||||
signal: aborter.signal,
|
||||
force: forceReconcile,
|
||||
@@ -1155,7 +1186,6 @@ export function useStep3Summary(
|
||||
appendStepIfNew,
|
||||
handleArtifactToolPhase,
|
||||
handleUpstreamFrame,
|
||||
openRealArtifactFallback,
|
||||
reconcileStubFromRealFile,
|
||||
finalizeDialogue,
|
||||
seedStubWithCurrentMd,
|
||||
@@ -1301,6 +1331,9 @@ export function useStep3Summary(
|
||||
|
||||
/** 仅重新生成流程图(不重跑 md 报告):模型挂了切模型后重试 / 手动重画。 */
|
||||
const regenerateFlow = useCallback(() => {
|
||||
// 第三步报告工作台已关闭流程图交付。历史草稿/旧按钮即使仍持有这个回调,
|
||||
// 也不能在总结收尾后意外启动结构化抽取。
|
||||
if (!flowEnabled) return;
|
||||
if (!currentMdRef.current.trim() && seatDeliveries.length === 0) return;
|
||||
aborterRef.current?.abort();
|
||||
// 重画流程图 = 重新分析 → 先复位入库状态(隐藏旧图的入库询问/按钮),新图生成完会重新询问。
|
||||
@@ -1308,7 +1341,7 @@ export function useStep3Summary(
|
||||
const aborter = new AbortController();
|
||||
aborterRef.current = aborter;
|
||||
void runFlowExtract(aborter);
|
||||
}, [runFlowExtract, seatDeliveries, resetIngestState]);
|
||||
}, [flowEnabled, runFlowExtract, seatDeliveries, resetIngestState]);
|
||||
|
||||
/** Phase 3:画布手动改/删节点后回写图 + 持久化(序列化进草稿快照)。 */
|
||||
const updateFlowGraph = useCallback(
|
||||
@@ -1335,10 +1368,10 @@ export function useStep3Summary(
|
||||
setPhase("creating");
|
||||
setDialogues([]);
|
||||
lastAutoOpenedUrlRef.current = null;
|
||||
stubRef.current = {};
|
||||
lastAutoThreadRef.current = null;
|
||||
replaceStubMessages({});
|
||||
currentMdRef.current = "";
|
||||
liveThreadRef.current = null;
|
||||
setSeatStubMessages({});
|
||||
setFlowGraphState(null);
|
||||
setFlowPhase("idle");
|
||||
// 清空对话同时复位流程图串行化句柄(旧气泡已随 setDialogues([]) 移除,不能再被复用)。
|
||||
@@ -1359,7 +1392,7 @@ export function useStep3Summary(
|
||||
try {
|
||||
// 编排提示里明确要求了结构化输出/流程图 → 首轮就走流程图模式(不写 md,直出 flow-json)。
|
||||
// 但 autoFlow(taskId/深链)模式要「先 md 再结构化输出」,故首轮强制走 md(忽略 detectFlowIntent)。
|
||||
const flowMode = !autoFlow && detectFlowIntent(coordinatorPrompt);
|
||||
const flowMode = flowEnabled && !autoFlow && detectFlowIntent(coordinatorPrompt);
|
||||
flowActiveRef.current = flowMode; // 流式结束后会按真实产物校正
|
||||
const prompt = buildSummaryPrompt({
|
||||
intentReady,
|
||||
@@ -1381,6 +1414,7 @@ export function useStep3Summary(
|
||||
// ask() 会先 abort() 本轮(aborterRef 被置空/换成新 aborter)→ 这里不再自动出图,
|
||||
// 由 ask 的手动出图独占,避免 auto + manual 双卡片。
|
||||
if (
|
||||
flowEnabled &&
|
||||
autoFlow &&
|
||||
!aborter.signal.aborted &&
|
||||
aborterRef.current === aborter &&
|
||||
@@ -1404,11 +1438,13 @@ export function useStep3Summary(
|
||||
consensusText,
|
||||
coordinatorPrompt,
|
||||
autoFlow,
|
||||
flowEnabled,
|
||||
appendStreamingDialogue,
|
||||
finalizeDialogue,
|
||||
streamOnce,
|
||||
runFlowExtract,
|
||||
resetIngestState,
|
||||
replaceStubMessages,
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -1566,6 +1602,12 @@ export function useStep3Summary(
|
||||
(choice: "generate" | "cancel") => {
|
||||
setPendingFlowForIngest(false);
|
||||
if (choice === "generate") {
|
||||
if (!flowEnabled) {
|
||||
// 老草稿可能遗留「先生成流程图再入库」的协助卡;报告模式不再提供这条链路。
|
||||
setIngestStatus("later");
|
||||
persistSummary();
|
||||
return;
|
||||
}
|
||||
// 重置为 idle —— 流程图生成完成后 runFlowExtract 在 idle 时会自动询问入库。
|
||||
setIngestStatus("idle");
|
||||
regenerateFlow();
|
||||
@@ -1660,6 +1702,11 @@ export function useStep3Summary(
|
||||
await runIngest();
|
||||
return;
|
||||
}
|
||||
// 当前第三步只维护 Markdown 报告。即使意图分类命中旧的流程图关键词,
|
||||
// 也按“重写报告”处理,绝不启动流程图抽取请求。
|
||||
if (!flowEnabled && (intent === "regenerate_flow" || intent === "modify_flow")) {
|
||||
intent = "regenerate_report";
|
||||
}
|
||||
if (intent === "regenerate_flow" || intent === "modify_flow") {
|
||||
// 重新/修改流程图 = 重新分析 → 先复位入库状态(隐藏旧图入库询问/按钮),
|
||||
// runFlowExtract 出新图后会在 idle 时重新询问是否入库。
|
||||
@@ -1809,6 +1856,7 @@ export function useStep3Summary(
|
||||
runFlowExtract,
|
||||
runIngest,
|
||||
resetIngestState,
|
||||
flowEnabled,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1902,7 +1950,7 @@ export function useStep3Summary(
|
||||
// 合并:合成 stub 保住「恢复后预览」锚点,历史 stub 保住各气泡步骤卡的真线程预览。
|
||||
const effectiveStub =
|
||||
savedStubs && Object.keys(savedStubs).length ? { ...stub, ...savedStubs } : stub;
|
||||
stubRef.current = effectiveStub;
|
||||
replaceStubMessages(effectiveStub);
|
||||
currentMdRef.current = snap.md;
|
||||
liveThreadRef.current = realThreadId;
|
||||
// 快照收尾语带 flow-json → 上次主产物是流程图,恢复后续问沿用流程图语境。
|
||||
@@ -1919,7 +1967,6 @@ export function useStep3Summary(
|
||||
lastAutoOpenedUrlRef.current = null;
|
||||
lastAutoThreadRef.current = null;
|
||||
}
|
||||
setSeatStubMessages(effectiveStub);
|
||||
setSummaryThreadId(synthThreadId);
|
||||
setActiveArtifactThreadId(synthThreadId);
|
||||
|
||||
@@ -1962,7 +2009,7 @@ export function useStep3Summary(
|
||||
// 写文件(live write_file 流式)时才自动打开;恢复历史时用户点气泡里的
|
||||
// 「点击预览」步骤卡随时可开(stub 已种好)。
|
||||
},
|
||||
[abort, buildWriteFileVirtualUrl],
|
||||
[abort, buildWriteFileVirtualUrl, replaceStubMessages],
|
||||
);
|
||||
|
||||
const busy = phase === "creating" || phase === "streaming";
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import {
|
||||
listRoundtableArtifactSubmissions,
|
||||
saveRoundtableArtifactSubmission,
|
||||
type RoundtableArtifactReference,
|
||||
type RoundtableArtifactSubmissionRecord,
|
||||
} from "@/roundtable-planning/api/artifact-submissions";
|
||||
import { buildArtifactDownloadUrl, type ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||
import type {
|
||||
TaskArtifactSubmissionConfig,
|
||||
TaskArtifactSubmissionController,
|
||||
TaskArtifactSubmissionState,
|
||||
TaskArtifactTarget,
|
||||
TaskProcessReportPayload,
|
||||
} from "@/roundtable-planning/lib/task-artifact-submission";
|
||||
|
||||
type StatusEntry = {
|
||||
state: TaskArtifactSubmissionState;
|
||||
error: string | null;
|
||||
record?: RoundtableArtifactSubmissionRecord;
|
||||
};
|
||||
|
||||
function normalizedPath(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed.startsWith("write-file:")) return trimmed;
|
||||
try {
|
||||
return decodeURIComponent(new URL(trimmed).pathname).trim();
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function fileName(path: string): string {
|
||||
const normalized = normalizedPath(path);
|
||||
return normalized.split("/").filter(Boolean).pop() || normalized || "产物.md";
|
||||
}
|
||||
|
||||
function isMarkdownTarget(target: TaskArtifactTarget): boolean {
|
||||
return /\.(md|markdown)$/i.test(normalizedPath(target.path))
|
||||
|| target.mimeType?.toLowerCase().includes("markdown") === true;
|
||||
}
|
||||
|
||||
function recordKey(target: TaskArtifactTarget): string {
|
||||
const source = target.agentId?.trim() || `thread:${target.threadId}`;
|
||||
return `${source}\u001f${normalizedPath(target.path)}\u001f0`;
|
||||
}
|
||||
|
||||
function payloadFrom(
|
||||
config: TaskArtifactSubmissionConfig,
|
||||
target: TaskArtifactTarget,
|
||||
content: string,
|
||||
submitStatus: 0 | 1,
|
||||
): TaskProcessReportPayload {
|
||||
const name = target.name?.trim() || fileName(target.path);
|
||||
const agentId = target.agentId?.trim() || target.threadId;
|
||||
return {
|
||||
taskId: config.taskId,
|
||||
agentId,
|
||||
agentName: target.agentName?.trim() || agentId,
|
||||
content,
|
||||
position: config.position,
|
||||
reportName: name,
|
||||
submitStatus,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges the task system's process-report API and DeerFlow's task-scoped
|
||||
* submission ledger. The ledger is read on mount, so "已提交" survives reloads
|
||||
* and is shared by all users opening the same task.
|
||||
*/
|
||||
export function useTaskArtifactSubmission(
|
||||
config: TaskArtifactSubmissionConfig | undefined,
|
||||
threadIds: ThreadIdMap | null,
|
||||
agentNameToRole: Record<string, { displayName: string }> | undefined,
|
||||
): TaskArtifactSubmissionController | undefined {
|
||||
const [entries, setEntries] = useState<Record<string, StatusEntry>>({});
|
||||
const entriesRef = useRef(entries);
|
||||
entriesRef.current = entries;
|
||||
const pendingRef = useRef(new Map<string, Promise<RoundtableArtifactSubmissionRecord | null>>());
|
||||
|
||||
const resolveTarget = useCallback((target: TaskArtifactTarget): TaskArtifactTarget => {
|
||||
const normalized = normalizedPath(target.path);
|
||||
const matchedAgentId = target.agentId || Object.entries(threadIds ?? {})
|
||||
.find(([, threadId]) => threadId === target.threadId)?.[0];
|
||||
const role = matchedAgentId ? agentNameToRole?.[matchedAgentId.toLowerCase()] : undefined;
|
||||
return {
|
||||
...target,
|
||||
path: normalized,
|
||||
name: target.name || fileName(normalized),
|
||||
mimeType: target.mimeType || (isMarkdownTarget({ ...target, path: normalized }) ? "text/markdown" : null),
|
||||
agentId: matchedAgentId || target.threadId,
|
||||
agentName: target.agentName || role?.displayName || matchedAgentId || target.threadId,
|
||||
};
|
||||
}, [agentNameToRole, threadIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.taskId) {
|
||||
setEntries({});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void Promise.all([
|
||||
listRoundtableArtifactSubmissions(config.taskId, true),
|
||||
listRoundtableArtifactSubmissions(config.taskId, false),
|
||||
]).then(([submitted, registered]) => {
|
||||
if (cancelled) return;
|
||||
const next: Record<string, StatusEntry> = {};
|
||||
for (const record of [...submitted, ...registered]) {
|
||||
const key = `${record.artifact.sourceKey}\u001f${normalizedPath(record.artifact.path)}\u001f${record.artifact.revision}`;
|
||||
next[key] = {
|
||||
state: record.submitted ? "submitted" : "idle",
|
||||
error: null,
|
||||
record,
|
||||
};
|
||||
}
|
||||
setEntries(next);
|
||||
}).catch((error) => {
|
||||
// State recovery is additive. A transient read failure must not prevent a
|
||||
// user from submitting a Markdown artifact from the current session.
|
||||
console.warn("[roundtable] 读取任务产物提交状态失败", error);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [config?.taskId]);
|
||||
|
||||
const submit = useCallback(async (rawTarget: TaskArtifactTarget) => {
|
||||
if (!config || !isMarkdownTarget(rawTarget)) return null;
|
||||
const target = resolveTarget(rawTarget);
|
||||
const key = recordKey(target);
|
||||
const existingPending = pendingRef.current.get(key);
|
||||
if (existingPending) return existingPending;
|
||||
const existing = entriesRef.current[key];
|
||||
if (existing?.state === "submitted" && existing.record) return existing.record;
|
||||
|
||||
const operation = (async () => {
|
||||
setEntries((current) => ({ ...current, [key]: { ...current[key], state: "submitting", error: null } }));
|
||||
try {
|
||||
let content = target.content;
|
||||
if (content == null) {
|
||||
const response = await apiFetch(buildArtifactDownloadUrl(target.threadId, target.path), { method: "GET" });
|
||||
if (!response.ok) throw new Error(`读取 Markdown 产物失败(${response.status})`);
|
||||
content = await response.text();
|
||||
}
|
||||
|
||||
let record = entriesRef.current[key]?.record;
|
||||
let reportId = record?.taskDeliveryId || undefined;
|
||||
if (!reportId) {
|
||||
const receipt = await config.createProcessReport(payloadFrom(config, target, content, 0));
|
||||
reportId = receipt.id;
|
||||
const artifact: RoundtableArtifactReference = {
|
||||
threadId: target.threadId,
|
||||
sourceKey: target.agentId || `thread:${target.threadId}`,
|
||||
path: target.path,
|
||||
name: target.name || fileName(target.path),
|
||||
mimeType: target.mimeType || "text/markdown",
|
||||
revision: 0,
|
||||
};
|
||||
record = await saveRoundtableArtifactSubmission(config.taskId, {
|
||||
submitted: false,
|
||||
registerOnly: true,
|
||||
taskDeliveryId: reportId,
|
||||
artifact,
|
||||
});
|
||||
}
|
||||
|
||||
await config.updateProcessReport(reportId, payloadFrom(config, target, content, 1));
|
||||
const submitted = await saveRoundtableArtifactSubmission(config.taskId, {
|
||||
submitted: true,
|
||||
taskDeliveryId: reportId,
|
||||
expectedVersion: record?.version,
|
||||
artifact: {
|
||||
threadId: target.threadId,
|
||||
sourceKey: target.agentId || `thread:${target.threadId}`,
|
||||
path: target.path,
|
||||
name: target.name || fileName(target.path),
|
||||
mimeType: target.mimeType || "text/markdown",
|
||||
revision: 0,
|
||||
},
|
||||
});
|
||||
setEntries((current) => ({ ...current, [key]: { state: "submitted", error: null, record: submitted } }));
|
||||
return submitted;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "提交产物失败,请重试";
|
||||
setEntries((current) => ({ ...current, [key]: { ...current[key], state: "error", error: message } }));
|
||||
throw error;
|
||||
} finally {
|
||||
pendingRef.current.delete(key);
|
||||
}
|
||||
})();
|
||||
pendingRef.current.set(key, operation);
|
||||
return operation;
|
||||
}, [config, resolveTarget]);
|
||||
|
||||
const submittedRecords = useMemo(
|
||||
() => Object.values(entries)
|
||||
.filter((entry) => entry.state === "submitted" && entry.record?.submitted)
|
||||
.map((entry) => entry.record as RoundtableArtifactSubmissionRecord),
|
||||
[entries],
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!config) return undefined;
|
||||
return {
|
||||
enabled: true,
|
||||
isMarkdown: isMarkdownTarget,
|
||||
targetFor: resolveTarget,
|
||||
stateFor: (target) => entries[recordKey(resolveTarget(target))]?.state || "idle",
|
||||
errorFor: (target) => entries[recordKey(resolveTarget(target))]?.error || null,
|
||||
submit,
|
||||
submittedRecords,
|
||||
} satisfies TaskArtifactSubmissionController;
|
||||
}, [config, entries, resolveTarget, submit, submittedRecords]);
|
||||
}
|
||||
@@ -95,6 +95,11 @@ export interface BusinessChain {
|
||||
gatherFirst?: boolean;
|
||||
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
|
||||
isPublic?: boolean;
|
||||
/**
|
||||
* 启停状态。缺省 / undefined = 启用(存量链条无该字段时按启用处理)。
|
||||
* 停用后管理弹窗仍可见,选链弹窗 / 公共入口不再选用。
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
|
||||
isMine?: boolean;
|
||||
/** 配置者(owner)的可读用户名(后端解析的 email);解析失败时为空。 */
|
||||
@@ -151,6 +156,11 @@ export interface SeatSelectionResult {
|
||||
export const CHAIN_MIN_SEATS = 2;
|
||||
export const CHAIN_MAX_SEATS = 20;
|
||||
|
||||
/** 启停状态:缺省 / undefined 视为启用(存量链条无该字段)。 */
|
||||
export function isChainEnabled(chain: Pick<BusinessChain, "enabled">): boolean {
|
||||
return chain.enabled !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 由一条业务链条 + 当前候选智能体,构造 `SeatSelectionResult`。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { RoundtableArtifactSubmissionRecord } from "@/roundtable-planning/api/artifact-submissions";
|
||||
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||
import type { ChainSeat } from "@/roundtable-planning/lib/business-chain";
|
||||
import { getDisplayNameFor, type SelectedAgent } from "@/roundtable-planning/lib/roundtable-constants";
|
||||
|
||||
function norm(value: string | null | undefined): string {
|
||||
return (value ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function sourceKeyOf(record: RoundtableArtifactSubmissionRecord): string {
|
||||
const raw = (record.artifact.sourceKey ?? "").trim();
|
||||
if (!raw || raw.toLowerCase().startsWith("thread:")) return "";
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a chain seat to a submitted Markdown artifact.
|
||||
*
|
||||
* Submissions are keyed by the graph agent name (`threadIds` key) or UUID;
|
||||
* chain seats expose both `agent_id` and display `name`.
|
||||
*/
|
||||
export function findSeatSubmission(
|
||||
seat: ChainSeat,
|
||||
records: RoundtableArtifactSubmissionRecord[],
|
||||
selectedAgents: SelectedAgent[],
|
||||
threadIds: ThreadIdMap | null,
|
||||
): RoundtableArtifactSubmissionRecord | null {
|
||||
const selected = selectedAgents.find((agent) => agent.agent_id === seat.agent_id);
|
||||
const aliases = new Set(
|
||||
[
|
||||
seat.agent_id,
|
||||
seat.name,
|
||||
selected?.agent_id,
|
||||
selected?.name,
|
||||
selected ? getDisplayNameFor(selected) : "",
|
||||
]
|
||||
.map(norm)
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const hits = records.filter((record) => {
|
||||
if (!record.submitted) return false;
|
||||
const sourceKey = norm(sourceKeyOf(record));
|
||||
if (sourceKey && aliases.has(sourceKey)) return true;
|
||||
if (!threadIds) return false;
|
||||
const owner = Object.entries(threadIds).find(([, id]) => id === record.artifact.threadId)?.[0];
|
||||
if (owner && aliases.has(norm(owner))) return true;
|
||||
for (const alias of aliases) {
|
||||
const mapped = threadIds[alias];
|
||||
if (mapped && mapped === record.artifact.threadId) return true;
|
||||
}
|
||||
const byName = threadIds[seat.name] ?? (selected ? threadIds[selected.name] : undefined);
|
||||
return Boolean(byName && byName === record.artifact.threadId);
|
||||
});
|
||||
|
||||
if (hits.length === 0) return null;
|
||||
return hits
|
||||
.slice()
|
||||
.sort((a, b) => (b.submittedAt ?? "").localeCompare(a.submittedAt ?? ""))[0] ?? null;
|
||||
}
|
||||
|
||||
/** Identity keys the flowchart can match against `seat.agent_id` / `seat.name`. */
|
||||
export function submittedSeatKeysFor(
|
||||
seats: ChainSeat[],
|
||||
records: RoundtableArtifactSubmissionRecord[],
|
||||
selectedAgents: SelectedAgent[],
|
||||
threadIds: ThreadIdMap | null,
|
||||
): string[] {
|
||||
const keys = new Set<string>();
|
||||
for (const record of records) {
|
||||
if (!record.submitted) continue;
|
||||
const sourceKey = sourceKeyOf(record);
|
||||
if (sourceKey) keys.add(norm(sourceKey));
|
||||
if (threadIds) {
|
||||
const owner = Object.entries(threadIds).find(([, id]) => id === record.artifact.threadId)?.[0];
|
||||
if (owner) keys.add(norm(owner));
|
||||
}
|
||||
}
|
||||
for (const seat of seats) {
|
||||
if (!findSeatSubmission(seat, records, selectedAgents, threadIds)) continue;
|
||||
keys.add(norm(seat.agent_id));
|
||||
if (seat.name) keys.add(norm(seat.name));
|
||||
}
|
||||
for (const agent of selectedAgents) {
|
||||
const seat = { agent_id: agent.agent_id, name: agent.name };
|
||||
if (!findSeatSubmission(seat, records, selectedAgents, threadIds)) continue;
|
||||
keys.add(norm(agent.agent_id));
|
||||
keys.add(norm(agent.name));
|
||||
keys.add(norm(getDisplayNameFor(agent)));
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { RoundtableArtifactSubmissionRecord } from "@/roundtable-planning/api/artifact-submissions";
|
||||
|
||||
export interface TaskProcessReportPayload {
|
||||
taskId: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
content: string;
|
||||
position: number;
|
||||
reportName: string;
|
||||
submitStatus: 0 | 1;
|
||||
}
|
||||
|
||||
export interface TaskArtifactSubmissionConfig {
|
||||
/** External task-system id, not the local roundtable draft storage key. */
|
||||
taskId: string;
|
||||
position: number;
|
||||
createProcessReport: (payload: TaskProcessReportPayload) => Promise<{ id: string }>;
|
||||
updateProcessReport: (reportId: string, payload: TaskProcessReportPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface TaskArtifactTarget {
|
||||
threadId: string;
|
||||
path: string;
|
||||
name?: string;
|
||||
mimeType?: string | null;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export type TaskArtifactSubmissionState = "idle" | "submitting" | "submitted" | "error";
|
||||
|
||||
export interface TaskArtifactSubmissionController {
|
||||
enabled: boolean;
|
||||
isMarkdown: (target: TaskArtifactTarget) => boolean;
|
||||
targetFor: (target: TaskArtifactTarget) => TaskArtifactTarget;
|
||||
stateFor: (target: TaskArtifactTarget) => TaskArtifactSubmissionState;
|
||||
errorFor: (target: TaskArtifactTarget) => string | null;
|
||||
submit: (target: TaskArtifactTarget) => Promise<RoundtableArtifactSubmissionRecord | null>;
|
||||
/** Task-scoped ledger rows that have been formally submitted. */
|
||||
submittedRecords: RoundtableArtifactSubmissionRecord[];
|
||||
}
|
||||
@@ -94,7 +94,7 @@ import { flowNodeMeta, type FlowNodeType } from "@/roundtable-planning/lib/flow-
|
||||
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
|
||||
import { BusinessChainListDialog } from "@/roundtable-planning/components/BusinessChainListDialog";
|
||||
import { DispatchChainJobModal } from "@/roundtable-planning/components/DispatchChainJobModal";
|
||||
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
|
||||
import { Step1Panel, type Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
|
||||
import { readActionDetail, readCopTaskDetail } from "@/core/auth/task-deeplink";
|
||||
import { Step2Panel } from "@/roundtable-planning/components/Step2Panel";
|
||||
import { Step3Panel } from "@/roundtable-planning/components/Step3Panel";
|
||||
@@ -102,7 +102,9 @@ import type {
|
||||
DraftStep2RunSnapshot,
|
||||
DraftStep2Snapshot,
|
||||
DraftStep3Snapshot,
|
||||
DraftStep3SummarySnapshot,
|
||||
} from "@/roundtable-planning/api/drafts";
|
||||
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||
import {
|
||||
useStep1Intent,
|
||||
type InteractiveMessage,
|
||||
@@ -132,7 +134,9 @@ import {
|
||||
} from "@/roundtable-planning/lib/seat-filter";
|
||||
import {
|
||||
buildChainSeatSelection,
|
||||
isChainEnabled,
|
||||
type BusinessChain,
|
||||
type ChainSeat,
|
||||
type SeatSelectionResult,
|
||||
} from "@/roundtable-planning/lib/business-chain";
|
||||
import { DEFAULT_SEAT_MODE, type SeatMode } from "@/roundtable-planning/lib/seat-mode";
|
||||
@@ -152,6 +156,16 @@ import { listAgents } from "@/core/agents";
|
||||
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
|
||||
import { emitWujieEvent, isWujieEnv, onWujieEvent, readWujieProps } from "@/core/embed/wujie";
|
||||
import { collectRoundtableDeliverables } from "@/roundtable-planning/lib/roundtable-deliverables";
|
||||
import { useTaskArtifactSubmission } from "@/roundtable-planning/hooks/useTaskArtifactSubmission";
|
||||
import type { TaskArtifactSubmissionConfig } from "@/roundtable-planning/lib/task-artifact-submission";
|
||||
import {
|
||||
findSeatSubmission,
|
||||
submittedSeatKeysFor,
|
||||
} from "@/roundtable-planning/lib/match-seat-submission";
|
||||
import {
|
||||
AnalysisResultDetailDialog,
|
||||
type AnalysisResultCardItem,
|
||||
} from "@/components/analysis-results";
|
||||
|
||||
/**
|
||||
* 业务码 → 任务工作区业务码(``TaskBusiness``),用于深链 taskId 模式底部「快捷跳转」栏
|
||||
@@ -298,8 +312,37 @@ export default function RoundtablePlanningPage({
|
||||
toolbarSlot,
|
||||
/** 独立业务页在第一步左上角替换默认业务链选择区的内容。 */
|
||||
entryRailBusinessChainSlot,
|
||||
/** 独立业务页在第一步左上角提供自己的任务卡片;不触发原深链任务接口。 */
|
||||
entryRailTaskCardSlot,
|
||||
/** 独立业务页传给意图智能体的隐藏任务上下文。 */
|
||||
taskContext,
|
||||
/** 独立业务页的任务作用域,仅用于左栏记录筛选,不改变原深链逻辑。 */
|
||||
entryRailTaskId,
|
||||
/** 独立业务页的根节点样式类,避免修改原会商样式。 */
|
||||
rootClassName,
|
||||
/** 独立业务页替换第一步消息工作区的纯展示组件;状态和行为仍由本页统一拥有。 */
|
||||
renderStep1,
|
||||
/** 独立业务页替换第一步左侧入口栏。 */
|
||||
renderStep1LeftSidebar,
|
||||
/** 独立业务页在第二步左栏顶部复用自己的任务/意图展示。 */
|
||||
renderStep2LeftSidebarHeader,
|
||||
/** 第一阶段使用内嵌右栏时,隐藏通用的模型/摘要/澄清记录栏。 */
|
||||
hideDefaultStep1RightSidebar = false,
|
||||
step2HeaderTitle,
|
||||
step2HeaderRightSlot,
|
||||
step3HeaderTitle,
|
||||
/** 独立业务页的工作区品牌区,取代通用步骤导航。 */
|
||||
headerLeftSlot,
|
||||
/** 放在历史记录后面的独立业务操作或状态。 */
|
||||
headerActionAfterHistory,
|
||||
/** 独立业务页可隐藏通用的“保存草稿”按钮。 */
|
||||
hideHeaderSaveDraft = false,
|
||||
/** 独立业务页可调整新建动作的展示文案,行为不变。 */
|
||||
newTaskLabel,
|
||||
/** 独立业务页可隐藏通用的设置入口。 */
|
||||
hideHeaderSettings = false,
|
||||
/** 可选的外部任务过程产物提交适配器(8BF 使用,通用圆桌保持不显示)。 */
|
||||
taskArtifactSubmissionConfig,
|
||||
}: {
|
||||
compactChrome?: boolean;
|
||||
lockedChainId?: string | null;
|
||||
@@ -307,7 +350,43 @@ export default function RoundtablePlanningPage({
|
||||
storageTaskId?: string | null;
|
||||
toolbarSlot?: ReactNode;
|
||||
entryRailBusinessChainSlot?: ReactNode;
|
||||
entryRailTaskCardSlot?: ReactNode;
|
||||
taskContext?: Record<string, unknown> | null;
|
||||
entryRailTaskId?: string | null;
|
||||
rootClassName?: string;
|
||||
renderStep1?: (props: Step1PanelProps & {
|
||||
selectedModel: string;
|
||||
modelOptions: Array<{ label: string; value: string }>;
|
||||
onSelectedModelChange: (model: string) => void;
|
||||
step2Dialogues: unknown[];
|
||||
step2ThreadIds: ThreadIdMap | null;
|
||||
step2CoordinatorName: string | null;
|
||||
step2SeatStubMessages: Record<string, unknown[]>;
|
||||
step3Summary: DraftStep3SummarySnapshot | null;
|
||||
}) => ReactNode;
|
||||
renderStep1LeftSidebar?: (context: {
|
||||
intentReady: object | null;
|
||||
onCollapse?: () => void;
|
||||
submittedSeatKeys?: string[];
|
||||
onSeatClick?: (seat: ChainSeat) => void;
|
||||
}) => ReactNode;
|
||||
renderStep2LeftSidebarHeader?: (context: {
|
||||
intentReady: object | null;
|
||||
onCollapse: () => void;
|
||||
}) => ReactNode;
|
||||
hideDefaultStep1RightSidebar?: boolean;
|
||||
/** 8BF 第二步对话区标题,与第一步顶栏同一套样式。 */
|
||||
step2HeaderTitle?: string;
|
||||
/** 8BF 第二步顶栏右侧附加操作(如返回)。 */
|
||||
step2HeaderRightSlot?: ReactNode;
|
||||
/** 8BF 第三步对话区标题,与第一、二步顶栏同一套样式。 */
|
||||
step3HeaderTitle?: string;
|
||||
headerLeftSlot?: ReactNode;
|
||||
headerActionAfterHistory?: ReactNode;
|
||||
hideHeaderSaveDraft?: boolean;
|
||||
newTaskLabel?: string;
|
||||
hideHeaderSettings?: boolean;
|
||||
taskArtifactSubmissionConfig?: TaskArtifactSubmissionConfig;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
|
||||
@@ -315,6 +394,10 @@ export default function RoundtablePlanningPage({
|
||||
const isDark = theme === "dark";
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
|
||||
useEffect(() => {
|
||||
taskContextRef.current = taskContext ?? null;
|
||||
}, [taskContext]);
|
||||
/** 无界(wujie)嵌入抽屉:隐藏业务链条入口,设置按钮改作手动回传交付。 */
|
||||
const inWujieEmbed = isWujieEnv();
|
||||
|
||||
@@ -331,6 +414,13 @@ export default function RoundtablePlanningPage({
|
||||
const draftStorageTaskId = storageTaskId ?? recordTaskIdOf(prefillRef.current);
|
||||
// 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。
|
||||
const [autoChainResult, setAutoChainResult] = useState<SeatSelectionResult | null>(null);
|
||||
// 固定业务链(8BF 等独立页面)的解析状态。不能只以 autoChainResult 是否为空判断:
|
||||
// 它为空既可能是「还在拉取」,也可能是「业务链已配置但可用席位不足」。
|
||||
// 若混为一谈,用户刚保存配置后立刻点击进入研讨时会被错误提示为「未配置业务链」。
|
||||
const [lockedChainResolution, setLockedChainResolution] = useState<
|
||||
"idle" | "loading" | "ready" | "unavailable"
|
||||
>("idle");
|
||||
const [lockedChainUnavailableReason, setLockedChainUnavailableReason] = useState<string | null>(null);
|
||||
/** 无界直传 chainId 时,链条解析完成前禁止弹选链窗(避免 modal 布局抖动)。 */
|
||||
const [autoChainLoading, setAutoChainLoading] = useState(
|
||||
() =>
|
||||
@@ -342,21 +432,57 @@ export default function RoundtablePlanningPage({
|
||||
useEffect(() => {
|
||||
if (!lockedChainId) {
|
||||
setAutoChainResult(null);
|
||||
setLockedChainResolution("idle");
|
||||
setLockedChainUnavailableReason(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
// 映射切换时先清掉旧链结果,避免异步请求尚未返回时误用上一条业务链进入研讨。
|
||||
setAutoChainResult(null);
|
||||
setLockedChainResolution("loading");
|
||||
setLockedChainUnavailableReason(null);
|
||||
setAutoChainLoading(true);
|
||||
void Promise.all([getChain(lockedChainId), listAgents().catch(() => [])])
|
||||
void Promise.all([getChain(lockedChainId), listAgents()])
|
||||
.then(([chain, agents]) => {
|
||||
if (cancelled || !chain) return;
|
||||
const result = buildChainSeatSelection(chain, filterRecommendCandidates(agents));
|
||||
if (!cancelled) setAutoChainResult(result);
|
||||
if (cancelled) return;
|
||||
if (!chain) {
|
||||
setAutoChainResult(null);
|
||||
setLockedChainResolution("unavailable");
|
||||
setLockedChainUnavailableReason("已配置的业务链不存在或未发布,请重新选择一条已发布的业务链");
|
||||
return;
|
||||
}
|
||||
const candidates = filterRecommendCandidates(agents);
|
||||
const result = buildChainSeatSelection(chain, candidates);
|
||||
if (!cancelled) {
|
||||
setAutoChainResult(result);
|
||||
setLockedChainResolution(result ? "ready" : "unavailable");
|
||||
if (!result) {
|
||||
const availableAgentIds = new Set(candidates.map((agent) => agent.agent_id));
|
||||
const unavailableSeats = chain.seats
|
||||
.filter((seat) => !availableAgentIds.has(seat.agent_id))
|
||||
.map((seat) => seat.name || seat.agent_id);
|
||||
setLockedChainUnavailableReason(
|
||||
unavailableSeats.length > 0
|
||||
? `业务链中的智能体「${unavailableSeats.join("、")}」尚未发布、已删除或当前账号无访问权限;请先将它们发布,并保证至少有 2 个可用智能体`
|
||||
: "业务链至少需要 2 个可用智能体,请检查业务链席位配置",
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAutoChainResult(null);
|
||||
if (!cancelled) {
|
||||
setAutoChainResult(null);
|
||||
setLockedChainResolution("unavailable");
|
||||
setLockedChainUnavailableReason("读取已配置业务链或智能体失败,请刷新后重试");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setAutoChainLoading(false);
|
||||
if (!cancelled) {
|
||||
setAutoChainLoading(false);
|
||||
// getChain 返回 null 时不会进入 then 内的 result 分支,同样标记为不可用。
|
||||
setLockedChainResolution((current) => current === "loading" ? "unavailable" : current);
|
||||
setLockedChainUnavailableReason((current) => current ?? "已配置的业务链暂不可用,请重新选择后重试");
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -374,11 +500,11 @@ export default function RoundtablePlanningPage({
|
||||
consensusEmittedRef.current = true;
|
||||
}
|
||||
}, [inWujieEmbed]);
|
||||
// 带 taskId 时,先按任务回显最新草稿再决定是否 autoStart。用 state 而非 ref:
|
||||
// 带存储键 / 深链 taskId 时,先回显最新草稿再决定是否 autoStart。用 state 而非 ref:
|
||||
// 解析完成需触发重渲染让 C2 effect 重跑(否则 intentThreadId 先就绪会错过 autoStart)。
|
||||
// 无 taskId 时初始即「已解析」,autoStart 照常。
|
||||
// 无存储键时初始即「已解析」,autoStart 照常。8BF 走 storageTaskId,不依赖 prefill.taskId。
|
||||
const [taskRestoreResolved, setTaskRestoreResolved] = useState<boolean>(
|
||||
!prefillRef.current?.taskId,
|
||||
!draftStorageTaskId,
|
||||
);
|
||||
// 深链/预填自动会话:进入页面后要先建意图线程、(带 taskId 时)回显历史草稿,这段时间
|
||||
// 页面只有一句欢迎语、问答还没真正开始,线程创建慢时像卡住。下面这个标志用来在超时后
|
||||
@@ -542,6 +668,38 @@ export default function RoundtablePlanningPage({
|
||||
}>({ intentReady: null, interactiveMessages: [] });
|
||||
|
||||
const buildInitialIntent = useCallback((): string => {
|
||||
const task = taskContextRef.current?.task as Record<string, unknown> | null | undefined;
|
||||
const taskTags = taskContextRef.current?.task_tags as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const taskValue = (value: unknown) =>
|
||||
value === undefined || value === null ? "" : String(value).trim();
|
||||
const taskLines = task
|
||||
? [
|
||||
["任务 ID", taskValue(task.id ?? taskContextRef.current?.task_id)],
|
||||
["任务名称", taskValue(task.title)],
|
||||
["任务描述", taskValue(task.description)],
|
||||
["任务概览", taskValue(task.overview)],
|
||||
["任务方向", taskValue(task.direction ?? task.target_direction)],
|
||||
["优先级", taskValue(task.task_priority ?? task.priority)],
|
||||
["任务状态", taskValue(task.task_status)],
|
||||
["优先级标签", taskValue(taskTags?.priority)],
|
||||
["方向标签", taskValue(taskTags?.direction)],
|
||||
["状态标签", taskValue(taskTags?.status)],
|
||||
]
|
||||
.filter(([, value]) => value)
|
||||
.map(([label, value]) => `${label}: ${value}`)
|
||||
: [];
|
||||
const taskCardContext = taskLines.length
|
||||
? [
|
||||
"【8BF 任务卡片上下文】",
|
||||
...taskLines,
|
||||
"请把以上任务卡片信息作为本轮研讨的既有背景,不要让用户重复提供。",
|
||||
"",
|
||||
].join("\n")
|
||||
: "";
|
||||
const withTaskCardContext = (value: string) =>
|
||||
taskCardContext ? `${taskCardContext}${value}` : value;
|
||||
const { intentReady, interactiveMessages } = step1StateRef.current;
|
||||
if (intentReady) {
|
||||
const parts: string[] = [intentReady.objective];
|
||||
@@ -559,17 +717,17 @@ export default function RoundtablePlanningPage({
|
||||
parts.push(
|
||||
"用户已经过任务理解智能体澄清,以上信息可以直接作为研讨依据。请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨,不要再反问用户。",
|
||||
);
|
||||
return parts.join("\n");
|
||||
return withTaskCardContext(parts.join("\n"));
|
||||
}
|
||||
const userTurns = interactiveMessages
|
||||
.filter((m) => m.sender === "user")
|
||||
.map((m) => m.content?.trim())
|
||||
.filter((s): s is string => !!s);
|
||||
if (userTurns.length === 0) {
|
||||
return "用户尚未在第一步明确任务意图。请先调用 ask_clarification,简要询问目标、范围与硬性约束,再进入研讨。";
|
||||
return withTaskCardContext("用户尚未在第一步明确任务意图。请先调用 ask_clarification,简要询问目标、范围与硬性约束,再进入研讨。");
|
||||
}
|
||||
const conversation = userTurns.map((t, i) => `用户#${i + 1}: ${t}`).join("\n");
|
||||
return `${conversation}\n\n注意:用户尚未完成结构化的任务理解流程,以上是原始输入。若信息不足以开展研讨,请先调用 ask_clarification 询问关键约束,再开始派活。`;
|
||||
return withTaskCardContext(`${conversation}\n\n注意:用户尚未完成结构化的任务理解流程,以上是原始输入。若信息不足以开展研讨,请先调用 ask_clarification 询问关键约束,再开始派活。`);
|
||||
}, []);
|
||||
|
||||
const step2 = useStep2Orchestration({
|
||||
@@ -593,8 +751,67 @@ export default function RoundtablePlanningPage({
|
||||
currentStep,
|
||||
selectedModel,
|
||||
triggerToast,
|
||||
taskContext,
|
||||
});
|
||||
|
||||
const taskArtifactSubmission = useTaskArtifactSubmission(
|
||||
taskArtifactSubmissionConfig,
|
||||
step2.threadIds,
|
||||
step2.agentNameToRole,
|
||||
);
|
||||
|
||||
const [seatDeliverable, setSeatDeliverable] = useState<AnalysisResultCardItem | null>(null);
|
||||
const submittedRecords = taskArtifactSubmission?.submittedRecords;
|
||||
const submittedSeatKeys = useMemo(
|
||||
() => submittedSeatKeysFor(
|
||||
step2.selectedAgents.map((agent) => ({ agent_id: agent.agent_id, name: agent.name })),
|
||||
submittedRecords ?? [],
|
||||
step2.selectedAgents,
|
||||
step2.threadIds,
|
||||
),
|
||||
[submittedRecords, step2.selectedAgents, step2.threadIds],
|
||||
);
|
||||
const handleChainSeatClick = useCallback((seat: ChainSeat) => {
|
||||
const record = findSeatSubmission(
|
||||
seat,
|
||||
submittedRecords ?? [],
|
||||
step2.selectedAgents,
|
||||
step2.threadIds,
|
||||
);
|
||||
if (!record) {
|
||||
triggerToast("该节点暂无可预览产物");
|
||||
return;
|
||||
}
|
||||
setSeatDeliverable({
|
||||
id: record.id,
|
||||
agentName: seat.name || record.artifact.sourceKey,
|
||||
productName: record.artifact.name,
|
||||
positionName: seat.description?.trim() || autoChainResult?.chain?.title || "业务链席位",
|
||||
delivered: true,
|
||||
artifactPath: record.artifact.path,
|
||||
artifactMimeType: record.artifact.mimeType,
|
||||
artifactThreadId: record.artifact.threadId,
|
||||
updatedAt: record.submittedAt ?? undefined,
|
||||
});
|
||||
}, [
|
||||
autoChainResult?.chain?.title,
|
||||
step2.selectedAgents,
|
||||
step2.threadIds,
|
||||
submittedRecords,
|
||||
triggerToast,
|
||||
]);
|
||||
const renderStep1LeftSidebarWithDeliveries = useMemo(
|
||||
() => (renderStep1LeftSidebar
|
||||
? (context: { intentReady: object | null; onCollapse?: () => void }) =>
|
||||
renderStep1LeftSidebar({
|
||||
...context,
|
||||
submittedSeatKeys,
|
||||
onSeatClick: taskArtifactSubmission ? handleChainSeatClick : undefined,
|
||||
})
|
||||
: undefined),
|
||||
[handleChainSeatClick, renderStep1LeftSidebar, submittedSeatKeys, taskArtifactSubmission],
|
||||
);
|
||||
|
||||
// 同步 step1StateRef,让 buildInitialIntent / Persona / Step 3 都能读到最新值。
|
||||
useEffect(() => {
|
||||
step1StateRef.current = {
|
||||
@@ -1140,11 +1357,11 @@ export default function RoundtablePlanningPage({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// C1.5(无界回显):带 taskId 时,挂载即按任务取最新草稿回显。命中 → 抑制 autoStart
|
||||
// C1.5(无界 / 8BF 回显):带存储键时,挂载即取最新草稿回显。命中 → 抑制 autoStart
|
||||
// (避免重复发首条消息);未命中 → 放行 autoStart 走全新任务。只跑一次。
|
||||
useEffect(() => {
|
||||
// 回显聊天记录按存储键(xdfx-A=行动id),与 useDraftPersistence 的 taskId 保持一致。
|
||||
const tid = recordTaskIdOf(prefillRef.current);
|
||||
// 回显聊天记录按存储键:8BF 用独立 `storageTaskId`;xdfx-A 按行动id;其它深链按真实 taskId。
|
||||
const tid = draftStorageTaskId;
|
||||
if (!tid) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
@@ -1400,9 +1617,9 @@ export default function RoundtablePlanningPage({
|
||||
.filter((g) => g.length > 0),
|
||||
[orchestrationPlan],
|
||||
);
|
||||
// 第三步首次生成报告后即**自动**续跑结构化输出(流程图)——不再限 taskId 深链,
|
||||
// 任何会商进第三步首次出报告都自动出流程图(用户 2026-06-17 要求)。
|
||||
const autoSummaryFlow = true;
|
||||
// 第三步的交付统一为 Markdown 总结报告。流程图不再作为本页的后续自动产物,
|
||||
// 避免报告写完后额外触发一次结构化抽取请求。
|
||||
const autoSummaryFlow = false;
|
||||
|
||||
const summaryFlow = useStep3Summary({
|
||||
intentReady: step1.intentReady,
|
||||
@@ -1412,6 +1629,7 @@ export default function RoundtablePlanningPage({
|
||||
coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null,
|
||||
chainLevels,
|
||||
autoFlow: autoSummaryFlow,
|
||||
flowEnabled: false,
|
||||
savedSummary: step3Report?.summaryReport ?? null,
|
||||
onSummarySaved: handleSummarySaved,
|
||||
ingestAvailable,
|
||||
@@ -1917,9 +2135,66 @@ export default function RoundtablePlanningPage({
|
||||
triggerToast("追问生成就绪!");
|
||||
}, [step1, triggerToast]);
|
||||
|
||||
// 独立业务页可只替换 Step 1 的外观;这里将通用状态/动作收敛为一份受控 props,
|
||||
// 避免它们复制或分叉意图识别、附件上传、澄清和进入研讨的业务逻辑。
|
||||
const step1PanelProps: Step1PanelProps = {
|
||||
interactiveMessages: step1.interactiveMessages,
|
||||
intentThreadId: step1.intentThreadId,
|
||||
intentReady: step1.intentReady,
|
||||
isIntentSending: step1.isIntentSending,
|
||||
pendingIntentClarificationMsgId: step1.pendingIntentClarificationMsgId,
|
||||
intentClarificationSelections: step1.intentClarificationSelections,
|
||||
setIntentClarificationSelections: step1.setIntentClarificationSelections,
|
||||
onSendMessage: (text, files) => void step1.sendIntentMessage(text, files),
|
||||
onStopIntent: step1.stopIntentStream,
|
||||
onSkipQuestion: handleSkipQuestion,
|
||||
onFollowUp: handleAgentFollowUp,
|
||||
onEnterRoundtable: () => {
|
||||
if (!step1.intentReady) {
|
||||
triggerToast("请先与任务理解智能体完成澄清,生成任务摘要后再进入");
|
||||
return;
|
||||
}
|
||||
// 会商自动化串联(模块 C):若任务页带来的业务链已解析成功,跳过选链弹窗,
|
||||
// 直接据该业务链进入 Step 2 研讨(用户无需再手选)。
|
||||
if (autoChainResult) {
|
||||
const result = autoChainResult;
|
||||
void drafts.ensureDraft(1).finally(() => handleConfirmAgents(result));
|
||||
return;
|
||||
}
|
||||
if (lockBusinessChain) {
|
||||
triggerToast(
|
||||
autoChainLoading || lockedChainResolution === "loading"
|
||||
? "正在加载管理员配置的业务链,请稍候…"
|
||||
: lockedChainResolution === "unavailable"
|
||||
? lockedChainUnavailableReason ?? "已配置的业务链暂不可用,请确认至少保留 2 个可用智能体后重试"
|
||||
: "管理员尚未配置可用的业务链,请联系管理员处理",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
inWujieEmbed &&
|
||||
autoChainLoading &&
|
||||
(prefillRef.current?.chainId || prefillRef.current?.businessCode)
|
||||
) {
|
||||
triggerToast("正在加载业务链条,请稍候…");
|
||||
return;
|
||||
}
|
||||
// 先把草稿落地拿到 draft_id,推荐历史才有归属;失败也照常打开弹窗
|
||||
// (弹窗在无 draftId 时退化为「直接推荐、不存历史」)。
|
||||
void drafts.ensureDraft(1).finally(() => setIsRecommendDialogOpen(true));
|
||||
},
|
||||
onManualSave: drafts.handleManualSave,
|
||||
onTriggerToast: triggerToast,
|
||||
submitIntentClarifications: step1.submitIntentClarifications,
|
||||
};
|
||||
// 定制 chrome 仅服务第一阶段;第二、三阶段继续保留原有步骤导航与操作,
|
||||
// 避免独立业务页为了复刻第一步视觉而收窄后续流程的可达性。
|
||||
const useCustomStep1Chrome = currentStep === 1 && Boolean(headerLeftSlot);
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────
|
||||
return (
|
||||
<div
|
||||
data-roundtable-step={currentStep}
|
||||
className={cn(
|
||||
"roundtable-planning theme-transition relative flex h-full min-h-0 flex-col bg-[#fef6f6] font-sans text-slate-800 selection:bg-red-200",
|
||||
isDark && "roundtable-planning--dark",
|
||||
@@ -2013,6 +2288,15 @@ export default function RoundtablePlanningPage({
|
||||
}}
|
||||
/>
|
||||
|
||||
{taskArtifactSubmission && (
|
||||
<AnalysisResultDetailDialog
|
||||
item={seatDeliverable}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSeatDeliverable(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showToast && (
|
||||
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 bg-primary text-white text-xs px-4 py-2.5 rounded-xl shadow-lg border border-primary/40 font-medium flex items-center gap-2 animate-bounce">
|
||||
<Sparkles className="h-4 w-4 shrink-0" />
|
||||
@@ -2022,13 +2306,15 @@ export default function RoundtablePlanningPage({
|
||||
|
||||
{/* HEADER */}
|
||||
<header ref={headerDzRef} className="relative z-[40] flex shrink-0 items-center justify-between gap-4 border-b border-slate-100 bg-white px-6 py-3 shadow-[0_1px_3px_rgba(0,0,0,0.02)] dark:border-border dark:bg-card dark:shadow-none">
|
||||
<StepNavigation
|
||||
currentStep={currentStep}
|
||||
intentReady={step1.intentReady}
|
||||
hasConsensus={step2.hasConsensus}
|
||||
onStepClick={(s) => setCurrentStep(s)}
|
||||
triggerToast={triggerToast}
|
||||
/>
|
||||
{useCustomStep1Chrome ? headerLeftSlot : (
|
||||
<StepNavigation
|
||||
currentStep={currentStep}
|
||||
intentReady={step1.intentReady}
|
||||
hasConsensus={step2.hasConsensus}
|
||||
onStepClick={(s) => setCurrentStep(s)}
|
||||
triggerToast={triggerToast}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{toolbarSlot}
|
||||
@@ -2077,18 +2363,20 @@ export default function RoundtablePlanningPage({
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-700 text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all shadow-sm shadow-emerald-500/10 active:scale-95"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
新建任务
|
||||
{useCustomStep1Chrome ? (newTaskLabel ?? "新建任务") : "新建任务"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
id="save-draft-header-btn"
|
||||
onClick={drafts.handleManualSave}
|
||||
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5 text-slate-500" />
|
||||
保存草稿
|
||||
</button>
|
||||
{(!hideHeaderSaveDraft || !useCustomStep1Chrome) && (
|
||||
<button
|
||||
id="save-draft-header-btn"
|
||||
onClick={drafts.handleManualSave}
|
||||
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5 text-slate-500" />
|
||||
保存草稿
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 「导入会商」按钮先隐藏(保留代码 + 弹窗逻辑,便于以后恢复)。 */}
|
||||
{false && (
|
||||
@@ -2126,22 +2414,26 @@ export default function RoundtablePlanningPage({
|
||||
onCancelRename={drafts.handleCancelRename}
|
||||
/>
|
||||
|
||||
<div className="h-6 w-[1px] bg-slate-200 mx-1" />
|
||||
{useCustomStep1Chrome && headerActionAfterHistory}
|
||||
|
||||
<button
|
||||
id="settings-btn"
|
||||
onClick={handleSettingsClick}
|
||||
title={
|
||||
inWujieEmbed
|
||||
? "回传各智能体交付结果给宿主"
|
||||
: getStoredAuth()?.system_role === "admin"
|
||||
? "会商诊断日志"
|
||||
: "系统设置"
|
||||
}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 rounded-lg hover:bg-slate-100 transition-colors dark:text-muted-foreground dark:hover:bg-accent dark:hover:text-foreground"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
{(!hideHeaderSettings || !useCustomStep1Chrome) && <div className="h-6 w-[1px] bg-slate-200 mx-1" />}
|
||||
|
||||
{(!hideHeaderSettings || !useCustomStep1Chrome) && (
|
||||
<button
|
||||
id="settings-btn"
|
||||
onClick={handleSettingsClick}
|
||||
title={
|
||||
inWujieEmbed
|
||||
? "回传各智能体交付结果给宿主"
|
||||
: getStoredAuth()?.system_role === "admin"
|
||||
? "会商诊断日志"
|
||||
: "系统设置"
|
||||
}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 rounded-lg hover:bg-slate-100 transition-colors dark:text-muted-foreground dark:hover:bg-accent dark:hover:text-foreground"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{compactChrome && (
|
||||
<>
|
||||
<ThemeSwitcher tone="content" />
|
||||
@@ -2157,7 +2449,12 @@ export default function RoundtablePlanningPage({
|
||||
sidebarExpanded={step2SidebarExpanded}
|
||||
setSidebarExpanded={setStep2SidebarExpanded}
|
||||
/>
|
||||
<main className="relative z-0 flex-1 overflow-hidden p-4 flex gap-4 min-h-0">
|
||||
<main
|
||||
data-step2-sidebar-expanded={
|
||||
currentStep === 2 || currentStep === 3 ? String(step2SidebarExpanded) : undefined
|
||||
}
|
||||
className="relative z-0 flex-1 overflow-hidden p-4 flex gap-4 min-h-0"
|
||||
>
|
||||
{/* LEFT SIDEBAR */}
|
||||
<LeftSidebar
|
||||
currentStep={currentStep}
|
||||
@@ -2176,12 +2473,15 @@ export default function RoundtablePlanningPage({
|
||||
onSelectChain={handleSelectChainFromSidebar}
|
||||
onOpenDraft={drafts.handleLoadDraft}
|
||||
activeDraftId={drafts.currentDraftId}
|
||||
entryRailTaskId={recordTaskIdOf(prefillRef.current)}
|
||||
entryRailTaskId={entryRailTaskId ?? recordTaskIdOf(prefillRef.current)}
|
||||
historyTaskId={draftStorageTaskId}
|
||||
entryRailBusinessCode={prefillRef.current?.businessCode ?? null}
|
||||
lockedChainId={lockedChainId}
|
||||
lockBusinessChain={lockBusinessChain}
|
||||
entryRailBusinessChainSlot={entryRailBusinessChainSlot}
|
||||
entryRailTaskCardSlot={entryRailTaskCardSlot}
|
||||
renderStep1LeftSidebar={renderStep1LeftSidebarWithDeliveries}
|
||||
renderStep2LeftSidebarHeader={renderStep2LeftSidebarHeader}
|
||||
/>
|
||||
|
||||
{/* MIDDLE VIEWPORT */}
|
||||
@@ -2190,7 +2490,18 @@ export default function RoundtablePlanningPage({
|
||||
className="flex-1 flex flex-col min-w-0 bg-white border border-slate-200/50 rounded-2xl shadow-[0_4px_16px_rgba(0,0,0,0.015)] overflow-hidden"
|
||||
>
|
||||
{currentStep === 1 && (
|
||||
<Step1Panel
|
||||
renderStep1 ? renderStep1({
|
||||
...step1PanelProps,
|
||||
selectedModel,
|
||||
modelOptions: availableModels.map((model) => ({ label: model.name, value: model.name })),
|
||||
onSelectedModelChange: setSelectedModel,
|
||||
step2Dialogues: step2.step2RoundtableDialogues,
|
||||
step2ThreadIds: step2.threadIds,
|
||||
step2CoordinatorName: step2.coordinatorName,
|
||||
step2SeatStubMessages: step2.seatStubMessages,
|
||||
step3Summary: step3Report?.summaryReport ?? null,
|
||||
}) : <Step1Panel
|
||||
{...step1PanelProps}
|
||||
headerLeftSlot={
|
||||
// 「原任务已有结果」(目的树预览)只在 rwfx-A(6BF)模式显示;xdfx/3qfx 不显示。
|
||||
prefillRef.current?.taskId &&
|
||||
@@ -2203,60 +2514,16 @@ export default function RoundtablePlanningPage({
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
interactiveMessages={step1.interactiveMessages}
|
||||
intentThreadId={step1.intentThreadId}
|
||||
intentReady={step1.intentReady}
|
||||
isIntentSending={step1.isIntentSending}
|
||||
pendingIntentClarificationMsgId={step1.pendingIntentClarificationMsgId}
|
||||
intentClarificationSelections={step1.intentClarificationSelections}
|
||||
setIntentClarificationSelections={step1.setIntentClarificationSelections}
|
||||
onSendMessage={(text, files) => void step1.sendIntentMessage(text, files)}
|
||||
onStopIntent={step1.stopIntentStream}
|
||||
onSkipQuestion={handleSkipQuestion}
|
||||
onFollowUp={handleAgentFollowUp}
|
||||
onEnterRoundtable={() => {
|
||||
if (!step1.intentReady) {
|
||||
triggerToast("请先与任务理解智能体完成澄清,生成任务摘要后再进入");
|
||||
return;
|
||||
}
|
||||
// 会商自动化串联(模块 C):若任务页带来的业务链已解析成功,跳过选链弹窗,
|
||||
// 直接据该业务链进入 Step 2 研讨(用户无需再手选)。
|
||||
if (autoChainResult) {
|
||||
const result = autoChainResult;
|
||||
void drafts.ensureDraft(1).finally(() => handleConfirmAgents(result));
|
||||
return;
|
||||
}
|
||||
if (lockBusinessChain) {
|
||||
triggerToast(
|
||||
autoChainLoading
|
||||
? "正在加载管理员配置的业务链,请稍候…"
|
||||
: "管理员尚未配置可用的业务链,请联系管理员处理",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
inWujieEmbed &&
|
||||
autoChainLoading &&
|
||||
(prefillRef.current?.chainId || prefillRef.current?.businessCode)
|
||||
) {
|
||||
triggerToast("正在加载业务链条,请稍候…");
|
||||
return;
|
||||
}
|
||||
// 先把草稿落地拿到 draft_id,推荐历史才有归属;失败也照常打开弹窗
|
||||
// (弹窗在无 draftId 时退化为「直接推荐、不存历史」)。
|
||||
void drafts.ensureDraft(1).finally(() => setIsRecommendDialogOpen(true));
|
||||
}}
|
||||
onManualSave={drafts.handleManualSave}
|
||||
onTriggerToast={triggerToast}
|
||||
submitIntentClarifications={step1.submitIntentClarifications}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<Step2Panel
|
||||
useStep1InputAppearance={rootClassName?.includes("eightbf-roundtable") ?? false}
|
||||
headerTitle={step2HeaderTitle}
|
||||
headerRightSlot={step2HeaderRightSlot}
|
||||
step2RoundtableDialogues={step2.step2RoundtableDialogues}
|
||||
isInitializing={step2.isInitializing}
|
||||
isRoundTableRunning={step2.isRoundTableRunning}
|
||||
isOrchestrating={step2.isOrchestrating}
|
||||
pendingClarification={step2.pendingClarification}
|
||||
clarificationDraft={step2.clarificationDraft}
|
||||
@@ -2284,7 +2551,6 @@ export default function RoundtablePlanningPage({
|
||||
openArtifactFromStep={step2.openArtifactFromStep}
|
||||
openArtifactPreview={step2.openArtifactPreview}
|
||||
onPauseRoundtable={step2.pauseRoundtable}
|
||||
onResumeRoundtable={step2.resumeRoundtable}
|
||||
onHandleInjectToRoundtable={step2.handleInjectToRoundtable}
|
||||
setClarificationDraft={step2.setClarificationDraft}
|
||||
setCustomAgentInput={step2.setCustomAgentInput}
|
||||
@@ -2305,11 +2571,14 @@ export default function RoundtablePlanningPage({
|
||||
onBackgroundSuspend={handleBackgroundSuspend}
|
||||
isViewingHistory={drafts.loadedFromDraftRef.current}
|
||||
onTriggerToast={triggerToast}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<Step3Panel
|
||||
useStep1Chrome={rootClassName?.includes("eightbf-roundtable") ?? false}
|
||||
headerTitle={step3HeaderTitle}
|
||||
threadIds={step2.threadIds}
|
||||
coordinatorName={step2.coordinatorName}
|
||||
consensusText={step2.lastLeaderContent}
|
||||
@@ -2334,12 +2603,13 @@ export default function RoundtablePlanningPage({
|
||||
onReset={handleStartNewTask}
|
||||
openArtifactPreview={step2.openArtifactPreview}
|
||||
seatStubMessages={step2.seatStubMessages}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* RIGHT SIDEBAR */}
|
||||
<RightSidebar
|
||||
{!hideDefaultStep1RightSidebar && <RightSidebar
|
||||
currentStep={currentStep}
|
||||
selectedModel={selectedModel}
|
||||
setSelectedModel={setSelectedModel}
|
||||
@@ -2349,7 +2619,7 @@ export default function RoundtablePlanningPage({
|
||||
timelineEntries={step1.timelineEntries}
|
||||
triggerToast={triggerToast}
|
||||
onSaveIntent={handleSaveIntent}
|
||||
/>
|
||||
/>}
|
||||
</main>
|
||||
|
||||
{/* 深链 taskId 模式:底部「快捷跳转」栏(与单智能体问答共用,按业务取按钮管理配置)。 */}
|
||||
@@ -2743,6 +3013,20 @@ interface LeftSidebarProps {
|
||||
lockedChainId?: string | null;
|
||||
/** 第一阶段替换默认业务链选择区的自定义内容。 */
|
||||
entryRailBusinessChainSlot?: ReactNode;
|
||||
/** 第一阶段由独立业务页提供的自定义任务卡片。 */
|
||||
entryRailTaskCardSlot?: ReactNode;
|
||||
/** 独立业务页替换第一步左侧工作区;仅提供已解析的意图状态。 */
|
||||
renderStep1LeftSidebar?: (context: {
|
||||
intentReady: object | null;
|
||||
onCollapse?: () => void;
|
||||
submittedSeatKeys?: string[];
|
||||
onSeatClick?: (seat: ChainSeat) => void;
|
||||
}) => ReactNode;
|
||||
/** 独立业务页在第二步左栏顶部复用任务与意图区。 */
|
||||
renderStep2LeftSidebarHeader?: (context: {
|
||||
intentReady: object | null;
|
||||
onCollapse: () => void;
|
||||
}) => ReactNode;
|
||||
}
|
||||
|
||||
function Step2SandboxSidebarSync({
|
||||
@@ -2756,13 +3040,21 @@ function Step2SandboxSidebarSync({
|
||||
}) {
|
||||
const { open } = useArtifacts();
|
||||
const prevOpenRef = useRef(open);
|
||||
const prevStepRef = useRef(currentStep);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep !== 2) {
|
||||
const enteredCollapsibleStep =
|
||||
(currentStep === 2 || currentStep === 3) &&
|
||||
prevStepRef.current !== currentStep;
|
||||
prevStepRef.current = currentStep;
|
||||
|
||||
if (currentStep !== 2 && currentStep !== 3) {
|
||||
prevOpenRef.current = open;
|
||||
return;
|
||||
}
|
||||
if (open && !prevOpenRef.current && sidebarExpanded) {
|
||||
const openedNow = open && !prevOpenRef.current;
|
||||
const landedWithSandbox = enteredCollapsibleStep && open;
|
||||
if ((openedNow || landedWithSandbox) && sidebarExpanded) {
|
||||
setSidebarExpanded(false);
|
||||
}
|
||||
prevOpenRef.current = open;
|
||||
@@ -2794,6 +3086,9 @@ function LeftSidebar({
|
||||
lockBusinessChain,
|
||||
lockedChainId,
|
||||
entryRailBusinessChainSlot,
|
||||
entryRailTaskCardSlot,
|
||||
renderStep1LeftSidebar,
|
||||
renderStep2LeftSidebarHeader,
|
||||
}: LeftSidebarProps) {
|
||||
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
|
||||
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
|
||||
@@ -2803,18 +3098,48 @@ function LeftSidebar({
|
||||
const toggleSeatFilter = (filter: SeatFilter) =>
|
||||
setSeatFilter((cur) => (cur === filter ? null : filter));
|
||||
|
||||
// Step 3 隐藏左侧栏(原「参与与结论贡献」列),让中间「最终方案展示」自适应
|
||||
// 撑满整行宽度(右侧栏在 Step !==1 时也已 return null)。
|
||||
if (currentStep === 3) return null;
|
||||
// Step 3:通用会商隐藏左栏以让方案展示撑满;8BF 复用第一步左栏,并和第二步一样可收起。
|
||||
if (currentStep === 3) {
|
||||
if (!renderStep1LeftSidebar) return null;
|
||||
return (
|
||||
<section
|
||||
ref={dzRef}
|
||||
id="left-sidebar"
|
||||
data-sidebar-expanded={String(sidebarExpanded)}
|
||||
className={cn(
|
||||
"min-h-0 shrink-0 flex flex-col gap-3 pr-1 transition-[width] duration-200 ease-out overflow-y-auto",
|
||||
sidebarExpanded ? "w-[280px]" : "w-[80px]",
|
||||
)}
|
||||
>
|
||||
{!sidebarExpanded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarExpanded(true)}
|
||||
title="展开侧栏"
|
||||
aria-expanded={false}
|
||||
className="w-full shrink-0 flex h-10 items-center justify-center rounded-[4px] border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
renderStep1LeftSidebar({
|
||||
intentReady,
|
||||
onCollapse: () => setSidebarExpanded(false),
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={dzRef}
|
||||
id="left-sidebar"
|
||||
data-sidebar-expanded={isStep2 ? String(sidebarExpanded) : undefined}
|
||||
className={cn(
|
||||
"min-h-0 shrink-0 flex flex-col gap-3 pr-1 transition-[width] duration-200 ease-out",
|
||||
isStep2 ? "overflow-y-auto" : "overflow-hidden",
|
||||
isStep2 && !sidebarExpanded ? "w-[56px]" : "w-[280px]",
|
||||
isStep2 && !sidebarExpanded ? "w-[80px]" : "w-[280px]",
|
||||
)}
|
||||
>
|
||||
{isStep2 && !sidebarExpanded && (
|
||||
@@ -2823,7 +3148,7 @@ function LeftSidebar({
|
||||
onClick={() => setSidebarExpanded(true)}
|
||||
title="展开侧栏"
|
||||
aria-expanded={false}
|
||||
className="w-full shrink-0 flex h-10 items-center justify-center rounded-2xl border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
|
||||
className="w-full shrink-0 flex h-10 items-center justify-center rounded-[4px] border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -2832,15 +3157,20 @@ function LeftSidebar({
|
||||
{currentStep === 2 ? (
|
||||
sidebarExpanded ? (
|
||||
<>
|
||||
<IntentSummaryCard
|
||||
intentReady={intentReady}
|
||||
title="意图理解"
|
||||
collapsible
|
||||
sidebarToggle={{
|
||||
expanded: sidebarExpanded,
|
||||
onToggle: () => setSidebarExpanded((v) => !v),
|
||||
}}
|
||||
/>
|
||||
{renderStep2LeftSidebarHeader?.({
|
||||
intentReady,
|
||||
onCollapse: () => setSidebarExpanded(false),
|
||||
}) ?? (
|
||||
<IntentSummaryCard
|
||||
intentReady={intentReady}
|
||||
title="意图理解"
|
||||
collapsible
|
||||
sidebarToggle={{
|
||||
expanded: sidebarExpanded,
|
||||
onToggle: () => setSidebarExpanded((v) => !v),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[240px]">
|
||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
|
||||
@@ -2926,18 +3256,21 @@ function LeftSidebar({
|
||||
)
|
||||
) : (
|
||||
// Step 1(落地,未开始研讨):左栏改为业务链 + 分析记录入口(需求 0616 #2)。
|
||||
<RoundtableEntryRail
|
||||
selectedChainId={selectedChainId}
|
||||
onSelectChain={onSelectChain}
|
||||
onOpenDraft={onOpenDraft}
|
||||
activeDraftId={activeDraftId}
|
||||
taskId={entryRailTaskId ?? null}
|
||||
historyTaskId={historyTaskId ?? null}
|
||||
businessCode={entryRailBusinessCode ?? null}
|
||||
lockBusinessChain={lockBusinessChain}
|
||||
lockedChainId={lockedChainId ?? null}
|
||||
businessChainSlot={entryRailBusinessChainSlot}
|
||||
/>
|
||||
renderStep1LeftSidebar?.({ intentReady }) ?? (
|
||||
<RoundtableEntryRail
|
||||
selectedChainId={selectedChainId}
|
||||
onSelectChain={onSelectChain}
|
||||
onOpenDraft={onOpenDraft}
|
||||
activeDraftId={activeDraftId}
|
||||
taskId={entryRailTaskId ?? null}
|
||||
historyTaskId={historyTaskId ?? null}
|
||||
businessCode={entryRailBusinessCode ?? null}
|
||||
lockBusinessChain={lockBusinessChain}
|
||||
lockedChainId={lockedChainId ?? null}
|
||||
businessChainSlot={entryRailBusinessChainSlot}
|
||||
taskCardSlot={entryRailTaskCardSlot}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
@@ -2992,6 +3325,7 @@ function RoundtableEntryRail({
|
||||
lockedChainId,
|
||||
lockBusinessChain = false,
|
||||
businessChainSlot,
|
||||
taskCardSlot,
|
||||
}: {
|
||||
selectedChainId: string | null;
|
||||
onSelectChain: (chain: BusinessChain) => void;
|
||||
@@ -3007,6 +3341,7 @@ function RoundtableEntryRail({
|
||||
lockedChainId?: string | null;
|
||||
lockBusinessChain?: boolean;
|
||||
businessChainSlot?: ReactNode;
|
||||
taskCardSlot?: ReactNode;
|
||||
}) {
|
||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||
const [chainSectionExpanded, setChainSectionExpanded] = useState(true);
|
||||
@@ -3061,7 +3396,8 @@ function RoundtableEntryRail({
|
||||
void listChains("public")
|
||||
.then((all) => {
|
||||
if (!cancelled) {
|
||||
setChains(lockBusinessChain ? all.filter((chain) => chain.id === lockedChainId) : all);
|
||||
const usable = all.filter(isChainEnabled);
|
||||
setChains(lockBusinessChain ? usable.filter((chain) => chain.id === lockedChainId) : usable);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -3093,7 +3429,12 @@ function RoundtableEntryRail({
|
||||
)}
|
||||
>
|
||||
{/* 深链接(taskId)模式:左上角展示「当前任务」(rwfx/3qfx)或「当前行动」(xdfx),替代业务链选择。 */}
|
||||
{deeplink ? (
|
||||
{taskCardSlot ? (
|
||||
<>
|
||||
{taskCardSlot}
|
||||
{lockBusinessChain && businessChainSlot}
|
||||
</>
|
||||
) : deeplink ? (
|
||||
isXdfx ? (
|
||||
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)]">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
@@ -3535,7 +3876,7 @@ function Step2RunSwitcher({
|
||||
type="button"
|
||||
onClick={() => onSelect(run.id)}
|
||||
className={cn(
|
||||
"w-full rounded-xl border p-2.5 text-left transition-colors",
|
||||
"roundtable-step2-run-card w-full rounded-xl border p-2.5 text-left transition-colors",
|
||||
active
|
||||
? "border-primary/40 bg-primary/5 text-slate-900"
|
||||
: "border-slate-100 bg-slate-50/60 text-slate-600 hover:border-primary/20 hover:bg-white",
|
||||
@@ -3646,7 +3987,7 @@ function HumanParticipationCard({
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3 transition",
|
||||
"roundtable-step2-human-row flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3 transition",
|
||||
clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5",
|
||||
active && "ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40",
|
||||
)}
|
||||
@@ -4033,7 +4374,7 @@ function CollapsedSidebarSection({
|
||||
<div
|
||||
title={title}
|
||||
className={cn(
|
||||
"w-full shrink-0 bg-white border border-slate-200/50 rounded-2xl p-2 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex flex-col items-center justify-center",
|
||||
"w-full shrink-0 bg-white border border-slate-200/50 rounded-[4px] p-2 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex flex-col items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -4170,7 +4511,7 @@ function RoleRow({
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition",
|
||||
"roundtable-step2-role-row flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition",
|
||||
isActive
|
||||
? "bg-slate-50 border border-slate-100"
|
||||
: isBootingUp
|
||||
|
||||
@@ -3,7 +3,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import type { CompactMenuOverride } from "@/core/page-layout/compact-menu-overrides";
|
||||
import {
|
||||
getRuntimeCompactMenuType,
|
||||
isDefaultCompactMenuType,
|
||||
type CompactMenuOverride,
|
||||
type CompactMenuType,
|
||||
} from "@/core/page-layout/compact-menu-overrides";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/compact-menu-overrides`;
|
||||
|
||||
@@ -12,6 +17,7 @@ interface ServerCompactOverride {
|
||||
placement: "sidebar" | "settings";
|
||||
sort_order: number;
|
||||
label: string | null;
|
||||
menu_type?: string | null;
|
||||
}
|
||||
|
||||
function toOverride(s: ServerCompactOverride): CompactMenuOverride {
|
||||
@@ -44,27 +50,40 @@ async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function listCompactMenuOverrides(): Promise<CompactMenuOverride[]> {
|
||||
const res = await apiFetch(BASE(), { method: "GET" });
|
||||
export async function listCompactMenuOverrides(
|
||||
menuType: CompactMenuType = getRuntimeCompactMenuType(),
|
||||
): Promise<CompactMenuOverride[]> {
|
||||
// 旧页面 / 未配置 VITE_COMPACT_MENU_TYPE 时不带参数,后端按类型 A 返回原来的数据。
|
||||
const url = isDefaultCompactMenuType(menuType)
|
||||
? BASE()
|
||||
: `${BASE()}?menu_type=${encodeURIComponent(menuType)}`;
|
||||
const res = await apiFetch(url, { method: "GET" });
|
||||
const data = await readJson<{ overrides: ServerCompactOverride[] }>(res, "list compact menu overrides");
|
||||
return (data.overrides ?? []).map(toOverride);
|
||||
}
|
||||
|
||||
export async function replaceCompactMenuOverrides(
|
||||
overrides: CompactMenuOverride[],
|
||||
menuType: CompactMenuType = getRuntimeCompactMenuType(),
|
||||
): Promise<CompactMenuOverride[]> {
|
||||
const body: { overrides: ServerCompactOverride[]; menu_type?: CompactMenuType } = {
|
||||
overrides: overrides.map(toBody),
|
||||
};
|
||||
if (!isDefaultCompactMenuType(menuType)) {
|
||||
body.menu_type = menuType;
|
||||
}
|
||||
const res = await apiFetch(BASE(), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ overrides: overrides.map(toBody) }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await readJson<{ overrides: ServerCompactOverride[] }>(res, "save compact menu overrides");
|
||||
const saved = (data.overrides ?? []).map(toOverride);
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "CM助手-简介-菜单管理-保存",
|
||||
optDetail: `CM助手-简介-菜单管理-保存-类型${menuType}`,
|
||||
url: "/api/compact-menu-overrides",
|
||||
reqParam: { overrides },
|
||||
reqParam: { menuType, overrides },
|
||||
returnParam: saved,
|
||||
});
|
||||
return saved;
|
||||
@@ -72,21 +91,23 @@ export async function replaceCompactMenuOverrides(
|
||||
|
||||
const COMPACT_MENU_OVERRIDES_KEY = "compact-menu-overrides";
|
||||
|
||||
export function useCompactMenuOverrides() {
|
||||
export function useCompactMenuOverrides(menuType?: CompactMenuType) {
|
||||
const type = menuType ?? getRuntimeCompactMenuType();
|
||||
return useQuery({
|
||||
queryKey: [COMPACT_MENU_OVERRIDES_KEY],
|
||||
queryFn: listCompactMenuOverrides,
|
||||
queryKey: [COMPACT_MENU_OVERRIDES_KEY, type],
|
||||
queryFn: () => listCompactMenuOverrides(type),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCompactMenuOverrideActions() {
|
||||
export function useCompactMenuOverrideActions(menuType?: CompactMenuType) {
|
||||
const queryClient = useQueryClient();
|
||||
const type = menuType ?? getRuntimeCompactMenuType();
|
||||
return {
|
||||
save: useMutation({
|
||||
mutationFn: replaceCompactMenuOverrides,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: [COMPACT_MENU_OVERRIDES_KEY] }),
|
||||
mutationFn: (overrides: CompactMenuOverride[]) => replaceCompactMenuOverrides(overrides, type),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: [COMPACT_MENU_OVERRIDES_KEY, type] }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
import type {
|
||||
DocumentRewriteSummary,
|
||||
DocumentRewriteStreamEvent,
|
||||
@@ -355,7 +356,7 @@ export async function startJob(
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
request_id: opts.requestId ?? crypto.randomUUID(),
|
||||
request_id: opts.requestId ?? createClientId(),
|
||||
entry: opts.entry ?? "legacy",
|
||||
config: opts.config ?? null,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 深度研究「工作流配置 / 报告结构」API 客户端 + React Query hooks。
|
||||
*
|
||||
* 后端:`/api/report-structures`(见 app/gateway/routers/report_structures.py)。
|
||||
* 全局共享、无用户隔离;新增永远默认启用(服务端强制,不收 enabled 字段)。
|
||||
* snake_case ⇄ camelCase 在此边界收敛。
|
||||
*/
|
||||
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface ReportStructure {
|
||||
id: string;
|
||||
/** 标题:卡片主标签,也是列表「标题筛选」的目标。 */
|
||||
title: string;
|
||||
/** 描述:一句话说明这套结构的适用场景(存储列沿用 name)。 */
|
||||
name: string;
|
||||
/** 接口内容:选中卡片后应用到报告结构(custom_outline)的 Markdown 文本。 */
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
/** 创建用户名(本部署为邮箱;系统内置种子为“系统内置”)。 */
|
||||
createdBy: string | null;
|
||||
createdAt: string | null;
|
||||
/** 最后修改时间(编辑、启停都会刷新)。 */
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export type ReportStructureInput = {
|
||||
title: string;
|
||||
name: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type ReportStructurePatch = Partial<ReportStructureInput>;
|
||||
|
||||
export type ReportStructureStatusFilter = "all" | "enabled" | "disabled";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/report-structures`;
|
||||
|
||||
interface ServerStructure {
|
||||
id: string;
|
||||
title: string;
|
||||
name: string;
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
created_by: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
function toStructure(s: ServerStructure): ReportStructure {
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
name: s.name ?? "",
|
||||
content: s.content,
|
||||
enabled: s.enabled ?? true,
|
||||
createdBy: s.created_by ?? null,
|
||||
createdAt: s.created_at ?? null,
|
||||
updatedAt: s.updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null as unknown);
|
||||
const detail =
|
||||
data && typeof data === "object" && "detail" in data
|
||||
? String((data as { detail: unknown }).detail)
|
||||
: `${ctx} failed (${res.status})`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function listReportStructures(params: {
|
||||
status?: ReportStructureStatusFilter;
|
||||
title?: string;
|
||||
} = {}): Promise<ReportStructure[]> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.status === "enabled") qs.set("enabled", "true");
|
||||
if (params.status === "disabled") qs.set("enabled", "false");
|
||||
const title = params.title?.trim();
|
||||
if (title) qs.set("title", title);
|
||||
const query = qs.toString();
|
||||
const res = await apiFetch(`${BASE()}${query ? `?${query}` : ""}`, { method: "GET" });
|
||||
const data = await readJson<{ structures: ServerStructure[] }>(res, "list report structures");
|
||||
return (data.structures ?? []).map(toStructure);
|
||||
}
|
||||
|
||||
export async function createReportStructure(input: ReportStructureInput): Promise<ReportStructure> {
|
||||
const res = await apiFetch(BASE(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: input.title, name: input.name, content: input.content }),
|
||||
});
|
||||
return toStructure(await readJson<ServerStructure>(res, "create report structure"));
|
||||
}
|
||||
|
||||
export async function updateReportStructure(
|
||||
id: string,
|
||||
patch: ReportStructurePatch,
|
||||
): Promise<ReportStructure> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (patch.title !== undefined) body.title = patch.title;
|
||||
if (patch.name !== undefined) body.name = patch.name;
|
||||
if (patch.content !== undefined) body.content = patch.content;
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return toStructure(await readJson<ServerStructure>(res, "update report structure"));
|
||||
}
|
||||
|
||||
export async function setReportStructureEnabled(id: string, enabled: boolean): Promise<ReportStructure> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}/enabled`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
return toStructure(await readJson<ServerStructure>(res, "toggle report structure"));
|
||||
}
|
||||
|
||||
export async function deleteReportStructure(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
// 204 无响应体,不能再走 readJson 的 res.json()。
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => null)) as { detail?: unknown } | null;
|
||||
throw new Error(
|
||||
data && typeof data.detail === "string" ? data.detail : `delete report structure failed (${res.status})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── React Query hooks(工作流配置弹窗内各视图共用缓存) ──────────────────────
|
||||
|
||||
const REPORT_STRUCTURES_KEY = "report-structures";
|
||||
|
||||
export function useReportStructures(params: { status?: ReportStructureStatusFilter; title?: string } = {}) {
|
||||
return useQuery({
|
||||
queryKey: [REPORT_STRUCTURES_KEY, "list", params.status ?? "all", params.title ?? ""],
|
||||
queryFn: () => listReportStructures(params),
|
||||
placeholderData: keepPreviousData,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useReportStructureMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: [REPORT_STRUCTURES_KEY] });
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (input: ReportStructureInput) => createReportStructure(input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, patch }: { id: string; patch: ReportStructurePatch }) =>
|
||||
updateReportStructure(id, patch),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const toggleEnabled = useMutation({
|
||||
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
|
||||
setReportStructureEnabled(id, enabled),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => deleteReportStructure(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, update, toggleEnabled, remove };
|
||||
}
|
||||
+609
@@ -0,0 +1,609 @@
|
||||
# Offline backend template for DeerFlow
|
||||
config_version: 8
|
||||
log_level: info
|
||||
|
||||
# Gateway FastAPI 监听端口。圆桌三路由(intent / recommend / multi_agent)的同进程
|
||||
# loopback 调用会拼 http://127.0.0.1:<port> 打到本机其它 endpoint,所以必须与
|
||||
# 实际监听端口一致(scripts/start-all.sh、Makefile 默认 8001)。
|
||||
# 部署机改了端口请同步改这里;也可设环境变量 DEER_FLOW_GATEWAY_PORT 临时覆盖。
|
||||
gateway:
|
||||
port: 8001
|
||||
|
||||
# Optional Redis-backed global concurrency coordination.
|
||||
# Leave disabled to keep the existing in-process behavior.
|
||||
# When enabled, Redis operations are short-timeout and fail-open by default:
|
||||
# if Redis is slow or unavailable, user Q&A continues with local-only limits.
|
||||
concurrency:
|
||||
redis:
|
||||
enabled: false
|
||||
url: redis://127.0.0.1:6379/0
|
||||
password: null
|
||||
# Prefix with the deployment/system id because Redis may be shared by
|
||||
# multiple projects. Keep this unique per system to avoid cross-project
|
||||
# leases affecting each other.
|
||||
namespace: cmzs_deerflow:concurrency
|
||||
socket_connect_timeout_ms: 80
|
||||
socket_timeout_ms: 80
|
||||
acquire_timeout_ms: 120
|
||||
operation_timeout_ms: 80
|
||||
circuit_breaker_failures: 3
|
||||
circuit_breaker_cooldown_seconds: 30
|
||||
lease_ttl_seconds: 90
|
||||
heartbeat_interval_seconds: 20
|
||||
fail_open: true
|
||||
|
||||
models:
|
||||
- name: deepseek-chat
|
||||
display_name: DeepSeek Chat
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-chat
|
||||
api_key: $DEEPSEEK_CHAT_API_KEY
|
||||
base_url: https://api.deepseek.com
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: false
|
||||
supports_thinking: true
|
||||
- name: zai-org/GLM-5-FP8
|
||||
display_name: zai-org/GLM-5-FP8
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: zai-org/GLM-5-FP8
|
||||
api_key: $GLM5_FP8_API_KEY
|
||||
base_url: https://api.us-west-2.modal.direct/v2
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
supports_reasoning_effort: false
|
||||
- name: deepseek-ai/DeepSeek-V4-Flash
|
||||
display_name: deepseek-ai/DeepSeek-V4-Flash
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-ai/DeepSeek-V4-Flash
|
||||
api_key: $SILICONFLOW_API_KEY
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
when_thinking_disabled:
|
||||
extra_body:
|
||||
enable_thinking: false
|
||||
- name: inclusionAI/Ling-flash-2.0
|
||||
display_name: inclusionAI/Ling-flash-2.0
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: inclusionAI/Ling-flash-2.0
|
||||
api_key: $SILICONFLOW_API_KEY
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: deepseek-ai/DeepSeek-V4-Pro
|
||||
display_name: deepseek-ai/DeepSeek-V4-Pro
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-ai/DeepSeek-V4-Pro
|
||||
api_key: $SILICONFLOW_API_KEY
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: qwen3.6-flash
|
||||
display_name: qwen3.6-flash
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: qwen3.6-flash
|
||||
api_key: sk-sp-H.XDIPL.Zgvs.MEQCIBasaS2RPHwgNUpGVfFJeRaTp5UgmZ4Y2rCVGNtpwfD1AiBTFg3S-uE_gqVDnHgdyEhDwcfaVRLi92xviye80AZnkg
|
||||
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
|
||||
request_timeout: 30.0
|
||||
max_retries: 2
|
||||
stream_usage: true
|
||||
supports_thinking: true
|
||||
supports_reasoning_effort: false
|
||||
supports_vision: false
|
||||
- name: qwen3.6
|
||||
display_name: qwen3.6
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: qwen3.6
|
||||
api_key: sk-6AqP_UYpPYJKdclYWR-FSA
|
||||
base_url: http://119.254.86.251:8001/v1
|
||||
request_timeout: 30.0
|
||||
max_retries: 2
|
||||
stream_usage: true
|
||||
supports_thinking: false
|
||||
supports_reasoning_effort: false
|
||||
supports_vision: false
|
||||
# - name: deepseek-v4-flash
|
||||
# display_name: DeepSeek V4 Flash
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: deepseek-v4-flash
|
||||
# api_key: sk-3fee562f834a4c08bb0d471ef44cb445
|
||||
# base_url: https://api.deepseek.com
|
||||
# request_timeout: 600.0
|
||||
# max_retries: 2
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: false
|
||||
# supports_thinking: true
|
||||
# - name: deepseek-v4-pro
|
||||
# display_name: DeepSeek V4 Pro
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: deepseek-v4-pro
|
||||
# api_key: sk-e1c22b7931dc446f8b67e57878edd105
|
||||
# base_url: https://api.deepseek.com
|
||||
# request_timeout: 600.0
|
||||
# max_retries: 2
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: false
|
||||
# supports_thinking: true
|
||||
# - name: MiniMax-M2.7
|
||||
# display_name: MiniMax-M2.7
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: MiniMax-M2.7
|
||||
# api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
|
||||
# base_url: https://api.minimaxi.com/v1
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_thinking: true
|
||||
# - name: glm-5.1
|
||||
# display_name: glm-5.1
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: glm-5.1
|
||||
# api_key: 75121e4bed7640d5b71ac95cf8b4310a.RA3GyRBAR3kv7VrR
|
||||
# base_url: https://open.bigmodel.cn/api/paas/v4/
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_thinking: true
|
||||
# - name: deepseek-ai/DeepSeek-OCR
|
||||
# display_name: deepseek-ai/DeepSeek-OCR
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: deepseek-ai/DeepSeek-OCR
|
||||
# api_key: sk-beshfxfxfrllsczpqfrgjbugfbpzxgqgedbehceytpcvvtyb
|
||||
# base_url: https://api.siliconflow.cn/v1/
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_thinking: true
|
||||
# - name: zai-org/GLM-5-FP8
|
||||
# display_name: zai-org/GLM-5-FP8
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: zai-org/GLM-5-FP8
|
||||
# api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
|
||||
# base_url: https://api.us-west-2.modal.direct/v1
|
||||
# request_timeout: 600.0
|
||||
# max_retries: 2
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_thinking: true
|
||||
# - name: Qwen/Qwen3.6-27B
|
||||
# display_name: Qwen/Qwen3.6-27B
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: Qwen/Qwen3.6-27B
|
||||
# api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
|
||||
# base_url: https://api.siliconflow.cn/v1/
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_thinking: true
|
||||
# SiliconFlow 混合推理模型关思考用「请求体顶层 enable_thinking: false」,
|
||||
# 不认 chat_template_kwargs / thinking.type 那两种嵌套形状。声明这个后,
|
||||
# 网页端「关闭思考」对本模型才生效(开放接口另由 force_disable_thinking 兜底)。
|
||||
# 识图(图片识别)配置。
|
||||
# - model_name:指定一个 models[] 中的模型名,作为“默认识图大模型”。view_image 工具会把
|
||||
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
|
||||
# 视觉,也能“看图”。请改成你实际用于识图的模型名。
|
||||
# - 若留空 / 注释掉 model_name,则回退到主对话模型自身的视觉能力(需该模型 supports_vision: true)。
|
||||
# - prompt:不带具体问题时使用的默认识图指令(可选)。
|
||||
# - max_tokens:识图模型输出上限(可选,默认用该模型自身的配置)。
|
||||
vision:
|
||||
model_name: deepseek-ai/DeepSeek-OCR
|
||||
# prompt: 请详细描述这张图片的内容。
|
||||
# max_tokens: 2048
|
||||
|
||||
# 深度研究可选的报告配图。默认关闭;仅在部署方配置文生图模型后,前端才会展示开关。
|
||||
# 使用 OpenAI 兼容的 POST {base_url}/images/generations 协议,并强制要求 b64_json
|
||||
# 返回,以便将图片保存为当前研究会话的受鉴权产物,而不是引用会过期的公网 URL。
|
||||
deep_research:
|
||||
image_generation:
|
||||
enabled: true
|
||||
provider: openai_compatible
|
||||
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation # 例如 https://api.openai.com/v1
|
||||
api_key: sk-sp-H.XDIPL.Zgvs.MEQCIBasaS2RPHwgNUpGVfFJeRaTp5UgmZ4Y2rCVGNtpwfD1AiBTFg3S-uE_gqVDnHgdyEhDwcfaVRLi92xviye80AZnkg # 生产环境建议填 $DEEP_RESEARCH_IMAGE_API_KEY
|
||||
model: wan2.7-image # 例如 gpt-image-1
|
||||
size: 1024x1024
|
||||
# quality: standard
|
||||
timeout_seconds: 120
|
||||
|
||||
# browser_act 相关配置
|
||||
browser_context:
|
||||
enabled: true
|
||||
actions_enabled: true
|
||||
|
||||
# 上传文件识别配置。
|
||||
# - auto_convert_documents: true 时,上传的 PDF / Word / Excel / PPT 会在服务端自动转换为
|
||||
# Markdown(.md),agent 通过 read_file / grep 读取其内容 —— 即“识别”文档内容。
|
||||
# 纯文本类(txt / csv / md / json / 代码等)无需转换,agent 用 read_file 直接读取。
|
||||
# 图片(png/jpg/webp)由 view_image + 上面的 vision 识图模型处理。
|
||||
# - pdf_converter: auto(优先 pymupdf4llm,缺失时回退 markitdown)| pymupdf4llm | markitdown
|
||||
uploads:
|
||||
auto_convert_documents: true
|
||||
pdf_converter: auto
|
||||
|
||||
title:
|
||||
enabled: true
|
||||
max_words: 6
|
||||
max_chars: 60
|
||||
prompt_template: |
|
||||
请为下面这段对话生成一个简洁的标题,概括对话的核心主题。
|
||||
|
||||
要求:
|
||||
- 标题必须使用简体中文
|
||||
- 尽量精炼,不超过 {max_words} 个词
|
||||
- 只返回标题本身,不要加引号、不要解释、结尾不要标点
|
||||
|
||||
User: {user_msg}
|
||||
Assistant: {assistant_msg}
|
||||
|
||||
# 上下文压缩(对话摘要):长对话接近上下文上限时,把较早的消息总结成一段摘要,
|
||||
# 只保留最近若干条原始消息,避免撑爆模型上下文窗口。
|
||||
# - trigger:触发条件,可配多个,任意一个先满足就压缩(OR 关系)。真正决定何时
|
||||
# 压缩的是 trigger(不是 keep)。type=tokens 按 token 总数;type=messages 按条数。
|
||||
# - keep:压缩后保留多少最近的上下文(tokens / messages)。
|
||||
# - trim_tokens_to_summarize:喂给摘要模型的消息最多裁到多少 token。
|
||||
# 当前为「测试值(易触发)」:发到服务器后稍微聊几句就会触发,可在 Gateway 日志里
|
||||
# grep「上下文压缩」/「context compaction」确认生效;确认后再调回生产值
|
||||
# (trigger tokens 60000 / messages 80,keep tokens 16000)。
|
||||
summarization:
|
||||
# 开关已移到聊天框下方(参考文献下面)的「上下文压缩」按钮,默认关闭。
|
||||
# 这里 enabled 仅作为非 Web 路径(渠道/定时任务)的后备开关;下面的 trigger/keep
|
||||
# 等仅是调参,Web 端是否压缩由前端按钮(summarization_enabled)决定。
|
||||
enabled: false
|
||||
# model_name: deepseek-chat # 留空则用主对话模型
|
||||
trigger:
|
||||
- type: tokens
|
||||
value: 120000
|
||||
- type: messages
|
||||
value: 160
|
||||
keep:
|
||||
type: tokens
|
||||
value: 40000
|
||||
trim_tokens_to_summarize: 8000
|
||||
|
||||
tool_groups:
|
||||
- name: web
|
||||
- name: file:read
|
||||
- name: file:write
|
||||
- name: bash
|
||||
|
||||
tools:
|
||||
- name: web_search
|
||||
group: web
|
||||
# DuckDuckGo search — no API key required. To switch to the deployment's
|
||||
# internal search service later, restore the configurable_search block
|
||||
# preserved in the comments below (fill in the real values) and point
|
||||
# `use` back to deerflow.community.configurable_search.tools:web_search_tool.
|
||||
use: deerflow.community.ddg_search.tools:web_search_tool
|
||||
enabled: true
|
||||
max_results: 5
|
||||
# ── configurable_search alternative (deployment-internal service) ──
|
||||
# endpoint: "https://xxx.xxx.xxx/searchName"
|
||||
# verify_ssl: false
|
||||
# timeout: 30
|
||||
# result_url_template: "https://xxx.xxx.xxx/mindId/xxx?recUuid={recUuid}"
|
||||
# The runtime query always overwrites payload.query; all other fields are
|
||||
# sent unchanged so deployment-specific request parameters stay in config.
|
||||
# payload:
|
||||
# query: ""
|
||||
# xxx: "xxxx"
|
||||
|
||||
- name: web_fetch
|
||||
group: web
|
||||
use: deerflow.community.jina_ai.tools:web_fetch_tool
|
||||
timeout: 15
|
||||
|
||||
- name: ls
|
||||
group: file:read
|
||||
use: deerflow.sandbox.tools:ls_tool
|
||||
|
||||
- name: read_file
|
||||
group: file:read
|
||||
use: deerflow.sandbox.tools:read_file_tool
|
||||
|
||||
- name: glob
|
||||
group: file:read
|
||||
use: deerflow.sandbox.tools:glob_tool
|
||||
max_results: 200
|
||||
|
||||
- name: grep
|
||||
group: file:read
|
||||
use: deerflow.sandbox.tools:grep_tool
|
||||
max_results: 100
|
||||
|
||||
- name: write_file
|
||||
group: file:write
|
||||
use: deerflow.sandbox.tools:write_file_tool
|
||||
|
||||
- name: str_replace
|
||||
group: file:write
|
||||
use: deerflow.sandbox.tools:str_replace_tool
|
||||
|
||||
- name: bash
|
||||
group: bash
|
||||
use: deerflow.sandbox.tools:bash_tool
|
||||
|
||||
# browser_act 相关配置
|
||||
- name: browser_fetch_page
|
||||
group: web
|
||||
use: deerflow.tools.builtins.browser_context_tool:browser_fetch_page_tool
|
||||
|
||||
# browser_act 相关配置
|
||||
- name: browser_act
|
||||
group: web
|
||||
use: deerflow.tools.builtins.browser_act_tool:browser_act_tool
|
||||
|
||||
sandbox:
|
||||
use: deerflow.sandbox.local:LocalSandboxProvider
|
||||
allow_host_bash: true
|
||||
mounts:
|
||||
- host_path: C:\Users\iie\Desktop\deerflow-new\offline-backend-20260512\backend\knowledge\knowledge_base
|
||||
container_path: /mnt/knowledge
|
||||
read_only: true
|
||||
|
||||
skills:
|
||||
path: skills
|
||||
security_scan:
|
||||
enabled: false
|
||||
|
||||
skill_evolution:
|
||||
enabled: true
|
||||
curator:
|
||||
enabled: true # 每 24 小时自动清理 + 合并 skill
|
||||
interval_hours: 24
|
||||
review_worker:
|
||||
enabled: true # 被动复盘开关
|
||||
min_tool_calls: 5 # 触发阈值:本轮至少 5 次工具调用,与 prompt 中"≥5 步"硬条件一致
|
||||
max_messages: 60 # 传给 LLM 的最大消息条数
|
||||
timeout_seconds: 120 # 单次 review 超时(秒)
|
||||
model_name: # 留空则使用主模型
|
||||
|
||||
agents_api:
|
||||
enabled: true
|
||||
|
||||
# 登录鉴权扩展:在基础登录之上叠加两种自定义模式。
|
||||
auth_login:
|
||||
# 用户名直登口令门:开关打开后,/login/<用户名> 直登必须再带 ?password=口令,
|
||||
# 口令不对直接 401。password 留空则默认口令为 123ewq。仅作用于用户名直登。
|
||||
username_login:
|
||||
require_password: false
|
||||
password: ''
|
||||
# token 换登录:前端用 ?authToken=上游token 进入,后端调用 token_info_url
|
||||
# (把该 token 直接拼成 Authorization: Bearer <token> 头)换出用户名,再登录;
|
||||
# 用户名不在库里时自动注册。
|
||||
token_login:
|
||||
enabled: true
|
||||
token_info_url: 'https://ch1.b.uat.4.cn/consumer/login/getTokenInfo'
|
||||
# 已弃用:鉴权头现由前端传入的 token 拼成 "Bearer <token>",此项不再生效,保留仅兼容。
|
||||
service_authorization: 'Bearer admin'
|
||||
timeout_seconds: 10
|
||||
# 上游是 HTTPS。UAT/内网常用自签或私有 CA 证书,默认 verify=true 会握手失败导致
|
||||
# 永远 401。此处 uat 域名按自签处理,先关校验;生产请置 true 或配 ca_cert_path。
|
||||
verify_ssl: false
|
||||
ca_cert_path: '' # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验
|
||||
# 离线测试映射:token -> 用户名。命中的 token 跳过 token_info_url 调用,直接当成
|
||||
# 该用户登录——外网调不通 getTokenInfo 时用来本地全链路联调。生产留空即可(不生效)。
|
||||
# 例:用 /login/任意?authToken=123ewq 进入,即以 admin 身份登录。
|
||||
mock_tokens:
|
||||
'123ewq': 'admin'
|
||||
|
||||
# 日志系统入口(仅管理员可见):右下角「设置」菜单中会多出一个「日志系统」入口,
|
||||
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
|
||||
|
||||
database:
|
||||
backend: sqlite
|
||||
sqlite_dir: .deer-flow/data
|
||||
pool_size: 20 # 基础连接池(postgres/mysql 生效;sqlite 忽略)
|
||||
max_overflow: 20 # 高峰期可临时超出 pool_size 的额外连接数,真实上限 = pool_size + max_overflow
|
||||
pool_timeout: 30 # 池满时等待空闲连接的秒数,超时报错
|
||||
pool_recycle: 1800 # 连接回收周期(秒);防火墙/pgbouncer/MySQL wait_timeout 静默断连兜底。多 worker 部署:每 worker 上限 = pool_size + max_overflow,总数 × worker 数 < DB max_connections
|
||||
echo_sql: false
|
||||
|
||||
checkpointer:
|
||||
type: sqlite
|
||||
connection_string: .deer-flow/data/deerflow.db
|
||||
|
||||
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
|
||||
# 三层覆盖优先级(高->低):
|
||||
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
|
||||
memory:
|
||||
enabled: true
|
||||
version:
|
||||
v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取)
|
||||
# 'v2'(USER.md/MEMORY.md + 可选 Hindsight)
|
||||
provider:
|
||||
hindsight # 'builtin' | 'hindsight'(外部 provider 名)
|
||||
# builtin 始终启用,这里只决定 external 选哪个
|
||||
# (version=v1 时此字段忽略)
|
||||
|
||||
# V1 专属配置 —— version=v1 时生效
|
||||
v1:
|
||||
max_facts: 100 # 最多保留的事实条数,超出时驱逐最低置信度的事实
|
||||
fact_confidence_threshold: 0.7 # 事实置信度阈值,低于此值不保存
|
||||
debounce_seconds: 30 # 对话结束后延迟多少秒才触发 LLM 提取
|
||||
model_name: '' # 用于事实提取的 LLM 模型名;空则继承全局默认模型
|
||||
token_budget: 2000 # 注入系统提示词的 token 预算
|
||||
builtin:
|
||||
enabled: true
|
||||
memory_char_limit: 2200
|
||||
user_char_limit: 1375
|
||||
deduplicate_on_load: true
|
||||
|
||||
hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务
|
||||
mode: local_external # 'cloud' | 'local_embedded' | 'local_external'
|
||||
api_url: http://47.88.25.99:18888
|
||||
api_key:
|
||||
'' # 部署 Hindsight 后填,或改成 $HINDSIGHT_API_KEY 并设同名环境变量
|
||||
# 注意:写成 $VAR 但环境变量未设置会导致配置加载失败
|
||||
bank_id_template: 'deerflow-user-{user_id}'
|
||||
work_bank_id_template: 'deerflow-work-{user_id}-{agent_id}'
|
||||
memory_mode: context # 'context' | 'tools' | 'hybrid'
|
||||
bank_mission: '你是一个中文记忆助手,负责从对话中提炼和存储重要信息。所有记忆内容必须使用中文记录。'
|
||||
bank_retain_mission: '请从以下对话中提炼重要信息,包括用户的偏好、需求、背景知识和关键事实。所有提炼的内容必须用中文表达。'
|
||||
recall_budget: mid # 'low' | 'mid' | 'high'
|
||||
recall_max_tokens: 4096
|
||||
recall_max_input_chars: 800
|
||||
auto_recall: true
|
||||
auto_retain: true
|
||||
retain_async: true
|
||||
retain_every_n_turns: 1
|
||||
retain_user_prefix: 'User'
|
||||
retain_assistant_prefix: 'Assistant'
|
||||
|
||||
# background_extraction:预留功能,代码尚未实现 —— 仅设计文档存在。
|
||||
# 待对应版本支持后,取消下面整段注释即可启用(漏记兜底)。
|
||||
# background_extraction:
|
||||
# enabled: false # 默认关闭,作为漏记兜底
|
||||
# debounce_seconds: 30
|
||||
# model_name: ~ # null = 用默认模型
|
||||
# fact_confidence_threshold: 0.7
|
||||
# max_facts_per_extraction: 10
|
||||
# correction_detection: true
|
||||
# reinforcement_detection: true
|
||||
|
||||
injection:
|
||||
enabled: true
|
||||
max_tokens: 2000
|
||||
include_builtin: true
|
||||
include_hindsight: true
|
||||
context_tag: 'memory-context' # 包裹 Hindsight recall 的标签名
|
||||
|
||||
security:
|
||||
scan_content: true # 注入/外渗模式扫描
|
||||
block_invisible_unicode: true
|
||||
# streaming_scrubber:预留字段,代码尚未实现 —— 待版本支持后取消注释。
|
||||
# streaming_scrubber: true # SSE 流前清洗 <memory-context>
|
||||
|
||||
# AI 写作子系统 —— 素材收集专家专用配置。
|
||||
# 注意:这三项必须嵌套在 ai_writing: 段下;写到顶层会被 _ai_writing_config()
|
||||
# (app/gateway/routers/ai_writing.py:_ai_writing_config) 直接忽略。
|
||||
ai_writing:
|
||||
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
|
||||
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
|
||||
max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
|
||||
# 用「真实智能体运行时」跑配置技能(read SKILL.md + 全套工具 + 沙箱),让技能的真实
|
||||
# 检索逻辑(调内网接口/MCP/读文件)真正执行,而不是塞进通用 web/ES 检索 provider。
|
||||
# 内网技能必须开 true,否则技能会走通用 provider、连不上内网而报 ConnectError。
|
||||
# 仅对非「知识库检索(knowledge-base-search)」的配置技能生效;失败回退直连检索。
|
||||
researcher_skill_agent: true
|
||||
# 最终兜底:当所有技能都没检索到素材时,直连内网知识库 ES 接口兜底检索(避免素材全空)。
|
||||
# 配上你内网 ES 接口地址即启用;留空则不启用该兜底。
|
||||
# 接口契约:POST {url} 体 {"query","knowledge_base_name","ts":"yes"} → [{"page_content","metadata":{m_title,content,recUuid,m_publish,source1}}]
|
||||
intranet_search_url: "" # 例如 http://10.106.57.180:7863/knowledge_base/search_docs_es
|
||||
intranet_knowledge_base: "samples" # 兜底检索用的知识库名
|
||||
intranet_search_timeout: 300 # 内网检索超时时间(秒),代码层强制最低 300;需要更长可调大
|
||||
# 内网接口诊断开关:开启后即使技能已检索成功,也照样把内网接口跑完并把执行结果
|
||||
# (端点/每个检索词命中数/报错/原始响应片段)记到 material_package.intranet_debug 里,
|
||||
# 便于在接口返回(devtools)排查内网接口到底通不通。生产建议关掉(会多跑一次内网)。
|
||||
intranet_search_debug: false
|
||||
# 四阶段「内置功能型智能体」开关(按阶段灰度)。true = 该阶段节点改为加载
|
||||
# .deer-flow/agents/ai-writing-<stage>/ 的 SOUL.md + config(admin 可在智能体
|
||||
# 管理页编辑人设/模型),false / 缺失 = 走旧的硬编码 prompt 路径。
|
||||
# 改完保存即生效(config 按 mtime 热加载),新写作会话立即按新开关运行。
|
||||
use_builtin_agents:
|
||||
researcher: false # 素材收集专家(检索词生成 / 素材排序两个子任务)
|
||||
outliner: false # 大纲规划师(大纲生成 / 按反馈调整)
|
||||
writer: false # 作家(逐章正文写作,逐章流式与严格模式求助逻辑不变)
|
||||
editor: false # 编辑(事实/逻辑/语言三维度审查)
|
||||
|
||||
# Optional independently deployed WeKnora integration. DeerFlow owns only
|
||||
# these connection values; database/Redis/storage/parser/model settings stay
|
||||
# in WeKnora's own docker-compose/.env/config files. A non-empty api_base_url
|
||||
# enables WeKnora mode; leaving it empty preserves the existing LLMWiki iframe.
|
||||
# admin_email/admin_password are used only by DeerFlow's server-side WeKnora
|
||||
# detail-page iframe proxy. Prefer environment variables in production, e.g.
|
||||
# admin_email: "$WEKNORA_ADMIN_EMAIL"
|
||||
# admin_password: "$WEKNORA_ADMIN_PASSWORD"
|
||||
llmwiki:
|
||||
weknora:
|
||||
api_base_url: "http://119.254.86.251:8088/deerflow-api"
|
||||
web_base_url: "http://119.254.86.251:8088"
|
||||
admin_email: "lqq@qq.com"
|
||||
admin_password: "1qaz@WSX"
|
||||
|
||||
log_system:
|
||||
enabled: true
|
||||
url: 'https://your-log-system.example.com'
|
||||
label: '日志系统'
|
||||
|
||||
task_deeplink:
|
||||
# 线上接口未通时用假数据;三个开关互相独立。
|
||||
test: true # 目的树查询走 mock.purpose_detail 假数据
|
||||
delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完)
|
||||
action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id)
|
||||
# 开放接口 POST /api/open/3qfx/ask 按 taskId 拉取任务详情拼开场白;上游不可达时打开下面开关走 mock.cop_task_detail。
|
||||
cop_task_detail_test: true
|
||||
cop_task_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/cop-task-detail"
|
||||
purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail"
|
||||
# rwfx 与 xdfx 共用同一删除接口:rwfx 追加 ?taskId=(不传 actionId);xdfx 追加 ?actionId=(不传 taskId,直接不出现)。
|
||||
delete_url: "https://ch1.b.uat.4.cn/bwjt/dropSixOrSevenData"
|
||||
treemap_edit_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit"
|
||||
treemap_next_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next"
|
||||
# xdfx 深链传入的是「行动id」,先调该接口(追加 /{actionId})拿 data.taskId 解析出真正「任务id」。
|
||||
action_detail_url: "https://ch1.b.uat.4.cn/consumer/plan/action"
|
||||
# xdfx 删除前「行动历史是否存在」预检接口(追加 ?actionId=);地址待提供,留空则前端视作无历史、不阻断删除。
|
||||
action_history_url: ""
|
||||
# rwfx 目的树面板「3q详情」链接的目标页(地址待定,留空则前端提示未配置);跳转追加 ?id=任务id。
|
||||
detail_3q_url: ""
|
||||
mock:
|
||||
# test=true 时原样返回给前端(结构同真实 getTaskPurposeDetail 返回)。
|
||||
purpose_detail:
|
||||
state: "200"
|
||||
msg: "操作成功!"
|
||||
data:
|
||||
- purposeDto: { id: 5837, zzPurpose: "目的01", selectedFlag: 1, taskId: 3324, recDetailId: 4271 }
|
||||
childrenList:
|
||||
- actorDto: { id: 9101, taskId: 3324, purposeId: 5837, actorName: "行为体01", actorOrgan: "行为体01", recReason: "行为体01理由", selectedFlag: 1 }
|
||||
childrenList:
|
||||
- expectedBehaviorDto: { id: 6910, taskId: 3324, purposeId: 5837, behavior: "预期01", recReason: "预期01理由", selectedFlag: "1" }
|
||||
childrenList:
|
||||
- drivingFactorDto: { id: 7964, taskId: 3324, purposeId: 5837, drivingFactor: "预期01因素", selectedFlag: 1 }
|
||||
childrenList:
|
||||
- narrationDto: { id: 6500, purposeId: 5837, taskId: 3324, narration: "叙事01", recReason: "叙事01理由", selectedFlag: 1 }
|
||||
childrenList: []
|
||||
- actorDto: { id: 9102, taskId: 3324, purposeId: 5837, actorName: "行为体02", actorOrgan: "行为体02", recReason: "行为体02理由", selectedFlag: 1 }
|
||||
childrenList:
|
||||
- expectedBehaviorDto: { id: 6911, taskId: 3324, purposeId: 5837, behavior: "预期02", recReason: "预期02理由", selectedFlag: "1" }
|
||||
childrenList:
|
||||
- drivingFactorDto: { id: 7965, taskId: 3324, purposeId: 5837, drivingFactor: "预期02因素", selectedFlag: 1 }
|
||||
childrenList:
|
||||
- narrationDto: { id: 6501, purposeId: 5837, taskId: 3324, narration: "叙事02", recReason: "叙事02理由", selectedFlag: 1 }
|
||||
childrenList: []
|
||||
# delete_test=true 时原样返回给前端。改 success 测「成功覆盖 / 不可覆盖」两条分支。
|
||||
delete:
|
||||
message: "行动已经开始策划,不可覆盖"
|
||||
success: true
|
||||
# action_test=true 时原样返回给前端(结构同真实 /consumer/plan/action/{actionId} 返回)。
|
||||
# 前端取 data.taskId 作为真正的「任务id」(xdfx 深链 ?taskId= 传入的是 data.id 这个行动id)。
|
||||
action_detail:
|
||||
state: "200"
|
||||
msg: "操作成功!"
|
||||
data:
|
||||
id: 27354
|
||||
purposeId: 2850
|
||||
taskId: 2055
|
||||
actionName: ""
|
||||
actionStatus: 25
|
||||
narration: ""
|
||||
# cop_task_detail_test=true 时原样返回给开放接口 /api/open/3qfx/ask(结构同真实 cop-task-detail 返回)。
|
||||
cop_task_detail:
|
||||
data:
|
||||
id: 2055
|
||||
overview: "暑期文旅消费高峰期间的舆情态势综合研判与引导任务"
|
||||
taskDirection: "正向引导 + 风险预警"
|
||||
taskName: "任务01:暑期文旅舆情综合研判"
|
||||
taskContent: "围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。"
|
||||
@@ -342,6 +342,7 @@ FastAPI application on port 8001 with health checks at `GET|HEAD /health`, `/api
|
||||
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
|
||||
| **Light Apps** (`/api/light-apps`) | Light-app registry (????? / ????), **global/shared** (no user scoping; `created_by`/`updated_by` audit-only). Two kinds: `window_open` (launched in a new tab from the ???? card grid) and `iframe` (embedded under a parent sidebar menu via `mount_location` and dynamically injected into the sidebar). Both carry `route_params` ? a list of `{key, paramType: login\|theme, source, customValue, themeValue}` descriptors the **frontend** resolves at launch time (login params read from `localStorage.userInfo`; theme params use a literal value) and appends to `url` as query string. `GET /` - list (filters `app_type`/`mount_location`/`enabled_only`/`search`); `GET /{app_id}` - read one (both open to any authenticated user); `POST /`, `PUT /{app_id}`, `DELETE /{app_id}` - **admin only**; iframe apps must carry a `mount_location` (400 otherwise); the unique `name` collides as HTTP 409. Backed by `deerflow.persistence.light_apps` (table `light_apps`, auto-created by `create_all`); store wired in `deps.py` as `app.state.light_app_store`. Tests: `tests/test_light_apps.py`. |
|
||||
| **Menu Overrides** (`/api/menu-overrides`) | Sidebar-menu overrides (菜单管理), **global/shared** (no user scoping; `updated_by` audit-only). The static menu tree (icons/routes/`adminOnly`) lives in the **frontend** code (`core/page-layout/sidebar-menu.ts`); iframe light-apps are injected at render time. This router persists a per-node **overlay** the frontend merges onto that tree: `parent_id` (re-parent, moves the whole subtree; NULL = top level), `sort_order` (reorder among siblings), `label` (rename; NULL keeps code label), `disabled` (停用 — hides the node **and its subtree**). A node with no row renders exactly as code defines it, so new code menus / newly-registered iframe apps appear automatically and are immediately configurable. `GET /` - list all overrides (open to any authenticated user); `PUT /` - **admin only**, atomically **replaces the whole set** (`{overrides: [...]}`); rejects re-parenting that would exceed **3 levels** or form a cycle (`_validate_depth`, HTTP 400). Backed by `deerflow.persistence.menu_overrides` (table `menu_overrides`, auto-created by `create_all`); store wired in `deps.py` as `app.state.menu_override_store`. Frontend: merge logic `core/page-layout/menu-overrides.ts` (`applyMenuOverrides` in `use-sidebar-menu.ts`), client `strategy-components/api/menu-overrides.ts`, admin page `pages/MenuManagementPage.tsx` (@dnd-kit drag-reorder/re-parent + 「移动到」parent selector + ↑↓ arrows + 改名/停用), entry in the bottom-left settings dropdown (`components/page-sidebar.tsx`, admin-only). Tests: `tests/test_menu_overrides.py`, `frontend-web/src/core/page-layout/menu-overrides.test.ts`. |
|
||||
| **Compact Menu Overrides** (`/api/compact-menu-overrides`) | Compact/sidebar-summary left-nav overlay (简介模式菜单), **global/shared**. Two independent sets keyed by `menu_type` (`A` default / `B`); saving one type does not wipe the other. Type B rows use a `B::` node_id prefix so they stay unique on databases that still use `node_id` as the sole PK. `GET ?menu_type=` lists one type (open); `PUT {menu_type, overrides}` admin-only replaces that type. Frontend: `MenuManagementPage` 简介模式 type switcher + `runtime-config.js` `VITE_COMPACT_MENU_TYPE`. Tests: `tests/test_compact_menu_overrides.py`. |
|
||||
| **Sensitive Words** (`/api/sensitive-words`) | Sensitive-word / desensitize map (敏感词管理 / 脱敏词), **global/shared** (no user scoping; `updated_by` audit-only). Each row maps an original `word` → `replacement`, with `enabled` (停用 parks a word without deleting) + `sort_order`. The **frontend** ships a seed map in code (`src/lib/desensitize.ts` `SEED_DESENSITIZE_MAP`) used as the default on a fresh deployment; once an admin saves, this table is the source of truth and the frontend loads it at startup (`DesensitizeWordsLoader` → `replaceDesensitizeMap`, **enabled** rows only). `GET /` - list all words (open to any authenticated user — every page desensitizes); `PUT /` - **admin OR the `lqq` account** (`_require_manager`: `system_role == "admin"` or email `@`-prefix `== "lqq"`), atomically **replaces the whole set** (`{words: [...]}`, de-duped by `word`, last wins). Backed by `deerflow.persistence.sensitive_words` (table `sensitive_words`, PK `word` `String(191)`, auto-created by `create_all`); store wired in `deps.py` as `app.state.sensitive_word_store`. Frontend: client `strategy-components/api/sensitive-words.ts`, page `pages/SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, entry in the bottom-left settings dropdown — **gated on username `lqq`, not admin role**, present in both sidebar modes; 「导入种子词」 merges missing seed entries). Tests: `tests/test_sensitive_words.py`. |
|
||||
| **Dashboard Sessions** (`/api/dashboard-sessions`) | 「大屏绘制智能体」独立页面会话,**按外部 `task_id` 存取,不按 user 分权**(open read + shared writes;`user_id` audit-only)。一个 task 一行(upsert):`transcript`(聊天记录 JSON)+ `report_json`(最新结构化数据,可解析 = 「有效结果」)。`GET /by-task/{task_id}` - 取该 task 会话(无则 404);`PUT /by-task/{task_id}` - upsert(`{transcript?, report_json?}`,只覆盖传入项)。Backed by `deerflow.persistence.dashboard_sessions`(table `dashboard_sessions`, auto-created by `create_all`,无迁移);store wired in `deps.py` as `app.state.dashboard_session_store`。前端独立页 `roundtable-planning/pages/DashboardAgentPage.tsx`(路由 `/page/strategy/dashboard-agent?taskId=`,侧边栏「问答管理 → 大屏绘制」)让智能体分析任务报告 JSON → report-json → 渲染 `DashboardApp`。Tests: `tests/test_dashboard_sessions.py`。 |
|
||||
| **Task Reports** (`/api/task-reports`) | 任务级研判情报报告详情,**按 `task_id` 共享、不按 user 分权**(写入者只留 `updated_by` 审计)。`GET /by-task/{task_id}` 始终返回原 Consumer 兼容外壳 `{state:"200", msg:"操作成功!", data:[...]}`(未保存为 `data: []`);`POST /by-task/{task_id}` 首次保存,已有报告返回 409;`PUT /by-task/{task_id}` 原子替换完整 `data` 数组。`POST /import/by-task/{task_id}` 面向 TaskCOP 导入页,接收 `sq-report-mock.json` 兼容的 `data`/`records` 数组并完整覆盖目标任务;路径中的选定任务 id 优先于文件内的 `taskId`,避免误写。每一项严格是 `{id, taskId, createTime, updateTime, sessionId, contentJson, categoryType}`;`contentJson` 以 JSON **字符串**原样落在 `records_json`,避免破坏大屏按类目解析的输入格式。保存或导入非空 `data` 后,router 会通过 `app.state.taskcop_task_store.mark_analysis_completed` 将同 id 的 TaskCOP 任务升级至状态 `25`,使旧任务列表显示并筛选为“已完成”;普通保存时不存在匹配任务的报告仍可独立保存,导入则要求目标任务存在。Backed by `deerflow.persistence.task_reports`(table `task_reports`, auto-created by `create_all`);store wired in `deps.py` as `app.state.task_report_store`。前端客户端 `strategy-components/api/task-reports.ts`,`core/auth/dashboard-input.ts` 与 rwfx 3q 完成检查均直接读此接口,不再依赖 Consumer 或本地 sq-report mock。Tests: `tests/test_task_reports.py`。 |
|
||||
@@ -481,6 +482,16 @@ Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊
|
||||
|
||||
### Position-collaboration foundation
|
||||
|
||||
### Roundtable business-chain enable/disable
|
||||
|
||||
`roundtable_chains.enabled` (BOOLEAN NOT NULL DEFAULT 1, migration `20260819_02`)
|
||||
is the per-chain 启停 switch. New and existing chains default to **enabled**.
|
||||
Owner or admin can flip it via `PUT /api/roundtable-chains/{id}` `{enabled}`.
|
||||
Disabled chains stay visible in the management list (with an 启用/停用 filter)
|
||||
and still show `updated_at`; the frontend hides them from the picker / public
|
||||
entry rail so they cannot be selected into a roundtable. Tests:
|
||||
`tests/test_roundtable_chain_stages.py` (`test_create_defaults_enabled_then_toggle`).
|
||||
|
||||
### Roundtable business-chain human validation
|
||||
|
||||
The existing `/api/roundtable-chains` `seats` JSON also accepts the optional
|
||||
@@ -1147,6 +1158,10 @@ Automatic conversation summarization when approaching token limits:
|
||||
the config flag. `config.summarization.enabled` now defaults to **false**.
|
||||
- `config.yaml` `summarization` still supplies the **tuning**: trigger types
|
||||
(tokens, messages, or fraction of max input) and the keep policy.
|
||||
- A compressed request must retain the latest non-empty `HumanMessage`, even
|
||||
when it has already been folded into the hidden summary. This keeps strict
|
||||
OpenAI-compatible gateways from rejecting a tool-continuation call with
|
||||
`No user query found in message`.
|
||||
- Keeps recent messages while summarizing older ones.
|
||||
|
||||
Tests: `tests/test_summarization_toggle.py` (gating + whitelist),
|
||||
@@ -1265,8 +1280,13 @@ the terminal `lastJobId` in `usage_snapshot` so a refreshed conversation page
|
||||
can replay its trace from the event log. Detailed mode uses stable targets (`introduction`,
|
||||
`section:N`, `conclusion`) because section writers are concurrent;
|
||||
multi-agent revisions must emit `report_reset` before their next report
|
||||
version. Do not emit raw model reasoning or one persisted event per provider
|
||||
token. The Vite page owns Word export through
|
||||
version. Do not persist raw model reasoning or one persisted event per provider
|
||||
token. Models that expose thinking inside ordinary `<think>...</think>` text
|
||||
must be split statefully before a report delta, checkpoint, or Markdown
|
||||
projection is created; an optional live-only `report_thinking` frame can drive
|
||||
the message-list thought trace, while the sandbox receives answer Markdown
|
||||
only. The Vite
|
||||
page owns Word export through
|
||||
`frontend-web/src/core/scheduled-tasks/markdown-docx.ts`; do not add Python
|
||||
document-conversion dependencies merely to export text.
|
||||
All non-streaming Deep Research control-plane calls (`complete()` for query /
|
||||
@@ -1284,6 +1304,31 @@ The per-user Deep Research concurrency guard counts only session statuses
|
||||
consume a slot; otherwise saved drafts make `POST /sessions/{id}/jobs` return
|
||||
429 permanently. Regression: `test_saved_draft_sessions_do_not_consume_research_concurrency`.
|
||||
|
||||
`POST /api/deep-research/sessions/{id}/jobs` is retry-safe under MySQL
|
||||
read/write splitting. Deep Research session/job/event/source/message writes
|
||||
must serialise their changed row while the write transaction is still open
|
||||
(`flush` for inserts, select-after-update **before** commit), never through a
|
||||
post-commit `session.refresh()` or a fresh pooled read. Replica lag otherwise
|
||||
turns a committed job into a misleading 500, can make dispatcher `claim_job()`
|
||||
lose its snapshot, or can fail report/follow-up persistence after the model has
|
||||
already completed. Always nudge the dispatcher for both a newly created job and
|
||||
an idempotently reused queued job; otherwise a retry after such an interrupted
|
||||
response returns the old job but leaves it waiting for the periodic scan. The
|
||||
SSE replay generator must catch temporary event/job-store reads, emit a
|
||||
recoverable warning, and keep the connection open so the next poll can repair
|
||||
the cursor instead of surfacing an ASGI `ExceptionGroup`. Regressions:
|
||||
`test_deep_research_writes_never_depend_on_post_commit_refresh`,
|
||||
`test_job_create_never_reads_back_after_commit`,
|
||||
`test_retry_of_existing_queued_job_nudges_dispatcher`, and
|
||||
`test_stream_stays_open_when_durable_replay_has_a_transient_failure`.
|
||||
|
||||
At ordinary report completion, persist and confirm the session's
|
||||
`report_markdown` projection **before** marking the Job `completed`. Retry a
|
||||
short transient projection failure; if it cannot be confirmed, let the job
|
||||
become failed rather than reporting a report that vanishes on refresh.
|
||||
`test_report_is_persisted_before_job_is_marked_completed` and
|
||||
`test_report_projection_failure_never_marks_job_completed` cover the ordering.
|
||||
|
||||
The chat-collection frontend must keep report-writing progress in the existing
|
||||
workspace `MessageList` / `MessageGroup` tool-step renderer: inject only
|
||||
virtual `deep_research_progress` tool calls for the durable phases, keep the
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
install:
|
||||
uv sync
|
||||
|
||||
dev:
|
||||
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload
|
||||
dev:
|
||||
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload --reload-exclude ".deer-flow/*" --reload-exclude ".runtime/*"
|
||||
|
||||
gateway:
|
||||
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001
|
||||
|
||||
@@ -151,7 +151,7 @@ FastAPI application providing REST endpoints for frontend integration:
|
||||
| `GET /api/threads/{id}/uploads/list` | List uploaded files |
|
||||
| `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
|
||||
| `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts |
|
||||
| `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. |
|
||||
| `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Deep Research session/job/event/source/message writes serialise their rows in the writer transaction, rather than doing a post-commit ORM refresh or readback; this prevents MySQL read/write-splitting replica lag from failing “start writing”, dispatcher claim, report persistence, or follow-up history. A report is written to the session and confirmed before its Job becomes `completed`; an unconfirmed write is retried and never exposed as a false completion. Retrying an already-created queued job re-nudges the dispatcher, recovering an interrupted HTTP response without creating a second report. If progress-event persistence, durable replay, or live fan-out is briefly unavailable, the worker/stream logs the degradation and continues or reconnects rather than failing the whole job. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. |
|
||||
| `POST /api/ai-writing/sample/extract` | Extract text from Word/PDF/Markdown/TXT samples for AI-writing imitation mode |
|
||||
| `POST /cop/saveSuperiorTask` | TaskCOP compatibility API (DeerFlow Bearer/session authentication required): create a situation-overview task with a sequential four-digit id (`0001`, `0002`, ...) and return the legacy `{state, msg, data}` envelope |
|
||||
| `POST /api/taskcop/import/tasks` | TaskCOP compatibility API (authenticated): import tasks from a JSON array or `tasks`/`records` envelope; every row's supplied `id`/`taskId` is authoritative and a duplicate id completely replaces the stored task |
|
||||
@@ -180,6 +180,11 @@ the right-side `report.md` sandbox can write smoothly, while bounded,
|
||||
persisted `report_chunk` events remain reconnect checkpoints. The durable log
|
||||
is still authoritative: a browser reconnect, slow consumer, or another worker
|
||||
falls back to `?after=<seq>` replay rather than starting a second model call.
|
||||
Reasoning exposed by providers either through structured reasoning fields or
|
||||
inline `<think>...</think>` text is separated from report deltas, checkpoints,
|
||||
and the persisted Markdown projection. It may be forwarded as an ephemeral
|
||||
live `report_thinking` frame for the message-list thought trace, but is never
|
||||
persisted or written to the sandbox; only answer Markdown reaches the sandbox.
|
||||
The short non-streaming control-plane calls used to plan queries, sections, and
|
||||
reviews are independently capped at 45 seconds (maximum 120 seconds). If query
|
||||
or section planning times out, the runner emits a visible recoverable warning
|
||||
@@ -231,12 +236,16 @@ only applies to sessions with a `running` or `awaiting_input` durable job.
|
||||
DeerFlow's stock configuration includes image recognition but no image-generation
|
||||
provider. Deep Research therefore keeps illustrations disabled until an operator
|
||||
fills `config.yaml -> deep_research.image_generation` with an
|
||||
OpenAI-compatible image endpoint, API key, and model. When configured, the
|
||||
capabilities endpoint enables the page's “生成报告配图” switch. A selected job
|
||||
requests at most four base64 image results, writes them as protected
|
||||
`research-image-*.png|jpg|webp` artifacts, injects `deep-research://` links into
|
||||
the Markdown report, and emits image SSE events. A failed/unconfigured image
|
||||
provider only emits a recoverable warning; the text report still completes.
|
||||
image endpoint, API key, and model. `provider: openai_compatible` uses the
|
||||
standard `/images/generations` protocol and requires `b64_json`; `provider:
|
||||
dashscope_native` supports Qwen Image 3.0 and Wan 2.7 on DashScope / Token Plan
|
||||
with the native multimodal-generation protocol. Native provider image URLs are
|
||||
downloaded immediately, so both providers produce protected local artifacts.
|
||||
When configured, the capabilities endpoint enables the page's “生成报告配图” switch.
|
||||
A selected job writes at most four `research-image-*.png|jpg|webp` artifacts,
|
||||
injects `deep-research://` links into the Markdown report, and emits image SSE
|
||||
events. A failed/unconfigured image provider only emits a recoverable warning;
|
||||
the text report still completes.
|
||||
|
||||
Completed reports also expose `GET /api/deep-research/sessions/{id}/messages`
|
||||
and `POST /api/deep-research/sessions/{id}/chat`. Follow-up answers are grounded
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -44,6 +44,7 @@ from deerflow.agents.deep_research.types import (
|
||||
DeepResearchResult,
|
||||
)
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
from deerflow.utils.stream_text import InlineThinkTagFilter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deerflow.config.app_config import AppConfig
|
||||
@@ -61,6 +62,8 @@ WORKER_ID = f"w-{os.getpid()}-{_uuid.uuid4().hex[:8]}"
|
||||
_LEASE_TTL_SECONDS = 60
|
||||
_HEARTBEAT_INTERVAL = 20.0
|
||||
_CANCEL_POLL_INTERVAL = 5.0
|
||||
_REPORT_PROJECTION_RETRY_ATTEMPTS = 3
|
||||
_REPORT_PROJECTION_RETRY_DELAY_SECONDS = 0.5
|
||||
|
||||
# Phase → progress% mapping for the job timeline.
|
||||
_PHASE_PROGRESS: dict[str, int] = {
|
||||
@@ -155,9 +158,7 @@ class RecordingEventSink:
|
||||
elif type == "sources_curated" and self._source_store is not None:
|
||||
for sid in (payload or {}).get("selectedIds", []):
|
||||
try:
|
||||
await self._source_store.set_selected(
|
||||
sid, selected=True, reason="curated", user_id=self._user_id
|
||||
)
|
||||
await self._source_store.set_selected(sid, selected=True, reason="curated", user_id=self._user_id)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
@@ -263,14 +264,8 @@ class DeepResearchJobExecutor:
|
||||
cancel_token = CancellationToken()
|
||||
|
||||
main_task = asyncio.current_task()
|
||||
heartbeat = (
|
||||
asyncio.create_task(self._heartbeat(job_id, lease_owner, main_task))
|
||||
if lease_owner
|
||||
else None
|
||||
)
|
||||
watcher = asyncio.create_task(
|
||||
self._watch_cancellation(job_id, main_task, lease_owner, cancel_token)
|
||||
)
|
||||
heartbeat = asyncio.create_task(self._heartbeat(job_id, lease_owner, main_task)) if lease_owner else None
|
||||
watcher = asyncio.create_task(self._watch_cancellation(job_id, main_task, lease_owner, cancel_token))
|
||||
|
||||
try:
|
||||
# Normal sandbox Markdown rewrites share the same durable lease,
|
||||
@@ -388,9 +383,7 @@ class DeepResearchJobExecutor:
|
||||
session_for_model.setdefault("config_snapshot", snapshot.get("config") or {})
|
||||
|
||||
if not report.strip() or not instruction:
|
||||
await self._mark_document_rewrite_failed(
|
||||
job_id, session_id, lease_owner, "INVALID_SNAPSHOT", "改写任务快照不完整,无法继续执行。"
|
||||
)
|
||||
await self._mark_document_rewrite_failed(job_id, session_id, lease_owner, "INVALID_SNAPSHOT", "改写任务快照不完整,无法继续执行。")
|
||||
return
|
||||
|
||||
events = PersistingEventSink(
|
||||
@@ -419,9 +412,7 @@ class DeepResearchJobExecutor:
|
||||
phase="planning",
|
||||
payload={"type": "requirements_started"},
|
||||
)
|
||||
await self._job_store.update_progress(
|
||||
job_id, lease_owner=lease_owner, phase="planning", progress=20
|
||||
)
|
||||
await self._job_store.update_progress(job_id, lease_owner=lease_owner, phase="planning", progress=20)
|
||||
requirement_parts: list[str] = []
|
||||
# Persist the first fragment too: the HTTP client opens its SSE stream
|
||||
# just after queuing the job, so this closes the tiny subscribe race
|
||||
@@ -447,6 +438,14 @@ class DeepResearchJobExecutor:
|
||||
)
|
||||
last_requirements_checkpoint = time.monotonic()
|
||||
|
||||
async def on_requirement_thinking(delta: str) -> None:
|
||||
if delta:
|
||||
await events.publish_live(
|
||||
"document_rewrite",
|
||||
phase="planning",
|
||||
payload={"type": "thinking", "chunk": delta},
|
||||
)
|
||||
|
||||
requirements_fallback = False
|
||||
try:
|
||||
requirements_plan = await stream_full_report_rewrite_requirements(
|
||||
@@ -456,6 +455,7 @@ class DeepResearchJobExecutor:
|
||||
style=style_instruction,
|
||||
model_name=model_name,
|
||||
on_delta=on_requirement_delta,
|
||||
on_reasoning=on_requirement_thinking,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
@@ -490,9 +490,7 @@ class DeepResearchJobExecutor:
|
||||
"plan": requirements_plan,
|
||||
},
|
||||
)
|
||||
await self._job_store.update_progress(
|
||||
job_id, lease_owner=lease_owner, phase="writing", progress=50
|
||||
)
|
||||
await self._job_store.update_progress(job_id, lease_owner=lease_owner, phase="writing", progress=50)
|
||||
|
||||
draft_parts: list[str] = []
|
||||
last_checkpoint = time.monotonic()
|
||||
@@ -527,6 +525,14 @@ class DeepResearchJobExecutor:
|
||||
)
|
||||
last_checkpoint = time.monotonic()
|
||||
|
||||
async def on_thinking(delta: str) -> None:
|
||||
if delta:
|
||||
await events.publish_live(
|
||||
"document_rewrite",
|
||||
phase="writing",
|
||||
payload={"type": "thinking", "chunk": delta},
|
||||
)
|
||||
|
||||
try:
|
||||
rewritten, citation_ids, _usage = await stream_full_report_rewrite(
|
||||
session=session_for_model,
|
||||
@@ -536,6 +542,7 @@ class DeepResearchJobExecutor:
|
||||
style=style,
|
||||
model_name=model_name,
|
||||
on_delta=on_delta,
|
||||
on_reasoning=on_thinking,
|
||||
requirements_plan=requirements_plan,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
@@ -558,9 +565,7 @@ class DeepResearchJobExecutor:
|
||||
await events.emit("document_rewrite", phase="reviewing", payload={"type": "validation_started"})
|
||||
validation_error = validate_markdown(rewritten)
|
||||
if validation_error:
|
||||
await self._mark_document_rewrite_failed(
|
||||
job_id, session_id, lease_owner, "VALIDATION_FAILED", validation_error, events=events, stage="validation"
|
||||
)
|
||||
await self._mark_document_rewrite_failed(job_id, session_id, lease_owner, "VALIDATION_FAILED", validation_error, events=events, stage="validation")
|
||||
return
|
||||
|
||||
before_headings = heading_count(report)
|
||||
@@ -580,13 +585,9 @@ class DeepResearchJobExecutor:
|
||||
validation_warnings=warnings,
|
||||
)
|
||||
summary["citationCount"] = len(citation_ids)
|
||||
await events.emit(
|
||||
"document_rewrite", phase="reviewing", payload={"type": "comparison_ready", **summary}
|
||||
)
|
||||
await events.emit("document_rewrite", phase="reviewing", payload={"type": "comparison_ready", **summary})
|
||||
await events.emit("document_rewrite", phase="exporting", payload={"type": "commit_started"})
|
||||
await self._job_store.update_progress(
|
||||
job_id, lease_owner=lease_owner, phase="exporting", progress=90
|
||||
)
|
||||
await self._job_store.update_progress(job_id, lease_owner=lease_owner, phase="exporting", progress=90)
|
||||
|
||||
committed_hash = __import__("hashlib").sha256(rewritten.encode("utf-8")).hexdigest()
|
||||
updated = await self._session_store.replace_report_if_unchanged(
|
||||
@@ -641,9 +642,7 @@ class DeepResearchJobExecutor:
|
||||
expected_report=rewritten,
|
||||
report_markdown=report,
|
||||
)
|
||||
message = "保存改写版本失败,报告已恢复原文。" if rolled_back is not None else (
|
||||
"保存改写版本失败,且报告随后被修改,未再覆盖。"
|
||||
)
|
||||
message = "保存改写版本失败,报告已恢复原文。" if rolled_back is not None else ("保存改写版本失败,且报告随后被修改,未再覆盖。")
|
||||
await self._mark_document_rewrite_failed(
|
||||
job_id,
|
||||
session_id,
|
||||
@@ -747,6 +746,11 @@ class DeepResearchJobExecutor:
|
||||
instruction = str(snapshot.get("instruction") or "").strip()
|
||||
style = str(snapshot.get("style") or "professional").strip()
|
||||
model_name = str(snapshot.get("model_name") or "").strip() or None
|
||||
generate_images = bool(snapshot.get("generate_images"))
|
||||
try:
|
||||
max_generated_images = min(max(int(snapshot.get("max_generated_images") or 2), 1), 4)
|
||||
except (TypeError, ValueError):
|
||||
max_generated_images = 2
|
||||
|
||||
async def fail(code: str, message: str, *, stage: str = "generation") -> None:
|
||||
await events.emit(
|
||||
@@ -806,13 +810,20 @@ class DeepResearchJobExecutor:
|
||||
)
|
||||
return
|
||||
|
||||
rewrite_source = original
|
||||
if generate_images:
|
||||
# Do not ask the writer to preserve a previous run's managed image
|
||||
# blocks. A new illustration pass will create a fresh, local set;
|
||||
# user-authored image links remain untouched.
|
||||
from app.gateway.document_rewrite_illustrations import strip_managed_document_illustrations
|
||||
|
||||
rewrite_source = strip_managed_document_illustrations(original)
|
||||
|
||||
style_instruction = DOCUMENT_STYLE_INSTRUCTIONS.get(
|
||||
style,
|
||||
DOCUMENT_STYLE_INSTRUCTIONS["professional"],
|
||||
)
|
||||
resolved_name = model_name or (
|
||||
self._app_config.models[0].name if self._app_config.models else None
|
||||
)
|
||||
resolved_name = model_name or (self._app_config.models[0].name if self._app_config.models else None)
|
||||
model_cfg = self._app_config.get_model_config(resolved_name) if resolved_name else None
|
||||
thinking_enabled = bool(model_cfg and getattr(model_cfg, "supports_thinking", False))
|
||||
try:
|
||||
@@ -847,12 +858,21 @@ class DeepResearchJobExecutor:
|
||||
last_requirements_checkpoint = 0.0
|
||||
requirements_fallback = False
|
||||
try:
|
||||
async def on_requirement_thinking(delta: str) -> None:
|
||||
if delta:
|
||||
await events.publish_live(
|
||||
"document_rewrite",
|
||||
phase="planning",
|
||||
payload={"type": "thinking", "chunk": delta},
|
||||
)
|
||||
|
||||
async for delta in astream_rewrite_requirements(
|
||||
model,
|
||||
file_name=actual_path.name,
|
||||
instruction=instruction,
|
||||
style_instruction=style_instruction,
|
||||
original=original,
|
||||
original=rewrite_source,
|
||||
on_thinking=on_requirement_thinking,
|
||||
):
|
||||
requirement_parts.append(delta)
|
||||
requirement_draft = "".join(requirement_parts)
|
||||
@@ -907,7 +927,7 @@ class DeepResearchJobExecutor:
|
||||
f"{requirements_plan}\n"
|
||||
"【已确认的改写执行计划结束】\n\n"
|
||||
"【原始 Markdown 开始】\n"
|
||||
f"{original}\n"
|
||||
f"{rewrite_source}\n"
|
||||
"【原始 Markdown 结束】"
|
||||
)
|
||||
),
|
||||
@@ -932,6 +952,7 @@ class DeepResearchJobExecutor:
|
||||
|
||||
draft_parts: list[str] = []
|
||||
last_checkpoint = time.monotonic()
|
||||
inline_think_filter = InlineThinkTagFilter()
|
||||
try:
|
||||
async for chunk in model.astream(messages, config={"run_name": "document_rewrite"}):
|
||||
thinking_delta, text_delta = extract_stream_chunk_parts(chunk)
|
||||
@@ -941,6 +962,13 @@ class DeepResearchJobExecutor:
|
||||
phase="writing",
|
||||
payload={"type": "thinking", "chunk": thinking_delta},
|
||||
)
|
||||
text_delta, inline_thinking = inline_think_filter.push_parts(text_delta)
|
||||
if inline_thinking:
|
||||
await events.publish_live(
|
||||
"document_rewrite",
|
||||
phase="writing",
|
||||
payload={"type": "thinking", "chunk": inline_thinking},
|
||||
)
|
||||
if not text_delta:
|
||||
continue
|
||||
draft_parts.append(text_delta)
|
||||
@@ -972,6 +1000,26 @@ class DeepResearchJobExecutor:
|
||||
await fail(type(exc).__name__, str(exc))
|
||||
return
|
||||
|
||||
tail, inline_thinking_tail = inline_think_filter.finish_parts()
|
||||
if inline_thinking_tail:
|
||||
await events.publish_live(
|
||||
"document_rewrite",
|
||||
phase="writing",
|
||||
payload={"type": "thinking", "chunk": inline_thinking_tail},
|
||||
)
|
||||
if tail:
|
||||
draft_parts.append(tail)
|
||||
draft = "".join(draft_parts)
|
||||
await events.publish_live(
|
||||
"document_rewrite",
|
||||
phase="writing",
|
||||
payload={
|
||||
"type": "rewrite_delta",
|
||||
"delta": tail,
|
||||
"charCount": visible_document_char_count(draft),
|
||||
},
|
||||
)
|
||||
|
||||
rewritten = "".join(draft_parts).strip()
|
||||
await events.emit(
|
||||
"document_rewrite",
|
||||
@@ -982,6 +1030,148 @@ class DeepResearchJobExecutor:
|
||||
"charCount": visible_document_char_count(rewritten),
|
||||
},
|
||||
)
|
||||
|
||||
# Reject an invalid text draft before spending image-generation quota.
|
||||
validation_error = validate_markdown_document(rewritten)
|
||||
if validation_error:
|
||||
await fail("VALIDATION_FAILED", validation_error, stage="validation")
|
||||
return
|
||||
|
||||
generated_illustrations = []
|
||||
persisted_illustrations = []
|
||||
illustration_warnings: list[str] = []
|
||||
if generate_images:
|
||||
from app.gateway.document_rewrite_illustrations import generate_document_illustrations
|
||||
from deerflow.agents.deep_research.adapters.image import ImageGenerationSettings, build_image_provider
|
||||
|
||||
await self._job_store.update_progress(
|
||||
job_id,
|
||||
lease_owner=lease_owner,
|
||||
phase="illustrating",
|
||||
progress=88,
|
||||
)
|
||||
# Make the phase visible even when the configured provider is
|
||||
# unavailable or every provider request fails before a result.
|
||||
await events.emit(
|
||||
"document_rewrite",
|
||||
phase="illustrating",
|
||||
payload={
|
||||
"type": "illustration_started",
|
||||
"section": "准备生成文档配图",
|
||||
"position": 0,
|
||||
"total": max_generated_images,
|
||||
},
|
||||
)
|
||||
image_settings = ImageGenerationSettings.from_app_config()
|
||||
if not image_settings.is_configured:
|
||||
illustration_warnings.append("当前部署未配置图片生成服务,已跳过文档配图。")
|
||||
else:
|
||||
provider = build_image_provider()
|
||||
|
||||
async def on_image_started(section: str, position: int, total: int) -> None:
|
||||
await events.emit(
|
||||
"document_rewrite",
|
||||
phase="illustrating",
|
||||
payload={
|
||||
"type": "illustration_started",
|
||||
"section": section,
|
||||
"position": position,
|
||||
"total": total,
|
||||
},
|
||||
)
|
||||
|
||||
async def on_image_generated(image, total: int, generated_count: int) -> None:
|
||||
await events.emit(
|
||||
"document_rewrite",
|
||||
phase="illustrating",
|
||||
payload={
|
||||
"type": "illustration_generated",
|
||||
"section": image.section,
|
||||
"position": image.position,
|
||||
"total": total,
|
||||
"generatedCount": generated_count,
|
||||
},
|
||||
)
|
||||
|
||||
generated_illustrations, generated_warnings = await generate_document_illustrations(
|
||||
provider,
|
||||
markdown=rewritten,
|
||||
document_name=actual_path.name,
|
||||
maximum=max_generated_images,
|
||||
on_started=on_image_started,
|
||||
on_generated=on_image_generated,
|
||||
)
|
||||
illustration_warnings.extend(generated_warnings)
|
||||
|
||||
# Do not save images if a manual edit has invalidated the frozen
|
||||
# source version while image calls were in flight.
|
||||
current_before_images = await asyncio.to_thread(actual_path.read_text, encoding="utf-8")
|
||||
if content_hash(current_before_images) != base_hash:
|
||||
message = "文件在生成配图期间已被修改,已保留原文件。"
|
||||
await events.emit(
|
||||
"document_rewrite",
|
||||
phase="done",
|
||||
payload={"type": "conflict", "message": message},
|
||||
)
|
||||
await self._job_store.update_progress(
|
||||
job_id,
|
||||
lease_owner=lease_owner,
|
||||
status="completed",
|
||||
phase="done",
|
||||
progress=100,
|
||||
result_snapshot={
|
||||
"kind": "artifact_document_rewrite",
|
||||
"status": "conflict",
|
||||
"content": rewritten,
|
||||
"originalContent": original,
|
||||
"requirementsPlan": requirements_plan,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if generated_illustrations:
|
||||
from app.gateway.document_rewrite_illustrations import (
|
||||
insert_document_illustrations,
|
||||
persist_document_illustrations,
|
||||
)
|
||||
|
||||
try:
|
||||
persisted_illustrations, persistence_warnings = await asyncio.to_thread(
|
||||
persist_document_illustrations,
|
||||
generated_illustrations,
|
||||
markdown_path=actual_path,
|
||||
virtual_markdown_path=virtual_path,
|
||||
job_id=job_id,
|
||||
)
|
||||
illustration_warnings.extend(persistence_warnings)
|
||||
rewritten = insert_document_illustrations(rewritten, persisted_illustrations)
|
||||
await events.emit(
|
||||
"document_rewrite",
|
||||
phase="illustrating",
|
||||
payload={
|
||||
"type": "rewrite_checkpoint",
|
||||
"content": rewritten,
|
||||
"charCount": visible_document_char_count(rewritten),
|
||||
},
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("could not save artifact rewrite illustrations for %s", job_id)
|
||||
illustration_warnings.append("配图保存失败,已完成文字改写但未插入图片。")
|
||||
persisted_illustrations = []
|
||||
else:
|
||||
persisted_illustrations = []
|
||||
|
||||
await events.emit(
|
||||
"document_rewrite",
|
||||
phase="illustrating",
|
||||
payload={
|
||||
"type": "illustration_completed",
|
||||
"generatedCount": len(persisted_illustrations),
|
||||
"requestedCount": max_generated_images,
|
||||
"warnings": illustration_warnings,
|
||||
},
|
||||
)
|
||||
|
||||
await events.emit("document_rewrite", phase="reviewing", payload={"type": "validation_started"})
|
||||
validation_error = validate_markdown_document(rewritten)
|
||||
if validation_error:
|
||||
@@ -991,6 +1181,7 @@ class DeepResearchJobExecutor:
|
||||
heading_count_before = sum(1 for line in original.splitlines() if line.lstrip().startswith("#"))
|
||||
heading_count_after = sum(1 for line in rewritten.splitlines() if line.lstrip().startswith("#"))
|
||||
warnings = ["标题数量发生变化,请在前后对比中确认结构。"] if heading_count_before != heading_count_after else []
|
||||
warnings.extend(illustration_warnings)
|
||||
summary = build_document_rewrite_summary(
|
||||
source_display_name=actual_path.name,
|
||||
instruction=instruction,
|
||||
@@ -999,6 +1190,8 @@ class DeepResearchJobExecutor:
|
||||
rewritten=rewritten,
|
||||
validation_warnings=warnings,
|
||||
)
|
||||
summary["generatedImageCount"] = len(persisted_illustrations)
|
||||
summary["requestedImageCount"] = max_generated_images if generate_images else 0
|
||||
await events.emit(
|
||||
"document_rewrite",
|
||||
phase="reviewing",
|
||||
@@ -1124,9 +1317,7 @@ class DeepResearchJobExecutor:
|
||||
job_id=job_id,
|
||||
on_event=self._live_publisher,
|
||||
)
|
||||
await events.emit(
|
||||
"document_rewrite", phase="done", payload={"type": "error", "message": message, "stage": stage}
|
||||
)
|
||||
await events.emit("document_rewrite", phase="done", payload={"type": "error", "message": message, "stage": stage})
|
||||
await self._job_store.update_progress(
|
||||
job_id,
|
||||
lease_owner=lease_owner,
|
||||
@@ -1332,11 +1523,7 @@ class DeepResearchJobExecutor:
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"请为以下研究报告写一段 150-250 字的摘要,使用"
|
||||
f"{lang_note},一段纯文本,不要标题、不要列表、不要 Markdown 格式:\n\n"
|
||||
f"{report_markdown[:24_000]}"
|
||||
),
|
||||
"content": (f"请为以下研究报告写一段 150-250 字的摘要,使用{lang_note},一段纯文本,不要标题、不要列表、不要 Markdown 格式:\n\n{report_markdown[:24_000]}"),
|
||||
},
|
||||
],
|
||||
on_delta=on_delta,
|
||||
@@ -1369,10 +1556,18 @@ class DeepResearchJobExecutor:
|
||||
|
||||
# ── terminal-state writers ──────────────────────────────────────────────
|
||||
|
||||
async def _mark_completed(
|
||||
self, job_id: str, session_id: str, lease_owner: str | None, result: DeepResearchResult
|
||||
) -> None:
|
||||
# Job row → completed.
|
||||
async def _mark_completed(self, job_id: str, session_id: str, lease_owner: str | None, result: DeepResearchResult) -> None:
|
||||
# Persist the report projection before exposing a terminal completed
|
||||
# job. The session row is the durable report itself; marking the job
|
||||
# complete first and suppressing a session-write failure can otherwise
|
||||
# make a successfully generated report disappear after refresh.
|
||||
await self._persist_completed_report_projection(
|
||||
job_id=job_id,
|
||||
session_id=session_id,
|
||||
result=result,
|
||||
)
|
||||
|
||||
# Job row → completed only after the report is durable.
|
||||
await self._job_store.update_progress(
|
||||
job_id,
|
||||
lease_owner=lease_owner,
|
||||
@@ -1385,29 +1580,61 @@ class DeepResearchJobExecutor:
|
||||
"plan": result.plan,
|
||||
},
|
||||
)
|
||||
# Session row → completed + report.
|
||||
source_count = len(result.source_ids)
|
||||
with suppress(Exception): # noqa: BLE001
|
||||
await self._session_store.update(
|
||||
session_id,
|
||||
status="completed",
|
||||
report_markdown=result.report_markdown,
|
||||
report_html=result.report_html,
|
||||
plan_snapshot=result.plan,
|
||||
# Keep the terminal job id alongside model usage. The
|
||||
# frontend uses it only to replay the persisted research trace
|
||||
# after a refresh; it does not affect billing or job state.
|
||||
usage_snapshot={**(result.usage or {}), "lastJobId": job_id},
|
||||
source_count=source_count,
|
||||
active_job_id=None,
|
||||
)
|
||||
# Emit a terminal event for SSE consumers.
|
||||
with suppress(Exception): # noqa: BLE001
|
||||
await self._emit_terminal(job_id, session_id, "job_completed")
|
||||
|
||||
async def _mark_awaiting(
|
||||
self, job_id: str, session_id: str, pause: ResearchAwaitingInput
|
||||
async def _persist_completed_report_projection(
|
||||
self,
|
||||
*,
|
||||
job_id: str,
|
||||
session_id: str,
|
||||
result: DeepResearchResult,
|
||||
) -> None:
|
||||
"""Durably save the report before publishing job completion.
|
||||
|
||||
A write can succeed while a short-lived MySQL proxy/readback hiccup
|
||||
makes its caller fail. Retry the full owner-scoped update a few times;
|
||||
if it still cannot be confirmed, propagate the error so ``_run`` marks
|
||||
the job failed instead of advertising a report that cannot be reopened.
|
||||
"""
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, _REPORT_PROJECTION_RETRY_ATTEMPTS + 1):
|
||||
try:
|
||||
updated = await self._session_store.update(
|
||||
session_id,
|
||||
status="completed",
|
||||
report_markdown=result.report_markdown,
|
||||
report_html=result.report_html,
|
||||
plan_snapshot=result.plan,
|
||||
# Keep the terminal job id alongside model usage. The
|
||||
# frontend uses it only to replay the persisted research
|
||||
# trace after a refresh; it does not affect billing.
|
||||
usage_snapshot={**(result.usage or {}), "lastJobId": job_id},
|
||||
source_count=len(result.source_ids),
|
||||
active_job_id=None,
|
||||
)
|
||||
if updated is not None:
|
||||
return
|
||||
last_error = RuntimeError(f"Deep Research session {session_id} no longer exists")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_error = exc
|
||||
|
||||
if attempt < _REPORT_PROJECTION_RETRY_ATTEMPTS:
|
||||
logger.warning(
|
||||
"deep-research report projection failed for %s (attempt %s/%s); retrying",
|
||||
job_id,
|
||||
attempt,
|
||||
_REPORT_PROJECTION_RETRY_ATTEMPTS,
|
||||
exc_info=True,
|
||||
)
|
||||
await asyncio.sleep(_REPORT_PROJECTION_RETRY_DELAY_SECONDS * attempt)
|
||||
|
||||
raise RuntimeError(f"研究报告未能保存到会话 {session_id},任务不会标记为完成") from last_error
|
||||
|
||||
async def _mark_awaiting(self, job_id: str, session_id: str, pause: ResearchAwaitingInput) -> None:
|
||||
"""Project a persisted plan-review interrupt onto the owning session."""
|
||||
with suppress(Exception): # noqa: BLE001
|
||||
await self._session_store.update(
|
||||
@@ -1417,9 +1644,7 @@ class DeepResearchJobExecutor:
|
||||
active_job_id=job_id,
|
||||
)
|
||||
|
||||
async def _mark_failed(
|
||||
self, job_id: str, session_id: str, lease_owner: str | None, code: str, message: str
|
||||
) -> None:
|
||||
async def _mark_failed(self, job_id: str, session_id: str, lease_owner: str | None, code: str, message: str) -> None:
|
||||
await self._job_store.update_progress(
|
||||
job_id,
|
||||
lease_owner=lease_owner,
|
||||
|
||||
@@ -85,6 +85,7 @@ async def stream_full_report_rewrite(
|
||||
style: str,
|
||||
model_name: str | None,
|
||||
on_delta: Callable[[str], Awaitable[None]],
|
||||
on_reasoning: Callable[[str], Awaitable[None]] | None = None,
|
||||
requirements_plan: str | None = None,
|
||||
) -> tuple[str, list[str], dict[str, Any]]:
|
||||
"""Generate a complete Markdown replacement without starting new research."""
|
||||
@@ -126,6 +127,7 @@ async def stream_full_report_rewrite(
|
||||
messages=messages,
|
||||
operation="full_report_rewrite",
|
||||
on_delta=on_delta,
|
||||
on_reasoning=on_reasoning,
|
||||
)
|
||||
answer = result.text.strip() or report
|
||||
answer, cited = _normalise_citations(answer, source_ids)
|
||||
@@ -140,6 +142,7 @@ async def stream_full_report_rewrite_requirements(
|
||||
style: str,
|
||||
model_name: str | None,
|
||||
on_delta: Callable[[str], Awaitable[None]],
|
||||
on_reasoning: Callable[[str], Awaitable[None]] | None = None,
|
||||
) -> str:
|
||||
"""Stream a compact plan before rewriting a research report.
|
||||
|
||||
@@ -169,6 +172,7 @@ async def stream_full_report_rewrite_requirements(
|
||||
],
|
||||
operation="full_report_rewrite_requirements",
|
||||
on_delta=on_delta,
|
||||
on_reasoning=on_reasoning,
|
||||
)
|
||||
return result.text.strip()
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,263 @@
|
||||
"""Safe, optional illustrations for a sandbox Markdown full rewrite.
|
||||
|
||||
The writer streams prose first. When requested, this module derives a small
|
||||
set of heading-scoped illustration prompts, persists the returned bytes inside
|
||||
the same user-owned artifact tree, then adds only local ``/mnt`` references to
|
||||
the Markdown. Provider URLs never reach the browser or the saved document.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from deerflow.agents.deep_research.adapters.image import ImageGenerationError, ImageProvider
|
||||
|
||||
_HEADING_RE = re.compile(r"^##[ \t]+([^\r\n]+?)[ \t]*$", re.MULTILINE)
|
||||
_TITLE_RE = re.compile(r"^#[ \t]+([^\r\n]+?)[ \t]*$", re.MULTILINE)
|
||||
_START_MARKER = "<!-- deerflow:ai-rewrite-image:start -->"
|
||||
_END_MARKER = "<!-- deerflow:ai-rewrite-image:end -->"
|
||||
_MANAGED_IMAGE_BLOCK_RE = re.compile(
|
||||
rf"\n?{re.escape(_START_MARKER)}.*?{re.escape(_END_MARKER)}\n?",
|
||||
re.DOTALL,
|
||||
)
|
||||
_MIME_EXTENSIONS = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/webp": "webp",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DocumentIllustration:
|
||||
"""A provider result that has not been exposed or persisted yet."""
|
||||
|
||||
section: str
|
||||
position: int
|
||||
data: bytes
|
||||
mime_type: str
|
||||
alt: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PersistedDocumentIllustration:
|
||||
"""A generated image with a virtual artifact path safe for Markdown."""
|
||||
|
||||
section: str
|
||||
position: int
|
||||
virtual_path: str
|
||||
alt: str
|
||||
|
||||
|
||||
def strip_managed_document_illustrations(markdown: str) -> str:
|
||||
"""Remove only DeerFlow-generated image blocks before a fresh image pass."""
|
||||
|
||||
return _MANAGED_IMAGE_BLOCK_RE.sub("\n", markdown).strip()
|
||||
|
||||
|
||||
def select_illustration_sections(markdown: str, maximum: int) -> list[str]:
|
||||
"""Pick distinct H2 sections; fall back to the title for short documents."""
|
||||
|
||||
limit = max(0, min(maximum, 4))
|
||||
if not limit:
|
||||
return []
|
||||
sections: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for match in _HEADING_RE.finditer(markdown):
|
||||
section = _clean_heading(match.group(1))
|
||||
if section and section not in seen:
|
||||
seen.add(section)
|
||||
sections.append(section)
|
||||
if len(sections) >= limit:
|
||||
return sections
|
||||
if sections:
|
||||
return sections
|
||||
title_match = _TITLE_RE.search(markdown)
|
||||
title = _clean_heading(title_match.group(1)) if title_match else "文档主题"
|
||||
return [title or "文档主题"]
|
||||
|
||||
|
||||
async def generate_document_illustrations(
|
||||
provider: ImageProvider,
|
||||
*,
|
||||
markdown: str,
|
||||
document_name: str,
|
||||
maximum: int,
|
||||
on_started=None,
|
||||
on_generated=None,
|
||||
) -> tuple[list[DocumentIllustration], list[str]]:
|
||||
"""Generate a bounded, sequential image set and keep failures recoverable."""
|
||||
|
||||
sections = select_illustration_sections(markdown, maximum)
|
||||
title_match = _TITLE_RE.search(markdown)
|
||||
document_title = _clean_heading(title_match.group(1)) if title_match else Path(document_name).stem
|
||||
generated: list[DocumentIllustration] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
for position, section in enumerate(sections, 1):
|
||||
if on_started is not None:
|
||||
await on_started(section, position, len(sections))
|
||||
try:
|
||||
result = await provider.generate(
|
||||
_illustration_prompt(document_title, section),
|
||||
section_id=section,
|
||||
)
|
||||
data = result.get("data") if isinstance(result, dict) else None
|
||||
if not isinstance(data, bytes) or not data:
|
||||
raise ImageGenerationError("图片生成服务未返回可保存的图片数据")
|
||||
mime_type = str(result.get("mime_type") or "image/png").lower()
|
||||
if mime_type not in _MIME_EXTENSIONS:
|
||||
mime_type = "image/png"
|
||||
illustration = DocumentIllustration(
|
||||
section=section,
|
||||
position=position,
|
||||
data=data,
|
||||
mime_type=mime_type,
|
||||
alt=_clean_alt(str(result.get("alt") or f"{section}配图")),
|
||||
)
|
||||
generated.append(illustration)
|
||||
if on_generated is not None:
|
||||
await on_generated(illustration, len(sections), len(generated))
|
||||
except ImageGenerationError as exc:
|
||||
warnings.append(f"“{section}”配图未生成:{exc}")
|
||||
except Exception: # noqa: BLE001
|
||||
# Generation remains best effort: an unavailable image provider
|
||||
# must never discard an otherwise complete document rewrite.
|
||||
warnings.append(f"“{section}”配图生成失败,已保留文字改写结果。")
|
||||
return generated, warnings
|
||||
|
||||
|
||||
def persist_document_illustrations(
|
||||
illustrations: list[DocumentIllustration],
|
||||
*,
|
||||
markdown_path: Path,
|
||||
virtual_markdown_path: str,
|
||||
job_id: str,
|
||||
) -> tuple[list[PersistedDocumentIllustration], list[str]]:
|
||||
"""Best-effort persist each asset beside the rewritten Markdown file.
|
||||
|
||||
A single failed local write must not discard earlier successful images. In
|
||||
particular, Windows development reloaders can briefly recreate an output
|
||||
directory while a report is being written. The caller receives warnings
|
||||
for the timeline, while any persisted image remains usable in the final
|
||||
Markdown.
|
||||
"""
|
||||
|
||||
if not illustrations:
|
||||
return [], []
|
||||
# Keep this name intentionally short. On Windows the local sandbox path
|
||||
# already contains user/thread UUIDs; including a long report title plus a
|
||||
# UUID-based temporary filename exceeded MAX_PATH (and surfaced as a
|
||||
# misleading FileNotFoundError). Job ids make names unique across files,
|
||||
# so a single short hidden directory per outputs folder is sufficient.
|
||||
asset_directory_name = ".ai-images"
|
||||
asset_directory = markdown_path.parent / asset_directory_name
|
||||
virtual_parent = PurePosixPath(virtual_markdown_path).parent.as_posix().rstrip("/")
|
||||
persisted: list[PersistedDocumentIllustration] = []
|
||||
warnings: list[str] = []
|
||||
for image in illustrations:
|
||||
try:
|
||||
extension = _MIME_EXTENSIONS[image.mime_type]
|
||||
file_name = f"{job_id}-{image.position:02d}.{extension}"
|
||||
_atomic_write_bytes(asset_directory / file_name, image.data)
|
||||
persisted.append(
|
||||
PersistedDocumentIllustration(
|
||||
section=image.section,
|
||||
position=image.position,
|
||||
virtual_path=f"{virtual_parent}/{asset_directory_name}/{file_name}",
|
||||
alt=image.alt,
|
||||
)
|
||||
)
|
||||
except OSError:
|
||||
warnings.append(f"“{image.section}”配图保存失败,已继续保留其他成功配图。")
|
||||
return persisted, warnings
|
||||
|
||||
|
||||
def insert_document_illustrations(
|
||||
markdown: str,
|
||||
illustrations: list[PersistedDocumentIllustration],
|
||||
) -> str:
|
||||
"""Insert fenced local-image blocks directly below their selected sections."""
|
||||
|
||||
amended = strip_managed_document_illustrations(markdown)
|
||||
for image in illustrations:
|
||||
marker = f"## {image.section}"
|
||||
block = (
|
||||
f"\n\n{_START_MARKER}\n"
|
||||
f"\n"
|
||||
f"{_END_MARKER}\n"
|
||||
)
|
||||
if marker in amended:
|
||||
amended = amended.replace(marker, f"{marker}{block}", 1)
|
||||
else:
|
||||
amended = f"{amended.rstrip()}{block}"
|
||||
return amended.strip()
|
||||
|
||||
|
||||
def _atomic_write_bytes(path: Path, content: bytes) -> None:
|
||||
"""Write one image atomically with Windows-safe temporary path lengths."""
|
||||
|
||||
last_error: FileNotFoundError | None = None
|
||||
for _attempt in range(3):
|
||||
temporary: Path | None = None
|
||||
try:
|
||||
# A runtime cleanup can remove a freshly-created asset folder
|
||||
# between image calls. Recreate it immediately before writing and
|
||||
# retry this small local operation. NamedTemporaryFile deliberately
|
||||
# keeps the temporary basename short: appending a report title,
|
||||
# job id, and UUID can exceed Windows MAX_PATH before os.replace.
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
dir=path.parent,
|
||||
prefix=".w-",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as file:
|
||||
temporary = Path(file.name)
|
||||
file.write(content)
|
||||
file.flush()
|
||||
os.fsync(file.fileno())
|
||||
os.replace(temporary, path)
|
||||
return
|
||||
except FileNotFoundError as exc:
|
||||
last_error = exc
|
||||
finally:
|
||||
try:
|
||||
if temporary is not None and temporary.exists():
|
||||
temporary.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
|
||||
|
||||
def _illustration_prompt(document_title: str, section: str) -> str:
|
||||
return (
|
||||
"为一份专业 Markdown 文档生成一张可嵌入的章节配图。"
|
||||
f"文档主题:{_clean_heading(document_title)}。章节:{section}。"
|
||||
"画面应信息性、简洁、与正文内容一致,可使用抽象示意、数据可视化风格或场景插画。"
|
||||
"不要在图片中放置可读文字、品牌标志、水印或人物肖像;不得虚构具体数据或事实。"
|
||||
)
|
||||
|
||||
|
||||
def _clean_heading(value: str) -> str:
|
||||
return " ".join(value.replace("\r", " ").replace("\n", " ").split())[:160]
|
||||
|
||||
|
||||
def _clean_alt(value: str) -> str:
|
||||
return _clean_heading(value).replace("[", "").replace("]", "") or "文档配图"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DocumentIllustration",
|
||||
"PersistedDocumentIllustration",
|
||||
"generate_document_illustrations",
|
||||
"insert_document_illustrations",
|
||||
"persist_document_illustrations",
|
||||
"select_illustration_sections",
|
||||
"strip_managed_document_illustrations",
|
||||
]
|
||||
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user