Compare commits

..
Author SHA1 Message Date
董宏凯 5f11926db5 ceshi 2026-08-07 12:30:20 +08:00
448 changed files with 4523 additions and 55240 deletions

No files matched your search

-20
View File
@@ -1,20 +0,0 @@
import os
import paramiko
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(
"119.254.86.251",
port=22322,
username="root",
password=os.environ["TARGET_SSH_PASSWORD"],
timeout=15,
auth_timeout=15,
banner_timeout=15,
)
_, stdout, stderr = client.exec_command(os.environ["TARGET_SSH_COMMAND"], timeout=30)
print(stdout.read().decode(errors="replace"), end="")
print(stderr.read().decode(errors="replace"), end="")
client.close()
+14 -30
View File
@@ -1,10 +1,6 @@
# Workspace/editor
.vscode/*
!.vscode/launch.json
!.vscode/tasks.json
!.vscode/extensions.json
!.vscode/settings.json
frontend-web/.vscode/
.vscode/
frontend-web/.vscode/
node_modules/
# Claude Code local-only settings (per-user permissions, not shared)
.claude/settings.local.json
@@ -56,9 +52,8 @@ 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/DEPLOY_BACKEND_OFFLINE_ZH.md
offline-backend-20260512/SHA256SUMS
offline-backend-20260512/config.yaml.example
offline-backend-20260512/data/
@@ -66,14 +61,12 @@ 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/start_backend.sh
offline-backend-20260512/stop_backend.sh
offline-backend-20260512/check_backend.sh
hermes-agent-main/
@@ -84,16 +77,11 @@ 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 the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py
deploy
offline-backend-20260512/backend/tests/*
deploy
.history
# Cloned third-party repos (local only)
@@ -107,10 +95,6 @@ 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
-6
View File
@@ -1,6 +0,0 @@
{
"recommendations": [
"ms-python.python",
"ms-python.debugpy"
]
}
-66
View File
@@ -1,66 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "DeerFlow: Backend FastAPI (8001)",
"type": "debugpy",
"request": "launch",
"module": "uvicorn",
"args": [
"app.gateway.app:app",
"--host",
"0.0.0.0",
"--port",
"8001"
],
"cwd": "${workspaceFolder}/offline-backend-20260512/backend",
"python": "${workspaceFolder}/offline-backend-20260512/backend/.venv/Scripts/python.exe",
"envFile": "${workspaceFolder}/offline-backend-20260512/.env",
"env": {
"PYTHONPATH": "${workspaceFolder}/offline-backend-20260512/backend;${workspaceFolder}/offline-backend-20260512/backend/.deer-flow/python-vendor",
"GATEWAY_CORS_ORIGINS": "http://localhost:5174,http://127.0.0.1:5174,http://localhost:8080,http://127.0.0.1:8080",
"UV_LINK_MODE": "copy",
"WEKNORA_ADMIN_PASSWORD": "1qaz@WSX"
},
"console": "integratedTerminal",
"redirectOutput": true,
"justMyCode": false,
"subProcess": true
},
{
"name": "DeerFlow: Frontend Vite (5174)",
"type": "node-terminal",
"request": "launch",
"command": "npm.cmd run dev -- --host 0.0.0.0 --port 5174",
"cwd": "${workspaceFolder}/frontend-web",
"env": {
"VITE_BACKEND_BASE_URL": "http://127.0.0.1:8001",
"VITE_LANGGRAPH_BASE_URL": "http://127.0.0.1:8001/api"
},
"serverReadyAction": {
"pattern": "Local:\\s+(https?://[^\\s]+)",
"uriFormat": "%s",
"action": "openExternally"
}
},
{
"name": "DeerFlow: Open Frontend in Chrome",
"type": "node-terminal",
"request": "launch",
"preLaunchTask": "DeerFlow: Start Full Stack and Wait",
"command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"Start-Process 'http://127.0.0.1:5174/'\"",
"cwd": "${workspaceFolder}"
}
],
"compounds": [
{
"name": "DeerFlow: Full Stack (Backend + Frontend)",
"preLaunchTask": "DeerFlow: Stop Full Stack",
"configurations": [
"DeerFlow: Backend FastAPI (8001)",
"DeerFlow: Frontend Vite (5174)"
],
"stopAll": true
}
]
}
-6
View File
@@ -1,6 +0,0 @@
{
"python.defaultInterpreterPath": "${workspaceFolder}/offline-backend-20260512/backend/.venv/Scripts/python.exe",
"python.terminal.activateEnvironment": true,
"typescript.tsdk": "frontend-web/node_modules/typescript/lib",
"npm.packageManager": "npm"
}
-152
View File
@@ -1,152 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "DeerFlow: Start Full Stack and Wait",
"type": "process",
"command": "powershell.exe",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"${workspaceFolder}/scripts/start-all-and-wait-frontend.ps1"
],
"problemMatcher": []
},
{
"label": "DeerFlow: Start Full Stack",
"type": "process",
"command": "powershell.exe",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"${workspaceFolder}/scripts/start-all.ps1"
],
"problemMatcher": []
},
{
"label": "DeerFlow: Stop Full Stack",
"type": "process",
"command": "powershell.exe",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"${workspaceFolder}/scripts/stop-all.ps1"
],
"problemMatcher": []
},
{
"label": "DeerFlow: Status",
"type": "process",
"command": "powershell.exe",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"${workspaceFolder}/scripts/status.ps1"
],
"problemMatcher": []
},
{
"label": "DeerFlow: Check Local Dev Environment",
"type": "shell",
"command": "$backendPython = '${workspaceFolder}\\offline-backend-20260512\\backend\\.venv\\Scripts\\python.exe'; if (-not (Test-Path -LiteralPath $backendPython)) { throw 'Backend virtual environment is missing. Run uv sync in offline-backend-20260512/backend first.' }; & $backendPython -c \"import uvicorn; print('Backend Python OK')\"; npm --prefix '${workspaceFolder}\\frontend-web' exec vite -- --version",
"options": {
"shell": {
"executable": "powershell.exe",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command"
]
}
},
"problemMatcher": []
},
{
"label": "DeerFlow: Install Frontend Dependencies",
"type": "process",
"command": "npm",
"args": [
"ci",
"--no-audit",
"--no-fund"
],
"options": {
"cwd": "${workspaceFolder}/frontend-web"
},
"problemMatcher": []
},
{
"label": "DeerFlow: Frontend Typecheck",
"type": "process",
"command": "npm",
"args": [
"run",
"typecheck"
],
"options": {
"cwd": "${workspaceFolder}/frontend-web"
},
"problemMatcher": "$tsc"
},
{
"label": "DeerFlow: Frontend Build",
"type": "process",
"command": "npm",
"args": [
"run",
"build"
],
"options": {
"cwd": "${workspaceFolder}/frontend-web"
},
"problemMatcher": []
},
{
"label": "DeerFlow: Backend Tests",
"type": "process",
"command": "${workspaceFolder}/offline-backend-20260512/backend/.venv/Scripts/python.exe",
"args": [
"-m",
"pytest",
"tests/",
"-v"
],
"options": {
"cwd": "${workspaceFolder}/offline-backend-20260512/backend",
"env": {
"PYTHONPATH": ".",
"WEKNORA_ADMIN_PASSWORD": "1qaz@WSX"
}
},
"problemMatcher": []
},
{
"label": "DeerFlow: WeKnora Backend Tests",
"type": "process",
"command": "${workspaceFolder}/offline-backend-20260512/backend/.venv/Scripts/python.exe",
"args": [
"-m",
"pytest",
"tests/test_llmwiki_weknora.py",
"-v"
],
"options": {
"cwd": "${workspaceFolder}/offline-backend-20260512/backend",
"env": {
"PYTHONPATH": ".",
"WEKNORA_ADMIN_PASSWORD": "1qaz@WSX"
}
},
"problemMatcher": []
}
]
}
@@ -1,61 +0,0 @@
# 深度研究第三轮:检索稳定性修复 + 参考文献确定性 + 页面对齐 AI 写作改造
## A. 两个 bug 的排查结论与修复
### A1. 第二次检索「网络波动」——确认是真问题(DDG 限流 + 错误语义混淆 + 零重试)
**排查结论**(用户判断"确实是网络波动"基本正确,但代码层面放大了它):
- 「网络波动」文案不存在于任何代码中——是收集员 LLM 看到 `{"error": "No results found"}` 信封后自己编的解释
- 真实根因:DDG html 端点对同 IP 快速连续请求限流(403/429 挑战页)→ `DDGSException` → `ddg_search/tools.py::_search_text` 把异常吞成 `[]` → 上层把「被限流」和「真没结果」混成同一个错误信封;工具层完全没有重试
**修复**(`packages/harness/deerflow/community/ddg_search/tools.py`):
1. `_search_text` 对异常加 2 次重试 + 指数退避(约 1.5s / 3s + 抖动)
2. 错误信封区分场景:真无结果 → `no results`;多次尝试仍失败 → `web search failed after N attempts (rate limited or network), 换一组检索词稍后重试`——让模型不再瞎猜「网络波动」
3. 收集员 SOUL.md 行为准则加一条(同步改种子资产 + 已安装副本 `.deer-flow/agents/deep-research-collector/`):搜索返回 error 信封时先换检索词重试一轮,而不是直接告知用户网络异常
### A2. 参考文献缺失——代码确有隐患(当前是模型自发行为,时有时无)
**排查结论**:后端没有任何「参考来源」追加逻辑;四个 runner 的报告 prompt 只要求内联 `[N]` 引用。出现过的参考列表是模型看到上下文头 `[N] 标题(URL)` 后**自发**补写的——弱模型下不可靠,与"信息够不够"无必然关系。
**修复**(prompt 指示 + 确定性兜底双保险):
1. 四个 runner(basic / detailed / deep / multi_agent)的报告 prompt 各加一条:报告最后附「## 参考来源」,按 `[N] 标题. URL` 列出正文实际引用过的来源
2. `basic.py` 新增共享函数 `ensure_reference_section(report, context_list, materials)`:报告缺少参考小节时,解析正文引用的 `[N]` 编号,按压缩上下文顺序生成编号列表追加(无编号时列全部来源,封顶 20 条;已有参考小节则不重复追加)。在各 runner 最终 `write_text("report.md", ...)` 前调用(约 7 个调用点,跳过零材料 fallback)
3. 新增测试 `tests/test_deep_research_references.py`:helper 单测(正常追加/编号映射/不重复追加/无 URL 来源)+ runner 集成断言
## B. 页面改造(核心需求):左列复用 AI 写作对话形态,右列改真沙箱
### B1. 左列 = AI 写作式对话收集面板(仿 WritingSetupChat)
- **空状态欢迎屏**(无消息时隐藏头部,展示欢迎卡片,WritingWelcome 式)+ 有消息后 `MessageList`(沿用现有绑 collector 线程的接线)+ 首 token 前「正在整理…」占位条
- **输入框换成白卡片 PromptInput 套件**(与 AI 写作完全同款组件):卡片内一行「当前对话模型」Select(沿用 ModelNameSelect)+ 高级配置入口;下方 PromptInputTextarea + PromptInputFooter + PromptInputSubmit(`status` ready/streaming,流式中变停止按钮)。经典模式入口保留在高级配置对话框
- **配置卡片进对话流**(对标 AI 写作「识别出意图后出现表单」):
- `message-list.tsx` 最小扩展(约 15 行):新增分组 `assistant:research-approval`(识别 `additional_kwargs.setup_research_approval`)+ 可选 prop `researchApprovalSlot?: ReactNode`;不传 slot 或无该载荷时零行为变化——普通对话/AI 写作完全不受影响
- workbench:收集回合结束(awaiting_report,即"意图识别完成")时向 `displayThread` 尾部注入一条虚拟 ai 消息(WritingSetupChat 的 displayThread 同款手法),slot 传现有 `ReportConfigCard` → 卡片作为助手下一轮出现在对话流里
- 写作进度卡、完成卡、摘要气泡、追问列表保留在列表下方卡片区(报告正文移到右侧沙箱)
### B2. 右列 = 工作区真沙箱(Step2SandboxLayout 的 stub 线程模式)
- 新建 `deep-research/ResearchSandboxPanel.tsx`(约 100 行,含关闭/重开)+ `useReportSandbox.ts` hook(仿 `useStep3Report` 的 stub 机制):
- threadStub:`messages = [{type:'ai', tool_calls:[{name:'write_file', args:{path:'/mnt/user-data/outputs/report.md', content: <流式报告>}}]}]`,`isLoading = job 运行中 && phase !== 'summarizing'`
- `report_delta` 首帧 → `openArtifact(write-file: 虚拟URL)` 自动展开右栏;`run.report` 每帧 upsert stub(带防抹空守卫);刷新/切换会话用 `session.reportMarkdown` 重建
- `ArtifactFileDetail` 自带能力全部免费获得:**流式期 code 视图自动滚底 → 报告写完 isLoading 翻 false 自动切 preview + 脉冲提示**(正是「生成完直接切预览」)、Streamdown 渲染的 Markdown(比现在的好看)、另存 Word/复制/下载、默认走展示层脱敏
- 布局:ResizablePanelGroup chat 60 / sandbox 40(对齐 ChatBox/圆桌),沙箱可关闭、头部按钮重开
- **删除自定义 `ResearchSandbox` 组件**;legacy 会话与经典模式共用同一右栏(`run.report` 数据源相同,行为一致)
- 已知小限制(可接受):报告配图模式下沙箱内图片可能无法经 artifacts API 解析(配图默认关闭;真需要时后续把产物落 thread artifacts)
### B3. 验证
- 后端:`PYTHONPATH=. uv run --no-sync pytest tests/ -k deep_research -q --ignore=tests/test_notification_templates.py` 全绿 + ruff;改完配置/SOUL 后 touch 触发 dev 服务重载
- 前端:`pnpm typecheck` 零新增错误(基线 51)
- 手动走查:连续两次检索不再误报、弱模型报告末尾必带参考来源、页面全流程(欢迎屏 → 对话收集 → 流内配置卡 → 沙箱流式 → 自动预览 → 摘要/追问)
## 涉及文件
**后端**:`community/ddg_search/tools.py`、`deep_research/runners/{basic,detailed,deep,multi_agent}.py`、收集员 SOUL.md(种子 + 已安装副本)、`tests/test_deep_research_references.py`(新)
**前端**:`components/workspace/messages/message-list.tsx`(可选 slot 分组)、`pages/deep-research/DeepResearchWorkbench.tsx`(大改)、`pages/deep-research/ResearchSandboxPanel.tsx`(新)、`pages/deep-research/useReportSandbox.ts`(新)
## 明确不动
- AI 写作 / 普通对话现有组件行为(message-list.tsx 仅加可选分组,缺省零变化)
- 报告写作管线的写作逻辑本身(prompt 加一条要求 + 末尾确定性追加参考小节,均为附加式)
- 深度研究后端会话/任务/事件模型(本轮不动)
-1
View File
@@ -82,7 +82,6 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **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/`。
- **敏感词管理 (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`.
File diff suppressed because it is too large. Load diff
@@ -1,15 +0,0 @@
# 岗位会商沙箱文档
本目录存放**岗位会商文件预览沙箱**的对接与排障说明,独立于 `src/position-roundtable/docs/`,避免与模块内其它文档互相覆盖。
| 文档 | 说明 |
| --- | --- |
| [沙箱打开关闭与步骤条.md](./沙箱打开关闭与步骤条.md) | **详细版**:打开/关闭完整时序、步骤条关系、对接接口字段说明、必守规则、历史 bug 根因、排障手册、自测清单、接线示例与代码索引 |
模块总览仍见:`frontend-web/src/position-roundtable/README.md`。
建议读法:
- **从零对接新页面**:正文第 3、5、6、7、8、12、15、16 节
- **线上排障**:第 13、14 节
- **只要一页纸**:文末「附录 A」
@@ -1,988 +0,0 @@
# 岗位会商 · 沙箱打开 / 关闭与步骤条(详细对接与排障指南)
> **文档位置**:`frontend-web/docs/position-roundtable-sandbox/`
> **读者**:下次重做或对接岗位会商页面的同事;线上「沙箱不实时 / 卡死 / 第二次不刷新」时的排障同学。
> **最后依据**:当前仓库 `PositionRoundtablePage` + `PositionNodeChat` 实现(含临时名打开、外部 store、前沿节流、按 tool-call 重挂)。
---
## 目录
1. [文档用途与读法](#1-文档用途与读法)
2. [背景:为什么岗位会商要单独做沙箱](#2-背景为什么岗位会商要单独做沙箱)
3. [用户可见的正确行为(验收标准)](#3-用户可见的正确行为验收标准)
4. [整体架构与数据流](#4-整体架构与数据流)
5. [职责划分:谁拥有什么状态](#5-职责划分谁拥有什么状态)
6. [对接接口详解](#6-对接接口详解)
7. [打开沙箱的完整步骤](#7-打开沙箱的完整步骤)
8. [关闭沙箱的完整步骤](#8-关闭沙箱的完整步骤)
9. [步骤条与沙箱的关系](#9-步骤条与沙箱的关系)
10. [流式内容如何进入沙箱(性能关键)](#10-流式内容如何进入沙箱性能关键)
11. [与普通对话页的对照](#11-与普通对话页的对照)
12. [重做页面时的必守规则](#12-重做页面时的必守规则)
13. [历史问题与根因(务必读)](#13-历史问题与根因务必读)
14. [排障手册(按症状)](#14-排障手册按症状)
15. [对接自测清单](#15-对接自测清单)
16. [最小接线示例](#16-最小接线示例)
17. [关键代码索引](#17-关键代码索引)
18. [术语表](#18-术语表)
---
## 1. 文档用途与读法
### 1.1 这份文档解决什么问题
岗位会商右侧「文件预览沙箱」看起来像普通对话页的产物预览,但实现路径完全不同。历史上这块反复出过:
- 沙箱不实时打开,要等全文写完才弹;
- 打开了但内容冻住,生成完才一口气灌进去;
- 整页卡顿,连消息列表文字都不实时;
- 同轮「重新生成」第二次写入时,步骤条变了,沙箱却不动;
- 第一次正常,关掉后再问就不实时。
这些 bug **特别容易在「重做页面 / 换布局 / 自己重写开沙箱逻辑」时复现**。本文把正确对接方式、时序、接口、禁区和排障步骤写全,方便下一位同事直接按文档接线,而不是再靠猜。
### 1.2 建议读法
| 你的目标 | 先读哪些章节 |
| --- | --- |
| 从零接一个新页面壳 | 第 3、5、6、7、8、12、15、16 节 |
| 只改对话子组件探测逻辑 | 第 7、9、12、13 节 |
| 线上排障 | 第 13、14 节,必要时回看第 10 节 |
| 理解为什么不能用页面 state | 第 2、10、13 节 |
### 1.3 对照代码(以仓库为准)
| 角色 | 路径 |
| --- | --- |
| 页面壳(唯一沙箱宿主) | `frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx` |
| 节点对话(探测 write_file、上报预览) | `frontend-web/src/position-roundtable/components/PositionNodeChat.tsx` |
| 收口对话 | `frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx` |
| 预览 UI(共享组件,勿分叉逻辑) | `frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx` |
| 流式正文读取 | `frontend-web/src/core/artifacts/loader.ts` → `loadArtifactContentFromToolCall` |
| 普通对话页对照(步骤条自动开) | `frontend-web/src/components/workspace/messages/message-group.tsx` |
| 模块总览 | `frontend-web/src/position-roundtable/README.md` |
---
## 2. 背景:为什么岗位会商要单独做沙箱
### 2.1 普通对话页怎么开沙箱
普通对话页(`ChatPage`)大致是:
```text
ArtifactsProvider(页面级)
└─ ChatBox / MessageList
└─ message-group 渲染 write_file 步骤
└─ 发现 path 且 autoOpen → artifacts.select + setOpen(true)
└─ 右侧 ArtifactFileDetail 读同一套 ThreadContext
```
要点:
- 对话和预览共享同一套 `ThreadContext`(同一个 `useThreadStream`)。
- 步骤条组件自己就能触发打开。
- 流式 `content` 本来就在当前 React 树的 thread 里,不需要「把 thread 镜像到别的地方」。
### 2.2 岗位会商为什么不能照搬
岗位会商页面同时有:
- 左侧任务 / 意图 / 流程图;
- 中间某个节点的对话(`PositionNodeChat`);
- 右侧配置抽屉;
- **以及一个页面级单例沙箱**,它是对话区的**兄弟节点**,不在 `PositionNodeChat` 的 ThreadContext 里面。
因此:
1. 沙箱拿不到节点对话内部的 `thread`,除非节点主动上报;
2. 若把每次流式 thread 写进**页面级 `useState`**,整页(任务卡、意图、流程图、对话)都会按流式频率重渲染,主线程会被打爆;
3. 页面必须自己决定「开 / 关 / 抑制自动再开 / 切节点时清理」,不能让多个子组件各自 `setOpen`。
所以岗位会商采用:
```text
对话子组件探测 + 上报
→ 页面壳统一打开唯一 ArtifactFileDetail
→ 流式 thread 走外部 store,只让沙箱子树订阅
```
### 2.3 和「多智能体会商 Step2 沙箱」的区别
仓库里还有 `frontend-web/docs/roundtable-step2-sandbox-progress.md`,那是另一套圆桌 Step2 沙箱进度文档,**不是**岗位会商这条链路。对接岗位会商时以本文为准。
---
## 3. 用户可见的正确行为(验收标准)
重做或对接完成后,下列行为必须全部成立(用户感知对齐普通对话页):
| # | 行为 | 说明 |
| --- | --- | --- |
| A | 步骤条刚出现「写入文件」时,沙箱应已打开 | **不必等**步骤条上出现文件名 |
| B | 沙箱源码边写边滚;消息列表也实时出字 | 不能整页卡死、不能「生成完一口气刷」 |
| C | content 先于 path 时:先临时名打开,path 到了再改名 | 不闪屏、不重挂 |
| D | 同轮第二次同路径 `write_file`(重新生成) | 沙箱清空并重新流式 |
| E | 整轮结束后自动切 Markdown 预览 | 「正在生成,完成后自动预览」消失 |
| F | 用户手动关闭后,同一次写入不再自动弹 | 下一轮新写入仍可自动开 |
| G | 切岗位 / 切节点 / 新会话 / 切历史 | 沙箱关掉且不串内容 |
**不要误判的正常现象:**
- 流式期间沙箱停在「源码 / 编辑」视图,结束后才切「预览」——这是 `ArtifactFileDetail` 对 markdown/html 的既定行为,与普通对话页相同,**不是**打开时机 bug。
- 步骤条长时间显示「写入文件」、没有路径,但沙箱已经打开并在滚动——content-first 模型下这是**预期**。
---
## 4. 整体架构与数据流
```text
┌──────────────────────────────────────────────────────────────────────────┐
│ PositionRoundtablePage(页面壳) │
│ │
│ state: sandboxArtifact | null │
│ ref: sandboxArtifactRef / closedSandboxKeyRef / latestSnapshotRef │
│ store: createSandboxThreadStore() ←── 禁止改成 useState │
│ │
│ handlePreviewArtifact(artifact) ←──────────────────────────────────┐ │
│ handleNodeThreadSnapshot(...) ←───────────────────────────────┐ │ │
│ closeSandbox() │ │ │
│ │ │ │
│ {sandboxOpen && ( │ │ │
│ SandboxThreadProvider(store) │ │ │
│ └─ ArtifactFileDetail key=sandboxCloseKey(...) │ │ │
│ )} │ │ │
└───────────────────────────────────────────────────────────────────│──│───┘
│ │
┌───────────────────────────────────────────────────────────────────│──│───┐
│ PositionNodeChat / PositionBuiltinAgentChat │ │ │
│ │ │ │
│ useThreadStream → thread.messages / thread.isLoading │ │ │
│ │ │ │ │
│ ├─ MessageList → 步骤条(只展示 tool call) │ │ │
│ │ │ │ │
│ ├─ latestStreamingWriteFile(messages) │ │ │
│ │ → onPreviewArtifact({..., auto:true}) ───────────────────┘ │
│ │ │
│ └─ useEffect → onThreadSnapshot({threadId, thread}) ───────────────┘
└──────────────────────────────────────────────────────────────────────────┘
```
一句话:
- **谁打开**:对话子组件决定「该不该开、开哪次交付」;
- **谁真正开面板**:页面壳;
- **内容怎么更新**:子组件持续上报 thread 快照 → 页面壳 store → 只有沙箱子树重渲染。
---
## 5. 职责划分:谁拥有什么状态
### 5.1 页面壳必须拥有
| 状态 / 能力 | 为什么必须在页面壳 |
| --- | --- |
| `sandboxArtifact` | 唯一预览目标;切节点时要清 |
| `closedSandboxKeyRef` | 用户关过后的抑制;跨子组件生命周期 |
| `sandboxThreadStore` | 流式内容通道;不能进大页面 state |
| `closeSandbox()` | 切岗位 / 切节点 / 新会话统一入口 |
| 唯一 `ArtifactFileDetail` | 避免双实例、关闭失效、深度异常 |
### 5.2 对话子组件必须拥有
| 状态 / 能力 | 作用 |
| --- | --- |
| `autoPreviewRunArmedRef`(armed) | 只有本轮用户发送后才允许自动开 |
| `autoPreviewBaselineCallKeysRef`(baseline) | 忽略发送前已有的历史 write_file |
| `activeWriteFilePreviewRef` | 记录当前打开的 previewKey / path / toolCallId |
| `autoPreviewedArtifactKeyRef` | 同一次 identity 去重,避免每 chunk 重复上报 |
| `latestStreamingWriteFile` | 从 streaming messages 探测可预览的 write |
| `onThreadSnapshot` 上报 | 把 thread 喂给页面壳 |
### 5.3 明确禁止
1. 在 `PositionNodeChat` 内再挂一套 `ArtifactFileDetail`。
2. 让步骤条组件直接 `setOpen(true)` 作为岗位会商主路径(对照普通页可以,岗位会商不行)。
3. 把 `onThreadSnapshot` 落到 `PositionRoundtablePage` 的 `useState`。
4. 用「整个 threadId」做关闭抑制。
5. 用「path 相同」复用 `previewKey`。
---
## 6. 对接接口详解
### 6.1 `PositionArtifactPreview`
```ts
type PositionArtifactPreview = {
threadId: string; // 必填:LangGraph thread id
path: string; // 必填:真实路径,或 write-file: 虚拟 URL,或临时文件名路径
name: string; // 展示名(文件名)
mimeType: string | null; // 如 text/markdown;帮助预览语言判断
previewKey?: string; // 强烈建议:一次交付身份,推荐 `${messageId}:${toolCallId}`
auto?: boolean; // true = 流式自动打开;缺省/false = 用户点击打开
};
```
字段说明:
| 字段 | 详细含义 |
| --- | --- |
| `threadId` | 沙箱用来匹配 `onThreadSnapshot` 的线程;也对关闭抑制键有贡献 |
| `path` | 传给 `ArtifactFileDetail.filepath`。流式阶段通常是 `write-file:...` 虚拟 URL;落盘后的手动打开可以是真实 artifacts 路径 |
| `name` | UI 展示用;临时名阶段可能是 `未命名文件.md` |
| `mimeType` | 当 path 还不稳定时,帮助把语言判成 markdown/html,避免落成 TEXT |
| `previewKey` | **一次交付的身份**。同一次 tool call 的 path 升级要保持不变;新的 tool call 必须变 |
| `auto` | 自动打开才走关闭抑制;手动打开应清抑制 |
### 6.2 `onPreviewArtifact(artifact)` —— 对话 → 页面
页面壳 `handlePreviewArtifact` 当前逻辑顺序:
1. 校验 `threadId`、`path`,缺则 toast 并 return。
2. 计算 `artifactKey = sandboxCloseKey(artifact)`。
3. 若 `artifact.auto === true` 且 `closedSandboxKeyRef.current === artifactKey` → **抑制,直接 return**。
4. 清 `closedSandboxKeyRef`(手动打开或新的自动打开)。
5. `ArtifactsContext.select(path, true)` + `setOpen(true)`。
6. 判断是否同一预览 / 是否仅 path upgrade:
- `isSamePreview`:threadId + path + previewKey 全同 → 不重复 setState。
- `isPathUpgrade`:previewKey 相同、path 不同 → 更新 artifact,**不要**再折叠右侧抽屉。
7. 若不是 same preview:
- `setSandboxArtifact(artifact)`;
- **同步** `sandboxArtifactRef.current = artifact`(关键!flush 会立刻读 ref);
- `sandboxSnapshotAppliedAtRef.current = 0`(允许立刻推首帧内容)。
8. 若 store 里已有别的 thread 快照,先 `publish(null)`。
9. 若 `latestNodeThreadSnapshotRef` 的 threadId 匹配,立刻 `queueSandboxThreadSnapshot`。
10. 非 path upgrade 时 `setRightPanelOpen(false)`。
`sandboxCloseKey`:
```ts
if (artifact.previewKey) {
return `${artifact.threadId}:write:${artifact.previewKey}`;
}
return `${artifact.threadId}:${normalizePreviewArtifactPath(artifact.path)}`;
```
`ArtifactFileDetail` 的 React `key` 必须用这个 key:
**新 previewKey → 组件重挂 → 视觉上「清空再生成」。**
### 6.3 `onThreadSnapshot({ threadId, thread })` —— 对话 → 页面
每次 `thread` 引用变化(消息 chunk 到达)子组件都会调:
```ts
onThreadSnapshot?.({ threadId: activeThreadId, thread });
```
页面壳应:
1. 计算 `signature = sandboxThreadSignature(thread)`(用 isLoading、messages 长度、最近 write_file 的 path/content 长度/尾部等拼签名,用于去重)。
2. 写入 `latestNodeThreadSnapshotRef`(即使沙箱还没开,打开瞬间也要用)。
3. 若当前没有打开的沙箱,或打开的沙箱 threadId 不一致 → **只更新 ref,不 publish**。
4. 若一致 → `queueSandboxThreadSnapshot`(前沿节流后 `store.publish`)。
### 6.4 虚拟 URL 与正文读取
流式阶段 path 形如:
```text
write-file:${sourcePath}?message_id=${messageId}&tool_call_id=${toolCallId}&language=markdown
```
`loadArtifactContentFromToolCall`:
1. 解析 URL 的 `message_id`、`tool_call_id`;
2. 在 `thread.messages` 里找对应 AI message 的 tool call;
3. 返回 `toolCall.args.content`。
结论:
- 沙箱跟的是 **tool call 身份**,不是磁盘文件是否已经写完;
- 没有真实 path 也能显示正文;
- 临时文件名只影响展示名和语言猜测。
### 6.5 对话子组件 → 页面壳的其它相关回调
| 回调 | 用途 |
| --- | --- |
| `onPreviewArtifact` | 打开 / 更新沙箱 |
| `onThreadSnapshot` | 推送流式 thread |
| 页面壳 `closeSandbox` 传给 `ArtifactFileDetail.onClose` | 用户点关闭 |
手动从流程图 / 产物列表打开时,也可以直接调 `handlePreviewArtifact`(通常不带 `auto`,或不带 previewKey)。
---
## 7. 打开沙箱的完整步骤
### 7.1 武装(arm):只有「本轮用户提交」才允许自动开
在 `PositionNodeChat.handleSubmit` 真正 `sendMessage` 之前:
```text
activeWriteFilePreviewRef = null
autoPreviewBaselineCallKeysRef = writeFileCallKeys(thread.messages) // 历史 write 的 messageId:toolCallId
autoPreviewRunArmedRef = true
→ sendMessage(...)
```
发送失败要把 `armed` 设回 `false`。
为什么要 baseline?
线程里往往已经有上几次问答写过的 `write_file`。如果只看「messages 里有没有 write_file」,一提问就会把旧文件再打开一次。baseline 的含义是:**只有这次提交之后新出现的 tool call 才能自动开沙箱**。
### 7.2 探测 `write_file`:不要等完整 path
`write_file` 参数是一整段 JSON 流,**键顺序由模型决定**。
| 模型吐参顺序 | 步骤条表现 | 错误做法的后果 |
| --- | --- | --- |
| path / description 先到,再 content | 很快显示描述和文件名 | 普通对话页常见;等 path 也能开 |
| **content 先到,最后才 path** | 长时间只有「写入文件」,文件名很晚才出现 | 若硬等完整 path,沙箱会晚几十秒甚至一分钟 |
岗位会商报告类 agent **经常是第二种**。
`latestStreamingWriteFile` 规则:
1. 倒序扫描 AI messages 的 `tool_calls`;
2. 工具名是 `write_file` 或 `str_replace`;
3. **有完整扩展名的 path**(`hasCompleteArtifactExtension`)→ 用真实路径;
4. **尚无可用 path,但 `write_file` 的 content 已有非空字符** → 临时文件名:
- 正文头像 HTML → `未命名文件.html`
- 否则 → `未命名文件.md`
5. `str_replace` 必须等真实 path(它改已有文件);
6. 组装:
- `key = messageId:toolCallId`
- `path = write-file:虚拟URL`(write_file)或真实 path(str_replace)
- `mimeType`:markdown 则 `text/markdown`
为什么扩展名必须完整?
流式 path 会经历 `/a/b/report.m` → `/a/b/report.md`。若在 `.m` 时就当真实文件打开,预览语言可能被永久判成 TEXT。
### 7.3 上报 effect 的四个门闩
自动上报 `onPreviewArtifact` 前必须同时满足:
1. `thread.isLoading === true`
2. 探测到 streaming artifact,且其 key **不在** baseline
3. 已有 `previewThreadId`
4. `autoPreviewRunArmedRef === true`
然后处理 previewKey:
```text
若与 activePreview 是同一次 tool call(含 tool-start 前缀兼容)
→ 复用旧 previewKey // 用于临时名 → 真实 path
否则
→ previewKey = 新的 messageId:toolCallId
```
去重键:
```text
`${previewThreadId}:${previewKey}:${sourcePath}`
```
把 `sourcePath` 算进去,是为了:**同 previewKey 下临时名变成真实 path 时,还要再报一次**(path upgrade),但不会每来一个 content chunk 都报。
### 7.4 线程绑定竞态(不要误 disarm)
首轮对话常出现:
1. 用户发送(本地 draft threadId / 稍后 SDK 创建真实 id);
2. 流已经开始、armed=true;
3. 会话 API 把 `node.threadId` 从 `null` 写成真实 id。
这是「本节点绑定自己的新线程」,**不是**切节点。若绑定 effect 无脑清空 armed / baseline,首轮会永远开不了沙箱。
正确:识别 `isOwnThreadBinding`(同 nodeKey、旧 threadId 为空、新 threadId 等于当前 boundThreadId)时 **直接 return,不清 armed**。
### 7.5 path upgrade(改名)≠ 第二次交付
同一次 tool call:
```text
T1 content 到达 → path=未命名文件.md, previewKey=msg:tc1 → 打开
T2 真实 path 到达 → path=/mnt/.../报告.md, previewKey=msg:tc1 → 只改名
```
页面壳看到相同 previewKey、不同 path → `isPathUpgrade`:
- 更新 `sandboxArtifact`;
- **不**重挂(React key 仍基于 previewKey);
- **不**再折叠右侧抽屉。
### 7.6 同路径二次写入(重新生成)
同一次问答里模型可能:
```text
1) write_file → 步骤条「写入文件」→ 打开沙箱
2) 再 write_file 同 path,description=「重新生成优化版…」
```
这是**两次 tool call**,必须两个 previewKey。
若错误地「path 相同就复用 previewKey」:
- 步骤条出现第二条;
- 沙箱 React key 不变;
- 用户看到「重新生成了,但沙箱没变化」。
正确:身份只看 tool call。
### 7.7 时序总图(content-first)
```text
T0 用户发送 → armed=true,拍 baseline
T1 步骤条出现「写入文件」(尚无 path)
T1+ args.content 首字符到达 → 临时名打开沙箱,源码开始滚动
… store 约每 160ms 推一次 thread 快照 …
T2 args.path 拼完整(含 .md)→ path upgrade,标题改真实文件名(不重挂)
T3 write 结束 / isLoading=false → 自动切 Markdown 预览
```
### 7.8 时序总图(同轮二次写入)
```text
T1 write_file#1 → previewKey=msg:tc1 → 打开沙箱 A
T2 #1 结束(可能短暂切预览)
T3 write_file#2(同 path,新 description)→ previewKey=msg:tc2
→ key 变 → 组件重挂清空 → 跟 #2 重新流式
```
---
## 8. 关闭沙箱的完整步骤
### 8.1 用户手动关闭
`closeSandbox()` 应做:
1. `closedSandboxKeyRef = sandboxCloseKey(current)` —— 抑制**这一次**自动再开;
2. 关闭 `ArtifactsContext.open`(若开着);
3. 清节流定时器、pending 快照;
4. `sandboxSnapshotAppliedAtRef = 0`;
5. `sandboxArtifactRef = null` + `setSandboxArtifact(null)`;
6. `sandboxThreadStore.publish(null)`。
抑制与 path upgrade 的关系:
- 关掉临时名面板后,同一次 tool call 升级真实 path **仍被抑制**(同一 previewKey)——符合「用户不要看这次」;
- 下一次新的 `write_file`(新 previewKey)**可以**再自动开。
### 8.2 场景切换时强制关闭
以下操作应先调用 `closeSandbox()`,避免串台:
- 切岗位;
- 切节点;
- 切收口阶段;
- 新建会话;
- 切换历史会话。
是否写入 `closedSandboxKeyRef` 可按产品决定;至少必须清面板和 store。
### 8.3 错误的抑制粒度(历史坑)
若做成「关过一次就整个 thread 不再自动开」:
- 第一次实时开正常;
- 用户关掉;
- 再问一次 → 同 thread 被永久抑制 → 看起来像「第二次不实时 / 要等生成完」。
正确粒度:**单次交付的 `sandboxCloseKey`(含 previewKey)**。
---
## 9. 步骤条与沙箱的关系
### 9.1 步骤条从哪来
步骤条由 `MessageList` → `message-group.tsx` 渲染 tool call:
- 标题:优先 `args.description`;没有则 i18n 兜底「写入文件」;
- 子行:有 `args.path` 才显示路径;
- 普通对话页还会在「有 path + autoOpen」时自己 `select/setOpen`。
岗位会商里,步骤条**仍然只负责展示**;打开沙箱走 `onPreviewArtifact`,不要让两套逻辑抢。
### 9.2 对照表
| 你看到的步骤条 | 含义 | 沙箱应处于 |
| --- | --- | --- |
| 仅「写入文件」,无路径 | tool call 已开始;path/description 可能还没流到 | **应已打开**(临时名),源码在滚 |
| 「写入文件」+ 路径 | path 已可用 | 已打开;若刚从临时名升级则只改名 |
| 带自定义 description 的标题 + 路径 | 模型给了 description | 同上 |
| 第二条「重新生成…」+ 同路径 | 新的 write_file(新 toolCallId) | **应重挂**并重新流式 |
| 顶部「正在生成,完成后自动预览」 | 仍在 code 视图 | 正常,等 isLoading 结束切预览 |
| 生成结束,出现文件卡片 / present_files | 工具结果已落 | 通常已是预览模式;可手动再点开 |
### 9.3 常见误解
**误解 1:**「步骤条出现文件名 = 打开沙箱的正确时机」
→ 错。那是 path 到达的时机。content-first 时正确打开时机更早:content 首字节 / 工具调用已开始。
**误解 2:**「步骤条还在写『写入文件』,沙箱却开了,是 bug」
→ 通常不是。两边数据源相同,但沙箱允许无 path 打开,步骤条标题可能还没 description。
**误解 3:**「等生成完从编辑切预览 = 沙箱打开晚了」
→ 错。那是 `ArtifactFileDetail` 的 viewMode 策略。
---
## 10. 流式内容如何进入沙箱(性能关键)
### 10.1 为什么不能用页面 `useState` 存线程快照
错误写法:
```ts
setSandboxThreadSnapshot(snapshot) // 页面级 useState
```
后果:
- 消息 chunk 约每 30ms 一次,节流后仍约 6 次/秒;
- 每次都重渲染整页:任务卡、意图结果、流程图、节点对话……;
- 主线程占满 → 消息列表停绘、沙箱冻住;
- 纯尾随 `setTimeout(160)` 会被饿死,十几秒不 flush。
这同时解释了两个历史现象:
1. 「沙箱打开了但内容不更新」;
2. 「修完节流后整页更卡、消息也不实时」——因为节流终于开始真正 setState 了。
### 10.2 正确做法:外部 store + 子树订阅
```ts
createSandboxThreadStore()
subscribe / getSnapshot / publish
SandboxThreadProvider
useSyncExternalStore(store.subscribe, store.getSnapshot)
→ ThreadContext.Provider value={{ thread, isMock:false }}
→ ArtifactFileDetail
```
页面壳本身**不**因 snapshot 变化而重渲染;只有 Provider 及其子树重渲染。
### 10.3 节流必须是前沿(leading-edge)
`SANDBOX_SNAPSHOT_MIN_INTERVAL_MS = 160`:
```text
queue(snapshot):
pending = snapshot
if 距上次 publish >= 160ms:
立刻 flush(同步 publish)
else:
若没有在途 timer,则 setTimeout(剩余时间) 做补尾
flush:
清 timer
校验 pending.threadId === sandboxArtifactRef.threadId
publish(pending)
```
**不要**改回:
```ts
if (timer != null) return;
timer = setTimeout(flush, 160);
```
那是纯尾随;忙主线程下会假死。
### 10.4 signature 去重
`sandboxThreadSignature` 用最近 write_file 的 path、content 长度、content 尾 32 字等拼签名。
store.publish 时若 threadId+signature 未变,则不通知订阅者,减少无效渲染。
### 10.5 打开瞬间的首帧
`handlePreviewArtifact` 里往往会立刻 `queueSandboxThreadSnapshot(latestSnapshot)`。
此时 React 的 `setSandboxArtifact` 可能还没提交,若 flush 只读旧的 `sandboxArtifactRef`,会因 threadId 不匹配把首帧丢掉。
所以打开新预览时必须:
```ts
sandboxArtifactRef.current = artifact; // 同步
sandboxSnapshotAppliedAtRef.current = 0;
```
---
## 11. 与普通对话页的对照
| 点 | 普通对话页 | 岗位会商 |
| --- | --- | --- |
| 谁触发打开 | `message-group` 内 `artifacts.select` | 子组件 `onPreviewArtifact` → 页面壳 |
| 是否要求 path | 是(有 path 才自动开) | 否(write_file + content 可临时名开) |
| thread 从哪来 | 同树 ThreadContext | `onThreadSnapshot` + 外部 store |
| 关闭抑制 | 主要靠 ArtifactsContext / 用户操作 | `closedSandboxKeyRef` 按 previewKey |
| 同 path 重写 | 依赖新的 tool call / 选择逻辑 | **必须**新 previewKey 以重挂 |
| 结束后切预览 | ArtifactFileDetail 同一套 | 同一套 |
对齐目标是**用户感知一致**,不是代码路径完全相同。
普通对话页自动开的核心条件(对照用):
```ts
artifacts && isLoading && isLast && autoOpen && autoSelect && path && !result
```
它也要求 path,所以 content-first 时普通页同样会偏晚——只是很多主聊天示例 agent 先吐 path,观感更好。岗位会商报告 agent 更常 content-first,所以必须做临时名提前开。
---
## 12. 重做页面时的必守规则
按优先级:
1. **流式 thread 快照禁止进页面级 `useState`** → 用 store + `useSyncExternalStore`。
2. **节流用前沿 + 补尾**,不要纯尾随 timer。
3. **不要等完整 path 才开**;`write_file` + content 即可临时名打开。
4. **扩展名完整前不要把 path 当最终 path**。
5. **previewKey 按 tool call,不按 path**。
6. **打开时同步写 `sandboxArtifactRef`,并重置节流时间戳**。
7. **主路径扫描 `thread.messages`,不要只靠 `onToolStart`**。
8. **path upgrade 保持 previewKey,不要当第二次交付**。
9. **关闭抑制按 sandboxCloseKey,不要按整 thread**。
10. **沙箱单例在页面壳;子组件只上报**。
11. **本节点 threadId 回写不要 disarm**。
12. **「结束后才切预览」不要当打开 bug 去「修」掉共享组件**,除非产品明确要求边生成边渲染 Markdown(需单独评估性能)。
---
## 13. 历史问题与根因(务必读)
下面每个都在真实联调里出现过。重做时若踩中,优先回来对照。
### 13.1 沙箱要等文件名出来才开(晚几十秒)
**现象:** 步骤条早就「写入文件」,沙箱很晚才开;普通对话页对比显得「实时」。
**根因:** 探测逻辑硬等完整 `path`;而该 agent 先流 `content`、最后才流 `path`。
**修复:** content 首批字符即可用临时名打开;path 到了再 upgrade。
### 13.2 沙箱打开了,内容冻住,生成完才灌满
**现象:** 面板开了,但里面一直是空/旧内容;日志里 queue 很多,flush 很少。
**根因:** 纯尾随 160ms 定时器在忙主线程下被饿死。
**修复:** 前沿节流——到期就在流回调里同步 flush。
### 13.3 修完「内容更新」后,整页卡顿、消息也不实时
**现象:** 沙箱开始更新了,但页面更卡,消息列表也「攒到最后刷」。
**根因:** flush 走的是页面 `useState`,每 160ms 整页重渲染。以前 timer 饿死时反而「不卡」。
**修复:** 快照改外部 store,只让沙箱子树订阅。
### 13.4 第二次「重新生成」步骤有了,沙箱不变
**现象:** 同轮第二条二条 write 步骤,沙箱仍显示第一次完整结果。
**根因:** 用「path 相同」复用了第一次 previewKey,React key 不变,组件不重挂。
**修复:** previewKey 只按 tool call;同 path 新 call 必须新 key。
### 13.5 第一次实时,关掉后再问就不实时
**现象:** 关过沙箱后,下一轮要等生成完才开,或不开。
**根因:** 抑制绑在整个 threadId,或把 path upgrade / 新 call 误判成同一次。
**修复:** 抑制键 = `sandboxCloseKey(previewKey)`;仅抑制那一次交付。
### 13.6 首轮永远不开
**现象:** 新建节点第一次提问,armed 后很快又不满足条件。
**根因:** `node.threadId` 从 null 回写真实 id 时,绑定 effect 误 disarm。
**修复:** `isOwnThreadBinding` 时不清 armed。
### 13.7 预览变成纯文本
**现象:** 明明是 md,却按 TEXT 打开。
**根因:** 在 `.m` 阶段就当完整 path;或临时名没有 `.md` / 没传 language、mimeType;或 path 带空格。
**修复:** `hasCompleteArtifactExtension`;临时名带扩展名;URL 加 `language=markdown`;path trim。
### 13.8 依赖 onToolStart,线上偶发不开
**现象:** 本地偶发正常,联调经常不开。
**根因:** 后端 StreamBridge / worker 可能过滤 tools/events 类 stream mode。
**修复:** 主路径必须是 messages 里渐进出现的 `tool_calls`;onToolStart 只能当补充。
---
## 14. 排障手册(按症状)
排障时建议先看 Chrome Performance / React 重渲染是否整页抖,再看消息里 tool_calls 的 args 是 path-first 还是 content-first。
### 14.1 沙箱开得很晚
检查顺序:
1. `latestStreamingWriteFile` 是否仍要求完整 path?
2. armed 是否被 threadId 回写清掉?
3. 是否命中 `closedSandboxKey`?
4. 新 call 是否被 baseline 当成历史?
5. `previewThreadId` 是否为空(线程还没绑上)?
### 14.2 打开了但内容空白 / 冻住
1. 快照是否进了页面 useState?
2. 节流是否纯尾随?
3. `SandboxThreadProvider` 是否拿到带 messages 的 thread?
4. 虚拟 URL 的 message_id / tool_call_id 是否对得上?
5. 打开时 `sandboxArtifactRef` 是否同步?(首帧是否被 drop)
6. signature 是否因实现错误一直不变,导致 publish 被去重?
### 14.3 整页卡、消息列表也不实时
1. 搜页面壳是否还有 `setSandboxThreadSnapshot` / 类似大对象 state。
2. 是否在每 chunk `console.debug` 打大对象(DevTools 打开时更卡)。
3. 是否错误地让页面壳订阅了 store(页面壳不应 `useSyncExternalStore` 这份 store;只有 Provider 订)。
### 14.4 重新生成不刷新
1. 第二次 write 的 toolCallId 是否变了?
2. previewKey 是否仍用 path 复用?
3. `ArtifactFileDetail` 的 `key=` 是否基于 sandboxCloseKey/previewKey?
### 14.5 关后再问异常
1. 打印 `closedSandboxKeyRef` 与新 artifact 的 sandboxCloseKey。
2. 确认新一轮 send 时 armed/baseline 已重置。
3. 确认抑制不是整 thread。
### 14.6 语言 / 预览模式不对
1. 看打开时的 path / mimeType / language 查询参数。
2. 确认不是在扩展名不完整时打开。
3. 确认「源码→预览」发生在 isLoading false 之后(属正常)。
---
## 15. 对接自测清单
重做页面后至少跑完:
### 功能
- [ ] 首轮:步骤条「写入文件」出现时,沙箱是否已开并开始出字?
- [ ] content-first agent:无文件名阶段是否也能开?
- [ ] path 到达后:是否只改名、不整页闪、不重挂?
- [ ] 同轮第二次同路径 write:是否清空并重新流式?
- [ ] 结束后是否自动切 Markdown 预览?
- [ ] 手动点步骤条路径 / 产物卡片:能否打开正确文件?
### 关闭与抑制
- [ ] 流式中手动关闭:本次是否不再自动弹?
- [ ] 关闭后再发一轮新问题:是否仍可自动开?
- [ ] path upgrade 是否不会绕过「用户刚关闭」的抑制?
### 场景切换
- [ ] 切节点:沙箱关闭,旧内容不残留。
- [ ] 切岗位:同上。
- [ ] 新建会话 / 切历史:同上。
### 性能
- [ ] 流式中消息列表是否实时出字?
- [ ] 页面是否明显掉帧 / 卡死?
- [ ] React DevTools 中,大页面壳是否在每 160ms 整页 update?(不应)
---
## 16. 最小接线示例
### 16.1 页面壳(示意)
```tsx
const [sandboxArtifact, setSandboxArtifact] = useState<PositionArtifactPreview | null>(null);
const sandboxArtifactRef = useRef<PositionArtifactPreview | null>(null);
const closedSandboxKeyRef = useRef<string | null>(null);
const latestNodeThreadSnapshotRef = useRef<SandboxThreadSnapshot | null>(null);
const store = useMemo(createSandboxThreadStore, []);
// pending + timer + appliedAt 用于前沿节流(见 PositionRoundtablePage)
function handlePreviewArtifact(artifact: PositionArtifactPreview) {
if (!artifact.threadId || !artifact.path) return;
const key = sandboxCloseKey(artifact);
if (artifact.auto && closedSandboxKeyRef.current === key) return;
closedSandboxKeyRef.current = null;
artifactsCtx?.select(artifact.path, true);
artifactsCtx?.setOpen(true);
const prev = sandboxArtifactRef.current;
const isSame =
prev?.threadId === artifact.threadId &&
prev.path === artifact.path &&
prev.previewKey === artifact.previewKey;
const isPathUpgrade =
!isSame &&
Boolean(artifact.previewKey) &&
prev?.threadId === artifact.threadId &&
prev.previewKey === artifact.previewKey;
if (!isSame) {
sandboxArtifactRef.current = artifact; // 必须同步
setSandboxArtifact(artifact);
sandboxSnapshotAppliedAtRef.current = 0;
}
if (store.getSnapshot()?.threadId !== artifact.threadId) {
store.publish(null);
}
const latest = latestNodeThreadSnapshotRef.current;
if (latest?.threadId === artifact.threadId) {
queueSandboxThreadSnapshot(latest);
}
if (!isPathUpgrade) setRightPanelOpen(false);
}
function handleNodeThreadSnapshot(snapshot: { threadId: string; thread: BaseStream<AgentThreadState> }) {
const next = { ...snapshot, signature: sandboxThreadSignature(snapshot.thread) };
latestNodeThreadSnapshotRef.current = next;
if (sandboxArtifactRef.current?.threadId !== snapshot.threadId) return;
queueSandboxThreadSnapshot(next);
}
function closeSandbox() {
if (sandboxArtifactRef.current) {
closedSandboxKeyRef.current = sandboxCloseKey(sandboxArtifactRef.current);
}
// clear timer / pending / appliedAt
sandboxArtifactRef.current = null;
setSandboxArtifact(null);
store.publish(null);
artifactsCtx?.setOpen(false);
}
// 渲染
{sandboxArtifact && (
<SandboxThreadProvider
store={store}
threadId={sandboxArtifact.threadId}
fallbackThread={stub}
>
<ArtifactFileDetail
key={sandboxCloseKey(sandboxArtifact)}
filepath={sandboxArtifact.path}
threadId={sandboxArtifact.threadId}
mimeType={sandboxArtifact.mimeType}
onClose={closeSandbox}
allowVirtualMarkdownEdit
/>
</SandboxThreadProvider>
)}
```
### 16.2 对话子组件(示意)
```tsx
// send 前
activeWriteFilePreviewRef.current = null;
autoPreviewBaselineCallKeysRef.current = writeFileCallKeys(thread.messages);
autoPreviewRunArmedRef.current = true;
await sendMessage(...);
// 探测
const streaming = thread.isLoading ? latestStreamingWriteFile(thread.messages) : null;
const next =
streaming && !autoPreviewBaselineCallKeysRef.current.has(streaming.key)
? streaming
: null;
useEffect(() => {
if (!thread.isLoading || !next || !previewThreadId || !autoPreviewRunArmedRef.current) return;
// previewKey:同 tool call 复用,否则用 next.key
// artifactKey = threadId:previewKey:sourcePath 去重
onPreviewArtifact?.({
threadId: previewThreadId,
path: next.path,
name: next.name,
mimeType: next.mimeType,
previewKey,
auto: true,
});
}, [next, previewThreadId, thread.isLoading, onPreviewArtifact]);
useEffect(() => {
onThreadSnapshot?.({ threadId: activeThreadId, thread });
}, [activeThreadId, thread, onThreadSnapshot]);
```
**强烈建议:** 探测 / armed / baseline / 临时名逻辑直接复用或抽取自 `PositionNodeChat`,不要从零重写。
---
## 17. 关键代码索引
| 主题 | 位置 |
| --- | --- |
| 临时名 / 完整扩展名 / latestStreamingWriteFile | `PositionNodeChat.tsx` 中 `provisionalWriteFilePath`、`hasCompleteArtifactExtension`、`latestStreamingWriteFile` |
| armed / baseline / 上报 effect | `PositionNodeChat.tsx` `handleSubmit` + streaming preview `useEffect` |
| 同 tool call 判断 / 二次写入重挂 | 同上,`isSameToolCall` / `previewKey` |
| 线程绑定保护 | `PositionNodeChat.tsx` node binding `useEffect` 中 `isOwnThreadBinding` |
| sandboxCloseKey / store / 前沿节流 | `PositionRoundtablePage.tsx` `sandboxCloseKey`、`createSandboxThreadStore`、`queueSandboxThreadSnapshot` |
| 打开 / path upgrade / 抑制 | `PositionRoundtablePage.tsx` `handlePreviewArtifact` |
| 关闭 | `PositionRoundtablePage.tsx` `closeSandbox` |
| 虚拟 URL 读 content | `core/artifacts/loader.ts` `loadArtifactContentFromToolCall` |
| 源码/预览切换 | `artifact-file-detail.tsx` 中 `viewMode` + `thread.isLoading` |
| 普通页步骤条自动开 | `message-group.tsx` `write_file` 分支 |
相关提交(背景):
- `fix(frontend): 岗位会商沙箱在 write_file 流式阶段实时打开并刷新`
— 临时名打开、外部 store、前沿节流
- `fix(frontend): 同路径二次 write_file 时重挂岗位会商沙箱`
— previewKey 按 tool call,禁止按 path 复用
---
## 18. 术语表
| 术语 | 含义 |
| --- | --- |
| armed | 本轮用户发送后,允许自动打开沙箱的开关 |
| baseline | send 前已有的 write tool call 集合,用于忽略历史产物 |
| previewKey | 一次交付的稳定身份,推荐 `messageId:toolCallId` |
| sandboxCloseKey | 关闭抑制键,同时用作 `ArtifactFileDetail` 的 React key |
| provisional path / 临时名 | 尚无真实 path 时的 `未命名文件.md` / `.html` |
| path upgrade | 同 previewKey 下临时名升级为真实 path |
| sandboxThreadStore | 流式线程外部 store,避免整页重渲染 |
| write-file: URL | 用 message_id + tool_call_id 定位流式 content 的虚拟路径 |
| leading-edge throttle / 前沿节流 | 到期立即在回调里 publish,而不是只挂尾随 timer |
| content-first / path-first | 模型流式 JSON 参数时 content 与 path 哪个先到 |
| isOwnThreadBinding | 本节点把自己的新 threadId 写回 props,不是切节点 |
---
## 附录 A:给对接同学的一页纸摘要
1. 页面壳只挂一个沙箱;子组件只调用 `onPreviewArtifact` / `onThreadSnapshot`。
2. 自动打开时机 ≈ 步骤条出现「写入文件」时,而不是出现文件名时。
3. `write_file` 可以没有 path:临时名打开,path 到了再改名。
4. 流式 thread 进外部 store,进页面 state 会卡死。
5. 节流要前沿;previewKey 看 tool call;关闭抑制看 sandboxCloseKey。
6. 同路径重新生成 = 新 previewKey = 沙箱重挂。
7. 出问题先对照第 13、14 节,再看代码索引第 17 节。
@@ -1,822 +0,0 @@
# 沙箱统一写作管线:可实现开发方案
> 状态:设计稿;本文件只定义实施方案,不包含本轮业务代码改动。
> 覆盖范围:普通沙箱 Markdown、深度研究 `report.md`、现有 Markdown 编辑器的选区/段落改写。
## 1. 目标与结论
目标不是再增加一个“AI 写作页面”,而是将现有的四类能力统一为一个安全、可流式、可追溯的**文档改写管线**:
1. Markdown 编辑器选区改写;
2. Markdown 编辑器段落改写;
3. 深度研究报告的局部候选改写;
4. 沙箱中 Markdown 文件的全文改写。
该管线负责固定原文版本、构造上下文、逐 token 输出、校验 Markdown、生成可核验 diff、按策略提交,以及在全文覆盖时保存可撤销版本。
结论:需求可行,现有工程已有足够的复用基础:
- `frontend-web/src/open-canvas/components/artifact/MarkdownAiEditor.tsx` 已具备局部改写弹窗与人工“替换/插入/重试/追问”交互。
- `frontend-web/src/open-canvas/hooks/useWritingRewrite.ts` 与 `frontend-web/src/open-canvas/api/rewrite.ts` 已具备 SSE 文本流消费。
- `offline-backend-20260512/backend/app/gateway/routers/writing.py` 已具备模型流式读取实现。
- `offline-backend-20260512/backend/app/gateway/routers/deep_research.py` 已具备报告哈希、候选内容和用户确认应用机制。
- `frontend-web/src/components/workspace/messages/message-list.tsx`、`MessageGroup.tsx` 已可承载普通对话的工具步骤条、流式消息和文件卡片。
- `frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx` 已经通过虚拟消息把研究进度接入普通 `MessageList`。
因此应该复用这些基础,建设一个执行内核;不能为全文改写另建独立聊天列表或独立写作流。
## 2. 产品边界与交互策略
### 2.1 支持范围
第一版仅对 `.md`、`.markdown` 文件显示“AI 全文改写”按钮。
不支持 PDF、DOCX、HTML、图片和二进制文件;不在改写过程中发起新的联网搜索;超大文件不静默截断后覆盖,而是提示用户选择章节改写。
### 2.2 范围、模式和提交策略
| 入口 | 文本范围 | 管线模式 | 提交策略 | 展示位置 |
| --- | --- | --- | --- | --- |
| 编辑器气泡菜单 | 当前选区 | `selection` | `preview_only` | 现有改写预览弹窗 |
| 编辑器段落菜单 | 当前段落 | `paragraph` | `preview_only` | 现有改写预览弹窗 |
| 深度研究“改写第一段/章节” | 被解析出的报告片段 | `report_section` | `manual_apply` | 普通消息列表候选稿 + 确认卡 |
| 文件右上角“AI 全文改写” | 当前完整 Markdown | `document` | `auto_commit_after_validation` | 普通消息列表步骤条 + 右侧沙箱 |
统一内核中的枚举:
~~~python
class RewriteMode(str, Enum):
SELECTION = "selection"
PARAGRAPH = "paragraph"
REPORT_SECTION = "report_section"
DOCUMENT = "document"
class CommitPolicy(str, Enum):
PREVIEW_ONLY = "preview_only"
MANUAL_APPLY = "manual_apply"
AUTO_COMMIT_AFTER_VALIDATION = "auto_commit_after_validation"
~~~
这三类策略必须严格区分:
- `preview_only`:只生成候选,前端编辑器确认后才改本地 TipTap 文档;不写真实沙箱文件。
- `manual_apply`:后端保留基准哈希和候选片段,用户点确认后才将该片段应用进研究报告。
- `auto_commit_after_validation`:全文生成完成并校验通过后,后端进行一次带哈希校验的原子覆盖;同时保存可撤销版本。
### 2.3 局部编辑器:保持当前体验
现有文件:
- `frontend-web/src/open-canvas/components/artifact/MarkdownAiEditor.tsx`
- `frontend-web/src/open-canvas/hooks/useWritingRewrite.ts`
- `frontend-web/src/open-canvas/api/rewrite.ts`
- `offline-backend-20260512/backend/app/gateway/routers/writing.py`
局部改写仍然是:
1. 选中文字或打开段落菜单;
2. 选择润色、扩写、缩写、改写等动作;
3. 在现有弹窗中流式出现候选;
4. 用户手动选择替换、插入、重试或继续追问。
不要让局部改写每次都往普通消息列表插完整步骤条,否则频繁编辑会淹没聊天历史。它只共享底层管线,不共享全文改写的消息展示方式。
需要补充本地版本保护:发起请求时保存当前完整编辑器文本哈希、选区 `from/to` 和选区文本;用户点击“替换原文”前再次检查该范围仍匹配。若用户已修改原文,提示“原文已变化,请重新生成候选稿”,禁止将过期候选替换到错误位置。
### 2.4 右上角“AI 全文改写”
宿主文件:
- `frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx`
新增独立图标按钮,位置在右上角文件操作区,靠近保存、复制、下载一类操作。它**不能**替代页面中间当前 Sparkles 编辑模式:
| 控件 | 职责 |
| --- | --- |
| 中部 Sparkles/编辑模式 | 打开 `MarkdownAiEditor`,处理选区和段落 |
| 新增右上角 AI 全文改写 | 对整个 Markdown 发起后台全文重写任务 |
建议显示条件:
~~~ts
const canUseFullDocumentRewrite =
isMarkdownFile &&
canEditArtifact &&
!isSkillFile &&
!isMock &&
!useWriteFileStreaming &&
!hasLocalEdits;
~~~
若 `hasLocalEdits` 为真,按钮置灰并提示“请先保存当前修改后再进行全文改写”。后端全文任务从真实文件读取快照,不能在用户本地还有未保存修改时读取旧版本并最终覆盖新修改。
用户体验:
1. 点击按钮,打开简洁配置弹窗。
2. 弹窗显示文件名、原文字数;用户填写改写要求、选择模型、选择风格。
3. 点击开始,左侧普通消息列表创建一组步骤条。
4. 右侧沙箱自动切到源码视图,先显示“正在准备临时草稿,原文件尚未修改”。
5. 新 Markdown 从空临时草稿开始逐 token 写入右侧代码区域。
6. 校验通过后,后端原子替换真实内容;前端再同步真实 Artifact 缓存。
7. 左侧保留最终对比总结、查看 diff 按钮和撤销按钮。
任务进行中,同一个文件的保存、直接编辑、局部编辑和再次全文改写必须禁用;其他文件不受影响。
### 2.5 深度研究报告的约束
普通 Markdown 和深度研究报告使用同一管线,但不能使用同一上下文策略:
| 能力 | 普通沙箱 Markdown | 深度研究 report.md |
| --- | --- | --- |
| 输入上下文 | 原文 + 用户指令 | 原文 + 当前会话已选资料 |
| 联网 | 不联网 | 不联网 |
| 事实约束 | 不新增无依据事实 | 只能引用已选资料,禁止编造 |
| 引用校验 | 可选 | 必须校验来源 ID 白名单 |
| 最终存储 | Artifact 文件 | 研究 session 的 `report_markdown` |
| 局部改写 | 编辑器确认 | 现有确认卡片 |
深度研究右侧的 `report.md` 是由 `ResearchSandboxPanel.tsx` 以虚拟 `write_file` 方式展示的;它不能当作普通磁盘 Artifact 直接写入。全文任务必须通过研究 session 更新 `report_markdown`,并使 `report_html` 缓存失效。
## 3. 总体架构
~~~mermaid
flowchart LR
A["MarkdownAiEditor<br/>选区 / 段落"] --> P
B["ArtifactFileDetail<br/>AI 全文改写"] --> P
C["DeepResearch<br/>局部 / 全文"] --> P
P["Document Rewrite Pipeline<br/>冻结版本 → 构造上下文 → 流式生成<br/>校验 → 差异总结 → 按策略提交"]
P --> I["InlineDocumentSource"]
P --> F["ArtifactDocumentSource"]
P --> R["DeepResearchReportSource"]
P --> E["可恢复 SSE 事件 / Job"]
E --> L["普通 MessageList"]
E --> S["右侧沙箱临时草稿"]
P --> V["内容哈希、版本快照、原子提交"]
~~~
设计原则:
1. 纯执行内核不依赖 FastAPI、路由、用户身份或操作系统路径。
2. 鉴权、线程路径解析、研究 session 获取只在 `app` 层的来源适配器中完成。
3. 完整文档任务使用持久化后台 Job;局部弹窗可维持单次 SSE,避免过度持久化。
4. 全文流式过程中只写临时草稿;真实内容只在最终 compare-and-swap 成功时改变。
不要直接把现有完整“AI 写作 Agent”塞进改写按钮。那个流程适合从主题写新文章,可能包含大纲、资料和章节生命周期;本需求是严格以当前文档为基准改写。第一版复用它的流式视觉表达和消息列表能力,但使用专门的文档改写执行内核。
## 4. 后端模块设计
### 4.1 目录与职责
建议新增:
~~~text
offline-backend-20260512/backend/
├─ packages/harness/deerflow/
│ ├─ document_rewrite/
│ │ ├─ __init__.py
│ │ ├─ models.py # 请求、事件、校验、差异领域模型
│ │ ├─ ports.py # Source / Model / Store 协议
│ │ ├─ pipeline.py # 统一编排,不导入 app.*
│ │ ├─ prompt_builder.py # 普通与深度研究提示词
│ │ ├─ validation.py # Markdown / 引用 / 截断检查
│ │ └─ diff_summary.py # 字数、标题、块、引用差异
│ └─ persistence/
│ └─ document_rewrites/
│ ├─ base.py
│ ├─ model.py
│ └─ sql.py
├─ app/gateway/
│ ├─ routers/document_rewrites.py
│ ├─ document_rewrite_sources.py
│ └─ document_rewrite_executor.py
└─ packages/harness/deerflow/persistence/migrations/versions/
└─ <next_revision>_document_rewrites.py
~~~
`packages/harness/deerflow/document_rewrite` 不能导入 `app.*`,以符合项目 harness→app import firewall。`app/gateway/document_rewrite_sources.py` 负责把已鉴权的 Artifact/研究会话包装成内核端口。
### 4.2 来源端口
~~~python
class DocumentSource(Protocol):
async def read_snapshot(self) -> DocumentSnapshot: ...
async def read_current_hash(self) -> str: ...
async def commit_if_unchanged(
self,
*,
expected_hash: str,
content: str,
metadata: CommitMetadata,
) -> CommitResult: ...
async def restore_if_unchanged(
self,
*,
expected_hash: str,
content: str,
) -> CommitResult: ...
~~~
三种实现:
| 适配器 | 使用场景 | 读取方式 | 提交方式 |
| --- | --- | --- | --- |
| `InlineDocumentSource` | 选区/段落候选 | 前端编辑器快照 | 不提交 |
| `ArtifactDocumentSource` | 普通 Markdown | 复用 Artifact 权限与虚拟路径解析 | 同目录临时文件 + `os.replace` |
| `DeepResearchReportSource` | 研究报告 | session `report_markdown` + 已选来源 | 数据库哈希条件更新 |
`ArtifactDocumentSource` 必须复用 `app/gateway/routers/artifacts.py` 中已有的线程访问校验、虚拟路径解析逻辑,绝不允许请求体传入任意本机绝对路径。
### 4.3 持久化 Job、事件和版本
全文改写使用持久化 Job,参考 `app/gateway/deep_research_job_executor.py` 的后台任务、租约、取消监听和事件重放模式。
#### document_rewrite_jobs
| 字段 | 作用 |
| --- | --- |
| `id` | `drw_xxx` Job ID |
| `user_id` | 发起人 |
| `source_kind` | `artifact` / `deep_research_report` |
| `thread_id`、`artifact_path` | 普通文件来源 |
| `research_session_id` | 研究报告来源 |
| `presentation_thread_id` | 普通消息列表归属 |
| `mode`、`commit_policy` | 范围和提交语义 |
| `instruction`、`style`、`model_name` | 用户配置 |
| `base_sha256` | 原文冻结哈希 |
| `draft_content`、`draft_sha256` | 临时流式草稿 |
| `status`、`stage` | 生命周期 |
| `validation_json`、`diff_summary_json` | 可展示结果 |
| `committed_sha256` | 成功提交后的哈希 |
| `error_message`、时间字段 | 错误、审计、取消 |
#### document_rewrite_events
至少包含 `job_id`、`seq`、`event_type`、`stage`、`payload_json`、`created_at`。`seq` 用于断线重连时通过 `after_seq` 重放。
不必将每个 token 单独入库:每 250ms 或每积累 4KB 持久化一次 `draft_content`;状态转换、警告、完成、冲突、取消必须立即写事件。重连时先发送最新草稿快照,再发送快照序号后的增量事件。
#### document_rewrite_versions
保存真实全文覆盖前的原文版本,字段包含 `job_id`、`source_identity`、`version_no`、`content`、`sha256`、`reason`、`created_at`。初版每个文档保留最近 20 个版本即可。
索引要求:
- `(user_id, presentation_thread_id, created_at DESC)`:恢复历史消息。
- 真实来源标识 + 活跃状态:防止同一文档并行两个全文改写。
### 4.4 Job 状态机
~~~mermaid
stateDiagram-v2
[*] --> queued
queued --> snapshotting
snapshotting --> generating
generating --> validating
validating --> comparing
comparing --> committing
committing --> completed
completed --> reverted
queued --> cancelled
generating --> cancelled
snapshotting --> failed
generating --> failed
validating --> failed
committing --> conflict
committing --> failed
~~~
状态约束:
- 同一真实文档只允许一个活跃 Job。
- 生成期间取消、异常、断线,真实文档绝不能变化。
- 提交前重新读取当前哈希;与 `base_sha256` 不一致时进入 `conflict`,草稿保留但不覆盖。
### 4.5 API 与 SSE 契约
创建全文任务:
~~~http
POST /api/document-rewrites
Content-Type: application/json
~~~
普通 Artifact 请求:
~~~json
{
"source": {
"kind": "artifact",
"thread_id": "thread_xxx",
"path": "/mnt/user-data/outputs/report.md"
},
"presentation_thread_id": "thread_xxx",
"mode": "document",
"instruction": "统一为正式行业分析语气,减少重复,不新增外部事实",
"style": "formal_analysis",
"model_name": "system_default"
}
~~~
深度研究请求:
~~~json
{
"source": {
"kind": "deep_research_report",
"session_id": "drs_xxx"
},
"presentation_thread_id": "collector_thread_xxx",
"mode": "document",
"instruction": "重新组织全文逻辑,保留全部可用引用",
"style": "formal_analysis",
"model_name": "system_default"
}
~~~
其他接口:
~~~text
GET /api/document-rewrites/{job_id}
GET /api/document-rewrites/{job_id}/stream?after_seq=42
POST /api/document-rewrites/{job_id}/cancel
POST /api/document-rewrites/{job_id}/undo
GET /api/document-rewrites?presentation_thread_id={thread_id}
~~~
SSE 事件:
| 事件 | 关键数据 | 前端动作 |
| --- | --- | --- |
| `job_started` | 文件名、模式 | 建立步骤条 |
| `snapshot_fixed` | 原文字数、哈希 | 完成“固定原文版本” |
| `requirements_resolved` | 模型、风格、要求 | 完成“理解改写要求” |
| `rewrite_delta` | `delta`、累计字符数 | 追加右侧临时草稿 |
| `validation_started` | 校验项 | 进入校验步骤 |
| `validation_completed` | passed、warnings | 显示校验结果 |
| `comparison_ready` | `DiffSummary` | 渲染总结数据 |
| `commit_started` | 无 | 显示安全替换中 |
| `committed` | 最终哈希、版本 ID | 刷新真实文件缓存 |
| `completed` | 完整结果 | 固化历史步骤条 |
| `conflict` / `error` / `cancelled` | 原因 | 恢复原文显示,不保存草稿 |
局部改写兼容方案:
- `POST /api/writing/rewrite` 的请求和 SSE 格式先不变。
- `writing.py` 内部逐步把 prompt、模型流、基础 Markdown 校验下沉给统一管线的 `preview_only` 分支。
- 深度研究 `/sessions/{id}/rewrite/stream` 和确认候选接口也先保持 URL、前端行为不变,再内部复用 `manual_apply` 分支。
这样可以先交付全文改写,不会一次性破坏已有编辑器和深度研究流程。
### 4.6 模型、提示词与校验
管线只依赖一个流式模型端口:
~~~python
class StreamingRewriteModel(Protocol):
async def stream_text(
self,
*,
messages: list[BaseMessage],
model_name: str | None,
) -> AsyncIterator[TextChunk]: ...
~~~
第一版可以有两个 adapter:
- 普通文档复用 `writing.py` 的 chat model `astream`;
- 深度研究复用 `deep_research.py` 使用的 `DeerFlowCompletionBackend.stream_complete`。
先统一编排,不强制立即统一所有模型配置实现。
普通 Markdown 系统约束:
~~~text
仅根据原文与用户要求改写,不联网,不补充未给出的事实。
保留有效链接、代码块、表格、标题语义和用户明确要求保留的内容。
只输出完整 Markdown 正文;不得输出解释、前缀或“改写如下”。
~~~
深度研究额外约束:
~~~text
不得联网。事实、数字、结论和引用只能来自当前 session 已选资料。
只允许使用给出的 source_id;资料不足时保持谨慎表述,禁止自行补全。
~~~
校验规则:
| 类别 | 硬失败 | 告警但允许完成 |
| --- | --- | --- |
| 正文 | 空文档、仅空白、无可见内容 | 改写后与原文完全一致 |
| Markdown | 未闭合围栏、明确截断 | 标题层级跳级、章节数明显减少 |
| 深度研究来源 | 未选来源 ID、非法引用 | 引用数量下降、可能需核验的数字断言 |
## 5. 安全提交、对比与撤销
### 5.1 临时草稿不等于真实文件
用户视觉上看到“先清空再逐 token 写入”,但真实文件的处理必须是:
1. 读取原文并保存内容哈希、版本快照。
2. 右侧只展示内存/Job 中的 `draft_content`。
3. 生成完成后做校验和 diff。
4. 提交前再次读取真实来源,并比较 `SHA-256`。
5. 哈希一致才原子替换;否则进入冲突,不覆盖。
普通 Artifact 使用同目录临时文件、刷盘和 `os.replace`;深度研究通过数据库事务内的“旧哈希条件更新”提交。任何失败、取消或断流都保留真实原文。
### 5.2 撤销
撤销流程:
1. 读取该 Job 的 `before_full_rewrite` 快照。
2. 确认当前真实内容哈希仍等于该 Job 的 `committed_sha256`。
3. 若用户在改写后又手工编辑,返回冲突,不盲目覆盖。
4. 先保存当前内容为 `before_undo` 版本。
5. 原子恢复旧版本,Job 状态改为 `reverted`,消息列表卡片同步显示“已撤销”。
### 5.3 结构化 DiffSummary
后端产出结构化差异,前端仅渲染,避免生成空泛结论:
~~~ts
type DiffSummary = {
sourceDisplayName: string;
instruction: string;
modelName: string;
originalCharCount: number;
rewrittenCharCount: number;
charChangeRatio: number;
changedBlockCount: number;
headings: {
added: string[];
removed: string[];
reordered: string[];
levelWarnings: string[];
};
citations?: {
beforeCount: number;
afterCount: number;
retainedCount: number;
removedIds: string[];
invalidIds: string[];
};
requestedOptimizations: string[];
validationWarnings: string[];
};
~~~
推荐总结展示:
~~~text
已完成《新能源汽车市场分析报告》的整体改写。
- 改写范围:全文;要求为“统一正式分析语气、减少重复”;模型:默认模型。
- 数量变化:原文 8,260 字,改写后 8,110 字,减少 1.8%;调整 14 个内容块。
- 结构变化:标题层级保持不变;未新增或删除一级标题。
- 已执行的优化:按要求合并重复表述、统一语气、调整段落衔接。
- 保留情况:保留 35 条已选来源引用,未新增外部来源。
- 风险提示:无。
[查看前后对比] [撤销到改写前版本]
~~~
“已执行的优化”仅来自用户要求和可计算的结构差异;不能无依据宣称“文章更专业、更严谨”。
## 6. 前端实现设计
### 6.1 新增模块
~~~text
frontend-web/src/core/document-rewrite/
├─ types.ts
├─ api.ts
├─ sse.ts
├─ use-document-rewrite-job.ts
├─ document-rewrite-provider.tsx
└─ document-rewrite-presentation.ts
frontend-web/src/components/workspace/document-rewrite/
├─ DocumentRewriteDialog.tsx
├─ DocumentRewriteProgressStep.tsx
├─ DocumentRewriteSummaryCard.tsx
└─ DocumentDiffDialog.tsx
~~~
职责:
| 模块 | 职责 |
| --- | --- |
| `api.ts` | 创建、查询、取消、撤销 Job |
| `sse.ts` | SSE 解析、`after_seq` 重连 |
| `use-document-rewrite-job.ts` | Job 状态、草稿、rAF 合并 token |
| Provider | 同时向文件面板与消息列表提供同一任务状态 |
| `DocumentRewriteDialog` | 改写要求、模型、风格配置 |
| ProgressStep | 普通消息列表内的五步步骤条 |
| SummaryCard | 结构化总结、diff、撤销动作 |
| DiffDialog | 原文与改写后双栏/行级对比 |
模型选择使用项目已有的 `tdesign-react` 选择组件,放在全文改写配置弹窗中;局部编辑器保留当前模型传递方式,后续再统一选择器。
### 6.2 流式平滑:每帧合并 delta
不能因为 SSE 是 token 流就每 token 调一次 `setState`。应当网络层完整读取、渲染层按动画帧合并:
~~~ts
const pendingDeltaRef = useRef("");
const frameRef = useRef<number | null>(null);
function onRewriteDelta(delta: string) {
pendingDeltaRef.current += delta;
if (frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
const pending = pendingDeltaRef.current;
pendingDeltaRef.current = "";
frameRef.current = null;
setDraftContent((previous) => previous + pending);
});
}
~~~
完成、失败、取消前必须 flush 最后一段缓存。右侧代码编辑器只消费 `draftContent`;真实 Artifact 查询缓存直到 `committed` 事件才更新。
### 6.3 ArtifactFileDetail 改造
在 `artifact-file-detail.tsx` 增加:
~~~ts
type DocumentRewriteTarget =
| {
kind: "artifact";
threadId: string;
path: string;
presentationThreadId: string;
}
| {
kind: "deep_research_report";
sessionId: string;
presentationThreadId: string;
};
~~~
改造步骤:
1. 加入右上角全文改写图标按钮和 `DocumentRewriteDialog`。
2. 有活跃任务时强制 `viewMode = "code"`。
3. 内容优先级为:`activeRewrite.draftContent ?? draftContent ?? artifactContent`。
4. 活跃任务中代码只读,禁用保存、局部编辑和重复全文改写。
5. 收到 `committed` 后用现有 `useUpdateArtifactContent` 或等价 Query cache 更新,使真实文件立即同步。
6. 收到 `conflict/error/cancelled` 时废弃临时草稿显示,回到真实文件内容。
### 6.4 ResearchSandboxPanel 接入
相关文件:
- `frontend-web/src/pages/deep-research/ResearchSandboxPanel.tsx`
- `frontend-web/src/pages/deep-research/useReportSandbox.ts`
- `frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx`
改造:
1. `ResearchSandboxPanel` 为虚拟 `report.md` 传入 `kind: "deep_research_report"` target。
2. `useReportSandbox` 接受 `externalDraftOverride`:
~~~ts
const visibleReport = activeDocumentRewrite?.isActive
? activeDocumentRewrite.draftContent
: reportMarkdown;
~~~
3. 流式期间只显示临时草稿,不提前写 session 报告。
4. `committed` 后刷新/替换 session 的 `report_markdown`;失败后恢复旧报告。
5. 当前“改写第一段 → 候选消息 → 确认应用”逻辑继续保留,不因全文改写而改变。
### 6.5 普通消息列表接入
复用:
- `frontend-web/src/components/workspace/messages/message-list.tsx`
- `frontend-web/src/components/workspace/messages/MessageGroup.tsx`
- `DeepResearchWorkbench.tsx` 中的虚拟消息注入模式。
`DocumentRewritePresentationProvider` 按 `presentation_thread_id` 读取 Job 历史并投影为:
1. 用户消息:“AI 全文改写《xxx.md》”;
2. AI 工具调用:`document_rewrite_progress`;
3. AI 富消息:`document_rewrite_summary`。
`MessageGroup.tsx` 增加 `document_rewrite_progress` 分支,渲染 `DocumentRewriteProgressStep`。最终摘要渲染 `DocumentRewriteSummaryCard`,其内含“查看前后对比”和“撤销”。
全文步骤固定为:
| 步骤 | 展示内容 |
| --- | --- |
| 固定原文版本 | 文件名、原文字数、快照已保存 |
| 理解改写要求 | 改写目标、模型、风格 |
| 流式重写 Markdown | 正在写入右侧 xxx.md、累计字数 |
| 校验文档 | 非空、标题、围栏、截断;报告还含来源检查 |
| 对比原文并生成说明 | 差异、风险、撤销入口 |
步骤条必须在完成后保留,不能因为流结束被隐藏。
## 7. 核心执行伪代码
~~~python
async def run_document_rewrite(job_id: str) -> None:
job = await jobs.acquire_lease(job_id)
source = source_factory.create(job) # 已完成鉴权的来源适配器
await emit("snapshotting")
snapshot = await source.read_snapshot()
base_hash = sha256(snapshot.content)
await jobs.freeze_snapshot(job_id, snapshot, base_hash)
await emit("snapshot_fixed", char_count=count_chars(snapshot.content))
messages = prompt_builder.build(
mode=job.mode,
source_kind=job.source_kind,
original=snapshot.content,
instruction=job.instruction,
style=job.style,
research_sources=snapshot.allowed_sources,
)
await emit("requirements_resolved", ...)
draft = ""
async for chunk in model.stream_text(messages=messages, model_name=job.model_name):
if await jobs.cancel_requested(job_id):
await jobs.mark_cancelled(job_id)
await emit("cancelled")
return
draft += chunk.text
await jobs.append_draft_periodically(job_id, draft)
await emit("rewrite_delta", delta=chunk.text)
await emit("validation_started")
validation = validate_markdown(
draft,
source_kind=job.source_kind,
allowed_citations=snapshot.allowed_citation_ids,
)
if validation.has_fatal_error:
await jobs.mark_failed(job_id, validation.message)
await emit("error", ...)
return
await emit("validation_completed", ...)
diff = build_diff_summary(snapshot.content, draft, validation)
await jobs.save_diff(job_id, diff)
await emit("comparison_ready", diff)
if job.commit_policy != AUTO_COMMIT_AFTER_VALIDATION:
await jobs.mark_candidate_ready(job_id)
return
await emit("commit_started")
await versions.save_before_rewrite(job, snapshot)
result = await source.commit_if_unchanged(
expected_hash=base_hash,
content=draft,
metadata=CommitMetadata(job_id=job_id),
)
if not result.ok:
await jobs.mark_conflict(job_id)
await emit("conflict", ...)
return
await jobs.mark_completed(job_id, result.sha256)
await emit("committed", ...)
await emit("completed", diff)
~~~
## 8. 实施顺序
### 阶段 A:后端内核与安全基础
1. 新增领域模型、来源端口、校验、确定性 diff。
2. 新增 Job、事件、版本数据表与 migration。
3. 参考深度研究 executor 完成后台任务、取消、SSE 重放。
4. 实现普通 Artifact 读取、哈希比较、原子提交和撤销 API。
5. 编写接口测试。
验收:可以通过 API 对普通 Markdown 创建全文任务;取消、失败、冲突均不改原文件;成功后可撤销。
### 阶段 B:普通沙箱全文 UI
1. 新增 `core/document-rewrite` 前端状态、SSE、rAF hook。
2. 改 `ArtifactFileDetail`:右上角按钮、配置弹窗、临时草稿、禁用态。
3. 接入普通 `MessageList` 的步骤条、总结卡、diff 和撤销。
4. 支持刷新后恢复 Job 状态与已完成历史。
验收:右侧可平滑逐 token 写入;完成后替换真实 Markdown;左侧步骤和总结保留;撤销成功恢复。
### 阶段 C:深度研究接入
1. 实现 `DeepResearchReportSource`。
2. 由 `ResearchSandboxPanel` 传入报告 target。
3. `useReportSandbox` 支持外部临时草稿。
4. 对深度研究添加引用白名单校验。
5. 保持现有报告局部候选确认卡不变,再逐步下沉公共 prompt/校验逻辑。
验收:报告全文改写不触发联网搜索,不允许新来源;局部报告改写依然先确认再应用。
### 阶段 D:局部改写内核迁移
1. 保持 `/api/writing/rewrite` 契约不变。
2. 将 `writing.py` 转为 `InlineDocumentSource + preview_only` 的兼容 facade。
3. 给 `useWritingRewrite` 增加原文快照失效检查。
4. 回归替换、插入、重试、继续追问。
## 9. 测试与验收
建议新增后端测试:
~~~text
offline-backend-20260512/backend/tests/
├─ test_document_rewrite_pipeline.py
├─ test_document_rewrite_api.py
├─ test_document_rewrite_versions.py
└─ test_document_rewrite_deep_research.py
~~~
必须覆盖:
- Job 状态、SSE 顺序和断线恢复;
- 空输出、围栏不闭合、截断、非法深度研究引用;
- 同一文件并发任务;
- 提交前手工修改导致的冲突;
- 取消/异常不改真实文件;
- 正常撤销、撤销前手工编辑导致的冲突;
- 旧 `/api/writing/rewrite`、深度研究 `/rewrite/stream` 兼容回归。
前端必须覆盖:
- 多个 delta 在一帧合并,最终内容不丢失;
- `committed` 前 Artifact 缓存不被临时草稿污染;
- 失败/取消/冲突后右侧恢复原文;
- 活跃任务禁用同文件编辑、保存、重复改写;
- 刷新后历史步骤条与总结仍存在;
- 深度研究虚拟 `report.md` 可用全文改写;
- 局部编辑器原文变化后不能应用过期候选。
执行基线:
~~~powershell
cd offline-backend-20260512/backend
$env:PYTHONPATH = "."
uv run --no-sync pytest tests/test_document_rewrite_pipeline.py -v
uv run --no-sync pytest tests/test_document_rewrite_api.py -v
uv run --no-sync ruff check app packages/harness
cd ../../frontend-web
pnpm typecheck
pnpm build
~~~
## 10. 风险控制
| 风险 | 控制措施 |
| --- | --- |
| 用户误以为真实文件已经清空 | 明确显示“临时草稿,原文件尚未修改” |
| 流式显示仍然卡顿 | 网络完整读取 + `requestAnimationFrame` 合并渲染 |
| 改写中用户手动编辑 | `SHA-256` compare-and-swap,冲突即拒绝覆盖 |
| 输出 Markdown 不完整 | 非空、围栏、截断和标题检查 |
| 深度研究幻觉 | 来源白名单、禁止联网、引用 ID 校验 |
| 文档过大 | 第一版拒绝并提示按章节改写,不隐式截断 |
| 局部编辑污染聊天历史 | 局部只在编辑器弹窗展示,不创建完整步骤条 |
| 历史记录只存在浏览器内存 | 全文 Job/Event/Diff 持久化,按展示线程恢复 |
## 11. 复用点清单
| 现有实现 | 在新方案中的用途 |
| --- | --- |
| `MarkdownAiEditor.tsx` | 保留局部编辑菜单、预览弹窗、确认动作 |
| `useWritingRewrite.ts` | 保留局部流控制,增加快照失效保护 |
| `open-canvas/api/rewrite.ts` | 保持兼容,逐步接入统一内核 |
| `routers/writing.py` | 复用当前模型流式读取,抽取 prompt/流逻辑 |
| `routers/deep_research.py` | 复用报告来源、哈希候选、来源约束 |
| `deep_research_job_executor.py` | 复用后台 Job、取消、事件重放模式 |
| `artifact-file-detail.tsx` | 复用沙箱文件操作、代码/预览、缓存同步 |
| `useUpdateArtifactContent` | 完成后更新普通 Artifact 缓存 |
| `ResearchSandboxPanel.tsx` | 复用研究报告的右侧沙箱宿主 |
| `useReportSandbox.ts` | 加入临时草稿覆盖显示 |
| `MessageList.tsx / MessageGroup.tsx` | 复用普通消息步骤条、总结消息、文件卡片能力 |
| `DeepResearchWorkbench.tsx` | 复用向普通消息列表投影过程事件的模式 |
## 12. 完成定义
完成后应满足:
1. 局部编辑依旧是编辑器弹窗中的人工确认操作。
2. 全文改写在普通消息列表展示完整且不隐藏的步骤条。
3. 右侧沙箱可平滑地逐 token 展示临时 Markdown 草稿。
4. 任何中断、取消、校验失败和冲突都不损坏真实文件。
5. 完成后用户可以看到量化差异、结构变化、来源保留和风险提示。
6. 普通 Markdown 与深度研究报告都可全文改写,但研究报告绝不绕过已选来源约束。
7. 每一次真实全文覆盖都有版本快照,并且能够安全撤销。
-14
View File
@@ -132,18 +132,4 @@ window.__CM_RUNTIME_CONFIG__ = {
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
VITE_GITHUB_OAUTH_TOKEN: "",
// 是否在智能体管理页显示「舆情分析」类型:"true" 显示,"false" 隐藏。
// 关闭后,该智能体的专用对话地址也会自动跳回智能体管理页。
VITE_SENTIMENT_AGENT_VISIBLE: "true",
// 舆情分析智能体:外部流式问答接口地址(POST,SSE 流式返回)。
// 留空时该智能体对话页会提示未配置。
VITE_SENTIMENT_AGENT_API_URL: "https://gw.y.sit.4.cn/alg/stream/report-agent",
// 舆情分析智能体:请求头 Authorization Basic Auth 用户名。
VITE_SENTIMENT_AGENT_AUTH_USERNAME: "1054841959975223296",
// 舆情分析智能体:请求头 Authorization Basic Auth 密码。
VITE_SENTIMENT_AGENT_AUTH_PASSWORD: "LzqqMTUxMTIwMjQxNjQ2MjY5MDUduC",
};
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:none;stroke:#fff;stroke-linecap:round;stroke-linejoin:round;}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M24.036,10l-.012,9.333" transform="translate(-16.016 -6.667)"/><path class="b" d="M10,24h9.333" transform="translate(-6.667 -16)"/></svg>

Before

Width:  |  Height:  |  Size: 401 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="36" height="36" viewBox="0 0 36 36"><defs><style>.a,.d{fill:#fff;}.a{opacity:0;}.b{fill:#da2331;}.c{fill:#0a54cf;}.e{fill:#1677ff;}.f{fill:#1e2433;}.g{fill:#10d6ff;}.h{fill:#ff8181;}.i{fill:none;stroke:#10d6ff;stroke-linecap:round;stroke-linejoin:round;}.j{filter:url(#d);}.k{filter:url(#a);}</style><filter id="a"><feOffset dx="4" input="SourceAlpha"/><feGaussianBlur stdDeviation="1" result="b"/><feFlood flood-opacity="0.161" result="c"/><feComposite operator="out" in="SourceGraphic" in2="b"/><feComposite operator="in" in="c"/><feComposite operator="in" in2="SourceGraphic"/></filter><filter id="d"><feOffset dx="-4" input="SourceAlpha"/><feGaussianBlur stdDeviation="1" result="e"/><feFlood flood-opacity="0.161" result="f"/><feComposite operator="out" in="SourceGraphic" in2="e"/><feComposite operator="in" in="f"/><feComposite operator="in" in2="SourceGraphic"/></filter></defs><g transform="translate(0 -1)"><rect class="a" width="36" height="36" transform="translate(0 1)"/><path class="b" d="M23.5,14.75a1.5,1.5,0,0,1-1.5-1.5V9.5a1.5,1.5,0,1,1,3,0v3.75A1.5,1.5,0,0,1,23.5,14.75Z" transform="translate(-5.5 -1)"/><g data-type="innerShadowGroup"><path class="c" d="M-2-2H1C3.206-2,5-.532,5,1.273V3.727C5,5.532,3.206,7,1,7H-2Z" transform="translate(28.66 19.541)"/><g class="k" transform="matrix(1, 0, 0, 1, 0, 1)"><path class="d" d="M-2-2H1C3.206-2,5-.532,5,1.273V3.727C5,5.532,3.206,7,1,7H-2Z" transform="translate(28.66 18.54)"/></g></g><g data-type="innerShadowGroup"><path class="c" d="M5-2H2C-.206-2-2-.532-2,1.273V3.727C-2,5.532-.206,7,2,7H5Z" transform="translate(4.34 19.541)"/><g class="j" transform="matrix(1, 0, 0, 1, 0, 1)"><path class="d" d="M5-2H2C-.206-2-2-.532-2,1.273V3.727C-2,5.532-.206,7,2,7H5Z" transform="translate(4.34 18.54)"/></g></g><path class="e" d="M5.5-2H16a7.508,7.508,0,0,1,7.5,7.5V13A7.508,7.508,0,0,1,16,20.5H5.5A7.508,7.508,0,0,1-2,13V5.5A7.508,7.508,0,0,1,5.5-2Z" transform="translate(7.25 13.5)"/><path class="f" d="M5.25,0h9A5.25,5.25,0,0,1,19.5,5.25V7.5a5.25,5.25,0,0,1-5.25,5.25h-9A5.25,5.25,0,0,1,0,7.5V5.25A5.25,5.25,0,0,1,5.25,0Z" transform="translate(8.25 15.25)"/><circle class="g" cx="1.5" cy="1.5" r="1.5" transform="translate(21 19)"/><circle class="g" cx="1.5" cy="1.5" r="1.5" transform="translate(12 19)"/><path class="d" d="M24,10v3.75" transform="translate(-6 -1.5)"/><circle class="h" cx="3" cy="3" r="3" transform="translate(15 4)"/><path class="i" d="M44,28.667a3.367,3.367,0,0,0,1.922.412,2.841,2.841,0,0,0,1.828-.412" transform="translate(-27.875 -5.017)"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><defs><style>.a{fill:#fff;opacity:0;}.b,.c{fill:none;stroke:#006efe;stroke-width:6px;}.b{stroke-linecap:round;stroke-linejoin:round;}</style></defs><g transform="translate(0 1)"><rect class="a" width="96" height="96" transform="translate(0 -1)"/><path class="b" d="M47,67H38L18,77V67H4V7H84V29" transform="translate(4 8)"/><circle class="c" cx="12" cy="12" r="12" transform="translate(56 45)"/><path class="b" d="M39,32l10,8" transform="translate(39 33)"/><path class="b" d="M12,15H24" transform="translate(12 16)"/><path class="b" d="M12,21H36" transform="translate(12 22)"/></g></svg>

Before

Width:  |  Height:  |  Size: 670 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="13" height="11" viewBox="0 0 13 11"><defs><style>.a{stroke:rgba(0,0,0,0);stroke-miterlimit:10;fill:url(#a);}</style><linearGradient id="a" x1="0.277" y1="0.03" x2="0.725" y2="1.083" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#92eeff"/><stop offset="1" stop-color="#00c7ff"/></linearGradient></defs><path class="a" d="M1,10A1,1,0,1,1,1,8H6a1,1,0,0,1,0,2Zm9.8-.482L7.848,7.361A.445.445,0,0,1,7.661,7a.46.46,0,0,1,.187-.366L10.8,4.481a.45.45,0,0,1,.716.365V9.152a.44.44,0,0,1-.131.317.445.445,0,0,1-.322.136A.454.454,0,0,1,10.8,9.518ZM1,6A1,1,0,1,1,1,4H6A1,1,0,0,1,6,6ZM1,2A1,1,0,1,1,1,0H11a1,1,0,1,1,0,2Z" transform="translate(0.5 0.5)"/></svg>

Before

Width:  |  Height:  |  Size: 753 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="13" height="11" viewBox="0 0 13 11"><defs><style>.a{fill:url(#a);}.b{fill:url(#b);}</style><linearGradient id="a" x1="0.023" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#92eeff" stop-opacity="0.302"/><stop offset="1" stop-color="#00c7ff" stop-opacity="0.302"/></linearGradient><linearGradient id="b" x1="0.023" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#92eeff"/><stop offset="1" stop-color="#00c7ff"/></linearGradient></defs><path class="a" d="M5.5,0,11,8H0Z" transform="translate(13) rotate(90)"/><path class="b" d="M5.5,0,11,8H0Z" transform="translate(8) rotate(90)"/></svg>

Before

Width:  |  Height:  |  Size: 735 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:rgba(255,255,255,0.8);}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M10,1.5A1.5,1.5,0,1,1,11.5,3,1.5,1.5,0,0,1,10,1.5Zm-5,0A1.5,1.5,0,1,1,6.5,3,1.5,1.5,0,0,1,5,1.5Zm-5,0A1.5,1.5,0,1,1,1.5,3,1.5,1.5,0,0,1,0,1.5Z" transform="translate(1.498 6.5)"/></svg>

Before

Width:  |  Height:  |  Size: 410 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:rgba(85,166,254,0.6);}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M12.715,13.421,9.588,10.294a5.841,5.841,0,1,1,.707-.707l3.128,3.128a.5.5,0,0,1-.708.706ZM1,5.833A4.833,4.833,0,1,0,5.834,1,4.838,4.838,0,0,0,1,5.833Z" transform="translate(1.334 1.216)"/></svg>

Before

Width:  |  Height:  |  Size: 418 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;}</style></defs><path class="a" d="M14.488.053.824,4.256a1.167,1.167,0,0,0-.285,2.1L4.576,8.936a1.167,1.167,0,0,0,1.382-.092L11,4.593A.292.292,0,0,1,11.408,5L7.156,10.043a1.167,1.167,0,0,0-.091,1.382l2.58,4.036a1.168,1.168,0,0,0,2.1-.286l4.2-13.663A1.167,1.167,0,0,0,14.488.053Z" transform="translate(0)"/></svg>

Before

Width:  |  Height:  |  Size: 422 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16.131" height="16" viewBox="0 0 16.131 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:none;stroke:#97edff;stroke-linecap:round;stroke-linejoin:round;}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M6,30V34.12H18.361V30" transform="translate(-2.73 -19.994)"/><path class="b" d="M21.181,9.09,18.09,6,15,9.09" transform="translate(-8.64 -4.235)"/><path class="b" d="M23.992,14.241V6" transform="translate(-14.544 -4.235)"/></svg>

Before

Width:  |  Height:  |  Size: 504 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b,.c{fill:none;stroke:#346fb1;stroke-linejoin:round;}.c{stroke-linecap:round;}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M10.667,17.333A6.667,6.667,0,1,0,4,10.667,6.667,6.667,0,0,0,10.667,17.333Z" transform="translate(-2.667 -2.667)"/><path class="c" d="M24.008,12v4l2.826,2.826" transform="translate(-16.005 -8)"/></svg>

Before

Width:  |  Height:  |  Size: 473 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(212,120,0,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

Before

Width:  |  Height:  |  Size: 320 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(207,19,34,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

Before

Width:  |  Height:  |  Size: 320 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(29,171,123,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

Before

Width:  |  Height:  |  Size: 321 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(132,85,230,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

Before

Width:  |  Height:  |  Size: 321 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(201,181,0,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

Before

Width:  |  Height:  |  Size: 320 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(69,195,243,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

Before

Width:  |  Height:  |  Size: 321 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:none;stroke:#346fb1;stroke-linecap:round;stroke-linejoin:round;}</style></defs><g transform="translate(0 -2)"><rect class="a" width="16" height="16" transform="translate(0 2)"/><path class="b" d="M17.333,8H4V18H9l1.667,1.667L12.333,18h5Z" transform="translate(-2.667 -3.667)"/><circle class="b" cx="1.667" cy="1.667" r="1.667" transform="translate(6.333 6.333)"/><path class="b" d="M21,26.667A2.848,2.848,0,0,0,18,24a2.848,2.848,0,0,0-3,2.667" transform="translate(-10 -14.333)"/></g></svg>

Before

Width:  |  Height:  |  Size: 618 B

+17 -15
View File
@@ -2,7 +2,6 @@ import { useEffect, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
Bell,
Cpu,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
@@ -11,6 +10,7 @@ import {
Gauge,
ListTree,
MousePointerClick,
FileText,
BriefcaseBusiness,
Settings,
ShieldAlert,
@@ -237,6 +237,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
按钮管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/notification-templates")}
>
<FileText className="size-4" />
通知模板管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/position-roles")}
@@ -253,14 +261,6 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
白名单管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/models")}
>
<Cpu className="size-4" />
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/settings/model-concurrency")}
@@ -559,15 +559,17 @@ function LegacyAccordionSidebar({
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
<BriefcaseBusiness className="size-4" />
岗位角色配置
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/notification-templates")}
>
<FileText className="size-4" />
通知模板管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/workspace/admin/models")}>
<Cpu className="size-4" />
模型配置
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
<BriefcaseBusiness className="size-4" />
岗位角色配置
</DropdownMenuItem>
)}
{isAdmin && (
@@ -8,7 +8,6 @@ import {
SlidersHorizontal,
Upload,
} from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
@@ -19,7 +18,7 @@ function HeaderButton({
variant = "outline",
className,
}: {
icon: ReactNode;
icon: React.ReactNode;
label: string;
onClick: () => void;
variant?: "outline" | "primary";
@@ -53,7 +52,6 @@ export function WikiHeader({
onCreate,
onManageFolders,
onManageTemplates,
extraActions,
}: {
onToggleSidebar: () => void;
onImportFile: () => void;
@@ -63,7 +61,6 @@ export function WikiHeader({
onCreate: () => void;
onManageFolders: () => void;
onManageTemplates: () => void;
extraActions?: ReactNode;
}) {
return (
<header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
@@ -82,7 +79,6 @@ export function WikiHeader({
</div>
<div className="flex items-center gap-2">
{extraActions}
<HeaderButton
icon={<Upload className="size-4" />}
label="导入文件"
@@ -3,7 +3,6 @@
import {
BotIcon,
CheckIcon,
DatabaseIcon,
GlobeIcon,
InfoIcon,
Loader2Icon,
@@ -44,7 +43,6 @@ import {
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
import { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats";
import { useArtifactsOptional } from "@/components/workspace/artifacts";
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
@@ -82,7 +80,6 @@ interface AgentDraft {
templateName: string;
/** 会商席位「技能识别强化」开关。 */
seatSkillDirective: boolean;
knowledgeBaseIds: string[];
}
function draftFromAgent(agent: Agent): AgentDraft {
@@ -96,7 +93,6 @@ function draftFromAgent(agent: Agent): AgentDraft {
featured: agent.featured_order != null,
templateName: agent.template_name ?? "",
seatSkillDirective: agent.seat_skill_directive ?? false,
knowledgeBaseIds: agent.llmwiki_knowledge_base_ids ?? [],
};
}
@@ -104,17 +100,15 @@ function Section({
icon,
title,
extra,
className,
children,
}: {
icon?: ReactNode;
title: string;
extra?: ReactNode;
className?: string;
children: ReactNode;
}) {
return (
<section className={cn("bg-card/60 rounded-xl border p-4", className)}>
<section className="bg-card/60 rounded-xl border p-4">
<div className="mb-3 flex items-center justify-between gap-2">
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
{icon}
@@ -145,15 +139,6 @@ export function AgentDetailPanel({
defaultCollapsed,
showRecentChats,
compactAppearanceActions,
avatarSrc,
positionName,
skillsIcon,
skillsTitle,
showCommonQuestions = false,
panelTitle,
embedded,
hideStatusBadges = false,
editIcon,
}: {
agent: Agent | null | undefined;
agentName: string;
@@ -165,19 +150,6 @@ export function AgentDetailPanel({
showRecentChats?: boolean;
/** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */
compactAppearanceActions?: ReactNode;
avatarSrc?: string;
positionName?: string;
skillsIcon?: ReactNode;
skillsTitle?: string;
showCommonQuestions?: boolean;
/** Overrides the standard panel heading for an embedded consumer. */
panelTitle?: string;
/** Renders one fixed panel instead of the responsive collapsible rail. */
embedded?: boolean;
/** Keeps deployment/status badges out of an embedded product surface. */
hideStatusBadges?: boolean;
/** Optional icon used by the compact embedded edit action. */
editIcon?: ReactNode;
}) {
const router = useRouter();
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
@@ -338,11 +310,6 @@ export function AgentDetailPanel({
request.template_name = draft.templateName.trim();
if (draft.seatSkillDirective !== (agent.seat_skill_directive ?? false))
request.seat_skill_directive = draft.seatSkillDirective;
if (
JSON.stringify(draft.knowledgeBaseIds) !==
JSON.stringify(agent.llmwiki_knowledge_base_ids ?? [])
)
request.llmwiki_knowledge_base_ids = draft.knowledgeBaseIds;
if (nextPublished !== agent.published) request.published = nextPublished;
if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? []))
request.skills = draft.skills;
@@ -505,25 +472,11 @@ export function AgentDetailPanel({
</div>
) : (
<div className="flex items-center gap-1">
{embedded ? (
<Tooltip content="编辑智能体">
<Button
type="button"
size="icon-sm"
variant="ghost"
onClick={beginEdit}
aria-label="编辑智能体"
>
{editIcon ?? <SquarePenIcon className="size-3.5" />}
</Button>
</Tooltip>
) : (
<Button size="sm" variant="outline" onClick={beginEdit}>
<SquarePenIcon className="size-3.5" />
编辑
</Button>
)}
{!embedded && canTogglePublish && (
<Button size="sm" variant="outline" onClick={beginEdit}>
<SquarePenIcon className="size-3.5" />
编辑
</Button>
{canTogglePublish && (
<Tooltip content={agent?.published ? "下架" : "发布"}>
<Button
size="icon-sm"
@@ -540,19 +493,17 @@ export function AgentDetailPanel({
</Button>
</Tooltip>
)}
{!embedded && (
<Tooltip content="删除智能体">
<Button
size="icon-sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => setDeleteOpen(true)}
aria-label="删除智能体"
>
<Trash2Icon className="size-3.5" />
</Button>
</Tooltip>
)}
<Tooltip content="删除智能体">
<Button
size="icon-sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => setDeleteOpen(true)}
aria-label="删除智能体"
>
<Trash2Icon className="size-3.5" />
</Button>
</Tooltip>
</div>
)
) : null;
@@ -570,11 +521,7 @@ export function AgentDetailPanel({
<section className="bg-card/60 rounded-xl border p-4">
<div className="flex items-start gap-3">
<div className="agent-avatar bg-primary/10 ring-primary/15 flex size-12 shrink-0 items-center justify-center rounded-2xl ring-1">
{avatarSrc ? (
<img src={avatarSrc} alt="" aria-hidden="true" className="size-9" />
) : (
<BotIcon className="text-primary size-6" />
)}
<BotIcon className="text-primary size-6" />
</div>
<div className="min-w-0 flex-1 space-y-1.5">
{editing && draft ? (
@@ -594,16 +541,9 @@ export function AgentDetailPanel({
{displayName}
</div>
)}
{!editing && positionName && (
<div className="position-agent-role text-sm">
岗位:{desensitize(positionName)}
</div>
)}
{!hideStatusBadges && (
<div className="flex flex-wrap items-center gap-1">
{statusBadges}
</div>
)}
<div className="flex flex-wrap items-center gap-1">
{statusBadges}
</div>
{!editing && agent.owner_username && !agent.builtin && (
<div className="text-muted-foreground flex items-center gap-1 text-xs">
<UserRoundIcon className="size-3 shrink-0" />
@@ -639,13 +579,6 @@ export function AgentDetailPanel({
</div>
)}
{!editing && (agent.llmwiki_knowledge_base_ids?.length ?? 0) > 0 && (
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-xs">
<DatabaseIcon className="size-3 shrink-0" />
LLMWiki 知识库:{agent.llmwiki_knowledge_base_ids?.length ?? 0} 个
</div>
)}
{/* Publish / featured toggles — only meaningful in edit mode. */}
{editing && draft && (
<div className="mt-3 space-y-2">
@@ -663,14 +596,6 @@ export function AgentDetailPanel({
配置后,对话生成的 Markdown 文件可一键发布到知识入库接口。
</p>
</div>
<LlmWikiKnowledgeSelector
source="all"
value={draft.knowledgeBaseIds}
onChange={(knowledgeBaseIds) =>
setDraft({ ...draft, knowledgeBaseIds })
}
variant="panel"
/>
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
<div className="pr-3">
<p className="text-sm font-medium">会商技能识别强化</p>
@@ -721,7 +646,7 @@ export function AgentDetailPanel({
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
{(showCommonQuestions || editing || questions.length > 0 || canEdit) && (
{(editing || questions.length > 0 || canEdit) && (
<Section
icon={<MessageCircleQuestionIcon className="size-3.5" />}
title="常用问题"
@@ -862,9 +787,8 @@ export function AgentDetailPanel({
{/* ── 技能管理 ───────────────────────────────────────────────────── */}
<Section
icon={skillsIcon ?? <SparklesIcon className="size-3.5" />}
title={skillsTitle ?? `已启用技能 ${skillNamesShown.length} 个`}
className="position-roundtable-skills-section"
icon={<SparklesIcon className="size-3.5" />}
title={`已启用技能 ${skillNamesShown.length} 个`}
extra={
editing ? (
<Button
@@ -906,9 +830,7 @@ 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">
{panelTitle ?? "智能体信息"}
</span>
<span className="shrink-0 whitespace-nowrap text-sm font-semibold">智能体信息</span>
<div className="flex min-w-0 items-center gap-1">
{headerActions}
{compactAppearanceActions}
@@ -922,11 +844,7 @@ export function AgentDetailPanel({
return (
<>
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
{embedded ? (
<aside className="bg-card/40 flex h-full w-full min-w-0 shrink-0 flex-col border-l">
{panelChrome(null)}
</aside>
) : collapsed ? (
{collapsed ? (
<div className="hidden shrink-0 items-start border-l px-2 pt-3 lg:flex">
<div className="flex items-center gap-1">
{compactAppearanceActions}
@@ -959,34 +877,30 @@ export function AgentDetailPanel({
</aside>
)}
{!embedded && (
<>
{/* ── Narrow screens (<lg): rail trigger + drawer with the same body ── */}
<div className="flex shrink-0 flex-col items-center border-l pt-3 lg:hidden">
<Tooltip content="智能体信息">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setMobileOpen(true)}
aria-label="打开智能体信息"
>
<InfoIcon className="size-4" />
</Button>
</Tooltip>
</div>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b px-4 pb-3 pt-4">
<SheetTitle className="flex items-center justify-between text-sm">
智能体信息
<span className="mr-6">{headerActions}</span>
</SheetTitle>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
</SheetContent>
</Sheet>
</>
)}
{/* ── Narrow screens (<lg): rail trigger + drawer with the same body ── */}
<div className="flex shrink-0 flex-col items-center border-l pt-3 lg:hidden">
<Tooltip content="智能体信息">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setMobileOpen(true)}
aria-label="打开智能体信息"
>
<InfoIcon className="size-4" />
</Button>
</Tooltip>
</div>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b px-4 pb-3 pt-4">
<SheetTitle className="flex items-center justify-between text-sm">
智能体信息
<span className="mr-6">{headerActions}</span>
</SheetTitle>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
</SheetContent>
</Sheet>
{/* ── Secondary surfaces ───────────────────────────────────────────── */}
<SkillPickerSheet
@@ -16,11 +16,9 @@ import { getStoredAuth } from "@/core/auth";
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
import { AgentCard } from "./agent-card";
import { SentimentAgentCard } from "./sentiment-agent-card";
const PAGE_SIZE = 12;
@@ -32,7 +30,6 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
export function AgentGallery() {
const { t } = useI18n();
const router = useRouter();
const sentimentAgentVisible = isSentimentAgentVisible();
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
const isAdmin = getStoredAuth()?.system_role === "admin";
const [viewMode, setViewMode] = useViewMode("agent");
@@ -44,9 +41,6 @@ export function AgentGallery() {
// Each configured publish square (发布广场) becomes its own browse tab; the
// backend always returns at least one (the built-in default).
const squareTabs = publishSquares?.squares ?? [];
// 默认广场固定排在「我的」之后;舆情分析是依附在默认广场后的前端虚拟类型,
// 其余自定义广场继续保持后端给出的顺序。
const [defaultSquare, ...additionalSquares] = squareTabs;
// Publisher filter: click a card's 发布者 to keep only that user's agents.
// {id} is the owner user_id (server filter), {label} the desensitized name.
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
@@ -76,7 +70,6 @@ export function AgentGallery() {
activeTab !== "mine" &&
activeTab !== "private" &&
activeTab !== "builtin" &&
activeTab !== "sentiment" &&
squareTabs.length > 0 &&
!squareTabs.some((sq) => sq.id === activeTab)
) {
@@ -97,13 +90,9 @@ export function AgentGallery() {
// Only the active tab fetches. "广场" maps to the server "square" scope
// (built-in/published, never private), "我的" to "mine" (owned + favorited),
// "内置" to "builtin" (admin-only, user_id IS NULL). 「舆情分析」是前端虚拟
// 类型,不查后端列表。
// "内置" to "builtin" (admin-only, user_id IS NULL).
const isSquareTab =
activeTab !== "mine" &&
activeTab !== "private" &&
activeTab !== "builtin" &&
activeTab !== "sentiment";
activeTab !== "mine" && activeTab !== "private" && activeTab !== "builtin";
const mainScope =
activeTab === "mine"
? "mine"
@@ -123,7 +112,7 @@ export function AgentGallery() {
sort,
page: mainPage,
pageSize: PAGE_SIZE,
enabled: activeTab !== "private" && activeTab !== "sentiment",
enabled: activeTab !== "private",
owner: publisher?.id,
// Each square tab fetches only that square's agents.
square: isSquareTab ? activeTab : undefined,
@@ -159,16 +148,9 @@ export function AgentGallery() {
// password has been verified. Until then we show the gate (or the lock
// prompt) and hide the keyword/tag search bar — there is no list to filter.
const privateUnlocked = privateFeatureUsable && !!privatePassword;
const showSearchBar =
activeTab !== "sentiment" &&
(activeTab !== "private" || privateUnlocked);
const showSearchBar = activeTab !== "private" || privateUnlocked;
const activeTotal =
activeTab === "sentiment"
? 1
: activeTab === "private"
? privateTotal
: mainTotal;
const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
const setTabPage = (tab: string, page: number) =>
setPages((prev) => ({ ...prev, [tab]: page }));
@@ -416,22 +398,7 @@ export function AgentGallery() {
<div className="flex items-center justify-between gap-3">
<TabsList className="w-auto max-w-full flex-wrap justify-start self-start">
<TabsTrigger value="mine">我的</TabsTrigger>
{defaultSquare && (
<TabsTrigger
key={defaultSquare.id}
value={defaultSquare.id}
title={defaultSquare.description || defaultSquare.name}
>
{defaultSquare.name}
</TabsTrigger>
)}
{sentimentAgentVisible && (
/* 舆情分析:前端虚拟类型,直连外部舆情问答接口。 */
<TabsTrigger value="sentiment" title="舆情分析智能体(外部接口对接)">
舆情分析
</TabsTrigger>
)}
{additionalSquares.map((sq) => (
{squareTabs.map((sq) => (
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
{sq.name}
</TabsTrigger>
@@ -545,25 +512,6 @@ export function AgentGallery() {
})}
</TabsContent>
{/* 舆情分析类型:固定一张虚拟智能体卡片(名称/简介可编辑),
接口地址与鉴权在 runtime-config.js 配置。 */}
{sentimentAgentVisible && (
<TabsContent
value="sentiment"
className="flex-1 overflow-y-auto p-6 mt-0"
>
{viewMode === "list" ? (
<div className="flex flex-col gap-2">
<SentimentAgentCard layout="list" />
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<SentimentAgentCard />
</div>
)}
</TabsContent>
)}
{isAdmin && (
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
{renderListBody({
@@ -1,160 +0,0 @@
"use client";
import { BotIcon, MessageSquareIcon, Radio, SquarePenIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { SENTIMENT_AGENT_ID } from "@/core/sentiment-agent/config";
import { readSentimentAgentProfile } from "@/core/sentiment-agent/profile";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { SentimentAgentProfileDialog } from "./sentiment-agent-profile-dialog";
/**
* 「舆情分析」类型下的虚拟智能体卡片:只存在于前端(不落库 /api/agents),
* 对话直连外部 AG-UI 网关。名称 / 简介可编辑(localStorage),
* 视觉与普通智能体卡片保持一致(网格 / 列表两种布局)。
*/
export function SentimentAgentCard({
layout = "grid",
className,
}: {
layout?: "grid" | "list";
className?: string;
}) {
const router = useRouter();
const [profile, setProfile] = useState(() => readSentimentAgentProfile());
const [editOpen, setEditOpen] = useState(false);
// 编辑弹窗也可能挂在对话页,保存后广播事件,这里即时刷新卡片。
useEffect(() => {
const onChange = () => setProfile(readSentimentAgentProfile());
window.addEventListener("sentiment-agent-profile-changed", onChange);
return () =>
window.removeEventListener("sentiment-agent-profile-changed", onChange);
}, []);
const handleChat = () => {
router.push(`/page/workspace/agents/${SENTIMENT_AGENT_ID}/chats/new`);
};
const editButton = (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setEditOpen(true)}
title="编辑名称与简介"
aria-label="编辑名称与简介"
>
<SquarePenIcon className="h-3.5 w-3.5" />
</Button>
);
const chatButton = (
<Button
size="sm"
className={cn(layout === "list" ? "shrink-0" : "w-full")}
onClick={handleChat}
>
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
对话
</Button>
);
const badge = (
<Badge variant="secondary" className="gap-1 text-xs">
<Radio className="size-3" />
舆情分析
</Badge>
);
const dialog = (
<SentimentAgentProfileDialog
open={editOpen}
onOpenChange={setEditOpen}
/>
);
if (layout === "list") {
return (
<div
className={cn(
"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]",
className,
)}
>
<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">
<div className="flex min-w-0 items-center gap-2">
<span
className="min-w-0 truncate text-sm font-medium"
title={desensitize(profile.name)}
>
{desensitize(profile.name)}
</span>
{badge}
</div>
<p className="text-muted-foreground mt-0.5 truncate text-xs">
{desensitize(profile.description)}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{editButton}
{chatButton}
</div>
{dialog}
</div>
);
}
return (
<Card
className={cn(
"group flex min-w-0 flex-col transition-shadow hover:shadow-md",
className,
)}
>
<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(profile.name)}
>
{desensitize(profile.name)}
</CardTitle>
<div className="mt-0.5">{badge}</div>
</div>
</div>
<div className="flex shrink-0 items-center justify-end gap-1">
{editButton}
</div>
</div>
<CardDescription className="mt-2 line-clamp-2 text-sm">
{desensitize(profile.description)}
</CardDescription>
</CardHeader>
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-2">
{chatButton}
</CardFooter>
{dialog}
</Card>
);
}
@@ -1,104 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
DEFAULT_SENTIMENT_AGENT_PROFILE,
type SentimentAgentProfile,
readSentimentAgentProfile,
saveSentimentAgentProfile,
} from "@/core/sentiment-agent/profile";
/**
* 舆情分析智能体的「编辑信息」弹窗:修改名称 / 简介并保存到 localStorage。
* 智能体管理页的卡片和对话页顶栏共用。
*/
export function SentimentAgentProfileDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
useEffect(() => {
if (!open) return;
const profile = readSentimentAgentProfile();
setName(profile.name);
setDescription(profile.description);
}, [open]);
const handleSave = () => {
const next: SentimentAgentProfile = {
name: name.trim() || DEFAULT_SENTIMENT_AGENT_PROFILE.name,
description:
description.trim() || DEFAULT_SENTIMENT_AGENT_PROFILE.description,
};
saveSentimentAgentProfile(next);
toast.success("舆情分析智能体信息已更新");
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>编辑舆情分析智能体</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<label htmlFor="sentiment-agent-name" className="text-sm font-medium">
名称
</label>
<Input
id="sentiment-agent-name"
value={name}
maxLength={50}
onChange={(e) => setName(e.target.value)}
placeholder="智能体名称"
/>
</div>
<div className="flex flex-col gap-2">
<label
htmlFor="sentiment-agent-description"
className="text-sm font-medium"
>
简介
</label>
<Textarea
id="sentiment-agent-description"
value={description}
maxLength={300}
onChange={(e) => setDescription(e.target.value)}
placeholder="智能体简介(展示在卡片与对话页)"
className="min-h-[96px] resize-none"
/>
</div>
<p className="text-muted-foreground text-xs">
仅修改本浏览器展示的名称与简介;接口地址与鉴权请在部署目录的
runtime-config.js 中配置。
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleSave}>保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large. Load diff
@@ -28,14 +28,11 @@ export function ArtifactFileList({
files,
threadId,
onOpenArtifact,
onDownloadArtifact,
}: {
className?: string;
files: string[];
threadId: string;
onOpenArtifact?: (filepath: string) => void;
/** Optional host download action for virtual/session-owned artifacts. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
}) {
const { t } = useI18n();
const { isMock, publicShareCode } = useThread();
@@ -68,14 +65,10 @@ export function ArtifactFileList({
async (e: React.MouseEvent, filepath: string) => {
e.stopPropagation();
e.preventDefault();
if (downloadingFile) return;
setDownloadingFile(filepath);
try {
if (onDownloadArtifact) {
await onDownloadArtifact(filepath);
} else {
await downloadArtifactFile({ filepath, threadId, isMock, publicShareCode });
}
if (downloadingFile) return;
setDownloadingFile(filepath);
try {
await downloadArtifactFile({ filepath, threadId, isMock, publicShareCode });
} catch (error) {
console.error(error);
toast.error(
@@ -85,7 +78,7 @@ export function ArtifactFileList({
setDownloadingFile(null);
}
},
[threadId, isMock, publicShareCode, downloadingFile, onDownloadArtifact],
[threadId, isMock, publicShareCode, downloadingFile],
);
return (
@@ -1,43 +1,22 @@
import {
createContext,
useCallback,
useContext,
useRef,
useState,
useCallback,
useContext,
useState,
type ReactNode,
} from "react";
import { useSidebarSafe } from "@/components/ui/sidebar";
import { env } from "@/env";
export function artifactPreviewKey(artifact: string): string {
if (!artifact.startsWith("write-file:")) {
return artifact.trim();
}
try {
const url = new URL(artifact);
const messageId = url.searchParams.get("message_id");
const toolCallId = url.searchParams.get("tool_call_id");
if (messageId && toolCallId) {
return `${messageId}:${toolCallId}`;
}
} catch {
// Fall back to the complete value for malformed legacy virtual URLs.
}
return artifact;
}
import { env } from "@/env";
export interface ArtifactsContextType {
artifacts: string[];
setArtifacts: (artifacts: string[]) => void;
selectedArtifact: string | null;
selectedArtifactKey: string | null;
autoSelect: boolean;
select: (artifact: string, autoSelect?: boolean) => void;
/** Open/update a streamed write without overriding a manual close of that call. */
openStreamedArtifact: (artifact: string, previewKey: string) => boolean;
deselect: () => void;
selectedArtifact: string | null;
autoSelect: boolean;
select: (artifact: string, autoSelect?: boolean) => void;
deselect: () => void;
open: boolean;
autoOpen: boolean;
@@ -57,55 +36,32 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const setArtifacts = useCallback((next: string[]) => {
setArtifactsState(Array.isArray(next) ? next : []);
}, []);
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 [autoOpen, setAutoOpen] = useState(true);
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
const sidebar = useSidebarSafe();
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
const [open, setOpen] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const [autoOpen, setAutoOpen] = useState(true);
const sidebar = useSidebarSafe();
const select = useCallback(
(artifact: string, autoSelect = false) => {
setSelectedArtifact(artifact);
setSelectedArtifactKey(artifactPreviewKey(artifact));
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
(artifact: string, autoSelect = false) => {
setSelectedArtifact(artifact);
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
sidebar?.setOpen(false);
}
if (!autoSelect) {
setAutoSelect(false);
}
},
[sidebar],
);
const openStreamedArtifact = useCallback(
(artifact: string, previewKey: string) => {
if (suppressedAutoOpenKeyRef.current === previewKey) {
return false;
}
setSelectedArtifact(artifact);
setSelectedArtifactKey(previewKey);
setAutoSelect(true);
setAutoOpen(true);
setOpenState(true);
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
sidebar?.setOpen(false);
}
return true;
},
[sidebar],
);
[sidebar, setSelectedArtifact, setAutoSelect],
);
const deselect = useCallback(() => {
setSelectedArtifact(null);
setSelectedArtifactKey(null);
setAutoSelect(true);
setOpenState(false);
suppressedAutoOpenKeyRef.current = null;
}, []);
setSelectedArtifact(null);
setAutoSelect(true);
setOpen(false);
}, []);
const value: ArtifactsContextType = {
artifacts,
@@ -119,17 +75,12 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
setAutoOpen(false);
setAutoSelect(false);
}
if (!isOpen) {
suppressedAutoOpenKeyRef.current = selectedArtifactKey;
}
setOpenState(isOpen);
},
selectedArtifact,
selectedArtifactKey,
select,
openStreamedArtifact,
deselect,
setOpen(isOpen);
},
selectedArtifact,
select,
deselect,
};
return (
@@ -1,157 +0,0 @@
"use client";
import { SparklesIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
const STYLE_OPTIONS: { value: DocumentRewriteStyle; label: string; description: string }[] = [
{
value: "formal_analysis",
label: "正式分析",
description: "客观、严谨,适合报告与研究材料。",
},
{
value: "professional",
label: "专业正式",
description: "适合业务文档和说明材料。",
},
{
value: "concise",
label: "精炼简洁",
description: "减少重复,保留核心信息。",
},
{
value: "readable",
label: "清晰易读",
description: "优化段落衔接和阅读节奏。",
},
];
export function DocumentRewriteDialog({
open,
onOpenChange,
fileName,
charCount,
onStart,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
fileName: string;
charCount: number;
onStart: (input: {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
}) => void;
}) {
const [instruction, setInstruction] = useState(
"统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。",
);
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
const [modelName, setModelName] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setStyle("formal_analysis");
setModelName(null);
}, [open]);
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
const canStart = instruction.trim().length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<SparklesIcon className="size-5 text-primary" />
AI 全文改写
</DialogTitle>
<DialogDescription>
将以当前文件快照生成临时草稿。校验通过前不会覆盖原 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>
<label className="grid gap-2 text-sm font-medium">
改写要求
<Textarea
value={instruction}
onChange={(event) => setInstruction(event.target.value)}
placeholder="例如:统一为正式行业分析语气,减少重复,不新增外部事实。"
rows={4}
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm font-medium">
改写风格
<Select value={style} onValueChange={(value) => setStyle(value as DocumentRewriteStyle)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STYLE_OPTIONS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs">{selectedStyle?.description}</span>
</label>
<label className="grid gap-2 text-sm font-medium">
模型
<ModelNameSelect
value={modelName}
onChange={setModelName}
triggerClassName="h-10 text-sm"
defaultLabel="系统默认模型"
/>
<span className="text-muted-foreground text-xs">仅影响本次全文改写。</span>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button
disabled={!canStart}
onClick={() => {
if (!canStart) return;
onStart({ instruction: instruction.trim(), style, modelName });
}}
>
<SparklesIcon />
开始改写
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -15,8 +15,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
import { useSkills } from "@/core/skills/hooks";
import type { SkillDisplayConfig } from "@/core/skills/type";
import {
@@ -94,20 +92,6 @@ const ChatBox: React.FC<{
const [settings] = useLocalSettings();
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
// 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.
const hasFrontendRag = useMemo(
() =>
(thread.values.messages ?? []).some(
(message) =>
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
),
[thread.values.messages],
);
const referencesEnabled = ragModeEnabled || hasFrontendRag;
const shouldFetchBackendReferenceBatches =
referencesEnabled && !hasFrontendRag;
const { skills } = useSkills();
const {
@@ -118,16 +102,15 @@ const ChatBox: React.FC<{
select: selectArtifact,
deselect,
selectedArtifact,
selectedArtifactKey,
} = useArtifacts();
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
const skillDisplayMap = useMemo(
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
[referencesEnabled, skills],
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
);
const fallbackReferenceBatches = useMemo(() => {
if (!referencesEnabled) {
if (!ragModeEnabled) {
return [];
}
return buildTurnReferenceBatches(
@@ -135,9 +118,9 @@ const ChatBox: React.FC<{
skillDisplayMap,
thread.isLoading,
);
}, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
}, [ragModeEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
useEffect(() => {
if (!shouldFetchBackendReferenceBatches) {
if (!ragModeEnabled) {
setBackendReferenceBatches([]);
return;
}
@@ -173,12 +156,7 @@ const ChatBox: React.FC<{
}
});
return () => controller.abort();
}, [
shouldFetchBackendReferenceBatches,
thread.isLoading,
threadId,
thread.values.messages,
]);
}, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]);
const adaptedBackendReferenceBatches = useMemo(
() =>
adaptBackendReferenceBatches(
@@ -189,22 +167,15 @@ const ChatBox: React.FC<{
);
const referenceBatches = useMemo(
() => {
if (
shouldFetchBackendReferenceBatches &&
adaptedBackendReferenceBatches.length > 0
) {
if (adaptedBackendReferenceBatches.length > 0) {
return adaptedBackendReferenceBatches;
}
return fallbackReferenceBatches;
},
[
adaptedBackendReferenceBatches,
fallbackReferenceBatches,
shouldFetchBackendReferenceBatches,
],
[adaptedBackendReferenceBatches, fallbackReferenceBatches],
);
useEffect(() => {
if (!shouldFetchBackendReferenceBatches) {
if (!ragModeEnabled) {
return;
}
const signature = JSON.stringify({
@@ -231,14 +202,7 @@ const ChatBox: React.FC<{
fallbackBatches: debugReferenceBatches(fallbackReferenceBatches),
finalBatches: debugReferenceBatches(referenceBatches),
});
}, [
adaptedBackendReferenceBatches,
backendReferenceBatches,
fallbackReferenceBatches,
referenceBatches,
shouldFetchBackendReferenceBatches,
threadId,
]);
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]);
const latestReferenceBatch =
referenceBatches.length > 0
? referenceBatches[referenceBatches.length - 1]!
@@ -249,7 +213,7 @@ const ChatBox: React.FC<{
const referenceSources = activeReferenceBatch?.sources ?? [];
const hasCitationReferences = referenceSources.length > 0;
useEffect(() => {
if (!shouldFetchBackendReferenceBatches || !activeReferenceBatch) {
if (!ragModeEnabled || !activeReferenceBatch) {
return;
}
logReferenceDebug("frontend-active-reference-batch-payload", threadId, {
@@ -258,7 +222,7 @@ const ChatBox: React.FC<{
sourceCount: activeReferenceBatch.sources.length,
sources: activeReferenceBatch.sources.map(debugReferenceSource),
});
}, [activeReferenceBatch, shouldFetchBackendReferenceBatches, threadId]);
}, [activeReferenceBatch, ragModeEnabled, threadId]);
useEffect(() => {
if (hasCitationReferences) {
setReferencesOpen(true);
@@ -456,7 +420,6 @@ const ChatBox: React.FC<{
</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}
@@ -472,7 +435,6 @@ const ChatBox: React.FC<{
</Tabs>
) : selectedArtifact ? (
<ArtifactFileDetail
key={selectedArtifactKey ?? selectedArtifact}
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
@@ -8,13 +8,8 @@ export function CitationLink({
href,
children,
className,
onReferenceClick,
onClick,
...props
}: ComponentProps<"a"> & {
/** Lets a message renderer show an in-place source preview instead of scrolling. */
onReferenceClick?: (referenceId: string) => void;
}) {
}: ComponentProps<"a">) {
const childrenText = normalizeCitationText(children);
const displayText = childrenText ?? "";
const referenceId = href?.startsWith("#reference-")
@@ -33,11 +28,6 @@ export function CitationLink({
)}
onClick={(event) => {
event.preventDefault();
if (onReferenceClick) {
onReferenceClick(referenceId);
onClick?.(event);
return;
}
const batchId =
event.currentTarget.closest<HTMLElement>("[data-reference-batch-id]")
?.dataset.referenceBatchId;
@@ -51,7 +41,6 @@ export function CitationLink({
target?.scrollIntoView({ behavior: "smooth", block: "center" });
};
window.setTimeout(scroll, 240);
onClick?.(event);
}}
{...props}
>
@@ -69,7 +58,6 @@ export function CitationLink({
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
className,
)}
onClick={onClick}
{...props}
>
{displayText || children}
@@ -24,7 +24,6 @@ import {
useRef,
useState,
type ComponentProps,
type ReactNode,
} from "react";
import {
@@ -62,10 +61,9 @@ import {
} from "@/components/ui/dropdown-menu";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import type { Model } from "@/core/models/types";
import { useI18n } from "@/core/i18n/hooks";
import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import type { AgentThreadContext } from "@/core/threads";
import { textOfMessage } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
@@ -136,8 +134,6 @@ export function InputBox({
onStop,
promptPrefix,
agentSelector,
knowledgeSelector,
extraModels = [],
assistantId,
hideAdminRecommendedQuestions,
compact,
@@ -198,24 +194,12 @@ export function InputBox({
selectedAgentId: string | null;
onAgentChange: (agentId: string | null) => void;
};
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
knowledgeSelector?: ReactNode;
/** Page-scoped model choices implemented outside the DeerFlow model API. */
extraModels?: Model[];
}) {
const { t } = useI18n();
const [localSettings, setLocalSettings] = useLocalSettings();
const searchParams = useSearchParams();
const [modelDialogOpen, setModelDialogOpen] = useState(false);
const { models } = useModels();
const singleRoundRagActive =
context.execution_profile === "single_round_rag";
const multiRoundRagActive =
context.execution_profile === "multi_round_rag";
const frontendRagActive = singleRoundRagActive || multiRoundRagActive;
const modelOptions = useMemo(() => {
return singleRoundRagActive ? extraModels : models;
}, [extraModels, models, singleRoundRagActive]);
const { thread, isMock } = useThread();
const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null);
@@ -239,25 +223,16 @@ export function InputBox({
recommendedQuestions.length > 0;
useEffect(() => {
// Multi-round RAG is a frontend execution profile, not a model option.
// Do not let the normal model fallback clear it on every render.
if (multiRoundRagActive) {
if (models.length === 0) {
return;
}
if (modelOptions.length === 0) {
return;
}
const currentModel = modelOptions.find((m) => m.name === context.model_name);
const fallbackModel = currentModel ?? modelOptions[0]!;
const currentModel = models.find((m) => m.name === context.model_name);
const fallbackModel = currentModel ?? models[0]!;
const supportsThinking = fallbackModel.supports_thinking ?? false;
const nextModelName = fallbackModel.name;
const nextMode = getResolvedMode(context.mode, supportsThinking);
if (
context.model_name === nextModelName &&
context.mode === nextMode &&
context.execution_profile === fallbackModel.execution_profile
) {
if (context.model_name === nextModelName && context.mode === nextMode) {
return;
}
@@ -265,19 +240,17 @@ export function InputBox({
...context,
model_name: nextModelName,
mode: nextMode,
execution_profile: fallbackModel.execution_profile,
});
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
}, [context, models, onContextChange]);
const selectedModel = useMemo(() => {
if (modelOptions.length === 0) {
if (models.length === 0) {
return undefined;
}
return modelOptions.find((m) => m.name === context.model_name) ?? modelOptions[0];
}, [context.model_name, modelOptions]);
return models.find((m) => m.name === context.model_name) ?? models[0];
}, [context.model_name, models]);
const resolvedModelName = selectedModel?.name;
const selectedSingleRoundRag = singleRoundRagActive;
const supportThinking = useMemo(
() => selectedModel?.supports_thinking ?? false,
@@ -291,7 +264,7 @@ export function InputBox({
const handleModelSelect = useCallback(
(model_name: string) => {
const model = modelOptions.find((m) => m.name === model_name);
const model = models.find((m) => m.name === model_name);
if (!model) {
return;
}
@@ -300,24 +273,17 @@ export function InputBox({
model_name,
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: context.reasoning_effort,
execution_profile: model.execution_profile,
});
setModelDialogOpen(false);
},
[onContextChange, context, modelOptions],
[onContextChange, context, models],
);
const handleModeSelect = useCallback(
(mode: InputMode) => {
const ordinaryModel =
models.find((model) => model.name === context.model_name) ?? models[0];
onContextChange?.({
...context,
model_name: ordinaryModel?.name ?? context.model_name,
mode: getResolvedMode(
mode,
ordinaryModel?.supports_thinking ?? supportThinking,
),
mode: getResolvedMode(mode, supportThinking),
reasoning_effort:
mode === "ultra"
? "high"
@@ -326,38 +292,11 @@ export function InputBox({
: mode === "thinking"
? "low"
: "minimal",
execution_profile: undefined,
});
},
[onContextChange, context, models, supportThinking],
[onContextChange, context, supportThinking],
);
const handleSingleRoundRagSelect = useCallback(() => {
const ragModel =
extraModels.find((model) => model.name === context.model_name) ??
extraModels[0];
if (!ragModel) {
return;
}
onContextChange?.({
...context,
model_name: ragModel.name,
mode: getResolvedMode(
context.mode,
ragModel.supports_thinking ?? false,
),
execution_profile: "single_round_rag",
});
}, [context, extraModels, onContextChange]);
const handleMultiRoundRagSelect = useCallback(() => {
onContextChange?.({
...context,
execution_profile: "multi_round_rag",
});
}, [context, onContextChange]);
const handleReasoningEffortSelect = useCallback(
(effort: "minimal" | "low" | "medium" | "high") => {
onContextChange?.({
@@ -426,7 +365,6 @@ export function InputBox({
context.mode,
selectedModel?.supports_thinking ?? false,
),
execution_profile: selectedModel?.execution_profile,
});
setTimeout(() => onSubmit?.(message), 0);
return;
@@ -442,7 +380,6 @@ export function InputBox({
resolvedModelName,
ragModeEnabled,
selectedModel?.supports_thinking,
selectedModel?.execution_profile,
status,
threadId,
],
@@ -502,7 +439,6 @@ export function InputBox({
const showFollowups =
!disabled &&
!isNewThread &&
!selectedSingleRoundRag &&
!followupsHidden &&
(followupsLoading || followups.length > 0);
@@ -528,19 +464,19 @@ export function InputBox({
return;
}
if (disabled || isMock || selectedSingleRoundRag) {
if (disabled || isMock) {
return;
}
const visibleMessages = thread.messages.filter((m) => !isHiddenFromUIMessage(m));
const lastAi = [...visibleMessages].reverse().find((m) => m.type === "ai");
const visibleMessages = thread.messages.filter((m) => !isHiddenFromUIMessage(m));
const lastAi = [...visibleMessages].reverse().find((m) => m.type === "ai");
const lastAiId = lastAi?.id ?? null;
if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) {
return;
}
lastGeneratedForAiIdRef.current = lastAiId;
const recent = visibleMessages
const recent = visibleMessages
.filter((m) => m.type === "human" || m.type === "ai")
.map((m) => {
const role = m.type === "human" ? "user" : "assistant";
@@ -590,7 +526,7 @@ export function InputBox({
});
return () => controller.abort();
}, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
}, [context.model_name, disabled, isMock, status, thread.messages, threadId]);
return (
<div ref={promptRootRef} className="relative flex flex-col gap-6">
@@ -677,7 +613,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"
? "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"
: // landing 态:透明,让外层 gemini-gradient-border 提供白卡 + 旋转描边
// 对话态:常规白卡 + 中性边框 + 轻阴影
isNewThread
@@ -727,16 +663,14 @@ export function InputBox({
/>
</PromptInputActionMenuContent>
</PromptInputActionMenu> */}
{!frontendRagActive && (
<AddAttachmentsButton
className={cn(
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact &&
"gap-1.5! rounded-lg px-2.5! py-1! text-[11px] font-medium",
)}
/>
)}
{!frontendRagActive && <Tooltip
<AddAttachmentsButton
className={cn(
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact &&
"gap-1.5! rounded-lg px-2.5! py-1! text-[11px] font-medium",
)}
/>
<Tooltip
content={
context.writing_mode
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
@@ -746,23 +680,23 @@ export function InputBox({
<PromptInputButton
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
context.writing_mode
? "border border-amber-300 bg-amber-50 text-amber-700 ring-1 ring-amber-300/35 hover:bg-amber-100 dark:border-amber-300/70 dark:bg-amber-400/20 dark:text-amber-100 dark:ring-amber-300/35 dark:shadow-[0_0_0_1px_rgba(251,191,36,0.18),0_0_18px_rgba(251,191,36,0.18)] dark:hover:bg-amber-400/25"
: 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={handleWritingModeToggle}
>
{context.writing_mode && (
<CheckIcon className="size-3 text-amber-700 dark:text-amber-100" />
)}
<FileTextIcon className="size-3" />
<span className="text-xs font-normal">写作</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && <Tooltip
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
context.writing_mode
? "border border-amber-300 bg-amber-50 text-amber-700 ring-1 ring-amber-300/35 hover:bg-amber-100 dark:border-amber-300/70 dark:bg-amber-400/20 dark:text-amber-100 dark:ring-amber-300/35 dark:shadow-[0_0_0_1px_rgba(251,191,36,0.18),0_0_18px_rgba(251,191,36,0.18)] dark:hover:bg-amber-400/25"
: 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={handleWritingModeToggle}
>
{context.writing_mode && (
<CheckIcon className="size-3 text-amber-700 dark:text-amber-100" />
)}
<FileTextIcon className="size-3" />
<span className="text-xs font-normal">写作</span>
</PromptInputButton>
</Tooltip>
<Tooltip
content={
memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆"
@@ -771,24 +705,24 @@ export function InputBox({
>
<PromptInputButton
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
memoryInjectionEnabled
? "border border-blue-300 bg-blue-50 text-blue-700 ring-1 ring-blue-300/35 hover:bg-blue-100 dark:border-cyan-300/70 dark:bg-cyan-400/20 dark:text-cyan-50 dark:ring-cyan-300/35 dark:shadow-[0_0_0_1px_rgba(34,211,238,0.18),0_0_18px_rgba(34,211,238,0.18)] dark:hover:bg-cyan-400/25"
: 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={handleMemoryInjectionToggle}
>
{memoryInjectionEnabled && (
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" />
)}
<BrainCircuitIcon className="size-3" />
<span className="text-xs font-normal">记忆</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && <Tooltip
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
memoryInjectionEnabled
? "border border-blue-300 bg-blue-50 text-blue-700 ring-1 ring-blue-300/35 hover:bg-blue-100 dark:border-cyan-300/70 dark:bg-cyan-400/20 dark:text-cyan-50 dark:ring-cyan-300/35 dark:shadow-[0_0_0_1px_rgba(34,211,238,0.18),0_0_18px_rgba(34,211,238,0.18)] dark:hover:bg-cyan-400/25"
: 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={handleMemoryInjectionToggle}
>
{memoryInjectionEnabled && (
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" />
)}
<BrainCircuitIcon className="size-3" />
<span className="text-xs font-normal">记忆</span>
</PromptInputButton>
</Tooltip>
<Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
@@ -817,8 +751,8 @@ export function InputBox({
<BookMarkedIcon className="size-3" />
<span className="text-xs font-normal">参考文献</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && <Tooltip
</Tooltip>
<Tooltip
content={
contextCompressionEnabled
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
@@ -846,7 +780,7 @@ export function InputBox({
<ShrinkIcon className="size-3" />
<span className="text-xs font-normal">上下文压缩</span>
</PromptInputButton>
</Tooltip>}
</Tooltip>
<PromptInputActionMenu>
<ModeHoverGuide
mode={
@@ -857,22 +791,13 @@ export function InputBox({
? context.mode
: "flash"
}
tooltipContent={
singleRoundRagActive
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
: multiRoundRagActive
? "多轮 RAG 问答:多次检索并展示检索过程与参考资料"
: undefined
}
>
<PromptInputActionMenuTrigger
className={cn(
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
compact &&
(frontendRagActive
? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: context.mode === "thinking"
(context.mode === "thinking"
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
: context.mode === "pro"
? "border-violet-200 bg-violet-50/60 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200"
@@ -880,31 +805,26 @@ export function InputBox({
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
: "border-transparent bg-muted/40 hover:bg-muted hover:text-foreground"),
)}
>
<div>
{frontendRagActive ? (
<BookMarkedIcon className="size-3" />
) : context.mode === "flash" ? (
<ZapIcon className="size-3" />
) : context.mode === "thinking" ? (
>
<div>
{context.mode === "flash" && <ZapIcon className="size-3" />}
{context.mode === "thinking" && (
<LightbulbIcon className="size-3" />
) : context.mode === "pro" ? (
)}
{context.mode === "pro" && (
<GraduationCapIcon className="size-3" />
) : context.mode === "ultra" ? (
)}
{context.mode === "ultra" && (
<RocketIcon className="size-3 text-[#dabb5e]" />
) : null}
</div>
)}
</div>
<div
className={cn(
"text-xs font-normal",
context.mode === "ultra" ? "golden-text" : "",
)}
>
{frontendRagActive
? multiRoundRagActive
? "多轮 RAG"
: "单轮 RAG"
: (context.mode === "flash" && t.inputBox.flashMode) ||
{(context.mode === "flash" && t.inputBox.flashMode) ||
(context.mode === "thinking" &&
t.inputBox.reasoningMode) ||
(context.mode === "pro" && t.inputBox.proMode) ||
@@ -920,7 +840,7 @@ export function InputBox({
<PromptInputActionMenu>
<PromptInputActionMenuItem
className={cn(
!frontendRagActive && context.mode === "flash"
context.mode === "flash"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -931,7 +851,7 @@ export function InputBox({
<ZapIcon
className={cn(
"mr-2 size-4",
!frontendRagActive && context.mode === "flash" &&
context.mode === "flash" &&
"text-accent-foreground",
)}
/>
@@ -941,7 +861,7 @@ export function InputBox({
{t.inputBox.flashModeDescription}
</div>
</div>
{!frontendRagActive && context.mode === "flash" ? (
{context.mode === "flash" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -950,7 +870,7 @@ export function InputBox({
{supportThinking && (
<PromptInputActionMenuItem
className={cn(
!frontendRagActive && context.mode === "thinking"
context.mode === "thinking"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -961,7 +881,7 @@ export function InputBox({
<LightbulbIcon
className={cn(
"mr-2 size-4",
!frontendRagActive && context.mode === "thinking" &&
context.mode === "thinking" &&
"text-accent-foreground",
)}
/>
@@ -971,7 +891,7 @@ export function InputBox({
{t.inputBox.reasoningModeDescription}
</div>
</div>
{!frontendRagActive && context.mode === "thinking" ? (
{context.mode === "thinking" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -980,7 +900,7 @@ export function InputBox({
)}
<PromptInputActionMenuItem
className={cn(
!frontendRagActive && context.mode === "pro"
context.mode === "pro"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -991,7 +911,7 @@ export function InputBox({
<GraduationCapIcon
className={cn(
"mr-2 size-4",
!frontendRagActive && context.mode === "pro" &&
context.mode === "pro" &&
"text-accent-foreground",
)}
/>
@@ -1001,7 +921,7 @@ export function InputBox({
{t.inputBox.proModeDescription}
</div>
</div>
{!frontendRagActive && context.mode === "pro" ? (
{context.mode === "pro" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -1009,7 +929,7 @@ export function InputBox({
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
!frontendRagActive && context.mode === "ultra"
context.mode === "ultra"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -1020,16 +940,12 @@ export function InputBox({
<RocketIcon
className={cn(
"mr-2 size-4",
!frontendRagActive &&
context.mode === "ultra" &&
"text-[#dabb5e]",
context.mode === "ultra" && "text-[#dabb5e]",
)}
/>
<div
className={cn(
!frontendRagActive &&
context.mode === "ultra" &&
"golden-text",
context.mode === "ultra" && "golden-text",
)}
>
{t.inputBox.ultraMode}
@@ -1039,68 +955,7 @@ export function InputBox({
{t.inputBox.ultraModeDescription}
</div>
</div>
{!frontendRagActive && context.mode === "ultra" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
{extraModels.length > 0 && (
<>
<DropdownMenuSeparator className="my-2" />
<PromptInputActionMenuItem
className={cn(
selectedSingleRoundRag
? "bg-emerald-50 text-emerald-800 hover:bg-emerald-100 focus:bg-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-200 dark:hover:bg-emerald-950/60 dark:focus:bg-emerald-950/60"
: "text-muted-foreground/65",
)}
onSelect={handleSingleRoundRagSelect}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<BookMarkedIcon
className={cn(
"mr-2 size-4",
selectedSingleRoundRag && "text-emerald-700 dark:text-emerald-200",
)}
/>
单轮 RAG 问答
</div>
<div className="pl-7 text-xs">
基于知识库检索生成答案,并展示思考内容与参考资料
</div>
</div>
{selectedSingleRoundRag ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
</>
)}
<PromptInputActionMenuItem
className={cn(
multiRoundRagActive
? "bg-emerald-50 text-emerald-800 hover:bg-emerald-100 focus:bg-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-200 dark:hover:bg-emerald-950/60 dark:focus:bg-emerald-950/60"
: "text-muted-foreground/65",
)}
onSelect={handleMultiRoundRagSelect}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<BookMarkedIcon
className={cn(
"mr-2 size-4",
multiRoundRagActive && "text-emerald-700 dark:text-emerald-200",
)}
/>
多轮 RAG 问答
</div>
<div className="pl-7 text-xs">
多次检索、展示过程与参考资料,适合复杂问题
</div>
</div>
{multiRoundRagActive ? (
{context.mode === "ultra" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -1110,7 +965,7 @@ export function InputBox({
</DropdownMenuGroup>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
{supportReasoningEffort && context.mode !== "flash" && (
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
<div className="text-xs font-normal">
@@ -1228,7 +1083,7 @@ export function InputBox({
)}
</PromptInputTools>
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
{!frontendRagActive && agentSelector && (
{agentSelector && (
<AgentSelector
selectedAgentId={agentSelector.selectedAgentId}
onAgentChange={agentSelector.onAgentChange}
@@ -1236,8 +1091,7 @@ export function InputBox({
compact={compact}
/>
)}
{!frontendRagActive && knowledgeSelector}
{!frontendRagActive && promptPrefix && (
{promptPrefix && (
<Tooltip
content={
promptPrefix.on
@@ -1265,7 +1119,7 @@ export function InputBox({
</PromptInputButton>
</Tooltip>
)}
{!multiRoundRagActive && <ModelSelector
<ModelSelector
open={modelDialogOpen}
onOpenChange={setModelDialogOpen}
>
@@ -1292,7 +1146,7 @@ export function InputBox({
<ModelSelectorContent>
<ModelSelectorInput placeholder={t.inputBox.searchModels} />
<ModelSelectorList>
{modelOptions.map((m) => (
{models.map((m) => (
<ModelSelectorItem
key={m.name}
value={m.name}
@@ -1301,9 +1155,7 @@ export function InputBox({
<div className="flex min-w-0 flex-1 flex-col">
<ModelSelectorName>{m.display_name}</ModelSelectorName>
<span className="text-muted-foreground truncate text-[10px]">
{selectedSingleRoundRag
? `单轮知识库问答 · ${m.name}`
: m.model}
{m.model}
</span>
</div>
{m.name === context.model_name ? (
@@ -1315,12 +1167,12 @@ export function InputBox({
))}
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>}
</ModelSelector>
<PromptInputSubmit
className={cn(
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
compact &&
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted",
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted",
)}
disabled={disabled}
variant="outline"
@@ -1,353 +0,0 @@
"use client";
import {
Check,
Database,
Folder,
Globe2,
Loader2,
Search,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
type LlmWikiKnowledgeBase,
useAgentLlmWikiKnowledgeBases,
useLlmWikiKnowledgeBases,
useLlmWikiRuntime,
} from "@/strategy-components/api/llmwiki";
type SelectorSource = "all" | "agent";
type SelectorVariant = "popover" | "panel";
function groupRows(rows: LlmWikiKnowledgeBase[]) {
return [
{
key: "personal",
label: "我的知识空间",
rows: rows.filter((item) => item.publication_status !== "published" || item.is_owner),
},
{
key: "public",
label: "公共知识空间",
rows: rows.filter((item) => item.publication_status === "published" && !item.is_owner),
},
].filter((group) => group.rows.length > 0);
}
function SpaceIcon({ item }: { item: LlmWikiKnowledgeBase }) {
return (
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md",
item.publication_status === "published"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-primary/10 text-primary",
)}
>
{item.publication_status === "published" ? (
<Globe2 className="size-3.5" />
) : (
<Folder className="size-3.5" />
)}
</span>
);
}
function SelectionContent({
rows,
value,
onChange,
search,
onSearchChange,
loading,
error,
source,
variant,
}: {
rows: LlmWikiKnowledgeBase[];
value: string[];
onChange: (value: string[]) => void;
search: string;
onSearchChange: (value: string) => void;
loading: boolean;
error: unknown;
source: SelectorSource;
variant: SelectorVariant;
}) {
const selected = useMemo(() => new Set(value), [value]);
const filtered = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
if (!query) return rows;
return rows.filter((item) =>
`${item.name} ${item.description}`.toLocaleLowerCase().includes(query),
);
}, [rows, search]);
const groups = useMemo(() => groupRows(filtered), [filtered]);
const toggle = (id: string) => {
onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]);
};
return (
<>
<div className="border-b p-2.5">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(event) => onSearchChange(event.target.value)}
onKeyDown={(event) => event.stopPropagation()}
placeholder="搜索知识空间"
className="h-8 bg-muted/40 pl-8 text-xs"
/>
</div>
</div>
<div className={cn("overflow-y-auto p-1.5", variant === "popover" ? "max-h-64" : "max-h-80")}>
{loading ? (
<div className="flex h-28 items-center justify-center text-xs text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />正在读取知识空间
</div>
) : error ? (
<div className="m-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
{error instanceof Error ? error.message : "知识空间读取失败"}
</div>
) : groups.length === 0 ? (
<div className="flex h-28 items-center justify-center px-4 text-center text-xs text-muted-foreground">
{search
? "没有匹配的知识空间"
: source === "agent"
? "该智能体尚未绑定可用知识空间"
: "当前账号暂无可用知识空间"}
</div>
) : (
groups.map((group, groupIndex) => (
<div key={group.key}>
{groupIndex > 0 && (
variant === "popover"
? <DropdownMenuSeparator className="my-1.5" />
: <div className="my-1.5 border-t" />
)}
{variant === "popover" ? (
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
</DropdownMenuLabel>
) : (
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
</div>
)}
{group.rows.map((item) => {
const rowContent = (
<>
<SpaceIcon item={item} />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{item.name}</div>
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
{item.type === "faq" ? "FAQ" : "文档库"} · {item.knowledge_count ?? 0} 项知识
</div>
</div>
{item.publication_status === "published" && (
<Badge variant="outline" className="h-4 px-1 text-[9px]">公共</Badge>
)}
</>
);
return variant === "popover" ? (
<DropdownMenuCheckboxItem
key={item.id}
checked={selected.has(item.id)}
onCheckedChange={() => toggle(item.id)}
onSelect={(event) => event.preventDefault()}
className={cn(
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
selected.has(item.id) && "bg-primary/8 focus:bg-primary/12",
)}
>
{rowContent}
</DropdownMenuCheckboxItem>
) : (
<button
key={item.id}
type="button"
role="checkbox"
aria-checked={selected.has(item.id)}
onClick={() => toggle(item.id)}
className={cn(
"my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary",
selected.has(item.id) && "bg-primary/8",
)}
>
<span className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border",
selected.has(item.id) && "border-primary bg-primary text-primary-foreground",
)}>
{selected.has(item.id) && <Check className="size-3" />}
</span>
{rowContent}
</button>
);
})}
</div>
))
)}
</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>
</div>
</>
);
}
export function LlmWikiKnowledgeSelector({
value,
onChange,
source = "all",
agentId,
autoSelectAll = false,
disabled = false,
className,
label = "知识空间",
variant = "popover",
}: {
value: string[];
onChange: (value: string[]) => void;
source?: SelectorSource;
agentId?: string | null;
autoSelectAll?: boolean;
disabled?: boolean;
className?: string;
label?: string;
variant?: SelectorVariant;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const runtime = useLlmWikiRuntime();
const all = useLlmWikiKnowledgeBases(
"all",
runtime.data?.provider === "weknora",
);
const agent = useAgentLlmWikiKnowledgeBases(
agentId,
runtime.data?.provider === "weknora" && source === "agent" && !!agentId,
);
const rows = all.data ?? [];
const defaultIds = useMemo(() => {
if (source !== "agent") {
return rows.map((item) => item.id);
}
return (agent.data ?? []).map((item) => item.id);
}, [agent.data, rows, source]);
const loading = all.isLoading || (source === "agent" && !!agentId && agent.isLoading);
const error = all.error ?? (source === "agent" ? agent.error : null);
const availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]);
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
const initialized = useRef<string | null>(null);
useEffect(() => {
if (!all.data || (source === "agent" && agentId && !agent.data) || initialized.current === initializationKey) return;
initialized.current = initializationKey;
const valid = value.filter((id) => availableIds.has(id));
const next = autoSelectAll && valid.length === 0 && defaultIds.length > 0
? defaultIds.filter((id) => availableIds.has(id))
: valid;
if (next.length !== value.length || next.some((id, index) => id !== value[index])) {
onChange(next);
}
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, source, value]);
useEffect(() => {
if (!open) setSearch("");
}, [open]);
if (runtime.isError || runtime.data?.provider !== "weknora") return null;
const content = (
<SelectionContent
rows={rows}
value={value}
onChange={onChange}
search={search}
onSearchChange={setSearch}
loading={loading}
error={error}
source={source}
variant={variant}
/>
);
if (variant === "panel") {
return (
<div className={cn("overflow-hidden rounded-xl border bg-background shadow-sm", className)}>
<div className="flex items-start justify-between gap-4 border-b px-4 py-3">
<div>
<div className="flex items-center gap-2 text-sm font-semibold">
<Database className="size-4 text-primary" />绑定知识空间
</div>
<p className="mt-1 text-xs text-muted-foreground">
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
</p>
</div>
<Badge variant="secondary" className="shrink-0">{value.length} 个</Badge>
</div>
{content}
</div>
);
}
const selectedName = value.length === 1
? rows.find((item) => item.id === value[0])?.name
: null;
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
"h-8 max-w-52 gap-1.5 rounded-full border-0 bg-transparent px-2.5 text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground",
value.length > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary",
className,
)}
disabled={disabled || loading}
>
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <Database className="size-3.5" />}
<span className="truncate text-xs font-normal">{selectedName || label}</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}
</span>
)}
{value.length > 0 && value.length === rows.length && <Check className="size-3" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={8} className="w-80 overflow-hidden rounded-xl p-0 shadow-xl">
{content}
</DropdownMenuContent>
</DropdownMenu>
);
}
export default LlmWikiKnowledgeSelector;
@@ -1,125 +0,0 @@
"use client";
import { useMemo } from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
type DiffRow = {
left: { text: string; kind: "same" | "removed" } | null;
right: { text: string; kind: "same" | "added" } | null;
};
function buildLineDiff(before: string, after: string): DiffRow[] {
const left = before.split("\n");
const right = after.split("\n");
// A large quadratic matrix would make the dialog itself a performance risk.
// Documents above this threshold still get an honest two-column comparison,
// just without expensive alignment of every unchanged line.
if (left.length * right.length > 1_000_000) {
const rows: DiffRow[] = [];
const length = Math.max(left.length, right.length);
for (let index = 0; index < length; index += 1) {
const beforeLine = left[index];
const afterLine = right[index];
const same = beforeLine === afterLine;
rows.push({
left: beforeLine === undefined ? null : { text: beforeLine, kind: same ? "same" : "removed" },
right: afterLine === undefined ? null : { text: afterLine, kind: same ? "same" : "added" },
});
}
return rows;
}
const table = Array.from({ length: left.length + 1 }, () => new Uint16Array(right.length + 1));
for (let i = left.length - 1; i >= 0; i -= 1) {
for (let j = right.length - 1; j >= 0; j -= 1) {
table[i][j] = left[i] === right[j]
? table[i + 1][j + 1] + 1
: Math.max(table[i + 1][j], table[i][j + 1]);
}
}
const rows: DiffRow[] = [];
let i = 0;
let j = 0;
while (i < left.length || j < right.length) {
if (i < left.length && j < right.length && left[i] === right[j]) {
rows.push({ left: { text: left[i], kind: "same" }, right: { text: right[j], kind: "same" } });
i += 1;
j += 1;
} else if (j < right.length && (i === left.length || table[i][j + 1] >= table[i + 1][j])) {
rows.push({ left: null, right: { text: right[j], kind: "added" } });
j += 1;
} else {
rows.push({ left: { text: left[i], kind: "removed" }, right: null });
i += 1;
}
}
return rows;
}
function DiffCell({
value,
line,
}: {
value: DiffRow["left"] | DiffRow["right"];
line: number | null;
}) {
return (
<div
className={cn(
"grid min-w-0 grid-cols-[3rem_minmax(0,1fr)] border-b font-mono text-xs leading-5",
value?.kind === "removed" && "bg-red-50 text-red-950 dark:bg-red-950/30 dark:text-red-100",
value?.kind === "added" && "bg-emerald-50 text-emerald-950 dark:bg-emerald-950/30 dark:text-emerald-100",
!value && "bg-muted/30",
)}
>
<span className="select-none border-r px-2 text-right text-muted-foreground">{line ?? ""}</span>
<pre className="min-w-0 whitespace-pre-wrap break-words px-3">{value?.text ?? ""}</pre>
</div>
);
}
export function DocumentRewriteDiffDialog({
open,
onOpenChange,
fileName,
original,
rewritten,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
fileName: string;
original: string;
rewritten: string;
}) {
const rows = useMemo(() => buildLineDiff(original, rewritten), [original, rewritten]);
let leftLine = 0;
let rightLine = 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[85vh] max-w-[calc(100vw-2rem)] flex-col p-0 sm:max-w-[min(94vw,1440px)]">
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>改写前后对比 · {fileName}</DialogTitle>
<DialogDescription>红色为删除或调整前内容,绿色为新增或改写后内容。</DialogDescription>
</DialogHeader>
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x overflow-auto" role="region" aria-label="Markdown 改写前后对比">
<section aria-label="改写前原文">
<div className="sticky top-0 z-10 border-b bg-background px-4 py-2 text-sm font-medium">改写前</div>
{rows.map((row, index) => {
const line = row.left ? ++leftLine : null;
return <DiffCell key={`left-${index}`} value={row.left} line={line} />;
})}
</section>
<section aria-label="改写后内容">
<div className="sticky top-0 z-10 border-b bg-background px-4 py-2 text-sm font-medium">改写后</div>
{rows.map((row, index) => {
const line = row.right ? ++rightLine : null;
return <DiffCell key={`right-${index}`} value={row.right} line={line} />;
})}
</section>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,295 +0,0 @@
"use client";
import {
CheckCircle2Icon,
FilePenLineIcon,
Loader2Icon,
Undo2Icon,
XCircleIcon,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { apiFetch } from "@/core/api/fetch-client";
import {
DOCUMENT_REWRITE_UNDONE_EVENT,
type DocumentRewritePresentation,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import { getBackendBaseURL } from "@/core/config";
import { cn } from "@/lib/utils";
import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
const STEPS = [
{ key: "snapshot", label: "固定原文版本" },
{ key: "requirements", label: "理解改写要求" },
{ key: "writing", label: "流式重写 Markdown" },
{ key: "validation", label: "校验文档" },
{ key: "comparison", label: "对比原文并生成说明" },
] as const;
function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
switch (stage) {
case "snapshot":
return 0;
case "requirements":
return 1;
case "writing":
return 2;
case "validation":
return 3;
case "committing":
case "completed":
return 5;
case "conflict":
case "error":
case "cancelled":
return 4;
default:
return -1;
}
}
function isTerminal(stage: DocumentRewritePresentation["stage"]) {
return ["completed", "conflict", "error", "cancelled"].includes(stage);
}
export function DocumentRewriteTimeline({
item,
}: {
item: DocumentRewritePresentation;
}) {
const current = stepPosition(item.stage);
const failed = ["conflict", "error", "cancelled"].includes(item.stage);
const completed = item.stage === "completed";
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 handleUndo = async () => {
if (!activeVersionId || undoing) return;
const baseUrl = getBackendBaseURL();
const endpoint =
item.source.kind === "artifact"
? `${baseUrl}/api/writing/document-rewrite/versions/${encodeURIComponent(activeVersionId)}/undo`
: `${baseUrl}/api/deep-research/sessions/${encodeURIComponent(item.source.id)}/document-rewrite/versions/${encodeURIComponent(activeVersionId)}/undo`;
const payload =
item.source.kind === "artifact"
? { threadId: item.source.id, path: item.source.path }
: undefined;
setUndoing(true);
try {
const response = await apiFetch(endpoint, {
method: "POST",
headers: payload ? { "Content-Type": "application/json" } : undefined,
body: payload ? JSON.stringify(payload) : undefined,
});
const result = (await response.json().catch(() => ({}))) as Record<string, unknown>;
if (!response.ok) {
throw new Error(
typeof result.detail === "string" ? result.detail : "撤销改写失败,请稍后重试。",
);
}
const content = typeof result.content === "string" ? result.content : "";
const nextInverseVersionId =
typeof result.redoVersionId === "string" ? result.redoVersionId : null;
presentation?.update(
item.id,
reapplying
? { versionId: nextInverseVersionId, redoVersionId: null, revertedAt: null }
: { redoVersionId: nextInverseVersionId, revertedAt: new Date().toISOString() },
);
window.dispatchEvent(
new CustomEvent(DOCUMENT_REWRITE_UNDONE_EVENT, {
detail: { source: item.source, content },
}),
);
toast.success(reapplying ? "已重新应用本次 AI 全文改写版本。" : "已恢复到本次 AI 全文改写前的版本。");
} catch (error) {
toast.error(error instanceof Error ? error.message : "撤销改写失败,请稍后重试。");
} finally {
setUndoing(false);
}
};
return (
<article className="w-full rounded-2xl border bg-card shadow-sm">
<header className="flex items-start gap-3 border-b px-5 py-4">
<div className="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-full">
<FilePenLineIcon className="size-4" />
</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>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs",
completed
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-300"
: failed
? "bg-destructive/10 text-destructive"
: "bg-primary/10 text-primary",
)}
>
{completed ? "已完成" : failed ? "未覆盖原文件" : "进行中"}
</span>
</div>
<p className="text-muted-foreground mt-1 text-sm">
{item.instruction}
</p>
</div>
</header>
<div className="space-y-3 px-5 py-4">
{STEPS.map((step, index) => {
const active = !isTerminal(item.stage) && index === current;
const done = completed || index < current;
return (
<div className="flex items-start gap-3" key={step.key}>
<div
className={cn(
"mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border",
done
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-400/30 dark:bg-emerald-400/10"
: active
? "border-primary/30 bg-primary/10 text-primary"
: "border-border text-muted-foreground",
)}
>
{done ? (
<CheckCircle2Icon className="size-4" />
) : active ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<span className="text-xs">{index + 1}</span>
)}
</div>
<div className="min-w-0 pt-0.5">
<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()} 字原文快照。
</p>
)}
{step.key === "requirements" && (
item.requirementsPlan ? (
<div
aria-live={active ? "polite" : undefined}
className="bg-muted/45 text-muted-foreground mt-2 max-w-3xl rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap"
role={active ? "status" : undefined}
>
{item.requirementsPlan}
</div>
) : active ? (
<p className="text-muted-foreground mt-0.5 text-xs">
正在识别改写目标、保留约束与执行方式…
</p>
) : (
<p className="text-muted-foreground mt-0.5 text-xs">
已根据改写要求确定执行计划。
</p>
)
)}
{step.key === "writing" && item.writtenCharCount > 0 && (
<p className="text-muted-foreground mt-0.5 text-xs">
正在写入右侧沙箱,已生成 {item.writtenCharCount.toLocaleString()} 字。
</p>
)}
{step.key === "validation" && item.warnings.length > 0 && (
<p className="mt-0.5 text-xs text-amber-700 dark:text-amber-300">
{item.warnings.join(";")}
</p>
)}
</div>
</div>
);
})}
</div>
{item.summary && (
<div className="border-t px-5 py-4 text-sm leading-6">
<div className="mb-2 font-semibold">改写说明</div>
<p>
原文 {item.summary.originalCharCount.toLocaleString()} 字,改写后{" "}
{item.summary.rewrittenCharCount.toLocaleString()} 字;调整了约{" "}
{item.summary.changedBlockCount} 个内容块。
</p>
<p className="text-muted-foreground">
标题数量 {item.summary.headingCountBefore} → {item.summary.headingCountAfter},
使用模型:{item.summary.modelName}。
</p>
{item.summary.requestedOptimizations?.length ? (
<p className="text-muted-foreground">
本次优化:{item.summary.requestedOptimizations.join("、")}。
</p>
) : null}
{item.summary.headingChanges && (
item.summary.headingChanges.reordered ||
item.summary.headingChanges.added.length > 0 ||
item.summary.headingChanges.removed.length > 0 ||
item.summary.headingChanges.levelWarnings.length > 0
) && (
<p className="text-muted-foreground">
{item.summary.headingChanges.reordered ? "已调整章节顺序;" : ""}
{item.summary.headingChanges.added.length
? `新增标题:${item.summary.headingChanges.added.join("、")};`
: ""}
{item.summary.headingChanges.removed.length
? `移除标题:${item.summary.headingChanges.removed.join("、")};`
: ""}
{item.summary.headingChanges.levelWarnings.join(";")}
</p>
)}
{item.originalContent && item.rewrittenContent && (
<Button
className="mt-3 mr-2"
onClick={() => setDiffOpen(true)}
size="sm"
type="button"
variant="outline"
>
查看前后对比
</Button>
)}
{completed && activeVersionId && (
<Button
className="mt-3"
disabled={undoing}
onClick={() => void handleUndo()}
size="sm"
type="button"
variant="outline"
>
{undoing ? <Loader2Icon className="mr-1.5 size-3.5 animate-spin" /> : <Undo2Icon className="mr-1.5 size-3.5" />}
{reapplying ? "重新应用此次改写" : "撤销此次改写"}
</Button>
)}
{item.revertedAt && (
<p className="mt-2 text-xs text-emerald-700 dark:text-emerald-300">
已恢复为改写前版本。{item.redoVersionId ? "可重新应用本次改写。" : ""}
</p>
)}
</div>
)}
{item.error && (
<div className="flex items-center gap-2 border-t px-5 py-3 text-sm text-destructive">
<XCircleIcon className="size-4 shrink-0" />
{item.error}
</div>
)}
<DocumentRewriteDiffDialog
fileName={item.fileName}
onOpenChange={setDiffOpen}
open={diffOpen}
original={item.originalContent}
rewritten={item.rewrittenContent ?? ""}
/>
</article>
);
}
@@ -1,21 +1,19 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
BookOpenTextIcon,
CheckCircle2Icon,
ChevronDownIcon,
ChevronUp,
BookOpenTextIcon,
ChevronDownIcon,
ChevronUp,
FolderOpenIcon,
GlobeIcon,
LightbulbIcon,
ListTodoIcon,
Loader2Icon,
LightbulbIcon,
ListTodoIcon,
MessageCircleQuestionMarkIcon,
NotebookPenIcon,
SearchIcon,
SquareTerminalIcon,
WrenchIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import {
ChainOfThought,
@@ -31,20 +29,14 @@ 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 { useI18n } from "@/core/i18n/hooks";
import {
normalizeSearchResults,
resolveSearchQuery,
@@ -235,7 +227,7 @@ export function MessageGroup({
);
}
function ToolCall({
function ToolCall({
id,
messageId,
name,
@@ -257,6 +249,8 @@ function ToolCall({
ragModeEnabled?: boolean;
}) {
const { t } = useI18n();
const artifacts = useArtifactsOptional();
const displayMatch =
result !== undefined && isDisplaySamplePayload(result)
? null
@@ -271,7 +265,7 @@ function ToolCall({
mode: item.displayMode ?? displayMatch.display.mode,
}))
: [];
if (displayItems.length > 0) {
if (displayItems.length > 0) {
return (
<ChainOfThoughtStep
key={id}
@@ -285,16 +279,9 @@ function ToolCall({
<DisplayResultsList results={displayItems} />
</ChainOfThoughtStep>
);
}
// Deep Research uses a virtual tool call for each durable writing phase.
// Rendering it here keeps its lifecycle in the very same ChainOfThought
// component as ordinary Q&A retrieval and sandbox tool calls.
if (name === "deep_research_progress") {
return <DeepResearchProgressStep id={id} args={args} />;
}
const searchItems = normalizeSearchResults(result);
}
const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
@@ -308,19 +295,17 @@ function ToolCall({
</ChainOfThoughtStep>
);
}
if (name === "web_search" || searchItems.length > 0) {
const query = resolveSearchQuery(args, result);
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
if (query) {
label =
name === "web_search"
? `知识库检索 "${query}"`
: t.toolCalls.searchFor(query);
} else if (name === "web_search") {
label = "知识库检索";
}
return (
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
if (name === "web_search" || searchItems.length > 0) {
const query = resolveSearchQuery(args, result);
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
if (query) {
label =
name === "web_search"
? t.toolCalls.searchOnWebFor(query)
: t.toolCalls.searchFor(query);
}
return (
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
{searchItems.length > 0 && (
<WebSearchResultsList results={searchItems} />
)}
@@ -437,18 +422,44 @@ 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}
/>
);
const path: string | undefined = (args as { path: string })?.path;
const artifactPath =
name === "write_file" && path
? new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString()
: path;
if (artifacts && isLoading && isLast && artifacts.autoOpen && artifacts.autoSelect && path && !result) {
setTimeout(() => {
if (!artifactPath || artifacts.selectedArtifact === artifactPath) {
return;
}
artifacts.select(artifactPath, true);
artifacts.setOpen(true);
}, 100);
}
return (
<ChainOfThoughtStep
key={id}
className="cursor-pointer"
label={description}
icon={NotebookPenIcon}
onClick={() => {
if (!artifactPath || !artifacts) {
return;
}
artifacts.select(artifactPath);
artifacts.setOpen(true);
}}
>
{path && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
} else if (name === "bash") {
const description: string | undefined = (args as { description: string })
?.description;
@@ -509,129 +520,12 @@ 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;
}
}
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
type: T;
}
File diff suppressed because it is too large. Load diff
@@ -11,8 +11,7 @@ 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 { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
@@ -31,13 +30,10 @@ import { useSkills } from "@/core/skills/hooks";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
import { cn } from "@/lib/utils";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
@@ -46,8 +42,7 @@ 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 { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item";
import {
@@ -102,19 +97,17 @@ export function MessageList({
tokenUsageEnabled = false,
extraRehypePlugins,
scrollButtonClassName,
onClarificationSubmit,
onEditResubmit,
onEditSave,
messageContentOverrides,
externallyHiddenMessageIds,
onSetupAgentApprove,
onSetupAgentReject,
onSetupWritingApprove,
onSetupWritingReject,
onSetupWritingStart,
researchApprovalSlot,
onClarificationSubmit,
onEditResubmit,
onEditSave,
messageContentOverrides,
externallyHiddenMessageIds,
onSetupAgentApprove,
onSetupAgentReject,
onSetupWritingApprove,
onSetupWritingReject,
onSetupWritingStart,
onOpenArtifact,
onDownloadArtifact,
}: {
className?: string;
contentClassName?: string;
@@ -126,35 +119,25 @@ export function MessageList({
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
scrollButtonClassName?: string;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onEditResubmit?: (text: string) => void;
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
messageContentOverrides?: Record<string, string>;
externallyHiddenMessageIds?: Set<string>;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onEditResubmit?: (text: string) => void;
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
messageContentOverrides?: Record<string, string>;
externallyHiddenMessageIds?: Set<string>;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
/**
* 深度研究的交互卡插槽(报告配置、计划确认、失败重试):workbench 向
* displayThread 注入带 `additional_kwargs.setup_research_approval` 的虚拟 ai
* 消息。撰写步骤和完成摘要不走此插槽,而是分别复用原生 tool-call 步骤条和
* assistant 消息行;不传该 slot 时普通对话/AI 写作零行为变化。
*/
researchApprovalSlot?: (message: Message) => React.ReactNode;
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onOpenArtifact?: (filepath: string) => void;
/** Host override for downloading virtual/session-owned files in a present-files card. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
}) {
}) {
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 { t } = useI18n();
const threadContext = useContext(ThreadContext);
// Read-only contexts (public share, mock previews) must not offer deletion.
@@ -180,34 +163,33 @@ export function MessageList({
});
}, []);
const allMessages = useMemo(() => {
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
return thread.messages
.filter((m) => !isHiddenFromUIMessage(m))
.map((message) => {
const override =
hasOverrides && message.id
? messageContentOverrides?.[message.id]
: undefined;
return override === undefined
? message
: ({ ...message, content: override } as Message);
});
}, [messageContentOverrides, thread.messages]);
const allMessages = useMemo(() => {
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
return thread.messages
.filter((m) => !isHiddenFromUIMessage(m))
.map((message) => {
const override =
hasOverrides && message.id
? messageContentOverrides?.[message.id]
: undefined;
return override === undefined
? message
: ({ ...message, content: override } as Message);
});
}, [messageContentOverrides, thread.messages]);
const messages = useMemo(
() => {
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
return hiddenMessageIds.size === 0 && externalHidden.size === 0
? allMessages
: allMessages.filter(
(m) =>
!m.id ||
(!hiddenMessageIds.has(m.id) && !externalHidden.has(m.id)),
);
},
() => {
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
return hiddenMessageIds.size === 0 && externalHidden.size === 0
? allMessages
: allMessages.filter(
(m) =>
!m.id ||
(!hiddenMessageIds.has(m.id) && !externalHidden.has(m.id)),
);
},
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
);
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
const askTimes = useChatAskTimes();
@@ -255,19 +237,10 @@ export function MessageList({
const [settings] = useLocalSettings();
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
const hasSingleRoundRag = useMemo(
() =>
messages.some(
(message) =>
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
),
[messages],
);
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
[referencesEnabled, skills],
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
);
// Same per-turn reference batches the right-side panel renders (backend
// batches preferred). Inline [n] tokens must resolve against these so badge
@@ -275,53 +248,6 @@ export function MessageList({
// frontend tool-message scan below comes up empty (e.g. after a reload).
const referenceBatches = useContext(ReferenceBatchesContext);
// 单轮/多轮 RAG 的参考资料存放在一条 hide_from_ui 的 tool 消息里(紧挨在
// assistant 消息之前)。allMessages 会把 hide_from_ui 消息过滤掉,导致通用
// 的 collectRuntimeDisplaySources 扫描不到它们。这里直接在原始消息流里按
// assistant 消息提取其对应的参考资料,保证 `[1]` 等编号能解析成可点击引用。
const frontendRagSourcesByAiId = useMemo(() => {
const map = new Map<string, RuntimeDisplaySource[]>();
const raw = thread.messages;
for (let i = 0; i < raw.length; i++) {
const msg = raw[i];
if (!msg || msg.type !== "ai" || !msg.id) continue;
const kwargs = msg.additional_kwargs;
if (!kwargs || (kwargs.single_round_rag !== true && kwargs.multi_round_rag !== true)) {
continue;
}
let sources: RuntimeDisplaySource[] = [];
for (let j = i - 1; j >= 0; j--) {
const prev = raw[j];
if (!prev) break;
// 前一条 human/ai 消息即为本轮边界,跨边界即停止。
if (prev.type === "human" || prev.type === "ai") break;
if (prev.type !== "tool") continue;
const prevKwargs = prev.additional_kwargs;
if (
!prevKwargs ||
(prevKwargs.single_round_rag !== true && prevKwargs.multi_round_rag !== true)
) {
continue;
}
const results = extractFrontendRagResults(prev.content);
if (results && results.length > 0) {
const toolName = String(
(prev as { name?: unknown }).name ??
(prevKwargs.multi_round_rag === true
? "multi_round_rag_references"
: "knowledge_base_search"),
);
sources = results.map((item, index) =>
toRuntimeDisplaySource(item, index + 1, toolName),
);
}
break;
}
map.set(msg.id, sources);
}
return map;
}, [thread.messages]);
// 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).
@@ -443,17 +369,11 @@ export function MessageList({
)}
>
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => {
{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 =
referencesEnabled &&
return group.messages.map((msg) => {
const isAssistantMessage =
ragModeEnabled &&
group.type === "assistant" &&
msg.type === "ai";
const batchSources = isAssistantMessage
@@ -461,21 +381,14 @@ export function MessageList({
(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,
})
: collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
})
: [];
return (
<MessageListItem
@@ -485,17 +398,17 @@ export function MessageList({
msg.type === "human"
? getQuestionAnchorId(msg, messages.indexOf(msg))
: undefined
}
message={msg}
isLoading={messageIsLoading}
}
message={msg}
isLoading={thread.isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
onEditSave={onEditSave}
onMessageDeleted={
allowDelete ? handleMessageDeleted : undefined
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
onEditSave={onEditSave}
onMessageDeleted={
allowDelete ? handleMessageDeleted : undefined
}
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
/>
@@ -582,15 +495,6 @@ export function MessageList({
/>
</div>
) : null;
} else if (group.type === "assistant:research-approval") {
// 深度研究交互卡(配置/计划确认/失败重试):宿主不传渲染函数时该
// 分组不渲染。撰写步骤和完成摘要会经过普通的 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:present-files") {
const files: string[] = [];
for (const message of group.messages) {
@@ -609,11 +513,10 @@ export function MessageList({
className="mb-4"
/>
)}
<ArtifactFileList
<ArtifactFileList
files={files}
threadId={threadId}
onOpenArtifact={onOpenArtifact}
onDownloadArtifact={onDownloadArtifact}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
@@ -738,11 +641,8 @@ export function MessageList({
/>
</div>
);
})}
{documentRewriteItems.map((item) => (
<DocumentRewriteTimeline key={item.id} item={item} />
))}
{thread.isLoading && <StreamingIndicator className="my-4" />}
})}
{thread.isLoading && <StreamingIndicator className="my-4" />}
<div style={{ height: `${paddingBottom}px` }} />
</MessageMetricsProvider>
</ConversationContent>
@@ -817,46 +717,6 @@ function findPreviousHumanMessageId(
return undefined;
}
/** 从单轮/多轮 RAG 的 tool 消息体里取出 `content.results`。 */
function extractFrontendRagResults(content: unknown): unknown[] | null {
if (content && typeof content === "object" && !Array.isArray(content)) {
const results = (content as { results?: unknown }).results;
if (Array.isArray(results)) return results;
}
return null;
}
/** 把单轮/多轮 RAG 的一条参考资料归一化为展示用的 RuntimeDisplaySource。 */
function toRuntimeDisplaySource(
item: unknown,
index: number,
skillName: string,
): RuntimeDisplaySource {
const record =
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>)
: {};
const raw =
(record.raw as Record<string, unknown> | undefined) ?? record;
const content = String(record.content ?? record.snippet ?? "");
return {
id: typeof record.id === "string" && record.id ? record.id : undefined,
title: String(record.title ?? `参考资料 ${index}`),
type: String(record.type ?? "result"),
snippet: String(record.snippet ?? content),
content,
source:
typeof record.source === "string" && record.source ? record.source : undefined,
author: undefined,
time: typeof record.time === "string" && record.time ? record.time : undefined,
url: typeof record.url === "string" && record.url ? record.url : undefined,
raw,
index,
skillName,
mode: "citation",
};
}
function getSetupAgentApprovalPayload(
message: unknown,
): SetupAgentApprovalPayload | null {
@@ -46,23 +46,20 @@ function getModeDescriptionKey(
}
}
export function ModeHoverGuide({
mode,
children,
showTitle = true,
tooltipContent,
}: {
mode: AgentMode;
children: React.ReactNode;
/** When true, tooltip shows "ModeName: Description". When false, only description. */
showTitle?: boolean;
/** Overrides the standard mode tooltip for page-specific execution modes. */
tooltipContent?: string;
}) {
const { t } = useI18n();
const label = t.inputBox[getModeLabelKey(mode)];
const description = t.inputBox[getModeDescriptionKey(mode)];
const content = tooltipContent ?? (showTitle ? `${label}: ${description}` : description);
export function ModeHoverGuide({
mode,
children,
showTitle = true,
}: {
mode: AgentMode;
children: React.ReactNode;
/** When true, tooltip shows "ModeName: Description". When false, only description. */
showTitle?: boolean;
}) {
const { t } = useI18n();
const label = t.inputBox[getModeLabelKey(mode)];
const description = t.inputBox[getModeDescriptionKey(mode)];
const content = showTitle ? `${label}: ${description}` : description;
return <Tooltip content={content}>{children}</Tooltip>;
}
@@ -71,9 +71,8 @@ import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import {
useDeleteBuiltinSkill,
useDeleteCustomSkill,
import {
useDeleteCustomSkill,
useDuplicateSkill,
useGenerateMissingSkillNameZh,
useGenerateSkillNameZh,
@@ -617,10 +616,9 @@ export function SkillRow({
const { data: cardPublishSquares } = usePublishSquares();
const setPinned = useSetSkillPinned();
const setFeatured = useSetSkillFeatured();
const setAlwaysOn = useSetSkillAlwaysOn();
const { data: compression } = useSkillCompressionSettings();
const deleteCustom = useDeleteCustomSkill();
const deleteBuiltin = useDeleteBuiltinSkill();
const setAlwaysOn = useSetSkillAlwaysOn();
const { data: compression } = useSkillCompressionSettings();
const deleteSkill = useDeleteCustomSkill();
const duplicateSkillMutation = useDuplicateSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [tagPickerOpen, setTagPickerOpen] = useState(false);
@@ -636,9 +634,8 @@ export function SkillRow({
const isOwned =
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
const isFromOthers = isCustom && !isBuiltin && !isOwned;
// Admin can manage custom skills from any owner; non-admin only their own.
const canManage = isCustom && (isOwned || isAdmin);
const canDelete = isAdmin || isOwned;
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own.
const canManage = isCustom && (isOwned || isAdmin);
const canTogglePublish = canManage && !isBuiltin;
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
const canSetAlwaysOn = isAdmin || isOwned;
@@ -689,15 +686,11 @@ export function SkillRow({
}
}
async function handleDelete() {
try {
if (isBuiltin) {
await deleteBuiltin.mutateAsync(skill.name);
} else {
await deleteCustom.mutateAsync(skill.name);
}
toast.success(t.settings.skills.deleteSuccess);
setConfirmDeleteOpen(false);
async function handleDelete() {
try {
await deleteSkill.mutateAsync(skill.name);
toast.success(t.settings.skills.deleteSuccess);
setConfirmDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
@@ -925,7 +918,7 @@ export function SkillRow({
>
<TagsIcon className="size-3.5" />
</Button>
{canManage && (
{canManage && (
<Button
size="icon"
variant="ghost"
@@ -946,12 +939,12 @@ export function SkillRow({
/>
</Button>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
{canManage && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
skill.pinned && "text-emerald-600 hover:text-emerald-700",
)}
disabled={setPinned.isPending}
@@ -1018,11 +1011,11 @@ export function SkillRow({
>
<CopyIcon className="size-3.5" />
</Button>
{canDelete && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
{canManage && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction}
>
@@ -1116,18 +1109,16 @@ export function SkillRow({
<Button
variant="outline"
onClick={() => setConfirmDeleteOpen(false)}
disabled={deleteCustom.isPending || deleteBuiltin.isPending}
disabled={deleteSkill.isPending}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
onClick={() => void handleDelete()}
disabled={deleteCustom.isPending || deleteBuiltin.isPending}
>
{deleteCustom.isPending || deleteBuiltin.isPending
? t.common.loading
: t.common.delete}
disabled={deleteSkill.isPending}
>
{deleteSkill.isPending ? t.common.loading : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
@@ -92,7 +92,7 @@ export function SourceCard({
knowledge_base_id: previewTarget.knowledgeBaseId,
chunk_id: previewTarget.chunkId,
});
void apiFetch(`${getBackendBaseURL()}/api/llmwiki/sources/context?${params}`)
void apiFetch(`${getBackendBaseURL()}/api/weknora/source-context?${params}`)
.then(async (response) => {
const payload = (await response.json().catch(() => null)) as unknown;
if (!response.ok || !isWeKnoraSourceContextResponse(payload)) {
@@ -240,18 +240,7 @@ 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;
@@ -323,33 +312,6 @@ function WeKnoraSourceContextPanel({
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">
@@ -5,7 +5,6 @@ import {
DownloadIcon,
Loader2,
PencilIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
@@ -23,7 +22,6 @@ import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { getSkillFileDownloadURL } from "@/core/skills/api";
import {
useDeleteSkillFile,
useSaveSkillFile,
useSkillFileContent,
} from "@/core/skills/hooks";
@@ -68,7 +66,6 @@ export function SkillFilePreviewDialog({
open ? path : null,
);
const save = useSaveSkillFile();
const remove = useDeleteSkillFile();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
@@ -80,8 +77,6 @@ export function SkillFilePreviewDialog({
}, [path, open]);
const fileEditable = !!data?.editable && treeEditable;
const isRootSkillFile = path?.replace(/\\/g, "/").toLowerCase() === "skill.md";
const fileDeletable = !!data && treeEditable && !isRootSkillFile;
const isMarkdown = useMemo(() => {
if (!path) return false;
const lower = path.toLowerCase();
@@ -120,18 +115,6 @@ export function SkillFilePreviewDialog({
}
}
async function handleDelete() {
if (!skillName || !path) return;
if (!window.confirm(tx.filesDeleteConfirm.replace("{path}", path))) return;
try {
await remove.mutateAsync({ skillName, path });
toast.success(tx.filesDeleteSuccess);
onOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleCopyPath() {
if (!path) return;
await navigator.clipboard.writeText(path);
@@ -175,18 +158,6 @@ export function SkillFilePreviewDialog({
{tx.filesEditAction}
</Button>
)}
{fileDeletable && !editing && (
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
onClick={() => void handleDelete()}
disabled={remove.isPending}
>
{remove.isPending ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2Icon className="size-3.5" />}
{tx.filesDeleteAction}
</Button>
)}
{editing && (
<>
<Button
@@ -14,7 +14,7 @@ import {
} 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, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -172,18 +172,6 @@ export function WorkspaceNavChatList({
</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")}
@@ -196,7 +184,7 @@ export function WorkspaceNavChatList({
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{/* <SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
asChild
@@ -207,7 +195,7 @@ export function WorkspaceNavChatList({
<span>岗位会商</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem> */}
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"}
+4 -8
View File
@@ -61,9 +61,7 @@ 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;
tags?: Tag[];
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
@@ -98,8 +96,7 @@ 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;
}
export interface UpdateAgentRequest {
@@ -116,6 +113,5 @@ export interface UpdateAgentRequest {
/** 入库模板名;传空字符串表示清除。 */
template_name?: string | null;
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null;
llmwiki_knowledge_base_ids?: string[] | null;
}
seat_skill_directive?: boolean | null;
}
@@ -1,68 +0,0 @@
export type StreamingArtifactLanguage = "markdown" | "html" | "mermaid";
export function normalizeStreamingArtifactPath(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;
}
export function hasCompleteArtifactExtension(path: string): boolean {
const pathname = normalizeStreamingArtifactPath(path).split(/[?#]/, 1)[0] ?? "";
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
// JSON tool arguments commonly stream `report.m` before `report.md`.
// Treating the partial suffix as final makes the sandbox permanently TEXT.
return /\.[a-z0-9]{2,12}$/i.test(filename);
}
export function provisionalWriteFilePath(content: string): string {
const head = content.slice(0, 256).trimStart().toLowerCase();
if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
return "未命名文件.html";
}
return "未命名文件.md";
}
export function languageOfStreamingArtifact(
path: string,
): StreamingArtifactLanguage | null {
const pathname = normalizeStreamingArtifactPath(path)
.split(/[?#]/, 1)[0]
?.toLowerCase();
if (pathname?.endsWith(".md") || pathname?.endsWith(".markdown")) {
return "markdown";
}
if (pathname?.endsWith(".html") || pathname?.endsWith(".htm")) {
return "html";
}
if (pathname?.endsWith(".mmd") || pathname?.endsWith(".mermaid")) {
return "mermaid";
}
return null;
}
export function buildWriteFileVirtualUrl({
path,
messageId,
toolCallId,
}: {
path: string;
messageId: string;
toolCallId: string;
}): string {
const language = languageOfStreamingArtifact(path);
const languageQuery = language ? `&language=${language}` : "";
return new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}${languageQuery}`,
).toString();
}
+9 -23
View File
@@ -13,33 +13,19 @@ export function buildIntranetSourceUrl(recUuid?: string | null): string {
return `${INTRANET_SOURCE_URL_BASE}${encodeURIComponent(recUuid)}`;
}
function getBaseOrigin() {
function getBaseOrigin() {
if (typeof window !== "undefined") {
return window.location.origin;
}
// Fallback for SSR
return "http://localhost:2026";
}
function alignLoopbackHostname(url: URL): URL {
if (typeof window === "undefined") return url;
const loopbackHosts = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
if (
loopbackHosts.has(url.hostname) &&
loopbackHosts.has(window.location.hostname)
) {
url.hostname = window.location.hostname;
}
return url;
}
export function getBackendBaseURL() {
if (env.NEXT_PUBLIC_BACKEND_BASE_URL) {
return alignLoopbackHostname(
new URL(env.NEXT_PUBLIC_BACKEND_BASE_URL, getBaseOrigin()),
)
.toString()
.replace(/\/+$/, "");
return "http://localhost:2026";
}
export function getBackendBaseURL() {
if (env.NEXT_PUBLIC_BACKEND_BASE_URL) {
return new URL(env.NEXT_PUBLIC_BACKEND_BASE_URL, getBaseOrigin())
.toString()
.replace(/\/+$/, "");
} else {
return "";
}
@@ -1,166 +0,0 @@
"use client";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import type {
DocumentRewriteStage,
} from "@/open-canvas/hooks/useDocumentRewrite";
import type { DocumentRewriteSummary } from "@/open-canvas/api/rewrite";
export type DocumentRewritePresentation = {
id: string;
threadId: string;
source: {
kind: "artifact" | "deep_research_report";
id: string;
path?: string;
};
fileName: string;
instruction: string;
style: string;
modelName: string | null;
stage: DocumentRewriteStage;
originalCharCount: number;
writtenCharCount: number;
/** Short model-generated plan, streamed while it understands the request. */
requirementsPlan: string;
warnings: string[];
summary: DocumentRewriteSummary | null;
/** Kept in-memory for the line-level compare dialog; backend job hydration refills it. */
originalContent: string;
rewrittenContent: string | null;
versionId: string | null;
/** Inverse snapshot created by a safe undo; enables a one-click reapply. */
redoVersionId: string | null;
revertedAt: string | null;
error: string | null;
createdAt: string;
updatedAt: string;
};
type CreatePresentationInput = Omit<
DocumentRewritePresentation,
| "id"
| "stage"
| "originalCharCount"
| "writtenCharCount"
| "requirementsPlan"
| "warnings"
| "summary"
| "originalContent"
| "rewrittenContent"
| "versionId"
| "redoVersionId"
| "revertedAt"
| "error"
| "createdAt"
| "updatedAt"
> & {
originalContent?: string;
rewrittenContent?: string | null;
requirementsPlan?: string;
};
type DocumentRewritePresentationContextValue = {
create: (input: CreatePresentationInput) => string;
update: (
id: string,
patch: Partial<Omit<DocumentRewritePresentation, "id" | "threadId" | "createdAt">>,
) => void;
forThread: (threadId: string) => DocumentRewritePresentation[];
};
const DocumentRewritePresentationContext =
createContext<DocumentRewritePresentationContextValue | null>(null);
function createId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return "drw-ui-" + crypto.randomUUID();
}
return "drw-ui-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
}
export function DocumentRewritePresentationProvider({
children,
}: {
children: React.ReactNode;
}) {
const [presentations, setPresentations] = useState<DocumentRewritePresentation[]>([]);
const create = useCallback((input: CreatePresentationInput) => {
const id = createId();
const now = new Date().toISOString();
setPresentations((previous) => [
...previous.slice(-29),
{
...input,
id,
stage: "snapshot",
originalCharCount: 0,
writtenCharCount: 0,
requirementsPlan: input.requirementsPlan ?? "",
warnings: [],
summary: null,
originalContent: input.originalContent ?? "",
rewrittenContent: input.rewrittenContent ?? null,
versionId: null,
redoVersionId: null,
revertedAt: null,
error: null,
createdAt: now,
updatedAt: now,
},
]);
return id;
}, []);
const update = useCallback(
(
id: string,
patch: Partial<Omit<DocumentRewritePresentation, "id" | "threadId" | "createdAt">>,
) => {
setPresentations((previous) => {
const index = previous.findIndex((item) => item.id === id);
if (index < 0) return previous;
const current = previous[index];
const changed = Object.entries(patch).some(
([key, value]) => current[key as keyof DocumentRewritePresentation] !== value,
);
// Do not create a new context value for an identical update. Stream
// subscribers can legitimately receive an already-hydrated snapshot;
// treating it as a state change makes effect-based synchronisation
// vulnerable to update loops.
if (!changed) return previous;
const next = [...previous];
next[index] = { ...current, ...patch, updatedAt: new Date().toISOString() };
return next;
});
},
[],
);
const value = useMemo<DocumentRewritePresentationContextValue>(
() => ({
create,
update,
forThread: (threadId) =>
presentations.filter((item) => item.threadId === threadId),
}),
[create, presentations, update],
);
return (
<DocumentRewritePresentationContext.Provider value={value}>
{children}
</DocumentRewritePresentationContext.Provider>
);
}
export function useDocumentRewritePresentation() {
return useContext(DocumentRewritePresentationContext);
}
/** Browser event consumed by the open sandbox after an in-feed undo succeeds. */
export const DOCUMENT_REWRITE_UNDONE_EVENT = "deerflow:document-rewrite-undone";
+4 -7
View File
@@ -545,13 +545,10 @@ export const enUS: Translations = {
filesSaveAction: "Save",
filesCancelEditAction: "Cancel",
filesDownloadAction: "Download",
filesCopyPathAction: "Copy path",
filesDiscardConfirm: "Discard unsaved changes?",
filesSaveSuccess: "File saved",
filesDeleteAction: "Delete",
filesDeleteConfirm: "Delete \"{path}\"? This cannot be undone.",
filesDeleteSuccess: "File deleted",
filesReadOnlyHint: "Only administrators can edit or delete built-in skill files.",
filesCopyPathAction: "Copy path",
filesDiscardConfirm: "Discard unsaved changes?",
filesSaveSuccess: "File saved",
filesReadOnlyHint: "Built-in skills are read-only.",
viewDetailAction: "View skill details",
stateSheet: {
archivedTitle: "Archived skills",
+4 -7
View File
@@ -475,13 +475,10 @@ export interface Translations {
filesSaveAction: string;
filesCancelEditAction: string;
filesDownloadAction: string;
filesCopyPathAction: string;
filesDiscardConfirm: string;
filesSaveSuccess: string;
filesDeleteAction: string;
filesDeleteConfirm: string;
filesDeleteSuccess: string;
filesReadOnlyHint: string;
filesCopyPathAction: string;
filesDiscardConfirm: string;
filesSaveSuccess: string;
filesReadOnlyHint: string;
viewDetailAction: string;
stateSheet: {
archivedTitle: string;
+2 -5
View File
@@ -524,11 +524,8 @@ export const zhCN: Translations = {
filesDownloadAction: "下载",
filesCopyPathAction: "复制路径",
filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
filesSaveSuccess: "文件已保存",
filesDeleteAction: "删除",
filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。",
filesDeleteSuccess: "文件已删除",
filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。",
filesSaveSuccess: "文件已保存",
filesReadOnlyHint: "内置技能为只读,仅可查看。",
viewDetailAction: "查看技能详情",
stateSheet: {
archivedTitle: "归档的技能",
@@ -1,66 +0,0 @@
import type { Message } from "@langchain/langgraph-sdk";
import { textOfMessage } from "@/core/threads/utils";
import { createLlmWikiConversationDeposit } from "@/strategy-components/api/llmwiki";
function isHidden(message: Message): boolean {
const extra = (message as { additional_kwargs?: Record<string, unknown> })
.additional_kwargs;
return extra?.hide_from_ui === true;
}
function messageText(message: Message | undefined): string {
return (message ? textOfMessage(message) : "")?.trim() ?? "";
}
function hasText(message: Message | undefined): message is Message {
return !!message && messageText(message).length > 0;
}
export function queueLlmWikiConversationDeposit({
threadId,
messages,
assistantId,
knowledgeBaseIds,
}: {
threadId: string | null | undefined;
messages: Message[] | undefined;
assistantId?: string | null;
knowledgeBaseIds?: string[];
}) {
if (!threadId || !messages?.length) {
return;
}
const visible = messages.filter((message) => !isHidden(message));
const assistantIndex = [...visible]
.map((message, index) => ({ message, index }))
.reverse()
.find(({ message }) => message.type === "ai" && hasText(message))?.index;
if (assistantIndex === undefined) {
return;
}
const assistantMessage = visible[assistantIndex];
const humanMessage = [...visible.slice(0, assistantIndex)]
.reverse()
.find((message) => message.type === "human" && hasText(message));
if (!assistantMessage?.id || !humanMessage) {
return;
}
const question = messageText(humanMessage);
const answer = messageText(assistantMessage);
if (!question || !answer) {
return;
}
void createLlmWikiConversationDeposit({
thread_id: threadId,
question,
answer,
human_message_id: humanMessage.id ?? null,
assistant_message_id: assistantMessage.id,
assistant_id: assistantId ?? null,
knowledge_base_ids: knowledgeBaseIds ?? [],
}).catch((error) => {
console.warn("Failed to queue LLMWiki conversation deposit", error);
});
}
+1 -25
View File
@@ -22,8 +22,6 @@ interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistan
interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {}
interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant:research-approval"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup =
@@ -34,7 +32,6 @@ type MessageGroup =
| AssistantClarificationGroup
| AssistantSetupAgentApprovalGroup
| AssistantSetupWritingApprovalGroup
| AssistantResearchApprovalGroup
| AssistantSubagentGroup;
export function groupMessages<T>(
@@ -57,8 +54,7 @@ export function groupMessages<T>(
last.type !== "assistant" &&
last.type !== "assistant:clarification" &&
last.type !== "assistant:setup-agent-approval" &&
last.type !== "assistant:setup-writing-approval" &&
last.type !== "assistant:research-approval"
last.type !== "assistant:setup-writing-approval"
) {
return last;
}
@@ -118,18 +114,6 @@ export function groupMessages<T>(
}
if (message.type === "ai") {
// Deep-research interaction card: virtual ai messages injected by the
// workbench (additional_kwargs.setup_research_approval) render through
// the host-provided slot. Writing phases use ordinary virtual tool calls
// and completed summaries remain normal assistant messages.
if (hasResearchApproval(message)) {
groups.push({
id: message.id,
type: "assistant:research-approval",
messages: [message],
});
continue;
}
if (hasPresentFiles(message)) {
groups.push({
id: message.id,
@@ -402,14 +386,6 @@ export function isSetupWritingApprovalToolMessage(message: Message) {
);
}
export function hasResearchApproval(message: Message) {
return (
message.type === "ai" &&
typeof message.additional_kwargs?.setup_research_approval === "object" &&
message.additional_kwargs.setup_research_approval !== null
);
}
export function extractPresentFilesFromMessage(message: Message) {
if (message.type !== "ai" || !hasPresentFiles(message)) {
return [];
@@ -1,114 +0,0 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export interface ManagedModel {
name: string;
display_name: string | null;
description: string | null;
use: string;
model: string;
base_url: string | null;
request_timeout: number | null;
max_retries: number | null;
max_tokens: number | null;
temperature: number | null;
stream_usage: boolean | null;
supports_thinking: boolean;
supports_reasoning_effort: boolean;
supports_vision: boolean;
when_thinking_enabled: Record<string, unknown> | null;
when_thinking_disabled: Record<string, unknown> | null;
thinking: Record<string, unknown> | null;
use_responses_api: boolean | null;
output_version: string | null;
extra_settings: Record<string, unknown>;
api_key_configured: boolean;
api_key_source: string | null;
source: "config.yaml";
}
export interface ManagedModelsResponse {
models: ManagedModel[];
config_path: string;
updated_at: string | null;
}
export interface ModelSavePayload {
name: string;
display_name?: string | null;
description?: string | null;
use: string;
model: string;
api_key?: string;
clear_api_key?: boolean;
base_url?: string | null;
request_timeout?: number | null;
max_retries?: number | null;
max_tokens?: number | null;
temperature?: number | null;
stream_usage?: boolean | null;
supports_thinking: boolean;
supports_reasoning_effort: boolean;
supports_vision: boolean;
when_thinking_enabled?: Record<string, unknown> | null;
when_thinking_disabled?: Record<string, unknown> | null;
thinking?: Record<string, unknown> | null;
use_responses_api?: boolean | null;
output_version?: string | null;
extra_settings: Record<string, unknown>;
}
export interface ModelConnectionTestResult {
success: boolean;
latency_ms: number;
message: string;
preview?: string | null;
}
async function readError(response: Response, fallback: string): Promise<never> {
const payload = await response.json().catch(() => null) as { detail?: string } | null;
throw new Error(payload?.detail || fallback);
}
export async function loadManagedModels(): Promise<ManagedModelsResponse> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models`);
if (!response.ok) return readError(response, "加载模型配置失败");
return response.json() as Promise<ManagedModelsResponse>;
}
export async function createManagedModel(payload: ModelSavePayload): Promise<ManagedModel> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) return readError(response, "创建模型失败");
return response.json() as Promise<ManagedModel>;
}
export async function updateManagedModel(name: string, payload: ModelSavePayload): Promise<ManagedModel> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/${encodeURIComponent(name)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) return readError(response, "保存模型配置失败");
return response.json() as Promise<ManagedModel>;
}
export async function deleteManagedModel(name: string): Promise<void> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/${encodeURIComponent(name)}`, {
method: "DELETE",
});
if (!response.ok) return readError(response, "删除模型失败");
}
export async function testManagedModel(model: ModelSavePayload): Promise<ModelConnectionTestResult> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
if (!response.ok) return readError(response, "模型连接测试失败");
return response.json() as Promise<ModelConnectionTestResult>;
}
@@ -1,52 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createManagedModel,
deleteManagedModel,
loadManagedModels,
testManagedModel,
updateManagedModel,
type ModelSavePayload,
} from "./api";
export const MANAGED_MODELS_QUERY_KEY = ["admin", "model-config"] as const;
export function useManagedModels() {
return useQuery({
queryKey: MANAGED_MODELS_QUERY_KEY,
queryFn: loadManagedModels,
staleTime: 10_000,
refetchOnWindowFocus: false,
});
}
export function useSaveManagedModel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ name, payload, isNew }: { name: string; payload: ModelSavePayload; isNew: boolean }) =>
isNew ? createManagedModel(payload) : updateManagedModel(name, payload),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: MANAGED_MODELS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ["models"] }),
]);
},
});
}
export function useTestManagedModel() {
return useMutation({ mutationFn: testManagedModel });
}
export function useDeleteManagedModel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteManagedModel,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: MANAGED_MODELS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ["models"] }),
]);
},
});
}
@@ -1,2 +0,0 @@
export * from "./api";
export * from "./hooks";
@@ -1,36 +0,0 @@
import type { Model } from "./types";
export const SINGLE_ROUND_RAG_MODELS: Model[] = [
{ id: "single-round-rag-openai3", name: "OpenAI3", model: "OpenAI3", display_name: "Qwen3-32B", supports_thinking: true, execution_profile: "single_round_rag" },
{ id: "single-round-rag-openai4", name: "OpenAI4", model: "OpenAI4", display_name: "Qwen3-235B", supports_thinking: true, execution_profile: "single_round_rag" },
{ id: "single-round-rag-openai5", name: "OpenAI5", model: "OpenAI5", display_name: "GLM-5", supports_thinking: true, execution_profile: "single_round_rag" },
{ id: "single-round-rag-openai6", name: "OpenAI6", model: "OpenAI6", display_name: "Qwen3.5-27B", supports_thinking: true, execution_profile: "single_round_rag" },
{ id: "single-round-rag-openai7", name: "OpenAI7", model: "OpenAI7", display_name: "Qwen3-8B", supports_thinking: true, execution_profile: "single_round_rag" },
{ id: "single-round-rag-openai9", name: "OpenAI9", model: "OpenAI9", display_name: "四季7B", supports_thinking: true, execution_profile: "single_round_rag" },
];
export function isSingleRoundRagProfile(
context: unknown,
): boolean {
return (
typeof context === "object" &&
context !== null &&
(context as { execution_profile?: unknown }).execution_profile ===
"single_round_rag"
);
}
export function isMultiRoundRagProfile(
context: unknown,
): boolean {
return (
typeof context === "object" &&
context !== null &&
(context as { execution_profile?: unknown }).execution_profile ===
"multi_round_rag"
);
}
export function isFrontendRagProfile(context: unknown): boolean {
return isSingleRoundRagProfile(context) || isMultiRoundRagProfile(context);
}
+3 -5
View File
@@ -4,11 +4,9 @@ export interface Model {
model: string;
display_name: string;
description?: string | null;
supports_thinking?: boolean;
supports_reasoning_effort?: boolean;
/** Execution profiles handled by the frontend instead of DeerFlow. */
execution_profile?: "single_round_rag" | "multi_round_rag";
}
supports_thinking?: boolean;
supports_reasoning_effort?: boolean;
}
export interface TokenUsageSettings {
enabled: boolean;
@@ -35,7 +35,6 @@ import {
Settings,
Sparkles,
Target,
Telescope,
Trophy,
Users,
} from "lucide-react";
@@ -93,12 +92,6 @@ export const sidebarMenuItems: MenuItem[] = [
icon: FileText,
path: "/page/canvas/ai-writing-chat",
},
{
id: "qa-deep-research",
label: "深度研究",
icon: Telescope,
path: "/page/strategy/qa/deep-research",
},
],
},
{
@@ -1,192 +0,0 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
buildSentimentAgentHeaders,
readSentimentAgentEndpoint,
} from "./config";
/**
* 舆情分析智能体走的是外部 AG-UI 协议网关(POST,SSE 返回):
* 请求体为 { threadId, runId, state, tools, context, forwardedProps, messages },
* 返回为 `data: {"type": ...}` 的 SSE 事件流:
* RUN_STARTED / THINKING_TEXT_MESSAGE_START|CONTENT|END /
* TEXT_MESSAGE_START|CONTENT|END / RUN_FINISHED(另有 RUN_ERROR 等异常事件)。
*/
/** AG-UI 协议消息(请求体里的 messages 数组元素)。 */
export interface AgUiMessage {
role: "user" | "assistant";
id: string;
content: string;
}
export interface SentimentAgentRunHandlers {
/** 推理(思考)文本开始:reasoning 消息流开始。 */
onReasoningStart?: (messageId: string) => void;
onReasoningDelta?: (messageId: string, delta: string) => void;
/** 正文回答开始:answer 消息流开始。 */
onAnswerStart?: (messageId: string) => void;
onAnswerDelta?: (messageId: string, delta: string) => void;
}
function textOfMessage(message: Message): string {
if (typeof message.content === "string") return message.content;
if (!Array.isArray(message.content)) return "";
return message.content
.map((part) => {
if (typeof part === "string") return part;
if (!part || typeof part !== "object") return "";
const text = (part as { text?: unknown }).text;
return typeof text === "string" ? text : "";
})
.join("");
}
/** 把本地会话消息转成 AG-UI 请求的历史消息(过滤空内容)。 */
export function toAgUiMessages(messages: Message[]): AgUiMessage[] {
return messages
.filter((message) => message.type === "human" || message.type === "ai")
.map((message) => ({
role: message.type === "human" ? ("user" as const) : ("assistant" as const),
id: message.id ?? "",
content: textOfMessage(message),
}))
.filter((message) => message.content.trim().length > 0);
}
type SseEvent = {
event?: string;
data: string;
};
/** 按 SSE 规范切帧:事件以空行分隔,`data:` 行可能跨多行、帧可能跨 chunk。 */
function parseSseFrames(buffer: string): { frames: SseEvent[]; rest: string } {
const normalized = buffer.replace(/\r\n/g, "\n");
const parts = normalized.split("\n\n");
const rest = parts.pop() ?? "";
const frames: SseEvent[] = [];
for (const part of parts) {
if (!part.trim()) continue;
const dataLines: string[] = [];
let eventName: string | undefined;
for (const line of part.split("\n")) {
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).replace(/^ /, ""));
} else if (line.startsWith("event:")) {
eventName = line.slice(6).replace(/^ /, "");
}
}
if (dataLines.length > 0) {
frames.push({ event: eventName, data: dataLines.join("\n") });
}
}
return { frames, rest };
}
export interface RunSentimentAgentOptions {
threadId: string;
runId: string;
messages: AgUiMessage[];
signal: AbortSignal;
handlers: SentimentAgentRunHandlers;
}
/**
* 发起一次舆情分析流式问答。resolve 于流正常结束(RUN_FINISHED 或连接关闭);
* 网络错误 / 非 2xx / RUN_ERROR 事件都会 reject(AbortError 由调用方自行忽略)。
*/
export async function runSentimentAgent(
options: RunSentimentAgentOptions,
): Promise<void> {
const { threadId, runId, messages, signal, handlers } = options;
const { apiUrl } = readSentimentAgentEndpoint();
if (!apiUrl) {
throw new Error("未配置舆情分析接口地址(runtime-config.js 的 VITE_SENTIMENT_AGENT_API_URL)。");
}
const response = await fetch(apiUrl, {
method: "POST",
headers: buildSentimentAgentHeaders(),
signal,
body: JSON.stringify({
threadId,
runId,
state: {},
tools: [],
context: [],
forwardedProps: {},
messages,
}),
});
if (!response.ok) {
throw new Error(`舆情分析服务请求失败(HTTP ${response.status})。`);
}
if (!response.body) {
throw new Error("舆情分析服务未返回流式响应。");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const dispatch = (payload: Record<string, unknown>) => {
// 样例里思考事件使用 message_id,而正文事件使用 messageId;两种 AG-UI
// 字段形式都兼容,避免后端升级/不同事件类型造成流式状态断开。
const messageId = String(payload.message_id ?? payload.messageId ?? "");
const delta = typeof payload.delta === "string" ? payload.delta : "";
switch (payload.type) {
case "THINKING_TEXT_MESSAGE_START":
handlers.onReasoningStart?.(messageId);
break;
case "THINKING_TEXT_MESSAGE_CONTENT":
handlers.onReasoningDelta?.(messageId, delta);
break;
case "TEXT_MESSAGE_START":
handlers.onAnswerStart?.(messageId);
break;
case "TEXT_MESSAGE_CONTENT":
handlers.onAnswerDelta?.(messageId, delta);
break;
case "RUN_ERROR": {
const message =
typeof payload.message === "string" && payload.message
? payload.message
: "舆情分析服务返回错误。";
throw new Error(message);
}
default:
// RUN_STARTED / *_END / RUN_FINISHED / TOOL_* 等事件不需要驱动 UI。
break;
}
};
const consumeFrames = (frames: SseEvent[]) => {
for (const frame of frames) {
let payload: Record<string, unknown>;
try {
payload = JSON.parse(frame.data) as Record<string, unknown>;
} catch {
continue; // 解析不了的脏帧直接跳过,不断流。
}
// RUN_ERROR 会在 dispatch 里抛出业务错误,正常往外传播。
dispatch(payload);
}
};
while (true) {
const { done, value } = await reader.read();
if (done) {
// 某些网关会在最后一帧后直接关闭连接而不补 SSE 的空行分隔符。流已
// 结束时剩余缓冲区就是完整帧,补上分隔符后仍要消费,避免丢最后几个字。
buffer += decoder.decode();
if (buffer.trim()) {
const { frames } = parseSseFrames(`${buffer}\n\n`);
consumeFrames(frames);
}
break;
}
buffer += decoder.decode(value, { stream: true });
const { frames, rest } = parseSseFrames(buffer);
buffer = rest;
consumeFrames(frames);
}
}
@@ -1,59 +0,0 @@
import { readRuntimeEnv } from "@/env";
/**
* 舆情分析智能体(前端直连的虚拟智能体)的接入配置。
* 接口地址与 Basic Auth 凭据都来自 public/runtime-config.js,部署后可直接改
* dist/runtime-config.js 切换环境,无需重新打包。
*/
/** 该虚拟智能体在路由里的固定 id(/agents/{id}/chats/...)。 */
export const SENTIMENT_AGENT_ID = "sentiment-analysis";
export interface SentimentAgentEndpoint {
apiUrl: string;
authUsername: string;
authPassword: string;
}
export function readSentimentAgentEndpoint(): SentimentAgentEndpoint {
return {
apiUrl: readRuntimeEnv("VITE_SENTIMENT_AGENT_API_URL") ?? "",
authUsername: readRuntimeEnv("VITE_SENTIMENT_AGENT_AUTH_USERNAME") ?? "",
authPassword: readRuntimeEnv("VITE_SENTIMENT_AGENT_AUTH_PASSWORD") ?? "",
};
}
export function isSentimentAgentConfigured(): boolean {
return !!readSentimentAgentEndpoint().apiUrl;
}
/**
* 是否开放舆情分析虚拟智能体。未配置时保持展示,避免旧部署升级后入口意外消失;
* 只有运行时配置显式写为 "false" 才关闭。
*/
export function isSentimentAgentVisible(): boolean {
return readRuntimeEnv("VITE_SENTIMENT_AGENT_VISIBLE") !== "false";
}
function toBase64(value: string): string {
// 凭据按 UTF-8 字节做 Basic Auth base64,避免非 ASCII 字符导致 btoa 抛错。
const bytes = new TextEncoder().encode(value);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
/** 组装请求头:Basic Auth(用户名/密码都配置了才带 Authorization)。 */
export function buildSentimentAgentHeaders(): Record<string, string> {
const { authUsername, authPassword } = readSentimentAgentEndpoint();
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "text/event-stream",
};
if (authUsername && authPassword) {
headers.Authorization = `Basic ${toBase64(`${authUsername}:${authPassword}`)}`;
}
return headers;
}
@@ -1,55 +0,0 @@
/**
* 舆情分析智能体的展示信息(名称 / 简介)。
* 这是一个前端虚拟智能体(不落库后端 /api/agents),名称与简介保存在
* localStorage,可在智能体管理页的编辑弹窗里随时修改。
*/
export interface SentimentAgentProfile {
name: string;
description: string;
}
const STORAGE_KEY = "deerflow.sentiment-agent.profile";
/** 名称 / 简介 未自定义时的默认文案。 */
export const DEFAULT_SENTIMENT_AGENT_PROFILE: SentimentAgentProfile = {
name: "舆情分析",
description:
"面向舆情领域的态势问答助手:围绕指定话题或事件,自动梳理舆情脉络、研判发展态势、归纳各方观点与热点走向,输出结构化的分析结论,为舆情应对与决策提供参考。",
};
export function readSentimentAgentProfile(): SentimentAgentProfile {
if (typeof window === "undefined") return { ...DEFAULT_SENTIMENT_AGENT_PROFILE };
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_SENTIMENT_AGENT_PROFILE };
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") {
return { ...DEFAULT_SENTIMENT_AGENT_PROFILE };
}
const record = parsed as Record<string, unknown>;
const name =
typeof record.name === "string" && record.name.trim()
? record.name.trim()
: DEFAULT_SENTIMENT_AGENT_PROFILE.name;
const description =
typeof record.description === "string"
? record.description.trim()
: DEFAULT_SENTIMENT_AGENT_PROFILE.description;
return { name, description };
} catch {
return { ...DEFAULT_SENTIMENT_AGENT_PROFILE };
}
}
export function saveSentimentAgentProfile(profile: SentimentAgentProfile): void {
if (typeof window === "undefined") return;
const name = profile.name.trim() || DEFAULT_SENTIMENT_AGENT_PROFILE.name;
const description =
profile.description.trim() || DEFAULT_SENTIMENT_AGENT_PROFILE.description;
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ name, description } satisfies SentimentAgentProfile),
);
// 编辑弹窗可能挂在管理页 / 对话页两处,广播一下让同页签的其他挂载点即时刷新。
window.dispatchEvent(new CustomEvent("sentiment-agent-profile-changed"));
}
@@ -1,187 +0,0 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { runSentimentAgent, toAgUiMessages } from "./api";
const STORAGE_PREFIX = "workspace.sentiment-agent.";
/**
* 舆情分析智能体的前端会话状态:消息只存在当前标签页的 sessionStorage,
* 不创建后端 thread、不进 LangGraph —— 所有问答都直连外部 AG-UI 网关。
* 整体结构参照 use-single-round-rag 的「合成 LangGraph 消息 + 本地持久化」模式。
*/
export function useSentimentAgentChat(threadId: string) {
const [messages, setMessages] = useState<Message[]>(() =>
readStoredMessages(threadId),
);
const messagesRef = useRef(messages);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
const stored = readStoredMessages(threadId);
messagesRef.current = stored;
setMessages(stored);
setError(null);
}, [threadId]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
// 卸载 / 切会话时中断进行中的流。
useEffect(() => () => controllerRef.current?.abort(), []);
const stop = useCallback(() => controllerRef.current?.abort(), []);
const commitMessages = useCallback(
(updater: (current: Message[]) => Message[]) => {
setMessages((current) => {
const next = updater(current);
messagesRef.current = next;
writeStoredMessages(threadId, next);
return next;
});
},
[threadId],
);
const submit = useCallback(
async ({ query }: { query: string }) => {
if (isLoading) return;
const question = query.trim();
if (!question) return;
const assistantId = `sentiment-ai-${crypto.randomUUID()}`;
const userMessage: Message = {
id: `sentiment-human-${crypto.randomUUID()}`,
type: "human",
content: [{ type: "text", text: question }],
additional_kwargs: {
client_ts: new Date().toISOString(),
sentiment_agent: true,
},
};
const initialAssistant: Message = {
id: assistantId,
type: "ai",
content: "",
additional_kwargs: { sentiment_agent: true },
};
// AG-UI 历史 = 之前已完成的轮次 + 本轮提问。
const history = [
...toAgUiMessages(messagesRef.current),
{ role: "user" as const, id: userMessage.id!, content: question },
];
commitMessages((current) => [...current, userMessage, initialAssistant]);
setError(null);
setIsLoading(true);
const controller = new AbortController();
controllerRef.current = controller;
// 思考 / 正文两个缓冲区 + rAF 合帧刷新:SSE 一轮几千个 delta,
// 直接逐条 setState 会造成大量无效渲染。
let reasoning = "";
let answer = "";
let flushScheduled = false;
const flush = () => {
flushScheduled = false;
commitMessages((current) =>
current.map((message) =>
message.id === assistantId
? ({
...message,
content: answer,
additional_kwargs: {
...message.additional_kwargs,
sentiment_agent: true,
reasoning_content: reasoning || null,
},
} as Message)
: message,
),
);
};
const scheduleFlush = () => {
if (flushScheduled) return;
flushScheduled = true;
requestAnimationFrame(flush);
};
try {
await runSentimentAgent({
threadId,
runId: `run-${crypto.randomUUID()}`,
messages: history,
signal: controller.signal,
handlers: {
onReasoningDelta: (_messageId, delta) => {
reasoning += delta;
scheduleFlush();
},
onAnswerDelta: (_messageId, delta) => {
answer += delta;
scheduleFlush();
},
},
});
flush();
} catch (caught) {
flush();
if (!isAbortError(caught)) {
const message =
caught instanceof Error ? caught.message : "舆情分析问答失败。";
setError(message);
// 一个字都没回答出来时,把错误写进气泡,用户能看到失败原因。
if (!answer) {
commitMessages((current) =>
current.map((item) =>
item.id === assistantId
? ({ ...item, content: message } as Message)
: item,
),
);
}
}
} finally {
if (controllerRef.current === controller) controllerRef.current = null;
setIsLoading(false);
}
},
[commitMessages, isLoading, threadId],
);
return { messages, isLoading, error, submit, stop };
}
function storageKey(threadId: string) {
return `${STORAGE_PREFIX}${threadId}`;
}
function readStoredMessages(threadId: string): Message[] {
if (typeof window === "undefined" || !threadId) return [];
try {
const raw = window.sessionStorage.getItem(storageKey(threadId));
const parsed: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? (parsed as Message[]) : [];
} catch {
return [];
}
}
function writeStoredMessages(threadId: string, messages: Message[]) {
if (typeof window === "undefined" || !threadId) return;
try {
window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages));
} catch {
// 配额异常不能打断进行中的回答。
}
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
@@ -71,12 +71,6 @@ test("keeps half-width syntax working", () => {
assert.equal(out.includes(citation(3)), true);
});
test("converts double-bracket RAG references", () => {
const out = applyInlineCitations("RAG answer [[1]] and [[2]].", makeSources(2));
assert.equal(out.includes(citation(1)), true);
assert.equal(out.includes(citation(2)), true);
});
test("accepts spaced and full-width square brackets", () => {
const out = applyInlineCitations("参考[ 1 ]和[2]。", makeSources(2));
assert.equal(out.includes(citation(1)), true);
+7 -31
View File
@@ -402,26 +402,15 @@ export async function getSkillContent(
return response.json() as Promise<SkillContentResponse>;
}
export async function deleteCustomSkill(skillName: string): Promise<void> {
export async function deleteCustomSkill(skillName: string): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
{ method: "DELETE" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete skill");
}
}
/** Delete a built-in skill. The backend permits this only for administrators. */
export async function deleteBuiltinSkill(skillName: string): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
{ method: "DELETE" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete built-in skill");
}
}
}
}
export interface SkillFileNode {
name: string;
@@ -490,7 +479,7 @@ export function getSkillFileDownloadURL(skillName: string, path: string): string
);
}
export async function saveSkillFile(
export async function saveSkillFile(
skillName: string,
path: string,
content: string,
@@ -505,22 +494,9 @@ export async function saveSkillFile(
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to save skill file");
}
return response.json() as Promise<SkillFileSaveResponse>;
}
export async function deleteSkillFile(
skillName: string,
path: string,
): Promise<void> {
const url =
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content` +
`?path=${encodeURIComponent(path)}`;
const response = await apiFetch(url, { method: "DELETE" });
if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete skill file");
}
}
}
return response.json() as Promise<SkillFileSaveResponse>;
}
export interface SkillNameZhResponse {
name: string;
+5 -35
View File
@@ -5,10 +5,8 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import {
deleteBuiltinSkill,
deleteCustomSkill,
deleteSkillFile,
import {
deleteCustomSkill,
duplicateSkill,
enableSkill,
generateMissingSkillNameZh,
@@ -320,7 +318,7 @@ export function useSkillFileContent(skillName: string | null, path: string | nul
});
}
export function useSaveSkillFile() {
export function useSaveSkillFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
@@ -341,33 +339,5 @@ export function useSaveSkillFile() {
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
}
},
});
}
export function useDeleteSkillFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skillName,
path,
}: {
skillName: string;
path: string;
}) => deleteSkillFile(skillName, path),
onSuccess: (_data, { skillName, path }) => {
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
void queryClient.removeQueries({ queryKey: ["skill-file", skillName, path] });
},
});
}
export function useDeleteBuiltinSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteBuiltinSkill,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["admin-skills"] });
},
});
}
});
}
+49 -131
View File
@@ -25,13 +25,10 @@ import { promptInputFilePartToFile, uploadFiles } from "../uploads";
import type { AgentThread, AgentThreadState } from "./types";
export type ToolEndEvent = {
name: string;
data: unknown;
toolCallId?: string;
};
export type ToolStartEvent = ToolEndEvent;
export type ToolEndEvent = {
name: string;
data: unknown;
};
export type ThreadStreamOptions = {
threadId?: string | null | undefined;
@@ -50,11 +47,10 @@ export type ThreadStreamOptions = {
/** 额外写到新建 thread 的 metadata(与 agent_id 合并)。
* 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */
threadMetadata?: Record<string, unknown>;
onStart?: (threadId: string) => void;
onFinish?: (state: AgentThreadState) => void;
onToolStart?: (event: ToolStartEvent) => void;
onToolEnd?: (event: ToolEndEvent) => void;
};
onStart?: (threadId: string) => void;
onFinish?: (state: AgentThreadState) => void;
onToolEnd?: (event: ToolEndEvent) => void;
};
type SendMessageOptions = {
additionalKwargs?: Record<string, unknown>;
@@ -244,11 +240,6 @@ const TERMINAL_RUN_STATUSES = new Set([
// for a while, then reconcile it from the authoritative run/state endpoints.
const STREAM_RECONCILIATION_IDLE_MS = 12_000;
const STREAM_RECONCILIATION_POLL_MS = 5_000;
// A run can legitimately be quiet while a long tool call is in progress, so
// reconnect more conservatively than the terminal-status check above. The
// reconnection only replaces the local SSE reader; the backend run keeps going.
const STREAM_RECONNECT_IDLE_MS = 30_000;
const STREAM_RECONNECT_MAX_ATTEMPTS = 3;
async function fetchThreadTitleRefreshState(
threadId: string,
@@ -456,11 +447,10 @@ export function useThreadStream({
assistantId,
runOptions,
threadMetadata,
onStart,
onFinish,
onToolStart,
onToolEnd,
}: ThreadStreamOptions) {
onStart,
onFinish,
onToolEnd,
}: ThreadStreamOptions) {
const { t } = useI18n();
const [localSettings] = useLocalSettings();
// Track the thread ID that is currently streaming to handle thread changes during streaming
@@ -471,28 +461,21 @@ export function useThreadStream({
const startedRef = useRef(false);
const lastStreamActivityAtRef = useRef(0);
const streamReconciliationInFlightRef = useRef(false);
const streamReconnectAttemptsRef = useRef(0);
const streamReconnectToastShownRef = useRef(false);
const streamReconnectFailureToastShownRef = useRef(false);
const markStreamActivity = useCallback(() => {
lastStreamActivityAtRef.current = Date.now();
streamReconnectAttemptsRef.current = 0;
streamReconnectToastShownRef.current = false;
streamReconnectFailureToastShownRef.current = false;
}, []);
const listeners = useRef({
onStart,
onFinish,
onToolStart,
onToolEnd,
});
const listeners = useRef({
onStart,
onFinish,
onToolEnd,
});
// Keep listeners ref updated with latest callbacks
useEffect(() => {
listeners.current = { onStart, onFinish, onToolStart, onToolEnd };
}, [onStart, onFinish, onToolStart, onToolEnd]);
useEffect(() => {
listeners.current = { onStart, onFinish, onToolEnd };
}, [onStart, onFinish, onToolEnd]);
useEffect(() => {
const normalizedThreadId = threadId ?? null;
@@ -562,6 +545,12 @@ export function useThreadStream({
},
onLangChainEvent(event) {
markStreamActivity();
if (event.event === "on_tool_end") {
listeners.current.onToolEnd?.({
name: event.name,
data: event.data,
});
}
// 操作日志(Y-Log):每一次大模型调用结束都各上报一条(一次 LLM 调用 = 一条),
// 让问答日志尽量「多、全」——一个问答轮次里 lead agent / 子代理的每次模型调用
// 都会落一条。门控与失败静默都在 writeYLog 内部处理,绝不影响问答。
@@ -583,32 +572,8 @@ export function useThreadStream({
} catch {
/* 上报绝不影响问答 */
}
}
}
},
// The SDK emits UI tool lifecycle events on the dedicated `tools` stream
// mode. `events` is a separate LangChain trace channel; using it for UI
// reactions can miss a tool that the conversation has already rendered.
onToolEvent:
onToolStart || onToolEnd
? (event) => {
markStreamActivity();
if (event.event === "on_tool_start") {
listeners.current.onToolStart?.({
name: event.name,
data: event.input,
toolCallId: event.toolCallId,
});
return;
}
if (event.event === "on_tool_end") {
listeners.current.onToolEnd?.({
name: event.name,
data: event.output,
toolCallId: event.toolCallId,
});
}
}
: undefined,
onUpdateEvent(data) {
markStreamActivity();
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
@@ -772,6 +737,13 @@ export function useThreadStream({
threadStreamRef.current = thread;
useEffect(() => {
if (!thread.isLoading) {
// A normal terminal event reached the browser. A later run may use the
// same thread, so it must be eligible for recovery again.
streamReconciliationInFlightRef.current = false;
return;
}
const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
if (!activeThreadId || typeof window === "undefined") {
return;
@@ -780,7 +752,7 @@ export function useThreadStream({
let disposed = false;
let refreshTimer: number | null = null;
const reconcileRun = async () => {
const reconcileCompletedRun = async () => {
if (
disposed ||
streamReconciliationInFlightRef.current ||
@@ -797,51 +769,7 @@ export function useThreadStream({
try {
const client = getAPIClient(isMock);
const run = await client.runs.get(activeThreadId, runId);
if (disposed) {
return;
}
if (!TERMINAL_RUN_STATUSES.has(run.status)) {
// ``useStream`` retries one failed connection on its own. If that
// reader quietly stalls, however, its internal queue never gets a
// terminal error and the page remains frozen despite the backend
// continuing the run. Abort only the local reader, then join the
// same run so no work or tokens are lost server-side.
if (Date.now() - lastStreamActivityAtRef.current < STREAM_RECONNECT_IDLE_MS) {
return;
}
if (streamReconnectAttemptsRef.current >= STREAM_RECONNECT_MAX_ATTEMPTS) {
if (!streamReconnectFailureToastShownRef.current) {
streamReconnectFailureToastShownRef.current = true;
toast.error("回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果。");
}
return;
}
streamReconnectAttemptsRef.current += 1;
// Avoid enqueueing another reconnect before this new reader has had
// enough time to receive its first event. A genuine event resets
// the attempt counter through ``markStreamActivity``.
lastStreamActivityAtRef.current = Date.now();
if (!streamReconnectToastShownRef.current) {
streamReconnectToastShownRef.current = true;
toast.info("回答连接出现波动,正在自动恢复…");
}
const stream = threadStreamRef.current;
void Promise.resolve(stream.stop())
.catch(() => {
// Stopping a stale local reader is best-effort; joining is still
// useful if the SDK already considers the stream stopped.
})
.finally(() => {
if (disposed || threadIdRef.current !== activeThreadId) return;
void stream.joinStream(runId).catch(() => {
// The status poll will either reconcile a terminal run or
// schedule the next bounded reconnect attempt.
});
});
if (disposed || !TERMINAL_RUN_STATUSES.has(run.status)) {
return;
}
@@ -849,17 +777,13 @@ export function useThreadStream({
// receive any more useful events. Load the checkpoint directly before
// resetting the SDK stream; this preserves the normal onFinish side
// effects (e.g. marking a position delivery complete) even when the
// browser missed the terminal event or already reported a transport
// error and dropped ``isLoading``.
// browser missed the terminal event.
const finalState = await client.threads.getState(activeThreadId);
if (disposed) {
if (disposed || !threadStreamRef.current.isLoading) {
return;
}
storage?.removeItem(`lg:stream:${activeThreadId}`);
streamReconnectAttemptsRef.current = 0;
streamReconnectToastShownRef.current = false;
streamReconnectFailureToastShownRef.current = false;
if (run.status === "success") {
listeners.current.onFinish?.(finalState.values as AgentThreadState);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
@@ -888,7 +812,7 @@ export function useThreadStream({
};
const interval = window.setInterval(
() => void reconcileRun(),
() => void reconcileCompletedRun(),
STREAM_RECONCILIATION_POLL_MS,
);
@@ -1198,26 +1122,20 @@ export function useThreadStream({
}),
);
const mergedContext = {
...context,
...extraContext,
};
const selectedLlmWikiIds = Array.isArray(
mergedContext.llmwiki_knowledge_base_ids,
)
? mergedContext.llmwiki_knowledge_base_ids
: [];
const ragModeEnabled =
localSettings.display.ragModeEnabled ||
isThreadRagModeLocked(threadId) ||
selectedLlmWikiIds.length > 0;
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
const ragModeEnabled =
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
const contextCompressionEnabled =
localSettings.display.contextCompressionEnabled ?? false;
const mergedContext = {
...context,
...extraContext,
};
const hiddenContextMessages = normalizeHiddenContextMessages(options);
const submittedMessages: Message[] = [
...hiddenContextMessages.map((item) => ({
+3 -7
View File
@@ -23,13 +23,9 @@ export interface AgentThreadContext extends Record<string, unknown> {
rag_mode_enabled?: boolean;
/** Per-request switch for context compression (上下文压缩). Default off. */
summarization_enabled?: boolean;
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
knowledge_rag_enabled?: boolean;
/** DeerFlow LLMWiki mapping ids selected for this conversation. */
llmwiki_knowledge_base_ids?: string[];
/** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */
execution_profile?: "single_round_rag" | "multi_round_rag";
agent_id?: string;
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
knowledge_rag_enabled?: boolean;
agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string;
}
@@ -1,483 +0,0 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { readRuntimeEnv } from "@/env";
const STORAGE_PREFIX = "workspace.multi-round-rag.";
export type MultiRoundRagRetrieval = {
id?: string;
title: string;
content: string;
source?: string;
time?: string;
recUuid?: string;
url?: string;
};
export type MultiRoundRagRound = {
id: string;
title: string;
thought: string;
/** Model text emitted for this round, before its retrieval results. */
body: string;
query: string;
isSearching: boolean;
retrievals: MultiRoundRagRetrieval[];
};
export type MultiRoundRagTrace = {
rounds: MultiRoundRagRound[];
answer: string;
isComplete: boolean;
};
type MultiRoundRagSource = {
id?: string;
title: string;
type: string;
snippet: string;
content: string;
source?: string;
time?: string;
url?: string;
raw: Record<string, unknown>;
};
type MultiRoundEvent = {
event?: unknown;
data?: unknown;
};
function storageKey(threadId: string) {
return `${STORAGE_PREFIX}${threadId}`;
}
function readStoredMessages(threadId: string): Message[] {
if (typeof window === "undefined" || !threadId) return [];
try {
const raw = window.sessionStorage.getItem(storageKey(threadId));
const parsed: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? (parsed as Message[]) : [];
} catch {
return [];
}
}
function writeStoredMessages(threadId: string, messages: Message[]) {
if (typeof window === "undefined" || !threadId) return;
try {
window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages));
} catch {
// Storage quota failures must not interrupt a running answer.
}
}
function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`;
}
function isAbortError(error: unknown) {
return error instanceof DOMException && error.name === "AbortError";
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function parseJson(value: unknown): unknown {
if (typeof value !== "string") return value;
try {
return JSON.parse(value) as unknown;
} catch {
return value;
}
}
function parseEventLine(line: string): MultiRoundEvent | null {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) return null;
try {
return JSON.parse(trimmed.slice("data:".length).trim()) as MultiRoundEvent;
} catch {
return null;
}
}
function initialTrace(): MultiRoundRagTrace {
return {
rounds: [
{
id: createId("multi-rag-round"),
title: "正在准备检索",
thought: "",
body: "",
query: "",
isSearching: true,
retrievals: [],
},
],
answer: "",
isComplete: false,
};
}
function latestRound(trace: MultiRoundRagTrace): MultiRoundRagRound {
const current = trace.rounds.at(-1);
if (current) return current;
const next = initialTrace().rounds[0]!;
trace.rounds.push(next);
return next;
}
function addThinkingRound(trace: MultiRoundRagTrace, thought: string) {
const current = latestRound(trace);
if (current.title === "正在准备检索" && !current.thought && !current.query) {
current.title = "思考过程";
current.thought = thought;
return;
}
current.isSearching = false;
trace.rounds.push({
id: createId("multi-rag-round"),
title: "思考过程",
thought,
body: "",
query: "",
isSearching: true,
retrievals: [],
});
}
function appendRoundBody(trace: MultiRoundRagTrace, body: string) {
if (!body) return;
const current = latestRound(trace);
// Stored traces created before `body` was introduced remain readable.
current.body = `${current.body ?? ""}${body}`;
}
function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] {
const root = asRecord(parseJson(value));
const list =
(Array.isArray(root?.results) && root.results) ||
(Array.isArray(root?.organic) && root.organic) ||
[];
return list.flatMap((item, index) => {
const record = asRecord(item);
if (!record) return [];
const recUuid = asString(record.recUuid ?? record.rec_uuid);
const url = asString(record.url ?? record.link ?? record.href);
return [
{
id: asString(record.id) || recUuid || undefined,
title: asString(record.title) || `检索资料 ${index + 1}`,
content: asString(record.content ?? record.snippet ?? record.text),
source: asString(record.source ?? record.source1 ?? url) || undefined,
time: asString(record.publish_time ?? record.m_publish ?? record.date) || undefined,
recUuid: recUuid || undefined,
url: /^https?:\/\//i.test(url) ? url : undefined,
},
];
});
}
function mapReferences(value: unknown): MultiRoundRagSource[] {
const record = asRecord(value);
const list = Array.isArray(record?.references) ? record.references : [];
return list.flatMap((item, index) => {
const source = asRecord(item);
if (!source) return [];
const content = asString(source.content ?? source.snippet);
const rawUrl = asString(source.url ?? source.link ?? source.href);
const recUuid = asString(source.recUuid ?? source.rec_uuid ?? source.uuid);
return [
{
id: asString(source.id) || recUuid || undefined,
title: asString(source.title) || `参考资料 ${index + 1}`,
type: asString(source.type) || "参考资料",
snippet: asString(source.snippet) || content,
content,
source: asString(source.source ?? source.source1) || undefined,
time: asString(source.time ?? source.m_publish ?? source.publish_time) || undefined,
url: /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined,
raw: source,
},
];
});
}
function cloneTrace(trace: MultiRoundRagTrace): MultiRoundRagTrace {
return {
...trace,
rounds: trace.rounds.map((round) => ({
...round,
retrievals: [...round.retrievals],
})),
};
}
function isSingleRoundRagMessage(message: Message) {
return message.additional_kwargs?.single_round_rag === true;
}
export function isMultiRoundRagMessage(message: Message): boolean {
return message.additional_kwargs?.multi_round_rag === true;
}
export function getMultiRoundRagTrace(
message: Message,
): MultiRoundRagTrace | null {
const trace = message.additional_kwargs?.multi_round_rag_trace;
return asRecord(trace)
? (trace as unknown as MultiRoundRagTrace)
: null;
}
/**
* Frontend adapter for GeneralQA's multi-round endpoint. It keeps the external
* SSE protocol outside DeerFlow and stores only its UI messages in sessionStorage.
*/
export function useMultiRoundRagMessages(threadId: string) {
const [messages, setMessages] = useState<Message[]>(() => readStoredMessages(threadId));
const messagesRef = useRef(messages);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
const stored = readStoredMessages(threadId);
messagesRef.current = stored;
setMessages(stored);
setError(null);
}, [threadId]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
useEffect(() => () => controllerRef.current?.abort(), []);
const updateMessages = useCallback(
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
setMessages((current) => {
const next = updater(current);
messagesRef.current = next;
writeStoredMessages(targetThreadId, next);
return next;
});
},
[],
);
const stop = useCallback(() => controllerRef.current?.abort(), []);
const submit = useCallback(
async ({ targetThreadId, query }: { targetThreadId: string; query: string }) => {
if (isLoading) return;
const question = query.trim();
if (!question) return;
const humanId = createId("multi-rag-human");
const assistantId = createId("multi-rag-ai");
const sourceId = createId("multi-rag-sources");
const trace = initialTrace();
let sources: MultiRoundRagSource[] = [];
const clientTs = new Date().toISOString();
const userMessage: Message = {
id: humanId,
type: "human",
content: [{ type: "text", text: question }],
additional_kwargs: { client_ts: clientTs, multi_round_rag: true },
};
const assistantMessage: Message = {
id: assistantId,
type: "ai",
content: "",
additional_kwargs: {
multi_round_rag: true,
multi_round_rag_trace: trace,
},
};
updateMessages(targetThreadId, (current) => [
...current,
userMessage,
assistantMessage,
]);
setError(null);
setIsLoading(true);
const controller = new AbortController();
controllerRef.current = controller;
const refreshAssistant = () => {
const traceSnapshot = cloneTrace(trace);
updateMessages(targetThreadId, (current) => {
const next = current.filter((message) => message.id !== sourceId);
const assistantIndex = next.findIndex((message) => message.id === assistantId);
if (assistantIndex < 0) return current;
next[assistantIndex] = {
...next[assistantIndex],
content: traceSnapshot.answer,
additional_kwargs: {
...next[assistantIndex]!.additional_kwargs,
multi_round_rag: true,
multi_round_rag_trace: traceSnapshot,
reasoning_content: traceSnapshot.rounds
.map((round) => round.thought)
.filter(Boolean)
.join("\n\n") || null,
},
} as Message;
if (sources.length > 0) {
next.splice(assistantIndex, 0, {
id: sourceId,
type: "tool",
name: "multi_round_rag_references",
content: { results: sources },
additional_kwargs: {
hide_from_ui: true,
multi_round_rag: true,
},
} as unknown as Message);
}
return next;
});
};
try {
const baseUrl = readRuntimeEnv("VITE_AIPI_GENERATE_CHAT_URL") || "";
if (!baseUrl) {
throw new Error("未配置 VITE_AIPI_GENERATE_CHAT_URL,无法发起多轮 RAG 问答。");
}
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
body: JSON.stringify({ prompt: question }),
});
if (!response.ok) {
throw new Error(`多轮 RAG 服务请求失败(${response.status})。`);
}
if (!response.body) {
throw new Error("多轮 RAG 服务未返回流式响应。");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? "";
for (const line of lines) {
const event = parseEventLine(line);
if (!event) continue;
const eventType = asString(event.event);
const data = event.data;
if (eventType === "tool_call") {
const tool = asRecord(data);
const toolName = asString(tool?.tool_name);
const input = asRecord(tool?.tool_input) ?? {};
const current = latestRound(trace);
if (toolName === "show_text") {
const text = asString(input.text);
if (text.includes("<think>")) {
const thinkEnd = text.indexOf("</think>");
addThinkingRound(
trace,
(thinkEnd >= 0
? text.slice(0, thinkEnd + "</think>".length)
: text
)
.replace(/<\/?think>/g, "")
.trim(),
);
if (thinkEnd >= 0) {
const roundBody = text
.slice(thinkEnd + "</think>".length)
.trim();
if (roundBody) {
appendRoundBody(trace, roundBody);
}
}
} else {
// A non-thinking `show_text` immediately after a thought is
// this round's narrative; the terminal `message` event is
// still rendered only as the final answer below all rounds.
current.isSearching = false;
trace.answer += text;
trace.isComplete = true;
}
} else if (
toolName === "local_search" ||
toolName === "tavily_search" ||
toolName === "google_search"
) {
const queryData = asRecord(parseJson(input.result));
current.title =
toolName === "local_search" ? "本地知识检索" : "联网检索";
current.query =
asString(input.q ?? input.query) ||
asString(queryData?.query) ||
current.query;
const retrievals = mapRetrievals(input.result);
if (retrievals.length > 0) current.retrievals = retrievals;
current.isSearching = false;
} else if (toolName === "get_textid_content") {
const article = asRecord(parseJson(input.result));
const body =
asString(article?.content ?? article?.text) ||
asString(input.content);
if (body) appendRoundBody(trace, body);
current.isSearching = false;
}
refreshAssistant();
} else if (eventType === "message" && typeof data === "string") {
latestRound(trace).isSearching = false;
trace.answer += data;
trace.isComplete = true;
refreshAssistant();
} else if (eventType === "references") {
sources = mapReferences(data);
refreshAssistant();
} else if (eventType === "done") {
trace.isComplete = true;
for (const round of trace.rounds) round.isSearching = false;
refreshAssistant();
}
}
}
trace.isComplete = true;
for (const round of trace.rounds) round.isSearching = false;
refreshAssistant();
} catch (caught) {
trace.isComplete = true;
for (const round of trace.rounds) round.isSearching = false;
if (!isAbortError(caught)) {
const message =
caught instanceof Error ? caught.message : "多轮 RAG 问答失败。";
setError(message);
if (!trace.answer) trace.answer = message;
}
refreshAssistant();
} finally {
if (controllerRef.current === controller) controllerRef.current = null;
setIsLoading(false);
}
},
[isLoading, updateMessages],
);
return { messages, isLoading, error, submit, stop };
}
export function isFrontendRagMessage(message: Message): boolean {
return isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message);
}
@@ -1,432 +0,0 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { readRuntimeEnv } from "@/env";
const STORAGE_PREFIX = "workspace.single-round-rag.";
type KnowledgeBaseChunk = {
answer?: unknown;
docs?: unknown;
};
type RagSource = {
id?: string;
title: string;
type: string;
snippet: string;
content: string;
source?: string;
time?: string;
url?: string;
raw: Record<string, unknown>;
};
type RagHistoryEntry = {
role: "user" | "assistant";
content: string;
};
const MAX_HISTORY_MESSAGES = 12;
/** Same JSON-object framing used by the established GeneralQA single-RAG flow. */
function extractJsonObjects(raw: string): { objects: string[]; rest: string } {
const objects: string[] = [];
let depth = 0;
let stringQuote: '"' | "'" | null = null;
let escaped = false;
let start = -1;
for (let index = 0; index < raw.length; index += 1) {
const char = raw[index]!;
if (stringQuote) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === stringQuote) stringQuote = null;
continue;
}
if (char === '"' || char === "'") {
stringQuote = char;
continue;
}
if (char === "{") {
if (depth === 0) start = index;
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0 && start !== -1) {
objects.push(raw.slice(start, index + 1));
start = -1;
}
}
}
return depth > 0 && start !== -1
? { objects, rest: raw.slice(start) }
: { objects, rest: "" };
}
/** Same `<think>` split semantics as GeneralQA, kept local to avoid loading its demo data. */
function parseSingleRagStream(chunks: { answer: string }[]) {
let thinkingMarkdown = "";
let answerMarkdown = "";
let inThink = false;
let thinkingComplete = false;
for (const { answer } of chunks) {
let remaining = answer;
while (remaining) {
if (inThink) {
const endIndex = remaining.indexOf("</think>");
if (endIndex === -1) {
thinkingMarkdown += remaining;
remaining = "";
} else {
thinkingMarkdown += remaining.slice(0, endIndex);
remaining = remaining.slice(endIndex + "</think>".length);
inThink = false;
thinkingComplete = true;
}
} else {
const startIndex = remaining.indexOf("<think>");
if (startIndex === -1) {
answerMarkdown += remaining;
remaining = "";
} else {
answerMarkdown += remaining.slice(0, startIndex);
remaining = remaining.slice(startIndex + "<think>".length);
inThink = true;
}
}
}
}
return { thinkingMarkdown, answerMarkdown, thinkingComplete };
}
function storageKey(threadId: string) {
return `${STORAGE_PREFIX}${threadId}`;
}
function readStoredMessages(threadId: string): Message[] {
if (typeof window === "undefined" || !threadId) return [];
try {
const raw = window.sessionStorage.getItem(storageKey(threadId));
const parsed: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? (parsed as Message[]) : [];
} catch {
return [];
}
}
function writeStoredMessages(threadId: string, messages: Message[]) {
if (typeof window === "undefined" || !threadId) return;
try {
window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages));
} catch {
// Quota errors must never interrupt an already-running answer.
}
}
function parseLooseStringValue(rawValue: string): string {
try {
return JSON.parse(rawValue) as string;
} catch {
const value = rawValue.trim();
if (value.startsWith("'") && value.endsWith("'")) {
return value
.slice(1, -1)
.replace(/\\n/g, "\n")
.replace(/\\r/g, "\r")
.replace(/\\t/g, "\t")
.replace(/\\'/g, "'")
.replace(/\\\\/g, "\\");
}
throw new Error(`无法解析知识库流片段:${rawValue}`);
}
}
function parseKnowledgeBaseChunk(jsonText: string): KnowledgeBaseChunk {
try {
return JSON.parse(jsonText) as KnowledgeBaseChunk;
} catch {
const objectText = jsonText.trim();
const answerMatch = objectText.match(/^\{\s*answer\s*:\s*([\s\S]*)\s*\}$/);
if (answerMatch) {
return { answer: parseLooseStringValue(answerMatch[1]!.trim()) };
}
const docsMatch = objectText.match(/^\{\s*docs\s*:\s*([\s\S]*)\s*\}$/);
if (docsMatch) {
const rawValue = docsMatch[1]!.trim();
try {
return { docs: JSON.parse(rawValue) };
} catch {
return { docs: parseLooseStringValue(rawValue) };
}
}
const normalizedText = objectText.replace(
/([{,]\s*)(answer|docs)(\s*:)/g,
'$1"$2"$3',
);
return JSON.parse(normalizedText) as KnowledgeBaseChunk;
}
}
function flattenDocs(value: unknown): Record<string, unknown>[] {
if (Array.isArray(value)) {
return value.flatMap((item) => flattenDocs(item));
}
if (!value || typeof value !== "object") return [];
const record = value as Record<string, unknown>;
if ("docs" in record) return flattenDocs(record.docs);
return [record];
}
function normalizeSources(value: unknown): RagSource[] {
return flattenDocs(value).map((item, index) => {
const title = String(item.m_title ?? item.title ?? item.source ?? `参考资料 ${index + 1}`);
const content = String(item.m_content ?? item.content ?? item.snippet ?? "");
const source = item.source1 ?? item.source;
const rawUrl = item.url ?? item.link ?? item.href;
return {
id: String(item.recUuid ?? item.rec_uuid ?? item.uuid ?? item.id ?? "") || undefined,
title,
type: String(item.source1 ?? item.type ?? "知识库"),
snippet: content,
content,
source: typeof source === "string" ? source : undefined,
time: typeof item.m_publish === "string" ? item.m_publish : undefined,
url: typeof rawUrl === "string" && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined,
raw: item,
};
});
}
function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`;
}
function isAbortError(error: unknown) {
return error instanceof DOMException && error.name === "AbortError";
}
function textFromMessage(message: Message): string {
if (typeof message.content === "string") {
return message.content.trim();
}
if (!Array.isArray(message.content)) {
return "";
}
return message.content
.map((part) => {
if (typeof part === "string") return part;
if (!part || typeof part !== "object") return "";
const text = (part as { text?: unknown }).text;
return typeof text === "string" ? text : "";
})
.join("\n")
.trim();
}
/**
* The general-QA endpoint accepts the usual `{ role, content }` history
* format. Keep it scoped to completed single-round-RAG turns so a normal
* DeerFlow conversation never leaks into this external endpoint.
*/
function buildRagHistory(messages: Message[]): RagHistoryEntry[] {
return messages
.filter(
(message) =>
message.additional_kwargs?.single_round_rag === true &&
(message.type === "human" || message.type === "ai"),
)
.map((message): RagHistoryEntry => ({
role: message.type === "human" ? "user" : "assistant",
content: textFromMessage(message),
}))
.filter((entry) => entry.content.length > 0)
.slice(-MAX_HISTORY_MESSAGES);
}
export function isSingleRoundRagMessage(message: Message): boolean {
return message.additional_kwargs?.single_round_rag === true;
}
/**
* Frontend-only adapter for the existing general-QA single-round endpoint.
* It deliberately never submits a run to DeerFlow or mutates the Lead Agent.
*/
export function useSingleRoundRagMessages(threadId: string) {
const [messages, setMessages] = useState<Message[]>(() => readStoredMessages(threadId));
const messagesRef = useRef(messages);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
const stored = readStoredMessages(threadId);
messagesRef.current = stored;
setMessages(stored);
setError(null);
}, [threadId]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
useEffect(() => () => controllerRef.current?.abort(), []);
const updateMessages = useCallback(
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
setMessages((current) => {
const next = updater(current);
messagesRef.current = next;
writeStoredMessages(targetThreadId, next);
return next;
});
},
[],
);
const stop = useCallback(() => controllerRef.current?.abort(), []);
const submit = useCallback(
async ({
targetThreadId,
query,
modelName,
}: {
targetThreadId: string;
query: string;
modelName: string;
}) => {
if (isLoading) return;
const question = query.trim();
if (!question) return;
const humanId = createId("single-rag-human");
const assistantId = createId("single-rag-ai");
const sourceId = createId("single-rag-sources");
const clientTs = new Date().toISOString();
const history = buildRagHistory(messagesRef.current);
const userMessage: Message = {
id: humanId,
type: "human",
content: [{ type: "text", text: question }],
additional_kwargs: { client_ts: clientTs, single_round_rag: true },
};
const initialAssistant: Message = {
id: assistantId,
type: "ai",
content: "",
additional_kwargs: { single_round_rag: true },
};
updateMessages(targetThreadId, (current) => [...current, userMessage, initialAssistant]);
setError(null);
setIsLoading(true);
const controller = new AbortController();
controllerRef.current = controller;
const answerChunks: { answer: string }[] = [];
let sources: RagSource[] = [];
const refreshAssistant = () => {
const trace = parseSingleRagStream(answerChunks);
updateMessages(targetThreadId, (current) => {
const next = current.filter((message) => message.id !== sourceId);
const assistantIndex = next.findIndex((message) => message.id === assistantId);
if (assistantIndex < 0) return current;
next[assistantIndex] = {
...next[assistantIndex],
content: trace.answerMarkdown,
additional_kwargs: {
...next[assistantIndex]!.additional_kwargs,
single_round_rag: true,
reasoning_content: trace.thinkingMarkdown || null,
},
} as Message;
if (sources.length > 0) {
next.splice(assistantIndex, 0, {
id: sourceId,
type: "tool",
name: "knowledge_base_search",
content: { results: sources },
additional_kwargs: {
hide_from_ui: true,
single_round_rag: true,
},
} as unknown as Message);
}
return next;
});
};
try {
const chatUrl = readRuntimeEnv("VITE_AIPI_CHAT_URL") || "";
if (!chatUrl) {
throw new Error("未配置 VITE_AIPI_CHAT_URL,无法发起单轮 RAG 问答。");
}
const response = await fetch(
`${chatUrl.replace(/\/$/, "")}/chat/knowledge_base_chat_tiaoshi`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
body: JSON.stringify({
query: question,
knowledge_base_name: [],
top_k: 5,
score_threshold: 0.5,
history,
stream: true,
model_name: modelName,
temperature: 0.3,
prompt_name: "default",
local_doc_url: false,
}),
},
);
if (!response.ok) throw new Error(`知识库服务请求失败(${response.status})`);
if (!response.body) throw new Error("知识库服务未返回流式响应。");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const extracted = extractJsonObjects(buffer);
buffer = extracted.rest;
for (const jsonText of extracted.objects) {
const chunk = parseKnowledgeBaseChunk(jsonText);
if (typeof chunk.answer === "string") answerChunks.push({ answer: chunk.answer });
if (chunk.docs !== undefined) sources = normalizeSources(chunk.docs);
refreshAssistant();
}
}
} catch (caught) {
if (!isAbortError(caught)) {
const message = caught instanceof Error ? caught.message : "单轮 RAG 问答失败。";
setError(message);
updateMessages(targetThreadId, (current) =>
current.map((item) =>
item.id === assistantId
? ({
...item,
content: answerChunks.length > 0 ? item.content : message,
additional_kwargs: {
...item.additional_kwargs,
single_round_rag: true,
},
} as Message)
: item,
),
);
}
} finally {
if (controllerRef.current === controller) controllerRef.current = null;
setIsLoading(false);
}
},
[isLoading, updateMessages],
);
return { messages, isLoading, error, submit, stop };
}
+1 -286
View File
@@ -31,7 +31,7 @@ export type RewriteStreamEvent =
// ── SSE parser ────────────────────────────────────────────────────────────────
export async function* parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<{ event: string; data: string }> {
async function* parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<{ event: string; data: string }> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
@@ -106,288 +106,3 @@ export async function* streamRewrite(
}
}
}
export type DocumentRewriteStyle =
| "formal_analysis"
| "professional"
| "concise"
| "readable";
export interface DocumentRewriteRequest {
threadId: string;
path: string;
instruction: string;
style?: DocumentRewriteStyle;
modelName?: string;
expectedHash?: string;
}
export interface DocumentRewriteSummary {
sourceDisplayName: string;
instruction: string;
modelName: string;
originalCharCount: number;
rewrittenCharCount: number;
charChangeRatio: number;
changedBlockCount: number;
headingCountBefore: number;
headingCountAfter: number;
headingChanges?: {
added: string[];
removed: string[];
reordered: boolean;
levelWarnings: string[];
};
requestedOptimizations?: string[];
validationWarnings?: string[];
}
export type DocumentRewriteStreamEvent =
| { type: "job_started"; jobId: string; reused: boolean }
| { type: "snapshot_fixed"; fileName: string; originalCharCount: number; baseHash: string }
| { type: "requirements_started" }
/** Small live rewrite plan shown in the message-list requirements step. */
| { type: "requirements_delta"; delta: string }
/** Durable recovery point for a plan produced before an SSE reconnect. */
| { type: "requirements_checkpoint"; content: string }
| { type: "requirements_completed"; content: string; fallback: boolean }
| { type: "requirements_resolved"; instruction: string; style: string; modelName: string; plan?: string }
| { type: "thinking"; chunk: string }
| { type: "rewrite_delta"; delta: string; charCount: number }
/** Durable full-draft recovery point used after an SSE reconnect. */
| { type: "rewrite_checkpoint"; content: string; charCount: number }
| { type: "validation_started" }
| { type: "validation_completed"; passed: boolean; warnings: string[] }
| { type: "comparison_ready"; summary: DocumentRewriteSummary }
| { type: "commit_started" }
| { type: "committed"; content: string; committedHash: string; versionId?: string }
| { type: "done"; committedHash: string; versionId?: string }
| { type: "conflict"; message: string }
| { type: "cancelled"; message: string }
| { type: "error"; message: string; stage?: string };
export interface DurableDocumentRewriteJob {
id: string;
status: string;
phase: string;
progress: number;
createdAt?: string | null;
result: Record<string, unknown>;
request: {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
originalContent: string;
};
}
async function readDocumentRewriteEvent(
event: string,
data: string,
): Promise<DocumentRewriteStreamEvent | null> {
try {
const parsed = JSON.parse(data) as Record<string, unknown>;
switch (event) {
case "snapshot_fixed":
return {
type: "snapshot_fixed",
fileName: String(parsed.fileName ?? ""),
originalCharCount: Number(parsed.originalCharCount ?? 0),
baseHash: String(parsed.baseHash ?? ""),
};
case "requirements_resolved":
return {
type: "requirements_resolved",
instruction: String(parsed.instruction ?? ""),
style: String(parsed.style ?? ""),
modelName: String(parsed.modelName ?? ""),
plan: typeof parsed.plan === "string" ? parsed.plan : undefined,
};
case "requirements_started":
return { type: "requirements_started" };
case "requirements_delta":
return { type: "requirements_delta", delta: String(parsed.delta ?? "") };
case "requirements_checkpoint":
return { type: "requirements_checkpoint", content: String(parsed.content ?? "") };
case "requirements_completed":
return {
type: "requirements_completed",
content: String(parsed.content ?? ""),
fallback: parsed.fallback === true,
};
case "thinking":
return { type: "thinking", chunk: String(parsed.chunk ?? "") };
case "rewrite_delta":
return {
type: "rewrite_delta",
delta: String(parsed.delta ?? ""),
charCount: Number(parsed.charCount ?? 0),
};
case "rewrite_checkpoint":
return {
type: "rewrite_checkpoint",
content: String(parsed.content ?? ""),
charCount: Number(parsed.charCount ?? 0),
};
case "validation_started":
return { type: "validation_started" };
case "validation_completed":
return {
type: "validation_completed",
passed: parsed.passed === true,
warnings: Array.isArray(parsed.warnings)
? parsed.warnings.filter((item): item is string => typeof item === "string")
: [],
};
case "comparison_ready":
return { type: "comparison_ready", summary: parsed as unknown as DocumentRewriteSummary };
case "commit_started":
return { type: "commit_started" };
case "committed":
return {
type: "committed",
content: String(parsed.content ?? ""),
committedHash: String(parsed.committedHash ?? ""),
versionId: typeof parsed.versionId === "string" ? parsed.versionId : undefined,
};
case "done":
return {
type: "done",
committedHash: String(parsed.committedHash ?? ""),
versionId: typeof parsed.versionId === "string" ? parsed.versionId : undefined,
};
case "conflict":
return { type: "conflict", message: String(parsed.message ?? "文件内容已变化") };
case "cancelled":
return { type: "cancelled", message: String(parsed.message ?? "已停止全文改写") };
case "error":
return {
type: "error",
message: String(parsed.message ?? "全文改写失败"),
stage: typeof parsed.stage === "string" ? parsed.stage : undefined,
};
default:
return null;
}
} catch {
return null;
}
}
async function startDurableDocumentRewrite(
req: DocumentRewriteRequest,
signal?: AbortSignal,
): Promise<{ jobId: string; reused: boolean }> {
const response = await apiFetch(getBackendBaseURL() + "/api/writing/document-rewrite/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
signal,
});
const body = await response.json().catch(() => ({})) as Record<string, unknown>;
if (!response.ok || typeof body.jobId !== "string") {
throw new Error(
(body.detail as string | undefined) ??
(body.message as string | undefined) ??
`HTTP ${response.status}`,
);
}
return { jobId: body.jobId, reused: body.reused === true };
}
export async function* streamDurableDocumentRewriteJob(
req: Pick<DocumentRewriteRequest, "threadId" | "path">,
jobId: string,
signal?: AbortSignal,
after = 0,
): AsyncGenerator<DocumentRewriteStreamEvent> {
const query = new URLSearchParams({
thread_id: req.threadId,
path: req.path,
after: String(after),
});
const response = await apiFetch(
`${getBackendBaseURL()}/api/writing/document-rewrite/jobs/${encodeURIComponent(jobId)}/stream?${query.toString()}`,
{ method: "GET", signal },
);
if (!response.ok || !response.body) {
const body = await response.json().catch(() => ({})) as Record<string, unknown>;
throw new Error((body.detail as string | undefined) ?? `HTTP ${response.status}`);
}
for await (const { event, data } of parseSse(response.body)) {
if (signal?.aborted) return;
const parsed = await readDocumentRewriteEvent(event, data);
if (!parsed) continue;
yield parsed;
if (["done", "conflict", "cancelled", "error"].includes(parsed.type)) return;
}
}
export async function getLatestDurableDocumentRewrite(
req: Pick<DocumentRewriteRequest, "threadId" | "path">,
): Promise<DurableDocumentRewriteJob | null> {
const jobs = await listDurableDocumentRewrites(req, 1);
return jobs[0] ?? null;
}
export async function listDurableDocumentRewrites(
req: Pick<DocumentRewriteRequest, "threadId" | "path">,
limit = 20,
): Promise<DurableDocumentRewriteJob[]> {
const query = new URLSearchParams({
thread_id: req.threadId,
path: req.path,
limit: String(Math.min(Math.max(limit, 1), 20)),
});
const response = await apiFetch(
`${getBackendBaseURL()}/api/writing/document-rewrite/jobs?${query.toString()}`,
{ method: "GET" },
);
const body = await response.json().catch(() => ({})) as Record<string, unknown>;
if (!response.ok) throw new Error((body.detail as string | undefined) ?? `HTTP ${response.status}`);
const rows = Array.isArray(body.items) ? body.items : [];
return rows.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const job = item as Record<string, unknown>;
if (typeof job.id !== "string") return [];
const request = (job.request ?? {}) as Record<string, unknown>;
return [{
id: job.id,
status: String(job.status ?? "queued"),
phase: String(job.phase ?? "initializing"),
progress: Number(job.progress ?? 0),
createdAt: typeof job.createdAt === "string" ? job.createdAt : null,
result: (job.result ?? {}) as Record<string, unknown>,
request: {
instruction: String(request.instruction ?? ""),
style: (request.style ?? "professional") as DocumentRewriteStyle,
modelName: typeof request.modelName === "string" ? request.modelName : null,
originalContent: String(request.originalContent ?? ""),
},
}];
});
}
export async function cancelDurableDocumentRewrite(
req: Pick<DocumentRewriteRequest, "threadId" | "path">,
jobId: string,
): Promise<void> {
const query = new URLSearchParams({ thread_id: req.threadId, path: req.path });
const response = await apiFetch(
`${getBackendBaseURL()}/api/writing/document-rewrite/jobs/${encodeURIComponent(jobId)}/cancel?${query.toString()}`,
{ method: "POST" },
);
if (!response.ok) {
const body = await response.json().catch(() => ({})) as Record<string, unknown>;
throw new Error((body.detail as string | undefined) ?? "停止全文改写失败");
}
}
export async function* streamDocumentRewrite(
req: DocumentRewriteRequest,
signal?: AbortSignal,
): AsyncGenerator<DocumentRewriteStreamEvent> {
const started = await startDurableDocumentRewrite(req, signal);
yield { type: "job_started", jobId: started.jobId, reused: started.reused };
yield* streamDurableDocumentRewriteJob(req, started.jobId, signal);
}
@@ -5,15 +5,8 @@ import { CanvasUIProvider, useCanvasUIContext } from "../../contexts/CanvasUICon
import { TiptapEditor } from "../../editor/TiptapEditor";
import { useWritingEditor } from "../../hooks/useWritingEditor";
import { useWritingRewrite } from "../../hooks/useWritingRewrite";
import {
applyInsert,
applyReplace,
createRewriteSelectionSnapshot,
isRewriteSelectionSnapshotCurrent,
type RewriteSelectionSnapshot,
} from "../../utils/apply-rewrite";
import { applyInsert, applyReplace } from "../../utils/apply-rewrite";
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
import { toast } from "sonner";
import { WritingAiMenu } from "./WritingAiMenu";
import { WritingBubbleMenu } from "./WritingBubbleMenu";
@@ -76,7 +69,6 @@ function MarkdownAiEditorInner({
const [phase, setPhase] = useState<FlowPhase>("idle");
const [panelPos, setPanelPos] = useState<PanelPos>({ x: 0, y: 0 });
const lastActionRef = useRef<LastAction | null>(null);
const candidateSnapshotRef = useRef<RewriteSelectionSnapshot | null>(null);
const editor = useWritingEditor({
markdown,
@@ -107,17 +99,10 @@ function MarkdownAiEditorInner({
const closeAll = useCallback(() => {
editor?.commands.clearAiHighlight();
candidateSnapshotRef.current = null;
setPhase("idle");
reset();
}, [reset, editor]);
const snapshotCurrentSelection = useCallback(() => {
if (!editor || !writingSelection) return false;
candidateSnapshotRef.current = createRewriteSelectionSnapshot(editor, writingSelection);
return true;
}, [editor, writingSelection]);
const handleAction = useCallback(
(
action: RewriteAction,
@@ -125,53 +110,39 @@ function MarkdownAiEditorInner({
customInstruction?: string,
referenceText?: string,
) => {
if (!snapshotCurrentSelection()) return;
lastActionRef.current = { action, tone, customInstruction, referenceText };
rewrite(action, tone, customInstruction, referenceText);
setPhase("preview");
},
[rewrite, snapshotCurrentSelection],
[rewrite],
);
const handleRetry = useCallback(() => {
if (!lastActionRef.current) return;
if (!snapshotCurrentSelection()) return;
const { action, tone, customInstruction, referenceText } = lastActionRef.current;
rewrite(action, tone, customInstruction, referenceText);
}, [rewrite, snapshotCurrentSelection]);
}, [rewrite]);
const handleFollowUp = useCallback(
(instruction: string) => {
if (!lastActionRef.current) return;
if (!snapshotCurrentSelection()) return;
const { action, tone, referenceText } = lastActionRef.current;
rewriteFollowUp(instruction, action, tone, referenceText);
},
[rewriteFollowUp, snapshotCurrentSelection],
[rewriteFollowUp],
);
const canApplyCandidate = useCallback(() => {
if (!editor || !candidateSnapshotRef.current) return false;
if (isRewriteSelectionSnapshotCurrent(editor, candidateSnapshotRef.current)) return true;
toast.error("原文已变化,请重新生成候选稿。", {
description: "为避免将过期候选替换到错误位置,本次内容没有写入文档。",
});
return false;
}, [editor]);
const handleReplace = useCallback(() => {
const snapshot = candidateSnapshotRef.current;
if (!editor || !snapshot || !result || !canApplyCandidate()) return;
applyReplace(editor, snapshot, result);
if (!editor || !writingSelection || !result) return;
applyReplace(editor, writingSelection, result);
closeAll();
}, [editor, result, canApplyCandidate, closeAll]);
}, [editor, writingSelection, result, closeAll]);
const handleInsert = useCallback(() => {
const snapshot = candidateSnapshotRef.current;
if (!editor || !snapshot || !result || !canApplyCandidate()) return;
applyInsert(editor, snapshot, result);
if (!editor || !writingSelection || !result) return;
applyInsert(editor, writingSelection, result);
closeAll();
}, [editor, result, canApplyCandidate, closeAll]);
}, [editor, writingSelection, result, closeAll]);
return (
<div className={className ?? "h-full flex flex-col overflow-hidden"}>
@@ -1,394 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
type DocumentRewriteRequest,
type DocumentRewriteSummary,
type DocumentRewriteStreamEvent,
cancelDurableDocumentRewrite,
streamDocumentRewrite,
} from "../api/rewrite";
export type DocumentRewriteStage =
| "idle"
| "snapshot"
| "requirements"
| "writing"
| "validation"
| "committing"
| "completed"
| "conflict"
| "error"
| "cancelled";
export interface DocumentRewriteRun {
stage: DocumentRewriteStage;
draft: string;
/** Live, compact execution plan produced before Markdown generation starts. */
requirementsPlan: string;
originalCharCount: number;
writtenCharCount: number;
fileName: string;
warnings: string[];
summary: DocumentRewriteSummary | null;
versionId: string | null;
jobId: string | null;
error: string | null;
}
const INITIAL_RUN: DocumentRewriteRun = {
stage: "idle",
draft: "",
requirementsPlan: "",
originalCharCount: 0,
writtenCharCount: 0,
fileName: "",
warnings: [],
summary: null,
versionId: null,
jobId: null,
error: null,
};
/**
* Full-document rewrite stream with requestAnimationFrame batching.
*
* The backend only persists after it emits `committed`; until then `draft`
* represents a temporary sandbox view and must never be treated as the file's
* actual content.
*/
export function useDocumentRewrite() {
const [run, setRun] = useState<DocumentRewriteRun>(INITIAL_RUN);
const controllerRef = useRef<AbortController | null>(null);
const jobRef = useRef<{ id: string; request: DocumentRewriteRequest } | null>(null);
const pendingDeltaRef = useRef("");
const pendingWrittenCharCountRef = useRef<number | null>(null);
const frameRef = useRef<number | null>(null);
const pendingRequirementsDeltaRef = useRef("");
const requirementsFrameRef = useRef<number | null>(null);
const flushDelta = useCallback(() => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
const delta = pendingDeltaRef.current;
const writtenCharCount = pendingWrittenCharCountRef.current;
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
if (!delta) return;
setRun((previous) => ({
...previous,
stage: "writing",
draft: previous.draft + delta,
...(writtenCharCount !== null ? { writtenCharCount } : {}),
}));
}, []);
const enqueueDelta = useCallback((delta: string, writtenCharCount: number) => {
if (!delta) return;
pendingDeltaRef.current += delta;
pendingWrittenCharCountRef.current = writtenCharCount;
if (frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
const pending = pendingDeltaRef.current;
const pendingWrittenCharCount = pendingWrittenCharCountRef.current;
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
if (!pending) return;
setRun((previous) => ({
...previous,
stage: "writing",
draft: previous.draft + pending,
...(pendingWrittenCharCount !== null ? { writtenCharCount: pendingWrittenCharCount } : {}),
}));
});
}, []);
const flushRequirements = useCallback(() => {
if (requirementsFrameRef.current !== null) {
cancelAnimationFrame(requirementsFrameRef.current);
requirementsFrameRef.current = null;
}
const delta = pendingRequirementsDeltaRef.current;
pendingRequirementsDeltaRef.current = "";
if (!delta) return;
setRun((previous) => ({
...previous,
stage: ["snapshot", "requirements"].includes(previous.stage)
? "requirements"
: previous.stage,
requirementsPlan: previous.requirementsPlan + delta,
}));
}, []);
const enqueueRequirementsDelta = useCallback((delta: string) => {
if (!delta) return;
pendingRequirementsDeltaRef.current += delta;
if (requirementsFrameRef.current !== null) return;
requirementsFrameRef.current = requestAnimationFrame(() => {
requirementsFrameRef.current = null;
const pending = pendingRequirementsDeltaRef.current;
pendingRequirementsDeltaRef.current = "";
if (!pending) return;
setRun((previous) => ({
...previous,
stage: ["snapshot", "requirements"].includes(previous.stage)
? "requirements"
: previous.stage,
requirementsPlan: previous.requirementsPlan + pending,
}));
});
}, []);
const cancel = useCallback(() => {
controllerRef.current?.abort();
controllerRef.current = null;
const job = jobRef.current;
if (job) {
void cancelDurableDocumentRewrite(job.request, job.id).catch(() => {
// The browser stream is already stopped. The terminal event will be
// recovered on the next hydration if the cancel request is delayed.
});
}
flushDelta();
flushRequirements();
setRun((previous) =>
previous.stage === "completed"
? previous
: { ...previous, stage: "cancelled", error: "已停止全文改写,原文件未被修改。" },
);
}, [flushDelta, flushRequirements]);
const reset = useCallback(() => {
controllerRef.current?.abort();
controllerRef.current = null;
jobRef.current = null;
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
pendingRequirementsDeltaRef.current = "";
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
if (requirementsFrameRef.current !== null) {
cancelAnimationFrame(requirementsFrameRef.current);
requirementsFrameRef.current = null;
}
setRun(INITIAL_RUN);
}, []);
const start = useCallback(
async (
request: DocumentRewriteRequest,
options?: {
onCommitted?: (content: string) => void;
onEvent?: (event: DocumentRewriteStreamEvent) => void;
stream?: (
signal: AbortSignal,
) => AsyncIterable<DocumentRewriteStreamEvent>;
/** Resume a durable task instead of creating a new one. */
jobId?: string;
},
) => {
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
if (options?.jobId) {
jobRef.current = { id: options.jobId, request };
}
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
pendingRequirementsDeltaRef.current = "";
setRun({
...INITIAL_RUN,
stage: "snapshot",
fileName: request.path.split("/").filter(Boolean).pop() ?? request.path,
jobId: options?.jobId ?? null,
});
try {
let receivedTerminalEvent = false;
const stream = options?.stream
? options.stream(controller.signal)
: streamDocumentRewrite(request, controller.signal);
for await (const event of stream) {
if (controller.signal.aborted) return;
options?.onEvent?.(event);
switch (event.type) {
case "job_started":
jobRef.current = { id: event.jobId, request };
setRun((previous) => ({ ...previous, jobId: event.jobId }));
break;
case "snapshot_fixed":
setRun((previous) => ({
...previous,
stage: "requirements",
fileName: event.fileName || previous.fileName,
originalCharCount: event.originalCharCount,
}));
break;
case "requirements_started":
setRun((previous) => ({ ...previous, stage: "requirements", requirementsPlan: "" }));
break;
case "requirements_delta":
enqueueRequirementsDelta(event.delta);
break;
case "requirements_checkpoint":
flushRequirements();
setRun((previous) => ({
...previous,
stage: "requirements",
requirementsPlan: event.content,
}));
break;
case "requirements_completed":
flushRequirements();
setRun((previous) => ({
...previous,
stage: "requirements",
requirementsPlan: event.content,
}));
break;
case "requirements_resolved":
flushRequirements();
setRun((previous) => ({
...previous,
stage: "writing",
...(event.plan ? { requirementsPlan: event.plan } : {}),
}));
break;
case "rewrite_delta":
enqueueDelta(event.delta, event.charCount);
break;
case "rewrite_checkpoint":
// A background job may have continued while this browser was
// disconnected. Replace, rather than append, so no missed or
// duplicated live deltas corrupt the temporary sandbox draft.
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
setRun((previous) => ({
...previous,
stage: "writing",
draft: event.content,
writtenCharCount: event.charCount,
}));
break;
case "validation_started":
flushDelta();
flushRequirements();
setRun((previous) => ({ ...previous, stage: "validation" }));
break;
case "validation_completed":
setRun((previous) => ({
...previous,
warnings: event.warnings,
}));
break;
case "comparison_ready":
setRun((previous) => ({ ...previous, summary: event.summary }));
break;
case "commit_started":
setRun((previous) => ({ ...previous, stage: "committing" }));
break;
case "committed":
flushDelta();
flushRequirements();
setRun((previous) => ({
...previous,
draft: event.content,
writtenCharCount: event.content.replace(/\s/g, "").length,
versionId: event.versionId ?? previous.versionId,
}));
options?.onCommitted?.(event.content);
break;
case "done":
flushDelta();
flushRequirements();
receivedTerminalEvent = true;
setRun((previous) => ({
...previous,
stage: "completed",
versionId: event.versionId ?? previous.versionId,
}));
return;
case "conflict":
flushDelta();
flushRequirements();
receivedTerminalEvent = true;
setRun((previous) => ({
...previous,
stage: "conflict",
error: event.message,
}));
return;
case "cancelled":
flushDelta();
flushRequirements();
receivedTerminalEvent = true;
setRun((previous) => ({ ...previous, stage: "cancelled", error: event.message }));
return;
case "error":
flushDelta();
flushRequirements();
receivedTerminalEvent = true;
setRun((previous) => ({
...previous,
stage: "error",
error: event.message,
}));
return;
default:
break;
}
}
if (!controller.signal.aborted && !receivedTerminalEvent) {
flushDelta();
flushRequirements();
setRun((previous) => ({
...previous,
stage: "error",
error: "改写任务连接已结束,但未收到完成状态;原文件未被修改。",
}));
}
} catch (error) {
if (controller.signal.aborted) return;
flushDelta();
flushRequirements();
setRun((previous) => ({
...previous,
stage: "error",
error: error instanceof Error ? error.message : "全文改写失败",
}));
} finally {
if (controllerRef.current === controller) {
controllerRef.current = null;
}
}
},
[enqueueDelta, enqueueRequirementsDelta, flushDelta, flushRequirements],
);
useEffect(
() => () => {
controllerRef.current?.abort();
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
if (requirementsFrameRef.current !== null) cancelAnimationFrame(requirementsFrameRef.current);
},
[],
);
return {
run,
isActive: ["snapshot", "requirements", "writing", "validation", "committing"].includes(run.stage),
start,
cancel,
reset,
};
}
@@ -1,54 +1,6 @@
import type { Editor } from "@tiptap/react";
import type { WritingSelection } from "../types/selection";
/**
* Immutable anchor for an AI rewrite candidate.
*
* An AI response can take seconds to arrive. During that time a user may
* keep editing the document, so a saved ProseMirror range is no longer a safe
* write target by itself. We bind the candidate to both the complete document
* and the selected text before allowing a destructive replacement.
*/
export interface RewriteSelectionSnapshot extends WritingSelection {
documentFingerprint: string;
}
/** A small deterministic fingerprint is sufficient for stale-candidate checks. */
function fingerprintDocument(editor: Editor): string {
const serialized = JSON.stringify(editor.getJSON());
let hash = 0x811c9dc5;
for (let index = 0; index < serialized.length; index += 1) {
hash ^= serialized.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16);
}
export function createRewriteSelectionSnapshot(
editor: Editor,
selection: WritingSelection,
): RewriteSelectionSnapshot {
return {
...selection,
documentFingerprint: fingerprintDocument(editor),
};
}
export function isRewriteSelectionSnapshotCurrent(
editor: Editor,
snapshot: RewriteSelectionSnapshot,
): boolean {
const documentSize = editor.state.doc.content.size;
if (snapshot.from < 0 || snapshot.to < snapshot.from || snapshot.to > documentSize) {
return false;
}
const selectedText = editor.state.doc.textBetween(snapshot.from, snapshot.to, "\n");
return (
selectedText === snapshot.text &&
fingerprintDocument(editor) === snapshot.documentFingerprint
);
}
/** Convert plain text (possibly multi-paragraph) to a list of Tiptap paragraph nodes. */
function textToNodes(schema: Editor["schema"], text: string) {
const paragraphs = text.split(/\n{2,}/);
+4 -139
View File
@@ -16,7 +16,6 @@ import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box";
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { QuestionNavigator } from "@/components/workspace/question-navigator";
@@ -25,9 +24,7 @@ import { TodoList } from "@/components/workspace/todo-list";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
import { Tooltip } from "@/components/workspace/tooltip";
import { normalizeAgentQuestions, useAgent } from "@/core/agents";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
@@ -72,26 +69,6 @@ const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000;
const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000;
function readPendingKnowledgeSelection(agentId: string | undefined): string[] | null {
if (typeof window === "undefined" || !agentId) return null;
try {
const raw = sessionStorage.getItem("pendingAgentMessage");
if (!raw) return null;
const parsed = JSON.parse(raw) as {
agentId?: string;
knowledgeBaseIds?: unknown;
};
if (parsed.agentId !== agentId || !Array.isArray(parsed.knowledgeBaseIds)) {
return null;
}
return parsed.knowledgeBaseIds
.map((item) => String(item).trim())
.filter(Boolean);
} catch {
return null;
}
}
function stringifyForHiddenContext(value: unknown): string {
if (typeof value === "string") return value;
try {
@@ -267,79 +244,6 @@ export default function AgentChatPage({
useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId);
const { tokenUsageEnabled } = useModels();
const pendingKnowledgeSelection = useRef(
readPendingKnowledgeSelection(agent_id),
);
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>(
() => pendingKnowledgeSelection.current ?? [],
);
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(
() => pendingKnowledgeSelection.current !== null,
);
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
const knowledgeSelectionThread = useRef<string | null>(null);
const knowledgeSelectionAgent = useRef(agent_id);
const activeThreadIdRef = useRef<string | null | undefined>(threadId);
const changeKnowledgeSelection = useCallback((ids: string[]) => {
setSelectedKnowledgeBaseIds(ids);
setKnowledgeSelectionReady(true);
}, []);
useEffect(() => {
activeThreadIdRef.current = threadId;
}, [threadId]);
useEffect(() => {
if (knowledgeSelectionAgent.current === agent_id) return;
knowledgeSelectionAgent.current = agent_id;
const pending = readPendingKnowledgeSelection(agent_id);
pendingKnowledgeSelection.current = pending;
knowledgeSelectionThread.current = null;
setSelectedKnowledgeBaseIds(pending ?? []);
setKnowledgeSelectionReady(pending !== null);
}, [agent_id]);
useEffect(() => {
if (isNewThread) {
if (knowledgeSelectionThread.current !== "__new__") {
setSelectedKnowledgeBaseIds(pendingKnowledgeSelection.current ?? []);
setKnowledgeSelectionReady(pendingKnowledgeSelection.current !== null);
}
knowledgeSelectionThread.current = "__new__";
setKnowledgeSelectionLoading(false);
return;
}
if (!threadId || knowledgeSelectionThread.current === threadId) return;
knowledgeSelectionThread.current = threadId;
setKnowledgeSelectionLoading(true);
void getAPIClient()
.threads.get(threadId)
.then((record) => {
if (knowledgeSelectionThread.current !== threadId) return;
const metadata = record.metadata as Record<string, unknown> | undefined;
const raw = metadata?.llmwiki_knowledge_base_ids;
if (Array.isArray(raw)) {
setSelectedKnowledgeBaseIds(
raw.map((item) => String(item).trim()).filter(Boolean),
);
setKnowledgeSelectionReady(true);
} else {
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
})
.catch(() => {
if (knowledgeSelectionThread.current === threadId) {
setKnowledgeSelectionReady(false);
}
})
.finally(() => {
if (knowledgeSelectionThread.current === threadId) {
setKnowledgeSelectionLoading(false);
}
});
}, [isNewThread, threadId]);
useEffect(() => {
if (isNewThread) {
@@ -349,34 +253,14 @@ export default function AgentChatPage({
}, [isNewThread, threadId, agent_id]);
const { showNotification } = useNotification();
const conversationMetadata = useMemo(
() => ({
...(taskId ? taskThreadMetadata(taskContext) : {}),
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: {}),
}),
[knowledgeSelectionReady, selectedKnowledgeBaseIds, taskContext, taskId],
);
const [thread, sendMessage] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
assistantId: agent_id,
context: {
...settings.context,
agent_id,
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: {}),
},
context: { ...settings.context, agent_id },
// 任务闸门:新建 thread 打 metadata.taskId 标签,供任务对话列表(1:N)按任务过滤;
// xdfx 额外打 metadata.actionId 标签,按「行动id」划分聊天记录(见 taskThreadMetadata)。
threadMetadata:
Object.keys(conversationMetadata).length > 0
? conversationMetadata
: undefined,
threadMetadata: taskId ? taskThreadMetadata(taskContext) : undefined,
onStart: (createdThreadId) => {
knowledgeSelectionThread.current = createdThreadId;
activeThreadIdRef.current = createdThreadId;
setThreadId(createdThreadId);
setIsNewThread(false);
// Keep the current component mounted while updating the hash route.
@@ -402,12 +286,6 @@ export default function AgentChatPage({
} catch {
/* 上报绝不影响问答 */
}
queueLlmWikiConversationDeposit({
threadId: activeThreadIdRef.current,
messages: state.messages,
assistantId: agent_id,
knowledgeBaseIds: selectedKnowledgeBaseIds,
});
if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished";
const lastMessage = state.messages[state.messages.length - 1];
@@ -703,23 +581,10 @@ export default function AgentChatPage({
autoFocus
status={status}
context={settings.context}
disabled={isDemoMode || knowledgeSelectionLoading}
disabled={isDemoMode}
onContextChange={(context) =>
setSettings("context", context)
}
knowledgeSelector={
<LlmWikiKnowledgeSelector
source="agent"
agentId={agent_id}
value={selectedKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
autoSelectAll={
isNewThread && pendingKnowledgeSelection.current === null
}
disabled={isDemoMode || knowledgeSelectionLoading}
label="知识空间"
/>
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
@@ -771,7 +636,7 @@ export default function AgentChatPage({
assistantId={agent_id}
status={status}
context={settings.context}
disabled={isDemoMode || knowledgeSelectionLoading}
disabled={isDemoMode}
onContextChange={(context) =>
setSettings("context", context)
}
+12 -230
View File
@@ -16,7 +16,6 @@ import {
} from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { KnowledgeSaveTrigger } from "@/components/workspace/knowledge-save-trigger";
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
import { InputBox } from "@/components/workspace/input-box";
import {
MessageList,
@@ -45,12 +44,6 @@ import { PanelLeftOpen } from "lucide-react";
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import {
isFrontendRagProfile,
isMultiRoundRagProfile,
isSingleRoundRagProfile,
SINGLE_ROUND_RAG_MODELS,
} from "@/core/models/single-round-rag";
import { useNotification } from "@/core/notification/hooks";
import {
getLastAgentId,
@@ -59,9 +52,6 @@ import {
useThreadSettings,
} from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag";
import { useLayoutMode } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base";
import { textOfMessage } from "@/core/threads/utils";
@@ -131,29 +121,12 @@ export default function ChatPage() {
// In-memory only — explicitly overrides any value persisted by the old
// per-thread header toggle so stale localStorage can't re-enable it.
const [knowledgeRagEnabled] = useKnowledgeRagEnabled();
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(false);
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
const knowledgeSelectionThread = useRef<string | null>(null);
const activeThreadIdRef = useRef<string | null | undefined>(threadId);
const changeKnowledgeSelection = useCallback((ids: string[]) => {
setSelectedKnowledgeBaseIds(ids);
setKnowledgeSelectionReady(true);
}, []);
const streamContext = useMemo(
() => ({
...settings.context,
knowledge_rag_enabled: knowledgeRagEnabled,
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: {}),
}),
[
settings.context,
knowledgeRagEnabled,
knowledgeSelectionReady,
selectedKnowledgeBaseIds,
],
[settings.context, knowledgeRagEnabled],
);
const [mounted, setMounted] = useState(false);
const { tokenUsageEnabled } = useModels();
@@ -177,10 +150,6 @@ export default function ChatPage() {
const { selectedArtifact } = useArtifacts();
useSpecificChatMode();
useEffect(() => {
activeThreadIdRef.current = threadId;
}, [threadId]);
useEffect(() => {
setMounted(true);
}, []);
@@ -189,54 +158,12 @@ export default function ChatPage() {
if (isNewThread) {
setHasStartedConversation(false);
setPromptPrefixOn(false);
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
setKnowledgeSelectionLoading(false);
knowledgeSelectionThread.current = "__new__";
// Pre-select the agent the user last started a conversation with so a
// fresh chat defaults to it. Validated against the known agent list below.
setSelectedAgentId(getLastAgentId());
}
}, [isNewThread, threadId]);
// The knowledge-base choice belongs to the conversation, not to a browser
// component instance. Restore it from LangGraph thread metadata whenever an
// existing conversation is opened. An explicit empty array means the user
// deliberately disabled LLMWiki retrieval for that conversation.
useEffect(() => {
if (isNewThread || !threadId || knowledgeSelectionThread.current === threadId) {
return;
}
knowledgeSelectionThread.current = threadId;
setKnowledgeSelectionLoading(true);
void getAPIClient(isMock)
.threads.get(threadId)
.then((record) => {
if (knowledgeSelectionThread.current !== threadId) return;
const metadata = record.metadata as Record<string, unknown> | undefined;
const raw = metadata?.llmwiki_knowledge_base_ids;
if (Array.isArray(raw)) {
setSelectedKnowledgeBaseIds(
raw.map((item) => String(item).trim()).filter(Boolean),
);
setKnowledgeSelectionReady(true);
} else {
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
})
.catch(() => {
if (knowledgeSelectionThread.current === threadId) {
setKnowledgeSelectionReady(false);
}
})
.finally(() => {
if (knowledgeSelectionThread.current === threadId) {
setKnowledgeSelectionLoading(false);
}
});
}, [isMock, isNewThread, threadId]);
// Drop a restored last-used agent that is no longer in the selector list
// (deleted / unpublished / no longer curated), so we never hand off to a
// dead agent. Wait for the list to load before deciding.
@@ -248,8 +175,6 @@ export default function ChatPage() {
if (known.length === 0) return;
if (!known.some((a) => a.id === selectedAgentId)) {
setSelectedAgentId(null);
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
@@ -258,13 +183,8 @@ export default function ChatPage() {
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
context: streamContext,
threadMetadata: knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: undefined,
isMock,
onStart: (createdThreadId) => {
knowledgeSelectionThread.current = createdThreadId;
activeThreadIdRef.current = createdThreadId;
setThreadId(createdThreadId);
setIsNewThread(false);
// Keep the current component mounted while updating the hash route.
@@ -290,12 +210,6 @@ export default function ChatPage() {
} catch {
/* 上报绝不影响问答 */
}
queueLlmWikiConversationDeposit({
threadId: activeThreadIdRef.current,
messages: state.messages,
assistantId: selectedAgentId,
knowledgeBaseIds: selectedKnowledgeBaseIds,
});
if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished";
const lastMessage = state.messages.at(-1);
@@ -312,39 +226,6 @@ export default function ChatPage() {
}
},
});
const singleRoundRag = useSingleRoundRagMessages(threadId);
const multiRoundRag = useMultiRoundRagMessages(threadId);
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
const frontendRagSelected = isFrontendRagProfile(settings.context);
const displayThread = useMemo(() => {
if (
singleRoundRag.messages.length === 0 &&
multiRoundRag.messages.length === 0 &&
!singleRoundRag.isLoading &&
!multiRoundRag.isLoading
) {
return thread;
}
const messages = [
...thread.messages,
...singleRoundRag.messages,
...multiRoundRag.messages,
];
return {
...thread,
values: { ...thread.values, messages },
messages,
isLoading:
thread.isLoading || singleRoundRag.isLoading || multiRoundRag.isLoading,
} as typeof thread;
}, [
multiRoundRag.isLoading,
multiRoundRag.messages,
singleRoundRag.isLoading,
singleRoundRag.messages,
thread,
]);
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
@@ -359,56 +240,6 @@ export default function ChatPage() {
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
if (frontendRagSelected) {
if (message.files && message.files.length > 0) {
toast.error("RAG 问答暂不支持附件,请移除附件后重试。");
return;
}
setHasStartedConversation(true);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void (async () => {
try {
let targetThreadId = threadId;
if (isNewThread) {
const created = await getAPIClient(isMock).threads.create({});
targetThreadId = created.thread_id;
setThreadId(targetThreadId);
setIsNewThread(false);
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#${routeBase}/chats/${targetThreadId}`,
);
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
if (title) {
void getAPIClient(isMock).threads
.updateState(targetThreadId, { values: { title } })
.catch(() => undefined);
}
}
if (multiRoundRagSelected) {
await multiRoundRag.submit({ targetThreadId, query: message.text });
} else {
await singleRoundRag.submit({
targetThreadId,
query: message.text,
modelName: settings.context.model_name ?? "OpenAI3",
});
}
} catch (error) {
toast.error(
error instanceof Error
? error.message
: multiRoundRagSelected
? "多轮 RAG 问答失败。"
: "单轮 RAG 问答失败。",
);
}
})();
return;
}
// If the user picked an agent before sending the opening turn, hand off
// to AgentChatPage so the conversation runs against that agent. We stash
// the pending text in sessionStorage because the next/navigation shim
@@ -431,12 +262,7 @@ export default function ChatPage() {
void (async () => {
try {
const created = await getAPIClient(isMock).threads.create({
metadata: {
agent_id: selectedAgentId,
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: {}),
},
metadata: { agent_id: selectedAgentId },
});
sessionStorage.setItem(
"pendingAgentMessage",
@@ -444,9 +270,6 @@ export default function ChatPage() {
agentId: selectedAgentId,
threadId: created.thread_id,
text,
...(knowledgeSelectionReady
? { knowledgeBaseIds: selectedKnowledgeBaseIds }
: {}),
}),
);
saveLastAgentId(selectedAgentId);
@@ -505,22 +328,11 @@ export default function ChatPage() {
hideAdminPrompt,
preferencesLoading,
selectedAgentId,
selectedKnowledgeBaseIds,
knowledgeSelectionReady,
agentHandoffPending,
isMock,
router,
settings.context.writing_mode,
settings.context.model_name,
writingArtifactPath,
singleRoundRag,
singleRoundRagSelected,
multiRoundRag,
multiRoundRagSelected,
frontendRagSelected,
routeBase,
setIsNewThread,
setThreadId,
],
);
@@ -606,21 +418,11 @@ export default function ChatPage() {
// Passing null (the ✕ clear) wipes the stored preference.
const handleAgentChange = useCallback((agentId: string | null) => {
setSelectedAgentId(agentId);
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
saveLastAgentId(agentId);
}, []);
const handleStop = useCallback(async () => {
if (singleRoundRag.isLoading) {
singleRoundRag.stop();
return;
}
if (multiRoundRag.isLoading) {
multiRoundRag.stop();
return;
}
await thread.stop();
}, [multiRoundRag, singleRoundRag, thread]);
}, [thread]);
const promptPrefixProp = useMemo(() => {
if (!promptPrefixSettings?.enabled) return undefined;
@@ -666,7 +468,7 @@ export default function ChatPage() {
const compactActionsHidden = isHideChromeActive();
return (
<ThreadContext.Provider value={{ thread: displayThread, isMock }}>
<ThreadContext.Provider value={{ thread, isMock }}>
<ChatBox threadId={threadId}>
<div
className={cn(
@@ -684,7 +486,7 @@ export default function ChatPage() {
)}
>
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={displayThread} />
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="flex items-center gap-2">
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
@@ -703,7 +505,7 @@ export default function ChatPage() {
)}
<TokenUsageIndicator
enabled={tokenUsageEnabled}
messages={displayThread.messages}
messages={thread.messages}
/>
<ExportTrigger threadId={threadId} />
<KnowledgeSaveTrigger threadId={threadId} />
@@ -741,7 +543,7 @@ export default function ChatPage() {
<MessageList
className={cn("size-full", "pt-10")}
threadId={threadId}
thread={displayThread}
thread={thread}
paddingBottom={messageListPaddingBottom}
tokenUsageEnabled={tokenUsageEnabled}
// 输入框为底部绝对浮层(约 160px 高),按钮上移到其上方居中。
@@ -783,9 +585,9 @@ export default function ChatPage() {
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={displayThread.values.todos ?? []}
todos={thread.values.todos ?? []}
hidden={
!displayThread.values.todos || displayThread.values.todos.length === 0
!thread.values.todos || thread.values.todos.length === 0
}
/>
</div>
@@ -810,19 +612,15 @@ export default function ChatPage() {
status={
thread.error
? "error"
: displayThread.isLoading
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraModels={SINGLE_ROUND_RAG_MODELS}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading ||
agentHandoffPending ||
knowledgeSelectionLoading ||
singleRoundRag.isLoading ||
multiRoundRag.isLoading
agentHandoffPending
}
onContextChange={(context) =>
setSettings("context", context)
@@ -836,22 +634,6 @@ export default function ChatPage() {
}
: undefined
}
knowledgeSelector={
isLandingState ? (
<LlmWikiKnowledgeSelector
source={selectedAgentId ? "agent" : "all"}
agentId={selectedAgentId}
value={selectedKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
autoSelectAll={!!selectedAgentId}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
knowledgeSelectionLoading
}
label="知识空间"
/>
) : undefined
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
@@ -882,7 +664,7 @@ export default function ChatPage() {
*/}
{!isLandingState && (
<QuestionNavigator
messages={displayThread.messages}
messages={thread.messages}
className="top-16 right-0 bottom-48 left-0"
/>
)}
File diff suppressed because it is too large. Load diff
+2 -7
View File
@@ -1,5 +1,5 @@
import { Loader2 } from "lucide-react";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
@@ -530,11 +530,7 @@ function VaultPageDialog({
);
}
export default function KnowledgeBasePage({
headerExtraActions,
}: {
headerExtraActions?: ReactNode;
} = {}) {
export default function KnowledgeBasePage() {
const navigate = useNavigate();
const { noteId } = useParams<{ noteId: string }>();
@@ -657,7 +653,6 @@ export default function KnowledgeBasePage({
onCreate={() => setCreateOpen(true)}
onManageFolders={() => setFoldersOpen(true)}
onManageTemplates={() => setTemplatesOpen(true)}
extraActions={headerExtraActions}
/>
<div
@@ -1,91 +0,0 @@
import { Loader2, Settings2 } from "lucide-react";
import { useState } from "react";
import { Navigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { getStoredAuth } from "@/core/auth";
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import WeKnoraKnowledgeWorkspace, {
ConnectionSettings,
} from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
import WeKnoraKnowledgeDetailPage from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage";
import KnowledgeBasePage from "./KnowledgeBasePage";
import KnowledgeGraphPage from "./KnowledgeGraphPage";
type LlmWikiKnowledgePageMode = "list" | "detail" | "graph";
function RuntimeLoading() {
return (
<div className="flex h-full min-h-64 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-5 animate-spin" />
正在读取 LLMWiki 运行模式
</div>
);
}
function AdminWeKnoraSettingsButton() {
const [open, setOpen] = useState(false);
return (
<>
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
<Settings2 className="mr-2 size-4" />
配置 WeKnora
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>LLMWiki 运行模式</DialogTitle>
</DialogHeader>
<ConnectionSettings />
</DialogContent>
</Dialog>
</>
);
}
function LegacyKnowledgePage() {
const isAdmin = getStoredAuth()?.system_role === "admin";
return (
<KnowledgeBasePage
headerExtraActions={isAdmin ? <AdminWeKnoraSettingsButton /> : undefined}
/>
);
}
export default function LlmWikiKnowledgePage({
mode = "list",
}: {
mode?: LlmWikiKnowledgePageMode;
}) {
const runtime = useLlmWikiRuntime();
if (runtime.isLoading) {
return <RuntimeLoading />;
}
if (runtime.isError || runtime.data?.provider !== "weknora") {
if (mode === "graph") {
return <KnowledgeGraphPage />;
}
return <LegacyKnowledgePage />;
}
if (mode === "graph") {
return <Navigate to="/page/workspace/knowledge" replace />;
}
if (mode === "detail") {
return <WeKnoraKnowledgeDetailPage />;
}
return <WeKnoraKnowledgeWorkspace />;
}
@@ -1,491 +0,0 @@
import {
BotIcon,
ChevronDownIcon,
CircleAlertIcon,
CpuIcon,
EyeIcon,
KeyRoundIcon,
LoaderCircleIcon,
PlusIcon,
RefreshCwIcon,
Settings2Icon,
SparklesIcon,
Trash2Icon,
WrenchIcon,
} from "lucide-react";
import { useMemo, useState, type ReactNode } from "react";
import {
Button as TButton,
Dialog,
Input,
InputNumber,
MessagePlugin,
Select,
Switch,
Tag,
Textarea,
} from "tdesign-react";
import {
type ManagedModel,
type ModelSavePayload,
useDeleteManagedModel,
useManagedModels,
useSaveManagedModel,
useTestManagedModel,
} from "@/core/model-management";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
type ModelDraft = ModelSavePayload & {
api_key: string;
keep_existing_key: boolean;
extra_settings_text: string;
};
const PROVIDER_OPTIONS = [
{ label: "OpenAI 兼容接口", value: "langchain_openai:ChatOpenAI" },
{ label: "Anthropic", value: "langchain_anthropic:ChatAnthropic" },
{ label: "Google Gemini", value: "langchain_google_genai:ChatGoogleGenerativeAI" },
{ label: "Groq", value: "langchain_groq:ChatGroq" },
{ label: "Ollama", value: "langchain_ollama:ChatOllama" },
];
const emptyDraft = (): ModelDraft => ({
name: "",
display_name: "",
description: "",
use: "langchain_openai:ChatOpenAI",
model: "",
api_key: "",
keep_existing_key: false,
clear_api_key: false,
base_url: "",
request_timeout: 30,
max_retries: 2,
max_tokens: undefined,
temperature: undefined,
stream_usage: true,
supports_thinking: false,
supports_reasoning_effort: false,
supports_vision: false,
when_thinking_enabled: null,
when_thinking_disabled: null,
thinking: null,
use_responses_api: null,
output_version: null,
extra_settings: {},
extra_settings_text: "{}",
});
function draftFromModel(model: ManagedModel): ModelDraft {
return {
...model,
api_key: "",
keep_existing_key: model.api_key_configured,
clear_api_key: false,
extra_settings_text: JSON.stringify(model.extra_settings ?? {}, null, 2),
};
}
function optionalString(value: string | null | undefined): string | null {
const normalized = (value ?? "").trim();
return normalized || null;
}
function toNumber(value: number | string | null | undefined): number | undefined {
if (value === "" || value === null || value === undefined) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function buildPayload(draft: ModelDraft): ModelSavePayload {
let extraSettings: Record<string, unknown> = {};
try {
const parsed = JSON.parse(draft.extra_settings_text || "{}") as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
throw new Error("额外参数必须是 JSON 对象");
}
extraSettings = parsed as Record<string, unknown>;
} catch (error) {
throw error instanceof Error ? error : new Error("额外参数不是有效 JSON");
}
const payload: ModelSavePayload = {
name: draft.name.trim(),
display_name: optionalString(draft.display_name),
description: optionalString(draft.description),
use: draft.use.trim(),
model: draft.model.trim(),
clear_api_key: Boolean(draft.clear_api_key),
base_url: optionalString(draft.base_url),
request_timeout: toNumber(draft.request_timeout),
max_retries: toNumber(draft.max_retries),
max_tokens: toNumber(draft.max_tokens),
temperature: toNumber(draft.temperature),
stream_usage: draft.stream_usage,
supports_thinking: draft.supports_thinking,
supports_reasoning_effort: draft.supports_reasoning_effort,
supports_vision: draft.supports_vision,
when_thinking_enabled: draft.when_thinking_enabled,
when_thinking_disabled: draft.when_thinking_disabled,
thinking: draft.thinking,
use_responses_api: draft.use_responses_api,
output_version: optionalString(draft.output_version),
extra_settings: extraSettings,
};
if (draft.api_key.trim()) payload.api_key = draft.api_key.trim();
return payload;
}
function StatCard({ icon: Icon, label, value }: { icon: typeof CpuIcon; label: string; value: number }) {
return (
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-sm">
<span className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary"><Icon className="size-5" /></span>
<div><div className="text-xl font-semibold tabular-nums">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div>
</div>
);
}
function Field({ label, required = false, children }: { label: string; required?: boolean; children: ReactNode }) {
return <label className="space-y-1.5 text-sm font-medium">{label}{required && <span className="ml-1 text-destructive">*</span>}{children}</label>;
}
function ModelCapabilityBadge({
icon: Icon,
label,
className,
}: {
icon?: typeof KeyRoundIcon;
label: string;
className: string;
}) {
return (
<span className={`inline-flex h-7 items-center gap-1 whitespace-nowrap rounded px-2 text-xs font-medium leading-none ${className}`}>
{Icon && <Icon aria-hidden className="block size-3.5 shrink-0" />}
<span className="block leading-none">{label}</span>
</span>
);
}
function showGlobalMessage(
theme: "success" | "error" | "warning",
content: string,
) {
// Schedule after the modal state update so the message is rendered above
// the page, rather than inside or behind a closing dialog.
window.setTimeout(() => {
MessagePlugin.closeAll();
void MessagePlugin[theme]({
content,
duration: 5_000,
placement: "top",
zIndex: 10_000,
attach: () => document.body,
});
}, 0);
}
function ModelEditor({ model, onClose }: { model: ManagedModel | null; onClose: () => void }) {
const isNew = model === null;
const [draft, setDraft] = useState<ModelDraft>(() => model ? draftFromModel(model) : emptyDraft());
const [advanced, setAdvanced] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; latency: number; message: string; preview?: string | null } | null>(null);
const save = useSaveManagedModel();
const test = useTestManagedModel();
const patch = (next: Partial<ModelDraft>) => setDraft((current) => ({ ...current, ...next }));
const handleTest = async () => {
try {
const payload = buildPayload(draft);
const result = await test.mutateAsync(payload);
setTestResult({ success: result.success, latency: result.latency_ms, message: result.message, preview: result.preview });
if (!result.success) return void showGlobalMessage("error", `连接失败:${result.message}`);
showGlobalMessage("success", `连接成功${result.latency_ms ? `,${result.latency_ms} ms` : ""}`);
} catch (error) {
showGlobalMessage("error", error instanceof Error ? error.message : "连接测试失败");
}
};
const handleSave = async () => {
try {
const payload = buildPayload(draft);
if (!payload.name || !payload.use || !payload.model) {
return void showGlobalMessage("warning", "请填写模型标识、驱动与实际模型名");
}
await save.mutateAsync({ name: model?.name ?? payload.name, payload, isNew });
onClose();
showGlobalMessage(
"success",
isNew
? `模型“${payload.name}”已添加,所有页面的模型选择器已刷新。`
: `模型“${payload.name}”的配置已保存并立即生效。`,
);
} catch (error) {
showGlobalMessage("error", error instanceof Error ? error.message : "保存模型配置失败");
}
};
return (
<Dialog
visible
header={isNew ? "添加模型" : `编辑模型:${model?.display_name || model?.name}`}
placement="center"
width={860}
zIndex={3200}
confirmBtn={{ content: isNew ? "添加并启用" : "保存配置", theme: "primary" }}
cancelBtn="取消"
confirmLoading={save.isPending}
onClose={onClose}
onConfirm={() => void handleSave()}
onCancel={onClose}
>
<div className="max-h-[70vh] space-y-5 overflow-y-auto px-1 py-1 pr-3">
<div className="rounded-lg border border-primary/20 bg-primary/5 px-3 py-2 text-sm text-muted-foreground">
保存会直接更新当前部署的 <code className="rounded bg-background px-1 py-0.5">config.yaml</code> 模型段,自动备份并热刷新;不会中断已开始的会话。
</div>
<section className="grid gap-4 md:grid-cols-2">
<Field label="稳定标识" required><Input value={draft.name} disabled={!isNew} placeholder="例如 deepseek-chat" tips={isNew ? "保存后不可修改,用于智能体和运行配置引用。" : "稳定标识不可修改,避免已有配置失效。"} onChange={(value) => patch({ name: String(value) })} /></Field>
<Field label="显示名称"><Input value={draft.display_name ?? ""} placeholder="例如 DeepSeek Chat" onChange={(value) => patch({ display_name: String(value) })} /></Field>
<Field label="模型驱动" required><Select value={draft.use} options={PROVIDER_OPTIONS} filterable creatable inputProps={{ placeholder: "选择或输入 use 类路径" }} onChange={(value) => patch({ use: String(value) })} /></Field>
<Field label="实际模型名" required><Input value={draft.model} placeholder="例如 deepseek-chat" onChange={(value) => patch({ model: String(value) })} /></Field>
<Field label="Base URL"><Input value={draft.base_url ?? ""} type="url" placeholder="https://api.example.com/v1" onChange={(value) => patch({ base_url: String(value) })} /></Field>
<Field label="API Key"><Input value={draft.api_key} type="password" placeholder={draft.keep_existing_key && !draft.clear_api_key ? "已配置,留空则保持不变" : "可填写密钥或 $ENVIRONMENT_VARIABLE"} onChange={(value) => patch({ api_key: String(value), clear_api_key: false })} /></Field>
{draft.keep_existing_key && !draft.api_key && <div className="md:col-span-2 flex items-center gap-2 text-xs text-muted-foreground"><KeyRoundIcon className="size-3.5" />已配置密钥{model?.api_key_source ? `(环境变量 ${model.api_key_source})` : ""},不会回显。<TButton theme="default" variant="text" size="small" onClick={() => patch({ clear_api_key: !draft.clear_api_key })}>{draft.clear_api_key ? "撤销清除" : "清除密钥"}</TButton></div>}
<div className="md:col-span-2"><Field label="描述"><Textarea value={draft.description ?? ""} autosize={{ minRows: 2, maxRows: 4 }} placeholder="说明模型适用的任务与限制" onChange={(value) => patch({ description: String(value) })} /></Field></div>
</section>
<section className="rounded-lg border border-border p-4">
<div className="mb-2"><h3 className="font-medium">能力声明</h3><p className="mt-1 text-xs text-muted-foreground">这些标识决定哪些页面会向模型提供思考、视觉或工具调用入口。</p></div>
<div className="flex flex-wrap gap-x-5 gap-y-3 text-sm"><span className="flex items-center gap-2"><Switch value={draft.supports_thinking} onChange={(value) => patch({ supports_thinking: Boolean(value) })} />支持思考</span><span className="flex items-center gap-2"><Switch value={draft.supports_vision} onChange={(value) => patch({ supports_vision: Boolean(value) })} />支持视觉</span><span className="flex items-center gap-2"><Switch value={draft.supports_reasoning_effort} onChange={(value) => patch({ supports_reasoning_effort: Boolean(value) })} />推理档位</span></div>
</section>
<div className="flex items-center justify-between border-t pt-4"><TButton theme="default" variant="outline" loading={test.isPending} onClick={() => void handleTest()}><WrenchIcon className="mr-1.5 size-4" />测试连接</TButton><TButton theme="default" variant="text" onClick={() => setAdvanced((value) => !value)}><Settings2Icon className="mr-1.5 size-4" />高级配置 <ChevronDownIcon className={`ml-1 size-4 transition-transform ${advanced ? "rotate-180" : ""}`} /></TButton></div>
{testResult && <div className={`rounded-lg border px-3 py-2 text-sm ${testResult.success ? "border-primary/20 bg-primary/5 text-foreground" : "border-destructive/25 bg-destructive/5 text-destructive"}`}><div className="font-medium">{testResult.success ? "连接成功" : "连接失败"}{testResult.success && testResult.latency ? ` · ${testResult.latency} ms` : ""}</div><p className="mt-1 break-words text-xs opacity-80">{testResult.preview || testResult.message}</p></div>}
{advanced && <section className="space-y-4 rounded-lg border border-dashed border-border bg-muted/10 p-4"><div className="grid gap-4 md:grid-cols-3"><Field label="请求超时(秒)"><InputNumber value={draft.request_timeout ?? ""} min={1} max={600} onChange={(value) => patch({ request_timeout: toNumber(value) })} /></Field><Field label="最大重试次数"><InputNumber value={draft.max_retries ?? ""} min={0} max={20} onChange={(value) => patch({ max_retries: toNumber(value) })} /></Field><Field label="最大输出 Token"><InputNumber value={draft.max_tokens ?? ""} min={1} max={1_000_000} onChange={(value) => patch({ max_tokens: toNumber(value) })} /></Field><Field label="温度"><InputNumber value={draft.temperature ?? ""} min={0} max={2} step={0.1} decimalPlaces={1} onChange={(value) => patch({ temperature: toNumber(value) })} /></Field><span className="flex items-end gap-2 pb-1 text-sm font-medium"><Switch value={draft.stream_usage ?? true} onChange={(value) => patch({ stream_usage: Boolean(value) })} />流式 Token 统计</span></div><Field label="额外参数 JSON"><Textarea value={draft.extra_settings_text} autosize={{ minRows: 4, maxRows: 10 }} placeholder={'{\n "extra_body": {}\n}'} onChange={(value) => patch({ extra_settings_text: String(value) })} /><p className="text-xs font-normal text-muted-foreground">保留并编辑配置文件中未在基础表单展示的兼容参数。必须为 JSON 对象。</p></Field></section>}
</div>
</Dialog>
);
}
export default function ModelManagementPage() {
const { data, isLoading, error, refetch, isFetching } = useManagedModels();
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<"all" | "thinking" | "vision">("all");
const [editor, setEditor] = useState<ManagedModel | null | "new">(null);
const [deleteTarget, setDeleteTarget] = useState<ManagedModel | null>(null);
const remove = useDeleteManagedModel();
const models = useMemo(() => {
const keyword = search.trim().toLowerCase();
return (data?.models ?? []).filter((model) => {
const matchesFilter = filter === "all" || (filter === "thinking" ? model.supports_thinking : model.supports_vision);
const matchesSearch = !keyword || [model.name, model.display_name, model.model, model.use].filter(Boolean).join(" ").toLowerCase().includes(keyword);
return matchesFilter && matchesSearch;
});
}, [data?.models, filter, search]);
const stats = useMemo(() => ({ total: data?.models.length ?? 0, thinking: data?.models.filter((item) => item.supports_thinking).length ?? 0, vision: data?.models.filter((item) => item.supports_vision).length ?? 0 }), [data?.models]);
const handleDelete = async () => {
if (!deleteTarget)
return;
try {
await remove.mutateAsync(deleteTarget.name);
const deletedName = deleteTarget.name;
setDeleteTarget(null);
showGlobalMessage("success", `模型“${deletedName}”已删除,配置已立即生效。`);
}
catch (error) {
showGlobalMessage("error", error instanceof Error ? error.message : "删除模型失败,请稍后重试。");
}
};
return (
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<main className="flex size-full min-h-0 flex-col overflow-y-auto bg-background px-6 py-5 md:px-8">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<CpuIcon className="size-5" />
</span>
<h1 className="text-2xl font-semibold tracking-tight">模型配置</h1>
</div>
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
管理当前部署 <code className="rounded bg-muted px-1 py-0.5">config.yaml</code> 中的对话模型。保存后所有页面立即可选;密钥仅可写入,永不回显。
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<TButton theme="default" variant="outline" loading={isFetching} onClick={() => void refetch()}>
<RefreshCwIcon className="mr-1.5 size-4" />刷新
</TButton>
<TButton theme="primary" onClick={() => setEditor("new")}>
<PlusIcon className="mr-1.5 size-4" />添加模型
</TButton>
</div>
</header>
<div className="grid gap-3 sm:grid-cols-3">
<StatCard icon={BotIcon} label="已配置模型" value={stats.total} />
<StatCard icon={SparklesIcon} label="支持思考" value={stats.thinking} />
<StatCard icon={EyeIcon} label="支持视觉" value={stats.vision} />
</div>
<section className="rounded-xl border border-border bg-card shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
{([
["all", "全部"],
["thinking", "支持思考"],
["vision", "支持视觉"],
] as const).map(([value, label]) => (
<TButton
key={value}
theme={filter === value ? "primary" : "default"}
variant={filter === value ? "base" : "text"}
size="small"
onClick={() => setFilter(value)}
>
{label}
</TButton>
))}
</div>
<Input
value={search}
placeholder="搜索名称、模型或驱动"
clearable
className="w-full sm:w-72"
onChange={(value) => setSearch(String(value))}
/>
</div>
{isLoading && (
<div className="flex h-52 items-center justify-center gap-2 text-sm text-muted-foreground">
<LoaderCircleIcon className="size-4 animate-spin" />加载模型配置…
</div>
)}
{error && (
<div className="m-4 flex items-start gap-2 rounded-lg border border-destructive/25 bg-destructive/5 p-3 text-sm text-destructive">
<CircleAlertIcon className="mt-0.5 size-4" />{error.message}
</div>
)}
{!isLoading && !error && models.length === 0 && (
<div className="flex h-52 flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
<CpuIcon className="size-7" />没有匹配的模型配置
</div>
)}
{!isLoading && !error && models.length > 0 && (
<div className="grid gap-3 p-4 lg:grid-cols-2">
{models.map((model) => (
<article key={model.name} className="rounded-lg border border-border bg-background p-4 transition-colors hover:border-primary/35">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="truncate font-semibold">{model.display_name || model.name}</h2>
<Tag theme="success" variant="light" size="small">config.yaml</Tag>
</div>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{model.name}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<TButton theme="default" variant="outline" size="small" onClick={() => setEditor(model)}>
配置
</TButton>
<TButton
theme="danger"
variant="text"
size="small"
aria-label={"删除模型 " + (model.display_name || model.name)}
onClick={() => setDeleteTarget(model)}
>
<Trash2Icon className="mr-1 size-3.5" />删除
</TButton>
</div>
</div>
{model.description ? (
<p className="mt-3 line-clamp-2 min-h-10 text-sm leading-5 text-muted-foreground">{model.description}</p>
) : (
<p className="mt-3 min-h-10 text-sm leading-5 text-muted-foreground">未填写描述</p>
)}
<div className="mt-3 grid gap-2 border-t pt-3 text-xs text-muted-foreground sm:grid-cols-2">
<span className="truncate" title={model.use}>驱动:{model.use}</span>
<span className="truncate" title={model.model}>模型:{model.model}</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{model.api_key_configured && (
<ModelCapabilityBadge
icon={KeyRoundIcon}
label="已配置密钥"
className="bg-muted text-foreground"
/>
)}
{model.supports_thinking && (
<ModelCapabilityBadge
icon={SparklesIcon}
label="思考"
className="bg-primary/10 text-primary"
/>
)}
{model.supports_vision && (
<ModelCapabilityBadge
icon={EyeIcon}
label="视觉"
className="bg-amber-500/10 text-amber-700 dark:text-amber-300"
/>
)}
{model.supports_reasoning_effort && (
<ModelCapabilityBadge
label="推理档位"
className="bg-muted text-muted-foreground"
/>
)}
</div>
</article>
))}
</div>
)}
</section>
{data?.config_path && (
<p className="text-xs text-muted-foreground">
当前配置文件:<code>{data.config_path}</code>
{data.updated_at ? " · 最近更新 " + new Date(data.updated_at).toLocaleString() : ""}
</p>
)}
</div>
</main>
</WorkspaceBody>
{editor !== null && (
<ModelEditor
key={editor === "new" ? "new" : editor.name}
model={editor === "new" ? null : editor}
onClose={() => setEditor(null)}
/>
)}
{deleteTarget && (
<Dialog
visible
header="删除模型"
placement="center"
width={440}
zIndex={3200}
confirmBtn={{ content: "删除", theme: "danger" }}
cancelBtn="取消"
confirmLoading={remove.isPending}
onClose={() => {
if (!remove.isPending) setDeleteTarget(null);
}}
onCancel={() => {
if (!remove.isPending) setDeleteTarget(null);
}}
onConfirm={() => void handleDelete()}
>
<p className="text-sm leading-6 text-muted-foreground">
确定要删除模型 <code className="rounded bg-muted px-1 py-0.5">{deleteTarget.name}</code> 吗?删除后会立即更新运行中的模型配置,依赖它的智能体或任务可能无法继续使用。
</p>
</Dialog>
)}
</WorkspaceContainer>
);
}
-12
View File
@@ -31,7 +31,6 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
import {
MessageList,
type SetupAgentApprovalPayload,
@@ -96,7 +95,6 @@ export default function NewAgentPage() {
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
const [isSavingDraft, setIsSavingDraft] = useState(false);
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
// 恢复已有 bootstrap 会话时该 thread 已存在,无需再 create(置为 true,跳过创建)。
@@ -232,7 +230,6 @@ export default function NewAgentPage() {
model: modelOverride,
template_name: templateNameInput.trim(),
seat_skill_directive: seatSkillDirectiveInput,
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
});
setAgent(updated);
return updated;
@@ -255,7 +252,6 @@ export default function NewAgentPage() {
model: modelOverride,
template_name: templateNameInput.trim(),
seat_skill_directive: seatSkillDirectiveInput,
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
});
setAgentId(created.id);
setAgent(created);
@@ -284,7 +280,6 @@ export default function NewAgentPage() {
descriptionInput,
modelInput,
selectedSkills,
selectedKnowledgeBaseIds,
soulInput,
templateNameInput,
seatSkillDirectiveInput,
@@ -595,13 +590,6 @@ export default function NewAgentPage() {
</p>
</div>
<LlmWikiKnowledgeSelector
source="all"
value={selectedKnowledgeBaseIds}
onChange={setSelectedKnowledgeBaseIds}
variant="panel"
/>
<div className="space-y-2">
<label className="text-sm font-medium">SOUL.md</label>
<Textarea
@@ -0,0 +1,706 @@
"use client";
/**
* 通知模板管理(仅 admin):创建/维护消息通知模板,支持变量校验与实时预览、
* 乐观锁冲突对比(重新加载 / 覆盖保存)。
*/
import { useEffect, useMemo, useState } from "react";
import { Navigate } from "react-router-dom";
import {
AlertTriangle,
Copy,
FileText,
Loader2,
Pencil,
Plus,
Search,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { getStoredAuth } from "@/core/auth";
import { cn } from "@/lib/utils";
import {
NOTIFICATION_TEMPLATE_VARIABLES,
NotificationTemplateConflictError,
previewNotificationTemplate,
useNotificationTemplateActions,
useNotificationTemplates,
type NotificationTemplate,
type NotificationTemplateInput,
type PreviewResult,
} from "@/strategy-components/api/notification-templates";
interface FormState {
name: string;
titleTemplate: string;
contentTemplate: string;
tagsText: string;
enabled: boolean;
}
interface ConflictState {
server: NotificationTemplate;
mine: FormState;
}
const emptyForm = (): FormState => ({
name: "",
titleTemplate: "",
contentTemplate: "",
tagsText: "",
enabled: true,
});
function formFromTemplate(t: NotificationTemplate): FormState {
return {
name: t.name,
titleTemplate: t.titleTemplate,
contentTemplate: t.contentTemplate,
tagsText: (t.tags ?? []).join(", "),
enabled: t.enabled,
};
}
function parseTags(text: string): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const part of text.split(/[,,]/)) {
const tag = part.trim();
if (!tag || seen.has(tag)) continue;
seen.add(tag);
out.push(tag);
}
return out;
}
function toInput(form: FormState): NotificationTemplateInput {
return {
name: form.name,
titleTemplate: form.titleTemplate,
contentTemplate: form.contentTemplate,
tags: parseTags(form.tagsText),
enabled: form.enabled,
};
}
const DEFAULT_PREVIEW_VARS: Record<string, string> = {
userName: "张三",
taskName: "示例任务",
taskId: "T-001",
dueDate: "2026-08-10",
};
export default function NotificationTemplatesPage() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const actions = useNotificationTemplateActions();
const [keyword, setKeyword] = useState("");
const [keywordApplied, setKeywordApplied] = useState("");
const [enabledFilter, setEnabledFilter] = useState<"all" | "true" | "false">("all");
const [tagFilter, setTagFilter] = useState("");
const [page, setPage] = useState(1);
const pageSize = 10;
const listParams = useMemo(
() => ({
keyword: keywordApplied || undefined,
enabled:
enabledFilter === "all" ? null : enabledFilter === "true" ? true : false,
tag: tagFilter.trim() || undefined,
page,
pageSize,
}),
[keywordApplied, enabledFilter, tagFilter, page, pageSize],
);
const { data, isLoading, isError, error, refetch, isFetching } =
useNotificationTemplates(listParams);
const items = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<NotificationTemplate | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [saveError, setSaveError] = useState<string | null>(null);
const [conflict, setConflict] = useState<ConflictState | null>(null);
const [deleteTarget, setDeleteTarget] = useState<NotificationTemplate | null>(null);
const [previewVars, setPreviewVars] = useState(DEFAULT_PREVIEW_VARS);
const [preview, setPreview] = useState<PreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const saving =
actions.create.isPending || actions.update.isPending || actions.duplicate.isPending;
// 实时预览(编辑弹窗打开时)
useEffect(() => {
if (!dialogOpen) return;
let cancelled = false;
const timer = window.setTimeout(() => {
setPreviewLoading(true);
void previewNotificationTemplate({
titleTemplate: form.titleTemplate,
contentTemplate: form.contentTemplate,
variables: previewVars,
})
.then((r) => {
if (!cancelled) setPreview(r);
})
.catch(() => {
if (!cancelled) setPreview(null);
})
.finally(() => {
if (!cancelled) setPreviewLoading(false);
});
}, 280);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [dialogOpen, form.titleTemplate, form.contentTemplate, previewVars]);
if (!isAdmin) {
return <Navigate to="/page/workspace/chats/new" replace />;
}
const openCreate = () => {
setEditing(null);
setForm(emptyForm());
setSaveError(null);
setConflict(null);
setDialogOpen(true);
};
const openEdit = (t: NotificationTemplate) => {
setEditing(t);
setForm(formFromTemplate(t));
setSaveError(null);
setConflict(null);
setDialogOpen(true);
};
const applySearch = () => {
setPage(1);
setKeywordApplied(keyword.trim());
};
const handleSave = async (opts?: { forceVersion?: number }) => {
setSaveError(null);
const input = toInput(form);
try {
if (editing) {
const version = opts?.forceVersion ?? editing.version;
const updated = await actions.update.mutateAsync({
id: editing.id,
input: { ...input, expectedVersion: version },
});
setEditing(updated);
setConflict(null);
toast.success("模板已保存");
setDialogOpen(false);
} else {
await actions.create.mutateAsync(input);
toast.success("模板已创建");
setDialogOpen(false);
}
} catch (e) {
if (e instanceof NotificationTemplateConflictError) {
setConflict({ server: e.server, mine: form });
setSaveError("版本冲突:服务器上的模板已被他人更新,请选择重新加载或覆盖保存。");
return;
}
const msg = e instanceof Error ? e.message : "保存失败";
setSaveError(msg);
toast.error(msg);
}
};
const handleReloadFromServer = () => {
if (!conflict) return;
setEditing(conflict.server);
setForm(formFromTemplate(conflict.server));
setConflict(null);
setSaveError(null);
toast.info("已加载服务器最新版本");
};
const handleForceOverwrite = async () => {
if (!conflict) return;
await handleSave({ forceVersion: conflict.server.version });
};
const handleToggle = async (t: NotificationTemplate) => {
try {
await actions.update.mutateAsync({
id: t.id,
input: {
name: t.name,
titleTemplate: t.titleTemplate,
contentTemplate: t.contentTemplate,
tags: t.tags,
enabled: !t.enabled,
expectedVersion: t.version,
},
});
toast.success(t.enabled ? "已停用" : "已启用");
} catch (e) {
if (e instanceof NotificationTemplateConflictError) {
toast.error("版本冲突,请刷新列表后重试");
void refetch();
return;
}
toast.error(e instanceof Error ? e.message : "启停失败");
}
};
const handleDuplicate = async (t: NotificationTemplate) => {
try {
await actions.duplicate.mutateAsync(t.id);
toast.success("已复制");
} catch (e) {
toast.error(e instanceof Error ? e.message : "复制失败");
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await actions.remove.mutateAsync(deleteTarget.id);
toast.success("已删除");
setDeleteTarget(null);
if (items.length <= 1 && page > 1) setPage((p) => p - 1);
} catch (e) {
toast.error(e instanceof Error ? e.message : "删除失败");
}
};
const insertVariable = (name: string, field: "titleTemplate" | "contentTemplate") => {
const token = `{{${name}}}`;
setForm((f) => ({ ...f, [field]: `${f[field]}${token}` }));
};
return (
<div className="mx-auto flex h-full w-full max-w-6xl flex-col gap-4 p-6">
<header className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
<FileText className="size-5" />
通知模板管理
</h1>
<p className="mt-1 text-sm text-muted-foreground">
维护消息通知的标题/正文模板。支持变量:
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
<code key={v} className="mx-1 rounded bg-muted px-1.5 py-0.5 text-xs">
{`{{${v}}}`}
</code>
))}
</p>
</div>
<Button onClick={openCreate}>
<Plus className="size-4" />
新建模板
</Button>
</header>
<div className="flex flex-wrap items-end gap-3 rounded-lg border bg-card p-3">
<div className="min-w-[200px] flex-1">
<label className="mb-1 block text-xs text-muted-foreground">关键字</label>
<div className="flex gap-2">
<Input
value={keyword}
placeholder="搜索名称 / 标题 / 正文"
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") applySearch();
}}
/>
<Button variant="secondary" onClick={applySearch}>
<Search className="size-4" />
</Button>
</div>
</div>
<div className="w-36">
<label className="mb-1 block text-xs text-muted-foreground">启用状态</label>
<Select
value={enabledFilter}
onValueChange={(v) => {
setEnabledFilter(v as "all" | "true" | "false");
setPage(1);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部</SelectItem>
<SelectItem value="true">已启用</SelectItem>
<SelectItem value="false">已停用</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-40">
<label className="mb-1 block text-xs text-muted-foreground">标签</label>
<Input
value={tagFilter}
placeholder="精确匹配标签"
onChange={(e) => {
setTagFilter(e.target.value);
setPage(1);
}}
/>
</div>
</div>
{isError ? (
<div className="flex flex-col items-center gap-3 rounded-lg border border-destructive/40 bg-destructive/5 px-6 py-12 text-center">
<AlertTriangle className="size-8 text-destructive" />
<p className="text-sm text-destructive">
{error instanceof Error && /403|仅限管理员|Forbidden/i.test(error.message)
? "无权限访问通知模板管理"
: error instanceof Error
? error.message
: "加载失败"}
</p>
<Button variant="outline" onClick={() => void refetch()}>
重试
</Button>
</div>
) : isLoading ? (
<div className="flex items-center justify-center gap-2 py-20 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
加载中…
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed px-6 py-16 text-center">
<FileText className="size-10 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">暂无通知模板</p>
<Button onClick={openCreate}>
<Plus className="size-4" />
创建第一个模板
</Button>
</div>
) : (
<div className={cn("overflow-hidden rounded-lg border", isFetching && "opacity-70")}>
<table className="w-full text-sm">
<thead className="bg-muted/50 text-left text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 font-medium">名称</th>
<th className="px-3 py-2 font-medium">标题模板</th>
<th className="px-3 py-2 font-medium">标签</th>
<th className="px-3 py-2 font-medium">版本</th>
<th className="px-3 py-2 font-medium">启用</th>
<th className="px-3 py-2 font-medium text-right">操作</th>
</tr>
</thead>
<tbody>
{items.map((t) => (
<tr key={t.id} className="border-t">
<td className="px-3 py-2.5 font-medium">{t.name}</td>
<td className="max-w-[240px] truncate px-3 py-2.5 text-muted-foreground">
{t.titleTemplate}
</td>
<td className="px-3 py-2.5">
<div className="flex flex-wrap gap-1">
{t.tags.length === 0 ? (
<span className="text-xs text-muted-foreground">—</span>
) : (
t.tags.map((tag) => (
<span
key={tag}
className="rounded bg-muted px-1.5 py-0.5 text-xs"
>
{tag}
</span>
))
)}
</div>
</td>
<td className="px-3 py-2.5 text-muted-foreground">v{t.version}</td>
<td className="px-3 py-2.5">
<Switch checked={t.enabled} onCheckedChange={() => void handleToggle(t)} />
</td>
<td className="px-3 py-2.5">
<div className="flex justify-end gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => openEdit(t)}
title="编辑"
>
<Pencil className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => void handleDuplicate(t)}
title="复制"
>
<Copy className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => setDeleteTarget(t)}
title="删除"
>
<Trash2 className="size-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
<div className="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground">
<span>
共 {total} 条 · 第 {page}/{totalPages} 页
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
上一页
</Button>
<Button
size="sm"
variant="outline"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
</div>
)}
{/* 新建 / 编辑 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[92vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? "编辑通知模板" : "新建通知模板"}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs text-muted-foreground">名称</label>
<Input
value={form.name}
maxLength={50}
placeholder="2~50 个字符"
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">标题模板</label>
<Input
value={form.titleTemplate}
maxLength={120}
placeholder="如:任务 {{taskName}} 即将到期"
onChange={(e) => setForm((f) => ({ ...f, titleTemplate: e.target.value }))}
/>
<div className="mt-1 flex flex-wrap gap-1">
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
<button
key={v}
type="button"
className="rounded border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted"
onClick={() => insertVariable(v, "titleTemplate")}
>
{`{{${v}}}`}
</button>
))}
</div>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">正文模板</label>
<Textarea
value={form.contentTemplate}
rows={7}
maxLength={5000}
placeholder="支持 {{userName}} / {{taskName}} / {{taskId}} / {{dueDate}}"
onChange={(e) => setForm((f) => ({ ...f, contentTemplate: e.target.value }))}
/>
<div className="mt-1 flex flex-wrap gap-1">
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
<button
key={v}
type="button"
className="rounded border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted"
onClick={() => insertVariable(v, "contentTemplate")}
>
{`{{${v}}}`}
</button>
))}
</div>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">
标签(逗号分隔,最多 5 个)
</label>
<Input
value={form.tagsText}
placeholder="提醒, 任务"
onChange={(e) => setForm((f) => ({ ...f, tagsText: e.target.value }))}
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={form.enabled}
onCheckedChange={(v) => setForm((f) => ({ ...f, enabled: v }))}
/>
<span className="text-sm">启用</span>
{editing && (
<span className="ml-auto text-xs text-muted-foreground">
当前编辑版本 v{editing.version}
</span>
)}
</div>
</div>
<div className="space-y-3 rounded-lg border bg-muted/20 p-3">
<div className="text-sm font-medium">实时预览</div>
<div className="grid grid-cols-2 gap-2">
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
<div key={v}>
<label className="mb-0.5 block text-[11px] text-muted-foreground">{v}</label>
<Input
value={previewVars[v] ?? ""}
onChange={(e) =>
setPreviewVars((prev) => ({ ...prev, [v]: e.target.value }))
}
/>
</div>
))}
</div>
<div className="rounded border bg-background p-3 text-sm">
{previewLoading && !preview ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
预览中…
</div>
) : (
<>
<div className="mb-2 font-medium">{preview?.title || "(标题预览)"}</div>
<div className="whitespace-pre-wrap text-muted-foreground">
{preview?.content || "(正文预览)"}
</div>
{preview && preview.missingVariables.length > 0 && (
<p className="mt-2 text-xs text-amber-600">
缺少变量:{preview.missingVariables.join(", ")}(保留原占位符)
</p>
)}
</>
)}
</div>
</div>
</div>
{saveError && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{saveError}
</div>
)}
{conflict && (
<div className="space-y-3 rounded-md border border-amber-500/40 bg-amber-500/5 p-3">
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 dark:text-amber-400">
<AlertTriangle className="size-4" />
版本冲突 — 请选择如何处理
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded border bg-background p-2 text-xs">
<div className="mb-1 font-medium">服务器最新版本 (v{conflict.server.version})</div>
<div className="text-muted-foreground">名称:{conflict.server.name}</div>
<div className="text-muted-foreground">标题:{conflict.server.titleTemplate}</div>
<div className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap text-muted-foreground">
{conflict.server.contentTemplate}
</div>
</div>
<div className="rounded border bg-background p-2 text-xs">
<div className="mb-1 font-medium">我的编辑内容</div>
<div className="text-muted-foreground">名称:{conflict.mine.name}</div>
<div className="text-muted-foreground">标题:{conflict.mine.titleTemplate}</div>
<div className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap text-muted-foreground">
{conflict.mine.contentTemplate}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={handleReloadFromServer}>
重新加载
</Button>
<Button variant="destructive" disabled={saving} onClick={() => void handleForceOverwrite()}>
覆盖保存
</Button>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button disabled={saving || !!conflict} onClick={() => void handleSave()}>
{saving && <Loader2 className="size-4 animate-spin" />}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认 */}
<Dialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>确认删除</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
确定删除模板「{deleteTarget?.name}」?此操作不可恢复。
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
取消
</Button>
<Button
variant="destructive"
disabled={actions.remove.isPending}
onClick={() => void handleDelete()}
>
{actions.remove.isPending && <Loader2 className="size-4 animate-spin" />}
确认删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1,250 +0,0 @@
"use client";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { BotIcon, PlusSquare } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
PromptInput,
PromptInputProvider,
type PromptInputMessage,
PromptInputBody,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
} from "@/components/ai-elements/prompt-input";
import { useThreadChat } from "@/components/workspace/chats";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { Button } from "@/components/ui/button";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { SENTIMENT_AGENT_ID, isSentimentAgentConfigured } from "@/core/sentiment-agent/config";
import { readSentimentAgentProfile } from "@/core/sentiment-agent/profile";
import { useSentimentAgentChat } from "@/core/sentiment-agent/use-sentiment-agent-chat";
import { SubtasksProvider } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads";
import { useRouteBase } from "@/core/route-base";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/**
* 舆情分析智能体对话页:一个前端虚拟智能体(不在后端 /api/agents 落库),
* 问答直连外部 AG-UI 网关(地址 + Basic Auth 在 runtime-config.js 配置)。
* 页面复用普通问答的消息渲染(MessageList),输入框只保留发送按钮;
* 会话只存在当前标签页 sessionStorage,不创建后端 thread。
*/
export default function SentimentAgentChatPage() {
// 与 WorkspaceRoutes 的 ChatRuntime 相同的 Provider 组合(这里内联以避免
// 页面与路由文件互相导入形成环),使精简输入框仍具备普通问答的一致键盘行为。
return (
<SubtasksProvider>
<ArtifactsProvider>
<PromptInputProvider>
<SentimentAgentChatPageInner />
</PromptInputProvider>
</ArtifactsProvider>
</SubtasksProvider>
);
}
function SentimentAgentChatPageInner() {
const router = useRouter();
const routeBase = useRouteBase();
const chatsBase = `${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats`;
const { threadId, isNewThread, setIsNewThread } = useThreadChat();
const chat = useSentimentAgentChat(threadId);
const [profile, setProfile] = useState(() => readSentimentAgentProfile());
const configured = isSentimentAgentConfigured();
// 编辑弹窗可能挂在管理页 / 本页两处,保存后广播事件,这里即时刷新标题与简介。
useEffect(() => {
const onChange = () => setProfile(readSentimentAgentProfile());
window.addEventListener("sentiment-agent-profile-changed", onChange);
return () =>
window.removeEventListener("sentiment-agent-profile-changed", onChange);
}, []);
const displayName = desensitize(profile.name);
// 合成一个 MessageList 需要的 BaseStream:消息与加载态全部来自前端流式
// hook(参照 ChatPage 对单轮 RAG displayThread 的合成方式)。
const displayThread = useMemo(
() =>
({
messages: chat.messages,
values: { messages: chat.messages, artifacts: [] },
isLoading: chat.isLoading,
isThreadLoading: false,
error: chat.error,
stop: () => chat.stop(),
submit: async () => undefined,
joinStream: async () => undefined,
}) as unknown as BaseStream<AgentThreadState>,
[chat.error, chat.isLoading, chat.messages, chat.stop],
);
const status = chat.error
? "error"
: chat.isLoading
? "streaming"
: "ready";
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
const text = message.text.trim();
if (!text || chat.isLoading) return;
if (isNewThread) {
// 首条消息发出后把 /new 换成本地会话 id,刷新后仍能恢复当前对话。
// 这里必须经 HashRouter 导航;直接 history.replaceState 会在 URL 的
// hash 内再拼一个 `#`,导致刷新或后续导航命中错误的通用智能体路由。
setIsNewThread(false);
router.replace(`${chatsBase}/${threadId}`);
}
void chat.submit({ query: text });
},
[chat, chatsBase, isNewThread, router, setIsNewThread, threadId],
);
const handleStop = useCallback(() => chat.stop(), [chat]);
const isLandingState = chat.messages.length === 0;
const composer = (autoFocus: boolean) => (
<div className="w-full">
<SentimentComposer
status={status}
autoFocus={autoFocus}
onSubmit={handleSubmit}
onStop={handleStop}
/>
{!configured && (
<p className="text-destructive mt-2 text-center text-xs">
尚未配置舆情分析接口:请在部署目录的 runtime-config.js 里填写
VITE_SENTIMENT_AGENT_API_URL 及鉴权用户名 / 密码后刷新页面。
</p>
)}
</div>
);
return (
// isMock=true:会话完全在前端(无后端 thread),借此关掉消息上的
// 「删除」按钮 —— 它会去调后端 thread 删除接口,对虚拟会话没有意义。
<ThreadContext.Provider value={{ thread: displayThread, isMock: true }}>
<div className="workspace-chat-bg flex size-full min-h-0 overflow-hidden">
<main className="relative flex size-full min-h-0 min-w-0 flex-col">
<header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span
className="max-w-[200px] truncate text-xs font-medium"
title={displayName}
>
{displayName}
</span>
</div>
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
<span className="text-muted-foreground max-w-[260px] truncate text-xs font-normal">
{desensitize(profile.description)}
</span>
</div>
<div className="flex items-center">
<Button
size="sm"
variant="secondary"
onClick={() => router.push(`${chatsBase}/new`)}
>
<PlusSquare /> 新对话
</Button>
</div>
</header>
{isLandingState ? (
/* 落地态:名称 + 简介 + 输入框整体居中(普通问答落地页样式)。 */
<div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-6 pb-10 pt-16 sm:pt-24">
<div className="mb-7 flex flex-col items-center gap-3 text-center">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full dark:bg-[#022248]">
<BotIcon className="text-primary h-7 w-7" />
</div>
<h1 className="text-2xl font-semibold">{displayName}</h1>
<p className="text-muted-foreground max-w-xl text-sm leading-6">
{desensitize(profile.description)}
</p>
</div>
<div className="w-full max-w-(--container-width-md)">
{composer(true)}
</div>
</div>
) : (
/* 对话态:消息列表滚动,输入框停靠在主列底部。 */
<div className="flex min-h-0 flex-1 flex-col">
<MessageList
className="min-h-0 flex-1"
threadId={threadId}
thread={displayThread}
paddingBottom={24}
scrollButtonClassName="bottom-2"
onEditResubmit={(text) =>
handleSubmit({ text, files: [] })
}
/>
<div className="from-background pointer-events-none shrink-0 bg-gradient-to-t to-transparent px-4 pb-4 pt-2">
<div className="pointer-events-auto mx-auto w-full max-w-(--container-width-lg)">
{composer(false)}
</div>
</div>
</div>
)}
</main>
</div>
</ThreadContext.Provider>
);
}
/**
* 精简输入框:只有文本域 + 发送/停止按钮(流式中点击变为停止),
* 不带附件、模式、模型选择等普通问答输入框的其余能力。
*/
function SentimentComposer({
status,
autoFocus,
onSubmit,
onStop,
}: {
status: "error" | "streaming" | "ready";
autoFocus?: boolean;
onSubmit: (message: PromptInputMessage) => void;
onStop: () => void;
}) {
return (
<PromptInput
className={cn(
"w-full *:data-[slot='input-group']:rounded-3xl",
"rounded-3xl border border-border bg-background shadow-sm transition-shadow hover:shadow-md focus-within:border-ring/40 focus-within:shadow-md focus-within:ring-1 focus-within:ring-ring/20 dark:border-slate-700 dark:bg-slate-800",
)}
onSubmit={(message) => {
if (status === "streaming") {
onStop();
return;
}
onSubmit(message);
}}
>
<PromptInputBody className="px-2 pt-2">
<PromptInputTextarea
className="min-h-16 px-2 py-1 text-[15px] leading-7"
placeholder="请输入要分析的舆情问题,例如:分析近三个月美伊局势"
aria-label="舆情分析问题"
autoFocus={autoFocus}
/>
</PromptInputBody>
<PromptInputFooter className="flex items-center justify-end px-2 pt-1 pb-1.5">
<PromptInputSubmit
className="rounded-full border-border bg-background shadow-sm hover:bg-muted dark:border-blue-500 dark:bg-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none"
variant="outline"
status={status}
/>
</PromptInputFooter>
</PromptInput>
);
}
+11 -25
View File
@@ -3,7 +3,6 @@ import { Toaster } from "sonner";
import { SidebarProvider } from "@/components/ui/sidebar";
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
import { RouteBaseProvider } from "@/core/route-base";
import GeneralQA from "@/strategy-components/components/chat-qa/GeneralQA";
import SentimentAnalyzes from "@/strategy-components/components/task-components/SentimentAnalyzes";
@@ -25,12 +24,10 @@ import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanni
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage";
import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage";
import DeepResearchPage from "./DeepResearchPage";
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
import AgentChatPage from "./AgentChatPage";
import SentimentAgentChatPage from "./SentimentAgentChatPage";
import { AgentModuleRoutes } from "./AgentModulePage";
import { AGENT_MODULES } from "@/components/workspace/agents/agent-module-sidebar";
import AgentsPage from "./AgentsPage";
@@ -48,6 +45,7 @@ import LoginWhitelistPage from "./LoginWhitelistPage";
import SensitiveWordsPage from "./SensitiveWordsPage";
import SkillAddressesPage from "./SkillAddressesPage";
import TaskButtonsPage from "./TaskButtonsPage";
import NotificationTemplatesPage from "./NotificationTemplatesPage";
import PositionRolesPage from "./PositionRolesPage";
import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
@@ -116,7 +114,6 @@ export default function StrategyRoutes() {
</ChatRuntime>
}
/>
<Route path="qa/deep-research" element={<DeepResearchPage />} />
{/* 大屏绘制智能体独立页面(按 taskId 存取;ChatRuntime 提供 ArtifactsProvider 给沙箱预览) */}
<Route
@@ -313,27 +310,6 @@ export default function StrategyRoutes() {
<Route path="agents" element={<AgentsPage />} />
<Route path="agents/new" element={<NewAgentPage />} />
{/* 舆情分析虚拟智能体:前端直连外部 AG-UI 网关的专用对话页。 */}
<Route
path="agents/sentiment-analysis/chats"
element={
isSentimentAgentVisible() ? (
<Navigate to="/page/strategy/agents/sentiment-analysis/chats/new" replace />
) : (
<Navigate to="/page/strategy/agents" replace />
)
}
/>
<Route
path="agents/sentiment-analysis/chats/:thread_id"
element={
isSentimentAgentVisible() ? (
<SentimentAgentChatPage />
) : (
<Navigate to="/page/strategy/agents" replace />
)
}
/>
<Route
path="agents/:agent_id/chats"
element={<Navigate to="/page/strategy/agents" replace />}
@@ -463,6 +439,16 @@ export default function StrategyRoutes() {
)
}
/>
<Route
path="admin/notification-templates"
element={
getStoredAuth()?.system_role === "admin" ? (
<NotificationTemplatesPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/position-roles"
element={
+8 -60
View File
@@ -2,7 +2,6 @@ import type { CSSProperties, ReactNode } from 'react';
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { getStoredAuth } from '@/core/auth';
import { isSentimentAgentVisible } from '@/core/sentiment-agent/config';
import {
resolveChatsIframeEntryRedirect,
resolveStandardChatFallback,
@@ -22,7 +21,6 @@ import { CommandPalette } from '@/components/workspace/command-palette';
import { Toaster } from 'sonner';
import { SubtasksProvider } from '@/core/tasks/context';
import { ArtifactsProvider } from '@/components/workspace/artifacts';
import { DocumentRewritePresentationProvider } from '@/core/document-rewrite/context';
import { RouteBaseProvider } from '@/core/route-base';
@@ -31,21 +29,20 @@ import ChatPage from './ChatPage';
import IframeChatPage from './IframeChatPage';
import AgentsPage from './AgentsPage';
import AgentChatPage from './AgentChatPage';
import SentimentAgentChatPage from './SentimentAgentChatPage';
import NewAgentPage from './NewAgentPage';
import AdminAgentsPage from './AdminAgentsPage';
import AdminScheduledTasksPage from './AdminScheduledTasksPage';
import AdminSkillsPage from './AdminSkillsPage';
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
import ScheduledTasksPage from './ScheduledTasksPage';
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
import KnowledgeBasePage from './KnowledgeBasePage';
import KnowledgeGraphPage from './KnowledgeGraphPage';
import LlmMetricsPage from './LlmMetricsPage';
import CheckpointMigrationPage from './CheckpointMigrationPage';
import MemoryPage from './MemoryPage';
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
import ModelConcurrencySettingsPage from './ModelConcurrencySettingsPage';
import ModelManagementPage from './ModelManagementPage';
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
import SkillEvolutionPage from './SkillEvolutionPage';
@@ -60,7 +57,6 @@ import PositionRolesPage from './PositionRolesPage';
import PluginManagementPage from './PluginManagementPage';
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import DeepResearchPage from './DeepResearchPage';
import StudioNotebooksPage from './StudioNotebooksPage';
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
@@ -213,11 +209,9 @@ export function WorkspaceLayout({
export function ChatRuntime({ children }: { children: ReactNode }) {
return (
<SubtasksProvider>
<DocumentRewritePresentationProvider>
<ArtifactsProvider>
<PromptInputProvider>{children}</PromptInputProvider>
</ArtifactsProvider>
</DocumentRewritePresentationProvider>
<ArtifactsProvider>
<PromptInputProvider>{children}</PromptInputProvider>
</ArtifactsProvider>
</SubtasksProvider>
);
}
@@ -473,30 +467,6 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
{/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走前端直连外部
AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */}
<Route
path="agents/sentiment-analysis/chats"
element={
isSentimentAgentVisible() ? (
<Navigate to="/page/workspace/agents/sentiment-analysis/chats/new" replace />
) : (
<Navigate to="/page/workspace/agents" replace />
)
}
/>
<Route
path="agents/sentiment-analysis/chats/:thread_id"
element={
isSentimentAgentVisible() ? (
<WorkspaceLayout {...layoutProps}>
<SentimentAgentChatPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/agents" replace />
)
}
/>
<Route
path="agents/:agent_id/chats"
element={<Navigate to="/page/workspace/agents" replace />}
@@ -551,16 +521,6 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="deep-research"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<DeepResearchPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="studio/notebooks"
element={
@@ -644,7 +604,7 @@ export default function WorkspaceRoutes({
path="knowledge"
element={
<WorkspaceLayout {...layoutProps}>
<LlmWikiKnowledgePage />
<KnowledgeBasePage />
</WorkspaceLayout>
}
/>
@@ -652,7 +612,7 @@ export default function WorkspaceRoutes({
path="knowledge/graph"
element={
<WorkspaceLayout {...layoutProps}>
<LlmWikiKnowledgePage mode="graph" />
<KnowledgeGraphPage />
</WorkspaceLayout>
}
/>
@@ -660,7 +620,7 @@ export default function WorkspaceRoutes({
path="knowledge/:noteId"
element={
<WorkspaceLayout {...layoutProps}>
<LlmWikiKnowledgePage mode="detail" />
<KnowledgeBasePage />
</WorkspaceLayout>
}
/>
@@ -826,18 +786,6 @@ export default function WorkspaceRoutes({
)
}
/>
<Route
path="admin/models"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<ModelManagementPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/model-concurrency"
element={
File diff suppressed because it is too large. Load diff
@@ -1,358 +0,0 @@
"use client";
/**
* 报告配置卡片(用户协助卡片)。
*
* 对话式收集完成后弹出:预填原模式下拉(快速/标准/详细/深度/多智能体)与
* 原高级配置对话框的全部选项(语言/语气/结构/配图/预算等),由用户确认后
* 以 chat 入口启动报告写作 job —— 保留旧输入框全部能力,改造收敛在这一张卡片。
*/
import { useEffect, useMemo, useState } from "react";
import {
ChevronRight,
ImagePlus,
Loader2,
MessageSquarePlus,
SlidersHorizontal,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
type ResearchMode,
type ResearchTone,
} from "@/strategy-components/api/deep-research";
const MODE_LABELS: Record<ResearchMode, string> = {
basic: "标准",
quick: "快速",
detailed: "详细",
deep: "深度",
multi_agent: "多智能体",
};
const MODE_HINTS: Record<ResearchMode, string> = {
basic: "完整单遍写作,结构与篇幅均衡",
quick: "短报告单遍成文,最快出结果",
detailed: "按主题分节综合,篇幅更长",
deep: "迭代深挖补充检索后再成文",
multi_agent: "分节撰写并附加修订审稿轮",
};
export type AdvancedResearchOptions = {
language: "zh-CN" | "en-US";
tone: ResearchTone;
maxSearchResults: string;
maxIterations: string;
maxConcurrency: string;
maxContextWords: string;
maxSubtopics: string;
deepBreadth: string;
deepDepth: string;
maxRevisionLoops: string;
maxGeneratedImages: string;
thinkingEnabled: boolean;
customOutline: string;
};
export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
language: "zh-CN",
tone: "objective",
maxSearchResults: "8",
maxIterations: "3",
maxConcurrency: "3",
maxContextWords: "24000",
maxSubtopics: "8",
deepBreadth: "4",
deepDepth: "2",
maxRevisionLoops: "2",
maxGeneratedImages: "2",
thinkingEnabled: false,
customOutline: "",
};
/** Seed the card's editable options from the frozen session config snapshot. */
export function advancedOptionsFromConfig(config?: Record<string, unknown>): AdvancedResearchOptions {
const numberish = (key: string, fallback: string): string => {
const value = config?.[key];
return typeof value === "number" && Number.isFinite(value) ? String(value) : fallback;
};
return {
language: config?.language === "en-US" ? "en-US" : "zh-CN",
tone: (typeof config?.tone === "string" ? config.tone : "objective") as ResearchTone,
maxSearchResults: numberish("max_search_results_per_query", DEFAULT_ADVANCED_OPTIONS.maxSearchResults),
maxIterations: numberish("max_iterations", DEFAULT_ADVANCED_OPTIONS.maxIterations),
maxConcurrency: numberish("max_concurrency", DEFAULT_ADVANCED_OPTIONS.maxConcurrency),
maxContextWords: numberish("max_context_words", DEFAULT_ADVANCED_OPTIONS.maxContextWords),
maxSubtopics: numberish("max_subtopics", DEFAULT_ADVANCED_OPTIONS.maxSubtopics),
deepBreadth: numberish("deep_breadth", DEFAULT_ADVANCED_OPTIONS.deepBreadth),
deepDepth: numberish("deep_depth", DEFAULT_ADVANCED_OPTIONS.deepDepth),
maxRevisionLoops: numberish("max_revision_loops", DEFAULT_ADVANCED_OPTIONS.maxRevisionLoops),
maxGeneratedImages: numberish("max_generated_images", DEFAULT_ADVANCED_OPTIONS.maxGeneratedImages),
thinkingEnabled: config?.thinking_enabled === true,
customOutline: typeof config?.custom_outline === "string" ? config.custom_outline : "",
};
}
function boundedInteger(value: string, fallback: number, min: number, max: number): number {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
}
/** The card's choices as the job config override sent with entry:"chat". */
export function buildReportCardConfig(
mode: ResearchMode,
generateImages: boolean,
options: AdvancedResearchOptions,
): Record<string, unknown> {
const config: Record<string, unknown> = {
mode,
generate_images: generateImages,
language: options.language,
tone: options.tone,
thinking_enabled: options.thinkingEnabled,
max_search_results_per_query: boundedInteger(options.maxSearchResults, 8, 1, 20),
max_iterations: boundedInteger(options.maxIterations, 3, 1, 10),
max_concurrency: boundedInteger(options.maxConcurrency, 3, 1, 8),
max_context_words: boundedInteger(options.maxContextWords, 24_000, 500, 120_000),
};
if (mode === "detailed" || mode === "multi_agent") {
config.max_subtopics = boundedInteger(options.maxSubtopics, 8, 1, 20);
}
if (mode === "deep") {
config.deep_breadth = boundedInteger(options.deepBreadth, 4, 1, 8);
config.deep_depth = boundedInteger(options.deepDepth, 2, 1, 4);
}
if (mode === "multi_agent") {
config.max_revision_loops = boundedInteger(options.maxRevisionLoops, 2, 0, 5);
}
if (generateImages) config.max_generated_images = boundedInteger(options.maxGeneratedImages, 2, 1, 4);
if (options.customOutline.trim()) config.custom_outline = options.customOutline.trim().slice(0, 4_000);
return config;
}
export function ReportConfigCard({
availableModes,
defaultMode,
defaultOptions,
defaultGenerateImages,
modelLabel,
materialCount,
imageGenerationAvailable,
imageLimit,
submitting,
onConfirm,
onContinueCollecting,
}: {
availableModes: ResearchMode[];
defaultMode: ResearchMode;
defaultOptions: AdvancedResearchOptions;
defaultGenerateImages: boolean;
modelLabel: string;
/** Number of unique, harvestable materials visible in the collector thread. */
materialCount: number;
imageGenerationAvailable: boolean;
imageLimit: number;
submitting: boolean;
onConfirm: (config: Record<string, unknown>) => void;
onContinueCollecting: () => void;
}) {
const [mode, setMode] = useState<ResearchMode>(
availableModes.includes(defaultMode) ? defaultMode : (availableModes[0] ?? "basic"),
);
const [options, setOptions] = useState<AdvancedResearchOptions>(defaultOptions);
const [generateImages, setGenerateImages] = useState(defaultGenerateImages);
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => {
setOptions(defaultOptions);
setGenerateImages(defaultGenerateImages);
}, [defaultOptions, defaultGenerateImages]);
const modes = useMemo(
() => (availableModes.length ? availableModes : (["basic", "quick", "detailed", "deep", "multi_agent"] as ResearchMode[])),
[availableModes],
);
return (
<div className="rounded-2xl rounded-tl-md border border-primary/25 bg-card p-4 shadow-sm">
<div className="flex items-start gap-2.5">
<div className="mt-0.5 rounded-full bg-primary/10 p-1.5 text-primary">
<Sparkles className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">资料收集完成,请确认报告写法</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
下方选项沿用原研究配置并可调整;确认后将基于以上对话收集到的资料撰写报告。
{modelLabel ? <>写作模型:<span className="font-medium text-foreground">{modelLabel}</span>。</> : null}
</p>
<div className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-primary/15 bg-primary/[0.06] px-2 py-1 text-[11px] text-primary">
<span className="font-medium">已收集 {materialCount} 条资料</span>
<span className="text-primary/70">将作为本次报告的写作依据</span>
</div>
</div>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1.5 block text-xs font-medium">报告模式</span>
<Select value={mode} onValueChange={(value) => setMode(value as ResearchMode)}>
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
{modes.map((item) => (
<SelectItem key={item} value={item}>{MODE_LABELS[item]}</SelectItem>
))}
</SelectContent>
</Select>
<span className="mt-1 block text-[11px] text-muted-foreground">{MODE_HINTS[mode]}</span>
</label>
{imageGenerationAvailable && (
<div>
<span className="mb-1.5 block text-xs font-medium">报告配图</span>
<button
type="button"
aria-pressed={generateImages}
onClick={() => setGenerateImages((value) => !value)}
className={`inline-flex h-9 items-center gap-1.5 rounded-md border px-3 text-sm transition-colors ${generateImages ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-muted-foreground hover:bg-muted"}`}
>
<ImagePlus className="h-3.5 w-3.5" />
{generateImages ? `生成配图(最多 ${imageLimit} 张)` : "不生成配图"}
</button>
</div>
)}
</div>
<div className="mt-3 overflow-hidden rounded-xl border border-border/70">
<button
type="button"
onClick={() => setAdvancedOpen((value) => !value)}
className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-xs font-medium hover:bg-muted/40"
aria-expanded={advancedOpen}
>
<SlidersHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
高级配置
<span className="font-normal text-muted-foreground">语言、语气、自定义结构等</span>
<ChevronRight className={`ml-auto h-3.5 w-3.5 text-muted-foreground transition-transform ${advancedOpen ? "rotate-90" : ""}`} />
</button>
{advancedOpen && (
<div className="grid gap-3 border-t border-border/70 p-3 sm:grid-cols-2">
<CardSelect
label="输出语言"
value={options.language}
onChange={(value) => update("language", value as AdvancedResearchOptions["language"])}
options={[{ value: "zh-CN", label: "中文" }, { value: "en-US", label: "English" }]}
/>
<CardSelect
label="报告语气"
value={options.tone}
onChange={(value) => update("tone", value as ResearchTone)}
options={[
{ value: "objective", label: "客观" },
{ value: "formal", label: "正式" },
{ value: "analytical", label: "分析" },
{ value: "persuasive", label: "论证" },
]}
/>
<CardNumber label="单次查询结果" hint="1–20 条" value={options.maxSearchResults} onChange={(v) => update("maxSearchResults", v)} />
<CardNumber label="上下文词数" hint="500–120000" value={options.maxContextWords} onChange={(v) => update("maxContextWords", v)} />
{(mode === "detailed" || mode === "multi_agent") && (
<CardNumber label="最多子主题" hint="1–20 个" value={options.maxSubtopics} onChange={(v) => update("maxSubtopics", v)} />
)}
{mode === "deep" && (
<>
<CardNumber label="深挖广度" hint="1–8 路" value={options.deepBreadth} onChange={(v) => update("deepBreadth", v)} />
<CardNumber label="深挖层级" hint="1–4 层" value={options.deepDepth} onChange={(v) => update("deepDepth", v)} />
</>
)}
{mode === "multi_agent" && (
<CardNumber label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(v) => update("maxRevisionLoops", v)} />
)}
<div className="sm:col-span-2">
<span className="mb-1.5 block text-xs font-medium">自定义报告结构</span>
<Textarea
value={options.customOutline}
onChange={(event) => update("customOutline", event.target.value)}
maxLength={4000}
className="min-h-20 resize-y text-xs"
placeholder={"例如:\n# 执行摘要\n## 市场与背景\n## 机会与风险\n## 建议"}
/>
<p className="mt-1 text-[10px] text-muted-foreground">支持 Markdown 标题或编号列表,作为报告写作结构要求。</p>
</div>
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4 sm:col-span-2">
<input
type="checkbox"
checked={options.thinkingEnabled}
onChange={(event) => update("thinkingEnabled", event.target.checked)}
className="mt-0.5 accent-primary"
/>
<span>
<span className="block font-medium text-foreground">启用模型深度思考</span>
仅在当前模型支持时生效;会增加写作时延与模型消耗。
</span>
</label>
</div>
)}
</div>
<div className="mt-4 flex flex-wrap justify-end gap-2">
<Button size="sm" variant="outline" onClick={onContinueCollecting} disabled={submitting}>
<MessageSquarePlus className="mr-1.5 h-3.5 w-3.5" />
继续补充资料
</Button>
<Button size="sm" disabled={submitting} onClick={() => onConfirm(buildReportCardConfig(mode, generateImages, options))}>
{submitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
开始撰写报告
</Button>
</div>
</div>
);
function update<K extends keyof AdvancedResearchOptions>(key: K, value: AdvancedResearchOptions[K]) {
setOptions((current) => ({ ...current, [key]: value }));
}
}
function CardSelect({ label, value, onChange, options }: {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
}) {
return (
<label className="block">
<span className="mb-1.5 block text-xs font-medium">{label}</span>
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{options.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}
</SelectContent>
</Select>
</label>
);
}
function CardNumber({ label, hint, value, onChange }: {
label: string;
hint: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<label className="block">
<span className="mb-1.5 block text-xs font-medium">{label}</span>
<Input value={value} inputMode="numeric" onChange={(event) => onChange(event.target.value)} className="h-8 text-xs" />
<span className="mt-0.5 block text-[10px] text-muted-foreground">{hint}</span>
</label>
);
}
@@ -1,197 +0,0 @@
"use client";
/**
* 深度研究沙箱分栏 —— 对齐主聊天 `ChatBox` / 圆桌 Step2 的 60/40 对话 | 产物预览布局。
*
* 深度研究没有 LangGraph SDK 线程承载报告(报告由写作 job 的 SSE 流出),这里用
* `useReportSandbox` 构造的最小 ThreadContext stub 供 `ArtifactFileDetail` 工作:
* 虚拟 `write-file:` URL 直接读 stub tool_call 的 args.content。右侧因此免费获得
* 真沙箱的全部能力(Streamdown Markdown 渲染、流式 code 视图自动滚底、写完自动
* 切预览 + 脉冲、复制/另存、展示层脱敏)。
*/
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { FileTextIcon, XIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
ArtifactFileDetail,
useArtifacts,
} from "@/components/workspace/artifacts";
import { DocumentRewriteDialog } from "@/components/workspace/artifacts/document-rewrite-dialog";
import { ThreadContext } from "@/components/workspace/messages/context";
import type { AgentThreadState } from "@/core/threads";
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
import { cn } from "@/lib/utils";
const CLOSE_MODE = { chat: 100, artifacts: 0 };
const OPEN_MODE = { chat: 60, artifacts: 40 };
interface ResearchSandboxPanelProps {
/** 左栏:对话收集区(MessageList / 卡片区 / 输入框),由宿主传入。 */
children: React.ReactNode;
/** stub 线程 id(与 useReportSandbox 的 threadKey 一致)。 */
threadId: string;
/** useReportSandbox 构造的 stub ai messages(承载 write_file(report.md))。 */
stubMessages: unknown[];
/** 报告是否正在流式写入 —— stub thread 的 isLoading。 */
writing: boolean;
/** 右栏是否展开(宿主导航状态;报告首帧到达时由 hook 自动置开)。 */
open: boolean;
onClose: () => void;
/**
* 覆盖沙箱「下载」按钮的默认行为(深度研究:弹 Markdown / Word 选择弹窗)。
* 不传时沿用 ArtifactFileDetail 内置下载。
*/
onDownload?: () => void;
/** 深度研究报告的全文改写由宿主执行;这里仅提供右侧沙箱入口和配置弹窗。 */
onDocumentRewriteStart?: (input: {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
}) => void;
documentRewriteActive?: boolean;
onCancelDocumentRewrite?: () => void;
reportCharCount?: number;
}
export function ResearchSandboxPanel({
children,
threadId,
stubMessages,
writing,
open,
onClose,
onDownload,
onDocumentRewriteStart,
documentRewriteActive = false,
onCancelDocumentRewrite,
reportCharCount = 0,
}: ResearchSandboxPanelProps) {
const layoutRef = useRef<GroupImperativeHandle>(null);
const { selectedArtifact, setOpen, deselect } = useArtifacts();
const [rewriteDialogOpen, setRewriteDialogOpen] = useState(false);
useEffect(() => {
if (!layoutRef.current) return;
layoutRef.current.setLayout(open ? OPEN_MODE : CLOSE_MODE);
}, [open]);
const threadStub = useMemo(
() =>
({
messages: stubMessages,
isLoading: writing,
isThreadLoading: false,
values: {
title: "",
messages: stubMessages,
artifacts: [],
},
}) as unknown as BaseStream<AgentThreadState>,
[stubMessages, writing],
);
const previewThreadId = threadId || "";
const handleArtifactsClose = () => {
deselect();
setOpen(false);
onClose();
};
return (
<ThreadContext.Provider value={{ thread: threadStub, isMock: false }}>
<ResizablePanelGroup
id="deep-research-panels"
orientation="horizontal"
defaultLayout={CLOSE_MODE}
groupRef={layoutRef}
className="h-full min-h-0 flex-1"
>
<ResizablePanel className="relative" defaultSize={100} id="chat">
{children}
</ResizablePanel>
<ResizableHandle
id="deep-research-separator"
className={cn(
"opacity-33 hover:opacity-100",
!open && "pointer-events-none opacity-0",
)}
/>
<ResizablePanel
className={cn(
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
!open && "opacity-0",
)}
id="artifacts"
>
<div className="relative h-full bg-[#fffdfa] p-4">
{selectedArtifact && previewThreadId ? (
<>
{onDocumentRewriteStart && (
<div className="absolute top-7 right-36 z-30">
<Button
size="sm"
variant="outline"
disabled={documentRewriteActive}
onClick={() => setRewriteDialogOpen(true)}
>
{documentRewriteActive ? "正在改写" : "AI 全文改写"}
</Button>
</div>
)}
{documentRewriteActive && onCancelDocumentRewrite && (
<div className="absolute right-36 bottom-7 z-30">
<Button size="sm" variant="outline" onClick={onCancelDocumentRewrite}>
停止改写
</Button>
</div>
)}
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={previewThreadId}
onClose={handleArtifactsClose}
onDownloadClick={onDownload}
/>
</>
) : (
<div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30">
<Button size="icon-sm" variant="ghost" onClick={handleArtifactsClose}>
<XIcon />
</Button>
</div>
<ConversationEmptyState
icon={<FileTextIcon />}
title="report.md"
description="报告开始撰写后将在此实时预览"
/>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
{onDocumentRewriteStart && (
<DocumentRewriteDialog
open={rewriteDialogOpen}
onOpenChange={setRewriteDialogOpen}
fileName="report.md"
charCount={reportCharCount}
onStart={(input) => {
setRewriteDialogOpen(false);
onDocumentRewriteStart(input);
}}
/>
)}
</ThreadContext.Provider>
);
}
@@ -1,104 +0,0 @@
"use client";
/**
* 深度研究「真沙箱」报告预览 hook —— 仿圆桌 `useStep3Report` 的 stub 机制。
*
* 深度研究的报告不是任何 LangGraph 线程里的真实 write_file 工具调用,而是写作
* job 通过 SSE(report_delta)流出、由 workbench 在内存里拼装的文本。要让工作区
* 的 `ArtifactFileDetail`(真沙箱)能预览它,这里构造一条**stub ai 消息**:携带
* 一个 `write_file(report.md)` 的 tool_call,args.content 就是当前报告全文。
* `ArtifactFileDetail` 通过虚拟 URL `write-file:...?message_id=...&tool_call_id=...`
* 从 ThreadContext 的 stub 消息里直接读取内容(见 `core/artifacts/loader.ts`),
* 于是免费获得沙箱的全部既有能力:
* - 流式期(stub isLoading=true):code 视图自动滚底;
* - isLoading 翻 false:自动切换 Markdown 预览(Streamdown 渲染)+ 脉冲提示;
* - 复制 / 另存 / 展示层脱敏等。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
artifactPreviewKey,
useArtifacts,
} from "@/components/workspace/artifacts";
/** 报告在沙箱中的固定虚拟路径(与 runner 实际写入的 artifacts 路径一致)。 */
export const REPORT_OUTPUT_PATH = "/mnt/user-data/outputs/report.md";
export type ReportStubAiMessage = {
id: string;
type: "ai";
content: string;
tool_calls: {
id: string;
name: string;
args: Record<string, unknown>;
}[];
};
interface UseReportSandboxOptions {
/** stub 线程标识(建议用 sessionId/jobId 派生,切换会话即切换 stub)。 */
threadKey: string;
/** 当前报告全文:流式期间为逐帧拼装的文本,完成后为已保存的 reportMarkdown。 */
report: string;
/** 报告是否正在流式写入 —— 作为 stub thread 的 isLoading。 */
writing: boolean;
}
export function useReportSandbox({ threadKey, report, writing }: UseReportSandboxOptions) {
const { select, setOpen, openStreamedArtifact } = useArtifacts();
const [stubMessage, setStubMessage] = useState<ReportStubAiMessage | null>(null);
// 每个 threadKey 只自动展开一次;刷新/切换会话后的重开由用户或 openReport 触发。
const autoOpenedRef = useRef(false);
const messageId = `dr-report-${threadKey}`;
const toolCallId = `dr-report-${threadKey}-write`;
const virtualUrl = useMemo(
() =>
new URL(
`write-file:${REPORT_OUTPUT_PATH}?message_id=${encodeURIComponent(messageId)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
).toString(),
[messageId, toolCallId],
);
useEffect(() => {
autoOpenedRef.current = false;
setStubMessage(null);
}, [threadKey]);
// 每帧 upsert stub(带防抹空:新帧为空而旧内容非空时保留旧内容)。
useEffect(() => {
if (!report) return;
setStubMessage((prev) => {
const prevArgs = prev?.tool_calls?.[0]?.args as { content?: unknown } | undefined;
const prevContent = typeof prevArgs?.content === "string" ? prevArgs.content : "";
const content = report.trim() ? report : prevContent;
return {
id: messageId,
type: "ai",
content: "",
tool_calls: [{ id: toolCallId, name: "write_file", args: { path: REPORT_OUTPUT_PATH, content } }],
};
});
}, [messageId, report, toolCallId]);
// 报告首帧到达且仍在写入 → 自动展开右栏并选中虚拟 URL。
// openStreamedArtifact 尊重用户手动关闭(suppress),不会强行弹回。
useEffect(() => {
if (!report.trim() || !writing || autoOpenedRef.current) return;
autoOpenedRef.current = true;
openStreamedArtifact(virtualUrl, artifactPreviewKey(virtualUrl));
}, [openStreamedArtifact, report, virtualUrl, writing]);
/** 手动打开(头部「报告」按钮 / 完成卡「查看报告」):始终选中虚拟 URL。 */
const openReport = useCallback(() => {
select(virtualUrl, false);
setOpen(true);
}, [select, setOpen, virtualUrl]);
return {
virtualUrl,
stubMessages: useMemo(() => (stubMessage ? [stubMessage] : []), [stubMessage]),
openReport,
};
}
+5 -48
View File
@@ -24,23 +24,11 @@
| 参数 | 是否必填 | 作用 |
| --- | --- | --- |
| `taskId` | 否 | 外部任务 ID。传入后它是岗位会商的共享存储作用域;缺失时保持原有的个人会话逻辑。mock 仅提供任务卡片的显示兜底,绝不替换路由传入的 ID。 |
| `taskId` | 否 | 外部任务 ID。缺失时使用本地 mock 任务,便于演示和开发。 |
| `sessionId` | 否 | 直接恢复指定岗位会商会话;缺失时页面会按 `taskId` 查找最近一条未归档会话。 |
| `positionId` | 否 | 初始打开的岗位,同时同步顶部岗位选择器。无效值回退到 `intelligence`。 |
| `new=1` | 否 | 强制不恢复历史会话,以新会话开始。 |
传入 `taskId` 时,同一个任务下可以有多条会商会话,所有用户共享同一份历史、节点快照与 LangGraph 线程;当前 `user_id` 仅保留创建审计,不参与这些任务会话的读写过滤。页面恢复 `sessionId` 时会核验其 `externalTaskId` 与路由 `taskId` 一致,不一致会丢弃该会话并按当前任务重新发现历史。未传 `taskId` 时,会继续只读取当前用户的原有个人会话。
### 外部系统按 taskId 对接
1. 进入页面时将业务侧任务的稳定主键原样放入 URL。工作区入口使用 `/#/page/workspace/roundtable/position?taskId=<taskId>`;策略页入口也兼容 `/#/page/strategy/qa/position-roundtable?taskId=<taskId>`。不要使用任务标题、序号或每次请求都变化的临时 ID。
2. 前端先调用 `GET /api/position-roundtable/sessions?external_task_id=<taskId>`;返回的是该任务下所有用户共享的会商记录,按最近更新时间排序。选中一条后再调用 `GET /sessions/{sessionId}` 恢复完整节点和聊天快照。
3. 没有可恢复记录时,调用 `POST /sessions`,至少传入 `{ external_task_id: taskId, task_snapshot: { ...任务展示字段 } }`。服务端将 `task_scoped` 设为 `true`,并固定快照中的 `id` / `externalTaskId` 为传入的 taskId。
4. 后续所有意图、链条、节点、收口智能体和历史删除 API 只使用返回的 `sessionId`;不要在每次消息请求中重新创建会话。一个 taskId 可以有多条 sessionId。
5. 不传 `taskId`(同时省略 `external_task_id`)就是兼容模式:后端只返回和操作当前用户自己的个人会话,绝不会把历史记录升级为共享。响应中的 `task_scoped` 可用来区分两种记录。
服务端从请求中的非空 `external_task_id` 判断是否开启共享,调用方无需也不应自行传 `task_scoped`。
当前岗位 ID 定义在 `lib/positions.ts`:
| ID | 页面岗位 | 职责 |
@@ -121,31 +109,6 @@ PositionRoundtablePage
后端会冻结链条快照并根据链条阶段创建节点。已启动后意图不可再修改;需要重新识别时必须新建会话。
The position workspace selects the dedicated built-in `position-roundtable-intent`
agent by sending `intent_agent_id` through the shared Step-1 init and stream
requests. The thread lifecycle, file upload, and SSE transport stay identical
to the generic multi-agent flow. The dedicated agent permits at most five
user-assistance rounds; one round may contain multiple independent
`ask_clarification` cards. `Step1Panel` renders every card separately and
submits the selected answers together, so cards must never be merged into one
numbered multi-question card. Only its final `[INTENT_READY]` schema changes to
the following four string arrays:
```json
{
"coreGoals": ["..."],
"riskWarnings": ["..."],
"keyPoints": ["..."],
"strategicSignificance": ["..."]
}
```
The position sidebar and edit dialog render these four fields. Business-chain
activation requires all four arrays and at least one core goal or strategic
significance. Historic `objective` / `constraints` / `assumptions` snapshots
are read through a compatibility mapping only; ordinary multi-agent Step 1 is
not changed.
### 4.2 普通节点问答
`PositionNodeChat` 在发送前调用 `getPositionNodeContext` 获取受长度控制的任务、意图和已交付上游内容,再以 `task_external_context` 作为隐藏上下文发送给当前智能体。
@@ -169,16 +132,10 @@ not changed.
页面只维护一个 `sandboxArtifact`。`PositionNodeChat` 和 `PositionBuiltinAgentChat` 通过 `onPreviewArtifact` 上报预览目标;父页面统一渲染 `ArtifactFileDetail`。
**对接 / 重做页面 / 排障指南(打开关闭时机、步骤条、接口约定):**
[`../../docs/position-roundtable-sandbox/沙箱打开关闭与步骤条.md`](../../docs/position-roundtable-sandbox/沙箱打开关闭与步骤条.md)
(目录说明见 [`../../docs/position-roundtable-sandbox/README.md`](../../docs/position-roundtable-sandbox/README.md))
设计要点:
- 文件正在由 `write_file` 流式生成时,优先显示源码;完成后交由预览组件切换 Markdown 预览;
- `write_file` 的参数是一整个 JSON 流,键顺序由模型决定。若 `content` 先于 `path` 到达,会先用临时文件名(`未命名文件.md`/`.html`,按正文首部嗅探)开沙箱——流式内容只按 `message_id` + `tool_call_id` 定位,与路径无关;真实 `path` 到达后沿用同一个 `previewKey` 改名,属于「更新」而非第二次交付;
- 流式快照**不能进页面 state**。本页很大(任务面板、意图结果、流程图、节点对话),按流式频率重渲染整页会把主线程占满,连消息列表都会停止绘制直到本轮结束。快照走 `createSandboxThreadStore()` + `SandboxThreadProvider`(`useSyncExternalStore`),只有沙箱子树重渲染;
- 快照的合并节流是**前沿**节流:距上次推送超过 160ms 就在流回调里同步推送,未满则挂一个补尾定时器。不能只靠尾随定时器——主线程繁忙时回调会被饿死,面板会一直冻在打开时的内容;
- `sandboxThreadSnapshot` 有 160ms 合并节流,只保留当前打开文件所在线程的最新快照,避免流式时频繁 setState;
- 用户手动关闭后,`closedSandboxKeyRef` 与 `suppressedAutoPreviewThreadIdsRef` 阻止同一线程立即把沙箱重新打开;
- 切岗位、切节点、切收口阶段、新建会话和切换历史会话都会先调用 `closeSandbox()`。
@@ -201,8 +158,8 @@ not changed.
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `GET` | `/sessions?external_task_id=<taskId>` | 传入时按任务作用域查询共享会话;不传参数时查询当前用户的个人历史。 |
| `POST` | `/sessions` | 创建会话并保存任务快照;传入非空 `external_task_id` 时服务端会将快照 ID 固定为该值并启用共享;缺失或空值时创建原有个人会话。 |
| `GET` | `/sessions?external_task_id=` | 按任务查询会话,用于恢复最近会话和历史记录。 |
| `POST` | `/sessions` | 创建初始会话并保存任务快照。 |
| `GET` | `/sessions/{id}` | 恢复会话、节点和冻结快照。 |
| `DELETE` | `/sessions/{id}` | 删除一条岗位会商历史及其节点;删除当前记录时前端会先屏蔽并发恢复请求。 |
| `PUT` | `/sessions/{id}/task` | 更新未冻结的任务快照。 |
@@ -216,7 +173,7 @@ not changed.
| `POST` | `/sessions/{id}/summary/*` | 准备、发送和完成方案总结。 |
| `POST` | `/sessions/{id}/action-plan/*` | 准备、发送和完成行动规划。 |
| `PUT` | `/sessions/{id}/{summary|action-plan}/conversation` | 保存两个收口智能体的消息与未发送草稿。 |
| `POST` | `/demo/seed` | 按 `demoKey` 幂等初始化完整演示会话。 |
| `POST` | `/demo/seed` | 按用户和 `demoKey` 幂等初始化完整演示会话。 |
后端实现在 `offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py`。
Loaded 100 of 448 files, more files were not shown because too many files have changed in this diff. Show more