Merge remote-tracking branch 'origin/master' into ji

# Conflicts:
#	.gitignore
#	frontend-web/src/components/workspace/artifacts/context.tsx
#	frontend-web/src/components/workspace/messages/message-group.tsx
#	offline-backend-20260512/backend/CLAUDE.md
#	offline-backend-20260512/backend/README.md
#	offline-backend-20260512/backend/app/gateway/app.py
#	offline-backend-20260512/backend/app/gateway/deps.py
#	offline-backend-20260512/backend/app/gateway/routers/llmwiki.py
This commit is contained in:
吉鑫升 committed 2026-08-24 14:21:19 +08:00
commit e818c24769
262 files changed
+29527 -5236

No files matched your search

+47 -36
View File
@@ -1,10 +1,10 @@
# Workspace/editor
.vscode/*
!.vscode/launch.json
!.vscode/tasks.json
!.vscode/extensions.json
!.vscode/settings.json
frontend-web/.vscode/
.vscode/*
!.vscode/launch.json
!.vscode/tasks.json
!.vscode/extensions.json
!.vscode/settings.json
frontend-web/.vscode/
node_modules/
# Claude Code local-only settings (per-user permissions, not shared)
.claude/settings.local.json
@@ -56,9 +56,9 @@ offline-backend-20260505/bundle/start_backend.sh
offline-backend-20260505/bundle/stop_backend.sh
# offline-backend-20260512 runtime artifacts (large files, local config, data)
offline-backend-20260512/.env.example
offline-backend-20260512/.env
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
offline-backend-20260512/.env.example
offline-backend-20260512/.env
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
offline-backend-20260512/SHA256SUMS
offline-backend-20260512/config.yaml.example
offline-backend-20260512/data/
@@ -66,14 +66,14 @@ offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
offline-backend-20260512/extensions_config.json
offline-backend-20260512/skills/
offline-backend-20260512/wheelhouse/
offline-backend-20260512/backend/.deer-flow/data/*.db
offline-backend-20260512/backend/.deer-flow/data/*.db-*
offline-backend-20260512/backend/.deer-flow/*
offline-backend-20260512/backend/.env
offline-backend-20260512/backend/.env.local
offline-backend-20260512/start_backend.sh
offline-backend-20260512/stop_backend.sh
offline-backend-20260512/check_backend.sh
offline-backend-20260512/backend/.deer-flow/data/*.db
offline-backend-20260512/backend/.deer-flow/data/*.db-*
offline-backend-20260512/backend/.deer-flow/*
offline-backend-20260512/backend/.env
offline-backend-20260512/backend/.env.local
offline-backend-20260512/start_backend.sh
offline-backend-20260512/stop_backend.sh
offline-backend-20260512/check_backend.sh
hermes-agent-main/
@@ -84,8 +84,8 @@ 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
@@ -101,6 +101,17 @@ offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.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
# Independent enterprise-research persistence, HTTP and race regressions.
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
deploy
.history
@@ -110,22 +121,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
@@ -142,4 +153,4 @@ offline-backend-20260512/backend/uv.lock
__pycache__/
# misc
.DS_Store
.DS_Store
+2 -2
View File
@@ -81,11 +81,11 @@ 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/`。
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
+2 -2
View File
@@ -82,11 +82,11 @@ 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/`。
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, openMode, loginParams, appendAuth, authTokenParam, authNameParam, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=<JWT>&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」页,在 **both** 单智能体 (`TaskFxWorkspace`) 与 多智能体会商 (`RoundtablePlanningPage` 深链 rwfx-A) 都显示(共用 `TaskJumpBar`)。**`runTaskButton` 的 `url` 类型支持应用内路由**:target 以单个 `/` 开头时按内部路由处理——`self` 走 react-router 同窗导航(不刷新),`blank` 拼成当前源的 hash 路由新标签打开;外链行为不变。 The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三Line truncated
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, openMode, loginParams, appendAuth, authTokenParam, authNameParam, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route (e.g. `/task/item-aiceh/step-analysis-audience`), `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). Independent-tab clicks toast and do nothing. **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=<JWT>&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」Line truncated
- **实时并发监控 (Concurrency / System-Pressure Monitor, admin-only)**: a live panel mounted at the **top of the admin 排行榜 page** (`components/workspace/admin/concurrency-monitor.tsx` `ConcurrencyMonitorPanel`, rendered in `pages/AdminUserLeaderboardPage.tsx`) showing **当前并发的大模型调用数 + 列表(用户/智能体/类型/已运行时长)**, each row with a 「停止」button (and a 「全部停止」) that **truly aborts** that run's in-flight model stream via `RunManager.cancel` (`POST /api/admin/active-runs/{run_id}/cancel`) — to relieve pressure when 使用人数过多. Two line charts (minute/hour toggle): **并发量折线图** (peak/avg, `GET /api/admin/active-runs/concurrency`, fed by a lightweight background sampler into `concurrency_samples`) and **大模型出字速度折线图** (tokens/sec, `GET /api/admin/active-runs/token-speed`, aggregated from `llm_call_metrics`) so a dip pinpoints where the bottleneck is. A **「后台采样」Switch** (`system_settings.json → concurrency_monitor.enabled`, **default off / opt-in**, `GET|PUT /api/admin/active-runs/settings`) turns the background sampling on/off **at runtime, no restart** (the live concurrency count + 「停止」 work even while sampling is off — only the 并发量 history chart needs it on); everything is designed to **never impact production** (one tiny INSERT/15s, retention-purged, best-effort). Client/hooks: `core/admin/active-runs.ts` + `core/admin/hooks.ts` (`useActiveRuns`/`useConcurrencySeries`/`useTokenSpeedSeries`/`useCancel*`/`useMonitorSettings`). See the backend CLAUDE.md「Admin Concurrency Monitor」.
- **会话冲突 409 中文化**: when a user submits a new question while the previous turn is still streaming, the backend's `RunManager.create_or_reject` rejects with HTTP 409 (`services.py` now returns a **Chinese** detail「当前对话还在生成回答…」). `core/threads/hooks.ts` `onError` detects the conflict (`isActiveRunConflictError`: 409 / "already has an active run" / the Chinese text) and shows a friendly `toast.info` instead of a raw error.
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
+3
View File
@@ -56,4 +56,7 @@
# VITE_STATIC_WEBSITE_ONLY=
# 部署级紧凑导航:true 时固定收起工作台左侧栏,并隐藏换肤与全量/简洁模式切换。
# VITE_FORCE_COLLAPSED_SIDEBAR=false
# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。
# VITE_COMPACT_MENU_TYPE=A
# VITE_GITHUB_OAUTH_TOKEN=
File diff suppressed because it is too large. Load diff
+21 -3
View File
@@ -32,10 +32,10 @@ window.__CM_RUNTIME_CONFIG__ = {
VITE_BRAND_WELCOME_DARK_URL: "",
// DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。
// VITE_BACKEND_BASE_URL: "http://10.19.200.5:2026",
// VITE_BACKEND_BASE_URL: "https://ch1.b.uat.4.cn/deerflow",
// LangGraph 流式对话接口地址:普通问答、智能体对话等流式能力使用。
// VITE_LANGGRAPH_BASE_URL: "http://10.19.200.5:2026/api",
// VITE_LANGGRAPH_BASE_URL: "https://ch1.b.uat.4.cn/deerflow/api",
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
@@ -50,6 +50,19 @@ 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: "",
// 任务深链「宿主路由」按钮的 postMessage targetOrigin(HOST_NAVIGATE)。
// 填宿主系统 origin,例如 https://localhost:4912;生产改成他们线上地址。
// 留空时尝试用 iframe 的 ancestorOrigins / referrer 自动探测,探测失败则不发送。
VITE_HOST_NAVIGATE_ORIGIN: "",
// 佳来模型对话接口。
VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt",
@@ -90,7 +103,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 +143,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;
}
@@ -72,6 +72,7 @@ import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
bootstrapResumePath,
isBootstrapThread,
isHiddenFromRecentChats,
isThreadFavorite,
isThreadPinned,
sortThreadsPinnedFirst,
@@ -372,17 +373,10 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
const [renameValue, setRenameValue] = useState("");
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
// 排除调度/会商类系统会话,与首页一致;智能体创建(bootstrap)会话保留展示
// (带机器人图标、点击回创建页);置顶优先。
// 排除调度/会商/报告结构&写作配置向导等系统会话,与首页一致;
// 智能体创建(bootstrap)会话保留展示(带机器人图标、点击回创建页);置顶优先。
const visibleThreads = useMemo(
() =>
sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable",
),
),
() => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
[threads],
);
@@ -2,6 +2,7 @@
import {
BotIcon,
BriefcaseBusinessIcon,
CheckIcon,
DatabaseIcon,
GlobeIcon,
@@ -150,6 +151,7 @@ export function AgentDetailPanel({
skillsIcon,
skillsTitle,
showCommonQuestions = false,
jobResponsibilities,
panelTitle,
embedded,
hideStatusBadges = false,
@@ -170,6 +172,11 @@ export function AgentDetailPanel({
skillsIcon?: ReactNode;
skillsTitle?: string;
showCommonQuestions?: boolean;
/**
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
* 不传则隐藏该区块。此字段来自业务链,不随智能体本身编辑。
*/
jobResponsibilities?: string | null;
/** Overrides the standard panel heading for an embedded consumer. */
panelTitle?: string;
/** Renders one fixed panel instead of the responsive collapsible rail. */
@@ -720,6 +727,23 @@ export function AgentDetailPanel({
</section>
{/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */}
{jobResponsibilities !== undefined && (
<Section
icon={<BriefcaseBusinessIcon className="size-3.5" />}
title="岗位职责"
>
{jobResponsibilities?.trim() ? (
<p className="text-muted-foreground text-sm leading-relaxed whitespace-pre-wrap">
{desensitize(jobResponsibilities)}
</p>
) : (
<p className="text-muted-foreground/60 text-sm">暂无岗位职责</p>
)}
</Section>
)}
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
{(showCommonQuestions || editing || questions.length > 0 || canEdit) && (
<Section
@@ -346,15 +346,22 @@ export function AgentGallery() {
请输入访问密码,验证通过后才能查看其中的智能体。
</p>
</div>
<div className="flex flex-col gap-2">
<form
className="flex flex-col gap-2"
onSubmit={(event) => {
event.preventDefault();
handleSubmitPassword();
}}
>
<label className="sr-only" htmlFor="private-square-access-code">
访问密码
</label>
<Input
id="private-square-access-code"
type="password"
placeholder="访问密码"
value={passwordInput}
onChange={(e) => setPasswordInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmitPassword();
}}
// This is a one-off access code, not a login credential. Tell
// browsers / password managers not to save or autofill it (which
// would also stop them from spilling a saved account into the
@@ -365,8 +372,8 @@ export function AgentGallery() {
data-lpignore="true"
data-form-type="other"
/>
<Button onClick={handleSubmitPassword}>进入私密广场</Button>
</div>
<Button type="submit">进入私密广场</Button>
</form>
</div>
);
}
@@ -5,7 +5,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useThreadsPage } from "@/core/threads/hooks";
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -22,12 +22,9 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
pageSize: RECENT_LIMIT,
metadata: { agent_id: agentId },
});
// 该智能体的会话里同样排除调度 / 引导 / 会商类系统会话。
// 该智能体的会话里同样排除引导 / 系统 / 配置向导会话。
const threads = (data?.threads ?? []).filter(
(th) =>
th.metadata?.thread_type !== "bootstrap" &&
th.metadata?.thread_type !== "scheduler" &&
th.metadata?.thread_type !== "roundtable",
(th) => !isBootstrapThread(th) && !isHiddenFromRecentChats(th),
);
return (
@@ -14,11 +14,12 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import type { CompactShortcutRouteParam } from "@/core/system-settings/api";
import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks";
import { isHideChromeActive } from "@/core/embed/embed-session";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
import { buildLightAppUrl, resolveRouteParamValue } from "@/strategy-components/lib/light-app";
/**
* 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用,
@@ -47,6 +48,34 @@ const triggerToneClass = (tone: AppearanceControlTone) =>
? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30"
: "text-muted-foreground hover:bg-muted hover:text-foreground";
function appendShortcutPathParams(url: string, params: CompactShortcutRouteParam[]) {
const pathSegments = params
.filter((param) => param.paramType === "path")
.map((param) =>
resolveRouteParamValue({ ...param, paramType: "login" }).trim(),
)
.filter(Boolean)
.map((value) => encodeURIComponent(value));
if (pathSegments.length === 0) return url;
const hashIndex = url.indexOf("#");
if (hashIndex < 0) {
const [path, query = ""] = url.split("?", 2);
return `${path.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`;
}
const beforeHash = url.slice(0, hashIndex);
const fragment = url.slice(hashIndex + 1);
const [routePath, query = ""] = fragment.split("?", 2);
return `${beforeHash}#${routePath.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`;
}
function isQueryShortcutParam(
param: CompactShortcutRouteParam,
): param is CompactShortcutRouteParam & { paramType: "login" | "theme" } {
return param.paramType === "login" || param.paramType === "theme";
}
export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) {
const { data: appearance } = useAppearanceSettings();
const buttons = (appearance?.compact_shortcut_buttons ?? [])
@@ -69,9 +98,10 @@ export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }
)}
title={button.label}
onClick={() => {
const routeParams = (button.route_params ?? []).map((param) => ({ ...param }));
const url = buildLightAppUrl({
url: button.url,
routeParams: button.route_params.map((param) => ({ ...param })),
url: appendShortcutPathParams(button.url, routeParams),
routeParams: routeParams.filter(isQueryShortcutParam),
});
window.open(url, "_blank", "noopener,noreferrer");
}}
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,5 @@
import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react";
import { useCallback, useState } from "react";
import { useCallback, useState, type ReactNode } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -29,6 +29,7 @@ export function ArtifactFileList({
threadId,
onOpenArtifact,
onDownloadArtifact,
fileDescriptions,
}: {
className?: string;
files: string[];
@@ -36,6 +37,8 @@ export function ArtifactFileList({
onOpenArtifact?: (filepath: string) => void;
/** Optional host download action for virtual/session-owned artifacts. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
/** Optional per-file secondary text, such as the report structure used. */
fileDescriptions?: Record<string, ReactNode>;
}) {
const { t } = useI18n();
const { isMock, publicShareCode } = useThread();
@@ -102,10 +105,10 @@ export function ArtifactFileList({
<div className="absolute top-2 -left-0.5">
{getFileIcon(file, "size-6")}
</div>
</CardTitle>
<CardDescription className="min-w-0 pl-8 text-xs">
{getFileExtensionDisplayName(file)} file
</CardDescription>
</CardTitle>
<CardDescription className="min-w-0 pl-8 text-xs">
{fileDescriptions?.[file] ?? `${getFileExtensionDisplayName(file)} file`}
</CardDescription>
<CardAction className="row-span-1 self-center">
{file.endsWith(".skill") && (
<Button
@@ -56,12 +56,12 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifactsState] = useState<string[]>([]);
const setArtifacts = useCallback((next: string[]) => {
const normalized = Array.isArray(next) ? next : [];
setArtifactsState((current) => {
const unchanged =
current.length === normalized.length &&
current.every((artifact, index) => artifact === normalized[index]);
return unchanged ? current : normalized;
});
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);
@@ -71,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],
);
@@ -92,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);
}
@@ -106,34 +114,33 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
);
const deselect = useCallback(() => {
setSelectedArtifact(null);
setSelectedArtifactKey(null);
setAutoSelect(true);
setOpenState(false);
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 && autoOpen) {
setAutoOpen(false);
setAutoSelect(false);
}
if (!isOpen) {
suppressedAutoOpenKeyRef.current = selectedArtifactKey;
}
setOpenState(isOpen);
},
[autoOpen, selectedArtifactKey],
);
const value: ArtifactsContextType = {
artifacts,
setArtifacts,
open,
autoOpen,
autoSelect,
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,
setArtifacts,
open,
autoOpen,
autoSelect,
setOpen,
selectedArtifact,
@@ -141,7 +148,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
select,
openStreamedArtifact,
deselect,
};
};
return (
<ArtifactsContext.Provider value={value}>
@@ -1,7 +1,7 @@
"use client";
import { SparklesIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { FilePenLineIcon, ImagePlusIcon, SparklesIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
@@ -20,8 +20,24 @@ 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";
import { useReportStructures, REPORT_STRUCTURE_MODE_OPTIONS, normalizeReportStructureMode, type ReportStructureMode } from "@/strategy-components/api/report-structures";
const KEEP_CURRENT_REPORT_STRUCTURE = "__keep_current_report_structure__";
const DEFAULT_REWRITE_INSTRUCTION = "统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。";
const DEFAULT_GENERATE_INSTRUCTION = "基于已选资料按所选报告结构生成一篇新的完整报告;保持事实可核验,不补充没有依据的信息。";
export interface DocumentRewriteDialogInput {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
reportOutline: string;
structureMode: "fixed" | "adaptive";
generateImages: boolean;
maxGeneratedImages: number;
}
const STYLE_OPTIONS: { value: DocumentRewriteStyle; label: string; description: string }[] = [
{
@@ -51,64 +67,130 @@ export function DocumentRewriteDialog({
onOpenChange,
fileName,
charCount,
imageGenerationAvailable = false,
maxGeneratedImages = 4,
initialReportOutline,
operationLabel,
operationDescription,
submitLabel,
operation,
onStart,
onRewriteOriginal,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
fileName: string;
charCount: number;
onStart: (input: {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
}) => void;
imageGenerationAvailable?: boolean;
maxGeneratedImages?: number;
/** A saved report structure selected before the dialog was opened. */
initialReportOutline?: string;
/** Optional copy for flows that create a new document instead of replacing one. */
operationLabel?: string;
operationDescription?: string;
submitLabel?: string;
/** ``generate`` creates an independent report; ``rewrite`` edits this report in place. */
operation?: "rewrite" | "generate";
onStart: (input: DocumentRewriteDialogInput) => void;
/** Optional secondary action for generation flows that rewrites the source report in place. */
onRewriteOriginal?: (input: DocumentRewriteDialogInput) => void;
}) {
const [instruction, setInstruction] = useState(
"统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。",
);
const isNewReportGeneration = operation === "generate";
const [instruction, setInstruction] = useState(DEFAULT_REWRITE_INSTRUCTION);
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
const [modelName, setModelName] = useState<string | null>(null);
const [reportStructureId, setReportStructureId] = useState(KEEP_CURRENT_REPORT_STRUCTURE);
const [structureMode, setStructureMode] = useState<ReportStructureMode>("adaptive");
const [generateImages, setGenerateImages] = useState(false);
const [imageCount, setImageCount] = useState("2");
const reportStructuresQuery = useReportStructures({ status: "enabled" });
const reportStructures = reportStructuresQuery.data ?? [];
const selectedReportStructure = reportStructures.find((item) => item.id === reportStructureId);
const initialReportStructureId = useMemo(() => {
const outline = initialReportOutline?.trim();
if (!outline) return null;
return reportStructures.find((item) => item.content.trim() === outline)?.id ?? null;
}, [initialReportOutline, reportStructures]);
useEffect(() => {
if (!open) return;
setInstruction(isNewReportGeneration ? DEFAULT_GENERATE_INSTRUCTION : DEFAULT_REWRITE_INSTRUCTION);
setStyle("formal_analysis");
setModelName(null);
}, [open]);
setReportStructureId(KEEP_CURRENT_REPORT_STRUCTURE);
setStructureMode("adaptive");
setGenerateImages(false);
setImageCount("2");
}, [isNewReportGeneration, open]);
// The structures query may resolve after the dialog opens. Only seed the
// value while the user still has the default selection, so a manual choice
// is never overwritten by a query refresh.
useEffect(() => {
if (!open || !initialReportStructureId) return;
setReportStructureId((current) => (
current === KEEP_CURRENT_REPORT_STRUCTURE ? initialReportStructureId : current
));
}, [initialReportStructureId, open]);
useEffect(() => {
if (!selectedReportStructure) return;
setStructureMode(selectedReportStructure.structureMode);
}, [selectedReportStructure?.id, selectedReportStructure?.structureMode]);
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
const canStart = instruction.trim().length > 0;
const startWith = (handler: (input: DocumentRewriteDialogInput) => void) => {
if (!canStart) return;
handler({
instruction: instruction.trim(),
style,
modelName,
reportOutline: selectedReportStructure?.content ?? "",
structureMode,
generateImages,
maxGeneratedImages: Math.min(
Math.max(Number(imageCount) || 1, 1),
Math.max(Math.min(maxGeneratedImages, 4), 1),
),
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<SparklesIcon className="size-5 text-primary" />
AI 全文改写
{operationLabel ?? "AI 全文改写"}
</DialogTitle>
<DialogDescription>
将以当前文件快照生成临时草稿。校验通过前不会覆盖原 Markdown。
{operationDescription ?? "将以当前文件快照生成临时草稿。校验通过前不会覆盖原 Markdown。"}
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border bg-muted/35 px-3 py-2.5 text-sm">
<div className="font-medium">{fileName}</div>
<div className="text-muted-foreground mt-1">原文约 {charCount.toLocaleString()} 字</div>
<div className="text-muted-foreground mt-1">
{isNewReportGeneration ? "来源报告" : "原文"}约 {charCount.toLocaleString()} 字
</div>
</div>
<label className="grid gap-2 text-sm font-medium">
改写要求
{isNewReportGeneration ? "生成要求" : "改写要求"}
<Textarea
value={instruction}
onChange={(event) => setInstruction(event.target.value)}
placeholder="例如:统一为正式行业分析语气,减少重复,不新增外部事实。"
placeholder={isNewReportGeneration
? "例如:突出政策影响,并按所选结构组织章节;仅使用已选资料。"
: "例如:统一为正式行业分析语气,减少重复,不新增外部事实。"}
rows={4}
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm font-medium">
改写风格
{isNewReportGeneration ? "报告风格" : "改写风格"}
<Select value={style} onValueChange={(value) => setStyle(value as DocumentRewriteStyle)}>
<SelectTrigger>
<SelectValue />
@@ -124,6 +206,58 @@ export function DocumentRewriteDialog({
<span className="text-muted-foreground text-xs">{selectedStyle?.description}</span>
</label>
<label className="grid gap-2 text-sm font-medium">
报告结构
<Select value={reportStructureId} onValueChange={setReportStructureId}>
<SelectTrigger aria-label="报告结构">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={KEEP_CURRENT_REPORT_STRUCTURE}>沿用当前结构</SelectItem>
{reportStructuresQuery.isLoading && (
<SelectItem value="__report_structures_loading__" disabled>
正在加载结构…
</SelectItem>
)}
{reportStructuresQuery.isError && (
<SelectItem value="__report_structures_error__" disabled>
报告结构库加载失败
</SelectItem>
)}
{reportStructures.map((structure) => (
<SelectItem key={structure.id} value={structure.id}>
{structure.title}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs">
{selectedReportStructure?.name || "默认保留原文的章节安排。"}
</span>
</label>
<label className="grid gap-2 text-sm font-medium">
结构约束
<Select
value={structureMode}
onValueChange={(value) => setStructureMode(normalizeReportStructureMode(value))}
>
<SelectTrigger aria-label="结构约束">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_STRUCTURE_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs">
{REPORT_STRUCTURE_MODE_OPTIONS.find((option) => option.value === structureMode)?.description}
</span>
</label>
<label className="grid gap-2 text-sm font-medium">
模型
<ModelNameSelect
@@ -132,23 +266,76 @@ export function DocumentRewriteDialog({
triggerClassName="h-10 text-sm"
defaultLabel="系统默认模型"
/>
<span className="text-muted-foreground text-xs">仅影响本次全文改写。</span>
<span className="text-muted-foreground text-xs">
仅影响本次{isNewReportGeneration ? "新报告生成" : "全文改写"}。
</span>
</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">
{isNewReportGeneration ? "为新报告补充配图" : "改写后补充配图"}
</span>
<span className="text-muted-foreground text-xs leading-5">
根据章节生成并保存为当前沙箱的本地图片,不使用外部图片链接。
</span>
</span>
</label>
<Switch
aria-label={isNewReportGeneration ? "为新报告补充配图" : "改写后补充配图"}
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)}>
取消
</Button>
{isNewReportGeneration && onRewriteOriginal && (
<Button
variant="outline"
disabled={!canStart}
onClick={() => startWith(onRewriteOriginal)}
>
<FilePenLineIcon />
改写原文
</Button>
)}
<Button
disabled={!canStart}
onClick={() => {
if (!canStart) return;
onStart({ instruction: instruction.trim(), style, modelName });
}}
onClick={() => startWith(onStart)}
>
<SparklesIcon />
开始改写
{submitLabel ?? "开始改写"}
</Button>
</DialogFooter>
</DialogContent>
@@ -916,11 +916,10 @@ function buildTurnReferenceBatches(
continue;
}
const nextHumanIndex = findNextHumanIndex(messages, index + 1);
if (nextHumanIndex === -1 && isLoading) {
continue;
}
const endIndex = nextHumanIndex === -1 ? messages.length : nextHumanIndex;
const assistant = findLastAssistantAnswer(messages, index + 1, endIndex);
const assistant =
findLastAssistantAnswer(messages, index + 1, endIndex) ??
findFrontendRagAssistant(messages, index + 1, endIndex);
if (!assistant?.id) {
continue;
}
@@ -930,6 +929,14 @@ function buildTurnReferenceBatches(
nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id,
includeBeforeMessage: false,
}).filter((source) => source.mode !== "none");
// 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) {
continue;
}
if (sources.length > 0) {
batches.push({
id: assistant.id,
@@ -968,6 +975,23 @@ function findLastAssistantAnswer(
return null;
}
function findFrontendRagAssistant(
messages: Message[],
start: number,
end: number,
): Message | null {
for (let index = end - 1; index >= start; index -= 1) {
const message = messages[index];
if (
message?.type === "ai" &&
(isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message))
) {
return message;
}
}
return null;
}
function hasAssistantAnswerContent(message: Message): boolean {
const content = message.content;
if (typeof content === "string") {
@@ -76,6 +76,7 @@ import {
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
isBootstrapThread,
isHiddenFromRecentChats,
isThreadFavorite,
isThreadPinned,
listPathOfThread,
@@ -226,14 +227,8 @@ export function GeneralQaSidebar() {
const visibleThreads = useMemo(
() =>
// 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页);
// 仅排除调度 / 会商类系统会话。
sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable",
),
),
// 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
[threads],
);
@@ -2,34 +2,61 @@
import {
CheckCircle2Icon,
ChevronDownIcon,
ChevronsDownIcon,
FilePenLineIcon,
LightbulbIcon,
Loader2Icon,
Undo2Icon,
XCircleIcon,
} from "lucide-react";
import { useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { apiFetch } from "@/core/api/fetch-client";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { apiFetch } from "@/core/api/fetch-client";
import { updateArtifactContent } from "@/core/artifacts/api";
import { urlOfArtifact } from "@/core/artifacts/utils";
import {
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
DOCUMENT_REWRITE_UNDONE_EVENT,
type DocumentRewritePresentation,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import {
clearArtifactRewriteFallback,
loadArtifactRewriteFallback,
sha256DocumentContent,
} from "@/core/document-rewrite/local-fallback";
import { getBackendBaseURL } from "@/core/config";
import { cn } from "@/lib/utils";
import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
const STEPS = [
const REWRITE_STEPS = [
{ key: "snapshot", label: "固定原文版本" },
{ key: "requirements", label: "理解改写要求" },
{ key: "writing", label: "流式重写 Markdown" },
{ key: "illustrations", label: "生成文档配图" },
{ key: "validation", label: "校验文档" },
{ key: "comparison", label: "对比原文并生成说明" },
] as const;
const GENERATE_STEPS = [
{ key: "snapshot", label: "固定来源报告快照" },
{ key: "requirements", label: "确认用户生成需求" },
{ key: "writing", label: "流式生成 Markdown" },
{ key: "illustrations", label: "生成文档配图" },
{ key: "validation", label: "校验新报告" },
{ key: "comparison", label: "生成报告说明" },
] as const;
function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
switch (stage) {
case "snapshot":
@@ -38,15 +65,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,23 +85,218 @@ 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);
const [autoScrollPaused, setAutoScrollPaused] = useState(false);
const contentRef = useRef<HTMLPreElement>(null);
const autoScrollEnabledRef = useRef(true);
const previousActiveRef = useRef(active);
const lastAutoScrollTopRef = useRef<number | null>(null);
useEffect(() => {
if (active && text) setOpen(true);
}, [active, text]);
useEffect(() => {
if (active && !previousActiveRef.current) {
autoScrollEnabledRef.current = true;
lastAutoScrollTopRef.current = null;
setAutoScrollPaused(false);
}
previousActiveRef.current = active;
}, [active]);
const scrollToLatestThinking = useCallback(() => {
if (!autoScrollEnabledRef.current) return;
const content = contentRef.current;
if (!content) return;
content.scrollTop = content.scrollHeight;
lastAutoScrollTopRef.current = content.scrollTop;
}, []);
useLayoutEffect(() => {
if (!active || !open || !text) return;
scrollToLatestThinking();
}, [active, open, scrollToLatestThinking, text]);
useEffect(() => {
if (!active || !open || !text) return;
const content = contentRef.current;
if (!content) return;
let frame: number | null = null;
const observer = new MutationObserver(() => {
if (frame !== null) cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
frame = null;
scrollToLatestThinking();
});
});
observer.observe(content, { childList: true, characterData: true, subtree: true });
return () => {
observer.disconnect();
if (frame !== null) cancelAnimationFrame(frame);
};
}, [active, open, scrollToLatestThinking, text]);
const pauseAutoScrollOnManualScroll = (event: React.UIEvent<HTMLPreElement>) => {
if (!active || !autoScrollEnabledRef.current) return;
const lastAutoScrollTop = lastAutoScrollTopRef.current;
if (
lastAutoScrollTop === null ||
Math.abs(event.currentTarget.scrollTop - lastAutoScrollTop) > 1
) {
autoScrollEnabledRef.current = false;
setAutoScrollPaused(true);
}
};
const resumeAutoScroll = () => {
autoScrollEnabledRef.current = true;
setAutoScrollPaused(false);
requestAnimationFrame(scrollToLatestThinking);
};
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>
<div className="relative mt-1">
<pre
className="bg-muted/45 text-muted-foreground max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words"
onScroll={pauseAutoScrollOnManualScroll}
ref={contentRef}
>
{text}
</pre>
{active && autoScrollPaused && (
<Button
aria-label="恢复思考内容自动下滑"
className="absolute top-1/2 left-1/2 h-9 -translate-x-1/2 -translate-y-1/2 gap-1.5 bg-background/95 px-3 text-xs shadow-md backdrop-blur-sm hover:bg-background"
onClick={resumeAutoScroll}
size="sm"
title="恢复自动下滑"
type="button"
variant="outline"
>
<ChevronsDownIcon className="size-3.5" />
自动下滑
</Button>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}
export function DocumentRewriteTimeline({
item,
}: {
item: DocumentRewritePresentation;
}) {
const isNewReportGeneration = item.operation === "generate";
const steps = isNewReportGeneration ? GENERATE_STEPS : REWRITE_STEPS;
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);
const reapplying = Boolean(item.revertedAt);
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
const canUseLocalFallback =
!reapplying &&
!activeVersionId &&
item.source.kind === "artifact" &&
typeof item.source.path === "string" &&
item.localFallbackAvailable &&
typeof item.committedHash === "string";
const handleUndo = async () => {
if (!activeVersionId || undoing) return;
if ((!activeVersionId && !canUseLocalFallback) || undoing) return;
if (canUseLocalFallback && item.source.kind === "artifact" && item.source.path && item.committedHash) {
setUndoing(true);
try {
const fallback = loadArtifactRewriteFallback({
threadId: item.source.id,
path: item.source.path,
committedHash: item.committedHash,
});
if (!fallback) {
console.error("本地 AI 全文改写撤销备份不可用,未修改当前文件。");
return;
}
const currentResponse = await apiFetch(
urlOfArtifact({ filepath: item.source.path, threadId: item.source.id }),
);
if (!currentResponse.ok) {
throw new Error(`读取当前文件失败(${currentResponse.status})。`);
}
const currentHash = await sha256DocumentContent(await currentResponse.text());
if (!currentHash || currentHash !== fallback.committedHash) {
console.error("当前文件已在改写后发生变化,已跳过本地 AI 全文改写撤销。");
return;
}
const guard = new CustomEvent<{
source: DocumentRewritePresentation["source"];
rewrittenContent: string | null;
unchanged: boolean;
}>(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, {
detail: {
source: item.source,
rewrittenContent: item.rewrittenContent,
unchanged: false,
},
});
window.dispatchEvent(guard);
if (!guard.detail.unchanged) {
console.error("沙箱中存在改写后的未保存编辑,已跳过本地 AI 全文改写撤销。");
return;
}
await updateArtifactContent({
filepath: item.source.path,
threadId: item.source.id,
content: fallback.originalContent,
});
clearArtifactRewriteFallback(item.source.id, item.source.path);
presentation?.update(item.id, {
localFallbackAvailable: false,
revertedAt: new Date().toISOString(),
});
window.dispatchEvent(
new CustomEvent(DOCUMENT_REWRITE_UNDONE_EVENT, {
detail: { source: item.source, content: fallback.originalContent },
}),
);
} catch (error) {
console.error("本地 AI 全文改写撤销失败:", error);
} finally {
setUndoing(false);
}
return;
}
if (!activeVersionId) return;
const baseUrl = getBackendBaseURL();
const endpoint =
item.source.kind === "artifact"
@@ -109,7 +333,15 @@ export function DocumentRewriteTimeline({
detail: { source: item.source, content },
}),
);
toast.success(reapplying ? "已重新应用本次 AI 全文改写版本。" : "已恢复到本次 AI 全文改写前的版本。");
toast.success(
isNewReportGeneration
? reapplying
? "已重新应用本次生成的新报告版本。"
: "已恢复本次生成前的新报告快照。"
: reapplying
? "已重新应用本次 AI 全文改写版本。"
: "已恢复到本次 AI 全文改写前的版本。",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "撤销改写失败,请稍后重试。");
} finally {
@@ -125,7 +357,9 @@ export function DocumentRewriteTimeline({
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold">AI 全文改写 · {item.fileName}</h3>
<h3 className="font-semibold">
{isNewReportGeneration ? "生成新报告" : "AI 全文改写"} · {item.fileName}
</h3>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs",
@@ -136,7 +370,7 @@ export function DocumentRewriteTimeline({
: "bg-primary/10 text-primary",
)}
>
{completed ? "已完成" : failed ? "未覆盖原文件" : "进行中"}
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未覆盖原文件") : "进行中"}
</span>
</div>
<p className="text-muted-foreground mt-1 text-sm">
@@ -146,9 +380,15 @@ export function DocumentRewriteTimeline({
</header>
<div className="space-y-3 px-5 py-4">
{STEPS.map((step, index) => {
{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
@@ -173,7 +413,7 @@ export function DocumentRewriteTimeline({
<div className="text-sm font-medium">{step.label}</div>
{step.key === "snapshot" && item.originalCharCount > 0 && (
<p className="text-muted-foreground mt-0.5 text-xs">
已冻结 {item.originalCharCount.toLocaleString()} 字原文快照。
已冻结 {item.originalCharCount.toLocaleString()} 字{isNewReportGeneration ? "来源报告" : "原文"}快照。
</p>
)}
{step.key === "requirements" && (
@@ -187,18 +427,52 @@ export function DocumentRewriteTimeline({
</div>
) : active ? (
<p className="text-muted-foreground mt-0.5 text-xs">
正在识别改写目标、保留约束与执行方式…
{isNewReportGeneration
? "正在确认新报告的结构、资料范围与生成要求…"
: "正在识别改写目标、保留约束与执行方式…"}
</p>
) : (
<p className="text-muted-foreground mt-0.5 text-xs">
已根据改写要求确定执行计划。
{isNewReportGeneration ? "已根据所选结构确定生成计划。" : "已根据改写要求确定执行计划。"}
</p>
)
)}
{step.key === "writing" && item.writtenCharCount > 0 && (
<p className="text-muted-foreground mt-0.5 text-xs">
正在写入右侧沙箱,已生成 {item.writtenCharCount.toLocaleString()} 字。
</p>
{step.key === "writing" && (
item.writtenCharCount > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs">
正在写入右侧沙箱,已{isNewReportGeneration ? "生成" : "改写"} {item.writtenCharCount.toLocaleString()} 字。
</p>
) : active ? (
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
{isNewReportGeneration
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
: "正在等待模型开始输出;右侧将在首个字符到达后显示草稿。"}
</p>
) : null
)}
{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">
@@ -213,9 +487,10 @@ export function DocumentRewriteTimeline({
{item.summary && (
<div className="border-t px-5 py-4 text-sm leading-6">
<div className="mb-2 font-semibold">改写说明</div>
<div className="mb-2 font-semibold">{isNewReportGeneration ? "生成说明" : "改写说明"}</div>
<p>
原文 {item.summary.originalCharCount.toLocaleString()} 字,改写后{" "}
{isNewReportGeneration ? "来源报告" : "原文"} {item.summary.originalCharCount.toLocaleString()} 字,
{isNewReportGeneration ? "新报告" : "改写后"}{" "}
{item.summary.rewrittenCharCount.toLocaleString()} 字;调整了约{" "}
{item.summary.changedBlockCount} 个内容块。
</p>
@@ -228,6 +503,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 ||
@@ -256,7 +536,25 @@ export function DocumentRewriteTimeline({
查看前后对比
</Button>
)}
{completed && activeVersionId && (
{isNewReportGeneration && completed && item.source.kind === "deep_research_report" && (
<Button
className="mt-3 mr-2"
onClick={() => {
window.dispatchEvent(
new CustomEvent(DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT, {
detail: { sessionId: item.source.id },
}),
);
}}
size="sm"
type="button"
variant="outline"
>
<FilePenLineIcon className="mr-1.5 size-3.5" />
改写新报告
</Button>
)}
{completed && (activeVersionId || canUseLocalFallback) && (
<Button
className="mt-3"
disabled={undoing}
@@ -266,12 +564,19 @@ export function DocumentRewriteTimeline({
variant="outline"
>
{undoing ? <Loader2Icon className="mr-1.5 size-3.5 animate-spin" /> : <Undo2Icon className="mr-1.5 size-3.5" />}
{reapplying ? "重新应用此次改写" : "撤销此次改写"}
{isNewReportGeneration
? reapplying
? "重新应用本次生成"
: "撤销本次生成"
: reapplying
? "重新应用此次改写"
: "撤销此次改写"}
</Button>
)}
{item.revertedAt && (
<p className="mt-2 text-xs text-emerald-700 dark:text-emerald-300">
已恢复为改写前版本。{item.redoVersionId ? "可重新应用本次改写。" : ""}
已恢复为{isNewReportGeneration ? "生成前" : "改写前"}版本。
{item.redoVersionId ? `可重新应用本次${isNewReportGeneration ? "生成" : "改写"}。` : ""}
</p>
)}
</div>
@@ -1,21 +1,22 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
BookOpenTextIcon,
BookOpenTextIcon,
CheckCircle2Icon,
ChevronDownIcon,
ChevronUp,
ChevronsDownIcon,
ChevronUp,
FolderOpenIcon,
GlobeIcon,
LightbulbIcon,
ListTodoIcon,
Loader2Icon,
LightbulbIcon,
ListTodoIcon,
Loader2Icon,
MessageCircleQuestionMarkIcon,
NotebookPenIcon,
SearchIcon,
SquareTerminalIcon,
WrenchIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type AnchorHTMLAttributes } from "react";
import {
ChainOfThought,
@@ -31,20 +32,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,33 +59,38 @@ 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";
applyInlineCitations,
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";
import { cn } from "@/lib/utils";
import { useArtifactsOptional } from "../artifacts";
import { CitationLink } from "../citations/citation-link";
import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content";
import { SingleRoundRagCitationDialog } from "./message-list-item";
export function MessageGroup({
className,
messages,
isLoading = false,
displaySources = [],
}: {
className?: string;
messages: Message[];
isLoading?: boolean;
displaySources?: RuntimeDisplaySource[];
}) {
const { t } = useI18n();
const [showAbove, setShowAbove] = useState(
@@ -121,11 +127,29 @@ export function MessageGroup({
}
}, [lastToolCallStep, steps]);
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const [selectedCitation, setSelectedCitation] =
useState<RuntimeDisplaySource | null>(null);
const handleCitationClick = useCallback(
(referenceId: string) => {
const referenceIndex = Number(referenceId);
if (!Number.isInteger(referenceIndex) || referenceIndex < 1) return;
const source =
displaySources.find((item) => item.index === referenceIndex) ??
displaySources[referenceIndex - 1];
if (source) setSelectedCitation(source);
},
[displaySources],
);
const reasoningId =
lastReasoningStep?.id ??
messages.find((message) => message.type === "ai")?.id;
return (
<ChainOfThought
className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
open={true}
>
<>
<ChainOfThought
className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
data-reference-batch-id={reasoningId}
open={true}
>
{aboveLastToolCallSteps.length > 0 && (
<Button
key="above"
@@ -160,10 +184,12 @@ export function MessageGroup({
<ChainOfThoughtStep
key={step.id}
label={
<MarkdownContent
<StepReasoningMarkdown
content={step.reasoning ?? ""}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
onCitationClick={handleCitationClick}
/>
}
></ChainOfThoughtStep>
@@ -220,10 +246,12 @@ export function MessageGroup({
<ChainOfThoughtStep
key={lastReasoningStep.id}
label={
<MarkdownContent
<StepReasoningMarkdown
content={lastReasoningStep.reasoning ?? ""}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
onCitationClick={handleCitationClick}
/>
}
></ChainOfThoughtStep>
@@ -231,11 +259,64 @@ export function MessageGroup({
)}
</>
)}
</ChainOfThought>
</ChainOfThought>
<SingleRoundRagCitationDialog
source={selectedCitation}
onOpenChange={(open) => {
if (!open) setSelectedCitation(null);
}}
/>
</>
);
}
function ToolCall({
function StepReasoningMarkdown({
content,
displaySources,
isLoading,
rehypePlugins,
onCitationClick,
}: {
content: string;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onCitationClick: (referenceId: string) => void;
}) {
const markdown = useMemo(
() =>
applyInlineCitations(content, displaySources, { appendSummary: false }),
[content, displaySources],
);
const components = useMemo(() => {
if (displaySources.length === 0) return undefined;
return {
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("#reference-")) {
return (
<CitationLink
href={href}
{...props}
onReferenceClick={onCitationClick}
/>
);
}
return <a href={href} {...props} />;
},
};
}, [displaySources.length, onCitationClick]);
return (
<MarkdownContent
content={markdown}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
);
}
function ToolCall({
id,
messageId,
name,
@@ -260,14 +341,14 @@ function ToolCall({
const displayMatch =
result !== undefined && isDisplaySamplePayload(result)
? null
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
null);
const displayItems =
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
null);
const displayItems =
displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))
: [];
@@ -278,9 +359,9 @@ function ToolCall({
: "Wiki 文档检索"
: null;
if (displayItems.length > 0) {
return (
<ChainOfThoughtStep
key={id}
return (
<ChainOfThoughtStep
key={id}
label={
wikiSearchLabel || (displayMatch?.display.mode === "citation" || !displayMatch
? "检索参考来源"
@@ -291,17 +372,17 @@ function ToolCall({
<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,
@@ -314,19 +395,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} />
)}
@@ -443,18 +524,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;
@@ -515,129 +596,235 @@ 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() : "";
}
}
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";
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;
const [thinkingOpen, setThinkingOpen] = useState(false);
const [autoScrollPaused, setAutoScrollPaused] = useState(false);
const thinkingContentRef = useRef<HTMLPreElement>(null);
const autoScrollEnabledRef = useRef(true);
const programmaticScrollRef = useRef(false);
const previousActiveRef = useRef(active);
useEffect(() => {
if (active && thinking) setThinkingOpen(true);
}, [active, thinking]);
useEffect(() => {
if (
!openStreamedArtifact ||
!artifactPath ||
!previewKey ||
!isLoading ||
!isLast ||
result !== undefined
) {
return;
if (active && !previousActiveRef.current) {
autoScrollEnabledRef.current = true;
setAutoScrollPaused(false);
}
openStreamedArtifact(artifactPath, previewKey);
}, [
artifactPath,
isLast,
isLoading,
openStreamedArtifact,
previewKey,
result,
]);
previousActiveRef.current = active;
}, [active]);
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>
);
}
const scrollToLatestThinking = useCallback(() => {
if (!autoScrollEnabledRef.current) return;
const content = thinkingContentRef.current;
if (!content) return;
// Scroll events do not identify their initiator reliably across browsers.
// Mark this assignment so it never disables following as if it were a
// user drag; a user-originated scroll away from the bottom still pauses it.
programmaticScrollRef.current = true;
content.scrollTop = content.scrollHeight;
requestAnimationFrame(() => {
programmaticScrollRef.current = false;
});
}, []);
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
useLayoutEffect(() => {
if (!active || !thinkingOpen || !thinking) return;
scrollToLatestThinking();
// A second frame covers the browser's delayed reflow of a growing <pre>.
const frame = requestAnimationFrame(scrollToLatestThinking);
return () => cancelAnimationFrame(frame);
}, [active, thinking, thinkingOpen, scrollToLatestThinking]);
const pauseAutoScrollOnManualScroll = (event: React.UIEvent<HTMLPreElement>) => {
if (!active || !autoScrollEnabledRef.current || programmaticScrollRef.current) return;
const { clientHeight, scrollHeight, scrollTop } = event.currentTarget;
// Being at the bottom is still the follow state. Only an actual move away
// from it (wheel, touch, keyboard or scrollbar drag) pauses auto-follow.
if (scrollTop + clientHeight >= scrollHeight - 4) return;
autoScrollEnabledRef.current = false;
setAutoScrollPaused(true);
};
const resumeAutoScroll = () => {
autoScrollEnabledRef.current = true;
setAutoScrollPaused(false);
requestAnimationFrame(scrollToLatestThinking);
};
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>
<div className="relative mt-1">
<pre
className="bg-muted/45 text-muted-foreground max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words"
onScroll={pauseAutoScrollOnManualScroll}
ref={thinkingContentRef}
>
{thinking}
</pre>
{active && autoScrollPaused && (
<Button
aria-label="恢复思考内容自动下滑"
className="absolute top-1/2 left-1/2 h-9 -translate-x-1/2 -translate-y-1/2 gap-1.5 bg-background/95 px-3 text-xs shadow-md backdrop-blur-sm hover:bg-background"
onClick={resumeAutoScroll}
size="sm"
title="恢复自动下滑"
type="button"
variant="outline"
>
<ChevronsDownIcon className="size-3.5" />
自动下滑
</Button>
)}
</div>
</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;
}
@@ -740,7 +927,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,10 @@ function MessageContent_({
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
return stripIncompleteCitationMarker(withCitations);
}, [rawContent, isHuman, displaySources]);
const markdownRehypePlugins = useMemo(
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
[rehypePlugins],
);
const parsedHumanContent = useMemo(
() =>
isHuman
@@ -780,7 +786,7 @@ function MessageContent_({
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
rehypePlugins={markdownRehypePlugins}
components={components}
/>
) : (
@@ -789,7 +795,7 @@ function MessageContent_({
displaySources={displaySources}
isLoading={isLoading}
renderSkillDisplayBlocks={false}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
rehypePlugins={markdownRehypePlugins}
components={components}
/>
)}
@@ -838,7 +844,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"
@@ -891,7 +897,7 @@ function FrontendRagSourcesPanel({
);
}
function SingleRoundRagCitationDialog({
export function SingleRoundRagCitationDialog({
source,
onOpenChange,
}: {
@@ -947,11 +953,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 +974,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 +989,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 +1038,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>
);
}
@@ -11,8 +11,11 @@ import {
} from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
import { useDocumentRewritePresentation } from "@/core/document-rewrite/context";
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
import {
DOCUMENT_REWRITE_STARTED_EVENT,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
@@ -46,8 +49,8 @@ import { getQuestionAnchorId } from "../question-navigator";
import { StreamingIndicator } from "../streaming-indicator";
import { ReferenceBatchesContext, ThreadContext } from "./context";
import { MarkdownContent } from "./markdown-content";
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
import { MarkdownContent } from "./markdown-content";
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item";
import {
@@ -89,6 +92,22 @@ function ScrollToBottomOnUserMessage({
}
}, [messages, scrollToBottom]);
useEffect(() => {
const handleDocumentRewriteStarted = (event: Event) => {
const detail = (event as CustomEvent<{ threadId?: string }>).detail;
if (detail?.threadId !== threadId) return;
// The presentation card is inserted by a context update in the same
// interaction. Two frames let React commit it before the smooth scroll.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
void scrollToBottom("smooth");
});
});
};
window.addEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
return () => window.removeEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
}, [scrollToBottom, threadId]);
return null;
}
@@ -112,10 +131,13 @@ export function MessageList({
onSetupWritingApprove,
onSetupWritingReject,
onSetupWritingStart,
researchApprovalSlot,
onOpenArtifact,
onDownloadArtifact,
}: {
onSetupReportStructureApprove,
onSetupReportStructureReject,
researchApprovalSlot,
onOpenArtifact,
onDownloadArtifact,
afterDocumentRewriteItems,
}: {
className?: string;
contentClassName?: string;
fitMessageRowsToContent?: boolean;
@@ -137,24 +159,28 @@ export function MessageList({
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupReportStructureApprove?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
onSetupReportStructureReject?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
/**
* 深度研究的交互卡插槽(报告配置、计划确认、失败重试):workbench 向
* displayThread 注入带 `additional_kwargs.setup_research_approval` 的虚拟 ai
* 消息。撰写步骤和完成摘要不走此插槽,而是分别复用原生 tool-call 步骤条和
* assistant 消息行;不传该 slot 时普通对话/AI 写作零行为变化。
* 深度研究的交互卡插槽(报告配置、计划确认、失败重试):workbench 向
* displayThread 注入带 `additional_kwargs.setup_research_approval` 的虚拟 ai
* 消息。撰写步骤和完成摘要不走此插槽,而是分别复用原生 tool-call 步骤条和
* assistant 消息行;不传该 slot 时普通对话/AI 写作零行为变化。
*/
researchApprovalSlot?: (message: Message) => React.ReactNode;
onOpenArtifact?: (filepath: string) => void;
/** Host override for downloading virtual/session-owned files in a present-files card. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
researchApprovalSlot?: (message: Message) => React.ReactNode;
onOpenArtifact?: (filepath: string) => void;
/** Host override for downloading virtual/session-owned files in a present-files card. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
/** Optional host content placed immediately after document-rewrite timelines. */
afterDocumentRewriteItems?: React.ReactNode;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
[baseRehypePlugins, extraRehypePlugins],
);
const updateSubtask = useUpdateSubtask();
const documentRewritePresentation = useDocumentRewritePresentation();
const updateSubtask = useUpdateSubtask();
const documentRewritePresentation = useDocumentRewritePresentation();
const { t } = useI18n();
const threadContext = useContext(ThreadContext);
// Read-only contexts (public share, mock previews) must not offer deletion.
@@ -194,7 +220,7 @@ export function MessageList({
: ({ ...message, content: override } as Message);
});
}, [messageContentOverrides, thread.messages]);
const messages = useMemo(
const messages = useMemo(
() => {
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
return hiddenMessageIds.size === 0 && externalHidden.size === 0
@@ -205,9 +231,9 @@ export function MessageList({
(!hiddenMessageIds.has(m.id) && !externalHidden.has(m.id)),
);
},
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
);
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
);
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
const askTimes = useChatAskTimes();
@@ -322,6 +348,32 @@ export function MessageList({
return map;
}, [thread.messages]);
const resolveDisplaySources = useCallback(
(msg: Message | undefined): RuntimeDisplaySource[] => {
if (!referencesEnabled || !msg || msg.type !== "ai") return [];
const batchSources =
referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
?.sources ?? [];
const frontendRagSources = msg.id
? frontendRagSourcesByAiId.get(msg.id) ?? []
: [];
if (batchSources.length > 0) return batchSources;
if (frontendRagSources.length > 0) return frontendRagSources;
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
});
},
[
frontendRagSourcesByAiId,
messages,
referenceBatches,
referencesEnabled,
skillDisplayMap,
],
);
// Track which clarification messages are still awaiting an answer.
// Pending = clarification tool messages that appear after the last human message
// (no user reply has been sent for them yet).
@@ -438,44 +490,35 @@ export function MessageList({
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
"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) => {
{(() => {
const rewriteItemsAboveFile = documentRewriteItems.filter(
(item) => item.operation !== "generate",
);
const rewriteItemsBelowFile = documentRewriteItems.filter(
(item) => item.operation === "generate",
);
let didRenderRewriteAboveFile = false;
const grouped = groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
// 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。
const messageIsLoading =
thread.isLoading ||
msg.additional_kwargs?.deep_research_summary_streaming === true ||
msg.additional_kwargs?.deep_research_follow_up_streaming === true;
const isAssistantMessage =
return group.messages.map((msg) => {
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
// 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。
const messageIsLoading =
thread.isLoading ||
msg.additional_kwargs?.deep_research_summary_streaming === true ||
msg.additional_kwargs?.deep_research_follow_up_streaming === true;
const isAssistantMessage =
referencesEnabled &&
group.type === "assistant" &&
msg.type === "ai";
const batchSources = isAssistantMessage
? referenceBatches.find(
(batch) => batch.assistant_message_id === msg.id,
)?.sources ?? []
: [];
// 单轮/多轮 RAG 的参考资料来自隐藏的 tool 消息,优先于通用扫描。
const frontendRagSources =
isAssistantMessage && msg.id
? frontendRagSourcesByAiId.get(msg.id) ?? []
: [];
const displaySources = isAssistantMessage
? batchSources.length > 0
? batchSources
: frontendRagSources.length > 0
? frontendRagSources
: collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
})
? resolveDisplaySources(msg)
: [];
return (
<MessageListItem
@@ -485,9 +528,9 @@ export function MessageList({
msg.type === "human"
? getQuestionAnchorId(msg, messages.indexOf(msg))
: undefined
}
message={msg}
isLoading={messageIsLoading}
}
message={msg}
isLoading={messageIsLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
@@ -582,9 +625,26 @@ export function MessageList({
/>
</div>
) : null;
} else if (group.type === "assistant:setup-report-structure-approval") {
const message = group.messages[0];
return message ? (
<div key={group.id} className="w-full">
<SetupReportStructureApprovalCard
disabled={thread.isLoading}
message={message}
onApprove={onSetupReportStructureApprove}
onReject={onSetupReportStructureReject}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
messages={group.messages}
/>
</div>
) : null;
} else if (group.type === "assistant:research-approval") {
// 深度研究交互卡(配置/计划确认/失败重试):宿主不传渲染函数时该
// 分组不渲染。撰写步骤和完成摘要会经过普通的 processing/assistant 分组。
// 深度研究交互卡(配置/计划确认/失败重试):宿主不传渲染函数时该
// 分组不渲染。撰写步骤和完成摘要会经过普通的 processing/assistant 分组。
const message = group.messages[0];
return researchApprovalSlot && message ? (
<div key={group.id} className="w-full pb-2">
@@ -599,8 +659,15 @@ export function MessageList({
files.push(...presentFiles);
}
}
const rewriteAbove = !didRenderRewriteAboveFile
? rewriteItemsAboveFile
: [];
didRenderRewriteAboveFile = true;
return (
<div className="w-full" key={group.id}>
{rewriteAbove.map((item) => (
<DocumentRewriteTimeline key={`rewrite-above-${item.id}`} item={item} />
))}
{group.messages[0] && hasContent(group.messages[0]) && (
<MarkdownContent
content={extractContentFromMessage(group.messages[0])}
@@ -610,11 +677,11 @@ export function MessageList({
/>
)}
<ArtifactFileList
files={files}
threadId={threadId}
onOpenArtifact={onOpenArtifact}
onDownloadArtifact={onDownloadArtifact}
/>
files={files}
threadId={threadId}
onOpenArtifact={onOpenArtifact}
onDownloadArtifact={onDownloadArtifact}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
@@ -682,6 +749,7 @@ export function MessageList({
key={"thinking-group-" + message.id}
messages={[message]}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(message)}
/>,
);
}
@@ -725,11 +793,15 @@ export function MessageList({
message.type === "ai" &&
(hasToolCalls(message) ? true : !hasContent(message)),
);
const processingAi = [...group.messages]
.reverse()
.find((message) => message.type === "ai");
return (
<div key={"group-" + group.id} className="w-full">
<MessageGroup
messages={group.messages}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(processingAi)}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
@@ -738,11 +810,21 @@ export function MessageList({
/>
</div>
);
})}
{documentRewriteItems.map((item) => (
<DocumentRewriteTimeline key={item.id} item={item} />
))}
{thread.isLoading && <StreamingIndicator className="my-4" />}
});
const leftoverRewriteItems = didRenderRewriteAboveFile
? rewriteItemsBelowFile
: documentRewriteItems;
return (
<>
{grouped}
{leftoverRewriteItems.map((item) => (
<DocumentRewriteTimeline key={item.id} item={item} />
))}
{afterDocumentRewriteItems}
</>
);
})()}
{thread.isLoading && <StreamingIndicator className="my-4" />}
<div style={{ height: `${paddingBottom}px` }} />
</MessageMetricsProvider>
</ConversationContent>
@@ -799,6 +881,15 @@ export type SetupWritingApprovalPayload = {
outline?: string;
};
export type SetupReportStructureApprovalPayload = {
title?: string;
name?: string;
content?: string;
structure_mode?: string;
retrieval_directions?: string[];
retrieval_skills?: string[];
};
function findPreviousHumanMessageId(
messages: Message[],
beforeMessageId?: string,
@@ -1169,6 +1260,147 @@ function SetupWritingApprovalCard({
);
}
function getSetupReportStructureApprovalPayload(
message: unknown,
): SetupReportStructureApprovalPayload | null {
if (typeof message !== "object" || message === null) {
return null;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
return null;
}
const approval = Reflect.get(additionalKwargs, "setup_report_structure_approval");
if (typeof approval !== "object" || approval === null) {
return null;
}
return approval as SetupReportStructureApprovalPayload;
}
const STRUCTURE_MODE_LABELS: Record<string, string> = {
fixed: "固定大纲",
adaptive: "弹性大纲",
};
function SetupReportStructureApprovalCard({
disabled,
message,
onApprove,
onReject,
}: {
disabled: boolean;
message: unknown;
onApprove?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
onReject?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
}) {
const payload = getSetupReportStructureApprovalPayload(message);
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(null);
if (!payload) {
return null;
}
const approvalPayload = payload;
const busy = disabled || submitting !== null;
const directions = approvalPayload.retrieval_directions ?? [];
const skills = approvalPayload.retrieval_skills ?? [];
async function run(action: "approve" | "reject") {
if (busy) {
return;
}
setSubmitting(action);
try {
if (action === "approve") {
await onApprove?.(approvalPayload);
} else {
await onReject?.(approvalPayload);
}
} finally {
setSubmitting(null);
}
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">报告结构建议</div>
<div className="text-sm font-medium">确认后将应用到右侧表单,再点保存入库。</div>
</div>
{approvalPayload.title ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">标题</div>
<p className="text-sm font-medium">{approvalPayload.title}</p>
</div>
) : null}
{approvalPayload.name ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">描述</div>
<p className="text-sm leading-6">{approvalPayload.name}</p>
</div>
) : null}
<div className="flex flex-wrap gap-x-6 gap-y-1.5">
{approvalPayload.structure_mode ? (
<div className="space-y-0.5">
<div className="text-muted-foreground text-xs">结构约束</div>
<p className="text-sm font-medium">
{STRUCTURE_MODE_LABELS[approvalPayload.structure_mode] ?? approvalPayload.structure_mode}
</p>
</div>
) : null}
{directions.length > 0 ? (
<div className="space-y-0.5">
<div className="text-muted-foreground text-xs">检索方向</div>
<p className="text-sm font-medium">{directions.join("、")}</p>
</div>
) : null}
{skills.length > 0 ? (
<div className="space-y-0.5">
<div className="text-muted-foreground text-xs">检索来源</div>
<p className="text-sm font-medium">{skills.join("、")}</p>
</div>
) : null}
</div>
{approvalPayload.content ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">报告大纲</div>
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
{approvalPayload.content}
</pre>
</div>
) : null}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={busy || !onReject}
onClick={() => void run("reject")}
>
<XIcon className="h-4 w-4" />
忽略
</Button>
<Button
type="button"
size="sm"
disabled={busy || !onApprove}
onClick={() => void run("approve")}
>
<CheckIcon className="h-4 w-4" />
应用到表单
</Button>
</div>
</div>
);
}
function getClarificationPayload(message: unknown): ClarificationPayload | null {
if (typeof message !== "object" || message === null) {
return null;
@@ -66,6 +66,7 @@ import {
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
isBootstrapThread,
isHiddenFromRecentChats,
isThreadFavorite,
isThreadPinned,
listPathOfThread,
@@ -193,13 +194,9 @@ export function RecentChatList({
);
// 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页);
// 仅排除调度 / 会商类系统会话。
// 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
const visibleThreads = sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable",
),
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
);
if (visibleThreads.length === 0) {
@@ -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 {
@@ -119,7 +117,7 @@ function normalizeShortcutDraft(
? button.route_params
: [newShortcutParam()]
).map((param) => ({
key: param.key || "",
key: param.paramType === "path" ? "" : param.key || "",
paramType: param.paramType || "login",
source: param.source || "access_token",
customValue: param.customValue ?? "",
@@ -134,8 +132,18 @@ function cleanShortcutParams(
params: CompactShortcutRouteParam[],
): CompactShortcutRouteParam[] {
return params
.filter((param) => param.key.trim())
.filter((param) => param.paramType === "path" || param.key.trim())
.map((param) => {
if (param.paramType === "path") {
const source = param.source || "access_token";
return {
key: "",
paramType: "path" as const,
source,
customValue:
source === "other" ? (param.customValue ?? "").trim() : undefined,
};
}
const base: CompactShortcutRouteParam = {
key: param.key.trim(),
paramType: param.paramType,
@@ -356,6 +364,13 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
return;
}
for (const param of item.route_params) {
if (param.paramType === "path") {
if (param.source === "other" && !param.customValue?.trim()) {
toast.error("路径参数自定义值不可为空");
return;
}
continue;
}
if (!param.key.trim()) {
toast.error("路由参数键名不可为空");
return;
@@ -550,7 +565,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
<Separator />
<SettingsSection
title="顶部快捷按钮(管理员)"
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停和与应用管理一致的登录/主题参数。"
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停,以及登录、主题和路径参数。"
>
<CompactShortcutSettings
buttons={shortcutDraft}
@@ -917,7 +932,7 @@ function CompactShortcutSettings({
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-muted-foreground text-xs">
已配置 {buttons.length} 个按钮;只有启用且填写了跳转链接的按钮会显示。
已配置 {buttons.length} 个按钮;支持追加登录、主题及路径参数。
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={onAdd}>
@@ -1014,7 +1029,7 @@ function CompactShortcutSettings({
</div>
<div className="rounded-lg border bg-muted/20 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-sm font-medium">
<ExternalLink className="size-4" />
路由参数
@@ -1030,13 +1045,13 @@ function CompactShortcutSettings({
</Button>
</div>
{button.route_params.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed bg-background/70 p-4 text-center text-xs">
不追加额外参数。
</div>
) : (
<div className="space-y-2">
{button.route_params.map((param, paramIndex) => {
{button.route_params.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed bg-background/70 p-4 text-center text-xs">
不追加额外参数。
</div>
) : (
<div className="space-y-2">
{button.route_params.map((param, paramIndex) => {
const themeMap =
param.paramType === "theme"
? parseThemeMap(param.themeValue)
@@ -1046,26 +1061,36 @@ function CompactShortcutSettings({
key={`${button.id}-${paramIndex}`}
className="space-y-2 rounded-md border bg-background p-2"
>
<div className="grid gap-2 md:grid-cols-[minmax(120px,1fr)_130px_minmax(180px,1.4fr)_32px]">
<Input
value={param.key}
onChange={(event) =>
onPatchParam(button.id, paramIndex, {
key: event.target.value,
})
}
placeholder="参数名 key"
className="font-mono text-xs"
/>
<div
className={cn(
"grid gap-2",
param.paramType === "path"
? "md:grid-cols-[130px_minmax(180px,1fr)_32px]"
: "md:grid-cols-[minmax(120px,1fr)_130px_minmax(180px,1.4fr)_32px]",
)}
>
{param.paramType !== "path" && (
<Input
value={param.key}
onChange={(event) =>
onPatchParam(button.id, paramIndex, {
key: event.target.value,
})
}
placeholder="参数名 key"
className="font-mono text-xs"
/>
)}
<Select
value={param.paramType}
onValueChange={(value) =>
onPatchParam(button.id, paramIndex, {
paramType: value as "login" | "theme",
paramType: value as "login" | "theme" | "path",
key: value === "path" ? "" : param.key,
source:
value === "login"
? param.source || "access_token"
: undefined,
value === "theme"
? undefined
: param.source || "access_token",
})
}
>
@@ -1075,9 +1100,10 @@ function CompactShortcutSettings({
<SelectContent>
<SelectItem value="login">登录参数</SelectItem>
<SelectItem value="theme">主题参数</SelectItem>
<SelectItem value="path">路径参数</SelectItem>
</SelectContent>
</Select>
{param.paramType === "login" ? (
{(param.paramType === "login" || param.paramType === "path") ? (
<div className="flex min-w-0 gap-2">
<Select
value={param.source || "access_token"}
@@ -1103,6 +1129,11 @@ function CompactShortcutSettings({
</Select>
{param.source === "other" && (
<Input
// This config value becomes a query parameter;
// it is not a login form field. A password input
// makes Chrome invoke password-manager heuristics
// whenever the settings dialog opens.
type="text"
value={param.customValue ?? ""}
onChange={(event) =>
onPatchParam(button.id, paramIndex, {
@@ -1111,6 +1142,8 @@ function CompactShortcutSettings({
}
placeholder="自定义值"
className="w-32 text-xs"
autoComplete="off"
aria-label="自定义参数值"
/>
)}
</div>
@@ -1133,6 +1166,12 @@ function CompactShortcutSettings({
</Button>
</div>
{param.paramType === "path" && (
<p className="text-muted-foreground text-xs">
不填写参数名,按添加顺序拼到链接或 Hash 路由的路径末尾。
</p>
)}
{param.paramType === "theme" && (
<div className="grid gap-2 rounded-md bg-muted/40 p-2 md:grid-cols-3">
{APP_THEME_COLORS.map((color) => (
@@ -1166,9 +1205,9 @@ function CompactShortcutSettings({
)}
</div>
);
})}
</div>
)}
})}
</div>
)}
</div>
</div>
))}
@@ -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}`;
}
@@ -14,7 +14,7 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, FileSearch, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -100,6 +100,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/deep-research",
match: (pathname) => pathname.startsWith("/page/workspace/deep-research"),
},
// {
// id: "workspace-enterprise-research",
// label: "企业深度研究",
// icon: FileSearch,
// path: "/page/workspace/enterprise-research",
// match: (pathname) => pathname.startsWith("/page/workspace/enterprise-research"),
// },
{
id: "workspace-roundtable-planning",
label: "多智能体规划",
@@ -7,9 +7,11 @@ import {
BriefcaseIcon,
CalendarClockIcon,
ChevronsUpDown,
CpuIcon,
DatabaseZapIcon,
GaugeIcon,
HistoryIcon,
ListTreeIcon,
MessageCircleQuestionIcon,
MessageSquareTextIcon,
ScrollTextIcon,
@@ -227,6 +229,24 @@ export function WorkspaceNavMenu() {
技能管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/models")}
>
<CpuIcon />
模型配置
</DropdownMenuItem>
)}
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口(管理员):
简洁模式没有全局侧边栏,这里放一份保证两种模式都能进菜单管理。 */}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/menu-management")}
>
<ListTreeIcon />
菜单管理
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
+8 -8
View File
@@ -61,9 +61,9 @@ 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;
/** 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 +98,8 @@ 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;
llmwiki_knowledge_base_ids?: string[] | null;
}
export interface UpdateAgentRequest {
@@ -116,6 +116,6 @@ export interface UpdateAgentRequest {
/** 入库模板名;传空字符串表示清除。 */
template_name?: string | null;
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null;
llmwiki_knowledge_base_ids?: string[] | null;
}
seat_skill_directive?: boolean | null;
llmwiki_knowledge_base_ids?: string[] | null;
}
+16 -4
View File
@@ -5,7 +5,12 @@ import { useThread } from "@/components/workspace/messages/context";
import { isImageFile } from "../utils/files";
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
import {
loadArtifactContent,
loadArtifactContentFromLatestWriteFile,
loadArtifactContentFromToolCall,
} from "./loader";
import { isArtifactApiPath } from "./streaming-write-preview";
import { updateArtifactContent } from "./api";
function isWriteFileStreamingUrl(filepath: string): boolean {
@@ -95,13 +100,19 @@ export function useArtifactContent({
}
return null;
}, [filepath, useWriteFileStreaming, thread]);
const toolCallFallbackContent = useMemo(() => {
if (useWriteFileStreaming || !isArtifactApiPath(filepath)) {
return undefined;
}
return loadArtifactContentFromLatestWriteFile({ filepath, thread });
}, [filepath, thread, useWriteFileStreaming]);
const { data, isLoading, error } = useQuery({
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["artifact", filepath, threadId, isMock, publicShareCode],
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,
@@ -120,11 +131,12 @@ export function useArtifactContent({
return {
content: useWriteFileStreaming
? content
: (pendingEdit?.content ?? data?.content),
: (pendingEdit?.content ?? data?.content ?? toolCallFallbackContent),
url: useWriteFileStreaming ? undefined : data?.url,
isLoading,
isApplyingEdit: pendingEdit?.kind === "editing",
error,
refetch,
};
}
+56 -3
View File
@@ -46,7 +46,7 @@ export async function loadArtifactContent({
return { content: text, url };
}
export function loadArtifactContentFromToolCall({
export function loadArtifactContentFromToolCall({
url: urlString,
thread,
}: {
@@ -66,5 +66,58 @@ export function loadArtifactContentFromToolCall({
return toolCall.args.content;
}
}
}
}
}
}
function normalizeArtifactPath(path: string): string {
let normalized = path.trim();
const first = normalized[0];
const last = normalized.at(-1);
if (
normalized.length >= 2 &&
((first === '"' && last === '"') ||
(first === "'" && last === "'") ||
(first === "`" && last === "`"))
) {
normalized = normalized.slice(1, -1).trim();
}
return normalized.replace(/\\/g, "/").replace(/^\/+/, "");
}
/**
* Last-resort preview for an output whose artifact HTTP request is unavailable.
*
* A completed `write_file` can be selected before its backing sandbox file is
* visible through the artifacts endpoint (or when a deployment temporarily
* serves a stale mount). The original tool arguments still contain the
* Markdown, so use the newest matching write as a read-only display fallback.
*/
export function loadArtifactContentFromLatestWriteFile({
filepath,
thread,
}: {
filepath: string;
thread: BaseStream<AgentThreadState>;
}): string | undefined {
const normalizedFilepath = normalizeArtifactPath(filepath);
if (!normalizedFilepath) return undefined;
for (const message of [...thread.messages].reverse()) {
if (message.type !== "ai" || !message.tool_calls) continue;
for (const toolCall of [...message.tool_calls].reverse()) {
if (toolCall.name !== "write_file") continue;
if (!toolCall.args || typeof toolCall.args !== "object" || Array.isArray(toolCall.args)) {
continue;
}
const args = toolCall.args as { path?: unknown; content?: unknown };
if (
typeof args.path === "string" &&
typeof args.content === "string" &&
normalizeArtifactPath(args.path) === normalizedFilepath
) {
return args.content;
}
}
}
return undefined;
}
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isArtifactApiPath,
isProvisionalStreamingArtifactPath,
provisionalWriteFilePath,
} from "./streaming-write-preview";
test("provisionalWriteFilePath uses html only for html documents", () => {
assert.equal(provisionalWriteFilePath("# title"), "未命名文件.md");
assert.equal(provisionalWriteFilePath("<!DOCTYPE html><html>"), "未命名文件.html");
assert.equal(provisionalWriteFilePath("<html lang='zh'>"), "未命名文件.html");
});
test("isProvisionalStreamingArtifactPath matches the streaming placeholder names", () => {
assert.equal(isProvisionalStreamingArtifactPath("未命名文件.md"), true);
assert.equal(isProvisionalStreamingArtifactPath("/未命名文件.html"), true);
assert.equal(isProvisionalStreamingArtifactPath("mnt/user-data/outputs/report.md"), false);
});
test("isArtifactApiPath rejects write-file URLs and placeholder names", () => {
assert.equal(isArtifactApiPath("未命名文件.md"), false);
assert.equal(
isArtifactApiPath(
"write-file:未命名文件.md?message_id=m1&tool_call_id=t1&language=markdown",
),
false,
);
assert.equal(isArtifactApiPath("mnt/user-data/outputs/report.md"), true);
assert.equal(isArtifactApiPath("/mnt/user-data/outputs/report.md"), true);
});
@@ -23,6 +23,11 @@ export function hasCompleteArtifactExtension(path: string): boolean {
return /\.[a-z0-9]{2,12}$/i.test(filename);
}
export const PROVISIONAL_WRITE_FILE_NAMES = new Set([
"未命名文件.md",
"未命名文件.html",
]);
export function provisionalWriteFilePath(content: string): string {
const head = content.slice(0, 256).trimStart().toLowerCase();
if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
@@ -31,6 +36,29 @@ export function provisionalWriteFilePath(content: string): string {
return "未命名文件.md";
}
export function isProvisionalStreamingArtifactPath(path: string): boolean {
const pathname =
normalizeStreamingArtifactPath(path).split(/[?#]/, 1)[0] ?? "";
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
return PROVISIONAL_WRITE_FILE_NAMES.has(filename);
}
/** Paths the artifacts HTTP API can actually serve (sandbox virtual paths). */
export function isArtifactApiPath(path: string): boolean {
if (!path || path.startsWith("write-file:")) {
return false;
}
if (isProvisionalStreamingArtifactPath(path)) {
return false;
}
const normalized = path.replace(/^\/+/, "").replace(/\\/g, "/");
return (
normalized === "mnt/user-data" ||
normalized.startsWith("mnt/user-data/") ||
normalized.startsWith("mnt/skills/")
);
}
export function languageOfStreamingArtifact(
path: string,
): StreamingArtifactLanguage | null {
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
HOST_NAVIGATE_TYPE,
buildHostNavigateMessage,
isHostNavigatePath,
parseHostTaskId,
resolveHostNavigateOrigin,
} from "./host-navigate";
test("isHostNavigatePath accepts a host route", () => {
assert.equal(isHostNavigatePath("/task/item-aiceh/step-analysis-audience"), true);
assert.equal(isHostNavigatePath(" /task/foo "), true);
});
test("isHostNavigatePath rejects urls and protocol-relative paths", () => {
assert.equal(isHostNavigatePath(""), false);
assert.equal(isHostNavigatePath("https://localhost:4912/task"), false);
assert.equal(isHostNavigatePath("//evil.example/task"), false);
assert.equal(isHostNavigatePath("task/foo"), false);
assert.equal(isHostNavigatePath("/task\\foo"), false);
});
test("parseHostTaskId returns a number", () => {
assert.equal(parseHostTaskId("108"), 108);
assert.equal(parseHostTaskId(" 108 "), 108);
assert.equal(parseHostTaskId("0108"), 108);
});
test("parseHostTaskId rejects non-integers", () => {
assert.equal(parseHostTaskId(""), null);
assert.equal(parseHostTaskId("abc"), null);
assert.equal(parseHostTaskId("108.5"), null);
assert.equal(parseHostTaskId("12e3"), null);
assert.equal(parseHostTaskId(null), null);
});
test("resolveHostNavigateOrigin prefers configured origin", () => {
assert.equal(
resolveHostNavigateOrigin({
configured: "https://localhost:4912",
ancestorOrigin: "https://other.example",
referrer: "https://referrer.example/page",
selfOrigin: "http://localhost:5174",
}),
"https://localhost:4912",
);
});
test("resolveHostNavigateOrigin strips a path from configured origin", () => {
assert.equal(
resolveHostNavigateOrigin({ configured: "https://localhost:4912/app" }),
"https://localhost:4912",
);
});
test("resolveHostNavigateOrigin falls back to ancestorOrigins then referrer", () => {
assert.equal(
resolveHostNavigateOrigin({
ancestorOrigin: "https://host.example",
referrer: "https://referrer.example/",
selfOrigin: "http://localhost:5174",
}),
"https://host.example",
);
assert.equal(
resolveHostNavigateOrigin({
referrer: "https://referrer.example/page",
selfOrigin: "http://localhost:5174",
}),
"https://referrer.example",
);
});
test("resolveHostNavigateOrigin never uses * or same-origin referrer", () => {
assert.equal(resolveHostNavigateOrigin({ configured: "*" }), null);
assert.equal(
resolveHostNavigateOrigin({
referrer: "http://localhost:5174/page/foo",
selfOrigin: "http://localhost:5174",
}),
null,
);
assert.equal(resolveHostNavigateOrigin({}), null);
});
test("buildHostNavigateMessage matches the host contract", () => {
assert.deepEqual(
buildHostNavigateMessage("/task/item-aiceh/step-analysis-audience", { id: 108 }),
{
type: HOST_NAVIGATE_TYPE,
path: "/task/item-aiceh/step-analysis-audience",
queryParams: { id: 108 },
},
);
});
@@ -0,0 +1,95 @@
/**
* iframe 嵌入宿主系统时,任务跳转按钮通过 ``window.parent.postMessage`` 让
* **父页面**改路由(宿主协议 ``HOST_NAVIGATE``)。不改 iframe 自己的地址。
*
* 消息形状对齐宿主约定:
* ``{ type: 'HOST_NAVIGATE', path, queryParams: { id: number } }``
* 第二参必须是宿主 origin,禁止 ``'*'``。
*/
export const HOST_NAVIGATE_TYPE = "HOST_NAVIGATE";
export interface HostNavigateMessage {
type: typeof HOST_NAVIGATE_TYPE;
path: string;
queryParams: Record<string, number>;
}
/** 宿主路由:以单个 ``/`` 开头的路径,不含 ``//`` / 反斜杠。 */
export function isHostNavigatePath(path: string): boolean {
const value = path.trim();
if (!value || value.length > 2048) return false;
if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return false;
return true;
}
/** 任务 id 转数字。只接受整数字符串(可带正负号),否则返回 null。 */
export function parseHostTaskId(raw: string | null | undefined): number | null {
const value = (raw ?? "").trim();
if (!value || !/^-?\d+$/.test(value)) return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function normalizeOrigin(value: string | null | undefined): string | null {
const raw = (value ?? "").trim();
if (!raw || raw === "*") return null;
try {
const url = new URL(raw);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return url.origin;
} catch {
return null;
}
}
/**
* 解析 postMessage 的 targetOrigin。
* 优先级:显式配置 → ``ancestorOrigins[0]`` → 跨源 ``document.referrer``。
* 解析失败返回 null(调用方应中止,不得退回 ``'*'``)。
*/
export function resolveHostNavigateOrigin(input: {
configured?: string | null;
ancestorOrigin?: string | null;
referrer?: string | null;
selfOrigin?: string | null;
}): string | null {
const configured = normalizeOrigin(input.configured);
if (configured) return configured;
const ancestor = normalizeOrigin(input.ancestorOrigin);
if (ancestor) return ancestor;
const referrer = normalizeOrigin(input.referrer);
if (!referrer) return null;
const selfOrigin = normalizeOrigin(input.selfOrigin);
if (selfOrigin && referrer === selfOrigin) return null;
return referrer;
}
export function readAncestorOrigin(): string | null {
if (typeof window === "undefined") return null;
try {
return window.location.ancestorOrigins?.[0] ?? null;
} catch {
return null;
}
}
export function isEmbeddedInParent(): boolean {
if (typeof window === "undefined") return false;
try {
return window.parent !== window;
} catch {
return true;
}
}
export function buildHostNavigateMessage(
path: string,
queryParams: Record<string, number> = {},
): HostNavigateMessage {
return {
type: HOST_NAVIGATE_TYPE,
path,
queryParams,
};
}
+51 -5
View File
@@ -7,9 +7,11 @@
* 任务深链配置读出 rwfx 的 4 个历史按钮,3qfx/xdfx 为空)——这样真实跳转地址通过实时
* config 进种子,避免写死;一旦保存过,后端即唯一数据源。
*
* 按钮两类(``linkType``):``url``(外链 ``window.open``;**或应用内路由**——target 以 ``/`` 开头
* 按钮类型(``linkType``):``url``(外链 ``window.open``;**或应用内路由**——target 以 ``/`` 开头
* 时按内部路由处理:``self`` 走 react-router 同窗导航、``blank`` 拼 hash 路由新标签;可选追加 ``?{idParam}=id``)/
* ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)。
* ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)/
* ``purpose`` 弹出任务目的树 / ``host`` iframe 内向父页面 ``postMessage({ type:'HOST_NAVIGATE' })``
* 让宿主系统跳转(见 [[host-navigate]])。
*/
import {
@@ -25,6 +27,15 @@ import {
readLoginToken,
readLoginUsername,
} from "@/core/auth/login-credentials";
import {
buildHostNavigateMessage,
isEmbeddedInParent,
isHostNavigatePath,
parseHostTaskId,
readAncestorOrigin,
resolveHostNavigateOrigin,
} from "@/core/auth/host-navigate";
import { readRuntimeEnv } from "@/env";
/** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */
export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent";
@@ -62,8 +73,8 @@ export const TASK_BUSINESSES: {
},
];
/** ``url``=外链 / ``business``=切换业务 / ``purpose``=弹出任务目的树表格(无需 target)。 */
export type TaskButtonLinkType = "url" | "business" | "purpose";
/** ``url``=外链 / ``business``=切换业务 / ``purpose``=弹出任务目的树表格 / ``host``=宿主路由(postMessage)。 */
export type TaskButtonLinkType = "url" | "business" | "purpose" | "host";
/** 追加的 id 取哪种:``task``=任务id(默认)/ ``action``=行动id(仅 xdfx 二者不同)。 */
export type TaskButtonIdKind = "task" | "action";
@@ -91,7 +102,7 @@ export interface TaskButton {
business: TaskBusiness;
label: string;
linkType: TaskButtonLinkType;
/** ``url``=完整地址;``business``=目标业务码(如 ``3qfx``)。 */
/** ``url``=完整地址;``business``=目标业务码(如 ``3qfx``);``host``=宿主系统路由(如 ``/task/...``)。 */
target: string;
/** ``url`` 类型是否追加 id 查询参数。 */
appendTaskId: boolean;
@@ -316,11 +327,46 @@ export interface RunTaskButtonCtx {
* 执行一个跳转按钮:
* - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。
* - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。
* - ``host``:向 ``window.parent`` 发 ``HOST_NAVIGATE``,由宿主系统跳转(仅 iframe 嵌入时生效)。
*/
export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise<void> {
const fail = (m: string) => ctx.onError?.(m);
// purpose(目的树弹框)由组件层处理(需打开 React 弹框),此处不动作。
if (btn.linkType === "purpose") return;
if (btn.linkType === "host") {
const path = btn.target.trim();
if (!isHostNavigatePath(path)) {
fail(`未配置「${btn.label}」的宿主路由`);
return;
}
if (!isEmbeddedInParent()) {
fail("当前不在嵌入页,无法跳转宿主系统");
return;
}
const origin = resolveHostNavigateOrigin({
configured: readRuntimeEnv("VITE_HOST_NAVIGATE_ORIGIN"),
ancestorOrigin: readAncestorOrigin(),
referrer: typeof document !== "undefined" ? document.referrer : null,
selfOrigin: typeof window !== "undefined" ? window.location.origin : null,
});
if (!origin) {
fail("未配置宿主地址(VITE_HOST_NAVIGATE_ORIGIN),无法安全跳转");
return;
}
const queryParams: Record<string, number> = {};
if (btn.appendTaskId) {
const id = parseHostTaskId(ctx.taskId);
if (id === null) {
fail("任务id不是数字,无法跳转宿主系统");
return;
}
queryParams[(btn.idParam || "id").trim() || "id"] = id;
}
const message = buildHostNavigateMessage(path, queryParams);
console.info(`[task-button] 「${btn.label}」宿主路由 →`, message, origin);
window.parent.postMessage(message, origin);
return;
}
if (btn.linkType === "url") {
const base = btn.target.trim();
if (!base) {
+41 -16
View File
@@ -95,25 +95,41 @@ function mockCopTaskDetail(taskId: string): CopTaskDetail {
};
}
/** 拉取任务详情;``VITE_MOCK_COP_TASK_DETAIL=true`` 走假数据;缺少 consumer 地址 / 返回不合法时返回 null。 */
/** 详情接口不可用时仍能继续深链跳转:至少带上任务 id,标题/简介可空。 */
export function stubCopTaskDetail(taskId: string): CopTaskDetail {
return {
id: taskId,
overview: "",
taskDirection: "",
taskName: "",
taskContent: "",
};
}
/** 拉取任务详情;``VITE_MOCK_COP_TASK_DETAIL=true`` 走假数据;缺少 consumer 地址 / 返回不合法 / 网络失败时返回 null。 */
export async function fetchCopTaskDetail(taskId: string): Promise<CopTaskDetail | null> {
if (copTaskDetailMockEnabled()) {
return mockCopTaskDetail(taskId);
}
const base = consumerBaseUrl();
if (!base || !taskId) return null;
// service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。
const body = await requestCrudService.get(`${base}/taskAnalyseSearch/cop-task-detail`, { taskId });
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
if (!data || typeof data !== "object") return null;
const d = data as Record<string, unknown>;
return {
id: (d.id as string | number | null) ?? null,
overview: typeof d.overview === "string" ? d.overview : "",
taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "",
taskName: typeof d.taskName === "string" ? d.taskName : "",
taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
};
try {
// service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。
const body = await requestCrudService.get(`${base}/taskAnalyseSearch/cop-task-detail`, { taskId });
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
if (!data || typeof data !== "object") return null;
const d = data as Record<string, unknown>;
return {
id: (d.id as string | number | null) ?? null,
overview: typeof d.overview === "string" ? d.overview : "",
taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "",
taskName: typeof d.taskName === "string" ? d.taskName : "",
taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
};
} catch (error) {
console.warn("[task-deeplink] cop-task-detail failed", { taskId, error });
return null;
}
}
/** 把任务详情整包暂存到 sessionStorage(隐私模式/quota 异常时静默失败)。 */
@@ -223,7 +239,12 @@ export async function fetchTaskIdFromAction(actionId: string): Promise<string> {
export async function resolveDeeplinkTaskId(goPath: string, rawId: string): Promise<string> {
const parsed = parseGoPath(goPath);
if (parsed?.businessCode === XDFX_BUSINESS_CODE) {
return fetchTaskIdFromAction(rawId);
try {
return (await fetchTaskIdFromAction(rawId)) || "";
} catch (error) {
console.warn("[task-deeplink] action→taskId resolve failed", { rawId, error });
return "";
}
}
return rawId;
}
@@ -332,10 +353,14 @@ export async function resolveTaskDeeplinkTarget(
try {
const mappings = await listBusinessMappings();
agentId = mappings.find((m) => m.businessCode === parsed.businessCode)?.agentId ?? null;
} catch {
} catch (error) {
console.warn("[task-deeplink] business mapping failed", error);
agentId = null;
}
if (!agentId) return null;
if (!agentId) {
console.warn("[task-deeplink] no agent mapped for", parsed.businessCode);
return null;
}
// 单智能体问答统一走「任务工作区」(任务↔对话 1:N,按 taskId + agentId 隔离不同业务的对话列表):
// 暂存任务上下文(taskId + agentId + 开场信息),跳到 chats 索引,由工作区按 taskId 决定
@@ -6,10 +6,13 @@ 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;
threadId: string;
/** A report regeneration is a new document, not an edit of the current one. */
operation: "rewrite" | "generate";
source: {
kind: "artifact" | "deep_research_report";
id: string;
@@ -19,17 +22,27 @@ 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. */
originalContent: string;
rewrittenContent: string | null;
versionId: string | null;
/** Hash of the currently committed rewrite, used by the local undo fallback. */
committedHash: string | null;
/** A browser-only undo backup is available because the server snapshot failed. */
localFallbackAvailable: boolean;
/** Inverse snapshot created by a safe undo; enables a one-click reapply. */
redoVersionId: string | null;
revertedAt: string | null;
@@ -42,23 +55,36 @@ type CreatePresentationInput = Omit<
DocumentRewritePresentation,
| "id"
| "stage"
| "operation"
| "generateImages"
| "maxGeneratedImages"
| "generatedImageCount"
| "illustrationTarget"
| "originalCharCount"
| "writtenCharCount"
| "requirementsPlan"
| "thinkingText"
| "warnings"
| "summary"
| "originalContent"
| "rewrittenContent"
| "versionId"
| "committedHash"
| "localFallbackAvailable"
| "redoVersionId"
| "revertedAt"
| "error"
| "createdAt"
| "updatedAt"
> & {
operation?: DocumentRewritePresentation["operation"];
originalContent?: string;
rewrittenContent?: string | null;
committedHash?: string | null;
localFallbackAvailable?: boolean;
requirementsPlan?: string;
generateImages?: boolean;
maxGeneratedImages?: number;
};
type DocumentRewritePresentationContextValue = {
@@ -74,10 +100,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,14 +119,22 @@ export function DocumentRewritePresentationProvider({
...input,
id,
stage: "snapshot",
operation: input.operation ?? "rewrite",
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 ?? "",
rewrittenContent: input.rewrittenContent ?? null,
versionId: null,
committedHash: input.committedHash ?? null,
localFallbackAvailable: input.localFallbackAvailable === true,
redoVersionId: null,
revertedAt: null,
error: null,
@@ -164,3 +195,12 @@ export function useDocumentRewritePresentation() {
/** Browser event consumed by the open sandbox after an in-feed undo succeeds. */
export const DOCUMENT_REWRITE_UNDONE_EVENT = "deerflow:document-rewrite-undone";
/** Synchronous guard used before a browser-only undo writes back the original text. */
export const DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT = "deerflow:document-rewrite-local-undo-guard";
/** Sent after the user confirms a rewrite so the conversation can reveal its new step card. */
export const DOCUMENT_REWRITE_STARTED_EVENT = "deerflow:document-rewrite-started";
/** A completed structural-regeneration card requests an in-place rewrite of that new report. */
export const DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT = "deerflow:deep-research-report-rewrite-requested";
@@ -0,0 +1,133 @@
import { getAccountIdentityKey } from "@/core/auth";
import { authStorage } from "@/core/auth/scoped-storage";
const STORAGE_PREFIX = "deerflow.document-rewrite.local-fallback:v1";
// Keep the browser fallback comfortably below the typical 5 MB storage quota.
const MAX_BACKUP_CHARACTERS = 1_000_000;
type PendingArtifactRewriteFallback = {
threadId: string;
path: string;
originalContent: string;
committedHash: string | null;
createdAt: string;
};
export type ArtifactRewriteFallback = PendingArtifactRewriteFallback & {
committedHash: string;
};
function storageKey(threadId: string, path: string) {
const account = getAccountIdentityKey() ?? "anonymous";
return [STORAGE_PREFIX, account, threadId, path]
.map((part) => encodeURIComponent(part))
.join(":");
}
function getStorage(): Storage | null {
if (typeof window === "undefined") return null;
return authStorage();
}
function canPersist(originalContent: string): boolean {
if (originalContent.length <= MAX_BACKUP_CHARACTERS) return true;
console.error("AI 全文改写的本地撤销备份过大,未写入浏览器存储。");
return false;
}
/**
* Save the original text before the durable rewrite starts. It is completed
* with the rewritten file hash only if the server-side undo snapshot fails.
*/
export function primeArtifactRewriteFallback({
threadId,
path,
originalContent,
}: Omit<PendingArtifactRewriteFallback, "committedHash" | "createdAt">): void {
if (!canPersist(originalContent)) return;
try {
getStorage()?.setItem(
storageKey(threadId, path),
JSON.stringify({
threadId,
path,
originalContent,
committedHash: null,
createdAt: new Date().toISOString(),
} satisfies PendingArtifactRewriteFallback),
);
} catch (error) {
console.error("保存 AI 全文改写的本地撤销备份失败:", error);
}
}
/** Mark a primed backup as safe to use for the server snapshot failure path. */
export function completeArtifactRewriteFallback({
threadId,
path,
originalContent,
committedHash,
}: Omit<ArtifactRewriteFallback, "createdAt">): boolean {
if (!committedHash || !canPersist(originalContent)) return false;
try {
const storage = getStorage();
if (!storage) return false;
storage.setItem(
storageKey(threadId, path),
JSON.stringify({
threadId,
path,
originalContent,
committedHash,
createdAt: new Date().toISOString(),
} satisfies ArtifactRewriteFallback),
);
return true;
} catch (error) {
console.error("完成 AI 全文改写的本地撤销备份失败:", error);
return false;
}
}
export function loadArtifactRewriteFallback({
threadId,
path,
committedHash,
}: Pick<ArtifactRewriteFallback, "threadId" | "path" | "committedHash">): ArtifactRewriteFallback | null {
try {
const raw = getStorage()?.getItem(storageKey(threadId, path));
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const fallback = parsed as Partial<ArtifactRewriteFallback>;
if (
fallback.threadId !== threadId ||
fallback.path !== path ||
fallback.committedHash !== committedHash ||
typeof fallback.originalContent !== "string"
) {
return null;
}
return fallback as ArtifactRewriteFallback;
} catch (error) {
console.error("读取 AI 全文改写的本地撤销备份失败:", error);
return null;
}
}
export function clearArtifactRewriteFallback(threadId: string, path: string): void {
try {
getStorage()?.removeItem(storageKey(threadId, path));
} catch (error) {
console.error("清理 AI 全文改写的本地撤销备份失败:", error);
}
}
export async function sha256DocumentContent(content: string): Promise<string | null> {
if (!globalThis.crypto?.subtle) return null;
const digest = await globalThis.crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(content),
);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
+41 -4
View File
@@ -22,6 +22,8 @@ interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistan
interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {}
interface AssistantSetupReportStructureApprovalGroup extends GenericMessageGroup<"assistant:setup-report-structure-approval"> {}
interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant:research-approval"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
@@ -34,6 +36,7 @@ type MessageGroup =
| AssistantClarificationGroup
| AssistantSetupAgentApprovalGroup
| AssistantSetupWritingApprovalGroup
| AssistantSetupReportStructureApprovalGroup
| AssistantResearchApprovalGroup
| AssistantSubagentGroup;
@@ -58,6 +61,7 @@ export function groupMessages<T>(
last.type !== "assistant:clarification" &&
last.type !== "assistant:setup-agent-approval" &&
last.type !== "assistant:setup-writing-approval" &&
last.type !== "assistant:setup-report-structure-approval" &&
last.type !== "assistant:research-approval"
) {
return last;
@@ -103,6 +107,13 @@ export function groupMessages<T>(
type: "assistant:setup-writing-approval",
messages: [message],
});
} else if (isSetupReportStructureApprovalToolMessage(message)) {
lastOpenGroup()?.messages.push(message);
groups.push({
id: message.id,
type: "assistant:setup-report-structure-approval",
messages: [message],
});
} else {
const open = lastOpenGroup();
if (open) {
@@ -118,10 +129,10 @@ export function groupMessages<T>(
}
if (message.type === "ai") {
// Deep-research interaction card: virtual ai messages injected by the
// workbench (additional_kwargs.setup_research_approval) render through
// the host-provided slot. Writing phases use ordinary virtual tool calls
// and completed summaries remain normal assistant messages.
// Deep-research interaction card: virtual ai messages injected by the
// workbench (additional_kwargs.setup_research_approval) render through
// the host-provided slot. Writing phases use ordinary virtual tool calls
// and completed summaries remain normal assistant messages.
if (hasResearchApproval(message)) {
groups.push({
id: message.id,
@@ -136,6 +147,15 @@ export function groupMessages<T>(
type: "assistant:present-files",
messages: [message],
});
} else if (hasOnlyDeepResearchProgress(message)) {
// Each deep-research writing bar is its own group. Merging consecutive
// processing messages would pin a new report's in-progress steps onto
// the parent bar, which sits above the original file card.
groups.push({
id: message.id,
type: "assistant:processing",
messages: [message],
});
} else if (hasSubagent(message)) {
groups.push({
id: message.id,
@@ -380,6 +400,14 @@ export function hasPresentFiles(message: Message) {
);
}
export function hasOnlyDeepResearchProgress(message: Message) {
return (
message.type === "ai" &&
Boolean(message.tool_calls?.length) &&
message.tool_calls!.every((toolCall) => toolCall.name === "deep_research_progress")
);
}
export function isClarificationToolMessage(message: Message) {
return message.type === "tool" && message.name === "ask_clarification";
}
@@ -402,6 +430,15 @@ export function isSetupWritingApprovalToolMessage(message: Message) {
);
}
export function isSetupReportStructureApprovalToolMessage(message: Message) {
return (
message.type === "tool" &&
message.name === "setup_report_structure" &&
typeof message.additional_kwargs?.setup_report_structure_approval === "object" &&
message.additional_kwargs.setup_report_structure_approval !== null
);
}
export function hasResearchApproval(message: Message) {
return (
message.type === "ai" &&
@@ -103,6 +103,16 @@ export async function deleteManagedModel(name: string): Promise<void> {
if (!response.ok) return readError(response, "删除模型失败");
}
export async function reorderManagedModels(names: string[]): Promise<ManagedModel[]> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/order`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ names }),
});
if (!response.ok) return readError(response, "调整模型顺序失败");
return response.json() as Promise<ManagedModel[]>;
}
export async function testManagedModel(model: ModelSavePayload): Promise<ModelConnectionTestResult> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/test`, {
method: "POST",
@@ -4,6 +4,7 @@ import {
createManagedModel,
deleteManagedModel,
loadManagedModels,
reorderManagedModels,
testManagedModel,
updateManagedModel,
type ModelSavePayload,
@@ -50,3 +51,16 @@ export function useDeleteManagedModel() {
},
});
}
export function useReorderManagedModels() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: reorderManagedModels,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: MANAGED_MODELS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ["models"] }),
]);
},
});
}
@@ -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;
@@ -19,6 +19,7 @@ import {
ClipboardList,
Database,
FileBox,
FileSearch,
FileText,
Gauge,
Grid3X3,
@@ -99,6 +100,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Telescope,
path: "/page/strategy/qa/deep-research",
},
// {
// id: "qa-enterprise-research",
// label: "企业深度研究",
// icon: FileSearch,
// path: "/page/strategy/qa/enterprise-research",
// },
],
},
{
@@ -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();
}
+8 -2
View File
@@ -99,8 +99,14 @@ export function useNotebookChat(
},
});
const send = async (text: string, selectedSourceIds: number[]) => {
if (thread.isLoading || !token) return;
const send = async (
text: string,
selectedSourceIds: number[],
options?: { fromClarification?: boolean },
) => {
if (!token) return;
// 协助卡提交前会先停轮;此时 isLoading 可能仍为 true,不能直接丢弃。
if (thread.isLoading && !options?.fromClarification) return;
// API 需要 document.id,而不是 source.id
const toDocIds = (ids: number[]) =>
ids
+1 -1
View File
@@ -314,7 +314,7 @@ export interface CustomThemePreset {
}
export type ViewMode = "card" | "list";
export type LayoutMode = "full" | "compact";
export type CompactShortcutParamType = "login" | "theme";
export type CompactShortcutParamType = "login" | "theme" | "path";
export interface CompactShortcutRouteParam {
key: string;
File diff suppressed because it is too large. Load diff
@@ -63,6 +63,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,10 +2,11 @@ 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.";
type KnowledgeBaseChunk = {
type KnowledgeBaseChunk = Record<string, unknown> & {
answer?: unknown;
docs?: unknown;
};
@@ -168,38 +169,116 @@ function parseKnowledgeBaseChunk(jsonText: string): KnowledgeBaseChunk {
}
}
function flattenDocs(value: unknown): Record<string, unknown>[] {
if (Array.isArray(value)) {
return value.flatMap((item) => flattenDocs(item));
function readString(...values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value === "string" && value.trim()) return value;
}
if (!value || typeof value !== "object") return [];
const record = value as Record<string, unknown>;
if ("docs" in record) return flattenDocs(record.docs);
return [record];
return undefined;
}
function normalizeSources(value: unknown): RagSource[] {
return flattenDocs(value).map((item, index) => {
const title = String(item.m_title ?? item.title ?? item.source ?? `参考资料 ${index + 1}`);
const content = String(item.m_content ?? item.content ?? item.snippet ?? "");
const source = item.source1 ?? item.source;
const rawUrl = item.url ?? item.link ?? item.href;
return {
id: String(item.recUuid ?? item.rec_uuid ?? item.uuid ?? item.id ?? "") || undefined,
title,
type: String(item.source1 ?? item.type ?? "知识库"),
snippet: content,
content,
source: typeof source === "string" ? source : undefined,
time: typeof item.m_publish === "string" ? item.m_publish : undefined,
url: typeof rawUrl === "string" && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined,
raw: item,
};
function docsFallbackContent(docs: unknown): string | undefined {
const candidates = Array.isArray(docs) ? docs : [docs];
return readString(...candidates);
}
function buildKnowledgeDetailUrl(recUuid: string | undefined): string | undefined {
const knowledgeUrl = readRuntimeEnv("VITE_AIPI_KNOWLEDGE_URL")?.replace(/\/$/, "");
if (!knowledgeUrl || !recUuid) return undefined;
return `${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${encodeURIComponent(recUuid)}`;
}
function toRagSource(
record: Record<string, unknown>,
index: number,
fallbackContent?: string,
): RagSource {
const title =
readString(record.m_title, record.title, record.source, record.source1) ??
`参考资料 ${index + 1}`;
const content =
readString(
record.m_content,
// Some deployed responses spell this field incorrectly; keep it as a
// read-only compatibility alias rather than dropping the document.
record.m_conent,
record.content,
record.snippet,
fallbackContent,
record.url,
) ?? "";
const source = readString(record.source1, record.source, record.m_source);
const rawUrl = readString(record.url, record.link, record.href);
const recUuid = readString(record.recUuid, record.rec_uuid, record.uuid, record.id);
return {
id: recUuid,
title,
type: readString(record.type, record.source1) ?? "知识库",
snippet: content,
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),
raw: record,
};
}
/**
* 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.
*/
function normalizeSources(chunk: KnowledgeBaseChunk): RagSource[] {
const hasRootMetadata = [
chunk.recUuid,
chunk.rec_uuid,
chunk.m_title,
chunk.m_content,
chunk.m_conent,
chunk.source1,
chunk.m_publish,
chunk.m_publicsh,
].some((value) => typeof value === "string" && value.trim());
if (hasRootMetadata) {
return [toRagSource(chunk, 0, docsFallbackContent(chunk.docs))];
}
const docs = Array.isArray(chunk.docs) ? chunk.docs : chunk.docs ? [chunk.docs] : [];
return docs.flatMap((item, index): RagSource[] => {
if (typeof item === "string") {
return [toRagSource({}, index, item)];
}
if (!item || typeof item !== "object") return [];
return [toRagSource(item as Record<string, unknown>, index)];
});
}
function mergeSources(current: RagSource[], incoming: RagSource[]): RagSource[] {
const result = [...current];
const keys = new Set(
current.map((source) =>
source.id
? `id:${source.id}`
: `content:${source.title}\u0000${source.content}\u0000${source.source ?? ""}`,
),
);
for (const source of incoming) {
const key = source.id
? `id:${source.id}`
: `content:${source.title}\u0000${source.content}\u0000${source.source ?? ""}`;
if (keys.has(key)) continue;
keys.add(key);
result.push(source);
}
return result;
}
function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`;
return createClientId(prefix);
}
function isAbortError(error: unknown) {
@@ -397,7 +476,9 @@ export function useSingleRoundRagMessages(threadId: string) {
for (const jsonText of extracted.objects) {
const chunk = parseKnowledgeBaseChunk(jsonText);
if (typeof chunk.answer === "string") answerChunks.push({ answer: chunk.answer });
if (chunk.docs !== undefined) sources = normalizeSources(chunk.docs);
if (chunk.docs !== undefined) {
sources = mergeSources(sources, normalizeSources(chunk));
}
refreshAssistant();
}
}
+38
View File
@@ -91,6 +91,44 @@ export function isBootstrapThread(thread: {
return thread.metadata?.thread_type === "bootstrap";
}
/**
* 不应出现在普通对话列表里的 thread_type。
* 与后端 ``EXCLUDED_SYSTEM_THREAD_TYPES`` 保持同步;bootstrap 不在此列(侧栏仍展示)。
*/
export const HIDDEN_FROM_RECENT_CHAT_TYPES = new Set([
"scheduler",
"roundtable",
"notebook",
"deep_research",
"writing_setup",
"report_structure_setup",
]);
export function isSetupWizardThread(thread: {
metadata?: Record<string, unknown> | null;
}): boolean {
const threadType = thread.metadata?.thread_type;
return threadType === "writing_setup" || threadType === "report_structure_setup";
}
/** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */
export function isHiddenFromRecentChats(thread: {
metadata?: Record<string, unknown> | null;
}): boolean {
const metadata = thread.metadata;
if (metadata?.system === true) return true;
const threadType = metadata?.thread_type;
return typeof threadType === "string" && HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType);
}
/** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */
export function shouldHideSystemThreadsFromList(
metadata?: Record<string, unknown> | null,
): boolean {
if (!metadata) return true;
return !("thread_type" in metadata || "system" in metadata);
}
/**
* bootstrap 会话点击后回到「创建智能体」页并恢复该会话。该页只挂在
* /page/workspace 下(见 WorkspaceRoutes 的 agents/new/:thread_id),故路径固定,
@@ -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,566 @@
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)}
jobResponsibilities={selectedSeat?.job_responsibilities ?? ""}
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 = firstAgentAvatarChar(name);
return (
<button type="button" className="eightbf-roundtable__agent-selector-card" onClick={onSelect}>
<span className="eightbf-roundtable__agent-selector-avatar" aria-hidden="true">
{avatarText ? avatarText : <img src={agentConfigLogo} alt="" />}
</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>
);
}
/** 名称首字若是【、( 这类标点,列表卡片改用智能体 logo,不把标点当头像。 */
function firstAgentAvatarChar(name: string): string | null {
const first = Array.from(name.trim())[0];
if (!first || /^[\p{P}\p{S}]$/u.test(first)) return null;
return first.toUpperCase();
}
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
@@ -157,12 +248,79 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
);
return (
<RoundtablePlanningPage
// 仅将 8BF 会商页锁为深蓝色:全局主题状态和切换逻辑保持原样,
// 后续去掉本容器与 forceDarkAppearance 即可恢复随主题切换。
<div className="dark flex h-full min-h-0 flex-col bg-[#061b3b]">
<RoundtablePlanningPage
compactChrome={compactChrome}
forceDarkAppearance
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 +329,8 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
/>
}
rootClassName="eightbf-roundtable"
/>
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
/>
</div>
);
}
@@ -17,14 +17,409 @@
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-avatar img {
width: 22px;
height: 22px;
object-fit: contain;
}
.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,125 @@
.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,
.eightbf-roundtable .eightbf-roundtable-step2-chat .position-roundtable-intent-header-actions .eightbf-roundtable__pause-button,
.eightbf-roundtable .eightbf-roundtable-step2-chat .position-roundtable-intent-header-actions .eightbf-roundtable__resume-button {
width: 100px;
}
.eightbf-roundtable .eightbf-roundtable-step2-chat .relative.flex-1 > .shrink-0 {
background: #032650;
border-color: rgb(43 143 254 / 28%);
}
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-log .text-slate-700,
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-log .text-slate-500 {
color: #aaceff;
}
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-log .text-slate-400 {
color: #8aa0c4;
}
/* 第二步输入区直接复用第一步 PositionIntentInput;共识动作也使用其
#enter-roundtable-btn 规则,保证和「确认意图并启动业务链」完全同款。 */
.dark .eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-input {
flex: 0 0 auto;
}
@media (max-width: 1280px) {
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { width: 326px; }
}
@media (max-width: 1024px) {
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { width: 294px; }
}
@@ -76,4 +76,70 @@
background: #21958a;
}
/* 可在此继续覆盖第 3 步报告、流程图与放大弹窗的样式。 */
/* 第三步:8BF 左栏与第一步相同,对话区去掉原外层圆角白框,沿用第一、二步深蓝面板。 */
.dark .eightbf-roundtable[data-roundtable-step="3"] > main {
padding: 12px 0;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] > main[data-step2-sidebar-expanded="true"] {
gap: 0;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] > main[data-step2-sidebar-expanded="false"] {
gap: 10px;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] #middle-viewport {
border-color: rgb(43 143 254 / 50%);
border-radius: 4px;
background: #032650;
box-shadow: none;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="true"] {
width: 374px;
padding: 0 12px;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="false"] {
width: 80px;
padding: 0 12px;
}
.eightbf-roundtable .eightbf-roundtable-step3-chat {
min-height: 0;
background: #032650;
}
.eightbf-roundtable .eightbf-roundtable-step3-log {
background: #032650;
border: 0;
box-shadow: none;
}
.eightbf-roundtable .eightbf-roundtable-step3-chat .position-roundtable-intent-header-actions button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-log .text-slate-700,
.eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-log .text-slate-500 {
color: #aaceff;
}
.eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-log .text-slate-400 {
color: #8aa0c4;
}
/* PositionIntentInput is the shared Step 1/2 composer; Step 3 uses the exact same frame. */
.dark .eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-input {
flex: 0 0 auto;
}
@media (max-width: 1280px) {
.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="true"] { width: 326px; }
}
@media (max-width: 1024px) {
.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="true"] { width: 294px; }
}
+5
View File
@@ -41,5 +41,10 @@ export const env = {
* flag so operations can switch the shell without rebuilding the frontend.
*/
FORCE_COLLAPSED_SIDEBAR: readRuntimeFlag("VITE_FORCE_COLLAPSED_SIDEBAR"),
/**
* Compact-menu variant for this website. Pair with the admin 简介模式
* type (A / B) so two deployments can show different left-nav items.
*/
COMPACT_MENU_TYPE: readRuntimeEnv("VITE_COMPACT_MENU_TYPE"),
GITHUB_OAUTH_TOKEN: readRuntimeEnv("VITE_GITHUB_OAUTH_TOKEN"),
};
+27
View File
@@ -0,0 +1,27 @@
/**
* Generate a client-only id without assuming a secure browser context.
*
* `crypto.randomUUID()` is unavailable in several browsers when the app is
* opened directly through an `http://<public-ip>:<port>` address. Request and
* UI ids need uniqueness, not a security token, so a timestamp/random fallback
* keeps those flows usable until the deployment is served over HTTPS.
*/
export function createClientId(prefix?: string): string {
const cryptoApi = typeof globalThis === "object" ? globalThis.crypto : undefined;
let id: string | undefined;
if (typeof cryptoApi?.randomUUID === "function") {
id = cryptoApi.randomUUID();
} else if (typeof cryptoApi?.getRandomValues === "function") {
try {
const values = new Uint32Array(4);
cryptoApi.getRandomValues(values);
id = Array.from(values, (value) => value.toString(16).padStart(8, "0")).join("");
} catch {
// Some restricted browser contexts expose crypto but reject random data.
}
}
id ??= `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
return prefix ? `${prefix}-${id}` : id;
}
+60 -2
View File
@@ -119,6 +119,14 @@ export interface DocumentRewriteRequest {
instruction: string;
style?: DocumentRewriteStyle;
modelName?: string;
/** Optional Markdown chapter structure selected from the report-structure library. */
reportOutline?: string;
/** ``fixed`` reproduces the outline; ``adaptive`` keeps the material-driven layout. */
structureMode?: "fixed" | "adaptive";
/** 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 +148,8 @@ export interface DocumentRewriteSummary {
};
requestedOptimizations?: string[];
validationWarnings?: string[];
generatedImageCount?: number;
requestedImageCount?: number;
}
export type DocumentRewriteStreamEvent =
@@ -156,12 +166,28 @@ 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 }
| { type: "commit_started" }
| { type: "committed"; content: string; committedHash: string; versionId?: string }
| { type: "done"; committedHash: string; versionId?: string }
| {
type: "committed";
content: string;
committedHash: string;
versionId?: string;
/** The rewrite is usable, but its optional undo snapshot was not saved. */
versionSnapshotFailed?: boolean;
}
| { type: "done"; committedHash: string; versionId?: string; versionSnapshotFailed?: boolean }
| { type: "conflict"; message: string }
| { type: "cancelled"; message: string }
| { type: "error"; message: string; stage?: string };
@@ -177,6 +203,9 @@ export interface DurableDocumentRewriteJob {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
reportOutline: string;
generateImages: boolean;
maxGeneratedImages: number;
originalContent: string;
};
}
@@ -229,6 +258,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":
@@ -249,12 +302,14 @@ async function readDocumentRewriteEvent(
content: String(parsed.content ?? ""),
committedHash: String(parsed.committedHash ?? ""),
versionId: typeof parsed.versionId === "string" ? parsed.versionId : undefined,
versionSnapshotFailed: parsed.versionSnapshotFailed === true,
};
case "done":
return {
type: "done",
committedHash: String(parsed.committedHash ?? ""),
versionId: typeof parsed.versionId === "string" ? parsed.versionId : undefined,
versionSnapshotFailed: parsed.versionSnapshotFailed === true,
};
case "conflict":
return { type: "conflict", message: String(parsed.message ?? "文件内容已变化") };
@@ -362,6 +417,9 @@ export async function listDurableDocumentRewrites(
instruction: String(request.instruction ?? ""),
style: (request.style ?? "professional") as DocumentRewriteStyle,
modelName: typeof request.modelName === "string" ? request.modelName : null,
reportOutline: typeof request.reportOutline === "string" ? request.reportOutline : "",
generateImages: request.generateImages === true,
maxGeneratedImages: Number(request.maxGeneratedImages ?? 2),
originalContent: String(request.originalContent ?? ""),
},
}];
@@ -46,8 +46,6 @@ const WRITING_STATUS_PILL: Record<string, { label: string; className: string; sp
error: { label: "出错", className: "text-rose-500" },
};
const SHOW_NOTEBOOK_SPACE_ENTRIES = false;
/** 单条历史记录项 —— 样式参考多智能体会商的 HistoryDropdown 列表项(圆角卡片 + 状态药丸 + 操作按钮)。 */
function WritingHistoryItem({
session,
@@ -253,8 +251,7 @@ export function ResearchSidebar({
</div>
)}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden px-3 py-4">
{SHOW_NOTEBOOK_SPACE_ENTRIES && (
<section className="flex flex-col">
<section className="flex flex-col">
<div className="mb-3 flex items-center justify-between px-2">
<h2 className="text-[15px] font-bold tracking-wide text-slate-400">
笔记本
@@ -350,8 +347,7 @@ export function ResearchSidebar({
<span>所有空间</span>
</button>
</div>
</section>
)}
</section>
{/* 「历史记录」列表(样式参考多智能体会商):跟随侧栏自然向下滚动、不固定在底部。
点击打开历史回看(显示到右侧区域),✏️/双击改名,🗑删除,「挂起中」点击看进度。 */}
@@ -26,7 +26,7 @@ import { ThreadContext } from '@/components/workspace/messages/context';
import { extractContentFromMessage, extractReasoningContentFromMessage, hasToolCalls } from '@/core/messages/utils';
import { getAPIClient } from '@/core/api';
import { useModels } from '@/core/models/hooks';
import { useThreadStream } from '@/core/threads/hooks';
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from '@/core/threads/hooks';
import { uuid } from '@/core/utils/uuid';
import { cn } from '@/lib/utils';
@@ -146,7 +146,10 @@ export function WritingSetupChat({
const ensureThreadExists = useCallback(async () => {
if (threadPrepared) return;
await getAPIClient().threads.create({ threadId, metadata: { thread_type: 'writing_setup' } });
await getAPIClient().threads.create({
threadId,
metadata: { thread_type: 'writing_setup', system: true },
});
setThreadPrepared(true);
}, [threadId, threadPrepared]);
@@ -165,6 +168,17 @@ export function WritingSetupChat({
[thread, ensureThreadExists, sendMessage, threadId, formCtx],
);
const handleClarificationSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] }, buildWritingSampleContext(formCtx));
},
[thread, ensureThreadExists, sendMessage, threadId, formCtx],
);
const publishSampleMessage = useCallback(
(filename: string, text: string) => {
const normalized = text.trim();
@@ -362,7 +376,7 @@ export function WritingSetupChat({
className="size-full"
threadId={threadId}
thread={displayThread}
onClarificationSubmit={handleChatSubmit}
onClarificationSubmit={handleClarificationSubmit}
onSetupWritingApprove={handleApprove}
onSetupWritingReject={handleReject}
onSetupWritingStart={canStartWriting ? handleStart : undefined}
@@ -36,6 +36,7 @@ import { Textarea } from "@/components/ui/textarea";
import { MessageList } from "@/components/workspace/messages";
import { useModels } from "@/core/models/hooks";
import type { AgentThreadState } from "@/core/threads";
import { stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import { cn } from "@/lib/utils";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
@@ -177,9 +178,10 @@ export function CanvasChatPanel() {
const handleClarificationSubmit = useCallback(
async (answer: string) => {
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(answer, { mode });
},
[sendMessage, mode],
[sendMessage, mode, thread, threadId],
);
const handleModelSelect = useCallback(
@@ -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,13 @@ 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());
// Kept outside React state so an HTTP/SSE failure can hand the last visible
// text back to the sandbox without briefly restoring the original document.
const latestDraftRef = useRef("");
const flushDelta = useCallback(() => {
if (frameRef.current !== null) {
@@ -86,27 +98,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 +115,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 +158,31 @@ 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;
latestDraftRef.current += visibleDelta;
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 +196,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 +211,10 @@ export function useDocumentRewrite() {
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
pendingRequirementsDeltaRef.current = "";
pendingThinkingRef.current = "";
draftThinkFilterRef.current = new InlineThinkTagFilter();
requirementsThinkFilterRef.current = new InlineThinkTagFilter();
latestDraftRef.current = "";
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
@@ -177,6 +223,10 @@ export function useDocumentRewrite() {
cancelAnimationFrame(requirementsFrameRef.current);
requirementsFrameRef.current = null;
}
if (thinkingFrameRef.current !== null) {
cancelAnimationFrame(thinkingFrameRef.current);
thinkingFrameRef.current = null;
}
setRun(INITIAL_RUN);
}, []);
@@ -184,7 +234,12 @@ export function useDocumentRewrite() {
async (
request: DocumentRewriteRequest,
options?: {
onCommitted?: (content: string) => void;
onCommitted?: (
content: string,
result: { versionSnapshotFailed: boolean; committedHash: string },
) => void;
/** Preserve the visible text locally when the rewrite request cannot finish. */
onFailure?: (result: { content: string; error: string }) => void;
onEvent?: (event: DocumentRewriteStreamEvent) => void;
stream?: (
signal: AbortSignal,
@@ -202,6 +257,10 @@ export function useDocumentRewrite() {
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
pendingRequirementsDeltaRef.current = "";
pendingThinkingRef.current = "";
draftThinkFilterRef.current = new InlineThinkTagFilter();
requirementsThinkFilterRef.current = new InlineThinkTagFilter();
latestDraftRef.current = "";
setRun({
...INITIAL_RUN,
stage: "snapshot",
@@ -236,28 +295,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":
@@ -273,16 +340,27 @@ export function useDocumentRewrite() {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
setRun((previous) => ({
{
const checkpointContent = draftThinkFilterRef.current.replace(event.content);
latestDraftRef.current = checkpointContent;
setRun((previous) => ({
...previous,
stage: "writing",
draft: event.content,
writtenCharCount: event.charCount,
}));
draft: checkpointContent,
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 +378,27 @@ 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();
latestDraftRef.current = committedContent;
setRun((previous) => ({
...previous,
draft: committedContent,
writtenCharCount: committedContent.replace(/\s/g, "").length,
versionId: event.versionId ?? previous.versionId,
}));
options?.onCommitted?.(committedContent, {
versionSnapshotFailed: event.versionSnapshotFailed === true,
committedHash: event.committedHash,
});
}
break;
case "done":
flushDelta();
flushRequirements();
flushThinking();
receivedTerminalEvent = true;
setRun((previous) => ({
...previous,
@@ -321,6 +409,7 @@ export function useDocumentRewrite() {
case "conflict":
flushDelta();
flushRequirements();
flushThinking();
receivedTerminalEvent = true;
setRun((previous) => ({
...previous,
@@ -331,13 +420,16 @@ 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;
options?.onFailure?.({ content: latestDraftRef.current, error: event.message });
setRun((previous) => ({
...previous,
stage: "error",
@@ -351,20 +443,26 @@ export function useDocumentRewrite() {
if (!controller.signal.aborted && !receivedTerminalEvent) {
flushDelta();
flushRequirements();
flushThinking();
const errorMessage = "改写任务连接已结束,但未收到完成状态;原文件未被修改。";
options?.onFailure?.({ content: latestDraftRef.current, error: errorMessage });
setRun((previous) => ({
...previous,
stage: "error",
error: "改写任务连接已结束,但未收到完成状态;原文件未被修改。",
error: errorMessage,
}));
}
} catch (error) {
if (controller.signal.aborted) return;
flushDelta();
flushRequirements();
flushThinking();
const errorMessage = error instanceof Error ? error.message : "全文改写失败";
options?.onFailure?.({ content: latestDraftRef.current, error: errorMessage });
setRun((previous) => ({
...previous,
stage: "error",
error: error instanceof Error ? error.message : "全文改写失败",
error: errorMessage,
}));
} finally {
if (controllerRef.current === controller) {
@@ -372,7 +470,7 @@ export function useDocumentRewrite() {
}
}
},
[enqueueDelta, enqueueRequirementsDelta, flushDelta, flushRequirements],
[enqueueDelta, enqueueRequirementsDelta, enqueueThinking, flushDelta, flushRequirements, flushThinking],
);
useEffect(
@@ -380,13 +478,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,
@@ -394,10 +394,7 @@ function AIWritingLayout({
);
}
/**
* 写作页主体(侧栏 + 内容区)。放在 Provider 内部,以便读 useAIWriting 状态:
* 进入写作(status !== idle 或查看历史)后自动收起左侧研究模板栏,让写作区更宽。
*/
/** 写作页主体(左侧工作空间 + 内容区)。 */
function AIWritingBody({
hideAdvancedFields,
initialNotebookId,
@@ -408,8 +405,6 @@ function AIWritingBody({
hideAdvancedFields: boolean;
compactChrome?: boolean;
} & NotebookProps) {
const { state } = useAIWriting();
const { isHistoryMode } = useAIWritingHistory();
const location = useLocation();
const navigate = useNavigate();
const routeBase = useAIWritingRouteBase();
@@ -437,10 +432,9 @@ function AIWritingBody({
const agentsActive = !externalNotebookMode && sidebarTab === 'agents';
// 研究模板侧栏在「写作配置(idle)」与「历史回看」两态都展示(点历史记录后保留侧栏、
// 历史内容显示到右侧区域);写作进行中收起;嵌入态(studioToken)始终不展示。
const showSidebar =
!externalNotebookMode && (state.status === 'idle' || isHistoryMode || agentsActive);
// 常规 AI 写作页始终保留左侧工作空间,方便在写作过程中继续切换课题、查看历史或使用智能体。
// 只有外部笔记本嵌入态才隐藏,避免占用嵌入页面的内容空间。
const showSidebar = !externalNotebookMode;
const compactAppearanceActions = compactChrome ? (
<AIWritingCompactAppearanceActions />
) : null;
+5 -4
View File
@@ -31,7 +31,7 @@ import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-dep
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
import { stopThreadRun, useThreadStream } from "@/core/threads/hooks";
import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { OptType, writeYLog } from "@/core/log/y-log";
import {
@@ -542,11 +542,12 @@ export default function AgentChatPage({
}, [taskAutoStart, taskStartText, isNewThread, handleSubmit]);
const handleClarificationSubmit = useCallback(
(answer: string) => {
async (answer: string) => {
setHasStartedConversation(true);
void sendMessage(threadId, { text: answer, files: [] }, { agent_id });
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] }, { agent_id });
},
[sendMessage, threadId, agent_id],
[sendMessage, thread, threadId, agent_id],
);
const handleStop = useCallback(async () => {
+7 -6
View File
@@ -58,7 +58,7 @@ import {
useKnowledgeRagEnabled,
useThreadSettings,
} from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag";
@@ -525,11 +525,12 @@ export default function ChatPage() {
);
const handleClarificationSubmit = useCallback(
(answer: string) => {
async (answer: string) => {
setHasStartedConversation(true);
void sendMessage(threadId, { text: answer, files: [] });
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] });
},
[sendMessage, threadId],
[sendMessage, thread, threadId],
);
useEffect(() => {
@@ -773,9 +774,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)",
)}
>
+5 -4
View File
@@ -9,7 +9,7 @@ import { setStoredAuth } from "@/core/auth";
import { resolveEmbedChatParams } from "@/core/embed/params";
import { registerEmbedSession } from "@/core/embed/status-api";
import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
import { ChatBox } from "@/components/workspace/chats";
import {
@@ -110,10 +110,11 @@ export default function EmbedChatPage() {
}, [authReady, threadId, registerSessionForThread]);
const handleClarificationSubmit = useCallback(
(answer: string) => {
void sendMessage(threadId, { text: answer, files: [] });
async (answer: string) => {
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] });
},
[sendMessage, threadId],
[sendMessage, thread, threadId],
);
useEffect(() => {
@@ -0,0 +1,734 @@
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
import {
AlertCircle,
BookOpenCheck,
CheckCircle2,
ChevronRight,
ClipboardList,
Database,
Download,
ExternalLink,
FileSearch,
FileText,
Loader2,
Play,
Plus,
Search,
Send,
ShieldCheck,
Square,
Sparkles,
WandSparkles,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import { DownloadWordButton } from "@/components/workspace/messages/download-word-button";
import { DocumentRewriteDiffDialog } from "@/components/workspace/messages/document-rewrite-diff-dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { openKnowledgeSource } from "@/core/llmwiki/source-links";
import MarkdownRenderer from "@/strategy-components/MarkdownRenderer";
import {
useLlmWikiKnowledgeBases,
useLlmWikiRuntime,
} from "@/strategy-components/api/llmwiki";
import {
type EnterpriseResearchEvidence,
type EnterpriseResearchTask,
type EnterpriseResearchTaskStatus,
type EnterpriseResearchReportEvent,
type EnterpriseResearchReportJob,
collectEnterpriseResearchTask,
cancelEnterpriseResearchReportJob,
createEnterpriseResearchAiPlan,
createEnterpriseResearchTask,
createEnterpriseResearchPlanDraft,
createEnterpriseResearchReportJob,
listEnterpriseResearchTasks,
listEnterpriseResearchReportJobs,
restoreEnterpriseResearchReportVersion,
streamEnterpriseResearchReportJob,
updateEnterpriseResearchPlan,
} from "@/strategy-components/api/enterprise-research";
type TemplateId = "topic" | "event" | "policy";
type TaskStatus = EnterpriseResearchTaskStatus;
type ResearchTemplate = {
id: TemplateId;
name: string;
description: string;
stages: string[];
buildQueries: (subject: string, focus: string) => string[];
};
const TEMPLATES: ResearchTemplate[] = [
{
id: "topic",
name: "通用议题研究",
description: "先梳理事实与背景,再归纳关键分歧、影响与待验证问题。",
stages: ["界定研究问题", "检索内部证据", "归纳观点与分歧", "形成研究任务包"],
buildQueries: (subject, focus) => [subject, `${subject} 背景 关键事实`, focus ? `${subject} ${focus}` : `${subject} 影响 风险 机会`],
},
{
id: "event",
name: "事件影响评估",
description: "适用于突发事件、舆情事件或专项行动的影响研判。",
stages: ["还原事件时间线", "识别相关主体", "评估影响传导", "输出风险与跟踪项"],
buildQueries: (subject, focus) => [
`${subject} 时间线 经过`,
`${subject} 相关主体 公开表态`,
focus ? `${subject} ${focus} 影响 风险` : `${subject} 影响 风险 应对`,
],
},
{
id: "policy",
name: "政策与行业研究",
description: "适用于政策、产业、市场等需比较变化与传导机制的主题。",
stages: ["明确政策或产业边界", "对比历史与现状", "拆解传导机制", "沉淀研判与建议"],
buildQueries: (subject, focus) => [
`${subject} 政策 现状`,
`${subject} 发展趋势 数据`,
focus ? `${subject} ${focus} 行业影响` : `${subject} 影响 挑战 机遇`,
],
},
];
const STATUS_META: Record<TaskStatus, { label: string; className: string }> = {
draft: { label: "草稿", className: "bg-muted text-muted-foreground" },
collecting: { label: "检索中", className: "bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-200" },
ready: { label: "证据已就绪", className: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-200" },
partial: { label: "部分完成", className: "bg-orange-100 text-orange-800 dark:bg-orange-950/60 dark:text-orange-200" },
failed: { label: "检索失败", className: "bg-red-100 text-red-800 dark:bg-red-950/60 dark:text-red-200" },
};
function formatTime(value?: string | null) {
if (!value) return "刚刚";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "刚刚";
return new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(parsed);
}
function getTemplate(id: TemplateId) {
return TEMPLATES.find((item) => item.id === id) ?? TEMPLATES[0];
}
function StatusBadge({ status }: { status: TaskStatus }) {
const meta = STATUS_META[status];
return <Badge className={cn("border-0", meta.className)}>{meta.label}</Badge>;
}
export default function EnterpriseResearchPage() {
const runtime = useLlmWikiRuntime();
const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora");
const [tasks, setTasks] = useState<EnterpriseResearchTask[]>([]);
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [subject, setSubject] = useState("");
const [focus, setFocus] = useState("");
const [templateId, setTemplateId] = useState<TemplateId>("topic");
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
const [selectedSourceId, setSelectedSourceId] = useState<string | null>(null);
const [reportJob, setReportJob] = useState<EnterpriseResearchReportJob | null>(null);
const [reportJobs, setReportJobs] = useState<EnterpriseResearchReportJob[]>([]);
const [reportDraft, setReportDraft] = useState("");
const [jobDraft, setJobDraft] = useState("");
const [reasoning, setReasoning] = useState("");
const [isStartingReport, setIsStartingReport] = useState(false);
const reportActionRef = useRef<EnterpriseResearchReportJob["action"]>("initial");
useEffect(() => {
let alive = true;
void listEnterpriseResearchTasks()
.then((stored) => {
if (!alive) return;
setTasks(stored);
setActiveTaskId(stored[0]?.id ?? null);
})
.catch(() => {
if (alive) toast.error("研究任务读取失败,请稍后重试");
});
return () => { alive = false; };
}, []);
const activeTask = useMemo(
() => tasks.find((task) => task.id === activeTaskId) ?? null,
[activeTaskId, tasks],
);
const template = getTemplate(templateId);
const selectedSource = activeTask?.sources.find((source) => source.chunk_id === selectedSourceId) ?? activeTask?.sources[0] ?? null;
const isWeKnoraReady = runtime.data?.provider === "weknora";
const consumeReportStream = (taskId: string, jobId: string, signal?: AbortSignal) => streamEnterpriseResearchReportJob(taskId, jobId, {
signal,
onEvent: (event: EnterpriseResearchReportEvent) => {
if (event.type === "snapshot") {
setReportJob(event.job);
reportActionRef.current = event.job.action;
if (event.job.action === "followup") setJobDraft(event.job.report_markdown || "");
else setReportDraft(event.job.report_markdown || "");
return;
}
if (event.type === "report_delta") {
if (reportActionRef.current === "followup") setJobDraft((current) => current + event.delta);
else setReportDraft((current) => current + event.delta);
return;
}
if (event.type === "reasoning_delta") {
setReasoning((current) => (current + event.delta).slice(-6_000));
return;
}
if (event.type === "phase") {
setReportJob((current) => current ? { ...current, status: event.status, phase: event.phase } : current);
return;
}
if (event.type === "completed" || event.type === "failed" || event.type === "cancelled") {
if (!event.job) return;
setReportJob(event.job);
setReportJobs((current) => [event.job!, ...current.filter((item) => item.id !== event.job!.id)]);
if (event.job.action === "followup") {
setJobDraft(event.job.report_markdown || "");
} else if (event.type === "completed") {
setReportDraft(event.job.report_markdown || "");
setTasks((current) => current.map((task) => task.id === taskId ? {
...task,
report_markdown: event.job?.report_markdown ?? task.report_markdown,
report_summary: event.job?.report_summary ?? task.report_summary,
report_model_name: event.job?.model_name ?? task.report_model_name,
active_report_job_id: null,
} : task));
} else {
// A failed or cancelled rewrite may contain a persisted temporary
// draft. Never expose it as the committed report in local state.
setReportDraft(event.job.original_report || tasks.find((task) => task.id === taskId)?.report_markdown || "");
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
}
if (event.type === "completed") toast.success("企业研究报告已完成");
if (event.type === "failed") toast.error(event.job.error_message || "报告生成失败");
}
},
}).catch((error: unknown) => {
if ((error as { name?: string } | null)?.name !== "AbortError") {
toast.error(error instanceof Error ? error.message : "报告流连接中断");
}
});
useEffect(() => {
if (!activeTaskId) {
setReportJob(null);
setReportJobs([]);
setReportDraft("");
setJobDraft("");
setReasoning("");
return;
}
const controller = new AbortController();
let alive = true;
setReportJob(null);
setReportJobs([]);
setReportDraft("");
setJobDraft("");
setReasoning("");
setIsStartingReport(false);
void listEnterpriseResearchReportJobs(activeTaskId)
.then((jobs) => {
if (!alive) return;
const latest = jobs[0] ?? null;
setReportJobs(jobs);
setReportJob(latest);
const latestReport = jobs.find((job) => job.status === "completed" && job.action !== "followup");
if (latestReport?.report_markdown) setReportDraft(latestReport.report_markdown);
const latestFollowup = jobs.find((job) => job.status === "completed" && job.action === "followup");
if (latestFollowup?.report_markdown) setJobDraft(latestFollowup.report_markdown);
if (latest && (latest.status === "queued" || latest.status === "running")) {
reportActionRef.current = latest.action;
void consumeReportStream(activeTaskId, latest.id, controller.signal);
}
})
.catch(() => {
if (alive) toast.error("报告任务读取失败,请稍后重试");
});
return () => {
alive = false;
controller.abort();
};
// The stream is intentionally scoped to the selected task only. It must not
// restart whenever token deltas update the task list.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTaskId]);
const resetComposer = () => {
setActiveTaskId(null);
setSubject("");
setFocus("");
setTemplateId("topic");
setSelectedKnowledgeBaseIds([]);
setSelectedSourceId(null);
setReportJob(null);
setReportJobs([]);
setReportDraft("");
setJobDraft("");
setReasoning("");
};
const toggleKnowledgeBase = (id: string, checked: boolean) => {
setSelectedKnowledgeBaseIds((current) => checked ? [...new Set([...current, id])] : current.filter((item) => item !== id));
};
const startResearch = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const normalizedSubject = subject.trim();
if (!normalizedSubject) {
toast.error("请先填写研究主题");
return;
}
if (!isWeKnoraReady) {
toast.error("知识服务尚未启用,请先在系统中配置 WeKnora");
return;
}
const selectedTemplate = getTemplate(templateId);
const queries = [...new Set(selectedTemplate.buildQueries(normalizedSubject, focus.trim()).map((item) => item.trim()).filter(Boolean))];
try {
const created = await createEnterpriseResearchTask({
subject: normalizedSubject,
focus: focus.trim(),
template_id: templateId,
knowledge_base_ids: selectedKnowledgeBaseIds,
queries,
});
const collecting: EnterpriseResearchTask = { ...created, status: "collecting" };
setTasks((current) => [collecting, ...current.filter((item) => item.id !== collecting.id)].slice(0, 20));
setActiveTaskId(created.id);
setSelectedSourceId(null);
const completed = await collectEnterpriseResearchTask(created.id);
setTasks((current) => current.map((item) => item.id === completed.id ? completed : item));
if (completed.sources[0]) setSelectedSourceId(completed.sources[0].chunk_id);
if (completed.status === "failed") toast.error("未检索到可用证据");
else toast.success(`已完成证据收集:${completed.sources.length} 条资料`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建研究任务失败,请稍后重试");
}
};
const createPlanDraft = async (taskId: string) => {
const updated = await createEnterpriseResearchPlanDraft(taskId);
setTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
return updated;
};
const createAiPlan = async (taskId: string, input: { model_name?: string | null; instruction?: string }) => {
const updated = await createEnterpriseResearchAiPlan(taskId, input);
setTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
return updated;
};
const savePlan = async (taskId: string, input: { plan_markdown: string; selected_source_ids: string[] }) => {
const updated = await updateEnterpriseResearchPlan(taskId, input);
setTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
return updated;
};
const startReport = async (
taskId: string,
input: Parameters<typeof createEnterpriseResearchReportJob>[1] = {},
) => {
if (isStartingReport) return;
setIsStartingReport(true);
try {
const job = await createEnterpriseResearchReportJob(taskId, input);
reportActionRef.current = job.action;
setReportJob(job);
setReportJobs((current) => [job, ...current.filter((item) => item.id !== job.id)]);
if (job.action === "followup") setJobDraft(job.report_markdown || "");
else setReportDraft(job.report_markdown || "");
setReasoning("");
void consumeReportStream(taskId, job.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建报告写作任务失败");
} finally {
setIsStartingReport(false);
}
};
const cancelReport = async (taskId: string, jobId: string) => {
const cancelled = await cancelEnterpriseResearchReportJob(taskId, jobId);
setReportJob(cancelled);
setReportJobs((current) => [cancelled, ...current.filter((item) => item.id !== cancelled.id)]);
if (cancelled.action === "followup") setJobDraft("");
else setReportDraft(tasks.find((task) => task.id === taskId)?.report_markdown || "");
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
toast.success("已取消报告任务,已保存的正式报告未受影响");
};
const restoreReport = async (taskId: string, jobId: string) => {
const updated = await restoreEnterpriseResearchReportVersion(taskId, jobId);
const jobs = await listEnterpriseResearchReportJobs(taskId);
setTasks((current) => current.map((task) => task.id === updated.id ? updated : task));
setReportJobs(jobs);
setReportJob(jobs[0] ?? null);
setReportDraft(updated.report_markdown || "");
toast.success("已恢复改写前版本");
};
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<header className="flex shrink-0 items-center justify-between border-b bg-card px-5 py-3.5 sm:px-7">
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<FileSearch className="size-5" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="truncate text-base font-semibold sm:text-lg">企业深度研究</h1>
<Badge variant="outline" className="text-muted-foreground">独立工作台</Badge>
</div>
<p className="hidden text-xs text-muted-foreground sm:block">面向内部知识库的研究任务、证据编排与可追溯交付</p>
</div>
</div>
<Button size="sm" onClick={resetComposer}>
<Plus className="mr-1.5 size-4" />
新建任务
</Button>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden xl:grid-cols-[272px_minmax(440px,0.9fr)_minmax(420px,1.1fr)]">
<aside className="min-h-0 border-b bg-muted/20 xl:border-r xl:border-b-0">
<div className="flex items-center justify-between px-5 py-4">
<div>
<p className="text-sm font-semibold">研究任务</p>
<p className="mt-0.5 text-xs text-muted-foreground">服务端保留最近 20 条</p>
</div>
<Badge variant="secondary">{tasks.length}</Badge>
</div>
<ScrollArea className="h-[220px] xl:h-[calc(100%-76px)]">
<div className="space-y-1 px-3 pb-4">
{tasks.length ? tasks.map((task) => (
<button
key={task.id}
type="button"
onClick={() => { setActiveTaskId(task.id); setSelectedSourceId(task.sources[0]?.chunk_id ?? null); }}
className={cn(
"w-full rounded-lg border px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
activeTaskId === task.id ? "border-primary/35 bg-primary/8" : "border-transparent hover:bg-accent",
)}
>
<div className="flex items-start justify-between gap-2">
<span className="line-clamp-2 text-sm font-medium leading-5">{task.subject}</span>
<StatusBadge status={task.status} />
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
<span>{task.sources.length} 条资料</span><span>·</span><span>{formatTime(task.updated_at || task.created_at)}</span>
</div>
</button>
)) : (
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">尚无任务。创建第一个研究任务开始。</div>
)}
</div>
</ScrollArea>
</aside>
<main className="min-h-0 overflow-y-auto border-b p-5 xl:border-r xl:border-b-0 xl:p-6">
{!activeTaskId ? (
<form className="mx-auto max-w-2xl space-y-5" onSubmit={startResearch}>
<section>
<Badge variant="secondary" className="mb-3"><Sparkles className="size-3" />阶段一:证据编排</Badge>
<h2 className="text-xl font-semibold tracking-tight">建立一个可追溯的研究任务</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 WeKnora 知识库,不会修改任何知识库或既有报告。</p>
</section>
<Card>
<CardHeader className="gap-1">
<CardTitle className="text-base">研究对象与范围</CardTitle>
<CardDescription>用一句话明确需要研究的对象;补充范围可帮助生成更聚焦的检索方向。</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<label className="block space-y-2 text-sm font-medium" htmlFor="enterprise-research-subject">
研究主题 <span className="text-destructive">*</span>
<Input id="enterprise-research-subject" value={subject} onChange={(event) => setSubject(event.target.value)} placeholder="例如:新能源汽车产业链的供应风险" maxLength={180} />
</label>
<label className="block space-y-2 text-sm font-medium" htmlFor="enterprise-research-focus">
重点范围 <span className="font-normal text-muted-foreground">(可选)</span>
<Textarea id="enterprise-research-focus" value={focus} onChange={(event) => setFocus(event.target.value)} placeholder="例如:聚焦 2025 年以来的海外供应、关键原材料与对国内产能的影响" className="min-h-22 resize-y" maxLength={800} />
</label>
</CardContent>
</Card>
<Card>
<CardHeader className="gap-1">
<CardTitle className="text-base">选择研究模板</CardTitle>
<CardDescription>模板只决定问题拆解和证据编排方式,不会替换你的原始问题。</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-3">
{TEMPLATES.map((item) => (
<button
type="button"
key={item.id}
onClick={() => setTemplateId(item.id)}
className={cn(
"rounded-lg border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
templateId === item.id ? "border-primary bg-primary/5" : "hover:bg-accent",
)}
>
<span className="block text-sm font-semibold">{item.name}</span>
<span className="mt-1.5 block text-xs leading-5 text-muted-foreground">{item.description}</span>
</button>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="gap-1">
<CardTitle className="flex items-center gap-2 text-base"><Database className="size-4 text-primary" />证据范围</CardTitle>
<CardDescription>{isWeKnoraReady ? "只显示当前账号已授权的知识库。未选择时会检索全部可见知识库。" : "知识服务未启用,无法开始内部证据检索。"}</CardDescription>
</CardHeader>
<CardContent>
{knowledgeBases.isLoading ? <div className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />正在读取知识库…</div> : knowledgeBases.isError ? <div className="flex items-center gap-2 text-sm text-destructive"><AlertCircle className="size-4" />知识库读取失败</div> : (
<div className="space-y-2">
{knowledgeBases.data?.length ? knowledgeBases.data.map((base) => {
const checked = selectedKnowledgeBaseIds.includes(base.id);
return <label key={base.id} className="flex cursor-pointer items-start gap-3 rounded-md border p-3 hover:bg-accent/60">
<Checkbox checked={checked} onCheckedChange={(next) => toggleKnowledgeBase(base.id, next)} aria-label={`选择知识库 ${base.name}`} />
<span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium">{base.name}</span><span className="mt-0.5 block line-clamp-1 text-xs text-muted-foreground">{base.description || `${base.knowledge_count} 份资料`}</span></span>
<span className="text-xs text-muted-foreground">{base.chunk_count} 段</span>
</label>;
}) : <p className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">当前账号没有可用知识库,可先前往“知识库管理”添加或发布资料。</p>}
</div>
)}
</CardContent>
</Card>
<div className="flex flex-col gap-3 rounded-lg border bg-muted/30 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm"><span className="font-medium">即将检索 {template.stages.length - 1} 个研究方向</span><p className="mt-1 text-xs text-muted-foreground">会生成主题、背景与重点范围三组可见检索词。</p></div>
<Button type="submit" disabled={!isWeKnoraReady || knowledgeBases.isLoading}><Play className="mr-1.5 size-4" />开始证据编排</Button>
</div>
</form>
) : activeTask ? (
<TaskOverview task={activeTask} reportJob={reportJob} isStartingReport={isStartingReport} onNew={resetComposer} onCreatePlanDraft={createPlanDraft} onCreateAiPlan={createAiPlan} onSavePlan={savePlan} />
) : null}
</main>
<section className="min-h-0 overflow-y-auto bg-muted/10 p-5 xl:p-6">
<EvidencePane task={activeTask} source={selectedSource} reportJob={reportJob} reportJobs={reportJobs} reportDraft={reportDraft} jobDraft={jobDraft} reasoning={reasoning} isStartingReport={isStartingReport} onStartReport={startReport} onCancelReport={cancelReport} onRestoreReport={restoreReport} onSourceSelect={setSelectedSourceId} />
</section>
</div>
</div>
);
}
function TaskOverview({
task,
reportJob,
isStartingReport,
onNew,
onCreatePlanDraft,
onCreateAiPlan,
onSavePlan,
}: {
task: EnterpriseResearchTask;
reportJob: EnterpriseResearchReportJob | null;
isStartingReport: boolean;
onNew: () => void;
onCreatePlanDraft: (taskId: string) => Promise<EnterpriseResearchTask>;
onCreateAiPlan: (taskId: string, input: { model_name?: string | null; instruction?: string }) => Promise<EnterpriseResearchTask>;
onSavePlan: (taskId: string, input: { plan_markdown: string; selected_source_ids: string[] }) => Promise<EnterpriseResearchTask>;
}) {
const template = getTemplate(task.template_id as TemplateId);
const [planMarkdown, setPlanMarkdown] = useState(task.plan_markdown ?? "");
const [selectedSourceIds, setSelectedSourceIds] = useState<string[]>(task.selected_source_ids ?? []);
const [isCreatingPlan, setIsCreatingPlan] = useState(false);
const [isSavingPlan, setIsSavingPlan] = useState(false);
const [isCreatingAiPlan, setIsCreatingAiPlan] = useState(false);
const [planModel, setPlanModel] = useState<string | null>(null);
const [planInstruction, setPlanInstruction] = useState("");
const canPlan = task.status === "ready" || task.status === "partial";
const isWriting = isStartingReport || reportJob?.status === "queued" || reportJob?.status === "running";
useEffect(() => {
setPlanMarkdown(task.plan_markdown ?? "");
setSelectedSourceIds(task.selected_source_ids ?? []);
}, [task.id, task.plan_markdown, task.selected_source_ids]);
const toggleSource = (sourceId: string, checked: boolean) => {
setSelectedSourceIds((current) => checked ? [...new Set([...current, sourceId])] : current.filter((item) => item !== sourceId));
};
const handleCreatePlan = async () => {
setIsCreatingPlan(true);
try {
const updated = await onCreatePlanDraft(task.id);
toast.success(`已根据 ${updated.selected_source_ids.length} 条资料生成可编辑研究计划`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成研究计划失败");
} finally {
setIsCreatingPlan(false);
}
};
const handleSavePlan = async () => {
setIsSavingPlan(true);
try {
const updated = await onSavePlan(task.id, { plan_markdown: planMarkdown, selected_source_ids: selectedSourceIds });
toast.success(`研究计划已保存,已选 ${updated.selected_source_ids.length} 条资料`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存研究计划失败");
} finally {
setIsSavingPlan(false);
}
};
const handleCreateAiPlan = async () => {
setIsCreatingAiPlan(true);
try {
const updated = await onCreateAiPlan(task.id, { model_name: planModel, instruction: planInstruction.trim() });
setPlanMarkdown(updated.plan_markdown ?? "");
setSelectedSourceIds(updated.selected_source_ids ?? []);
toast.success("AI 研究计划已生成,可继续人工修改后保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "AI 规划失败");
} finally {
setIsCreatingAiPlan(false);
}
};
return <div className="mx-auto max-w-2xl space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div><Badge variant="secondary" className="mb-3"><ClipboardList className="size-3" />研究任务</Badge><h2 className="text-xl font-semibold tracking-tight">{task.subject}</h2>{task.focus ? <p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">{task.focus}</p> : null}</div>
<div className="flex items-center gap-2"><StatusBadge status={task.status} /><Button size="sm" variant="outline" onClick={onNew}><Plus className="mr-1 size-4" />新建</Button></div>
</div>
<Card>
<CardHeader className="gap-1"><CardTitle className="text-base">研究路径</CardTitle><CardDescription>{template.name} · 每一步保留用户可核验的检索词和证据来源。</CardDescription></CardHeader>
<CardContent><ol className="space-y-4">{template.stages.map((stage, index) => {
const complete = task.status !== "collecting" && (index < 2 || task.sources.length > 0);
const current = task.status === "collecting" && index === 1;
return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用 WeKnora 检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>;
})}</ol></CardContent>
</Card>
<Card>
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 WeKnora 知识库。</CardDescription></CardHeader>
<CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{task.warning}</div> : null}</CardContent>
</Card>
{canPlan ? <Card>
<CardHeader className="gap-1"><CardTitle className="text-base">研究计划与选用资料</CardTitle><CardDescription>计划和资料选择会保存到当前任务;后续报告只能使用这里确认过的证据。</CardDescription></CardHeader>
<CardContent className="space-y-4">
{!task.plan_markdown ? <div className="rounded-lg border border-dashed bg-muted/30 p-4"><p className="text-sm font-medium">证据已收集,可生成一份可编辑的研究计划</p><p className="mt-1 text-xs leading-5 text-muted-foreground">初版会按当前研究模板组织目标、核心问题、章节和写作约束;生成报告前仍可修改。</p><Button className="mt-3" size="sm" onClick={handleCreatePlan} disabled={isCreatingPlan}><Sparkles className="mr-1.5 size-4" />{isCreatingPlan ? "正在生成…" : "生成研究计划"}</Button></div> : <>
<div className="space-y-2"><div className="flex items-center justify-between gap-3"><label htmlFor={`enterprise-plan-${task.id}`} className="text-sm font-medium">可编辑研究计划</label><span className="text-xs text-muted-foreground">{planMarkdown.length} / 16,000</span></div><Textarea id={`enterprise-plan-${task.id}`} value={planMarkdown} onChange={(event) => setPlanMarkdown(event.target.value)} className="min-h-80 resize-y font-mono text-xs leading-6" maxLength={16_000} /></div>
<div className="space-y-2"><div className="flex items-center justify-between gap-3"><p className="text-sm font-medium">选用资料</p><span className="text-xs text-muted-foreground">已选 {selectedSourceIds.length} / {task.sources.length}</span></div><ScrollArea className="h-44 rounded-md border"><div className="space-y-1 p-2">{task.sources.map((source, index) => { const checked = selectedSourceIds.includes(source.chunk_id); return <label key={source.chunk_id || `${source.knowledge_id}-${index}`} className="flex cursor-pointer items-start gap-2 rounded-md px-2 py-2 hover:bg-muted"><Checkbox checked={checked} onCheckedChange={(next) => toggleSource(source.chunk_id, next === true)} aria-label={`选用资料 ${source.title || source.filename || index + 1}`} /><span className="min-w-0 text-xs leading-5"><span className="block truncate font-medium">{source.title || source.filename || "未命名资料"}</span><span className="block truncate text-muted-foreground">{source.knowledge_base_name || "知识库"}</span></span></label>; })}</div></ScrollArea></div>
<div className="rounded-lg border bg-muted/20 p-3"><div className="flex flex-wrap items-center gap-2"><ModelNameSelect value={planModel} onChange={setPlanModel} disabled={isCreatingAiPlan || isWriting} triggerClassName="w-40" /><Input value={planInstruction} onChange={(event) => setPlanInstruction(event.target.value)} placeholder="AI 规划补充要求(可选)" className="h-8 min-w-48 flex-1 text-xs" maxLength={2_000} /><Button size="sm" variant="outline" onClick={handleCreateAiPlan} disabled={isCreatingAiPlan || isWriting}><WandSparkles className="mr-1.5 size-4" />{isCreatingAiPlan ? "正在规划…" : "AI 优化计划"}</Button></div></div>
<div className="flex justify-end"><Button size="sm" onClick={handleSavePlan} disabled={isSavingPlan || !planMarkdown.trim() || isWriting}>{isSavingPlan ? "正在保存…" : "保存研究计划"}</Button></div>
{reportJob ? <div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground"><span className="font-medium text-foreground">报告任务:</span>{reportJob.status === "completed" ? "已完成" : reportJob.status === "failed" ? `失败:${reportJob.error_message || "请稍后重试"}` : `正在${reportJob.phase === "understanding" ? "理解研究计划" : reportJob.phase === "reviewing" ? "校验报告" : "流式写入 Markdown"}`};仅使用本任务已保存的计划与已选资料快照。</div> : null}
</>}
</CardContent>
</Card> : null}
</div>;
}
function EvidencePane({
task, source, reportJob, reportJobs, reportDraft, jobDraft, reasoning, isStartingReport,
onStartReport, onCancelReport, onRestoreReport, onSourceSelect,
}: {
task: EnterpriseResearchTask | null;
source: EnterpriseResearchEvidence | null;
reportJob: EnterpriseResearchReportJob | null;
reportJobs: EnterpriseResearchReportJob[];
reportDraft: string;
jobDraft: string;
reasoning: string;
isStartingReport: boolean;
onStartReport: (taskId: string, input?: Parameters<typeof createEnterpriseResearchReportJob>[1]) => Promise<void>;
onCancelReport: (taskId: string, jobId: string) => Promise<void>;
onRestoreReport: (taskId: string, jobId: string) => Promise<void>;
onSourceSelect: (id: string | null) => void;
}) {
const [modelName, setModelName] = useState<string | null>(null);
const [style, setStyle] = useState<"formal" | "analytical" | "concise">("formal");
const [targetLength, setTargetLength] = useState("5000");
const [rewriteInstruction, setRewriteInstruction] = useState("优化结构、逻辑与表达,减少重复内容,并保留事实与引用");
const [followup, setFollowup] = useState("");
const [diffJob, setDiffJob] = useState<EnterpriseResearchReportJob | null>(null);
if (!task) return <div className="flex h-full min-h-72 flex-col items-center justify-center rounded-xl border border-dashed p-8 text-center"><div className="flex size-11 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"><BookOpenCheck className="size-5" /></div><h2 className="mt-4 text-sm font-semibold">等待研究任务</h2><p className="mt-1 max-w-xs text-sm leading-6 text-muted-foreground">提交主题后,这里会展示证据、研究报告和可追溯引用。</p></div>;
const isCollecting = task.status === "collecting";
const isWriting = isStartingReport || reportJob?.status === "queued" || reportJob?.status === "running";
const currentReport = isWriting && reportJob?.action !== "followup"
? reportDraft
: reportDraft || task.report_markdown || "";
const latestRewrite = reportJobs.find((job) => job.action === "rewrite" && job.status === "completed" && job.original_report);
const latestCommittedJob = reportJobs.find((job) => job.action !== "followup" && job.status === "completed");
const completedFollowups = reportJobs.filter((job) => job.action === "followup" && job.status === "completed");
const selectedSources = task.selected_source_ids.map((id) => task.sources.find((item) => item.chunk_id === id)).filter(Boolean) as EnterpriseResearchEvidence[];
const citationIndex = new Map(selectedSources.map((item, index) => [item.chunk_id, index + 1]));
const renderedReport = currentReport.replace(/\[资料:([^\]]+)\]/g, (match, id: string) => {
const index = citationIndex.get(id.trim());
return index ? `[[${index}]]` : match;
});
const parsedTarget = Number(targetLength);
const downloadMarkdown = () => {
const blob = new Blob([currentReport], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${task.subject.replace(/[\\/:*?"<>|]/g, "-") || "企业研究报告"}.md`;
anchor.click();
URL.revokeObjectURL(url);
};
const start = (action: "initial" | "rewrite" | "followup", instruction = "") => onStartReport(task.id, {
model_name: modelName,
action,
instruction,
style,
target_length: Number.isFinite(parsedTarget) && parsedTarget >= 800 ? parsedTarget : null,
});
const phaseOrder = ["understanding", "writing", "reviewing", "completed"];
const phaseIndex = reportJob ? phaseOrder.indexOf(reportJob.phase) : -1;
return <div className="space-y-4">
{task.plan_markdown && !currentReport && !isWriting ? <Card>
<CardHeader className="gap-1"><CardTitle className="text-sm">报告写作配置</CardTitle><CardDescription>写作只使用已保存计划和 {task.selected_source_ids.length} 条已选资料。</CardDescription></CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2 sm:grid-cols-3"><ModelNameSelect value={modelName} onChange={setModelName} triggerClassName="w-full" /><Select value={style} onValueChange={(value) => setStyle(value as typeof style)}><SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="formal">正式报告</SelectItem><SelectItem value="analytical">分析研判</SelectItem><SelectItem value="concise">精炼摘要</SelectItem></SelectContent></Select><Input type="number" min={800} max={30000} value={targetLength} onChange={(event) => setTargetLength(event.target.value)} className="h-8 text-xs" aria-label="报告目标字数" /></div>
<Button className="w-full" onClick={() => void start("initial")}><Play className="mr-1.5 size-4" />开始流式撰写报告</Button>
</CardContent>
</Card> : null}
{isWriting && reportJob ? <Card>
<CardHeader className="gap-1"><div className="flex items-center justify-between gap-3"><CardTitle className="flex items-center gap-2 text-sm"><Loader2 className="size-4 animate-spin text-primary" />{reportJob.action === "followup" ? "正在回答报告追问" : reportJob.action === "rewrite" ? "正在全文改写" : "正在撰写研究报告"}</CardTitle><Button size="sm" variant="outline" onClick={() => void onCancelReport(task.id, reportJob.id)}><Square className="mr-1.5 size-3.5" />停止</Button></div><CardDescription>真实报告只在任务成功完成后提交;取消或失败不会覆盖已保存版本。</CardDescription></CardHeader>
<CardContent><ol className="grid gap-2 sm:grid-cols-4">{["理解要求", "流式写入", "校验报告", "保存版本"].map((label, index) => <li key={label} className={cn("rounded-md border px-3 py-2 text-xs", index < phaseIndex ? "border-emerald-200 bg-emerald-50 text-emerald-800" : index === phaseIndex ? "border-primary/40 bg-primary/5 text-primary" : "text-muted-foreground")}><span className="font-medium">{index + 1}. {label}</span></li>)}</ol>{reasoning ? <details className="mt-3 rounded-md border px-3 py-2 text-xs text-muted-foreground"><summary className="cursor-pointer">模型思考过程(不会写入报告)</summary><pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap font-sans leading-5">{reasoning}</pre></details> : null}</CardContent>
</Card> : null}
{currentReport ? <Card className="overflow-hidden">
<CardHeader className="border-b py-4"><div className="flex flex-wrap items-start justify-between gap-3"><div><CardTitle className="flex items-center gap-2 text-sm"><FileText className="size-4 text-primary" />{task.subject}</CardTitle><CardDescription className="mt-1">{latestCommittedJob?.report_summary || task.report_summary || `${currentReport.length} 字`}</CardDescription></div><div className="flex items-center gap-1"><Button size="icon-sm" variant="ghost" onClick={downloadMarkdown} aria-label="下载 Markdown" title="下载 Markdown"><Download className="size-4" /></Button><DownloadWordButton markdown={currentReport} fileName={task.subject} title={task.subject} aria-label="下载 Word" />{latestRewrite ? <Button size="sm" variant="outline" onClick={() => setDiffJob(latestRewrite)}>查看改写对比</Button> : null}</div></div></CardHeader>
<CardContent className="p-0"><ScrollArea className="h-[430px]"><article className="p-5"><MarkdownRenderer content={renderedReport} onCitationClick={(index) => onSourceSelect(selectedSources[index - 1]?.chunk_id ?? null)} /></article></ScrollArea></CardContent>
</Card> : null}
{currentReport && !isWriting ? <Card>
<CardHeader className="gap-1"><CardTitle className="text-sm">继续完善报告</CardTitle><CardDescription>全文改写会保存原版本;追问只生成回答,不会覆盖报告。</CardDescription></CardHeader>
<CardContent className="space-y-3"><div className="grid gap-2 sm:grid-cols-[160px_120px_1fr]"><ModelNameSelect value={modelName} onChange={setModelName} triggerClassName="w-full" /><Select value={style} onValueChange={(value) => setStyle(value as typeof style)}><SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="formal">正式报告</SelectItem><SelectItem value="analytical">分析研判</SelectItem><SelectItem value="concise">精炼表达</SelectItem></SelectContent></Select><Input type="number" min={800} max={30000} value={targetLength} onChange={(event) => setTargetLength(event.target.value)} className="h-8 text-xs" aria-label="改写目标字数" /></div><Textarea value={rewriteInstruction} onChange={(event) => setRewriteInstruction(event.target.value)} className="min-h-20 text-sm" maxLength={4_000} /><div className="flex justify-end gap-2">{latestRewrite ? <Button variant="outline" onClick={() => void onRestoreReport(task.id, latestRewrite.id)}>恢复最近改写前版本</Button> : null}<Button onClick={() => void start("rewrite", rewriteInstruction)}><WandSparkles className="mr-1.5 size-4" />全文改写</Button></div><div className="flex gap-2 border-t pt-3"><Input value={followup} onChange={(event) => setFollowup(event.target.value)} placeholder="基于当前报告和已选资料继续追问" maxLength={4_000} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && followup.trim()) { event.preventDefault(); void start("followup", followup.trim()); setFollowup(""); } }} /><Button size="icon" disabled={!followup.trim()} onClick={() => { void start("followup", followup.trim()); setFollowup(""); }} aria-label="发送报告追问"><Send className="size-4" /></Button></div></CardContent>
</Card> : null}
{(jobDraft || completedFollowups.length) ? <Card><CardHeader className="gap-1"><CardTitle className="text-sm">报告追问</CardTitle><CardDescription>回答基于当前报告和已选资料,不会自动发起新检索。</CardDescription></CardHeader><CardContent className="space-y-4">{jobDraft ? <div className="rounded-md border bg-muted/20 p-4"><MarkdownRenderer content={jobDraft} /></div> : null}{completedFollowups.slice(0, 3).map((job) => job.id === reportJob?.id && jobDraft ? null : <div key={job.id} className="border-t pt-4"><p className="mb-2 text-xs font-medium text-muted-foreground">{job.instruction}</p><MarkdownRenderer content={job.report_markdown} /></div>)}</CardContent></Card> : null}
<div className="flex items-start justify-between gap-3"><div><p className="text-sm font-semibold">证据工作台</p><p className="mt-1 text-xs text-muted-foreground">{isCollecting ? "正在收集内部证据…" : `已收集 ${task.sources.length} 条去重资料;报告引用可点击定位`}</p></div>{isCollecting ? <Loader2 className="size-4 animate-spin text-primary" /> : <ShieldCheck className="size-5 text-emerald-600" />}</div>
{isCollecting ? <div className="rounded-xl border bg-card p-5 text-sm text-muted-foreground"><div className="flex items-center gap-2"><Loader2 className="size-4 animate-spin" />正在执行 {task.queries.length} 个检索方向</div></div> : task.sources.length ? <div className="grid gap-4 2xl:grid-cols-[minmax(220px,0.8fr)_minmax(260px,1.2fr)]"><div className="rounded-xl border bg-card"><div className="border-b px-4 py-3 text-xs font-medium text-muted-foreground">资料列表</div><ScrollArea className="h-80"><div className="p-2">{task.sources.map((item, index) => <button key={item.chunk_id || `${item.knowledge_id}-${index}`} type="button" onClick={() => onSourceSelect(item.chunk_id)} className={cn("w-full rounded-lg p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", source?.chunk_id === item.chunk_id ? "bg-primary/10" : "hover:bg-muted")}><p className="line-clamp-2 text-sm font-medium leading-5">{index + 1}. {item.title || item.filename || "未命名资料"}</p><p className="mt-1 truncate text-xs text-muted-foreground">{item.knowledge_base_name || "知识库"}</p></button>)}</div></ScrollArea></div><article className="rounded-xl border bg-card p-5"><p className="text-sm font-semibold">{source?.title || source?.filename || "资料预览"}</p><p className="mt-1 text-xs text-muted-foreground">{source?.knowledge_base_name || "知识库"} · 段落 {typeof source?.chunk_index === "number" ? source.chunk_index + 1 : "-"}</p><p className="mt-5 whitespace-pre-wrap text-sm leading-7 text-foreground/90">{source?.content || "请选择一条资料查看内容。"}</p>{source?.url ? <button type="button" className="mt-5 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline" onClick={() => { if (!openKnowledgeSource(source)) toast.error("暂时无法定位该资料原文"); }}><ExternalLink className="size-4" />定位知识库原文</button> : null}</article></div> : <div className="rounded-xl border border-dashed bg-card p-6 text-center"><FileText className="mx-auto size-6 text-muted-foreground" /><h2 className="mt-3 text-sm font-semibold">未发现可用资料</h2></div>}
<DocumentRewriteDiffDialog
open={Boolean(diffJob)}
onOpenChange={(open) => { if (!open) setDiffJob(null); }}
fileName={`${task.subject}.md`}
original={diffJob?.original_report || ""}
rewritten={diffJob?.report_markdown || ""}
/>
</div>;
}
+5 -4
View File
@@ -40,7 +40,7 @@ import {
useThreadSettings,
} from "@/core/settings";
import { usePluginManagementSettings } from "@/core/system-settings/hooks";
import { useThreadStream } from "@/core/threads/hooks";
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@@ -336,11 +336,12 @@ export default function IframeChatPage() {
);
const handleClarificationSubmit = useCallback(
(answer: string) => {
async (answer: string) => {
setHasStartedConversation(true);
void sendMessage(threadId, { text: answer, files: [] });
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] });
},
[sendMessage, threadId],
[sendMessage, thread, threadId],
);
const handleAgentChange = useCallback((agentId: string | null) => {
+42 -17
View File
@@ -18,11 +18,13 @@ import {
readExternalLoginParams,
resolveThemeFromQuery,
} from '@/core/auth/external-login';
import { toast } from 'sonner';
import {
fetchCopTaskDetail,
persistCopTaskDetail,
resolveDeeplinkTaskId,
resolveTaskDeeplinkTarget,
stubCopTaskDetail,
} from '@/core/auth/task-deeplink';
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
@@ -61,6 +63,17 @@ function maskLoginToken(value: string): string {
return `${value.slice(0, 6)}...${value.slice(-4)}(${value.length})`;
}
/**
* Hash 路由下深链参数可能写在 ``#/login?...``,也可能写在 hash 前的
* ``?taskId=&goPath=#/login``。两边都读,避免宿主把 query 放在外层 URL 时丢参落到首页。
*/
function readLoginQuery(searchParams: URLSearchParams, key: string): string {
const fromHash = searchParams.get(key)?.trim() ?? '';
if (fromHash) return fromHash;
if (typeof window === 'undefined') return '';
return new URLSearchParams(window.location.search).get(key)?.trim() ?? '';
}
/** 读取已落地的 userInfo(脱敏 access token 后)供登录后排查。 */
function summarizeStoredUserInfo(): Record<string, unknown> | null {
try {
@@ -206,8 +219,8 @@ export default function LoginPage() {
const effectiveGatePassword = configuredRouteLoginPassword || gatePassword;
const usesConfiguredRouteLoginPassword = Boolean(configuredRouteLoginPassword);
// 任务深链:外部系统带 ?taskId=&goPath= 时,登录后拉任务详情并直送对应问答/会商页
const taskId = searchParams.get('taskId')?.trim() ?? '';
const goPath = searchParams.get('goPath')?.trim() ?? '';
const taskId = readLoginQuery(searchParams, 'taskId');
const goPath = readLoginQuery(searchParams, 'goPath');
// 通用「登录后跳指定页」:?redirect=page/strategy/dashboard-agent(值可不带前导斜杠,
// 也可自带 query,如 page/strategy/dashboard-agent?taskId=1580——记得整体 URL 编码)。
// 优先级最高:显式指定即按它跳,先于 taskId/goPath 深链与默认首页。出于安全只接受**站内
@@ -447,26 +460,38 @@ export default function LoginPage() {
return;
}
// 任务深链:带 taskId + goPath 时拉任务详情并直送对应问答/会商页;
// 任一环节失败(无地址/拉取失败/未配置智能体/goPath 无法识别)都回退默认首页。
// 任务深链:带 taskId + goPath 时直送对应问答/会商页。
// 任务详情拉取失败不再阻断跳转(本地/外网 consumer 常不可达),用仅含 taskId 的 stub 继续。
// 真正无法解析(goPath 无法识别 / 单智能体未配置业务映射)才回退默认首页。
if (taskId && goPath) {
try {
// xdfx 深链 ?taskId= 传入的是「行动id」,先换成真正的「任务id」;其它业务原样。
const realTaskId = await resolveDeeplinkTaskId(goPath, taskId);
// xdfx 深链 ?taskId= 传入的是「行动id」,先换成真正的「任务id」;解析失败回退原始 id。
const resolvedTaskId = await resolveDeeplinkTaskId(goPath, taskId);
const realTaskId = resolvedTaskId || taskId;
if (cancelled) return;
const detail = await fetchCopTaskDetail(realTaskId);
let detail = await fetchCopTaskDetail(realTaskId);
if (cancelled) return;
if (detail) {
persistCopTaskDetail(detail);
const target = await resolveTaskDeeplinkTarget(goPath, detail, realTaskId, taskId);
if (cancelled) return;
if (target) {
navigate(target.path, { replace: true, state: target.state });
return;
}
if (!detail) {
console.warn('[login] cop-task-detail missing, continue with stub', {
goPath,
taskId,
realTaskId,
});
detail = stubCopTaskDetail(realTaskId);
}
} catch {
/* 深链失败不阻断登录,落默认首页 */
persistCopTaskDetail(detail);
const target = await resolveTaskDeeplinkTarget(goPath, detail, realTaskId, taskId);
if (cancelled) return;
if (target) {
console.info('[login] deeplink →', target.path, { goPath, realTaskId });
navigate(target.path, { replace: true, state: target.state });
return;
}
console.warn('[login] deeplink target unresolved', { goPath, realTaskId });
toast.warning('深链未配置对应智能体或 goPath 无法识别,已进入首页');
} catch (error) {
console.warn('[login] deeplink failed, fallback home', error);
toast.warning('深链跳转失败,已进入首页');
}
}
+22 -13
View File
@@ -78,9 +78,11 @@ import {
type MenuOverride,
type MergedMenuNode,
} from "@/core/page-layout/menu-overrides";
import type {
CompactMenuOverride,
CompactMenuPlacement,
import {
COMPACT_MENU_TYPE_LABELS,
getRuntimeCompactMenuType,
type CompactMenuOverride,
type CompactMenuPlacement,
} from "@/core/page-layout/compact-menu-overrides";
import { sidebarMenuItems, type MenuItem } from "@/core/page-layout/sidebar-menu";
import { injectLightApps } from "@/core/page-layout/use-sidebar-menu";
@@ -506,7 +508,7 @@ export default function MenuManagementPage() {
() => [
{
id: "workspace-compact-nav",
label: "简介模式菜单",
label: "简洁模式菜单",
icon: Settings,
children: WORKSPACE_COMPACT_NAV_ITEMS,
},
@@ -775,7 +777,7 @@ export default function MenuManagementPage() {
mode === "compact" ? "bg-red-600 text-white shadow-sm" : "text-slate-500 hover:text-slate-800"
}`}
>
简介模式
简洁模式
</button>
</div>
);
@@ -975,8 +977,11 @@ export default function MenuManagementPage() {
}
function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
const { data: overrides = [], isLoading } = useCompactMenuOverrides();
const { save } = useCompactMenuOverrideActions();
const runtimeType = getRuntimeCompactMenuType();
// 不再提供 A/B 切换:固定编辑当前网页(runtime-config.js VITE_COMPACT_MENU_TYPE)使用的那套。
const menuType = runtimeType;
const { data: overrides = [], isLoading } = useCompactMenuOverrides(menuType);
const { save } = useCompactMenuOverrideActions(menuType);
const initialRows = useMemo(() => compactRowsFromTree(baseTree, overrides), [baseTree, overrides]);
const [rows, setRows] = useState<CompactRow[]>(initialRows);
const [loadedKey, setLoadedKey] = useState("");
@@ -988,8 +993,9 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
(top.children ?? []).map((child) => [top.id, child.id, child.label]),
),
overrides,
menuType,
}),
[baseTree, overrides],
[baseTree, overrides, menuType],
);
useEffect(() => {
if (sourceKey !== loadedKey) {
@@ -1031,14 +1037,14 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
const handleSave = async () => {
try {
await save.mutateAsync(currentOverrides);
toast.success("简介菜单配置已保存");
toast.success(`简洁菜单配置已保存(${COMPACT_MENU_TYPE_LABELS[menuType]})`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存失败");
}
};
const handleReset = () => {
if (!confirm("确定要重置简介模式菜单为默认布局吗?保存后将清除简介模式自定义配置。")) return;
if (!confirm(`确定要重置${COMPACT_MENU_TYPE_LABELS[menuType]}的简洁模式菜单为默认布局吗?保存后将清除该类型的自定义配置。`)) return;
setRows(compactRowsFromTree(baseTree, []));
};
@@ -1053,10 +1059,10 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
<div>
<span className="mb-3 inline-flex select-none items-center gap-1.5 rounded-full border border-red-100 bg-red-50 px-3 py-1 text-xs font-semibold text-red-600">
<Sparkles className="size-3" />
仅简介模式生效
仅简洁模式生效
</span>
<h1 className="flex items-center gap-3 text-2xl font-bold tracking-tight text-slate-800 md:text-3xl">
简介菜单管理
简洁菜单管理
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-0.5 font-mono text-xs font-medium text-slate-500">
{rows.length} 个项目
</span>
@@ -1084,7 +1090,10 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
</div>
<p className="flex max-w-2xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600">
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span>这里配置的是简介模式工作区左侧菜单:可改显示名称、调整顺序,或迁移到「设置和更多」下拉中;全量模式菜单不受影响。</span>
<span>
这里配置的是简洁模式工作区左侧菜单,保存后仅对当前网页使用的
{COMPACT_MENU_TYPE_LABELS[runtimeType]}生效(两套部署各改各的);全量模式菜单不受影响。
</span>
</p>
</div>
</div>
+183 -68
View File
@@ -1,9 +1,28 @@
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
rectSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
BotIcon,
ChevronDownIcon,
CircleAlertIcon,
CpuIcon,
EyeIcon,
GripVerticalIcon,
KeyRoundIcon,
LoaderCircleIcon,
PlusIcon,
@@ -31,6 +50,7 @@ import {
type ModelSavePayload,
useDeleteManagedModel,
useManagedModels,
useReorderManagedModels,
useSaveManagedModel,
useTestManagedModel,
} from "@/core/model-management";
@@ -170,6 +190,103 @@ function ModelCapabilityBadge({
);
}
function SortableModelCard({
model,
isDefault,
dragEnabled,
onConfigure,
onDelete,
}: {
model: ManagedModel;
isDefault: boolean;
dragEnabled: boolean;
onConfigure: () => void;
onDelete: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: model.name,
disabled: !dragEnabled,
});
return (
<article
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.45 : 1,
}}
className={`rounded-lg border border-border bg-background p-4 transition-[border-color,box-shadow] ${
dragEnabled ? "hover:border-primary/35 hover:shadow-sm" : ""
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2">
<button
type="button"
className={`mt-0.5 shrink-0 rounded p-1 text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
dragEnabled
? "cursor-grab touch-none hover:bg-muted hover:text-foreground active:cursor-grabbing"
: "cursor-not-allowed opacity-40"
}`}
title={dragEnabled ? "拖动调整顺序" : "请清空搜索和筛选后再调整顺序"}
aria-label={`${model.display_name || model.name}:${dragEnabled ? "拖动调整顺序" : "当前不可调整顺序"}`}
{...(dragEnabled ? attributes : {})}
{...(dragEnabled ? listeners : {})}
>
<GripVerticalIcon className="size-4" aria-hidden />
</button>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="truncate font-semibold">{model.display_name || model.name}</h2>
{isDefault && <Tag theme="primary" variant="light" size="small">默认模型</Tag>}
<Tag theme="success" variant="light" size="small">config.yaml</Tag>
</div>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{model.name}</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<TButton theme="default" variant="outline" size="small" onClick={onConfigure}>
配置
</TButton>
<TButton
theme="danger"
variant="text"
size="small"
aria-label={`删除模型 ${model.display_name || model.name}`}
onClick={onDelete}
>
<Trash2Icon className="mr-1 size-3.5" />删除
</TButton>
</div>
</div>
{model.description ? (
<p className="mt-3 line-clamp-2 min-h-10 text-sm leading-5 text-muted-foreground">{model.description}</p>
) : (
<p className="mt-3 min-h-10 text-sm leading-5 text-muted-foreground">未填写描述</p>
)}
<div className="mt-3 grid gap-2 border-t pt-3 text-xs text-muted-foreground sm:grid-cols-2">
<span className="truncate" title={model.use}>驱动:{model.use}</span>
<span className="truncate" title={model.model}>模型:{model.model}</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{model.api_key_configured && (
<ModelCapabilityBadge icon={KeyRoundIcon} label="已配置密钥" className="bg-muted text-foreground" />
)}
{model.supports_thinking && (
<ModelCapabilityBadge icon={SparklesIcon} label="思考" className="bg-primary/10 text-primary" />
)}
{model.supports_vision && (
<ModelCapabilityBadge icon={EyeIcon} label="视觉" className="bg-amber-500/10 text-amber-700 dark:text-amber-300" />
)}
{model.supports_reasoning_effort && (
<ModelCapabilityBadge label="推理档位" className="bg-muted text-muted-foreground" />
)}
</div>
</article>
);
}
function showGlobalMessage(
theme: "success" | "error" | "warning",
content: string,
@@ -274,15 +391,25 @@ export default function ModelManagementPage() {
const [filter, setFilter] = useState<"all" | "thinking" | "vision">("all");
const [editor, setEditor] = useState<ManagedModel | null | "new">(null);
const [deleteTarget, setDeleteTarget] = useState<ManagedModel | null>(null);
const [pendingOrder, setPendingOrder] = useState<string[] | null>(null);
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const remove = useDeleteManagedModel();
const reorder = useReorderManagedModels();
const sourceModels = data?.models ?? [];
const orderedModels = useMemo(() => {
if (!pendingOrder || pendingOrder.length !== sourceModels.length) return sourceModels;
const byName = new Map(sourceModels.map((model) => [model.name, model]));
const reordered = pendingOrder.map((name) => byName.get(name)).filter((model): model is ManagedModel => Boolean(model));
return reordered.length === sourceModels.length ? reordered : sourceModels;
}, [pendingOrder, sourceModels]);
const models = useMemo(() => {
const keyword = search.trim().toLowerCase();
return (data?.models ?? []).filter((model) => {
return orderedModels.filter((model) => {
const matchesFilter = filter === "all" || (filter === "thinking" ? model.supports_thinking : model.supports_vision);
const matchesSearch = !keyword || [model.name, model.display_name, model.model, model.use].filter(Boolean).join(" ").toLowerCase().includes(keyword);
return matchesFilter && matchesSearch;
});
}, [data?.models, filter, search]);
}, [filter, orderedModels, search]);
const stats = useMemo(() => ({ total: data?.models.length ?? 0, thinking: data?.models.filter((item) => item.supports_thinking).length ?? 0, vision: data?.models.filter((item) => item.supports_vision).length ?? 0 }), [data?.models]);
const handleDelete = async () => {
if (!deleteTarget)
@@ -297,6 +424,28 @@ export default function ModelManagementPage() {
showGlobalMessage("error", error instanceof Error ? error.message : "删除模型失败,请稍后重试。");
}
};
const dragEnabled = filter === "all" && !search.trim() && !reorder.isPending;
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const handleDragEnd = async ({ active, over }: DragEndEvent) => {
setActiveDragId(null);
if (!dragEnabled || !over || active.id === over.id) return;
const oldIndex = orderedModels.findIndex((model) => model.name === active.id);
const newIndex = orderedModels.findIndex((model) => model.name === over.id);
if (oldIndex < 0 || newIndex < 0) return;
const nextOrder = arrayMove(orderedModels.map((model) => model.name), oldIndex, newIndex);
setPendingOrder(nextOrder);
try {
await reorder.mutateAsync(nextOrder);
showGlobalMessage("success", `模型顺序已更新,“${nextOrder[0]}”现在是默认模型。`);
} catch (error) {
setPendingOrder(null);
showGlobalMessage("error", error instanceof Error ? error.message : "调整模型顺序失败,请稍后重试。");
}
};
return (
<WorkspaceContainer>
<WorkspaceHeader />
@@ -375,73 +524,39 @@ export default function ModelManagementPage() {
</div>
)}
{!isLoading && !error && models.length > 0 && (
<div className="grid gap-3 p-4 lg:grid-cols-2">
{models.map((model) => (
<article key={model.name} className="rounded-lg border border-border bg-background p-4 transition-colors hover:border-primary/35">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="truncate font-semibold">{model.display_name || model.name}</h2>
<Tag theme="success" variant="light" size="small">config.yaml</Tag>
</div>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{model.name}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<TButton theme="default" variant="outline" size="small" onClick={() => setEditor(model)}>
配置
</TButton>
<TButton
theme="danger"
variant="text"
size="small"
aria-label={"删除模型 " + (model.display_name || model.name)}
onClick={() => setDeleteTarget(model)}
>
<Trash2Icon className="mr-1 size-3.5" />删除
</TButton>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={({ active }) => setActiveDragId(String(active.id))}
onDragCancel={() => setActiveDragId(null)}
onDragEnd={(event) => void handleDragEnd(event)}
>
<div className="border-b bg-muted/20 px-4 py-2.5 text-xs text-muted-foreground">
拖动卡片左侧手柄即可调整顺序;排在第一位的模型会作为默认模型。
{!dragEnabled && " 请清空搜索和筛选条件后再调整顺序。"}
</div>
<SortableContext items={models.map((model) => model.name)} strategy={rectSortingStrategy}>
<div className="grid gap-3 p-4 lg:grid-cols-2">
{models.map((model) => (
<SortableModelCard
key={model.name}
model={model}
isDefault={model.name === orderedModels[0]?.name}
dragEnabled={dragEnabled}
onConfigure={() => setEditor(model)}
onDelete={() => setDeleteTarget(model)}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeDragId ? (
<div className="rounded-lg border border-primary/30 bg-background px-4 py-3 text-sm font-medium shadow-lg">
{orderedModels.find((model) => model.name === activeDragId)?.display_name || activeDragId}
</div>
{model.description ? (
<p className="mt-3 line-clamp-2 min-h-10 text-sm leading-5 text-muted-foreground">{model.description}</p>
) : (
<p className="mt-3 min-h-10 text-sm leading-5 text-muted-foreground">未填写描述</p>
)}
<div className="mt-3 grid gap-2 border-t pt-3 text-xs text-muted-foreground sm:grid-cols-2">
<span className="truncate" title={model.use}>驱动:{model.use}</span>
<span className="truncate" title={model.model}>模型:{model.model}</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{model.api_key_configured && (
<ModelCapabilityBadge
icon={KeyRoundIcon}
label="已配置密钥"
className="bg-muted text-foreground"
/>
)}
{model.supports_thinking && (
<ModelCapabilityBadge
icon={SparklesIcon}
label="思考"
className="bg-primary/10 text-primary"
/>
)}
{model.supports_vision && (
<ModelCapabilityBadge
icon={EyeIcon}
label="视觉"
className="bg-amber-500/10 text-amber-700 dark:text-amber-300"
/>
)}
{model.supports_reasoning_effort && (
<ModelCapabilityBadge
label="推理档位"
className="bg-muted text-muted-foreground"
/>
)}
</div>
</article>
))}
</div>
) : null}
</DragOverlay>
</DndContext>
)}
</section>
+15 -3
View File
@@ -53,7 +53,7 @@ import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { isAgentSkillCandidate } from "@/core/skills";
import { useSkills } from "@/core/skills/hooks";
import { useThreadStream } from "@/core/threads/hooks";
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
import { cn } from "@/lib/utils";
@@ -335,13 +335,25 @@ export default function NewAgentPage() {
// Chat is purely a discussion channel. The agent record is only
// created when the user explicitly clicks Save (top-right). If the
// model proposes a config via setup_agent, our approval handler
// prefills the form locally 鈥?it does NOT persist anything.
// prefills the form locally — it does NOT persist anything.
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
[ensureThreadExists, sendMessage, thread.isLoading, threadId],
);
const handleClarificationSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
// 协助卡提交不能因 isLoading 直接丢弃——弱模型出卡后可能仍在流式。
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
[ensureThreadExists, sendMessage, thread, threadId],
);
const handleApproveSetupAgent = useCallback(
async (payload: SetupAgentApprovalPayload) => {
// Approving the model's setup_agent proposal **does not** persist
@@ -425,7 +437,7 @@ export default function NewAgentPage() {
className="size-full"
threadId={threadId}
thread={thread}
onClarificationSubmit={handleChatSubmit}
onClarificationSubmit={handleClarificationSubmit}
onSetupAgentApprove={handleApproveSetupAgent}
onSetupAgentReject={handleRejectSetupAgent}
/>
@@ -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,
@@ -1,7 +1,7 @@
"use client";
import { AlertCircle,CheckCircle2, Loader2Icon, ShieldIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type FormEvent } from "react";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
@@ -86,6 +86,11 @@ export default function PrivateSquareSettingsPage({
}
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
void handleSave();
};
const handleClearPassword = async () => {
if (!window.confirm("确认清除私密广场密码?清除后任何人都将无法访问私密广场。")) {
return;
@@ -123,7 +128,18 @@ export default function PrivateSquareSettingsPage({
加载失败:{error.message}
</div>
) : (
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<form
className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white"
onSubmit={handleSubmit}
>
<input
className="sr-only"
type="text"
name="private-square-admin"
autoComplete="username"
tabIndex={-1}
aria-hidden="true"
/>
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">启用私密广场</div>
@@ -135,7 +151,7 @@ export default function PrivateSquareSettingsPage({
</div>
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">
<label className="text-sm font-medium" htmlFor="private-square-password">
访问密码
{data?.password_set ? (
<span className="text-muted-foreground ml-2 text-xs font-normal">
@@ -146,25 +162,31 @@ export default function PrivateSquareSettingsPage({
(尚未设置)
</span>
)}
</p>
</label>
<Input
id="private-square-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={
data?.password_set ? "留空表示不修改密码" : "请输入访问密码"
}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">确认密码</p>
<label className="text-sm font-medium" htmlFor="private-square-confirm-password">
确认密码
</label>
<Input
id="private-square-confirm-password"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="再次输入新密码"
disabled={!password}
autoComplete="new-password"
/>
</div>
@@ -179,14 +201,14 @@ export default function PrivateSquareSettingsPage({
<ShieldIcon className="mr-1.5 size-4" />
清除密码
</Button>
<Button onClick={handleSave} disabled={update.isPending}>
<Button type="submit" disabled={update.isPending}>
{update.isPending && (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
)}
保存
</Button>
</div>
</div>
</form>
)}
</div>
);
+11
View File
@@ -26,6 +26,8 @@ import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtab
import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage";
import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage";
import DeepResearchPage from "./DeepResearchPage";
import ReportStructureCreatePage from "./deep-research/ReportStructureCreatePage";
import EnterpriseResearchPage from "./EnterpriseResearchPage";
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
@@ -118,6 +120,15 @@ export default function StrategyRoutes() {
}
/>
<Route path="qa/deep-research" element={<DeepResearchPage />} />
<Route
path="qa/deep-research/structures/new"
element={
<ChatRuntime>
<ReportStructureCreatePage />
</ChatRuntime>
}
/>
<Route path="qa/enterprise-research" element={<EnterpriseResearchPage />} />
{/* 大屏绘制智能体独立页面(按 taskId 存取;ChatRuntime 提供 ArtifactsProvider 给沙箱预览) */}
<Route
@@ -25,6 +25,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { useNotebookChat, useNotebookSources, useNotebookThreads, useStudioNotebooks } from "@/core/studio/hooks";
import { stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import { fetchDocumentByChunk } from "@/core/studio/api";
import type { DocumentWithChunks } from "@/core/studio/api";
import type { SourceItem } from "@/core/studio/types";
@@ -381,12 +382,13 @@ export default function StudioNotebookDetailPage({
// Clarification 卡片的「发送」按钮回调。MessageList 内部把用户选中的选项 +
// 自由文本合并成一段 answer 传过来,这里走和普通发送同一条 send() 链路,
// 把当前选中的来源也一并带上,保持上下文一致。
// 弱模型出卡后可能仍在流式:先 stop 再发,不能因 isStreaming 直接丢弃。
const handleClarificationSubmit = useCallback(
async (answer: string) => {
if (isStreaming) return;
await send(answer, selectedSourceIds);
await stopActiveThreadBeforeClarificationReply(chatThreadId, thread);
await send(answer, selectedSourceIds, { fromClarification: true });
},
[send, selectedSourceIds, isStreaming],
[send, selectedSourceIds, chatThreadId, thread],
);
const handleBackToList = () => {
+36 -2
View File
@@ -173,7 +173,7 @@ export default function TaskButtonsPage() {
<div>
<div className="text-foreground text-base font-semibold">按钮管理</div>
<div className="text-muted-foreground text-xs">
配置任务工作区底部「快捷跳转」栏的按钮(名称 / 跳转方式 / 地址 / 启停 / 排序)
配置任务工作区底部「快捷跳转」栏的按钮(名称 / 跳转方式 / 地址 / 启停 / 排序)。「宿主路由」用于 iframe 套用时让对方系统跳转。
</div>
</div>
</div>
@@ -265,13 +265,14 @@ export default function TaskButtonsPage() {
value={r.linkType}
onValueChange={(v) => update(r.id, { linkType: v as TaskButton["linkType"] })}
>
<SelectTrigger className="h-8 w-36">
<SelectTrigger className="h-8 w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="url">外部链接</SelectItem>
<SelectItem value="business">切换业务</SelectItem>
<SelectItem value="purpose">目的树弹框</SelectItem>
<SelectItem value="host">宿主路由</SelectItem>
</SelectContent>
</Select>
</div>
@@ -282,6 +283,39 @@ export default function TaskButtonsPage() {
<span className="text-muted-foreground text-xs">
点击弹出当前任务的「任务目的」表格,无需配置目标。
</span>
) : r.linkType === "host" ? (
<>
<Input
value={r.target}
onChange={(e) => update(r.id, { target: e.target.value })}
placeholder="/task/…(宿主系统路由)"
className="h-8 flex-1 min-w-[16rem]"
title="宿主系统路由,点击后通过 postMessage 让父页面跳转"
/>
<label className="text-muted-foreground ml-2 flex shrink-0 items-center gap-1.5 text-xs">
<Switch
checked={r.appendTaskId}
onCheckedChange={(v) => update(r.id, { appendTaskId: v })}
/>
追加任务id
</label>
{r.appendTaskId && (
<div className="flex shrink-0 items-center gap-1 text-xs">
<span className="text-muted-foreground">queryParams.</span>
<Input
value={r.idParam}
onChange={(e) => update(r.id, { idParam: e.target.value })}
placeholder="id"
className="h-8 w-24"
title="发给宿主的参数名(默认 id)"
/>
<span className="text-muted-foreground">= 任务id(数字)</span>
</div>
)}
<span className="text-muted-foreground w-full text-[11px]">
iframe 嵌入时向父页面发送 HOST_NAVIGATE,由宿主系统跳转;独立打开本系统时无效。
</span>
</>
) : r.linkType === "url" ? (
<>
<Input
@@ -64,6 +64,8 @@ import PluginManagementPage from './PluginManagementPage';
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import DeepResearchPage from './DeepResearchPage';
import ReportStructureCreatePage from './deep-research/ReportStructureCreatePage';
import EnterpriseResearchPage from './EnterpriseResearchPage';
import StudioNotebooksPage from './StudioNotebooksPage';
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
@@ -564,6 +566,24 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="deep-research/structures/new"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<ReportStructureCreatePage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="enterprise-research"
element={
<WorkspaceLayout {...layoutProps}>
<EnterpriseResearchPage />
</WorkspaceLayout>
}
/>
<Route
path="studio/notebooks"
element={
File diff suppressed because it is too large. Load diff
@@ -32,6 +32,16 @@ import {
type ResearchMode,
type ResearchTone,
} from "@/strategy-components/api/deep-research";
import {
DEFAULT_REPORT_STRUCTURE_MODE,
normalizeReportStructureMode,
normalizeRetrievalDirections,
normalizeRetrievalSkills,
REPORT_STRUCTURE_MODE_OPTIONS,
type ReportStructureMode,
useReportStructures,
} from "@/strategy-components/api/report-structures";
import { WorkflowConfigButton } from "./WorkflowConfigDialog";
const MODE_LABELS: Record<ResearchMode, string> = {
basic: "标准",
@@ -63,8 +73,317 @@ export type AdvancedResearchOptions = {
maxGeneratedImages: string;
thinkingEnabled: boolean;
customOutline: string;
structureMode: ReportStructureMode;
retrievalDirections: string[];
retrievalSkills: 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 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。`;
const CUSTOM_REPORT_STRUCTURE_VALUE = "__custom_report_structure__";
const REPORT_STRUCTURE_VALUE_PREFIX = "structure:";
function matchingReportStructureId(
outline: string,
structures: Array<{ id: string; content: string }>,
): string | null {
const normalized = outline.trim();
if (!normalized) return null;
return structures.find((structure) => structure.content.trim() === normalized)?.id ?? null;
}
/**
* The concise structure picker is deliberately usable in the confirmation
* card. Users should not have to open advanced settings merely to choose how
* their report is organised.
*/
export function ReportStructureSelect({
id,
value,
onChange,
onStructureModeChange,
onRetrievalDirectionsChange,
onRetrievalSkillsChange,
}: {
id: string;
value: string;
onChange: (value: string) => void;
onStructureModeChange?: (mode: ReportStructureMode) => void;
onRetrievalDirectionsChange?: (directions: string[]) => void;
onRetrievalSkillsChange?: (skills: string[]) => void;
}) {
// 与「工作流配置」共用报告结构库;创建/编辑/启停后 React Query 的失效会
// 自动刷新本下拉,避免维护一份会逐渐过时的前端硬编码模板。
const structuresQuery = useReportStructures({ status: "enabled" });
const structures = structuresQuery.data ?? [];
const selectedStructureId = matchingReportStructureId(value, structures);
const templateValue = selectedStructureId
? `${REPORT_STRUCTURE_VALUE_PREFIX}${selectedStructureId}`
: CUSTOM_REPORT_STRUCTURE_VALUE;
const selectTemplate = (next: string) => {
if (next === CUSTOM_REPORT_STRUCTURE_VALUE) {
// “自定义” is an intentional blank starting point. Saved structures are
// always available as one-click scaffolds whenever the user wants them.
onChange("");
onStructureModeChange?.(DEFAULT_REPORT_STRUCTURE_MODE);
onRetrievalDirectionsChange?.([]);
onRetrievalSkillsChange?.([]);
return;
}
if (!next.startsWith(REPORT_STRUCTURE_VALUE_PREFIX)) return;
const structureId = next.slice(REPORT_STRUCTURE_VALUE_PREFIX.length);
const structure = structures.find((item) => item.id === structureId);
if (structure) {
onChange(structure.content);
onStructureModeChange?.(structure.structureMode);
onRetrievalDirectionsChange?.(structure.retrievalDirections);
onRetrievalSkillsChange?.(structure.retrievalSkills);
}
};
return (
<div>
<label htmlFor={`${id}-template`} className="mb-1.5 block text-xs font-medium">
报告结构
</label>
<Select value={templateValue} onValueChange={selectTemplate}>
<SelectTrigger id={`${id}-template`} className="h-9 text-sm" aria-label="报告结构模板">
<SelectValue />
</SelectTrigger>
<SelectContent>
{structuresQuery.isLoading && (
<SelectItem value="__report_structures_loading__" disabled>正在加载工作流结构…</SelectItem>
)}
{structuresQuery.isError && (
<SelectItem value="__report_structures_error__" disabled>报告结构库加载失败</SelectItem>
)}
{structures.map((structure) => (
<SelectItem key={structure.id} value={`${REPORT_STRUCTURE_VALUE_PREFIX}${structure.id}`}>
{structure.title}
</SelectItem>
))}
<SelectItem value={CUSTOM_REPORT_STRUCTURE_VALUE}>自定义结构</SelectItem>
</SelectContent>
</Select>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
{selectedStructureId
? "已选工作流结构;可在高级配置中继续编辑章节内容。"
: "当前使用自定义结构;可在高级配置中编辑章节内容。"}
</p>
</div>
);
}
export function StructureModeSelect({
id,
value,
onChange,
}: {
id?: string;
value: ReportStructureMode;
onChange: (value: ReportStructureMode) => void;
}) {
const selected = REPORT_STRUCTURE_MODE_OPTIONS.find((option) => option.value === value)
?? REPORT_STRUCTURE_MODE_OPTIONS[1];
return (
<div>
<label htmlFor={id} className="mb-1.5 block text-xs font-medium">
结构约束
</label>
<Select value={value} onValueChange={(next) => onChange(normalizeReportStructureMode(next))}>
<SelectTrigger id={id} className="h-9 text-sm" aria-label="结构约束">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_STRUCTURE_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
{selected.description}
</p>
</div>
);
}
/** Shared by the initial settings dialog and the write-confirmation card. */
export function ReportOutlineEditor({
id,
value,
onChange,
structureMode,
onStructureModeChange,
onRetrievalDirectionsChange,
onRetrievalSkillsChange,
showStructureSelect = true,
}: {
id: string;
value: string;
onChange: (value: string) => void;
structureMode?: ReportStructureMode;
onStructureModeChange?: (mode: ReportStructureMode) => void;
onRetrievalDirectionsChange?: (directions: string[]) => void;
onRetrievalSkillsChange?: (skills: string[]) => void;
/** The confirmation card already exposes its picker in the primary form. */
showStructureSelect?: boolean;
}) {
return (
<div className="space-y-2">
{showStructureSelect && (
<div className="grid gap-3 sm:grid-cols-2">
<ReportStructureSelect
id={id}
value={value}
onChange={onChange}
onStructureModeChange={onStructureModeChange}
onRetrievalDirectionsChange={onRetrievalDirectionsChange}
onRetrievalSkillsChange={onRetrievalSkillsChange}
/>
{onStructureModeChange && (
<StructureModeSelect
id={`${id}-mode`}
value={structureMode ?? DEFAULT_REPORT_STRUCTURE_MODE}
onChange={onStructureModeChange}
/>
)}
</div>
)}
<label htmlFor={id} className="block pt-1 text-xs font-medium">
报告结构(可编辑)
</label>
<Textarea
id={id}
value={value}
onChange={(event) => 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 +397,10 @@ export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
maxRevisionLoops: "2",
maxGeneratedImages: "2",
thinkingEnabled: false,
customOutline: "",
customOutline: DEFAULT_REPORT_OUTLINE,
structureMode: DEFAULT_REPORT_STRUCTURE_MODE,
retrievalDirections: [],
retrievalSkills: [],
};
/** Seed the card's editable options from the frozen session config snapshot. */
@@ -100,7 +422,12 @@ 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,
structureMode: normalizeReportStructureMode(config?.structure_mode),
retrievalDirections: normalizeRetrievalDirections(config?.retrieval_directions),
retrievalSkills: normalizeRetrievalSkills(config?.retrieval_skills),
};
}
@@ -138,6 +465,9 @@ export function buildReportCardConfig(
}
if (generateImages) config.max_generated_images = boundedInteger(options.maxGeneratedImages, 2, 1, 4);
if (options.customOutline.trim()) config.custom_outline = options.customOutline.trim().slice(0, 4_000);
config.structure_mode = normalizeReportStructureMode(options.structureMode);
config.retrieval_directions = normalizeRetrievalDirections(options.retrievalDirections);
config.retrieval_skills = normalizeRetrievalSkills(options.retrievalSkills);
return config;
}
@@ -145,6 +475,15 @@ export function ReportConfigCard({
availableModes,
defaultMode,
defaultOptions,
/**
* A report structure chosen from the collector's workflow configuration
* while this card is open. It supersedes only the outline, leaving choices
* already made in this card intact.
*/
outlineOverride,
structureModeOverride,
retrievalDirectionsOverride,
retrievalSkillsOverride,
defaultGenerateImages,
modelLabel,
materialCount,
@@ -157,6 +496,10 @@ export function ReportConfigCard({
availableModes: ResearchMode[];
defaultMode: ResearchMode;
defaultOptions: AdvancedResearchOptions;
outlineOverride?: string;
structureModeOverride?: ReportStructureMode;
retrievalDirectionsOverride?: string[];
retrievalSkillsOverride?: string[];
defaultGenerateImages: boolean;
modelLabel: string;
/** Number of unique, harvestable materials visible in the collector thread. */
@@ -170,14 +513,47 @@ export function ReportConfigCard({
const [mode, setMode] = useState<ResearchMode>(
availableModes.includes(defaultMode) ? defaultMode : (availableModes[0] ?? "basic"),
);
const [options, setOptions] = useState<AdvancedResearchOptions>(defaultOptions);
const [options, setOptions] = useState<AdvancedResearchOptions>(() => ({
...defaultOptions,
customOutline: outlineOverride ?? defaultOptions.customOutline,
structureMode: structureModeOverride ?? defaultOptions.structureMode,
retrievalDirections: retrievalDirectionsOverride ?? defaultOptions.retrievalDirections,
retrievalSkills: retrievalSkillsOverride ?? defaultOptions.retrievalSkills,
}));
const [generateImages, setGenerateImages] = useState(defaultGenerateImages);
const [advancedOpen, setAdvancedOpen] = useState(false);
// The collector composer and this confirmation card are both reachable while
// a session is awaiting report writing. Selecting a structure in the former
// must be reflected here, but should not reset the card's mode, image switch,
// or any other in-progress edits.
useEffect(() => {
setOptions(defaultOptions);
setGenerateImages(defaultGenerateImages);
}, [defaultOptions, defaultGenerateImages]);
if (
outlineOverride === undefined
&& structureModeOverride === undefined
&& retrievalDirectionsOverride === undefined
&& retrievalSkillsOverride === undefined
) return;
setOptions((current) => {
const nextOutline = outlineOverride ?? current.customOutline;
const nextMode = structureModeOverride ?? current.structureMode;
const nextDirections = retrievalDirectionsOverride ?? current.retrievalDirections;
const nextSkills = retrievalSkillsOverride ?? current.retrievalSkills;
if (
current.customOutline === nextOutline
&& current.structureMode === nextMode
&& current.retrievalDirections.join("\n") === nextDirections.join("\n")
&& current.retrievalSkills.join("\n") === nextSkills.join("\n")
) return current;
return {
...current,
customOutline: nextOutline,
structureMode: nextMode,
retrievalDirections: nextDirections,
retrievalSkills: nextSkills,
};
});
}, [outlineOverride, structureModeOverride, retrievalDirectionsOverride, retrievalSkillsOverride]);
const modes = useMemo(
() => (availableModes.length ? availableModes : (["basic", "quick", "detailed", "deep", "multi_agent"] as ResearchMode[])),
@@ -203,7 +579,7 @@ export function ReportConfigCard({
</div>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<label className="block">
<span className="mb-1.5 block text-xs font-medium">报告模式</span>
<Select value={mode} onValueChange={(value) => setMode(value as ResearchMode)}>
@@ -216,6 +592,19 @@ export function ReportConfigCard({
</Select>
<span className="mt-1 block text-[11px] text-muted-foreground">{MODE_HINTS[mode]}</span>
</label>
<ReportStructureSelect
id="report-card-structure"
value={options.customOutline}
onChange={(value) => update("customOutline", value)}
onStructureModeChange={(mode) => update("structureMode", mode)}
onRetrievalDirectionsChange={(directions) => update("retrievalDirections", directions)}
onRetrievalSkillsChange={(skills) => update("retrievalSkills", skills)}
/>
<StructureModeSelect
id="report-card-structure-mode"
value={options.structureMode}
onChange={(mode) => update("structureMode", mode)}
/>
{imageGenerationAvailable && (
<div>
<span className="mb-1.5 block text-xs font-medium">报告配图</span>
@@ -241,7 +630,7 @@ export function ReportConfigCard({
>
<SlidersHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
高级配置
<span className="font-normal text-muted-foreground">语言、语气、自定义结构等</span>
<span className="font-normal text-muted-foreground">语言、语气、检索预算与结构细调</span>
<ChevronRight className={`ml-auto h-3.5 w-3.5 text-muted-foreground transition-transform ${advancedOpen ? "rotate-90" : ""}`} />
</button>
{advancedOpen && (
@@ -278,15 +667,27 @@ 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)}
structureMode={options.structureMode}
onStructureModeChange={(mode) => update("structureMode", mode)}
onRetrievalDirectionsChange={(directions) => update("retrievalDirections", directions)}
onRetrievalSkillsChange={(skills) => update("retrievalSkills", skills)}
showStructureSelect={false}
/>
</div>
<div className="flex justify-end sm:col-span-2">
<WorkflowConfigButton
appliedContent={options.customOutline}
onApply={(value, mode, directions, skills) => {
update("customOutline", value);
update("structureMode", mode);
update("retrievalDirections", directions);
update("retrievalSkills", skills);
}}
/>
<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
@@ -0,0 +1,124 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { ArrowLeft, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import type { SetupReportStructureApprovalPayload } from "@/components/workspace/messages";
import {
normalizeReportStructureMode,
normalizeRetrievalDirections,
normalizeRetrievalSkills,
useReportStructureMutations,
} from "@/strategy-components/api/report-structures";
import {
EMPTY_REPORT_STRUCTURE_FORM,
ReportStructureFormFields,
type ReportStructureFormValue,
} from "./ReportStructureFormFields";
import { ReportStructureSetupChat } from "./ReportStructureSetupChat";
/**
* 深度研究「智能新增报告结构」页。
*
* 左对话(几轮问清需求 → 确认卡回填)+ 右表单(可纯手工填写)。保存后回到来源页。
*/
export default function ReportStructureCreatePage() {
const navigate = useNavigate();
const location = useLocation();
const mutations = useReportStructureMutations();
const [form, setForm] = useState<ReportStructureFormValue>(EMPTY_REPORT_STRUCTURE_FORM);
const [saving, setSaving] = useState(false);
const backPath = useMemo(() => {
const path = location.pathname.replace(/\/structures\/new\/?$/, "");
return path || "/page/workspace/deep-research";
}, [location.pathname]);
const applyProposal = useCallback((payload: SetupReportStructureApprovalPayload) => {
setForm((current) => ({
title: payload.title?.trim() || current.title,
name: payload.name?.trim() || current.name,
content: payload.content?.trim() || current.content,
structureMode: payload.structure_mode
? normalizeReportStructureMode(payload.structure_mode)
: current.structureMode,
retrievalDirections:
payload.retrieval_directions && payload.retrieval_directions.length > 0
? normalizeRetrievalDirections(payload.retrieval_directions)
: current.retrievalDirections,
retrievalSkills:
payload.retrieval_skills && payload.retrieval_skills.length > 0
? normalizeRetrievalSkills(payload.retrieval_skills)
: current.retrievalSkills,
}));
}, []);
const handleSave = async () => {
if (!form.title.trim() || !form.content.trim()) {
toast.error("请填写标题和结构内容");
return;
}
setSaving(true);
try {
await mutations.create.mutateAsync({
title: form.title.trim(),
name: form.name.trim(),
content: form.content,
structureMode: form.structureMode,
retrievalDirections: form.retrievalDirections,
retrievalSkills: form.retrievalSkills,
});
toast.success(`已保存报告结构「${form.title.trim()}」`);
navigate(backPath, { replace: true, state: { openWorkflowConfig: true } });
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
setSaving(false);
}
};
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-3">
<div className="flex min-w-0 items-center gap-2">
<Button variant="ghost" size="icon-sm" onClick={() => navigate(backPath)} aria-label="返回">
<ArrowLeft className="size-4" />
</Button>
<div className="min-w-0">
<h1 className="text-base font-semibold">智能新增报告结构</h1>
<p className="text-xs text-muted-foreground">
左侧用问答描述需求,出现确认卡后点「应用到表单」;也可直接在右侧手工填写。
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm" onClick={() => navigate(backPath)} disabled={saving}>
取消
</Button>
<Button size="sm" onClick={() => void handleSave()} disabled={saving}>
{saving && <Loader2 className="mr-1.5 size-4 animate-spin" />}
保存
</Button>
</div>
</header>
<div className="flex min-h-0 flex-1">
<div className="min-w-0 flex-[7] border-r border-border">
<ReportStructureSetupChat onApprove={applyProposal} />
</div>
<aside className="flex min-w-0 flex-[3] flex-col overflow-hidden">
<div className="shrink-0 border-b border-border px-5 py-3">
<h2 className="text-sm font-semibold">报告结构配置</h2>
<p className="text-xs text-muted-foreground">对话确认后自动填充,也可随时改。</p>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-5">
<ReportStructureFormFields value={form} onChange={setForm} />
</div>
</aside>
</div>
</div>
);
}
@@ -0,0 +1,232 @@
"use client";
import { useMemo, useState } from "react";
import { X } from "lucide-react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useSkills } from "@/core/skills/hooks";
import { desensitize } from "@/lib/desensitize";
import {
type ReportStructureInput,
type ReportStructureMode,
MAX_RETRIEVAL_DIRECTIONS,
MAX_RETRIEVAL_DIRECTION_CHARS,
MAX_RETRIEVAL_SKILLS,
REPORT_STRUCTURE_MODE_OPTIONS,
normalizeReportStructureMode,
normalizeRetrievalDirections,
normalizeRetrievalSkills,
} from "@/strategy-components/api/report-structures";
export type ReportStructureFormValue = ReportStructureInput;
export const EMPTY_REPORT_STRUCTURE_FORM: ReportStructureFormValue = {
title: "",
name: "",
content: "",
structureMode: "adaptive",
retrievalDirections: [],
retrievalSkills: [],
};
function skillChipLabel(name: string, nameZh?: string | null) {
const zh = nameZh?.trim();
return zh || desensitize(name);
}
function RetrievalSkillPicker({
value,
onChange,
}: {
value: string[];
onChange: (next: string[]) => void;
}) {
const { skills, isLoading } = useSkills();
const selected = normalizeRetrievalSkills(value);
const byName = useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]);
const remaining = useMemo(
() => skills.filter((skill) => skill.enabled !== false && !selected.includes(skill.name)),
[skills, selected],
);
const atCap = selected.length >= MAX_RETRIEVAL_SKILLS;
return (
<div>
<span className="mb-1.5 block text-sm font-medium">检索来源</span>
<div className="flex flex-wrap gap-1.5">
{selected.map((name) => (
<span
key={name}
className="inline-flex max-w-full items-center gap-1 rounded-full border border-border bg-muted/50 px-2 py-0.5 text-xs"
>
<span className="truncate">{skillChipLabel(name, byName.get(name)?.name_zh)}</span>
<button
type="button"
aria-label={`移除检索技能 ${name}`}
className="rounded-full p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={() => onChange(selected.filter((item) => item !== name))}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
<Select
key={selected.join("|")}
onValueChange={(name) => {
if (!name || atCap) return;
onChange(normalizeRetrievalSkills([...selected, name]));
}}
disabled={atCap || remaining.length === 0}
>
<SelectTrigger aria-label="添加检索技能" className="mt-2">
<SelectValue placeholder={isLoading ? "正在加载技能…" : atCap ? "已达上限" : "选择技能添加到检索来源"} />
</SelectTrigger>
<SelectContent>
{remaining.map((skill) => (
<SelectItem key={skill.name} value={skill.name}>
{skillChipLabel(skill.name, skill.name_zh)}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="mt-1 block text-[11px] text-muted-foreground">
选填。指定后,搜集素材会调用这些技能检索;不填则仍由收集员使用默认联网/知识库工具。
</span>
</div>
);
}
export function ReportStructureFormFields({
value,
onChange,
}: {
value: ReportStructureFormValue;
onChange: (next: ReportStructureFormValue) => void;
}) {
const [directionDraft, setDirectionDraft] = useState("");
const set = <K extends keyof ReportStructureFormValue>(key: K, next: ReportStructureFormValue[K]) =>
onChange({ ...value, [key]: next });
const commitDirectionDraft = () => {
const next = normalizeRetrievalDirections([...value.retrievalDirections, directionDraft]);
set("retrievalDirections", next);
setDirectionDraft("");
};
return (
<div className="space-y-3">
<label className="block">
<span className="mb-1.5 block text-sm font-medium">标题</span>
<Input
value={value.title}
onChange={(event) => set("title", event.target.value)}
maxLength={256}
placeholder="例如:行业研究通用结构"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">描述</span>
<Input
value={value.name}
onChange={(event) => set("name", event.target.value)}
maxLength={256}
placeholder="可选,一句话描述这套结构的适用场景"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">结构约束</span>
<Select
value={value.structureMode}
onValueChange={(next) => set("structureMode", normalizeReportStructureMode(next) as ReportStructureMode)}
>
<SelectTrigger aria-label="结构约束">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_STRUCTURE_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="mt-1 block text-[11px] text-muted-foreground">
{REPORT_STRUCTURE_MODE_OPTIONS.find((option) => option.value === value.structureMode)?.description}
</span>
</label>
<div>
<span className="mb-1.5 block text-sm font-medium">检索方向</span>
<div className="flex flex-wrap gap-1.5">
{value.retrievalDirections.map((item) => (
<span
key={item}
className="inline-flex max-w-full items-center gap-1 rounded-full border border-border bg-muted/50 px-2 py-0.5 text-xs"
>
<span className="truncate">{item}</span>
<button
type="button"
aria-label={`移除检索方向 ${item}`}
className="rounded-full p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={() =>
set(
"retrievalDirections",
value.retrievalDirections.filter((entry) => entry !== item),
)
}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
<Input
value={directionDraft}
onChange={(event) => setDirectionDraft(event.target.value)}
maxLength={MAX_RETRIEVAL_DIRECTION_CHARS}
placeholder="输入后按回车添加,可写多个;用作拆词方向,不是直接搜索的词"
className="mt-2"
disabled={value.retrievalDirections.length >= MAX_RETRIEVAL_DIRECTIONS}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
commitDirectionDraft();
}
}}
onBlur={() => {
if (directionDraft.trim()) commitDirectionDraft();
}}
/>
<span className="mt-1 block text-[11px] text-muted-foreground">
选填。指定后,收集员会按这些方向拆分具体检索词,而不是把方向名称直接拿去搜;不填则仍由收集员自行拆词。
</span>
</div>
<RetrievalSkillPicker
value={value.retrievalSkills}
onChange={(next) => set("retrievalSkills", next)}
/>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">结构内容</span>
<Textarea
value={value.content}
onChange={(event) => set("content", 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>
);
}
@@ -0,0 +1,238 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Sparkles, Zap } from "lucide-react";
import { toast } from "sonner";
import {
PromptInput,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
} from "@/components/ai-elements/prompt-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import {
MessageList,
type SetupReportStructureApprovalPayload,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { extractContentFromMessage, extractReasoningContentFromMessage, hasToolCalls } from "@/core/messages/utils";
import { getAPIClient } from "@/core/api";
import { useModels } from "@/core/models/hooks";
import { useSkills } from "@/core/skills/hooks";
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
import { cn } from "@/lib/utils";
import { ReportStructureWelcome } from "./ReportStructureWelcome";
/**
* 报告结构智能新增的左侧对话(对标 WritingSetupChat / 新建智能体)。
*
* context 带 ``is_report_structure_setup``,后端只挂 setup_report_structure + ask_clarification。
* 模型提交后被拦截成确认卡;点「应用到表单」回填右侧,不落库。
*/
export function ReportStructureSetupChat({
onApprove,
}: {
onApprove: (payload: SetupReportStructureApprovalPayload) => void;
}) {
const threadId = useMemo(() => uuid(), []);
const [threadPrepared, setThreadPrepared] = useState(false);
const [chatModel, setChatModel] = useState("");
const [quickMode, setQuickMode] = useState(false);
const { models, isLoading: modelsLoading } = useModels();
const { skills } = useSkills();
useEffect(() => {
if (models.length === 0) return;
setChatModel((prev) => (prev && models.some((m) => m.name === prev) ? prev : models[0]!.name));
}, [models]);
const validRetrievalSkills = useMemo(
() =>
skills
.filter((skill) => skill.enabled !== false)
.slice(0, 40)
.map((skill) => ({ name: skill.name, name_zh: skill.name_zh ?? "" })),
[skills],
);
const [thread, sendMessage] = useThreadStream({
threadId,
context: {
mode: "flash",
is_report_structure_setup: true,
writing_setup_quick: quickMode,
valid_retrieval_skills: validRetrievalSkills,
model_name: chatModel || undefined,
},
});
const ensureThreadExists = useCallback(async () => {
if (threadPrepared) return;
await getAPIClient().threads.create({
threadId,
metadata: { thread_type: "report_structure_setup", system: true },
});
setThreadPrepared(true);
}, [threadId, threadPrepared]);
const handleChatSubmit = useCallback(
async (text: string) => {
if (thread.isLoading) {
void thread.stop();
return;
}
const trimmed = text.trim();
if (!trimmed) return;
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
[thread, ensureThreadExists, sendMessage, threadId],
);
const handleClarificationSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
[thread, ensureThreadExists, sendMessage, threadId],
);
const submitStatus = thread.error ? "error" : thread.isLoading ? "streaming" : "ready";
const handleApprove = useCallback(
async (payload: SetupReportStructureApprovalPayload) => {
onApprove(payload);
toast.success("已应用到表单,可继续编辑后保存");
},
[onApprove],
);
const handleReject = useCallback(async () => {
await handleChatSubmit("这份配置先不应用,请继续帮我调整。");
}, [handleChatSubmit]);
const showWelcome = thread.messages.length === 0 && !thread.isLoading;
const showThinkingPlaceholder = useMemo(() => {
if (!thread.isLoading) return false;
const last = thread.messages[thread.messages.length - 1];
if (!last || last.type !== "ai") return true;
const hasOutput =
!!extractContentFromMessage(last).trim() ||
!!extractReasoningContentFromMessage(last) ||
hasToolCalls(last);
return !hasOutput;
}, [thread.isLoading, thread.messages]);
return (
<ThreadContext.Provider value={{ thread }}>
<ArtifactsProvider>
<div className="flex size-full flex-col bg-background dark:bg-transparent">
{!showWelcome && (
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
<Sparkles className="size-4 text-primary" />
<h2 className="text-sm font-semibold">结构助手</h2>
<span className="text-muted-foreground text-xs">描述你想要的报告结构,我来帮你填好右侧表单</span>
</header>
)}
<div className="min-h-0 flex-1">
{showWelcome ? (
<ReportStructureWelcome />
) : (
<MessageList
className="size-full"
threadId={threadId}
thread={thread}
onClarificationSubmit={handleClarificationSubmit}
onSetupReportStructureApprove={handleApprove}
onSetupReportStructureReject={handleReject}
/>
)}
</div>
{showThinkingPlaceholder && (
<div className="shrink-0 border-t bg-background px-4 py-2 dark:bg-transparent">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="inline-block size-1.5 animate-pulse rounded-full bg-blue-500" />
正在整理…
</div>
</div>
)}
<div className="bg-background dark:bg-transparent shrink-0 border-t px-4 py-4">
<div className="space-y-3 rounded-2xl border border-[#ece5e4] bg-card p-3 shadow-sm dark:border-[#1B477E] dark:bg-[#022248]/40">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<Sparkles className="size-3.5 shrink-0 text-primary" />
<span className="shrink-0">当前对话模型</span>
{modelsLoading || models.length === 0 ? (
<div className="border-input bg-background text-muted-foreground flex h-7 min-w-[9rem] items-center rounded-md border px-2 text-xs shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
{modelsLoading ? "加载中…" : "暂无模型"}
</div>
) : (
<Select value={chatModel} onValueChange={setChatModel} disabled={thread.isLoading}>
<SelectTrigger
className={cn(
"border-input bg-background h-7 min-w-[9rem] max-w-[14rem] px-2 text-xs shadow-xs outline-none",
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
)}
>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.name} value={model.name}>
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<label
className={cn(
"flex shrink-0 cursor-pointer select-none items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition",
quickMode
? "border-primary/40 bg-primary/5 text-primary dark:border-[#1B477E] dark:bg-[#022248]"
: "border-input bg-background text-muted-foreground dark:border-[#1B477E] dark:bg-[#022248]",
)}
title="开启后跳过逐项澄清,给出主题即直接生成大纲"
>
<Zap className={cn("size-3.5 shrink-0", quickMode && "fill-current")} />
<span className="shrink-0">快捷模式</span>
<Switch
checked={quickMode}
onCheckedChange={setQuickMode}
disabled={thread.isLoading}
className="ml-0.5 scale-90"
/>
</label>
</div>
<PromptInput onSubmit={({ text }) => void handleChatSubmit(text)}>
<PromptInputTextarea placeholder="例如:帮我建一套市场分析报告结构,固定大纲,检索方向是各品牌汽车销量" />
<PromptInputFooter className="justify-end">
<PromptInputSubmit status={submitStatus} size="icon-sm" />
</PromptInputFooter>
</PromptInput>
</div>
</div>
</div>
</ArtifactsProvider>
</ThreadContext.Provider>
);
}
@@ -0,0 +1,68 @@
import { Compass, ListTree, Sparkles } from "lucide-react";
/**
* 报告结构智能新增页左栏欢迎屏(对标 AI 写作 WritingWelcome)。
* 仅在助手对话还没有任何消息时显示。
*/
const STEPS = [
{
n: "1",
title: "对话描述需求",
desc: "用一句话说你想要什么报告结构,例如「市场分析固定大纲,检索方向是各品牌销量」。",
icon: Sparkles,
iconClass: "bg-indigo-50 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300",
},
{
n: "2",
title: "确认大纲卡片",
desc: "助手会先给出 Markdown 大纲,确认后出现「应用到表单」卡片,一点即可填到右侧。",
icon: ListTree,
iconClass: "bg-amber-50 text-amber-600 dark:bg-amber-500/15 dark:text-amber-300",
},
{
n: "3",
title: "保存入库",
desc: "右侧表单可再改标题、约束、检索方向。点保存后回到工作流配置列表。",
icon: Compass,
iconClass: "bg-emerald-50 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-300",
},
];
export function ReportStructureWelcome() {
return (
<div className="size-full overflow-y-auto">
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col px-6 py-7">
<div className="flex flex-1 flex-col justify-center gap-6">
<div>
<h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white">
智能新增报告结构
</h2>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
左侧用对话把结构聊清楚,确认后填到右侧表单;也可以
<strong className="font-semibold text-slate-700 dark:text-white/80">直接在右侧手工填写</strong>
,再点保存。
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{STEPS.map((step) => (
<div
key={step.n}
className="flex flex-col gap-2 rounded-xl border border-slate-100/80 bg-slate-50/50 p-4 transition-colors hover:bg-slate-50 dark:border-white/5 dark:bg-white/[0.02] dark:hover:bg-white/5"
>
<div className={`flex size-8 items-center justify-center rounded-lg ${step.iconClass}`}>
<step.icon className="size-4" />
</div>
<h4 className="text-sm font-semibold text-slate-800 dark:text-white">
{step.n}. {step.title}
</h4>
<p className="text-xs leading-relaxed text-slate-500 dark:text-white/55">{step.desc}</p>
</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -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,
@@ -29,6 +30,7 @@ import { DocumentRewriteDialog } from "@/components/workspace/artifacts/document
import { ThreadContext } from "@/components/workspace/messages/context";
import type { AgentThreadState } from "@/core/threads";
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
import type { ReportStructureMode } from "@/strategy-components/api/report-structures";
import { cn } from "@/lib/utils";
const CLOSE_MODE = { chat: 100, artifacts: 0 };
@@ -56,9 +58,21 @@ interface ResearchSandboxPanelProps {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
reportOutline: string;
structureMode?: ReportStructureMode;
/** Regeneration forks the report session before it starts the rewrite. */
createNewReport?: boolean;
}) => void;
documentRewriteActive?: boolean;
/** Distinguishes a new report generation from an in-place full rewrite. */
documentRewriteOperation?: "rewrite" | "generate" | null;
onCancelDocumentRewrite?: () => void;
/** A completed-report card can request opening the same safe rewrite dialog. */
documentRewriteDialogRequest?: number;
/** Controls whether a requested dialog creates another report or rewrites this one. */
documentRewriteDialogPurpose?: "rewrite" | "regenerate";
/** Outline selected from the research workflow, used to preselect a saved structure. */
initialDocumentRewriteOutline?: string;
reportCharCount?: number;
}
@@ -72,12 +86,18 @@ export function ResearchSandboxPanel({
onDownload,
onDocumentRewriteStart,
documentRewriteActive = false,
documentRewriteOperation = null,
onCancelDocumentRewrite,
documentRewriteDialogRequest = 0,
documentRewriteDialogPurpose = "rewrite",
initialDocumentRewriteOutline,
reportCharCount = 0,
}: ResearchSandboxPanelProps) {
const layoutRef = useRef<GroupImperativeHandle>(null);
const { selectedArtifact, setOpen, deselect } = useArtifacts();
const [rewriteDialogOpen, setRewriteDialogOpen] = useState(false);
const [rewriteDialogPurpose, setRewriteDialogPurpose] = useState<"rewrite" | "regenerate">("rewrite");
const generatingNewReport = documentRewriteOperation === "generate";
useEffect(() => {
if (!layoutRef.current) return;
@@ -107,6 +127,13 @@ export function ResearchSandboxPanel({
onClose();
};
useEffect(() => {
if (documentRewriteDialogRequest > 0) {
setRewriteDialogPurpose(documentRewriteDialogPurpose);
setRewriteDialogOpen(true);
}
}, [documentRewriteDialogPurpose, documentRewriteDialogRequest]);
return (
<ThreadContext.Provider value={{ thread: threadStub, isMock: false }}>
<ResizablePanelGroup
@@ -134,24 +161,12 @@ export function ResearchSandboxPanel({
id="artifacts"
>
<div className="relative h-full bg-[#fffdfa] p-4">
{selectedArtifact && previewThreadId ? (
{selectedArtifact && previewThreadId && stubMessages.length > 0 ? (
<>
{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}>
停止改写
停止{generatingNewReport ? "生成" : "改写"}
</Button>
</div>
)}
@@ -159,8 +174,32 @@ 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
? generatingNewReport
? "正在生成新报告"
: "正在全文改写"
: "AI 全文改写"}
tooltip={
documentRewriteActive
? generatingNewReport
? "正在生成独立的新报告,当前报告不会被覆盖"
: "正在生成临时草稿,原报告尚未被覆盖"
: "使用 AI 重写完整 report.md"
}
onClick={() => {
setRewriteDialogPurpose("rewrite");
setRewriteDialogOpen(true);
}}
/>
) : null
}
/>
</>
) : (
@@ -173,7 +212,11 @@ export function ResearchSandboxPanel({
<ConversationEmptyState
icon={<FileTextIcon />}
title="report.md"
description="报告开始撰写后将在此实时预览"
description={
documentRewriteActive && generatingNewReport
? "正在生成新报告,正文将在模型首个字符输出后显示"
: "报告开始撰写后将在此实时预览"
}
/>
</div>
)}
@@ -186,9 +229,30 @@ export function ResearchSandboxPanel({
onOpenChange={setRewriteDialogOpen}
fileName="report.md"
charCount={reportCharCount}
initialReportOutline={initialDocumentRewriteOutline}
operationLabel={rewriteDialogPurpose === "regenerate" ? "确认新报告生成要求" : undefined}
operationDescription={
rewriteDialogPurpose === "regenerate"
? "请确认新报告的结构、资料范围和生成要求;确认后会创建一篇独立的新报告,当前报告不会被覆盖。"
: undefined
}
submitLabel={rewriteDialogPurpose === "regenerate" ? "确认并生成新报告" : undefined}
operation={rewriteDialogPurpose === "regenerate" ? "generate" : "rewrite"}
onRewriteOriginal={rewriteDialogPurpose === "regenerate"
? (input) => {
setRewriteDialogOpen(false);
onDocumentRewriteStart({
...input,
createNewReport: false,
});
}
: undefined}
onStart={(input) => {
setRewriteDialogOpen(false);
onDocumentRewriteStart(input);
onDocumentRewriteStart({
...input,
createNewReport: rewriteDialogPurpose === "regenerate",
});
}}
/>
)}
Loaded 100 of 262 files, more files were not shown because too many files have changed in this diff. Show more