Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca2696b23f | ||
|
|
90e94994f5 | ||
|
|
2718915d34 | ||
|
|
8e0d755b57 | ||
|
|
ee83258b70 | ||
|
|
40542db400 | ||
|
|
f18994fd38 | ||
|
|
7c15ca6710 | ||
|
|
31cfa93408 | ||
|
|
b164e70541 | ||
|
|
3715af3138 | ||
|
|
973500a0f0 | ||
|
|
d9a94245fc | ||
|
|
1764d0d5f2 | ||
|
|
84370e2072 | ||
|
|
0289a76fce | ||
|
|
32e509b2b3 | ||
|
|
440b490b0d | ||
|
|
52b9cbf707 | ||
|
|
87505feac4 | ||
|
|
11326eeb1c | ||
|
|
485a8fa9d9 | ||
|
|
c5eefe60f0 | ||
|
|
4ce2f3a502 | ||
|
|
6e9e89a6cb | ||
|
|
5be21f2c3d | ||
|
|
90d29ed232 | ||
|
|
d9de41dd67 | ||
|
|
f1245da3e4 | ||
|
|
38c3de39f0 | ||
|
|
33639e5b89 | ||
|
|
d488e65e49 | ||
|
|
676b00afb6 | ||
|
|
0980eea975 | ||
|
|
eba3008482 | ||
|
|
9b904ac3dd | ||
|
|
704438b3d2 | ||
|
|
d35d075669 | ||
|
|
910cee9e67 | ||
|
|
dcc3e0799d | ||
|
|
21f3787b8b | ||
|
|
33b719d460 | ||
|
|
c6fcf51e0c | ||
|
|
62a3faa7c8 | ||
|
|
7cc6aca398 | ||
|
|
d87dfec2de | ||
|
|
5c0864bf0a | ||
|
|
8ce3a62826 | ||
|
|
8c3d6a02fc | ||
|
|
dd1064b3cb | ||
|
|
be9a490923 | ||
|
|
e053b36b6e | ||
|
|
9f29ecae9e | ||
|
|
2ea331f9ec | ||
|
|
296974e9e5 | ||
|
|
ed5b550ead | ||
|
|
4b86106a24 | ||
|
|
7f2c51957b | ||
|
|
043b0900d5 | ||
|
|
0c5e2e8cb0 | ||
|
|
40d06b781a | ||
|
|
c5328e6a35 | ||
|
|
babc5e95c6 | ||
|
|
644cabd23d | ||
|
|
1736227723 | ||
|
|
30943159bf | ||
|
|
c4d61288b4 | ||
|
|
389df2ace5 | ||
|
|
007ed5a7de | ||
|
|
ebc727e4fd | ||
|
|
32dbdca480 | ||
|
|
bc49df4561 | ||
|
|
28eb8f86a7 | ||
|
|
cdb8aa5f81 | ||
|
|
e818c24769 | ||
|
|
49babb6d25 | ||
|
|
b3aa9f4bac | ||
|
|
3785345353 | ||
|
|
daaa0eab26 | ||
|
|
541bc3280f | ||
|
|
b81d200eca | ||
|
|
51afebb394 | ||
|
|
1c0e4e9723 | ||
|
|
a5a9c952e8 | ||
|
|
4c9f75f31e | ||
|
|
d19d6ced66 | ||
|
|
7c34e18ad6 | ||
|
|
11f928bc95 | ||
|
|
e881c242fe | ||
|
|
8e77e2da1e | ||
|
|
e860357563 | ||
|
|
bc49ef6027 | ||
|
|
288ba64923 | ||
|
|
c69fde22bd | ||
|
|
0ce060693b | ||
|
|
db5b6ba574 | ||
|
|
be4851555d | ||
|
|
3afa76894f | ||
|
|
acf538ac01 | ||
|
|
5e1765d889 | ||
|
|
30799f17be | ||
|
|
dcfaa199bf | ||
|
|
1f961ee1df | ||
|
|
33b0af333c | ||
|
|
172250e6b5 | ||
|
|
15a46b903a | ||
|
|
6ff04ea4e3 | ||
|
|
0f667261be | ||
|
|
e412d7db3f | ||
|
|
6de3026f8f | ||
|
|
64067ea478 | ||
|
|
8af4fed89d | ||
|
|
e0b6e1cd19 | ||
|
|
64a5305bbf | ||
|
|
819e537aa3 | ||
|
|
68c4896088 | ||
|
|
771957429a | ||
|
|
5bf08c83a6 | ||
|
|
3ab2c9b282 | ||
|
|
94cf37a2ae | ||
|
|
47faa9bcdf | ||
|
|
5901246030 | ||
|
|
44e0b7596b | ||
|
|
5c586652af | ||
|
|
067f602732 | ||
|
|
96bfdacee5 | ||
|
|
a28ad2140e | ||
|
|
e2041c0b07 | ||
|
|
2fa7000ff7 | ||
|
|
760b88d6f1 |
No files matched your search
@@ -1,10 +1,10 @@
|
||||
# Workspace/editor
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
frontend-web/.vscode/
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
frontend-web/.vscode/
|
||||
node_modules/
|
||||
# Claude Code local-only settings (per-user permissions, not shared)
|
||||
.claude/settings.local.json
|
||||
@@ -56,9 +56,9 @@ offline-backend-20260505/bundle/start_backend.sh
|
||||
offline-backend-20260505/bundle/stop_backend.sh
|
||||
|
||||
# offline-backend-20260512 runtime artifacts (large files, local config, data)
|
||||
offline-backend-20260512/.env.example
|
||||
offline-backend-20260512/.env
|
||||
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
|
||||
offline-backend-20260512/.env.example
|
||||
offline-backend-20260512/.env
|
||||
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
|
||||
offline-backend-20260512/SHA256SUMS
|
||||
offline-backend-20260512/config.yaml.example
|
||||
offline-backend-20260512/data/
|
||||
@@ -66,14 +66,14 @@ offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
|
||||
offline-backend-20260512/extensions_config.json
|
||||
offline-backend-20260512/skills/
|
||||
offline-backend-20260512/wheelhouse/
|
||||
offline-backend-20260512/backend/.deer-flow/data/*.db
|
||||
offline-backend-20260512/backend/.deer-flow/data/*.db-*
|
||||
offline-backend-20260512/backend/.deer-flow/*
|
||||
offline-backend-20260512/backend/.env
|
||||
offline-backend-20260512/backend/.env.local
|
||||
offline-backend-20260512/start_backend.sh
|
||||
offline-backend-20260512/stop_backend.sh
|
||||
offline-backend-20260512/check_backend.sh
|
||||
offline-backend-20260512/backend/.deer-flow/data/*.db
|
||||
offline-backend-20260512/backend/.deer-flow/data/*.db-*
|
||||
offline-backend-20260512/backend/.deer-flow/*
|
||||
offline-backend-20260512/backend/.env
|
||||
offline-backend-20260512/backend/.env.local
|
||||
offline-backend-20260512/start_backend.sh
|
||||
offline-backend-20260512/stop_backend.sh
|
||||
offline-backend-20260512/check_backend.sh
|
||||
|
||||
hermes-agent-main/
|
||||
|
||||
@@ -84,15 +84,41 @@ offline-backend-20260512/backend/.deer-flow/*
|
||||
|
||||
# Per-user skill_evolution overrides — runtime data written by the API
|
||||
offline-backend-20260512/backend/skill_evolution.json
|
||||
skills/
|
||||
hermes-agent-main/
|
||||
skills/
|
||||
hermes-agent-main/
|
||||
.gitignore
|
||||
offline-backend-20260512/backend/tests/*
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
||||
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
||||
# Keep formal Word OOXML and embedded-font regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_word_export.py
|
||||
# Keep the model configuration regression coverage versioned even though
|
||||
# this deployment repository otherwise ignores newly added backend tests.
|
||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
||||
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
|
||||
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_fixed_questions.py
|
||||
# Keep configurable es_query system-prompt routing regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py
|
||||
# Keep the local LLMWiki vector-index and external API regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
|
||||
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
|
||||
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
|
||||
# Deep-research reliability regression coverage.
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_start_job.py
|
||||
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
|
||||
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
|
||||
# Independent enterprise-research persistence, HTTP and race regressions.
|
||||
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
|
||||
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
|
||||
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
|
||||
deploy
|
||||
.history
|
||||
|
||||
@@ -102,17 +128,22 @@ git-clone/
|
||||
# Repo-root offline dependency pack (local only)
|
||||
/dist/
|
||||
|
||||
# Frontend offline pack working directories
|
||||
frontend-web/.offline-pack-build/
|
||||
frontend-web/.offline-extract-test/
|
||||
frontend-web-node_modules-offline-*.tar.gz
|
||||
|
||||
# Local dependency caches and backend scratch archives
|
||||
/.pnpm-store/
|
||||
/offline-backend-20260512/backend/backend*.zip
|
||||
|
||||
.codegraph
|
||||
codegraph.mdc
|
||||
# Frontend offline pack working directories
|
||||
frontend-web/.offline-pack-build/
|
||||
frontend-web/.offline-extract-test/
|
||||
frontend-web-node_modules-offline-*.tar.gz
|
||||
|
||||
# Local dependency caches and backend scratch archives
|
||||
/.pnpm-store/
|
||||
/offline-backend-20260512/backend/backend*.zip
|
||||
|
||||
# Local offline bundles and scratch tooling
|
||||
/*.tar.gz
|
||||
/offline-backend-20260512/*.zip
|
||||
/tmp/
|
||||
|
||||
.codegraph
|
||||
codegraph.mdc
|
||||
|
||||
docs/aidev-docs
|
||||
docs/backups
|
||||
@@ -129,4 +160,4 @@ offline-backend-20260512/backend/uv.lock
|
||||
__pycache__/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
@@ -47,7 +47,7 @@
|
||||
"name": "DeerFlow: Open Frontend in Chrome",
|
||||
"type": "node-terminal",
|
||||
"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/'\"",
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"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",
|
||||
"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/`.
|
||||
|
||||
## Offline Release Repository
|
||||
|
||||
Linux AMD64 离线发版脚本、wheelhouse、`deploy.env` 模板和部署文档统一维护在
|
||||
`knowledge-base-bj/deerflow-offline-deployment`,不要把离线镜像或发版资产提交到
|
||||
本业务源码仓库。正式发版从该仓库执行 `scripts/build-latest-master.sh`,脚本会读取
|
||||
本仓库最新 `master` 的干净源码快照。
|
||||
|
||||
## Commands
|
||||
|
||||
### Full Application (from repo root)
|
||||
@@ -81,16 +88,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)
|
||||
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
|
||||
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
|
||||
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all).
|
||||
- **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 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/`。
|
||||
- **菜单管理 (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`,**输入框只有发送按钮**;问答经 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.
|
||||
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
|
||||
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
|
||||
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
|
||||
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
|
||||
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
||||
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
|
||||
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
|
||||
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations staLine truncated
|
||||
- **agentfx 任务深链(新系统 iframe 嵌入的智能体问答,3qfx 单智能体 lite 模式的独立复制)**: goPath=`agentfx`(`${loginBase}?password=…&taskId=…&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue`)。与 3qfx 的差异:①任务详情不走外部 consumer,改调**本系统 Gateway** `GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=`(`apiFetch` 带刚登录的 DeerFlow 态;任务数据来自 TaskCOP 任务表,与任务列表/报告导入同一份);②**固定使用内置智能体** `agentfx-analyst`(不走业务映射);③路由 `task-management/plan-task-agentfx/agent/:agent_id/chats`,页面为独立复制的 `AgentFxWorkspace.tsx`(`AgentFxWorkspace`/`AgentFxIndexResolver`/`AgentFxChat` —— 左「当前任务」卡 + 1:N 对话列表 + 新建对话,`AgentChatPage taskBind` 自动发开场白;无 TaskJumpBar/清除记录/闸门/xdfx 行动卡)。模块 `core/auth/agentfx-deeplink.ts`(`isAgentfxGoPath`/`fetchAgentfxTaskDetail`/`resolveAgentfxDeeplinkTarget`);任务详情整包仍存共享键 `login.copTaskDetail`(按标签页隔离),`AgentChatPage` 的 taskBind 自恢复对 agentfx 生效。`LoginPage` 在既有深链分支前用 `isAgentfxGoPath` 分流。**内置「任务研判报告助手」**:后端 seeder `app/gateway/routers/_agentfx_seed.py` + `_agentfx_seed_assets/`(config.yaml + SOUL.md,目录存在不覆盖;app.py lifespan 在 `_sync_legacy_agents` 之前调用,自动进 agents 表 user_id IS NULL)——SOUL 规定工作流「搜集信息(`web_search`,**首步不可跳过**)→ `write_file` 生成 `/mnt/user-data/outputs/report.md` + `task-reports.json`(14 个 categoryType 的 `{contentJson, categoryType}` 数组,contentJson 为 JSON 字符串)→ 聊天总结 → 引导入库」,硬性约束:categoryType 逐字用 14 个全称(禁缩写自造如「DQ关键事件」)、JSON 直接写汉字禁 `\uXXXX` 转义、每类必须有实质内容不许空数组/纯占位、文件一律用 `/mnt/user-data/...` 虚拟路径(防宿主路径泄漏进 tool args)。**检索依赖 `config.yaml tools.web_search`(DDG,enabled 已开;内网需换 configurable_search 时改回并填 endpoint)**。任务工作区输入框在 `taskCtxOn` 时隐藏「写作/记忆/参考文献」(InputBox `hideContextTools` prop)与「知识空间」(不传 knowledgeSelector);消息流里 write_file 文件卡片只显示文件名(`artifactDisplayName` 收敛宿主/虚拟路径)。**入库两条路径**:(a) **前端协助卡** —— `AgentChatPage`(taskBind 且非 taskGate)检测线程 artifacts 出现 `task-reports.json`(按文件名后缀匹配,兼容本地沙箱记录的宿主路径)且流式结束时,向 displayThread 尾部注入 `additional_kwargs.task_report_import_approval` 虚拟 ai 消息(`core/messages/utils.ts` 分组 `assistant:task-report-import` + `MessageList` 的 `taskReportImportSlot` 插槽渲染 `TaskReportImportCard`),点「开始入库」前端校验(`strategy-components/lib/agentfx-report-schema.ts`,14 类 + contentJson 合法 JSON + **\u 转义归一化还原为汉字**,与技能脚本同规则)后 `importTaskReport`(`strategy-components/api/task-reports.ts`)POST 导入接口整体替换;任务已有详情**不抑制**卡片(入库即整体替换,卡片文案说明覆盖语义),导入成功按 thread 记 sessionStorage(刷新不重复询问)、「暂不入库」为会话内记忆;(b) **入库技能** `task-report-import`(seeder 一并复制到 `skills/public/`,public 类技能 extensions_config 缺省即启用)—— 用户聊天里说「入库」时智能体按 SKILL.md 用 bash 跑 `scripts/task_report_import.py`(纯标准库:同样的校验规则 + 转义归一化;**默认关闭 SSL 证书校验** `ssl._create_unverified_context` + 忽略代理,适配内网自签证书;鉴权 `--token`/env `AGENTFX_IMPORT_TOKEN`→`DEERFLOW_AUTH_TOKEN`→`TOOLS_TOKEN` 优先,缺省经公共端点 `POST /api/parallel-agents/auth/token` 以 `agentfx-import` 用户现签 token——内网既有鉴权旁路;gateway 地址 env `AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:{DEER_FLOW_GATEWAY_PORT}`);`--strict` 可要求 14 类齐全,默认允许部分导入并在 stdout JSON 的 `missing_categories` 里列缺。入库成功后任务 TaskCOP 状态自动置 25(已完成分析)。**入库成功通知宿主**(iframe 嵌入场景):两条路径成功后都会 `window.top.postMessage({source: "magent-web", type: "task-report:saved", taskId: String(taskId)}, "*")`(`notifyTaskReportSaved`,`agentfx-report-schema.ts`)——卡片路径在 `TaskReportImportCard` 导入成功回调里直发;技能路径由 `AgentChatPage` 从消息流检测 `task_report_import.py` 的成功 stdout(`"success": true` + `task_id`)代发——**仅在回合结束后检测**(`!thread.isLoading` 才扫描;流式过程中 messages Line truncated
|
||||
- **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
|
||||
|
||||
@@ -82,13 +82,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.
|
||||
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
|
||||
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
|
||||
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all).
|
||||
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`.
|
||||
- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`.
|
||||
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
|
||||
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
|
||||
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, openMode, loginParams, appendAuth, authTokenParam, authNameParam, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=<JWT>&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」页,在 **both** 单智能体 (`TaskFxWorkspace`) 与 多智能体会商 (`RoundtablePlanningPage` 深链 rwfx-A) 都显示(共用 `TaskJumpBar`)。**`runTaskButton` 的 `url` 类型支持应用内路由**:target 以单个 `/` 开头时按内部路由处理——`self` 走 react-router 同窗导航(不刷新),`blank` 拼成当前源的 hash 路由新标签打开;外链行为不变。 The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三Line truncated
|
||||
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, openMode, loginParams, appendAuth, authTokenParam, authNameParam, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route (e.g. `/task/item-aiceh/step-analysis-audience`), `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). Independent-tab clicks toast and do nothing. **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=<JWT>&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」Line truncated
|
||||
- **实时并发监控 (Concurrency / System-Pressure Monitor, admin-only)**: a live panel mounted at the **top of the admin 排行榜 page** (`components/workspace/admin/concurrency-monitor.tsx` `ConcurrencyMonitorPanel`, rendered in `pages/AdminUserLeaderboardPage.tsx`) showing **当前并发的大模型调用数 + 列表(用户/智能体/类型/已运行时长)**, each row with a 「停止」button (and a 「全部停止」) that **truly aborts** that run's in-flight model stream via `RunManager.cancel` (`POST /api/admin/active-runs/{run_id}/cancel`) — to relieve pressure when 使用人数过多. Two line charts (minute/hour toggle): **并发量折线图** (peak/avg, `GET /api/admin/active-runs/concurrency`, fed by a lightweight background sampler into `concurrency_samples`) and **大模型出字速度折线图** (tokens/sec, `GET /api/admin/active-runs/token-speed`, aggregated from `llm_call_metrics`) so a dip pinpoints where the bottleneck is. A **「后台采样」Switch** (`system_settings.json → concurrency_monitor.enabled`, **default off / opt-in**, `GET|PUT /api/admin/active-runs/settings`) turns the background sampling on/off **at runtime, no restart** (the live concurrency count + 「停止」 work even while sampling is off — only the 并发量 history chart needs it on); everything is designed to **never impact production** (one tiny INSERT/15s, retention-purged, best-effort). Client/hooks: `core/admin/active-runs.ts` + `core/admin/hooks.ts` (`useActiveRuns`/`useConcurrencySeries`/`useTokenSpeedSeries`/`useCancel*`/`useMonitorSettings`). See the backend CLAUDE.md「Admin Concurrency Monitor」.
|
||||
- **会话冲突 409 中文化**: when a user submits a new question while the previous turn is still streaming, the backend's `RunManager.create_or_reject` rejects with HTTP 409 (`services.py` now returns a **Chinese** detail「当前对话还在生成回答…」). `core/threads/hooks.ts` `onError` detects the conflict (`isActiveRunConflictError`: 409 / "already has an active run" / the Chinese text) and shows a friendly `toast.info` instead of a raw error.
|
||||
- **会话冲突 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`
|
||||
- **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/`
|
||||
|
||||
@@ -8,7 +8,7 @@ VITE_USE_REAL_DATA=true
|
||||
VITE_MOCK_COP_TASK_DETAIL=true
|
||||
|
||||
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。
|
||||
VITE_MOCK_SQ_REPORT=true
|
||||
VITE_MOCK_SQ_REPORT=false
|
||||
|
||||
# 深链任务详情(cop-task-detail:任务名称/描述)数据源:true 走本地假数据(真实接口未就绪时调试用);联调通过后置 false。
|
||||
VITE_MOCK_COP_TASK_DETAIL=true
|
||||
|
||||
@@ -56,4 +56,7 @@
|
||||
# VITE_STATIC_WEBSITE_ONLY=
|
||||
# 部署级紧凑导航:true 时固定收起工作台左侧栏,并隐藏换肤与全量/简洁模式切换。
|
||||
# VITE_FORCE_COLLAPSED_SIDEBAR=false
|
||||
# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。
|
||||
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。
|
||||
# VITE_COMPACT_MENU_TYPE=A
|
||||
# VITE_GITHUB_OAUTH_TOKEN=
|
||||
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-labelledby="title">
|
||||
<title id="title">CM 助手</title>
|
||||
<defs>
|
||||
<linearGradient id="logoTopLeft" x1="7" y1="7" x2="25" y2="20" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#5DB5FF"/>
|
||||
<stop offset="1" stop-color="#328AF2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="logoTopRight" x1="24" y1="3" x2="43" y2="19" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#62B2FF"/>
|
||||
<stop offset="1" stop-color="#4B98F4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="logoLeft" x1="5" y1="14" x2="24" y2="45" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2C7CF0"/>
|
||||
<stop offset="1" stop-color="#1F5EDA"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="logoBottom" x1="9" y1="31" x2="43" y2="40" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#276DE3"/>
|
||||
<stop offset="1" stop-color="#4E9DF5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- The mark is drawn as four independent folded faces. Together they form
|
||||
the open C-shaped cube used in the product reference. -->
|
||||
<path d="M5 13.5 24 3v10L14 18.5 5 13.5Z" fill="url(#logoTopLeft)"/>
|
||||
<path d="M24 3 43 13.5 34 18.5 24 13V3Z" fill="url(#logoTopRight)"/>
|
||||
<path d="M5 13.5 14 18.5v11L24 35v10L5 34.5v-21Z" fill="url(#logoLeft)"/>
|
||||
<path d="M14 29.5 24 35l10-5.5 9 5L24 45 5 34.5l9-5Z" fill="url(#logoBottom)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -32,10 +32,10 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
VITE_BRAND_WELCOME_DARK_URL: "",
|
||||
|
||||
// DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。
|
||||
// VITE_BACKEND_BASE_URL: "http://10.19.200.5:2026",
|
||||
// VITE_BACKEND_BASE_URL: "https://ch1.b.uat.4.cn/deerflow",
|
||||
|
||||
// LangGraph 流式对话接口地址:普通问答、智能体对话等流式能力使用。
|
||||
// VITE_LANGGRAPH_BASE_URL: "http://10.19.200.5:2026/api",
|
||||
// VITE_LANGGRAPH_BASE_URL: "https://ch1.b.uat.4.cn/deerflow/api",
|
||||
|
||||
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
|
||||
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
|
||||
@@ -50,6 +50,19 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// CMZS 后端接口:任务管理、任务分析、三情分析、行动分析等业务接口。
|
||||
VITE_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm",
|
||||
|
||||
// 8BF 嵌入页专用任务系统接口。部署到其他系统时只需修改这一项,
|
||||
// 不影响原多智能体会商使用的任务详情接口。
|
||||
VITE_8BF_TASK_API_BASE_URL: "https://ch1.b.uat.4.cn/zl_consumer",
|
||||
|
||||
// 8BF 会商页右上角「返回」按钮的跳转地址(当前窗口 window.location.href)。
|
||||
// 部署后直接改 dist/runtime-config.js 即可,无需重新打包。留空则点击时提示未配置。
|
||||
VITE_8BF_ROUNDTABLE_BACK_URL: "",
|
||||
|
||||
// 任务深链「宿主路由」按钮的 postMessage targetOrigin(HOST_NAVIGATE)。
|
||||
// 填宿主系统 origin,例如 https://localhost:4912;生产改成他们线上地址。
|
||||
// 留空时尝试用 iframe 的 ancestorOrigins / referrer 自动探测,探测失败则不发送。
|
||||
VITE_HOST_NAVIGATE_ORIGIN: "",
|
||||
|
||||
// 佳来模型对话接口。
|
||||
VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt",
|
||||
|
||||
@@ -90,7 +103,7 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
VITE_AIPI_FRONTEND_URL: "http://47.88.25.99:3000",
|
||||
|
||||
// Consumer 接口:深链任务详情 cop-task-detail、研判情报报告 selectSqReport 等使用。
|
||||
VITE_AIPI_CONSUMER_URL: "http://10.19.200.5:8002/consumer",
|
||||
VITE_AIPI_CONSUMER_URL: "https://ch1.b.uat.4.cn/consumer",
|
||||
|
||||
// A 系统跳转地址:人物、组织、事件等列表项打开链接使用。
|
||||
VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain",
|
||||
@@ -130,6 +143,11 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// 隐藏系统 Logo、换肤按钮以及“全量/简洁模式”切换。修改后刷新页面即可生效。
|
||||
VITE_FORCE_COLLAPSED_SIDEBAR: "false",
|
||||
|
||||
// 简介模式左侧菜单类型:与菜单管理页「简介模式」里的类型 A / B 对应。
|
||||
// 两套网页部署时分别填 "A" 或 "B"。不填、留空、或旧配置没有这一项,都按类型 A,
|
||||
// 继续读取原来的简介菜单数据,无需改旧页面。
|
||||
VITE_COMPACT_MENU_TYPE: "A",
|
||||
|
||||
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
|
||||
VITE_GITHUB_OAUTH_TOKEN: "",
|
||||
|
||||
@@ -138,8 +156,8 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
VITE_SENTIMENT_AGENT_VISIBLE: "true",
|
||||
|
||||
// 舆情分析智能体:外部流式问答接口地址(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 用户名。
|
||||
VITE_SENTIMENT_AGENT_AUTH_USERNAME: "1054841959975223296",
|
||||
|
||||
@@ -31,6 +31,16 @@ const requiredLegacyFallbacks = [
|
||||
pattern: /<InputGroup className="h-auto min-h-16">/,
|
||||
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) {
|
||||
|
||||
@@ -38,6 +38,7 @@ import EmbedChatPage from "./pages/EmbedChatPage";
|
||||
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
||||
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
|
||||
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
||||
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
|
||||
import {
|
||||
isPositionRoundtableStandaloneMode,
|
||||
POSITION_ROUNDTABLE_ROUTE,
|
||||
@@ -45,7 +46,12 @@ import {
|
||||
import { ChatRuntime } from "./pages/WorkspaceRoutes";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
|
||||
const PUBLIC_EMBED_PREFIXES = ["/share/", "/public/", "/roundtable-share/"];
|
||||
const PUBLIC_EMBED_PREFIXES = [
|
||||
"/share/",
|
||||
"/public/",
|
||||
"/roundtable-share/",
|
||||
"/embed/knowledge/",
|
||||
];
|
||||
|
||||
function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
const locale = useMemo(() => detectLocale(), []);
|
||||
@@ -102,16 +108,17 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
forcedTheme={forcedTheme}
|
||||
>
|
||||
<QueryClientProvider>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
{!positionStandaloneMode && <AuthSyncWatcher />}
|
||||
{!positionStandaloneMode && <DesensitizeWordsLoader />}
|
||||
<FontScaleApplier />
|
||||
{children}
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
<AuthSyncWatcher disabled={positionStandaloneMode}>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
{!positionStandaloneMode && <DesensitizeWordsLoader />}
|
||||
<FontScaleApplier />
|
||||
{children}
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</AuthSyncWatcher>
|
||||
</QueryClientProvider>
|
||||
</NextThemesProvider>
|
||||
);
|
||||
@@ -188,6 +195,10 @@ export function App() {
|
||||
}
|
||||
/>
|
||||
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
|
||||
<Route
|
||||
path="/embed/knowledge/:noteId"
|
||||
element={<WeKnoraWikiPageView publicEmbed />}
|
||||
/>
|
||||
<Route
|
||||
path="/roundtable-dashboard"
|
||||
element={<DashboardStandalonePage />}
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
@@ -165,7 +165,7 @@ export const ReasoningTrigger = memo(
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
@@ -178,7 +178,11 @@ export const ReasoningContent = memo(
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
|
||||
{typeof children === "string" ? (
|
||||
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { FileOutput } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import responsibilityIcon from "@/assets/position-roundtable/责任岗位.svg";
|
||||
import resultTimeIcon from "@/assets/position-roundtable/时间.svg";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import "./analysis-results.css";
|
||||
import type { AnalysisResultCardItem } from "./types";
|
||||
|
||||
function formatResultTime(value: string | undefined, fallback: string): string {
|
||||
if (!value) return fallback;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function AnalysisResultCardList({
|
||||
items,
|
||||
emptyTitle = "暂无交付产物",
|
||||
emptyDescription = "第二步研讨与第三步总结生成 Markdown 后,产物会显示在这里。",
|
||||
padded = true,
|
||||
onOpen,
|
||||
}: {
|
||||
items: AnalysisResultCardItem[];
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
padded?: boolean;
|
||||
onOpen: (item: AnalysisResultCardItem) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="position-roundtable-analysis-results-empty flex h-full min-h-[260px] flex-col items-center justify-center p-6 text-center">
|
||||
<FileOutput className="mb-3 size-8 text-muted-foreground/50" />
|
||||
<p className="text-sm font-semibold">{emptyTitle}</p>
|
||||
<p className="mt-1 max-w-[240px] text-xs leading-5 text-muted-foreground">
|
||||
{emptyDescription}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("analysis-result-card-list", padded && "p-[15px]")}>
|
||||
{items.map((item, index) => {
|
||||
const displayedTime = formatResultTime(
|
||||
item.updatedAt,
|
||||
item.fallbackTime || "刚刚",
|
||||
);
|
||||
const displayedProductName = item.delivered ? item.productName : "未交付";
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="position-roundtable-analysis-result-card"
|
||||
onClick={() => {
|
||||
if (!item.delivered) {
|
||||
toast.info(
|
||||
item.unavailableMessage ||
|
||||
`「${desensitize(item.agentName)}」尚未交付可预览的 Markdown 文件。`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
onOpen(item);
|
||||
}}
|
||||
>
|
||||
<div className="position-roundtable-analysis-result-card-body">
|
||||
<div
|
||||
className={cn(
|
||||
"position-roundtable-analysis-result-tag",
|
||||
`position-roundtable-analysis-result-tag--${(index % 6) + 1}`,
|
||||
)}
|
||||
aria-label={desensitize(item.agentName)}
|
||||
>
|
||||
<span>{desensitize(item.agentName)}</span>
|
||||
</div>
|
||||
<p className="position-roundtable-analysis-result-product">
|
||||
{desensitize(displayedProductName)}
|
||||
</p>
|
||||
<footer className="position-roundtable-analysis-result-meta">
|
||||
<span>
|
||||
<img src={responsibilityIcon} alt="" aria-hidden="true" />
|
||||
{desensitize(item.positionName)}
|
||||
</span>
|
||||
<i aria-hidden="true" />
|
||||
<span>
|
||||
<img src={resultTimeIcon} alt="" aria-hidden="true" />
|
||||
{displayedTime}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FileOutput, Loader2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { ArtifactFilePreview } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { loadArtifactContent } from "@/core/artifacts/loader";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { Button as TButton } from "tdesign-react";
|
||||
|
||||
import type { AnalysisResultCardItem } from "./types";
|
||||
|
||||
function isArtifactErrorContent(content: string | null | undefined): boolean {
|
||||
const trimmed = content?.trim();
|
||||
if (!trimmed || !trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
const detail = parsed.detail ?? parsed.message ?? parsed.error;
|
||||
return typeof detail === "string" && detail.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackContent(item: AnalysisResultCardItem | null): string {
|
||||
return (
|
||||
item?.artifactContent?.trim() ||
|
||||
item?.summary?.trim() ||
|
||||
"暂无产物正文"
|
||||
);
|
||||
}
|
||||
|
||||
export function AnalysisResultDetailDialog({
|
||||
item,
|
||||
onOpenChange,
|
||||
extraBody,
|
||||
footerStart,
|
||||
}: {
|
||||
item: AnalysisResultCardItem | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
extraBody?: ReactNode;
|
||||
footerStart?: ReactNode;
|
||||
}) {
|
||||
const artifactThreadId = item?.artifactThreadId || null;
|
||||
const artifactPathValue = item?.artifactPath || "";
|
||||
const artifactContentQuery = useQuery({
|
||||
queryKey: [
|
||||
"analysis-results",
|
||||
"deliverable-artifact-content",
|
||||
artifactThreadId,
|
||||
artifactPathValue,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!artifactThreadId || !artifactPathValue) return "";
|
||||
const result = await loadArtifactContent({
|
||||
filepath: artifactPathValue,
|
||||
threadId: artifactThreadId,
|
||||
isMock: false,
|
||||
});
|
||||
return result.content;
|
||||
},
|
||||
enabled: Boolean(item && artifactThreadId && artifactPathValue),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const loadedArtifactContent =
|
||||
typeof artifactContentQuery.data === "string" ? artifactContentQuery.data : "";
|
||||
const loadedArtifactIsError = isArtifactErrorContent(loadedArtifactContent);
|
||||
const artifactContent =
|
||||
loadedArtifactContent.trim() && !loadedArtifactIsError ? loadedArtifactContent : null;
|
||||
const displayedContent = artifactContent || fallbackContent(item);
|
||||
const artifactLoadFailed =
|
||||
(artifactContentQuery.isError || loadedArtifactIsError) &&
|
||||
!item?.artifactContent?.trim();
|
||||
|
||||
return (
|
||||
<Dialog open={Boolean(item)} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
overlayClassName="!z-[6000]"
|
||||
className="!z-[6001] flex max-h-[86vh] w-[900px] !max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0"
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<FileOutput className="size-4 text-primary" />
|
||||
交付产物详情
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!item ? null : (
|
||||
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
|
||||
{desensitize(item.productName)}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{desensitize(item.agentName)} · {desensitize(item.positionName)}
|
||||
</p>
|
||||
</div>
|
||||
{extraBody}
|
||||
<div className="mt-4 rounded-xl border border-slate-200 bg-white p-3 dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/65">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-bold text-slate-700 dark:text-[#e8edf5]">产物内容</p>
|
||||
</div>
|
||||
<div className="h-[52vh] min-h-[320px] overflow-hidden rounded-lg border border-slate-100 bg-slate-50/60 dark:border-[#2b8ffe]/20 dark:bg-[#021d3d]/70">
|
||||
{artifactContentQuery.isLoading ? (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground dark:text-[#8aa0c4]">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载 Markdown 文件内容…
|
||||
</div>
|
||||
) : (
|
||||
<ArtifactFilePreview
|
||||
content={displayedContent}
|
||||
language="markdown"
|
||||
threadId={artifactThreadId ?? ""}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{artifactLoadFailed && (
|
||||
<p className="mt-2 text-[11px] text-amber-600 dark:text-amber-300">
|
||||
Markdown 文件内容加载失败,当前展示的是已保存正文。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="shrink-0 border-t px-5 py-3 dark:border-[#2b8ffe]/25">
|
||||
{footerStart}
|
||||
<TButton variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</TButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
.analysis-result-card-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-card {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 102px;
|
||||
box-sizing: border-box;
|
||||
padding: 40px 12px 12px;
|
||||
border: 1px solid rgba(85, 166, 254, 0.1);
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 98, 217, 0.1);
|
||||
color: #ffffff;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-card:hover,
|
||||
.position-roundtable-analysis-result-card:focus-visible {
|
||||
border-color: #0062d9;
|
||||
background: rgba(0, 98, 217, 0.3);
|
||||
box-shadow: 0 4px 14px rgba(0, 44, 110, 0.18);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-card-body {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-tag {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: -12px;
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
max-width: calc(100% + 12px);
|
||||
height: 26px;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0 22px 0 12px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px 0 0 0;
|
||||
background: rgba(212, 120, 0, 0.5);
|
||||
color: #ffffff;
|
||||
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: normal;
|
||||
white-space: nowrap;
|
||||
clip-path: polygon(0 0, 100% 0, calc(100% - 14px) 100%, 0 100%);
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-tag--1 { background: rgba(212, 120, 0, 0.5); }
|
||||
.position-roundtable-analysis-result-tag--2 { background: rgba(207, 19, 34, 0.5); }
|
||||
.position-roundtable-analysis-result-tag--3 { background: rgba(29, 171, 123, 0.5); }
|
||||
.position-roundtable-analysis-result-tag--4 { background: rgba(132, 85, 230, 0.5); }
|
||||
.position-roundtable-analysis-result-tag--5 { background: rgba(201, 181, 0, 0.5); }
|
||||
.position-roundtable-analysis-result-tag--6 { background: rgba(69, 195, 243, 0.5); }
|
||||
|
||||
.position-roundtable-analysis-result-tag span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-product {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: #ffffff;
|
||||
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-meta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
color: #80b7ff;
|
||||
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-meta span {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-meta img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-meta i {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
background: rgba(128, 183, 255, 0.45);
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-results-empty {
|
||||
color: #80b7ff;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { AnalysisResultCardList } from "./AnalysisResultCardList";
|
||||
export { AnalysisResultDetailDialog } from "./AnalysisResultDetailDialog";
|
||||
export type { AnalysisResultCardItem } from "./types";
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface AnalysisResultCardItem {
|
||||
id: string;
|
||||
agentName: string;
|
||||
productName: string;
|
||||
positionName: string;
|
||||
updatedAt?: string;
|
||||
/** Shown when `updatedAt` is missing. */
|
||||
fallbackTime?: string;
|
||||
/** Clickable preview is available. */
|
||||
delivered: boolean;
|
||||
artifactPath?: string;
|
||||
artifactMimeType?: string | null;
|
||||
artifactThreadId?: string | null;
|
||||
artifactContent?: string | null;
|
||||
summary?: string;
|
||||
unavailableMessage?: string;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
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.
|
||||
@@ -26,7 +26,7 @@ export function HtmlPreviewFrame({
|
||||
const [url, setUrl] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
const blob = new Blob([injectInPageAnchorHandler(html ?? "")], {
|
||||
const blob = new Blob([prepareGeneratedHtml(html ?? "")], {
|
||||
type: "text/html;charset=utf-8",
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
FolderOpen,
|
||||
Gauge,
|
||||
ListTree,
|
||||
MessageSquareText,
|
||||
MousePointerClick,
|
||||
BriefcaseBusiness,
|
||||
Settings,
|
||||
@@ -237,6 +238,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/fixed-questions")}
|
||||
>
|
||||
<MessageSquareText className="size-4" />
|
||||
问题配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/position-roles")}
|
||||
@@ -558,6 +567,12 @@ function LegacyAccordionSidebar({
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/fixed-questions")}>
|
||||
<MessageSquareText className="size-4" />
|
||||
问题配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
|
||||
<BriefcaseBusiness className="size-4" />
|
||||
|
||||
@@ -27,7 +27,9 @@ import {
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
// Keep the workbench navigation compact across every theme. The visual theme
|
||||
// only changes tokens; the information density should remain consistent.
|
||||
const SIDEBAR_WIDTH = "14rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
@@ -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" />
|
||||
</button>
|
||||
<div className="text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
问答梳理
|
||||
知识库管理
|
||||
</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). */
|
||||
export function noteBreadcrumbSegments(note: KnowledgeNote): string[] {
|
||||
return ["问答梳理", ...noteFolderSegments(note).map(localizeFolderName)];
|
||||
return ["知识库管理", ...noteFolderSegments(note).map(localizeFolderName)];
|
||||
}
|
||||
|
||||
function sortTree(nodes: TreeNode[]): void {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import agentAvatarDark from "@/assets/img/agent-avatar.png";
|
||||
import agentAvatarLight from "@/assets/img/agent-avatar-red.png";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AgentBrandIconProps = {
|
||||
className?: string;
|
||||
size?: "nav" | "sm" | "md" | "lg";
|
||||
};
|
||||
|
||||
const SIZE_STYLES = {
|
||||
nav: {
|
||||
frame: "size-[22px] rounded-md",
|
||||
},
|
||||
sm: {
|
||||
frame: "size-10 rounded-full",
|
||||
},
|
||||
md: {
|
||||
frame: "size-12 rounded-full",
|
||||
},
|
||||
lg: {
|
||||
frame: "size-16 rounded-full",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function AgentBrandIcon({
|
||||
className,
|
||||
size = "md",
|
||||
}: AgentBrandIconProps) {
|
||||
const styles = SIZE_STYLES[size];
|
||||
const { theme } = useTheme();
|
||||
const avatarSrc =
|
||||
theme === "light" ? agentAvatarLight : agentAvatarDark;
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-agent-brand-icon={size}
|
||||
className={cn(
|
||||
"relative inline-flex shrink-0 overflow-hidden rounded-full",
|
||||
styles.frame,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={avatarSrc}
|
||||
alt=""
|
||||
className="size-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
import {
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
EyeOffIcon,
|
||||
GlobeIcon,
|
||||
LockIcon,
|
||||
MessageSquareIcon,
|
||||
MessageSquareIcon,
|
||||
MoreHorizontalIcon,
|
||||
PinIcon,
|
||||
ShieldIcon,
|
||||
SparklesIcon,
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -38,14 +38,21 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -59,7 +66,8 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { usePublishSquares } from "@/core/system-settings/hooks";
|
||||
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
|
||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
||||
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
||||
import { AgentBrandIcon } from "@/components/workspace/agents/agent-brand-icon";
|
||||
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
@@ -548,10 +556,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
内置
|
||||
</Badge>
|
||||
)}
|
||||
{isOwned && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
||||
{isOwned && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="border border-emerald-200 bg-emerald-50 text-xs text-emerald-700 hover:bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300"
|
||||
>
|
||||
我的
|
||||
</Badge>
|
||||
@@ -559,7 +567,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
{isFromOthers && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
||||
className="border-primary/20 bg-primary/5 text-primary text-xs dark:border-primary/40 dark:bg-primary/10 dark:text-primary"
|
||||
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
|
||||
>
|
||||
来自他人
|
||||
@@ -568,7 +576,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
{(isOwned || isAdmin) && !isBuiltin && agent.published && !agent.is_private && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||
className="border-primary/20 bg-primary/5 text-primary text-xs dark:border-primary/40 dark:bg-primary/10 dark:text-primary"
|
||||
title="该智能体已对所有用户可见"
|
||||
>
|
||||
<GlobeIcon className="size-3" />
|
||||
@@ -663,8 +671,12 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
const skillBadgeItems = [
|
||||
...(agent.tool_groups ?? []).map((group) => ({
|
||||
key: `tg:${group}`,
|
||||
node: (
|
||||
<Badge key={`tg:${group}`} variant="outline" className="text-xs">
|
||||
node: (
|
||||
<Badge
|
||||
key={`tg:${group}`}
|
||||
variant="outline"
|
||||
className="border-0 bg-primary/5 text-primary hover:bg-primary/10 px-2.5 py-1 text-xs font-normal"
|
||||
>
|
||||
{desensitize(group)}
|
||||
</Badge>
|
||||
),
|
||||
@@ -674,8 +686,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
node: (
|
||||
<Badge
|
||||
key={`sk:${skill}`}
|
||||
variant="secondary"
|
||||
className="hover:bg-secondary/70 cursor-pointer text-xs"
|
||||
variant="secondary"
|
||||
className="bg-primary/5 text-primary hover:bg-primary/10 cursor-pointer border-0 px-2.5 py-1 text-xs font-normal"
|
||||
title={t.settings.skills.viewDetailAction}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -823,80 +835,185 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
</>
|
||||
);
|
||||
|
||||
const chatButton = (
|
||||
<Button
|
||||
size="sm"
|
||||
className={cn(isList ? "shrink-0" : "w-full")}
|
||||
onClick={handleChat}
|
||||
const chatButton = (
|
||||
<Button
|
||||
variant={isList ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
isList
|
||||
? "bg-primary hover:bg-primary/90 shrink-0 rounded-xl px-4 font-medium text-primary-foreground"
|
||||
: "border-primary/35 text-primary hover:border-primary/55 hover:bg-primary/5 h-11 w-full rounded-lg bg-white font-medium shadow-none dark:bg-slate-900 dark:text-primary",
|
||||
)}
|
||||
onClick={handleChat}
|
||||
>
|
||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t.agents.chat}
|
||||
开始对话
|
||||
</Button>
|
||||
);
|
||||
);
|
||||
|
||||
const cardStatusBadge = agent.is_private ? (
|
||||
<Badge className="border-0 bg-purple-50 px-2.5 py-1 text-xs font-normal text-purple-600 hover:bg-purple-50 dark:bg-purple-500/15 dark:text-purple-300">
|
||||
<span className="mr-1.5 size-1.5 rounded-full bg-purple-500" />
|
||||
私密
|
||||
</Badge>
|
||||
) : agent.published || isBuiltin ? (
|
||||
<Badge className="border-0 bg-primary/5 text-primary hover:bg-primary/5 px-2.5 py-1 text-xs font-normal dark:bg-primary/15 dark:text-primary">
|
||||
<span className="bg-primary mr-1.5 size-1.5 rounded-full" />
|
||||
已发布
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="border-0 bg-emerald-50 px-2.5 py-1 text-xs font-normal text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300">
|
||||
<span className="mr-1.5 size-1.5 rounded-full bg-emerald-500" />
|
||||
运行中
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const updatedAtText = formatDateTime(agent.updated_at ?? agent.created_at);
|
||||
const cardActionsMenu = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
title="更多操作"
|
||||
>
|
||||
<MoreHorizontalIcon className="size-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
{canFavorite && (
|
||||
<DropdownMenuItem onSelect={() => void handleToggleFavorite()}>
|
||||
<StarIcon className={cn("size-4", isFavorite && "fill-current text-amber-500")} />
|
||||
{isFavorite ? "取消收藏" : "收藏到我的"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => void handleTogglePin()}>
|
||||
<PinIcon className={cn("size-4", isPinned && "fill-current text-emerald-600")} />
|
||||
{isPinned ? "取消置顶" : "置顶"}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onSelect={() => void handleToggleFeatured()}>
|
||||
<SparklesIcon className="size-4" />
|
||||
{agent.featured_order != null ? "取消推荐" : "设为推荐"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => setTagPickerOpen(true)}>
|
||||
<TagsIcon className="size-4" />
|
||||
{t.adminTags.manageTags}
|
||||
</DropdownMenuItem>
|
||||
{isOwned && (
|
||||
<DropdownMenuItem onSelect={() => void handleTogglePublished()}>
|
||||
{agent.published ? <LockIcon className="size-4" /> : <GlobeIcon className="size-4" />}
|
||||
{agent.published ? "取消发布" : "发布"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(isOwned || isAdmin) && (
|
||||
<DropdownMenuItem onSelect={() => void handleTogglePrivate()}>
|
||||
<ShieldIcon className="size-4" />
|
||||
{agent.is_private ? "移出私密广场" : "放入私密广场"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => void handleDuplicate()}>
|
||||
<CopyIcon className="size-4" />
|
||||
复制智能体
|
||||
</DropdownMenuItem>
|
||||
{canEdit && (
|
||||
<DropdownMenuItem onSelect={() => setEditOpen(true)}>
|
||||
<SquarePenIcon className="size-4" />
|
||||
编辑智能体
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
{t.agents.delete}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
const cardLayout = (
|
||||
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
|
||||
<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 flex-1 items-center gap-2 overflow-hidden">
|
||||
<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" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<CardTitle
|
||||
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
|
||||
title={desensitize(agent.name)}
|
||||
>
|
||||
{desensitize(agent.name)}
|
||||
</CardTitle>
|
||||
{agent.model && (
|
||||
<Badge variant="secondary" className="mt-0.5 text-xs">
|
||||
{desensitize(agent.model)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
|
||||
{statusBadges}
|
||||
</div>
|
||||
</div>
|
||||
{agent.description && (
|
||||
<CardDescription className="mt-2 line-clamp-2 text-sm">
|
||||
{desensitize(agent.description)}
|
||||
</CardDescription>
|
||||
)}
|
||||
{publisherNode && <div className="mt-2">{publisherNode}</div>}
|
||||
{createdAtNode && <div className="mt-1">{createdAtNode}</div>}
|
||||
</CardHeader>
|
||||
|
||||
{(agent.tags?.length ?? 0) > 0 && (
|
||||
<CardContent className="pt-0 pb-2">
|
||||
<TagChipList tags={agent.tags!} />
|
||||
const cardLayout = (
|
||||
<Card
|
||||
className={cn(
|
||||
"group flex min-h-[334px] min-w-0 flex-col gap-0 overflow-hidden rounded-2xl border-slate-200/90 bg-white py-0 shadow-[0_5px_18px_rgba(15,23,42,0.035)] transition-[background-color,border-color,box-shadow] duration-200 hover:!border-primary/30 hover:!bg-primary/5 hover:!ring-1 hover:!ring-primary/10 hover:shadow-[0_8px_24px_rgba(15,23,42,0.08)] dark:border-slate-800 dark:bg-slate-900",
|
||||
agent.featured_order != null && "border-primary/40 ring-1 ring-primary/15 dark:border-primary/45 dark:ring-primary/20",
|
||||
)}
|
||||
data-agent-guide="agent-card"
|
||||
>
|
||||
<CardHeader className="min-w-0 gap-0 px-5 pt-5 pb-2">
|
||||
{agent.featured_order != null ? (
|
||||
<>
|
||||
<div className="mb-1 flex h-7 items-center justify-between">
|
||||
<Badge className="border border-primary/20 bg-primary/5 text-primary hover:bg-primary/5 px-2.5 py-1 text-xs font-medium dark:border-primary/40 dark:bg-primary/15 dark:text-primary">
|
||||
<SparklesIcon className="mr-1 size-3.5 fill-current" />
|
||||
推荐
|
||||
</Badge>
|
||||
{cardActionsMenu}
|
||||
</div>
|
||||
<AgentBrandIcon size="lg" />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-start justify-between">
|
||||
<AgentBrandIcon size="lg" />
|
||||
{cardActionsMenu}
|
||||
</div>
|
||||
)}
|
||||
<CardTitle
|
||||
className="mt-3 block w-full min-w-0 overflow-hidden text-[18px] leading-6 font-semibold tracking-[-0.015em] text-ellipsis whitespace-nowrap text-slate-950 dark:text-white"
|
||||
title={desensitize(agent.name)}
|
||||
>
|
||||
{desensitize(agent.name)}
|
||||
</CardTitle>
|
||||
{agent.description && (
|
||||
<CardDescription className="mt-1.5 min-h-11 line-clamp-2 text-[14px] leading-[22px] text-slate-600 dark:text-slate-400">
|
||||
{desensitize(agent.description)}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{(agent.tags?.length ?? 0) > 0 && (
|
||||
<CardContent className="px-5 pt-0 pb-1.5">
|
||||
<TagChipList tags={agent.tags!} />
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{hasSkillsOrTools && (
|
||||
<CardContent className="pt-0 pb-2">
|
||||
<ClampedBadgeRow items={skillBadgeItems} />
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-2">
|
||||
{showActionRow && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||
{actionButtons}
|
||||
</div>
|
||||
)}
|
||||
{chatButton}
|
||||
</CardFooter>
|
||||
<CardContent className="px-5 pt-0 pb-2">
|
||||
<ClampedBadgeRow items={skillBadgeItems} />
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<CardFooter className="mt-auto flex flex-col items-stretch gap-4 px-5 pt-2 pb-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{cardStatusBadge}
|
||||
<span
|
||||
className="min-w-0 truncate text-xs text-slate-400"
|
||||
title={updatedAtText ? `更新于 ${updatedAtText}` : undefined}
|
||||
>
|
||||
{updatedAtText ? `更新于 ${updatedAtText}` : "最近更新"}
|
||||
{publisherLabel ? ` · 由 ${publisherLabel} 更新` : isBuiltin ? " · 由系统更新" : ""}
|
||||
</span>
|
||||
</div>
|
||||
{chatButton}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
|
||||
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="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" />
|
||||
</div>
|
||||
<div
|
||||
className="group flex min-w-0 items-center gap-4 rounded-2xl border border-slate-200/90 bg-white p-4 shadow-[0_8px_24px_rgba(15,23,42,0.04)] transition-all hover:border-primary/25 hover:shadow-[0_12px_32px_rgba(15,23,42,0.09)] dark:border-slate-800 dark:bg-slate-900"
|
||||
data-agent-guide="agent-card"
|
||||
>
|
||||
<AgentBrandIcon className="agent-avatar" size="md" />
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
@@ -980,7 +1097,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
? "正在加载 SOUL.md..."
|
||||
: "输入 SOUL.md 内容"
|
||||
}
|
||||
className="max-h-[45vh] min-h-48 resize-y text-xs"
|
||||
className="max-h-[45vh] min-h-48 resize-y text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -72,6 +72,7 @@ import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import {
|
||||
bootstrapResumePath,
|
||||
isBootstrapThread,
|
||||
isHiddenFromRecentChats,
|
||||
isThreadFavorite,
|
||||
isThreadPinned,
|
||||
sortThreadsPinnedFirst,
|
||||
@@ -372,17 +373,10 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
|
||||
|
||||
// 排除调度/会商类系统会话,与首页一致;智能体创建(bootstrap)会话保留展示
|
||||
// (带机器人图标、点击回创建页);置顶优先。
|
||||
// 排除调度/会商/报告结构&写作配置向导等系统会话,与首页一致;
|
||||
// 智能体创建(bootstrap)会话保留展示(带机器人图标、点击回创建页);置顶优先。
|
||||
const visibleThreads = useMemo(
|
||||
() =>
|
||||
sortThreadsPinnedFirst(
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "roundtable",
|
||||
),
|
||||
),
|
||||
() => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
|
||||
[threads],
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
BriefcaseBusinessIcon,
|
||||
CheckIcon,
|
||||
DatabaseIcon,
|
||||
GlobeIcon,
|
||||
@@ -143,6 +144,8 @@ export function AgentDetailPanel({
|
||||
onAskQuestion,
|
||||
disabled,
|
||||
defaultCollapsed,
|
||||
collapsed: collapsedProp,
|
||||
onCollapsedChange,
|
||||
showRecentChats,
|
||||
compactAppearanceActions,
|
||||
avatarSrc,
|
||||
@@ -150,6 +153,7 @@ export function AgentDetailPanel({
|
||||
skillsIcon,
|
||||
skillsTitle,
|
||||
showCommonQuestions = false,
|
||||
jobResponsibilities,
|
||||
panelTitle,
|
||||
embedded,
|
||||
hideStatusBadges = false,
|
||||
@@ -161,6 +165,14 @@ export function AgentDetailPanel({
|
||||
disabled?: boolean;
|
||||
/** 初始收起(任务工作区进入时默认收起智能体信息面板)。 */
|
||||
defaultCollapsed?: boolean;
|
||||
/**
|
||||
* 受控折叠态:传入时由父级持有状态(智能体问答页把展开/收起按钮放在顶部
|
||||
* 「新对话」右侧)。此时收起 = 整列宽度动画归零(不保留细条),面板内置的
|
||||
* 收起按钮不再渲染。不传则沿用面板内置状态 + 细条收起交互(会商等工作区)。
|
||||
*/
|
||||
collapsed?: boolean;
|
||||
/** 受控折叠态变化回调(配合 `collapsed` 使用)。 */
|
||||
onCollapsedChange?: (collapsed: boolean) => void;
|
||||
/** 在面板底部展示「有关此智能体的最近对话」(仅智能体问答页开启,任务工作区不展示)。 */
|
||||
showRecentChats?: boolean;
|
||||
/** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */
|
||||
@@ -170,6 +182,11 @@ export function AgentDetailPanel({
|
||||
skillsIcon?: ReactNode;
|
||||
skillsTitle?: string;
|
||||
showCommonQuestions?: boolean;
|
||||
/**
|
||||
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
|
||||
* 不传则隐藏该区块。此字段来自业务链,不随智能体本身编辑。
|
||||
*/
|
||||
jobResponsibilities?: string | null;
|
||||
/** Overrides the standard panel heading for an embedded consumer. */
|
||||
panelTitle?: string;
|
||||
/** Renders one fixed panel instead of the responsive collapsible rail. */
|
||||
@@ -180,7 +197,15 @@ export function AgentDetailPanel({
|
||||
editIcon?: ReactNode;
|
||||
}) {
|
||||
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 [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<AgentDraft | null>(null);
|
||||
@@ -230,9 +255,10 @@ export function AgentDetailPanel({
|
||||
|
||||
const isSaving = updateAgent.isPending || setFeatured.isPending;
|
||||
|
||||
// 对话生成文件(md/txt 等 artifact)并在右侧展开预览时,自动收起智能体
|
||||
// 信息面板给预览让位;切换到新文件时同样收起。编辑中不打断用户,用户随时
|
||||
// 可手动重新展开。
|
||||
// 对话生成文件(md/txt 等 artifact)并首次展开预览时,自动收起智能体
|
||||
// 信息面板给预览让位。目录内切换文件不能再次改写用户的手动选择:否则用户
|
||||
// 刚展开智能体信息,点另一个文件时面板又会突然收起,看起来就像收起按钮被
|
||||
// 移除了。编辑中也不打断用户。
|
||||
//
|
||||
// 关键:判定用 open + selectedArtifact,而非 artifacts.length。流式写文件时
|
||||
// message-group 一开始就调用 select()+setOpen(true) 打开预览并选中该文件,但
|
||||
@@ -241,20 +267,19 @@ export function AgentDetailPanel({
|
||||
const artifactsCtx = useArtifactsOptional();
|
||||
const artifactCount = artifactsCtx?.artifacts.length ?? 0;
|
||||
const selectedArtifact = artifactsCtx?.selectedArtifact ?? null;
|
||||
// preOpen:流式自动开沙箱的两阶段标记 —— 沙箱真正展开(open 翻转)前就
|
||||
// 先收起本面板,让收起布局先落地,避免与沙箱展开同帧叠加造成卡顿。
|
||||
const artifactPanelVisible =
|
||||
!!artifactsCtx?.open && (!!selectedArtifact || artifactCount > 0);
|
||||
(!!artifactsCtx?.open || !!artifactsCtx?.preOpen) &&
|
||||
(!!selectedArtifact || artifactCount > 0);
|
||||
const prevArtifactPanelVisible = useRef(false);
|
||||
const prevSelectedArtifact = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const opened = artifactPanelVisible && !prevArtifactPanelVisible.current;
|
||||
const switched =
|
||||
artifactPanelVisible && selectedArtifact !== prevSelectedArtifact.current;
|
||||
prevArtifactPanelVisible.current = artifactPanelVisible;
|
||||
prevSelectedArtifact.current = selectedArtifact;
|
||||
if ((opened || switched) && !editing) {
|
||||
if (opened && !editing) {
|
||||
setCollapsed(true);
|
||||
}
|
||||
}, [artifactPanelVisible, selectedArtifact, editing]);
|
||||
}, [artifactPanelVisible, editing]);
|
||||
|
||||
const displayName = desensitize(agent?.name ?? agentName);
|
||||
const questions = useMemo(
|
||||
@@ -720,6 +745,23 @@ export function AgentDetailPanel({
|
||||
</section>
|
||||
|
||||
|
||||
{/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */}
|
||||
{jobResponsibilities !== undefined && (
|
||||
<Section
|
||||
icon={<BriefcaseBusinessIcon className="size-3.5" />}
|
||||
title="岗位职责"
|
||||
>
|
||||
{jobResponsibilities?.trim() ? (
|
||||
<p className="text-muted-foreground text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{desensitize(jobResponsibilities)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground/60 text-sm">暂无岗位职责</p>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
|
||||
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
|
||||
{(showCommonQuestions || editing || questions.length > 0 || canEdit) && (
|
||||
<Section
|
||||
@@ -905,14 +947,18 @@ export function AgentDetailPanel({
|
||||
|
||||
const panelChrome = (collapseButton: ReactNode) => (
|
||||
<>
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<span className="shrink-0 whitespace-nowrap text-sm font-semibold">
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||
{/* 标题不参与压缩(此前被简洁模式外观控件挤成单字);空间不足时只
|
||||
裁剪 compactAppearanceActions,编辑等主操作与标题始终完整。 */}
|
||||
<span className="shrink-0 text-sm font-semibold whitespace-nowrap">
|
||||
{panelTitle ?? "智能体信息"}
|
||||
</span>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{headerActions}
|
||||
{compactAppearanceActions}
|
||||
{collapseButton}
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-1">
|
||||
<div className="flex shrink-0 items-center gap-1">{headerActions}</div>
|
||||
<div className="flex min-w-0 items-center justify-end gap-1 overflow-hidden">
|
||||
{compactAppearanceActions}
|
||||
</div>
|
||||
{collapseButton && <div className="shrink-0">{collapseButton}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
|
||||
@@ -921,21 +967,48 @@ export function AgentDetailPanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
|
||||
{/* ── Desktop (lg+) ────────────────────────────────────────────────── */}
|
||||
{embedded ? (
|
||||
<aside className="bg-card/40 flex h-full w-full min-w-0 shrink-0 flex-col border-l">
|
||||
{panelChrome(null)}
|
||||
</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 ? (
|
||||
<div className="hidden shrink-0 items-start border-l px-2 pt-3 lg:flex">
|
||||
<div className="flex items-center gap-1">
|
||||
{compactAppearanceActions}
|
||||
<Tooltip content="智能体信息">
|
||||
<Tooltip content="展开智能体信息">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setCollapsed(false)}
|
||||
aria-label="展开智能体信息"
|
||||
aria-expanded={false}
|
||||
>
|
||||
<PanelRightOpenIcon className="size-4" />
|
||||
</Button>
|
||||
@@ -951,6 +1024,7 @@ export function AgentDetailPanel({
|
||||
size="icon-sm"
|
||||
onClick={() => setCollapsed(true)}
|
||||
aria-label="收起智能体信息"
|
||||
aria-expanded={true}
|
||||
>
|
||||
<PanelRightCloseIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"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 { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -21,6 +21,11 @@ import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/s
|
||||
|
||||
import { AgentCard } from "./agent-card";
|
||||
import { SentimentAgentCard } from "./sentiment-agent-card";
|
||||
import {
|
||||
AgentGuide,
|
||||
isAgentGuideEnvironment,
|
||||
startAgentGuide,
|
||||
} from "./agent-guide";
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
@@ -346,15 +351,22 @@ export function AgentGallery() {
|
||||
请输入访问密码,验证通过后才能查看其中的智能体。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<form
|
||||
className="flex flex-col gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleSubmitPassword();
|
||||
}}
|
||||
>
|
||||
<label className="sr-only" htmlFor="private-square-access-code">
|
||||
访问密码
|
||||
</label>
|
||||
<Input
|
||||
id="private-square-access-code"
|
||||
type="password"
|
||||
placeholder="访问密码"
|
||||
value={passwordInput}
|
||||
onChange={(e) => setPasswordInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSubmitPassword();
|
||||
}}
|
||||
// This is a one-off access code, not a login credential. Tell
|
||||
// browsers / password managers not to save or autofill it (which
|
||||
// would also stop them from spilling a saved account into the
|
||||
@@ -365,8 +377,8 @@ export function AgentGallery() {
|
||||
data-lpignore="true"
|
||||
data-form-type="other"
|
||||
/>
|
||||
<Button onClick={handleSubmitPassword}>进入私密广场</Button>
|
||||
</div>
|
||||
<Button type="submit">进入私密广场</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -391,10 +403,22 @@ export function AgentGallery() {
|
||||
{t.agents.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleNewAgent}>
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
{t.agents.newAgent}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
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>
|
||||
|
||||
{/* Tabs */}
|
||||
@@ -414,7 +438,10 @@ export function AgentGallery() {
|
||||
>
|
||||
<div className="mx-6 mt-4 flex flex-col 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>
|
||||
{defaultSquare && (
|
||||
<TabsTrigger
|
||||
@@ -468,7 +495,8 @@ export function AgentGallery() {
|
||||
</div>
|
||||
</div>
|
||||
{showSearchBar && (
|
||||
<TagSearchBar
|
||||
<div data-agent-guide="search">
|
||||
<TagSearchBar
|
||||
// Remount per tab so the internal keyword input / 展开 state also
|
||||
// resets when the parent-owned filters are cleared on tab switch.
|
||||
key={activeTab}
|
||||
@@ -499,6 +527,7 @@ export function AgentGallery() {
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{publisher && (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -586,6 +615,11 @@ export function AgentGallery() {
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
<AgentGuide
|
||||
stage="list"
|
||||
eligible={isAgentGuideEnvironment()}
|
||||
autoStart={false}
|
||||
/>
|
||||
</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 { useThreadsPage } from "@/core/threads/hooks";
|
||||
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
|
||||
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -22,12 +22,9 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
||||
pageSize: RECENT_LIMIT,
|
||||
metadata: { agent_id: agentId },
|
||||
});
|
||||
// 该智能体的会话里同样排除调度 / 引导 / 会商类系统会话。
|
||||
// 该智能体的会话里同样排除引导 / 系统 / 配置向导会话。
|
||||
const threads = (data?.threads ?? []).filter(
|
||||
(th) =>
|
||||
th.metadata?.thread_type !== "bootstrap" &&
|
||||
th.metadata?.thread_type !== "scheduler" &&
|
||||
th.metadata?.thread_type !== "roundtable",
|
||||
(th) => !isBootstrapThread(th) && !isHiddenFromRecentChats(th),
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -22,7 +22,7 @@ import { SentimentAgentProfileDialog } from "./sentiment-agent-profile-dialog";
|
||||
|
||||
/**
|
||||
* 「舆情分析」类型下的虚拟智能体卡片:只存在于前端(不落库 /api/agents),
|
||||
* 对话直连外部 AG-UI 网关。名称 / 简介可编辑(localStorage),
|
||||
* 对话经 Gateway 转发外部 AG-UI 网关。名称 / 简介可编辑(localStorage),
|
||||
* 视觉与普通智能体卡片保持一致(网格 / 列表两种布局)。
|
||||
*/
|
||||
export function SentimentAgentCard({
|
||||
|
||||
@@ -165,10 +165,26 @@ export function SkillPickerSheet({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const validation = await validateUpload.mutateAsync(file);
|
||||
const validation = await validateUpload.mutateAsync({
|
||||
file,
|
||||
targetCategory: "custom",
|
||||
});
|
||||
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(
|
||||
`已存在同名技能「${validation.skill_name}」,请到「技能」设置页上传以选择是否覆盖。`,
|
||||
`已存在同名技能「${validation.skill_name}」,当前不可覆盖。`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -14,11 +14,12 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
|
||||
import type { CompactShortcutRouteParam } from "@/core/system-settings/api";
|
||||
import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { isHideChromeActive } from "@/core/embed/embed-session";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
|
||||
import { buildLightAppUrl, resolveRouteParamValue } from "@/strategy-components/lib/light-app";
|
||||
|
||||
/**
|
||||
* 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用,
|
||||
@@ -47,6 +48,34 @@ const triggerToneClass = (tone: AppearanceControlTone) =>
|
||||
? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground";
|
||||
|
||||
function appendShortcutPathParams(url: string, params: CompactShortcutRouteParam[]) {
|
||||
const pathSegments = params
|
||||
.filter((param) => param.paramType === "path")
|
||||
.map((param) =>
|
||||
resolveRouteParamValue({ ...param, paramType: "login" }).trim(),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.map((value) => encodeURIComponent(value));
|
||||
if (pathSegments.length === 0) return url;
|
||||
|
||||
const hashIndex = url.indexOf("#");
|
||||
if (hashIndex < 0) {
|
||||
const [path, query = ""] = url.split("?", 2);
|
||||
return `${path.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
const beforeHash = url.slice(0, hashIndex);
|
||||
const fragment = url.slice(hashIndex + 1);
|
||||
const [routePath, query = ""] = fragment.split("?", 2);
|
||||
return `${beforeHash}#${routePath.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
function isQueryShortcutParam(
|
||||
param: CompactShortcutRouteParam,
|
||||
): param is CompactShortcutRouteParam & { paramType: "login" | "theme" } {
|
||||
return param.paramType === "login" || param.paramType === "theme";
|
||||
}
|
||||
|
||||
export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) {
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
const buttons = (appearance?.compact_shortcut_buttons ?? [])
|
||||
@@ -69,9 +98,10 @@ export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }
|
||||
)}
|
||||
title={button.label}
|
||||
onClick={() => {
|
||||
const routeParams = (button.route_params ?? []).map((param) => ({ ...param }));
|
||||
const url = buildLightAppUrl({
|
||||
url: button.url,
|
||||
routeParams: button.route_params.map((param) => ({ ...param })),
|
||||
url: appendShortcutPathParams(button.url, routeParams),
|
||||
routeParams: routeParams.filter(isQueryShortcutParam),
|
||||
});
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
@@ -122,6 +152,7 @@ export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
data-compact-guide="theme-switcher"
|
||||
onMouseEnter={() => {
|
||||
cancelClose();
|
||||
setOpen(true);
|
||||
@@ -221,6 +252,7 @@ export function LayoutModeSwitcher({
|
||||
className={cn("size-8 shrink-0 rounded-sm", triggerToneClass(tone))}
|
||||
title="界面模式"
|
||||
aria-label="界面模式"
|
||||
data-compact-guide="layout-mode"
|
||||
>
|
||||
<TriggerIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -29,6 +29,7 @@ export function ArtifactFileList({
|
||||
threadId,
|
||||
onOpenArtifact,
|
||||
onDownloadArtifact,
|
||||
fileDescriptions,
|
||||
}: {
|
||||
className?: string;
|
||||
files: string[];
|
||||
@@ -36,6 +37,8 @@ export function ArtifactFileList({
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
/** Optional host download action for virtual/session-owned artifacts. */
|
||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||
/** Optional per-file secondary text, such as the report structure used. */
|
||||
fileDescriptions?: Record<string, ReactNode>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { isMock, publicShareCode } = useThread();
|
||||
@@ -102,10 +105,10 @@ export function ArtifactFileList({
|
||||
<div className="absolute top-2 -left-0.5">
|
||||
{getFileIcon(file, "size-6")}
|
||||
</div>
|
||||
</CardTitle>
|
||||
<CardDescription className="min-w-0 pl-8 text-xs">
|
||||
{getFileExtensionDisplayName(file)} file
|
||||
</CardDescription>
|
||||
</CardTitle>
|
||||
<CardDescription className="min-w-0 pl-8 text-xs">
|
||||
{fileDescriptions?.[file] ?? `${getFileExtensionDisplayName(file)} file`}
|
||||
</CardDescription>
|
||||
<CardAction className="row-span-1 self-center">
|
||||
{file.endsWith(".skill") && (
|
||||
<Button
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import {
|
||||
createContext,
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { useSidebarSafe } from "@/components/ui/sidebar";
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { useSidebarSafe } from "@/components/ui/sidebar";
|
||||
import { env } from "@/env";
|
||||
|
||||
export function artifactPreviewKey(artifact: string): string {
|
||||
@@ -26,11 +27,11 @@ export function artifactPreviewKey(artifact: string): string {
|
||||
}
|
||||
return artifact;
|
||||
}
|
||||
|
||||
export interface ArtifactsContextType {
|
||||
artifacts: string[];
|
||||
setArtifacts: (artifacts: string[]) => void;
|
||||
|
||||
|
||||
export interface ArtifactsContextType {
|
||||
artifacts: string[];
|
||||
setArtifacts: (artifacts: string[]) => void;
|
||||
|
||||
selectedArtifact: string | null;
|
||||
selectedArtifactKey: string | null;
|
||||
autoSelect: boolean;
|
||||
@@ -38,46 +39,107 @@ export interface ArtifactsContextType {
|
||||
/** Open/update a streamed write without overriding a manual close of that call. */
|
||||
openStreamedArtifact: (artifact: string, previewKey: string) => boolean;
|
||||
deselect: () => void;
|
||||
|
||||
open: boolean;
|
||||
autoOpen: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const ArtifactsContext = createContext<ArtifactsContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface ArtifactsProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
const [artifacts, setArtifactsState] = useState<string[]>([]);
|
||||
const setArtifacts = useCallback((next: string[]) => {
|
||||
setArtifactsState(Array.isArray(next) ? next : []);
|
||||
}, []);
|
||||
|
||||
open: boolean;
|
||||
autoOpen: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
/**
|
||||
* 流式写文件自动开沙箱的两阶段标记:openStreamedArtifact 先收起两侧侧栏
|
||||
* (阶段一),等收起布局落定后再真正翻转 open(阶段二)。两阶段之间为
|
||||
* true —— 右侧智能体信息面板据此提前收起,避免与沙箱展开同帧叠加造成卡顿。
|
||||
*/
|
||||
preOpen: boolean;
|
||||
}
|
||||
|
||||
const ArtifactsContext = createContext<ArtifactsContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface ArtifactsProviderProps {
|
||||
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) {
|
||||
const [artifacts, setArtifactsState] = useState<string[]>([]);
|
||||
const setArtifacts = useCallback((next: string[]) => {
|
||||
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 [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null);
|
||||
const [autoSelect, setAutoSelect] = useState(true);
|
||||
const [open, setOpenState] = useState(
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
const [preOpen, setPreOpen] = useState(false);
|
||||
const [autoOpen, setAutoOpen] = useState(true);
|
||||
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
|
||||
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
|
||||
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
|
||||
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
|
||||
const autoOpenRef = useRef(autoOpen);
|
||||
const openStateRef = useRef(open);
|
||||
const preOpenTimerRef = useRef<number | null>(null);
|
||||
const openTimerRef = useRef<number | null>(null);
|
||||
const sidebar = useSidebarSafe();
|
||||
|
||||
const select = useCallback(
|
||||
|
||||
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(
|
||||
(artifact: string, autoSelect = false) => {
|
||||
setSelectedArtifact(artifact);
|
||||
setSelectedArtifactKey(artifactPreviewKey(artifact));
|
||||
const previewKey = artifactPreviewKey(artifact);
|
||||
selectedArtifactKeyRef.current = previewKey;
|
||||
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
|
||||
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
|
||||
sidebar?.setOpen(false);
|
||||
}
|
||||
if (!autoSelect) {
|
||||
setAutoSelect(false);
|
||||
}
|
||||
},
|
||||
sidebar?.setOpen(false);
|
||||
}
|
||||
if (!autoSelect) {
|
||||
setAutoSelect((previous) => (previous ? false : previous));
|
||||
}
|
||||
},
|
||||
[sidebar],
|
||||
);
|
||||
|
||||
@@ -86,67 +148,100 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
if (suppressedAutoOpenKeyRef.current === previewKey) {
|
||||
return false;
|
||||
}
|
||||
setSelectedArtifact(artifact);
|
||||
setSelectedArtifactKey(previewKey);
|
||||
setAutoSelect(true);
|
||||
setAutoOpen(true);
|
||||
setOpenState(true);
|
||||
selectedArtifactKeyRef.current = previewKey;
|
||||
autoOpenRef.current = true;
|
||||
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
|
||||
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
|
||||
setAutoSelect((previous) => (previous ? previous : true));
|
||||
setAutoOpen((previous) => (previous ? previous : true));
|
||||
// 阶段一(t=0):立即收起左侧聊天记录侧栏。
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
|
||||
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;
|
||||
},
|
||||
[sidebar],
|
||||
[applyOpen, cancelPendingStreamedOpen, sidebar],
|
||||
);
|
||||
|
||||
const deselect = useCallback(() => {
|
||||
setSelectedArtifact(null);
|
||||
setSelectedArtifactKey(null);
|
||||
setAutoSelect(true);
|
||||
setOpenState(false);
|
||||
|
||||
const deselect = useCallback(() => {
|
||||
selectedArtifactKeyRef.current = null;
|
||||
setSelectedArtifact((previous) => (previous === null ? previous : null));
|
||||
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
|
||||
setAutoSelect((previous) => (previous ? previous : true));
|
||||
cancelPendingStreamedOpen();
|
||||
applyOpen(false);
|
||||
suppressedAutoOpenKeyRef.current = null;
|
||||
}, []);
|
||||
|
||||
const value: ArtifactsContextType = {
|
||||
artifacts,
|
||||
setArtifacts,
|
||||
|
||||
open,
|
||||
autoOpen,
|
||||
autoSelect,
|
||||
setOpen: (isOpen: boolean) => {
|
||||
if (!isOpen && autoOpen) {
|
||||
setAutoOpen(false);
|
||||
setAutoSelect(false);
|
||||
}
|
||||
}, [applyOpen, cancelPendingStreamedOpen]);
|
||||
|
||||
const setOpen = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
suppressedAutoOpenKeyRef.current = selectedArtifactKey;
|
||||
// 两阶段等待期间用户手动关闭:取消尚未触发的自动展开。
|
||||
cancelPendingStreamedOpen();
|
||||
if (autoOpenRef.current) {
|
||||
autoOpenRef.current = false;
|
||||
setAutoOpen((previous) => (previous ? false : previous));
|
||||
setAutoSelect((previous) => (previous ? false : previous));
|
||||
}
|
||||
suppressedAutoOpenKeyRef.current = selectedArtifactKeyRef.current;
|
||||
}
|
||||
setOpenState(isOpen);
|
||||
applyOpen(isOpen);
|
||||
},
|
||||
[applyOpen, cancelPendingStreamedOpen],
|
||||
);
|
||||
|
||||
const value: ArtifactsContextType = {
|
||||
artifacts,
|
||||
setArtifacts,
|
||||
|
||||
open,
|
||||
preOpen,
|
||||
autoOpen,
|
||||
autoSelect,
|
||||
setOpen,
|
||||
|
||||
selectedArtifact,
|
||||
selectedArtifactKey,
|
||||
select,
|
||||
openStreamedArtifact,
|
||||
deselect,
|
||||
};
|
||||
|
||||
return (
|
||||
<ArtifactsContext.Provider value={value}>
|
||||
{children}
|
||||
</ArtifactsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useArtifacts() {
|
||||
const context = useContext(ArtifactsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useArtifacts must be used within an ArtifactsProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useArtifactsOptional(): ArtifactsContextType | null {
|
||||
return useContext(ArtifactsContext) ?? null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ArtifactsContext.Provider value={value}>
|
||||
{children}
|
||||
</ArtifactsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useArtifacts() {
|
||||
const context = useContext(ArtifactsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useArtifacts must be used within an ArtifactsProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useArtifactsOptional(): ArtifactsContextType | null {
|
||||
return useContext(ArtifactsContext) ?? null;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SparklesIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FilePenLineIcon, ImagePlusIcon, SparklesIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -20,8 +20,24 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
|
||||
import { useReportStructures, REPORT_STRUCTURE_MODE_OPTIONS, normalizeReportStructureMode, type ReportStructureMode } from "@/strategy-components/api/report-structures";
|
||||
|
||||
const KEEP_CURRENT_REPORT_STRUCTURE = "__keep_current_report_structure__";
|
||||
const DEFAULT_REWRITE_INSTRUCTION = "统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。";
|
||||
const DEFAULT_GENERATE_INSTRUCTION = "基于已选资料按所选报告结构生成一篇新的完整报告;保持事实可核验,不补充没有依据的信息。";
|
||||
|
||||
export interface DocumentRewriteDialogInput {
|
||||
instruction: string;
|
||||
style: DocumentRewriteStyle;
|
||||
modelName: string | null;
|
||||
reportOutline: string;
|
||||
structureMode: "fixed" | "adaptive";
|
||||
generateImages: boolean;
|
||||
maxGeneratedImages: number;
|
||||
}
|
||||
|
||||
const STYLE_OPTIONS: { value: DocumentRewriteStyle; label: string; description: string }[] = [
|
||||
{
|
||||
@@ -51,64 +67,130 @@ export function DocumentRewriteDialog({
|
||||
onOpenChange,
|
||||
fileName,
|
||||
charCount,
|
||||
imageGenerationAvailable = false,
|
||||
maxGeneratedImages = 4,
|
||||
initialReportOutline,
|
||||
operationLabel,
|
||||
operationDescription,
|
||||
submitLabel,
|
||||
operation,
|
||||
onStart,
|
||||
onRewriteOriginal,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
fileName: string;
|
||||
charCount: number;
|
||||
onStart: (input: {
|
||||
instruction: string;
|
||||
style: DocumentRewriteStyle;
|
||||
modelName: string | null;
|
||||
}) => void;
|
||||
imageGenerationAvailable?: boolean;
|
||||
maxGeneratedImages?: number;
|
||||
/** A saved report structure selected before the dialog was opened. */
|
||||
initialReportOutline?: string;
|
||||
/** Optional copy for flows that create a new document instead of replacing one. */
|
||||
operationLabel?: string;
|
||||
operationDescription?: string;
|
||||
submitLabel?: string;
|
||||
/** ``generate`` creates an independent report; ``rewrite`` edits this report in place. */
|
||||
operation?: "rewrite" | "generate";
|
||||
onStart: (input: DocumentRewriteDialogInput) => void;
|
||||
/** Optional secondary action for generation flows that rewrites the source report in place. */
|
||||
onRewriteOriginal?: (input: DocumentRewriteDialogInput) => void;
|
||||
}) {
|
||||
const [instruction, setInstruction] = useState(
|
||||
"统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。",
|
||||
);
|
||||
const isNewReportGeneration = operation === "generate";
|
||||
const [instruction, setInstruction] = useState(DEFAULT_REWRITE_INSTRUCTION);
|
||||
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
|
||||
const [modelName, setModelName] = useState<string | null>(null);
|
||||
const [reportStructureId, setReportStructureId] = useState(KEEP_CURRENT_REPORT_STRUCTURE);
|
||||
const [structureMode, setStructureMode] = useState<ReportStructureMode>("adaptive");
|
||||
const [generateImages, setGenerateImages] = useState(false);
|
||||
const [imageCount, setImageCount] = useState("2");
|
||||
const reportStructuresQuery = useReportStructures({ status: "enabled" });
|
||||
const reportStructures = reportStructuresQuery.data ?? [];
|
||||
const selectedReportStructure = reportStructures.find((item) => item.id === reportStructureId);
|
||||
const initialReportStructureId = useMemo(() => {
|
||||
const outline = initialReportOutline?.trim();
|
||||
if (!outline) return null;
|
||||
return reportStructures.find((item) => item.content.trim() === outline)?.id ?? null;
|
||||
}, [initialReportOutline, reportStructures]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setInstruction(isNewReportGeneration ? DEFAULT_GENERATE_INSTRUCTION : DEFAULT_REWRITE_INSTRUCTION);
|
||||
setStyle("formal_analysis");
|
||||
setModelName(null);
|
||||
}, [open]);
|
||||
setReportStructureId(KEEP_CURRENT_REPORT_STRUCTURE);
|
||||
setStructureMode("adaptive");
|
||||
setGenerateImages(false);
|
||||
setImageCount("2");
|
||||
}, [isNewReportGeneration, open]);
|
||||
|
||||
// The structures query may resolve after the dialog opens. Only seed the
|
||||
// value while the user still has the default selection, so a manual choice
|
||||
// is never overwritten by a query refresh.
|
||||
useEffect(() => {
|
||||
if (!open || !initialReportStructureId) return;
|
||||
setReportStructureId((current) => (
|
||||
current === KEEP_CURRENT_REPORT_STRUCTURE ? initialReportStructureId : current
|
||||
));
|
||||
}, [initialReportStructureId, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedReportStructure) return;
|
||||
setStructureMode(selectedReportStructure.structureMode);
|
||||
}, [selectedReportStructure?.id, selectedReportStructure?.structureMode]);
|
||||
|
||||
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
|
||||
const canStart = instruction.trim().length > 0;
|
||||
const startWith = (handler: (input: DocumentRewriteDialogInput) => void) => {
|
||||
if (!canStart) return;
|
||||
handler({
|
||||
instruction: instruction.trim(),
|
||||
style,
|
||||
modelName,
|
||||
reportOutline: selectedReportStructure?.content ?? "",
|
||||
structureMode,
|
||||
generateImages,
|
||||
maxGeneratedImages: Math.min(
|
||||
Math.max(Number(imageCount) || 1, 1),
|
||||
Math.max(Math.min(maxGeneratedImages, 4), 1),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<SparklesIcon className="size-5 text-primary" />
|
||||
AI 全文改写
|
||||
{operationLabel ?? "AI 全文改写"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
将以当前文件快照生成临时草稿。校验通过前不会覆盖原 Markdown。
|
||||
{operationDescription ?? "将以当前文件快照生成临时草稿。校验通过前不会覆盖原 Markdown。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-lg border bg-muted/35 px-3 py-2.5 text-sm">
|
||||
<div className="font-medium">{fileName}</div>
|
||||
<div className="text-muted-foreground mt-1">原文约 {charCount.toLocaleString()} 字</div>
|
||||
<div className="text-muted-foreground mt-1">
|
||||
{isNewReportGeneration ? "来源报告" : "原文"}约 {charCount.toLocaleString()} 字
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
改写要求
|
||||
{isNewReportGeneration ? "生成要求" : "改写要求"}
|
||||
<Textarea
|
||||
value={instruction}
|
||||
onChange={(event) => setInstruction(event.target.value)}
|
||||
placeholder="例如:统一为正式行业分析语气,减少重复,不新增外部事实。"
|
||||
placeholder={isNewReportGeneration
|
||||
? "例如:突出政策影响,并按所选结构组织章节;仅使用已选资料。"
|
||||
: "例如:统一为正式行业分析语气,减少重复,不新增外部事实。"}
|
||||
rows={4}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
改写风格
|
||||
{isNewReportGeneration ? "报告风格" : "改写风格"}
|
||||
<Select value={style} onValueChange={(value) => setStyle(value as DocumentRewriteStyle)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -124,6 +206,58 @@ export function DocumentRewriteDialog({
|
||||
<span className="text-muted-foreground text-xs">{selectedStyle?.description}</span>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
报告结构
|
||||
<Select value={reportStructureId} onValueChange={setReportStructureId}>
|
||||
<SelectTrigger aria-label="报告结构">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={KEEP_CURRENT_REPORT_STRUCTURE}>沿用当前结构</SelectItem>
|
||||
{reportStructuresQuery.isLoading && (
|
||||
<SelectItem value="__report_structures_loading__" disabled>
|
||||
正在加载结构…
|
||||
</SelectItem>
|
||||
)}
|
||||
{reportStructuresQuery.isError && (
|
||||
<SelectItem value="__report_structures_error__" disabled>
|
||||
报告结构库加载失败
|
||||
</SelectItem>
|
||||
)}
|
||||
{reportStructures.map((structure) => (
|
||||
<SelectItem key={structure.id} value={structure.id}>
|
||||
{structure.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{selectedReportStructure?.name || "默认保留原文的章节安排。"}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
结构约束
|
||||
<Select
|
||||
value={structureMode}
|
||||
onValueChange={(value) => setStructureMode(normalizeReportStructureMode(value))}
|
||||
>
|
||||
<SelectTrigger aria-label="结构约束">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REPORT_STRUCTURE_MODE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{REPORT_STRUCTURE_MODE_OPTIONS.find((option) => option.value === structureMode)?.description}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
模型
|
||||
<ModelNameSelect
|
||||
@@ -132,23 +266,76 @@ export function DocumentRewriteDialog({
|
||||
triggerClassName="h-10 text-sm"
|
||||
defaultLabel="系统默认模型"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">仅影响本次全文改写。</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
仅影响本次{isNewReportGeneration ? "新报告生成" : "全文改写"}。
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{imageGenerationAvailable && (
|
||||
<div className="rounded-lg border bg-muted/20 p-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<label className="flex min-w-0 cursor-pointer items-start gap-3" htmlFor="document-rewrite-images">
|
||||
<span className="bg-primary/10 text-primary mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
|
||||
<ImagePlusIcon className="size-4" />
|
||||
</span>
|
||||
<span className="grid gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{isNewReportGeneration ? "为新报告补充配图" : "改写后补充配图"}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs leading-5">
|
||||
根据章节生成并保存为当前沙箱的本地图片,不使用外部图片链接。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<Switch
|
||||
aria-label={isNewReportGeneration ? "为新报告补充配图" : "改写后补充配图"}
|
||||
checked={generateImages}
|
||||
id="document-rewrite-images"
|
||||
onCheckedChange={setGenerateImages}
|
||||
/>
|
||||
</div>
|
||||
{generateImages && (
|
||||
<div className="mt-3 flex items-center justify-between gap-3 border-t pt-3">
|
||||
<span className="text-muted-foreground text-xs">每次会额外消耗图片模型额度</span>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
配图数量
|
||||
<Select value={imageCount} onValueChange={setImageCount}>
|
||||
<SelectTrigger className="h-8 w-20 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: Math.min(Math.max(maxGeneratedImages, 1), 4) }, (_, index) => index + 1).map((count) => (
|
||||
<SelectItem key={count} value={String(count)}>{count} 张</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
{isNewReportGeneration && onRewriteOriginal && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canStart}
|
||||
onClick={() => startWith(onRewriteOriginal)}
|
||||
>
|
||||
<FilePenLineIcon />
|
||||
改写原文
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={!canStart}
|
||||
onClick={() => {
|
||||
if (!canStart) return;
|
||||
onStart({ instruction: instruction.trim(), style, modelName });
|
||||
}}
|
||||
onClick={() => startWith(onStart)}
|
||||
>
|
||||
<SparklesIcon />
|
||||
开始改写
|
||||
{submitLabel ?? "开始改写"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./artifact-file-detail";
|
||||
export * from "./artifact-file-list";
|
||||
export * from "./artifact-file-detail";
|
||||
export * from "./artifact-directory";
|
||||
export * from "./artifact-file-list";
|
||||
export * from "./artifact-trigger";
|
||||
export * from "./context";
|
||||
@@ -117,7 +117,7 @@ export function SpreadsheetPreview({
|
||||
{Array.from({ length: columnCount }, (_, columnIndex) => (
|
||||
<th
|
||||
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] ?? "")}
|
||||
</th>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -13,8 +14,13 @@ import {
|
||||
} from "@/components/ui/resizable";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import {
|
||||
listThreadArtifacts,
|
||||
mergeArtifactPaths,
|
||||
} from "@/core/artifacts/listing";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
@@ -32,6 +38,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
ArtifactFileDetail,
|
||||
ArtifactDirectory,
|
||||
ArtifactFileList,
|
||||
useArtifacts,
|
||||
} from "../artifacts";
|
||||
@@ -40,6 +47,7 @@ import { SkillDisplaySources } from "../skill-display-sources";
|
||||
|
||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||
const DIRECTORY_OPEN_MODE = { chat: 48, artifacts: 52 };
|
||||
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
||||
|
||||
type BackendReferenceSource = Parameters<typeof SkillDisplaySources>[0]["sources"][number];
|
||||
@@ -78,7 +86,11 @@ const ChatBox: React.FC<{
|
||||
threadId,
|
||||
disableArtifacts = false,
|
||||
}) => {
|
||||
const { thread } = useThread();
|
||||
const {
|
||||
isMock,
|
||||
thread,
|
||||
knowledgeReferencesEnabled = false,
|
||||
} = useThread();
|
||||
const pathname = usePathname();
|
||||
const threadIdRef = useRef(threadId);
|
||||
const artifactsCountRef = useRef(0);
|
||||
@@ -92,8 +104,15 @@ const ChatBox: React.FC<{
|
||||
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||
const [settings] = useLocalSettings();
|
||||
const artifactDirectoryModeEnabled =
|
||||
settings.display.artifactDirectoryModeEnabled ?? false;
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
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
|
||||
// the existing reference panel, but must not request or write reference
|
||||
// batches through DeerFlow's backend APIs.
|
||||
@@ -107,9 +126,55 @@ const ChatBox: React.FC<{
|
||||
);
|
||||
const referencesEnabled = ragModeEnabled || hasFrontendRag;
|
||||
const shouldFetchBackendReferenceBatches =
|
||||
referencesEnabled && !hasFrontendRag;
|
||||
referencesEnabled && !hasFrontendRag && !!threadId && threadId !== "new";
|
||||
const { skills } = useSkills();
|
||||
|
||||
const canScanArtifactDirectory =
|
||||
artifactDirectoryModeEnabled &&
|
||||
!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(
|
||||
() =>
|
||||
artifactDirectoryModeEnabled
|
||||
? mergeArtifactPaths(
|
||||
checkpointArtifacts,
|
||||
(artifactListingQuery.data?.files ?? []).map((file) => file.path),
|
||||
)
|
||||
: checkpointArtifacts,
|
||||
[
|
||||
artifactDirectoryModeEnabled,
|
||||
artifactListingQuery.data?.files,
|
||||
checkpointArtifacts,
|
||||
],
|
||||
);
|
||||
|
||||
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 {
|
||||
artifacts,
|
||||
open: artifactsOpen,
|
||||
@@ -302,7 +367,8 @@ const ChatBox: React.FC<{
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (threadIdRef.current !== threadId) {
|
||||
const threadChanged = threadIdRef.current !== threadId;
|
||||
if (threadChanged) {
|
||||
threadIdRef.current = threadId;
|
||||
artifactsCountRef.current = 0;
|
||||
deselect();
|
||||
@@ -316,15 +382,21 @@ const ChatBox: React.FC<{
|
||||
}
|
||||
|
||||
// Update artifacts from the current thread
|
||||
const nextArtifacts = disableArtifacts ? [] : (thread.values.artifacts ?? []);
|
||||
const nextArtifacts = availableArtifacts;
|
||||
const hadArtifacts = artifactsCountRef.current > 0;
|
||||
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
|
||||
artifactsCountRef.current = nextArtifacts.length;
|
||||
setArtifacts(nextArtifacts);
|
||||
const artifactsChanged =
|
||||
artifacts.length !== nextArtifacts.length ||
|
||||
artifacts.some((artifact, index) => artifact !== nextArtifacts[index]);
|
||||
if (artifactsChanged) {
|
||||
setArtifacts(nextArtifacts);
|
||||
}
|
||||
if (
|
||||
!disableArtifacts &&
|
||||
(hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) &&
|
||||
!selectedArtifact
|
||||
nextArtifacts.length > 0 &&
|
||||
((artifactDirectoryModeEnabled && threadChanged) ||
|
||||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
|
||||
) {
|
||||
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
|
||||
setArtifactsOpen(true);
|
||||
@@ -351,15 +423,41 @@ const ChatBox: React.FC<{
|
||||
}
|
||||
}, [
|
||||
threadId,
|
||||
artifactDirectoryModeEnabled,
|
||||
autoSelectFirstArtifact,
|
||||
deselect,
|
||||
selectArtifact,
|
||||
selectedArtifact,
|
||||
artifacts,
|
||||
setArtifacts,
|
||||
thread.values.artifacts,
|
||||
availableArtifacts,
|
||||
disableArtifacts,
|
||||
]);
|
||||
|
||||
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(() => {
|
||||
if (disableArtifacts) {
|
||||
return false;
|
||||
@@ -370,7 +468,10 @@ const ChatBox: React.FC<{
|
||||
return artifactsOpen;
|
||||
}, [artifactsOpen, artifacts, disableArtifacts]);
|
||||
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
|
||||
const showTabs = !!selectedArtifact && hasCitationReferences;
|
||||
const showTabs =
|
||||
hasCitationReferences &&
|
||||
(!!selectedArtifact ||
|
||||
(artifactDirectoryModeEnabled && artifacts.length > 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedArtifact) {
|
||||
@@ -390,12 +491,103 @@ const ChatBox: React.FC<{
|
||||
useEffect(() => {
|
||||
if (layoutRef.current) {
|
||||
if (sidePanelOpen) {
|
||||
layoutRef.current.setLayout(OPEN_MODE);
|
||||
layoutRef.current.setLayout(
|
||||
artifactDirectoryModeEnabled && artifactPanelOpen
|
||||
? DIRECTORY_OPEN_MODE
|
||||
: OPEN_MODE,
|
||||
);
|
||||
} else {
|
||||
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) {
|
||||
return (
|
||||
@@ -455,13 +647,7 @@ const ChatBox: React.FC<{
|
||||
</Button>
|
||||
</div>
|
||||
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
<ArtifactFileDetail
|
||||
key={selectedArtifactKey ?? selectedArtifact}
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
allowVirtualMarkdownEdit
|
||||
/>
|
||||
{artifactDocumentPanel}
|
||||
</TabsContent>
|
||||
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
||||
<ReferencePanel
|
||||
@@ -470,14 +656,8 @@ const ChatBox: React.FC<{
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
key={selectedArtifactKey ?? selectedArtifact}
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
allowVirtualMarkdownEdit
|
||||
/>
|
||||
) : artifactPanelOpen ? (
|
||||
artifactDocumentPanel
|
||||
) : hasCitationReferences ? (
|
||||
<div className="relative size-full overflow-hidden">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
@@ -497,41 +677,7 @@ const ChatBox: React.FC<{
|
||||
sources={referenceSources}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
@@ -780,11 +926,10 @@ function buildTurnReferenceBatches(
|
||||
continue;
|
||||
}
|
||||
const nextHumanIndex = findNextHumanIndex(messages, index + 1);
|
||||
if (nextHumanIndex === -1 && isLoading) {
|
||||
continue;
|
||||
}
|
||||
const endIndex = nextHumanIndex === -1 ? messages.length : nextHumanIndex;
|
||||
const assistant = findLastAssistantAnswer(messages, index + 1, endIndex);
|
||||
const assistant =
|
||||
findLastAssistantAnswer(messages, index + 1, endIndex) ??
|
||||
findFrontendRagAssistant(messages, index + 1, endIndex);
|
||||
if (!assistant?.id) {
|
||||
continue;
|
||||
}
|
||||
@@ -794,6 +939,14 @@ function buildTurnReferenceBatches(
|
||||
nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id,
|
||||
includeBeforeMessage: false,
|
||||
}).filter((source) => source.mode !== "none");
|
||||
// GeneralQA's single-RAG stream sends `docs` before the answer. Make
|
||||
// those cards available immediately, while keeping normal streaming turns
|
||||
// on their existing "wait for an answer" behavior.
|
||||
const isFrontendRagTurn =
|
||||
isSingleRoundRagMessage(assistant) || isMultiRoundRagMessage(assistant);
|
||||
if (nextHumanIndex === -1 && isLoading && !isFrontendRagTurn) {
|
||||
continue;
|
||||
}
|
||||
if (sources.length > 0) {
|
||||
batches.push({
|
||||
id: assistant.id,
|
||||
@@ -832,6 +985,23 @@ function findLastAssistantAnswer(
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFrontendRagAssistant(
|
||||
messages: Message[],
|
||||
start: number,
|
||||
end: number,
|
||||
): Message | null {
|
||||
for (let index = end - 1; index >= start; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (
|
||||
message?.type === "ai" &&
|
||||
(isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message))
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasAssistantAnswerContent(message: Message): boolean {
|
||||
const content = message.content;
|
||||
if (typeof content === "string") {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BookOpenCheck,
|
||||
Brain,
|
||||
CalendarClock,
|
||||
MessageSquare,
|
||||
MessageSquarePlus,
|
||||
PanelLeftClose,
|
||||
PenLine,
|
||||
Settings,
|
||||
Sparkles,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { MouseEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import cmAssistantLogoDark from "@/assets/img/cm-assistant-logo.png";
|
||||
import cmAssistantLogoLight from "@/assets/img/cm-assistant-logo-red.png";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
import { AgentBrandIcon } from "./agents/agent-brand-icon";
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||
|
||||
const NEW_CHAT_PATH = "/page/workspace/chats/new";
|
||||
|
||||
type NavItem = {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: typeof MessageSquare;
|
||||
match: string;
|
||||
};
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ label: "新建对话", href: NEW_CHAT_PATH, icon: MessageSquarePlus, match: NEW_CHAT_PATH },
|
||||
{ label: "对话", href: "/page/workspace/chats", icon: MessageSquare, match: "/page/workspace/chats" },
|
||||
{ label: "智能体", href: "/page/workspace/agents", icon: Sparkles, match: "/page/workspace/agents" },
|
||||
{ label: "技能", href: "/page/workspace/skills", icon: Sparkles, match: "/page/workspace/skills" },
|
||||
{ label: "记忆", href: "/page/workspace/memory", icon: Brain, match: "/page/workspace/memory" },
|
||||
{ label: "AI 写作", href: "/page/workspace/ai-writing", icon: PenLine, match: "/page/workspace/ai-writing" },
|
||||
{
|
||||
label: "多智能体规划",
|
||||
href: "/page/workspace/roundtable/planning",
|
||||
icon: UsersRound,
|
||||
match: "/page/workspace/roundtable",
|
||||
},
|
||||
{
|
||||
label: "定时任务",
|
||||
href: "/page/workspace/scheduled/tasks",
|
||||
icon: CalendarClock,
|
||||
match: "/page/workspace/scheduled",
|
||||
},
|
||||
{ label: "问答梳理", href: "/page/workspace/knowledge", icon: BookOpenCheck, match: "/page/workspace/knowledge" },
|
||||
] as const;
|
||||
|
||||
export function CmChatSidebar({
|
||||
onCollapse,
|
||||
collapsed = false,
|
||||
forceCollapsed = false,
|
||||
}: {
|
||||
onCollapse?: () => void;
|
||||
collapsed?: boolean;
|
||||
/** Deployment-level compact navigation: retain only accessible menu icons. */
|
||||
forceCollapsed?: boolean;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const navigate = useNavigate();
|
||||
const { theme } = useTheme();
|
||||
const brandLogoSrc = theme === "light" ? cmAssistantLogoLight : cmAssistantLogoDark;
|
||||
|
||||
const handleNewChat = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
|
||||
event.preventDefault();
|
||||
navigate(NEW_CHAT_PATH, { replace: true, state: { resetAt: Date.now() } });
|
||||
};
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<aside className="flex h-full min-h-0 w-14 flex-col border-r border-slate-200/80 bg-white text-slate-700 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-200">
|
||||
{!forceCollapsed && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
aria-label="展开侧边栏"
|
||||
title="展开侧边栏"
|
||||
className="mx-auto mt-2 flex size-11 shrink-0 items-center justify-center rounded-xl transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 dark:hover:bg-slate-900"
|
||||
>
|
||||
<img
|
||||
src={brandLogoSrc}
|
||||
alt="CM 助手"
|
||||
className="size-10 object-contain"
|
||||
/>
|
||||
</button>
|
||||
<div className="mx-2 mt-2 h-px shrink-0 bg-slate-200/80 dark:bg-slate-800" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
"flex shrink-0 flex-col items-center gap-1 px-2 py-3",
|
||||
forceCollapsed && "pt-4",
|
||||
)}
|
||||
aria-label="工作台导航"
|
||||
>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = item.href === NEW_CHAT_PATH
|
||||
? pathname === NEW_CHAT_PATH
|
||||
: item.href === "/page/workspace/chats"
|
||||
? pathname.startsWith(item.match) && pathname !== NEW_CHAT_PATH
|
||||
: pathname.startsWith(item.match);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={item.href === NEW_CHAT_PATH ? handleNewChat : undefined}
|
||||
aria-current={active ? "page" : undefined}
|
||||
aria-label={item.label}
|
||||
title={item.label}
|
||||
className={cn(
|
||||
"flex size-10 items-center justify-center rounded-xl transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
|
||||
active
|
||||
? "bg-primary/10 text-primary dark:bg-primary/15 dark:text-primary"
|
||||
: "text-slate-600 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
{item.href === "/page/workspace/agents" ? (
|
||||
<AgentBrandIcon size="nav" />
|
||||
) : (
|
||||
<Icon className="size-[19px]" strokeWidth={1.8} />
|
||||
)}
|
||||
<span className="sr-only">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto flex shrink-0 justify-center border-t border-slate-200/80 py-3 dark:border-slate-800">
|
||||
<Link
|
||||
href="/page/workspace/settings"
|
||||
aria-label="设置和更多"
|
||||
title="设置和更多"
|
||||
className="flex size-10 items-center justify-center rounded-xl text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white"
|
||||
>
|
||||
<Settings className="size-[19px]" strokeWidth={1.8} />
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden border-r border-slate-200/80 bg-white text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-100">
|
||||
<div className="flex shrink-0 items-center gap-1.5 px-4 pt-3.5 pb-2.5">
|
||||
<Link href={NEW_CHAT_PATH} className="flex min-w-0 flex-1 items-center gap-2.5 outline-none focus-visible:ring-2 focus-visible:ring-primary/40">
|
||||
<img
|
||||
src={brandLogoSrc}
|
||||
alt="CM 助手"
|
||||
className="size-9 shrink-0 object-contain"
|
||||
/>
|
||||
<span className="truncate text-[19px] font-semibold tracking-[-0.025em]">CM 助手</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
aria-label="收起侧边栏"
|
||||
title="收起侧边栏"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 dark:hover:bg-slate-900 dark:hover:text-white"
|
||||
>
|
||||
<PanelLeftClose className="size-[18px]" strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="shrink-0 px-3 pt-1" aria-label="工作台导航">
|
||||
<div className="space-y-1">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = item.href === NEW_CHAT_PATH
|
||||
? pathname === NEW_CHAT_PATH
|
||||
: item.href === "/page/workspace/chats"
|
||||
? pathname.startsWith(item.match) && pathname !== NEW_CHAT_PATH
|
||||
: pathname.startsWith(item.match);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={item.href === NEW_CHAT_PATH ? handleNewChat : undefined}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex h-10 items-center gap-3 rounded-xl px-3 text-[14px] font-medium transition-colors",
|
||||
active
|
||||
? "bg-primary/10 text-primary dark:bg-primary/15 dark:text-primary"
|
||||
: "text-slate-600 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
{item.href === "/page/workspace/agents" ? (
|
||||
<AgentBrandIcon size="nav" />
|
||||
) : (
|
||||
<Icon className="size-[18px] shrink-0" strokeWidth={1.8} />
|
||||
)}
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-1">
|
||||
<RecentChatList compact limit={5} moreHref="/page/workspace/chats" />
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-slate-200/80 px-3 py-3 dark:border-slate-800">
|
||||
<WorkspaceNavMenu />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,12 +11,11 @@ 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 { memo, useMemo } from "react";
|
||||
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useThread } from "./messages/context";
|
||||
const customDarkTheme = monokaiInit({
|
||||
settings: {
|
||||
background: "transparent",
|
||||
@@ -34,7 +33,7 @@ const customLightTheme = basicLightInit({
|
||||
},
|
||||
});
|
||||
|
||||
export function CodeEditor({
|
||||
export const CodeEditor = memo(function CodeEditor({
|
||||
className,
|
||||
placeholder,
|
||||
value,
|
||||
@@ -43,6 +42,7 @@ export function CodeEditor({
|
||||
disabled,
|
||||
autoFocus,
|
||||
settings,
|
||||
isStreaming = false,
|
||||
streamingReadonlyFallback = true,
|
||||
}: {
|
||||
className?: string;
|
||||
@@ -53,11 +53,10 @@ export function CodeEditor({
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
settings?: unknown;
|
||||
/** Parent-owned streaming flag so this editor does not subscribe to `thread`. */
|
||||
isStreaming?: boolean;
|
||||
streamingReadonlyFallback?: boolean;
|
||||
}) {
|
||||
const {
|
||||
thread: { isLoading },
|
||||
} = useThread();
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
@@ -81,7 +80,7 @@ export function CodeEditor({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isLoading && streamingReadonlyFallback ? (
|
||||
{isStreaming && streamingReadonlyFallback ? (
|
||||
<Textarea
|
||||
className={cn(
|
||||
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
|
||||
@@ -116,4 +115,4 @@ export function CodeEditor({
|
||||
)}
|
||||
</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,162 @@
|
||||
"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`` 泳道。点击右侧竖排按钮后展开单列竖向抽屉。
|
||||
*/
|
||||
export function CompactToolbox() {
|
||||
const { toolboxItems } = useWorkspaceCompactNavItems();
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => setOpen(false), [pathname]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
closeRef.current?.focus();
|
||||
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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="关闭工具箱抽屉"
|
||||
aria-hidden={!open}
|
||||
onClick={closeDrawer}
|
||||
className={cn(
|
||||
"fixed inset-0 z-[90] bg-black/20 transition-opacity duration-200",
|
||||
open ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0",
|
||||
)}
|
||||
/>
|
||||
|
||||
<aside
|
||||
id="compact-toolbox-panel"
|
||||
role="dialog"
|
||||
aria-modal={open}
|
||||
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
|
||||
ref={closeRef}
|
||||
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={() => {
|
||||
setOpen(false);
|
||||
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,24 +42,46 @@ import {
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
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({
|
||||
onInsert,
|
||||
className,
|
||||
currentAgentId,
|
||||
compact = false,
|
||||
}: {
|
||||
onInsert: (content: string) => void;
|
||||
className?: string;
|
||||
currentAgentId?: string | null;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const allPrompts = useCustomPrompts();
|
||||
const prompts = allPrompts.filter(
|
||||
(prompt) => !prompt.agent_id || prompt.agent_id === currentAgentId,
|
||||
);
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
const guideOwnsDialogRef = useRef(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canLeft, setCanLeft] = 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 el = scrollRef.current;
|
||||
if (!el) return;
|
||||
@@ -81,7 +103,14 @@ export function CustomPromptBar({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex min-w-0 items-center gap-1", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-1",
|
||||
compact && "min-h-9 justify-end",
|
||||
className,
|
||||
)}
|
||||
data-compact-guide="prompt-bar"
|
||||
>
|
||||
{prompts.length > 0 && (
|
||||
<>
|
||||
{overflowing && (
|
||||
@@ -111,6 +140,7 @@ export function CustomPromptBar({
|
||||
title={prompt.content}
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 text-foreground/80 hover:bg-muted hover:text-foreground hover:border-border max-w-[200px] shrink-0 truncate rounded-full border px-3 py-1 text-xs font-medium shadow-sm transition-colors",
|
||||
compact && "border-slate-200 bg-white px-3.5 py-1.5 text-[12px] text-slate-600 shadow-none hover:border-primary/25 hover:bg-primary/5 hover:text-primary",
|
||||
"dark:border-slate-600/70 dark:bg-slate-700/40 dark:text-slate-200 dark:hover:bg-slate-700",
|
||||
)}
|
||||
>
|
||||
@@ -139,7 +169,11 @@ export function CustomPromptBar({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full",
|
||||
compact &&
|
||||
"h-9 w-9 rounded-xl border border-slate-200 bg-white text-slate-600 shadow-[0_6px_18px_-14px_rgba(15,23,42,0.4)] hover:border-primary/25 hover:bg-primary/5 hover:text-primary dark:border-slate-700 dark:bg-slate-900",
|
||||
)}
|
||||
title="管理常用提示词"
|
||||
aria-label="管理常用提示词"
|
||||
onClick={() => setManageOpen(true)}
|
||||
@@ -148,7 +182,14 @@ export function CustomPromptBar({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ManagePromptsDialog open={manageOpen} onOpenChange={setManageOpen} />
|
||||
<ManagePromptsDialog
|
||||
open={manageOpen}
|
||||
onOpenChange={(next) => {
|
||||
// 引导演示期间接管弹窗:ESC / 点击遮罩不关闭,避免高亮目标消失导致引导报错。
|
||||
if (!next && guideOwnsDialogRef.current) return;
|
||||
setManageOpen(next);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -257,7 +298,10 @@ function ManagePromptsDialog({
|
||||
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>
|
||||
<DialogTitle>管理常用提示词</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -12,10 +12,11 @@ export function FlipDisplay({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("relative overflow-hidden", className)}>
|
||||
<div className={cn("relative min-w-0 overflow-hidden", className)}>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={uniqueKey}
|
||||
className="min-w-0 max-w-full"
|
||||
initial={{ y: 8, opacity: 0 }}
|
||||
animate={{ y: 2, opacity: 1 }}
|
||||
exit={{ y: -8, opacity: 0 }}
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import {
|
||||
isBootstrapThread,
|
||||
isHiddenFromRecentChats,
|
||||
isThreadFavorite,
|
||||
isThreadPinned,
|
||||
listPathOfThread,
|
||||
@@ -226,14 +227,8 @@ export function GeneralQaSidebar() {
|
||||
const visibleThreads = useMemo(
|
||||
() =>
|
||||
// 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页);
|
||||
// 仅排除调度 / 会商类系统会话。
|
||||
sortThreadsPinnedFirst(
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "roundtable",
|
||||
),
|
||||
),
|
||||
// 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
|
||||
sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
|
||||
[threads],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
BrainCircuitIcon,
|
||||
BookMarkedIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
BarChart3Icon,
|
||||
BrainCircuitIcon,
|
||||
BookMarkedIcon,
|
||||
CheckIcon,
|
||||
ClipboardCheckIcon,
|
||||
FileTextIcon,
|
||||
FolderTreeIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
PaperclipIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
ShrinkIcon,
|
||||
SparklesIcon,
|
||||
RocketIcon,
|
||||
@@ -92,19 +97,69 @@ import {
|
||||
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import { useUserPreferences } from "@/core/user-preferences";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
useLocalSettings,
|
||||
} from "@/core/settings";
|
||||
|
||||
|
||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
useLocalSettings,
|
||||
} from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
|
||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||
import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
const COMPACT_QUICK_STARTS = [
|
||||
{
|
||||
id: "report",
|
||||
title: "撰写报告",
|
||||
content: "请帮我撰写一份结构清晰的专业报告,先向我确认主题、受众和篇幅要求。",
|
||||
icon: FileTextIcon,
|
||||
},
|
||||
{
|
||||
id: "knowledge",
|
||||
title: "知识问答",
|
||||
content: "请基于已有资料回答我的问题,并明确区分已知事实、推断和需要补充的信息。",
|
||||
icon: MessageCircleQuestionIcon,
|
||||
},
|
||||
{
|
||||
id: "planning",
|
||||
title: "任务规划",
|
||||
content: "请把我的目标拆解为可执行的任务计划,列出优先级、里程碑和风险。",
|
||||
icon: ClipboardCheckIcon,
|
||||
},
|
||||
{
|
||||
id: "analysis",
|
||||
title: "数据分析",
|
||||
content: "请帮我分析数据中的关键趋势、异常和可能原因,并给出下一步验证建议。",
|
||||
icon: BarChart3Icon,
|
||||
},
|
||||
{
|
||||
id: "solution",
|
||||
title: "方案策划",
|
||||
content: "请围绕我的目标设计一套完整方案,包含背景、目标、实施步骤、资源需求和风险预案。",
|
||||
icon: LightbulbIcon,
|
||||
},
|
||||
{
|
||||
id: "summary",
|
||||
title: "内容总结",
|
||||
content: "请提炼我提供内容的核心观点、关键结论和待办事项,并按重要程度整理。",
|
||||
icon: BookMarkedIcon,
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
title: "调研提纲",
|
||||
content: "请根据我的调研主题生成一份结构完整的调研提纲,覆盖对象、问题、方法和输出形式。",
|
||||
icon: GraduationCapIcon,
|
||||
},
|
||||
{
|
||||
id: "polish",
|
||||
title: "文案润色",
|
||||
content: "请在不改变原意的前提下润色我提供的内容,使表达更清晰、专业、简洁。",
|
||||
icon: SparklesIcon,
|
||||
},
|
||||
] as const;
|
||||
|
||||
|
||||
function getResolvedMode(
|
||||
@@ -141,6 +196,7 @@ export function InputBox({
|
||||
assistantId,
|
||||
hideAdminRecommendedQuestions,
|
||||
compact,
|
||||
hideContextTools,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||
assistantId?: string | null;
|
||||
@@ -148,6 +204,9 @@ export function InputBox({
|
||||
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
|
||||
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
|
||||
compact?: boolean;
|
||||
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
|
||||
* 受控场景用;文件/模型等其他按钮不受影响)。 */
|
||||
hideContextTools?: boolean;
|
||||
disabled?: boolean;
|
||||
context: Omit<
|
||||
AgentThreadContext,
|
||||
@@ -216,9 +275,12 @@ export function InputBox({
|
||||
const modelOptions = useMemo(() => {
|
||||
return singleRoundRagActive ? extraModels : models;
|
||||
}, [extraModels, models, singleRoundRagActive]);
|
||||
const { thread, isMock } = useThread();
|
||||
const { textInput } = usePromptInputController();
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const { thread, isMock } = useThread();
|
||||
const { textInput } = usePromptInputController();
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
|
||||
const [followups, setFollowups] = useState<string[]>([]);
|
||||
const [followupsHidden, setFollowupsHidden] = useState(false);
|
||||
@@ -227,6 +289,7 @@ export function InputBox({
|
||||
const wasStreamingRef = useRef(false);
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [compactPromptOrder, setCompactPromptOrder] = useState(0);
|
||||
const [pendingSuggestion, setPendingSuggestion] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -237,6 +300,29 @@ export function InputBox({
|
||||
!hideAdminRecommendedQuestions &&
|
||||
!userPreferences.hide_admin_prompt_prefix &&
|
||||
recommendedQuestions.length > 0;
|
||||
const compactQuickStarts = useMemo(() => {
|
||||
if (showAdminRecommendedQuestions) {
|
||||
return recommendedQuestions.map((configured, index) => {
|
||||
const fallback =
|
||||
COMPACT_QUICK_STARTS[index % COMPACT_QUICK_STARTS.length]!;
|
||||
return {
|
||||
...fallback,
|
||||
id: configured.id,
|
||||
title: desensitize(configured.title) || `提示词 ${index + 1}`,
|
||||
content: configured.content || fallback.content,
|
||||
};
|
||||
});
|
||||
}
|
||||
return [...COMPACT_QUICK_STARTS];
|
||||
}, [recommendedQuestions, showAdminRecommendedQuestions]);
|
||||
const orderedCompactQuickStarts = useMemo(() => {
|
||||
if (compactQuickStarts.length < 2) return compactQuickStarts;
|
||||
const offset = compactPromptOrder % compactQuickStarts.length;
|
||||
return [
|
||||
...compactQuickStarts.slice(offset),
|
||||
...compactQuickStarts.slice(0, offset),
|
||||
];
|
||||
}, [compactPromptOrder, compactQuickStarts]);
|
||||
|
||||
useEffect(() => {
|
||||
// Multi-round RAG is a frontend execution profile, not a model option.
|
||||
@@ -375,6 +461,14 @@ export function InputBox({
|
||||
});
|
||||
}, [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 handleMemoryInjectionToggle = useCallback(() => {
|
||||
onContextChange?.({
|
||||
@@ -593,14 +687,53 @@ export function InputBox({
|
||||
}, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
|
||||
|
||||
return (
|
||||
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
||||
<div ref={promptRootRef} className={cn("relative flex flex-col", compact ? "gap-[clamp(32px,6vh,58px)]" : "gap-6")}>
|
||||
{extraHeader && (
|
||||
<div className="flex items-center justify-center">
|
||||
{extraHeader}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && compact && (
|
||||
<section className="px-2" aria-labelledby="recommended-prompts-title">
|
||||
<div className="mb-3 flex items-center justify-center gap-1.5">
|
||||
<h2 id="recommended-prompts-title" className="text-[14px] font-normal text-slate-400 dark:text-slate-500">
|
||||
你可以这样问我
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
data-prompt-order-refresh="true"
|
||||
title="调整提示词顺序"
|
||||
aria-label="调整提示词顺序"
|
||||
onClick={() => setCompactPromptOrder((current) => current + 1)}
|
||||
className="flex size-7 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 dark:hover:bg-slate-800 dark:hover:text-primary"
|
||||
>
|
||||
<RefreshCwIcon className="size-3.5" strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mx-auto flex max-w-[900px] flex-wrap justify-center gap-x-3 gap-y-3">
|
||||
{orderedCompactQuickStarts.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-quick-prompt={item.id}
|
||||
title={item.content}
|
||||
className="inline-flex min-h-11 max-w-full items-center justify-center rounded-xl border border-[#e5e7eb] bg-white px-4 py-2 text-center text-[14px] font-normal leading-5 text-slate-700 shadow-[0_2px_6px_rgba(15,23,42,0.025)] transition-[background-color,border-color,box-shadow] hover:border-primary/25 hover:bg-primary/5 hover:text-slate-900 hover:shadow-[0_4px_10px_rgba(15,23,42,0.04)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-primary/35 dark:hover:bg-primary/10"
|
||||
onClick={() => {
|
||||
textInput.setInput(item.content);
|
||||
requestAnimationFrame(() =>
|
||||
document.querySelector<HTMLTextAreaElement>("textarea[name='message']")?.focus(),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span className="max-w-full whitespace-normal">{item.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && !compact && showAdminRecommendedQuestions && (
|
||||
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
||||
{recommendedQuestions.map((q) => (
|
||||
<button
|
||||
@@ -653,7 +786,8 @@ export function InputBox({
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{isNewThread && (
|
||||
<CustomPromptBar
|
||||
className="px-1"
|
||||
compact={compact}
|
||||
className={cn(compact ? "px-2" : "px-1")}
|
||||
currentAgentId={agentSelector?.selectedAgentId ?? assistantId ?? null}
|
||||
onInsert={(content) => {
|
||||
const current = textInput.value?.trim() ?? "";
|
||||
@@ -677,7 +811,7 @@ export function InputBox({
|
||||
: "*:data-[slot='input-group']:rounded-3xl",
|
||||
// 紧凑落地态(CM 参考图):白卡 + 细边 + 轻阴影 + 主色聚焦环(无旋转彩边)
|
||||
compact && isNewThread
|
||||
? "rounded-2xl border border-gray-200/70 bg-white overflow-hidden shadow-[0_4.5px_22px_rgba(0,0,0,0.025)] transition-all duration-300 focus-within:border-[rgb(var(--legacy-primary-rgb)/35%)] focus-within:ring-4 focus-within:ring-[rgb(var(--legacy-primary-rgb)/5%)] focus-within:shadow-[0_10px_35px_rgb(var(--legacy-primary-rgb)/4%)] dark:border-slate-700 dark:bg-slate-800"
|
||||
? "overflow-hidden rounded-[24px] border border-slate-200/90 bg-white shadow-[0_18px_50px_-32px_rgba(15,23,42,0.22),0_8px_24px_-20px_rgba(15,23,42,0.35)] transition-all duration-300 focus-within:border-primary/35 focus-within:ring-4 focus-within:ring-primary/10 dark:border-slate-700 dark:bg-slate-800"
|
||||
: // landing 态:透明,让外层 gemini-gradient-border 提供白卡 + 旋转描边
|
||||
// 对话态:常规白卡 + 中性边框 + 轻阴影
|
||||
isNewThread
|
||||
@@ -701,7 +835,7 @@ export function InputBox({
|
||||
className={cn(
|
||||
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-muted-foreground/60",
|
||||
isNewThread && "min-h-[110px]!",
|
||||
compact && "min-h-[84px]! px-3 pt-3 pb-1 text-[14px]",
|
||||
compact && "min-h-[clamp(150px,18vh,176px)]! px-7 pt-7 pb-2 text-[15px]",
|
||||
)}
|
||||
disabled={disabled}
|
||||
placeholder={t.inputBox.placeholder}
|
||||
@@ -712,7 +846,7 @@ export function InputBox({
|
||||
<PromptInputFooter
|
||||
className={cn(
|
||||
"flex items-center border-t-0 px-2 pt-1 pb-1.5",
|
||||
compact && "gap-2 px-3 pt-1.5 pb-3",
|
||||
compact && "gap-2 px-6 pt-2 pb-5",
|
||||
)}
|
||||
>
|
||||
<PromptInputTools
|
||||
@@ -736,11 +870,11 @@ export function InputBox({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!frontendRagActive && <Tooltip
|
||||
{!frontendRagActive && !hideContextTools && <Tooltip
|
||||
content={
|
||||
context.writing_mode
|
||||
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
|
||||
: "开启写作模式:最终输出保存为 Markdown 文件并进入画布"
|
||||
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
|
||||
: "开启写作模式:对话照常进行,交付时由深度研究管线撰写报告并保存为 Markdown 进入画布"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
@@ -763,6 +897,35 @@ export function InputBox({
|
||||
</PromptInputButton>
|
||||
</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 && <Tooltip
|
||||
content={
|
||||
memoryInjectionEnabled
|
||||
? "记忆注入已开启:对话前会注入历史记忆"
|
||||
@@ -788,9 +951,9 @@ export function InputBox({
|
||||
<span className="text-xs font-normal">记忆</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!frontendRagActive && <Tooltip
|
||||
content={
|
||||
ragModeEnabled
|
||||
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
|
||||
content={
|
||||
ragModeEnabled
|
||||
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
||||
: "参考文献模式已关闭:点击开启"
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ function SelectionContent({
|
||||
error,
|
||||
source,
|
||||
variant,
|
||||
readOnly,
|
||||
}: {
|
||||
rows: LlmWikiKnowledgeBase[];
|
||||
value: string[];
|
||||
@@ -86,6 +87,7 @@ function SelectionContent({
|
||||
error: unknown;
|
||||
source: SelectorSource;
|
||||
variant: SelectorVariant;
|
||||
readOnly: boolean;
|
||||
}) {
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
const filtered = useMemo(() => {
|
||||
@@ -97,6 +99,7 @@ function SelectionContent({
|
||||
}, [rows, search]);
|
||||
const groups = useMemo(() => groupRows(filtered), [filtered]);
|
||||
const toggle = (id: string) => {
|
||||
if (readOnly) return;
|
||||
onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]);
|
||||
};
|
||||
|
||||
@@ -168,6 +171,7 @@ function SelectionContent({
|
||||
<DropdownMenuCheckboxItem
|
||||
key={item.id}
|
||||
checked={selected.has(item.id)}
|
||||
disabled={readOnly}
|
||||
onCheckedChange={() => toggle(item.id)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
className={cn(
|
||||
@@ -183,6 +187,7 @@ function SelectionContent({
|
||||
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",
|
||||
@@ -205,15 +210,21 @@ function SelectionContent({
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange(filtered.map((item) => item.id))}>
|
||||
全选
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{readOnly ? `本对话已选 ${value.length} 个` : `已选择 ${value.length} 个`}
|
||||
</span>
|
||||
{readOnly ? (
|
||||
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange(filtered.map((item) => item.id))}>
|
||||
全选
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -226,6 +237,7 @@ export function LlmWikiKnowledgeSelector({
|
||||
agentId,
|
||||
autoSelectAll = false,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
className,
|
||||
label = "知识空间",
|
||||
variant = "popover",
|
||||
@@ -236,6 +248,8 @@ export function LlmWikiKnowledgeSelector({
|
||||
agentId?: string | null;
|
||||
autoSelectAll?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Keep the selected knowledge spaces visible without allowing the thread scope to change. */
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
label?: string;
|
||||
variant?: SelectorVariant;
|
||||
@@ -293,6 +307,7 @@ export function LlmWikiKnowledgeSelector({
|
||||
error={error}
|
||||
source={source}
|
||||
variant={variant}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -315,9 +330,14 @@ export function LlmWikiKnowledgeSelector({
|
||||
);
|
||||
}
|
||||
|
||||
const selectedName = value.length === 1
|
||||
? rows.find((item) => item.id === value[0])?.name
|
||||
: null;
|
||||
const selectedNames = value
|
||||
.map((id) => rows.find((item) => item.id === id)?.name)
|
||||
.filter((name): name is string => !!name);
|
||||
const selectedLabel = selectedNames.length > 0
|
||||
? selectedNames.join("、")
|
||||
: value.length > 0
|
||||
? `已选 ${value.length} 个知识空间`
|
||||
: label;
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
@@ -332,9 +352,10 @@ export function LlmWikiKnowledgeSelector({
|
||||
className,
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
title={value.length > 0 ? selectedLabel : undefined}
|
||||
>
|
||||
{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 && (
|
||||
<span className="flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
|
||||
{value.length}
|
||||
|
||||
@@ -4,11 +4,17 @@ import { createContext, useContext } from "react";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
|
||||
export interface ThreadContextType {
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
isMock?: boolean;
|
||||
/**
|
||||
* When set, the conversation is rendered from a public read-only share.
|
||||
export interface ThreadContextType {
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
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.
|
||||
* Artifact/image URLs then resolve to the unauthenticated
|
||||
* `/api/public/shares/{code}/files/...` endpoint instead of the
|
||||
* auth-gated thread artifact route.
|
||||
|
||||
@@ -2,34 +2,61 @@
|
||||
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronsDownIcon,
|
||||
FilePenLineIcon,
|
||||
LightbulbIcon,
|
||||
Loader2Icon,
|
||||
Undo2Icon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { updateArtifactContent } from "@/core/artifacts/api";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import {
|
||||
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
|
||||
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
||||
DOCUMENT_REWRITE_UNDONE_EVENT,
|
||||
type DocumentRewritePresentation,
|
||||
useDocumentRewritePresentation,
|
||||
} from "@/core/document-rewrite/context";
|
||||
import {
|
||||
clearArtifactRewriteFallback,
|
||||
loadArtifactRewriteFallback,
|
||||
sha256DocumentContent,
|
||||
} from "@/core/document-rewrite/local-fallback";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
|
||||
|
||||
const STEPS = [
|
||||
const REWRITE_STEPS = [
|
||||
{ key: "snapshot", label: "固定原文版本" },
|
||||
{ key: "requirements", label: "理解改写要求" },
|
||||
{ key: "writing", label: "流式重写 Markdown" },
|
||||
{ key: "illustrations", label: "生成文档配图" },
|
||||
{ key: "validation", label: "校验文档" },
|
||||
{ key: "comparison", label: "对比原文并生成说明" },
|
||||
] as const;
|
||||
|
||||
const GENERATE_STEPS = [
|
||||
{ key: "snapshot", label: "固定来源报告快照" },
|
||||
{ key: "requirements", label: "确认用户生成需求" },
|
||||
{ key: "writing", label: "流式生成 Markdown" },
|
||||
{ key: "illustrations", label: "生成文档配图" },
|
||||
{ key: "validation", label: "校验新报告" },
|
||||
{ key: "comparison", label: "生成报告说明" },
|
||||
] as const;
|
||||
|
||||
function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
|
||||
switch (stage) {
|
||||
case "snapshot":
|
||||
@@ -38,15 +65,17 @@ function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
|
||||
return 1;
|
||||
case "writing":
|
||||
return 2;
|
||||
case "validation":
|
||||
case "illustrating":
|
||||
return 3;
|
||||
case "validation":
|
||||
return 4;
|
||||
case "committing":
|
||||
case "completed":
|
||||
return 5;
|
||||
return 6;
|
||||
case "conflict":
|
||||
case "error":
|
||||
case "cancelled":
|
||||
return 4;
|
||||
return 5;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
@@ -56,23 +85,218 @@ function isTerminal(stage: DocumentRewritePresentation["stage"]) {
|
||||
return ["completed", "conflict", "error", "cancelled"].includes(stage);
|
||||
}
|
||||
|
||||
function RewriteThinkingTrace({ text, active }: { text: string; active: boolean }) {
|
||||
const [open, setOpen] = useState(active);
|
||||
const [autoScrollPaused, setAutoScrollPaused] = useState(false);
|
||||
const contentRef = useRef<HTMLPreElement>(null);
|
||||
const autoScrollEnabledRef = useRef(true);
|
||||
const previousActiveRef = useRef(active);
|
||||
const lastAutoScrollTopRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && text) setOpen(true);
|
||||
}, [active, text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && !previousActiveRef.current) {
|
||||
autoScrollEnabledRef.current = true;
|
||||
lastAutoScrollTopRef.current = null;
|
||||
setAutoScrollPaused(false);
|
||||
}
|
||||
previousActiveRef.current = active;
|
||||
}, [active]);
|
||||
|
||||
const scrollToLatestThinking = useCallback(() => {
|
||||
if (!autoScrollEnabledRef.current) return;
|
||||
const content = contentRef.current;
|
||||
if (!content) return;
|
||||
content.scrollTop = content.scrollHeight;
|
||||
lastAutoScrollTopRef.current = content.scrollTop;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!active || !open || !text) return;
|
||||
scrollToLatestThinking();
|
||||
}, [active, open, scrollToLatestThinking, text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !open || !text) return;
|
||||
const content = contentRef.current;
|
||||
if (!content) return;
|
||||
let frame: number | null = null;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
scrollToLatestThinking();
|
||||
});
|
||||
});
|
||||
observer.observe(content, { childList: true, characterData: true, subtree: true });
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [active, open, scrollToLatestThinking, text]);
|
||||
|
||||
const pauseAutoScrollOnManualScroll = (event: React.UIEvent<HTMLPreElement>) => {
|
||||
if (!active || !autoScrollEnabledRef.current) return;
|
||||
const lastAutoScrollTop = lastAutoScrollTopRef.current;
|
||||
if (
|
||||
lastAutoScrollTop === null ||
|
||||
Math.abs(event.currentTarget.scrollTop - lastAutoScrollTop) > 1
|
||||
) {
|
||||
autoScrollEnabledRef.current = false;
|
||||
setAutoScrollPaused(true);
|
||||
}
|
||||
};
|
||||
|
||||
const resumeAutoScroll = () => {
|
||||
autoScrollEnabledRef.current = true;
|
||||
setAutoScrollPaused(false);
|
||||
requestAnimationFrame(scrollToLatestThinking);
|
||||
};
|
||||
|
||||
if (!text.trim()) return null;
|
||||
return (
|
||||
<Collapsible className="mt-2 max-w-3xl" onOpenChange={setOpen} open={open}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
aria-label={open ? "收起模型思考" : "展开模型思考"}
|
||||
className="h-7 gap-1.5 px-2 text-xs"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
|
||||
<span>模型思考</span>
|
||||
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
|
||||
<ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
|
||||
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
|
||||
</p>
|
||||
<div className="relative mt-1">
|
||||
<pre
|
||||
className="bg-muted/45 text-muted-foreground max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words"
|
||||
onScroll={pauseAutoScrollOnManualScroll}
|
||||
ref={contentRef}
|
||||
>
|
||||
{text}
|
||||
</pre>
|
||||
{active && autoScrollPaused && (
|
||||
<Button
|
||||
aria-label="恢复思考内容自动下滑"
|
||||
className="absolute top-1/2 left-1/2 h-9 -translate-x-1/2 -translate-y-1/2 gap-1.5 bg-background/95 px-3 text-xs shadow-md backdrop-blur-sm hover:bg-background"
|
||||
onClick={resumeAutoScroll}
|
||||
size="sm"
|
||||
title="恢复自动下滑"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ChevronsDownIcon className="size-3.5" />
|
||||
自动下滑
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentRewriteTimeline({
|
||||
item,
|
||||
}: {
|
||||
item: DocumentRewritePresentation;
|
||||
}) {
|
||||
const isNewReportGeneration = item.operation === "generate";
|
||||
const steps = isNewReportGeneration ? GENERATE_STEPS : REWRITE_STEPS;
|
||||
const current = stepPosition(item.stage);
|
||||
const failed = ["conflict", "error", "cancelled"].includes(item.stage);
|
||||
const completed = item.stage === "completed";
|
||||
const thinkingStep = ["snapshot", "requirements"].includes(item.stage)
|
||||
? "requirements"
|
||||
: "writing";
|
||||
const presentation = useDocumentRewritePresentation();
|
||||
const [undoing, setUndoing] = useState(false);
|
||||
const [diffOpen, setDiffOpen] = useState(false);
|
||||
|
||||
const reapplying = Boolean(item.revertedAt);
|
||||
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
|
||||
const canUseLocalFallback =
|
||||
!reapplying &&
|
||||
!activeVersionId &&
|
||||
item.source.kind === "artifact" &&
|
||||
typeof item.source.path === "string" &&
|
||||
item.localFallbackAvailable &&
|
||||
typeof item.committedHash === "string";
|
||||
|
||||
const handleUndo = async () => {
|
||||
if (!activeVersionId || undoing) return;
|
||||
if ((!activeVersionId && !canUseLocalFallback) || undoing) return;
|
||||
if (canUseLocalFallback && item.source.kind === "artifact" && item.source.path && item.committedHash) {
|
||||
setUndoing(true);
|
||||
try {
|
||||
const fallback = loadArtifactRewriteFallback({
|
||||
threadId: item.source.id,
|
||||
path: item.source.path,
|
||||
committedHash: item.committedHash,
|
||||
});
|
||||
if (!fallback) {
|
||||
console.error("本地 AI 全文改写撤销备份不可用,未修改当前文件。");
|
||||
return;
|
||||
}
|
||||
const currentResponse = await apiFetch(
|
||||
urlOfArtifact({ filepath: item.source.path, threadId: item.source.id }),
|
||||
);
|
||||
if (!currentResponse.ok) {
|
||||
throw new Error(`读取当前文件失败(${currentResponse.status})。`);
|
||||
}
|
||||
const currentHash = await sha256DocumentContent(await currentResponse.text());
|
||||
if (!currentHash || currentHash !== fallback.committedHash) {
|
||||
console.error("当前文件已在改写后发生变化,已跳过本地 AI 全文改写撤销。");
|
||||
return;
|
||||
}
|
||||
const guard = new CustomEvent<{
|
||||
source: DocumentRewritePresentation["source"];
|
||||
rewrittenContent: string | null;
|
||||
unchanged: boolean;
|
||||
}>(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, {
|
||||
detail: {
|
||||
source: item.source,
|
||||
rewrittenContent: item.rewrittenContent,
|
||||
unchanged: false,
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(guard);
|
||||
if (!guard.detail.unchanged) {
|
||||
console.error("沙箱中存在改写后的未保存编辑,已跳过本地 AI 全文改写撤销。");
|
||||
return;
|
||||
}
|
||||
await updateArtifactContent({
|
||||
filepath: item.source.path,
|
||||
threadId: item.source.id,
|
||||
content: fallback.originalContent,
|
||||
});
|
||||
clearArtifactRewriteFallback(item.source.id, item.source.path);
|
||||
presentation?.update(item.id, {
|
||||
localFallbackAvailable: false,
|
||||
revertedAt: new Date().toISOString(),
|
||||
});
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(DOCUMENT_REWRITE_UNDONE_EVENT, {
|
||||
detail: { source: item.source, content: fallback.originalContent },
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("本地 AI 全文改写撤销失败:", error);
|
||||
} finally {
|
||||
setUndoing(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeVersionId) return;
|
||||
const baseUrl = getBackendBaseURL();
|
||||
const endpoint =
|
||||
item.source.kind === "artifact"
|
||||
@@ -109,7 +333,15 @@ export function DocumentRewriteTimeline({
|
||||
detail: { source: item.source, content },
|
||||
}),
|
||||
);
|
||||
toast.success(reapplying ? "已重新应用本次 AI 全文改写版本。" : "已恢复到本次 AI 全文改写前的版本。");
|
||||
toast.success(
|
||||
isNewReportGeneration
|
||||
? reapplying
|
||||
? "已重新应用本次生成的新报告版本。"
|
||||
: "已恢复本次生成前的新报告快照。"
|
||||
: reapplying
|
||||
? "已重新应用本次 AI 全文改写版本。"
|
||||
: "已恢复到本次 AI 全文改写前的版本。",
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "撤销改写失败,请稍后重试。");
|
||||
} finally {
|
||||
@@ -125,7 +357,9 @@ export function DocumentRewriteTimeline({
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-semibold">AI 全文改写 · {item.fileName}</h3>
|
||||
<h3 className="font-semibold">
|
||||
{isNewReportGeneration ? "生成新报告" : "AI 全文改写"} · {item.fileName}
|
||||
</h3>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs",
|
||||
@@ -136,7 +370,7 @@ export function DocumentRewriteTimeline({
|
||||
: "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{completed ? "已完成" : failed ? "未覆盖原文件" : "进行中"}
|
||||
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未覆盖原文件") : "进行中"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
@@ -146,9 +380,15 @@ export function DocumentRewriteTimeline({
|
||||
</header>
|
||||
|
||||
<div className="space-y-3 px-5 py-4">
|
||||
{STEPS.map((step, index) => {
|
||||
{steps.map((step, index) => {
|
||||
const active = !isTerminal(item.stage) && index === current;
|
||||
const done = completed || index < current;
|
||||
const illustrationSkipped = step.key === "illustrations" && !item.generateImages;
|
||||
const illustrationFailed =
|
||||
step.key === "illustrations" &&
|
||||
item.generateImages &&
|
||||
completed &&
|
||||
item.generatedImageCount === 0;
|
||||
const done = !illustrationSkipped && !illustrationFailed && (completed || index < current);
|
||||
return (
|
||||
<div className="flex items-start gap-3" key={step.key}>
|
||||
<div
|
||||
@@ -173,7 +413,7 @@ export function DocumentRewriteTimeline({
|
||||
<div className="text-sm font-medium">{step.label}</div>
|
||||
{step.key === "snapshot" && item.originalCharCount > 0 && (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
已冻结 {item.originalCharCount.toLocaleString()} 字原文快照。
|
||||
已冻结 {item.originalCharCount.toLocaleString()} 字{isNewReportGeneration ? "来源报告" : "原文"}快照。
|
||||
</p>
|
||||
)}
|
||||
{step.key === "requirements" && (
|
||||
@@ -187,18 +427,52 @@ export function DocumentRewriteTimeline({
|
||||
</div>
|
||||
) : active ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
正在识别改写目标、保留约束与执行方式…
|
||||
{isNewReportGeneration
|
||||
? "正在确认新报告的结构、资料范围与生成要求…"
|
||||
: "正在识别改写目标、保留约束与执行方式…"}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
已根据改写要求确定执行计划。
|
||||
{isNewReportGeneration ? "已根据所选结构确定生成计划。" : "已根据改写要求确定执行计划。"}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{step.key === "writing" && item.writtenCharCount > 0 && (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
正在写入右侧沙箱,已生成 {item.writtenCharCount.toLocaleString()} 字。
|
||||
</p>
|
||||
{step.key === "writing" && (
|
||||
item.writtenCharCount > 0 ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
正在写入右侧沙箱,已{isNewReportGeneration ? "生成" : "改写"} {item.writtenCharCount.toLocaleString()} 字。
|
||||
</p>
|
||||
) : active ? (
|
||||
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
|
||||
{isNewReportGeneration
|
||||
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
|
||||
: "正在等待模型开始输出;右侧将在首个字符到达后显示草稿。"}
|
||||
</p>
|
||||
) : null
|
||||
)}
|
||||
{step.key === "illustrations" && (
|
||||
illustrationSkipped ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
本次未启用配图,保留原文已有图片与链接。
|
||||
</p>
|
||||
) : active ? (
|
||||
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
|
||||
{item.illustrationTarget
|
||||
? `正在为「${item.illustrationTarget}」生成配图,已完成 ${item.generatedImageCount}/${item.maxGeneratedImages} 张。`
|
||||
: `正在准备 ${item.maxGeneratedImages} 张文档配图…`}
|
||||
</p>
|
||||
) : item.generatedImageCount > 0 ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
已生成并保存 {item.generatedImageCount} 张本地配图。
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
未成功生成配图,文字改写结果仍已保留。
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{step.key === thinkingStep && (
|
||||
<RewriteThinkingTrace active={active} text={item.thinkingText} />
|
||||
)}
|
||||
{step.key === "validation" && item.warnings.length > 0 && (
|
||||
<p className="mt-0.5 text-xs text-amber-700 dark:text-amber-300">
|
||||
@@ -213,9 +487,10 @@ export function DocumentRewriteTimeline({
|
||||
|
||||
{item.summary && (
|
||||
<div className="border-t px-5 py-4 text-sm leading-6">
|
||||
<div className="mb-2 font-semibold">改写说明</div>
|
||||
<div className="mb-2 font-semibold">{isNewReportGeneration ? "生成说明" : "改写说明"}</div>
|
||||
<p>
|
||||
原文 {item.summary.originalCharCount.toLocaleString()} 字,改写后{" "}
|
||||
{isNewReportGeneration ? "来源报告" : "原文"} {item.summary.originalCharCount.toLocaleString()} 字,
|
||||
{isNewReportGeneration ? "新报告" : "改写后"}{" "}
|
||||
{item.summary.rewrittenCharCount.toLocaleString()} 字;调整了约{" "}
|
||||
{item.summary.changedBlockCount} 个内容块。
|
||||
</p>
|
||||
@@ -228,6 +503,11 @@ export function DocumentRewriteTimeline({
|
||||
本次优化:{item.summary.requestedOptimizations.join("、")}。
|
||||
</p>
|
||||
) : null}
|
||||
{item.summary.requestedImageCount ? (
|
||||
<p className="text-muted-foreground">
|
||||
文档配图:请求 {item.summary.requestedImageCount} 张,已保存 {item.summary.generatedImageCount ?? 0} 张本地图片。
|
||||
</p>
|
||||
) : null}
|
||||
{item.summary.headingChanges && (
|
||||
item.summary.headingChanges.reordered ||
|
||||
item.summary.headingChanges.added.length > 0 ||
|
||||
@@ -256,7 +536,25 @@ export function DocumentRewriteTimeline({
|
||||
查看前后对比
|
||||
</Button>
|
||||
)}
|
||||
{completed && activeVersionId && (
|
||||
{isNewReportGeneration && completed && item.source.kind === "deep_research_report" && (
|
||||
<Button
|
||||
className="mt-3 mr-2"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT, {
|
||||
detail: { sessionId: item.source.id },
|
||||
}),
|
||||
);
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<FilePenLineIcon className="mr-1.5 size-3.5" />
|
||||
改写新报告
|
||||
</Button>
|
||||
)}
|
||||
{completed && (activeVersionId || canUseLocalFallback) && (
|
||||
<Button
|
||||
className="mt-3"
|
||||
disabled={undoing}
|
||||
@@ -266,12 +564,19 @@ export function DocumentRewriteTimeline({
|
||||
variant="outline"
|
||||
>
|
||||
{undoing ? <Loader2Icon className="mr-1.5 size-3.5 animate-spin" /> : <Undo2Icon className="mr-1.5 size-3.5" />}
|
||||
{reapplying ? "重新应用此次改写" : "撤销此次改写"}
|
||||
{isNewReportGeneration
|
||||
? reapplying
|
||||
? "重新应用本次生成"
|
||||
: "撤销本次生成"
|
||||
: reapplying
|
||||
? "重新应用此次改写"
|
||||
: "撤销此次改写"}
|
||||
</Button>
|
||||
)}
|
||||
{item.revertedAt && (
|
||||
<p className="mt-2 text-xs text-emerald-700 dark:text-emerald-300">
|
||||
已恢复为改写前版本。{item.redoVersionId ? "可重新应用本次改写。" : ""}
|
||||
已恢复为{isNewReportGeneration ? "生成前" : "改写前"}版本。
|
||||
{item.redoVersionId ? `可重新应用本次${isNewReportGeneration ? "生成" : "改写"}。` : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { FileText, Loader2 } from "lucide-react";
|
||||
import { FileText, Loader2, MessagesSquare } from "lucide-react";
|
||||
import { useState, type ComponentProps } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
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 { Tooltip } from "../tooltip";
|
||||
@@ -11,29 +17,84 @@ export function DownloadWordButton({
|
||||
markdown,
|
||||
fileName,
|
||||
title,
|
||||
questionMarkdown,
|
||||
...props
|
||||
}: ComponentProps<typeof Button> & {
|
||||
markdown: string;
|
||||
fileName?: string;
|
||||
title?: string;
|
||||
/** When present, the button offers both answer-only and current Q&A export. */
|
||||
questionMarkdown?: string;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const handleDownload = async () => {
|
||||
const [busyScope, setBusyScope] = useState<"answer" | "qa" | null>(null);
|
||||
const handleDownload = async (scope: "answer" | "qa" = "answer") => {
|
||||
const source = markdown?.trim();
|
||||
if (!source) {
|
||||
toast.error("无可下载的 Markdown 内容");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
const question = questionMarkdown?.trim();
|
||||
setBusyScope(scope);
|
||||
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 文件已生成");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "生成 Word 失败");
|
||||
} 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 (
|
||||
<Tooltip content="下载为 Word">
|
||||
<Button
|
||||
@@ -41,14 +102,10 @@ export function DownloadWordButton({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={handleDownload}
|
||||
onClick={() => void handleDownload("answer")}
|
||||
{...props}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<FileText size={12} />
|
||||
)}
|
||||
{icon}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||
import type {
|
||||
AnchorHTMLAttributes,
|
||||
HTMLAttributes,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
MessageResponse,
|
||||
@@ -34,6 +39,12 @@ export type MarkdownContentProps = {
|
||||
className?: string;
|
||||
remarkPlugins?: MessageResponseProps["remarkPlugins"];
|
||||
components?: MessageResponseProps["components"];
|
||||
linkClassName?: (href: string, label: string) => string | undefined;
|
||||
onLinkClick?: (
|
||||
event: ReactMouseEvent<HTMLAnchorElement>,
|
||||
href: string,
|
||||
label: string,
|
||||
) => void;
|
||||
};
|
||||
|
||||
/** Renders markdown content. */
|
||||
@@ -43,6 +54,8 @@ export function MarkdownContent({
|
||||
className,
|
||||
remarkPlugins = streamdownPlugins.remarkPlugins,
|
||||
components: componentsFromProps,
|
||||
linkClassName,
|
||||
onLinkClick,
|
||||
}: MarkdownContentProps) {
|
||||
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
||||
const components = useMemo(() => {
|
||||
@@ -93,6 +106,7 @@ export function MarkdownContent({
|
||||
);
|
||||
}
|
||||
const finalHref = resolvedHref ?? props.href;
|
||||
const label = extractTextFromChildren(props.children);
|
||||
return (
|
||||
<a
|
||||
{...rest}
|
||||
@@ -100,6 +114,7 @@ export function MarkdownContent({
|
||||
className={cn(
|
||||
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
||||
className,
|
||||
linkClassName?.(finalHref || "", label),
|
||||
)}
|
||||
target={target ?? (external ? "_blank" : undefined)}
|
||||
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
|
||||
@@ -110,6 +125,7 @@ export function MarkdownContent({
|
||||
event.preventDefault();
|
||||
scrollToInPageAnchor(event.currentTarget, finalHref);
|
||||
}
|
||||
onLinkClick?.(event, finalHref || "", label);
|
||||
rest.onClick?.(event);
|
||||
}}
|
||||
/>
|
||||
@@ -117,7 +133,7 @@ export function MarkdownContent({
|
||||
},
|
||||
...componentsFromProps,
|
||||
};
|
||||
}, [anchorResolver, componentsFromProps]);
|
||||
}, [anchorResolver, componentsFromProps, linkClassName, onLinkClick]);
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
@@ -127,6 +143,7 @@ export function MarkdownContent({
|
||||
className={className}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
mermaidConfig={streamdownPlugins.mermaidConfig}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import {
|
||||
BookOpenTextIcon,
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronUp,
|
||||
BookOpenTextIcon,
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronsDownIcon,
|
||||
ChevronUp,
|
||||
FolderOpenIcon,
|
||||
GlobeIcon,
|
||||
LightbulbIcon,
|
||||
ListTodoIcon,
|
||||
Loader2Icon,
|
||||
LightbulbIcon,
|
||||
ListTodoIcon,
|
||||
Loader2Icon,
|
||||
MessageCircleQuestionMarkIcon,
|
||||
NotebookPenIcon,
|
||||
SearchIcon,
|
||||
SquareTerminalIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type AnchorHTMLAttributes } from "react";
|
||||
|
||||
import {
|
||||
ChainOfThought,
|
||||
@@ -31,61 +32,70 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
buildWriteFileVirtualUrl,
|
||||
hasCompleteArtifactExtension,
|
||||
normalizeStreamingArtifactPath,
|
||||
provisionalWriteFilePath,
|
||||
} from "@/core/artifacts/streaming-write-preview";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
buildWriteFileVirtualUrl,
|
||||
hasCompleteArtifactExtension,
|
||||
normalizeStreamingArtifactPath,
|
||||
provisionalWriteFilePath,
|
||||
} from "@/core/artifacts/streaming-write-preview";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
normalizeSearchResults,
|
||||
resolveSearchQuery,
|
||||
type SearchResultItem,
|
||||
} from "@/core/messages/search-results";
|
||||
import {
|
||||
extractReasoningContentFromMessage,
|
||||
findToolCallResult,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
extractReasoningContentFromMessage,
|
||||
findToolCallResult,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
findDisplayConfigForTool,
|
||||
isDisplaySamplePayload,
|
||||
normalizeDefaultDisplaySources,
|
||||
shouldUseDefaultCitationMode,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
applyInlineCitations,
|
||||
buildSkillDisplayMap,
|
||||
findDisplayConfigForTool,
|
||||
isDisplaySamplePayload,
|
||||
normalizeDefaultDisplaySources,
|
||||
shouldUseDefaultCitationMode,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
||||
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useArtifactsOptional } from "../artifacts";
|
||||
import { CitationLink } from "../citations/citation-link";
|
||||
import { FlipDisplay } from "../flip-display";
|
||||
import { Tooltip } from "../tooltip";
|
||||
import { SourceCard } from "../skill-display-sources";
|
||||
import {
|
||||
isWeKnoraSourcePreviewUrl,
|
||||
SourceCard,
|
||||
} from "../skill-display-sources";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { SingleRoundRagCitationDialog } from "./message-list-item";
|
||||
|
||||
export function MessageGroup({
|
||||
className,
|
||||
messages,
|
||||
isLoading = false,
|
||||
}: {
|
||||
className?: string;
|
||||
messages: Message[];
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
export function MessageGroup({
|
||||
className,
|
||||
messages,
|
||||
isLoading = false,
|
||||
displaySources = [],
|
||||
ragModeEnabled = false,
|
||||
}: {
|
||||
className?: string;
|
||||
messages: Message[];
|
||||
isLoading?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
ragModeEnabled?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [showAbove, setShowAbove] = useState(
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
@@ -93,13 +103,12 @@ export function MessageGroup({
|
||||
const [showLastThinking, setShowLastThinking] = useState(
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
const steps = useMemo(() => convertToSteps(messages), [messages]);
|
||||
const [settings] = useLocalSettings();
|
||||
const { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[settings.display.ragModeEnabled, skills],
|
||||
);
|
||||
const steps = useMemo(() => convertToSteps(messages), [messages]);
|
||||
const { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[ragModeEnabled, skills],
|
||||
);
|
||||
const lastToolCallStep = useMemo(() => {
|
||||
const filteredSteps = steps.filter((step) => step.type === "toolCall");
|
||||
return filteredSteps[filteredSteps.length - 1];
|
||||
@@ -121,11 +130,29 @@ export function MessageGroup({
|
||||
}
|
||||
}, [lastToolCallStep, steps]);
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
const [selectedCitation, setSelectedCitation] =
|
||||
useState<RuntimeDisplaySource | null>(null);
|
||||
const handleCitationClick = useCallback(
|
||||
(referenceId: string) => {
|
||||
const referenceIndex = Number(referenceId);
|
||||
if (!Number.isInteger(referenceIndex) || referenceIndex < 1) return;
|
||||
const source =
|
||||
displaySources.find((item) => item.index === referenceIndex) ??
|
||||
displaySources[referenceIndex - 1];
|
||||
if (source) setSelectedCitation(source);
|
||||
},
|
||||
[displaySources],
|
||||
);
|
||||
const reasoningId =
|
||||
lastReasoningStep?.id ??
|
||||
messages.find((message) => message.type === "ai")?.id;
|
||||
return (
|
||||
<ChainOfThought
|
||||
className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
|
||||
open={true}
|
||||
>
|
||||
<>
|
||||
<ChainOfThought
|
||||
className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
|
||||
data-reference-batch-id={reasoningId}
|
||||
open={true}
|
||||
>
|
||||
{aboveLastToolCallSteps.length > 0 && (
|
||||
<Button
|
||||
key="above"
|
||||
@@ -160,10 +187,12 @@ export function MessageGroup({
|
||||
<ChainOfThoughtStep
|
||||
key={step.id}
|
||||
label={
|
||||
<MarkdownContent
|
||||
<StepReasoningMarkdown
|
||||
content={step.reasoning ?? ""}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
onCitationClick={handleCitationClick}
|
||||
/>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
@@ -171,11 +200,11 @@ export function MessageGroup({
|
||||
<ToolCall
|
||||
key={step.id}
|
||||
{...step}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
ragModeEnabled={settings.display.ragModeEnabled}
|
||||
/>
|
||||
),
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{lastToolCallStep && (
|
||||
<FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}>
|
||||
@@ -183,10 +212,10 @@ export function MessageGroup({
|
||||
key={lastToolCallStep.id}
|
||||
{...lastToolCallStep}
|
||||
isLast={true}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
ragModeEnabled={settings.display.ragModeEnabled}
|
||||
/>
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
</FlipDisplay>
|
||||
)}
|
||||
</ChainOfThoughtContent>
|
||||
@@ -220,10 +249,12 @@ export function MessageGroup({
|
||||
<ChainOfThoughtStep
|
||||
key={lastReasoningStep.id}
|
||||
label={
|
||||
<MarkdownContent
|
||||
<StepReasoningMarkdown
|
||||
content={lastReasoningStep.reasoning ?? ""}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
onCitationClick={handleCitationClick}
|
||||
/>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
@@ -231,11 +262,64 @@ export function MessageGroup({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ChainOfThought>
|
||||
</ChainOfThought>
|
||||
<SingleRoundRagCitationDialog
|
||||
source={selectedCitation}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedCitation(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCall({
|
||||
function StepReasoningMarkdown({
|
||||
content,
|
||||
displaySources,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
onCitationClick,
|
||||
}: {
|
||||
content: string;
|
||||
displaySources: RuntimeDisplaySource[];
|
||||
isLoading: boolean;
|
||||
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onCitationClick: (referenceId: string) => void;
|
||||
}) {
|
||||
const markdown = useMemo(
|
||||
() =>
|
||||
applyInlineCitations(content, displaySources, { appendSummary: false }),
|
||||
[content, displaySources],
|
||||
);
|
||||
const components = useMemo(() => {
|
||||
if (displaySources.length === 0) return undefined;
|
||||
return {
|
||||
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (href?.startsWith("#reference-")) {
|
||||
return (
|
||||
<CitationLink
|
||||
href={href}
|
||||
{...props}
|
||||
onReferenceClick={onCitationClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <a href={href} {...props} />;
|
||||
},
|
||||
};
|
||||
}, [displaySources.length, onCitationClick]);
|
||||
|
||||
return (
|
||||
<MarkdownContent
|
||||
content={markdown}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCall({
|
||||
id,
|
||||
messageId,
|
||||
name,
|
||||
@@ -257,45 +341,51 @@ function ToolCall({
|
||||
ragModeEnabled?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const displayMatch =
|
||||
result !== undefined && isDisplaySamplePayload(result)
|
||||
? null
|
||||
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
|
||||
null);
|
||||
const displayMatch =
|
||||
result !== undefined && isDisplaySamplePayload(result)
|
||||
? null
|
||||
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
|
||||
null);
|
||||
const displayItems =
|
||||
displayMatch && result !== undefined
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: item.displayMode ?? displayMatch.display.mode,
|
||||
}))
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: item.displayMode ?? displayMatch.display.mode,
|
||||
}))
|
||||
: [];
|
||||
if (displayItems.length > 0) {
|
||||
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
|
||||
const wikiSearchLabel = name === "llmwiki_search"
|
||||
? wikiQuery
|
||||
? `Wiki 文档检索 "${wikiQuery}"`
|
||||
: "Wiki 文档检索"
|
||||
: null;
|
||||
if (displayItems.length > 0) {
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
label={
|
||||
displayMatch?.display.mode === "citation" || !displayMatch
|
||||
? "检索参考来源"
|
||||
: `获取 ${displayItems.length} 条结果`
|
||||
}
|
||||
label={
|
||||
wikiSearchLabel || (displayMatch?.display.mode === "citation" || !displayMatch
|
||||
? "检索参考来源"
|
||||
: `获取 ${displayItems.length} 条结果`)
|
||||
}
|
||||
icon={SearchIcon}
|
||||
>
|
||||
<DisplayResultsList results={displayItems} />
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
// Deep Research uses a virtual tool call for each durable writing phase.
|
||||
// Rendering it here keeps its lifecycle in the very same ChainOfThought
|
||||
// component as ordinary Q&A retrieval and sandbox tool calls.
|
||||
if (name === "deep_research_progress") {
|
||||
return <DeepResearchProgressStep id={id} args={args} />;
|
||||
}
|
||||
|
||||
const searchItems = normalizeSearchResults(result);
|
||||
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
||||
}
|
||||
|
||||
// Deep Research uses a virtual tool call for each durable writing phase.
|
||||
// Rendering it here keeps its lifecycle in the very same ChainOfThought
|
||||
// component as ordinary Q&A retrieval and sandbox tool calls.
|
||||
if (name === "deep_research_progress") {
|
||||
return <DeepResearchProgressStep id={id} args={args} />;
|
||||
}
|
||||
|
||||
const searchItems = normalizeSearchResults(result);
|
||||
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
||||
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
@@ -303,24 +393,24 @@ function ToolCall({
|
||||
mode: "citation" as const,
|
||||
}));
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label="检索参考来源" icon={SearchIcon}>
|
||||
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
|
||||
<DisplayResultsList results={defaultItems} />
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
if (name === "web_search" || searchItems.length > 0) {
|
||||
const query = resolveSearchQuery(args, result);
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||
if (query) {
|
||||
label =
|
||||
name === "web_search"
|
||||
? `知识库检索 "${query}"`
|
||||
: t.toolCalls.searchFor(query);
|
||||
} else if (name === "web_search") {
|
||||
label = "知识库检索";
|
||||
}
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
|
||||
if (name === "web_search" || searchItems.length > 0) {
|
||||
const query = resolveSearchQuery(args, result);
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||
if (query) {
|
||||
label =
|
||||
name === "web_search"
|
||||
? `知识库检索 "${query}"`
|
||||
: t.toolCalls.searchFor(query);
|
||||
} else if (name === "web_search") {
|
||||
label = "知识库检索";
|
||||
}
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
|
||||
{searchItems.length > 0 && (
|
||||
<WebSearchResultsList results={searchItems} />
|
||||
)}
|
||||
@@ -437,18 +527,18 @@ function ToolCall({
|
||||
if (!description) {
|
||||
description = t.toolCalls.writeFile;
|
||||
}
|
||||
return (
|
||||
<ArtifactWriteToolCall
|
||||
id={id}
|
||||
messageId={messageId}
|
||||
name={name}
|
||||
args={args}
|
||||
result={result}
|
||||
description={description}
|
||||
isLast={isLast}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<ArtifactWriteToolCall
|
||||
id={id}
|
||||
messageId={messageId}
|
||||
name={name}
|
||||
args={args}
|
||||
result={result}
|
||||
description={description}
|
||||
isLast={isLast}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
} else if (name === "bash") {
|
||||
const description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
@@ -509,129 +599,242 @@ function ToolCall({
|
||||
icon={WrenchIcon}
|
||||
></ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function DeepResearchProgressStep({
|
||||
id,
|
||||
args,
|
||||
}: {
|
||||
id?: string;
|
||||
args: Record<string, unknown>;
|
||||
}) {
|
||||
const label = typeof args.label === "string" && args.label.trim()
|
||||
? args.label
|
||||
: "正在撰写研究报告";
|
||||
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
|
||||
const active = args.status !== "complete";
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
status={active ? "active" : "complete"}
|
||||
icon={active
|
||||
? <Loader2Icon className="text-primary size-4 animate-spin" />
|
||||
: <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />}
|
||||
label={
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||
<span>{label}</span>
|
||||
<span className={active ? "text-primary text-xs" : "text-muted-foreground text-xs"}>
|
||||
{active ? "进行中" : "已完成"}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<div className="text-muted-foreground text-xs leading-5">
|
||||
{detail}
|
||||
</div>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactWriteToolCall({
|
||||
id,
|
||||
messageId,
|
||||
name,
|
||||
args,
|
||||
result,
|
||||
description,
|
||||
isLast,
|
||||
isLoading,
|
||||
}: {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
name: "write_file" | "str_replace";
|
||||
args: Record<string, unknown>;
|
||||
result?: string | Record<string, unknown>;
|
||||
description: string;
|
||||
isLast: boolean;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const artifacts = useArtifactsOptional();
|
||||
const openStreamedArtifact = artifacts?.openStreamedArtifact;
|
||||
const rawPath = typeof args.path === "string" ? args.path : "";
|
||||
const content = typeof args.content === "string" ? args.content : "";
|
||||
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
|
||||
const hasUsablePath =
|
||||
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
|
||||
const sourcePath = hasUsablePath
|
||||
? normalizedPath
|
||||
: name === "write_file" && content.trim()
|
||||
? provisionalWriteFilePath(content)
|
||||
: null;
|
||||
const previewKey = messageId && id ? `${messageId}:${id}` : null;
|
||||
const artifactPath =
|
||||
sourcePath && name === "write_file" && messageId && id
|
||||
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
|
||||
: sourcePath;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!openStreamedArtifact ||
|
||||
!artifactPath ||
|
||||
!previewKey ||
|
||||
!isLoading ||
|
||||
!isLast ||
|
||||
result !== undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
openStreamedArtifact(artifactPath, previewKey);
|
||||
}, [
|
||||
artifactPath,
|
||||
isLast,
|
||||
isLoading,
|
||||
openStreamedArtifact,
|
||||
previewKey,
|
||||
result,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
className={cn(artifactPath && "cursor-pointer")}
|
||||
label={description}
|
||||
icon={NotebookPenIcon}
|
||||
onClick={() => {
|
||||
if (!artifactPath || !artifacts) return;
|
||||
artifacts.select(artifactPath);
|
||||
artifacts.setOpen(true);
|
||||
}}
|
||||
>
|
||||
{(normalizedPath || sourcePath) && (
|
||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||
{normalizedPath || sourcePath}
|
||||
</ChainOfThoughtSearchResult>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
interface GenericCoTStep<T extends string = string> {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
}
|
||||
}
|
||||
|
||||
function DeepResearchProgressStep({
|
||||
id,
|
||||
args,
|
||||
}: {
|
||||
id?: string;
|
||||
args: Record<string, unknown>;
|
||||
}) {
|
||||
const label = typeof args.label === "string" && args.label.trim()
|
||||
? args.label
|
||||
: "正在撰写研究报告";
|
||||
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
|
||||
const thinking = typeof args.thinking === "string" ? args.thinking.trim() : "";
|
||||
const active = args.status !== "complete";
|
||||
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 (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
status={active ? "active" : "complete"}
|
||||
icon={active
|
||||
? <Loader2Icon className="text-primary size-4 animate-spin" />
|
||||
: <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />}
|
||||
label={
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||
<span>{label}</span>
|
||||
<span className={active ? "text-primary text-xs" : "text-muted-foreground text-xs"}>
|
||||
{active ? "进行中" : "已完成"}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<div className="text-muted-foreground text-xs leading-5">
|
||||
{detail}
|
||||
</div>
|
||||
)}
|
||||
{thinking && (
|
||||
<Collapsible className="mt-2" onOpenChange={setThinkingOpen} open={thinkingOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
aria-label={thinkingOpen ? "收起模型思考" : "展开模型思考"}
|
||||
className="h-7 gap-1.5 px-2 text-xs"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
|
||||
<span>模型思考</span>
|
||||
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
|
||||
<ChevronDownIcon className={cn("size-3.5 transition-transform", thinkingOpen && "rotate-180")} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
|
||||
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
|
||||
</p>
|
||||
<div className="relative mt-1">
|
||||
<pre
|
||||
className="bg-muted/45 text-muted-foreground max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words"
|
||||
onScroll={pauseAutoScrollOnManualScroll}
|
||||
ref={thinkingContentRef}
|
||||
>
|
||||
{thinking}
|
||||
</pre>
|
||||
{active && autoScrollPaused && (
|
||||
<Button
|
||||
aria-label="恢复思考内容自动下滑"
|
||||
className="absolute top-1/2 left-1/2 h-9 -translate-x-1/2 -translate-y-1/2 gap-1.5 bg-background/95 px-3 text-xs shadow-md backdrop-blur-sm hover:bg-background"
|
||||
onClick={resumeAutoScroll}
|
||||
size="sm"
|
||||
title="恢复自动下滑"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ChevronsDownIcon className="size-3.5" />
|
||||
自动下滑
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
/** 文件卡片只显示文件名:宿主泄漏路径(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({
|
||||
id,
|
||||
messageId,
|
||||
name,
|
||||
args,
|
||||
result,
|
||||
description,
|
||||
isLast,
|
||||
isLoading,
|
||||
}: {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
name: "write_file" | "str_replace";
|
||||
args: Record<string, unknown>;
|
||||
result?: string | Record<string, unknown>;
|
||||
description: string;
|
||||
isLast: boolean;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const artifacts = useArtifactsOptional();
|
||||
const openStreamedArtifact = artifacts?.openStreamedArtifact;
|
||||
const rawPath = typeof args.path === "string" ? args.path : "";
|
||||
const content = typeof args.content === "string" ? args.content : "";
|
||||
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
|
||||
const hasUsablePath =
|
||||
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
|
||||
const sourcePath = hasUsablePath
|
||||
? normalizedPath
|
||||
: name === "write_file" && content.trim()
|
||||
? provisionalWriteFilePath(content)
|
||||
: null;
|
||||
const previewKey = messageId && id ? `${messageId}:${id}` : null;
|
||||
// Never select a bare provisional name like `未命名文件.md` — the artifacts
|
||||
// API only serves `/mnt/user-data/...` paths and would 400. Wait until the
|
||||
// tool call has ids so we can open a write-file: memory preview instead.
|
||||
const artifactPath =
|
||||
sourcePath && name === "write_file" && messageId && id
|
||||
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!openStreamedArtifact ||
|
||||
!artifactPath ||
|
||||
!previewKey ||
|
||||
!isLoading ||
|
||||
!isLast ||
|
||||
result !== undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
openStreamedArtifact(artifactPath, previewKey);
|
||||
}, [
|
||||
artifactPath,
|
||||
isLast,
|
||||
isLoading,
|
||||
openStreamedArtifact,
|
||||
previewKey,
|
||||
result,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
className={cn(artifactPath && "cursor-pointer")}
|
||||
label={description}
|
||||
icon={NotebookPenIcon}
|
||||
onClick={() => {
|
||||
if (!artifactPath || !artifacts) return;
|
||||
artifacts.select(artifactPath);
|
||||
artifacts.setOpen(true);
|
||||
}}
|
||||
>
|
||||
{(normalizedPath || sourcePath) && (
|
||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
|
||||
</ChainOfThoughtSearchResult>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
interface GenericCoTStep<T extends string = string> {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
type: T;
|
||||
}
|
||||
|
||||
@@ -734,7 +937,7 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
<span>{results.length} 条真实返回结果</span>
|
||||
<span>{results.length} 条真实返回结果</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 flex flex-col gap-2">
|
||||
{results.map((item) => (
|
||||
@@ -745,19 +948,43 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const content = item.snippet ?? item.content;
|
||||
const date = item.published_date ?? item.publishedDate ?? item.date;
|
||||
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const content = item.snippet ?? item.content;
|
||||
const date = item.published_date ?? item.publishedDate ?? item.date;
|
||||
let sourceLabel: string | undefined = item.source;
|
||||
if (item.url) {
|
||||
try {
|
||||
sourceLabel = new URL(item.url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
sourceLabel = sourceLabel ?? item.url;
|
||||
}
|
||||
}
|
||||
return (
|
||||
} catch {
|
||||
sourceLabel = sourceLabel ?? item.url;
|
||||
}
|
||||
}
|
||||
|
||||
// MCP retrieval tools without a configured display adapter fall back to this
|
||||
// generic result card. Preserve the normal web-search UI, but route our
|
||||
// credential-free WeKnora pointer through SourceCard so its dialog exposes a
|
||||
// deliberate “查看原文” action rather than rendering the internal route text.
|
||||
if (isWeKnoraSourcePreviewUrl(item.url)) {
|
||||
const weknoraSource: RuntimeDisplaySource = {
|
||||
id: item.id ?? item.uuid ?? item.docId,
|
||||
title: item.title ?? "检索结果",
|
||||
type: item.source ?? "document",
|
||||
snippet: item.snippet ?? item.content ?? "",
|
||||
content,
|
||||
source: item.source,
|
||||
author: item.author,
|
||||
time: date,
|
||||
url: item.url,
|
||||
index: 0,
|
||||
skillName: "weknora-knowledge-search",
|
||||
mode: "list",
|
||||
raw: { ...item },
|
||||
};
|
||||
return <SourceCard source={weknoraSource} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Loader2Icon,
|
||||
PencilIcon,
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
memo,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
useState,
|
||||
type AnchorHTMLAttributes,
|
||||
type ImgHTMLAttributes,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
|
||||
@@ -157,8 +159,9 @@ export function MessageListItem({
|
||||
className,
|
||||
fitRowToContent = false,
|
||||
anchorId,
|
||||
message,
|
||||
isLoading,
|
||||
message,
|
||||
questionMarkdown,
|
||||
isLoading,
|
||||
threadId,
|
||||
tokenUsageEnabled = false,
|
||||
displaySources = [],
|
||||
@@ -175,7 +178,9 @@ export function MessageListItem({
|
||||
* message. `scroll-mt` keeps the target clear of the overlay header.
|
||||
*/
|
||||
anchorId?: string;
|
||||
message: Message;
|
||||
message: Message;
|
||||
/** Nearest preceding user question, used by the current-Q&A Word export. */
|
||||
questionMarkdown?: string;
|
||||
isLoading?: boolean;
|
||||
threadId: string;
|
||||
tokenUsageEnabled?: boolean;
|
||||
@@ -325,10 +330,12 @@ export function MessageListItem({
|
||||
<div className="flex gap-1">
|
||||
<CopyButton clipboardData={messageMarkdown} />
|
||||
{messageMarkdown && (
|
||||
<DownloadWordButton
|
||||
markdown={messageMarkdown}
|
||||
fileName="message"
|
||||
/>
|
||||
<DownloadWordButton
|
||||
markdown={messageMarkdown}
|
||||
fileName="message"
|
||||
title="问答回答"
|
||||
questionMarkdown={questionMarkdown}
|
||||
/>
|
||||
)}
|
||||
{canSaveAssistantEdit && (
|
||||
<Tooltip content="编辑消息">
|
||||
@@ -693,6 +700,10 @@ function MessageContent_({
|
||||
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
|
||||
return stripIncompleteCitationMarker(withCitations);
|
||||
}, [rawContent, isHuman, displaySources]);
|
||||
const markdownRehypePlugins = useMemo(
|
||||
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
|
||||
[rehypePlugins],
|
||||
);
|
||||
const parsedHumanContent = useMemo(
|
||||
() =>
|
||||
isHuman
|
||||
@@ -780,7 +791,7 @@ function MessageContent_({
|
||||
content={contentToDisplay}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
|
||||
rehypePlugins={markdownRehypePlugins}
|
||||
components={components}
|
||||
/>
|
||||
) : (
|
||||
@@ -789,7 +800,7 @@ function MessageContent_({
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
renderSkillDisplayBlocks={false}
|
||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
|
||||
rehypePlugins={markdownRehypePlugins}
|
||||
components={components}
|
||||
/>
|
||||
)}
|
||||
@@ -838,7 +849,7 @@ function FrontendRagSourcesPanel({
|
||||
const [open, setOpen] = useState(false);
|
||||
if (sources.length === 0) return null;
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full">
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -891,7 +902,7 @@ function FrontendRagSourcesPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function SingleRoundRagCitationDialog({
|
||||
export function SingleRoundRagCitationDialog({
|
||||
source,
|
||||
onOpenChange,
|
||||
}: {
|
||||
@@ -947,11 +958,14 @@ function MultiRoundRagContent({
|
||||
}) {
|
||||
const [open, setOpen] = useState(!trace.isComplete);
|
||||
useEffect(() => {
|
||||
if (!trace.isComplete) setOpen(true);
|
||||
// Keep the active turn expanded so each new retrieval is visible as it
|
||||
// arrives, then match GeneralQA by collapsing the completed trace. The
|
||||
// header still carries the total round count for quick scanning.
|
||||
setOpen(!trace.isComplete);
|
||||
}, [trace.isComplete]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
|
||||
<section className="border-border/70 overflow-hidden rounded-xl border bg-muted/20">
|
||||
<button
|
||||
type="button"
|
||||
@@ -965,10 +979,10 @@ function MultiRoundRagContent({
|
||||
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium">多轮检索过程</div>
|
||||
<div className="text-sm font-medium">多轮检索思考过程</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{trace.isComplete
|
||||
? `已完成 ${trace.rounds.length} 轮检索`
|
||||
? `共 ${trace.rounds.length} 轮推理与检索,可展开查看细节`
|
||||
: '正在分析问题并检索资料'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -980,14 +994,26 @@ function MultiRoundRagContent({
|
||||
{open && (
|
||||
<div className="border-border/60 space-y-3 border-t px-4 py-3">
|
||||
{trace.rounds.map((round, index) => (
|
||||
<MultiRoundRagRoundCard
|
||||
<div
|
||||
key={round.id}
|
||||
index={index + 1}
|
||||
round={round}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
/>
|
||||
className={cn(
|
||||
'relative pl-5',
|
||||
index < trace.rounds.length - 1 && 'pb-1',
|
||||
)}
|
||||
>
|
||||
{index < trace.rounds.length - 1 && (
|
||||
<span className="bg-border absolute bottom-0 left-[7px] top-8 w-px" />
|
||||
)}
|
||||
<span className="border-primary bg-background absolute left-0 top-4 size-3.5 rounded-full border-2" />
|
||||
<MultiRoundRagRoundCard
|
||||
index={index + 1}
|
||||
round={round}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -1017,85 +1043,244 @@ function MultiRoundRagContent({
|
||||
function MultiRoundRagRoundCard({
|
||||
index,
|
||||
round,
|
||||
displaySources,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
components,
|
||||
}: {
|
||||
index: number;
|
||||
round: MultiRoundRagRound;
|
||||
displaySources: RuntimeDisplaySource[];
|
||||
isLoading: boolean;
|
||||
rehypePlugins: MarkdownContentProps['rehypePlugins'];
|
||||
components: MarkdownContentProps['components'];
|
||||
}) {
|
||||
const [open, setOpen] = useState(!round.query || round.isSearching);
|
||||
useEffect(() => {
|
||||
// A newly created round stays expanded to expose its thought and loading
|
||||
// query. Once that query has returned, retain only the round header until
|
||||
// the user asks to inspect the completed detail again.
|
||||
setOpen(!round.query || round.isSearching);
|
||||
}, [round.id, round.isSearching, round.query]);
|
||||
const thoughtContent = useMemo(
|
||||
() =>
|
||||
applyInlineCitations(round.thought, displaySources, {
|
||||
appendSummary: false,
|
||||
}),
|
||||
[displaySources, round.thought],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-border/60 rounded-lg border bg-background/70 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs">
|
||||
{index}
|
||||
</span>
|
||||
<span>{round.title}</span>
|
||||
{round.isSearching && <Loader2Icon className="text-primary size-3.5 animate-spin" />}
|
||||
</div>
|
||||
{round.thought && (
|
||||
<section className="border-border/50 bg-muted/30 mt-3 rounded-md border p-3">
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">思考内容</div>
|
||||
<MarkdownContent
|
||||
content={round.thought}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
className="text-muted-foreground text-sm"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{round.body && (
|
||||
<section className="border-border/50 mt-3 rounded-md border p-3">
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
|
||||
<MarkdownContent
|
||||
content={round.body}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
className="text-sm"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{round.query && (
|
||||
<div className="border-primary/20 bg-primary/5 text-primary mt-2 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs">
|
||||
<SearchIcon className="size-3 shrink-0" />
|
||||
<span className="truncate">{round.query}</span>
|
||||
</div>
|
||||
)}
|
||||
{round.retrievals.length > 0 && (
|
||||
<section className="border-border/50 mt-3 rounded-md border p-3">
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">
|
||||
检索内容 · {round.retrievals.length} 条
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{round.retrievals.map((item, resultIndex) => (
|
||||
<details
|
||||
key={`${item.id ?? item.title}-${resultIndex}`}
|
||||
className="border-border/60 rounded-md border px-3 py-2"
|
||||
open={resultIndex === 0}
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<article className="border-border/60 bg-background/80 overflow-hidden rounded-xl border shadow-sm">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2.5 text-left text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
|
||||
>
|
||||
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
|
||||
{index}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{round.title}</span>
|
||||
{round.isSearching ? (
|
||||
<span className="bg-primary/10 text-primary inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
|
||||
<Loader2Icon className="size-3 motion-reduce:animate-none animate-spin" />
|
||||
检索中
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground shrink-0 text-[11px]">已完成</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
|
||||
!open && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border/50 border-t px-3 pb-3">
|
||||
<div className="pt-3">
|
||||
{round.thought && (
|
||||
<MultiRoundRagDetailSection
|
||||
title="思考内容"
|
||||
icon={<SparklesIcon className="text-primary size-4" />}
|
||||
>
|
||||
<summary className="cursor-pointer list-none text-sm font-medium">
|
||||
<span className="mr-2 text-xs text-muted-foreground">{resultIndex + 1}.</span>
|
||||
{item.title}
|
||||
</summary>
|
||||
<div className="text-muted-foreground mt-2 text-sm leading-6 whitespace-pre-wrap">
|
||||
{item.content || '暂无资料摘要'}
|
||||
</div>
|
||||
{(item.source || item.time) && (
|
||||
<div className="text-muted-foreground mt-2 text-xs">
|
||||
{[item.source, item.time].filter(Boolean).join(' · ')}
|
||||
<MarkdownContent
|
||||
content={thoughtContent}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
className="text-muted-foreground text-sm"
|
||||
/>
|
||||
</MultiRoundRagDetailSection>
|
||||
)}
|
||||
{round.body && (
|
||||
<section className="border-border/50 mt-3 rounded-lg border p-3">
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
|
||||
<MarkdownContent
|
||||
content={round.body}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
className="text-sm"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{(round.query || round.isSearching) && (
|
||||
<div
|
||||
className={cn(
|
||||
'border-primary/20 bg-primary/5 text-primary mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1.5 text-xs',
|
||||
round.isSearching && 'animate-pulse motion-reduce:animate-none',
|
||||
)}
|
||||
aria-live={round.isSearching ? 'polite' : undefined}
|
||||
>
|
||||
{round.isSearching ? (
|
||||
<Loader2Icon className="size-3.5 shrink-0 motion-reduce:animate-none animate-spin" />
|
||||
) : (
|
||||
<SearchIcon className="size-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="shrink-0 text-muted-foreground">检索词</span>
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
{round.query || '正在生成检索词…'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(round.query || round.isSearching || round.retrievals.length > 0) && (
|
||||
<MultiRoundRagDetailSection
|
||||
defaultOpen={!round.isSearching}
|
||||
title="检索内容"
|
||||
icon={<FileIcon className="text-primary size-3.5" />}
|
||||
suffix={<span className="text-muted-foreground text-xs">{round.retrievals.length} 条</span>}
|
||||
>
|
||||
{round.isSearching ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 py-1 text-sm">
|
||||
<Loader2Icon className="text-primary size-4 motion-reduce:animate-none animate-spin" />
|
||||
正在召回相关内容…
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{round.retrievals.length > 0 ? (
|
||||
round.retrievals.map((item, resultIndex) => (
|
||||
<MultiRoundRagResultCard
|
||||
key={`${item.id ?? item.title}-${resultIndex}`}
|
||||
item={item}
|
||||
index={resultIndex + 1}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-muted-foreground py-1 text-sm">
|
||||
本轮未召回相关资料。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
))}
|
||||
</MultiRoundRagDetailSection>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</article>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiRoundRagDetailSection({
|
||||
title,
|
||||
icon,
|
||||
suffix,
|
||||
defaultOpen = true,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
suffix?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
useEffect(() => setOpen(defaultOpen), [defaultOpen]);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<section className="border-border/50 bg-muted/20 mt-3 overflow-hidden rounded-lg border">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
|
||||
>
|
||||
{icon}
|
||||
<span className="min-w-0 flex-1 text-sm font-medium">{title}</span>
|
||||
{suffix}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
|
||||
!open && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</section>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiRoundRagResultCard({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: MultiRoundRagRound['retrievals'][number];
|
||||
index: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(index === 1);
|
||||
const sourceMeta = [item.source, item.time].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<div className="border-border/60 hover:border-primary/30 bg-background rounded-lg border transition-colors">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
|
||||
>
|
||||
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-semibold">
|
||||
{index}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={item.title}>
|
||||
{item.title}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
|
||||
!open && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
|
||||
<p className="text-muted-foreground text-sm leading-6 whitespace-pre-wrap">
|
||||
{item.content || '暂无资料摘要'}
|
||||
</p>
|
||||
{(sourceMeta || item.url) && (
|
||||
<div className="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
{sourceMeta && <span>{sourceMeta}</span>}
|
||||
{item.url && (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
查看原文
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,11 @@ import {
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
|
||||
import { useDocumentRewritePresentation } from "@/core/document-rewrite/context";
|
||||
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
|
||||
import {
|
||||
DOCUMENT_REWRITE_STARTED_EVENT,
|
||||
useDocumentRewritePresentation,
|
||||
} from "@/core/document-rewrite/context";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
@@ -25,11 +28,12 @@ import {
|
||||
hasToolCalls,
|
||||
isHiddenFromUIMessage,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
@@ -46,8 +50,8 @@ import { getQuestionAnchorId } from "../question-navigator";
|
||||
import { StreamingIndicator } from "../streaming-indicator";
|
||||
|
||||
import { ReferenceBatchesContext, ThreadContext } from "./context";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
|
||||
import { MessageGroup } from "./message-group";
|
||||
import { MessageListItem } from "./message-list-item";
|
||||
import {
|
||||
@@ -89,6 +93,22 @@ function ScrollToBottomOnUserMessage({
|
||||
}
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDocumentRewriteStarted = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ threadId?: string }>).detail;
|
||||
if (detail?.threadId !== threadId) return;
|
||||
// The presentation card is inserted by a context update in the same
|
||||
// interaction. Two frames let React commit it before the smooth scroll.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
void scrollToBottom("smooth");
|
||||
});
|
||||
});
|
||||
};
|
||||
window.addEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
|
||||
return () => window.removeEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
|
||||
}, [scrollToBottom, threadId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -112,10 +132,14 @@ export function MessageList({
|
||||
onSetupWritingApprove,
|
||||
onSetupWritingReject,
|
||||
onSetupWritingStart,
|
||||
researchApprovalSlot,
|
||||
onOpenArtifact,
|
||||
onDownloadArtifact,
|
||||
}: {
|
||||
onSetupReportStructureApprove,
|
||||
onSetupReportStructureReject,
|
||||
researchApprovalSlot,
|
||||
taskReportImportSlot,
|
||||
onOpenArtifact,
|
||||
onDownloadArtifact,
|
||||
afterDocumentRewriteItems,
|
||||
}: {
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
fitMessageRowsToContent?: boolean;
|
||||
@@ -137,24 +161,34 @@ export function MessageList({
|
||||
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
|
||||
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
onSetupReportStructureApprove?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
|
||||
onSetupReportStructureReject?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
|
||||
/**
|
||||
* 深度研究的交互卡插槽(报告配置、计划确认、失败重试):workbench 向
|
||||
* displayThread 注入带 `additional_kwargs.setup_research_approval` 的虚拟 ai
|
||||
* 消息。撰写步骤和完成摘要不走此插槽,而是分别复用原生 tool-call 步骤条和
|
||||
* assistant 消息行;不传该 slot 时普通对话/AI 写作零行为变化。
|
||||
* 深度研究的交互卡插槽(报告配置、计划确认、失败重试):workbench 向
|
||||
* displayThread 注入带 `additional_kwargs.setup_research_approval` 的虚拟 ai
|
||||
* 消息。撰写步骤和完成摘要不走此插槽,而是分别复用原生 tool-call 步骤条和
|
||||
* assistant 消息行;不传该 slot 时普通对话/AI 写作零行为变化。
|
||||
*/
|
||||
researchApprovalSlot?: (message: Message) => React.ReactNode;
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
/** Host override for downloading virtual/session-owned files in a present-files card. */
|
||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||
researchApprovalSlot?: (message: Message) => React.ReactNode;
|
||||
/**
|
||||
* agentfx「入库」协助卡插槽:任务工作区宿主(AgentChatPage taskBind)向 displayThread
|
||||
* 注入带 `additional_kwargs.task_report_import_approval` 的虚拟 ai 消息,由本插槽渲染
|
||||
* 「是否入库」卡片。不传该 slot 时(普通对话等)该分组不渲染,零行为变化。
|
||||
*/
|
||||
taskReportImportSlot?: (message: Message) => React.ReactNode;
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
/** Host override for downloading virtual/session-owned files in a present-files card. */
|
||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||
/** Optional host content placed immediately after document-rewrite timelines. */
|
||||
afterDocumentRewriteItems?: React.ReactNode;
|
||||
}) {
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const rehypePlugins = useMemo(
|
||||
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
||||
[baseRehypePlugins, extraRehypePlugins],
|
||||
);
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const documentRewritePresentation = useDocumentRewritePresentation();
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const documentRewritePresentation = useDocumentRewritePresentation();
|
||||
const { t } = useI18n();
|
||||
const threadContext = useContext(ThreadContext);
|
||||
// Read-only contexts (public share, mock previews) must not offer deletion.
|
||||
@@ -194,7 +228,7 @@ export function MessageList({
|
||||
: ({ ...message, content: override } as Message);
|
||||
});
|
||||
}, [messageContentOverrides, thread.messages]);
|
||||
const messages = useMemo(
|
||||
const messages = useMemo(
|
||||
() => {
|
||||
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
|
||||
return hiddenMessageIds.size === 0 && externalHidden.size === 0
|
||||
@@ -205,9 +239,9 @@ export function MessageList({
|
||||
(!hiddenMessageIds.has(m.id) && !externalHidden.has(m.id)),
|
||||
);
|
||||
},
|
||||
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
||||
);
|
||||
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
|
||||
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
||||
);
|
||||
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
|
||||
|
||||
const askTimes = useChatAskTimes();
|
||||
|
||||
@@ -252,9 +286,31 @@ export function MessageList({
|
||||
return map;
|
||||
}, [messages, askTimes]);
|
||||
|
||||
const [settings] = useLocalSettings();
|
||||
const ragModeEnabled =
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
// 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 { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
const knowledgeReferencesEnabled =
|
||||
threadContext?.knowledgeReferencesEnabled === true;
|
||||
const ragModeEnabled =
|
||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
knowledgeReferencesEnabled;
|
||||
const hasSingleRoundRag = useMemo(
|
||||
() =>
|
||||
messages.some(
|
||||
@@ -322,6 +378,32 @@ export function MessageList({
|
||||
return map;
|
||||
}, [thread.messages]);
|
||||
|
||||
const resolveDisplaySources = useCallback(
|
||||
(msg: Message | undefined): RuntimeDisplaySource[] => {
|
||||
if (!referencesEnabled || !msg || msg.type !== "ai") return [];
|
||||
const batchSources =
|
||||
referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
|
||||
?.sources ?? [];
|
||||
const frontendRagSources = msg.id
|
||||
? frontendRagSourcesByAiId.get(msg.id) ?? []
|
||||
: [];
|
||||
if (batchSources.length > 0) return batchSources;
|
||||
if (frontendRagSources.length > 0) return frontendRagSources;
|
||||
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
|
||||
beforeMessageId: msg.id,
|
||||
includeBeforeMessage: true,
|
||||
});
|
||||
},
|
||||
[
|
||||
frontendRagSourcesByAiId,
|
||||
messages,
|
||||
referenceBatches,
|
||||
referencesEnabled,
|
||||
skillDisplayMap,
|
||||
],
|
||||
);
|
||||
|
||||
// Track which clarification messages are still awaiting an answer.
|
||||
// Pending = clarification tool messages that appear after the last human message
|
||||
// (no user reply has been sent for them yet).
|
||||
@@ -438,44 +520,35 @@ export function MessageList({
|
||||
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
|
||||
"mx-auto w-full max-w-(--container-width-lg)",
|
||||
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
||||
{groupMessages(messages, (group) => {
|
||||
{(() => {
|
||||
const rewriteItemsAboveFile = documentRewriteItems.filter(
|
||||
(item) => item.operation !== "generate",
|
||||
);
|
||||
const rewriteItemsBelowFile = documentRewriteItems.filter(
|
||||
(item) => item.operation === "generate",
|
||||
);
|
||||
let didRenderRewriteAboveFile = false;
|
||||
const grouped = groupMessages(messages, (group) => {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return group.messages.map((msg) => {
|
||||
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
|
||||
// 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。
|
||||
const messageIsLoading =
|
||||
thread.isLoading ||
|
||||
msg.additional_kwargs?.deep_research_summary_streaming === true ||
|
||||
msg.additional_kwargs?.deep_research_follow_up_streaming === true;
|
||||
const isAssistantMessage =
|
||||
return group.messages.map((msg) => {
|
||||
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
|
||||
// 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。
|
||||
const messageIsLoading =
|
||||
thread.isLoading ||
|
||||
msg.additional_kwargs?.deep_research_summary_streaming === true ||
|
||||
msg.additional_kwargs?.deep_research_follow_up_streaming === true;
|
||||
const isAssistantMessage =
|
||||
referencesEnabled &&
|
||||
group.type === "assistant" &&
|
||||
msg.type === "ai";
|
||||
const batchSources = isAssistantMessage
|
||||
? referenceBatches.find(
|
||||
(batch) => batch.assistant_message_id === msg.id,
|
||||
)?.sources ?? []
|
||||
: [];
|
||||
// 单轮/多轮 RAG 的参考资料来自隐藏的 tool 消息,优先于通用扫描。
|
||||
const frontendRagSources =
|
||||
isAssistantMessage && msg.id
|
||||
? frontendRagSourcesByAiId.get(msg.id) ?? []
|
||||
: [];
|
||||
const displaySources = isAssistantMessage
|
||||
? batchSources.length > 0
|
||||
? batchSources
|
||||
: frontendRagSources.length > 0
|
||||
? frontendRagSources
|
||||
: collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
|
||||
beforeMessageId: msg.id,
|
||||
includeBeforeMessage: true,
|
||||
})
|
||||
? resolveDisplaySources(msg)
|
||||
: [];
|
||||
return (
|
||||
<MessageListItem
|
||||
@@ -485,9 +558,12 @@ export function MessageList({
|
||||
msg.type === "human"
|
||||
? getQuestionAnchorId(msg, messages.indexOf(msg))
|
||||
: undefined
|
||||
}
|
||||
message={msg}
|
||||
isLoading={messageIsLoading}
|
||||
}
|
||||
message={msg}
|
||||
questionMarkdown={
|
||||
msg.id ? questionMarkdownByAssistantId.get(msg.id) : undefined
|
||||
}
|
||||
isLoading={messageIsLoading}
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
@@ -582,15 +658,40 @@ export function MessageList({
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:setup-report-structure-approval") {
|
||||
const message = group.messages[0];
|
||||
return message ? (
|
||||
<div key={group.id} className="w-full">
|
||||
<SetupReportStructureApprovalCard
|
||||
disabled={thread.isLoading}
|
||||
message={message}
|
||||
onApprove={onSetupReportStructureApprove}
|
||||
onReject={onSetupReportStructureReject}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={thread.isLoading}
|
||||
messages={group.messages}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:research-approval") {
|
||||
// 深度研究交互卡(配置/计划确认/失败重试):宿主不传渲染函数时该
|
||||
// 分组不渲染。撰写步骤和完成摘要会经过普通的 processing/assistant 分组。
|
||||
// 深度研究交互卡(配置/计划确认/失败重试):宿主不传渲染函数时该
|
||||
// 分组不渲染。撰写步骤和完成摘要会经过普通的 processing/assistant 分组。
|
||||
const message = group.messages[0];
|
||||
return researchApprovalSlot && message ? (
|
||||
<div key={group.id} className="w-full pb-2">
|
||||
{researchApprovalSlot(message)}
|
||||
</div>
|
||||
) : 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:present-files") {
|
||||
const files: string[] = [];
|
||||
for (const message of group.messages) {
|
||||
@@ -599,8 +700,15 @@ export function MessageList({
|
||||
files.push(...presentFiles);
|
||||
}
|
||||
}
|
||||
const rewriteAbove = !didRenderRewriteAboveFile
|
||||
? rewriteItemsAboveFile
|
||||
: [];
|
||||
didRenderRewriteAboveFile = true;
|
||||
return (
|
||||
<div className="w-full" key={group.id}>
|
||||
{rewriteAbove.map((item) => (
|
||||
<DocumentRewriteTimeline key={`rewrite-above-${item.id}`} item={item} />
|
||||
))}
|
||||
{group.messages[0] && hasContent(group.messages[0]) && (
|
||||
<MarkdownContent
|
||||
content={extractContentFromMessage(group.messages[0])}
|
||||
@@ -610,11 +718,11 @@ export function MessageList({
|
||||
/>
|
||||
)}
|
||||
<ArtifactFileList
|
||||
files={files}
|
||||
threadId={threadId}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
onDownloadArtifact={onDownloadArtifact}
|
||||
/>
|
||||
files={files}
|
||||
threadId={threadId}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
onDownloadArtifact={onDownloadArtifact}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={thread.isLoading}
|
||||
@@ -678,11 +786,13 @@ export function MessageList({
|
||||
)) {
|
||||
if (hasReasoning(message)) {
|
||||
results.push(
|
||||
<MessageGroup
|
||||
key={"thinking-group-" + message.id}
|
||||
messages={[message]}
|
||||
isLoading={thread.isLoading}
|
||||
/>,
|
||||
<MessageGroup
|
||||
key={"thinking-group-" + message.id}
|
||||
messages={[message]}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(message)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
results.push(
|
||||
@@ -725,12 +835,17 @@ export function MessageList({
|
||||
message.type === "ai" &&
|
||||
(hasToolCalls(message) ? true : !hasContent(message)),
|
||||
);
|
||||
const processingAi = [...group.messages]
|
||||
.reverse()
|
||||
.find((message) => message.type === "ai");
|
||||
return (
|
||||
<div key={"group-" + group.id} className="w-full">
|
||||
<MessageGroup
|
||||
messages={group.messages}
|
||||
isLoading={thread.isLoading}
|
||||
/>
|
||||
<MessageGroup
|
||||
messages={group.messages}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(processingAi)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={thread.isLoading}
|
||||
@@ -738,11 +853,21 @@ export function MessageList({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{documentRewriteItems.map((item) => (
|
||||
<DocumentRewriteTimeline key={item.id} item={item} />
|
||||
))}
|
||||
{thread.isLoading && <StreamingIndicator className="my-4" />}
|
||||
});
|
||||
const leftoverRewriteItems = didRenderRewriteAboveFile
|
||||
? rewriteItemsBelowFile
|
||||
: documentRewriteItems;
|
||||
return (
|
||||
<>
|
||||
{grouped}
|
||||
{leftoverRewriteItems.map((item) => (
|
||||
<DocumentRewriteTimeline key={item.id} item={item} />
|
||||
))}
|
||||
{afterDocumentRewriteItems}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{thread.isLoading && <StreamingIndicator className="my-4" />}
|
||||
<div style={{ height: `${paddingBottom}px` }} />
|
||||
</MessageMetricsProvider>
|
||||
</ConversationContent>
|
||||
@@ -799,6 +924,15 @@ export type SetupWritingApprovalPayload = {
|
||||
outline?: string;
|
||||
};
|
||||
|
||||
export type SetupReportStructureApprovalPayload = {
|
||||
title?: string;
|
||||
name?: string;
|
||||
content?: string;
|
||||
structure_mode?: string;
|
||||
retrieval_directions?: string[];
|
||||
retrieval_skills?: string[];
|
||||
};
|
||||
|
||||
function findPreviousHumanMessageId(
|
||||
messages: Message[],
|
||||
beforeMessageId?: string,
|
||||
@@ -1169,6 +1303,147 @@ function SetupWritingApprovalCard({
|
||||
);
|
||||
}
|
||||
|
||||
function getSetupReportStructureApprovalPayload(
|
||||
message: unknown,
|
||||
): SetupReportStructureApprovalPayload | null {
|
||||
if (typeof message !== "object" || message === null) {
|
||||
return null;
|
||||
}
|
||||
const additionalKwargs = Reflect.get(message, "additional_kwargs");
|
||||
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
|
||||
return null;
|
||||
}
|
||||
const approval = Reflect.get(additionalKwargs, "setup_report_structure_approval");
|
||||
if (typeof approval !== "object" || approval === null) {
|
||||
return null;
|
||||
}
|
||||
return approval as SetupReportStructureApprovalPayload;
|
||||
}
|
||||
|
||||
const STRUCTURE_MODE_LABELS: Record<string, string> = {
|
||||
fixed: "固定大纲",
|
||||
adaptive: "弹性大纲",
|
||||
};
|
||||
|
||||
function SetupReportStructureApprovalCard({
|
||||
disabled,
|
||||
message,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
message: unknown;
|
||||
onApprove?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
|
||||
onReject?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
|
||||
}) {
|
||||
const payload = getSetupReportStructureApprovalPayload(message);
|
||||
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(null);
|
||||
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const approvalPayload = payload;
|
||||
const busy = disabled || submitting !== null;
|
||||
const directions = approvalPayload.retrieval_directions ?? [];
|
||||
const skills = approvalPayload.retrieval_skills ?? [];
|
||||
|
||||
async function run(action: "approve" | "reject") {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
setSubmitting(action);
|
||||
try {
|
||||
if (action === "approve") {
|
||||
await onApprove?.(approvalPayload);
|
||||
} else {
|
||||
await onReject?.(approvalPayload);
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs font-medium">报告结构建议</div>
|
||||
<div className="text-sm font-medium">确认后将应用到右侧表单,再点保存入库。</div>
|
||||
</div>
|
||||
|
||||
{approvalPayload.title ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">标题</div>
|
||||
<p className="text-sm font-medium">{approvalPayload.title}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{approvalPayload.name ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">描述</div>
|
||||
<p className="text-sm leading-6">{approvalPayload.name}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1.5">
|
||||
{approvalPayload.structure_mode ? (
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-muted-foreground text-xs">结构约束</div>
|
||||
<p className="text-sm font-medium">
|
||||
{STRUCTURE_MODE_LABELS[approvalPayload.structure_mode] ?? approvalPayload.structure_mode}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{directions.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-muted-foreground text-xs">检索方向</div>
|
||||
<p className="text-sm font-medium">{directions.join("、")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{skills.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-muted-foreground text-xs">检索来源</div>
|
||||
<p className="text-sm font-medium">{skills.join("、")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{approvalPayload.content ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">报告大纲</div>
|
||||
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
|
||||
{approvalPayload.content}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy || !onReject}
|
||||
onClick={() => void run("reject")}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
忽略
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={busy || !onApprove}
|
||||
onClick={() => void run("approve")}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
应用到表单
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getClarificationPayload(message: unknown): ClarificationPayload | null {
|
||||
if (typeof message !== "object" || message === null) {
|
||||
return null;
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import {
|
||||
isBootstrapThread,
|
||||
isHiddenFromRecentChats,
|
||||
isThreadFavorite,
|
||||
isThreadPinned,
|
||||
listPathOfThread,
|
||||
@@ -193,13 +194,9 @@ export function RecentChatList({
|
||||
);
|
||||
|
||||
// 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页);
|
||||
// 仅排除调度 / 会商类系统会话。
|
||||
// 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
|
||||
const visibleThreads = sortThreadsPinnedFirst(
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "roundtable",
|
||||
),
|
||||
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
|
||||
);
|
||||
|
||||
if (visibleThreads.length === 0) {
|
||||
@@ -209,7 +206,10 @@ export function RecentChatList({
|
||||
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
|
||||
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
|
||||
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",
|
||||
|
||||
@@ -54,6 +54,7 @@ import type {
|
||||
CompactShortcutRouteParam,
|
||||
} from "@/core/system-settings/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
import {
|
||||
APP_THEME_COLORS,
|
||||
LOGIN_SOURCE_OPTIONS,
|
||||
@@ -77,10 +78,7 @@ const languageOptions: { value: Locale; label: string }[] = [
|
||||
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue";
|
||||
|
||||
function makeShortcutId() {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return createClientId("shortcut");
|
||||
}
|
||||
|
||||
function newShortcutParam(): CompactShortcutRouteParam {
|
||||
@@ -119,7 +117,7 @@ function normalizeShortcutDraft(
|
||||
? button.route_params
|
||||
: [newShortcutParam()]
|
||||
).map((param) => ({
|
||||
key: param.key || "",
|
||||
key: param.paramType === "path" ? "" : param.key || "",
|
||||
paramType: param.paramType || "login",
|
||||
source: param.source || "access_token",
|
||||
customValue: param.customValue ?? "",
|
||||
@@ -134,8 +132,18 @@ function cleanShortcutParams(
|
||||
params: CompactShortcutRouteParam[],
|
||||
): CompactShortcutRouteParam[] {
|
||||
return params
|
||||
.filter((param) => param.key.trim())
|
||||
.filter((param) => param.paramType === "path" || param.key.trim())
|
||||
.map((param) => {
|
||||
if (param.paramType === "path") {
|
||||
const source = param.source || "access_token";
|
||||
return {
|
||||
key: "",
|
||||
paramType: "path" as const,
|
||||
source,
|
||||
customValue:
|
||||
source === "other" ? (param.customValue ?? "").trim() : undefined,
|
||||
};
|
||||
}
|
||||
const base: CompactShortcutRouteParam = {
|
||||
key: param.key.trim(),
|
||||
paramType: param.paramType,
|
||||
@@ -197,6 +205,8 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
setShortcutDraft(
|
||||
@@ -356,6 +366,13 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
return;
|
||||
}
|
||||
for (const param of item.route_params) {
|
||||
if (param.paramType === "path") {
|
||||
if (param.source === "other" && !param.customValue?.trim()) {
|
||||
toast.error("路径参数自定义值不可为空");
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!param.key.trim()) {
|
||||
toast.error("路由参数键名不可为空");
|
||||
return;
|
||||
@@ -550,7 +567,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
title="顶部快捷按钮(管理员)"
|
||||
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停和与应用管理一致的登录/主题参数。"
|
||||
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停,以及登录、主题和路径参数。"
|
||||
>
|
||||
<CompactShortcutSettings
|
||||
buttons={shortcutDraft}
|
||||
@@ -830,23 +847,45 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
aria-label="回答速度指示器"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">RAG 引用模式</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.display.ragModeEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings("display", { ragModeEnabled: checked })
|
||||
}
|
||||
aria-label="RAG 引用模式"
|
||||
/>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="rounded-lg border p-4">
|
||||
{isAdmin && (
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">参考文献模式入口</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||
管理员配置。开启后,普通问答和 iframe 问答输入区会显示「参考文献」按钮;默认关闭。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={referenceModeEntryEnabled}
|
||||
disabled={updateCitationDisplay.isPending}
|
||||
onCheckedChange={(checked) =>
|
||||
updateCitationDisplay.mutate({
|
||||
reference_mode_enabled: checked,
|
||||
})
|
||||
}
|
||||
aria-label="参考文献模式入口"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{referenceModeEntryEnabled && (
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">RAG 引用模式</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.display.ragModeEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings("display", { ragModeEnabled: checked })
|
||||
}
|
||||
aria-label="RAG 引用模式"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3">
|
||||
<div className="text-sm font-medium">参考文献去除词</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-2xl text-xs leading-relaxed">
|
||||
@@ -917,7 +956,7 @@ function CompactShortcutSettings({
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
已配置 {buttons.length} 个按钮;只有启用且填写了跳转链接的按钮会显示。
|
||||
已配置 {buttons.length} 个按钮;支持追加登录、主题及路径参数。
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onAdd}>
|
||||
@@ -1014,7 +1053,7 @@ function CompactShortcutSettings({
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/20 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<ExternalLink className="size-4" />
|
||||
路由参数
|
||||
@@ -1030,13 +1069,13 @@ function CompactShortcutSettings({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{button.route_params.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed bg-background/70 p-4 text-center text-xs">
|
||||
不追加额外参数。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{button.route_params.map((param, paramIndex) => {
|
||||
{button.route_params.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed bg-background/70 p-4 text-center text-xs">
|
||||
不追加额外参数。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{button.route_params.map((param, paramIndex) => {
|
||||
const themeMap =
|
||||
param.paramType === "theme"
|
||||
? parseThemeMap(param.themeValue)
|
||||
@@ -1046,26 +1085,36 @@ function CompactShortcutSettings({
|
||||
key={`${button.id}-${paramIndex}`}
|
||||
className="space-y-2 rounded-md border bg-background p-2"
|
||||
>
|
||||
<div className="grid gap-2 md:grid-cols-[minmax(120px,1fr)_130px_minmax(180px,1.4fr)_32px]">
|
||||
<Input
|
||||
value={param.key}
|
||||
onChange={(event) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
key: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="参数名 key"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2",
|
||||
param.paramType === "path"
|
||||
? "md:grid-cols-[130px_minmax(180px,1fr)_32px]"
|
||||
: "md:grid-cols-[minmax(120px,1fr)_130px_minmax(180px,1.4fr)_32px]",
|
||||
)}
|
||||
>
|
||||
{param.paramType !== "path" && (
|
||||
<Input
|
||||
value={param.key}
|
||||
onChange={(event) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
key: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="参数名 key"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
value={param.paramType}
|
||||
onValueChange={(value) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
paramType: value as "login" | "theme",
|
||||
paramType: value as "login" | "theme" | "path",
|
||||
key: value === "path" ? "" : param.key,
|
||||
source:
|
||||
value === "login"
|
||||
? param.source || "access_token"
|
||||
: undefined,
|
||||
value === "theme"
|
||||
? undefined
|
||||
: param.source || "access_token",
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -1075,9 +1124,10 @@ function CompactShortcutSettings({
|
||||
<SelectContent>
|
||||
<SelectItem value="login">登录参数</SelectItem>
|
||||
<SelectItem value="theme">主题参数</SelectItem>
|
||||
<SelectItem value="path">路径参数</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{param.paramType === "login" ? (
|
||||
{(param.paramType === "login" || param.paramType === "path") ? (
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<Select
|
||||
value={param.source || "access_token"}
|
||||
@@ -1103,6 +1153,11 @@ function CompactShortcutSettings({
|
||||
</Select>
|
||||
{param.source === "other" && (
|
||||
<Input
|
||||
// This config value becomes a query parameter;
|
||||
// it is not a login form field. A password input
|
||||
// makes Chrome invoke password-manager heuristics
|
||||
// whenever the settings dialog opens.
|
||||
type="text"
|
||||
value={param.customValue ?? ""}
|
||||
onChange={(event) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
@@ -1111,6 +1166,8 @@ function CompactShortcutSettings({
|
||||
}
|
||||
placeholder="自定义值"
|
||||
className="w-32 text-xs"
|
||||
autoComplete="off"
|
||||
aria-label="自定义参数值"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1133,6 +1190,12 @@ function CompactShortcutSettings({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{param.paramType === "path" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
不填写参数名,按添加顺序拼到链接或 Hash 路由的路径末尾。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{param.paramType === "theme" && (
|
||||
<div className="grid gap-2 rounded-md bg-muted/40 p-2 md:grid-cols-3">
|
||||
{APP_THEME_COLORS.map((color) => (
|
||||
@@ -1166,9 +1229,9 @@ function CompactShortcutSettings({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
customThemeTextContrast,
|
||||
type CustomThemeAdvancedKey,
|
||||
} from "@/core/theme/custom-theme";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MAX_CUSTOM_THEMES = 12;
|
||||
@@ -127,10 +128,7 @@ const ADVANCED_COLOR_FIELDS: Array<{
|
||||
];
|
||||
|
||||
function makeThemeId(): `custom-theme-${string}` {
|
||||
const suffix =
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "").toLowerCase()
|
||||
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
||||
const suffix = createClientId().replace(/[^a-z0-9-]/gi, "").toLowerCase();
|
||||
return `custom-theme-${suffix}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
Trash2Icon,
|
||||
TrendingUpIcon,
|
||||
Upload,
|
||||
UserIcon,
|
||||
UserIcon,
|
||||
WorkflowIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -50,13 +51,6 @@ import {
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemTitle,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
} from "@/components/ui/item";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@@ -756,7 +750,7 @@ export function SkillRow({
|
||||
|
||||
const badgeGroup = (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn(isCard && "truncate font-medium")}>
|
||||
<span className="min-w-0 truncate text-[15px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-100">
|
||||
{skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name)}
|
||||
</span>
|
||||
{skill.name_zh?.trim() && (
|
||||
@@ -861,65 +855,12 @@ export function SkillRow({
|
||||
</div>
|
||||
);
|
||||
|
||||
const metaNodes = (
|
||||
<>
|
||||
{!isBuiltin && skill.owner_username && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!skill.owner_user_id || !onPublisherClick}
|
||||
onClick={(e) => {
|
||||
if (!skill.owner_user_id || !onPublisherClick) return;
|
||||
e.stopPropagation();
|
||||
onPublisherClick({
|
||||
id: skill.owner_user_id,
|
||||
label: desensitize(skill.owner_username!),
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
"text-muted-foreground mt-1 flex max-w-full items-center gap-1 text-sm font-medium",
|
||||
skill.owner_user_id && onPublisherClick &&
|
||||
"hover:text-primary cursor-pointer transition-colors hover:underline",
|
||||
)}
|
||||
title={
|
||||
skill.owner_user_id && onPublisherClick
|
||||
? `点击筛选发布者:${desensitize(skill.owner_username)}`
|
||||
: `发布者:${desensitize(skill.owner_username)}`
|
||||
}
|
||||
>
|
||||
<UserIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">发布者:{desensitize(skill.owner_username)}</span>
|
||||
</button>
|
||||
)}
|
||||
{skill.use_count !== undefined && (
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{skill.use_count > 0
|
||||
? t.settings.skills.useCountInline.replace(
|
||||
"{count}",
|
||||
String(skill.use_count),
|
||||
)
|
||||
: t.settings.skills.useCountInlineZero}
|
||||
</div>
|
||||
)}
|
||||
{formatDateTime(skill.created_at) && (
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-1 text-xs">
|
||||
<ClockIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
创建时间:{formatDateTime(skill.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(skill.tags?.length ?? 0) > 0 && (
|
||||
<TagChipList tags={skill.tags!} className="mt-2" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const actionButtons = (
|
||||
<>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
className="h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
onClick={() => setTagPickerOpen(true)}
|
||||
title={t.adminTags.manageTags}
|
||||
>
|
||||
@@ -930,7 +871,7 @@ export function SkillRow({
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
"h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
|
||||
skill.featured && "text-amber-600 hover:text-amber-700",
|
||||
)}
|
||||
disabled={setFeatured.isPending}
|
||||
@@ -951,7 +892,7 @@ export function SkillRow({
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
"h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
|
||||
skill.pinned && "text-emerald-600 hover:text-emerald-700",
|
||||
)}
|
||||
disabled={setPinned.isPending}
|
||||
@@ -972,7 +913,7 @@ export function SkillRow({
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
"h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
|
||||
skill.always_on && "text-amber-600 hover:text-amber-700",
|
||||
)}
|
||||
disabled={setAlwaysOn.isPending}
|
||||
@@ -992,7 +933,7 @@ export function SkillRow({
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
className="h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
disabled={setPublished.isPending}
|
||||
onClick={() => void handleTogglePublished(!skill.published)}
|
||||
title={
|
||||
@@ -1011,7 +952,7 @@ export function SkillRow({
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
className="h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
disabled={duplicateSkillMutation.isPending}
|
||||
onClick={handleOpenDuplicate}
|
||||
title="复制为新技能"
|
||||
@@ -1022,7 +963,7 @@ export function SkillRow({
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
onClick={() => setConfirmDeleteOpen(true)}
|
||||
title={t.settings.skills.deleteAction}
|
||||
>
|
||||
@@ -1031,39 +972,125 @@ export function SkillRow({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const iconStyle = skill.name.includes("web") || skill.name.includes("search")
|
||||
? {
|
||||
Icon: GlobeIcon,
|
||||
className: "bg-emerald-600 text-white shadow-[0_6px_16px_rgba(22,163,74,0.22)]",
|
||||
}
|
||||
: skill.name.includes("doc") || skill.name.includes("file")
|
||||
? {
|
||||
Icon: FileArchive,
|
||||
className: "bg-violet-600 text-white shadow-[0_6px_16px_rgba(124,58,237,0.22)]",
|
||||
}
|
||||
: skill.source === "agent"
|
||||
? {
|
||||
Icon: WorkflowIcon,
|
||||
className: "bg-[#2166e8] text-white shadow-[0_6px_16px_rgba(37,99,235,0.22)]",
|
||||
}
|
||||
: {
|
||||
Icon: Upload,
|
||||
className: "bg-amber-500 text-white shadow-[0_6px_16px_rgba(245,158,11,0.22)]",
|
||||
};
|
||||
const SkillIcon = iconStyle.Icon;
|
||||
const activityAt = formatDateTime(skill.last_activity_at ?? skill.created_at);
|
||||
const actionArea = (
|
||||
<div
|
||||
className="flex flex-wrap items-center justify-end gap-1"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{actionButtons}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isCard ? (
|
||||
<div className="flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div className="flex h-full min-w-0 flex-col rounded-xl border border-slate-200 bg-white p-5 shadow-[0_5px_18px_rgba(15,23,42,0.035)] transition-[border-color,box-shadow] hover:border-blue-200 hover:shadow-[0_10px_28px_rgba(37,99,235,0.07)] dark:border-slate-800 dark:bg-slate-900 dark:text-white">
|
||||
<div
|
||||
className="min-w-0 flex-1 cursor-pointer"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
{badgeGroup}
|
||||
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm">
|
||||
{desensitize(skill.description ?? "")}
|
||||
</p>
|
||||
{metaNodes}
|
||||
<div className="flex items-start gap-3.5">
|
||||
<div className={cn("flex size-12 shrink-0 items-center justify-center rounded-full", iconStyle.className)}>
|
||||
<SkillIcon className="size-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{badgeGroup}
|
||||
<p className="mt-1.5 line-clamp-3 text-sm leading-6 text-slate-600 dark:text-slate-400">
|
||||
{desensitize(skill.description ?? "")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Badge className={cn("border-0 px-2.5 py-1 text-xs font-normal", skill.enabled ? "bg-emerald-50 text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300" : "bg-slate-100 text-slate-500 hover:bg-slate-100 dark:bg-slate-800 dark:text-slate-400")}>
|
||||
<span className={cn("mr-1.5 size-1.5 rounded-full", skill.enabled ? "bg-emerald-500" : "bg-slate-400")} />
|
||||
{skill.enabled ? "已启用" : "未启用"}
|
||||
</Badge>
|
||||
<span className="text-xs text-slate-400">{skill.use_count ? `调用 ${skill.use_count} 次` : "暂无调用"}</span>
|
||||
{activityAt && <span className="text-xs text-slate-400">更新于 {activityAt}</span>}
|
||||
</div>
|
||||
{(skill.tags?.length ?? 0) > 0 && <TagChipList tags={skill.tags!} className="mt-3" />}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center justify-end gap-1 border-t pt-2 dark:border-[#213D5E]">
|
||||
{actionButtons}
|
||||
<div className="mt-4 border-t border-slate-100 pt-3 dark:border-slate-800">
|
||||
{actionArea}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
|
||||
<ItemContent
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
<ItemTitle>{badgeGroup}</ItemTitle>
|
||||
<ItemDescription className="line-clamp-4">
|
||||
{desensitize(skill.description ?? "")}
|
||||
</ItemDescription>
|
||||
{metaNodes}
|
||||
</ItemContent>
|
||||
<ItemActions>{actionButtons}</ItemActions>
|
||||
</Item>
|
||||
<div className="group/skill-card w-full rounded-xl border border-slate-200 bg-white px-5 py-4 shadow-[0_5px_18px_rgba(15,23,42,0.03)] transition-[border-color,box-shadow] hover:border-blue-200 hover:shadow-[0_10px_28px_rgba(37,99,235,0.07)] dark:border-slate-800 dark:bg-slate-900 dark:text-white">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1.65fr)_minmax(150px,0.72fr)_minmax(100px,0.42fr)_minmax(132px,0.58fr)_auto] xl:items-center xl:gap-x-6">
|
||||
<div className="flex min-w-0 cursor-pointer items-start gap-4" onClick={() => setDetailOpen(true)}>
|
||||
<div className={cn("flex size-[60px] shrink-0 items-center justify-center rounded-full", iconStyle.className)}>
|
||||
<SkillIcon className="size-7" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
{badgeGroup}
|
||||
<p className="mt-1.5 line-clamp-2 text-sm leading-5 text-slate-600 dark:text-slate-400">
|
||||
{desensitize(skill.description ?? "")}
|
||||
</p>
|
||||
{!isBuiltin && skill.owner_username && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!skill.owner_user_id || !onPublisherClick}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!skill.owner_user_id || !onPublisherClick) return;
|
||||
onPublisherClick({ id: skill.owner_user_id, label: desensitize(skill.owner_username!) });
|
||||
}}
|
||||
className={cn(
|
||||
"mt-2 flex max-w-full items-center gap-1 text-xs text-slate-400",
|
||||
skill.owner_user_id && onPublisherClick && "cursor-pointer hover:text-blue-600 hover:underline",
|
||||
)}
|
||||
>
|
||||
<UserIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">发布者:{desensitize(skill.owner_username)}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 xl:border-l xl:border-slate-100 xl:pl-5 xl:dark:border-slate-800">
|
||||
{(skill.tags?.length ?? 0) > 0 ? (
|
||||
<TagChipList tags={skill.tags!} />
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">未设置标签</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 text-sm text-slate-600 dark:text-slate-300">
|
||||
<span className="block text-xs text-slate-400 xl:hidden">使用次数</span>
|
||||
{skill.use_count ? `${skill.use_count} 次` : "暂无调用"}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<Badge className={cn("border-0 px-2.5 py-1 text-xs font-normal", skill.enabled ? "bg-emerald-50 text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300" : "bg-slate-100 text-slate-500 hover:bg-slate-100 dark:bg-slate-800 dark:text-slate-400")}>
|
||||
<span className={cn("mr-1.5 size-1.5 rounded-full", skill.enabled ? "bg-emerald-500" : "bg-slate-400")} />
|
||||
{skill.enabled ? "已启用" : "未启用"}
|
||||
</Badge>
|
||||
{activityAt && <p className="mt-2 truncate text-xs text-slate-400" title={activityAt}>更新于 {activityAt}</p>}
|
||||
</div>
|
||||
<div className="min-w-0 border-t border-slate-100 pt-3 xl:border-t-0 xl:pt-0 dark:border-slate-800">
|
||||
{actionArea}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
|
||||
<DialogContent>
|
||||
@@ -1201,7 +1228,24 @@ function InstallSkillDialog({
|
||||
const validateMutation = useValidateSkillUpload();
|
||||
const installMutation = useInstallSkillUpload();
|
||||
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(() => {
|
||||
setFile(null);
|
||||
@@ -1240,19 +1284,12 @@ function InstallSkillDialog({
|
||||
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
|
||||
toast.error(dialog.rejectInvalidExt);
|
||||
return;
|
||||
}
|
||||
setFile(selected);
|
||||
setValidation(null);
|
||||
setValidationError(null);
|
||||
try {
|
||||
const result = await validateMutation.mutateAsync(selected);
|
||||
setValidation(result);
|
||||
} catch (err) {
|
||||
setValidationError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
},
|
||||
[dialog.rejectInvalidExt, validateMutation],
|
||||
);
|
||||
}
|
||||
setFile(selected);
|
||||
await validateFile(selected, targetCategory);
|
||||
},
|
||||
[dialog.rejectInvalidExt, targetCategory, validateFile],
|
||||
);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
@@ -1329,12 +1366,29 @@ function InstallSkillDialog({
|
||||
}
|
||||
}, [dialog.nameZhSaved, installedName, nameZhDraft, onClose, reset, setNameZhMutation]);
|
||||
|
||||
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
|
||||
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
|
||||
// Admin targeting public directory can overwrite an existing public skill.
|
||||
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
|
||||
const canInstall = !!validation && !validationError && !isCustomConflict && (adminCanOverwritePublic ? false : !isPublicConflict);
|
||||
const canOverwrite = !!validation && !validationError && (!!isCustomConflict || adminCanOverwritePublic);
|
||||
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
|
||||
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
|
||||
// Admin targeting public directory can overwrite an existing public skill.
|
||||
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
|
||||
const canInstall = !!validation && !validationError && validation.can_install_as_is;
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||
@@ -1453,9 +1507,12 @@ function InstallSkillDialog({
|
||||
<input
|
||||
type="radio"
|
||||
name="targetCategory"
|
||||
value={cat}
|
||||
checked={targetCategory === cat}
|
||||
onChange={() => setTargetCategory(cat)}
|
||||
value={cat}
|
||||
checked={targetCategory === cat}
|
||||
onChange={() => {
|
||||
setTargetCategory(cat);
|
||||
if (file) void validateFile(file, cat);
|
||||
}}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
|
||||
@@ -1513,15 +1570,18 @@ function InstallSkillDialog({
|
||||
</ul>
|
||||
</Field>
|
||||
)}
|
||||
{isCustomConflict && (
|
||||
<div className="text-amber-600 mt-1 flex items-start gap-2 text-xs">
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{desensitize(
|
||||
dialog.conflictCustom.replace("{name}", validation.skill_name),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{isCustomConflict && (
|
||||
<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" />
|
||||
<span>
|
||||
{desensitize(customConflictMessage)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isPublicConflict && (
|
||||
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
|
||||
|
||||
@@ -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 {
|
||||
@@ -9,12 +9,22 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import {
|
||||
parseWeKnoraPreviewUrl,
|
||||
resolveWeKnoraSourceTarget,
|
||||
} from "@/core/llmwiki/source-links";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
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";
|
||||
|
||||
/** Whether a source URL is an internal WeKnora preview pointer. */
|
||||
export function isWeKnoraSourcePreviewUrl(url?: string | null): boolean {
|
||||
return parseWeKnoraPreviewUrl(url) !== null;
|
||||
}
|
||||
|
||||
export function SkillDisplaySources({
|
||||
highlightedReferenceId,
|
||||
@@ -52,17 +62,31 @@ export function SourceCard({
|
||||
source: RuntimeDisplaySource;
|
||||
referenceTarget?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [context, setContext] = useState<WeKnoraSourceContext | null>(null);
|
||||
const [contextLoading, setContextLoading] = useState(false);
|
||||
const [contextError, setContextError] = useState<string | null>(null);
|
||||
const [relatedOpen, setRelatedOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [settings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const previewTarget = resolveWeKnoraSourceTarget({
|
||||
...(source.raw || {}),
|
||||
url: source.url,
|
||||
});
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||||
const snippet = compactReferenceText(content).slice(0, 280);
|
||||
const paragraphs = segmentReferenceText(content);
|
||||
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
|
||||
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 =
|
||||
typeof source.score === "number"
|
||||
? source.score <= 1
|
||||
@@ -71,64 +95,43 @@ export function SourceCard({
|
||||
: null;
|
||||
const meta = [
|
||||
source.source,
|
||||
isWikiSource ? "检索对象:Wiki 文档" : null,
|
||||
matchedHeading ? `命中章节:${matchedHeading}` : 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,
|
||||
score ? `相关度 ${score}` : null,
|
||||
].filter(Boolean);
|
||||
const isCitation = source.mode === "citation";
|
||||
const showReferenceNumber = isCitation;
|
||||
const previewTarget = parseWeKnoraPreviewUrl(source.url);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !previewTarget) {
|
||||
return;
|
||||
const openSource = () => {
|
||||
if (previewTarget) {
|
||||
setDetailOpen(true);
|
||||
} else {
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
<div
|
||||
id={referenceTarget ? `reference-${source.index}` : undefined}
|
||||
type="button"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
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",
|
||||
"target:border-amber-300 target:bg-amber-50/80",
|
||||
highlighted &&
|
||||
"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">
|
||||
{showReferenceNumber && (
|
||||
@@ -156,20 +159,40 @@ export function SourceCard({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{source.url && (
|
||||
{(source.url || isWikiSource) && (
|
||||
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">
|
||||
<FileTextIcon className="size-3.5" />
|
||||
查看原文
|
||||
<span className={cn(
|
||||
"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-primary/10 text-primary",
|
||||
)}>
|
||||
{isWikiSource ? <BookOpenTextIcon className="size-3.5" /> : <FileTextIcon className="size-3.5" />}
|
||||
{isWikiSource ? "Wiki 文档" : "查看原文"}
|
||||
</span>
|
||||
) : (
|
||||
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
{previewTarget && (
|
||||
<div className="mt-3 flex items-center justify-between border-t pt-2 text-xs text-muted-foreground">
|
||||
<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>
|
||||
<div className="flex items-start justify-between gap-3 pr-8">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -205,19 +228,23 @@ export function SourceCard({
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{previewTarget ? (
|
||||
<WeKnoraSourceContextPanel
|
||||
context={context}
|
||||
loading={contextLoading}
|
||||
error={contextError}
|
||||
/>
|
||||
) : paragraphs.length > 0 ? (
|
||||
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
<p key={index} className="whitespace-pre-wrap">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
{content ? (
|
||||
<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">
|
||||
{isWikiSource && (
|
||||
<div className="mb-4 flex items-center gap-2 border-b pb-3 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
<BookOpenTextIcon className="size-4" />
|
||||
本次检索命中的 Wiki 文档内容
|
||||
</div>
|
||||
)}
|
||||
<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">
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={content}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
) : null}
|
||||
{source.url && !previewTarget && (
|
||||
@@ -226,161 +253,11 @@ export function SourceCard({
|
||||
</div>
|
||||
)}
|
||||
</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(
|
||||
raw: string | undefined,
|
||||
cleanupWords: string[],
|
||||
@@ -405,44 +282,18 @@ function compactReferenceText(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function segmentReferenceText(text: string): string[] {
|
||||
const explicit = text
|
||||
.split(/\n{2,}/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const base = explicit.length > 1 ? explicit : splitLongParagraph(text);
|
||||
return base.slice(0, 80);
|
||||
}
|
||||
|
||||
function splitLongParagraph(text: string): string[] {
|
||||
const normalized = text
|
||||
.split(/\n+/)
|
||||
.map((item) => item.trim())
|
||||
.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;
|
||||
function plainTextReferencePreview(text: string): string {
|
||||
return compactReferenceText(
|
||||
text
|
||||
.replace(/^---[\s\S]*?---\s*/m, "")
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||
.replace(/\[\[[^\]\s|]+[|\s]+([^\]]+)\]\]/g, "$1")
|
||||
.replace(/\[\[([^\]]+)\]\]/g, "$1")
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.replace(/^\s*[-*+]\s+/gm, "")
|
||||
.replace(/^\s*\d+[.)]\s+/gm, "")
|
||||
.replace(/[>*_`~|]/g, " "),
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
import { useThreadChat } from "./chats";
|
||||
import { FlipDisplay } from "./flip-display";
|
||||
@@ -17,18 +17,18 @@ export function ThreadTitle({
|
||||
threadId: string;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { isNewThread } = useThreadChat();
|
||||
const displayTitle = sanitizeThreadTitleForDisplay(thread.values?.title);
|
||||
|
||||
useEffect(() => {
|
||||
let _title = t.pages.untitled;
|
||||
|
||||
if (thread.values?.title) {
|
||||
_title = displayTitle;
|
||||
} else if (isNewThread) {
|
||||
_title = t.pages.newChat;
|
||||
}
|
||||
const { t } = useI18n();
|
||||
const { isNewThread } = useThreadChat();
|
||||
const displayTitle = sanitizeThreadTitleForDisplay(thread.values?.title);
|
||||
|
||||
useEffect(() => {
|
||||
let _title = t.pages.untitled;
|
||||
|
||||
if (thread.values?.title) {
|
||||
_title = displayTitle;
|
||||
} else if (isNewThread) {
|
||||
_title = t.pages.newChat;
|
||||
}
|
||||
if (thread.isThreadLoading) {
|
||||
document.title = `Loading... - ${t.pages.appName}`;
|
||||
} else {
|
||||
@@ -38,18 +38,21 @@ export function ThreadTitle({
|
||||
isNewThread,
|
||||
t.pages.newChat,
|
||||
t.pages.untitled,
|
||||
t.pages.appName,
|
||||
thread.isThreadLoading,
|
||||
thread.values,
|
||||
displayTitle,
|
||||
]);
|
||||
t.pages.appName,
|
||||
thread.isThreadLoading,
|
||||
thread.values,
|
||||
displayTitle,
|
||||
]);
|
||||
|
||||
if (!thread.values?.title) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<FlipDisplay uniqueKey={threadId}>
|
||||
{desensitize(displayTitle)}
|
||||
</FlipDisplay>
|
||||
);
|
||||
}
|
||||
const visibleTitle = desensitize(displayTitle);
|
||||
return (
|
||||
<FlipDisplay uniqueKey={threadId} className="min-w-0 w-full">
|
||||
<span className="block truncate" title={visibleTitle}>
|
||||
{visibleTitle}
|
||||
</span>
|
||||
</FlipDisplay>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { User } from "lucide-react";
|
||||
import { UserRound } from "lucide-react";
|
||||
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { useMyPosition } from "@/core/positions";
|
||||
@@ -15,7 +15,13 @@ import { cn } from "@/lib/utils";
|
||||
* 配色按当前内容区前景色(foreground / muted-foreground),适配聊天页浅底顶栏,
|
||||
* 而非 Header 的白字贴 banner 方案。
|
||||
*/
|
||||
export function UserIdentityBadge({ className }: { className?: string }) {
|
||||
export function UserIdentityBadge({
|
||||
className,
|
||||
variant = "plain",
|
||||
}: {
|
||||
className?: string;
|
||||
variant?: "plain" | "topbar";
|
||||
}) {
|
||||
// userInfo belongs to external-app route parameters and can legitimately
|
||||
// outlive a login switch. The visible account name must instead match the
|
||||
// settings page's current auth identity.
|
||||
@@ -26,13 +32,33 @@ export function UserIdentityBadge({ className }: { className?: string }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex shrink-0 items-center gap-2", className)}
|
||||
data-workspace-user={variant}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2",
|
||||
variant === "topbar" &&
|
||||
"h-10 max-w-[240px] rounded-lg px-1.5 py-1 text-slate-700 dark:text-slate-200",
|
||||
className,
|
||||
)}
|
||||
title={positionName ? `${displayName}·${positionName}` : displayName}
|
||||
>
|
||||
<div className="bg-muted text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full">
|
||||
<User className="size-3.5" />
|
||||
<div
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground relative flex size-7 shrink-0 items-center justify-center rounded-full",
|
||||
variant === "topbar" &&
|
||||
"bg-primary text-primary-foreground size-8 shadow-[0_4px_12px_-6px_rgba(15,23,42,0.26)] ring-1 ring-primary/15",
|
||||
)}
|
||||
>
|
||||
<UserRound className={cn("size-3.5", variant === "topbar" && "size-4")} />
|
||||
{variant === "topbar" && (
|
||||
<span className="absolute right-0 bottom-0 size-2.5 rounded-full border-2 border-white bg-emerald-500 dark:border-slate-950" />
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden min-w-0 text-left sm:block">
|
||||
<div
|
||||
className={cn(
|
||||
"hidden min-w-0 text-left sm:block",
|
||||
variant === "topbar" && "max-w-[165px]",
|
||||
)}
|
||||
>
|
||||
<p className="text-foreground truncate text-sm font-medium leading-tight">
|
||||
{displayName}
|
||||
</p>
|
||||
|
||||
@@ -12,8 +12,11 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThemeSwitcher } from "@/components/workspace/appearance-controls";
|
||||
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
|
||||
|
||||
export function WorkspaceContainer({
|
||||
className,
|
||||
@@ -93,9 +96,19 @@ export function WorkspaceHeader({
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
{rightActions ? (
|
||||
<div className="flex items-center gap-2 px-4">{rightActions}</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
{rightActions}
|
||||
{!env.FORCE_COLLAPSED_SIDEBAR && (
|
||||
<>
|
||||
<ThemeSwitcher tone="content" />
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<UserIdentityBadge variant="topbar" />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -123,7 +136,8 @@ function nameOfSegment(
|
||||
t: ReturnType<typeof useI18n>["t"],
|
||||
) {
|
||||
if (!segment) return t.common.home;
|
||||
if (segment === "workspace") return t.breadcrumb.workspace;
|
||||
if (segment === "chats") return t.breadcrumb.chats;
|
||||
return segment[0]?.toUpperCase() + segment.slice(1);
|
||||
}
|
||||
if (segment === "workspace") return t.breadcrumb.workspace;
|
||||
if (segment === "chats") return t.breadcrumb.chats;
|
||||
if (segment === "agents") return t.agents.title;
|
||||
return segment[0]?.toUpperCase() + segment.slice(1);
|
||||
}
|
||||
@@ -5,21 +5,26 @@ import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import cmAssistantLogoDark from "@/assets/img/cm-assistant-logo.png";
|
||||
import cmAssistantLogoLight from "@/assets/img/cm-assistant-logo-red.png";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function BrandMark({ className }: { className?: string }) {
|
||||
const { theme } = useTheme();
|
||||
const brandLogoSrc =
|
||||
theme === "dark"
|
||||
? cmAssistantLogoDark
|
||||
: cmAssistantLogoLight;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold shadow-sm",
|
||||
className,
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
C
|
||||
</div>
|
||||
<img
|
||||
src={brandLogoSrc}
|
||||
alt="CM 助手"
|
||||
className={cn("size-9 shrink-0 object-contain", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
@@ -13,13 +14,18 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, FileSearch, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||
|
||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
import { WorkspaceNavStudio } from "./workspace-nav-studio";
|
||||
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 =
|
||||
@@ -40,6 +46,108 @@ 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%)] " +
|
||||
"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-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-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({
|
||||
showCollapseTrigger = false,
|
||||
compact = false,
|
||||
@@ -50,6 +158,8 @@ export function WorkspaceNavChatList({
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
const navigate = useNavigate();
|
||||
const { sidebarItems } = useWorkspaceCompactNavItems();
|
||||
const newChatItem = sidebarItems.find((item) => item.id === "workspace-new-chat");
|
||||
// 「新对话」点击时,如果已经在 /chats/new 路径,直接 navigate 会被 react-router
|
||||
// 当成同路径 no-op,不更新 location.key,挂在 location.key 上的下游 hook
|
||||
// (useThreadChat)就感知不到「用户想重开一个对话」,UI 不刷新。
|
||||
@@ -66,173 +176,77 @@ export function WorkspaceNavChatList({
|
||||
};
|
||||
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">
|
||||
<SidebarMenuItem>
|
||||
{compact ? (
|
||||
{(showCollapseTrigger || newChatItem) && (
|
||||
<SidebarMenuItem>
|
||||
{compact ? (
|
||||
// 紧凑模式:新对话占满整行白卡样式 + 右侧收起钮(点击收起整列历史栏)。
|
||||
<div className="flex items-center gap-1">
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/chats/new"}
|
||||
asChild
|
||||
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}
|
||||
<div className="flex items-center gap-1">
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/chats/new"}
|
||||
asChild
|
||||
className={cn(NEW_CHAT_COMPACT_CLASS, "min-w-0 flex-1")}
|
||||
>
|
||||
<MessageSquarePlus />
|
||||
<span>{t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{showCollapseTrigger && (
|
||||
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
|
||||
<Plus className="stroke-[2.5] text-[var(--primary)]" />
|
||||
<span>{newChatItem?.label ?? 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 />
|
||||
<span>{newChatItem?.label ?? t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{showCollapseTrigger && (
|
||||
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
{!showCollapseTrigger && (
|
||||
<>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/chats"}
|
||||
asChild
|
||||
className={NAV_ITEM_CLASS}
|
||||
>
|
||||
<Link href="/page/workspace/chats">
|
||||
<MessagesSquare />
|
||||
<span>{t.sidebar.chats}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/agents")}
|
||||
asChild
|
||||
className={NAV_ITEM_CLASS}
|
||||
>
|
||||
<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>
|
||||
{sidebarItems
|
||||
.filter((item) => item.id !== "workspace-new-chat")
|
||||
.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<SidebarMenuItem key={item.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={item.match(pathname)}
|
||||
asChild
|
||||
className={NAV_ITEM_CLASS}
|
||||
>
|
||||
<Link href={item.path ?? "/page/workspace/chats/new"}>
|
||||
<Icon />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
<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>
|
||||
|
||||
@@ -7,10 +7,14 @@ import {
|
||||
BriefcaseIcon,
|
||||
CalendarClockIcon,
|
||||
ChevronsUpDown,
|
||||
Compass,
|
||||
CpuIcon,
|
||||
DatabaseZapIcon,
|
||||
GaugeIcon,
|
||||
HistoryIcon,
|
||||
ListTreeIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
MessageSquareTextIcon,
|
||||
ScrollTextIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
@@ -19,6 +23,7 @@ import {
|
||||
TrophyIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -43,9 +48,14 @@ import {
|
||||
} from "@/core/system-settings/api";
|
||||
|
||||
import { NotificationDialog } from "./notification-dialog";
|
||||
import {
|
||||
COMPACT_GUIDE_START_EVENT,
|
||||
requestCompactGuideStart,
|
||||
} from "./compact-mode-guide";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { SettingsDialog } from "./settings";
|
||||
import { useWorkspaceCompactNavItems } from "./workspace-nav-chat-list";
|
||||
|
||||
function NavMenuButtonContent({
|
||||
isSidebarOpen,
|
||||
@@ -69,6 +79,7 @@ function NavMenuButtonContent({
|
||||
|
||||
export function WorkspaceNavMenu() {
|
||||
const navigate = useNavigate();
|
||||
const pathname = usePathname();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
|
||||
@@ -80,11 +91,26 @@ export function WorkspaceNavMenu() {
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { t } = useI18n();
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
const { settingsItems } = useWorkspaceCompactNavItems();
|
||||
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
if (!isAdmin) return;
|
||||
let cancelled = false;
|
||||
@@ -111,7 +137,7 @@ export function WorkspaceNavMenu() {
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<SidebarMenu className="w-full">
|
||||
<SidebarMenu className="w-full" data-compact-guide="settings-menu">
|
||||
<SidebarMenuItem>
|
||||
{mounted ? (
|
||||
<DropdownMenu>
|
||||
@@ -129,6 +155,18 @@ export function WorkspaceNavMenu() {
|
||||
sideOffset={4}
|
||||
>
|
||||
<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)}>
|
||||
<BellIcon />
|
||||
{t.notifications.title}
|
||||
@@ -138,6 +176,10 @@ export function WorkspaceNavMenu() {
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={startCompactGuide}>
|
||||
<Compass />
|
||||
界面使用引导
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSettingsDefaultSection("appearance");
|
||||
@@ -212,6 +254,24 @@ export function WorkspaceNavMenu() {
|
||||
技能管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/models")}
|
||||
>
|
||||
<CpuIcon />
|
||||
模型配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口(管理员):
|
||||
简洁模式没有全局侧边栏,这里放一份保证两种模式都能进菜单管理。 */}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/menu-management")}
|
||||
>
|
||||
<ListTreeIcon />
|
||||
菜单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
>
|
||||
@@ -254,6 +314,16 @@ export function WorkspaceNavMenu() {
|
||||
推荐问题配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/admin/fixed-questions")
|
||||
}
|
||||
>
|
||||
<MessageSquareTextIcon />
|
||||
问题配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && logSystem?.enabled && logSystem.url && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarHeader,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { GeneralQaSidebar } from "./general-qa-sidebar";
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
import { RecentRoundtableList } from "./recent-roundtable-list";
|
||||
import { RecentWritingList } from "./recent-writing-list";
|
||||
import { WorkspaceHeader } from "./workspace-header";
|
||||
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarHeader,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { GeneralQaSidebar } from "./general-qa-sidebar";
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
import { RecentRoundtableList } from "./recent-roundtable-list";
|
||||
import { RecentWritingList } from "./recent-writing-list";
|
||||
import { WorkspaceHeader } from "./workspace-header";
|
||||
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||
|
||||
export function WorkspaceSidebar({
|
||||
compactChrome = false,
|
||||
forceCollapsed = false,
|
||||
@@ -29,13 +29,13 @@ export function WorkspaceSidebar({
|
||||
compactChrome?: boolean;
|
||||
forceCollapsed?: boolean;
|
||||
}) {
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { theme } = useTheme();
|
||||
const pathname = usePathname();
|
||||
const useWhiteSidebar = compactChrome && theme === "light";
|
||||
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
|
||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
||||
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { theme } = useTheme();
|
||||
const pathname = usePathname();
|
||||
const useWhiteSidebar = compactChrome && theme === "light";
|
||||
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
|
||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
||||
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
||||
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
|
||||
|
||||
if (forceCollapsed) {
|
||||
@@ -56,85 +56,85 @@ export function WorkspaceSidebar({
|
||||
);
|
||||
}
|
||||
|
||||
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
|
||||
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
|
||||
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
|
||||
// --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。
|
||||
if (compactChrome) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!isSidebarOpen}
|
||||
className={cn(
|
||||
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
|
||||
isSidebarOpen ? "w-(--sidebar-width)" : "w-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="none"
|
||||
className={cn(
|
||||
"h-full w-(--sidebar-width) border-r border-border",
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
{isGeneralQa ? (
|
||||
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
||||
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
||||
<GeneralQaSidebar />
|
||||
) : (
|
||||
<>
|
||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||
<RecentChatList compact />
|
||||
</>
|
||||
)}
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
// 紧凑模式:展开时为固定宽度整列(none,无内置折叠),收起由上面的条件渲染处理;
|
||||
// 非紧凑沿用 icon。
|
||||
collapsible={compactChrome ? "none" : "icon"}
|
||||
className={cn(
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{!compactChrome && (
|
||||
<SidebarHeader className="py-0">
|
||||
<WorkspaceHeader />
|
||||
</SidebarHeader>
|
||||
)}
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList
|
||||
showCollapseTrigger={compactChrome}
|
||||
compact={compactChrome}
|
||||
/>
|
||||
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
|
||||
{/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐:
|
||||
仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */}
|
||||
{isSidebarOpen && !compactChrome && <RecentRoundtableList />}
|
||||
{isSidebarOpen && !compactChrome && <RecentWritingList />}
|
||||
</SidebarContent>
|
||||
{!compactChrome && (
|
||||
<SidebarFooter>
|
||||
<WorkspaceNavMenu />
|
||||
</SidebarFooter>
|
||||
)}
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
|
||||
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
|
||||
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
|
||||
// --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。
|
||||
if (compactChrome) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!isSidebarOpen}
|
||||
className={cn(
|
||||
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
|
||||
isSidebarOpen ? "w-(--sidebar-width)" : "w-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="none"
|
||||
className={cn(
|
||||
"h-full w-(--sidebar-width) border-r border-border",
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
{isGeneralQa ? (
|
||||
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
||||
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
||||
<GeneralQaSidebar />
|
||||
) : (
|
||||
<>
|
||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||
<RecentChatList compact />
|
||||
</>
|
||||
)}
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
// 紧凑模式:展开时为固定宽度整列(none,无内置折叠),收起由上面的条件渲染处理;
|
||||
// 非紧凑沿用 icon。
|
||||
collapsible={compactChrome ? "none" : "icon"}
|
||||
className={cn(
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{!compactChrome && (
|
||||
<SidebarHeader className="py-0">
|
||||
<WorkspaceHeader />
|
||||
</SidebarHeader>
|
||||
)}
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList
|
||||
showCollapseTrigger={compactChrome}
|
||||
compact={compactChrome}
|
||||
/>
|
||||
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
|
||||
{/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐:
|
||||
仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */}
|
||||
{isSidebarOpen && !compactChrome && <RecentRoundtableList />}
|
||||
{isSidebarOpen && !compactChrome && <RecentWritingList />}
|
||||
</SidebarContent>
|
||||
{!compactChrome && (
|
||||
<SidebarFooter>
|
||||
<WorkspaceNavMenu />
|
||||
</SidebarFooter>
|
||||
)}
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -61,9 +61,9 @@ export interface Agent {
|
||||
/** 会商席位「技能识别强化」开关。作为会商席位运行时,是否把本智能体配置的技能
|
||||
* (名称 + SKILL.md 路径 + 必须先 read_file 的硬指令) 拼进本轮任务以补偿弱模型。
|
||||
* 与业务链条同名开关取 OR。null/false = 关 (默认)。 */
|
||||
seat_skill_directive?: boolean | null;
|
||||
/** DeerFlow LLMWiki mapping ids this agent allows conversations to select. */
|
||||
llmwiki_knowledge_base_ids?: string[] | null;
|
||||
seat_skill_directive?: boolean | null;
|
||||
/** DeerFlow LLMWiki mapping ids this agent allows conversations to select. */
|
||||
llmwiki_knowledge_base_ids?: string[] | null;
|
||||
tags?: Tag[];
|
||||
featured_order?: number | null;
|
||||
/** Whether the current user has favorited (收藏) this agent. */
|
||||
@@ -98,8 +98,8 @@ export interface CreateAgentRequest {
|
||||
common_questions?: AgentQuestion[] | null;
|
||||
template_name?: string | null;
|
||||
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
|
||||
seat_skill_directive?: boolean | null;
|
||||
llmwiki_knowledge_base_ids?: string[] | null;
|
||||
seat_skill_directive?: boolean | null;
|
||||
llmwiki_knowledge_base_ids?: string[] | null;
|
||||
}
|
||||
|
||||
export interface UpdateAgentRequest {
|
||||
@@ -116,6 +116,6 @@ export interface UpdateAgentRequest {
|
||||
/** 入库模板名;传空字符串表示清除。 */
|
||||
template_name?: string | null;
|
||||
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
|
||||
seat_skill_directive?: boolean | null;
|
||||
llmwiki_knowledge_base_ids?: string[] | null;
|
||||
}
|
||||
seat_skill_directive?: boolean | null;
|
||||
llmwiki_knowledge_base_ids?: string[] | null;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
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
|
||||
* Body: { title?: string, markdown: string }
|
||||
*
|
||||
* 返回 `application/vnd.openxmlformats-officedocument.wordprocessingml.document`,
|
||||
* 文件名在 `Content-Disposition` 中。
|
||||
* All download surfaces use the same required Gateway endpoint. The Gateway
|
||||
* applies the formal OOXML profile and embeds its bundled title font; there is
|
||||
* deliberately no client-font fallback.
|
||||
*/
|
||||
export async function downloadMarkdownDocxFromBackend(options: {
|
||||
title?: string;
|
||||
markdown: string;
|
||||
}): Promise<void> {
|
||||
const markdown = options.markdown.trim();
|
||||
if (!markdown) {
|
||||
throw new Error("文档内容为空");
|
||||
}
|
||||
if (!markdown) throw new Error("文档内容为空");
|
||||
|
||||
const fallbackFilename = sanitizeFallbackFilename(options.title);
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/writing/export/docx`, {
|
||||
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;
|
||||
}
|
||||
await downloadMarkdownAsDocx(
|
||||
markdown,
|
||||
options.title?.trim() || "document",
|
||||
options.title,
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,25 @@
|
||||
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 { isImageFile } from "../utils/files";
|
||||
|
||||
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
|
||||
import {
|
||||
loadArtifactContent,
|
||||
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";
|
||||
|
||||
function isWriteFileStreamingUrl(filepath: string): boolean {
|
||||
if (!filepath.startsWith("write-file:")) {
|
||||
return false;
|
||||
}
|
||||
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) {
|
||||
function hasToolResult(
|
||||
thread: ReturnType<typeof useThread>["thread"],
|
||||
toolCallId?: string,
|
||||
) {
|
||||
if (!toolCallId) {
|
||||
return false;
|
||||
}
|
||||
@@ -76,6 +74,48 @@ function applyPendingTextEdit({
|
||||
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({
|
||||
filepath,
|
||||
threadId,
|
||||
@@ -89,19 +129,32 @@ export function useArtifactContent({
|
||||
return isWriteFileStreamingUrl(filepath);
|
||||
}, [filepath]);
|
||||
const { thread, isMock, publicShareCode } = useThread();
|
||||
const content = useMemo(() => {
|
||||
if (useWriteFileStreaming) {
|
||||
return loadArtifactContentFromToolCall({ url: filepath, thread });
|
||||
const streamPerfEnabled = isSandboxStreamPerfEnabled();
|
||||
const liveWriteFileContent = useWriteFileStreaming
|
||||
? 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;
|
||||
}, [filepath, useWriteFileStreaming, thread]);
|
||||
return loadArtifactContentFromLatestWriteFile({ filepath, thread });
|
||||
}, [filepath, thread, useWriteFileStreaming]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["artifact", filepath, threadId, isMock, publicShareCode],
|
||||
queryFn: () => {
|
||||
return loadArtifactContent({ filepath, threadId, isMock, publicShareCode });
|
||||
},
|
||||
enabled,
|
||||
enabled: Boolean(enabled) && !useWriteFileStreaming && isArtifactApiPath(filepath),
|
||||
refetchInterval: thread.isLoading ? 1000 : false,
|
||||
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
|
||||
staleTime: 5 * 60 * 1000,
|
||||
@@ -120,11 +173,12 @@ export function useArtifactContent({
|
||||
return {
|
||||
content: useWriteFileStreaming
|
||||
? content
|
||||
: (pendingEdit?.content ?? data?.content),
|
||||
: (pendingEdit?.content ?? data?.content ?? toolCallFallbackContent),
|
||||
url: useWriteFileStreaming ? undefined : data?.url,
|
||||
isLoading,
|
||||
isApplyingEdit: pendingEdit?.kind === "editing",
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface ArtifactListingFile {
|
||||
path: string;
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
mimeType: string | null;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
export interface ArtifactListing {
|
||||
threadId: string;
|
||||
files: ArtifactListingFile[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the thread's outputs directory instead of relying only on artifacts
|
||||
* explicitly surfaced by `present_files`. This is used by file mode so files
|
||||
* written by any tool are still discoverable from the right-hand directory.
|
||||
*/
|
||||
export async function listThreadArtifacts(
|
||||
threadId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ArtifactListing> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts`,
|
||||
{ method: "GET", signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Failed to list artifacts (${response.status})${detail ? `: ${detail}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
thread_id?: string;
|
||||
files?: Array<{
|
||||
path: string;
|
||||
name: string;
|
||||
size_bytes: number;
|
||||
mime_type: string | null;
|
||||
modified_at: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
return {
|
||||
threadId: data.thread_id ?? threadId,
|
||||
files: (data.files ?? []).map((file) => ({
|
||||
path: file.path,
|
||||
name: file.name,
|
||||
sizeBytes: file.size_bytes,
|
||||
mimeType: file.mime_type,
|
||||
modifiedAt: file.modified_at,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Return the generated file path represented by a real or streaming artifact. */
|
||||
export function artifactPathname(artifact: string): string {
|
||||
const trimmed = artifact.trim();
|
||||
if (!trimmed.startsWith("write-file:")) {
|
||||
return trimmed.replace(/^\/+/, "");
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(new URL(trimmed).pathname).replace(/^\/+/, "");
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
export function artifactRelativePath(artifact: string): string {
|
||||
const pathname = artifactPathname(artifact);
|
||||
return pathname.replace(/^mnt\/user-data\/outputs\/?/i, "") || pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the checkpoint order, append files found only on disk from old to new,
|
||||
* and replace a completed streaming URL with its durable path.
|
||||
*/
|
||||
export function mergeArtifactPaths(
|
||||
checkpointArtifacts: string[],
|
||||
discoveredArtifacts: string[],
|
||||
): string[] {
|
||||
const merged: string[] = [];
|
||||
const indexByPath = new Map<string, number>();
|
||||
|
||||
for (const artifact of checkpointArtifacts) {
|
||||
const key = artifactPathname(artifact);
|
||||
if (!key || indexByPath.has(key)) continue;
|
||||
indexByPath.set(key, merged.length);
|
||||
merged.push(artifact);
|
||||
}
|
||||
|
||||
// The listing endpoint returns newest first. Append in chronological order
|
||||
// so the last item remains the newest artifact for the existing auto-open.
|
||||
for (const artifact of [...discoveredArtifacts].reverse()) {
|
||||
const key = artifactPathname(artifact);
|
||||
if (!key) continue;
|
||||
const existingIndex = indexByPath.get(key);
|
||||
if (existingIndex !== undefined) {
|
||||
if (merged[existingIndex]?.startsWith("write-file:")) {
|
||||
merged[existingIndex] = artifact;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
indexByPath.set(key, merged.length);
|
||||
merged.push(artifact);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export async function loadArtifactContent({
|
||||
return { content: text, url };
|
||||
}
|
||||
|
||||
export function loadArtifactContentFromToolCall({
|
||||
export function loadArtifactContentFromToolCall({
|
||||
url: urlString,
|
||||
thread,
|
||||
}: {
|
||||
@@ -66,5 +66,58 @@ export function loadArtifactContentFromToolCall({
|
||||
return toolCall.args.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeArtifactPath(path: string): string {
|
||||
let normalized = path.trim();
|
||||
const first = normalized[0];
|
||||
const last = normalized.at(-1);
|
||||
if (
|
||||
normalized.length >= 2 &&
|
||||
((first === '"' && last === '"') ||
|
||||
(first === "'" && last === "'") ||
|
||||
(first === "`" && last === "`"))
|
||||
) {
|
||||
normalized = normalized.slice(1, -1).trim();
|
||||
}
|
||||
return normalized.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-resort preview for an output whose artifact HTTP request is unavailable.
|
||||
*
|
||||
* A completed `write_file` can be selected before its backing sandbox file is
|
||||
* visible through the artifacts endpoint (or when a deployment temporarily
|
||||
* serves a stale mount). The original tool arguments still contain the
|
||||
* Markdown, so use the newest matching write as a read-only display fallback.
|
||||
*/
|
||||
export function loadArtifactContentFromLatestWriteFile({
|
||||
filepath,
|
||||
thread,
|
||||
}: {
|
||||
filepath: string;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
}): string | undefined {
|
||||
const normalizedFilepath = normalizeArtifactPath(filepath);
|
||||
if (!normalizedFilepath) return undefined;
|
||||
|
||||
for (const message of [...thread.messages].reverse()) {
|
||||
if (message.type !== "ai" || !message.tool_calls) continue;
|
||||
for (const toolCall of [...message.tool_calls].reverse()) {
|
||||
if (toolCall.name !== "write_file") continue;
|
||||
if (!toolCall.args || typeof toolCall.args !== "object" || Array.isArray(toolCall.args)) {
|
||||
continue;
|
||||
}
|
||||
const args = toolCall.args as { path?: unknown; content?: unknown };
|
||||
if (
|
||||
typeof args.path === "string" &&
|
||||
typeof args.content === "string" &&
|
||||
normalizeArtifactPath(args.path) === normalizedFilepath
|
||||
) {
|
||||
return args.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
SANDBOX_STREAM_PERF_QUERY,
|
||||
resolveSandboxStreamPerfEnabled,
|
||||
} from "./sandbox-stream-perf";
|
||||
|
||||
test("sandbox stream perf defaults to on", () => {
|
||||
assert.equal(resolveSandboxStreamPerfEnabled({}), true);
|
||||
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: null }), true);
|
||||
});
|
||||
|
||||
test("sandbox stream perf can be rolled back from hash or search query", () => {
|
||||
assert.equal(
|
||||
resolveSandboxStreamPerfEnabled({
|
||||
hash: "#/page/workspace/chats/new?sandboxStreamPerf=0",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
resolveSandboxStreamPerfEnabled({
|
||||
search: `?${SANDBOX_STREAM_PERF_QUERY}=off`,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
resolveSandboxStreamPerfEnabled({
|
||||
hash: "#/page/workspace/agents/abc/chats/def?foo=1&sandboxStreamPerf=false",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("sandbox stream perf query wins over localStorage", () => {
|
||||
assert.equal(
|
||||
resolveSandboxStreamPerfEnabled({
|
||||
hash: "#/page/workspace/chats/new?sandboxStreamPerf=1",
|
||||
storageValue: "0",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("sandbox stream perf can be rolled back from localStorage", () => {
|
||||
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: "0" }), false);
|
||||
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: "off" }), false);
|
||||
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: "1" }), true);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Sandbox streaming performance guard (write_file live preview).
|
||||
*
|
||||
* Default ON. Rollback without a rebuild:
|
||||
* 1. Append `?sandboxStreamPerf=0` to the page URL and refresh
|
||||
* e.g. `/#/page/workspace/chats/new?sandboxStreamPerf=0`
|
||||
* 2. Or in the console:
|
||||
* localStorage.setItem("deerflow.sandbox-stream-perf", "0"); location.reload();
|
||||
*
|
||||
* Restore the optimized path:
|
||||
* localStorage.removeItem("deerflow.sandbox-stream-perf") and drop the query, then refresh.
|
||||
*/
|
||||
export const SANDBOX_STREAM_PERF_STORAGE_KEY = "deerflow.sandbox-stream-perf";
|
||||
export const SANDBOX_STREAM_PERF_QUERY = "sandboxStreamPerf";
|
||||
/** Aligns with roundtable write_file preview (~12fps). */
|
||||
export const SANDBOX_STREAM_FLUSH_MS = 80;
|
||||
export const SANDBOX_LEGACY_SCROLL_INTERVAL_MS = 180;
|
||||
|
||||
function parseFlag(raw: string | null | undefined): boolean | null {
|
||||
if (raw == null) return null;
|
||||
const value = raw.trim().toLowerCase();
|
||||
if (value === "0" || value === "off" || value === "false" || value === "no") {
|
||||
return false;
|
||||
}
|
||||
if (value === "1" || value === "on" || value === "true" || value === "yes") {
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function queryValue(search: string, key: string): string | null {
|
||||
if (!search) return null;
|
||||
const normalized = search.startsWith("?") ? search.slice(1) : search;
|
||||
return new URLSearchParams(normalized).get(key);
|
||||
}
|
||||
|
||||
export function resolveSandboxStreamPerfEnabled(input: {
|
||||
search?: string;
|
||||
hash?: string;
|
||||
storageValue?: string | null;
|
||||
}): boolean {
|
||||
const searchFlag = parseFlag(queryValue(input.search ?? "", SANDBOX_STREAM_PERF_QUERY));
|
||||
if (searchFlag != null) return searchFlag;
|
||||
|
||||
const hash = input.hash ?? "";
|
||||
const hashQueryIndex = hash.indexOf("?");
|
||||
if (hashQueryIndex >= 0) {
|
||||
const hashFlag = parseFlag(
|
||||
queryValue(hash.slice(hashQueryIndex), SANDBOX_STREAM_PERF_QUERY),
|
||||
);
|
||||
if (hashFlag != null) return hashFlag;
|
||||
}
|
||||
|
||||
const storedFlag = parseFlag(input.storageValue);
|
||||
if (storedFlag != null) return storedFlag;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isSandboxStreamPerfEnabled(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
let storageValue: string | null = null;
|
||||
try {
|
||||
storageValue = window.localStorage.getItem(SANDBOX_STREAM_PERF_STORAGE_KEY);
|
||||
} catch {
|
||||
storageValue = null;
|
||||
}
|
||||
return resolveSandboxStreamPerfEnabled({
|
||||
search: window.location.search,
|
||||
hash: window.location.hash,
|
||||
storageValue,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSandboxFollowScroll({
|
||||
follow,
|
||||
scrollToBottom,
|
||||
contentKey,
|
||||
viewMode,
|
||||
}: {
|
||||
follow: boolean;
|
||||
scrollToBottom: (behavior?: ScrollBehavior) => boolean;
|
||||
contentKey: string;
|
||||
viewMode: string;
|
||||
}) {
|
||||
const perfEnabled = isSandboxStreamPerfEnabled();
|
||||
|
||||
useEffect(() => {
|
||||
if (!perfEnabled || !follow) return;
|
||||
let raf = 0;
|
||||
raf = requestAnimationFrame(() => {
|
||||
raf = 0;
|
||||
scrollToBottom("auto");
|
||||
});
|
||||
return () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [perfEnabled, follow, contentKey, viewMode, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (perfEnabled || !follow) return;
|
||||
const tick = () => {
|
||||
scrollToBottom("auto");
|
||||
};
|
||||
tick();
|
||||
const intervalId = window.setInterval(tick, SANDBOX_LEGACY_SCROLL_INTERVAL_MS);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [perfEnabled, follow, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (perfEnabled || !follow) return;
|
||||
scrollToBottom("auto");
|
||||
}, [perfEnabled, follow, contentKey, viewMode, scrollToBottom]);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
isArtifactApiPath,
|
||||
isProvisionalStreamingArtifactPath,
|
||||
provisionalWriteFilePath,
|
||||
} from "./streaming-write-preview";
|
||||
|
||||
test("provisionalWriteFilePath uses html only for html documents", () => {
|
||||
assert.equal(provisionalWriteFilePath("# title"), "未命名文件.md");
|
||||
assert.equal(provisionalWriteFilePath("<!DOCTYPE html><html>"), "未命名文件.html");
|
||||
assert.equal(provisionalWriteFilePath("<html lang='zh'>"), "未命名文件.html");
|
||||
});
|
||||
|
||||
test("isProvisionalStreamingArtifactPath matches the streaming placeholder names", () => {
|
||||
assert.equal(isProvisionalStreamingArtifactPath("未命名文件.md"), true);
|
||||
assert.equal(isProvisionalStreamingArtifactPath("/未命名文件.html"), true);
|
||||
assert.equal(isProvisionalStreamingArtifactPath("mnt/user-data/outputs/report.md"), false);
|
||||
});
|
||||
|
||||
test("isArtifactApiPath rejects write-file URLs and placeholder names", () => {
|
||||
assert.equal(isArtifactApiPath("未命名文件.md"), false);
|
||||
assert.equal(
|
||||
isArtifactApiPath(
|
||||
"write-file:未命名文件.md?message_id=m1&tool_call_id=t1&language=markdown",
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(isArtifactApiPath("mnt/user-data/outputs/report.md"), true);
|
||||
assert.equal(isArtifactApiPath("/mnt/user-data/outputs/report.md"), true);
|
||||
});
|
||||
@@ -23,6 +23,11 @@ export function hasCompleteArtifactExtension(path: string): boolean {
|
||||
return /\.[a-z0-9]{2,12}$/i.test(filename);
|
||||
}
|
||||
|
||||
export const PROVISIONAL_WRITE_FILE_NAMES = new Set([
|
||||
"未命名文件.md",
|
||||
"未命名文件.html",
|
||||
]);
|
||||
|
||||
export function provisionalWriteFilePath(content: string): string {
|
||||
const head = content.slice(0, 256).trimStart().toLowerCase();
|
||||
if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
|
||||
@@ -31,6 +36,29 @@ export function provisionalWriteFilePath(content: string): string {
|
||||
return "未命名文件.md";
|
||||
}
|
||||
|
||||
export function isProvisionalStreamingArtifactPath(path: string): boolean {
|
||||
const pathname =
|
||||
normalizeStreamingArtifactPath(path).split(/[?#]/, 1)[0] ?? "";
|
||||
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
|
||||
return PROVISIONAL_WRITE_FILE_NAMES.has(filename);
|
||||
}
|
||||
|
||||
/** Paths the artifacts HTTP API can actually serve (sandbox virtual paths). */
|
||||
export function isArtifactApiPath(path: string): boolean {
|
||||
if (!path || path.startsWith("write-file:")) {
|
||||
return false;
|
||||
}
|
||||
if (isProvisionalStreamingArtifactPath(path)) {
|
||||
return false;
|
||||
}
|
||||
const normalized = path.replace(/^\/+/, "").replace(/\\/g, "/");
|
||||
return (
|
||||
normalized === "mnt/user-data" ||
|
||||
normalized.startsWith("mnt/user-data/") ||
|
||||
normalized.startsWith("mnt/skills/")
|
||||
);
|
||||
}
|
||||
|
||||
export function languageOfStreamingArtifact(
|
||||
path: string,
|
||||
): StreamingArtifactLanguage | null {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { isWriteFileInFlight, resolveWriteFilePath } from "./write-file-in-flight";
|
||||
|
||||
test("resolveWriteFilePath reads the virtual URL pathname", () => {
|
||||
assert.equal(
|
||||
resolveWriteFilePath("write-file:report.md?message_id=m1&tool_call_id=t1"),
|
||||
"report.md",
|
||||
);
|
||||
assert.equal(resolveWriteFilePath("mnt/report.md"), "mnt/report.md");
|
||||
});
|
||||
|
||||
test("isWriteFileInFlight is true only until that file's tool result arrives", () => {
|
||||
const inFlight = {
|
||||
messages: [
|
||||
{
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{ id: "w1", name: "write_file", args: { path: "report.md" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert.equal(isWriteFileInFlight(inFlight, "write-file:report.md?tool_call_id=w1"), true);
|
||||
|
||||
const completed = {
|
||||
messages: [
|
||||
{
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{ id: "w1", name: "write_file", args: { path: "report.md" } },
|
||||
],
|
||||
},
|
||||
{ type: "tool", tool_call_id: "w1", name: "write_file" },
|
||||
],
|
||||
};
|
||||
assert.equal(
|
||||
isWriteFileInFlight(completed, "write-file:report.md?tool_call_id=w1"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("a later thread turn does not treat a finished write as in-flight", () => {
|
||||
const laterTurn = {
|
||||
messages: [
|
||||
{
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{ id: "w1", name: "write_file", args: { path: "report.md" } },
|
||||
],
|
||||
},
|
||||
{ type: "tool", tool_call_id: "w1", name: "write_file" },
|
||||
{ type: "human", content: "按方案 A" },
|
||||
{
|
||||
type: "ai",
|
||||
tool_calls: [{ id: "c2", name: "web_search", args: { query: "x" } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert.equal(
|
||||
isWriteFileInFlight(laterTurn, "write-file:report.md?tool_call_id=w1"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { isImageFile } from "../utils/files";
|
||||
|
||||
export function isWriteFileStreamingUrl(filepath: string): boolean {
|
||||
if (!filepath.startsWith("write-file:")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const url = new URL(filepath);
|
||||
const path = decodeURIComponent(url.pathname);
|
||||
return !isImageFile(path);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWriteFilePath(filepath: string): string {
|
||||
if (filepath.startsWith("write-file:")) {
|
||||
try {
|
||||
return decodeURIComponent(new URL(filepath).pathname).trim();
|
||||
} catch {
|
||||
const rest = filepath.slice("write-file:".length);
|
||||
return rest.split(/[?#]/, 1)[0]?.trim() ?? filepath;
|
||||
}
|
||||
}
|
||||
return filepath.trim();
|
||||
}
|
||||
|
||||
type WriteFileThreadLike = {
|
||||
messages: Array<{
|
||||
type?: string;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
args?: unknown;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
|
||||
function hasToolResult(thread: WriteFileThreadLike, toolCallId?: string) {
|
||||
if (!toolCallId) {
|
||||
return false;
|
||||
}
|
||||
return thread.messages.some(
|
||||
(message) => message.type === "tool" && message.tool_call_id === toolCallId,
|
||||
);
|
||||
}
|
||||
|
||||
/** True only while THIS file's write_file call has not yet returned a tool result. */
|
||||
export function isWriteFileInFlight(
|
||||
thread: WriteFileThreadLike,
|
||||
filepath: string,
|
||||
): boolean {
|
||||
const path = resolveWriteFilePath(filepath);
|
||||
if (!path) {
|
||||
return false;
|
||||
}
|
||||
for (const message of thread.messages) {
|
||||
if (message.type !== "ai" || !message.tool_calls) {
|
||||
continue;
|
||||
}
|
||||
for (const toolCall of message.tool_calls) {
|
||||
if (toolCall.name !== "write_file") {
|
||||
continue;
|
||||
}
|
||||
const args = toolCall.args as { path?: unknown } | undefined;
|
||||
const callPath = typeof args?.path === "string" ? args.path.trim() : "";
|
||||
if (callPath !== path) {
|
||||
continue;
|
||||
}
|
||||
if (!hasToolResult(thread, toolCall.id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 「agentfx」任务深链:复制自 [[task-deeplink]] 的 3qfx 单智能体模式,供新系统 iframe 嵌入
|
||||
* (``${loginBase}?password=xxx&taskId=xxx&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue``)。
|
||||
*
|
||||
* 与 3qfx 的差异(也是单独复制一份的原因,后续逻辑改动互不影响):
|
||||
* 1. 任务详情不走外部 consumer(``VITE_AIPI_CONSUMER_URL``),改调本系统 Gateway 的
|
||||
* ``GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=``(带 DeerFlow 登录态,任务数据
|
||||
* 来自 TaskCOP 任务表,即任务列表 / 报告导入用的同一份)。
|
||||
* 2. **固定使用内置智能体** ``agentfx-analyst``(后端 seeder ``_agentfx_seed.py`` 启动时补建:
|
||||
* 搜集信息 → 生成 report.md + task-reports.json(14 类)→ 总结 → 前端「入库」协助卡 /
|
||||
* task-report-import 技能入库),不走「配置管理」业务映射。
|
||||
* 3. 路由挂在 ``plan-task-agentfx`` 前缀下,页面代码见 [[AgentFxWorkspace]]。
|
||||
*
|
||||
* 任务详情整包仍暂存到与 3qfx 相同的 sessionStorage 键(``login.copTaskDetail``,见
|
||||
* [[persistCopTaskDetail]]):该键按标签页隔离(嵌入态无跨标签污染),工作区「当前任务」
|
||||
* 卡片与 AgentChatPage 的 taskBind 自恢复逻辑都读它,保持行为一致。
|
||||
*/
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { clearTaskContext, persistTaskContext } from "@/core/auth/task-purpose";
|
||||
import {
|
||||
type CopTaskDetail,
|
||||
type TaskDeeplinkTarget,
|
||||
} from "@/core/auth/task-deeplink";
|
||||
|
||||
/** 深链 goPath 基础码(大小写不敏感匹配;不支持 ``-A`` 会商后缀)。 */
|
||||
export const AGENTFX_GOPATH = "agentfx";
|
||||
|
||||
/**
|
||||
* 固定使用的内置智能体 id(后端 ``_agentfx_seed.py`` 启动时补建并 upsert 进 agents 表,
|
||||
* ``user_id IS NULL`` 对所有用户可见)。前端深链不查业务映射,直接进该智能体的问答。
|
||||
*/
|
||||
export const AGENTFX_BUILTIN_AGENT_ID = "agentfx-analyst";
|
||||
|
||||
/** 工作区路由前缀(与 StrategyRoutes 的 plan-task-agentfx 子树一致)。 */
|
||||
export const AGENTFX_TASK_BASE = "/page/strategy/task-management/plan-task-agentfx";
|
||||
|
||||
/** 判断 goPath 是否为 agentfx(大小写不敏感)。 */
|
||||
export function isAgentfxGoPath(goPath: string): boolean {
|
||||
return goPath.trim().toLowerCase() === AGENTFX_GOPATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取任务详情:本系统 Gateway 的 cop-task-detail(返回与旧 consumer 同构的
|
||||
* ``{state, msg, data}`` 信封)。任务不存在 / 网络失败时返回 null,调用方(LoginPage)
|
||||
* 用仅含任务 id 的 stub 兜底继续跳转。
|
||||
*/
|
||||
export async function fetchAgentfxTaskDetail(taskId: string): Promise<CopTaskDetail | null> {
|
||||
if (!taskId) return null;
|
||||
try {
|
||||
// apiFetch 已统一注入 DeerFlow 登录态(Authorization / cookie),登录刚完成即可用。
|
||||
const query = new URLSearchParams({ taskId });
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/taskAnalyseSearch/cop-task-detail?${query.toString()}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.warn("[agentfx-deeplink] cop-task-detail failed", { taskId, status: res.status });
|
||||
return null;
|
||||
}
|
||||
const body = (await res.json().catch(() => null)) as { data?: unknown } | null;
|
||||
const data = body && typeof body === "object" ? body.data : null;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const d = data as Record<string, unknown>;
|
||||
return {
|
||||
id: (d.id as string | number | null) ?? null,
|
||||
overview: typeof d.overview === "string" ? d.overview : "",
|
||||
taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "",
|
||||
taskName: typeof d.taskName === "string" ? d.taskName : "",
|
||||
taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[agentfx-deeplink] cop-task-detail failed", { taskId, error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 taskName / taskContent 拼开场信息文本;末尾附任务 id(task_id)供后端识别任务。
|
||||
* 复制自 task-deeplink 的 buildOpeningText 基础三行(无 xdfx 行动追加),便于后续按新系统
|
||||
* 要求单独调整格式。
|
||||
*/
|
||||
export function buildAgentfxOpeningText(detail: CopTaskDetail, taskId: string): string {
|
||||
return [
|
||||
detail.taskName ? `任务名称:${detail.taskName}` : "",
|
||||
detail.taskContent ? `任务内容:${detail.taskContent}` : "",
|
||||
taskId ? `任务id(task_id)为${taskId}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 agentfx 跳转目标:内置「任务研判报告智能体」的问答工作区(任务↔对话 1:N,按
|
||||
* taskId + agentId 隔离)。清残留任务上下文 → 暂存任务上下文(taskId + agentId + 开场
|
||||
* 信息)→ 返回 chats 索引路径,由工作区决定开最新历史对话 / 新建(首次无历史自动发
|
||||
* 开场信息,见 AgentChatPage 的 taskBind)。
|
||||
*/
|
||||
export async function resolveAgentfxDeeplinkTarget(
|
||||
detail: CopTaskDetail,
|
||||
taskId: string,
|
||||
): Promise<TaskDeeplinkTarget | null> {
|
||||
// 清掉上一次深链可能残留的任务上下文(下面用本次任务重新写入)。
|
||||
clearTaskContext();
|
||||
|
||||
const agentId = AGENTFX_BUILTIN_AGENT_ID;
|
||||
persistTaskContext({
|
||||
taskId,
|
||||
agentId,
|
||||
openingText: buildAgentfxOpeningText(detail, taskId),
|
||||
actionId: taskId,
|
||||
partitionByAction: false,
|
||||
});
|
||||
return { path: `${AGENTFX_TASK_BASE}/agent/${agentId}/chats` };
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* 大屏绘制智能体的任务输入适配层。
|
||||
*
|
||||
* 报告详情由 Gateway 的 `/api/task-reports/by-task/{taskId}` 提供,返回格式
|
||||
* 与原 `selectSqReport` 一致:`{state, msg, data}`。每条 `contentJson` 是
|
||||
* 报告详情由 Consumer 的 `/cop/selectSqReport?taskId=` 提供,返回格式为
|
||||
* `{state, msg, data}`。每条 `contentJson` 是
|
||||
* JSON 字符串,本模块将其按 `categoryType` 解析为智能体和大屏页面可直接使用的
|
||||
* 结构化输入。
|
||||
*/
|
||||
|
||||
import { getTaskReport } from "@/strategy-components/api/task-reports";
|
||||
import { readRuntimeFlag } from "@/env";
|
||||
import { readRuntimeEnv, readRuntimeFlag } from "@/env";
|
||||
import { requestCrudService } from "@/service/request-axios-service";
|
||||
import { SQ_REPORT_MOCK_RESPONSE } from "@/strategy-components/components/task-components/sq-report-mock-data";
|
||||
|
||||
/** 保留给既有调用方的 sessionStorage key;报告本身不再使用 mock 缓存。 */
|
||||
@@ -32,9 +32,15 @@ function categoryKey(categoryType: unknown, index: number): string {
|
||||
return cleaned || `分类${index + 1}`;
|
||||
}
|
||||
|
||||
/** Consumer 研判情报报告接口完整地址。 */
|
||||
function sqReportUrl(): string {
|
||||
const base = readRuntimeEnv("VITE_AIPI_CONSUMER_URL")?.replace(/\/+$/, "") ?? "";
|
||||
return base ? `${base}/cop/selectSqReport` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 将接口 envelope 提取为 `{ taskId, source, categories }`。
|
||||
* 返回 null 表示该任务尚未保存可用的报告内容。
|
||||
* 返回 null 表示接口没有返回可用的报告内容。
|
||||
*/
|
||||
export function extractSqReport(body: unknown, taskId: string): unknown | null {
|
||||
const data = (body && typeof body === "object" ? (body as { data?: unknown }).data : null) as
|
||||
@@ -51,7 +57,7 @@ export function extractSqReport(body: unknown, taskId: string): unknown | null {
|
||||
categories[key] = content;
|
||||
});
|
||||
if (Object.keys(categories).length === 0) return null;
|
||||
return { taskId, source: "api/task-reports", categories };
|
||||
return { taskId, source: "cop/selectSqReport", categories };
|
||||
}
|
||||
|
||||
/** 兼容旧调用方:报告由接口实时读取,不再在浏览器中缓存。 */
|
||||
@@ -70,8 +76,7 @@ export async function fetchDashboardInputJson(
|
||||
_opts?: { forceRefresh?: boolean },
|
||||
): Promise<unknown | null> {
|
||||
if (!taskId) return null;
|
||||
// 开发演示模式使用与 Gateway 接口同形的本地样例;这样不依赖报告库是否已为
|
||||
// 当前深链 taskId 写入数据,同时真实/生产环境仍始终从 Gateway 读取。
|
||||
// 开发演示模式不发外部请求,直接返回与 Consumer 接口同形的本地样例。
|
||||
if (readRuntimeFlag("VITE_MOCK_SQ_REPORT")) {
|
||||
return extractSqReport(
|
||||
{
|
||||
@@ -81,7 +86,9 @@ export async function fetchDashboardInputJson(
|
||||
taskId,
|
||||
);
|
||||
}
|
||||
const body = await getTaskReport(taskId);
|
||||
const url = sqReportUrl();
|
||||
if (!url) return null;
|
||||
const body = await requestCrudService.get(url, { taskId });
|
||||
return extractSqReport(body, taskId);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import { authStorageGet, authStorageSet } from "@/core/auth/scoped-storage";
|
||||
import type { ThemeType } from "@/contexts/ThemeContext";
|
||||
|
||||
export const EXTERNAL_LOGIN_STORAGE_KEYS = {
|
||||
ticket: "login.ticket",
|
||||
@@ -58,6 +59,21 @@ export function readExternalLoginParams(): Required<ExternalLoginParams> {
|
||||
* - ``blue`` → 暂无该主题,先用浅色代替(命中 else 分支)
|
||||
* 其余/缺省同样回退浅色。将来若新增 blue 主题,在此扩展映射即可。
|
||||
*/
|
||||
export function resolveThemeFromQuery(themeParam: string | null | undefined): "dark" | "light" {
|
||||
return themeParam === "dark-blue" ? "dark" : "light";
|
||||
export function resolveThemeFromQuery(themeParam: string | null | undefined): ThemeType {
|
||||
switch (themeParam?.trim().toLowerCase()) {
|
||||
case "dark":
|
||||
case "dark-blue":
|
||||
case "darkblue":
|
||||
return "dark";
|
||||
case "blue":
|
||||
case "lightblue":
|
||||
case "light-blue":
|
||||
return "lightblue";
|
||||
case "green":
|
||||
case "lightgreen":
|
||||
case "light-green":
|
||||
return "lightgreen";
|
||||
default:
|
||||
return "light";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
HOST_NAVIGATE_TYPE,
|
||||
buildHostNavigateMessage,
|
||||
isHostNavigatePath,
|
||||
parseHostTaskId,
|
||||
resolveHostNavigateOrigin,
|
||||
} from "./host-navigate";
|
||||
|
||||
test("isHostNavigatePath accepts a host route", () => {
|
||||
assert.equal(isHostNavigatePath("/task/item-aiceh/step-analysis-audience"), true);
|
||||
assert.equal(isHostNavigatePath(" /task/foo "), true);
|
||||
});
|
||||
|
||||
test("isHostNavigatePath rejects urls and protocol-relative paths", () => {
|
||||
assert.equal(isHostNavigatePath(""), false);
|
||||
assert.equal(isHostNavigatePath("https://localhost:4912/task"), false);
|
||||
assert.equal(isHostNavigatePath("//evil.example/task"), false);
|
||||
assert.equal(isHostNavigatePath("task/foo"), false);
|
||||
assert.equal(isHostNavigatePath("/task\\foo"), false);
|
||||
});
|
||||
|
||||
test("parseHostTaskId returns a number", () => {
|
||||
assert.equal(parseHostTaskId("108"), 108);
|
||||
assert.equal(parseHostTaskId(" 108 "), 108);
|
||||
assert.equal(parseHostTaskId("0108"), 108);
|
||||
});
|
||||
|
||||
test("parseHostTaskId rejects non-integers", () => {
|
||||
assert.equal(parseHostTaskId(""), null);
|
||||
assert.equal(parseHostTaskId("abc"), null);
|
||||
assert.equal(parseHostTaskId("108.5"), null);
|
||||
assert.equal(parseHostTaskId("12e3"), null);
|
||||
assert.equal(parseHostTaskId(null), null);
|
||||
});
|
||||
|
||||
test("resolveHostNavigateOrigin prefers configured origin", () => {
|
||||
assert.equal(
|
||||
resolveHostNavigateOrigin({
|
||||
configured: "https://localhost:4912",
|
||||
ancestorOrigin: "https://other.example",
|
||||
referrer: "https://referrer.example/page",
|
||||
selfOrigin: "http://localhost:5174",
|
||||
}),
|
||||
"https://localhost:4912",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveHostNavigateOrigin strips a path from configured origin", () => {
|
||||
assert.equal(
|
||||
resolveHostNavigateOrigin({ configured: "https://localhost:4912/app" }),
|
||||
"https://localhost:4912",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveHostNavigateOrigin falls back to ancestorOrigins then referrer", () => {
|
||||
assert.equal(
|
||||
resolveHostNavigateOrigin({
|
||||
ancestorOrigin: "https://host.example",
|
||||
referrer: "https://referrer.example/",
|
||||
selfOrigin: "http://localhost:5174",
|
||||
}),
|
||||
"https://host.example",
|
||||
);
|
||||
assert.equal(
|
||||
resolveHostNavigateOrigin({
|
||||
referrer: "https://referrer.example/page",
|
||||
selfOrigin: "http://localhost:5174",
|
||||
}),
|
||||
"https://referrer.example",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveHostNavigateOrigin never uses * or same-origin referrer", () => {
|
||||
assert.equal(resolveHostNavigateOrigin({ configured: "*" }), null);
|
||||
assert.equal(
|
||||
resolveHostNavigateOrigin({
|
||||
referrer: "http://localhost:5174/page/foo",
|
||||
selfOrigin: "http://localhost:5174",
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(resolveHostNavigateOrigin({}), null);
|
||||
});
|
||||
|
||||
test("buildHostNavigateMessage matches the host contract", () => {
|
||||
assert.deepEqual(
|
||||
buildHostNavigateMessage("/task/item-aiceh/step-analysis-audience", { id: 108 }),
|
||||
{
|
||||
type: HOST_NAVIGATE_TYPE,
|
||||
path: "/task/item-aiceh/step-analysis-audience",
|
||||
queryParams: { id: 108 },
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* iframe 嵌入宿主系统时,任务跳转按钮通过 ``window.parent.postMessage`` 让
|
||||
* **父页面**改路由(宿主协议 ``HOST_NAVIGATE``)。不改 iframe 自己的地址。
|
||||
*
|
||||
* 消息形状对齐宿主约定:
|
||||
* ``{ type: 'HOST_NAVIGATE', path, queryParams: { id: number } }``
|
||||
* 第二参必须是宿主 origin,禁止 ``'*'``。
|
||||
*/
|
||||
|
||||
export const HOST_NAVIGATE_TYPE = "HOST_NAVIGATE";
|
||||
|
||||
export interface HostNavigateMessage {
|
||||
type: typeof HOST_NAVIGATE_TYPE;
|
||||
path: string;
|
||||
queryParams: Record<string, number>;
|
||||
}
|
||||
|
||||
/** 宿主路由:以单个 ``/`` 开头的路径,不含 ``//`` / 反斜杠。 */
|
||||
export function isHostNavigatePath(path: string): boolean {
|
||||
const value = path.trim();
|
||||
if (!value || value.length > 2048) return false;
|
||||
if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 任务 id 转数字。只接受整数字符串(可带正负号),否则返回 null。 */
|
||||
export function parseHostTaskId(raw: string | null | undefined): number | null {
|
||||
const value = (raw ?? "").trim();
|
||||
if (!value || !/^-?\d+$/.test(value)) return null;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function normalizeOrigin(value: string | null | undefined): string | null {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!raw || raw === "*") return null;
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
return url.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 postMessage 的 targetOrigin。
|
||||
* 优先级:显式配置 → ``ancestorOrigins[0]`` → 跨源 ``document.referrer``。
|
||||
* 解析失败返回 null(调用方应中止,不得退回 ``'*'``)。
|
||||
*/
|
||||
export function resolveHostNavigateOrigin(input: {
|
||||
configured?: string | null;
|
||||
ancestorOrigin?: string | null;
|
||||
referrer?: string | null;
|
||||
selfOrigin?: string | null;
|
||||
}): string | null {
|
||||
const configured = normalizeOrigin(input.configured);
|
||||
if (configured) return configured;
|
||||
const ancestor = normalizeOrigin(input.ancestorOrigin);
|
||||
if (ancestor) return ancestor;
|
||||
const referrer = normalizeOrigin(input.referrer);
|
||||
if (!referrer) return null;
|
||||
const selfOrigin = normalizeOrigin(input.selfOrigin);
|
||||
if (selfOrigin && referrer === selfOrigin) return null;
|
||||
return referrer;
|
||||
}
|
||||
|
||||
export function readAncestorOrigin(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.location.ancestorOrigins?.[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isEmbeddedInParent(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.parent !== window;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildHostNavigateMessage(
|
||||
path: string,
|
||||
queryParams: Record<string, number> = {},
|
||||
): HostNavigateMessage {
|
||||
return {
|
||||
type: HOST_NAVIGATE_TYPE,
|
||||
path,
|
||||
queryParams,
|
||||
};
|
||||
}
|
||||
@@ -45,6 +45,79 @@ export function getStoredAuth(): LoginByUsernameResponse | null {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIdentityPart(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function identityFromLoginName(value: unknown): string | null {
|
||||
const normalized = normalizeIdentityPart(value);
|
||||
if (!normalized) return null;
|
||||
// Explicit email login is domain-specific. Bare username login is the
|
||||
// stable business principal even if the backend's synthetic email domain or
|
||||
// internal UUID changes.
|
||||
return normalized.includes("@")
|
||||
? `email:${normalized}`
|
||||
: `account:${normalized}`;
|
||||
}
|
||||
|
||||
function readUserInfoIdentity(): string | null {
|
||||
let info: Record<string, unknown> | null = null;
|
||||
try {
|
||||
const raw = authStorageGet("userInfo");
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
info = parsed as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
info = null;
|
||||
}
|
||||
if (!info) return null;
|
||||
|
||||
const candidates = [
|
||||
["yUserId", info.yUserId],
|
||||
["userName", info.userName],
|
||||
["userId", info.userId],
|
||||
] as const;
|
||||
|
||||
for (const [prefix, value] of candidates) {
|
||||
const normalized = normalizeIdentityPart(value);
|
||||
if (normalized) return `${prefix}:${normalized}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable cross-tab identity key for "did the account really change?" checks.
|
||||
*
|
||||
* Some external login paths can re-issue a backend ``user_id`` or use a
|
||||
* different synthetic email domain while resolving to the same business
|
||||
* account. Prefer the raw login username returned by the backend, then the
|
||||
* email local-part, and keep ``user_id`` only as the final fallback.
|
||||
*/
|
||||
export function getAccountIdentityKey(
|
||||
auth: LoginByUsernameResponse | null = getStoredAuth(),
|
||||
): string | null {
|
||||
if (!auth) return null;
|
||||
|
||||
const loginIdentity = identityFromLoginName(auth.username);
|
||||
if (loginIdentity) return loginIdentity;
|
||||
|
||||
const email = normalizeIdentityPart(auth.email);
|
||||
if (email) {
|
||||
const localPart = email.split("@", 1)[0];
|
||||
return localPart ? `account:${localPart}` : `email:${email}`;
|
||||
}
|
||||
|
||||
const userInfoIdentity = readUserInfoIdentity();
|
||||
if (userInfoIdentity) return `userInfo:${userInfoIdentity}`;
|
||||
|
||||
const userId = normalizeIdentityPart(auth.user_id);
|
||||
return userId ? `user_id:${userId}` : null;
|
||||
}
|
||||
|
||||
export function setStoredAuth(auth: LoginByUsernameResponse) {
|
||||
if (!isBrowser()) {
|
||||
return;
|
||||
|
||||
@@ -1,27 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
AUTH_STATE_CHANGED_EVENT,
|
||||
getAccountDisplayName,
|
||||
getAccountIdentityKey,
|
||||
} from "@/core/auth";
|
||||
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
|
||||
|
||||
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||
const SETTLE_DELAY_MS = 1200;
|
||||
const IDENTITY_TOAST_ID = "auth-identity-changed";
|
||||
|
||||
function readUserId(): string | null {
|
||||
return getStoredAuth()?.user_id ?? null;
|
||||
function readIdentityKey(): string | null {
|
||||
return getAccountIdentityKey();
|
||||
}
|
||||
|
||||
function freshRouteForAccount(pathname: string): string {
|
||||
// A thread URL belongs to the old account. Keep its workspace/agent context
|
||||
// but move to a valid new conversation before the user can submit into the
|
||||
// stale thread. Other pages can stay in place and are remounted below.
|
||||
const chatMatch = pathname.match(/^(.*\/chats)\/[^/]+\/?$/);
|
||||
return chatMatch ? `${chatMatch[1]}/new` : pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount once at the app root. Watches localStorage auth changes from other
|
||||
* tabs and lets the user decide when to refresh on account switches.
|
||||
* Cross-tab auth boundary for normal pages.
|
||||
*
|
||||
* Same-account token re-issuance is ignored. A genuine account change clears
|
||||
* account-scoped query state, remounts the page, and moves an open chat to its
|
||||
* fresh-chat route without a full browser reload. Embedded/isolated tabs keep
|
||||
* their sessionStorage identity and never participate in this synchronization.
|
||||
*/
|
||||
export function AuthSyncWatcher() {
|
||||
const lastUserIdRef = useRef<string | null>(readUserId());
|
||||
export function AuthSyncWatcher({
|
||||
children,
|
||||
disabled = false,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const lastIdentityRef = useRef<string | null>(readIdentityKey());
|
||||
const [revision, setRevision] = useState(0);
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
// 嵌入(iframe)态 / 独立会话态(?isolate=1):鉴权走按标签页的 sessionStorage,本就不与其它标签页
|
||||
// 共享,且不应被其它标签页的 localStorage 账号变更打扰 —— 直接不挂跨标签监听。
|
||||
if (isEmbedActive() || isIsolatedSession()) return;
|
||||
@@ -29,25 +58,34 @@ export function AuthSyncWatcher() {
|
||||
let settleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function evaluateSettledIdentity() {
|
||||
const nextUserId = readUserId();
|
||||
if (nextUserId === lastUserIdRef.current) {
|
||||
const nextIdentity = readIdentityKey();
|
||||
if (nextIdentity === lastIdentityRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastUserIdRef.current = nextUserId;
|
||||
lastIdentityRef.current = nextIdentity;
|
||||
|
||||
if (nextUserId === null) {
|
||||
void queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
window.dispatchEvent(new Event(AUTH_STATE_CHANGED_EVENT));
|
||||
setRevision((value) => value + 1);
|
||||
|
||||
if (nextIdentity === null) {
|
||||
toast.info("账号已在其它页面退出", {
|
||||
id: IDENTITY_TOAST_ID,
|
||||
description: "当前页面已同步退出状态。",
|
||||
});
|
||||
navigate("/login", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.warning("检测到账号在其它标签页发生变化", {
|
||||
const freshRoute = freshRouteForAccount(location.pathname);
|
||||
if (freshRoute !== location.pathname) {
|
||||
navigate(freshRoute, { replace: true });
|
||||
}
|
||||
toast.info("账号已在页面间自动同步", {
|
||||
id: IDENTITY_TOAST_ID,
|
||||
description: "当前标签页仍是原会话。如需与新账号同步,请刷新页面。",
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: "刷新同步",
|
||||
onClick: () => window.location.reload(),
|
||||
},
|
||||
description: `当前账号:${getAccountDisplayName()}。无需刷新页面。`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,14 +106,24 @@ export function AuthSyncWatcher() {
|
||||
settleTimer = setTimeout(evaluateSettledIdentity, SETTLE_DELAY_MS);
|
||||
}
|
||||
|
||||
function handleSameTabAuthChange() {
|
||||
// `storage` never fires in the tab that called setStoredAuth/clearStoredAuth.
|
||||
// Keep the comparison baseline current so a later same-account token
|
||||
// renewal from another normal page is not compared with the pre-login
|
||||
// `null` identity and falsely classified as an account switch.
|
||||
lastIdentityRef.current = readIdentityKey();
|
||||
}
|
||||
|
||||
window.addEventListener("storage", handleStorage);
|
||||
window.addEventListener(AUTH_STATE_CHANGED_EVENT, handleSameTabAuthChange);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener(AUTH_STATE_CHANGED_EVENT, handleSameTabAuthChange);
|
||||
if (settleTimer) {
|
||||
clearTimeout(settleTimer);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [disabled, location.pathname, navigate, queryClient]);
|
||||
|
||||
return null;
|
||||
return children ? <Fragment key={revision}>{children}</Fragment> : null;
|
||||
}
|
||||
@@ -7,9 +7,11 @@
|
||||
* 任务深链配置读出 rwfx 的 4 个历史按钮,3qfx/xdfx 为空)——这样真实跳转地址通过实时
|
||||
* config 进种子,避免写死;一旦保存过,后端即唯一数据源。
|
||||
*
|
||||
* 按钮两类(``linkType``):``url``(外链 ``window.open``;**或应用内路由**——target 以 ``/`` 开头
|
||||
* 按钮类型(``linkType``):``url``(外链 ``window.open``;**或应用内路由**——target 以 ``/`` 开头
|
||||
* 时按内部路由处理:``self`` 走 react-router 同窗导航、``blank`` 拼 hash 路由新标签;可选追加 ``?{idParam}=id``)/
|
||||
* ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)。
|
||||
* ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)/
|
||||
* ``purpose`` 弹出任务目的树 / ``host`` iframe 内向父页面 ``postMessage({ type:'HOST_NAVIGATE' })``
|
||||
* 让宿主系统跳转(见 [[host-navigate]])。
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -25,6 +27,15 @@ import {
|
||||
readLoginToken,
|
||||
readLoginUsername,
|
||||
} from "@/core/auth/login-credentials";
|
||||
import {
|
||||
buildHostNavigateMessage,
|
||||
isEmbeddedInParent,
|
||||
isHostNavigatePath,
|
||||
parseHostTaskId,
|
||||
readAncestorOrigin,
|
||||
resolveHostNavigateOrigin,
|
||||
} from "@/core/auth/host-navigate";
|
||||
import { readRuntimeEnv } from "@/env";
|
||||
|
||||
/** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */
|
||||
export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent";
|
||||
@@ -62,8 +73,8 @@ export const TASK_BUSINESSES: {
|
||||
},
|
||||
];
|
||||
|
||||
/** ``url``=外链 / ``business``=切换业务 / ``purpose``=弹出任务目的树表格(无需 target)。 */
|
||||
export type TaskButtonLinkType = "url" | "business" | "purpose";
|
||||
/** ``url``=外链 / ``business``=切换业务 / ``purpose``=弹出任务目的树表格 / ``host``=宿主路由(postMessage)。 */
|
||||
export type TaskButtonLinkType = "url" | "business" | "purpose" | "host";
|
||||
|
||||
/** 追加的 id 取哪种:``task``=任务id(默认)/ ``action``=行动id(仅 xdfx 二者不同)。 */
|
||||
export type TaskButtonIdKind = "task" | "action";
|
||||
@@ -91,7 +102,7 @@ export interface TaskButton {
|
||||
business: TaskBusiness;
|
||||
label: string;
|
||||
linkType: TaskButtonLinkType;
|
||||
/** ``url``=完整地址;``business``=目标业务码(如 ``3qfx``)。 */
|
||||
/** ``url``=完整地址;``business``=目标业务码(如 ``3qfx``);``host``=宿主系统路由(如 ``/task/...``)。 */
|
||||
target: string;
|
||||
/** ``url`` 类型是否追加 id 查询参数。 */
|
||||
appendTaskId: boolean;
|
||||
@@ -316,11 +327,46 @@ export interface RunTaskButtonCtx {
|
||||
* 执行一个跳转按钮:
|
||||
* - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。
|
||||
* - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。
|
||||
* - ``host``:向 ``window.parent`` 发 ``HOST_NAVIGATE``,由宿主系统跳转(仅 iframe 嵌入时生效)。
|
||||
*/
|
||||
export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise<void> {
|
||||
const fail = (m: string) => ctx.onError?.(m);
|
||||
// purpose(目的树弹框)由组件层处理(需打开 React 弹框),此处不动作。
|
||||
if (btn.linkType === "purpose") return;
|
||||
if (btn.linkType === "host") {
|
||||
const path = btn.target.trim();
|
||||
if (!isHostNavigatePath(path)) {
|
||||
fail(`未配置「${btn.label}」的宿主路由`);
|
||||
return;
|
||||
}
|
||||
if (!isEmbeddedInParent()) {
|
||||
fail("当前不在嵌入页,无法跳转宿主系统");
|
||||
return;
|
||||
}
|
||||
const origin = resolveHostNavigateOrigin({
|
||||
configured: readRuntimeEnv("VITE_HOST_NAVIGATE_ORIGIN"),
|
||||
ancestorOrigin: readAncestorOrigin(),
|
||||
referrer: typeof document !== "undefined" ? document.referrer : null,
|
||||
selfOrigin: typeof window !== "undefined" ? window.location.origin : null,
|
||||
});
|
||||
if (!origin) {
|
||||
fail("未配置宿主地址(VITE_HOST_NAVIGATE_ORIGIN),无法安全跳转");
|
||||
return;
|
||||
}
|
||||
const queryParams: Record<string, number> = {};
|
||||
if (btn.appendTaskId) {
|
||||
const id = parseHostTaskId(ctx.taskId);
|
||||
if (id === null) {
|
||||
fail("任务id不是数字,无法跳转宿主系统");
|
||||
return;
|
||||
}
|
||||
queryParams[(btn.idParam || "id").trim() || "id"] = id;
|
||||
}
|
||||
const message = buildHostNavigateMessage(path, queryParams);
|
||||
console.info(`[task-button] 「${btn.label}」宿主路由 →`, message, origin);
|
||||
window.parent.postMessage(message, origin);
|
||||
return;
|
||||
}
|
||||
if (btn.linkType === "url") {
|
||||
const base = btn.target.trim();
|
||||
if (!base) {
|
||||
|
||||
@@ -95,25 +95,41 @@ function mockCopTaskDetail(taskId: string): CopTaskDetail {
|
||||
};
|
||||
}
|
||||
|
||||
/** 拉取任务详情;``VITE_MOCK_COP_TASK_DETAIL=true`` 走假数据;缺少 consumer 地址 / 返回不合法时返回 null。 */
|
||||
/** 详情接口不可用时仍能继续深链跳转:至少带上任务 id,标题/简介可空。 */
|
||||
export function stubCopTaskDetail(taskId: string): CopTaskDetail {
|
||||
return {
|
||||
id: taskId,
|
||||
overview: "",
|
||||
taskDirection: "",
|
||||
taskName: "",
|
||||
taskContent: "",
|
||||
};
|
||||
}
|
||||
|
||||
/** 拉取任务详情;``VITE_MOCK_COP_TASK_DETAIL=true`` 走假数据;缺少 consumer 地址 / 返回不合法 / 网络失败时返回 null。 */
|
||||
export async function fetchCopTaskDetail(taskId: string): Promise<CopTaskDetail | null> {
|
||||
if (copTaskDetailMockEnabled()) {
|
||||
return mockCopTaskDetail(taskId);
|
||||
}
|
||||
const base = consumerBaseUrl();
|
||||
if (!base || !taskId) return null;
|
||||
// service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。
|
||||
const body = await requestCrudService.get(`${base}/taskAnalyseSearch/cop-task-detail`, { taskId });
|
||||
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const d = data as Record<string, unknown>;
|
||||
return {
|
||||
id: (d.id as string | number | null) ?? null,
|
||||
overview: typeof d.overview === "string" ? d.overview : "",
|
||||
taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "",
|
||||
taskName: typeof d.taskName === "string" ? d.taskName : "",
|
||||
taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
|
||||
};
|
||||
try {
|
||||
// service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。
|
||||
const body = await requestCrudService.get(`${base}/taskAnalyseSearch/cop-task-detail`, { taskId });
|
||||
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const d = data as Record<string, unknown>;
|
||||
return {
|
||||
id: (d.id as string | number | null) ?? null,
|
||||
overview: typeof d.overview === "string" ? d.overview : "",
|
||||
taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "",
|
||||
taskName: typeof d.taskName === "string" ? d.taskName : "",
|
||||
taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[task-deeplink] cop-task-detail failed", { taskId, error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 把任务详情整包暂存到 sessionStorage(隐私模式/quota 异常时静默失败)。 */
|
||||
@@ -223,7 +239,12 @@ export async function fetchTaskIdFromAction(actionId: string): Promise<string> {
|
||||
export async function resolveDeeplinkTaskId(goPath: string, rawId: string): Promise<string> {
|
||||
const parsed = parseGoPath(goPath);
|
||||
if (parsed?.businessCode === XDFX_BUSINESS_CODE) {
|
||||
return fetchTaskIdFromAction(rawId);
|
||||
try {
|
||||
return (await fetchTaskIdFromAction(rawId)) || "";
|
||||
} catch (error) {
|
||||
console.warn("[task-deeplink] action→taskId resolve failed", { rawId, error });
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return rawId;
|
||||
}
|
||||
@@ -332,10 +353,14 @@ export async function resolveTaskDeeplinkTarget(
|
||||
try {
|
||||
const mappings = await listBusinessMappings();
|
||||
agentId = mappings.find((m) => m.businessCode === parsed.businessCode)?.agentId ?? null;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.warn("[task-deeplink] business mapping failed", error);
|
||||
agentId = null;
|
||||
}
|
||||
if (!agentId) return null;
|
||||
if (!agentId) {
|
||||
console.warn("[task-deeplink] no agent mapped for", parsed.businessCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 单智能体问答统一走「任务工作区」(任务↔对话 1:N,按 taskId + agentId 隔离不同业务的对话列表):
|
||||
// 暂存任务上下文(taskId + agentId + 开场信息),跳到 chats 索引,由工作区按 taskId 决定
|
||||
|
||||
@@ -5,9 +5,10 @@ import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AUTH_STATE_CHANGED_EVENT,
|
||||
getAccountDisplayName,
|
||||
getAccountIdentityKey,
|
||||
getStoredAuth,
|
||||
} from "@/core/auth";
|
||||
import { isEmbedActive } from "@/core/embed/embed-session";
|
||||
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
|
||||
|
||||
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||
|
||||
@@ -36,11 +37,12 @@ export interface LiveAccount {
|
||||
* 账号时给出提示 —— 否则会出现「用 A 账号创建却归到 B 账号名下」。
|
||||
*/
|
||||
export function useLiveAccount(): LiveAccount {
|
||||
const mountRef = useRef<{ id: string | null; name: string } | null>(null);
|
||||
const mountRef = useRef<{ id: string | null; key: string | null; name: string } | null>(null);
|
||||
if (mountRef.current === null) {
|
||||
const auth = getStoredAuth();
|
||||
mountRef.current = {
|
||||
id: auth?.user_id ?? null,
|
||||
key: getAccountIdentityKey(auth),
|
||||
name: getAccountDisplayName(auth),
|
||||
};
|
||||
}
|
||||
@@ -50,9 +52,10 @@ export function useLiveAccount(): LiveAccount {
|
||||
function refresh() {
|
||||
const auth = getStoredAuth();
|
||||
const id = auth?.user_id ?? null;
|
||||
const key = getAccountIdentityKey(auth);
|
||||
const name = getAccountDisplayName(auth);
|
||||
setLive((prev) =>
|
||||
prev.id === id && prev.name === name ? prev : { id, name },
|
||||
prev.id === id && prev.key === key && prev.name === name ? prev : { id, key, name },
|
||||
);
|
||||
}
|
||||
// 嵌入(iframe)态鉴权走按标签页的 sessionStorage、不跨标签,收不到 storage 事件
|
||||
@@ -62,7 +65,9 @@ export function useLiveAccount(): LiveAccount {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
if (!isEmbedActive()) {
|
||||
// Normal pages share localStorage identity across tabs. Embedded and
|
||||
// isolated pages deliberately keep their per-tab sessionStorage identity.
|
||||
if (!isEmbedActive() && !isIsolatedSession()) {
|
||||
window.addEventListener("storage", onStorage);
|
||||
}
|
||||
window.addEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
|
||||
@@ -83,6 +88,9 @@ export function useLiveAccount(): LiveAccount {
|
||||
displayName: live.name,
|
||||
mountUserId: mount.id,
|
||||
mountDisplayName: mount.name,
|
||||
switched: live.id !== null && mount.id !== null && live.id !== mount.id,
|
||||
switched:
|
||||
live.key !== null && mount.key !== null
|
||||
? live.key !== mount.key
|
||||
: live.id !== null && mount.id !== null && live.id !== mount.id,
|
||||
};
|
||||
}
|
||||