Compare commits
26
Commits
gzy_browser
...
master-yx
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19d3cb96dc | ||
|
|
e243ec3a46 | ||
|
|
f4c7d41a60 | ||
|
|
d765afe2ac | ||
|
|
4f7f07504a | ||
|
|
1244fb1f2f | ||
|
|
3ca5528640 | ||
|
|
250db4de04 | ||
|
|
f9b6d7e322 | ||
|
|
6edb6fa7aa | ||
|
|
cf9fe58905 | ||
|
|
56bb4f141d | ||
|
|
75f288b241 | ||
|
|
bf42a48fcc | ||
|
|
5e953c9fcb | ||
|
|
814a44f0b9 | ||
|
|
b80433f091 | ||
|
|
5c75e1af16 | ||
|
|
438772f0b7 | ||
|
|
dea4697ea0 | ||
|
|
7dbac9cc56 | ||
|
|
e58da3fbdf | ||
|
|
60e2c72514 | ||
|
|
1eae02f782 | ||
|
|
db333a4c33 | ||
|
|
f109a95535 |
No files matched your search
+27
-16
@@ -88,20 +88,25 @@ skills/
|
||||
hermes-agent-main/
|
||||
.gitignore
|
||||
offline-backend-20260512/backend/tests/*
|
||||
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_gauss_database.py
|
||||
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_gauss_database.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py
|
||||
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
||||
# Keep formal Word OOXML and embedded-font regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_word_export.py
|
||||
# Keep generated-file library aggregation and filtering regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_artifact_library.py
|
||||
# Keep generated-file library aggregation and filtering regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_artifact_library.py
|
||||
# Keep the model configuration regression coverage versioned even though
|
||||
# this deployment repository otherwise ignores newly added backend tests.
|
||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
||||
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
|
||||
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
||||
# Keep vLLM reasoning/reasoning_content field compatibility regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_reasoning_fields.py
|
||||
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
|
||||
# Keep strategy-selection built-in agent seed and safety-contract regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_strategy_selection_seed.py
|
||||
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_fixed_questions.py
|
||||
# Keep configurable es_query system-prompt routing regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py
|
||||
@@ -109,6 +114,7 @@ offline-backend-20260512/backend/tests/*
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_encoder_runtime.py
|
||||
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
|
||||
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
|
||||
@@ -122,25 +128,28 @@ offline-backend-20260512/backend/tests/*
|
||||
# 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
|
||||
# Roundtable coordinator/seat capability isolation regressions.
|
||||
!offline-backend-20260512/backend/tests/test_roundtable_role_boundary.py
|
||||
# Workflow Studio: schemas / definitions / engine / run-lifecycle regressions
|
||||
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
|
||||
# Roundtable coordinator/seat capability isolation regressions.
|
||||
!offline-backend-20260512/backend/tests/test_roundtable_role_boundary.py
|
||||
# Workflow Studio: schemas / definitions / engine / run-lifecycle regressions
|
||||
# plus the concurrency remediation suite (atomic resume, draft CAS, publish
|
||||
# counter, event seq) — required in CI per docs/WORKFLOW_STUDIO_BACKEND_REMEDIATION_ZH.md P1-06.
|
||||
!offline-backend-20260512/backend/tests/test_workflow_schemas.py
|
||||
!offline-backend-20260512/backend/tests/test_workflow_definitions.py
|
||||
!offline-backend-20260512/backend/tests/test_workflow_engine.py
|
||||
!offline-backend-20260512/backend/tests/test_workflow_runs.py
|
||||
!offline-backend-20260512/backend/tests/test_workflow_concurrency.py
|
||||
!offline-backend-20260512/backend/tests/test_workflow_schema_rebuild.py
|
||||
# Writing-mode first-report pipeline enforcement regressions.
|
||||
!offline-backend-20260512/backend/tests/test_workflow_engine.py
|
||||
!offline-backend-20260512/backend/tests/test_workflow_runs.py
|
||||
!offline-backend-20260512/backend/tests/test_workflow_concurrency.py
|
||||
!offline-backend-20260512/backend/tests/test_workflow_schema_rebuild.py
|
||||
# Writing-mode first-report pipeline enforcement regressions.
|
||||
!offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py
|
||||
!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py
|
||||
# LLM call timeout retry classification regression coverage.
|
||||
!offline-backend-20260512/backend/tests/test_llm_error_handling_middleware.py
|
||||
# Writing-mode pipeline conversation-material regressions.
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_report_tool.py
|
||||
# Report collaboration (RC-BE-000–018) regression coverage.
|
||||
!offline-backend-20260512/backend/tests/test_report_collaboration_*.py
|
||||
!offline-backend-20260512/backend/tests/test_ensure_orm_columns_sync.py
|
||||
deploy
|
||||
.history
|
||||
|
||||
@@ -175,6 +184,8 @@ docs/backups
|
||||
# uv lockfile (not tracked per project preference)
|
||||
offline-backend-20260512/backend/uv.lock
|
||||
|
||||
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
|
||||
|
||||
# Local-only scratch directory (not committed)
|
||||
0622/
|
||||
|
||||
|
||||
@@ -96,13 +96,13 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:<stage>` 窗口事件强制重放(忽略已看过记忆)。
|
||||
- **敏感词管理 (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). **`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`.
|
||||
- **按钮管理 (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, or action id when `idKind:"action"` — same selector as url buttons, xdfx 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 **or host** 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/`
|
||||
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
|
||||
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations staLine truncated
|
||||
- **agentfx 任务深链(新系统 iframe 嵌入的智能体问答,3qfx 单智能体 lite 模式的独立复制)**: goPath=`agentfx`(`${loginBase}?password=…&taskId=…&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue`)。与 3qfx 的差异:①任务详情不走外部 consumer,改调**本系统 Gateway** `GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=`(`apiFetch` 带刚登录的 DeerFlow 态;任务数据来自 TaskCOP 任务表,与任务列表/报告导入同一份);②**固定使用内置智能体** `agentfx-analyst`(不走业务映射);③路由 `task-management/plan-task-agentfx/agent/:agent_id/chats`,页面为独立复制的 `AgentFxWorkspace.tsx`(`AgentFxWorkspace`/`AgentFxIndexResolver`/`AgentFxChat` —— 左「当前任务」卡 + 1:N 对话列表 + 新建对话,`AgentChatPage taskBind` 自动发开场白;无 TaskJumpBar/清除记录/闸门/xdfx 行动卡)。模块 `core/auth/agentfx-deeplink.ts`(`isAgentfxGoPath`/`fetchAgentfxTaskDetail`/`resolveAgentfxDeeplinkTarget`);任务详情整包仍存共享键 `login.copTaskDetail`(按标签页隔离),`AgentChatPage` 的 taskBind 自恢复对 agentfx 生效。`LoginPage` 在既有深链分支前用 `isAgentfxGoPath` 分流。**内置「任务研判报告助手」**:后端 seeder `app/gateway/routers/_agentfx_seed.py` + `_agentfx_seed_assets/`(config.yaml + SOUL.md,目录存在不覆盖;app.py lifespan 在 `_sync_legacy_agents` 之前调用,自动进 agents 表 user_id IS NULL)——SOUL 规定工作流「搜集信息(`web_search`,**首步不可跳过**)→ `write_file` 生成 `/mnt/user-data/outputs/report.md` + `task-reports.json`(14 个 categoryType 的 `{contentJson, categoryType}` 数组,contentJson 为 JSON 字符串)→ 聊天总结 → 引导入库」,硬性约束:categoryType 逐字用 14 个全称(禁缩写自造如「DQ关键事件」)、JSON 直接写汉字禁 `\uXXXX` 转义、每类必须有实质内容不许空数组/纯占位、文件一律用 `/mnt/user-data/...` 虚拟路径(防宿主路径泄漏进 tool args)。**检索依赖 `config.yaml tools.web_search`(DDG,enabled 已开;内网需换 configurable_search 时改回并填 endpoint)**。任务工作区输入框在 `taskCtxOn` 时隐藏「写作/记忆/参考文献」(InputBox `hideContextTools` prop)与「知识空间」(不传 knowledgeSelector);消息流里 write_file 文件卡片只显示文件名(`artifactDisplayName` 收敛宿主/虚拟路径)。**入库两条路径**:(a) **前端协助卡** —— `AgentChatPage`(taskBind 且非 taskGate)检测线程 artifacts 出现 `task-reports.json`(按文件名后缀匹配,兼容本地沙箱记录的宿主路径)且流式结束时,向 displayThread 尾部注入 `additional_kwargs.task_report_import_approval` 虚拟 ai 消息(`core/messages/utils.ts` 分组 `assistant:task-report-import` + `MessageList` 的 `taskReportImportSlot` 插槽渲染 `TaskReportImportCard`),点「开始入库」前端校验(`strategy-components/lib/agentfx-report-schema.ts`,14 类 + contentJson 合法 JSON + **\u 转义归一化还原为汉字**,与技能脚本同规则)后 `importTaskReport`(`strategy-components/api/task-reports.ts`)POST 导入接口整体替换;任务已有详情**不抑制**卡片(入库即整体替换,卡片文案说明覆盖语义),导入成功按 thread 记 sessionStorage(刷新不重复询问)、「暂不入库」为会话内记忆;(b) **入库技能** `task-report-import`(seeder 一并复制到 `skills/public/`,public 类技能 extensions_config 缺省即启用)—— 用户聊天里说「入库」时智能体按 SKILL.md 用 bash 跑 `scripts/task_report_import.py`(纯标准库:同样的校验规则 + 转义归一化;**默认关闭 SSL 证书校验** `ssl._create_unverified_context` + 忽略代理,适配内网自签证书;鉴权 `--token`/env `AGENTFX_IMPORT_TOKEN`→`DEERFLOW_AUTH_TOKEN`→`TOOLS_TOKEN` 优先,缺省经公共端点 `POST /api/parallel-agents/auth/token` 以 `agentfx-import` 用户现签 token——内网既有鉴权旁路;gateway 地址 env `AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:{DEER_FLOW_GATEWAY_PORT}`);`--strict` 可要求 14 类齐全,默认允许部分导入并在 stdout JSON 的 `missing_categories` 里列缺。入库成功后任务 TaskCOP 状态自动置 25(已完成分析)。**入库成功通知宿主**(iframe 嵌入场景):两条路径成功后都会 `window.top.postMessage({source: "magent-web", type: "task-report:saved", taskId: String(taskId)}, "*")`(`notifyTaskReportSaved`,`agentfx-report-schema.ts`)——卡片路径在 `TaskReportImportCard` 导入成功回调里直发;技能路径由 `AgentChatPage` 从消息流检测 `task_report_import.py` 的成功 stdout(`"success": true` + `task_id`)代发——**仅在回合结束后检测**(`!thread.isLoading` 才扫描;流式过程中 messages Line truncated
|
||||
- **agentfx 任务深链(新系统 iframe 嵌入的智能体问答,3qfx 单智能体 lite 模式的独立复制)**: goPath=`agentfx`(`${loginBase}?password=…&taskId=…&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue`)。与 3qfx 的差异:①任务详情不走外部 consumer,改调**本系统 Gateway** `GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=`(`apiFetch` 带刚登录的 DeerFlow 态;任务数据来自 TaskCOP 任务表,与任务列表/报告导入同一份);②**固定使用内置智能体** `agentfx-analyst`(不走业务映射);③路由 `task-management/plan-task-agentfx/agent/:agent_id/chats`,页面为独立复制的 `AgentFxWorkspace.tsx`(`AgentFxWorkspace`/`AgentFxIndexResolver`/`AgentFxChat` —— 左「当前任务」卡 + 1:N 对话列表 + 新建对话,`AgentChatPage taskBind` 自动发开场白;无 TaskJumpBar/清除记录/闸门/xdfx 行动卡)。模块 `core/auth/agentfx-deeplink.ts`(`isAgentfxGoPath`/`fetchAgentfxTaskDetail`/`resolveAgentfxDeeplinkTarget`);任务详情整包仍存共享键 `login.copTaskDetail`(按标签页隔离),`AgentChatPage` 的 taskBind 自恢复对 agentfx 生效。`LoginPage` 在既有深链分支前用 `isAgentfxGoPath` 分流。**内置「任务研判报告助手」**:后端 seeder `app/gateway/routers/_agentfx_seed.py` + `_agentfx_seed_assets/`(config.yaml + SOUL.md,目录存在不覆盖;app.py lifespan 在 `_sync_legacy_agents` 之前调用,自动进 agents 表 user_id IS NULL)——SOUL 规定工作流「搜集信息(`web_search`,**首步不可跳过**)→ 每次检索 JSON 原样落盘 `/mnt/user-data/workspace/hits/qN.json` → **一条命令** `task-report-build`(group `all`)技能函数转换成 14 类 `task-reports.json`(模型禁止手写该 JSON;分页技能 `task-report-{enemy,our,env,judge}` 仅用于事后补跑单页;五者都是共享引擎 `task-report-lib/convert_lib.py` 的瘦 CLI,`resolve_hit_files` 对虚拟绝对路径读空的情况自动回退到相对写法并在 `warnings` 里列出尝试过的路径,免得智能体自己写脚本排查)→ `write_file` 生成 `/mnt/user-data/outputs/report.md` → 聊天总结 → 引导入库」,硬性约束:categoryType 逐字用 14 个全称(禁缩写自造如「DQ关键事件」)、JSON 直接写汉字禁 `\uXXXX` 转义、每类必须有实质内容不许空数组/纯占位、文件一律用 `/mnt/user-data/...` 虚拟路径(防宿主路径泄漏进 tool args)。**检索依赖 `config.yaml tools.web_search`(DDG,enabled 已开;内网需换 configurable_search 时改回并填 endpoint)**。任务工作区输入框在 `taskCtxOn` 时隐藏「写作/记忆/参考文献」(InputBox `hideContextTools` prop)与「知识空间」(不传 knowledgeSelector);消息流里 write_file 文件卡片只显示文件名(`artifactDisplayName` 收敛宿主/虚拟路径)。**入库两条路径**:(a) **前端协助卡** —— `AgentChatPage`(taskBind 且非 taskGate)检测线程 artifacts 出现 `task-reports.json`(按文件名后缀匹配,兼容本地沙箱记录的宿主路径)且流式结束时,向 displayThread 尾部注入 `additional_kwargs.task_report_import_approval` 虚拟 ai 消息(`core/messages/utils.ts` 分组 `assistant:task-report-import` + `MessageList` 的 `taskReportImportSlot` 插槽渲染 `TaskReportImportCard`),点「开始入库」前端校验(`strategy-components/lib/agentfx-report-schema.ts`,14 类 + contentJson 合法 JSON + **\u 转义归一化还原为汉字**,与技能脚本同规则)后 `importTaskReport`(`strategy-components/api/task-reports.ts`)POST 导入接口整体替换;任务已有详情**不抑制**卡片(入库即整体替换,卡片文案说明覆盖语义),导入成功按 thread 记 sessionStorage(刷新不重复询问)、「暂不入库」为会话内记忆;(b) **入库技能** `task-report-import`(seeder 一并复制到 `skills/public/`,public 类技能 extensions_config 缺省即启用)—— 用户聊天里说「入库」时智能体按 SKILL.md 用 bash 跑 `scripts/task_report_import.py`(纯标准库:同样的校验规则 + 转义归一化;**默认关闭 SSL 证书校验** `ssl._create_unverified_context` + 忽略代理,适配内网自签证书;鉴权 `--token`/env `AGENTFX_IMPORT_TOKEN`→`DEERFLOW_AUTH_TOKEN`→`TOOLS_TOKEN` 优先,缺省经公共端点 `POST /api/parallel-agents/auth/token` 以 `agentfx-import` 用户现签 token——内网既有鉴权旁路;gateway 地址 env `AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:{DEER_FLOW_GATEWAY_PORT}`);`--strict` 可要求 14 类齐全,默认允许部分导入并在 stdout JSON 的 `missing_categories` 里列缺。入库成功后任务 TaskCOP 状态自动置 25(已完成分析)。**入库成功通知宿主**(iframe 嵌入场景):两条路径成功后都会 `window.top.postMessage({source: "magent-web", type: "Line truncated
|
||||
- **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -83,6 +83,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
|
||||
- **定时任务「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`.
|
||||
- **策略优选**: route `/page/strategy/qa/strategy-selection`, sidebar 「问答管理 → 策略优选」. The standalone `src/strategy-selection/` workbench has two horizontal split stages: Step 1 keeps intent-understanding conversation cards on the left and an editable task draft plus angle selection on the right; Step 2 keeps multiplexed parallel strategy cards and automatic judging on the left while the right stays dedicated to the selected-strategy basket and final Markdown synthesis. It consumes the existing `POST /api/parallel-agents/run/stream` protocol, isolates output by stable agent id, supports expanded per-agent dialogs, user selection, Markdown editing/copy/download, and shared server-side DOCX export. A clearly marked neutral Taiwan-Strait risk-management demo is loaded by default for offline UI acceptance. Administrators can edit the SOUL and skills of the nine built-in functional agents through the page dialog; the backend seeds `strategy-selection-{intent,angle-*,judge,solution}` only when their directories are missing, preserving later admin changes. Pure parsing/SSE tests live in `src/strategy-selection/lib.test.ts`; backend seed tests live in `tests/test_strategy_selection_seed.py`.
|
||||
- **轻应用中心 (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). **简介模式** 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`.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Wiki 数据流转与本地编码验收(2026-09-03)
|
||||
|
||||
## 结论
|
||||
|
||||
本地 BGE-M3 小批量编码已安装、配置并实际运行。已有 Wiki 的向量化、自动沉淀、
|
||||
普通库文件导出、助手库异步导入、助手库文件导出、空普通库回流及两端向量检索已实测通过。
|
||||
**技能自动生成 Wiki 尚未完成验收**:WeKnora 的两个生成模型都返回 HTTP 402
|
||||
`Insufficient Balance`。不能把后半段通过等同于全链路通过。
|
||||
|
||||
本次修改运行在本地 DeerFlow 开发服务(前端 5175、后端 8001),连接用户提供的远端
|
||||
WeKnora;没有把业务代码发布到服务器,没有提交 Git,也没有直接写 WeKnora 图数据库。
|
||||
|
||||
## 本地编码配置
|
||||
|
||||
- 实现:FastEmbed / ONNX Runtime CPU,官方 `BAAI/bge-m3` 权重。
|
||||
- 配置名:`bge-embedding-m3`,1024 维,每批 2 条,2 个 CPU 推理线程。
|
||||
- 运行依赖:FastEmbed 随标准后端依赖安装;模型必须提前放在离线目录中,仍按需加载,不影响主服务启动。
|
||||
- 权重:约 2.3 GB,持久缓存 `C:\Users\ji\.cache\deerflow\embeddings`,不进入业务仓库。
|
||||
- 配置:`llmwiki.local_wiki_index.embedding.provider: local`;可用
|
||||
`local_model_path` 指定离线权重目录,详见后端 README。
|
||||
- 查询向量在本地实时编码。已有 Wiki 在内容哈希、模型指纹匹配且向量非空时直接复用。
|
||||
不同模型或维度不能混用;导入不静默重新编码。
|
||||
- 原始文档分段向量与模型生成后的 Wiki 文本不同,不能相互冒用。本次新建的是 Wiki
|
||||
正文的 BGE-M3 索引,不是把既有 512 维文档向量改名为 1024 维。
|
||||
|
||||
## 实际数据验收
|
||||
|
||||
来源是已有普通知识库“图片测试111”,没有改写其 Wiki 内容。
|
||||
|
||||
| 阶段 | Wiki | Wiki 向量 | 实体 | 引用关系 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| 普通知识库导出 | 19 | 101 | 19 | 108 |
|
||||
| 助手知识库导入后导出 | 19 | 101 | 19 | 108 |
|
||||
| 新建普通知识库回流后导出 | 19 | 101 | 19 | 108 |
|
||||
| 知识梳理总库导出(7 个来源记录) | 19 | 101 | 19 | 108 |
|
||||
|
||||
逐项断言通过:
|
||||
|
||||
1. 三份包的 Wiki slug、标题、正文、摘要、页面类型、别名、分类路径、出链一致。
|
||||
2. 101 条向量的原始 float32 字节、分段编号、维度和模型指纹完全一致。
|
||||
3. 将导入前后实体 ID 映射回原 Wiki slug 后,108 条关系的端点及谓词集合完全一致。
|
||||
4. 总库导出没有因多个来源重复同步而重复导出同一当前页面的分段向量。
|
||||
5. 元数据回归测试另覆盖 tags、目录恢复、原始文档片段不计入 Wiki 数量、原生技能无
|
||||
ownership 记录时可被选中、短 Wiki 不漏向量、模型指纹不兼容时不假装向量检索。
|
||||
|
||||
普通包约 1.01 MB,助手包约 0.79 MB,回流包约 0.83 MB。普通包还带 2 个原始文档及
|
||||
108 个原始片段;助手导出提供 Wiki、Wiki 向量和关系,不重新上传原始文档触发二次生成。
|
||||
|
||||
### 检索
|
||||
|
||||
实际查询:“军方用什么工具评估极端天气带来的风险?”
|
||||
|
||||
- 回流普通库 `/api/llmwiki/search` 返回 `mode=vector`,首条“国防气候评估工具”。
|
||||
- 助手库 `/api/assistant-knowledge/search` 返回 `retrieval_mode=vector`,首条同上;
|
||||
首个命中分段余弦相似度约 0.6343。
|
||||
- 浏览器实际提交总库检索,显示“向量检索 · 余弦相似度”和相同命中;不是只检查返回字段。
|
||||
- 普通库采用已有页面级聚合评分,因此页面最终分数与单分段余弦值不必相等。
|
||||
- 回流的向量写入 **DeerFlow 管理的本地 Wiki 索引**。未写入 WeKnora 原生向量数据库,
|
||||
本次结论适用于本系统问答检索,不能声称原生 WeKnora 自身的检索也已完成该向量导入。
|
||||
|
||||
### 页面
|
||||
|
||||
普通与助手库复用同一 Wiki 阅读组件:目录树、类型/标签筛选、分类面包屑、别名标签、
|
||||
正文内链、出链、反向链接及邻接关系视图。窄窗口可收起目录。
|
||||
|
||||
浏览器验证了“索引 → 国防气候评估工具”、展开关系视图、助手 Wiki 跳回普通库来源,
|
||||
以及普通 Wiki 的“在知识梳理中查看”反向跳转。
|
||||
|
||||
### 验收数据位置
|
||||
|
||||
- 原有普通库:`48f669dc-433e-4aab-b5ea-897b6f7a3d2f`
|
||||
- 知识梳理总库:`c3fd9841-0718-44dc-860e-aae3020fd485`
|
||||
- 最终助手测试库:`489be905-08c8-40fe-8d60-42ac21dd945a`
|
||||
- 最终普通回流测试库:`428162aa-03f6-4c61-8994-360f2f39e0c9`
|
||||
|
||||
新建测试库名称均以“Wiki链路验收-0903-1826”开头,保留用于复核;未删除用户已有库。
|
||||
本地 `.runtime/wiki-live-result.json` 保存验收结果,`.runtime/wiki_live_acceptance.py`
|
||||
保存本次验收流程,均为忽略的运行产物,不包含认证口令。
|
||||
|
||||
## 这次实际修复的问题
|
||||
|
||||
- 内置技能只有磁盘目录、没有归属记录时,被错误判定为不存在。
|
||||
- 技能应上传去代码后的知识正文,让 WeKnora 生成 Wiki,而不是伪造一个 `skill` 类型页面。
|
||||
- Wiki 生成等待使用实际 stats 接口;零页结果明确失败,不无期限等待。
|
||||
- 向量化完成后自动导出沉淀到总库,并阻断导出回调重复触发全量同步。
|
||||
- 短摘要和短正文同时存在时,旧分段阈值会将整页过滤为零向量。
|
||||
- 分类路径必须先创建 WeKnora folder 并绑定 folder_id;只传 category_path 会被服务端清空。
|
||||
- Wiki 的标签/分类/别名/原始引用保留;图节点绑定真实 Wiki,避免生成重复的空实体页面。
|
||||
- 保留归一化 float32 原始字节;导入中不能用 `section_index or index`,因为合法编号 0 会被改写。
|
||||
- 总库多个来源保留审计,但检索与导出只使用当前 Wiki 版本对应的向量,避免旧贡献污染检索。
|
||||
- 包解析改为同盘临时 SQLite,上传先落盘再异步导入,导出完成后才提供文件下载。
|
||||
|
||||
## 阻塞与未通过的范围
|
||||
|
||||
1. 技能 `knowledge-base-ingest` 已送入专门创建的普通测试库,但远端
|
||||
`deepseek-v4-flash` 和 `qwen3.6-flash` 均因余额不足失败。WeKnora 即使失败也可能把
|
||||
文档 parse_status 标为 completed。需恢复可用生成模型后重新运行技能全链路验收。
|
||||
2. 已采用磁盘暂存和有界批处理,但本次只有约 1 MB 实测,**未做数 GB 压测、断点续传、
|
||||
进程中断恢复验收**。大包部署仍需磁盘容量、代理 body size 和超时配置。
|
||||
3. 当前实体去重主要按类型及规范化名称;这次没有完成跨别名、歧义实体的模型对齐及
|
||||
多来源事实冲突合并验收,不能视为这一历史需求已全面完成。
|
||||
4. 重复上传仍会保留独立来源记录。向量导出已消除旧版本重复,但上传身份幂等、部分远端
|
||||
写入失败后的无损重试、跨实例图片资源打包仍需独立验证。
|
||||
5. 当前版本已补充自动增量链路:服务启动立即补扫,之后默认每 30 秒扫描全部普通
|
||||
知识库;Wiki 抽取完成后无需手工点击向量化,并自动以来源快照覆盖同步到知识梳理总库。
|
||||
6. 回流当前只允许新建空 Wiki 普通库,避免覆盖已有页面。普通库现有页面列表的超大规模
|
||||
分页与助手问答的最终生成回答不属于这轮通过结论。
|
||||
|
||||
## 自动化检查
|
||||
|
||||
- 后端相关测试:118 passed(assistant repository、Wiki sync、package roundtrip、
|
||||
WeKnora 接口、向量检索与知识库管理)。
|
||||
- 修改的 Python 功能文件 Ruff 检查通过;app.py 既有启动顺序导致的 E402 不在此次改动中处理。
|
||||
- `git diff --check` 通过(只有仓库换行提示)。
|
||||
- 前端完整 `pnpm typecheck` 未通过:57 个既有/本轮范围外错误,涉及 nextra 缺失、Markdown
|
||||
插件类型及其他模块;本轮新建/修改的 Wiki 组件、助手 API、技能面板没有出现在错误清单中。
|
||||
浏览器实际页面与跳转已验证,但不能声称全项目构建通过。
|
||||
@@ -1,13 +1,25 @@
|
||||
# AgentScope 2.0 多智能体报告协作工作台——前端实施计划
|
||||
|
||||
> 文档状态:待评审、尚未开始编码
|
||||
> 编写日期:2026-09-02
|
||||
> 文档状态:前端代码已落地、契约冻结;联调等待后端运行中干预与报告改写
|
||||
> 编写日期:2026-09-02;进度更新日期:2026-09-06
|
||||
> 目标项目:`F:\react01\deeflow-code\deerflow-server\frontend-web`
|
||||
> 视觉与交互参考:`F:\react01\coze-frontend\frontend\apps\coze-studio`
|
||||
> 参考页面:`http://localhost:3000/work_flow?space_id=1&workflow_id=wf_88cb9f4c6642`
|
||||
> 配套后端计划:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md`
|
||||
> 本文只规划新增页面,不修改现有多智能体会商页面,不复用旧 Workflow Studio 执行状态。
|
||||
|
||||
## 0. 当前进度
|
||||
|
||||
`frontend-web/src/report-collaboration/` 已实现两栏工作台、会话/方案/run API 客户端、mock driver、SSE reducer、`MessageList` 适配、方案卡、画布、节点抽屉、Composer、报告预览与历史。`RC-FE-001`~`013` 的页面骨架与契约层视为完成。
|
||||
|
||||
后端已通规划前路径(澄清 → 出方案 → 选定 → `queued` run),见后端计划 §0。前端真实执行体验仍受阻:
|
||||
|
||||
- `GET /runs/{id}/stream` 已挂载(`RC-BE-011`);dispatcher/租约已落地(`RC-BE-012`),默认 `worker_enabled=false` 仍不自动领取 queued run。
|
||||
- 报告改写 / 恢复接口已由后端 `RC-BE-015` 挂载。
|
||||
- 质量角色已由 `QualityRoleKernel` 跑通(`RC-BE-013`);真实 AgentScope 模型/`web_search` 仍待后续票。
|
||||
|
||||
联调顺序:先打开 `report_collaboration.enabled` 验证规划前 REST 与 `GET /runs/{id}/stream` 重放;画布自动执行需同时打开 `worker_enabled`(默认关)。
|
||||
|
||||
## 1. 前端目标
|
||||
|
||||
新增独立的「报告协作」工作台。用户可以通过对话提出任意报告需求,查看系统生成的多个协作方案,点击方案卡片在流程图中预览协作结构,确认后观察多智能体执行,并在全过程通过统一对话框提出修改意见。
|
||||
@@ -662,6 +674,8 @@ POST /api/report-collaboration/sessions/{session_id}/report-rewrites/{id}/appl
|
||||
|
||||
## 9. 前端开发顺序
|
||||
|
||||
页面与契约层已完成(代码在 `frontend-web/src/report-collaboration/`):
|
||||
|
||||
1. `RC-FE-001`:视觉截图与行为基线。
|
||||
2. `RC-FE-002`:路由、菜单和左对话/右画布两栏空壳。
|
||||
3. `RC-FE-003`:页面 token、弹窗、抽屉和响应式。
|
||||
@@ -678,15 +692,17 @@ POST /api/report-collaboration/sessions/{session_id}/report-rewrites/{id}/appl
|
||||
|
||||
依赖规则:
|
||||
|
||||
- `RC-FE-004` 必须等待前后端契约冻结。
|
||||
- 页面骨架和视觉可与后端 BE-0~BE-3 并行。
|
||||
- 真实 SSE 联调必须等待后端 durable event store。
|
||||
- 报告版本操作必须等待后端候选应用语义完成。
|
||||
- 契约已冻结为 `src/report-collaboration/api/types.ts`;后端 `RC-BE-001` 已对齐。
|
||||
- 真实 SSE 联调:后端 `RC-BE-011` 已挂载 `GET /runs/{id}/stream`;默认无后台循环时流只会重放已入库事件。
|
||||
- 报告版本操作已接后端 `RC-BE-015`(改写候选 / apply / restore)。
|
||||
- 执行画布实时状态依赖打开 `worker_enabled`;质量角色内核已在 `RC-BE-013` 落地,真实成员见 `RC-BE-017`。
|
||||
|
||||
---
|
||||
|
||||
## 10. 前端完成定义
|
||||
|
||||
页面与 mock 联调路径已落地。真实后端可联调 `GET /runs/{id}/stream`、领取/恢复(`RC-BE-012`,默认不挂 worker)、质量角色(`RC-BE-013`)以及报告改写/恢复(`RC-BE-015`)。此前以 mock driver 覆盖交互,不以「前端未编码」计未完成。
|
||||
|
||||
前端只有同时满足以下条件才算完成:
|
||||
|
||||
1. 页面结构与 Coze 参考页面逐状态验收通过。
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
# 报告协作工作台 — 前端对接指南
|
||||
|
||||
> **适用版本:** RC-BE-000~018 已落地(2026-09-06)
|
||||
> **配套文档:**
|
||||
> - [前端实施计划](./agentscope-多智能体报告协作工作台-前端实施计划.md)
|
||||
> - [后端实施计划](./agentscope-多智能体报告协作工作台-后端实施计划.md)
|
||||
> - 模块 README:`frontend-web/src/report-collaboration/README.md`
|
||||
|
||||
本文说明**前端如何对接真实后端**:环境配置、API 契约、用户流程、状态机、错误处理与联调验收。前端 API 客户端与页面已实现,通常**无需再写一套 HTTP 层**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论速览
|
||||
|
||||
| 问题 | 答案 |
|
||||
|------|------|
|
||||
| 前端还要写接口吗? | **不用**。`src/report-collaboration/api/` 已封装 REST + SSE,页面通过 `getReportCollaborationDriver()` 调用。 |
|
||||
| 为什么调不通? | 最常见:`config.yaml` 里 `report_collaboration.enabled: false` → 全部 **503**;或 `worker_enabled: false` → run 不入队执行。 |
|
||||
| Mock 和真后端怎么切? | **Mock 已移除**。前端永远请求真实后端(`deerflow.rc-mock` / `VITE_RC_MOCK` 开关已删除);联调需 `enabled: true`。 |
|
||||
| 字段会和 MySQL 对不上吗? | 前端只认 JSON 契约(`api/types.ts`),不直连表。缺列表现为后端 500/SQL 错误,部署后需**重启 Gateway** 跑 `create_all` + 列同步。 |
|
||||
| 从哪进页面? | `/page/workspace/report-collaboration`(新会话)或 `/page/workspace/report-collaboration/:sessionId`。侧边栏入口默认注释,可直接 URL 访问。 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构:前端已有什么
|
||||
|
||||
```
|
||||
ReportCollaborationPage
|
||||
└─ useReportCollaborationSession(唯一数据入口)
|
||||
├─ driver.getSnapshot() ← REST 冷启动
|
||||
├─ driver.openRunStream() ← SSE 实时事件
|
||||
├─ driver.sendMessage / createCommand / startRun …
|
||||
└─ event-reducer ← 事件 → UI 状态
|
||||
|
||||
ReportConversationPanel
|
||||
└─ MessageList(message-adapter 映射 CollaborationMessage → LangGraph Message)
|
||||
```
|
||||
|
||||
### 2.1 目录结构
|
||||
|
||||
```text
|
||||
frontend-web/src/report-collaboration/
|
||||
api/
|
||||
types.ts # 冻结契约(snake_case,与后端 Pydantic 一致)
|
||||
client.ts # rcFetch:鉴权、幂等键、乐观锁、错误映射
|
||||
sessions.ts # 会话 CRUD、消息
|
||||
plans.ts # 方案请求 / 列表 / 选定
|
||||
runs.ts # Run、SSE、命令、节点合同
|
||||
reports.ts # 报告版本、改写、恢复
|
||||
driver.ts # 真实后端统一门面(Mock 驱动已移除)
|
||||
state/
|
||||
event-normalizer.ts
|
||||
event-reducer.ts
|
||||
selectors.ts
|
||||
message-adapter.ts
|
||||
hooks/
|
||||
useReportCollaborationSession.ts # 快照 → SSE → reducer → 动作
|
||||
useReportCollaborationStream.ts # 断线重连、after_seq 续接
|
||||
useReportCollaborationMessages.ts
|
||||
components/ …
|
||||
pages/ReportCollaborationPage.tsx
|
||||
```
|
||||
|
||||
### 2.2 事实源原则
|
||||
|
||||
- **冷启动:** `GET /sessions/{id}` 返回 `SessionSnapshot`(session、messages、plans、run、nodes、agent_runs、commands、report_versions、last_seq)。
|
||||
- **运行中:** `GET /runs/{id}/stream?after_seq=N` 订阅 durable SSE。
|
||||
- **断线 / 序号缺口:** `GET /runs/{id}/events?after_seq=N` REST 补拉。
|
||||
- **澄清 / 规划阶段(尚无 run):** 每 2.5s 轮询快照(后端事件主要在 run 流上)。
|
||||
|
||||
纯展示状态(预览方案、聚焦成员、面板宽度)**不**写入服务器,仅存前端。
|
||||
|
||||
---
|
||||
|
||||
## 3. 后端启用与部署
|
||||
|
||||
### 3.1 必改配置
|
||||
|
||||
文件:`offline-backend-20260512/backend/config.yaml`
|
||||
|
||||
```yaml
|
||||
report_collaboration:
|
||||
enabled: false # 联调必须改为 true
|
||||
worker_enabled: false # 要看成员执行必须 true
|
||||
planner_model: null # 建议填可用模型名
|
||||
coordinator_model: null
|
||||
research_model: null
|
||||
writer_model: null
|
||||
reviewer_model: null
|
||||
# … token_budget、max_concurrent_runs_per_user 等见同文件
|
||||
```
|
||||
|
||||
| 配置 | 现象 |
|
||||
|------|------|
|
||||
| `enabled: false` | 所有路由 **503**,`code: REPORT_COLLABORATION_DISABLED` |
|
||||
| `enabled: true`,`worker_enabled: false` | REST/SSE 可用,run 入队但不自动执行(测 API 可用,无成员流式输出) |
|
||||
| 两者 `true` + 模型配置完整 | 完整联调 |
|
||||
|
||||
### 3.2 MySQL / 表结构
|
||||
|
||||
生产环境 Alembic 自动升级默认关闭,启动时走 `create_all` + `_ensure_orm_columns_sync`。
|
||||
|
||||
- 迁移脚本(可选手动跑):`20260905_01`、`20260906_01`、`20260907_01`
|
||||
- ORM:`packages/harness/deerflow/persistence/report_collaboration/model.py`(13 张表)
|
||||
- **部署后务必重启 Gateway**,观察日志是否有 `Failed to auto-add column report_collaboration_*`
|
||||
|
||||
已有 RC-003 时代旧表会通过列同步补上 `template_snapshot_json`、`budget_snapshot_json`、`usage_json`、`budget_exhausted_reason` 等可空列,并创建 `rewrite_proposals`、`audits`。
|
||||
|
||||
### 3.3 前端环境变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `VITE_BACKEND_BASE_URL` | Gateway 地址,默认 `http://127.0.0.1:8001` |
|
||||
|
||||
请求经 `apiFetch`,自动携带登录 JWT(`credentials: "include"`)。**必须先登录 DeerFlow**。
|
||||
|
||||
---
|
||||
|
||||
## 4. API 基址与请求约定
|
||||
|
||||
### 4.1 Base URL
|
||||
|
||||
```text
|
||||
{VITE_BACKEND_BASE_URL}/api/report-collaboration
|
||||
```
|
||||
|
||||
代码常量:`REPORT_COLLABORATION_BASE()`(`api/client.ts`)。
|
||||
|
||||
### 4.2 请求头
|
||||
|
||||
| Header | 何时携带 | 说明 |
|
||||
|--------|----------|------|
|
||||
| `Authorization` | 全部 | 由 `apiFetch` 注入 |
|
||||
| `X-Idempotency-Key` | 写操作 | 格式 `rc-{uuid}`;重复提交返回首次结果 |
|
||||
| `X-Expected-Revision` | 选方案、开 run、改会话等 | 会话 `requirement_revision` 或方案 `revision` |
|
||||
| `Accept: text/event-stream` | SSE | `openRunStream` |
|
||||
|
||||
### 4.3 错误响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "REPORT_COLLABORATION_DISABLED",
|
||||
"message": "报告协作工作台未启用",
|
||||
"detail": { }
|
||||
}
|
||||
```
|
||||
|
||||
前端映射(`api/client.ts`):
|
||||
|
||||
| HTTP | 异常类 | 典型场景 |
|
||||
|------|--------|----------|
|
||||
| 409 | `ConflictError` | revision 过期、active run 冲突 |
|
||||
| 422 | `ValidationError` | 参数/语义错误、缺幂等键 |
|
||||
| 429 | `RateLimitError` | 预算耗尽、每用户并发 run 上限 |
|
||||
| 5xx | `ServerError` | 可带同一幂等键重试 |
|
||||
| 0 | `NetworkError` | fetch 失败 |
|
||||
|
||||
### 4.4 完整路由清单(与前端 client 对齐)
|
||||
|
||||
| 方法 | 路径 | 前端模块 |
|
||||
|------|------|----------|
|
||||
| POST | `/sessions` | `sessions.createSession` |
|
||||
| GET | `/sessions` | `sessions.listSessions` |
|
||||
| GET | `/sessions/{id}` | `sessions.getSessionSnapshot` |
|
||||
| PATCH | `/sessions/{id}` | `sessions.updateSession` |
|
||||
| DELETE | `/sessions/{id}` | `sessions.deleteSession` |
|
||||
| GET | `/sessions/{id}/messages` | `sessions.listMessages` |
|
||||
| POST | `/sessions/{id}/messages` | `sessions.sendMessage` |
|
||||
| POST | `/sessions/{id}/plan-requests` | `plans.requestPlans` |
|
||||
| GET | `/sessions/{id}/plans` | `plans.listPlans` |
|
||||
| POST | `/sessions/{id}/plans/{planId}/select` | `plans.selectPlan` |
|
||||
| POST | `/sessions/{id}/runs` | `runs.startRun` |
|
||||
| GET | `/sessions/{id}/reports` | `reports.listReportVersions` |
|
||||
| POST | `/sessions/{id}/report-rewrites` | `reports.createRewrite` |
|
||||
| POST | `/sessions/{id}/report-rewrites/{id}/apply` | `reports.applyRewrite` |
|
||||
| POST | `/sessions/{id}/report-versions/{id}/restore` | `reports.restoreReportVersion` |
|
||||
| GET | `/runs/{id}` | `runs.getRun` |
|
||||
| GET | `/runs/{id}/events` | `runs.listRunEvents` |
|
||||
| GET | `/runs/{id}/stream` | `runs.openRunStream` |
|
||||
| POST | `/runs/{id}/cancel` | `runs.cancelRun` |
|
||||
| POST | `/runs/{id}/commands` | `runs.createCommand` |
|
||||
| POST | `/runs/{id}/commands/{id}/confirm` | `runs.confirmCommand` |
|
||||
| POST | `/runs/{id}/commands/{id}/cancel` | `runs.cancelCommand` |
|
||||
| GET | `/runs/{id}/nodes/{nodeId}/contract` | `runs.getNodeContract` |
|
||||
| GET | `/runs/{id}/artifacts` | (后端已暴露,前端按需接) |
|
||||
| GET | `/runs/{id}/sources` | (后端已暴露,前端按需接) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 用户流程与 API 时序
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant FE as 前端 RC
|
||||
participant API as Gateway
|
||||
participant W as Worker
|
||||
|
||||
U->>FE: 输入需求
|
||||
FE->>API: POST /sessions
|
||||
FE->>API: POST /sessions/{id}/messages
|
||||
API-->>FE: 澄清(快照 messages)
|
||||
U->>FE: 回答澄清
|
||||
FE->>API: POST /messages
|
||||
FE->>API: POST /plan-requests
|
||||
API-->>FE: 候选方案(快照 plans)
|
||||
U->>FE: 采用方案
|
||||
FE->>API: POST /plans/{id}/select
|
||||
U->>FE: 开始协作
|
||||
FE->>API: POST /runs
|
||||
FE->>API: GET /runs/{id}/stream
|
||||
W-->>API: 持久化事件
|
||||
API-->>FE: SSE
|
||||
U->>FE: 运行中干预
|
||||
FE->>API: POST /commands → confirm
|
||||
```
|
||||
|
||||
### 5.1 阶段 0:进入页面
|
||||
|
||||
| 路由 | 行为 |
|
||||
|------|------|
|
||||
| `/page/workspace/report-collaboration` | 空态;`POST /sessions` 后跳转带 `sessionId` |
|
||||
| `/page/workspace/report-collaboration/:sessionId` | 加载快照;有 `location.state.rcInitialMessage` 时自动发首条消息 |
|
||||
|
||||
路由注册:`frontend-web/src/pages/WorkspaceRoutes.tsx`。
|
||||
侧边栏入口已在 `workspace-nav-chat-list.tsx` 中放开(「报告协作」项),也可直接访问 URL。
|
||||
|
||||
### 5.2 阶段 1:需求澄清(无 run)
|
||||
|
||||
```http
|
||||
POST /api/report-collaboration/sessions
|
||||
POST /api/report-collaboration/sessions/{sessionId}/messages
|
||||
Content-Type: application/json
|
||||
|
||||
{ "text": "请写一份…", "target_node_id": null, "agent_run_id": null }
|
||||
```
|
||||
|
||||
- `useReportCollaborationSession.send()`:当 `session.status` 不是 `running` / `awaiting_input` / `reviewing` 时走 `sendMessage`。
|
||||
- 会话状态:`empty` → `clarifying` → `planning` → `proposal_ready`。
|
||||
- 澄清问题以 `CollaborationMessage` 进入时间线;业务卡通过 `MessageList` 的 `businessCardSlot` 渲染(`additional_kwargs.report_collaboration_card`)。
|
||||
|
||||
### 5.3 阶段 2:候选方案
|
||||
|
||||
```http
|
||||
POST /api/report-collaboration/sessions/{sessionId}/plan-requests
|
||||
POST /api/report-collaboration/sessions/{sessionId}/plans/{planId}/select
|
||||
```
|
||||
|
||||
- **预览方案**(`previewPlanId`):纯前端,不调 API。
|
||||
- **采用方案**:`select` 写 `selected_plan_id`,**不**创建 run。
|
||||
- 409:方案 revision 过期 → 前端 `refreshSnapshot`,提示用户重选。
|
||||
- 右栏 `ReportFlowCanvas` 使用 `PlanNode` / `PlanEdge` 渲染 DAG。
|
||||
|
||||
### 5.4 阶段 3:开始执行与 SSE
|
||||
|
||||
```http
|
||||
POST /api/report-collaboration/sessions/{sessionId}/runs
|
||||
{ "plan_id": "plan-xxx" }
|
||||
|
||||
GET /api/report-collaboration/runs/{runId}/stream?after_seq=0
|
||||
Accept: text/event-stream
|
||||
```
|
||||
|
||||
SSE 每条 `data:` 为 JSON:
|
||||
|
||||
```typescript
|
||||
interface CollaborationEvent {
|
||||
eventId: string;
|
||||
sessionId: string;
|
||||
runId?: string | null;
|
||||
seq: number;
|
||||
type: CollaborationEventType;
|
||||
timestamp: string;
|
||||
data: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
常用 `type`(完整列表见 `api/types.ts`):
|
||||
|
||||
| 类型 | 含义 |
|
||||
|------|------|
|
||||
| `message.created` / `message.delta` / `message.completed` | 对话镜像流式 |
|
||||
| `tool_call.*` / `tool_result.*` | 工具调用与返回 |
|
||||
| `team_message.*` | 成员 / 协调者广播 |
|
||||
| `plan.proposed` / `plan.selected` | 方案生命周期 |
|
||||
| `run.status.changed` | Run 状态 |
|
||||
| `node.status.changed` / `node.progress` | 节点执行 |
|
||||
| `command.confirmation.required` | 需用户确认的干预 |
|
||||
| `report.version.created` | 报告版本落盘 |
|
||||
| `heartbeat` | 保活 |
|
||||
|
||||
### 5.5 阶段 4:运行中干预
|
||||
|
||||
```http
|
||||
POST /api/report-collaboration/runs/{runId}/commands
|
||||
POST /api/report-collaboration/runs/{runId}/commands/{commandId}/confirm
|
||||
POST /api/report-collaboration/runs/{runId}/commands/{commandId}/cancel
|
||||
POST /api/report-collaboration/runs/{runId}/cancel
|
||||
```
|
||||
|
||||
- Run 活跃时 `send()` 自动走 `createCommand`(而非 `sendMessage`)。
|
||||
- 可从节点抽屉 / `AgentStageSelector` 传入 `target_node_id`、`agent_run_id`。
|
||||
- `InterventionCard` 展示 `confirmation_required` 命令;确认后调 `confirmCommand`。
|
||||
|
||||
`CommandIntent` 枚举(后端分类,前端展示 reason/impact):`answer_question`、`update_requirements`、`research_again`、`rewrite_section`、`rerun_node`、`replan`、`cancel` 等。
|
||||
|
||||
### 5.6 阶段 5:报告版本与改写
|
||||
|
||||
```http
|
||||
GET /api/report-collaboration/sessions/{sessionId}/reports
|
||||
POST /api/report-collaboration/sessions/{sessionId}/report-rewrites
|
||||
POST /api/report-collaboration/sessions/{sessionId}/report-rewrites/{rewriteId}/apply
|
||||
POST /api/report-collaboration/sessions/{sessionId}/report-versions/{versionId}/restore
|
||||
```
|
||||
|
||||
- `createRewrite` 只生成候选,不直接覆盖正式版本。
|
||||
- `applyRewrite` / `restoreReportVersion` 产生新的 head 版本,历史保留。
|
||||
- `ReportPreviewDialog` 展示 `ReportVersion.markdown`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端状态机(接入方须知)
|
||||
|
||||
**不要**在页面里用零散 REST 字段拼 UI 状态。统一路径:
|
||||
|
||||
```text
|
||||
SessionSnapshot → applySessionSnapshot()
|
||||
CollaborationEvent[] → normalizeCollaborationEvent() → applyCollaborationEvents()
|
||||
→ selectors(derivePagePhase、selectPlanForCanvas、…)
|
||||
```
|
||||
|
||||
### 6.1 核心 Selector
|
||||
|
||||
| Selector | 用途 |
|
||||
|----------|------|
|
||||
| `derivePagePhase` | Composer 占位、方案卡、空态 |
|
||||
| `selectPlanForCanvas` | 画布 DAG(含预览方案) |
|
||||
| `selectAgentTabs` | 并行成员 Tab |
|
||||
| `selectNodeStatusMap` | 节点颜色 / 状态 |
|
||||
| `selectPendingConfirmationCommands` | 干预确认卡 |
|
||||
| `selectHeadReportVersion` | 报告预览入口 |
|
||||
| `selectStreamingAgentRunIds` | 流式成员指示 |
|
||||
|
||||
### 6.2 SSE 连接策略(`useReportCollaborationStream`)
|
||||
|
||||
- Run 非终态时连接;`completed` / `failed` / `cancelled` 后关流并 REST 最终补拉。
|
||||
- 断线:指数退避重连(1s~30s),`after_seq` 取 reducer 内 `lastSeq`。
|
||||
- `hasSeqGap`:暂停 live 应用,先 `listRunEvents` 回填。
|
||||
|
||||
### 6.3 乐观消息
|
||||
|
||||
用户发送时插入 `metadata.optimistic: true` 的 pending 消息;服务端镜像带相同 `idempotency_key` 时 reducer 原地替换。
|
||||
|
||||
---
|
||||
|
||||
## 7. 消息与 MessageList 复用
|
||||
|
||||
`useReportCollaborationMessages` 将 `CollaborationMessage[]` 转为 `@langchain/langgraph-sdk` 的 `Message[]`,并构造合成 `BaseStream` 供 `MessageList` 消费。
|
||||
|
||||
| 来源 | 渲染 |
|
||||
|------|------|
|
||||
| `human` / `ai` 文本 | 普通气泡 |
|
||||
| `tool_calls` + `tool` | 现有工具卡 |
|
||||
| `team_message` 事件 | 带 `name` 的成员发言 |
|
||||
| 业务卡 | `businessCardSlot`(方案组、命令确认、报告可用) |
|
||||
|
||||
分组键:`core/messages/utils.ts` 中 `assistant:task-report-import` 同级扩展 `assistant:business-card`,判据 `additional_kwargs.report_collaboration_card`。
|
||||
|
||||
### 7.1 在其他页面嵌入 RC(最小集)
|
||||
|
||||
1. `useReportCollaborationSession(sessionId)`
|
||||
2. `ThreadContext.Provider` + `MessageList` + `businessCardSlot`
|
||||
3. 引入 `report-collaboration/styles/report-collaboration.css`
|
||||
|
||||
---
|
||||
|
||||
## 8. 单测
|
||||
|
||||
Mock 驱动(原 `api/mock.ts`)与 `deerflow.rc-mock` / `VITE_RC_MOCK` 开关已移除,前端只请求真实后端;本地演示请直接联调后端。
|
||||
|
||||
单测:
|
||||
|
||||
```bash
|
||||
cd frontend-web
|
||||
pnpm test:report-collaboration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 联调验收清单
|
||||
|
||||
### 9.1 准备
|
||||
|
||||
- [ ] `report_collaboration.enabled: true`
|
||||
- [ ] `report_collaboration.worker_enabled: true`
|
||||
- [ ] 五个 `*_model` 已配置且可用
|
||||
- [ ] Gateway 已重启(MySQL 表/列就绪)
|
||||
- [ ] 前端已登录(JWT 有效)
|
||||
|
||||
### 9.2 探活
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer <token>" \
|
||||
http://127.0.0.1:8001/api/report-collaboration/sessions
|
||||
```
|
||||
|
||||
期望:**200** + `{ "sessions": [...] }`,而非 503。
|
||||
|
||||
### 9.3 页面流程(浏览器 Network)
|
||||
|
||||
1. 打开 `/#/page/workspace/report-collaboration`
|
||||
2. 输入需求 → `POST .../sessions` **200**
|
||||
3. 跳转 → `GET .../sessions/{id}` **200**,响应含 `SessionSnapshot`
|
||||
4. 首条消息 → `POST .../messages` **200**
|
||||
5. 请求方案 → `POST .../plan-requests` **200**
|
||||
6. 采用方案 → `POST .../plans/{id}/select` **200**
|
||||
7. 开始协作 → `POST .../runs` **200**
|
||||
8. `GET .../runs/{id}/stream` 持续收到 SSE(`message.delta`、`node.status.changed` 等)
|
||||
9. 刷新页面 → 消息与 `last_seq` 续接正确
|
||||
10. 运行中输入「重新检索」→ `POST .../commands` → 确认卡 → `confirm` **200**
|
||||
|
||||
### 9.4 常见失败
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
|------|------|------|
|
||||
| 全部 503 | `enabled: false` | 改配置并重启 |
|
||||
| run 一直 queued | `worker_enabled: false` | 改为 true |
|
||||
| 409 选方案 | revision 过期 | 刷新后重选(前端已自动 refresh) |
|
||||
| 429 | 预算/并发上限 | 调大配置或取消其他 run |
|
||||
| 500 + SQL | 表缺列 | 重启 Gateway,查列同步日志 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 契约与类型维护
|
||||
|
||||
- **单一事实源:** `frontend-web/src/report-collaboration/api/types.ts`
|
||||
- **后端镜像:** `offline-backend-20260512/backend/app/report_collaboration/contracts/`
|
||||
- 修改字段须同步两份实施计划与本指南,并跑前后端相关测试。
|
||||
|
||||
`SessionSnapshot` 形状(摘要):
|
||||
|
||||
```typescript
|
||||
interface SessionSnapshot {
|
||||
session: ReportSession;
|
||||
messages: CollaborationMessage[];
|
||||
plans: ReportPlanCandidate[];
|
||||
run: ReportRun | null;
|
||||
nodes: CollaborationNodeState[];
|
||||
agent_runs: AgentRun[];
|
||||
commands: CollaborationCommand[];
|
||||
report_versions: ReportVersion[];
|
||||
last_seq: number;
|
||||
}
|
||||
```
|
||||
|
||||
`ReportRun` 可选字段(RC-BE-016):`usage`、`budget_exhausted_reason`。
|
||||
|
||||
---
|
||||
|
||||
## 11. 后续工作(非对接阻塞)
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| RC-BE-019 | 灰度发布与离线部署文档 |
|
||||
| 侧边栏入口 | 已放开(`workspace-nav-chat-list.tsx` 的「报告协作」项) |
|
||||
| iframe 嵌入 | RC 页尚未专门适配 `embed=1`,可参考现有深链模式扩展 |
|
||||
| 真人盲评 | RC-BE-018 为确定性评测集,上线前仍需计划 §11.4 人工评审 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 相关代码索引
|
||||
|
||||
| 用途 | 路径 |
|
||||
|------|------|
|
||||
| API 客户端 | `frontend-web/src/report-collaboration/api/` |
|
||||
| 会话 Hook | `frontend-web/src/report-collaboration/hooks/useReportCollaborationSession.ts` |
|
||||
| 页面 | `frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx` |
|
||||
| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
|
||||
| 后端路由 | `offline-backend-20260512/backend/app/gateway/routers/report_collaboration.py` |
|
||||
| 配置 | `offline-backend-20260512/backend/config.yaml` → `report_collaboration` |
|
||||
| 持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_collaboration/` |
|
||||
@@ -1,11 +1,44 @@
|
||||
# AgentScope 2.0 多智能体报告协作工作台——后端实施计划
|
||||
|
||||
> 文档状态:待评审、尚未开始编码
|
||||
> 编写日期:2026-09-02
|
||||
> 文档状态:实施中(`RC-BE-000`~`018` 已完成;下一票 `RC-BE-019`)
|
||||
> 编写日期:2026-09-05;进度更新日期:2026-09-06
|
||||
> 目标项目:`F:\react01\deeflow-code\deerflow-server\offline-backend-20260512\backend`
|
||||
> 协作框架:[AgentScope 2.0](https://github.com/agentscope-ai/agentscope)
|
||||
> 配套前端计划:`docs/agentscope-多智能体报告协作工作台-前端实施计划.md`
|
||||
> 前端契约来源:`frontend-web/src/report-collaboration/api/types.ts`、`api/mock.ts`、`state/event-reducer.ts`(前端已完成,契约冻结)
|
||||
> 尖峰基线:`offline-backend-20260512/backend/docs/AGENTSCOPE_BASELINE.md`
|
||||
> 本文只规划新增的报告协作后端,不修改现有多智能体会商,不使用旧 Workflow Studio DAG 调度器。
|
||||
> 下文 §8 的 `BE-n` 与 §10 的 `RC-BE-00n` 是同一批工单的两种编号;进度以 `RC-BE-*` 为准。
|
||||
|
||||
## 0. 当前进度
|
||||
|
||||
已打通 **建会话 → 澄清需求 → 生成 2~3 套候选方案 → 选定 → 创建 `queued` run(冻结模型 + 模板 + 预算)**,以及 **TaskLedger 波次调度 + 受控成员 / 真实 AgentScope 内核 + 主张级 QualityGate + 正式报告版本/改写 + 产品 durable SSE + 租约 dispatcher/恢复 + 运行期自然语言干预 + owner/预算/审计**。主开关默认关闭:`config.yaml → report_collaboration.enabled` / `worker_enabled` 均为 `false`。
|
||||
|
||||
运行期 IntentRouter 的歧义兜底已可调用冻结的 coordinator 模型,但明确命令走高精度规则;该智能体无工具且不能调度。`POST /runs` 仍只入队。`worker_enabled` 为 false 时 lifespan **不**挂载后台循环;测试用 `dispatch_once()` / `WaveExecutor` 驱动。`GET /runs/{id}/stream` 可重放/尾随已持久化事件。生产 worker 在双开时注入 `AgentScopeMemberKernel`。20 项质量评测已在 `RC-BE-018` 落地(确定性打分,不跑真实模型)。
|
||||
|
||||
| 工单 | 状态 | 代码落点 / 缺口 |
|
||||
|---|---|---|
|
||||
| `RC-BE-000` | 条件完成 | `agentscope_runtime/event_adapter.py`。`agentscope==2.0.5` 仅为 optional extra,**未写入 `uv.lock`**(等纵向 POC)。 |
|
||||
| `RC-BE-001` | 完成 | `app/report_collaboration/contracts/` + `execution/completion.py`。 |
|
||||
| `RC-BE-002` | 完成 | `deerflow.config.report_collaboration_config`;默认双关。 |
|
||||
| `RC-BE-003` | 完成 | `deerflow.persistence.report_collaboration/`,迁移 `20260905_01`。 |
|
||||
| `RC-BE-004` | 完成 | `/api/report-collaboration`。改写 / apply / restore 见 `015`。SSE 见 `011`。 |
|
||||
| `RC-BE-005` | 完成 | `requirement/`:规划前澄清;「直接开始」记默认假设;澄清完成 ≠ 出方案。 |
|
||||
| `RC-BE-006` | 完成 | `planning/`:目录投影(无 SOUL/密钥)+ 三策略方案;select 与 start-run 独立。 |
|
||||
| `RC-BE-007` | 完成 | `DeerFlowChatModelAdapter` + `freeze_plan_models`;缺模型/能力不匹配在 `POST /runs` 前 422。 |
|
||||
| `RC-BE-008` | 完成 | `permission_adapter.py` + `tool_adapter.py`:角色 × 工具矩阵、注入扫描。 |
|
||||
| `RC-BE-009` | 完成 | `execution/ledger.py` + `member_runner.py`:TaskLedger 按依赖波次分派;AgentScope/协调者建议不能选下一节点;空输出/非法 JSON 进入新 attempt;超限失败不完成整个 run。内核目前为 `ScriptedMemberKernel`(单测),真实模型/web_search 仍待接。 |
|
||||
| `RC-BE-010` | 完成 | `execution/quality_gate.py` + `ArtifactBoard.supersede_previous`:冲突数字/缺口径拒绝;未核验或争议主张不能进分析/章节;审稿必须映射到节点或章节;覆盖矩阵缺口拒绝。Ledger 先 schema 再质检,通过才 `validated`。 |
|
||||
| `RC-BE-011` | 完成 | `execution/live_hub.py`:run-scoped Live Hub、`PublishingReportCollaborationStore`(先入库再发布)、`after_seq`/`Last-Event-ID` 重放、心跳、token delta 合帧、SSE 断开不取消 run。`GET /runs/{id}/stream` 已挂载。报告正式版本见 `015`。 |
|
||||
| `RC-BE-012` | 完成 | store `list_claimable` / `renew_lease` / `finalize_cancel`;`execution/{dispatcher,executor,recovery}.py`:原子 claim、心跳续租、两阶段取消、过期租约接管。恢复保留已完成节点,遗留 in-flight 标 `interrupted`/`superseded` 并开新 `planned` attempt。晚到成果 `validation_status=superseded`,不改终态。`start()` 仅在 `worker_enabled` 时挂循环;默认双关。 |
|
||||
| `RC-BE-013` | 完成 | `execution/quality_roles.py`:Verifier/Analyst/Writer/Reviewer/Reviser 作为受控成员(确定性内核,非 AgentScope Team)。核验标记过期/跨包冲突;缺失角度只重开检索链;审稿 `revise` 只重开目标写作节点 + 审稿自身。`TaskResult.additional_drafts` 允许多条 VerificationResult。脚本内核仍供单测;生产 worker 见 `017`。 |
|
||||
| `RC-BE-014` | 完成 | `interventions/`:运行期 IntentRouterAgent(规则优先、模型兜底且无工具)+ 确定性 ImpactAnalyzer + command CAS/safe-point。支持节点/agent 归属校验、角度定向、确认闸门、等待边界、overlay、新 attempt、成果失效及晚到结果隔离。 |
|
||||
| `RC-BE-015` | 完成 | `reporting/`:run 开始冻结模板;`ReportFinalizer` 在审稿通过且 lint 后落正式版本并发 `report.version.created`;改写候选 / apply / restore 已挂路由,不再 503。 |
|
||||
| `RC-BE-016` | 完成 | owner 隔离、跨会话幂等拒绝、`sanitize_for_user` + 审计落库、每用户 active run 上限、每 run 预算 fail-closed。 |
|
||||
| `RC-BE-017` | 完成 | `AgentScopeMemberKernel` 真实 `reply_stream` + 工具 + 结构化成果;生产 worker 注入该内核;冒烟可跳过未安装的 `agentscope`。并发交错/租约恢复仍由 `012` 测试覆盖。 |
|
||||
| `RC-BE-018` | 完成 | `app/report_collaboration/eval/`:20 项标注任务(政策/市场/企业/事件/对比各 4)、确定性指标、金标/失败实例、会商盲评对照、20 次固定任务可靠性计数。`cite_required` 与当前默认 knobs 为通过评测的推荐画像。真实模型盲评仍属上线决策,不阻塞本票。 |
|
||||
| `RC-BE-019` | 未开始 | 灰度与离线发版。 |
|
||||
|
||||
当前可联调路径:打开 `report_collaboration.enabled` 后,规划前轮询 `GET /sessions/{id}` 即可看到澄清消息和方案卡;`POST /runs` 只入队;`GET /runs/{id}/stream` 重放已入库事件。默认 **不**自动领取 queued run(`worker_enabled=false`)。测试可调 `dispatch_once()`。
|
||||
|
||||
## 1. 后端目标
|
||||
|
||||
@@ -14,7 +47,7 @@
|
||||
1. 理解任意报告需求并在必要时澄清。
|
||||
2. 从当前用户可见的智能体、技能、模型和资料源中筛选能力。
|
||||
3. 生成 2~3 个真实有差异的候选协作计划。
|
||||
4. 将选定计划实例化为 AgentScope leader-worker 团队。
|
||||
4. 将选定计划实例化为受 `TaskLedger` 约束的 AgentScope 任务成员,而非让自由会商自行决定流程。
|
||||
5. 通过结构化成果物完成检索、核验、分析、综合、写作、审稿和返工。
|
||||
6. 接受全过程自然语言干预,并只重开受影响的任务和下游成果。
|
||||
7. 使用数据库租约、durable events、幂等命令和版本化报告保证恢复与一致性。
|
||||
@@ -23,11 +56,27 @@
|
||||
|
||||
框架能提供协作机制,不能单独保证报告质量。质量由证据合同、来源核验、独立审稿、定向返工、模板约束和真实评测集共同保证。
|
||||
|
||||
### 1.1 前端冻结契约:后端必须适配,不要求前端回退
|
||||
|
||||
已完成的双栏前端以 `SessionSnapshot` 和 run-scoped durable SSE 为唯一事实来源;后端实现不得另造一套摘要卡或节点结果接口来补足过程。
|
||||
|
||||
| 前端已实现行为 | 后端适配要求 |
|
||||
|---|---|
|
||||
| run 创建前轮询 `GET /sessions/{id}` | 澄清、需求修订、候选方案与选择结果必须同步写入完整快照;不能只存在于内存任务。 |
|
||||
| run 后以 `seq` 重放/订阅 SSE | 每个 run 的事件先持久化、再发布,`seq` 严格单调且可用 `after_seq` 补拉。 |
|
||||
| 并行成员横向阶段卡 | 同一并行波次使用相同 `phase_id`;`node_run_id` 固定为 `${node_id}-attempt{attempt}`,不可使用前端无法反查节点的随机值。 |
|
||||
| 复用 `MessageList` 展示过程 | assistant、TeamSay、工具调用、工具结果与流式增量均写入 `report_collaboration_messages` 并按原消息协议推送。 |
|
||||
| 成员状态来自局部快照合并 | 前端没有 `agent_run.*` 事件;成员开始、等待输入、完成、失败、取消时必须发 `session.snapshot: {agent_runs:[...], run?:...}`。 |
|
||||
| 报告在对话流中渐显 | 写作阶段发 `report.delta`;质量门和版本落库完成后才发 `report.version.created`。 |
|
||||
| 节点抽屉只读 | `GET /runs/{id}/nodes/{node_id}/contract` 只返回职责合同,永不返回执行结果正文。 |
|
||||
|
||||
每个写操作读取前端发送的 `X-Idempotency-Key` 与 `X-Expected-Revision`;用户消息的服务端镜像必须将同一幂等键放入 `metadata.idempotency_key`,以替换前端乐观消息而不重复显示。
|
||||
|
||||
---
|
||||
|
||||
## 2. 关键架构决策
|
||||
|
||||
### 2.1 使用 AgentScope Core,不采用演示服务作为事实源
|
||||
### 2.1 AgentScope 用作任务执行内核,不把 Agent Team 自主会商当作调度器
|
||||
|
||||
AgentScope 官方提供 Event System、Agent Team、任务规划和 Agent Service 示例,但团队可靠性与多进程/分布式服务仍在持续完善:
|
||||
|
||||
@@ -36,13 +85,16 @@ AgentScope 官方提供 Event System、Agent Team、任务规划和 Agent Servic
|
||||
- [Agent Team 可靠性跟踪](https://github.com/agentscope-ai/agentscope/issues/1802)
|
||||
- [多进程与分布式能力跟踪](https://github.com/agentscope-ai/agentscope/issues/1722)
|
||||
|
||||
因此采用:
|
||||
因此采用下列职责分界:
|
||||
|
||||
- AgentScope Core:Agent、Team、Tool、Event、Permission。
|
||||
- AgentScope Core:单个受控任务成员的模型调用、流式事件、工具调用、权限封装与结构化输出修复。
|
||||
- DeerFlow `TaskLedger`:计划实例化、依赖拓扑、并发波次、输入成果选择、状态迁移、重试、租约、取消、命令安全点与业务终态。
|
||||
- DeerFlow `ArtifactBoard` / `QualityGate`:成果物事实源、验收、失效传播、引用约束和报告版本准入。
|
||||
- `Coordinator`:受限的规划/协调智能体,只能提出 `PlanProposal`、`TaskAssignment`、`RepairRequest` 和 `ClarificationRequest`;它不能自行创建任意图、改变数据库或宣告节点完成。
|
||||
- AgentScope TeamSay:允许作为可见的协作交接文本,但不是任务分派、完成判定或数据依赖的事实来源。
|
||||
- DeerFlow:身份、权限、配置、数据库、消息、命令、作业、租约、SSE、成果、文件、报告版本和审计。
|
||||
- AgentScope Event 既驱动运行状态,也通过无损 adapter 转为 DeerFlow 领域事件和 LangChain 兼容消息;其用户可见内容不能在 adapter 中被压缩为进度摘要。
|
||||
- 不运行 AgentScope `examples/web_ui`。
|
||||
- 不把 AgentScope Session、MessageBus 或内存对象作为恢复依据。
|
||||
- AgentScope Event 通过无损 adapter 转为 DeerFlow 领域事件和 LangChain 兼容消息;用户可见内容不能在 adapter 中压缩为进度摘要。
|
||||
- 不运行 AgentScope `examples/web_ui`,不使用 AgentScope Session、MessageBus 或内存对象作为恢复/重放依据,也不使用其 session SSE 作为产品 SSE。
|
||||
- 节点公开接口只返回职责合同、图关系和状态,不返回节点实际输出;实际阶段消息、工具过程和成果仍从会话消息流以原生消息、工具步骤、引用和报告文件卡的形式呈现。
|
||||
|
||||
### 2.2 新功能独立,不进入旧运行内核
|
||||
@@ -63,7 +115,7 @@ AgentScope 官方提供 Event System、Agent Team、任务规划和 Agent Servic
|
||||
- 展示当前状态和返工范围。
|
||||
- 供用户理解和选择方案。
|
||||
|
||||
真正调度由 `Coordinator + TaskLedger + ArtifactBoard + QualityGate` 完成。协调者只能从服务端允许的动作中选择,不能返回任意 Python、任意图或数据库操作。
|
||||
真正调度由 `TaskLedger + ArtifactBoard + QualityGate` 完成。协调者只能提出服务端可验证的建议;`TaskLedger` 才能执行分派、依赖解锁、重试和终态迁移,不能返回任意 Python、任意图或数据库操作。
|
||||
|
||||
### 2.4 数据库是唯一事实源
|
||||
|
||||
@@ -89,12 +141,12 @@ flowchart LR
|
||||
API --> DISP[Lease Dispatcher]
|
||||
|
||||
DISP --> EXEC[Report Collaboration Executor]
|
||||
EXEC --> AS[AgentScope Core]
|
||||
AS --> LEAD[Coordinator]
|
||||
AS --> TEAM[Research/Verify/Analyze/Write/Review Team]
|
||||
|
||||
EXEC --> LEDGER[Task Ledger]
|
||||
EXEC --> BOARD[Typed Artifact Board]
|
||||
LEDGER --> COORD[Constrained Coordinator]
|
||||
LEDGER --> AS[AgentScope Core Task Workers]
|
||||
COORD --> LEDGER
|
||||
AS --> TEAM[Research / Verify / Analyze / Write / Review]
|
||||
EXEC --> MODEL[DeerFlow Model Adapter]
|
||||
EXEC --> TOOL[Allowlisted Tool Adapter]
|
||||
|
||||
@@ -290,6 +342,59 @@ completed -> reopened -> queued
|
||||
- 期望成果物落库、Schema 通过、验收器通过后才能完成。
|
||||
- 返工创建新 attempt,保留旧 attempt 和旧成果。
|
||||
|
||||
### 5.6 `TaskLedger`:智能体协作的确定性边界
|
||||
|
||||
每个计划节点被实例化为一条可审计的 ledger task;并行只是多个 ready task 位于同一波次,不是让多个智能体在群聊中自行决定谁做什么。
|
||||
|
||||
```text
|
||||
TaskAssignment
|
||||
node_id / node_run_id / phase_id / attempt
|
||||
requirement_revision
|
||||
allowed_input_artifact_ids[]
|
||||
expected_artifact_type + acceptance_criteria
|
||||
agent_profile_snapshot + allowed_tools + budgets
|
||||
command_overlay_revision
|
||||
|
||||
TaskResult
|
||||
artifact_draft | clarification_request | repair_request | failure
|
||||
cited_artifact_ids[]
|
||||
source_ids[]
|
||||
```
|
||||
|
||||
调度规则:
|
||||
|
||||
1. `PlanAssembler` 将角色建议转为固定节点、边、成果合同和质量门;只允许无环主图。
|
||||
2. `TaskLedger` 仅在依赖的成果物均为 `validated`、未 supersede 且预算可用时使节点 ready。
|
||||
3. 每个成员只读 requirement revision、自己的 `TaskAssignment` 与允许的已验证上游成果;不能把其他成员的原始上下文、工具返回或内部 prompt 当作输入。
|
||||
4. 并行节点共享 `phase_id`,但各自有独立 `agent_run_id`;串行节点用独立 `phase_id`。成员的 `node_run_id` 一律为 `${node_id}-attempt{attempt}`。
|
||||
5. 只有 `TaskResult` 转为成果物并通过 `QualityGate` 后,ledger 才解锁下游;TeamSay 与普通文本没有解锁能力。
|
||||
6. 修复/返工创建新 attempt;旧 attempt 及其消息留存但被标为 `superseded`,不得重新进入下游。
|
||||
|
||||
### 5.7 用户命令的约束化协议
|
||||
|
||||
每句话先持久化为用户消息和 `CollaborationCommand`,再由独立的**意图理解智能体(`IntentRouterAgent`)**进行两段式判定:规则/上下文高精度路由优先,必要时调用模型并输出受 Pydantic 约束的 `IntentDecision`。它负责理解,不负责调度或执行。
|
||||
|
||||
同一能力有两种受控入口:
|
||||
|
||||
| 时机 | `IntentRouterAgent` 的工作 | 后续处理 |
|
||||
|---|---|---|
|
||||
| 尚未开始 run | 提取主题、目标、受众、范围、时间、角度、模板和来源约束;判断真正缺失的信息 | `RequirementResolver` 写 requirement revision;`PlanController` 生成候选计划。 |
|
||||
| run 正在执行或已有报告 | 结合当前选中的 node/agent、等待澄清、成果血缘和报告章节,识别用户是在回答、补角度、重检索、重分析、重跑、改章节、润色、查询还是改规划 | `ImpactAnalyzer` 计算实际影响;`TaskLedger` 在确认和安全点后执行。 |
|
||||
|
||||
输入包含用户文本、session/run 状态、最新 requirement、当前 `target_node_id` / `agent_run_id`、awaiting clarification、当前报告版本和可见成果摘要;不得输入其他成员的内部 prompt、隐藏推理或无权访问的资料。
|
||||
|
||||
```text
|
||||
IntentDecision
|
||||
intent / confidence / normalized_instruction
|
||||
scope: conversation | node | artifact_lineage | section | plan
|
||||
requested_target_ids[] / requirement_patch?
|
||||
requires_confirmation / cost_level / reason
|
||||
```
|
||||
|
||||
服务端必须验证 `target_node_id`、`agent_run_id` 属于当前 session/run,再解析到 node 和成果物血缘;不可相信客户端传入的任意 ID。`ImpactAnalyzer` 产出实际 `impact_nodes`、失效成果和新 attempt 计划,用户所见的命令状态由它和 `TaskLedger` 写入,而非由协调者文本决定。
|
||||
|
||||
`IntentRouterAgent` 的置信度不足、目标不唯一、涉及 requirement/plan revision、预计成本为 high 或影响多个并行分支时,必须返回 `requires_confirmation=true`;只有用户确认后 `TaskLedger` 才可执行。对于处于 `awaiting_input` 的节点,明确回答优先分类为 `answer_question`,只解锁该节点的依赖链。
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据库设计
|
||||
@@ -300,9 +405,9 @@ completed -> reopened -> queued
|
||||
| `report_collaboration_messages` | 对话原文镜像 | message_id、role、content、tool_calls_json、tool_call_id、agent_run_id、node_run_id、phase_id、visibility、metadata、command_id、created_at、completed_at |
|
||||
| `report_collaboration_plans` | 候选及修订 | proposal_group_id、revision、status、graph_json、validation_json |
|
||||
| `report_collaboration_runs` | 正式执行 | plan_id、status、lease_owner、lease_until、cancel_requested、next_event_seq |
|
||||
| `report_collaboration_node_runs` | 节点尝试 | node_id、attempt、status、contract_json、agent_snapshot、error、started_at、ended_at |
|
||||
| `report_collaboration_node_runs` | 节点尝试/ledger task | node_id、attempt、`node_run_id=${node_id}-attempt{attempt}`、status、contract_json、input_artifact_ids、command_overlay_revision、agent_snapshot、error、started_at、ended_at |
|
||||
| `report_collaboration_agent_runs` | 实际团队成员实例 | agent_run_id、node_run_id、phase_id、role、display_name、avatar_key、status、agent_snapshot、started_at、ended_at |
|
||||
| `report_collaboration_artifacts` | 成果物 | artifact_type、producer_node_run_id、schema_version、content_json、validation_status |
|
||||
| `report_collaboration_artifacts` | 成果物及血缘 | artifact_type、producer_node_run_id、requirement_revision、schema_version、content_json、validation_status、superseded_by |
|
||||
| `report_collaboration_events` | durable SSE | run_id、seq、event_type、payload、created_at |
|
||||
| `report_collaboration_commands` | 用户命令 | command_id、idempotency_key、intent、scope、status、expected_revision |
|
||||
| `report_collaboration_report_versions` | 报告版本 | version、parent_version_id、markdown、source_index、change_summary |
|
||||
@@ -316,6 +421,7 @@ completed -> reopened -> queued
|
||||
- 事件 seq 在 run 行内原子分配。
|
||||
- 用户消息、协调者消息、成员 TeamSay、流式回复、工具调用和工具返回使用稳定 `message_id` 与 timeline/event seq;同一 `agentRunId` 内严格保持因果顺序,保证 `MessageList` 能以原生消息重建全过程。
|
||||
- 并行成员的消息分别归属 `agent_run_id`,同一事件流仍全量持久化;前端选择标签只能过滤/聚焦,不能影响事件写入或消息顺序。
|
||||
- 成员状态的 event 投影使用 `session.snapshot` 中的增量 `agent_runs[]` 合并;不可自定义未被前端处理的 `agent_run.*` 事件。
|
||||
- 删除 session 显式删除子资源,不依赖 SQLite cascade pragma。
|
||||
- 写事务在 commit 前完成必要序列化。
|
||||
- 不使用跨连接 post-commit refresh。
|
||||
@@ -346,6 +452,7 @@ GET /api/report-collaboration/runs/{run_id}/events?after_seq={seq}
|
||||
GET /api/report-collaboration/runs/{run_id}/stream?after_seq={seq}
|
||||
POST /api/report-collaboration/runs/{run_id}/commands
|
||||
POST /api/report-collaboration/runs/{run_id}/commands/{command_id}/confirm
|
||||
POST /api/report-collaboration/runs/{run_id}/commands/{command_id}/cancel
|
||||
POST /api/report-collaboration/runs/{run_id}/cancel
|
||||
GET /api/report-collaboration/runs/{run_id}/nodes/{node_id}/contract
|
||||
|
||||
@@ -357,10 +464,17 @@ POST /api/report-collaboration/sessions/{session_id}/report-rewrites/{id}/appl
|
||||
POST /api/report-collaboration/sessions/{session_id}/report-versions/{id}/restore
|
||||
```
|
||||
|
||||
写请求必须支持:
|
||||
`GET /sessions/{session_id}` 必须返回完整 `SessionSnapshot`:
|
||||
|
||||
- `idempotencyKey`
|
||||
- `expectedRevision`
|
||||
```text
|
||||
session / messages / plans / run / nodes / agent_runs / commands /
|
||||
report_versions / last_seq
|
||||
```
|
||||
|
||||
写请求必须读取并回显:
|
||||
|
||||
- `X-Idempotency-Key`(服务端唯一去重键;用户消息镜像写入 `metadata.idempotency_key`)
|
||||
- `X-Expected-Revision`(陈旧修改返回 409,不静默覆盖)
|
||||
|
||||
计划请求和 run 请求只创建 durable command/job,不在请求生命周期中等待完整模型输出。
|
||||
|
||||
@@ -437,10 +551,11 @@ heartbeat
|
||||
|
||||
- 历史与实时使用相同 envelope。
|
||||
- 支持 `Last-Event-ID` 和 `after_seq`。
|
||||
- token delta 可 live-only 合帧,但需周期性保存可恢复 checkpoint。
|
||||
- 不能每 token 写数据库。
|
||||
- 高频 token/tool 参数增量按 50~250ms 或大小阈值合帧为**已持久化** `message.delta` / `tool_call.delta` / `tool_result.delta`;不能每 token 写数据库,也不能仅 live-only 后丢失重放。
|
||||
- 每一批增量与消息最新正文同事务写入,再向 Live Hub 发布;断线时能通过 `after_seq` 重放,快照也能读到最新 checkpoint。
|
||||
- 状态、成果、命令和报告版本必须 durable。
|
||||
- 读取事件暂时失败时保持连接并发送可恢复 warning。
|
||||
- `session.snapshot` 可以携带完整快照,也可以携带 `run`、`nodes`、`agent_runs` 的增量集合;成员状态变化必须用此事件投影。
|
||||
|
||||
`message.*` / `team_message.*` 的 `data` 必须携带 LangChain 兼容的消息字段和归属,而非仅有摘要:
|
||||
|
||||
@@ -466,26 +581,33 @@ heartbeat
|
||||
|
||||
工具调用先在同一条 `ai` 消息的 `tool_calls` 上创建/追加参数 delta;返回值随后以 `type: "tool"`、相同 `tool_call_id` 的独立消息传输。允许用户查看的正文、参数和返回必须保持原文以及原始增量边界;只有统一脱敏器标记的字段可替换,替换理由写入 metadata。每个成员在开始生成时先发送 `message.created`,因此即使首 token 尚未到达,前端也能立即显示该成员正在回复,而非静默等待。
|
||||
|
||||
`reasoning_delta` 仅可承载经策略批准的、面向用户的工作说明(如“正在比对统计口径”),不允许桥接模型隐藏推理链、系统 prompt 或内部审稿思路;如模型未提供安全的公开工作说明,省略字段,过程可见性由 TeamSay、工具步骤、来源与普通 assistant 文本承担。
|
||||
|
||||
---
|
||||
|
||||
## 8. 分步实施计划
|
||||
|
||||
工单状态见 §0。本节正文仍是验收标准,不因部分完成而删减。
|
||||
|
||||
### BE-0:AgentScope 兼容性尖峰
|
||||
|
||||
**状态:条件完成(`RC-BE-000`)。** 事件适配与 dict/真实事件单测已有;真实双模型流式与 `uv.lock` 仍属后续 POC。20 项标注评测与 20 次固定任务统计已在 `RC-BE-018` 落地。
|
||||
|
||||
先写实验和测试,不接前端,不建正式业务表。
|
||||
|
||||
验证:
|
||||
|
||||
1. 当前 Python 3.12 与稳定 AgentScope 2.x 兼容。
|
||||
2. 固定精确版本和 commit,不使用浮动 `main`。
|
||||
1. 当前 Python 3.12 与候选 `agentscope==2.0.5` 兼容;POC 通过后才将精确版本写入 lock,不能使用浮动 `main`。
|
||||
2. 验证单任务 worker 所需的 Agent、Tool、Event、Permission API;Agent Team 的自主调度与 Session SSE 不属于本功能的正确性依赖。
|
||||
3. `uv lock` 后检查 Pydantic、SQLAlchemy、FastAPI、OpenAI SDK 冲突。
|
||||
4. 使用当前至少两个模型完成流式回复。
|
||||
5. leader + 2 workers 完成任务分配、TeamSay、并发和结构化输出。
|
||||
5. `TaskLedger` 分派 2 个并行 worker,完成可见 TeamSay、并发和结构化输出。
|
||||
6. 主动取消、超时、工具异常和成员异常行为可控。
|
||||
7. AgentScope Event 可稳定关联 agent、task、message、tool call,并能一对一写出 LangChain 兼容的 assistant/tool 消息。
|
||||
8. 内存状态丢失后可由 DeerFlow task ledger/artifact 重建。
|
||||
9. 连续运行 20 次固定任务,统计早停、空输出、非法 JSON、事件缺失。
|
||||
10. 从首个 `REPLY_START`、TeamSay、tool-call start、tool result 到 token delta 的首个可见事件均在指定时限内到达 adapter;并行两个成员交错输出时不串 message ID 或 tool call ID。
|
||||
10. 从首个 `REPLY_START`、TeamSay、tool-call start、tool result 到 token delta 的首个可见事件均在指定时限内到达 adapter;并行两个成员交错输出时不串 message ID 或 tool call ID,并能按前端既定 `phase_id` / `node_run_id` 聚焦。
|
||||
11. 人为注入流式中断、未闭合 tool call、等待用户输入与恢复场景;adapter 必须补发终态 tool result,不能留下永久 loading 的工具步骤。
|
||||
|
||||
交付:
|
||||
|
||||
@@ -502,6 +624,8 @@ Go 条件:
|
||||
|
||||
### BE-1:配置、依赖和总开关
|
||||
|
||||
**状态:完成(`RC-BE-002`)。** `enabled` / `worker_enabled` 默认 `false`。
|
||||
|
||||
新增 `config.yaml -> report_collaboration`:
|
||||
|
||||
```text
|
||||
@@ -538,6 +662,8 @@ cost_budget
|
||||
|
||||
### BE-2:领域合同和状态机
|
||||
|
||||
**状态:完成(`RC-BE-001`)。**
|
||||
|
||||
完成 Pydantic 合同:
|
||||
|
||||
- requirement。
|
||||
@@ -560,6 +686,8 @@ cost_budget
|
||||
|
||||
### BE-3:迁移和持久化 Store
|
||||
|
||||
**状态:完成(`RC-BE-003`)。** 表前缀 `report_collaboration_*`,迁移 `20260905_01`。
|
||||
|
||||
任务:
|
||||
|
||||
1. 创建数据库迁移。
|
||||
@@ -579,15 +707,19 @@ cost_budget
|
||||
|
||||
### BE-4:会话、消息和计划 API 骨架
|
||||
|
||||
**状态:骨架完成(`RC-BE-004`)+ SSE 已挂载(`RC-BE-011`)+ 改写/恢复已挂载(`RC-BE-015`)。** REST 会话/消息/方案/run 已挂载;`GET /runs/{id}/stream` 为 durable SSE。
|
||||
|
||||
任务:
|
||||
|
||||
- session CRUD 和用户隔离。
|
||||
- `GET /sessions/{id}` 一次返回前端 `SessionSnapshot` 的全部字段;run 前的澄清/规划阶段由该快照在 2.5 秒轮询内可见。
|
||||
- message 持久化:完整保存用户可见的 assistant、TeamSay、`tool_calls` 与 `tool` 返回,并保留 `agent_run_id`/`node_run_id`/`phase_id`;不只保存阶段摘要。
|
||||
- plan request 入队。
|
||||
- plan list/select。
|
||||
- run create/query。
|
||||
- OpenAPI 响应模型。
|
||||
- 路由注册顺序和统一错误模型。
|
||||
- 按 `frontend-web/src/report-collaboration/api/types.ts` 建立契约测试:字段名保持 snake_case,枚举、事件类型、命令确认/取消端点和 revision 头不可漂移。
|
||||
|
||||
安全:
|
||||
|
||||
@@ -597,14 +729,17 @@ cost_budget
|
||||
|
||||
### BE-5:需求解析和澄清
|
||||
|
||||
`RequirementResolver`:
|
||||
**状态:完成(`RC-BE-005`)。** 规则路径;尚未接规划用 LLM。澄清完成不会自动 `plan-requests`。
|
||||
|
||||
`IntentRouterAgent`(规划前模式)与 `RequirementResolver`:
|
||||
|
||||
1. 读取会话消息和最新 requirement snapshot。
|
||||
2. 提取主题、目标、受众、范围、角度、模板和来源限制。
|
||||
3. 判断是否缺少影响方案选择的必要信息。
|
||||
4. 必要时产生一个或多个独立 clarification。
|
||||
5. 用户回答后创建新 requirement revision。
|
||||
6. 达到澄清上限后使用明确假设继续,不能无限追问。
|
||||
2. `IntentRouterAgent` 输出结构化需求提取、澄清判定或“可直接规划”判定;不得自行建图或启动任务。
|
||||
3. `RequirementResolver` 校验并写入主题、目标、受众、范围、角度、模板和来源限制。
|
||||
4. 判断是否缺少真正影响方案选择的必要信息。
|
||||
5. 必要时产生一个或多个独立 clarification。
|
||||
6. 用户回答后创建新 requirement revision。
|
||||
7. 达到澄清上限后使用明确假设继续,不能无限追问。
|
||||
|
||||
验收:
|
||||
|
||||
@@ -614,6 +749,8 @@ cost_budget
|
||||
|
||||
### BE-6:智能体目录、筛选和候选计划
|
||||
|
||||
**状态:完成(`RC-BE-006`)。** 选择器为规则重排,未接规划模型。`POST /plan-requests` 在 `worker_enabled=false` 时于请求内同步生成方案(非 LLM)。
|
||||
|
||||
五阶段:
|
||||
|
||||
1. `AgentCatalogProjection`:读取用户可见且启用的 agent、技能和工具元数据。
|
||||
@@ -638,6 +775,8 @@ cost_budget
|
||||
|
||||
### BE-7:AgentScope 模型适配
|
||||
|
||||
**状态:完成(`RC-BE-007`)。** 适配器鸭子类型对齐 `ChatModelBase.__call__`;运行前冻结快照。真实 Provider 冒烟仍属 `RC-BE-009` / `017`。
|
||||
|
||||
目标:让 AgentScope 使用 DeerFlow 配置的模型,不再建立第二套 Provider 配置。
|
||||
|
||||
任务:
|
||||
@@ -657,13 +796,15 @@ cost_budget
|
||||
|
||||
### BE-8:工具与权限适配
|
||||
|
||||
**状态:完成(`RC-BE-008`)。**
|
||||
|
||||
第一版允许:
|
||||
|
||||
- Web 检索和正文获取。
|
||||
- 用户有权限的知识库/Wiki 检索。
|
||||
- 明确批准的只读技能/MCP。
|
||||
- 成果物板读写工具。
|
||||
- AgentScope 团队通信与任务工具。
|
||||
- 用户可见交接(TeamSay)与受控成果物/澄清工具。
|
||||
|
||||
第一版默认禁止:
|
||||
|
||||
@@ -678,7 +819,7 @@ cost_budget
|
||||
- Researcher 不能发布报告。
|
||||
- Writer 不能把未核验证据改成已核验。
|
||||
- Reviewer 只能输出 ReviewDecision,不能覆盖报告。
|
||||
- Coordinator 只能通过受控工具创建任务、分派、等待、返工和建议完成。
|
||||
- Coordinator 只能提出受控的计划、澄清与修复建议;实际创建任务、分派、等待、返工和完成由 `TaskLedger` 服务执行。
|
||||
- 所有成员获得本次 run 的最小权限快照。
|
||||
|
||||
验收:
|
||||
@@ -688,7 +829,9 @@ cost_budget
|
||||
- 网页 prompt injection 不能提升权限。
|
||||
- AgentScope 工具异常被转换为 node/tool event,不泄露内部堆栈给用户。
|
||||
|
||||
### BE-9:AgentScope Team 运行器
|
||||
### BE-9:受控任务成员运行器(不采用自治 Team 调度)
|
||||
|
||||
**状态:完成(`RC-BE-009` 脚本内核 + `RC-BE-013` 质量角色内核)。** `TaskLedger` + `WaveExecutor` + `QualityRoleKernel`。真实 AgentScope `reply_stream` / web_search 仍待接。
|
||||
|
||||
基础角色:
|
||||
|
||||
@@ -706,18 +849,21 @@ cost_budget
|
||||
- 计划决定 Researcher/Analyst 数量和角度。
|
||||
- 服务器限制团队人数、并发、attempt、返工和预算。
|
||||
- 每个成员上下文只包含当前任务、需求快照和必要上游成果。
|
||||
- Coordinator 不直接操作数据库,只调用 TaskLedger/ArtifactBoard 工具。
|
||||
- `Coordinator` 只接收结构化成果摘要、质量失败与用户命令影响,输出候选计划/修复建议;它不拥有自由消息总线或数据库权限。
|
||||
- `TaskLedger` 依据已验证成果、预算与并发限额逐波创建 AgentScope worker;AgentScope 不选择下一节点,也不决定任务完成。
|
||||
- 每个 worker 只能调用 `submit_artifact`、`request_clarification`、`request_repair`、允许的资料工具和可见 TeamSay;所有改变运行状态的调用都由服务端重新校验。
|
||||
- 任务完成由 QualityGate 判定,不接受文本声明。
|
||||
|
||||
事件映射:
|
||||
|
||||
- AgentScope `REPLY_*` → 以原始文本 chunk 创建/更新协调者或成员的稳定 assistant 消息,保留 `agentRunId`;不得等待 reply 完成后再合成为进度摘要。
|
||||
- AgentScope TeamSay → 以成员身份创建/更新普通 assistant 消息,保留完整用户可见交接内容与因果顺序。
|
||||
- AgentScope TeamSay → 以成员身份创建/更新普通 assistant 消息,保留完整用户可见交接内容与因果顺序;它不会改变 ledger 状态。
|
||||
- `MODEL_CALL_*` → 内部遥测,默认不下发原始 prompt。
|
||||
- `TOOL_CALL_*` → 原生 `ai.tool_calls` 参数事件;对应工具返回 → 原生 `tool` 消息和相同 `tool_call_id`。两者在 SSE 和持久化中一一对应,供现有 `MessageGroup` 直接渲染。
|
||||
- team task/update → node/task ledger 状态。
|
||||
- `TaskLedger` 的分派/校验/重试/取消结果 → node/task ledger 状态;AgentScope 仅上报可验证的 TaskResult。
|
||||
- 成果工具 → artifact created/validated/rejected。
|
||||
- 每个用户可见阶段开始、进展、完成、等待、返工和失败都必须同步产生稳定的消息或状态事件,供左侧 DeerFlow `MessageList` 按原始消息顺序展示;不能要求前端读取节点结果后自行拼装过程,也不能用聚合卡替换 AgentScope 的实时可见消息。
|
||||
- agent run 的开始、等待输入、完成、失败和取消,紧邻节点状态事件发 `session.snapshot` 增量,至少包括相应的 `agent_runs[]` 和当前 `run.current_phase`。
|
||||
|
||||
验收:
|
||||
|
||||
@@ -727,6 +873,8 @@ cost_budget
|
||||
|
||||
### BE-10:成果物板与质量门
|
||||
|
||||
**状态:完成(`RC-BE-010`)。** `QualityGate` 为确定性规则(无二次模型调用)。模板冻结快照与正式 `ReportVersion` 落库已在 `RC-BE-015` 落地。
|
||||
|
||||
`ArtifactBoard`:
|
||||
|
||||
- 按 run/node/attempt/type 保存成果。
|
||||
@@ -736,21 +884,26 @@ cost_budget
|
||||
|
||||
`QualityGate`:
|
||||
|
||||
- EvidenceBundle:来源数量、日期、URL/文档 ID、摘录和去重。
|
||||
- VerificationResult:支持与反对证据、冲突、时效。
|
||||
- AngleAnalysis:结论必须引用验证过的 evidence ID。
|
||||
- ReportSectionDraft:关键事实和数字必须有引用。
|
||||
- ReviewDecision:问题必须有目标、严重度和修复要求。
|
||||
- ReportVersion:模板覆盖、引用完整、章节一致、正式版本已持久化。
|
||||
- `CoverageMatrix`:把需求中的每个必选角度/关键问题映射到证据目标、分析节点和模板章节;计划和最终版本均不可漏项。
|
||||
- `EvidenceBundle`:按**主张级**保存 `claim_id`、来源 URL/文档 ID、发布/获取时间、原文摘录、事实或推断标签、适用范围、数值单位/口径、可信度与去重结果。
|
||||
- `VerificationResult`:对关键主张给出支持与反对证据、冲突、时效、来源独立性和可用结论;数字及重要事实必须经此门。
|
||||
- `AngleAnalysis`:结论只能引用验证过的 evidence ID,必须分别标示事实、推断、假设、局限和资料缺口。
|
||||
- `ReportSectionDraft`:关键事实和数字必须有 evidence ID;章节的主张与引用一一可追溯。
|
||||
- `CitationTemplateLinter`:校验模板章节、必答问题、来源格式、数字/日期/实体一致性、不可用或过期来源和未引用的关键事实。
|
||||
- `ReviewDecision`:独立于 Writer 上下文运行,问题必须有目标、严重度、证据和修复要求;不能笼统地要求“整体再优化”。
|
||||
- `ReportVersion`:覆盖矩阵、引用、模板、章节衔接、结论一致性和正式版本持久化全部通过才可创建。
|
||||
|
||||
验收:
|
||||
|
||||
- 无成果、空成果和不合 Schema 的成果不能完成节点。
|
||||
- 未验证来源不能直接进入最终事实陈述。
|
||||
- Reviewer 的返工目标可映射到具体节点和章节。
|
||||
- 同一事实出现冲突或同一数字缺少口径时,QualityGate 必须拒绝或要求定向补证,不能由 Writer 擅自选择一个答案。
|
||||
|
||||
### BE-11:后台 Dispatcher、租约和恢复
|
||||
|
||||
**状态:完成(`RC-BE-012`)。** `worker_enabled` 默认仍为 false:Gateway lifespan 只有 `enabled` **且** `worker_enabled` 同时为真才 `dispatcher.start()`。测试走 `dispatch_once()` / `executor.execute()`,不要依赖后台循环。
|
||||
|
||||
复用现有成熟模式:
|
||||
|
||||
- active-dedupe key。
|
||||
@@ -780,6 +933,8 @@ cost_budget
|
||||
|
||||
### BE-12:SSE 和 Live Hub
|
||||
|
||||
**状态:完成(`RC-BE-011`)。** `ConversationReplica` 已实现协议镜像;产品 SSE 经 `execution/live_hub.py` 挂载。报告正式版本写入已在 `RC-BE-015` 落地。
|
||||
|
||||
任务:
|
||||
|
||||
- 实现 event repository。
|
||||
@@ -790,6 +945,7 @@ cost_budget
|
||||
- 将高频 token delta 合帧。
|
||||
- 建立 `ConversationReplica`:将 AgentScope 用户可见消息、TeamSay、assistant delta、`tool_calls`、`tool` 返回、协助请求、报告文件和领域状态写成可重放的统一消息事件。它是协议镜像而不是摘要生成器。
|
||||
- 对话镜像完整保留用户有权查看的 TeamSay、原始工具参数和返回;不包含 chain-of-thought、系统/内部 prompt、密钥、权限不可见内容和异常堆栈。脱敏在入库/下发前一次执行,不能由前端自行推断。
|
||||
- 对前端已支持的 `reasoning_delta`,仅推送由本系统生成或模型明确允许公开的短工作说明;它不等同于原始思维链,也不得用作质量门、调度或审计的唯一依据。
|
||||
- `agentRunId` 是并行消息流的稳定归属。Live Hub 始终发布所有成员事件;前端标签切换只在本地选择消息,不启动第二条运行流,也不取消未聚焦成员的 delta。
|
||||
|
||||
验收:
|
||||
@@ -799,10 +955,15 @@ cost_budget
|
||||
- 终态前报告正式版本已写入并可读。
|
||||
- SSE 断开不取消后台 run。
|
||||
- 仅依靠会话快照和事件重放即可重建每个成员的完整原生消息、工具过程和左侧聚焦视图,不需要调用节点结果接口。
|
||||
- 在模型/工具中断、取消和接管时,所有已开始的 tool call 都有对应的 completed tool result(成功、失败或 interrupted),不会让复用的 `MessageList` 永久显示执行中。
|
||||
|
||||
### BE-13:全过程命令与影响分析
|
||||
|
||||
`IntentRouter` 输入:
|
||||
**状态:完成(`RC-BE-014`)。** `app/report_collaboration/interventions/` 已实现运行期 IntentRouterAgent、ImpactAnalyzer 和 RuntimeCommandService;SQL/memory store 通过 `patch_command(..., expected_statuses=...)` 对命令执行权做 CAS。置信度阈值由 `report_collaboration.intent_confirmation_threshold` 配置(默认 `0.8`)。
|
||||
|
||||
实现边界:本票负责识别、授权、影响计算、确认、定向失效、overlay 和安全重排队。`rewrite_section` / `polish_report` 已能严格保留研究链并只返工写作/审校链;replacement candidate、正式版本 apply/restore 已在 `RC-BE-015` 落地。`add_angle` 优先使用用户定向的研究分支,否则按冻结计划顺序确定性选择一个研究分支,只接入其必要下游;无研究支路立即 `rejected`。`rerun_node` 没有合法目标时不得扩成整图。需求补丁只在返工成功后写入。卡住的 `executing` 可在接管或租约超时后收回。`command.cancelled` 进入 SSE。运行中 `POST /sessions/{id}/messages` 返回 409。
|
||||
|
||||
`IntentRouterAgent`(运行中干预模式)输入:
|
||||
|
||||
- 用户文本。
|
||||
- session/run 状态。
|
||||
@@ -810,7 +971,7 @@ cost_budget
|
||||
- 当前选中节点或章节上下文。
|
||||
- 后端动作白名单。
|
||||
|
||||
输出:
|
||||
先由规则层处理明确的回答、取消和节点/章节显式指令;其余由 `IntentRouterAgent` 输出:
|
||||
|
||||
```text
|
||||
intent
|
||||
@@ -823,6 +984,8 @@ cost_level
|
||||
reason
|
||||
```
|
||||
|
||||
模型输出必须通过 Pydantic 校验,并经服务端 target 授权与置信度/成本闸门后才创建可执行 command;`IntentRouterAgent` 不可调用分派、取消、成果写入或报告版本工具。
|
||||
|
||||
命令类型:
|
||||
|
||||
- `answer_question`
|
||||
@@ -839,11 +1002,15 @@ reason
|
||||
|
||||
`ImpactAnalyzer`:
|
||||
|
||||
- 重新检索:失效指定 EvidenceBundle 和引用它的下游。
|
||||
- 重新分析:保留 evidence,失效分析和对应章节。
|
||||
- 章节重写:保留研究/分析,只生成 replacement candidate。
|
||||
- 语气润色:创建新报告版本,不失效事实成果。
|
||||
- 全局改向:新建 plan revision,确认后创建 run branch。
|
||||
- `answer_question`:只解析相应的 clarification;仅解锁依赖该答案的节点,其他并行节点继续。
|
||||
- `research_again`:为指定节点/角度追加或替换 EvidenceBundle,失效其验证、分析、综合和受影响章节,不误伤无关角度。
|
||||
- `reanalyze`:保留 validated evidence,只失效指定分析、综合、写作和审校分支。
|
||||
- `add_angle`:在当前冻结计划中选择一个明确目标或确定性的研究支路承接新角度,再只失效其必要下游;无可用支路时拒绝执行。运行中不暗改前端已选择的 DAG,真正新增画布节点只发生在后续重新规划产生的新 plan revision 中。
|
||||
- `rerun_node`:以新 attempt 重跑指定节点及必要下游,旧结果保留为 superseded。
|
||||
- `rewrite_section`:保留研究/分析,只生成 replacement candidate 和该章节审校;用户 apply 后才产生新版本。
|
||||
- `polish_report`:不重检索;在当前正式版本上创建新版本,并再次执行模板/引用 lint。
|
||||
- `update_requirements` / `replan`:均先展示整图影响与高成本确认;前者写入新的 requirement revision 后重开冻结计划,后者重开当前计划全部节点。生成新候选 plan/run branch 的交互留在后续规划版本能力,不能在旧 run 的 SSE 内偷偷切换 run。
|
||||
- `ask_report`:只读取当前已验证成果或报告版本,不改变任务图。
|
||||
|
||||
Safe-point:
|
||||
|
||||
@@ -851,15 +1018,19 @@ Safe-point:
|
||||
- 不可立即取消的 attempt 继续到边界,command 状态为 `accepted_waiting_boundary`。
|
||||
- 晚到输出可保存但标记 superseded,不能进入下游。
|
||||
- 每条 command 有分类、影响范围、状态和结果。
|
||||
- command overlay 仅在模型调用之间、工具返回后或节点提交成果前生效;在不可中断工具期间不能伪称已经完成修改。
|
||||
|
||||
验收:
|
||||
|
||||
- 低置信度“改一下”必须追问。
|
||||
- 相同幂等键不重复创建返工任务。
|
||||
- 运行中命令不能造成 completed/cancelled 状态竞争。
|
||||
- 用户针对画布/成员卡的定向修改只影响解析出的节点和成果血缘,不能被协调者扩大为整场重跑。
|
||||
|
||||
### BE-14:报告模板、写作、审稿和版本
|
||||
|
||||
**状态:完成(`RC-BE-015`)。** 改写 / apply / restore 已挂载;正式版本在质量门通过后落库。
|
||||
|
||||
复用 `/api/report-structures`,run 开始时冻结模板快照:
|
||||
|
||||
- 章节层级。
|
||||
@@ -871,25 +1042,25 @@ Safe-point:
|
||||
|
||||
写作规则:
|
||||
|
||||
- Writer 只能使用 validated artifacts。
|
||||
- 关键判断绑定 evidence ID。
|
||||
- 无法证实的内容明确标为分析、假设或局限。
|
||||
- 每章获得确定 evidence set。
|
||||
- 完整报告执行引用、模板、衔接和结论一致性检查。
|
||||
- Reviewer 输出结构化 ReviewDecision。
|
||||
- Reviser 只修改指定问题。
|
||||
- Writer 只能使用冻结模板、`CoverageMatrix` 和 validated artifacts,禁止携带 Web/知识库工具或原始检索上下文进入写作提示词。
|
||||
- 关键判断绑定 evidence ID;无法证实的内容明确标为分析、假设或局限。
|
||||
- 每章获得确定 evidence set、字数目标和必答问题,输出章节草稿及主张—引用映射。
|
||||
- 完整报告先执行 CitationTemplateLinter,再由独立 Reviewer 进行事实、逻辑、受众适配和结论审校。
|
||||
- Reviewer 输出结构化 ReviewDecision;Reviser 只修改指定问题,并由同一质量门复验。
|
||||
- 达到返工上限后报告未解决问题,不无限循环。
|
||||
|
||||
版本规则:
|
||||
|
||||
- 流式 Markdown 是临时草稿,不覆盖当前正式版本。
|
||||
- 正式版本落库并确认后 run 才能 completed。
|
||||
- 正式版本须在所有质量门通过后落库,随后才发 `report.version.created` 并令 run completed。
|
||||
- 章节改写生成 replacement candidate。
|
||||
- 用户 apply 后创建新版本。
|
||||
- restore 也创建新头版本,不破坏历史。
|
||||
|
||||
### BE-15:安全、审计和预算
|
||||
|
||||
**状态:完成(`RC-BE-016`)。** 快照与 SSE 走统一脱敏;资源 owner、跨会话幂等、预算上限与审计已落地。
|
||||
|
||||
- 所有资源校验 owner 或明确授权。
|
||||
- agent snapshot 移除密钥和不可见 prompt。
|
||||
- 工具参数/结果完整镜像到用户消息流;只有密钥、令牌、隐私字段、越权内容和明确的异常堆栈由统一脱敏器替换并记录原因。不能以大小截断或摘要替代用户有权查看的返回;超大返回以可重放 chunk 传输和按需折叠展示,正文仍可完整读取。
|
||||
@@ -897,10 +1068,12 @@ Safe-point:
|
||||
- 每用户 active run 上限。
|
||||
- 每 run 团队、并发、检索、来源正文、模型调用、token、费用和总时长上限。
|
||||
- 审计 agent 选择、工具调用、来源、用户命令、人工确认和报告版本。
|
||||
- 不持久化、不下发原始 chain-of-thought、系统/内部 prompt 或模型推理 token;这不影响用户可见 assistant 文本、TeamSay、工具调用与工具返回的完整镜像。
|
||||
- 不持久化、不下发原始 chain-of-thought、系统/内部 prompt 或模型隐藏推理 token;前端可显示的 `reasoning_delta` 必须是经过独立策略筛选的公开工作说明。这不影响用户可见 assistant 文本、TeamSay、工具调用与工具返回的完整镜像。
|
||||
|
||||
### BE-16:测试与质量评测
|
||||
|
||||
**状态:完成(`RC-BE-018`)。** 合同 / 适配器 / 规划 / 需求 / SSE / lease / 20 项标注评测集已有。打分器不调用模型;金标必须过门,会商风失败实例必须可复现。
|
||||
|
||||
单元测试:
|
||||
|
||||
- Pydantic 合同。
|
||||
@@ -912,6 +1085,8 @@ Safe-point:
|
||||
- AgentScope event adapter。
|
||||
- 同一 `message_id` 的 token delta、同一 `tool_call_id` 的参数/结果 delta 以及脱敏字段替换;断线重放后消息正文不得被摘要化或重复。
|
||||
- 两个 `agentRunId` 并行交错输出、其中一员等待用户协助时,消息归属、工具配对、状态和命令目标均正确。
|
||||
- `session.snapshot` 增量 `agent_runs[]` 能驱动前端阶段卡从 pending → running → waiting_input → terminal;`node_run_id` 与画布节点前缀匹配。
|
||||
- 流式中断后每个已创建 tool call 都有终态 tool result,`MessageList` 不留下旋转步骤。
|
||||
|
||||
集成测试:
|
||||
|
||||
@@ -925,7 +1100,7 @@ Safe-point:
|
||||
真实 AgentScope 冒烟:
|
||||
|
||||
- 至少两个已配置模型。
|
||||
- leader + 多 worker。
|
||||
- ledger 驱动的多 worker(不依赖 AgentScope Team 自动分派)。
|
||||
- 并发工具和 TeamSay。
|
||||
- 取消、超时、异常成员和结果修复。
|
||||
|
||||
@@ -934,19 +1109,24 @@ Safe-point:
|
||||
- 不少于 20 个真实任务。
|
||||
- 覆盖政策影响、市场趋势、企业研究、事件研判、对比分析。
|
||||
- 每个任务记录必选角度、关键事实、禁止编造项、模板和评审规则。
|
||||
- 与现有多智能体会商进行盲评。
|
||||
- 指标:事实准确、来源质量、角度覆盖、逻辑一致、格式遵循、可修改性、耗时和成本。
|
||||
- 与现有多智能体会商进行盲评,并保留每项失败的可复现实例。
|
||||
- 指标:事实准确、来源质量、关键主张引用覆盖、角度覆盖、逻辑一致、模板遵循、定向修改正确率、耗时和成本。
|
||||
- 上线前为每个任务标注关键主张/数字、可接受来源、必选角度和模板约束;不能只以主观“文章更像报告”判定成功。
|
||||
|
||||
---
|
||||
|
||||
## 9. 首个纵向 POC
|
||||
|
||||
POC-1 的规则路径已通(三策略差异方案、select ≠ start)。**POC-2 脚本闭环已通**(研究员 → 撰写员,协调者建议不调度)。**POC-3 质量角色已通**(核验 / 分析 / 审稿定向返工,确定性内核)。真实检索与 AgentScope 成员仍待接。**POC-4 未开始**。
|
||||
|
||||
固定任务:
|
||||
|
||||
> 撰写一份新能源汽车主要品牌市场趋势分析报告,覆盖 2024 年至今的市场份额、价格策略、技术路线、海外进展、政策环境和未来 12~24 个月风险与机会,面向企业战略决策人员,要求所有关键数字给出来源。
|
||||
|
||||
### POC-1:候选计划
|
||||
|
||||
**状态:规则路径已通(`RC-BE-006`)。** 三策略差异方案、select ≠ start;尚未接规划 LLM。
|
||||
|
||||
- 生成 3 种真实有差异的方案。
|
||||
- 每个方案只引用有效 agent/tool/template。
|
||||
- 每个节点有职责、输入、输出和验收条件。
|
||||
@@ -954,6 +1134,8 @@ Safe-point:
|
||||
|
||||
### POC-2:三角色闭环
|
||||
|
||||
**状态:脚本路径已通(`RC-BE-009`),SSE 重放已挂载(`RC-BE-011`)。** 真实 Web 检索仍待工具实装接入运行器。
|
||||
|
||||
角色:Coordinator、Researcher、Writer。
|
||||
|
||||
- 完成真实 Web 检索。
|
||||
@@ -964,6 +1146,8 @@ Safe-point:
|
||||
|
||||
### POC-3:质量角色
|
||||
|
||||
**状态:完成(`RC-BE-010` 质检 + `RC-BE-013` 受控成员 + `RC-BE-017` 真实内核)。** `QualityRoleKernel` 跑通 Verifier / Analyst / Writer / Reviewer / Reviser;过期与跨包冲突写入 `VerificationResult`;缺失角度只重开检索链;审稿失败只重开目标写作节点。生产 worker 使用 `AgentScopeMemberKernel`。
|
||||
|
||||
加入 Verifier、Analyst、Reviewer、Reviser。
|
||||
|
||||
- 过期资料被识别。
|
||||
@@ -973,6 +1157,8 @@ Safe-point:
|
||||
|
||||
### POC-4:全过程干预
|
||||
|
||||
**状态:命令与 safe-point 路径完成(`RC-BE-014`)。** 增加角度、指定角度重检索、章节重写/润色的定向影响已覆盖;运行中旧 attempt 在边界后 supersede,晚到成果不能进入下游。正式报告 replacement/version 展示已在 `RC-BE-015` 落地。
|
||||
|
||||
必须演示:
|
||||
|
||||
1. “增加供应链与原材料风险角度”。
|
||||
@@ -986,28 +1172,36 @@ Safe-point:
|
||||
|
||||
## 10. 后端开发顺序
|
||||
|
||||
1. `RC-BE-000`:AgentScope 版本与兼容性尖峰。
|
||||
已完成:
|
||||
|
||||
1. `RC-BE-000`:AgentScope 版本与兼容性尖峰(条件 Go;未写入 `uv.lock`)。
|
||||
2. `RC-BE-001`:领域合同和状态机。
|
||||
3. `RC-BE-002`:配置、依赖和 feature flag。
|
||||
4. `RC-BE-003`:数据库迁移和 stores。
|
||||
5. `RC-BE-004`:session/message/plan API。
|
||||
5. `RC-BE-004`:session/message/plan API(改写 / 恢复见 `015`)。
|
||||
6. `RC-BE-005`:需求解析与澄清。
|
||||
7. `RC-BE-006`:agent catalog、selector、plan assembler/validator。
|
||||
8. `RC-BE-007`:AgentScope model adapter。
|
||||
9. `RC-BE-008`:tool/permission adapter。
|
||||
10. `RC-BE-009`:三角色 team POC。
|
||||
11. `RC-BE-010`:task ledger、artifact board、quality gate。
|
||||
10. `RC-BE-009`:三角色 team POC(脚本内核 + TaskLedger 波次)。
|
||||
11. `RC-BE-010`:artifact board 主张级质量门(Coverage / 核验 / 审稿)。
|
||||
12. `RC-BE-011`:durable event、原生消息镜像、SSE 和 live hub。
|
||||
13. `RC-BE-012`:dispatcher、executor、lease、cancel、recovery。
|
||||
14. `RC-BE-013`:Verifier/Analyst/Reviewer/Reviser。
|
||||
15. `RC-BE-014`:intent router、impact analyzer、safe-point commands。
|
||||
16. `RC-BE-015`:report structures、report versions、rewrite candidates。
|
||||
17. `RC-BE-016`:安全、审计、预算、监控。
|
||||
18. `RC-BE-017`:并发、故障恢复和真实 AgentScope 测试。
|
||||
19. `RC-BE-018`:20 项质量评测和参数调优。
|
||||
|
||||
15. `RC-BE-014`:intent router、impact analyzer、safe-point commands(已完成)。
|
||||
16. `RC-BE-015`:report structures、report versions、rewrite candidates(已完成)。
|
||||
17. `RC-BE-016`:安全、审计、预算、监控(已完成)。
|
||||
18. `RC-BE-017`:并发、故障恢复和真实 AgentScope 测试(已完成)。
|
||||
19. `RC-BE-018`:20 项质量评测和参数调优(已完成)。
|
||||
|
||||
下一票起:
|
||||
|
||||
20. `RC-BE-019`:灰度与离线发布接入。
|
||||
|
||||
前后端共同依赖:在 `RC-BE-001` 后冻结 OpenAPI、event envelope 和共享枚举,前端再开始真实 API 层开发。
|
||||
编号说明:§8 的 `BE-n` 与上表 `RC-BE-00n` 在 **008 之后不完全一一对应**(例如 §8 `BE-11` dispatcher ≈ `RC-BE-012`,`BE-12` SSE ≈ `RC-BE-011`)。排期与代码以 `RC-BE-*` 为准。
|
||||
|
||||
前后端共同依赖:OpenAPI / event envelope / 共享枚举已按 `frontend-web/src/report-collaboration/api/types.ts` 冻结;前端页面与 mock 已完成。真实 SSE 可联调 `GET /runs/{id}/stream`(`RC-BE-011`);领取/恢复见 `RC-BE-012`;质量角色见 `RC-BE-013`;运行中自然语言命令见 `RC-BE-014`。正式报告改写候选与版本 apply/restore 已在 `RC-BE-015` 挂载。生产默认仍不挂后台循环(`worker_enabled=false`)。
|
||||
|
||||
---
|
||||
|
||||
@@ -1082,7 +1276,7 @@ TeamSay 不作为事实来源;报告事实只能引用 validated EvidenceBundl
|
||||
|
||||
后端只有同时满足以下条件才算完成:
|
||||
|
||||
1. 使用 AgentScope 2.0 Core 组织真实 leader-worker 团队。
|
||||
1. 使用 AgentScope 2.0 Core 执行真实的受控任务成员,且由 DeerFlow `TaskLedger` 而非自治 Team 调度协作。
|
||||
2. 不调用旧 Workflow Studio DAG scheduler。
|
||||
3. 不改变现有多智能体会商行为。
|
||||
4. DeerFlow 数据库是状态唯一事实源。
|
||||
|
||||
@@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=true
|
||||
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
|
||||
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
||||
|
||||
# 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。
|
||||
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||
# CMZS后端接口
|
||||
|
||||
@@ -59,4 +59,13 @@
|
||||
# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。
|
||||
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。
|
||||
# VITE_COMPACT_MENU_TYPE=A
|
||||
# 问答页「极速」模式:true=在模式菜单显示并直连大模型;false 或未设=隐藏。
|
||||
# 部署后优先改 dist/runtime-config.js 的 VITE_TURBO_MODE_VISIBLE,刷新即可。
|
||||
# VITE_TURBO_MODE_VISIBLE=false
|
||||
# VITE_GITHUB_OAUTH_TOKEN=
|
||||
|
||||
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
|
||||
# true 时,普通登录后进入同一 Vite 应用中隔离的 /official/*。
|
||||
# 已指定 redirect 或 taskId/goPath 的登录仍保持原来的优先级。
|
||||
# 仅 .env.development 默认 true;其余环境保持 false。
|
||||
# VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
|
||||
@@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=false
|
||||
VITE_BACKEND_BASE_URL=http://10.19.200.5:2026
|
||||
VITE_LANGGRAPH_BASE_URL=http://10.19.200.5:2026/api
|
||||
|
||||
# 内网环境保持当前定制版默认入口。
|
||||
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||
# CMZS后端接口
|
||||
|
||||
@@ -14,6 +14,9 @@ VITE_MOCK_SQ_REPORT=false
|
||||
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
|
||||
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
|
||||
|
||||
# 线上保持当前定制版默认入口。
|
||||
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
|
||||
# CMZS后端接口
|
||||
|
||||
@@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=false
|
||||
VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow
|
||||
VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api
|
||||
|
||||
# SIT 环境保持当前定制版默认入口。
|
||||
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
|
||||
# CMZS后端接口
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
|
||||
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
|
||||
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
|
||||
},
|
||||
|
||||
@@ -151,6 +151,11 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
|
||||
VITE_GITHUB_OAUTH_TOKEN: "",
|
||||
|
||||
// 是否在问答输入框「模式」菜单显示「极速」:"true" 显示,"false" 隐藏。
|
||||
// 默认关闭。开启后可直连大模型流式问答,不走智能体工具与技能。
|
||||
// 部署后改 dist/runtime-config.js 再刷新页面即可,无需重新打包。
|
||||
VITE_TURBO_MODE_VISIBLE: "true",
|
||||
|
||||
// 是否在智能体管理页显示「舆情分析」类型:"true" 显示,"false" 隐藏。
|
||||
// 关闭后,该智能体的专用对话地址也会自动跳回智能体管理页。
|
||||
VITE_SENTIMENT_AGENT_VISIBLE: "true",
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from "@/position-roundtable/demo/position-roundtable-demo-config";
|
||||
import { ChatRuntime } from "./pages/WorkspaceRoutes";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
import OfficialDeerFlowEntry from "./pages/OfficialDeerFlowEntry";
|
||||
|
||||
const PUBLIC_EMBED_PREFIXES = [
|
||||
"/share/",
|
||||
@@ -203,6 +204,7 @@ export function App() {
|
||||
path="/roundtable-dashboard"
|
||||
element={<DashboardStandalonePage />}
|
||||
/>
|
||||
<Route path="/official/*" element={<OfficialDeerFlowEntry />} />
|
||||
<Route path="/page/*" element={pageElement} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2064"
|
||||
height="2591" viewBox="0 0 2064 2591">
|
||||
<path fill-rule="nonzero"
|
||||
d="M 68.582031 0 C 66.890625 0 65.300781 1 64.617188 2.640625 C 49.46875 38.800781 33.71875 76.199219 33.628906 106.539062 C 33.550781 134.230469 46.445312 155.730469 59.949219 175.71875 L 59.949219 175.730469 C 52.414062 175.671875 44.992188 175.371094 37.472656 174.171875 C 27.210938 172.53125 16.585938 169.199219 5.585938 165.738281 C 5.15625 165.609375 4.722656 165.539062 4.296875 165.539062 C 2.824219 165.539062 1.417969 166.300781 0.625 167.609375 C -0.402344 169.300781 -0.140625 171.46875 1.257812 172.871094 L 1.261719 172.878906 C 11.230469 182.871094 21.8125 193.53125 36.980469 200.050781 C 51.5625 206.320312 70.167969 208.628906 88.085938 210.78125 L 88.027344 210.78125 C 127.152344 253.410156 166.617188 296.300781 215.761719 334.941406 C 230.558594 346.578125 246.292969 357.878906 261.785156 369 C 277.171875 380.050781 292.441406 391.011719 306.660156 402.191406 C 326.132812 417.511719 343.5625 433.179688 356.773438 450.019531 C 377.808594 476.828125 388.449219 506.960938 395.4375 538.628906 L 395.4375 538.640625 C 372.683594 510.710938 349.328125 483.929688 324.855469 469.339844 C 295.957031 452.121094 265.800781 452 236.960938 451.808594 L 236.933594 451.820312 C 235.792969 451.808594 234.707031 452.261719 233.898438 453.070312 C 233.085938 453.871094 232.632812 454.96875 232.636719 456.121094 C 232.722656 494.011719 232.699219 533.539062 256.304688 567.480469 C 279.351562 600.621094 324.738281 628.019531 369.191406 654.679688 L 369.132812 654.691406 C 371.476562 683.539062 373.40625 710.691406 367.109375 728.03125 C 365.828125 731.558594 364.191406 734.71875 362.058594 738.820312 C 358.910156 744.871094 354.964844 752.441406 350.417969 764.960938 C 342.890625 785.699219 333.769531 819.839844 323.648438 883.109375 C 303.347656 1010.019531 278.722656 1255.949219 308.882812 1409.269531 C 326.265625 1497.660156 361.828125 1555.210938 393.511719 1606.519531 C 417.042969 1644.632812 438.207031 1678.960938 448.835938 1720.09375 C 466.921875 1790.089844 454.648438 1880.246094 444.5 1954.386719 C 440.613281 1982.78125 437.042969 2008.839844 435.726562 2030.261719 C 433.410156 2067.929688 438.066406 2091.179688 442.304688 2112.347656 L 442.304688 2112.355469 C 445.910156 2130.347656 449.152344 2146.589844 447.757812 2169.28125 C 447.429688 2174.613281 446.835938 2180.359375 445.933594 2186.535156 L 445.933594 2186.542969 C 436.582031 2250.328125 393.613281 2362.808594 362.105469 2416.636719 C 339.761719 2454.804688 323.546875 2462.851562 309.597656 2469.925781 C 303.980469 2472.777344 298.261719 2475.660156 292.691406 2480.238281 C 273.378906 2496.113281 256.457031 2532.078125 240.183594 2566.417969 C 239.578125 2567.703125 239.640625 2569.203125 240.363281 2570.429688 C 241.082031 2571.65625 242.359375 2572.449219 243.777344 2572.542969 L 243.789062 2572.542969 L 243.882812 2572.550781 L 243.882812 2572.554688 C 261.582031 2573.746094 279.460938 2574.957031 296.386719 2574.957031 C 323.671875 2574.957031 349.332031 2571.84375 368.355469 2559.785156 C 392.640625 2544.394531 405.109375 2514.960938 414.488281 2493.074219 C 417.011719 2487.183594 419.289062 2481.871094 421.546875 2477.308594 C 432.054688 2456.039062 441.753906 2451.664062 451.855469 2446.960938 C 453.054688 2446.402344 453.929688 2445.320312 454.226562 2444.042969 L 454.238281 2443.996094 L 454.238281 2443.988281 C 476.210938 2348.800781 498.035156 2254.347656 524.300781 2196.363281 L 524.300781 2196.367188 C 536.363281 2169.730469 549.363281 2150.796875 562.695312 2131.402344 C 576.28125 2111.625 590.1875 2091.402344 603.300781 2062.445312 L 603.304688 2062.453125 C 605.183594 2058.292969 607.054688 2053.949219 608.894531 2049.421875 C 625.066406 2009.675781 639.746094 1954.289062 653.585938 1902.101562 C 663.0625 1866.367188 672.15625 1832.09375 681.085938 1805.085938 C 699.308594 1749.992188 716.863281 1725.09375 734.371094 1705.144531 L 734.421875 1705.148438 C 727.285156 1740.746094 721.597656 1776.628906 729.320312 1807.472656 C 733.351562 1823.570312 740.996094 1838.160156 749.066406 1853.613281 C 758.828125 1872.296875 769.433594 1892.636719 776.359375 1919.675781 C 783.289062 1946.71875 786.574219 1980.605469 789.527344 2011.140625 L 789.527344 2011.148438 C 791.875 2035.402344 794.023438 2057.78125 797.746094 2072.316406 L 797.746094 2072.320312 C 797.929688 2073.023438 798.128906 2073.769531 798.316406 2074.441406 L 798.320312 2074.441406 C 802.132812 2087.941406 807.617188 2093.914062 813.515625 2100.324219 C 821.132812 2108.597656 830.351562 2118.441406 839.742188 2151.671875 L 839.738281 2151.675781 C 840.105469 2152.96875 840.402344 2154.039062 840.765625 2155.398438 C 857.484375 2217.601562 874.097656 2354.589844 872.078125 2423.390625 C 870.472656 2478.15625 857.140625 2488.390625 845.339844 2498.136719 C 842.320312 2500.632812 839.085938 2503.296875 836.09375 2506.566406 C 820.847656 2523.230469 812.652344 2554.886719 804.800781 2584.753906 C 804.457031 2586.0625 804.75 2587.453125 805.589844 2588.515625 C 806.40625 Line truncated
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 19 KiB |
@@ -3,6 +3,7 @@
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import deerMaskUrl from "@/assets/deer.svg";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FlickeringGrid } from "@/components/ui/flickering-grid";
|
||||
import Galaxy from "@/components/ui/galaxy";
|
||||
@@ -29,7 +30,11 @@ export function Hero({ className }: { className?: string }) {
|
||||
/>
|
||||
</div>
|
||||
<FlickeringGrid
|
||||
className="absolute inset-0 z-0 translate-y-8 mask-[url(/images/deer.svg)] mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"
|
||||
className="absolute inset-0 z-0 translate-y-8 mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"
|
||||
style={{
|
||||
WebkitMaskImage: `url("${deerMaskUrl}")`,
|
||||
maskImage: `url("${deerMaskUrl}")`,
|
||||
}}
|
||||
squareSize={4}
|
||||
gridGap={4}
|
||||
color={"white"}
|
||||
|
||||
@@ -56,7 +56,7 @@ type PageSidebarProps = {
|
||||
headerHidden?: boolean;
|
||||
};
|
||||
|
||||
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
|
||||
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "encoder" | "about";
|
||||
|
||||
const hasActiveChild = (item: MenuItem, pathname: string): boolean =>
|
||||
menuItemContainsPath(item, pathname);
|
||||
@@ -270,6 +270,12 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
||||
模型配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
||||
<Cpu className="size-4" />
|
||||
本地编码器
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/settings/model-concurrency")}
|
||||
@@ -585,6 +591,12 @@ function LegacyAccordionSidebar({
|
||||
模型配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
||||
<Cpu className="size-4" />
|
||||
本地编码器
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
|
||||
<ShieldCheck className="size-4" />
|
||||
|
||||
@@ -51,6 +51,10 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import {
|
||||
MyProjectThreadBadge,
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useAgentsPaginated } from "@/core/agents";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
@@ -69,6 +73,7 @@ import {
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||
import {
|
||||
bootstrapResumePath,
|
||||
isBootstrapThread,
|
||||
@@ -479,6 +484,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
) : (
|
||||
filteredThreads.map((thread) => {
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const projectType = myProjectConversationTypeOfThread(thread);
|
||||
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
|
||||
const isActive = href === pathname;
|
||||
const pinned = isThreadPinned(thread);
|
||||
@@ -505,6 +511,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
)}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : projectType ? (
|
||||
<MyProjectThreadIcon type={projectType} />
|
||||
) : (
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
@@ -516,9 +524,15 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
{pinned && (
|
||||
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
{projectType && (
|
||||
<MyProjectThreadBadge
|
||||
className="mr-4"
|
||||
type={projectType}
|
||||
/>
|
||||
)}
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,12 @@ import { MessageSquareIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import {
|
||||
MyProjectThreadBadge,
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { useThreadsPage } from "@/core/threads/hooks";
|
||||
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -45,6 +50,7 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
||||
{threads.map((thread) => {
|
||||
const href = pathOfThread(thread);
|
||||
const isActive = href === pathname;
|
||||
const projectType = myProjectConversationTypeOfThread(thread);
|
||||
return (
|
||||
<Link
|
||||
key={thread.thread_id}
|
||||
@@ -57,15 +63,20 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<MessageSquareIcon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{projectType ? (
|
||||
<MyProjectThreadIcon type={projectType} />
|
||||
) : (
|
||||
<MessageSquareIcon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{desensitize(titleOfThread(thread, "新对话"))}
|
||||
</span>
|
||||
{projectType && <MyProjectThreadBadge type={projectType} />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -223,22 +223,19 @@ const LAYOUT_OPTIONS = [
|
||||
* 界面模式(全量 / 简洁)下拉切换。换肤旁并排,图标 + 下拉:
|
||||
* - 简洁模式 ChatPage 顶栏(全局顶栏被隐藏)。
|
||||
* - 全量模式 Header 右上角。
|
||||
* 用户未显式设置过界面模式时(mode === undefined),当前态按调用方所在的实际界面
|
||||
* 兜底——`fallback` 指明这一回退值(Header 传 "full",ChatPage 传 "compact")。
|
||||
* 当前态为有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认)——
|
||||
* useLayoutMode 总会返回具体值,不再有 undefined 兜底。
|
||||
*/
|
||||
export function LayoutModeSwitcher({
|
||||
tone,
|
||||
fallback = "compact",
|
||||
navigateTo = "/page/workspace/chats/new",
|
||||
}: {
|
||||
tone: AppearanceControlTone;
|
||||
fallback?: "full" | "compact";
|
||||
navigateTo?: string | null;
|
||||
}) {
|
||||
const [mode, setLayoutMode] = useLayoutMode();
|
||||
const navigate = useNavigate();
|
||||
const current = mode ?? fallback;
|
||||
const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon;
|
||||
const TriggerIcon = mode === "full" ? PanelLeftIcon : LayoutIcon;
|
||||
|
||||
if (env.FORCE_COLLAPSED_SIDEBAR || isHideChromeActive()) return null;
|
||||
|
||||
@@ -262,7 +259,7 @@ export function LayoutModeSwitcher({
|
||||
<DropdownMenuSeparator />
|
||||
{LAYOUT_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const active = current === option.id;
|
||||
const active = mode === option.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
|
||||
@@ -85,7 +85,7 @@ import {
|
||||
import { isArtifactApiPath } from "@/core/artifacts/streaming-write-preview";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
|
||||
import {
|
||||
checkCodeFile,
|
||||
getFileName,
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
} from "@/core/utils/files";
|
||||
import {
|
||||
downloadMermaidSvgFile,
|
||||
downloadSvgElementAsPng,
|
||||
isMermaidFile,
|
||||
openMermaidSvgInNewTab,
|
||||
renderMermaidToSvg,
|
||||
@@ -428,15 +429,15 @@ export function ArtifactFileDetail({
|
||||
if (streamPerfEnabled && isLiveStreaming) return raw;
|
||||
return desensitize(raw);
|
||||
}, [disableDesensitize, displayContent, isLiveStreaming, streamPerfEnabled]);
|
||||
const artifactContentPending =
|
||||
isCodeFile &&
|
||||
!useWriteFileStreaming &&
|
||||
content === undefined &&
|
||||
isArtifactContentLoading;
|
||||
// `write_file` 的 path 会先于正文抵达。它仍是正常的流式过程,不能显示成一块
|
||||
// 空白编辑器,更不能误导用户去点击重试真实文件;下一帧内容到达后会自然切换为代码视图。
|
||||
const streamingWriteContentPending =
|
||||
useWriteFileStreaming && writeFileInFlight && !content;
|
||||
const artifactContentPending =
|
||||
isCodeFile &&
|
||||
!useWriteFileStreaming &&
|
||||
content === undefined &&
|
||||
isArtifactContentLoading;
|
||||
// `write_file` 的 path 会先于正文抵达。它仍是正常的流式过程,不能显示成一块
|
||||
// 空白编辑器,更不能误导用户去点击重试真实文件;下一帧内容到达后会自然切换为代码视图。
|
||||
const streamingWriteContentPending =
|
||||
useWriteFileStreaming && writeFileInFlight && !content;
|
||||
const artifactContentUnavailable =
|
||||
isCodeFile &&
|
||||
!useWriteFileStreaming &&
|
||||
@@ -1770,10 +1771,10 @@ export function ArtifactFileDetail({
|
||||
activeCursor={isApplyingEdit}
|
||||
/>
|
||||
)}
|
||||
{(artifactContentPending || streamingWriteContentPending) && viewMode === "code" && (
|
||||
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 text-sm">
|
||||
<LoaderIcon aria-hidden="true" className="size-5 animate-spin" />
|
||||
{streamingWriteContentPending ? "正在接收文件内容…" : "正在加载文档内容…"}
|
||||
{(artifactContentPending || streamingWriteContentPending) && viewMode === "code" && (
|
||||
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 text-sm">
|
||||
<LoaderIcon aria-hidden="true" className="size-5 animate-spin" />
|
||||
{streamingWriteContentPending ? "正在接收文件内容…" : "正在加载文档内容…"}
|
||||
</div>
|
||||
)}
|
||||
{artifactContentUnavailable && viewMode === "code" && (
|
||||
@@ -1791,7 +1792,7 @@ export function ArtifactFileDetail({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isCodeFile && viewMode === "code" && !artifactContentPending && !streamingWriteContentPending && !artifactContentUnavailable && (
|
||||
{isCodeFile && viewMode === "code" && !artifactContentPending && !streamingWriteContentPending && !artifactContentUnavailable && (
|
||||
<div className="size-full">
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
@@ -2011,6 +2012,9 @@ export function ArtifactFilePreview({
|
||||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
||||
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
|
||||
const [mermaidError, setMermaidError] = useState<string | null>(null);
|
||||
const markdownPreviewRef = useRef<HTMLDivElement>(null);
|
||||
const mermaidChartRef = useRef<HTMLDivElement>(null);
|
||||
useStreamdownMermaidPngDownload(markdownPreviewRef);
|
||||
const researchSourcesQuery = useResearchSources(
|
||||
language === "markdown" ? (researchSessionId ?? null) : null,
|
||||
);
|
||||
@@ -2135,6 +2139,7 @@ export function ArtifactFilePreview({
|
||||
if (language === "markdown") {
|
||||
return (
|
||||
<div
|
||||
ref={markdownPreviewRef}
|
||||
data-artifact-preview-scroll
|
||||
data-markdown-anchor-root
|
||||
className={cn(
|
||||
@@ -2199,10 +2204,42 @@ export function ArtifactFilePreview({
|
||||
)}
|
||||
>
|
||||
{mermaidSvg ? (
|
||||
<div
|
||||
className="mx-auto flex min-h-full items-start justify-center rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"
|
||||
dangerouslySetInnerHTML={{ __html: mermaidSvg }}
|
||||
/>
|
||||
<div className="relative mx-auto flex min-h-full items-start justify-center rounded-2xl border border-slate-200 bg-white p-4 pt-12 shadow-sm">
|
||||
<div className="absolute right-3 top-3 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground rounded-md p-1"
|
||||
title="下载图片"
|
||||
onClick={() => {
|
||||
const svg = mermaidChartRef.current?.querySelector("svg");
|
||||
if (!(svg instanceof SVGSVGElement)) {
|
||||
toast.error("流程图尚未渲染完成,请稍后再下载");
|
||||
return;
|
||||
}
|
||||
void downloadSvgElementAsPng(svg, "flowchart.png").catch((error) => {
|
||||
console.error("Failed to download mermaid image:", error);
|
||||
toast.error(error instanceof Error ? error.message : "下载流程图失败");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground rounded-md p-1"
|
||||
title="新窗口打开"
|
||||
onClick={() => {
|
||||
void openMermaidSvgInNewTab(content ?? "").catch((error) => {
|
||||
console.error("Failed to open mermaid preview:", error);
|
||||
toast.error(error instanceof Error ? error.message : "打开流程图失败");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div ref={mermaidChartRef} dangerouslySetInnerHTML={{ __html: mermaidSvg }} />
|
||||
</div>
|
||||
) : mermaidError ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
|
||||
@@ -25,7 +25,6 @@ import { getBackendBaseURL } from "@/core/config";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||
import {
|
||||
@@ -130,9 +129,8 @@ const ChatBox: React.FC<{
|
||||
// batches through DeerFlow's backend APIs.
|
||||
const hasFrontendRag = useMemo(
|
||||
() =>
|
||||
(thread.values.messages ?? []).some(
|
||||
(message) =>
|
||||
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
|
||||
(thread.values.messages ?? []).some((message) =>
|
||||
isSingleRoundRagMessage(message),
|
||||
),
|
||||
[thread.values.messages],
|
||||
);
|
||||
@@ -1007,8 +1005,7 @@ function buildTurnReferenceBatches(
|
||||
// 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);
|
||||
const isFrontendRagTurn = isSingleRoundRagMessage(assistant);
|
||||
if (nextHumanIndex === -1 && isLoading && !isFrontendRagTurn) {
|
||||
continue;
|
||||
}
|
||||
@@ -1059,7 +1056,7 @@ function findFrontendRagAssistant(
|
||||
const message = messages[index];
|
||||
if (
|
||||
message?.type === "ai" &&
|
||||
(isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message))
|
||||
isSingleRoundRagMessage(message)
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import {
|
||||
MyProjectThreadBadge,
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { useAgentsPaginated, type Agent } from "@/core/agents";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -75,6 +79,7 @@ import {
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||
import {
|
||||
isBootstrapThread,
|
||||
isHiddenFromRecentChats,
|
||||
@@ -483,6 +488,7 @@ export function GeneralQaSidebar() {
|
||||
);
|
||||
const isActive = href === pathname;
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const projectType = myProjectConversationTypeOfThread(thread);
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
return (
|
||||
@@ -507,6 +513,8 @@ export function GeneralQaSidebar() {
|
||||
)}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : projectType ? (
|
||||
<MyProjectThreadIcon type={projectType} />
|
||||
) : (
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
@@ -518,9 +526,15 @@ export function GeneralQaSidebar() {
|
||||
{pinned && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
{projectType && (
|
||||
<MyProjectThreadBadge
|
||||
className="mr-4"
|
||||
type={projectType}
|
||||
/>
|
||||
)}
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
FolderTreeIcon,
|
||||
GaugeIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
PaperclipIcon,
|
||||
@@ -68,6 +69,7 @@ import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { Model } from "@/core/models/types";
|
||||
import type { AgentThreadContext } from "@/core/threads";
|
||||
import { isTurboModeVisible } from "@/core/threads/use-direct-chat";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -93,14 +95,14 @@ import {
|
||||
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import { useUserPreferences } from "@/core/user-preferences";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
useLocalSettings,
|
||||
} from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
|
||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
useLocalSettings,
|
||||
} from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
|
||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||
import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
@@ -223,16 +225,20 @@ export function InputBox({
|
||||
context.execution_profile === "single_round_rag";
|
||||
const multiRoundRagActive =
|
||||
context.execution_profile === "multi_round_rag";
|
||||
const turboModeVisible = isTurboModeVisible();
|
||||
const directChatActive =
|
||||
turboModeVisible && context.execution_profile === "direct";
|
||||
const frontendRagActive = singleRoundRagActive || multiRoundRagActive;
|
||||
const bypassAgentActive = frontendRagActive || directChatActive;
|
||||
const modelOptions = useMemo(() => {
|
||||
return singleRoundRagActive ? extraModels : models;
|
||||
}, [extraModels, models, singleRoundRagActive]);
|
||||
const { thread, isMock } = useThread();
|
||||
const { textInput } = usePromptInputController();
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
const { thread, isMock } = useThread();
|
||||
const { textInput } = usePromptInputController();
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
|
||||
const [followups, setFollowups] = useState<string[]>([]);
|
||||
const [followupsHidden, setFollowupsHidden] = useState(false);
|
||||
@@ -253,11 +259,37 @@ export function InputBox({
|
||||
recommendedQuestions.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
// Multi-round RAG is a frontend execution profile, not a model option.
|
||||
// Do not let the normal model fallback clear it on every render.
|
||||
// Frontend-only execution profiles are not model options.
|
||||
// Do not let the normal model fallback clear them on every render.
|
||||
if (!turboModeVisible && context.execution_profile === "direct") {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
execution_profile: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (multiRoundRagActive) {
|
||||
return;
|
||||
}
|
||||
if (directChatActive) {
|
||||
if (models.length === 0) {
|
||||
return;
|
||||
}
|
||||
const currentModel = models.find((m) => m.name === context.model_name);
|
||||
const fallbackModel = currentModel ?? models[0]!;
|
||||
if (
|
||||
context.model_name === fallbackModel.name &&
|
||||
context.execution_profile === "direct"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: fallbackModel.name,
|
||||
execution_profile: "direct",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (modelOptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -281,7 +313,7 @@ export function InputBox({
|
||||
mode: nextMode,
|
||||
execution_profile: fallbackModel.execution_profile,
|
||||
});
|
||||
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
|
||||
}, [context, directChatActive, modelOptions, models, multiRoundRagActive, onContextChange, turboModeVisible]);
|
||||
|
||||
const selectedModel = useMemo(() => {
|
||||
if (modelOptions.length === 0) {
|
||||
@@ -314,7 +346,10 @@ export function InputBox({
|
||||
model_name,
|
||||
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
||||
reasoning_effort: context.reasoning_effort,
|
||||
execution_profile: model.execution_profile,
|
||||
execution_profile:
|
||||
context.execution_profile === "direct"
|
||||
? "direct"
|
||||
: model.execution_profile,
|
||||
});
|
||||
setModelDialogOpen(false);
|
||||
},
|
||||
@@ -346,6 +381,16 @@ export function InputBox({
|
||||
[onContextChange, context, models, supportThinking],
|
||||
);
|
||||
|
||||
const handleDirectChatSelect = useCallback(() => {
|
||||
const ordinaryModel =
|
||||
models.find((model) => model.name === context.model_name) ?? models[0];
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: ordinaryModel?.name ?? context.model_name,
|
||||
execution_profile: "direct",
|
||||
});
|
||||
}, [context, models, onContextChange]);
|
||||
|
||||
const handleSingleRoundRagSelect = useCallback(() => {
|
||||
const ragModel =
|
||||
extraModels.find((model) => model.name === context.model_name) ??
|
||||
@@ -749,7 +794,7 @@ export function InputBox({
|
||||
/>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu> */}
|
||||
{!frontendRagActive && (
|
||||
{!bypassAgentActive && (
|
||||
<AddAttachmentsButton
|
||||
className={cn(
|
||||
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
@@ -758,7 +803,7 @@ export function InputBox({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!frontendRagActive && !hideContextTools && <Tooltip
|
||||
{!bypassAgentActive && !hideContextTools && <Tooltip
|
||||
content={
|
||||
context.writing_mode
|
||||
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
|
||||
@@ -784,7 +829,7 @@ export function InputBox({
|
||||
<span className="text-xs font-normal">写作</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!frontendRagActive && <Tooltip
|
||||
{!bypassAgentActive && <Tooltip
|
||||
content={
|
||||
artifactDirectoryModeEnabled
|
||||
? "文件模式已开启:右侧会显示当前对话生成的全部文件目录"
|
||||
@@ -813,7 +858,7 @@ export function InputBox({
|
||||
<span className="text-xs font-normal">文件</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
|
||||
{!bypassAgentActive && !hideContextTools && !hideMemoryTool && <Tooltip
|
||||
content={
|
||||
memoryInjectionEnabled
|
||||
? "记忆注入已开启:对话前会注入历史记忆"
|
||||
@@ -839,9 +884,9 @@ export function InputBox({
|
||||
<span className="text-xs font-normal">记忆</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
|
||||
content={
|
||||
ragModeEnabled
|
||||
{referenceModeEntryEnabled && !bypassAgentActive && !hideContextTools && <Tooltip
|
||||
content={
|
||||
ragModeEnabled
|
||||
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
||||
: "参考文献模式已关闭:点击开启"
|
||||
}
|
||||
@@ -869,7 +914,7 @@ export function InputBox({
|
||||
<span className="text-xs font-normal">参考文献</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!frontendRagActive && <Tooltip
|
||||
{!bypassAgentActive && <Tooltip
|
||||
content={
|
||||
contextCompressionEnabled
|
||||
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
|
||||
@@ -909,7 +954,9 @@ export function InputBox({
|
||||
: "flash"
|
||||
}
|
||||
tooltipContent={
|
||||
singleRoundRagActive
|
||||
directChatActive
|
||||
? `${t.inputBox.turboMode}:${t.inputBox.turboModeDescription}`
|
||||
: singleRoundRagActive
|
||||
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
|
||||
: multiRoundRagActive
|
||||
? "多轮 RAG 问答:多次检索并展示检索过程与参考资料"
|
||||
@@ -921,7 +968,9 @@ export function InputBox({
|
||||
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
|
||||
compact &&
|
||||
(frontendRagActive
|
||||
(directChatActive
|
||||
? "border-sky-200 bg-sky-50/60 text-sky-700 hover:bg-sky-100 dark:border-sky-700 dark:bg-sky-950/40 dark:text-sky-200"
|
||||
: frontendRagActive
|
||||
? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||
: context.mode === "thinking"
|
||||
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
|
||||
@@ -933,7 +982,9 @@ export function InputBox({
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{frontendRagActive ? (
|
||||
{directChatActive ? (
|
||||
<GaugeIcon className="size-3" />
|
||||
) : frontendRagActive ? (
|
||||
<BookMarkedIcon className="size-3" />
|
||||
) : context.mode === "flash" ? (
|
||||
<ZapIcon className="size-3" />
|
||||
@@ -948,10 +999,14 @@ export function InputBox({
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs font-normal",
|
||||
context.mode === "ultra" ? "golden-text" : "",
|
||||
context.mode === "ultra" && !directChatActive && !frontendRagActive
|
||||
? "golden-text"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{frontendRagActive
|
||||
{directChatActive
|
||||
? t.inputBox.turboMode
|
||||
: frontendRagActive
|
||||
? multiRoundRagActive
|
||||
? "多轮 RAG"
|
||||
: "单轮 RAG"
|
||||
@@ -969,9 +1024,39 @@ export function InputBox({
|
||||
{t.inputBox.mode}
|
||||
</DropdownMenuLabel>
|
||||
<PromptInputActionMenu>
|
||||
{turboModeVisible && (
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
!frontendRagActive && context.mode === "flash"
|
||||
directChatActive
|
||||
? "bg-sky-50 text-sky-800 hover:bg-sky-100 focus:bg-sky-100 dark:bg-sky-950/40 dark:text-sky-200 dark:hover:bg-sky-950/60 dark:focus:bg-sky-950/60"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={handleDirectChatSelect}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<GaugeIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
directChatActive && "text-sky-700 dark:text-sky-200",
|
||||
)}
|
||||
/>
|
||||
{t.inputBox.turboMode}
|
||||
</div>
|
||||
<div className="pl-7 text-xs">
|
||||
{t.inputBox.turboModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{directChatActive ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
)}
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
!bypassAgentActive && context.mode === "flash"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
@@ -982,7 +1067,7 @@ export function InputBox({
|
||||
<ZapIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
!frontendRagActive && context.mode === "flash" &&
|
||||
!bypassAgentActive && context.mode === "flash" &&
|
||||
"text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
@@ -992,7 +1077,7 @@ export function InputBox({
|
||||
{t.inputBox.flashModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{!frontendRagActive && context.mode === "flash" ? (
|
||||
{!bypassAgentActive && context.mode === "flash" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
@@ -1001,7 +1086,7 @@ export function InputBox({
|
||||
{supportThinking && (
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
!frontendRagActive && context.mode === "thinking"
|
||||
!bypassAgentActive && context.mode === "thinking"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
@@ -1012,7 +1097,7 @@ export function InputBox({
|
||||
<LightbulbIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
!frontendRagActive && context.mode === "thinking" &&
|
||||
!bypassAgentActive && context.mode === "thinking" &&
|
||||
"text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
@@ -1022,7 +1107,7 @@ export function InputBox({
|
||||
{t.inputBox.reasoningModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{!frontendRagActive && context.mode === "thinking" ? (
|
||||
{!bypassAgentActive && context.mode === "thinking" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
@@ -1031,7 +1116,7 @@ export function InputBox({
|
||||
)}
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
!frontendRagActive && context.mode === "pro"
|
||||
!bypassAgentActive && context.mode === "pro"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
@@ -1042,7 +1127,7 @@ export function InputBox({
|
||||
<GraduationCapIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
!frontendRagActive && context.mode === "pro" &&
|
||||
!bypassAgentActive && context.mode === "pro" &&
|
||||
"text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
@@ -1052,7 +1137,7 @@ export function InputBox({
|
||||
{t.inputBox.proModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{!frontendRagActive && context.mode === "pro" ? (
|
||||
{!bypassAgentActive && context.mode === "pro" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
@@ -1060,7 +1145,7 @@ export function InputBox({
|
||||
</PromptInputActionMenuItem>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
!frontendRagActive && context.mode === "ultra"
|
||||
!bypassAgentActive && context.mode === "ultra"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
@@ -1071,14 +1156,14 @@ export function InputBox({
|
||||
<RocketIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
!frontendRagActive &&
|
||||
!bypassAgentActive &&
|
||||
context.mode === "ultra" &&
|
||||
"text-[#dabb5e]",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
!frontendRagActive &&
|
||||
!bypassAgentActive &&
|
||||
context.mode === "ultra" &&
|
||||
"golden-text",
|
||||
)}
|
||||
@@ -1090,7 +1175,7 @@ export function InputBox({
|
||||
{t.inputBox.ultraModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{!frontendRagActive && context.mode === "ultra" ? (
|
||||
{!bypassAgentActive && context.mode === "ultra" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
@@ -1161,7 +1246,7 @@ export function InputBox({
|
||||
</DropdownMenuGroup>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
|
||||
{!bypassAgentActive && supportReasoningEffort && context.mode !== "flash" && (
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
|
||||
<div className="text-xs font-normal">
|
||||
@@ -1279,7 +1364,7 @@ export function InputBox({
|
||||
)}
|
||||
</PromptInputTools>
|
||||
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
||||
{!frontendRagActive && agentSelector && (
|
||||
{!bypassAgentActive && agentSelector && (
|
||||
<AgentSelector
|
||||
selectedAgentId={agentSelector.selectedAgentId}
|
||||
onAgentChange={agentSelector.onAgentChange}
|
||||
@@ -1287,8 +1372,8 @@ export function InputBox({
|
||||
compact={compact}
|
||||
/>
|
||||
)}
|
||||
{!frontendRagActive && knowledgeSelector}
|
||||
{!frontendRagActive && promptPrefix && (
|
||||
{!bypassAgentActive && knowledgeSelector}
|
||||
{!bypassAgentActive && promptPrefix && (
|
||||
<Tooltip
|
||||
content={
|
||||
promptPrefix.on
|
||||
|
||||
@@ -6,13 +6,22 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||
import {
|
||||
loadKnowledgeBaseProductVersion,
|
||||
loadOrdinaryKnowledgeLanding,
|
||||
saveOrdinaryKnowledgeLanding,
|
||||
type OrdinaryKnowledgeLanding,
|
||||
type KnowledgeBaseProductVersion,
|
||||
updateKnowledgeBaseProductVersion,
|
||||
} from "@/core/system-settings/api";
|
||||
|
||||
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
|
||||
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
|
||||
|
||||
export function useOrdinaryKnowledgeLanding() {
|
||||
return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 });
|
||||
}
|
||||
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
|
||||
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
|
||||
|
||||
@@ -25,6 +34,14 @@ export function KnowledgeProductNav() {
|
||||
const navigate = useNavigate();
|
||||
const client = useQueryClient();
|
||||
const version = useKnowledgeBaseProductVersion();
|
||||
const landing = useOrdinaryKnowledgeLanding();
|
||||
const saveLanding = useMutation({
|
||||
mutationFn: saveOrdinaryKnowledgeLanding,
|
||||
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const save = useMutation({
|
||||
mutationFn: updateKnowledgeBaseProductVersion,
|
||||
@@ -36,15 +53,20 @@ export function KnowledgeProductNav() {
|
||||
const assistant = location.pathname.includes("/knowledge/assistant");
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b bg-card px-6 py-2">
|
||||
<Button size="sm" variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
|
||||
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||
<BookOpen />普通知识库
|
||||
</Button>
|
||||
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
||||
<Database />助手知识库
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
普通知识库默认页面
|
||||
<Select disabled={landing.isLoading || saveLanding.isPending} value={landing.data ?? "wiki"} onValueChange={(value) => saveLanding.mutate(value as OrdinaryKnowledgeLanding)}>
|
||||
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
|
||||
</Select>
|
||||
默认产品版本
|
||||
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
|
||||
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import type {
|
||||
AnchorHTMLAttributes,
|
||||
HTMLAttributes,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
MessageResponse,
|
||||
type MessageResponseProps,
|
||||
} from "@/components/ai-elements/message";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
|
||||
import {
|
||||
createMarkdownAnchorResolver,
|
||||
isInPageAnchor,
|
||||
@@ -57,7 +57,9 @@ export function MarkdownContent({
|
||||
linkClassName,
|
||||
onLinkClick,
|
||||
}: MarkdownContentProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
||||
useStreamdownMermaidPngDownload(rootRef);
|
||||
const components = useMemo(() => {
|
||||
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
|
||||
const Tag = `h${level}` as const;
|
||||
@@ -138,7 +140,7 @@ export function MarkdownContent({
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<div data-markdown-anchor-root className="contents">
|
||||
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
||||
<MessageResponse
|
||||
className={className}
|
||||
remarkPlugins={remarkPlugins}
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
FileIcon,
|
||||
Loader2Icon,
|
||||
PencilIcon,
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
memo,
|
||||
@@ -77,7 +75,6 @@ import { humanMessagePlugins } from '@/core/streamdown';
|
||||
import {
|
||||
getMultiRoundRagTrace,
|
||||
isMultiRoundRagMessage,
|
||||
type MultiRoundRagRound,
|
||||
} from '@/core/threads/use-multi-round-rag';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -90,6 +87,7 @@ import { DeleteMessageButton } from './delete-message-button';
|
||||
import { DownloadWordButton } from './download-word-button';
|
||||
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
|
||||
import { MessageTokenUsage } from './message-token-usage';
|
||||
import { MultiRoundRagAssistantBody } from './multi-round-rag-message-list';
|
||||
|
||||
type EmbeddedPageContext = {
|
||||
title?: string;
|
||||
@@ -159,9 +157,9 @@ export function MessageListItem({
|
||||
className,
|
||||
fitRowToContent = false,
|
||||
anchorId,
|
||||
message,
|
||||
questionMarkdown,
|
||||
isLoading,
|
||||
message,
|
||||
questionMarkdown,
|
||||
isLoading,
|
||||
threadId,
|
||||
tokenUsageEnabled = false,
|
||||
displaySources = [],
|
||||
@@ -178,9 +176,9 @@ export function MessageListItem({
|
||||
* message. `scroll-mt` keeps the target clear of the overlay header.
|
||||
*/
|
||||
anchorId?: string;
|
||||
message: Message;
|
||||
/** Nearest preceding user question, used by the current-Q&A Word export. */
|
||||
questionMarkdown?: string;
|
||||
message: Message;
|
||||
/** Nearest preceding user question, used by the current-Q&A Word export. */
|
||||
questionMarkdown?: string;
|
||||
isLoading?: boolean;
|
||||
threadId: string;
|
||||
tokenUsageEnabled?: boolean;
|
||||
@@ -330,12 +328,12 @@ export function MessageListItem({
|
||||
<div className="flex gap-1">
|
||||
<CopyButton clipboardData={messageMarkdown} />
|
||||
{messageMarkdown && (
|
||||
<DownloadWordButton
|
||||
markdown={messageMarkdown}
|
||||
fileName="message"
|
||||
title="问答回答"
|
||||
questionMarkdown={questionMarkdown}
|
||||
/>
|
||||
<DownloadWordButton
|
||||
markdown={messageMarkdown}
|
||||
fileName="message"
|
||||
title="问答回答"
|
||||
questionMarkdown={questionMarkdown}
|
||||
/>
|
||||
)}
|
||||
{canSaveAssistantEdit && (
|
||||
<Tooltip content="编辑消息">
|
||||
@@ -380,12 +378,12 @@ function HumanMessageBlock({
|
||||
messageId?: string;
|
||||
text: string;
|
||||
pageContext?: EmbeddedPageContext | null;
|
||||
filesList: React.ReactNode;
|
||||
filesList: ReactNode;
|
||||
messageTime: { label: string; full: string } | null;
|
||||
components: MarkdownContentProps["components"];
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||
deleteButton?: React.ReactNode;
|
||||
deleteButton?: ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -629,9 +627,7 @@ function MessageContent_({
|
||||
href={href}
|
||||
{...props}
|
||||
onReferenceClick={
|
||||
isSingleRoundRag || isMultiRoundRag
|
||||
? handleCitationClick
|
||||
: undefined
|
||||
isSingleRoundRag ? handleCitationClick : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -658,7 +654,6 @@ function MessageContent_({
|
||||
}),
|
||||
[
|
||||
handleCitationClick,
|
||||
isMultiRoundRag,
|
||||
isSingleRoundRag,
|
||||
resolveArtifact,
|
||||
threadId,
|
||||
@@ -697,9 +692,12 @@ function MessageContent_({
|
||||
? stripArtifactTag(stripUploadedFilesTag(rawContent))
|
||||
: '';
|
||||
}
|
||||
if (isMultiRoundRag) {
|
||||
return stripIncompleteCitationMarker(rawContent ?? '');
|
||||
}
|
||||
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
|
||||
return stripIncompleteCitationMarker(withCitations);
|
||||
}, [rawContent, isHuman, displaySources]);
|
||||
}, [rawContent, isHuman, isMultiRoundRag, displaySources]);
|
||||
const markdownRehypePlugins = useMemo(
|
||||
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
|
||||
[rehypePlugins],
|
||||
@@ -734,7 +732,7 @@ function MessageContent_({
|
||||
}
|
||||
|
||||
// Reasoning-only AI message (no main response content yet)
|
||||
if (!isHuman && reasoningContent && !rawContent && !multiRoundTrace) {
|
||||
if (!isHuman && reasoningContent && !rawContent && !isMultiRoundRag) {
|
||||
return (
|
||||
<AIElementMessageContent className={className}>
|
||||
<Reasoning isStreaming={isLoading}>
|
||||
@@ -755,7 +753,11 @@ function MessageContent_({
|
||||
// it exists server-side (real, non-optimistic id). Deleting it cascades to
|
||||
// its answer on the backend.
|
||||
const canDelete =
|
||||
!!onMessageDeleted && !!message.id && !message.id.startsWith('opt-');
|
||||
!!onMessageDeleted &&
|
||||
!!message.id &&
|
||||
!message.id.startsWith('opt-') &&
|
||||
!isMultiRoundRag &&
|
||||
!isSingleRoundRag;
|
||||
return (
|
||||
<HumanMessageBlock
|
||||
messageId={message.id}
|
||||
@@ -764,8 +766,10 @@ function MessageContent_({
|
||||
filesList={filesList}
|
||||
messageTime={messageTime}
|
||||
components={components}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onEditSave={onEditSave}
|
||||
onEditResubmit={
|
||||
isMultiRoundRag || isSingleRoundRag ? undefined : onEditResubmit
|
||||
}
|
||||
onEditSave={isMultiRoundRag || isSingleRoundRag ? undefined : onEditSave}
|
||||
deleteButton={
|
||||
canDelete ? (
|
||||
<DeleteMessageButton
|
||||
@@ -785,14 +789,12 @@ function MessageContent_({
|
||||
<AIElementMessageContent className={className}>
|
||||
{filesList}
|
||||
<div data-reference-batch-id={message.id} className="contents">
|
||||
{multiRoundTrace ? (
|
||||
<MultiRoundRagContent
|
||||
trace={multiRoundTrace}
|
||||
{isMultiRoundRag ? (
|
||||
<MultiRoundRagAssistantBody
|
||||
content={contentToDisplay}
|
||||
displaySources={displaySources}
|
||||
thinkingTrace={multiRoundTrace}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={markdownRehypePlugins}
|
||||
components={components}
|
||||
/>
|
||||
) : (
|
||||
<AssistantDisplayContent
|
||||
@@ -805,7 +807,7 @@ function MessageContent_({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(isSingleRoundRag || isMultiRoundRag) && displaySources.length > 0 && (
|
||||
{isSingleRoundRag && displaySources.length > 0 && (
|
||||
<FrontendRagSourcesPanel
|
||||
sources={displaySources}
|
||||
onSelect={setSelectedCitation}
|
||||
@@ -820,7 +822,7 @@ function MessageContent_({
|
||||
<MessageTimeStamp time={messageTime} align="left" />
|
||||
) : null}
|
||||
</AIElementMessageContent>
|
||||
{(isSingleRoundRag || isMultiRoundRag) && (
|
||||
{isSingleRoundRag && (
|
||||
<SingleRoundRagCitationDialog
|
||||
source={selectedCitation}
|
||||
onOpenChange={(open) => {
|
||||
@@ -833,11 +835,9 @@ function MessageContent_({
|
||||
}
|
||||
|
||||
/**
|
||||
* 单轮/多轮 RAG 专属的「引用来源」折叠面板。
|
||||
* 单轮 RAG 专属的「引用来源」折叠面板。
|
||||
*
|
||||
* 参考 GeneralQA 的引用来源区:默认折叠,列出全部参考资料,点击任一条打开详情
|
||||
* 弹窗(复用 SingleRoundRagCitationDialog)。这样即使模型正文里没有 `[1]` 编号,
|
||||
* 用户也能看到本次回答依据的资料。
|
||||
* 多轮 RAG 的检索卡片已经画在思考过程里,正文下面不再重复一份来源列表。
|
||||
*/
|
||||
function FrontendRagSourcesPanel({
|
||||
sources,
|
||||
@@ -941,349 +941,6 @@ export function SingleRoundRagCitationDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function MultiRoundRagContent({
|
||||
trace,
|
||||
content,
|
||||
displaySources,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
components,
|
||||
}: {
|
||||
trace: NonNullable<ReturnType<typeof getMultiRoundRagTrace>>;
|
||||
content: string;
|
||||
displaySources: RuntimeDisplaySource[];
|
||||
isLoading: boolean;
|
||||
rehypePlugins: MarkdownContentProps['rehypePlugins'];
|
||||
components: MarkdownContentProps['components'];
|
||||
}) {
|
||||
const [open, setOpen] = useState(!trace.isComplete);
|
||||
useEffect(() => {
|
||||
// Keep the active turn expanded so each new retrieval is visible as it
|
||||
// arrives, then match GeneralQA by collapsing the completed trace. The
|
||||
// header still carries the total round count for quick scanning.
|
||||
setOpen(!trace.isComplete);
|
||||
}, [trace.isComplete]);
|
||||
|
||||
return (
|
||||
<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"
|
||||
className="hover:bg-muted/50 flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{trace.isComplete ? (
|
||||
<SearchIcon className="text-primary size-4 shrink-0" />
|
||||
) : (
|
||||
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium">多轮检索思考过程</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{trace.isComplete
|
||||
? `共 ${trace.rounds.length} 轮推理与检索,可展开查看细节`
|
||||
: '正在分析问题并检索资料'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={cn('text-muted-foreground size-4 transition-transform', !open && '-rotate-90')}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-border/60 space-y-3 border-t px-4 py-3">
|
||||
{trace.rounds.map((round, index) => (
|
||||
<div
|
||||
key={round.id}
|
||||
className={cn(
|
||||
'relative pl-5',
|
||||
index < trace.rounds.length - 1 && 'pb-1',
|
||||
)}
|
||||
>
|
||||
{index < trace.rounds.length - 1 && (
|
||||
<span className="bg-border absolute bottom-0 left-[7px] top-8 w-px" />
|
||||
)}
|
||||
<span className="border-primary bg-background absolute left-0 top-4 size-3.5 rounded-full border-2" />
|
||||
<MultiRoundRagRoundCard
|
||||
index={index + 1}
|
||||
round={round}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="border-border/70 rounded-xl border p-4">
|
||||
<div className="mb-3 text-sm font-semibold">最终回答</div>
|
||||
{content ? (
|
||||
<AssistantDisplayContent
|
||||
content={content}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
renderSkillDisplayBlocks={false}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在生成最终回答…
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<article className="border-border/60 bg-background/80 overflow-hidden rounded-xl border shadow-sm">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2.5 text-left text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
|
||||
>
|
||||
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
|
||||
{index}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{round.title}</span>
|
||||
{round.isSearching ? (
|
||||
<span className="bg-primary/10 text-primary inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
|
||||
<Loader2Icon className="size-3 motion-reduce:animate-none animate-spin" />
|
||||
检索中
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground shrink-0 text-[11px]">已完成</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
|
||||
!open && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border/50 border-t px-3 pb-3">
|
||||
<div className="pt-3">
|
||||
{round.thought && (
|
||||
<MultiRoundRagDetailSection
|
||||
title="思考内容"
|
||||
icon={<SparklesIcon className="text-primary size-4" />}
|
||||
>
|
||||
<MarkdownContent
|
||||
content={thoughtContent}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
className="text-muted-foreground text-sm"
|
||||
/>
|
||||
</MultiRoundRagDetailSection>
|
||||
)}
|
||||
{round.body && (
|
||||
<section className="border-border/50 mt-3 rounded-lg border p-3">
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
|
||||
<MarkdownContent
|
||||
content={round.body}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
className="text-sm"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{(round.query || round.isSearching) && (
|
||||
<div
|
||||
className={cn(
|
||||
'border-primary/20 bg-primary/5 text-primary mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1.5 text-xs',
|
||||
round.isSearching && 'animate-pulse motion-reduce:animate-none',
|
||||
)}
|
||||
aria-live={round.isSearching ? 'polite' : undefined}
|
||||
>
|
||||
{round.isSearching ? (
|
||||
<Loader2Icon className="size-3.5 shrink-0 motion-reduce:animate-none animate-spin" />
|
||||
) : (
|
||||
<SearchIcon className="size-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="shrink-0 text-muted-foreground">检索词</span>
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
{round.query || '正在生成检索词…'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(round.query || round.isSearching || round.retrievals.length > 0) && (
|
||||
<MultiRoundRagDetailSection
|
||||
defaultOpen={!round.isSearching}
|
||||
title="检索内容"
|
||||
icon={<FileIcon className="text-primary size-3.5" />}
|
||||
suffix={<span className="text-muted-foreground text-xs">{round.retrievals.length} 条</span>}
|
||||
>
|
||||
{round.isSearching ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 py-1 text-sm">
|
||||
<Loader2Icon className="text-primary size-4 motion-reduce:animate-none animate-spin" />
|
||||
正在召回相关内容…
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{round.retrievals.length > 0 ? (
|
||||
round.retrievals.map((item, resultIndex) => (
|
||||
<MultiRoundRagResultCard
|
||||
key={`${item.id ?? item.title}-${resultIndex}`}
|
||||
item={item}
|
||||
index={resultIndex + 1}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-muted-foreground py-1 text-sm">
|
||||
本轮未召回相关资料。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</MultiRoundRagDetailSection>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</article>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiRoundRagDetailSection({
|
||||
title,
|
||||
icon,
|
||||
suffix,
|
||||
defaultOpen = true,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
suffix?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
useEffect(() => setOpen(defaultOpen), [defaultOpen]);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<section className="border-border/50 bg-muted/20 mt-3 overflow-hidden rounded-lg border">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
|
||||
>
|
||||
{icon}
|
||||
<span className="min-w-0 flex-1 text-sm font-medium">{title}</span>
|
||||
{suffix}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
|
||||
!open && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</section>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiRoundRagResultCard({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: MultiRoundRagRound['retrievals'][number];
|
||||
index: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(index === 1);
|
||||
const sourceMeta = [item.source, item.time].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<div className="border-border/60 hover:border-primary/30 bg-background rounded-lg border transition-colors">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
|
||||
>
|
||||
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-semibold">
|
||||
{index}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={item.title}>
|
||||
{item.title}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
|
||||
!open && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
|
||||
<p className="text-muted-foreground text-sm leading-6 whitespace-pre-wrap">
|
||||
{item.content || '暂无资料摘要'}
|
||||
</p>
|
||||
{(sourceMeta || item.url) && (
|
||||
<div className="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
{sourceMeta && <span>{sourceMeta}</span>}
|
||||
{item.url && (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
查看原文
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSingleRoundRagSourceUrl(
|
||||
source: RuntimeDisplaySource | null,
|
||||
): string | undefined {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { CheckIcon, PenLineIcon, SendIcon, XIcon } from "lucide-react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
||||
import {
|
||||
@@ -28,12 +28,12 @@ import {
|
||||
hasToolCalls,
|
||||
isHiddenFromUIMessage,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
@@ -136,6 +136,8 @@ export function MessageList({
|
||||
onSetupReportStructureReject,
|
||||
researchApprovalSlot,
|
||||
taskReportImportSlot,
|
||||
businessCardSlot,
|
||||
speakerShell,
|
||||
onOpenArtifact,
|
||||
onDownloadArtifact,
|
||||
afterDocumentRewriteItems,
|
||||
@@ -176,6 +178,19 @@ export function MessageList({
|
||||
* 「是否入库」卡片。不传该 slot 时(普通对话等)该分组不渲染,零行为变化。
|
||||
*/
|
||||
taskReportImportSlot?: (message: Message) => React.ReactNode;
|
||||
/**
|
||||
* 通用业务卡插槽(报告协作工作台等):宿主向 displayThread 注入带
|
||||
* `additional_kwargs.report_collaboration_card` 的虚拟 ai 消息(方案组 /
|
||||
* 命令确认 / 报告入口),由本插槽渲染。不传该 slot 时(普通对话等)该
|
||||
* 分组不渲染,零行为变化;卡 payload 结构由各页面在 message-adapter
|
||||
* 层定义,MessageList 不感知具体业务字段。
|
||||
*/
|
||||
businessCardSlot?: (message: Message) => React.ReactNode;
|
||||
/**
|
||||
* 可选说话人壳(报告协作对齐会商第二步):包住 assistant / processing 分组。
|
||||
* 不传则问答页零变化。
|
||||
*/
|
||||
speakerShell?: (messages: Message[], children: ReactNode) => ReactNode;
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
/** Host override for downloading virtual/session-owned files in a present-files card. */
|
||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||
@@ -300,23 +315,19 @@ export function MessageList({
|
||||
}
|
||||
return map;
|
||||
}, [messages]);
|
||||
|
||||
const [settings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
const knowledgeReferencesEnabled =
|
||||
threadContext?.knowledgeReferencesEnabled === true;
|
||||
const ragModeEnabled =
|
||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
knowledgeReferencesEnabled;
|
||||
|
||||
const [settings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
const knowledgeReferencesEnabled =
|
||||
threadContext?.knowledgeReferencesEnabled === true;
|
||||
const ragModeEnabled =
|
||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
knowledgeReferencesEnabled;
|
||||
const hasSingleRoundRag = useMemo(
|
||||
() =>
|
||||
messages.some(
|
||||
(message) =>
|
||||
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
|
||||
),
|
||||
() => messages.some((message) => isSingleRoundRagMessage(message)),
|
||||
[messages],
|
||||
);
|
||||
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
|
||||
@@ -331,7 +342,7 @@ export function MessageList({
|
||||
// frontend tool-message scan below comes up empty (e.g. after a reload).
|
||||
const referenceBatches = useContext(ReferenceBatchesContext);
|
||||
|
||||
// 单轮/多轮 RAG 的参考资料存放在一条 hide_from_ui 的 tool 消息里(紧挨在
|
||||
// 单轮 RAG 的参考资料存放在一条 hide_from_ui 的 tool 消息里(紧挨在
|
||||
// assistant 消息之前)。allMessages 会把 hide_from_ui 消息过滤掉,导致通用
|
||||
// 的 collectRuntimeDisplaySources 扫描不到它们。这里直接在原始消息流里按
|
||||
// assistant 消息提取其对应的参考资料,保证 `[1]` 等编号能解析成可点击引用。
|
||||
@@ -342,7 +353,7 @@ export function MessageList({
|
||||
const msg = raw[i];
|
||||
if (!msg || msg.type !== "ai" || !msg.id) continue;
|
||||
const kwargs = msg.additional_kwargs;
|
||||
if (!kwargs || (kwargs.single_round_rag !== true && kwargs.multi_round_rag !== true)) {
|
||||
if (!kwargs || kwargs.single_round_rag !== true) {
|
||||
continue;
|
||||
}
|
||||
let sources: RuntimeDisplaySource[] = [];
|
||||
@@ -353,19 +364,13 @@ export function MessageList({
|
||||
if (prev.type === "human" || prev.type === "ai") break;
|
||||
if (prev.type !== "tool") continue;
|
||||
const prevKwargs = prev.additional_kwargs;
|
||||
if (
|
||||
!prevKwargs ||
|
||||
(prevKwargs.single_round_rag !== true && prevKwargs.multi_round_rag !== true)
|
||||
) {
|
||||
if (!prevKwargs || prevKwargs.single_round_rag !== true) {
|
||||
continue;
|
||||
}
|
||||
const results = extractFrontendRagResults(prev.content);
|
||||
if (results && results.length > 0) {
|
||||
const toolName = String(
|
||||
(prev as { name?: unknown }).name ??
|
||||
(prevKwargs.multi_round_rag === true
|
||||
? "multi_round_rag_references"
|
||||
: "knowledge_base_search"),
|
||||
(prev as { name?: unknown }).name ?? "knowledge_base_search",
|
||||
);
|
||||
sources = results.map((item, index) =>
|
||||
toRuntimeDisplaySource(item, index + 1, toolName),
|
||||
@@ -381,6 +386,7 @@ export function MessageList({
|
||||
const resolveDisplaySources = useCallback(
|
||||
(msg: Message | undefined): RuntimeDisplaySource[] => {
|
||||
if (!referencesEnabled || !msg || msg.type !== "ai") return [];
|
||||
if (isMultiRoundRagMessage(msg)) return [];
|
||||
const batchSources =
|
||||
referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
|
||||
?.sources ?? [];
|
||||
@@ -536,7 +542,7 @@ export function MessageList({
|
||||
let didRenderRewriteAboveFile = false;
|
||||
const grouped = groupMessages(messages, (group) => {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return group.messages.map((msg) => {
|
||||
const items = group.messages.map((msg) => {
|
||||
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
|
||||
// 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。
|
||||
const messageIsLoading =
|
||||
@@ -563,7 +569,12 @@ export function MessageList({
|
||||
questionMarkdown={
|
||||
msg.id ? questionMarkdownByAssistantId.get(msg.id) : undefined
|
||||
}
|
||||
isLoading={messageIsLoading}
|
||||
isLoading={
|
||||
isMultiRoundRagMessage(msg)
|
||||
? thread.isLoading &&
|
||||
msg.id === messages[messages.length - 1]?.id
|
||||
: messageIsLoading
|
||||
}
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
@@ -577,6 +588,14 @@ export function MessageList({
|
||||
/>
|
||||
);
|
||||
});
|
||||
if (speakerShell && group.type === "assistant") {
|
||||
return (
|
||||
<div key={group.id} className="w-full">
|
||||
{speakerShell(group.messages, items)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return items;
|
||||
} else if (group.type === "assistant:clarification") {
|
||||
const message = group.messages[0];
|
||||
if (message && hasContent(message)) {
|
||||
@@ -692,6 +711,14 @@ export function MessageList({
|
||||
{taskReportImportSlot(message)}
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:business-card") {
|
||||
// 通用业务卡(报告协作):宿主不传渲染函数时该分组不渲染。
|
||||
const message = group.messages[0];
|
||||
return businessCardSlot && message ? (
|
||||
<div key={group.id} className="w-full pb-2">
|
||||
{businessCardSlot(message)}
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:present-files") {
|
||||
const files: string[] = [];
|
||||
for (const message of group.messages) {
|
||||
@@ -786,13 +813,13 @@ export function MessageList({
|
||||
)) {
|
||||
if (hasReasoning(message)) {
|
||||
results.push(
|
||||
<MessageGroup
|
||||
key={"thinking-group-" + message.id}
|
||||
messages={[message]}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(message)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>,
|
||||
<MessageGroup
|
||||
key={"thinking-group-" + message.id}
|
||||
messages={[message]}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(message)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
results.push(
|
||||
@@ -838,14 +865,14 @@ export function MessageList({
|
||||
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)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
const processingBody = (
|
||||
<div className="w-full">
|
||||
<MessageGroup
|
||||
messages={group.messages}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(processingAi)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={thread.isLoading}
|
||||
@@ -853,6 +880,11 @@ export function MessageList({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div key={"group-" + group.id} className="w-full">
|
||||
{speakerShell ? speakerShell(group.messages, processingBody) : processingBody}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
const leftoverRewriteItems = didRenderRewriteAboveFile
|
||||
? rewriteItemsBelowFile
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import {
|
||||
BookOpenIcon,
|
||||
ChevronDownIcon,
|
||||
FileTextIcon,
|
||||
Loader2Icon,
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import {
|
||||
Message as AIElementMessage,
|
||||
MessageContent as AIElementMessageContent,
|
||||
} from "@/components/ai-elements/message";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import type { MessageResponseProps } from "@/components/ai-elements/message";
|
||||
import type {
|
||||
MultiRoundRagChatMessage,
|
||||
MultiRoundRagRetrieval,
|
||||
MultiRoundRagRound,
|
||||
MultiRoundRagTrace,
|
||||
} from "@/core/threads/use-multi-round-rag";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
|
||||
export function MultiRoundRagMessageList({
|
||||
className,
|
||||
messages,
|
||||
isLoading,
|
||||
paddingBottom = 160,
|
||||
}: {
|
||||
className?: string;
|
||||
messages: MultiRoundRagChatMessage[];
|
||||
isLoading: boolean;
|
||||
paddingBottom?: number;
|
||||
}) {
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
|
||||
return (
|
||||
<Conversation className={cn("flex size-full flex-col justify-center", className)}>
|
||||
<ConversationContent
|
||||
className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-10"
|
||||
>
|
||||
{messages.map((message, index) => {
|
||||
const isLastAssistant =
|
||||
message.role === "assistant" && index === messages.length - 1;
|
||||
return (
|
||||
<AIElementMessage
|
||||
key={message.id}
|
||||
className="group/conversation-message relative w-full"
|
||||
from={message.role === "user" ? "user" : "assistant"}
|
||||
>
|
||||
{message.role === "user" ? (
|
||||
<AIElementMessageContent className="w-fit">
|
||||
<p className="whitespace-pre-wrap text-[15px] leading-7">
|
||||
{message.content}
|
||||
</p>
|
||||
</AIElementMessageContent>
|
||||
) : (
|
||||
<AIElementMessageContent className="w-full">
|
||||
<MultiRoundRagAssistantBody
|
||||
content={
|
||||
typeof message.content === "string" ? message.content : ""
|
||||
}
|
||||
thinkingTrace={message.thinkingTrace}
|
||||
isLoading={isLastAssistant && isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
/>
|
||||
</AIElementMessageContent>
|
||||
)}
|
||||
</AIElementMessage>
|
||||
);
|
||||
})}
|
||||
<div style={{ height: `${paddingBottom}px` }} />
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton
|
||||
className="pointer-events-auto z-20 size-9 shadow-md bottom-40"
|
||||
aria-label="回到最新"
|
||||
title="回到最新"
|
||||
/>
|
||||
</Conversation>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiRoundRagAssistantBody({
|
||||
content,
|
||||
thinkingTrace,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
}: {
|
||||
content: string;
|
||||
thinkingTrace?: MultiRoundRagTrace | null;
|
||||
isLoading: boolean;
|
||||
rehypePlugins: MessageResponseProps["rehypePlugins"];
|
||||
}) {
|
||||
const [selected, setSelected] = useState<MultiRoundRagRetrieval | null>(
|
||||
null,
|
||||
);
|
||||
const trace = thinkingTrace;
|
||||
const answer = trace?.finalAnswerMarkdown || content;
|
||||
|
||||
return (
|
||||
<>
|
||||
{trace && (
|
||||
<MultiRoundRagTraceView
|
||||
trace={trace}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
onSelectRetrieval={setSelected}
|
||||
/>
|
||||
)}
|
||||
<section className="mt-4">
|
||||
<div className="text-foreground mb-2 text-sm font-medium">最终回答</div>
|
||||
{answer ? (
|
||||
<MarkdownContent
|
||||
content={answer}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
className="my-0 text-[15px] leading-7"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在生成思考过程与检索内容...
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<RetrievalDetailDialog
|
||||
item={selected}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelected(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiRoundRagTraceView({
|
||||
trace,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
onSelectRetrieval,
|
||||
}: {
|
||||
trace: MultiRoundRagTrace;
|
||||
isLoading: boolean;
|
||||
rehypePlugins: MessageResponseProps["rehypePlugins"];
|
||||
onSelectRetrieval: (item: MultiRoundRagRetrieval) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(!trace.thinkingComplete);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="not-prose w-full min-w-0 max-w-5xl"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors"
|
||||
>
|
||||
{trace.thinkingComplete ? (
|
||||
<SparklesIcon className="text-primary size-4 shrink-0" />
|
||||
) : (
|
||||
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
|
||||
)}
|
||||
<span>{trace.title || "思考过程"}</span>
|
||||
<span className="text-muted-foreground/70 text-xs">
|
||||
共 {trace.rounds.length} 轮推理与检索
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn("ml-auto size-4 transition-transform", !open && "-rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-3 space-y-3">
|
||||
{trace.rounds.map((round, index) => (
|
||||
<div
|
||||
key={round.id}
|
||||
className={cn("relative pl-5", index < trace.rounds.length - 1 && "pb-1")}
|
||||
>
|
||||
{index < trace.rounds.length - 1 && (
|
||||
<span className="bg-primary/25 absolute bottom-0 left-[7px] top-8 w-px" />
|
||||
)}
|
||||
<span className="border-primary bg-background absolute left-0 top-4 size-3.5 rounded-full border-2" />
|
||||
<MultiRoundRagRoundCard
|
||||
index={index + 1}
|
||||
round={round}
|
||||
isLoading={isLoading}
|
||||
isActiveRound={isLoading && index === trace.rounds.length - 1}
|
||||
rehypePlugins={rehypePlugins}
|
||||
onSelectRetrieval={onSelectRetrieval}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiRoundRagRoundCard({
|
||||
index,
|
||||
round,
|
||||
isLoading,
|
||||
isActiveRound,
|
||||
rehypePlugins,
|
||||
onSelectRetrieval,
|
||||
}: {
|
||||
index: number;
|
||||
round: MultiRoundRagRound;
|
||||
isLoading: boolean;
|
||||
isActiveRound: boolean;
|
||||
rehypePlugins: MessageResponseProps["rehypePlugins"];
|
||||
onSelectRetrieval: (item: MultiRoundRagRetrieval) => void;
|
||||
}) {
|
||||
const query = round.query.trim();
|
||||
const searching = Boolean(query && round.queryLoading);
|
||||
const showQuery = Boolean(query);
|
||||
const showRetrievals = showQuery || round.retrievals.length > 0;
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<article className="overflow-hidden rounded-lg border border-border/70 bg-background">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/40 flex min-h-11 w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors"
|
||||
>
|
||||
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-medium">
|
||||
{index}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{round.title}</span>
|
||||
{searching ? (
|
||||
<span className="text-primary inline-flex items-center gap-1 text-[11px]">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
检索中
|
||||
</span>
|
||||
) : isActiveRound ? null : (
|
||||
<span className="text-muted-foreground text-[11px]">已完成</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className={cn("text-muted-foreground size-4 shrink-0 transition-transform", !open && "-rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border/60 space-y-3 border-t px-3 pb-3 pt-3">
|
||||
{round.thoughtMarkdown && (
|
||||
<section>
|
||||
<div className="text-muted-foreground mb-1.5 flex items-center gap-2 text-sm font-medium">
|
||||
<SparklesIcon className="size-4" />
|
||||
思考内容
|
||||
</div>
|
||||
<MarkdownContent
|
||||
content={round.thoughtMarkdown}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
className="text-muted-foreground my-0 text-sm leading-6"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{showQuery && (
|
||||
<div
|
||||
className="border-primary/20 bg-primary/5 text-primary inline-flex max-w-full items-center gap-2 rounded-full border px-3 py-1.5"
|
||||
aria-live={searching ? "polite" : undefined}
|
||||
>
|
||||
{searching ? (
|
||||
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<SearchIcon className="size-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="text-primary/80 shrink-0 text-xs">
|
||||
{round.queryTitle || "检索词"}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-xs font-medium">
|
||||
{query}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showRetrievals && (
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<FileTextIcon className="text-muted-foreground size-4" />
|
||||
检索内容
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
{round.retrievals.length} 条
|
||||
</span>
|
||||
</div>
|
||||
{searching ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 rounded-lg border border-border/70 px-3 py-3 text-sm">
|
||||
<Loader2Icon className="text-primary size-4 animate-spin" />
|
||||
正在召回相关内容...
|
||||
</div>
|
||||
) : (
|
||||
<RetrievalCardList
|
||||
items={round.retrievals}
|
||||
onSelect={onSelectRetrieval}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{(round.extractInfo || round.extractContent || round.extractLoading) && (
|
||||
<section className="rounded-lg border border-border/70 px-3 py-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<BookOpenIcon className="text-muted-foreground size-4" />
|
||||
阅读内容
|
||||
{round.extractLoading && <Loader2Icon className="text-primary size-3.5 animate-spin" />}
|
||||
</div>
|
||||
{round.extractInfo && (
|
||||
<p className="text-muted-foreground text-sm leading-6">{round.extractInfo}</p>
|
||||
)}
|
||||
{round.extractContent && (
|
||||
<p className="mt-2 text-sm leading-6 whitespace-pre-wrap">{round.extractContent}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{round.textidContent && (
|
||||
<section className="rounded-lg border border-border/70 px-3 py-3 text-sm leading-6 whitespace-pre-wrap">
|
||||
{round.textidContent}
|
||||
</section>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</article>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function RetrievalCardList({
|
||||
items,
|
||||
onSelect,
|
||||
}: {
|
||||
items: MultiRoundRagRetrieval[];
|
||||
onSelect: (item: MultiRoundRagRetrieval) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return <p className="text-muted-foreground py-1 text-sm">本轮未召回相关资料。</p>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<RetrievalCard
|
||||
key={`${item.id ?? item.title}-${index}`}
|
||||
item={item}
|
||||
index={index + 1}
|
||||
defaultOpen={index === 0}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RetrievalCard({
|
||||
item,
|
||||
index,
|
||||
defaultOpen,
|
||||
onSelect,
|
||||
}: {
|
||||
item: MultiRoundRagRetrieval;
|
||||
index: number;
|
||||
defaultOpen: boolean;
|
||||
onSelect: (item: MultiRoundRagRetrieval) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const meta = retrievalMeta(item);
|
||||
|
||||
if (item.type === "user_behavior") {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/70 px-3 py-2 text-sm">
|
||||
<span className="text-muted-foreground mr-2 font-medium">{index}.</span>
|
||||
{item.title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 bg-background">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/40 flex min-h-11 w-full items-center gap-2 px-3 py-2.5 text-left transition-colors"
|
||||
>
|
||||
<span className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-medium">
|
||||
{index}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium" title={item.title}>
|
||||
{item.title}
|
||||
</div>
|
||||
{meta && (
|
||||
<div className="text-muted-foreground truncate text-xs">{meta}</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={cn("text-muted-foreground size-4 shrink-0 transition-transform", !open && "-rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border/60 border-t px-3 pb-3 pt-2.5">
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground line-clamp-3 w-full text-left text-sm leading-6 whitespace-pre-wrap"
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
{item.content || "暂无资料摘要"}
|
||||
</button>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function retrievalMeta(item: MultiRoundRagRetrieval): string {
|
||||
if (item.type === "more") return `时间:${item.time || "-"}`;
|
||||
if (item.type === "task") return `关键词:${item.keywords || "-"}`;
|
||||
if (item.type === "hx") return `平台:${item.platform || "-"} 粉丝数:${item.fans ?? "-"}`;
|
||||
if (
|
||||
item.type === "taiwan_news" ||
|
||||
item.type === "world_news" ||
|
||||
item.type === "messaage_comment" ||
|
||||
item.type === "america_news"
|
||||
) {
|
||||
return `平台:${item.platform || "-"}${item.comment_count ? ` 评论数:${item.comment_count}` : ""}`;
|
||||
}
|
||||
if (item.type === "get_time_data") return `时间:${item.time || "-"} 热度:${item.hot_num ?? "-"}`;
|
||||
return [item.source, item.time].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function RetrievalDetailDialog({
|
||||
item,
|
||||
onOpenChange,
|
||||
}: {
|
||||
item: MultiRoundRagRetrieval | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const content = item?.content;
|
||||
return (
|
||||
<Dialog open={!!item} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
|
||||
<DialogHeader className="pr-8">
|
||||
<DialogTitle className="leading-relaxed">{item?.title || "引用资料"}</DialogTitle>
|
||||
<DialogDescription className="space-y-1 text-xs">
|
||||
{item?.time && <span className="block">时间:{item.time}</span>}
|
||||
{item?.source && <span className="block">来源:{item.source}</span>}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="bg-muted/50 max-h-[min(26rem,calc(100vh-16rem))] overflow-auto rounded-xl p-3 text-sm leading-7 whitespace-pre-wrap">
|
||||
{content || "暂无引用内容"}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export function ModelNameSelect({
|
||||
className,
|
||||
triggerClassName,
|
||||
defaultLabel = "默认模型",
|
||||
title = "选择用于本次操作的大模型(默认使用系统配置的模型)",
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (modelName: string | null) => void;
|
||||
@@ -32,6 +33,7 @@ export function ModelNameSelect({
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
defaultLabel?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const { models } = useModels();
|
||||
return (
|
||||
@@ -42,7 +44,7 @@ export function ModelNameSelect({
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
|
||||
title="选择用于本次操作的大模型(默认使用系统配置的模型)"
|
||||
title={title}
|
||||
>
|
||||
<SelectValue placeholder={defaultLabel} />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { FolderKanban } from "lucide-react";
|
||||
|
||||
import {
|
||||
MY_PROJECT_CONVERSATION_LABELS,
|
||||
type MyProjectConversationType,
|
||||
} from "@/core/threads/project-metadata";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const BADGE_STYLES: Record<MyProjectConversationType, string> = {
|
||||
qa: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-700/70 dark:bg-sky-950/55 dark:text-sky-300",
|
||||
strategy:
|
||||
"border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-700/70 dark:bg-violet-950/55 dark:text-violet-300",
|
||||
solution:
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700/70 dark:bg-emerald-950/55 dark:text-emerald-300",
|
||||
};
|
||||
|
||||
export function MyProjectThreadIcon({
|
||||
type,
|
||||
className,
|
||||
}: {
|
||||
type: MyProjectConversationType;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<FolderKanban
|
||||
aria-label={`我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
|
||||
className={cn("size-3.5 shrink-0 text-violet-500", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MyProjectThreadBadge({
|
||||
type,
|
||||
className,
|
||||
}: {
|
||||
type: MyProjectConversationType;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded border px-1 py-0.5 text-[9px] font-medium leading-none",
|
||||
BADGE_STYLES[type],
|
||||
className,
|
||||
)}
|
||||
title={`来自我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
|
||||
>
|
||||
{MY_PROJECT_CONVERSATION_LABELS[type]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,11 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import {
|
||||
MyProjectThreadBadge,
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
@@ -63,7 +67,8 @@ import {
|
||||
useToggleThreadFavorite,
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||
import {
|
||||
isBootstrapThread,
|
||||
isHiddenFromRecentChats,
|
||||
@@ -245,7 +250,8 @@ export function RecentChatList({
|
||||
{shownThreads.map((thread) => {
|
||||
const href = listPathOfThread(thread);
|
||||
const isActive = href === pathname;
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const projectType = myProjectConversationTypeOfThread(thread);
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
return (
|
||||
@@ -279,8 +285,10 @@ export function RecentChatList({
|
||||
}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : (
|
||||
compact && (
|
||||
) : projectType ? (
|
||||
<MyProjectThreadIcon type={projectType} />
|
||||
) : (
|
||||
compact && (
|
||||
<MessageSquare
|
||||
className={
|
||||
"size-3.5 shrink-0 " +
|
||||
@@ -294,9 +302,15 @@ export function RecentChatList({
|
||||
{pinned && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
{projectType && (
|
||||
<MyProjectThreadBadge
|
||||
className="mr-3"
|
||||
type={projectType}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||
|
||||
@@ -188,7 +188,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
const globalDefault = appearance?.default_theme ?? "light";
|
||||
const globalSkillView = appearance?.default_skill_view ?? "list";
|
||||
const globalAgentView = appearance?.default_agent_view ?? "card";
|
||||
const globalLayoutMode = appearance?.default_layout_mode ?? "full";
|
||||
const globalLayoutMode = appearance?.default_layout_mode ?? "compact";
|
||||
const globalDefaultLabel = useMemo(() => {
|
||||
const customTheme = (appearance?.custom_themes ?? []).find(
|
||||
(preset) => preset.id === globalDefault,
|
||||
@@ -201,7 +201,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
if (globalDefault === "lightyellow") return "浅黄色";
|
||||
return "浅蓝";
|
||||
}, [appearance?.custom_themes, globalDefault]);
|
||||
// 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则会话级回退)。
|
||||
// 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则内置默认简洁模式)。
|
||||
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { BellIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||
import { BellIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -14,16 +14,19 @@ import { AboutSettingsPage } from "@/components/workspace/settings/about-setting
|
||||
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
|
||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
|
||||
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
|
||||
|
||||
type SettingsSection =
|
||||
| "account"
|
||||
| "appearance"
|
||||
| "memory"
|
||||
| "tools"
|
||||
| "about";
|
||||
| "memory"
|
||||
| "tools"
|
||||
| "encoder"
|
||||
| "about";
|
||||
|
||||
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
||||
defaultSection?: SettingsSection;
|
||||
@@ -31,7 +34,8 @@ type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
||||
|
||||
export function SettingsDialog(props: SettingsDialogProps) {
|
||||
const { defaultSection = "appearance", ...dialogProps } = props;
|
||||
const { t } = useI18n();
|
||||
const { t } = useI18n();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<SettingsSection>(defaultSection);
|
||||
|
||||
@@ -63,7 +67,10 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
// label: t.settings.sections.memory,
|
||||
// icon: BrainIcon,
|
||||
// },
|
||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
...(isAdmin
|
||||
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
|
||||
: []),
|
||||
{
|
||||
id: "account",
|
||||
label: "个人信息",
|
||||
@@ -75,7 +82,8 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
t.settings.sections.appearance,
|
||||
t.settings.sections.memory,
|
||||
t.settings.sections.tools,
|
||||
t.settings.sections.about,
|
||||
t.settings.sections.about,
|
||||
isAdmin,
|
||||
],
|
||||
);
|
||||
return (
|
||||
@@ -125,7 +133,8 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
|
||||
)}
|
||||
{activeSection === "memory" && <MemorySettingsPage />}
|
||||
{activeSection === "tools" && <ToolSettingsPage />}
|
||||
{activeSection === "tools" && <ToolSettingsPage />}
|
||||
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
|
||||
{activeSection === "about" && <AboutSettingsPage />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -102,9 +102,16 @@ import { cn } from "@/lib/utils";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { SkillStateSheet } from "./skill-state-sheet";
|
||||
|
||||
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
export function SkillSettingsPage({
|
||||
onClose,
|
||||
embedded = false,
|
||||
}: {
|
||||
onClose?: () => void;
|
||||
embedded?: boolean;
|
||||
} = {}) {
|
||||
const { t } = useI18n();
|
||||
if (embedded) return <SkillSettingsList onClose={onClose} />;
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t.settings.skills.title}
|
||||
description={t.settings.skills.description}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Database, Loader2, RefreshCw, Search, Sparkles } from "lucide-react";
|
||||
import { CheckCircle2, Database, Loader2, RefreshCw, Search, Sparkles } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -8,8 +8,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useAllSkillsAdmin } from "@/core/skills/hooks";
|
||||
import { useAllSkillsAdmin, useSkills } from "@/core/skills/hooks";
|
||||
import { useLlmWikiKnowledgeBases, useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||
import {
|
||||
type SkillKnowledgeTarget,
|
||||
@@ -25,21 +24,37 @@ const PHASE_LABELS: Record<string, string> = {
|
||||
awaiting_review: "等待审核",
|
||||
merging: "合并实体关系",
|
||||
writing_wiki: "写入 Wiki",
|
||||
writing_sources: "上传知识资料",
|
||||
generating_wiki: "分析并生成分类 Wiki",
|
||||
vectorizing_wiki: "Wiki 向量化",
|
||||
syncing_assistant: "同步知识梳理总库",
|
||||
activating: "激活新版本",
|
||||
completed: "完成",
|
||||
};
|
||||
|
||||
export function SkillKnowledgePanel() {
|
||||
const { skills, isLoading } = useAllSkillsAdmin(true);
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
queued: "等待处理",
|
||||
running: "处理中",
|
||||
awaiting_review: "等待管理员审核",
|
||||
completed: "已完成",
|
||||
partial_failed: "部分失败",
|
||||
failed: "失败",
|
||||
canceled: "已取消",
|
||||
};
|
||||
|
||||
export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean }) {
|
||||
const visibleSkills = useSkills();
|
||||
const allSkills = useAllSkillsAdmin(adminMode);
|
||||
const skills = adminMode ? allSkills.skills : visibleSkills.skills;
|
||||
const isLoading = adminMode ? allSkills.isLoading : visibleSkills.isLoading;
|
||||
const liveSkills = useMemo(() => skills.filter((skill) => (skill.state ?? "active") === "active"), [skills]);
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
||||
const [skillQuery, setSkillQuery] = useState("");
|
||||
const [selectedTargetKey, setSelectedTargetKey] = useState<string | null>(null);
|
||||
const [reviewMode, setReviewMode] = useState<"auto_high_confidence" | "off">("auto_high_confidence");
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora");
|
||||
const bindings = useSkillKnowledgeBindings();
|
||||
const jobs = useSkillKnowledgeJobs();
|
||||
const bindings = useSkillKnowledgeBindings(undefined, !adminMode);
|
||||
const jobs = useSkillKnowledgeJobs(!adminMode);
|
||||
const actions = useSkillKnowledgeActions();
|
||||
|
||||
const targets = useMemo<SkillKnowledgeTarget[]>(() => {
|
||||
@@ -118,7 +133,7 @@ export function SkillKnowledgePanel() {
|
||||
const job = await actions.createJob.mutateAsync({
|
||||
skill_names: selectedSkills,
|
||||
targets: selectedTargets,
|
||||
review_mode: reviewMode,
|
||||
review_mode: "off",
|
||||
});
|
||||
toast.success(`已创建 ${job.total_count} 个归纳项`);
|
||||
setSelectedSkills([]);
|
||||
@@ -128,17 +143,17 @@ export function SkillKnowledgePanel() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid size-full min-h-0 grid-cols-[minmax(320px,0.9fr)_minmax(420px,1.1fr)] gap-4 overflow-hidden p-6">
|
||||
<div className="grid size-full min-h-0 gap-4 overflow-auto p-4 lg:grid-cols-[minmax(320px,0.9fr)_minmax(420px,1.1fr)] lg:overflow-hidden lg:p-6">
|
||||
<section className="flex min-h-0 flex-col rounded-lg border bg-card">
|
||||
<div className="border-b p-4">
|
||||
<h2 className="font-semibold">技能到知识库管理</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">一次选择多个技能归纳到一个 WeKnora Wiki 知识库;代码与脚本不会被读取。</p>
|
||||
<h2 className="font-semibold">归纳技能知识</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">选择技能和一个目标知识库后,系统会在后台提取 Markdown 等知识资料,忽略 Python 等代码文件,自动生成 Wiki、关系和向量索引。</p>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-5 p-4">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">1. 选择技能({selectedSkills.length})</span>
|
||||
<span className="text-sm font-medium">第一步:选择技能(已选 {selectedSkills.length})</span>
|
||||
<Button variant="ghost" size="sm" onClick={toggleFilteredSkills}>
|
||||
{allFilteredSelected ? "清空当前结果" : "选择当前结果"}
|
||||
</Button>
|
||||
@@ -158,6 +173,13 @@ export function SkillKnowledgePanel() {
|
||||
<Checkbox checked={selectedSkills.includes(skill.name)} onCheckedChange={() => toggle(selectedSkills, skill.name, setSelectedSkills)} />
|
||||
<span className="truncate text-sm">{skill.name_zh || skill.name}</span>
|
||||
<span className="ml-auto truncate text-xs text-muted-foreground">{skill.name}</span>
|
||||
{(bindings.data ?? []).some((binding) => binding.skill_name === skill.name) && (
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px]">
|
||||
{(bindings.data ?? []).some((binding) => binding.skill_name === skill.name && `${binding.target_type}:${binding.target_id}` === selectedTargetKey)
|
||||
? "已在目标库"
|
||||
: `已归纳 ${(bindings.data ?? []).filter((binding) => binding.skill_name === skill.name).length} 库`}
|
||||
</Badge>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{!isLoading && !filteredSkills.length && (
|
||||
@@ -170,7 +192,7 @@ export function SkillKnowledgePanel() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">2. 选择目标知识库({selectedTargetKey ? 1 : 0})</span>
|
||||
<span className="text-sm font-medium">第二步:选择一个目标知识库</span>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-md border p-2">
|
||||
{targets.map((target) => {
|
||||
@@ -183,39 +205,32 @@ export function SkillKnowledgePanel() {
|
||||
/>
|
||||
<Database className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm">{target.target_name}</span>
|
||||
<Badge variant="outline" className="ml-auto text-[10px]">WeKnora Wiki</Badge>
|
||||
<Badge variant="outline" className="ml-auto text-[10px]">普通 Wiki 库</Badge>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
|
||||
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
|
||||
<p className="p-3 text-xs text-destructive">
|
||||
读取 WeKnora 普通知识库失败:
|
||||
读取普通知识库失败:
|
||||
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
|
||||
</p>
|
||||
)}
|
||||
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
|
||||
<p className="p-3 text-xs text-muted-foreground">
|
||||
{runtime.data?.provider !== "weknora"
|
||||
? "当前未配置 WeKnora 普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
||||
? "当前未配置普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
||||
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium">审核策略</span>
|
||||
<Select value={reviewMode} onValueChange={(value) => setReviewMode(value as typeof reviewMode)}>
|
||||
<SelectTrigger className="w-52"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto_high_confidence">高置信自动通过</SelectItem>
|
||||
<SelectItem value="off">不阻塞写入</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="rounded-lg border bg-muted/30 p-3 text-xs leading-5 text-muted-foreground">
|
||||
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及同步到“知识梳理”总库均在后台完成。
|
||||
</div>
|
||||
<Button className="w-full" disabled={actions.createJob.isPending} onClick={() => void submit()}>
|
||||
<Button className="w-full" disabled={actions.createJob.isPending || !selectedSkills.length || !selectedTargetKey} onClick={() => void submit()}>
|
||||
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
|
||||
开始归纳({selectedSkills.length * (selectedTargetKey ? 1 : 0)} 项)
|
||||
开始归纳 {selectedSkills.length ? `${selectedSkills.length} 个技能` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -224,22 +239,26 @@ export function SkillKnowledgePanel() {
|
||||
<section className="flex min-h-0 flex-col gap-4 overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col rounded-lg border bg-card">
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div><h2 className="font-semibold">归纳任务</h2><p className="text-xs text-muted-foreground">后台执行、失败可单项重试</p></div>
|
||||
<div><h2 className="font-semibold">我的归纳任务</h2><p className="text-xs text-muted-foreground">任务会自动刷新,失败项可直接重试</p></div>
|
||||
<Button size="icon" variant="ghost" onClick={() => void jobs.refetch()}><RefreshCw className="size-4" /></Button>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-3 p-4">
|
||||
{(jobs.data ?? []).map((job) => {
|
||||
const done = job.success_count + job.partial_failed_count + job.failed_count;
|
||||
const countedDone = job.success_count + job.partial_failed_count + job.failed_count;
|
||||
const itemDone = (job.items ?? []).filter((item) =>
|
||||
["completed", "partial_failed", "failed", "canceled"].includes(item.status),
|
||||
).length;
|
||||
const done = Math.max(countedDone, itemDone);
|
||||
const progress = job.total_count ? Math.round((done / job.total_count) * 100) : 0;
|
||||
return (
|
||||
<div key={job.id} className="rounded-md border p-3">
|
||||
<div className="flex items-center gap-2 text-sm"><Badge variant={job.status === "completed" ? "default" : "secondary"}>{job.status}</Badge><span>{done}/{job.total_count} 项</span><span className="ml-auto text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString()}</span></div>
|
||||
<div className="flex items-center gap-2 text-sm"><Badge variant={job.status === "completed" ? "default" : "secondary"}>{STATUS_LABELS[job.status] || job.status}</Badge><span>{done}/{job.total_count} 项</span><span className="ml-auto text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString()}</span></div>
|
||||
<Progress className="my-2" value={progress} />
|
||||
{job.items?.map((item) => (
|
||||
<div key={item.id} className="mt-2 flex items-center gap-2 border-t pt-2 text-xs">
|
||||
<span className="max-w-36 truncate font-medium">{item.skill_name}</span>
|
||||
<span className="text-muted-foreground">{PHASE_LABELS[item.phase] || item.phase}</span>
|
||||
<span className="text-muted-foreground">{PHASE_LABELS[item.phase] || STATUS_LABELS[item.status] || item.phase}</span>
|
||||
<span className="ml-auto">{item.progress}%</span>
|
||||
{item.status === "failed" && <Button size="sm" variant="outline" onClick={() => actions.retry.mutate(item.id)}>重试</Button>}
|
||||
</div>
|
||||
@@ -252,8 +271,8 @@ export function SkillKnowledgePanel() {
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-auto rounded-lg border bg-card p-3 text-xs">
|
||||
<div className="mb-2 text-muted-foreground">当前有效绑定:{bindings.data?.length ?? 0};同名同类型实体按来源贡献合并。</div>
|
||||
<div className="space-y-2">{bindings.data?.map((binding) => <div key={binding.id} className="flex flex-wrap items-center gap-2 rounded border p-2"><span className="max-w-32 truncate font-medium">{binding.skill_name}</span><span className="max-w-40 truncate text-muted-foreground">→ {binding.target_name}</span><Badge variant="outline">{binding.status}</Badge><code className="max-w-40 truncate text-[10px] text-muted-foreground">{binding.remote_business_key}</code><Button size="sm" variant="outline" className="ml-auto" disabled={actions.resync.isPending} onClick={() => actions.resync.mutate(binding.id)}>重归纳</Button><Button size="sm" variant="ghost" onClick={() => { if (window.confirm("解除关联并保留目标知识内容?")) actions.detach.mutate({ id: binding.id, deleteContributions: false }); }}>解绑并保留</Button><Button size="sm" variant="destructive" onClick={() => { if (window.confirm("解除关联并删除此技能贡献?共享知识仍会保留其他来源。")) actions.detach.mutate({ id: binding.id, deleteContributions: true }); }}>解绑并删除贡献</Button></div>)}</div>
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground"><CheckCircle2 className="size-4 text-emerald-600" />已归纳关联:{bindings.data?.length ?? 0}</div>
|
||||
<div className="space-y-2">{bindings.data?.map((binding) => <div key={binding.id} className="flex flex-wrap items-center gap-2 rounded border p-2"><span className="max-w-40 truncate font-medium">{binding.skill_name}</span><span className="max-w-48 truncate text-muted-foreground">已归纳到 {binding.target_name}</span><Badge variant="outline">{STATUS_LABELS[binding.status] || binding.status}</Badge><Button size="sm" variant="outline" className="ml-auto" disabled={actions.resync.isPending} onClick={() => actions.resync.mutate(binding.id)}>重新归纳</Button><Button size="sm" variant="ghost" onClick={() => { if (window.confirm("解除关联并保留目标知识内容?")) actions.detach.mutate({ id: binding.id, deleteContributions: false }); }}>解除关联</Button>{adminMode && <Button size="sm" variant="destructive" onClick={() => { if (window.confirm("解除关联并删除此技能贡献?共享知识仍会保留其他来源。")) actions.detach.mutate({ id: binding.id, deleteContributions: true }); }}>删除贡献</Button>}</div>)}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { BookOpen, BotIcon, BrainIcon, FileSearch, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, FolderKanban, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||
|
||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
applyCompactMenuOverrides,
|
||||
type CompactMenuItem,
|
||||
} from "@/core/page-layout/compact-menu-overrides";
|
||||
import { type MenuItem } from "@/core/page-layout/sidebar-menu";
|
||||
import {
|
||||
MARKDOWN_DOCX_MENU_ITEM,
|
||||
type MenuItem,
|
||||
} from "@/core/page-layout/sidebar-menu";
|
||||
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
|
||||
|
||||
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
||||
@@ -65,6 +68,15 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
path: "/page/workspace/chats",
|
||||
match: (pathname) => pathname === "/page/workspace/chats",
|
||||
},
|
||||
{
|
||||
id: "strategy-projects",
|
||||
label: "我的项目",
|
||||
icon: FolderKanban,
|
||||
path: "/page/strategy/qa/projects",
|
||||
match: (pathname) =>
|
||||
pathname.startsWith("/page/strategy/qa/projects") ||
|
||||
pathname.startsWith("/page/strategy/qa/strategy-selection"),
|
||||
},
|
||||
{
|
||||
id: "workspace-library",
|
||||
label: "资料库",
|
||||
@@ -108,6 +120,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/deep-research"),
|
||||
},
|
||||
// {
|
||||
// id: "workspace-report-collaboration",
|
||||
// label: "报告协作",
|
||||
// icon: FileText,
|
||||
// path: "/page/workspace/report-collaboration",
|
||||
// match: (pathname) => pathname.startsWith("/page/workspace/report-collaboration"),
|
||||
// },
|
||||
// {
|
||||
// id: "workspace-enterprise-research",
|
||||
// label: "企业深度研究",
|
||||
// icon: FileSearch,
|
||||
@@ -133,7 +152,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
label: "知识库管理",
|
||||
icon: BookOpen,
|
||||
path: "/page/workspace/knowledge",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/knowledge"),
|
||||
match: (pathname) =>
|
||||
pathname.startsWith("/page/workspace/knowledge") &&
|
||||
!pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
||||
},
|
||||
{
|
||||
...MARKDOWN_DOCX_MENU_ITEM,
|
||||
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ export function WorkspaceNavMenu() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
|
||||
"appearance" | "memory" | "tools" | "about"
|
||||
"appearance" | "memory" | "tools" | "encoder" | "about"
|
||||
>("appearance");
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -189,6 +189,17 @@ export function WorkspaceNavMenu() {
|
||||
<Settings2Icon />
|
||||
{t.common.settings}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSettingsDefaultSection("encoder");
|
||||
setSettingsOpen(true);
|
||||
}}
|
||||
>
|
||||
<CpuIcon />
|
||||
本地编码器
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
@@ -254,14 +265,6 @@ export function WorkspaceNavMenu() {
|
||||
技能管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/skills?tab=knowledge")}
|
||||
>
|
||||
<DatabaseZapIcon />
|
||||
技能到知识库管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/models")}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* ``GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=``(带 DeerFlow 登录态,任务数据
|
||||
* 来自 TaskCOP 任务表,即任务列表 / 报告导入用的同一份)。
|
||||
* 2. **固定使用内置智能体** ``agentfx-analyst``(后端 seeder ``_agentfx_seed.py`` 启动时补建:
|
||||
* 搜集信息 → 生成 report.md + task-reports.json(14 类)→ 总结 → 前端「入库」协助卡 /
|
||||
* 搜集信息 → hits 落盘 → task-report-build 一条命令转换成 14 类 task-reports.json → 写 report.md → 总结 → 前端「入库」协助卡 /
|
||||
* task-report-import 技能入库),不走「配置管理」业务映射。
|
||||
* 3. 路由挂在 ``plan-task-agentfx`` 前缀下,页面代码见 [[AgentFxWorkspace]]。
|
||||
*
|
||||
|
||||
@@ -109,8 +109,8 @@ export interface TaskButton {
|
||||
/** 追加的查询参数名(如 ``id`` / ``task_id``);空则用 ``id``。 */
|
||||
idParam: string;
|
||||
/**
|
||||
* ``url`` 追加 id 时取任务id 还是行动id。仅 xdfx(行动分析)二者不同:深链原始 ``?taskId=``
|
||||
* 实为行动id,任务id 由 action 接口解析。其它业务两者相同。
|
||||
* ``url`` / ``host`` 追加 id 时取任务id 还是行动id。仅 xdfx(行动分析)二者不同:深链原始
|
||||
* ``?taskId=`` 实为行动id,任务id 由 action 接口解析。其它业务两者相同。
|
||||
*/
|
||||
idKind: TaskButtonIdKind;
|
||||
/** ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口。仅 ``url`` 类型有意义。 */
|
||||
@@ -327,7 +327,8 @@ export interface RunTaskButtonCtx {
|
||||
* 执行一个跳转按钮:
|
||||
* - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。
|
||||
* - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。
|
||||
* - ``host``:向 ``window.parent`` 发 ``HOST_NAVIGATE``,由宿主系统跳转(仅 iframe 嵌入时生效)。
|
||||
* - ``host``:向 ``window.parent`` 发 ``HOST_NAVIGATE``,由宿主系统跳转(仅 iframe 嵌入时生效;
|
||||
* 追加 id 时同样按 ``idKind`` 取任务id / 行动id)。
|
||||
*/
|
||||
export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise<void> {
|
||||
const fail = (m: string) => ctx.onError?.(m);
|
||||
@@ -355,9 +356,11 @@ export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Pro
|
||||
}
|
||||
const queryParams: Record<string, number> = {};
|
||||
if (btn.appendTaskId) {
|
||||
const id = parseHostTaskId(ctx.taskId);
|
||||
// 行动分析(xdfx)可选发行动id;与 url 外链同规则:行动id 缺失时回退任务id。
|
||||
const raw = btn.idKind === "action" ? ctx.actionId || ctx.taskId : ctx.taskId;
|
||||
const id = parseHostTaskId(raw);
|
||||
if (id === null) {
|
||||
fail("任务id不是数字,无法跳转宿主系统");
|
||||
fail(`${btn.idKind === "action" ? "行动id" : "任务id"}不是数字,无法跳转宿主系统`);
|
||||
return;
|
||||
}
|
||||
queryParams[(btn.idParam || "id").trim() || "id"] = id;
|
||||
|
||||
@@ -96,6 +96,9 @@ export const enUS: Translations = {
|
||||
mode: "Mode",
|
||||
flashMode: "Flash",
|
||||
flashModeDescription: "Fast and efficient, but may not be accurate",
|
||||
turboMode: "Turbo",
|
||||
turboModeDescription:
|
||||
"Talk to the model directly with no agent tools or skills, for lower first-token latency",
|
||||
reasoningMode: "Reasoning",
|
||||
reasoningModeDescription:
|
||||
"Reasoning before action, balance between time and accuracy",
|
||||
@@ -448,17 +451,17 @@ export const enUS: Translations = {
|
||||
system: "System",
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
lightblue: "Light Blue",
|
||||
lightgreen: "Green",
|
||||
iceblue: "Glacier Blue",
|
||||
lightyellow: "Pale Yellow",
|
||||
lightblue: "Light Blue",
|
||||
lightgreen: "Green",
|
||||
iceblue: "Glacier Blue",
|
||||
lightyellow: "Pale Yellow",
|
||||
systemDescription: "Match the operating system preference automatically.",
|
||||
lightDescription: "Bright palette with higher contrast for daytime.",
|
||||
darkDescription: "Dim palette that reduces glare for focus.",
|
||||
lightblueDescription: "A crisp light palette built around blue.",
|
||||
lightgreenDescription: "A crisp light palette built around fresh green tones.",
|
||||
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
|
||||
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
|
||||
lightblueDescription: "A crisp light palette built around blue.",
|
||||
lightgreenDescription: "A crisp light palette built around fresh green tones.",
|
||||
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
|
||||
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
|
||||
languageTitle: "Language",
|
||||
languageDescription: "Switch between languages.",
|
||||
},
|
||||
@@ -545,13 +548,13 @@ export const enUS: Translations = {
|
||||
filesSaveAction: "Save",
|
||||
filesCancelEditAction: "Cancel",
|
||||
filesDownloadAction: "Download",
|
||||
filesCopyPathAction: "Copy path",
|
||||
filesDiscardConfirm: "Discard unsaved changes?",
|
||||
filesSaveSuccess: "File saved",
|
||||
filesDeleteAction: "Delete",
|
||||
filesDeleteConfirm: "Delete \"{path}\"? This cannot be undone.",
|
||||
filesDeleteSuccess: "File deleted",
|
||||
filesReadOnlyHint: "Only administrators can edit or delete built-in skill files.",
|
||||
filesCopyPathAction: "Copy path",
|
||||
filesDiscardConfirm: "Discard unsaved changes?",
|
||||
filesSaveSuccess: "File saved",
|
||||
filesDeleteAction: "Delete",
|
||||
filesDeleteConfirm: "Delete \"{path}\"? This cannot be undone.",
|
||||
filesDeleteSuccess: "File deleted",
|
||||
filesReadOnlyHint: "Only administrators can edit or delete built-in skill files.",
|
||||
viewDetailAction: "View skill details",
|
||||
stateSheet: {
|
||||
archivedTitle: "Archived skills",
|
||||
@@ -591,26 +594,26 @@ export const enUS: Translations = {
|
||||
chooseFile: "Choose file",
|
||||
validating: "Validating archive…",
|
||||
installing: "Installing…",
|
||||
replace: "Upload again and replace",
|
||||
replace: "Upload again and replace",
|
||||
install: "Install",
|
||||
cancel: "Cancel",
|
||||
nameLabel: "Name",
|
||||
descriptionLabel: "Description",
|
||||
licenseLabel: "License",
|
||||
toolsLabel: "Allowed tools",
|
||||
warningsLabel: "Warnings",
|
||||
conflictOwnedByMe:
|
||||
"You already uploaded a skill named '{name}'. You may upload it again and replace your existing copy (its enabled state will be kept).",
|
||||
conflictOwnedByMeWrongTarget:
|
||||
"The custom skill '{name}' belongs to you. Switch the target to 'Custom skill' to replace it.",
|
||||
conflictOwnedByOther:
|
||||
"Skill '{name}' was uploaded by user '{owner}' and cannot be replaced. Rename the skill before uploading.",
|
||||
conflictCustom:
|
||||
"A custom skill named '{name}' already exists, but its uploader could not be identified. Rename it to avoid overwriting someone else's skill.",
|
||||
warningsLabel: "Warnings",
|
||||
conflictOwnedByMe:
|
||||
"You already uploaded a skill named '{name}'. You may upload it again and replace your existing copy (its enabled state will be kept).",
|
||||
conflictOwnedByMeWrongTarget:
|
||||
"The custom skill '{name}' belongs to you. Switch the target to 'Custom skill' to replace it.",
|
||||
conflictOwnedByOther:
|
||||
"Skill '{name}' was uploaded by user '{owner}' and cannot be replaced. Rename the skill before uploading.",
|
||||
conflictCustom:
|
||||
"A custom skill named '{name}' already exists, but its uploader could not be identified. Rename it to avoid overwriting someone else's skill.",
|
||||
conflictPublic:
|
||||
"A built-in public skill named '{name}' already exists and cannot be overwritten.",
|
||||
conflictPublicAdminHint:
|
||||
"A built-in public skill named '{name}' exists. Click 'Upload again and replace' to overwrite it.",
|
||||
conflictPublicAdminHint:
|
||||
"A built-in public skill named '{name}' exists. Click 'Upload again and replace' to overwrite it.",
|
||||
installSuccess: "Skill '{name}' installed.",
|
||||
installSuccessReplaced: "Skill '{name}' replaced.",
|
||||
rejectInvalidExt: "Only .zip or .skill files are accepted.",
|
||||
|
||||
@@ -91,6 +91,8 @@ export interface Translations {
|
||||
mode: string;
|
||||
flashMode: string;
|
||||
flashModeDescription: string;
|
||||
turboMode: string;
|
||||
turboModeDescription: string;
|
||||
reasoningMode: string;
|
||||
reasoningModeDescription: string;
|
||||
proMode: string;
|
||||
@@ -383,17 +385,17 @@ export interface Translations {
|
||||
system: string;
|
||||
light: string;
|
||||
dark: string;
|
||||
lightblue: string;
|
||||
lightgreen: string;
|
||||
iceblue: string;
|
||||
lightyellow: string;
|
||||
lightblue: string;
|
||||
lightgreen: string;
|
||||
iceblue: string;
|
||||
lightyellow: string;
|
||||
systemDescription: string;
|
||||
lightDescription: string;
|
||||
darkDescription: string;
|
||||
lightblueDescription: string;
|
||||
lightgreenDescription: string;
|
||||
iceblueDescription: string;
|
||||
lightyellowDescription: string;
|
||||
lightblueDescription: string;
|
||||
lightgreenDescription: string;
|
||||
iceblueDescription: string;
|
||||
lightyellowDescription: string;
|
||||
languageTitle: string;
|
||||
languageDescription: string;
|
||||
};
|
||||
@@ -475,13 +477,13 @@ export interface Translations {
|
||||
filesSaveAction: string;
|
||||
filesCancelEditAction: string;
|
||||
filesDownloadAction: string;
|
||||
filesCopyPathAction: string;
|
||||
filesDiscardConfirm: string;
|
||||
filesSaveSuccess: string;
|
||||
filesDeleteAction: string;
|
||||
filesDeleteConfirm: string;
|
||||
filesDeleteSuccess: string;
|
||||
filesReadOnlyHint: string;
|
||||
filesCopyPathAction: string;
|
||||
filesDiscardConfirm: string;
|
||||
filesSaveSuccess: string;
|
||||
filesDeleteAction: string;
|
||||
filesDeleteConfirm: string;
|
||||
filesDeleteSuccess: string;
|
||||
filesReadOnlyHint: string;
|
||||
viewDetailAction: string;
|
||||
stateSheet: {
|
||||
archivedTitle: string;
|
||||
@@ -525,12 +527,12 @@ export interface Translations {
|
||||
nameLabel: string;
|
||||
descriptionLabel: string;
|
||||
licenseLabel: string;
|
||||
toolsLabel: string;
|
||||
warningsLabel: string;
|
||||
conflictOwnedByMe: string;
|
||||
conflictOwnedByMeWrongTarget: string;
|
||||
conflictOwnedByOther: string;
|
||||
conflictCustom: string;
|
||||
toolsLabel: string;
|
||||
warningsLabel: string;
|
||||
conflictOwnedByMe: string;
|
||||
conflictOwnedByMeWrongTarget: string;
|
||||
conflictOwnedByOther: string;
|
||||
conflictCustom: string;
|
||||
conflictPublic: string;
|
||||
conflictPublicAdminHint: string;
|
||||
installSuccess: string;
|
||||
|
||||
@@ -95,6 +95,8 @@ export const zhCN: Translations = {
|
||||
mode: "模式",
|
||||
flashMode: "快速",
|
||||
flashModeDescription: "快速且高效的完成任务,但可能不够精准",
|
||||
turboMode: "极速",
|
||||
turboModeDescription: "直连大模型流式问答,不走智能体工具与技能,首字更快",
|
||||
reasoningMode: "思考",
|
||||
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
|
||||
proMode: "专业问答",
|
||||
@@ -429,17 +431,17 @@ export const zhCN: Translations = {
|
||||
system: "系统",
|
||||
light: "浅色",
|
||||
dark: "深色",
|
||||
lightblue: "浅蓝",
|
||||
lightgreen: "绿色",
|
||||
iceblue: "冰川蓝",
|
||||
lightyellow: "浅黄色",
|
||||
lightblue: "浅蓝",
|
||||
lightgreen: "绿色",
|
||||
iceblue: "冰川蓝",
|
||||
lightyellow: "浅黄色",
|
||||
systemDescription: "自动跟随系统主题。",
|
||||
lightDescription: "更明亮的配色,适合日间使用。",
|
||||
darkDescription: "更暗的配色,减少眩光方便专注。",
|
||||
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
|
||||
lightgreenDescription: "采用清新绿色的浅色配色。",
|
||||
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
|
||||
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
|
||||
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
|
||||
lightgreenDescription: "采用清新绿色的浅色配色。",
|
||||
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
|
||||
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
|
||||
languageTitle: "语言",
|
||||
languageDescription: "在不同语言之间切换。",
|
||||
},
|
||||
@@ -524,11 +526,11 @@ export const zhCN: Translations = {
|
||||
filesDownloadAction: "下载",
|
||||
filesCopyPathAction: "复制路径",
|
||||
filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
|
||||
filesSaveSuccess: "文件已保存",
|
||||
filesDeleteAction: "删除",
|
||||
filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。",
|
||||
filesDeleteSuccess: "文件已删除",
|
||||
filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。",
|
||||
filesSaveSuccess: "文件已保存",
|
||||
filesDeleteAction: "删除",
|
||||
filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。",
|
||||
filesDeleteSuccess: "文件已删除",
|
||||
filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。",
|
||||
viewDetailAction: "查看技能详情",
|
||||
stateSheet: {
|
||||
archivedTitle: "归档的技能",
|
||||
@@ -566,24 +568,24 @@ export const zhCN: Translations = {
|
||||
chooseFile: "选择文件",
|
||||
validating: "正在校验文件…",
|
||||
installing: "安装中…",
|
||||
replace: "重复上传并覆盖",
|
||||
replace: "重复上传并覆盖",
|
||||
install: "安装",
|
||||
cancel: "取消",
|
||||
nameLabel: "名称",
|
||||
descriptionLabel: "描述",
|
||||
licenseLabel: "许可证",
|
||||
toolsLabel: "允许的工具",
|
||||
warningsLabel: "警告",
|
||||
conflictOwnedByMe:
|
||||
"您已上传过同名技能 “{name}”,可重复上传并覆盖原版本(原启用状态保留)。",
|
||||
conflictOwnedByMeWrongTarget:
|
||||
"同名技能 “{name}” 是您上传的自定义技能,请切换到「自定义技能」后覆盖。",
|
||||
conflictOwnedByOther:
|
||||
"技能 “{name}” 已由用户 “{owner}” 上传,不能覆盖,请修改技能名称后再上传。",
|
||||
conflictCustom:
|
||||
"已存在同名自定义技能 “{name}”,但无法确认上传用户,为避免误覆盖请修改技能名称。",
|
||||
warningsLabel: "警告",
|
||||
conflictOwnedByMe:
|
||||
"您已上传过同名技能 “{name}”,可重复上传并覆盖原版本(原启用状态保留)。",
|
||||
conflictOwnedByMeWrongTarget:
|
||||
"同名技能 “{name}” 是您上传的自定义技能,请切换到「自定义技能」后覆盖。",
|
||||
conflictOwnedByOther:
|
||||
"技能 “{name}” 已由用户 “{owner}” 上传,不能覆盖,请修改技能名称后再上传。",
|
||||
conflictCustom:
|
||||
"已存在同名自定义技能 “{name}”,但无法确认上传用户,为避免误覆盖请修改技能名称。",
|
||||
conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
|
||||
conflictPublicAdminHint: `存在同名内置技能 '{name}'。点击「重复上传并覆盖」可替换。`,
|
||||
conflictPublicAdminHint: `存在同名内置技能 '{name}'。点击「重复上传并覆盖」可替换。`,
|
||||
installSuccess: `技能 '{name}' 已安装。`,
|
||||
installSuccessReplaced: `技能 '{name}' 已替换。`,
|
||||
rejectInvalidExt: `只接受 .zip 或 .skill 文件。`,
|
||||
|
||||
@@ -111,6 +111,28 @@ test("a lone deep-research progress bar still opens its own group", () => {
|
||||
assert.deepEqual(types, ["assistant:processing"]);
|
||||
});
|
||||
|
||||
test("multi-round RAG assistant rows render even while the final answer is still empty", () => {
|
||||
const types = groupTypes([
|
||||
{ type: "human", id: "h1", content: "检索一下", additional_kwargs: { multi_round_rag: true } } as Message,
|
||||
{
|
||||
type: "ai",
|
||||
id: "a1",
|
||||
content: "",
|
||||
additional_kwargs: {
|
||||
multi_round_rag: true,
|
||||
multi_round_rag_trace: {
|
||||
title: "多轮检索思考过程",
|
||||
rounds: [{ id: "r1", title: "思考过程", thoughtMarkdown: "正在拆解问题", query: "", queryShow: true, retrievals: [] }],
|
||||
finalAnswerMarkdown: "",
|
||||
thinkingComplete: false,
|
||||
},
|
||||
},
|
||||
} as Message,
|
||||
]);
|
||||
|
||||
assert.deepEqual(types, ["human", "assistant"]);
|
||||
});
|
||||
|
||||
test("tool messages still attach to an open processing group", () => {
|
||||
const types = groupTypes([
|
||||
{
|
||||
|
||||
@@ -28,6 +28,8 @@ interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant:
|
||||
|
||||
interface AssistantTaskReportImportGroup extends GenericMessageGroup<"assistant:task-report-import"> {}
|
||||
|
||||
interface AssistantBusinessCardGroup extends GenericMessageGroup<"assistant:business-card"> {}
|
||||
|
||||
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
||||
|
||||
type MessageGroup =
|
||||
@@ -41,6 +43,7 @@ type MessageGroup =
|
||||
| AssistantSetupReportStructureApprovalGroup
|
||||
| AssistantResearchApprovalGroup
|
||||
| AssistantTaskReportImportGroup
|
||||
| AssistantBusinessCardGroup
|
||||
| AssistantSubagentGroup;
|
||||
|
||||
export function groupMessages<T>(
|
||||
@@ -65,7 +68,8 @@ export function groupMessages<T>(
|
||||
last.type !== "assistant:setup-agent-approval" &&
|
||||
last.type !== "assistant:setup-writing-approval" &&
|
||||
last.type !== "assistant:setup-report-structure-approval" &&
|
||||
last.type !== "assistant:research-approval"
|
||||
last.type !== "assistant:research-approval" &&
|
||||
last.type !== "assistant:business-card"
|
||||
) {
|
||||
return last;
|
||||
}
|
||||
@@ -137,6 +141,16 @@ export function groupMessages<T>(
|
||||
}
|
||||
|
||||
if (message.type === "ai") {
|
||||
// Multi-round RAG keeps rounds/retrievals on additional_kwargs while the
|
||||
// visible answer is still empty. The default rule below only opens an
|
||||
// assistant bubble when hasContent() is true, which would swallow the
|
||||
// live thinking cards until the final answer arrives (or forever, if the
|
||||
// turn is think/search-only). Force a normal assistant row so
|
||||
// MessageListItem can render MultiRoundRagAssistantBody immediately.
|
||||
if (message.additional_kwargs?.multi_round_rag === true) {
|
||||
groups.push({ id: message.id, type: "assistant", messages: [message] });
|
||||
continue;
|
||||
}
|
||||
// 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
|
||||
@@ -159,6 +173,17 @@ export function groupMessages<T>(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// 通用业务卡(报告协作方案组/命令确认/报告入口等):虚拟 ai 消息
|
||||
// (additional_kwargs.report_collaboration_card),由宿主注入并经
|
||||
// businessCardSlot 渲染;不传插槽的页面零渲染、零行为变化。
|
||||
if (hasBusinessCard(message)) {
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:business-card",
|
||||
messages: [message],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (hasPresentFiles(message)) {
|
||||
groups.push({
|
||||
id: message.id,
|
||||
@@ -339,11 +364,16 @@ export function extractReasoningContentFromMessage(message: Message) {
|
||||
if (message.type !== "ai") {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
message.additional_kwargs &&
|
||||
"reasoning_content" in message.additional_kwargs
|
||||
) {
|
||||
return message.additional_kwargs.reasoning_content as string | null;
|
||||
if (message.additional_kwargs) {
|
||||
const reasoningContent = message.additional_kwargs.reasoning_content;
|
||||
if (typeof reasoningContent === "string") {
|
||||
return reasoningContent;
|
||||
}
|
||||
// Newer vLLM puts thinking in `reasoning` instead of `reasoning_content`.
|
||||
const reasoning = message.additional_kwargs.reasoning;
|
||||
if (typeof reasoning === "string") {
|
||||
return reasoning;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
const part = message.content[0];
|
||||
@@ -399,6 +429,9 @@ export function hasReasoning(message: Message) {
|
||||
if (typeof message.additional_kwargs?.reasoning_content === "string") {
|
||||
return true;
|
||||
}
|
||||
if (typeof message.additional_kwargs?.reasoning === "string") {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
const part = message.content[0];
|
||||
// Compatible with the Anthropic gateway
|
||||
@@ -487,6 +520,19 @@ export function hasTaskReportImportApproval(message: Message) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用业务卡(报告协作等页面):宿主注入的虚拟 ai 消息
|
||||
* (additional_kwargs.report_collaboration_card),经 MessageList 的
|
||||
* businessCardSlot 渲染。集中在此识别,业务卡 payload 结构由各页面自定义。
|
||||
*/
|
||||
export function hasBusinessCard(message: Message) {
|
||||
return (
|
||||
message.type === "ai" &&
|
||||
typeof message.additional_kwargs?.report_collaboration_card === "object" &&
|
||||
message.additional_kwargs.report_collaboration_card !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function extractPresentFilesFromMessage(message: Message) {
|
||||
if (message.type !== "ai" || !hasPresentFiles(message)) {
|
||||
return [];
|
||||
|
||||
@@ -4,11 +4,11 @@ export interface Model {
|
||||
model: string;
|
||||
display_name: string;
|
||||
description?: string | null;
|
||||
supports_thinking?: boolean;
|
||||
supports_reasoning_effort?: boolean;
|
||||
/** Execution profiles handled by the frontend instead of DeerFlow. */
|
||||
execution_profile?: "single_round_rag" | "multi_round_rag";
|
||||
}
|
||||
supports_thinking?: boolean;
|
||||
supports_reasoning_effort?: boolean;
|
||||
/** Execution profiles handled by the frontend instead of DeerFlow. */
|
||||
execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
|
||||
}
|
||||
|
||||
export interface TokenUsageSettings {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Hash-router entry used by the customized Vite application for the isolated
|
||||
* official-style UI. Keeping this prefix distinct from `/page/*` prevents its
|
||||
* routes and mock state from altering the customized application.
|
||||
*/
|
||||
export const OFFICIAL_DEERFLOW_ROUTE_PREFIX = "/official";
|
||||
|
||||
/** The official-style workspace home shown after a normal login. */
|
||||
export const OFFICIAL_DEERFLOW_HOME_ROUTE =
|
||||
`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`;
|
||||
|
||||
/** Existing customized application's normal post-login destination. */
|
||||
export const CUSTOMIZED_DEERFLOW_HOME_ROUTE = "/page/workspace/chats/new";
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FileBox,
|
||||
FileSearch,
|
||||
FileText,
|
||||
FolderKanban,
|
||||
Gauge,
|
||||
Grid3X3,
|
||||
HelpCircle,
|
||||
@@ -48,12 +49,23 @@ export type MenuItem = {
|
||||
path?: string;
|
||||
/** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */
|
||||
aliasPaths?: string[];
|
||||
/** 从当前菜单项自身的 path / aliasPaths 前缀匹配中排除的路由。 */
|
||||
excludePaths?: string[];
|
||||
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
|
||||
userPath?: string;
|
||||
adminOnly?: boolean;
|
||||
children?: MenuItem[];
|
||||
};
|
||||
|
||||
/** Markdown 转 Word 功能的菜单定义,供全量与简洁模式共用。 */
|
||||
export const MARKDOWN_DOCX_MENU_PATH = "/page/workspace/knowledge/markdown-editor";
|
||||
export const MARKDOWN_DOCX_MENU_ITEM: MenuItem & { path: string } = {
|
||||
id: "resource-markdown-docx-editor",
|
||||
label: "md转word",
|
||||
icon: FileText,
|
||||
path: MARKDOWN_DOCX_MENU_PATH,
|
||||
};
|
||||
|
||||
export const sidebarMenuItems: MenuItem[] = [
|
||||
{
|
||||
id: "qa-center",
|
||||
@@ -68,6 +80,13 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
icon: Users,
|
||||
path: "/page/strategy/qa/multi-agent-roundtable",
|
||||
},
|
||||
{
|
||||
id: "strategy-projects",
|
||||
label: "我的项目",
|
||||
icon: FolderKanban,
|
||||
path: "/page/strategy/qa/projects",
|
||||
aliasPaths: ["/page/strategy/qa/strategy-selection"],
|
||||
},
|
||||
// {
|
||||
// id: "position-roundtable",
|
||||
// label: "岗位会商",
|
||||
@@ -258,12 +277,7 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
},
|
||||
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
|
||||
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
||||
{
|
||||
id: "resource-markdown-docx-editor",
|
||||
label: "md转word",
|
||||
icon: FileText,
|
||||
path: "/page/workspace/knowledge/markdown-editor",
|
||||
},
|
||||
MARKDOWN_DOCX_MENU_ITEM,
|
||||
{
|
||||
id: "mgmt-tasks",
|
||||
label: "定时任务",
|
||||
@@ -330,6 +344,7 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
label: "知识库管理",
|
||||
icon: BookOpen,
|
||||
path: "/page/workspace/knowledge",
|
||||
excludePaths: [MARKDOWN_DOCX_MENU_PATH],
|
||||
},
|
||||
{
|
||||
id: "resource-knowledge-base",
|
||||
@@ -365,6 +380,9 @@ function routeMatchesPathname(pathname: string, route?: string): boolean {
|
||||
|
||||
/** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */
|
||||
export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean {
|
||||
if (item.excludePaths?.some((path) => routeMatchesPathname(pathname, path))) {
|
||||
return false;
|
||||
}
|
||||
if (routeMatchesPathname(pathname, item.path)) return true;
|
||||
return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import { replaceMermaidFencesForWordExport } from "@/core/utils/mermaid";
|
||||
|
||||
const DOCX_MEDIA_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
@@ -38,13 +39,15 @@ export async function markdownToDocxBlob(
|
||||
const source = markdown?.trim();
|
||||
if (!source) throw new Error("文档内容为空");
|
||||
|
||||
const prepared = await replaceMermaidFencesForWordExport(source);
|
||||
const endpoint = `${getBackendBaseURL()}/api/writing/export/docx`;
|
||||
const response = await apiFetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: title?.trim() || undefined,
|
||||
markdown: source,
|
||||
markdown: prepared.markdown,
|
||||
embedded_images: prepared.embeddedImages,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw await responseError(response);
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./plugins";
|
||||
export * from "./mermaid-png-download";
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, type RefObject } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { downloadSvgElementAsPng } from "@/core/utils/mermaid";
|
||||
|
||||
function findStreamdownMermaidDownloadBlock(target: EventTarget | null): HTMLElement | null {
|
||||
if (!(target instanceof Element)) return null;
|
||||
const button = target.closest("button");
|
||||
if (!(button instanceof HTMLButtonElement)) return null;
|
||||
if (button.getAttribute("title") !== "Download file") return null;
|
||||
const block = button.closest('[data-streamdown="mermaid-block"]');
|
||||
return block instanceof HTMLElement ? block : null;
|
||||
}
|
||||
|
||||
function findRenderedMermaidChartSvg(block: HTMLElement): SVGSVGElement | null {
|
||||
const fromChart = block.querySelector('[aria-label="Mermaid chart"] svg, [role="img"] svg');
|
||||
if (fromChart instanceof SVGSVGElement && !fromChart.closest("button")) {
|
||||
return fromChart;
|
||||
}
|
||||
for (const svg of block.querySelectorAll("svg")) {
|
||||
if (svg instanceof SVGSVGElement && !svg.closest("button")) {
|
||||
return svg;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Intercept Streamdown mermaid download buttons and save a PNG of the chart. */
|
||||
export function useStreamdownMermaidPngDownload(rootRef: RefObject<HTMLElement | null>) {
|
||||
useEffect(() => {
|
||||
const onClickCapture = (event: MouseEvent) => {
|
||||
const root = rootRef.current;
|
||||
if (!root || !root.contains(event.target as Node)) return;
|
||||
const block = findStreamdownMermaidDownloadBlock(event.target);
|
||||
if (!block) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
const svg = findRenderedMermaidChartSvg(block);
|
||||
if (!svg) {
|
||||
toast.error("流程图尚未渲染完成,请稍后再下载");
|
||||
return;
|
||||
}
|
||||
|
||||
void downloadSvgElementAsPng(svg, "flowchart.png").catch((error) => {
|
||||
console.error("Failed to download mermaid image:", error);
|
||||
toast.error(error instanceof Error ? error.message : "下载流程图失败");
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("click", onClickCapture, true);
|
||||
return () => document.removeEventListener("click", onClickCapture, true);
|
||||
}, [rootRef]);
|
||||
}
|
||||
@@ -11,6 +11,60 @@ export interface PromptPrefixSettings {
|
||||
|
||||
export type KnowledgeBaseProductVersion = "v1" | "v2";
|
||||
|
||||
export type OrdinaryKnowledgeLanding = "wiki" | "weknora";
|
||||
|
||||
export interface StrategyProjectSettings {
|
||||
taiwan_strait_demo_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface StrategyProjectSettingsUpdate {
|
||||
taiwan_strait_demo_enabled?: boolean;
|
||||
}
|
||||
|
||||
export async function loadStrategyProjectSettings(): Promise<StrategyProjectSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/strategy-projects`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`无法读取策略项目设置(HTTP ${response.status})`);
|
||||
}
|
||||
return (await response.json()) as StrategyProjectSettings;
|
||||
}
|
||||
|
||||
export async function updateStrategyProjectSettings(
|
||||
body: StrategyProjectSettingsUpdate,
|
||||
): Promise<StrategyProjectSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/strategy-projects`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "保存策略项目设置失败" }));
|
||||
throw new Error(detail.detail ?? "保存策略项目设置失败");
|
||||
}
|
||||
return (await response.json()) as StrategyProjectSettings;
|
||||
}
|
||||
|
||||
export async function loadOrdinaryKnowledgeLanding(): Promise<OrdinaryKnowledgeLanding> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`);
|
||||
if (!response.ok) throw new Error("无法读取普通知识库默认页面设置");
|
||||
return ((await response.json()) as { mode: OrdinaryKnowledgeLanding }).mode;
|
||||
}
|
||||
|
||||
export async function saveOrdinaryKnowledgeLanding(mode: OrdinaryKnowledgeLanding): Promise<OrdinaryKnowledgeLanding> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`, {
|
||||
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode }),
|
||||
});
|
||||
if (!response.ok) throw new Error("保存失败,仅管理员可配置普通知识库默认页面");
|
||||
return ((await response.json()) as { mode: OrdinaryKnowledgeLanding }).mode;
|
||||
}
|
||||
|
||||
export async function loadKnowledgeBaseProductVersion(): Promise<KnowledgeBaseProductVersion> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
loadPromptPrefixSettings,
|
||||
loadPublishSquares,
|
||||
loadSkillCompressionSettings,
|
||||
loadStrategyProjectSettings,
|
||||
updateAgentModuleConfig,
|
||||
updateAppearanceSettings,
|
||||
updateCitationDisplaySettings,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
updatePromptPrefixSettings,
|
||||
updatePublishSquares,
|
||||
updateSkillCompressionSettings,
|
||||
updateStrategyProjectSettings,
|
||||
type AppearanceUpdate,
|
||||
type CitationDisplayUpdate,
|
||||
type LeaderboardSettingsUpdate,
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
type PromptPrefixUpdate,
|
||||
type PublishSquaresUpdate,
|
||||
type SkillCompressionUpdate,
|
||||
type StrategyProjectSettingsUpdate,
|
||||
type LayoutMode,
|
||||
type ViewMode,
|
||||
} from "./api";
|
||||
@@ -45,6 +48,28 @@ const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
|
||||
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
|
||||
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
|
||||
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
|
||||
const STRATEGY_PROJECTS_KEY = ["systemSettings", "strategyProjects"] as const;
|
||||
|
||||
export function useStrategyProjectSettings() {
|
||||
return useQuery({
|
||||
queryKey: STRATEGY_PROJECTS_KEY,
|
||||
queryFn: () => loadStrategyProjectSettings(),
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateStrategyProjectSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: StrategyProjectSettingsUpdate) =>
|
||||
updateStrategyProjectSettings(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(STRATEGY_PROJECTS_KEY, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePromptPrefixSettings() {
|
||||
return useQuery({
|
||||
@@ -164,19 +189,15 @@ export function useViewMode(
|
||||
/**
|
||||
* Effective interface layout mode (full chrome vs compact). A user's own choice
|
||||
* (persisted in local settings) wins; otherwise the admin-configured system
|
||||
* default applies; otherwise `undefined` (callers fall back to the session-level
|
||||
* `sidebarEnabled` flag set by portal / yUserId login). `setMode` persists the
|
||||
* user's override locally.
|
||||
* default applies; otherwise the built-in default is compact (简洁模式).
|
||||
* `setMode` persists the user's override locally.
|
||||
*/
|
||||
export function useLayoutMode(): [
|
||||
LayoutMode | undefined,
|
||||
(mode: LayoutMode) => void,
|
||||
] {
|
||||
export function useLayoutMode(): [LayoutMode, (mode: LayoutMode) => void] {
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
const userOverride = settings.display.layoutMode;
|
||||
const adminDefault = appearance?.default_layout_mode;
|
||||
const mode = userOverride ?? adminDefault;
|
||||
const mode: LayoutMode = userOverride ?? adminDefault ?? "compact";
|
||||
const setMode = (next: LayoutMode) =>
|
||||
setSettings("display", { layoutMode: next });
|
||||
return [mode, setMode];
|
||||
|
||||
@@ -16,14 +16,14 @@ import { rememberAskTime } from "../chat-timestamps";
|
||||
import { getBackendBaseURL, getLangGraphBaseURL } from "../config";
|
||||
import { useI18n } from "../i18n/hooks";
|
||||
import { OptType, writeYLog } from "../log/y-log";
|
||||
import type { FileInMessage } from "../messages/utils";
|
||||
import type { LocalSettings } from "../settings";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
useLocalSettings,
|
||||
} from "../settings";
|
||||
import { useCitationDisplaySettings } from "../system-settings/hooks";
|
||||
import type { FileInMessage } from "../messages/utils";
|
||||
import type { LocalSettings } from "../settings";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
useLocalSettings,
|
||||
} from "../settings";
|
||||
import { useCitationDisplaySettings } from "../system-settings/hooks";
|
||||
import { useUpdateSubtask } from "../tasks/context";
|
||||
import type { UploadedFileInfo } from "../uploads";
|
||||
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
||||
@@ -571,11 +571,11 @@ export function useThreadStream({
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
}: ThreadStreamOptions) {
|
||||
const { t } = useI18n();
|
||||
const [localSettings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
const { t } = useI18n();
|
||||
const [localSettings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
// Track the thread ID that is currently streaming to handle thread changes during streaming
|
||||
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
|
||||
// Ref to track current thread ID across async callbacks without causing re-renders,
|
||||
@@ -1507,21 +1507,21 @@ export function useThreadStream({
|
||||
...context,
|
||||
...extraContext,
|
||||
};
|
||||
const selectedLlmWikiIds = Array.isArray(
|
||||
mergedContext.llmwiki_knowledge_base_ids,
|
||||
)
|
||||
? mergedContext.llmwiki_knowledge_base_ids
|
||||
: [];
|
||||
const selectedAssistantKnowledgeIds = Array.isArray(
|
||||
mergedContext.assistant_knowledge_base_ids,
|
||||
)
|
||||
? mergedContext.assistant_knowledge_base_ids
|
||||
: [];
|
||||
const ragModeEnabled =
|
||||
(referenceModeEntryEnabled && localSettings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
selectedLlmWikiIds.length > 0 ||
|
||||
selectedAssistantKnowledgeIds.length > 0;
|
||||
const selectedLlmWikiIds = Array.isArray(
|
||||
mergedContext.llmwiki_knowledge_base_ids,
|
||||
)
|
||||
? mergedContext.llmwiki_knowledge_base_ids
|
||||
: [];
|
||||
const selectedAssistantKnowledgeIds = Array.isArray(
|
||||
mergedContext.assistant_knowledge_base_ids,
|
||||
)
|
||||
? mergedContext.assistant_knowledge_base_ids
|
||||
: [];
|
||||
const ragModeEnabled =
|
||||
(referenceModeEntryEnabled && localSettings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
selectedLlmWikiIds.length > 0 ||
|
||||
selectedAssistantKnowledgeIds.length > 0;
|
||||
if (ragModeEnabled) {
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
@@ -1579,6 +1579,7 @@ export function useThreadStream({
|
||||
context: {
|
||||
...mergedContext,
|
||||
thinking_enabled: mergedContext.mode !== "flash",
|
||||
thinking_force_disabled: mergedContext.mode === "flash",
|
||||
is_plan_mode:
|
||||
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
|
||||
subagent_enabled: mergedContext.mode === "ultra",
|
||||
@@ -1639,18 +1640,18 @@ export function useThreadStream({
|
||||
sendInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[
|
||||
thread,
|
||||
_handleOnStart,
|
||||
t.uploads.uploadingFiles,
|
||||
context,
|
||||
referenceModeEntryEnabled,
|
||||
localSettings.display.ragModeEnabled,
|
||||
queryClient,
|
||||
runOptions,
|
||||
confirmOrRejoin,
|
||||
],
|
||||
);
|
||||
[
|
||||
thread,
|
||||
_handleOnStart,
|
||||
t.uploads.uploadingFiles,
|
||||
context,
|
||||
referenceModeEntryEnabled,
|
||||
localSettings.display.ragModeEnabled,
|
||||
queryClient,
|
||||
runOptions,
|
||||
confirmOrRejoin,
|
||||
],
|
||||
);
|
||||
|
||||
const handleThreadStop = useCallback(async () => {
|
||||
const tid = threadIdRef.current;
|
||||
@@ -1699,7 +1700,7 @@ export function useThreadStream({
|
||||
? undefined
|
||||
: thread.error;
|
||||
|
||||
const mergedThread = {
|
||||
const mergedThread = {
|
||||
...thread,
|
||||
error: visibleThreadError,
|
||||
values: mergedValues,
|
||||
@@ -1709,7 +1710,7 @@ export function useThreadStream({
|
||||
: mergedMessages,
|
||||
isLoading: thread.isLoading || streamHoldOpen,
|
||||
stop: handleThreadStop,
|
||||
} as typeof thread;
|
||||
} as typeof thread;
|
||||
|
||||
// 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。
|
||||
// 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
myProjectConversationTypeFromSearchParams,
|
||||
myProjectConversationTypeOfThread,
|
||||
myProjectThreadMetadata,
|
||||
withMyProjectConversationMarker,
|
||||
} from "./project-metadata";
|
||||
|
||||
test("project conversation type is read from persisted metadata", () => {
|
||||
assert.equal(
|
||||
myProjectConversationTypeOfThread({
|
||||
metadata: myProjectThreadMetadata("strategy"),
|
||||
}),
|
||||
"strategy",
|
||||
);
|
||||
assert.equal(
|
||||
myProjectConversationTypeOfThread({
|
||||
metadata: { project_source: "elsewhere", project_type: "qa" },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("metadata wins over the compatibility context fallback", () => {
|
||||
assert.equal(
|
||||
myProjectConversationTypeOfThread({
|
||||
metadata: myProjectThreadMetadata("solution"),
|
||||
context: myProjectThreadMetadata("qa"),
|
||||
}),
|
||||
"solution",
|
||||
);
|
||||
});
|
||||
|
||||
test("new-chat route marker round-trips all supported project types", () => {
|
||||
for (const type of ["qa", "strategy", "solution"] as const) {
|
||||
const path = withMyProjectConversationMarker(
|
||||
"/page/strategy/chats/new?keep=1",
|
||||
type,
|
||||
);
|
||||
const params = new URLSearchParams(path.split("?", 2)[1]);
|
||||
assert.equal(params.get("keep"), "1");
|
||||
assert.equal(myProjectConversationTypeFromSearchParams(params), type);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Stable marker for conversations entered from 「我的项目」.
|
||||
*
|
||||
* Keep this separate from ``project_id``: that key belongs to DeerFlow's
|
||||
* folder-style conversation projects, while this marker describes the product
|
||||
* mode that created (or explicitly continued) a conversation.
|
||||
*/
|
||||
export const MY_PROJECT_THREAD_SOURCE = "my-projects" as const;
|
||||
|
||||
export const MY_PROJECT_THREAD_SOURCE_KEY = "project_source" as const;
|
||||
export const MY_PROJECT_THREAD_TYPE_KEY = "project_type" as const;
|
||||
|
||||
export const MY_PROJECT_QUERY_SOURCE_KEY = "projectSource" as const;
|
||||
export const MY_PROJECT_QUERY_TYPE_KEY = "projectType" as const;
|
||||
|
||||
export type MyProjectConversationType = "qa" | "strategy" | "solution";
|
||||
|
||||
export const MY_PROJECT_CONVERSATION_LABELS: Record<
|
||||
MyProjectConversationType,
|
||||
string
|
||||
> = {
|
||||
qa: "普通问答",
|
||||
strategy: "策略优选",
|
||||
solution: "方案生成",
|
||||
};
|
||||
|
||||
const MY_PROJECT_CONVERSATION_TYPES = new Set<MyProjectConversationType>([
|
||||
"qa",
|
||||
"strategy",
|
||||
"solution",
|
||||
]);
|
||||
|
||||
function typeFromRecord(
|
||||
value: Record<string, unknown> | null | undefined,
|
||||
): MyProjectConversationType | null {
|
||||
if (value?.[MY_PROJECT_THREAD_SOURCE_KEY] !== MY_PROJECT_THREAD_SOURCE) {
|
||||
return null;
|
||||
}
|
||||
const type = value[MY_PROJECT_THREAD_TYPE_KEY];
|
||||
return typeof type === "string" &&
|
||||
MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType)
|
||||
? (type as MyProjectConversationType)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function myProjectThreadMetadata(
|
||||
type: MyProjectConversationType,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
[MY_PROJECT_THREAD_SOURCE_KEY]: MY_PROJECT_THREAD_SOURCE,
|
||||
[MY_PROJECT_THREAD_TYPE_KEY]: type,
|
||||
};
|
||||
}
|
||||
|
||||
/** Read persisted metadata first; context is only a compatibility fallback. */
|
||||
export function myProjectConversationTypeOfThread(thread: {
|
||||
metadata?: Record<string, unknown> | null;
|
||||
context?: Record<string, unknown> | null;
|
||||
}): MyProjectConversationType | null {
|
||||
return typeFromRecord(thread.metadata) ?? typeFromRecord(thread.context);
|
||||
}
|
||||
|
||||
export function myProjectConversationTypeFromSearchParams(params: {
|
||||
get(name: string): string | null;
|
||||
}): MyProjectConversationType | null {
|
||||
if (params.get(MY_PROJECT_QUERY_SOURCE_KEY) !== MY_PROJECT_THREAD_SOURCE) {
|
||||
return null;
|
||||
}
|
||||
const type = params.get(MY_PROJECT_QUERY_TYPE_KEY);
|
||||
return typeof type === "string" &&
|
||||
MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType)
|
||||
? (type as MyProjectConversationType)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Add an explicit, one-shot marker to a new-conversation route. */
|
||||
export function withMyProjectConversationMarker(
|
||||
path: string,
|
||||
type: MyProjectConversationType,
|
||||
): string {
|
||||
const hashIndex = path.indexOf("#");
|
||||
const hash = hashIndex >= 0 ? path.slice(hashIndex) : "";
|
||||
const pathWithoutHash = hashIndex >= 0 ? path.slice(0, hashIndex) : path;
|
||||
const queryIndex = pathWithoutHash.indexOf("?");
|
||||
const pathname =
|
||||
queryIndex >= 0 ? pathWithoutHash.slice(0, queryIndex) : pathWithoutHash;
|
||||
const params = new URLSearchParams(
|
||||
queryIndex >= 0 ? pathWithoutHash.slice(queryIndex + 1) : "",
|
||||
);
|
||||
params.set(MY_PROJECT_QUERY_SOURCE_KEY, MY_PROJECT_THREAD_SOURCE);
|
||||
params.set(MY_PROJECT_QUERY_TYPE_KEY, type);
|
||||
const search = params.toString();
|
||||
return `${pathname}${search ? `?${search}` : ""}${hash}`;
|
||||
}
|
||||
@@ -2,18 +2,20 @@ import type { Message, Thread } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { Todo } from "../todos";
|
||||
|
||||
export interface AgentThreadState extends Record<string, unknown> {
|
||||
title: string;
|
||||
title_provisional?: boolean;
|
||||
messages: Message[];
|
||||
artifacts: string[];
|
||||
todos?: Todo[];
|
||||
}
|
||||
export interface AgentThreadState extends Record<string, unknown> {
|
||||
title: string;
|
||||
title_provisional?: boolean;
|
||||
messages: Message[];
|
||||
artifacts: string[];
|
||||
todos?: Todo[];
|
||||
}
|
||||
|
||||
export interface AgentThreadContext extends Record<string, unknown> {
|
||||
thread_id: string;
|
||||
model_name: string | undefined;
|
||||
thinking_enabled: boolean;
|
||||
/** Flash mode: force vLLM/Qwen ``enable_thinking=false`` even without a per-model disable shape. */
|
||||
thinking_force_disabled?: boolean;
|
||||
is_plan_mode: boolean;
|
||||
subagent_enabled: boolean;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
@@ -23,15 +25,15 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
||||
rag_mode_enabled?: boolean;
|
||||
/** Per-request switch for context compression (上下文压缩). Default off. */
|
||||
summarization_enabled?: boolean;
|
||||
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
|
||||
knowledge_rag_enabled?: boolean;
|
||||
/** DeerFlow LLMWiki mapping ids selected for this conversation. */
|
||||
llmwiki_knowledge_base_ids?: string[];
|
||||
/** Assistant knowledge-base ids selected for this conversation. */
|
||||
assistant_knowledge_base_ids?: string[];
|
||||
/** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */
|
||||
execution_profile?: "single_round_rag" | "multi_round_rag";
|
||||
agent_id?: string;
|
||||
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
|
||||
knowledge_rag_enabled?: boolean;
|
||||
/** DeerFlow LLMWiki mapping ids selected for this conversation. */
|
||||
llmwiki_knowledge_base_ids?: string[];
|
||||
/** Assistant knowledge-base ids selected for this conversation. */
|
||||
assistant_knowledge_base_ids?: string[];
|
||||
/** Frontend-only execution paths that never enter Lead Agent (RAG / 极速直连). */
|
||||
execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
|
||||
agent_id?: string;
|
||||
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||
agent_name?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { readRuntimeFlag } from "@/env";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
|
||||
import { textOfMessage } from "./utils";
|
||||
|
||||
const STORAGE_PREFIX = "workspace.direct-chat.";
|
||||
const MAX_HISTORY_MESSAGES = 20;
|
||||
|
||||
export function isDirectChatProfile(context: unknown): boolean {
|
||||
return (
|
||||
typeof context === "object" &&
|
||||
context !== null &&
|
||||
(context as { execution_profile?: unknown }).execution_profile === "direct"
|
||||
);
|
||||
}
|
||||
|
||||
/** 是否在模式菜单展示「极速」。仅 runtime-config.js 显式为 "true" 时开启,默认关闭。 */
|
||||
export function isTurboModeVisible(): boolean {
|
||||
return readRuntimeFlag("VITE_TURBO_MODE_VISIBLE");
|
||||
}
|
||||
|
||||
function storageKey(threadId: string) {
|
||||
return `${STORAGE_PREFIX}${threadId}`;
|
||||
}
|
||||
|
||||
function readStoredMessages(threadId: string): Message[] {
|
||||
if (typeof window === "undefined" || !threadId) return [];
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(storageKey(threadId));
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? (parsed as Message[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredMessages(threadId: string, messages: Message[]) {
|
||||
if (typeof window === "undefined" || !threadId) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages));
|
||||
} catch {
|
||||
// Quota errors must never interrupt an already-running answer.
|
||||
}
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException
|
||||
? error.name === "AbortError"
|
||||
: error instanceof Error && error.name === "AbortError";
|
||||
}
|
||||
|
||||
function historyFromMessages(messages: Message[]): Array<{
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}> {
|
||||
const history: Array<{ role: "user" | "assistant"; content: string }> = [];
|
||||
for (const message of messages) {
|
||||
if (message.type !== "human" && message.type !== "ai") continue;
|
||||
const content = textOfMessage(message)?.trim();
|
||||
if (!content) continue;
|
||||
history.push({
|
||||
role: message.type === "human" ? "user" : "assistant",
|
||||
content,
|
||||
});
|
||||
}
|
||||
return history.slice(-MAX_HISTORY_MESSAGES);
|
||||
}
|
||||
|
||||
function parseSseBlocks(buffer: string): {
|
||||
events: Array<{ event?: string; data: string }>;
|
||||
rest: string;
|
||||
} {
|
||||
const parts = buffer.split("\n\n");
|
||||
const rest = parts.pop() ?? "";
|
||||
const events: Array<{ event?: string; data: string }> = [];
|
||||
for (const part of parts) {
|
||||
let event: string | undefined;
|
||||
const dataLines: string[] = [];
|
||||
for (const line of part.split("\n")) {
|
||||
if (line.startsWith(":")) continue;
|
||||
if (line.startsWith("event:")) event = line.slice(6).trim();
|
||||
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
|
||||
}
|
||||
if (dataLines.length > 0) {
|
||||
events.push({ event, data: dataLines.join("\n") });
|
||||
}
|
||||
}
|
||||
return { events, rest };
|
||||
}
|
||||
|
||||
async function readErrorDetail(response: Response): Promise<string> {
|
||||
try {
|
||||
const payload: unknown = await response.json();
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"detail" in payload &&
|
||||
typeof (payload as { detail: unknown }).detail === "string"
|
||||
) {
|
||||
return (payload as { detail: string }).detail;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to status text.
|
||||
}
|
||||
return `极速问答失败(${response.status})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frontend-only adapter that talks to Gateway ``POST /api/direct-chat/stream``.
|
||||
* It never submits a LangGraph run or touches the lead-agent stack.
|
||||
*/
|
||||
export function useDirectChatMessages(threadId: string | undefined) {
|
||||
const [messages, setMessages] = useState<Message[]>(() =>
|
||||
readStoredMessages(threadId ?? ""),
|
||||
);
|
||||
const messagesRef = useRef(messages);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readStoredMessages(threadId ?? "");
|
||||
messagesRef.current = stored;
|
||||
setMessages(stored);
|
||||
setError(null);
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesRef.current = messages;
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => () => controllerRef.current?.abort(), []);
|
||||
|
||||
const updateMessages = useCallback(
|
||||
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
|
||||
setMessages((current) => {
|
||||
const next = updater(current);
|
||||
messagesRef.current = next;
|
||||
writeStoredMessages(targetThreadId, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => controllerRef.current?.abort(), []);
|
||||
|
||||
const submit = useCallback(
|
||||
async ({
|
||||
targetThreadId,
|
||||
query,
|
||||
modelName,
|
||||
}: {
|
||||
targetThreadId: string;
|
||||
query: string;
|
||||
modelName?: string;
|
||||
}) => {
|
||||
if (isLoading) return;
|
||||
const question = query.trim();
|
||||
if (!question) return;
|
||||
|
||||
const humanId = createClientId("direct-human");
|
||||
const assistantId = createClientId("direct-ai");
|
||||
const clientTs = new Date().toISOString();
|
||||
const history = historyFromMessages(messagesRef.current);
|
||||
const userMessage: Message = {
|
||||
id: humanId,
|
||||
type: "human",
|
||||
content: [{ type: "text", text: question }],
|
||||
additional_kwargs: { client_ts: clientTs, direct_chat: true },
|
||||
};
|
||||
const initialAssistant: Message = {
|
||||
id: assistantId,
|
||||
type: "ai",
|
||||
content: "",
|
||||
additional_kwargs: { direct_chat: true },
|
||||
};
|
||||
updateMessages(targetThreadId, (current) => [
|
||||
...current,
|
||||
userMessage,
|
||||
initialAssistant,
|
||||
]);
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
let answer = "";
|
||||
|
||||
const refreshAssistant = (content: string) => {
|
||||
updateMessages(targetThreadId, (current) =>
|
||||
current.map((item) =>
|
||||
item.id === assistantId
|
||||
? ({
|
||||
...item,
|
||||
content,
|
||||
additional_kwargs: {
|
||||
...item.additional_kwargs,
|
||||
direct_chat: true,
|
||||
},
|
||||
} as Message)
|
||||
: item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/direct-chat/stream`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model_name: modelName,
|
||||
messages: [...history, { role: "user", content: question }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorDetail(response));
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("极速问答未返回数据流。");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const parsed = parseSseBlocks(buffer);
|
||||
buffer = parsed.rest;
|
||||
for (const block of parsed.events) {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = JSON.parse(block.data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (block.event === "error") {
|
||||
const detail =
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"detail" in payload &&
|
||||
typeof (payload as { detail: unknown }).detail === "string"
|
||||
? (payload as { detail: string }).detail
|
||||
: "极速问答失败。";
|
||||
throw new Error(detail);
|
||||
}
|
||||
if (
|
||||
block.event === "delta" &&
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"text" in payload &&
|
||||
typeof (payload as { text: unknown }).text === "string"
|
||||
) {
|
||||
answer += (payload as { text: string }).text;
|
||||
refreshAssistant(answer);
|
||||
}
|
||||
if (
|
||||
block.event === "result" &&
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"answer" in payload &&
|
||||
typeof (payload as { answer: unknown }).answer === "string"
|
||||
) {
|
||||
answer = (payload as { answer: string }).answer;
|
||||
refreshAssistant(answer);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!answer) {
|
||||
throw new Error("模型未返回任何文本。");
|
||||
}
|
||||
} catch (caught) {
|
||||
if (!isAbortError(caught)) {
|
||||
const message =
|
||||
caught instanceof Error ? caught.message : "极速问答失败。";
|
||||
setError(message);
|
||||
updateMessages(targetThreadId, (current) =>
|
||||
current.map((item) =>
|
||||
item.id === assistantId
|
||||
? ({
|
||||
...item,
|
||||
content: answer || message,
|
||||
additional_kwargs: {
|
||||
...item.additional_kwargs,
|
||||
direct_chat: true,
|
||||
},
|
||||
} as Message)
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (controllerRef.current === controller) controllerRef.current = null;
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[isLoading, updateMessages],
|
||||
);
|
||||
|
||||
return { messages, isLoading, error, submit, stop };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { sanitizeMermaidSource } from "./mermaid";
|
||||
import { findMermaidFenceMatches, getMermaidImageFileName, sanitizeMermaidSource } from "./mermaid";
|
||||
|
||||
test("inserts a missing arrow before a bare edge label", () => {
|
||||
const input = "E --> G|价值输出 V_s| H[PPO 损失]";
|
||||
@@ -69,3 +69,17 @@ test("repairs the DAC-PPO architecture diagram class block", () => {
|
||||
assert.doesNotMatch(output, /class,/);
|
||||
assert.doesNotMatch(output, /input,module/);
|
||||
});
|
||||
|
||||
test("turns mermaid source filenames into png downloads", () => {
|
||||
assert.equal(getMermaidImageFileName("outputs/图2.mmd"), "图2.png");
|
||||
assert.equal(getMermaidImageFileName("chart.mermaid", "png"), "chart.png");
|
||||
assert.equal(getMermaidImageFileName("flowchart.png"), "flowchart.png");
|
||||
});
|
||||
|
||||
test("finds mermaid fences for Word image replacement", () => {
|
||||
const markdown = "前文\n```mermaid\nflowchart TB\nA-->B\n```\n后文\n```mmd\ngraph LR\nC-->D\n```";
|
||||
const fences = findMermaidFenceMatches(markdown);
|
||||
assert.equal(fences.length, 2);
|
||||
assert.equal(fences[0].source, "flowchart TB\nA-->B");
|
||||
assert.equal(fences[1].source, "graph LR\nC-->D");
|
||||
});
|
||||
@@ -96,12 +96,209 @@ function sanitizeMermaidClassStatements(source: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
const MERMAID_FILE_EXT = /\.(mmd|mermaid)$/i;
|
||||
|
||||
export function isMermaidFile(filepath: string): boolean {
|
||||
return /\.mmd$/i.test(filepath);
|
||||
return MERMAID_FILE_EXT.test(filepath);
|
||||
}
|
||||
|
||||
export function getMermaidImageFileName(filepath: string, extension = "svg"): string {
|
||||
return getFileName(filepath).replace(/\.mmd$/i, `.${extension}`);
|
||||
export function getMermaidImageFileName(filepath: string, extension = "png"): string {
|
||||
const name = getFileName(filepath) || "mermaid-diagram";
|
||||
if (MERMAID_FILE_EXT.test(name)) {
|
||||
return name.replace(MERMAID_FILE_EXT, `.${extension}`);
|
||||
}
|
||||
if (/\.(svg|png|jpe?g|webp)$/i.test(name)) {
|
||||
return name.replace(/\.(svg|png|jpe?g|webp)$/i, `.${extension}`);
|
||||
}
|
||||
return `${name}.${extension}`;
|
||||
}
|
||||
|
||||
function triggerBlobDownload(blob: Blob, filename: string): void {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = objectUrl;
|
||||
a.download = filename;
|
||||
a.rel = "noopener";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
||||
}
|
||||
|
||||
function readPositiveNumber(value: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function getSvgExportSize(svg: SVGSVGElement): { width: number; height: number } {
|
||||
const viewBox = svg.viewBox?.baseVal;
|
||||
if (viewBox && viewBox.width > 0 && viewBox.height > 0) {
|
||||
return { width: viewBox.width, height: viewBox.height };
|
||||
}
|
||||
const attrWidth = readPositiveNumber(svg.getAttribute("width"));
|
||||
const attrHeight = readPositiveNumber(svg.getAttribute("height"));
|
||||
if (attrWidth && attrHeight) {
|
||||
return { width: attrWidth, height: attrHeight };
|
||||
}
|
||||
try {
|
||||
const box = svg.getBBox();
|
||||
if (box.width > 0 && box.height > 0) {
|
||||
return { width: box.width, height: box.height };
|
||||
}
|
||||
} catch {
|
||||
// Detached SVGs may throw on getBBox().
|
||||
}
|
||||
return {
|
||||
width: Math.max(1, svg.clientWidth),
|
||||
height: Math.max(1, svg.clientHeight),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeSvg(svg: SVGSVGElement): string {
|
||||
const clone = svg.cloneNode(true) as SVGSVGElement;
|
||||
if (!clone.getAttribute("xmlns")) {
|
||||
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||
}
|
||||
if (!clone.getAttribute("xmlns:xlink")) {
|
||||
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
||||
}
|
||||
return new XMLSerializer().serializeToString(clone);
|
||||
}
|
||||
|
||||
export async function renderSvgElementToPngBlob(svg: SVGSVGElement): Promise<Blob> {
|
||||
const { width, height } = getSvgExportSize(svg);
|
||||
const scale = 2;
|
||||
const markup = serializeSvg(svg);
|
||||
const blob = new Blob([markup], { type: "image/svg+xml;charset=utf-8" });
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
try {
|
||||
const image = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
image.onload = () => resolve();
|
||||
image.onerror = () => reject(new Error("流程图转图片失败"));
|
||||
image.src = objectUrl;
|
||||
});
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, Math.ceil(width * scale));
|
||||
canvas.height = Math.max(1, Math.ceil(height * scale));
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
throw new Error("无法创建画布");
|
||||
}
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const png = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(result) => (result ? resolve(result) : reject(new Error("生成 PNG 失败"))),
|
||||
"image/png",
|
||||
);
|
||||
});
|
||||
return png;
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadSvgElementAsPng(
|
||||
svg: SVGSVGElement,
|
||||
filename: string,
|
||||
): Promise<void> {
|
||||
const png = await renderSvgElementToPngBlob(svg);
|
||||
triggerBlobDownload(png, getMermaidImageFileName(filename, "png"));
|
||||
}
|
||||
|
||||
function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result || "");
|
||||
const comma = result.indexOf(",");
|
||||
resolve(comma >= 0 ? result.slice(comma + 1) : result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error("图片编码失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
const MERMAID_FENCE_RE = /```(?:mermaid|mmd)[^\n]*\r?\n([\s\S]*?)```/gi;
|
||||
|
||||
export function findMermaidFenceMatches(markdown: string): Array<{
|
||||
index: number;
|
||||
length: number;
|
||||
source: string;
|
||||
}> {
|
||||
const matches: Array<{ index: number; length: number; source: string }> = [];
|
||||
const matcher = new RegExp(MERMAID_FENCE_RE.source, MERMAID_FENCE_RE.flags);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = matcher.exec(markdown)) !== null) {
|
||||
matches.push({
|
||||
index: match.index,
|
||||
length: match[0].length,
|
||||
source: (match[1] ?? "").trim(),
|
||||
});
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
export async function renderMermaidToPngBlob(source: string): Promise<Blob> {
|
||||
const markup = await renderMermaidToSvg(source);
|
||||
const host = document.createElement("div");
|
||||
host.style.position = "fixed";
|
||||
host.style.left = "-10000px";
|
||||
host.style.top = "-10000px";
|
||||
host.style.pointerEvents = "none";
|
||||
host.style.opacity = "0";
|
||||
host.innerHTML = markup;
|
||||
document.body.appendChild(host);
|
||||
try {
|
||||
const svg = host.querySelector("svg");
|
||||
if (!(svg instanceof SVGSVGElement)) {
|
||||
throw new Error("无法生成流程图图片");
|
||||
}
|
||||
return await renderSvgElementToPngBlob(svg);
|
||||
} finally {
|
||||
host.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export async function replaceMermaidFencesForWordExport(markdown: string): Promise<{
|
||||
markdown: string;
|
||||
embeddedImages: Record<string, string>;
|
||||
}> {
|
||||
const fences = findMermaidFenceMatches(markdown);
|
||||
if (fences.length === 0) {
|
||||
return { markdown, embeddedImages: {} };
|
||||
}
|
||||
|
||||
const embeddedImages: Record<string, string> = {};
|
||||
const replacements: Array<{ index: number; length: number; text: string }> = [];
|
||||
|
||||
for (const [offset, fence] of fences.entries()) {
|
||||
if (!fence.source) continue;
|
||||
try {
|
||||
const png = await renderMermaidToPngBlob(fence.source);
|
||||
const id = `mermaid-${offset + 1}`;
|
||||
embeddedImages[id] = await blobToBase64(png);
|
||||
replacements.push({
|
||||
index: fence.index,
|
||||
length: fence.length,
|
||||
text: `\n\n`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to render mermaid for Word export:", error);
|
||||
}
|
||||
}
|
||||
|
||||
let next = markdown;
|
||||
for (const piece of replacements.sort((left, right) => right.index - left.index)) {
|
||||
next = `${next.slice(0, piece.index)}${piece.text}${next.slice(piece.index + piece.length)}`;
|
||||
}
|
||||
return { markdown: next, embeddedImages };
|
||||
}
|
||||
|
||||
async function getMermaidModule() {
|
||||
@@ -152,15 +349,6 @@ export async function openMermaidSvgInNewTab(source: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function downloadMermaidSvgFile(source: string, filepath: string): Promise<void> {
|
||||
const svg = await renderMermaidToSvg(source);
|
||||
const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = objectUrl;
|
||||
a.download = getMermaidImageFileName(filepath);
|
||||
a.rel = "noopener";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
||||
const png = await renderMermaidToPngBlob(source);
|
||||
triggerBlobDownload(png, getMermaidImageFileName(filepath, "png"));
|
||||
}
|
||||
@@ -116,6 +116,14 @@ export interface EightBfTaskProcessReportReceipt {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部任务系统要求 8BF 会商页提交的过程产物携带 eightTask=1,
|
||||
* 用于与岗位会商等其它入口的产物区分来源。
|
||||
*/
|
||||
function processReportBody(input: EightBfTaskProcessReportInput): string {
|
||||
return JSON.stringify({ ...input, eightTask: 1 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
|
||||
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
|
||||
@@ -180,7 +188,7 @@ export async function createEightBfTaskProcessReport(
|
||||
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
body: processReportBody(input),
|
||||
});
|
||||
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
|
||||
const id = findProcessReportId(await response.json().catch(() => null));
|
||||
@@ -198,7 +206,7 @@ export async function updateEightBfTaskProcessReport(
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
body: processReportBody(input),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
syncPositionOptionsFromRoles,
|
||||
} from "@/position-roundtable/lib/positions";
|
||||
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
|
||||
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent";
|
||||
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||
import "@/position-roundtable/styles/intent-understanding.css";
|
||||
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
|
||||
@@ -96,6 +97,8 @@ export function EightBfStep1Panel({
|
||||
}: EightBfStep1PanelProps) {
|
||||
const [rightPanelOpen, setRightPanelOpen] = useState(true);
|
||||
const [rightTab, setRightTab] = useState<"agents" | "results">("agents");
|
||||
// 第一步内置任务理解智能体:其「常用问题」显示在输入框左上角,点击直接发送。
|
||||
const { agent: intentAgent } = useAgent(EIGHTBF_INTENT_AGENT_ID);
|
||||
|
||||
const openPanel = (tab: "agents" | "results") => {
|
||||
if (rightPanelOpen && rightTab === tab) {
|
||||
@@ -167,6 +170,8 @@ export function EightBfStep1Panel({
|
||||
modelOptions={modelOptions}
|
||||
selectedModel={selectedModel}
|
||||
onSelectedModelChange={onSelectedModelChange}
|
||||
commonQuestions={intentAgent?.common_questions ?? null}
|
||||
sendCommonQuestionOnClick
|
||||
/>
|
||||
)}
|
||||
intentReady={intentReady}
|
||||
@@ -331,8 +336,26 @@ function EightBfAgentWorkspace({
|
||||
summaryReport: step3Summary,
|
||||
});
|
||||
|
||||
// 第一步绑定的内置任务理解智能体:不在业务链席位里,固定置顶展示,
|
||||
// 供管理员在本面板查看 / 配置其提示词与常用问题。
|
||||
const intentAgent = useMemo(
|
||||
() => agents.find((agent) => agent.id === EIGHTBF_INTENT_AGENT_ID) ?? null,
|
||||
[agents],
|
||||
);
|
||||
const intentAgentMatchesKeyword = useMemo(() => {
|
||||
const keyword = agentKeyword.trim().toLowerCase();
|
||||
if (!keyword) return true;
|
||||
const name = (intentAgent?.name || "8BF 任务理解").toLowerCase();
|
||||
const description = (intentAgent?.description || "").toLowerCase();
|
||||
return name.includes(keyword) || description.includes(keyword);
|
||||
}, [agentKeyword, intentAgent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAgentId && !seats.some((seat) => seat.agent_id === selectedAgentId)) {
|
||||
if (
|
||||
selectedAgentId &&
|
||||
selectedAgentId !== EIGHTBF_INTENT_AGENT_ID &&
|
||||
!seats.some((seat) => seat.agent_id === selectedAgentId)
|
||||
) {
|
||||
setSelectedAgentId(null);
|
||||
}
|
||||
}, [seats, selectedAgentId]);
|
||||
@@ -412,7 +435,12 @@ function EightBfAgentWorkspace({
|
||||
) : (
|
||||
<AgentDetailPanel
|
||||
agent={selectedAgent}
|
||||
agentName={selectedAgent?.name ?? selectedSeat?.name ?? ""}
|
||||
agentName={
|
||||
selectedAgent?.name
|
||||
?? (selectedAgentId === EIGHTBF_INTENT_AGENT_ID
|
||||
? "8BF 任务理解"
|
||||
: selectedSeat?.name ?? "")
|
||||
}
|
||||
onAskQuestion={() => toast.info("请在中间对话区向任务理解智能体发起问题")}
|
||||
showRecentChats={false}
|
||||
avatarSrc={agentConfigLogo}
|
||||
@@ -468,7 +496,7 @@ function EightBfAgentWorkspace({
|
||||
</header>
|
||||
{isLoading && seats.length > 0 ? (
|
||||
<div className="eightbf-roundtable__agent-selector-empty">正在加载智能体…</div>
|
||||
) : filteredSeatAgents.length === 0 ? (
|
||||
) : filteredSeatAgents.length === 0 && !intentAgentMatchesKeyword ? (
|
||||
<div className="eightbf-roundtable__agent-selector-empty">
|
||||
<p>{seatAgents.length ? "没有符合条件的智能体" : "当前岗位尚未配置智能体"}</p>
|
||||
{!seatAgents.length && canConfigureChain && (
|
||||
@@ -477,6 +505,13 @@ function EightBfAgentWorkspace({
|
||||
</div>
|
||||
) : (
|
||||
<div className="eightbf-roundtable__agent-selector-list">
|
||||
{intentAgentMatchesKeyword && (
|
||||
<EightBfAgentCard
|
||||
name={intentAgent?.name || "8BF 任务理解"}
|
||||
description={intentAgent?.description || "第一步「意图识别」的内置任务理解智能体"}
|
||||
onSelect={() => setSelectedAgentId(EIGHTBF_INTENT_AGENT_ID)}
|
||||
/>
|
||||
)}
|
||||
{filteredSeatAgents.map(({ seat, agent }) => (
|
||||
<EightBfAgentRow
|
||||
key={seat.agent_id}
|
||||
@@ -503,6 +538,19 @@ function EightBfAgentRow({
|
||||
}) {
|
||||
const name = agent?.name || seat.name || "未命名智能体";
|
||||
const description = agent?.description || seat.description || "智能体助手";
|
||||
return <EightBfAgentCard name={name} description={description} onSelect={onSelect} />;
|
||||
}
|
||||
|
||||
/** 智能体列表卡片:席位智能体与置顶的内置任务理解智能体共用同一展示结构。 */
|
||||
function EightBfAgentCard({
|
||||
name,
|
||||
description,
|
||||
onSelect,
|
||||
}: {
|
||||
name: string;
|
||||
description: string;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const avatarText = firstAgentAvatarChar(name);
|
||||
return (
|
||||
<button type="button" className="eightbf-roundtable__agent-selector-card" onClick={onSelect}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useSearchParams } from "react-router-dom";
|
||||
import { Loader2, Save, X } from "lucide-react";
|
||||
|
||||
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||||
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent";
|
||||
import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard";
|
||||
import {
|
||||
EightBfStep1Panel,
|
||||
@@ -76,6 +77,9 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
||||
const [selectedChainId, setSelectedChainId] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// 第二步「人工干预」:开启后每走一步暂停等待用户输入;关闭自动推进。会话内状态,
|
||||
// 新建会话 / 刷新后回到默认关闭(与现状一致)。
|
||||
const [step2Intervention, setStep2Intervention] = useState(false);
|
||||
const configuredChain = useMemo(
|
||||
() => chains.find((chain) => chain.id === mapping?.chainId) ?? null,
|
||||
[chains, mapping?.chainId],
|
||||
@@ -84,14 +88,29 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
||||
() => buildEightBfTaskContext(task, taskDictionaries),
|
||||
[task, taskDictionaries],
|
||||
);
|
||||
// 席位「产物提交 position」映射:业务链里逐席位配置的 artifact_position(agent_id → 值)。
|
||||
// 提交产物时按产出该产物的席位智能体取值;未配置的席位不进 map(提交时回退默认 0)。
|
||||
const artifactPositionByAgent = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const seat of configuredChain?.seats ?? []) {
|
||||
if (typeof seat.artifact_position === "number" && Number.isFinite(seat.artifact_position)) {
|
||||
map.set(seat.agent_id, seat.artifact_position);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [configuredChain]);
|
||||
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
|
||||
() => taskId ? {
|
||||
taskId,
|
||||
position: 0,
|
||||
positionFor: (target) => {
|
||||
const agentId = target.agentId?.trim();
|
||||
return agentId ? artifactPositionByAgent.get(agentId) ?? null : null;
|
||||
},
|
||||
createProcessReport: createEightBfTaskProcessReport,
|
||||
updateProcessReport: updateEightBfTaskProcessReport,
|
||||
} : undefined,
|
||||
[taskId],
|
||||
[taskId, artifactPositionByAgent],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -260,6 +279,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
||||
// 无 taskId 进入时每次都开全新会话(不回显 anonymous 键下的最新草稿);带 taskId 保持回显现状。
|
||||
skipInitialDraftRestore={!taskId}
|
||||
taskContext={taskContext as Record<string, unknown> | null}
|
||||
// 第一步绑定 8BF 专属内置意图智能体(roundtable-intent 的同源复制,
|
||||
// 提示词独立可调优),不再与通用多智能体会商共用一个 SOUL。
|
||||
intentAgentId={EIGHTBF_INTENT_AGENT_ID}
|
||||
// 页头「人工干预」开关:控制第二步是否每步暂停等待用户输入。
|
||||
step2InterventionEnabled={step2Intervention}
|
||||
onStep2InterventionChange={setStep2Intervention}
|
||||
entryRailTaskId={taskId || null}
|
||||
entryRailTaskCardSlot={taskId ? (
|
||||
<EightBfTaskCard
|
||||
|
||||
@@ -41,6 +41,13 @@ export const env = {
|
||||
* flag so operations can switch the shell without rebuilding the frontend.
|
||||
*/
|
||||
FORCE_COLLAPSED_SIDEBAR: readRuntimeFlag("VITE_FORCE_COLLAPSED_SIDEBAR"),
|
||||
/**
|
||||
* Development-only default entry switch for the isolated Vite-native
|
||||
* official-style workspace. It never starts or embeds a Next.js app.
|
||||
*/
|
||||
USE_OFFICIAL_DEERFLOW_ENTRY: readRuntimeFlag(
|
||||
"VITE_USE_OFFICIAL_DEERFLOW_ENTRY",
|
||||
),
|
||||
/**
|
||||
* Compact-menu variant for this website. Pair with the admin 简介模式
|
||||
* type (A / B) so two deployments can show different left-nav items.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Neutral product terminology for system messages; never apply to knowledge content or API identifiers. */
|
||||
export function userFacingKnowledgeMessage(value: string): string {
|
||||
return value.replace(/weknora/gi, "知识服务");
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DatabaseZap,
|
||||
Replace,
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { SkillRow } from "@/components/workspace/settings/skill-settings-page";
|
||||
import { SkillKnowledgePanel } from "@/components/workspace/skills/SkillKnowledgePanel";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useAllSkillsAdmin } from "@/core/skills/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
@@ -37,7 +35,7 @@ export default function AdminSkillsPage() {
|
||||
const knowledgeBindings = useSkillKnowledgeBindings();
|
||||
const activeTab = useMemo(() => {
|
||||
const tab = searchParams.get("tab");
|
||||
return tab === "addresses" || tab === "knowledge" ? tab : "skills";
|
||||
return tab === "addresses" ? tab : "skills";
|
||||
}, [searchParams]);
|
||||
|
||||
const changeTab = (value: string) => {
|
||||
@@ -89,10 +87,6 @@ export default function AdminSkillsPage() {
|
||||
<Replace />
|
||||
技能地址管理
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="knowledge">
|
||||
<DatabaseZap />
|
||||
技能到知识库管理
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,12 +183,6 @@ export default function AdminSkillsPage() {
|
||||
>
|
||||
<SkillAddressesPanel />
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="knowledge"
|
||||
className="mt-0 min-h-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<SkillKnowledgePanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</WorkspaceBody>
|
||||
</WorkspaceContainer>
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useParams, useRouter } from "next/navigation";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
} from "@/components/workspace/agents/agent-guide";
|
||||
import { AgentWelcome } from "@/components/workspace/agent-welcome";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { listThreadArtifacts } from "@/core/artifacts/listing";
|
||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
@@ -46,6 +49,7 @@ import { useNotification } from "@/core/notification/hooks";
|
||||
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
|
||||
import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks";
|
||||
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
||||
import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from "@/core/threads/use-direct-chat";
|
||||
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
||||
@@ -98,6 +102,14 @@ const PAGE_REPORT_DESIGNER_AGENT_ID = "page-report-designer";
|
||||
const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000;
|
||||
const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000;
|
||||
|
||||
function isTaskReportsJsonPath(path: string | undefined): boolean {
|
||||
const normalized = (path ?? "").replace(/\\/g, "/");
|
||||
return (
|
||||
normalized === "task-reports.json" ||
|
||||
normalized.endsWith("/task-reports.json")
|
||||
);
|
||||
}
|
||||
|
||||
function readPendingKnowledgeSelection(agentId: string | undefined): string[] | null {
|
||||
if (typeof window === "undefined" || !agentId) return null;
|
||||
try {
|
||||
@@ -550,16 +562,35 @@ export default function AgentChatPage({
|
||||
},
|
||||
});
|
||||
|
||||
const directChat = useDirectChatMessages(threadId);
|
||||
const directChatSelected =
|
||||
isDirectChatProfile(settings.context) && isTurboModeVisible();
|
||||
|
||||
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
|
||||
// 流式 stub),成功且真实产物落地后自动撤出。
|
||||
const { displayThread } = useResearchWritingFeed(threadId, thread);
|
||||
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
|
||||
const displayThread = useMemo(() => {
|
||||
const base = researchDisplayThread;
|
||||
if (directChat.messages.length === 0 && !directChat.isLoading) {
|
||||
return base;
|
||||
}
|
||||
const messages = [...base.messages, ...directChat.messages];
|
||||
return {
|
||||
...base,
|
||||
values: { ...base.values, messages },
|
||||
messages,
|
||||
isLoading: base.isLoading || directChat.isLoading,
|
||||
} as typeof base;
|
||||
}, [directChat.isLoading, directChat.messages, researchDisplayThread]);
|
||||
|
||||
// ── agentfx「入库」协助卡 ──────────────────────────────────────────────────
|
||||
// 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且
|
||||
// 本轮流式结束后,向消息流尾部注入「是否入库」卡片。本地沙箱下 artifacts 可能
|
||||
// 记录宿主真实路径,按文件名匹配(虚拟/宿主路径都命中)。任务已有详情**不抑制**
|
||||
// 卡片(入库是整体替换,由卡片文案说明覆盖语义);已导入按 thread 记忆在
|
||||
// sessionStorage(刷新后不再重复询问)、「暂不入库」为本次会话内记忆。
|
||||
// 记录宿主真实路径,按文件名匹配(虚拟/宿主路径都命中)。转换脚本已函数合并
|
||||
// 写出该文件,即使模型中断、没跑 present_files,也再扫一遍 outputs 目录。
|
||||
// 任务已有详情**不抑制**卡片(入库是整体替换,由卡片文案说明覆盖语义);
|
||||
// 已导入按 thread 记忆在 sessionStorage(刷新后不再重复询问)、
|
||||
// 「暂不入库」为本次会话内记忆。
|
||||
const reportImportSessionKey = threadId
|
||||
? `agentfx.reportImport.done.${threadId}`
|
||||
: "";
|
||||
@@ -572,9 +603,19 @@ export default function AgentChatPage({
|
||||
}
|
||||
});
|
||||
const [reportImportDismissed, setReportImportDismissed] = useState(false);
|
||||
const reportJsonArtifactReady = (thread.values.artifacts ?? []).some((path) =>
|
||||
path.replace(/\\/g, "/").endsWith("/task-reports.json"),
|
||||
);
|
||||
const listedReportJsonQuery = useQuery({
|
||||
queryKey: ["agentfx-task-reports", threadId],
|
||||
queryFn: ({ signal }) => listThreadArtifacts(threadId, signal),
|
||||
enabled: Boolean(
|
||||
taskBind && !taskGate && threadId && threadId !== "new" && !thread.isLoading,
|
||||
),
|
||||
retry: false,
|
||||
});
|
||||
const reportJsonArtifactReady =
|
||||
(thread.values.artifacts ?? []).some(isTaskReportsJsonPath) ||
|
||||
(listedReportJsonQuery.data?.files ?? []).some((file) =>
|
||||
isTaskReportsJsonPath(file.path) || file.name === "task-reports.json",
|
||||
);
|
||||
const showReportImportCard =
|
||||
taskBind &&
|
||||
!taskGate &&
|
||||
@@ -721,6 +762,62 @@ export default function AgentChatPage({
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage, extraContext?: Record<string, unknown>) => {
|
||||
if (directChatSelected) {
|
||||
if (message.files && message.files.length > 0) {
|
||||
toast.error("极速问答暂不支持附件,请移除附件后重试。");
|
||||
return;
|
||||
}
|
||||
setHasStartedConversation(true);
|
||||
saveLastAgentId(agent_id);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
void (async () => {
|
||||
try {
|
||||
let targetThreadId = threadId;
|
||||
if (isNewThread) {
|
||||
const created = await getAPIClient().threads.create({
|
||||
metadata: {
|
||||
agent_id,
|
||||
...(Object.keys(conversationMetadata).length > 0
|
||||
? conversationMetadata
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
targetThreadId = created.thread_id;
|
||||
knowledgeSelectionThread.current = targetThreadId;
|
||||
activeThreadIdRef.current = targetThreadId;
|
||||
setThreadId(targetThreadId);
|
||||
setIsNewThread(false);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#${agentChatsBase}/${targetThreadId}`,
|
||||
);
|
||||
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
|
||||
if (title) {
|
||||
void getAPIClient().threads
|
||||
.updateState(targetThreadId, { values: { title } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
if (!targetThreadId) {
|
||||
toast.error("无法创建会话。");
|
||||
return;
|
||||
}
|
||||
await directChat.submit({
|
||||
targetThreadId,
|
||||
query: message.text,
|
||||
modelName: settings.context.model_name,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "极速问答失败。",
|
||||
);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
setHasStartedConversation(true);
|
||||
// Remember this agent so the new-chat landing screen pre-selects it next
|
||||
// time (covers both direct agent chats and the ChatPage handoff replay).
|
||||
@@ -745,7 +842,7 @@ export default function AgentChatPage({
|
||||
...pageReportThemeContext,
|
||||
});
|
||||
},
|
||||
[sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath, baseMode],
|
||||
[sendMessage, threadId, agent_id, settings.context.writing_mode, settings.context.model_name, writingArtifactPath, baseMode, directChat, directChatSelected, isNewThread, conversationMetadata, agentChatsBase, setThreadId, setIsNewThread],
|
||||
);
|
||||
|
||||
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
|
||||
@@ -830,8 +927,12 @@ export default function AgentChatPage({
|
||||
);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
if (directChat.isLoading) {
|
||||
directChat.stop();
|
||||
return;
|
||||
}
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
}, [directChat, thread]);
|
||||
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
// 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
|
||||
@@ -845,7 +946,7 @@ export default function AgentChatPage({
|
||||
|
||||
const status = thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
: thread.isLoading || directChat.isLoading
|
||||
? "streaming"
|
||||
: "ready";
|
||||
|
||||
@@ -1062,7 +1163,7 @@ export default function AgentChatPage({
|
||||
autoFocus
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
@@ -1082,7 +1183,7 @@ export default function AgentChatPage({
|
||||
pendingKnowledgeSelection.current === null &&
|
||||
pendingAssistantKnowledgeSelection.current === null
|
||||
}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
|
||||
label="知识空间"
|
||||
/>
|
||||
)
|
||||
@@ -1153,7 +1254,7 @@ export default function AgentChatPage({
|
||||
assistantId={agent_id}
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
@@ -1168,7 +1269,7 @@ export default function AgentChatPage({
|
||||
assistantValue={selectedAssistantKnowledgeBaseIds}
|
||||
onChange={changeKnowledgeSelection}
|
||||
onAssistantChange={changeAssistantKnowledgeSelection}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
|
||||
readOnly
|
||||
label="知识空间"
|
||||
/>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -37,7 +37,6 @@ import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
|
||||
import { CompactModeGuide } from "@/components/workspace/compact-mode-guide";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { CompactWelcome } from "@/components/workspace/compact-welcome";
|
||||
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
|
||||
import {
|
||||
buildFullChatNavigationPath,
|
||||
isChatsIframeWorkspacePath,
|
||||
@@ -65,12 +64,17 @@ import {
|
||||
useThreadSettings,
|
||||
} from "@/core/settings";
|
||||
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
|
||||
import {
|
||||
myProjectConversationTypeFromSearchParams,
|
||||
myProjectThreadMetadata,
|
||||
} from "@/core/threads/project-metadata";
|
||||
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
||||
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
||||
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
||||
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";
|
||||
import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from "@/core/threads/use-direct-chat";
|
||||
import { useMultiRoundRagMessages, mergeMessagesByTime, toLangGraphMessages } from "@/core/threads/use-multi-round-rag";
|
||||
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
@@ -129,7 +133,7 @@ export default function ChatPage() {
|
||||
const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } =
|
||||
useSidebar();
|
||||
const { t } = useI18n();
|
||||
// 有效界面模式:用户自选 > 管理员系统默认 > (下方再回退会话级 sidebarEnabled)
|
||||
// 有效界面模式:用户自选 > 管理员系统默认 > 内置默认「简洁模式」
|
||||
const [effectiveLayoutMode] = useLayoutMode();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
@@ -286,16 +290,28 @@ export default function ChatPage() {
|
||||
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const projectConversationType =
|
||||
myProjectConversationTypeFromSearchParams(searchParams);
|
||||
const newThreadMetadata = useMemo<Record<string, unknown> | undefined>(() => {
|
||||
const metadata: Record<string, unknown> = projectConversationType
|
||||
? myProjectThreadMetadata(projectConversationType)
|
||||
: {};
|
||||
if (knowledgeSelectionReady) {
|
||||
metadata.llmwiki_knowledge_base_ids = selectedKnowledgeBaseIds;
|
||||
metadata.assistant_knowledge_base_ids = selectedAssistantKnowledgeBaseIds;
|
||||
}
|
||||
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
||||
}, [
|
||||
knowledgeSelectionReady,
|
||||
projectConversationType,
|
||||
selectedAssistantKnowledgeBaseIds,
|
||||
selectedKnowledgeBaseIds,
|
||||
]);
|
||||
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
context: streamContext,
|
||||
threadMetadata: knowledgeSelectionReady
|
||||
? {
|
||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
||||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
||||
}
|
||||
: undefined,
|
||||
threadMetadata: newThreadMetadata,
|
||||
isMock,
|
||||
onStart: (createdThreadId) => {
|
||||
knowledgeSelectionThread.current = createdThreadId;
|
||||
@@ -349,40 +365,50 @@ export default function ChatPage() {
|
||||
});
|
||||
const singleRoundRag = useSingleRoundRagMessages(threadId);
|
||||
const multiRoundRag = useMultiRoundRagMessages(threadId);
|
||||
const directChat = useDirectChatMessages(threadId);
|
||||
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
|
||||
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
|
||||
const frontendRagSelected = isFrontendRagProfile(settings.context);
|
||||
const directChatSelected =
|
||||
isDirectChatProfile(settings.context) && isTurboModeVisible();
|
||||
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
|
||||
// 流式 stub),成功且真实产物落地后自动撤出。
|
||||
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
|
||||
const displayThread = useMemo(() => {
|
||||
const base = researchDisplayThread;
|
||||
const ragMessages = toLangGraphMessages(multiRoundRag.messages);
|
||||
if (
|
||||
singleRoundRag.messages.length === 0 &&
|
||||
multiRoundRag.messages.length === 0 &&
|
||||
ragMessages.length === 0 &&
|
||||
directChat.messages.length === 0 &&
|
||||
!singleRoundRag.isLoading &&
|
||||
!multiRoundRag.isLoading
|
||||
!multiRoundRag.isLoading &&
|
||||
!directChat.isLoading
|
||||
) {
|
||||
return base;
|
||||
}
|
||||
const messages = [
|
||||
...base.messages,
|
||||
...singleRoundRag.messages,
|
||||
...multiRoundRag.messages,
|
||||
];
|
||||
const messages = mergeMessagesByTime(
|
||||
[...base.messages, ...singleRoundRag.messages, ...directChat.messages],
|
||||
ragMessages,
|
||||
);
|
||||
return {
|
||||
...base,
|
||||
values: { ...base.values, messages },
|
||||
messages,
|
||||
isLoading:
|
||||
base.isLoading || singleRoundRag.isLoading || multiRoundRag.isLoading,
|
||||
base.isLoading ||
|
||||
singleRoundRag.isLoading ||
|
||||
multiRoundRag.isLoading ||
|
||||
directChat.isLoading,
|
||||
} as typeof base;
|
||||
}, [
|
||||
directChat.isLoading,
|
||||
directChat.messages,
|
||||
multiRoundRag.isLoading,
|
||||
multiRoundRag.messages,
|
||||
researchDisplayThread,
|
||||
singleRoundRag.isLoading,
|
||||
singleRoundRag.messages,
|
||||
researchDisplayThread,
|
||||
]);
|
||||
|
||||
const writingArtifactPath = useMemo(
|
||||
@@ -393,6 +419,52 @@ export default function ChatPage() {
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
if (directChatSelected) {
|
||||
if (message.files && message.files.length > 0) {
|
||||
toast.error("极速问答暂不支持附件,请移除附件后重试。");
|
||||
return;
|
||||
}
|
||||
setHasStartedConversation(true);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
void (async () => {
|
||||
try {
|
||||
let targetThreadId = threadId;
|
||||
if (isNewThread) {
|
||||
const created = await getAPIClient(isMock).threads.create({});
|
||||
targetThreadId = created.thread_id;
|
||||
setThreadId(targetThreadId);
|
||||
setIsNewThread(false);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#${routeBase}/chats/${targetThreadId}`,
|
||||
);
|
||||
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
|
||||
if (title) {
|
||||
void getAPIClient(isMock).threads
|
||||
.updateState(targetThreadId, { values: { title } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
if (!targetThreadId) {
|
||||
toast.error("无法创建会话。");
|
||||
return;
|
||||
}
|
||||
await directChat.submit({
|
||||
targetThreadId,
|
||||
query: message.text,
|
||||
modelName: settings.context.model_name,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "极速问答失败。",
|
||||
);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
if (frontendRagSelected) {
|
||||
if (message.files && message.files.length > 0) {
|
||||
toast.error("RAG 问答暂不支持附件,请移除附件后重试。");
|
||||
@@ -406,7 +478,9 @@ export default function ChatPage() {
|
||||
try {
|
||||
let targetThreadId = threadId;
|
||||
if (isNewThread) {
|
||||
const created = await getAPIClient(isMock).threads.create({});
|
||||
const created = await getAPIClient(isMock).threads.create({
|
||||
metadata: newThreadMetadata ?? {},
|
||||
});
|
||||
targetThreadId = created.thread_id;
|
||||
setThreadId(targetThreadId);
|
||||
setIsNewThread(false);
|
||||
@@ -466,13 +540,8 @@ export default function ChatPage() {
|
||||
try {
|
||||
const created = await getAPIClient(isMock).threads.create({
|
||||
metadata: {
|
||||
...newThreadMetadata,
|
||||
agent_id: selectedAgentId,
|
||||
...(knowledgeSelectionReady
|
||||
? {
|
||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
||||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
sessionStorage.setItem(
|
||||
@@ -491,7 +560,7 @@ export default function ChatPage() {
|
||||
);
|
||||
saveLastAgentId(selectedAgentId);
|
||||
router.push(
|
||||
`/page/workspace/agents/${selectedAgentId}/chats/${created.thread_id}`,
|
||||
`${routeBase}/agents/${encodeURIComponent(selectedAgentId)}/chats/${encodeURIComponent(created.thread_id)}`,
|
||||
);
|
||||
} catch (err) {
|
||||
agentHandoffPendingRef.current = false;
|
||||
@@ -550,6 +619,7 @@ export default function ChatPage() {
|
||||
selectedAssistantKnowledgeBaseIds,
|
||||
selectedKnowledgeBaseIds,
|
||||
knowledgeSelectionReady,
|
||||
newThreadMetadata,
|
||||
agentHandoffPending,
|
||||
isMock,
|
||||
router,
|
||||
@@ -561,6 +631,8 @@ export default function ChatPage() {
|
||||
multiRoundRag,
|
||||
multiRoundRagSelected,
|
||||
frontendRagSelected,
|
||||
directChat,
|
||||
directChatSelected,
|
||||
routeBase,
|
||||
setIsNewThread,
|
||||
setThreadId,
|
||||
@@ -656,6 +728,10 @@ export default function ChatPage() {
|
||||
saveLastAgentId(agentId);
|
||||
}, []);
|
||||
const handleStop = useCallback(async () => {
|
||||
if (directChat.isLoading) {
|
||||
directChat.stop();
|
||||
return;
|
||||
}
|
||||
if (singleRoundRag.isLoading) {
|
||||
singleRoundRag.stop();
|
||||
return;
|
||||
@@ -665,7 +741,7 @@ export default function ChatPage() {
|
||||
return;
|
||||
}
|
||||
await thread.stop();
|
||||
}, [multiRoundRag, singleRoundRag, thread]);
|
||||
}, [directChat, multiRoundRag, singleRoundRag, thread]);
|
||||
|
||||
const promptPrefixProp = useMemo(() => {
|
||||
if (!promptPrefixSettings?.enabled) return undefined;
|
||||
@@ -695,16 +771,10 @@ export default function ChatPage() {
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
// 紧凑模式(正常 App,有外层页面侧栏)落地态:用 CM 参考图样式重画;
|
||||
// 判定与 PageRoutes 的 compactWorkspaceSidebar(pageSidebarAllowed) 保持一致 ——
|
||||
// 优先看外观设置的「全量/简洁」,未设置过才回退到会话级 sidebarEnabled。否则用户显式
|
||||
// 选「全量」时 PageRoutes 已挂出顶栏+侧栏,这里却还按旧 sidebarEnabled 判定,导致深蓝
|
||||
// 主题底图(workspace-chat-bg)不生效。非紧凑(嵌入/standalone)保持原样。
|
||||
const layoutMode = effectiveLayoutMode;
|
||||
// 用 useLayoutMode 的有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认),
|
||||
// 与 PageRoutes 的 chrome 判定同源。非紧凑(嵌入/standalone)保持原样。
|
||||
const chromeByPreference =
|
||||
fullChatMode || layoutMode === "full"
|
||||
? true
|
||||
: layoutMode === "compact"
|
||||
? false
|
||||
: readSidebarEnabled();
|
||||
fullChatMode || effectiveLayoutMode === "full" ? true : false;
|
||||
const compactChrome =
|
||||
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
|
||||
const compactLanding = isLandingState && compactChrome;
|
||||
@@ -909,7 +979,7 @@ export default function ChatPage() {
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: displayThread.isLoading
|
||||
: displayThread.isLoading || multiRoundRag.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
@@ -921,7 +991,8 @@ export default function ChatPage() {
|
||||
agentHandoffPending ||
|
||||
knowledgeSelectionLoading ||
|
||||
singleRoundRag.isLoading ||
|
||||
multiRoundRag.isLoading
|
||||
multiRoundRag.isLoading ||
|
||||
directChat.isLoading
|
||||
}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -211,7 +212,7 @@ export default function EnterpriseResearchPage() {
|
||||
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 || "报告生成失败");
|
||||
if (event.type === "failed") toast.error(userFacingKnowledgeMessage(event.job.error_message || "报告生成失败"));
|
||||
}
|
||||
},
|
||||
}).catch((error: unknown) => {
|
||||
@@ -290,7 +291,7 @@ export default function EnterpriseResearchPage() {
|
||||
return;
|
||||
}
|
||||
if (!isWeKnoraReady) {
|
||||
toast.error("知识服务尚未启用,请先在系统中配置 WeKnora");
|
||||
toast.error("知识服务尚未启用,请先在系统中配置知识服务");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -442,7 +443,7 @@ export default function EnterpriseResearchPage() {
|
||||
<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>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 普通知识库,不会修改任何知识库或既有报告。</p>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
@@ -609,12 +610,12 @@ function TaskOverview({
|
||||
<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>;
|
||||
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" ? "正在调用知识服务检索并去重证据。" : `已执行 ${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>
|
||||
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 普通知识库。</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" />{userFacingKnowledgeMessage(task.warning)}</div> : null}</CardContent>
|
||||
</Card>
|
||||
{canPlan ? <Card>
|
||||
<CardHeader className="gap-1"><CardTitle className="text-base">研究计划与选用资料</CardTitle><CardDescription>计划和资料选择会保存到当前任务;后续报告只能使用这里确认过的证据。</CardDescription></CardHeader>
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function LlmWikiKnowledgePage({
|
||||
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
|
||||
}
|
||||
|
||||
if (runtime.isError || runtime.data?.provider !== "weknora") {
|
||||
if (runtime.isError || runtime.data?.provider !== "weknora" || !runtime.data?.api_base_url?.trim()) {
|
||||
if (mode === "list" || mode === "detail") {
|
||||
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
|
||||
import { isEmbeddedHost } from '@/core/embed/frame-context';
|
||||
import { readEmbedParam } from '@/core/embed/embed-session';
|
||||
import {
|
||||
GENERAL_QA_ROUTE,
|
||||
loginByYUserId,
|
||||
persistAccessToken,
|
||||
persistToolsToken,
|
||||
@@ -61,6 +60,11 @@ import { persistYLogToken } from '@/core/log/y-log-token';
|
||||
import { persistLoginCredentials } from '@/core/auth/login-credentials';
|
||||
import { OptType, writeYLog } from '@/core/log/y-log';
|
||||
import { authStorageGet } from '@/core/auth/scoped-storage';
|
||||
import {
|
||||
CUSTOMIZED_DEERFLOW_HOME_ROUTE,
|
||||
OFFICIAL_DEERFLOW_HOME_ROUTE,
|
||||
} from '@/core/official-deerflow/routes';
|
||||
import { env } from '@/env';
|
||||
|
||||
/** 令牌脱敏:日志里只露头尾,避免完整令牌泄漏。 */
|
||||
function maskLoginToken(value: string): string {
|
||||
@@ -103,7 +107,11 @@ function summarizeStoredUserInfo(): Record<string, unknown> | null {
|
||||
|
||||
const DEFAULT_USERNAME = 'guest';
|
||||
const IFRAME_CHAT_MODE = 'iframe';
|
||||
const DEFAULT_REDIRECT_PATH = '/page/workspace/chats/new';
|
||||
// Explicit redirects and task deep links retain their existing higher
|
||||
// priority. This only picks the destination for a standard completed login.
|
||||
const DEFAULT_REDIRECT_PATH = env.USE_OFFICIAL_DEERFLOW_ENTRY
|
||||
? OFFICIAL_DEERFLOW_HOME_ROUTE
|
||||
: CUSTOMIZED_DEERFLOW_HOME_ROUTE;
|
||||
const FULL_CHAT_MODE = 'full';
|
||||
|
||||
type LoginStatus = 'loading' | 'success' | 'error';
|
||||
@@ -191,7 +199,7 @@ export default function LoginPage() {
|
||||
// External iframe callers frequently also provide yUserId for login.
|
||||
// That identity parameter must not steal a deliberate iframe entry and
|
||||
// send it to the generic full-chat home before the route split above.
|
||||
if (yUserId) return GENERAL_QA_ROUTE;
|
||||
if (yUserId) return DEFAULT_REDIRECT_PATH;
|
||||
return resolvePostLoginRedirectPath(
|
||||
searchParams.toString(),
|
||||
DEFAULT_REDIRECT_PATH,
|
||||
|
||||
@@ -0,0 +1,848 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import {
|
||||
Bot,
|
||||
CalendarClock,
|
||||
ChevronDown,
|
||||
CircleHelp,
|
||||
Clock3,
|
||||
FolderKanban,
|
||||
LoaderCircle,
|
||||
MessageSquareText,
|
||||
PanelLeftClose,
|
||||
Pause,
|
||||
Play,
|
||||
PlusSquare,
|
||||
Search,
|
||||
SendHorizontal,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { type FormEvent, useCallback, useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
import {
|
||||
MyProjectThreadBadge,
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { useAgent, useAgents } from "@/core/agents";
|
||||
import { getAccountDisplayName } from "@/core/auth";
|
||||
import {
|
||||
type ScheduledTask,
|
||||
useScheduledTaskActions,
|
||||
useScheduledTaskRuns,
|
||||
useScheduledTasks,
|
||||
} from "@/core/scheduled-tasks";
|
||||
import {
|
||||
useAppearanceSettings,
|
||||
useCitationDisplaySettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import { useThreadStream, useThreads } from "@/core/threads/hooks";
|
||||
import {
|
||||
myProjectConversationTypeOfThread,
|
||||
myProjectConversationTypeFromSearchParams,
|
||||
myProjectThreadMetadata,
|
||||
} from "@/core/threads/project-metadata";
|
||||
import { textOfMessage, titleOfThread } from "@/core/threads/utils";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
import {
|
||||
OFFICIAL_DEERFLOW_HOME_ROUTE,
|
||||
OFFICIAL_DEERFLOW_ROUTE_PREFIX,
|
||||
} from "@/core/official-deerflow/routes";
|
||||
import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
|
||||
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
|
||||
|
||||
import "./official-deerflow-entry.css";
|
||||
|
||||
type OfficialPage =
|
||||
| "chat"
|
||||
| "projects"
|
||||
| "agents"
|
||||
| "agent-chat"
|
||||
| "scheduled"
|
||||
| "settings";
|
||||
|
||||
function pageFromPath(pathname: string): OfficialPage {
|
||||
const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length);
|
||||
if (path.startsWith("/workspace/agents/") && path.includes("/chats")) {
|
||||
return "agent-chat";
|
||||
}
|
||||
if (path.startsWith("/workspace/projects")) return "projects";
|
||||
if (path.startsWith("/workspace/agents")) return "agents";
|
||||
if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled";
|
||||
if (path.startsWith("/workspace/settings")) return "settings";
|
||||
return "chat";
|
||||
}
|
||||
|
||||
function parseChatRoute(pathname: string) {
|
||||
const chatMatch = pathname.match(/\/workspace\/chats\/([^/]+)/);
|
||||
if (chatMatch) return { threadId: decodeURIComponent(chatMatch[1]!), agentId: null };
|
||||
|
||||
const agentMatch = pathname.match(
|
||||
/\/workspace\/agents\/([^/]+)\/chats\/([^/]+)/,
|
||||
);
|
||||
if (agentMatch) {
|
||||
return {
|
||||
agentId: decodeURIComponent(agentMatch[1]!),
|
||||
threadId: decodeURIComponent(agentMatch[2]!),
|
||||
};
|
||||
}
|
||||
return { threadId: "new", agentId: null };
|
||||
}
|
||||
|
||||
function parseScheduledTaskId(pathname: string) {
|
||||
const match = pathname.match(/\/workspace\/scheduled-tasks\/([^/]+)/);
|
||||
return match ? decodeURIComponent(match[1]!) : null;
|
||||
}
|
||||
|
||||
function parseProjectId(pathname: string) {
|
||||
const match = pathname.match(/\/workspace\/projects\/([^/]+)/);
|
||||
return match ? decodeURIComponent(match[1]!) : null;
|
||||
}
|
||||
|
||||
function readableError(error: unknown, fallback: string) {
|
||||
return error instanceof Error && error.message ? error.message : fallback;
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null | undefined) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const elapsed = Date.now() - date.getTime();
|
||||
if (elapsed < 60_000) return "刚刚";
|
||||
if (elapsed < 3_600_000) return `${Math.max(1, Math.floor(elapsed / 60_000))} 分钟前`;
|
||||
if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)} 小时前`;
|
||||
return new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric" }).format(date);
|
||||
}
|
||||
|
||||
function statusForTask(task: ScheduledTask) {
|
||||
const status = task.last_status;
|
||||
if (status === "running" || status === "pending") {
|
||||
return { className: status, label: "执行中" };
|
||||
}
|
||||
if (!task.enabled) return { className: "paused", label: "已暂停" };
|
||||
if (status === "failed") return { className: "failed", label: "执行失败" };
|
||||
if (status === "succeeded") return { className: "succeeded", label: "已完成" };
|
||||
return { className: "ready", label: "已启用" };
|
||||
}
|
||||
|
||||
function runStatusText(status: string) {
|
||||
if (status === "running" || status === "pending") return "执行中";
|
||||
if (status === "succeeded") return "已完成";
|
||||
if (status === "failed") return "执行失败";
|
||||
return status || "等待执行";
|
||||
}
|
||||
|
||||
function OfficialNavItem({
|
||||
active,
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
icon: typeof MessageSquareText;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={`official-nav-item${active ? " is-active" : ""}`}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Icon aria-hidden="true" size={18} strokeWidth={1.8} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadMessage({ message }: { message: Message }) {
|
||||
const text = textOfMessage(message)?.trim();
|
||||
if (!text) return null;
|
||||
const isUser = message.type === "human";
|
||||
return (
|
||||
<div className={`official-message official-message-${isUser ? "user" : "assistant"}`}>
|
||||
{!isUser && (
|
||||
<div className="official-assistant-mark">
|
||||
<Sparkles aria-hidden="true" size={16} />
|
||||
DeerFlow
|
||||
</div>
|
||||
)}
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatWorkspace({
|
||||
agentId,
|
||||
routeThreadId,
|
||||
}: {
|
||||
agentId: string | null;
|
||||
routeThreadId: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const isNewThread = routeThreadId === "new";
|
||||
const projectConversationType = isNewThread
|
||||
? myProjectConversationTypeFromSearchParams(
|
||||
new URLSearchParams(location.search),
|
||||
)
|
||||
: null;
|
||||
// A new conversation uses the same client-generated id approach as the
|
||||
// existing ChatPage. The backend owns the persisted id once streaming starts.
|
||||
const [draftThreadId] = useState<string>(() => uuid() as string);
|
||||
const activeThreadId = isNewThread ? draftThreadId : routeThreadId;
|
||||
const { agent } = useAgent(agentId, { enabled: !!agentId });
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : routeThreadId,
|
||||
assistantId: agentId ?? undefined,
|
||||
threadMetadata: projectConversationType
|
||||
? myProjectThreadMetadata(projectConversationType)
|
||||
: undefined,
|
||||
context: {
|
||||
// This compact official shell has no model/mode controls of its own.
|
||||
// Do not inherit a hidden selection saved by the customized workspace
|
||||
// (for example an obsolete or quota-exhausted model); an omitted model
|
||||
// lets the backend resolve the current system default or the agent's
|
||||
// globally configured model.
|
||||
model_name: undefined,
|
||||
mode: undefined,
|
||||
reasoning_effort: undefined,
|
||||
writing_mode: false,
|
||||
...(agentId ? { agent_id: agentId } : {}),
|
||||
},
|
||||
onStart: (createdThreadId) => {
|
||||
const nextPath = agentId
|
||||
? `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/${encodeURIComponent(createdThreadId)}`
|
||||
: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(createdThreadId)}`;
|
||||
navigate(nextPath, { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const messages = useMemo(
|
||||
() =>
|
||||
(thread.messages ?? []).filter(
|
||||
(message): message is Message =>
|
||||
message.type === "human" || message.type === "ai",
|
||||
),
|
||||
[thread.messages],
|
||||
);
|
||||
const title = isNewThread
|
||||
? agent?.name ?? "新对话"
|
||||
: thread.values?.title ?? agent?.name ?? "对话";
|
||||
const streamError = thread.error
|
||||
? readableError(thread.error, "对话加载失败,请稍后重试。")
|
||||
: null;
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const text = prompt.trim();
|
||||
if (!text || thread.isLoading || isUploading) return;
|
||||
setPrompt("");
|
||||
setSendError(null);
|
||||
void sendMessage(activeThreadId, { text, files: [] }).catch((error) => {
|
||||
setSendError(readableError(error, "消息发送失败,请稍后重试。"));
|
||||
});
|
||||
},
|
||||
[activeThreadId, isUploading, prompt, sendMessage, thread.isLoading],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="official-chat-page">
|
||||
<header className="official-page-header official-chat-header">
|
||||
<div className="official-thread-title">
|
||||
{title}
|
||||
<ChevronDown aria-hidden="true" size={16} />
|
||||
</div>
|
||||
<div className="official-header-actions">
|
||||
<button
|
||||
className="official-outline-button"
|
||||
onClick={() => navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`)}
|
||||
type="button"
|
||||
>
|
||||
<CalendarClock aria-hidden="true" size={17} />
|
||||
定时任务
|
||||
</button>
|
||||
<button aria-label="帮助" className="official-icon-button" type="button">
|
||||
<CircleHelp aria-hidden="true" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={`official-chat-canvas${messages.length ? " has-history" : ""}`}>
|
||||
{messages.length ? (
|
||||
<div className="official-message-list">
|
||||
{messages.map((message, index) => (
|
||||
<ThreadMessage key={message.id ?? `${message.type}-${index}`} message={message} />
|
||||
))}
|
||||
{thread.isLoading && (
|
||||
<div className="official-streaming-indicator">
|
||||
<LoaderCircle aria-hidden="true" size={15} />
|
||||
DeerFlow 正在回复…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="official-chat-welcome">
|
||||
<div className="official-logo-orb">
|
||||
<Sparkles aria-hidden="true" size={28} />
|
||||
</div>
|
||||
<h1>{agent?.name ? `与 ${agent.name} 开始对话` : "今天想一起完成什么?"}</h1>
|
||||
<p>直接连接当前 DeerFlow 后端;消息与历史记录会同步保存。</p>
|
||||
{!agentId && (
|
||||
<div className="official-suggestion-grid">
|
||||
{[
|
||||
["做一份市场调研", "比较行业趋势并形成要点"],
|
||||
["规划项目", "把目标拆解成清晰步骤"],
|
||||
["写一篇初稿", "从想法到结构化内容"],
|
||||
].map(([suggestion, detail]) => (
|
||||
<button key={suggestion} onClick={() => setPrompt(suggestion)} type="button">
|
||||
<strong>{suggestion}</strong>
|
||||
<span>{detail}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(streamError || sendError) && (
|
||||
<p className="official-inline-error" role="alert">
|
||||
{sendError ?? streamError}
|
||||
</p>
|
||||
)}
|
||||
<form className="official-composer-shell" onSubmit={handleSubmit}>
|
||||
<div className="official-composer">
|
||||
<textarea
|
||||
aria-label="输入消息"
|
||||
disabled={thread.isLoading || isUploading}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder="给 DeerFlow 发送消息…"
|
||||
rows={1}
|
||||
value={prompt}
|
||||
/>
|
||||
<button
|
||||
aria-label="发送"
|
||||
disabled={!prompt.trim() || thread.isLoading || isUploading}
|
||||
type="submit"
|
||||
>
|
||||
<SendHorizontal aria-hidden="true" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<p>连接当前 DeerFlow 后端</p>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentsPage({ openAgent }: { openAgent: (id: string) => void }) {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
const { agents, isLoading, error } = useAgents({ search: query });
|
||||
|
||||
return (
|
||||
<section className="official-content-page">
|
||||
<header className="official-page-header">
|
||||
<div>
|
||||
<p className="official-eyebrow">工作区</p>
|
||||
<h1>智能体</h1>
|
||||
<p>以下智能体直接来自当前 DeerFlow 后端。</p>
|
||||
</div>
|
||||
<button
|
||||
className="official-primary-button"
|
||||
onClick={() => navigate("/page/workspace/agents/new")}
|
||||
type="button"
|
||||
>
|
||||
<PlusSquare aria-hidden="true" size={17} />
|
||||
创建智能体
|
||||
</button>
|
||||
</header>
|
||||
<div className="official-toolbar">
|
||||
<label className="official-search">
|
||||
<Search aria-hidden="true" size={17} />
|
||||
<input
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索智能体"
|
||||
value={query}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="official-query-error" role="alert">
|
||||
{readableError(error, "智能体列表加载失败。")}
|
||||
</p>
|
||||
) : isLoading ? (
|
||||
<div className="official-loading-state"><LoaderCircle aria-hidden="true" size={18} /> 正在加载智能体…</div>
|
||||
) : agents.length ? (
|
||||
<div className="official-agent-grid">
|
||||
{agents.map((agent, index) => (
|
||||
<article className="official-agent-card" key={agent.id}>
|
||||
<div className={`official-agent-avatar ${["violet", "sky", "amber"][index % 3]}`}>
|
||||
{agent.name.slice(0, 2).toLocaleUpperCase()}
|
||||
</div>
|
||||
<h2>{agent.name}</h2>
|
||||
<p>{agent.description || "暂无简介"}</p>
|
||||
<button onClick={() => openAgent(agent.id)} type="button">
|
||||
开始对话
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="official-empty-panel">
|
||||
<div className="official-empty-icon"><Bot aria-hidden="true" size={24} /></div>
|
||||
<h2>暂无可用智能体</h2>
|
||||
<p>可从“创建智能体”进入现有的完整配置页面。</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduledTasksPage({ selectedTaskId }: { selectedTaskId: string | null }) {
|
||||
const navigate = useNavigate();
|
||||
const { data: tasks = [], isLoading, error } = useScheduledTasks();
|
||||
const { data: runs = [], isLoading: runsLoading, error: runsError } =
|
||||
useScheduledTaskRuns(selectedTaskId);
|
||||
const actions = useScheduledTaskActions();
|
||||
const [actionTaskId, setActionTaskId] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const selectedTask = tasks.find((task) => task.task_id === selectedTaskId) ?? null;
|
||||
|
||||
const openTask = useCallback(
|
||||
(taskId: string) => {
|
||||
navigate(
|
||||
`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks/${encodeURIComponent(taskId)}`,
|
||||
);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const closeTask = useCallback(() => {
|
||||
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`);
|
||||
}, [navigate]);
|
||||
|
||||
const runTaskAction = useCallback(
|
||||
async (task: ScheduledTask, action: "trigger" | "pause" | "resume") => {
|
||||
setActionTaskId(task.task_id);
|
||||
setActionError(null);
|
||||
try {
|
||||
if (action === "trigger") {
|
||||
await actions.trigger.mutateAsync(task.task_id);
|
||||
openTask(task.task_id);
|
||||
} else if (action === "pause") {
|
||||
await actions.pause.mutateAsync(task.task_id);
|
||||
} else {
|
||||
await actions.resume.mutateAsync(task.task_id);
|
||||
}
|
||||
} catch (actionFailure) {
|
||||
setActionError(
|
||||
readableError(
|
||||
actionFailure,
|
||||
action === "trigger" ? "任务触发失败。" : "任务状态更新失败。",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setActionTaskId(null);
|
||||
}
|
||||
},
|
||||
[actions.pause, actions.resume, actions.trigger, openTask],
|
||||
);
|
||||
|
||||
const actionIsPending = (taskId: string) => actionTaskId === taskId;
|
||||
|
||||
return (
|
||||
<section className="official-content-page">
|
||||
<header className="official-page-header">
|
||||
<div>
|
||||
<p className="official-eyebrow">工作区</p>
|
||||
<h1>定时任务</h1>
|
||||
<p>任务、运行记录和控制操作均直接请求当前 DeerFlow 后端。</p>
|
||||
</div>
|
||||
<button
|
||||
className="official-primary-button"
|
||||
onClick={() => navigate("/page/workspace/scheduled/tasks")}
|
||||
type="button"
|
||||
>
|
||||
<PlusSquare aria-hidden="true" size={17} />
|
||||
新建任务
|
||||
</button>
|
||||
</header>
|
||||
{actionError && (
|
||||
<p className="official-query-error" role="alert">
|
||||
{actionError}
|
||||
</p>
|
||||
)}
|
||||
{selectedTask && (
|
||||
<section className="official-task-detail" aria-label={`${selectedTask.name}详情`}>
|
||||
<header>
|
||||
<div>
|
||||
<p className="official-eyebrow">任务详情</p>
|
||||
<h2>{selectedTask.name}</h2>
|
||||
<p>{selectedTask.description || selectedTask.prompt}</p>
|
||||
</div>
|
||||
<button
|
||||
aria-label="关闭任务详情"
|
||||
className="official-icon-button"
|
||||
onClick={closeTask}
|
||||
type="button"
|
||||
>
|
||||
<X aria-hidden="true" size={18} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="official-task-meta">
|
||||
<span>计划:{selectedTask.schedule_text || selectedTask.cron_expr}</span>
|
||||
<span>时区:{selectedTask.timezone}</span>
|
||||
<span>下次执行:{formatTimestamp(selectedTask.next_run_at) || "未计划"}</span>
|
||||
</div>
|
||||
<div className="official-task-detail-actions">
|
||||
<button
|
||||
className="official-secondary-button"
|
||||
disabled={actionIsPending(selectedTask.task_id)}
|
||||
onClick={() => void runTaskAction(selectedTask, "trigger")}
|
||||
type="button"
|
||||
>
|
||||
{actionIsPending(selectedTask.task_id) ? <LoaderCircle aria-hidden="true" size={15} /> : <Play aria-hidden="true" size={15} />}
|
||||
立即执行
|
||||
</button>
|
||||
<button
|
||||
className="official-secondary-button"
|
||||
disabled={actionIsPending(selectedTask.task_id)}
|
||||
onClick={() =>
|
||||
void runTaskAction(
|
||||
selectedTask,
|
||||
selectedTask.enabled ? "pause" : "resume",
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{selectedTask.enabled ? <Pause aria-hidden="true" size={15} /> : <Play aria-hidden="true" size={15} />}
|
||||
{selectedTask.enabled ? "暂停任务" : "恢复任务"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="official-run-list">
|
||||
<h3>最近运行</h3>
|
||||
{runsError ? (
|
||||
<p className="official-query-error" role="alert">
|
||||
{readableError(runsError, "运行记录加载失败。")}
|
||||
</p>
|
||||
) : runsLoading ? (
|
||||
<p className="official-run-empty"><LoaderCircle aria-hidden="true" size={15} /> 正在加载运行记录…</p>
|
||||
) : runs.length ? (
|
||||
runs.slice(0, 8).map((run) => (
|
||||
<button
|
||||
className="official-run-row"
|
||||
key={run.id}
|
||||
onClick={() => navigate(`/page/workspace/scheduled/runs/${encodeURIComponent(run.id)}`)}
|
||||
type="button"
|
||||
>
|
||||
<span className={`official-run-status is-${run.status}`}>{runStatusText(run.status)}</span>
|
||||
<span>{formatTimestamp(run.started_at ?? run.scheduled_for) || "等待执行"}</span>
|
||||
<span>{run.result?.title || run.error || "查看完整运行结果"}</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className="official-run-empty">该任务暂时没有运行记录。</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{error ? (
|
||||
<p className="official-query-error" role="alert">
|
||||
{readableError(error, "定时任务列表加载失败。")}
|
||||
</p>
|
||||
) : isLoading ? (
|
||||
<div className="official-loading-state"><LoaderCircle aria-hidden="true" size={18} /> 正在加载定时任务…</div>
|
||||
) : tasks.length ? (
|
||||
<div className="official-task-list">
|
||||
{tasks.map((task) => {
|
||||
const status = statusForTask(task);
|
||||
const pending = actionIsPending(task.task_id);
|
||||
return (
|
||||
<article
|
||||
className={`official-task-card${selectedTaskId === task.task_id ? " is-selected" : ""}`}
|
||||
key={task.task_id}
|
||||
>
|
||||
<button
|
||||
className="official-task-card-main"
|
||||
onClick={() => openTask(task.task_id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="official-task-icon"><Clock3 aria-hidden="true" size={18} /></span>
|
||||
<span className="official-task-copy">
|
||||
<strong>{task.name}</strong>
|
||||
<small>{task.schedule_text || task.cron_expr}</small>
|
||||
</span>
|
||||
<span className={`official-task-status is-${status.className}`}>{status.label}</span>
|
||||
<time>{formatTimestamp(task.next_run_at ?? task.updated_at)}</time>
|
||||
</button>
|
||||
<div className="official-task-card-actions">
|
||||
<button
|
||||
aria-label={`立即执行 ${task.name}`}
|
||||
disabled={pending}
|
||||
onClick={() => void runTaskAction(task, "trigger")}
|
||||
title="立即执行"
|
||||
type="button"
|
||||
>
|
||||
{pending ? <LoaderCircle aria-hidden="true" size={16} /> : <Play aria-hidden="true" size={16} />}
|
||||
</button>
|
||||
<button
|
||||
aria-label={task.enabled ? `暂停 ${task.name}` : `恢复 ${task.name}`}
|
||||
disabled={pending}
|
||||
onClick={() => void runTaskAction(task, task.enabled ? "pause" : "resume")}
|
||||
title={task.enabled ? "暂停任务" : "恢复任务"}
|
||||
type="button"
|
||||
>
|
||||
{task.enabled ? <Pause aria-hidden="true" size={16} /> : <Play aria-hidden="true" size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="official-empty-panel">
|
||||
<div className="official-empty-icon"><Clock3 aria-hidden="true" size={24} /></div>
|
||||
<h2>还没有定时任务</h2>
|
||||
<p>创建一个任务,按天、周或自定义时间自动执行。</p>
|
||||
<button
|
||||
className="official-secondary-button"
|
||||
onClick={() => navigate("/page/workspace/scheduled/tasks")}
|
||||
type="button"
|
||||
>
|
||||
创建定时任务
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPage() {
|
||||
const { data: appearance, isLoading: appearanceLoading, error: appearanceError } =
|
||||
useAppearanceSettings();
|
||||
const { data: citation, isLoading: citationLoading, error: citationError } =
|
||||
useCitationDisplaySettings();
|
||||
const settings = [
|
||||
appearance && {
|
||||
label: "默认外观",
|
||||
detail: `主题:${appearance.default_theme};布局:${appearance.default_layout_mode}`,
|
||||
},
|
||||
appearance && {
|
||||
label: "默认展示方式",
|
||||
detail: `智能体:${appearance.default_agent_view};技能:${appearance.default_skill_view}`,
|
||||
},
|
||||
citation && {
|
||||
label: "引用展示",
|
||||
detail: citation.reference_mode_enabled ? "已启用引用模式" : "未启用引用模式",
|
||||
},
|
||||
].filter(Boolean) as Array<{ label: string; detail: string }>;
|
||||
const isLoading = appearanceLoading || citationLoading;
|
||||
|
||||
return (
|
||||
<section className="official-content-page">
|
||||
<header className="official-page-header">
|
||||
<div>
|
||||
<p className="official-eyebrow">工作区</p>
|
||||
<h1>设置</h1>
|
||||
<p>只展示已由当前 DeerFlow 后端提供的配置,不使用模拟值。</p>
|
||||
</div>
|
||||
</header>
|
||||
{isLoading && !settings.length ? (
|
||||
<div className="official-loading-state"><LoaderCircle aria-hidden="true" size={18} /> 正在加载系统配置…</div>
|
||||
) : settings.length ? (
|
||||
<div className="official-settings-list">
|
||||
{settings.map((setting) => (
|
||||
<div className="official-setting-row" key={setting.label}>
|
||||
<strong>{setting.label}</strong>
|
||||
<small>{setting.detail}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="official-empty-panel">
|
||||
<div className="official-empty-icon"><Settings2 aria-hidden="true" size={24} /></div>
|
||||
<h2>暂时没有可读取的系统配置</h2>
|
||||
<p>不会用静态数据替代未返回的后端配置。</p>
|
||||
</div>
|
||||
)}
|
||||
{(appearanceError || citationError) && (
|
||||
<p className="official-query-error" role="alert">
|
||||
部分系统配置不可读取:{readableError(appearanceError ?? citationError, "请检查登录权限。")}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite-native official-style workspace. It reuses the current frontend's
|
||||
* backend clients and stream adapter; only reference-only UI has local state.
|
||||
*/
|
||||
export default function OfficialDeerFlowEntry() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const page = pageFromPath(location.pathname);
|
||||
const chatRoute = useMemo(
|
||||
() => parseChatRoute(location.pathname),
|
||||
[location.pathname],
|
||||
);
|
||||
const scheduledTaskId = useMemo(
|
||||
() => parseScheduledTaskId(location.pathname),
|
||||
[location.pathname],
|
||||
);
|
||||
const projectId = useMemo(
|
||||
() => parseProjectId(location.pathname),
|
||||
[location.pathname],
|
||||
);
|
||||
const { data: threads = [], isLoading: threadsLoading, error: threadsError } =
|
||||
useThreads({
|
||||
limit: 20,
|
||||
sortBy: "updated_at",
|
||||
sortOrder: "desc",
|
||||
select: ["thread_id", "updated_at", "values", "metadata"],
|
||||
});
|
||||
const go = (path: string) => navigate(path);
|
||||
const projectBasePath = `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/projects`;
|
||||
|
||||
const nav = [
|
||||
{ icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, pages: ["chat"] },
|
||||
{ icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] },
|
||||
{ icon: FolderKanban, label: "我的项目", path: projectBasePath, pages: ["projects"] },
|
||||
{ icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] },
|
||||
{ icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<main className={`official-deerflow${sidebarCollapsed ? " sidebar-collapsed" : ""}`}>
|
||||
<aside className="official-sidebar">
|
||||
<div className="official-brand-row">
|
||||
<div className="official-brand">DeerFlow</div>
|
||||
<button
|
||||
aria-label="折叠侧栏"
|
||||
className="official-collapse-button"
|
||||
onClick={() => setSidebarCollapsed((value) => !value)}
|
||||
type="button"
|
||||
>
|
||||
<PanelLeftClose aria-hidden="true" size={17} />
|
||||
</button>
|
||||
</div>
|
||||
<nav aria-label="官方页面导航" className="official-nav">
|
||||
{nav.map((item) => (
|
||||
<OfficialNavItem
|
||||
active={item.pages.some((candidate) => candidate === page)}
|
||||
icon={item.icon}
|
||||
key={item.label}
|
||||
label={item.label}
|
||||
onClick={() => go(item.path)}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
<div className="official-recent-section">
|
||||
<p>最近对话</p>
|
||||
{threadsLoading ? (
|
||||
<div className="official-sidebar-loading"><LoaderCircle aria-hidden="true" size={14} /> 加载中…</div>
|
||||
) : threadsError ? (
|
||||
<p className="official-sidebar-error">对话列表加载失败</p>
|
||||
) : threads.length ? (
|
||||
threads.map((chat) => {
|
||||
const projectType = myProjectConversationTypeOfThread(chat);
|
||||
return (
|
||||
<button
|
||||
className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`}
|
||||
key={chat.thread_id}
|
||||
onClick={() => {
|
||||
const agentId = typeof chat.context?.agent_id === "string"
|
||||
? chat.context.agent_id
|
||||
: typeof chat.metadata?.agent_id === "string"
|
||||
? chat.metadata.agent_id
|
||||
: null;
|
||||
go(
|
||||
agentId
|
||||
? `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/${encodeURIComponent(chat.thread_id)}`
|
||||
: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(chat.thread_id)}`,
|
||||
);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{projectType ? (
|
||||
<MyProjectThreadIcon className="size-[15px]" type={projectType} />
|
||||
) : (
|
||||
<MessageSquareText aria-hidden="true" size={15} />
|
||||
)}
|
||||
<span className="official-recent-chat-copy">
|
||||
<span className="official-recent-chat-title">
|
||||
{titleOfThread(chat, "未命名对话")}
|
||||
</span>
|
||||
{projectType && (
|
||||
<MyProjectThreadBadge type={projectType} />
|
||||
)}
|
||||
</span>
|
||||
<small>{formatTimestamp(chat.updated_at)}</small>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="official-sidebar-empty">暂无历史对话</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="official-sidebar-footer">
|
||||
<button onClick={() => go(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/settings`)} type="button">
|
||||
<Settings2 aria-hidden="true" size={18} />
|
||||
<span>设置和更多</span>
|
||||
<ChevronDown aria-hidden="true" size={15} />
|
||||
</button>
|
||||
<div className="official-user-row">
|
||||
<span className="official-user-avatar"><UserRound aria-hidden="true" size={15} /></span>
|
||||
<span>{getAccountDisplayName()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="official-main-panel">
|
||||
{page === "chat" && (
|
||||
<ChatWorkspace
|
||||
agentId={null}
|
||||
key={location.key}
|
||||
routeThreadId={chatRoute.threadId}
|
||||
/>
|
||||
)}
|
||||
{page === "projects" && (
|
||||
projectId ? (
|
||||
<StrategySelectionPage
|
||||
key={projectId}
|
||||
projectBasePath={projectBasePath}
|
||||
projectId={projectId}
|
||||
/>
|
||||
) : (
|
||||
<StrategyProjectsPage basePath={projectBasePath} />
|
||||
)
|
||||
)}
|
||||
{page === "agents" && (
|
||||
<AgentsPage
|
||||
openAgent={(agentId) =>
|
||||
go(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/new`)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{page === "agent-chat" && (
|
||||
<ChatWorkspace
|
||||
agentId={chatRoute.agentId}
|
||||
key={location.key}
|
||||
routeThreadId={chatRoute.threadId}
|
||||
/>
|
||||
)}
|
||||
{page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />}
|
||||
{page === "settings" && <SettingsPage />}
|
||||
</div>
|
||||
<Toaster position="top-center" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { PageSidebar } from "@/components/page-sidebar";
|
||||
import { PageSidebarDark } from "@/components/page-sidebar-dark";
|
||||
import { CompactToolbox } from "@/components/workspace/compact-toolbox";
|
||||
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
|
||||
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
|
||||
import { isWujieEnv } from "@/core/embed/wujie";
|
||||
import {
|
||||
isEmbedActive,
|
||||
@@ -34,7 +33,6 @@ import { RoundtableJobNotifier } from "@/roundtable-planning/components/Roundtab
|
||||
function PageLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const forceCollapsedSidebar = env.FORCE_COLLAPSED_SIDEBAR;
|
||||
const sidebarEnabled = useMemo(() => readSidebarEnabled(), []);
|
||||
const location = useLocation();
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
@@ -45,17 +43,11 @@ function PageLayout() {
|
||||
const thirdLevel = useActiveThirdLevel();
|
||||
const lightExpanded = lightNarrowSidebar && thirdLevel.length > 0;
|
||||
// 界面模式(外观设置):用户显式选「全量/简洁」时以其为准;否则跟随管理员系统默认
|
||||
// (default_layout_mode);两者都没有才回退到会话级 sidebarEnabled(外部门户/yUserId
|
||||
// 登录时置位)这一旧默认。这样无论是否走门户登录,「全量↔简洁」都能真正控制顶栏+侧边栏。
|
||||
// (default_layout_mode);两者都没有时使用内置默认「简洁模式」(useLayoutMode 兜底,
|
||||
// 不再回退到会话级 sidebarEnabled 旧默认)。「全量↔简洁」始终能真正控制顶栏+侧边栏。
|
||||
const [layoutMode] = useLayoutMode();
|
||||
const chromeByPreference =
|
||||
forceCollapsedSidebar
|
||||
? false
|
||||
: layoutMode === "full"
|
||||
? true
|
||||
: layoutMode === "compact"
|
||||
? false
|
||||
: sidebarEnabled;
|
||||
forceCollapsedSidebar ? false : layoutMode === "compact" ? false : true;
|
||||
// iframe 嵌入模式(外部系统 `&embed=1`,按标签页 sessionStorage):去掉全局导航栏 + 侧边栏,
|
||||
// 只留问答/会商工作区本体。按 location 重算以跟随页内导航;非嵌入标签页恒为 false,不受影响。
|
||||
const embedActive = useMemo(
|
||||
|
||||
@@ -138,8 +138,9 @@ const emptyForm = {
|
||||
html_negative_prompt: "",
|
||||
html_reference_favorite_id: "",
|
||||
html_model_name: "",
|
||||
// 参考选了内置模板(tpl:<id>)时的「数据抽取」开关:关闭=用模板默认数据,开启=检索抽取。
|
||||
html_template_extract: false,
|
||||
// 参考选了内置模板(tpl:<id>)时的「数据抽取」开关:默认开启(检索抽取+抽不到回退
|
||||
// 模板原数据),仅可转换模板生效;显式关闭=直接用模板默认数据。
|
||||
html_template_extract: true,
|
||||
};
|
||||
|
||||
/** 覆盖 Textarea 默认 field-sizing-content,避免输入过多时撑破弹窗 */
|
||||
@@ -331,7 +332,8 @@ export default function ScheduledTasksPage({
|
||||
html_negative_prompt: html.negative_prompt ?? "",
|
||||
html_reference_favorite_id: html.reference_favorite_id ?? "",
|
||||
html_model_name: html.model_name ?? "",
|
||||
html_template_extract: !!html.template_extract,
|
||||
// 与后端缺省一致:未存该键(老任务)视为开启,仅显式 false 才关闭。
|
||||
html_template_extract: html.template_extract !== false,
|
||||
});
|
||||
// Prefer the canonical cron_expr over the user-typed schedule_text: the
|
||||
// latter may be natural language ("每天9点") that parseCron can't read,
|
||||
@@ -358,6 +360,10 @@ export default function ScheduledTasksPage({
|
||||
isTemplateAgent || form.require_html,
|
||||
);
|
||||
const selectedTemplate = htmlTemplates.find((t) => t.id === form.template_id);
|
||||
// 「参考收藏页面」选中的内置模板(require_html 模式,值形如 tpl:<id>)。
|
||||
const selectedTplRef = htmlTemplates.find(
|
||||
(t) => `tpl:${t.id}` === form.html_reference_favorite_id,
|
||||
);
|
||||
// 选中的模板是「只展示」型(convertible===false)时,不需要源 JSON,隐藏上传/输入。
|
||||
const templateDisplayOnly =
|
||||
isTemplateAgent && selectedTemplate?.convertible === false;
|
||||
@@ -1396,23 +1402,28 @@ export default function ScheduledTasksPage({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 参考选了内置模板(tpl:<id>)时:数据抽取开关。关闭=用模板默认数据。 */}
|
||||
{form.html_reference_favorite_id.startsWith("tpl:") && (
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="min-w-0 pr-3">
|
||||
<p className="text-sm font-medium">执行数据抽取</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
关闭则直接用所选模板的默认数据生成(含全部子页面);开启才检索并按模板结构抽取数据填充。
|
||||
</p>
|
||||
{/* 参考选了内置模板(tpl:<id>)时:数据抽取开关(仅可转换模板;只展示模板给提示)。 */}
|
||||
{form.html_reference_favorite_id.startsWith("tpl:") &&
|
||||
(selectedTplRef?.convertible === false ? (
|
||||
<p className="text-muted-foreground rounded-md border p-3 text-xs">
|
||||
该模板暂为「直接展示已有数据」,执行时将原样输出其现有页面,不做数据抽取。
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="min-w-0 pr-3">
|
||||
<p className="text-sm font-medium">执行数据抽取</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
开启后执行时由所选智能体检索生成内容,并按模板结构抽取数据填充;抽取不到的子页面自动回退模板原有数据。关闭则直接用所选模板的默认数据生成(含全部子页面)。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.html_template_extract}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((value) => ({ ...value, html_template_extract: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.html_template_extract}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((value) => ({ ...value, html_template_extract: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-xs">生成模型</p>
|
||||
<Select
|
||||
|
||||
@@ -1,9 +1,55 @@
|
||||
import { BookOpenCheck, Sparkles } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
|
||||
import { SkillKnowledgePanel } from "@/components/workspace/skills/SkillKnowledgePanel";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
export default function SkillsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = useMemo(
|
||||
() => (searchParams.get("tab") === "knowledge" ? "knowledge" : "skills"),
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const changeTab = (value: string) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (value === "knowledge") next.set("tab", "knowledge");
|
||||
else next.delete("tab");
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0 flex-col overflow-auto p-6">
|
||||
<SkillSettingsPage />
|
||||
</div>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={changeTab}
|
||||
className="flex size-full min-h-0 flex-col gap-0 overflow-hidden"
|
||||
>
|
||||
<header className="flex flex-wrap items-center justify-between gap-4 border-b bg-background px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">技能</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
浏览和维护技能,也可以把多个技能的知识一次归纳到一个 Wiki 知识库。
|
||||
</p>
|
||||
</div>
|
||||
<TabsList>
|
||||
<TabsTrigger value="skills">
|
||||
<Sparkles className="mr-2 size-4" />
|
||||
技能列表
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="knowledge">
|
||||
<BookOpenCheck className="mr-2 size-4" />
|
||||
归纳到知识库
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</header>
|
||||
<TabsContent value="skills" className="mt-0 min-h-0 flex-1 overflow-auto p-6">
|
||||
<SkillSettingsPage embedded />
|
||||
</TabsContent>
|
||||
<TabsContent value="knowledge" className="mt-0 min-h-0 flex-1 overflow-hidden">
|
||||
<SkillKnowledgePanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { Navigate, Route, Routes, useParams } from "react-router-dom";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
@@ -78,6 +78,8 @@ import UsersPage from "./UsersPage";
|
||||
import LlmMetricsPage from "./LlmMetricsPage";
|
||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||
import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||
import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
|
||||
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
|
||||
import { ChatRuntime } from "./WorkspaceRoutes";
|
||||
|
||||
/**
|
||||
@@ -98,6 +100,11 @@ function StrategyChatThreadLayout() {
|
||||
);
|
||||
}
|
||||
|
||||
function StrategyProjectWorkbenchRoute() {
|
||||
const { project_id: projectId = "" } = useParams<{ project_id: string }>();
|
||||
return <StrategySelectionPage key={projectId} projectId={projectId} />;
|
||||
}
|
||||
|
||||
export default function StrategyRoutes() {
|
||||
const storedAuth = getStoredAuth();
|
||||
const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin";
|
||||
@@ -110,6 +117,20 @@ export default function StrategyRoutes() {
|
||||
<Route index element={<Navigate to="qa/general" replace />} />
|
||||
|
||||
<Route path="qa/general" element={<GeneralQA />} />
|
||||
<Route
|
||||
path="qa/projects"
|
||||
element={
|
||||
<StrategyProjectsPage basePath="/page/strategy/qa/projects" />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="qa/projects/:project_id"
|
||||
element={<StrategyProjectWorkbenchRoute />}
|
||||
/>
|
||||
<Route
|
||||
path="qa/strategy-selection"
|
||||
element={<Navigate to="/page/strategy/qa/projects" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="qa/expert-qa"
|
||||
element={
|
||||
|
||||
@@ -309,7 +309,24 @@ export default function TaskButtonsPage() {
|
||||
className="h-8 w-24"
|
||||
title="发给宿主的参数名(默认 id)"
|
||||
/>
|
||||
<span className="text-muted-foreground">= 任务id(数字)</span>
|
||||
<span className="text-muted-foreground">=</span>
|
||||
{/* 行动分析(xdfx):行动id 与任务id 不同,可选发哪种;其它业务固定任务id */}
|
||||
{tab === "xdfx" ? (
|
||||
<Select
|
||||
value={r.idKind}
|
||||
onValueChange={(v) => update(r.id, { idKind: v as TaskButton["idKind"] })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="task">任务id</SelectItem>
|
||||
<SelectItem value="action">行动id</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<span className="text-muted-foreground">任务id(数字)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-muted-foreground w-full text-[11px]">
|
||||
|
||||
@@ -1,85 +1,96 @@
|
||||
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
|
||||
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import {
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useParams,
|
||||
} from "react-router-dom";
|
||||
|
||||
import { getStoredAuth } from '@/core/auth';
|
||||
import { getAPIClient } from '@/core/api';
|
||||
import { isSentimentAgentVisible } from '@/core/sentiment-agent/config';
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
|
||||
import {
|
||||
resolveChatsIframeEntryRedirect,
|
||||
resolveStandardChatFallback,
|
||||
} from '@/core/page-layout/chats-iframe-route';
|
||||
import { isEmbeddedHost } from '@/core/embed/frame-context';
|
||||
} from "@/core/page-layout/chats-iframe-route";
|
||||
import { isEmbeddedHost } from "@/core/embed/frame-context";
|
||||
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
||||
import {
|
||||
CompactShortcutButtons,
|
||||
LayoutModeSwitcher,
|
||||
ThemeSwitcher,
|
||||
} from '@/components/workspace/appearance-controls';
|
||||
import { WorkspaceSidebar } from '@/components/workspace/workspace-sidebar';
|
||||
import { UserIdentityBadge } from '@/components/workspace/user-identity-badge';
|
||||
import { CommandPalette } from '@/components/workspace/command-palette';
|
||||
import { Toaster } from 'sonner';
|
||||
import { SubtasksProvider } from '@/core/tasks/context';
|
||||
import { ArtifactsProvider } from '@/components/workspace/artifacts';
|
||||
import { DocumentRewritePresentationProvider } from '@/core/document-rewrite/context';
|
||||
} from "@/components/workspace/appearance-controls";
|
||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||||
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
|
||||
import { CommandPalette } from "@/components/workspace/command-palette";
|
||||
import { Toaster } from "sonner";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
|
||||
|
||||
import { RouteBaseProvider } from '@/core/route-base';
|
||||
import { agentRefOfThread } from '@/core/threads/utils';
|
||||
import { RouteBaseProvider } from "@/core/route-base";
|
||||
import { agentRefOfThread } from "@/core/threads/utils";
|
||||
|
||||
import ChatsPage from './ChatsPage';
|
||||
import ChatPage from './ChatPage';
|
||||
import ArtifactLibraryPage from './ArtifactLibraryPage';
|
||||
import IframeChatPage from './IframeChatPage';
|
||||
import AgentsPage from './AgentsPage';
|
||||
import AgentChatPage from './AgentChatPage';
|
||||
import SentimentAgentChatPage from './SentimentAgentChatPage';
|
||||
import NewAgentPage from './NewAgentPage';
|
||||
import AdminAgentsPage from './AdminAgentsPage';
|
||||
import AdminScheduledTasksPage from './AdminScheduledTasksPage';
|
||||
import AdminSkillsPage from './AdminSkillsPage';
|
||||
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
|
||||
import ScheduledTasksPage from './ScheduledTasksPage';
|
||||
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
|
||||
import AssistantKnowledgePage from './AssistantKnowledgePage';
|
||||
import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage';
|
||||
import WeKnoraWikiPageView from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView';
|
||||
import MarkdownDocxEditorPage from './MarkdownDocxEditorPage';
|
||||
import LlmMetricsPage from './LlmMetricsPage';
|
||||
import CheckpointMigrationPage from './CheckpointMigrationPage';
|
||||
import MemoryPage from './MemoryPage';
|
||||
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
|
||||
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
|
||||
import ModelConcurrencySettingsPage from './ModelConcurrencySettingsPage';
|
||||
import ModelManagementPage from './ModelManagementPage';
|
||||
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
|
||||
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
|
||||
import SkillEvolutionPage from './SkillEvolutionPage';
|
||||
import SkillsPage from './SkillsPage';
|
||||
import UsersPage from './UsersPage';
|
||||
import AdminUserActivityPage from './AdminUserActivityPage';
|
||||
import AdminMemoryPage from './AdminMemoryPage';
|
||||
import AdminUserHubPage from './AdminUserHubPage';
|
||||
import TagsPage from './TagsPage';
|
||||
import PositionsPage from './PositionsPage';
|
||||
import PositionRolesPage from './PositionRolesPage';
|
||||
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';
|
||||
import PositionRoundtablePage from '@/position-roundtable/pages/PositionRoundtablePage';
|
||||
import EightBfRoundtablePage from '@/eightbf-roundtable/pages/EightBfRoundtablePage';
|
||||
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
|
||||
import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage';
|
||||
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
|
||||
import '@/roundtable-planning/styles/roundtable-planning.css';
|
||||
import { cn } from '@/lib/utils';
|
||||
import ChatsPage from "./ChatsPage";
|
||||
import ChatPage from "./ChatPage";
|
||||
import ArtifactLibraryPage from "./ArtifactLibraryPage";
|
||||
import IframeChatPage from "./IframeChatPage";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
import AgentChatPage from "./AgentChatPage";
|
||||
import SentimentAgentChatPage from "./SentimentAgentChatPage";
|
||||
import NewAgentPage from "./NewAgentPage";
|
||||
import AdminAgentsPage from "./AdminAgentsPage";
|
||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||
import AdminSkillsPage from "./AdminSkillsPage";
|
||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import LlmWikiKnowledgePage from "./LlmWikiKnowledgePage";
|
||||
import AssistantKnowledgePage from "./AssistantKnowledgePage";
|
||||
import WeKnoraWikiManagementPage from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage";
|
||||
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
|
||||
import WeKnoraPackageTransfersPage from "@/strategy-components/components/resource-management/llmwiki/WeKnoraPackageTransfersPage";
|
||||
import MarkdownDocxEditorPage from "./MarkdownDocxEditorPage";
|
||||
import LlmMetricsPage from "./LlmMetricsPage";
|
||||
import CheckpointMigrationPage from "./CheckpointMigrationPage";
|
||||
import MemoryPage from "./MemoryPage";
|
||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||
import ModelConcurrencySettingsPage from "./ModelConcurrencySettingsPage";
|
||||
import ModelManagementPage from "./ModelManagementPage";
|
||||
import PublishSquaresSettingsPage from "./PublishSquaresSettingsPage";
|
||||
import SkillCompressionSettingsPage from "./SkillCompressionSettingsPage";
|
||||
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||
import SkillsPage from "./SkillsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||
import AdminMemoryPage from "./AdminMemoryPage";
|
||||
import AdminUserHubPage from "./AdminUserHubPage";
|
||||
import TagsPage from "./TagsPage";
|
||||
import PositionsPage from "./PositionsPage";
|
||||
import PositionRolesPage from "./PositionRolesPage";
|
||||
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";
|
||||
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
||||
import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage";
|
||||
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
|
||||
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
|
||||
import DispatchChainPreviewPage from "@/roundtable-planning/pages/DispatchChainPreviewPage";
|
||||
import "@/roundtable-planning/styles/roundtable-planning.css";
|
||||
import ReportCollaborationPage, {
|
||||
ReportCollaborationSessionPage,
|
||||
} from "@/report-collaboration/pages/ReportCollaborationPage";
|
||||
import "@/report-collaboration/styles/report-collaboration.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function NotebookDetailPageWithKey() {
|
||||
const { notebookId } = useParams<{ notebookId: string }>();
|
||||
@@ -92,14 +103,37 @@ function NewAgentPageWithKey() {
|
||||
return <NewAgentPage key={thread_id} />;
|
||||
}
|
||||
|
||||
// 报告协作会话页:按 sessionId 重挂(旧 SSE/状态不串台)。
|
||||
function ReportCollaborationSessionRoute({
|
||||
layoutProps,
|
||||
}: {
|
||||
layoutProps: {
|
||||
pageSidebarCollapsed: boolean;
|
||||
pageSidebarHidden: boolean;
|
||||
workspaceSidebarHidden: boolean;
|
||||
headerHidden: boolean;
|
||||
compactActionsHidden: boolean;
|
||||
compactWorkspaceSidebar: boolean;
|
||||
pageSidebarDark: boolean;
|
||||
forceWorkspaceSidebarCollapsed: boolean;
|
||||
};
|
||||
}) {
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
return (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ReportCollaborationSessionPage key={sessionId} sessionId={sessionId ?? ""} />
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function shouldShowCompactTopActions(pathname: string): boolean {
|
||||
return (
|
||||
pathname === '/page/workspace/agents' ||
|
||||
pathname === '/page/workspace/skills' ||
|
||||
pathname === '/page/workspace/memory' ||
|
||||
pathname === '/page/workspace/library' ||
|
||||
pathname === '/page/workspace/knowledge' ||
|
||||
pathname.startsWith('/page/workspace/knowledge/')
|
||||
pathname === "/page/workspace/agents" ||
|
||||
pathname === "/page/workspace/skills" ||
|
||||
pathname === "/page/workspace/memory" ||
|
||||
pathname === "/page/workspace/library" ||
|
||||
pathname === "/page/workspace/knowledge" ||
|
||||
pathname.startsWith("/page/workspace/knowledge/")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,47 +191,52 @@ export function WorkspaceLayout({
|
||||
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
|
||||
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
|
||||
const pathname = useLocation().pathname;
|
||||
const isGeneralQa = pathname.startsWith('/page/workspace/chats');
|
||||
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
|
||||
const showCompactTopActions =
|
||||
headerHidden && !compactActionsHidden && shouldShowCompactTopActions(pathname);
|
||||
headerHidden &&
|
||||
!compactActionsHidden &&
|
||||
shouldShowCompactTopActions(pathname);
|
||||
|
||||
const pageSidebarWidth = pageSidebarHidden
|
||||
? '0rem'
|
||||
? "0rem"
|
||||
: pageSidebarDark
|
||||
? pageSidebarCollapsed
|
||||
? '0rem'
|
||||
: '118px'
|
||||
? "0rem"
|
||||
: "118px"
|
||||
: pageSidebarCollapsed
|
||||
? '4rem'
|
||||
: '16rem';
|
||||
? "4rem"
|
||||
: "16rem";
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
className={[
|
||||
'relative h-full w-full overflow-x-hidden',
|
||||
'[&_[data-slot=sidebar-container]]:absolute',
|
||||
'[&_[data-slot=sidebar-container]]:left-0',
|
||||
'[&_[data-slot=sidebar-container]]:top-0',
|
||||
].join(' ')}
|
||||
"relative h-full w-full overflow-x-hidden",
|
||||
"[&_[data-slot=sidebar-container]]:absolute",
|
||||
"[&_[data-slot=sidebar-container]]:left-0",
|
||||
"[&_[data-slot=sidebar-container]]:top-0",
|
||||
].join(" ")}
|
||||
style={
|
||||
{
|
||||
'--page-sidebar-width': pageSidebarWidth,
|
||||
'--header-height': headerHidden ? '0rem' : '4rem',
|
||||
"--page-sidebar-width": pageSidebarWidth,
|
||||
"--header-height": headerHidden ? "0rem" : "4rem",
|
||||
} as CSSProperties
|
||||
}
|
||||
// Agent and general-chat routes share this layout component. Keep their
|
||||
// sidebar state separate: otherwise React preserves the collapsed agent
|
||||
// state when a history item switches to `/chats/:thread_id`, while the
|
||||
// new page's header button is meant to control GeneralQaSidebar.
|
||||
key={`${String(compactWorkspaceSidebar)}:${isGeneralQa ? 'general' : 'workspace'}`}
|
||||
key={`${String(compactWorkspaceSidebar)}:${isGeneralQa ? "general" : "workspace"}`}
|
||||
data-page-sidebar-collapsed={pageSidebarCollapsed}
|
||||
defaultOpen={
|
||||
forceWorkspaceSidebarCollapsed
|
||||
? false
|
||||
: (defaultHistorySidebarOpen ?? (isGeneralQa || !compactWorkspaceSidebar))
|
||||
: (defaultHistorySidebarOpen ??
|
||||
(isGeneralQa || !compactWorkspaceSidebar))
|
||||
}
|
||||
open={forceWorkspaceSidebarCollapsed ? false : undefined}
|
||||
onOpenChange={forceWorkspaceSidebarCollapsed ? () => undefined : undefined}
|
||||
onOpenChange={
|
||||
forceWorkspaceSidebarCollapsed ? () => undefined : undefined
|
||||
}
|
||||
disableToggle={forceWorkspaceSidebarCollapsed}
|
||||
>
|
||||
{!workspaceSidebarHidden && (
|
||||
@@ -207,7 +246,7 @@ export function WorkspaceLayout({
|
||||
/>
|
||||
)}
|
||||
<SidebarInset
|
||||
className={cn('min-w-0', insetOverflowHidden && 'overflow-hidden')}
|
||||
className={cn("min-w-0", insetOverflowHidden && "overflow-hidden")}
|
||||
>
|
||||
{showCompactTopActions ? (
|
||||
<>
|
||||
@@ -244,9 +283,14 @@ export function ChatRuntime({ children }: { children: ReactNode }) {
|
||||
*/
|
||||
function NewChatContent() {
|
||||
const location = useLocation();
|
||||
const agentId = new URLSearchParams(location.search).get('agent');
|
||||
const agentId = new URLSearchParams(location.search).get("agent");
|
||||
if (agentId) {
|
||||
return <AgentChatPage key={`${agentId}:${location.key}`} embeddedAgentId={agentId} />;
|
||||
return (
|
||||
<AgentChatPage
|
||||
key={`${agentId}:${location.key}`}
|
||||
embeddedAgentId={agentId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ChatPage />;
|
||||
}
|
||||
@@ -393,8 +437,8 @@ function AgentAwareChatThreadContent() {
|
||||
};
|
||||
// Task-bound threads need their task host to preserve the purpose gate,
|
||||
// action partitioning, and other task-specific controls.
|
||||
const isTaskThread = typeof record.metadata?.taskId === 'string';
|
||||
setAgentId(isTaskThread ? null : agentRefOfThread(record) ?? null);
|
||||
const isTaskThread = typeof record.metadata?.taskId === "string";
|
||||
setAgentId(isTaskThread ? null : (agentRefOfThread(record) ?? null));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAgentId(null);
|
||||
@@ -537,11 +581,18 @@ export default function WorkspaceRoutes({
|
||||
/>
|
||||
<Route
|
||||
path="chats_iframe/new"
|
||||
element={<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam={false} />}
|
||||
element={
|
||||
<IframeChatEntryRoute
|
||||
layoutProps={layoutProps}
|
||||
hasThreadParam={false}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="chats_iframe/:thread_id"
|
||||
element={<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam />}
|
||||
element={
|
||||
<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents"
|
||||
@@ -575,7 +626,10 @@ export default function WorkspaceRoutes({
|
||||
path="agents/sentiment-analysis/chats"
|
||||
element={
|
||||
isSentimentAgentVisible() ? (
|
||||
<Navigate to="/page/workspace/agents/sentiment-analysis/chats/new" replace />
|
||||
<Navigate
|
||||
to="/page/workspace/agents/sentiment-analysis/chats/new"
|
||||
replace
|
||||
/>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/agents" replace />
|
||||
)
|
||||
@@ -605,7 +659,9 @@ export default function WorkspaceRoutes({
|
||||
<AgentChatPage
|
||||
showAgentList
|
||||
compactAppearanceActions={
|
||||
headerHidden && !compactActionsHidden ? <CompactAppearanceActions /> : undefined
|
||||
headerHidden && !compactActionsHidden ? (
|
||||
<CompactAppearanceActions />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</ChatRuntime>
|
||||
@@ -704,7 +760,9 @@ export default function WorkspaceRoutes({
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<RoundtablePlanningPage compactChrome={headerHidden && !compactActionsHidden} />
|
||||
<RoundtablePlanningPage
|
||||
compactChrome={headerHidden && !compactActionsHidden}
|
||||
/>
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
@@ -714,7 +772,9 @@ export default function WorkspaceRoutes({
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<EightBfRoundtablePage compactChrome={headerHidden && !compactActionsHidden} />
|
||||
<EightBfRoundtablePage
|
||||
compactChrome={headerHidden && !compactActionsHidden}
|
||||
/>
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
@@ -724,7 +784,9 @@ export default function WorkspaceRoutes({
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<PositionRoundtablePage compactChrome={headerHidden && !compactActionsHidden} />
|
||||
<PositionRoundtablePage
|
||||
compactChrome={headerHidden && !compactActionsHidden}
|
||||
/>
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
@@ -745,6 +807,20 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
{/* AgentScope 报告协作工作台:左对话(复用 MessageList)/右画布的独立新页面,
|
||||
不与旧多智能体会商共享状态。key 绑定 sessionId——切换会话重挂干净的页面。 */}
|
||||
<Route
|
||||
path="report-collaboration"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ReportCollaborationPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="report-collaboration/:sessionId"
|
||||
element={<ReportCollaborationSessionRoute layoutProps={layoutProps} />}
|
||||
/>
|
||||
{/* Phase 7 临时预览入口:派活链路流程图弹窗(假数据自测,接通后台后可删) */}
|
||||
<Route
|
||||
path="roundtable/chain-preview"
|
||||
@@ -794,6 +870,14 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="knowledge/:noteId/packages"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<WeKnoraPackageTransfersPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="knowledge/markdown-editor"
|
||||
element={
|
||||
@@ -818,7 +902,9 @@ export default function WorkspaceRoutes({
|
||||
path="scheduled/tasks"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ScheduledTasksPage compactChrome={headerHidden && !compactActionsHidden} />
|
||||
<ScheduledTasksPage
|
||||
compactChrome={headerHidden && !compactActionsHidden}
|
||||
/>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
@@ -834,7 +920,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="admin"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps} pageSidebarHidden>
|
||||
<AdminUserHubPage />
|
||||
</WorkspaceLayout>
|
||||
@@ -859,7 +945,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="admin/scheduled-tasks"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminScheduledTasksPage />
|
||||
</WorkspaceLayout>
|
||||
@@ -871,7 +957,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="admin/agents"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminAgentsPage />
|
||||
</WorkspaceLayout>
|
||||
@@ -883,7 +969,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="admin/skills"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminSkillsPage />
|
||||
</WorkspaceLayout>
|
||||
@@ -903,7 +989,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="admin/plugins"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PluginManagementPage />
|
||||
</WorkspaceLayout>
|
||||
@@ -915,7 +1001,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="admin/positions"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PositionsPage />
|
||||
</WorkspaceLayout>
|
||||
@@ -927,7 +1013,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="admin/position-roles"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PositionRolesPage />
|
||||
</WorkspaceLayout>
|
||||
@@ -939,7 +1025,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="settings/recommended-questions"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<RecommendedQuestionsPage />
|
||||
</WorkspaceLayout>
|
||||
@@ -951,7 +1037,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="settings/prompt-prefix"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PromptPrefixSettingsPage />
|
||||
</WorkspaceLayout>
|
||||
@@ -963,7 +1049,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="settings/private-square"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PrivateSquareSettingsPage />
|
||||
</WorkspaceLayout>
|
||||
@@ -975,7 +1061,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="admin/models"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ModelManagementPage />
|
||||
</WorkspaceLayout>
|
||||
@@ -987,7 +1073,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="settings/model-concurrency"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ModelConcurrencySettingsPage />
|
||||
</WorkspaceLayout>
|
||||
@@ -999,7 +1085,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="settings/publish-squares"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PublishSquaresSettingsPage />
|
||||
</WorkspaceLayout>
|
||||
@@ -1011,7 +1097,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="settings/skill-compression"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<SkillCompressionSettingsPage />
|
||||
</WorkspaceLayout>
|
||||
@@ -1023,7 +1109,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<LlmMetricsPage />
|
||||
</WorkspaceLayout>
|
||||
@@ -1035,7 +1121,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="settings/checkpoint-migration"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<CheckpointMigrationPage />
|
||||
</WorkspaceLayout>
|
||||
|
||||
@@ -99,6 +99,7 @@ import {
|
||||
extractToolResultPayload,
|
||||
hasToolCalls,
|
||||
} from "@/core/messages/utils";
|
||||
import { normalizeSearchResults } from "@/core/messages/search-results";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import {
|
||||
type DeepResearchSession,
|
||||
@@ -273,6 +274,23 @@ function assembleStreamingReport(targets: Record<string, { content: string; vers
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
const REPORT_REFUSAL_PATTERN = /(?:无法|不能|未能|不可以).{0,24}?(?:生成|撰写|产出|输出).{0,12}?报告/s;
|
||||
|
||||
function isRefusalReportText(value: string): boolean {
|
||||
const text = value || "";
|
||||
const compact = text.replace(/\s+/g, "");
|
||||
// Empty means the writer has not emitted Markdown yet (thinking / connecting).
|
||||
// Treating that as a refusal opened the sandbox on an error card while the
|
||||
// model was still working.
|
||||
if (!compact) return false;
|
||||
const headings = text.match(/^#{1,6}\s+\S+/gm) ?? [];
|
||||
// A structured report may discuss evidence gaps. Only treat a short,
|
||||
// unstructured refusal as “no report”, otherwise the zero-source fallback
|
||||
// is hidden behind the diagnostic card and looks like writing failed.
|
||||
if (headings.length >= 2) return false;
|
||||
return REPORT_REFUSAL_PATTERN.test(compact);
|
||||
}
|
||||
|
||||
function researchExportBaseName(session: DeepResearchSession): string {
|
||||
const title = (session.title || session.query || "研究报告")
|
||||
.replace(/[\\/:*?"<>|]/g, "-")
|
||||
@@ -416,6 +434,100 @@ function countCollectorMaterials(messages: readonly Message[] | undefined): numb
|
||||
return unique.size;
|
||||
}
|
||||
|
||||
const COLLECTOR_PERSIST_LIMIT = 80;
|
||||
const WRITE_FALLBACK_MATERIAL_LIMIT = 20;
|
||||
const SKIP_COLLECTOR_PERSIST_TOOLS = new Set(["ask_clarification", "write_todos"]);
|
||||
|
||||
function collectorMaterialKey(item: Record<string, unknown>): string {
|
||||
const text = (value: unknown) => typeof value === "string" ? value.trim() : "";
|
||||
return text(item.id) || text(item.url) || `${text(item.title)}\n${text(item.raw_content).slice(0, 240)}`;
|
||||
}
|
||||
|
||||
function isCompleteToolPayload(message: Message): boolean {
|
||||
const payload = extractToolResultPayload(message);
|
||||
if (payload == null) return false;
|
||||
if (typeof payload === "string") {
|
||||
const trimmed = payload.trim();
|
||||
if (!trimmed) return false;
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return trimmed.length > 20;
|
||||
}
|
||||
return typeof payload === "object";
|
||||
}
|
||||
|
||||
/** Freeze a compact evidence pool for a worker-independent report job. */
|
||||
function snapshotCollectorMaterials(
|
||||
messages: readonly Message[] | undefined,
|
||||
options?: { limit?: number; includeSummary?: boolean },
|
||||
): Record<string, unknown>[] {
|
||||
if (!messages?.length) return [];
|
||||
const limit = options?.limit ?? COLLECTOR_PERSIST_LIMIT;
|
||||
const frozen: Record<string, unknown>[] = [];
|
||||
const seen = new Set<string>();
|
||||
const text = (value: unknown) => typeof value === "string" ? value.trim() : "";
|
||||
const addNormalized = (item: ReturnType<typeof normalizeSearchResults>[number], toolName: string) => {
|
||||
const url = text(item.url);
|
||||
const id = text(item.id) || text(item.recUuid) || text(item.uuid) || text(item.docId);
|
||||
const title = text(item.title) || `检索结果 ${frozen.length + 1}`;
|
||||
const content = text(item.content) || text(item.snippet);
|
||||
if (!url && !id && !content) return;
|
||||
const key = id || url || `${title}\n${content.slice(0, 240)}`;
|
||||
if (seen.has(key) || frozen.length >= limit) return;
|
||||
seen.add(key);
|
||||
frozen.push({
|
||||
id: id || undefined,
|
||||
title: title.slice(0, 500),
|
||||
url: url.slice(0, 2_000) || undefined,
|
||||
raw_content: content.slice(0, 6_000),
|
||||
snippet: (text(item.snippet) || content).slice(0, 500) || undefined,
|
||||
source: text(item.source) || toolName || "collector",
|
||||
source_type: toolName === "web_search" ? "web" : "other",
|
||||
published_at: text(item.published_date) || text(item.publishedDate) || text(item.date) || undefined,
|
||||
rec_uuid: text(item.recUuid) || text(item.uuid) || undefined,
|
||||
});
|
||||
};
|
||||
for (const message of messages) {
|
||||
if (message.type === "tool" && message.name && SKIP_COLLECTOR_PERSIST_TOOLS.has(message.name)) {
|
||||
continue;
|
||||
}
|
||||
const toolName = message.name || (message.type === "tool" ? "collector_tool" : "");
|
||||
const payload = message.type === "tool" || message.type === "ai"
|
||||
? extractToolResultPayload(message)
|
||||
: undefined;
|
||||
const rows = normalizeSearchResults(
|
||||
payload && typeof payload === "object" && !Array.isArray(payload)
|
||||
? payload
|
||||
: typeof payload === "string"
|
||||
? payload
|
||||
: undefined,
|
||||
);
|
||||
for (const row of rows) addNormalized(row, toolName || "collector");
|
||||
}
|
||||
if (!frozen.length && options?.includeSummary !== false) {
|
||||
const summary = [...messages].reverse().find(
|
||||
(message) => message.type === "ai" && extractContentFromMessage(message).trim(),
|
||||
);
|
||||
const content = summary ? extractContentFromMessage(summary).trim() : "";
|
||||
if (content) {
|
||||
frozen.push({
|
||||
title: "收集员小结",
|
||||
raw_content: content.slice(0, 6_000),
|
||||
snippet: content.slice(0, 500),
|
||||
source: "collector",
|
||||
source_type: "other",
|
||||
});
|
||||
}
|
||||
}
|
||||
return frozen;
|
||||
}
|
||||
|
||||
function countEventMaterials(events: ProgressEvent[]): number {
|
||||
const unique = new Set<string>();
|
||||
for (const event of events) {
|
||||
@@ -512,9 +624,18 @@ function DeepResearchWorkbenchInner() {
|
||||
const replayedTraceJobs = useRef(new Set<string>());
|
||||
const pendingReportEvents = useRef<ProgressEvent[]>([]);
|
||||
const reportAnimationFrame = useRef<number | null>(null);
|
||||
const boundStreamJobRef = useRef<string | null>(null);
|
||||
const openReportRef = useRef<() => void>(() => {});
|
||||
const refetchLiveJobRef = useRef<() => void>(() => {});
|
||||
const inlineGenerationSessionRef = useRef<DeepResearchSession | null>(null);
|
||||
// Chat-collection flow bookkeeping.
|
||||
const pendingInitialQuery = useRef<string | null>(null);
|
||||
const finishedTurnKey = useRef<string | null>(null);
|
||||
const persistedCollectorKey = useRef("");
|
||||
const persistedCollectorToolIds = useRef(new Set<string>());
|
||||
const persistedCollectorMaterialKeys = useRef(new Set<string>());
|
||||
const collectorPersistChain = useRef(Promise.resolve());
|
||||
const collectorSessionIdRef = useRef<string | null>(null);
|
||||
const followUpAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const refetchSession = sessionQuery.refetch;
|
||||
@@ -523,6 +644,22 @@ function DeepResearchWorkbenchInner() {
|
||||
const refetchReportVariants = reportVariants.refetch;
|
||||
|
||||
const session = sessionQuery.data;
|
||||
const loggedSessionDiagnosticKey = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
const diagnostics = session.runtimeDiagnostics;
|
||||
if (!session.error && !diagnostics) return;
|
||||
const key = `${session.id}:${session.activeJobId ?? session.usage?.lastJobId ?? ""}:${session.error ?? ""}`;
|
||||
if (loggedSessionDiagnosticKey.current === key) return;
|
||||
loggedSessionDiagnosticKey.current = key;
|
||||
console.warn("[深度研究] session_runtime_diagnostic", {
|
||||
sessionId: session.id,
|
||||
status: session.status,
|
||||
error: session.error,
|
||||
activeJobId: session.activeJobId,
|
||||
runtimeDiagnostics: diagnostics,
|
||||
});
|
||||
}, [session]);
|
||||
const generatedReportVariants = useMemo(() => {
|
||||
const byId = new Map((reportVariants.data ?? []).map((item) => [item.id, item]));
|
||||
if (inlineGeneratedReport && session && inlineGeneratedReport.parentSessionId === session.id) {
|
||||
@@ -585,7 +722,7 @@ function DeepResearchWorkbenchInner() {
|
||||
: "";
|
||||
// 全文改写启动后,右侧只渲染临时草稿:首个 token 到达前保持空白,
|
||||
// 不把旧报告混在新稿前面。真实 report.md 仍只在后台校验 + CAS 成功后替换。
|
||||
const displayedReport = reportDocumentRewrite.isActive
|
||||
const rawDisplayedReport = reportDocumentRewrite.isActive
|
||||
? reportDocumentRewrite.run.draft
|
||||
: reportRewritePreview
|
||||
|| (viewingLiveGeneration
|
||||
@@ -593,6 +730,65 @@ function DeepResearchWorkbenchInner() {
|
||||
: viewingParentReport
|
||||
? (session?.reportMarkdown || "")
|
||||
: sandboxVariantSession?.reportMarkdown || "");
|
||||
// Never hide a backend response. Hiding refusal prose also removed the
|
||||
// virtual report file and its sandbox-open trigger, leaving operators with
|
||||
// neither a report nor an error. The backend now replaces refusal output at
|
||||
// its final persistence gate; this client-side signal remains visible when
|
||||
// a worker is stale or a new provider wording escapes that guard.
|
||||
const refusalReportDetected = isRefusalReportText(rawDisplayedReport);
|
||||
const hasUsableDisplayedReport = Boolean(rawDisplayedReport.trim()) && !refusalReportDetected;
|
||||
const streamStillOpen = Boolean(run.jobId && run.phase !== "done" && run.phase !== "failed");
|
||||
const liveWriting = Boolean(
|
||||
isRunning
|
||||
|| isAwaitingReview
|
||||
|| reportDocumentRewrite.isActive
|
||||
|| streamStillOpen
|
||||
|| (viewingLiveGeneration && run.phase !== "done"),
|
||||
);
|
||||
// Never replace a live write with the operator diagnostic. An empty sandbox
|
||||
// during thinking is normal; the JSON dump is only for a finished run that
|
||||
// still has no usable Markdown.
|
||||
const repairOnlyError = typeof session?.error === "string"
|
||||
&& session.error.includes("session_read_repair");
|
||||
const showRunDiagnostic = Boolean(
|
||||
!liveWriting && !hasUsableDisplayedReport && (
|
||||
refusalReportDetected
|
||||
|| session?.status === "failed"
|
||||
|| (Boolean(session?.error) && !repairOnlyError)
|
||||
),
|
||||
);
|
||||
const runtimeDiagnosticText = JSON.stringify(
|
||||
session?.runtimeDiagnostics ?? {
|
||||
revision: "frontend-fallback-diagnostic",
|
||||
session: {
|
||||
id: session?.id,
|
||||
status: session?.status,
|
||||
sourceCount: session?.sourceCount ?? 0,
|
||||
activeJobId: session?.activeJobId,
|
||||
},
|
||||
error: session?.error ?? "报告正文被识别为拒绝型输出",
|
||||
invalidOutputPreview: refusalReportDetected ? rawDisplayedReport.slice(0, 1_200) : undefined,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const diagnosticMarkdown = showRunDiagnostic
|
||||
? [
|
||||
"## 系统运行说明",
|
||||
"",
|
||||
"> 本次报告生成链路出现异常。系统不能据此判断为‘没有收集到素材’,已有素材不会被删除。请将下面的诊断信息复制给管理员排查。",
|
||||
"",
|
||||
"```json",
|
||||
runtimeDiagnosticText,
|
||||
"```",
|
||||
].join("\n")
|
||||
: "";
|
||||
// Invalid refusal prose (or a failed run with no report) is replaced instead
|
||||
// of repeated to the user. Any usable normal/fallback/partial report is shown
|
||||
// as-is and never receives diagnostic text.
|
||||
const displayedReport = showRunDiagnostic
|
||||
? `# 报告生成异常\n\n${diagnosticMarkdown}`
|
||||
: rawDisplayedReport;
|
||||
|
||||
const updateFullReportRewritePresentation = useCallback(
|
||||
(presentationId: string | null, event: DocumentRewriteStreamEvent) => {
|
||||
@@ -676,10 +872,19 @@ function DeepResearchWorkbenchInner() {
|
||||
report: displayedReport,
|
||||
writing: sandboxWriting,
|
||||
onFirstReportContent: handleFirstReportContent,
|
||||
// Open only after thinking has finished and the first write token exists.
|
||||
// Thinking stays on the left progress card; an empty writing-phase stub
|
||||
// must not steal the right panel.
|
||||
activity: Boolean(run.report.trim()),
|
||||
// Open on the first visible write, including first-generation reports and
|
||||
// a new sibling. Stay put if the user has already switched to another card.
|
||||
autoOpen: viewingLiveGeneration || reportDocumentRewrite.isActive,
|
||||
});
|
||||
openReportRef.current = openReport;
|
||||
inlineGenerationSessionRef.current = inlineGenerationSession;
|
||||
refetchLiveJobRef.current = () => {
|
||||
void Promise.all([refetchSession(), refetchSessions(), refetchSources(), refetchReportVariants()]);
|
||||
};
|
||||
const sandboxVisible = sandboxOpen && Boolean(
|
||||
displayedReport || isRunning || isAwaitingReview || reportDocumentRewrite.isActive || isGeneratingNewReport,
|
||||
);
|
||||
@@ -688,6 +893,13 @@ function DeepResearchWorkbenchInner() {
|
||||
openReport();
|
||||
}, [openReport]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewingLiveGeneration && !reportDocumentRewrite.isActive) return;
|
||||
if (!run.report.trim()) return;
|
||||
setSandboxOpen(true);
|
||||
openReport();
|
||||
}, [openReport, reportDocumentRewrite.isActive, run.report, viewingLiveGeneration]);
|
||||
|
||||
// ArtifactFileList invokes its handler synchronously. Switch the virtual
|
||||
// sandbox file first, then open it from an effect once useReportSandbox has
|
||||
// rebuilt its URL for the generated report session.
|
||||
@@ -986,6 +1198,66 @@ function DeepResearchWorkbenchInner() {
|
||||
session,
|
||||
]);
|
||||
|
||||
// Persist each completed search as it lands in the step list, instead of
|
||||
// waiting until the whole collection turn finishes and writing 80 rows at once.
|
||||
useEffect(() => {
|
||||
collectorSessionIdRef.current = session?.id ?? null;
|
||||
persistedCollectorToolIds.current = new Set();
|
||||
persistedCollectorMaterialKeys.current = new Set();
|
||||
persistedCollectorKey.current = "";
|
||||
collectorPersistChain.current = Promise.resolve();
|
||||
}, [session?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isChatSession || !session?.id) return;
|
||||
if (session.status !== "collecting" && session.status !== "draft") return;
|
||||
const remaining = COLLECTOR_PERSIST_LIMIT - persistedCollectorMaterialKeys.current.size;
|
||||
if (remaining <= 0) return;
|
||||
const pending: Record<string, unknown>[] = [];
|
||||
const claimedToolIds: string[] = [];
|
||||
const claimedKeys: string[] = [];
|
||||
for (const message of collectorThread.messages ?? []) {
|
||||
if (message.type !== "tool" || !message.id) continue;
|
||||
if (message.name && SKIP_COLLECTOR_PERSIST_TOOLS.has(message.name)) continue;
|
||||
if (persistedCollectorToolIds.current.has(message.id)) continue;
|
||||
if (!isCompleteToolPayload(message)) continue;
|
||||
const rows = snapshotCollectorMaterials([message], {
|
||||
limit: remaining - pending.length,
|
||||
includeSummary: false,
|
||||
});
|
||||
persistedCollectorToolIds.current.add(message.id);
|
||||
claimedToolIds.push(message.id);
|
||||
for (const row of rows) {
|
||||
const key = collectorMaterialKey(row);
|
||||
if (!key || persistedCollectorMaterialKeys.current.has(key)) continue;
|
||||
persistedCollectorMaterialKeys.current.add(key);
|
||||
claimedKeys.push(key);
|
||||
pending.push(row);
|
||||
if (pending.length >= remaining) break;
|
||||
}
|
||||
if (pending.length >= remaining) break;
|
||||
}
|
||||
if (!pending.length) return;
|
||||
const sessionId = session.id;
|
||||
collectorPersistChain.current = collectorPersistChain.current
|
||||
.then(() => actions.persistCollectorMaterials.mutateAsync({
|
||||
sessionId,
|
||||
collectorMaterials: pending,
|
||||
quiet: true,
|
||||
}))
|
||||
.catch(() => {
|
||||
if (collectorSessionIdRef.current !== sessionId) return;
|
||||
for (const toolId of claimedToolIds) persistedCollectorToolIds.current.delete(toolId);
|
||||
for (const key of claimedKeys) persistedCollectorMaterialKeys.current.delete(key);
|
||||
});
|
||||
}, [
|
||||
actions.persistCollectorMaterials,
|
||||
collectorThread.messages,
|
||||
isChatSession,
|
||||
session?.id,
|
||||
session?.status,
|
||||
]);
|
||||
|
||||
// Turn finished without a pending clarification → surface the config card.
|
||||
// Requiring a non-human tail closes the window between the optimistic user
|
||||
// message and the run flag flipping, where the turn would look "ended".
|
||||
@@ -1002,7 +1274,20 @@ function DeepResearchWorkbenchInner() {
|
||||
if (finishedTurnKey.current === key) return;
|
||||
finishedTurnKey.current = key;
|
||||
if (session) {
|
||||
void actions.updateStatus.mutate({ sessionId: session.id, status: "awaiting_report" });
|
||||
const leftover = snapshotCollectorMaterials(collectorThread.messages, {
|
||||
limit: COLLECTOR_PERSIST_LIMIT,
|
||||
}).filter((item) => {
|
||||
const materialKey = collectorMaterialKey(item);
|
||||
if (!materialKey || persistedCollectorMaterialKeys.current.has(materialKey)) return false;
|
||||
persistedCollectorMaterialKeys.current.add(materialKey);
|
||||
return true;
|
||||
});
|
||||
persistedCollectorKey.current = `${session.id}:${persistedCollectorMaterialKeys.current.size}`;
|
||||
void actions.updateStatus.mutate({
|
||||
sessionId: session.id,
|
||||
status: "awaiting_report",
|
||||
...(leftover.length ? { collectorMaterials: leftover } : {}),
|
||||
});
|
||||
}
|
||||
}, [
|
||||
actions.updateStatus,
|
||||
@@ -1010,10 +1295,45 @@ function DeepResearchWorkbenchInner() {
|
||||
collectorPendingClarification,
|
||||
collectorStreaming,
|
||||
collectorTailIsHuman,
|
||||
collectorThread.messages,
|
||||
isChatSession,
|
||||
session,
|
||||
]);
|
||||
|
||||
// After a refresh the collector messages reload from the thread. Persist
|
||||
// anything the incremental writer missed so 「选择结构再生成」 still has rows.
|
||||
useEffect(() => {
|
||||
if (!isChatSession || !session?.id) return;
|
||||
if (session.status !== "awaiting_report") return;
|
||||
const leftover = snapshotCollectorMaterials(collectorThread.messages, {
|
||||
limit: COLLECTOR_PERSIST_LIMIT,
|
||||
}).filter((item) => {
|
||||
const materialKey = collectorMaterialKey(item);
|
||||
if (!materialKey || persistedCollectorMaterialKeys.current.has(materialKey)) return false;
|
||||
persistedCollectorMaterialKeys.current.add(materialKey);
|
||||
return true;
|
||||
});
|
||||
if (!leftover.length) return;
|
||||
const persistKey = `${session.id}:${persistedCollectorMaterialKeys.current.size}`;
|
||||
if (persistedCollectorKey.current === persistKey) return;
|
||||
persistedCollectorKey.current = persistKey;
|
||||
void actions.persistCollectorMaterials.mutateAsync({
|
||||
sessionId: session.id,
|
||||
collectorMaterials: leftover,
|
||||
}).catch(() => {
|
||||
persistedCollectorKey.current = "";
|
||||
for (const item of leftover) {
|
||||
persistedCollectorMaterialKeys.current.delete(collectorMaterialKey(item));
|
||||
}
|
||||
});
|
||||
}, [
|
||||
actions.persistCollectorMaterials,
|
||||
collectorThread.messages,
|
||||
isChatSession,
|
||||
session?.id,
|
||||
session?.status,
|
||||
]);
|
||||
|
||||
const collectedMaterialCount = useMemo(
|
||||
() => countCollectorMaterials(collectorThread.messages),
|
||||
[collectorThread.messages],
|
||||
@@ -1267,18 +1587,21 @@ function DeepResearchWorkbenchInner() {
|
||||
if (!streamJobId || (!isRunning && !isAwaitingReview && !replayingTerminalTrace)) return;
|
||||
if (replayingTerminalTrace && replayedTraceJobs.current.has(streamJobId)) return;
|
||||
if (replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId);
|
||||
seenEventKeys.current = new Set();
|
||||
liveEventSerial.current = 0;
|
||||
pendingReportEvents.current = [];
|
||||
setRun({ ...EMPTY_RUN, jobId: streamJobId });
|
||||
// Keep the sandbox closed until a report delta is available. `useReportSandbox`
|
||||
// opens it at that first visible write, rather than during model thinking
|
||||
// or after the report has already finished. If the user has already opened
|
||||
// another file card, leave that view alone.
|
||||
const keepCurrentSandbox = Boolean(
|
||||
inlineGenerationSession && sandboxSessionId !== inlineGenerationSession.id,
|
||||
inlineGenerationSessionRef.current && sandboxSessionId !== inlineGenerationSessionRef.current.id,
|
||||
);
|
||||
if (!replayingTerminalTrace && !keepCurrentSandbox) setSandboxOpen(false);
|
||||
const jobChanged = boundStreamJobRef.current !== streamJobId;
|
||||
if (jobChanged) {
|
||||
boundStreamJobRef.current = streamJobId;
|
||||
seenEventKeys.current = new Set();
|
||||
liveEventSerial.current = 0;
|
||||
pendingReportEvents.current = [];
|
||||
setRun({ ...EMPTY_RUN, jobId: streamJobId });
|
||||
// Only close the sandbox when a *new* job starts. Reconnecting the same
|
||||
// write stream used to wipe the live draft and flip auto-open off, so
|
||||
// thinking could finish while the right panel stayed closed.
|
||||
if (!replayingTerminalTrace && !keepCurrentSandbox) setSandboxOpen(false);
|
||||
}
|
||||
|
||||
const flushReportEvents = () => {
|
||||
reportAnimationFrame.current = null;
|
||||
@@ -1329,6 +1652,7 @@ function DeepResearchWorkbenchInner() {
|
||||
continue;
|
||||
}
|
||||
const version = typeof event.payload.reportVersion === "number" ? event.payload.reportVersion : reportVersion;
|
||||
if (version < reportVersion) continue;
|
||||
const target = typeof event.payload.target === "string" ? event.payload.target : "report";
|
||||
const currentTarget = reportTargets[target];
|
||||
if (!currentTarget || version >= currentTarget.version) {
|
||||
@@ -1353,6 +1677,13 @@ function DeepResearchWorkbenchInner() {
|
||||
: eventKey(event);
|
||||
if (seenEventKeys.current.has(key)) return;
|
||||
seenEventKeys.current.add(key);
|
||||
const firstWriteToken = (
|
||||
event.type === "report_delta" || event.type === "report_chunk"
|
||||
) && typeof event.payload?.delta === "string" && event.payload.delta.trim();
|
||||
if (firstWriteToken && !keepCurrentSandbox) {
|
||||
setSandboxOpen(true);
|
||||
openReportRef.current();
|
||||
}
|
||||
if (
|
||||
event.type === "plan_delta" ||
|
||||
event.type === "plan_reasoning" ||
|
||||
@@ -1385,8 +1716,9 @@ function DeepResearchWorkbenchInner() {
|
||||
},
|
||||
onTerminal: () => {
|
||||
if (!replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId);
|
||||
if (inlineGenerationSession) {
|
||||
void getResearchSession(inlineGenerationSession.id).then((completed) => {
|
||||
const liveInline = inlineGenerationSessionRef.current;
|
||||
if (liveInline) {
|
||||
void getResearchSession(liveInline.id).then((completed) => {
|
||||
setInlineGeneratedReport((current) => current?.session.id === completed.id
|
||||
? { ...current, session: completed }
|
||||
: current);
|
||||
@@ -1395,7 +1727,7 @@ function DeepResearchWorkbenchInner() {
|
||||
console.error("刷新新报告状态失败:", error);
|
||||
});
|
||||
}
|
||||
void Promise.all([refetchSession(), refetchSessions(), refetchSources(), refetchReportVariants()]);
|
||||
refetchLiveJobRef.current();
|
||||
},
|
||||
onError: (detail) => setRun((current) => current.jobId === streamJobId ? { ...current, error: detail } : current),
|
||||
},
|
||||
@@ -1409,15 +1741,25 @@ function DeepResearchWorkbenchInner() {
|
||||
}
|
||||
pendingReportEvents.current = [];
|
||||
};
|
||||
}, [activeJobId, inlineGenerationSession, isAwaitingReview, isRunning, refetchReportVariants, refetchSession, refetchSessions, refetchSources, sandboxSessionId, session?.status, streamJobId]);
|
||||
}, [activeJobId, isAwaitingReview, isRunning, sandboxSessionId, session?.status, streamJobId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.reportMarkdown) return;
|
||||
// A background regeneration writes into `run` for the child job. Do not
|
||||
// copy the parent markdown over that live stream.
|
||||
if (inlineGenerationSession && inlineGenerationSession.id !== session.id) return;
|
||||
setRun((current) => current.jobId === streamJobId ? { ...current, report: session.reportMarkdown! } : current);
|
||||
}, [inlineGenerationSession, session?.id, session?.reportMarkdown, streamJobId]);
|
||||
// A new write must start from a blank sandbox. Copying the previous
|
||||
// durable report into `run` opened the panel with stale text, then the
|
||||
// model only thought (summary / reasoning) and the sandbox never grew.
|
||||
if (isRunning || isAwaitingReview) return;
|
||||
const lastJobId = typeof session.usage?.lastJobId === "string" ? session.usage.lastJobId : null;
|
||||
setRun((current) => {
|
||||
if (current.jobId !== streamJobId) return current;
|
||||
if (current.jobId && lastJobId !== current.jobId) return current;
|
||||
if (current.report === session.reportMarkdown) return current;
|
||||
return { ...current, report: session.reportMarkdown! };
|
||||
});
|
||||
}, [inlineGenerationSession, isAwaitingReview, isRunning, session?.id, session?.reportMarkdown, session?.usage?.lastJobId, streamJobId]);
|
||||
|
||||
// ── actions ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1435,6 +1777,10 @@ function DeepResearchWorkbenchInner() {
|
||||
setFollowUpStream(null);
|
||||
pendingInitialQuery.current = null;
|
||||
finishedTurnKey.current = null;
|
||||
persistedCollectorKey.current = "";
|
||||
persistedCollectorToolIds.current = new Set();
|
||||
persistedCollectorMaterialKeys.current = new Set();
|
||||
collectorPersistChain.current = Promise.resolve();
|
||||
}, []);
|
||||
|
||||
// 历史报告不抢占对话区域:切换研究记录时总是先折叠右侧沙箱;用户可通过
|
||||
@@ -1484,6 +1830,10 @@ function DeepResearchWorkbenchInner() {
|
||||
});
|
||||
pendingInitialQuery.current = query;
|
||||
finishedTurnKey.current = null;
|
||||
persistedCollectorKey.current = "";
|
||||
persistedCollectorToolIds.current = new Set();
|
||||
persistedCollectorMaterialKeys.current = new Set();
|
||||
collectorPersistChain.current = Promise.resolve();
|
||||
setSelectedId(created.id);
|
||||
setSandboxOpen(false);
|
||||
} catch (error) {
|
||||
@@ -1800,10 +2150,16 @@ function DeepResearchWorkbenchInner() {
|
||||
if (!activeJobId) return;
|
||||
try {
|
||||
await actions.cancelJob.mutateAsync(activeJobId);
|
||||
toast.success("已请求停止研究");
|
||||
toast.success("已停止撰写");
|
||||
await Promise.all([refetchSession(), refetchSessions()]);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "停止研究失败");
|
||||
const message = error instanceof Error ? error.message : "停止研究失败";
|
||||
if (/not in a cancellable state/i.test(message) || /409/.test(message)) {
|
||||
toast.success("已停止撰写");
|
||||
await Promise.all([refetchSession(), refetchSessions()]);
|
||||
return;
|
||||
}
|
||||
toast.error(message);
|
||||
}
|
||||
}, [actions.cancelJob, activeJobId, refetchSession, refetchSessions]);
|
||||
|
||||
@@ -1841,14 +2197,23 @@ function DeepResearchWorkbenchInner() {
|
||||
const handleReopenReportCard = useCallback(async () => {
|
||||
if (!session) return;
|
||||
try {
|
||||
await actions.updateStatus.mutateAsync({ sessionId: session.id, status: "awaiting_report" });
|
||||
await actions.updateStatus.mutateAsync({
|
||||
sessionId: session.id,
|
||||
status: "awaiting_report",
|
||||
collectorMaterials: snapshotCollectorMaterials(collectorThread.messages, {
|
||||
limit: COLLECTOR_PERSIST_LIMIT,
|
||||
}),
|
||||
});
|
||||
await refetchSession();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "打开报告配置失败");
|
||||
}
|
||||
}, [actions.updateStatus, refetchSession, session]);
|
||||
}, [actions.updateStatus, collectorThread.messages, refetchSession, session]);
|
||||
|
||||
const handleConfirmReportCard = useCallback(async (config: Record<string, unknown>) => {
|
||||
const handleConfirmReportCard = useCallback(async (
|
||||
config: Record<string, unknown>,
|
||||
extras?: { writeWithoutMaterials?: boolean },
|
||||
) => {
|
||||
if (!session) return;
|
||||
// Keep the composer's explicit choice authoritative when the collection
|
||||
// phase turns into a report-writing job.
|
||||
@@ -1856,17 +2221,47 @@ function DeepResearchWorkbenchInner() {
|
||||
? { fast_model: selectedModel, smart_model: selectedModel, strategic_model: selectedModel }
|
||||
: {};
|
||||
try {
|
||||
await actions.startJob.mutateAsync({
|
||||
const writeWithoutMaterials = extras?.writeWithoutMaterials === true;
|
||||
const collectorMaterials = writeWithoutMaterials
|
||||
? []
|
||||
: snapshotCollectorMaterials(collectorThread.messages, { limit: WRITE_FALLBACK_MATERIAL_LIMIT });
|
||||
console.warn("[深度研究] browser_snapshot_sending", {
|
||||
sessionId: session.id,
|
||||
messageCount: collectorThread.messages?.length ?? 0,
|
||||
toolMessageCount: (collectorThread.messages ?? []).filter((message) => message.type === "tool").length,
|
||||
snapshotCount: collectorMaterials.length,
|
||||
snapshotLimit: WRITE_FALLBACK_MATERIAL_LIMIT,
|
||||
writeWithoutMaterials,
|
||||
sampleTitles: collectorMaterials.slice(0, 5).map((item) => item.title),
|
||||
});
|
||||
const started = await actions.startJob.mutateAsync({
|
||||
sessionId: session.id,
|
||||
entry: "chat",
|
||||
query: session.query,
|
||||
title: session.title,
|
||||
config: { ...config, ...modelConfig },
|
||||
collectorMaterials,
|
||||
forceNoMaterials: writeWithoutMaterials,
|
||||
});
|
||||
console.warn("[深度研究] browser_snapshot_accepted", started);
|
||||
if (writeWithoutMaterials) {
|
||||
toast.warning("已按无素材模式启动撰写,对话里收集到的资料本次不会送入写作。");
|
||||
} else if (!collectorMaterials.length) {
|
||||
toast.warning("未传入检索素材,将按无素材模式撰写。请看控制台 [深度研究] browser_snapshot_sending");
|
||||
}
|
||||
setSandboxOpen(false);
|
||||
toast.success("开始撰写研究报告");
|
||||
} catch (error) {
|
||||
console.error("[深度研究] write_report_failed", {
|
||||
sessionId: session.id,
|
||||
query: session.query,
|
||||
title: session.title,
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
error,
|
||||
});
|
||||
toast.error(error instanceof Error ? error.message : "启动报告撰写失败");
|
||||
}
|
||||
}, [actions.startJob, selectedModel, session]);
|
||||
}, [actions.startJob, collectorThread.messages, selectedModel, session]);
|
||||
|
||||
const handleContinueCollecting = useCallback(() => {
|
||||
document.getElementById("deep-research-composer")?.focus();
|
||||
@@ -2059,7 +2454,7 @@ function DeepResearchWorkbenchInner() {
|
||||
imageGenerationAvailable={imageGenerationAvailable}
|
||||
imageLimit={capabilities.data?.imageGeneration?.maxImages ?? 4}
|
||||
submitting={actions.startJob.isPending}
|
||||
onConfirm={(config) => void handleConfirmReportCard(config)}
|
||||
onConfirm={(config, extras) => void handleConfirmReportCard(config, extras)}
|
||||
onContinueCollecting={handleContinueCollecting}
|
||||
/>
|
||||
);
|
||||
@@ -2739,7 +3134,7 @@ function ResearchConversation({
|
||||
}) {
|
||||
const active = session.status === "running" || session.status === "awaiting_input";
|
||||
const hasTrace = active || events.length > 0;
|
||||
const streamedSourceCount = events.reduce((count, event) => count + (event.type === "source_added" ? 1 : 0), 0);
|
||||
const streamedSourceCount = countEventMaterials(events);
|
||||
const liveSourceCount = Math.max(sourceCount, streamedSourceCount);
|
||||
const requestedImageCount = requestedReportImageCount(session.config);
|
||||
return (
|
||||
@@ -2847,6 +3242,7 @@ type TraceSnapshot = {
|
||||
images: TraceImage[];
|
||||
imagePhaseFinished: boolean;
|
||||
warnings: string[];
|
||||
diagnostics: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
function traceRecord(value: unknown): Record<string, unknown> {
|
||||
@@ -2884,6 +3280,8 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
|
||||
const selectedIds = new Set<string>();
|
||||
const removedIds = new Set<string>();
|
||||
const warnings: string[] = [];
|
||||
const diagnostics: Record<string, unknown>[] = [];
|
||||
const diagnosticKeys = new Set<string>();
|
||||
let curationReason: string | undefined;
|
||||
let compressedSourceCount: number | undefined;
|
||||
let reportCompleted = false;
|
||||
@@ -2995,6 +3393,19 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
|
||||
if (event.type === "warning" || event.type === "error") {
|
||||
const message = traceString(payload.message) ?? traceString(payload.detail);
|
||||
if (message && !warnings.includes(message)) warnings.push(message);
|
||||
const diagnostic = traceRecord(payload.diagnostic);
|
||||
if (Object.keys(diagnostic).length > 0) {
|
||||
const item = {
|
||||
code: traceString(payload.code) ?? event.type,
|
||||
...(message ? { message } : {}),
|
||||
...diagnostic,
|
||||
};
|
||||
const key = JSON.stringify(item);
|
||||
if (!diagnosticKeys.has(key)) {
|
||||
diagnosticKeys.add(key);
|
||||
diagnostics.push(item);
|
||||
}
|
||||
}
|
||||
if (traceString(payload.code) === "image_generation_failed") {
|
||||
const position = Math.max(1, Math.floor(numberValue(payload.position) ?? imageMap.size + 1));
|
||||
const total = numberValue(payload.total);
|
||||
@@ -3027,6 +3438,7 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
|
||||
images: [...imageMap.values()].sort((left, right) => left.position - right.position),
|
||||
imagePhaseFinished,
|
||||
warnings,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3123,6 +3535,7 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla
|
||||
</TraceStage>}
|
||||
|
||||
{snapshot.warnings.length > 0 && <div className="rounded-lg bg-amber-500/10 px-3 py-2.5 text-xs leading-5 text-amber-800 dark:text-amber-200"><p className="font-medium">过程提示</p>{snapshot.warnings.map((warning) => <p key={warning} className="mt-1">{warning}</p>)}</div>}
|
||||
{snapshot.diagnostics.length > 0 && <details className="rounded-lg border border-amber-500/20 bg-amber-500/[0.05] px-3 py-2 text-xs"><summary className="cursor-pointer font-medium text-amber-800 dark:text-amber-200">运行诊断(用于排查,可复制)</summary><pre className="mt-2 max-h-52 overflow-auto whitespace-pre-wrap break-all rounded bg-background/70 p-2 text-[11px] leading-5 text-muted-foreground">{snapshot.diagnostics.map((item) => JSON.stringify(item, null, 2)).join("\n\n")}</pre></details>}
|
||||
{error && <p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">实时连接提示:{error}。研究仍会继续,页面会从已保存的事件记录恢复。</p>}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
@@ -507,7 +507,7 @@ export function ReportConfigCard({
|
||||
imageGenerationAvailable: boolean;
|
||||
imageLimit: number;
|
||||
submitting: boolean;
|
||||
onConfirm: (config: Record<string, unknown>) => void;
|
||||
onConfirm: (config: Record<string, unknown>, extras?: { writeWithoutMaterials?: boolean }) => void;
|
||||
onContinueCollecting: () => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<ResearchMode>(
|
||||
@@ -521,6 +521,7 @@ export function ReportConfigCard({
|
||||
retrievalSkills: retrievalSkillsOverride ?? defaultOptions.retrievalSkills,
|
||||
}));
|
||||
const [generateImages, setGenerateImages] = useState(defaultGenerateImages);
|
||||
const [writeWithoutMaterials, setWriteWithoutMaterials] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
// The collector composer and this confirmation card are both reachable while
|
||||
@@ -572,9 +573,11 @@ export function ReportConfigCard({
|
||||
下方选项沿用原研究配置并可调整;确认后将基于以上对话收集到的资料撰写报告。
|
||||
{modelLabel ? <>写作模型:<span className="font-medium text-foreground">{modelLabel}</span>。</> : null}
|
||||
</p>
|
||||
<div className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-primary/15 bg-primary/[0.06] px-2 py-1 text-[11px] text-primary">
|
||||
<div className={`mt-2 inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] ${writeWithoutMaterials ? "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-200" : "border-primary/15 bg-primary/[0.06] text-primary"}`}>
|
||||
<span className="font-medium">已收集 {materialCount} 条资料</span>
|
||||
<span className="text-primary/70">将作为本次报告的写作依据</span>
|
||||
<span className={writeWithoutMaterials ? "opacity-80" : "text-primary/70"}>
|
||||
{writeWithoutMaterials ? "本次不送入写作,按无素材模式成稿" : "将作为本次报告的写作依据"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -621,6 +624,19 @@ export function ReportConfigCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="mt-3 flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={writeWithoutMaterials}
|
||||
onChange={(event) => setWriteWithoutMaterials(event.target.checked)}
|
||||
className="mt-0.5 accent-primary"
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium text-foreground">无素材写作</span>
|
||||
忽略对话里已收集的资料,直接走无素材成稿,用来确认这条链路是否生效。已落库的来源不会被删除。
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="mt-3 overflow-hidden rounded-xl border border-border/70">
|
||||
<button
|
||||
type="button"
|
||||
@@ -710,7 +726,14 @@ export function ReportConfigCard({
|
||||
<MessageSquarePlus className="mr-1.5 h-3.5 w-3.5" />
|
||||
继续补充资料
|
||||
</Button>
|
||||
<Button size="sm" disabled={submitting} onClick={() => onConfirm(buildReportCardConfig(mode, generateImages, options))}>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={submitting}
|
||||
onClick={() => onConfirm(
|
||||
buildReportCardConfig(mode, generateImages, options),
|
||||
{ writeWithoutMaterials },
|
||||
)}
|
||||
>
|
||||
{submitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
||||
开始撰写报告
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
* 全新部署自动播种「通用分析结构 / 递进式论证结构」),支持 启用/停用 筛选
|
||||
* 与 标题 筛选;卡片上可 switch 启停、编辑、删除(二次确认),并显示
|
||||
* 最后修改时间与创建用户名;新增时不填状态(服务端强制默认启用)。
|
||||
* - 详情视图:点击卡片即「选择」该结构(内容回填到高级配置的报告结构
|
||||
* custom_outline),弹窗切换为展示 标题 + Markdown 大纲预览,右上角「返回」回列表。
|
||||
* **点击卡片即「选择」该结构(内容回填到高级配置的报告结构 custom_outline,
|
||||
* 卡片留在列表中高亮为当前使用,不进入详情)**。
|
||||
* - 详情视图:通过卡片右上角「查看大纲结构」眼睛图标进入,展示 标题 + Markdown
|
||||
* 大纲预览,右上角「返回」回列表。
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -238,8 +240,9 @@ export function WorkflowConfigDialog({
|
||||
toast.error(`「${structure.title}」已停用,请先启用后再选用`);
|
||||
return;
|
||||
}
|
||||
// 点击卡片只做「选择」:内容回填后留在列表视图(卡片高亮为当前使用),
|
||||
// 详情视图通过卡片上的「查看大纲结构」眼睛图标进入。
|
||||
onApply?.(structure.content, structure.structureMode, structure.retrievalDirections, structure.retrievalSkills);
|
||||
setDetailId(structure.id);
|
||||
toast.success(`已选择报告结构「${structure.title}」,并填充到报告结构配置`);
|
||||
};
|
||||
|
||||
@@ -325,7 +328,7 @@ export function WorkflowConfigDialog({
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400 sm:text-[13px]">
|
||||
点击卡片选择报告结构(自动填充到报告结构配置);可启停、编辑、删除。
|
||||
点击卡片选择报告结构(自动填充到报告结构配置);点击卡片上的眼睛图标查看大纲详情;可启停、编辑、删除。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -501,7 +504,7 @@ export function WorkflowConfigDialog({
|
||||
key={structure.id}
|
||||
structure={structure}
|
||||
applied={structure.id === appliedStructureId}
|
||||
onOpen={() => handleSelect(structure)}
|
||||
onSelect={() => handleSelect(structure)}
|
||||
onPreview={(event) => {
|
||||
event.stopPropagation();
|
||||
setDetailId(structure.id);
|
||||
@@ -750,7 +753,7 @@ export function WorkflowConfigDialog({
|
||||
function StructureCard({
|
||||
structure,
|
||||
applied,
|
||||
onOpen,
|
||||
onSelect,
|
||||
onPreview,
|
||||
onDuplicate,
|
||||
onEdit,
|
||||
@@ -760,7 +763,7 @@ function StructureCard({
|
||||
}: {
|
||||
structure: ReportStructure;
|
||||
applied: boolean;
|
||||
onOpen: () => void;
|
||||
onSelect: () => void;
|
||||
onPreview: (event: React.MouseEvent) => void;
|
||||
onDuplicate: (event: React.MouseEvent) => void;
|
||||
onEdit: (event: React.MouseEvent) => void;
|
||||
@@ -775,11 +778,11 @@ function StructureCard({
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onOpen();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
className={`group relative flex cursor-pointer select-none flex-col justify-between rounded-2xl border p-5 text-left transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-[#F5222D]/30 ${
|
||||
|
||||
@@ -36,6 +36,14 @@ export type ReportStubAiMessage = {
|
||||
}[];
|
||||
};
|
||||
|
||||
export type ReportStubToolMessage = {
|
||||
id: string;
|
||||
type: "tool";
|
||||
name: "write_file";
|
||||
tool_call_id: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
interface UseReportSandboxOptions {
|
||||
/** stub 线程标识(建议用 sessionId/jobId 派生,切换会话即切换 stub)。 */
|
||||
threadKey: string;
|
||||
@@ -45,6 +53,13 @@ interface UseReportSandboxOptions {
|
||||
writing: boolean;
|
||||
/** First visible Markdown has arrived; the host can expand the right panel then. */
|
||||
onFirstReportContent?: () => void;
|
||||
/**
|
||||
* The writing run has produced something observable (report text or model
|
||||
* reasoning). Report text streams as non-durable live frames, so on a
|
||||
* multi-worker backend it can lag well behind the actual write; this lets
|
||||
* the host open the sandbox on the first sign of progress instead.
|
||||
*/
|
||||
activity?: boolean;
|
||||
/**
|
||||
* A structural regeneration writes a sibling file in the background. Leave
|
||||
* the currently displayed report in the sandbox until the user opens that
|
||||
@@ -58,6 +73,7 @@ export function useReportSandbox({
|
||||
report,
|
||||
writing,
|
||||
onFirstReportContent,
|
||||
activity = false,
|
||||
autoOpen = true,
|
||||
}: UseReportSandboxOptions) {
|
||||
const { select, setOpen, openStreamedArtifact } = useArtifacts();
|
||||
@@ -87,9 +103,37 @@ export function useReportSandbox({
|
||||
wasWritingRef.current = writing;
|
||||
}, [writing]);
|
||||
|
||||
// 每帧 upsert stub(带防抹空:新帧为空而旧内容非空时保留旧内容)。
|
||||
const hadReportRef = useRef(Boolean(report.trim()));
|
||||
useEffect(() => {
|
||||
if (!report) return;
|
||||
const hasReport = Boolean(report.trim());
|
||||
// Keep the panel closed through reasoning. When the first Markdown token
|
||||
// arrives, allow auto-open even if an earlier empty stub was prepared.
|
||||
if (writing && hasReport && !hadReportRef.current) autoOpenedRef.current = false;
|
||||
hadReportRef.current = hasReport;
|
||||
}, [report, writing]);
|
||||
|
||||
// 每帧 upsert stub。普通空帧不抹掉已渲染的文件,但写作过程中的
|
||||
// 空正文表示后端要求重置报告版本(report_reset)或新稿尚未出首字,
|
||||
// 此时必须清掉旧版,避免把首版失败文本继续展示在沙箱里。
|
||||
//
|
||||
// 清掉的是**正文**而不是文件本身:多 worker 部署下 report_delta 属于
|
||||
// 进程内 live 帧,观察端可能连在另一个 worker 上,首帧要等 0.8s 的
|
||||
// 落库轮询(report_chunk)才到。若此时把 stub 置为 null,右栏就没有
|
||||
// 可打开的虚拟文件,"开始撰写即打开沙箱" 会整段失效。
|
||||
useEffect(() => {
|
||||
if (!report) {
|
||||
if (writing) {
|
||||
setStubMessage({
|
||||
id: messageId,
|
||||
type: "ai",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{ id: toolCallId, name: "write_file", args: { path: REPORT_OUTPUT_PATH, content: "" } },
|
||||
],
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
setStubMessage((prev) => {
|
||||
const prevArgs = prev?.tool_calls?.[0]?.args as { content?: unknown } | undefined;
|
||||
const prevContent = typeof prevArgs?.content === "string" ? prevArgs.content : "";
|
||||
@@ -106,16 +150,16 @@ export function useReportSandbox({
|
||||
tool_calls: [{ id: toolCallId, name: "write_file", args: { path: REPORT_OUTPUT_PATH, content } }],
|
||||
};
|
||||
});
|
||||
}, [messageId, report, toolCallId]);
|
||||
}, [messageId, report, toolCallId, writing]);
|
||||
|
||||
// 报告首帧到达且仍在写入 → 自动展开右栏并选中虚拟 URL。
|
||||
// 思考结束后的报告正文首帧到达且仍在写入 → 自动展开右栏并选中虚拟 URL。
|
||||
// openStreamedArtifact 尊重用户手动关闭(suppress),不会强行弹回。
|
||||
useEffect(() => {
|
||||
if (!autoOpen || !report.trim() || !writing || autoOpenedRef.current) return;
|
||||
if (!autoOpen || (!report.trim() && !activity) || !writing || autoOpenedRef.current) return;
|
||||
autoOpenedRef.current = true;
|
||||
onFirstReportContent?.();
|
||||
openStreamedArtifact(virtualUrl, artifactPreviewKey(virtualUrl));
|
||||
}, [autoOpen, onFirstReportContent, openStreamedArtifact, report, virtualUrl, writing]);
|
||||
}, [activity, autoOpen, onFirstReportContent, openStreamedArtifact, report, virtualUrl, writing]);
|
||||
|
||||
/** 手动打开(头部「报告」按钮 / 完成卡「查看报告」):始终选中虚拟 URL。 */
|
||||
const openReport = useCallback(() => {
|
||||
@@ -132,8 +176,23 @@ export function useReportSandbox({
|
||||
return {
|
||||
virtualUrl,
|
||||
stubMessages: useMemo(
|
||||
() => (currentStubMessage ? [currentStubMessage] : []),
|
||||
[currentStubMessage],
|
||||
() => {
|
||||
if (!currentStubMessage) return [];
|
||||
if (writing) return [currentStubMessage];
|
||||
// ArtifactFileDetail determines whether a write is still streaming by
|
||||
// matching the write_file call with its tool result. `isLoading=false`
|
||||
// alone is intentionally ignored there, so completed and historical
|
||||
// virtual reports must include this synthetic completion receipt.
|
||||
const completedWrite: ReportStubToolMessage = {
|
||||
id: `${toolCallId}-result`,
|
||||
type: "tool",
|
||||
name: "write_file",
|
||||
tool_call_id: toolCallId,
|
||||
content: "ok",
|
||||
};
|
||||
return [currentStubMessage, completedWrite];
|
||||
},
|
||||
[currentStubMessage, toolCallId, writing],
|
||||
),
|
||||
openReport,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
.official-deerflow {
|
||||
--official-ink: #232323;
|
||||
--official-muted: #787878;
|
||||
--official-border: #eeeeeb;
|
||||
--official-sidebar: #fbfbfa;
|
||||
--official-accent: #111111;
|
||||
background: #fff;
|
||||
color: var(--official-ink);
|
||||
display: flex;
|
||||
font-family: Inter, "Noto Sans SC", "PingFang SC", system-ui, sans-serif;
|
||||
height: 100vh;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.official-deerflow *,
|
||||
.official-deerflow *::before,
|
||||
.official-deerflow *::after { box-sizing: border-box; }
|
||||
.official-deerflow button,
|
||||
.official-deerflow input,
|
||||
.official-deerflow textarea { font: inherit; }
|
||||
|
||||
.official-sidebar {
|
||||
background: var(--official-sidebar);
|
||||
border-right: 1px solid var(--official-border);
|
||||
display: flex;
|
||||
flex: 0 0 288px;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 16px 10px 12px;
|
||||
transition: flex-basis 180ms ease;
|
||||
}
|
||||
.sidebar-collapsed .official-sidebar { flex-basis: 64px; }
|
||||
.sidebar-collapsed .official-sidebar span,
|
||||
.sidebar-collapsed .official-sidebar p,
|
||||
.sidebar-collapsed .official-sidebar small,
|
||||
.sidebar-collapsed .official-brand { display: none; }
|
||||
.sidebar-collapsed .official-brand-row { justify-content: center; }
|
||||
.sidebar-collapsed .official-collapse-button { transform: rotate(180deg); }
|
||||
.sidebar-collapsed .official-nav-item,
|
||||
.sidebar-collapsed .official-recent-chat,
|
||||
.sidebar-collapsed .official-sidebar-footer > button,
|
||||
.sidebar-collapsed .official-user-row { justify-content: center; }
|
||||
|
||||
.official-brand-row { align-items: center; display: flex; height: 34px; justify-content: space-between; padding: 0 4px 0 1px; }
|
||||
.official-brand { font-family: Georgia, "Times New Roman", serif; font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
|
||||
.official-collapse-button,
|
||||
.official-icon-button { align-items: center; background: transparent; border: 0; border-radius: 7px; color: #787878; cursor: pointer; display: inline-flex; justify-content: center; padding: 6px; }
|
||||
.official-collapse-button:hover,
|
||||
.official-icon-button:hover { background: #f0f0ee; color: var(--official-ink); }
|
||||
|
||||
.official-nav { display: grid; gap: 3px; margin-top: 31px; }
|
||||
.official-nav-item,
|
||||
.official-recent-chat,
|
||||
.official-sidebar-footer > button { align-items: center; background: transparent; border: 0; border-radius: 7px; color: #6e6e6e; cursor: pointer; display: flex; gap: 10px; min-width: 0; padding: 9px 8px; text-align: left; width: 100%; }
|
||||
.official-nav-item { font-size: 16px; }
|
||||
.official-nav-item:hover,
|
||||
.official-recent-chat:hover,
|
||||
.official-sidebar-footer > button:hover { background: #f0f0ee; color: #222; }
|
||||
.official-nav-item.is-active { background: #efefed; color: #252525; font-weight: 600; }
|
||||
.official-nav-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.official-recent-section { border-top: 1px solid var(--official-border); margin-top: 25px; padding-top: 18px; }
|
||||
.official-recent-section > p { color: #90908e; font-size: 12px; margin: 0 8px 9px; }
|
||||
.official-recent-chat { display: grid; font-size: 13px; gap: 8px; grid-template-columns: 15px minmax(0, 1fr) auto; padding: 8px; }
|
||||
.official-recent-chat span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.official-recent-chat-copy { align-items: center; display: flex; gap: 6px; min-width: 0; }
|
||||
.official-recent-chat-title { flex: 1; min-width: 0; }
|
||||
.official-recent-chat small { color: #acaca9; font-size: 11px; }
|
||||
.official-recent-chat.is-active { background: #efefed; color: #252525; }
|
||||
.official-sidebar-loading,
|
||||
.official-sidebar-error,
|
||||
.official-sidebar-empty { align-items: center; color: #989895; display: flex; font-size: 12px; gap: 6px; margin: 0; padding: 8px; }
|
||||
.official-sidebar-error { color: #b04747; }
|
||||
|
||||
.official-sidebar-footer { border-top: 1px solid var(--official-border); margin-top: auto; padding-top: 9px; }
|
||||
.official-sidebar-footer > button { font-size: 14px; }
|
||||
.official-sidebar-footer > button svg:last-child { margin-left: auto; }
|
||||
.official-user-row { align-items: center; color: #777; display: flex; font-size: 13px; gap: 8px; margin: 10px 8px 1px; }
|
||||
.official-user-avatar { align-items: center; background: #ecece9; border-radius: 50%; color: #71716f; display: inline-flex; height: 25px; justify-content: center; width: 25px; }
|
||||
|
||||
.official-main-panel { background: #fff; flex: 1; min-width: 0; overflow: auto; }
|
||||
.official-page-header { align-items: flex-start; border-bottom: 1px solid var(--official-border); display: flex; justify-content: space-between; min-height: 104px; padding: 27px max(32px, 5vw); }
|
||||
.official-page-header h1 { font-size: 24px; letter-spacing: -0.5px; line-height: 1.2; margin: 3px 0 7px; }
|
||||
.official-page-header p { color: var(--official-muted); font-size: 14px; margin: 0; }
|
||||
.official-eyebrow { color: #9a9a98 !important; font-size: 12px !important; }
|
||||
.official-primary-button,
|
||||
.official-secondary-button,
|
||||
.official-outline-button { align-items: center; border-radius: 8px; cursor: pointer; display: inline-flex; gap: 7px; font-size: 14px; font-weight: 550; justify-content: center; white-space: nowrap; }
|
||||
.official-primary-button { background: var(--official-accent); border: 1px solid var(--official-accent); color: #fff; padding: 9px 13px; }
|
||||
.official-primary-button:hover { background: #303030; }
|
||||
.official-secondary-button,
|
||||
.official-outline-button { background: #fff; border: 1px solid #dededb; color: #3d3d3d; padding: 8px 12px; }
|
||||
.official-secondary-button:hover,
|
||||
.official-outline-button:hover { background: #f7f7f5; }
|
||||
.official-secondary-button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
|
||||
.official-chat-page { display: flex; flex-direction: column; height: 100%; min-height: 660px; }
|
||||
.official-chat-header { align-items: center; min-height: 62px; padding-bottom: 12px; padding-top: 12px; }
|
||||
.official-thread-title { align-items: center; display: flex; font-size: 14px; font-weight: 600; gap: 3px; }
|
||||
.official-header-actions { align-items: center; display: flex; gap: 10px; }
|
||||
.official-chat-canvas { align-items: center; display: flex; flex: 1; justify-content: center; min-height: 0; padding: 32px; }
|
||||
.official-chat-canvas.has-history { align-items: stretch; justify-content: center; overflow: auto; }
|
||||
.official-chat-welcome { margin: auto; max-width: 650px; text-align: center; transform: translateY(-35px); }
|
||||
.official-logo-orb { align-items: center; background: linear-gradient(145deg, #202020, #626260); border-radius: 18px; color: white; display: inline-flex; height: 55px; justify-content: center; width: 55px; }
|
||||
.official-chat-welcome h1 { font-size: clamp(25px, 3vw, 35px); letter-spacing: -1.15px; line-height: 1.2; margin: 21px 0 9px; }
|
||||
.official-chat-welcome > p { color: var(--official-muted); font-size: 15px; margin: 0; }
|
||||
.official-suggestion-grid { display: grid; gap: 10px; grid-template-columns: repeat(3, 1fr); margin-top: 31px; text-align: left; }
|
||||
.official-suggestion-grid button { background: #fff; border: 1px solid #e7e7e4; border-radius: 11px; color: #363636; cursor: pointer; min-height: 94px; padding: 14px; text-align: left; transition: border-color 120ms ease, box-shadow 120ms ease; }
|
||||
.official-suggestion-grid button:hover { border-color: #bdbdba; box-shadow: 0 4px 18px #0000000a; }
|
||||
.official-suggestion-grid strong,
|
||||
.official-suggestion-grid span { display: block; }
|
||||
.official-suggestion-grid strong { font-size: 14px; margin-bottom: 6px; }
|
||||
.official-suggestion-grid span { color: #8a8a87; font-size: 12px; line-height: 1.45; }
|
||||
|
||||
.official-composer-shell { margin: 0 auto; max-width: 850px; padding: 0 24px 19px; width: 100%; }
|
||||
.official-composer { align-items: flex-end; background: #fff; border: 1px solid #dcdcd8; border-radius: 13px; box-shadow: 0 7px 20px #00000008; display: flex; gap: 8px; padding: 11px 11px 10px 15px; }
|
||||
.official-composer:focus-within { border-color: #adadaa; box-shadow: 0 0 0 3px #00000009; }
|
||||
.official-composer textarea { background: transparent; border: 0; color: #333; flex: 1; font-size: 15px; line-height: 24px; max-height: 144px; outline: 0; resize: none; }
|
||||
.official-composer textarea::placeholder { color: #a2a2a0; }
|
||||
.official-composer button { align-items: center; background: #222; border: 0; border-radius: 8px; color: #fff; cursor: pointer; display: inline-flex; height: 31px; justify-content: center; transition: opacity 100ms ease; width: 31px; }
|
||||
.official-composer button:disabled { cursor: not-allowed; opacity: .3; }
|
||||
.official-composer-shell > p { color: #acaca8; font-size: 11px; margin: 9px 0 0; text-align: center; }
|
||||
.official-message-list { display: grid; gap: 18px; margin: 0 auto; max-width: 760px; padding: 12px 0 22px; width: 100%; }
|
||||
.official-message { border-radius: 12px; font-size: 15px; line-height: 1.65; padding: 14px 16px; }
|
||||
.official-message-user { background: #f4f4f2; justify-self: end; max-width: 76%; }
|
||||
.official-message-assistant { border: 1px solid #eeeeeb; justify-self: start; max-width: 88%; }
|
||||
.official-message p { margin: 0; white-space: pre-wrap; }
|
||||
.official-message-assistant p { margin: 10px 0 13px; }
|
||||
.official-assistant-mark { align-items: center; color: #5c5c5a; display: flex; font-size: 12px; font-weight: 600; gap: 6px; }
|
||||
.official-insight-card { background: #f7f7f5; border-radius: 8px; display: grid; gap: 3px; padding: 10px 11px; }
|
||||
.official-insight-card strong { font-size: 13px; }
|
||||
.official-insight-card span { color: #858582; font-size: 12px; }
|
||||
.official-streaming-indicator { align-items: center; color: #7d7d7a; display: flex; font-size: 13px; gap: 7px; padding: 6px 1px; }
|
||||
.official-streaming-indicator svg,
|
||||
.official-loading-state svg,
|
||||
.official-sidebar-loading svg { animation: official-spin 900ms linear infinite; }
|
||||
.official-inline-error { color: #ae3e3e; font-size: 12px; margin: 0 auto 8px; max-width: 850px; padding: 0 24px; width: 100%; }
|
||||
|
||||
.official-content-page { margin: 0 auto; max-width: 1180px; min-height: 100%; }
|
||||
.official-content-page .official-page-header { padding-left: max(32px, 5vw); padding-right: max(32px, 5vw); }
|
||||
.official-toolbar { padding: 22px max(32px, 5vw) 0; }
|
||||
.official-search { align-items: center; background: #f7f7f5; border: 1px solid transparent; border-radius: 8px; color: #8a8a87; display: flex; gap: 8px; max-width: 330px; padding: 8px 10px; }
|
||||
.official-search:focus-within { background: #fff; border-color: #d1d1ce; }
|
||||
.official-search input { background: transparent; border: 0; color: #333; font-size: 14px; min-width: 0; outline: 0; width: 100%; }
|
||||
.official-agent-grid { display: grid; gap: 15px; grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 24px max(32px, 5vw) 40px; }
|
||||
.official-agent-card { border: 1px solid #e8e8e5; border-radius: 12px; min-height: 244px; padding: 19px; position: relative; }
|
||||
.official-agent-card:hover { border-color: #cfcfcb; box-shadow: 0 8px 22px #00000008; }
|
||||
.official-agent-avatar { align-items: center; border-radius: 9px; display: inline-flex; font-size: 13px; font-weight: 700; height: 38px; justify-content: center; width: 38px; }
|
||||
.official-agent-avatar.violet { background: #eeeafe; color: #6d56b7; }
|
||||
.official-agent-avatar.sky { background: #e6f4ff; color: #2786bc; }
|
||||
.official-agent-avatar.amber { background: #fff1d7; color: #b46d0d; }
|
||||
.official-agent-card h2 { font-size: 16px; letter-spacing: -.2px; margin: 17px 0 7px; }
|
||||
.official-agent-card p { color: #80807e; font-size: 13px; line-height: 1.55; margin: 0; }
|
||||
.official-agent-card button { background: transparent; border: 0; bottom: 17px; color: #3c3c3a; cursor: pointer; font-size: 13px; font-weight: 600; left: 14px; padding: 5px; position: absolute; }
|
||||
.official-agent-card button:hover { text-decoration: underline; }
|
||||
.official-query-error { color: #ae3e3e; font-size: 14px; margin: 26px max(32px, 5vw); }
|
||||
.official-loading-state { align-items: center; color: #7e7e7b; display: flex; font-size: 14px; gap: 8px; justify-content: center; min-height: 300px; }
|
||||
.official-empty-panel { align-items: center; display: flex; flex-direction: column; justify-content: center; min-height: 430px; padding: 32px; text-align: center; }
|
||||
.official-empty-icon { align-items: center; background: #f3f3f1; border-radius: 13px; color: #666; display: flex; height: 52px; justify-content: center; width: 52px; }
|
||||
.official-empty-panel h2 { font-size: 18px; margin: 17px 0 7px; }
|
||||
.official-empty-panel p { color: #858582; font-size: 14px; margin: 0 0 18px; }
|
||||
.official-settings-list { display: grid; gap: 8px; padding: 25px max(32px, 5vw); }
|
||||
.official-setting-row { background: #fff; border: 1px solid #e8e8e5; border-radius: 10px; color: #3b3b39; display: block; padding: 15px 17px; text-align: left; }
|
||||
.official-settings-list strong,
|
||||
.official-settings-list small { display: block; }
|
||||
.official-settings-list strong { font-size: 14px; }
|
||||
.official-settings-list small { color: #888885; font-size: 12px; margin-top: 3px; }
|
||||
.official-task-list { display: grid; gap: 9px; padding: 24px max(32px, 5vw) 40px; }
|
||||
.official-task-card { align-items: center; background: #fff; border: 1px solid #e8e8e5; border-radius: 10px; color: #393937; display: flex; min-width: 0; padding: 5px 6px 5px 15px; transition: border-color 120ms ease, box-shadow 120ms ease; }
|
||||
.official-task-card:hover { background: #fafaf8; border-color: #d2d2ce; }
|
||||
.official-task-card.is-selected { border-color: #969692; box-shadow: 0 0 0 3px #00000008; }
|
||||
.official-task-card-main { align-items: center; background: transparent; border: 0; color: inherit; cursor: pointer; display: grid; flex: 1; gap: 12px; grid-template-columns: 38px minmax(0, 1fr) auto auto; min-width: 0; padding: 8px 0; text-align: left; }
|
||||
.official-task-icon { align-items: center; background: #f0f0ed; border-radius: 8px; color: #686865; display: flex; height: 34px; justify-content: center; width: 34px; }
|
||||
.official-task-copy { min-width: 0; }
|
||||
.official-task-copy strong,
|
||||
.official-task-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.official-task-copy strong { font-size: 14px; }
|
||||
.official-task-copy small { color: #888885; font-size: 12px; margin-top: 3px; }
|
||||
.official-task-status { background: #eef5ef; border-radius: 999px; color: #46804e; font-size: 11px; padding: 3px 8px; white-space: nowrap; }
|
||||
.official-task-status.is-running,
|
||||
.official-task-status.is-pending { background: #fff4de; color: #a97018; }
|
||||
.official-task-status.is-paused { background: #f0f0ee; color: #7e7e7b; }
|
||||
.official-task-status.is-failed { background: #fff0f0; color: #b34e4e; }
|
||||
.official-task-card time { color: #9a9a97; font-size: 12px; white-space: nowrap; }
|
||||
.official-task-card-actions { display: flex; gap: 3px; margin-left: 11px; }
|
||||
.official-task-card-actions button { align-items: center; background: transparent; border: 0; border-radius: 7px; color: #686865; cursor: pointer; display: inline-flex; height: 30px; justify-content: center; width: 30px; }
|
||||
.official-task-card-actions button:hover { background: #ededeb; color: #242422; }
|
||||
.official-task-card-actions button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.official-task-card-actions button:disabled svg { animation: official-spin 900ms linear infinite; }
|
||||
|
||||
.official-task-detail { border-bottom: 1px solid var(--official-border); padding: 24px max(32px, 5vw); }
|
||||
.official-task-detail > header { align-items: flex-start; display: flex; gap: 16px; justify-content: space-between; }
|
||||
.official-task-detail h2 { font-size: 19px; margin: 3px 0 7px; }
|
||||
.official-task-detail header p:not(.official-eyebrow) { color: #777774; font-size: 13px; line-height: 1.5; margin: 0; max-width: 720px; }
|
||||
.official-task-meta { color: #787875; display: flex; flex-wrap: wrap; font-size: 12px; gap: 7px 16px; margin-top: 16px; }
|
||||
.official-task-detail-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 17px; }
|
||||
.official-task-detail-actions button:disabled svg { animation: official-spin 900ms linear infinite; }
|
||||
.official-run-list { margin-top: 24px; }
|
||||
.official-run-list h3 { font-size: 14px; margin: 0 0 10px; }
|
||||
.official-run-row { align-items: center; background: transparent; border: 0; border-top: 1px solid #eeeeeb; color: #4c4c49; cursor: pointer; display: grid; font-size: 12px; gap: 13px; grid-template-columns: 76px 92px minmax(0, 1fr); padding: 11px 2px; text-align: left; width: 100%; }
|
||||
.official-run-row:hover { color: #111; }
|
||||
.official-run-row > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.official-run-status { background: #f0f0ee; border-radius: 999px; color: #777774; justify-self: start; padding: 3px 7px; }
|
||||
.official-run-status.is-running,
|
||||
.official-run-status.is-pending { background: #fff4de; color: #a97018; }
|
||||
.official-run-status.is-succeeded { background: #eef5ef; color: #46804e; }
|
||||
.official-run-status.is-failed { background: #fff0f0; color: #b34e4e; }
|
||||
.official-run-empty { align-items: center; color: #858582; display: flex; font-size: 12px; gap: 6px; margin: 10px 0 0; }
|
||||
.official-run-empty svg { animation: official-spin 900ms linear infinite; }
|
||||
|
||||
@keyframes official-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.official-sidebar { flex-basis: 62px; padding-left: 7px; padding-right: 7px; }
|
||||
.official-sidebar span,
|
||||
.official-sidebar p,
|
||||
.official-sidebar small,
|
||||
.official-brand { display: none; }
|
||||
.official-brand-row { justify-content: center; }
|
||||
.official-collapse-button { display: none; }
|
||||
.official-nav-item,
|
||||
.official-recent-chat,
|
||||
.official-sidebar-footer > button,
|
||||
.official-user-row { justify-content: center; }
|
||||
.official-page-header { padding-left: 22px; padding-right: 22px; }
|
||||
.official-page-header h1 { font-size: 21px; }
|
||||
.official-agent-grid { grid-template-columns: 1fr; padding-left: 22px; padding-right: 22px; }
|
||||
.official-toolbar { padding-left: 22px; padding-right: 22px; }
|
||||
.official-content-page .official-page-header { padding-left: 22px; padding-right: 22px; }
|
||||
.official-suggestion-grid { grid-template-columns: 1fr; }
|
||||
.official-chat-welcome { transform: none; }
|
||||
.official-message-list { padding-left: 0; padding-right: 0; }
|
||||
.official-outline-button { font-size: 0; gap: 0; padding: 8px; }
|
||||
.official-composer-shell { padding-left: 14px; padding-right: 14px; }
|
||||
.official-task-card-main { gap: 8px; grid-template-columns: 38px minmax(0, 1fr) auto; }
|
||||
.official-task-card time { display: none; }
|
||||
.official-task-detail { padding-left: 22px; padding-right: 22px; }
|
||||
.official-run-row { grid-template-columns: 76px minmax(0, 1fr); }
|
||||
.official-run-row > span:nth-child(2) { display: none; }
|
||||
}
|
||||
@@ -26,6 +26,11 @@ interface PositionIntentInputProps extends Step1InputAreaProps {
|
||||
selectedModel?: string;
|
||||
onSelectedModelChange?: (model: string) => void;
|
||||
commonQuestions?: (string | AgentQuestion)[] | null;
|
||||
/**
|
||||
* 点击常用问题 chip 时直接把问题发送出去(8BF 第一步)。
|
||||
* 缺省 false:仅填充输入框(岗位会商等既有页面的行为保持不变)。
|
||||
*/
|
||||
sendCommonQuestionOnClick?: boolean;
|
||||
/** 仅保留上传、模型选择和发送的紧凑输入区(用于会商第 3 步)。 */
|
||||
hideUtilityActions?: boolean;
|
||||
}
|
||||
@@ -68,6 +73,7 @@ export function PositionIntentInput({
|
||||
selectedModel,
|
||||
onSelectedModelChange,
|
||||
commonQuestions,
|
||||
sendCommonQuestionOnClick = false,
|
||||
hideUtilityActions = false,
|
||||
}: PositionIntentInputProps) {
|
||||
// Files are owned by Step1Panel. This ref only opens the native picker and
|
||||
@@ -86,7 +92,13 @@ export function PositionIntentInput({
|
||||
: (modelOptions[0]?.value ?? "");
|
||||
|
||||
const pickCommonQuestion = (question: AgentQuestion) => {
|
||||
onValueChange(question.prompt);
|
||||
// 直接发送模式:以问题全文为覆盖文本调 onSend,绕过输入框 state 的
|
||||
// 异步更新(同 tick 里 onSend 闭包读不到刚 setValue 的新值)。
|
||||
if (sendCommonQuestionOnClick) {
|
||||
onSend(question.prompt);
|
||||
} else {
|
||||
onValueChange(question.prompt);
|
||||
}
|
||||
setShowMoreQuestions(false);
|
||||
};
|
||||
|
||||
@@ -160,6 +172,7 @@ export function PositionIntentInput({
|
||||
type="button"
|
||||
className="position-roundtable-intent-common-question"
|
||||
title={question.prompt}
|
||||
disabled={sendCommonQuestionOnClick ? isSending : undefined}
|
||||
onClick={() => pickCommonQuestion(question)}
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
@@ -185,6 +198,7 @@ export function PositionIntentInput({
|
||||
key={`${question.prompt}-${index}`}
|
||||
type="button"
|
||||
title={question.prompt}
|
||||
disabled={sendCommonQuestionOnClick ? isSending : undefined}
|
||||
onClick={() => pickCommonQuestion(question)}
|
||||
>
|
||||
{question.title}
|
||||
@@ -268,7 +282,7 @@ export function PositionIntentInput({
|
||||
<button
|
||||
id="send-message-btn"
|
||||
type="button"
|
||||
onClick={isSending ? onStop : onSend}
|
||||
onClick={() => (isSending ? onStop() : onSend())}
|
||||
disabled={isSending ? false : !canSend}
|
||||
aria-label={isSending ? "暂停生成" : "发送"}
|
||||
title={isSending ? "暂停生成" : "发送"}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# 报告协作工作台(AgentScope 多智能体)—— 前端模块
|
||||
|
||||
实现计划:`docs/agentscope-多智能体报告协作工作台-前端实施计划.md`
|
||||
(配套后端计划:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md`)。
|
||||
|
||||
**联调对接:** `docs/agentscope-多智能体报告协作工作台-前端对接指南.md`(环境配置、API 时序、验收清单)。
|
||||
|
||||
**进度(2026-09-06):** 页面与契约已完成,默认对接真实后端(`GET/POST /api/report-collaboration/*`)。后端 `RC-BE-000`~`018` 已落地。联调见 `docs/agentscope-多智能体报告协作工作台-前端对接指南.md`。
|
||||
|
||||
## 结构
|
||||
|
||||
```text
|
||||
src/report-collaboration/
|
||||
api/ # 契约类型 + REST/SSE 客户端
|
||||
types.ts # 前后端共享领域类型(冻结契约,字段与后端 Pydantic 一致)
|
||||
client.ts # rcFetch:鉴权、X-Idempotency-Key、X-Expected-Revision、409/422/429/5xx 错误映射
|
||||
sessions.ts / plans.ts / runs.ts / reports.ts # 端点函数
|
||||
driver.ts # 真实后端统一门面(getReportCollaborationDriver;Mock 驱动已移除)
|
||||
state/
|
||||
event-normalizer.ts # SSE/REST 事件防御性归一化
|
||||
event-reducer.ts # durable 事件 reducer(seq 去重、乱序缓冲、节点状态机守卫、乐观消息替换)
|
||||
selectors.ts # 页面状态投影(并行聚焦过滤、画布方案、阶段推导)
|
||||
message-adapter.ts # CollaborationMessage → LangGraph Message + BaseStream 外观 + 虚拟业务卡
|
||||
hooks/
|
||||
useReportCollaborationSession.ts # 快照→SSE→合帧 reducer→动作(send/select/startRun/commands)
|
||||
useReportCollaborationStream.ts # SSE 连接/指数退避重连/after_seq 续接
|
||||
useReportCollaborationMessages.ts # 消息组装(聚焦过滤 + 业务卡 + thread 外观)
|
||||
useReportPanelLayout.ts # 两栏宽度(持久化)、窄屏切换、键盘分隔条
|
||||
useReportPlanSelection.ts # 预览(纯前端)与选定(服务器事实)分离
|
||||
components/ # Header / 两栏 Layout / 会话面板(复用 MessageList)/ 方案卡 /
|
||||
# 画布(@xyflow/react)/ 节点抽屉 / Composer / 干预卡 / 报告预览 / 历史 / 状态栏
|
||||
pages/ReportCollaborationPage.tsx
|
||||
styles/report-collaboration.css # --rc-* 语义 token(Chrome 109 兼容,无 color-mix)
|
||||
tests/ # node:test 单测(reducer / adapter),运行:pnpm test:report-collaboration
|
||||
```
|
||||
|
||||
## 路由与入口
|
||||
|
||||
- `/page/workspace/report-collaboration` 新会话;`/page/workspace/report-collaboration/:sessionId` 会话页(按 sessionId 重挂)。
|
||||
- 侧边栏导航项「报告协作」(`workspace-nav-chat-list.tsx`)。
|
||||
- `WorkspaceRoutes.tsx` 引入页面与 CSS。
|
||||
|
||||
## 关键约束(与计划一致)
|
||||
|
||||
- 消息区**复用** `MessageList`:AgentScope 文本 / TeamSay / 工具调用与返回经
|
||||
`message-adapter.ts` 一对一映射,经 `ThreadContext.Provider` + 合成 `BaseStream` 渲染;
|
||||
不另做摘要卡或第二套渲染器。
|
||||
- MessageList 新增的**通用业务卡插槽** `businessCardSlot`(分组 `assistant:business-card`,
|
||||
判据 `additional_kwargs.report_collaboration_card`,见 `core/messages/utils.ts`)只在本页
|
||||
传入;其他页面零行为变化。
|
||||
- 服务器快照 + durable SSE 是唯一事实源;`previewPlanId` / 聚焦成员 / 面板宽度均为纯展示状态。
|
||||
- 节点抽屉只读合同;重跑/纠偏经对话命令(`targetNodeId`/`agentRunId`)。
|
||||
|
||||
## 只走真实后端
|
||||
|
||||
Mock 驱动(`mock.ts`)与切换开关(`deerflow.rc-mock` / `VITE_RC_MOCK`)已移除,
|
||||
页面永远请求真实后端。历史遗留的浏览器设置可执行
|
||||
`localStorage.removeItem('deerflow.rc-mock')` 清掉(不再被读取)。日常联调请保证:
|
||||
|
||||
- `config.yaml` → `report_collaboration.enabled: true`
|
||||
- 需要成员自动执行时再开 `worker_enabled: true`
|
||||
|
||||
## 后端契约冻结说明
|
||||
|
||||
`api/types.ts` 按后端计划 §5/§7 编写,已与 `app/report_collaboration/contracts/` 对齐。
|
||||
快照端点约定 `GET /sessions/{id}` 返回 `SessionSnapshot`
|
||||
(session/messages/plans/run/nodes/agent_runs/commands/report_versions/last_seq)。
|
||||
`GET /runs/{id}/stream` 已按 `after_seq` / `Last-Event-ID` 重放 durable 事件(`RC-BE-011`)。queued run 的领取/租约恢复已落地(`RC-BE-012`),质量角色内核已落地(`RC-BE-013`)。报告改写 / apply / restore 已由 `RC-BE-015` 挂载。后端 20 项质量评测集见 `RC-BE-018`(确定性打分,不替代上线前真人盲评)。
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 报告协作 API 基础客户端:鉴权、幂等键、乐观锁与统一错误映射。
|
||||
*
|
||||
* - 写请求自动携带 `X-Idempotency-Key` 与 `X-Expected-Revision`。
|
||||
* - 409 → ConflictError(刷新快照后由用户重新确认);422 → ValidationError;
|
||||
* 429 → RateLimitError(预算/并发限制,不自动重试);5xx → ServerError
|
||||
* (保留输入,允许同一幂等键重试)。
|
||||
*/
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { ApiErrorDetail } from "./types";
|
||||
|
||||
export const REPORT_COLLABORATION_BASE = () =>
|
||||
`${getBackendBaseURL()}/api/report-collaboration`;
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code?: string;
|
||||
readonly detail?: unknown;
|
||||
|
||||
constructor(status: number, message: string, code?: string, detail?: unknown) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
/** 409:期望 revision 已过期(或 active run 冲突),不能静默覆盖。 */
|
||||
export class ConflictError extends ApiError {}
|
||||
/** 422:请求参数/语义错误,需在对应操作附近展示。 */
|
||||
export class ValidationError extends ApiError {}
|
||||
/** 429:预算、并发或速率限制,不自动重试。 */
|
||||
export class RateLimitError extends ApiError {}
|
||||
/** 5xx:服务端错误,可带同一幂等键重试。 */
|
||||
export class ServerError extends ApiError {}
|
||||
/** 网络层错误(fetch reject)。 */
|
||||
export class NetworkError extends ApiError {
|
||||
constructor(message: string) {
|
||||
super(0, message, "network");
|
||||
}
|
||||
}
|
||||
|
||||
export function newIdempotencyKey(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return `rc-${crypto.randomUUID()}`;
|
||||
}
|
||||
return `rc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
async function parseErrorDetail(res: Response): Promise<ApiErrorDetail | null> {
|
||||
try {
|
||||
const data = (await res.json()) as ApiErrorDetail | { detail?: unknown };
|
||||
const source = "message" in data ? data : null;
|
||||
if (source) return source;
|
||||
if (typeof data.detail === "string") {
|
||||
return { message: data.detail };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function errorForStatus(res: Response, message: string, code?: string, detail?: unknown) {
|
||||
if (res.status === 409) return new ConflictError(409, message, code, detail);
|
||||
if (res.status === 422) return new ValidationError(422, message, code, detail);
|
||||
if (res.status === 429) return new RateLimitError(429, message, code, detail);
|
||||
if (res.status >= 500) return new ServerError(res.status, message, code, detail);
|
||||
return new ApiError(res.status, message, code, detail);
|
||||
}
|
||||
|
||||
export interface RcRequestInit extends Omit<RequestInit, "body"> {
|
||||
body?: unknown;
|
||||
idempotencyKey?: string;
|
||||
expectedRevision?: number | null;
|
||||
/** 404 时返回 null 而不是抛错。 */
|
||||
nullOn404?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function rcRequest<T>(path: string, init: RcRequestInit = {}): Promise<T | null> {
|
||||
const headers = new Headers();
|
||||
headers.set("Accept", "application/json");
|
||||
if (init.body !== undefined) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
if (init.idempotencyKey) {
|
||||
headers.set("X-Idempotency-Key", init.idempotencyKey);
|
||||
}
|
||||
if (init.expectedRevision !== undefined && init.expectedRevision !== null) {
|
||||
headers.set("X-Expected-Revision", String(init.expectedRevision));
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await apiFetch(`${REPORT_COLLABORATION_BASE()}${path}`, {
|
||||
method: init.method ?? (init.body !== undefined ? "POST" : "GET"),
|
||||
headers,
|
||||
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
|
||||
credentials: "include",
|
||||
signal: init.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new NetworkError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
if (res.status === 404 && init.nullOn404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await parseErrorDetail(res);
|
||||
throw errorForStatus(
|
||||
res,
|
||||
detail?.message ?? `request failed (${res.status})`,
|
||||
detail?.code,
|
||||
detail?.detail,
|
||||
);
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
return null;
|
||||
}
|
||||
const text = await res.text();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
/** GET 便捷封装(非空断言版)。 */
|
||||
export async function rcGet<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||
const data = await rcRequest<T>(path, { method: "GET", signal });
|
||||
if (data === null) {
|
||||
throw new ApiError(404, `resource not found: ${path}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 驱动接口:直接请求真实后端(不再提供 Mock 驱动)。
|
||||
*/
|
||||
|
||||
import * as plansApi from "@/report-collaboration/api/plans";
|
||||
import * as reportsApi from "@/report-collaboration/api/reports";
|
||||
import * as runsApi from "@/report-collaboration/api/runs";
|
||||
import * as sessionsApi from "@/report-collaboration/api/sessions";
|
||||
import type {
|
||||
CollaborationCommand,
|
||||
CollaborationEvent,
|
||||
CollaborationMessage,
|
||||
CreateCommandPayload,
|
||||
CreateRewritePayload,
|
||||
NodeContractDetail,
|
||||
ReportRewriteProposal,
|
||||
ReportRun,
|
||||
ReportSession,
|
||||
ReportSessionSummary,
|
||||
ReportVersion,
|
||||
SendMessagePayload,
|
||||
SessionSnapshot,
|
||||
StartRunPayload,
|
||||
} from "@/report-collaboration/api/types";
|
||||
|
||||
export interface OpenStreamHandlers {
|
||||
onEvent: (event: CollaborationEvent) => void;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface ReportCollaborationDriver {
|
||||
listSessions(signal?: AbortSignal): Promise<ReportSessionSummary[]>;
|
||||
getSnapshot(sessionId: string, signal?: AbortSignal): Promise<SessionSnapshot | null>;
|
||||
|
||||
createSession(title?: string): Promise<ReportSession>;
|
||||
updateSession(
|
||||
sessionId: string,
|
||||
patch: { title?: string },
|
||||
expectedRevision?: number | null,
|
||||
): Promise<ReportSession | null>;
|
||||
deleteSession(sessionId: string): Promise<void>;
|
||||
|
||||
sendMessage(
|
||||
sessionId: string,
|
||||
payload: SendMessagePayload,
|
||||
idempotencyKey: string,
|
||||
): Promise<CollaborationMessage>;
|
||||
|
||||
requestPlans(sessionId: string, expectedRevision?: number | null): Promise<void>;
|
||||
selectPlan(
|
||||
sessionId: string,
|
||||
planId: string,
|
||||
expectedRevision?: number | null,
|
||||
): Promise<void>;
|
||||
startRun(
|
||||
sessionId: string,
|
||||
payload: StartRunPayload,
|
||||
expectedRevision?: number | null,
|
||||
): Promise<ReportRun>;
|
||||
|
||||
getRun(runId: string, signal?: AbortSignal): Promise<ReportRun | null>;
|
||||
listRunEvents(runId: string, afterSeq: number, signal?: AbortSignal): Promise<CollaborationEvent[]>;
|
||||
openRunStream(
|
||||
runId: string,
|
||||
afterSeq: number,
|
||||
handlers: OpenStreamHandlers,
|
||||
): () => void;
|
||||
cancelRun(runId: string): Promise<ReportRun>;
|
||||
|
||||
createCommand(
|
||||
runId: string,
|
||||
payload: CreateCommandPayload,
|
||||
idempotencyKey: string,
|
||||
): Promise<CollaborationCommand>;
|
||||
confirmCommand(runId: string, commandId: string): Promise<CollaborationCommand>;
|
||||
cancelCommand(runId: string, commandId: string): Promise<CollaborationCommand>;
|
||||
|
||||
getNodeContract(runId: string, nodeId: string, signal?: AbortSignal): Promise<NodeContractDetail>;
|
||||
|
||||
listReportVersions(sessionId: string, signal?: AbortSignal): Promise<ReportVersion[]>;
|
||||
createRewrite(sessionId: string, payload: CreateRewritePayload): Promise<ReportRewriteProposal>;
|
||||
applyRewrite(sessionId: string, rewriteId: string): Promise<ReportVersion>;
|
||||
restoreReportVersion(sessionId: string, versionId: string): Promise<ReportVersion>;
|
||||
}
|
||||
|
||||
export const realDriver: ReportCollaborationDriver = {
|
||||
listSessions: sessionsApi.listSessions,
|
||||
getSnapshot: sessionsApi.getSessionSnapshot,
|
||||
createSession: (title) => sessionsApi.createSession(title ? { title } : {}),
|
||||
updateSession: sessionsApi.updateSession,
|
||||
deleteSession: sessionsApi.deleteSession,
|
||||
sendMessage: sessionsApi.sendMessage,
|
||||
requestPlans: (sessionId, expectedRevision) =>
|
||||
plansApi.requestPlans(sessionId, undefined, expectedRevision),
|
||||
selectPlan: async (sessionId, planId, expectedRevision) => {
|
||||
await plansApi.selectPlan(sessionId, planId, expectedRevision);
|
||||
},
|
||||
startRun: runsApi.startRun,
|
||||
getRun: runsApi.getRun,
|
||||
listRunEvents: runsApi.listRunEvents,
|
||||
openRunStream: runsApi.openRunStream,
|
||||
cancelRun: runsApi.cancelRun,
|
||||
createCommand: runsApi.createCommand,
|
||||
confirmCommand: runsApi.confirmCommand,
|
||||
cancelCommand: runsApi.cancelCommand,
|
||||
getNodeContract: runsApi.getNodeContract,
|
||||
listReportVersions: reportsApi.listReportVersions,
|
||||
createRewrite: reportsApi.createRewrite,
|
||||
applyRewrite: reportsApi.applyRewrite,
|
||||
restoreReportVersion: reportsApi.restoreReportVersion,
|
||||
};
|
||||
|
||||
export function getReportCollaborationDriver(): ReportCollaborationDriver {
|
||||
return realDriver;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 候选计划:plan request / 查询 / 选定(`/sessions/{id}/plans*`)。
|
||||
*
|
||||
* 选择方案与开始执行是两个独立命令:select 只写 `selected_plan_id`,
|
||||
* 不创建 run。
|
||||
*/
|
||||
|
||||
import { newIdempotencyKey, rcGet, rcRequest } from "./client";
|
||||
import type { ReportPlanCandidate } from "./types";
|
||||
|
||||
export async function requestPlans(
|
||||
sessionId: string,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
expectedRevision?: number | null,
|
||||
): Promise<void> {
|
||||
await rcRequest<void>(`/sessions/${encodeURIComponent(sessionId)}/plan-requests`, {
|
||||
method: "POST",
|
||||
body: {},
|
||||
idempotencyKey,
|
||||
expectedRevision,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listPlans(
|
||||
sessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReportPlanCandidate[]> {
|
||||
const data = await rcGet<{ plans: ReportPlanCandidate[] }>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/plans`,
|
||||
signal,
|
||||
);
|
||||
return data.plans ?? [];
|
||||
}
|
||||
|
||||
/** 采用方案:409 表示方案 revision 过期或会话已选定,需刷新后重新确认。 */
|
||||
export async function selectPlan(
|
||||
sessionId: string,
|
||||
planId: string,
|
||||
expectedRevision?: number | null,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<{ selected_plan_id: string }> {
|
||||
return rcRequest<{ selected_plan_id: string }>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/plans/${encodeURIComponent(planId)}/select`,
|
||||
{ method: "POST", body: {}, idempotencyKey, expectedRevision },
|
||||
).then((res) => res as { selected_plan_id: string });
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 报告版本、章节改写候选与版本恢复(`/sessions/{id}/reports*`)。
|
||||
*/
|
||||
|
||||
import { newIdempotencyKey, rcGet, rcRequest } from "./client";
|
||||
import type { CreateRewritePayload, ReportRewriteProposal, ReportVersion } from "./types";
|
||||
|
||||
export async function listReportVersions(
|
||||
sessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReportVersion[]> {
|
||||
const data = await rcGet<{ versions: ReportVersion[] }>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/reports`,
|
||||
signal,
|
||||
);
|
||||
return data.versions ?? [];
|
||||
}
|
||||
|
||||
/** 发起章节重写 / 润色:只生成 replacement candidate,不直接覆盖正式版本。 */
|
||||
export async function createRewrite(
|
||||
sessionId: string,
|
||||
payload: CreateRewritePayload,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<ReportRewriteProposal> {
|
||||
return rcRequest<ReportRewriteProposal>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/report-rewrites`,
|
||||
{ method: "POST", body: payload, idempotencyKey },
|
||||
).then((res) => res as ReportRewriteProposal);
|
||||
}
|
||||
|
||||
export async function applyRewrite(
|
||||
sessionId: string,
|
||||
rewriteId: string,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<ReportVersion> {
|
||||
return rcRequest<ReportVersion>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/report-rewrites/${encodeURIComponent(rewriteId)}/apply`,
|
||||
{ method: "POST", body: {}, idempotencyKey },
|
||||
).then((res) => res as ReportVersion);
|
||||
}
|
||||
|
||||
/** 版本恢复:创建新的头版本,不删除历史。 */
|
||||
export async function restoreReportVersion(
|
||||
sessionId: string,
|
||||
versionId: string,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<ReportVersion> {
|
||||
return rcRequest<ReportVersion>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/report-versions/${encodeURIComponent(versionId)}/restore`,
|
||||
{ method: "POST", body: {}, idempotencyKey },
|
||||
).then((res) => res as ReportVersion);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Run 执行与干预:查询、事件回放、SSE 流、命令、取消、节点合同。
|
||||
*/
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { REPORT_COLLABORATION_BASE, newIdempotencyKey, rcGet, rcRequest } from "./client";
|
||||
import type {
|
||||
CollaborationCommand,
|
||||
CollaborationEvent,
|
||||
CreateCommandPayload,
|
||||
NodeContractDetail,
|
||||
ReportRun,
|
||||
StartRunPayload,
|
||||
} from "./types";
|
||||
|
||||
export async function startRun(
|
||||
sessionId: string,
|
||||
payload: StartRunPayload,
|
||||
expectedRevision?: number | null,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<ReportRun> {
|
||||
return rcRequest<ReportRun>(`/sessions/${encodeURIComponent(sessionId)}/runs`, {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
idempotencyKey,
|
||||
expectedRevision,
|
||||
}).then((res) => res as ReportRun);
|
||||
}
|
||||
|
||||
export async function getRun(runId: string, signal?: AbortSignal): Promise<ReportRun | null> {
|
||||
return rcRequest<ReportRun>(`/runs/${encodeURIComponent(runId)}`, {
|
||||
method: "GET",
|
||||
signal,
|
||||
nullOn404: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** 历史事件回放(断线补拉 / 序号缺口补齐)。 */
|
||||
export async function listRunEvents(
|
||||
runId: string,
|
||||
afterSeq: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CollaborationEvent[]> {
|
||||
const data = await rcGet<{ events: CollaborationEvent[] }>(
|
||||
`/runs/${encodeURIComponent(runId)}/events?after_seq=${Math.max(0, afterSeq)}`,
|
||||
signal,
|
||||
);
|
||||
return data.events ?? [];
|
||||
}
|
||||
|
||||
export async function cancelRun(
|
||||
runId: string,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<ReportRun> {
|
||||
return rcRequest<ReportRun>(`/runs/${encodeURIComponent(runId)}/cancel`, {
|
||||
method: "POST",
|
||||
body: {},
|
||||
idempotencyKey,
|
||||
}).then((res) => res as ReportRun);
|
||||
}
|
||||
|
||||
export async function getNodeContract(
|
||||
runId: string,
|
||||
nodeId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NodeContractDetail> {
|
||||
return rcGet<NodeContractDetail>(
|
||||
`/runs/${encodeURIComponent(runId)}/nodes/${encodeURIComponent(nodeId)}/contract`,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 用户命令(全过程干预)────────────────────────────────────────────────────
|
||||
|
||||
export async function createCommand(
|
||||
runId: string,
|
||||
payload: CreateCommandPayload,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<CollaborationCommand> {
|
||||
return rcRequest<CollaborationCommand>(`/runs/${encodeURIComponent(runId)}/commands`, {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
idempotencyKey,
|
||||
}).then((res) => res as CollaborationCommand);
|
||||
}
|
||||
|
||||
export async function confirmCommand(
|
||||
runId: string,
|
||||
commandId: string,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<CollaborationCommand> {
|
||||
return rcRequest<CollaborationCommand>(
|
||||
`/runs/${encodeURIComponent(runId)}/commands/${encodeURIComponent(commandId)}/confirm`,
|
||||
{ method: "POST", body: {}, idempotencyKey },
|
||||
).then((res) => res as CollaborationCommand);
|
||||
}
|
||||
|
||||
export async function cancelCommand(
|
||||
runId: string,
|
||||
commandId: string,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<CollaborationCommand> {
|
||||
return rcRequest<CollaborationCommand>(
|
||||
`/runs/${encodeURIComponent(runId)}/commands/${encodeURIComponent(commandId)}/cancel`,
|
||||
{ method: "POST",
|
||||
body: {},
|
||||
idempotencyKey },
|
||||
).then((res) => res as CollaborationCommand);
|
||||
}
|
||||
|
||||
// ── SSE 订阅 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RunStreamHandlers {
|
||||
onEvent: (event: CollaborationEvent) => void;
|
||||
onOpen?: () => void;
|
||||
/** 服务端正常关流(终态)。 */
|
||||
onClose?: () => void;
|
||||
/** 传输层异常(触发重连)。 */
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅 run 的 durable SSE 流。支持 `after_seq` 与 `Last-Event-ID`。
|
||||
*
|
||||
* 返回 close 函数:幂等,调用后中断连接且不再触发回调。
|
||||
* 注意:fetch 流式读取无法携带 EventSource 的自动重连,重连由
|
||||
* useReportCollaborationStream 以指数退避驱动。
|
||||
*/
|
||||
export function openRunStream(
|
||||
runId: string,
|
||||
afterSeq: number,
|
||||
handlers: RunStreamHandlers,
|
||||
): () => void {
|
||||
const controller = new AbortController();
|
||||
let closed = false;
|
||||
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
controller.abort();
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
`${REPORT_COLLABORATION_BASE()}/runs/${encodeURIComponent(runId)}/stream?after_seq=${Math.max(0, afterSeq)}`,
|
||||
{ method: "GET", signal: controller.signal, headers: { Accept: "text/event-stream" } },
|
||||
);
|
||||
if (!res.ok || !res.body) {
|
||||
if (!closed) handlers.onError?.(`stream failed (${res.status})`);
|
||||
return;
|
||||
}
|
||||
handlers.onOpen?.();
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let dataLines: string[] = [];
|
||||
let lastEventId: string | undefined;
|
||||
|
||||
const flushData = () => {
|
||||
if (dataLines.length === 0) return;
|
||||
const json = dataLines.join("\n").trim();
|
||||
dataLines = [];
|
||||
if (!json) return;
|
||||
try {
|
||||
const event = JSON.parse(json) as CollaborationEvent;
|
||||
if (lastEventId) event.eventId = event.eventId || lastEventId;
|
||||
handlers.onEvent(event);
|
||||
} catch {
|
||||
// 非 JSON 帧(如注释/心跳文本)忽略,不中断连接。
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const raw of lines) {
|
||||
const line = raw.replace(/\r$/, "");
|
||||
if (line === "") {
|
||||
flushData();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("id:")) {
|
||||
lastEventId = line.slice(3).trim() || undefined;
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice(5).trimStart());
|
||||
}
|
||||
// `event:` / `:` 注释行不影响 data 解析。
|
||||
}
|
||||
}
|
||||
flushData();
|
||||
if (!closed) handlers.onClose?.();
|
||||
} catch (err) {
|
||||
if (!closed && !controller.signal.aborted) {
|
||||
handlers.onError?.(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return close;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 会话 CRUD 与消息读写(`/api/report-collaboration/sessions`)。
|
||||
*/
|
||||
|
||||
import { newIdempotencyKey, rcGet, rcRequest } from "./client";
|
||||
import type {
|
||||
CollaborationMessage,
|
||||
CreateSessionPayload,
|
||||
ReportSession,
|
||||
ReportSessionSummary,
|
||||
SendMessagePayload,
|
||||
SessionSnapshot,
|
||||
UpdateSessionPayload,
|
||||
} from "./types";
|
||||
|
||||
export async function listSessions(signal?: AbortSignal): Promise<ReportSessionSummary[]> {
|
||||
const data = await rcGet<{ sessions: ReportSessionSummary[] }>("/sessions", signal);
|
||||
return data.sessions ?? [];
|
||||
}
|
||||
|
||||
export async function getSessionSnapshot(
|
||||
sessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionSnapshot | null> {
|
||||
return rcRequest<SessionSnapshot>(`/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
method: "GET",
|
||||
signal,
|
||||
nullOn404: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
payload: CreateSessionPayload = {},
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<ReportSession> {
|
||||
return rcRequest<ReportSession>("/sessions", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
idempotencyKey,
|
||||
}).then((res) => res as ReportSession);
|
||||
}
|
||||
|
||||
export async function updateSession(
|
||||
sessionId: string,
|
||||
patch: UpdateSessionPayload,
|
||||
expectedRevision?: number | null,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<ReportSession | null> {
|
||||
return rcRequest<ReportSession>(`/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
method: "PATCH",
|
||||
body: patch,
|
||||
idempotencyKey,
|
||||
expectedRevision,
|
||||
nullOn404: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
await rcRequest<void>(`/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function listMessages(
|
||||
sessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CollaborationMessage[]> {
|
||||
const data = await rcGet<{ messages: CollaborationMessage[] }>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/messages`,
|
||||
signal,
|
||||
);
|
||||
return data.messages ?? [];
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
sessionId: string,
|
||||
payload: SendMessagePayload,
|
||||
idempotencyKey = newIdempotencyKey(),
|
||||
): Promise<CollaborationMessage> {
|
||||
return rcRequest<CollaborationMessage>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/messages`,
|
||||
{ method: "POST", body: payload, idempotencyKey },
|
||||
).then((res) => res as CollaborationMessage);
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
/**
|
||||
* 报告协作工作台(AgentScope 多智能体)—— 前后端共享领域类型。
|
||||
*
|
||||
* 契约来源:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md` §5/§7。
|
||||
* 字段与后端 Pydantic 合同保持 snake_case 一一对应;前端只在 selector/adapter
|
||||
* 层做投影,不再二次推断状态。
|
||||
*
|
||||
* 后端尚未实现时本层即为冻结契约(RC-FE-004 前置条件),修改字段需同步两份计划文档。
|
||||
*/
|
||||
|
||||
// ── 会话 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 会话状态机(后端 §6 sessions.status)。 */
|
||||
export type ReportSessionStatus =
|
||||
| "empty"
|
||||
| "clarifying"
|
||||
| "planning"
|
||||
| "proposal_ready"
|
||||
| "running"
|
||||
| "awaiting_input"
|
||||
| "reviewing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export interface ReportSession {
|
||||
id: string;
|
||||
title: string;
|
||||
status: ReportSessionStatus;
|
||||
requirement_revision: number;
|
||||
selected_plan_id: string | null;
|
||||
active_run_id: string | null;
|
||||
current_report_version_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 会话列表条目(轻量,无 messages)。 */
|
||||
export type ReportSessionSummary = ReportSession;
|
||||
|
||||
// ── 需求快照 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReportRequirementSnapshot {
|
||||
topic: string;
|
||||
objective?: string | null;
|
||||
audience?: string | null;
|
||||
language?: string | null;
|
||||
time_range?: string | null;
|
||||
geography?: string | null;
|
||||
required_angles: string[];
|
||||
excluded_angles: string[];
|
||||
source_constraints?: string | null;
|
||||
report_structure_id?: string | null;
|
||||
length_target?: string | null;
|
||||
tone?: string | null;
|
||||
deadline_hint?: string | null;
|
||||
quality_priority?: string | null;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
// ── 消息镜像(ConversationReplica)──────────────────────────────────────────
|
||||
|
||||
export type CollaborationMessageRole = "human" | "ai" | "tool";
|
||||
|
||||
/** ai 消息上的工具调用(args 流式期间为部分 JSON 字符串,完成后为对象)。 */
|
||||
export interface CollaborationToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
args: string | Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端 `report_collaboration_messages` 行的镜像:用户可见的 assistant 文本、
|
||||
* TeamSay、`tool_calls` 与 `tool` 返回都落在这同一张时间线上。
|
||||
*/
|
||||
export interface CollaborationMessage {
|
||||
message_id: string;
|
||||
role: CollaborationMessageRole;
|
||||
content: string;
|
||||
/** 成员显示名(TeamSay / 成员回复 / tool 名)。 */
|
||||
name?: string | null;
|
||||
tool_calls?: CollaborationToolCall[];
|
||||
tool_call_id?: string | null;
|
||||
/** 并行阶段稳定归属;协调者广播可为 null(共享上下文)。 */
|
||||
agent_run_id?: string | null;
|
||||
node_run_id?: string | null;
|
||||
phase_id?: string | null;
|
||||
visibility: "visible" | "internal";
|
||||
metadata?: Record<string, unknown> | null;
|
||||
command_id?: string | null;
|
||||
created_at: string;
|
||||
/** null = 仍在流式输出。 */
|
||||
completed_at?: string | null;
|
||||
}
|
||||
|
||||
// ── 候选计划 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ReportPlanStatus =
|
||||
| "proposed"
|
||||
| "selected"
|
||||
| "superseded"
|
||||
| "rejected"
|
||||
| "executing"
|
||||
| "completed";
|
||||
|
||||
export type ReportCostLevel = "low" | "medium" | "high";
|
||||
|
||||
export interface PlanRole {
|
||||
key: string;
|
||||
display_name: string;
|
||||
responsibility: string;
|
||||
angle?: string | null;
|
||||
}
|
||||
|
||||
export interface QualityGate {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface PlanValidation {
|
||||
ok: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** 计划图节点:节点合同的用户可见摘要(不含执行结果)。 */
|
||||
export interface PlanNode {
|
||||
id: string;
|
||||
label: string;
|
||||
role_key?: string | null;
|
||||
role_display_name?: string | null;
|
||||
angle?: string | null;
|
||||
depends_on: string[];
|
||||
/** 合同摘要;完整合同走 `GET /runs/{run_id}/nodes/{node_id}/contract`。 */
|
||||
mission?: string | null;
|
||||
output_artifact_type?: string | null;
|
||||
input_artifact_types?: string[];
|
||||
allowed_tools?: string[];
|
||||
acceptance_criteria?: string[];
|
||||
}
|
||||
|
||||
export interface PlanEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
export interface ReportPlanCandidate {
|
||||
id: string;
|
||||
proposal_group_id: string;
|
||||
title: string;
|
||||
strategy: string;
|
||||
summary: string;
|
||||
rationale: string;
|
||||
recommended?: boolean;
|
||||
estimated_duration_seconds: number;
|
||||
estimated_cost_level: ReportCostLevel;
|
||||
requirement_revision: number;
|
||||
roles: PlanRole[];
|
||||
nodes: PlanNode[];
|
||||
edges: PlanEdge[];
|
||||
quality_gates: QualityGate[];
|
||||
report_structure_id: string | null;
|
||||
validation: PlanValidation;
|
||||
revision: number;
|
||||
status: ReportPlanStatus;
|
||||
}
|
||||
|
||||
// ── 节点运行状态 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** 后端 §5.5 状态机:completed 只能经 reopened 重新入队。 */
|
||||
export type CollaborationNodeStatus =
|
||||
| "planned"
|
||||
| "queued"
|
||||
| "running"
|
||||
| "validating"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "awaiting_input"
|
||||
| "cancel_requested"
|
||||
| "cancelled"
|
||||
| "superseded"
|
||||
| "reopened"
|
||||
| "interrupted";
|
||||
|
||||
export interface NodeAwaitingInput {
|
||||
reason: string;
|
||||
question: string;
|
||||
impact?: string[];
|
||||
}
|
||||
|
||||
export interface NodeAttemptState {
|
||||
attempt: number;
|
||||
status: CollaborationNodeStatus;
|
||||
started_at?: string | null;
|
||||
ended_at?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface CollaborationNodeState {
|
||||
node_id: string;
|
||||
label: string;
|
||||
role_display_name?: string | null;
|
||||
angle?: string | null;
|
||||
status: CollaborationNodeStatus;
|
||||
attempt: number;
|
||||
progress?: string | null;
|
||||
awaiting_input?: NodeAwaitingInput | null;
|
||||
error?: string | null;
|
||||
attempts: NodeAttemptState[];
|
||||
}
|
||||
|
||||
// ── 智能体成员(并行阶段稳定身份)────────────────────────────────────────────
|
||||
|
||||
export type AgentRunStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "waiting_input"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export interface AgentRun {
|
||||
agent_run_id: string;
|
||||
node_run_id?: string | null;
|
||||
phase_id?: string | null;
|
||||
role: string;
|
||||
display_name: string;
|
||||
avatar_key?: string | null;
|
||||
status: AgentRunStatus;
|
||||
started_at?: string | null;
|
||||
ended_at?: string | null;
|
||||
}
|
||||
|
||||
// ── Run ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ReportRunStatus =
|
||||
| "queued"
|
||||
| "planning"
|
||||
| "running"
|
||||
| "awaiting_input"
|
||||
| "reviewing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "recovering";
|
||||
|
||||
export interface ReportRun {
|
||||
id: string;
|
||||
session_id: string;
|
||||
plan_id: string;
|
||||
status: ReportRunStatus;
|
||||
cancel_requested: boolean;
|
||||
next_event_seq: number;
|
||||
current_phase?: string | null;
|
||||
usage?: Record<string, unknown>;
|
||||
budget_exhausted_reason?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ── 用户命令(全过程干预)────────────────────────────────────────────────────
|
||||
|
||||
export type CommandIntent =
|
||||
| "answer_question"
|
||||
| "update_requirements"
|
||||
| "add_angle"
|
||||
| "research_again"
|
||||
| "reanalyze"
|
||||
| "rerun_node"
|
||||
| "rewrite_section"
|
||||
| "polish_report"
|
||||
| "ask_report"
|
||||
| "replan"
|
||||
| "cancel";
|
||||
|
||||
export type CommandStatus =
|
||||
| "pending"
|
||||
| "classified"
|
||||
| "confirmation_required"
|
||||
| "accepted"
|
||||
| "accepted_waiting_boundary"
|
||||
| "executing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "rejected";
|
||||
|
||||
export interface CollaborationCommand {
|
||||
command_id: string;
|
||||
idempotency_key: string;
|
||||
text: string;
|
||||
intent?: CommandIntent | null;
|
||||
confidence?: number | null;
|
||||
target_type?: string | null;
|
||||
target_ids: string[];
|
||||
normalized_instruction?: string | null;
|
||||
requires_confirmation: boolean;
|
||||
cost_level?: ReportCostLevel | null;
|
||||
reason?: string | null;
|
||||
impact_nodes?: string[];
|
||||
status: CommandStatus;
|
||||
expected_revision?: number | null;
|
||||
error?: string | null;
|
||||
created_at: string;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
// ── 成果物 / 来源 / 报告版本 ────────────────────────────────────────────────
|
||||
|
||||
export type ArtifactValidationStatus =
|
||||
| "pending"
|
||||
| "validated"
|
||||
| "rejected"
|
||||
| "superseded";
|
||||
|
||||
export interface CollaborationArtifact {
|
||||
id: string;
|
||||
run_id: string;
|
||||
node_id: string;
|
||||
attempt: number;
|
||||
artifact_type: string;
|
||||
schema_version: number;
|
||||
validation_status: ArtifactValidationStatus;
|
||||
/** 用户可见摘要(如「12 条来源,8 条近 90 天」);不含正文。 */
|
||||
summary?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ReportSource {
|
||||
id: string;
|
||||
url?: string | null;
|
||||
title: string;
|
||||
published_at?: string | null;
|
||||
added_at: string;
|
||||
agent_run_id?: string | null;
|
||||
}
|
||||
|
||||
export type ReportChangeType =
|
||||
| "initial"
|
||||
| "section_rewrite"
|
||||
| "polish"
|
||||
| "restore"
|
||||
| "revision";
|
||||
|
||||
export interface ReportVersion {
|
||||
id: string;
|
||||
session_id: string;
|
||||
version: number;
|
||||
parent_version_id: string | null;
|
||||
change_type: ReportChangeType;
|
||||
change_summary?: string | null;
|
||||
markdown: string;
|
||||
source_index?: ReportSource[];
|
||||
created_at: string;
|
||||
is_head?: boolean;
|
||||
}
|
||||
|
||||
export interface ReportRewriteProposal {
|
||||
id: string;
|
||||
session_id: string;
|
||||
section?: string | null;
|
||||
instruction: string;
|
||||
status: "proposed" | "applied" | "dismissed";
|
||||
markdown?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ── 澄清 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ClarificationRecord {
|
||||
id: string;
|
||||
questions: string[];
|
||||
impact?: string[];
|
||||
resolved: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ── SSE 事件协议 ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type CollaborationEventType =
|
||||
// 会话与消息镜像
|
||||
| "session.snapshot"
|
||||
| "message.created"
|
||||
| "message.delta"
|
||||
| "message.completed"
|
||||
| "tool_call.created"
|
||||
| "tool_call.delta"
|
||||
| "tool_call.completed"
|
||||
| "tool_result.created"
|
||||
| "tool_result.delta"
|
||||
| "tool_result.completed"
|
||||
| "team_message.created"
|
||||
| "team_message.delta"
|
||||
| "team_message.completed"
|
||||
// 需求与澄清
|
||||
| "requirement.updated"
|
||||
| "clarification.requested"
|
||||
| "clarification.resolved"
|
||||
// 计划
|
||||
| "plan.progress"
|
||||
| "plan.proposed"
|
||||
| "plan.selected"
|
||||
// Run / 节点
|
||||
| "run.status.changed"
|
||||
| "node.created"
|
||||
| "node.status.changed"
|
||||
| "node.progress"
|
||||
| "node.attempt.created"
|
||||
// 成果与来源
|
||||
| "artifact.created"
|
||||
| "artifact.validated"
|
||||
| "artifact.rejected"
|
||||
| "source.added"
|
||||
// 命令
|
||||
| "command.classified"
|
||||
| "command.confirmation.required"
|
||||
| "command.accepted"
|
||||
| "command.completed"
|
||||
| "command.failed"
|
||||
| "command.cancelled"
|
||||
// 报告与评审
|
||||
| "report.delta"
|
||||
| "report.version.created"
|
||||
| "review.created"
|
||||
// 其他
|
||||
| "error"
|
||||
| "heartbeat";
|
||||
|
||||
export interface CollaborationEvent<T = unknown> {
|
||||
eventId: string;
|
||||
sessionId: string;
|
||||
runId?: string | null;
|
||||
seq: number;
|
||||
type: CollaborationEventType;
|
||||
timestamp: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// ── 事件 payload ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MessageEventPayload {
|
||||
message: CollaborationMessage;
|
||||
}
|
||||
|
||||
export interface MessageDeltaPayload {
|
||||
message_id: string;
|
||||
agent_run_id?: string | null;
|
||||
node_run_id?: string | null;
|
||||
delta: string;
|
||||
/** 思考过程增量,写入消息 `metadata.reasoning_content`,由适配器映射到问答 CoT。 */
|
||||
reasoning_delta?: string;
|
||||
}
|
||||
|
||||
export interface MessageCompletedPayload {
|
||||
message_id: string;
|
||||
agent_run_id?: string | null;
|
||||
content?: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
export interface ToolCallCreatedPayload {
|
||||
message_id: string;
|
||||
agent_run_id?: string | null;
|
||||
tool_call: CollaborationToolCall;
|
||||
}
|
||||
|
||||
export interface ToolCallDeltaPayload {
|
||||
message_id: string;
|
||||
tool_call_id: string;
|
||||
args_delta: string;
|
||||
}
|
||||
|
||||
export interface ToolCallCompletedPayload {
|
||||
message_id: string;
|
||||
tool_call: CollaborationToolCall;
|
||||
}
|
||||
|
||||
export interface ToolResultCreatedPayload {
|
||||
/** 工具返回作为独立 tool 消息的稳定 id(缺省时前端以 `tool:{tool_call_id}` 派生)。 */
|
||||
message_id?: string;
|
||||
tool_call_id: string;
|
||||
name: string;
|
||||
agent_run_id?: string | null;
|
||||
node_run_id?: string | null;
|
||||
phase_id?: string | null;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface ToolResultDeltaPayload {
|
||||
message_id?: string;
|
||||
tool_call_id: string;
|
||||
delta: string;
|
||||
}
|
||||
|
||||
export interface ToolResultCompletedPayload {
|
||||
message_id?: string;
|
||||
tool_call_id: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface RequirementUpdatedPayload {
|
||||
requirement: ReportRequirementSnapshot;
|
||||
}
|
||||
|
||||
export interface ClarificationRequestedPayload {
|
||||
clarification: ClarificationRecord;
|
||||
/** 同时镜像为 ask_clarification tool 消息(由后端 ConversationReplica 发出)。 */
|
||||
message?: CollaborationMessage;
|
||||
}
|
||||
|
||||
export interface ClarificationResolvedPayload {
|
||||
clarification_id: string;
|
||||
}
|
||||
|
||||
export interface PlanProgressPayload {
|
||||
stage: string;
|
||||
detail?: string | null;
|
||||
}
|
||||
|
||||
export interface PlanProposedPayload {
|
||||
plans: ReportPlanCandidate[];
|
||||
}
|
||||
|
||||
export interface PlanSelectedPayload {
|
||||
plan_id: string;
|
||||
}
|
||||
|
||||
export interface RunStatusChangedPayload {
|
||||
status: ReportRunStatus;
|
||||
phase?: string | null;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
export interface NodeCreatedPayload {
|
||||
node: CollaborationNodeState;
|
||||
}
|
||||
|
||||
export interface NodeStatusChangedPayload {
|
||||
node_id: string;
|
||||
status: CollaborationNodeStatus;
|
||||
attempt: number;
|
||||
awaiting_input?: NodeAwaitingInput | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface NodeProgressPayload {
|
||||
node_id: string;
|
||||
progress: string;
|
||||
}
|
||||
|
||||
export interface NodeAttemptCreatedPayload {
|
||||
node_id: string;
|
||||
attempt: number;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
export interface ArtifactCreatedPayload {
|
||||
artifact: CollaborationArtifact;
|
||||
}
|
||||
|
||||
export interface ArtifactValidatedPayload {
|
||||
artifact_id: string;
|
||||
}
|
||||
|
||||
export interface ArtifactRejectedPayload {
|
||||
artifact_id: string;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface SourceAddedPayload {
|
||||
source: ReportSource;
|
||||
}
|
||||
|
||||
export interface CommandClassifiedPayload {
|
||||
command: CollaborationCommand;
|
||||
}
|
||||
|
||||
export interface CommandConfirmationRequiredPayload {
|
||||
command: CollaborationCommand;
|
||||
}
|
||||
|
||||
export interface CommandAcceptedPayload {
|
||||
command_id: string;
|
||||
waiting_boundary: boolean;
|
||||
impact_nodes?: string[];
|
||||
}
|
||||
|
||||
export interface CommandCompletedPayload {
|
||||
command_id: string;
|
||||
result_summary?: string | null;
|
||||
report_version_id?: string | null;
|
||||
}
|
||||
|
||||
export interface CommandFailedPayload {
|
||||
command_id: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface CommandCancelledPayload {
|
||||
command_id: string;
|
||||
command?: CollaborationCommand;
|
||||
}
|
||||
|
||||
export interface ReportDeltaPayload {
|
||||
draft_id?: string | null;
|
||||
delta: string;
|
||||
}
|
||||
|
||||
export interface ReportVersionCreatedPayload {
|
||||
version: ReportVersion;
|
||||
}
|
||||
|
||||
export interface ReviewCreatedPayload {
|
||||
review: {
|
||||
reviewer: string;
|
||||
verdict: "pass" | "revise";
|
||||
issues: { severity: string; target: string; requirement: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ErrorEventPayload {
|
||||
message: string;
|
||||
recover_hint?: string | null;
|
||||
node_id?: string | null;
|
||||
}
|
||||
|
||||
export interface HeartbeatPayload {
|
||||
ts?: string;
|
||||
}
|
||||
|
||||
// ── 快照 / 请求体 ────────────────────────────────────────────────────────────
|
||||
|
||||
/** `GET /sessions/{id}` 返回的完整快照:前端唯一事实源投影。 */
|
||||
export interface SessionSnapshot {
|
||||
session: ReportSession;
|
||||
messages: CollaborationMessage[];
|
||||
plans: ReportPlanCandidate[];
|
||||
run: ReportRun | null;
|
||||
nodes: CollaborationNodeState[];
|
||||
agent_runs: AgentRun[];
|
||||
commands: CollaborationCommand[];
|
||||
report_versions: ReportVersion[];
|
||||
/** 已持久化事件的最大 seq;SSE 以 after_seq=last_seq 续接。 */
|
||||
last_seq: number;
|
||||
}
|
||||
|
||||
export interface CreateSessionPayload {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSessionPayload {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface SendMessagePayload {
|
||||
text: string;
|
||||
/** 从节点抽屉 / 并行选择器带出的目标上下文。 */
|
||||
target_node_id?: string | null;
|
||||
agent_run_id?: string | null;
|
||||
}
|
||||
|
||||
export interface StartRunPayload {
|
||||
plan_id: string;
|
||||
}
|
||||
|
||||
export interface CreateCommandPayload {
|
||||
text: string;
|
||||
target_node_id?: string | null;
|
||||
agent_run_id?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateRewritePayload {
|
||||
instruction: string;
|
||||
section?: string | null;
|
||||
}
|
||||
|
||||
/** 统一错误模型(client 层抛出的 ApiError 携带这些字段)。 */
|
||||
export interface ApiErrorDetail {
|
||||
code?: string;
|
||||
message: string;
|
||||
detail?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /runs/{run_id}/nodes/{node_id}/contract` 返回的节点合同详情。
|
||||
* 只含职责与输入输出合同,绝不含执行结果正文(后端 §7.1)。
|
||||
*/
|
||||
export interface NodeContractDetail {
|
||||
node_id: string;
|
||||
label: string;
|
||||
role: string | null;
|
||||
angle: string | null;
|
||||
status: CollaborationNodeStatus;
|
||||
attempt: number;
|
||||
mission: string;
|
||||
input_artifact_types: string[];
|
||||
source_policy?: string | null;
|
||||
output_artifact_type: string;
|
||||
output_structure?: string | null;
|
||||
acceptance_criteria: string[];
|
||||
allowed_tools: string[];
|
||||
forbidden?: string[];
|
||||
time_budget_seconds?: number | null;
|
||||
token_budget?: number | null;
|
||||
max_attempts: number;
|
||||
upstream: string[];
|
||||
downstream: string[];
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { Check, Loader2, X } from "lucide-react";
|
||||
|
||||
import type { AgentRun } from "@/report-collaboration/api/types";
|
||||
import { roleIcon } from "@/report-collaboration/components/ReportFlowNode";
|
||||
|
||||
interface AgentStageSelectorProps {
|
||||
/** 当前正在执行的成员;空数组且无 pinned 时不渲染。 */
|
||||
tabs: AgentRun[];
|
||||
/** 点击画布节点后钉住的历史成员(波次结束后回看步骤条)。 */
|
||||
pinnedAgent?: AgentRun | null;
|
||||
phaseLabel?: string | null;
|
||||
focusedAgentRunId: string | null;
|
||||
onFocus: (agentRunId: string) => void;
|
||||
/** 取消钉住,回到全量时间线。 */
|
||||
onShowAll?: () => void;
|
||||
/** 正在流式输出的成员。 */
|
||||
streamingAgentRunIds: Set<string>;
|
||||
/** 请求用户协助(待回复)的成员。 */
|
||||
awaitingAgentRunIds: Set<string>;
|
||||
}
|
||||
|
||||
function avatarStatus(
|
||||
agentRun: AgentRun,
|
||||
streaming: boolean,
|
||||
awaiting: boolean,
|
||||
): "running" | "awaiting" | "done" | "error" | "queued" {
|
||||
if (awaiting) return "awaiting";
|
||||
if (agentRun.status === "failed" || agentRun.status === "cancelled") return "error";
|
||||
if (agentRun.status === "completed") return "done";
|
||||
if (agentRun.status === "running" || streaming) return "running";
|
||||
return "queued";
|
||||
}
|
||||
|
||||
function statusLabel(status: ReturnType<typeof avatarStatus>, displayName: string): string {
|
||||
const suffix =
|
||||
status === "running"
|
||||
? "进行中"
|
||||
: status === "awaiting"
|
||||
? "待回复"
|
||||
: status === "done"
|
||||
? "已完成"
|
||||
: status === "error"
|
||||
? "失败"
|
||||
: "排队中";
|
||||
return `${displayName}(${suffix})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行中头像条(对齐会商第二步 sandbox):
|
||||
* 单个模型节点与并行波次都展示;点头像切换到该成员的步骤条 / 模型输出。
|
||||
* 未选中成员的事件继续接收并持久更新。
|
||||
*/
|
||||
export const AgentStageSelector = memo(function AgentStageSelector({
|
||||
tabs,
|
||||
pinnedAgent = null,
|
||||
phaseLabel,
|
||||
focusedAgentRunId,
|
||||
onFocus,
|
||||
onShowAll,
|
||||
streamingAgentRunIds,
|
||||
awaitingAgentRunIds,
|
||||
}: AgentStageSelectorProps) {
|
||||
const visibleTabs =
|
||||
tabs.length > 0
|
||||
? tabs
|
||||
: pinnedAgent
|
||||
? [pinnedAgent]
|
||||
: [];
|
||||
if (visibleTabs.length === 0) return null;
|
||||
const focused = focusedAgentRunId ?? visibleTabs[0]?.agent_run_id;
|
||||
const runningCount = visibleTabs.filter((tab) => {
|
||||
const status = avatarStatus(
|
||||
tab,
|
||||
streamingAgentRunIds.has(tab.agent_run_id),
|
||||
awaitingAgentRunIds.has(tab.agent_run_id),
|
||||
);
|
||||
return status === "running" || status === "awaiting";
|
||||
}).length;
|
||||
const stripLabel =
|
||||
phaseLabel ??
|
||||
(visibleTabs.length > 1 ? `并行 ${visibleTabs.length} 席` : visibleTabs[0]?.display_name ?? "当前节点");
|
||||
|
||||
return (
|
||||
<div className="rc-agent-tabs" role="tablist" aria-label="执行成员">
|
||||
<div className="rc-agent-strip-meta">
|
||||
<span>{stripLabel}</span>
|
||||
{runningCount > 0 && <span className="rc-agent-strip-running">{runningCount} 进行中</span>}
|
||||
</div>
|
||||
<div className="rc-agent-avatars">
|
||||
{visibleTabs.map((tab) => {
|
||||
const streaming = streamingAgentRunIds.has(tab.agent_run_id);
|
||||
const awaiting = awaitingAgentRunIds.has(tab.agent_run_id);
|
||||
const status = avatarStatus(tab, streaming, awaiting);
|
||||
const active = focused === tab.agent_run_id;
|
||||
return (
|
||||
<button
|
||||
key={tab.agent_run_id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
data-active={active}
|
||||
data-status={status}
|
||||
data-role={tab.role}
|
||||
className="rc-agent-avatar"
|
||||
title={statusLabel(status, tab.display_name)}
|
||||
aria-label={statusLabel(status, tab.display_name)}
|
||||
onClick={() => onFocus(tab.agent_run_id)}
|
||||
>
|
||||
{status === "running" && <span className="rc-agent-avatar-ping" aria-hidden />}
|
||||
<span className="rc-agent-avatar-face" aria-hidden>
|
||||
{roleIcon(tab.role)}
|
||||
</span>
|
||||
<span className="rc-agent-avatar-badge" data-status={status} aria-hidden>
|
||||
{status === "running" && <Loader2 className="rc-spin size-2.5" />}
|
||||
{status === "awaiting" && <span className="rc-agent-avatar-dot" />}
|
||||
{status === "done" && <Check className="size-2.5" strokeWidth={3} />}
|
||||
{status === "error" && <X className="size-2.5" strokeWidth={3} />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{onShowAll && (tabs.length <= 1 || Boolean(pinnedAgent)) ? (
|
||||
<button type="button" className="rc-btn rc-agent-show-all" onClick={onShowAll}>
|
||||
全部对话
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
import type { RcConnectionStatus } from "@/report-collaboration/hooks/useReportCollaborationStream";
|
||||
|
||||
const STATUS_TEXT: Record<RcConnectionStatus, string> = {
|
||||
idle: "未连接",
|
||||
connecting: "连接中…",
|
||||
live: "实时",
|
||||
reconnecting: "重连中…",
|
||||
closed: "已关闭",
|
||||
error: "连接异常",
|
||||
};
|
||||
|
||||
/** 底部连接状态栏:连接态 + run + 事件游标(aria-live 播报)。 */
|
||||
export function ConnectionStatusBar({
|
||||
status,
|
||||
runId,
|
||||
lastSeq,
|
||||
message,
|
||||
}: {
|
||||
status: RcConnectionStatus;
|
||||
runId: string | null;
|
||||
lastSeq: number;
|
||||
message?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
void t;
|
||||
return (
|
||||
<footer className="rc-status-bar" aria-live="polite">
|
||||
<span>
|
||||
<span className="rc-status-dot" data-status={status} />
|
||||
SSE:{STATUS_TEXT[status]}
|
||||
{status === "reconnecting" && message ? `(${message.slice(0, 80)})` : ""}
|
||||
</span>
|
||||
<span>run:{runId ? runId.slice(0, 18) : "—"}</span>
|
||||
<span>事件 seq:{lastSeq}</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, Check, Loader2, X } from "lucide-react";
|
||||
|
||||
import type { CollaborationCommand } from "@/report-collaboration/api/types";
|
||||
|
||||
const INTENT_LABELS: Record<string, string> = {
|
||||
answer_question: "只读问答",
|
||||
update_requirements: "更新需求",
|
||||
add_angle: "新增分析角度",
|
||||
research_again: "重新检索",
|
||||
reanalyze: "重新分析",
|
||||
rerun_node: "重跑节点",
|
||||
rewrite_section: "重写章节",
|
||||
polish_report: "全文润色",
|
||||
ask_report: "报告问答",
|
||||
replan: "重新规划",
|
||||
cancel: "取消",
|
||||
};
|
||||
|
||||
interface InterventionCardProps {
|
||||
command: CollaborationCommand;
|
||||
busy: boolean;
|
||||
onConfirm: (commandId: string) => void;
|
||||
onCancel: (commandId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令确认卡:高成本 / 低置信度命令需要用户显式确认。
|
||||
* 确认/取消都只是提交命令,不改变节点完成状态(状态只由后端状态机驱动)。
|
||||
*/
|
||||
export function InterventionCard({ command, busy, onConfirm, onCancel }: InterventionCardProps) {
|
||||
return (
|
||||
<div className="rc-intervention-card">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" style={{ color: "var(--rc-waiting)" }} aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-semibold">需要确认:{INTENT_LABELS[command.intent ?? ""] ?? "修改操作"}</div>
|
||||
<p className="mt-0.5 text-[12px] leading-5 text-[var(--rc-text-secondary)]">
|
||||
「{command.text}」
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="list-disc pl-8 text-[11px] leading-4 text-[var(--rc-text-secondary)]">
|
||||
<li>
|
||||
影响范围:
|
||||
{command.impact_nodes && command.impact_nodes.length > 0
|
||||
? command.impact_nodes.join("、") + " 及其下游"
|
||||
: "由协调者判定"}
|
||||
</li>
|
||||
<li>
|
||||
成本:{command.cost_level === "high" ? "高(触发重新检索/重写)" : command.cost_level === "low" ? "低" : "中"}
|
||||
</li>
|
||||
{command.reason ? <li>理由:{command.reason}</li> : null}
|
||||
</ul>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" className="rc-btn" disabled={busy} onClick={() => onCancel(command.command_id)}>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rc-btn rc-btn-primary"
|
||||
disabled={busy}
|
||||
onClick={() => onConfirm(command.command_id)}
|
||||
>
|
||||
{busy ? <Loader2 className="rc-spin size-3.5" /> : <Check className="size-3.5" aria-hidden />}
|
||||
确认执行
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Ban, ChevronDown, Target, X } from "lucide-react";
|
||||
|
||||
import { getReportCollaborationDriver } from "@/report-collaboration/api/driver";
|
||||
import type { NodeContractDetail } from "@/report-collaboration/api/types";
|
||||
import { roleIcon, type ReportFlowNodeKind } from "@/report-collaboration/components/ReportFlowNode";
|
||||
|
||||
export interface NodeInspectorFallback {
|
||||
label: string;
|
||||
status: string;
|
||||
role?: string | null;
|
||||
angle?: string | null;
|
||||
kind?: ReportFlowNodeKind;
|
||||
mission?: string | null;
|
||||
input_artifact_types?: string[];
|
||||
output_artifact_type?: string | null;
|
||||
allowed_tools?: string[];
|
||||
acceptance_criteria?: string[];
|
||||
upstream?: string[];
|
||||
downstream?: string[];
|
||||
}
|
||||
|
||||
interface NodeInspectorDrawerProps {
|
||||
open: boolean;
|
||||
nodeId: string | null;
|
||||
runId: string | null;
|
||||
/** 节点基础信息(画布侧已有,预览方案时也能填抽屉,不发起结果请求)。 */
|
||||
fallback?: NodeInspectorFallback | null;
|
||||
onClose: () => void;
|
||||
/** 「在对话中对此节点提出意见」:关闭抽屉 + 聚焦 Composer + 绑定目标。 */
|
||||
onComment: (nodeId: string, nodeName: string) => void;
|
||||
}
|
||||
|
||||
const SECTION_KEYS = ["mission", "input", "output", "tools", "graph"] as const;
|
||||
type SectionKey = (typeof SECTION_KEYS)[number];
|
||||
|
||||
/**
|
||||
* 只读节点说明抽屉:覆盖在画布右侧,不占第三栏。
|
||||
* 只读取节点合同(职责/输入/产出/工具/验收),不请求节点执行结果;
|
||||
* 执行过程统一在左侧 MessageList 查看。
|
||||
*/
|
||||
export function NodeInspectorDrawer({
|
||||
open,
|
||||
nodeId,
|
||||
runId,
|
||||
fallback,
|
||||
onClose,
|
||||
onComment,
|
||||
}: NodeInspectorDrawerProps) {
|
||||
const [contract, setContract] = useState<NodeContractDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openSections, setOpenSections] = useState<Record<SectionKey, boolean>>({
|
||||
mission: true,
|
||||
input: true,
|
||||
output: true,
|
||||
tools: true,
|
||||
graph: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !nodeId || !runId) {
|
||||
setContract(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getReportCollaborationDriver()
|
||||
.getNodeContract(runId, nodeId)
|
||||
.then((detail) => {
|
||||
if (!cancelled) setContract(detail);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, nodeId, runId]);
|
||||
|
||||
const view = useMemo(() => {
|
||||
return {
|
||||
label: contract?.label ?? fallback?.label ?? nodeId ?? "",
|
||||
status: contract?.status ?? fallback?.status ?? "planned",
|
||||
role: contract?.role ?? fallback?.role ?? null,
|
||||
angle: contract?.angle ?? fallback?.angle ?? null,
|
||||
kind: fallback?.kind ?? "agent",
|
||||
mission: contract?.mission ?? fallback?.mission ?? null,
|
||||
input_artifact_types: contract?.input_artifact_types ?? fallback?.input_artifact_types ?? [],
|
||||
output_artifact_type: contract?.output_artifact_type ?? fallback?.output_artifact_type ?? null,
|
||||
allowed_tools: contract?.allowed_tools ?? fallback?.allowed_tools ?? [],
|
||||
acceptance_criteria: contract?.acceptance_criteria ?? fallback?.acceptance_criteria ?? [],
|
||||
upstream: contract?.upstream ?? fallback?.upstream ?? [],
|
||||
downstream: contract?.downstream ?? fallback?.downstream ?? [],
|
||||
forbidden: contract?.forbidden ?? [],
|
||||
max_attempts: contract?.max_attempts ?? null,
|
||||
time_budget_seconds: contract?.time_budget_seconds ?? null,
|
||||
token_budget: contract?.token_budget ?? null,
|
||||
source_policy: contract?.source_policy ?? null,
|
||||
};
|
||||
}, [contract, fallback, nodeId]);
|
||||
|
||||
if (!open || !nodeId) return null;
|
||||
|
||||
const hasBody = Boolean(contract) || Boolean(fallback?.mission) || Boolean(fallback?.label);
|
||||
const showLoading = loading && !hasBody;
|
||||
|
||||
const toggle = (key: SectionKey) => {
|
||||
setOpenSections((current) => ({ ...current, [key]: !current[key] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="rc-drawer" role="dialog" aria-label={`节点说明:${view.label}`}>
|
||||
<header className="rc-drawer-header">
|
||||
<span className="rc-flow-node-icon" aria-hidden>
|
||||
{roleIcon(view.role, view.kind)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="rc-drawer-title">{view.label}</div>
|
||||
<div className="rc-drawer-subtitle">
|
||||
{view.role ? <span>{view.role}</span> : null}
|
||||
{view.angle ? <span>· {view.angle}</span> : null}
|
||||
<span>· {statusText(view.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="rc-drawer-icon-btn" onClick={onClose} aria-label="关闭节点说明">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="rc-drawer-body">
|
||||
{showLoading ? (
|
||||
<div className="rc-empty py-10 text-[12px]">正在读取节点合同…</div>
|
||||
) : error && !hasBody ? (
|
||||
<div className="rc-empty py-10 text-[12px]" style={{ color: "var(--rc-danger)" }}>
|
||||
读取节点合同失败:{error}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DrawerSection title="节点职责" open={openSections.mission} onToggle={() => toggle("mission")}>
|
||||
<p className="rc-drawer-text">{view.mission || "暂无职责说明"}</p>
|
||||
</DrawerSection>
|
||||
<DrawerSection title="输入" open={openSections.input} onToggle={() => toggle("input")}>
|
||||
<ul className="rc-drawer-list">
|
||||
{view.input_artifact_types.length > 0 ? (
|
||||
view.input_artifact_types.map((type) => (
|
||||
<li key={type}>
|
||||
<span className="rc-flow-pill rc-flow-pill-input">{shortArtifact(type)}</span>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="text-[var(--rc-text-secondary)]">无上游成果要求</li>
|
||||
)}
|
||||
{view.upstream.length > 0 ? <li>上游节点:{view.upstream.join("、")}</li> : null}
|
||||
{view.source_policy ? <li>资料策略:{view.source_policy}</li> : null}
|
||||
</ul>
|
||||
</DrawerSection>
|
||||
<DrawerSection title="输出" open={openSections.output} onToggle={() => toggle("output")}>
|
||||
<ul className="rc-drawer-list">
|
||||
<li>
|
||||
成果类型:
|
||||
{view.output_artifact_type ? (
|
||||
<span className="rc-flow-pill rc-flow-pill-output">{shortArtifact(view.output_artifact_type)}</span>
|
||||
) : (
|
||||
"未指定"
|
||||
)}
|
||||
</li>
|
||||
{view.acceptance_criteria.map((criterion) => (
|
||||
<li key={criterion}>验收:{criterion}</li>
|
||||
))}
|
||||
</ul>
|
||||
</DrawerSection>
|
||||
<DrawerSection title="技能" open={openSections.tools} onToggle={() => toggle("tools")}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{view.allowed_tools.length > 0 ? (
|
||||
view.allowed_tools.map((tool) => (
|
||||
<span key={tool} className="rc-flow-pill rc-flow-pill-neutral">
|
||||
{tool}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[12px] text-[var(--rc-text-secondary)]">未配置技能</span>
|
||||
)}
|
||||
</div>
|
||||
{view.forbidden.length > 0 ? (
|
||||
<ul className="rc-drawer-list mt-2">
|
||||
{view.forbidden.map((rule) => (
|
||||
<li key={rule}>
|
||||
<Ban className="mr-1 inline size-3" aria-hidden />
|
||||
{rule}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{view.max_attempts != null ? (
|
||||
<p className="mt-2 text-[12px] text-[var(--rc-text-secondary)]">
|
||||
预算:至多 {view.max_attempts} 次尝试
|
||||
{view.time_budget_seconds ? ` · ${view.time_budget_seconds}s` : ""}
|
||||
{view.token_budget ? ` · ${view.token_budget} tokens` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
</DrawerSection>
|
||||
<DrawerSection title="上下游" open={openSections.graph} onToggle={() => toggle("graph")}>
|
||||
<p className="rc-drawer-text">
|
||||
{view.downstream.length > 0 ? `下游:${view.downstream.join("、")}` : "终端节点(无下游)"}
|
||||
</p>
|
||||
</DrawerSection>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="rc-drawer-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="rc-btn rc-btn-primary w-full justify-center"
|
||||
onClick={() => onComment(nodeId, view.label)}
|
||||
>
|
||||
<Target className="size-3.5" aria-hidden />
|
||||
在对话中对此节点提出意见
|
||||
</button>
|
||||
<p className="mt-2 text-[11px] leading-4 text-[var(--rc-text-secondary)]">
|
||||
重跑、补充或纠偏通过对话完成(例如「重新检索」「重新分析」);抽屉不直接发起变更。
|
||||
</p>
|
||||
</footer>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerSection({
|
||||
title,
|
||||
open,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rc-drawer-section" data-open={open}>
|
||||
<button type="button" className="rc-drawer-section-toggle" onClick={onToggle} aria-expanded={open}>
|
||||
<ChevronDown className="rc-drawer-chevron" aria-hidden />
|
||||
<span>{title}</span>
|
||||
</button>
|
||||
{open ? <div className="rc-drawer-section-body">{children}</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function shortArtifact(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return value;
|
||||
return trimmed.includes(":") ? trimmed.slice(trimmed.lastIndexOf(":") + 1) : trimmed;
|
||||
}
|
||||
|
||||
function statusText(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
planned: "规划中",
|
||||
queued: "排队中",
|
||||
running: "运行中",
|
||||
validating: "校验中",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
awaiting_input: "等待用户",
|
||||
cancel_requested: "取消中",
|
||||
cancelled: "已取消",
|
||||
superseded: "已被替代",
|
||||
reopened: "已重开",
|
||||
interrupted: "已中断",
|
||||
};
|
||||
return map[status] ?? status;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, FileText, MessageSquarePlus, Square } from "lucide-react";
|
||||
|
||||
import type { PagePhase } from "@/report-collaboration/state/selectors";
|
||||
|
||||
const PHASE_BADGES: Record<PagePhase, { label: string; color: string; bg: string }> = {
|
||||
empty: { label: "新会话", color: "var(--rc-text-secondary)", bg: "transparent" },
|
||||
clarifying: { label: "澄清需求", color: "var(--rc-waiting)", bg: "rgba(217, 119, 6, 0.1)" },
|
||||
planning: { label: "生成方案", color: "var(--rc-running)", bg: "rgba(37, 99, 235, 0.1)" },
|
||||
proposal_ready: { label: "待确认方案", color: "var(--rc-accent)", bg: "rgb(var(--rc-accent-rgb) / 10%)" },
|
||||
running: { label: "协作中", color: "var(--rc-running)", bg: "rgba(37, 99, 235, 0.1)" },
|
||||
awaiting_input: { label: "等待输入", color: "var(--rc-waiting)", bg: "rgba(217, 119, 6, 0.1)" },
|
||||
reviewing: { label: "审稿中", color: "var(--rc-running)", bg: "rgba(37, 99, 235, 0.1)" },
|
||||
completed: { label: "已完成", color: "var(--rc-success)", bg: "rgba(22, 163, 74, 0.1)" },
|
||||
failed: { label: "失败", color: "var(--rc-danger)", bg: "rgba(220, 38, 38, 0.1)" },
|
||||
cancelled: { label: "已取消", color: "var(--rc-text-secondary)", bg: "transparent" },
|
||||
};
|
||||
|
||||
interface ReportCollaborationHeaderProps {
|
||||
title: string;
|
||||
phase: PagePhase;
|
||||
onBack: () => void;
|
||||
onNewSession: () => void;
|
||||
onOpenReport: () => void;
|
||||
hasReport: boolean;
|
||||
running: boolean;
|
||||
onCancelRun: () => void;
|
||||
extra?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** 顶栏:返回 / 标题+状态 / 报告 / 停止 / 新建(右侧由布局注入窄屏切换等)。 */
|
||||
export function ReportCollaborationHeader({
|
||||
title,
|
||||
phase,
|
||||
onBack,
|
||||
onNewSession,
|
||||
onOpenReport,
|
||||
hasReport,
|
||||
running,
|
||||
onCancelRun,
|
||||
extra,
|
||||
}: ReportCollaborationHeaderProps) {
|
||||
const badge = PHASE_BADGES[phase] ?? PHASE_BADGES.empty;
|
||||
return (
|
||||
<header className="rc-header">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<button type="button" className="rc-btn !min-h-8 !px-2" onClick={onBack} aria-label="返回工作台">
|
||||
<ArrowLeft className="size-4" />
|
||||
</button>
|
||||
<span className="rc-header-title" title={title}>
|
||||
报告协作 / {title}
|
||||
</span>
|
||||
<span className="rc-chip" style={{ background: badge.bg, color: badge.color }}>
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{extra}
|
||||
<button type="button" className="rc-btn" onClick={onOpenReport} disabled={!hasReport}>
|
||||
<FileText className="size-3.5" aria-hidden />
|
||||
报告
|
||||
</button>
|
||||
{running ? (
|
||||
<button type="button" className="rc-btn rc-btn-danger" onClick={onCancelRun}>
|
||||
<Square className="size-3.5" aria-hidden />
|
||||
停止
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="rc-btn" onClick={onNewSession}>
|
||||
<MessageSquarePlus className="size-3.5" aria-hidden />
|
||||
新建
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 418 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user