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

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

No files matched your search

+11
View File
@@ -101,6 +101,17 @@ offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py !offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.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_llmwiki_wiki_retrieval.py
# Deep-research reliability regression coverage.
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py
!offline-backend-20260512/backend/tests/test_deep_research_start_job.py
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
# Independent enterprise-research persistence, HTTP and race regressions.
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
deploy deploy
.history .history
+2 -2
View File
@@ -81,11 +81,11 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps) - **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`. - **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`.
- **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答直连外部 AG-UI 网关(POST SSE),不经过 LangGraph,会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`),核心模块 `src/core/sentiment-agent/`。 - **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答直连外部 AG-UI 网关(POST SSE),不经过 LangGraph,会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`),核心模块 `src/core/sentiment-agent/`。
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. - **敏感词管理 (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/`。 - **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. - **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` - **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 - **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/` - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
+2 -2
View File
@@ -82,11 +82,11 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API. - **定时任务「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`. - **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`.
- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`. - **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`.
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. - **敏感词管理 (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/`。 - **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, openMode, loginParams, appendAuth, authTokenParam, authNameParam, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=<JWT>&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」页,在 **both** 单智能体 (`TaskFxWorkspace`) 与 多智能体会商 (`RoundtablePlanningPage` 深链 rwfx-A) 都显示(共用 `TaskJumpBar`)。**`runTaskButton` 的 `url` 类型支持应用内路由**:target 以单个 `/` 开头时按内部路由处理——`self` 走 react-router 同窗导航(不刷新),`blank` 拼成当前源的 hash 路由新标签打开;外链行为不变。 The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三Line truncated - **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, openMode, loginParams, appendAuth, authTokenParam, authNameParam, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route (e.g. `/task/item-aiceh/step-analysis-audience`), `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). Independent-tab clicks toast and do nothing. **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=<JWT>&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」Line truncated
- **实时并发监控 (Concurrency / System-Pressure Monitor, admin-only)**: a live panel mounted at the **top of the admin 排行榜 page** (`components/workspace/admin/concurrency-monitor.tsx` `ConcurrencyMonitorPanel`, rendered in `pages/AdminUserLeaderboardPage.tsx`) showing **当前并发的大模型调用数 + 列表(用户/智能体/类型/已运行时长)**, each row with a 「停止」button (and a 「全部停止」) that **truly aborts** that run's in-flight model stream via `RunManager.cancel` (`POST /api/admin/active-runs/{run_id}/cancel`) — to relieve pressure when 使用人数过多. Two line charts (minute/hour toggle): **并发量折线图** (peak/avg, `GET /api/admin/active-runs/concurrency`, fed by a lightweight background sampler into `concurrency_samples`) and **大模型出字速度折线图** (tokens/sec, `GET /api/admin/active-runs/token-speed`, aggregated from `llm_call_metrics`) so a dip pinpoints where the bottleneck is. A **「后台采样」Switch** (`system_settings.json → concurrency_monitor.enabled`, **default off / opt-in**, `GET|PUT /api/admin/active-runs/settings`) turns the background sampling on/off **at runtime, no restart** (the live concurrency count + 「停止」 work even while sampling is off — only the 并发量 history chart needs it on); everything is designed to **never impact production** (one tiny INSERT/15s, retention-purged, best-effort). Client/hooks: `core/admin/active-runs.ts` + `core/admin/hooks.ts` (`useActiveRuns`/`useConcurrencySeries`/`useTokenSpeedSeries`/`useCancel*`/`useMonitorSettings`). See the backend CLAUDE.md「Admin Concurrency Monitor」. - **实时并发监控 (Concurrency / System-Pressure Monitor, admin-only)**: a live panel mounted at the **top of the admin 排行榜 page** (`components/workspace/admin/concurrency-monitor.tsx` `ConcurrencyMonitorPanel`, rendered in `pages/AdminUserLeaderboardPage.tsx`) showing **当前并发的大模型调用数 + 列表(用户/智能体/类型/已运行时长)**, each row with a 「停止」button (and a 「全部停止」) that **truly aborts** that run's in-flight model stream via `RunManager.cancel` (`POST /api/admin/active-runs/{run_id}/cancel`) — to relieve pressure when 使用人数过多. Two line charts (minute/hour toggle): **并发量折线图** (peak/avg, `GET /api/admin/active-runs/concurrency`, fed by a lightweight background sampler into `concurrency_samples`) and **大模型出字速度折线图** (tokens/sec, `GET /api/admin/active-runs/token-speed`, aggregated from `llm_call_metrics`) so a dip pinpoints where the bottleneck is. A **「后台采样」Switch** (`system_settings.json → concurrency_monitor.enabled`, **default off / opt-in**, `GET|PUT /api/admin/active-runs/settings`) turns the background sampling on/off **at runtime, no restart** (the live concurrency count + 「停止」 work even while sampling is off — only the 并发量 history chart needs it on); everything is designed to **never impact production** (one tiny INSERT/15s, retention-purged, best-effort). Client/hooks: `core/admin/active-runs.ts` + `core/admin/hooks.ts` (`useActiveRuns`/`useConcurrencySeries`/`useTokenSpeedSeries`/`useCancel*`/`useMonitorSettings`). See the backend CLAUDE.md「Admin Concurrency Monitor」.
- **会话冲突 409 中文化**: when a user submits a new question while the previous turn is still streaming, the backend's `RunManager.create_or_reject` rejects with HTTP 409 (`services.py` now returns a **Chinese** detail「当前对话还在生成回答…」). `core/threads/hooks.ts` `onError` detects the conflict (`isActiveRunConflictError`: 409 / "already has an active run" / the Chinese text) and shows a friendly `toast.info` instead of a raw error. - **会话冲突 409 中文化**: when a user submits a new question while the previous turn is still streaming, the backend's `RunManager.create_or_reject` rejects with HTTP 409 (`services.py` now returns a **Chinese** detail「当前对话还在生成回答…」). `core/threads/hooks.ts` `onError` detects the conflict (`isActiveRunConflictError`: 409 / "already has an active run" / the Chinese text) and shows a friendly `toast.info` instead of a raw error.
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` - **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
+3
View File
@@ -56,4 +56,7 @@
# VITE_STATIC_WEBSITE_ONLY= # VITE_STATIC_WEBSITE_ONLY=
# 部署级紧凑导航:true 时固定收起工作台左侧栏,并隐藏换肤与全量/简洁模式切换。 # 部署级紧凑导航:true 时固定收起工作台左侧栏,并隐藏换肤与全量/简洁模式切换。
# VITE_FORCE_COLLAPSED_SIDEBAR=false # VITE_FORCE_COLLAPSED_SIDEBAR=false
# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。
# VITE_COMPACT_MENU_TYPE=A
# VITE_GITHUB_OAUTH_TOKEN= # VITE_GITHUB_OAUTH_TOKEN=
File diff suppressed because it is too large. Load diff
+21 -3
View File
@@ -32,10 +32,10 @@ window.__CM_RUNTIME_CONFIG__ = {
VITE_BRAND_WELCOME_DARK_URL: "", VITE_BRAND_WELCOME_DARK_URL: "",
// DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。 // DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。
// VITE_BACKEND_BASE_URL: "http://10.19.200.5:2026", // VITE_BACKEND_BASE_URL: "https://ch1.b.uat.4.cn/deerflow",
// LangGraph 流式对话接口地址:普通问答、智能体对话等流式能力使用。 // LangGraph 流式对话接口地址:普通问答、智能体对话等流式能力使用。
// VITE_LANGGRAPH_BASE_URL: "http://10.19.200.5:2026/api", // VITE_LANGGRAPH_BASE_URL: "https://ch1.b.uat.4.cn/deerflow/api",
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。 // Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
VITE_CANVAS_LANGGRAPH_BASE_URL: "", VITE_CANVAS_LANGGRAPH_BASE_URL: "",
@@ -50,6 +50,19 @@ window.__CM_RUNTIME_CONFIG__ = {
// CMZS 后端接口:任务管理、任务分析、三情分析、行动分析等业务接口。 // CMZS 后端接口:任务管理、任务分析、三情分析、行动分析等业务接口。
VITE_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm", VITE_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm",
// 8BF 嵌入页专用任务系统接口。部署到其他系统时只需修改这一项,
// 不影响原多智能体会商使用的任务详情接口。
VITE_8BF_TASK_API_BASE_URL: "https://ch1.b.uat.4.cn/zl_consumer",
// 8BF 会商页右上角「返回」按钮的跳转地址(当前窗口 window.location.href)。
// 部署后直接改 dist/runtime-config.js 即可,无需重新打包。留空则点击时提示未配置。
VITE_8BF_ROUNDTABLE_BACK_URL: "",
// 任务深链「宿主路由」按钮的 postMessage targetOrigin(HOST_NAVIGATE)。
// 填宿主系统 origin,例如 https://localhost:4912;生产改成他们线上地址。
// 留空时尝试用 iframe 的 ancestorOrigins / referrer 自动探测,探测失败则不发送。
VITE_HOST_NAVIGATE_ORIGIN: "",
// 佳来模型对话接口。 // 佳来模型对话接口。
VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt", VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt",
@@ -90,7 +103,7 @@ window.__CM_RUNTIME_CONFIG__ = {
VITE_AIPI_FRONTEND_URL: "http://47.88.25.99:3000", VITE_AIPI_FRONTEND_URL: "http://47.88.25.99:3000",
// Consumer 接口:深链任务详情 cop-task-detail、研判情报报告 selectSqReport 等使用。 // Consumer 接口:深链任务详情 cop-task-detail、研判情报报告 selectSqReport 等使用。
VITE_AIPI_CONSUMER_URL: "http://10.19.200.5:8002/consumer", VITE_AIPI_CONSUMER_URL: "https://ch1.b.uat.4.cn/consumer",
// A 系统跳转地址:人物、组织、事件等列表项打开链接使用。 // A 系统跳转地址:人物、组织、事件等列表项打开链接使用。
VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain", VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain",
@@ -130,6 +143,11 @@ window.__CM_RUNTIME_CONFIG__ = {
// 隐藏系统 Logo、换肤按钮以及“全量/简洁模式”切换。修改后刷新页面即可生效。 // 隐藏系统 Logo、换肤按钮以及“全量/简洁模式”切换。修改后刷新页面即可生效。
VITE_FORCE_COLLAPSED_SIDEBAR: "false", VITE_FORCE_COLLAPSED_SIDEBAR: "false",
// 简介模式左侧菜单类型:与菜单管理页「简介模式」里的类型 A / B 对应。
// 两套网页部署时分别填 "A" 或 "B"。不填、留空、或旧配置没有这一项,都按类型 A,
// 继续读取原来的简介菜单数据,无需改旧页面。
VITE_COMPACT_MENU_TYPE: "A",
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。 // GitHub OAuth Token;不使用 GitHub 相关能力时留空。
VITE_GITHUB_OAUTH_TOKEN: "", VITE_GITHUB_OAUTH_TOKEN: "",
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="12" viewBox="0 0 14 12" fill="none" aria-hidden="true">
<path d="M13 6H1.5M1.5 6L6.2 1.4M1.5 6L6.2 10.6" stroke="#A2E1F9" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

@@ -165,7 +165,7 @@ export const ReasoningTrigger = memo(
export type ReasoningContentProps = ComponentProps< export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent typeof CollapsibleContent
> & { > & {
children: string; children: ReactNode;
}; };
export const ReasoningContent = memo( export const ReasoningContent = memo(
@@ -178,7 +178,11 @@ export const ReasoningContent = memo(
)} )}
{...props} {...props}
> >
<Streamdown {...reasoningPlugins}>{children}</Streamdown> {typeof children === "string" ? (
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
) : (
children
)}
</CollapsibleContent> </CollapsibleContent>
), ),
); );
@@ -0,0 +1,104 @@
import { FileOutput } from "lucide-react";
import { toast } from "sonner";
import responsibilityIcon from "@/assets/position-roundtable/责任岗位.svg";
import resultTimeIcon from "@/assets/position-roundtable/时间.svg";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import "./analysis-results.css";
import type { AnalysisResultCardItem } from "./types";
function formatResultTime(value: string | undefined, fallback: string): string {
if (!value) return fallback;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
}
export function AnalysisResultCardList({
items,
emptyTitle = "暂无交付产物",
emptyDescription = "第二步研讨与第三步总结生成 Markdown 后,产物会显示在这里。",
padded = true,
onOpen,
}: {
items: AnalysisResultCardItem[];
emptyTitle?: string;
emptyDescription?: string;
padded?: boolean;
onOpen: (item: AnalysisResultCardItem) => void;
}) {
if (items.length === 0) {
return (
<div className="position-roundtable-analysis-results-empty flex h-full min-h-[260px] flex-col items-center justify-center p-6 text-center">
<FileOutput className="mb-3 size-8 text-muted-foreground/50" />
<p className="text-sm font-semibold">{emptyTitle}</p>
<p className="mt-1 max-w-[240px] text-xs leading-5 text-muted-foreground">
{emptyDescription}
</p>
</div>
);
}
return (
<div className={cn("analysis-result-card-list", padded && "p-[15px]")}>
{items.map((item, index) => {
const displayedTime = formatResultTime(
item.updatedAt,
item.fallbackTime || "刚刚",
);
const displayedProductName = item.delivered ? item.productName : "未交付";
return (
<button
key={item.id}
type="button"
className="position-roundtable-analysis-result-card"
onClick={() => {
if (!item.delivered) {
toast.info(
item.unavailableMessage ||
`「${desensitize(item.agentName)}」尚未交付可预览的 Markdown 文件。`,
);
return;
}
onOpen(item);
}}
>
<div className="position-roundtable-analysis-result-card-body">
<div
className={cn(
"position-roundtable-analysis-result-tag",
`position-roundtable-analysis-result-tag--${(index % 6) + 1}`,
)}
aria-label={desensitize(item.agentName)}
>
<span>{desensitize(item.agentName)}</span>
</div>
<p className="position-roundtable-analysis-result-product">
{desensitize(displayedProductName)}
</p>
<footer className="position-roundtable-analysis-result-meta">
<span>
<img src={responsibilityIcon} alt="" aria-hidden="true" />
{desensitize(item.positionName)}
</span>
<i aria-hidden="true" />
<span>
<img src={resultTimeIcon} alt="" aria-hidden="true" />
{displayedTime}
</span>
</footer>
</div>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,141 @@
import { useQuery } from "@tanstack/react-query";
import { FileOutput, Loader2 } from "lucide-react";
import type { ReactNode } from "react";
import { ArtifactFilePreview } from "@/components/workspace/artifacts";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { loadArtifactContent } from "@/core/artifacts/loader";
import { desensitize } from "@/lib/desensitize";
import { Button as TButton } from "tdesign-react";
import type { AnalysisResultCardItem } from "./types";
function isArtifactErrorContent(content: string | null | undefined): boolean {
const trimmed = content?.trim();
if (!trimmed || !trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return false;
}
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
const detail = parsed.detail ?? parsed.message ?? parsed.error;
return typeof detail === "string" && detail.trim().length > 0;
} catch {
return false;
}
}
function fallbackContent(item: AnalysisResultCardItem | null): string {
return (
item?.artifactContent?.trim() ||
item?.summary?.trim() ||
"暂无产物正文"
);
}
export function AnalysisResultDetailDialog({
item,
onOpenChange,
extraBody,
footerStart,
}: {
item: AnalysisResultCardItem | null;
onOpenChange: (open: boolean) => void;
extraBody?: ReactNode;
footerStart?: ReactNode;
}) {
const artifactThreadId = item?.artifactThreadId || null;
const artifactPathValue = item?.artifactPath || "";
const artifactContentQuery = useQuery({
queryKey: [
"analysis-results",
"deliverable-artifact-content",
artifactThreadId,
artifactPathValue,
],
queryFn: async () => {
if (!artifactThreadId || !artifactPathValue) return "";
const result = await loadArtifactContent({
filepath: artifactPathValue,
threadId: artifactThreadId,
isMock: false,
});
return result.content;
},
enabled: Boolean(item && artifactThreadId && artifactPathValue),
staleTime: 30_000,
});
const loadedArtifactContent =
typeof artifactContentQuery.data === "string" ? artifactContentQuery.data : "";
const loadedArtifactIsError = isArtifactErrorContent(loadedArtifactContent);
const artifactContent =
loadedArtifactContent.trim() && !loadedArtifactIsError ? loadedArtifactContent : null;
const displayedContent = artifactContent || fallbackContent(item);
const artifactLoadFailed =
(artifactContentQuery.isError || loadedArtifactIsError) &&
!item?.artifactContent?.trim();
return (
<Dialog open={Boolean(item)} onOpenChange={onOpenChange}>
<DialogContent
overlayClassName="!z-[6000]"
className="!z-[6001] flex max-h-[86vh] w-[900px] !max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0"
>
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
<DialogTitle className="flex items-center gap-2 text-base">
<FileOutput className="size-4 text-primary" />
交付产物详情
</DialogTitle>
</DialogHeader>
{!item ? null : (
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="min-w-0">
<p className="text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
{desensitize(item.productName)}
</p>
<p className="mt-1 truncate text-xs text-muted-foreground">
{desensitize(item.agentName)} · {desensitize(item.positionName)}
</p>
</div>
{extraBody}
<div className="mt-4 rounded-xl border border-slate-200 bg-white p-3 dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/65">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-xs font-bold text-slate-700 dark:text-[#e8edf5]">产物内容</p>
</div>
<div className="h-[52vh] min-h-[320px] overflow-hidden rounded-lg border border-slate-100 bg-slate-50/60 dark:border-[#2b8ffe]/20 dark:bg-[#021d3d]/70">
{artifactContentQuery.isLoading ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground dark:text-[#8aa0c4]">
<Loader2 className="size-4 animate-spin" />
正在加载 Markdown 文件内容…
</div>
) : (
<ArtifactFilePreview
content={displayedContent}
language="markdown"
threadId={artifactThreadId ?? ""}
/>
)}
</div>
{artifactLoadFailed && (
<p className="mt-2 text-[11px] text-amber-600 dark:text-amber-300">
Markdown 文件内容加载失败,当前展示的是已保存正文。
</p>
)}
</div>
</div>
)}
<DialogFooter className="shrink-0 border-t px-5 py-3 dark:border-[#2b8ffe]/25">
{footerStart}
<TButton variant="outline" onClick={() => onOpenChange(false)}>
关闭
</TButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,130 @@
.analysis-result-card-list {
display: grid;
gap: 10px;
min-width: 0;
max-width: 100%;
}
.position-roundtable-analysis-result-card {
position: relative;
display: block;
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 102px;
box-sizing: border-box;
padding: 40px 12px 12px;
border: 1px solid rgba(85, 166, 254, 0.1);
border-radius: 4px;
background: rgba(0, 98, 217, 0.1);
color: #ffffff;
text-align: left;
cursor: pointer;
transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
}
.position-roundtable-analysis-result-card:hover,
.position-roundtable-analysis-result-card:focus-visible {
border-color: #0062d9;
background: rgba(0, 98, 217, 0.3);
box-shadow: 0 4px 14px rgba(0, 44, 110, 0.18);
outline: none;
}
.position-roundtable-analysis-result-card-body {
position: relative;
min-width: 0;
max-width: 100%;
}
.position-roundtable-analysis-result-tag {
position: absolute;
top: -40px;
left: -12px;
display: inline-flex;
width: fit-content;
max-width: calc(100% + 12px);
height: 26px;
align-items: center;
box-sizing: border-box;
padding: 0 22px 0 12px;
overflow: hidden;
border-radius: 4px 0 0 0;
background: rgba(212, 120, 0, 0.5);
color: #ffffff;
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
font-size: 16px;
line-height: normal;
white-space: nowrap;
clip-path: polygon(0 0, 100% 0, calc(100% - 14px) 100%, 0 100%);
}
.position-roundtable-analysis-result-tag--1 { background: rgba(212, 120, 0, 0.5); }
.position-roundtable-analysis-result-tag--2 { background: rgba(207, 19, 34, 0.5); }
.position-roundtable-analysis-result-tag--3 { background: rgba(29, 171, 123, 0.5); }
.position-roundtable-analysis-result-tag--4 { background: rgba(132, 85, 230, 0.5); }
.position-roundtable-analysis-result-tag--5 { background: rgba(201, 181, 0, 0.5); }
.position-roundtable-analysis-result-tag--6 { background: rgba(69, 195, 243, 0.5); }
.position-roundtable-analysis-result-tag span {
position: relative;
z-index: 1;
box-sizing: border-box;
min-width: 0;
max-width: 100%;
overflow: hidden;
padding: 0;
text-overflow: ellipsis;
}
.position-roundtable-analysis-result-product {
margin: 0;
overflow: hidden;
color: #ffffff;
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
font-size: 18px;
font-weight: 500;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.position-roundtable-analysis-result-meta {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
margin-top: 6px;
color: #80b7ff;
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
font-size: 16px;
font-weight: 300;
line-height: 24px;
}
.position-roundtable-analysis-result-meta span {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.position-roundtable-analysis-result-meta img {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.position-roundtable-analysis-result-meta i {
width: 1px;
height: 14px;
flex: 0 0 auto;
background: rgba(128, 183, 255, 0.45);
}
.position-roundtable-analysis-results-empty {
color: #80b7ff;
}
@@ -0,0 +1,3 @@
export { AnalysisResultCardList } from "./AnalysisResultCardList";
export { AnalysisResultDetailDialog } from "./AnalysisResultDetailDialog";
export type { AnalysisResultCardItem } from "./types";
@@ -0,0 +1,17 @@
export interface AnalysisResultCardItem {
id: string;
agentName: string;
productName: string;
positionName: string;
updatedAt?: string;
/** Shown when `updatedAt` is missing. */
fallbackTime?: string;
/** Clickable preview is available. */
delivered: boolean;
artifactPath?: string;
artifactMimeType?: string | null;
artifactThreadId?: string | null;
artifactContent?: string | null;
summary?: string;
unavailableMessage?: string;
}
@@ -72,6 +72,7 @@ import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { import {
bootstrapResumePath, bootstrapResumePath,
isBootstrapThread, isBootstrapThread,
isHiddenFromRecentChats,
isThreadFavorite, isThreadFavorite,
isThreadPinned, isThreadPinned,
sortThreadsPinnedFirst, sortThreadsPinnedFirst,
@@ -372,17 +373,10 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
const [renameValue, setRenameValue] = useState(""); const [renameValue, setRenameValue] = useState("");
const [shareThreadId, setShareThreadId] = useState<string | null>(null); const [shareThreadId, setShareThreadId] = useState<string | null>(null);
// 排除调度/会商类系统会话,与首页一致;智能体创建(bootstrap)会话保留展示 // 排除调度/会商/报告结构&写作配置向导等系统会话,与首页一致;
// (带机器人图标、点击回创建页);置顶优先。 // 智能体创建(bootstrap)会话保留展示(带机器人图标、点击回创建页);置顶优先。
const visibleThreads = useMemo( const visibleThreads = useMemo(
() => () => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable",
),
),
[threads], [threads],
); );
@@ -2,6 +2,7 @@
import { import {
BotIcon, BotIcon,
BriefcaseBusinessIcon,
CheckIcon, CheckIcon,
DatabaseIcon, DatabaseIcon,
GlobeIcon, GlobeIcon,
@@ -150,6 +151,7 @@ export function AgentDetailPanel({
skillsIcon, skillsIcon,
skillsTitle, skillsTitle,
showCommonQuestions = false, showCommonQuestions = false,
jobResponsibilities,
panelTitle, panelTitle,
embedded, embedded,
hideStatusBadges = false, hideStatusBadges = false,
@@ -170,6 +172,11 @@ export function AgentDetailPanel({
skillsIcon?: ReactNode; skillsIcon?: ReactNode;
skillsTitle?: string; skillsTitle?: string;
showCommonQuestions?: boolean; showCommonQuestions?: boolean;
/**
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
* 不传则隐藏该区块。此字段来自业务链,不随智能体本身编辑。
*/
jobResponsibilities?: string | null;
/** Overrides the standard panel heading for an embedded consumer. */ /** Overrides the standard panel heading for an embedded consumer. */
panelTitle?: string; panelTitle?: string;
/** Renders one fixed panel instead of the responsive collapsible rail. */ /** Renders one fixed panel instead of the responsive collapsible rail. */
@@ -720,6 +727,23 @@ export function AgentDetailPanel({
</section> </section>
{/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */}
{jobResponsibilities !== undefined && (
<Section
icon={<BriefcaseBusinessIcon className="size-3.5" />}
title="岗位职责"
>
{jobResponsibilities?.trim() ? (
<p className="text-muted-foreground text-sm leading-relaxed whitespace-pre-wrap">
{desensitize(jobResponsibilities)}
</p>
) : (
<p className="text-muted-foreground/60 text-sm">暂无岗位职责</p>
)}
</Section>
)}
{/* ── 常用问题 ───────────────────────────────────────────────────── */} {/* ── 常用问题 ───────────────────────────────────────────────────── */}
{(showCommonQuestions || editing || questions.length > 0 || canEdit) && ( {(showCommonQuestions || editing || questions.length > 0 || canEdit) && (
<Section <Section
@@ -346,15 +346,22 @@ export function AgentGallery() {
请输入访问密码,验证通过后才能查看其中的智能体。 请输入访问密码,验证通过后才能查看其中的智能体。
</p> </p>
</div> </div>
<div className="flex flex-col gap-2"> <form
className="flex flex-col gap-2"
onSubmit={(event) => {
event.preventDefault();
handleSubmitPassword();
}}
>
<label className="sr-only" htmlFor="private-square-access-code">
访问密码
</label>
<Input <Input
id="private-square-access-code"
type="password" type="password"
placeholder="访问密码" placeholder="访问密码"
value={passwordInput} value={passwordInput}
onChange={(e) => setPasswordInput(e.target.value)} onChange={(e) => setPasswordInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmitPassword();
}}
// This is a one-off access code, not a login credential. Tell // This is a one-off access code, not a login credential. Tell
// browsers / password managers not to save or autofill it (which // browsers / password managers not to save or autofill it (which
// would also stop them from spilling a saved account into the // would also stop them from spilling a saved account into the
@@ -365,8 +372,8 @@ export function AgentGallery() {
data-lpignore="true" data-lpignore="true"
data-form-type="other" data-form-type="other"
/> />
<Button onClick={handleSubmitPassword}>进入私密广场</Button> <Button type="submit">进入私密广场</Button>
</div> </form>
</div> </div>
); );
} }
@@ -5,7 +5,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useThreadsPage } from "@/core/threads/hooks"; import { useThreadsPage } from "@/core/threads/hooks";
import { pathOfThread, titleOfThread } from "@/core/threads/utils"; import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -22,12 +22,9 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
pageSize: RECENT_LIMIT, pageSize: RECENT_LIMIT,
metadata: { agent_id: agentId }, metadata: { agent_id: agentId },
}); });
// 该智能体的会话里同样排除调度 / 引导 / 会商类系统会话。 // 该智能体的会话里同样排除引导 / 系统 / 配置向导会话。
const threads = (data?.threads ?? []).filter( const threads = (data?.threads ?? []).filter(
(th) => (th) => !isBootstrapThread(th) && !isHiddenFromRecentChats(th),
th.metadata?.thread_type !== "bootstrap" &&
th.metadata?.thread_type !== "scheduler" &&
th.metadata?.thread_type !== "roundtable",
); );
return ( return (
@@ -14,11 +14,12 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext"; import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import type { CompactShortcutRouteParam } from "@/core/system-settings/api";
import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks"; import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks";
import { isHideChromeActive } from "@/core/embed/embed-session"; import { isHideChromeActive } from "@/core/embed/embed-session";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app"; import { buildLightAppUrl, resolveRouteParamValue } from "@/strategy-components/lib/light-app";
/** /**
* 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用, * 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用,
@@ -47,6 +48,34 @@ const triggerToneClass = (tone: AppearanceControlTone) =>
? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30" ? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30"
: "text-muted-foreground hover:bg-muted hover:text-foreground"; : "text-muted-foreground hover:bg-muted hover:text-foreground";
function appendShortcutPathParams(url: string, params: CompactShortcutRouteParam[]) {
const pathSegments = params
.filter((param) => param.paramType === "path")
.map((param) =>
resolveRouteParamValue({ ...param, paramType: "login" }).trim(),
)
.filter(Boolean)
.map((value) => encodeURIComponent(value));
if (pathSegments.length === 0) return url;
const hashIndex = url.indexOf("#");
if (hashIndex < 0) {
const [path, query = ""] = url.split("?", 2);
return `${path.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`;
}
const beforeHash = url.slice(0, hashIndex);
const fragment = url.slice(hashIndex + 1);
const [routePath, query = ""] = fragment.split("?", 2);
return `${beforeHash}#${routePath.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`;
}
function isQueryShortcutParam(
param: CompactShortcutRouteParam,
): param is CompactShortcutRouteParam & { paramType: "login" | "theme" } {
return param.paramType === "login" || param.paramType === "theme";
}
export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) { export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) {
const { data: appearance } = useAppearanceSettings(); const { data: appearance } = useAppearanceSettings();
const buttons = (appearance?.compact_shortcut_buttons ?? []) const buttons = (appearance?.compact_shortcut_buttons ?? [])
@@ -69,9 +98,10 @@ export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }
)} )}
title={button.label} title={button.label}
onClick={() => { onClick={() => {
const routeParams = (button.route_params ?? []).map((param) => ({ ...param }));
const url = buildLightAppUrl({ const url = buildLightAppUrl({
url: button.url, url: appendShortcutPathParams(button.url, routeParams),
routeParams: button.route_params.map((param) => ({ ...param })), routeParams: routeParams.filter(isQueryShortcutParam),
}); });
window.open(url, "_blank", "noopener,noreferrer"); window.open(url, "_blank", "noopener,noreferrer");
}} }}
@@ -20,6 +20,7 @@ import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react"; import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { defaultUrlTransform } from "react-markdown";
import { Streamdown } from "streamdown"; import { Streamdown } from "streamdown";
import { import {
@@ -42,6 +43,11 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor"; import { CodeEditor } from "@/components/workspace/code-editor";
import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor"; import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor";
import { useDocumentRewrite } from "@/open-canvas/hooks/useDocumentRewrite"; import { useDocumentRewrite } from "@/open-canvas/hooks/useDocumentRewrite";
import {
useCapabilities as useResearchCapabilities,
useResearchSources,
type DeepResearchSource,
} from "@/strategy-components/api/deep-research";
import { import {
listDurableDocumentRewrites, listDurableDocumentRewrites,
streamDurableDocumentRewriteJob, streamDurableDocumentRewriteJob,
@@ -53,9 +59,16 @@ import { useAgent } from "@/core/agents/hooks";
import { apiFetch } from "@/core/api"; import { apiFetch } from "@/core/api";
import { copyTextToClipboard } from "@/core/clipboard"; import { copyTextToClipboard } from "@/core/clipboard";
import { import {
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
DOCUMENT_REWRITE_STARTED_EVENT,
DOCUMENT_REWRITE_UNDONE_EVENT, DOCUMENT_REWRITE_UNDONE_EVENT,
useDocumentRewritePresentation, useDocumentRewritePresentation,
} from "@/core/document-rewrite/context"; } from "@/core/document-rewrite/context";
import {
clearArtifactRewriteFallback,
completeArtifactRewriteFallback,
primeArtifactRewriteFallback,
} from "@/core/document-rewrite/local-fallback";
import { import {
downloadArtifactFile, downloadArtifactFile,
} from "@/core/artifacts/download"; } from "@/core/artifacts/download";
@@ -64,6 +77,7 @@ import {
useUpdateArtifactContent, useUpdateArtifactContent,
} from "@/core/artifacts/hooks"; } from "@/core/artifacts/hooks";
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils"; import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
import { isArtifactApiPath } from "@/core/artifacts/streaming-write-preview";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { streamdownPlugins } from "@/core/streamdown"; import { streamdownPlugins } from "@/core/streamdown";
@@ -98,6 +112,54 @@ import { SpreadsheetPreview } from "./spreadsheet-preview";
import { SkillInstallDialog } from "./skill-install-dialog"; import { SkillInstallDialog } from "./skill-install-dialog";
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll"; const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
const RESEARCH_SOURCE_MARKER_RE = /(?:\[\[\s*source\s*[::]\s*([A-Za-z0-9_-]{1,128})\s*\]\]|\[\s*来源\s*[::]\s*([A-Za-z0-9_-]{1,128})\s*\])/gi;
const TRAILING_RESEARCH_SOURCE_MARKER_RE = /(?:\[\[\s*source\s*[::][^\]\n]*(?:\]?)|\[\s*来源\s*[::][^\]\n]*)$/i;
/** Keep durable source ids in Markdown while presenting plain numeric citations. */
function formatResearchCitationsForPreview(
content: string,
sources: DeepResearchSource[],
): string {
const citationNumbers = new Map(
sources.map((source, index) => [source.id, index + 1]),
);
let nextCitationNumber = citationNumbers.size + 1;
return preserveMarkdownStandaloneLines(
content
.replace(RESEARCH_SOURCE_MARKER_RE, (_marker, legacyId: string, displayId: string) => {
const sourceId = legacyId || displayId;
let citationNumber = citationNumbers.get(sourceId);
if (!citationNumber) {
citationNumber = nextCitationNumber;
nextCitationNumber += 1;
citationNumbers.set(sourceId, citationNumber);
}
return `[${citationNumber}]`;
})
// Hide a citation token while it is still arriving over the live stream.
.replace(TRAILING_RESEARCH_SOURCE_MARKER_RE, ""),
);
}
/**
* Markdown treats a single newline as a space. Cover fields and chapter
* headings like 「日期:」「第一章」「1.1」must be their own blocks in preview.
*/
function preserveMarkdownStandaloneLines(markdown: string): string {
const standalone =
/^(?:《.+》|(?:日期|类型|密级)\s*[::].+|第[一二三四五六七八九十百千0-9]+章\b.*|\d+\.\d+(?:\.\d+)?\s+\S+)$/;
const lines = markdown.split("\n");
const output: string[] = [];
for (const line of lines) {
const stripped = line.trim();
if (stripped && standalone.test(stripped) && output.length > 0 && output[output.length - 1]!.trim()) {
output.push("");
}
output.push(line);
}
return output.join("\n");
}
async function sha256Content(content: string): Promise<string | undefined> { async function sha256Content(content: string): Promise<string | undefined> {
if (!globalThis.crypto?.subtle) return undefined; if (!globalThis.crypto?.subtle) return undefined;
@@ -151,6 +213,10 @@ export function ArtifactFileDetail({
disableDesensitize = false, disableDesensitize = false,
hideFileName = false, hideFileName = false,
onDownloadClick, onDownloadClick,
toolbarActions,
headerLeadingAction,
headerLeadingActionBare = false,
researchSessionId,
}: { }: {
className?: string; className?: string;
filepath: string; filepath: string;
@@ -178,6 +244,20 @@ export function ArtifactFileDetail({
* 「另存为 Word」。默认不传 —— 其它调用方行为不变。 * 「另存为 Word」。默认不传 —— 其它调用方行为不变。
*/ */
onDownloadClick?: () => void; onDownloadClick?: () => void;
/** Deep-research reports use protected `deep-research://` image references. */
researchSessionId?: string;
/**
* 由宿主注入到右上角 ArtifactActions 图标组的动作。深度研究报告使用它
* 放置全文改写入口,避免再在沙箱内容区叠一个文字按钮。
*/
toolbarActions?: ReactNode;
/** Replaces the left file-type badge for an embedded workspace action. */
headerLeadingAction?: ReactNode;
/**
* 嵌入式工作区的标题动作可直接放在标题栏中,避免再被文件类型徽标的胶囊容器包一层。
* 默认保留原有徽标容器,避免影响普通聊天和产物预览。
*/
headerLeadingActionBare?: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts(); const { artifacts, setOpen, select } = useArtifacts();
@@ -265,7 +345,13 @@ export function ArtifactFileDetail({
language === "mermaid" language === "mermaid"
); );
}, [language]); }, [language]);
const { content, isApplyingEdit } = useArtifactContent({ const {
content,
isApplyingEdit,
isLoading: isArtifactContentLoading,
error: artifactContentError,
refetch: refetchArtifactContent,
} = useArtifactContent({
threadId, threadId,
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟 // editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容 // URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
@@ -291,17 +377,33 @@ export function ArtifactFileDetail({
const updateArtifact = useUpdateArtifactContent(); const updateArtifact = useUpdateArtifactContent();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const documentRewrite = useDocumentRewrite(); const documentRewrite = useDocumentRewrite();
const imageCapabilities = useResearchCapabilities();
const rewritePresentation = useDocumentRewritePresentation(); const rewritePresentation = useDocumentRewritePresentation();
const hydratedRewriteScopeRef = useRef<string | null>(null); const hydratedRewriteScopeRef = useRef<string | null>(null);
const mirroredRequirementsPlanRef = useRef<{ presentationId: string | null; plan: string }>({ const mirroredRequirementsPlanRef = useRef<{ presentationId: string | null; plan: string }>({
presentationId: null, presentationId: null,
plan: "", plan: "",
}); });
const mirroredThinkingTextRef = useRef<{ presentationId: string | null; thinking: string }>({
presentationId: null,
thinking: "",
});
const documentRewriteActive = documentRewrite.isActive; const documentRewriteActive = documentRewrite.isActive;
const canUseFullDocumentRewrite = canEditArtifact && !hasLocalEdits; const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
const displayContent = documentRewriteActive const displayContent = documentRewriteActive
? documentRewrite.run.draft ? documentRewrite.run.draft
: (canEditArtifact ? draftContent : (content ?? "")); : (canEditArtifact ? draftContent : (content ?? ""));
const artifactContentPending =
isCodeFile &&
!useWriteFileStreaming &&
content === undefined &&
isArtifactContentLoading;
const artifactContentUnavailable =
isCodeFile &&
!useWriteFileStreaming &&
content === undefined &&
!isArtifactContentLoading;
const isAutoPreviewLanguage = const isAutoPreviewLanguage =
language === "html" || language === "markdown" || language === "mermaid"; language === "html" || language === "markdown" || language === "mermaid";
const isPreviewStreaming = const isPreviewStreaming =
@@ -360,7 +462,9 @@ export function ArtifactFileDetail({
!filepath || !filepath ||
isMock || isMock ||
useWriteFileStreaming || useWriteFileStreaming ||
documentRewriteActive documentRewriteActive ||
content === undefined ||
artifactContentError
) { ) {
return; return;
} }
@@ -405,6 +509,29 @@ export function ArtifactFileDetail({
writtenCharCount: event.charCount, writtenCharCount: event.charCount,
}); });
break; break;
case "illustration_started":
rewritePresentation.update(presentationId, {
stage: "illustrating",
illustrationTarget: event.section,
maxGeneratedImages: event.total || 1,
});
break;
case "illustration_generated":
rewritePresentation.update(presentationId, {
stage: "illustrating",
illustrationTarget: event.section,
generatedImageCount: event.generatedCount ?? event.position,
maxGeneratedImages: event.total || 1,
});
break;
case "illustration_completed":
rewritePresentation.update(presentationId, {
stage: "illustrating",
generatedImageCount: event.generatedCount,
maxGeneratedImages: event.requestedCount || 1,
warnings: event.warnings,
});
break;
case "validation_started": case "validation_started":
rewritePresentation.update(presentationId, { stage: "validation" }); rewritePresentation.update(presentationId, { stage: "validation" });
break; break;
@@ -421,6 +548,7 @@ export function ArtifactFileDetail({
rewritePresentation.update(presentationId, { rewritePresentation.update(presentationId, {
rewrittenContent: event.content, rewrittenContent: event.content,
versionId: event.versionId ?? null, versionId: event.versionId ?? null,
committedHash: event.committedHash,
}); });
break; break;
case "done": case "done":
@@ -436,7 +564,7 @@ export function ArtifactFileDetail({
rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message }); rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message });
break; break;
case "error": case "error":
rewritePresentation.update(presentationId, { stage: "error", error: event.message }); console.error("AI 全文改写历史恢复失败:", event.message);
break; break;
default: default:
break; break;
@@ -453,6 +581,9 @@ export function ArtifactFileDetail({
// still be active and needs its SSE stream resumed. // still be active and needs its SSE stream resumed.
for (const historical of jobs.slice(1).reverse()) { for (const historical of jobs.slice(1).reverse()) {
const historicalResult = historical.result; const historicalResult = historical.result;
const historicalCommittedHash = typeof historicalResult.committedHash === "string"
? historicalResult.committedHash
: null;
const historicalSummary = historicalResult.summary && typeof historicalResult.summary === "object" const historicalSummary = historicalResult.summary && typeof historicalResult.summary === "object"
? historicalResult.summary as DocumentRewriteSummary ? historicalResult.summary as DocumentRewriteSummary
: null; : null;
@@ -463,8 +594,11 @@ export function ArtifactFileDetail({
instruction: historical.request.instruction, instruction: historical.request.instruction,
style: historical.request.style, style: historical.request.style,
modelName: historical.request.modelName, modelName: historical.request.modelName,
generateImages: historical.request.generateImages,
maxGeneratedImages: historical.request.maxGeneratedImages,
originalContent: historical.request.originalContent, originalContent: historical.request.originalContent,
rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null, rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null,
committedHash: historicalCommittedHash,
requirementsPlan: typeof historicalResult.requirementsPlan === "string" requirementsPlan: typeof historicalResult.requirementsPlan === "string"
? historicalResult.requirementsPlan ? historicalResult.requirementsPlan
: "", : "",
@@ -490,6 +624,22 @@ export function ArtifactFileDetail({
: null; : null;
const originalContent = latest.request.originalContent || draftContent; const originalContent = latest.request.originalContent || draftContent;
const rewrittenContent = typeof result.content === "string" ? result.content : null; const rewrittenContent = typeof result.content === "string" ? result.content : null;
const versionSnapshotFailed = result.versionSnapshotFailed === true;
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
const localFallbackAvailable =
versionSnapshotFailed &&
latest.status === "completed" &&
result.status !== "conflict" &&
committedHash !== null &&
completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent,
committedHash,
});
const presentationId = rewritePresentation?.create({ const presentationId = rewritePresentation?.create({
threadId, threadId,
source: { kind: "artifact", id: threadId, path: filepath }, source: { kind: "artifact", id: threadId, path: filepath },
@@ -497,8 +647,12 @@ export function ArtifactFileDetail({
instruction: latest.request.instruction, instruction: latest.request.instruction,
style: latest.request.style, style: latest.request.style,
modelName: latest.request.modelName, modelName: latest.request.modelName,
generateImages: latest.request.generateImages,
maxGeneratedImages: latest.request.maxGeneratedImages,
originalContent, originalContent,
rewrittenContent, rewrittenContent,
committedHash,
localFallbackAvailable,
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "", requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
}); });
if (!presentationId || !rewritePresentation) return; if (!presentationId || !rewritePresentation) return;
@@ -510,11 +664,18 @@ export function ArtifactFileDetail({
summary, summary,
warnings: summary?.validationWarnings ?? [], warnings: summary?.validationWarnings ?? [],
versionId: typeof result.versionId === "string" ? result.versionId : null, versionId: typeof result.versionId === "string" ? result.versionId : null,
committedHash,
localFallbackAvailable,
error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null, error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null,
}); });
if (rewrittenContent && result.status !== "conflict") { if (rewrittenContent && result.status !== "conflict") {
setDraftContent(rewrittenContent); setDraftContent(rewrittenContent);
setHasLocalEdits(false); setHasLocalEdits(versionSnapshotFailed);
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
}
queryClient.setQueriesData( queryClient.setQueriesData(
{ queryKey: ["artifact", filepath, threadId, isMock] }, { queryKey: ["artifact", filepath, threadId, isMock] },
{ content: rewrittenContent, url: undefined }, { content: rewrittenContent, url: undefined },
@@ -537,6 +698,8 @@ export function ArtifactFileDetail({
instruction: latest.request.instruction, instruction: latest.request.instruction,
style: latest.request.style, style: latest.request.style,
modelName: latest.request.modelName ?? undefined, modelName: latest.request.modelName ?? undefined,
generateImages: latest.request.generateImages,
maxGeneratedImages: latest.request.maxGeneratedImages,
}, },
{ {
jobId: latest.id, jobId: latest.id,
@@ -546,10 +709,28 @@ export function ArtifactFileDetail({
signal, signal,
), ),
onEvent: (event) => updatePresentation(presentationId, event), onEvent: (event) => updatePresentation(presentationId, event),
onCommitted: (rewritten) => { onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
setDraftContent(rewritten); setDraftContent(rewritten);
setHasLocalEdits(false); setHasLocalEdits(versionSnapshotFailed);
rewritePresentation.update(presentationId, { rewrittenContent: rewritten }); const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent,
committedHash,
});
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
}
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
localFallbackAvailable,
});
queryClient.setQueriesData( queryClient.setQueriesData(
{ queryKey: ["artifact", filepath, threadId, isMock] }, { queryKey: ["artifact", filepath, threadId, isMock] },
{ content: rewritten, url: undefined }, { content: rewritten, url: undefined },
@@ -565,7 +746,9 @@ export function ArtifactFileDetail({
disposed = true; disposed = true;
}; };
}, [ }, [
artifactContentError,
canEditArtifact, canEditArtifact,
content,
documentRewrite, documentRewrite,
documentRewriteActive, documentRewriteActive,
draftContent, draftContent,
@@ -605,6 +788,40 @@ export function ArtifactFileDetail({
rewritePresentation, rewritePresentation,
]); ]);
// Thoughts use the same rAF coalescing as the draft, then are mirrored once
// per browser frame into the regular message-list card. They never touch
// the artifact Markdown or the durable rewrite checkpoint.
useEffect(() => {
const presentationId = documentRewritePresentationId;
const thinking = documentRewrite.run.thinkingText;
if (!presentationId || !rewritePresentation || !thinking) return;
const previous = mirroredThinkingTextRef.current;
if (previous.presentationId === presentationId && previous.thinking === thinking) return;
mirroredThinkingTextRef.current = { presentationId, thinking };
rewritePresentation.update(presentationId, { thinkingText: thinking });
}, [documentRewrite.run.thinkingText, documentRewritePresentationId, rewritePresentation]);
useEffect(() => {
const handleLocalRewriteUndoGuard = (event: Event) => {
const detail = (event as CustomEvent<{
source?: { kind?: string; id?: string; path?: string };
rewrittenContent?: string | null;
unchanged?: boolean;
}>).detail;
if (
detail?.source?.kind !== "artifact" ||
detail.source.id !== threadId ||
detail.source.path !== filepath ||
typeof detail.rewrittenContent !== "string"
) {
return;
}
detail.unchanged = draftContent === detail.rewrittenContent;
};
window.addEventListener(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, handleLocalRewriteUndoGuard);
return () => window.removeEventListener(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, handleLocalRewriteUndoGuard);
}, [draftContent, filepath, threadId]);
useEffect(() => { useEffect(() => {
const handleRewriteUndo = (event: Event) => { const handleRewriteUndo = (event: Event) => {
const detail = (event as CustomEvent<{ const detail = (event as CustomEvent<{
@@ -747,7 +964,7 @@ export function ArtifactFileDetail({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (isCodeFile) { if (isCodeFile || isWriteFileVirtual || !isArtifactApiPath(filepath)) {
setSpreadsheetData(null); setSpreadsheetData(null);
setBinaryPreviewUrl((prev) => { setBinaryPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev); if (prev) URL.revokeObjectURL(prev);
@@ -794,7 +1011,7 @@ export function ArtifactFileDetail({
return null; return null;
}); });
}; };
}, [filepath, threadId, isMock, isCodeFile, isSpreadsheetFile, publicShareCode]); }, [filepath, threadId, isMock, isCodeFile, isSpreadsheetFile, isWriteFileVirtual, publicShareCode]);
const handlePauseAutoScroll = useCallback(() => { const handlePauseAutoScroll = useCallback(() => {
if (!thread.isLoading || !autoScrollEnabled) { if (!thread.isLoading || !autoScrollEnabled) {
@@ -834,7 +1051,9 @@ export function ArtifactFileDetail({
toast.success("Markdown 已保存"); toast.success("Markdown 已保存");
} catch (error) { } catch (error) {
console.error("Failed to save artifact:", error); console.error("Failed to save artifact:", error);
toast.error(error instanceof Error ? error.message : "Markdown 保存失败"); // Keep the draft and leave the button enabled. The caller can retry when
// the artifact endpoint recovers; this must never reset visible text.
setHasLocalEdits(true);
} }
}, [ }, [
canEditArtifact, canEditArtifact,
@@ -851,19 +1070,44 @@ export function ArtifactFileDetail({
instruction: string; instruction: string;
style: "formal_analysis" | "professional" | "concise" | "readable"; style: "formal_analysis" | "professional" | "concise" | "readable";
modelName: string | null; modelName: string | null;
reportOutline: string;
structureMode?: "fixed" | "adaptive";
generateImages: boolean;
maxGeneratedImages: number;
}) => { }) => {
if (!canUseFullDocumentRewrite) { if (!canEditArtifact) {
toast.error( toast.error("当前文件暂不支持全文改写。");
hasLocalEdits
? "请先保存当前 Markdown 修改后再进行全文改写。"
: "当前文件暂不支持全文改写。",
);
return; return;
} }
setDocumentRewriteDialogOpen(false); setDocumentRewriteDialogOpen(false);
setAllowAutoViewSwitch(false); setAllowAutoViewSwitch(false);
setAutoScrollEnabled(true); setAutoScrollEnabled(true);
setViewMode("code"); setViewMode("code");
// Do not make users manually save before using AI rewrite. Persist their
// current draft first so the rewrite starts from exactly what they see,
// and the optimistic hash remains valid on the server.
if (hasLocalEdits) {
try {
await updateArtifact.mutateAsync({
filepath,
threadId,
isMock,
content: draftContent,
});
setHasLocalEdits(false);
} catch (error) {
console.error("Failed to save Markdown before AI rewrite:", error);
setHasLocalEdits(true);
return;
}
}
primeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent: draftContent,
});
const expectedHash = await sha256Content(draftContent); const expectedHash = await sha256Content(draftContent);
const presentationId = rewritePresentation?.create({ const presentationId = rewritePresentation?.create({
threadId, threadId,
@@ -872,16 +1116,27 @@ export function ArtifactFileDetail({
instruction: input.instruction, instruction: input.instruction,
style: input.style, style: input.style,
modelName: input.modelName, modelName: input.modelName,
generateImages: input.generateImages,
maxGeneratedImages: input.maxGeneratedImages,
originalContent: draftContent, originalContent: draftContent,
}); });
setDocumentRewritePresentationId(presentationId ?? null); setDocumentRewritePresentationId(presentationId ?? null);
window.dispatchEvent(
new CustomEvent(DOCUMENT_REWRITE_STARTED_EVENT, {
detail: { threadId },
}),
);
void documentRewrite.start( void documentRewrite.start(
{ {
threadId, threadId,
path: filepath, path: filepath,
instruction: input.instruction, instruction: input.instruction,
style: input.style, style: input.style,
modelName: input.modelName ?? undefined, modelName: input.modelName ?? undefined,
reportOutline: input.reportOutline || undefined,
structureMode: input.structureMode,
generateImages: input.generateImages,
maxGeneratedImages: input.maxGeneratedImages,
expectedHash, expectedHash,
}, },
{ {
@@ -921,6 +1176,29 @@ export function ArtifactFileDetail({
writtenCharCount: event.charCount, writtenCharCount: event.charCount,
}); });
break; break;
case "illustration_started":
rewritePresentation.update(presentationId, {
stage: "illustrating",
illustrationTarget: event.section,
maxGeneratedImages: event.total || 1,
});
break;
case "illustration_generated":
rewritePresentation.update(presentationId, {
stage: "illustrating",
illustrationTarget: event.section,
generatedImageCount: event.generatedCount ?? event.position,
maxGeneratedImages: event.total || 1,
});
break;
case "illustration_completed":
rewritePresentation.update(presentationId, {
stage: "illustrating",
generatedImageCount: event.generatedCount,
maxGeneratedImages: event.requestedCount || 1,
warnings: event.warnings,
});
break;
case "validation_started": case "validation_started":
rewritePresentation.update(presentationId, { stage: "validation" }); rewritePresentation.update(presentationId, { stage: "validation" });
break; break;
@@ -936,6 +1214,7 @@ export function ArtifactFileDetail({
case "committed": case "committed":
rewritePresentation.update(presentationId, { rewritePresentation.update(presentationId, {
versionId: event.versionId ?? null, versionId: event.versionId ?? null,
committedHash: event.committedHash,
}); });
break; break;
case "done": case "done":
@@ -951,20 +1230,32 @@ export function ArtifactFileDetail({
}); });
break; break;
case "error": case "error":
rewritePresentation.update(presentationId, { // The sandbox retains the visible draft and exposes manual
stage: "error", // save instead of showing an error state to the user.
error: event.message, console.error("AI 全文改写接口失败:", event.message);
});
break; break;
default: default:
break; break;
} }
}, },
onCommitted: (rewritten) => { onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
setDraftContent(rewritten); setDraftContent(rewritten);
setHasLocalEdits(false); setHasLocalEdits(versionSnapshotFailed);
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent: draftContent,
committedHash,
});
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
if (presentationId && rewritePresentation) { if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, { rewrittenContent: rewritten }); rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
localFallbackAvailable,
});
} }
queryClient.setQueriesData( queryClient.setQueriesData(
{ {
@@ -972,13 +1263,42 @@ export function ArtifactFileDetail({
}, },
{ content: rewritten, url: undefined }, { content: rewritten, url: undefined },
); );
toast.success("Markdown 全文改写完成,已安全替换原文件。"); if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
} else {
toast.success("Markdown 全文改写完成,已安全替换原文件。");
}
},
onFailure: ({ content, error }) => {
// The rewrite transport can fail after the server has already
// streamed text (or before the first token on a 404). Keep that
// visible content in the sandbox, make Save actionable, and leave
// diagnostics to the developer console only.
const retainedContent = content || draftContent;
setDraftContent(retainedContent);
setHasLocalEdits(true);
queryClient.setQueriesData(
{
queryKey: ["artifact", filepath, threadId, isMock],
},
{ content: retainedContent, url: undefined },
);
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
stage: "completed",
rewrittenContent: retainedContent,
error: null,
});
}
console.error("AI 全文改写接口失败,已保留沙箱内容供手动保存:", error);
}, },
}, },
); );
}, },
[ [
canUseFullDocumentRewrite, canEditArtifact,
draftContent, draftContent,
documentRewrite, documentRewrite,
filepath, filepath,
@@ -987,6 +1307,7 @@ export function ArtifactFileDetail({
queryClient, queryClient,
rewritePresentation, rewritePresentation,
threadId, threadId,
updateArtifact,
], ],
); );
@@ -1113,10 +1434,18 @@ export function ArtifactFileDetail({
<Artifact className={cn(className)}> <Artifact className={cn(className)}>
<ArtifactHeader className="flex flex-wrap items-center gap-3 px-4 py-3"> <ArtifactHeader className="flex flex-wrap items-center gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<div className="bg-muted/65 inline-flex min-w-0 items-center rounded-full border border-black/6 p-1 shadow-sm dark:border-white/8"> <div
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8"> className={cn(
{fileTypeLabel} "inline-flex min-w-0 items-center gap-2",
</span> !headerLeadingActionBare &&
"bg-muted/65 rounded-full border border-black/6 p-1 shadow-sm dark:border-white/8",
)}
>
{headerLeadingAction ?? (
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
{fileTypeLabel}
</span>
)}
{!hideFileName && ( {!hideFileName && (
<ArtifactTitle className="min-w-0"> <ArtifactTitle className="min-w-0">
{/* markdown:隐藏文件名(仅留 MD 徽标),把横向空间让给工具栏,避免窄面板下 {/* markdown:隐藏文件名(仅留 MD 徽标),把横向空间让给工具栏,避免窄面板下
@@ -1217,6 +1546,7 @@ export function ArtifactFileDetail({
onClick={handleSaveArtifact} onClick={handleSaveArtifact}
/> />
)} )}
{toolbarActions}
{canEditArtifact && ( {canEditArtifact && (
<ArtifactAction <ArtifactAction
icon={documentRewriteActive ? LoaderIcon : SparklesIcon} icon={documentRewriteActive ? LoaderIcon : SparklesIcon}
@@ -1225,10 +1555,12 @@ export function ArtifactFileDetail({
documentRewriteActive documentRewriteActive
? "正在生成临时草稿,原文件尚未被覆盖" ? "正在生成临时草稿,原文件尚未被覆盖"
: hasLocalEdits : hasLocalEdits
? "请先保存当前 Markdown 修改" ? "将先保存当前 Markdown 修改,再开始改写"
: "使用 AI 重写当前完整 Markdown" : "使用 AI 重写当前完整 Markdown"
} }
disabled={documentRewriteActive || !canUseFullDocumentRewrite} // Keep the entry available after a completed rewrite and when
// the user has local edits. Starting with local edits saves
// them automatically in handleStartDocumentRewrite.
onClick={() => setDocumentRewriteDialogOpen(true)} onClick={() => setDocumentRewriteDialogOpen(true)}
/> />
)} )}
@@ -1396,11 +1728,33 @@ export function ArtifactFileDetail({
language={language ?? "text"} language={language ?? "text"}
threadId={threadId} threadId={threadId}
publicShareCode={publicShareCode} publicShareCode={publicShareCode}
researchSessionId={researchSessionId}
animated={showPreviewSwitchPulse} animated={showPreviewSwitchPulse}
activeCursor={isApplyingEdit} activeCursor={isApplyingEdit}
/> />
)} )}
{isCodeFile && viewMode === "code" && ( {artifactContentPending && 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" />
正在加载文档内容…
</div>
)}
{artifactContentUnavailable && viewMode === "code" && (
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 px-6 text-center text-sm">
<FileType2Icon aria-hidden="true" className="size-6" />
<p>{artifactContentError ? "暂时无法读取文档内容。" : "当前文档没有可显示的内容。"}</p>
<button
className="text-primary rounded-md px-2 py-1 font-medium underline-offset-4 hover:underline focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
onClick={() => {
void refetchArtifactContent();
}}
type="button"
>
重新加载
</button>
</div>
)}
{isCodeFile && viewMode === "code" && !artifactContentPending && !artifactContentUnavailable && (
<div className="size-full"> <div className="size-full">
<CodeEditor <CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent" className="size-full resize-none rounded-none border-none bg-transparent"
@@ -1454,6 +1808,8 @@ export function ArtifactFileDetail({
onOpenChange={setDocumentRewriteDialogOpen} onOpenChange={setDocumentRewriteDialogOpen}
fileName={getFileName(filepath)} fileName={getFileName(filepath)}
charCount={(displayContent ?? "").replace(/\s/g, "").length} charCount={(displayContent ?? "").replace(/\s/g, "").length}
imageGenerationAvailable={imageGenerationAvailable}
maxGeneratedImages={maxGeneratedImages}
onStart={handleStartDocumentRewrite} onStart={handleStartDocumentRewrite}
/> />
</Artifact> </Artifact>
@@ -1465,15 +1821,28 @@ function SandboxPreviewImage({
alt, alt,
threadId, threadId,
publicShareCode, publicShareCode,
researchSessionId,
...props ...props
}: React.ImgHTMLAttributes<HTMLImageElement> & { }: React.ImgHTMLAttributes<HTMLImageElement> & {
threadId: string; threadId: string;
publicShareCode?: string; publicShareCode?: string;
researchSessionId?: string;
}) { }) {
if (!src || typeof src !== "string") { if (!src || typeof src !== "string") {
return null; return null;
} }
if (src.startsWith("deep-research://")) {
return researchSessionId ? (
<ProtectedResearchReportImage
{...props}
alt={alt}
sessionId={researchSessionId}
src={src}
/>
) : null;
}
let url = src; let url = src;
if (src.startsWith("/mnt/")) { if (src.startsWith("/mnt/")) {
if (publicShareCode) { if (publicShareCode) {
@@ -1499,11 +1868,97 @@ function SandboxPreviewImage({
); );
} }
/** Fetches a private deep-research image with the normal auth client. */
function ProtectedResearchReportImage({
sessionId,
src,
alt,
...props
}: React.ImgHTMLAttributes<HTMLImageElement> & {
sessionId: string;
}) {
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loadFailed, setLoadFailed] = useState(false);
const [retryKey, setRetryKey] = useState(0);
useEffect(() => {
setObjectUrl(null);
setLoadFailed(false);
if (!src?.startsWith("deep-research://")) {
return;
}
const name = src.slice("deep-research://".length);
if (!name) {
setLoadFailed(true);
return;
}
const controller = new AbortController();
let nextUrl: string | null = null;
let disposed = false;
void (async () => {
try {
const response = await apiFetch(
`${getBackendBaseURL()}/api/deep-research/sessions/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(name)}`,
{ method: "GET", signal: controller.signal },
);
if (!response.ok) {
throw new Error(`Unable to load report image (${response.status})`);
}
const blob = await response.blob();
if (!blob.size) {
throw new Error("Report image is empty");
}
nextUrl = URL.createObjectURL(blob);
if (disposed) return;
setObjectUrl(nextUrl);
} catch (error) {
if (!disposed && !(error instanceof DOMException && error.name === "AbortError")) {
// Never let an optional illustration break Markdown rendering, but don't
// leave the user with a misleading infinite loading label either.
setLoadFailed(true);
}
}
})();
return () => {
disposed = true;
controller.abort();
if (nextUrl) URL.revokeObjectURL(nextUrl);
};
}, [retryKey, sessionId, src]);
if (!objectUrl) {
if (loadFailed) {
return (
<span className="my-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/70 dark:bg-amber-950/30 dark:text-amber-300">
报告配图加载失败
<button
type="button"
className="font-medium underline underline-offset-2 hover:opacity-80"
onClick={() => setRetryKey((value) => value + 1)}
>
重试
</button>
</span>
);
}
return <span className="block text-xs text-muted-foreground">正在加载报告配图…</span>;
}
return (
<img
{...props}
alt={alt || "研究报告配图"}
className={cn("my-3 max-w-full overflow-hidden rounded-lg border", props.className)}
src={objectUrl}
/>
);
}
export function ArtifactFilePreview({ export function ArtifactFilePreview({
content, content,
language, language,
threadId, threadId,
publicShareCode, publicShareCode,
researchSessionId,
animated = false, animated = false,
activeCursor = false, activeCursor = false,
}: { }: {
@@ -1511,14 +1966,24 @@ export function ArtifactFilePreview({
language: string; language: string;
threadId: string; threadId: string;
publicShareCode?: string; publicShareCode?: string;
researchSessionId?: string;
animated?: boolean; animated?: boolean;
activeCursor?: boolean; activeCursor?: boolean;
}) { }) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>(); const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null); const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
const [mermaidError, setMermaidError] = useState<string | null>(null); const [mermaidError, setMermaidError] = useState<string | null>(null);
const researchSourcesQuery = useResearchSources(
language === "markdown" ? (researchSessionId ?? null) : null,
);
const previewContent = useMemo(
() => researchSessionId
? formatResearchCitationsForPreview(content ?? "", researchSourcesQuery.data?.items ?? [])
: preserveMarkdownStandaloneLines(content ?? ""),
[content, researchSessionId, researchSourcesQuery.data?.items],
);
const markdownComponents = useMemo(() => { const markdownComponents = useMemo(() => {
const anchorResolver = createMarkdownAnchorResolver(content ?? ""); const anchorResolver = createMarkdownAnchorResolver(previewContent);
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => { const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
const Tag = `h${level}` as const; const Tag = `h${level}` as const;
return ({ children, id, ...props }: HTMLAttributes<HTMLHeadingElement>) => ( return ({ children, id, ...props }: HTMLAttributes<HTMLHeadingElement>) => (
@@ -1574,7 +2039,7 @@ export function ArtifactFilePreview({
); );
}, },
}; };
}, [content]); }, [previewContent, publicShareCode, researchSessionId, threadId]);
useEffect(() => { useEffect(() => {
if (language !== "html") { if (language !== "html") {
@@ -1639,21 +2104,31 @@ export function ArtifactFilePreview({
animated && "animate-[sandbox-preview-enter_320ms_ease-out]", animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
)} )}
> >
<Streamdown <Streamdown
className="size-full" className="size-full"
{...streamdownPlugins} {...streamdownPlugins}
components={{ // react-markdown's safe default strips unknown protocols before the
// custom `img` renderer sees them. Deep-research images use this
// private pseudo URL and are then fetched through the authenticated
// artifact endpoint in `SandboxPreviewImage`.
urlTransform={(url) =>
url.startsWith("deep-research://")
? url
: defaultUrlTransform(url)
}
components={{
...markdownComponents, ...markdownComponents,
img: (props) => ( img: (props) => (
<SandboxPreviewImage <SandboxPreviewImage
{...props} {...props}
threadId={threadId} threadId={threadId}
publicShareCode={publicShareCode} publicShareCode={publicShareCode}
researchSessionId={researchSessionId}
/> />
), ),
}} }}
> >
{content ?? ""} {previewContent}
</Streamdown> </Streamdown>
{activeCursor && ( {activeCursor && (
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" /> <span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
@@ -1,5 +1,5 @@
import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react"; import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react";
import { useCallback, useState } from "react"; import { useCallback, useState, type ReactNode } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -29,6 +29,7 @@ export function ArtifactFileList({
threadId, threadId,
onOpenArtifact, onOpenArtifact,
onDownloadArtifact, onDownloadArtifact,
fileDescriptions,
}: { }: {
className?: string; className?: string;
files: string[]; files: string[];
@@ -36,6 +37,8 @@ export function ArtifactFileList({
onOpenArtifact?: (filepath: string) => void; onOpenArtifact?: (filepath: string) => void;
/** Optional host download action for virtual/session-owned artifacts. */ /** Optional host download action for virtual/session-owned artifacts. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>; onDownloadArtifact?: (filepath: string) => void | Promise<void>;
/** Optional per-file secondary text, such as the report structure used. */
fileDescriptions?: Record<string, ReactNode>;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { isMock, publicShareCode } = useThread(); const { isMock, publicShareCode } = useThread();
@@ -104,7 +107,7 @@ export function ArtifactFileList({
</div> </div>
</CardTitle> </CardTitle>
<CardDescription className="min-w-0 pl-8 text-xs"> <CardDescription className="min-w-0 pl-8 text-xs">
{getFileExtensionDisplayName(file)} file {fileDescriptions?.[file] ?? `${getFileExtensionDisplayName(file)} file`}
</CardDescription> </CardDescription>
<CardAction className="row-span-1 self-center"> <CardAction className="row-span-1 self-center">
{file.endsWith(".skill") && ( {file.endsWith(".skill") && (
@@ -56,12 +56,12 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifactsState] = useState<string[]>([]); const [artifacts, setArtifactsState] = useState<string[]>([]);
const setArtifacts = useCallback((next: string[]) => { const setArtifacts = useCallback((next: string[]) => {
const normalized = Array.isArray(next) ? next : []; const normalized = Array.isArray(next) ? next : [];
setArtifactsState((current) => { setArtifactsState((previous) =>
const unchanged = previous.length === normalized.length &&
current.length === normalized.length && previous.every((artifact, index) => artifact === normalized[index])
current.every((artifact, index) => artifact === normalized[index]); ? previous
return unchanged ? current : normalized; : normalized,
}); );
}, []); }, []);
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null); const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
const [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null); const [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null);
@@ -71,17 +71,23 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
); );
const [autoOpen, setAutoOpen] = useState(true); const [autoOpen, setAutoOpen] = useState(true);
const suppressedAutoOpenKeyRef = useRef<string | null>(null); const suppressedAutoOpenKeyRef = useRef<string | null>(null);
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
const autoOpenRef = useRef(autoOpen);
const sidebar = useSidebarSafe(); const sidebar = useSidebarSafe();
const select = useCallback( const select = useCallback(
(artifact: string, autoSelect = false) => { (artifact: string, autoSelect = false) => {
setSelectedArtifact(artifact); const previewKey = artifactPreviewKey(artifact);
setSelectedArtifactKey(artifactPreviewKey(artifact)); selectedArtifactKeyRef.current = previewKey;
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") { if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
sidebar?.setOpen(false); sidebar?.setOpen(false);
} }
if (!autoSelect) { if (!autoSelect) {
setAutoSelect(false); setAutoSelect((previous) => (previous ? false : previous));
} }
}, },
[sidebar], [sidebar],
@@ -92,11 +98,13 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
if (suppressedAutoOpenKeyRef.current === previewKey) { if (suppressedAutoOpenKeyRef.current === previewKey) {
return false; return false;
} }
setSelectedArtifact(artifact); selectedArtifactKeyRef.current = previewKey;
setSelectedArtifactKey(previewKey); autoOpenRef.current = true;
setAutoSelect(true); setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
setAutoOpen(true); setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
setOpenState(true); setAutoSelect((previous) => (previous ? previous : true));
setAutoOpen((previous) => (previous ? previous : true));
setOpenState((previous) => (previous ? previous : true));
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") { if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
sidebar?.setOpen(false); sidebar?.setOpen(false);
} }
@@ -106,26 +114,25 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
); );
const deselect = useCallback(() => { const deselect = useCallback(() => {
setSelectedArtifact(null); selectedArtifactKeyRef.current = null;
setSelectedArtifactKey(null); setSelectedArtifact((previous) => (previous === null ? previous : null));
setAutoSelect(true); setSelectedArtifactKey((previous) => (previous === null ? previous : null));
setOpenState(false); setAutoSelect((previous) => (previous ? previous : true));
setOpenState((previous) => (previous ? false : previous));
suppressedAutoOpenKeyRef.current = null; suppressedAutoOpenKeyRef.current = null;
}, []); }, []);
const setOpen = useCallback( const setOpen = useCallback((isOpen: boolean) => {
(isOpen: boolean) => { if (!isOpen && autoOpenRef.current) {
if (!isOpen && autoOpen) { autoOpenRef.current = false;
setAutoOpen(false); setAutoOpen((previous) => (previous ? false : previous));
setAutoSelect(false); setAutoSelect((previous) => (previous ? false : previous));
} }
if (!isOpen) { if (!isOpen) {
suppressedAutoOpenKeyRef.current = selectedArtifactKey; suppressedAutoOpenKeyRef.current = selectedArtifactKeyRef.current;
} }
setOpenState(isOpen); setOpenState((previous) => (previous === isOpen ? previous : isOpen));
}, }, []);
[autoOpen, selectedArtifactKey],
);
const value: ArtifactsContextType = { const value: ArtifactsContextType = {
artifacts, artifacts,
@@ -1,7 +1,7 @@
"use client"; "use client";
import { SparklesIcon } from "lucide-react"; import { FilePenLineIcon, ImagePlusIcon, SparklesIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -20,8 +20,24 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { ModelNameSelect } from "@/components/workspace/model-name-select"; import { ModelNameSelect } from "@/components/workspace/model-name-select";
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite"; import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
import { useReportStructures, REPORT_STRUCTURE_MODE_OPTIONS, normalizeReportStructureMode, type ReportStructureMode } from "@/strategy-components/api/report-structures";
const KEEP_CURRENT_REPORT_STRUCTURE = "__keep_current_report_structure__";
const DEFAULT_REWRITE_INSTRUCTION = "统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。";
const DEFAULT_GENERATE_INSTRUCTION = "基于已选资料按所选报告结构生成一篇新的完整报告;保持事实可核验,不补充没有依据的信息。";
export interface DocumentRewriteDialogInput {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
reportOutline: string;
structureMode: "fixed" | "adaptive";
generateImages: boolean;
maxGeneratedImages: number;
}
const STYLE_OPTIONS: { value: DocumentRewriteStyle; label: string; description: string }[] = [ const STYLE_OPTIONS: { value: DocumentRewriteStyle; label: string; description: string }[] = [
{ {
@@ -51,64 +67,130 @@ export function DocumentRewriteDialog({
onOpenChange, onOpenChange,
fileName, fileName,
charCount, charCount,
imageGenerationAvailable = false,
maxGeneratedImages = 4,
initialReportOutline,
operationLabel,
operationDescription,
submitLabel,
operation,
onStart, onStart,
onRewriteOriginal,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
fileName: string; fileName: string;
charCount: number; charCount: number;
onStart: (input: { imageGenerationAvailable?: boolean;
instruction: string; maxGeneratedImages?: number;
style: DocumentRewriteStyle; /** A saved report structure selected before the dialog was opened. */
modelName: string | null; initialReportOutline?: string;
}) => void; /** Optional copy for flows that create a new document instead of replacing one. */
operationLabel?: string;
operationDescription?: string;
submitLabel?: string;
/** ``generate`` creates an independent report; ``rewrite`` edits this report in place. */
operation?: "rewrite" | "generate";
onStart: (input: DocumentRewriteDialogInput) => void;
/** Optional secondary action for generation flows that rewrites the source report in place. */
onRewriteOriginal?: (input: DocumentRewriteDialogInput) => void;
}) { }) {
const [instruction, setInstruction] = useState( const isNewReportGeneration = operation === "generate";
"统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。", const [instruction, setInstruction] = useState(DEFAULT_REWRITE_INSTRUCTION);
);
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis"); const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
const [modelName, setModelName] = useState<string | null>(null); const [modelName, setModelName] = useState<string | null>(null);
const [reportStructureId, setReportStructureId] = useState(KEEP_CURRENT_REPORT_STRUCTURE);
const [structureMode, setStructureMode] = useState<ReportStructureMode>("adaptive");
const [generateImages, setGenerateImages] = useState(false);
const [imageCount, setImageCount] = useState("2");
const reportStructuresQuery = useReportStructures({ status: "enabled" });
const reportStructures = reportStructuresQuery.data ?? [];
const selectedReportStructure = reportStructures.find((item) => item.id === reportStructureId);
const initialReportStructureId = useMemo(() => {
const outline = initialReportOutline?.trim();
if (!outline) return null;
return reportStructures.find((item) => item.content.trim() === outline)?.id ?? null;
}, [initialReportOutline, reportStructures]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setInstruction(isNewReportGeneration ? DEFAULT_GENERATE_INSTRUCTION : DEFAULT_REWRITE_INSTRUCTION);
setStyle("formal_analysis"); setStyle("formal_analysis");
setModelName(null); setModelName(null);
}, [open]); setReportStructureId(KEEP_CURRENT_REPORT_STRUCTURE);
setStructureMode("adaptive");
setGenerateImages(false);
setImageCount("2");
}, [isNewReportGeneration, open]);
// The structures query may resolve after the dialog opens. Only seed the
// value while the user still has the default selection, so a manual choice
// is never overwritten by a query refresh.
useEffect(() => {
if (!open || !initialReportStructureId) return;
setReportStructureId((current) => (
current === KEEP_CURRENT_REPORT_STRUCTURE ? initialReportStructureId : current
));
}, [initialReportStructureId, open]);
useEffect(() => {
if (!selectedReportStructure) return;
setStructureMode(selectedReportStructure.structureMode);
}, [selectedReportStructure?.id, selectedReportStructure?.structureMode]);
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style); const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
const canStart = instruction.trim().length > 0; const canStart = instruction.trim().length > 0;
const startWith = (handler: (input: DocumentRewriteDialogInput) => void) => {
if (!canStart) return;
handler({
instruction: instruction.trim(),
style,
modelName,
reportOutline: selectedReportStructure?.content ?? "",
structureMode,
generateImages,
maxGeneratedImages: Math.min(
Math.max(Number(imageCount) || 1, 1),
Math.max(Math.min(maxGeneratedImages, 4), 1),
),
});
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl"> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<SparklesIcon className="size-5 text-primary" /> <SparklesIcon className="size-5 text-primary" />
AI 全文改写 {operationLabel ?? "AI 全文改写"}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
将以当前文件快照生成临时草稿。校验通过前不会覆盖原 Markdown。 {operationDescription ?? "将以当前文件快照生成临时草稿。校验通过前不会覆盖原 Markdown。"}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="rounded-lg border bg-muted/35 px-3 py-2.5 text-sm"> <div className="rounded-lg border bg-muted/35 px-3 py-2.5 text-sm">
<div className="font-medium">{fileName}</div> <div className="font-medium">{fileName}</div>
<div className="text-muted-foreground mt-1">原文约 {charCount.toLocaleString()} 字</div> <div className="text-muted-foreground mt-1">
{isNewReportGeneration ? "来源报告" : "原文"}约 {charCount.toLocaleString()} 字
</div>
</div> </div>
<label className="grid gap-2 text-sm font-medium"> <label className="grid gap-2 text-sm font-medium">
改写要求 {isNewReportGeneration ? "生成要求" : "改写要求"}
<Textarea <Textarea
value={instruction} value={instruction}
onChange={(event) => setInstruction(event.target.value)} onChange={(event) => setInstruction(event.target.value)}
placeholder="例如:统一为正式行业分析语气,减少重复,不新增外部事实。" placeholder={isNewReportGeneration
? "例如:突出政策影响,并按所选结构组织章节;仅使用已选资料。"
: "例如:统一为正式行业分析语气,减少重复,不新增外部事实。"}
rows={4} rows={4}
/> />
</label> </label>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm font-medium"> <label className="grid gap-2 text-sm font-medium">
改写风格 {isNewReportGeneration ? "报告风格" : "改写风格"}
<Select value={style} onValueChange={(value) => setStyle(value as DocumentRewriteStyle)}> <Select value={style} onValueChange={(value) => setStyle(value as DocumentRewriteStyle)}>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -124,6 +206,58 @@ export function DocumentRewriteDialog({
<span className="text-muted-foreground text-xs">{selectedStyle?.description}</span> <span className="text-muted-foreground text-xs">{selectedStyle?.description}</span>
</label> </label>
<label className="grid gap-2 text-sm font-medium">
报告结构
<Select value={reportStructureId} onValueChange={setReportStructureId}>
<SelectTrigger aria-label="报告结构">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={KEEP_CURRENT_REPORT_STRUCTURE}>沿用当前结构</SelectItem>
{reportStructuresQuery.isLoading && (
<SelectItem value="__report_structures_loading__" disabled>
正在加载结构…
</SelectItem>
)}
{reportStructuresQuery.isError && (
<SelectItem value="__report_structures_error__" disabled>
报告结构库加载失败
</SelectItem>
)}
{reportStructures.map((structure) => (
<SelectItem key={structure.id} value={structure.id}>
{structure.title}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs">
{selectedReportStructure?.name || "默认保留原文的章节安排。"}
</span>
</label>
<label className="grid gap-2 text-sm font-medium">
结构约束
<Select
value={structureMode}
onValueChange={(value) => setStructureMode(normalizeReportStructureMode(value))}
>
<SelectTrigger aria-label="结构约束">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_STRUCTURE_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs">
{REPORT_STRUCTURE_MODE_OPTIONS.find((option) => option.value === structureMode)?.description}
</span>
</label>
<label className="grid gap-2 text-sm font-medium"> <label className="grid gap-2 text-sm font-medium">
模型 模型
<ModelNameSelect <ModelNameSelect
@@ -132,23 +266,76 @@ export function DocumentRewriteDialog({
triggerClassName="h-10 text-sm" triggerClassName="h-10 text-sm"
defaultLabel="系统默认模型" defaultLabel="系统默认模型"
/> />
<span className="text-muted-foreground text-xs">仅影响本次全文改写。</span> <span className="text-muted-foreground text-xs">
仅影响本次{isNewReportGeneration ? "新报告生成" : "全文改写"}。
</span>
</label> </label>
</div> </div>
{imageGenerationAvailable && (
<div className="rounded-lg border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-4">
<label className="flex min-w-0 cursor-pointer items-start gap-3" htmlFor="document-rewrite-images">
<span className="bg-primary/10 text-primary mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
<ImagePlusIcon className="size-4" />
</span>
<span className="grid gap-0.5">
<span className="text-sm font-medium">
{isNewReportGeneration ? "为新报告补充配图" : "改写后补充配图"}
</span>
<span className="text-muted-foreground text-xs leading-5">
根据章节生成并保存为当前沙箱的本地图片,不使用外部图片链接。
</span>
</span>
</label>
<Switch
aria-label={isNewReportGeneration ? "为新报告补充配图" : "改写后补充配图"}
checked={generateImages}
id="document-rewrite-images"
onCheckedChange={setGenerateImages}
/>
</div>
{generateImages && (
<div className="mt-3 flex items-center justify-between gap-3 border-t pt-3">
<span className="text-muted-foreground text-xs">每次会额外消耗图片模型额度</span>
<label className="flex items-center gap-2 text-sm">
配图数量
<Select value={imageCount} onValueChange={setImageCount}>
<SelectTrigger className="h-8 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: Math.min(Math.max(maxGeneratedImages, 1), 4) }, (_, index) => index + 1).map((count) => (
<SelectItem key={count} value={String(count)}>{count} 张</SelectItem>
))}
</SelectContent>
</Select>
</label>
</div>
)}
</div>
)}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
取消 取消
</Button> </Button>
{isNewReportGeneration && onRewriteOriginal && (
<Button
variant="outline"
disabled={!canStart}
onClick={() => startWith(onRewriteOriginal)}
>
<FilePenLineIcon />
改写原文
</Button>
)}
<Button <Button
disabled={!canStart} disabled={!canStart}
onClick={() => { onClick={() => startWith(onStart)}
if (!canStart) return;
onStart({ instruction: instruction.trim(), style, modelName });
}}
> >
<SparklesIcon /> <SparklesIcon />
开始改写 {submitLabel ?? "开始改写"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -916,11 +916,10 @@ function buildTurnReferenceBatches(
continue; continue;
} }
const nextHumanIndex = findNextHumanIndex(messages, index + 1); const nextHumanIndex = findNextHumanIndex(messages, index + 1);
if (nextHumanIndex === -1 && isLoading) {
continue;
}
const endIndex = nextHumanIndex === -1 ? messages.length : nextHumanIndex; const endIndex = nextHumanIndex === -1 ? messages.length : nextHumanIndex;
const assistant = findLastAssistantAnswer(messages, index + 1, endIndex); const assistant =
findLastAssistantAnswer(messages, index + 1, endIndex) ??
findFrontendRagAssistant(messages, index + 1, endIndex);
if (!assistant?.id) { if (!assistant?.id) {
continue; continue;
} }
@@ -930,6 +929,14 @@ function buildTurnReferenceBatches(
nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id, nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id,
includeBeforeMessage: false, includeBeforeMessage: false,
}).filter((source) => source.mode !== "none"); }).filter((source) => source.mode !== "none");
// GeneralQA's single-RAG stream sends `docs` before the answer. Make
// those cards available immediately, while keeping normal streaming turns
// on their existing "wait for an answer" behavior.
const isFrontendRagTurn =
isSingleRoundRagMessage(assistant) || isMultiRoundRagMessage(assistant);
if (nextHumanIndex === -1 && isLoading && !isFrontendRagTurn) {
continue;
}
if (sources.length > 0) { if (sources.length > 0) {
batches.push({ batches.push({
id: assistant.id, id: assistant.id,
@@ -968,6 +975,23 @@ function findLastAssistantAnswer(
return null; return null;
} }
function findFrontendRagAssistant(
messages: Message[],
start: number,
end: number,
): Message | null {
for (let index = end - 1; index >= start; index -= 1) {
const message = messages[index];
if (
message?.type === "ai" &&
(isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message))
) {
return message;
}
}
return null;
}
function hasAssistantAnswerContent(message: Message): boolean { function hasAssistantAnswerContent(message: Message): boolean {
const content = message.content; const content = message.content;
if (typeof content === "string") { if (typeof content === "string") {
@@ -76,6 +76,7 @@ import {
import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { import {
isBootstrapThread, isBootstrapThread,
isHiddenFromRecentChats,
isThreadFavorite, isThreadFavorite,
isThreadPinned, isThreadPinned,
listPathOfThread, listPathOfThread,
@@ -226,14 +227,8 @@ export function GeneralQaSidebar() {
const visibleThreads = useMemo( const visibleThreads = useMemo(
() => () =>
// 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页); // 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页);
// 仅排除调度 / 会商类系统会话。 // 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
sortThreadsPinnedFirst( sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable",
),
),
[threads], [threads],
); );
@@ -2,34 +2,61 @@
import { import {
CheckCircle2Icon, CheckCircle2Icon,
ChevronDownIcon,
ChevronsDownIcon,
FilePenLineIcon, FilePenLineIcon,
LightbulbIcon,
Loader2Icon, Loader2Icon,
Undo2Icon, Undo2Icon,
XCircleIcon, XCircleIcon,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { apiFetch } from "@/core/api/fetch-client";
import { import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { apiFetch } from "@/core/api/fetch-client";
import { updateArtifactContent } from "@/core/artifacts/api";
import { urlOfArtifact } from "@/core/artifacts/utils";
import {
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
DOCUMENT_REWRITE_UNDONE_EVENT, DOCUMENT_REWRITE_UNDONE_EVENT,
type DocumentRewritePresentation, type DocumentRewritePresentation,
useDocumentRewritePresentation, useDocumentRewritePresentation,
} from "@/core/document-rewrite/context"; } from "@/core/document-rewrite/context";
import {
clearArtifactRewriteFallback,
loadArtifactRewriteFallback,
sha256DocumentContent,
} from "@/core/document-rewrite/local-fallback";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog"; import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
const STEPS = [ const REWRITE_STEPS = [
{ key: "snapshot", label: "固定原文版本" }, { key: "snapshot", label: "固定原文版本" },
{ key: "requirements", label: "理解改写要求" }, { key: "requirements", label: "理解改写要求" },
{ key: "writing", label: "流式重写 Markdown" }, { key: "writing", label: "流式重写 Markdown" },
{ key: "illustrations", label: "生成文档配图" },
{ key: "validation", label: "校验文档" }, { key: "validation", label: "校验文档" },
{ key: "comparison", label: "对比原文并生成说明" }, { key: "comparison", label: "对比原文并生成说明" },
] as const; ] as const;
const GENERATE_STEPS = [
{ key: "snapshot", label: "固定来源报告快照" },
{ key: "requirements", label: "确认用户生成需求" },
{ key: "writing", label: "流式生成 Markdown" },
{ key: "illustrations", label: "生成文档配图" },
{ key: "validation", label: "校验新报告" },
{ key: "comparison", label: "生成报告说明" },
] as const;
function stepPosition(stage: DocumentRewritePresentation["stage"]): number { function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
switch (stage) { switch (stage) {
case "snapshot": case "snapshot":
@@ -38,15 +65,17 @@ function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
return 1; return 1;
case "writing": case "writing":
return 2; return 2;
case "validation": case "illustrating":
return 3; return 3;
case "validation":
return 4;
case "committing": case "committing":
case "completed": case "completed":
return 5; return 6;
case "conflict": case "conflict":
case "error": case "error":
case "cancelled": case "cancelled":
return 4; return 5;
default: default:
return -1; return -1;
} }
@@ -56,23 +85,218 @@ function isTerminal(stage: DocumentRewritePresentation["stage"]) {
return ["completed", "conflict", "error", "cancelled"].includes(stage); return ["completed", "conflict", "error", "cancelled"].includes(stage);
} }
function RewriteThinkingTrace({ text, active }: { text: string; active: boolean }) {
const [open, setOpen] = useState(active);
const [autoScrollPaused, setAutoScrollPaused] = useState(false);
const contentRef = useRef<HTMLPreElement>(null);
const autoScrollEnabledRef = useRef(true);
const previousActiveRef = useRef(active);
const lastAutoScrollTopRef = useRef<number | null>(null);
useEffect(() => {
if (active && text) setOpen(true);
}, [active, text]);
useEffect(() => {
if (active && !previousActiveRef.current) {
autoScrollEnabledRef.current = true;
lastAutoScrollTopRef.current = null;
setAutoScrollPaused(false);
}
previousActiveRef.current = active;
}, [active]);
const scrollToLatestThinking = useCallback(() => {
if (!autoScrollEnabledRef.current) return;
const content = contentRef.current;
if (!content) return;
content.scrollTop = content.scrollHeight;
lastAutoScrollTopRef.current = content.scrollTop;
}, []);
useLayoutEffect(() => {
if (!active || !open || !text) return;
scrollToLatestThinking();
}, [active, open, scrollToLatestThinking, text]);
useEffect(() => {
if (!active || !open || !text) return;
const content = contentRef.current;
if (!content) return;
let frame: number | null = null;
const observer = new MutationObserver(() => {
if (frame !== null) cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
frame = null;
scrollToLatestThinking();
});
});
observer.observe(content, { childList: true, characterData: true, subtree: true });
return () => {
observer.disconnect();
if (frame !== null) cancelAnimationFrame(frame);
};
}, [active, open, scrollToLatestThinking, text]);
const pauseAutoScrollOnManualScroll = (event: React.UIEvent<HTMLPreElement>) => {
if (!active || !autoScrollEnabledRef.current) return;
const lastAutoScrollTop = lastAutoScrollTopRef.current;
if (
lastAutoScrollTop === null ||
Math.abs(event.currentTarget.scrollTop - lastAutoScrollTop) > 1
) {
autoScrollEnabledRef.current = false;
setAutoScrollPaused(true);
}
};
const resumeAutoScroll = () => {
autoScrollEnabledRef.current = true;
setAutoScrollPaused(false);
requestAnimationFrame(scrollToLatestThinking);
};
if (!text.trim()) return null;
return (
<Collapsible className="mt-2 max-w-3xl" onOpenChange={setOpen} open={open}>
<CollapsibleTrigger asChild>
<Button
aria-label={open ? "收起模型思考" : "展开模型思考"}
className="h-7 gap-1.5 px-2 text-xs"
size="sm"
type="button"
variant="ghost"
>
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
<span>模型思考</span>
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
<ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
</p>
<div className="relative mt-1">
<pre
className="bg-muted/45 text-muted-foreground max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words"
onScroll={pauseAutoScrollOnManualScroll}
ref={contentRef}
>
{text}
</pre>
{active && autoScrollPaused && (
<Button
aria-label="恢复思考内容自动下滑"
className="absolute top-1/2 left-1/2 h-9 -translate-x-1/2 -translate-y-1/2 gap-1.5 bg-background/95 px-3 text-xs shadow-md backdrop-blur-sm hover:bg-background"
onClick={resumeAutoScroll}
size="sm"
title="恢复自动下滑"
type="button"
variant="outline"
>
<ChevronsDownIcon className="size-3.5" />
自动下滑
</Button>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}
export function DocumentRewriteTimeline({ export function DocumentRewriteTimeline({
item, item,
}: { }: {
item: DocumentRewritePresentation; item: DocumentRewritePresentation;
}) { }) {
const isNewReportGeneration = item.operation === "generate";
const steps = isNewReportGeneration ? GENERATE_STEPS : REWRITE_STEPS;
const current = stepPosition(item.stage); const current = stepPosition(item.stage);
const failed = ["conflict", "error", "cancelled"].includes(item.stage); const failed = ["conflict", "error", "cancelled"].includes(item.stage);
const completed = item.stage === "completed"; const completed = item.stage === "completed";
const thinkingStep = ["snapshot", "requirements"].includes(item.stage)
? "requirements"
: "writing";
const presentation = useDocumentRewritePresentation(); const presentation = useDocumentRewritePresentation();
const [undoing, setUndoing] = useState(false); const [undoing, setUndoing] = useState(false);
const [diffOpen, setDiffOpen] = useState(false); const [diffOpen, setDiffOpen] = useState(false);
const reapplying = Boolean(item.revertedAt); const reapplying = Boolean(item.revertedAt);
const activeVersionId = reapplying ? item.redoVersionId : item.versionId; const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
const canUseLocalFallback =
!reapplying &&
!activeVersionId &&
item.source.kind === "artifact" &&
typeof item.source.path === "string" &&
item.localFallbackAvailable &&
typeof item.committedHash === "string";
const handleUndo = async () => { const handleUndo = async () => {
if (!activeVersionId || undoing) return; if ((!activeVersionId && !canUseLocalFallback) || undoing) return;
if (canUseLocalFallback && item.source.kind === "artifact" && item.source.path && item.committedHash) {
setUndoing(true);
try {
const fallback = loadArtifactRewriteFallback({
threadId: item.source.id,
path: item.source.path,
committedHash: item.committedHash,
});
if (!fallback) {
console.error("本地 AI 全文改写撤销备份不可用,未修改当前文件。");
return;
}
const currentResponse = await apiFetch(
urlOfArtifact({ filepath: item.source.path, threadId: item.source.id }),
);
if (!currentResponse.ok) {
throw new Error(`读取当前文件失败(${currentResponse.status})。`);
}
const currentHash = await sha256DocumentContent(await currentResponse.text());
if (!currentHash || currentHash !== fallback.committedHash) {
console.error("当前文件已在改写后发生变化,已跳过本地 AI 全文改写撤销。");
return;
}
const guard = new CustomEvent<{
source: DocumentRewritePresentation["source"];
rewrittenContent: string | null;
unchanged: boolean;
}>(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, {
detail: {
source: item.source,
rewrittenContent: item.rewrittenContent,
unchanged: false,
},
});
window.dispatchEvent(guard);
if (!guard.detail.unchanged) {
console.error("沙箱中存在改写后的未保存编辑,已跳过本地 AI 全文改写撤销。");
return;
}
await updateArtifactContent({
filepath: item.source.path,
threadId: item.source.id,
content: fallback.originalContent,
});
clearArtifactRewriteFallback(item.source.id, item.source.path);
presentation?.update(item.id, {
localFallbackAvailable: false,
revertedAt: new Date().toISOString(),
});
window.dispatchEvent(
new CustomEvent(DOCUMENT_REWRITE_UNDONE_EVENT, {
detail: { source: item.source, content: fallback.originalContent },
}),
);
} catch (error) {
console.error("本地 AI 全文改写撤销失败:", error);
} finally {
setUndoing(false);
}
return;
}
if (!activeVersionId) return;
const baseUrl = getBackendBaseURL(); const baseUrl = getBackendBaseURL();
const endpoint = const endpoint =
item.source.kind === "artifact" item.source.kind === "artifact"
@@ -109,7 +333,15 @@ export function DocumentRewriteTimeline({
detail: { source: item.source, content }, detail: { source: item.source, content },
}), }),
); );
toast.success(reapplying ? "已重新应用本次 AI 全文改写版本。" : "已恢复到本次 AI 全文改写前的版本。"); toast.success(
isNewReportGeneration
? reapplying
? "已重新应用本次生成的新报告版本。"
: "已恢复本次生成前的新报告快照。"
: reapplying
? "已重新应用本次 AI 全文改写版本。"
: "已恢复到本次 AI 全文改写前的版本。",
);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "撤销改写失败,请稍后重试。"); toast.error(error instanceof Error ? error.message : "撤销改写失败,请稍后重试。");
} finally { } finally {
@@ -125,7 +357,9 @@ export function DocumentRewriteTimeline({
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold">AI 全文改写 · {item.fileName}</h3> <h3 className="font-semibold">
{isNewReportGeneration ? "生成新报告" : "AI 全文改写"} · {item.fileName}
</h3>
<span <span
className={cn( className={cn(
"rounded-full px-2 py-0.5 text-xs", "rounded-full px-2 py-0.5 text-xs",
@@ -136,7 +370,7 @@ export function DocumentRewriteTimeline({
: "bg-primary/10 text-primary", : "bg-primary/10 text-primary",
)} )}
> >
{completed ? "已完成" : failed ? "未覆盖原文件" : "进行中"} {completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未覆盖原文件") : "进行中"}
</span> </span>
</div> </div>
<p className="text-muted-foreground mt-1 text-sm"> <p className="text-muted-foreground mt-1 text-sm">
@@ -146,9 +380,15 @@ export function DocumentRewriteTimeline({
</header> </header>
<div className="space-y-3 px-5 py-4"> <div className="space-y-3 px-5 py-4">
{STEPS.map((step, index) => { {steps.map((step, index) => {
const active = !isTerminal(item.stage) && index === current; const active = !isTerminal(item.stage) && index === current;
const done = completed || index < current; const illustrationSkipped = step.key === "illustrations" && !item.generateImages;
const illustrationFailed =
step.key === "illustrations" &&
item.generateImages &&
completed &&
item.generatedImageCount === 0;
const done = !illustrationSkipped && !illustrationFailed && (completed || index < current);
return ( return (
<div className="flex items-start gap-3" key={step.key}> <div className="flex items-start gap-3" key={step.key}>
<div <div
@@ -173,7 +413,7 @@ export function DocumentRewriteTimeline({
<div className="text-sm font-medium">{step.label}</div> <div className="text-sm font-medium">{step.label}</div>
{step.key === "snapshot" && item.originalCharCount > 0 && ( {step.key === "snapshot" && item.originalCharCount > 0 && (
<p className="text-muted-foreground mt-0.5 text-xs"> <p className="text-muted-foreground mt-0.5 text-xs">
已冻结 {item.originalCharCount.toLocaleString()} 字原文快照。 已冻结 {item.originalCharCount.toLocaleString()} 字{isNewReportGeneration ? "来源报告" : "原文"}快照。
</p> </p>
)} )}
{step.key === "requirements" && ( {step.key === "requirements" && (
@@ -187,18 +427,52 @@ export function DocumentRewriteTimeline({
</div> </div>
) : active ? ( ) : active ? (
<p className="text-muted-foreground mt-0.5 text-xs"> <p className="text-muted-foreground mt-0.5 text-xs">
正在识别改写目标、保留约束与执行方式… {isNewReportGeneration
? "正在确认新报告的结构、资料范围与生成要求…"
: "正在识别改写目标、保留约束与执行方式…"}
</p> </p>
) : ( ) : (
<p className="text-muted-foreground mt-0.5 text-xs"> <p className="text-muted-foreground mt-0.5 text-xs">
已根据改写要求确定执行计划。 {isNewReportGeneration ? "已根据所选结构确定生成计划。" : "已根据改写要求确定执行计划。"}
</p> </p>
) )
)} )}
{step.key === "writing" && item.writtenCharCount > 0 && ( {step.key === "writing" && (
<p className="text-muted-foreground mt-0.5 text-xs"> item.writtenCharCount > 0 ? (
正在写入右侧沙箱,已生成 {item.writtenCharCount.toLocaleString()} 字。 <p className="text-muted-foreground mt-0.5 text-xs">
</p> 正在写入右侧沙箱,已{isNewReportGeneration ? "生成" : "改写"} {item.writtenCharCount.toLocaleString()} 字。
</p>
) : active ? (
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
{isNewReportGeneration
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
: "正在等待模型开始输出;右侧将在首个字符到达后显示草稿。"}
</p>
) : null
)}
{step.key === "illustrations" && (
illustrationSkipped ? (
<p className="text-muted-foreground mt-0.5 text-xs">
本次未启用配图,保留原文已有图片与链接。
</p>
) : active ? (
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
{item.illustrationTarget
? `正在为「${item.illustrationTarget}」生成配图,已完成 ${item.generatedImageCount}/${item.maxGeneratedImages} 张。`
: `正在准备 ${item.maxGeneratedImages} 张文档配图…`}
</p>
) : item.generatedImageCount > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs">
已生成并保存 {item.generatedImageCount} 张本地配图。
</p>
) : (
<p className="text-muted-foreground mt-0.5 text-xs">
未成功生成配图,文字改写结果仍已保留。
</p>
)
)}
{step.key === thinkingStep && (
<RewriteThinkingTrace active={active} text={item.thinkingText} />
)} )}
{step.key === "validation" && item.warnings.length > 0 && ( {step.key === "validation" && item.warnings.length > 0 && (
<p className="mt-0.5 text-xs text-amber-700 dark:text-amber-300"> <p className="mt-0.5 text-xs text-amber-700 dark:text-amber-300">
@@ -213,9 +487,10 @@ export function DocumentRewriteTimeline({
{item.summary && ( {item.summary && (
<div className="border-t px-5 py-4 text-sm leading-6"> <div className="border-t px-5 py-4 text-sm leading-6">
<div className="mb-2 font-semibold">改写说明</div> <div className="mb-2 font-semibold">{isNewReportGeneration ? "生成说明" : "改写说明"}</div>
<p> <p>
原文 {item.summary.originalCharCount.toLocaleString()} 字,改写后{" "} {isNewReportGeneration ? "来源报告" : "原文"} {item.summary.originalCharCount.toLocaleString()} 字,
{isNewReportGeneration ? "新报告" : "改写后"}{" "}
{item.summary.rewrittenCharCount.toLocaleString()} 字;调整了约{" "} {item.summary.rewrittenCharCount.toLocaleString()} 字;调整了约{" "}
{item.summary.changedBlockCount} 个内容块。 {item.summary.changedBlockCount} 个内容块。
</p> </p>
@@ -228,6 +503,11 @@ export function DocumentRewriteTimeline({
本次优化:{item.summary.requestedOptimizations.join("、")}。 本次优化:{item.summary.requestedOptimizations.join("、")}。
</p> </p>
) : null} ) : null}
{item.summary.requestedImageCount ? (
<p className="text-muted-foreground">
文档配图:请求 {item.summary.requestedImageCount} 张,已保存 {item.summary.generatedImageCount ?? 0} 张本地图片。
</p>
) : null}
{item.summary.headingChanges && ( {item.summary.headingChanges && (
item.summary.headingChanges.reordered || item.summary.headingChanges.reordered ||
item.summary.headingChanges.added.length > 0 || item.summary.headingChanges.added.length > 0 ||
@@ -256,7 +536,25 @@ export function DocumentRewriteTimeline({
查看前后对比 查看前后对比
</Button> </Button>
)} )}
{completed && activeVersionId && ( {isNewReportGeneration && completed && item.source.kind === "deep_research_report" && (
<Button
className="mt-3 mr-2"
onClick={() => {
window.dispatchEvent(
new CustomEvent(DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT, {
detail: { sessionId: item.source.id },
}),
);
}}
size="sm"
type="button"
variant="outline"
>
<FilePenLineIcon className="mr-1.5 size-3.5" />
改写新报告
</Button>
)}
{completed && (activeVersionId || canUseLocalFallback) && (
<Button <Button
className="mt-3" className="mt-3"
disabled={undoing} disabled={undoing}
@@ -266,12 +564,19 @@ export function DocumentRewriteTimeline({
variant="outline" variant="outline"
> >
{undoing ? <Loader2Icon className="mr-1.5 size-3.5 animate-spin" /> : <Undo2Icon className="mr-1.5 size-3.5" />} {undoing ? <Loader2Icon className="mr-1.5 size-3.5 animate-spin" /> : <Undo2Icon className="mr-1.5 size-3.5" />}
{reapplying ? "重新应用此次改写" : "撤销此次改写"} {isNewReportGeneration
? reapplying
? "重新应用本次生成"
: "撤销本次生成"
: reapplying
? "重新应用此次改写"
: "撤销此次改写"}
</Button> </Button>
)} )}
{item.revertedAt && ( {item.revertedAt && (
<p className="mt-2 text-xs text-emerald-700 dark:text-emerald-300"> <p className="mt-2 text-xs text-emerald-700 dark:text-emerald-300">
已恢复为改写前版本。{item.redoVersionId ? "可重新应用本次改写。" : ""} 已恢复为{isNewReportGeneration ? "生成前" : "改写前"}版本。
{item.redoVersionId ? `可重新应用本次${isNewReportGeneration ? "生成" : "改写"}。` : ""}
</p> </p>
)} )}
</div> </div>
@@ -3,6 +3,7 @@ import {
BookOpenTextIcon, BookOpenTextIcon,
CheckCircle2Icon, CheckCircle2Icon,
ChevronDownIcon, ChevronDownIcon,
ChevronsDownIcon,
ChevronUp, ChevronUp,
FolderOpenIcon, FolderOpenIcon,
GlobeIcon, GlobeIcon,
@@ -15,7 +16,7 @@ import {
SquareTerminalIcon, SquareTerminalIcon,
WrenchIcon, WrenchIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type AnchorHTMLAttributes } from "react";
import { import {
ChainOfThought, ChainOfThought,
@@ -58,6 +59,7 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
import { import {
applyInlineCitations,
buildSkillDisplayMap, buildSkillDisplayMap,
findDisplayConfigForTool, findDisplayConfigForTool,
isDisplaySamplePayload, isDisplaySamplePayload,
@@ -71,20 +73,24 @@ import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useArtifactsOptional } from "../artifacts"; import { useArtifactsOptional } from "../artifacts";
import { CitationLink } from "../citations/citation-link";
import { FlipDisplay } from "../flip-display"; import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources"; import { SourceCard } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content"; import { MarkdownContent } from "./markdown-content";
import { SingleRoundRagCitationDialog } from "./message-list-item";
export function MessageGroup({ export function MessageGroup({
className, className,
messages, messages,
isLoading = false, isLoading = false,
displaySources = [],
}: { }: {
className?: string; className?: string;
messages: Message[]; messages: Message[];
isLoading?: boolean; isLoading?: boolean;
displaySources?: RuntimeDisplaySource[];
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const [showAbove, setShowAbove] = useState( const [showAbove, setShowAbove] = useState(
@@ -121,11 +127,29 @@ export function MessageGroup({
} }
}, [lastToolCallStep, steps]); }, [lastToolCallStep, steps]);
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const [selectedCitation, setSelectedCitation] =
useState<RuntimeDisplaySource | null>(null);
const handleCitationClick = useCallback(
(referenceId: string) => {
const referenceIndex = Number(referenceId);
if (!Number.isInteger(referenceIndex) || referenceIndex < 1) return;
const source =
displaySources.find((item) => item.index === referenceIndex) ??
displaySources[referenceIndex - 1];
if (source) setSelectedCitation(source);
},
[displaySources],
);
const reasoningId =
lastReasoningStep?.id ??
messages.find((message) => message.type === "ai")?.id;
return ( return (
<ChainOfThought <>
className={cn("w-full gap-2 rounded-lg border p-0.5", className)} <ChainOfThought
open={true} className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
> data-reference-batch-id={reasoningId}
open={true}
>
{aboveLastToolCallSteps.length > 0 && ( {aboveLastToolCallSteps.length > 0 && (
<Button <Button
key="above" key="above"
@@ -160,10 +184,12 @@ export function MessageGroup({
<ChainOfThoughtStep <ChainOfThoughtStep
key={step.id} key={step.id}
label={ label={
<MarkdownContent <StepReasoningMarkdown
content={step.reasoning ?? ""} content={step.reasoning ?? ""}
displaySources={displaySources}
isLoading={isLoading} isLoading={isLoading}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
onCitationClick={handleCitationClick}
/> />
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
@@ -220,10 +246,12 @@ export function MessageGroup({
<ChainOfThoughtStep <ChainOfThoughtStep
key={lastReasoningStep.id} key={lastReasoningStep.id}
label={ label={
<MarkdownContent <StepReasoningMarkdown
content={lastReasoningStep.reasoning ?? ""} content={lastReasoningStep.reasoning ?? ""}
displaySources={displaySources}
isLoading={isLoading} isLoading={isLoading}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
onCitationClick={handleCitationClick}
/> />
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
@@ -231,7 +259,60 @@ export function MessageGroup({
)} )}
</> </>
)} )}
</ChainOfThought> </ChainOfThought>
<SingleRoundRagCitationDialog
source={selectedCitation}
onOpenChange={(open) => {
if (!open) setSelectedCitation(null);
}}
/>
</>
);
}
function StepReasoningMarkdown({
content,
displaySources,
isLoading,
rehypePlugins,
onCitationClick,
}: {
content: string;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onCitationClick: (referenceId: string) => void;
}) {
const markdown = useMemo(
() =>
applyInlineCitations(content, displaySources, { appendSummary: false }),
[content, displaySources],
);
const components = useMemo(() => {
if (displaySources.length === 0) return undefined;
return {
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("#reference-")) {
return (
<CitationLink
href={href}
{...props}
onReferenceClick={onCitationClick}
/>
);
}
return <a href={href} {...props} />;
},
};
}, [displaySources.length, onCitationClick]);
return (
<MarkdownContent
content={markdown}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
); );
} }
@@ -529,7 +610,64 @@ function DeepResearchProgressStep({
? args.label ? args.label
: "正在撰写研究报告"; : "正在撰写研究报告";
const detail = typeof args.detail === "string" ? args.detail.trim() : ""; const detail = typeof args.detail === "string" ? args.detail.trim() : "";
const thinking = typeof args.thinking === "string" ? args.thinking.trim() : "";
const active = args.status !== "complete"; const active = args.status !== "complete";
const [thinkingOpen, setThinkingOpen] = useState(false);
const [autoScrollPaused, setAutoScrollPaused] = useState(false);
const thinkingContentRef = useRef<HTMLPreElement>(null);
const autoScrollEnabledRef = useRef(true);
const programmaticScrollRef = useRef(false);
const previousActiveRef = useRef(active);
useEffect(() => {
if (active && thinking) setThinkingOpen(true);
}, [active, thinking]);
useEffect(() => {
if (active && !previousActiveRef.current) {
autoScrollEnabledRef.current = true;
setAutoScrollPaused(false);
}
previousActiveRef.current = active;
}, [active]);
const scrollToLatestThinking = useCallback(() => {
if (!autoScrollEnabledRef.current) return;
const content = thinkingContentRef.current;
if (!content) return;
// Scroll events do not identify their initiator reliably across browsers.
// Mark this assignment so it never disables following as if it were a
// user drag; a user-originated scroll away from the bottom still pauses it.
programmaticScrollRef.current = true;
content.scrollTop = content.scrollHeight;
requestAnimationFrame(() => {
programmaticScrollRef.current = false;
});
}, []);
useLayoutEffect(() => {
if (!active || !thinkingOpen || !thinking) return;
scrollToLatestThinking();
// A second frame covers the browser's delayed reflow of a growing <pre>.
const frame = requestAnimationFrame(scrollToLatestThinking);
return () => cancelAnimationFrame(frame);
}, [active, thinking, thinkingOpen, scrollToLatestThinking]);
const pauseAutoScrollOnManualScroll = (event: React.UIEvent<HTMLPreElement>) => {
if (!active || !autoScrollEnabledRef.current || programmaticScrollRef.current) return;
const { clientHeight, scrollHeight, scrollTop } = event.currentTarget;
// Being at the bottom is still the follow state. Only an actual move away
// from it (wheel, touch, keyboard or scrollbar drag) pauses auto-follow.
if (scrollTop + clientHeight >= scrollHeight - 4) return;
autoScrollEnabledRef.current = false;
setAutoScrollPaused(true);
};
const resumeAutoScroll = () => {
autoScrollEnabledRef.current = true;
setAutoScrollPaused(false);
requestAnimationFrame(scrollToLatestThinking);
};
return ( return (
<ChainOfThoughtStep <ChainOfThoughtStep
@@ -552,6 +690,52 @@ function DeepResearchProgressStep({
{detail} {detail}
</div> </div>
)} )}
{thinking && (
<Collapsible className="mt-2" onOpenChange={setThinkingOpen} open={thinkingOpen}>
<CollapsibleTrigger asChild>
<Button
aria-label={thinkingOpen ? "收起模型思考" : "展开模型思考"}
className="h-7 gap-1.5 px-2 text-xs"
size="sm"
type="button"
variant="ghost"
>
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
<span>模型思考</span>
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
<ChevronDownIcon className={cn("size-3.5 transition-transform", thinkingOpen && "rotate-180")} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
</p>
<div className="relative mt-1">
<pre
className="bg-muted/45 text-muted-foreground max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words"
onScroll={pauseAutoScrollOnManualScroll}
ref={thinkingContentRef}
>
{thinking}
</pre>
{active && autoScrollPaused && (
<Button
aria-label="恢复思考内容自动下滑"
className="absolute top-1/2 left-1/2 h-9 -translate-x-1/2 -translate-y-1/2 gap-1.5 bg-background/95 px-3 text-xs shadow-md backdrop-blur-sm hover:bg-background"
onClick={resumeAutoScroll}
size="sm"
title="恢复自动下滑"
type="button"
variant="outline"
>
<ChevronsDownIcon className="size-3.5" />
自动下滑
</Button>
)}
</div>
</CollapsibleContent>
</Collapsible>
)}
</ChainOfThoughtStep> </ChainOfThoughtStep>
); );
} }
@@ -588,10 +772,13 @@ function ArtifactWriteToolCall({
? provisionalWriteFilePath(content) ? provisionalWriteFilePath(content)
: null; : null;
const previewKey = messageId && id ? `${messageId}:${id}` : null; const previewKey = messageId && id ? `${messageId}:${id}` : null;
// Never select a bare provisional name like `未命名文件.md` — the artifacts
// API only serves `/mnt/user-data/...` paths and would 400. Wait until the
// tool call has ids so we can open a write-file: memory preview instead.
const artifactPath = const artifactPath =
sourcePath && name === "write_file" && messageId && id sourcePath && name === "write_file" && messageId && id
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id }) ? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
: sourcePath; : null;
useEffect(() => { useEffect(() => {
if ( if (
@@ -8,6 +8,7 @@ import {
Loader2Icon, Loader2Icon,
PencilIcon, PencilIcon,
SearchIcon, SearchIcon,
SparklesIcon,
} from 'lucide-react'; } from 'lucide-react';
import { import {
memo, memo,
@@ -18,6 +19,7 @@ import {
useState, useState,
type AnchorHTMLAttributes, type AnchorHTMLAttributes,
type ImgHTMLAttributes, type ImgHTMLAttributes,
type ReactNode,
} from 'react'; } from 'react';
import rehypeKatex from 'rehype-katex'; import rehypeKatex from 'rehype-katex';
@@ -693,6 +695,10 @@ function MessageContent_({
const withCitations = applyInlineCitations(rawContent ?? '', displaySources); const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
return stripIncompleteCitationMarker(withCitations); return stripIncompleteCitationMarker(withCitations);
}, [rawContent, isHuman, displaySources]); }, [rawContent, isHuman, displaySources]);
const markdownRehypePlugins = useMemo(
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
[rehypePlugins],
);
const parsedHumanContent = useMemo( const parsedHumanContent = useMemo(
() => () =>
isHuman isHuman
@@ -780,7 +786,7 @@ function MessageContent_({
content={contentToDisplay} content={contentToDisplay}
displaySources={displaySources} displaySources={displaySources}
isLoading={isLoading} isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]} rehypePlugins={markdownRehypePlugins}
components={components} components={components}
/> />
) : ( ) : (
@@ -789,7 +795,7 @@ function MessageContent_({
displaySources={displaySources} displaySources={displaySources}
isLoading={isLoading} isLoading={isLoading}
renderSkillDisplayBlocks={false} renderSkillDisplayBlocks={false}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]} rehypePlugins={markdownRehypePlugins}
components={components} components={components}
/> />
)} )}
@@ -838,7 +844,7 @@ function FrontendRagSourcesPanel({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
if (sources.length === 0) return null; if (sources.length === 0) return null;
return ( return (
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full"> <Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button <button
type="button" type="button"
@@ -891,7 +897,7 @@ function FrontendRagSourcesPanel({
); );
} }
function SingleRoundRagCitationDialog({ export function SingleRoundRagCitationDialog({
source, source,
onOpenChange, onOpenChange,
}: { }: {
@@ -947,11 +953,14 @@ function MultiRoundRagContent({
}) { }) {
const [open, setOpen] = useState(!trace.isComplete); const [open, setOpen] = useState(!trace.isComplete);
useEffect(() => { useEffect(() => {
if (!trace.isComplete) setOpen(true); // Keep the active turn expanded so each new retrieval is visible as it
// arrives, then match GeneralQA by collapsing the completed trace. The
// header still carries the total round count for quick scanning.
setOpen(!trace.isComplete);
}, [trace.isComplete]); }, [trace.isComplete]);
return ( return (
<div className="space-y-3"> <div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<section className="border-border/70 overflow-hidden rounded-xl border bg-muted/20"> <section className="border-border/70 overflow-hidden rounded-xl border bg-muted/20">
<button <button
type="button" type="button"
@@ -965,10 +974,10 @@ function MultiRoundRagContent({
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" /> <Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
)} )}
<div> <div>
<div className="text-sm font-medium">多轮检索过程</div> <div className="text-sm font-medium">多轮检索思考过程</div>
<div className="text-muted-foreground text-xs"> <div className="text-muted-foreground text-xs">
{trace.isComplete {trace.isComplete
? `已完成 ${trace.rounds.length} 轮检索` ? `共 ${trace.rounds.length} 轮推理与检索,可展开查看细节`
: '正在分析问题并检索资料'} : '正在分析问题并检索资料'}
</div> </div>
</div> </div>
@@ -980,14 +989,26 @@ function MultiRoundRagContent({
{open && ( {open && (
<div className="border-border/60 space-y-3 border-t px-4 py-3"> <div className="border-border/60 space-y-3 border-t px-4 py-3">
{trace.rounds.map((round, index) => ( {trace.rounds.map((round, index) => (
<MultiRoundRagRoundCard <div
key={round.id} key={round.id}
index={index + 1} className={cn(
round={round} 'relative pl-5',
isLoading={isLoading} index < trace.rounds.length - 1 && 'pb-1',
rehypePlugins={rehypePlugins} )}
components={components} >
/> {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> </div>
)} )}
@@ -1017,85 +1038,244 @@ function MultiRoundRagContent({
function MultiRoundRagRoundCard({ function MultiRoundRagRoundCard({
index, index,
round, round,
displaySources,
isLoading, isLoading,
rehypePlugins, rehypePlugins,
components, components,
}: { }: {
index: number; index: number;
round: MultiRoundRagRound; round: MultiRoundRagRound;
displaySources: RuntimeDisplaySource[];
isLoading: boolean; isLoading: boolean;
rehypePlugins: MarkdownContentProps['rehypePlugins']; rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components']; 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 ( return (
<div className="border-border/60 rounded-lg border bg-background/70 p-3"> <Collapsible open={open} onOpenChange={setOpen}>
<div className="flex items-center gap-2 text-sm font-medium"> <article className="border-border/60 bg-background/80 overflow-hidden rounded-xl border shadow-sm">
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs"> <CollapsibleTrigger asChild>
{index} <button
</span> type="button"
<span>{round.title}</span> 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"
{round.isSearching && <Loader2Icon className="text-primary size-3.5 animate-spin" />} >
</div> <span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
{round.thought && ( {index}
<section className="border-border/50 bg-muted/30 mt-3 rounded-md border p-3"> </span>
<div className="text-muted-foreground mb-2 text-xs font-medium">思考内容</div> <span className="min-w-0 flex-1 truncate">{round.title}</span>
<MarkdownContent {round.isSearching ? (
content={round.thought} <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">
isLoading={isLoading} <Loader2Icon className="size-3 motion-reduce:animate-none animate-spin" />
rehypePlugins={rehypePlugins} 检索中
components={components} </span>
className="text-muted-foreground text-sm" ) : (
/> <span className="text-muted-foreground shrink-0 text-[11px]">已完成</span>
</section> )}
)} <ChevronDownIcon
{round.body && ( className={cn(
<section className="border-border/50 mt-3 rounded-md border p-3"> 'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div> !open && '-rotate-90',
<MarkdownContent )}
content={round.body} />
isLoading={isLoading} </button>
rehypePlugins={rehypePlugins} </CollapsibleTrigger>
components={components} <CollapsibleContent className="border-border/50 border-t px-3 pb-3">
className="text-sm" <div className="pt-3">
/> {round.thought && (
</section> <MultiRoundRagDetailSection
)} title="思考内容"
{round.query && ( icon={<SparklesIcon className="text-primary size-4" />}
<div className="border-primary/20 bg-primary/5 text-primary mt-2 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs">
<SearchIcon className="size-3 shrink-0" />
<span className="truncate">{round.query}</span>
</div>
)}
{round.retrievals.length > 0 && (
<section className="border-border/50 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">
检索内容 · {round.retrievals.length} 条
</div>
<div className="space-y-2">
{round.retrievals.map((item, resultIndex) => (
<details
key={`${item.id ?? item.title}-${resultIndex}`}
className="border-border/60 rounded-md border px-3 py-2"
open={resultIndex === 0}
> >
<summary className="cursor-pointer list-none text-sm font-medium"> <MarkdownContent
<span className="mr-2 text-xs text-muted-foreground">{resultIndex + 1}.</span> content={thoughtContent}
{item.title} isLoading={isLoading}
</summary> rehypePlugins={rehypePlugins}
<div className="text-muted-foreground mt-2 text-sm leading-6 whitespace-pre-wrap"> components={components}
{item.content || '暂无资料摘要'} className="text-muted-foreground text-sm"
</div> />
{(item.source || item.time) && ( </MultiRoundRagDetailSection>
<div className="text-muted-foreground mt-2 text-xs"> )}
{[item.source, item.time].filter(Boolean).join(' · ')} {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> </div>
)} )}
</details> </MultiRoundRagDetailSection>
))} )}
</div> </div>
</section> </CollapsibleContent>
)} </article>
</div> </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>
); );
} }
@@ -12,7 +12,10 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps"; import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
import { useDocumentRewritePresentation } from "@/core/document-rewrite/context"; import {
DOCUMENT_REWRITE_STARTED_EVENT,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
extractContentFromMessage, extractContentFromMessage,
@@ -89,6 +92,22 @@ function ScrollToBottomOnUserMessage({
} }
}, [messages, scrollToBottom]); }, [messages, scrollToBottom]);
useEffect(() => {
const handleDocumentRewriteStarted = (event: Event) => {
const detail = (event as CustomEvent<{ threadId?: string }>).detail;
if (detail?.threadId !== threadId) return;
// The presentation card is inserted by a context update in the same
// interaction. Two frames let React commit it before the smooth scroll.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
void scrollToBottom("smooth");
});
});
};
window.addEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
return () => window.removeEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
}, [scrollToBottom, threadId]);
return null; return null;
} }
@@ -112,9 +131,12 @@ export function MessageList({
onSetupWritingApprove, onSetupWritingApprove,
onSetupWritingReject, onSetupWritingReject,
onSetupWritingStart, onSetupWritingStart,
onSetupReportStructureApprove,
onSetupReportStructureReject,
researchApprovalSlot, researchApprovalSlot,
onOpenArtifact, onOpenArtifact,
onDownloadArtifact, onDownloadArtifact,
afterDocumentRewriteItems,
}: { }: {
className?: string; className?: string;
contentClassName?: string; contentClassName?: string;
@@ -137,6 +159,8 @@ export function MessageList({
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>; onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */ /** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>; onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupReportStructureApprove?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
onSetupReportStructureReject?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
/** /**
* 深度研究的交互卡插槽(报告配置、计划确认、失败重试):workbench 向 * 深度研究的交互卡插槽(报告配置、计划确认、失败重试):workbench 向
* displayThread 注入带 `additional_kwargs.setup_research_approval` 的虚拟 ai * displayThread 注入带 `additional_kwargs.setup_research_approval` 的虚拟 ai
@@ -147,6 +171,8 @@ export function MessageList({
onOpenArtifact?: (filepath: string) => void; onOpenArtifact?: (filepath: string) => void;
/** Host override for downloading virtual/session-owned files in a present-files card. */ /** Host override for downloading virtual/session-owned files in a present-files card. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>; onDownloadArtifact?: (filepath: string) => void | Promise<void>;
/** Optional host content placed immediately after document-rewrite timelines. */
afterDocumentRewriteItems?: React.ReactNode;
}) { }) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo( const rehypePlugins = useMemo(
@@ -322,6 +348,32 @@ export function MessageList({
return map; return map;
}, [thread.messages]); }, [thread.messages]);
const resolveDisplaySources = useCallback(
(msg: Message | undefined): RuntimeDisplaySource[] => {
if (!referencesEnabled || !msg || msg.type !== "ai") return [];
const batchSources =
referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
?.sources ?? [];
const frontendRagSources = msg.id
? frontendRagSourcesByAiId.get(msg.id) ?? []
: [];
if (batchSources.length > 0) return batchSources;
if (frontendRagSources.length > 0) return frontendRagSources;
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
});
},
[
frontendRagSourcesByAiId,
messages,
referenceBatches,
referencesEnabled,
skillDisplayMap,
],
);
// Track which clarification messages are still awaiting an answer. // Track which clarification messages are still awaiting an answer.
// Pending = clarification tool messages that appear after the last human message // Pending = clarification tool messages that appear after the last human message
// (no user reply has been sent for them yet). // (no user reply has been sent for them yet).
@@ -438,12 +490,21 @@ export function MessageList({
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} /> <ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationContent <ConversationContent
className={cn( className={cn(
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12", "mx-auto w-full max-w-(--container-width-lg)",
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
contentClassName, contentClassName,
)} )}
> >
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}> <MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => { {(() => {
const rewriteItemsAboveFile = documentRewriteItems.filter(
(item) => item.operation !== "generate",
);
const rewriteItemsBelowFile = documentRewriteItems.filter(
(item) => item.operation === "generate",
);
let didRenderRewriteAboveFile = false;
const grouped = groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") { if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => { return group.messages.map((msg) => {
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态, // 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
@@ -456,26 +517,8 @@ export function MessageList({
referencesEnabled && referencesEnabled &&
group.type === "assistant" && group.type === "assistant" &&
msg.type === "ai"; msg.type === "ai";
const batchSources = isAssistantMessage
? referenceBatches.find(
(batch) => batch.assistant_message_id === msg.id,
)?.sources ?? []
: [];
// 单轮/多轮 RAG 的参考资料来自隐藏的 tool 消息,优先于通用扫描。
const frontendRagSources =
isAssistantMessage && msg.id
? frontendRagSourcesByAiId.get(msg.id) ?? []
: [];
const displaySources = isAssistantMessage const displaySources = isAssistantMessage
? batchSources.length > 0 ? resolveDisplaySources(msg)
? batchSources
: frontendRagSources.length > 0
? frontendRagSources
: collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
})
: []; : [];
return ( return (
<MessageListItem <MessageListItem
@@ -582,6 +625,23 @@ export function MessageList({
/> />
</div> </div>
) : null; ) : null;
} else if (group.type === "assistant:setup-report-structure-approval") {
const message = group.messages[0];
return message ? (
<div key={group.id} className="w-full">
<SetupReportStructureApprovalCard
disabled={thread.isLoading}
message={message}
onApprove={onSetupReportStructureApprove}
onReject={onSetupReportStructureReject}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
messages={group.messages}
/>
</div>
) : null;
} else if (group.type === "assistant:research-approval") { } else if (group.type === "assistant:research-approval") {
// 深度研究交互卡(配置/计划确认/失败重试):宿主不传渲染函数时该 // 深度研究交互卡(配置/计划确认/失败重试):宿主不传渲染函数时该
// 分组不渲染。撰写步骤和完成摘要会经过普通的 processing/assistant 分组。 // 分组不渲染。撰写步骤和完成摘要会经过普通的 processing/assistant 分组。
@@ -599,8 +659,15 @@ export function MessageList({
files.push(...presentFiles); files.push(...presentFiles);
} }
} }
const rewriteAbove = !didRenderRewriteAboveFile
? rewriteItemsAboveFile
: [];
didRenderRewriteAboveFile = true;
return ( return (
<div className="w-full" key={group.id}> <div className="w-full" key={group.id}>
{rewriteAbove.map((item) => (
<DocumentRewriteTimeline key={`rewrite-above-${item.id}`} item={item} />
))}
{group.messages[0] && hasContent(group.messages[0]) && ( {group.messages[0] && hasContent(group.messages[0]) && (
<MarkdownContent <MarkdownContent
content={extractContentFromMessage(group.messages[0])} content={extractContentFromMessage(group.messages[0])}
@@ -682,6 +749,7 @@ export function MessageList({
key={"thinking-group-" + message.id} key={"thinking-group-" + message.id}
messages={[message]} messages={[message]}
isLoading={thread.isLoading} isLoading={thread.isLoading}
displaySources={resolveDisplaySources(message)}
/>, />,
); );
} }
@@ -725,11 +793,15 @@ export function MessageList({
message.type === "ai" && message.type === "ai" &&
(hasToolCalls(message) ? true : !hasContent(message)), (hasToolCalls(message) ? true : !hasContent(message)),
); );
const processingAi = [...group.messages]
.reverse()
.find((message) => message.type === "ai");
return ( return (
<div key={"group-" + group.id} className="w-full"> <div key={"group-" + group.id} className="w-full">
<MessageGroup <MessageGroup
messages={group.messages} messages={group.messages}
isLoading={thread.isLoading} isLoading={thread.isLoading}
displaySources={resolveDisplaySources(processingAi)}
/> />
<MessageTokenUsageList <MessageTokenUsageList
enabled={tokenUsageEnabled} enabled={tokenUsageEnabled}
@@ -738,10 +810,20 @@ export function MessageList({
/> />
</div> </div>
); );
})} });
{documentRewriteItems.map((item) => ( const leftoverRewriteItems = didRenderRewriteAboveFile
<DocumentRewriteTimeline key={item.id} item={item} /> ? rewriteItemsBelowFile
))} : documentRewriteItems;
return (
<>
{grouped}
{leftoverRewriteItems.map((item) => (
<DocumentRewriteTimeline key={item.id} item={item} />
))}
{afterDocumentRewriteItems}
</>
);
})()}
{thread.isLoading && <StreamingIndicator className="my-4" />} {thread.isLoading && <StreamingIndicator className="my-4" />}
<div style={{ height: `${paddingBottom}px` }} /> <div style={{ height: `${paddingBottom}px` }} />
</MessageMetricsProvider> </MessageMetricsProvider>
@@ -799,6 +881,15 @@ export type SetupWritingApprovalPayload = {
outline?: string; outline?: string;
}; };
export type SetupReportStructureApprovalPayload = {
title?: string;
name?: string;
content?: string;
structure_mode?: string;
retrieval_directions?: string[];
retrieval_skills?: string[];
};
function findPreviousHumanMessageId( function findPreviousHumanMessageId(
messages: Message[], messages: Message[],
beforeMessageId?: string, beforeMessageId?: string,
@@ -1169,6 +1260,147 @@ function SetupWritingApprovalCard({
); );
} }
function getSetupReportStructureApprovalPayload(
message: unknown,
): SetupReportStructureApprovalPayload | null {
if (typeof message !== "object" || message === null) {
return null;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
return null;
}
const approval = Reflect.get(additionalKwargs, "setup_report_structure_approval");
if (typeof approval !== "object" || approval === null) {
return null;
}
return approval as SetupReportStructureApprovalPayload;
}
const STRUCTURE_MODE_LABELS: Record<string, string> = {
fixed: "固定大纲",
adaptive: "弹性大纲",
};
function SetupReportStructureApprovalCard({
disabled,
message,
onApprove,
onReject,
}: {
disabled: boolean;
message: unknown;
onApprove?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
onReject?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
}) {
const payload = getSetupReportStructureApprovalPayload(message);
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(null);
if (!payload) {
return null;
}
const approvalPayload = payload;
const busy = disabled || submitting !== null;
const directions = approvalPayload.retrieval_directions ?? [];
const skills = approvalPayload.retrieval_skills ?? [];
async function run(action: "approve" | "reject") {
if (busy) {
return;
}
setSubmitting(action);
try {
if (action === "approve") {
await onApprove?.(approvalPayload);
} else {
await onReject?.(approvalPayload);
}
} finally {
setSubmitting(null);
}
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">报告结构建议</div>
<div className="text-sm font-medium">确认后将应用到右侧表单,再点保存入库。</div>
</div>
{approvalPayload.title ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">标题</div>
<p className="text-sm font-medium">{approvalPayload.title}</p>
</div>
) : null}
{approvalPayload.name ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">描述</div>
<p className="text-sm leading-6">{approvalPayload.name}</p>
</div>
) : null}
<div className="flex flex-wrap gap-x-6 gap-y-1.5">
{approvalPayload.structure_mode ? (
<div className="space-y-0.5">
<div className="text-muted-foreground text-xs">结构约束</div>
<p className="text-sm font-medium">
{STRUCTURE_MODE_LABELS[approvalPayload.structure_mode] ?? approvalPayload.structure_mode}
</p>
</div>
) : null}
{directions.length > 0 ? (
<div className="space-y-0.5">
<div className="text-muted-foreground text-xs">检索方向</div>
<p className="text-sm font-medium">{directions.join("、")}</p>
</div>
) : null}
{skills.length > 0 ? (
<div className="space-y-0.5">
<div className="text-muted-foreground text-xs">检索来源</div>
<p className="text-sm font-medium">{skills.join("、")}</p>
</div>
) : null}
</div>
{approvalPayload.content ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">报告大纲</div>
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
{approvalPayload.content}
</pre>
</div>
) : null}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={busy || !onReject}
onClick={() => void run("reject")}
>
<XIcon className="h-4 w-4" />
忽略
</Button>
<Button
type="button"
size="sm"
disabled={busy || !onApprove}
onClick={() => void run("approve")}
>
<CheckIcon className="h-4 w-4" />
应用到表单
</Button>
</div>
</div>
);
}
function getClarificationPayload(message: unknown): ClarificationPayload | null { function getClarificationPayload(message: unknown): ClarificationPayload | null {
if (typeof message !== "object" || message === null) { if (typeof message !== "object" || message === null) {
return null; return null;
@@ -66,6 +66,7 @@ import {
import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { import {
isBootstrapThread, isBootstrapThread,
isHiddenFromRecentChats,
isThreadFavorite, isThreadFavorite,
isThreadPinned, isThreadPinned,
listPathOfThread, listPathOfThread,
@@ -193,13 +194,9 @@ export function RecentChatList({
); );
// 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页); // 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页);
// 仅排除调度 / 会商类系统会话。 // 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
const visibleThreads = sortThreadsPinnedFirst( const visibleThreads = sortThreadsPinnedFirst(
threads.filter( threads.filter((thread) => !isHiddenFromRecentChats(thread)),
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable",
),
); );
if (visibleThreads.length === 0) { if (visibleThreads.length === 0) {
@@ -54,6 +54,7 @@ import type {
CompactShortcutRouteParam, CompactShortcutRouteParam,
} from "@/core/system-settings/api"; } from "@/core/system-settings/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { createClientId } from "@/lib/client-id";
import { import {
APP_THEME_COLORS, APP_THEME_COLORS,
LOGIN_SOURCE_OPTIONS, LOGIN_SOURCE_OPTIONS,
@@ -77,10 +78,7 @@ const languageOptions: { value: Locale; label: string }[] = [
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue"; const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue";
function makeShortcutId() { function makeShortcutId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) { return createClientId("shortcut");
return crypto.randomUUID();
}
return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`;
} }
function newShortcutParam(): CompactShortcutRouteParam { function newShortcutParam(): CompactShortcutRouteParam {
@@ -119,7 +117,7 @@ function normalizeShortcutDraft(
? button.route_params ? button.route_params
: [newShortcutParam()] : [newShortcutParam()]
).map((param) => ({ ).map((param) => ({
key: param.key || "", key: param.paramType === "path" ? "" : param.key || "",
paramType: param.paramType || "login", paramType: param.paramType || "login",
source: param.source || "access_token", source: param.source || "access_token",
customValue: param.customValue ?? "", customValue: param.customValue ?? "",
@@ -134,8 +132,18 @@ function cleanShortcutParams(
params: CompactShortcutRouteParam[], params: CompactShortcutRouteParam[],
): CompactShortcutRouteParam[] { ): CompactShortcutRouteParam[] {
return params return params
.filter((param) => param.key.trim()) .filter((param) => param.paramType === "path" || param.key.trim())
.map((param) => { .map((param) => {
if (param.paramType === "path") {
const source = param.source || "access_token";
return {
key: "",
paramType: "path" as const,
source,
customValue:
source === "other" ? (param.customValue ?? "").trim() : undefined,
};
}
const base: CompactShortcutRouteParam = { const base: CompactShortcutRouteParam = {
key: param.key.trim(), key: param.key.trim(),
paramType: param.paramType, paramType: param.paramType,
@@ -356,6 +364,13 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
return; return;
} }
for (const param of item.route_params) { for (const param of item.route_params) {
if (param.paramType === "path") {
if (param.source === "other" && !param.customValue?.trim()) {
toast.error("路径参数自定义值不可为空");
return;
}
continue;
}
if (!param.key.trim()) { if (!param.key.trim()) {
toast.error("路由参数键名不可为空"); toast.error("路由参数键名不可为空");
return; return;
@@ -550,7 +565,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
<Separator /> <Separator />
<SettingsSection <SettingsSection
title="顶部快捷按钮(管理员)" title="顶部快捷按钮(管理员)"
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停和与应用管理一致的登录/主题参数。" description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停,以及登录、主题和路径参数。"
> >
<CompactShortcutSettings <CompactShortcutSettings
buttons={shortcutDraft} buttons={shortcutDraft}
@@ -917,7 +932,7 @@ function CompactShortcutSettings({
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
已配置 {buttons.length} 个按钮;只有启用且填写了跳转链接的按钮会显示。 已配置 {buttons.length} 个按钮;支持追加登录、主题及路径参数。
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={onAdd}> <Button type="button" variant="outline" size="sm" onClick={onAdd}>
@@ -1014,7 +1029,7 @@ function CompactShortcutSettings({
</div> </div>
<div className="rounded-lg border bg-muted/20 p-3"> <div className="rounded-lg border bg-muted/20 p-3">
<div className="mb-2 flex items-center justify-between gap-2"> <div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-sm font-medium"> <div className="flex items-center gap-1.5 text-sm font-medium">
<ExternalLink className="size-4" /> <ExternalLink className="size-4" />
路由参数 路由参数
@@ -1030,13 +1045,13 @@ function CompactShortcutSettings({
</Button> </Button>
</div> </div>
{button.route_params.length === 0 ? ( {button.route_params.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed bg-background/70 p-4 text-center text-xs"> <div className="text-muted-foreground rounded-md border border-dashed bg-background/70 p-4 text-center text-xs">
不追加额外参数。 不追加额外参数。
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{button.route_params.map((param, paramIndex) => { {button.route_params.map((param, paramIndex) => {
const themeMap = const themeMap =
param.paramType === "theme" param.paramType === "theme"
? parseThemeMap(param.themeValue) ? parseThemeMap(param.themeValue)
@@ -1046,26 +1061,36 @@ function CompactShortcutSettings({
key={`${button.id}-${paramIndex}`} key={`${button.id}-${paramIndex}`}
className="space-y-2 rounded-md border bg-background p-2" className="space-y-2 rounded-md border bg-background p-2"
> >
<div className="grid gap-2 md:grid-cols-[minmax(120px,1fr)_130px_minmax(180px,1.4fr)_32px]"> <div
<Input className={cn(
value={param.key} "grid gap-2",
onChange={(event) => param.paramType === "path"
onPatchParam(button.id, paramIndex, { ? "md:grid-cols-[130px_minmax(180px,1fr)_32px]"
key: event.target.value, : "md:grid-cols-[minmax(120px,1fr)_130px_minmax(180px,1.4fr)_32px]",
}) )}
} >
placeholder="参数名 key" {param.paramType !== "path" && (
className="font-mono text-xs" <Input
/> value={param.key}
onChange={(event) =>
onPatchParam(button.id, paramIndex, {
key: event.target.value,
})
}
placeholder="参数名 key"
className="font-mono text-xs"
/>
)}
<Select <Select
value={param.paramType} value={param.paramType}
onValueChange={(value) => onValueChange={(value) =>
onPatchParam(button.id, paramIndex, { onPatchParam(button.id, paramIndex, {
paramType: value as "login" | "theme", paramType: value as "login" | "theme" | "path",
key: value === "path" ? "" : param.key,
source: source:
value === "login" value === "theme"
? param.source || "access_token" ? undefined
: undefined, : param.source || "access_token",
}) })
} }
> >
@@ -1075,9 +1100,10 @@ function CompactShortcutSettings({
<SelectContent> <SelectContent>
<SelectItem value="login">登录参数</SelectItem> <SelectItem value="login">登录参数</SelectItem>
<SelectItem value="theme">主题参数</SelectItem> <SelectItem value="theme">主题参数</SelectItem>
<SelectItem value="path">路径参数</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{param.paramType === "login" ? ( {(param.paramType === "login" || param.paramType === "path") ? (
<div className="flex min-w-0 gap-2"> <div className="flex min-w-0 gap-2">
<Select <Select
value={param.source || "access_token"} value={param.source || "access_token"}
@@ -1103,6 +1129,11 @@ function CompactShortcutSettings({
</Select> </Select>
{param.source === "other" && ( {param.source === "other" && (
<Input <Input
// This config value becomes a query parameter;
// it is not a login form field. A password input
// makes Chrome invoke password-manager heuristics
// whenever the settings dialog opens.
type="text"
value={param.customValue ?? ""} value={param.customValue ?? ""}
onChange={(event) => onChange={(event) =>
onPatchParam(button.id, paramIndex, { onPatchParam(button.id, paramIndex, {
@@ -1111,6 +1142,8 @@ function CompactShortcutSettings({
} }
placeholder="自定义值" placeholder="自定义值"
className="w-32 text-xs" className="w-32 text-xs"
autoComplete="off"
aria-label="自定义参数值"
/> />
)} )}
</div> </div>
@@ -1133,6 +1166,12 @@ function CompactShortcutSettings({
</Button> </Button>
</div> </div>
{param.paramType === "path" && (
<p className="text-muted-foreground text-xs">
不填写参数名,按添加顺序拼到链接或 Hash 路由的路径末尾。
</p>
)}
{param.paramType === "theme" && ( {param.paramType === "theme" && (
<div className="grid gap-2 rounded-md bg-muted/40 p-2 md:grid-cols-3"> <div className="grid gap-2 rounded-md bg-muted/40 p-2 md:grid-cols-3">
{APP_THEME_COLORS.map((color) => ( {APP_THEME_COLORS.map((color) => (
@@ -1166,9 +1205,9 @@ function CompactShortcutSettings({
)} )}
</div> </div>
); );
})} })}
</div> </div>
)} )}
</div> </div>
</div> </div>
))} ))}
@@ -43,6 +43,7 @@ import {
customThemeTextContrast, customThemeTextContrast,
type CustomThemeAdvancedKey, type CustomThemeAdvancedKey,
} from "@/core/theme/custom-theme"; } from "@/core/theme/custom-theme";
import { createClientId } from "@/lib/client-id";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const MAX_CUSTOM_THEMES = 12; const MAX_CUSTOM_THEMES = 12;
@@ -127,10 +128,7 @@ const ADVANCED_COLOR_FIELDS: Array<{
]; ];
function makeThemeId(): `custom-theme-${string}` { function makeThemeId(): `custom-theme-${string}` {
const suffix = const suffix = createClientId().replace(/[^a-z0-9-]/gi, "").toLowerCase();
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "").toLowerCase()
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
return `custom-theme-${suffix}`; return `custom-theme-${suffix}`;
} }
@@ -14,7 +14,7 @@ import {
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react"; import { BookOpen, BotIcon, BrainIcon, FileSearch, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle"; import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header"; import { SidebarCollapseButton } from "./workspace-header";
@@ -100,6 +100,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/deep-research", path: "/page/workspace/deep-research",
match: (pathname) => pathname.startsWith("/page/workspace/deep-research"), match: (pathname) => pathname.startsWith("/page/workspace/deep-research"),
}, },
// {
// id: "workspace-enterprise-research",
// label: "企业深度研究",
// icon: FileSearch,
// path: "/page/workspace/enterprise-research",
// match: (pathname) => pathname.startsWith("/page/workspace/enterprise-research"),
// },
{ {
id: "workspace-roundtable-planning", id: "workspace-roundtable-planning",
label: "多智能体规划", label: "多智能体规划",
@@ -7,9 +7,11 @@ import {
BriefcaseIcon, BriefcaseIcon,
CalendarClockIcon, CalendarClockIcon,
ChevronsUpDown, ChevronsUpDown,
CpuIcon,
DatabaseZapIcon, DatabaseZapIcon,
GaugeIcon, GaugeIcon,
HistoryIcon, HistoryIcon,
ListTreeIcon,
MessageCircleQuestionIcon, MessageCircleQuestionIcon,
MessageSquareTextIcon, MessageSquareTextIcon,
ScrollTextIcon, ScrollTextIcon,
@@ -227,6 +229,24 @@ export function WorkspaceNavMenu() {
技能管理 技能管理
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/models")}
>
<CpuIcon />
模型配置
</DropdownMenuItem>
)}
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口(管理员):
简洁模式没有全局侧边栏,这里放一份保证两种模式都能进菜单管理。 */}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/menu-management")}
>
<ListTreeIcon />
菜单管理
</DropdownMenuItem>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")} onClick={() => navigate("/page/workspace/admin/tags")}
> >
+16 -4
View File
@@ -5,7 +5,12 @@ import { useThread } from "@/components/workspace/messages/context";
import { isImageFile } from "../utils/files"; import { isImageFile } from "../utils/files";
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader"; import {
loadArtifactContent,
loadArtifactContentFromLatestWriteFile,
loadArtifactContentFromToolCall,
} from "./loader";
import { isArtifactApiPath } from "./streaming-write-preview";
import { updateArtifactContent } from "./api"; import { updateArtifactContent } from "./api";
function isWriteFileStreamingUrl(filepath: string): boolean { function isWriteFileStreamingUrl(filepath: string): boolean {
@@ -95,13 +100,19 @@ export function useArtifactContent({
} }
return null; return null;
}, [filepath, useWriteFileStreaming, thread]); }, [filepath, useWriteFileStreaming, thread]);
const toolCallFallbackContent = useMemo(() => {
if (useWriteFileStreaming || !isArtifactApiPath(filepath)) {
return undefined;
}
return loadArtifactContentFromLatestWriteFile({ filepath, thread });
}, [filepath, thread, useWriteFileStreaming]);
const { data, isLoading, error } = useQuery({ const { data, isLoading, error, refetch } = useQuery({
queryKey: ["artifact", filepath, threadId, isMock, publicShareCode], queryKey: ["artifact", filepath, threadId, isMock, publicShareCode],
queryFn: () => { queryFn: () => {
return loadArtifactContent({ filepath, threadId, isMock, publicShareCode }); return loadArtifactContent({ filepath, threadId, isMock, publicShareCode });
}, },
enabled, enabled: Boolean(enabled) && !useWriteFileStreaming && isArtifactApiPath(filepath),
refetchInterval: thread.isLoading ? 1000 : false, refetchInterval: thread.isLoading ? 1000 : false,
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction) // Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
@@ -120,11 +131,12 @@ export function useArtifactContent({
return { return {
content: useWriteFileStreaming content: useWriteFileStreaming
? content ? content
: (pendingEdit?.content ?? data?.content), : (pendingEdit?.content ?? data?.content ?? toolCallFallbackContent),
url: useWriteFileStreaming ? undefined : data?.url, url: useWriteFileStreaming ? undefined : data?.url,
isLoading, isLoading,
isApplyingEdit: pendingEdit?.kind === "editing", isApplyingEdit: pendingEdit?.kind === "editing",
error, error,
refetch,
}; };
} }
+53
View File
@@ -68,3 +68,56 @@ export function loadArtifactContentFromToolCall({
} }
} }
} }
function normalizeArtifactPath(path: string): string {
let normalized = path.trim();
const first = normalized[0];
const last = normalized.at(-1);
if (
normalized.length >= 2 &&
((first === '"' && last === '"') ||
(first === "'" && last === "'") ||
(first === "`" && last === "`"))
) {
normalized = normalized.slice(1, -1).trim();
}
return normalized.replace(/\\/g, "/").replace(/^\/+/, "");
}
/**
* Last-resort preview for an output whose artifact HTTP request is unavailable.
*
* A completed `write_file` can be selected before its backing sandbox file is
* visible through the artifacts endpoint (or when a deployment temporarily
* serves a stale mount). The original tool arguments still contain the
* Markdown, so use the newest matching write as a read-only display fallback.
*/
export function loadArtifactContentFromLatestWriteFile({
filepath,
thread,
}: {
filepath: string;
thread: BaseStream<AgentThreadState>;
}): string | undefined {
const normalizedFilepath = normalizeArtifactPath(filepath);
if (!normalizedFilepath) return undefined;
for (const message of [...thread.messages].reverse()) {
if (message.type !== "ai" || !message.tool_calls) continue;
for (const toolCall of [...message.tool_calls].reverse()) {
if (toolCall.name !== "write_file") continue;
if (!toolCall.args || typeof toolCall.args !== "object" || Array.isArray(toolCall.args)) {
continue;
}
const args = toolCall.args as { path?: unknown; content?: unknown };
if (
typeof args.path === "string" &&
typeof args.content === "string" &&
normalizeArtifactPath(args.path) === normalizedFilepath
) {
return args.content;
}
}
}
return undefined;
}
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isArtifactApiPath,
isProvisionalStreamingArtifactPath,
provisionalWriteFilePath,
} from "./streaming-write-preview";
test("provisionalWriteFilePath uses html only for html documents", () => {
assert.equal(provisionalWriteFilePath("# title"), "未命名文件.md");
assert.equal(provisionalWriteFilePath("<!DOCTYPE html><html>"), "未命名文件.html");
assert.equal(provisionalWriteFilePath("<html lang='zh'>"), "未命名文件.html");
});
test("isProvisionalStreamingArtifactPath matches the streaming placeholder names", () => {
assert.equal(isProvisionalStreamingArtifactPath("未命名文件.md"), true);
assert.equal(isProvisionalStreamingArtifactPath("/未命名文件.html"), true);
assert.equal(isProvisionalStreamingArtifactPath("mnt/user-data/outputs/report.md"), false);
});
test("isArtifactApiPath rejects write-file URLs and placeholder names", () => {
assert.equal(isArtifactApiPath("未命名文件.md"), false);
assert.equal(
isArtifactApiPath(
"write-file:未命名文件.md?message_id=m1&tool_call_id=t1&language=markdown",
),
false,
);
assert.equal(isArtifactApiPath("mnt/user-data/outputs/report.md"), true);
assert.equal(isArtifactApiPath("/mnt/user-data/outputs/report.md"), true);
});
@@ -23,6 +23,11 @@ export function hasCompleteArtifactExtension(path: string): boolean {
return /\.[a-z0-9]{2,12}$/i.test(filename); return /\.[a-z0-9]{2,12}$/i.test(filename);
} }
export const PROVISIONAL_WRITE_FILE_NAMES = new Set([
"未命名文件.md",
"未命名文件.html",
]);
export function provisionalWriteFilePath(content: string): string { export function provisionalWriteFilePath(content: string): string {
const head = content.slice(0, 256).trimStart().toLowerCase(); const head = content.slice(0, 256).trimStart().toLowerCase();
if (head.startsWith("<!doctype html") || head.startsWith("<html")) { if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
@@ -31,6 +36,29 @@ export function provisionalWriteFilePath(content: string): string {
return "未命名文件.md"; return "未命名文件.md";
} }
export function isProvisionalStreamingArtifactPath(path: string): boolean {
const pathname =
normalizeStreamingArtifactPath(path).split(/[?#]/, 1)[0] ?? "";
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
return PROVISIONAL_WRITE_FILE_NAMES.has(filename);
}
/** Paths the artifacts HTTP API can actually serve (sandbox virtual paths). */
export function isArtifactApiPath(path: string): boolean {
if (!path || path.startsWith("write-file:")) {
return false;
}
if (isProvisionalStreamingArtifactPath(path)) {
return false;
}
const normalized = path.replace(/^\/+/, "").replace(/\\/g, "/");
return (
normalized === "mnt/user-data" ||
normalized.startsWith("mnt/user-data/") ||
normalized.startsWith("mnt/skills/")
);
}
export function languageOfStreamingArtifact( export function languageOfStreamingArtifact(
path: string, path: string,
): StreamingArtifactLanguage | null { ): StreamingArtifactLanguage | null {
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
HOST_NAVIGATE_TYPE,
buildHostNavigateMessage,
isHostNavigatePath,
parseHostTaskId,
resolveHostNavigateOrigin,
} from "./host-navigate";
test("isHostNavigatePath accepts a host route", () => {
assert.equal(isHostNavigatePath("/task/item-aiceh/step-analysis-audience"), true);
assert.equal(isHostNavigatePath(" /task/foo "), true);
});
test("isHostNavigatePath rejects urls and protocol-relative paths", () => {
assert.equal(isHostNavigatePath(""), false);
assert.equal(isHostNavigatePath("https://localhost:4912/task"), false);
assert.equal(isHostNavigatePath("//evil.example/task"), false);
assert.equal(isHostNavigatePath("task/foo"), false);
assert.equal(isHostNavigatePath("/task\\foo"), false);
});
test("parseHostTaskId returns a number", () => {
assert.equal(parseHostTaskId("108"), 108);
assert.equal(parseHostTaskId(" 108 "), 108);
assert.equal(parseHostTaskId("0108"), 108);
});
test("parseHostTaskId rejects non-integers", () => {
assert.equal(parseHostTaskId(""), null);
assert.equal(parseHostTaskId("abc"), null);
assert.equal(parseHostTaskId("108.5"), null);
assert.equal(parseHostTaskId("12e3"), null);
assert.equal(parseHostTaskId(null), null);
});
test("resolveHostNavigateOrigin prefers configured origin", () => {
assert.equal(
resolveHostNavigateOrigin({
configured: "https://localhost:4912",
ancestorOrigin: "https://other.example",
referrer: "https://referrer.example/page",
selfOrigin: "http://localhost:5174",
}),
"https://localhost:4912",
);
});
test("resolveHostNavigateOrigin strips a path from configured origin", () => {
assert.equal(
resolveHostNavigateOrigin({ configured: "https://localhost:4912/app" }),
"https://localhost:4912",
);
});
test("resolveHostNavigateOrigin falls back to ancestorOrigins then referrer", () => {
assert.equal(
resolveHostNavigateOrigin({
ancestorOrigin: "https://host.example",
referrer: "https://referrer.example/",
selfOrigin: "http://localhost:5174",
}),
"https://host.example",
);
assert.equal(
resolveHostNavigateOrigin({
referrer: "https://referrer.example/page",
selfOrigin: "http://localhost:5174",
}),
"https://referrer.example",
);
});
test("resolveHostNavigateOrigin never uses * or same-origin referrer", () => {
assert.equal(resolveHostNavigateOrigin({ configured: "*" }), null);
assert.equal(
resolveHostNavigateOrigin({
referrer: "http://localhost:5174/page/foo",
selfOrigin: "http://localhost:5174",
}),
null,
);
assert.equal(resolveHostNavigateOrigin({}), null);
});
test("buildHostNavigateMessage matches the host contract", () => {
assert.deepEqual(
buildHostNavigateMessage("/task/item-aiceh/step-analysis-audience", { id: 108 }),
{
type: HOST_NAVIGATE_TYPE,
path: "/task/item-aiceh/step-analysis-audience",
queryParams: { id: 108 },
},
);
});
@@ -0,0 +1,95 @@
/**
* iframe 嵌入宿主系统时,任务跳转按钮通过 ``window.parent.postMessage`` 让
* **父页面**改路由(宿主协议 ``HOST_NAVIGATE``)。不改 iframe 自己的地址。
*
* 消息形状对齐宿主约定:
* ``{ type: 'HOST_NAVIGATE', path, queryParams: { id: number } }``
* 第二参必须是宿主 origin,禁止 ``'*'``。
*/
export const HOST_NAVIGATE_TYPE = "HOST_NAVIGATE";
export interface HostNavigateMessage {
type: typeof HOST_NAVIGATE_TYPE;
path: string;
queryParams: Record<string, number>;
}
/** 宿主路由:以单个 ``/`` 开头的路径,不含 ``//`` / 反斜杠。 */
export function isHostNavigatePath(path: string): boolean {
const value = path.trim();
if (!value || value.length > 2048) return false;
if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return false;
return true;
}
/** 任务 id 转数字。只接受整数字符串(可带正负号),否则返回 null。 */
export function parseHostTaskId(raw: string | null | undefined): number | null {
const value = (raw ?? "").trim();
if (!value || !/^-?\d+$/.test(value)) return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function normalizeOrigin(value: string | null | undefined): string | null {
const raw = (value ?? "").trim();
if (!raw || raw === "*") return null;
try {
const url = new URL(raw);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return url.origin;
} catch {
return null;
}
}
/**
* 解析 postMessage 的 targetOrigin。
* 优先级:显式配置 → ``ancestorOrigins[0]`` → 跨源 ``document.referrer``。
* 解析失败返回 null(调用方应中止,不得退回 ``'*'``)。
*/
export function resolveHostNavigateOrigin(input: {
configured?: string | null;
ancestorOrigin?: string | null;
referrer?: string | null;
selfOrigin?: string | null;
}): string | null {
const configured = normalizeOrigin(input.configured);
if (configured) return configured;
const ancestor = normalizeOrigin(input.ancestorOrigin);
if (ancestor) return ancestor;
const referrer = normalizeOrigin(input.referrer);
if (!referrer) return null;
const selfOrigin = normalizeOrigin(input.selfOrigin);
if (selfOrigin && referrer === selfOrigin) return null;
return referrer;
}
export function readAncestorOrigin(): string | null {
if (typeof window === "undefined") return null;
try {
return window.location.ancestorOrigins?.[0] ?? null;
} catch {
return null;
}
}
export function isEmbeddedInParent(): boolean {
if (typeof window === "undefined") return false;
try {
return window.parent !== window;
} catch {
return true;
}
}
export function buildHostNavigateMessage(
path: string,
queryParams: Record<string, number> = {},
): HostNavigateMessage {
return {
type: HOST_NAVIGATE_TYPE,
path,
queryParams,
};
}
+51 -5
View File
@@ -7,9 +7,11 @@
* 任务深链配置读出 rwfx 的 4 个历史按钮,3qfx/xdfx 为空)——这样真实跳转地址通过实时 * 任务深链配置读出 rwfx 的 4 个历史按钮,3qfx/xdfx 为空)——这样真实跳转地址通过实时
* config 进种子,避免写死;一旦保存过,后端即唯一数据源。 * config 进种子,避免写死;一旦保存过,后端即唯一数据源。
* *
* 按钮两类(``linkType``):``url``(外链 ``window.open``;**或应用内路由**——target 以 ``/`` 开头 * 按钮类型(``linkType``):``url``(外链 ``window.open``;**或应用内路由**——target 以 ``/`` 开头
* 时按内部路由处理:``self`` 走 react-router 同窗导航、``blank`` 拼 hash 路由新标签;可选追加 ``?{idParam}=id``)/ * 时按内部路由处理:``self`` 走 react-router 同窗导航、``blank`` 拼 hash 路由新标签;可选追加 ``?{idParam}=id``)/
* ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)。 * ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)/
* ``purpose`` 弹出任务目的树 / ``host`` iframe 内向父页面 ``postMessage({ type:'HOST_NAVIGATE' })``
* 让宿主系统跳转(见 [[host-navigate]])。
*/ */
import { import {
@@ -25,6 +27,15 @@ import {
readLoginToken, readLoginToken,
readLoginUsername, readLoginUsername,
} from "@/core/auth/login-credentials"; } from "@/core/auth/login-credentials";
import {
buildHostNavigateMessage,
isEmbeddedInParent,
isHostNavigatePath,
parseHostTaskId,
readAncestorOrigin,
resolveHostNavigateOrigin,
} from "@/core/auth/host-navigate";
import { readRuntimeEnv } from "@/env";
/** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */ /** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */
export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent"; export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent";
@@ -62,8 +73,8 @@ export const TASK_BUSINESSES: {
}, },
]; ];
/** ``url``=外链 / ``business``=切换业务 / ``purpose``=弹出任务目的树表格(无需 target)。 */ /** ``url``=外链 / ``business``=切换业务 / ``purpose``=弹出任务目的树表格 / ``host``=宿主路由(postMessage)。 */
export type TaskButtonLinkType = "url" | "business" | "purpose"; export type TaskButtonLinkType = "url" | "business" | "purpose" | "host";
/** 追加的 id 取哪种:``task``=任务id(默认)/ ``action``=行动id(仅 xdfx 二者不同)。 */ /** 追加的 id 取哪种:``task``=任务id(默认)/ ``action``=行动id(仅 xdfx 二者不同)。 */
export type TaskButtonIdKind = "task" | "action"; export type TaskButtonIdKind = "task" | "action";
@@ -91,7 +102,7 @@ export interface TaskButton {
business: TaskBusiness; business: TaskBusiness;
label: string; label: string;
linkType: TaskButtonLinkType; linkType: TaskButtonLinkType;
/** ``url``=完整地址;``business``=目标业务码(如 ``3qfx``)。 */ /** ``url``=完整地址;``business``=目标业务码(如 ``3qfx``);``host``=宿主系统路由(如 ``/task/...``)。 */
target: string; target: string;
/** ``url`` 类型是否追加 id 查询参数。 */ /** ``url`` 类型是否追加 id 查询参数。 */
appendTaskId: boolean; appendTaskId: boolean;
@@ -316,11 +327,46 @@ export interface RunTaskButtonCtx {
* 执行一个跳转按钮: * 执行一个跳转按钮:
* - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。 * - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。
* - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。 * - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。
* - ``host``:向 ``window.parent`` 发 ``HOST_NAVIGATE``,由宿主系统跳转(仅 iframe 嵌入时生效)。
*/ */
export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise<void> { export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise<void> {
const fail = (m: string) => ctx.onError?.(m); const fail = (m: string) => ctx.onError?.(m);
// purpose(目的树弹框)由组件层处理(需打开 React 弹框),此处不动作。 // purpose(目的树弹框)由组件层处理(需打开 React 弹框),此处不动作。
if (btn.linkType === "purpose") return; if (btn.linkType === "purpose") return;
if (btn.linkType === "host") {
const path = btn.target.trim();
if (!isHostNavigatePath(path)) {
fail(`未配置「${btn.label}」的宿主路由`);
return;
}
if (!isEmbeddedInParent()) {
fail("当前不在嵌入页,无法跳转宿主系统");
return;
}
const origin = resolveHostNavigateOrigin({
configured: readRuntimeEnv("VITE_HOST_NAVIGATE_ORIGIN"),
ancestorOrigin: readAncestorOrigin(),
referrer: typeof document !== "undefined" ? document.referrer : null,
selfOrigin: typeof window !== "undefined" ? window.location.origin : null,
});
if (!origin) {
fail("未配置宿主地址(VITE_HOST_NAVIGATE_ORIGIN),无法安全跳转");
return;
}
const queryParams: Record<string, number> = {};
if (btn.appendTaskId) {
const id = parseHostTaskId(ctx.taskId);
if (id === null) {
fail("任务id不是数字,无法跳转宿主系统");
return;
}
queryParams[(btn.idParam || "id").trim() || "id"] = id;
}
const message = buildHostNavigateMessage(path, queryParams);
console.info(`[task-button] 「${btn.label}」宿主路由 →`, message, origin);
window.parent.postMessage(message, origin);
return;
}
if (btn.linkType === "url") { if (btn.linkType === "url") {
const base = btn.target.trim(); const base = btn.target.trim();
if (!base) { if (!base) {
+41 -16
View File
@@ -95,25 +95,41 @@ function mockCopTaskDetail(taskId: string): CopTaskDetail {
}; };
} }
/** 拉取任务详情;``VITE_MOCK_COP_TASK_DETAIL=true`` 走假数据;缺少 consumer 地址 / 返回不合法时返回 null。 */ /** 详情接口不可用时仍能继续深链跳转:至少带上任务 id,标题/简介可空。 */
export function stubCopTaskDetail(taskId: string): CopTaskDetail {
return {
id: taskId,
overview: "",
taskDirection: "",
taskName: "",
taskContent: "",
};
}
/** 拉取任务详情;``VITE_MOCK_COP_TASK_DETAIL=true`` 走假数据;缺少 consumer 地址 / 返回不合法 / 网络失败时返回 null。 */
export async function fetchCopTaskDetail(taskId: string): Promise<CopTaskDetail | null> { export async function fetchCopTaskDetail(taskId: string): Promise<CopTaskDetail | null> {
if (copTaskDetailMockEnabled()) { if (copTaskDetailMockEnabled()) {
return mockCopTaskDetail(taskId); return mockCopTaskDetail(taskId);
} }
const base = consumerBaseUrl(); const base = consumerBaseUrl();
if (!base || !taskId) return null; if (!base || !taskId) return null;
// service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。 try {
const body = await requestCrudService.get(`${base}/taskAnalyseSearch/cop-task-detail`, { taskId }); // service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null; const body = await requestCrudService.get(`${base}/taskAnalyseSearch/cop-task-detail`, { taskId });
if (!data || typeof data !== "object") return null; const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
const d = data as Record<string, unknown>; if (!data || typeof data !== "object") return null;
return { const d = data as Record<string, unknown>;
id: (d.id as string | number | null) ?? null, return {
overview: typeof d.overview === "string" ? d.overview : "", id: (d.id as string | number | null) ?? null,
taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "", overview: typeof d.overview === "string" ? d.overview : "",
taskName: typeof d.taskName === "string" ? d.taskName : "", taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "",
taskContent: typeof d.taskContent === "string" ? d.taskContent : "", taskName: typeof d.taskName === "string" ? d.taskName : "",
}; taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
};
} catch (error) {
console.warn("[task-deeplink] cop-task-detail failed", { taskId, error });
return null;
}
} }
/** 把任务详情整包暂存到 sessionStorage(隐私模式/quota 异常时静默失败)。 */ /** 把任务详情整包暂存到 sessionStorage(隐私模式/quota 异常时静默失败)。 */
@@ -223,7 +239,12 @@ export async function fetchTaskIdFromAction(actionId: string): Promise<string> {
export async function resolveDeeplinkTaskId(goPath: string, rawId: string): Promise<string> { export async function resolveDeeplinkTaskId(goPath: string, rawId: string): Promise<string> {
const parsed = parseGoPath(goPath); const parsed = parseGoPath(goPath);
if (parsed?.businessCode === XDFX_BUSINESS_CODE) { if (parsed?.businessCode === XDFX_BUSINESS_CODE) {
return fetchTaskIdFromAction(rawId); try {
return (await fetchTaskIdFromAction(rawId)) || "";
} catch (error) {
console.warn("[task-deeplink] action→taskId resolve failed", { rawId, error });
return "";
}
} }
return rawId; return rawId;
} }
@@ -332,10 +353,14 @@ export async function resolveTaskDeeplinkTarget(
try { try {
const mappings = await listBusinessMappings(); const mappings = await listBusinessMappings();
agentId = mappings.find((m) => m.businessCode === parsed.businessCode)?.agentId ?? null; agentId = mappings.find((m) => m.businessCode === parsed.businessCode)?.agentId ?? null;
} catch { } catch (error) {
console.warn("[task-deeplink] business mapping failed", error);
agentId = null; agentId = null;
} }
if (!agentId) return null; if (!agentId) {
console.warn("[task-deeplink] no agent mapped for", parsed.businessCode);
return null;
}
// 单智能体问答统一走「任务工作区」(任务↔对话 1:N,按 taskId + agentId 隔离不同业务的对话列表): // 单智能体问答统一走「任务工作区」(任务↔对话 1:N,按 taskId + agentId 隔离不同业务的对话列表):
// 暂存任务上下文(taskId + agentId + 开场信息),跳到 chats 索引,由工作区按 taskId 决定 // 暂存任务上下文(taskId + agentId + 开场信息),跳到 chats 索引,由工作区按 taskId 决定
@@ -6,10 +6,13 @@ import type {
DocumentRewriteStage, DocumentRewriteStage,
} from "@/open-canvas/hooks/useDocumentRewrite"; } from "@/open-canvas/hooks/useDocumentRewrite";
import type { DocumentRewriteSummary } from "@/open-canvas/api/rewrite"; import type { DocumentRewriteSummary } from "@/open-canvas/api/rewrite";
import { createClientId } from "@/lib/client-id";
export type DocumentRewritePresentation = { export type DocumentRewritePresentation = {
id: string; id: string;
threadId: string; threadId: string;
/** A report regeneration is a new document, not an edit of the current one. */
operation: "rewrite" | "generate";
source: { source: {
kind: "artifact" | "deep_research_report"; kind: "artifact" | "deep_research_report";
id: string; id: string;
@@ -19,17 +22,27 @@ export type DocumentRewritePresentation = {
instruction: string; instruction: string;
style: string; style: string;
modelName: string | null; modelName: string | null;
generateImages: boolean;
maxGeneratedImages: number;
generatedImageCount: number;
illustrationTarget: string;
stage: DocumentRewriteStage; stage: DocumentRewriteStage;
originalCharCount: number; originalCharCount: number;
writtenCharCount: number; writtenCharCount: number;
/** Short model-generated plan, streamed while it understands the request. */ /** Short model-generated plan, streamed while it understands the request. */
requirementsPlan: string; requirementsPlan: string;
/** Ephemeral thought trace. Never included in the Markdown draft or persisted job state. */
thinkingText: string;
warnings: string[]; warnings: string[];
summary: DocumentRewriteSummary | null; summary: DocumentRewriteSummary | null;
/** Kept in-memory for the line-level compare dialog; backend job hydration refills it. */ /** Kept in-memory for the line-level compare dialog; backend job hydration refills it. */
originalContent: string; originalContent: string;
rewrittenContent: string | null; rewrittenContent: string | null;
versionId: string | null; versionId: string | null;
/** Hash of the currently committed rewrite, used by the local undo fallback. */
committedHash: string | null;
/** A browser-only undo backup is available because the server snapshot failed. */
localFallbackAvailable: boolean;
/** Inverse snapshot created by a safe undo; enables a one-click reapply. */ /** Inverse snapshot created by a safe undo; enables a one-click reapply. */
redoVersionId: string | null; redoVersionId: string | null;
revertedAt: string | null; revertedAt: string | null;
@@ -42,23 +55,36 @@ type CreatePresentationInput = Omit<
DocumentRewritePresentation, DocumentRewritePresentation,
| "id" | "id"
| "stage" | "stage"
| "operation"
| "generateImages"
| "maxGeneratedImages"
| "generatedImageCount"
| "illustrationTarget"
| "originalCharCount" | "originalCharCount"
| "writtenCharCount" | "writtenCharCount"
| "requirementsPlan" | "requirementsPlan"
| "thinkingText"
| "warnings" | "warnings"
| "summary" | "summary"
| "originalContent" | "originalContent"
| "rewrittenContent" | "rewrittenContent"
| "versionId" | "versionId"
| "committedHash"
| "localFallbackAvailable"
| "redoVersionId" | "redoVersionId"
| "revertedAt" | "revertedAt"
| "error" | "error"
| "createdAt" | "createdAt"
| "updatedAt" | "updatedAt"
> & { > & {
operation?: DocumentRewritePresentation["operation"];
originalContent?: string; originalContent?: string;
rewrittenContent?: string | null; rewrittenContent?: string | null;
committedHash?: string | null;
localFallbackAvailable?: boolean;
requirementsPlan?: string; requirementsPlan?: string;
generateImages?: boolean;
maxGeneratedImages?: number;
}; };
type DocumentRewritePresentationContextValue = { type DocumentRewritePresentationContextValue = {
@@ -74,10 +100,7 @@ const DocumentRewritePresentationContext =
createContext<DocumentRewritePresentationContextValue | null>(null); createContext<DocumentRewritePresentationContextValue | null>(null);
function createId() { function createId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return createClientId("drw-ui");
return "drw-ui-" + crypto.randomUUID();
}
return "drw-ui-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
} }
export function DocumentRewritePresentationProvider({ export function DocumentRewritePresentationProvider({
@@ -96,14 +119,22 @@ export function DocumentRewritePresentationProvider({
...input, ...input,
id, id,
stage: "snapshot", stage: "snapshot",
operation: input.operation ?? "rewrite",
generateImages: input.generateImages === true,
maxGeneratedImages: Math.min(Math.max(input.maxGeneratedImages ?? 2, 1), 4),
generatedImageCount: 0,
illustrationTarget: "",
originalCharCount: 0, originalCharCount: 0,
writtenCharCount: 0, writtenCharCount: 0,
requirementsPlan: input.requirementsPlan ?? "", requirementsPlan: input.requirementsPlan ?? "",
thinkingText: "",
warnings: [], warnings: [],
summary: null, summary: null,
originalContent: input.originalContent ?? "", originalContent: input.originalContent ?? "",
rewrittenContent: input.rewrittenContent ?? null, rewrittenContent: input.rewrittenContent ?? null,
versionId: null, versionId: null,
committedHash: input.committedHash ?? null,
localFallbackAvailable: input.localFallbackAvailable === true,
redoVersionId: null, redoVersionId: null,
revertedAt: null, revertedAt: null,
error: null, error: null,
@@ -164,3 +195,12 @@ export function useDocumentRewritePresentation() {
/** Browser event consumed by the open sandbox after an in-feed undo succeeds. */ /** Browser event consumed by the open sandbox after an in-feed undo succeeds. */
export const DOCUMENT_REWRITE_UNDONE_EVENT = "deerflow:document-rewrite-undone"; export const DOCUMENT_REWRITE_UNDONE_EVENT = "deerflow:document-rewrite-undone";
/** Synchronous guard used before a browser-only undo writes back the original text. */
export const DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT = "deerflow:document-rewrite-local-undo-guard";
/** Sent after the user confirms a rewrite so the conversation can reveal its new step card. */
export const DOCUMENT_REWRITE_STARTED_EVENT = "deerflow:document-rewrite-started";
/** A completed structural-regeneration card requests an in-place rewrite of that new report. */
export const DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT = "deerflow:deep-research-report-rewrite-requested";
@@ -0,0 +1,133 @@
import { getAccountIdentityKey } from "@/core/auth";
import { authStorage } from "@/core/auth/scoped-storage";
const STORAGE_PREFIX = "deerflow.document-rewrite.local-fallback:v1";
// Keep the browser fallback comfortably below the typical 5 MB storage quota.
const MAX_BACKUP_CHARACTERS = 1_000_000;
type PendingArtifactRewriteFallback = {
threadId: string;
path: string;
originalContent: string;
committedHash: string | null;
createdAt: string;
};
export type ArtifactRewriteFallback = PendingArtifactRewriteFallback & {
committedHash: string;
};
function storageKey(threadId: string, path: string) {
const account = getAccountIdentityKey() ?? "anonymous";
return [STORAGE_PREFIX, account, threadId, path]
.map((part) => encodeURIComponent(part))
.join(":");
}
function getStorage(): Storage | null {
if (typeof window === "undefined") return null;
return authStorage();
}
function canPersist(originalContent: string): boolean {
if (originalContent.length <= MAX_BACKUP_CHARACTERS) return true;
console.error("AI 全文改写的本地撤销备份过大,未写入浏览器存储。");
return false;
}
/**
* Save the original text before the durable rewrite starts. It is completed
* with the rewritten file hash only if the server-side undo snapshot fails.
*/
export function primeArtifactRewriteFallback({
threadId,
path,
originalContent,
}: Omit<PendingArtifactRewriteFallback, "committedHash" | "createdAt">): void {
if (!canPersist(originalContent)) return;
try {
getStorage()?.setItem(
storageKey(threadId, path),
JSON.stringify({
threadId,
path,
originalContent,
committedHash: null,
createdAt: new Date().toISOString(),
} satisfies PendingArtifactRewriteFallback),
);
} catch (error) {
console.error("保存 AI 全文改写的本地撤销备份失败:", error);
}
}
/** Mark a primed backup as safe to use for the server snapshot failure path. */
export function completeArtifactRewriteFallback({
threadId,
path,
originalContent,
committedHash,
}: Omit<ArtifactRewriteFallback, "createdAt">): boolean {
if (!committedHash || !canPersist(originalContent)) return false;
try {
const storage = getStorage();
if (!storage) return false;
storage.setItem(
storageKey(threadId, path),
JSON.stringify({
threadId,
path,
originalContent,
committedHash,
createdAt: new Date().toISOString(),
} satisfies ArtifactRewriteFallback),
);
return true;
} catch (error) {
console.error("完成 AI 全文改写的本地撤销备份失败:", error);
return false;
}
}
export function loadArtifactRewriteFallback({
threadId,
path,
committedHash,
}: Pick<ArtifactRewriteFallback, "threadId" | "path" | "committedHash">): ArtifactRewriteFallback | null {
try {
const raw = getStorage()?.getItem(storageKey(threadId, path));
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const fallback = parsed as Partial<ArtifactRewriteFallback>;
if (
fallback.threadId !== threadId ||
fallback.path !== path ||
fallback.committedHash !== committedHash ||
typeof fallback.originalContent !== "string"
) {
return null;
}
return fallback as ArtifactRewriteFallback;
} catch (error) {
console.error("读取 AI 全文改写的本地撤销备份失败:", error);
return null;
}
}
export function clearArtifactRewriteFallback(threadId: string, path: string): void {
try {
getStorage()?.removeItem(storageKey(threadId, path));
} catch (error) {
console.error("清理 AI 全文改写的本地撤销备份失败:", error);
}
}
export async function sha256DocumentContent(content: string): Promise<string | null> {
if (!globalThis.crypto?.subtle) return null;
const digest = await globalThis.crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(content),
);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
+37
View File
@@ -22,6 +22,8 @@ interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistan
interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {} interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {}
interface AssistantSetupReportStructureApprovalGroup extends GenericMessageGroup<"assistant:setup-report-structure-approval"> {}
interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant:research-approval"> {} interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant:research-approval"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {} interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
@@ -34,6 +36,7 @@ type MessageGroup =
| AssistantClarificationGroup | AssistantClarificationGroup
| AssistantSetupAgentApprovalGroup | AssistantSetupAgentApprovalGroup
| AssistantSetupWritingApprovalGroup | AssistantSetupWritingApprovalGroup
| AssistantSetupReportStructureApprovalGroup
| AssistantResearchApprovalGroup | AssistantResearchApprovalGroup
| AssistantSubagentGroup; | AssistantSubagentGroup;
@@ -58,6 +61,7 @@ export function groupMessages<T>(
last.type !== "assistant:clarification" && last.type !== "assistant:clarification" &&
last.type !== "assistant:setup-agent-approval" && last.type !== "assistant:setup-agent-approval" &&
last.type !== "assistant:setup-writing-approval" && last.type !== "assistant:setup-writing-approval" &&
last.type !== "assistant:setup-report-structure-approval" &&
last.type !== "assistant:research-approval" last.type !== "assistant:research-approval"
) { ) {
return last; return last;
@@ -103,6 +107,13 @@ export function groupMessages<T>(
type: "assistant:setup-writing-approval", type: "assistant:setup-writing-approval",
messages: [message], messages: [message],
}); });
} else if (isSetupReportStructureApprovalToolMessage(message)) {
lastOpenGroup()?.messages.push(message);
groups.push({
id: message.id,
type: "assistant:setup-report-structure-approval",
messages: [message],
});
} else { } else {
const open = lastOpenGroup(); const open = lastOpenGroup();
if (open) { if (open) {
@@ -136,6 +147,15 @@ export function groupMessages<T>(
type: "assistant:present-files", type: "assistant:present-files",
messages: [message], messages: [message],
}); });
} else if (hasOnlyDeepResearchProgress(message)) {
// Each deep-research writing bar is its own group. Merging consecutive
// processing messages would pin a new report's in-progress steps onto
// the parent bar, which sits above the original file card.
groups.push({
id: message.id,
type: "assistant:processing",
messages: [message],
});
} else if (hasSubagent(message)) { } else if (hasSubagent(message)) {
groups.push({ groups.push({
id: message.id, id: message.id,
@@ -380,6 +400,14 @@ export function hasPresentFiles(message: Message) {
); );
} }
export function hasOnlyDeepResearchProgress(message: Message) {
return (
message.type === "ai" &&
Boolean(message.tool_calls?.length) &&
message.tool_calls!.every((toolCall) => toolCall.name === "deep_research_progress")
);
}
export function isClarificationToolMessage(message: Message) { export function isClarificationToolMessage(message: Message) {
return message.type === "tool" && message.name === "ask_clarification"; return message.type === "tool" && message.name === "ask_clarification";
} }
@@ -402,6 +430,15 @@ export function isSetupWritingApprovalToolMessage(message: Message) {
); );
} }
export function isSetupReportStructureApprovalToolMessage(message: Message) {
return (
message.type === "tool" &&
message.name === "setup_report_structure" &&
typeof message.additional_kwargs?.setup_report_structure_approval === "object" &&
message.additional_kwargs.setup_report_structure_approval !== null
);
}
export function hasResearchApproval(message: Message) { export function hasResearchApproval(message: Message) {
return ( return (
message.type === "ai" && message.type === "ai" &&
@@ -103,6 +103,16 @@ export async function deleteManagedModel(name: string): Promise<void> {
if (!response.ok) return readError(response, "删除模型失败"); if (!response.ok) return readError(response, "删除模型失败");
} }
export async function reorderManagedModels(names: string[]): Promise<ManagedModel[]> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/order`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ names }),
});
if (!response.ok) return readError(response, "调整模型顺序失败");
return response.json() as Promise<ManagedModel[]>;
}
export async function testManagedModel(model: ModelSavePayload): Promise<ModelConnectionTestResult> { export async function testManagedModel(model: ModelSavePayload): Promise<ModelConnectionTestResult> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/test`, { const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/test`, {
method: "POST", method: "POST",
@@ -4,6 +4,7 @@ import {
createManagedModel, createManagedModel,
deleteManagedModel, deleteManagedModel,
loadManagedModels, loadManagedModels,
reorderManagedModels,
testManagedModel, testManagedModel,
updateManagedModel, updateManagedModel,
type ModelSavePayload, type ModelSavePayload,
@@ -50,3 +51,16 @@ export function useDeleteManagedModel() {
}, },
}); });
} }
export function useReorderManagedModels() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: reorderManagedModels,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: MANAGED_MODELS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ["models"] }),
]);
},
});
}
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyCompactMenuOverrides, isDefaultCompactMenuType, normalizeCompactMenuType } from "./compact-menu-overrides";
import type { MenuItem } from "./sidebar-menu";
const I = (() => null) as unknown as MenuItem["icon"];
test("normalizeCompactMenuType defaults to A", () => {
assert.equal(normalizeCompactMenuType(undefined), "A");
assert.equal(normalizeCompactMenuType(""), "A");
assert.equal(normalizeCompactMenuType("default"), "A");
assert.equal(normalizeCompactMenuType("b"), "B");
assert.equal(normalizeCompactMenuType("2"), "B");
assert.equal(normalizeCompactMenuType("alt"), "B");
assert.equal(isDefaultCompactMenuType(undefined), true);
assert.equal(isDefaultCompactMenuType(""), true);
assert.equal(isDefaultCompactMenuType("B"), false);
});
test("applyCompactMenuOverrides splits sidebar vs settings", () => {
const items: MenuItem[] = [
{ id: "a", label: "对话", icon: I, path: "/a" },
{ id: "b", label: "智能体", icon: I, path: "/b" },
];
const applied = applyCompactMenuOverrides(items, [
{ nodeId: "b", placement: "settings", sortOrder: 0, label: "智能体设置" },
]);
assert.deepEqual(
applied.sidebarItems.map((item) => item.id),
["a"],
);
assert.equal(applied.settingsItems[0]?.id, "b");
assert.equal(applied.settingsItems[0]?.label, "智能体设置");
});
@@ -1,7 +1,41 @@
import type { MenuItem } from "./sidebar-menu"; import type { MenuItem } from "./sidebar-menu";
import { readRuntimeEnv } from "@/env";
export type CompactMenuPlacement = "sidebar" | "settings"; export type CompactMenuPlacement = "sidebar" | "settings";
export const COMPACT_MENU_TYPES = ["A", "B"] as const;
export type CompactMenuType = (typeof COMPACT_MENU_TYPES)[number];
export const DEFAULT_COMPACT_MENU_TYPE: CompactMenuType = "A";
const TYPE_ALIASES: Record<string, CompactMenuType> = {
A: "A",
"1": "A",
DEFAULT: "A",
B: "B",
"2": "B",
ALT: "B",
};
export function normalizeCompactMenuType(value?: string | null): CompactMenuType {
const raw = (value ?? "").trim().toUpperCase();
return TYPE_ALIASES[raw] ?? DEFAULT_COMPACT_MENU_TYPE;
}
export function isDefaultCompactMenuType(value?: string | null): boolean {
return normalizeCompactMenuType(value) === DEFAULT_COMPACT_MENU_TYPE;
}
/** Compact-menu variant for this website, from runtime-config.js.
* Missing / empty config is type A, which loads the original compact menu. */
export function getRuntimeCompactMenuType(): CompactMenuType {
return normalizeCompactMenuType(readRuntimeEnv("VITE_COMPACT_MENU_TYPE"));
}
export const COMPACT_MENU_TYPE_LABELS: Record<CompactMenuType, string> = {
A: "类型 A",
B: "类型 B",
};
export interface CompactMenuOverride { export interface CompactMenuOverride {
nodeId: string; nodeId: string;
placement: CompactMenuPlacement; placement: CompactMenuPlacement;
@@ -19,6 +19,7 @@ import {
ClipboardList, ClipboardList,
Database, Database,
FileBox, FileBox,
FileSearch,
FileText, FileText,
Gauge, Gauge,
Grid3X3, Grid3X3,
@@ -99,6 +100,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Telescope, icon: Telescope,
path: "/page/strategy/qa/deep-research", path: "/page/strategy/qa/deep-research",
}, },
// {
// id: "qa-enterprise-research",
// label: "企业深度研究",
// icon: FileSearch,
// path: "/page/strategy/qa/enterprise-research",
// },
], ],
}, },
{ {
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { runSentimentAgent, toAgUiMessages } from "./api"; import { runSentimentAgent, toAgUiMessages } from "./api";
import { createClientId } from "@/lib/client-id";
const STORAGE_PREFIX = "workspace.sentiment-agent."; const STORAGE_PREFIX = "workspace.sentiment-agent.";
@@ -53,9 +54,9 @@ export function useSentimentAgentChat(threadId: string) {
const question = query.trim(); const question = query.trim();
if (!question) return; if (!question) return;
const assistantId = `sentiment-ai-${crypto.randomUUID()}`; const assistantId = createClientId("sentiment-ai");
const userMessage: Message = { const userMessage: Message = {
id: `sentiment-human-${crypto.randomUUID()}`, id: createClientId("sentiment-human"),
type: "human", type: "human",
content: [{ type: "text", text: question }], content: [{ type: "text", text: question }],
additional_kwargs: { additional_kwargs: {
@@ -115,7 +116,7 @@ export function useSentimentAgentChat(threadId: string) {
try { try {
await runSentimentAgent({ await runSentimentAgent({
threadId, threadId,
runId: `run-${crypto.randomUUID()}`, runId: createClientId("run"),
messages: history, messages: history,
signal: controller.signal, signal: controller.signal,
handlers: { handlers: {
@@ -82,3 +82,14 @@ test("accepts spaced and full-width square brackets", () => {
assert.equal(out.includes(citation(1)), true); assert.equal(out.includes(citation(1)), true);
assert.equal(out.includes(citation(2)), true); assert.equal(out.includes(citation(2)), true);
}); });
test("can skip the trailing source summary", () => {
const withSummary = applyInlineCitations("没有编号。", makeSources(2));
assert.equal(withSummary.includes("参考来源"), true);
const withoutSummary = applyInlineCitations("没有编号。", makeSources(2), {
appendSummary: false,
});
assert.equal(withoutSummary, "没有编号。");
assert.equal(withoutSummary.includes("#reference-"), false);
});
@@ -509,6 +509,7 @@ export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceI
export function applyInlineCitations( export function applyInlineCitations(
content: string, content: string,
sources: RuntimeDisplaySource[], sources: RuntimeDisplaySource[],
options?: { appendSummary?: boolean },
): string { ): string {
if (!content || sources.length === 0) { if (!content || sources.length === 0) {
return content; return content;
@@ -563,6 +564,9 @@ export function applyInlineCitations(
if (replaced || hasCitationMarkdown(next)) { if (replaced || hasCitationMarkdown(next)) {
return next; return next;
} }
if (options?.appendSummary === false) {
return next;
}
return appendCitationSummary(next, referenceSources); return appendCitationSummary(next, referenceSources);
} }
@@ -0,0 +1,90 @@
/**
* Some OpenAI-compatible reasoning models write ``<think>...</think>`` into
* the ordinary text stream rather than a dedicated reasoning field. Markdown
* sandboxes must never render those fragments as document content. Callers
* can separately render the returned thinking part in a labelled UI trace.
*
* The filter is stateful because either tag can be split across arbitrary SSE
* deltas. Hold only a possible tag suffix, so confirmed Markdown continues
* to appear with the normal token-streaming cadence.
*/
export class InlineThinkTagFilter {
private static readonly OPEN_TAG = "<think>";
private static readonly CLOSE_TAG = "</think>";
private insideThink = false;
private pending = "";
private possibleTagPrefixSize(content: string, tag: string) {
const lowered = content.toLowerCase();
for (let size = Math.min(content.length, tag.length - 1); size > 0; size -= 1) {
if (lowered.slice(-size) === tag.slice(0, size)) return size;
}
return 0;
}
pushParts(chunk: string): { text: string; thinking: string } {
if (!chunk) return { text: "", thinking: "" };
let remaining = this.pending + chunk;
this.pending = "";
let visible = "";
let thinking = "";
while (remaining) {
const tag = this.insideThink
? InlineThinkTagFilter.CLOSE_TAG
: InlineThinkTagFilter.OPEN_TAG;
const tagIndex = remaining.toLowerCase().indexOf(tag);
if (tagIndex >= 0) {
if (this.insideThink) thinking += remaining.slice(0, tagIndex);
else visible += remaining.slice(0, tagIndex);
remaining = remaining.slice(tagIndex + tag.length);
this.insideThink = !this.insideThink;
continue;
}
const suffixSize = this.possibleTagPrefixSize(remaining, tag);
const confirmed = suffixSize ? remaining.slice(0, -suffixSize) : remaining;
if (this.insideThink) thinking += confirmed;
else visible += confirmed;
this.pending = suffixSize ? remaining.slice(-suffixSize) : "";
break;
}
return { text: visible, thinking };
}
push(chunk: string) {
return this.pushParts(chunk).text;
}
/** Flush a completed stream into its visible/thinking channels. */
finishParts(): { text: string; thinking: string } {
if (this.insideThink) {
const thinking = this.pending;
this.insideThink = false;
this.pending = "";
return { text: "", thinking };
}
const tail = this.pending;
this.pending = "";
return { text: tail, thinking: "" };
}
finish() {
return this.finishParts().text;
}
/** Replace a rendered checkpoint while retaining the correct tag state. */
replace(content: string) {
this.insideThink = false;
this.pending = "";
return this.push(content);
}
}
export function stripInlineThinkTags(content: string) {
const filter = new InlineThinkTagFilter();
return filter.push(content) + filter.finish();
}
+8 -2
View File
@@ -99,8 +99,14 @@ export function useNotebookChat(
}, },
}); });
const send = async (text: string, selectedSourceIds: number[]) => { const send = async (
if (thread.isLoading || !token) return; text: string,
selectedSourceIds: number[],
options?: { fromClarification?: boolean },
) => {
if (!token) return;
// 协助卡提交前会先停轮;此时 isLoading 可能仍为 true,不能直接丢弃。
if (thread.isLoading && !options?.fromClarification) return;
// API 需要 document.id,而不是 source.id // API 需要 document.id,而不是 source.id
const toDocIds = (ids: number[]) => const toDocIds = (ids: number[]) =>
ids ids
+1 -1
View File
@@ -314,7 +314,7 @@ export interface CustomThemePreset {
} }
export type ViewMode = "card" | "list"; export type ViewMode = "card" | "list";
export type LayoutMode = "full" | "compact"; export type LayoutMode = "full" | "compact";
export type CompactShortcutParamType = "login" | "theme"; export type CompactShortcutParamType = "login" | "theme" | "path";
export interface CompactShortcutRouteParam { export interface CompactShortcutRouteParam {
key: string; key: string;
+93 -20
View File
@@ -24,6 +24,11 @@ import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads"; import { promptInputFilePartToFile, uploadFiles } from "../uploads";
import type { AgentThread, AgentThreadState } from "./types"; import type { AgentThread, AgentThreadState } from "./types";
import {
isAbortLikeError,
isTransientStreamTransportError,
} from "./thread-access-error";
import { isHiddenFromRecentChats, shouldHideSystemThreadsFromList } from "./utils";
export type ToolEndEvent = { export type ToolEndEvent = {
name: string; name: string;
@@ -206,6 +211,39 @@ export async function stopThreadRun(
} }
} }
/**
* 协助卡提交前:若上一轮还在流式(弱模型出卡后偶发未停干净),先取消服务端 run
* 并断开本地 SSE,避免 409「已有活跃 run」或用户点了选项却发不出去。
*/
export async function stopActiveThreadBeforeClarificationReply(
threadId: string | null | undefined,
thread: { isLoading?: boolean; stop: () => void | Promise<void> },
): Promise<void> {
let storedRunId: string | null = null;
if (threadId && typeof window !== "undefined") {
try {
storedRunId = normalizeStoredRunId(
window.sessionStorage.getItem(`lg:stream:${threadId}`),
);
} catch {
storedRunId = null;
}
}
if (!thread.isLoading && !storedRunId) return;
try {
await stopThreadRun(threadId);
} catch {
/* best-effort */
}
try {
await Promise.resolve(thread.stop());
} catch {
/* best-effort */
}
// cancel 落地需要一拍,立刻 submit 仍可能 409。
await new Promise((resolve) => setTimeout(resolve, 120));
}
function getStreamErrorMessage(error: unknown): string { function getStreamErrorMessage(error: unknown): string {
if (typeof error === "string" && error.trim()) { if (typeof error === "string" && error.trim()) {
return error; return error;
@@ -244,10 +282,10 @@ const TERMINAL_RUN_STATUSES = new Set([
// for a while, then reconcile it from the authoritative run/state endpoints. // for a while, then reconcile it from the authoritative run/state endpoints.
const STREAM_RECONCILIATION_IDLE_MS = 12_000; const STREAM_RECONCILIATION_IDLE_MS = 12_000;
const STREAM_RECONCILIATION_POLL_MS = 5_000; const STREAM_RECONCILIATION_POLL_MS = 5_000;
// A run can legitimately be quiet while a long tool call is in progress, so // A run can legitimately be quiet while a long tool call / slow model write is
// reconnect more conservatively than the terminal-status check above. The // in progress. Reconnect only after a long stall; replacing the local reader
// reconnection only replaces the local SSE reader; the backend run keeps going. // must NOT cancel the backend run (see reconnect below).
const STREAM_RECONNECT_IDLE_MS = 30_000; const STREAM_RECONNECT_IDLE_MS = 90_000;
const STREAM_RECONNECT_MAX_ATTEMPTS = 3; const STREAM_RECONNECT_MAX_ATTEMPTS = 3;
async function fetchThreadTitleRefreshState( async function fetchThreadTitleRefreshState(
@@ -526,6 +564,9 @@ export function useThreadStream({
const runMetadataStorageRef = useRef< const runMetadataStorageRef = useRef<
ReturnType<typeof getRunMetadataStorage> | undefined ReturnType<typeof getRunMetadataStorage> | undefined
>(undefined); >(undefined);
const joinStreamRef = useRef<(runId: string) => Promise<unknown>>(
async () => undefined,
);
if ( if (
typeof window !== "undefined" && typeof window !== "undefined" &&
@@ -681,8 +722,32 @@ export function useThreadStream({
}, },
onError(error) { onError(error) {
markStreamActivity(); markStreamActivity();
setOptimisticMessages([]);
const message = getStreamErrorMessage(error); const message = getStreamErrorMessage(error);
// Replacing a stalled local SSE reader aborts the in-flight fetch. That
// is expected and must not look like a user-facing "network" failure.
if (isAbortLikeError(error)) {
return;
}
// A dropped SSE while the backend run is still alive: rejoin instead of
// toasting "Failed to fetch" / 网络波动 and leaving the answer half-done.
if (isTransientStreamTransportError(message)) {
const staleThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
const runId = staleThreadId
? runMetadataStorageRef.current?.getItem(`lg:stream:${staleThreadId}`)
: null;
if (
runId &&
streamReconnectAttemptsRef.current < STREAM_RECONNECT_MAX_ATTEMPTS
) {
streamReconnectAttemptsRef.current += 1;
lastStreamActivityAtRef.current = Date.now();
void joinStreamRef.current(runId).catch(() => {
// Reconciliation will retry while the run stays non-terminal.
});
return;
}
}
setOptimisticMessages([]);
// A stale auto-reconnect ("Run <id> not found") after a backend restart: // A stale auto-reconnect ("Run <id> not found") after a backend restart:
// drop the dirty ``lg:stream:{threadId}`` key so switching back into this // drop the dirty ``lg:stream:{threadId}`` key so switching back into this
// conversation stops re-firing the same 404, and render from history // conversation stops re-firing the same 404, and render from history
@@ -756,6 +821,7 @@ export function useThreadStream({
// new `thread` identity. // new `thread` identity.
const threadStreamRef = useRef(thread); const threadStreamRef = useRef(thread);
threadStreamRef.current = thread; threadStreamRef.current = thread;
joinStreamRef.current = (runId: string) => thread.joinStream(runId);
useEffect(() => { useEffect(() => {
const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null; const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
@@ -791,8 +857,12 @@ export function useThreadStream({
// ``useStream`` retries one failed connection on its own. If that // ``useStream`` retries one failed connection on its own. If that
// reader quietly stalls, however, its internal queue never gets a // reader quietly stalls, however, its internal queue never gets a
// terminal error and the page remains frozen despite the backend // terminal error and the page remains frozen despite the backend
// continuing the run. Abort only the local reader, then join the // continuing the run.
// same run so no work or tokens are lost server-side. //
// SDK ``stop()`` is *not* a local-only abort: its onStop hook calls
// ``runs.cancel`` and deletes ``lg:stream:{threadId}``, which is
// exactly "答一半就不答了". Hide the run id first so stop() only
// aborts the browser reader, then join the still-running server run.
if (Date.now() - lastStreamActivityAtRef.current < STREAM_RECONNECT_IDLE_MS) { if (Date.now() - lastStreamActivityAtRef.current < STREAM_RECONNECT_IDLE_MS) {
return; return;
} }
@@ -812,6 +882,7 @@ export function useThreadStream({
lastStreamActivityAtRef.current = Date.now(); lastStreamActivityAtRef.current = Date.now();
const stream = threadStreamRef.current; const stream = threadStreamRef.current;
storage?.removeItem(`lg:stream:${activeThreadId}`);
void Promise.resolve(stream.stop()) void Promise.resolve(stream.stop())
.catch(() => { .catch(() => {
// Stopping a stale local reader is best-effort; joining is still // Stopping a stale local reader is best-effort; joining is still
@@ -819,6 +890,11 @@ export function useThreadStream({
}) })
.finally(() => { .finally(() => {
if (disposed || threadIdRef.current !== activeThreadId) return; if (disposed || threadIdRef.current !== activeThreadId) return;
try {
storage?.setItem(`lg:stream:${activeThreadId}`, runId);
} catch {
/* noop */
}
void stream.joinStream(runId).catch(() => { void stream.joinStream(runId).catch(() => {
// The status poll will either reconcile a terminal run or // The status poll will either reconcile a terminal run or
// schedule the next bounded reconnect attempt. // schedule the next bounded reconnect attempt.
@@ -1396,18 +1472,10 @@ export function useThreads(
} }
// Safety net on top of the backend's system-thread exclusion: drop // Safety net on top of the backend's system-thread exclusion: drop
// machine-created threads (scheduled tasks, roundtable workers, // machine-created / wizard threads (scheduled tasks, report-structure
// notebooks) in case an older backend still returns them. // setup, writing setup, notebooks, …) in case an older backend still
return threads.filter((t) => { // returns them.
const metadata = t.metadata as Record<string, unknown> | null; return threads.filter((t) => !isHiddenFromRecentChats(t));
if (metadata?.system) return false;
const threadType = metadata?.thread_type;
return (
threadType !== "notebook" &&
threadType !== "scheduler" &&
threadType !== "roundtable"
);
});
}, },
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
@@ -1490,8 +1558,13 @@ export function useThreadsPage(options: {
throw new Error("Failed to load conversations"); throw new Error("Failed to load conversations");
} }
const rows = (await response.json()) as AgentThread[]; const rows = (await response.json()) as AgentThread[];
// 与后端 exclude_system 对齐:普通列表再兜一层。调用方显式按
// thread_type / system 取数(笔记本等)时不滤,避免把目标会话藏掉。
const visible = shouldHideSystemThreadsFromList(metadata)
? rows.filter((thread) => !isHiddenFromRecentChats(thread))
: rows;
return { return {
threads: rows.slice(0, pageSize), threads: visible.slice(0, pageSize),
hasMore: rows.length > pageSize, hasMore: rows.length > pageSize,
}; };
}, },
@@ -63,6 +63,37 @@ export function isThreadNotAccessibleError(message: string): boolean {
); );
} }
export function isAbortLikeError(error: unknown): boolean {
if (error instanceof DOMException && error.name === "AbortError") {
return true;
}
if (error instanceof Error && error.name === "AbortError") {
return true;
}
const message = getStreamErrorMessage(error).toLowerCase();
return (
message.includes("aborterror") ||
message.includes("the operation was aborted") ||
message.includes("bodystreambuffer")
);
}
export function isTransientStreamTransportError(message: string): boolean {
const lowered = message.toLowerCase();
return (
/failed to fetch/i.test(message) ||
lowered.includes("networkerror") ||
lowered.includes("network error") ||
lowered.includes("load failed") ||
lowered.includes("err_connection") ||
lowered.includes("err_incomplete") ||
lowered.includes("err_network") ||
lowered.includes("econnreset") ||
lowered.includes("econnaborted") ||
/fetch failed/i.test(message)
);
}
/** Build a label for the *current* logged-in identity (email → user_id → 未知). */ /** Build a label for the *current* logged-in identity (email → user_id → 未知). */
export function buildCurrentUserLabel(): string { export function buildCurrentUserLabel(): string {
const auth = getStoredAuth(); const auth = getStoredAuth();
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { readRuntimeEnv } from "@/env"; import { readRuntimeEnv } from "@/env";
import { createClientId } from "@/lib/client-id";
const STORAGE_PREFIX = "workspace.multi-round-rag."; const STORAGE_PREFIX = "workspace.multi-round-rag.";
@@ -13,6 +14,9 @@ export type MultiRoundRagRetrieval = {
time?: string; time?: string;
recUuid?: string; recUuid?: string;
url?: string; url?: string;
/** Original tool payload, retained so a future renderer can add more fields
* without losing data received from the multi-round service. */
raw?: Record<string, unknown>;
}; };
export type MultiRoundRagRound = { export type MultiRoundRagRound = {
@@ -74,7 +78,7 @@ function writeStoredMessages(threadId: string, messages: Message[]) {
} }
function createId(prefix: string) { function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`; return createClientId(prefix);
} }
function isAbortError(error: unknown) { function isAbortError(error: unknown) {
@@ -87,8 +91,11 @@ function asRecord(value: unknown): Record<string, unknown> | null {
: null; : null;
} }
function asString(value: unknown): string { /** Values in the deployed tools are occasionally numeric IDs, not strings. */
return typeof value === "string" ? value : ""; function asText(value: unknown): string {
if (typeof value === "string") return value;
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return "";
} }
function parseJson(value: unknown): unknown { function parseJson(value: unknown): unknown {
@@ -103,8 +110,11 @@ function parseJson(value: unknown): unknown {
function parseEventLine(line: string): MultiRoundEvent | null { function parseEventLine(line: string): MultiRoundEvent | null {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed.startsWith("data:")) return null; if (!trimmed.startsWith("data:")) return null;
const payload = trimmed.slice("data:".length).trim();
if (!payload || payload === "[DONE]") return { event: "done" };
try { try {
return JSON.parse(trimmed.slice("data:".length).trim()) as MultiRoundEvent; const parsed = parseJson(payload);
return asRecord(parsed) as MultiRoundEvent | null;
} catch { } catch {
return null; return null;
} }
@@ -141,10 +151,10 @@ function addThinkingRound(trace: MultiRoundRagTrace, thought: string) {
if (current.title === "正在准备检索" && !current.thought && !current.query) { if (current.title === "正在准备检索" && !current.thought && !current.query) {
current.title = "思考过程"; current.title = "思考过程";
current.thought = thought; current.thought = thought;
return; return current;
} }
current.isSearching = false; current.isSearching = false;
trace.rounds.push({ const next = {
id: createId("multi-rag-round"), id: createId("multi-rag-round"),
title: "思考过程", title: "思考过程",
thought, thought,
@@ -152,7 +162,9 @@ function addThinkingRound(trace: MultiRoundRagTrace, thought: string) {
query: "", query: "",
isSearching: true, isSearching: true,
retrievals: [], retrievals: [],
}); };
trace.rounds.push(next);
return next;
} }
function appendRoundBody(trace: MultiRoundRagTrace, body: string) { function appendRoundBody(trace: MultiRoundRagTrace, body: string) {
@@ -162,49 +174,94 @@ function appendRoundBody(trace: MultiRoundRagTrace, body: string) {
current.body = `${current.body ?? ""}${body}`; current.body = `${current.body ?? ""}${body}`;
} }
function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] { function firstArray(values: unknown[]): unknown[] {
const root = asRecord(parseJson(value)); for (const value of values) {
const list = const parsed = parseJson(value);
(Array.isArray(root?.results) && root.results) || if (Array.isArray(parsed)) return parsed;
(Array.isArray(root?.organic) && root.organic) || }
[]; return [];
return list.flatMap((item, index) => { }
function toRecordList(value: unknown): Record<string, unknown>[] {
const parsed = parseJson(value);
const root = asRecord(parsed);
const list = Array.isArray(parsed)
? parsed
: firstArray([
root?.results,
root?.organic,
root?.data,
root?.items,
root?.list,
root?.documents,
]);
return list.flatMap((item) => {
if (typeof item === "string") return [{ content: item }];
const record = asRecord(item); const record = asRecord(item);
if (!record) return []; return record ? [record] : [];
const recUuid = asString(record.recUuid ?? record.rec_uuid); });
const url = asString(record.url ?? record.link ?? record.href); }
/**
* GeneralQA's service fans out to several business search tools. Normalising
* all of their result shapes here prevents a successful tool call from being
* silently absent just because it did not use `results` or `organic`.
*/
function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] {
const list = toRecordList(value);
return list.flatMap((item, index) => {
const recUuid = asText(item.recUuid ?? item.rec_uuid ?? item.uuid);
const url = asText(item.url ?? item.link ?? item.href);
const title = asText(
item.title ?? item.m_title ?? item.taskName ?? item.user_name ?? item.name ?? item.user_id,
);
const content = asText(
item.content ??
item.m_content ??
item.m_conent ??
item.snippet ??
item.text ??
item.overview ??
item.page_content ??
item.description ??
item.summary,
);
return [ return [
{ {
id: asString(record.id) || recUuid || undefined, id: asText(item.id) || recUuid || undefined,
title: asString(record.title) || `检索资料 ${index + 1}`, title: title || `检索资料 ${index + 1}`,
content: asString(record.content ?? record.snippet ?? record.text), content: content || (url && !title ? url : ""),
source: asString(record.source ?? record.source1 ?? url) || undefined, source: asText(item.source ?? item.source1 ?? item.m_source ?? item.platform) || undefined,
time: asString(record.publish_time ?? record.m_publish ?? record.date) || undefined, time: asText(item.publish_time ?? item.m_publish ?? item.m_publicsh ?? item.date ?? item.time) || undefined,
recUuid: recUuid || undefined, recUuid: recUuid || undefined,
url: /^https?:\/\//i.test(url) ? url : undefined, url: /^https?:\/\//i.test(url) ? url : undefined,
raw: item,
}, },
]; ];
}); });
} }
function mapReferences(value: unknown): MultiRoundRagSource[] { function mapReferences(value: unknown): MultiRoundRagSource[] {
const record = asRecord(value); const parsed = parseJson(value);
const list = Array.isArray(record?.references) ? record.references : []; const record = asRecord(parsed);
const list = Array.isArray(parsed)
? parsed
: firstArray([record?.references, record?.results, record?.sources, record?.documents]);
return list.flatMap((item, index) => { return list.flatMap((item, index) => {
const source = asRecord(item); const source = asRecord(item);
if (!source) return []; if (!source) return [];
const content = asString(source.content ?? source.snippet); const content = asText(source.content ?? source.m_content ?? source.m_conent ?? source.snippet ?? source.text);
const rawUrl = asString(source.url ?? source.link ?? source.href); const rawUrl = asText(source.url ?? source.link ?? source.href);
const recUuid = asString(source.recUuid ?? source.rec_uuid ?? source.uuid); const recUuid = asText(source.recUuid ?? source.rec_uuid ?? source.uuid);
return [ return [
{ {
id: asString(source.id) || recUuid || undefined, id: asText(source.id) || recUuid || undefined,
title: asString(source.title) || `参考资料 ${index + 1}`, title: asText(source.title ?? source.m_title ?? source.taskName ?? source.user_name) || `参考资料 ${index + 1}`,
type: asString(source.type) || "参考资料", type: asText(source.type) || "参考资料",
snippet: asString(source.snippet) || content, snippet: asText(source.snippet) || content,
content, content,
source: asString(source.source ?? source.source1) || undefined, source: asText(source.source ?? source.source1 ?? source.m_source ?? source.platform) || undefined,
time: asString(source.time ?? source.m_publish ?? source.publish_time) || undefined, time: asText(source.time ?? source.m_publish ?? source.m_publicsh ?? source.publish_time ?? source.date) || undefined,
url: /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined, url: /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined,
raw: source, raw: source,
}, },
@@ -222,6 +279,71 @@ function cloneTrace(trace: MultiRoundRagTrace): MultiRoundRagTrace {
}; };
} }
function toolTitle(toolName: string): string {
switch (toolName) {
case "local_search":
return "本地知识检索";
case "tavily_search":
return "联网检索";
case "google_search":
case "getTaskLikeTop":
return "思考过程";
case "search_user_profile":
return "靶标平台画像库";
case "get_taiwan_news":
case "search_keyword_taiwan_news":
return "台湾热点新闻";
case "get_world_news":
return "世界热点新闻";
case "get_root_messaage_comment":
return "获取该消息的全部评论";
case "search_user_behavior":
return "查询该用户的历史行为";
case "get_america_news":
return "美国热点新闻";
case "get_time_data":
return "态势新闻搜索";
case "get_textid_content":
return "获取新闻详细内容";
default:
return toolName || "检索过程";
}
}
function isSearchTool(toolName: string): boolean {
return [
"local_search",
"tavily_search",
"google_search",
"getTaskLikeTop",
"search_user_profile",
"get_taiwan_news",
"search_keyword_taiwan_news",
"get_world_news",
"get_root_messaage_comment",
"search_user_behavior",
"get_america_news",
"get_time_data",
].includes(toolName);
}
function queryFromToolInput(input: Record<string, unknown>, result: unknown): string {
const resultRecord = asRecord(parseJson(result));
const direct = asText(input.q ?? input.query ?? input.keyword ?? input.user_fig);
if (direct) return direct;
if (input.s || input.e) {
return [asText(input.s), asText(input.e)].filter(Boolean).join(" - ");
}
return asText(resultRecord?.query ?? resultRecord?.q);
}
function textFromEventData(value: unknown): string {
const parsed = parseJson(value);
if (typeof parsed === "string") return parsed;
const record = asRecord(parsed);
return asText(record?.text ?? record?.content ?? record?.message ?? record?.answer ?? record?.output);
}
function isSingleRoundRagMessage(message: Message) { function isSingleRoundRagMessage(message: Message) {
return message.additional_kwargs?.single_round_rag === true; return message.additional_kwargs?.single_round_rag === true;
} }
@@ -370,92 +492,204 @@ export function useMultiRoundRagMessages(threadId: string) {
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ""; let buffer = "";
let activeThoughtRound: MultiRoundRagRound | null = null;
let activeThoughtTailIsAnswer = false;
let answerFromShowText = false;
const appendAnswer = (text: string, allowCompleteSnapshot = false) => {
if (!text) return;
// Some gateways emit one complete `message` after a `show_text`
// answer. Restrict snapshot detection to that case and to a
// meaningful length: adjacent streaming chunks can legitimately
// contain the same character, and must never be discarded.
if (
allowCompleteSnapshot &&
trace.answer.length >= 16 &&
text.startsWith(trace.answer)
) {
trace.answer = text;
return;
}
trace.answer += text;
};
const consumeShowText = (text: string, tailIsAnswer = false) => {
if (!text) return;
if (activeThoughtRound) {
const endIndex = text.indexOf("</think>");
if (endIndex < 0) {
activeThoughtRound.thought += text;
return;
}
activeThoughtRound.thought += text.slice(0, endIndex);
const roundBody = text.slice(endIndex + "</think>".length).trim();
if (roundBody) {
if (activeThoughtTailIsAnswer) {
appendAnswer(roundBody);
answerFromShowText = false;
trace.isComplete = true;
} else {
appendRoundBody(trace, roundBody);
}
}
activeThoughtRound = null;
activeThoughtTailIsAnswer = false;
return;
}
const thinkStart = text.indexOf("<think>");
if (thinkStart < 0) {
const current = latestRound(trace);
current.isSearching = false;
appendAnswer(text);
answerFromShowText = true;
trace.isComplete = true;
return;
}
const prefix = text.slice(0, thinkStart).trim();
if (prefix) appendRoundBody(trace, prefix);
// A full <think>…</think> envelope is normally delivered as one
// tool call. Keep the unfinished variant open as well: production
// proxies are allowed to split an SSE frame at any byte boundary.
const thinkEnd = text.indexOf("</think>", thinkStart + "<think>".length);
const thought = text
.slice(thinkStart + "<think>".length, thinkEnd < 0 ? undefined : thinkEnd)
.trim();
const thoughtRound = addThinkingRound(trace, thought);
if (thinkEnd < 0) {
activeThoughtRound = thoughtRound;
activeThoughtTailIsAnswer = tailIsAnswer;
return;
}
const roundBody = text.slice(thinkEnd + "</think>".length).trim();
if (roundBody) {
if (tailIsAnswer) {
appendAnswer(roundBody);
answerFromShowText = false;
trace.isComplete = true;
} else {
appendRoundBody(trace, roundBody);
}
}
};
const consumeToolCall = (payload: unknown) => {
const parsedPayload = parseJson(payload);
const tools = Array.isArray(parsedPayload) ? parsedPayload : [parsedPayload];
for (const toolValue of tools) {
const tool = asRecord(toolValue);
if (!tool) continue;
const toolName = asText(tool.tool_name ?? tool.name);
const input = asRecord(parseJson(tool.tool_input ?? tool.input ?? tool.arguments)) ?? {};
const current = latestRound(trace);
if (toolName === "show_text") {
consumeShowText(asText(input.text ?? input.content));
continue;
}
if (toolName === "get_textid_content") {
current.title = toolTitle(toolName);
const article = asRecord(parseJson(input.result));
const body = asText(article?.content ?? article?.text ?? input.content ?? input.result);
if (body) appendRoundBody(trace, body);
current.isSearching = false;
continue;
}
const hasResult = input.result !== undefined;
const query = queryFromToolInput(input, input.result);
if (isSearchTool(toolName) || hasResult) {
current.title = toolTitle(toolName);
current.query = query || current.query;
if (!hasResult && isSearchTool(toolName)) {
// GeneralQA can receive a model text event before the search
// tool announces its query. The query starts a new visible
// loading phase, so restore the round/trace state here and
// keep the query badge spinning until its result arrives.
current.isSearching = true;
trace.isComplete = false;
} else if (hasResult) {
const retrievals = mapRetrievals(input.result);
// An empty search response is still a completed response; it
// must replace a previous list instead of leaving stale cards.
current.retrievals = retrievals;
if (retrievals.length === 0) {
const fallbackBody = textFromEventData(input.result);
if (fallbackBody && fallbackBody !== current.query) {
appendRoundBody(trace, fallbackBody);
}
}
current.isSearching = false;
}
}
}
};
const finishTrace = () => {
trace.isComplete = true;
activeThoughtRound = null;
activeThoughtTailIsAnswer = false;
for (const round of trace.rounds) round.isSearching = false;
};
const consumeEvent = (event: MultiRoundEvent) => {
const eventType = asText(event.event);
const data = event.data;
if (eventType === "tool_call") {
consumeToolCall(data);
} else if (eventType === "message") {
const text = textFromEventData(data);
if (text) {
if (activeThoughtRound || /<\/?think>/i.test(text)) {
// Most deployments put thoughts in `show_text`, but this
// fallback also preserves a compatible gateway that emits
// `<think>` in normal message chunks.
consumeShowText(text, true);
} else {
latestRound(trace).isSearching = false;
appendAnswer(text, answerFromShowText);
answerFromShowText = false;
trace.isComplete = true;
}
}
} else if (eventType === "references") {
sources = mapReferences(data);
} else if (eventType === "error") {
const message = textFromEventData(data) || "多轮 RAG 服务返回错误。";
setError(message);
if (!trace.answer) trace.answer = message;
finishTrace();
} else if (eventType === "done") {
// A few compatible gateway versions include the whole final
// answer in `done`; render it when no `message` chunk was sent.
const finalText = textFromEventData(data);
if (finalText && !trace.answer) appendAnswer(finalText);
finishTrace();
}
refreshAssistant();
};
const consumeLines = (flush = false) => {
const lines = buffer.split(/\r?\n/);
buffer = flush ? "" : (lines.pop() ?? "");
for (const line of lines) {
const event = parseEventLine(line);
if (event) consumeEvent(event);
}
};
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r?\n/); consumeLines();
buffer = lines.pop() ?? "";
for (const line of lines) {
const event = parseEventLine(line);
if (!event) continue;
const eventType = asString(event.event);
const data = event.data;
if (eventType === "tool_call") {
const tool = asRecord(data);
const toolName = asString(tool?.tool_name);
const input = asRecord(tool?.tool_input) ?? {};
const current = latestRound(trace);
if (toolName === "show_text") {
const text = asString(input.text);
if (text.includes("<think>")) {
const thinkEnd = text.indexOf("</think>");
addThinkingRound(
trace,
(thinkEnd >= 0
? text.slice(0, thinkEnd + "</think>".length)
: text
)
.replace(/<\/?think>/g, "")
.trim(),
);
if (thinkEnd >= 0) {
const roundBody = text
.slice(thinkEnd + "</think>".length)
.trim();
if (roundBody) {
appendRoundBody(trace, roundBody);
}
}
} else {
// A non-thinking `show_text` immediately after a thought is
// this round's narrative; the terminal `message` event is
// still rendered only as the final answer below all rounds.
current.isSearching = false;
trace.answer += text;
trace.isComplete = true;
}
} else if (
toolName === "local_search" ||
toolName === "tavily_search" ||
toolName === "google_search"
) {
const queryData = asRecord(parseJson(input.result));
current.title =
toolName === "local_search" ? "本地知识检索" : "联网检索";
current.query =
asString(input.q ?? input.query) ||
asString(queryData?.query) ||
current.query;
const retrievals = mapRetrievals(input.result);
if (retrievals.length > 0) current.retrievals = retrievals;
current.isSearching = false;
} else if (toolName === "get_textid_content") {
const article = asRecord(parseJson(input.result));
const body =
asString(article?.content ?? article?.text) ||
asString(input.content);
if (body) appendRoundBody(trace, body);
current.isSearching = false;
}
refreshAssistant();
} else if (eventType === "message" && typeof data === "string") {
latestRound(trace).isSearching = false;
trace.answer += data;
trace.isComplete = true;
refreshAssistant();
} else if (eventType === "references") {
sources = mapReferences(data);
refreshAssistant();
} else if (eventType === "done") {
trace.isComplete = true;
for (const round of trace.rounds) round.isSearching = false;
refreshAssistant();
}
}
} }
trace.isComplete = true; buffer += decoder.decode();
for (const round of trace.rounds) round.isSearching = false; consumeLines(true);
finishTrace();
refreshAssistant(); refreshAssistant();
} catch (caught) { } catch (caught) {
trace.isComplete = true; trace.isComplete = true;
@@ -2,10 +2,11 @@ import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { readRuntimeEnv } from "@/env"; import { readRuntimeEnv } from "@/env";
import { createClientId } from "@/lib/client-id";
const STORAGE_PREFIX = "workspace.single-round-rag."; const STORAGE_PREFIX = "workspace.single-round-rag.";
type KnowledgeBaseChunk = { type KnowledgeBaseChunk = Record<string, unknown> & {
answer?: unknown; answer?: unknown;
docs?: unknown; docs?: unknown;
}; };
@@ -168,38 +169,116 @@ function parseKnowledgeBaseChunk(jsonText: string): KnowledgeBaseChunk {
} }
} }
function flattenDocs(value: unknown): Record<string, unknown>[] { function readString(...values: unknown[]): string | undefined {
if (Array.isArray(value)) { for (const value of values) {
return value.flatMap((item) => flattenDocs(item)); if (typeof value === "string" && value.trim()) return value;
} }
if (!value || typeof value !== "object") return []; return undefined;
const record = value as Record<string, unknown>;
if ("docs" in record) return flattenDocs(record.docs);
return [record];
} }
function normalizeSources(value: unknown): RagSource[] { function docsFallbackContent(docs: unknown): string | undefined {
return flattenDocs(value).map((item, index) => { const candidates = Array.isArray(docs) ? docs : [docs];
const title = String(item.m_title ?? item.title ?? item.source ?? `参考资料 ${index + 1}`); return readString(...candidates);
const content = String(item.m_content ?? item.content ?? item.snippet ?? ""); }
const source = item.source1 ?? item.source;
const rawUrl = item.url ?? item.link ?? item.href; function buildKnowledgeDetailUrl(recUuid: string | undefined): string | undefined {
return { const knowledgeUrl = readRuntimeEnv("VITE_AIPI_KNOWLEDGE_URL")?.replace(/\/$/, "");
id: String(item.recUuid ?? item.rec_uuid ?? item.uuid ?? item.id ?? "") || undefined, if (!knowledgeUrl || !recUuid) return undefined;
title, return `${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${encodeURIComponent(recUuid)}`;
type: String(item.source1 ?? item.type ?? "知识库"), }
snippet: content,
content, function toRagSource(
source: typeof source === "string" ? source : undefined, record: Record<string, unknown>,
time: typeof item.m_publish === "string" ? item.m_publish : undefined, index: number,
url: typeof rawUrl === "string" && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined, fallbackContent?: string,
raw: item, ): RagSource {
}; const title =
readString(record.m_title, record.title, record.source, record.source1) ??
`参考资料 ${index + 1}`;
const content =
readString(
record.m_content,
// Some deployed responses spell this field incorrectly; keep it as a
// read-only compatibility alias rather than dropping the document.
record.m_conent,
record.content,
record.snippet,
fallbackContent,
record.url,
) ?? "";
const source = readString(record.source1, record.source, record.m_source);
const rawUrl = readString(record.url, record.link, record.href);
const recUuid = readString(record.recUuid, record.rec_uuid, record.uuid, record.id);
return {
id: recUuid,
title,
type: readString(record.type, record.source1) ?? "知识库",
snippet: content,
content,
source,
time: readString(record.m_publish, record.m_publicsh, record.time),
// GeneralQA uses recUuid to open the knowledge-detail page. Prefer that
// stable internal record link, then fall back to a backend-provided URL.
url:
buildKnowledgeDetailUrl(recUuid) ??
(rawUrl && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined),
raw: record,
};
}
/**
* A single-RAG stream is a concatenation of source envelopes, for example:
* `{docs:["..."], recUuid:"...", m_title:"...", m_content:"..."}`.
* The root-level metadata is the source; `docs` merely marks the envelope and
* supplies a fallback body. This mirrors GeneralQA's per-chunk processing.
*/
function normalizeSources(chunk: KnowledgeBaseChunk): RagSource[] {
const hasRootMetadata = [
chunk.recUuid,
chunk.rec_uuid,
chunk.m_title,
chunk.m_content,
chunk.m_conent,
chunk.source1,
chunk.m_publish,
chunk.m_publicsh,
].some((value) => typeof value === "string" && value.trim());
if (hasRootMetadata) {
return [toRagSource(chunk, 0, docsFallbackContent(chunk.docs))];
}
const docs = Array.isArray(chunk.docs) ? chunk.docs : chunk.docs ? [chunk.docs] : [];
return docs.flatMap((item, index): RagSource[] => {
if (typeof item === "string") {
return [toRagSource({}, index, item)];
}
if (!item || typeof item !== "object") return [];
return [toRagSource(item as Record<string, unknown>, index)];
}); });
} }
function mergeSources(current: RagSource[], incoming: RagSource[]): RagSource[] {
const result = [...current];
const keys = new Set(
current.map((source) =>
source.id
? `id:${source.id}`
: `content:${source.title}\u0000${source.content}\u0000${source.source ?? ""}`,
),
);
for (const source of incoming) {
const key = source.id
? `id:${source.id}`
: `content:${source.title}\u0000${source.content}\u0000${source.source ?? ""}`;
if (keys.has(key)) continue;
keys.add(key);
result.push(source);
}
return result;
}
function createId(prefix: string) { function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`; return createClientId(prefix);
} }
function isAbortError(error: unknown) { function isAbortError(error: unknown) {
@@ -397,7 +476,9 @@ export function useSingleRoundRagMessages(threadId: string) {
for (const jsonText of extracted.objects) { for (const jsonText of extracted.objects) {
const chunk = parseKnowledgeBaseChunk(jsonText); const chunk = parseKnowledgeBaseChunk(jsonText);
if (typeof chunk.answer === "string") answerChunks.push({ answer: chunk.answer }); if (typeof chunk.answer === "string") answerChunks.push({ answer: chunk.answer });
if (chunk.docs !== undefined) sources = normalizeSources(chunk.docs); if (chunk.docs !== undefined) {
sources = mergeSources(sources, normalizeSources(chunk));
}
refreshAssistant(); refreshAssistant();
} }
} }
+38
View File
@@ -91,6 +91,44 @@ export function isBootstrapThread(thread: {
return thread.metadata?.thread_type === "bootstrap"; return thread.metadata?.thread_type === "bootstrap";
} }
/**
* 不应出现在普通对话列表里的 thread_type。
* 与后端 ``EXCLUDED_SYSTEM_THREAD_TYPES`` 保持同步;bootstrap 不在此列(侧栏仍展示)。
*/
export const HIDDEN_FROM_RECENT_CHAT_TYPES = new Set([
"scheduler",
"roundtable",
"notebook",
"deep_research",
"writing_setup",
"report_structure_setup",
]);
export function isSetupWizardThread(thread: {
metadata?: Record<string, unknown> | null;
}): boolean {
const threadType = thread.metadata?.thread_type;
return threadType === "writing_setup" || threadType === "report_structure_setup";
}
/** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */
export function isHiddenFromRecentChats(thread: {
metadata?: Record<string, unknown> | null;
}): boolean {
const metadata = thread.metadata;
if (metadata?.system === true) return true;
const threadType = metadata?.thread_type;
return typeof threadType === "string" && HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType);
}
/** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */
export function shouldHideSystemThreadsFromList(
metadata?: Record<string, unknown> | null,
): boolean {
if (!metadata) return true;
return !("thread_type" in metadata || "system" in metadata);
}
/** /**
* bootstrap 会话点击后回到「创建智能体」页并恢复该会话。该页只挂在 * bootstrap 会话点击后回到「创建智能体」页并恢复该会话。该页只挂在
* /page/workspace 下(见 WorkspaceRoutes 的 agents/new/:thread_id),故路径固定, * /page/workspace 下(见 WorkspaceRoutes 的 agents/new/:thread_id),故路径固定,
@@ -0,0 +1,392 @@
import { getBackendBaseURL } from "@/core/config";
import { readRuntimeEnv } from "@/env";
export interface EightBfTask {
id: string;
externalTaskId: string;
title: string;
description: string;
direction: string;
overview: string;
priority: string;
taskPriority: string;
targetDirection: string;
taskStatus: string;
source: string;
raw: Record<string, unknown> | null;
}
export interface EightBfDictionaryOption {
label: string;
value: string;
alias?: string;
statusTone?: string;
}
export interface EightBfTaskDictionaries {
priority: EightBfDictionaryOption[];
direction: EightBfDictionaryOption[];
status: EightBfDictionaryOption[];
}
export interface EightBfTaskContextPayload {
kind: "eightbf_roundtable_task";
usage: string;
task_id: string;
task: {
id: string;
title: string;
description: string;
direction: string;
overview: string;
priority: string;
task_priority: string;
target_direction: string;
task_status: string;
};
task_tags: {
priority: string;
direction: string;
status: string;
};
}
/**
* 外部任务系统在离线部署或本地联调时可能不可用。此回退仅用于页面展示与
* 意图上下文,保持与岗位会商示例一致,避免左上任务卡退化成接口错误文本。
*/
export function createEightBfFallbackTask(taskId: string): EightBfTask {
return {
id: taskId,
externalTaskId: taskId,
title: "暑期活动",
description: "围绕暑期活动的游泳夏令营开展宣传,结合线上线下渠道吸引目标人群参与,持续提升活动影响力。",
direction: "暑期活动",
overview: "面向家长群体传播暑期游泳夏令营活动信息,提升品牌曝光与触达声量。",
priority: "urgent",
taskPriority: "urgent",
targetDirection: "summer_activity",
taskStatus: "system_created",
source: "本系统创建",
raw: null,
};
}
const TASK_DETAIL_PATH = "/task/{taskId}";
const PROCESS_REPORT_ADD_PATH = "/task/process-report/add";
const PROCESS_REPORT_PATH = "/task/process-report/{id}";
const DICTIONARY_TYPES = {
priority: "task_priority",
direction: "task_direction",
status: "task_status",
} as const;
function externalApiBase(): string {
// The position-roundtable Vue app calls this task system through
// VITE_API_BASE_URL (usually the /zncm gateway), not DeerFlow's own API.
return (
readRuntimeEnv("VITE_8BF_TASK_API_BASE_URL")?.replace(/\/+$/, "") ||
readRuntimeEnv("VITE_API_BASE_URL")?.replace(/\/+$/, "") ||
getBackendBaseURL().replace(/\/+$/, "")
);
}
/** 8BF 会商页右上角「返回」按钮的跳转地址,来自 runtime-config.js。 */
export function getEightBfRoundtableBackUrl(): string {
return readRuntimeEnv("VITE_8BF_ROUNDTABLE_BACK_URL") ?? "";
}
function externalUrl(path: string): string {
if (/^https?:\/\//i.test(path)) return path;
return `${externalApiBase()}${path.startsWith("/") ? path : `/${path}`}`;
}
export interface EightBfTaskProcessReportInput {
taskId: string;
agentId: string;
agentName: string;
content: string;
/** 与岗位会商一致:测试分析岗的过程产物位置为 0。 */
position: number;
reportName: string;
submitStatus: 0 | 1;
}
export interface EightBfTaskProcessReportReceipt {
id: string;
}
/**
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
* 两套 token 的用途不同,不能直接复用 `apiFetch` 的默认鉴权头;否则
* 任务详情/字典接口可能返回 401,页面只会看到空任务卡。
*
* 这里仍保留 include credentials,与原 Vue 请求保持一致;同时不走
* `apiFetch` 的 401 自动清理逻辑,避免外部任务系统的鉴权失败误登出
* DeerFlow 当前用户。
*/
async function fetchExternalTaskSystem(
path: string,
init: RequestInit = {},
): Promise<Response> {
const headers = new Headers(init.headers);
if (!headers.has("Authorization")) {
headers.set("Authorization", "Bearer admin");
}
if (!headers.has("Accept")) {
headers.set("Accept", "application/json");
}
return fetch(externalUrl(path), {
...init,
credentials: init.credentials ?? "include",
headers,
});
}
async function externalTaskError(response: Response, fallback: string): Promise<Error> {
const text = await response.text().catch(() => "");
try {
const payload = JSON.parse(text) as { message?: unknown; msg?: unknown; detail?: unknown };
const detail = firstText(payload.message, payload.msg, payload.detail);
if (detail) return new Error(`${fallback}(${response.status}):${detail}`);
} catch {
// Keep the HTTP status when this deployment returns a non-JSON error body.
}
return new Error(`${fallback}(${response.status})`);
}
function findProcessReportId(payload: unknown): string {
if (!payload || typeof payload !== "object") return "";
const source = payload as Record<string, unknown>;
const direct = firstText(
source.id,
source.processReportId,
source.process_report_id,
source.reportId,
);
if (direct) return direct;
return findProcessReportId(source.data);
}
/**
* Create the external task system's process report in its unsubmitted state.
* The local gateway stores the returned id so a refresh can later submit the
* same report instead of creating a duplicate one.
*/
export async function createEightBfTaskProcessReport(
input: EightBfTaskProcessReportInput,
): Promise<EightBfTaskProcessReportReceipt> {
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
const id = findProcessReportId(await response.json().catch(() => null));
if (!id) throw new Error("过程产物登记成功,但接口未返回报告 ID");
return { id };
}
/** Submit an already-registered process report to the external task system. */
export async function updateEightBfTaskProcessReport(
reportId: string,
input: EightBfTaskProcessReportInput,
): Promise<void> {
const response = await fetchExternalTaskSystem(
PROCESS_REPORT_PATH.replace("{id}", encodeURIComponent(reportId)),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
},
);
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
}
function firstText(...values: unknown[]): string {
for (const value of values) {
if (value === undefined || value === null) continue;
const text = String(value).trim();
if (text) return text;
}
return "";
}
function unwrapData(payload: unknown): Record<string, unknown> {
let current: unknown = payload;
// Task services in different deployments wrap the object as either
// { data: task } or { data: { data: task } }.
for (let index = 0; index < 3; index += 1) {
if (!current || typeof current !== "object" || Array.isArray(current)) break;
const next = (current as { data?: unknown }).data;
if (!next || typeof next !== "object" || Array.isArray(next)) break;
current = next;
}
return current && typeof current === "object" && !Array.isArray(current)
? (current as Record<string, unknown>)
: {};
}
export function normalizeEightBfTask(
raw: Record<string, unknown> | null | undefined,
fallbackTaskId = "",
): EightBfTask {
const source = raw ?? {};
const id = firstText(
source.id,
source.taskId,
source.externalTaskId,
source.external_task_id,
fallbackTaskId,
);
return {
id,
externalTaskId: firstText(source.externalTaskId, source.external_task_id, id),
title: firstText(source.title, source.taskName, source.task_name, source.name),
description: firstText(
source.description,
source.taskDescription,
source.task_description,
source.taskContent,
source.task_content,
source.content,
),
direction: firstText(
source.direction,
source.taskDirection,
source.task_direction,
),
overview: firstText(source.overview, source.summary),
priority: firstText(source.priority, source.urgency),
taskPriority: firstText(
source.taskPriority,
source.task_priority,
source.priority,
source.urgency,
),
targetDirection: firstText(
source.targetDirection,
source.target_direction,
source.direction,
source.taskDirection,
source.task_direction,
),
taskStatus: firstText(source.taskStatus, source.task_status, source.status),
source: "测试分析岗任务详情接口",
raw: source,
};
}
function dictionaryRows(payload: unknown): unknown[] {
if (Array.isArray(payload)) return payload;
if (payload && typeof payload === "object") {
const directData = (payload as { data?: unknown }).data;
if (Array.isArray(directData)) return directData;
if (directData && typeof directData === "object") {
const nested = directData as { rows?: unknown; list?: unknown };
if (Array.isArray(nested.rows)) return nested.rows;
if (Array.isArray(nested.list)) return nested.list;
}
}
const data = unwrapData(payload);
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.list)) return data.list;
return [];
}
function normalizeDictionary(payload: unknown): EightBfDictionaryOption[] {
return dictionaryRows(payload)
.map((item) => {
if (!item || typeof item !== "object") return null;
const row = item as Record<string, unknown>;
const label = firstText(row.dictLabel, row.label);
const value = firstText(row.dictValue, row.dict_value, row.value);
if (!label || !value) return null;
return {
label,
value,
alias: firstText(row.alias) || undefined,
statusTone: firstText(row.statusTone, row.status_tone) || undefined,
} as EightBfDictionaryOption;
})
.filter((item): item is EightBfDictionaryOption => item !== null);
}
export async function fetchEightBfTask(taskId: string): Promise<EightBfTask> {
const encodedId = encodeURIComponent(taskId);
const path = TASK_DETAIL_PATH.replaceAll("{taskId}", encodedId);
const response = await fetchExternalTaskSystem(path, { method: "GET" });
if (!response.ok) {
throw new Error(`任务详情接口请求失败(${response.status})`);
}
const payload = (await response.json()) as unknown;
const task = normalizeEightBfTask(unwrapData(payload), taskId);
// Keep the external route id as the stable scope even if a deployment's
// response includes a legacy/internal numeric id.
return { ...task, id: taskId, externalTaskId: taskId };
}
export async function fetchEightBfTaskDictionaries(): Promise<EightBfTaskDictionaries> {
const entries = await Promise.all(
(Object.entries(DICTIONARY_TYPES) as Array<[
keyof EightBfTaskDictionaries,
string,
]>).map(async ([key, path]) => {
try {
const response = await fetchExternalTaskSystem(
`/system/dict/data/type/${path}`,
{ method: "GET" },
);
if (!response.ok) throw new Error(`字典接口请求失败(${response.status})`);
return [key, normalizeDictionary(await response.json())] as const;
} catch (error) {
// A dictionary failure must not hide the task card; the card falls back
// to the raw task codes and the detail request remains usable.
console.warn(`[8bf] ${key} 字典加载失败`, error);
return [key, []] as const;
}
}),
);
return Object.fromEntries(entries) as EightBfTaskDictionaries;
}
export function dictionaryText(
options: EightBfDictionaryOption[] | undefined,
value: unknown,
preferredField: "label" | "alias" = "label",
): string {
const raw = firstText(value);
if (!raw) return "";
const match = options?.find((option) => option.value === raw);
return firstText(match?.[preferredField], match?.label, raw);
}
export function buildEightBfTaskContext(
task: EightBfTask | null,
dictionaries: EightBfTaskDictionaries,
): EightBfTaskContextPayload | null {
if (!task?.id) return null;
return {
kind: "eightbf_roundtable_task",
usage:
"这是 8BF 页面左上角任务卡片中的任务信息。请直接基于这些信息理解用户问题,不要要求用户重复提供已经存在的任务名称、任务描述、任务方向或任务状态。",
task_id: task.id,
task: {
id: task.id,
title: task.title,
description: task.description,
direction: task.direction,
overview: task.overview,
priority: task.priority,
task_priority: task.taskPriority,
target_direction: task.targetDirection,
task_status: task.taskStatus,
},
task_tags: {
priority: dictionaryText(dictionaries.priority, task.taskPriority),
direction: dictionaryText(dictionaries.direction, task.targetDirection, "alias"),
status: dictionaryText(dictionaries.status, task.taskStatus),
},
};
}
@@ -0,0 +1,566 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useSearchParams } from "react-router-dom";
import {
ChevronDown,
ChevronLeft,
Loader2,
Settings2,
UserRoundCog,
} from "lucide-react";
import { toast } from "sonner";
import {
AnalysisResultCardList,
AnalysisResultDetailDialog,
type AnalysisResultCardItem,
} from "@/components/analysis-results";
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
import { useAgent, useAgents, type Agent } from "@/core/agents";
import backArrowIcon from "@/assets/eightbf-roundtable/back-arrow.svg";
import agentConfigLogo from "@/assets/position-roundtable/agent-config-logo.svg";
import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg";
import doubleArrowDecoration from "@/assets/position-roundtable/double-arrow-decoration.svg";
import {
parentReturnRoute,
requestEightBfParentReturn,
} from "@/eightbf-roundtable/lib/parent-return";
import { useEightBfAnalysisResults } from "@/eightbf-roundtable/lib/collect-md-artifacts";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
import {
positionNameById,
syncPositionOptionsFromRoles,
} from "@/position-roundtable/lib/positions";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import "@/position-roundtable/styles/intent-understanding.css";
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
import { usePositionRoles } from "@/strategy-components/api/position-roles";
import { cn } from "@/lib/utils";
type IntentRecord = Record<string, unknown>;
export interface EightBfStep1PanelProps extends Step1PanelProps {
chain: BusinessChain | null;
canConfigureChain: boolean;
onConfigureChain: () => void;
selectedModel: string;
modelOptions: Array<{ label: string; value: string }>;
onSelectedModelChange: (model: string) => void;
step2Dialogues?: unknown[];
step2ThreadIds?: ThreadIdMap | null;
step2CoordinatorName?: string | null;
step2SeatStubMessages?: Record<string, unknown[]>;
step3Summary?: DraftStep3SummarySnapshot | null;
}
export function EightBfRoundtableBackButton() {
const [searchParams] = useSearchParams();
const handleBack = () => {
const route = parentReturnRoute(
searchParams.get("parentReturnTo")
?? searchParams.get("parentRoute")
?? searchParams.get("returnTo")
?? "/list",
);
if (requestEightBfParentReturn(route)) return;
toast.warning("尚未配置返回地址,请在 runtime-config.js 填写 VITE_8BF_ROUNDTABLE_BACK_URL");
};
return (
<button
type="button"
className="eightbf-roundtable__back-button"
onClick={handleBack}
>
<img src={backArrowIcon} alt="" />
返回
</button>
);
}
export function EightBfStep1Panel({
chain,
canConfigureChain,
onConfigureChain,
selectedModel,
modelOptions,
onSelectedModelChange,
intentReady,
step2Dialogues = [],
step2ThreadIds = null,
step2CoordinatorName = null,
step2SeatStubMessages,
step3Summary = null,
...step1Props
}: EightBfStep1PanelProps) {
const [rightPanelOpen, setRightPanelOpen] = useState(true);
const [rightTab, setRightTab] = useState<"agents" | "results">("agents");
const openPanel = (tab: "agents" | "results") => {
if (rightPanelOpen && rightTab === tab) {
setRightPanelOpen(false);
return;
}
setRightTab(tab);
setRightPanelOpen(true);
};
return (
<div className="position-roundtable-intent-page eightbf-roundtable-intent-page contents">
<Step1Panel
{...step1Props}
rootClassName="position-roundtable-intent-panel eightbf-roundtable-intent-panel [&_#save-draft-btn]:hidden [&_#skip-question-btn]:hidden [&_#suggest-followup-btn]:hidden"
headerLeftSlot={
<div className="position-roundtable-intent-header-title">
<span aria-hidden="true" />
<h2>{chain?.title || "8BF 业务链"} · 意图识别</h2>
</div>
}
headerRightSlot={
<div className="position-roundtable-intent-header-actions">
<button
type="button"
className={cn(rightPanelOpen && rightTab === "agents" && "is-selected")}
onClick={() => openPanel("agents")}
>
智能体配置
</button>
<button
type="button"
className={cn(rightPanelOpen && rightTab === "results" && "is-selected")}
onClick={() => openPanel("results")}
>
分析结果
</button>
<EightBfRoundtableBackButton />
</div>
}
rightSidebar={
<aside
aria-hidden={!rightPanelOpen || undefined}
className={cn(
"position-roundtable-intent-config-pane eightbf-roundtable-intent-config-pane",
rightPanelOpen ? "is-open" : "is-closed",
)}
>
<div className="position-roundtable-intent-config-pane-inner">
<EightBfAgentWorkspace
tab={rightTab}
chain={chain}
canConfigureChain={canConfigureChain}
onConfigureChain={onConfigureChain}
onCollapse={() => setRightPanelOpen(false)}
step2Dialogues={step2Dialogues}
step2ThreadIds={step2ThreadIds}
step2CoordinatorName={step2CoordinatorName}
step2SeatStubMessages={step2SeatStubMessages}
step3Summary={step3Summary}
/>
</div>
</aside>
}
hideQuestionCount
inputArea={(inputProps) => (
<PositionIntentInput
{...inputProps}
modelOptions={modelOptions}
selectedModel={selectedModel}
onSelectedModelChange={onSelectedModelChange}
/>
)}
intentReady={intentReady}
/>
</div>
);
}
export function EightBfStep1Sidebar({
taskCard,
businessChain,
intentReady,
onCollapse,
}: {
taskCard?: ReactNode;
businessChain?: ReactNode;
intentReady: object | null;
onCollapse?: () => void;
}) {
return (
<div className="eightbf-roundtable__step1-sidebar position-roundtable-entry-sidebar">
{taskCard}
{onCollapse && !taskCard && (
<button
type="button"
className="position-roundtable-intent-config-collapse-btn ml-auto"
onClick={onCollapse}
aria-label="收起左侧面板"
title="收起左侧面板"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
)}
<EightBfIntentDelivery intentReady={intentReady} businessChain={businessChain} />
</div>
);
}
/** 第一、二步共用的意图结果面板,保持完全相同的展示结构和样式。 */
export function EightBfIntentDelivery({
intentReady,
businessChain,
}: {
intentReady: object | null;
businessChain?: ReactNode;
}) {
const sections = intentSections(intentReady);
return (
<section className="eightbf-roundtable__intent-delivery-sidebar position-roundtable-intent-delivery-sidebar" aria-labelledby="eightbf-intent-sidebar-title">
{sections.length > 0 && (
<div className="position-roundtable-intent-delivery-result">
<header className="position-roundtable-intent-delivery-header">
<span aria-hidden="true" />
<h2 id="eightbf-intent-sidebar-title">意图理解结果</h2>
</header>
<div className="position-roundtable-intent-delivery-sections">
{sections.map((section) => (
<article
key={section.label}
className={cn(
"position-roundtable-intent-delivery-section",
intentSectionTone(section.label),
)}
>
<h3>{section.label}</h3>
<ul>
{section.items.map((item) => <li key={item}>{item}</li>)}
</ul>
</article>
))}
</div>
</div>
)}
{businessChain}
</section>
);
}
/** 第二步不需要第一步的整列占位,只复用其任务卡和意图面板。 */
export function EightBfStep2SidebarHeader({
taskCard,
intentReady,
}: {
taskCard?: ReactNode;
intentReady: object | null;
}) {
return (
<div className="eightbf-roundtable__step2-sidebar-header">
{taskCard}
<EightBfIntentDelivery intentReady={intentReady} />
</div>
);
}
export function EightBfHeaderBrand({ chainTitle }: { chainTitle?: string }) {
return (
<div className="eightbf-roundtable__header-brand">
<span className="eightbf-roundtable__header-brand-icon" aria-hidden="true">
<UserRoundCog />
</span>
<div className="min-w-0">
<h1>8BF 多智能体会商</h1>
<p>按业务链承接任务、对话与分析产物</p>
</div>
<span className="eightbf-roundtable__header-status">
<span aria-hidden="true">⌁</span>
{chainTitle ? "业务链已启动" : "等待业务链配置"}
</span>
</div>
);
}
export function EightBfHeaderChain({ chainTitle }: { chainTitle?: string }) {
return (
<span className="eightbf-roundtable__header-chain" title={chainTitle || "尚未配置业务链"}>
<span>当前业务链</span>
<strong>{chainTitle || "暂未配置"}</strong>
<ChevronDown aria-hidden="true" />
</span>
);
}
function EightBfAgentWorkspace({
tab,
chain,
canConfigureChain,
onConfigureChain,
onCollapse,
step2Dialogues,
step2ThreadIds,
step2CoordinatorName,
step2SeatStubMessages,
step3Summary,
}: {
tab: "agents" | "results";
chain: BusinessChain | null;
canConfigureChain: boolean;
onConfigureChain: () => void;
onCollapse: () => void;
step2Dialogues: unknown[];
step2ThreadIds: ThreadIdMap | null;
step2CoordinatorName: string | null;
step2SeatStubMessages?: Record<string, unknown[]>;
step3Summary: DraftStep3SummarySnapshot | null;
}) {
const { agents, isLoading } = useAgents();
const { data: positionRoles = [] } = usePositionRoles();
useEffect(() => {
syncPositionOptionsFromRoles(positionRoles);
}, [positionRoles]);
const seats = chain?.seats ?? [];
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [agentKeyword, setAgentKeyword] = useState("");
const { agent: selectedAgent, isLoading: selectedAgentLoading } = useAgent(selectedAgentId);
const [detailItem, setDetailItem] = useState<AnalysisResultCardItem | null>(null);
const analysisItems = useEightBfAnalysisResults({
chain,
dialogues: step2Dialogues,
threadIds: step2ThreadIds,
coordinatorName: step2CoordinatorName,
seatStubMessages: step2SeatStubMessages,
summaryReport: step3Summary,
});
useEffect(() => {
if (selectedAgentId && !seats.some((seat) => seat.agent_id === selectedAgentId)) {
setSelectedAgentId(null);
}
}, [seats, selectedAgentId]);
const seatAgents = useMemo(() => {
const agentById = new Map(agents.map((agent) => [agent.id, agent]));
return seats.map((seat) => ({ seat, agent: agentById.get(seat.agent_id) ?? null }));
}, [agents, seats]);
const filteredSeatAgents = useMemo(() => {
const keyword = agentKeyword.trim().toLowerCase();
if (!keyword) return seatAgents;
return seatAgents.filter(({ seat, agent }) => {
const name = (agent?.name || seat.name || "").toLowerCase();
const description = (agent?.description || seat.description || "").toLowerCase();
return name.includes(keyword) || description.includes(keyword);
});
}, [agentKeyword, seatAgents]);
const selectedSeat = seats.find((seat) => seat.agent_id === selectedAgentId) ?? null;
if (tab === "results") {
return (
<section className="position-roundtable-intent-analysis-results eightbf-roundtable-intent-analysis-results">
<div className="position-roundtable-intent-analysis-header">
<span>分析成果列表</span>
<button
type="button"
className="position-roundtable-intent-config-collapse-btn"
onClick={onCollapse}
aria-label="收起分析结果"
title="收起分析结果"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
</div>
<div className="position-roundtable-intent-analysis-results-scroll eightbf-roundtable-intent-results-scroll">
<AnalysisResultCardList
items={analysisItems}
emptyTitle="暂无交付产物"
emptyDescription="完成第二步研讨与第三步方案总结并生成 Markdown 后,产物会显示在这里。"
padded={false}
onOpen={setDetailItem}
/>
</div>
<AnalysisResultDetailDialog
item={detailItem}
onOpenChange={(open) => {
if (!open) setDetailItem(null);
}}
/>
</section>
);
}
if (selectedAgentId) {
return (
<div className="position-roundtable-intent-config-workspace">
<div className="eightbf-roundtable__agent-detail-topbar">
<button type="button" onClick={() => setSelectedAgentId(null)}>
<ChevronLeft className="size-4" />
智能体列表
</button>
<button
type="button"
className="position-roundtable-intent-config-collapse-btn"
onClick={onCollapse}
aria-label="收起智能体配置"
title="收起智能体配置"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
</div>
<div className="position-roundtable-agent-detail position-roundtable-agent-detail--special h-[calc(100%-48px)] min-h-0">
{selectedAgentLoading ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-[#aaceff]">
<Loader2 className="size-4 animate-spin" /> 正在读取智能体配置…
</div>
) : (
<AgentDetailPanel
agent={selectedAgent}
agentName={selectedAgent?.name ?? selectedSeat?.name ?? ""}
onAskQuestion={() => toast.info("请在中间对话区向任务理解智能体发起问题")}
showRecentChats={false}
avatarSrc={agentConfigLogo}
positionName={positionNameById(selectedSeat?.position_id)}
jobResponsibilities={selectedSeat?.job_responsibilities ?? ""}
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
skillsTitle="技能列表"
showCommonQuestions
panelTitle="智能体配置"
embedded
hideStatusBadges
/>
)}
</div>
</div>
);
}
return (
<section className="eightbf-roundtable__agent-list-workspace">
<div className="position-roundtable-intent-analysis-header">
<span>智能体配置</span>
<div className="flex items-center gap-1.5">
{canConfigureChain && (
<button type="button" className="eightbf-roundtable__configure-chain-button" onClick={onConfigureChain}>
<Settings2 className="size-3.5" /> 配置业务链
</button>
)}
<button
type="button"
className="position-roundtable-intent-config-collapse-btn"
onClick={onCollapse}
aria-label="收起智能体配置"
title="收起智能体配置"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
</div>
</div>
<div className="eightbf-roundtable__agent-selector">
<header className="eightbf-roundtable__agent-selector-head">
<div className="eightbf-roundtable__agent-selector-title">
<b>当前岗位智能体</b>
<i>{seatAgents.length}</i>
</div>
<input
type="text"
value={agentKeyword}
onChange={(event) => setAgentKeyword(event.target.value)}
placeholder="搜索名称或说明"
className="eightbf-roundtable__agent-selector-search"
/>
</header>
{isLoading && seats.length > 0 ? (
<div className="eightbf-roundtable__agent-selector-empty">正在加载智能体…</div>
) : filteredSeatAgents.length === 0 ? (
<div className="eightbf-roundtable__agent-selector-empty">
<p>{seatAgents.length ? "没有符合条件的智能体" : "当前岗位尚未配置智能体"}</p>
{!seatAgents.length && canConfigureChain && (
<button type="button" onClick={onConfigureChain}>前往业务链配置</button>
)}
</div>
) : (
<div className="eightbf-roundtable__agent-selector-list">
{filteredSeatAgents.map(({ seat, agent }) => (
<EightBfAgentRow
key={seat.agent_id}
seat={seat}
agent={agent}
onSelect={() => setSelectedAgentId(seat.agent_id)}
/>
))}
</div>
)}
</div>
</section>
);
}
function EightBfAgentRow({
seat,
agent,
onSelect,
}: {
seat: ChainSeat;
agent: Agent | null;
onSelect: () => void;
}) {
const name = agent?.name || seat.name || "未命名智能体";
const description = agent?.description || seat.description || "智能体助手";
const avatarText = firstAgentAvatarChar(name);
return (
<button type="button" className="eightbf-roundtable__agent-selector-card" onClick={onSelect}>
<span className="eightbf-roundtable__agent-selector-avatar" aria-hidden="true">
{avatarText ? avatarText : <img src={agentConfigLogo} alt="" />}
</span>
<span className="eightbf-roundtable__agent-selector-main">
<strong>{name}</strong>
<p>{description}</p>
</span>
<i className="eightbf-roundtable__agent-selector-bar" aria-hidden="true" />
</button>
);
}
/** 名称首字若是【、( 这类标点,列表卡片改用智能体 logo,不把标点当头像。 */
function firstAgentAvatarChar(name: string): string | null {
const first = Array.from(name.trim())[0];
if (!first || /^[\p{P}\p{S}]$/u.test(first)) return null;
return first.toUpperCase();
}
function intentSections(intentReady: object | null): Array<{ label: string; items: string[] }> {
const source = intentReady && typeof intentReady === "object" ? intentReady as IntentRecord : null;
if (!source) return [];
const entries = [
["核心目标", arrayValue(source.coreGoals, source.objective)],
["风险提示", arrayValue(source.riskWarnings, source.constraints)],
["关键要点", arrayValue(source.keyPoints, source.assumptions)],
["战略意义", arrayValue(source.strategicSignificance)],
] as const;
return entries
.filter(([, items]) => items.length > 0)
.map(([label, items]) => ({ label, items }));
}
function intentSectionTone(label: string) {
switch (label) {
case "核心目标":
return "position-roundtable-intent-delivery-section--goals";
case "风险提示":
return "position-roundtable-intent-delivery-section--constraints";
case "关键要点":
return "position-roundtable-intent-delivery-section--purpose";
case "战略意义":
return "position-roundtable-intent-delivery-section--significance";
default:
return "position-roundtable-intent-delivery-section--significance";
}
}
function arrayValue(...values: unknown[]): string[] {
for (const value of values) {
if (Array.isArray(value)) {
const items = value.map((item) => String(item).trim()).filter(Boolean);
if (items.length > 0) return items;
}
if (typeof value === "string" && value.trim()) return [value.trim()];
}
return [];
}
@@ -0,0 +1,97 @@
import { cn } from "@/lib/utils";
import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg";
import {
dictionaryText,
type EightBfDictionaryOption,
type EightBfTask,
} from "@/eightbf-roundtable/api/task-context";
interface EightBfTaskCardProps {
task: EightBfTask | null;
dictionaries: {
priority: EightBfDictionaryOption[];
direction: EightBfDictionaryOption[];
status: EightBfDictionaryOption[];
};
loading?: boolean;
error?: string | null;
/** 第二步左栏使用,与“智能体配置”同款图标收起整列。 */
onCollapse?: () => void;
}
export function EightBfTaskCard({
task,
dictionaries,
loading = false,
error,
onCollapse,
}: EightBfTaskCardProps) {
const priority = dictionaryText(dictionaries.priority, task?.taskPriority);
const direction = dictionaryText(dictionaries.direction, task?.targetDirection, "alias");
const status = dictionaryText(dictionaries.status, task?.taskStatus);
// 外部任务接口不可用时,卡片使用与岗位会商相同的示例任务;标签也必须
// 保持可读,而不能暴露内部状态码。
const tags = task?.raw === null
? ["紧急", "本系统创建", "暑期活动"]
: [priority, status, direction].filter(Boolean);
return (
<section
className="position-roundtable-task-card eightbf-roundtable__task-card group relative shrink-0 overflow-hidden rounded-[4px] border border-[#ECE7E4] bg-white p-5 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[rgba(0,110,254,0.5)] dark:bg-[#032650] dark:shadow-none"
aria-labelledby="eightbf-task-card-title"
>
<div className="position-roundtable-task-card__content relative z-10">
{onCollapse && (
<button
type="button"
className="position-roundtable-intent-config-collapse-btn absolute right-0 top-0"
onClick={onCollapse}
aria-label="收起左侧面板"
title="收起左侧面板"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
)}
<h2
id="eightbf-task-card-title"
title={task?.title || task?.id || "等待任务信息"}
className="pr-8 text-[18px] font-medium leading-7 text-slate-900 dark:text-white"
>
<span className="line-clamp-1">{task?.title || (task?.id ? `任务 ${task.id}` : "等待任务信息")}</span>
</h2>
{tags.length > 0 && (
<div
data-task-tags
className="mt-3 flex flex-wrap gap-2 [&>span]:inline-flex [&>span]:min-h-7 [&>span]:items-center [&>span]:!leading-5 [&>span:first-child]:!border-[#cf1322] [&>span:nth-child(2)]:!shadow-none"
aria-label="任务标签"
>
{tags.map((tag, index) => (
<span
key={tag}
className={[
"rounded-[2px] border px-2 py-0.5 text-center text-[16px] font-light",
index === 0 && "border-[#cf1322] bg-[rgba(207,19,34,0.5)] leading-4 text-white",
index === 1 && "border-[#4bcdff] bg-[rgba(75,205,255,0.3)] leading-7 text-[#4bcdff] shadow-[0_0_9px_rgba(252,131,131,0.27)]",
index > 1 && "border-[#a2e1f9] bg-[rgba(162,225,249,0.3)] leading-[18px] text-[#a2e1f9]",
].filter(Boolean).join(" ")}
>
{tag}
</span>
))}
</div>
)}
<p
data-task-description
className={cn(
"mt-3 line-clamp-3 whitespace-pre-wrap text-sm leading-[22px] text-slate-600 dark:text-[#96bbf8]",
!task?.description && "is-empty",
)}
>
{task?.description || task?.overview || (loading ? "正在读取任务详情…" : "暂无任务描述")}
</p>
{error && <p className="eightbf-roundtable__task-card-error" role="status">{error}</p>}
</div>
</section>
);
}
@@ -0,0 +1,343 @@
import { useEffect, useMemo, useState } from "react";
import type { AnalysisResultCardItem } from "@/components/analysis-results";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import {
listSessionArtifacts,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
const MD_PATH_RE = /\.md$/i;
const SUMMARY_OUTPUT_PATH = "/mnt/user-data/outputs/方案总结报告.md";
type DialogueLike = {
sender?: string;
agentThreadId?: string;
time?: string;
steps?: Array<{ key?: string; args?: Record<string, unknown> }>;
};
interface MdFileHit {
path: string;
name: string;
threadId: string | null;
content: string | null;
updatedAt?: string;
agentName: string;
order?: number;
}
function isMarkdownPath(path: string): boolean {
return MD_PATH_RE.test(path.split("/").pop() ?? path);
}
function filenameOf(path: string): string {
return path.split("/").filter(Boolean).at(-1) || path;
}
function asString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function fileKey(threadId: string | null, path: string): string {
return `${threadId ?? ""}::${path}`;
}
function collectPathsFromSteps(
steps: DialogueLike["steps"],
): Array<{ path: string; content: string | null }> {
if (!Array.isArray(steps) || steps.length === 0) return [];
const out: Array<{ path: string; content: string | null }> = [];
const seen = new Set<string>();
const add = (path: string, content: string | null) => {
if (!path || seen.has(path) || !isMarkdownPath(path)) return;
seen.add(path);
out.push({ path, content });
};
for (const step of steps) {
if (step.key === "tool_calling:present_files") {
const fps = step.args?.filepaths;
if (Array.isArray(fps)) {
for (const fp of fps) add(asString(fp), null);
}
continue;
}
if (step.key === "tool_calling:write_file" || step.key === "tool_calling:str_replace") {
add(asString(step.args?.path), asString(step.args?.content) || null);
}
}
return out;
}
function collectFromDialogues(dialogues: unknown[]): MdFileHit[] {
const hits: MdFileHit[] = [];
let order = 0;
for (const raw of dialogues) {
if (!raw || typeof raw !== "object") continue;
const dialogue = raw as DialogueLike;
const sender = asString(dialogue.sender) || "智能体";
const threadId = asString(dialogue.agentThreadId) || null;
for (const file of collectPathsFromSteps(dialogue.steps)) {
hits.push({
path: file.path,
name: filenameOf(file.path),
threadId,
content: file.content,
updatedAt: asString(dialogue.time) || undefined,
agentName: sender,
order: order++,
});
}
}
return hits;
}
function collectFromStubs(stubs: Record<string, unknown[]> | undefined): MdFileHit[] {
const hits: MdFileHit[] = [];
for (const [threadId, messages] of Object.entries(stubs ?? {})) {
if (!Array.isArray(messages)) continue;
for (const message of messages) {
if (!message || typeof message !== "object") continue;
const calls = (message as { tool_calls?: unknown }).tool_calls;
if (!Array.isArray(calls)) continue;
for (const call of calls) {
if (!call || typeof call !== "object") continue;
const tool = call as { name?: unknown; args?: Record<string, unknown> };
if (asString(tool.name) !== "write_file") continue;
const path = asString(tool.args?.path);
if (!path || !isMarkdownPath(path)) continue;
hits.push({
path,
name: filenameOf(path),
threadId,
content: asString(tool.args?.content) || null,
agentName: "",
});
}
}
}
return hits;
}
function mergeHits(groups: MdFileHit[][]): Map<string, MdFileHit> {
const merged = new Map<string, MdFileHit>();
for (const group of groups) {
for (const hit of group) {
const key = fileKey(hit.threadId, hit.path);
const prev = merged.get(key);
if (!prev) {
merged.set(key, hit);
continue;
}
merged.set(key, {
path: hit.path || prev.path,
name: hit.name || prev.name,
threadId: hit.threadId || prev.threadId,
content: hit.content || prev.content,
updatedAt: hit.updatedAt || prev.updatedAt,
agentName: prev.agentName || hit.agentName,
order: Math.max(prev.order ?? 0, hit.order ?? 0),
});
}
}
return merged;
}
function latestHit(hits: MdFileHit[]): MdFileHit | null {
if (hits.length === 0) return null;
return hits.reduce((latest, hit) => {
const latestTime = Date.parse(latest.updatedAt ?? "") || 0;
const hitTime = Date.parse(hit.updatedAt ?? "") || 0;
if (hitTime !== latestTime) return hitTime > latestTime ? hit : latest;
return (hit.order ?? 0) >= (latest.order ?? 0) ? hit : latest;
});
}
function pendingSeatCard(seat: BusinessChain["seats"][number]): AnalysisResultCardItem {
const agentName = seat.name?.trim() || seat.agent_id;
return {
id: `eightbf:seat:${seat.agent_id}:pending`,
agentName,
productName: `待交付:${agentName} Markdown报告`,
positionName: agentName,
delivered: false,
fallbackTime: "待完成",
unavailableMessage: `「${agentName}」尚未交付可预览的 Markdown 文件。完成第二步研讨后,这里会直接展示产物。`,
};
}
function hitToCard(
hit: MdFileHit,
positionName: string,
id: string,
): AnalysisResultCardItem {
return {
id,
agentName: hit.agentName || positionName,
productName: hit.name,
positionName,
updatedAt: hit.updatedAt,
fallbackTime: "刚刚",
delivered: true,
artifactPath: hit.path,
artifactMimeType: "text/markdown",
artifactThreadId: hit.threadId,
artifactContent: hit.content,
};
}
export function collectEightBfAnalysisResults({
chain,
dialogues,
threadIds,
coordinatorName,
seatStubMessages,
summaryReport,
remoteFiles = [],
}: {
chain: BusinessChain | null;
dialogues: unknown[];
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
seatStubMessages?: Record<string, unknown[]>;
summaryReport: DraftStep3SummarySnapshot | null;
remoteFiles?: MdFileHit[];
}): AnalysisResultCardItem[] {
const merged = mergeHits([
collectFromDialogues(dialogues),
collectFromStubs(seatStubMessages),
]);
const seats = chain?.seats ?? [];
const coordinator = coordinatorName?.trim() || "";
const items: AnalysisResultCardItem[] = [];
const threadIdOfSeat = (seat: BusinessChain["seats"][number]): string | null => {
const byId = threadIds?.[seat.agent_id];
if (byId) return byId;
const byName = Object.entries(threadIds ?? {}).find(
([name]) => name.toLowerCase() === seat.agent_id.toLowerCase() || name === seat.name,
)?.[1];
return byName ?? null;
};
for (const seat of seats) {
if (coordinator && (seat.name === coordinator || seat.agent_id === coordinator)) continue;
const seatThreadId = threadIdOfSeat(seat);
const agentName = seat.name?.trim() || seat.agent_id;
const seatHits = [...merged.values()].filter((hit) => {
if (seatThreadId && hit.threadId === seatThreadId) return true;
return hit.agentName === agentName || hit.agentName === seat.agent_id;
});
const latest = latestHit(seatHits) ?? latestHit(
remoteFiles.filter((hit) => Boolean(seatThreadId) && hit.threadId === seatThreadId),
);
if (!latest) {
items.push(pendingSeatCard(seat));
continue;
}
const enriched = latest.content
? latest
: remoteFiles.find((hit) =>
hit.threadId === latest.threadId &&
(hit.path === latest.path || hit.name === latest.name),
) ?? latest;
items.push(hitToCard(enriched, agentName, `eightbf:seat:${seat.agent_id}:latest`));
}
const deliveredSeatCount = items.filter((item) => item.delivered).length;
if (summaryReport?.md.trim()) {
items.push({
id: "eightbf:summary",
agentName: "方案总结智能体",
productName: "方案总结报告.md",
positionName: "方案总结",
updatedAt: summaryReport.generatedAt,
fallbackTime: "刚刚",
delivered: true,
artifactPath: SUMMARY_OUTPUT_PATH,
artifactMimeType: "text/markdown",
artifactThreadId: summaryReport.threadId ?? null,
artifactContent: summaryReport.md,
summary: summaryReport.closing ?? summaryReport.md,
});
} else if (deliveredSeatCount > 0) {
items.push({
id: "eightbf:summary:pending",
agentName: "方案总结智能体",
productName: "待生成:方案总结报告.md",
positionName: "方案总结",
delivered: false,
fallbackTime: "待完成",
unavailableMessage: "第三步方案总结尚未生成 Markdown 报告。完成总结后,这里会直接展示产物。",
});
}
return items;
}
export function useEightBfAnalysisResults(input: {
chain: BusinessChain | null;
dialogues: unknown[];
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
seatStubMessages?: Record<string, unknown[]>;
summaryReport: DraftStep3SummarySnapshot | null;
}): AnalysisResultCardItem[] {
const [remoteFiles, setRemoteFiles] = useState<MdFileHit[]>([]);
const threadEntries = useMemo(
() => Object.entries(input.threadIds ?? {}).filter(([, threadId]) => Boolean(threadId)),
[input.threadIds],
);
useEffect(() => {
if (threadEntries.length === 0) {
setRemoteFiles([]);
return;
}
const abort = new AbortController();
let cancelled = false;
void Promise.all(
threadEntries.map(async ([agentName, threadId]) => {
try {
const res = await listSessionArtifacts(threadId, { signal: abort.signal });
return res.files
.filter((file) => isMarkdownPath(file.path) || isMarkdownPath(file.name))
.map((file) => ({
path: file.path,
name: file.name || filenameOf(file.path),
threadId,
content: null,
updatedAt: file.modifiedAt,
agentName,
}));
} catch {
return [] as MdFileHit[];
}
}),
).then((groups) => {
if (!cancelled) setRemoteFiles(groups.flat());
});
return () => {
cancelled = true;
abort.abort();
};
}, [threadEntries]);
return useMemo(
() =>
collectEightBfAnalysisResults({
...input,
remoteFiles,
}),
[
input.chain,
input.coordinatorName,
input.dialogues,
input.seatStubMessages,
input.summaryReport,
input.threadIds,
remoteFiles,
],
);
}
@@ -0,0 +1,128 @@
import { useEffect } from "react";
import { emitWujieEvent, isWujieEnv } from "@/core/embed/wujie";
import { getEightBfRoundtableBackUrl } from "@/eightbf-roundtable/api/task-context";
export const EIGHTBF_PARENT_RETURN_SOURCE = "deerflow-8bf-roundtable";
export const EIGHTBF_PARENT_RETURN_TYPE = "deerflow:8bf-roundtable:return";
export const EIGHTBF_BRIDGE_HOST_SOURCE = "code-zk-roundtable-host";
export const EIGHTBF_BRIDGE_INIT_TYPE = "code-zk:8bf-roundtable-bridge:init";
export const EIGHTBF_BRIDGE_READY_TYPE = "deerflow:8bf-roundtable:bridge-ready";
export interface EightBfParentReturnMessage {
source: typeof EIGHTBF_PARENT_RETURN_SOURCE;
type: typeof EIGHTBF_PARENT_RETURN_TYPE;
payload: {
route: string | null;
fallback: "history-back";
};
}
type EightBfBridgeInitMessage = {
source: typeof EIGHTBF_BRIDGE_HOST_SOURCE;
type: typeof EIGHTBF_BRIDGE_INIT_TYPE;
};
/**
* `parentReturnTo` belongs to the embedding application. Keep it a route/hash
* path so same-origin fallback cannot become an open redirect.
*/
export function parentReturnRoute(value: string | null | undefined): string | null {
const route = value?.trim();
if (!route || route.length > 2048) return null;
if ((!route.startsWith("/") && !route.startsWith("#")) || route.startsWith("//") || route.includes("\\")) {
return null;
}
return route;
}
function isInIframe(): boolean {
if (typeof window === "undefined") return false;
try {
return window.parent !== window;
} catch {
return true;
}
}
const embeddingBridgePort = { current: null as MessagePort | null };
/**
* Accept a MessagePort from code-zk. Wujie can lift this iframe into the
* outer host document, so `window.parent` is no longer code-zk. The port
* still talks to the Vue page that created the channel.
*/
export function useEightBfParentReturnBridge(): void {
useEffect(() => {
const receiveBridgePort = (event: MessageEvent<EightBfBridgeInitMessage>) => {
const message = event.data;
const bridgePort = event.ports?.[0];
if (
message?.source !== EIGHTBF_BRIDGE_HOST_SOURCE
|| message?.type !== EIGHTBF_BRIDGE_INIT_TYPE
|| !bridgePort
) {
return;
}
embeddingBridgePort.current?.close();
embeddingBridgePort.current = bridgePort;
bridgePort.start?.();
bridgePort.postMessage({
source: EIGHTBF_PARENT_RETURN_SOURCE,
type: EIGHTBF_BRIDGE_READY_TYPE,
});
};
window.addEventListener("message", receiveBridgePort);
return () => {
window.removeEventListener("message", receiveBridgePort);
embeddingBridgePort.current?.close();
embeddingBridgePort.current = null;
};
}, []);
}
/**
* Ask the embedding Vue host to leave this iframe.
*
* Topology: Wujie host → code-zk → DeerFlow iframe. Wujie may reparent the
* iframe so native `window.parent.postMessage` never reaches code-zk.
* MessageChannel is the channel that survives that. Native postMessage is
* only the fallback for a plain iframe.
*/
export function requestEightBfParentReturn(route: string | null): boolean {
const message: EightBfParentReturnMessage = {
source: EIGHTBF_PARENT_RETURN_SOURCE,
type: EIGHTBF_PARENT_RETURN_TYPE,
payload: { route, fallback: "history-back" },
};
if (typeof window === "undefined") return false;
const bridgePort = embeddingBridgePort.current;
if (bridgePort) {
try {
bridgePort.postMessage(message);
return true;
} catch {
// Host unmounted/reloaded. Fall through to native bridges.
}
}
if (isInIframe()) {
window.parent.postMessage(message, "*");
return true;
}
if (isWujieEnv()) {
emitWujieEvent("8bf-roundtable:return", message.payload);
return true;
}
const url = getEightBfRoundtableBackUrl().trim();
if (url) {
window.location.href = url;
return true;
}
return false;
}
@@ -1,11 +1,31 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Loader2, Save, Settings2, X } from "lucide-react"; import { useSearchParams } from "react-router-dom";
import { Loader2, Save, X } from "lucide-react";
import { getAccountDisplayName, getStoredAuth } from "@/core/auth"; import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard";
import {
EightBfStep1Panel,
EightBfStep1Sidebar,
EightBfStep2SidebarHeader,
} from "@/eightbf-roundtable/components/EightBfStep1Workspace";
import {
buildEightBfTaskContext,
createEightBfFallbackTask,
fetchEightBfTask,
fetchEightBfTaskDictionaries,
normalizeEightBfTask,
createEightBfTaskProcessReport,
updateEightBfTaskProcessReport,
type EightBfTask,
type EightBfTaskDictionaries,
} from "@/eightbf-roundtable/api/task-context";
import type { TaskArtifactSubmissionConfig } from "@/roundtable-planning/lib/task-artifact-submission";
import { PositionBusinessChainFlow } from "@/position-roundtable/components/PositionBusinessChainFlow"; import { PositionBusinessChainFlow } from "@/position-roundtable/components/PositionBusinessChainFlow";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage"; import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import { listChains } from "@/roundtable-planning/api/chains"; import { listChains } from "@/roundtable-planning/api/chains";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain"; import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
import { useEightBfParentReturnBridge } from "@/eightbf-roundtable/lib/parent-return";
import { import {
listBusinessMappings, listBusinessMappings,
updateBusinessMapping, updateBusinessMapping,
@@ -25,11 +45,30 @@ const BUSINESS_CODE = "8BF";
* 不引用岗位会商的页面、会话或接口。 * 不引用岗位会商的页面、会话或接口。
*/ */
export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) { export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) {
useEightBfParentReturnBridge();
const auth = getStoredAuth(); const auth = getStoredAuth();
const [searchParams] = useSearchParams();
const taskId = useMemo(
() => searchParams.get("taskId")?.trim() || "",
[searchParams],
);
// 8BF 公共业务链由指定账号维护,不依赖外部透传的管理员角色。 // 8BF 公共业务链由指定账号维护,不依赖外部透传的管理员角色。
// 与敏感词管理使用同一可信账户展示名规则(登录邮箱 @ 前缀)。 // 与敏感词管理使用同一可信账户展示名规则(登录邮箱 @ 前缀)。
const canConfigureChain = getAccountDisplayName(auth).toLowerCase() === "lqq"; const canConfigureChain = getAccountDisplayName(auth).toLowerCase() === "lqq";
const storageTaskId = useMemo(() => `8bf:${auth?.user_id || "anonymous"}`, [auth?.user_id]); const storageTaskId = useMemo(
() => `8bf:${auth?.user_id || "anonymous"}:${taskId || "anonymous"}`,
[auth?.user_id, taskId],
);
const [task, setTask] = useState<EightBfTask | null>(() => (
taskId ? normalizeEightBfTask({}, taskId) : null
));
const [taskDictionaries, setTaskDictionaries] = useState<EightBfTaskDictionaries>({
priority: [],
direction: [],
status: [],
});
const [taskLoading, setTaskLoading] = useState(Boolean(taskId));
const [taskError, setTaskError] = useState<string | null>(null);
const [mapping, setMapping] = useState<BusinessMapping | null>(null); const [mapping, setMapping] = useState<BusinessMapping | null>(null);
const [chains, setChains] = useState<BusinessChain[]>([]); const [chains, setChains] = useState<BusinessChain[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -41,6 +80,64 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
() => chains.find((chain) => chain.id === mapping?.chainId) ?? null, () => chains.find((chain) => chain.id === mapping?.chainId) ?? null,
[chains, mapping?.chainId], [chains, mapping?.chainId],
); );
const taskContext = useMemo(
() => buildEightBfTaskContext(task, taskDictionaries),
[task, taskDictionaries],
);
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
() => taskId ? {
taskId,
position: 0,
createProcessReport: createEightBfTaskProcessReport,
updateProcessReport: updateEightBfTaskProcessReport,
} : undefined,
[taskId],
);
useEffect(() => {
if (!taskId) {
setTask(null);
setTaskError(null);
setTaskLoading(false);
setTaskDictionaries({ priority: [], direction: [], status: [] });
return;
}
let cancelled = false;
setTaskLoading(true);
setTaskError(null);
setTask(normalizeEightBfTask({}, taskId));
let pendingRequests = 2;
const markRequestFinished = () => {
pendingRequests -= 1;
if (!cancelled && pendingRequests === 0) setTaskLoading(false);
};
// Keep detail and dictionary requests independent: a missing dictionary
// must not prevent the task card or task context from being available.
void fetchEightBfTask(taskId)
.then((nextTask) => {
if (!cancelled) setTask(nextTask);
})
.catch((error) => {
if (cancelled) return;
console.warn("[8bf] 任务详情请求失败,使用页面示例任务", error);
setTask(createEightBfFallbackTask(taskId));
setTaskError(null);
})
.finally(markRequestFinished);
void fetchEightBfTaskDictionaries()
.then((nextDictionaries) => {
if (!cancelled) setTaskDictionaries(nextDictionaries);
})
.catch(() => {
// fetchEightBfTaskDictionaries already degrades each dictionary to an
// empty list; this guard protects future adapter changes.
})
.finally(markRequestFinished);
return () => {
cancelled = true;
};
}, [taskId]);
const loadConfiguration = useCallback(async () => { const loadConfiguration = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
@@ -94,18 +191,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
} }
}, [canConfigureChain, chains, selectedChainId]); }, [canConfigureChain, chains, selectedChainId]);
const openChainSettings = useCallback(() => {
if (canConfigureChain) setIsSettingsOpen(true);
}, [canConfigureChain]);
const toolbarSlot = ( const toolbarSlot = (
<> <>
{canConfigureChain && (
<button
type="button"
className="eightbf-roundtable__chain-settings-button"
onClick={() => setIsSettingsOpen(true)}
>
<Settings2 className="h-3.5 w-3.5" />
配置业务链
</button>
)}
{canConfigureChain && isSettingsOpen && ( {canConfigureChain && isSettingsOpen && (
<div className="eightbf-roundtable__settings-mask" role="presentation"> <div className="eightbf-roundtable__settings-mask" role="presentation">
<section <section
@@ -157,12 +248,79 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
); );
return ( return (
<RoundtablePlanningPage // 仅将 8BF 会商页锁为深蓝色:全局主题状态和切换逻辑保持原样,
// 后续去掉本容器与 forceDarkAppearance 即可恢复随主题切换。
<div className="dark flex h-full min-h-0 flex-col bg-[#061b3b]">
<RoundtablePlanningPage
compactChrome={compactChrome} compactChrome={compactChrome}
forceDarkAppearance
lockedChainId={mapping?.chainId ?? null} lockedChainId={mapping?.chainId ?? null}
lockBusinessChain lockBusinessChain
storageTaskId={storageTaskId} storageTaskId={storageTaskId}
taskContext={taskContext as Record<string, unknown> | null}
entryRailTaskId={taskId || null}
entryRailTaskCardSlot={taskId ? (
<EightBfTaskCard
task={task}
dictionaries={taskDictionaries}
loading={taskLoading}
error={taskError}
/>
) : undefined}
toolbarSlot={toolbarSlot} toolbarSlot={toolbarSlot}
hideHeaderSaveDraft
hideHeaderSettings
newTaskLabel="新建会话"
hideDefaultStep1RightSidebar
step2HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 多智能体会商`}
step3HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 方案总结`}
renderStep1={(props) => (
<EightBfStep1Panel
{...props}
chain={configuredChain}
canConfigureChain={canConfigureChain}
onConfigureChain={openChainSettings}
/>
)}
renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => (
<EightBfStep1Sidebar
intentReady={intentReady}
onCollapse={onCollapse}
taskCard={taskId ? (
<EightBfTaskCard
task={task}
dictionaries={taskDictionaries}
loading={taskLoading}
error={taskError}
onCollapse={onCollapse}
/>
) : undefined}
businessChain={
<PositionBusinessChainFlow
chain={configuredChain}
loading={isLoading}
embedded
submittedSeatKeys={submittedSeatKeys}
onSeatClick={onSeatClick}
className="position-roundtable-intent-delivery-flow eightbf-roundtable__business-chain-flow"
/>
}
/>
)}
renderStep2LeftSidebarHeader={({ intentReady, onCollapse }) => (
<EightBfStep2SidebarHeader
intentReady={intentReady}
taskCard={taskId ? (
<EightBfTaskCard
task={task}
dictionaries={taskDictionaries}
loading={taskLoading}
error={taskError}
onCollapse={onCollapse}
/>
) : undefined}
/>
)}
entryRailBusinessChainSlot={ entryRailBusinessChainSlot={
<PositionBusinessChainFlow <PositionBusinessChainFlow
chain={configuredChain} chain={configuredChain}
@@ -171,6 +329,8 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
/> />
} }
rootClassName="eightbf-roundtable" rootClassName="eightbf-roundtable"
/> taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
/>
</div>
); );
} }
@@ -17,14 +17,409 @@
font-size: 12px; font-size: 12px;
} }
.eightbf-roundtable .position-roundtable-intent-header-actions button:nth-child(2) {
width: 100px;
}
.eightbf-roundtable .position-roundtable-intent-header-actions .eightbf-roundtable__back-button {
width: 86.04px;
height: 28px;
gap: 4px;
padding: 0 8px;
background: linear-gradient(109.46deg, rgba(0, 98, 217, 0.59) 3.83%, rgba(0, 98, 217, 0.3) 98.23%);
border-radius: 20px;
}
.eightbf-roundtable .position-roundtable-intent-header-actions .eightbf-roundtable__back-button img {
width: 14px;
height: 12px;
flex: 0 0 auto;
}
.eightbf-roundtable .eightbf-roundtable__chain-status-label { color: #0f5f56; font-weight: 700; } .eightbf-roundtable .eightbf-roundtable__chain-status-label { color: #0f5f56; font-weight: 700; }
.eightbf-roundtable .eightbf-roundtable__chain-status-value { color: #134e4a; font-weight: 600; } .eightbf-roundtable .eightbf-roundtable__chain-status-value { color: #134e4a; font-weight: 600; }
.eightbf-roundtable .eightbf-roundtable__configured-icon { color: var(--eightbf-accent); } .eightbf-roundtable .eightbf-roundtable__configured-icon { color: var(--eightbf-accent); }
/* 8BF 左上业务链区域:复用岗位会商的纵向节点、点阵画布与放大查看交互。 */ /* 8BF 左上业务链区域:复用岗位会商的纵向节点、点阵画布与放大查看交互。 */
.eightbf-roundtable .eightbf-roundtable__business-chain-flow { .eightbf-roundtable .eightbf-roundtable__business-chain-flow {
height: 500px; height: 430px;
min-height: 460px; min-height: 430px;
} }
/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */ /* 8BF 外部 taskId:左上任务卡。仅挂在 8BF 根节点下,避免改动原会商任务卡。 */
.eightbf-roundtable .eightbf-roundtable__task-card {
flex: 0 0 auto;
min-width: 0;
}
.eightbf-roundtable .eightbf-roundtable__task-card-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.eightbf-roundtable .eightbf-roundtable__task-card-tags {
margin-top: 12px;
}
.eightbf-roundtable .eightbf-roundtable__task-card-tags span {
border-radius: 2px;
}
.eightbf-roundtable .eightbf-roundtable__task-card-description {
margin: 12px 0 0;
}
.eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #94a3b8; }
.eightbf-roundtable .eightbf-roundtable__task-card-error { margin: 8px 0 0; color: #fca5a5; font-size: 12px; line-height: 1.5; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #8fa8c5; }
/*
* 8BF 第一阶段直接采用「岗位多智能体会商」意图工作台的三栏尺寸和深蓝表面。
* 专属类只改变 8BF;岗位会商原有页面继续由 intent-understanding.css 管理。
*/
.eightbf-roundtable .eightbf-roundtable__header-brand {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 12px;
}
.eightbf-roundtable .eightbf-roundtable__header-brand-icon {
display: inline-flex;
width: 42px;
height: 42px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: 14px;
background: rgb(43 143 254 / 16%);
color: #2b8ffe;
}
.eightbf-roundtable .eightbf-roundtable__header-brand-icon svg { width: 23px; height: 23px; }
.eightbf-roundtable .eightbf-roundtable__header-brand h1 { margin: 0; color: #1e293b; font-size: 16px; font-weight: 700; }
.eightbf-roundtable .eightbf-roundtable__header-brand p { margin: 2px 0 0; color: #64748b; font-size: 12px; }
.eightbf-roundtable .eightbf-roundtable__header-status {
display: inline-flex;
align-items: center;
gap: 5px;
margin-left: 8px;
border: 1px solid rgb(16 185 129 / 30%);
border-radius: 999px;
background: rgb(16 185 129 / 8%);
color: #047857;
padding: 5px 10px;
font-size: 12px;
font-weight: 700;
}
.eightbf-roundtable .eightbf-roundtable__header-chain {
display: inline-flex;
min-width: 230px;
max-width: 360px;
align-items: center;
gap: 8px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
color: #64748b;
padding: 8px 10px;
font-size: 12px;
}
.eightbf-roundtable .eightbf-roundtable__header-chain strong { min-width: 0; flex: 1; overflow: hidden; color: #334155; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.eightbf-roundtable .eightbf-roundtable__header-chain svg { width: 16px; height: 16px; flex: 0 0 auto; }
/* 参考工作台的直角圆角层级:内容面、消息区与输入框均为 4px。 */
.eightbf-roundtable #middle-viewport { border-radius: 4px; }
.eightbf-roundtable .position-roundtable-intent-panel > div:first-child,
.eightbf-roundtable .position-roundtable-intent-composer-frame,
.eightbf-roundtable .position-roundtable-intent-config-pane { border-radius: 4px; }
.eightbf-roundtable .position-roundtable-intent-panel > div:first-child { border-radius: 4px 4px 0 0; }
.eightbf-roundtable .eightbf-roundtable__step1-sidebar {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 12px;
overflow: hidden;
padding-right: 0;
}
/* 8BF 首步左栏不允许横向滚动;流程图的横向超出由画布自身裁切。 */
.eightbf-roundtable[data-roundtable-step="1"] #left-sidebar,
.eightbf-roundtable .eightbf-roundtable__step1-sidebar,
.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar {
overflow-x: hidden !important;
}
.eightbf-roundtable[data-roundtable-step="1"] #left-sidebar::-webkit-scrollbar:horizontal,
.eightbf-roundtable .eightbf-roundtable__step1-sidebar::-webkit-scrollbar:horizontal,
.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar::-webkit-scrollbar:horizontal {
display: none;
height: 0;
}
.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar {
min-height: 0;
overflow-y: auto;
}
.eightbf-roundtable .eightbf-roundtable__intent-sidebar-empty {
margin: 0;
padding: 12px 16px 18px;
color: #aaceff;
font-size: 14px;
line-height: 22px;
}
/* The flow is embedded into the intent surface: only its own header line and
dotted canvas are visible. The surrounding intent panel owns the border. */
.eightbf-roundtable .position-roundtable-intent-delivery-flow {
/* Reference flow has horizontal gutters. Reduce the width by those gutters
so the right-side magnifier is never clipped by the left-rail crop. */
width: calc(100% - 24px) !important;
height: 580px !important;
min-height: 580px !important;
flex-basis: 580px !important;
}
.eightbf-roundtable .position-roundtable-intent-delivery-flow > div:first-child {
margin: 0 0 12px !important;
}
.eightbf-roundtable .eightbf-roundtable__agent-list-workspace {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
background: #032650;
}
.eightbf-roundtable .eightbf-roundtable__configure-chain-button {
display: inline-flex;
height: 28px;
align-items: center;
gap: 4px;
border: 1px solid rgb(115 200 255 / 52%);
border-radius: 4px;
background: rgb(0 98 217 / 22%);
color: #dceaff;
padding: 0 8px;
font-size: 11px;
}
.eightbf-roundtable .eightbf-roundtable__configure-chain-button:hover { background: rgb(0 98 217 / 40%); }
.eightbf-roundtable .eightbf-roundtable__agent-selector {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 12px;
overflow: hidden;
padding: 16px;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-head {
display: flex;
flex-direction: column;
gap: 8px;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-title {
display: inline-flex;
align-items: center;
gap: 8px;
color: #fff;
font-size: 14px;
font-weight: 500;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-title b { font-weight: 500; }
.eightbf-roundtable .eightbf-roundtable__agent-selector-title i {
min-width: 18px;
padding: 0 5px;
border-radius: 9px;
color: #aaceff;
background: rgb(21 66 128 / 58%);
font-size: 12px;
font-style: normal;
line-height: 18px;
text-align: center;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-search {
width: 100%;
box-sizing: border-box;
padding: 6px 10px;
border: 1px solid rgb(43 143 254 / 40%);
border-radius: 4px;
color: #fff;
background: rgb(0 12 28 / 40%);
font-size: 13px;
outline: none;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-search::placeholder { color: rgb(255 255 255 / 40%); }
.eightbf-roundtable .eightbf-roundtable__agent-selector-search:focus { border-color: #45c3f3; }
.eightbf-roundtable .eightbf-roundtable__agent-selector-list {
display: grid;
min-height: 0;
flex: 1;
align-content: start;
gap: 8px;
overflow-x: hidden;
overflow-y: auto;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-card {
position: relative;
display: grid;
width: 100%;
grid-template-columns: 40px 1fr;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border: 1px solid rgb(43 143 254 / 20%);
border-radius: 8px;
color: inherit;
font: inherit;
text-align: left;
background: rgb(0 98 217 / 8%);
cursor: pointer;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-card:hover {
border-color: rgb(43 143 254 / 60%);
background: rgb(0 98 217 / 22%);
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-card:hover .eightbf-roundtable__agent-selector-bar {
background: #45c3f3;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-avatar {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border-radius: 8px;
color: #fff;
background: linear-gradient(135deg, #1675d1, #06447f);
font-size: 16px;
font-weight: 700;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-avatar img {
width: 22px;
height: 22px;
object-fit: contain;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-main strong {
overflow: hidden;
color: #fff;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 15px;
font-weight: 700;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-main p {
margin: 0;
overflow: hidden;
color: #9ebfe2;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-bar {
position: absolute;
top: 50%;
right: 6px;
width: 6px;
height: 32px;
border-radius: 3px;
background: transparent;
transform: translateY(-50%);
transition: background 0.15s ease;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-empty {
display: flex;
min-height: 200px;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 32px 16px;
color: rgb(255 255 255 / 60%);
font-size: 13px;
text-align: center;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-empty p { margin: 0; }
.eightbf-roundtable .eightbf-roundtable__agent-selector-empty button {
padding: 6px 16px;
border: 1px solid rgb(43 143 254 / 60%);
border-radius: 4px;
color: #fff;
background: rgb(0 98 217 / 18%);
cursor: pointer;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-empty button:hover {
background: rgb(0 98 217 / 32%);
}
.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar { display: flex; height: 48px; align-items: center; justify-content: space-between; border-bottom: 1px solid rgb(43 143 254 / 35%); padding: 0 12px; }
.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar > button:first-child { display: inline-flex; align-items: center; gap: 2px; color: #aaceff; font-size: 12px; }
.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar > button:first-child:hover { color: #fff; }
.eightbf-roundtable .position-roundtable-intent-config-pane-inner {
width: 100% !important;
max-width: 100%;
min-width: 0;
overflow: hidden;
}
.eightbf-roundtable .eightbf-roundtable-intent-results-scroll {
min-width: 0;
max-width: 100%;
overflow-x: hidden;
padding: 10px;
}
.eightbf-roundtable .eightbf-roundtable__intent-results-empty { margin: 0; color: #aaceff; font-size: 13px; line-height: 1.8; }
.eightbf-roundtable .eightbf-roundtable__intent-results-list { display: grid; gap: 10px; }
.eightbf-roundtable .eightbf-roundtable__intent-results-list section { border: 1px solid rgb(43 143 254 / 32%); border-radius: 4px; background: rgb(0 12 28 / 20%); padding: 12px; }
.eightbf-roundtable .eightbf-roundtable__intent-results-list h3 { margin: 0 0 8px; color: #a2e1f9; font-size: 14px; font-weight: 500; }
.eightbf-roundtable .eightbf-roundtable__intent-results-list ul { margin: 0; padding-left: 17px; color: #dceaff; font-size: 12px; line-height: 1.8; }
/* Screenshot-equivalent dark workbench shell. */
.dark .eightbf-roundtable { background: #061b3b; color: #e7f0ff; }
.dark .eightbf-roundtable > header { min-height: 64px; border-color: rgb(43 143 254 / 45%); background: #032650; box-shadow: none; }
.dark .eightbf-roundtable .eightbf-roundtable__header-brand h1 { color: #e7f0ff; }
.dark .eightbf-roundtable .eightbf-roundtable__header-brand p { color: #8aa0c4; }
.dark .eightbf-roundtable .eightbf-roundtable__header-status { border-color: rgb(101 214 198 / 45%); background: rgb(15 118 110 / 24%); color: #9ef0df; }
.dark .eightbf-roundtable .eightbf-roundtable__header-chain { border-color: rgb(143 196 255 / 44%); background: #082b57; color: #aaceff; }
.dark .eightbf-roundtable .eightbf-roundtable__header-chain strong { color: #e7f0ff; }
.dark .eightbf-roundtable #new-task-btn { border: 1px solid rgb(115 200 255 / 45%); border-radius: 4px; background: transparent; color: #e7f0ff; box-shadow: none; }
.dark .eightbf-roundtable #new-task-btn:hover { background: rgb(0 98 217 / 24%); }
.dark .eightbf-roundtable[data-roundtable-step="1"] > main { gap: 0; padding: 12px 0; }
.dark .eightbf-roundtable[data-roundtable-step="1"] #middle-viewport { border-color: rgb(43 143 254 / 50%); border-radius: 4px; background: #032650; box-shadow: none; }
.dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar {
width: 374px;
overflow: hidden;
border: 0;
border-radius: 0;
background: transparent;
padding: 0 12px;
}
.dark .eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar {
border-color: rgb(0 110 254 / 50%);
background: #032650;
}
@media (max-width: 1280px) {
.eightbf-roundtable .eightbf-roundtable__header-chain { min-width: 180px; max-width: 220px; }
.dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar { width: 326px; }
.eightbf-roundtable .position-roundtable-intent-config-pane { --prt-intent-config-width: 320px; }
}
@media (max-width: 1024px) {
.dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar { width: 294px; }
.eightbf-roundtable .eightbf-roundtable__header-status,
.eightbf-roundtable .eightbf-roundtable__header-brand p { display: none; }
.eightbf-roundtable .eightbf-roundtable__header-chain { display: none; }
.eightbf-roundtable .position-roundtable-intent-config-pane { --prt-intent-config-width: min(360px, 42vw); }
}
@@ -14,4 +14,125 @@
.eightbf-roundtable .eightbf-roundtable__chain-settings-button:hover { background: #115e59; } .eightbf-roundtable .eightbf-roundtable__chain-settings-button:hover { background: #115e59; }
/* 可在此继续覆盖会商消息、席位卡片、流程节点及展开按钮的样式。 */ /* 展开时工作区紧贴左栏;收起时恢复通用的 16px 间距,避免窄栏贴着内容区。 */
.dark .eightbf-roundtable[data-roundtable-step="2"] > main[data-step2-sidebar-expanded="true"] {
gap: 0;
}
.dark .eightbf-roundtable[data-roundtable-step="2"] > main[data-step2-sidebar-expanded="false"] {
gap: 10px;
}
/* 第二步左栏顶部直接复用第一步任务卡和意图理解区,但不复用第一步整列的
flex: 1 行为;内容高度随意图实际内容收拢,剩余内容交给左栏统一滚动。 */
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable__step2-sidebar-header {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 12px;
min-height: 0;
overflow: visible;
}
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable__step2-sidebar-header
.eightbf-roundtable__intent-delivery-sidebar {
flex: 0 0 auto;
overflow: visible;
}
/* 角色面板与列表均依内容撑开,再交由左栏的单一滚动容器承载;否则取消
内层滚动后,角色项会从固定高度的父面板溢出并覆盖下方内容。 */
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .eightbf-roundtable__step2-sidebar-header + div {
flex: 0 0 auto !important;
min-height: 0 !important;
}
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .custom-scrollbar {
flex: 0 0 auto;
overflow: visible;
padding-right: 0;
}
/* 8BF 第二步沿用第一步的 4px 面板语言,避免通用会商的 rounded-2xl 混入。 */
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .rounded-2xl {
border-radius: 4px !important;
}
/* 展开态与第一步左栏保持同一宽度和内边距。注意不能无条件锁定宽度,否则会
覆盖收起态的 w-[56px],使顶部图标按钮看起来无效。 */
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] {
width: 374px;
padding: 0 12px;
}
/* 收起态也要预留与展开态一致的左右边距。此前 56px 宽度叠加内边距后,图标卡只剩
约 36px 可用空间,导致图标与边框被挤变形。80px 壳体保留 56px 的完整点击区域。 */
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="false"] {
width: 80px;
padding: 0 12px;
}
/* 第二步左侧各级内容卡沿用 8BF 的 4px 圆角;头像、状态徽标仍保持圆形。 */
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .rounded-2xl,
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .eightbf-roundtable__step2-sidebar-header > .rounded-2xl,
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .roundtable-step2-role-row,
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .roundtable-step2-human-row,
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .roundtable-step2-run-card {
border-radius: 4px !important;
}
.dark .eightbf-roundtable[data-roundtable-step="2"] > main {
padding: 12px 0;
}
.dark .eightbf-roundtable[data-roundtable-step="2"] #middle-viewport {
border-color: rgb(43 143 254 / 50%);
border-radius: 4px;
background: #032650;
box-shadow: none;
}
.eightbf-roundtable .eightbf-roundtable-step2-chat {
min-height: 0;
background: #032650;
}
.eightbf-roundtable .eightbf-roundtable-step2-log {
background: #032650;
border: 0;
box-shadow: none;
}
.eightbf-roundtable .eightbf-roundtable-step2-chat .position-roundtable-intent-header-actions .eightbf-roundtable__suspend-button,
.eightbf-roundtable .eightbf-roundtable-step2-chat .position-roundtable-intent-header-actions .eightbf-roundtable__pause-button,
.eightbf-roundtable .eightbf-roundtable-step2-chat .position-roundtable-intent-header-actions .eightbf-roundtable__resume-button {
width: 100px;
}
.eightbf-roundtable .eightbf-roundtable-step2-chat .relative.flex-1 > .shrink-0 {
background: #032650;
border-color: rgb(43 143 254 / 28%);
}
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-log .text-slate-700,
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-log .text-slate-500 {
color: #aaceff;
}
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-log .text-slate-400 {
color: #8aa0c4;
}
/* 第二步输入区直接复用第一步 PositionIntentInput;共识动作也使用其
#enter-roundtable-btn 规则,保证和「确认意图并启动业务链」完全同款。 */
.dark .eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-input {
flex: 0 0 auto;
}
@media (max-width: 1280px) {
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { width: 326px; }
}
@media (max-width: 1024px) {
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { width: 294px; }
}
@@ -76,4 +76,70 @@
background: #21958a; background: #21958a;
} }
/* 可在此继续覆盖第 3 步报告、流程图与放大弹窗的样式。 */ /* 第三步:8BF 左栏与第一步相同,对话区去掉原外层圆角白框,沿用第一、二步深蓝面板。 */
.dark .eightbf-roundtable[data-roundtable-step="3"] > main {
padding: 12px 0;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] > main[data-step2-sidebar-expanded="true"] {
gap: 0;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] > main[data-step2-sidebar-expanded="false"] {
gap: 10px;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] #middle-viewport {
border-color: rgb(43 143 254 / 50%);
border-radius: 4px;
background: #032650;
box-shadow: none;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="true"] {
width: 374px;
padding: 0 12px;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="false"] {
width: 80px;
padding: 0 12px;
}
.eightbf-roundtable .eightbf-roundtable-step3-chat {
min-height: 0;
background: #032650;
}
.eightbf-roundtable .eightbf-roundtable-step3-log {
background: #032650;
border: 0;
box-shadow: none;
}
.eightbf-roundtable .eightbf-roundtable-step3-chat .position-roundtable-intent-header-actions button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-log .text-slate-700,
.eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-log .text-slate-500 {
color: #aaceff;
}
.eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-log .text-slate-400 {
color: #8aa0c4;
}
/* PositionIntentInput is the shared Step 1/2 composer; Step 3 uses the exact same frame. */
.dark .eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-input {
flex: 0 0 auto;
}
@media (max-width: 1280px) {
.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="true"] { width: 326px; }
}
@media (max-width: 1024px) {
.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="true"] { width: 294px; }
}
+5
View File
@@ -41,5 +41,10 @@ export const env = {
* flag so operations can switch the shell without rebuilding the frontend. * flag so operations can switch the shell without rebuilding the frontend.
*/ */
FORCE_COLLAPSED_SIDEBAR: readRuntimeFlag("VITE_FORCE_COLLAPSED_SIDEBAR"), FORCE_COLLAPSED_SIDEBAR: readRuntimeFlag("VITE_FORCE_COLLAPSED_SIDEBAR"),
/**
* Compact-menu variant for this website. Pair with the admin 简介模式
* type (A / B) so two deployments can show different left-nav items.
*/
COMPACT_MENU_TYPE: readRuntimeEnv("VITE_COMPACT_MENU_TYPE"),
GITHUB_OAUTH_TOKEN: readRuntimeEnv("VITE_GITHUB_OAUTH_TOKEN"), GITHUB_OAUTH_TOKEN: readRuntimeEnv("VITE_GITHUB_OAUTH_TOKEN"),
}; };
+27
View File
@@ -0,0 +1,27 @@
/**
* Generate a client-only id without assuming a secure browser context.
*
* `crypto.randomUUID()` is unavailable in several browsers when the app is
* opened directly through an `http://<public-ip>:<port>` address. Request and
* UI ids need uniqueness, not a security token, so a timestamp/random fallback
* keeps those flows usable until the deployment is served over HTTPS.
*/
export function createClientId(prefix?: string): string {
const cryptoApi = typeof globalThis === "object" ? globalThis.crypto : undefined;
let id: string | undefined;
if (typeof cryptoApi?.randomUUID === "function") {
id = cryptoApi.randomUUID();
} else if (typeof cryptoApi?.getRandomValues === "function") {
try {
const values = new Uint32Array(4);
cryptoApi.getRandomValues(values);
id = Array.from(values, (value) => value.toString(16).padStart(8, "0")).join("");
} catch {
// Some restricted browser contexts expose crypto but reject random data.
}
}
id ??= `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
return prefix ? `${prefix}-${id}` : id;
}
+60 -2
View File
@@ -119,6 +119,14 @@ export interface DocumentRewriteRequest {
instruction: string; instruction: string;
style?: DocumentRewriteStyle; style?: DocumentRewriteStyle;
modelName?: string; modelName?: string;
/** Optional Markdown chapter structure selected from the report-structure library. */
reportOutline?: string;
/** ``fixed`` reproduces the outline; ``adaptive`` keeps the material-driven layout. */
structureMode?: "fixed" | "adaptive";
/** Generate locally saved illustration assets after the prose rewrite. */
generateImages?: boolean;
/** Bounded server-side to 1–4. Ignored unless generateImages is true. */
maxGeneratedImages?: number;
expectedHash?: string; expectedHash?: string;
} }
@@ -140,6 +148,8 @@ export interface DocumentRewriteSummary {
}; };
requestedOptimizations?: string[]; requestedOptimizations?: string[];
validationWarnings?: string[]; validationWarnings?: string[];
generatedImageCount?: number;
requestedImageCount?: number;
} }
export type DocumentRewriteStreamEvent = export type DocumentRewriteStreamEvent =
@@ -156,12 +166,28 @@ export type DocumentRewriteStreamEvent =
| { type: "rewrite_delta"; delta: string; charCount: number } | { type: "rewrite_delta"; delta: string; charCount: number }
/** Durable full-draft recovery point used after an SSE reconnect. */ /** Durable full-draft recovery point used after an SSE reconnect. */
| { type: "rewrite_checkpoint"; content: string; charCount: number } | { type: "rewrite_checkpoint"; content: string; charCount: number }
| { type: "illustration_started"; section: string; position: number; total: number }
| {
type: "illustration_generated";
section: string;
position: number;
total: number;
generatedCount?: number;
}
| { type: "illustration_completed"; generatedCount: number; requestedCount: number; warnings: string[] }
| { type: "validation_started" } | { type: "validation_started" }
| { type: "validation_completed"; passed: boolean; warnings: string[] } | { type: "validation_completed"; passed: boolean; warnings: string[] }
| { type: "comparison_ready"; summary: DocumentRewriteSummary } | { type: "comparison_ready"; summary: DocumentRewriteSummary }
| { type: "commit_started" } | { type: "commit_started" }
| { type: "committed"; content: string; committedHash: string; versionId?: string } | {
| { type: "done"; committedHash: string; versionId?: string } type: "committed";
content: string;
committedHash: string;
versionId?: string;
/** The rewrite is usable, but its optional undo snapshot was not saved. */
versionSnapshotFailed?: boolean;
}
| { type: "done"; committedHash: string; versionId?: string; versionSnapshotFailed?: boolean }
| { type: "conflict"; message: string } | { type: "conflict"; message: string }
| { type: "cancelled"; message: string } | { type: "cancelled"; message: string }
| { type: "error"; message: string; stage?: string }; | { type: "error"; message: string; stage?: string };
@@ -177,6 +203,9 @@ export interface DurableDocumentRewriteJob {
instruction: string; instruction: string;
style: DocumentRewriteStyle; style: DocumentRewriteStyle;
modelName: string | null; modelName: string | null;
reportOutline: string;
generateImages: boolean;
maxGeneratedImages: number;
originalContent: string; originalContent: string;
}; };
} }
@@ -229,6 +258,30 @@ async function readDocumentRewriteEvent(
content: String(parsed.content ?? ""), content: String(parsed.content ?? ""),
charCount: Number(parsed.charCount ?? 0), charCount: Number(parsed.charCount ?? 0),
}; };
case "illustration_started":
return {
type: "illustration_started",
section: String(parsed.section ?? ""),
position: Number(parsed.position ?? 0),
total: Number(parsed.total ?? 0),
};
case "illustration_generated":
return {
type: "illustration_generated",
section: String(parsed.section ?? ""),
position: Number(parsed.position ?? 0),
total: Number(parsed.total ?? 0),
generatedCount: Number(parsed.generatedCount ?? parsed.position ?? 0),
};
case "illustration_completed":
return {
type: "illustration_completed",
generatedCount: Number(parsed.generatedCount ?? 0),
requestedCount: Number(parsed.requestedCount ?? 0),
warnings: Array.isArray(parsed.warnings)
? parsed.warnings.filter((item): item is string => typeof item === "string")
: [],
};
case "validation_started": case "validation_started":
return { type: "validation_started" }; return { type: "validation_started" };
case "validation_completed": case "validation_completed":
@@ -249,12 +302,14 @@ async function readDocumentRewriteEvent(
content: String(parsed.content ?? ""), content: String(parsed.content ?? ""),
committedHash: String(parsed.committedHash ?? ""), committedHash: String(parsed.committedHash ?? ""),
versionId: typeof parsed.versionId === "string" ? parsed.versionId : undefined, versionId: typeof parsed.versionId === "string" ? parsed.versionId : undefined,
versionSnapshotFailed: parsed.versionSnapshotFailed === true,
}; };
case "done": case "done":
return { return {
type: "done", type: "done",
committedHash: String(parsed.committedHash ?? ""), committedHash: String(parsed.committedHash ?? ""),
versionId: typeof parsed.versionId === "string" ? parsed.versionId : undefined, versionId: typeof parsed.versionId === "string" ? parsed.versionId : undefined,
versionSnapshotFailed: parsed.versionSnapshotFailed === true,
}; };
case "conflict": case "conflict":
return { type: "conflict", message: String(parsed.message ?? "文件内容已变化") }; return { type: "conflict", message: String(parsed.message ?? "文件内容已变化") };
@@ -362,6 +417,9 @@ export async function listDurableDocumentRewrites(
instruction: String(request.instruction ?? ""), instruction: String(request.instruction ?? ""),
style: (request.style ?? "professional") as DocumentRewriteStyle, style: (request.style ?? "professional") as DocumentRewriteStyle,
modelName: typeof request.modelName === "string" ? request.modelName : null, modelName: typeof request.modelName === "string" ? request.modelName : null,
reportOutline: typeof request.reportOutline === "string" ? request.reportOutline : "",
generateImages: request.generateImages === true,
maxGeneratedImages: Number(request.maxGeneratedImages ?? 2),
originalContent: String(request.originalContent ?? ""), originalContent: String(request.originalContent ?? ""),
}, },
}]; }];
@@ -46,8 +46,6 @@ const WRITING_STATUS_PILL: Record<string, { label: string; className: string; sp
error: { label: "出错", className: "text-rose-500" }, error: { label: "出错", className: "text-rose-500" },
}; };
const SHOW_NOTEBOOK_SPACE_ENTRIES = false;
/** 单条历史记录项 —— 样式参考多智能体会商的 HistoryDropdown 列表项(圆角卡片 + 状态药丸 + 操作按钮)。 */ /** 单条历史记录项 —— 样式参考多智能体会商的 HistoryDropdown 列表项(圆角卡片 + 状态药丸 + 操作按钮)。 */
function WritingHistoryItem({ function WritingHistoryItem({
session, session,
@@ -253,8 +251,7 @@ export function ResearchSidebar({
</div> </div>
)} )}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden px-3 py-4"> <div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden px-3 py-4">
{SHOW_NOTEBOOK_SPACE_ENTRIES && ( <section className="flex flex-col">
<section className="flex flex-col">
<div className="mb-3 flex items-center justify-between px-2"> <div className="mb-3 flex items-center justify-between px-2">
<h2 className="text-[15px] font-bold tracking-wide text-slate-400"> <h2 className="text-[15px] font-bold tracking-wide text-slate-400">
笔记本 笔记本
@@ -350,8 +347,7 @@ export function ResearchSidebar({
<span>所有空间</span> <span>所有空间</span>
</button> </button>
</div> </div>
</section> </section>
)}
{/* 「历史记录」列表(样式参考多智能体会商):跟随侧栏自然向下滚动、不固定在底部。 {/* 「历史记录」列表(样式参考多智能体会商):跟随侧栏自然向下滚动、不固定在底部。
点击打开历史回看(显示到右侧区域),✏️/双击改名,🗑删除,「挂起中」点击看进度。 */} 点击打开历史回看(显示到右侧区域),✏️/双击改名,🗑删除,「挂起中」点击看进度。 */}
@@ -26,7 +26,7 @@ import { ThreadContext } from '@/components/workspace/messages/context';
import { extractContentFromMessage, extractReasoningContentFromMessage, hasToolCalls } from '@/core/messages/utils'; import { extractContentFromMessage, extractReasoningContentFromMessage, hasToolCalls } from '@/core/messages/utils';
import { getAPIClient } from '@/core/api'; import { getAPIClient } from '@/core/api';
import { useModels } from '@/core/models/hooks'; import { useModels } from '@/core/models/hooks';
import { useThreadStream } from '@/core/threads/hooks'; import { stopActiveThreadBeforeClarificationReply, useThreadStream } from '@/core/threads/hooks';
import { uuid } from '@/core/utils/uuid'; import { uuid } from '@/core/utils/uuid';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -146,7 +146,10 @@ export function WritingSetupChat({
const ensureThreadExists = useCallback(async () => { const ensureThreadExists = useCallback(async () => {
if (threadPrepared) return; if (threadPrepared) return;
await getAPIClient().threads.create({ threadId, metadata: { thread_type: 'writing_setup' } }); await getAPIClient().threads.create({
threadId,
metadata: { thread_type: 'writing_setup', system: true },
});
setThreadPrepared(true); setThreadPrepared(true);
}, [threadId, threadPrepared]); }, [threadId, threadPrepared]);
@@ -165,6 +168,17 @@ export function WritingSetupChat({
[thread, ensureThreadExists, sendMessage, threadId, formCtx], [thread, ensureThreadExists, sendMessage, threadId, formCtx],
); );
const handleClarificationSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] }, buildWritingSampleContext(formCtx));
},
[thread, ensureThreadExists, sendMessage, threadId, formCtx],
);
const publishSampleMessage = useCallback( const publishSampleMessage = useCallback(
(filename: string, text: string) => { (filename: string, text: string) => {
const normalized = text.trim(); const normalized = text.trim();
@@ -362,7 +376,7 @@ export function WritingSetupChat({
className="size-full" className="size-full"
threadId={threadId} threadId={threadId}
thread={displayThread} thread={displayThread}
onClarificationSubmit={handleChatSubmit} onClarificationSubmit={handleClarificationSubmit}
onSetupWritingApprove={handleApprove} onSetupWritingApprove={handleApprove}
onSetupWritingReject={handleReject} onSetupWritingReject={handleReject}
onSetupWritingStart={canStartWriting ? handleStart : undefined} onSetupWritingStart={canStartWriting ? handleStart : undefined}
@@ -36,6 +36,7 @@ import { Textarea } from "@/components/ui/textarea";
import { MessageList } from "@/components/workspace/messages"; import { MessageList } from "@/components/workspace/messages";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
import { stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext"; import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
@@ -177,9 +178,10 @@ export function CanvasChatPanel() {
const handleClarificationSubmit = useCallback( const handleClarificationSubmit = useCallback(
async (answer: string) => { async (answer: string) => {
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(answer, { mode }); await sendMessage(answer, { mode });
}, },
[sendMessage, mode], [sendMessage, mode, thread, threadId],
); );
const handleModelSelect = useCallback( const handleModelSelect = useCallback(
@@ -9,12 +9,14 @@ import {
cancelDurableDocumentRewrite, cancelDurableDocumentRewrite,
streamDocumentRewrite, streamDocumentRewrite,
} from "../api/rewrite"; } from "../api/rewrite";
import { InlineThinkTagFilter, stripInlineThinkTags } from "@/core/streamdown/inline-think";
export type DocumentRewriteStage = export type DocumentRewriteStage =
| "idle" | "idle"
| "snapshot" | "snapshot"
| "requirements" | "requirements"
| "writing" | "writing"
| "illustrating"
| "validation" | "validation"
| "committing" | "committing"
| "completed" | "completed"
@@ -27,6 +29,8 @@ export interface DocumentRewriteRun {
draft: string; draft: string;
/** Live, compact execution plan produced before Markdown generation starts. */ /** Live, compact execution plan produced before Markdown generation starts. */
requirementsPlan: string; requirementsPlan: string;
/** Ephemeral model reasoning, deliberately kept out of the Markdown draft. */
thinkingText: string;
originalCharCount: number; originalCharCount: number;
writtenCharCount: number; writtenCharCount: number;
fileName: string; fileName: string;
@@ -41,6 +45,7 @@ const INITIAL_RUN: DocumentRewriteRun = {
stage: "idle", stage: "idle",
draft: "", draft: "",
requirementsPlan: "", requirementsPlan: "",
thinkingText: "",
originalCharCount: 0, originalCharCount: 0,
writtenCharCount: 0, writtenCharCount: 0,
fileName: "", fileName: "",
@@ -67,6 +72,13 @@ export function useDocumentRewrite() {
const frameRef = useRef<number | null>(null); const frameRef = useRef<number | null>(null);
const pendingRequirementsDeltaRef = useRef(""); const pendingRequirementsDeltaRef = useRef("");
const requirementsFrameRef = useRef<number | null>(null); const requirementsFrameRef = useRef<number | null>(null);
const pendingThinkingRef = useRef("");
const thinkingFrameRef = useRef<number | null>(null);
const draftThinkFilterRef = useRef(new InlineThinkTagFilter());
const requirementsThinkFilterRef = useRef(new InlineThinkTagFilter());
// Kept outside React state so an HTTP/SSE failure can hand the last visible
// text back to the sandbox without briefly restoring the original document.
const latestDraftRef = useRef("");
const flushDelta = useCallback(() => { const flushDelta = useCallback(() => {
if (frameRef.current !== null) { if (frameRef.current !== null) {
@@ -86,27 +98,6 @@ export function useDocumentRewrite() {
})); }));
}, []); }, []);
const enqueueDelta = useCallback((delta: string, writtenCharCount: number) => {
if (!delta) return;
pendingDeltaRef.current += delta;
pendingWrittenCharCountRef.current = writtenCharCount;
if (frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
const pending = pendingDeltaRef.current;
const pendingWrittenCharCount = pendingWrittenCharCountRef.current;
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
if (!pending) return;
setRun((previous) => ({
...previous,
stage: "writing",
draft: previous.draft + pending,
...(pendingWrittenCharCount !== null ? { writtenCharCount: pendingWrittenCharCount } : {}),
}));
});
}, []);
const flushRequirements = useCallback(() => { const flushRequirements = useCallback(() => {
if (requirementsFrameRef.current !== null) { if (requirementsFrameRef.current !== null) {
cancelAnimationFrame(requirementsFrameRef.current); cancelAnimationFrame(requirementsFrameRef.current);
@@ -124,9 +115,35 @@ export function useDocumentRewrite() {
})); }));
}, []); }, []);
const enqueueRequirementsDelta = useCallback((delta: string) => { const flushThinking = useCallback(() => {
if (thinkingFrameRef.current !== null) {
cancelAnimationFrame(thinkingFrameRef.current);
thinkingFrameRef.current = null;
}
const delta = pendingThinkingRef.current;
pendingThinkingRef.current = "";
if (!delta) return; if (!delta) return;
pendingRequirementsDeltaRef.current += delta; setRun((previous) => ({ ...previous, thinkingText: previous.thinkingText + delta }));
}, []);
const enqueueThinking = useCallback((delta: string) => {
if (!delta) return;
pendingThinkingRef.current += delta;
if (thinkingFrameRef.current !== null) return;
thinkingFrameRef.current = requestAnimationFrame(() => {
thinkingFrameRef.current = null;
const pending = pendingThinkingRef.current;
pendingThinkingRef.current = "";
if (!pending) return;
setRun((previous) => ({ ...previous, thinkingText: previous.thinkingText + pending }));
});
}, []);
const enqueueRequirementsDelta = useCallback((delta: string) => {
const { text: visibleDelta, thinking } = requirementsThinkFilterRef.current.pushParts(delta);
enqueueThinking(thinking);
if (!visibleDelta) return;
pendingRequirementsDeltaRef.current += visibleDelta;
if (requirementsFrameRef.current !== null) return; if (requirementsFrameRef.current !== null) return;
requirementsFrameRef.current = requestAnimationFrame(() => { requirementsFrameRef.current = requestAnimationFrame(() => {
requirementsFrameRef.current = null; requirementsFrameRef.current = null;
@@ -141,7 +158,31 @@ export function useDocumentRewrite() {
requirementsPlan: previous.requirementsPlan + pending, requirementsPlan: previous.requirementsPlan + pending,
})); }));
}); });
}, []); }, [enqueueThinking]);
const enqueueDelta = useCallback((delta: string, writtenCharCount: number) => {
const { text: visibleDelta, thinking } = draftThinkFilterRef.current.pushParts(delta);
enqueueThinking(thinking);
if (!visibleDelta) return;
latestDraftRef.current += visibleDelta;
pendingDeltaRef.current += visibleDelta;
pendingWrittenCharCountRef.current = writtenCharCount;
if (frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
const pending = pendingDeltaRef.current;
const pendingWrittenCharCount = pendingWrittenCharCountRef.current;
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
if (!pending) return;
setRun((previous) => ({
...previous,
stage: "writing",
draft: previous.draft + pending,
...(pendingWrittenCharCount !== null ? { writtenCharCount: pendingWrittenCharCount } : {}),
}));
});
}, [enqueueThinking]);
const cancel = useCallback(() => { const cancel = useCallback(() => {
controllerRef.current?.abort(); controllerRef.current?.abort();
@@ -155,12 +196,13 @@ export function useDocumentRewrite() {
} }
flushDelta(); flushDelta();
flushRequirements(); flushRequirements();
flushThinking();
setRun((previous) => setRun((previous) =>
previous.stage === "completed" previous.stage === "completed"
? previous ? previous
: { ...previous, stage: "cancelled", error: "已停止全文改写,原文件未被修改。" }, : { ...previous, stage: "cancelled", error: "已停止全文改写,原文件未被修改。" },
); );
}, [flushDelta, flushRequirements]); }, [flushDelta, flushRequirements, flushThinking]);
const reset = useCallback(() => { const reset = useCallback(() => {
controllerRef.current?.abort(); controllerRef.current?.abort();
@@ -169,6 +211,10 @@ export function useDocumentRewrite() {
pendingDeltaRef.current = ""; pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null; pendingWrittenCharCountRef.current = null;
pendingRequirementsDeltaRef.current = ""; pendingRequirementsDeltaRef.current = "";
pendingThinkingRef.current = "";
draftThinkFilterRef.current = new InlineThinkTagFilter();
requirementsThinkFilterRef.current = new InlineThinkTagFilter();
latestDraftRef.current = "";
if (frameRef.current !== null) { if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current); cancelAnimationFrame(frameRef.current);
frameRef.current = null; frameRef.current = null;
@@ -177,6 +223,10 @@ export function useDocumentRewrite() {
cancelAnimationFrame(requirementsFrameRef.current); cancelAnimationFrame(requirementsFrameRef.current);
requirementsFrameRef.current = null; requirementsFrameRef.current = null;
} }
if (thinkingFrameRef.current !== null) {
cancelAnimationFrame(thinkingFrameRef.current);
thinkingFrameRef.current = null;
}
setRun(INITIAL_RUN); setRun(INITIAL_RUN);
}, []); }, []);
@@ -184,7 +234,12 @@ export function useDocumentRewrite() {
async ( async (
request: DocumentRewriteRequest, request: DocumentRewriteRequest,
options?: { options?: {
onCommitted?: (content: string) => void; onCommitted?: (
content: string,
result: { versionSnapshotFailed: boolean; committedHash: string },
) => void;
/** Preserve the visible text locally when the rewrite request cannot finish. */
onFailure?: (result: { content: string; error: string }) => void;
onEvent?: (event: DocumentRewriteStreamEvent) => void; onEvent?: (event: DocumentRewriteStreamEvent) => void;
stream?: ( stream?: (
signal: AbortSignal, signal: AbortSignal,
@@ -202,6 +257,10 @@ export function useDocumentRewrite() {
pendingDeltaRef.current = ""; pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null; pendingWrittenCharCountRef.current = null;
pendingRequirementsDeltaRef.current = ""; pendingRequirementsDeltaRef.current = "";
pendingThinkingRef.current = "";
draftThinkFilterRef.current = new InlineThinkTagFilter();
requirementsThinkFilterRef.current = new InlineThinkTagFilter();
latestDraftRef.current = "";
setRun({ setRun({
...INITIAL_RUN, ...INITIAL_RUN,
stage: "snapshot", stage: "snapshot",
@@ -236,28 +295,36 @@ export function useDocumentRewrite() {
case "requirements_delta": case "requirements_delta":
enqueueRequirementsDelta(event.delta); enqueueRequirementsDelta(event.delta);
break; break;
case "thinking":
enqueueThinking(event.chunk);
break;
case "requirements_checkpoint": case "requirements_checkpoint":
flushRequirements(); flushRequirements();
setRun((previous) => ({ setRun((previous) => ({
...previous, ...previous,
stage: "requirements", stage: "requirements",
requirementsPlan: event.content, requirementsPlan: requirementsThinkFilterRef.current.replace(event.content),
})); }));
break; break;
case "requirements_completed": case "requirements_completed":
flushRequirements(); flushRequirements();
setRun((previous) => ({ {
...previous, const requirementsPlan =
stage: "requirements", requirementsThinkFilterRef.current.replace(event.content) +
requirementsPlan: event.content, requirementsThinkFilterRef.current.finish();
})); setRun((previous) => ({
...previous,
stage: "requirements",
requirementsPlan,
}));
}
break; break;
case "requirements_resolved": case "requirements_resolved":
flushRequirements(); flushRequirements();
setRun((previous) => ({ setRun((previous) => ({
...previous, ...previous,
stage: "writing", stage: "writing",
...(event.plan ? { requirementsPlan: event.plan } : {}), ...(event.plan ? { requirementsPlan: stripInlineThinkTags(event.plan) } : {}),
})); }));
break; break;
case "rewrite_delta": case "rewrite_delta":
@@ -273,16 +340,27 @@ export function useDocumentRewrite() {
cancelAnimationFrame(frameRef.current); cancelAnimationFrame(frameRef.current);
frameRef.current = null; frameRef.current = null;
} }
setRun((previous) => ({ {
const checkpointContent = draftThinkFilterRef.current.replace(event.content);
latestDraftRef.current = checkpointContent;
setRun((previous) => ({
...previous, ...previous,
stage: "writing", stage: "writing",
draft: event.content, draft: checkpointContent,
writtenCharCount: event.charCount, writtenCharCount: stripInlineThinkTags(event.content).replace(/\s/g, "").length,
})); }));
}
break;
case "illustration_started":
case "illustration_generated":
case "illustration_completed":
flushDelta();
setRun((previous) => ({ ...previous, stage: "illustrating" }));
break; break;
case "validation_started": case "validation_started":
flushDelta(); flushDelta();
flushRequirements(); flushRequirements();
flushThinking();
setRun((previous) => ({ ...previous, stage: "validation" })); setRun((previous) => ({ ...previous, stage: "validation" }));
break; break;
case "validation_completed": case "validation_completed":
@@ -300,17 +378,27 @@ export function useDocumentRewrite() {
case "committed": case "committed":
flushDelta(); flushDelta();
flushRequirements(); flushRequirements();
setRun((previous) => ({ flushThinking();
...previous, {
draft: event.content, const committedContent =
writtenCharCount: event.content.replace(/\s/g, "").length, draftThinkFilterRef.current.replace(event.content) + draftThinkFilterRef.current.finish();
versionId: event.versionId ?? previous.versionId, latestDraftRef.current = committedContent;
})); setRun((previous) => ({
options?.onCommitted?.(event.content); ...previous,
draft: committedContent,
writtenCharCount: committedContent.replace(/\s/g, "").length,
versionId: event.versionId ?? previous.versionId,
}));
options?.onCommitted?.(committedContent, {
versionSnapshotFailed: event.versionSnapshotFailed === true,
committedHash: event.committedHash,
});
}
break; break;
case "done": case "done":
flushDelta(); flushDelta();
flushRequirements(); flushRequirements();
flushThinking();
receivedTerminalEvent = true; receivedTerminalEvent = true;
setRun((previous) => ({ setRun((previous) => ({
...previous, ...previous,
@@ -321,6 +409,7 @@ export function useDocumentRewrite() {
case "conflict": case "conflict":
flushDelta(); flushDelta();
flushRequirements(); flushRequirements();
flushThinking();
receivedTerminalEvent = true; receivedTerminalEvent = true;
setRun((previous) => ({ setRun((previous) => ({
...previous, ...previous,
@@ -331,13 +420,16 @@ export function useDocumentRewrite() {
case "cancelled": case "cancelled":
flushDelta(); flushDelta();
flushRequirements(); flushRequirements();
flushThinking();
receivedTerminalEvent = true; receivedTerminalEvent = true;
setRun((previous) => ({ ...previous, stage: "cancelled", error: event.message })); setRun((previous) => ({ ...previous, stage: "cancelled", error: event.message }));
return; return;
case "error": case "error":
flushDelta(); flushDelta();
flushRequirements(); flushRequirements();
flushThinking();
receivedTerminalEvent = true; receivedTerminalEvent = true;
options?.onFailure?.({ content: latestDraftRef.current, error: event.message });
setRun((previous) => ({ setRun((previous) => ({
...previous, ...previous,
stage: "error", stage: "error",
@@ -351,20 +443,26 @@ export function useDocumentRewrite() {
if (!controller.signal.aborted && !receivedTerminalEvent) { if (!controller.signal.aborted && !receivedTerminalEvent) {
flushDelta(); flushDelta();
flushRequirements(); flushRequirements();
flushThinking();
const errorMessage = "改写任务连接已结束,但未收到完成状态;原文件未被修改。";
options?.onFailure?.({ content: latestDraftRef.current, error: errorMessage });
setRun((previous) => ({ setRun((previous) => ({
...previous, ...previous,
stage: "error", stage: "error",
error: "改写任务连接已结束,但未收到完成状态;原文件未被修改。", error: errorMessage,
})); }));
} }
} catch (error) { } catch (error) {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
flushDelta(); flushDelta();
flushRequirements(); flushRequirements();
flushThinking();
const errorMessage = error instanceof Error ? error.message : "全文改写失败";
options?.onFailure?.({ content: latestDraftRef.current, error: errorMessage });
setRun((previous) => ({ setRun((previous) => ({
...previous, ...previous,
stage: "error", stage: "error",
error: error instanceof Error ? error.message : "全文改写失败", error: errorMessage,
})); }));
} finally { } finally {
if (controllerRef.current === controller) { if (controllerRef.current === controller) {
@@ -372,7 +470,7 @@ export function useDocumentRewrite() {
} }
} }
}, },
[enqueueDelta, enqueueRequirementsDelta, flushDelta, flushRequirements], [enqueueDelta, enqueueRequirementsDelta, enqueueThinking, flushDelta, flushRequirements, flushThinking],
); );
useEffect( useEffect(
@@ -380,13 +478,14 @@ export function useDocumentRewrite() {
controllerRef.current?.abort(); controllerRef.current?.abort();
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
if (requirementsFrameRef.current !== null) cancelAnimationFrame(requirementsFrameRef.current); if (requirementsFrameRef.current !== null) cancelAnimationFrame(requirementsFrameRef.current);
if (thinkingFrameRef.current !== null) cancelAnimationFrame(thinkingFrameRef.current);
}, },
[], [],
); );
return { return {
run, run,
isActive: ["snapshot", "requirements", "writing", "validation", "committing"].includes(run.stage), isActive: ["snapshot", "requirements", "writing", "illustrating", "validation", "committing"].includes(run.stage),
start, start,
cancel, cancel,
reset, reset,
@@ -394,10 +394,7 @@ function AIWritingLayout({
); );
} }
/** /** 写作页主体(左侧工作空间 + 内容区)。 */
* 写作页主体(侧栏 + 内容区)。放在 Provider 内部,以便读 useAIWriting 状态:
* 进入写作(status !== idle 或查看历史)后自动收起左侧研究模板栏,让写作区更宽。
*/
function AIWritingBody({ function AIWritingBody({
hideAdvancedFields, hideAdvancedFields,
initialNotebookId, initialNotebookId,
@@ -408,8 +405,6 @@ function AIWritingBody({
hideAdvancedFields: boolean; hideAdvancedFields: boolean;
compactChrome?: boolean; compactChrome?: boolean;
} & NotebookProps) { } & NotebookProps) {
const { state } = useAIWriting();
const { isHistoryMode } = useAIWritingHistory();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const routeBase = useAIWritingRouteBase(); const routeBase = useAIWritingRouteBase();
@@ -437,10 +432,9 @@ function AIWritingBody({
const agentsActive = !externalNotebookMode && sidebarTab === 'agents'; const agentsActive = !externalNotebookMode && sidebarTab === 'agents';
// 研究模板侧栏在「写作配置(idle)」与「历史回看」两态都展示(点历史记录后保留侧栏、 // 常规 AI 写作页始终保留左侧工作空间,方便在写作过程中继续切换课题、查看历史或使用智能体。
// 历史内容显示到右侧区域);写作进行中收起;嵌入态(studioToken)始终不展示。 // 只有外部笔记本嵌入态才隐藏,避免占用嵌入页面的内容空间。
const showSidebar = const showSidebar = !externalNotebookMode;
!externalNotebookMode && (state.status === 'idle' || isHistoryMode || agentsActive);
const compactAppearanceActions = compactChrome ? ( const compactAppearanceActions = compactChrome ? (
<AIWritingCompactAppearanceActions /> <AIWritingCompactAppearanceActions />
) : null; ) : null;
+5 -4
View File
@@ -31,7 +31,7 @@ import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-dep
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
import { saveLastAgentId, useThreadSettings } from "@/core/settings"; import { saveLastAgentId, useThreadSettings } from "@/core/settings";
import { stopThreadRun, useThreadStream } from "@/core/threads/hooks"; import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { OptType, writeYLog } from "@/core/log/y-log"; import { OptType, writeYLog } from "@/core/log/y-log";
import { import {
@@ -542,11 +542,12 @@ export default function AgentChatPage({
}, [taskAutoStart, taskStartText, isNewThread, handleSubmit]); }, [taskAutoStart, taskStartText, isNewThread, handleSubmit]);
const handleClarificationSubmit = useCallback( const handleClarificationSubmit = useCallback(
(answer: string) => { async (answer: string) => {
setHasStartedConversation(true); setHasStartedConversation(true);
void sendMessage(threadId, { text: answer, files: [] }, { agent_id }); await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] }, { agent_id });
}, },
[sendMessage, threadId, agent_id], [sendMessage, thread, threadId, agent_id],
); );
const handleStop = useCallback(async () => { const handleStop = useCallback(async () => {
+7 -6
View File
@@ -58,7 +58,7 @@ import {
useKnowledgeRagEnabled, useKnowledgeRagEnabled,
useThreadSettings, useThreadSettings,
} from "@/core/settings"; } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks"; import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit"; import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag"; import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag"; import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag";
@@ -525,11 +525,12 @@ export default function ChatPage() {
); );
const handleClarificationSubmit = useCallback( const handleClarificationSubmit = useCallback(
(answer: string) => { async (answer: string) => {
setHasStartedConversation(true); setHasStartedConversation(true);
void sendMessage(threadId, { text: answer, files: [] }); await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] });
}, },
[sendMessage, threadId], [sendMessage, thread, threadId],
); );
useEffect(() => { useEffect(() => {
@@ -773,9 +774,9 @@ export default function ChatPage() {
!compactLanding && !compactLanding &&
"-translate-y-[calc(50vh-96px)]", "-translate-y-[calc(50vh-96px)]",
compactLanding compactLanding
? "max-w-(--container-width-xl)" ? "max-w-[calc(var(--container-width-xl)+150px)]"
: isLandingState : isLandingState
? "max-w-(--container-width-md)" ? "max-w-[calc(var(--container-width-md)+150px)]"
: "max-w-(--container-width-lg)", : "max-w-(--container-width-lg)",
)} )}
> >
+5 -4
View File
@@ -9,7 +9,7 @@ import { setStoredAuth } from "@/core/auth";
import { resolveEmbedChatParams } from "@/core/embed/params"; import { resolveEmbedChatParams } from "@/core/embed/params";
import { registerEmbedSession } from "@/core/embed/status-api"; import { registerEmbedSession } from "@/core/embed/status-api";
import { useThreadSettings } from "@/core/settings"; import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks"; import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid"; import { uuid } from "@/core/utils/uuid";
import { ChatBox } from "@/components/workspace/chats"; import { ChatBox } from "@/components/workspace/chats";
import { import {
@@ -110,10 +110,11 @@ export default function EmbedChatPage() {
}, [authReady, threadId, registerSessionForThread]); }, [authReady, threadId, registerSessionForThread]);
const handleClarificationSubmit = useCallback( const handleClarificationSubmit = useCallback(
(answer: string) => { async (answer: string) => {
void sendMessage(threadId, { text: answer, files: [] }); await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] });
}, },
[sendMessage, threadId], [sendMessage, thread, threadId],
); );
useEffect(() => { useEffect(() => {
@@ -0,0 +1,734 @@
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
import {
AlertCircle,
BookOpenCheck,
CheckCircle2,
ChevronRight,
ClipboardList,
Database,
Download,
ExternalLink,
FileSearch,
FileText,
Loader2,
Play,
Plus,
Search,
Send,
ShieldCheck,
Square,
Sparkles,
WandSparkles,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import { DownloadWordButton } from "@/components/workspace/messages/download-word-button";
import { DocumentRewriteDiffDialog } from "@/components/workspace/messages/document-rewrite-diff-dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { openKnowledgeSource } from "@/core/llmwiki/source-links";
import MarkdownRenderer from "@/strategy-components/MarkdownRenderer";
import {
useLlmWikiKnowledgeBases,
useLlmWikiRuntime,
} from "@/strategy-components/api/llmwiki";
import {
type EnterpriseResearchEvidence,
type EnterpriseResearchTask,
type EnterpriseResearchTaskStatus,
type EnterpriseResearchReportEvent,
type EnterpriseResearchReportJob,
collectEnterpriseResearchTask,
cancelEnterpriseResearchReportJob,
createEnterpriseResearchAiPlan,
createEnterpriseResearchTask,
createEnterpriseResearchPlanDraft,
createEnterpriseResearchReportJob,
listEnterpriseResearchTasks,
listEnterpriseResearchReportJobs,
restoreEnterpriseResearchReportVersion,
streamEnterpriseResearchReportJob,
updateEnterpriseResearchPlan,
} from "@/strategy-components/api/enterprise-research";
type TemplateId = "topic" | "event" | "policy";
type TaskStatus = EnterpriseResearchTaskStatus;
type ResearchTemplate = {
id: TemplateId;
name: string;
description: string;
stages: string[];
buildQueries: (subject: string, focus: string) => string[];
};
const TEMPLATES: ResearchTemplate[] = [
{
id: "topic",
name: "通用议题研究",
description: "先梳理事实与背景,再归纳关键分歧、影响与待验证问题。",
stages: ["界定研究问题", "检索内部证据", "归纳观点与分歧", "形成研究任务包"],
buildQueries: (subject, focus) => [subject, `${subject} 背景 关键事实`, focus ? `${subject} ${focus}` : `${subject} 影响 风险 机会`],
},
{
id: "event",
name: "事件影响评估",
description: "适用于突发事件、舆情事件或专项行动的影响研判。",
stages: ["还原事件时间线", "识别相关主体", "评估影响传导", "输出风险与跟踪项"],
buildQueries: (subject, focus) => [
`${subject} 时间线 经过`,
`${subject} 相关主体 公开表态`,
focus ? `${subject} ${focus} 影响 风险` : `${subject} 影响 风险 应对`,
],
},
{
id: "policy",
name: "政策与行业研究",
description: "适用于政策、产业、市场等需比较变化与传导机制的主题。",
stages: ["明确政策或产业边界", "对比历史与现状", "拆解传导机制", "沉淀研判与建议"],
buildQueries: (subject, focus) => [
`${subject} 政策 现状`,
`${subject} 发展趋势 数据`,
focus ? `${subject} ${focus} 行业影响` : `${subject} 影响 挑战 机遇`,
],
},
];
const STATUS_META: Record<TaskStatus, { label: string; className: string }> = {
draft: { label: "草稿", className: "bg-muted text-muted-foreground" },
collecting: { label: "检索中", className: "bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-200" },
ready: { label: "证据已就绪", className: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-200" },
partial: { label: "部分完成", className: "bg-orange-100 text-orange-800 dark:bg-orange-950/60 dark:text-orange-200" },
failed: { label: "检索失败", className: "bg-red-100 text-red-800 dark:bg-red-950/60 dark:text-red-200" },
};
function formatTime(value?: string | null) {
if (!value) return "刚刚";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "刚刚";
return new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(parsed);
}
function getTemplate(id: TemplateId) {
return TEMPLATES.find((item) => item.id === id) ?? TEMPLATES[0];
}
function StatusBadge({ status }: { status: TaskStatus }) {
const meta = STATUS_META[status];
return <Badge className={cn("border-0", meta.className)}>{meta.label}</Badge>;
}
export default function EnterpriseResearchPage() {
const runtime = useLlmWikiRuntime();
const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora");
const [tasks, setTasks] = useState<EnterpriseResearchTask[]>([]);
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [subject, setSubject] = useState("");
const [focus, setFocus] = useState("");
const [templateId, setTemplateId] = useState<TemplateId>("topic");
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
const [selectedSourceId, setSelectedSourceId] = useState<string | null>(null);
const [reportJob, setReportJob] = useState<EnterpriseResearchReportJob | null>(null);
const [reportJobs, setReportJobs] = useState<EnterpriseResearchReportJob[]>([]);
const [reportDraft, setReportDraft] = useState("");
const [jobDraft, setJobDraft] = useState("");
const [reasoning, setReasoning] = useState("");
const [isStartingReport, setIsStartingReport] = useState(false);
const reportActionRef = useRef<EnterpriseResearchReportJob["action"]>("initial");
useEffect(() => {
let alive = true;
void listEnterpriseResearchTasks()
.then((stored) => {
if (!alive) return;
setTasks(stored);
setActiveTaskId(stored[0]?.id ?? null);
})
.catch(() => {
if (alive) toast.error("研究任务读取失败,请稍后重试");
});
return () => { alive = false; };
}, []);
const activeTask = useMemo(
() => tasks.find((task) => task.id === activeTaskId) ?? null,
[activeTaskId, tasks],
);
const template = getTemplate(templateId);
const selectedSource = activeTask?.sources.find((source) => source.chunk_id === selectedSourceId) ?? activeTask?.sources[0] ?? null;
const isWeKnoraReady = runtime.data?.provider === "weknora";
const consumeReportStream = (taskId: string, jobId: string, signal?: AbortSignal) => streamEnterpriseResearchReportJob(taskId, jobId, {
signal,
onEvent: (event: EnterpriseResearchReportEvent) => {
if (event.type === "snapshot") {
setReportJob(event.job);
reportActionRef.current = event.job.action;
if (event.job.action === "followup") setJobDraft(event.job.report_markdown || "");
else setReportDraft(event.job.report_markdown || "");
return;
}
if (event.type === "report_delta") {
if (reportActionRef.current === "followup") setJobDraft((current) => current + event.delta);
else setReportDraft((current) => current + event.delta);
return;
}
if (event.type === "reasoning_delta") {
setReasoning((current) => (current + event.delta).slice(-6_000));
return;
}
if (event.type === "phase") {
setReportJob((current) => current ? { ...current, status: event.status, phase: event.phase } : current);
return;
}
if (event.type === "completed" || event.type === "failed" || event.type === "cancelled") {
if (!event.job) return;
setReportJob(event.job);
setReportJobs((current) => [event.job!, ...current.filter((item) => item.id !== event.job!.id)]);
if (event.job.action === "followup") {
setJobDraft(event.job.report_markdown || "");
} else if (event.type === "completed") {
setReportDraft(event.job.report_markdown || "");
setTasks((current) => current.map((task) => task.id === taskId ? {
...task,
report_markdown: event.job?.report_markdown ?? task.report_markdown,
report_summary: event.job?.report_summary ?? task.report_summary,
report_model_name: event.job?.model_name ?? task.report_model_name,
active_report_job_id: null,
} : task));
} else {
// A failed or cancelled rewrite may contain a persisted temporary
// draft. Never expose it as the committed report in local state.
setReportDraft(event.job.original_report || tasks.find((task) => task.id === taskId)?.report_markdown || "");
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
}
if (event.type === "completed") toast.success("企业研究报告已完成");
if (event.type === "failed") toast.error(event.job.error_message || "报告生成失败");
}
},
}).catch((error: unknown) => {
if ((error as { name?: string } | null)?.name !== "AbortError") {
toast.error(error instanceof Error ? error.message : "报告流连接中断");
}
});
useEffect(() => {
if (!activeTaskId) {
setReportJob(null);
setReportJobs([]);
setReportDraft("");
setJobDraft("");
setReasoning("");
return;
}
const controller = new AbortController();
let alive = true;
setReportJob(null);
setReportJobs([]);
setReportDraft("");
setJobDraft("");
setReasoning("");
setIsStartingReport(false);
void listEnterpriseResearchReportJobs(activeTaskId)
.then((jobs) => {
if (!alive) return;
const latest = jobs[0] ?? null;
setReportJobs(jobs);
setReportJob(latest);
const latestReport = jobs.find((job) => job.status === "completed" && job.action !== "followup");
if (latestReport?.report_markdown) setReportDraft(latestReport.report_markdown);
const latestFollowup = jobs.find((job) => job.status === "completed" && job.action === "followup");
if (latestFollowup?.report_markdown) setJobDraft(latestFollowup.report_markdown);
if (latest && (latest.status === "queued" || latest.status === "running")) {
reportActionRef.current = latest.action;
void consumeReportStream(activeTaskId, latest.id, controller.signal);
}
})
.catch(() => {
if (alive) toast.error("报告任务读取失败,请稍后重试");
});
return () => {
alive = false;
controller.abort();
};
// The stream is intentionally scoped to the selected task only. It must not
// restart whenever token deltas update the task list.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTaskId]);
const resetComposer = () => {
setActiveTaskId(null);
setSubject("");
setFocus("");
setTemplateId("topic");
setSelectedKnowledgeBaseIds([]);
setSelectedSourceId(null);
setReportJob(null);
setReportJobs([]);
setReportDraft("");
setJobDraft("");
setReasoning("");
};
const toggleKnowledgeBase = (id: string, checked: boolean) => {
setSelectedKnowledgeBaseIds((current) => checked ? [...new Set([...current, id])] : current.filter((item) => item !== id));
};
const startResearch = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const normalizedSubject = subject.trim();
if (!normalizedSubject) {
toast.error("请先填写研究主题");
return;
}
if (!isWeKnoraReady) {
toast.error("知识服务尚未启用,请先在系统中配置 WeKnora");
return;
}
const selectedTemplate = getTemplate(templateId);
const queries = [...new Set(selectedTemplate.buildQueries(normalizedSubject, focus.trim()).map((item) => item.trim()).filter(Boolean))];
try {
const created = await createEnterpriseResearchTask({
subject: normalizedSubject,
focus: focus.trim(),
template_id: templateId,
knowledge_base_ids: selectedKnowledgeBaseIds,
queries,
});
const collecting: EnterpriseResearchTask = { ...created, status: "collecting" };
setTasks((current) => [collecting, ...current.filter((item) => item.id !== collecting.id)].slice(0, 20));
setActiveTaskId(created.id);
setSelectedSourceId(null);
const completed = await collectEnterpriseResearchTask(created.id);
setTasks((current) => current.map((item) => item.id === completed.id ? completed : item));
if (completed.sources[0]) setSelectedSourceId(completed.sources[0].chunk_id);
if (completed.status === "failed") toast.error("未检索到可用证据");
else toast.success(`已完成证据收集:${completed.sources.length} 条资料`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建研究任务失败,请稍后重试");
}
};
const createPlanDraft = async (taskId: string) => {
const updated = await createEnterpriseResearchPlanDraft(taskId);
setTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
return updated;
};
const createAiPlan = async (taskId: string, input: { model_name?: string | null; instruction?: string }) => {
const updated = await createEnterpriseResearchAiPlan(taskId, input);
setTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
return updated;
};
const savePlan = async (taskId: string, input: { plan_markdown: string; selected_source_ids: string[] }) => {
const updated = await updateEnterpriseResearchPlan(taskId, input);
setTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
return updated;
};
const startReport = async (
taskId: string,
input: Parameters<typeof createEnterpriseResearchReportJob>[1] = {},
) => {
if (isStartingReport) return;
setIsStartingReport(true);
try {
const job = await createEnterpriseResearchReportJob(taskId, input);
reportActionRef.current = job.action;
setReportJob(job);
setReportJobs((current) => [job, ...current.filter((item) => item.id !== job.id)]);
if (job.action === "followup") setJobDraft(job.report_markdown || "");
else setReportDraft(job.report_markdown || "");
setReasoning("");
void consumeReportStream(taskId, job.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建报告写作任务失败");
} finally {
setIsStartingReport(false);
}
};
const cancelReport = async (taskId: string, jobId: string) => {
const cancelled = await cancelEnterpriseResearchReportJob(taskId, jobId);
setReportJob(cancelled);
setReportJobs((current) => [cancelled, ...current.filter((item) => item.id !== cancelled.id)]);
if (cancelled.action === "followup") setJobDraft("");
else setReportDraft(tasks.find((task) => task.id === taskId)?.report_markdown || "");
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
toast.success("已取消报告任务,已保存的正式报告未受影响");
};
const restoreReport = async (taskId: string, jobId: string) => {
const updated = await restoreEnterpriseResearchReportVersion(taskId, jobId);
const jobs = await listEnterpriseResearchReportJobs(taskId);
setTasks((current) => current.map((task) => task.id === updated.id ? updated : task));
setReportJobs(jobs);
setReportJob(jobs[0] ?? null);
setReportDraft(updated.report_markdown || "");
toast.success("已恢复改写前版本");
};
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<header className="flex shrink-0 items-center justify-between border-b bg-card px-5 py-3.5 sm:px-7">
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<FileSearch className="size-5" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="truncate text-base font-semibold sm:text-lg">企业深度研究</h1>
<Badge variant="outline" className="text-muted-foreground">独立工作台</Badge>
</div>
<p className="hidden text-xs text-muted-foreground sm:block">面向内部知识库的研究任务、证据编排与可追溯交付</p>
</div>
</div>
<Button size="sm" onClick={resetComposer}>
<Plus className="mr-1.5 size-4" />
新建任务
</Button>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden xl:grid-cols-[272px_minmax(440px,0.9fr)_minmax(420px,1.1fr)]">
<aside className="min-h-0 border-b bg-muted/20 xl:border-r xl:border-b-0">
<div className="flex items-center justify-between px-5 py-4">
<div>
<p className="text-sm font-semibold">研究任务</p>
<p className="mt-0.5 text-xs text-muted-foreground">服务端保留最近 20 条</p>
</div>
<Badge variant="secondary">{tasks.length}</Badge>
</div>
<ScrollArea className="h-[220px] xl:h-[calc(100%-76px)]">
<div className="space-y-1 px-3 pb-4">
{tasks.length ? tasks.map((task) => (
<button
key={task.id}
type="button"
onClick={() => { setActiveTaskId(task.id); setSelectedSourceId(task.sources[0]?.chunk_id ?? null); }}
className={cn(
"w-full rounded-lg border px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
activeTaskId === task.id ? "border-primary/35 bg-primary/8" : "border-transparent hover:bg-accent",
)}
>
<div className="flex items-start justify-between gap-2">
<span className="line-clamp-2 text-sm font-medium leading-5">{task.subject}</span>
<StatusBadge status={task.status} />
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
<span>{task.sources.length} 条资料</span><span>·</span><span>{formatTime(task.updated_at || task.created_at)}</span>
</div>
</button>
)) : (
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">尚无任务。创建第一个研究任务开始。</div>
)}
</div>
</ScrollArea>
</aside>
<main className="min-h-0 overflow-y-auto border-b p-5 xl:border-r xl:border-b-0 xl:p-6">
{!activeTaskId ? (
<form className="mx-auto max-w-2xl space-y-5" onSubmit={startResearch}>
<section>
<Badge variant="secondary" className="mb-3"><Sparkles className="size-3" />阶段一:证据编排</Badge>
<h2 className="text-xl font-semibold tracking-tight">建立一个可追溯的研究任务</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 WeKnora 知识库,不会修改任何知识库或既有报告。</p>
</section>
<Card>
<CardHeader className="gap-1">
<CardTitle className="text-base">研究对象与范围</CardTitle>
<CardDescription>用一句话明确需要研究的对象;补充范围可帮助生成更聚焦的检索方向。</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<label className="block space-y-2 text-sm font-medium" htmlFor="enterprise-research-subject">
研究主题 <span className="text-destructive">*</span>
<Input id="enterprise-research-subject" value={subject} onChange={(event) => setSubject(event.target.value)} placeholder="例如:新能源汽车产业链的供应风险" maxLength={180} />
</label>
<label className="block space-y-2 text-sm font-medium" htmlFor="enterprise-research-focus">
重点范围 <span className="font-normal text-muted-foreground">(可选)</span>
<Textarea id="enterprise-research-focus" value={focus} onChange={(event) => setFocus(event.target.value)} placeholder="例如:聚焦 2025 年以来的海外供应、关键原材料与对国内产能的影响" className="min-h-22 resize-y" maxLength={800} />
</label>
</CardContent>
</Card>
<Card>
<CardHeader className="gap-1">
<CardTitle className="text-base">选择研究模板</CardTitle>
<CardDescription>模板只决定问题拆解和证据编排方式,不会替换你的原始问题。</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-3">
{TEMPLATES.map((item) => (
<button
type="button"
key={item.id}
onClick={() => setTemplateId(item.id)}
className={cn(
"rounded-lg border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
templateId === item.id ? "border-primary bg-primary/5" : "hover:bg-accent",
)}
>
<span className="block text-sm font-semibold">{item.name}</span>
<span className="mt-1.5 block text-xs leading-5 text-muted-foreground">{item.description}</span>
</button>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="gap-1">
<CardTitle className="flex items-center gap-2 text-base"><Database className="size-4 text-primary" />证据范围</CardTitle>
<CardDescription>{isWeKnoraReady ? "只显示当前账号已授权的知识库。未选择时会检索全部可见知识库。" : "知识服务未启用,无法开始内部证据检索。"}</CardDescription>
</CardHeader>
<CardContent>
{knowledgeBases.isLoading ? <div className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />正在读取知识库…</div> : knowledgeBases.isError ? <div className="flex items-center gap-2 text-sm text-destructive"><AlertCircle className="size-4" />知识库读取失败</div> : (
<div className="space-y-2">
{knowledgeBases.data?.length ? knowledgeBases.data.map((base) => {
const checked = selectedKnowledgeBaseIds.includes(base.id);
return <label key={base.id} className="flex cursor-pointer items-start gap-3 rounded-md border p-3 hover:bg-accent/60">
<Checkbox checked={checked} onCheckedChange={(next) => toggleKnowledgeBase(base.id, next)} aria-label={`选择知识库 ${base.name}`} />
<span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium">{base.name}</span><span className="mt-0.5 block line-clamp-1 text-xs text-muted-foreground">{base.description || `${base.knowledge_count} 份资料`}</span></span>
<span className="text-xs text-muted-foreground">{base.chunk_count} 段</span>
</label>;
}) : <p className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">当前账号没有可用知识库,可先前往“知识库管理”添加或发布资料。</p>}
</div>
)}
</CardContent>
</Card>
<div className="flex flex-col gap-3 rounded-lg border bg-muted/30 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm"><span className="font-medium">即将检索 {template.stages.length - 1} 个研究方向</span><p className="mt-1 text-xs text-muted-foreground">会生成主题、背景与重点范围三组可见检索词。</p></div>
<Button type="submit" disabled={!isWeKnoraReady || knowledgeBases.isLoading}><Play className="mr-1.5 size-4" />开始证据编排</Button>
</div>
</form>
) : activeTask ? (
<TaskOverview task={activeTask} reportJob={reportJob} isStartingReport={isStartingReport} onNew={resetComposer} onCreatePlanDraft={createPlanDraft} onCreateAiPlan={createAiPlan} onSavePlan={savePlan} />
) : null}
</main>
<section className="min-h-0 overflow-y-auto bg-muted/10 p-5 xl:p-6">
<EvidencePane task={activeTask} source={selectedSource} reportJob={reportJob} reportJobs={reportJobs} reportDraft={reportDraft} jobDraft={jobDraft} reasoning={reasoning} isStartingReport={isStartingReport} onStartReport={startReport} onCancelReport={cancelReport} onRestoreReport={restoreReport} onSourceSelect={setSelectedSourceId} />
</section>
</div>
</div>
);
}
function TaskOverview({
task,
reportJob,
isStartingReport,
onNew,
onCreatePlanDraft,
onCreateAiPlan,
onSavePlan,
}: {
task: EnterpriseResearchTask;
reportJob: EnterpriseResearchReportJob | null;
isStartingReport: boolean;
onNew: () => void;
onCreatePlanDraft: (taskId: string) => Promise<EnterpriseResearchTask>;
onCreateAiPlan: (taskId: string, input: { model_name?: string | null; instruction?: string }) => Promise<EnterpriseResearchTask>;
onSavePlan: (taskId: string, input: { plan_markdown: string; selected_source_ids: string[] }) => Promise<EnterpriseResearchTask>;
}) {
const template = getTemplate(task.template_id as TemplateId);
const [planMarkdown, setPlanMarkdown] = useState(task.plan_markdown ?? "");
const [selectedSourceIds, setSelectedSourceIds] = useState<string[]>(task.selected_source_ids ?? []);
const [isCreatingPlan, setIsCreatingPlan] = useState(false);
const [isSavingPlan, setIsSavingPlan] = useState(false);
const [isCreatingAiPlan, setIsCreatingAiPlan] = useState(false);
const [planModel, setPlanModel] = useState<string | null>(null);
const [planInstruction, setPlanInstruction] = useState("");
const canPlan = task.status === "ready" || task.status === "partial";
const isWriting = isStartingReport || reportJob?.status === "queued" || reportJob?.status === "running";
useEffect(() => {
setPlanMarkdown(task.plan_markdown ?? "");
setSelectedSourceIds(task.selected_source_ids ?? []);
}, [task.id, task.plan_markdown, task.selected_source_ids]);
const toggleSource = (sourceId: string, checked: boolean) => {
setSelectedSourceIds((current) => checked ? [...new Set([...current, sourceId])] : current.filter((item) => item !== sourceId));
};
const handleCreatePlan = async () => {
setIsCreatingPlan(true);
try {
const updated = await onCreatePlanDraft(task.id);
toast.success(`已根据 ${updated.selected_source_ids.length} 条资料生成可编辑研究计划`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成研究计划失败");
} finally {
setIsCreatingPlan(false);
}
};
const handleSavePlan = async () => {
setIsSavingPlan(true);
try {
const updated = await onSavePlan(task.id, { plan_markdown: planMarkdown, selected_source_ids: selectedSourceIds });
toast.success(`研究计划已保存,已选 ${updated.selected_source_ids.length} 条资料`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存研究计划失败");
} finally {
setIsSavingPlan(false);
}
};
const handleCreateAiPlan = async () => {
setIsCreatingAiPlan(true);
try {
const updated = await onCreateAiPlan(task.id, { model_name: planModel, instruction: planInstruction.trim() });
setPlanMarkdown(updated.plan_markdown ?? "");
setSelectedSourceIds(updated.selected_source_ids ?? []);
toast.success("AI 研究计划已生成,可继续人工修改后保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "AI 规划失败");
} finally {
setIsCreatingAiPlan(false);
}
};
return <div className="mx-auto max-w-2xl space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div><Badge variant="secondary" className="mb-3"><ClipboardList className="size-3" />研究任务</Badge><h2 className="text-xl font-semibold tracking-tight">{task.subject}</h2>{task.focus ? <p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">{task.focus}</p> : null}</div>
<div className="flex items-center gap-2"><StatusBadge status={task.status} /><Button size="sm" variant="outline" onClick={onNew}><Plus className="mr-1 size-4" />新建</Button></div>
</div>
<Card>
<CardHeader className="gap-1"><CardTitle className="text-base">研究路径</CardTitle><CardDescription>{template.name} · 每一步保留用户可核验的检索词和证据来源。</CardDescription></CardHeader>
<CardContent><ol className="space-y-4">{template.stages.map((stage, index) => {
const complete = task.status !== "collecting" && (index < 2 || task.sources.length > 0);
const current = task.status === "collecting" && index === 1;
return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用 WeKnora 检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>;
})}</ol></CardContent>
</Card>
<Card>
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 WeKnora 知识库。</CardDescription></CardHeader>
<CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{task.warning}</div> : null}</CardContent>
</Card>
{canPlan ? <Card>
<CardHeader className="gap-1"><CardTitle className="text-base">研究计划与选用资料</CardTitle><CardDescription>计划和资料选择会保存到当前任务;后续报告只能使用这里确认过的证据。</CardDescription></CardHeader>
<CardContent className="space-y-4">
{!task.plan_markdown ? <div className="rounded-lg border border-dashed bg-muted/30 p-4"><p className="text-sm font-medium">证据已收集,可生成一份可编辑的研究计划</p><p className="mt-1 text-xs leading-5 text-muted-foreground">初版会按当前研究模板组织目标、核心问题、章节和写作约束;生成报告前仍可修改。</p><Button className="mt-3" size="sm" onClick={handleCreatePlan} disabled={isCreatingPlan}><Sparkles className="mr-1.5 size-4" />{isCreatingPlan ? "正在生成…" : "生成研究计划"}</Button></div> : <>
<div className="space-y-2"><div className="flex items-center justify-between gap-3"><label htmlFor={`enterprise-plan-${task.id}`} className="text-sm font-medium">可编辑研究计划</label><span className="text-xs text-muted-foreground">{planMarkdown.length} / 16,000</span></div><Textarea id={`enterprise-plan-${task.id}`} value={planMarkdown} onChange={(event) => setPlanMarkdown(event.target.value)} className="min-h-80 resize-y font-mono text-xs leading-6" maxLength={16_000} /></div>
<div className="space-y-2"><div className="flex items-center justify-between gap-3"><p className="text-sm font-medium">选用资料</p><span className="text-xs text-muted-foreground">已选 {selectedSourceIds.length} / {task.sources.length}</span></div><ScrollArea className="h-44 rounded-md border"><div className="space-y-1 p-2">{task.sources.map((source, index) => { const checked = selectedSourceIds.includes(source.chunk_id); return <label key={source.chunk_id || `${source.knowledge_id}-${index}`} className="flex cursor-pointer items-start gap-2 rounded-md px-2 py-2 hover:bg-muted"><Checkbox checked={checked} onCheckedChange={(next) => toggleSource(source.chunk_id, next === true)} aria-label={`选用资料 ${source.title || source.filename || index + 1}`} /><span className="min-w-0 text-xs leading-5"><span className="block truncate font-medium">{source.title || source.filename || "未命名资料"}</span><span className="block truncate text-muted-foreground">{source.knowledge_base_name || "知识库"}</span></span></label>; })}</div></ScrollArea></div>
<div className="rounded-lg border bg-muted/20 p-3"><div className="flex flex-wrap items-center gap-2"><ModelNameSelect value={planModel} onChange={setPlanModel} disabled={isCreatingAiPlan || isWriting} triggerClassName="w-40" /><Input value={planInstruction} onChange={(event) => setPlanInstruction(event.target.value)} placeholder="AI 规划补充要求(可选)" className="h-8 min-w-48 flex-1 text-xs" maxLength={2_000} /><Button size="sm" variant="outline" onClick={handleCreateAiPlan} disabled={isCreatingAiPlan || isWriting}><WandSparkles className="mr-1.5 size-4" />{isCreatingAiPlan ? "正在规划…" : "AI 优化计划"}</Button></div></div>
<div className="flex justify-end"><Button size="sm" onClick={handleSavePlan} disabled={isSavingPlan || !planMarkdown.trim() || isWriting}>{isSavingPlan ? "正在保存…" : "保存研究计划"}</Button></div>
{reportJob ? <div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground"><span className="font-medium text-foreground">报告任务:</span>{reportJob.status === "completed" ? "已完成" : reportJob.status === "failed" ? `失败:${reportJob.error_message || "请稍后重试"}` : `正在${reportJob.phase === "understanding" ? "理解研究计划" : reportJob.phase === "reviewing" ? "校验报告" : "流式写入 Markdown"}`};仅使用本任务已保存的计划与已选资料快照。</div> : null}
</>}
</CardContent>
</Card> : null}
</div>;
}
function EvidencePane({
task, source, reportJob, reportJobs, reportDraft, jobDraft, reasoning, isStartingReport,
onStartReport, onCancelReport, onRestoreReport, onSourceSelect,
}: {
task: EnterpriseResearchTask | null;
source: EnterpriseResearchEvidence | null;
reportJob: EnterpriseResearchReportJob | null;
reportJobs: EnterpriseResearchReportJob[];
reportDraft: string;
jobDraft: string;
reasoning: string;
isStartingReport: boolean;
onStartReport: (taskId: string, input?: Parameters<typeof createEnterpriseResearchReportJob>[1]) => Promise<void>;
onCancelReport: (taskId: string, jobId: string) => Promise<void>;
onRestoreReport: (taskId: string, jobId: string) => Promise<void>;
onSourceSelect: (id: string | null) => void;
}) {
const [modelName, setModelName] = useState<string | null>(null);
const [style, setStyle] = useState<"formal" | "analytical" | "concise">("formal");
const [targetLength, setTargetLength] = useState("5000");
const [rewriteInstruction, setRewriteInstruction] = useState("优化结构、逻辑与表达,减少重复内容,并保留事实与引用");
const [followup, setFollowup] = useState("");
const [diffJob, setDiffJob] = useState<EnterpriseResearchReportJob | null>(null);
if (!task) return <div className="flex h-full min-h-72 flex-col items-center justify-center rounded-xl border border-dashed p-8 text-center"><div className="flex size-11 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"><BookOpenCheck className="size-5" /></div><h2 className="mt-4 text-sm font-semibold">等待研究任务</h2><p className="mt-1 max-w-xs text-sm leading-6 text-muted-foreground">提交主题后,这里会展示证据、研究报告和可追溯引用。</p></div>;
const isCollecting = task.status === "collecting";
const isWriting = isStartingReport || reportJob?.status === "queued" || reportJob?.status === "running";
const currentReport = isWriting && reportJob?.action !== "followup"
? reportDraft
: reportDraft || task.report_markdown || "";
const latestRewrite = reportJobs.find((job) => job.action === "rewrite" && job.status === "completed" && job.original_report);
const latestCommittedJob = reportJobs.find((job) => job.action !== "followup" && job.status === "completed");
const completedFollowups = reportJobs.filter((job) => job.action === "followup" && job.status === "completed");
const selectedSources = task.selected_source_ids.map((id) => task.sources.find((item) => item.chunk_id === id)).filter(Boolean) as EnterpriseResearchEvidence[];
const citationIndex = new Map(selectedSources.map((item, index) => [item.chunk_id, index + 1]));
const renderedReport = currentReport.replace(/\[资料:([^\]]+)\]/g, (match, id: string) => {
const index = citationIndex.get(id.trim());
return index ? `[[${index}]]` : match;
});
const parsedTarget = Number(targetLength);
const downloadMarkdown = () => {
const blob = new Blob([currentReport], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${task.subject.replace(/[\\/:*?"<>|]/g, "-") || "企业研究报告"}.md`;
anchor.click();
URL.revokeObjectURL(url);
};
const start = (action: "initial" | "rewrite" | "followup", instruction = "") => onStartReport(task.id, {
model_name: modelName,
action,
instruction,
style,
target_length: Number.isFinite(parsedTarget) && parsedTarget >= 800 ? parsedTarget : null,
});
const phaseOrder = ["understanding", "writing", "reviewing", "completed"];
const phaseIndex = reportJob ? phaseOrder.indexOf(reportJob.phase) : -1;
return <div className="space-y-4">
{task.plan_markdown && !currentReport && !isWriting ? <Card>
<CardHeader className="gap-1"><CardTitle className="text-sm">报告写作配置</CardTitle><CardDescription>写作只使用已保存计划和 {task.selected_source_ids.length} 条已选资料。</CardDescription></CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2 sm:grid-cols-3"><ModelNameSelect value={modelName} onChange={setModelName} triggerClassName="w-full" /><Select value={style} onValueChange={(value) => setStyle(value as typeof style)}><SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="formal">正式报告</SelectItem><SelectItem value="analytical">分析研判</SelectItem><SelectItem value="concise">精炼摘要</SelectItem></SelectContent></Select><Input type="number" min={800} max={30000} value={targetLength} onChange={(event) => setTargetLength(event.target.value)} className="h-8 text-xs" aria-label="报告目标字数" /></div>
<Button className="w-full" onClick={() => void start("initial")}><Play className="mr-1.5 size-4" />开始流式撰写报告</Button>
</CardContent>
</Card> : null}
{isWriting && reportJob ? <Card>
<CardHeader className="gap-1"><div className="flex items-center justify-between gap-3"><CardTitle className="flex items-center gap-2 text-sm"><Loader2 className="size-4 animate-spin text-primary" />{reportJob.action === "followup" ? "正在回答报告追问" : reportJob.action === "rewrite" ? "正在全文改写" : "正在撰写研究报告"}</CardTitle><Button size="sm" variant="outline" onClick={() => void onCancelReport(task.id, reportJob.id)}><Square className="mr-1.5 size-3.5" />停止</Button></div><CardDescription>真实报告只在任务成功完成后提交;取消或失败不会覆盖已保存版本。</CardDescription></CardHeader>
<CardContent><ol className="grid gap-2 sm:grid-cols-4">{["理解要求", "流式写入", "校验报告", "保存版本"].map((label, index) => <li key={label} className={cn("rounded-md border px-3 py-2 text-xs", index < phaseIndex ? "border-emerald-200 bg-emerald-50 text-emerald-800" : index === phaseIndex ? "border-primary/40 bg-primary/5 text-primary" : "text-muted-foreground")}><span className="font-medium">{index + 1}. {label}</span></li>)}</ol>{reasoning ? <details className="mt-3 rounded-md border px-3 py-2 text-xs text-muted-foreground"><summary className="cursor-pointer">模型思考过程(不会写入报告)</summary><pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap font-sans leading-5">{reasoning}</pre></details> : null}</CardContent>
</Card> : null}
{currentReport ? <Card className="overflow-hidden">
<CardHeader className="border-b py-4"><div className="flex flex-wrap items-start justify-between gap-3"><div><CardTitle className="flex items-center gap-2 text-sm"><FileText className="size-4 text-primary" />{task.subject}</CardTitle><CardDescription className="mt-1">{latestCommittedJob?.report_summary || task.report_summary || `${currentReport.length} 字`}</CardDescription></div><div className="flex items-center gap-1"><Button size="icon-sm" variant="ghost" onClick={downloadMarkdown} aria-label="下载 Markdown" title="下载 Markdown"><Download className="size-4" /></Button><DownloadWordButton markdown={currentReport} fileName={task.subject} title={task.subject} aria-label="下载 Word" />{latestRewrite ? <Button size="sm" variant="outline" onClick={() => setDiffJob(latestRewrite)}>查看改写对比</Button> : null}</div></div></CardHeader>
<CardContent className="p-0"><ScrollArea className="h-[430px]"><article className="p-5"><MarkdownRenderer content={renderedReport} onCitationClick={(index) => onSourceSelect(selectedSources[index - 1]?.chunk_id ?? null)} /></article></ScrollArea></CardContent>
</Card> : null}
{currentReport && !isWriting ? <Card>
<CardHeader className="gap-1"><CardTitle className="text-sm">继续完善报告</CardTitle><CardDescription>全文改写会保存原版本;追问只生成回答,不会覆盖报告。</CardDescription></CardHeader>
<CardContent className="space-y-3"><div className="grid gap-2 sm:grid-cols-[160px_120px_1fr]"><ModelNameSelect value={modelName} onChange={setModelName} triggerClassName="w-full" /><Select value={style} onValueChange={(value) => setStyle(value as typeof style)}><SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="formal">正式报告</SelectItem><SelectItem value="analytical">分析研判</SelectItem><SelectItem value="concise">精炼表达</SelectItem></SelectContent></Select><Input type="number" min={800} max={30000} value={targetLength} onChange={(event) => setTargetLength(event.target.value)} className="h-8 text-xs" aria-label="改写目标字数" /></div><Textarea value={rewriteInstruction} onChange={(event) => setRewriteInstruction(event.target.value)} className="min-h-20 text-sm" maxLength={4_000} /><div className="flex justify-end gap-2">{latestRewrite ? <Button variant="outline" onClick={() => void onRestoreReport(task.id, latestRewrite.id)}>恢复最近改写前版本</Button> : null}<Button onClick={() => void start("rewrite", rewriteInstruction)}><WandSparkles className="mr-1.5 size-4" />全文改写</Button></div><div className="flex gap-2 border-t pt-3"><Input value={followup} onChange={(event) => setFollowup(event.target.value)} placeholder="基于当前报告和已选资料继续追问" maxLength={4_000} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && followup.trim()) { event.preventDefault(); void start("followup", followup.trim()); setFollowup(""); } }} /><Button size="icon" disabled={!followup.trim()} onClick={() => { void start("followup", followup.trim()); setFollowup(""); }} aria-label="发送报告追问"><Send className="size-4" /></Button></div></CardContent>
</Card> : null}
{(jobDraft || completedFollowups.length) ? <Card><CardHeader className="gap-1"><CardTitle className="text-sm">报告追问</CardTitle><CardDescription>回答基于当前报告和已选资料,不会自动发起新检索。</CardDescription></CardHeader><CardContent className="space-y-4">{jobDraft ? <div className="rounded-md border bg-muted/20 p-4"><MarkdownRenderer content={jobDraft} /></div> : null}{completedFollowups.slice(0, 3).map((job) => job.id === reportJob?.id && jobDraft ? null : <div key={job.id} className="border-t pt-4"><p className="mb-2 text-xs font-medium text-muted-foreground">{job.instruction}</p><MarkdownRenderer content={job.report_markdown} /></div>)}</CardContent></Card> : null}
<div className="flex items-start justify-between gap-3"><div><p className="text-sm font-semibold">证据工作台</p><p className="mt-1 text-xs text-muted-foreground">{isCollecting ? "正在收集内部证据…" : `已收集 ${task.sources.length} 条去重资料;报告引用可点击定位`}</p></div>{isCollecting ? <Loader2 className="size-4 animate-spin text-primary" /> : <ShieldCheck className="size-5 text-emerald-600" />}</div>
{isCollecting ? <div className="rounded-xl border bg-card p-5 text-sm text-muted-foreground"><div className="flex items-center gap-2"><Loader2 className="size-4 animate-spin" />正在执行 {task.queries.length} 个检索方向</div></div> : task.sources.length ? <div className="grid gap-4 2xl:grid-cols-[minmax(220px,0.8fr)_minmax(260px,1.2fr)]"><div className="rounded-xl border bg-card"><div className="border-b px-4 py-3 text-xs font-medium text-muted-foreground">资料列表</div><ScrollArea className="h-80"><div className="p-2">{task.sources.map((item, index) => <button key={item.chunk_id || `${item.knowledge_id}-${index}`} type="button" onClick={() => onSourceSelect(item.chunk_id)} className={cn("w-full rounded-lg p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", source?.chunk_id === item.chunk_id ? "bg-primary/10" : "hover:bg-muted")}><p className="line-clamp-2 text-sm font-medium leading-5">{index + 1}. {item.title || item.filename || "未命名资料"}</p><p className="mt-1 truncate text-xs text-muted-foreground">{item.knowledge_base_name || "知识库"}</p></button>)}</div></ScrollArea></div><article className="rounded-xl border bg-card p-5"><p className="text-sm font-semibold">{source?.title || source?.filename || "资料预览"}</p><p className="mt-1 text-xs text-muted-foreground">{source?.knowledge_base_name || "知识库"} · 段落 {typeof source?.chunk_index === "number" ? source.chunk_index + 1 : "-"}</p><p className="mt-5 whitespace-pre-wrap text-sm leading-7 text-foreground/90">{source?.content || "请选择一条资料查看内容。"}</p>{source?.url ? <button type="button" className="mt-5 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline" onClick={() => { if (!openKnowledgeSource(source)) toast.error("暂时无法定位该资料原文"); }}><ExternalLink className="size-4" />定位知识库原文</button> : null}</article></div> : <div className="rounded-xl border border-dashed bg-card p-6 text-center"><FileText className="mx-auto size-6 text-muted-foreground" /><h2 className="mt-3 text-sm font-semibold">未发现可用资料</h2></div>}
<DocumentRewriteDiffDialog
open={Boolean(diffJob)}
onOpenChange={(open) => { if (!open) setDiffJob(null); }}
fileName={`${task.subject}.md`}
original={diffJob?.original_report || ""}
rewritten={diffJob?.report_markdown || ""}
/>
</div>;
}
+5 -4
View File
@@ -40,7 +40,7 @@ import {
useThreadSettings, useThreadSettings,
} from "@/core/settings"; } from "@/core/settings";
import { usePluginManagementSettings } from "@/core/system-settings/hooks"; import { usePluginManagementSettings } from "@/core/system-settings/hooks";
import { useThreadStream } from "@/core/threads/hooks"; import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -336,11 +336,12 @@ export default function IframeChatPage() {
); );
const handleClarificationSubmit = useCallback( const handleClarificationSubmit = useCallback(
(answer: string) => { async (answer: string) => {
setHasStartedConversation(true); setHasStartedConversation(true);
void sendMessage(threadId, { text: answer, files: [] }); await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] });
}, },
[sendMessage, threadId], [sendMessage, thread, threadId],
); );
const handleAgentChange = useCallback((agentId: string | null) => { const handleAgentChange = useCallback((agentId: string | null) => {
+42 -17
View File
@@ -18,11 +18,13 @@ import {
readExternalLoginParams, readExternalLoginParams,
resolveThemeFromQuery, resolveThemeFromQuery,
} from '@/core/auth/external-login'; } from '@/core/auth/external-login';
import { toast } from 'sonner';
import { import {
fetchCopTaskDetail, fetchCopTaskDetail,
persistCopTaskDetail, persistCopTaskDetail,
resolveDeeplinkTaskId, resolveDeeplinkTaskId,
resolveTaskDeeplinkTarget, resolveTaskDeeplinkTarget,
stubCopTaskDetail,
} from '@/core/auth/task-deeplink'; } from '@/core/auth/task-deeplink';
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login'; import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie'; import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
@@ -61,6 +63,17 @@ function maskLoginToken(value: string): string {
return `${value.slice(0, 6)}...${value.slice(-4)}(${value.length})`; return `${value.slice(0, 6)}...${value.slice(-4)}(${value.length})`;
} }
/**
* Hash 路由下深链参数可能写在 ``#/login?...``,也可能写在 hash 前的
* ``?taskId=&goPath=#/login``。两边都读,避免宿主把 query 放在外层 URL 时丢参落到首页。
*/
function readLoginQuery(searchParams: URLSearchParams, key: string): string {
const fromHash = searchParams.get(key)?.trim() ?? '';
if (fromHash) return fromHash;
if (typeof window === 'undefined') return '';
return new URLSearchParams(window.location.search).get(key)?.trim() ?? '';
}
/** 读取已落地的 userInfo(脱敏 access token 后)供登录后排查。 */ /** 读取已落地的 userInfo(脱敏 access token 后)供登录后排查。 */
function summarizeStoredUserInfo(): Record<string, unknown> | null { function summarizeStoredUserInfo(): Record<string, unknown> | null {
try { try {
@@ -206,8 +219,8 @@ export default function LoginPage() {
const effectiveGatePassword = configuredRouteLoginPassword || gatePassword; const effectiveGatePassword = configuredRouteLoginPassword || gatePassword;
const usesConfiguredRouteLoginPassword = Boolean(configuredRouteLoginPassword); const usesConfiguredRouteLoginPassword = Boolean(configuredRouteLoginPassword);
// 任务深链:外部系统带 ?taskId=&goPath= 时,登录后拉任务详情并直送对应问答/会商页 // 任务深链:外部系统带 ?taskId=&goPath= 时,登录后拉任务详情并直送对应问答/会商页
const taskId = searchParams.get('taskId')?.trim() ?? ''; const taskId = readLoginQuery(searchParams, 'taskId');
const goPath = searchParams.get('goPath')?.trim() ?? ''; const goPath = readLoginQuery(searchParams, 'goPath');
// 通用「登录后跳指定页」:?redirect=page/strategy/dashboard-agent(值可不带前导斜杠, // 通用「登录后跳指定页」:?redirect=page/strategy/dashboard-agent(值可不带前导斜杠,
// 也可自带 query,如 page/strategy/dashboard-agent?taskId=1580——记得整体 URL 编码)。 // 也可自带 query,如 page/strategy/dashboard-agent?taskId=1580——记得整体 URL 编码)。
// 优先级最高:显式指定即按它跳,先于 taskId/goPath 深链与默认首页。出于安全只接受**站内 // 优先级最高:显式指定即按它跳,先于 taskId/goPath 深链与默认首页。出于安全只接受**站内
@@ -447,26 +460,38 @@ export default function LoginPage() {
return; return;
} }
// 任务深链:带 taskId + goPath 时拉任务详情并直送对应问答/会商页; // 任务深链:带 taskId + goPath 时直送对应问答/会商页。
// 任一环节失败(无地址/拉取失败/未配置智能体/goPath 无法识别)都回退默认首页。 // 任务详情拉取失败不再阻断跳转(本地/外网 consumer 常不可达),用仅含 taskId 的 stub 继续。
// 真正无法解析(goPath 无法识别 / 单智能体未配置业务映射)才回退默认首页。
if (taskId && goPath) { if (taskId && goPath) {
try { try {
// xdfx 深链 ?taskId= 传入的是「行动id」,先换成真正的「任务id」;其它业务原样。 // xdfx 深链 ?taskId= 传入的是「行动id」,先换成真正的「任务id」;解析失败回退原始 id。
const realTaskId = await resolveDeeplinkTaskId(goPath, taskId); const resolvedTaskId = await resolveDeeplinkTaskId(goPath, taskId);
const realTaskId = resolvedTaskId || taskId;
if (cancelled) return; if (cancelled) return;
const detail = await fetchCopTaskDetail(realTaskId); let detail = await fetchCopTaskDetail(realTaskId);
if (cancelled) return; if (cancelled) return;
if (detail) { if (!detail) {
persistCopTaskDetail(detail); console.warn('[login] cop-task-detail missing, continue with stub', {
const target = await resolveTaskDeeplinkTarget(goPath, detail, realTaskId, taskId); goPath,
if (cancelled) return; taskId,
if (target) { realTaskId,
navigate(target.path, { replace: true, state: target.state }); });
return; detail = stubCopTaskDetail(realTaskId);
}
} }
} catch { persistCopTaskDetail(detail);
/* 深链失败不阻断登录,落默认首页 */ const target = await resolveTaskDeeplinkTarget(goPath, detail, realTaskId, taskId);
if (cancelled) return;
if (target) {
console.info('[login] deeplink →', target.path, { goPath, realTaskId });
navigate(target.path, { replace: true, state: target.state });
return;
}
console.warn('[login] deeplink target unresolved', { goPath, realTaskId });
toast.warning('深链未配置对应智能体或 goPath 无法识别,已进入首页');
} catch (error) {
console.warn('[login] deeplink failed, fallback home', error);
toast.warning('深链跳转失败,已进入首页');
} }
} }
+22 -13
View File
@@ -78,9 +78,11 @@ import {
type MenuOverride, type MenuOverride,
type MergedMenuNode, type MergedMenuNode,
} from "@/core/page-layout/menu-overrides"; } from "@/core/page-layout/menu-overrides";
import type { import {
CompactMenuOverride, COMPACT_MENU_TYPE_LABELS,
CompactMenuPlacement, getRuntimeCompactMenuType,
type CompactMenuOverride,
type CompactMenuPlacement,
} from "@/core/page-layout/compact-menu-overrides"; } from "@/core/page-layout/compact-menu-overrides";
import { sidebarMenuItems, type MenuItem } from "@/core/page-layout/sidebar-menu"; import { sidebarMenuItems, type MenuItem } from "@/core/page-layout/sidebar-menu";
import { injectLightApps } from "@/core/page-layout/use-sidebar-menu"; import { injectLightApps } from "@/core/page-layout/use-sidebar-menu";
@@ -506,7 +508,7 @@ export default function MenuManagementPage() {
() => [ () => [
{ {
id: "workspace-compact-nav", id: "workspace-compact-nav",
label: "简介模式菜单", label: "简洁模式菜单",
icon: Settings, icon: Settings,
children: WORKSPACE_COMPACT_NAV_ITEMS, children: WORKSPACE_COMPACT_NAV_ITEMS,
}, },
@@ -775,7 +777,7 @@ export default function MenuManagementPage() {
mode === "compact" ? "bg-red-600 text-white shadow-sm" : "text-slate-500 hover:text-slate-800" mode === "compact" ? "bg-red-600 text-white shadow-sm" : "text-slate-500 hover:text-slate-800"
}`} }`}
> >
简介模式 简洁模式
</button> </button>
</div> </div>
); );
@@ -975,8 +977,11 @@ export default function MenuManagementPage() {
} }
function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) { function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
const { data: overrides = [], isLoading } = useCompactMenuOverrides(); const runtimeType = getRuntimeCompactMenuType();
const { save } = useCompactMenuOverrideActions(); // 不再提供 A/B 切换:固定编辑当前网页(runtime-config.js VITE_COMPACT_MENU_TYPE)使用的那套。
const menuType = runtimeType;
const { data: overrides = [], isLoading } = useCompactMenuOverrides(menuType);
const { save } = useCompactMenuOverrideActions(menuType);
const initialRows = useMemo(() => compactRowsFromTree(baseTree, overrides), [baseTree, overrides]); const initialRows = useMemo(() => compactRowsFromTree(baseTree, overrides), [baseTree, overrides]);
const [rows, setRows] = useState<CompactRow[]>(initialRows); const [rows, setRows] = useState<CompactRow[]>(initialRows);
const [loadedKey, setLoadedKey] = useState(""); const [loadedKey, setLoadedKey] = useState("");
@@ -988,8 +993,9 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
(top.children ?? []).map((child) => [top.id, child.id, child.label]), (top.children ?? []).map((child) => [top.id, child.id, child.label]),
), ),
overrides, overrides,
menuType,
}), }),
[baseTree, overrides], [baseTree, overrides, menuType],
); );
useEffect(() => { useEffect(() => {
if (sourceKey !== loadedKey) { if (sourceKey !== loadedKey) {
@@ -1031,14 +1037,14 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
const handleSave = async () => { const handleSave = async () => {
try { try {
await save.mutateAsync(currentOverrides); await save.mutateAsync(currentOverrides);
toast.success("简介菜单配置已保存"); toast.success(`简洁菜单配置已保存(${COMPACT_MENU_TYPE_LABELS[menuType]})`);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "保存失败"); toast.error(err instanceof Error ? err.message : "保存失败");
} }
}; };
const handleReset = () => { const handleReset = () => {
if (!confirm("确定要重置简介模式菜单为默认布局吗?保存后将清除简介模式自定义配置。")) return; if (!confirm(`确定要重置${COMPACT_MENU_TYPE_LABELS[menuType]}的简洁模式菜单为默认布局吗?保存后将清除该类型的自定义配置。`)) return;
setRows(compactRowsFromTree(baseTree, [])); setRows(compactRowsFromTree(baseTree, []));
}; };
@@ -1053,10 +1059,10 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
<div> <div>
<span className="mb-3 inline-flex select-none items-center gap-1.5 rounded-full border border-red-100 bg-red-50 px-3 py-1 text-xs font-semibold text-red-600"> <span className="mb-3 inline-flex select-none items-center gap-1.5 rounded-full border border-red-100 bg-red-50 px-3 py-1 text-xs font-semibold text-red-600">
<Sparkles className="size-3" /> <Sparkles className="size-3" />
仅简介模式生效 仅简洁模式生效
</span> </span>
<h1 className="flex items-center gap-3 text-2xl font-bold tracking-tight text-slate-800 md:text-3xl"> <h1 className="flex items-center gap-3 text-2xl font-bold tracking-tight text-slate-800 md:text-3xl">
简介菜单管理 简洁菜单管理
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-0.5 font-mono text-xs font-medium text-slate-500"> <span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-0.5 font-mono text-xs font-medium text-slate-500">
{rows.length} 个项目 {rows.length} 个项目
</span> </span>
@@ -1084,7 +1090,10 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
</div> </div>
<p className="flex max-w-2xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600"> <p className="flex max-w-2xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600">
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" /> <Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span>这里配置的是简介模式工作区左侧菜单:可改显示名称、调整顺序,或迁移到「设置和更多」下拉中;全量模式菜单不受影响。</span> <span>
这里配置的是简洁模式工作区左侧菜单,保存后仅对当前网页使用的
{COMPACT_MENU_TYPE_LABELS[runtimeType]}生效(两套部署各改各的);全量模式菜单不受影响。
</span>
</p> </p>
</div> </div>
</div> </div>
+183 -68
View File
@@ -1,9 +1,28 @@
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
rectSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { import {
BotIcon, BotIcon,
ChevronDownIcon, ChevronDownIcon,
CircleAlertIcon, CircleAlertIcon,
CpuIcon, CpuIcon,
EyeIcon, EyeIcon,
GripVerticalIcon,
KeyRoundIcon, KeyRoundIcon,
LoaderCircleIcon, LoaderCircleIcon,
PlusIcon, PlusIcon,
@@ -31,6 +50,7 @@ import {
type ModelSavePayload, type ModelSavePayload,
useDeleteManagedModel, useDeleteManagedModel,
useManagedModels, useManagedModels,
useReorderManagedModels,
useSaveManagedModel, useSaveManagedModel,
useTestManagedModel, useTestManagedModel,
} from "@/core/model-management"; } from "@/core/model-management";
@@ -170,6 +190,103 @@ function ModelCapabilityBadge({
); );
} }
function SortableModelCard({
model,
isDefault,
dragEnabled,
onConfigure,
onDelete,
}: {
model: ManagedModel;
isDefault: boolean;
dragEnabled: boolean;
onConfigure: () => void;
onDelete: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: model.name,
disabled: !dragEnabled,
});
return (
<article
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.45 : 1,
}}
className={`rounded-lg border border-border bg-background p-4 transition-[border-color,box-shadow] ${
dragEnabled ? "hover:border-primary/35 hover:shadow-sm" : ""
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2">
<button
type="button"
className={`mt-0.5 shrink-0 rounded p-1 text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
dragEnabled
? "cursor-grab touch-none hover:bg-muted hover:text-foreground active:cursor-grabbing"
: "cursor-not-allowed opacity-40"
}`}
title={dragEnabled ? "拖动调整顺序" : "请清空搜索和筛选后再调整顺序"}
aria-label={`${model.display_name || model.name}:${dragEnabled ? "拖动调整顺序" : "当前不可调整顺序"}`}
{...(dragEnabled ? attributes : {})}
{...(dragEnabled ? listeners : {})}
>
<GripVerticalIcon className="size-4" aria-hidden />
</button>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="truncate font-semibold">{model.display_name || model.name}</h2>
{isDefault && <Tag theme="primary" variant="light" size="small">默认模型</Tag>}
<Tag theme="success" variant="light" size="small">config.yaml</Tag>
</div>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{model.name}</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<TButton theme="default" variant="outline" size="small" onClick={onConfigure}>
配置
</TButton>
<TButton
theme="danger"
variant="text"
size="small"
aria-label={`删除模型 ${model.display_name || model.name}`}
onClick={onDelete}
>
<Trash2Icon className="mr-1 size-3.5" />删除
</TButton>
</div>
</div>
{model.description ? (
<p className="mt-3 line-clamp-2 min-h-10 text-sm leading-5 text-muted-foreground">{model.description}</p>
) : (
<p className="mt-3 min-h-10 text-sm leading-5 text-muted-foreground">未填写描述</p>
)}
<div className="mt-3 grid gap-2 border-t pt-3 text-xs text-muted-foreground sm:grid-cols-2">
<span className="truncate" title={model.use}>驱动:{model.use}</span>
<span className="truncate" title={model.model}>模型:{model.model}</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{model.api_key_configured && (
<ModelCapabilityBadge icon={KeyRoundIcon} label="已配置密钥" className="bg-muted text-foreground" />
)}
{model.supports_thinking && (
<ModelCapabilityBadge icon={SparklesIcon} label="思考" className="bg-primary/10 text-primary" />
)}
{model.supports_vision && (
<ModelCapabilityBadge icon={EyeIcon} label="视觉" className="bg-amber-500/10 text-amber-700 dark:text-amber-300" />
)}
{model.supports_reasoning_effort && (
<ModelCapabilityBadge label="推理档位" className="bg-muted text-muted-foreground" />
)}
</div>
</article>
);
}
function showGlobalMessage( function showGlobalMessage(
theme: "success" | "error" | "warning", theme: "success" | "error" | "warning",
content: string, content: string,
@@ -274,15 +391,25 @@ export default function ModelManagementPage() {
const [filter, setFilter] = useState<"all" | "thinking" | "vision">("all"); const [filter, setFilter] = useState<"all" | "thinking" | "vision">("all");
const [editor, setEditor] = useState<ManagedModel | null | "new">(null); const [editor, setEditor] = useState<ManagedModel | null | "new">(null);
const [deleteTarget, setDeleteTarget] = useState<ManagedModel | null>(null); const [deleteTarget, setDeleteTarget] = useState<ManagedModel | null>(null);
const [pendingOrder, setPendingOrder] = useState<string[] | null>(null);
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const remove = useDeleteManagedModel(); const remove = useDeleteManagedModel();
const reorder = useReorderManagedModels();
const sourceModels = data?.models ?? [];
const orderedModels = useMemo(() => {
if (!pendingOrder || pendingOrder.length !== sourceModels.length) return sourceModels;
const byName = new Map(sourceModels.map((model) => [model.name, model]));
const reordered = pendingOrder.map((name) => byName.get(name)).filter((model): model is ManagedModel => Boolean(model));
return reordered.length === sourceModels.length ? reordered : sourceModels;
}, [pendingOrder, sourceModels]);
const models = useMemo(() => { const models = useMemo(() => {
const keyword = search.trim().toLowerCase(); const keyword = search.trim().toLowerCase();
return (data?.models ?? []).filter((model) => { return orderedModels.filter((model) => {
const matchesFilter = filter === "all" || (filter === "thinking" ? model.supports_thinking : model.supports_vision); const matchesFilter = filter === "all" || (filter === "thinking" ? model.supports_thinking : model.supports_vision);
const matchesSearch = !keyword || [model.name, model.display_name, model.model, model.use].filter(Boolean).join(" ").toLowerCase().includes(keyword); const matchesSearch = !keyword || [model.name, model.display_name, model.model, model.use].filter(Boolean).join(" ").toLowerCase().includes(keyword);
return matchesFilter && matchesSearch; return matchesFilter && matchesSearch;
}); });
}, [data?.models, filter, search]); }, [filter, orderedModels, search]);
const stats = useMemo(() => ({ total: data?.models.length ?? 0, thinking: data?.models.filter((item) => item.supports_thinking).length ?? 0, vision: data?.models.filter((item) => item.supports_vision).length ?? 0 }), [data?.models]); const stats = useMemo(() => ({ total: data?.models.length ?? 0, thinking: data?.models.filter((item) => item.supports_thinking).length ?? 0, vision: data?.models.filter((item) => item.supports_vision).length ?? 0 }), [data?.models]);
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteTarget) if (!deleteTarget)
@@ -297,6 +424,28 @@ export default function ModelManagementPage() {
showGlobalMessage("error", error instanceof Error ? error.message : "删除模型失败,请稍后重试。"); showGlobalMessage("error", error instanceof Error ? error.message : "删除模型失败,请稍后重试。");
} }
}; };
const dragEnabled = filter === "all" && !search.trim() && !reorder.isPending;
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const handleDragEnd = async ({ active, over }: DragEndEvent) => {
setActiveDragId(null);
if (!dragEnabled || !over || active.id === over.id) return;
const oldIndex = orderedModels.findIndex((model) => model.name === active.id);
const newIndex = orderedModels.findIndex((model) => model.name === over.id);
if (oldIndex < 0 || newIndex < 0) return;
const nextOrder = arrayMove(orderedModels.map((model) => model.name), oldIndex, newIndex);
setPendingOrder(nextOrder);
try {
await reorder.mutateAsync(nextOrder);
showGlobalMessage("success", `模型顺序已更新,“${nextOrder[0]}”现在是默认模型。`);
} catch (error) {
setPendingOrder(null);
showGlobalMessage("error", error instanceof Error ? error.message : "调整模型顺序失败,请稍后重试。");
}
};
return ( return (
<WorkspaceContainer> <WorkspaceContainer>
<WorkspaceHeader /> <WorkspaceHeader />
@@ -375,73 +524,39 @@ export default function ModelManagementPage() {
</div> </div>
)} )}
{!isLoading && !error && models.length > 0 && ( {!isLoading && !error && models.length > 0 && (
<div className="grid gap-3 p-4 lg:grid-cols-2"> <DndContext
{models.map((model) => ( sensors={sensors}
<article key={model.name} className="rounded-lg border border-border bg-background p-4 transition-colors hover:border-primary/35"> collisionDetection={closestCenter}
<div className="flex items-start justify-between gap-3"> onDragStart={({ active }) => setActiveDragId(String(active.id))}
<div className="min-w-0"> onDragCancel={() => setActiveDragId(null)}
<div className="flex flex-wrap items-center gap-2"> onDragEnd={(event) => void handleDragEnd(event)}
<h2 className="truncate font-semibold">{model.display_name || model.name}</h2> >
<Tag theme="success" variant="light" size="small">config.yaml</Tag> <div className="border-b bg-muted/20 px-4 py-2.5 text-xs text-muted-foreground">
</div> 拖动卡片左侧手柄即可调整顺序;排在第一位的模型会作为默认模型。
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{model.name}</p> {!dragEnabled && " 请清空搜索和筛选条件后再调整顺序。"}
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <SortableContext items={models.map((model) => model.name)} strategy={rectSortingStrategy}>
<TButton theme="default" variant="outline" size="small" onClick={() => setEditor(model)}> <div className="grid gap-3 p-4 lg:grid-cols-2">
配置 {models.map((model) => (
</TButton> <SortableModelCard
<TButton key={model.name}
theme="danger" model={model}
variant="text" isDefault={model.name === orderedModels[0]?.name}
size="small" dragEnabled={dragEnabled}
aria-label={"删除模型 " + (model.display_name || model.name)} onConfigure={() => setEditor(model)}
onClick={() => setDeleteTarget(model)} onDelete={() => setDeleteTarget(model)}
> />
<Trash2Icon className="mr-1 size-3.5" />删除 ))}
</TButton> </div>
</div> </SortableContext>
<DragOverlay>
{activeDragId ? (
<div className="rounded-lg border border-primary/30 bg-background px-4 py-3 text-sm font-medium shadow-lg">
{orderedModels.find((model) => model.name === activeDragId)?.display_name || activeDragId}
</div> </div>
{model.description ? ( ) : null}
<p className="mt-3 line-clamp-2 min-h-10 text-sm leading-5 text-muted-foreground">{model.description}</p> </DragOverlay>
) : ( </DndContext>
<p className="mt-3 min-h-10 text-sm leading-5 text-muted-foreground">未填写描述</p>
)}
<div className="mt-3 grid gap-2 border-t pt-3 text-xs text-muted-foreground sm:grid-cols-2">
<span className="truncate" title={model.use}>驱动:{model.use}</span>
<span className="truncate" title={model.model}>模型:{model.model}</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{model.api_key_configured && (
<ModelCapabilityBadge
icon={KeyRoundIcon}
label="已配置密钥"
className="bg-muted text-foreground"
/>
)}
{model.supports_thinking && (
<ModelCapabilityBadge
icon={SparklesIcon}
label="思考"
className="bg-primary/10 text-primary"
/>
)}
{model.supports_vision && (
<ModelCapabilityBadge
icon={EyeIcon}
label="视觉"
className="bg-amber-500/10 text-amber-700 dark:text-amber-300"
/>
)}
{model.supports_reasoning_effort && (
<ModelCapabilityBadge
label="推理档位"
className="bg-muted text-muted-foreground"
/>
)}
</div>
</article>
))}
</div>
)} )}
</section> </section>
+15 -3
View File
@@ -53,7 +53,7 @@ import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import { isAgentSkillCandidate } from "@/core/skills"; import { isAgentSkillCandidate } from "@/core/skills";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
import { useThreadStream } from "@/core/threads/hooks"; import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid"; import { uuid } from "@/core/utils/uuid";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -335,13 +335,25 @@ export default function NewAgentPage() {
// Chat is purely a discussion channel. The agent record is only // Chat is purely a discussion channel. The agent record is only
// created when the user explicitly clicks Save (top-right). If the // created when the user explicitly clicks Save (top-right). If the
// model proposes a config via setup_agent, our approval handler // model proposes a config via setup_agent, our approval handler
// prefills the form locally 鈥?it does NOT persist anything. // prefills the form locally — it does NOT persist anything.
await ensureThreadExists(); await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] }); await sendMessage(threadId, { text: trimmed, files: [] });
}, },
[ensureThreadExists, sendMessage, thread.isLoading, threadId], [ensureThreadExists, sendMessage, thread.isLoading, threadId],
); );
const handleClarificationSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
// 协助卡提交不能因 isLoading 直接丢弃——弱模型出卡后可能仍在流式。
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
[ensureThreadExists, sendMessage, thread, threadId],
);
const handleApproveSetupAgent = useCallback( const handleApproveSetupAgent = useCallback(
async (payload: SetupAgentApprovalPayload) => { async (payload: SetupAgentApprovalPayload) => {
// Approving the model's setup_agent proposal **does not** persist // Approving the model's setup_agent proposal **does not** persist
@@ -425,7 +437,7 @@ export default function NewAgentPage() {
className="size-full" className="size-full"
threadId={threadId} threadId={threadId}
thread={thread} thread={thread}
onClarificationSubmit={handleChatSubmit} onClarificationSubmit={handleClarificationSubmit}
onSetupAgentApprove={handleApproveSetupAgent} onSetupAgentApprove={handleApproveSetupAgent}
onSetupAgentReject={handleRejectSetupAgent} onSetupAgentReject={handleRejectSetupAgent}
/> />
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { useAgentSelector } from "@/core/agents"; import { useAgentSelector } from "@/core/agents";
import { createClientId } from "@/lib/client-id";
import { import {
type DomainAgentMapping, type DomainAgentMapping,
normalizeDomainInput, normalizeDomainInput,
@@ -20,10 +21,7 @@ import {
function makeRow(): DomainAgentMapping { function makeRow(): DomainAgentMapping {
return { return {
id: id: createClientId(),
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
domain: "", domain: "",
agentId: "", agentId: "",
enabled: true, enabled: true,
@@ -1,7 +1,7 @@
"use client"; "use client";
import { AlertCircle,CheckCircle2, Loader2Icon, ShieldIcon } from "lucide-react"; import { AlertCircle,CheckCircle2, Loader2Icon, ShieldIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState, type FormEvent } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
@@ -86,6 +86,11 @@ export default function PrivateSquareSettingsPage({
} }
}; };
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
void handleSave();
};
const handleClearPassword = async () => { const handleClearPassword = async () => {
if (!window.confirm("确认清除私密广场密码?清除后任何人都将无法访问私密广场。")) { if (!window.confirm("确认清除私密广场密码?清除后任何人都将无法访问私密广场。")) {
return; return;
@@ -123,7 +128,18 @@ export default function PrivateSquareSettingsPage({
加载失败:{error.message} 加载失败:{error.message}
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white"> <form
className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white"
onSubmit={handleSubmit}
>
<input
className="sr-only"
type="text"
name="private-square-admin"
autoComplete="username"
tabIndex={-1}
aria-hidden="true"
/>
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div> <div>
<div className="text-sm font-medium">启用私密广场</div> <div className="text-sm font-medium">启用私密广场</div>
@@ -135,7 +151,7 @@ export default function PrivateSquareSettingsPage({
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<p className="text-sm font-medium"> <label className="text-sm font-medium" htmlFor="private-square-password">
访问密码 访问密码
{data?.password_set ? ( {data?.password_set ? (
<span className="text-muted-foreground ml-2 text-xs font-normal"> <span className="text-muted-foreground ml-2 text-xs font-normal">
@@ -146,25 +162,31 @@ export default function PrivateSquareSettingsPage({
(尚未设置) (尚未设置)
</span> </span>
)} )}
</p> </label>
<Input <Input
id="private-square-password"
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder={ placeholder={
data?.password_set ? "留空表示不修改密码" : "请输入访问密码" data?.password_set ? "留空表示不修改密码" : "请输入访问密码"
} }
autoComplete="new-password"
/> />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">确认密码</p> <label className="text-sm font-medium" htmlFor="private-square-confirm-password">
确认密码
</label>
<Input <Input
id="private-square-confirm-password"
type="password" type="password"
value={confirm} value={confirm}
onChange={(e) => setConfirm(e.target.value)} onChange={(e) => setConfirm(e.target.value)}
placeholder="再次输入新密码" placeholder="再次输入新密码"
disabled={!password} disabled={!password}
autoComplete="new-password"
/> />
</div> </div>
@@ -179,14 +201,14 @@ export default function PrivateSquareSettingsPage({
<ShieldIcon className="mr-1.5 size-4" /> <ShieldIcon className="mr-1.5 size-4" />
清除密码 清除密码
</Button> </Button>
<Button onClick={handleSave} disabled={update.isPending}> <Button type="submit" disabled={update.isPending}>
{update.isPending && ( {update.isPending && (
<Loader2Icon className="mr-1.5 size-4 animate-spin" /> <Loader2Icon className="mr-1.5 size-4 animate-spin" />
)} )}
保存 保存
</Button> </Button>
</div> </div>
</div> </form>
)} )}
</div> </div>
); );
+11
View File
@@ -26,6 +26,8 @@ import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtab
import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage"; import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage";
import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage"; import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage";
import DeepResearchPage from "./DeepResearchPage"; import DeepResearchPage from "./DeepResearchPage";
import ReportStructureCreatePage from "./deep-research/ReportStructureCreatePage";
import EnterpriseResearchPage from "./EnterpriseResearchPage";
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage"; import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage"; import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
import "@/roundtable-planning/styles/roundtable-planning.css"; import "@/roundtable-planning/styles/roundtable-planning.css";
@@ -118,6 +120,15 @@ export default function StrategyRoutes() {
} }
/> />
<Route path="qa/deep-research" element={<DeepResearchPage />} /> <Route path="qa/deep-research" element={<DeepResearchPage />} />
<Route
path="qa/deep-research/structures/new"
element={
<ChatRuntime>
<ReportStructureCreatePage />
</ChatRuntime>
}
/>
<Route path="qa/enterprise-research" element={<EnterpriseResearchPage />} />
{/* 大屏绘制智能体独立页面(按 taskId 存取;ChatRuntime 提供 ArtifactsProvider 给沙箱预览) */} {/* 大屏绘制智能体独立页面(按 taskId 存取;ChatRuntime 提供 ArtifactsProvider 给沙箱预览) */}
<Route <Route
@@ -25,6 +25,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { useNotebookChat, useNotebookSources, useNotebookThreads, useStudioNotebooks } from "@/core/studio/hooks"; import { useNotebookChat, useNotebookSources, useNotebookThreads, useStudioNotebooks } from "@/core/studio/hooks";
import { stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import { fetchDocumentByChunk } from "@/core/studio/api"; import { fetchDocumentByChunk } from "@/core/studio/api";
import type { DocumentWithChunks } from "@/core/studio/api"; import type { DocumentWithChunks } from "@/core/studio/api";
import type { SourceItem } from "@/core/studio/types"; import type { SourceItem } from "@/core/studio/types";
@@ -381,12 +382,13 @@ export default function StudioNotebookDetailPage({
// Clarification 卡片的「发送」按钮回调。MessageList 内部把用户选中的选项 + // Clarification 卡片的「发送」按钮回调。MessageList 内部把用户选中的选项 +
// 自由文本合并成一段 answer 传过来,这里走和普通发送同一条 send() 链路, // 自由文本合并成一段 answer 传过来,这里走和普通发送同一条 send() 链路,
// 把当前选中的来源也一并带上,保持上下文一致。 // 把当前选中的来源也一并带上,保持上下文一致。
// 弱模型出卡后可能仍在流式:先 stop 再发,不能因 isStreaming 直接丢弃。
const handleClarificationSubmit = useCallback( const handleClarificationSubmit = useCallback(
async (answer: string) => { async (answer: string) => {
if (isStreaming) return; await stopActiveThreadBeforeClarificationReply(chatThreadId, thread);
await send(answer, selectedSourceIds); await send(answer, selectedSourceIds, { fromClarification: true });
}, },
[send, selectedSourceIds, isStreaming], [send, selectedSourceIds, chatThreadId, thread],
); );
const handleBackToList = () => { const handleBackToList = () => {
+36 -2
View File
@@ -173,7 +173,7 @@ export default function TaskButtonsPage() {
<div> <div>
<div className="text-foreground text-base font-semibold">按钮管理</div> <div className="text-foreground text-base font-semibold">按钮管理</div>
<div className="text-muted-foreground text-xs"> <div className="text-muted-foreground text-xs">
配置任务工作区底部「快捷跳转」栏的按钮(名称 / 跳转方式 / 地址 / 启停 / 排序) 配置任务工作区底部「快捷跳转」栏的按钮(名称 / 跳转方式 / 地址 / 启停 / 排序)。「宿主路由」用于 iframe 套用时让对方系统跳转。
</div> </div>
</div> </div>
</div> </div>
@@ -265,13 +265,14 @@ export default function TaskButtonsPage() {
value={r.linkType} value={r.linkType}
onValueChange={(v) => update(r.id, { linkType: v as TaskButton["linkType"] })} onValueChange={(v) => update(r.id, { linkType: v as TaskButton["linkType"] })}
> >
<SelectTrigger className="h-8 w-36"> <SelectTrigger className="h-8 w-40">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="url">外部链接</SelectItem> <SelectItem value="url">外部链接</SelectItem>
<SelectItem value="business">切换业务</SelectItem> <SelectItem value="business">切换业务</SelectItem>
<SelectItem value="purpose">目的树弹框</SelectItem> <SelectItem value="purpose">目的树弹框</SelectItem>
<SelectItem value="host">宿主路由</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -282,6 +283,39 @@ export default function TaskButtonsPage() {
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
点击弹出当前任务的「任务目的」表格,无需配置目标。 点击弹出当前任务的「任务目的」表格,无需配置目标。
</span> </span>
) : r.linkType === "host" ? (
<>
<Input
value={r.target}
onChange={(e) => update(r.id, { target: e.target.value })}
placeholder="/task/…(宿主系统路由)"
className="h-8 flex-1 min-w-[16rem]"
title="宿主系统路由,点击后通过 postMessage 让父页面跳转"
/>
<label className="text-muted-foreground ml-2 flex shrink-0 items-center gap-1.5 text-xs">
<Switch
checked={r.appendTaskId}
onCheckedChange={(v) => update(r.id, { appendTaskId: v })}
/>
追加任务id
</label>
{r.appendTaskId && (
<div className="flex shrink-0 items-center gap-1 text-xs">
<span className="text-muted-foreground">queryParams.</span>
<Input
value={r.idParam}
onChange={(e) => update(r.id, { idParam: e.target.value })}
placeholder="id"
className="h-8 w-24"
title="发给宿主的参数名(默认 id)"
/>
<span className="text-muted-foreground">= 任务id(数字)</span>
</div>
)}
<span className="text-muted-foreground w-full text-[11px]">
iframe 嵌入时向父页面发送 HOST_NAVIGATE,由宿主系统跳转;独立打开本系统时无效。
</span>
</>
) : r.linkType === "url" ? ( ) : r.linkType === "url" ? (
<> <>
<Input <Input
@@ -64,6 +64,8 @@ import PluginManagementPage from './PluginManagementPage';
import RecommendedQuestionsPage from './RecommendedQuestionsPage'; import RecommendedQuestionsPage from './RecommendedQuestionsPage';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage'; import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import DeepResearchPage from './DeepResearchPage'; import DeepResearchPage from './DeepResearchPage';
import ReportStructureCreatePage from './deep-research/ReportStructureCreatePage';
import EnterpriseResearchPage from './EnterpriseResearchPage';
import StudioNotebooksPage from './StudioNotebooksPage'; import StudioNotebooksPage from './StudioNotebooksPage';
import StudioNotebookDetailPage from './StudioNotebookDetailPage'; import StudioNotebookDetailPage from './StudioNotebookDetailPage';
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage'; import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
@@ -564,6 +566,24 @@ export default function WorkspaceRoutes({
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
<Route
path="deep-research/structures/new"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<ReportStructureCreatePage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="enterprise-research"
element={
<WorkspaceLayout {...layoutProps}>
<EnterpriseResearchPage />
</WorkspaceLayout>
}
/>
<Route <Route
path="studio/notebooks" path="studio/notebooks"
element={ element={
File diff suppressed because it is too large. Load diff
@@ -32,6 +32,16 @@ import {
type ResearchMode, type ResearchMode,
type ResearchTone, type ResearchTone,
} from "@/strategy-components/api/deep-research"; } from "@/strategy-components/api/deep-research";
import {
DEFAULT_REPORT_STRUCTURE_MODE,
normalizeReportStructureMode,
normalizeRetrievalDirections,
normalizeRetrievalSkills,
REPORT_STRUCTURE_MODE_OPTIONS,
type ReportStructureMode,
useReportStructures,
} from "@/strategy-components/api/report-structures";
import { WorkflowConfigButton } from "./WorkflowConfigDialog";
const MODE_LABELS: Record<ResearchMode, string> = { const MODE_LABELS: Record<ResearchMode, string> = {
basic: "标准", basic: "标准",
@@ -63,8 +73,317 @@ export type AdvancedResearchOptions = {
maxGeneratedImages: string; maxGeneratedImages: string;
thinkingEnabled: boolean; thinkingEnabled: boolean;
customOutline: string; customOutline: string;
structureMode: ReportStructureMode;
retrievalDirections: string[];
retrievalSkills: string[];
}; };
/**
* A deliberately domain-neutral structure which gives lightweight models a
* dependable writing scaffold without forcing market-analysis terminology on
* every research topic. Users can edit or replace it in the advanced dialog.
*/
export const DEFAULT_REPORT_OUTLINE = `# 报告标题
# 引言
用 1–2 段说明研究对象、提出本议题的背景、研究范围及资料边界;简要交代全文的分析路径,不单独拆分小节。
# 一、研究对象与事实基础
## 1.1 概念、范围与背景
界定研究对象、关键概念、时间和空间范围;说明读者理解问题所需的背景。
## 1.2 已有事实与证据
按时间或逻辑顺序梳理已知事实、数据、案例或材料发现;优先使用可核验的来源。
## 1.3 当前状态与主要特征
归纳研究对象目前呈现的状态、关键变化、共性与差异;没有数据支撑时明确说明。
# 二、问题形成与作用机制
## 2.1 直接原因与触发条件
分析近期事件、条件变化或关键节点如何推动问题出现或演变。
## 2.2 深层影响因素
从制度、技术、资源、行为、环境或历史条件中选择适用维度,分析长期影响。
## 2.3 因果关系与相互作用
说明不同因素如何共同作用;区分来源已证实的事实、分析判断和仍待验证的推断。
# 三、影响、问题与不同观点
## 3.1 对研究对象的直接影响
从结果、效率、质量、风险、成本、体验或其他适用维度,分析当前影响。
## 3.2 对相关主体与外部环境的影响
说明影响如何传导到相关群体、组织、地区、系统或后续事项;不适用的维度可省略。
## 3.3 主要争议与解释差异
并列材料支持的不同观点、解释或结论,并说明各自的证据基础与局限。
# 四、比较、趋势与不确定性
## 4.1 可比案例或横向比较
在材料允许时选择历史案例、不同地区、不同方案或不同阶段进行比较,说明异同与启示。
## 4.2 发展趋势与关键变量
识别后续可能变化、决定性变量、观察指标和潜在转折点;不把预测写成既成事实。
## 4.3 风险情景与不确定性
说明不同情景的触发条件、可能影响和证据不足之处;对没有依据的推测明确标注。
# 五、结论、建议与后续关注
## 5.1 核心结论
用编号或分点方式收束全文;每条结论都应能回溯到正文中的事实和分析。
## 5.2 可行建议或应对方向
仅在研究材料支持时提出建议;说明适用对象、前提条件、优先级和预期作用。
## 5.3 资料局限与后续关注
列出证据缺口、数据口径差异、尚不能下结论的问题,以及值得持续跟踪的事项。
# 参考资料
仅列出本报告实际使用的来源,并与正文引用保持一致。
> 写作要求:引言保持简洁;每个二级小节按材料充分程度写 2–5 段完整分析,每段围绕一个明确观点展开,采用“事实/证据 → 分析 → 影响或小结”的顺序。章节可根据研究主题合并、增删或调整,但不要为了套模板而编造不适用的主体、数据、案例或引用。`;
/**
* A causal, chapter-to-chapter alternative for topics where the report should
* read as one continuous argument rather than a set of parallel dimensions.
* Each chapter explicitly takes the previous chapter's conclusion as input.
*/
export const PROGRESSIVE_REPORT_OUTLINE = `# 报告标题
# 引言
用 1–2 段说明研究对象、核心问题、研究范围和资料边界;简要说明本文将按“事实基线 → 成因机制 → 影响判断 → 应对方向 → 结论”的递进逻辑展开。
# 一、事实基线:问题是什么
## 1.1 概念界定与研究范围
明确关键概念、时间/对象边界和判断口径,避免后续讨论混用概念。
## 1.2 关键事实、数据与证据
按时间或逻辑顺序列出已经发生的事实、可核验数据、案例和来源。
## 1.3 基线判断与待解问题
基于前两节归纳当前状态、主要矛盾和需要解释的核心问题。
> 本章产出:后续分析必须以本章已经确认的事实和问题为基础,不得在后文补写没有来源支撑的新事实。
# 二、形成机制:为什么会这样
## 2.1 直接原因与触发条件
从第一章识别的问题出发,分析近期事件、条件变化或关键节点如何导致当前结果。
## 2.2 深层条件与关键约束
结合第一章的证据,说明制度、技术、资源、行为、环境或历史条件等长期影响。
## 2.3 因果链条与关键变量
把前两节串成清晰的“条件 → 机制 → 结果”链条,标出决定性变量及仍待验证的环节。
> 本章产出:第三章只能沿用本章已说明的机制评估影响,不能把相关性直接写成因果关系。
# 三、结果与影响:会带来什么
## 3.1 直接结果与短期影响
依据第二章的因果链条,分析对研究对象最直接、最先出现的影响。
## 3.2 传导效应与中长期影响
继续说明影响如何传导至相关主体、外部环境或后续事项;不适用的维度可省略。
## 3.3 权衡、分歧与边界
比较不同影响之间的取舍、材料支持的不同观点,以及结论适用的边界。
> 本章产出:第四章的趋势判断、情景和建议应以本章已评估的影响、风险和权衡为依据。
# 四、趋势与应对:接下来怎么办
## 4.1 未来走势与观察指标
从第三章的影响出发,识别可能变化、关键观察指标和潜在转折点;不把预测写成既成事实。
## 4.2 可选方案与适用条件
针对第三章发现的问题和风险,提出可行的应对方向、前提条件与优先级。
## 4.3 风险情景与验证重点
说明不同情景的触发条件、可能后果,以及下一步需要补充验证的数据或材料。
> 本章产出:第五章只综合前四章已经建立的事实、机制、影响和建议,不新增独立论点。
# 五、综合结论与后续关注
## 5.1 核心结论
按“事实基线 → 原因机制 → 主要影响 → 应对方向”逐项收束,每条结论都可回溯到前文证据。
## 5.2 局限与后续研究
列出资料缺口、数据口径差异、尚不能下结论的问题和后续跟踪清单。
# 参考资料
仅列出本报告实际使用的来源,并与正文引用保持一致。
> 写作要求:除引言外,每个二级小节写 2–5 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。`;
const CUSTOM_REPORT_STRUCTURE_VALUE = "__custom_report_structure__";
const REPORT_STRUCTURE_VALUE_PREFIX = "structure:";
function matchingReportStructureId(
outline: string,
structures: Array<{ id: string; content: string }>,
): string | null {
const normalized = outline.trim();
if (!normalized) return null;
return structures.find((structure) => structure.content.trim() === normalized)?.id ?? null;
}
/**
* The concise structure picker is deliberately usable in the confirmation
* card. Users should not have to open advanced settings merely to choose how
* their report is organised.
*/
export function ReportStructureSelect({
id,
value,
onChange,
onStructureModeChange,
onRetrievalDirectionsChange,
onRetrievalSkillsChange,
}: {
id: string;
value: string;
onChange: (value: string) => void;
onStructureModeChange?: (mode: ReportStructureMode) => void;
onRetrievalDirectionsChange?: (directions: string[]) => void;
onRetrievalSkillsChange?: (skills: string[]) => void;
}) {
// 与「工作流配置」共用报告结构库;创建/编辑/启停后 React Query 的失效会
// 自动刷新本下拉,避免维护一份会逐渐过时的前端硬编码模板。
const structuresQuery = useReportStructures({ status: "enabled" });
const structures = structuresQuery.data ?? [];
const selectedStructureId = matchingReportStructureId(value, structures);
const templateValue = selectedStructureId
? `${REPORT_STRUCTURE_VALUE_PREFIX}${selectedStructureId}`
: CUSTOM_REPORT_STRUCTURE_VALUE;
const selectTemplate = (next: string) => {
if (next === CUSTOM_REPORT_STRUCTURE_VALUE) {
// “自定义” is an intentional blank starting point. Saved structures are
// always available as one-click scaffolds whenever the user wants them.
onChange("");
onStructureModeChange?.(DEFAULT_REPORT_STRUCTURE_MODE);
onRetrievalDirectionsChange?.([]);
onRetrievalSkillsChange?.([]);
return;
}
if (!next.startsWith(REPORT_STRUCTURE_VALUE_PREFIX)) return;
const structureId = next.slice(REPORT_STRUCTURE_VALUE_PREFIX.length);
const structure = structures.find((item) => item.id === structureId);
if (structure) {
onChange(structure.content);
onStructureModeChange?.(structure.structureMode);
onRetrievalDirectionsChange?.(structure.retrievalDirections);
onRetrievalSkillsChange?.(structure.retrievalSkills);
}
};
return (
<div>
<label htmlFor={`${id}-template`} className="mb-1.5 block text-xs font-medium">
报告结构
</label>
<Select value={templateValue} onValueChange={selectTemplate}>
<SelectTrigger id={`${id}-template`} className="h-9 text-sm" aria-label="报告结构模板">
<SelectValue />
</SelectTrigger>
<SelectContent>
{structuresQuery.isLoading && (
<SelectItem value="__report_structures_loading__" disabled>正在加载工作流结构…</SelectItem>
)}
{structuresQuery.isError && (
<SelectItem value="__report_structures_error__" disabled>报告结构库加载失败</SelectItem>
)}
{structures.map((structure) => (
<SelectItem key={structure.id} value={`${REPORT_STRUCTURE_VALUE_PREFIX}${structure.id}`}>
{structure.title}
</SelectItem>
))}
<SelectItem value={CUSTOM_REPORT_STRUCTURE_VALUE}>自定义结构</SelectItem>
</SelectContent>
</Select>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
{selectedStructureId
? "已选工作流结构;可在高级配置中继续编辑章节内容。"
: "当前使用自定义结构;可在高级配置中编辑章节内容。"}
</p>
</div>
);
}
export function StructureModeSelect({
id,
value,
onChange,
}: {
id?: string;
value: ReportStructureMode;
onChange: (value: ReportStructureMode) => void;
}) {
const selected = REPORT_STRUCTURE_MODE_OPTIONS.find((option) => option.value === value)
?? REPORT_STRUCTURE_MODE_OPTIONS[1];
return (
<div>
<label htmlFor={id} className="mb-1.5 block text-xs font-medium">
结构约束
</label>
<Select value={value} onValueChange={(next) => onChange(normalizeReportStructureMode(next))}>
<SelectTrigger id={id} className="h-9 text-sm" aria-label="结构约束">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_STRUCTURE_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
{selected.description}
</p>
</div>
);
}
/** Shared by the initial settings dialog and the write-confirmation card. */
export function ReportOutlineEditor({
id,
value,
onChange,
structureMode,
onStructureModeChange,
onRetrievalDirectionsChange,
onRetrievalSkillsChange,
showStructureSelect = true,
}: {
id: string;
value: string;
onChange: (value: string) => void;
structureMode?: ReportStructureMode;
onStructureModeChange?: (mode: ReportStructureMode) => void;
onRetrievalDirectionsChange?: (directions: string[]) => void;
onRetrievalSkillsChange?: (skills: string[]) => void;
/** The confirmation card already exposes its picker in the primary form. */
showStructureSelect?: boolean;
}) {
return (
<div className="space-y-2">
{showStructureSelect && (
<div className="grid gap-3 sm:grid-cols-2">
<ReportStructureSelect
id={id}
value={value}
onChange={onChange}
onStructureModeChange={onStructureModeChange}
onRetrievalDirectionsChange={onRetrievalDirectionsChange}
onRetrievalSkillsChange={onRetrievalSkillsChange}
/>
{onStructureModeChange && (
<StructureModeSelect
id={`${id}-mode`}
value={structureMode ?? DEFAULT_REPORT_STRUCTURE_MODE}
onChange={onStructureModeChange}
/>
)}
</div>
)}
<label htmlFor={id} className="block pt-1 text-xs font-medium">
报告结构(可编辑)
</label>
<Textarea
id={id}
value={value}
onChange={(event) => onChange(event.target.value)}
maxLength={4000}
className="min-h-28 resize-y text-xs"
placeholder={"例如:\n# 引言\n# 一、问题与事实\n## 1.1 范围与依据"}
/>
<p className="text-[10px] leading-4 text-muted-foreground">
支持 Markdown 标题或编号列表。选择“自定义结构”会清空后从头编辑。
</p>
</div>
);
}
export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = { export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
language: "zh-CN", language: "zh-CN",
tone: "objective", tone: "objective",
@@ -78,7 +397,10 @@ export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
maxRevisionLoops: "2", maxRevisionLoops: "2",
maxGeneratedImages: "2", maxGeneratedImages: "2",
thinkingEnabled: false, thinkingEnabled: false,
customOutline: "", customOutline: DEFAULT_REPORT_OUTLINE,
structureMode: DEFAULT_REPORT_STRUCTURE_MODE,
retrievalDirections: [],
retrievalSkills: [],
}; };
/** Seed the card's editable options from the frozen session config snapshot. */ /** Seed the card's editable options from the frozen session config snapshot. */
@@ -100,7 +422,12 @@ export function advancedOptionsFromConfig(config?: Record<string, unknown>): Adv
maxRevisionLoops: numberish("max_revision_loops", DEFAULT_ADVANCED_OPTIONS.maxRevisionLoops), maxRevisionLoops: numberish("max_revision_loops", DEFAULT_ADVANCED_OPTIONS.maxRevisionLoops),
maxGeneratedImages: numberish("max_generated_images", DEFAULT_ADVANCED_OPTIONS.maxGeneratedImages), maxGeneratedImages: numberish("max_generated_images", DEFAULT_ADVANCED_OPTIONS.maxGeneratedImages),
thinkingEnabled: config?.thinking_enabled === true, thinkingEnabled: config?.thinking_enabled === true,
customOutline: typeof config?.custom_outline === "string" ? config.custom_outline : "", customOutline: typeof config?.custom_outline === "string" && config.custom_outline.trim()
? config.custom_outline
: DEFAULT_REPORT_OUTLINE,
structureMode: normalizeReportStructureMode(config?.structure_mode),
retrievalDirections: normalizeRetrievalDirections(config?.retrieval_directions),
retrievalSkills: normalizeRetrievalSkills(config?.retrieval_skills),
}; };
} }
@@ -138,6 +465,9 @@ export function buildReportCardConfig(
} }
if (generateImages) config.max_generated_images = boundedInteger(options.maxGeneratedImages, 2, 1, 4); if (generateImages) config.max_generated_images = boundedInteger(options.maxGeneratedImages, 2, 1, 4);
if (options.customOutline.trim()) config.custom_outline = options.customOutline.trim().slice(0, 4_000); if (options.customOutline.trim()) config.custom_outline = options.customOutline.trim().slice(0, 4_000);
config.structure_mode = normalizeReportStructureMode(options.structureMode);
config.retrieval_directions = normalizeRetrievalDirections(options.retrievalDirections);
config.retrieval_skills = normalizeRetrievalSkills(options.retrievalSkills);
return config; return config;
} }
@@ -145,6 +475,15 @@ export function ReportConfigCard({
availableModes, availableModes,
defaultMode, defaultMode,
defaultOptions, defaultOptions,
/**
* A report structure chosen from the collector's workflow configuration
* while this card is open. It supersedes only the outline, leaving choices
* already made in this card intact.
*/
outlineOverride,
structureModeOverride,
retrievalDirectionsOverride,
retrievalSkillsOverride,
defaultGenerateImages, defaultGenerateImages,
modelLabel, modelLabel,
materialCount, materialCount,
@@ -157,6 +496,10 @@ export function ReportConfigCard({
availableModes: ResearchMode[]; availableModes: ResearchMode[];
defaultMode: ResearchMode; defaultMode: ResearchMode;
defaultOptions: AdvancedResearchOptions; defaultOptions: AdvancedResearchOptions;
outlineOverride?: string;
structureModeOverride?: ReportStructureMode;
retrievalDirectionsOverride?: string[];
retrievalSkillsOverride?: string[];
defaultGenerateImages: boolean; defaultGenerateImages: boolean;
modelLabel: string; modelLabel: string;
/** Number of unique, harvestable materials visible in the collector thread. */ /** Number of unique, harvestable materials visible in the collector thread. */
@@ -170,14 +513,47 @@ export function ReportConfigCard({
const [mode, setMode] = useState<ResearchMode>( const [mode, setMode] = useState<ResearchMode>(
availableModes.includes(defaultMode) ? defaultMode : (availableModes[0] ?? "basic"), availableModes.includes(defaultMode) ? defaultMode : (availableModes[0] ?? "basic"),
); );
const [options, setOptions] = useState<AdvancedResearchOptions>(defaultOptions); const [options, setOptions] = useState<AdvancedResearchOptions>(() => ({
...defaultOptions,
customOutline: outlineOverride ?? defaultOptions.customOutline,
structureMode: structureModeOverride ?? defaultOptions.structureMode,
retrievalDirections: retrievalDirectionsOverride ?? defaultOptions.retrievalDirections,
retrievalSkills: retrievalSkillsOverride ?? defaultOptions.retrievalSkills,
}));
const [generateImages, setGenerateImages] = useState(defaultGenerateImages); const [generateImages, setGenerateImages] = useState(defaultGenerateImages);
const [advancedOpen, setAdvancedOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false);
// The collector composer and this confirmation card are both reachable while
// a session is awaiting report writing. Selecting a structure in the former
// must be reflected here, but should not reset the card's mode, image switch,
// or any other in-progress edits.
useEffect(() => { useEffect(() => {
setOptions(defaultOptions); if (
setGenerateImages(defaultGenerateImages); outlineOverride === undefined
}, [defaultOptions, defaultGenerateImages]); && structureModeOverride === undefined
&& retrievalDirectionsOverride === undefined
&& retrievalSkillsOverride === undefined
) return;
setOptions((current) => {
const nextOutline = outlineOverride ?? current.customOutline;
const nextMode = structureModeOverride ?? current.structureMode;
const nextDirections = retrievalDirectionsOverride ?? current.retrievalDirections;
const nextSkills = retrievalSkillsOverride ?? current.retrievalSkills;
if (
current.customOutline === nextOutline
&& current.structureMode === nextMode
&& current.retrievalDirections.join("\n") === nextDirections.join("\n")
&& current.retrievalSkills.join("\n") === nextSkills.join("\n")
) return current;
return {
...current,
customOutline: nextOutline,
structureMode: nextMode,
retrievalDirections: nextDirections,
retrievalSkills: nextSkills,
};
});
}, [outlineOverride, structureModeOverride, retrievalDirectionsOverride, retrievalSkillsOverride]);
const modes = useMemo( const modes = useMemo(
() => (availableModes.length ? availableModes : (["basic", "quick", "detailed", "deep", "multi_agent"] as ResearchMode[])), () => (availableModes.length ? availableModes : (["basic", "quick", "detailed", "deep", "multi_agent"] as ResearchMode[])),
@@ -203,7 +579,7 @@ export function ReportConfigCard({
</div> </div>
</div> </div>
<div className="mt-4 grid gap-3 sm:grid-cols-2"> <div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<label className="block"> <label className="block">
<span className="mb-1.5 block text-xs font-medium">报告模式</span> <span className="mb-1.5 block text-xs font-medium">报告模式</span>
<Select value={mode} onValueChange={(value) => setMode(value as ResearchMode)}> <Select value={mode} onValueChange={(value) => setMode(value as ResearchMode)}>
@@ -216,6 +592,19 @@ export function ReportConfigCard({
</Select> </Select>
<span className="mt-1 block text-[11px] text-muted-foreground">{MODE_HINTS[mode]}</span> <span className="mt-1 block text-[11px] text-muted-foreground">{MODE_HINTS[mode]}</span>
</label> </label>
<ReportStructureSelect
id="report-card-structure"
value={options.customOutline}
onChange={(value) => update("customOutline", value)}
onStructureModeChange={(mode) => update("structureMode", mode)}
onRetrievalDirectionsChange={(directions) => update("retrievalDirections", directions)}
onRetrievalSkillsChange={(skills) => update("retrievalSkills", skills)}
/>
<StructureModeSelect
id="report-card-structure-mode"
value={options.structureMode}
onChange={(mode) => update("structureMode", mode)}
/>
{imageGenerationAvailable && ( {imageGenerationAvailable && (
<div> <div>
<span className="mb-1.5 block text-xs font-medium">报告配图</span> <span className="mb-1.5 block text-xs font-medium">报告配图</span>
@@ -241,7 +630,7 @@ export function ReportConfigCard({
> >
<SlidersHorizontal className="h-3.5 w-3.5 text-muted-foreground" /> <SlidersHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
高级配置 高级配置
<span className="font-normal text-muted-foreground">语言、语气、自定义结构等</span> <span className="font-normal text-muted-foreground">语言、语气、检索预算与结构细调</span>
<ChevronRight className={`ml-auto h-3.5 w-3.5 text-muted-foreground transition-transform ${advancedOpen ? "rotate-90" : ""}`} /> <ChevronRight className={`ml-auto h-3.5 w-3.5 text-muted-foreground transition-transform ${advancedOpen ? "rotate-90" : ""}`} />
</button> </button>
{advancedOpen && ( {advancedOpen && (
@@ -278,15 +667,27 @@ export function ReportConfigCard({
<CardNumber label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(v) => update("maxRevisionLoops", v)} /> <CardNumber label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(v) => update("maxRevisionLoops", v)} />
)} )}
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<span className="mb-1.5 block text-xs font-medium">自定义报告结构</span> <ReportOutlineEditor
<Textarea id="report-card-outline"
value={options.customOutline} value={options.customOutline}
onChange={(event) => update("customOutline", event.target.value)} onChange={(value) => update("customOutline", value)}
maxLength={4000} structureMode={options.structureMode}
className="min-h-20 resize-y text-xs" onStructureModeChange={(mode) => update("structureMode", mode)}
placeholder={"例如:\n# 执行摘要\n## 市场与背景\n## 机会与风险\n## 建议"} onRetrievalDirectionsChange={(directions) => update("retrievalDirections", directions)}
onRetrievalSkillsChange={(skills) => update("retrievalSkills", skills)}
showStructureSelect={false}
/>
</div>
<div className="flex justify-end sm:col-span-2">
<WorkflowConfigButton
appliedContent={options.customOutline}
onApply={(value, mode, directions, skills) => {
update("customOutline", value);
update("structureMode", mode);
update("retrievalDirections", directions);
update("retrievalSkills", skills);
}}
/> />
<p className="mt-1 text-[10px] text-muted-foreground">支持 Markdown 标题或编号列表,作为报告写作结构要求。</p>
</div> </div>
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4 sm:col-span-2"> <label className="flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4 sm:col-span-2">
<input <input
@@ -0,0 +1,124 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { ArrowLeft, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import type { SetupReportStructureApprovalPayload } from "@/components/workspace/messages";
import {
normalizeReportStructureMode,
normalizeRetrievalDirections,
normalizeRetrievalSkills,
useReportStructureMutations,
} from "@/strategy-components/api/report-structures";
import {
EMPTY_REPORT_STRUCTURE_FORM,
ReportStructureFormFields,
type ReportStructureFormValue,
} from "./ReportStructureFormFields";
import { ReportStructureSetupChat } from "./ReportStructureSetupChat";
/**
* 深度研究「智能新增报告结构」页。
*
* 左对话(几轮问清需求 → 确认卡回填)+ 右表单(可纯手工填写)。保存后回到来源页。
*/
export default function ReportStructureCreatePage() {
const navigate = useNavigate();
const location = useLocation();
const mutations = useReportStructureMutations();
const [form, setForm] = useState<ReportStructureFormValue>(EMPTY_REPORT_STRUCTURE_FORM);
const [saving, setSaving] = useState(false);
const backPath = useMemo(() => {
const path = location.pathname.replace(/\/structures\/new\/?$/, "");
return path || "/page/workspace/deep-research";
}, [location.pathname]);
const applyProposal = useCallback((payload: SetupReportStructureApprovalPayload) => {
setForm((current) => ({
title: payload.title?.trim() || current.title,
name: payload.name?.trim() || current.name,
content: payload.content?.trim() || current.content,
structureMode: payload.structure_mode
? normalizeReportStructureMode(payload.structure_mode)
: current.structureMode,
retrievalDirections:
payload.retrieval_directions && payload.retrieval_directions.length > 0
? normalizeRetrievalDirections(payload.retrieval_directions)
: current.retrievalDirections,
retrievalSkills:
payload.retrieval_skills && payload.retrieval_skills.length > 0
? normalizeRetrievalSkills(payload.retrieval_skills)
: current.retrievalSkills,
}));
}, []);
const handleSave = async () => {
if (!form.title.trim() || !form.content.trim()) {
toast.error("请填写标题和结构内容");
return;
}
setSaving(true);
try {
await mutations.create.mutateAsync({
title: form.title.trim(),
name: form.name.trim(),
content: form.content,
structureMode: form.structureMode,
retrievalDirections: form.retrievalDirections,
retrievalSkills: form.retrievalSkills,
});
toast.success(`已保存报告结构「${form.title.trim()}」`);
navigate(backPath, { replace: true, state: { openWorkflowConfig: true } });
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
setSaving(false);
}
};
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-3">
<div className="flex min-w-0 items-center gap-2">
<Button variant="ghost" size="icon-sm" onClick={() => navigate(backPath)} aria-label="返回">
<ArrowLeft className="size-4" />
</Button>
<div className="min-w-0">
<h1 className="text-base font-semibold">智能新增报告结构</h1>
<p className="text-xs text-muted-foreground">
左侧用问答描述需求,出现确认卡后点「应用到表单」;也可直接在右侧手工填写。
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm" onClick={() => navigate(backPath)} disabled={saving}>
取消
</Button>
<Button size="sm" onClick={() => void handleSave()} disabled={saving}>
{saving && <Loader2 className="mr-1.5 size-4 animate-spin" />}
保存
</Button>
</div>
</header>
<div className="flex min-h-0 flex-1">
<div className="min-w-0 flex-[7] border-r border-border">
<ReportStructureSetupChat onApprove={applyProposal} />
</div>
<aside className="flex min-w-0 flex-[3] flex-col overflow-hidden">
<div className="shrink-0 border-b border-border px-5 py-3">
<h2 className="text-sm font-semibold">报告结构配置</h2>
<p className="text-xs text-muted-foreground">对话确认后自动填充,也可随时改。</p>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-5">
<ReportStructureFormFields value={form} onChange={setForm} />
</div>
</aside>
</div>
</div>
);
}
@@ -0,0 +1,232 @@
"use client";
import { useMemo, useState } from "react";
import { X } from "lucide-react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useSkills } from "@/core/skills/hooks";
import { desensitize } from "@/lib/desensitize";
import {
type ReportStructureInput,
type ReportStructureMode,
MAX_RETRIEVAL_DIRECTIONS,
MAX_RETRIEVAL_DIRECTION_CHARS,
MAX_RETRIEVAL_SKILLS,
REPORT_STRUCTURE_MODE_OPTIONS,
normalizeReportStructureMode,
normalizeRetrievalDirections,
normalizeRetrievalSkills,
} from "@/strategy-components/api/report-structures";
export type ReportStructureFormValue = ReportStructureInput;
export const EMPTY_REPORT_STRUCTURE_FORM: ReportStructureFormValue = {
title: "",
name: "",
content: "",
structureMode: "adaptive",
retrievalDirections: [],
retrievalSkills: [],
};
function skillChipLabel(name: string, nameZh?: string | null) {
const zh = nameZh?.trim();
return zh || desensitize(name);
}
function RetrievalSkillPicker({
value,
onChange,
}: {
value: string[];
onChange: (next: string[]) => void;
}) {
const { skills, isLoading } = useSkills();
const selected = normalizeRetrievalSkills(value);
const byName = useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]);
const remaining = useMemo(
() => skills.filter((skill) => skill.enabled !== false && !selected.includes(skill.name)),
[skills, selected],
);
const atCap = selected.length >= MAX_RETRIEVAL_SKILLS;
return (
<div>
<span className="mb-1.5 block text-sm font-medium">检索来源</span>
<div className="flex flex-wrap gap-1.5">
{selected.map((name) => (
<span
key={name}
className="inline-flex max-w-full items-center gap-1 rounded-full border border-border bg-muted/50 px-2 py-0.5 text-xs"
>
<span className="truncate">{skillChipLabel(name, byName.get(name)?.name_zh)}</span>
<button
type="button"
aria-label={`移除检索技能 ${name}`}
className="rounded-full p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={() => onChange(selected.filter((item) => item !== name))}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
<Select
key={selected.join("|")}
onValueChange={(name) => {
if (!name || atCap) return;
onChange(normalizeRetrievalSkills([...selected, name]));
}}
disabled={atCap || remaining.length === 0}
>
<SelectTrigger aria-label="添加检索技能" className="mt-2">
<SelectValue placeholder={isLoading ? "正在加载技能…" : atCap ? "已达上限" : "选择技能添加到检索来源"} />
</SelectTrigger>
<SelectContent>
{remaining.map((skill) => (
<SelectItem key={skill.name} value={skill.name}>
{skillChipLabel(skill.name, skill.name_zh)}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="mt-1 block text-[11px] text-muted-foreground">
选填。指定后,搜集素材会调用这些技能检索;不填则仍由收集员使用默认联网/知识库工具。
</span>
</div>
);
}
export function ReportStructureFormFields({
value,
onChange,
}: {
value: ReportStructureFormValue;
onChange: (next: ReportStructureFormValue) => void;
}) {
const [directionDraft, setDirectionDraft] = useState("");
const set = <K extends keyof ReportStructureFormValue>(key: K, next: ReportStructureFormValue[K]) =>
onChange({ ...value, [key]: next });
const commitDirectionDraft = () => {
const next = normalizeRetrievalDirections([...value.retrievalDirections, directionDraft]);
set("retrievalDirections", next);
setDirectionDraft("");
};
return (
<div className="space-y-3">
<label className="block">
<span className="mb-1.5 block text-sm font-medium">标题</span>
<Input
value={value.title}
onChange={(event) => set("title", event.target.value)}
maxLength={256}
placeholder="例如:行业研究通用结构"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">描述</span>
<Input
value={value.name}
onChange={(event) => set("name", event.target.value)}
maxLength={256}
placeholder="可选,一句话描述这套结构的适用场景"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">结构约束</span>
<Select
value={value.structureMode}
onValueChange={(next) => set("structureMode", normalizeReportStructureMode(next) as ReportStructureMode)}
>
<SelectTrigger aria-label="结构约束">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_STRUCTURE_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="mt-1 block text-[11px] text-muted-foreground">
{REPORT_STRUCTURE_MODE_OPTIONS.find((option) => option.value === value.structureMode)?.description}
</span>
</label>
<div>
<span className="mb-1.5 block text-sm font-medium">检索方向</span>
<div className="flex flex-wrap gap-1.5">
{value.retrievalDirections.map((item) => (
<span
key={item}
className="inline-flex max-w-full items-center gap-1 rounded-full border border-border bg-muted/50 px-2 py-0.5 text-xs"
>
<span className="truncate">{item}</span>
<button
type="button"
aria-label={`移除检索方向 ${item}`}
className="rounded-full p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={() =>
set(
"retrievalDirections",
value.retrievalDirections.filter((entry) => entry !== item),
)
}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
<Input
value={directionDraft}
onChange={(event) => setDirectionDraft(event.target.value)}
maxLength={MAX_RETRIEVAL_DIRECTION_CHARS}
placeholder="输入后按回车添加,可写多个;用作拆词方向,不是直接搜索的词"
className="mt-2"
disabled={value.retrievalDirections.length >= MAX_RETRIEVAL_DIRECTIONS}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
commitDirectionDraft();
}
}}
onBlur={() => {
if (directionDraft.trim()) commitDirectionDraft();
}}
/>
<span className="mt-1 block text-[11px] text-muted-foreground">
选填。指定后,收集员会按这些方向拆分具体检索词,而不是把方向名称直接拿去搜;不填则仍由收集员自行拆词。
</span>
</div>
<RetrievalSkillPicker
value={value.retrievalSkills}
onChange={(next) => set("retrievalSkills", next)}
/>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">结构内容</span>
<Textarea
value={value.content}
onChange={(event) => set("content", event.target.value)}
maxLength={20000}
placeholder={"报告结构正文,支持 Markdown 标题,例如:\n# 引言\n# 一、事实与背景\n## 1.1 研究范围"}
className="min-h-[260px] resize-y font-mono text-xs"
/>
<span className="mt-1 block text-[11px] text-muted-foreground">
选中卡片时,该内容会填充到高级配置的「报告结构」。
</span>
</label>
</div>
);
}
@@ -0,0 +1,238 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Sparkles, Zap } from "lucide-react";
import { toast } from "sonner";
import {
PromptInput,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
} from "@/components/ai-elements/prompt-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import {
MessageList,
type SetupReportStructureApprovalPayload,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { extractContentFromMessage, extractReasoningContentFromMessage, hasToolCalls } from "@/core/messages/utils";
import { getAPIClient } from "@/core/api";
import { useModels } from "@/core/models/hooks";
import { useSkills } from "@/core/skills/hooks";
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
import { cn } from "@/lib/utils";
import { ReportStructureWelcome } from "./ReportStructureWelcome";
/**
* 报告结构智能新增的左侧对话(对标 WritingSetupChat / 新建智能体)。
*
* context 带 ``is_report_structure_setup``,后端只挂 setup_report_structure + ask_clarification。
* 模型提交后被拦截成确认卡;点「应用到表单」回填右侧,不落库。
*/
export function ReportStructureSetupChat({
onApprove,
}: {
onApprove: (payload: SetupReportStructureApprovalPayload) => void;
}) {
const threadId = useMemo(() => uuid(), []);
const [threadPrepared, setThreadPrepared] = useState(false);
const [chatModel, setChatModel] = useState("");
const [quickMode, setQuickMode] = useState(false);
const { models, isLoading: modelsLoading } = useModels();
const { skills } = useSkills();
useEffect(() => {
if (models.length === 0) return;
setChatModel((prev) => (prev && models.some((m) => m.name === prev) ? prev : models[0]!.name));
}, [models]);
const validRetrievalSkills = useMemo(
() =>
skills
.filter((skill) => skill.enabled !== false)
.slice(0, 40)
.map((skill) => ({ name: skill.name, name_zh: skill.name_zh ?? "" })),
[skills],
);
const [thread, sendMessage] = useThreadStream({
threadId,
context: {
mode: "flash",
is_report_structure_setup: true,
writing_setup_quick: quickMode,
valid_retrieval_skills: validRetrievalSkills,
model_name: chatModel || undefined,
},
});
const ensureThreadExists = useCallback(async () => {
if (threadPrepared) return;
await getAPIClient().threads.create({
threadId,
metadata: { thread_type: "report_structure_setup", system: true },
});
setThreadPrepared(true);
}, [threadId, threadPrepared]);
const handleChatSubmit = useCallback(
async (text: string) => {
if (thread.isLoading) {
void thread.stop();
return;
}
const trimmed = text.trim();
if (!trimmed) return;
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
[thread, ensureThreadExists, sendMessage, threadId],
);
const handleClarificationSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
[thread, ensureThreadExists, sendMessage, threadId],
);
const submitStatus = thread.error ? "error" : thread.isLoading ? "streaming" : "ready";
const handleApprove = useCallback(
async (payload: SetupReportStructureApprovalPayload) => {
onApprove(payload);
toast.success("已应用到表单,可继续编辑后保存");
},
[onApprove],
);
const handleReject = useCallback(async () => {
await handleChatSubmit("这份配置先不应用,请继续帮我调整。");
}, [handleChatSubmit]);
const showWelcome = thread.messages.length === 0 && !thread.isLoading;
const showThinkingPlaceholder = useMemo(() => {
if (!thread.isLoading) return false;
const last = thread.messages[thread.messages.length - 1];
if (!last || last.type !== "ai") return true;
const hasOutput =
!!extractContentFromMessage(last).trim() ||
!!extractReasoningContentFromMessage(last) ||
hasToolCalls(last);
return !hasOutput;
}, [thread.isLoading, thread.messages]);
return (
<ThreadContext.Provider value={{ thread }}>
<ArtifactsProvider>
<div className="flex size-full flex-col bg-background dark:bg-transparent">
{!showWelcome && (
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
<Sparkles className="size-4 text-primary" />
<h2 className="text-sm font-semibold">结构助手</h2>
<span className="text-muted-foreground text-xs">描述你想要的报告结构,我来帮你填好右侧表单</span>
</header>
)}
<div className="min-h-0 flex-1">
{showWelcome ? (
<ReportStructureWelcome />
) : (
<MessageList
className="size-full"
threadId={threadId}
thread={thread}
onClarificationSubmit={handleClarificationSubmit}
onSetupReportStructureApprove={handleApprove}
onSetupReportStructureReject={handleReject}
/>
)}
</div>
{showThinkingPlaceholder && (
<div className="shrink-0 border-t bg-background px-4 py-2 dark:bg-transparent">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="inline-block size-1.5 animate-pulse rounded-full bg-blue-500" />
正在整理…
</div>
</div>
)}
<div className="bg-background dark:bg-transparent shrink-0 border-t px-4 py-4">
<div className="space-y-3 rounded-2xl border border-[#ece5e4] bg-card p-3 shadow-sm dark:border-[#1B477E] dark:bg-[#022248]/40">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<Sparkles className="size-3.5 shrink-0 text-primary" />
<span className="shrink-0">当前对话模型</span>
{modelsLoading || models.length === 0 ? (
<div className="border-input bg-background text-muted-foreground flex h-7 min-w-[9rem] items-center rounded-md border px-2 text-xs shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
{modelsLoading ? "加载中…" : "暂无模型"}
</div>
) : (
<Select value={chatModel} onValueChange={setChatModel} disabled={thread.isLoading}>
<SelectTrigger
className={cn(
"border-input bg-background h-7 min-w-[9rem] max-w-[14rem] px-2 text-xs shadow-xs outline-none",
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
)}
>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.name} value={model.name}>
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<label
className={cn(
"flex shrink-0 cursor-pointer select-none items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition",
quickMode
? "border-primary/40 bg-primary/5 text-primary dark:border-[#1B477E] dark:bg-[#022248]"
: "border-input bg-background text-muted-foreground dark:border-[#1B477E] dark:bg-[#022248]",
)}
title="开启后跳过逐项澄清,给出主题即直接生成大纲"
>
<Zap className={cn("size-3.5 shrink-0", quickMode && "fill-current")} />
<span className="shrink-0">快捷模式</span>
<Switch
checked={quickMode}
onCheckedChange={setQuickMode}
disabled={thread.isLoading}
className="ml-0.5 scale-90"
/>
</label>
</div>
<PromptInput onSubmit={({ text }) => void handleChatSubmit(text)}>
<PromptInputTextarea placeholder="例如:帮我建一套市场分析报告结构,固定大纲,检索方向是各品牌汽车销量" />
<PromptInputFooter className="justify-end">
<PromptInputSubmit status={submitStatus} size="icon-sm" />
</PromptInputFooter>
</PromptInput>
</div>
</div>
</div>
</ArtifactsProvider>
</ThreadContext.Provider>
);
}
@@ -0,0 +1,68 @@
import { Compass, ListTree, Sparkles } from "lucide-react";
/**
* 报告结构智能新增页左栏欢迎屏(对标 AI 写作 WritingWelcome)。
* 仅在助手对话还没有任何消息时显示。
*/
const STEPS = [
{
n: "1",
title: "对话描述需求",
desc: "用一句话说你想要什么报告结构,例如「市场分析固定大纲,检索方向是各品牌销量」。",
icon: Sparkles,
iconClass: "bg-indigo-50 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300",
},
{
n: "2",
title: "确认大纲卡片",
desc: "助手会先给出 Markdown 大纲,确认后出现「应用到表单」卡片,一点即可填到右侧。",
icon: ListTree,
iconClass: "bg-amber-50 text-amber-600 dark:bg-amber-500/15 dark:text-amber-300",
},
{
n: "3",
title: "保存入库",
desc: "右侧表单可再改标题、约束、检索方向。点保存后回到工作流配置列表。",
icon: Compass,
iconClass: "bg-emerald-50 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-300",
},
];
export function ReportStructureWelcome() {
return (
<div className="size-full overflow-y-auto">
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col px-6 py-7">
<div className="flex flex-1 flex-col justify-center gap-6">
<div>
<h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white">
智能新增报告结构
</h2>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
左侧用对话把结构聊清楚,确认后填到右侧表单;也可以
<strong className="font-semibold text-slate-700 dark:text-white/80">直接在右侧手工填写</strong>
,再点保存。
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{STEPS.map((step) => (
<div
key={step.n}
className="flex flex-col gap-2 rounded-xl border border-slate-100/80 bg-slate-50/50 p-4 transition-colors hover:bg-slate-50 dark:border-white/5 dark:bg-white/[0.02] dark:hover:bg-white/5"
>
<div className={`flex size-8 items-center justify-center rounded-lg ${step.iconClass}`}>
<step.icon className="size-4" />
</div>
<h4 className="text-sm font-semibold text-slate-800 dark:text-white">
{step.n}. {step.title}
</h4>
<p className="text-xs leading-relaxed text-slate-500 dark:text-white/55">{step.desc}</p>
</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -10,11 +10,12 @@
* 切预览 + 脉冲、复制/另存、展示层脱敏)。 * 切预览 + 脉冲、复制/另存、展示层脱敏)。
*/ */
import type { BaseStream } from "@langchain/langgraph-sdk/react"; import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { FileTextIcon, XIcon } from "lucide-react"; import { FileTextIcon, Loader2Icon, SparklesIcon, XIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels"; import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation"; import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { ArtifactAction } from "@/components/ai-elements/artifact";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
ResizableHandle, ResizableHandle,
@@ -29,6 +30,7 @@ import { DocumentRewriteDialog } from "@/components/workspace/artifacts/document
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite"; import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
import type { ReportStructureMode } from "@/strategy-components/api/report-structures";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const CLOSE_MODE = { chat: 100, artifacts: 0 }; const CLOSE_MODE = { chat: 100, artifacts: 0 };
@@ -56,9 +58,21 @@ interface ResearchSandboxPanelProps {
instruction: string; instruction: string;
style: DocumentRewriteStyle; style: DocumentRewriteStyle;
modelName: string | null; modelName: string | null;
reportOutline: string;
structureMode?: ReportStructureMode;
/** Regeneration forks the report session before it starts the rewrite. */
createNewReport?: boolean;
}) => void; }) => void;
documentRewriteActive?: boolean; documentRewriteActive?: boolean;
/** Distinguishes a new report generation from an in-place full rewrite. */
documentRewriteOperation?: "rewrite" | "generate" | null;
onCancelDocumentRewrite?: () => void; onCancelDocumentRewrite?: () => void;
/** A completed-report card can request opening the same safe rewrite dialog. */
documentRewriteDialogRequest?: number;
/** Controls whether a requested dialog creates another report or rewrites this one. */
documentRewriteDialogPurpose?: "rewrite" | "regenerate";
/** Outline selected from the research workflow, used to preselect a saved structure. */
initialDocumentRewriteOutline?: string;
reportCharCount?: number; reportCharCount?: number;
} }
@@ -72,12 +86,18 @@ export function ResearchSandboxPanel({
onDownload, onDownload,
onDocumentRewriteStart, onDocumentRewriteStart,
documentRewriteActive = false, documentRewriteActive = false,
documentRewriteOperation = null,
onCancelDocumentRewrite, onCancelDocumentRewrite,
documentRewriteDialogRequest = 0,
documentRewriteDialogPurpose = "rewrite",
initialDocumentRewriteOutline,
reportCharCount = 0, reportCharCount = 0,
}: ResearchSandboxPanelProps) { }: ResearchSandboxPanelProps) {
const layoutRef = useRef<GroupImperativeHandle>(null); const layoutRef = useRef<GroupImperativeHandle>(null);
const { selectedArtifact, setOpen, deselect } = useArtifacts(); const { selectedArtifact, setOpen, deselect } = useArtifacts();
const [rewriteDialogOpen, setRewriteDialogOpen] = useState(false); const [rewriteDialogOpen, setRewriteDialogOpen] = useState(false);
const [rewriteDialogPurpose, setRewriteDialogPurpose] = useState<"rewrite" | "regenerate">("rewrite");
const generatingNewReport = documentRewriteOperation === "generate";
useEffect(() => { useEffect(() => {
if (!layoutRef.current) return; if (!layoutRef.current) return;
@@ -107,6 +127,13 @@ export function ResearchSandboxPanel({
onClose(); onClose();
}; };
useEffect(() => {
if (documentRewriteDialogRequest > 0) {
setRewriteDialogPurpose(documentRewriteDialogPurpose);
setRewriteDialogOpen(true);
}
}, [documentRewriteDialogPurpose, documentRewriteDialogRequest]);
return ( return (
<ThreadContext.Provider value={{ thread: threadStub, isMock: false }}> <ThreadContext.Provider value={{ thread: threadStub, isMock: false }}>
<ResizablePanelGroup <ResizablePanelGroup
@@ -134,24 +161,12 @@ export function ResearchSandboxPanel({
id="artifacts" id="artifacts"
> >
<div className="relative h-full bg-[#fffdfa] p-4"> <div className="relative h-full bg-[#fffdfa] p-4">
{selectedArtifact && previewThreadId ? ( {selectedArtifact && previewThreadId && stubMessages.length > 0 ? (
<> <>
{onDocumentRewriteStart && (
<div className="absolute top-7 right-36 z-30">
<Button
size="sm"
variant="outline"
disabled={documentRewriteActive}
onClick={() => setRewriteDialogOpen(true)}
>
{documentRewriteActive ? "正在改写" : "AI 全文改写"}
</Button>
</div>
)}
{documentRewriteActive && onCancelDocumentRewrite && ( {documentRewriteActive && onCancelDocumentRewrite && (
<div className="absolute right-36 bottom-7 z-30"> <div className="absolute right-36 bottom-7 z-30">
<Button size="sm" variant="outline" onClick={onCancelDocumentRewrite}> <Button size="sm" variant="outline" onClick={onCancelDocumentRewrite}>
停止改写 停止{generatingNewReport ? "生成" : "改写"}
</Button> </Button>
</div> </div>
)} )}
@@ -159,8 +174,32 @@ export function ResearchSandboxPanel({
className="size-full" className="size-full"
filepath={selectedArtifact} filepath={selectedArtifact}
threadId={previewThreadId} threadId={previewThreadId}
researchSessionId={previewThreadId}
onClose={handleArtifactsClose} onClose={handleArtifactsClose}
onDownloadClick={onDownload} onDownloadClick={onDownload}
toolbarActions={
onDocumentRewriteStart ? (
<ArtifactAction
icon={documentRewriteActive ? Loader2Icon : SparklesIcon}
label={documentRewriteActive
? generatingNewReport
? "正在生成新报告"
: "正在全文改写"
: "AI 全文改写"}
tooltip={
documentRewriteActive
? generatingNewReport
? "正在生成独立的新报告,当前报告不会被覆盖"
: "正在生成临时草稿,原报告尚未被覆盖"
: "使用 AI 重写完整 report.md"
}
onClick={() => {
setRewriteDialogPurpose("rewrite");
setRewriteDialogOpen(true);
}}
/>
) : null
}
/> />
</> </>
) : ( ) : (
@@ -173,7 +212,11 @@ export function ResearchSandboxPanel({
<ConversationEmptyState <ConversationEmptyState
icon={<FileTextIcon />} icon={<FileTextIcon />}
title="report.md" title="report.md"
description="报告开始撰写后将在此实时预览" description={
documentRewriteActive && generatingNewReport
? "正在生成新报告,正文将在模型首个字符输出后显示"
: "报告开始撰写后将在此实时预览"
}
/> />
</div> </div>
)} )}
@@ -186,9 +229,30 @@ export function ResearchSandboxPanel({
onOpenChange={setRewriteDialogOpen} onOpenChange={setRewriteDialogOpen}
fileName="report.md" fileName="report.md"
charCount={reportCharCount} charCount={reportCharCount}
initialReportOutline={initialDocumentRewriteOutline}
operationLabel={rewriteDialogPurpose === "regenerate" ? "确认新报告生成要求" : undefined}
operationDescription={
rewriteDialogPurpose === "regenerate"
? "请确认新报告的结构、资料范围和生成要求;确认后会创建一篇独立的新报告,当前报告不会被覆盖。"
: undefined
}
submitLabel={rewriteDialogPurpose === "regenerate" ? "确认并生成新报告" : undefined}
operation={rewriteDialogPurpose === "regenerate" ? "generate" : "rewrite"}
onRewriteOriginal={rewriteDialogPurpose === "regenerate"
? (input) => {
setRewriteDialogOpen(false);
onDocumentRewriteStart({
...input,
createNewReport: false,
});
}
: undefined}
onStart={(input) => { onStart={(input) => {
setRewriteDialogOpen(false); setRewriteDialogOpen(false);
onDocumentRewriteStart(input); onDocumentRewriteStart({
...input,
createNewReport: rewriteDialogPurpose === "regenerate",
});
}} }}
/> />
)} )}
File diff suppressed because it is too large. Load diff
Loaded 100 of 259 files, more files were not shown because too many files have changed in this diff. Show more