Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b638b541d0 | ||
|
|
34474bd5fc |
No files matched your search
-132
@@ -1,132 +0,0 @@
|
|||||||
# Workspace/editor
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/launch.json
|
|
||||||
!.vscode/tasks.json
|
|
||||||
!.vscode/extensions.json
|
|
||||||
!.vscode/settings.json
|
|
||||||
frontend-web/.vscode/
|
|
||||||
node_modules/
|
|
||||||
# Claude Code local-only settings (per-user permissions, not shared)
|
|
||||||
.claude/settings.local.json
|
|
||||||
|
|
||||||
# Local runtime logs and pid files
|
|
||||||
.runtime/
|
|
||||||
logs/
|
|
||||||
offline-backend-20260512/backend/logs/
|
|
||||||
|
|
||||||
# Frontend local/runtime artifacts
|
|
||||||
frontend-web/.env
|
|
||||||
frontend-web/.env.local
|
|
||||||
frontend-web/.env.*.local
|
|
||||||
frontend-web/node_modules/
|
|
||||||
frontend-web/.pnpm-store/
|
|
||||||
frontend-web/tsconfig.tsbuildinfo
|
|
||||||
frontend-web/vite-dev.log
|
|
||||||
frontend-web/vite-dev.err.log
|
|
||||||
|
|
||||||
# Frontend local packages/archives/docs (non-source)
|
|
||||||
frontend-web/pnpm-lock.yaml
|
|
||||||
frontend-web/README.md
|
|
||||||
frontend-web/doc.md
|
|
||||||
frontend-web/src.zip
|
|
||||||
frontend-web/src9.zip
|
|
||||||
frontend-web/src-v1.zip
|
|
||||||
frontend-web/src-*.rar
|
|
||||||
frontend-web/dist
|
|
||||||
# Frontend build artifacts
|
|
||||||
frontend-web/dist/
|
|
||||||
frontend-web/dist-scheduled-check/
|
|
||||||
|
|
||||||
# Offline backend bundles and local deployment artifacts
|
|
||||||
offline-backend-20260505.zip
|
|
||||||
offline-backend-20260505/output/
|
|
||||||
offline-backend-20260505/bundle/.env
|
|
||||||
offline-backend-20260505/bundle/.env.example
|
|
||||||
offline-backend-20260505/bundle/DEPLOY_BACKEND_OFFLINE_ZH.md
|
|
||||||
offline-backend-20260505/bundle/SHA256SUMS
|
|
||||||
offline-backend-20260505/bundle/config.yaml
|
|
||||||
offline-backend-20260505/bundle/config.yaml.example
|
|
||||||
offline-backend-20260505/bundle/data/
|
|
||||||
offline-backend-20260505/bundle/deerflow-backend-offline-image-20260505.tar
|
|
||||||
offline-backend-20260505/bundle/extensions_config.json
|
|
||||||
offline-backend-20260505/bundle/frontend-vite.zip
|
|
||||||
offline-backend-20260505/bundle/skills/
|
|
||||||
offline-backend-20260505/bundle/start_backend.0509.sh
|
|
||||||
offline-backend-20260505/bundle/start_backend.sh
|
|
||||||
offline-backend-20260505/bundle/stop_backend.sh
|
|
||||||
|
|
||||||
# offline-backend-20260512 runtime artifacts (large files, local config, data)
|
|
||||||
offline-backend-20260512/.env.example
|
|
||||||
offline-backend-20260512/.env
|
|
||||||
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
|
|
||||||
offline-backend-20260512/SHA256SUMS
|
|
||||||
offline-backend-20260512/config.yaml.example
|
|
||||||
offline-backend-20260512/data/
|
|
||||||
offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
|
|
||||||
offline-backend-20260512/extensions_config.json
|
|
||||||
offline-backend-20260512/skills/
|
|
||||||
offline-backend-20260512/wheelhouse/
|
|
||||||
offline-backend-20260512/backend/.deer-flow/data/*.db
|
|
||||||
offline-backend-20260512/backend/.deer-flow/data/*.db-*
|
|
||||||
offline-backend-20260512/backend/.deer-flow/*
|
|
||||||
offline-backend-20260512/backend/.env
|
|
||||||
offline-backend-20260512/backend/.env.local
|
|
||||||
offline-backend-20260512/start_backend.sh
|
|
||||||
offline-backend-20260512/stop_backend.sh
|
|
||||||
offline-backend-20260512/check_backend.sh
|
|
||||||
|
|
||||||
hermes-agent-main/
|
|
||||||
|
|
||||||
|
|
||||||
skills/*
|
|
||||||
|
|
||||||
offline-backend-20260512/backend/.deer-flow/*
|
|
||||||
|
|
||||||
# Per-user skill_evolution overrides — runtime data written by the API
|
|
||||||
offline-backend-20260512/backend/skill_evolution.json
|
|
||||||
skills/
|
|
||||||
hermes-agent-main/
|
|
||||||
.gitignore
|
|
||||||
offline-backend-20260512/backend/tests/*
|
|
||||||
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
|
||||||
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
|
||||||
# Keep the model configuration regression coverage versioned even though
|
|
||||||
# this deployment repository otherwise ignores newly added backend tests.
|
|
||||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
|
||||||
deploy
|
|
||||||
.history
|
|
||||||
|
|
||||||
# Cloned third-party repos (local only)
|
|
||||||
git-clone/
|
|
||||||
|
|
||||||
# Repo-root offline dependency pack (local only)
|
|
||||||
/dist/
|
|
||||||
|
|
||||||
# Frontend offline pack working directories
|
|
||||||
frontend-web/.offline-pack-build/
|
|
||||||
frontend-web/.offline-extract-test/
|
|
||||||
frontend-web-node_modules-offline-*.tar.gz
|
|
||||||
|
|
||||||
# Local dependency caches and backend scratch archives
|
|
||||||
/.pnpm-store/
|
|
||||||
/offline-backend-20260512/backend/backend*.zip
|
|
||||||
|
|
||||||
.codegraph
|
|
||||||
codegraph.mdc
|
|
||||||
|
|
||||||
docs/aidev-docs
|
|
||||||
docs/backups
|
|
||||||
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
|
|
||||||
!frontend-web/src/components/workspace/skills/
|
|
||||||
|
|
||||||
# uv lockfile (not tracked per project preference)
|
|
||||||
offline-backend-20260512/backend/uv.lock
|
|
||||||
|
|
||||||
# Local-only scratch directory (not committed)
|
|
||||||
0622/
|
|
||||||
|
|
||||||
# Python bytecode cache
|
|
||||||
__pycache__/
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
Vendored
+1
-1
@@ -47,7 +47,7 @@
|
|||||||
"name": "DeerFlow: Open Frontend in Chrome",
|
"name": "DeerFlow: Open Frontend in Chrome",
|
||||||
"type": "node-terminal",
|
"type": "node-terminal",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"preLaunchTask": "DeerFlow: Start Full Stack and Wait",
|
"preLaunchTask": "DeerFlow: VS Code Start",
|
||||||
"command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"Start-Process 'http://127.0.0.1:5174/'\"",
|
"command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"Start-Process 'http://127.0.0.1:5174/'\"",
|
||||||
"cwd": "${workspaceFolder}"
|
"cwd": "${workspaceFolder}"
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+23
@@ -1,6 +1,29 @@
|
|||||||
{
|
{
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "DeerFlow: VS Code Start",
|
||||||
|
"type": "process",
|
||||||
|
"command": "powershell.exe",
|
||||||
|
"args": [
|
||||||
|
"-NoProfile",
|
||||||
|
"-ExecutionPolicy",
|
||||||
|
"Bypass",
|
||||||
|
"-File",
|
||||||
|
"${workspaceFolder}/scripts/vscode-start.ps1",
|
||||||
|
"-OpenBrowser"
|
||||||
|
],
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": true
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "dedicated",
|
||||||
|
"clear": true
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "DeerFlow: Start Full Stack and Wait",
|
"label": "DeerFlow: Start Full Stack and Wait",
|
||||||
"type": "process",
|
"type": "process",
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ This is a customized offline deployment of **DeerFlow** — a LangGraph-based AI
|
|||||||
|
|
||||||
Managed together via shell scripts in `scripts/`.
|
Managed together via shell scripts in `scripts/`.
|
||||||
|
|
||||||
|
## Offline Release Repository
|
||||||
|
|
||||||
|
Linux AMD64 离线发版脚本、wheelhouse、`deploy.env` 模板和部署文档统一维护在
|
||||||
|
`knowledge-base-bj/deerflow-offline-deployment`,不要把离线镜像或发版资产提交到
|
||||||
|
本业务源码仓库。正式发版从该仓库执行 `scripts/build-latest-master.sh`,脚本会读取
|
||||||
|
本仓库最新 `master` 的干净源码快照。
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### Full Application (from repo root)
|
### Full Application (from repo root)
|
||||||
@@ -73,6 +80,8 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph
|
|||||||
|
|
||||||
**Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI).
|
**Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI).
|
||||||
|
|
||||||
|
**Workflow Studio** (`deerflow/workflows/` + `/api/workflows*`) is a second, independent execution path beside the lead agent: a persisted DAG scheduler that runs published workflow graphs, streams a durable event log over SSE, and supports human-input pauses, cancellation, and crash recovery via lease reclaim. Coze Studio supplies the canvas UI only; LangGraph is used only *inside* an `agent`/`skill` node. Master switch and all limits live in `config.yaml` → `workflows`. See the backend `AGENTS.md`/`CLAUDE.md`「Workflow Studio」and `docs/WORKFLOW_STUDIO_BACKEND_DEV_ZH.md`.
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||||
@@ -81,16 +90,19 @@ 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). **简介模式** uses a separate overlay: every compact workspace menu item can be placed in `sidebar`(左侧菜单)、`settings`(设置和更多)or `toolbox`(右侧工具箱); `CompactToolbox` (`components/workspace/compact-toolbox.tsx`, mounted by `PageRoutes.tsx`) opens only when the square right-edge trigger is clicked, then slides a compact, content-height right-side drawer over a backdrop above global dropdowns; configured items render top-to-bottom as centered square cards with a large icon above the label while the effective layout is compact. `/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`,**输入框只有发送按钮**;问答经 Gateway `POST /api/sentiment-agent/stream` 转发外部 AG-UI 网关(不经过 LangGraph),会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`)并随请求传给后台;后台按传入地址转发、关闭 TLS 校验,失败时把完整错误回传。核心模块 `src/core/sentiment-agent/`。
|
||||||
|
- **简洁模式用户引导 (Compact-mode onboarding tour)**: 简洁模式(外观设置的「全量/简洁」= compact,无全局导航栏 + 页面侧边栏)新增 TDesign React `Guide` 组件驱动的分步引导。组件 `components/workspace/compact-mode-guide.tsx`(挂在 `ChatPage`):9 步 —— 欢迎弹窗(dialog 模式)→ 左侧功能导航 → 聊天记录 → 设置和更多 → 换肤 → 界面模式(切回全量)→ 常用提示词栏 → 提示词管理弹窗 → 输入框;高亮目标由各组件上的 `data-compact-guide` 标记定位(`workspace-nav-chat-list` / `recent-chat-list` / `workspace-nav-menu` / `appearance-controls` / `custom-prompt-menu.tsx` 的 `CustomPromptBar`(提示词栏,仅新对话落地态渲染)与其 `ManagePromptsDialog`(`prompt-dialog` 锚点)/ ChatPage 输入框容器),**启动时按目标是否存在过滤步骤**(如新用户无聊天记录、侧栏收起时自动剔除该步 —— Guide 对缺失元素会抛错,不能盲传)。**提示词管理弹窗步骤是动态目标**:弹窗平时不渲染,引导切到该步前先广播 `PROMPT_DIALOG_GUIDE_EVENT`(`custom-prompt-menu.tsx` 导出)让 `CustomPromptBar` 打开 `ManagePromptsDialog` 并**接管**(`owner: true` 期间 ESC/点遮罩不关闭,防止高亮目标消失导致 Guide 报错),轮询锚点真正挂载后才切步;离开该步 / 跳过 / 完成 / 中途卸载时以 `owner: false` 收回弹窗,4 秒打不开则跳过该步防卡死。**TDesign Guide × Radix 模态互操作**:Radix 模态弹窗打开时会把 `body` 置 `pointer-events:none`(滚动锁定),Guide 直接挂在 body 下的遮罩/高亮/弹层会一并继承失活(引导按钮点不动),`styles/tdesign-theme.css` 末尾对 `.t-guide__overlay/__highlight/__popup/__wrapper` 显式 `pointer-events:auto` 兜底。触发:① 自动 —— 简洁模式新对话落地页自动演示,直到用户显式点「完成」或「跳过」才写 localStorage `deerflow.compact-guide-finished` 记忆(浏览器级);**中途刷新/离开不记忆,下次进入还会再演示**。② 手动 —— 「设置和更多」下拉新增「界面使用引导」(该下拉仅存在于简洁模式工作区侧栏 footer):已在问答页时广播 `compact-guide:start` 窗口事件直接启动,否则写 sessionStorage 请求并导航 `/chats/new` 由挂载的引导消费(消费在定时器真正启动时才执行,规避 StrictMode effect 双跑吞请求)。`eligible` 排除 embed=1 / hideChrome=1 / chats_iframe / FORCE_COLLAPSED_SIDEBAR(无界)等同样无全局 chrome 的特殊环境;启动延迟 800ms 等侧栏宽度过渡稳定后再测量高亮框。
|
||||||
|
- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:<stage>` 窗口事件强制重放(忽略已看过记忆)。
|
||||||
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
|
- **敏感词管理 (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/`
|
||||||
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
|
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
|
||||||
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations staLine truncated
|
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations staLine truncated
|
||||||
|
- **agentfx 任务深链(新系统 iframe 嵌入的智能体问答,3qfx 单智能体 lite 模式的独立复制)**: goPath=`agentfx`(`${loginBase}?password=…&taskId=…&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue`)。与 3qfx 的差异:①任务详情不走外部 consumer,改调**本系统 Gateway** `GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=`(`apiFetch` 带刚登录的 DeerFlow 态;任务数据来自 TaskCOP 任务表,与任务列表/报告导入同一份);②**固定使用内置智能体** `agentfx-analyst`(不走业务映射);③路由 `task-management/plan-task-agentfx/agent/:agent_id/chats`,页面为独立复制的 `AgentFxWorkspace.tsx`(`AgentFxWorkspace`/`AgentFxIndexResolver`/`AgentFxChat` —— 左「当前任务」卡 + 1:N 对话列表 + 新建对话,`AgentChatPage taskBind` 自动发开场白;无 TaskJumpBar/清除记录/闸门/xdfx 行动卡)。模块 `core/auth/agentfx-deeplink.ts`(`isAgentfxGoPath`/`fetchAgentfxTaskDetail`/`resolveAgentfxDeeplinkTarget`);任务详情整包仍存共享键 `login.copTaskDetail`(按标签页隔离),`AgentChatPage` 的 taskBind 自恢复对 agentfx 生效。`LoginPage` 在既有深链分支前用 `isAgentfxGoPath` 分流。**内置「任务研判报告助手」**:后端 seeder `app/gateway/routers/_agentfx_seed.py` + `_agentfx_seed_assets/`(config.yaml + SOUL.md,目录存在不覆盖;app.py lifespan 在 `_sync_legacy_agents` 之前调用,自动进 agents 表 user_id IS NULL)——SOUL 规定工作流「搜集信息(`web_search`,**首步不可跳过**)→ 每次检索 JSON 原样落盘 `/mnt/user-data/workspace/hits/qN.json` → **一条命令** `task-report-build`(group `all`)技能函数转换成 14 类 `task-reports.json`(模型禁止手写该 JSON;分页技能 `task-report-{enemy,our,env,judge}` 仅用于事后补跑单页;五者都是共享引擎 `task-report-lib/convert_lib.py` 的瘦 CLI,`resolve_hit_files` 对虚拟绝对路径读空的情况自动回退到相对写法并在 `warnings` 里列出尝试过的路径,免得智能体自己写脚本排查)→ `write_file` 生成 `/mnt/user-data/outputs/report.md` → 聊天总结 → 引导入库」,硬性约束:categoryType 逐字用 14 个全称(禁缩写自造如「DQ关键事件」)、JSON 直接写汉字禁 `\uXXXX` 转义、每类必须有实质内容不许空数组/纯占位、文件一律用 `/mnt/user-data/...` 虚拟路径(防宿主路径泄漏进 tool args)。**检索依赖 `config.yaml tools.web_search`(DDG,enabled 已开;内网需换 configurable_search 时改回并填 endpoint)**。任务工作区输入框在 `taskCtxOn` 时隐藏「写作/记忆/参考文献」(InputBox `hideContextTools` prop)与「知识空间」(不传 knowledgeSelector);消息流里 write_file 文件卡片只显示文件名(`artifactDisplayName` 收敛宿主/虚拟路径)。**入库两条路径**:(a) **前端协助卡** —— `AgentChatPage`(taskBind 且非 taskGate)检测线程 artifacts 出现 `task-reports.json`(按文件名后缀匹配,兼容本地沙箱记录的宿主路径)且流式结束时,向 displayThread 尾部注入 `additional_kwargs.task_report_import_approval` 虚拟 ai 消息(`core/messages/utils.ts` 分组 `assistant:task-report-import` + `MessageList` 的 `taskReportImportSlot` 插槽渲染 `TaskReportImportCard`),点「开始入库」前端校验(`strategy-components/lib/agentfx-report-schema.ts`,14 类 + contentJson 合法 JSON + **\u 转义归一化还原为汉字**,与技能脚本同规则)后 `importTaskReport`(`strategy-components/api/task-reports.ts`)POST 导入接口整体替换;任务已有详情**不抑制**卡片(入库即整体替换,卡片文案说明覆盖语义),导入成功按 thread 记 sessionStorage(刷新不重复询问)、「暂不入库」为会话内记忆;(b) **入库技能** `task-report-import`(seeder 一并复制到 `skills/public/`,public 类技能 extensions_config 缺省即启用)—— 用户聊天里说「入库」时智能体按 SKILL.md 用 bash 跑 `scripts/task_report_import.py`(纯标准库:同样的校验规则 + 转义归一化;**默认关闭 SSL 证书校验** `ssl._create_unverified_context` + 忽略代理,适配内网自签证书;鉴权 `--token`/env `AGENTFX_IMPORT_TOKEN`→`DEERFLOW_AUTH_TOKEN`→`TOOLS_TOKEN` 优先,缺省经公共端点 `POST /api/parallel-agents/auth/token` 以 `agentfx-import` 用户现签 token——内网既有鉴权旁路;gateway 地址 env `AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:{DEER_FLOW_GATEWAY_PORT}`);`--strict` 可要求 14 类齐全,默认允许部分导入并在 stdout JSON 的 `missing_categories` 里列缺。入库成功后任务 TaskCOP 状态自动置 25(已完成分析)。**入库成功通知宿主**(iframe 嵌入场景):两条路径成功后都会 `window.top.postMessage({source: "magent-web", type: "Line truncated
|
||||||
- **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。
|
- **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph
|
|||||||
|
|
||||||
**Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI).
|
**Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI).
|
||||||
|
|
||||||
|
**Workflow Studio** (`deerflow/workflows/` + `/api/workflows*`) is a second, independent execution path beside the lead agent: a persisted DAG scheduler that runs published workflow graphs, streams a durable event log over SSE, and supports human-input pauses, cancellation, and crash recovery via lease reclaim. Coze Studio supplies the canvas UI only; LangGraph is used only *inside* an `agent`/`skill` node. Master switch and all limits live in `config.yaml` → `workflows`. See the backend `CLAUDE.md`「Workflow Studio」and `docs/WORKFLOW_STUDIO_BACKEND_DEV_ZH.md`.
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||||
@@ -82,13 +84,13 @@ 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). A premature SSE close (SDK treats it as success and restores the send button while the backend run is still occupying the thread) is recovered by `confirmOrRejoin`: hold the stop button, `joinStream` the live run, and on 409 auto-reconnect instead of a fake AI bubble. `sse_consumer` no longer ends a resumable (`on_disconnect=continue`) stream on a false-positive `is_disconnected()`.
|
||||||
- **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/`
|
||||||
|
|||||||
Submodule _skills_src/ai-skills deleted from cde1c8385e.
Submodule _skills_src/anthropics-skills deleted from 5754626092.
Submodule _skills_src/awesome-claude-skills deleted from 92568c1eda.
Submodule _skills_src/ce-kit deleted from d4973c4827.
Submodule _skills_src/csm deleted from 3fa16a94e0.
Submodule _skills_src/superpowers deleted from 6fd4507659.
Submodule _skills_src/tapestry-skills deleted from 80e1dc56df.
@@ -5,6 +5,9 @@ const LEGACY_LOCAL_API_BASE_URL = "http://localhost:8001";
|
|||||||
const LEGACY_LOCAL_WEB_BASE_URL = "http://localhost:5174";
|
const LEGACY_LOCAL_WEB_BASE_URL = "http://localhost:5174";
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
let isPolling = false;
|
let isPolling = false;
|
||||||
|
let startupPromise = null;
|
||||||
|
|
||||||
|
const POLLING_SCHEDULE_SETTING_KEYS = new Set(["taskPollingEnabled", "taskPollIntervalSeconds"]);
|
||||||
|
|
||||||
function delay(ms) {
|
function delay(ms) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -12,6 +15,14 @@ function delay(ms) {
|
|||||||
|
|
||||||
async function getSettings() {
|
async function getSettings() {
|
||||||
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
|
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
|
||||||
|
const discovered = await chrome.storage.local.get({
|
||||||
|
discoveredApiBaseUrl: "",
|
||||||
|
discoveredWebBaseUrl: "",
|
||||||
|
});
|
||||||
|
if (settings.autoDiscoverServiceUrl) {
|
||||||
|
settings.apiBaseUrl = discovered.discoveredApiBaseUrl || settings.apiBaseUrl;
|
||||||
|
settings.webBaseUrl = discovered.discoveredWebBaseUrl || settings.webBaseUrl;
|
||||||
|
}
|
||||||
const updates = {};
|
const updates = {};
|
||||||
if (!settings.apiBaseUrl || settings.apiBaseUrl === LEGACY_LOCAL_API_BASE_URL) {
|
if (!settings.apiBaseUrl || settings.apiBaseUrl === LEGACY_LOCAL_API_BASE_URL) {
|
||||||
updates.apiBaseUrl = DEER_CLIPPER_DEFAULTS.apiBaseUrl;
|
updates.apiBaseUrl = DEER_CLIPPER_DEFAULTS.apiBaseUrl;
|
||||||
@@ -69,10 +80,7 @@ function apiCandidatesForWebBase(webBase, currentApiBase) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
add(currentApiBase);
|
|
||||||
add(webBase);
|
add(webBase);
|
||||||
add(DEER_CLIPPER_DEFAULTS.apiBaseUrl);
|
|
||||||
add(LEGACY_LOCAL_API_BASE_URL);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(normalizeServiceBase(webBase));
|
const url = new URL(normalizeServiceBase(webBase));
|
||||||
@@ -85,6 +93,10 @@ function apiCandidatesForWebBase(webBase, currentApiBase) {
|
|||||||
// Ignore invalid candidates.
|
// Ignore invalid candidates.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
add(currentApiBase);
|
||||||
|
add(DEER_CLIPPER_DEFAULTS.apiBaseUrl);
|
||||||
|
add(LEGACY_LOCAL_API_BASE_URL);
|
||||||
|
|
||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +105,12 @@ async function browserContextStatus(apiBaseUrl) {
|
|||||||
const response = await fetch(`${apiBaseUrl}/api/browser-context/status`, {
|
const response = await fetch(`${apiBaseUrl}/api/browser-context/status`, {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
|
// The gateway may enforce authentication globally before this otherwise
|
||||||
|
// public discovery endpoint is reached. A 401/403 still proves that this
|
||||||
|
// candidate is the zncm API paired with the open frontend tab.
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
return { enabled: true, authenticationRequired: true };
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -201,9 +219,12 @@ async function discoverServiceBases(settings, force = false) {
|
|||||||
settings.webBaseUrl !== next.webBaseUrl ||
|
settings.webBaseUrl !== next.webBaseUrl ||
|
||||||
settings.lastServiceSyncSource !== next.lastServiceSyncSource
|
settings.lastServiceSyncSource !== next.lastServiceSyncSource
|
||||||
) {
|
) {
|
||||||
await chrome.storage.sync.set({
|
// Discovered endpoints are runtime state, not user-authored preferences.
|
||||||
apiBaseUrl: next.apiBaseUrl,
|
// Keep them in storage.local so Chrome sync write quotas cannot break task
|
||||||
webBaseUrl: next.webBaseUrl,
|
// polling. The options page may still persist explicit user choices.
|
||||||
|
await chrome.storage.local.set({
|
||||||
|
discoveredApiBaseUrl: next.apiBaseUrl,
|
||||||
|
discoveredWebBaseUrl: next.webBaseUrl,
|
||||||
lastServiceSyncAt: next.lastServiceSyncAt,
|
lastServiceSyncAt: next.lastServiceSyncAt,
|
||||||
lastServiceSyncSource: next.lastServiceSyncSource,
|
lastServiceSyncSource: next.lastServiceSyncSource,
|
||||||
});
|
});
|
||||||
@@ -826,19 +847,50 @@ async function pollOnce() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startup() {
|
async function initializePolling() {
|
||||||
let settings = await getSettings();
|
let settings = await getSettings();
|
||||||
settings = await discoverServiceBases(settings);
|
try {
|
||||||
settings = await syncAuthToken(settings);
|
settings = await discoverServiceBases(settings);
|
||||||
|
} catch (error) {
|
||||||
|
// Service discovery persists diagnostic metadata in storage.sync. Chrome can
|
||||||
|
// temporarily reject those writes after its quota is reached; polling must
|
||||||
|
// still start with the last known settings.
|
||||||
|
console.warn("zncm service discovery failed during startup", error);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
settings = await syncAuthToken(settings);
|
||||||
|
} catch (error) {
|
||||||
|
// A failed status/token write must not prevent alarms from being installed.
|
||||||
|
// pollOnce() will retry token synchronization before claiming each task.
|
||||||
|
console.warn("zncm token sync failed during startup", error);
|
||||||
|
}
|
||||||
await refreshAlarm(settings);
|
await refreshAlarm(settings);
|
||||||
await scheduleNextPoll(1000);
|
await scheduleNextPoll(1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
chrome.runtime.onInstalled.addListener(() => void startup());
|
function startup() {
|
||||||
chrome.runtime.onStartup.addListener(() => void startup());
|
if (!startupPromise) {
|
||||||
|
startupPromise = initializePolling().finally(() => {
|
||||||
|
startupPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return startupPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartPollingSchedule() {
|
||||||
|
const settings = await getSettings();
|
||||||
|
await refreshAlarm(settings);
|
||||||
|
await scheduleNextPoll(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.runtime.onInstalled.addListener(() => void startup().catch((error) => console.warn("zncm startup failed", error)));
|
||||||
|
chrome.runtime.onStartup.addListener(() => void startup().catch((error) => console.warn("zncm startup failed", error)));
|
||||||
chrome.storage.onChanged.addListener((changes, areaName) => {
|
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||||
if (areaName === "sync") {
|
if (
|
||||||
void startup();
|
areaName === "sync" &&
|
||||||
|
Object.keys(changes || {}).some((key) => POLLING_SCHEDULE_SETTING_KEYS.has(key))
|
||||||
|
) {
|
||||||
|
void restartPollingSchedule().catch((error) => console.warn("zncm polling reschedule failed", error));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||||
@@ -902,4 +954,4 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
void startup();
|
void startup().catch((error) => console.warn("zncm startup failed", error));
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const test = require("node:test");
|
||||||
|
const vm = require("node:vm");
|
||||||
|
|
||||||
|
const BACKGROUND_PATH = path.join(__dirname, "background.js");
|
||||||
|
|
||||||
|
function createHarness() {
|
||||||
|
let storageChangedListener;
|
||||||
|
let alarmCreateCount = 0;
|
||||||
|
const settings = {
|
||||||
|
apiBaseUrl: "http://localhost:8001",
|
||||||
|
webBaseUrl: "http://localhost:5174",
|
||||||
|
autoDiscoverServiceUrl: false,
|
||||||
|
accessToken: "",
|
||||||
|
tokenSyncEnabled: false,
|
||||||
|
taskPollingEnabled: true,
|
||||||
|
workerId: "test-worker",
|
||||||
|
taskPollIntervalSeconds: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const chrome = {
|
||||||
|
alarms: {
|
||||||
|
clear: async () => {},
|
||||||
|
create: async () => {
|
||||||
|
alarmCreateCount += 1;
|
||||||
|
},
|
||||||
|
onAlarm: { addListener: () => {} },
|
||||||
|
},
|
||||||
|
cookies: { get: async () => null },
|
||||||
|
runtime: {
|
||||||
|
onInstalled: { addListener: () => {} },
|
||||||
|
onMessage: { addListener: () => {} },
|
||||||
|
onStartup: { addListener: () => {} },
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
local: {
|
||||||
|
get: async (defaults) => ({ ...defaults }),
|
||||||
|
set: async () => {},
|
||||||
|
},
|
||||||
|
session: { get: async () => ({ actSessions: {} }), set: async () => {} },
|
||||||
|
sync: {
|
||||||
|
get: async () => ({ ...settings }),
|
||||||
|
set: async (updates) => Object.assign(settings, updates),
|
||||||
|
},
|
||||||
|
onChanged: {
|
||||||
|
addListener: (listener) => {
|
||||||
|
storageChangedListener = listener;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tabs: { query: async () => [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
chrome,
|
||||||
|
console,
|
||||||
|
fetch: async () => {
|
||||||
|
throw new Error("offline test");
|
||||||
|
},
|
||||||
|
importScripts: () => {},
|
||||||
|
setTimeout: () => 1,
|
||||||
|
clearTimeout: () => {},
|
||||||
|
URL,
|
||||||
|
DEER_CLIPPER_DEFAULTS: settings,
|
||||||
|
deerNormalizeBaseUrl: (value) => String(value || "").replace(/\/+$/, ""),
|
||||||
|
deerMakeWorkerId: () => "test-worker",
|
||||||
|
deerAuthHeaders: () => ({}),
|
||||||
|
deerExtractAuthTokenFromPage: () => ({ token: "" }),
|
||||||
|
};
|
||||||
|
context.globalThis = context;
|
||||||
|
vm.runInNewContext(fs.readFileSync(BACKGROUND_PATH, "utf8"), context, {
|
||||||
|
filename: BACKGROUND_PATH,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
emitStorageChange(changes) {
|
||||||
|
storageChangedListener(changes, "sync");
|
||||||
|
},
|
||||||
|
getAlarmCreateCount: () => alarmCreateCount,
|
||||||
|
resetAlarmCreateCount: () => {
|
||||||
|
alarmCreateCount = 0;
|
||||||
|
},
|
||||||
|
getApiCandidates: (webBase, currentApiBase) =>
|
||||||
|
vm.runInContext(`apiCandidatesForWebBase(${JSON.stringify(webBase)}, ${JSON.stringify(currentApiBase)})`, context),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
test("self-written sync metadata does not restart browser task polling", async () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
await settle();
|
||||||
|
harness.resetAlarmCreateCount();
|
||||||
|
|
||||||
|
harness.emitStorageChange({
|
||||||
|
lastTokenSyncAt: { oldValue: "before", newValue: "after" },
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
assert.equal(harness.getAlarmCreateCount(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("polling configuration changes restart browser task polling", async () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
await settle();
|
||||||
|
harness.resetAlarmCreateCount();
|
||||||
|
|
||||||
|
harness.emitStorageChange({
|
||||||
|
taskPollingEnabled: { oldValue: false, newValue: true },
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
assert.equal(harness.getAlarmCreateCount(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("service discovery prefers the API paired with the open zncm tab", async () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
Array.from(harness.getApiCandidates("http://localhost:5174", "http://47.94.209.59:2026")),
|
||||||
|
[
|
||||||
|
"http://localhost:5174",
|
||||||
|
"http://localhost:8001",
|
||||||
|
"http://47.94.209.59:2026",
|
||||||
|
"http://localhost:8001",
|
||||||
|
].filter((value, index, values) => values.indexOf(value) === index),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
const HOST_ID = "zncm-page-assistant-host";
|
const HOST_ID = "zncm-page-assistant-host";
|
||||||
const state = {
|
const state = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
taskPollingEnabled: true,
|
||||||
|
webBaseUrl: "",
|
||||||
open: false,
|
open: false,
|
||||||
busy: false,
|
busy: false,
|
||||||
threadId: "",
|
threadId: "",
|
||||||
@@ -709,20 +711,58 @@
|
|||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
|
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
|
||||||
state.enabled = Boolean(settings.pageAssistantEnabled);
|
state.enabled = Boolean(settings.pageAssistantEnabled);
|
||||||
|
state.taskPollingEnabled = Boolean(settings.taskPollingEnabled);
|
||||||
|
state.webBaseUrl = deerNormalizeBaseUrl(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl);
|
||||||
ensureWidget();
|
ensureWidget();
|
||||||
}
|
}
|
||||||
|
|
||||||
chrome.storage.onChanged.addListener((changes, areaName) => {
|
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||||
if (areaName !== "sync" || !changes.pageAssistantEnabled) {
|
if (areaName !== "sync") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.enabled = Boolean(changes.pageAssistantEnabled.newValue);
|
if (changes.pageAssistantEnabled) {
|
||||||
ensureWidget();
|
state.enabled = Boolean(changes.pageAssistantEnabled.newValue);
|
||||||
if (state.enabled) {
|
ensureWidget();
|
||||||
setStatus("页面问答框已开启。");
|
if (state.enabled) {
|
||||||
|
setStatus("页面问答框已开启。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changes.taskPollingEnabled) {
|
||||||
|
state.taskPollingEnabled = Boolean(changes.taskPollingEnabled.newValue);
|
||||||
|
}
|
||||||
|
if (changes.webBaseUrl) {
|
||||||
|
state.webBaseUrl = deerNormalizeBaseUrl(changes.webBaseUrl.newValue || DEER_CLIPPER_DEFAULTS.webBaseUrl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function isZncmPage() {
|
||||||
|
const appPath = `${location.pathname}${location.hash}`.toLowerCase();
|
||||||
|
if (
|
||||||
|
appPath.includes("/page/workspace") ||
|
||||||
|
appPath.includes("/chats/") ||
|
||||||
|
appPath.includes("#/login")
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const configured = new URL(state.webBaseUrl);
|
||||||
|
return configured.origin === location.origin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MV3 service workers may be suspended between their short polling timers,
|
||||||
|
// while chrome.alarms cannot reliably run more often than every 30 seconds.
|
||||||
|
// A visible zncm tab provides a cheap wake-up signal so browser_act requests
|
||||||
|
// are normally claimed within the backend/model timeout window.
|
||||||
|
setInterval(() => {
|
||||||
|
if (!state.taskPollingEnabled || !isZncmPage()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chrome.runtime.sendMessage({ type: "zncm-poll-now" }).catch(() => {});
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (!state.enabled || !ui.pageTitle) {
|
if (!state.enabled || !ui.pageTitle) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Wiki 数据流转与本地编码验收(2026-09-03)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
本地 BGE-M3 小批量编码已安装、配置并实际运行。已有 Wiki 的向量化、自动沉淀、
|
||||||
|
普通库文件导出、助手库异步导入、助手库文件导出、空普通库回流及两端向量检索已实测通过。
|
||||||
|
**技能自动生成 Wiki 尚未完成验收**:WeKnora 的两个生成模型都返回 HTTP 402
|
||||||
|
`Insufficient Balance`。不能把后半段通过等同于全链路通过。
|
||||||
|
|
||||||
|
本次修改运行在本地 DeerFlow 开发服务(前端 5175、后端 8001),连接用户提供的远端
|
||||||
|
WeKnora;没有把业务代码发布到服务器,没有提交 Git,也没有直接写 WeKnora 图数据库。
|
||||||
|
|
||||||
|
## 本地编码配置
|
||||||
|
|
||||||
|
- 实现:FastEmbed / ONNX Runtime CPU,官方 `BAAI/bge-m3` 权重。
|
||||||
|
- 配置名:`bge-embedding-m3`,1024 维,每批 2 条,2 个 CPU 推理线程。
|
||||||
|
- 运行依赖:FastEmbed 随标准后端依赖安装;模型必须提前放在离线目录中,仍按需加载,不影响主服务启动。
|
||||||
|
- 权重:约 2.3 GB,持久缓存 `C:\Users\ji\.cache\deerflow\embeddings`,不进入业务仓库。
|
||||||
|
- 配置:`llmwiki.local_wiki_index.embedding.provider: local`;可用
|
||||||
|
`local_model_path` 指定离线权重目录,详见后端 README。
|
||||||
|
- 查询向量在本地实时编码。已有 Wiki 在内容哈希、模型指纹匹配且向量非空时直接复用。
|
||||||
|
不同模型或维度不能混用;导入不静默重新编码。
|
||||||
|
- 原始文档分段向量与模型生成后的 Wiki 文本不同,不能相互冒用。本次新建的是 Wiki
|
||||||
|
正文的 BGE-M3 索引,不是把既有 512 维文档向量改名为 1024 维。
|
||||||
|
|
||||||
|
## 实际数据验收
|
||||||
|
|
||||||
|
来源是已有普通知识库“图片测试111”,没有改写其 Wiki 内容。
|
||||||
|
|
||||||
|
| 阶段 | Wiki | Wiki 向量 | 实体 | 引用关系 |
|
||||||
|
| --- | ---: | ---: | ---: | ---: |
|
||||||
|
| 普通知识库导出 | 19 | 101 | 19 | 108 |
|
||||||
|
| 助手知识库导入后导出 | 19 | 101 | 19 | 108 |
|
||||||
|
| 新建普通知识库回流后导出 | 19 | 101 | 19 | 108 |
|
||||||
|
| 知识梳理总库导出(7 个来源记录) | 19 | 101 | 19 | 108 |
|
||||||
|
|
||||||
|
逐项断言通过:
|
||||||
|
|
||||||
|
1. 三份包的 Wiki slug、标题、正文、摘要、页面类型、别名、分类路径、出链一致。
|
||||||
|
2. 101 条向量的原始 float32 字节、分段编号、维度和模型指纹完全一致。
|
||||||
|
3. 将导入前后实体 ID 映射回原 Wiki slug 后,108 条关系的端点及谓词集合完全一致。
|
||||||
|
4. 总库导出没有因多个来源重复同步而重复导出同一当前页面的分段向量。
|
||||||
|
5. 元数据回归测试另覆盖 tags、目录恢复、原始文档片段不计入 Wiki 数量、原生技能无
|
||||||
|
ownership 记录时可被选中、短 Wiki 不漏向量、模型指纹不兼容时不假装向量检索。
|
||||||
|
|
||||||
|
普通包约 1.01 MB,助手包约 0.79 MB,回流包约 0.83 MB。普通包还带 2 个原始文档及
|
||||||
|
108 个原始片段;助手导出提供 Wiki、Wiki 向量和关系,不重新上传原始文档触发二次生成。
|
||||||
|
|
||||||
|
### 检索
|
||||||
|
|
||||||
|
实际查询:“军方用什么工具评估极端天气带来的风险?”
|
||||||
|
|
||||||
|
- 回流普通库 `/api/llmwiki/search` 返回 `mode=vector`,首条“国防气候评估工具”。
|
||||||
|
- 助手库 `/api/assistant-knowledge/search` 返回 `retrieval_mode=vector`,首条同上;
|
||||||
|
首个命中分段余弦相似度约 0.6343。
|
||||||
|
- 浏览器实际提交总库检索,显示“向量检索 · 余弦相似度”和相同命中;不是只检查返回字段。
|
||||||
|
- 普通库采用已有页面级聚合评分,因此页面最终分数与单分段余弦值不必相等。
|
||||||
|
- 回流的向量写入 **DeerFlow 管理的本地 Wiki 索引**。未写入 WeKnora 原生向量数据库,
|
||||||
|
本次结论适用于本系统问答检索,不能声称原生 WeKnora 自身的检索也已完成该向量导入。
|
||||||
|
|
||||||
|
### 页面
|
||||||
|
|
||||||
|
普通与助手库复用同一 Wiki 阅读组件:目录树、类型/标签筛选、分类面包屑、别名标签、
|
||||||
|
正文内链、出链、反向链接及邻接关系视图。窄窗口可收起目录。
|
||||||
|
|
||||||
|
浏览器验证了“索引 → 国防气候评估工具”、展开关系视图、助手 Wiki 跳回普通库来源,
|
||||||
|
以及普通 Wiki 的“在知识梳理中查看”反向跳转。
|
||||||
|
|
||||||
|
### 验收数据位置
|
||||||
|
|
||||||
|
- 原有普通库:`48f669dc-433e-4aab-b5ea-897b6f7a3d2f`
|
||||||
|
- 知识梳理总库:`c3fd9841-0718-44dc-860e-aae3020fd485`
|
||||||
|
- 最终助手测试库:`489be905-08c8-40fe-8d60-42ac21dd945a`
|
||||||
|
- 最终普通回流测试库:`428162aa-03f6-4c61-8994-360f2f39e0c9`
|
||||||
|
|
||||||
|
新建测试库名称均以“Wiki链路验收-0903-1826”开头,保留用于复核;未删除用户已有库。
|
||||||
|
本地 `.runtime/wiki-live-result.json` 保存验收结果,`.runtime/wiki_live_acceptance.py`
|
||||||
|
保存本次验收流程,均为忽略的运行产物,不包含认证口令。
|
||||||
|
|
||||||
|
## 这次实际修复的问题
|
||||||
|
|
||||||
|
- 内置技能只有磁盘目录、没有归属记录时,被错误判定为不存在。
|
||||||
|
- 技能应上传去代码后的知识正文,让 WeKnora 生成 Wiki,而不是伪造一个 `skill` 类型页面。
|
||||||
|
- Wiki 生成等待使用实际 stats 接口;零页结果明确失败,不无期限等待。
|
||||||
|
- 向量化完成后自动导出沉淀到总库,并阻断导出回调重复触发全量同步。
|
||||||
|
- 短摘要和短正文同时存在时,旧分段阈值会将整页过滤为零向量。
|
||||||
|
- 分类路径必须先创建 WeKnora folder 并绑定 folder_id;只传 category_path 会被服务端清空。
|
||||||
|
- Wiki 的标签/分类/别名/原始引用保留;图节点绑定真实 Wiki,避免生成重复的空实体页面。
|
||||||
|
- 保留归一化 float32 原始字节;导入中不能用 `section_index or index`,因为合法编号 0 会被改写。
|
||||||
|
- 总库多个来源保留审计,但检索与导出只使用当前 Wiki 版本对应的向量,避免旧贡献污染检索。
|
||||||
|
- 包解析改为同盘临时 SQLite,上传先落盘再异步导入,导出完成后才提供文件下载。
|
||||||
|
|
||||||
|
## 阻塞与未通过的范围
|
||||||
|
|
||||||
|
1. 技能 `knowledge-base-ingest` 已送入专门创建的普通测试库,但远端
|
||||||
|
`deepseek-v4-flash` 和 `qwen3.6-flash` 均因余额不足失败。WeKnora 即使失败也可能把
|
||||||
|
文档 parse_status 标为 completed。需恢复可用生成模型后重新运行技能全链路验收。
|
||||||
|
2. 已采用磁盘暂存和有界批处理,但本次只有约 1 MB 实测,**未做数 GB 压测、断点续传、
|
||||||
|
进程中断恢复验收**。大包部署仍需磁盘容量、代理 body size 和超时配置。
|
||||||
|
3. 当前实体去重主要按类型及规范化名称;这次没有完成跨别名、歧义实体的模型对齐及
|
||||||
|
多来源事实冲突合并验收,不能视为这一历史需求已全面完成。
|
||||||
|
4. 重复上传仍会保留独立来源记录。向量导出已消除旧版本重复,但上传身份幂等、部分远端
|
||||||
|
写入失败后的无损重试、跨实例图片资源打包仍需独立验证。
|
||||||
|
5. 当前版本已补充自动增量链路:服务启动立即补扫,之后默认每 30 秒扫描全部普通
|
||||||
|
知识库;Wiki 抽取完成后无需手工点击向量化,并自动以来源快照覆盖同步到知识梳理总库。
|
||||||
|
6. 回流当前只允许新建空 Wiki 普通库,避免覆盖已有页面。普通库现有页面列表的超大规模
|
||||||
|
分页与助手问答的最终生成回答不属于这轮通过结论。
|
||||||
|
|
||||||
|
## 自动化检查
|
||||||
|
|
||||||
|
- 后端相关测试:118 passed(assistant repository、Wiki sync、package roundtrip、
|
||||||
|
WeKnora 接口、向量检索与知识库管理)。
|
||||||
|
- 修改的 Python 功能文件 Ruff 检查通过;app.py 既有启动顺序导致的 E402 不在此次改动中处理。
|
||||||
|
- `git diff --check` 通过(只有仓库换行提示)。
|
||||||
|
- 前端完整 `pnpm typecheck` 未通过:57 个既有/本轮范围外错误,涉及 nextra 缺失、Markdown
|
||||||
|
插件类型及其他模块;本轮新建/修改的 Wiki 组件、助手 API、技能面板没有出现在错误清单中。
|
||||||
|
浏览器实际页面与跳转已验证,但不能声称全项目构建通过。
|
||||||
@@ -0,0 +1,719 @@
|
|||||||
|
# AgentScope 2.0 多智能体报告协作工作台——前端实施计划
|
||||||
|
|
||||||
|
> 文档状态:前端代码已落地、契约冻结;联调等待后端运行中干预与报告改写
|
||||||
|
> 编写日期:2026-09-02;进度更新日期:2026-09-06
|
||||||
|
> 目标项目:`F:\react01\deeflow-code\deerflow-server\frontend-web`
|
||||||
|
> 视觉与交互参考:`F:\react01\coze-frontend\frontend\apps\coze-studio`
|
||||||
|
> 参考页面:`http://localhost:3000/work_flow?space_id=1&workflow_id=wf_88cb9f4c6642`
|
||||||
|
> 配套后端计划:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md`
|
||||||
|
> 本文只规划新增页面,不修改现有多智能体会商页面,不复用旧 Workflow Studio 执行状态。
|
||||||
|
|
||||||
|
## 0. 当前进度
|
||||||
|
|
||||||
|
`frontend-web/src/report-collaboration/` 已实现两栏工作台、会话/方案/run API 客户端、mock driver、SSE reducer、`MessageList` 适配、方案卡、画布、节点抽屉、Composer、报告预览与历史。`RC-FE-001`~`013` 的页面骨架与契约层视为完成。
|
||||||
|
|
||||||
|
后端已通规划前路径(澄清 → 出方案 → 选定 → `queued` run),见后端计划 §0。前端真实执行体验仍受阻:
|
||||||
|
|
||||||
|
- `GET /runs/{id}/stream` 已挂载(`RC-BE-011`);dispatcher/租约已落地(`RC-BE-012`),默认 `worker_enabled=false` 仍不自动领取 queued run。
|
||||||
|
- 报告改写 / 恢复接口已由后端 `RC-BE-015` 挂载。
|
||||||
|
- 质量角色已由 `QualityRoleKernel` 跑通(`RC-BE-013`);真实 AgentScope 模型/`web_search` 仍待后续票。
|
||||||
|
|
||||||
|
联调顺序:先打开 `report_collaboration.enabled` 验证规划前 REST 与 `GET /runs/{id}/stream` 重放;画布自动执行需同时打开 `worker_enabled`(默认关)。
|
||||||
|
|
||||||
|
## 1. 前端目标
|
||||||
|
|
||||||
|
新增独立的「报告协作」工作台。用户可以通过对话提出任意报告需求,查看系统生成的多个协作方案,点击方案卡片在流程图中预览协作结构,确认后观察多智能体执行,并在全过程通过统一对话框提出修改意见。
|
||||||
|
|
||||||
|
前端必须满足:
|
||||||
|
|
||||||
|
1. 页面采用“左侧对话、右侧流程画布”的固定两栏布局,画布、工具栏、弹窗和抽屉参考 Coze Studio 页面风格。
|
||||||
|
2. 消息列表必须复用 DeerFlow 现有 `MessageList`,不复制 Coze 的会话消息实现。
|
||||||
|
3. 所有执行过程、AgentScope 团队消息、智能体回复、工具调用及其原始返回、错误、协助卡、返工和最终报告入口都通过现有消息协议按时间顺序显示在左侧对话区,不设置独立执行结果面板,也不另做“阶段摘要”替代实时消息。
|
||||||
|
4. 候选方案、流程节点、对话进度和报告使用同一份后端事实,不各自推断状态。
|
||||||
|
5. 用户可在规划中、运行中、等待输入和报告完成后继续对话。
|
||||||
|
6. 用户协助卡的出现和提交都不能在前端被解释为“节点已经完成”。
|
||||||
|
7. 页面刷新、SSE 断线重连、历史会话切换后能恢复到一致状态。
|
||||||
|
|
||||||
|
暂定:
|
||||||
|
|
||||||
|
- 页面名称:`报告协作`。
|
||||||
|
- 路由:`/page/workspace/report-collaboration`。
|
||||||
|
- 会话路由:`/page/workspace/report-collaboration/:sessionId`。
|
||||||
|
- 默认候选方案数量:3。
|
||||||
|
- 点击画布节点时打开覆盖式节点说明抽屉;抽屉只展示节点职责和合同,不展示节点执行结果。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 范围与非目标
|
||||||
|
|
||||||
|
### 2.1 本期范围
|
||||||
|
|
||||||
|
- 新建、查看、重命名、删除协作报告会话。
|
||||||
|
- 使用对话澄清报告需求。
|
||||||
|
- 显示 2~3 个候选方案卡片。
|
||||||
|
- 卡片与右侧流程图预览联动。
|
||||||
|
- 节点说明抽屉:职责、所依据的信息类型、预期产出、工具、资料要求和验收条件。
|
||||||
|
- 实时展示节点排队、运行、校验、等待用户、完成、返工、失败和取消。
|
||||||
|
- 在左侧对话时间线中直接展示每个智能体的流式文本、团队对话、工具调用和工具返回,并展示审稿意见、报告文件和报告版本。
|
||||||
|
- 并行阶段用横向的智能体选择器切换当前查看对象;选择器只控制同一份消息流的聚焦范围,不创建另一套执行结果视图。
|
||||||
|
- 运行期间发送补充要求、重新检索、重新分析、节点重跑等指令。
|
||||||
|
- 完成后进行报告问答、章节重写、全文润色和版本恢复。
|
||||||
|
- 报告模板选择、Markdown 预览、Markdown/Word 下载。
|
||||||
|
- 响应式布局、键盘操作、加载反馈和错误恢复。
|
||||||
|
|
||||||
|
### 2.2 明确不做
|
||||||
|
|
||||||
|
- 不引入 `@coze-workflow/playground` 或 `@coze-arch/coze-design`。
|
||||||
|
- 不加载 Coze workflow definition、run 或 node run。
|
||||||
|
- 不允许前端拖拽连线后直接改变执行逻辑。
|
||||||
|
- 不新增第二套消息气泡、Markdown、引用和文件卡渲染器。
|
||||||
|
- 不展示模型原始 chain-of-thought、系统/内部 prompt、密钥和被标记为内部控制信息的字段。
|
||||||
|
- 不把用户有权查看的 AgentScope TeamSay、智能体文本、工具调用或工具返回压缩成“正在检索”等摘要卡;这些原始业务消息必须进入复用的 `MessageList`。敏感字段只能按统一脱敏策略替换,不能以摘要替代。
|
||||||
|
- 不增加第三栏、常驻详情栏、节点执行结果栏或独立执行时间线。
|
||||||
|
- 节点抽屉不展示节点生成正文、工具返回、证据列表、错误堆栈或 attempt 历史;这些过程统一在左侧 `MessageList` 的原生消息/工具步骤中查看。
|
||||||
|
- 不改造现有圆桌、多智能体会商、深度研究和 AI 写作页面。
|
||||||
|
- 不把浏览器状态作为业务事实源。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 参考页面行为映射
|
||||||
|
|
||||||
|
| 目标行为 | Coze 参考文件 | DeerFlow 目标组件 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 两栏骨架 | `workflow-studio-layout.tsx` 的面板比例与分隔条 | `ReportCollaborationLayout.tsx` | 左对话、右画布;只保留一条可调整分隔线 |
|
||||||
|
| 顶栏 | `workflow-studio-header.tsx` | `ReportCollaborationHeader.tsx` | 替换为会话、模板、报告、停止等操作 |
|
||||||
|
| 候选卡片 | `workflow-proposal-cards.tsx` | `ReportPlanCards.tsx` | 点击预览,明确确认后才执行 |
|
||||||
|
| 右侧画布 | `workflow-canvas.tsx` | `ReportFlowCanvas.tsx` | 使用 `@xyflow/react` 重建,不引入 Coze Playground |
|
||||||
|
| 左侧消息区 | `run-conversation-panel.tsx` | `ReportConversationPanel.tsx` | 外层风格参考 Coze,内部复用 DeerFlow `MessageList` |
|
||||||
|
| 节点说明抽屉 | Coze 节点配置面板的视觉与抽屉行为 | `NodeInspectorDrawer.tsx` | 点击画布节点打开,只展示职责与输入输出合同 |
|
||||||
|
| 全程执行反馈 | 参考其进度和状态表达 | `AgentStageSelector` + DeerFlow `MessageList` | 选择智能体后直接显示其流式消息、TeamSay、工具调用与返回;不再合成为阶段摘要 |
|
||||||
|
| 状态栏 | `connection-status.tsx` | `ConnectionStatusBar.tsx` | 展示连接、重连和事件游标 |
|
||||||
|
| 对话历史 | `left-panel-tabs.tsx` | `ConversationSidebarTabs.tsx` | 对话与历史,不增加“节点资源”Tab |
|
||||||
|
| 异步反馈 | 规划进度、运行状态 | MessageList 步骤条 + 画布状态 | 操作超过 300ms 必须有反馈 |
|
||||||
|
|
||||||
|
主要参考源:
|
||||||
|
|
||||||
|
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\workflow-studio-layout.tsx`
|
||||||
|
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\workflow-studio-composition.tsx`
|
||||||
|
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\workflow-studio-header.tsx`
|
||||||
|
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\run-conversation\workflow-proposal-cards.tsx`
|
||||||
|
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\styles.module.less`
|
||||||
|
|
||||||
|
若直接复制 Apache-2.0 源码片段或样式,必须保留版权头并维护来源说明;默认做行为映射和视觉重建。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 页面结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 返回 报告协作 / 会话标题 保存状态 模板 报告 停止/继续 更多 │
|
||||||
|
├────────────────────────────────┬─────────────────────────────────────────┤
|
||||||
|
│ 对话 / 历史 │ 协作流程图 │
|
||||||
|
│ │ │
|
||||||
|
│ DeerFlow MessageList │ @xyflow/react │
|
||||||
|
│ · 并行时:[协调者][市场][政策] │ · 节点状态与活动连线 │
|
||||||
|
│ · 用户与协调者/成员消息 │ · 缩放、适配视图、小地图 │
|
||||||
|
│ · 原生 ToolCall / ToolMessage │ · 点击节点打开覆盖式说明抽屉 │
|
||||||
|
│ · 协助卡、报告文件和版本 │ │
|
||||||
|
│ │ │
|
||||||
|
│ 统一输入框 │ │
|
||||||
|
├────────────────────────────────┴─────────────────────────────────────────┤
|
||||||
|
│ 已连接 · run_xxx · 事件 128 │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
节点抽屉覆盖在右侧画布之上,不占用第三栏:
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ 节点名称 关闭 × │
|
||||||
|
│ 角色 / 当前状态 │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ 节点职责 │
|
||||||
|
│ 要根据什么:所需输入、上游类型、资料要求 │
|
||||||
|
│ 要产出什么:成果类型、格式、验收条件 │
|
||||||
|
│ 可用工具与边界 │
|
||||||
|
│ 上下游关系 │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ 在对话中对此节点提出意见 │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
抽屉明确不展示实际执行结果、生成正文、工具返回、来源明细、错误堆栈或 attempt 时间线;这些信息只在左侧原生消息流中展示。
|
||||||
|
|
||||||
|
布局默认值:
|
||||||
|
|
||||||
|
- 顶栏高度:56px。
|
||||||
|
- 左侧面板:默认 480px,最小 360px,最大 720px。
|
||||||
|
- 右侧画布:最小 480px,使用剩余空间。
|
||||||
|
- 节点说明抽屉:覆盖画布右侧,建议宽度 420px;关闭后完整归还画布空间。
|
||||||
|
- 状态栏:24~28px。
|
||||||
|
- 低于 960px:左侧对话和右侧画布改为顶部按钮切换的单面板模式;节点说明仍使用覆盖式 Drawer。
|
||||||
|
- 两栏分隔条:支持 pointer 拖动、方向键、Home、End。
|
||||||
|
|
||||||
|
### 4.1 原生消息流与并行智能体切换
|
||||||
|
|
||||||
|
“复用 `MessageList`”在本页面的含义是复用它现有的 assistant 文本、`tool_calls`、`tool` 消息、引用、文件和协助卡渲染语义;不把 AgentScope 的运行过程先解析成专用步骤条、进度摘要或阶段结果卡。
|
||||||
|
|
||||||
|
单个智能体:
|
||||||
|
|
||||||
|
- `agent.reply.started` 立即创建一条稳定的 assistant 消息;`message.delta` 持续更新同一 `messageId`,所以用户会先看到流式状态、再看到逐字回复,而不是等待节点结束。
|
||||||
|
- 工具调用创建/更新该 assistant 消息的 `tool_calls`;工具返回创建匹配 `tool_call_id` 的原生 `tool` 消息。现有 `MessageGroup` 负责把两者配对显示,不再另写工具过程渲染器。
|
||||||
|
- AgentScope TeamSay 作为带 `agentRunId` 的普通 assistant 消息进入同一流,按到达顺序显示。它不是节点完成的证据,节点状态仍由右侧画布和后端状态机决定。
|
||||||
|
|
||||||
|
并行智能体:
|
||||||
|
|
||||||
|
```text
|
||||||
|
多角度并行研究 3/4 进行中
|
||||||
|
[ 协调者 ] [ 市场规模 ✓ ] [ 品牌竞争 ● ] [ 海外市场 ● ] [ 政策风险 ! ]
|
||||||
|
└──────────────────── 当前选中智能体的原生 MessageList ────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- `AgentStageSelector` 只在并行阶段出现,横向放置在左侧对话区顶部;使用现有图标体系的头像/角色图标、名称、状态文字和非纯颜色状态标识。默认选中第一位已开始运行的成员;用户手动切换后保持选择,不因其他成员返回而抢走阅读焦点。
|
||||||
|
- 所有成员的事件都持续接收、按每个 `agentRunId` 持久化和更新,即使当前未选中;切回即可看到完整历史与正在更新的同一条消息,不丢 token、不重新请求、不把结果拼成摘要。
|
||||||
|
- 当前选择只将共享用户消息、协调者广播消息和目标成员消息组成一个 `BaseStream<AgentThreadState>` 交给同一个 `MessageList`。切换标签仅切换该视图的消息范围,不改变 run、节点或消息的后端事实。
|
||||||
|
- 某成员请求用户协助时,其标签显示“待回复”徽标,选择器显示待处理数;点击徽标切到该成员,原生协助工具消息仍由现有 `MessageList` 协助卡渲染。其他成员可继续运行。
|
||||||
|
- 节点抽屉打开某并行成员时,同步聚焦对应标签;抽屉仍只展示合同。用户从抽屉或输入框发送反馈时带 `targetNodeId`/`agentRunId`,后端在安全点生效。
|
||||||
|
|
||||||
|
这不是把完整日志另做成调试面板:对话区展示的是模型和工具的真实业务消息;只有 chain-of-thought、内部提示词及按权限必须脱敏的字段不进入该消息流。
|
||||||
|
|
||||||
|
### 4.2 页面主要状态
|
||||||
|
|
||||||
|
| 状态 | 左侧对话区 | 右侧流程画布 | 主要操作 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `empty` | 欢迎语和输入框 | 空态 | 输入需求、选模板 |
|
||||||
|
| `clarifying` | 澄清问题和协助卡 | 需求理解节点 | 回答或补充 |
|
||||||
|
| `planning` | 用户消息和规划进度 | 骨架或需求图 | 继续补充,等待安全点生效 |
|
||||||
|
| `proposal_ready` | 2~3 张方案卡 | 点击卡片预览对应图 | 切换、修改、采用 |
|
||||||
|
| `running` | 协调者与成员原生消息、工具过程和用户反馈 | 实时节点状态 | 干预、停止、在对话中要求重跑 |
|
||||||
|
| `awaiting_input` | 等待用户卡片、原因和影响 | 对应节点显示等待 | 回答、取消 |
|
||||||
|
| `reviewing` | 审稿意见和返工过程 | 评审/返工节点状态 | 接受或补充要求 |
|
||||||
|
| `completed` | 总结、报告文件卡、版本和后续对话 | 完整执行图 | 问答、改写、导出 |
|
||||||
|
| `failed` | 错误说明和恢复建议 | 失败节点 | 通过对话重试或取消 |
|
||||||
|
| `cancelled` | 已保留成果说明 | 未完成节点取消 | 从已有成果继续或新建方案 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 前端目录与组件边界
|
||||||
|
|
||||||
|
建议新增:
|
||||||
|
|
||||||
|
```text
|
||||||
|
frontend-web/src/report-collaboration/
|
||||||
|
api/
|
||||||
|
client.ts
|
||||||
|
sessions.ts
|
||||||
|
plans.ts
|
||||||
|
runs.ts
|
||||||
|
reports.ts
|
||||||
|
types.ts
|
||||||
|
components/
|
||||||
|
ReportCollaborationLayout.tsx
|
||||||
|
ReportCollaborationHeader.tsx
|
||||||
|
ConversationSidebarTabs.tsx
|
||||||
|
ReportConversationPanel.tsx
|
||||||
|
AgentStageSelector.tsx
|
||||||
|
ReportComposer.tsx
|
||||||
|
ReportPlanCards.tsx
|
||||||
|
ReportFlowCanvas.tsx
|
||||||
|
ReportFlowNode.tsx
|
||||||
|
NodeInspectorDrawer.tsx
|
||||||
|
ReportPreviewDialog.tsx
|
||||||
|
InterventionCard.tsx
|
||||||
|
SessionHistoryPanel.tsx
|
||||||
|
ConnectionStatusBar.tsx
|
||||||
|
hooks/
|
||||||
|
useReportCollaborationSession.ts
|
||||||
|
useReportCollaborationStream.ts
|
||||||
|
useReportCollaborationMessages.ts
|
||||||
|
useReportPlanSelection.ts
|
||||||
|
useReportPanelLayout.ts
|
||||||
|
state/
|
||||||
|
event-normalizer.ts
|
||||||
|
event-reducer.ts
|
||||||
|
selectors.ts
|
||||||
|
message-adapter.ts
|
||||||
|
pages/
|
||||||
|
ReportCollaborationPage.tsx
|
||||||
|
styles/
|
||||||
|
report-collaboration.css
|
||||||
|
tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
修改范围:
|
||||||
|
|
||||||
|
- `frontend-web/src/pages/WorkspaceRoutes.tsx`:增加页面和会话路由。
|
||||||
|
- `frontend-web/src/components/workspace/workspace-nav-chat-list.tsx`:增加导航入口。
|
||||||
|
- `frontend-web/src/components/workspace/messages/message-list.tsx`:仅允许增加一个向后兼容的通用业务卡片插槽。
|
||||||
|
- `frontend-web/src/core/messages/utils.ts`:如确需识别通用业务卡类型,只做集中式扩展。
|
||||||
|
|
||||||
|
不得把新功能放进:
|
||||||
|
|
||||||
|
- `frontend-web/src/roundtable-planning/`
|
||||||
|
- `frontend-web/src/open-canvas/`
|
||||||
|
- Coze 项目的 `workflow-standalone/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 数据来源与前端状态
|
||||||
|
|
||||||
|
### 6.1 三层状态
|
||||||
|
|
||||||
|
前端状态分为:
|
||||||
|
|
||||||
|
1. 服务器快照:session、原生 messages、plans、run、nodes、artifacts、report versions;每条消息带稳定的 `agentRunId`、`nodeRunId`、`phaseId` 和可见性标记,其中 artifacts 只用于生成左侧消息卡和报告文件,不形成独立结果面板。
|
||||||
|
2. durable SSE reducer:从 `seq` 连续应用事件,更新服务器快照投影。
|
||||||
|
3. 纯展示状态:预览中的方案、选中的节点、聚焦的并行 `agentRunId`、两栏宽度、节点抽屉开关、窄屏当前视图和画布位置。
|
||||||
|
|
||||||
|
纯展示状态不能改变执行结果。点击卡片只改变 `previewPlanId`;只有点击“采用方案”才改变服务器 `selectedPlanId`。
|
||||||
|
|
||||||
|
### 6.2 前端事件包
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface CollaborationEvent<T = unknown> {
|
||||||
|
eventId: string;
|
||||||
|
sessionId: string;
|
||||||
|
runId?: string;
|
||||||
|
seq: number;
|
||||||
|
type: string;
|
||||||
|
timestamp: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
必须处理:
|
||||||
|
|
||||||
|
- 快照先到、事件后到。
|
||||||
|
- 事件重复。
|
||||||
|
- 事件短暂乱序。
|
||||||
|
- SSE 断线后从 `after_seq` 重放。
|
||||||
|
- 页面切换时旧连接晚到事件。
|
||||||
|
- token delta 多次更新同一消息。
|
||||||
|
- TeamSay、assistant 文本、tool-call 参数 delta、tool 返回 delta 分别更新各自稳定消息,不得等节点完成后批量生成摘要。
|
||||||
|
- 并行成员的消息可交错到达;Reducer 必须按 `agentRunId + messageId` 更新,而不是按当前被选中的标签丢弃未聚焦成员的事件。
|
||||||
|
- 节点重新打开后出现新 attempt。
|
||||||
|
|
||||||
|
Reducer 以 `sessionId + runId + seq` 去重;消息行以 `messageId`、工具结果以 `toolCallId`、标签以 `agentRunId` 使用后端稳定 ID,禁止使用数组下标。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 分步实施计划
|
||||||
|
|
||||||
|
### FE-0:冻结参考视觉与行为基线
|
||||||
|
|
||||||
|
任务:
|
||||||
|
|
||||||
|
1. 在 1440×900、1280×800、1024×768 打开参考页面。
|
||||||
|
2. 保存初始态、方案态、运行态和节点说明抽屉四组截图。
|
||||||
|
3. 记录顶栏、两栏比例、间距、圆角、边框、阴影、画布工具栏和状态色。
|
||||||
|
4. 形成“参考组件—目标组件—允许差异”表。
|
||||||
|
5. 明确消息列表保持 DeerFlow 原样,不纳入像素对齐。
|
||||||
|
|
||||||
|
交付物:
|
||||||
|
|
||||||
|
- 视觉基线截图。
|
||||||
|
- 视觉 token 表。
|
||||||
|
- 页面行为清单。
|
||||||
|
|
||||||
|
验收:参考的每个用户可见状态都有截图或明确说明不存在。
|
||||||
|
|
||||||
|
### FE-1:路由、菜单和静态页面骨架
|
||||||
|
|
||||||
|
任务:
|
||||||
|
|
||||||
|
1. 增加两个路由和菜单项。
|
||||||
|
2. 创建 `ReportCollaborationPage`。
|
||||||
|
3. 创建顶栏、左侧对话栏、右侧画布和底部连接栏。
|
||||||
|
4. 实现可调整宽度的两栏布局,以及“专注对话/专注画布”切换。
|
||||||
|
5. 实现窄屏单面板切换与覆盖式节点 Drawer。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 不连接后端也能呈现完整空态。
|
||||||
|
- 375~1440px 不出现页面级横向滚动。
|
||||||
|
- 对话/画布切换不会卸载会话状态或清空输入内容。
|
||||||
|
- 键盘可以调整两栏分隔条。
|
||||||
|
|
||||||
|
### FE-2:页面设计系统与 Coze 风格重建
|
||||||
|
|
||||||
|
建立页面专用语义变量:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--rc-bg-page
|
||||||
|
--rc-bg-panel
|
||||||
|
--rc-bg-canvas
|
||||||
|
--rc-border
|
||||||
|
--rc-text-primary
|
||||||
|
--rc-text-secondary
|
||||||
|
--rc-accent
|
||||||
|
--rc-running
|
||||||
|
--rc-waiting
|
||||||
|
--rc-success
|
||||||
|
--rc-danger
|
||||||
|
--rc-panel-shadow
|
||||||
|
--rc-card-radius
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 从 DeerFlow 主题 token 派生,不在组件中散落十六进制颜色。
|
||||||
|
- 使用 Lucide SVG 图标,不使用 emoji。
|
||||||
|
- hover/focus/展开动画控制在 150~300ms。
|
||||||
|
- 支持 `prefers-reduced-motion`。
|
||||||
|
- 交互状态不能只靠颜色表达。
|
||||||
|
- 正文对比度达到 4.5:1。
|
||||||
|
- 操作超过 300ms 显示 spinner、骨架或流式状态。
|
||||||
|
|
||||||
|
验收:除 MessageList 内部外,两栏布局、密度、卡片、画布工具栏、弹窗和节点抽屉达到参考基线。
|
||||||
|
|
||||||
|
### FE-3:API 类型和 TanStack Query 层
|
||||||
|
|
||||||
|
实现:
|
||||||
|
|
||||||
|
- session CRUD。
|
||||||
|
- messages 查询与发送。
|
||||||
|
- plan request、plans 查询、select。
|
||||||
|
- run 创建、查询、停止、重试。
|
||||||
|
- command 创建、确认、取消。
|
||||||
|
- artifacts、sources、reports、versions 查询。
|
||||||
|
|
||||||
|
每个写请求携带:
|
||||||
|
|
||||||
|
- `idempotencyKey`
|
||||||
|
- `expectedRevision`
|
||||||
|
|
||||||
|
冲突行为:
|
||||||
|
|
||||||
|
- `409`:刷新当前快照并提示用户重新确认。
|
||||||
|
- `422`:在对应操作附近显示具体错误。
|
||||||
|
- `429`:显示预算/并发限制,不自动重试。
|
||||||
|
- `5xx`:保留用户输入,允许使用同一幂等键重试。
|
||||||
|
|
||||||
|
验收:API 层不包含页面展示逻辑,类型与后端 OpenAPI 一致。
|
||||||
|
|
||||||
|
### FE-4:SSE、快照和事件 Reducer
|
||||||
|
|
||||||
|
实现 `useReportCollaborationStream`:
|
||||||
|
|
||||||
|
1. 先拉 session snapshot。
|
||||||
|
2. 使用 snapshot 的 `lastSeq` 建立 SSE。
|
||||||
|
3. 校验每个事件 session/run 身份。
|
||||||
|
4. 按 seq 应用事件并去重。
|
||||||
|
5. 出现序号缺口时暂停 live apply,补拉缺失事件。
|
||||||
|
6. 断线指数退避并支持 `Last-Event-ID`。
|
||||||
|
7. 终态后完成最后一次事件补拉再关闭连接。
|
||||||
|
|
||||||
|
Reducer 必须覆盖:
|
||||||
|
|
||||||
|
- 消息流增量。
|
||||||
|
- 计划候选和 revision。
|
||||||
|
- node/node attempt 状态。
|
||||||
|
- artifact 创建和校验。
|
||||||
|
- command 分类和执行。
|
||||||
|
- report delta 和正式版本。
|
||||||
|
- 错误、取消、接管恢复。
|
||||||
|
- AgentScope 原始可见消息和工具事件:`message.created/delta/completed`、`tool_call.created/delta/completed`、`tool_result.created/delta/completed`、`team_message.created/delta/completed`。
|
||||||
|
|
||||||
|
验收:重复事件、乱序事件和重连都不产生重复消息、重复节点或状态倒退。
|
||||||
|
|
||||||
|
### FE-5:复用 DeerFlow `MessageList`
|
||||||
|
|
||||||
|
复用:
|
||||||
|
|
||||||
|
- `frontend-web/src/components/workspace/messages/message-list.tsx`
|
||||||
|
- `frontend-web/src/components/workspace/messages/message-list-item.tsx`
|
||||||
|
- `frontend-web/src/components/workspace/messages/message-group.tsx`
|
||||||
|
- `frontend-web/src/components/workspace/messages/context.ts`
|
||||||
|
|
||||||
|
实现无损协议适配 `message-adapter.ts`:
|
||||||
|
|
||||||
|
1. 将已持久化的 `CollaborationMessage` 一对一映射为 LangGraph SDK `Message[]`,不将文本或工具结果二次概括。
|
||||||
|
2. 构造最小 `BaseStream<AgentThreadState>` 外观:`messages`、`isLoading`、`isThreadLoading`、`values`。
|
||||||
|
3. 协调者、成员 AgentScope TeamSay 和成员回复都映射为普通 assistant 消息;保留 `messageId`、文本 chunk、角色/成员名称、`agentRunId`、`nodeRunId`、`phaseId`、来源事件 ID 和完成态。
|
||||||
|
4. AgentScope 工具调用映射为该 assistant 消息的标准 `tool_calls`;调用结果映射为标准 `tool` 消息,并严格复用原有 `tool_call_id` 配对。工具参数、返回正文及增量保持原文,只有后端明确标注的脱敏字段被替换。
|
||||||
|
5. 将来源、文件、报告 Markdown delta 和正式版本继续映射为既有引用、文件卡和普通 assistant 消息;规划卡、用户确认卡和报告替换卡仍通过一个通用业务卡片插槽渲染。
|
||||||
|
6. 错误、恢复建议、命令影响范围和报告版本变化也必须进入同一时间线;错误堆栈以可读错误消息替代,但不能吞掉已发生的工具/成员消息。
|
||||||
|
7. `selectMessagesForAgentRun(allMessages, focusedAgentRunId)` 只在客户端从已同步消息中选出共享消息与目标成员消息;并行选择器不得重新拉取、重新排序或改写消息。
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 不使用 LangGraph `useThreadStream` 发送消息。
|
||||||
|
- 新页面输入框调用报告协作 API。
|
||||||
|
- 不增加多个页面专用 MessageList props。
|
||||||
|
- 普通聊天、AI 写作、深度研究等未传插槽时必须零变化。
|
||||||
|
- 不另建执行结果列表、节点结果面板或成果时间线。
|
||||||
|
- 不用“运行中”“已检索 N 条”等专用摘要替代真实消息。`MessageList` 本身的流式状态和原生工具步骤是唯一过程视图。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 历史和流式消息使用同一行稳定更新。
|
||||||
|
- 单成员首次开始回复、每次工具启动和工具返回均在 300ms 内出现可见的原生消息行或原生工具步骤;不允许持续数秒只有静态加载圈。
|
||||||
|
- 并行标签切换后显示目标成员的完整已持久化消息和实时 delta;未选中成员持续接收,标签上的运行/待回复状态及时更新。
|
||||||
|
- 用户上翻阅读时不强制滚底。
|
||||||
|
- 用户发送新消息后滚到最新位置。
|
||||||
|
- 卡片显示成功不改变后端节点状态。
|
||||||
|
|
||||||
|
### FE-6:需求澄清和候选方案卡片
|
||||||
|
|
||||||
|
`ReportPlanCards` 展示:
|
||||||
|
|
||||||
|
- 推荐标签、策略、标题、摘要和推荐理由。
|
||||||
|
- 分析角度和参与角色。
|
||||||
|
- 关键步骤与质量门。
|
||||||
|
- 预计时间、成本等级和资料范围。
|
||||||
|
- 方案验证错误。
|
||||||
|
|
||||||
|
交互:
|
||||||
|
|
||||||
|
- 点击卡片或“查看流程”只切换预览。
|
||||||
|
- “采用此方案”调用 select API。
|
||||||
|
- “开始协作”再次校验并创建 run。
|
||||||
|
- 方案调整后显示新 revision;旧 revision 只读。
|
||||||
|
- 过期 revision 返回 409 后不静默覆盖。
|
||||||
|
|
||||||
|
验收:连续快速切换三张卡时,画布最终显示最后一次选择,不闪回旧图。
|
||||||
|
|
||||||
|
### FE-7:协作流程画布
|
||||||
|
|
||||||
|
技术:`@xyflow/react`。可以借鉴但不直接耦合:
|
||||||
|
|
||||||
|
- `frontend-web/src/components/ai-elements/canvas.tsx`
|
||||||
|
- `frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx`
|
||||||
|
|
||||||
|
节点显示:
|
||||||
|
|
||||||
|
- 节点名、角色名和分析角度。
|
||||||
|
- 状态文字、图标和颜色。
|
||||||
|
- 等待用户、返工、失败和 superseded 标记。
|
||||||
|
|
||||||
|
画布能力:
|
||||||
|
|
||||||
|
- 自动布局。
|
||||||
|
- 拖动节点只保存浏览器展示位置。
|
||||||
|
- 缩放、适配视图、方向切换、小地图。
|
||||||
|
- 活动边低强度流动动画。
|
||||||
|
- 点击节点打开覆盖在画布上的节点说明抽屉。
|
||||||
|
- 节点重跑、补充或纠偏一律通过左侧对话完成;抽屉只提供“在对话中对此节点提出意见”入口。
|
||||||
|
- 方案切换后清理不属于新方案的本地坐标。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 30 个节点内流畅操作。
|
||||||
|
- 单个 node event 不导致整个 React Flow 重挂。
|
||||||
|
- `awaiting_input`、`reopened`、`superseded` 不与 `completed` 混淆。
|
||||||
|
|
||||||
|
### FE-8:节点说明抽屉与对话定位
|
||||||
|
|
||||||
|
点击画布节点打开 `NodeInspectorDrawer`。抽屉只展示计划合同:
|
||||||
|
|
||||||
|
1. 节点名称、角色、分析角度和当前状态标签。
|
||||||
|
2. 节点职责:负责解决什么问题。
|
||||||
|
3. 要根据什么:所需输入类型、上游节点类型、资料范围和时效要求。
|
||||||
|
4. 要产出什么:成果类型、结构要求和验收条件。
|
||||||
|
5. 可用工具、禁止事项、预算上限。
|
||||||
|
6. 上游与下游关系。
|
||||||
|
|
||||||
|
抽屉不展示:
|
||||||
|
|
||||||
|
- 节点实际生成内容。
|
||||||
|
- 工具调用返回值。
|
||||||
|
- EvidenceBundle 或其他成果物正文。
|
||||||
|
- attempt 执行时间线。
|
||||||
|
- 错误堆栈和调试信息。
|
||||||
|
- 报告正文。
|
||||||
|
|
||||||
|
交互:
|
||||||
|
|
||||||
|
- “在对话中对此节点提出意见”关闭抽屉、聚焦左侧 Composer,并绑定 `targetNodeId` 上下文。
|
||||||
|
- 若该节点对应正在并行运行的成员,同时绑定该成员的 `agentRunId` 并切换左侧选择器,使用户发送前能先看到该成员的真实对话与工具过程。
|
||||||
|
- 用户仍需在对话区输入“重新检索”“重新分析”等要求;抽屉不直接发起业务变更。
|
||||||
|
- 状态标签来自后端节点状态,但不加载节点结果接口。
|
||||||
|
- 关闭抽屉不清空 selected node;再次点击同一节点可恢复说明。
|
||||||
|
|
||||||
|
报告预览不使用常驻第三栏。最终报告、章节候选和版本先作为左侧 MessageList 中的普通消息/文件卡出现;点击文件卡时复用 `ArtifactFileDetail` 或现有 Markdown 能力打开大尺寸 Dialog,关闭后回到两栏工作台。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 抽屉宽度不改变两栏布局,不挤压左侧对话区。
|
||||||
|
- 抽屉只读取节点合同和图结构,不请求节点执行结果。
|
||||||
|
- 所有原生成员消息、工具过程、错误和报告入口都能在左侧对话时间线找到。
|
||||||
|
|
||||||
|
### FE-9:全过程对话干预
|
||||||
|
|
||||||
|
`ReportComposer` 根据状态显示不同提示:
|
||||||
|
|
||||||
|
- 空闲:输入报告需求。
|
||||||
|
- 规划中:补充要求将在规划安全点应用。
|
||||||
|
- 运行中:协调者正在判断影响范围。
|
||||||
|
- 等待用户:优先回答等待问题。
|
||||||
|
- 完成后:可问答、重写、润色或重新研究。
|
||||||
|
|
||||||
|
发送流程:
|
||||||
|
|
||||||
|
1. 生成 idempotency key。
|
||||||
|
2. 插入 pending 用户消息;带 `targetNodeId`/`agentRunId` 时在相关成员消息流和共享上下文中引用同一条用户消息,不重复持久化。
|
||||||
|
3. 显示“正在识别修改范围”。
|
||||||
|
4. `command.classified` 后展示意图和影响节点。
|
||||||
|
5. 高成本或低置信度命令显示确认卡。
|
||||||
|
6. `command.accepted` 后显示排队或等待安全点。
|
||||||
|
7. 完成后关联新 node attempt 或 report version。
|
||||||
|
8. 失败时保留原消息和重试入口。
|
||||||
|
|
||||||
|
典型映射:
|
||||||
|
|
||||||
|
| 用户输入 | 预期操作 |
|
||||||
|
|---|---|
|
||||||
|
| “增加政策风险角度” | 新增角度节点和受影响下游 |
|
||||||
|
| “资料太旧,重新找” | 重新检索指定证据包 |
|
||||||
|
| “竞争格局分析不到位” | 复用证据,重新分析 |
|
||||||
|
| “重写第二章” | 只生成章节替换候选 |
|
||||||
|
| “语气更正式” | 新报告版本,不重新研究 |
|
||||||
|
| “为什么得出这个结论” | 只读问答,不修改报告 |
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 双击发送不创建重复命令。
|
||||||
|
- 选中“品牌竞争分析师”后发送补充要求,只影响该成员及依赖分析,其他并行成员仍保持消息流和运行状态。
|
||||||
|
- 运行中反馈不会让 run 或 node 误变 completed。
|
||||||
|
- “重写第二章”不会触发全量检索。
|
||||||
|
- “资料太旧”不会只做文字润色。
|
||||||
|
|
||||||
|
### FE-10:会话历史、报告版本与导出
|
||||||
|
|
||||||
|
- History Tab 显示标题、状态、更新时间、当前版本。
|
||||||
|
- 新建会话不删除旧会话。
|
||||||
|
- 会话切换时先关闭旧 SSE,再加载新快照。
|
||||||
|
- 报告修改先生成候选,确认后创建新版本。
|
||||||
|
- 版本恢复创建新的头版本,不删除历史。
|
||||||
|
- 下载复用现有 Markdown/Word 能力。
|
||||||
|
- 文件名来自报告标题,不暴露宿主机或沙箱路径。
|
||||||
|
|
||||||
|
验收:历史会话可完整恢复左侧消息流程、右侧节点状态和报告版本;不额外恢复节点结果面板。
|
||||||
|
|
||||||
|
### FE-11:测试、视觉回归和性能
|
||||||
|
|
||||||
|
单元测试:
|
||||||
|
|
||||||
|
- event normalizer/reducer。
|
||||||
|
- message adapter。
|
||||||
|
- plan revision 和选择状态。
|
||||||
|
- node status 和 available actions。
|
||||||
|
- command pending/idempotency。
|
||||||
|
|
||||||
|
组件测试:
|
||||||
|
|
||||||
|
- 空态、澄清、规划、候选、运行、等待、完成、失败、取消。
|
||||||
|
- 卡片选择、节点点击、只读说明 Drawer、确认弹窗和报告预览 Dialog。
|
||||||
|
- MessageList 通用业务卡插槽零回归。
|
||||||
|
- 单成员流式文本、TeamSay、工具调用与工具返回的原样重放;并行成员交错到达、切换标签、切回后不中断 delta。
|
||||||
|
- 非当前标签成员发起协助时显示待回复徽标,点击后使用原生协助卡回复到正确节点。
|
||||||
|
|
||||||
|
E2E:
|
||||||
|
|
||||||
|
1. 新建会话并输入新能源汽车报告要求。
|
||||||
|
2. 选择候选并开始。
|
||||||
|
3. 运行中增加风险角度。
|
||||||
|
4. 重新检索海外最新资料。
|
||||||
|
5. 完成后重写第二章。
|
||||||
|
6. 刷新、断网重连、切换会话。
|
||||||
|
7. 停止和重试失败节点。
|
||||||
|
|
||||||
|
性能门:
|
||||||
|
|
||||||
|
- 30 节点画布不卡顿。
|
||||||
|
- 长消息列表使用现有优化策略。
|
||||||
|
- SSE 高频 delta 合帧后再 setState。
|
||||||
|
- 画布和消息区使用独立 selector,避免互相触发整树重渲染。
|
||||||
|
- 节点抽屉只订阅节点合同和状态,不能因阶段产出更新而反复重渲染。
|
||||||
|
- 只渲染当前聚焦成员和共享消息的可视窗口;非当前成员的消息仍写入 store,但不因 token delta 重渲染当前 `MessageList`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 后端接口依赖
|
||||||
|
|
||||||
|
前端开发前,后端必须冻结以下接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/report-collaboration/sessions
|
||||||
|
GET /api/report-collaboration/sessions
|
||||||
|
GET /api/report-collaboration/sessions/{session_id}
|
||||||
|
PATCH /api/report-collaboration/sessions/{session_id}
|
||||||
|
DELETE /api/report-collaboration/sessions/{session_id}
|
||||||
|
|
||||||
|
POST /api/report-collaboration/sessions/{session_id}/messages
|
||||||
|
GET /api/report-collaboration/sessions/{session_id}/messages
|
||||||
|
POST /api/report-collaboration/sessions/{session_id}/plan-requests
|
||||||
|
GET /api/report-collaboration/sessions/{session_id}/plans
|
||||||
|
POST /api/report-collaboration/sessions/{session_id}/plans/{plan_id}/select
|
||||||
|
POST /api/report-collaboration/sessions/{session_id}/runs
|
||||||
|
|
||||||
|
GET /api/report-collaboration/runs/{run_id}
|
||||||
|
GET /api/report-collaboration/runs/{run_id}/events
|
||||||
|
GET /api/report-collaboration/runs/{run_id}/stream
|
||||||
|
POST /api/report-collaboration/runs/{run_id}/commands
|
||||||
|
POST /api/report-collaboration/runs/{run_id}/cancel
|
||||||
|
GET /api/report-collaboration/runs/{run_id}/nodes/{node_id}/contract
|
||||||
|
|
||||||
|
GET /api/report-collaboration/sessions/{session_id}/reports
|
||||||
|
POST /api/report-collaboration/sessions/{session_id}/report-rewrites
|
||||||
|
POST /api/report-collaboration/sessions/{session_id}/report-rewrites/{id}/apply
|
||||||
|
```
|
||||||
|
|
||||||
|
前后端共享枚举:
|
||||||
|
|
||||||
|
- session status。
|
||||||
|
- run status。
|
||||||
|
- node/node attempt status。
|
||||||
|
- command intent/status。
|
||||||
|
- event type/version。
|
||||||
|
- artifact type/schema version。
|
||||||
|
- report version/change type。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 前端开发顺序
|
||||||
|
|
||||||
|
页面与契约层已完成(代码在 `frontend-web/src/report-collaboration/`):
|
||||||
|
|
||||||
|
1. `RC-FE-001`:视觉截图与行为基线。
|
||||||
|
2. `RC-FE-002`:路由、菜单和左对话/右画布两栏空壳。
|
||||||
|
3. `RC-FE-003`:页面 token、弹窗、抽屉和响应式。
|
||||||
|
4. `RC-FE-004`:API 类型和 mock server。
|
||||||
|
5. `RC-FE-005`:SSE reducer 与快照恢复。
|
||||||
|
6. `RC-FE-006`:MessageList adapter 和通用卡片插槽。
|
||||||
|
7. `RC-FE-007`:需求澄清与候选方案卡。
|
||||||
|
8. `RC-FE-008`:流程画布和自定义节点。
|
||||||
|
9. `RC-FE-009`:只读节点说明抽屉与对话目标定位。
|
||||||
|
10. `RC-FE-010`:统一 Composer 和命令确认。
|
||||||
|
11. `RC-FE-011`:报告预览、版本和导出。
|
||||||
|
12. `RC-FE-012`:历史、重连和异常恢复。
|
||||||
|
13. `RC-FE-013`:组件/E2E/视觉/性能验收。
|
||||||
|
|
||||||
|
依赖规则:
|
||||||
|
|
||||||
|
- 契约已冻结为 `src/report-collaboration/api/types.ts`;后端 `RC-BE-001` 已对齐。
|
||||||
|
- 真实 SSE 联调:后端 `RC-BE-011` 已挂载 `GET /runs/{id}/stream`;默认无后台循环时流只会重放已入库事件。
|
||||||
|
- 报告版本操作已接后端 `RC-BE-015`(改写候选 / apply / restore)。
|
||||||
|
- 执行画布实时状态依赖打开 `worker_enabled`;质量角色内核已在 `RC-BE-013` 落地,真实成员见 `RC-BE-017`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 前端完成定义
|
||||||
|
|
||||||
|
页面与 mock 联调路径已落地。真实后端可联调 `GET /runs/{id}/stream`、领取/恢复(`RC-BE-012`,默认不挂 worker)、质量角色(`RC-BE-013`)以及报告改写/恢复(`RC-BE-015`)。此前以 mock driver 覆盖交互,不以「前端未编码」计未完成。
|
||||||
|
|
||||||
|
前端只有同时满足以下条件才算完成:
|
||||||
|
|
||||||
|
1. 页面结构与 Coze 参考页面逐状态验收通过。
|
||||||
|
2. 消息区复用 DeerFlow `MessageList`,其他聊天页面无回归。
|
||||||
|
3. 候选卡、左侧对话流程、右侧流程图和报告来自同一 session/run/event 状态。
|
||||||
|
4. 点击候选只预览,明确确认后才执行。
|
||||||
|
5. 用户可以在规划、运行、完成后三个阶段继续对话。
|
||||||
|
6. 协助卡显示或提交不会导致前端误标完成。
|
||||||
|
7. 节点点击只打开说明抽屉,可查看职责、所需输入、预期输出、工具和验收条件,不展示执行结果。
|
||||||
|
8. 节点重开、返工和 superseded 状态表达正确。
|
||||||
|
9. SSE 重连、刷新和历史切换无重复消息和状态倒退。
|
||||||
|
10. 报告修改以候选和版本形式完成,不直接覆盖正式报告。
|
||||||
|
11. 页面始终只有对话与画布两个常驻区域,所有执行过程和产出入口都能在对话时间线中找到。
|
||||||
|
12. 响应式、键盘、对比度、加载反馈和错误恢复通过验收。
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
# 报告协作工作台 — 前端对接指南
|
||||||
|
|
||||||
|
> **适用版本:** RC-BE-000~018 已落地(2026-09-06)
|
||||||
|
> **配套文档:**
|
||||||
|
> - [前端实施计划](./agentscope-多智能体报告协作工作台-前端实施计划.md)
|
||||||
|
> - [后端实施计划](./agentscope-多智能体报告协作工作台-后端实施计划.md)
|
||||||
|
> - 模块 README:`frontend-web/src/report-collaboration/README.md`
|
||||||
|
|
||||||
|
本文说明**前端如何对接真实后端**:环境配置、API 契约、用户流程、状态机、错误处理与联调验收。前端 API 客户端与页面已实现,通常**无需再写一套 HTTP 层**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 结论速览
|
||||||
|
|
||||||
|
| 问题 | 答案 |
|
||||||
|
|------|------|
|
||||||
|
| 前端还要写接口吗? | **不用**。`src/report-collaboration/api/` 已封装 REST + SSE,页面通过 `getReportCollaborationDriver()` 调用。 |
|
||||||
|
| 为什么调不通? | 最常见:`config.yaml` 里 `report_collaboration.enabled: false` → 全部 **503**;或 `worker_enabled: false` → run 不入队执行。 |
|
||||||
|
| Mock 和真后端怎么切? | **Mock 已移除**。前端永远请求真实后端(`deerflow.rc-mock` / `VITE_RC_MOCK` 开关已删除);联调需 `enabled: true`。 |
|
||||||
|
| 字段会和 MySQL 对不上吗? | 前端只认 JSON 契约(`api/types.ts`),不直连表。缺列表现为后端 500/SQL 错误,部署后需**重启 Gateway** 跑 `create_all` + 列同步。 |
|
||||||
|
| 从哪进页面? | `/page/workspace/report-collaboration`(新会话)或 `/page/workspace/report-collaboration/:sessionId`。侧边栏入口默认注释,可直接 URL 访问。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 架构:前端已有什么
|
||||||
|
|
||||||
|
```
|
||||||
|
ReportCollaborationPage
|
||||||
|
└─ useReportCollaborationSession(唯一数据入口)
|
||||||
|
├─ driver.getSnapshot() ← REST 冷启动
|
||||||
|
├─ driver.openRunStream() ← SSE 实时事件
|
||||||
|
├─ driver.sendMessage / createCommand / startRun …
|
||||||
|
└─ event-reducer ← 事件 → UI 状态
|
||||||
|
|
||||||
|
ReportConversationPanel
|
||||||
|
└─ MessageList(message-adapter 映射 CollaborationMessage → LangGraph Message)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.1 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
frontend-web/src/report-collaboration/
|
||||||
|
api/
|
||||||
|
types.ts # 冻结契约(snake_case,与后端 Pydantic 一致)
|
||||||
|
client.ts # rcFetch:鉴权、幂等键、乐观锁、错误映射
|
||||||
|
sessions.ts # 会话 CRUD、消息
|
||||||
|
plans.ts # 方案请求 / 列表 / 选定
|
||||||
|
runs.ts # Run、SSE、命令、节点合同
|
||||||
|
reports.ts # 报告版本、改写、恢复
|
||||||
|
driver.ts # 真实后端统一门面(Mock 驱动已移除)
|
||||||
|
state/
|
||||||
|
event-normalizer.ts
|
||||||
|
event-reducer.ts
|
||||||
|
selectors.ts
|
||||||
|
message-adapter.ts
|
||||||
|
hooks/
|
||||||
|
useReportCollaborationSession.ts # 快照 → SSE → reducer → 动作
|
||||||
|
useReportCollaborationStream.ts # 断线重连、after_seq 续接
|
||||||
|
useReportCollaborationMessages.ts
|
||||||
|
components/ …
|
||||||
|
pages/ReportCollaborationPage.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 事实源原则
|
||||||
|
|
||||||
|
- **冷启动:** `GET /sessions/{id}` 返回 `SessionSnapshot`(session、messages、plans、run、nodes、agent_runs、commands、report_versions、last_seq)。
|
||||||
|
- **运行中:** `GET /runs/{id}/stream?after_seq=N` 订阅 durable SSE。
|
||||||
|
- **断线 / 序号缺口:** `GET /runs/{id}/events?after_seq=N` REST 补拉。
|
||||||
|
- **澄清 / 规划阶段(尚无 run):** 每 2.5s 轮询快照(后端事件主要在 run 流上)。
|
||||||
|
|
||||||
|
纯展示状态(预览方案、聚焦成员、面板宽度)**不**写入服务器,仅存前端。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 后端启用与部署
|
||||||
|
|
||||||
|
### 3.1 必改配置
|
||||||
|
|
||||||
|
文件:`offline-backend-20260512/backend/config.yaml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
report_collaboration:
|
||||||
|
enabled: false # 联调必须改为 true
|
||||||
|
worker_enabled: false # 要看成员执行必须 true
|
||||||
|
planner_model: null # 建议填可用模型名
|
||||||
|
coordinator_model: null
|
||||||
|
research_model: null
|
||||||
|
writer_model: null
|
||||||
|
reviewer_model: null
|
||||||
|
# … token_budget、max_concurrent_runs_per_user 等见同文件
|
||||||
|
```
|
||||||
|
|
||||||
|
| 配置 | 现象 |
|
||||||
|
|------|------|
|
||||||
|
| `enabled: false` | 所有路由 **503**,`code: REPORT_COLLABORATION_DISABLED` |
|
||||||
|
| `enabled: true`,`worker_enabled: false` | REST/SSE 可用,run 入队但不自动执行(测 API 可用,无成员流式输出) |
|
||||||
|
| 两者 `true` + 模型配置完整 | 完整联调 |
|
||||||
|
|
||||||
|
### 3.2 MySQL / 表结构
|
||||||
|
|
||||||
|
生产环境 Alembic 自动升级默认关闭,启动时走 `create_all` + `_ensure_orm_columns_sync`。
|
||||||
|
|
||||||
|
- 迁移脚本(可选手动跑):`20260905_01`、`20260906_01`、`20260907_01`
|
||||||
|
- ORM:`packages/harness/deerflow/persistence/report_collaboration/model.py`(13 张表)
|
||||||
|
- **部署后务必重启 Gateway**,观察日志是否有 `Failed to auto-add column report_collaboration_*`
|
||||||
|
|
||||||
|
已有 RC-003 时代旧表会通过列同步补上 `template_snapshot_json`、`budget_snapshot_json`、`usage_json`、`budget_exhausted_reason` 等可空列,并创建 `rewrite_proposals`、`audits`。
|
||||||
|
|
||||||
|
### 3.3 前端环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `VITE_BACKEND_BASE_URL` | Gateway 地址,默认 `http://127.0.0.1:8001` |
|
||||||
|
|
||||||
|
请求经 `apiFetch`,自动携带登录 JWT(`credentials: "include"`)。**必须先登录 DeerFlow**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 基址与请求约定
|
||||||
|
|
||||||
|
### 4.1 Base URL
|
||||||
|
|
||||||
|
```text
|
||||||
|
{VITE_BACKEND_BASE_URL}/api/report-collaboration
|
||||||
|
```
|
||||||
|
|
||||||
|
代码常量:`REPORT_COLLABORATION_BASE()`(`api/client.ts`)。
|
||||||
|
|
||||||
|
### 4.2 请求头
|
||||||
|
|
||||||
|
| Header | 何时携带 | 说明 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| `Authorization` | 全部 | 由 `apiFetch` 注入 |
|
||||||
|
| `X-Idempotency-Key` | 写操作 | 格式 `rc-{uuid}`;重复提交返回首次结果 |
|
||||||
|
| `X-Expected-Revision` | 选方案、开 run、改会话等 | 会话 `requirement_revision` 或方案 `revision` |
|
||||||
|
| `Accept: text/event-stream` | SSE | `openRunStream` |
|
||||||
|
|
||||||
|
### 4.3 错误响应格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "REPORT_COLLABORATION_DISABLED",
|
||||||
|
"message": "报告协作工作台未启用",
|
||||||
|
"detail": { }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
前端映射(`api/client.ts`):
|
||||||
|
|
||||||
|
| HTTP | 异常类 | 典型场景 |
|
||||||
|
|------|--------|----------|
|
||||||
|
| 409 | `ConflictError` | revision 过期、active run 冲突 |
|
||||||
|
| 422 | `ValidationError` | 参数/语义错误、缺幂等键 |
|
||||||
|
| 429 | `RateLimitError` | 预算耗尽、每用户并发 run 上限 |
|
||||||
|
| 5xx | `ServerError` | 可带同一幂等键重试 |
|
||||||
|
| 0 | `NetworkError` | fetch 失败 |
|
||||||
|
|
||||||
|
### 4.4 完整路由清单(与前端 client 对齐)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 前端模块 |
|
||||||
|
|------|------|----------|
|
||||||
|
| POST | `/sessions` | `sessions.createSession` |
|
||||||
|
| GET | `/sessions` | `sessions.listSessions` |
|
||||||
|
| GET | `/sessions/{id}` | `sessions.getSessionSnapshot` |
|
||||||
|
| PATCH | `/sessions/{id}` | `sessions.updateSession` |
|
||||||
|
| DELETE | `/sessions/{id}` | `sessions.deleteSession` |
|
||||||
|
| GET | `/sessions/{id}/messages` | `sessions.listMessages` |
|
||||||
|
| POST | `/sessions/{id}/messages` | `sessions.sendMessage` |
|
||||||
|
| POST | `/sessions/{id}/plan-requests` | `plans.requestPlans` |
|
||||||
|
| GET | `/sessions/{id}/plans` | `plans.listPlans` |
|
||||||
|
| POST | `/sessions/{id}/plans/{planId}/select` | `plans.selectPlan` |
|
||||||
|
| POST | `/sessions/{id}/runs` | `runs.startRun` |
|
||||||
|
| GET | `/sessions/{id}/reports` | `reports.listReportVersions` |
|
||||||
|
| POST | `/sessions/{id}/report-rewrites` | `reports.createRewrite` |
|
||||||
|
| POST | `/sessions/{id}/report-rewrites/{id}/apply` | `reports.applyRewrite` |
|
||||||
|
| POST | `/sessions/{id}/report-versions/{id}/restore` | `reports.restoreReportVersion` |
|
||||||
|
| GET | `/runs/{id}` | `runs.getRun` |
|
||||||
|
| GET | `/runs/{id}/events` | `runs.listRunEvents` |
|
||||||
|
| GET | `/runs/{id}/stream` | `runs.openRunStream` |
|
||||||
|
| POST | `/runs/{id}/cancel` | `runs.cancelRun` |
|
||||||
|
| POST | `/runs/{id}/commands` | `runs.createCommand` |
|
||||||
|
| POST | `/runs/{id}/commands/{id}/confirm` | `runs.confirmCommand` |
|
||||||
|
| POST | `/runs/{id}/commands/{id}/cancel` | `runs.cancelCommand` |
|
||||||
|
| GET | `/runs/{id}/nodes/{nodeId}/contract` | `runs.getNodeContract` |
|
||||||
|
| GET | `/runs/{id}/artifacts` | (后端已暴露,前端按需接) |
|
||||||
|
| GET | `/runs/{id}/sources` | (后端已暴露,前端按需接) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 用户流程与 API 时序
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as 用户
|
||||||
|
participant FE as 前端 RC
|
||||||
|
participant API as Gateway
|
||||||
|
participant W as Worker
|
||||||
|
|
||||||
|
U->>FE: 输入需求
|
||||||
|
FE->>API: POST /sessions
|
||||||
|
FE->>API: POST /sessions/{id}/messages
|
||||||
|
API-->>FE: 澄清(快照 messages)
|
||||||
|
U->>FE: 回答澄清
|
||||||
|
FE->>API: POST /messages
|
||||||
|
FE->>API: POST /plan-requests
|
||||||
|
API-->>FE: 候选方案(快照 plans)
|
||||||
|
U->>FE: 采用方案
|
||||||
|
FE->>API: POST /plans/{id}/select
|
||||||
|
U->>FE: 开始协作
|
||||||
|
FE->>API: POST /runs
|
||||||
|
FE->>API: GET /runs/{id}/stream
|
||||||
|
W-->>API: 持久化事件
|
||||||
|
API-->>FE: SSE
|
||||||
|
U->>FE: 运行中干预
|
||||||
|
FE->>API: POST /commands → confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.1 阶段 0:进入页面
|
||||||
|
|
||||||
|
| 路由 | 行为 |
|
||||||
|
|------|------|
|
||||||
|
| `/page/workspace/report-collaboration` | 空态;`POST /sessions` 后跳转带 `sessionId` |
|
||||||
|
| `/page/workspace/report-collaboration/:sessionId` | 加载快照;有 `location.state.rcInitialMessage` 时自动发首条消息 |
|
||||||
|
|
||||||
|
路由注册:`frontend-web/src/pages/WorkspaceRoutes.tsx`。
|
||||||
|
侧边栏入口已在 `workspace-nav-chat-list.tsx` 中放开(「报告协作」项),也可直接访问 URL。
|
||||||
|
|
||||||
|
### 5.2 阶段 1:需求澄清(无 run)
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/report-collaboration/sessions
|
||||||
|
POST /api/report-collaboration/sessions/{sessionId}/messages
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "text": "请写一份…", "target_node_id": null, "agent_run_id": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `useReportCollaborationSession.send()`:当 `session.status` 不是 `running` / `awaiting_input` / `reviewing` 时走 `sendMessage`。
|
||||||
|
- 会话状态:`empty` → `clarifying` → `planning` → `proposal_ready`。
|
||||||
|
- 澄清问题以 `CollaborationMessage` 进入时间线;业务卡通过 `MessageList` 的 `businessCardSlot` 渲染(`additional_kwargs.report_collaboration_card`)。
|
||||||
|
|
||||||
|
### 5.3 阶段 2:候选方案
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/report-collaboration/sessions/{sessionId}/plan-requests
|
||||||
|
POST /api/report-collaboration/sessions/{sessionId}/plans/{planId}/select
|
||||||
|
```
|
||||||
|
|
||||||
|
- **预览方案**(`previewPlanId`):纯前端,不调 API。
|
||||||
|
- **采用方案**:`select` 写 `selected_plan_id`,**不**创建 run。
|
||||||
|
- 409:方案 revision 过期 → 前端 `refreshSnapshot`,提示用户重选。
|
||||||
|
- 右栏 `ReportFlowCanvas` 使用 `PlanNode` / `PlanEdge` 渲染 DAG。
|
||||||
|
|
||||||
|
### 5.4 阶段 3:开始执行与 SSE
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/report-collaboration/sessions/{sessionId}/runs
|
||||||
|
{ "plan_id": "plan-xxx" }
|
||||||
|
|
||||||
|
GET /api/report-collaboration/runs/{runId}/stream?after_seq=0
|
||||||
|
Accept: text/event-stream
|
||||||
|
```
|
||||||
|
|
||||||
|
SSE 每条 `data:` 为 JSON:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CollaborationEvent {
|
||||||
|
eventId: string;
|
||||||
|
sessionId: string;
|
||||||
|
runId?: string | null;
|
||||||
|
seq: number;
|
||||||
|
type: CollaborationEventType;
|
||||||
|
timestamp: string;
|
||||||
|
data: unknown;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
常用 `type`(完整列表见 `api/types.ts`):
|
||||||
|
|
||||||
|
| 类型 | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| `message.created` / `message.delta` / `message.completed` | 对话镜像流式 |
|
||||||
|
| `tool_call.*` / `tool_result.*` | 工具调用与返回 |
|
||||||
|
| `team_message.*` | 成员 / 协调者广播 |
|
||||||
|
| `plan.proposed` / `plan.selected` | 方案生命周期 |
|
||||||
|
| `run.status.changed` | Run 状态 |
|
||||||
|
| `node.status.changed` / `node.progress` | 节点执行 |
|
||||||
|
| `command.confirmation.required` | 需用户确认的干预 |
|
||||||
|
| `report.version.created` | 报告版本落盘 |
|
||||||
|
| `heartbeat` | 保活 |
|
||||||
|
|
||||||
|
### 5.5 阶段 4:运行中干预
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/report-collaboration/runs/{runId}/commands
|
||||||
|
POST /api/report-collaboration/runs/{runId}/commands/{commandId}/confirm
|
||||||
|
POST /api/report-collaboration/runs/{runId}/commands/{commandId}/cancel
|
||||||
|
POST /api/report-collaboration/runs/{runId}/cancel
|
||||||
|
```
|
||||||
|
|
||||||
|
- Run 活跃时 `send()` 自动走 `createCommand`(而非 `sendMessage`)。
|
||||||
|
- 可从节点抽屉 / `AgentStageSelector` 传入 `target_node_id`、`agent_run_id`。
|
||||||
|
- `InterventionCard` 展示 `confirmation_required` 命令;确认后调 `confirmCommand`。
|
||||||
|
|
||||||
|
`CommandIntent` 枚举(后端分类,前端展示 reason/impact):`answer_question`、`update_requirements`、`research_again`、`rewrite_section`、`rerun_node`、`replan`、`cancel` 等。
|
||||||
|
|
||||||
|
### 5.6 阶段 5:报告版本与改写
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/report-collaboration/sessions/{sessionId}/reports
|
||||||
|
POST /api/report-collaboration/sessions/{sessionId}/report-rewrites
|
||||||
|
POST /api/report-collaboration/sessions/{sessionId}/report-rewrites/{rewriteId}/apply
|
||||||
|
POST /api/report-collaboration/sessions/{sessionId}/report-versions/{versionId}/restore
|
||||||
|
```
|
||||||
|
|
||||||
|
- `createRewrite` 只生成候选,不直接覆盖正式版本。
|
||||||
|
- `applyRewrite` / `restoreReportVersion` 产生新的 head 版本,历史保留。
|
||||||
|
- `ReportPreviewDialog` 展示 `ReportVersion.markdown`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 前端状态机(接入方须知)
|
||||||
|
|
||||||
|
**不要**在页面里用零散 REST 字段拼 UI 状态。统一路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SessionSnapshot → applySessionSnapshot()
|
||||||
|
CollaborationEvent[] → normalizeCollaborationEvent() → applyCollaborationEvents()
|
||||||
|
→ selectors(derivePagePhase、selectPlanForCanvas、…)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.1 核心 Selector
|
||||||
|
|
||||||
|
| Selector | 用途 |
|
||||||
|
|----------|------|
|
||||||
|
| `derivePagePhase` | Composer 占位、方案卡、空态 |
|
||||||
|
| `selectPlanForCanvas` | 画布 DAG(含预览方案) |
|
||||||
|
| `selectAgentTabs` | 并行成员 Tab |
|
||||||
|
| `selectNodeStatusMap` | 节点颜色 / 状态 |
|
||||||
|
| `selectPendingConfirmationCommands` | 干预确认卡 |
|
||||||
|
| `selectHeadReportVersion` | 报告预览入口 |
|
||||||
|
| `selectStreamingAgentRunIds` | 流式成员指示 |
|
||||||
|
|
||||||
|
### 6.2 SSE 连接策略(`useReportCollaborationStream`)
|
||||||
|
|
||||||
|
- Run 非终态时连接;`completed` / `failed` / `cancelled` 后关流并 REST 最终补拉。
|
||||||
|
- 断线:指数退避重连(1s~30s),`after_seq` 取 reducer 内 `lastSeq`。
|
||||||
|
- `hasSeqGap`:暂停 live 应用,先 `listRunEvents` 回填。
|
||||||
|
|
||||||
|
### 6.3 乐观消息
|
||||||
|
|
||||||
|
用户发送时插入 `metadata.optimistic: true` 的 pending 消息;服务端镜像带相同 `idempotency_key` 时 reducer 原地替换。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 消息与 MessageList 复用
|
||||||
|
|
||||||
|
`useReportCollaborationMessages` 将 `CollaborationMessage[]` 转为 `@langchain/langgraph-sdk` 的 `Message[]`,并构造合成 `BaseStream` 供 `MessageList` 消费。
|
||||||
|
|
||||||
|
| 来源 | 渲染 |
|
||||||
|
|------|------|
|
||||||
|
| `human` / `ai` 文本 | 普通气泡 |
|
||||||
|
| `tool_calls` + `tool` | 现有工具卡 |
|
||||||
|
| `team_message` 事件 | 带 `name` 的成员发言 |
|
||||||
|
| 业务卡 | `businessCardSlot`(方案组、命令确认、报告可用) |
|
||||||
|
|
||||||
|
分组键:`core/messages/utils.ts` 中 `assistant:task-report-import` 同级扩展 `assistant:business-card`,判据 `additional_kwargs.report_collaboration_card`。
|
||||||
|
|
||||||
|
### 7.1 在其他页面嵌入 RC(最小集)
|
||||||
|
|
||||||
|
1. `useReportCollaborationSession(sessionId)`
|
||||||
|
2. `ThreadContext.Provider` + `MessageList` + `businessCardSlot`
|
||||||
|
3. 引入 `report-collaboration/styles/report-collaboration.css`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 单测
|
||||||
|
|
||||||
|
Mock 驱动(原 `api/mock.ts`)与 `deerflow.rc-mock` / `VITE_RC_MOCK` 开关已移除,前端只请求真实后端;本地演示请直接联调后端。
|
||||||
|
|
||||||
|
单测:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend-web
|
||||||
|
pnpm test:report-collaboration
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 联调验收清单
|
||||||
|
|
||||||
|
### 9.1 准备
|
||||||
|
|
||||||
|
- [ ] `report_collaboration.enabled: true`
|
||||||
|
- [ ] `report_collaboration.worker_enabled: true`
|
||||||
|
- [ ] 五个 `*_model` 已配置且可用
|
||||||
|
- [ ] Gateway 已重启(MySQL 表/列就绪)
|
||||||
|
- [ ] 前端已登录(JWT 有效)
|
||||||
|
|
||||||
|
### 9.2 探活
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: Bearer <token>" \
|
||||||
|
http://127.0.0.1:8001/api/report-collaboration/sessions
|
||||||
|
```
|
||||||
|
|
||||||
|
期望:**200** + `{ "sessions": [...] }`,而非 503。
|
||||||
|
|
||||||
|
### 9.3 页面流程(浏览器 Network)
|
||||||
|
|
||||||
|
1. 打开 `/#/page/workspace/report-collaboration`
|
||||||
|
2. 输入需求 → `POST .../sessions` **200**
|
||||||
|
3. 跳转 → `GET .../sessions/{id}` **200**,响应含 `SessionSnapshot`
|
||||||
|
4. 首条消息 → `POST .../messages` **200**
|
||||||
|
5. 请求方案 → `POST .../plan-requests` **200**
|
||||||
|
6. 采用方案 → `POST .../plans/{id}/select` **200**
|
||||||
|
7. 开始协作 → `POST .../runs` **200**
|
||||||
|
8. `GET .../runs/{id}/stream` 持续收到 SSE(`message.delta`、`node.status.changed` 等)
|
||||||
|
9. 刷新页面 → 消息与 `last_seq` 续接正确
|
||||||
|
10. 运行中输入「重新检索」→ `POST .../commands` → 确认卡 → `confirm` **200**
|
||||||
|
|
||||||
|
### 9.4 常见失败
|
||||||
|
|
||||||
|
| 现象 | 原因 | 处理 |
|
||||||
|
|------|------|------|
|
||||||
|
| 全部 503 | `enabled: false` | 改配置并重启 |
|
||||||
|
| run 一直 queued | `worker_enabled: false` | 改为 true |
|
||||||
|
| 409 选方案 | revision 过期 | 刷新后重选(前端已自动 refresh) |
|
||||||
|
| 429 | 预算/并发上限 | 调大配置或取消其他 run |
|
||||||
|
| 500 + SQL | 表缺列 | 重启 Gateway,查列同步日志 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 契约与类型维护
|
||||||
|
|
||||||
|
- **单一事实源:** `frontend-web/src/report-collaboration/api/types.ts`
|
||||||
|
- **后端镜像:** `offline-backend-20260512/backend/app/report_collaboration/contracts/`
|
||||||
|
- 修改字段须同步两份实施计划与本指南,并跑前后端相关测试。
|
||||||
|
|
||||||
|
`SessionSnapshot` 形状(摘要):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SessionSnapshot {
|
||||||
|
session: ReportSession;
|
||||||
|
messages: CollaborationMessage[];
|
||||||
|
plans: ReportPlanCandidate[];
|
||||||
|
run: ReportRun | null;
|
||||||
|
nodes: CollaborationNodeState[];
|
||||||
|
agent_runs: AgentRun[];
|
||||||
|
commands: CollaborationCommand[];
|
||||||
|
report_versions: ReportVersion[];
|
||||||
|
last_seq: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ReportRun` 可选字段(RC-BE-016):`usage`、`budget_exhausted_reason`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 后续工作(非对接阻塞)
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| RC-BE-019 | 灰度发布与离线部署文档 |
|
||||||
|
| 侧边栏入口 | 已放开(`workspace-nav-chat-list.tsx` 的「报告协作」项) |
|
||||||
|
| iframe 嵌入 | RC 页尚未专门适配 `embed=1`,可参考现有深链模式扩展 |
|
||||||
|
| 真人盲评 | RC-BE-018 为确定性评测集,上线前仍需计划 §11.4 人工评审 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 相关代码索引
|
||||||
|
|
||||||
|
| 用途 | 路径 |
|
||||||
|
|------|------|
|
||||||
|
| API 客户端 | `frontend-web/src/report-collaboration/api/` |
|
||||||
|
| 会话 Hook | `frontend-web/src/report-collaboration/hooks/useReportCollaborationSession.ts` |
|
||||||
|
| 页面 | `frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx` |
|
||||||
|
| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
|
||||||
|
| 后端路由 | `offline-backend-20260512/backend/app/gateway/routers/report_collaboration.py` |
|
||||||
|
| 配置 | `offline-backend-20260512/backend/config.yaml` → `report_collaboration` |
|
||||||
|
| 持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_collaboration/` |
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,304 @@
|
|||||||
|
# OpenVibeCoding 内网可视化应用生成平台:分步开发方案
|
||||||
|
|
||||||
|
> 文档状态:待实施
|
||||||
|
>
|
||||||
|
> 目标环境:现有 Ubuntu 22.04 Docker 服务器;对外可用端口限定为 `8020-8030`。
|
||||||
|
>
|
||||||
|
> 本文基于 2026-08-31 的只读巡检编写。未在目标服务器上安装、停止或修改任何服务。
|
||||||
|
|
||||||
|
## 1. 目标与范围
|
||||||
|
|
||||||
|
将 OpenVibeCoding 改造成一个使用公司内网模型、在公司服务器 Docker 沙箱中生成并运行可视化应用的工作台。用户通过对话生成项目,查看并修改文件,在浏览器中预览运行结果。
|
||||||
|
|
||||||
|
第一期只服务以下项目类型:
|
||||||
|
|
||||||
|
- 数据大屏、数据看板、海报和展示型页面;
|
||||||
|
- React + Vite + TypeScript 前端;
|
||||||
|
- ECharts 图表、流程图;
|
||||||
|
- 可选的轻量 Python FastAPI 接口;
|
||||||
|
- 项目文件浏览、编辑、运行日志和实时预览。
|
||||||
|
|
||||||
|
第一期明确不做:
|
||||||
|
|
||||||
|
- 腾讯云 CloudBase、SCF、TCR、CodeBuddy、云端一键部署;
|
||||||
|
- 任意语言、任意 npm/PyPI 包的无限制安装;
|
||||||
|
- 用户自定义 Dockerfile、Docker Socket 挂载、宿主机命令执行;
|
||||||
|
- 直接将沙箱暴露为独立公网端口;
|
||||||
|
- 对不可信代码提供“绝对安全”的隔离承诺。
|
||||||
|
|
||||||
|
## 2. 已确认的服务器基线
|
||||||
|
|
||||||
|
目标服务器当前具备:
|
||||||
|
|
||||||
|
| 项目 | 巡检结果 | 判断 |
|
||||||
|
|---|---:|---|
|
||||||
|
| 操作系统 | Ubuntu 22.04.5 LTS | 支持 Docker 方案 |
|
||||||
|
| CPU | 32 vCPU | 足够第一期 |
|
||||||
|
| 内存 | 125 GiB;巡检时可用约 106 GiB | 足够第一期 |
|
||||||
|
| 根磁盘 | 2 TB;巡检时可用约 1.6 TB | 足够 Harbor、镜像与工作区 |
|
||||||
|
| Docker | 29.1.3,Docker Compose 2.40.3 | 已满足 |
|
||||||
|
| 负载 | 约 0.5 | 当前有余量 |
|
||||||
|
| `8020-8030` | 巡检时无监听、无 Docker 端口映射 | 可规划使用 |
|
||||||
|
|
||||||
|
该服务器已承载 DeerFlow、Coze、LiteLLM、数据库和其他容器;`80`、`8000`、`8005`、`8006`、`3306`、`5432` 等端口已经被使用。因此,本项目不能占用现有端口、网络、数据库或容器名称。
|
||||||
|
|
||||||
|
该服务器有公网 IP,且已有多项服务绑定在 `0.0.0.0`。本方案将它视作“公网可达的共享主机”处理;云安全组和宿主机防火墙必须额外核验,不能仅凭端口监听结果假设安全。
|
||||||
|
|
||||||
|
## 3. 总体架构
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
U[用户浏览器] -->|:8020| G[App Builder Gateway]
|
||||||
|
U -->|:8022 /preview/<sandboxId>/| P[Preview Router]
|
||||||
|
|
||||||
|
G --> O[OpenVibe Web / Agent Server]
|
||||||
|
O --> M[现有内网模型网关]
|
||||||
|
O --> DB[(独立 PostgreSQL)]
|
||||||
|
O --> R[(独立 Redis)]
|
||||||
|
O --> SM[Local Sandbox Manager]
|
||||||
|
|
||||||
|
SM --> D[Docker Engine]
|
||||||
|
D --> S1[visual-ui Sandbox]
|
||||||
|
D --> S2[visual-fullstack Sandbox]
|
||||||
|
P --> S1
|
||||||
|
P --> S2
|
||||||
|
|
||||||
|
D --> H[Harbor :8023]
|
||||||
|
S1 --> N[内网 pnpm/npm 源或预置离线依赖]
|
||||||
|
S2 --> N
|
||||||
|
```
|
||||||
|
|
||||||
|
设计原则:
|
||||||
|
|
||||||
|
1. 保留 OpenVibe 的对话、任务、文件树、工具卡片和 OpenCode ACP 代码代理体验。
|
||||||
|
2. 模型请求仅由 OpenVibe 服务端发往既有内网模型网关;API Key 不进入沙箱。
|
||||||
|
3. 每个项目运行于独立 Docker 容器和独立工作区;沙箱只运行项目代码,不承担平台控制逻辑。
|
||||||
|
4. 浏览器预览只经过统一预览路由,不为每个项目映射新的公网端口。
|
||||||
|
5. 先用固定模板和固定依赖清单保证稳定,后续再按白名单增加依赖。
|
||||||
|
|
||||||
|
## 4. 端口规划
|
||||||
|
|
||||||
|
端口虽然可用,但应尽量少暴露。除 8020、8022 和按需开放的 8023 外,其余服务只加入 Docker 内部网络。
|
||||||
|
|
||||||
|
| 端口 | 服务 | 绑定范围 | 用途 |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| 8020 | `appbuilder-gateway` | 公网访问面或 VPN 白名单 | 控制台页面、`/api`、SSE/WebSocket |
|
||||||
|
| 8021 | 预留 | 仅 `127.0.0.1` | 平台调试;第一期不对外开放 |
|
||||||
|
| 8022 | `preview-router` | 公网访问面或 VPN 白名单 | 统一预览入口:`/preview/<sandboxId>/` |
|
||||||
|
| 8023 | Harbor | 仅沙箱节点、构建节点和管理员网段 | 私有镜像仓库 HTTPS Registry |
|
||||||
|
| 8024 | 预留 | 不对外开放 | 后续 npm 私服或运维服务 |
|
||||||
|
| 8025-8030 | 预留 | 不对外开放 | 扩展、迁移或故障切换 |
|
||||||
|
|
||||||
|
### 4.1 预览路由规则
|
||||||
|
|
||||||
|
暂时没有可用泛域名时,使用路径型预览:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http(s)://<服务器地址>:8022/preview/<sandboxId>/
|
||||||
|
```
|
||||||
|
|
||||||
|
项目模板的 Vite `base` 和 HMR 路径必须由启动脚本注入为该前缀,确保图片、JS/CSS 资源及热更新不会错误请求到根路径。后续若获得 `*.preview.company.local` 内网泛域名,可切换为子域名预览,用户体验更好。
|
||||||
|
|
||||||
|
## 5. 镜像、模板与离线依赖策略
|
||||||
|
|
||||||
|
### 5.1 标准镜像
|
||||||
|
|
||||||
|
第一期维护两种版本化镜像,不允许用户自行构建 Dockerfile:
|
||||||
|
|
||||||
|
| 镜像 | 用途 | 预置内容 |
|
||||||
|
|---|---|---|
|
||||||
|
| `appbuilder/visual-ui:<version>` | 大屏、海报、纯前端 | Node.js 20、pnpm、React、Vite、TypeScript、ECharts、流程图库、统一 UI 库、Tailwind、图标与中文字体 |
|
||||||
|
| `appbuilder/visual-fullstack:<version>` | 前端加接口 | 上述前端依赖 + Python 3.12、FastAPI、Uvicorn、Pydantic |
|
||||||
|
|
||||||
|
流程图库第一期统一选择一种(建议 React Flow),避免同时生成多种流程图语法和样式。地图、背景图、字体等资源也应作为受版本控制的本地资产打包,不能依赖 Google Fonts 或公网 CDN。
|
||||||
|
|
||||||
|
### 5.2 项目模板
|
||||||
|
|
||||||
|
每次创建项目时从镜像中的模板初始化到 `/workspace`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
templates/
|
||||||
|
visual-screen/ # React + ECharts + React Flow + Mock 数据
|
||||||
|
visual-screen-api/ # visual-screen + FastAPI + /api 反向代理
|
||||||
|
poster-page/ # 海报、营销展示、静态动效
|
||||||
|
```
|
||||||
|
|
||||||
|
`visual-screen-api` 约定:前端只请求相对路径 `/api/*`;预览网关将其转发到同一沙箱的 FastAPI `8000` 端口。没有后端时保留 Mock 数据模块,生成的页面仍可运行。
|
||||||
|
|
||||||
|
### 5.3 依赖预置方式
|
||||||
|
|
||||||
|
不能仅把安装命令写进 Dockerfile 后在用户创建项目时再联网执行。正确做法是:
|
||||||
|
|
||||||
|
1. 在受控构建环境中锁定 `package.json`、`pnpm-lock.yaml`、`requirements.lock`。
|
||||||
|
2. 构建镜像时执行 `pnpm fetch`,在镜像保存只读 pnpm store;Python 包保存到 `/opt/wheelhouse`。
|
||||||
|
3. 沙箱初始化使用 `pnpm install --offline --frozen-lockfile` 和 `pip install --no-index --find-links=/opt/wheelhouse`。
|
||||||
|
4. 第一期开启“固定依赖白名单”:AI 只能使用镜像已安装的依赖;新依赖先由管理员审核、加入锁文件和下一版基础镜像。
|
||||||
|
5. 第二期部署 Verdaccio/Nexus 等内部制品源,作为受控补充而非允许访问公网 npm/PyPI。
|
||||||
|
|
||||||
|
## 6. OpenVibe 改造边界
|
||||||
|
|
||||||
|
### 6.1 保留
|
||||||
|
|
||||||
|
- 前端交互:对话、任务状态、文件浏览与修改、预览、日志;
|
||||||
|
- OpenCode ACP 代码代理;
|
||||||
|
- 项目与会话领域模型;
|
||||||
|
- 代码代理的流式事件协议。
|
||||||
|
|
||||||
|
### 6.2 替换或关闭
|
||||||
|
|
||||||
|
| OpenVibe 当前能力 | 第一期开源/内网实现 |
|
||||||
|
|---|---|
|
||||||
|
| CodeBuddy | 只启用 OpenCode,配置内网 OpenAI 兼容模型网关 |
|
||||||
|
| CloudBase、SCF 沙箱 | 新增 `LocalDockerSandboxManager` |
|
||||||
|
| TCR | Harbor |
|
||||||
|
| CloudBase 环境、用户环境池 | Docker 容器、工作区卷、沙箱记录 |
|
||||||
|
| CloudBase 消息与事件持久化 | 独立 PostgreSQL;Redis 用于临时队列与状态 |
|
||||||
|
| CloudBase 存储、函数、MCP、资源管理后台 | 第一期开关关闭或移除入口 |
|
||||||
|
| CloudBase 项目模板和系统提示词 | 替换为大屏/海报/FastAPI 模板与受控依赖提示词 |
|
||||||
|
|
||||||
|
重点改造点是将 OpenVibe 的 `scf-sandbox-manager` 和 CloudBase 生命周期调用替换为本地 Docker 生命周期。为降低前端和 OpenCode 工具改动范围,新管理器应适配现有的文件、命令、日志、健康检查与工作区信息契约,而不是改写所有 Agent 工具。
|
||||||
|
|
||||||
|
## 7. 沙箱安全基线
|
||||||
|
|
||||||
|
每个沙箱容器必须满足:
|
||||||
|
|
||||||
|
- 非 root 用户运行;禁止 `privileged`,禁止 Docker Socket,禁止宿主机目录挂载;
|
||||||
|
- `--cap-drop=ALL`、`no-new-privileges`、`pids-limit`;
|
||||||
|
- 首期默认限制:`1 vCPU`、`3 GB 内存`、`10 GB 工作区`、60 分钟空闲回收;
|
||||||
|
- 使用独立 Docker network;默认无公网出口,只允许访问预置依赖、必要的内网 DNS 和平台回调;
|
||||||
|
- 数据库、Redis、Harbor 管理接口、宿主机管理端口不在沙箱网络可访问范围;
|
||||||
|
- 运行命令、容器生命周期、文件变更和预览访问写入审计日志;
|
||||||
|
- 平台服务由受限服务账号调用 Docker API;用户代码永远不直接获得 Docker 权限。
|
||||||
|
|
||||||
|
第一期为“内部可信用户的容器隔离”。若后续允许大量非可信代码或文件上传,应迁移为 Kubernetes 节点,并评估 gVisor/Kata 等更强运行时隔离。
|
||||||
|
|
||||||
|
## 8. 分步实施计划
|
||||||
|
|
||||||
|
### 阶段 0:部署前隔离与基线确认
|
||||||
|
|
||||||
|
**工作项**
|
||||||
|
|
||||||
|
1. 为本项目建立独立目录、Docker Compose Project、卷和网络,命名统一为 `appbuilder-*`。
|
||||||
|
2. 确认云安全组、iptables/nftables 规则:仅允许管理员 SSH;8020、8022、8023 仅向 VPN 或指定办公出口开放。
|
||||||
|
3. 不复用当前服务器上已有的 MySQL、PostgreSQL、Redis、Nginx 配置;建立独立实例或独立命名空间。
|
||||||
|
4. 创建内部 CA 证书或确定可信 HTTPS 证书方案;Docker 构建/运行节点信任 Harbor 证书。
|
||||||
|
5. 轮换目标服务器 root 密码,改用 SSH 密钥和受限运维账号;关闭 root 密码远程登录。
|
||||||
|
|
||||||
|
**验收**
|
||||||
|
|
||||||
|
- `8020-8030` 无端口冲突;
|
||||||
|
- 新容器、网络和数据卷均不影响既有 DeerFlow 等服务;
|
||||||
|
- 业务数据库和沙箱端口无法从非授权公网地址访问。
|
||||||
|
|
||||||
|
### 阶段 1:内网基础设施与镜像供应链
|
||||||
|
|
||||||
|
**工作项**
|
||||||
|
|
||||||
|
1. 在独立 Compose 项目中部署 Harbor,主入口为 `8023`,镜像数据使用独立持久化目录。
|
||||||
|
2. 构建并推送 `visual-ui`、`visual-fullstack` 两种基础镜像;记录镜像版本、SBOM 和依赖锁文件。
|
||||||
|
3. 完成三个模板及离线依赖初始化脚本。
|
||||||
|
4. 先不启动漏洞扫描;严格断网环境下后续通过离线包维护漏洞库。
|
||||||
|
5. 预留内部 npm/PyPI 源接入方式;第一期仅使用预置依赖。
|
||||||
|
|
||||||
|
**验收**
|
||||||
|
|
||||||
|
- Docker daemon 能从 Harbor 拉取指定版本镜像;
|
||||||
|
- 在禁止公网访问的测试网络中,三个模板均能启动并构建;
|
||||||
|
- 一个 ECharts 页面、一个流程图页面和一个 FastAPI `/api/health` 均可运行。
|
||||||
|
|
||||||
|
### 阶段 2:OpenVibe 去 CloudBase 化与内网模型接入
|
||||||
|
|
||||||
|
**工作项**
|
||||||
|
|
||||||
|
1. Fork 固定版本的 OpenVibeCoding,建立 `local-platform` 改造分支,记录上游 commit 与许可证。
|
||||||
|
2. 只保留 OpenCode Agent;将 provider 指向现有内网模型网关,模型凭证只保存在服务端环境变量或密钥服务。
|
||||||
|
3. 将基础实体、任务、消息、流式事件的持久化迁移为独立 PostgreSQL;不再调用 CloudBase 集合。
|
||||||
|
4. 关闭 CloudBase 环境、函数、存储、数据库、资源仪表盘和相关 MCP 入口。
|
||||||
|
5. 将系统提示词和项目创建默认项改为“可视化页面 + 受控模板/依赖”。
|
||||||
|
|
||||||
|
**验收**
|
||||||
|
|
||||||
|
- 不设置任何腾讯云、CloudBase、CodeBuddy、TCR 环境变量时,平台可启动;
|
||||||
|
- 用户发起“生成 ECharts 大屏”后,OpenCode 能调用内网模型并生成项目文件;
|
||||||
|
- 服务端日志和沙箱环境中均没有模型 API Key。
|
||||||
|
|
||||||
|
### 阶段 3:本地沙箱管理器与预览路由
|
||||||
|
|
||||||
|
**工作项**
|
||||||
|
|
||||||
|
1. 实现 `LocalDockerSandboxManager`:创建、启动、停止、超时回收、查询状态和删除容器。
|
||||||
|
2. 每个沙箱分配工作区卷、随机内部容器地址、模板类型和资源限制;在数据库记录 `sandbox_id`、项目、用户、镜像版本、过期时间和状态。
|
||||||
|
3. 实现或适配沙箱内 runner:文件读写、受限命令执行、日志流、健康检查、前端端口和 FastAPI 端口发现。
|
||||||
|
4. 实现 `preview-router`:由平台根据 `sandboxId` 维护路由,将 `/preview/<sandboxId>/` 代理到对应内部容器;支持 WebSocket/HMR。
|
||||||
|
5. 增加容器异常退出、端口未就绪、构建失败、超时与手动重启的可见状态。
|
||||||
|
|
||||||
|
**验收**
|
||||||
|
|
||||||
|
- 两个用户创建的项目存在于不同容器和不同工作区;
|
||||||
|
- 修改 ECharts 配置文件后,预览地址显示修改结果;
|
||||||
|
- FastAPI 模板的 `/api/health` 可通过前端相对路径访问;
|
||||||
|
- 沙箱不能访问 Docker Socket、宿主机数据库和非白名单网络;
|
||||||
|
- 空闲超时后容器被回收,项目文件仍可恢复。
|
||||||
|
|
||||||
|
### 阶段 4:产品能力收口
|
||||||
|
|
||||||
|
**工作项**
|
||||||
|
|
||||||
|
1. 在文件面板支持安全的文件查看、编辑、保存、目录树刷新与下载;限制文件大小和可编辑扩展名。
|
||||||
|
2. 在对话中增加模板选择和能力提示:大屏、流程图、海报、可选 FastAPI。
|
||||||
|
3. 为 Agent 增加依赖白名单、图表主题、中文字体、Mock 数据、接口契约等明确提示词。
|
||||||
|
4. 增加“运行/停止/重启/删除沙箱”和日志面板;仅项目所有者或管理员有相应权限。
|
||||||
|
5. 增加项目快照、模板版本记录和基础审计日志。
|
||||||
|
|
||||||
|
**验收**
|
||||||
|
|
||||||
|
- 从一句自然语言需求到可预览项目的完整链路可完成;
|
||||||
|
- 用户可查看并手动修改文件,刷新后改动保留;
|
||||||
|
- 页面覆盖 ECharts、流程图、响应式大屏和可选 FastAPI 接口;
|
||||||
|
- 非拥有者不能读取、修改或预览另一用户的项目。
|
||||||
|
|
||||||
|
### 阶段 5:上线前安全与运行验收
|
||||||
|
|
||||||
|
**工作项**
|
||||||
|
|
||||||
|
1. 压测 6 个并发沙箱,并记录 CPU、内存、磁盘、容器启动时间、首屏预览时间和模型等待时间。
|
||||||
|
2. 验证镜像保留策略、工作区配额、容器回收、数据库备份和 Harbor 备份恢复。
|
||||||
|
3. 执行越权、路径穿越、命令注入、网络探测、资源耗尽和模型密钥泄露测试。
|
||||||
|
4. 建立日常运维操作:新增基础依赖、构建镜像、发布模板、回滚镜像、清理工作区和审计查询。
|
||||||
|
|
||||||
|
**验收**
|
||||||
|
|
||||||
|
- 并发运行时既有 DeerFlow 等服务不受明显影响;
|
||||||
|
- 任一沙箱异常退出不会影响其他项目或平台控制服务;
|
||||||
|
- 备份可恢复项目元数据、工作区和 Harbor 镜像;
|
||||||
|
- 仅开放计划中的入口端口,内部组件无不必要公网监听。
|
||||||
|
|
||||||
|
## 9. 首期资源与并发建议
|
||||||
|
|
||||||
|
由于该主机已有多项业务,首期不应按理论最大值分配:
|
||||||
|
|
||||||
|
| 组件 | 首期限制 |
|
||||||
|
|---|---|
|
||||||
|
| Harbor | 8 vCPU、16 GB 内存、500 GB 数据盘配额 |
|
||||||
|
| OpenVibe 控制服务 | 4 vCPU、8-16 GB 内存 |
|
||||||
|
| 单个 UI 沙箱 | 1 vCPU、3 GB 内存、10 GB 工作区 |
|
||||||
|
| 单个 Fullstack 沙箱 | 1.5 vCPU、4 GB 内存、12 GB 工作区 |
|
||||||
|
| 沙箱并发 | 先限制为 6;稳定后再逐步上调 |
|
||||||
|
|
||||||
|
当并发超过 10 个,或要面向更广泛用户开放时,应优先增加独立沙箱节点,而不是继续在本共享主机上加容器。
|
||||||
|
|
||||||
|
## 10. 实施顺序与交付物
|
||||||
|
|
||||||
|
建议按以下提交/交付顺序推进,每一步都可独立验收和回退:
|
||||||
|
|
||||||
|
1. `docs: confirm appbuilder network, ports and security baseline`
|
||||||
|
2. `infra: add Harbor and versioned visual sandbox images`
|
||||||
|
3. `infra: add offline templates and dependency locks`
|
||||||
|
4. `feat: replace OpenVibe CloudBase persistence and model provider`
|
||||||
|
5. `feat: add local Docker sandbox manager`
|
||||||
|
6. `feat: add sandbox runner and path-based preview router`
|
||||||
|
7. `feat: add visual-screen and FastAPI project workflows`
|
||||||
|
8. `test: add sandbox isolation, preview and recovery coverage`
|
||||||
|
9. `ops: add backup, cleanup, monitoring and rollout runbook`
|
||||||
|
|
||||||
|
达到阶段 3 即可交付“内网模型驱动、生成并预览大屏/海报页面”的 PoC;达到阶段 5 后再作为受控的内部多人平台发布。
|
||||||
@@ -8,7 +8,7 @@ VITE_USE_REAL_DATA=true
|
|||||||
VITE_MOCK_COP_TASK_DETAIL=true
|
VITE_MOCK_COP_TASK_DETAIL=true
|
||||||
|
|
||||||
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。
|
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。
|
||||||
VITE_MOCK_SQ_REPORT=true
|
VITE_MOCK_SQ_REPORT=false
|
||||||
|
|
||||||
# 深链任务详情(cop-task-detail:任务名称/描述)数据源:true 走本地假数据(真实接口未就绪时调试用);联调通过后置 false。
|
# 深链任务详情(cop-task-detail:任务名称/描述)数据源:true 走本地假数据(真实接口未就绪时调试用);联调通过后置 false。
|
||||||
VITE_MOCK_COP_TASK_DETAIL=true
|
VITE_MOCK_COP_TASK_DETAIL=true
|
||||||
@@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=true
|
|||||||
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
|
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
|
||||||
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
||||||
|
|
||||||
|
# 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。
|
||||||
|
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=true
|
||||||
|
|
||||||
# 轻应用「新建笔记」系列接口
|
# 轻应用「新建笔记」系列接口
|
||||||
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||||
# CMZS后端接口
|
# CMZS后端接口
|
||||||
|
|||||||
@@ -56,4 +56,13 @@
|
|||||||
# 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=
|
||||||
|
|
||||||
|
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
|
||||||
|
# true 时,普通登录后进入同一 Vite 应用中隔离的 /official/*。
|
||||||
|
# 已指定 redirect 或 taskId/goPath 的登录仍保持原来的优先级。
|
||||||
|
# 仅 .env.development 默认 true;其余环境保持 false。
|
||||||
|
# VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
|
||||||
@@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=false
|
|||||||
VITE_BACKEND_BASE_URL=http://10.19.200.5:2026
|
VITE_BACKEND_BASE_URL=http://10.19.200.5:2026
|
||||||
VITE_LANGGRAPH_BASE_URL=http://10.19.200.5:2026/api
|
VITE_LANGGRAPH_BASE_URL=http://10.19.200.5:2026/api
|
||||||
|
|
||||||
|
# 内网环境保持当前定制版默认入口。
|
||||||
|
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
|
||||||
|
|
||||||
# 轻应用「新建笔记」系列接口
|
# 轻应用「新建笔记」系列接口
|
||||||
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||||
# CMZS后端接口
|
# CMZS后端接口
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ VITE_MOCK_SQ_REPORT=false
|
|||||||
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
|
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
|
||||||
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
|
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
|
||||||
|
|
||||||
|
# 线上保持当前定制版默认入口。
|
||||||
|
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
|
||||||
|
|
||||||
# 轻应用「新建笔记」系列接口
|
# 轻应用「新建笔记」系列接口
|
||||||
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
|
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
|
||||||
# CMZS后端接口
|
# CMZS后端接口
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=false
|
|||||||
VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow
|
VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow
|
||||||
VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api
|
VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api
|
||||||
|
|
||||||
|
# SIT 环境保持当前定制版默认入口。
|
||||||
|
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
|
||||||
|
|
||||||
# 轻应用「新建笔记」系列接口
|
# 轻应用「新建笔记」系列接口
|
||||||
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
|
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
|
||||||
# CMZS后端接口
|
# CMZS后端接口
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,7 @@
|
|||||||
|
# 技能知识归纳与“知识梳理”助手知识库
|
||||||
|
|
||||||
|
管理员在「技能管理 → 技能知识归纳」中选择一个或多个技能、一个或多个目标,系统按“技能 × 目标”创建后台任务。界面提供总体进度、单项失败重试、实体/关系审核、绑定重归纳、自动同步和两种解绑动作。技能列表通过一次 bindings 查询展示归纳目标,避免逐技能请求。
|
||||||
|
|
||||||
|
扫描仅处理知识资料。代码、脚本、锁文件、密钥文件以及 Markdown 代码块不会进入上传、摘要、提示词或知识指纹;ZIP 只读取逐项验证通过的知识成员。自动监听只比较该知识指纹,所以纯代码改动不会产生 stale 状态。
|
||||||
|
|
||||||
|
知识库页面新增「普通知识库 / 助手知识库」并行入口。管理员可将系统默认入口设为 v1(普通 WeKnora)或 v2(全局“知识梳理”)。助手库无需 WeKnora 即可初始化,所有用户可只读查看和搜索 Wiki;管理员可上传本地文件、从普通库导入、重新导入或删除来源、查看版本并回滚。来源删除只移除对应贡献,共享实体与关系继续保留。
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,7 @@
|
|||||||
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
|
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
|
||||||
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
|
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
|
||||||
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
|
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
|
||||||
@@ -138,8 +156,8 @@ window.__CM_RUNTIME_CONFIG__ = {
|
|||||||
VITE_SENTIMENT_AGENT_VISIBLE: "true",
|
VITE_SENTIMENT_AGENT_VISIBLE: "true",
|
||||||
|
|
||||||
// 舆情分析智能体:外部流式问答接口地址(POST,SSE 流式返回)。
|
// 舆情分析智能体:外部流式问答接口地址(POST,SSE 流式返回)。
|
||||||
// 留空时该智能体对话页会提示未配置。
|
// 前端会把该地址随请求传给后台转发。留空时该智能体对话页会提示未配置。
|
||||||
VITE_SENTIMENT_AGENT_API_URL: "https://gw.y.sit.4.cn/alg/stream/report-agent",
|
VITE_SENTIMENT_AGENT_API_URL: "http://alg.gw.y.uat.cn/a6_report_agent/alg/stream/report-agent",
|
||||||
|
|
||||||
// 舆情分析智能体:请求头 Authorization Basic Auth 用户名。
|
// 舆情分析智能体:请求头 Authorization Basic Auth 用户名。
|
||||||
VITE_SENTIMENT_AGENT_AUTH_USERNAME: "1054841959975223296",
|
VITE_SENTIMENT_AGENT_AUTH_USERNAME: "1054841959975223296",
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ const requiredLegacyFallbacks = [
|
|||||||
pattern: /<InputGroup className="h-auto min-h-16">/,
|
pattern: /<InputGroup className="h-auto min-h-16">/,
|
||||||
description: "Prompt textarea must not rely on :has() for its parent height",
|
description: "Prompt textarea must not rely on :has() for its parent height",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
file: "src/core/utils/mermaid.ts",
|
||||||
|
pattern: /htmlLabels:\s*false[\s\S]*secure:\s*\[[\s\S]*"htmlLabels"/,
|
||||||
|
description: "Sandbox flowcharts must use native SVG labels in legacy browsers",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "src/core/utils/legacy-html-preview.ts",
|
||||||
|
pattern: /export function downlevelModernColors[\s\S]*function injectLegacyChartShim/,
|
||||||
|
description: "Generated HTML charts must downlevel modern CSS colors for Chrome 109",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const file of cssFiles) {
|
for (const file of cssFiles) {
|
||||||
|
|||||||
+24
-11
@@ -38,14 +38,21 @@ import EmbedChatPage from "./pages/EmbedChatPage";
|
|||||||
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
||||||
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
|
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
|
||||||
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
||||||
|
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
|
||||||
import {
|
import {
|
||||||
isPositionRoundtableStandaloneMode,
|
isPositionRoundtableStandaloneMode,
|
||||||
POSITION_ROUNDTABLE_ROUTE,
|
POSITION_ROUNDTABLE_ROUTE,
|
||||||
} from "@/position-roundtable/demo/position-roundtable-demo-config";
|
} from "@/position-roundtable/demo/position-roundtable-demo-config";
|
||||||
import { ChatRuntime } from "./pages/WorkspaceRoutes";
|
import { ChatRuntime } from "./pages/WorkspaceRoutes";
|
||||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||||
|
import OfficialDeerFlowEntry from "./pages/OfficialDeerFlowEntry";
|
||||||
|
|
||||||
const PUBLIC_EMBED_PREFIXES = ["/share/", "/public/", "/roundtable-share/"];
|
const PUBLIC_EMBED_PREFIXES = [
|
||||||
|
"/share/",
|
||||||
|
"/public/",
|
||||||
|
"/roundtable-share/",
|
||||||
|
"/embed/knowledge/",
|
||||||
|
];
|
||||||
|
|
||||||
function AppProviders({ children }: { children: React.ReactNode }) {
|
function AppProviders({ children }: { children: React.ReactNode }) {
|
||||||
const locale = useMemo(() => detectLocale(), []);
|
const locale = useMemo(() => detectLocale(), []);
|
||||||
@@ -102,16 +109,17 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
|||||||
forcedTheme={forcedTheme}
|
forcedTheme={forcedTheme}
|
||||||
>
|
>
|
||||||
<QueryClientProvider>
|
<QueryClientProvider>
|
||||||
<I18nProvider initialLocale={locale}>
|
<AuthSyncWatcher disabled={positionStandaloneMode}>
|
||||||
<ThemeProvider>
|
<I18nProvider initialLocale={locale}>
|
||||||
<ChatProvider>
|
<ThemeProvider>
|
||||||
{!positionStandaloneMode && <AuthSyncWatcher />}
|
<ChatProvider>
|
||||||
{!positionStandaloneMode && <DesensitizeWordsLoader />}
|
{!positionStandaloneMode && <DesensitizeWordsLoader />}
|
||||||
<FontScaleApplier />
|
<FontScaleApplier />
|
||||||
{children}
|
{children}
|
||||||
</ChatProvider>
|
</ChatProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
</AuthSyncWatcher>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</NextThemesProvider>
|
</NextThemesProvider>
|
||||||
);
|
);
|
||||||
@@ -188,10 +196,15 @@ export function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
|
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
|
||||||
|
<Route
|
||||||
|
path="/embed/knowledge/:noteId"
|
||||||
|
element={<WeKnoraWikiPageView publicEmbed />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/roundtable-dashboard"
|
path="/roundtable-dashboard"
|
||||||
element={<DashboardStandalonePage />}
|
element={<DashboardStandalonePage />}
|
||||||
/>
|
/>
|
||||||
|
<Route path="/official/*" element={<OfficialDeerFlowEntry />} />
|
||||||
<Route path="/page/*" element={pageElement} />
|
<Route path="/page/*" element={pageElement} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2064"
|
||||||
|
height="2591" viewBox="0 0 2064 2591">
|
||||||
|
<path fill-rule="nonzero"
|
||||||
|
d="M 68.582031 0 C 66.890625 0 65.300781 1 64.617188 2.640625 C 49.46875 38.800781 33.71875 76.199219 33.628906 106.539062 C 33.550781 134.230469 46.445312 155.730469 59.949219 175.71875 L 59.949219 175.730469 C 52.414062 175.671875 44.992188 175.371094 37.472656 174.171875 C 27.210938 172.53125 16.585938 169.199219 5.585938 165.738281 C 5.15625 165.609375 4.722656 165.539062 4.296875 165.539062 C 2.824219 165.539062 1.417969 166.300781 0.625 167.609375 C -0.402344 169.300781 -0.140625 171.46875 1.257812 172.871094 L 1.261719 172.878906 C 11.230469 182.871094 21.8125 193.53125 36.980469 200.050781 C 51.5625 206.320312 70.167969 208.628906 88.085938 210.78125 L 88.027344 210.78125 C 127.152344 253.410156 166.617188 296.300781 215.761719 334.941406 C 230.558594 346.578125 246.292969 357.878906 261.785156 369 C 277.171875 380.050781 292.441406 391.011719 306.660156 402.191406 C 326.132812 417.511719 343.5625 433.179688 356.773438 450.019531 C 377.808594 476.828125 388.449219 506.960938 395.4375 538.628906 L 395.4375 538.640625 C 372.683594 510.710938 349.328125 483.929688 324.855469 469.339844 C 295.957031 452.121094 265.800781 452 236.960938 451.808594 L 236.933594 451.820312 C 235.792969 451.808594 234.707031 452.261719 233.898438 453.070312 C 233.085938 453.871094 232.632812 454.96875 232.636719 456.121094 C 232.722656 494.011719 232.699219 533.539062 256.304688 567.480469 C 279.351562 600.621094 324.738281 628.019531 369.191406 654.679688 L 369.132812 654.691406 C 371.476562 683.539062 373.40625 710.691406 367.109375 728.03125 C 365.828125 731.558594 364.191406 734.71875 362.058594 738.820312 C 358.910156 744.871094 354.964844 752.441406 350.417969 764.960938 C 342.890625 785.699219 333.769531 819.839844 323.648438 883.109375 C 303.347656 1010.019531 278.722656 1255.949219 308.882812 1409.269531 C 326.265625 1497.660156 361.828125 1555.210938 393.511719 1606.519531 C 417.042969 1644.632812 438.207031 1678.960938 448.835938 1720.09375 C 466.921875 1790.089844 454.648438 1880.246094 444.5 1954.386719 C 440.613281 1982.78125 437.042969 2008.839844 435.726562 2030.261719 C 433.410156 2067.929688 438.066406 2091.179688 442.304688 2112.347656 L 442.304688 2112.355469 C 445.910156 2130.347656 449.152344 2146.589844 447.757812 2169.28125 C 447.429688 2174.613281 446.835938 2180.359375 445.933594 2186.535156 L 445.933594 2186.542969 C 436.582031 2250.328125 393.613281 2362.808594 362.105469 2416.636719 C 339.761719 2454.804688 323.546875 2462.851562 309.597656 2469.925781 C 303.980469 2472.777344 298.261719 2475.660156 292.691406 2480.238281 C 273.378906 2496.113281 256.457031 2532.078125 240.183594 2566.417969 C 239.578125 2567.703125 239.640625 2569.203125 240.363281 2570.429688 C 241.082031 2571.65625 242.359375 2572.449219 243.777344 2572.542969 L 243.789062 2572.542969 L 243.882812 2572.550781 L 243.882812 2572.554688 C 261.582031 2573.746094 279.460938 2574.957031 296.386719 2574.957031 C 323.671875 2574.957031 349.332031 2571.84375 368.355469 2559.785156 C 392.640625 2544.394531 405.109375 2514.960938 414.488281 2493.074219 C 417.011719 2487.183594 419.289062 2481.871094 421.546875 2477.308594 C 432.054688 2456.039062 441.753906 2451.664062 451.855469 2446.960938 C 453.054688 2446.402344 453.929688 2445.320312 454.226562 2444.042969 L 454.238281 2443.996094 L 454.238281 2443.988281 C 476.210938 2348.800781 498.035156 2254.347656 524.300781 2196.363281 L 524.300781 2196.367188 C 536.363281 2169.730469 549.363281 2150.796875 562.695312 2131.402344 C 576.28125 2111.625 590.1875 2091.402344 603.300781 2062.445312 L 603.304688 2062.453125 C 605.183594 2058.292969 607.054688 2053.949219 608.894531 2049.421875 C 625.066406 2009.675781 639.746094 1954.289062 653.585938 1902.101562 C 663.0625 1866.367188 672.15625 1832.09375 681.085938 1805.085938 C 699.308594 1749.992188 716.863281 1725.09375 734.371094 1705.144531 L 734.421875 1705.148438 C 727.285156 1740.746094 721.597656 1776.628906 729.320312 1807.472656 C 733.351562 1823.570312 740.996094 1838.160156 749.066406 1853.613281 C 758.828125 1872.296875 769.433594 1892.636719 776.359375 1919.675781 C 783.289062 1946.71875 786.574219 1980.605469 789.527344 2011.140625 L 789.527344 2011.148438 C 791.875 2035.402344 794.023438 2057.78125 797.746094 2072.316406 L 797.746094 2072.320312 C 797.929688 2073.023438 798.128906 2073.769531 798.316406 2074.441406 L 798.320312 2074.441406 C 802.132812 2087.941406 807.617188 2093.914062 813.515625 2100.324219 C 821.132812 2108.597656 830.351562 2118.441406 839.742188 2151.671875 L 839.738281 2151.675781 C 840.105469 2152.96875 840.402344 2154.039062 840.765625 2155.398438 C 857.484375 2217.601562 874.097656 2354.589844 872.078125 2423.390625 C 870.472656 2478.15625 857.140625 2488.390625 845.339844 2498.136719 C 842.320312 2500.632812 839.085938 2503.296875 836.09375 2506.566406 C 820.847656 2523.230469 812.652344 2554.886719 804.800781 2584.753906 C 804.457031 2586.0625 804.75 2587.453125 805.589844 2588.515625 C 806.40625 Line truncated
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 19 KiB |
@@ -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 |
@@ -60,7 +60,6 @@ import {
|
|||||||
createContext,
|
createContext,
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
type FormEventHandler,
|
type FormEventHandler,
|
||||||
Fragment,
|
|
||||||
type HTMLAttributes,
|
type HTMLAttributes,
|
||||||
type KeyboardEventHandler,
|
type KeyboardEventHandler,
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
@@ -124,8 +123,10 @@ export const usePromptInputController = () => {
|
|||||||
return ctx;
|
return ctx;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Optional variants (do NOT throw). Useful for dual-mode components.
|
// Optional variants (do NOT throw). Useful for dual-mode components and for
|
||||||
const useOptionalPromptInputController = () =>
|
// Fast Refresh transitions where a page can briefly retain the previous
|
||||||
|
// provider context instance.
|
||||||
|
export const useOptionalPromptInputController = () =>
|
||||||
useContext(PromptInputController);
|
useContext(PromptInputController);
|
||||||
|
|
||||||
export const useProviderAttachments = () => {
|
export const useProviderAttachments = () => {
|
||||||
@@ -406,9 +407,9 @@ export function PromptInputAttachments({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{attachments.files.map((file) => (
|
{attachments.files.map((file) => (
|
||||||
<Fragment key={file.id}>
|
<div key={file.id} className="max-w-60">
|
||||||
<div className="max-w-60">{children(file)}</div>
|
{children(file)}
|
||||||
</Fragment>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { injectInPageAnchorHandler } from "@/core/utils/markdown";
|
import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sandboxed preview of a generated HTML document.
|
* Sandboxed preview of a generated HTML document.
|
||||||
@@ -26,7 +26,7 @@ export function HtmlPreviewFrame({
|
|||||||
const [url, setUrl] = useState<string>();
|
const [url, setUrl] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const blob = new Blob([injectInPageAnchorHandler(html ?? "")], {
|
const blob = new Blob([prepareGeneratedHtml(html ?? "")], {
|
||||||
type: "text/html;charset=utf-8",
|
type: "text/html;charset=utf-8",
|
||||||
});
|
});
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { ChevronRightIcon } from "lucide-react";
|
import { ChevronRightIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import deerMaskUrl from "@/assets/deer.svg";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { FlickeringGrid } from "@/components/ui/flickering-grid";
|
import { FlickeringGrid } from "@/components/ui/flickering-grid";
|
||||||
import Galaxy from "@/components/ui/galaxy";
|
import Galaxy from "@/components/ui/galaxy";
|
||||||
@@ -29,7 +30,11 @@ export function Hero({ className }: { className?: string }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<FlickeringGrid
|
<FlickeringGrid
|
||||||
className="absolute inset-0 z-0 translate-y-8 mask-[url(/images/deer.svg)] mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"
|
className="absolute inset-0 z-0 translate-y-8 mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"
|
||||||
|
style={{
|
||||||
|
WebkitMaskImage: `url("${deerMaskUrl}")`,
|
||||||
|
maskImage: `url("${deerMaskUrl}")`,
|
||||||
|
}}
|
||||||
squareSize={4}
|
squareSize={4}
|
||||||
gridGap={4}
|
gridGap={4}
|
||||||
color={"white"}
|
color={"white"}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
Gauge,
|
Gauge,
|
||||||
ListTree,
|
ListTree,
|
||||||
|
MessageSquareText,
|
||||||
MousePointerClick,
|
MousePointerClick,
|
||||||
BriefcaseBusiness,
|
BriefcaseBusiness,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -55,7 +56,7 @@ type PageSidebarProps = {
|
|||||||
headerHidden?: boolean;
|
headerHidden?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
|
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "encoder" | "about";
|
||||||
|
|
||||||
const hasActiveChild = (item: MenuItem, pathname: string): boolean =>
|
const hasActiveChild = (item: MenuItem, pathname: string): boolean =>
|
||||||
menuItemContainsPath(item, pathname);
|
menuItemContainsPath(item, pathname);
|
||||||
@@ -237,6 +238,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
|||||||
按钮管理
|
按钮管理
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigate("/page/strategy/admin/fixed-questions")}
|
||||||
|
>
|
||||||
|
<MessageSquareText className="size-4" />
|
||||||
|
问题配置
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/strategy/admin/position-roles")}
|
onClick={() => navigate("/page/strategy/admin/position-roles")}
|
||||||
@@ -261,6 +270,12 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
|||||||
模型配置
|
模型配置
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
||||||
|
<Cpu className="size-4" />
|
||||||
|
本地编码器
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/strategy/settings/model-concurrency")}
|
onClick={() => navigate("/page/strategy/settings/model-concurrency")}
|
||||||
@@ -558,6 +573,12 @@ function LegacyAccordionSidebar({
|
|||||||
按钮管理
|
按钮管理
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/fixed-questions")}>
|
||||||
|
<MessageSquareText className="size-4" />
|
||||||
|
问题配置
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
|
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
|
||||||
<BriefcaseBusiness className="size-4" />
|
<BriefcaseBusiness className="size-4" />
|
||||||
@@ -570,6 +591,12 @@ function LegacyAccordionSidebar({
|
|||||||
模型配置
|
模型配置
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
||||||
|
<Cpu className="size-4" />
|
||||||
|
本地编码器
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
|
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
|
||||||
<ShieldCheck className="size-4" />
|
<ShieldCheck className="size-4" />
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
|
||||||
|
import { languages } from "@codemirror/language-data";
|
||||||
|
import { basicLightInit } from "@uiw/codemirror-theme-basic";
|
||||||
|
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
|
||||||
|
import CodeMirror from "@uiw/react-codemirror";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||||
|
import { reasoningPlugins } from "@/core/streamdown";
|
||||||
|
|
||||||
|
const lightTheme = basicLightInit({ settings: { background: "transparent", fontSize: "14px" } });
|
||||||
|
const darkTheme = monokaiInit({
|
||||||
|
settings: { background: "transparent", gutterBackground: "transparent", fontSize: "14px" },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Markdown-aware source editor shared by Wiki create/edit dialogs. */
|
||||||
|
export function MarkdownCodeEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const extensions = useMemo(
|
||||||
|
() => [markdown({ base: markdownLanguage, codeLanguages: languages })],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("grid min-h-[46vh] overflow-hidden rounded-lg border bg-background lg:grid-cols-2", className)}>
|
||||||
|
<section className="flex min-h-0 flex-col border-b lg:border-r lg:border-b-0">
|
||||||
|
<div className="flex h-9 shrink-0 items-center border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground">
|
||||||
|
Markdown 编辑
|
||||||
|
</div>
|
||||||
|
<CodeMirror
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
theme={resolvedTheme === "dark" ? darkTheme : lightTheme}
|
||||||
|
extensions={extensions}
|
||||||
|
basicSetup={{
|
||||||
|
foldGutter: true,
|
||||||
|
highlightActiveLine: true,
|
||||||
|
highlightActiveLineGutter: false,
|
||||||
|
lineNumbers: true,
|
||||||
|
}}
|
||||||
|
className="min-h-[42vh] flex-1 overflow-auto font-mono text-sm [&_.cm-editor]:min-h-[42vh] [&_.cm-focused]:outline-none!"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section className="flex min-h-0 flex-col bg-muted/10">
|
||||||
|
<div className="flex h-9 shrink-0 items-center border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground">
|
||||||
|
实时渲染
|
||||||
|
</div>
|
||||||
|
<div className="min-h-[42vh] flex-1 overflow-auto p-4">
|
||||||
|
<MarkdownContent
|
||||||
|
content={value || "*暂无正文*"}
|
||||||
|
isLoading={false}
|
||||||
|
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||||
|
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -77,7 +77,7 @@ export function WikiHeader({
|
|||||||
<Menu className="size-5" />
|
<Menu className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
<div className="text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100">
|
<div className="text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100">
|
||||||
问答梳理
|
知识库管理
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export function noteFolderSegments(note: KnowledgeNote): string[] {
|
|||||||
|
|
||||||
/** Breadcrumb segments for a note (folders only; the title is appended by the caller). */
|
/** Breadcrumb segments for a note (folders only; the title is appended by the caller). */
|
||||||
export function noteBreadcrumbSegments(note: KnowledgeNote): string[] {
|
export function noteBreadcrumbSegments(note: KnowledgeNote): string[] {
|
||||||
return ["问答梳理", ...noteFolderSegments(note).map(localizeFolderName)];
|
return ["知识库管理", ...noteFolderSegments(note).map(localizeFolderName)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortTree(nodes: TreeNode[]): void {
|
function sortTree(nodes: TreeNode[]): void {
|
||||||
|
|||||||
@@ -835,7 +835,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
);
|
);
|
||||||
|
|
||||||
const cardLayout = (
|
const cardLayout = (
|
||||||
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
|
<Card
|
||||||
|
className="group flex min-w-0 flex-col transition-shadow hover:shadow-md"
|
||||||
|
data-agent-guide="agent-card"
|
||||||
|
>
|
||||||
<CardHeader className="min-w-0 pb-2">
|
<CardHeader className="min-w-0 pb-2">
|
||||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||||
@@ -893,7 +896,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
);
|
);
|
||||||
|
|
||||||
const listLayout = (
|
const listLayout = (
|
||||||
<div className="group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
<div
|
||||||
|
className="group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]"
|
||||||
|
data-agent-guide="agent-card"
|
||||||
|
>
|
||||||
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||||
<BotIcon className="h-5 w-5" />
|
<BotIcon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -143,6 +144,8 @@ export function AgentDetailPanel({
|
|||||||
onAskQuestion,
|
onAskQuestion,
|
||||||
disabled,
|
disabled,
|
||||||
defaultCollapsed,
|
defaultCollapsed,
|
||||||
|
collapsed: collapsedProp,
|
||||||
|
onCollapsedChange,
|
||||||
showRecentChats,
|
showRecentChats,
|
||||||
compactAppearanceActions,
|
compactAppearanceActions,
|
||||||
avatarSrc,
|
avatarSrc,
|
||||||
@@ -150,6 +153,7 @@ export function AgentDetailPanel({
|
|||||||
skillsIcon,
|
skillsIcon,
|
||||||
skillsTitle,
|
skillsTitle,
|
||||||
showCommonQuestions = false,
|
showCommonQuestions = false,
|
||||||
|
jobResponsibilities,
|
||||||
panelTitle,
|
panelTitle,
|
||||||
embedded,
|
embedded,
|
||||||
hideStatusBadges = false,
|
hideStatusBadges = false,
|
||||||
@@ -161,6 +165,14 @@ export function AgentDetailPanel({
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** 初始收起(任务工作区进入时默认收起智能体信息面板)。 */
|
/** 初始收起(任务工作区进入时默认收起智能体信息面板)。 */
|
||||||
defaultCollapsed?: boolean;
|
defaultCollapsed?: boolean;
|
||||||
|
/**
|
||||||
|
* 受控折叠态:传入时由父级持有状态(智能体问答页把展开/收起按钮放在顶部
|
||||||
|
* 「新对话」右侧)。此时收起 = 整列宽度动画归零(不保留细条),面板内置的
|
||||||
|
* 收起按钮不再渲染。不传则沿用面板内置状态 + 细条收起交互(会商等工作区)。
|
||||||
|
*/
|
||||||
|
collapsed?: boolean;
|
||||||
|
/** 受控折叠态变化回调(配合 `collapsed` 使用)。 */
|
||||||
|
onCollapsedChange?: (collapsed: boolean) => void;
|
||||||
/** 在面板底部展示「有关此智能体的最近对话」(仅智能体问答页开启,任务工作区不展示)。 */
|
/** 在面板底部展示「有关此智能体的最近对话」(仅智能体问答页开启,任务工作区不展示)。 */
|
||||||
showRecentChats?: boolean;
|
showRecentChats?: boolean;
|
||||||
/** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */
|
/** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */
|
||||||
@@ -170,6 +182,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. */
|
||||||
@@ -180,7 +197,15 @@ export function AgentDetailPanel({
|
|||||||
editIcon?: ReactNode;
|
editIcon?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
|
// 折叠态:受控(collapsedProp 传入,智能体问答页)优先;否则面板内置(会商等工作区)。
|
||||||
|
const [internalCollapsed, setInternalCollapsed] = useState(
|
||||||
|
defaultCollapsed ?? false,
|
||||||
|
);
|
||||||
|
const collapsed = collapsedProp ?? internalCollapsed;
|
||||||
|
const setCollapsed = (next: boolean) => {
|
||||||
|
if (collapsedProp === undefined) setInternalCollapsed(next);
|
||||||
|
onCollapsedChange?.(next);
|
||||||
|
};
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState<AgentDraft | null>(null);
|
const [draft, setDraft] = useState<AgentDraft | null>(null);
|
||||||
@@ -230,9 +255,10 @@ export function AgentDetailPanel({
|
|||||||
|
|
||||||
const isSaving = updateAgent.isPending || setFeatured.isPending;
|
const isSaving = updateAgent.isPending || setFeatured.isPending;
|
||||||
|
|
||||||
// 对话生成文件(md/txt 等 artifact)并在右侧展开预览时,自动收起智能体
|
// 对话生成文件(md/txt 等 artifact)并首次展开预览时,自动收起智能体
|
||||||
// 信息面板给预览让位;切换到新文件时同样收起。编辑中不打断用户,用户随时
|
// 信息面板给预览让位。目录内切换文件不能再次改写用户的手动选择:否则用户
|
||||||
// 可手动重新展开。
|
// 刚展开智能体信息,点另一个文件时面板又会突然收起,看起来就像收起按钮被
|
||||||
|
// 移除了。编辑中也不打断用户。
|
||||||
//
|
//
|
||||||
// 关键:判定用 open + selectedArtifact,而非 artifacts.length。流式写文件时
|
// 关键:判定用 open + selectedArtifact,而非 artifacts.length。流式写文件时
|
||||||
// message-group 一开始就调用 select()+setOpen(true) 打开预览并选中该文件,但
|
// message-group 一开始就调用 select()+setOpen(true) 打开预览并选中该文件,但
|
||||||
@@ -241,20 +267,19 @@ export function AgentDetailPanel({
|
|||||||
const artifactsCtx = useArtifactsOptional();
|
const artifactsCtx = useArtifactsOptional();
|
||||||
const artifactCount = artifactsCtx?.artifacts.length ?? 0;
|
const artifactCount = artifactsCtx?.artifacts.length ?? 0;
|
||||||
const selectedArtifact = artifactsCtx?.selectedArtifact ?? null;
|
const selectedArtifact = artifactsCtx?.selectedArtifact ?? null;
|
||||||
|
// preOpen:流式自动开沙箱的两阶段标记 —— 沙箱真正展开(open 翻转)前就
|
||||||
|
// 先收起本面板,让收起布局先落地,避免与沙箱展开同帧叠加造成卡顿。
|
||||||
const artifactPanelVisible =
|
const artifactPanelVisible =
|
||||||
!!artifactsCtx?.open && (!!selectedArtifact || artifactCount > 0);
|
(!!artifactsCtx?.open || !!artifactsCtx?.preOpen) &&
|
||||||
|
(!!selectedArtifact || artifactCount > 0);
|
||||||
const prevArtifactPanelVisible = useRef(false);
|
const prevArtifactPanelVisible = useRef(false);
|
||||||
const prevSelectedArtifact = useRef<string | null>(null);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const opened = artifactPanelVisible && !prevArtifactPanelVisible.current;
|
const opened = artifactPanelVisible && !prevArtifactPanelVisible.current;
|
||||||
const switched =
|
|
||||||
artifactPanelVisible && selectedArtifact !== prevSelectedArtifact.current;
|
|
||||||
prevArtifactPanelVisible.current = artifactPanelVisible;
|
prevArtifactPanelVisible.current = artifactPanelVisible;
|
||||||
prevSelectedArtifact.current = selectedArtifact;
|
if (opened && !editing) {
|
||||||
if ((opened || switched) && !editing) {
|
|
||||||
setCollapsed(true);
|
setCollapsed(true);
|
||||||
}
|
}
|
||||||
}, [artifactPanelVisible, selectedArtifact, editing]);
|
}, [artifactPanelVisible, editing]);
|
||||||
|
|
||||||
const displayName = desensitize(agent?.name ?? agentName);
|
const displayName = desensitize(agent?.name ?? agentName);
|
||||||
const questions = useMemo(
|
const questions = useMemo(
|
||||||
@@ -720,6 +745,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
|
||||||
@@ -905,14 +947,18 @@ export function AgentDetailPanel({
|
|||||||
|
|
||||||
const panelChrome = (collapseButton: ReactNode) => (
|
const panelChrome = (collapseButton: ReactNode) => (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||||
<span className="shrink-0 whitespace-nowrap text-sm font-semibold">
|
{/* 标题不参与压缩(此前被简洁模式外观控件挤成单字);空间不足时只
|
||||||
|
裁剪 compactAppearanceActions,编辑等主操作与标题始终完整。 */}
|
||||||
|
<span className="shrink-0 text-sm font-semibold whitespace-nowrap">
|
||||||
{panelTitle ?? "智能体信息"}
|
{panelTitle ?? "智能体信息"}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
<div className="flex min-w-0 flex-1 items-center justify-end gap-1">
|
||||||
{headerActions}
|
<div className="flex shrink-0 items-center gap-1">{headerActions}</div>
|
||||||
{compactAppearanceActions}
|
<div className="flex min-w-0 items-center justify-end gap-1 overflow-hidden">
|
||||||
{collapseButton}
|
{compactAppearanceActions}
|
||||||
|
</div>
|
||||||
|
{collapseButton && <div className="shrink-0">{collapseButton}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
|
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
|
||||||
@@ -921,21 +967,48 @@ export function AgentDetailPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
|
{/* ── Desktop (lg+) ────────────────────────────────────────────────── */}
|
||||||
{embedded ? (
|
{embedded ? (
|
||||||
<aside className="bg-card/40 flex h-full w-full min-w-0 shrink-0 flex-col border-l">
|
<aside className="bg-card/40 flex h-full w-full min-w-0 shrink-0 flex-col border-l">
|
||||||
{panelChrome(null)}
|
{panelChrome(null)}
|
||||||
</aside>
|
</aside>
|
||||||
|
) : collapsedProp !== undefined ? (
|
||||||
|
/* 受控模式(智能体问答页):收起时整列宽度动画归零(含左边框),不再保留细条;
|
||||||
|
展开态的「收起」按钮在面板右上角,收起态的「展开」按钮在页面
|
||||||
|
header「新对话」按钮右侧。 */
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"hidden h-full shrink-0 overflow-hidden transition-[width] duration-300 ease-in-out lg:block",
|
||||||
|
collapsed ? "w-0" : "w-[360px]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<aside className="bg-card/40 flex h-full w-[360px] flex-col border-l">
|
||||||
|
{panelChrome(
|
||||||
|
<Tooltip content="收起">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => setCollapsed(true)}
|
||||||
|
aria-label="收起智能体信息"
|
||||||
|
aria-expanded={true}
|
||||||
|
>
|
||||||
|
<PanelRightCloseIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>,
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
) : collapsed ? (
|
) : collapsed ? (
|
||||||
<div className="hidden shrink-0 items-start border-l px-2 pt-3 lg:flex">
|
<div className="hidden shrink-0 items-start border-l px-2 pt-3 lg:flex">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{compactAppearanceActions}
|
{compactAppearanceActions}
|
||||||
<Tooltip content="智能体信息">
|
<Tooltip content="展开智能体信息">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => setCollapsed(false)}
|
onClick={() => setCollapsed(false)}
|
||||||
aria-label="展开智能体信息"
|
aria-label="展开智能体信息"
|
||||||
|
aria-expanded={false}
|
||||||
>
|
>
|
||||||
<PanelRightOpenIcon className="size-4" />
|
<PanelRightOpenIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -951,6 +1024,7 @@ export function AgentDetailPanel({
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => setCollapsed(true)}
|
onClick={() => setCollapsed(true)}
|
||||||
aria-label="收起智能体信息"
|
aria-label="收起智能体信息"
|
||||||
|
aria-expanded={true}
|
||||||
>
|
>
|
||||||
<PanelRightCloseIcon className="size-4" />
|
<PanelRightCloseIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowDownUpIcon, BotIcon, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
|
import { ArrowDownUpIcon, BotIcon, Compass, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -21,6 +21,11 @@ import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/s
|
|||||||
|
|
||||||
import { AgentCard } from "./agent-card";
|
import { AgentCard } from "./agent-card";
|
||||||
import { SentimentAgentCard } from "./sentiment-agent-card";
|
import { SentimentAgentCard } from "./sentiment-agent-card";
|
||||||
|
import {
|
||||||
|
AgentGuide,
|
||||||
|
isAgentGuideEnvironment,
|
||||||
|
startAgentGuide,
|
||||||
|
} from "./agent-guide";
|
||||||
|
|
||||||
const PAGE_SIZE = 12;
|
const PAGE_SIZE = 12;
|
||||||
|
|
||||||
@@ -346,15 +351,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 +377,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -391,10 +403,22 @@ export function AgentGallery() {
|
|||||||
{t.agents.description}
|
{t.agents.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleNewAgent}>
|
<div className="flex items-center gap-2">
|
||||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
<Button
|
||||||
{t.agents.newAgent}
|
variant="ghost"
|
||||||
</Button>
|
size="sm"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
title="重新播放智能体使用引导"
|
||||||
|
onClick={() => startAgentGuide("list")}
|
||||||
|
>
|
||||||
|
<Compass className="mr-1.5 h-4 w-4" />
|
||||||
|
使用引导
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleNewAgent} data-agent-guide="new-agent-btn">
|
||||||
|
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||||
|
{t.agents.newAgent}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
@@ -414,7 +438,10 @@ export function AgentGallery() {
|
|||||||
>
|
>
|
||||||
<div className="mx-6 mt-4 flex flex-col gap-3">
|
<div className="mx-6 mt-4 flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<TabsList className="w-auto max-w-full flex-wrap justify-start self-start">
|
<TabsList
|
||||||
|
className="w-auto max-w-full flex-wrap justify-start self-start"
|
||||||
|
data-agent-guide="tabs"
|
||||||
|
>
|
||||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||||
{defaultSquare && (
|
{defaultSquare && (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
@@ -468,7 +495,8 @@ export function AgentGallery() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showSearchBar && (
|
{showSearchBar && (
|
||||||
<TagSearchBar
|
<div data-agent-guide="search">
|
||||||
|
<TagSearchBar
|
||||||
// Remount per tab so the internal keyword input / 展开 state also
|
// Remount per tab so the internal keyword input / 展开 state also
|
||||||
// resets when the parent-owned filters are cleared on tab switch.
|
// resets when the parent-owned filters are cleared on tab switch.
|
||||||
key={activeTab}
|
key={activeTab}
|
||||||
@@ -499,6 +527,7 @@ export function AgentGallery() {
|
|||||||
</Select>
|
</Select>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{publisher && (
|
{publisher && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -586,6 +615,11 @@ export function AgentGallery() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
<AgentGuide
|
||||||
|
stage="list"
|
||||||
|
eligible={isAgentGuideEnvironment()}
|
||||||
|
autoStart={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Guide, type GuideStep } from "tdesign-react";
|
||||||
|
|
||||||
|
import { env } from "@/env";
|
||||||
|
import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
|
||||||
|
import { isWujieEnv } from "@/core/embed/wujie";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能体使用引导(三段式,基于 TDesign React 的 Guide 组件),完整覆盖
|
||||||
|
* 「浏览管理 → 创建与配置 → 发起问答」流程:
|
||||||
|
*
|
||||||
|
* - list :智能体列表页(/page/workspace/agents)——分区、搜索排序、卡片操作、
|
||||||
|
* 创建入口;完成 / 跳过就地结束,不跳转创建页(创建页有自己的一段
|
||||||
|
* 引导,点「创建智能体」进入时自动演示)。
|
||||||
|
* - create :创建页(/page/workspace/agents/new)——对话式创建、基本信息、模型、
|
||||||
|
* 知识空间、SOUL.md、技能、保存。
|
||||||
|
* - chat :智能体对话页(/page/workspace/agents/:id/chats/...)——侧栏切换与
|
||||||
|
* 历史、常用问题、输入框、右侧详情面板。
|
||||||
|
*
|
||||||
|
* 记忆语义与简洁模式引导(compact-mode-guide.tsx)一致:每个阶段各自记一个
|
||||||
|
* localStorage 标记,**只有用户显式点「完成」或「跳过」才写入**;中途刷新/离开
|
||||||
|
* 不记忆,下次进入该页面还会自动演示。各页头部的「使用引导」按钮可随时强制重放。
|
||||||
|
*
|
||||||
|
* 自动演示范围:只有 create 段(进入新建页)与 chat 段自动弹;list 段挂在
|
||||||
|
* 智能体列表页但不自动演示(`autoStart={false}`,避免一进列表页就弹引导),
|
||||||
|
* 仅由头部「使用引导」按钮手动触发。
|
||||||
|
*
|
||||||
|
* 高亮目标通过 `data-agent-guide` 标记在各组件上;启动时按目标是否存在过滤步骤
|
||||||
|
* (如新用户无智能体卡片、无预设常用问题时自动剔除该步)——Guide 对找不到的
|
||||||
|
* 目标元素会抛错,不能盲传。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AgentGuideStage = "list" | "create" | "chat";
|
||||||
|
|
||||||
|
const finishedKey = (stage: AgentGuideStage) =>
|
||||||
|
`deerflow.agent-guide.${stage}.finished`;
|
||||||
|
|
||||||
|
const startEventName = (stage: AgentGuideStage) =>
|
||||||
|
`agent-guide:start:${stage}`;
|
||||||
|
|
||||||
|
/** 手动触发某阶段的引导(各页面头部「使用引导」按钮调用,忽略已看过记忆)。 */
|
||||||
|
export function startAgentGuide(stage: AgentGuideStage) {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new CustomEvent(startEventName(stage)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 环境闸门:iframe 嵌入 / 去 chrome / 强制收起侧栏(无界)等环境不弹引导。 */
|
||||||
|
export function isAgentGuideEnvironment(): boolean {
|
||||||
|
return (
|
||||||
|
!env.FORCE_COLLAPSED_SIDEBAR &&
|
||||||
|
!isEmbedActive() &&
|
||||||
|
!isHideChromeActive() &&
|
||||||
|
!isWujieEnv()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 引导步骤高亮目标选择器;各目标元素由所在组件打上同名 data 标记。 */
|
||||||
|
const TARGET = {
|
||||||
|
// list 阶段(智能体列表页)
|
||||||
|
tabs: '[data-agent-guide="tabs"]',
|
||||||
|
search: '[data-agent-guide="search"]',
|
||||||
|
agentCard: '[data-agent-guide="agent-card"]',
|
||||||
|
newAgentBtn: '[data-agent-guide="new-agent-btn"]',
|
||||||
|
// create 阶段(创建页)
|
||||||
|
createChatInput: '[data-agent-guide="create-chat-input"]',
|
||||||
|
createBasic: '[data-agent-guide="create-basic"]',
|
||||||
|
createModel: '[data-agent-guide="create-model"]',
|
||||||
|
createKnowledge: '[data-agent-guide="create-knowledge"]',
|
||||||
|
createSoul: '[data-agent-guide="create-soul"]',
|
||||||
|
createSkills: '[data-agent-guide="create-skills"]',
|
||||||
|
createSaveBtn: '[data-agent-guide="create-save-btn"]',
|
||||||
|
// chat 阶段(智能体对话页)
|
||||||
|
chatSidebar: '[data-agent-guide="chat-sidebar"]',
|
||||||
|
chatQuestions: '[data-agent-guide="chat-questions"]',
|
||||||
|
chatInput: '[data-agent-guide="chat-input"]',
|
||||||
|
chatDetail: '[data-agent-guide="chat-detail"]',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// 等布局稳定后再启动:Guide 只在步骤切换时测量目标位置,起步太早会把高亮框
|
||||||
|
// 定位到过渡中的坐标且不会自行纠正。
|
||||||
|
const START_DELAY_MS = 800;
|
||||||
|
|
||||||
|
interface StepSpec {
|
||||||
|
/** 高亮目标选择器;缺省(dialog 步骤)以 body 兜底。 */
|
||||||
|
selector?: string;
|
||||||
|
mode: "popup" | "dialog";
|
||||||
|
placement: NonNullable<GuideStep["placement"]>;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIST_STEPS: StepSpec[] = [
|
||||||
|
{
|
||||||
|
mode: "dialog",
|
||||||
|
placement: "center",
|
||||||
|
title: "欢迎使用智能体",
|
||||||
|
body: "这里是你的智能体中心。接下来花几分钟走完整个流程:浏览与管理智能体 → 创建与配置 → 发起问答。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.tabs,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "bottom",
|
||||||
|
title: "分区浏览",
|
||||||
|
body: "「我的」放着你创建和收藏的智能体;各「广场」是他人发布共享的,可直接使用或收藏;「私密广场」需要密码访问(管理员还可以查看「内置智能体」)。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.search,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "bottom",
|
||||||
|
title: "搜索与排序",
|
||||||
|
body: "支持关键词、标签搜索,可按最新创建、最近更新、名称排序;右侧按钮可在卡片 / 列表两种视图间切换。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.agentCard,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "top",
|
||||||
|
title: "智能体卡片",
|
||||||
|
body: "卡片展示名称、简介、模型与技能徽章;底部操作栏支持收藏、置顶、打标签、发布到广场、复制、编辑、删除;点「对话」直接进入该智能体的问答。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.newAgentBtn,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "bottom-left",
|
||||||
|
title: "创建智能体",
|
||||||
|
body: "准备好了就点这里,开始创建你的专属智能体;进入创建页后还会有一段配置引导。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CREATE_STEPS: StepSpec[] = [
|
||||||
|
{
|
||||||
|
mode: "dialog",
|
||||||
|
placement: "center",
|
||||||
|
title: "创建智能体",
|
||||||
|
body: "页面分两栏:左侧直接与模型对话,让它帮你配置;右侧表单手动填写。两边内容实时联动,选顺手的方式即可。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.createChatInput,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "top",
|
||||||
|
title: "对话式创建",
|
||||||
|
body: "在这里描述你想要的智能体,例如「帮我做一个周报助手,语气正式、要点条目化」。模型会追问细节并给出配置方案,你确认后方案自动填入右侧表单,可继续微调。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.createBasic,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "left",
|
||||||
|
title: "基本信息",
|
||||||
|
body: "「名称」显示在卡片和对话页;「描述」用一句话说明它擅长什么,方便日后挑选。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.createModel,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "left",
|
||||||
|
title: "运行模型与入库",
|
||||||
|
body: "「模型」选择该智能体被使用时调用的模型;「入库模板名」配置后,对话生成的文档可一键发布入库(不需要可留空)。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.createKnowledge,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "left",
|
||||||
|
title: "知识空间",
|
||||||
|
body: "为智能体挂载知识库,回答时会优先检索其中的资料,让结果更贴合你的业务数据。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.createSoul,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "left",
|
||||||
|
title: "SOUL.md · 人设",
|
||||||
|
body: "智能体的「灵魂文件」——写清角色定位、能力边界、工作步骤和输出格式。这份提示词直接决定回答质量,是最值得花时间打磨的配置。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.createSkills,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "left",
|
||||||
|
title: "技能挂载",
|
||||||
|
body: "从可用技能中勾选要赋予的能力,已选技能会作为工具在对话中被调用;全部不选则是纯对话型智能体。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.createSaveBtn,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "top",
|
||||||
|
title: "创建并保存",
|
||||||
|
body: "配置完成后点这里保存(左侧对话只是试用场,内容不会自动保存)。保存成功会回到智能体列表。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: "dialog",
|
||||||
|
placement: "center",
|
||||||
|
title: "开始问答",
|
||||||
|
body: "回到列表页后,点智能体卡片上的「对话」按钮即可开始问答;进入对话页还会有最后一段引导。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CHAT_STEPS: StepSpec[] = [
|
||||||
|
{
|
||||||
|
mode: "dialog",
|
||||||
|
placement: "center",
|
||||||
|
title: "智能体问答",
|
||||||
|
body: "一切就绪!这里就是与智能体对话的工作区,快速认识一下。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.chatSidebar,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "right",
|
||||||
|
title: "切换与历史",
|
||||||
|
body: "左栏顶部可在「我的 / 广场」智能体之间切换;下方是当前智能体的历史会话,点击任意一条继续上次的对话。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.chatQuestions,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "bottom",
|
||||||
|
title: "常用问题",
|
||||||
|
body: "智能体预设的开场问题,点击即可直接提问,快速体验它的能力。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.chatInput,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "top",
|
||||||
|
title: "输入框",
|
||||||
|
body: "输入问题后发送即可开始问答;新对话里还可以挂载知识空间,让回答优先检索知识库。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.chatDetail,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "left",
|
||||||
|
title: "智能体详情",
|
||||||
|
body: "右侧面板展示当前智能体的配置(人设、技能、常用问题),可以直接修改并即时生效;里面的快捷提问也能一键发送。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const STAGE_STEPS: Record<AgentGuideStage, StepSpec[]> = {
|
||||||
|
list: LIST_STEPS,
|
||||||
|
create: CREATE_STEPS,
|
||||||
|
chat: CHAT_STEPS,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isBrowser(): boolean {
|
||||||
|
return typeof window !== "undefined";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStageFinished(stage: AgentGuideStage): boolean {
|
||||||
|
return (
|
||||||
|
isBrowser() && localStorage.getItem(finishedKey(stage)) === "1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markStageFinished(stage: AgentGuideStage) {
|
||||||
|
if (isBrowser()) localStorage.setItem(finishedKey(stage), "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSteps(stage: AgentGuideStage): GuideStep[] {
|
||||||
|
if (!isBrowser()) return [];
|
||||||
|
return STAGE_STEPS[stage]
|
||||||
|
.filter((spec) => !spec.selector || document.querySelector(spec.selector))
|
||||||
|
.map((spec) => ({
|
||||||
|
element: spec.selector ?? (() => document.body),
|
||||||
|
mode: spec.mode,
|
||||||
|
placement: spec.placement,
|
||||||
|
title: spec.title,
|
||||||
|
body: spec.body,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能体三段引导的运行组件:`stage` 决定步骤内容,`eligible` 为 false 时完全
|
||||||
|
* 静默(不监听、不自动弹)。挂在三个页面各自的作用域内;`autoStart` 为 false
|
||||||
|
* 时仅响应手动触发(列表页不自动弹,进新建页才自动演示)。
|
||||||
|
*/
|
||||||
|
export function AgentGuide({
|
||||||
|
stage,
|
||||||
|
eligible,
|
||||||
|
autoStart = true,
|
||||||
|
}: {
|
||||||
|
stage: AgentGuideStage;
|
||||||
|
eligible: boolean;
|
||||||
|
autoStart?: boolean;
|
||||||
|
}) {
|
||||||
|
const [current, setCurrent] = useState(-1);
|
||||||
|
const stepsRef = useRef<GuideStep[]>([]);
|
||||||
|
|
||||||
|
const start = useCallback(() => {
|
||||||
|
const steps = buildSteps(stage);
|
||||||
|
if (steps.length === 0) return;
|
||||||
|
stepsRef.current = steps;
|
||||||
|
setCurrent(0);
|
||||||
|
}, [stage]);
|
||||||
|
|
||||||
|
// 自动演示:没显式结束过(没点过完成/跳过)时,每次进入该页都演示;是否
|
||||||
|
// 「已看过」只由 finish 回调(完成/跳过)写入。
|
||||||
|
const autoTriedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!eligible || !autoStart || autoTriedRef.current) return;
|
||||||
|
autoTriedRef.current = true;
|
||||||
|
if (isStageFinished(stage)) return;
|
||||||
|
const timer = setTimeout(start, START_DELAY_MS);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
// 组件在计时窗口内卸载(用户迅速离开页面)则下次进入再演示。
|
||||||
|
autoTriedRef.current = false;
|
||||||
|
};
|
||||||
|
}, [eligible, autoStart, stage, start]);
|
||||||
|
|
||||||
|
// 手动触发:页面头部「使用引导」按钮广播事件,忽略已看过记忆强制重放。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!eligible) return;
|
||||||
|
const handler = () => start();
|
||||||
|
window.addEventListener(startEventName(stage), handler);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener(startEventName(stage), handler);
|
||||||
|
}, [eligible, stage, start]);
|
||||||
|
|
||||||
|
// 完成 / 跳过都是用户显式结束引导,就地关闭并记忆「已看过」,不跳转任何页面。
|
||||||
|
const finish = useCallback(() => {
|
||||||
|
markStageFinished(stage);
|
||||||
|
setCurrent(-1);
|
||||||
|
}, [stage]);
|
||||||
|
|
||||||
|
if (!eligible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Guide
|
||||||
|
current={current}
|
||||||
|
steps={stepsRef.current}
|
||||||
|
onChange={setCurrent}
|
||||||
|
onFinish={finish}
|
||||||
|
onSkip={finish}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { SentimentAgentProfileDialog } from "./sentiment-agent-profile-dialog";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 「舆情分析」类型下的虚拟智能体卡片:只存在于前端(不落库 /api/agents),
|
* 「舆情分析」类型下的虚拟智能体卡片:只存在于前端(不落库 /api/agents),
|
||||||
* 对话直连外部 AG-UI 网关。名称 / 简介可编辑(localStorage),
|
* 对话经 Gateway 转发外部 AG-UI 网关。名称 / 简介可编辑(localStorage),
|
||||||
* 视觉与普通智能体卡片保持一致(网格 / 列表两种布局)。
|
* 视觉与普通智能体卡片保持一致(网格 / 列表两种布局)。
|
||||||
*/
|
*/
|
||||||
export function SentimentAgentCard({
|
export function SentimentAgentCard({
|
||||||
|
|||||||
@@ -165,10 +165,26 @@ export function SkillPickerSheet({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const validation = await validateUpload.mutateAsync(file);
|
const validation = await validateUpload.mutateAsync({
|
||||||
|
file,
|
||||||
|
targetCategory: "custom",
|
||||||
|
});
|
||||||
if (validation.conflict) {
|
if (validation.conflict) {
|
||||||
|
const owner = validation.owner_username ?? validation.owner_user_id;
|
||||||
|
if (validation.owned_by_other && owner) {
|
||||||
|
toast.error(
|
||||||
|
`技能「${validation.skill_name}」已由用户「${owner}」上传,不能覆盖。`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (validation.owned_by_me && validation.can_overwrite) {
|
||||||
|
toast.warning(
|
||||||
|
`您已上传过同名技能「${validation.skill_name}」,可到「技能」设置页重复上传并覆盖原版本。`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast.warning(
|
toast.warning(
|
||||||
`已存在同名技能「${validation.skill_name}」,请到「技能」设置页上传以选择是否覆盖。`,
|
`已存在同名技能「${validation.skill_name}」,当前不可覆盖。`,
|
||||||
);
|
);
|
||||||
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");
|
||||||
}}
|
}}
|
||||||
@@ -122,6 +152,7 @@ export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative"
|
className="relative"
|
||||||
|
data-compact-guide="theme-switcher"
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
cancelClose();
|
cancelClose();
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
@@ -192,22 +223,19 @@ const LAYOUT_OPTIONS = [
|
|||||||
* 界面模式(全量 / 简洁)下拉切换。换肤旁并排,图标 + 下拉:
|
* 界面模式(全量 / 简洁)下拉切换。换肤旁并排,图标 + 下拉:
|
||||||
* - 简洁模式 ChatPage 顶栏(全局顶栏被隐藏)。
|
* - 简洁模式 ChatPage 顶栏(全局顶栏被隐藏)。
|
||||||
* - 全量模式 Header 右上角。
|
* - 全量模式 Header 右上角。
|
||||||
* 用户未显式设置过界面模式时(mode === undefined),当前态按调用方所在的实际界面
|
* 当前态为有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认)——
|
||||||
* 兜底——`fallback` 指明这一回退值(Header 传 "full",ChatPage 传 "compact")。
|
* useLayoutMode 总会返回具体值,不再有 undefined 兜底。
|
||||||
*/
|
*/
|
||||||
export function LayoutModeSwitcher({
|
export function LayoutModeSwitcher({
|
||||||
tone,
|
tone,
|
||||||
fallback = "compact",
|
|
||||||
navigateTo = "/page/workspace/chats/new",
|
navigateTo = "/page/workspace/chats/new",
|
||||||
}: {
|
}: {
|
||||||
tone: AppearanceControlTone;
|
tone: AppearanceControlTone;
|
||||||
fallback?: "full" | "compact";
|
|
||||||
navigateTo?: string | null;
|
navigateTo?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const [mode, setLayoutMode] = useLayoutMode();
|
const [mode, setLayoutMode] = useLayoutMode();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const current = mode ?? fallback;
|
const TriggerIcon = mode === "full" ? PanelLeftIcon : LayoutIcon;
|
||||||
const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon;
|
|
||||||
|
|
||||||
if (env.FORCE_COLLAPSED_SIDEBAR || isHideChromeActive()) return null;
|
if (env.FORCE_COLLAPSED_SIDEBAR || isHideChromeActive()) return null;
|
||||||
|
|
||||||
@@ -221,6 +249,7 @@ export function LayoutModeSwitcher({
|
|||||||
className={cn("size-8 shrink-0 rounded-sm", triggerToneClass(tone))}
|
className={cn("size-8 shrink-0 rounded-sm", triggerToneClass(tone))}
|
||||||
title="界面模式"
|
title="界面模式"
|
||||||
aria-label="界面模式"
|
aria-label="界面模式"
|
||||||
|
data-compact-guide="layout-mode"
|
||||||
>
|
>
|
||||||
<TriggerIcon className="size-4" />
|
<TriggerIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -230,7 +259,7 @@ export function LayoutModeSwitcher({
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{LAYOUT_OPTIONS.map((option) => {
|
{LAYOUT_OPTIONS.map((option) => {
|
||||||
const Icon = option.icon;
|
const Icon = option.icon;
|
||||||
const active = current === option.id;
|
const active = mode === option.id;
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={option.id}
|
key={option.id}
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
FolderIcon,
|
||||||
|
FolderOpenIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { artifactPathname, artifactRelativePath } from "@/core/artifacts/listing";
|
||||||
|
import { getFileIcon, getFileName } from "@/core/utils/files";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type DirectoryNode = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
directories: DirectoryNode[];
|
||||||
|
files: Array<{ artifact: string; name: string; relativePath: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildDirectoryTree(artifacts: string[]): DirectoryNode {
|
||||||
|
const root: DirectoryNode = {
|
||||||
|
name: "生成文件",
|
||||||
|
path: "",
|
||||||
|
directories: [],
|
||||||
|
files: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const artifact of artifacts) {
|
||||||
|
const relativePath = artifactRelativePath(artifact);
|
||||||
|
const parts = relativePath.split("/").filter(Boolean);
|
||||||
|
if (parts.length === 0) continue;
|
||||||
|
|
||||||
|
let current = root;
|
||||||
|
for (const directoryName of parts.slice(0, -1)) {
|
||||||
|
let child = current.directories.find((node) => node.name === directoryName);
|
||||||
|
if (!child) {
|
||||||
|
child = {
|
||||||
|
name: directoryName,
|
||||||
|
path: current.path ? `${current.path}/${directoryName}` : directoryName,
|
||||||
|
directories: [],
|
||||||
|
files: [],
|
||||||
|
};
|
||||||
|
current.directories.push(child);
|
||||||
|
}
|
||||||
|
current = child;
|
||||||
|
}
|
||||||
|
|
||||||
|
current.files.push({
|
||||||
|
artifact,
|
||||||
|
name: parts.at(-1) ?? getFileName(artifact),
|
||||||
|
relativePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sort = (node: DirectoryNode) => {
|
||||||
|
node.directories.sort((left, right) =>
|
||||||
|
left.name.localeCompare(right.name, "zh-CN"),
|
||||||
|
);
|
||||||
|
node.files.sort((left, right) =>
|
||||||
|
left.name.localeCompare(right.name, "zh-CN"),
|
||||||
|
);
|
||||||
|
node.directories.forEach(sort);
|
||||||
|
};
|
||||||
|
sort(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirectoryBranch({
|
||||||
|
node,
|
||||||
|
depth,
|
||||||
|
collapsedDirectories,
|
||||||
|
onToggleDirectory,
|
||||||
|
selectedArtifact,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
node: DirectoryNode;
|
||||||
|
depth: number;
|
||||||
|
collapsedDirectories: Set<string>;
|
||||||
|
onToggleDirectory: (path: string) => void;
|
||||||
|
selectedArtifact: string | null;
|
||||||
|
onSelect: (artifact: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{node.directories.map((directory) => {
|
||||||
|
const collapsed = collapsedDirectories.has(directory.path);
|
||||||
|
return (
|
||||||
|
<div key={directory.path}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:bg-muted/70 hover:text-foreground flex h-8 w-full items-center gap-1.5 rounded-md pr-2 text-left text-xs"
|
||||||
|
style={{ paddingLeft: `${8 + depth * 14}px` }}
|
||||||
|
onClick={() => onToggleDirectory(directory.path)}
|
||||||
|
title={directory.path}
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronRightIcon className="size-3.5 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronDownIcon className="size-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
{collapsed ? (
|
||||||
|
<FolderIcon className="size-4 shrink-0 text-amber-500" />
|
||||||
|
) : (
|
||||||
|
<FolderOpenIcon className="size-4 shrink-0 text-amber-500" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{directory.name}</span>
|
||||||
|
</button>
|
||||||
|
{!collapsed && (
|
||||||
|
<DirectoryBranch
|
||||||
|
node={directory}
|
||||||
|
depth={depth + 1}
|
||||||
|
collapsedDirectories={collapsedDirectories}
|
||||||
|
onToggleDirectory={onToggleDirectory}
|
||||||
|
selectedArtifact={selectedArtifact}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{node.files.map((file) => {
|
||||||
|
const selected =
|
||||||
|
!!selectedArtifact &&
|
||||||
|
artifactPathname(selectedArtifact) === artifactPathname(file.artifact);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={file.relativePath}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted/70 flex min-h-8 w-full items-center gap-2 rounded-md py-1 pr-2 text-left text-xs",
|
||||||
|
selected
|
||||||
|
? "bg-primary/10 text-primary font-medium"
|
||||||
|
: "text-foreground/85",
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${27 + depth * 14}px` }}
|
||||||
|
onClick={() => onSelect(file.artifact)}
|
||||||
|
title={file.relativePath}
|
||||||
|
aria-current={selected ? "page" : undefined}
|
||||||
|
>
|
||||||
|
<span className="shrink-0">{getFileIcon(file.artifact, "size-4")}</span>
|
||||||
|
<span className="truncate">{file.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArtifactDirectory({
|
||||||
|
className,
|
||||||
|
artifacts,
|
||||||
|
selectedArtifact,
|
||||||
|
onSelect,
|
||||||
|
onRefresh,
|
||||||
|
refreshing = false,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
artifacts: string[];
|
||||||
|
selectedArtifact: string | null;
|
||||||
|
onSelect: (artifact: string) => void;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
refreshing?: boolean;
|
||||||
|
}) {
|
||||||
|
const tree = useMemo(() => buildDirectoryTree(artifacts), [artifacts]);
|
||||||
|
const [collapsedDirectories, setCollapsedDirectories] = useState<Set<string>>(
|
||||||
|
() => new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleDirectory = (path: string) => {
|
||||||
|
setCollapsedDirectories((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(path)) next.delete(path);
|
||||||
|
else next.add(path);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/20 flex min-h-0 w-48 shrink-0 flex-col border-r xl:w-56",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label="生成文件目录"
|
||||||
|
>
|
||||||
|
<div className="flex h-12 shrink-0 items-center justify-between gap-2 border-b px-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold">生成文件</div>
|
||||||
|
<div className="text-muted-foreground text-[11px]">{artifacts.length} 个文件</div>
|
||||||
|
</div>
|
||||||
|
{onRefresh && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="size-7 shrink-0"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={refreshing}
|
||||||
|
title="刷新文件目录"
|
||||||
|
aria-label="刷新文件目录"
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className={cn("size-3.5", refreshing && "animate-spin")} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||||
|
{artifacts.length > 0 ? (
|
||||||
|
<DirectoryBranch
|
||||||
|
node={tree}
|
||||||
|
depth={0}
|
||||||
|
collapsedDirectories={collapsedDirectories}
|
||||||
|
onToggleDirectory={toggleDirectory}
|
||||||
|
selectedArtifact={selectedArtifact}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground px-2 py-8 text-center text-xs leading-5">
|
||||||
|
生成的文件会显示在这里
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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") && (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -42,6 +43,12 @@ export interface ArtifactsContextType {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
autoOpen: boolean;
|
autoOpen: boolean;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
|
/**
|
||||||
|
* 流式写文件自动开沙箱的两阶段标记:openStreamedArtifact 先收起两侧侧栏
|
||||||
|
* (阶段一),等收起布局落定后再真正翻转 open(阶段二)。两阶段之间为
|
||||||
|
* true —— 右侧智能体信息面板据此提前收起,避免与沙箱展开同帧叠加造成卡顿。
|
||||||
|
*/
|
||||||
|
preOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ArtifactsContext = createContext<ArtifactsContextType | undefined>(
|
const ArtifactsContext = createContext<ArtifactsContextType | undefined>(
|
||||||
@@ -52,10 +59,23 @@ interface ArtifactsProviderProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 流式写文件自动开沙箱的分步延迟:t=0 收起左侧聊天记录侧栏,延迟
|
||||||
|
// STREAMED_PREOPEN_DELAY_MS 后再收右侧智能体信息面板(两次收起错开、
|
||||||
|
// 不做「左边收完再收右边」的判断,只给固定延迟,避免收起瞬间同帧叠加
|
||||||
|
// 卡顿),到 STREAMED_OPEN_DELAY_MS(右侧 300ms 收起动画结束)才展开沙箱。
|
||||||
|
const STREAMED_PREOPEN_DELAY_MS = 200;
|
||||||
|
const STREAMED_OPEN_DELAY_MS = 500;
|
||||||
|
|
||||||
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
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[]) => {
|
||||||
setArtifactsState(Array.isArray(next) ? next : []);
|
const normalized = Array.isArray(next) ? next : [];
|
||||||
|
setArtifactsState((previous) =>
|
||||||
|
previous.length === normalized.length &&
|
||||||
|
previous.every((artifact, index) => artifact === normalized[index])
|
||||||
|
? previous
|
||||||
|
: normalized,
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
|
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
|
||||||
const [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null);
|
const [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null);
|
||||||
@@ -63,19 +83,61 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
const [open, setOpenState] = useState(
|
const [open, setOpenState] = useState(
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||||
);
|
);
|
||||||
|
const [preOpen, setPreOpen] = useState(false);
|
||||||
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 openStateRef = useRef(open);
|
||||||
|
const preOpenTimerRef = useRef<number | null>(null);
|
||||||
|
const openTimerRef = useRef<number | null>(null);
|
||||||
const sidebar = useSidebarSafe();
|
const sidebar = useSidebarSafe();
|
||||||
|
|
||||||
|
const applyOpen = useCallback((next: boolean) => {
|
||||||
|
openStateRef.current = next;
|
||||||
|
setOpenState((previous) => (previous === next ? previous : next));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 取消尚未触发的分步自动开沙箱(用户手动关闭 / 切换会话时)。
|
||||||
|
const cancelPendingStreamedOpen = useCallback(() => {
|
||||||
|
if (preOpenTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(preOpenTimerRef.current);
|
||||||
|
preOpenTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (openTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(openTimerRef.current);
|
||||||
|
openTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setPreOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (preOpenTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(preOpenTimerRef.current);
|
||||||
|
preOpenTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (openTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(openTimerRef.current);
|
||||||
|
openTimerRef.current = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
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],
|
||||||
@@ -86,44 +148,77 @@ 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));
|
||||||
|
// 阶段一(t=0):立即收起左侧聊天记录侧栏。
|
||||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
|
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
|
||||||
sidebar?.setOpen(false);
|
sidebar?.setOpen(false);
|
||||||
}
|
}
|
||||||
|
if (openStateRef.current) {
|
||||||
|
// 沙箱已展开(如连续写多个文件):只需切换选中文件,无需分步。
|
||||||
|
cancelPendingStreamedOpen();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (openTimerRef.current === null) {
|
||||||
|
// 阶段二(t=+200ms):延迟固定时长再置 preOpen,右侧智能体信息
|
||||||
|
// 面板此时才开始收起 —— 与左侧错开,避免两次收起同帧叠加卡顿。
|
||||||
|
preOpenTimerRef.current = window.setTimeout(() => {
|
||||||
|
preOpenTimerRef.current = null;
|
||||||
|
setPreOpen(true);
|
||||||
|
}, STREAMED_PREOPEN_DELAY_MS);
|
||||||
|
// 阶段三(t=+500ms,右侧 300ms 收起动画结束):翻转 open 展开
|
||||||
|
// 沙箱,把「聊天列变窄 + 沙箱内容挂载」与侧栏收起彻底分帧。
|
||||||
|
openTimerRef.current = window.setTimeout(() => {
|
||||||
|
openTimerRef.current = null;
|
||||||
|
applyOpen(true);
|
||||||
|
setPreOpen(false);
|
||||||
|
}, STREAMED_OPEN_DELAY_MS);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[sidebar],
|
[applyOpen, cancelPendingStreamedOpen, sidebar],
|
||||||
);
|
);
|
||||||
|
|
||||||
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));
|
||||||
|
cancelPendingStreamedOpen();
|
||||||
|
applyOpen(false);
|
||||||
suppressedAutoOpenKeyRef.current = null;
|
suppressedAutoOpenKeyRef.current = null;
|
||||||
}, []);
|
}, [applyOpen, cancelPendingStreamedOpen]);
|
||||||
|
|
||||||
|
const setOpen = useCallback(
|
||||||
|
(isOpen: boolean) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
// 两阶段等待期间用户手动关闭:取消尚未触发的自动展开。
|
||||||
|
cancelPendingStreamedOpen();
|
||||||
|
if (autoOpenRef.current) {
|
||||||
|
autoOpenRef.current = false;
|
||||||
|
setAutoOpen((previous) => (previous ? false : previous));
|
||||||
|
setAutoSelect((previous) => (previous ? false : previous));
|
||||||
|
}
|
||||||
|
suppressedAutoOpenKeyRef.current = selectedArtifactKeyRef.current;
|
||||||
|
}
|
||||||
|
applyOpen(isOpen);
|
||||||
|
},
|
||||||
|
[applyOpen, cancelPendingStreamedOpen],
|
||||||
|
);
|
||||||
|
|
||||||
const value: ArtifactsContextType = {
|
const value: ArtifactsContextType = {
|
||||||
artifacts,
|
artifacts,
|
||||||
setArtifacts,
|
setArtifacts,
|
||||||
|
|
||||||
open,
|
open,
|
||||||
|
preOpen,
|
||||||
autoOpen,
|
autoOpen,
|
||||||
autoSelect,
|
autoSelect,
|
||||||
setOpen: (isOpen: boolean) => {
|
setOpen,
|
||||||
if (!isOpen && autoOpen) {
|
|
||||||
setAutoOpen(false);
|
|
||||||
setAutoSelect(false);
|
|
||||||
}
|
|
||||||
if (!isOpen) {
|
|
||||||
suppressedAutoOpenKeyRef.current = selectedArtifactKey;
|
|
||||||
}
|
|
||||||
setOpenState(isOpen);
|
|
||||||
},
|
|
||||||
|
|
||||||
selectedArtifact,
|
selectedArtifact,
|
||||||
selectedArtifactKey,
|
selectedArtifactKey,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./artifact-file-detail";
|
export * from "./artifact-file-detail";
|
||||||
|
export * from "./artifact-directory";
|
||||||
export * from "./artifact-file-list";
|
export * from "./artifact-file-list";
|
||||||
export * from "./artifact-trigger";
|
export * from "./artifact-trigger";
|
||||||
export * from "./context";
|
export * from "./context";
|
||||||
@@ -117,7 +117,7 @@ export function SpreadsheetPreview({
|
|||||||
{Array.from({ length: columnCount }, (_, columnIndex) => (
|
{Array.from({ length: columnCount }, (_, columnIndex) => (
|
||||||
<th
|
<th
|
||||||
key={columnIndex}
|
key={columnIndex}
|
||||||
className="border-border bg-muted/60 text-foreground sticky top-0 border px-3 py-1.5 text-left font-medium whitespace-pre-wrap"
|
className="border-border bg-muted/60 text-foreground border px-3 py-1.5 text-left font-semibold whitespace-pre-wrap"
|
||||||
>
|
>
|
||||||
{desensitize(headerRow?.[columnIndex] ?? "")}
|
{desensitize(headerRow?.[columnIndex] ?? "")}
|
||||||
</th>
|
</th>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Message } from "@langchain/langgraph-sdk";
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { FilesIcon, XIcon } from "lucide-react";
|
import { FilesIcon, XIcon } from "lucide-react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
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";
|
||||||
|
|
||||||
@@ -13,10 +14,17 @@ import {
|
|||||||
} from "@/components/ui/resizable";
|
} from "@/components/ui/resizable";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { apiFetch } from "@/core/api";
|
import { apiFetch } from "@/core/api";
|
||||||
|
import {
|
||||||
|
artifactPathname,
|
||||||
|
listThreadArtifacts,
|
||||||
|
mergeArtifactPaths,
|
||||||
|
} from "@/core/artifacts/listing";
|
||||||
|
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
|
||||||
|
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||||
|
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||||
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
|
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +40,7 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ArtifactFileDetail,
|
ArtifactFileDetail,
|
||||||
|
ArtifactDirectory,
|
||||||
ArtifactFileList,
|
ArtifactFileList,
|
||||||
useArtifacts,
|
useArtifacts,
|
||||||
} from "../artifacts";
|
} from "../artifacts";
|
||||||
@@ -40,6 +49,7 @@ import { SkillDisplaySources } from "../skill-display-sources";
|
|||||||
|
|
||||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||||
|
const DIRECTORY_OPEN_MODE = { chat: 48, artifacts: 52 };
|
||||||
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
||||||
|
|
||||||
type BackendReferenceSource = Parameters<typeof SkillDisplaySources>[0]["sources"][number];
|
type BackendReferenceSource = Parameters<typeof SkillDisplaySources>[0]["sources"][number];
|
||||||
@@ -78,9 +88,19 @@ const ChatBox: React.FC<{
|
|||||||
threadId,
|
threadId,
|
||||||
disableArtifacts = false,
|
disableArtifacts = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { thread } = useThread();
|
const {
|
||||||
|
isMock,
|
||||||
|
thread,
|
||||||
|
knowledgeReferencesEnabled = false,
|
||||||
|
} = useThread();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const requestedArtifact = useMemo(() => {
|
||||||
|
const value = searchParams.get("artifact")?.trim();
|
||||||
|
return value ? artifactPathname(value) : null;
|
||||||
|
}, [searchParams]);
|
||||||
const threadIdRef = useRef(threadId);
|
const threadIdRef = useRef(threadId);
|
||||||
|
const openedRequestedArtifactRef = useRef<string | null>(null);
|
||||||
const artifactsCountRef = useRef(0);
|
const artifactsCountRef = useRef(0);
|
||||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||||
const latestReferenceBatchIdRef = useRef<string | null>(null);
|
const latestReferenceBatchIdRef = useRef<string | null>(null);
|
||||||
@@ -92,24 +112,83 @@ const ChatBox: React.FC<{
|
|||||||
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||||
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
|
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
|
||||||
|
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
|
||||||
|
const writingModeEnabled = !!settings.context.writing_mode;
|
||||||
|
const artifactDirectoryModeEnabled =
|
||||||
|
settings.display.artifactDirectoryModeEnabled ?? false;
|
||||||
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
|
const referenceModeEntryEnabled =
|
||||||
|
citationDisplay?.reference_mode_enabled ?? false;
|
||||||
const ragModeEnabled =
|
const ragModeEnabled =
|
||||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||||
|
isThreadRagModeLocked(threadId) ||
|
||||||
|
knowledgeReferencesEnabled;
|
||||||
// Frontend RAG modes write their own hidden source tool messages. They use
|
// Frontend RAG modes write their own hidden source tool messages. They use
|
||||||
// the existing reference panel, but must not request or write reference
|
// the existing reference panel, but must not request or write reference
|
||||||
// batches through DeerFlow's backend APIs.
|
// batches through DeerFlow's backend APIs.
|
||||||
const hasFrontendRag = useMemo(
|
const hasFrontendRag = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(thread.values.messages ?? []).some(
|
(thread.values.messages ?? []).some((message) =>
|
||||||
(message) =>
|
isSingleRoundRagMessage(message),
|
||||||
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
|
|
||||||
),
|
),
|
||||||
[thread.values.messages],
|
[thread.values.messages],
|
||||||
);
|
);
|
||||||
const referencesEnabled = ragModeEnabled || hasFrontendRag;
|
const referencesEnabled = ragModeEnabled || hasFrontendRag;
|
||||||
const shouldFetchBackendReferenceBatches =
|
const shouldFetchBackendReferenceBatches =
|
||||||
referencesEnabled && !hasFrontendRag;
|
referencesEnabled && !hasFrontendRag && !!threadId && threadId !== "new";
|
||||||
const { skills } = useSkills();
|
const { skills } = useSkills();
|
||||||
|
|
||||||
|
const canScanArtifactDirectory =
|
||||||
|
(artifactDirectoryModeEnabled || !!requestedArtifact) &&
|
||||||
|
!disableArtifacts &&
|
||||||
|
!isMock &&
|
||||||
|
!!threadId &&
|
||||||
|
threadId !== "new";
|
||||||
|
const artifactListingQuery = useQuery({
|
||||||
|
queryKey: ["artifact-directory", threadId],
|
||||||
|
queryFn: ({ signal }) => listThreadArtifacts(threadId, signal),
|
||||||
|
enabled: canScanArtifactDirectory,
|
||||||
|
refetchInterval: thread.isLoading ? 1_500 : false,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
const checkpointArtifacts = useMemo(
|
||||||
|
() => (disableArtifacts ? [] : (thread.values.artifacts ?? [])),
|
||||||
|
[disableArtifacts, thread.values.artifacts],
|
||||||
|
);
|
||||||
|
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
|
||||||
|
const availableArtifacts = useMemo(
|
||||||
|
() => {
|
||||||
|
if (!artifactDirectoryModeEnabled && !requestedArtifact) {
|
||||||
|
return checkpointArtifacts;
|
||||||
|
}
|
||||||
|
const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map(
|
||||||
|
(file) => file.path,
|
||||||
|
);
|
||||||
|
if (requestedArtifact) discoveredArtifacts.push(requestedArtifact);
|
||||||
|
return mergeArtifactPaths(checkpointArtifacts, discoveredArtifacts);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
artifactDirectoryModeEnabled,
|
||||||
|
artifactListingQuery.data?.files,
|
||||||
|
checkpointArtifacts,
|
||||||
|
requestedArtifact,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canScanArtifactDirectory || thread.isLoading) return;
|
||||||
|
// Run once after streaming settles as well as when checkpoint artifacts
|
||||||
|
// change, so files written without present_files are not missed by the
|
||||||
|
// last polling tick.
|
||||||
|
void artifactListingQuery.refetch();
|
||||||
|
}, [
|
||||||
|
artifactListingQuery.refetch,
|
||||||
|
canScanArtifactDirectory,
|
||||||
|
checkpointArtifactSignature,
|
||||||
|
thread.isLoading,
|
||||||
|
]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
artifacts,
|
artifacts,
|
||||||
open: artifactsOpen,
|
open: artifactsOpen,
|
||||||
@@ -119,6 +198,7 @@ const ChatBox: React.FC<{
|
|||||||
deselect,
|
deselect,
|
||||||
selectedArtifact,
|
selectedArtifact,
|
||||||
selectedArtifactKey,
|
selectedArtifactKey,
|
||||||
|
openStreamedArtifact,
|
||||||
} = useArtifacts();
|
} = useArtifacts();
|
||||||
|
|
||||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||||
@@ -136,6 +216,29 @@ const ChatBox: React.FC<{
|
|||||||
thread.isLoading,
|
thread.isLoading,
|
||||||
);
|
);
|
||||||
}, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
}, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
disableArtifacts ||
|
||||||
|
!requestedArtifact ||
|
||||||
|
!threadId ||
|
||||||
|
threadId === "new"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestKey = `${threadId}:${artifactPathname(requestedArtifact)}`;
|
||||||
|
if (openedRequestedArtifactRef.current === requestKey) return;
|
||||||
|
openedRequestedArtifactRef.current = requestKey;
|
||||||
|
selectArtifact(requestedArtifact);
|
||||||
|
setArtifactsOpen(true);
|
||||||
|
setSideTab("document");
|
||||||
|
}, [
|
||||||
|
disableArtifacts,
|
||||||
|
requestedArtifact,
|
||||||
|
selectArtifact,
|
||||||
|
setArtifactsOpen,
|
||||||
|
threadId,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldFetchBackendReferenceBatches) {
|
if (!shouldFetchBackendReferenceBatches) {
|
||||||
setBackendReferenceBatches([]);
|
setBackendReferenceBatches([]);
|
||||||
@@ -302,7 +405,8 @@ const ChatBox: React.FC<{
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (threadIdRef.current !== threadId) {
|
const threadChanged = threadIdRef.current !== threadId;
|
||||||
|
if (threadChanged) {
|
||||||
threadIdRef.current = threadId;
|
threadIdRef.current = threadId;
|
||||||
artifactsCountRef.current = 0;
|
artifactsCountRef.current = 0;
|
||||||
deselect();
|
deselect();
|
||||||
@@ -316,19 +420,28 @@ const ChatBox: React.FC<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update artifacts from the current thread
|
// Update artifacts from the current thread
|
||||||
const nextArtifacts = disableArtifacts ? [] : (thread.values.artifacts ?? []);
|
const nextArtifacts = availableArtifacts;
|
||||||
const hadArtifacts = artifactsCountRef.current > 0;
|
const hadArtifacts = artifactsCountRef.current > 0;
|
||||||
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
|
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
|
||||||
artifactsCountRef.current = nextArtifacts.length;
|
artifactsCountRef.current = nextArtifacts.length;
|
||||||
setArtifacts(nextArtifacts);
|
const artifactsChanged =
|
||||||
|
artifacts.length !== nextArtifacts.length ||
|
||||||
|
artifacts.some((artifact, index) => artifact !== nextArtifacts[index]);
|
||||||
|
if (artifactsChanged) {
|
||||||
|
setArtifacts(nextArtifacts);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!disableArtifacts &&
|
!disableArtifacts &&
|
||||||
(hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) &&
|
nextArtifacts.length > 0 &&
|
||||||
!selectedArtifact
|
((artifactDirectoryModeEnabled && threadChanged) ||
|
||||||
|
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
|
||||||
) {
|
) {
|
||||||
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
|
const newest = nextArtifacts[nextArtifacts.length - 1]!;
|
||||||
setArtifactsOpen(true);
|
if (shouldAutoOpenWritingArtifact(newest, thread.messages, writingModeEnabled)) {
|
||||||
setSideTab("document");
|
selectArtifact(newest);
|
||||||
|
setArtifactsOpen(true);
|
||||||
|
setSideTab("document");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
|
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
|
||||||
@@ -351,13 +464,61 @@ const ChatBox: React.FC<{
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
threadId,
|
threadId,
|
||||||
|
artifactDirectoryModeEnabled,
|
||||||
autoSelectFirstArtifact,
|
autoSelectFirstArtifact,
|
||||||
deselect,
|
deselect,
|
||||||
selectArtifact,
|
selectArtifact,
|
||||||
selectedArtifact,
|
selectedArtifact,
|
||||||
|
artifacts,
|
||||||
setArtifacts,
|
setArtifacts,
|
||||||
thread.values.artifacts,
|
availableArtifacts,
|
||||||
disableArtifacts,
|
disableArtifacts,
|
||||||
|
thread.messages,
|
||||||
|
writingModeEnabled,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Live sandbox: open as soon as any in-flight write_file has a path or
|
||||||
|
// the first content bytes — not only when that tool is the last CoT step.
|
||||||
|
// Agentfx (and other agents) often emit write_file + present_files/bash in
|
||||||
|
// the same turn; the collapsed earlier write would otherwise never auto-open.
|
||||||
|
const streamingWritePreview = useMemo(
|
||||||
|
() =>
|
||||||
|
disableArtifacts || !thread.isLoading
|
||||||
|
? null
|
||||||
|
: latestInFlightWriteFilePreview(thread.messages ?? [], writingModeEnabled),
|
||||||
|
[disableArtifacts, thread.isLoading, thread.messages, writingModeEnabled],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!streamingWritePreview) return;
|
||||||
|
openStreamedArtifact(
|
||||||
|
streamingWritePreview.artifactPath,
|
||||||
|
streamingWritePreview.previewKey,
|
||||||
|
);
|
||||||
|
setSideTab("document");
|
||||||
|
}, [openStreamedArtifact, streamingWritePreview]);
|
||||||
|
|
||||||
|
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
|
||||||
|
useEffect(() => {
|
||||||
|
const wasEnabled = directoryModeWasEnabledRef.current;
|
||||||
|
directoryModeWasEnabledRef.current = artifactDirectoryModeEnabled;
|
||||||
|
if (
|
||||||
|
wasEnabled ||
|
||||||
|
!artifactDirectoryModeEnabled ||
|
||||||
|
availableArtifacts.length === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedArtifact) {
|
||||||
|
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!);
|
||||||
|
}
|
||||||
|
setArtifactsOpen(true);
|
||||||
|
setSideTab("document");
|
||||||
|
}, [
|
||||||
|
artifactDirectoryModeEnabled,
|
||||||
|
availableArtifacts,
|
||||||
|
selectArtifact,
|
||||||
|
selectedArtifact,
|
||||||
|
setArtifactsOpen,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const artifactPanelOpen = useMemo(() => {
|
const artifactPanelOpen = useMemo(() => {
|
||||||
@@ -370,7 +531,10 @@ const ChatBox: React.FC<{
|
|||||||
return artifactsOpen;
|
return artifactsOpen;
|
||||||
}, [artifactsOpen, artifacts, disableArtifacts]);
|
}, [artifactsOpen, artifacts, disableArtifacts]);
|
||||||
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
|
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
|
||||||
const showTabs = !!selectedArtifact && hasCitationReferences;
|
const showTabs =
|
||||||
|
hasCitationReferences &&
|
||||||
|
(!!selectedArtifact ||
|
||||||
|
(artifactDirectoryModeEnabled && artifacts.length > 0));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedArtifact) {
|
if (selectedArtifact) {
|
||||||
@@ -390,12 +554,103 @@ const ChatBox: React.FC<{
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (layoutRef.current) {
|
if (layoutRef.current) {
|
||||||
if (sidePanelOpen) {
|
if (sidePanelOpen) {
|
||||||
layoutRef.current.setLayout(OPEN_MODE);
|
layoutRef.current.setLayout(
|
||||||
|
artifactDirectoryModeEnabled && artifactPanelOpen
|
||||||
|
? DIRECTORY_OPEN_MODE
|
||||||
|
: OPEN_MODE,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
layoutRef.current.setLayout(CLOSE_MODE);
|
layoutRef.current.setLayout(CLOSE_MODE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [sidePanelOpen]);
|
}, [artifactDirectoryModeEnabled, artifactPanelOpen, sidePanelOpen]);
|
||||||
|
|
||||||
|
const artifactDocumentPanel = artifactDirectoryModeEnabled ? (
|
||||||
|
<div className="flex size-full min-w-0 overflow-hidden rounded-lg border">
|
||||||
|
<ArtifactDirectory
|
||||||
|
artifacts={artifacts}
|
||||||
|
selectedArtifact={selectedArtifact}
|
||||||
|
onSelect={(artifact) => {
|
||||||
|
selectArtifact(artifact);
|
||||||
|
setArtifactsOpen(true);
|
||||||
|
}}
|
||||||
|
onRefresh={
|
||||||
|
canScanArtifactDirectory
|
||||||
|
? () => void artifactListingQuery.refetch()
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
refreshing={artifactListingQuery.isFetching}
|
||||||
|
/>
|
||||||
|
<div className="relative min-w-0 flex-1 overflow-hidden">
|
||||||
|
{selectedArtifact ? (
|
||||||
|
<ArtifactFileDetail
|
||||||
|
key={selectedArtifactKey ?? selectedArtifact}
|
||||||
|
className="size-full"
|
||||||
|
filepath={selectedArtifact}
|
||||||
|
threadId={threadId}
|
||||||
|
allowVirtualMarkdownEdit
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="absolute top-1 right-1 z-30">
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setArtifactsOpen(false)}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ConversationEmptyState
|
||||||
|
icon={<FilesIcon />}
|
||||||
|
title="请选择文件"
|
||||||
|
description="从左侧目录中选择一个文件进行查看"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : selectedArtifact ? (
|
||||||
|
<ArtifactFileDetail
|
||||||
|
key={selectedArtifactKey ?? selectedArtifact}
|
||||||
|
className="size-full"
|
||||||
|
filepath={selectedArtifact}
|
||||||
|
threadId={threadId}
|
||||||
|
allowVirtualMarkdownEdit
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="relative flex size-full justify-center">
|
||||||
|
<div className="absolute top-1 right-1 z-30">
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setArtifactsOpen(false)}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{artifacts.length === 0 ? (
|
||||||
|
<ConversationEmptyState
|
||||||
|
icon={<FilesIcon />}
|
||||||
|
title="No artifact selected"
|
||||||
|
description="Select an artifact to view its details"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||||
|
<header className="shrink-0">
|
||||||
|
<h2 className="text-lg font-medium">Artifacts</h2>
|
||||||
|
</header>
|
||||||
|
<main className="min-h-0 grow">
|
||||||
|
<ArtifactFileList
|
||||||
|
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||||
|
files={artifacts}
|
||||||
|
threadId={threadId}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
if (disableArtifacts) {
|
if (disableArtifacts) {
|
||||||
return (
|
return (
|
||||||
@@ -455,13 +710,7 @@ const ChatBox: React.FC<{
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
||||||
<ArtifactFileDetail
|
{artifactDocumentPanel}
|
||||||
key={selectedArtifactKey ?? selectedArtifact}
|
|
||||||
className="size-full"
|
|
||||||
filepath={selectedArtifact}
|
|
||||||
threadId={threadId}
|
|
||||||
allowVirtualMarkdownEdit
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
||||||
<ReferencePanel
|
<ReferencePanel
|
||||||
@@ -470,14 +719,8 @@ const ChatBox: React.FC<{
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
) : selectedArtifact ? (
|
) : artifactPanelOpen ? (
|
||||||
<ArtifactFileDetail
|
artifactDocumentPanel
|
||||||
key={selectedArtifactKey ?? selectedArtifact}
|
|
||||||
className="size-full"
|
|
||||||
filepath={selectedArtifact}
|
|
||||||
threadId={threadId}
|
|
||||||
allowVirtualMarkdownEdit
|
|
||||||
/>
|
|
||||||
) : hasCitationReferences ? (
|
) : hasCitationReferences ? (
|
||||||
<div className="relative size-full overflow-hidden">
|
<div className="relative size-full overflow-hidden">
|
||||||
<div className="absolute top-1 right-1 z-30">
|
<div className="absolute top-1 right-1 z-30">
|
||||||
@@ -497,41 +740,7 @@ const ChatBox: React.FC<{
|
|||||||
sources={referenceSources}
|
sources={referenceSources}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
<div className="relative flex size-full justify-center">
|
|
||||||
<div className="absolute top-1 right-1 z-30">
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setArtifactsOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XIcon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{thread.values.artifacts?.length === 0 ? (
|
|
||||||
<ConversationEmptyState
|
|
||||||
icon={<FilesIcon />}
|
|
||||||
title="No artifact selected"
|
|
||||||
description="Select an artifact to view its details"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
|
||||||
<header className="shrink-0">
|
|
||||||
<h2 className="text-lg font-medium">Artifacts</h2>
|
|
||||||
</header>
|
|
||||||
<main className="min-h-0 grow">
|
|
||||||
<ArtifactFileList
|
|
||||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
|
||||||
files={thread.values.artifacts ?? []}
|
|
||||||
threadId={threadId}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
@@ -780,11 +989,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;
|
||||||
}
|
}
|
||||||
@@ -794,6 +1002,13 @@ 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);
|
||||||
|
if (nextHumanIndex === -1 && isLoading && !isFrontendRagTurn) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (sources.length > 0) {
|
if (sources.length > 0) {
|
||||||
batches.push({
|
batches.push({
|
||||||
id: assistant.id,
|
id: assistant.id,
|
||||||
@@ -832,6 +1047,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)
|
||||||
|
) {
|
||||||
|
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") {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useParams, useSearchParams } from "next/navigation";
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
|
||||||
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
|
import { useOptionalPromptInputController } from "@/components/ai-elements/prompt-input";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,7 +11,11 @@ export function useSpecificChatMode() {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const promptInputController = usePromptInputController();
|
// A regular history item can navigate here while Vite Fast Refresh is
|
||||||
|
// reconciling the previous page tree. In that short window the page may see
|
||||||
|
// a stale provider context. This hook only pre-fills the skill-mode draft,
|
||||||
|
// so it must not make an ordinary chat page fail to render.
|
||||||
|
const promptInputController = useOptionalPromptInputController();
|
||||||
const inputInitialValue = useMemo(() => {
|
const inputInitialValue = useMemo(() => {
|
||||||
if (threadIdFromPath !== "new" || searchParams.get("mode") !== "skill") {
|
if (threadIdFromPath !== "new" || searchParams.get("mode") !== "skill") {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -19,8 +23,10 @@ export function useSpecificChatMode() {
|
|||||||
return t.inputBox.createSkillPrompt;
|
return t.inputBox.createSkillPrompt;
|
||||||
}, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]);
|
}, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]);
|
||||||
const lastInitialValueRef = useRef<string | undefined>(undefined);
|
const lastInitialValueRef = useRef<string | undefined>(undefined);
|
||||||
const setInputRef = useRef(promptInputController.textInput.setInput);
|
const setInputRef = useRef<((value: string) => void) | undefined>(
|
||||||
setInputRef.current = promptInputController.textInput.setInput;
|
promptInputController?.textInput.setInput,
|
||||||
|
);
|
||||||
|
setInputRef.current = promptInputController?.textInput.setInput;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
inputInitialValue &&
|
inputInitialValue &&
|
||||||
@@ -28,7 +34,10 @@ export function useSpecificChatMode() {
|
|||||||
) {
|
) {
|
||||||
lastInitialValueRef.current = inputInitialValue;
|
lastInitialValueRef.current = inputInitialValue;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setInputRef.current(inputInitialValue);
|
const setInput = setInputRef.current;
|
||||||
|
if (!setInput) return;
|
||||||
|
|
||||||
|
setInput(inputInitialValue);
|
||||||
const textarea = document.querySelector("textarea");
|
const textarea = document.querySelector("textarea");
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
|
|||||||
@@ -11,12 +11,11 @@ import { basicLightInit } from "@uiw/codemirror-theme-basic";
|
|||||||
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
|
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
|
||||||
import CodeMirror from "@uiw/react-codemirror";
|
import CodeMirror from "@uiw/react-codemirror";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { useThread } from "./messages/context";
|
|
||||||
const customDarkTheme = monokaiInit({
|
const customDarkTheme = monokaiInit({
|
||||||
settings: {
|
settings: {
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
@@ -34,7 +33,7 @@ const customLightTheme = basicLightInit({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function CodeEditor({
|
export const CodeEditor = memo(function CodeEditor({
|
||||||
className,
|
className,
|
||||||
placeholder,
|
placeholder,
|
||||||
value,
|
value,
|
||||||
@@ -43,6 +42,7 @@ export function CodeEditor({
|
|||||||
disabled,
|
disabled,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
settings,
|
settings,
|
||||||
|
isStreaming = false,
|
||||||
streamingReadonlyFallback = true,
|
streamingReadonlyFallback = true,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -53,11 +53,10 @@ export function CodeEditor({
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
settings?: unknown;
|
settings?: unknown;
|
||||||
|
/** Parent-owned streaming flag so this editor does not subscribe to `thread`. */
|
||||||
|
isStreaming?: boolean;
|
||||||
streamingReadonlyFallback?: boolean;
|
streamingReadonlyFallback?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const {
|
|
||||||
thread: { isLoading },
|
|
||||||
} = useThread();
|
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
const extensions = useMemo(() => {
|
||||||
@@ -81,7 +80,7 @@ export function CodeEditor({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isLoading && streamingReadonlyFallback ? (
|
{isStreaming && streamingReadonlyFallback ? (
|
||||||
<Textarea
|
<Textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
|
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
|
||||||
@@ -116,4 +115,4 @@ export function CodeEditor({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Guide, type GuideStep } from "tdesign-react";
|
||||||
|
|
||||||
|
import { PROMPT_DIALOG_GUIDE_EVENT } from "@/components/workspace/custom-prompt-menu";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简洁模式(隐藏全局导航栏 + 页面侧边栏的布局)新手引导,基于 TDesign React 的
|
||||||
|
* Guide 组件(https://tdesign.tencent.com/react/components/guide)。
|
||||||
|
*
|
||||||
|
* 触发方式:
|
||||||
|
* - 自动:简洁模式问答落地页自动演示,直到用户显式点「完成」或「跳过」才写
|
||||||
|
* localStorage 记忆;中途刷新/离开不记忆,下次进入还会再演示。
|
||||||
|
* - 手动:左侧「设置和更多」下拉的「界面使用引导」。问答页内通过
|
||||||
|
* COMPACT_GUIDE_START_EVENT 直接启动;其它页面先落 sessionStorage 再导航到
|
||||||
|
* /chats/new,由本组件在挂载后消费。
|
||||||
|
*
|
||||||
|
* 高亮目标通过 `data-compact-guide` 标记在各组件上(见 TARGET 选择器)。启动时逐个
|
||||||
|
* 检查目标是否存在(新用户无聊天记录、侧栏被收起等场景对应区块不渲染),缺失的
|
||||||
|
* 步骤直接剔除 —— Guide 对找不到的目标元素会抛错,不能盲传。唯一例外是提示词
|
||||||
|
* 管理弹窗步骤:弹窗由引导在切到该步时才打开(PROMPT_DIALOG_GUIDE_EVENT 事件),
|
||||||
|
* 存在性检查落在它的入口(提示词栏)上,且必须等弹窗锚点真正挂载后才切步。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const COMPACT_GUIDE_DONE_KEY = "deerflow.compact-guide-finished";
|
||||||
|
const COMPACT_GUIDE_PENDING_KEY = "deerflow.compact-guide-pending";
|
||||||
|
|
||||||
|
/** 同页手动触发引导的窗口事件名(「设置和更多」→「界面使用引导」)。 */
|
||||||
|
export const COMPACT_GUIDE_START_EVENT = "compact-guide:start";
|
||||||
|
|
||||||
|
/** 引导步骤高亮目标选择器;各目标元素由所在组件打上同名 data 标记。 */
|
||||||
|
const TARGET = {
|
||||||
|
nav: '[data-compact-guide="sidebar-nav"]',
|
||||||
|
recentChats: '[data-compact-guide="recent-chats"]',
|
||||||
|
settingsMenu: '[data-compact-guide="settings-menu"]',
|
||||||
|
themeSwitcher: '[data-compact-guide="theme-switcher"]',
|
||||||
|
layoutMode: '[data-compact-guide="layout-mode"]',
|
||||||
|
promptBar: '[data-compact-guide="prompt-bar"]',
|
||||||
|
promptDialog: '[data-compact-guide="prompt-dialog"]',
|
||||||
|
promptInput: '[data-compact-guide="prompt-input"]',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// 等侧栏宽度过渡 / 落地态布局稳定后再启动:Guide 只在步骤切换时测量目标位置,
|
||||||
|
// 起步太早会把高亮框定位到过渡中的坐标且不会自行纠正。
|
||||||
|
const START_DELAY_MS = 800;
|
||||||
|
|
||||||
|
// 引导打开提示词管理弹窗后等待其锚点挂载的最长时间,超时视为弹窗打不开,
|
||||||
|
// 跳过该步避免引导卡死。
|
||||||
|
const PROMPT_DIALOG_TIMEOUT_MS = 4000;
|
||||||
|
|
||||||
|
function isBrowser(): boolean {
|
||||||
|
return typeof window !== "undefined";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCompactGuideDone(): boolean {
|
||||||
|
return isBrowser() && localStorage.getItem(COMPACT_GUIDE_DONE_KEY) === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function markCompactGuideDone() {
|
||||||
|
if (isBrowser()) localStorage.setItem(COMPACT_GUIDE_DONE_KEY, "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跨页触发:非问答页点击「界面使用引导」时先记录请求,导航到 /chats/new 后消费。 */
|
||||||
|
export function requestCompactGuideStart() {
|
||||||
|
if (isBrowser()) sessionStorage.setItem(COMPACT_GUIDE_PENDING_KEY, "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCompactGuideRequest(): boolean {
|
||||||
|
return (
|
||||||
|
isBrowser() &&
|
||||||
|
sessionStorage.getItem(COMPACT_GUIDE_PENDING_KEY) === "1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeCompactGuideRequest() {
|
||||||
|
if (isBrowser()) sessionStorage.removeItem(COMPACT_GUIDE_PENDING_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepSpec {
|
||||||
|
/** 高亮目标选择器;缺省(欢迎步骤 dialog 模式)以 body 兜底。 */
|
||||||
|
selector?: string;
|
||||||
|
/**
|
||||||
|
* 目标元素在引导过程中才渲染时(提示词管理弹窗由引导动态打开),存在性检查
|
||||||
|
* 改查这个已存在的「入口」元素 —— Guide 只在切到某步时才解析其元素,所以
|
||||||
|
* 该步可以留在步骤列表里,等锚点真正挂载后再切过去。
|
||||||
|
*/
|
||||||
|
dependsOn?: string;
|
||||||
|
mode: "popup" | "dialog";
|
||||||
|
placement: NonNullable<GuideStep["placement"]>;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_SPECS: StepSpec[] = [
|
||||||
|
{
|
||||||
|
mode: "dialog",
|
||||||
|
placement: "center",
|
||||||
|
title: "欢迎使用简洁模式",
|
||||||
|
body: "当前已隐藏顶部导航栏与全局侧边栏,为您留出更大的工作区。接下来花一分钟认识一下界面布局。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.nav,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "right",
|
||||||
|
title: "功能导航",
|
||||||
|
body: "新对话、智能体、技能、AI 写作、深度研究等主要入口都在左侧这里,点击即可切换(菜单项由管理员统一配置)。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.recentChats,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "right",
|
||||||
|
title: "聊天记录",
|
||||||
|
body: "最近的对话会保留在这里,点击任意一条即可继续上次的问答。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.settingsMenu,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "right-top",
|
||||||
|
title: "设置和更多",
|
||||||
|
body: "通知、设置、用户排行榜,以及菜单管理、技能管理等管理入口都收纳在这个菜单里。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.themeSwitcher,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "bottom-left",
|
||||||
|
title: "主题换肤",
|
||||||
|
body: "在这里一键切换红色、蓝色、深蓝等多套界面主题。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.layoutMode,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "bottom-left",
|
||||||
|
title: "切回全量模式",
|
||||||
|
body: "需要顶部导航栏和全局侧边栏时,从这里随时切换回「全量模式」。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.promptBar,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "top",
|
||||||
|
title: "常用提示词",
|
||||||
|
body: "输入框上方是常用提示词栏:点击任意提示词即可一键填入输入框,省去重复输入。最右侧的齿轮按钮用来管理这些提示词,点击「下一步」带你看一看。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.promptDialog,
|
||||||
|
dependsOn: TARGET.promptBar,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "top",
|
||||||
|
title: "管理常用提示词",
|
||||||
|
body: "这就是齿轮按钮打开的管理弹窗:填写名称与提示词内容、选择关联的智能体后保存,即可添加自己的常用提示词(与账号绑定、跨设备可用);下方列表中已保存的提示词可随时编辑、删除;顶部开关可让本账号只用自己保存的提示词。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: TARGET.promptInput,
|
||||||
|
mode: "popup",
|
||||||
|
placement: "top",
|
||||||
|
title: "开始提问",
|
||||||
|
body: "在输入框中输入问题,可先选择智能体与知识空间,发送后即可开始问答。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简洁模式引导。挂在 ChatPage:`eligible` 表示当前处于简洁模式(无全局
|
||||||
|
* chrome)且非嵌入/强制收起等特殊环境;`autoStart` 表示处于新对话落地态,
|
||||||
|
* 满足「首次进入」条件时自动演示一遍。
|
||||||
|
*/
|
||||||
|
export function CompactModeGuide({
|
||||||
|
eligible,
|
||||||
|
autoStart = false,
|
||||||
|
}: {
|
||||||
|
eligible: boolean;
|
||||||
|
autoStart?: boolean;
|
||||||
|
}) {
|
||||||
|
const [current, setCurrent] = useState(-1);
|
||||||
|
const specsRef = useRef<StepSpec[]>([]);
|
||||||
|
const stepsRef = useRef<GuideStep[]>([]);
|
||||||
|
const pollTimerRef = useRef<number | null>(null);
|
||||||
|
// 引导是否处于「已要求打开并接管提示词管理弹窗」状态(事件已发出 owner:true)。
|
||||||
|
const promptDialogTakenRef = useRef(false);
|
||||||
|
|
||||||
|
const clearPollTimer = useCallback(() => {
|
||||||
|
if (pollTimerRef.current !== null) {
|
||||||
|
window.clearInterval(pollTimerRef.current);
|
||||||
|
pollTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setPromptDialog = useCallback(
|
||||||
|
(open: boolean, takeOver: boolean) => {
|
||||||
|
promptDialogTakenRef.current = takeOver;
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(PROMPT_DIALOG_GUIDE_EVENT, {
|
||||||
|
detail: { open, owner: takeOver },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const releasePromptDialog = useCallback(() => {
|
||||||
|
if (!promptDialogTakenRef.current) return;
|
||||||
|
setPromptDialog(false, false);
|
||||||
|
}, [setPromptDialog]);
|
||||||
|
|
||||||
|
const start = useCallback(() => {
|
||||||
|
clearPollTimer();
|
||||||
|
releasePromptDialog();
|
||||||
|
const specs = STEP_SPECS.filter(
|
||||||
|
(spec) =>
|
||||||
|
!spec.selector || document.querySelector(spec.dependsOn ?? spec.selector),
|
||||||
|
);
|
||||||
|
if (specs.length === 0) return;
|
||||||
|
specsRef.current = specs;
|
||||||
|
stepsRef.current = specs.map((spec) => ({
|
||||||
|
element: spec.selector ?? (() => document.body),
|
||||||
|
mode: spec.mode,
|
||||||
|
placement: spec.placement,
|
||||||
|
title: spec.title,
|
||||||
|
body: spec.body,
|
||||||
|
}));
|
||||||
|
setCurrent(0);
|
||||||
|
}, [clearPollTimer, releasePromptDialog]);
|
||||||
|
|
||||||
|
// 自动引导:没显式结束过(没点过完成/跳过)时,每次落地页都演示。是否「已看过」
|
||||||
|
// 只由下方 finish 回调(完成/跳过)写入 —— 这里启动时绝不能提前写,否则中途刷新
|
||||||
|
// 又会被当成已看过。
|
||||||
|
const autoTriedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!eligible || !autoStart || autoTriedRef.current) return;
|
||||||
|
autoTriedRef.current = true;
|
||||||
|
if (isCompactGuideDone()) return;
|
||||||
|
const timer = setTimeout(start, START_DELAY_MS);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
// 组件在计时窗口内卸载(用户迅速离开页面)则下次落地再演示。
|
||||||
|
autoTriedRef.current = false;
|
||||||
|
};
|
||||||
|
}, [eligible, autoStart, start]);
|
||||||
|
|
||||||
|
// 跨页手动触发:非问答页点「界面使用引导」→ 记录请求 → 导航到 /chats/new → 此处启动。
|
||||||
|
// 请求只在定时器真正启动引导时才消费 —— StrictMode 下 effect 会 mount→cleanup→mount
|
||||||
|
// 双跑,若在检查时就删除,第二次跑就拿不到请求了。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!eligible) return;
|
||||||
|
if (!hasCompactGuideRequest()) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
consumeCompactGuideRequest();
|
||||||
|
start();
|
||||||
|
}, START_DELAY_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [eligible, start]);
|
||||||
|
|
||||||
|
// 同页手动触发:已在问答页时「界面使用引导」直接广播事件启动。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!eligible) return;
|
||||||
|
const handler = () => start();
|
||||||
|
window.addEventListener(COMPACT_GUIDE_START_EVENT, handler);
|
||||||
|
return () => window.removeEventListener(COMPACT_GUIDE_START_EVENT, handler);
|
||||||
|
}, [eligible, start]);
|
||||||
|
|
||||||
|
// 中途卸载(用户离开页面)也要归还弹窗控制权并停止等待。
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearPollTimer();
|
||||||
|
releasePromptDialog();
|
||||||
|
};
|
||||||
|
}, [clearPollTimer, releasePromptDialog]);
|
||||||
|
|
||||||
|
// 受控切步:进入提示词弹窗步骤前先打开弹窗并等锚点挂载(Guide 切步时才解析
|
||||||
|
// 元素,元素必须已存在);离开该步 / 跳过 / 完成时收回弹窗。跳过和完成时
|
||||||
|
// onChange 也会收到 -1,与 finish 回调各自幂等。
|
||||||
|
const goTo = (next: number) => {
|
||||||
|
const specs = specsRef.current;
|
||||||
|
clearPollTimer();
|
||||||
|
if (next < 0 || next >= specs.length) {
|
||||||
|
releasePromptDialog();
|
||||||
|
setCurrent(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (specs[next].selector === TARGET.promptDialog) {
|
||||||
|
setPromptDialog(true, true);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
pollTimerRef.current = window.setInterval(() => {
|
||||||
|
if (document.querySelector(TARGET.promptDialog)) {
|
||||||
|
clearPollTimer();
|
||||||
|
setCurrent(next);
|
||||||
|
} else if (Date.now() - startedAt > PROMPT_DIALOG_TIMEOUT_MS) {
|
||||||
|
clearPollTimer();
|
||||||
|
releasePromptDialog();
|
||||||
|
goTo(next + 1 >= specs.length ? -1 : next + 1);
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
releasePromptDialog();
|
||||||
|
setCurrent(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 完成 / 跳过是用户显式结束引导 —— 只有这两种情况才记忆「已看过」;中途刷新或
|
||||||
|
// 离开不记忆,下次进入简洁模式问答页还会再演示。
|
||||||
|
const finish = useCallback(() => {
|
||||||
|
clearPollTimer();
|
||||||
|
releasePromptDialog();
|
||||||
|
markCompactGuideDone();
|
||||||
|
setCurrent(-1);
|
||||||
|
}, [clearPollTimer, releasePromptDialog]);
|
||||||
|
|
||||||
|
if (!eligible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Guide
|
||||||
|
current={current}
|
||||||
|
steps={stepsRef.current}
|
||||||
|
onChange={goTo}
|
||||||
|
onFinish={finish}
|
||||||
|
onSkip={finish}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Wrench, X } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { useWorkspaceCompactNavItems } from "./workspace-nav-chat-list";
|
||||||
|
|
||||||
|
const TOOLBOX_ICON_TONES = [
|
||||||
|
"bg-blue-500/12 text-blue-600 dark:text-blue-400",
|
||||||
|
"bg-violet-500/12 text-violet-600 dark:text-violet-400",
|
||||||
|
"bg-emerald-500/12 text-emerald-600 dark:text-emerald-400",
|
||||||
|
"bg-amber-500/14 text-amber-600 dark:text-amber-400",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简洁模式右侧工具箱。
|
||||||
|
*
|
||||||
|
* 菜单来源与左侧简洁菜单、设置和更多完全一致,由管理员在菜单管理中把项目
|
||||||
|
* 放到 ``toolbox`` 泳道。默认展开常显(不再藏进右侧按钮后面);点 X 或 Esc
|
||||||
|
* 收起后右侧出现竖排按钮,可再次展开。
|
||||||
|
*/
|
||||||
|
export function CompactToolbox() {
|
||||||
|
const { toolboxItems } = useWorkspaceCompactNavItems();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (toolboxItems.length === 0) return null;
|
||||||
|
|
||||||
|
const closeDrawer = () => {
|
||||||
|
setOpen(false);
|
||||||
|
requestAnimationFrame(() => triggerRef.current?.focus());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
aria-label="打开工具箱"
|
||||||
|
aria-controls="compact-toolbox-panel"
|
||||||
|
aria-expanded={open}
|
||||||
|
title="打开工具箱"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className={cn(
|
||||||
|
"fixed right-0 top-1/2 z-[80] flex size-16 -translate-y-1/2 flex-col items-center justify-center gap-1 rounded-l-2xl border border-r-0 border-primary/25 bg-primary text-primary-foreground shadow-xl transition-[transform,opacity,background-color] duration-200 hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
open && "pointer-events-none translate-x-full opacity-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Wrench className="size-6 shrink-0" />
|
||||||
|
<span className="text-xs font-semibold leading-4">工具箱</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
id="compact-toolbox-panel"
|
||||||
|
aria-label="工具箱"
|
||||||
|
aria-hidden={!open}
|
||||||
|
inert={!open}
|
||||||
|
className={cn(
|
||||||
|
"fixed right-0 top-1/2 z-[100] flex max-h-[calc(100vh-32px)] w-32 max-w-[calc(100vw-48px)] -translate-y-1/2 flex-col overflow-hidden rounded-l-2xl bg-background text-foreground shadow-2xl transition-transform duration-300 ease-out",
|
||||||
|
open
|
||||||
|
? "pointer-events-auto translate-x-0"
|
||||||
|
: "pointer-events-none translate-x-full",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<header className="relative flex h-12 shrink-0 items-center justify-center px-2">
|
||||||
|
<h2 className="text-sm font-semibold">工具箱</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="收起工具箱"
|
||||||
|
title="收起"
|
||||||
|
onClick={closeDrawer}
|
||||||
|
className="absolute right-2 flex size-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
aria-label="工具箱菜单"
|
||||||
|
className="flex min-h-0 flex-col items-center gap-2.5 overflow-y-auto bg-muted/20 p-2"
|
||||||
|
>
|
||||||
|
{toolboxItems.map((item, index) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = item.match(pathname);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"group flex h-24 w-24 aspect-square shrink-0 flex-col items-center justify-center gap-1.5 rounded-xl p-2 text-center transition-[color,background-color,transform] hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
active
|
||||||
|
? "bg-primary/12 text-primary ring-1 ring-inset ring-primary/25"
|
||||||
|
: "bg-muted/55 text-card-foreground hover:bg-primary/8 hover:text-primary",
|
||||||
|
)}
|
||||||
|
onClick={() => navigate(item.path ?? "/page/workspace/chats/new")}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform group-hover:scale-105",
|
||||||
|
active
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: TOOLBOX_ICON_TONES[index % TOOLBOX_ICON_TONES.length],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-6" strokeWidth={1.8} />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="line-clamp-2 w-full text-center text-xs font-medium leading-4"
|
||||||
|
title={item.label}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,6 +42,13 @@ import {
|
|||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简洁模式引导控制提示词管理弹窗的窗口事件。detail 为
|
||||||
|
* `{ open: boolean; owner?: boolean }`:`owner` 表示弹窗由引导接管,期间 ESC /
|
||||||
|
* 点击遮罩等关闭请求一律忽略,直到引导以 `owner: false` 收回控制权。
|
||||||
|
*/
|
||||||
|
export const PROMPT_DIALOG_GUIDE_EVENT = "compact-guide:prompt-dialog";
|
||||||
|
|
||||||
export function CustomPromptBar({
|
export function CustomPromptBar({
|
||||||
onInsert,
|
onInsert,
|
||||||
className,
|
className,
|
||||||
@@ -56,10 +63,23 @@ export function CustomPromptBar({
|
|||||||
(prompt) => !prompt.agent_id || prompt.agent_id === currentAgentId,
|
(prompt) => !prompt.agent_id || prompt.agent_id === currentAgentId,
|
||||||
);
|
);
|
||||||
const [manageOpen, setManageOpen] = useState(false);
|
const [manageOpen, setManageOpen] = useState(false);
|
||||||
|
const guideOwnsDialogRef = useRef(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const [canLeft, setCanLeft] = useState(false);
|
const [canLeft, setCanLeft] = useState(false);
|
||||||
const [canRight, setCanRight] = useState(false);
|
const [canRight, setCanRight] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (event: Event) => {
|
||||||
|
const detail = (
|
||||||
|
event as CustomEvent<{ open?: boolean; owner?: boolean }>
|
||||||
|
).detail;
|
||||||
|
guideOwnsDialogRef.current = Boolean(detail?.owner);
|
||||||
|
setManageOpen(Boolean(detail?.open));
|
||||||
|
};
|
||||||
|
window.addEventListener(PROMPT_DIALOG_GUIDE_EVENT, handler);
|
||||||
|
return () => window.removeEventListener(PROMPT_DIALOG_GUIDE_EVENT, handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const updateArrows = useCallback(() => {
|
const updateArrows = useCallback(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -81,7 +101,10 @@ export function CustomPromptBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cn("flex min-w-0 items-center gap-1", className)}>
|
<div
|
||||||
|
className={cn("flex min-w-0 items-center gap-1", className)}
|
||||||
|
data-compact-guide="prompt-bar"
|
||||||
|
>
|
||||||
{prompts.length > 0 && (
|
{prompts.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{overflowing && (
|
{overflowing && (
|
||||||
@@ -148,7 +171,14 @@ export function CustomPromptBar({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ManagePromptsDialog open={manageOpen} onOpenChange={setManageOpen} />
|
<ManagePromptsDialog
|
||||||
|
open={manageOpen}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
// 引导演示期间接管弹窗:ESC / 点击遮罩不关闭,避免高亮目标消失导致引导报错。
|
||||||
|
if (!next && guideOwnsDialogRef.current) return;
|
||||||
|
setManageOpen(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -257,7 +287,10 @@ function ManagePromptsDialog({
|
|||||||
onOpenChange(next);
|
onOpenChange(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
|
<DialogContent
|
||||||
|
className="max-h-[85vh] overflow-y-auto sm:max-w-xl"
|
||||||
|
data-compact-guide="prompt-dialog"
|
||||||
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>管理常用提示词</DialogTitle>
|
<DialogTitle>管理常用提示词</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ export function FlipDisplay({
|
|||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("relative overflow-hidden", className)}>
|
<div className={cn("relative min-w-0 overflow-hidden", className)}>
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={uniqueKey}
|
key={uniqueKey}
|
||||||
|
className="min-w-0 max-w-full"
|
||||||
initial={{ y: 8, opacity: 0 }}
|
initial={{ y: 8, opacity: 0 }}
|
||||||
animate={{ y: 2, opacity: 1 }}
|
animate={{ y: 2, opacity: 1 }}
|
||||||
exit={{ y: -8, opacity: 0 }}
|
exit={{ y: -8, opacity: 0 }}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
* 共享的 WorkspaceNavChatList / FrequentAgentsList / RecentChatList 里塞会越来越难
|
* 共享的 WorkspaceNavChatList / FrequentAgentsList / RecentChatList 里塞会越来越难
|
||||||
* 维护,故单独成一个组件,只在 `/page/workspace/chats*` 的全量布局下挂载,互不影响。
|
* 维护,故单独成一个组件,只在 `/page/workspace/chats*` 的全量布局下挂载,互不影响。
|
||||||
*
|
*
|
||||||
* 三块结构(自上而下):
|
* 可挂在 workspace / strategy 两个普通问答路由下;导航目标由 RouteBaseProvider
|
||||||
|
* 决定。三块结构(自上而下):
|
||||||
* 1. 「新对话」胶囊按钮 + 收起整列的圆形按钮;
|
* 1. 「新对话」胶囊按钮 + 收起整列的圆形按钮;
|
||||||
* 2. 「常用智能体」—— 彩色图标卡(名称 + 两行简介,命中为选中态:主色描边 + 右侧主色条);
|
* 2. 「常用智能体」—— 彩色图标卡(名称 + 两行简介,命中为选中态:主色描边 + 右侧主色条);
|
||||||
* 3. 「最近的对话」—— 计数徽标 + 折叠、历史搜索框、会话列表(悬浮「更多」菜单:
|
* 3. 「最近的对话」—— 计数徽标 + 折叠、历史搜索框、会话列表(悬浮「更多」菜单:
|
||||||
@@ -76,6 +77,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,
|
||||||
@@ -84,6 +86,7 @@ import {
|
|||||||
titleOfThread,
|
titleOfThread,
|
||||||
} from "@/core/threads/utils";
|
} from "@/core/threads/utils";
|
||||||
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
|
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
|
||||||
|
import { useRouteBase } from "@/core/route-base";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { isIMEComposing } from "@/lib/ime";
|
import { isIMEComposing } from "@/lib/ime";
|
||||||
@@ -92,11 +95,6 @@ import { cn } from "@/lib/utils";
|
|||||||
import { agentColor } from "./agent-icon-color";
|
import { agentColor } from "./agent-icon-color";
|
||||||
import { SidebarCollapseButton } from "./workspace-header";
|
import { SidebarCollapseButton } from "./workspace-header";
|
||||||
|
|
||||||
const NEW_CHAT_PATH = "/page/workspace/chats/new";
|
|
||||||
/** 「更多」常用智能体跳转目标:智能体管理页。 */
|
|
||||||
const MORE_AGENTS_HREF = "/page/workspace/agents";
|
|
||||||
/** 「更多最近对话」跳转目标。 */
|
|
||||||
const MORE_CHATS_HREF = "/page/workspace/chats";
|
|
||||||
/** 常用智能体最多展示个数。 */
|
/** 常用智能体最多展示个数。 */
|
||||||
const MAX_FREQUENT = 4;
|
const MAX_FREQUENT = 4;
|
||||||
|
|
||||||
@@ -141,6 +139,7 @@ function SectionLabel({
|
|||||||
|
|
||||||
export function GeneralQaSidebar() {
|
export function GeneralQaSidebar() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const routeBase = useRouteBase();
|
||||||
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
|
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
@@ -187,9 +186,14 @@ export function GeneralQaSidebar() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const activeAgentId = searchParams.get("agent");
|
const activeAgentId = searchParams.get("agent");
|
||||||
const currentSearch = searchParams.toString();
|
const currentSearch = searchParams.toString();
|
||||||
const newChatHref = buildFullChatNavigationPath(NEW_CHAT_PATH, currentSearch);
|
const chatsBasePath = `${routeBase}/chats`;
|
||||||
const moreAgentsHref = buildFullChatNavigationPath(MORE_AGENTS_HREF, currentSearch);
|
const newChatPath = `${chatsBasePath}/new`;
|
||||||
const moreChatsHref = buildFullChatNavigationPath(MORE_CHATS_HREF, currentSearch);
|
const newChatHref = buildFullChatNavigationPath(newChatPath, currentSearch);
|
||||||
|
const moreAgentsHref = buildFullChatNavigationPath(
|
||||||
|
`${routeBase}/agents`,
|
||||||
|
currentSearch,
|
||||||
|
);
|
||||||
|
const moreChatsHref = buildFullChatNavigationPath(chatsBasePath, currentSearch);
|
||||||
|
|
||||||
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
|
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
|
||||||
// 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。
|
// 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。
|
||||||
@@ -198,19 +202,18 @@ export function GeneralQaSidebar() {
|
|||||||
(agentId: string) => {
|
(agentId: string) => {
|
||||||
navigate(
|
navigate(
|
||||||
buildFullChatNavigationPath(
|
buildFullChatNavigationPath(
|
||||||
`${NEW_CHAT_PATH}?agent=${encodeURIComponent(agentId)}`,
|
routeBase === "/page/workspace"
|
||||||
|
? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
|
||||||
|
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
|
||||||
currentSearch,
|
currentSearch,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[currentSearch, navigate],
|
[currentSearch, navigate, newChatPath, routeBase],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── 最近的对话取数 + 搜索 ──
|
// ── 最近的对话取数 + 搜索 ──
|
||||||
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } = useParams<{
|
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||||
thread_id: string;
|
|
||||||
agent_id?: string;
|
|
||||||
}>();
|
|
||||||
const { data: threads = [] } = useThreads();
|
const { data: threads = [] } = useThreads();
|
||||||
const { mutate: deleteThread } = useDeleteThread();
|
const { mutate: deleteThread } = useDeleteThread();
|
||||||
const { mutate: renameThread } = useRenameThread();
|
const { mutate: renameThread } = useRenameThread();
|
||||||
@@ -223,17 +226,19 @@ export function GeneralQaSidebar() {
|
|||||||
const [renameValue, setRenameValue] = useState("");
|
const [renameValue, setRenameValue] = useState("");
|
||||||
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
|
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const pathForThread = useCallback(
|
||||||
|
(thread: AgentThread) =>
|
||||||
|
isBootstrapThread(thread)
|
||||||
|
? listPathOfThread(thread)
|
||||||
|
: pathOfThread(thread, undefined, routeBase),
|
||||||
|
[routeBase],
|
||||||
|
);
|
||||||
|
|
||||||
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],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -259,21 +264,22 @@ export function GeneralQaSidebar() {
|
|||||||
deleteThread({ threadId });
|
deleteThread({ threadId });
|
||||||
if (threadId === threadIdFromPath) {
|
if (threadId === threadIdFromPath) {
|
||||||
const threadIndex = threads.findIndex((th) => th.thread_id === threadId);
|
const threadIndex = threads.findIndex((th) => th.thread_id === threadId);
|
||||||
let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath });
|
let nextThreadPath = newChatPath;
|
||||||
if (threadIndex > -1) {
|
if (threadIndex > -1) {
|
||||||
if (threads[threadIndex + 1]) {
|
if (threads[threadIndex + 1]) {
|
||||||
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
|
nextThreadPath = pathForThread(threads[threadIndex + 1]!);
|
||||||
} else if (threads[threadIndex - 1]) {
|
} else if (threads[threadIndex - 1]) {
|
||||||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
|
nextThreadPath = pathForThread(threads[threadIndex - 1]!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void router.push(buildFullChatNavigationPath(nextThreadPath, currentSearch));
|
void router.push(buildFullChatNavigationPath(nextThreadPath, currentSearch));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
agentIdFromPath,
|
|
||||||
currentSearch,
|
currentSearch,
|
||||||
deleteThread,
|
deleteThread,
|
||||||
|
newChatPath,
|
||||||
|
pathForThread,
|
||||||
router,
|
router,
|
||||||
threadIdFromPath,
|
threadIdFromPath,
|
||||||
threads,
|
threads,
|
||||||
@@ -472,7 +478,7 @@ export function GeneralQaSidebar() {
|
|||||||
) : (
|
) : (
|
||||||
filteredThreads.map((thread) => {
|
filteredThreads.map((thread) => {
|
||||||
const href = buildFullChatNavigationPath(
|
const href = buildFullChatNavigationPath(
|
||||||
listPathOfThread(thread),
|
pathForThread(thread),
|
||||||
currentSearch,
|
currentSearch,
|
||||||
);
|
);
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
BookMarkedIcon,
|
BookMarkedIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
|
FolderTreeIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
LightbulbIcon,
|
LightbulbIcon,
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
@@ -97,7 +98,7 @@ import {
|
|||||||
lockThreadRagMode,
|
lockThreadRagMode,
|
||||||
useLocalSettings,
|
useLocalSettings,
|
||||||
} from "@/core/settings";
|
} from "@/core/settings";
|
||||||
|
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||||
|
|
||||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||||
import { useThread } from "./messages/context";
|
import { useThread } from "./messages/context";
|
||||||
@@ -141,6 +142,8 @@ export function InputBox({
|
|||||||
assistantId,
|
assistantId,
|
||||||
hideAdminRecommendedQuestions,
|
hideAdminRecommendedQuestions,
|
||||||
compact,
|
compact,
|
||||||
|
hideContextTools,
|
||||||
|
hideMemoryTool = true,
|
||||||
...props
|
...props
|
||||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||||
assistantId?: string | null;
|
assistantId?: string | null;
|
||||||
@@ -148,6 +151,14 @@ export function InputBox({
|
|||||||
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
|
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
|
||||||
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
|
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
|
||||||
|
* 受控场景用;文件/模型等其他按钮不受影响)。 */
|
||||||
|
hideContextTools?: boolean;
|
||||||
|
/**
|
||||||
|
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
|
||||||
|
* 写作和参考文献开关不受影响。
|
||||||
|
*/
|
||||||
|
hideMemoryTool?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
context: Omit<
|
context: Omit<
|
||||||
AgentThreadContext,
|
AgentThreadContext,
|
||||||
@@ -219,6 +230,9 @@ export function InputBox({
|
|||||||
const { thread, isMock } = useThread();
|
const { thread, isMock } = useThread();
|
||||||
const { textInput } = usePromptInputController();
|
const { textInput } = usePromptInputController();
|
||||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
|
const referenceModeEntryEnabled =
|
||||||
|
citationDisplay?.reference_mode_enabled ?? false;
|
||||||
|
|
||||||
const [followups, setFollowups] = useState<string[]>([]);
|
const [followups, setFollowups] = useState<string[]>([]);
|
||||||
const [followupsHidden, setFollowupsHidden] = useState(false);
|
const [followupsHidden, setFollowupsHidden] = useState(false);
|
||||||
@@ -375,6 +389,14 @@ export function InputBox({
|
|||||||
});
|
});
|
||||||
}, [context, onContextChange]);
|
}, [context, onContextChange]);
|
||||||
|
|
||||||
|
const artifactDirectoryModeEnabled =
|
||||||
|
localSettings.display.artifactDirectoryModeEnabled ?? false;
|
||||||
|
const handleArtifactDirectoryModeToggle = useCallback(() => {
|
||||||
|
setLocalSettings("display", {
|
||||||
|
artifactDirectoryModeEnabled: !artifactDirectoryModeEnabled,
|
||||||
|
});
|
||||||
|
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
||||||
|
|
||||||
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
||||||
const handleMemoryInjectionToggle = useCallback(() => {
|
const handleMemoryInjectionToggle = useCallback(() => {
|
||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
@@ -736,11 +758,11 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!frontendRagActive && <Tooltip
|
{!frontendRagActive && !hideContextTools && <Tooltip
|
||||||
content={
|
content={
|
||||||
context.writing_mode
|
context.writing_mode
|
||||||
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
|
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
|
||||||
: "开启写作模式:最终输出保存为 Markdown 文件并进入画布"
|
: "开启写作模式:对话照常进行,交付时由深度研究管线撰写报告并保存为 Markdown 进入画布"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
@@ -763,6 +785,35 @@ export function InputBox({
|
|||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>}
|
</Tooltip>}
|
||||||
{!frontendRagActive && <Tooltip
|
{!frontendRagActive && <Tooltip
|
||||||
|
content={
|
||||||
|
artifactDirectoryModeEnabled
|
||||||
|
? "文件模式已开启:右侧会显示当前对话生成的全部文件目录"
|
||||||
|
: "开启文件模式:在右侧目录中浏览当前对话生成的全部文件"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PromptInputButton
|
||||||
|
type="button"
|
||||||
|
aria-pressed={artifactDirectoryModeEnabled}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"gap-1.5! rounded-full px-3! shadow-none",
|
||||||
|
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||||
|
artifactDirectoryModeEnabled
|
||||||
|
? "border border-sky-300 bg-sky-50 text-sky-700 hover:bg-sky-100 dark:border-sky-600 dark:bg-sky-950/45 dark:text-sky-200"
|
||||||
|
: compact
|
||||||
|
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||||
|
)}
|
||||||
|
onClick={handleArtifactDirectoryModeToggle}
|
||||||
|
>
|
||||||
|
{artifactDirectoryModeEnabled && (
|
||||||
|
<CheckIcon className="size-3 text-sky-700 dark:text-sky-200" />
|
||||||
|
)}
|
||||||
|
<FolderTreeIcon className="size-3" />
|
||||||
|
<span className="text-xs font-normal">文件</span>
|
||||||
|
</PromptInputButton>
|
||||||
|
</Tooltip>}
|
||||||
|
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
|
||||||
content={
|
content={
|
||||||
memoryInjectionEnabled
|
memoryInjectionEnabled
|
||||||
? "记忆注入已开启:对话前会注入历史记忆"
|
? "记忆注入已开启:对话前会注入历史记忆"
|
||||||
@@ -788,7 +839,7 @@ export function InputBox({
|
|||||||
<span className="text-xs font-normal">记忆</span>
|
<span className="text-xs font-normal">记忆</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>}
|
</Tooltip>}
|
||||||
{!frontendRagActive && <Tooltip
|
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
|
||||||
content={
|
content={
|
||||||
ragModeEnabled
|
ragModeEnabled
|
||||||
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { BookOpen, Database } from "lucide-react";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||||
|
import {
|
||||||
|
loadKnowledgeBaseProductVersion,
|
||||||
|
loadOrdinaryKnowledgeLanding,
|
||||||
|
saveOrdinaryKnowledgeLanding,
|
||||||
|
type OrdinaryKnowledgeLanding,
|
||||||
|
type KnowledgeBaseProductVersion,
|
||||||
|
updateKnowledgeBaseProductVersion,
|
||||||
|
} from "@/core/system-settings/api";
|
||||||
|
|
||||||
|
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
|
||||||
|
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
|
||||||
|
|
||||||
|
export function useOrdinaryKnowledgeLanding() {
|
||||||
|
return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 });
|
||||||
|
}
|
||||||
|
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
|
||||||
|
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
|
||||||
|
|
||||||
|
export function useKnowledgeBaseProductVersion() {
|
||||||
|
return useQuery({ queryKey: KNOWLEDGE_PRODUCT_KEY, queryFn: loadKnowledgeBaseProductVersion, staleTime: 60_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KnowledgeProductNav() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const client = useQueryClient();
|
||||||
|
const version = useKnowledgeBaseProductVersion();
|
||||||
|
const landing = useOrdinaryKnowledgeLanding();
|
||||||
|
const saveLanding = useMutation({
|
||||||
|
mutationFn: saveOrdinaryKnowledgeLanding,
|
||||||
|
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
const runtime = useLlmWikiRuntime();
|
||||||
|
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
|
||||||
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: updateKnowledgeBaseProductVersion,
|
||||||
|
onSuccess: (value) => {
|
||||||
|
client.setQueryData(KNOWLEDGE_PRODUCT_KEY, value);
|
||||||
|
toast.success(`默认知识库体验已切换为 ${value}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const assistant = location.pathname.includes("/knowledge/assistant");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
|
||||||
|
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||||
|
<BookOpen />普通知识库
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
||||||
|
<Database />助手知识库
|
||||||
|
</Button>
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
普通知识库默认页面
|
||||||
|
<Select disabled={landing.isLoading || saveLanding.isPending} value={landing.data ?? "wiki"} onValueChange={(value) => saveLanding.mutate(value as OrdinaryKnowledgeLanding)}>
|
||||||
|
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
|
||||||
|
</Select>
|
||||||
|
默认产品版本
|
||||||
|
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
|
||||||
|
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent><SelectItem value="v1">v1 普通知识库</SelectItem><SelectItem value="v2">v2 助手知识库</SelectItem></SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,10 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
type AssistantKnowledgeBase,
|
||||||
|
useAssistantKnowledgeBases,
|
||||||
|
} from "@/strategy-components/api/assistant-knowledge";
|
||||||
import {
|
import {
|
||||||
type LlmWikiKnowledgeBase,
|
type LlmWikiKnowledgeBase,
|
||||||
useAgentLlmWikiKnowledgeBases,
|
useAgentLlmWikiKnowledgeBases,
|
||||||
@@ -66,28 +70,50 @@ function SpaceIcon({ item }: { item: LlmWikiKnowledgeBase }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AssistantSpaceIcon({ item }: { item: AssistantKnowledgeBase }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex size-7 shrink-0 items-center justify-center rounded-md",
|
||||||
|
item.is_global ? "bg-amber-500/10 text-amber-600 dark:text-amber-400" : "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Database className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SelectionContent({
|
function SelectionContent({
|
||||||
rows,
|
rows,
|
||||||
|
assistantRows,
|
||||||
value,
|
value,
|
||||||
|
assistantValue,
|
||||||
onChange,
|
onChange,
|
||||||
|
onAssistantChange,
|
||||||
search,
|
search,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
source,
|
source,
|
||||||
variant,
|
variant,
|
||||||
|
readOnly,
|
||||||
}: {
|
}: {
|
||||||
rows: LlmWikiKnowledgeBase[];
|
rows: LlmWikiKnowledgeBase[];
|
||||||
|
assistantRows: AssistantKnowledgeBase[];
|
||||||
value: string[];
|
value: string[];
|
||||||
|
assistantValue: string[];
|
||||||
onChange: (value: string[]) => void;
|
onChange: (value: string[]) => void;
|
||||||
|
onAssistantChange: (value: string[]) => void;
|
||||||
search: string;
|
search: string;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: unknown;
|
error: unknown;
|
||||||
source: SelectorSource;
|
source: SelectorSource;
|
||||||
variant: SelectorVariant;
|
variant: SelectorVariant;
|
||||||
|
readOnly: boolean;
|
||||||
}) {
|
}) {
|
||||||
const selected = useMemo(() => new Set(value), [value]);
|
const selected = useMemo(() => new Set(value), [value]);
|
||||||
|
const selectedAssistant = useMemo(() => new Set(assistantValue), [assistantValue]);
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const query = search.trim().toLocaleLowerCase();
|
const query = search.trim().toLocaleLowerCase();
|
||||||
if (!query) return rows;
|
if (!query) return rows;
|
||||||
@@ -95,10 +121,28 @@ function SelectionContent({
|
|||||||
`${item.name} ${item.description}`.toLocaleLowerCase().includes(query),
|
`${item.name} ${item.description}`.toLocaleLowerCase().includes(query),
|
||||||
);
|
);
|
||||||
}, [rows, search]);
|
}, [rows, search]);
|
||||||
|
const filteredAssistant = useMemo(() => {
|
||||||
|
const query = search.trim().toLocaleLowerCase();
|
||||||
|
if (!query) return assistantRows;
|
||||||
|
return assistantRows.filter((item) =>
|
||||||
|
`${item.name} ${item.description ?? ""}`.toLocaleLowerCase().includes(query),
|
||||||
|
);
|
||||||
|
}, [assistantRows, search]);
|
||||||
const groups = useMemo(() => groupRows(filtered), [filtered]);
|
const groups = useMemo(() => groupRows(filtered), [filtered]);
|
||||||
const toggle = (id: string) => {
|
const toggle = (id: string) => {
|
||||||
|
if (readOnly) return;
|
||||||
onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]);
|
onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]);
|
||||||
};
|
};
|
||||||
|
const toggleAssistant = (id: string) => {
|
||||||
|
if (readOnly) return;
|
||||||
|
onAssistantChange(
|
||||||
|
selectedAssistant.has(id)
|
||||||
|
? assistantValue.filter((item) => item !== id)
|
||||||
|
: [...assistantValue, id],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const selectedCount = value.length + assistantValue.length;
|
||||||
|
const hasRows = groups.length > 0 || filteredAssistant.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -124,7 +168,7 @@ function SelectionContent({
|
|||||||
<div className="m-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
|
<div className="m-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
|
||||||
{error instanceof Error ? error.message : "知识空间读取失败"}
|
{error instanceof Error ? error.message : "知识空间读取失败"}
|
||||||
</div>
|
</div>
|
||||||
) : groups.length === 0 ? (
|
) : !hasRows ? (
|
||||||
<div className="flex h-28 items-center justify-center px-4 text-center text-xs text-muted-foreground">
|
<div className="flex h-28 items-center justify-center px-4 text-center text-xs text-muted-foreground">
|
||||||
{search
|
{search
|
||||||
? "没有匹配的知识空间"
|
? "没有匹配的知识空间"
|
||||||
@@ -133,87 +177,185 @@ function SelectionContent({
|
|||||||
: "当前账号暂无可用知识空间"}
|
: "当前账号暂无可用知识空间"}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
groups.map((group, groupIndex) => (
|
<>
|
||||||
<div key={group.key}>
|
{groups.map((group, groupIndex) => (
|
||||||
{groupIndex > 0 && (
|
<div key={group.key}>
|
||||||
variant === "popover"
|
{groupIndex > 0 && (
|
||||||
? <DropdownMenuSeparator className="my-1.5" />
|
variant === "popover"
|
||||||
: <div className="my-1.5 border-t" />
|
? <DropdownMenuSeparator className="my-1.5" />
|
||||||
)}
|
: <div className="my-1.5 border-t" />
|
||||||
{variant === "popover" ? (
|
)}
|
||||||
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
|
{variant === "popover" ? (
|
||||||
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
|
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
|
||||||
</DropdownMenuLabel>
|
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
|
||||||
) : (
|
</DropdownMenuLabel>
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
|
|
||||||
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{group.rows.map((item) => {
|
|
||||||
const rowContent = (
|
|
||||||
<>
|
|
||||||
<SpaceIcon item={item} />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate text-xs font-medium">{item.name}</div>
|
|
||||||
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
|
|
||||||
{item.type === "faq" ? "FAQ" : "文档库"} · {item.knowledge_count ?? 0} 项知识
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{item.publication_status === "published" && (
|
|
||||||
<Badge variant="outline" className="h-4 px-1 text-[9px]">公共</Badge>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
return variant === "popover" ? (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={item.id}
|
|
||||||
checked={selected.has(item.id)}
|
|
||||||
onCheckedChange={() => toggle(item.id)}
|
|
||||||
onSelect={(event) => event.preventDefault()}
|
|
||||||
className={cn(
|
|
||||||
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
|
|
||||||
selected.has(item.id) && "bg-primary/8 focus:bg-primary/12",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{rowContent}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
) : (
|
) : (
|
||||||
<button
|
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
|
||||||
key={item.id}
|
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
|
||||||
type="button"
|
</div>
|
||||||
role="checkbox"
|
)}
|
||||||
aria-checked={selected.has(item.id)}
|
{group.rows.map((item) => {
|
||||||
onClick={() => toggle(item.id)}
|
const rowContent = (
|
||||||
className={cn(
|
<>
|
||||||
"my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary",
|
<SpaceIcon item={item} />
|
||||||
selected.has(item.id) && "bg-primary/8",
|
<div className="min-w-0 flex-1">
|
||||||
)}
|
<div className="truncate text-xs font-medium">{item.name}</div>
|
||||||
>
|
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
|
||||||
<span className={cn(
|
{item.type === "faq" ? "FAQ" : "文档库"} · {item.knowledge_count ?? 0} 项知识
|
||||||
"flex size-4 shrink-0 items-center justify-center rounded border",
|
</div>
|
||||||
selected.has(item.id) && "border-primary bg-primary text-primary-foreground",
|
</div>
|
||||||
)}>
|
{item.publication_status === "published" && (
|
||||||
{selected.has(item.id) && <Check className="size-3" />}
|
<Badge variant="outline" className="h-4 px-1 text-[9px]">公共</Badge>
|
||||||
</span>
|
)}
|
||||||
{rowContent}
|
</>
|
||||||
</button>
|
);
|
||||||
);
|
return variant === "popover" ? (
|
||||||
})}
|
<DropdownMenuCheckboxItem
|
||||||
</div>
|
key={item.id}
|
||||||
))
|
checked={selected.has(item.id)}
|
||||||
|
disabled={readOnly}
|
||||||
|
onCheckedChange={() => toggle(item.id)}
|
||||||
|
onSelect={(event) => event.preventDefault()}
|
||||||
|
className={cn(
|
||||||
|
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
|
||||||
|
selected.has(item.id) && "bg-primary/8 focus:bg-primary/12",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{rowContent}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={selected.has(item.id)}
|
||||||
|
disabled={readOnly}
|
||||||
|
onClick={() => toggle(item.id)}
|
||||||
|
className={cn(
|
||||||
|
"my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary",
|
||||||
|
selected.has(item.id) && "bg-primary/8",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
"flex size-4 shrink-0 items-center justify-center rounded border",
|
||||||
|
selected.has(item.id) && "border-primary bg-primary text-primary-foreground",
|
||||||
|
)}>
|
||||||
|
{selected.has(item.id) && <Check className="size-3" />}
|
||||||
|
</span>
|
||||||
|
{rowContent}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filteredAssistant.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{(groups.length > 0) && (
|
||||||
|
variant === "popover"
|
||||||
|
? <DropdownMenuSeparator className="my-1.5" />
|
||||||
|
: <div className="my-1.5 border-t" />
|
||||||
|
)}
|
||||||
|
{variant === "popover" ? (
|
||||||
|
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
|
||||||
|
<Database className="size-3" />助手知识库
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
|
||||||
|
<Database className="size-3" />助手知识库
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filteredAssistant.map((item) => {
|
||||||
|
const rowContent = (
|
||||||
|
<>
|
||||||
|
<AssistantSpaceIcon item={item} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-xs font-medium">{item.name}</div>
|
||||||
|
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
|
||||||
|
Wiki · {item.page_count ?? 0} 页 · {item.entity_count ?? 0} 实体
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="h-4 px-1 text-[9px]">
|
||||||
|
{item.is_global ? "总库" : "助手"}
|
||||||
|
</Badge>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return variant === "popover" ? (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={`assistant:${item.id}`}
|
||||||
|
checked={selectedAssistant.has(item.id)}
|
||||||
|
disabled={readOnly}
|
||||||
|
onCheckedChange={() => toggleAssistant(item.id)}
|
||||||
|
onSelect={(event) => event.preventDefault()}
|
||||||
|
className={cn(
|
||||||
|
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
|
||||||
|
selectedAssistant.has(item.id) && "bg-primary/8 focus:bg-primary/12",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{rowContent}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={`assistant:${item.id}`}
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={selectedAssistant.has(item.id)}
|
||||||
|
disabled={readOnly}
|
||||||
|
onClick={() => toggleAssistant(item.id)}
|
||||||
|
className={cn(
|
||||||
|
"my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary",
|
||||||
|
selectedAssistant.has(item.id) && "bg-primary/8",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
"flex size-4 shrink-0 items-center justify-center rounded border",
|
||||||
|
selectedAssistant.has(item.id) && "border-primary bg-primary text-primary-foreground",
|
||||||
|
)}>
|
||||||
|
{selectedAssistant.has(item.id) && <Check className="size-3" />}
|
||||||
|
</span>
|
||||||
|
{rowContent}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
|
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
|
||||||
<span className="text-[11px] text-muted-foreground">已选择 {value.length} 个</span>
|
<span className="text-[11px] text-muted-foreground">
|
||||||
<div className="flex items-center gap-1">
|
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
|
||||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange(filtered.map((item) => item.id))}>
|
</span>
|
||||||
全选
|
{readOnly ? (
|
||||||
</Button>
|
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
|
||||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}>
|
) : (
|
||||||
清空
|
<div className="flex items-center gap-1">
|
||||||
</Button>
|
<Button
|
||||||
</div>
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(filtered.map((item) => item.id));
|
||||||
|
onAssistantChange(filteredAssistant.map((item) => item.id));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
onChange([]);
|
||||||
|
onAssistantChange([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -221,21 +363,28 @@ function SelectionContent({
|
|||||||
|
|
||||||
export function LlmWikiKnowledgeSelector({
|
export function LlmWikiKnowledgeSelector({
|
||||||
value,
|
value,
|
||||||
|
assistantValue = [],
|
||||||
onChange,
|
onChange,
|
||||||
|
onAssistantChange = () => undefined,
|
||||||
source = "all",
|
source = "all",
|
||||||
agentId,
|
agentId,
|
||||||
autoSelectAll = false,
|
autoSelectAll = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
readOnly = false,
|
||||||
className,
|
className,
|
||||||
label = "知识空间",
|
label = "知识空间",
|
||||||
variant = "popover",
|
variant = "popover",
|
||||||
}: {
|
}: {
|
||||||
value: string[];
|
value: string[];
|
||||||
|
assistantValue?: string[];
|
||||||
onChange: (value: string[]) => void;
|
onChange: (value: string[]) => void;
|
||||||
|
onAssistantChange?: (value: string[]) => void;
|
||||||
source?: SelectorSource;
|
source?: SelectorSource;
|
||||||
agentId?: string | null;
|
agentId?: string | null;
|
||||||
autoSelectAll?: boolean;
|
autoSelectAll?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** Keep the selected knowledge spaces visible without allowing the thread scope to change. */
|
||||||
|
readOnly?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
variant?: SelectorVariant;
|
variant?: SelectorVariant;
|
||||||
@@ -243,28 +392,39 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const runtime = useLlmWikiRuntime();
|
const runtime = useLlmWikiRuntime();
|
||||||
|
const weknoraEnabled = runtime.data?.provider === "weknora";
|
||||||
const all = useLlmWikiKnowledgeBases(
|
const all = useLlmWikiKnowledgeBases(
|
||||||
"all",
|
"all",
|
||||||
runtime.data?.provider === "weknora",
|
weknoraEnabled,
|
||||||
);
|
);
|
||||||
const agent = useAgentLlmWikiKnowledgeBases(
|
const agent = useAgentLlmWikiKnowledgeBases(
|
||||||
agentId,
|
agentId,
|
||||||
runtime.data?.provider === "weknora" && source === "agent" && !!agentId,
|
weknoraEnabled && source === "agent" && !!agentId,
|
||||||
);
|
);
|
||||||
const rows = all.data ?? [];
|
const assistantBases = useAssistantKnowledgeBases();
|
||||||
|
const rows = weknoraEnabled ? (all.data ?? []) : [];
|
||||||
|
const assistantRows = assistantBases.data ?? [];
|
||||||
const defaultIds = useMemo(() => {
|
const defaultIds = useMemo(() => {
|
||||||
if (source !== "agent") {
|
if (source !== "agent") {
|
||||||
return rows.map((item) => item.id);
|
return rows.map((item) => item.id);
|
||||||
}
|
}
|
||||||
return (agent.data ?? []).map((item) => item.id);
|
return (agent.data ?? []).map((item) => item.id);
|
||||||
}, [agent.data, rows, source]);
|
}, [agent.data, rows, source]);
|
||||||
const loading = all.isLoading || (source === "agent" && !!agentId && agent.isLoading);
|
const loading = runtime.isLoading || (weknoraEnabled && all.isLoading) || assistantBases.isLoading || (source === "agent" && !!agentId && agent.isLoading);
|
||||||
const error = all.error ?? (source === "agent" ? agent.error : null);
|
const error = (weknoraEnabled ? all.error ?? (source === "agent" ? agent.error : null) : null) ?? assistantBases.error;
|
||||||
const availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]);
|
const availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]);
|
||||||
|
const assistantAvailableIds = useMemo(
|
||||||
|
() => new Set(assistantRows.map((item) => item.id)),
|
||||||
|
[assistantRows],
|
||||||
|
);
|
||||||
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
|
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
|
||||||
const initialized = useRef<string | null>(null);
|
const initialized = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!runtime.isLoading && !weknoraEnabled) {
|
||||||
|
if (value.length > 0) onChange([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!all.data || (source === "agent" && agentId && !agent.data) || initialized.current === initializationKey) return;
|
if (!all.data || (source === "agent" && agentId && !agent.data) || initialized.current === initializationKey) return;
|
||||||
initialized.current = initializationKey;
|
initialized.current = initializationKey;
|
||||||
const valid = value.filter((id) => availableIds.has(id));
|
const valid = value.filter((id) => availableIds.has(id));
|
||||||
@@ -274,25 +434,37 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
if (next.length !== value.length || next.some((id, index) => id !== value[index])) {
|
if (next.length !== value.length || next.some((id, index) => id !== value[index])) {
|
||||||
onChange(next);
|
onChange(next);
|
||||||
}
|
}
|
||||||
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, source, value]);
|
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, runtime.isLoading, source, value, weknoraEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!assistantBases.data) return;
|
||||||
|
const valid = assistantValue.filter((id) => assistantAvailableIds.has(id));
|
||||||
|
if (valid.length !== assistantValue.length || valid.some((id, index) => id !== assistantValue[index])) {
|
||||||
|
onAssistantChange(valid);
|
||||||
|
}
|
||||||
|
}, [assistantAvailableIds, assistantBases.data, assistantValue, onAssistantChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) setSearch("");
|
if (!open) setSearch("");
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
if (runtime.isError || runtime.data?.provider !== "weknora") return null;
|
if ((runtime.isError || !weknoraEnabled) && !assistantBases.isLoading && assistantRows.length === 0) return null;
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<SelectionContent
|
<SelectionContent
|
||||||
rows={rows}
|
rows={rows}
|
||||||
|
assistantRows={assistantRows}
|
||||||
value={value}
|
value={value}
|
||||||
|
assistantValue={assistantValue}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onAssistantChange={onAssistantChange}
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
source={source}
|
source={source}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -308,16 +480,26 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
|
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary" className="shrink-0">{value.length} 个</Badge>
|
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
|
||||||
</div>
|
</div>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedName = value.length === 1
|
const selectedNames = value
|
||||||
? rows.find((item) => item.id === value[0])?.name
|
.map((id) => rows.find((item) => item.id === id)?.name)
|
||||||
: null;
|
.filter((name): name is string => !!name);
|
||||||
|
const selectedAssistantNames = assistantValue
|
||||||
|
.map((id) => assistantRows.find((item) => item.id === id)?.name)
|
||||||
|
.filter((name): name is string => !!name);
|
||||||
|
const allSelectedNames = [...selectedNames, ...selectedAssistantNames];
|
||||||
|
const selectedCount = value.length + assistantValue.length;
|
||||||
|
const selectedLabel = allSelectedNames.length > 0
|
||||||
|
? allSelectedNames.join("、")
|
||||||
|
: selectedCount > 0
|
||||||
|
? `已选 ${selectedCount} 个知识空间`
|
||||||
|
: label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
@@ -328,19 +510,20 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 max-w-52 gap-1.5 rounded-full border-0 bg-transparent px-2.5 text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground",
|
"h-8 max-w-52 gap-1.5 rounded-full border-0 bg-transparent px-2.5 text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground",
|
||||||
value.length > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary",
|
selectedCount > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
|
title={selectedCount > 0 ? selectedLabel : undefined}
|
||||||
>
|
>
|
||||||
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <Database className="size-3.5" />}
|
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <Database className="size-3.5" />}
|
||||||
<span className="truncate text-xs font-normal">{selectedName || label}</span>
|
<span className="truncate text-xs font-normal">{selectedLabel}</span>
|
||||||
{value.length > 0 && (
|
{selectedCount > 0 && (
|
||||||
<span className="flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
|
<span className="flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
|
||||||
{value.length}
|
{selectedCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{value.length > 0 && value.length === rows.length && <Check className="size-3" />}
|
{selectedCount > 0 && selectedCount === rows.length + assistantRows.length && <Check className="size-3" />}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" sideOffset={8} className="w-80 overflow-hidden rounded-xl p-0 shadow-xl">
|
<DropdownMenuContent align="start" sideOffset={8} className="w-80 overflow-hidden rounded-xl p-0 shadow-xl">
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import type { AgentThreadState } from "@/core/threads";
|
|||||||
export interface ThreadContextType {
|
export interface ThreadContextType {
|
||||||
thread: BaseStream<AgentThreadState>;
|
thread: BaseStream<AgentThreadState>;
|
||||||
isMock?: boolean;
|
isMock?: boolean;
|
||||||
|
/**
|
||||||
|
* True when the active conversation has selected LLMWiki knowledge bases.
|
||||||
|
* Knowledge-base Q&A should always expose its source documents, independent
|
||||||
|
* from the admin-controlled manual reference-mode button.
|
||||||
|
*/
|
||||||
|
knowledgeReferencesEnabled?: boolean;
|
||||||
/**
|
/**
|
||||||
* When set, the conversation is rendered from a public read-only share.
|
* When set, the conversation is rendered from a public read-only share.
|
||||||
* Artifact/image URLs then resolve to the unauthenticated
|
* Artifact/image URLs then resolve to the unauthenticated
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { FileText, Loader2 } from "lucide-react";
|
import { FileText, Loader2, MessagesSquare } from "lucide-react";
|
||||||
import { useState, type ComponentProps } from "react";
|
import { useState, type ComponentProps } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||||
|
|
||||||
import { Tooltip } from "../tooltip";
|
import { Tooltip } from "../tooltip";
|
||||||
@@ -11,29 +17,84 @@ export function DownloadWordButton({
|
|||||||
markdown,
|
markdown,
|
||||||
fileName,
|
fileName,
|
||||||
title,
|
title,
|
||||||
|
questionMarkdown,
|
||||||
...props
|
...props
|
||||||
}: ComponentProps<typeof Button> & {
|
}: ComponentProps<typeof Button> & {
|
||||||
markdown: string;
|
markdown: string;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
/** When present, the button offers both answer-only and current Q&A export. */
|
||||||
|
questionMarkdown?: string;
|
||||||
}) {
|
}) {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busyScope, setBusyScope] = useState<"answer" | "qa" | null>(null);
|
||||||
const handleDownload = async () => {
|
const handleDownload = async (scope: "answer" | "qa" = "answer") => {
|
||||||
const source = markdown?.trim();
|
const source = markdown?.trim();
|
||||||
if (!source) {
|
if (!source) {
|
||||||
toast.error("无可下载的 Markdown 内容");
|
toast.error("无可下载的 Markdown 内容");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(true);
|
const question = questionMarkdown?.trim();
|
||||||
|
setBusyScope(scope);
|
||||||
try {
|
try {
|
||||||
await downloadMarkdownAsDocx(source, fileName || "message", title);
|
if (scope === "qa" && question) {
|
||||||
|
const qaMarkdown = `## 问题\n\n${question}\n\n## 回答\n\n${source}`;
|
||||||
|
await downloadMarkdownAsDocx(
|
||||||
|
qaMarkdown,
|
||||||
|
`${fileName || "message"}-问答`,
|
||||||
|
"问答记录",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await downloadMarkdownAsDocx(
|
||||||
|
source,
|
||||||
|
fileName || "message",
|
||||||
|
title || (question ? "问答回答" : undefined),
|
||||||
|
);
|
||||||
|
}
|
||||||
toast.success("Word 文件已生成");
|
toast.success("Word 文件已生成");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : "生成 Word 失败");
|
toast.error(error instanceof Error ? error.message : "生成 Word 失败");
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusyScope(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const busy = busyScope !== null;
|
||||||
|
const icon = busy ? (
|
||||||
|
<Loader2 size={12} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FileText size={12} />
|
||||||
|
);
|
||||||
|
|
||||||
|
if (questionMarkdown?.trim()) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<Tooltip content="下载为 Word">
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={busy}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
</Tooltip>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onSelect={() => void handleDownload("answer")}>
|
||||||
|
<FileText className="text-muted-foreground" />
|
||||||
|
仅下载当前回答
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => void handleDownload("qa")}>
|
||||||
|
<MessagesSquare className="text-muted-foreground" />
|
||||||
|
下载当前问题和回答
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip content="下载为 Word">
|
<Tooltip content="下载为 Word">
|
||||||
<Button
|
<Button
|
||||||
@@ -41,14 +102,10 @@ export function DownloadWordButton({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={handleDownload}
|
onClick={() => void handleDownload("answer")}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{busy ? (
|
{icon}
|
||||||
<Loader2 size={12} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<FileText size={12} />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
import type {
|
||||||
|
AnchorHTMLAttributes,
|
||||||
|
HTMLAttributes,
|
||||||
|
MouseEvent as ReactMouseEvent,
|
||||||
|
ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
type MessageResponseProps,
|
type MessageResponseProps,
|
||||||
} from "@/components/ai-elements/message";
|
} from "@/components/ai-elements/message";
|
||||||
import { streamdownPlugins } from "@/core/streamdown";
|
import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
|
||||||
import {
|
import {
|
||||||
createMarkdownAnchorResolver,
|
createMarkdownAnchorResolver,
|
||||||
isInPageAnchor,
|
isInPageAnchor,
|
||||||
@@ -34,6 +39,12 @@ export type MarkdownContentProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
remarkPlugins?: MessageResponseProps["remarkPlugins"];
|
remarkPlugins?: MessageResponseProps["remarkPlugins"];
|
||||||
components?: MessageResponseProps["components"];
|
components?: MessageResponseProps["components"];
|
||||||
|
linkClassName?: (href: string, label: string) => string | undefined;
|
||||||
|
onLinkClick?: (
|
||||||
|
event: ReactMouseEvent<HTMLAnchorElement>,
|
||||||
|
href: string,
|
||||||
|
label: string,
|
||||||
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Renders markdown content. */
|
/** Renders markdown content. */
|
||||||
@@ -43,8 +54,12 @@ export function MarkdownContent({
|
|||||||
className,
|
className,
|
||||||
remarkPlugins = streamdownPlugins.remarkPlugins,
|
remarkPlugins = streamdownPlugins.remarkPlugins,
|
||||||
components: componentsFromProps,
|
components: componentsFromProps,
|
||||||
|
linkClassName,
|
||||||
|
onLinkClick,
|
||||||
}: MarkdownContentProps) {
|
}: MarkdownContentProps) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
||||||
|
useStreamdownMermaidPngDownload(rootRef);
|
||||||
const components = useMemo(() => {
|
const components = useMemo(() => {
|
||||||
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;
|
||||||
@@ -93,6 +108,7 @@ export function MarkdownContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const finalHref = resolvedHref ?? props.href;
|
const finalHref = resolvedHref ?? props.href;
|
||||||
|
const label = extractTextFromChildren(props.children);
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
{...rest}
|
{...rest}
|
||||||
@@ -100,6 +116,7 @@ export function MarkdownContent({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
||||||
className,
|
className,
|
||||||
|
linkClassName?.(finalHref || "", label),
|
||||||
)}
|
)}
|
||||||
target={target ?? (external ? "_blank" : undefined)}
|
target={target ?? (external ? "_blank" : undefined)}
|
||||||
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
|
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
|
||||||
@@ -110,6 +127,7 @@ export function MarkdownContent({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
scrollToInPageAnchor(event.currentTarget, finalHref);
|
scrollToInPageAnchor(event.currentTarget, finalHref);
|
||||||
}
|
}
|
||||||
|
onLinkClick?.(event, finalHref || "", label);
|
||||||
rest.onClick?.(event);
|
rest.onClick?.(event);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -117,16 +135,17 @@ export function MarkdownContent({
|
|||||||
},
|
},
|
||||||
...componentsFromProps,
|
...componentsFromProps,
|
||||||
};
|
};
|
||||||
}, [anchorResolver, componentsFromProps]);
|
}, [anchorResolver, componentsFromProps, linkClassName, onLinkClick]);
|
||||||
|
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-markdown-anchor-root className="contents">
|
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
||||||
<MessageResponse
|
<MessageResponse
|
||||||
className={className}
|
className={className}
|
||||||
remarkPlugins={remarkPlugins}
|
remarkPlugins={remarkPlugins}
|
||||||
rehypePlugins={rehypePlugins}
|
rehypePlugins={rehypePlugins}
|
||||||
|
mermaidConfig={streamdownPlugins.mermaidConfig}
|
||||||
components={components}
|
components={components}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -41,6 +42,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildWriteFileVirtualUrl,
|
buildWriteFileVirtualUrl,
|
||||||
hasCompleteArtifactExtension,
|
hasCompleteArtifactExtension,
|
||||||
|
isAutoPreviewableArtifactPath,
|
||||||
|
languageOfStreamingArtifact,
|
||||||
normalizeStreamingArtifactPath,
|
normalizeStreamingArtifactPath,
|
||||||
provisionalWriteFilePath,
|
provisionalWriteFilePath,
|
||||||
} from "@/core/artifacts/streaming-write-preview";
|
} from "@/core/artifacts/streaming-write-preview";
|
||||||
@@ -58,6 +61,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,
|
||||||
@@ -66,25 +70,32 @@ import {
|
|||||||
type RuntimeDisplaySource,
|
type RuntimeDisplaySource,
|
||||||
} from "@/core/skill-display/runtime";
|
} from "@/core/skill-display/runtime";
|
||||||
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
||||||
|
import { isInterceptedWritingReportWrite } from "@/core/threads/writing-mode";
|
||||||
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
|
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
|
||||||
import { env } from "@/env";
|
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 = [],
|
||||||
|
ragModeEnabled = false,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
displaySources?: RuntimeDisplaySource[];
|
||||||
|
ragModeEnabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [showAbove, setShowAbove] = useState(
|
const [showAbove, setShowAbove] = useState(
|
||||||
@@ -93,12 +104,18 @@ export function MessageGroup({
|
|||||||
const [showLastThinking, setShowLastThinking] = useState(
|
const [showLastThinking, setShowLastThinking] = useState(
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||||
);
|
);
|
||||||
const steps = useMemo(() => convertToSteps(messages), [messages]);
|
|
||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
|
// 写作模式的「拦截 outputs/*.md 手写」只在写作模式生效;普通模式的
|
||||||
|
// write_file / read_file 步骤条必须照旧显示。
|
||||||
|
const writingModeEnabled = !!settings.context.writing_mode;
|
||||||
|
const steps = useMemo(
|
||||||
|
() => convertToSteps(messages, writingModeEnabled),
|
||||||
|
[messages, writingModeEnabled],
|
||||||
|
);
|
||||||
const { skills } = useSkills();
|
const { skills } = useSkills();
|
||||||
const skillDisplayMap = useMemo(
|
const skillDisplayMap = useMemo(
|
||||||
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||||
[settings.display.ragModeEnabled, skills],
|
[ragModeEnabled, skills],
|
||||||
);
|
);
|
||||||
const lastToolCallStep = useMemo(() => {
|
const lastToolCallStep = useMemo(() => {
|
||||||
const filteredSteps = steps.filter((step) => step.type === "toolCall");
|
const filteredSteps = steps.filter((step) => step.type === "toolCall");
|
||||||
@@ -121,11 +138,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 +195,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>
|
||||||
@@ -173,7 +210,7 @@ export function MessageGroup({
|
|||||||
{...step}
|
{...step}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
skillDisplayMap={skillDisplayMap}
|
skillDisplayMap={skillDisplayMap}
|
||||||
ragModeEnabled={settings.display.ragModeEnabled}
|
ragModeEnabled={ragModeEnabled}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
@@ -185,7 +222,7 @@ export function MessageGroup({
|
|||||||
isLast={true}
|
isLast={true}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
skillDisplayMap={skillDisplayMap}
|
skillDisplayMap={skillDisplayMap}
|
||||||
ragModeEnabled={settings.display.ragModeEnabled}
|
ragModeEnabled={ragModeEnabled}
|
||||||
/>
|
/>
|
||||||
</FlipDisplay>
|
</FlipDisplay>
|
||||||
)}
|
)}
|
||||||
@@ -220,10 +257,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 +270,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}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,14 +363,20 @@ function ToolCall({
|
|||||||
mode: item.displayMode ?? displayMatch.display.mode,
|
mode: item.displayMode ?? displayMatch.display.mode,
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
|
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
|
||||||
|
const wikiSearchLabel = name === "llmwiki_search"
|
||||||
|
? wikiQuery
|
||||||
|
? `Wiki 文档检索 "${wikiQuery}"`
|
||||||
|
: "Wiki 文档检索"
|
||||||
|
: null;
|
||||||
if (displayItems.length > 0) {
|
if (displayItems.length > 0) {
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
key={id}
|
key={id}
|
||||||
label={
|
label={
|
||||||
displayMatch?.display.mode === "citation" || !displayMatch
|
wikiSearchLabel || (displayMatch?.display.mode === "citation" || !displayMatch
|
||||||
? "检索参考来源"
|
? "检索参考来源"
|
||||||
: `获取 ${displayItems.length} 条结果`
|
: `获取 ${displayItems.length} 条结果`)
|
||||||
}
|
}
|
||||||
icon={SearchIcon}
|
icon={SearchIcon}
|
||||||
>
|
>
|
||||||
@@ -303,7 +401,7 @@ function ToolCall({
|
|||||||
mode: "citation" as const,
|
mode: "citation" as const,
|
||||||
}));
|
}));
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep key={id} label="检索参考来源" icon={SearchIcon}>
|
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
|
||||||
<DisplayResultsList results={defaultItems} />
|
<DisplayResultsList results={defaultItems} />
|
||||||
</ChainOfThoughtStep>
|
</ChainOfThoughtStep>
|
||||||
);
|
);
|
||||||
@@ -523,7 +621,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
|
||||||
@@ -546,10 +701,63 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 文件卡片只显示文件名:宿主泄漏路径(F:\...\outputs\report.md)与虚拟路径
|
||||||
|
* (/mnt/user-data/outputs/report.md)统一收敛到最后一段,避免把服务端磁盘结构暴露给用户。 */
|
||||||
|
function artifactDisplayName(path: string): string {
|
||||||
|
const filename = path.replace(/\\/g, "/").split("/").filter(Boolean).at(-1);
|
||||||
|
return filename || path;
|
||||||
|
}
|
||||||
|
|
||||||
function ArtifactWriteToolCall({
|
function ArtifactWriteToolCall({
|
||||||
id,
|
id,
|
||||||
messageId,
|
messageId,
|
||||||
@@ -557,7 +765,6 @@ function ArtifactWriteToolCall({
|
|||||||
args,
|
args,
|
||||||
result,
|
result,
|
||||||
description,
|
description,
|
||||||
isLast,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
}: {
|
}: {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -582,10 +789,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 (
|
||||||
@@ -593,19 +803,21 @@ function ArtifactWriteToolCall({
|
|||||||
!artifactPath ||
|
!artifactPath ||
|
||||||
!previewKey ||
|
!previewKey ||
|
||||||
!isLoading ||
|
!isLoading ||
|
||||||
!isLast ||
|
result !== undefined ||
|
||||||
result !== undefined
|
!sourcePath ||
|
||||||
|
!languageOfStreamingArtifact(sourcePath) ||
|
||||||
|
!isAutoPreviewableArtifactPath(sourcePath)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openStreamedArtifact(artifactPath, previewKey);
|
openStreamedArtifact(artifactPath, previewKey);
|
||||||
}, [
|
}, [
|
||||||
artifactPath,
|
artifactPath,
|
||||||
isLast,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
openStreamedArtifact,
|
openStreamedArtifact,
|
||||||
previewKey,
|
previewKey,
|
||||||
result,
|
result,
|
||||||
|
sourcePath,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -622,7 +834,7 @@ function ArtifactWriteToolCall({
|
|||||||
>
|
>
|
||||||
{(normalizedPath || sourcePath) && (
|
{(normalizedPath || sourcePath) && (
|
||||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||||
{normalizedPath || sourcePath}
|
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
|
||||||
</ChainOfThoughtSearchResult>
|
</ChainOfThoughtSearchResult>
|
||||||
)}
|
)}
|
||||||
</ChainOfThoughtStep>
|
</ChainOfThoughtStep>
|
||||||
@@ -647,7 +859,7 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
|
|||||||
|
|
||||||
type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
||||||
|
|
||||||
function convertToSteps(messages: Message[]): CoTStep[] {
|
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||||
const steps: CoTStep[] = [];
|
const steps: CoTStep[] = [];
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
if (message.type === "ai") {
|
if (message.type === "ai") {
|
||||||
@@ -665,6 +877,22 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
|||||||
if (tool_call.name === "task") {
|
if (tool_call.name === "task") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const writePath =
|
||||||
|
typeof (tool_call.args as { path?: unknown } | undefined)?.path === "string"
|
||||||
|
? (tool_call.args as { path: string }).path
|
||||||
|
: "";
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
tool_call.name === "write_file" ||
|
||||||
|
tool_call.name === "str_replace" ||
|
||||||
|
tool_call.name === "read_file" ||
|
||||||
|
tool_call.name === "read_multiple_files" ||
|
||||||
|
tool_call.name === "present_files"
|
||||||
|
) &&
|
||||||
|
isInterceptedWritingReportWrite(writePath, message.id, messages, writingMode)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const step: CoTToolCallStep = {
|
const step: CoTToolCallStep = {
|
||||||
id: tool_call.id,
|
id: tool_call.id,
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
FileIcon,
|
FileIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
SearchIcon,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
@@ -18,6 +17,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';
|
||||||
|
|
||||||
@@ -75,7 +75,6 @@ import { humanMessagePlugins } from '@/core/streamdown';
|
|||||||
import {
|
import {
|
||||||
getMultiRoundRagTrace,
|
getMultiRoundRagTrace,
|
||||||
isMultiRoundRagMessage,
|
isMultiRoundRagMessage,
|
||||||
type MultiRoundRagRound,
|
|
||||||
} from '@/core/threads/use-multi-round-rag';
|
} from '@/core/threads/use-multi-round-rag';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -88,6 +87,7 @@ import { DeleteMessageButton } from './delete-message-button';
|
|||||||
import { DownloadWordButton } from './download-word-button';
|
import { DownloadWordButton } from './download-word-button';
|
||||||
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
|
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
|
||||||
import { MessageTokenUsage } from './message-token-usage';
|
import { MessageTokenUsage } from './message-token-usage';
|
||||||
|
import { MultiRoundRagAssistantBody } from './multi-round-rag-message-list';
|
||||||
|
|
||||||
type EmbeddedPageContext = {
|
type EmbeddedPageContext = {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -158,6 +158,7 @@ export function MessageListItem({
|
|||||||
fitRowToContent = false,
|
fitRowToContent = false,
|
||||||
anchorId,
|
anchorId,
|
||||||
message,
|
message,
|
||||||
|
questionMarkdown,
|
||||||
isLoading,
|
isLoading,
|
||||||
threadId,
|
threadId,
|
||||||
tokenUsageEnabled = false,
|
tokenUsageEnabled = false,
|
||||||
@@ -176,6 +177,8 @@ export function MessageListItem({
|
|||||||
*/
|
*/
|
||||||
anchorId?: string;
|
anchorId?: string;
|
||||||
message: Message;
|
message: Message;
|
||||||
|
/** Nearest preceding user question, used by the current-Q&A Word export. */
|
||||||
|
questionMarkdown?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
tokenUsageEnabled?: boolean;
|
tokenUsageEnabled?: boolean;
|
||||||
@@ -328,6 +331,8 @@ export function MessageListItem({
|
|||||||
<DownloadWordButton
|
<DownloadWordButton
|
||||||
markdown={messageMarkdown}
|
markdown={messageMarkdown}
|
||||||
fileName="message"
|
fileName="message"
|
||||||
|
title="问答回答"
|
||||||
|
questionMarkdown={questionMarkdown}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{canSaveAssistantEdit && (
|
{canSaveAssistantEdit && (
|
||||||
@@ -373,12 +378,12 @@ function HumanMessageBlock({
|
|||||||
messageId?: string;
|
messageId?: string;
|
||||||
text: string;
|
text: string;
|
||||||
pageContext?: EmbeddedPageContext | null;
|
pageContext?: EmbeddedPageContext | null;
|
||||||
filesList: React.ReactNode;
|
filesList: ReactNode;
|
||||||
messageTime: { label: string; full: string } | null;
|
messageTime: { label: string; full: string } | null;
|
||||||
components: MarkdownContentProps["components"];
|
components: MarkdownContentProps["components"];
|
||||||
onEditResubmit?: (text: string) => void;
|
onEditResubmit?: (text: string) => void;
|
||||||
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||||
deleteButton?: React.ReactNode;
|
deleteButton?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
@@ -622,9 +627,7 @@ function MessageContent_({
|
|||||||
href={href}
|
href={href}
|
||||||
{...props}
|
{...props}
|
||||||
onReferenceClick={
|
onReferenceClick={
|
||||||
isSingleRoundRag || isMultiRoundRag
|
isSingleRoundRag ? handleCitationClick : undefined
|
||||||
? handleCitationClick
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -651,7 +654,6 @@ function MessageContent_({
|
|||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
handleCitationClick,
|
handleCitationClick,
|
||||||
isMultiRoundRag,
|
|
||||||
isSingleRoundRag,
|
isSingleRoundRag,
|
||||||
resolveArtifact,
|
resolveArtifact,
|
||||||
threadId,
|
threadId,
|
||||||
@@ -690,9 +692,16 @@ function MessageContent_({
|
|||||||
? stripArtifactTag(stripUploadedFilesTag(rawContent))
|
? stripArtifactTag(stripUploadedFilesTag(rawContent))
|
||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
if (isMultiRoundRag) {
|
||||||
|
return stripIncompleteCitationMarker(rawContent ?? '');
|
||||||
|
}
|
||||||
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
|
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
|
||||||
return stripIncompleteCitationMarker(withCitations);
|
return stripIncompleteCitationMarker(withCitations);
|
||||||
}, [rawContent, isHuman, displaySources]);
|
}, [rawContent, isHuman, isMultiRoundRag, displaySources]);
|
||||||
|
const markdownRehypePlugins = useMemo(
|
||||||
|
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
|
||||||
|
[rehypePlugins],
|
||||||
|
);
|
||||||
const parsedHumanContent = useMemo(
|
const parsedHumanContent = useMemo(
|
||||||
() =>
|
() =>
|
||||||
isHuman
|
isHuman
|
||||||
@@ -723,7 +732,7 @@ function MessageContent_({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reasoning-only AI message (no main response content yet)
|
// Reasoning-only AI message (no main response content yet)
|
||||||
if (!isHuman && reasoningContent && !rawContent && !multiRoundTrace) {
|
if (!isHuman && reasoningContent && !rawContent && !isMultiRoundRag) {
|
||||||
return (
|
return (
|
||||||
<AIElementMessageContent className={className}>
|
<AIElementMessageContent className={className}>
|
||||||
<Reasoning isStreaming={isLoading}>
|
<Reasoning isStreaming={isLoading}>
|
||||||
@@ -744,7 +753,11 @@ function MessageContent_({
|
|||||||
// it exists server-side (real, non-optimistic id). Deleting it cascades to
|
// it exists server-side (real, non-optimistic id). Deleting it cascades to
|
||||||
// its answer on the backend.
|
// its answer on the backend.
|
||||||
const canDelete =
|
const canDelete =
|
||||||
!!onMessageDeleted && !!message.id && !message.id.startsWith('opt-');
|
!!onMessageDeleted &&
|
||||||
|
!!message.id &&
|
||||||
|
!message.id.startsWith('opt-') &&
|
||||||
|
!isMultiRoundRag &&
|
||||||
|
!isSingleRoundRag;
|
||||||
return (
|
return (
|
||||||
<HumanMessageBlock
|
<HumanMessageBlock
|
||||||
messageId={message.id}
|
messageId={message.id}
|
||||||
@@ -753,8 +766,10 @@ function MessageContent_({
|
|||||||
filesList={filesList}
|
filesList={filesList}
|
||||||
messageTime={messageTime}
|
messageTime={messageTime}
|
||||||
components={components}
|
components={components}
|
||||||
onEditResubmit={onEditResubmit}
|
onEditResubmit={
|
||||||
onEditSave={onEditSave}
|
isMultiRoundRag || isSingleRoundRag ? undefined : onEditResubmit
|
||||||
|
}
|
||||||
|
onEditSave={isMultiRoundRag || isSingleRoundRag ? undefined : onEditSave}
|
||||||
deleteButton={
|
deleteButton={
|
||||||
canDelete ? (
|
canDelete ? (
|
||||||
<DeleteMessageButton
|
<DeleteMessageButton
|
||||||
@@ -774,14 +789,12 @@ function MessageContent_({
|
|||||||
<AIElementMessageContent className={className}>
|
<AIElementMessageContent className={className}>
|
||||||
{filesList}
|
{filesList}
|
||||||
<div data-reference-batch-id={message.id} className="contents">
|
<div data-reference-batch-id={message.id} className="contents">
|
||||||
{multiRoundTrace ? (
|
{isMultiRoundRag ? (
|
||||||
<MultiRoundRagContent
|
<MultiRoundRagAssistantBody
|
||||||
trace={multiRoundTrace}
|
|
||||||
content={contentToDisplay}
|
content={contentToDisplay}
|
||||||
displaySources={displaySources}
|
thinkingTrace={multiRoundTrace}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
|
rehypePlugins={markdownRehypePlugins}
|
||||||
components={components}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AssistantDisplayContent
|
<AssistantDisplayContent
|
||||||
@@ -789,12 +802,12 @@ 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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(isSingleRoundRag || isMultiRoundRag) && displaySources.length > 0 && (
|
{isSingleRoundRag && displaySources.length > 0 && (
|
||||||
<FrontendRagSourcesPanel
|
<FrontendRagSourcesPanel
|
||||||
sources={displaySources}
|
sources={displaySources}
|
||||||
onSelect={setSelectedCitation}
|
onSelect={setSelectedCitation}
|
||||||
@@ -809,7 +822,7 @@ function MessageContent_({
|
|||||||
<MessageTimeStamp time={messageTime} align="left" />
|
<MessageTimeStamp time={messageTime} align="left" />
|
||||||
) : null}
|
) : null}
|
||||||
</AIElementMessageContent>
|
</AIElementMessageContent>
|
||||||
{(isSingleRoundRag || isMultiRoundRag) && (
|
{isSingleRoundRag && (
|
||||||
<SingleRoundRagCitationDialog
|
<SingleRoundRagCitationDialog
|
||||||
source={selectedCitation}
|
source={selectedCitation}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
@@ -822,11 +835,9 @@ function MessageContent_({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单轮/多轮 RAG 专属的「引用来源」折叠面板。
|
* 单轮 RAG 专属的「引用来源」折叠面板。
|
||||||
*
|
*
|
||||||
* 参考 GeneralQA 的引用来源区:默认折叠,列出全部参考资料,点击任一条打开详情
|
* 多轮 RAG 的检索卡片已经画在思考过程里,正文下面不再重复一份来源列表。
|
||||||
* 弹窗(复用 SingleRoundRagCitationDialog)。这样即使模型正文里没有 `[1]` 编号,
|
|
||||||
* 用户也能看到本次回答依据的资料。
|
|
||||||
*/
|
*/
|
||||||
function FrontendRagSourcesPanel({
|
function FrontendRagSourcesPanel({
|
||||||
sources,
|
sources,
|
||||||
@@ -838,7 +849,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 +902,7 @@ function FrontendRagSourcesPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SingleRoundRagCitationDialog({
|
export function SingleRoundRagCitationDialog({
|
||||||
source,
|
source,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: {
|
}: {
|
||||||
@@ -930,175 +941,6 @@ function SingleRoundRagCitationDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MultiRoundRagContent({
|
|
||||||
trace,
|
|
||||||
content,
|
|
||||||
displaySources,
|
|
||||||
isLoading,
|
|
||||||
rehypePlugins,
|
|
||||||
components,
|
|
||||||
}: {
|
|
||||||
trace: NonNullable<ReturnType<typeof getMultiRoundRagTrace>>;
|
|
||||||
content: string;
|
|
||||||
displaySources: RuntimeDisplaySource[];
|
|
||||||
isLoading: boolean;
|
|
||||||
rehypePlugins: MarkdownContentProps['rehypePlugins'];
|
|
||||||
components: MarkdownContentProps['components'];
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(!trace.isComplete);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!trace.isComplete) setOpen(true);
|
|
||||||
}, [trace.isComplete]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<section className="border-border/70 overflow-hidden rounded-xl border bg-muted/20">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hover:bg-muted/50 flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors"
|
|
||||||
onClick={() => setOpen((value) => !value)}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
{trace.isComplete ? (
|
|
||||||
<SearchIcon className="text-primary size-4 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium">多轮检索过程</div>
|
|
||||||
<div className="text-muted-foreground text-xs">
|
|
||||||
{trace.isComplete
|
|
||||||
? `已完成 ${trace.rounds.length} 轮检索`
|
|
||||||
: '正在分析问题并检索资料'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronDownIcon
|
|
||||||
className={cn('text-muted-foreground size-4 transition-transform', !open && '-rotate-90')}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div className="border-border/60 space-y-3 border-t px-4 py-3">
|
|
||||||
{trace.rounds.map((round, index) => (
|
|
||||||
<MultiRoundRagRoundCard
|
|
||||||
key={round.id}
|
|
||||||
index={index + 1}
|
|
||||||
round={round}
|
|
||||||
isLoading={isLoading}
|
|
||||||
rehypePlugins={rehypePlugins}
|
|
||||||
components={components}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
<section className="border-border/70 rounded-xl border p-4">
|
|
||||||
<div className="mb-3 text-sm font-semibold">最终回答</div>
|
|
||||||
{content ? (
|
|
||||||
<AssistantDisplayContent
|
|
||||||
content={content}
|
|
||||||
displaySources={displaySources}
|
|
||||||
isLoading={isLoading}
|
|
||||||
renderSkillDisplayBlocks={false}
|
|
||||||
rehypePlugins={rehypePlugins}
|
|
||||||
components={components}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
|
||||||
正在生成最终回答…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MultiRoundRagRoundCard({
|
|
||||||
index,
|
|
||||||
round,
|
|
||||||
isLoading,
|
|
||||||
rehypePlugins,
|
|
||||||
components,
|
|
||||||
}: {
|
|
||||||
index: number;
|
|
||||||
round: MultiRoundRagRound;
|
|
||||||
isLoading: boolean;
|
|
||||||
rehypePlugins: MarkdownContentProps['rehypePlugins'];
|
|
||||||
components: MarkdownContentProps['components'];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="border-border/60 rounded-lg border bg-background/70 p-3">
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
|
||||||
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs">
|
|
||||||
{index}
|
|
||||||
</span>
|
|
||||||
<span>{round.title}</span>
|
|
||||||
{round.isSearching && <Loader2Icon className="text-primary size-3.5 animate-spin" />}
|
|
||||||
</div>
|
|
||||||
{round.thought && (
|
|
||||||
<section className="border-border/50 bg-muted/30 mt-3 rounded-md border p-3">
|
|
||||||
<div className="text-muted-foreground mb-2 text-xs font-medium">思考内容</div>
|
|
||||||
<MarkdownContent
|
|
||||||
content={round.thought}
|
|
||||||
isLoading={isLoading}
|
|
||||||
rehypePlugins={rehypePlugins}
|
|
||||||
components={components}
|
|
||||||
className="text-muted-foreground text-sm"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
{round.body && (
|
|
||||||
<section className="border-border/50 mt-3 rounded-md border p-3">
|
|
||||||
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
|
|
||||||
<MarkdownContent
|
|
||||||
content={round.body}
|
|
||||||
isLoading={isLoading}
|
|
||||||
rehypePlugins={rehypePlugins}
|
|
||||||
components={components}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
{round.query && (
|
|
||||||
<div className="border-primary/20 bg-primary/5 text-primary mt-2 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs">
|
|
||||||
<SearchIcon className="size-3 shrink-0" />
|
|
||||||
<span className="truncate">{round.query}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{round.retrievals.length > 0 && (
|
|
||||||
<section className="border-border/50 mt-3 rounded-md border p-3">
|
|
||||||
<div className="text-muted-foreground mb-2 text-xs font-medium">
|
|
||||||
检索内容 · {round.retrievals.length} 条
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{round.retrievals.map((item, resultIndex) => (
|
|
||||||
<details
|
|
||||||
key={`${item.id ?? item.title}-${resultIndex}`}
|
|
||||||
className="border-border/60 rounded-md border px-3 py-2"
|
|
||||||
open={resultIndex === 0}
|
|
||||||
>
|
|
||||||
<summary className="cursor-pointer list-none text-sm font-medium">
|
|
||||||
<span className="mr-2 text-xs text-muted-foreground">{resultIndex + 1}.</span>
|
|
||||||
{item.title}
|
|
||||||
</summary>
|
|
||||||
<div className="text-muted-foreground mt-2 text-sm leading-6 whitespace-pre-wrap">
|
|
||||||
{item.content || '暂无资料摘要'}
|
|
||||||
</div>
|
|
||||||
{(item.source || item.time) && (
|
|
||||||
<div className="text-muted-foreground mt-2 text-xs">
|
|
||||||
{[item.source, item.time].filter(Boolean).join(' · ')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</details>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSingleRoundRagSourceUrl(
|
function resolveSingleRoundRagSourceUrl(
|
||||||
source: RuntimeDisplaySource | null,
|
source: RuntimeDisplaySource | null,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Message } from "@langchain/langgraph-sdk";
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { CheckIcon, PenLineIcon, SendIcon, XIcon } from "lucide-react";
|
import { CheckIcon, PenLineIcon, SendIcon, XIcon } from "lucide-react";
|
||||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
import { useStickToBottomContext } from "use-stick-to-bottom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -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,
|
||||||
@@ -28,6 +31,7 @@ import {
|
|||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
|
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||||
import {
|
import {
|
||||||
buildSkillDisplayMap,
|
buildSkillDisplayMap,
|
||||||
collectRuntimeDisplaySources,
|
collectRuntimeDisplaySources,
|
||||||
@@ -89,6 +93,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 +132,15 @@ export function MessageList({
|
|||||||
onSetupWritingApprove,
|
onSetupWritingApprove,
|
||||||
onSetupWritingReject,
|
onSetupWritingReject,
|
||||||
onSetupWritingStart,
|
onSetupWritingStart,
|
||||||
|
onSetupReportStructureApprove,
|
||||||
|
onSetupReportStructureReject,
|
||||||
researchApprovalSlot,
|
researchApprovalSlot,
|
||||||
|
taskReportImportSlot,
|
||||||
|
businessCardSlot,
|
||||||
|
speakerShell,
|
||||||
onOpenArtifact,
|
onOpenArtifact,
|
||||||
onDownloadArtifact,
|
onDownloadArtifact,
|
||||||
|
afterDocumentRewriteItems,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
contentClassName?: string;
|
contentClassName?: string;
|
||||||
@@ -137,6 +163,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
|
||||||
@@ -144,9 +172,30 @@ export function MessageList({
|
|||||||
* assistant 消息行;不传该 slot 时普通对话/AI 写作零行为变化。
|
* assistant 消息行;不传该 slot 时普通对话/AI 写作零行为变化。
|
||||||
*/
|
*/
|
||||||
researchApprovalSlot?: (message: Message) => React.ReactNode;
|
researchApprovalSlot?: (message: Message) => React.ReactNode;
|
||||||
|
/**
|
||||||
|
* agentfx「入库」协助卡插槽:任务工作区宿主(AgentChatPage taskBind)向 displayThread
|
||||||
|
* 注入带 `additional_kwargs.task_report_import_approval` 的虚拟 ai 消息,由本插槽渲染
|
||||||
|
* 「是否入库」卡片。不传该 slot 时(普通对话等)该分组不渲染,零行为变化。
|
||||||
|
*/
|
||||||
|
taskReportImportSlot?: (message: Message) => React.ReactNode;
|
||||||
|
/**
|
||||||
|
* 通用业务卡插槽(报告协作工作台等):宿主向 displayThread 注入带
|
||||||
|
* `additional_kwargs.report_collaboration_card` 的虚拟 ai 消息(方案组 /
|
||||||
|
* 命令确认 / 报告入口),由本插槽渲染。不传该 slot 时(普通对话等)该
|
||||||
|
* 分组不渲染,零行为变化;卡 payload 结构由各页面在 message-adapter
|
||||||
|
* 层定义,MessageList 不感知具体业务字段。
|
||||||
|
*/
|
||||||
|
businessCardSlot?: (message: Message) => React.ReactNode;
|
||||||
|
/**
|
||||||
|
* 可选说话人壳(报告协作对齐会商第二步):包住 assistant / processing 分组。
|
||||||
|
* 不传则问答页零变化。
|
||||||
|
*/
|
||||||
|
speakerShell?: (messages: Message[], children: ReactNode) => ReactNode;
|
||||||
onOpenArtifact?: (filepath: string) => void;
|
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(
|
||||||
@@ -252,15 +301,33 @@ export function MessageList({
|
|||||||
return map;
|
return map;
|
||||||
}, [messages, askTimes]);
|
}, [messages, askTimes]);
|
||||||
|
|
||||||
|
// Associate each assistant answer with the nearest preceding user question.
|
||||||
|
// This powers the per-answer Word menu without changing message persistence.
|
||||||
|
const questionMarkdownByAssistantId = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
let currentQuestion = "";
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.type === "human") {
|
||||||
|
currentQuestion = extractContentFromMessage(message)?.trim() ?? "";
|
||||||
|
} else if (message.type === "ai" && message.id && currentQuestion) {
|
||||||
|
map.set(message.id, currentQuestion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
|
const referenceModeEntryEnabled =
|
||||||
|
citationDisplay?.reference_mode_enabled ?? false;
|
||||||
|
const knowledgeReferencesEnabled =
|
||||||
|
threadContext?.knowledgeReferencesEnabled === true;
|
||||||
const ragModeEnabled =
|
const ragModeEnabled =
|
||||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||||
|
isThreadRagModeLocked(threadId) ||
|
||||||
|
knowledgeReferencesEnabled;
|
||||||
const hasSingleRoundRag = useMemo(
|
const hasSingleRoundRag = useMemo(
|
||||||
() =>
|
() => messages.some((message) => isSingleRoundRagMessage(message)),
|
||||||
messages.some(
|
|
||||||
(message) =>
|
|
||||||
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
|
|
||||||
),
|
|
||||||
[messages],
|
[messages],
|
||||||
);
|
);
|
||||||
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
|
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
|
||||||
@@ -275,7 +342,7 @@ export function MessageList({
|
|||||||
// frontend tool-message scan below comes up empty (e.g. after a reload).
|
// frontend tool-message scan below comes up empty (e.g. after a reload).
|
||||||
const referenceBatches = useContext(ReferenceBatchesContext);
|
const referenceBatches = useContext(ReferenceBatchesContext);
|
||||||
|
|
||||||
// 单轮/多轮 RAG 的参考资料存放在一条 hide_from_ui 的 tool 消息里(紧挨在
|
// 单轮 RAG 的参考资料存放在一条 hide_from_ui 的 tool 消息里(紧挨在
|
||||||
// assistant 消息之前)。allMessages 会把 hide_from_ui 消息过滤掉,导致通用
|
// assistant 消息之前)。allMessages 会把 hide_from_ui 消息过滤掉,导致通用
|
||||||
// 的 collectRuntimeDisplaySources 扫描不到它们。这里直接在原始消息流里按
|
// 的 collectRuntimeDisplaySources 扫描不到它们。这里直接在原始消息流里按
|
||||||
// assistant 消息提取其对应的参考资料,保证 `[1]` 等编号能解析成可点击引用。
|
// assistant 消息提取其对应的参考资料,保证 `[1]` 等编号能解析成可点击引用。
|
||||||
@@ -286,7 +353,7 @@ export function MessageList({
|
|||||||
const msg = raw[i];
|
const msg = raw[i];
|
||||||
if (!msg || msg.type !== "ai" || !msg.id) continue;
|
if (!msg || msg.type !== "ai" || !msg.id) continue;
|
||||||
const kwargs = msg.additional_kwargs;
|
const kwargs = msg.additional_kwargs;
|
||||||
if (!kwargs || (kwargs.single_round_rag !== true && kwargs.multi_round_rag !== true)) {
|
if (!kwargs || kwargs.single_round_rag !== true) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let sources: RuntimeDisplaySource[] = [];
|
let sources: RuntimeDisplaySource[] = [];
|
||||||
@@ -297,19 +364,13 @@ export function MessageList({
|
|||||||
if (prev.type === "human" || prev.type === "ai") break;
|
if (prev.type === "human" || prev.type === "ai") break;
|
||||||
if (prev.type !== "tool") continue;
|
if (prev.type !== "tool") continue;
|
||||||
const prevKwargs = prev.additional_kwargs;
|
const prevKwargs = prev.additional_kwargs;
|
||||||
if (
|
if (!prevKwargs || prevKwargs.single_round_rag !== true) {
|
||||||
!prevKwargs ||
|
|
||||||
(prevKwargs.single_round_rag !== true && prevKwargs.multi_round_rag !== true)
|
|
||||||
) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const results = extractFrontendRagResults(prev.content);
|
const results = extractFrontendRagResults(prev.content);
|
||||||
if (results && results.length > 0) {
|
if (results && results.length > 0) {
|
||||||
const toolName = String(
|
const toolName = String(
|
||||||
(prev as { name?: unknown }).name ??
|
(prev as { name?: unknown }).name ?? "knowledge_base_search",
|
||||||
(prevKwargs.multi_round_rag === true
|
|
||||||
? "multi_round_rag_references"
|
|
||||||
: "knowledge_base_search"),
|
|
||||||
);
|
);
|
||||||
sources = results.map((item, index) =>
|
sources = results.map((item, index) =>
|
||||||
toRuntimeDisplaySource(item, index + 1, toolName),
|
toRuntimeDisplaySource(item, index + 1, toolName),
|
||||||
@@ -322,6 +383,33 @@ 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 [];
|
||||||
|
if (isMultiRoundRagMessage(msg)) 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,14 +526,23 @@ 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) => {
|
const items = group.messages.map((msg) => {
|
||||||
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
|
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
|
||||||
// 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。
|
// 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。
|
||||||
const messageIsLoading =
|
const messageIsLoading =
|
||||||
@@ -456,26 +553,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
|
||||||
@@ -487,7 +566,15 @@ export function MessageList({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
message={msg}
|
message={msg}
|
||||||
isLoading={messageIsLoading}
|
questionMarkdown={
|
||||||
|
msg.id ? questionMarkdownByAssistantId.get(msg.id) : undefined
|
||||||
|
}
|
||||||
|
isLoading={
|
||||||
|
isMultiRoundRagMessage(msg)
|
||||||
|
? thread.isLoading &&
|
||||||
|
msg.id === messages[messages.length - 1]?.id
|
||||||
|
: messageIsLoading
|
||||||
|
}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
tokenUsageEnabled={tokenUsageEnabled}
|
tokenUsageEnabled={tokenUsageEnabled}
|
||||||
displaySources={displaySources}
|
displaySources={displaySources}
|
||||||
@@ -501,6 +588,14 @@ export function MessageList({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
if (speakerShell && group.type === "assistant") {
|
||||||
|
return (
|
||||||
|
<div key={group.id} className="w-full">
|
||||||
|
{speakerShell(group.messages, items)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
} else if (group.type === "assistant:clarification") {
|
} else if (group.type === "assistant:clarification") {
|
||||||
const message = group.messages[0];
|
const message = group.messages[0];
|
||||||
if (message && hasContent(message)) {
|
if (message && hasContent(message)) {
|
||||||
@@ -582,6 +677,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 分组。
|
||||||
@@ -591,6 +703,22 @@ export function MessageList({
|
|||||||
{researchApprovalSlot(message)}
|
{researchApprovalSlot(message)}
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
} else if (group.type === "assistant:task-report-import") {
|
||||||
|
// agentfx「入库」协助卡:宿主不传渲染函数时该分组不渲染。
|
||||||
|
const message = group.messages[0];
|
||||||
|
return taskReportImportSlot && message ? (
|
||||||
|
<div key={group.id} className="w-full pb-2">
|
||||||
|
{taskReportImportSlot(message)}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
} else if (group.type === "assistant:business-card") {
|
||||||
|
// 通用业务卡(报告协作):宿主不传渲染函数时该分组不渲染。
|
||||||
|
const message = group.messages[0];
|
||||||
|
return businessCardSlot && message ? (
|
||||||
|
<div key={group.id} className="w-full pb-2">
|
||||||
|
{businessCardSlot(message)}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
} else if (group.type === "assistant:present-files") {
|
} else if (group.type === "assistant:present-files") {
|
||||||
const files: string[] = [];
|
const files: string[] = [];
|
||||||
for (const message of group.messages) {
|
for (const message of group.messages) {
|
||||||
@@ -599,8 +727,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 +817,8 @@ 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)}
|
||||||
|
ragModeEnabled={ragModeEnabled}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -725,11 +862,16 @@ export function MessageList({
|
|||||||
message.type === "ai" &&
|
message.type === "ai" &&
|
||||||
(hasToolCalls(message) ? true : !hasContent(message)),
|
(hasToolCalls(message) ? true : !hasContent(message)),
|
||||||
);
|
);
|
||||||
return (
|
const processingAi = [...group.messages]
|
||||||
<div key={"group-" + group.id} className="w-full">
|
.reverse()
|
||||||
|
.find((message) => message.type === "ai");
|
||||||
|
const processingBody = (
|
||||||
|
<div className="w-full">
|
||||||
<MessageGroup
|
<MessageGroup
|
||||||
messages={group.messages}
|
messages={group.messages}
|
||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
|
displaySources={resolveDisplaySources(processingAi)}
|
||||||
|
ragModeEnabled={ragModeEnabled}
|
||||||
/>
|
/>
|
||||||
<MessageTokenUsageList
|
<MessageTokenUsageList
|
||||||
enabled={tokenUsageEnabled}
|
enabled={tokenUsageEnabled}
|
||||||
@@ -738,10 +880,25 @@ export function MessageList({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
return (
|
||||||
{documentRewriteItems.map((item) => (
|
<div key={"group-" + group.id} className="w-full">
|
||||||
<DocumentRewriteTimeline key={item.id} item={item} />
|
{speakerShell ? speakerShell(group.messages, processingBody) : processingBody}
|
||||||
))}
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const leftoverRewriteItems = didRenderRewriteAboveFile
|
||||||
|
? 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 +956,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 +1335,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;
|
||||||
|
|||||||
@@ -0,0 +1,472 @@
|
|||||||
|
import {
|
||||||
|
BookOpenIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
SearchIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Conversation,
|
||||||
|
ConversationContent,
|
||||||
|
ConversationScrollButton,
|
||||||
|
} from "@/components/ai-elements/conversation";
|
||||||
|
import {
|
||||||
|
Message as AIElementMessage,
|
||||||
|
MessageContent as AIElementMessageContent,
|
||||||
|
} from "@/components/ai-elements/message";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
|
import type { MessageResponseProps } from "@/components/ai-elements/message";
|
||||||
|
import type {
|
||||||
|
MultiRoundRagChatMessage,
|
||||||
|
MultiRoundRagRetrieval,
|
||||||
|
MultiRoundRagRound,
|
||||||
|
MultiRoundRagTrace,
|
||||||
|
} from "@/core/threads/use-multi-round-rag";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { MarkdownContent } from "./markdown-content";
|
||||||
|
|
||||||
|
export function MultiRoundRagMessageList({
|
||||||
|
className,
|
||||||
|
messages,
|
||||||
|
isLoading,
|
||||||
|
paddingBottom = 160,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
messages: MultiRoundRagChatMessage[];
|
||||||
|
isLoading: boolean;
|
||||||
|
paddingBottom?: number;
|
||||||
|
}) {
|
||||||
|
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Conversation className={cn("flex size-full flex-col justify-center", className)}>
|
||||||
|
<ConversationContent
|
||||||
|
className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-10"
|
||||||
|
>
|
||||||
|
{messages.map((message, index) => {
|
||||||
|
const isLastAssistant =
|
||||||
|
message.role === "assistant" && index === messages.length - 1;
|
||||||
|
return (
|
||||||
|
<AIElementMessage
|
||||||
|
key={message.id}
|
||||||
|
className="group/conversation-message relative w-full"
|
||||||
|
from={message.role === "user" ? "user" : "assistant"}
|
||||||
|
>
|
||||||
|
{message.role === "user" ? (
|
||||||
|
<AIElementMessageContent className="w-fit">
|
||||||
|
<p className="whitespace-pre-wrap text-[15px] leading-7">
|
||||||
|
{message.content}
|
||||||
|
</p>
|
||||||
|
</AIElementMessageContent>
|
||||||
|
) : (
|
||||||
|
<AIElementMessageContent className="w-full">
|
||||||
|
<MultiRoundRagAssistantBody
|
||||||
|
content={
|
||||||
|
typeof message.content === "string" ? message.content : ""
|
||||||
|
}
|
||||||
|
thinkingTrace={message.thinkingTrace}
|
||||||
|
isLoading={isLastAssistant && isLoading}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
/>
|
||||||
|
</AIElementMessageContent>
|
||||||
|
)}
|
||||||
|
</AIElementMessage>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div style={{ height: `${paddingBottom}px` }} />
|
||||||
|
</ConversationContent>
|
||||||
|
<ConversationScrollButton
|
||||||
|
className="pointer-events-auto z-20 size-9 shadow-md bottom-40"
|
||||||
|
aria-label="回到最新"
|
||||||
|
title="回到最新"
|
||||||
|
/>
|
||||||
|
</Conversation>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MultiRoundRagAssistantBody({
|
||||||
|
content,
|
||||||
|
thinkingTrace,
|
||||||
|
isLoading,
|
||||||
|
rehypePlugins,
|
||||||
|
}: {
|
||||||
|
content: string;
|
||||||
|
thinkingTrace?: MultiRoundRagTrace | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
rehypePlugins: MessageResponseProps["rehypePlugins"];
|
||||||
|
}) {
|
||||||
|
const [selected, setSelected] = useState<MultiRoundRagRetrieval | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const trace = thinkingTrace;
|
||||||
|
const answer = trace?.finalAnswerMarkdown || content;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{trace && (
|
||||||
|
<MultiRoundRagTraceView
|
||||||
|
trace={trace}
|
||||||
|
isLoading={isLoading}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
onSelectRetrieval={setSelected}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<section className="mt-4">
|
||||||
|
<div className="text-foreground mb-2 text-sm font-medium">最终回答</div>
|
||||||
|
{answer ? (
|
||||||
|
<MarkdownContent
|
||||||
|
content={answer}
|
||||||
|
isLoading={isLoading}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
className="my-0 text-[15px] leading-7"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
正在生成思考过程与检索内容...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<RetrievalDetailDialog
|
||||||
|
item={selected}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setSelected(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultiRoundRagTraceView({
|
||||||
|
trace,
|
||||||
|
isLoading,
|
||||||
|
rehypePlugins,
|
||||||
|
onSelectRetrieval,
|
||||||
|
}: {
|
||||||
|
trace: MultiRoundRagTrace;
|
||||||
|
isLoading: boolean;
|
||||||
|
rehypePlugins: MessageResponseProps["rehypePlugins"];
|
||||||
|
onSelectRetrieval: (item: MultiRoundRagRetrieval) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(!trace.thinkingComplete);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
className="not-prose w-full min-w-0 max-w-5xl"
|
||||||
|
>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
{trace.thinkingComplete ? (
|
||||||
|
<SparklesIcon className="text-primary size-4 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
|
||||||
|
)}
|
||||||
|
<span>{trace.title || "思考过程"}</span>
|
||||||
|
<span className="text-muted-foreground/70 text-xs">
|
||||||
|
共 {trace.rounds.length} 轮推理与检索
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={cn("ml-auto size-4 transition-transform", !open && "-rotate-90")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="mt-3 space-y-3">
|
||||||
|
{trace.rounds.map((round, index) => (
|
||||||
|
<div
|
||||||
|
key={round.id}
|
||||||
|
className={cn("relative pl-5", index < trace.rounds.length - 1 && "pb-1")}
|
||||||
|
>
|
||||||
|
{index < trace.rounds.length - 1 && (
|
||||||
|
<span className="bg-primary/25 absolute bottom-0 left-[7px] top-8 w-px" />
|
||||||
|
)}
|
||||||
|
<span className="border-primary bg-background absolute left-0 top-4 size-3.5 rounded-full border-2" />
|
||||||
|
<MultiRoundRagRoundCard
|
||||||
|
index={index + 1}
|
||||||
|
round={round}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isActiveRound={isLoading && index === trace.rounds.length - 1}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
onSelectRetrieval={onSelectRetrieval}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultiRoundRagRoundCard({
|
||||||
|
index,
|
||||||
|
round,
|
||||||
|
isLoading,
|
||||||
|
isActiveRound,
|
||||||
|
rehypePlugins,
|
||||||
|
onSelectRetrieval,
|
||||||
|
}: {
|
||||||
|
index: number;
|
||||||
|
round: MultiRoundRagRound;
|
||||||
|
isLoading: boolean;
|
||||||
|
isActiveRound: boolean;
|
||||||
|
rehypePlugins: MessageResponseProps["rehypePlugins"];
|
||||||
|
onSelectRetrieval: (item: MultiRoundRagRetrieval) => void;
|
||||||
|
}) {
|
||||||
|
const query = round.query.trim();
|
||||||
|
const searching = Boolean(query && round.queryLoading);
|
||||||
|
const showQuery = Boolean(query);
|
||||||
|
const showRetrievals = showQuery || round.retrievals.length > 0;
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
<article className="overflow-hidden rounded-lg border border-border/70 bg-background">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:bg-muted/40 flex min-h-11 w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-medium">
|
||||||
|
{index}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium">{round.title}</span>
|
||||||
|
{searching ? (
|
||||||
|
<span className="text-primary inline-flex items-center gap-1 text-[11px]">
|
||||||
|
<Loader2Icon className="size-3 animate-spin" />
|
||||||
|
检索中
|
||||||
|
</span>
|
||||||
|
) : isActiveRound ? null : (
|
||||||
|
<span className="text-muted-foreground text-[11px]">已完成</span>
|
||||||
|
)}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={cn("text-muted-foreground size-4 shrink-0 transition-transform", !open && "-rotate-90")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="border-border/60 space-y-3 border-t px-3 pb-3 pt-3">
|
||||||
|
{round.thoughtMarkdown && (
|
||||||
|
<section>
|
||||||
|
<div className="text-muted-foreground mb-1.5 flex items-center gap-2 text-sm font-medium">
|
||||||
|
<SparklesIcon className="size-4" />
|
||||||
|
思考内容
|
||||||
|
</div>
|
||||||
|
<MarkdownContent
|
||||||
|
content={round.thoughtMarkdown}
|
||||||
|
isLoading={isLoading}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
className="text-muted-foreground my-0 text-sm leading-6"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{showQuery && (
|
||||||
|
<div
|
||||||
|
className="border-primary/20 bg-primary/5 text-primary inline-flex max-w-full items-center gap-2 rounded-full border px-3 py-1.5"
|
||||||
|
aria-live={searching ? "polite" : undefined}
|
||||||
|
>
|
||||||
|
{searching ? (
|
||||||
|
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<SearchIcon className="size-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="text-primary/80 shrink-0 text-xs">
|
||||||
|
{round.queryTitle || "检索词"}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 truncate text-xs font-medium">
|
||||||
|
{query}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showRetrievals && (
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||||
|
<FileTextIcon className="text-muted-foreground size-4" />
|
||||||
|
检索内容
|
||||||
|
<span className="text-muted-foreground text-xs font-normal">
|
||||||
|
{round.retrievals.length} 条
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{searching ? (
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2 rounded-lg border border-border/70 px-3 py-3 text-sm">
|
||||||
|
<Loader2Icon className="text-primary size-4 animate-spin" />
|
||||||
|
正在召回相关内容...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<RetrievalCardList
|
||||||
|
items={round.retrievals}
|
||||||
|
onSelect={onSelectRetrieval}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{(round.extractInfo || round.extractContent || round.extractLoading) && (
|
||||||
|
<section className="rounded-lg border border-border/70 px-3 py-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||||
|
<BookOpenIcon className="text-muted-foreground size-4" />
|
||||||
|
阅读内容
|
||||||
|
{round.extractLoading && <Loader2Icon className="text-primary size-3.5 animate-spin" />}
|
||||||
|
</div>
|
||||||
|
{round.extractInfo && (
|
||||||
|
<p className="text-muted-foreground text-sm leading-6">{round.extractInfo}</p>
|
||||||
|
)}
|
||||||
|
{round.extractContent && (
|
||||||
|
<p className="mt-2 text-sm leading-6 whitespace-pre-wrap">{round.extractContent}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{round.textidContent && (
|
||||||
|
<section className="rounded-lg border border-border/70 px-3 py-3 text-sm leading-6 whitespace-pre-wrap">
|
||||||
|
{round.textidContent}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</article>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RetrievalCardList({
|
||||||
|
items,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
items: MultiRoundRagRetrieval[];
|
||||||
|
onSelect: (item: MultiRoundRagRetrieval) => void;
|
||||||
|
}) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return <p className="text-muted-foreground py-1 text-sm">本轮未召回相关资料。</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<RetrievalCard
|
||||||
|
key={`${item.id ?? item.title}-${index}`}
|
||||||
|
item={item}
|
||||||
|
index={index + 1}
|
||||||
|
defaultOpen={index === 0}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RetrievalCard({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
defaultOpen,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
item: MultiRoundRagRetrieval;
|
||||||
|
index: number;
|
||||||
|
defaultOpen: boolean;
|
||||||
|
onSelect: (item: MultiRoundRagRetrieval) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
const meta = retrievalMeta(item);
|
||||||
|
|
||||||
|
if (item.type === "user_behavior") {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border/70 px-3 py-2 text-sm">
|
||||||
|
<span className="text-muted-foreground mr-2 font-medium">{index}.</span>
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
<div className="overflow-hidden rounded-xl border border-border/70 bg-background">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:bg-muted/40 flex min-h-11 w-full items-center gap-2 px-3 py-2.5 text-left transition-colors"
|
||||||
|
>
|
||||||
|
<span className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-medium">
|
||||||
|
{index}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm font-medium" title={item.title}>
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
{meta && (
|
||||||
|
<div className="text-muted-foreground truncate text-xs">{meta}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={cn("text-muted-foreground size-4 shrink-0 transition-transform", !open && "-rotate-90")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="border-border/60 border-t px-3 pb-3 pt-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-foreground line-clamp-3 w-full text-left text-sm leading-6 whitespace-pre-wrap"
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
>
|
||||||
|
{item.content || "暂无资料摘要"}
|
||||||
|
</button>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function retrievalMeta(item: MultiRoundRagRetrieval): string {
|
||||||
|
if (item.type === "more") return `时间:${item.time || "-"}`;
|
||||||
|
if (item.type === "task") return `关键词:${item.keywords || "-"}`;
|
||||||
|
if (item.type === "hx") return `平台:${item.platform || "-"} 粉丝数:${item.fans ?? "-"}`;
|
||||||
|
if (
|
||||||
|
item.type === "taiwan_news" ||
|
||||||
|
item.type === "world_news" ||
|
||||||
|
item.type === "messaage_comment" ||
|
||||||
|
item.type === "america_news"
|
||||||
|
) {
|
||||||
|
return `平台:${item.platform || "-"}${item.comment_count ? ` 评论数:${item.comment_count}` : ""}`;
|
||||||
|
}
|
||||||
|
if (item.type === "get_time_data") return `时间:${item.time || "-"} 热度:${item.hot_num ?? "-"}`;
|
||||||
|
return [item.source, item.time].filter(Boolean).join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function RetrievalDetailDialog({
|
||||||
|
item,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
item: MultiRoundRagRetrieval | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const content = item?.content;
|
||||||
|
return (
|
||||||
|
<Dialog open={!!item} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
|
||||||
|
<DialogHeader className="pr-8">
|
||||||
|
<DialogTitle className="leading-relaxed">{item?.title || "引用资料"}</DialogTitle>
|
||||||
|
<DialogDescription className="space-y-1 text-xs">
|
||||||
|
{item?.time && <span className="block">时间:{item.time}</span>}
|
||||||
|
{item?.source && <span className="block">来源:{item.source}</span>}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="bg-muted/50 max-h-[min(26rem,calc(100vh-16rem))] overflow-auto rounded-xl p-3 text-sm leading-7 whitespace-pre-wrap">
|
||||||
|
{content || "暂无引用内容"}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
@@ -209,7 +206,10 @@ export function RecentChatList({
|
|||||||
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
|
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
|
<SidebarGroup
|
||||||
|
className="px-2 pt-3 mt-2 border-t border-sidebar-border/60"
|
||||||
|
data-compact-guide="recent-chats"
|
||||||
|
>
|
||||||
<SidebarGroupLabel
|
<SidebarGroupLabel
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase select-none flex items-center justify-between transition-colors",
|
"text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase select-none flex items-center justify-between transition-colors",
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import { ChevronDown, Loader2, MoreHorizontal, Pencil, Share2, Trash2 } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuAction,
|
|
||||||
SidebarMenuItem,
|
|
||||||
SidebarMenuButton,
|
|
||||||
} from "@/components/ui/sidebar";
|
|
||||||
import { ShareRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
|
|
||||||
import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
|
|
||||||
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
|
|
||||||
import type { DraftMeta } from "@/roundtable-planning/api/drafts";
|
|
||||||
import { isIMEComposing } from "@/lib/ime";
|
|
||||||
|
|
||||||
const ROUNDTABLE_PATH = "/page/workspace/roundtable/planning";
|
|
||||||
|
|
||||||
function openDraft(
|
|
||||||
draft: DraftMeta,
|
|
||||||
isOnPage: boolean,
|
|
||||||
router: ReturnType<typeof useRouter>,
|
|
||||||
) {
|
|
||||||
if (isOnPage) {
|
|
||||||
// 已在会商页面:直接派事件让页面加载该草稿(仿 AI 写作的 open-session)。
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("roundtable:open-draft", { detail: draft.id }),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// 不在该页面:把草稿 id 暂存到 sessionStorage,跳转后由页面消费。
|
|
||||||
sessionStorage.setItem("roundtable:pending-draft", draft.id);
|
|
||||||
router.push(ROUNDTABLE_PATH);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 把草稿的后台作业状态 / 步骤映射成侧边栏小标签(仿页面内 HistoryDropdown)。 */
|
|
||||||
function StatusBadge({ draft }: { draft: DraftMeta }) {
|
|
||||||
if (draft.status === "running") {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-primary">
|
|
||||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
|
||||||
进行中
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (draft.status === "awaiting_input") {
|
|
||||||
return <span className="text-[10px] font-medium text-amber-500">待澄清</span>;
|
|
||||||
}
|
|
||||||
if (draft.status === "error") {
|
|
||||||
return <span className="text-[10px] font-medium text-rose-500">出错</span>;
|
|
||||||
}
|
|
||||||
// 非进行中/待澄清/出错时,展示本次会商所用业务链(无链条 → 自由模式)。
|
|
||||||
return (
|
|
||||||
<span className="text-[10px] font-medium text-muted-foreground">
|
|
||||||
{draft.chainTitle || "自由模式"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RecentRoundtableList() {
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const { drafts, loading, refresh, rename, remove } = useRoundtableDrafts();
|
|
||||||
const [collapsed, setCollapsed] = useState(
|
|
||||||
() => localStorage.getItem("sidebar:recent-roundtable-collapsed") === "true",
|
|
||||||
);
|
|
||||||
|
|
||||||
// 重命名对话框状态。
|
|
||||||
const [renameId, setRenameId] = useState<string | null>(null);
|
|
||||||
const [renameValue, setRenameValue] = useState("");
|
|
||||||
// 分享对话框 —— 正在分享的草稿 id。
|
|
||||||
const [shareId, setShareId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const toggleCollapsed = () => {
|
|
||||||
const next = !collapsed;
|
|
||||||
setCollapsed(next);
|
|
||||||
localStorage.setItem("sidebar:recent-roundtable-collapsed", String(next));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
const handleRenameSubmit = useCallback(async () => {
|
|
||||||
const id = renameId;
|
|
||||||
const title = renameValue.trim();
|
|
||||||
if (!id || !title) {
|
|
||||||
setRenameId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await rename(id, title);
|
|
||||||
toast.success("已重命名");
|
|
||||||
} catch {
|
|
||||||
toast.error("重命名失败,请重试");
|
|
||||||
} finally {
|
|
||||||
setRenameId(null);
|
|
||||||
setRenameValue("");
|
|
||||||
}
|
|
||||||
}, [renameId, renameValue, rename]);
|
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
|
||||||
async (draft: DraftMeta) => {
|
|
||||||
if (!window.confirm("确定删除这条会商记录吗?此操作不可恢复。")) return;
|
|
||||||
try {
|
|
||||||
await remove(draft.id);
|
|
||||||
toast.success("已删除会商记录");
|
|
||||||
} catch {
|
|
||||||
toast.error("删除失败,请重试");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[remove],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!loading && drafts.length === 0) return null;
|
|
||||||
|
|
||||||
const isOnPage = pathname === ROUNDTABLE_PATH;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
|
|
||||||
<SidebarGroupLabel
|
|
||||||
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
|
|
||||||
onClick={toggleCollapsed}
|
|
||||||
>
|
|
||||||
<span>多智能体会商</span>
|
|
||||||
<ChevronDown
|
|
||||||
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
|
|
||||||
/>
|
|
||||||
</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent
|
|
||||||
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
|
|
||||||
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<SidebarMenu>
|
|
||||||
<div className="flex w-full flex-col gap-0.5">
|
|
||||||
{loading && drafts.length === 0 ? (
|
|
||||||
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
|
|
||||||
) : (
|
|
||||||
drafts.slice(0, 10).map((draft) => (
|
|
||||||
<SidebarMenuItem key={draft.id} className="group/side-menu-item">
|
|
||||||
<SidebarMenuButton
|
|
||||||
className={
|
|
||||||
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
|
|
||||||
"hover:text-foreground hover:bg-sidebar-accent/60"
|
|
||||||
}
|
|
||||||
onClick={() => openDraft(draft, isOnPage, router)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-start min-w-0 w-full">
|
|
||||||
<span className="text-[13px] truncate w-full leading-snug">
|
|
||||||
{draft.title || "未命名草稿"}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1 mt-0.5">
|
|
||||||
<StatusBadge draft={draft} />
|
|
||||||
<span className="text-[10px] text-muted-foreground">·</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground">
|
|
||||||
{formatRelative(draft.updatedAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<SidebarMenuAction
|
|
||||||
showOnHover
|
|
||||||
className="bg-background/50 hover:bg-background top-2"
|
|
||||||
>
|
|
||||||
<MoreHorizontal />
|
|
||||||
<span className="sr-only">更多</span>
|
|
||||||
</SidebarMenuAction>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
className="w-44 rounded-lg"
|
|
||||||
side="right"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
setRenameId(draft.id);
|
|
||||||
setRenameValue(draft.title || "");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil className="text-muted-foreground" />
|
|
||||||
<span>重命名</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onSelect={() => setShareId(draft.id)}>
|
|
||||||
<Share2 className="text-muted-foreground" />
|
|
||||||
<span>分享</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onSelect={() => void handleDelete(draft)}>
|
|
||||||
<Trash2 className="text-muted-foreground" />
|
|
||||||
<span>删除</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
|
|
||||||
{/* 重命名对话框 */}
|
|
||||||
<Dialog open={renameId !== null} onOpenChange={(o) => !o && setRenameId(null)}>
|
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>重命名会商</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="py-4">
|
|
||||||
<Input
|
|
||||||
autoFocus
|
|
||||||
value={renameValue}
|
|
||||||
onChange={(e) => setRenameValue(e.target.value)}
|
|
||||||
placeholder="输入新名称"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && !isIMEComposing(e)) {
|
|
||||||
e.preventDefault();
|
|
||||||
void handleRenameSubmit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setRenameId(null)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void handleRenameSubmit()}>保存</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* 分享对话框 */}
|
|
||||||
<ShareRoundtableDraftDialog
|
|
||||||
draftId={shareId}
|
|
||||||
open={shareId !== null}
|
|
||||||
onOpenChange={(o) => {
|
|
||||||
if (!o) setShareId(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import { ChevronDown } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuItem,
|
|
||||||
SidebarMenuButton,
|
|
||||||
} from "@/components/ui/sidebar";
|
|
||||||
import {
|
|
||||||
formatRelative,
|
|
||||||
STATUS_LABELS,
|
|
||||||
} from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
|
|
||||||
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
|
|
||||||
import type { AIWritingSessionSummary } from "@/open-canvas/api/ai-writing-sessions";
|
|
||||||
|
|
||||||
const AI_WRITING_PATH = "/page/workspace/ai-writing";
|
|
||||||
|
|
||||||
function openSession(
|
|
||||||
session: AIWritingSessionSummary,
|
|
||||||
isOnPage: boolean,
|
|
||||||
router: ReturnType<typeof useRouter>,
|
|
||||||
) {
|
|
||||||
if (isOnPage) {
|
|
||||||
// 已在 AI 写作页面,直接通过自定义事件通知面板
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("ai-writing:open-session", { detail: session }),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// 不在该页面,把 session 存入 sessionStorage 后跳转
|
|
||||||
sessionStorage.setItem("ai-writing:pending-session", JSON.stringify(session));
|
|
||||||
router.push(AI_WRITING_PATH);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RecentWritingList() {
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const { sessions, loading, refresh } = useAIWritingSessions();
|
|
||||||
const [collapsed, setCollapsed] = useState(
|
|
||||||
() => localStorage.getItem("sidebar:recent-writing-collapsed") === "true",
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleCollapsed = () => {
|
|
||||||
const next = !collapsed;
|
|
||||||
setCollapsed(next);
|
|
||||||
localStorage.setItem("sidebar:recent-writing-collapsed", String(next));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
if (!loading && sessions.length === 0) return null;
|
|
||||||
|
|
||||||
const isOnPage = pathname === AI_WRITING_PATH;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
|
|
||||||
<SidebarGroupLabel
|
|
||||||
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
|
|
||||||
onClick={toggleCollapsed}
|
|
||||||
>
|
|
||||||
<span>最近写作</span>
|
|
||||||
<ChevronDown
|
|
||||||
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
|
|
||||||
/>
|
|
||||||
</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent
|
|
||||||
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
|
|
||||||
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<SidebarMenu>
|
|
||||||
<div className="flex w-full flex-col gap-0.5">
|
|
||||||
{loading && sessions.length === 0 ? (
|
|
||||||
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
|
|
||||||
) : (
|
|
||||||
sessions.slice(0, 10).map((session) => {
|
|
||||||
const { label, color } = STATUS_LABELS[session.status] ?? {
|
|
||||||
label: session.status,
|
|
||||||
color: "text-muted-foreground",
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem key={session.id} className="group/side-menu-item">
|
|
||||||
<SidebarMenuButton
|
|
||||||
className={
|
|
||||||
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
|
|
||||||
"hover:text-foreground hover:bg-sidebar-accent/60"
|
|
||||||
}
|
|
||||||
onClick={() => openSession(session, isOnPage, router)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-start min-w-0 w-full">
|
|
||||||
<span className="text-[13px] truncate w-full leading-snug">
|
|
||||||
{session.title}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1 mt-0.5">
|
|
||||||
<span className={cn("text-[10px] font-medium", color)}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground">·</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground">
|
|
||||||
{formatRelative(session.updated_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
@@ -180,7 +188,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
const globalDefault = appearance?.default_theme ?? "light";
|
const globalDefault = appearance?.default_theme ?? "light";
|
||||||
const globalSkillView = appearance?.default_skill_view ?? "list";
|
const globalSkillView = appearance?.default_skill_view ?? "list";
|
||||||
const globalAgentView = appearance?.default_agent_view ?? "card";
|
const globalAgentView = appearance?.default_agent_view ?? "card";
|
||||||
const globalLayoutMode = appearance?.default_layout_mode ?? "full";
|
const globalLayoutMode = appearance?.default_layout_mode ?? "compact";
|
||||||
const globalDefaultLabel = useMemo(() => {
|
const globalDefaultLabel = useMemo(() => {
|
||||||
const customTheme = (appearance?.custom_themes ?? []).find(
|
const customTheme = (appearance?.custom_themes ?? []).find(
|
||||||
(preset) => preset.id === globalDefault,
|
(preset) => preset.id === globalDefault,
|
||||||
@@ -193,10 +201,12 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
if (globalDefault === "lightyellow") return "浅黄色";
|
if (globalDefault === "lightyellow") return "浅黄色";
|
||||||
return "浅蓝";
|
return "浅蓝";
|
||||||
}, [appearance?.custom_themes, globalDefault]);
|
}, [appearance?.custom_themes, globalDefault]);
|
||||||
// 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则会话级回退)。
|
// 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则内置默认简洁模式)。
|
||||||
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
|
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
|
||||||
const cleanupWords =
|
const cleanupWords =
|
||||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||||
|
const referenceModeEntryEnabled =
|
||||||
|
citationDisplay?.reference_mode_enabled ?? false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShortcutDraft(
|
setShortcutDraft(
|
||||||
@@ -356,6 +366,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 +567,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
<Separator />
|
<Separator />
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="顶部快捷按钮(管理员)"
|
title="顶部快捷按钮(管理员)"
|
||||||
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停和与应用管理一致的登录/主题参数。"
|
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停,以及登录、主题和路径参数。"
|
||||||
>
|
>
|
||||||
<CompactShortcutSettings
|
<CompactShortcutSettings
|
||||||
buttons={shortcutDraft}
|
buttons={shortcutDraft}
|
||||||
@@ -830,21 +847,43 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
|||||||
aria-label="回答速度指示器"
|
aria-label="回答速度指示器"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
{isAdmin && (
|
||||||
<div>
|
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||||
<div className="text-sm font-medium">RAG 引用模式</div>
|
<div>
|
||||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
<div className="text-sm font-medium">参考文献模式入口</div>
|
||||||
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
|
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||||
</p>
|
管理员配置。开启后,普通问答和 iframe 问答输入区会显示「参考文献」按钮;默认关闭。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={referenceModeEntryEnabled}
|
||||||
|
disabled={updateCitationDisplay.isPending}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateCitationDisplay.mutate({
|
||||||
|
reference_mode_enabled: checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label="参考文献模式入口"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
)}
|
||||||
checked={settings.display.ragModeEnabled}
|
{referenceModeEntryEnabled && (
|
||||||
onCheckedChange={(checked) =>
|
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||||
setSettings("display", { ragModeEnabled: checked })
|
<div>
|
||||||
}
|
<div className="text-sm font-medium">RAG 引用模式</div>
|
||||||
aria-label="RAG 引用模式"
|
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||||
/>
|
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={settings.display.ragModeEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setSettings("display", { ragModeEnabled: checked })
|
||||||
|
}
|
||||||
|
aria-label="RAG 引用模式"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
@@ -917,7 +956,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 +1053,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 +1069,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 +1085,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 +1124,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 +1153,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 +1166,8 @@ function CompactShortcutSettings({
|
|||||||
}
|
}
|
||||||
placeholder="自定义值"
|
placeholder="自定义值"
|
||||||
className="w-32 text-xs"
|
className="w-32 text-xs"
|
||||||
|
autoComplete="off"
|
||||||
|
aria-label="自定义参数值"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1133,6 +1190,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 +1229,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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BellIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
|
import { BellIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -15,14 +15,17 @@ import { AccountSettingsPage } from "@/components/workspace/settings/account-set
|
|||||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||||
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
|
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
|
||||||
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
|
||||||
|
|
||||||
type SettingsSection =
|
type SettingsSection =
|
||||||
| "account"
|
| "account"
|
||||||
| "appearance"
|
| "appearance"
|
||||||
| "memory"
|
| "memory"
|
||||||
| "tools"
|
| "tools"
|
||||||
|
| "encoder"
|
||||||
| "about";
|
| "about";
|
||||||
|
|
||||||
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
||||||
@@ -32,6 +35,7 @@ type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
|||||||
export function SettingsDialog(props: SettingsDialogProps) {
|
export function SettingsDialog(props: SettingsDialogProps) {
|
||||||
const { defaultSection = "appearance", ...dialogProps } = props;
|
const { defaultSection = "appearance", ...dialogProps } = props;
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
const [activeSection, setActiveSection] =
|
const [activeSection, setActiveSection] =
|
||||||
useState<SettingsSection>(defaultSection);
|
useState<SettingsSection>(defaultSection);
|
||||||
|
|
||||||
@@ -64,6 +68,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
// icon: BrainIcon,
|
// icon: BrainIcon,
|
||||||
// },
|
// },
|
||||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||||
|
...(isAdmin
|
||||||
|
? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
id: "account",
|
id: "account",
|
||||||
label: "个人信息",
|
label: "个人信息",
|
||||||
@@ -76,6 +83,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
t.settings.sections.memory,
|
t.settings.sections.memory,
|
||||||
t.settings.sections.tools,
|
t.settings.sections.tools,
|
||||||
t.settings.sections.about,
|
t.settings.sections.about,
|
||||||
|
isAdmin,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
@@ -126,6 +134,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
)}
|
)}
|
||||||
{activeSection === "memory" && <MemorySettingsPage />}
|
{activeSection === "memory" && <MemorySettingsPage />}
|
||||||
{activeSection === "tools" && <ToolSettingsPage />}
|
{activeSection === "tools" && <ToolSettingsPage />}
|
||||||
|
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
|
||||||
{activeSection === "about" && <AboutSettingsPage />}
|
{activeSection === "about" && <AboutSettingsPage />}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ import {
|
|||||||
useViewMode,
|
useViewMode,
|
||||||
} from "@/core/system-settings/hooks";
|
} from "@/core/system-settings/hooks";
|
||||||
import type { Skill } from "@/core/skills/type";
|
import type { Skill } from "@/core/skills/type";
|
||||||
|
import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge";
|
||||||
import { formatDateTime } from "@/core/utils/datetime";
|
import { formatDateTime } from "@/core/utils/datetime";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
@@ -101,8 +102,15 @@ import { cn } from "@/lib/utils";
|
|||||||
import { SettingsSection } from "./settings-section";
|
import { SettingsSection } from "./settings-section";
|
||||||
import { SkillStateSheet } from "./skill-state-sheet";
|
import { SkillStateSheet } from "./skill-state-sheet";
|
||||||
|
|
||||||
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
export function SkillSettingsPage({
|
||||||
|
onClose,
|
||||||
|
embedded = false,
|
||||||
|
}: {
|
||||||
|
onClose?: () => void;
|
||||||
|
embedded?: boolean;
|
||||||
|
} = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
if (embedded) return <SkillSettingsList onClose={onClose} />;
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title={t.settings.skills.title}
|
title={t.settings.skills.title}
|
||||||
@@ -601,6 +609,7 @@ export function SkillRow({
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
layout = "row",
|
layout = "row",
|
||||||
onPublisherClick,
|
onPublisherClick,
|
||||||
|
knowledgeBindings = [],
|
||||||
}: {
|
}: {
|
||||||
skill: Skill;
|
skill: Skill;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
@@ -610,6 +619,8 @@ export function SkillRow({
|
|||||||
/** Click the 发布者 line to filter the list to that publisher. Receives the
|
/** Click the 发布者 line to filter the list to that publisher. Receives the
|
||||||
* owner_user_id + its desensitized display label. */
|
* owner_user_id + its desensitized display label. */
|
||||||
onPublisherClick?: (publisher: { id: string; label: string }) => void;
|
onPublisherClick?: (publisher: { id: string; label: string }) => void;
|
||||||
|
/** Admin list can inject its single batched binding query here (never N+1). */
|
||||||
|
knowledgeBindings?: SkillKnowledgeBinding[];
|
||||||
}) {
|
}) {
|
||||||
const isCard = layout === "card";
|
const isCard = layout === "card";
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -858,6 +869,20 @@ export function SkillRow({
|
|||||||
常驻
|
常驻
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{knowledgeBindings.slice(0, 3).map((binding) => (
|
||||||
|
<Badge
|
||||||
|
key={binding.id}
|
||||||
|
variant="outline"
|
||||||
|
className="border-indigo-500/30 text-indigo-700 dark:text-indigo-300 text-xs"
|
||||||
|
title={`${binding.target_name} · ${binding.status}${binding.remote_business_key ? ` · ${binding.remote_business_key}` : ""}`}
|
||||||
|
>
|
||||||
|
知识库:{binding.target_name || binding.target_id}
|
||||||
|
{binding.status === "stale" ? "(待更新)" : ""}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{knowledgeBindings.length > 3 && (
|
||||||
|
<Badge variant="secondary" className="text-xs">+{knowledgeBindings.length - 3}</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1203,6 +1228,23 @@ function InstallSkillDialog({
|
|||||||
const setNameZhMutation = useSetSkillNameZh();
|
const setNameZhMutation = useSetSkillNameZh();
|
||||||
const generateNameZhMutation = useGenerateSkillNameZh();
|
const generateNameZhMutation = useGenerateSkillNameZh();
|
||||||
|
|
||||||
|
const validateFile = useCallback(
|
||||||
|
async (selected: File, category: "custom" | "public") => {
|
||||||
|
setValidation(null);
|
||||||
|
setValidationError(null);
|
||||||
|
try {
|
||||||
|
const result = await validateMutation.mutateAsync({
|
||||||
|
file: selected,
|
||||||
|
targetCategory: category,
|
||||||
|
});
|
||||||
|
setValidation(result);
|
||||||
|
} catch (err) {
|
||||||
|
setValidationError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[validateMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
setFile(null);
|
setFile(null);
|
||||||
setValidation(null);
|
setValidation(null);
|
||||||
@@ -1242,16 +1284,9 @@ function InstallSkillDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFile(selected);
|
setFile(selected);
|
||||||
setValidation(null);
|
await validateFile(selected, targetCategory);
|
||||||
setValidationError(null);
|
|
||||||
try {
|
|
||||||
const result = await validateMutation.mutateAsync(selected);
|
|
||||||
setValidation(result);
|
|
||||||
} catch (err) {
|
|
||||||
setValidationError(err instanceof Error ? err.message : String(err));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[dialog.rejectInvalidExt, validateMutation],
|
[dialog.rejectInvalidExt, targetCategory, validateFile],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
@@ -1333,8 +1368,25 @@ function InstallSkillDialog({
|
|||||||
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
|
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
|
||||||
// Admin targeting public directory can overwrite an existing public skill.
|
// Admin targeting public directory can overwrite an existing public skill.
|
||||||
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
|
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
|
||||||
const canInstall = !!validation && !validationError && !isCustomConflict && (adminCanOverwritePublic ? false : !isPublicConflict);
|
const canInstall = !!validation && !validationError && validation.can_install_as_is;
|
||||||
const canOverwrite = !!validation && !validationError && (!!isCustomConflict || adminCanOverwritePublic);
|
const canOverwrite = !!validation && !validationError && validation.can_overwrite;
|
||||||
|
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
|
||||||
|
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
|
||||||
|
const customConflictMessage = !validation
|
||||||
|
? ""
|
||||||
|
: otherCustomConflict
|
||||||
|
? dialog.conflictOwnedByOther
|
||||||
|
.replace("{name}", validation.skill_name)
|
||||||
|
.replace(
|
||||||
|
"{owner}",
|
||||||
|
validation.owner_username ?? validation.owner_user_id ?? "—",
|
||||||
|
)
|
||||||
|
: validation.owned_by_me
|
||||||
|
? (ownCustomCanOverwrite
|
||||||
|
? dialog.conflictOwnedByMe
|
||||||
|
: dialog.conflictOwnedByMeWrongTarget
|
||||||
|
).replace("{name}", validation.skill_name)
|
||||||
|
: dialog.conflictCustom.replace("{name}", validation.skill_name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||||
@@ -1455,7 +1507,10 @@ function InstallSkillDialog({
|
|||||||
name="targetCategory"
|
name="targetCategory"
|
||||||
value={cat}
|
value={cat}
|
||||||
checked={targetCategory === cat}
|
checked={targetCategory === cat}
|
||||||
onChange={() => setTargetCategory(cat)}
|
onChange={() => {
|
||||||
|
setTargetCategory(cat);
|
||||||
|
if (file) void validateFile(file, cat);
|
||||||
|
}}
|
||||||
className="accent-primary"
|
className="accent-primary"
|
||||||
/>
|
/>
|
||||||
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
|
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
|
||||||
@@ -1514,12 +1569,15 @@ function InstallSkillDialog({
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
{isCustomConflict && (
|
{isCustomConflict && (
|
||||||
<div className="text-amber-600 mt-1 flex items-start gap-2 text-xs">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-1 flex items-start gap-2 text-xs",
|
||||||
|
ownCustomCanOverwrite ? "text-amber-600" : "text-destructive",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
{desensitize(
|
{desensitize(customConflictMessage)}
|
||||||
dialog.conflictCustom.replace("{name}", validation.skill_name),
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ExternalLinkIcon, FileTextIcon, LoaderCircle } from "lucide-react";
|
import { BookOpenTextIcon, ChevronRightIcon, ExternalLinkIcon, FileTextIcon } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -9,12 +9,14 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||||
import { apiFetch } from "@/core/api/fetch-client";
|
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
|
||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||||
|
import { reasoningPlugins } from "@/core/streamdown";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
||||||
|
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||||
|
|
||||||
export function SkillDisplaySources({
|
export function SkillDisplaySources({
|
||||||
highlightedReferenceId,
|
highlightedReferenceId,
|
||||||
@@ -52,17 +54,31 @@ export function SourceCard({
|
|||||||
source: RuntimeDisplaySource;
|
source: RuntimeDisplaySource;
|
||||||
referenceTarget?: boolean;
|
referenceTarget?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [relatedOpen, setRelatedOpen] = useState(false);
|
||||||
const [context, setContext] = useState<WeKnoraSourceContext | null>(null);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
const [contextLoading, setContextLoading] = useState(false);
|
|
||||||
const [contextError, setContextError] = useState<string | null>(null);
|
|
||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
|
const previewTarget = resolveWeKnoraSourceTarget({
|
||||||
|
...(source.raw || {}),
|
||||||
|
url: source.url,
|
||||||
|
});
|
||||||
const cleanupWords =
|
const cleanupWords =
|
||||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||||||
const snippet = compactReferenceText(content).slice(0, 280);
|
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
|
||||||
const paragraphs = segmentReferenceText(content);
|
const matchedHeading = isWikiSource
|
||||||
|
? String(
|
||||||
|
(source.raw?.matched_heading as string | undefined) ||
|
||||||
|
(source.raw?.matched_sections as Array<{ heading?: string }> | undefined)?.find(
|
||||||
|
(section) => section.heading,
|
||||||
|
)?.heading ||
|
||||||
|
"",
|
||||||
|
).trim()
|
||||||
|
: "";
|
||||||
|
const summary = isWikiSource && typeof source.raw?.summary === "string"
|
||||||
|
? source.raw.summary
|
||||||
|
: "";
|
||||||
|
const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
|
||||||
const score =
|
const score =
|
||||||
typeof source.score === "number"
|
typeof source.score === "number"
|
||||||
? source.score <= 1
|
? source.score <= 1
|
||||||
@@ -71,64 +87,43 @@ export function SourceCard({
|
|||||||
: null;
|
: null;
|
||||||
const meta = [
|
const meta = [
|
||||||
source.source,
|
source.source,
|
||||||
|
isWikiSource ? "检索对象:Wiki 文档" : null,
|
||||||
|
matchedHeading ? `命中章节:${matchedHeading}` : null,
|
||||||
source.author ? `作者 ${source.author}` : null,
|
source.author ? `作者 ${source.author}` : null,
|
||||||
source.type && source.type !== "result" ? source.type : null,
|
!isWikiSource && source.type && source.type !== "result" ? source.type : null,
|
||||||
source.time,
|
source.time,
|
||||||
score ? `相关度 ${score}` : null,
|
score ? `相关度 ${score}` : null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const isCitation = source.mode === "citation";
|
const isCitation = source.mode === "citation";
|
||||||
const showReferenceNumber = isCitation;
|
const showReferenceNumber = isCitation;
|
||||||
const previewTarget = parseWeKnoraPreviewUrl(source.url);
|
const openSource = () => {
|
||||||
|
if (previewTarget) {
|
||||||
useEffect(() => {
|
setDetailOpen(true);
|
||||||
if (!open || !previewTarget) {
|
} else {
|
||||||
return;
|
setRelatedOpen(true);
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
};
|
||||||
setContext(null);
|
|
||||||
setContextError(null);
|
|
||||||
setContextLoading(true);
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
knowledge_base_id: previewTarget.knowledgeBaseId,
|
|
||||||
chunk_id: previewTarget.chunkId,
|
|
||||||
});
|
|
||||||
void apiFetch(`${getBackendBaseURL()}/api/llmwiki/sources/context?${params}`)
|
|
||||||
.then(async (response) => {
|
|
||||||
const payload = (await response.json().catch(() => null)) as unknown;
|
|
||||||
if (!response.ok || !isWeKnoraSourceContextResponse(payload)) {
|
|
||||||
throw new Error(readPreviewError(payload, response.status));
|
|
||||||
}
|
|
||||||
if (!cancelled) {
|
|
||||||
setContext(payload.data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setContextError(error instanceof Error ? error.message : "无法加载原文上下文");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setContextLoading(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [open, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<div
|
||||||
id={referenceTarget ? `reference-${source.index}` : undefined}
|
id={referenceTarget ? `reference-${source.index}` : undefined}
|
||||||
type="button"
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group bg-card/60 hover:bg-card hover:border-primary/40 focus-visible:ring-primary/30 w-full scroll-mt-12 rounded-xl border px-3.5 py-3 text-left shadow-sm transition-all focus-visible:ring-2 focus-visible:outline-none",
|
"group bg-card/60 hover:bg-card hover:border-primary/40 focus-visible:ring-primary/30 w-full scroll-mt-12 rounded-xl border px-3.5 py-3 text-left shadow-sm transition-all focus-visible:ring-2 focus-visible:outline-none",
|
||||||
"target:border-amber-300 target:bg-amber-50/80",
|
"target:border-amber-300 target:bg-amber-50/80",
|
||||||
highlighted &&
|
highlighted &&
|
||||||
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
|
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
|
||||||
|
"cursor-pointer",
|
||||||
)}
|
)}
|
||||||
onClick={() => setOpen(true)}
|
onClick={openSource}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
openSource();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{showReferenceNumber && (
|
{showReferenceNumber && (
|
||||||
@@ -156,20 +151,40 @@ export function SourceCard({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{source.url && (
|
{(source.url || isWikiSource) && (
|
||||||
previewTarget ? (
|
previewTarget ? (
|
||||||
<span className="text-primary bg-primary/10 mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium">
|
<span className={cn(
|
||||||
<FileTextIcon className="size-3.5" />
|
"mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium",
|
||||||
查看原文
|
isWikiSource
|
||||||
|
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
|
)}>
|
||||||
|
{isWikiSource ? <BookOpenTextIcon className="size-3.5" /> : <FileTextIcon className="size-3.5" />}
|
||||||
|
{isWikiSource ? "Wiki 文档" : "知识库文档"}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
{previewTarget && (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<div className="mt-3 flex items-center justify-between border-t pt-2 text-xs text-muted-foreground">
|
||||||
<DialogContent className="max-w-2xl">
|
<span>{isWikiSource ? "点击卡片查看完整 Wiki" : "点击卡片查看详情"}</span>
|
||||||
|
<ChevronRightIcon className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{previewTarget && (
|
||||||
|
<WeKnoraSourceDrawer
|
||||||
|
open={detailOpen}
|
||||||
|
target={previewTarget}
|
||||||
|
title={source.title}
|
||||||
|
quote={content}
|
||||||
|
onOpenChange={setDetailOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!previewTarget && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
|
||||||
|
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-start justify-between gap-3 pr-8">
|
<div className="flex items-start justify-between gap-3 pr-8">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -205,19 +220,23 @@ export function SourceCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{previewTarget ? (
|
{content ? (
|
||||||
<WeKnoraSourceContextPanel
|
<div className="max-h-[62vh] overflow-y-auto rounded-xl border bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
|
||||||
context={context}
|
{isWikiSource && (
|
||||||
loading={contextLoading}
|
<div className="mb-4 flex items-center gap-2 border-b pb-3 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||||
error={contextError}
|
<BookOpenTextIcon className="size-4" />
|
||||||
/>
|
本次检索命中的 Wiki 文档内容
|
||||||
) : paragraphs.length > 0 ? (
|
</div>
|
||||||
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
|
)}
|
||||||
{paragraphs.map((paragraph, index) => (
|
<article className="mx-auto max-w-3xl rounded-lg bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
|
||||||
<p key={index} className="whitespace-pre-wrap">
|
<MarkdownContent
|
||||||
{paragraph}
|
className="max-w-none"
|
||||||
</p>
|
content={content}
|
||||||
))}
|
isLoading={false}
|
||||||
|
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||||
|
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{source.url && !previewTarget && (
|
{source.url && !previewTarget && (
|
||||||
@@ -226,161 +245,11 @@ export function SourceCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type WeKnoraPreviewTarget = {
|
|
||||||
knowledgeBaseId: string;
|
|
||||||
chunkId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WeKnoraSourceContext = {
|
|
||||||
knowledge_base_name?: string;
|
|
||||||
document_title?: string;
|
|
||||||
document_file_name?: string;
|
|
||||||
display_mode?: "wiki" | "chunks";
|
|
||||||
focus_chunk_id: string;
|
|
||||||
wiki_page?: {
|
|
||||||
id?: string;
|
|
||||||
slug?: string;
|
|
||||||
title?: string;
|
|
||||||
page_type?: string;
|
|
||||||
status?: string;
|
|
||||||
summary?: string;
|
|
||||||
content?: string;
|
|
||||||
source_refs?: string[];
|
|
||||||
} | null;
|
|
||||||
chunks: Array<{
|
|
||||||
id: string;
|
|
||||||
chunk_index?: number;
|
|
||||||
content: string;
|
|
||||||
is_focus: boolean;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseWeKnoraPreviewUrl(url?: string): WeKnoraPreviewTarget | null {
|
|
||||||
if (!url?.startsWith("/weknora-source-preview?")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const queryIndex = url.indexOf("?");
|
|
||||||
const params = new URLSearchParams(url.slice(queryIndex + 1));
|
|
||||||
const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
|
|
||||||
const chunkId = params.get("chunk_id")?.trim();
|
|
||||||
return knowledgeBaseId && chunkId ? { knowledgeBaseId, chunkId } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWeKnoraSourceContextResponse(
|
|
||||||
value: unknown,
|
|
||||||
): value is { success: true; data: WeKnoraSourceContext } {
|
|
||||||
if (!value || typeof value !== "object") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const data = (value as { data?: unknown }).data;
|
|
||||||
return (
|
|
||||||
(value as { success?: unknown }).success === true &&
|
|
||||||
!!data &&
|
|
||||||
typeof data === "object" &&
|
|
||||||
Array.isArray((data as WeKnoraSourceContext).chunks)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readPreviewError(payload: unknown, status: number): string {
|
|
||||||
if (payload && typeof payload === "object") {
|
|
||||||
const detail = (payload as { detail?: unknown }).detail;
|
|
||||||
if (typeof detail === "string" && detail) {
|
|
||||||
return detail;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return status === 404 ? "原文已不可用" : "无法加载原文上下文";
|
|
||||||
}
|
|
||||||
|
|
||||||
function WeKnoraSourceContextPanel({
|
|
||||||
context,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
}: {
|
|
||||||
context: WeKnoraSourceContext | null;
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
}) {
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="text-muted-foreground flex min-h-36 items-center justify-center gap-2 text-sm">
|
|
||||||
<LoaderCircle className="size-4 animate-spin" />
|
|
||||||
正在加载原文上下文…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="border-destructive/30 bg-destructive/5 text-destructive rounded-lg border p-3 text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!context) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const wikiPage = context.wiki_page;
|
|
||||||
const wikiContent = cleanReferenceText(
|
|
||||||
[wikiPage?.summary, wikiPage?.content].filter(Boolean).join("\n\n"),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
if (wikiPage && wikiContent) {
|
|
||||||
return (
|
|
||||||
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1">
|
|
||||||
<div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs">
|
|
||||||
<span>{context.knowledge_base_name || "WeKnora 知识库"}</span>
|
|
||||||
<span> · LLMWiki</span>
|
|
||||||
{wikiPage.page_type && <span> · {wikiPage.page_type}</span>}
|
|
||||||
</div>
|
|
||||||
<section className="border-primary/40 bg-primary/5 rounded-lg border p-3 text-sm leading-relaxed shadow-sm">
|
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-foreground text-sm font-medium">
|
|
||||||
{wikiPage.title || wikiPage.slug || "LLMWiki 页面"}
|
|
||||||
</span>
|
|
||||||
<span className="bg-primary text-primary-foreground rounded px-1.5 py-0.5 text-xs font-medium">
|
|
||||||
清洗页
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3 whitespace-pre-wrap">{wikiContent}</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1">
|
|
||||||
<div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs">
|
|
||||||
<span>{context.knowledge_base_name || "WeKnora 知识库"}</span>
|
|
||||||
{context.document_title && <span> · {context.document_title}</span>}
|
|
||||||
</div>
|
|
||||||
{context.chunks.map((chunk) => (
|
|
||||||
<section
|
|
||||||
key={chunk.id}
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg border p-3 text-sm leading-relaxed",
|
|
||||||
chunk.is_focus
|
|
||||||
? "border-primary/50 bg-primary/5 shadow-sm"
|
|
||||||
: "bg-muted/20",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="text-muted-foreground mb-2 flex items-center gap-2 text-xs">
|
|
||||||
<span>分段 {typeof chunk.chunk_index === "number" ? chunk.chunk_index + 1 : "-"}</span>
|
|
||||||
{chunk.is_focus && (
|
|
||||||
<span className="bg-primary text-primary-foreground rounded px-1.5 py-0.5 font-medium">
|
|
||||||
检索命中
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="whitespace-pre-wrap">{chunk.content}</p>
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanReferenceText(
|
function cleanReferenceText(
|
||||||
raw: string | undefined,
|
raw: string | undefined,
|
||||||
cleanupWords: string[],
|
cleanupWords: string[],
|
||||||
@@ -405,44 +274,18 @@ function compactReferenceText(text: string): string {
|
|||||||
return text.replace(/\s+/g, " ").trim();
|
return text.replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function segmentReferenceText(text: string): string[] {
|
function plainTextReferencePreview(text: string): string {
|
||||||
const explicit = text
|
return compactReferenceText(
|
||||||
.split(/\n{2,}/)
|
text
|
||||||
.map((item) => item.trim())
|
.replace(/^---[\s\S]*?---\s*/m, "")
|
||||||
.filter(Boolean);
|
.replace(/```[\s\S]*?```/g, " ")
|
||||||
const base = explicit.length > 1 ? explicit : splitLongParagraph(text);
|
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||||
return base.slice(0, 80);
|
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||||
}
|
.replace(/\[\[[^\]\s|]+[|\s]+([^\]]+)\]\]/g, "$1")
|
||||||
|
.replace(/\[\[([^\]]+)\]\]/g, "$1")
|
||||||
function splitLongParagraph(text: string): string[] {
|
.replace(/^#{1,6}\s+/gm, "")
|
||||||
const normalized = text
|
.replace(/^\s*[-*+]\s+/gm, "")
|
||||||
.split(/\n+/)
|
.replace(/^\s*\d+[.)]\s+/gm, "")
|
||||||
.map((item) => item.trim())
|
.replace(/[>*_`~|]/g, " "),
|
||||||
.filter(Boolean)
|
);
|
||||||
.join("\n");
|
|
||||||
if (normalized.length <= 360) {
|
|
||||||
return normalized ? [normalized] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const sentences = normalized
|
|
||||||
.split(/(?<=[。!?!?;;])\s*/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (sentences.length <= 1) {
|
|
||||||
return normalized.match(/[\s\S]{1,360}/g) ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const paragraphs: string[] = [];
|
|
||||||
let current = "";
|
|
||||||
for (const sentence of sentences) {
|
|
||||||
if ((current + sentence).length > 360 && current) {
|
|
||||||
paragraphs.push(current.trim());
|
|
||||||
current = "";
|
|
||||||
}
|
|
||||||
current += sentence;
|
|
||||||
}
|
|
||||||
if (current.trim()) {
|
|
||||||
paragraphs.push(current.trim());
|
|
||||||
}
|
|
||||||
return paragraphs;
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { CheckCircle2, Database, Loader2, RefreshCw, Search, Sparkles } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { useAllSkillsAdmin, useSkills } from "@/core/skills/hooks";
|
||||||
|
import { useLlmWikiKnowledgeBases, useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||||
|
import {
|
||||||
|
type SkillKnowledgeTarget,
|
||||||
|
useSkillKnowledgeActions,
|
||||||
|
useSkillKnowledgeBindings,
|
||||||
|
useSkillKnowledgeJobs,
|
||||||
|
} from "@/strategy-components/api/skill-knowledge";
|
||||||
|
|
||||||
|
const PHASE_LABELS: Record<string, string> = {
|
||||||
|
scanning: "扫描知识文件",
|
||||||
|
parsing: "解析资料",
|
||||||
|
extracting: "抽取实体关系",
|
||||||
|
awaiting_review: "等待审核",
|
||||||
|
merging: "合并实体关系",
|
||||||
|
writing_wiki: "写入 Wiki",
|
||||||
|
writing_sources: "上传知识资料",
|
||||||
|
generating_wiki: "分析并生成分类 Wiki",
|
||||||
|
vectorizing_wiki: "Wiki 向量化",
|
||||||
|
syncing_assistant: "同步知识梳理总库",
|
||||||
|
activating: "激活新版本",
|
||||||
|
completed: "完成",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
queued: "等待处理",
|
||||||
|
running: "处理中",
|
||||||
|
awaiting_review: "等待管理员审核",
|
||||||
|
completed: "已完成",
|
||||||
|
partial_failed: "部分失败",
|
||||||
|
failed: "失败",
|
||||||
|
canceled: "已取消",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean }) {
|
||||||
|
const visibleSkills = useSkills();
|
||||||
|
const allSkills = useAllSkillsAdmin(adminMode);
|
||||||
|
const skills = adminMode ? allSkills.skills : visibleSkills.skills;
|
||||||
|
const isLoading = adminMode ? allSkills.isLoading : visibleSkills.isLoading;
|
||||||
|
const liveSkills = useMemo(() => skills.filter((skill) => (skill.state ?? "active") === "active"), [skills]);
|
||||||
|
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
||||||
|
const [skillQuery, setSkillQuery] = useState("");
|
||||||
|
const [selectedTargetKey, setSelectedTargetKey] = useState<string | null>(null);
|
||||||
|
const runtime = useLlmWikiRuntime();
|
||||||
|
const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora");
|
||||||
|
const bindings = useSkillKnowledgeBindings(undefined, !adminMode);
|
||||||
|
const jobs = useSkillKnowledgeJobs(!adminMode);
|
||||||
|
const actions = useSkillKnowledgeActions();
|
||||||
|
|
||||||
|
const targets = useMemo<SkillKnowledgeTarget[]>(() => {
|
||||||
|
return (knowledgeBases.data ?? [])
|
||||||
|
.filter((kb) => {
|
||||||
|
const strategy = kb.indexing_strategy ?? {};
|
||||||
|
const capabilities = kb.capabilities ?? {};
|
||||||
|
const wikiEnabled =
|
||||||
|
kb.type === "wiki" ||
|
||||||
|
kb.wiki_index_enabled === true ||
|
||||||
|
strategy.wiki_enabled === true ||
|
||||||
|
capabilities.wiki_enabled === true ||
|
||||||
|
(kb.wiki_config && Object.keys(kb.wiki_config).length > 0);
|
||||||
|
return kb.can_write && kb.remote_status !== "missing" && !kb.is_conversation_deposit && kb.type !== "faq" && wikiEnabled;
|
||||||
|
})
|
||||||
|
.map((kb) => ({
|
||||||
|
target_type: "weknora",
|
||||||
|
target_mode: "wiki",
|
||||||
|
target_id: kb.id,
|
||||||
|
target_name: kb.name,
|
||||||
|
remote_business_key: `weknora:kb:${kb.id}`,
|
||||||
|
}));
|
||||||
|
}, [knowledgeBases.data]);
|
||||||
|
|
||||||
|
const filteredSkills = useMemo(() => {
|
||||||
|
const needle = skillQuery.trim().toLowerCase();
|
||||||
|
if (!needle) return liveSkills;
|
||||||
|
return liveSkills.filter((skill) => {
|
||||||
|
const tagText = (skill.tags ?? []).map((tag) => tag.name).join(" ");
|
||||||
|
const haystack = [
|
||||||
|
skill.name,
|
||||||
|
skill.name_zh,
|
||||||
|
skill.description,
|
||||||
|
skill.category,
|
||||||
|
tagText,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
.toLowerCase();
|
||||||
|
return haystack.includes(needle);
|
||||||
|
});
|
||||||
|
}, [liveSkills, skillQuery]);
|
||||||
|
|
||||||
|
const filteredSkillNames = useMemo(() => filteredSkills.map((skill) => skill.name), [filteredSkills]);
|
||||||
|
const allFilteredSelected = filteredSkillNames.length > 0 && filteredSkillNames.every((name) => selectedSkills.includes(name));
|
||||||
|
|
||||||
|
const toggleFilteredSkills = () => {
|
||||||
|
if (!filteredSkillNames.length) return;
|
||||||
|
setSelectedSkills((current) => {
|
||||||
|
if (allFilteredSelected) {
|
||||||
|
const filtered = new Set(filteredSkillNames);
|
||||||
|
return current.filter((name) => !filtered.has(name));
|
||||||
|
}
|
||||||
|
return [...current, ...filteredSkillNames.filter((name) => !current.includes(name))];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTargetKey && !targets.some((target) => `${target.target_type}:${target.target_id}` === selectedTargetKey)) {
|
||||||
|
setSelectedTargetKey(null);
|
||||||
|
}
|
||||||
|
}, [selectedTargetKey, targets]);
|
||||||
|
|
||||||
|
const toggle = (values: string[], value: string, setter: (next: string[]) => void) =>
|
||||||
|
setter(values.includes(value) ? values.filter((item) => item !== value) : [...values, value]);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const selectedTargets = targets.filter((target) =>
|
||||||
|
selectedTargetKey === `${target.target_type}:${target.target_id}`,
|
||||||
|
);
|
||||||
|
if (!selectedSkills.length || !selectedTargets.length) {
|
||||||
|
toast.error("请至少选择一个技能和一个目标知识库");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const job = await actions.createJob.mutateAsync({
|
||||||
|
skill_names: selectedSkills,
|
||||||
|
targets: selectedTargets,
|
||||||
|
review_mode: "off",
|
||||||
|
});
|
||||||
|
toast.success(`已创建 ${job.total_count} 个归纳项`);
|
||||||
|
setSelectedSkills([]);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid size-full min-h-0 gap-4 overflow-auto p-4 lg:grid-cols-[minmax(320px,0.9fr)_minmax(420px,1.1fr)] lg:overflow-hidden lg:p-6">
|
||||||
|
<section className="flex min-h-0 flex-col rounded-lg border bg-card">
|
||||||
|
<div className="border-b p-4">
|
||||||
|
<h2 className="font-semibold">归纳技能知识</h2>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">选择技能和一个目标知识库后,系统会在后台提取 Markdown 等知识资料,忽略 Python 等代码文件,自动生成 Wiki、关系和向量索引。</p>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<div className="space-y-5 p-4">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">第一步:选择技能(已选 {selectedSkills.length})</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={toggleFilteredSkills}>
|
||||||
|
{allFilteredSelected ? "清空当前结果" : "选择当前结果"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="relative mb-2">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={skillQuery}
|
||||||
|
onChange={(event) => setSkillQuery(event.target.value)}
|
||||||
|
placeholder="按技能中文名、英文名、描述、分类或标签搜索"
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-60 space-y-1 overflow-y-auto rounded-md border p-2">
|
||||||
|
{isLoading ? <Loader2 className="m-4 size-5 animate-spin" /> : filteredSkills.map((skill) => (
|
||||||
|
<label key={skill.name} className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-muted">
|
||||||
|
<Checkbox checked={selectedSkills.includes(skill.name)} onCheckedChange={() => toggle(selectedSkills, skill.name, setSelectedSkills)} />
|
||||||
|
<span className="truncate text-sm">{skill.name_zh || skill.name}</span>
|
||||||
|
<span className="ml-auto truncate text-xs text-muted-foreground">{skill.name}</span>
|
||||||
|
{(bindings.data ?? []).some((binding) => binding.skill_name === skill.name) && (
|
||||||
|
<Badge variant="secondary" className="shrink-0 text-[10px]">
|
||||||
|
{(bindings.data ?? []).some((binding) => binding.skill_name === skill.name && `${binding.target_type}:${binding.target_id}` === selectedTargetKey)
|
||||||
|
? "已在目标库"
|
||||||
|
: `已归纳 ${(bindings.data ?? []).filter((binding) => binding.skill_name === skill.name).length} 库`}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{!isLoading && !filteredSkills.length && (
|
||||||
|
<p className="p-3 text-xs text-muted-foreground">没有匹配的技能,请换个关键词。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
当前显示 {filteredSkills.length}/{liveSkills.length} 个技能。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">第二步:选择一个目标知识库</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 rounded-md border p-2">
|
||||||
|
{targets.map((target) => {
|
||||||
|
const key = `${target.target_type}:${target.target_id}`;
|
||||||
|
return (
|
||||||
|
<label key={key} className="flex cursor-pointer items-center gap-2 rounded px-2 py-2 hover:bg-muted">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedTargetKey === key}
|
||||||
|
onCheckedChange={() => setSelectedTargetKey((current) => (current === key ? null : key))}
|
||||||
|
/>
|
||||||
|
<Database className="size-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm">{target.target_name}</span>
|
||||||
|
<Badge variant="outline" className="ml-auto text-[10px]">普通 Wiki 库</Badge>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
|
||||||
|
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
|
||||||
|
<p className="p-3 text-xs text-destructive">
|
||||||
|
读取普通知识库失败:
|
||||||
|
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
|
||||||
|
<p className="p-3 text-xs text-muted-foreground">
|
||||||
|
{runtime.data?.provider !== "weknora"
|
||||||
|
? "当前未配置普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
||||||
|
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-3 text-xs leading-5 text-muted-foreground">
|
||||||
|
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及同步到“知识梳理”总库均在后台完成。
|
||||||
|
</div>
|
||||||
|
<Button className="w-full" disabled={actions.createJob.isPending || !selectedSkills.length || !selectedTargetKey} onClick={() => void submit()}>
|
||||||
|
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
|
||||||
|
开始归纳 {selectedSkills.length ? `${selectedSkills.length} 个技能` : ""}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex min-h-0 flex-col gap-4 overflow-hidden">
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col rounded-lg border bg-card">
|
||||||
|
<div className="flex items-center justify-between border-b p-4">
|
||||||
|
<div><h2 className="font-semibold">我的归纳任务</h2><p className="text-xs text-muted-foreground">任务会自动刷新,失败项可直接重试</p></div>
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => void jobs.refetch()}><RefreshCw className="size-4" /></Button>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
{(jobs.data ?? []).map((job) => {
|
||||||
|
const countedDone = job.success_count + job.partial_failed_count + job.failed_count;
|
||||||
|
const itemDone = (job.items ?? []).filter((item) =>
|
||||||
|
["completed", "partial_failed", "failed", "canceled"].includes(item.status),
|
||||||
|
).length;
|
||||||
|
const done = Math.max(countedDone, itemDone);
|
||||||
|
const progress = job.total_count ? Math.round((done / job.total_count) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div key={job.id} className="rounded-md border p-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm"><Badge variant={job.status === "completed" ? "default" : "secondary"}>{STATUS_LABELS[job.status] || job.status}</Badge><span>{done}/{job.total_count} 项</span><span className="ml-auto text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString()}</span></div>
|
||||||
|
<Progress className="my-2" value={progress} />
|
||||||
|
{job.items?.map((item) => (
|
||||||
|
<div key={item.id} className="mt-2 flex items-center gap-2 border-t pt-2 text-xs">
|
||||||
|
<span className="max-w-36 truncate font-medium">{item.skill_name}</span>
|
||||||
|
<span className="text-muted-foreground">{PHASE_LABELS[item.phase] || STATUS_LABELS[item.status] || item.phase}</span>
|
||||||
|
<span className="ml-auto">{item.progress}%</span>
|
||||||
|
{item.status === "failed" && <Button size="sm" variant="outline" onClick={() => actions.retry.mutate(item.id)}>重试</Button>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!jobs.data?.length && <p className="py-10 text-center text-sm text-muted-foreground">暂无归纳任务</p>}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-80 overflow-auto rounded-lg border bg-card p-3 text-xs">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-muted-foreground"><CheckCircle2 className="size-4 text-emerald-600" />已归纳关联:{bindings.data?.length ?? 0}</div>
|
||||||
|
<div className="space-y-2">{bindings.data?.map((binding) => <div key={binding.id} className="flex flex-wrap items-center gap-2 rounded border p-2"><span className="max-w-40 truncate font-medium">{binding.skill_name}</span><span className="max-w-48 truncate text-muted-foreground">已归纳到 {binding.target_name}</span><Badge variant="outline">{STATUS_LABELS[binding.status] || binding.status}</Badge><Button size="sm" variant="outline" className="ml-auto" disabled={actions.resync.isPending} onClick={() => actions.resync.mutate(binding.id)}>重新归纳</Button><Button size="sm" variant="ghost" onClick={() => { if (window.confirm("解除关联并保留目标知识内容?")) actions.detach.mutate({ id: binding.id, deleteContributions: false }); }}>解除关联</Button>{adminMode && <Button size="sm" variant="destructive" onClick={() => { if (window.confirm("解除关联并删除此技能贡献?共享知识仍会保留其他来源。")) actions.detach.mutate({ id: binding.id, deleteContributions: true }); }}>删除贡献</Button>}</div>)}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,11 +5,13 @@ import { useI18n } from "@/core/i18n/hooks";
|
|||||||
import type { AgentThreadState } from "@/core/threads";
|
import type { AgentThreadState } from "@/core/threads";
|
||||||
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
|
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { useThreadChat } from "./chats";
|
import { useThreadChat } from "./chats";
|
||||||
import { FlipDisplay } from "./flip-display";
|
import { FlipDisplay } from "./flip-display";
|
||||||
|
|
||||||
export function ThreadTitle({
|
export function ThreadTitle({
|
||||||
|
className,
|
||||||
threadId,
|
threadId,
|
||||||
thread,
|
thread,
|
||||||
}: {
|
}: {
|
||||||
@@ -47,9 +49,12 @@ export function ThreadTitle({
|
|||||||
if (!thread.values?.title) {
|
if (!thread.values?.title) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const visibleTitle = desensitize(displayTitle);
|
||||||
return (
|
return (
|
||||||
<FlipDisplay uniqueKey={threadId}>
|
<FlipDisplay uniqueKey={threadId} className={cn("min-w-0 w-full", className)}>
|
||||||
{desensitize(displayTitle)}
|
<span className="block truncate" title={visibleTitle}>
|
||||||
|
{visibleTitle}
|
||||||
|
</span>
|
||||||
</FlipDisplay>
|
</FlipDisplay>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
@@ -13,13 +14,18 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||||
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, 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";
|
||||||
import { WorkspaceNavStudio } from "./workspace-nav-studio";
|
import { WorkspaceNavStudio } from "./workspace-nav-studio";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
applyCompactMenuOverrides,
|
||||||
|
type CompactMenuItem,
|
||||||
|
} from "@/core/page-layout/compact-menu-overrides";
|
||||||
|
import { type MenuItem } from "@/core/page-layout/sidebar-menu";
|
||||||
|
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
|
||||||
|
|
||||||
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
||||||
const NAV_ITEM_CLASS =
|
const NAV_ITEM_CLASS =
|
||||||
@@ -40,6 +46,122 @@ const NEW_CHAT_COMPACT_CLASS =
|
|||||||
"hover:bg-[rgb(var(--legacy-primary-rgb)/4%)] hover:text-[var(--primary)] hover:border-[rgb(var(--legacy-primary-rgb)/32%)] " +
|
"hover:bg-[rgb(var(--legacy-primary-rgb)/4%)] hover:text-[var(--primary)] hover:border-[rgb(var(--legacy-primary-rgb)/32%)] " +
|
||||||
"data-[active=true]:bg-card data-[active=true]:text-[var(--primary)] data-[active=true]:shadow-none";
|
"data-[active=true]:bg-card data-[active=true]:text-[var(--primary)] data-[active=true]:shadow-none";
|
||||||
|
|
||||||
|
export type WorkspaceCompactNavItem = MenuItem & {
|
||||||
|
match: (pathname: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||||
|
{
|
||||||
|
id: "workspace-new-chat",
|
||||||
|
label: "新对话",
|
||||||
|
icon: MessageSquarePlus,
|
||||||
|
path: "/page/workspace/chats/new",
|
||||||
|
match: (pathname) => pathname === "/page/workspace/chats/new",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "workspace-chats",
|
||||||
|
label: "对话",
|
||||||
|
icon: MessagesSquare,
|
||||||
|
path: "/page/workspace/chats",
|
||||||
|
match: (pathname) => pathname === "/page/workspace/chats",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "workspace-library",
|
||||||
|
label: "资料库",
|
||||||
|
icon: Library,
|
||||||
|
path: "/page/workspace/library",
|
||||||
|
match: (pathname) => pathname === "/page/workspace/library",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "workspace-agents",
|
||||||
|
label: "智能体",
|
||||||
|
icon: BotIcon,
|
||||||
|
path: "/page/workspace/agents",
|
||||||
|
match: (pathname) => pathname.startsWith("/page/workspace/agents"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "workspace-skills",
|
||||||
|
label: "技能",
|
||||||
|
icon: SparklesIcon,
|
||||||
|
path: "/page/workspace/skills",
|
||||||
|
match: (pathname) => pathname.startsWith("/page/workspace/skills"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "workspace-memory",
|
||||||
|
label: "记忆",
|
||||||
|
icon: BrainIcon,
|
||||||
|
path: "/page/workspace/memory",
|
||||||
|
match: (pathname) => pathname.startsWith("/page/workspace/memory"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "workspace-ai-writing",
|
||||||
|
label: "AI写作",
|
||||||
|
icon: PenLine,
|
||||||
|
path: "/page/workspace/ai-writing",
|
||||||
|
match: (pathname) => pathname.startsWith("/page/workspace/ai-writing"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "workspace-deep-research",
|
||||||
|
label: "深度研究",
|
||||||
|
icon: Telescope,
|
||||||
|
path: "/page/workspace/deep-research",
|
||||||
|
match: (pathname) => pathname.startsWith("/page/workspace/deep-research"),
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// id: "workspace-report-collaboration",
|
||||||
|
// label: "报告协作",
|
||||||
|
// icon: FileText,
|
||||||
|
// path: "/page/workspace/report-collaboration",
|
||||||
|
// match: (pathname) => pathname.startsWith("/page/workspace/report-collaboration"),
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// id: "workspace-enterprise-research",
|
||||||
|
// label: "企业深度研究",
|
||||||
|
// icon: FileSearch,
|
||||||
|
// path: "/page/workspace/enterprise-research",
|
||||||
|
// match: (pathname) => pathname.startsWith("/page/workspace/enterprise-research"),
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
id: "workspace-roundtable-planning",
|
||||||
|
label: "多智能体规划",
|
||||||
|
icon: Users,
|
||||||
|
path: "/page/workspace/roundtable/planning",
|
||||||
|
match: (pathname) => pathname.startsWith("/page/workspace/roundtable/planning"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "workspace-scheduled-tasks",
|
||||||
|
label: "定时任务",
|
||||||
|
icon: ListChecks,
|
||||||
|
path: "/page/workspace/scheduled/tasks",
|
||||||
|
match: (pathname) => pathname === "/page/workspace/scheduled/tasks",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "workspace-knowledge",
|
||||||
|
label: "知识库管理",
|
||||||
|
icon: BookOpen,
|
||||||
|
path: "/page/workspace/knowledge",
|
||||||
|
match: (pathname) => pathname.startsWith("/page/workspace/knowledge"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export type EffectiveWorkspaceCompactNavItem = CompactMenuItem & WorkspaceCompactNavItem;
|
||||||
|
|
||||||
|
export function useWorkspaceCompactNavItems(): {
|
||||||
|
sidebarItems: EffectiveWorkspaceCompactNavItem[];
|
||||||
|
settingsItems: EffectiveWorkspaceCompactNavItem[];
|
||||||
|
toolboxItems: EffectiveWorkspaceCompactNavItem[];
|
||||||
|
} {
|
||||||
|
const { data: overrides = [] } = useCompactMenuOverrides();
|
||||||
|
return useMemo(() => {
|
||||||
|
const applied = applyCompactMenuOverrides(WORKSPACE_COMPACT_NAV_ITEMS, overrides);
|
||||||
|
return {
|
||||||
|
sidebarItems: applied.sidebarItems as EffectiveWorkspaceCompactNavItem[],
|
||||||
|
settingsItems: applied.settingsItems as EffectiveWorkspaceCompactNavItem[],
|
||||||
|
toolboxItems: applied.toolboxItems as EffectiveWorkspaceCompactNavItem[],
|
||||||
|
};
|
||||||
|
}, [overrides]);
|
||||||
|
}
|
||||||
|
|
||||||
export function WorkspaceNavChatList({
|
export function WorkspaceNavChatList({
|
||||||
showCollapseTrigger = false,
|
showCollapseTrigger = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
@@ -50,6 +172,8 @@ export function WorkspaceNavChatList({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { sidebarItems } = useWorkspaceCompactNavItems();
|
||||||
|
const newChatItem = sidebarItems.find((item) => item.id === "workspace-new-chat");
|
||||||
// 「新对话」点击时,如果已经在 /chats/new 路径,直接 navigate 会被 react-router
|
// 「新对话」点击时,如果已经在 /chats/new 路径,直接 navigate 会被 react-router
|
||||||
// 当成同路径 no-op,不更新 location.key,挂在 location.key 上的下游 hook
|
// 当成同路径 no-op,不更新 location.key,挂在 location.key 上的下游 hook
|
||||||
// (useThreadChat)就感知不到「用户想重开一个对话」,UI 不刷新。
|
// (useThreadChat)就感知不到「用户想重开一个对话」,UI 不刷新。
|
||||||
@@ -66,173 +190,77 @@ export function WorkspaceNavChatList({
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
|
<SidebarGroup
|
||||||
|
className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}
|
||||||
|
data-compact-guide="sidebar-nav"
|
||||||
|
>
|
||||||
<SidebarMenu className="gap-1">
|
<SidebarMenu className="gap-1">
|
||||||
<SidebarMenuItem>
|
{(showCollapseTrigger || newChatItem) && (
|
||||||
{compact ? (
|
<SidebarMenuItem>
|
||||||
|
{compact ? (
|
||||||
// 紧凑模式:新对话占满整行白卡样式 + 右侧收起钮(点击收起整列历史栏)。
|
// 紧凑模式:新对话占满整行白卡样式 + 右侧收起钮(点击收起整列历史栏)。
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={pathname === "/page/workspace/chats/new"}
|
isActive={pathname === "/page/workspace/chats/new"}
|
||||||
asChild
|
asChild
|
||||||
className={cn(NEW_CHAT_COMPACT_CLASS, "min-w-0 flex-1")}
|
className={cn(NEW_CHAT_COMPACT_CLASS, "min-w-0 flex-1")}
|
||||||
>
|
|
||||||
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
|
|
||||||
<Plus className="stroke-[2.5] text-[var(--primary)]" />
|
|
||||||
<span>{t.sidebar.newChat}</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
{showCollapseTrigger && (
|
|
||||||
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<SidebarMenuButton
|
|
||||||
isActive={pathname === "/page/workspace/chats/new"}
|
|
||||||
asChild
|
|
||||||
className={cn(
|
|
||||||
NEW_CHAT_CLASS,
|
|
||||||
showCollapseTrigger && "min-w-0 flex-1",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href="/page/workspace/chats/new"
|
|
||||||
onClick={handleNewChatClick}
|
|
||||||
>
|
>
|
||||||
<MessageSquarePlus />
|
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
|
||||||
<span>{t.sidebar.newChat}</span>
|
<Plus className="stroke-[2.5] text-[var(--primary)]" />
|
||||||
</Link>
|
<span>{newChatItem?.label ?? t.sidebar.newChat}</span>
|
||||||
</SidebarMenuButton>
|
</Link>
|
||||||
{showCollapseTrigger && (
|
</SidebarMenuButton>
|
||||||
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
|
{showCollapseTrigger && (
|
||||||
)}
|
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</SidebarMenuItem>
|
) : (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<SidebarMenuButton
|
||||||
|
isActive={pathname === "/page/workspace/chats/new"}
|
||||||
|
asChild
|
||||||
|
className={cn(
|
||||||
|
NEW_CHAT_CLASS,
|
||||||
|
showCollapseTrigger && "min-w-0 flex-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/page/workspace/chats/new"
|
||||||
|
onClick={handleNewChatClick}
|
||||||
|
>
|
||||||
|
<MessageSquarePlus />
|
||||||
|
<span>{newChatItem?.label ?? t.sidebar.newChat}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
{showCollapseTrigger && (
|
||||||
|
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)}
|
||||||
{!showCollapseTrigger && (
|
{!showCollapseTrigger && (
|
||||||
<>
|
<>
|
||||||
<SidebarMenuItem>
|
{sidebarItems
|
||||||
<SidebarMenuButton
|
.filter((item) => item.id !== "workspace-new-chat")
|
||||||
isActive={pathname === "/page/workspace/chats"}
|
.map((item) => {
|
||||||
asChild
|
const Icon = item.icon;
|
||||||
className={NAV_ITEM_CLASS}
|
return (
|
||||||
>
|
<SidebarMenuItem key={item.id}>
|
||||||
<Link href="/page/workspace/chats">
|
<SidebarMenuButton
|
||||||
<MessagesSquare />
|
isActive={item.match(pathname)}
|
||||||
<span>{t.sidebar.chats}</span>
|
asChild
|
||||||
</Link>
|
className={NAV_ITEM_CLASS}
|
||||||
</SidebarMenuButton>
|
>
|
||||||
</SidebarMenuItem>
|
<Link href={item.path ?? "/page/workspace/chats/new"}>
|
||||||
<SidebarMenuItem>
|
<Icon />
|
||||||
<SidebarMenuButton
|
<span>{item.label}</span>
|
||||||
isActive={pathname.startsWith("/page/workspace/agents")}
|
</Link>
|
||||||
asChild
|
</SidebarMenuButton>
|
||||||
className={NAV_ITEM_CLASS}
|
</SidebarMenuItem>
|
||||||
>
|
);
|
||||||
<Link href="/page/workspace/agents">
|
})}
|
||||||
<BotIcon />
|
|
||||||
<span>{t.sidebar.agents}</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton
|
|
||||||
isActive={pathname.startsWith("/page/workspace/skills")}
|
|
||||||
asChild
|
|
||||||
className={NAV_ITEM_CLASS}
|
|
||||||
>
|
|
||||||
<Link href="/page/workspace/skills">
|
|
||||||
<SparklesIcon />
|
|
||||||
<span>技能</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton
|
|
||||||
isActive={pathname.startsWith("/page/workspace/memory")}
|
|
||||||
asChild
|
|
||||||
className={NAV_ITEM_CLASS}
|
|
||||||
>
|
|
||||||
<Link href="/page/workspace/memory">
|
|
||||||
<BrainIcon />
|
|
||||||
<span>记忆</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton
|
|
||||||
isActive={pathname.startsWith("/page/workspace/ai-writing")}
|
|
||||||
asChild
|
|
||||||
className={NAV_ITEM_CLASS}
|
|
||||||
>
|
|
||||||
<Link href="/page/workspace/ai-writing">
|
|
||||||
<PenLine />
|
|
||||||
<span>AI写作</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton
|
|
||||||
isActive={pathname.startsWith("/page/workspace/deep-research")}
|
|
||||||
asChild
|
|
||||||
className={NAV_ITEM_CLASS}
|
|
||||||
>
|
|
||||||
<Link href="/page/workspace/deep-research">
|
|
||||||
<Telescope />
|
|
||||||
<span>深度研究</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton
|
|
||||||
isActive={pathname.startsWith("/page/workspace/roundtable/planning")}
|
|
||||||
asChild
|
|
||||||
className={NAV_ITEM_CLASS}
|
|
||||||
>
|
|
||||||
<Link href="/page/workspace/roundtable/planning">
|
|
||||||
<Users />
|
|
||||||
<span>多智能体规划</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
{/* <SidebarMenuItem>
|
|
||||||
<SidebarMenuButton
|
|
||||||
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
|
|
||||||
asChild
|
|
||||||
className={NAV_ITEM_CLASS}
|
|
||||||
>
|
|
||||||
<Link href="/page/workspace/roundtable/position">
|
|
||||||
<BriefcaseBusiness />
|
|
||||||
<span>岗位会商</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem> */}
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton
|
|
||||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
|
||||||
asChild
|
|
||||||
className={NAV_ITEM_CLASS}
|
|
||||||
>
|
|
||||||
<Link href="/page/workspace/scheduled/tasks">
|
|
||||||
<ListChecks />
|
|
||||||
<span>定时任务</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<KnowledgeRagToggle className={NAV_ITEM_CLASS} />
|
<KnowledgeRagToggle className={NAV_ITEM_CLASS} />
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton
|
|
||||||
isActive={pathname.startsWith("/page/workspace/knowledge")}
|
|
||||||
asChild
|
|
||||||
className={NAV_ITEM_CLASS}
|
|
||||||
>
|
|
||||||
<Link href="/page/workspace/knowledge">
|
|
||||||
<BookOpen />
|
|
||||||
<span>问答梳理</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ import {
|
|||||||
BriefcaseIcon,
|
BriefcaseIcon,
|
||||||
CalendarClockIcon,
|
CalendarClockIcon,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
|
Compass,
|
||||||
|
CpuIcon,
|
||||||
DatabaseZapIcon,
|
DatabaseZapIcon,
|
||||||
GaugeIcon,
|
GaugeIcon,
|
||||||
HistoryIcon,
|
HistoryIcon,
|
||||||
|
ListTreeIcon,
|
||||||
MessageCircleQuestionIcon,
|
MessageCircleQuestionIcon,
|
||||||
|
MessageSquareTextIcon,
|
||||||
ScrollTextIcon,
|
ScrollTextIcon,
|
||||||
Settings2Icon,
|
Settings2Icon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
@@ -19,6 +23,7 @@ import {
|
|||||||
TrophyIcon,
|
TrophyIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -43,9 +48,14 @@ import {
|
|||||||
} from "@/core/system-settings/api";
|
} from "@/core/system-settings/api";
|
||||||
|
|
||||||
import { NotificationDialog } from "./notification-dialog";
|
import { NotificationDialog } from "./notification-dialog";
|
||||||
|
import {
|
||||||
|
COMPACT_GUIDE_START_EVENT,
|
||||||
|
requestCompactGuideStart,
|
||||||
|
} from "./compact-mode-guide";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { SettingsDialog } from "./settings";
|
import { SettingsDialog } from "./settings";
|
||||||
|
import { useWorkspaceCompactNavItems } from "./workspace-nav-chat-list";
|
||||||
|
|
||||||
function NavMenuButtonContent({
|
function NavMenuButtonContent({
|
||||||
isSidebarOpen,
|
isSidebarOpen,
|
||||||
@@ -69,10 +79,11 @@ function NavMenuButtonContent({
|
|||||||
|
|
||||||
export function WorkspaceNavMenu() {
|
export function WorkspaceNavMenu() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const pathname = usePathname();
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
|
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
|
||||||
"appearance" | "memory" | "tools" | "about"
|
"appearance" | "memory" | "tools" | "encoder" | "about"
|
||||||
>("appearance");
|
>("appearance");
|
||||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
@@ -80,11 +91,26 @@ export function WorkspaceNavMenu() {
|
|||||||
const { open: isSidebarOpen } = useSidebar();
|
const { open: isSidebarOpen } = useSidebar();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { data: unreadCount = 0 } = useUnreadCount();
|
const { data: unreadCount = 0 } = useUnreadCount();
|
||||||
|
const { settingsItems } = useWorkspaceCompactNavItems();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 简洁模式界面引导:已在问答页(ChatPage 挂载中)时直接广播事件启动;否则先落
|
||||||
|
// sessionStorage 请求,导航到新对话页后由 CompactModeGuide 消费启动。
|
||||||
|
const startCompactGuide = () => {
|
||||||
|
if (/^\/page\/workspace\/chats\/.+/.test(pathname)) {
|
||||||
|
window.dispatchEvent(new CustomEvent(COMPACT_GUIDE_START_EVENT));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestCompactGuideStart();
|
||||||
|
navigate("/page/workspace/chats/new", {
|
||||||
|
replace: true,
|
||||||
|
state: { resetAt: Date.now() },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAdmin) return;
|
if (!isAdmin) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -111,7 +137,7 @@ export function WorkspaceNavMenu() {
|
|||||||
open={notificationOpen}
|
open={notificationOpen}
|
||||||
onOpenChange={setNotificationOpen}
|
onOpenChange={setNotificationOpen}
|
||||||
/>
|
/>
|
||||||
<SidebarMenu className="w-full">
|
<SidebarMenu className="w-full" data-compact-guide="settings-menu">
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
{mounted ? (
|
{mounted ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -129,6 +155,18 @@ export function WorkspaceNavMenu() {
|
|||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
>
|
>
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
|
{settingsItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => item.path && navigate(item.path)}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
{item.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
|
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
|
||||||
<BellIcon />
|
<BellIcon />
|
||||||
{t.notifications.title}
|
{t.notifications.title}
|
||||||
@@ -138,6 +176,10 @@ export function WorkspaceNavMenu() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={startCompactGuide}>
|
||||||
|
<Compass />
|
||||||
|
界面使用引导
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSettingsDefaultSection("appearance");
|
setSettingsDefaultSection("appearance");
|
||||||
@@ -147,6 +189,17 @@ export function WorkspaceNavMenu() {
|
|||||||
<Settings2Icon />
|
<Settings2Icon />
|
||||||
{t.common.settings}
|
{t.common.settings}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setSettingsDefaultSection("encoder");
|
||||||
|
setSettingsOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CpuIcon />
|
||||||
|
本地编码器
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -212,6 +265,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")}
|
||||||
>
|
>
|
||||||
@@ -254,6 +325,16 @@ export function WorkspaceNavMenu() {
|
|||||||
推荐问题配置
|
推荐问题配置
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
navigate("/page/strategy/admin/fixed-questions")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MessageSquareTextIcon />
|
||||||
|
问题配置
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && logSystem?.enabled && logSystem.url && (
|
{isAdmin && logSystem?.enabled && logSystem.url && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ import { usePathname } from "next/navigation";
|
|||||||
|
|
||||||
import { GeneralQaSidebar } from "./general-qa-sidebar";
|
import { GeneralQaSidebar } from "./general-qa-sidebar";
|
||||||
import { RecentChatList } from "./recent-chat-list";
|
import { RecentChatList } from "./recent-chat-list";
|
||||||
import { RecentRoundtableList } from "./recent-roundtable-list";
|
|
||||||
import { RecentWritingList } from "./recent-writing-list";
|
|
||||||
import { WorkspaceHeader } from "./workspace-header";
|
import { WorkspaceHeader } from "./workspace-header";
|
||||||
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
||||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||||
@@ -36,7 +34,9 @@ export function WorkspaceSidebar({
|
|||||||
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
|
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
|
||||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
||||||
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
||||||
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
|
const isGeneralQa =
|
||||||
|
pathname.startsWith("/page/workspace/chats") ||
|
||||||
|
pathname.startsWith("/page/strategy/chats");
|
||||||
|
|
||||||
if (forceCollapsed) {
|
if (forceCollapsed) {
|
||||||
return (
|
return (
|
||||||
@@ -123,10 +123,10 @@ export function WorkspaceSidebar({
|
|||||||
compact={compactChrome}
|
compact={compactChrome}
|
||||||
/>
|
/>
|
||||||
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
|
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
|
||||||
{/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐:
|
{/* 「多智能体会商」「最近写作」历史入口已移至「对话历史」页
|
||||||
仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */}
|
(/page/workspace/chats 的 Tabs,2026-08 需求):简洁模式(本分支,
|
||||||
{isSidebarOpen && !compactChrome && <RecentRoundtableList />}
|
无全局导航栏/页面侧栏时的工作区侧栏)不再在左下角展示这两个分组,
|
||||||
{isSidebarOpen && !compactChrome && <RecentWritingList />}
|
全量模式本就不展示。 */}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
{!compactChrome && (
|
{!compactChrome && (
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
|
|||||||
@@ -1,112 +1,22 @@
|
|||||||
import { apiFetch } from "@/core/api/fetch-client";
|
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
|
||||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
|
||||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||||
|
|
||||||
function filenameFromContentDisposition(header: string | null): string | null {
|
|
||||||
if (!header) return null;
|
|
||||||
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
|
|
||||||
if (star) {
|
|
||||||
const raw = star[1].replace(/["']/g, "").trim();
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(raw);
|
|
||||||
} catch {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const plain = /filename="?([^";\n]+)"?/i.exec(header);
|
|
||||||
return plain ? plain[1].trim() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeFallbackFilename(title: string | undefined): string {
|
|
||||||
const base = (title?.trim() || "document").replace(/[<>:"/\\|?*\x00-\x1f]+/g, "_");
|
|
||||||
return base.toLowerCase().endsWith(".docx") ? base : `${base}.docx`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadBlobResponse(response: Response, fallbackFilename: string) {
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response
|
|
||||||
.json()
|
|
||||||
.catch(() => ({ detail: "导出 Word 失败" }));
|
|
||||||
const detail = error.detail;
|
|
||||||
if (typeof detail === "string") throw new Error(detail);
|
|
||||||
throw new Error("导出 Word 失败");
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const filename =
|
|
||||||
filenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
|
|
||||||
fallbackFilename;
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const anchor = document.createElement("a");
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = filename;
|
|
||||||
anchor.rel = "noopener";
|
|
||||||
document.body.appendChild(anchor);
|
|
||||||
anchor.click();
|
|
||||||
anchor.remove();
|
|
||||||
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用 Markdown → Word 导出(Gateway `writing` 路由,与 `/api/writing/rewrite` 同级)。
|
* Unified Markdown → Word export.
|
||||||
*
|
*
|
||||||
* POST /api/writing/export/docx
|
* All download surfaces use the same required Gateway endpoint. The Gateway
|
||||||
* Body: { title?: string, markdown: string }
|
* applies the formal OOXML profile and embeds its bundled title font; there is
|
||||||
*
|
* deliberately no client-font fallback.
|
||||||
* 返回 `application/vnd.openxmlformats-officedocument.wordprocessingml.document`,
|
|
||||||
* 文件名在 `Content-Disposition` 中。
|
|
||||||
*/
|
*/
|
||||||
export async function downloadMarkdownDocxFromBackend(options: {
|
export async function downloadMarkdownDocxFromBackend(options: {
|
||||||
title?: string;
|
title?: string;
|
||||||
markdown: string;
|
markdown: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const markdown = options.markdown.trim();
|
const markdown = options.markdown.trim();
|
||||||
if (!markdown) {
|
if (!markdown) throw new Error("文档内容为空");
|
||||||
throw new Error("文档内容为空");
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackFilename = sanitizeFallbackFilename(options.title);
|
await downloadMarkdownAsDocx(
|
||||||
|
markdown,
|
||||||
try {
|
options.title?.trim() || "document",
|
||||||
const response = await apiFetch(`${getBackendBaseURL()}/api/writing/export/docx`, {
|
options.title,
|
||||||
method: "POST",
|
);
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: options.title?.trim() || undefined,
|
|
||||||
markdown,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status === 404) {
|
|
||||||
await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title);
|
|
||||||
writeYLog({
|
|
||||||
optType: OptType.export,
|
|
||||||
optDetail: "CM助手-全量-导出-Word",
|
|
||||||
url: `${getBackendBaseURL()}/api/writing/export/docx`,
|
|
||||||
reqParam: { title: options.title?.trim() || undefined },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadBlobResponse(response, fallbackFilename);
|
|
||||||
writeYLog({
|
|
||||||
optType: OptType.export,
|
|
||||||
optDetail: "CM助手-全量-导出-Word",
|
|
||||||
url: `${getBackendBaseURL()}/api/writing/export/docx`,
|
|
||||||
reqParam: { title: options.title?.trim() || undefined },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof TypeError) {
|
|
||||||
await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title);
|
|
||||||
writeYLog({
|
|
||||||
optType: OptType.export,
|
|
||||||
optDetail: "CM助手-全量-导出-Word",
|
|
||||||
url: `${getBackendBaseURL()}/api/writing/export/docx`,
|
|
||||||
reqParam: { title: options.title?.trim() || undefined },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { apiFetch } from "../api";
|
import { apiFetch } from "../api";
|
||||||
|
import { downloadMarkdownAsDocx } from "../scheduled-tasks/markdown-docx";
|
||||||
import { getFileName } from "../utils/files";
|
import { getFileName } from "../utils/files";
|
||||||
|
|
||||||
import { urlOfArtifact } from "./utils";
|
import { urlOfArtifact } from "./utils";
|
||||||
@@ -76,6 +77,24 @@ export async function downloadArtifactFile({
|
|||||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function downloadArtifactMarkdownAsWord({
|
||||||
|
filepath,
|
||||||
|
threadId,
|
||||||
|
}: {
|
||||||
|
filepath: string;
|
||||||
|
threadId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const url = urlOfArtifact({ filepath, threadId, download: false });
|
||||||
|
const res = await apiFetch(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(await errorMessageFromResponse(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = getFileName(filepath);
|
||||||
|
const title = fileName.replace(/\.(md|markdown)$/i, "");
|
||||||
|
await downloadMarkdownAsDocx(await res.text(), fileName, title);
|
||||||
|
}
|
||||||
|
|
||||||
export async function openArtifactInNewTab({
|
export async function openArtifactInNewTab({
|
||||||
filepath,
|
filepath,
|
||||||
threadId,
|
threadId,
|
||||||
|
|||||||
@@ -1,27 +1,25 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useThread } from "@/components/workspace/messages/context";
|
import { useThread } from "@/components/workspace/messages/context";
|
||||||
|
|
||||||
import { isImageFile } from "../utils/files";
|
import {
|
||||||
|
loadArtifactContent,
|
||||||
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
|
loadArtifactContentFromLatestWriteFile,
|
||||||
|
loadArtifactContentFromToolCall,
|
||||||
|
} from "./loader";
|
||||||
|
import {
|
||||||
|
SANDBOX_STREAM_FLUSH_MS,
|
||||||
|
isSandboxStreamPerfEnabled,
|
||||||
|
} from "./sandbox-stream-perf";
|
||||||
|
import { isArtifactApiPath } from "./streaming-write-preview";
|
||||||
|
import { isWriteFileInFlight, isWriteFileStreamingUrl } from "./write-file-in-flight";
|
||||||
import { updateArtifactContent } from "./api";
|
import { updateArtifactContent } from "./api";
|
||||||
|
|
||||||
function isWriteFileStreamingUrl(filepath: string): boolean {
|
function hasToolResult(
|
||||||
if (!filepath.startsWith("write-file:")) {
|
thread: ReturnType<typeof useThread>["thread"],
|
||||||
return false;
|
toolCallId?: string,
|
||||||
}
|
) {
|
||||||
try {
|
|
||||||
const url = new URL(filepath);
|
|
||||||
const path = decodeURIComponent(url.pathname);
|
|
||||||
return !isImageFile(path);
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasToolResult(thread: ReturnType<typeof useThread>["thread"], toolCallId?: string) {
|
|
||||||
if (!toolCallId) {
|
if (!toolCallId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -76,6 +74,48 @@ function applyPendingTextEdit({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the latest write_file text in a ref (always current for later readers)
|
||||||
|
* but only publish a display snapshot on a timer while the run is in flight.
|
||||||
|
* Display therefore does not follow `thread` object identity / every SSE chunk.
|
||||||
|
*/
|
||||||
|
function useThrottledWriteFileDisplay({
|
||||||
|
filepath,
|
||||||
|
liveContent,
|
||||||
|
enabled,
|
||||||
|
streaming,
|
||||||
|
}: {
|
||||||
|
filepath: string;
|
||||||
|
liveContent: string | undefined;
|
||||||
|
enabled: boolean;
|
||||||
|
streaming: boolean;
|
||||||
|
}) {
|
||||||
|
const latestRef = useRef(liveContent);
|
||||||
|
latestRef.current = liveContent;
|
||||||
|
const [displayed, setDisplayed] = useState(liveContent);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setDisplayed(latestRef.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const flush = () => {
|
||||||
|
setDisplayed((previous) =>
|
||||||
|
Object.is(previous, latestRef.current) ? previous : latestRef.current,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if (!streaming) {
|
||||||
|
flush();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
const intervalId = window.setInterval(flush, SANDBOX_STREAM_FLUSH_MS);
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, [enabled, filepath, streaming]);
|
||||||
|
|
||||||
|
return enabled && streaming ? displayed : liveContent;
|
||||||
|
}
|
||||||
|
|
||||||
export function useArtifactContent({
|
export function useArtifactContent({
|
||||||
filepath,
|
filepath,
|
||||||
threadId,
|
threadId,
|
||||||
@@ -89,19 +129,32 @@ export function useArtifactContent({
|
|||||||
return isWriteFileStreamingUrl(filepath);
|
return isWriteFileStreamingUrl(filepath);
|
||||||
}, [filepath]);
|
}, [filepath]);
|
||||||
const { thread, isMock, publicShareCode } = useThread();
|
const { thread, isMock, publicShareCode } = useThread();
|
||||||
const content = useMemo(() => {
|
const streamPerfEnabled = isSandboxStreamPerfEnabled();
|
||||||
if (useWriteFileStreaming) {
|
const liveWriteFileContent = useWriteFileStreaming
|
||||||
return loadArtifactContentFromToolCall({ url: filepath, thread });
|
? loadArtifactContentFromToolCall({ url: filepath, thread })
|
||||||
|
: undefined;
|
||||||
|
const writeFileInFlight =
|
||||||
|
useWriteFileStreaming && isWriteFileInFlight(thread, filepath);
|
||||||
|
const displayedWriteFileContent = useThrottledWriteFileDisplay({
|
||||||
|
filepath,
|
||||||
|
liveContent: liveWriteFileContent,
|
||||||
|
enabled: streamPerfEnabled && useWriteFileStreaming,
|
||||||
|
streaming: writeFileInFlight,
|
||||||
|
});
|
||||||
|
const content = useWriteFileStreaming ? displayedWriteFileContent : null;
|
||||||
|
const toolCallFallbackContent = useMemo(() => {
|
||||||
|
if (useWriteFileStreaming || !isArtifactApiPath(filepath)) {
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
return null;
|
return loadArtifactContentFromLatestWriteFile({ filepath, thread });
|
||||||
}, [filepath, useWriteFileStreaming, 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 +173,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { apiFetch } from "@/core/api";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
|
export type ArtifactLibraryKind =
|
||||||
|
| "all"
|
||||||
|
| "document"
|
||||||
|
| "spreadsheet"
|
||||||
|
| "text_data"
|
||||||
|
| "archive"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
export type ArtifactLibrarySource = "all" | "general_qa" | "agent";
|
||||||
|
|
||||||
|
export interface ArtifactLibraryItem {
|
||||||
|
threadId: string;
|
||||||
|
threadTitle: string;
|
||||||
|
agentId: string | null;
|
||||||
|
agentName: string | null;
|
||||||
|
threadType: string | null;
|
||||||
|
sourceType: Exclude<ArtifactLibrarySource, "all">;
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
mimeType: string | null;
|
||||||
|
modifiedAt: string;
|
||||||
|
fileKind: Exclude<ArtifactLibraryKind, "all">;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtifactLibraryResponse {
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
items: ArtifactLibraryItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchArtifactLibrary({
|
||||||
|
query,
|
||||||
|
fileKind,
|
||||||
|
sourceType,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
refresh = false,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
query?: string;
|
||||||
|
fileKind: ArtifactLibraryKind;
|
||||||
|
sourceType: ArtifactLibrarySource;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
refresh?: boolean;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<ArtifactLibraryResponse> {
|
||||||
|
const search = new URLSearchParams({
|
||||||
|
file_kind: fileKind,
|
||||||
|
source_type: sourceType,
|
||||||
|
offset: String(offset),
|
||||||
|
limit: String(limit),
|
||||||
|
});
|
||||||
|
const normalizedQuery = query?.trim();
|
||||||
|
if (normalizedQuery) search.set("query", normalizedQuery);
|
||||||
|
if (refresh) search.set("refresh", "true");
|
||||||
|
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/artifact-library?${search.toString()}`,
|
||||||
|
{ method: "GET", signal },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text().catch(() => "");
|
||||||
|
throw new Error(
|
||||||
|
`Failed to load artifact library (${response.status})${detail ? `: ${detail}` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
total?: number;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
items?: Array<{
|
||||||
|
thread_id: string;
|
||||||
|
thread_title: string;
|
||||||
|
agent_id?: string | null;
|
||||||
|
agent_name?: string | null;
|
||||||
|
thread_type?: string | null;
|
||||||
|
source_type?: Exclude<ArtifactLibrarySource, "all">;
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
size_bytes: number;
|
||||||
|
mime_type?: string | null;
|
||||||
|
modified_at: string;
|
||||||
|
file_kind: Exclude<ArtifactLibraryKind, "all">;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: data.total ?? 0,
|
||||||
|
offset: data.offset ?? offset,
|
||||||
|
limit: data.limit ?? limit,
|
||||||
|
items: (data.items ?? []).map((item) => ({
|
||||||
|
threadId: item.thread_id,
|
||||||
|
threadTitle: item.thread_title,
|
||||||
|
agentId: item.agent_id ?? null,
|
||||||
|
agentName: item.agent_name ?? item.agent_id ?? null,
|
||||||
|
threadType: item.thread_type ?? null,
|
||||||
|
sourceType: item.source_type ?? (item.agent_id ? "agent" : "general_qa"),
|
||||||
|
path: item.path,
|
||||||
|
name: item.name,
|
||||||
|
sizeBytes: item.size_bytes,
|
||||||
|
mimeType: item.mime_type ?? null,
|
||||||
|
modifiedAt: item.modified_at,
|
||||||
|
fileKind: item.file_kind,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
Loaded 100 of 1115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user