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:
commit
e818c24769
262 files changed
+29527
-5236
No files matched your search
+47
-36
@@ -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
|
||||
@@ -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/`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -56,4 +56,7 @@
|
||||
# VITE_STATIC_WEBSITE_ONLY=
|
||||
# 部署级紧凑导航:true 时固定收起工作台左侧栏,并隐藏换肤与全量/简洁模式切换。
|
||||
# VITE_FORCE_COLLAPSED_SIDEBAR=false
|
||||
# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。
|
||||
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。
|
||||
# VITE_COMPACT_MENU_TYPE=A
|
||||
# VITE_GITHUB_OAUTH_TOKEN=
|
||||
File diff suppressed because it is too large.
Load diff
@@ -32,10 +32,10 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
VITE_BRAND_WELCOME_DARK_URL: "",
|
||||
|
||||
// DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。
|
||||
// VITE_BACKEND_BASE_URL: "http://10.19.200.5:2026",
|
||||
// VITE_BACKEND_BASE_URL: "https://ch1.b.uat.4.cn/deerflow",
|
||||
|
||||
// LangGraph 流式对话接口地址:普通问答、智能体对话等流式能力使用。
|
||||
// VITE_LANGGRAPH_BASE_URL: "http://10.19.200.5:2026/api",
|
||||
// VITE_LANGGRAPH_BASE_URL: "https://ch1.b.uat.4.cn/deerflow/api",
|
||||
|
||||
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
|
||||
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
|
||||
@@ -50,6 +50,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")}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -41,5 +41,10 @@ export const env = {
|
||||
* flag so operations can switch the shell without rebuilding the frontend.
|
||||
*/
|
||||
FORCE_COLLAPSED_SIDEBAR: readRuntimeFlag("VITE_FORCE_COLLAPSED_SIDEBAR"),
|
||||
/**
|
||||
* Compact-menu variant for this website. Pair with the admin 简介模式
|
||||
* type (A / B) so two deployments can show different left-nav items.
|
||||
*/
|
||||
COMPACT_MENU_TYPE: readRuntimeEnv("VITE_COMPACT_MENU_TYPE"),
|
||||
GITHUB_OAUTH_TOKEN: readRuntimeEnv("VITE_GITHUB_OAUTH_TOKEN"),
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Generate a client-only id without assuming a secure browser context.
|
||||
*
|
||||
* `crypto.randomUUID()` is unavailable in several browsers when the app is
|
||||
* opened directly through an `http://<public-ip>:<port>` address. Request and
|
||||
* UI ids need uniqueness, not a security token, so a timestamp/random fallback
|
||||
* keeps those flows usable until the deployment is served over HTTPS.
|
||||
*/
|
||||
export function createClientId(prefix?: string): string {
|
||||
const cryptoApi = typeof globalThis === "object" ? globalThis.crypto : undefined;
|
||||
let id: string | undefined;
|
||||
|
||||
if (typeof cryptoApi?.randomUUID === "function") {
|
||||
id = cryptoApi.randomUUID();
|
||||
} else if (typeof cryptoApi?.getRandomValues === "function") {
|
||||
try {
|
||||
const values = new Uint32Array(4);
|
||||
cryptoApi.getRandomValues(values);
|
||||
id = Array.from(values, (value) => value.toString(16).padStart(8, "0")).join("");
|
||||
} catch {
|
||||
// Some restricted browser contexts expose crypto but reject random data.
|
||||
}
|
||||
}
|
||||
|
||||
id ??= `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
|
||||
return prefix ? `${prefix}-${id}` : id;
|
||||
}
|
||||
@@ -119,6 +119,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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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('深链跳转失败,已进入首页');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user