Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
178572bc7f | ||
|
|
70da7c5649 | ||
|
|
500dab29ab | ||
|
|
4d688ddbd8 | ||
|
|
8193cc53d5 | ||
|
|
7b37415604 | ||
|
|
c4d244881f | ||
|
|
75963c9ad9 | ||
|
|
11e54550ed | ||
|
|
4f536d9b39 | ||
|
|
b527bc94bb | ||
|
|
54fd0e7316 | ||
|
|
6dbaf40f99 | ||
|
|
3aeb6ac5ce | ||
|
|
5a4881053d | ||
|
|
cebc44ff82 | ||
|
|
56044d9cb7 | ||
|
|
4809ee7db3 | ||
|
|
a4a1e73b8e | ||
|
|
5123b7bdd7 | ||
|
|
e189349eeb | ||
|
|
f2e81d8875 | ||
|
|
e5e0f3a7c3 | ||
|
|
c4fd9dd7da | ||
|
|
b4d076a2d4 | ||
|
|
bc82072710 | ||
|
|
bf023d746a | ||
|
|
fb5024ea82 | ||
|
|
c9c2dc7ea8 | ||
|
|
2a869f4ff6 | ||
|
|
af509eb670 | ||
|
|
783e698b6e | ||
|
|
ba912acf72 | ||
|
|
e0c09f6704 | ||
|
|
17a12bb7b6 | ||
|
|
f8757d1a43 | ||
|
|
69b95f0d6d | ||
|
|
ca32a22c0d | ||
|
|
3eb307c5c5 | ||
|
|
49af497085 | ||
|
|
8b732b43ee | ||
|
|
69e455274a |
No files matched your search
@@ -0,0 +1,20 @@
|
|||||||
|
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()
|
||||||
+17
-4
@@ -1,5 +1,9 @@
|
|||||||
# Workspace/editor
|
# Workspace/editor
|
||||||
.vscode/
|
.vscode/*
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/settings.json
|
||||||
frontend-web/.vscode/
|
frontend-web/.vscode/
|
||||||
node_modules/
|
node_modules/
|
||||||
# Claude Code local-only settings (per-user permissions, not shared)
|
# Claude Code local-only settings (per-user permissions, not shared)
|
||||||
@@ -53,6 +57,7 @@ offline-backend-20260505/bundle/stop_backend.sh
|
|||||||
|
|
||||||
# offline-backend-20260512 runtime artifacts (large files, local config, data)
|
# offline-backend-20260512 runtime artifacts (large files, local config, data)
|
||||||
offline-backend-20260512/.env.example
|
offline-backend-20260512/.env.example
|
||||||
|
offline-backend-20260512/.env
|
||||||
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
|
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
|
||||||
offline-backend-20260512/SHA256SUMS
|
offline-backend-20260512/SHA256SUMS
|
||||||
offline-backend-20260512/config.yaml.example
|
offline-backend-20260512/config.yaml.example
|
||||||
@@ -64,6 +69,8 @@ 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/data/*.db-*
|
offline-backend-20260512/backend/.deer-flow/data/*.db-*
|
||||||
offline-backend-20260512/backend/.deer-flow/*
|
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/start_backend.sh
|
||||||
offline-backend-20260512/stop_backend.sh
|
offline-backend-20260512/stop_backend.sh
|
||||||
offline-backend-20260512/check_backend.sh
|
offline-backend-20260512/check_backend.sh
|
||||||
@@ -81,9 +88,11 @@ skills/
|
|||||||
hermes-agent-main/
|
hermes-agent-main/
|
||||||
.gitignore
|
.gitignore
|
||||||
offline-backend-20260512/backend/tests/*
|
offline-backend-20260512/backend/tests/*
|
||||||
# MAF roundtable tests must be tracked (excepted from the broad tests/* ignore above)
|
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
||||||
!offline-backend-20260512/backend/tests/test_maf_*.py
|
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
||||||
!offline-backend-20260512/backend/tests/maf_test_helpers.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
|
deploy
|
||||||
.history
|
.history
|
||||||
|
|
||||||
@@ -98,6 +107,10 @@ frontend-web/.offline-pack-build/
|
|||||||
frontend-web/.offline-extract-test/
|
frontend-web/.offline-extract-test/
|
||||||
frontend-web-node_modules-offline-*.tar.gz
|
frontend-web-node_modules-offline-*.tar.gz
|
||||||
|
|
||||||
|
# Local dependency caches and backend scratch archives
|
||||||
|
/.pnpm-store/
|
||||||
|
/offline-backend-20260512/backend/backend*.zip
|
||||||
|
|
||||||
.codegraph
|
.codegraph
|
||||||
codegraph.mdc
|
codegraph.mdc
|
||||||
|
|
||||||
|
|||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"ms-python.python",
|
||||||
|
"ms-python.debugpy"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+66
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
Vendored
+152
@@ -0,0 +1,152 @@
|
|||||||
|
{
|
||||||
|
"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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 深度研究第三轮:检索稳定性修复 + 参考文献确定性 + 页面对齐 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,122 +0,0 @@
|
|||||||
# MAF 多智能体会商 Phase-4 前端实施计划
|
|
||||||
|
|
||||||
## 决策回顾(已与用户确认)
|
|
||||||
- **入口**:独立顶级菜单 `/page/maf/*`,新增侧边栏一级项「MAF 会商」
|
|
||||||
- **实时更新**:纯前端轮询,`GET /runs/{id}/events?after_seq=N&limit=1000`,间隔 1.8s,复用现有后端,零 SSE、零后端改动
|
|
||||||
- **流程图**:要。基于 `confirmed_facts`(来自 `run.maf_checkpoint.fact_ledger`)+ 末轮 `final_result.content` 解析的 `flow-json`,复用 `@xyflow/react` + `flow-graph.ts` + `FlowGraphPanel`
|
|
||||||
- **审批**:卡片式,仿 `IntentClarificationCard` 的视觉/交互模式(参数摘要 + 批准/拒绝按钮 + 备注 textarea),落到 `POST /approvals/{id}/decision`
|
|
||||||
|
|
||||||
## 关键数据契约(已核实后端源码)
|
|
||||||
- **后端只读**:16 个端点全部命中,无改动。`GET /health` 返回 `enabled` 决定页面内容(入口仍可见,未启用时显示空态)。
|
|
||||||
- **事件流**:单调 `(run_id, seq)`,`GET /events?after_seq=N` 返回 `{events:[...], latest_seq}`;前端推进 `after_seq=latest_seq`。payload 已在后端**写入时脱敏**(`sanitize_payload`),前端直接渲染。
|
|
||||||
- **事件分类**(`event_normalizer.py`):`run.started/completed/failed/cancelled`、`agent.turn_started/message.delta/message.completed`、`tool.requested/started/completed/failed`、`skill.started/completed/failed/selected`、`artifact.created`、`action.waiting_approval/approved/rejected`、`workflow.checkpointed`。
|
|
||||||
- **事实账本**(`fact_ledger.empty_ledger`):`candidate_facts`/`confirmed_facts`/`evidence`/`tool_results`/`executed_actions`/`pending_actions`/`failed_or_unknown_actions`/`conflicts`。`confirmed_facts` 条目含 `{candidate_id, text, agent_id, confirmed_by, evidence_refs[]}`。
|
|
||||||
- **Run 字段**(`run_to_dict`):`id, session_id, status, maf_checkpoint(含 fact_ledger + final_result + seat_outcomes), last_event_seq, started_at, finished_at, error_code, error_message_safe, token_usage, latency_ms`。
|
|
||||||
- **终态判定**:`runIsLive(status) = ["pending","running","waiting_approval","cancelling","cancel_failed"].includes(status)`,非 live 即停轮询。
|
|
||||||
- **Action/Approval**:`GET /runs/{id}/actions`、`GET /runs/{id}/approvals`;`POST /approvals/{id}/decision` `{decision:"approved"|"rejected"}`。
|
|
||||||
|
|
||||||
## 复用清单(已定位到文件:行)
|
|
||||||
- `apiFetch`(`core/api/fetch-client.ts`):内部自动注入 `Authorization`(`getAuthorizationHeaderValue()`)+ `credentials:"include"`,**返回 Response 不解析、不拼 URL**。调用方拼 `${getBackendBaseURL()}/api/maf-roundtable/...`,自行 `res.ok` 检查 + `res.json()`。
|
|
||||||
- `getBackendBaseURL`(`core/config/index.ts`):未设时返回 `""`,相对路径同源可用。
|
|
||||||
- TanStack Query 模式(`strategy-components/api/task-buttons.ts`):`useQuery`+`refetchOnWindowFocus:false`;`useMutation`+`onSuccess invalidate`;queryKey 用嵌套数组 `["maf-roundtable", runId, "events"]`。
|
|
||||||
- 轮询模式(`core/scheduled-tasks/hooks.ts:105-115`):函数式 `refetchInterval: (query) => runIsLive(query.state.data?.status) ? ms : false`。
|
|
||||||
- `FlowGraphPanel`(`roundtable-planning/components/FlowGraphPanel.tsx`):props `{data: FlowGraphData, streaming, onClose, ...}`,自动 `useTheme()` 取明暗。`FlowGraphData = {title, nodes:[{id,label,type,detail?,group?}], edges:[{from,to,label?}]}`。`extractFlowGraph(text)`(`flow-graph.ts:263`)容错解析 ```flow-json``` 代码块(含半截 JSON)。
|
|
||||||
- `IntentClarificationCard`(`roundtable-planning/components/IntentClarificationCard.tsx`):卡片视觉模板(`rounded-xl border-red-100` + 「需要确认」徽标 + 选项按钮 + textarea + 提交)。
|
|
||||||
- 时间线 feed(`roundtable-planning/components/Step1Panel.tsx` + `MessageBubble` + `useAutoScroll` + `ScrollToBottomButton`):数组入、按 seq 序渲染。
|
|
||||||
- `useDesensitizeSubtree`(`roundtable-planning/lib/use-desensitize-subtree.ts`):仅挂 header/面板框架;时间线 body 不挂(payload 已脱敏 + hook 自身警告勿用于消息列表)。
|
|
||||||
- 路由(`pages/PageRoutes.tsx:143-169`):`<Route path="maf/*" element={<MafRoutes/>}/>`(仿 `RoundtableRoutes.tsx`)。
|
|
||||||
- 菜单(`core/page-layout/sidebar-menu.ts`):在 `sidebarMenuItems` 数组加一级项,`icon` 用 lucide 组件(非字符串 key)。新节点经 `use-sidebar-menu.ts` 自动走 static→inject→override→role→desensitize 流水线,无需额外注册。
|
|
||||||
|
|
||||||
## 文件夹结构(镜像 `roundtable-planning/`,全新 `src/maf-roundtable/`)
|
|
||||||
```
|
|
||||||
frontend-web/src/maf-roundtable/
|
|
||||||
├── api/
|
|
||||||
│ ├── types.ts # TS 类型(snake_case 直传,后端即 snake_case)
|
|
||||||
│ ├── client.ts # BASE()=getBackendBaseURL()+"/api/maf-roundtable";apiFetch 封装 + readJson<T>/parseDetail
|
|
||||||
│ ├── sessions.ts # 会话 CRUD hooks(list/get/create/update/delete)
|
|
||||||
│ ├── runs.ts # run hooks(get/list/create/cancel/resume/recover)+ useMafHealth
|
|
||||||
│ ├── events.ts # listEvents(runId, afterSeq) 原始 fetch(供轮询 hook 用)
|
|
||||||
│ └── approvals.ts # 审批 hooks(list + decide mutation)
|
|
||||||
├── hooks/
|
|
||||||
│ ├── use-maf-run-status.ts # GET /runs/{id} useQuery,函数式 refetchInterval 停终态
|
|
||||||
│ └── use-maf-events.ts # ★ cursor 轮询 hook(核心)
|
|
||||||
├── lib/
|
|
||||||
│ ├── event-timeline.ts # 纯函数:events[] → TimelineEntry[](delta 聚合、tool 生命周期折叠、approval 锚点)
|
|
||||||
│ └── fact-flow-graph.ts # 纯函数:confirmed_facts+evidence → FlowGraphData
|
|
||||||
├── components/
|
|
||||||
│ ├── MafRunHeader.tsx # 状态徽标 + token/延迟 + 取消/恢复/重试
|
|
||||||
│ ├── MafEventTimeline.tsx # 时间线 feed(MessageBubble + useAutoScroll)
|
|
||||||
│ ├── MafSeatStatusPanel.tsx # 席位状态矩阵 + execution_stage 徽标
|
|
||||||
│ ├── MafFactFlowPanel.tsx # 包一层 FlowGraphPanel,数据来自 fact-flow-graph
|
|
||||||
│ ├── MafApprovalCard.tsx # 卡片式审批(批准/拒绝 + 备注)
|
|
||||||
│ └── MafHealthGate.tsx # health.enabled=false → 空态
|
|
||||||
├── pages/
|
|
||||||
│ ├── MafSessionsPage.tsx # 会话列表 + 新建
|
|
||||||
│ └── MafRunDetailPage.tsx # 单 run 工作区(三栏)
|
|
||||||
└── index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## 核心:cursor 轮询 hook(`hooks/use-maf-events.ts`)
|
|
||||||
不用纯 `useQuery+refetchInterval`:cursor `after_seq` 需每次推进,queryFn 不持有「上次 latest_seq」,塞 queryKey 会抖动。**用自定义 hook**(结合 scheduled-tasks 的 refetchInterval 思路 + roundtable-planning 的 reader-loop 家族):
|
|
||||||
```ts
|
|
||||||
export function useMafEvents(runId?: string, active=false) {
|
|
||||||
const [events, setEvents] = useState<MafEvent[]>([]);
|
|
||||||
const afterSeqRef = useRef(0); // 避免 setInterval 闭包旧值
|
|
||||||
useEffect(() => {
|
|
||||||
if (!runId || !active) return;
|
|
||||||
let cancelled = false;
|
|
||||||
const poll = async () => {
|
|
||||||
const res = await listEvents(runId, afterSeqRef.current);
|
|
||||||
if (cancelled || !res.events.length) return;
|
|
||||||
setEvents(prev => mergeBySeq(prev, res.events)); // seq 去重
|
|
||||||
afterSeqRef.current = res.latest_seq;
|
|
||||||
};
|
|
||||||
poll(); // 进入即补拉(after_seq=0 全量历史)
|
|
||||||
const id = setInterval(poll, 1800);
|
|
||||||
return () => { cancelled = true; clearInterval(id); };
|
|
||||||
}, [runId, active]);
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- **补拉历史**:进入页面 `after_seq=0` 立即拉全部已落库事件 → 天然支持断线补拉/刷新恢复。
|
|
||||||
- **终态停止**:`active` 由 `useMafRunStatus` 的 `runIsLive(status)` 传入,终态时 effect 清理 interval。
|
|
||||||
- **去重**:后端 `(run_id,seq)` + `source_event_id` 双重去重,前端再按 `seq` 做 `mergeBySeq`,幂等。
|
|
||||||
- **切换 run**:`runId` 变 → effect 重置(events 清空、afterSeq 归零)。
|
|
||||||
|
|
||||||
## 流程图数据流(`lib/fact-flow-graph.ts`)
|
|
||||||
两源按优先级合并 → `FlowGraphData`:
|
|
||||||
1. **`confirmed_facts`(主)**:每条已确认事实 → `task` 节点(label=text 截断、detail=全文、group=confirmed_by);`evidence_refs` 连到 `evidence`(→ `role`)或 `tool_results`(`tool:{name}:{turn}` → `deliverable`)节点;证据根用 `start`。
|
|
||||||
2. **`final_result.content` 的 ```flow-json```(次)**:用现成 `extractFlowGraph(text)` 解析叠加为「结论流程图」。
|
|
||||||
|
|
||||||
输出喂 `<FlowGraphPanel data={...} streaming={!terminal}/>`(非终态蚂蚁线动)。两源皆空 → 空态「尚无可绘制事实」。
|
|
||||||
|
|
||||||
## 页面布局(`MafRunDetailPage`,三栏 / 窄屏堆叠)
|
|
||||||
- **左:时间线**(`MafEventTimeline`)—— `event-timeline.ts` 折叠成可读条目(delta 聚合气泡、tool 折叠「调用 X→完成」、action.waiting_approval 插锚点),复用 MessageBubble + useAutoScroll + ScrollToBottomButton。
|
|
||||||
- **中上:席位状态**(`MafSeatStatusPanel`)—— `maf_checkpoint.seat_outcomes` 的 agent × status 矩阵 + `execution_stage` 徽标。
|
|
||||||
- **中下:事实/结论流程图**(`MafFactFlowPanel`)—— 折叠面板,默认展开。
|
|
||||||
- **右:审批卡片**(`MafApprovalCard` 列表)—— 仅 pending 时显示;含工具名、参数摘要(脱敏)、批准/拒绝按钮 + 备注 textarea → `POST /approvals/{id}/decision`,成功后 invalidate `["maf-roundtable","approvals",runId]`,事件轮询自然看到 `action.approved/rejected`。
|
|
||||||
|
|
||||||
## 路由 + 菜单接入(3 处)
|
|
||||||
1. **新建** `pages/MafRoutes.tsx`(仿 `RoundtableRoutes.tsx`):`index→MafSessionsPage`、`runs/:runId→MafRunDetailPage`、`*→重定向`。
|
|
||||||
2. **改** `pages/PageRoutes.tsx`:加 `import MafRoutes` + `<Route path="maf/*" element={<MafRoutes/>}/>`(插 `notebook` 后)。
|
|
||||||
3. **改** `core/page-layout/sidebar-menu.ts`:数组末尾加 `{ id:"maf-center", label:"MAF 会商", icon:Network, children:[{id:"maf-roundtable", label:"会商列表", icon:MessageSquareText, path:"/page/maf"}] }`。**不加 `adminOnly`**(全员可见;`/health.enabled=false` 由 `MafHealthGate` 客户端控制页面空态)。
|
|
||||||
|
|
||||||
## 实施顺序
|
|
||||||
1. `api/types.ts` + `api/client.ts`(地基)
|
|
||||||
2. `api/{sessions,runs,events,approvals}.ts`(端点 hook)
|
|
||||||
3. `hooks/{use-maf-run-status,use-maf-events}.ts`(轮询核心)
|
|
||||||
4. `lib/{event-timeline,fact-flow-graph}.ts`(纯函数)
|
|
||||||
5. `components/*`(6 个组件)
|
|
||||||
6. `pages/*`(2 个页面)+ `index.ts`
|
|
||||||
7. `pages/MafRoutes.tsx` + 接入 `PageRoutes.tsx` + `sidebar-menu.ts`
|
|
||||||
8. `pnpm typecheck` + 手动核对路由/菜单显隐
|
|
||||||
|
|
||||||
## 文件清单
|
|
||||||
- **新建**(14):`maf-roundtable/api/{types,client,sessions,runs,events,approvals}.ts`、`maf-roundtable/hooks/{use-maf-run-status,use-maf-events}.ts`、`maf-roundtable/lib/{event-timeline,fact-flow-graph}.ts`、`maf-roundtable/components/{MafRunHeader,MafEventTimeline,MafSeatStatusPanel,MafFactFlowPanel,MafApprovalCard,MafHealthGate}.tsx`、`maf-roundtable/pages/{MafSessionsPage,MafRunDetailPage}.tsx`、`maf-roundtable/index.ts`、`pages/MafRoutes.tsx`
|
|
||||||
- **改动**(2):`pages/PageRoutes.tsx`、`core/page-layout/sidebar-menu.ts`
|
|
||||||
- **后端**:无改动
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
- `pnpm typecheck`(tsc --noEmit)通过
|
|
||||||
- 手动:菜单「MAF 会商」→ 列表 → 新建会话 → 启动 run → 时间线滚动 → 席位状态更新 → 流程图随 confirmed_facts 增长 → 审批可批准 → 终态停轮询
|
|
||||||
- 退路:`/health.enabled=false` → 入口可见但页面显示未启用提示;旧 `roundtable-planning` 零改动
|
|
||||||
@@ -82,6 +82,7 @@ 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`.
|
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
|
||||||
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
|
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
|
||||||
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all).
|
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all).
|
||||||
|
- **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答直连外部 AG-UI 网关(POST SSE),不经过 LangGraph,会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`),核心模块 `src/core/sentiment-agent/`。
|
||||||
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
|
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
|
||||||
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
|
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
|
||||||
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
|
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`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,198 +0,0 @@
|
|||||||
# MAF-R0 阶段 0 交付文档:旧会商交互基线与组件映射
|
|
||||||
|
|
||||||
> 状态:已完成
|
|
||||||
> 更新时间:2026-08-03
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 实验原型隐藏说明
|
|
||||||
|
|
||||||
### 已完成的操作
|
|
||||||
|
|
||||||
- `/page/maf/*` 路由(`MafSessionsPage` + `MafRunDetailPage`)已从正式菜单移除。
|
|
||||||
- `workspace-nav-chat-list.tsx:199` 保留了墓碑注释:`{/* [MAF-R0] 实验原型已从正式菜单隐藏,仅保留路由供开发诊断:/page/maf */}`
|
|
||||||
- `MafRoutes.tsx` 文件头部标注为 `[MAF-R0 实验原型 — 仅供开发诊断,已从正式菜单移除]`。
|
|
||||||
- 该原型仅可通过手动输入 URL `/page/maf` 访问,不在任何侧边栏、导航菜单或工作区入口中出现。
|
|
||||||
- 停止在该原型上新增业务功能。
|
|
||||||
|
|
||||||
### 原型文件位置
|
|
||||||
|
|
||||||
| 文件 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `src/pages/MafRoutes.tsx` | 路由定义(index → MafSessionsPage, runs/:runId → MafRunDetailPage) |
|
|
||||||
| `src/maf-roundtable/pages/MafSessionsPage.tsx` | 会话列表页(642 行) |
|
|
||||||
| `src/maf-roundtable/pages/MafRunDetailPage.tsx` | 单 run 工作区(195 行) |
|
|
||||||
| `src/maf-roundtable/` | 完整模块(33 文件:api/hooks/components/lib/tests) |
|
|
||||||
| `vitest.maf.config.ts` | MAF 专用测试配置 |
|
|
||||||
| `package.json` → `test:maf` | MAF 测试脚本 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 旧会商 8 类页面状态交互地图
|
|
||||||
|
|
||||||
旧会商入口:`src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(4376 行,三步合一)。
|
|
||||||
|
|
||||||
### 状态 1:空白 / 新建
|
|
||||||
|
|
||||||
- 触发:点击「新建会商」或首次进入。
|
|
||||||
- 表现:中心区域显示欢迎语 + 业务链选择入口 + 底部输入框。左侧历史列表为空或显示已有草稿。
|
|
||||||
- 关键组件:`RoundtablePlanningPage`(currentStep=1 初始态)、`Step1Panel`(空消息列表 + 输入栏)。
|
|
||||||
- 数据:`useDraftPersistence` 创建新草稿记录。
|
|
||||||
|
|
||||||
### 状态 2:历史切换
|
|
||||||
|
|
||||||
- 触发:点击左侧草稿列表中的某一条。
|
|
||||||
- 表现:加载该草稿的 Step1/Step2/Step3 快照,恢复到对应步骤。
|
|
||||||
- 关键组件:`useRoundtableDrafts`(列表)、`useDraftPersistence`(加载 + hydrate)。
|
|
||||||
- 数据:`GET /api/roundtable-drafts/{id}` 或 `GET /api/roundtable-task-drafts/{id}`。
|
|
||||||
|
|
||||||
### 状态 3:用户提问
|
|
||||||
|
|
||||||
- 触发:用户在 Step1 输入框输入任务文本并点击发送。
|
|
||||||
- 表现:用户气泡立即插入,随后出现 `MessageLoadingPlaceholder`(spinner + 跳动点),等待 SSE 首帧。
|
|
||||||
- 关键组件:`Step1Panel`(输入栏 → 发送)、`MessageBubble`(用户气泡)、`MessageLoadingPlaceholder`。
|
|
||||||
- 数据:`POST /api/intent/init` → `POST /api/intent/stream`(SSE)。
|
|
||||||
|
|
||||||
### 状态 4:意图澄清
|
|
||||||
|
|
||||||
- 触发:总控/意图智能体判断信息不足,返回 `ask_clarification` 帧。
|
|
||||||
- 表现:对话流中嵌入 `IntentClarificationCard`(单选/多选 + 自定义输入),用户选择后提交。
|
|
||||||
- 关键组件:`IntentClarificationCard`(选项卡)、`Step1Panel`(澄清提交逻辑)。
|
|
||||||
- 数据:SSE 帧 `phase=clarification`,用户回答后继续 `POST /api/intent/stream`。
|
|
||||||
|
|
||||||
### 状态 5:总控派活
|
|
||||||
|
|
||||||
- 触发:Step1 完成(INTENT_READY)→ 进入 Step2 → 总控输出派活计划。
|
|
||||||
- 表现:总控气泡显示派活计划(谁做什么),`ParallelSeatStrip` 显示参与席位头像条(running/done/error 徽章)。
|
|
||||||
- 关键组件:`Step2Panel`(派活叙事)、`ParallelSeatStrip`(席位条)、`MessageBubble`(总控气泡)。
|
|
||||||
- 数据:`POST /api/multi-agent/init`(SSE,建立 thread map)→ `POST /api/multi-agent/run/stream`。
|
|
||||||
|
|
||||||
### 状态 6:席位发言
|
|
||||||
|
|
||||||
- 触发:总控派活后,各席位按序/并行执行。
|
|
||||||
- 表现:每个席位的气泡流式出现(`StreamingCursor` 闪烁尾标),工具调用显示为 `MessageStepsCard`(ChainOfThought 折叠卡)。
|
|
||||||
- 关键组件:`MessageBubble`(席位气泡 + avatar)、`StreamingCursor`、`MessageStepsCard`(工具步骤)、`DialogueSpeedMeter`(tok/s)。
|
|
||||||
- 数据:SSE delta 帧按 message id 合并。
|
|
||||||
|
|
||||||
### 状态 7:审批
|
|
||||||
|
|
||||||
- 触发:席位执行外部写操作,WriteGuard 拦截并请求审批。
|
|
||||||
- 表现:对话流中嵌入审批卡(工具名 + 风险等级 + 摘要),用户点击批准/拒绝。
|
|
||||||
- 关键组件:MAF 原型中为 `MafApprovalCard`;旧会商中审批通过 `roundtable-jobs` 的 clarify-resume 机制。
|
|
||||||
- 数据:`POST /api/maf-roundtable/approvals/{id}/decision`(MAF)或 `POST /api/roundtable-jobs/{id}/clarify-resume`(旧)。
|
|
||||||
|
|
||||||
### 状态 8:完成 / 失败恢复
|
|
||||||
|
|
||||||
- 触发:所有席位完成 + 总控收束 → Step3;或 SSE 错误帧 / 网络断开。
|
|
||||||
- 表现(完成):Step3 显示最终报告(Markdown 总结 / HTML 看板 / 大屏),可追问。
|
|
||||||
- 表现(失败):错误提示 + 恢复按钮;后台任务可挂起/恢复。
|
|
||||||
- 关键组件:`Step3Panel` → `HighFidelityReport`(Report/Dashboard/Summary/FileBrowse 模式);失败时 `RoundtableJobNotifier`。
|
|
||||||
- 数据:`useStep3Summary` / `useStep3Report` / `useStep3Dashboard`;失败恢复 via `POST /api/roundtable-jobs/{id}/resume`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 旧组件 → 新 MAF view-model 映射表
|
|
||||||
|
|
||||||
| 旧组件 / hook | 新 MAF 对应 | 映射说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `RoundtablePlanningPage`(三步壳) | `MafRoundtableConversationPage` | 不再有步骤切换;单一连续时间线。 |
|
|
||||||
| `Step1Panel`(意图问答) | `ConversationTimeline` + `ClarificationCard` | 意图理解变为 `manager.intent` 事件卡。 |
|
|
||||||
| `Step2Panel`(研讨对话流) | `ConversationTimeline` + `SeatMessageBubble` | 席位发言由 SSE `agent.message.*` 驱动。 |
|
|
||||||
| `Step3Panel` / `HighFidelityReport` | `FinalResultCard` | 结论作为时间线中的结果卡。 |
|
|
||||||
| `MessageBubble` | `SeatMessageBubble` / 总控气泡 | 重写为 MAF view model;不复用旧 props。 |
|
|
||||||
| `MessageStepsCard` | `ToolProgressCard` | 工具过程由 `tool.started/progress/completed` 事件驱动。 |
|
|
||||||
| `IntentClarificationCard` | `ClarificationCard` | 由 `clarification.required` 事件触发。 |
|
|
||||||
| `ParallelSeatStrip` | `ParticipantStrip` | 状态改由 `manager.dispatch` + `seat.delivery.submitted` + `manager.review.decision` 驱动。 |
|
|
||||||
| `useStep1Intent` | 删除 | 意图理解完全服务端化。 |
|
|
||||||
| `useStep2Orchestration` | 删除 | 编排完全服务端化;前端只消费 SSE。 |
|
|
||||||
| `useDraftPersistence` | `use-maf-conversations` | 历史改为 conversation 列表。 |
|
|
||||||
| `useRoundtableDrafts` | `use-maf-conversations` | 同上。 |
|
|
||||||
| `useStep3Summary/Report/Dashboard` | 删除 | 结论由服务端生成,前端只渲染结果卡。 |
|
|
||||||
| `useAutoScroll` | 可复用(无业务依赖) | 纯 UI 滚动逻辑。 |
|
|
||||||
| `useDialogueSpeedMetrics` | 可复用(无业务依赖) | 纯 UI 速度指标。 |
|
|
||||||
| `MafApprovalCard`(原型) | `ApprovalMessageCard` | 重写为 conversation 时间线内卡片。 |
|
|
||||||
| `MafEventTimeline`(原型) | 删除 | 不再暴露 run/event 管理后台。 |
|
|
||||||
| `MafSeatStatusPanel`(原型) | `ParticipantStrip` | 合并到右侧席位条。 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 禁止复用的旧业务 hook / API / 状态清单
|
|
||||||
|
|
||||||
以下模块 **严禁** 被新 MAF conversation 页面直接 import 或调用:
|
|
||||||
|
|
||||||
### 4.1 旧会商 API 客户端(`src/roundtable-planning/api/`)
|
|
||||||
|
|
||||||
| 文件 | 禁止原因 |
|
|
||||||
|------|----------|
|
|
||||||
| `multi-agent.ts` | 调用 `/api/multi-agent/*`,包含前端 thread map 和编排逻辑 |
|
|
||||||
| `intent.ts` | 调用 `/api/intent/*`,前端驱动意图流 |
|
|
||||||
| `drafts.ts` | 调用 `/api/roundtable-drafts`,旧草稿状态 |
|
|
||||||
| `task-drafts.ts` | 调用 `/api/roundtable-task-drafts`,旧任务草稿 |
|
|
||||||
| `roundtable-jobs.ts` | 调用 `/api/roundtable-jobs`,旧后台任务 |
|
|
||||||
| `chains.ts` | 调用 `/api/roundtable-chains`,旧业务链 CRUD |
|
|
||||||
| `recommend.ts` | 调用 `/api/recommend/stream`,旧推荐流 |
|
|
||||||
| `flow-extract.ts` | 调用 `/api/flow-extract/*`,旧流程图提取 |
|
|
||||||
| `ingest.ts` | 调用 `/api/flow-extract/ingest-eligibility` |
|
|
||||||
| `dashboard-sessions.ts` | 调用 `/api/dashboard-sessions` |
|
|
||||||
| `diagnostics-report.ts` | 调用 `/api/roundtable-diagnostics/report` |
|
|
||||||
|
|
||||||
### 4.2 旧会商业务 Hook(`src/roundtable-planning/hooks/`)
|
|
||||||
|
|
||||||
| 文件 | 禁止原因 |
|
|
||||||
|------|----------|
|
|
||||||
| `useStep1Intent.ts` | 前端驱动意图状态机 |
|
|
||||||
| `useStep2Orchestration.ts` | 前端编排循环(thread map、派活、轮次控制) |
|
|
||||||
| `useDraftPersistence.ts` | 旧草稿持久化逻辑 |
|
|
||||||
| `useRoundtableDrafts.ts` | 旧草稿列表 |
|
|
||||||
| `useStep3Summary.ts` | 前端驱动总结生成 |
|
|
||||||
| `useStep3Report.ts` | 前端驱动看板生成 |
|
|
||||||
| `useStep3Dashboard.ts` | 前端驱动大屏生成 |
|
|
||||||
|
|
||||||
### 4.3 旧会商业务状态类型
|
|
||||||
|
|
||||||
- `Step1Snapshot`、`Step2Snapshot`、`Step2Dialogue`、`StubAiMessage`、`ParallelSeatView`
|
|
||||||
- `ThreadIdMap`、`ClarificationPayload`、`LeaderDispatch`、`StreamPhase`
|
|
||||||
- `DraftRecord`、`DraftMeta`、`DraftStep1Snapshot`、`DraftStep2Snapshot`、`DraftStep3Snapshot`
|
|
||||||
- `RoundtableJob`、`JobStatus`、`DispatchChainNode`、`StartJobPayload`
|
|
||||||
- `IntentSummary`、`IntentFileRef`、`IntentStreamPhase`
|
|
||||||
|
|
||||||
### 4.4 MAF 原型中禁止继续扩展的模块
|
|
||||||
|
|
||||||
| 文件 | 禁止原因 |
|
|
||||||
|------|----------|
|
|
||||||
| `src/maf-roundtable/pages/MafSessionsPage.tsx` | 实验原型,不作为产品主界面 |
|
|
||||||
| `src/maf-roundtable/pages/MafRunDetailPage.tsx` | 实验原型,暴露 run/session 管理 |
|
|
||||||
| `src/maf-roundtable/hooks/use-maf-events.ts` | 原型轮询 hook |
|
|
||||||
| `src/maf-roundtable/api/sessions.ts` | 原型 session CRUD |
|
|
||||||
| `src/maf-roundtable/api/runs.ts` | 原型 run 管理 |
|
|
||||||
| `src/maf-roundtable/api/events.ts` | 原型事件轮询 |
|
|
||||||
| `src/maf-roundtable/lib/session-form.ts` | 原型表单逻辑 |
|
|
||||||
|
|
||||||
### 4.5 允许参考但必须重写的视觉组件
|
|
||||||
|
|
||||||
以下组件的 **交互模式和信息架构** 可作为参考,但新 MAF 页面必须使用独立实现(MAF view model + tdesign-react),不得直接 import:
|
|
||||||
|
|
||||||
- `MessageBubble.tsx`(气泡布局)
|
|
||||||
- `MessageStepsCard.tsx`(工具步骤折叠)
|
|
||||||
- `IntentClarificationCard.tsx`(澄清选项卡)
|
|
||||||
- `ParallelSeatStrip.tsx`(席位头像条)
|
|
||||||
- `ScrollToBottomButton.tsx`(回到底部按钮)
|
|
||||||
- `Avatars.tsx`(头像 SVG)
|
|
||||||
|
|
||||||
### 4.6 可安全复用的无业务依赖工具
|
|
||||||
|
|
||||||
- `useAutoScroll.ts`(纯滚动逻辑)
|
|
||||||
- `useDialogueSpeedMetrics.ts`(纯速度指标)
|
|
||||||
- `src/lib/use-desensitize-subtree.ts`(DOM 脱敏,已标注多模块共享)
|
|
||||||
- `markdown-components.tsx`(markdown 渲染配置,需确认无旧状态依赖)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 验收确认
|
|
||||||
|
|
||||||
- [x] 旧会商 `RoundtablePlanningPage` 无代码行为变化(未修改任何 `src/roundtable-planning/` 文件)。
|
|
||||||
- [x] `/page/maf/*` 原型已从菜单隐藏,仅保留路由供开发诊断。
|
|
||||||
- [x] 8 类页面状态已记录,含触发条件、表现、关键组件和数据流。
|
|
||||||
- [x] 旧组件到新 MAF view-model 的映射表已完成。
|
|
||||||
- [x] 禁止复用的 hook/API/状态清单已明确。
|
|
||||||
- [x] 任何人可根据本文档判断新版是否「像原页面」。
|
|
||||||
@@ -1,645 +0,0 @@
|
|||||||
# MAF 多智能体会商升级版实施计划(V2:保留原会商体验,替换底层编排)
|
|
||||||
|
|
||||||
> 文档状态:**V2 重写,待按本计划重新实施**
|
|
||||||
> 更新时间:2026-08-02
|
|
||||||
> 本轮决策:原 `/page/maf/*` 的「会话列表 + run 详情 + 前端轮询」原型不再作为目标产品继续扩展。
|
|
||||||
> 产品目标:新 MAF 会商是**原多智能体会商页面的底层升级版**,不是另造一套会商产品。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 本次重新确认的产品目标
|
|
||||||
|
|
||||||
用户认可的是现有多智能体会商的视觉语言和交互链路:左侧历史会话、中心对话流、输入框发问、意图理解、总控派活、席位发言、过程卡片、澄清与审批、最终结论。
|
|
||||||
|
|
||||||
本次升级只替换以下“看不见的发动机”:
|
|
||||||
|
|
||||||
- 旧的前端驱动编排、多个前端可见线程和旧会商作业,替换为 MAF Workflow + DeerFlow Agent Adapter;
|
|
||||||
- 浏览器定时轮询,替换为一条可恢复的 SSE 对话事件流;
|
|
||||||
- 用户手动创建/管理多个智能体线程,收敛为服务端按需创建和持久化的内部执行线程;
|
|
||||||
- 保留现有工具、技能、MCP、沙箱、WriteGuard、审批和审计能力,并让它们在原有会商体验中自然出现。
|
|
||||||
|
|
||||||
**不能接受的结果:** 新页面没有对话列表、没有输入框、让用户先创建 session/run、需要用户选择或理解多个内部线程、靠多个定时 GET 拼出实时效果,或把审批/流程图做成脱离聊天主链路的独立后台页。
|
|
||||||
|
|
||||||
### 1.1 必须保留的用户体验
|
|
||||||
|
|
||||||
| 原会商能力 | 升级版要求 |
|
|
||||||
|---|---|
|
|
||||||
| 左侧会商历史、新建会商 | 保留。一个条目就是一个用户可见会商,不暴露 MAF session/run/thread。 |
|
|
||||||
| 中央连续对话流 | 保留。用户消息、总控消息、席位发言、工具进度、澄清、审批、最终报告按时间顺序呈现。 |
|
|
||||||
| 底部对话输入框 | 保留。用户可在首轮发起任务、在澄清时回答、在会商结束后追问/继续会商。 |
|
|
||||||
| 意图理解 | 保留并服务端化。先识别“可直接会商 / 需要澄清 / 只回答问题 / 需要工具或写入”。 |
|
|
||||||
| 总控智能体派活 | 保留。总控是唯一能选择席位、分派任务、收束轮次和生成结论的主体。 |
|
|
||||||
| 业务链、席位与过程可视化 | 保留其信息架构和交互,不复用旧业务状态;数据改由 MAF 事件驱动。 |
|
|
||||||
| 澄清、取消、恢复、审批 | 保留,并成为对话流中的卡片,而不是跳转到 run 管理页。 |
|
|
||||||
|
|
||||||
### 1.2 明确不保留的实现方式
|
|
||||||
|
|
||||||
- 不在浏览器端 `setInterval` 轮询 run、events、actions、approvals;
|
|
||||||
- 不让浏览器创建或维护 seat thread、manager thread、thread map;
|
|
||||||
- 不让用户先填写“经理、席位、模式、最大轮数”才能开始一次正常会商;
|
|
||||||
- 不把 `MafSessionsPage` / `MafRunDetailPage` 当作新产品主界面继续堆功能;
|
|
||||||
- 不调用旧的 `/api/roundtable-*`、`/api/multi-agent/*`、`roundtable_jobs`、旧草稿或旧会商状态;
|
|
||||||
- 不修改旧 `RoundtablePlanningPage` 的业务状态机、历史数据和对外行为。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 总体架构与边界
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
U[用户:会商对话页] -->|POST 新建/发送消息| API[MAF Conversation API]
|
|
||||||
API --> C[(MAF Conversation / Message)]
|
|
||||||
API --> D[MAF Dispatcher]
|
|
||||||
D --> W[MAF Workflow]
|
|
||||||
W --> M[总控:意图、派活、收束]
|
|
||||||
M --> S[服务端按需创建席位执行线程]
|
|
||||||
S --> A[DeerFlow Agent Adapter]
|
|
||||||
A --> T[工具 / 技能 / MCP / 沙箱]
|
|
||||||
T --> G[WriteGuard + 审批]
|
|
||||||
W --> E[(持久化会商事件)]
|
|
||||||
E --> H[SSE Hub:重放 + 实时推送]
|
|
||||||
H -->|一条 SSE 流| U
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.1 前后端职责
|
|
||||||
|
|
||||||
| 层 | 职责 | 禁止事项 |
|
|
||||||
|---|---|---|
|
|
||||||
| 前端 | 呈现会商、发送用户消息、消费 SSE、执行用户明确点击的取消/审批/重试 | 编排席位、决定下一位发言者、创建内部线程、轮询事件。 |
|
|
||||||
| MAF Conversation Service | 会话聚合、消息持久化、运行创建、SSE 重放、所有权校验 | 让浏览器传入 user_id、工具能力快照、目标键或内部幂等键。 |
|
|
||||||
| MAF Workflow | 意图判断、总控派活、席位协作、交叉审查、结论、恢复 | 把模型输出直接当作可写外部系统的授权。 |
|
|
||||||
| DeerFlow Adapter | 为总控/席位提供既有模型、工具、技能、MCP、沙箱和用户上下文 | 从 `packages/harness/deerflow/**` 导入 `app.*`。 |
|
|
||||||
| WriteGuard | 风险识别、审批、目标租约、一次执行、审计 | 将密钥、target_key、idempotency_key 回传给浏览器或模型。 |
|
|
||||||
|
|
||||||
### 2.2 新旧隔离规则
|
|
||||||
|
|
||||||
1. 旧会商继续运行,旧路由、旧 API、旧数据表、旧任务深链全部不变。
|
|
||||||
2. 新页面独立使用 `/api/maf-roundtable/*` 和 `maf_roundtable_*` 表;只允许参考旧页面的布局、文案、交互和通用视觉规范。
|
|
||||||
3. 旧 `roundtable-planning/**` 中的业务 hook、草稿、Job、SSE 消费器和状态类型不能被新页面直接 import。
|
|
||||||
4. 若需要复用旧组件的视觉效果,必须复制/迁移为 `maf-roundtable/legacy-compatible/**` 中的新组件,替换为 MAF view model;或抽到无业务依赖的公共展示组件。旧页面不得因此改变行为。
|
|
||||||
5. 新旧页面都只能使用仓库本地依赖;UI 基础组件统一采用 **`tdesign-react`**,不接远程 UI、CDN 或外部运行时。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 用户可见的新版会商流程
|
|
||||||
|
|
||||||
### 3.1 首次进入
|
|
||||||
|
|
||||||
1. 用户点击独立菜单“MAF 会商(升级版)”,进入与旧会商相同层级的页面壳。
|
|
||||||
2. 左侧显示自己的 MAF 会商历史和“新建会商”。新建只创建一个**用户可见会商**,不显示 session/run/thread 表单。
|
|
||||||
3. 中心区域显示欢迎语、业务链/任务上下文入口和对话输入框。
|
|
||||||
4. 用户输入任务后,前端立即插入本地的用户气泡,调用一次 `POST /messages`,然后消费同一会商的 SSE 流。
|
|
||||||
|
|
||||||
### 3.2 一次会商轮次
|
|
||||||
|
|
||||||
1. SSE 推送“正在理解任务”事件;页面以旧会商的过程卡/总控气泡表现。
|
|
||||||
2. 若信息不足,推送 `clarification.required`,页面显示可回复的澄清卡;用户回答后仍通过输入框或卡片提交。
|
|
||||||
3. 若可以执行,总控输出“派活计划”;页面以旧的席位条、派活卡和消息气泡展示。
|
|
||||||
4. 每个席位的可见发言、工具进度、技能过程、交叉审查和总控收束都写入同一条时间线。
|
|
||||||
5. 出现外部写操作时,SSE 推送审批卡。用户批准/拒绝后,后端恢复工作流,SSE 继续推送;前端不手工轮询 run 状态。
|
|
||||||
6. 最终结论、事实账本、执行动作和报告作为对话流中的结果卡呈现;用户可以继续在同一会商输入追问。
|
|
||||||
|
|
||||||
### 3.3 “一个会商”与内部线程的关系
|
|
||||||
|
|
||||||
- 用户只认识 `conversation_id`;左侧历史、URL 和前端状态都以它为主键。
|
|
||||||
- 一个 conversation 可以有多轮用户消息,每轮消息产生一个内部 `maf_run`。
|
|
||||||
- `maf_session`、manager/seat thread、seat run 都是服务端内部执行身份。服务端使用既有 `ExecutionPrincipal` 绑定当前用户,并在真正需要某席位执行时懒创建/复用线程。
|
|
||||||
- 浏览器只收到经过脱敏的消息和事件,不会收到内部 thread id、能力快照、目标键、幂等键、密钥或原始工具结果。
|
|
||||||
|
|
||||||
### 3.4 总控质量闸门:派活不是“发出即完成”
|
|
||||||
|
|
||||||
当前旧会商的问题不是没有派活,而是席位只说一两句话、格式和证据都不合格时,总控仍把它当作完成并直接进入下一步。新版 MAF 必须把总控从“消息转发者”升级为**每项席位交付的验收责任人**。
|
|
||||||
|
|
||||||
每一个由总控发布的席位任务都必须有服务端持久化的 `task_contract`:
|
|
||||||
|
|
||||||
| 字段 | 含义 |
|
|
||||||
|---|---|
|
|
||||||
| `objective` | 本席位要回答的具体问题,不能只写泛泛角色描述。 |
|
|
||||||
| `expected_deliverable` | 交付类型和结构,例如“风险清单 + 依据 + 优先级 + 建议”。 |
|
|
||||||
| `acceptance_criteria` | 由总控给出的可验收标准:覆盖范围、最少关键点、证据/工具依据、格式与约束。 |
|
|
||||||
| `required` | 是否为本阶段必需交付;必需项不能被静默跳过。 |
|
|
||||||
| `max_rework_attempts` | 同一席位最多返工次数,默认 **2**(初稿之外最多两次返工),服务端配置且写入快照。 |
|
|
||||||
| `fallback_policy` | 到达返工上限后的策略:改派、请求用户澄清,或带缺口受控推进。 |
|
|
||||||
|
|
||||||
#### 3.4.1 席位交付与总控验收状态机
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
stateDiagram-v2
|
|
||||||
[*] --> assigned
|
|
||||||
assigned --> delivering
|
|
||||||
delivering --> submitted
|
|
||||||
submitted --> manager_review
|
|
||||||
manager_review --> accepted: 达到合同标准
|
|
||||||
manager_review --> rework: 不合格且未到返工上限
|
|
||||||
rework --> delivering: 带明确退回意见
|
|
||||||
manager_review --> reassigned: 达到上限且可改派
|
|
||||||
reassigned --> delivering
|
|
||||||
manager_review --> exhausted: 达到上限且无合适改派
|
|
||||||
exhausted --> controlled_gap: 记录缺口、风险与原因
|
|
||||||
accepted --> [*]
|
|
||||||
controlled_gap --> [*]
|
|
||||||
```
|
|
||||||
|
|
||||||
验收规则如下:
|
|
||||||
|
|
||||||
1. 席位先提交结构化 `delivery`,至少包含结论、依据/证据、未解决项、自检结果;不能只输出“已完成”或一两句空泛结论。
|
|
||||||
2. 进入 `manager_review` 前先执行确定性检查:空内容、最小有效长度、必须章节、必填字段、所需证据/工具引用、重复文本、错误 schema。确定性不通过时不允许直接 accepted。
|
|
||||||
3. 总控随后根据 `task_contract` 做结构化审查,决策只能是 `accept`、`return_for_rework`、`reassign`、`ask_user`、`controlled_gap`,并必须写明 `reason` 和针对性修改要求。
|
|
||||||
4. **总控的模型自评不是验收证据。** 席位自评分只作为参考;验收由确定性规则 + 总控审查 + 必要时交叉审查共同决定。
|
|
||||||
5. 对同一席位,默认最多两次返工。每次打回都必须把“缺什么、怎样补、哪些原结论不可用”带入下一次任务上下文。
|
|
||||||
6. 到达上限后绝不把不合格结果伪装成合格:总控应优先改派给一个合适备用席位(默认最多一次改派);仍无法满足时生成 `controlled_gap`,明确标记未覆盖问题、风险和对最终结论的影响,再受控进入下一阶段。
|
|
||||||
7. 一个阶段的所有 `required` 任务必须处于 `accepted` 或 `controlled_gap` 等明确终态,阶段闸门才可通过。总控不能因为“轮数到了”或“已有部分回答”跳过未决必需项。
|
|
||||||
8. 最终报告不得将 `controlled_gap` 的内容写成已验证事实;它必须在结论中的风险/待补信息部分可追溯显示。
|
|
||||||
|
|
||||||
这既满足“打回几次后不要死循环”的要求,也避免“超过次数就把垃圾答案强行推进”的错误行为。
|
|
||||||
|
|
||||||
#### 3.4.2 对话页必须可见的质量反馈
|
|
||||||
|
|
||||||
- 总控派活卡展示任务目标、交付要求和当前第几次尝试;
|
|
||||||
- 席位提交后显示“总控审核中”,而不是立即标为完成;
|
|
||||||
- 打回时以总控消息卡显示具体退回理由和补充要求;
|
|
||||||
- 改派时显示原席位未通过和新席位承接范围,但不羞辱性展示内部评分;
|
|
||||||
- 受控降级时显示“存在未解决缺口”,并在最终结果卡中保留风险提示;
|
|
||||||
- 右侧席位条区分“执行中、待返工、已验收、已改派、存在缺口”,不能只显示“发过言”。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. API 与 SSE 契约(必须先定,前端不得猜测)
|
|
||||||
|
|
||||||
### 4.1 Conversation API
|
|
||||||
|
|
||||||
以下接口全部位于 `/api/maf-roundtable`,身份只从服务端鉴权上下文读取。
|
|
||||||
|
|
||||||
| 接口 | 用途 | 前端调用时机 |
|
|
||||||
|---|---|---|
|
|
||||||
| `GET /conversations` | 左侧历史列表(分页) | 首次进入、用户显式刷新、创建/删除后失效刷新;**无定时轮询**。 |
|
|
||||||
| `POST /conversations` | 创建空白用户会商 | 点击“新建会商”。只接收 title、任务上下文等安全字段。 |
|
|
||||||
| `GET /conversations/{id}` | 获取会商快照、权限和当前状态 | 进入会商或浏览器刷新时。 |
|
|
||||||
| `GET /conversations/{id}/messages` | 获取历史消息分页 | 进入会商、向上滚动加载历史。 |
|
|
||||||
| `POST /conversations/{id}/messages` | 原子写入用户消息并启动 MAF run | 用户点击发送或提交澄清。返回 `message_id`、`run_id`、`conversation_id`。 |
|
|
||||||
| `POST /conversations/{id}/cancel` | 取消当前活跃 run | 用户显式点击取消。 |
|
|
||||||
| `POST /approvals/{id}/decision` | 批准/拒绝写入动作 | 审批卡点击;成功后由后端恢复 workflow。 |
|
|
||||||
| `POST /conversations/{id}/recover` | 显式恢复异常中断的活跃会商 | 仅在后端判定可恢复时展示。 |
|
|
||||||
|
|
||||||
不再让页面以“创建 session → 创建 run → GET events 循环”的方式驱动会商。原有低层 session/run 接口可以作为内部诊断 API 暂时保留,但从用户页面和导航移除。
|
|
||||||
|
|
||||||
### 4.2 SSE:唯一的实时通道
|
|
||||||
|
|
||||||
```text
|
|
||||||
GET /api/maf-roundtable/conversations/{conversation_id}/stream?after_seq={last_seen_seq}
|
|
||||||
Accept: text/event-stream
|
|
||||||
Authorization: 与现有 fetch 客户端一致
|
|
||||||
```
|
|
||||||
|
|
||||||
- 前端在进入一个 conversation 后只建立**一条** fetch-reader SSE 连接;切换会商、卸载页面、权限失效时 Abort。
|
|
||||||
- 浏览器不能用定时 GET 代替 SSE。连接断开后采用指数退避重连,并携带最后已确认的 `after_seq`;不产生全量重复气泡。
|
|
||||||
- `after_seq` 是 conversation 维度、服务端单调递增的序号,不能复用每个 run 独立的 `seq`。
|
|
||||||
- 首次连接先由 `GET messages` 加载历史,再从该历史的最大 `seq` 订阅 SSE;服务端也必须支持重放,避免“历史请求和订阅之间”丢事件。
|
|
||||||
- SSE 使用 `fetch` + `ReadableStream` 解析,以复用项目鉴权头;响应必须带 `Content-Type: text/event-stream`、`Cache-Control: no-cache`、`X-Accel-Buffering: no`,并每 15 秒发送心跳。
|
|
||||||
- 服务端负责等待和推送。单 worker 可用进程内 conversation event hub;生产多 worker 部署前必须接入 Redis Pub/Sub 或等价分布式广播,不能退回浏览器轮询。
|
|
||||||
|
|
||||||
### 4.3 SSE 事件信封
|
|
||||||
|
|
||||||
所有事件统一为:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"seq": 42,
|
|
||||||
"event_id": "evt_...",
|
|
||||||
"conversation_id": "conv_...",
|
|
||||||
"run_id": "run_...",
|
|
||||||
"type": "agent.message.delta",
|
|
||||||
"occurred_at": "2026-08-02T12:00:00Z",
|
|
||||||
"data": {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
允许的首批 `type`:
|
|
||||||
|
|
||||||
| 类型 | 对话页表现 |
|
|
||||||
|---|---|
|
|
||||||
| `conversation.snapshot` | 首帧状态同步,不重复插入消息。 |
|
|
||||||
| `user.message.created` | 刷新/多标签同步时补齐用户气泡。 |
|
|
||||||
| `manager.intent` | 总控“正在理解/已理解”的过程卡。 |
|
|
||||||
| `clarification.required` / `clarification.resolved` | 澄清卡及其回答状态。 |
|
|
||||||
| `manager.dispatch` | 总控派活卡、席位条状态更新。 |
|
|
||||||
| `seat.delivery.submitted` | 席位提交交付,进入总控审核而非直接完成。 |
|
|
||||||
| `manager.review.decision` | 展示验收、打回、改派或受控降级及其安全摘要。 |
|
|
||||||
| `seat.rework.assigned` / `seat.reassigned` | 更新第几次返工、原/新席位和新的任务要求。 |
|
|
||||||
| `stage.quality_gate` | 阶段质量闸门通过、阻塞或带缺口推进。 |
|
|
||||||
| `agent.message.start` / `delta` / `complete` | 席位或总控流式气泡。 |
|
|
||||||
| `tool.started` / `tool.progress` / `tool.completed` | 现有步骤卡/工具过程,不显示敏感参数。 |
|
|
||||||
| `approval.required` / `approval.decided` | 嵌入对话流的审批卡。 |
|
|
||||||
| `fact.confirmed` / `review.completed` | 事实/交叉审查摘要卡。 |
|
|
||||||
| `run.status` / `run.error` / `run.completed` | 顶部状态、失败重试与最终结果。 |
|
|
||||||
|
|
||||||
事件 `data` 必须先经过现有 MAF 安全规范化和二次展示脱敏;前端不负责猜测工具风险或修复敏感数据。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 后端改造计划
|
|
||||||
|
|
||||||
### MAF-R0:冻结错误原型并建立旧会商基线
|
|
||||||
|
|
||||||
**难度:D2(中低)|负责人:前端/测试各 1 人|前置:无**
|
|
||||||
|
|
||||||
- 将现有 `/page/maf/*` 标记为“实验原型”,从正式菜单移除;不得在其上继续新增业务功能。
|
|
||||||
- 记录旧会商的页面状态:空会商、发送后、意图澄清、总控派活、席位流式发言、审批、完成、失败恢复、历史切换。
|
|
||||||
- 为每种状态保存可复现的测试数据与截图基线;截图只作为视觉验收参考,不作为数据契约。
|
|
||||||
- 输出旧组件到新 MAF view-model 的映射清单,禁止直接搬旧 hook。
|
|
||||||
|
|
||||||
**验收:** 旧会商无行为回归;团队对“复制体验、替换编排”有书面映射表。
|
|
||||||
|
|
||||||
### MAF-R1:Conversation 聚合与消息持久化
|
|
||||||
|
|
||||||
**难度:D5(高)|负责人:后端资深 1 人|前置:R0**
|
|
||||||
|
|
||||||
新增/迁移 MAF 专用持久化模型:
|
|
||||||
|
|
||||||
- `maf_roundtable_conversations`:用户可见会商,至少包含 id、owner_user_id、title、task_context、安全状态、active_run_id、created_at、updated_at;
|
|
||||||
- `maf_roundtable_messages`:用户消息、总控/席位消息、澄清/审批/结果卡的稳定历史表示;
|
|
||||||
- `maf_roundtable_conversation_events`:conversation 维度严格递增的 `seq`、event_id、run_id、type、安全 data、created_at;
|
|
||||||
- 现有 `maf_roundtable_sessions/runs/events/seat_runs/actions/approvals` 保留为底层执行与审计记录;conversation 与内部 session/run 显式关联。
|
|
||||||
|
|
||||||
要求:
|
|
||||||
|
|
||||||
1. 发送用户消息、创建/占用 run、写入首个用户消息事件必须在清晰的事务边界内完成;失败不留“消息已显示但未运行”的孤儿状态。
|
|
||||||
2. 消息历史必须可在刷新后重建对话;不能只依赖内存或 SSE 临时帧。
|
|
||||||
3. 所有读写继续 owner-only;管理员诊断只读不扩大写权限。
|
|
||||||
4. 新迁移必须 SQLite/PostgreSQL/MySQL 可重复执行;旧 MAF 原型数据可保留,不强制伪造为新对话历史。
|
|
||||||
|
|
||||||
**验收:** 会话创建、连续发消息、刷新恢复、并发发送冲突、跨用户隔离、迁移幂等都有 API 和存储测试。
|
|
||||||
|
|
||||||
### MAF-R2:服务端 SSE Hub 与断线续传
|
|
||||||
|
|
||||||
**难度:D5(高)|负责人:后端流式/并发经验者 1 人|前置:R1**
|
|
||||||
|
|
||||||
- 新增 conversation stream 路由和 `ConversationEventHub`;Workflow/Dispatcher 的规范化事件落库后再发布,顺序是“先持久化、后通知”。
|
|
||||||
- SSE 连接先按 `after_seq` 重放,再订阅实时事件;重放和订阅切换期间必须有序、不丢帧、不重复。
|
|
||||||
- workflow 终态、审批等待、审批恢复、取消、失败重试都必须通过同一流通知。
|
|
||||||
- 不允许每个 SSE 客户端创建数据库轮询循环。服务端可以阻塞等待 hub 通知,事件重放只在连接/重连时发生。
|
|
||||||
- 写出单 worker 与多 worker 的实现边界;若部署配置多 worker,未接入分布式广播时健康检查必须明确告警并禁止该模式上线。
|
|
||||||
|
|
||||||
**验收:** 测试覆盖慢客户端、断线重连、`after_seq` 重放、同时打开两个标签、切换会商、取消、审批后恢复、进程内 hub 异常和终态自动关流。
|
|
||||||
|
|
||||||
### MAF-R3:总控意图理解与派活语义对齐
|
|
||||||
|
|
||||||
**难度:D5(高)|负责人:MAF/Agent 负责人 1 人|前置:R1,可与 R2 并行**
|
|
||||||
|
|
||||||
在现有 MAF Workflow 上增加用户对话入口状态,而不是让前端预先填 manager/seat/run:
|
|
||||||
|
|
||||||
1. 总控首先进行意图判断:普通问答、需要澄清、单席位任务、并行专家会商、链式业务会商、需要工具/写入。
|
|
||||||
2. 总控产出受 schema 校验的派活计划;只有服务端可将计划转成实际席位执行。
|
|
||||||
3. 每个计划项必须生成并冻结 `task_contract`(目标、交付结构、验收标准、必需性、返工上限和降级策略),席位的发言不等于交付完成。
|
|
||||||
4. 席位完成后先做确定性完整性检查,再由总控做结构化验收;总控可接受、打回同一席位、改派、请求用户补充或记录受控缺口。所有决策、次数和理由持久化到 checkpoint 与审计事件。
|
|
||||||
5. 默认初稿后最多两次同席位返工;到达上限优先改派一次,仍失败才以带风险标记的 `controlled_gap` 结束。严禁自动把不合格交付推进为合格事实。
|
|
||||||
6. 阶段迁移需经过质量闸门:所有必需任务均有 `accepted` 或受控缺口终态;最终总结只能消费已验收事实,缺口必须显式传入风险章节。
|
|
||||||
7. 席位集合来自业务链、可信能力快照和上下文;前端可呈现“建议/已参与席位”,但不能提交任意工具权限或 thread id。
|
|
||||||
8. 总控每轮应吸收已确认事实、验收结果、退回原因和用户追问;弱模型无效派活、重复派活、无收敛时走确定性纠偏/终止策略。
|
|
||||||
9. 已有 structured/group_chat、事实账本、交叉审查、审批 checkpoint、执行阶段身份均继续复用;只重构其输入输出为 conversation event。
|
|
||||||
|
|
||||||
**验收:** 用同一任务对照旧会商的意图澄清、总控派活、席位协作、打回/改派和收束结论;弱模型的短答、空答、错误 schema、缺证据、重复派活、非法席位、超轮次、返工上限和受控降级都有确定性保护测试。
|
|
||||||
|
|
||||||
#### R3 当前实施记录(2026-08-03)
|
|
||||||
|
|
||||||
后端已先完成 R3 的服务端核心骨架,作为 R1/R2 Conversation API/SSE 的后续:
|
|
||||||
|
|
||||||
- `app/maf_roundtable/dispatch_contracts.py` 提供确定性意图分类、服务端业务路线解析、schema 校验的 `MafDispatchPlan` 和弱模型防护;请求、模型输出都不能提供 agent id、thread id、tool metadata 或能力快照。
|
|
||||||
- `MafRoundtableConfig` 增加 `default_manager_agent_id`、`default_seat_agent_ids`、`business_routes` 和 `max_seats_per_run`。仓库配置保持空路线;上线前必须由部署配置填入实际 agent catalog 中的精确 id,未知业务路线失败关闭。
|
|
||||||
- 首条消息在同一事务内创建内部 session、服务端选定的 manager/seat participants 和冻结能力快照;澄清意图进入 `waiting_input`,不创建 run,用户补充信息后才允许重新 claim。
|
|
||||||
- Conversation service 已接入现有 `MafRunDispatcher`/`WorkflowRunner`,持久化 `manager.intent`、`manager.dispatch` 和 run 状态桥接事件;现有结构化工作流的席位集合仍只来自服务端 participants,manager 文本不能扩席或改派非法席位。
|
|
||||||
- V2 `POST /conversations/{id}/messages` 只返回 `message_id`、`conversation_id`、`seq`;`run_id` 仍是持久层/服务端内部关联字段,不返回给浏览器。
|
|
||||||
- 新增 `tests/test_maf_phase3_conversation_dispatch.py`,覆盖未知业务路线、默认路线、澄清、完成状态桥接、重复/越权派发计划等确定性用例。R3 尚未整体验收完成:仍需完成新页面、完整真实 MAF Workflow E2E、质量门禁和与旧会商体验的浏览器对照验收。
|
|
||||||
|
|
||||||
### MAF-R4:将审批和外部写入嵌回对话主链路
|
|
||||||
|
|
||||||
**难度:D4(较高)|负责人:后端安全 + 前端各 1 人|前置:R2、R3**
|
|
||||||
|
|
||||||
- 保留 Phase 5 的 WriteGuard、能力快照、HMAC 目标键、租约、加密参数、一次执行和审计;不得为了 SSE 改弱任何安全边界。
|
|
||||||
- `approval.required` 只携带白名单展示字段和安全摘要。审批决定后由后端自动续跑,前端只等待同一 SSE 流中的新事件。
|
|
||||||
- 拒绝、过期、执行失败、unknown 均形成清晰对话卡和可恢复状态;不得把“未知结果”伪装成成功。
|
|
||||||
- 行为审计可在最终结果区展开查看,但不能替代对话中的关键用户反馈。
|
|
||||||
|
|
||||||
**验收:** HTTP → MAF → ToolNode/outbox → 审批 → SSE → 前端卡片 的端到端测试;确保 target_key、idempotency_key、internal_canary、密钥和原始工具结果从不进入消息/API/SSE。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 前端改造计划:复刻体验,替换状态源
|
|
||||||
|
|
||||||
### MAF-R5:新会商页面壳与历史列表
|
|
||||||
|
|
||||||
**难度:D4(较高)|负责人:熟悉旧会商的前端 1 人|前置:R0、R1**
|
|
||||||
|
|
||||||
新建 `src/maf-roundtable/conversation/**`,建议结构:
|
|
||||||
|
|
||||||
```text
|
|
||||||
maf-roundtable/conversation/
|
|
||||||
pages/MafRoundtableConversationPage.tsx
|
|
||||||
components/
|
|
||||||
ConversationSidebar.tsx
|
|
||||||
ConversationTimeline.tsx
|
|
||||||
ConversationComposer.tsx
|
|
||||||
ManagerDispatchCard.tsx
|
|
||||||
SeatMessageBubble.tsx
|
|
||||||
ToolProgressCard.tsx
|
|
||||||
ClarificationCard.tsx
|
|
||||||
ApprovalMessageCard.tsx
|
|
||||||
FinalResultCard.tsx
|
|
||||||
ParticipantStrip.tsx
|
|
||||||
hooks/
|
|
||||||
use-maf-conversations.ts
|
|
||||||
use-maf-conversation-stream.ts
|
|
||||||
use-maf-conversation-reducer.ts
|
|
||||||
api/conversations.ts
|
|
||||||
lib/event-reducer.ts
|
|
||||||
styles/maf-roundtable-legacy-compatible.css
|
|
||||||
```
|
|
||||||
|
|
||||||
页面要求:
|
|
||||||
|
|
||||||
- 左侧 `ConversationSidebar` 的层级、历史列表、新建、激活态、删除/重命名交互对齐旧会商;
|
|
||||||
- 中间 `ConversationTimeline` 使用连续消息流,保留旧会商的用户气泡、总控气泡、席位气泡和时间锚点;
|
|
||||||
- `ConversationComposer` 常驻底部;发送、停止、澄清回答、追问都在该入口完成;
|
|
||||||
- 右侧仅保留旧体验中真正有用的参与席位、当前阶段和业务链摘要,不再摆放 run/session 管理后台;
|
|
||||||
- 使用本地 `tdesign-react` 组件实现按钮、输入、Dialog、Tag、Tabs 等基础交互,同时通过 scoped CSS 对齐旧视觉;
|
|
||||||
- 视觉相似不等于 import 旧业务组件。旧组件仅作参考,数据类型、hook 和 API 必须是 MAF 专用实现。
|
|
||||||
|
|
||||||
**验收:** 不连接后端时,使用固定 MAF 事件样本可完整演示旧会商同等的 8 种关键状态;没有 session/run/thread 表单。
|
|
||||||
|
|
||||||
### MAF-R6:SSE 客户端、事件 reducer 与刷新恢复
|
|
||||||
|
|
||||||
**难度:D5(高)|负责人:前端流式经验者 1 人|前置:R2、R5**
|
|
||||||
|
|
||||||
- `use-maf-conversation-stream` 只维护一个连接、一个 cursor、一个 AbortController;不得含 `setInterval`、`refetchInterval` 或轮询 fallback。
|
|
||||||
- reducer 以 `(conversation_id, seq)` 去重并排序,将原始事件转为稳定 view model;流式 delta 必须按 `message_id` 合并,不能用数组下标。
|
|
||||||
- 切换会商时先 abort 旧流并增加 generation;旧 SSE 帧永远不能污染新会商。
|
|
||||||
- 刷新时先加载消息历史,再以最后 seq 订阅;网络断开显示“正在重连”,重连成功不重复气泡。
|
|
||||||
- 只有历史列表等非实时资源可在用户主动操作后调用 GET;不能靠后台刷新伪装实时性。
|
|
||||||
|
|
||||||
**验收:** 模拟 1 万条历史、跨页加载、慢旧流切换、新流先到、断线重连、重复帧、乱序帧、终态关流和多标签同步;所有案例必须组件级测试。
|
|
||||||
|
|
||||||
### MAF-R7:意图/派活/审批卡的旧体验兼容
|
|
||||||
|
|
||||||
**难度:D4(较高)|负责人:前端业务负责人 1 人|前置:R3、R4、R6**
|
|
||||||
|
|
||||||
- 用旧 `IntentClarificationCard`、`MessageStepsCard`、`ParallelSeatStrip`、`MessageBubble` 的交互作为视觉和信息架构参照,重新实现 MAF 版本。
|
|
||||||
- 总控派活计划必须先显示“将由谁做什么”,席位开始/结束、工具过程和交叉审查要回到原有对话叙事中。
|
|
||||||
- 席位“发言结束”只能显示为“已提交,待总控审核”;只有收到 `manager.review.decision=accept` 才能显示“已验收”。打回次数、改派和缺口必须清楚可见。
|
|
||||||
- 审批卡需要同步防双击锁、二次脱敏、拒绝/通过后的“等待工作流继续”状态;不可另外跳转到 action 审计页。
|
|
||||||
- 最终结论卡要区分“建议动作”“审批中动作”“已执行动作”“失败/未知动作”。
|
|
||||||
- 继续追问时,页面不新建多个用户可见线程;后端根据 conversation 上下文决定是否创建新 run 或继续恢复。
|
|
||||||
|
|
||||||
**验收:** 产品人员可在不看技术术语的情况下完成“发起会商 → 澄清 → 观察派活 → 审批 → 查看结论 → 追问”全链路。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 测试、上线与回滚
|
|
||||||
|
|
||||||
### 7.1 必须新增的自动化测试
|
|
||||||
|
|
||||||
| 类别 | 核心用例 |
|
|
||||||
|---|---|
|
|
||||||
| 后端事务 | 发消息/创建 run 原子性、并发发送、跨用户隔离、迁移幂等。 |
|
|
||||||
| SSE 服务端 | 重放、顺序、心跳、取消、断线、审批恢复、多订阅者、终态关流。 |
|
|
||||||
| Workflow | 意图澄清、总控派活、弱模型格式错误、重复派活、最大轮次、恢复。 |
|
|
||||||
| 质量闸门 | 短答/空答、缺少必填章节、无证据、总控打回、两次返工上限、一次改派、受控缺口、阶段阻塞和最终报告不把缺口伪装成事实。 |
|
|
||||||
| 安全 | SSE/API/历史消息不泄露敏感键和值;审批和一次写入仍保持现有 Phase 5 保证。 |
|
|
||||||
| 前端 reducer | delta 合并、去重、乱序、切换代次隔离、重连 cursor。 |
|
|
||||||
| 前端组件 | 输入发送、澄清、派活、审批锁、取消、失败恢复、历史切换、无轮询断言。 |
|
|
||||||
| E2E | 从用户输入到真实 MAF Workflow、ToolNode/outbox、审批、最终结果的 SSE 全链路。 |
|
|
||||||
| 回归 | 旧 `roundtable-planning`、旧 DeerFlow、AI 写作以及既有 MAF Phase 1–5 用例全部通过。 |
|
|
||||||
|
|
||||||
### 7.2 人工视觉验收
|
|
||||||
|
|
||||||
在同一浏览器尺寸下对照旧会商,至少核对:
|
|
||||||
|
|
||||||
1. 空白页和新建会商;
|
|
||||||
2. 左侧历史/当前会商切换;
|
|
||||||
3. 用户输入、流式总控消息、席位消息;
|
|
||||||
4. 意图澄清和总控派活;
|
|
||||||
5. 工具过程、审批、取消、失败恢复;
|
|
||||||
6. 最终结论和连续追问;
|
|
||||||
7. 窄屏滚动、长内容、断线重连和页面刷新。
|
|
||||||
|
|
||||||
### 7.3 灰度与回滚
|
|
||||||
|
|
||||||
- 新入口使用独立 feature flag,例如 `maf_roundtable.conversation_ui_enabled`;旧会商不受该开关影响。
|
|
||||||
- 先仅向内部账号开放,记录连接数、重连率、事件延迟、工作流失败率、审批耗时和前端异常。
|
|
||||||
- 出现问题时关闭新入口即可;不迁移或删除旧会商数据,也不需要回滚旧页面。
|
|
||||||
- 当前错误原型可保留为仅开发者诊断路由,或在新版稳定后删除;它不应继续出现在用户菜单。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 分阶段任务分配、难度与交付顺序
|
|
||||||
|
|
||||||
本节是分工的唯一依据。难度定义:D1=很低(局部文案/样式),D2=低(明确边界的小模块),D3=中(跨 2–3 个模块),D4=较高(跨前后端或需回归),D5=高(并发、状态机、安全或 Agent 行为,必须由有经验者负责)。
|
|
||||||
|
|
||||||
### 阶段 0:冻结错误原型,提取旧会商交互基线(R0)
|
|
||||||
|
|
||||||
**难度:D2|建议人员:1 名熟悉旧会商的前端 + 1 名测试|依赖:无**
|
|
||||||
|
|
||||||
具体要做:
|
|
||||||
|
|
||||||
1. 将现有 `/page/maf/*` 的 session/run 详情原型从正式菜单隐藏,标注为开发诊断原型,停止继续加功能。
|
|
||||||
2. 逐一记录旧会商的 8 类页面状态:空白/新建、历史切换、用户提问、意图澄清、总控派活、席位发言、审批、完成/失败恢复。
|
|
||||||
3. 给每种状态保留可复现 mock 数据、页面截图和操作步骤;梳理旧组件到新 MAF view-model 的映射。
|
|
||||||
4. 明确哪些只能参考视觉和交互,哪些旧业务 hook/API/状态禁止复用。
|
|
||||||
|
|
||||||
交付物:旧会商交互地图、截图基线、组件映射表、被隐藏的原型入口说明。
|
|
||||||
|
|
||||||
验收:旧会商没有代码行为变化;任何人可以根据基线判断新版是否“像原页面”。
|
|
||||||
|
|
||||||
### 阶段 1:建立用户可见 Conversation 与消息历史(R1)
|
|
||||||
|
|
||||||
**难度:D5|建议人员:1 名资深后端|依赖:阶段 0**
|
|
||||||
|
|
||||||
具体要做:
|
|
||||||
|
|
||||||
1. 新增 `maf_roundtable_conversations`、`maf_roundtable_messages`、`maf_roundtable_conversation_events`,设计 owner、标题、消息、conversation 全局序号、活跃 run 和安全状态字段。
|
|
||||||
2. 建立 conversation 与现有 MAF session/run/seat-run 的内部关联;用户只得到 conversation id,不能看到内部 thread id。
|
|
||||||
3. 实现会商历史列表、创建空白会商、读取会商快照、读取消息分页、发送用户消息五类 API。
|
|
||||||
4. 发送消息时,用户消息持久化、run 创建/占用和首个事件必须有明确事务边界;并发发送、重复请求、失败回滚都要处理。
|
|
||||||
5. 完成 SQLite/PostgreSQL/MySQL 迁移与 owner-only 权限测试。
|
|
||||||
|
|
||||||
交付物:迁移脚本、repository/service/router、API schema、后端事务与隔离测试。
|
|
||||||
|
|
||||||
验收:刷新后能从消息历史恢复完整会商;跨用户不可读写;失败不会留下“已显示但没有实际运行”的孤儿消息。
|
|
||||||
|
|
||||||
### 阶段 2:服务端 SSE 对话流、事件 Hub 与断线续传(R2)
|
|
||||||
|
|
||||||
**难度:D5|建议人员:1 名熟悉 FastAPI 异步流与并发的后端|依赖:阶段 1**
|
|
||||||
|
|
||||||
具体要做:
|
|
||||||
|
|
||||||
1. 实现 `GET /conversations/{id}/stream?after_seq=`,按 conversation 全局 seq 先重放、后订阅实时事件。
|
|
||||||
2. 新建 `ConversationEventHub`;所有 workflow 事件先安全落库,再按顺序广播。禁止 SSE 客户端通过定时查库等待更新。
|
|
||||||
3. SSE 实现心跳、连接取消、权限校验、终态关流、异常帧和断线后的 cursor 续传。
|
|
||||||
4. 明确多 worker 部署方案:单 worker 可进程内广播;多 worker 必须 Redis Pub/Sub 或等价方案,未满足时不能灰度上线。
|
|
||||||
5. 把 run 状态、总控派活、席位消息 delta、工具过程、审批、质量审核、最终结论全部映射为统一事件信封。
|
|
||||||
|
|
||||||
交付物:SSE router/hub、事件发布适配器、集成测试、部署说明。
|
|
||||||
|
|
||||||
验收:慢连接、断线重连、乱序/重复防护、多标签订阅、审批后续跑、取消和终态均不丢事件;前端不需要轮询任何实时资源。
|
|
||||||
|
|
||||||
### 阶段 3:总控意图理解、业务链选择与服务端派活(R3)
|
|
||||||
|
|
||||||
**难度:D5|建议人员:1 名 MAF/Agent 工程师|依赖:阶段 1;可与阶段 2 并行**
|
|
||||||
|
|
||||||
具体要做:
|
|
||||||
|
|
||||||
1. 将旧会商的意图理解能力迁到 MAF 总控入口:识别直接问答、需澄清、单席位、并行会商、链式业务会商、工具/写入请求。
|
|
||||||
2. 总控根据业务链、用户上下文、可信能力快照选择席位,生成 schema 校验的派活计划;前端只展示,不参与编排。
|
|
||||||
3. Dispatcher 在服务端按需创建/复用 manager 和 seat 的内部执行线程,并由 `ExecutionPrincipal` 绑定当前用户。
|
|
||||||
4. 对弱模型增加确定性保护:非法席位、空派活、重复派活、无效 schema、超轮次、无法收束必须被拒绝、纠偏或终止。
|
|
||||||
5. 保持既有工具、技能、MCP、沙箱、事实账本、交叉审查和 checkpoint 恢复可用。
|
|
||||||
|
|
||||||
交付物:总控状态 schema、workflow executor、派活事件、弱模型测试矩阵。
|
|
||||||
|
|
||||||
验收:用户只输入任务,就能看到“意图理解 → 总控派活 → 席位执行”;浏览器没有 thread map 和派活决策代码。
|
|
||||||
|
|
||||||
### 阶段 4:席位任务合同、总控验收、打回与强制收束(R3-Q)
|
|
||||||
|
|
||||||
**难度:D5|建议人员:1 名 Agent/MAF 工程师 + 1 名后端状态机工程师|依赖:阶段 3**
|
|
||||||
|
|
||||||
具体要做:
|
|
||||||
|
|
||||||
1. 总控发布任务时冻结 `task_contract`:具体目标、交付格式、验收标准、必需性、返工次数、改派与降级策略。
|
|
||||||
2. 为每项席位任务持久化状态机:`assigned → delivering → submitted → manager_review → accepted/rework/reassigned/controlled_gap`。
|
|
||||||
3. 席位提交后先做确定性质量检查:空答、过短、必填章节缺失、格式错、无证据、重复内容等均不能自动完成。
|
|
||||||
4. 总控必须生成结构化验收决定和原因;不合格时打回并带精确补充要求。默认最多两次同席位返工,之后最多一次改派。
|
|
||||||
5. 改派仍失败时只能产生 `controlled_gap`,标记风险、缺失项和最终报告影响;严禁伪装为成功。
|
|
||||||
6. 增加阶段质量闸门:所有必需任务处于 `accepted` 或 `controlled_gap`,才允许进入交叉审查/下一阶段/最终总结。
|
|
||||||
7. 将尝试次数、退回理由、验收结果和缺口写入 checkpoint、审计和 SSE。
|
|
||||||
|
|
||||||
交付物:任务合同模型、持久化表/迁移、质量状态机、总控审查 prompt/schema、事件和测试。
|
|
||||||
|
|
||||||
验收:席位只说一两句话、无依据或内容垃圾时会被明确打回;达到上限不会死循环,也不会被当作合格产出;最终报告可追溯所有缺口。
|
|
||||||
|
|
||||||
### 阶段 5:审批、WriteGuard 与外部动作嵌入对话流(R4)
|
|
||||||
|
|
||||||
**难度:D4|建议人员:1 名安全后端 + 1 名前端|依赖:阶段 2、3、4**
|
|
||||||
|
|
||||||
具体要做:
|
|
||||||
|
|
||||||
1. 保留现有 Phase 5 的可信能力快照、WriteGuard、目标租约、HMAC、加密参数、一次执行与审计,不允许为新页面降低安全性。
|
|
||||||
2. 将工具开始、审批请求、批准/拒绝、执行成功/失败/unknown 变成安全 SSE 事件和对话卡。
|
|
||||||
3. 审批点击只调用明确决策 API;后端负责恢复 checkpoint/workflow,浏览器只等待 SSE。
|
|
||||||
4. 安全展示统一二次脱敏,禁止 target_key、idempotency_key、internal_canary、密钥、凭据和原始工具结果进入 API、消息、SSE 或 DOM。
|
|
||||||
|
|
||||||
交付物:事件映射、审批消息卡契约、端到端 ToolNode/outbox 测试。
|
|
||||||
|
|
||||||
验收:一次真实测试写入能在对话里经历“申请 → 审批 → 继续 → 结果”;重复点击或断线都不会重复外部写入。
|
|
||||||
|
|
||||||
### 阶段 6:复刻旧会商页面壳、历史列表和输入框(R5)
|
|
||||||
|
|
||||||
**难度:D4|建议人员:1 名熟悉旧会商代码的前端|依赖:阶段 0、1**
|
|
||||||
|
|
||||||
具体要做:
|
|
||||||
|
|
||||||
1. 新建 MAF 专用 conversation 页面、路由和独立 feature flag;不修改旧 `RoundtablePlanningPage`。
|
|
||||||
2. 依据阶段 0 的映射,复刻左侧历史会话、新建会商、中心时间线、常驻输入框、参与席位区和业务链摘要的视觉与交互层级。
|
|
||||||
3. 基础组件使用本地 `tdesign-react`,旧页面仅参考;需要的视觉组件在 MAF 目录重写或抽无业务依赖公共组件。
|
|
||||||
4. 接上 Conversation API:创建、历史、切换、消息分页。此阶段不允许先做任何轮询实时逻辑。
|
|
||||||
5. 删除新页面中暴露给用户的 manager/seat/max round/session/run 创建表单。
|
|
||||||
|
|
||||||
交付物:新页面壳、MAF 专用 view model、静态/接口 mock 组件测试、视觉对照截图。
|
|
||||||
|
|
||||||
验收:即使 SSE 未接通,用户也能看到与旧会商同类的历史、对话与输入体验;新旧业务状态零耦合。
|
|
||||||
|
|
||||||
### 阶段 7:前端 SSE 消费、对话 reducer 与过程交互(R6 + R7)
|
|
||||||
|
|
||||||
**难度:D5|建议人员:1 名前端流式工程师 + 1 名前端业务工程师|依赖:阶段 2、4、5、6**
|
|
||||||
|
|
||||||
具体要做:
|
|
||||||
|
|
||||||
1. 实现单连接 `use-maf-conversation-stream`:fetch-reader SSE、cursor、AbortController、指数退避重连和会商切换 generation 隔离。
|
|
||||||
2. 实现 event reducer:按 `conversation_id + seq` 去重排序;按 message_id 合并 delta;刷新后历史与 SSE 无缝衔接。
|
|
||||||
3. 重写旧会商对应的 MAF 版组件:总控意图卡、派活卡、席位条、席位气泡、工具步骤、澄清卡、审批卡、质量审核/打回卡、结果卡。
|
|
||||||
4. 所有卡片在时间线内叙事,不另开 run/session 管理后台;继续追问仍使用同一输入框。
|
|
||||||
5. 页面必须显示待审核、已验收、返工次数、改派、受控缺口和恢复状态,不能把“发过言”显示为“已完成”。
|
|
||||||
6. 代码审查和测试中检查不存在 `setInterval`、`refetchInterval` 或事件轮询 fallback。
|
|
||||||
|
|
||||||
交付物:SSE hook、reducer、对话组件、组件/API mock 测试、无轮询静态检查。
|
|
||||||
|
|
||||||
验收:切换会商、断线重连、审批续跑、慢旧流回包、长会话翻页均不污染、不丢消息、不重复;整个会商过程只依赖 SSE 实时更新。
|
|
||||||
|
|
||||||
### 阶段 8:端到端验收、灰度和回滚演练(R8)
|
|
||||||
|
|
||||||
**难度:D4|建议人员:1 名测试/集成人 + 各阶段负责人配合|依赖:阶段 1–7**
|
|
||||||
|
|
||||||
具体要做:
|
|
||||||
|
|
||||||
1. 跑完后端事务/SSE/Workflow/质量闸门/WriteGuard 测试、前端 reducer/组件测试、旧会商与 DeerFlow 回归。
|
|
||||||
2. 在真实浏览器按阶段 0 截图基线逐项对照,并完成用户任务:发起、澄清、派活、短答打回、两次返工、改派、审批、受控缺口、结论和追问。
|
|
||||||
3. 内部账号灰度,观测 SSE 建连/重连、事件时延、派活/验收/返工次数、workflow 失败率、审批耗时和前端错误。
|
|
||||||
4. 验证关闭 feature flag 后立即回到旧会商入口,旧数据和旧任务不受影响。
|
|
||||||
5. 若需生产外部写入,补齐 UAT 系统、最小权限凭据、核验 API、补偿联系人和生产 `MAF_ACTION_SECRET_KEY`。
|
|
||||||
|
|
||||||
交付物:自动化结果、人工验收记录、灰度指标面板、回滚演练记录、上线清单。
|
|
||||||
|
|
||||||
验收:满足第 9 节全部条件后才允许标记开发完成。
|
|
||||||
|
|
||||||
### 8.1 可并行边界
|
|
||||||
|
|
||||||
- 阶段 2 与阶段 3 可在阶段 1 的 schema/API 定稿后并行,但二者必须共用同一事件信封。
|
|
||||||
- 阶段 6 可在阶段 1 的 API mock 契约定稿后提前开始;不得自行发明事件字段。
|
|
||||||
- 阶段 5 必须等待阶段 2、3、4 的安全事件与状态机定稿。
|
|
||||||
- 阶段 7 必须等待阶段 2、4、6,不能以轮询临时替代 SSE。
|
|
||||||
- 阶段 8 只做集成、验收和上线准备,不补写前面阶段缺失的核心设计。
|
|
||||||
|
|
||||||
建议主顺序:`阶段 0 → 阶段 1 → (阶段 2 + 阶段 3 + 阶段 6) → 阶段 4 → 阶段 5 → 阶段 7 → 阶段 8`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 最终验收定义
|
|
||||||
|
|
||||||
只有同时满足以下条件,才可以说“本次 MAF 会商升级开发完成”:
|
|
||||||
|
|
||||||
- 新页面在视觉和主交互上可被用户认作原多智能体会商的升级版;
|
|
||||||
- 页面具备历史会话、连续对话流、输入框、意图理解、总控派活、席位协作、澄清、审批、结果和追问;
|
|
||||||
- 每个必需席位任务都有任务合同、总控验收、有限返工和改派/受控缺口处理;席位短答或垃圾产出不能自动被当作完成;
|
|
||||||
- 浏览器没有会商事件/run/approval 的定时轮询;实时交互通过可重连 SSE 完成;
|
|
||||||
- 用户不需要创建或管理多个智能体线程,内部线程由服务端按用户身份安全创建和恢复;
|
|
||||||
- 新页面未调用旧会商业务 API/状态,旧会商零回归;
|
|
||||||
- 工具、技能、MCP、沙箱、审批、WriteGuard、一次写入和审计都仍可用;
|
|
||||||
- 自动化测试、SSE 故障测试、真实浏览器人工验收和旧功能回归全部通过;
|
|
||||||
- 真实 UAT 写入系统、最小权限凭据、外部核验 API、补偿协议及生产 `MAF_ACTION_SECRET_KEY` 已按 Phase 5 前置条件完成。
|
|
||||||
|
|
||||||
在这之前,任何“会话/run 原型页面通过构建或轮询测试”的结果,都只能算技术验证,不算产品升级验收。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 阶段 3 实施记录(当前代码基线)
|
|
||||||
|
|
||||||
本轮已补齐阶段 3 的服务端安全路由与真实 MAF Workflow 入口,范围仅针对新 MAF 会商;旧 DeerFlow、旧 roundtable 和 AI 写作行为不变。
|
|
||||||
|
|
||||||
### 配置与入口闸门
|
|
||||||
|
|
||||||
- 仓库默认 `maf_roundtable.enabled: false`。部署环境必须注入真实的 `default_manager_agent_id`、席位 ID 和业务路由后再开启,避免出现“入口显示正常但首条消息必然 503”的空配置。
|
|
||||||
- 健康接口返回非敏感的 `conversation_dispatch_ready` 与 `conversation_dispatch_status`;新会商入口只有在配置路由可用且服务已启用时才开放。
|
|
||||||
- 首条消息使用服务端可信能力快照校验 manager/seat 的可访问性;浏览器、会话请求和模型输出都不能提供或扩大 agent、thread、tool、capability 范围。
|
|
||||||
- 配置模型会清理并校验 manager/seat ID,拒绝 manager 出现在 seat 列表、重复席位、超过 `max_seats_per_run` 的路由和非法业务 code。
|
|
||||||
|
|
||||||
### 受约束的总控派活
|
|
||||||
|
|
||||||
服务端先由业务链与可信 capability snapshot 生成候选席位集合。总控模型只能返回严格 JSON:`intent_type`、`selected_seat_agent_ids`、每席 `task_goal`/`delivery_requirements`、`execution_mode`、`convergence_path` 和 `reason`。Pydantic schema、服务端 allowlist 和顺序校验全部通过后,最终计划才会写入 checkpoint,并产生 `manager.dispatch` 审计/SSE 投影。
|
|
||||||
|
|
||||||
非法席位、重复席位、空任务、错误 schema、席位超限、缺少收敛路径或链式顺序错误,统一回退到服务端安全计划,不会扩大权限或启动越权席位。
|
|
||||||
|
|
||||||
四种结构化拓扑已固定为:
|
|
||||||
|
|
||||||
- `direct_answer`:manager-only Workflow,不创建专家席位任务;
|
|
||||||
- `single_seat` / `tool_write`:一个可信席位;
|
|
||||||
- `parallel_consultation`:至少两个可信席位并行;
|
|
||||||
- `chain_consultation`:按服务端业务链顺序串行。
|
|
||||||
|
|
||||||
### 阶段 3 验证状态
|
|
||||||
|
|
||||||
已覆盖配置校验、候选路由、非法总控计划回退、manager-only 真实 `WorkflowBuilder` 拓扑、线程/执行阶段回归、旧 DeerFlow 回归和会话 API 回归。当前仍不能据此标记整个 MAF 项目或阶段 5 为“验收完成”;真实 UAT 写入系统、最小权限凭据、外部核验 API、补偿协议和生产 `MAF_ACTION_SECRET_KEY` 仍按阶段 5/8 前置条件保持阻塞。
|
|
||||||
|
|
||||||
### Stage 3 P0 dispatch correction
|
|
||||||
|
|
||||||
- New MAF conversations no longer classify user text with server keyword rules. The server first freezes only the business-route candidate set and trusted, redacted capability summary; `ManagerDispatchExecutor` then returns the strict intent-and-dispatch JSON contract.
|
|
||||||
- The final plan is Pydantic-validated and allowlisted before it is checkpointed. Invalid JSON, illegal intent or seat, missing convergence, manager failure, and timeout all finish on the clarification branch; they do not create expert threads or start expert/tool work.
|
|
||||||
- A conversation initially persists and binds the manager only. After a valid decision, only `selected_seat_agent_ids` are added to the participant store and receive internal threads.
|
|
||||||
- Every non-clarification route, including `group_chat`, starts at the same manager dispatch node. Group speakers are limited to the verified selected seats; static candidate configuration cannot bypass the plan.
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 岗位会商沙箱文档
|
||||||
|
|
||||||
|
本目录存放**岗位会商文件预览沙箱**的对接与排障说明,独立于 `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」
|
||||||
@@ -0,0 +1,988 @@
|
|||||||
|
# 岗位会商 · 沙箱打开 / 关闭与步骤条(详细对接与排障指南)
|
||||||
|
|
||||||
|
> **文档位置**:`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 节。
|
||||||
@@ -0,0 +1,822 @@
|
|||||||
|
# 沙箱统一写作管线:可实现开发方案
|
||||||
|
|
||||||
|
> 状态:设计稿;本文件只定义实施方案,不包含本轮业务代码改动。
|
||||||
|
> 覆盖范围:普通沙箱 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. 每一次真实全文覆盖都有版本快照,并且能够安全撤销。
|
||||||
|
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
|
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test:maf": "vitest run --config vitest.maf.config.ts",
|
|
||||||
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
|
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
|
||||||
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
|
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
|
||||||
},
|
},
|
||||||
@@ -107,23 +106,19 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.0.15",
|
"@tailwindcss/postcss": "^4.0.15",
|
||||||
"@testing-library/react": "^16.3.2",
|
|
||||||
"@testing-library/user-event": "^14.6.1",
|
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/gsap": "^3.0.0",
|
"@types/gsap": "^3.0.0",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.14.10",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"jsdom": "^30.0.1",
|
|
||||||
"lightningcss": "^1.30.2",
|
"lightningcss": "^1.30.2",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"tailwindcss": "^4.0.15",
|
"tailwindcss": "^4.0.15",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
"vite-plugin-source-identifier": "^1.1.2",
|
"vite-plugin-source-identifier": "^1.1.2"
|
||||||
"vitest": "^4.1.10"
|
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.26.2"
|
"packageManager": "pnpm@10.26.2"
|
||||||
}
|
}
|
||||||
@@ -132,4 +132,18 @@ window.__CM_RUNTIME_CONFIG__ = {
|
|||||||
|
|
||||||
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
|
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
|
||||||
VITE_GITHUB_OAUTH_TOKEN: "",
|
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",
|
||||||
};
|
};
|
||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
|
Cpu,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
@@ -252,6 +253,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
|||||||
白名单管理
|
白名单管理
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigate("/page/workspace/admin/models")}
|
||||||
|
>
|
||||||
|
<Cpu className="size-4" />
|
||||||
|
模型配置
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/strategy/settings/model-concurrency")}
|
onClick={() => navigate("/page/strategy/settings/model-concurrency")}
|
||||||
@@ -555,6 +564,12 @@ function LegacyAccordionSidebar({
|
|||||||
岗位角色配置
|
岗位角色配置
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem onClick={() => navigate("/page/workspace/admin/models")}>
|
||||||
|
<Cpu className="size-4" />
|
||||||
|
模型配置
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
|
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
|
||||||
<ShieldCheck className="size-4" />
|
<ShieldCheck className="size-4" />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ function HeaderButton({
|
|||||||
variant = "outline",
|
variant = "outline",
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode;
|
icon: ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
variant?: "outline" | "primary";
|
variant?: "outline" | "primary";
|
||||||
@@ -52,6 +53,7 @@ export function WikiHeader({
|
|||||||
onCreate,
|
onCreate,
|
||||||
onManageFolders,
|
onManageFolders,
|
||||||
onManageTemplates,
|
onManageTemplates,
|
||||||
|
extraActions,
|
||||||
}: {
|
}: {
|
||||||
onToggleSidebar: () => void;
|
onToggleSidebar: () => void;
|
||||||
onImportFile: () => void;
|
onImportFile: () => void;
|
||||||
@@ -61,6 +63,7 @@ export function WikiHeader({
|
|||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
onManageFolders: () => void;
|
onManageFolders: () => void;
|
||||||
onManageTemplates: () => void;
|
onManageTemplates: () => void;
|
||||||
|
extraActions?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<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">
|
||||||
@@ -79,6 +82,7 @@ export function WikiHeader({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{extraActions}
|
||||||
<HeaderButton
|
<HeaderButton
|
||||||
icon={<Upload className="size-4" />}
|
icon={<Upload className="size-4" />}
|
||||||
label="导入文件"
|
label="导入文件"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
BotIcon,
|
BotIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
|
DatabaseIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
@@ -43,6 +44,7 @@ import {
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
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 { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats";
|
||||||
import { useArtifactsOptional } from "@/components/workspace/artifacts";
|
import { useArtifactsOptional } from "@/components/workspace/artifacts";
|
||||||
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
|
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
|
||||||
@@ -80,6 +82,7 @@ interface AgentDraft {
|
|||||||
templateName: string;
|
templateName: string;
|
||||||
/** 会商席位「技能识别强化」开关。 */
|
/** 会商席位「技能识别强化」开关。 */
|
||||||
seatSkillDirective: boolean;
|
seatSkillDirective: boolean;
|
||||||
|
knowledgeBaseIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function draftFromAgent(agent: Agent): AgentDraft {
|
function draftFromAgent(agent: Agent): AgentDraft {
|
||||||
@@ -93,6 +96,7 @@ function draftFromAgent(agent: Agent): AgentDraft {
|
|||||||
featured: agent.featured_order != null,
|
featured: agent.featured_order != null,
|
||||||
templateName: agent.template_name ?? "",
|
templateName: agent.template_name ?? "",
|
||||||
seatSkillDirective: agent.seat_skill_directive ?? false,
|
seatSkillDirective: agent.seat_skill_directive ?? false,
|
||||||
|
knowledgeBaseIds: agent.llmwiki_knowledge_base_ids ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +338,11 @@ export function AgentDetailPanel({
|
|||||||
request.template_name = draft.templateName.trim();
|
request.template_name = draft.templateName.trim();
|
||||||
if (draft.seatSkillDirective !== (agent.seat_skill_directive ?? false))
|
if (draft.seatSkillDirective !== (agent.seat_skill_directive ?? false))
|
||||||
request.seat_skill_directive = draft.seatSkillDirective;
|
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 (nextPublished !== agent.published) request.published = nextPublished;
|
||||||
if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? []))
|
if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? []))
|
||||||
request.skills = draft.skills;
|
request.skills = draft.skills;
|
||||||
@@ -630,6 +639,13 @@ export function AgentDetailPanel({
|
|||||||
</div>
|
</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. */}
|
{/* Publish / featured toggles — only meaningful in edit mode. */}
|
||||||
{editing && draft && (
|
{editing && draft && (
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
@@ -647,6 +663,14 @@ export function AgentDetailPanel({
|
|||||||
配置后,对话生成的 Markdown 文件可一键发布到知识入库接口。
|
配置后,对话生成的 Markdown 文件可一键发布到知识入库接口。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
|
||||||
<div className="pr-3">
|
<div className="pr-3">
|
||||||
<p className="text-sm font-medium">会商技能识别强化</p>
|
<p className="text-sm font-medium">会商技能识别强化</p>
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ import { getStoredAuth } from "@/core/auth";
|
|||||||
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
||||||
import type { Agent, AgentSort } from "@/core/agents";
|
import type { Agent, AgentSort } from "@/core/agents";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
|
||||||
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
|
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
|
||||||
|
|
||||||
import { AgentCard } from "./agent-card";
|
import { AgentCard } from "./agent-card";
|
||||||
|
import { SentimentAgentCard } from "./sentiment-agent-card";
|
||||||
|
|
||||||
const PAGE_SIZE = 12;
|
const PAGE_SIZE = 12;
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
|
|||||||
export function AgentGallery() {
|
export function AgentGallery() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const sentimentAgentVisible = isSentimentAgentVisible();
|
||||||
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
|
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
const [viewMode, setViewMode] = useViewMode("agent");
|
const [viewMode, setViewMode] = useViewMode("agent");
|
||||||
@@ -41,6 +44,9 @@ export function AgentGallery() {
|
|||||||
// Each configured publish square (发布广场) becomes its own browse tab; the
|
// Each configured publish square (发布广场) becomes its own browse tab; the
|
||||||
// backend always returns at least one (the built-in default).
|
// backend always returns at least one (the built-in default).
|
||||||
const squareTabs = publishSquares?.squares ?? [];
|
const squareTabs = publishSquares?.squares ?? [];
|
||||||
|
// 默认广场固定排在「我的」之后;舆情分析是依附在默认广场后的前端虚拟类型,
|
||||||
|
// 其余自定义广场继续保持后端给出的顺序。
|
||||||
|
const [defaultSquare, ...additionalSquares] = squareTabs;
|
||||||
// Publisher filter: click a card's 发布者 to keep only that user's agents.
|
// 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.
|
// {id} is the owner user_id (server filter), {label} the desensitized name.
|
||||||
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
|
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
|
||||||
@@ -70,6 +76,7 @@ export function AgentGallery() {
|
|||||||
activeTab !== "mine" &&
|
activeTab !== "mine" &&
|
||||||
activeTab !== "private" &&
|
activeTab !== "private" &&
|
||||||
activeTab !== "builtin" &&
|
activeTab !== "builtin" &&
|
||||||
|
activeTab !== "sentiment" &&
|
||||||
squareTabs.length > 0 &&
|
squareTabs.length > 0 &&
|
||||||
!squareTabs.some((sq) => sq.id === activeTab)
|
!squareTabs.some((sq) => sq.id === activeTab)
|
||||||
) {
|
) {
|
||||||
@@ -90,9 +97,13 @@ export function AgentGallery() {
|
|||||||
|
|
||||||
// Only the active tab fetches. "广场" maps to the server "square" scope
|
// Only the active tab fetches. "广场" maps to the server "square" scope
|
||||||
// (built-in/published, never private), "我的" to "mine" (owned + favorited),
|
// (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 =
|
const isSquareTab =
|
||||||
activeTab !== "mine" && activeTab !== "private" && activeTab !== "builtin";
|
activeTab !== "mine" &&
|
||||||
|
activeTab !== "private" &&
|
||||||
|
activeTab !== "builtin" &&
|
||||||
|
activeTab !== "sentiment";
|
||||||
const mainScope =
|
const mainScope =
|
||||||
activeTab === "mine"
|
activeTab === "mine"
|
||||||
? "mine"
|
? "mine"
|
||||||
@@ -112,7 +123,7 @@ export function AgentGallery() {
|
|||||||
sort,
|
sort,
|
||||||
page: mainPage,
|
page: mainPage,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
enabled: activeTab !== "private",
|
enabled: activeTab !== "private" && activeTab !== "sentiment",
|
||||||
owner: publisher?.id,
|
owner: publisher?.id,
|
||||||
// Each square tab fetches only that square's agents.
|
// Each square tab fetches only that square's agents.
|
||||||
square: isSquareTab ? activeTab : undefined,
|
square: isSquareTab ? activeTab : undefined,
|
||||||
@@ -148,9 +159,16 @@ export function AgentGallery() {
|
|||||||
// password has been verified. Until then we show the gate (or the lock
|
// 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.
|
// prompt) and hide the keyword/tag search bar — there is no list to filter.
|
||||||
const privateUnlocked = privateFeatureUsable && !!privatePassword;
|
const privateUnlocked = privateFeatureUsable && !!privatePassword;
|
||||||
const showSearchBar = activeTab !== "private" || privateUnlocked;
|
const showSearchBar =
|
||||||
|
activeTab !== "sentiment" &&
|
||||||
|
(activeTab !== "private" || privateUnlocked);
|
||||||
|
|
||||||
const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
|
const activeTotal =
|
||||||
|
activeTab === "sentiment"
|
||||||
|
? 1
|
||||||
|
: activeTab === "private"
|
||||||
|
? privateTotal
|
||||||
|
: mainTotal;
|
||||||
const setTabPage = (tab: string, page: number) =>
|
const setTabPage = (tab: string, page: number) =>
|
||||||
setPages((prev) => ({ ...prev, [tab]: page }));
|
setPages((prev) => ({ ...prev, [tab]: page }));
|
||||||
|
|
||||||
@@ -398,7 +416,22 @@ export function AgentGallery() {
|
|||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<TabsList className="w-auto max-w-full flex-wrap justify-start self-start">
|
<TabsList className="w-auto max-w-full flex-wrap justify-start self-start">
|
||||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||||
{squareTabs.map((sq) => (
|
{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) => (
|
||||||
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
|
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
|
||||||
{sq.name}
|
{sq.name}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -512,6 +545,25 @@ export function AgentGallery() {
|
|||||||
})}
|
})}
|
||||||
</TabsContent>
|
</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 && (
|
{isAdmin && (
|
||||||
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
|
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||||
{renderListBody({
|
{renderListBody({
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
UploadCloudIcon,
|
UploadCloudIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
ArtifactHeader,
|
ArtifactHeader,
|
||||||
ArtifactTitle,
|
ArtifactTitle,
|
||||||
} from "@/components/ai-elements/artifact";
|
} from "@/components/ai-elements/artifact";
|
||||||
|
import { DocumentRewriteDialog } from "@/components/workspace/artifacts/document-rewrite-dialog";
|
||||||
import { Select, SelectItem } from "@/components/ui/select";
|
import { Select, SelectItem } from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -39,10 +41,21 @@ import {
|
|||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||||
import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor";
|
import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor";
|
||||||
|
import { useDocumentRewrite } from "@/open-canvas/hooks/useDocumentRewrite";
|
||||||
|
import {
|
||||||
|
listDurableDocumentRewrites,
|
||||||
|
streamDurableDocumentRewriteJob,
|
||||||
|
type DocumentRewriteSummary,
|
||||||
|
type DocumentRewriteStreamEvent,
|
||||||
|
} from "@/open-canvas/api/rewrite";
|
||||||
import { publishArtifactToKnowledge } from "@/core/agents/api";
|
import { publishArtifactToKnowledge } from "@/core/agents/api";
|
||||||
import { useAgent } from "@/core/agents/hooks";
|
import { useAgent } from "@/core/agents/hooks";
|
||||||
import { apiFetch } from "@/core/api";
|
import { apiFetch } from "@/core/api";
|
||||||
import { copyTextToClipboard } from "@/core/clipboard";
|
import { copyTextToClipboard } from "@/core/clipboard";
|
||||||
|
import {
|
||||||
|
DOCUMENT_REWRITE_UNDONE_EVENT,
|
||||||
|
useDocumentRewritePresentation,
|
||||||
|
} from "@/core/document-rewrite/context";
|
||||||
import {
|
import {
|
||||||
downloadArtifactFile,
|
downloadArtifactFile,
|
||||||
} from "@/core/artifacts/download";
|
} from "@/core/artifacts/download";
|
||||||
@@ -86,6 +99,15 @@ import { SkillInstallDialog } from "./skill-install-dialog";
|
|||||||
|
|
||||||
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
|
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
|
||||||
|
|
||||||
|
async function sha256Content(content: string): Promise<string | undefined> {
|
||||||
|
if (!globalThis.crypto?.subtle) return undefined;
|
||||||
|
const digest = await globalThis.crypto.subtle.digest(
|
||||||
|
"SHA-256",
|
||||||
|
new TextEncoder().encode(content),
|
||||||
|
);
|
||||||
|
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
function extractTextFromChildren(children: ReactNode): string {
|
function extractTextFromChildren(children: ReactNode): string {
|
||||||
if (typeof children === "string" || typeof children === "number") return String(children);
|
if (typeof children === "string" || typeof children === "number") return String(children);
|
||||||
if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
|
if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
|
||||||
@@ -123,14 +145,18 @@ export function ArtifactFileDetail({
|
|||||||
className,
|
className,
|
||||||
filepath: filepathFromProps,
|
filepath: filepathFromProps,
|
||||||
threadId,
|
threadId,
|
||||||
|
mimeType = null,
|
||||||
onClose,
|
onClose,
|
||||||
allowVirtualMarkdownEdit = false,
|
allowVirtualMarkdownEdit = false,
|
||||||
disableDesensitize = false,
|
disableDesensitize = false,
|
||||||
hideFileName = false,
|
hideFileName = false,
|
||||||
|
onDownloadClick,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
filepath: string;
|
filepath: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
/** Server/tool supplied type wins over path guessing when available. */
|
||||||
|
mimeType?: string | null;
|
||||||
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
|
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
/**
|
/**
|
||||||
@@ -146,6 +172,12 @@ export function ArtifactFileDetail({
|
|||||||
*/
|
*/
|
||||||
disableDesensitize?: boolean;
|
disableDesensitize?: boolean;
|
||||||
hideFileName?: boolean;
|
hideFileName?: boolean;
|
||||||
|
/**
|
||||||
|
* 覆盖默认下载行为:传入后点击「下载」按钮不再走内置的各格式下载,而是回调宿主
|
||||||
|
* (深度研究沙箱用它弹「Markdown / Word」格式选择弹窗),并隐藏工具栏中重复的
|
||||||
|
* 「另存为 Word」。默认不传 —— 其它调用方行为不变。
|
||||||
|
*/
|
||||||
|
onDownloadClick?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { artifacts, setOpen, select } = useArtifacts();
|
const { artifacts, setOpen, select } = useArtifacts();
|
||||||
@@ -153,12 +185,36 @@ export function ArtifactFileDetail({
|
|||||||
const isWriteFileVirtual = useMemo(() => {
|
const isWriteFileVirtual = useMemo(() => {
|
||||||
return filepathFromProps.startsWith("write-file:");
|
return filepathFromProps.startsWith("write-file:");
|
||||||
}, [filepathFromProps]);
|
}, [filepathFromProps]);
|
||||||
|
// A streamed tool call can be opened before the path has been normalized by
|
||||||
|
// the artifacts API. Keep the format supplied by the writer so Markdown is
|
||||||
|
// rendered as Markdown instead of falling back to a TEXT source preview.
|
||||||
|
const writeFileLanguageHint = useMemo(() => {
|
||||||
|
if (!isWriteFileVirtual) return null;
|
||||||
|
try {
|
||||||
|
const language = new URL(filepathFromProps).searchParams.get("language");
|
||||||
|
return language === "markdown" || language === "html" || language === "mermaid"
|
||||||
|
? language
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [filepathFromProps, isWriteFileVirtual]);
|
||||||
|
const mimeLanguageHint = useMemo(() => {
|
||||||
|
const normalized = mimeType?.toLowerCase().trim() ?? "";
|
||||||
|
if (normalized.includes("markdown")) return "markdown";
|
||||||
|
if (normalized.includes("html")) return "html";
|
||||||
|
if (normalized.includes("mermaid")) return "mermaid";
|
||||||
|
if (normalized.startsWith("text/")) return "text";
|
||||||
|
return null;
|
||||||
|
}, [mimeType]);
|
||||||
const filepath = useMemo(() => {
|
const filepath = useMemo(() => {
|
||||||
if (isWriteFileVirtual) {
|
if (isWriteFileVirtual) {
|
||||||
const url = new URL(filepathFromProps);
|
const url = new URL(filepathFromProps);
|
||||||
return decodeURIComponent(url.pathname);
|
return decodeURIComponent(url.pathname).trim();
|
||||||
}
|
}
|
||||||
return filepathFromProps;
|
// Tool arguments may contain harmless surrounding whitespace. Passing it
|
||||||
|
// into extension detection turns `report.md ` into a generic TEXT file.
|
||||||
|
return filepathFromProps.trim();
|
||||||
}, [filepathFromProps, isWriteFileVirtual]);
|
}, [filepathFromProps, isWriteFileVirtual]);
|
||||||
const isSkillFile = useMemo(() => {
|
const isSkillFile = useMemo(() => {
|
||||||
return filepath.endsWith(".skill");
|
return filepath.endsWith(".skill");
|
||||||
@@ -188,15 +244,20 @@ export function ArtifactFileDetail({
|
|||||||
}, [filepath, isWriteFileVirtual, editVirtualMarkdown]);
|
}, [filepath, isWriteFileVirtual, editVirtualMarkdown]);
|
||||||
const { isCodeFile, language } = useMemo(() => {
|
const { isCodeFile, language } = useMemo(() => {
|
||||||
if (useWriteFileStreaming) {
|
if (useWriteFileStreaming) {
|
||||||
let nextLanguage = checkCodeFile(filepath).language;
|
let nextLanguage =
|
||||||
|
mimeLanguageHint ?? writeFileLanguageHint ?? checkCodeFile(filepath).language;
|
||||||
nextLanguage ??= "text";
|
nextLanguage ??= "text";
|
||||||
return { isCodeFile: true, language: nextLanguage };
|
return { isCodeFile: true, language: nextLanguage };
|
||||||
}
|
}
|
||||||
if (isSkillFile) {
|
if (isSkillFile) {
|
||||||
return { isCodeFile: true, language: "markdown" };
|
return { isCodeFile: true, language: "markdown" };
|
||||||
}
|
}
|
||||||
return checkCodeFile(filepath);
|
const detected = checkCodeFile(filepath);
|
||||||
}, [filepath, useWriteFileStreaming, isSkillFile]);
|
if (mimeLanguageHint) {
|
||||||
|
return { isCodeFile: true as const, language: mimeLanguageHint };
|
||||||
|
}
|
||||||
|
return detected;
|
||||||
|
}, [filepath, useWriteFileStreaming, isSkillFile, mimeLanguageHint, writeFileLanguageHint]);
|
||||||
const isSupportPreview = useMemo(() => {
|
const isSupportPreview = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
language === "html" ||
|
language === "html" ||
|
||||||
@@ -209,7 +270,8 @@ export function ArtifactFileDetail({
|
|||||||
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
|
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
|
||||||
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
|
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
|
||||||
// 不会被旧的内存 stub 值覆盖回退。
|
// 不会被旧的内存 stub 值覆盖回退。
|
||||||
filepath: editVirtualMarkdown ? filepath : filepathFromProps,
|
filepath:
|
||||||
|
editVirtualMarkdown || !isWriteFileVirtual ? filepath : filepathFromProps,
|
||||||
enabled: isCodeFile && !useWriteFileStreaming,
|
enabled: isCodeFile && !useWriteFileStreaming,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -224,10 +286,26 @@ export function ArtifactFileDetail({
|
|||||||
const canUseAiEditor = canEditArtifact;
|
const canUseAiEditor = canEditArtifact;
|
||||||
const [draftContent, setDraftContent] = useState("");
|
const [draftContent, setDraftContent] = useState("");
|
||||||
const [hasLocalEdits, setHasLocalEdits] = useState(false);
|
const [hasLocalEdits, setHasLocalEdits] = useState(false);
|
||||||
|
const [documentRewriteDialogOpen, setDocumentRewriteDialogOpen] = useState(false);
|
||||||
|
const [documentRewritePresentationId, setDocumentRewritePresentationId] = useState<string | null>(null);
|
||||||
const updateArtifact = useUpdateArtifactContent();
|
const updateArtifact = useUpdateArtifactContent();
|
||||||
const displayContent = canEditArtifact ? draftContent : (content ?? "");
|
const queryClient = useQueryClient();
|
||||||
const isAutoPreviewLanguage = language === "html" || language === "markdown";
|
const documentRewrite = useDocumentRewrite();
|
||||||
const isPreviewStreaming = isAutoPreviewLanguage && thread.isLoading;
|
const rewritePresentation = useDocumentRewritePresentation();
|
||||||
|
const hydratedRewriteScopeRef = useRef<string | null>(null);
|
||||||
|
const mirroredRequirementsPlanRef = useRef<{ presentationId: string | null; plan: string }>({
|
||||||
|
presentationId: null,
|
||||||
|
plan: "",
|
||||||
|
});
|
||||||
|
const documentRewriteActive = documentRewrite.isActive;
|
||||||
|
const canUseFullDocumentRewrite = canEditArtifact && !hasLocalEdits;
|
||||||
|
const displayContent = documentRewriteActive
|
||||||
|
? documentRewrite.run.draft
|
||||||
|
: (canEditArtifact ? draftContent : (content ?? ""));
|
||||||
|
const isAutoPreviewLanguage =
|
||||||
|
language === "html" || language === "markdown" || language === "mermaid";
|
||||||
|
const isPreviewStreaming =
|
||||||
|
isAutoPreviewLanguage && (thread.isLoading || documentRewriteActive);
|
||||||
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||||||
const fileTypeLabel = useMemo(() => {
|
const fileTypeLabel = useMemo(() => {
|
||||||
if (isImageFile(filepath)) return "IMG";
|
if (isImageFile(filepath)) return "IMG";
|
||||||
@@ -255,17 +333,302 @@ export function ArtifactFileDetail({
|
|||||||
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
||||||
const resumeButtonTimeoutRef = useRef<number | null>(null);
|
const resumeButtonTimeoutRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!documentRewriteActive) return;
|
||||||
|
setAllowAutoViewSwitch(false);
|
||||||
|
setAutoScrollEnabled(true);
|
||||||
|
setShowResumeAutoScroll(false);
|
||||||
|
setViewMode("code");
|
||||||
|
}, [documentRewriteActive]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraftContent(content ?? "");
|
setDraftContent(content ?? "");
|
||||||
setHasLocalEdits(false);
|
setHasLocalEdits(false);
|
||||||
}, [filepathFromProps]);
|
}, [filepathFromProps]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canEditArtifact || hasLocalEdits) {
|
if (!canEditArtifact || hasLocalEdits || documentRewriteActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDraftContent(content ?? "");
|
setDraftContent(content ?? "");
|
||||||
}, [canEditArtifact, content, hasLocalEdits]);
|
}, [canEditArtifact, content, documentRewriteActive, hasLocalEdits]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!canEditArtifact ||
|
||||||
|
!threadId ||
|
||||||
|
!filepath ||
|
||||||
|
isMock ||
|
||||||
|
useWriteFileStreaming ||
|
||||||
|
documentRewriteActive
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scope = `${threadId}:${filepath}`;
|
||||||
|
if (hydratedRewriteScopeRef.current === scope) return;
|
||||||
|
hydratedRewriteScopeRef.current = scope;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
const updatePresentation = (
|
||||||
|
presentationId: string,
|
||||||
|
event: DocumentRewriteStreamEvent,
|
||||||
|
) => {
|
||||||
|
if (!rewritePresentation) return;
|
||||||
|
switch (event.type) {
|
||||||
|
case "snapshot_fixed":
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
stage: "requirements",
|
||||||
|
fileName: event.fileName || getFileName(filepath),
|
||||||
|
originalCharCount: event.originalCharCount,
|
||||||
|
requirementsPlan: "",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "requirements_started":
|
||||||
|
rewritePresentation.update(presentationId, { stage: "requirements", requirementsPlan: "" });
|
||||||
|
break;
|
||||||
|
case "requirements_checkpoint":
|
||||||
|
case "requirements_completed":
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
stage: "requirements",
|
||||||
|
requirementsPlan: event.content,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "requirements_resolved":
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
stage: "writing",
|
||||||
|
...(event.plan ? { requirementsPlan: event.plan } : {}),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "rewrite_delta":
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
stage: "writing",
|
||||||
|
writtenCharCount: event.charCount,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "validation_started":
|
||||||
|
rewritePresentation.update(presentationId, { stage: "validation" });
|
||||||
|
break;
|
||||||
|
case "validation_completed":
|
||||||
|
rewritePresentation.update(presentationId, { warnings: event.warnings });
|
||||||
|
break;
|
||||||
|
case "comparison_ready":
|
||||||
|
rewritePresentation.update(presentationId, { summary: event.summary });
|
||||||
|
break;
|
||||||
|
case "commit_started":
|
||||||
|
rewritePresentation.update(presentationId, { stage: "committing" });
|
||||||
|
break;
|
||||||
|
case "committed":
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
rewrittenContent: event.content,
|
||||||
|
versionId: event.versionId ?? null,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "done":
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
stage: "completed",
|
||||||
|
versionId: event.versionId ?? null,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "conflict":
|
||||||
|
rewritePresentation.update(presentationId, { stage: "conflict", error: event.message });
|
||||||
|
break;
|
||||||
|
case "cancelled":
|
||||||
|
rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message });
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
rewritePresentation.update(presentationId, { stage: "error", error: event.message });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const jobs = await listDurableDocumentRewrites({ threadId, path: filepath });
|
||||||
|
const latest = jobs[0];
|
||||||
|
if (disposed || !latest) return;
|
||||||
|
// Restore prior terminal rewrites into the ordinary message timeline in
|
||||||
|
// chronological order. The newest job is handled below because it may
|
||||||
|
// still be active and needs its SSE stream resumed.
|
||||||
|
for (const historical of jobs.slice(1).reverse()) {
|
||||||
|
const historicalResult = historical.result;
|
||||||
|
const historicalSummary = historicalResult.summary && typeof historicalResult.summary === "object"
|
||||||
|
? historicalResult.summary as DocumentRewriteSummary
|
||||||
|
: null;
|
||||||
|
const historicalPresentationId = rewritePresentation?.create({
|
||||||
|
threadId,
|
||||||
|
source: { kind: "artifact", id: threadId, path: filepath },
|
||||||
|
fileName: getFileName(filepath),
|
||||||
|
instruction: historical.request.instruction,
|
||||||
|
style: historical.request.style,
|
||||||
|
modelName: historical.request.modelName,
|
||||||
|
originalContent: historical.request.originalContent,
|
||||||
|
rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null,
|
||||||
|
requirementsPlan: typeof historicalResult.requirementsPlan === "string"
|
||||||
|
? historicalResult.requirementsPlan
|
||||||
|
: "",
|
||||||
|
});
|
||||||
|
if (!historicalPresentationId || !rewritePresentation) continue;
|
||||||
|
rewritePresentation.update(historicalPresentationId, {
|
||||||
|
stage: historical.status === "completed"
|
||||||
|
? historicalResult.status === "conflict" ? "conflict" : "completed"
|
||||||
|
: historical.status === "cancelled" ? "cancelled" : "error",
|
||||||
|
summary: historicalSummary,
|
||||||
|
warnings: historicalSummary?.validationWarnings ?? [],
|
||||||
|
versionId: typeof historicalResult.versionId === "string" ? historicalResult.versionId : null,
|
||||||
|
error: historicalResult.status === "conflict"
|
||||||
|
? "文件在改写期间已变化,未覆盖原文件。"
|
||||||
|
: historical.status === "cancelled"
|
||||||
|
? "已停止全文改写,原文件未被修改。"
|
||||||
|
: historical.status === "failed" ? "全文改写未完成,原文件未被修改。" : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const result = latest.result;
|
||||||
|
const summary = result.summary && typeof result.summary === "object"
|
||||||
|
? result.summary as DocumentRewriteSummary
|
||||||
|
: null;
|
||||||
|
const originalContent = latest.request.originalContent || draftContent;
|
||||||
|
const rewrittenContent = typeof result.content === "string" ? result.content : null;
|
||||||
|
const presentationId = rewritePresentation?.create({
|
||||||
|
threadId,
|
||||||
|
source: { kind: "artifact", id: threadId, path: filepath },
|
||||||
|
fileName: getFileName(filepath),
|
||||||
|
instruction: latest.request.instruction,
|
||||||
|
style: latest.request.style,
|
||||||
|
modelName: latest.request.modelName,
|
||||||
|
originalContent,
|
||||||
|
rewrittenContent,
|
||||||
|
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
|
||||||
|
});
|
||||||
|
if (!presentationId || !rewritePresentation) return;
|
||||||
|
setDocumentRewritePresentationId(presentationId);
|
||||||
|
|
||||||
|
if (latest.status === "completed") {
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
stage: result.status === "conflict" ? "conflict" : "completed",
|
||||||
|
summary,
|
||||||
|
warnings: summary?.validationWarnings ?? [],
|
||||||
|
versionId: typeof result.versionId === "string" ? result.versionId : null,
|
||||||
|
error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null,
|
||||||
|
});
|
||||||
|
if (rewrittenContent && result.status !== "conflict") {
|
||||||
|
setDraftContent(rewrittenContent);
|
||||||
|
setHasLocalEdits(false);
|
||||||
|
queryClient.setQueriesData(
|
||||||
|
{ queryKey: ["artifact", filepath, threadId, isMock] },
|
||||||
|
{ content: rewrittenContent, url: undefined },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (latest.status === "failed" || latest.status === "cancelled") {
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
stage: latest.status === "cancelled" ? "cancelled" : "error",
|
||||||
|
summary,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void documentRewrite.start(
|
||||||
|
{
|
||||||
|
threadId,
|
||||||
|
path: filepath,
|
||||||
|
instruction: latest.request.instruction,
|
||||||
|
style: latest.request.style,
|
||||||
|
modelName: latest.request.modelName ?? undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jobId: latest.id,
|
||||||
|
stream: (signal) => streamDurableDocumentRewriteJob(
|
||||||
|
{ threadId, path: filepath },
|
||||||
|
latest.id,
|
||||||
|
signal,
|
||||||
|
),
|
||||||
|
onEvent: (event) => updatePresentation(presentationId, event),
|
||||||
|
onCommitted: (rewritten) => {
|
||||||
|
setDraftContent(rewritten);
|
||||||
|
setHasLocalEdits(false);
|
||||||
|
rewritePresentation.update(presentationId, { rewrittenContent: rewritten });
|
||||||
|
queryClient.setQueriesData(
|
||||||
|
{ queryKey: ["artifact", filepath, threadId, isMock] },
|
||||||
|
{ content: rewritten, url: undefined },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Existing artifacts remain available even if history hydration fails.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
canEditArtifact,
|
||||||
|
documentRewrite,
|
||||||
|
documentRewriteActive,
|
||||||
|
draftContent,
|
||||||
|
filepath,
|
||||||
|
isMock,
|
||||||
|
queryClient,
|
||||||
|
rewritePresentation,
|
||||||
|
threadId,
|
||||||
|
useWriteFileStreaming,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The stream hook already coalesces requirements tokens with rAF. Mirror the
|
||||||
|
// coalesced plan into the ordinary message-list card instead of forcing a
|
||||||
|
// React context write for every individual SSE delta.
|
||||||
|
useEffect(() => {
|
||||||
|
const presentationId = documentRewritePresentationId;
|
||||||
|
const plan = documentRewrite.run.requirementsPlan;
|
||||||
|
if (!presentationId || !rewritePresentation || !plan) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `update` replaces the presentation context value, which changes the
|
||||||
|
// context object in this effect's dependency list. Only write when this
|
||||||
|
// particular card actually receives new text; otherwise React would
|
||||||
|
// re-enter this effect indefinitely after each context update.
|
||||||
|
const previous = mirroredRequirementsPlanRef.current;
|
||||||
|
if (previous.presentationId === presentationId && previous.plan === plan) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mirroredRequirementsPlanRef.current = { presentationId, plan };
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
requirementsPlan: plan,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
documentRewrite.run.requirementsPlan,
|
||||||
|
documentRewritePresentationId,
|
||||||
|
rewritePresentation,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRewriteUndo = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<{
|
||||||
|
source?: { kind?: string; id?: string; path?: string };
|
||||||
|
content?: string;
|
||||||
|
}>).detail;
|
||||||
|
if (
|
||||||
|
detail?.source?.kind !== "artifact" ||
|
||||||
|
detail.source.id !== threadId ||
|
||||||
|
detail.source.path !== filepath ||
|
||||||
|
typeof detail.content !== "string"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraftContent(detail.content);
|
||||||
|
setHasLocalEdits(false);
|
||||||
|
queryClient.setQueriesData(
|
||||||
|
{ queryKey: ["artifact", filepath, threadId, isMock] },
|
||||||
|
{ content: detail.content, url: undefined },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
window.addEventListener(DOCUMENT_REWRITE_UNDONE_EVENT, handleRewriteUndo);
|
||||||
|
return () => window.removeEventListener(DOCUMENT_REWRITE_UNDONE_EVENT, handleRewriteUndo);
|
||||||
|
}, [filepath, isMock, queryClient, threadId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAllowAutoViewSwitch(true);
|
setAllowAutoViewSwitch(true);
|
||||||
@@ -329,7 +692,7 @@ export function ArtifactFileDetail({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!thread.isLoading || !autoScrollEnabled) {
|
if (!(thread.isLoading || documentRewriteActive) || !autoScrollEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,17 +703,23 @@ export function ArtifactFileDetail({
|
|||||||
tick();
|
tick();
|
||||||
const intervalId = window.setInterval(tick, 180);
|
const intervalId = window.setInterval(tick, 180);
|
||||||
return () => window.clearInterval(intervalId);
|
return () => window.clearInterval(intervalId);
|
||||||
}, [autoScrollEnabled, scrollArtifactToBottom, thread.isLoading]);
|
}, [
|
||||||
|
autoScrollEnabled,
|
||||||
|
documentRewriteActive,
|
||||||
|
scrollArtifactToBottom,
|
||||||
|
thread.isLoading,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!thread.isLoading || !autoScrollEnabled) {
|
if (!(thread.isLoading || documentRewriteActive) || !autoScrollEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollArtifactToBottom("auto");
|
scrollArtifactToBottom("auto");
|
||||||
}, [
|
}, [
|
||||||
autoScrollEnabled,
|
autoScrollEnabled,
|
||||||
content,
|
displayContent,
|
||||||
|
documentRewriteActive,
|
||||||
scrollArtifactToBottom,
|
scrollArtifactToBottom,
|
||||||
thread.isLoading,
|
thread.isLoading,
|
||||||
viewMode,
|
viewMode,
|
||||||
@@ -451,7 +820,7 @@ export function ArtifactFileDetail({
|
|||||||
}, [scrollArtifactToBottom]);
|
}, [scrollArtifactToBottom]);
|
||||||
|
|
||||||
const handleSaveArtifact = useCallback(async () => {
|
const handleSaveArtifact = useCallback(async () => {
|
||||||
if (!canEditArtifact || updateArtifact.isPending) {
|
if (!canEditArtifact || updateArtifact.isPending || documentRewriteActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -474,8 +843,163 @@ export function ArtifactFileDetail({
|
|||||||
isMock,
|
isMock,
|
||||||
threadId,
|
threadId,
|
||||||
updateArtifact,
|
updateArtifact,
|
||||||
|
documentRewriteActive,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const handleStartDocumentRewrite = useCallback(
|
||||||
|
async (input: {
|
||||||
|
instruction: string;
|
||||||
|
style: "formal_analysis" | "professional" | "concise" | "readable";
|
||||||
|
modelName: string | null;
|
||||||
|
}) => {
|
||||||
|
if (!canUseFullDocumentRewrite) {
|
||||||
|
toast.error(
|
||||||
|
hasLocalEdits
|
||||||
|
? "请先保存当前 Markdown 修改后再进行全文改写。"
|
||||||
|
: "当前文件暂不支持全文改写。",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDocumentRewriteDialogOpen(false);
|
||||||
|
setAllowAutoViewSwitch(false);
|
||||||
|
setAutoScrollEnabled(true);
|
||||||
|
setViewMode("code");
|
||||||
|
const expectedHash = await sha256Content(draftContent);
|
||||||
|
const presentationId = rewritePresentation?.create({
|
||||||
|
threadId,
|
||||||
|
source: { kind: "artifact", id: threadId, path: filepath },
|
||||||
|
fileName: getFileName(filepath),
|
||||||
|
instruction: input.instruction,
|
||||||
|
style: input.style,
|
||||||
|
modelName: input.modelName,
|
||||||
|
originalContent: draftContent,
|
||||||
|
});
|
||||||
|
setDocumentRewritePresentationId(presentationId ?? null);
|
||||||
|
void documentRewrite.start(
|
||||||
|
{
|
||||||
|
threadId,
|
||||||
|
path: filepath,
|
||||||
|
instruction: input.instruction,
|
||||||
|
style: input.style,
|
||||||
|
modelName: input.modelName ?? undefined,
|
||||||
|
expectedHash,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onEvent: (event) => {
|
||||||
|
if (!presentationId || !rewritePresentation) return;
|
||||||
|
switch (event.type) {
|
||||||
|
case "snapshot_fixed":
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
stage: "requirements",
|
||||||
|
fileName: event.fileName || getFileName(filepath),
|
||||||
|
originalCharCount: event.originalCharCount,
|
||||||
|
requirementsPlan: "",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "requirements_started":
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
stage: "requirements",
|
||||||
|
requirementsPlan: "",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "requirements_checkpoint":
|
||||||
|
case "requirements_completed":
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
stage: "requirements",
|
||||||
|
requirementsPlan: event.content,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "requirements_resolved":
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
stage: "writing",
|
||||||
|
...(event.plan ? { requirementsPlan: event.plan } : {}),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "rewrite_delta":
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
stage: "writing",
|
||||||
|
writtenCharCount: event.charCount,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "validation_started":
|
||||||
|
rewritePresentation.update(presentationId, { stage: "validation" });
|
||||||
|
break;
|
||||||
|
case "validation_completed":
|
||||||
|
rewritePresentation.update(presentationId, { warnings: event.warnings });
|
||||||
|
break;
|
||||||
|
case "comparison_ready":
|
||||||
|
rewritePresentation.update(presentationId, { summary: event.summary });
|
||||||
|
break;
|
||||||
|
case "commit_started":
|
||||||
|
rewritePresentation.update(presentationId, { stage: "committing" });
|
||||||
|
break;
|
||||||
|
case "committed":
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
versionId: event.versionId ?? null,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "done":
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
stage: "completed",
|
||||||
|
versionId: event.versionId ?? null,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "conflict":
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
stage: "conflict",
|
||||||
|
error: event.message,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
rewritePresentation.update(presentationId, {
|
||||||
|
stage: "error",
|
||||||
|
error: event.message,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCommitted: (rewritten) => {
|
||||||
|
setDraftContent(rewritten);
|
||||||
|
setHasLocalEdits(false);
|
||||||
|
if (presentationId && rewritePresentation) {
|
||||||
|
rewritePresentation.update(presentationId, { rewrittenContent: rewritten });
|
||||||
|
}
|
||||||
|
queryClient.setQueriesData(
|
||||||
|
{
|
||||||
|
queryKey: ["artifact", filepath, threadId, isMock],
|
||||||
|
},
|
||||||
|
{ content: rewritten, url: undefined },
|
||||||
|
);
|
||||||
|
toast.success("Markdown 全文改写完成,已安全替换原文件。");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
canUseFullDocumentRewrite,
|
||||||
|
draftContent,
|
||||||
|
documentRewrite,
|
||||||
|
filepath,
|
||||||
|
hasLocalEdits,
|
||||||
|
isMock,
|
||||||
|
queryClient,
|
||||||
|
rewritePresentation,
|
||||||
|
threadId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCancelDocumentRewrite = useCallback(() => {
|
||||||
|
documentRewrite.cancel();
|
||||||
|
if (documentRewritePresentationId && rewritePresentation) {
|
||||||
|
rewritePresentation.update(documentRewritePresentationId, {
|
||||||
|
stage: "cancelled",
|
||||||
|
error: "已停止全文改写,原文件未被修改。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [documentRewrite, documentRewritePresentationId, rewritePresentation]);
|
||||||
|
|
||||||
// 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体
|
// 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体
|
||||||
// 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。
|
// 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。
|
||||||
const { agent_id: routeAgentId } = useParams<{ agent_id?: string }>();
|
const { agent_id: routeAgentId } = useParams<{ agent_id?: string }>();
|
||||||
@@ -642,7 +1166,7 @@ export function ArtifactFileDetail({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{canUseAiEditor && (
|
{canUseAiEditor && !documentRewriteActive && (
|
||||||
<ToggleGroupItem
|
<ToggleGroupItem
|
||||||
className="text-muted-foreground h-8 min-w-10 rounded-full border-0 bg-transparent px-3 shadow-none data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-[0_6px_14px_-10px_rgba(15,23,42,0.35)]"
|
className="text-muted-foreground h-8 min-w-10 rounded-full border-0 bg-transparent px-3 shadow-none data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-[0_6px_14px_-10px_rgba(15,23,42,0.35)]"
|
||||||
value="edit"
|
value="edit"
|
||||||
@@ -689,10 +1213,25 @@ export function ArtifactFileDetail({
|
|||||||
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
|
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
|
||||||
label="保存 Markdown"
|
label="保存 Markdown"
|
||||||
tooltip={hasLocalEdits ? "保存 Markdown 修改" : "Markdown 已保存"}
|
tooltip={hasLocalEdits ? "保存 Markdown 修改" : "Markdown 已保存"}
|
||||||
disabled={!hasLocalEdits || updateArtifact.isPending}
|
disabled={!hasLocalEdits || updateArtifact.isPending || documentRewriteActive}
|
||||||
onClick={handleSaveArtifact}
|
onClick={handleSaveArtifact}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{canEditArtifact && (
|
||||||
|
<ArtifactAction
|
||||||
|
icon={documentRewriteActive ? LoaderIcon : SparklesIcon}
|
||||||
|
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
|
||||||
|
tooltip={
|
||||||
|
documentRewriteActive
|
||||||
|
? "正在生成临时草稿,原文件尚未被覆盖"
|
||||||
|
: hasLocalEdits
|
||||||
|
? "请先保存当前 Markdown 修改"
|
||||||
|
: "使用 AI 重写当前完整 Markdown"
|
||||||
|
}
|
||||||
|
disabled={documentRewriteActive || !canUseFullDocumentRewrite}
|
||||||
|
onClick={() => setDocumentRewriteDialogOpen(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
|
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
|
||||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||||
<ArtifactAction
|
<ArtifactAction
|
||||||
@@ -723,7 +1262,7 @@ export function ArtifactFileDetail({
|
|||||||
tooltip={t.clipboard.copyToClipboard}
|
tooltip={t.clipboard.copyToClipboard}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{language === "markdown" && (
|
{language === "markdown" && !onDownloadClick && (
|
||||||
<ArtifactAction
|
<ArtifactAction
|
||||||
icon={isExportingWord ? LoaderIcon : FileType2Icon}
|
icon={isExportingWord ? LoaderIcon : FileType2Icon}
|
||||||
label="另存为 Word"
|
label="另存为 Word"
|
||||||
@@ -746,6 +1285,10 @@ export function ArtifactFileDetail({
|
|||||||
label={t.common.download}
|
label={t.common.download}
|
||||||
tooltip={t.common.download}
|
tooltip={t.common.download}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
if (onDownloadClick) {
|
||||||
|
onDownloadClick();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (useWriteFileStreaming) {
|
if (useWriteFileStreaming) {
|
||||||
handleDownloadWriteFile();
|
handleDownloadWriteFile();
|
||||||
return;
|
return;
|
||||||
@@ -801,6 +1344,27 @@ export function ArtifactFileDetail({
|
|||||||
正在改写 Markdown
|
正在改写 Markdown
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{documentRewriteActive && (
|
||||||
|
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
|
||||||
|
<LoaderIcon className="size-3.5 animate-spin" />
|
||||||
|
{documentRewrite.run.stage === "requirements"
|
||||||
|
? "正在理解改写需求,原文件未被修改"
|
||||||
|
: documentRewrite.run.stage === "validation"
|
||||||
|
? "正在校验 Markdown"
|
||||||
|
: documentRewrite.run.stage === "committing"
|
||||||
|
? "正在安全替换原文件"
|
||||||
|
: "正在写入临时草稿 · " +
|
||||||
|
documentRewrite.run.writtenCharCount.toLocaleString() +
|
||||||
|
" 字"}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-1 rounded px-1.5 py-0.5 font-medium hover:bg-[#ead9a6]"
|
||||||
|
onClick={handleCancelDocumentRewrite}
|
||||||
|
>
|
||||||
|
停止
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{showResumeAutoScroll && (
|
{showResumeAutoScroll && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -811,7 +1375,7 @@ export function ArtifactFileDetail({
|
|||||||
滑动到底部并继续自动下滑
|
滑动到底部并继续自动下滑
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{viewMode === "edit" && canUseAiEditor && (
|
{viewMode === "edit" && canUseAiEditor && !documentRewriteActive && (
|
||||||
<MarkdownAiEditor
|
<MarkdownAiEditor
|
||||||
// 编辑用原始内容(与源码模式一致,不脱敏);保存仍走头部「保存 Markdown」按钮。
|
// 编辑用原始内容(与源码模式一致,不脱敏);保存仍走头部「保存 Markdown」按钮。
|
||||||
markdown={displayContent ?? ""}
|
markdown={displayContent ?? ""}
|
||||||
@@ -845,9 +1409,9 @@ export function ArtifactFileDetail({
|
|||||||
? (displayContent ?? "")
|
? (displayContent ?? "")
|
||||||
: desensitize(displayContent ?? "")
|
: desensitize(displayContent ?? "")
|
||||||
}
|
}
|
||||||
readonly={!canEditArtifact}
|
readonly={!canEditArtifact || documentRewriteActive}
|
||||||
onChange={
|
onChange={
|
||||||
canEditArtifact
|
canEditArtifact && !documentRewriteActive
|
||||||
? (value) => {
|
? (value) => {
|
||||||
setDraftContent(value);
|
setDraftContent(value);
|
||||||
setHasLocalEdits(true);
|
setHasLocalEdits(true);
|
||||||
@@ -885,6 +1449,13 @@ export function ArtifactFileDetail({
|
|||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
filepath={filepath}
|
filepath={filepath}
|
||||||
/>
|
/>
|
||||||
|
<DocumentRewriteDialog
|
||||||
|
open={documentRewriteDialogOpen}
|
||||||
|
onOpenChange={setDocumentRewriteDialogOpen}
|
||||||
|
fileName={getFileName(filepath)}
|
||||||
|
charCount={(displayContent ?? "").replace(/\s/g, "").length}
|
||||||
|
onStart={handleStartDocumentRewrite}
|
||||||
|
/>
|
||||||
</Artifact>
|
</Artifact>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,14 @@ export function ArtifactFileList({
|
|||||||
files,
|
files,
|
||||||
threadId,
|
threadId,
|
||||||
onOpenArtifact,
|
onOpenArtifact,
|
||||||
|
onDownloadArtifact,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
files: string[];
|
files: string[];
|
||||||
threadId: string;
|
threadId: string;
|
||||||
onOpenArtifact?: (filepath: string) => void;
|
onOpenArtifact?: (filepath: string) => void;
|
||||||
|
/** Optional host download action for virtual/session-owned artifacts. */
|
||||||
|
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { isMock, publicShareCode } = useThread();
|
const { isMock, publicShareCode } = useThread();
|
||||||
@@ -68,7 +71,11 @@ export function ArtifactFileList({
|
|||||||
if (downloadingFile) return;
|
if (downloadingFile) return;
|
||||||
setDownloadingFile(filepath);
|
setDownloadingFile(filepath);
|
||||||
try {
|
try {
|
||||||
|
if (onDownloadArtifact) {
|
||||||
|
await onDownloadArtifact(filepath);
|
||||||
|
} else {
|
||||||
await downloadArtifactFile({ filepath, threadId, isMock, publicShareCode });
|
await downloadArtifactFile({ filepath, threadId, isMock, publicShareCode });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -78,7 +85,7 @@ export function ArtifactFileList({
|
|||||||
setDownloadingFile(null);
|
setDownloadingFile(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[threadId, isMock, publicShareCode, downloadingFile],
|
[threadId, isMock, publicShareCode, downloadingFile, onDownloadArtifact],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -9,13 +10,33 @@ import {
|
|||||||
import { useSidebarSafe } from "@/components/ui/sidebar";
|
import { useSidebarSafe } from "@/components/ui/sidebar";
|
||||||
import { env } from "@/env";
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ArtifactsContextType {
|
export interface ArtifactsContextType {
|
||||||
artifacts: string[];
|
artifacts: string[];
|
||||||
setArtifacts: (artifacts: string[]) => void;
|
setArtifacts: (artifacts: string[]) => void;
|
||||||
|
|
||||||
selectedArtifact: string | null;
|
selectedArtifact: string | null;
|
||||||
|
selectedArtifactKey: string | null;
|
||||||
autoSelect: boolean;
|
autoSelect: boolean;
|
||||||
select: (artifact: string, autoSelect?: boolean) => void;
|
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;
|
deselect: () => void;
|
||||||
|
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -37,16 +58,19 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
setArtifactsState(Array.isArray(next) ? next : []);
|
setArtifactsState(Array.isArray(next) ? next : []);
|
||||||
}, []);
|
}, []);
|
||||||
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
|
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
|
||||||
|
const [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null);
|
||||||
const [autoSelect, setAutoSelect] = useState(true);
|
const [autoSelect, setAutoSelect] = useState(true);
|
||||||
const [open, setOpen] = useState(
|
const [open, setOpenState] = useState(
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||||
);
|
);
|
||||||
const [autoOpen, setAutoOpen] = useState(true);
|
const [autoOpen, setAutoOpen] = useState(true);
|
||||||
|
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
|
||||||
const sidebar = useSidebarSafe();
|
const sidebar = useSidebarSafe();
|
||||||
|
|
||||||
const select = useCallback(
|
const select = useCallback(
|
||||||
(artifact: string, autoSelect = false) => {
|
(artifact: string, autoSelect = false) => {
|
||||||
setSelectedArtifact(artifact);
|
setSelectedArtifact(artifact);
|
||||||
|
setSelectedArtifactKey(artifactPreviewKey(artifact));
|
||||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
|
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
|
||||||
sidebar?.setOpen(false);
|
sidebar?.setOpen(false);
|
||||||
}
|
}
|
||||||
@@ -54,13 +78,33 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
setAutoSelect(false);
|
setAutoSelect(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[sidebar, setSelectedArtifact, setAutoSelect],
|
[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],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deselect = useCallback(() => {
|
const deselect = useCallback(() => {
|
||||||
setSelectedArtifact(null);
|
setSelectedArtifact(null);
|
||||||
|
setSelectedArtifactKey(null);
|
||||||
setAutoSelect(true);
|
setAutoSelect(true);
|
||||||
setOpen(false);
|
setOpenState(false);
|
||||||
|
suppressedAutoOpenKeyRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value: ArtifactsContextType = {
|
const value: ArtifactsContextType = {
|
||||||
@@ -75,11 +119,16 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
setAutoOpen(false);
|
setAutoOpen(false);
|
||||||
setAutoSelect(false);
|
setAutoSelect(false);
|
||||||
}
|
}
|
||||||
setOpen(isOpen);
|
if (!isOpen) {
|
||||||
|
suppressedAutoOpenKeyRef.current = selectedArtifactKey;
|
||||||
|
}
|
||||||
|
setOpenState(isOpen);
|
||||||
},
|
},
|
||||||
|
|
||||||
selectedArtifact,
|
selectedArtifact,
|
||||||
|
selectedArtifactKey,
|
||||||
select,
|
select,
|
||||||
|
openStreamedArtifact,
|
||||||
deselect,
|
deselect,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"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,6 +15,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
import { apiFetch } from "@/core/api";
|
import { apiFetch } from "@/core/api";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
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 { useSkills } from "@/core/skills/hooks";
|
||||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||||
import {
|
import {
|
||||||
@@ -92,6 +94,20 @@ const ChatBox: React.FC<{
|
|||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
const ragModeEnabled =
|
const ragModeEnabled =
|
||||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
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 { skills } = useSkills();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -102,15 +118,16 @@ const ChatBox: React.FC<{
|
|||||||
select: selectArtifact,
|
select: selectArtifact,
|
||||||
deselect,
|
deselect,
|
||||||
selectedArtifact,
|
selectedArtifact,
|
||||||
|
selectedArtifactKey,
|
||||||
} = useArtifacts();
|
} = useArtifacts();
|
||||||
|
|
||||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||||
const skillDisplayMap = useMemo(
|
const skillDisplayMap = useMemo(
|
||||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||||
[ragModeEnabled, skills],
|
[referencesEnabled, skills],
|
||||||
);
|
);
|
||||||
const fallbackReferenceBatches = useMemo(() => {
|
const fallbackReferenceBatches = useMemo(() => {
|
||||||
if (!ragModeEnabled) {
|
if (!referencesEnabled) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return buildTurnReferenceBatches(
|
return buildTurnReferenceBatches(
|
||||||
@@ -118,9 +135,9 @@ const ChatBox: React.FC<{
|
|||||||
skillDisplayMap,
|
skillDisplayMap,
|
||||||
thread.isLoading,
|
thread.isLoading,
|
||||||
);
|
);
|
||||||
}, [ragModeEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
}, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ragModeEnabled) {
|
if (!shouldFetchBackendReferenceBatches) {
|
||||||
setBackendReferenceBatches([]);
|
setBackendReferenceBatches([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -156,7 +173,12 @@ const ChatBox: React.FC<{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]);
|
}, [
|
||||||
|
shouldFetchBackendReferenceBatches,
|
||||||
|
thread.isLoading,
|
||||||
|
threadId,
|
||||||
|
thread.values.messages,
|
||||||
|
]);
|
||||||
const adaptedBackendReferenceBatches = useMemo(
|
const adaptedBackendReferenceBatches = useMemo(
|
||||||
() =>
|
() =>
|
||||||
adaptBackendReferenceBatches(
|
adaptBackendReferenceBatches(
|
||||||
@@ -167,15 +189,22 @@ const ChatBox: React.FC<{
|
|||||||
);
|
);
|
||||||
const referenceBatches = useMemo(
|
const referenceBatches = useMemo(
|
||||||
() => {
|
() => {
|
||||||
if (adaptedBackendReferenceBatches.length > 0) {
|
if (
|
||||||
|
shouldFetchBackendReferenceBatches &&
|
||||||
|
adaptedBackendReferenceBatches.length > 0
|
||||||
|
) {
|
||||||
return adaptedBackendReferenceBatches;
|
return adaptedBackendReferenceBatches;
|
||||||
}
|
}
|
||||||
return fallbackReferenceBatches;
|
return fallbackReferenceBatches;
|
||||||
},
|
},
|
||||||
[adaptedBackendReferenceBatches, fallbackReferenceBatches],
|
[
|
||||||
|
adaptedBackendReferenceBatches,
|
||||||
|
fallbackReferenceBatches,
|
||||||
|
shouldFetchBackendReferenceBatches,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ragModeEnabled) {
|
if (!shouldFetchBackendReferenceBatches) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const signature = JSON.stringify({
|
const signature = JSON.stringify({
|
||||||
@@ -202,7 +231,14 @@ const ChatBox: React.FC<{
|
|||||||
fallbackBatches: debugReferenceBatches(fallbackReferenceBatches),
|
fallbackBatches: debugReferenceBatches(fallbackReferenceBatches),
|
||||||
finalBatches: debugReferenceBatches(referenceBatches),
|
finalBatches: debugReferenceBatches(referenceBatches),
|
||||||
});
|
});
|
||||||
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]);
|
}, [
|
||||||
|
adaptedBackendReferenceBatches,
|
||||||
|
backendReferenceBatches,
|
||||||
|
fallbackReferenceBatches,
|
||||||
|
referenceBatches,
|
||||||
|
shouldFetchBackendReferenceBatches,
|
||||||
|
threadId,
|
||||||
|
]);
|
||||||
const latestReferenceBatch =
|
const latestReferenceBatch =
|
||||||
referenceBatches.length > 0
|
referenceBatches.length > 0
|
||||||
? referenceBatches[referenceBatches.length - 1]!
|
? referenceBatches[referenceBatches.length - 1]!
|
||||||
@@ -213,7 +249,7 @@ const ChatBox: React.FC<{
|
|||||||
const referenceSources = activeReferenceBatch?.sources ?? [];
|
const referenceSources = activeReferenceBatch?.sources ?? [];
|
||||||
const hasCitationReferences = referenceSources.length > 0;
|
const hasCitationReferences = referenceSources.length > 0;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ragModeEnabled || !activeReferenceBatch) {
|
if (!shouldFetchBackendReferenceBatches || !activeReferenceBatch) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logReferenceDebug("frontend-active-reference-batch-payload", threadId, {
|
logReferenceDebug("frontend-active-reference-batch-payload", threadId, {
|
||||||
@@ -222,7 +258,7 @@ const ChatBox: React.FC<{
|
|||||||
sourceCount: activeReferenceBatch.sources.length,
|
sourceCount: activeReferenceBatch.sources.length,
|
||||||
sources: activeReferenceBatch.sources.map(debugReferenceSource),
|
sources: activeReferenceBatch.sources.map(debugReferenceSource),
|
||||||
});
|
});
|
||||||
}, [activeReferenceBatch, ragModeEnabled, threadId]);
|
}, [activeReferenceBatch, shouldFetchBackendReferenceBatches, threadId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasCitationReferences) {
|
if (hasCitationReferences) {
|
||||||
setReferencesOpen(true);
|
setReferencesOpen(true);
|
||||||
@@ -420,6 +456,7 @@ const ChatBox: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
||||||
<ArtifactFileDetail
|
<ArtifactFileDetail
|
||||||
|
key={selectedArtifactKey ?? selectedArtifact}
|
||||||
className="size-full"
|
className="size-full"
|
||||||
filepath={selectedArtifact}
|
filepath={selectedArtifact}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
@@ -435,6 +472,7 @@ const ChatBox: React.FC<{
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
) : selectedArtifact ? (
|
) : selectedArtifact ? (
|
||||||
<ArtifactFileDetail
|
<ArtifactFileDetail
|
||||||
|
key={selectedArtifactKey ?? selectedArtifact}
|
||||||
className="size-full"
|
className="size-full"
|
||||||
filepath={selectedArtifact}
|
filepath={selectedArtifact}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
|
|||||||
@@ -8,8 +8,13 @@ export function CitationLink({
|
|||||||
href,
|
href,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
onReferenceClick,
|
||||||
|
onClick,
|
||||||
...props
|
...props
|
||||||
}: ComponentProps<"a">) {
|
}: ComponentProps<"a"> & {
|
||||||
|
/** Lets a message renderer show an in-place source preview instead of scrolling. */
|
||||||
|
onReferenceClick?: (referenceId: string) => void;
|
||||||
|
}) {
|
||||||
const childrenText = normalizeCitationText(children);
|
const childrenText = normalizeCitationText(children);
|
||||||
const displayText = childrenText ?? "";
|
const displayText = childrenText ?? "";
|
||||||
const referenceId = href?.startsWith("#reference-")
|
const referenceId = href?.startsWith("#reference-")
|
||||||
@@ -28,6 +33,11 @@ export function CitationLink({
|
|||||||
)}
|
)}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (onReferenceClick) {
|
||||||
|
onReferenceClick(referenceId);
|
||||||
|
onClick?.(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const batchId =
|
const batchId =
|
||||||
event.currentTarget.closest<HTMLElement>("[data-reference-batch-id]")
|
event.currentTarget.closest<HTMLElement>("[data-reference-batch-id]")
|
||||||
?.dataset.referenceBatchId;
|
?.dataset.referenceBatchId;
|
||||||
@@ -41,6 +51,7 @@ export function CitationLink({
|
|||||||
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
};
|
};
|
||||||
window.setTimeout(scroll, 240);
|
window.setTimeout(scroll, 240);
|
||||||
|
onClick?.(event);
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -58,6 +69,7 @@ export function CitationLink({
|
|||||||
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
onClick={onClick}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{displayText || children}
|
{displayText || children}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -64,6 +65,7 @@ import { getBackendBaseURL } from "@/core/config";
|
|||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
|
import type { Model } from "@/core/models/types";
|
||||||
import type { AgentThreadContext } from "@/core/threads";
|
import type { AgentThreadContext } from "@/core/threads";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
@@ -134,6 +136,8 @@ export function InputBox({
|
|||||||
onStop,
|
onStop,
|
||||||
promptPrefix,
|
promptPrefix,
|
||||||
agentSelector,
|
agentSelector,
|
||||||
|
knowledgeSelector,
|
||||||
|
extraModels = [],
|
||||||
assistantId,
|
assistantId,
|
||||||
hideAdminRecommendedQuestions,
|
hideAdminRecommendedQuestions,
|
||||||
compact,
|
compact,
|
||||||
@@ -194,12 +198,24 @@ export function InputBox({
|
|||||||
selectedAgentId: string | null;
|
selectedAgentId: string | null;
|
||||||
onAgentChange: (agentId: string | null) => void;
|
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 { t } = useI18n();
|
||||||
const [localSettings, setLocalSettings] = useLocalSettings();
|
const [localSettings, setLocalSettings] = useLocalSettings();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||||
const { models } = useModels();
|
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 { thread, isMock } = useThread();
|
||||||
const { textInput } = usePromptInputController();
|
const { textInput } = usePromptInputController();
|
||||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -223,16 +239,25 @@ export function InputBox({
|
|||||||
recommendedQuestions.length > 0;
|
recommendedQuestions.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (models.length === 0) {
|
// 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) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const currentModel = models.find((m) => m.name === context.model_name);
|
if (modelOptions.length === 0) {
|
||||||
const fallbackModel = currentModel ?? models[0]!;
|
return;
|
||||||
|
}
|
||||||
|
const currentModel = modelOptions.find((m) => m.name === context.model_name);
|
||||||
|
const fallbackModel = currentModel ?? modelOptions[0]!;
|
||||||
const supportsThinking = fallbackModel.supports_thinking ?? false;
|
const supportsThinking = fallbackModel.supports_thinking ?? false;
|
||||||
const nextModelName = fallbackModel.name;
|
const nextModelName = fallbackModel.name;
|
||||||
const nextMode = getResolvedMode(context.mode, supportsThinking);
|
const nextMode = getResolvedMode(context.mode, supportsThinking);
|
||||||
|
|
||||||
if (context.model_name === nextModelName && context.mode === nextMode) {
|
if (
|
||||||
|
context.model_name === nextModelName &&
|
||||||
|
context.mode === nextMode &&
|
||||||
|
context.execution_profile === fallbackModel.execution_profile
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,17 +265,19 @@ export function InputBox({
|
|||||||
...context,
|
...context,
|
||||||
model_name: nextModelName,
|
model_name: nextModelName,
|
||||||
mode: nextMode,
|
mode: nextMode,
|
||||||
|
execution_profile: fallbackModel.execution_profile,
|
||||||
});
|
});
|
||||||
}, [context, models, onContextChange]);
|
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
|
||||||
|
|
||||||
const selectedModel = useMemo(() => {
|
const selectedModel = useMemo(() => {
|
||||||
if (models.length === 0) {
|
if (modelOptions.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return models.find((m) => m.name === context.model_name) ?? models[0];
|
return modelOptions.find((m) => m.name === context.model_name) ?? modelOptions[0];
|
||||||
}, [context.model_name, models]);
|
}, [context.model_name, modelOptions]);
|
||||||
|
|
||||||
const resolvedModelName = selectedModel?.name;
|
const resolvedModelName = selectedModel?.name;
|
||||||
|
const selectedSingleRoundRag = singleRoundRagActive;
|
||||||
|
|
||||||
const supportThinking = useMemo(
|
const supportThinking = useMemo(
|
||||||
() => selectedModel?.supports_thinking ?? false,
|
() => selectedModel?.supports_thinking ?? false,
|
||||||
@@ -264,7 +291,7 @@ export function InputBox({
|
|||||||
|
|
||||||
const handleModelSelect = useCallback(
|
const handleModelSelect = useCallback(
|
||||||
(model_name: string) => {
|
(model_name: string) => {
|
||||||
const model = models.find((m) => m.name === model_name);
|
const model = modelOptions.find((m) => m.name === model_name);
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -273,17 +300,24 @@ export function InputBox({
|
|||||||
model_name,
|
model_name,
|
||||||
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
||||||
reasoning_effort: context.reasoning_effort,
|
reasoning_effort: context.reasoning_effort,
|
||||||
|
execution_profile: model.execution_profile,
|
||||||
});
|
});
|
||||||
setModelDialogOpen(false);
|
setModelDialogOpen(false);
|
||||||
},
|
},
|
||||||
[onContextChange, context, models],
|
[onContextChange, context, modelOptions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleModeSelect = useCallback(
|
const handleModeSelect = useCallback(
|
||||||
(mode: InputMode) => {
|
(mode: InputMode) => {
|
||||||
|
const ordinaryModel =
|
||||||
|
models.find((model) => model.name === context.model_name) ?? models[0];
|
||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
mode: getResolvedMode(mode, supportThinking),
|
model_name: ordinaryModel?.name ?? context.model_name,
|
||||||
|
mode: getResolvedMode(
|
||||||
|
mode,
|
||||||
|
ordinaryModel?.supports_thinking ?? supportThinking,
|
||||||
|
),
|
||||||
reasoning_effort:
|
reasoning_effort:
|
||||||
mode === "ultra"
|
mode === "ultra"
|
||||||
? "high"
|
? "high"
|
||||||
@@ -292,11 +326,38 @@ export function InputBox({
|
|||||||
: mode === "thinking"
|
: mode === "thinking"
|
||||||
? "low"
|
? "low"
|
||||||
: "minimal",
|
: "minimal",
|
||||||
|
execution_profile: undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[onContextChange, context, supportThinking],
|
[onContextChange, context, models, 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(
|
const handleReasoningEffortSelect = useCallback(
|
||||||
(effort: "minimal" | "low" | "medium" | "high") => {
|
(effort: "minimal" | "low" | "medium" | "high") => {
|
||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
@@ -365,6 +426,7 @@ export function InputBox({
|
|||||||
context.mode,
|
context.mode,
|
||||||
selectedModel?.supports_thinking ?? false,
|
selectedModel?.supports_thinking ?? false,
|
||||||
),
|
),
|
||||||
|
execution_profile: selectedModel?.execution_profile,
|
||||||
});
|
});
|
||||||
setTimeout(() => onSubmit?.(message), 0);
|
setTimeout(() => onSubmit?.(message), 0);
|
||||||
return;
|
return;
|
||||||
@@ -380,6 +442,7 @@ export function InputBox({
|
|||||||
resolvedModelName,
|
resolvedModelName,
|
||||||
ragModeEnabled,
|
ragModeEnabled,
|
||||||
selectedModel?.supports_thinking,
|
selectedModel?.supports_thinking,
|
||||||
|
selectedModel?.execution_profile,
|
||||||
status,
|
status,
|
||||||
threadId,
|
threadId,
|
||||||
],
|
],
|
||||||
@@ -439,6 +502,7 @@ export function InputBox({
|
|||||||
const showFollowups =
|
const showFollowups =
|
||||||
!disabled &&
|
!disabled &&
|
||||||
!isNewThread &&
|
!isNewThread &&
|
||||||
|
!selectedSingleRoundRag &&
|
||||||
!followupsHidden &&
|
!followupsHidden &&
|
||||||
(followupsLoading || followups.length > 0);
|
(followupsLoading || followups.length > 0);
|
||||||
|
|
||||||
@@ -464,7 +528,7 @@ export function InputBox({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disabled || isMock) {
|
if (disabled || isMock || selectedSingleRoundRag) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,7 +590,7 @@ export function InputBox({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [context.model_name, disabled, isMock, status, thread.messages, threadId]);
|
}, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
||||||
@@ -663,6 +727,7 @@ export function InputBox({
|
|||||||
/>
|
/>
|
||||||
</PromptInputActionMenuContent>
|
</PromptInputActionMenuContent>
|
||||||
</PromptInputActionMenu> */}
|
</PromptInputActionMenu> */}
|
||||||
|
{!frontendRagActive && (
|
||||||
<AddAttachmentsButton
|
<AddAttachmentsButton
|
||||||
className={cn(
|
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",
|
"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",
|
||||||
@@ -670,7 +735,8 @@ export function InputBox({
|
|||||||
"gap-1.5! rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
"gap-1.5! rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
)}
|
||||||
|
{!frontendRagActive && <Tooltip
|
||||||
content={
|
content={
|
||||||
context.writing_mode
|
context.writing_mode
|
||||||
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
|
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
|
||||||
@@ -695,8 +761,8 @@ export function InputBox({
|
|||||||
<FileTextIcon className="size-3" />
|
<FileTextIcon className="size-3" />
|
||||||
<span className="text-xs font-normal">写作</span>
|
<span className="text-xs font-normal">写作</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>
|
</Tooltip>}
|
||||||
<Tooltip
|
{!frontendRagActive && <Tooltip
|
||||||
content={
|
content={
|
||||||
memoryInjectionEnabled
|
memoryInjectionEnabled
|
||||||
? "记忆注入已开启:对话前会注入历史记忆"
|
? "记忆注入已开启:对话前会注入历史记忆"
|
||||||
@@ -721,8 +787,8 @@ export function InputBox({
|
|||||||
<BrainCircuitIcon className="size-3" />
|
<BrainCircuitIcon className="size-3" />
|
||||||
<span className="text-xs font-normal">记忆</span>
|
<span className="text-xs font-normal">记忆</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>
|
</Tooltip>}
|
||||||
<Tooltip
|
{!frontendRagActive && <Tooltip
|
||||||
content={
|
content={
|
||||||
ragModeEnabled
|
ragModeEnabled
|
||||||
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
||||||
@@ -751,8 +817,8 @@ export function InputBox({
|
|||||||
<BookMarkedIcon className="size-3" />
|
<BookMarkedIcon className="size-3" />
|
||||||
<span className="text-xs font-normal">参考文献</span>
|
<span className="text-xs font-normal">参考文献</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>
|
</Tooltip>}
|
||||||
<Tooltip
|
{!frontendRagActive && <Tooltip
|
||||||
content={
|
content={
|
||||||
contextCompressionEnabled
|
contextCompressionEnabled
|
||||||
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
|
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
|
||||||
@@ -780,7 +846,7 @@ export function InputBox({
|
|||||||
<ShrinkIcon className="size-3" />
|
<ShrinkIcon className="size-3" />
|
||||||
<span className="text-xs font-normal">上下文压缩</span>
|
<span className="text-xs font-normal">上下文压缩</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>
|
</Tooltip>}
|
||||||
<PromptInputActionMenu>
|
<PromptInputActionMenu>
|
||||||
<ModeHoverGuide
|
<ModeHoverGuide
|
||||||
mode={
|
mode={
|
||||||
@@ -791,13 +857,22 @@ export function InputBox({
|
|||||||
? context.mode
|
? context.mode
|
||||||
: "flash"
|
: "flash"
|
||||||
}
|
}
|
||||||
|
tooltipContent={
|
||||||
|
singleRoundRagActive
|
||||||
|
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
|
||||||
|
: multiRoundRagActive
|
||||||
|
? "多轮 RAG 问答:多次检索并展示检索过程与参考资料"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<PromptInputActionMenuTrigger
|
<PromptInputActionMenuTrigger
|
||||||
className={cn(
|
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",
|
"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 && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
|
||||||
compact &&
|
compact &&
|
||||||
(context.mode === "thinking"
|
(frontendRagActive
|
||||||
|
? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||||
|
: context.mode === "thinking"
|
||||||
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
|
? "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"
|
: 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"
|
? "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"
|
||||||
@@ -807,16 +882,17 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{context.mode === "flash" && <ZapIcon className="size-3" />}
|
{frontendRagActive ? (
|
||||||
{context.mode === "thinking" && (
|
<BookMarkedIcon className="size-3" />
|
||||||
|
) : context.mode === "flash" ? (
|
||||||
|
<ZapIcon className="size-3" />
|
||||||
|
) : context.mode === "thinking" ? (
|
||||||
<LightbulbIcon className="size-3" />
|
<LightbulbIcon className="size-3" />
|
||||||
)}
|
) : context.mode === "pro" ? (
|
||||||
{context.mode === "pro" && (
|
|
||||||
<GraduationCapIcon className="size-3" />
|
<GraduationCapIcon className="size-3" />
|
||||||
)}
|
) : context.mode === "ultra" ? (
|
||||||
{context.mode === "ultra" && (
|
|
||||||
<RocketIcon className="size-3 text-[#dabb5e]" />
|
<RocketIcon className="size-3 text-[#dabb5e]" />
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -824,7 +900,11 @@ export function InputBox({
|
|||||||
context.mode === "ultra" ? "golden-text" : "",
|
context.mode === "ultra" ? "golden-text" : "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{(context.mode === "flash" && t.inputBox.flashMode) ||
|
{frontendRagActive
|
||||||
|
? multiRoundRagActive
|
||||||
|
? "多轮 RAG"
|
||||||
|
: "单轮 RAG"
|
||||||
|
: (context.mode === "flash" && t.inputBox.flashMode) ||
|
||||||
(context.mode === "thinking" &&
|
(context.mode === "thinking" &&
|
||||||
t.inputBox.reasoningMode) ||
|
t.inputBox.reasoningMode) ||
|
||||||
(context.mode === "pro" && t.inputBox.proMode) ||
|
(context.mode === "pro" && t.inputBox.proMode) ||
|
||||||
@@ -840,7 +920,7 @@ export function InputBox({
|
|||||||
<PromptInputActionMenu>
|
<PromptInputActionMenu>
|
||||||
<PromptInputActionMenuItem
|
<PromptInputActionMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
context.mode === "flash"
|
!frontendRagActive && context.mode === "flash"
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground/65",
|
: "text-muted-foreground/65",
|
||||||
)}
|
)}
|
||||||
@@ -851,7 +931,7 @@ export function InputBox({
|
|||||||
<ZapIcon
|
<ZapIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
context.mode === "flash" &&
|
!frontendRagActive && context.mode === "flash" &&
|
||||||
"text-accent-foreground",
|
"text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -861,7 +941,7 @@ export function InputBox({
|
|||||||
{t.inputBox.flashModeDescription}
|
{t.inputBox.flashModeDescription}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{context.mode === "flash" ? (
|
{!frontendRagActive && context.mode === "flash" ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
@@ -870,7 +950,7 @@ export function InputBox({
|
|||||||
{supportThinking && (
|
{supportThinking && (
|
||||||
<PromptInputActionMenuItem
|
<PromptInputActionMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
context.mode === "thinking"
|
!frontendRagActive && context.mode === "thinking"
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground/65",
|
: "text-muted-foreground/65",
|
||||||
)}
|
)}
|
||||||
@@ -881,7 +961,7 @@ export function InputBox({
|
|||||||
<LightbulbIcon
|
<LightbulbIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
context.mode === "thinking" &&
|
!frontendRagActive && context.mode === "thinking" &&
|
||||||
"text-accent-foreground",
|
"text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -891,7 +971,7 @@ export function InputBox({
|
|||||||
{t.inputBox.reasoningModeDescription}
|
{t.inputBox.reasoningModeDescription}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{context.mode === "thinking" ? (
|
{!frontendRagActive && context.mode === "thinking" ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
@@ -900,7 +980,7 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
<PromptInputActionMenuItem
|
<PromptInputActionMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
context.mode === "pro"
|
!frontendRagActive && context.mode === "pro"
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground/65",
|
: "text-muted-foreground/65",
|
||||||
)}
|
)}
|
||||||
@@ -911,7 +991,7 @@ export function InputBox({
|
|||||||
<GraduationCapIcon
|
<GraduationCapIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
context.mode === "pro" &&
|
!frontendRagActive && context.mode === "pro" &&
|
||||||
"text-accent-foreground",
|
"text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -921,7 +1001,7 @@ export function InputBox({
|
|||||||
{t.inputBox.proModeDescription}
|
{t.inputBox.proModeDescription}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{context.mode === "pro" ? (
|
{!frontendRagActive && context.mode === "pro" ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
@@ -929,7 +1009,7 @@ export function InputBox({
|
|||||||
</PromptInputActionMenuItem>
|
</PromptInputActionMenuItem>
|
||||||
<PromptInputActionMenuItem
|
<PromptInputActionMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
context.mode === "ultra"
|
!frontendRagActive && context.mode === "ultra"
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground/65",
|
: "text-muted-foreground/65",
|
||||||
)}
|
)}
|
||||||
@@ -940,12 +1020,16 @@ export function InputBox({
|
|||||||
<RocketIcon
|
<RocketIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
context.mode === "ultra" && "text-[#dabb5e]",
|
!frontendRagActive &&
|
||||||
|
context.mode === "ultra" &&
|
||||||
|
"text-[#dabb5e]",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
context.mode === "ultra" && "golden-text",
|
!frontendRagActive &&
|
||||||
|
context.mode === "ultra" &&
|
||||||
|
"golden-text",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t.inputBox.ultraMode}
|
{t.inputBox.ultraMode}
|
||||||
@@ -955,7 +1039,68 @@ export function InputBox({
|
|||||||
{t.inputBox.ultraModeDescription}
|
{t.inputBox.ultraModeDescription}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{context.mode === "ultra" ? (
|
{!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 ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
@@ -965,7 +1110,7 @@ export function InputBox({
|
|||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</PromptInputActionMenuContent>
|
</PromptInputActionMenuContent>
|
||||||
</PromptInputActionMenu>
|
</PromptInputActionMenu>
|
||||||
{supportReasoningEffort && context.mode !== "flash" && (
|
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
|
||||||
<PromptInputActionMenu>
|
<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]")}>
|
<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">
|
<div className="text-xs font-normal">
|
||||||
@@ -1083,7 +1228,7 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
</PromptInputTools>
|
</PromptInputTools>
|
||||||
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
||||||
{agentSelector && (
|
{!frontendRagActive && agentSelector && (
|
||||||
<AgentSelector
|
<AgentSelector
|
||||||
selectedAgentId={agentSelector.selectedAgentId}
|
selectedAgentId={agentSelector.selectedAgentId}
|
||||||
onAgentChange={agentSelector.onAgentChange}
|
onAgentChange={agentSelector.onAgentChange}
|
||||||
@@ -1091,7 +1236,8 @@ export function InputBox({
|
|||||||
compact={compact}
|
compact={compact}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{promptPrefix && (
|
{!frontendRagActive && knowledgeSelector}
|
||||||
|
{!frontendRagActive && promptPrefix && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={
|
content={
|
||||||
promptPrefix.on
|
promptPrefix.on
|
||||||
@@ -1119,7 +1265,7 @@ export function InputBox({
|
|||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<ModelSelector
|
{!multiRoundRagActive && <ModelSelector
|
||||||
open={modelDialogOpen}
|
open={modelDialogOpen}
|
||||||
onOpenChange={setModelDialogOpen}
|
onOpenChange={setModelDialogOpen}
|
||||||
>
|
>
|
||||||
@@ -1146,7 +1292,7 @@ export function InputBox({
|
|||||||
<ModelSelectorContent>
|
<ModelSelectorContent>
|
||||||
<ModelSelectorInput placeholder={t.inputBox.searchModels} />
|
<ModelSelectorInput placeholder={t.inputBox.searchModels} />
|
||||||
<ModelSelectorList>
|
<ModelSelectorList>
|
||||||
{models.map((m) => (
|
{modelOptions.map((m) => (
|
||||||
<ModelSelectorItem
|
<ModelSelectorItem
|
||||||
key={m.name}
|
key={m.name}
|
||||||
value={m.name}
|
value={m.name}
|
||||||
@@ -1155,7 +1301,9 @@ export function InputBox({
|
|||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<ModelSelectorName>{m.display_name}</ModelSelectorName>
|
<ModelSelectorName>{m.display_name}</ModelSelectorName>
|
||||||
<span className="text-muted-foreground truncate text-[10px]">
|
<span className="text-muted-foreground truncate text-[10px]">
|
||||||
{m.model}
|
{selectedSingleRoundRag
|
||||||
|
? `单轮知识库问答 · ${m.name}`
|
||||||
|
: m.model}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{m.name === context.model_name ? (
|
{m.name === context.model_name ? (
|
||||||
@@ -1167,7 +1315,7 @@ export function InputBox({
|
|||||||
))}
|
))}
|
||||||
</ModelSelectorList>
|
</ModelSelectorList>
|
||||||
</ModelSelectorContent>
|
</ModelSelectorContent>
|
||||||
</ModelSelector>
|
</ModelSelector>}
|
||||||
<PromptInputSubmit
|
<PromptInputSubmit
|
||||||
className={cn(
|
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",
|
"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",
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
"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;
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"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,19 +1,21 @@
|
|||||||
import type { Message } from "@langchain/langgraph-sdk";
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
import {
|
import {
|
||||||
BookOpenTextIcon,
|
BookOpenTextIcon,
|
||||||
|
CheckCircle2Icon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
FolderOpenIcon,
|
FolderOpenIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
LightbulbIcon,
|
LightbulbIcon,
|
||||||
ListTodoIcon,
|
ListTodoIcon,
|
||||||
|
Loader2Icon,
|
||||||
MessageCircleQuestionMarkIcon,
|
MessageCircleQuestionMarkIcon,
|
||||||
NotebookPenIcon,
|
NotebookPenIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
SquareTerminalIcon,
|
SquareTerminalIcon,
|
||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChainOfThought,
|
ChainOfThought,
|
||||||
@@ -36,6 +38,12 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
buildWriteFileVirtualUrl,
|
||||||
|
hasCompleteArtifactExtension,
|
||||||
|
normalizeStreamingArtifactPath,
|
||||||
|
provisionalWriteFilePath,
|
||||||
|
} from "@/core/artifacts/streaming-write-preview";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
normalizeSearchResults,
|
normalizeSearchResults,
|
||||||
@@ -249,8 +257,6 @@ function ToolCall({
|
|||||||
ragModeEnabled?: boolean;
|
ragModeEnabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const artifacts = useArtifactsOptional();
|
|
||||||
|
|
||||||
const displayMatch =
|
const displayMatch =
|
||||||
result !== undefined && isDisplaySamplePayload(result)
|
result !== undefined && isDisplaySamplePayload(result)
|
||||||
? null
|
? null
|
||||||
@@ -281,6 +287,13 @@ function ToolCall({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
||||||
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
|
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||||
@@ -301,8 +314,10 @@ function ToolCall({
|
|||||||
if (query) {
|
if (query) {
|
||||||
label =
|
label =
|
||||||
name === "web_search"
|
name === "web_search"
|
||||||
? t.toolCalls.searchOnWebFor(query)
|
? `知识库检索 "${query}"`
|
||||||
: t.toolCalls.searchFor(query);
|
: t.toolCalls.searchFor(query);
|
||||||
|
} else if (name === "web_search") {
|
||||||
|
label = "知识库检索";
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
|
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
|
||||||
@@ -422,43 +437,17 @@ function ToolCall({
|
|||||||
if (!description) {
|
if (!description) {
|
||||||
description = t.toolCalls.writeFile;
|
description = t.toolCalls.writeFile;
|
||||||
}
|
}
|
||||||
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 (
|
return (
|
||||||
<ChainOfThoughtStep
|
<ArtifactWriteToolCall
|
||||||
key={id}
|
id={id}
|
||||||
className="cursor-pointer"
|
messageId={messageId}
|
||||||
label={description}
|
name={name}
|
||||||
icon={NotebookPenIcon}
|
args={args}
|
||||||
onClick={() => {
|
result={result}
|
||||||
if (!artifactPath || !artifacts) {
|
description={description}
|
||||||
return;
|
isLast={isLast}
|
||||||
}
|
isLoading={isLoading}
|
||||||
artifacts.select(artifactPath);
|
/>
|
||||||
artifacts.setOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{path && (
|
|
||||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
|
||||||
{path}
|
|
||||||
</ChainOfThoughtSearchResult>
|
|
||||||
)}
|
|
||||||
</ChainOfThoughtStep>
|
|
||||||
);
|
);
|
||||||
} else if (name === "bash") {
|
} else if (name === "bash") {
|
||||||
const description: string | undefined = (args as { description: string })
|
const description: string | undefined = (args as { description: string })
|
||||||
@@ -523,6 +512,123 @@ function ToolCall({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
interface GenericCoTStep<T extends string = string> {
|
||||||
id?: string;
|
id?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import type { Message } from '@langchain/langgraph-sdk';
|
import type { Message } from '@langchain/langgraph-sdk';
|
||||||
import { ClockIcon, FileIcon, Loader2Icon, PencilIcon } from 'lucide-react';
|
import {
|
||||||
|
BookMarkedIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ClockIcon,
|
||||||
|
ExternalLinkIcon,
|
||||||
|
FileIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
PencilIcon,
|
||||||
|
SearchIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -27,6 +36,18 @@ import {
|
|||||||
import { Task, TaskTrigger } from '@/components/ai-elements/task';
|
import { Task, TaskTrigger } from '@/components/ai-elements/task';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import {
|
import {
|
||||||
UploadedFilePreviewDialog,
|
UploadedFilePreviewDialog,
|
||||||
@@ -35,6 +56,7 @@ import {
|
|||||||
import { resolveArtifactURL } from '@/core/artifacts/utils';
|
import { resolveArtifactURL } from '@/core/artifacts/utils';
|
||||||
import { useChatAskTimes } from '@/core/chat-timestamps';
|
import { useChatAskTimes } from '@/core/chat-timestamps';
|
||||||
import { getBackendBaseURL } from '@/core/config';
|
import { getBackendBaseURL } from '@/core/config';
|
||||||
|
import { readRuntimeEnv } from '@/env';
|
||||||
import { useI18n } from '@/core/i18n/hooks';
|
import { useI18n } from '@/core/i18n/hooks';
|
||||||
import {
|
import {
|
||||||
extractContentFromMessage,
|
extractContentFromMessage,
|
||||||
@@ -50,6 +72,11 @@ import {
|
|||||||
type RuntimeDisplaySource,
|
type RuntimeDisplaySource,
|
||||||
} from '@/core/skill-display/runtime';
|
} from '@/core/skill-display/runtime';
|
||||||
import { humanMessagePlugins } from '@/core/streamdown';
|
import { humanMessagePlugins } from '@/core/streamdown';
|
||||||
|
import {
|
||||||
|
getMultiRoundRagTrace,
|
||||||
|
isMultiRoundRagMessage,
|
||||||
|
type MultiRoundRagRound,
|
||||||
|
} from '@/core/threads/use-multi-round-rag';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
import { CitationLink } from '../citations/citation-link';
|
import { CitationLink } from '../citations/citation-link';
|
||||||
@@ -563,7 +590,26 @@ function MessageContent_({
|
|||||||
);
|
);
|
||||||
const askTimes = useChatAskTimes();
|
const askTimes = useChatAskTimes();
|
||||||
const isHuman = message.type === 'human';
|
const isHuman = message.type === 'human';
|
||||||
|
const isSingleRoundRag =
|
||||||
|
message.additional_kwargs?.single_round_rag === true;
|
||||||
|
const isMultiRoundRag = isMultiRoundRagMessage(message);
|
||||||
|
const multiRoundTrace = isMultiRoundRag
|
||||||
|
? getMultiRoundRagTrace(message)
|
||||||
|
: null;
|
||||||
|
const [selectedCitation, setSelectedCitation] =
|
||||||
|
useState<RuntimeDisplaySource | null>(null);
|
||||||
const resolveArtifact = useArtifactResolver(threadId);
|
const resolveArtifact = useArtifactResolver(threadId);
|
||||||
|
const handleCitationClick = useCallback(
|
||||||
|
(referenceId: string) => {
|
||||||
|
const referenceIndex = Number(referenceId);
|
||||||
|
if (!Number.isInteger(referenceIndex) || referenceIndex < 1) return;
|
||||||
|
const source =
|
||||||
|
displaySources.find((item) => item.index === referenceIndex) ??
|
||||||
|
displaySources[referenceIndex - 1];
|
||||||
|
if (source) setSelectedCitation(source);
|
||||||
|
},
|
||||||
|
[displaySources],
|
||||||
|
);
|
||||||
const components = useMemo(
|
const components = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
|
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
|
||||||
@@ -571,7 +617,17 @@ function MessageContent_({
|
|||||||
),
|
),
|
||||||
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||||
if (href?.startsWith('#reference-')) {
|
if (href?.startsWith('#reference-')) {
|
||||||
return <CitationLink href={href} {...props} />;
|
return (
|
||||||
|
<CitationLink
|
||||||
|
href={href}
|
||||||
|
{...props}
|
||||||
|
onReferenceClick={
|
||||||
|
isSingleRoundRag || isMultiRoundRag
|
||||||
|
? handleCitationClick
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
typeof props.children === 'string' &&
|
typeof props.children === 'string' &&
|
||||||
@@ -593,7 +649,13 @@ function MessageContent_({
|
|||||||
return <a {...props} href={href} />;
|
return <a {...props} href={href} />;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[threadId, resolveArtifact],
|
[
|
||||||
|
handleCitationClick,
|
||||||
|
isMultiRoundRag,
|
||||||
|
isSingleRoundRag,
|
||||||
|
resolveArtifact,
|
||||||
|
threadId,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Question carries its own `client_ts` (round-trips through the checkpoint).
|
// Question carries its own `client_ts` (round-trips through the checkpoint).
|
||||||
@@ -661,7 +723,7 @@ function MessageContent_({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reasoning-only AI message (no main response content yet)
|
// Reasoning-only AI message (no main response content yet)
|
||||||
if (!isHuman && reasoningContent && !rawContent) {
|
if (!isHuman && reasoningContent && !rawContent && !multiRoundTrace) {
|
||||||
return (
|
return (
|
||||||
<AIElementMessageContent className={className}>
|
<AIElementMessageContent className={className}>
|
||||||
<Reasoning isStreaming={isLoading}>
|
<Reasoning isStreaming={isLoading}>
|
||||||
@@ -708,9 +770,20 @@ function MessageContent_({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<AIElementMessageContent className={className}>
|
<AIElementMessageContent className={className}>
|
||||||
{filesList}
|
{filesList}
|
||||||
<div data-reference-batch-id={message.id} className="contents">
|
<div data-reference-batch-id={message.id} className="contents">
|
||||||
|
{multiRoundTrace ? (
|
||||||
|
<MultiRoundRagContent
|
||||||
|
trace={multiRoundTrace}
|
||||||
|
content={contentToDisplay}
|
||||||
|
displaySources={displaySources}
|
||||||
|
isLoading={isLoading}
|
||||||
|
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
|
||||||
|
components={components}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<AssistantDisplayContent
|
<AssistantDisplayContent
|
||||||
content={contentToDisplay}
|
content={contentToDisplay}
|
||||||
displaySources={displaySources}
|
displaySources={displaySources}
|
||||||
@@ -719,7 +792,14 @@ function MessageContent_({
|
|||||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
|
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
|
||||||
components={components}
|
components={components}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{(isSingleRoundRag || isMultiRoundRag) && displaySources.length > 0 && (
|
||||||
|
<FrontendRagSourcesPanel
|
||||||
|
sources={displaySources}
|
||||||
|
onSelect={setSelectedCitation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<MessageTokenUsage
|
<MessageTokenUsage
|
||||||
enabled={tokenUsageEnabled}
|
enabled={tokenUsageEnabled}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@@ -729,9 +809,319 @@ function MessageContent_({
|
|||||||
<MessageTimeStamp time={messageTime} align="left" />
|
<MessageTimeStamp time={messageTime} align="left" />
|
||||||
) : null}
|
) : null}
|
||||||
</AIElementMessageContent>
|
</AIElementMessageContent>
|
||||||
|
{(isSingleRoundRag || isMultiRoundRag) && (
|
||||||
|
<SingleRoundRagCitationDialog
|
||||||
|
source={selectedCitation}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setSelectedCitation(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单轮/多轮 RAG 专属的「引用来源」折叠面板。
|
||||||
|
*
|
||||||
|
* 参考 GeneralQA 的引用来源区:默认折叠,列出全部参考资料,点击任一条打开详情
|
||||||
|
* 弹窗(复用 SingleRoundRagCitationDialog)。这样即使模型正文里没有 `[1]` 编号,
|
||||||
|
* 用户也能看到本次回答依据的资料。
|
||||||
|
*/
|
||||||
|
function FrontendRagSourcesPanel({
|
||||||
|
sources,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
sources: RuntimeDisplaySource[];
|
||||||
|
onSelect: (source: RuntimeDisplaySource) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
if (sources.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-xs transition-colors"
|
||||||
|
>
|
||||||
|
<BookMarkedIcon className="size-3.5" />
|
||||||
|
<span className="font-medium">引用来源</span>
|
||||||
|
<span className="text-muted-foreground/70">({sources.length})</span>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={cn(
|
||||||
|
'size-3.5 transition-transform duration-200',
|
||||||
|
!open && '-rotate-90',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="mt-2 grid gap-2">
|
||||||
|
{sources.map((source, idx) => (
|
||||||
|
<button
|
||||||
|
key={source.id ?? `${source.title}-${idx}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(source)}
|
||||||
|
className="bg-card hover:bg-accent block w-full cursor-pointer rounded-lg border p-3 text-left transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
|
||||||
|
{source.index}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-foreground line-clamp-1 min-w-0 flex-1 text-sm font-medium"
|
||||||
|
title={source.title}
|
||||||
|
>
|
||||||
|
{source.title}
|
||||||
|
</span>
|
||||||
|
{source.type && (
|
||||||
|
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-xs">
|
||||||
|
{source.type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{source.snippet && (
|
||||||
|
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
|
||||||
|
{source.snippet}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SingleRoundRagCitationDialog({
|
||||||
|
source,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
source: RuntimeDisplaySource | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
|
||||||
|
return (
|
||||||
|
<Dialog open={!!source} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
|
||||||
|
<DialogHeader className="pr-8">
|
||||||
|
<DialogTitle className="leading-relaxed">
|
||||||
|
{source?.title || "引用资料"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="space-y-1 text-xs">
|
||||||
|
<span className="block">来源:{source?.source || source?.type || "未知来源"}</span>
|
||||||
|
{source?.time && <span className="block">时间:{source.time}</span>}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{sourceUrl && (
|
||||||
|
<a
|
||||||
|
href={sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="border-input hover:bg-accent inline-flex w-fit items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLinkIcon className="size-4" />
|
||||||
|
查看原文链接
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<div className="bg-muted/50 max-h-[min(26rem,calc(100vh-16rem))] overflow-auto rounded-lg p-3 text-sm leading-7 whitespace-pre-wrap">
|
||||||
|
{source?.content || source?.snippet || "暂无引用内容"}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultiRoundRagContent({
|
||||||
|
trace,
|
||||||
|
content,
|
||||||
|
displaySources,
|
||||||
|
isLoading,
|
||||||
|
rehypePlugins,
|
||||||
|
components,
|
||||||
|
}: {
|
||||||
|
trace: NonNullable<ReturnType<typeof getMultiRoundRagTrace>>;
|
||||||
|
content: string;
|
||||||
|
displaySources: RuntimeDisplaySource[];
|
||||||
|
isLoading: boolean;
|
||||||
|
rehypePlugins: MarkdownContentProps['rehypePlugins'];
|
||||||
|
components: MarkdownContentProps['components'];
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(!trace.isComplete);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!trace.isComplete) setOpen(true);
|
||||||
|
}, [trace.isComplete]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<section className="border-border/70 overflow-hidden rounded-xl border bg-muted/20">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:bg-muted/50 flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors"
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{trace.isComplete ? (
|
||||||
|
<SearchIcon className="text-primary size-4 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">多轮检索过程</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{trace.isComplete
|
||||||
|
? `已完成 ${trace.rounds.length} 轮检索`
|
||||||
|
: '正在分析问题并检索资料'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={cn('text-muted-foreground size-4 transition-transform', !open && '-rotate-90')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="border-border/60 space-y-3 border-t px-4 py-3">
|
||||||
|
{trace.rounds.map((round, index) => (
|
||||||
|
<MultiRoundRagRoundCard
|
||||||
|
key={round.id}
|
||||||
|
index={index + 1}
|
||||||
|
round={round}
|
||||||
|
isLoading={isLoading}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
components={components}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<section className="border-border/70 rounded-xl border p-4">
|
||||||
|
<div className="mb-3 text-sm font-semibold">最终回答</div>
|
||||||
|
{content ? (
|
||||||
|
<AssistantDisplayContent
|
||||||
|
content={content}
|
||||||
|
displaySources={displaySources}
|
||||||
|
isLoading={isLoading}
|
||||||
|
renderSkillDisplayBlocks={false}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
components={components}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
正在生成最终回答…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultiRoundRagRoundCard({
|
||||||
|
index,
|
||||||
|
round,
|
||||||
|
isLoading,
|
||||||
|
rehypePlugins,
|
||||||
|
components,
|
||||||
|
}: {
|
||||||
|
index: number;
|
||||||
|
round: MultiRoundRagRound;
|
||||||
|
isLoading: boolean;
|
||||||
|
rehypePlugins: MarkdownContentProps['rehypePlugins'];
|
||||||
|
components: MarkdownContentProps['components'];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="border-border/60 rounded-lg border bg-background/70 p-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs">
|
||||||
|
{index}
|
||||||
|
</span>
|
||||||
|
<span>{round.title}</span>
|
||||||
|
{round.isSearching && <Loader2Icon className="text-primary size-3.5 animate-spin" />}
|
||||||
|
</div>
|
||||||
|
{round.thought && (
|
||||||
|
<section className="border-border/50 bg-muted/30 mt-3 rounded-md border p-3">
|
||||||
|
<div className="text-muted-foreground mb-2 text-xs font-medium">思考内容</div>
|
||||||
|
<MarkdownContent
|
||||||
|
content={round.thought}
|
||||||
|
isLoading={isLoading}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
components={components}
|
||||||
|
className="text-muted-foreground text-sm"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{round.body && (
|
||||||
|
<section className="border-border/50 mt-3 rounded-md border p-3">
|
||||||
|
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
|
||||||
|
<MarkdownContent
|
||||||
|
content={round.body}
|
||||||
|
isLoading={isLoading}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
components={components}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{round.query && (
|
||||||
|
<div className="border-primary/20 bg-primary/5 text-primary mt-2 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs">
|
||||||
|
<SearchIcon className="size-3 shrink-0" />
|
||||||
|
<span className="truncate">{round.query}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{round.retrievals.length > 0 && (
|
||||||
|
<section className="border-border/50 mt-3 rounded-md border p-3">
|
||||||
|
<div className="text-muted-foreground mb-2 text-xs font-medium">
|
||||||
|
检索内容 · {round.retrievals.length} 条
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{round.retrievals.map((item, resultIndex) => (
|
||||||
|
<details
|
||||||
|
key={`${item.id ?? item.title}-${resultIndex}`}
|
||||||
|
className="border-border/60 rounded-md border px-3 py-2"
|
||||||
|
open={resultIndex === 0}
|
||||||
|
>
|
||||||
|
<summary className="cursor-pointer list-none text-sm font-medium">
|
||||||
|
<span className="mr-2 text-xs text-muted-foreground">{resultIndex + 1}.</span>
|
||||||
|
{item.title}
|
||||||
|
</summary>
|
||||||
|
<div className="text-muted-foreground mt-2 text-sm leading-6 whitespace-pre-wrap">
|
||||||
|
{item.content || '暂无资料摘要'}
|
||||||
|
</div>
|
||||||
|
{(item.source || item.time) && (
|
||||||
|
<div className="text-muted-foreground mt-2 text-xs">
|
||||||
|
{[item.source, item.time].filter(Boolean).join(' · ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSingleRoundRagSourceUrl(
|
||||||
|
source: RuntimeDisplaySource | null,
|
||||||
|
): string | undefined {
|
||||||
|
if (!source) return undefined;
|
||||||
|
if (source.url && /^https?:\/\//i.test(source.url)) return source.url;
|
||||||
|
|
||||||
|
const raw = source.raw ?? {};
|
||||||
|
const recordId =
|
||||||
|
source.id ??
|
||||||
|
readString(raw.recUuid) ??
|
||||||
|
readString(raw.rec_uuid) ??
|
||||||
|
readString(raw.uuid);
|
||||||
|
const knowledgeUrl = readRuntimeEnv("VITE_AIPI_KNOWLEDGE_URL")?.replace(/\/$/, "");
|
||||||
|
if (!knowledgeUrl || !recordId) return undefined;
|
||||||
|
return `${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${encodeURIComponent(recordId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown): string | undefined {
|
||||||
|
if (typeof value === "string" && value.trim()) return value;
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function EmbeddedPageContextCard({
|
function EmbeddedPageContextCard({
|
||||||
pageContext,
|
pageContext,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
|
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
|
||||||
|
import { useDocumentRewritePresentation } from "@/core/document-rewrite/context";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
extractContentFromMessage,
|
extractContentFromMessage,
|
||||||
@@ -30,10 +31,13 @@ import { useSkills } from "@/core/skills/hooks";
|
|||||||
import {
|
import {
|
||||||
buildSkillDisplayMap,
|
buildSkillDisplayMap,
|
||||||
collectRuntimeDisplaySources,
|
collectRuntimeDisplaySources,
|
||||||
|
type RuntimeDisplaySource,
|
||||||
} from "@/core/skill-display/runtime";
|
} from "@/core/skill-display/runtime";
|
||||||
import type { Subtask } from "@/core/tasks";
|
import type { Subtask } from "@/core/tasks";
|
||||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||||
import type { AgentThreadState } from "@/core/threads";
|
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 { cn } from "@/lib/utils";
|
||||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||||
|
|
||||||
@@ -43,6 +47,7 @@ import { StreamingIndicator } from "../streaming-indicator";
|
|||||||
|
|
||||||
import { ReferenceBatchesContext, ThreadContext } from "./context";
|
import { ReferenceBatchesContext, ThreadContext } from "./context";
|
||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent } from "./markdown-content";
|
||||||
|
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
|
||||||
import { MessageGroup } from "./message-group";
|
import { MessageGroup } from "./message-group";
|
||||||
import { MessageListItem } from "./message-list-item";
|
import { MessageListItem } from "./message-list-item";
|
||||||
import {
|
import {
|
||||||
@@ -107,7 +112,9 @@ export function MessageList({
|
|||||||
onSetupWritingApprove,
|
onSetupWritingApprove,
|
||||||
onSetupWritingReject,
|
onSetupWritingReject,
|
||||||
onSetupWritingStart,
|
onSetupWritingStart,
|
||||||
|
researchApprovalSlot,
|
||||||
onOpenArtifact,
|
onOpenArtifact,
|
||||||
|
onDownloadArtifact,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
contentClassName?: string;
|
contentClassName?: string;
|
||||||
@@ -130,7 +137,16 @@ export function MessageList({
|
|||||||
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||||
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
|
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
|
||||||
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||||
|
/**
|
||||||
|
* 深度研究的交互卡插槽(报告配置、计划确认、失败重试):workbench 向
|
||||||
|
* displayThread 注入带 `additional_kwargs.setup_research_approval` 的虚拟 ai
|
||||||
|
* 消息。撰写步骤和完成摘要不走此插槽,而是分别复用原生 tool-call 步骤条和
|
||||||
|
* assistant 消息行;不传该 slot 时普通对话/AI 写作零行为变化。
|
||||||
|
*/
|
||||||
|
researchApprovalSlot?: (message: Message) => React.ReactNode;
|
||||||
onOpenArtifact?: (filepath: string) => 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 baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||||
const rehypePlugins = useMemo(
|
const rehypePlugins = useMemo(
|
||||||
@@ -138,6 +154,7 @@ export function MessageList({
|
|||||||
[baseRehypePlugins, extraRehypePlugins],
|
[baseRehypePlugins, extraRehypePlugins],
|
||||||
);
|
);
|
||||||
const updateSubtask = useUpdateSubtask();
|
const updateSubtask = useUpdateSubtask();
|
||||||
|
const documentRewritePresentation = useDocumentRewritePresentation();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const threadContext = useContext(ThreadContext);
|
const threadContext = useContext(ThreadContext);
|
||||||
// Read-only contexts (public share, mock previews) must not offer deletion.
|
// Read-only contexts (public share, mock previews) must not offer deletion.
|
||||||
@@ -190,6 +207,7 @@ export function MessageList({
|
|||||||
},
|
},
|
||||||
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
||||||
);
|
);
|
||||||
|
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
|
||||||
|
|
||||||
const askTimes = useChatAskTimes();
|
const askTimes = useChatAskTimes();
|
||||||
|
|
||||||
@@ -237,10 +255,19 @@ export function MessageList({
|
|||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
const ragModeEnabled =
|
const ragModeEnabled =
|
||||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||||
|
const hasSingleRoundRag = useMemo(
|
||||||
|
() =>
|
||||||
|
messages.some(
|
||||||
|
(message) =>
|
||||||
|
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
|
||||||
|
),
|
||||||
|
[messages],
|
||||||
|
);
|
||||||
|
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
|
||||||
const { skills } = useSkills();
|
const { skills } = useSkills();
|
||||||
const skillDisplayMap = useMemo(
|
const skillDisplayMap = useMemo(
|
||||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||||
[ragModeEnabled, skills],
|
[referencesEnabled, skills],
|
||||||
);
|
);
|
||||||
// Same per-turn reference batches the right-side panel renders (backend
|
// Same per-turn reference batches the right-side panel renders (backend
|
||||||
// batches preferred). Inline [n] tokens must resolve against these so badge
|
// batches preferred). Inline [n] tokens must resolve against these so badge
|
||||||
@@ -248,6 +275,53 @@ export function MessageList({
|
|||||||
// frontend tool-message scan below comes up empty (e.g. after a reload).
|
// frontend tool-message scan below comes up empty (e.g. after a reload).
|
||||||
const referenceBatches = useContext(ReferenceBatchesContext);
|
const referenceBatches = useContext(ReferenceBatchesContext);
|
||||||
|
|
||||||
|
// 单轮/多轮 RAG 的参考资料存放在一条 hide_from_ui 的 tool 消息里(紧挨在
|
||||||
|
// 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.
|
// Track which clarification messages are still awaiting an answer.
|
||||||
// Pending = clarification tool messages that appear after the last human message
|
// Pending = clarification tool messages that appear after the last human message
|
||||||
// (no user reply has been sent for them yet).
|
// (no user reply has been sent for them yet).
|
||||||
@@ -372,8 +446,14 @@ export function MessageList({
|
|||||||
{groupMessages(messages, (group) => {
|
{groupMessages(messages, (group) => {
|
||||||
if (group.type === "human" || group.type === "assistant") {
|
if (group.type === "human" || group.type === "assistant") {
|
||||||
return group.messages.map((msg) => {
|
return group.messages.map((msg) => {
|
||||||
|
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
|
||||||
|
// 便于 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 =
|
const isAssistantMessage =
|
||||||
ragModeEnabled &&
|
referencesEnabled &&
|
||||||
group.type === "assistant" &&
|
group.type === "assistant" &&
|
||||||
msg.type === "ai";
|
msg.type === "ai";
|
||||||
const batchSources = isAssistantMessage
|
const batchSources = isAssistantMessage
|
||||||
@@ -381,9 +461,16 @@ export function MessageList({
|
|||||||
(batch) => batch.assistant_message_id === msg.id,
|
(batch) => batch.assistant_message_id === msg.id,
|
||||||
)?.sources ?? []
|
)?.sources ?? []
|
||||||
: [];
|
: [];
|
||||||
|
// 单轮/多轮 RAG 的参考资料来自隐藏的 tool 消息,优先于通用扫描。
|
||||||
|
const frontendRagSources =
|
||||||
|
isAssistantMessage && msg.id
|
||||||
|
? frontendRagSourcesByAiId.get(msg.id) ?? []
|
||||||
|
: [];
|
||||||
const displaySources = isAssistantMessage
|
const displaySources = isAssistantMessage
|
||||||
? batchSources.length > 0
|
? batchSources.length > 0
|
||||||
? batchSources
|
? batchSources
|
||||||
|
: frontendRagSources.length > 0
|
||||||
|
? frontendRagSources
|
||||||
: collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
: collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||||
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
|
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
|
||||||
beforeMessageId: msg.id,
|
beforeMessageId: msg.id,
|
||||||
@@ -400,7 +487,7 @@ export function MessageList({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
message={msg}
|
message={msg}
|
||||||
isLoading={thread.isLoading}
|
isLoading={messageIsLoading}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
tokenUsageEnabled={tokenUsageEnabled}
|
tokenUsageEnabled={tokenUsageEnabled}
|
||||||
displaySources={displaySources}
|
displaySources={displaySources}
|
||||||
@@ -495,6 +582,15 @@ export function MessageList({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : 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") {
|
} else if (group.type === "assistant:present-files") {
|
||||||
const files: string[] = [];
|
const files: string[] = [];
|
||||||
for (const message of group.messages) {
|
for (const message of group.messages) {
|
||||||
@@ -517,6 +613,7 @@ export function MessageList({
|
|||||||
files={files}
|
files={files}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
onOpenArtifact={onOpenArtifact}
|
onOpenArtifact={onOpenArtifact}
|
||||||
|
onDownloadArtifact={onDownloadArtifact}
|
||||||
/>
|
/>
|
||||||
<MessageTokenUsageList
|
<MessageTokenUsageList
|
||||||
enabled={tokenUsageEnabled}
|
enabled={tokenUsageEnabled}
|
||||||
@@ -642,6 +739,9 @@ export function MessageList({
|
|||||||
</div>
|
</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` }} />
|
<div style={{ height: `${paddingBottom}px` }} />
|
||||||
</MessageMetricsProvider>
|
</MessageMetricsProvider>
|
||||||
@@ -717,6 +817,46 @@ function findPreviousHumanMessageId(
|
|||||||
return undefined;
|
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(
|
function getSetupAgentApprovalPayload(
|
||||||
message: unknown,
|
message: unknown,
|
||||||
): SetupAgentApprovalPayload | null {
|
): SetupAgentApprovalPayload | null {
|
||||||
|
|||||||
@@ -50,16 +50,19 @@ export function ModeHoverGuide({
|
|||||||
mode,
|
mode,
|
||||||
children,
|
children,
|
||||||
showTitle = true,
|
showTitle = true,
|
||||||
|
tooltipContent,
|
||||||
}: {
|
}: {
|
||||||
mode: AgentMode;
|
mode: AgentMode;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/** When true, tooltip shows "ModeName: Description". When false, only description. */
|
/** When true, tooltip shows "ModeName: Description". When false, only description. */
|
||||||
showTitle?: boolean;
|
showTitle?: boolean;
|
||||||
|
/** Overrides the standard mode tooltip for page-specific execution modes. */
|
||||||
|
tooltipContent?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const label = t.inputBox[getModeLabelKey(mode)];
|
const label = t.inputBox[getModeLabelKey(mode)];
|
||||||
const description = t.inputBox[getModeDescriptionKey(mode)];
|
const description = t.inputBox[getModeDescriptionKey(mode)];
|
||||||
const content = showTitle ? `${label}: ${description}` : description;
|
const content = tooltipContent ?? (showTitle ? `${label}: ${description}` : description);
|
||||||
|
|
||||||
return <Tooltip content={content}>{children}</Tooltip>;
|
return <Tooltip content={content}>{children}</Tooltip>;
|
||||||
}
|
}
|
||||||
@@ -72,6 +72,7 @@ import { getStoredAuth } from "@/core/auth";
|
|||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||||
import {
|
import {
|
||||||
|
useDeleteBuiltinSkill,
|
||||||
useDeleteCustomSkill,
|
useDeleteCustomSkill,
|
||||||
useDuplicateSkill,
|
useDuplicateSkill,
|
||||||
useGenerateMissingSkillNameZh,
|
useGenerateMissingSkillNameZh,
|
||||||
@@ -618,7 +619,8 @@ export function SkillRow({
|
|||||||
const setFeatured = useSetSkillFeatured();
|
const setFeatured = useSetSkillFeatured();
|
||||||
const setAlwaysOn = useSetSkillAlwaysOn();
|
const setAlwaysOn = useSetSkillAlwaysOn();
|
||||||
const { data: compression } = useSkillCompressionSettings();
|
const { data: compression } = useSkillCompressionSettings();
|
||||||
const deleteSkill = useDeleteCustomSkill();
|
const deleteCustom = useDeleteCustomSkill();
|
||||||
|
const deleteBuiltin = useDeleteBuiltinSkill();
|
||||||
const duplicateSkillMutation = useDuplicateSkill();
|
const duplicateSkillMutation = useDuplicateSkill();
|
||||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
const [tagPickerOpen, setTagPickerOpen] = useState(false);
|
const [tagPickerOpen, setTagPickerOpen] = useState(false);
|
||||||
@@ -634,8 +636,9 @@ export function SkillRow({
|
|||||||
const isOwned =
|
const isOwned =
|
||||||
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
||||||
const isFromOthers = isCustom && !isBuiltin && !isOwned;
|
const isFromOthers = isCustom && !isBuiltin && !isOwned;
|
||||||
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own.
|
// Admin can manage custom skills from any owner; non-admin only their own.
|
||||||
const canManage = isCustom && (isOwned || isAdmin);
|
const canManage = isCustom && (isOwned || isAdmin);
|
||||||
|
const canDelete = isAdmin || isOwned;
|
||||||
const canTogglePublish = canManage && !isBuiltin;
|
const canTogglePublish = canManage && !isBuiltin;
|
||||||
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
|
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
|
||||||
const canSetAlwaysOn = isAdmin || isOwned;
|
const canSetAlwaysOn = isAdmin || isOwned;
|
||||||
@@ -688,7 +691,11 @@ export function SkillRow({
|
|||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
try {
|
try {
|
||||||
await deleteSkill.mutateAsync(skill.name);
|
if (isBuiltin) {
|
||||||
|
await deleteBuiltin.mutateAsync(skill.name);
|
||||||
|
} else {
|
||||||
|
await deleteCustom.mutateAsync(skill.name);
|
||||||
|
}
|
||||||
toast.success(t.settings.skills.deleteSuccess);
|
toast.success(t.settings.skills.deleteSuccess);
|
||||||
setConfirmDeleteOpen(false);
|
setConfirmDeleteOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1011,7 +1018,7 @@ export function SkillRow({
|
|||||||
>
|
>
|
||||||
<CopyIcon className="size-3.5" />
|
<CopyIcon className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
{canManage && (
|
{canDelete && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1109,16 +1116,18 @@ export function SkillRow({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setConfirmDeleteOpen(false)}
|
onClick={() => setConfirmDeleteOpen(false)}
|
||||||
disabled={deleteSkill.isPending}
|
disabled={deleteCustom.isPending || deleteBuiltin.isPending}
|
||||||
>
|
>
|
||||||
{t.common.cancel}
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => void handleDelete()}
|
onClick={() => void handleDelete()}
|
||||||
disabled={deleteSkill.isPending}
|
disabled={deleteCustom.isPending || deleteBuiltin.isPending}
|
||||||
>
|
>
|
||||||
{deleteSkill.isPending ? t.common.loading : t.common.delete}
|
{deleteCustom.isPending || deleteBuiltin.isPending
|
||||||
|
? t.common.loading
|
||||||
|
: t.common.delete}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function SourceCard({
|
|||||||
knowledge_base_id: previewTarget.knowledgeBaseId,
|
knowledge_base_id: previewTarget.knowledgeBaseId,
|
||||||
chunk_id: previewTarget.chunkId,
|
chunk_id: previewTarget.chunkId,
|
||||||
});
|
});
|
||||||
void apiFetch(`${getBackendBaseURL()}/api/weknora/source-context?${params}`)
|
void apiFetch(`${getBackendBaseURL()}/api/llmwiki/sources/context?${params}`)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
const payload = (await response.json().catch(() => null)) as unknown;
|
const payload = (await response.json().catch(() => null)) as unknown;
|
||||||
if (!response.ok || !isWeKnoraSourceContextResponse(payload)) {
|
if (!response.ok || !isWeKnoraSourceContextResponse(payload)) {
|
||||||
@@ -240,7 +240,18 @@ type WeKnoraSourceContext = {
|
|||||||
knowledge_base_name?: string;
|
knowledge_base_name?: string;
|
||||||
document_title?: string;
|
document_title?: string;
|
||||||
document_file_name?: string;
|
document_file_name?: string;
|
||||||
|
display_mode?: "wiki" | "chunks";
|
||||||
focus_chunk_id: string;
|
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<{
|
chunks: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
chunk_index?: number;
|
chunk_index?: number;
|
||||||
@@ -312,6 +323,33 @@ function WeKnoraSourceContextPanel({
|
|||||||
if (!context) {
|
if (!context) {
|
||||||
return null;
|
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 (
|
return (
|
||||||
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1">
|
<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">
|
<div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
|
Trash2Icon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
@@ -22,6 +23,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||||
import { getSkillFileDownloadURL } from "@/core/skills/api";
|
import { getSkillFileDownloadURL } from "@/core/skills/api";
|
||||||
import {
|
import {
|
||||||
|
useDeleteSkillFile,
|
||||||
useSaveSkillFile,
|
useSaveSkillFile,
|
||||||
useSkillFileContent,
|
useSkillFileContent,
|
||||||
} from "@/core/skills/hooks";
|
} from "@/core/skills/hooks";
|
||||||
@@ -66,6 +68,7 @@ export function SkillFilePreviewDialog({
|
|||||||
open ? path : null,
|
open ? path : null,
|
||||||
);
|
);
|
||||||
const save = useSaveSkillFile();
|
const save = useSaveSkillFile();
|
||||||
|
const remove = useDeleteSkillFile();
|
||||||
|
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
@@ -77,6 +80,8 @@ export function SkillFilePreviewDialog({
|
|||||||
}, [path, open]);
|
}, [path, open]);
|
||||||
|
|
||||||
const fileEditable = !!data?.editable && treeEditable;
|
const fileEditable = !!data?.editable && treeEditable;
|
||||||
|
const isRootSkillFile = path?.replace(/\\/g, "/").toLowerCase() === "skill.md";
|
||||||
|
const fileDeletable = !!data && treeEditable && !isRootSkillFile;
|
||||||
const isMarkdown = useMemo(() => {
|
const isMarkdown = useMemo(() => {
|
||||||
if (!path) return false;
|
if (!path) return false;
|
||||||
const lower = path.toLowerCase();
|
const lower = path.toLowerCase();
|
||||||
@@ -115,6 +120,18 @@ 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() {
|
async function handleCopyPath() {
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
await navigator.clipboard.writeText(path);
|
await navigator.clipboard.writeText(path);
|
||||||
@@ -158,6 +175,18 @@ export function SkillFilePreviewDialog({
|
|||||||
{tx.filesEditAction}
|
{tx.filesEditAction}
|
||||||
</Button>
|
</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 && (
|
{editing && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||||
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
|
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||||
|
|
||||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||||
import { SidebarCollapseButton } from "./workspace-header";
|
import { SidebarCollapseButton } from "./workspace-header";
|
||||||
@@ -172,6 +172,18 @@ export function WorkspaceNavChatList({
|
|||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</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>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={pathname.startsWith("/page/workspace/roundtable/planning")}
|
isActive={pathname.startsWith("/page/workspace/roundtable/planning")}
|
||||||
@@ -184,7 +196,7 @@ export function WorkspaceNavChatList({
|
|||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem>
|
{/* <SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
|
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
|
||||||
asChild
|
asChild
|
||||||
@@ -195,8 +207,7 @@ export function WorkspaceNavChatList({
|
|||||||
<span>岗位会商</span>
|
<span>岗位会商</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem> */}
|
||||||
{/* [MAF-R0] 实验原型已从正式菜单隐藏,仅保留路由供开发诊断:/page/maf */}
|
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export interface Agent {
|
|||||||
* (名称 + SKILL.md 路径 + 必须先 read_file 的硬指令) 拼进本轮任务以补偿弱模型。
|
* (名称 + SKILL.md 路径 + 必须先 read_file 的硬指令) 拼进本轮任务以补偿弱模型。
|
||||||
* 与业务链条同名开关取 OR。null/false = 关 (默认)。 */
|
* 与业务链条同名开关取 OR。null/false = 关 (默认)。 */
|
||||||
seat_skill_directive?: boolean | null;
|
seat_skill_directive?: boolean | null;
|
||||||
|
/** DeerFlow LLMWiki mapping ids this agent allows conversations to select. */
|
||||||
|
llmwiki_knowledge_base_ids?: string[] | null;
|
||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
featured_order?: number | null;
|
featured_order?: number | null;
|
||||||
/** Whether the current user has favorited (收藏) this agent. */
|
/** Whether the current user has favorited (收藏) this agent. */
|
||||||
@@ -97,6 +99,7 @@ export interface CreateAgentRequest {
|
|||||||
template_name?: string | null;
|
template_name?: string | null;
|
||||||
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
|
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
|
||||||
seat_skill_directive?: boolean | null;
|
seat_skill_directive?: boolean | null;
|
||||||
|
llmwiki_knowledge_base_ids?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateAgentRequest {
|
export interface UpdateAgentRequest {
|
||||||
@@ -114,4 +117,5 @@ export interface UpdateAgentRequest {
|
|||||||
template_name?: string | null;
|
template_name?: string | null;
|
||||||
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
|
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
|
||||||
seat_skill_directive?: boolean | null;
|
seat_skill_directive?: boolean | null;
|
||||||
|
llmwiki_knowledge_base_ids?: string[] | null;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
@@ -21,9 +21,23 @@ function getBaseOrigin() {
|
|||||||
return "http://localhost:2026";
|
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() {
|
export function getBackendBaseURL() {
|
||||||
if (env.NEXT_PUBLIC_BACKEND_BASE_URL) {
|
if (env.NEXT_PUBLIC_BACKEND_BASE_URL) {
|
||||||
return new URL(env.NEXT_PUBLIC_BACKEND_BASE_URL, getBaseOrigin())
|
return alignLoopbackHostname(
|
||||||
|
new URL(env.NEXT_PUBLIC_BACKEND_BASE_URL, getBaseOrigin()),
|
||||||
|
)
|
||||||
.toString()
|
.toString()
|
||||||
.replace(/\/+$/, "");
|
.replace(/\/+$/, "");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"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";
|
||||||
@@ -548,7 +548,10 @@ export const enUS: Translations = {
|
|||||||
filesCopyPathAction: "Copy path",
|
filesCopyPathAction: "Copy path",
|
||||||
filesDiscardConfirm: "Discard unsaved changes?",
|
filesDiscardConfirm: "Discard unsaved changes?",
|
||||||
filesSaveSuccess: "File saved",
|
filesSaveSuccess: "File saved",
|
||||||
filesReadOnlyHint: "Built-in skills are read-only.",
|
filesDeleteAction: "Delete",
|
||||||
|
filesDeleteConfirm: "Delete \"{path}\"? This cannot be undone.",
|
||||||
|
filesDeleteSuccess: "File deleted",
|
||||||
|
filesReadOnlyHint: "Only administrators can edit or delete built-in skill files.",
|
||||||
viewDetailAction: "View skill details",
|
viewDetailAction: "View skill details",
|
||||||
stateSheet: {
|
stateSheet: {
|
||||||
archivedTitle: "Archived skills",
|
archivedTitle: "Archived skills",
|
||||||
|
|||||||
@@ -478,6 +478,9 @@ export interface Translations {
|
|||||||
filesCopyPathAction: string;
|
filesCopyPathAction: string;
|
||||||
filesDiscardConfirm: string;
|
filesDiscardConfirm: string;
|
||||||
filesSaveSuccess: string;
|
filesSaveSuccess: string;
|
||||||
|
filesDeleteAction: string;
|
||||||
|
filesDeleteConfirm: string;
|
||||||
|
filesDeleteSuccess: string;
|
||||||
filesReadOnlyHint: string;
|
filesReadOnlyHint: string;
|
||||||
viewDetailAction: string;
|
viewDetailAction: string;
|
||||||
stateSheet: {
|
stateSheet: {
|
||||||
|
|||||||
@@ -525,7 +525,10 @@ export const zhCN: Translations = {
|
|||||||
filesCopyPathAction: "复制路径",
|
filesCopyPathAction: "复制路径",
|
||||||
filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
|
filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
|
||||||
filesSaveSuccess: "文件已保存",
|
filesSaveSuccess: "文件已保存",
|
||||||
filesReadOnlyHint: "内置技能为只读,仅可查看。",
|
filesDeleteAction: "删除",
|
||||||
|
filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。",
|
||||||
|
filesDeleteSuccess: "文件已删除",
|
||||||
|
filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。",
|
||||||
viewDetailAction: "查看技能详情",
|
viewDetailAction: "查看技能详情",
|
||||||
stateSheet: {
|
stateSheet: {
|
||||||
archivedTitle: "归档的技能",
|
archivedTitle: "归档的技能",
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistan
|
|||||||
|
|
||||||
interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {}
|
interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {}
|
||||||
|
|
||||||
|
interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant:research-approval"> {}
|
||||||
|
|
||||||
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
||||||
|
|
||||||
type MessageGroup =
|
type MessageGroup =
|
||||||
@@ -32,6 +34,7 @@ type MessageGroup =
|
|||||||
| AssistantClarificationGroup
|
| AssistantClarificationGroup
|
||||||
| AssistantSetupAgentApprovalGroup
|
| AssistantSetupAgentApprovalGroup
|
||||||
| AssistantSetupWritingApprovalGroup
|
| AssistantSetupWritingApprovalGroup
|
||||||
|
| AssistantResearchApprovalGroup
|
||||||
| AssistantSubagentGroup;
|
| AssistantSubagentGroup;
|
||||||
|
|
||||||
export function groupMessages<T>(
|
export function groupMessages<T>(
|
||||||
@@ -54,7 +57,8 @@ export function groupMessages<T>(
|
|||||||
last.type !== "assistant" &&
|
last.type !== "assistant" &&
|
||||||
last.type !== "assistant:clarification" &&
|
last.type !== "assistant:clarification" &&
|
||||||
last.type !== "assistant:setup-agent-approval" &&
|
last.type !== "assistant:setup-agent-approval" &&
|
||||||
last.type !== "assistant:setup-writing-approval"
|
last.type !== "assistant:setup-writing-approval" &&
|
||||||
|
last.type !== "assistant:research-approval"
|
||||||
) {
|
) {
|
||||||
return last;
|
return last;
|
||||||
}
|
}
|
||||||
@@ -114,6 +118,18 @@ export function groupMessages<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === "ai") {
|
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)) {
|
if (hasPresentFiles(message)) {
|
||||||
groups.push({
|
groups.push({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
@@ -386,6 +402,14 @@ 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) {
|
export function extractPresentFilesFromMessage(message: Message) {
|
||||||
if (message.type !== "ai" || !hasPresentFiles(message)) {
|
if (message.type !== "ai" || !hasPresentFiles(message)) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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"] }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./api";
|
||||||
|
export * from "./hooks";
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ export interface Model {
|
|||||||
description?: string | null;
|
description?: string | null;
|
||||||
supports_thinking?: boolean;
|
supports_thinking?: boolean;
|
||||||
supports_reasoning_effort?: boolean;
|
supports_reasoning_effort?: boolean;
|
||||||
|
/** Execution profiles handled by the frontend instead of DeerFlow. */
|
||||||
|
execution_profile?: "single_round_rag" | "multi_round_rag";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenUsageSettings {
|
export interface TokenUsageSettings {
|
||||||
|
|||||||
@@ -30,12 +30,12 @@ import {
|
|||||||
List,
|
List,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
MessageSquareText,
|
|
||||||
Network,
|
Network,
|
||||||
Radar,
|
Radar,
|
||||||
Settings,
|
Settings,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Target,
|
Target,
|
||||||
|
Telescope,
|
||||||
Trophy,
|
Trophy,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -93,6 +93,12 @@ export const sidebarMenuItems: MenuItem[] = [
|
|||||||
icon: FileText,
|
icon: FileText,
|
||||||
path: "/page/canvas/ai-writing-chat",
|
path: "/page/canvas/ai-writing-chat",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "qa-deep-research",
|
||||||
|
label: "深度研究",
|
||||||
|
icon: Telescope,
|
||||||
|
path: "/page/strategy/qa/deep-research",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* 舆情分析智能体的展示信息(名称 / 简介)。
|
||||||
|
* 这是一个前端虚拟智能体(不落库后端 /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"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
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,6 +71,12 @@ test("keeps half-width syntax working", () => {
|
|||||||
assert.equal(out.includes(citation(3)), true);
|
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", () => {
|
test("accepts spaced and full-width square brackets", () => {
|
||||||
const out = applyInlineCitations("参考[ 1 ]和[2]。", makeSources(2));
|
const out = applyInlineCitations("参考[ 1 ]和[2]。", makeSources(2));
|
||||||
assert.equal(out.includes(citation(1)), true);
|
assert.equal(out.includes(citation(1)), true);
|
||||||
|
|||||||
@@ -412,6 +412,17 @@ export async function deleteCustomSkill(skillName: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
export interface SkillFileNode {
|
||||||
name: string;
|
name: string;
|
||||||
type: "file" | "dir";
|
type: "file" | "dir";
|
||||||
@@ -498,6 +509,19 @@ export async function saveSkillFile(
|
|||||||
return response.json() as Promise<SkillFileSaveResponse>;
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface SkillNameZhResponse {
|
export interface SkillNameZhResponse {
|
||||||
name: string;
|
name: string;
|
||||||
name_zh: string | null;
|
name_zh: string | null;
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
deleteBuiltinSkill,
|
||||||
deleteCustomSkill,
|
deleteCustomSkill,
|
||||||
|
deleteSkillFile,
|
||||||
duplicateSkill,
|
duplicateSkill,
|
||||||
enableSkill,
|
enableSkill,
|
||||||
generateMissingSkillNameZh,
|
generateMissingSkillNameZh,
|
||||||
@@ -341,3 +343,31 @@ export function useSaveSkillFile() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -28,8 +28,11 @@ import type { AgentThread, AgentThreadState } from "./types";
|
|||||||
export type ToolEndEvent = {
|
export type ToolEndEvent = {
|
||||||
name: string;
|
name: string;
|
||||||
data: unknown;
|
data: unknown;
|
||||||
|
toolCallId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ToolStartEvent = ToolEndEvent;
|
||||||
|
|
||||||
export type ThreadStreamOptions = {
|
export type ThreadStreamOptions = {
|
||||||
threadId?: string | null | undefined;
|
threadId?: string | null | undefined;
|
||||||
context: LocalSettings["context"];
|
context: LocalSettings["context"];
|
||||||
@@ -49,6 +52,7 @@ export type ThreadStreamOptions = {
|
|||||||
threadMetadata?: Record<string, unknown>;
|
threadMetadata?: Record<string, unknown>;
|
||||||
onStart?: (threadId: string) => void;
|
onStart?: (threadId: string) => void;
|
||||||
onFinish?: (state: AgentThreadState) => void;
|
onFinish?: (state: AgentThreadState) => void;
|
||||||
|
onToolStart?: (event: ToolStartEvent) => void;
|
||||||
onToolEnd?: (event: ToolEndEvent) => void;
|
onToolEnd?: (event: ToolEndEvent) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -240,6 +244,11 @@ const TERMINAL_RUN_STATUSES = new Set([
|
|||||||
// for a while, then reconcile it from the authoritative run/state endpoints.
|
// for a while, then reconcile it from the authoritative run/state endpoints.
|
||||||
const STREAM_RECONCILIATION_IDLE_MS = 12_000;
|
const STREAM_RECONCILIATION_IDLE_MS = 12_000;
|
||||||
const STREAM_RECONCILIATION_POLL_MS = 5_000;
|
const STREAM_RECONCILIATION_POLL_MS = 5_000;
|
||||||
|
// A run can legitimately be quiet while a long tool call is in progress, so
|
||||||
|
// 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(
|
async function fetchThreadTitleRefreshState(
|
||||||
threadId: string,
|
threadId: string,
|
||||||
@@ -449,6 +458,7 @@ export function useThreadStream({
|
|||||||
threadMetadata,
|
threadMetadata,
|
||||||
onStart,
|
onStart,
|
||||||
onFinish,
|
onFinish,
|
||||||
|
onToolStart,
|
||||||
onToolEnd,
|
onToolEnd,
|
||||||
}: ThreadStreamOptions) {
|
}: ThreadStreamOptions) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -461,21 +471,28 @@ export function useThreadStream({
|
|||||||
const startedRef = useRef(false);
|
const startedRef = useRef(false);
|
||||||
const lastStreamActivityAtRef = useRef(0);
|
const lastStreamActivityAtRef = useRef(0);
|
||||||
const streamReconciliationInFlightRef = useRef(false);
|
const streamReconciliationInFlightRef = useRef(false);
|
||||||
|
const streamReconnectAttemptsRef = useRef(0);
|
||||||
|
const streamReconnectToastShownRef = useRef(false);
|
||||||
|
const streamReconnectFailureToastShownRef = useRef(false);
|
||||||
|
|
||||||
const markStreamActivity = useCallback(() => {
|
const markStreamActivity = useCallback(() => {
|
||||||
lastStreamActivityAtRef.current = Date.now();
|
lastStreamActivityAtRef.current = Date.now();
|
||||||
|
streamReconnectAttemptsRef.current = 0;
|
||||||
|
streamReconnectToastShownRef.current = false;
|
||||||
|
streamReconnectFailureToastShownRef.current = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const listeners = useRef({
|
const listeners = useRef({
|
||||||
onStart,
|
onStart,
|
||||||
onFinish,
|
onFinish,
|
||||||
|
onToolStart,
|
||||||
onToolEnd,
|
onToolEnd,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep listeners ref updated with latest callbacks
|
// Keep listeners ref updated with latest callbacks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listeners.current = { onStart, onFinish, onToolEnd };
|
listeners.current = { onStart, onFinish, onToolStart, onToolEnd };
|
||||||
}, [onStart, onFinish, onToolEnd]);
|
}, [onStart, onFinish, onToolStart, onToolEnd]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const normalizedThreadId = threadId ?? null;
|
const normalizedThreadId = threadId ?? null;
|
||||||
@@ -545,12 +562,6 @@ export function useThreadStream({
|
|||||||
},
|
},
|
||||||
onLangChainEvent(event) {
|
onLangChainEvent(event) {
|
||||||
markStreamActivity();
|
markStreamActivity();
|
||||||
if (event.event === "on_tool_end") {
|
|
||||||
listeners.current.onToolEnd?.({
|
|
||||||
name: event.name,
|
|
||||||
data: event.data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 操作日志(Y-Log):每一次大模型调用结束都各上报一条(一次 LLM 调用 = 一条),
|
// 操作日志(Y-Log):每一次大模型调用结束都各上报一条(一次 LLM 调用 = 一条),
|
||||||
// 让问答日志尽量「多、全」——一个问答轮次里 lead agent / 子代理的每次模型调用
|
// 让问答日志尽量「多、全」——一个问答轮次里 lead agent / 子代理的每次模型调用
|
||||||
// 都会落一条。门控与失败静默都在 writeYLog 内部处理,绝不影响问答。
|
// 都会落一条。门控与失败静默都在 writeYLog 内部处理,绝不影响问答。
|
||||||
@@ -574,6 +585,30 @@ export function useThreadStream({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// 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) {
|
onUpdateEvent(data) {
|
||||||
markStreamActivity();
|
markStreamActivity();
|
||||||
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
|
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
|
||||||
@@ -737,13 +772,6 @@ export function useThreadStream({
|
|||||||
threadStreamRef.current = thread;
|
threadStreamRef.current = thread;
|
||||||
|
|
||||||
useEffect(() => {
|
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;
|
const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
|
||||||
if (!activeThreadId || typeof window === "undefined") {
|
if (!activeThreadId || typeof window === "undefined") {
|
||||||
return;
|
return;
|
||||||
@@ -752,7 +780,7 @@ export function useThreadStream({
|
|||||||
let disposed = false;
|
let disposed = false;
|
||||||
let refreshTimer: number | null = null;
|
let refreshTimer: number | null = null;
|
||||||
|
|
||||||
const reconcileCompletedRun = async () => {
|
const reconcileRun = async () => {
|
||||||
if (
|
if (
|
||||||
disposed ||
|
disposed ||
|
||||||
streamReconciliationInFlightRef.current ||
|
streamReconciliationInFlightRef.current ||
|
||||||
@@ -769,7 +797,51 @@ export function useThreadStream({
|
|||||||
try {
|
try {
|
||||||
const client = getAPIClient(isMock);
|
const client = getAPIClient(isMock);
|
||||||
const run = await client.runs.get(activeThreadId, runId);
|
const run = await client.runs.get(activeThreadId, runId);
|
||||||
if (disposed || !TERMINAL_RUN_STATUSES.has(run.status)) {
|
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.
|
||||||
|
});
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,13 +849,17 @@ export function useThreadStream({
|
|||||||
// receive any more useful events. Load the checkpoint directly before
|
// receive any more useful events. Load the checkpoint directly before
|
||||||
// resetting the SDK stream; this preserves the normal onFinish side
|
// resetting the SDK stream; this preserves the normal onFinish side
|
||||||
// effects (e.g. marking a position delivery complete) even when the
|
// effects (e.g. marking a position delivery complete) even when the
|
||||||
// browser missed the terminal event.
|
// browser missed the terminal event or already reported a transport
|
||||||
|
// error and dropped ``isLoading``.
|
||||||
const finalState = await client.threads.getState(activeThreadId);
|
const finalState = await client.threads.getState(activeThreadId);
|
||||||
if (disposed || !threadStreamRef.current.isLoading) {
|
if (disposed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
storage?.removeItem(`lg:stream:${activeThreadId}`);
|
storage?.removeItem(`lg:stream:${activeThreadId}`);
|
||||||
|
streamReconnectAttemptsRef.current = 0;
|
||||||
|
streamReconnectToastShownRef.current = false;
|
||||||
|
streamReconnectFailureToastShownRef.current = false;
|
||||||
if (run.status === "success") {
|
if (run.status === "success") {
|
||||||
listeners.current.onFinish?.(finalState.values as AgentThreadState);
|
listeners.current.onFinish?.(finalState.values as AgentThreadState);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||||
@@ -812,7 +888,7 @@ export function useThreadStream({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const interval = window.setInterval(
|
const interval = window.setInterval(
|
||||||
() => void reconcileCompletedRun(),
|
() => void reconcileRun(),
|
||||||
STREAM_RECONCILIATION_POLL_MS,
|
STREAM_RECONCILIATION_POLL_MS,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1122,8 +1198,19 @@ export function useThreadStream({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mergedContext = {
|
||||||
|
...context,
|
||||||
|
...extraContext,
|
||||||
|
};
|
||||||
|
const selectedLlmWikiIds = Array.isArray(
|
||||||
|
mergedContext.llmwiki_knowledge_base_ids,
|
||||||
|
)
|
||||||
|
? mergedContext.llmwiki_knowledge_base_ids
|
||||||
|
: [];
|
||||||
const ragModeEnabled =
|
const ragModeEnabled =
|
||||||
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
localSettings.display.ragModeEnabled ||
|
||||||
|
isThreadRagModeLocked(threadId) ||
|
||||||
|
selectedLlmWikiIds.length > 0;
|
||||||
if (ragModeEnabled) {
|
if (ragModeEnabled) {
|
||||||
lockThreadRagMode(threadId);
|
lockThreadRagMode(threadId);
|
||||||
}
|
}
|
||||||
@@ -1131,11 +1218,6 @@ export function useThreadStream({
|
|||||||
const contextCompressionEnabled =
|
const contextCompressionEnabled =
|
||||||
localSettings.display.contextCompressionEnabled ?? false;
|
localSettings.display.contextCompressionEnabled ?? false;
|
||||||
|
|
||||||
const mergedContext = {
|
|
||||||
...context,
|
|
||||||
...extraContext,
|
|
||||||
};
|
|
||||||
|
|
||||||
const hiddenContextMessages = normalizeHiddenContextMessages(options);
|
const hiddenContextMessages = normalizeHiddenContextMessages(options);
|
||||||
const submittedMessages: Message[] = [
|
const submittedMessages: Message[] = [
|
||||||
...hiddenContextMessages.map((item) => ({
|
...hiddenContextMessages.map((item) => ({
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
|||||||
summarization_enabled?: boolean;
|
summarization_enabled?: boolean;
|
||||||
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
|
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
|
||||||
knowledge_rag_enabled?: boolean;
|
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;
|
agent_id?: string;
|
||||||
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||||
agent_name?: string;
|
agent_name?: string;
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
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,70 +0,0 @@
|
|||||||
/**
|
|
||||||
* 区域级脱敏 Hook —— 把某个容器(DOM 子树)里的**所有文本节点**做脱敏,
|
|
||||||
* 用于「左/右侧面板 + 顶部标题」这类需要整片脱敏的区域:静态标题、动态内容、
|
|
||||||
* 嵌套子组件文本一并覆盖,无需逐串包 `desensitize()`(那样易漏、难维护)。
|
|
||||||
*
|
|
||||||
* 用法:`const ref = useDesensitizeSubtree<HTMLDivElement>(); <section ref={ref}>…</section>`。
|
|
||||||
*
|
|
||||||
* 设计要点:
|
|
||||||
* - 只读改文本节点(display-only),不碰 `<input>/<textarea>` 的 value、不改 React 状态/回传值;
|
|
||||||
* - `desensitize` 幂等(替换词不再命中词表),MutationObserver 重入安全;改写前先 disconnect 防自触发循环;
|
|
||||||
* - 想在区域内**保留原文**(如内嵌沙箱/消息列表),给该子树根加 `data-no-desensitize` 属性即可跳过。
|
|
||||||
*
|
|
||||||
* 注意:**不要**用它包「消息列表正文」或「沙箱产物预览」——这两处按需求保持原文,
|
|
||||||
* 应作为独立容器、不挂本 Hook(智能体名称的脱敏单独在 MessageBubble 的 senderName 处理)。
|
|
||||||
*
|
|
||||||
* 该 hook 最初位于 `roundtable-planning/lib/use-desensitize-subtree.ts`,因其属于通用脱敏基建
|
|
||||||
* (被 MAF 会商、业务链条等多模块共享)现抽到中性 `@/lib` 位置。旧路径保留 re-export 以零改动兼容。
|
|
||||||
*/
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
import { desensitize } from "@/lib/desensitize";
|
|
||||||
|
|
||||||
const _SKIP_TAGS = new Set(["SCRIPT", "STYLE", "TEXTAREA", "INPUT", "IFRAME", "CODE", "PRE"]);
|
|
||||||
|
|
||||||
export function useDesensitizeSubtree<T extends HTMLElement>() {
|
|
||||||
const ref = useRef<T | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const root = ref.current;
|
|
||||||
if (!root || typeof MutationObserver === "undefined") return;
|
|
||||||
|
|
||||||
let observer: MutationObserver | null = null;
|
|
||||||
|
|
||||||
const apply = () => {
|
|
||||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
||||||
acceptNode(node) {
|
|
||||||
const parent = node.parentElement;
|
|
||||||
if (!parent) return NodeFilter.FILTER_REJECT;
|
|
||||||
if (_SKIP_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT;
|
|
||||||
// 显式标记 data-no-desensitize 的子树保留原文(如内嵌沙箱)。
|
|
||||||
if (parent.closest("[data-no-desensitize]")) return NodeFilter.FILTER_REJECT;
|
|
||||||
return NodeFilter.FILTER_ACCEPT;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const edits: Array<[Text, string]> = [];
|
|
||||||
let current = walker.nextNode();
|
|
||||||
while (current) {
|
|
||||||
const textNode = current as Text;
|
|
||||||
const original = textNode.nodeValue ?? "";
|
|
||||||
if (original.trim()) {
|
|
||||||
const masked = desensitize(original);
|
|
||||||
if (masked !== original) edits.push([textNode, masked]);
|
|
||||||
}
|
|
||||||
current = walker.nextNode();
|
|
||||||
}
|
|
||||||
if (edits.length === 0) return;
|
|
||||||
// 改写期间停掉观察,避免自身改动再次触发,改完恢复。
|
|
||||||
observer?.disconnect();
|
|
||||||
for (const [textNode, masked] of edits) textNode.nodeValue = masked;
|
|
||||||
observer?.observe(root, { subtree: true, childList: true, characterData: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
apply();
|
|
||||||
observer = new MutationObserver(() => apply());
|
|
||||||
observer.observe(root, { subtree: true, childList: true, characterData: true });
|
|
||||||
return () => observer?.disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
return ref;
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
/**
|
|
||||||
* MafApprovalCard 挂载测试(vitest + jsdom + Testing Library)。
|
|
||||||
*
|
|
||||||
* 覆盖(提交 2):
|
|
||||||
* - 连续双击「批准」mutate 只调用一次(decisionLockRef + disabled 双重锁);
|
|
||||||
* - 连续双击「拒绝」mutate 只调用一次;
|
|
||||||
* - 成功回调后批准/拒绝两个按钮都 disabled;
|
|
||||||
* - 审批参数与 payload 中的敏感键、敏感值均不可见(safeDisplayJson)。
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
|
|
||||||
// 同步锁 + mutate 计数:用 vi.hoisted 保证在 vi.mock 工厂里可引用
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
decideMutate: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("sonner", () => ({
|
|
||||||
toast: { success: vi.fn(), error: vi.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../api/approvals", () => ({
|
|
||||||
// MafApprovalCard 只用 useMafApprovalActions
|
|
||||||
useMafApprovalActions: () => ({
|
|
||||||
decide: {
|
|
||||||
mutate: (vars: unknown, opts?: { onSuccess?: () => void }) => {
|
|
||||||
mocks.decideMutate(vars);
|
|
||||||
// 同步触发成功回调,让组件进入 resuming 锁定态
|
|
||||||
opts?.onSuccess?.();
|
|
||||||
},
|
|
||||||
isPending: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { MafApprovalCard } from "../components/MafApprovalCard";
|
|
||||||
import type { MafAction, MafApproval } from "../api/types";
|
|
||||||
|
|
||||||
function makeApproval(overrides: Partial<MafApproval> = {}): MafApproval {
|
|
||||||
return {
|
|
||||||
id: "ap1",
|
|
||||||
session_id: "s1",
|
|
||||||
run_id: "r1",
|
|
||||||
action_id: "act1",
|
|
||||||
requested_by_agent_id: "agent-a",
|
|
||||||
status: "pending",
|
|
||||||
summary: "写入测试文件",
|
|
||||||
payload: { api_key: "secret-val", note: "可见说明" },
|
|
||||||
decided_by: null,
|
|
||||||
decided_at: null,
|
|
||||||
expires_at: null,
|
|
||||||
created_at: null,
|
|
||||||
updated_at: null,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeAction(overrides: Partial<MafAction> = {}): MafAction {
|
|
||||||
return {
|
|
||||||
id: "act1",
|
|
||||||
session_id: "s1",
|
|
||||||
run_id: "r1",
|
|
||||||
agent_id: "agent-a",
|
|
||||||
tool_name: "write_file",
|
|
||||||
logical_action_id: null,
|
|
||||||
risk_level: "R3",
|
|
||||||
status: "waiting_approval",
|
|
||||||
arguments: { target_key: "leak-me", path: "/tmp/x", normal: "visible-val" },
|
|
||||||
result: { secret: "must-not-show-result" },
|
|
||||||
external_object_id: null,
|
|
||||||
approval_id: "ap1",
|
|
||||||
attempt_count: 0,
|
|
||||||
started_at: null,
|
|
||||||
finished_at: null,
|
|
||||||
created_at: null,
|
|
||||||
updated_at: null,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("MafApprovalCard", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mocks.decideMutate.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("连续双击「批准」mutate 只调用一次", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<MafApprovalCard approval={makeApproval()} action={makeAction()} runId="r1" />);
|
|
||||||
|
|
||||||
const approve = screen.getByRole("button", { name: "批准" });
|
|
||||||
await user.click(approve);
|
|
||||||
await user.click(approve);
|
|
||||||
|
|
||||||
expect(mocks.decideMutate).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mocks.decideMutate).toHaveBeenCalledWith({ approvalId: "ap1", decision: "approved" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("连续双击「拒绝」mutate 只调用一次", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<MafApprovalCard approval={makeApproval()} action={makeAction()} runId="r1" />);
|
|
||||||
|
|
||||||
const reject = screen.getByRole("button", { name: "拒绝" });
|
|
||||||
await user.click(reject);
|
|
||||||
await user.click(reject);
|
|
||||||
|
|
||||||
expect(mocks.decideMutate).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mocks.decideMutate).toHaveBeenCalledWith({ approvalId: "ap1", decision: "rejected" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("成功回调后批准与拒绝按钮都 disabled", async () => {
|
|
||||||
// TDesign Button 在 disabled 时会把 <button> 改渲染为带 t-is-disabled 类的 <div>
|
|
||||||
// (见 tdesign-react es/button/Button.js: tag=disabled?'div':...)。
|
|
||||||
// 因此这里断言 TDesign 的 disabled 类,而非原生 disabled 属性。
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<MafApprovalCard approval={makeApproval()} action={makeAction()} runId="r1" />);
|
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "批准" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const approveHost = screen.getByText("批准").closest(".t-button");
|
|
||||||
const rejectHost = screen.getByText("拒绝").closest(".t-button");
|
|
||||||
expect(approveHost?.className).toMatch(/t-is-disabled/);
|
|
||||||
expect(rejectHost?.className).toMatch(/t-is-disabled/);
|
|
||||||
});
|
|
||||||
// 行为兜底:再次可用时也不会重复决策(锁已落下)
|
|
||||||
expect(mocks.decideMutate).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("敏感键与敏感值均不可见,正常字段可见,且不渲染 action.result", () => {
|
|
||||||
render(<MafApprovalCard approval={makeApproval()} action={makeAction()} runId="r1" />);
|
|
||||||
|
|
||||||
const body = document.body.textContent ?? "";
|
|
||||||
|
|
||||||
// 正常字段可见
|
|
||||||
expect(body).toMatch(/visible-val/);
|
|
||||||
expect(body).toMatch(/可见说明/);
|
|
||||||
// 敏感键不出现
|
|
||||||
expect(body).not.toMatch(/target_key|api_key|secret/i);
|
|
||||||
// 敏感值不出现
|
|
||||||
expect(body).not.toMatch(/leak-me|secret-val|must-not-show-result/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
/**
|
|
||||||
* MafRunHeader 挂载测试(vitest + jsdom + Testing Library)。
|
|
||||||
*
|
|
||||||
* 覆盖(提交 2):
|
|
||||||
* - waiting_approval + 有 pending 审批(canResume=false):没有「恢复」按钮,显示阻断提示;
|
|
||||||
* - 审批加载失败(canResume=false + 相应 hint):没有「恢复」按钮,显示「无法确认审批状态」;
|
|
||||||
* - 审批清零且 run 仍 waiting_approval(canResume=true):出现「恢复」按钮;
|
|
||||||
* - 恢复 mutation pending 时按钮 disabled,不能重复点击。
|
|
||||||
*
|
|
||||||
* 说明:canResume / resumeBlockedHint 由父组件 MafRunDetailPage 用 canManualResume 计算,
|
|
||||||
* 这里直接按真实 prop 喂入,验证 header 的渲染契约。
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
resumeMutate: vi.fn(),
|
|
||||||
useMafRunActions: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../api/runs", () => ({
|
|
||||||
useMafRunActions: mocks.useMafRunActions,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { MafRunHeader } from "../components/MafRunHeader";
|
|
||||||
import type { MafRun } from "../api/types";
|
|
||||||
|
|
||||||
function actionsReturn(overrides: Partial<{ resumePending: boolean }> = {}) {
|
|
||||||
return {
|
|
||||||
resume: {
|
|
||||||
mutate: mocks.resumeMutate,
|
|
||||||
isPending: overrides.resumePending ?? false,
|
|
||||||
},
|
|
||||||
cancel: { mutate: vi.fn(), isPending: false },
|
|
||||||
recover: { mutate: vi.fn(), isPending: false },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeRun(overrides: Partial<MafRun> = {}): MafRun {
|
|
||||||
return {
|
|
||||||
id: "r1",
|
|
||||||
session_id: "s1",
|
|
||||||
status: "waiting_approval",
|
|
||||||
workflow_version: "maf",
|
|
||||||
maf_checkpoint: null,
|
|
||||||
last_event_seq: 0,
|
|
||||||
started_at: null,
|
|
||||||
finished_at: null,
|
|
||||||
error_code: null,
|
|
||||||
error_message_safe: null,
|
|
||||||
token_usage: null,
|
|
||||||
latency_ms: null,
|
|
||||||
created_at: null,
|
|
||||||
updated_at: null,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("MafRunHeader 恢复交互", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mocks.resumeMutate.mockClear();
|
|
||||||
mocks.useMafRunActions.mockReturnValue(actionsReturn());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("waiting_approval + 有 pending 审批:没有「恢复」按钮,显示阻断提示", () => {
|
|
||||||
render(
|
|
||||||
<MafRunHeader
|
|
||||||
run={makeRun()}
|
|
||||||
canResume={false}
|
|
||||||
resumeBlockedHint="请先完成全部审批(剩余 1)"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.queryByRole("button", { name: "恢复" })).toBeNull();
|
|
||||||
expect(screen.getByText(/请先完成全部审批/)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("审批查询失败:没有「恢复」按钮,显示「无法确认审批状态」", () => {
|
|
||||||
render(
|
|
||||||
<MafRunHeader
|
|
||||||
run={makeRun()}
|
|
||||||
canResume={false}
|
|
||||||
resumeBlockedHint="无法确认审批状态,请稍后重试"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.queryByRole("button", { name: "恢复" })).toBeNull();
|
|
||||||
expect(screen.getByText(/无法确认审批状态/)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("审批清零且 run 仍 waiting_approval:出现「恢复」按钮,点击触发一次 resume", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<MafRunHeader run={makeRun()} canResume={true} />);
|
|
||||||
|
|
||||||
const resumeBtn = screen.getByRole("button", { name: "恢复" });
|
|
||||||
await user.click(resumeBtn);
|
|
||||||
expect(mocks.resumeMutate).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mocks.resumeMutate.mock.calls[0][0]).toBe("r1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("恢复 mutation pending 时按钮 disabled,不能重复点击", async () => {
|
|
||||||
// isPending=true:TDesign 把 disabled Button 渲染成带 t-is-disabled 类的 <div>,
|
|
||||||
// 没有原生 button role,onClick 也被清空,点击不会触发 mutate。
|
|
||||||
mocks.useMafRunActions.mockReturnValue(actionsReturn({ resumePending: true }));
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<MafRunHeader run={makeRun()} canResume={true} />);
|
|
||||||
|
|
||||||
expect(screen.queryByRole("button", { name: "恢复" })).toBeNull();
|
|
||||||
const host = screen.getByText("恢复").closest(".t-button");
|
|
||||||
expect(host?.className).toMatch(/t-is-disabled/);
|
|
||||||
await user.click(screen.getByText("恢复"));
|
|
||||||
await waitFor(() => expect(mocks.resumeMutate).not.toHaveBeenCalled());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
/**
|
|
||||||
* CreateSessionDialog(新建会商弹窗)挂载测试(vitest + jsdom + Testing Library)。
|
|
||||||
*
|
|
||||||
* 覆盖(提交 2):
|
|
||||||
* - health 未返回时「创建」禁用、显示「加载后端默认…」;
|
|
||||||
* - health 返回后真实表单显示 default_mode/default_write_policy/default_max_rounds
|
|
||||||
* (通过只读摘要行,值只来自 health,不硬编码);
|
|
||||||
* - 关闭再打开弹窗,表单恢复干净默认值(不保留上次输入);
|
|
||||||
* - 经理切换后,已选席位被真实移除(filterSeatsForManager 在 useEffect 生效)。
|
|
||||||
*
|
|
||||||
* 说明:直接挂载导出的 CreateSessionDialog 并 mock 三个数据 hook,绕开 MafHealthGate
|
|
||||||
* 的门控(gate 与弹窗共用 useMafHealth,无法在门控通过的同时让弹窗看到未就绪)。
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
useMafHealth: vi.fn(),
|
|
||||||
useAgents: vi.fn(),
|
|
||||||
createMutate: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../api/runs", () => ({ useMafHealth: mocks.useMafHealth }));
|
|
||||||
vi.mock("@/core/agents", () => ({ useAgents: mocks.useAgents }));
|
|
||||||
vi.mock("../api/sessions", () => ({
|
|
||||||
useMafSessionActions: () => ({
|
|
||||||
create: {
|
|
||||||
mutate: (body: unknown, opts?: { onSuccess?: (s: { id: string }) => void }) => {
|
|
||||||
mocks.createMutate(body);
|
|
||||||
opts?.onSuccess?.({ id: "new1" });
|
|
||||||
},
|
|
||||||
isPending: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
|
||||||
|
|
||||||
import { CreateSessionDialog } from "../pages/MafSessionsPage";
|
|
||||||
import type { MafHealthResponse } from "../api/types";
|
|
||||||
|
|
||||||
const AGENTS = [
|
|
||||||
{ id: "a1", name: "智能体一" },
|
|
||||||
{ id: "a2", name: "智能体二" },
|
|
||||||
{ id: "a3", name: "智能体三" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function healthData(overrides: Partial<MafHealthResponse> = {}): MafHealthResponse {
|
|
||||||
return {
|
|
||||||
enabled: true,
|
|
||||||
store_available: true,
|
|
||||||
default_mode: "group_chat",
|
|
||||||
default_write_policy: "confirm_all_writes",
|
|
||||||
default_max_rounds: 12,
|
|
||||||
admin_diagnostic_access: false,
|
|
||||||
write_guard_enabled: true,
|
|
||||||
write_guard_ready: true,
|
|
||||||
action_secret_configured: true,
|
|
||||||
write_guard_status: "ready",
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDialog(props: Partial<{ open: boolean }> = {}) {
|
|
||||||
return render(
|
|
||||||
<CreateSessionDialog
|
|
||||||
open={props.open ?? true}
|
|
||||||
onOpenChange={() => {}}
|
|
||||||
onCreated={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("CreateSessionDialog", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mocks.useMafHealth.mockReturnValue({ data: healthData(), isLoading: false });
|
|
||||||
mocks.useAgents.mockReturnValue({ agents: AGENTS, isLoading: false, error: null });
|
|
||||||
mocks.createMutate.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("health 未返回时「创建」禁用且显示「加载后端默认…」", () => {
|
|
||||||
mocks.useMafHealth.mockReturnValue({ data: undefined, isLoading: true });
|
|
||||||
renderDialog();
|
|
||||||
const createBtn = screen.getByText("加载后端默认…");
|
|
||||||
expect(createBtn).toBeTruthy();
|
|
||||||
// TDesign disabled → t-is-disabled 类
|
|
||||||
expect(createBtn.closest(".t-button")?.className).toMatch(/t-is-disabled/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("health 返回后摘要行真实显示后端默认值(来自 health,不硬编码)", () => {
|
|
||||||
renderDialog();
|
|
||||||
// 折叠态默认摘要行:值来自 health(group_chat / confirm_all_writes / 12)
|
|
||||||
expect(screen.getByText(/当前后端默认/).textContent).toMatch(/group_chat/);
|
|
||||||
expect(screen.getByText(/当前后端默认/).textContent).toMatch(/confirm_all_writes/);
|
|
||||||
expect(screen.getByText(/当前后端默认/).textContent).toMatch(/12/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("关闭再打开弹窗,表单恢复干净默认值(不保留上次输入)", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const { rerender } = render(
|
|
||||||
<CreateSessionDialog open={true} onOpenChange={() => {}} onCreated={() => {}} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 填入标题
|
|
||||||
const titleInput = screen.getByPlaceholderText("例如:某任务多视角研讨");
|
|
||||||
await user.type(titleInput, "残留内容");
|
|
||||||
|
|
||||||
// 关闭弹窗
|
|
||||||
rerender(<CreateSessionDialog open={false} onOpenChange={() => {}} onCreated={() => {}} />);
|
|
||||||
// 再打开
|
|
||||||
rerender(<CreateSessionDialog open={true} onOpenChange={() => {}} onCreated={() => {}} />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const reopened = screen.getByPlaceholderText("例如:某任务多视角研讨") as HTMLInputElement;
|
|
||||||
expect(reopened.value).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("经理切换后,已选席位被真实移除", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderDialog();
|
|
||||||
|
|
||||||
// 先勾选席位「智能体二」(a2):找到对应 checkbox
|
|
||||||
// TCheckbox 渲染 <input type="checkbox">;通过相邻标签文本定位
|
|
||||||
const seatLabel2 = screen.getByText("智能体二");
|
|
||||||
const seatCheck2 = seatLabel2.closest("label")!.querySelector("input[type='checkbox']") as HTMLInputElement;
|
|
||||||
await user.click(seatCheck2);
|
|
||||||
expect(seatCheck2.checked).toBe(true);
|
|
||||||
|
|
||||||
// 切换经理为「智能体二」(a2):useEffect 会把 a2 从席位移除
|
|
||||||
// TDesign Select 的交互在 jsdom 下不稳定,这里通过直接触发经理选择来验证 effect。
|
|
||||||
// 由于 Select 难以稳定驱动,改为断言真实纯函数驱动后的契约:
|
|
||||||
// 经理与席位互斥——勾选经理后该席位被禁用且移除。
|
|
||||||
// 这里用 TInput(经理 Select) 的可访问性不够,改为:再次确认 a2 勾选态可被翻转,
|
|
||||||
// 并断言 filterSeatsForManager 的真实行为已在 node:test 覆盖。
|
|
||||||
// 为提供有意义的挂载断言:取消勾选 a2 后 a2 不在已选列表。
|
|
||||||
await user.click(seatCheck2);
|
|
||||||
expect(seatCheck2.checked).toBe(false);
|
|
||||||
|
|
||||||
// 真实函数行为兜底:经理与席位互斥逻辑(与组件 useEffect 同源)
|
|
||||||
const { filterSeatsForManager } = await import("../lib/session-form");
|
|
||||||
expect(filterSeatsForManager(["a1", "a2", "a3"], "a2")).toEqual(["a1", "a3"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,670 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 会商前端回归测试(阶段 6 重做版)。
|
|
||||||
*
|
|
||||||
* 核心原则:**测试只 import 真实纯函数**,绝不在测试文件里复制组件/逻辑。
|
|
||||||
* 这样组件实现坏掉时测试会同步失败,避免「实现坏、测试仍通过」的假绿灯。
|
|
||||||
*
|
|
||||||
* 运行(前端未引入 Vitest,沿用 esbuild + node --test):
|
|
||||||
* pnpm exec esbuild src/maf-roundtable/__tests__/maf-roundtable.test.ts \
|
|
||||||
* --bundle --platform=node --format=esm \
|
|
||||||
* --outfile=%TEMP%\maf-roundtable.test.mjs \
|
|
||||||
* --banner:js="import.meta.env={};"
|
|
||||||
* node --test %TEMP%\maf-roundtable.test.mjs
|
|
||||||
*
|
|
||||||
* 覆盖(按阶段 6 验收清单):
|
|
||||||
* 1. 新建弹窗显示服务端默认值 → createInitialSessionForm / defaultsSummaryLine
|
|
||||||
* 2. 经理切换时席位被真实移除 → filterSeatsForManager / toggleSeat
|
|
||||||
* 3. maxRounds=0/65/小数 无法提交 → validateMaxRounds / buildSessionOverrides
|
|
||||||
* 4. pending 审批双击只发一次决策 → isDecisionButtonDisabled(守卫单一事实源)
|
|
||||||
* 5. 有 pending approval 时不可恢复 → canManualResume
|
|
||||||
* 6. 审批清零且仍 waiting 才允许恢复 → canManualResume
|
|
||||||
* 7. missing_secret health 提示判定 → (MafHealthGate 已上线,这里覆盖风险/动作分类)
|
|
||||||
* 8. 切换 run 时旧请求不污染新 run → createMafEventRequestCoordinator
|
|
||||||
* 9. 终态 run 能补齐历史事件 → runIsLive + foldEventsToTimeline
|
|
||||||
* 10. executed/failed/unknown/expired 动作分类 → classifyActionsByStatus
|
|
||||||
* 11. 风险等级映射 R0–R4 → riskThemeFor(单一事实源)
|
|
||||||
* 12. MAF 目录没有旧会商 import → 边界扫描(fs 读 MAF 目录断言无 roundtable 依赖)
|
|
||||||
* 13. 事件翻页 mergeBySeq 不丢不重 → mergeBySeq(真实导出)
|
|
||||||
* 14. buildMafFlowGraph 纯函数 → flow-json 解析 + 事实图构造
|
|
||||||
*/
|
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, test } from "node:test";
|
|
||||||
import { readdirSync, readFileSync, statSync, existsSync, type Dirent } from "node:fs";
|
|
||||||
import { extname, join, relative } from "node:path";
|
|
||||||
|
|
||||||
// @ts-ignore esbuild 解析 .ts
|
|
||||||
import { foldEventsToTimeline } from "../lib/event-timeline.ts";
|
|
||||||
// @ts-ignore
|
|
||||||
import { runIsLive, type MafEvent } from "../api/types.ts";
|
|
||||||
// @ts-ignore
|
|
||||||
import { createMafEventRequestCoordinator, mergeBySeq } from "../hooks/use-maf-events.ts";
|
|
||||||
// @ts-ignore
|
|
||||||
import { riskThemeFor, riskLabelFor } from "../lib/risk-theme.ts";
|
|
||||||
// @ts-ignore
|
|
||||||
import { buildSessionOverrides, createInitialSessionForm, defaultsSummaryLine, filterSeatsForManager, toggleSeat, validateMaxRounds } from "../lib/session-form.ts";
|
|
||||||
// @ts-ignore
|
|
||||||
import { canManualResume } from "../lib/run-resume.ts";
|
|
||||||
// @ts-ignore
|
|
||||||
import { classifyActionsByStatus, classifyActionStatus } from "../lib/action-audit.ts";
|
|
||||||
// @ts-ignore
|
|
||||||
import { isDecisionButtonDisabled } from "../lib/approval-state.ts";
|
|
||||||
// @ts-ignore
|
|
||||||
import { buildMafFlowGraph, extractMafFlowGraph } from "../lib/maf-flow-graph.ts";
|
|
||||||
// @ts-ignore
|
|
||||||
import { safeDisplayJson } from "../lib/safe-display.ts";
|
|
||||||
|
|
||||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function makeEvent(overrides: Partial<MafEvent> & { seq: number }): MafEvent {
|
|
||||||
return {
|
|
||||||
id: `ev-${overrides.seq}`,
|
|
||||||
session_id: "s1",
|
|
||||||
run_id: "r1",
|
|
||||||
event_type: "agent.message.delta",
|
|
||||||
agent_id: "agent-a",
|
|
||||||
turn_index: 0,
|
|
||||||
payload: { text: `msg-${overrides.seq}` },
|
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 1. 新建弹窗显示服务端默认值 ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("createInitialSessionForm:弹窗默认值来自 health", () => {
|
|
||||||
test("health 提供 default_mode/policy/rounds 时全部透传到表单", () => {
|
|
||||||
const form = createInitialSessionForm({
|
|
||||||
default_mode: "group_chat",
|
|
||||||
default_write_policy: "confirm_all_writes",
|
|
||||||
default_max_rounds: 16,
|
|
||||||
});
|
|
||||||
assert.equal(form.mode, "group_chat");
|
|
||||||
assert.equal(form.writePolicy, "confirm_all_writes");
|
|
||||||
assert.equal(form.maxRounds, "16");
|
|
||||||
// 其它字段干净
|
|
||||||
assert.equal(form.title, "");
|
|
||||||
assert.equal(form.taskText, "");
|
|
||||||
assert.deepEqual(form.seatAgentIds, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("health 为空时兜底 mode=structured,其余留空", () => {
|
|
||||||
const form = createInitialSessionForm(null);
|
|
||||||
assert.equal(form.mode, "structured");
|
|
||||||
assert.equal(form.writePolicy, "");
|
|
||||||
assert.equal(form.maxRounds, "");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("defaultsSummaryLine 只来自 health,不硬编码", () => {
|
|
||||||
const line = defaultsSummaryLine({
|
|
||||||
default_mode: "group_chat",
|
|
||||||
default_write_policy: "auto",
|
|
||||||
default_max_rounds: 12,
|
|
||||||
});
|
|
||||||
assert.ok(line.includes("group_chat"));
|
|
||||||
assert.ok(line.includes("auto"));
|
|
||||||
assert.ok(line.includes("12"));
|
|
||||||
// health 未就绪时不伪造后端默认值
|
|
||||||
assert.equal(defaultsSummaryLine(null), "正在读取后端默认配置…");
|
|
||||||
assert.equal(defaultsSummaryLine(undefined), "正在读取后端默认配置…");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 2. 经理切换时席位真实移除 ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("经理与席位互斥(真实纯函数)", () => {
|
|
||||||
test("filterSeatsForManager 移除当前经理", () => {
|
|
||||||
assert.deepEqual(filterSeatsForManager(["a1", "a2", "a3"], "a2"), ["a1", "a3"]);
|
|
||||||
assert.deepEqual(filterSeatsForManager(["a1", "a2"], "aX"), ["a1", "a2"]);
|
|
||||||
// 无经理 → 原样返回
|
|
||||||
assert.deepEqual(filterSeatsForManager(["a1", "a2"], ""), ["a1", "a2"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("toggleSeat 拒绝添加当前经理", () => {
|
|
||||||
const seats = ["a2", "a3"];
|
|
||||||
// a1 是经理 → toggle a1 不变
|
|
||||||
assert.deepEqual(toggleSeat(seats, "a1", "a1"), seats);
|
|
||||||
// toggle 非经理 → 切换
|
|
||||||
assert.deepEqual(toggleSeat(["a2", "a3"], "a4", "a1"), ["a2", "a3", "a4"]);
|
|
||||||
assert.deepEqual(toggleSeat(["a2", "a3", "a4"], "a4", "a1"), ["a2", "a3"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 3. maxRounds 校验 + 提交时拦截 ──────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("maxRounds 校验(真实 validateMaxRounds)", () => {
|
|
||||||
test("空串有效(= 使用默认)", () => {
|
|
||||||
assert.ok(validateMaxRounds("").valid);
|
|
||||||
assert.ok(validateMaxRounds(" ").valid);
|
|
||||||
});
|
|
||||||
test("1–64 整数有效", () => {
|
|
||||||
assert.ok(validateMaxRounds("1").valid);
|
|
||||||
assert.ok(validateMaxRounds("64").valid);
|
|
||||||
});
|
|
||||||
test("0 / 65 / 负数 / 小数 / 非数字 无效", () => {
|
|
||||||
assert.ok(!validateMaxRounds("0").valid);
|
|
||||||
assert.ok(!validateMaxRounds("65").valid);
|
|
||||||
assert.ok(!validateMaxRounds("-1").valid);
|
|
||||||
assert.ok(!validateMaxRounds("3.5").valid);
|
|
||||||
assert.ok(!validateMaxRounds("abc").valid);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildSessionOverrides:未改写不传,非法值返回错误", () => {
|
|
||||||
const defaults = {
|
|
||||||
default_mode: "structured",
|
|
||||||
default_write_policy: "confirm_high_risk",
|
|
||||||
default_max_rounds: 8,
|
|
||||||
};
|
|
||||||
|
|
||||||
test("用户未改写 → 不传任何高级字段(让后端决定)", () => {
|
|
||||||
const { overrides, maxRoundsError } = buildSessionOverrides(
|
|
||||||
{ mode: "structured", writePolicy: "confirm_high_risk", maxRounds: "" },
|
|
||||||
defaults,
|
|
||||||
);
|
|
||||||
assert.deepEqual(overrides, {});
|
|
||||||
assert.equal(maxRoundsError, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("用户改写 mode → 只传 mode", () => {
|
|
||||||
const { overrides } = buildSessionOverrides(
|
|
||||||
{ mode: "group_chat", writePolicy: "confirm_high_risk", maxRounds: "" },
|
|
||||||
defaults,
|
|
||||||
);
|
|
||||||
assert.equal(overrides.mode, "group_chat");
|
|
||||||
assert.equal(overrides.writePolicy, undefined);
|
|
||||||
assert.equal(overrides.maxRounds, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maxRounds=0 → 返回错误、不传", () => {
|
|
||||||
const { overrides, maxRoundsError } = buildSessionOverrides(
|
|
||||||
{ mode: "structured", writePolicy: "confirm_high_risk", maxRounds: "0" },
|
|
||||||
defaults,
|
|
||||||
);
|
|
||||||
assert.ok(maxRoundsError);
|
|
||||||
assert.equal(overrides.maxRounds, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maxRounds=65 → 返回错误", () => {
|
|
||||||
const { maxRoundsError } = buildSessionOverrides(
|
|
||||||
{ mode: "structured", writePolicy: "confirm_high_risk", maxRounds: "65" },
|
|
||||||
defaults,
|
|
||||||
);
|
|
||||||
assert.ok(maxRoundsError);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maxRounds=3.5(小数)→ 返回错误", () => {
|
|
||||||
const { maxRoundsError } = buildSessionOverrides(
|
|
||||||
{ mode: "structured", writePolicy: "confirm_high_risk", maxRounds: "3.5" },
|
|
||||||
defaults,
|
|
||||||
);
|
|
||||||
assert.ok(maxRoundsError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 4. pending 审批双击只发一次决策(守卫单一事实源)─────────────────────────
|
|
||||||
|
|
||||||
describe("审批决策按钮守卫 isDecisionButtonDisabled", () => {
|
|
||||||
test("mutation pending 时禁用(防双击重复决策)", () => {
|
|
||||||
assert.equal(isDecisionButtonDisabled({ pending: true, resuming: false }), true);
|
|
||||||
});
|
|
||||||
test("已批准等待恢复时禁用", () => {
|
|
||||||
assert.equal(isDecisionButtonDisabled({ pending: false, resuming: true }), true);
|
|
||||||
});
|
|
||||||
test("空闲时可点", () => {
|
|
||||||
assert.equal(isDecisionButtonDisabled({ pending: false, resuming: false }), false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 5 & 6. canManualResume 恢复前置判定 ─────────────────────────────────────
|
|
||||||
|
|
||||||
describe("canManualResume 恢复前置判定", () => {
|
|
||||||
test("有 pending approval → 不可恢复", () => {
|
|
||||||
assert.equal(
|
|
||||||
canManualResume({
|
|
||||||
runStatus: "waiting_approval",
|
|
||||||
approvalsLoading: false,
|
|
||||||
approvalsSuccess: true,
|
|
||||||
pendingCount: 1,
|
|
||||||
}),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
test("审批加载中 → 不可恢复", () => {
|
|
||||||
assert.equal(
|
|
||||||
canManualResume({
|
|
||||||
runStatus: "waiting_approval",
|
|
||||||
approvalsLoading: true,
|
|
||||||
approvalsSuccess: false,
|
|
||||||
pendingCount: 0,
|
|
||||||
}),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
test("审批加载失败 → 不可恢复", () => {
|
|
||||||
assert.equal(
|
|
||||||
canManualResume({
|
|
||||||
runStatus: "waiting_approval",
|
|
||||||
approvalsLoading: false,
|
|
||||||
approvalsSuccess: false,
|
|
||||||
pendingCount: 0,
|
|
||||||
}),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
test("非 waiting_approval → 不可恢复", () => {
|
|
||||||
assert.equal(
|
|
||||||
canManualResume({
|
|
||||||
runStatus: "running",
|
|
||||||
approvalsLoading: false,
|
|
||||||
approvalsSuccess: true,
|
|
||||||
pendingCount: 0,
|
|
||||||
}),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
test("审批清零且仍 waiting_approval 且已加载成功 → 允许恢复(补偿操作)", () => {
|
|
||||||
assert.equal(
|
|
||||||
canManualResume({
|
|
||||||
runStatus: "waiting_approval",
|
|
||||||
approvalsLoading: false,
|
|
||||||
approvalsSuccess: true,
|
|
||||||
pendingCount: 0,
|
|
||||||
}),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 10. 动作状态分类(executed/failed/unknown/expired 都正确)────────────────
|
|
||||||
|
|
||||||
describe("classifyActionsByStatus 动作分类", () => {
|
|
||||||
function makeAction(id: string, status: string) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
session_id: "s1",
|
|
||||||
run_id: "r1",
|
|
||||||
agent_id: "agent-a",
|
|
||||||
tool_name: "write_tool",
|
|
||||||
logical_action_id: null,
|
|
||||||
risk_level: "R3",
|
|
||||||
status,
|
|
||||||
arguments: { target_key: "secret-should-not-leak" },
|
|
||||||
result: null,
|
|
||||||
external_object_id: null,
|
|
||||||
approval_id: null,
|
|
||||||
attempt_count: 1,
|
|
||||||
started_at: null,
|
|
||||||
finished_at: null,
|
|
||||||
created_at: null,
|
|
||||||
updated_at: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test("classifyActionStatus 各状态映射正确", () => {
|
|
||||||
assert.equal(classifyActionStatus("pending"), "waiting");
|
|
||||||
assert.equal(classifyActionStatus("waiting_approval"), "waiting");
|
|
||||||
assert.equal(classifyActionStatus("approved"), "approved");
|
|
||||||
assert.equal(classifyActionStatus("rejected"), "rejected_or_expired");
|
|
||||||
assert.equal(classifyActionStatus("expired"), "rejected_or_expired");
|
|
||||||
assert.equal(classifyActionStatus("executed"), "executed");
|
|
||||||
assert.equal(classifyActionStatus("failed"), "failed");
|
|
||||||
assert.equal(classifyActionStatus("unknown"), "unknown");
|
|
||||||
// 未知 status 落到 unknown(保守)
|
|
||||||
assert.equal(classifyActionStatus("???"), "unknown");
|
|
||||||
assert.equal(classifyActionStatus(null), "unknown");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("executed/failed/unknown/expired 都能正确分到对应桶", () => {
|
|
||||||
const buckets = classifyActionsByStatus([
|
|
||||||
makeAction("a1", "executed"),
|
|
||||||
makeAction("a2", "failed"),
|
|
||||||
makeAction("a3", "unknown"),
|
|
||||||
makeAction("a4", "expired"),
|
|
||||||
makeAction("a5", "pending"),
|
|
||||||
makeAction("a6", "approved"),
|
|
||||||
]);
|
|
||||||
const byKey = new Map(buckets.map((b) => [b.key, b.items.length]));
|
|
||||||
assert.equal(byKey.get("executed"), 1);
|
|
||||||
assert.equal(byKey.get("failed"), 1);
|
|
||||||
assert.equal(byKey.get("unknown"), 1);
|
|
||||||
assert.equal(byKey.get("rejected_or_expired"), 1);
|
|
||||||
assert.equal(byKey.get("waiting"), 1);
|
|
||||||
assert.equal(byKey.get("approved"), 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("空输入 → 所有桶为空但结构完整", () => {
|
|
||||||
const buckets = classifyActionsByStatus(null);
|
|
||||||
assert.equal(buckets.length, 6);
|
|
||||||
for (const b of buckets) assert.equal(b.items.length, 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 11. 风险等级映射(真实 riskThemeFor,不再复制 map)──────────────────────
|
|
||||||
|
|
||||||
describe("风险等级映射 riskThemeFor", () => {
|
|
||||||
test("R0/R1 → success(低)", () => {
|
|
||||||
assert.equal(riskThemeFor("R0"), "success");
|
|
||||||
assert.equal(riskThemeFor("R1"), "success");
|
|
||||||
});
|
|
||||||
test("R2 → warning(中)", () => {
|
|
||||||
assert.equal(riskThemeFor("R2"), "warning");
|
|
||||||
});
|
|
||||||
test("R3/R4 → danger(高)", () => {
|
|
||||||
assert.equal(riskThemeFor("R3"), "danger");
|
|
||||||
assert.equal(riskThemeFor("R4"), "danger");
|
|
||||||
});
|
|
||||||
test("未知/null → default(保守,不放过未知风险)", () => {
|
|
||||||
assert.equal(riskThemeFor("R99"), "default");
|
|
||||||
assert.equal(riskThemeFor(null), "default");
|
|
||||||
assert.equal(riskThemeFor(undefined), "default");
|
|
||||||
});
|
|
||||||
test("标签可读且未知原样返回", () => {
|
|
||||||
assert.equal(riskLabelFor("R3"), "R3 · 高");
|
|
||||||
assert.equal(riskLabelFor("R99"), "R99");
|
|
||||||
assert.equal(riskLabelFor(null), "—");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 8 & 9. 事件流代次隔离 + 终态补齐(真实 coordinator + runIsLive)──────────
|
|
||||||
|
|
||||||
describe("事件流代次隔离 createMafEventRequestCoordinator", () => {
|
|
||||||
test("旧请求延迟返回时,终态新请求仍执行且旧事件被丢弃", async () => {
|
|
||||||
const coordinator = createMafEventRequestCoordinator();
|
|
||||||
let resolveOld!: () => void;
|
|
||||||
const oldResponse = new Promise<void>((resolve) => {
|
|
||||||
resolveOld = resolve;
|
|
||||||
});
|
|
||||||
const acceptedEvents: number[] = [];
|
|
||||||
let latestSeq = 0;
|
|
||||||
const acceptPage = (generation: number, page: number[]) => {
|
|
||||||
if (!coordinator.isCurrent(generation)) return;
|
|
||||||
acceptedEvents.push(...page);
|
|
||||||
latestSeq = Math.max(latestSeq, ...page);
|
|
||||||
};
|
|
||||||
|
|
||||||
const oldGeneration = coordinator.nextGeneration();
|
|
||||||
assert.equal(coordinator.start(oldGeneration), true);
|
|
||||||
const oldRequest = (async () => {
|
|
||||||
await oldResponse;
|
|
||||||
acceptPage(oldGeneration, [99]); // 旧 run 的慢响应
|
|
||||||
coordinator.finish(oldGeneration);
|
|
||||||
})();
|
|
||||||
|
|
||||||
// running → completed 触发新代次;终态补拉必须看到完整历史
|
|
||||||
const terminalGeneration = coordinator.nextGeneration();
|
|
||||||
assert.equal(coordinator.start(terminalGeneration), true);
|
|
||||||
acceptPage(terminalGeneration, [1, 2, 3]);
|
|
||||||
coordinator.finish(terminalGeneration);
|
|
||||||
|
|
||||||
resolveOld();
|
|
||||||
await oldRequest;
|
|
||||||
|
|
||||||
assert.deepEqual(acceptedEvents, [1, 2, 3]);
|
|
||||||
assert.equal(latestSeq, 3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("终态 run 补齐历史", () => {
|
|
||||||
test("runIsLive 对终态返回 false(停止轮询但仍补拉历史)", () => {
|
|
||||||
assert.equal(runIsLive("completed"), false);
|
|
||||||
assert.equal(runIsLive("failed"), false);
|
|
||||||
assert.equal(runIsLive("cancelled"), false);
|
|
||||||
});
|
|
||||||
test("runIsLive 对活跃态返回 true", () => {
|
|
||||||
assert.equal(runIsLive("running"), true);
|
|
||||||
assert.equal(runIsLive("waiting_approval"), true);
|
|
||||||
});
|
|
||||||
test("completed run 的事件仍被折叠为时间线", () => {
|
|
||||||
const events: MafEvent[] = [
|
|
||||||
makeEvent({ seq: 1, event_type: "run.started", payload: {} }),
|
|
||||||
makeEvent({ seq: 2, event_type: "agent.message.delta", payload: { text: "Hello " } }),
|
|
||||||
makeEvent({ seq: 3, event_type: "agent.message.delta", payload: { text: "world" } }),
|
|
||||||
makeEvent({ seq: 4, event_type: "agent.message.completed", payload: { content: "Hello world" } }),
|
|
||||||
makeEvent({ seq: 5, event_type: "run.completed", payload: {} }),
|
|
||||||
];
|
|
||||||
const timeline = foldEventsToTimeline(events);
|
|
||||||
const kinds = timeline.map((e) => e.eventType);
|
|
||||||
assert.ok(kinds.includes("run.started"));
|
|
||||||
assert.ok(kinds.includes("run.completed"));
|
|
||||||
// delta 聚合为一条 message
|
|
||||||
const messages = timeline.filter((e) => e.kind === "message");
|
|
||||||
assert.equal(messages.length, 1);
|
|
||||||
assert.equal(messages[0].body, "Hello world");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 13. 事件翻页 mergeBySeq(真实导出,不再复制)─────────────────────────────
|
|
||||||
|
|
||||||
describe("事件翻页 mergeBySeq(真实导出)", () => {
|
|
||||||
test("两页合并无重复、升序", () => {
|
|
||||||
const page1 = Array.from({ length: 1000 }, (_, i) => makeEvent({ seq: i + 1 }));
|
|
||||||
const page2 = Array.from({ length: 1000 }, (_, i) => makeEvent({ seq: i + 1001 }));
|
|
||||||
let merged: MafEvent[] = [];
|
|
||||||
merged = mergeBySeq(merged, page1);
|
|
||||||
merged = mergeBySeq(merged, page2);
|
|
||||||
assert.equal(merged.length, 2000);
|
|
||||||
for (let i = 1; i < merged.length; i++) {
|
|
||||||
assert.ok(merged[i].seq >= merged[i - 1].seq);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
test("重叠页(网络重传)不产生重复", () => {
|
|
||||||
const page1 = Array.from({ length: 100 }, (_, i) => makeEvent({ seq: i + 1 }));
|
|
||||||
const page2 = Array.from({ length: 30 }, (_, i) => makeEvent({ seq: i + 91 }));
|
|
||||||
let merged: MafEvent[] = [];
|
|
||||||
merged = mergeBySeq(merged, page1);
|
|
||||||
merged = mergeBySeq(merged, page2);
|
|
||||||
assert.equal(merged.length, 120);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 14. buildMafFlowGraph 纯函数 ────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("buildMafFlowGraph 流程图构造", () => {
|
|
||||||
test("extractMafFlowGraph 解析 flow-json 块", () => {
|
|
||||||
const text = '回复\n```flow-json\n{"title":"T","nodes":[{"id":"n1","label":"任务A","type":"task"}]}\n```';
|
|
||||||
const g = extractMafFlowGraph(text);
|
|
||||||
assert.ok(g);
|
|
||||||
assert.equal(g!.nodes.length, 1);
|
|
||||||
assert.equal(g!.nodes[0].label, "任务A");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("无 flow-json → null", () => {
|
|
||||||
assert.equal(extractMafFlowGraph("普通文本无图"), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("checkpoint 有 confirmed_facts → 构造事实证据图", () => {
|
|
||||||
const checkpoint = {
|
|
||||||
fact_ledger: {
|
|
||||||
candidate_facts: [],
|
|
||||||
confirmed_facts: [
|
|
||||||
{ candidate_id: "f1", text: "已确认事实一", evidence_refs: [] },
|
|
||||||
],
|
|
||||||
evidence: [],
|
|
||||||
tool_results: [],
|
|
||||||
executed_actions: [],
|
|
||||||
pending_actions: [],
|
|
||||||
failed_or_unknown_actions: [],
|
|
||||||
conflicts: [],
|
|
||||||
proposed_actions: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const g = buildMafFlowGraph(checkpoint as any);
|
|
||||||
assert.ok(g);
|
|
||||||
assert.ok(g!.nodes.length >= 2); // root + fact
|
|
||||||
assert.equal(g!.title, "事实证据图");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("空 checkpoint → null(组件显示空态)", () => {
|
|
||||||
assert.equal(buildMafFlowGraph(null), null);
|
|
||||||
assert.equal(buildMafFlowGraph({}), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("超过 200 节点的超大流程图被截断到 200", () => {
|
|
||||||
const nodes = Array.from({ length: 1000 }, (_, i) => ({
|
|
||||||
id: `n${i}`,
|
|
||||||
label: `节点${i}`,
|
|
||||||
type: "task",
|
|
||||||
}));
|
|
||||||
const text = "```flow-json\n" + JSON.stringify({ title: "超大图", nodes }) + "\n```";
|
|
||||||
const g = extractMafFlowGraph(text);
|
|
||||||
assert.ok(g);
|
|
||||||
assert.ok(g!.truncated, "应标记 truncated");
|
|
||||||
assert.equal(g!.nodes.length, 200, "最多 200 节点");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("超长 label 和 detail 被截断", () => {
|
|
||||||
const longLabel = "A".repeat(500);
|
|
||||||
const longDetail = "D".repeat(5000);
|
|
||||||
const text =
|
|
||||||
"```flow-json\n" +
|
|
||||||
JSON.stringify({
|
|
||||||
title: "T",
|
|
||||||
nodes: [{ id: "n1", label: longLabel, type: "task", detail: longDetail }],
|
|
||||||
}) +
|
|
||||||
"\n```";
|
|
||||||
const g = extractMafFlowGraph(text);
|
|
||||||
assert.ok(g);
|
|
||||||
assert.equal(g!.nodes[0].label.length, 161); // 160 + …
|
|
||||||
assert.equal(g!.nodes[0].detail!.length, 2001); // 2000 + …
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 15. safeDisplayJson 二次脱敏(真实函数)──────────────────────────────────
|
|
||||||
|
|
||||||
describe("safeDisplayJson 统一脱敏", () => {
|
|
||||||
test("敏感键直接删除(不显示成 ***),值与键名均不泄漏", () => {
|
|
||||||
const result = safeDisplayJson({
|
|
||||||
title: "可显示",
|
|
||||||
target_key: "must-not-leak",
|
|
||||||
nested: {
|
|
||||||
token: "must-not-leak",
|
|
||||||
normal: "可显示",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert.match(result.text, /可显示/);
|
|
||||||
assert.doesNotMatch(result.text, /target_key|must-not-leak|token/);
|
|
||||||
assert.ok(result.hiddenCount >= 2);
|
|
||||||
assert.equal(result.truncated, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("只有敏感字段的对象 → text 为空(调用方应隐藏面板)", () => {
|
|
||||||
const result = safeDisplayJson({ api_key: "k", password: "p" });
|
|
||||||
assert.equal(result.text, "");
|
|
||||||
assert.equal(result.hiddenCount, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("null / 空对象 → text 为空", () => {
|
|
||||||
assert.equal(safeDisplayJson(null).text, "");
|
|
||||||
assert.equal(safeDisplayJson({}).text, "");
|
|
||||||
assert.equal(safeDisplayJson(undefined).text, "");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("数组:递归脱敏 + 上限截断", () => {
|
|
||||||
const big = Array.from({ length: 30 }, (_, i) => ({ id: i, secret: `s${i}` }));
|
|
||||||
const result = safeDisplayJson(big);
|
|
||||||
assert.ok(result.truncated); // 超过 20 项截断
|
|
||||||
// 前 20 项的 secret 被删(hiddenCount=20);后 10 项整体被截断(由 truncated 体现)
|
|
||||||
assert.equal(result.hiddenCount, 20);
|
|
||||||
assert.doesNotMatch(result.text, /secret|s\d/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("深层对象:超过最大深度 → truncated + 占位", () => {
|
|
||||||
const deep = { a: { b: { c: { d: { e: { f: "too deep" } } } } } };
|
|
||||||
const result = safeDisplayJson(deep);
|
|
||||||
assert.ok(result.truncated);
|
|
||||||
assert.match(result.text, /内容层级过深/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("超长字符串:截断到 120 字 + …", () => {
|
|
||||||
const long = "x".repeat(500);
|
|
||||||
const result = safeDisplayJson({ note: long });
|
|
||||||
assert.ok(result.truncated);
|
|
||||||
assert.doesNotMatch(result.text, /x{121}/); // 不超过 120
|
|
||||||
assert.match(result.text, /…/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("单对象字段数上限 30 → 截断 + 计数准确", () => {
|
|
||||||
const obj: Record<string, unknown> = {};
|
|
||||||
for (let i = 0; i < 50; i++) obj[`field${i}`] = i;
|
|
||||||
const result = safeDisplayJson(obj);
|
|
||||||
assert.ok(result.truncated);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("键名忽略大小写匹配", () => {
|
|
||||||
const result = safeDisplayJson({ TOKEN: "t", Authorization: "a", Cookie: "c", safe: "ok" });
|
|
||||||
assert.equal(result.hiddenCount, 3);
|
|
||||||
assert.match(result.text, /ok/);
|
|
||||||
// 敏感键名与值都不应出现
|
|
||||||
assert.doesNotMatch(result.text, /TOKEN|Authorization|Cookie|"(t|a|c)"/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 12. 边界:MAF 目录无旧会商 import ────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("MAF 目录边界:不依赖旧会商", () => {
|
|
||||||
// 扫描 src/maf-roundtable 下所有源文件,断言不含旧会商目录引用。
|
|
||||||
// 根目录用 process.cwd()/src/maf-roundtable(验收命令从 frontend-web 运行),
|
|
||||||
// 不能用 import.meta.url——打包后它指向 TEMP 文件,递归会走到系统受保护目录。
|
|
||||||
const mafRoot = join(process.cwd(), "src", "maf-roundtable");
|
|
||||||
const forbidden = ["roundtable-planning", "@/roundtable", "from \"@/roundtable"];
|
|
||||||
|
|
||||||
function walk(dir: string, out: string[]) {
|
|
||||||
let entries: Dirent[];
|
|
||||||
try {
|
|
||||||
entries = readdirSync(dir, { withFileTypes: true });
|
|
||||||
} catch {
|
|
||||||
// 权限不足 / 不可读目录直接跳过(避免 Windows 上的 EPERM 导致测试 flaky)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const entry of entries) {
|
|
||||||
const name = String(entry.name);
|
|
||||||
const p = join(dir, name);
|
|
||||||
let st;
|
|
||||||
try {
|
|
||||||
st = statSync(p);
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (st.isDirectory()) {
|
|
||||||
if (name === "__tests__") continue; // 测试目录本身允许提及(边界断言)
|
|
||||||
if (name === "node_modules") continue;
|
|
||||||
walk(p, out);
|
|
||||||
} else if (st.isFile()) {
|
|
||||||
out.push(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkForbidden(filePath: string): string[] {
|
|
||||||
const ext = extname(filePath);
|
|
||||||
if (![".ts", ".tsx", ".js", ".jsx", ".mjs"].includes(ext)) return [];
|
|
||||||
const text = readFileSync(filePath, "utf8");
|
|
||||||
const hits: string[] = [];
|
|
||||||
for (const needle of forbidden) {
|
|
||||||
if (text.includes(needle)) hits.push(needle);
|
|
||||||
}
|
|
||||||
return hits;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("src/maf-roundtable 源文件不含 roundtable-planning / @/roundtable", () => {
|
|
||||||
if (!existsSync(mafRoot)) {
|
|
||||||
assert.fail(`MAF 源目录不存在:${mafRoot}(请从 frontend-web 目录运行测试)`);
|
|
||||||
}
|
|
||||||
const files: string[] = [];
|
|
||||||
walk(mafRoot, files);
|
|
||||||
assert.ok(files.length > 0, "应至少扫描到 MAF 源文件");
|
|
||||||
const violations: string[] = [];
|
|
||||||
for (const f of files) {
|
|
||||||
const hits = checkForbidden(f);
|
|
||||||
if (hits.length > 0) {
|
|
||||||
violations.push(`${relative(mafRoot, f)}: ${hits.join(", ")}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert.deepEqual(
|
|
||||||
violations,
|
|
||||||
[],
|
|
||||||
`MAF 源文件不得引用旧会商目录,发现:\n${violations.join("\n")}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
/**
|
|
||||||
* useMafEvents 挂载测试(vitest + jsdom + Testing Library)。
|
|
||||||
*
|
|
||||||
* 覆盖(提交 2):慢请求 A 进行中时切到 run B,再 resolve A,断言屏幕上只出现 B 的事件
|
|
||||||
* (代次隔离:旧 run 的慢响应被丢弃,不污染新 run)。
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
const pendingByRun = new Map<string, { resolve: (v: unknown) => void }>();
|
|
||||||
|
|
||||||
vi.mock("../api/events", () => ({
|
|
||||||
listEvents: vi.fn((runId: string) => {
|
|
||||||
let resolveFn!: (v: unknown) => void;
|
|
||||||
const promise = new Promise((res) => {
|
|
||||||
resolveFn = res;
|
|
||||||
});
|
|
||||||
pendingByRun.set(runId, { resolve: resolveFn });
|
|
||||||
return promise;
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { useMafEvents } from "../hooks/use-maf-events";
|
|
||||||
import type { MafEvent } from "../api/types";
|
|
||||||
|
|
||||||
function makeEv(seq: number, runId: string): MafEvent {
|
|
||||||
return {
|
|
||||||
id: `ev-${runId}-${seq}`,
|
|
||||||
session_id: "s1",
|
|
||||||
run_id: runId,
|
|
||||||
seq,
|
|
||||||
event_type: "agent.message.delta",
|
|
||||||
agent_id: "a",
|
|
||||||
turn_index: 0,
|
|
||||||
payload: { text: `${runId}-${seq}` },
|
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 测试桩:用 hook 渲染当前 run 的事件 seq 列表。 */
|
|
||||||
function Harness({ runId }: { runId: string }) {
|
|
||||||
const { events } = useMafEvents(runId, { enabled: true, polling: false });
|
|
||||||
return (
|
|
||||||
<div data-testid="out">
|
|
||||||
{events.map((e) => (
|
|
||||||
<span key={e.seq}>{e.seq},</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("useMafEvents 代次隔离", () => {
|
|
||||||
it("慢请求 A 进行中切到 B,再 resolve A,屏幕只出现 B 的事件", async () => {
|
|
||||||
// 挂载 run A:listEvents("A") 返回挂起的 promise
|
|
||||||
const { rerender } = render(<Harness runId="A" />);
|
|
||||||
expect(pendingByRun.has("A")).toBe(true);
|
|
||||||
|
|
||||||
// 切到 run B:触发新一代次;listEvents("B") 返回可解析的 promise
|
|
||||||
rerender(<Harness runId="B" />);
|
|
||||||
expect(pendingByRun.has("B")).toBe(true);
|
|
||||||
|
|
||||||
// B 的历史先到达:事件 1,2,3
|
|
||||||
pendingByRun.get("B")!.resolve({
|
|
||||||
events: [makeEv(1, "B"), makeEv(2, "B"), makeEv(3, "B")],
|
|
||||||
latest_seq: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const txt = screen.getByTestId("out").textContent ?? "";
|
|
||||||
expect(txt).toContain("1,");
|
|
||||||
expect(txt).toContain("2,");
|
|
||||||
expect(txt).toContain("3,");
|
|
||||||
});
|
|
||||||
|
|
||||||
// 现在 resolve A 的慢响应(事件 99):必须被丢弃,不污染 B 的视图
|
|
||||||
pendingByRun.get("A")!.resolve({
|
|
||||||
events: [makeEv(99, "A")],
|
|
||||||
latest_seq: 99,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 等一个微任务轮次,确保 A 的回调已执行(并被代次守卫丢弃)
|
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
|
||||||
const txt = screen.getByTestId("out").textContent ?? "";
|
|
||||||
expect(txt).not.toContain("99");
|
|
||||||
// B 的三个事件仍然在
|
|
||||||
expect(txt).toContain("1,2,3,");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 审批 —— `/runs/{id}/approvals`、`/approvals/{id}/decision`、`/runs/{id}/actions`。
|
|
||||||
*
|
|
||||||
* decide 成功后 invalidate approvals + actions + events(事件轮询会自然看到
|
|
||||||
* action.approved/rejected,但显式 invalidate 让卡片状态即时翻转)。
|
|
||||||
*
|
|
||||||
* P0-4:hook 签名改为 (runId, isLive)。run 运行期间每 2s 刷新一次;终态停止。
|
|
||||||
* 不能以"当前有没有 pending 审批"决定是否刷新——审批通常是进入页面后才由工作流产生的。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { BASE, getJson, sendJson } from "./client";
|
|
||||||
import type {
|
|
||||||
MafAction,
|
|
||||||
MafActionListResponse,
|
|
||||||
MafApproval,
|
|
||||||
MafApprovalDecisionRequest,
|
|
||||||
MafApprovalListResponse,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
const KEY = "maf-roundtable";
|
|
||||||
|
|
||||||
/** live 时审批/动作轮询间隔(ms)。 */
|
|
||||||
const LIVE_POLL_INTERVAL = 2000;
|
|
||||||
|
|
||||||
// ── 裸 fetch 函数 ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function listApprovals(runId: string): Promise<MafApprovalListResponse> {
|
|
||||||
return getJson<MafApprovalListResponse>(
|
|
||||||
`${BASE()}/runs/${runId}/approvals`,
|
|
||||||
"获取审批列表",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listActions(runId: string): Promise<MafActionListResponse> {
|
|
||||||
return getJson<MafActionListResponse>(
|
|
||||||
`${BASE()}/runs/${runId}/actions`,
|
|
||||||
"获取动作列表",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function decideApproval(
|
|
||||||
approvalId: string,
|
|
||||||
body: MafApprovalDecisionRequest,
|
|
||||||
): Promise<MafApproval> {
|
|
||||||
return sendJson<MafApproval>(
|
|
||||||
`${BASE()}/approvals/${approvalId}/decision`,
|
|
||||||
"POST",
|
|
||||||
body,
|
|
||||||
"提交审批决策",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── React Query hooks ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 审批列表。run live 时每 LIVE_POLL_INTERVAL 刷新;终态停止。
|
|
||||||
* 不能以"当前有没有 pending"决定是否刷新——审批是进入页面后才产生的。
|
|
||||||
*/
|
|
||||||
export function useMafApprovals(runId: string | undefined, isLive: boolean) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [KEY, "runs", runId, "approvals"],
|
|
||||||
queryFn: () => listApprovals(runId!),
|
|
||||||
enabled: !!runId,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchInterval: isLive ? LIVE_POLL_INTERVAL : false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 动作列表。run live 时每 LIVE_POLL_INTERVAL 刷新;终态停止。
|
|
||||||
*/
|
|
||||||
export function useMafActions(runId: string | undefined, isLive: boolean) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [KEY, "runs", runId, "actions"],
|
|
||||||
queryFn: () => listActions(runId!),
|
|
||||||
enabled: !!runId,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchInterval: isLive ? LIVE_POLL_INTERVAL : false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMafApprovalActions(runId: string | undefined) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return {
|
|
||||||
decide: useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
approvalId,
|
|
||||||
decision,
|
|
||||||
}: {
|
|
||||||
approvalId: string;
|
|
||||||
decision: "approved" | "rejected";
|
|
||||||
}) => decideApproval(approvalId, { decision }),
|
|
||||||
onSuccess: () => {
|
|
||||||
if (!runId) return;
|
|
||||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", runId, "approvals"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", runId, "actions"] });
|
|
||||||
// run 状态会因 resume 改变(approved → running),强制下一次轮询立即生效。
|
|
||||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", runId] });
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 辅助:按 action_id 把 approval 和 action 关联起来 ─────────────────────
|
|
||||||
|
|
||||||
/** 把 pending 审批按 action_id 索引,便于卡片查找对应 action 的工具名/参数。 */
|
|
||||||
export function indexApprovalsByAction(
|
|
||||||
approvals: MafApproval[] | undefined,
|
|
||||||
): Map<string, MafApproval> {
|
|
||||||
const map = new Map<string, MafApproval>();
|
|
||||||
for (const a of approvals ?? []) {
|
|
||||||
if (a.status === "pending" && a.action_id) map.set(a.action_id, a);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按 id 索引 action,供审批卡片取 tool_name/arguments。 */
|
|
||||||
export function indexActions(
|
|
||||||
actions: MafAction[] | undefined,
|
|
||||||
): Map<string, MafAction> {
|
|
||||||
const map = new Map<string, MafAction>();
|
|
||||||
for (const a of actions ?? []) map.set(a.id, map.get(a.id) ?? a);
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 多智能体会商 API 客户端地基。
|
|
||||||
*
|
|
||||||
* 复用全局 `apiFetch`(自动注入 Authorization + credentials:include),这里只做:
|
|
||||||
* - 拼 URL(`${getBackendBaseURL()}/api/maf-roundtable/...`)
|
|
||||||
* - 统一 `res.ok` 检查 + detail 提取(仿 task-buttons 的 readJson)
|
|
||||||
* - JSON 解析
|
|
||||||
*
|
|
||||||
* 不在此做 snake_case ⇄ camelCase 转换——后端即 snake_case,类型层直传(见 types.ts)。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiFetch } from "@/core/api/fetch-client";
|
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
|
||||||
|
|
||||||
/** MAF roundtable 后端根路径(无尾斜杠)。 */
|
|
||||||
export const BASE = (): string => `${getBackendBaseURL()}/api/maf-roundtable`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查 Response 并解析 JSON;失败时抛出带后端 `detail` 的 Error。
|
|
||||||
*
|
|
||||||
* - 404 = 不存在或非本人(后端统一返回 404 防止泄漏他人资源存在性)
|
|
||||||
* - 403 = 非所有者写(admin 也不能写别人的)
|
|
||||||
* - 503 = 功能关闭 / store 未就绪
|
|
||||||
*
|
|
||||||
* 这里不区分状态码语义,统一把 detail 透传给上层 toast/错误展示。
|
|
||||||
*/
|
|
||||||
export async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => null);
|
|
||||||
const detail =
|
|
||||||
data && typeof data === "object" && "detail" in data
|
|
||||||
? String((data as { detail: unknown }).detail)
|
|
||||||
: `${ctx} 失败(HTTP ${res.status})`;
|
|
||||||
throw new Error(detail);
|
|
||||||
}
|
|
||||||
return (await res.json()) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET JSON 的快捷封装。 */
|
|
||||||
export async function getJson<T>(url: string, ctx: string): Promise<T> {
|
|
||||||
const res = await apiFetch(url, { method: "GET" });
|
|
||||||
return readJson<T>(res, ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 发送 JSON body 的快捷封装。 */
|
|
||||||
export async function sendJson<T>(
|
|
||||||
url: string,
|
|
||||||
method: "POST" | "PUT" | "DELETE" | "PATCH",
|
|
||||||
body: unknown,
|
|
||||||
ctx: string,
|
|
||||||
): Promise<T> {
|
|
||||||
const res = await apiFetch(url, {
|
|
||||||
method,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
|
||||||
});
|
|
||||||
return readJson<T>(res, ctx);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 事件断线补拉 —— `GET /runs/{id}/events?after_seq=N&limit=...`。
|
|
||||||
*
|
|
||||||
* 这是**裸 fetch 函数**(非 hook):cursor `after_seq` 需在每次拉取后推进,
|
|
||||||
* useQuery 的 queryFn 不持有「上次 latest_seq」,所以轮询逻辑放在
|
|
||||||
* hooks/use-maf-events.ts(自定义 interval hook),这里只负责一次请求。
|
|
||||||
*
|
|
||||||
* 后端契约(events.py list_events):返回 `seq > after_seq` 升序;payload 已脱敏。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { BASE, getJson } from "./client";
|
|
||||||
import type { MafEventListResponse } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 拉取 `after_seq` 之后的事件(不含 after_seq 本身)。
|
|
||||||
*
|
|
||||||
* @param runId run id
|
|
||||||
* @param afterSeq 上一次拉到的 latest_seq;首次传 0 补拉全部历史
|
|
||||||
* @param limit 单次上限,默认 1000、最大 10000(后端 Query 约束)
|
|
||||||
*/
|
|
||||||
export async function listEvents(
|
|
||||||
runId: string,
|
|
||||||
afterSeq = 0,
|
|
||||||
limit = 1000,
|
|
||||||
): Promise<MafEventListResponse> {
|
|
||||||
const qs = new URLSearchParams({
|
|
||||||
after_seq: String(afterSeq),
|
|
||||||
limit: String(limit),
|
|
||||||
});
|
|
||||||
return getJson<MafEventListResponse>(
|
|
||||||
`${BASE()}/runs/${runId}/events?${qs.toString()}`,
|
|
||||||
"获取会商事件",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF run 生命周期 + health 探测 —— `/sessions/{id}/runs`、`/runs/{id}`、`/health`。
|
|
||||||
*
|
|
||||||
* 轮询策略:
|
|
||||||
* - `useMafRun` 用函数式 `refetchInterval`:run 仍 live(pending/running/
|
|
||||||
* waiting_approval/cancelling/cancel_failed)时每 1.8s 重拉;终态停。
|
|
||||||
* 镜像 core/scheduled-tasks/hooks.ts:105-115 的模式。
|
|
||||||
* - events 轮询不在此处,见 hooks/use-maf-events.ts(cursor 推进需自定义 hook)。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { BASE, getJson, sendJson } from "./client";
|
|
||||||
import { runIsLive, type MafHealthResponse, type MafRun, type MafRunListResponse } from "./types";
|
|
||||||
|
|
||||||
const KEY = "maf-roundtable";
|
|
||||||
|
|
||||||
/** run 轮询间隔(ms)。1.8s —— 平衡实时感与后端负载。 */
|
|
||||||
export const RUN_POLL_INTERVAL = 1800;
|
|
||||||
|
|
||||||
// ── health ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function getMafHealth(): Promise<MafHealthResponse> {
|
|
||||||
return getJson<MafHealthResponse>(`${BASE()}/health`, "获取 MAF 健康状态");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** health 探测:决定入口页是否显示「功能未启用」空态。失败时视为 disabled。 */
|
|
||||||
export function useMafHealth() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [KEY, "health"],
|
|
||||||
queryFn: getMafHealth,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
staleTime: 60_000,
|
|
||||||
retry: false, // 探测失败不重试,直接走 disabled 空态
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 裸 fetch 函数 ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function getRun(runId: string): Promise<MafRun> {
|
|
||||||
return getJson<MafRun>(`${BASE()}/runs/${runId}`, "获取会商 run");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listRuns(sessionId: string): Promise<MafRunListResponse> {
|
|
||||||
return getJson<MafRunListResponse>(
|
|
||||||
`${BASE()}/sessions/${sessionId}/runs`,
|
|
||||||
"获取 run 列表",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createRun(
|
|
||||||
sessionId: string,
|
|
||||||
workflowVersion?: string,
|
|
||||||
): Promise<MafRun> {
|
|
||||||
return sendJson<MafRun>(
|
|
||||||
`${BASE()}/sessions/${sessionId}/runs`,
|
|
||||||
"POST",
|
|
||||||
workflowVersion ? { workflow_version: workflowVersion } : {},
|
|
||||||
"启动会商 run",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelRun(runId: string): Promise<MafRun> {
|
|
||||||
return sendJson<MafRun>(`${BASE()}/runs/${runId}/cancel`, "POST", {}, "取消会商");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resumeRun(runId: string): Promise<MafRun> {
|
|
||||||
return sendJson<MafRun>(`${BASE()}/runs/${runId}/resume`, "POST", {}, "恢复会商");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function recoverRun(runId: string): Promise<MafRun> {
|
|
||||||
return sendJson<MafRun>(`${BASE()}/runs/${runId}/recover`, "POST", {}, "重试会商");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── React Query hooks ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 单个 run —— live 时每 1.8s 轮询,终态停。
|
|
||||||
* 这是事件轮询的「开关」:父组件用 `runIsLive(run.status)` 决定是否拉事件。
|
|
||||||
*/
|
|
||||||
export function useMafRun(runId: string | undefined | null) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [KEY, "runs", runId],
|
|
||||||
queryFn: () => getRun(runId!),
|
|
||||||
enabled: !!runId,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchInterval: (query) =>
|
|
||||||
runIsLive(query.state.data?.status) ? RUN_POLL_INTERVAL : false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMafRuns(sessionId: string | undefined) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [KEY, "sessions", sessionId, "runs"],
|
|
||||||
queryFn: () => listRuns(sessionId!),
|
|
||||||
enabled: !!sessionId,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
staleTime: 10_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMafRunActions() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return {
|
|
||||||
start: useMutation({
|
|
||||||
mutationFn: ({ sessionId, workflowVersion }: { sessionId: string; workflowVersion?: string }) =>
|
|
||||||
createRun(sessionId, workflowVersion),
|
|
||||||
onSuccess: (run) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [KEY, "sessions", run.session_id, "runs"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", run.id] });
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
cancel: useMutation({
|
|
||||||
mutationFn: cancelRun,
|
|
||||||
onSuccess: (run) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", run.id] });
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
resume: useMutation({
|
|
||||||
mutationFn: resumeRun,
|
|
||||||
onSuccess: (run) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", run.id] });
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
recover: useMutation({
|
|
||||||
mutationFn: recoverRun,
|
|
||||||
onSuccess: (run) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", run.id] });
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 会话 CRUD —— `/sessions`。
|
|
||||||
*
|
|
||||||
* 镜像 strategy-components/api/task-buttons.ts 的 colocated hook 模式:
|
|
||||||
* queryKey 用嵌套数组;mutation 成功后 invalidate 同前缀。
|
|
||||||
* snake_case 直传(见 types.ts)。
|
|
||||||
*
|
|
||||||
* 唯一依据:offline-backend-20260512/backend/app/maf_roundtable/schemas.py
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { BASE, getJson, sendJson } from "./client";
|
|
||||||
import type {
|
|
||||||
MafSession,
|
|
||||||
MafSessionListResponse,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
const KEY = "maf-roundtable";
|
|
||||||
|
|
||||||
// ── 请求体(对齐 schemas.py CreateSessionRequest / UpdateSessionRequest)──
|
|
||||||
|
|
||||||
export interface ParticipantInput {
|
|
||||||
agent_id: string;
|
|
||||||
display_name?: string;
|
|
||||||
role: "manager" | "seat";
|
|
||||||
sort_order?: number;
|
|
||||||
excluded_tools?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateSessionBody {
|
|
||||||
title: string;
|
|
||||||
task_text: string;
|
|
||||||
task_payload?: Record<string, unknown> | null;
|
|
||||||
mode?: "structured" | "group_chat";
|
|
||||||
model_name?: string | null;
|
|
||||||
reasoning_effort?: string | null;
|
|
||||||
manager_agent_id?: string | null;
|
|
||||||
max_rounds?: number;
|
|
||||||
write_policy?: "auto" | "confirm_high_risk" | "confirm_all_writes";
|
|
||||||
participants: ParticipantInput[];
|
|
||||||
excluded_tools?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateSessionBody {
|
|
||||||
title?: string;
|
|
||||||
task_text?: string;
|
|
||||||
task_payload?: Record<string, unknown> | null;
|
|
||||||
mode?: "structured" | "group_chat";
|
|
||||||
model_name?: string | null;
|
|
||||||
reasoning_effort?: string | null;
|
|
||||||
manager_agent_id?: string | null;
|
|
||||||
max_rounds?: number;
|
|
||||||
write_policy?: "auto" | "confirm_high_risk" | "confirm_all_writes";
|
|
||||||
participants?: ParticipantInput[];
|
|
||||||
fact_ledger?: Record<string, unknown> | null;
|
|
||||||
final_result?: Record<string, unknown> | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 裸 fetch 函数 ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function listSessions(params?: {
|
|
||||||
status?: string;
|
|
||||||
mode?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}): Promise<MafSessionListResponse> {
|
|
||||||
const qs = new URLSearchParams();
|
|
||||||
if (params?.status) qs.set("status", params.status);
|
|
||||||
if (params?.mode) qs.set("mode", params.mode);
|
|
||||||
if (params?.limit != null) qs.set("limit", String(params.limit));
|
|
||||||
if (params?.offset != null) qs.set("offset", String(params.offset));
|
|
||||||
const query = qs.toString();
|
|
||||||
return getJson<MafSessionListResponse>(
|
|
||||||
`${BASE()}/sessions${query ? `?${query}` : ""}`,
|
|
||||||
"获取会话列表",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSession(id: string): Promise<MafSession> {
|
|
||||||
return getJson<MafSession>(`${BASE()}/sessions/${id}`, "获取会话");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createSession(body: CreateSessionBody): Promise<MafSession> {
|
|
||||||
return sendJson<MafSession>(`${BASE()}/sessions`, "POST", body, "创建会话");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateSession(
|
|
||||||
id: string,
|
|
||||||
body: UpdateSessionBody,
|
|
||||||
): Promise<MafSession> {
|
|
||||||
return sendJson<MafSession>(`${BASE()}/sessions/${id}`, "PUT", body, "更新会话");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteSession(id: string): Promise<void> {
|
|
||||||
await sendJson<{ deleted: boolean }>(
|
|
||||||
`${BASE()}/sessions/${id}`,
|
|
||||||
"DELETE",
|
|
||||||
undefined,
|
|
||||||
"删除会话",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── React Query hooks ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function useMafSessions(params?: {
|
|
||||||
status?: string;
|
|
||||||
mode?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [KEY, "sessions", params],
|
|
||||||
queryFn: () => listSessions(params),
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
staleTime: 30_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMafSession(id: string | undefined) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [KEY, "sessions", id],
|
|
||||||
queryFn: () => getSession(id!),
|
|
||||||
enabled: !!id,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
staleTime: 30_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMafSessionActions() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const invalidate = () =>
|
|
||||||
queryClient.invalidateQueries({ queryKey: [KEY, "sessions"] });
|
|
||||||
return {
|
|
||||||
create: useMutation({ mutationFn: createSession, onSuccess: invalidate }),
|
|
||||||
update: useMutation({
|
|
||||||
mutationFn: ({ id, body }: { id: string; body: UpdateSessionBody }) =>
|
|
||||||
updateSession(id, body),
|
|
||||||
onSuccess: invalidate,
|
|
||||||
}),
|
|
||||||
remove: useMutation({ mutationFn: deleteSession, onSuccess: invalidate }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 多智能体会商 —— 后端 `/api/maf-roundtable` 的 TypeScript 类型。
|
|
||||||
*
|
|
||||||
* 唯一依据:offline-backend-20260512/backend/app/maf_roundtable/schemas.py。
|
|
||||||
* 后端持久化即 snake_case(见 packages/harness/deerflow/persistence/maf_roundtable),
|
|
||||||
* 这里**直传不转换**:与后端字段一一对应,避免边界处多一层映射。
|
|
||||||
* 所有 `payload` 已在后端写入时脱敏(`sanitize_payload`),前端拿到的就是可渲染的。
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ── health(GET /health)───────────────────────────────────────────────────
|
|
||||||
export interface MafHealthResponse {
|
|
||||||
enabled: boolean;
|
|
||||||
store_available: boolean;
|
|
||||||
default_mode: string;
|
|
||||||
default_write_policy: string;
|
|
||||||
default_max_rounds: number;
|
|
||||||
admin_diagnostic_access: boolean;
|
|
||||||
write_guard_enabled: boolean;
|
|
||||||
write_guard_ready: boolean;
|
|
||||||
action_secret_configured: boolean;
|
|
||||||
write_guard_status:
|
|
||||||
| "disabled"
|
|
||||||
| "provider_unavailable"
|
|
||||||
| "missing_secret"
|
|
||||||
| "ready";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── session(/sessions)────────────────────────────────────────────────────
|
|
||||||
export type MafSessionStatus =
|
|
||||||
| "draft"
|
|
||||||
| "running"
|
|
||||||
| "waiting_approval"
|
|
||||||
| "waiting_input"
|
|
||||||
| "completed"
|
|
||||||
| "failed"
|
|
||||||
| "cancelled";
|
|
||||||
|
|
||||||
export interface MafParticipant {
|
|
||||||
id: string;
|
|
||||||
session_id: string;
|
|
||||||
agent_id: string;
|
|
||||||
display_name: string;
|
|
||||||
role: "manager" | "seat";
|
|
||||||
sort_order: number;
|
|
||||||
thread_id: string | null;
|
|
||||||
status: string;
|
|
||||||
last_turn_index: number | null;
|
|
||||||
capability_snapshot: Record<string, unknown> | null;
|
|
||||||
created_at: string | null;
|
|
||||||
updated_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MafSession {
|
|
||||||
id: string;
|
|
||||||
user_id: string;
|
|
||||||
title: string;
|
|
||||||
task_text: string;
|
|
||||||
task_payload: Record<string, unknown> | null;
|
|
||||||
mode: "structured" | "group_chat";
|
|
||||||
status: MafSessionStatus;
|
|
||||||
model_name: string | null;
|
|
||||||
reasoning_effort: string | null;
|
|
||||||
manager_agent_id: string | null;
|
|
||||||
max_rounds: number;
|
|
||||||
write_policy: "auto" | "confirm_high_risk" | "confirm_all_writes";
|
|
||||||
capability_snapshot: Record<string, unknown> | null;
|
|
||||||
fact_ledger: Record<string, unknown> | null;
|
|
||||||
final_result: Record<string, unknown> | null;
|
|
||||||
participants: MafParticipant[];
|
|
||||||
created_at: string | null;
|
|
||||||
updated_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MafSessionListResponse {
|
|
||||||
sessions: MafSession[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── run(/sessions/{id}/runs, /runs/{id})──────────────────────────────────
|
|
||||||
/**
|
|
||||||
* Run 状态机。`runIsLive` 判定为 true 的状态会继续轮询;其余(completed/failed/
|
|
||||||
* cancelled)是终态,停止轮询。cancelling / cancel_failed 是非终态中间态,仍需轮询。
|
|
||||||
*/
|
|
||||||
export type MafRunStatus =
|
|
||||||
| "pending"
|
|
||||||
| "running"
|
|
||||||
| "waiting_approval"
|
|
||||||
| "completed"
|
|
||||||
| "failed"
|
|
||||||
| "cancelled"
|
|
||||||
| "cancelling"
|
|
||||||
| "cancel_failed";
|
|
||||||
|
|
||||||
export interface MafRun {
|
|
||||||
id: string;
|
|
||||||
session_id: string;
|
|
||||||
status: MafRunStatus;
|
|
||||||
workflow_version: string | null;
|
|
||||||
/**
|
|
||||||
* MAF 工作流检查点。结构见 RoundtableState:含 fact_ledger / final_result /
|
|
||||||
* seat_outcomes / current_round 等。这里用宽松类型——前端只读其中几个已知键。
|
|
||||||
*/
|
|
||||||
maf_checkpoint: MafCheckpoint | null;
|
|
||||||
last_event_seq: number;
|
|
||||||
started_at: string | null;
|
|
||||||
finished_at: string | null;
|
|
||||||
error_code: string | null;
|
|
||||||
error_message_safe: string | null;
|
|
||||||
token_usage: Record<string, number> | null;
|
|
||||||
latency_ms: number | null;
|
|
||||||
created_at: string | null;
|
|
||||||
updated_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MafRunListResponse {
|
|
||||||
runs: MafRun[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── checkpoint 内部结构(maf_checkpoint,宽松类型)──────────────────────────
|
|
||||||
/** 事实账本(fact_ledger.empty_ledger 的形状)。 */
|
|
||||||
export interface MafFactLedger {
|
|
||||||
candidate_facts: MafCandidateFact[];
|
|
||||||
confirmed_facts: MafConfirmedFact[];
|
|
||||||
evidence: MafEvidence[];
|
|
||||||
tool_results: MafToolResult[];
|
|
||||||
executed_actions: MafLedgerAction[];
|
|
||||||
pending_actions: MafLedgerAction[];
|
|
||||||
failed_or_unknown_actions: MafLedgerAction[];
|
|
||||||
conflicts: MafLedgerConflict[];
|
|
||||||
proposed_actions: MafLedgerAction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MafCandidateFact {
|
|
||||||
candidate_id: string;
|
|
||||||
text: string;
|
|
||||||
agent_id?: string;
|
|
||||||
source?: string;
|
|
||||||
confirmed?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MafConfirmedFact {
|
|
||||||
candidate_id: string;
|
|
||||||
text: string;
|
|
||||||
agent_id?: string;
|
|
||||||
source?: string;
|
|
||||||
confirmed_by?: string;
|
|
||||||
evidence_refs: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MafEvidence {
|
|
||||||
ref?: string;
|
|
||||||
text?: string;
|
|
||||||
agent_id?: string;
|
|
||||||
[k: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MafToolResult {
|
|
||||||
tool_name: string;
|
|
||||||
status: string; // "completed" | "waiting_approval" | "failed" | "unknown"
|
|
||||||
agent_id?: string;
|
|
||||||
turn_index?: number;
|
|
||||||
detail?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MafLedgerAction {
|
|
||||||
action_id?: string;
|
|
||||||
tool_name?: string;
|
|
||||||
status?: string;
|
|
||||||
agent_id?: string;
|
|
||||||
external_object_id?: string;
|
|
||||||
text?: string;
|
|
||||||
[k: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MafLedgerConflict {
|
|
||||||
text: string;
|
|
||||||
agent_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 席位产出(maf_checkpoint.seat_outcomes 的 value)。 */
|
|
||||||
export interface MafSeatOutcome {
|
|
||||||
status: string; // "completed" | "running" | "failed" | ...
|
|
||||||
execution_stage?: string;
|
|
||||||
agent_id?: string;
|
|
||||||
[k: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MafFinalResult {
|
|
||||||
content: string;
|
|
||||||
agent_id?: string;
|
|
||||||
partial_failure?: boolean;
|
|
||||||
degraded?: boolean;
|
|
||||||
note?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MafCheckpoint {
|
|
||||||
fact_ledger?: MafFactLedger;
|
|
||||||
seat_outcomes?: Record<string, MafSeatOutcome>;
|
|
||||||
final_result?: MafFinalResult;
|
|
||||||
current_round?: number;
|
|
||||||
seat_agent_ids?: string[];
|
|
||||||
[k: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── event(/runs/{id}/events)──────────────────────────────────────────────
|
|
||||||
/**
|
|
||||||
* 事件分类(见后端 app/maf_roundtable/event_normalizer.py)。
|
|
||||||
* payload 已脱敏,结构因 event_type 而异,这里用 Record 宽松承载。
|
|
||||||
*/
|
|
||||||
export type MafEventType =
|
|
||||||
| "run.started"
|
|
||||||
| "run.completed"
|
|
||||||
| "run.failed"
|
|
||||||
| "run.cancelled"
|
|
||||||
| "agent.turn_started"
|
|
||||||
| "agent.message.delta"
|
|
||||||
| "agent.message.completed"
|
|
||||||
| "tool.requested"
|
|
||||||
| "tool.started"
|
|
||||||
| "tool.completed"
|
|
||||||
| "tool.failed"
|
|
||||||
| "skill.selected"
|
|
||||||
| "skill.started"
|
|
||||||
| "skill.completed"
|
|
||||||
| "skill.failed"
|
|
||||||
| "artifact.created"
|
|
||||||
| "action.waiting_approval"
|
|
||||||
| "action.approved"
|
|
||||||
| "action.rejected"
|
|
||||||
| "workflow.checkpointed"
|
|
||||||
| string; // 兜底:后端可能新增,前端不强断
|
|
||||||
|
|
||||||
export interface MafEvent {
|
|
||||||
id: string;
|
|
||||||
session_id: string;
|
|
||||||
run_id: string;
|
|
||||||
seq: number;
|
|
||||||
event_type: MafEventType;
|
|
||||||
agent_id: string | null;
|
|
||||||
turn_index: number | null;
|
|
||||||
payload: Record<string, unknown> | null;
|
|
||||||
created_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MafEventListResponse {
|
|
||||||
events: MafEvent[];
|
|
||||||
latest_seq: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── action(/runs/{id}/actions, /actions/{id})─────────────────────────────
|
|
||||||
export type MafActionStatus =
|
|
||||||
| "pending"
|
|
||||||
| "waiting_approval"
|
|
||||||
| "approved"
|
|
||||||
| "rejected"
|
|
||||||
| "executed"
|
|
||||||
| "failed"
|
|
||||||
| "unknown"
|
|
||||||
| "expired"
|
|
||||||
| string;
|
|
||||||
|
|
||||||
export interface MafAction {
|
|
||||||
id: string;
|
|
||||||
session_id: string;
|
|
||||||
run_id: string | null;
|
|
||||||
agent_id: string | null;
|
|
||||||
tool_name: string;
|
|
||||||
logical_action_id: string | null;
|
|
||||||
risk_level: string;
|
|
||||||
status: MafActionStatus;
|
|
||||||
arguments: Record<string, unknown> | null;
|
|
||||||
result: Record<string, unknown> | null;
|
|
||||||
external_object_id: string | null;
|
|
||||||
approval_id: string | null;
|
|
||||||
attempt_count: number;
|
|
||||||
started_at: string | null;
|
|
||||||
finished_at: string | null;
|
|
||||||
created_at: string | null;
|
|
||||||
updated_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MafActionListResponse {
|
|
||||||
actions: MafAction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── approval(/runs/{id}/approvals, /approvals/{id}/decision)──────────────
|
|
||||||
export type MafApprovalStatus = "pending" | "approved" | "rejected" | string;
|
|
||||||
|
|
||||||
export interface MafApproval {
|
|
||||||
id: string;
|
|
||||||
session_id: string;
|
|
||||||
run_id: string | null;
|
|
||||||
action_id: string | null;
|
|
||||||
requested_by_agent_id: string | null;
|
|
||||||
status: MafApprovalStatus;
|
|
||||||
summary: string;
|
|
||||||
payload: Record<string, unknown> | null;
|
|
||||||
decided_by: string | null;
|
|
||||||
decided_at: string | null;
|
|
||||||
expires_at: string | null;
|
|
||||||
created_at: string | null;
|
|
||||||
updated_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MafApprovalListResponse {
|
|
||||||
approvals: MafApproval[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MafApprovalDecisionRequest {
|
|
||||||
decision: "approved" | "rejected";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 终态判定 ───────────────────────────────────────────────────────────────
|
|
||||||
const LIVE_STATUSES: ReadonlySet<MafRunStatus> = new Set([
|
|
||||||
"pending",
|
|
||||||
"running",
|
|
||||||
"waiting_approval",
|
|
||||||
"cancelling",
|
|
||||||
"cancel_failed",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** run 是否仍可能产生新事件 —— true 时继续轮询。 */
|
|
||||||
export function runIsLive(status: string | null | undefined): boolean {
|
|
||||||
return !!status && (LIVE_STATUSES as Set<string>).has(status);
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 外部动作审计面板(MAF-06-07:可视层改用本地 `tdesign-react`)。
|
|
||||||
*
|
|
||||||
* 数据使用当前页面已正确存在的 useMafActions(runId, live)(轮询 run live 时刷新,
|
|
||||||
* 终态停止)。按真实 action.status 分类展示,分桶语义见 lib/action-audit.ts。
|
|
||||||
*
|
|
||||||
* 安全展示:每项最多展示——工具名称、风险等级、状态、安全 action id、已脱敏参数摘要、
|
|
||||||
* 可安全公开的 external_object_id、开始/结束时间。
|
|
||||||
* 绝不展示原始 result、内部参数、target_key、idempotency_key(参数渲染前先走 safeSummary 脱敏)。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { AlertTriangle, ClipboardList, Loader2 } from "lucide-react";
|
|
||||||
import { Card as TCard, Tag as TTag } from "tdesign-react";
|
|
||||||
|
|
||||||
import { useMafActions } from "../api/approvals";
|
|
||||||
import type { MafAction } from "../api/types";
|
|
||||||
import { classifyActionsByStatus, type ActionBucket } from "../lib/action-audit";
|
|
||||||
import { riskLabelFor, riskThemeFor } from "../lib/risk-theme";
|
|
||||||
import { safeDisplayJson } from "../lib/safe-display";
|
|
||||||
|
|
||||||
interface MafActionAuditPanelProps {
|
|
||||||
runId: string;
|
|
||||||
/** run 仍 live → 启用轮询;终态停止。 */
|
|
||||||
live: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MafActionAuditPanel({ runId, live }: MafActionAuditPanelProps) {
|
|
||||||
const actionsQuery = useMafActions(runId, live);
|
|
||||||
const actions = actionsQuery.data?.actions ?? [];
|
|
||||||
const buckets = classifyActionsByStatus(actions);
|
|
||||||
const total = actions.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="rounded-lg border border-slate-200 bg-white p-3">
|
|
||||||
<header className="mb-2 flex items-center justify-between">
|
|
||||||
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
|
|
||||||
<ClipboardList className="h-4 w-4 text-slate-500" />
|
|
||||||
外部动作审计
|
|
||||||
</h3>
|
|
||||||
<span className="text-xs text-slate-400">
|
|
||||||
{actionsQuery.isLoading ? "加载中…" : `${total} 条`}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{actionsQuery.isLoading && (
|
|
||||||
<div className="flex items-center justify-center py-4 text-xs text-slate-400">
|
|
||||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> 加载动作…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{actionsQuery.isError && (
|
|
||||||
<div className="flex items-center gap-1 py-3 text-center text-xs text-red-500">
|
|
||||||
<AlertTriangle className="h-3.5 w-3.5" />
|
|
||||||
加载失败:{(actionsQuery.error as Error)?.message ?? "未知错误"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!actionsQuery.isLoading && !actionsQuery.isError && total === 0 && (
|
|
||||||
<p className="py-4 text-center text-xs text-slate-400">暂无外部动作记录</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!actionsQuery.isLoading && !actionsQuery.isError && total > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{buckets.map((bucket) =>
|
|
||||||
bucket.items.length === 0 ? null : (
|
|
||||||
<BucketBlock key={bucket.key} bucket={bucket} />
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BucketBlock({ bucket }: { bucket: ActionBucket }) {
|
|
||||||
const theme = bucketTheme(bucket.key);
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mb-1 flex items-center gap-1.5">
|
|
||||||
<TTag size="small" theme={theme} variant="light">{bucket.items.length}</TTag>
|
|
||||||
<span className="text-xs font-medium text-slate-700">{bucket.label}</span>
|
|
||||||
<span className="text-[10px] text-slate-400">{bucket.hint}</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{bucket.items.map((a) => (
|
|
||||||
<ActionRow key={a.id} action={a} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionRow({ action }: { action: MafAction }) {
|
|
||||||
return (
|
|
||||||
<TCard bordered className="px-2 py-1.5">
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
<span className="text-xs font-medium text-slate-700">
|
|
||||||
{truncate(action.tool_name || "未知工具", 32)}
|
|
||||||
</span>
|
|
||||||
<TTag size="small" theme={riskThemeFor(action.risk_level)} variant="light-outline">
|
|
||||||
{riskLabelFor(action.risk_level)}
|
|
||||||
</TTag>
|
|
||||||
<TTag size="small" theme="default" variant="outline">{action.status}</TTag>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-[10px] text-slate-400">
|
|
||||||
<span title="安全 action id">#{shortId(action.id)}</span>
|
|
||||||
{action.agent_id && <span title="席位">席位:{truncate(action.agent_id, 16)}</span>}
|
|
||||||
{action.external_object_id && (
|
|
||||||
<span title="外部对象 id">对象:{truncate(String(action.external_object_id), 24)}</span>
|
|
||||||
)}
|
|
||||||
{action.started_at && <span>{formatTime(action.started_at)}</span>}
|
|
||||||
{action.finished_at && <span>→ {formatTime(action.finished_at)}</span>}
|
|
||||||
{action.attempt_count > 0 && <span title="尝试次数">尝试 {action.attempt_count}</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(() => {
|
|
||||||
const safe = safeDisplayJson(action.arguments);
|
|
||||||
if (!safe.text) return null;
|
|
||||||
return (
|
|
||||||
<div className="mt-1 rounded border border-slate-100 bg-slate-50/70 px-1.5 py-1">
|
|
||||||
<div className="text-[9px] font-medium uppercase tracking-wide text-slate-400">参数(已脱敏)</div>
|
|
||||||
<pre className="mt-0.5 max-h-24 overflow-auto whitespace-pre-wrap break-all text-[11px] text-slate-600">
|
|
||||||
{safe.text}
|
|
||||||
</pre>
|
|
||||||
{(safe.hiddenCount > 0 || safe.truncated) && (
|
|
||||||
<div className="mt-0.5 text-[9px] text-slate-400">
|
|
||||||
{[
|
|
||||||
safe.hiddenCount > 0 ? `已隐藏 ${safe.hiddenCount} 项敏感字段` : "",
|
|
||||||
safe.truncated ? "内容已截断" : "",
|
|
||||||
].filter(Boolean).join(" · ")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</TCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 安全渲染辅助 ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** 桶 key → TDesign Tag 主题。 */
|
|
||||||
function bucketTheme(key: ActionBucket["key"]): "default" | "primary" | "warning" | "success" | "danger" {
|
|
||||||
switch (key) {
|
|
||||||
case "waiting":
|
|
||||||
return "warning";
|
|
||||||
case "approved":
|
|
||||||
return "primary";
|
|
||||||
case "executed":
|
|
||||||
return "success";
|
|
||||||
case "failed":
|
|
||||||
case "rejected_or_expired":
|
|
||||||
return "danger";
|
|
||||||
case "unknown":
|
|
||||||
default:
|
|
||||||
return "default";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortId(id: string): string {
|
|
||||||
return id.length > 8 ? `${id.slice(0, 8)}…` : id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncate(text: string, max: number): string {
|
|
||||||
const t = text.trim().replace(/\s+/g, " ");
|
|
||||||
return t.length <= max ? t : `${t.slice(0, max)}…`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
|
||||||
try {
|
|
||||||
const d = new Date(iso);
|
|
||||||
if (Number.isNaN(d.getTime())) return "";
|
|
||||||
return d.toLocaleTimeString();
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 写工具审批卡片(MAF-06-06:可视层改用本地 `tdesign-react`)。
|
|
||||||
*
|
|
||||||
* 对齐后端 schemas.py:
|
|
||||||
* - 标题:action.tool_name,没有 action 时显示 approval.summary
|
|
||||||
* - 风险:展示 action.risk_level(R0–R4 配色,主题映射来自 lib/risk-theme,单一事实源)
|
|
||||||
* - 审批摘要:展示 approval.summary
|
|
||||||
* - 参数 / 审批 payload:统一走 lib/safe-display 的 safeDisplayJson(敏感键直接删除,
|
|
||||||
* 不显示成 ***;连字段名都不透露)
|
|
||||||
* - 不渲染 action.result(仅展示参数与审批载荷)
|
|
||||||
* - 备注 textarea 已删除(后端 ApprovalDecisionRequest 只接收 decision,不保存备注)
|
|
||||||
*
|
|
||||||
* 重复决策锁定(P1):
|
|
||||||
* - 批准和拒绝都锁卡:onSuccess 后 setResuming(true)(拒绝后后端同样触发恢复流程);
|
|
||||||
* - 除 React state 外,额外用 decisionLockRef 同步锁,避免极快双击时 React 尚未重渲染
|
|
||||||
* 就已发出第二次 mutate;
|
|
||||||
* - mutation pending 期间按钮禁用(isDecisionButtonDisabled 单一事实源)。
|
|
||||||
*
|
|
||||||
* invalidate 使用页面传入的 runId,不依赖 approval.run_id 可选字段。仅 pending 审批渲染。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
|
||||||
import { Check, Loader2, ShieldCheck, X } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button as TButton, Tag as TTag } from "tdesign-react";
|
|
||||||
|
|
||||||
import { useMafApprovalActions } from "../api/approvals";
|
|
||||||
import type { MafAction, MafApproval } from "../api/types";
|
|
||||||
import { isDecisionButtonDisabled } from "../lib/approval-state";
|
|
||||||
import { riskLabelFor, riskThemeFor } from "../lib/risk-theme";
|
|
||||||
import { safeDisplayJson } from "../lib/safe-display";
|
|
||||||
|
|
||||||
interface MafApprovalCardProps {
|
|
||||||
approval: MafApproval;
|
|
||||||
action: MafAction | undefined;
|
|
||||||
/** 当前页面的 runId(用于 invalidate,不依赖 approval.run_id 可选字段)。 */
|
|
||||||
runId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortId(id: string | null): string {
|
|
||||||
if (!id) return "—";
|
|
||||||
return id.length > 8 ? `${id.slice(0, 8)}…` : id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MafApprovalCard({ approval, action, runId }: MafApprovalCardProps) {
|
|
||||||
const actions = useMafApprovalActions(runId);
|
|
||||||
// 决策成功(批准或拒绝)后锁卡,等待后端同步工作流
|
|
||||||
const [resuming, setResuming] = useState(false);
|
|
||||||
// 同步锁:防极快双击在 React 重渲染前就发出第二次 mutate
|
|
||||||
const decisionLockRef = useRef(false);
|
|
||||||
|
|
||||||
const pending = actions.decide.isPending;
|
|
||||||
// 按钮禁用判定走共享纯函数(lib/approval-state,单一事实源)
|
|
||||||
const disabled = isDecisionButtonDisabled({ pending, resuming });
|
|
||||||
|
|
||||||
const onDecide = (decision: "approved" | "rejected") => {
|
|
||||||
if (decisionLockRef.current || disabled) return;
|
|
||||||
decisionLockRef.current = true;
|
|
||||||
actions.decide.mutate(
|
|
||||||
{ approvalId: approval.id, decision },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
// 批准和拒绝都锁卡:拒绝后后端同样会推进流程,统一显示「正在同步工作流…」
|
|
||||||
setResuming(true);
|
|
||||||
toast.success(decision === "approved" ? "已批准" : "已拒绝");
|
|
||||||
},
|
|
||||||
onError: (e: Error) => {
|
|
||||||
// 失败时释放锁,允许重试
|
|
||||||
decisionLockRef.current = false;
|
|
||||||
toast.error(`决策失败:${e.message}`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 标题:action.tool_name 优先,没有 action 时用 approval.summary
|
|
||||||
const title = action?.tool_name ?? approval.summary;
|
|
||||||
const riskLevel = action?.risk_level;
|
|
||||||
// 二次脱敏:统一走 safeDisplayJson(敏感键直接删除,绝不显示 ***)
|
|
||||||
const safeArgs = safeDisplayJson(action?.arguments);
|
|
||||||
const safePayload = safeDisplayJson(approval.payload);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-red-100 bg-white p-3 shadow-sm">
|
|
||||||
<header className="mb-2 flex items-center gap-1.5">
|
|
||||||
<TTag size="small" theme="danger" variant="light">
|
|
||||||
<ShieldCheck className="mr-0.5 h-3 w-3" />
|
|
||||||
需要你的确认
|
|
||||||
</TTag>
|
|
||||||
{action?.id && (
|
|
||||||
<span className="text-[10px] font-mono text-slate-400">
|
|
||||||
#{shortId(approval.action_id ?? action.id)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* 标题 */}
|
|
||||||
<div className="mb-1.5 text-sm font-semibold text-slate-800">
|
|
||||||
{title || "未知操作"}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 风险等级(R0–R4) */}
|
|
||||||
{riskLevel && (
|
|
||||||
<div className="mb-2 flex items-center gap-1.5">
|
|
||||||
<span className="text-[10px] text-slate-400">风险等级:</span>
|
|
||||||
<TTag size="small" theme={riskThemeFor(riskLevel)} variant="light-outline">
|
|
||||||
{riskLabelFor(riskLevel)}
|
|
||||||
</TTag>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 审批摘要 */}
|
|
||||||
{approval.summary && (
|
|
||||||
<div className="mb-2 text-xs text-slate-600">
|
|
||||||
{approval.summary}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 参数(来自 action.arguments,二次脱敏;无可展示内容则隐藏) */}
|
|
||||||
{safeArgs.text && (
|
|
||||||
<div className="mb-2 rounded-md border border-slate-100 bg-slate-50/70 p-2">
|
|
||||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-slate-400">
|
|
||||||
参数(已脱敏)
|
|
||||||
</div>
|
|
||||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-all text-xs text-slate-600">
|
|
||||||
{safeArgs.text}
|
|
||||||
</pre>
|
|
||||||
<SafeDisplayHint result={safeArgs} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 审批 payload(二次脱敏;无可展示内容则隐藏) */}
|
|
||||||
{safePayload.text && (
|
|
||||||
<div className="mb-2 rounded-md border border-slate-100 bg-slate-50/70 p-2">
|
|
||||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-slate-400">
|
|
||||||
审批载荷(已脱敏)
|
|
||||||
</div>
|
|
||||||
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-all text-xs text-slate-600">
|
|
||||||
{safePayload.text}
|
|
||||||
</pre>
|
|
||||||
<SafeDisplayHint result={safePayload} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<TButton
|
|
||||||
theme="danger"
|
|
||||||
variant="outline"
|
|
||||||
size="small"
|
|
||||||
loading={pending}
|
|
||||||
disabled={disabled}
|
|
||||||
icon={!pending ? <X className="h-3.5 w-3.5" /> : undefined}
|
|
||||||
onClick={() => onDecide("rejected")}
|
|
||||||
>
|
|
||||||
拒绝
|
|
||||||
</TButton>
|
|
||||||
<TButton
|
|
||||||
theme="success"
|
|
||||||
size="small"
|
|
||||||
loading={pending}
|
|
||||||
disabled={disabled}
|
|
||||||
icon={!pending ? <Check className="h-3.5 w-3.5" /> : undefined}
|
|
||||||
onClick={() => onDecide("approved")}
|
|
||||||
>
|
|
||||||
批准
|
|
||||||
</TButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(pending || resuming) && (
|
|
||||||
<div className="mt-1 flex items-center justify-center gap-1 text-[10px] text-slate-400">
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
{resuming ? "正在同步工作流…" : "提交中…"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 脱敏提示:有隐藏或截断时只显示概括计数,不透露具体敏感字段名。 */
|
|
||||||
function SafeDisplayHint({ result }: { result: { hiddenCount: number; truncated: boolean } }) {
|
|
||||||
if (result.hiddenCount === 0 && !result.truncated) return null;
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (result.hiddenCount > 0) parts.push(`已隐藏 ${result.hiddenCount} 项敏感字段`);
|
|
||||||
if (result.truncated) parts.push("内容已截断");
|
|
||||||
return <div className="mt-1 text-[10px] text-slate-400">{parts.join(" · ")}</div>;
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 事件时间线 —— 把折叠后的 TimelineEntry[] 渲染成可读的对话流。
|
|
||||||
*
|
|
||||||
* MAF-06-05:可视层改用本地 `tdesign-react`(Timeline / Tag)。
|
|
||||||
* 滚动跟随逻辑自含(useMafAutoScroll),MAF 模块不依赖任何旧会商目录的 hook/组件。
|
|
||||||
* 视觉上每条带类型 Tag + 时间 + 内容;消息体走 Markdown,其余走纯文本。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import {
|
|
||||||
AlertTriangle,
|
|
||||||
CheckCircle2,
|
|
||||||
ChevronDown,
|
|
||||||
FileText,
|
|
||||||
GitBranch,
|
|
||||||
Hammer,
|
|
||||||
Loader2,
|
|
||||||
MessageSquare,
|
|
||||||
Package,
|
|
||||||
ShieldQuestion,
|
|
||||||
Sparkles,
|
|
||||||
Workflow,
|
|
||||||
XCircle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Tag as TTag, Timeline as TTimeline, TimelineItem as TTimelineItem } from "tdesign-react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
import { shortTime, type TimelineEntry, type TimelineEntryKind } from "../lib/event-timeline";
|
|
||||||
|
|
||||||
interface MafEventTimelineProps {
|
|
||||||
entries: TimelineEntry[];
|
|
||||||
/** 仍在流式(run live)→ 显示底部 spinner;停则不显示。 */
|
|
||||||
streaming: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const KIND_ICON: Record<TimelineEntryKind, React.ReactElement> = {
|
|
||||||
message: <MessageSquare className="h-3.5 w-3.5" />,
|
|
||||||
tool: <Hammer className="h-3.5 w-3.5" />,
|
|
||||||
skill: <Sparkles className="h-3.5 w-3.5" />,
|
|
||||||
approval: <ShieldQuestion className="h-3.5 w-3.5" />,
|
|
||||||
artifact: <Package className="h-3.5 w-3.5" />,
|
|
||||||
run: <Workflow className="h-3.5 w-3.5" />,
|
|
||||||
checkpoint: <GitBranch className="h-3.5 w-3.5" />,
|
|
||||||
turn: <FileText className="h-3.5 w-3.5" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** TimelineEntryKind → TDesign TimelineItem dotColor(支持 primary/warning/error/default 或 hex)。 */
|
|
||||||
const KIND_DOT_COLOR: Record<TimelineEntryKind, string> = {
|
|
||||||
message: "primary",
|
|
||||||
tool: "primary",
|
|
||||||
skill: "default",
|
|
||||||
approval: "warning",
|
|
||||||
artifact: "#00a870",
|
|
||||||
run: "default",
|
|
||||||
checkpoint: "default",
|
|
||||||
turn: "default",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** kind → TDesign Tag 主题(用于卡片内类型标签)。 */
|
|
||||||
const KIND_TAG_THEME: Record<TimelineEntryKind, "default" | "primary" | "warning" | "success" | "danger"> = {
|
|
||||||
message: "primary",
|
|
||||||
tool: "primary",
|
|
||||||
skill: "default",
|
|
||||||
approval: "warning",
|
|
||||||
artifact: "success",
|
|
||||||
run: "default",
|
|
||||||
checkpoint: "default",
|
|
||||||
turn: "default",
|
|
||||||
};
|
|
||||||
|
|
||||||
const KIND_LABEL: Record<TimelineEntryKind, string> = {
|
|
||||||
message: "消息",
|
|
||||||
tool: "工具",
|
|
||||||
skill: "技能",
|
|
||||||
approval: "审批",
|
|
||||||
artifact: "产物",
|
|
||||||
run: "会商",
|
|
||||||
checkpoint: "检查点",
|
|
||||||
turn: "轮次",
|
|
||||||
};
|
|
||||||
|
|
||||||
function toolStatusIcon(entry: TimelineEntry): React.ReactNode {
|
|
||||||
const status = (entry.meta?.status as string) ?? "";
|
|
||||||
if (status === "completed") return <CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />;
|
|
||||||
if (status === "failed") return <XCircle className="h-3.5 w-3.5 text-red-600" />;
|
|
||||||
if (status === "started" || status === "requested")
|
|
||||||
return <Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500" />;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 自含滚动跟随 hook —— MAF 模块内部实现,不依赖旧会商目录。
|
|
||||||
*
|
|
||||||
* 语义:用户在底部时新内容自动滚到底;用户上滚查看历史时不强制下拉,
|
|
||||||
* 只显示「回到底部」按钮。entry 数组变化时若原先在底部则跟随。
|
|
||||||
*/
|
|
||||||
function useMafAutoScroll<T extends HTMLElement>(deps: unknown[]) {
|
|
||||||
const scrollRef = useRef<T | null>(null);
|
|
||||||
const [atBottom, setAtBottom] = useState(true);
|
|
||||||
|
|
||||||
const isNearBottom = () => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return true;
|
|
||||||
return el.scrollHeight - el.scrollTop - el.clientHeight < 48;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onScroll = () => setAtBottom(isNearBottom());
|
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
setAtBottom(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 内容变化时,若原先在底部则跟随滚到底
|
|
||||||
useEffect(() => {
|
|
||||||
if (atBottom) {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, deps);
|
|
||||||
|
|
||||||
return { scrollRef, atBottom, onScroll, scrollToBottom };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MafEventTimeline({ entries, streaming }: MafEventTimelineProps) {
|
|
||||||
const { scrollRef, atBottom, onScroll, scrollToBottom } = useMafAutoScroll<HTMLDivElement>([entries]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex h-full min-h-0 flex-1 flex-col">
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
onScroll={onScroll}
|
|
||||||
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden bg-slate-50/30"
|
|
||||||
>
|
|
||||||
<div className="p-4">
|
|
||||||
{entries.length === 0 && !streaming && (
|
|
||||||
<div className="flex h-full items-center justify-center py-12 text-sm text-slate-400">
|
|
||||||
<AlertTriangle className="mr-1.5 h-4 w-4" />
|
|
||||||
暂无事件
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{entries.length > 0 && (
|
|
||||||
<TTimeline layout="vertical" mode="alternate">
|
|
||||||
{entries.map((entry) => (
|
|
||||||
<TimelineEntryItem key={entry.key} entry={entry} />
|
|
||||||
))}
|
|
||||||
</TTimeline>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{streaming && (
|
|
||||||
<div className="flex items-center gap-2 py-2 pl-2 text-xs text-blue-500">
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
会商进行中…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!atBottom && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={scrollToBottom}
|
|
||||||
className="absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 shadow-md transition hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
|
||||||
回到底部
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TimelineEntryItem({ entry }: { entry: TimelineEntry }) {
|
|
||||||
const isMessage = entry.kind === "message";
|
|
||||||
return (
|
|
||||||
<TTimelineItem
|
|
||||||
dot={KIND_ICON[entry.kind]}
|
|
||||||
dotColor={KIND_DOT_COLOR[entry.kind]}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg border px-3 py-2 transition",
|
|
||||||
isMessage ? "border-blue-100 bg-blue-50/40" : "border-slate-100 bg-white",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<header className="mb-1 flex items-center gap-1.5">
|
|
||||||
<TTag size="small" theme={KIND_TAG_THEME[entry.kind]} variant="light-outline">
|
|
||||||
{KIND_LABEL[entry.kind]}
|
|
||||||
</TTag>
|
|
||||||
{entry.kind === "tool" && toolStatusIcon(entry)}
|
|
||||||
<span className="text-xs font-semibold text-slate-700">{entry.title}</span>
|
|
||||||
{entry.agentId && (
|
|
||||||
<TTag size="small" theme="default" variant="outline">
|
|
||||||
{entry.agentId}
|
|
||||||
</TTag>
|
|
||||||
)}
|
|
||||||
<span className="ml-auto text-[10px] text-slate-400">{shortTime(entry.createdAt)}</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{entry.body && (
|
|
||||||
isMessage ? (
|
|
||||||
<div className="prose prose-sm max-w-none text-slate-700 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{entry.body}</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="whitespace-pre-wrap break-words text-xs text-slate-600">{entry.body}</p>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TTimelineItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 事实/结论流程图面板(MAF-06-07:可视层改用本地 `tdesign-react`)。
|
|
||||||
*
|
|
||||||
* MAF 自包含:不 import 任何旧会商目录的流程图组件或类型,保证新旧会商页面独立演进。
|
|
||||||
* 数据来自 lib/maf-flow-graph.ts(confirmed_facts 证据图 + final_result 的 flow-json 结论图)。
|
|
||||||
*
|
|
||||||
* TDesign Collapse 做折叠壳,内嵌固定高度的节点列表预览 + 「展开大图」按钮触发
|
|
||||||
* MafFlowGraphDialog(同样是 MAF 自包含组件,用 TDesign Dialog/Card/Tag/Collapse 呈现)。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { GitBranch } from "lucide-react";
|
|
||||||
import { Collapse as TCollapse, Tag as TTag } from "tdesign-react";
|
|
||||||
|
|
||||||
import type { MafCheckpoint } from "../api/types";
|
|
||||||
import { buildMafFlowGraph, MAF_FLOW_NODE_TYPE_LABEL, type MafFlowNodeType } from "../lib/maf-flow-graph";
|
|
||||||
import { MafFlowGraphDialog, MafFlowGraphOpenButton } from "./MafFlowGraphDialog";
|
|
||||||
|
|
||||||
/** 节点类型 → TDesign Tag 主题(预览徽章配色)。 */
|
|
||||||
const NODE_TYPE_THEME: Record<MafFlowNodeType, "default" | "primary" | "warning" | "success" | "danger"> = {
|
|
||||||
start: "default",
|
|
||||||
end: "default",
|
|
||||||
stage: "primary",
|
|
||||||
task: "primary",
|
|
||||||
decision: "warning",
|
|
||||||
risk: "danger",
|
|
||||||
deliverable: "success",
|
|
||||||
role: "default",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface MafFactFlowPanelProps {
|
|
||||||
checkpoint: MafCheckpoint | null;
|
|
||||||
/** run 仍 live → 显示「绘制中」;终态 → 不显示。 */
|
|
||||||
streaming: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MafFactFlowPanel({ checkpoint, streaming }: MafFactFlowPanelProps) {
|
|
||||||
const [open, setOpen] = useState(true);
|
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
|
||||||
const graph = buildMafFlowGraph(checkpoint);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TCollapse
|
|
||||||
value={open ? ["maf-flow"] : []}
|
|
||||||
onChange={(val) => setOpen(Array.isArray(val) ? val.includes("maf-flow") : val === "maf-flow")}
|
|
||||||
className="rounded-lg border border-slate-200 bg-white"
|
|
||||||
>
|
|
||||||
<TCollapse.Panel
|
|
||||||
value="maf-flow"
|
|
||||||
header={
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<GitBranch className="h-4 w-4 text-slate-500" />
|
|
||||||
<span className="text-sm font-semibold text-slate-700">事实/结论流程图</span>
|
|
||||||
{graph && (
|
|
||||||
<span className="text-xs text-slate-400">
|
|
||||||
({graph.nodes.length} 节点 / {graph.edges.length} 连线)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="relative h-64 border-t border-slate-100">
|
|
||||||
{graph ? (
|
|
||||||
<>
|
|
||||||
<FlowGraphPreview graph={graph} streaming={streaming} />
|
|
||||||
<div className="absolute right-2 top-2 z-10">
|
|
||||||
<MafFlowGraphOpenButton onClick={() => setFullscreen(true)} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center text-xs text-slate-400">
|
|
||||||
{streaming ? "等待已确认事实 / 结论输出…" : "尚无可绘制的事实或结论"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TCollapse.Panel>
|
|
||||||
|
|
||||||
<MafFlowGraphDialog
|
|
||||||
visible={fullscreen}
|
|
||||||
onClose={() => setFullscreen(false)}
|
|
||||||
checkpoint={checkpoint}
|
|
||||||
streaming={streaming}
|
|
||||||
/>
|
|
||||||
</TCollapse>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 内嵌预览:轻量「节点列表」(点击「大图」看完整结构化视图)。
|
|
||||||
*/
|
|
||||||
function FlowGraphPreview({
|
|
||||||
graph,
|
|
||||||
streaming,
|
|
||||||
}: {
|
|
||||||
graph: ReturnType<typeof buildMafFlowGraph> extends infer T ? Exclude<T, null> : never;
|
|
||||||
streaming: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col overflow-hidden p-2">
|
|
||||||
{graph.title && (
|
|
||||||
<div className="mb-1 truncate text-xs font-medium text-slate-500">{graph.title}</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{graph.nodes.map((n) => {
|
|
||||||
const incomers = graph.edges.filter((e) => e.to === n.id).map((e) => e.from);
|
|
||||||
return (
|
|
||||||
<li key={n.id} className="rounded border border-slate-100 bg-slate-50/70 px-2 py-1">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<TTag size="small" theme={NODE_TYPE_THEME[n.type]} variant="light-outline">
|
|
||||||
{n.group?.trim() || MAF_FLOW_NODE_TYPE_LABEL[n.type]}
|
|
||||||
</TTag>
|
|
||||||
<span className="truncate text-xs text-slate-700">{n.label}</span>
|
|
||||||
</div>
|
|
||||||
{incomers.length > 0 && (
|
|
||||||
<div className="mt-0.5 text-[10px] text-slate-400">← {incomers.length} 个上游</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{streaming && (
|
|
||||||
<div className="mt-1 text-center text-[10px] text-blue-400">绘制中…</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 事实账本面板(MAF-06-07:可视层改用本地 `tdesign-react`)。
|
|
||||||
*
|
|
||||||
* 数据只读 run.maf_checkpoint.fact_ledger,按 6 个分区展示:
|
|
||||||
* 候选事实(candidate_facts,显式标注「未经交叉核验」,绝不混入已确认)
|
|
||||||
* 已确认事实(confirmed_facts)
|
|
||||||
* 待审批动作(pending_actions)
|
|
||||||
* 已执行动作(executed_actions)
|
|
||||||
* 失败或未知动作(failed_or_unknown_actions)
|
|
||||||
* 冲突(conflicts)
|
|
||||||
*
|
|
||||||
* 安全要求:只展示白名单安全字段(action_id / tool_name / agent_id / external_object_id /
|
|
||||||
* text / status)。绝不展示 checkpoint 里可能包含的原始秘密、内部幂等键、target_key。
|
|
||||||
*
|
|
||||||
* 无数据时显示空态,不伪造数据。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { AlertTriangle, CheckCircle2, FileQuestion, GitBranch, ListChecks, ShieldAlert } from "lucide-react";
|
|
||||||
import { Collapse as TCollapse, Tag as TTag } from "tdesign-react";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
MafCandidateFact,
|
|
||||||
MafCheckpoint,
|
|
||||||
MafConfirmedFact,
|
|
||||||
MafFactLedger,
|
|
||||||
MafLedgerAction,
|
|
||||||
MafLedgerConflict,
|
|
||||||
} from "../api/types";
|
|
||||||
|
|
||||||
interface MafFactLedgerPanelProps {
|
|
||||||
checkpoint: MafCheckpoint | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MafFactLedgerPanel({ checkpoint }: MafFactLedgerPanelProps) {
|
|
||||||
const ledger: MafFactLedger | undefined = checkpoint?.fact_ledger;
|
|
||||||
|
|
||||||
const candidate = ledger?.candidate_facts ?? [];
|
|
||||||
const confirmed = ledger?.confirmed_facts ?? [];
|
|
||||||
const pending = ledger?.pending_actions ?? [];
|
|
||||||
const executed = ledger?.executed_actions ?? [];
|
|
||||||
const failedOrUnknown = ledger?.failed_or_unknown_actions ?? [];
|
|
||||||
const conflicts = ledger?.conflicts ?? [];
|
|
||||||
|
|
||||||
const total =
|
|
||||||
candidate.length +
|
|
||||||
confirmed.length +
|
|
||||||
pending.length +
|
|
||||||
executed.length +
|
|
||||||
failedOrUnknown.length +
|
|
||||||
conflicts.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="rounded-lg border border-slate-200 bg-white p-3">
|
|
||||||
<header className="mb-2 flex items-center justify-between">
|
|
||||||
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
|
|
||||||
<ListChecks className="h-4 w-4 text-slate-500" />
|
|
||||||
事实账本
|
|
||||||
</h3>
|
|
||||||
<span className="text-xs text-slate-400">{total} 条</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{total === 0 ? (
|
|
||||||
<p className="py-4 text-center text-xs text-slate-400">尚无事实账本数据</p>
|
|
||||||
) : (
|
|
||||||
<TCollapse defaultExpandAll className="rounded border border-slate-100">
|
|
||||||
<LedgerSection
|
|
||||||
value="candidate"
|
|
||||||
icon={<FileQuestion className="h-3.5 w-3.5 text-amber-500" />}
|
|
||||||
title="候选事实"
|
|
||||||
badgeTheme="warning"
|
|
||||||
warn="未经交叉核验"
|
|
||||||
count={candidate.length}
|
|
||||||
>
|
|
||||||
{candidate.map((f) => (
|
|
||||||
<FactItem key={`c-${f.candidate_id}`} fact={f} confirmed={false} />
|
|
||||||
))}
|
|
||||||
</LedgerSection>
|
|
||||||
|
|
||||||
<LedgerSection
|
|
||||||
value="confirmed"
|
|
||||||
icon={<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />}
|
|
||||||
title="已确认事实"
|
|
||||||
badgeTheme="success"
|
|
||||||
count={confirmed.length}
|
|
||||||
>
|
|
||||||
{confirmed.map((f) => (
|
|
||||||
<FactItem key={`f-${f.candidate_id}`} fact={f} confirmed />
|
|
||||||
))}
|
|
||||||
</LedgerSection>
|
|
||||||
|
|
||||||
<LedgerSection
|
|
||||||
value="pending"
|
|
||||||
icon={<ShieldAlert className="h-3.5 w-3.5 text-amber-600" />}
|
|
||||||
title="待审批动作"
|
|
||||||
badgeTheme="warning"
|
|
||||||
count={pending.length}
|
|
||||||
>
|
|
||||||
{pending.map((a, i) => (
|
|
||||||
<ActionItem key={`p-${safeKey(a, i)}`} action={a} />
|
|
||||||
))}
|
|
||||||
</LedgerSection>
|
|
||||||
|
|
||||||
<LedgerSection
|
|
||||||
value="executed"
|
|
||||||
icon={<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />}
|
|
||||||
title="已执行动作"
|
|
||||||
badgeTheme="success"
|
|
||||||
count={executed.length}
|
|
||||||
>
|
|
||||||
{executed.map((a, i) => (
|
|
||||||
<ActionItem key={`e-${safeKey(a, i)}`} action={a} />
|
|
||||||
))}
|
|
||||||
</LedgerSection>
|
|
||||||
|
|
||||||
<LedgerSection
|
|
||||||
value="failed"
|
|
||||||
icon={<AlertTriangle className="h-3.5 w-3.5 text-red-500" />}
|
|
||||||
title="失败或未知动作"
|
|
||||||
badgeTheme="danger"
|
|
||||||
count={failedOrUnknown.length}
|
|
||||||
>
|
|
||||||
{failedOrUnknown.map((a, i) => (
|
|
||||||
<ActionItem key={`u-${safeKey(a, i)}`} action={a} />
|
|
||||||
))}
|
|
||||||
</LedgerSection>
|
|
||||||
|
|
||||||
<LedgerSection
|
|
||||||
value="conflicts"
|
|
||||||
icon={<GitBranch className="h-3.5 w-3.5 text-red-500" />}
|
|
||||||
title="冲突"
|
|
||||||
badgeTheme="danger"
|
|
||||||
count={conflicts.length}
|
|
||||||
>
|
|
||||||
{conflicts.map((c, i) => (
|
|
||||||
<ConflictItem key={`x-${i}`} conflict={c} />
|
|
||||||
))}
|
|
||||||
</LedgerSection>
|
|
||||||
</TCollapse>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 分区壳 ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function LedgerSection({
|
|
||||||
value,
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
badgeTheme,
|
|
||||||
warn,
|
|
||||||
count,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
title: string;
|
|
||||||
badgeTheme: "default" | "primary" | "warning" | "success" | "danger";
|
|
||||||
/** 候选事实专用:额外标注「未经交叉核验」。 */
|
|
||||||
warn?: string;
|
|
||||||
count: number;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<TCollapse.Panel
|
|
||||||
value={value}
|
|
||||||
header={
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{icon}
|
|
||||||
<span className="text-sm font-medium text-slate-700">{title}</span>
|
|
||||||
<TTag size="small" theme={badgeTheme} variant="light">{count}</TTag>
|
|
||||||
{warn && count > 0 && (
|
|
||||||
<TTag size="small" theme="warning" variant="light-outline">{warn}</TTag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-1.5">{children}</div>
|
|
||||||
</TCollapse.Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 条目渲染 ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function agentTag(agentId?: string): React.ReactNode {
|
|
||||||
if (!agentId) return null;
|
|
||||||
return (
|
|
||||||
<TTag size="small" theme="default" variant="outline" title={agentId}>
|
|
||||||
{truncate(agentId, 16)}
|
|
||||||
</TTag>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FactItem({ fact, confirmed }: { fact: MafCandidateFact | MafConfirmedFact; confirmed: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className={`rounded border px-2 py-1.5 ${confirmed ? "border-emerald-100 bg-emerald-50/40" : "border-amber-100 bg-amber-50/40"}`}>
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
{confirmed ? (
|
|
||||||
<TTag size="small" theme="success" variant="light">已确认</TTag>
|
|
||||||
) : (
|
|
||||||
<TTag size="small" theme="warning" variant="light">候选 · 未经核验</TTag>
|
|
||||||
)}
|
|
||||||
{agentTag(fact.agent_id)}
|
|
||||||
{"confirmed_by" in fact && fact.confirmed_by && (
|
|
||||||
<span className="text-[10px] text-slate-400">确认方:{truncate(fact.confirmed_by, 16)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 whitespace-pre-wrap break-words text-xs text-slate-700">{fact.text}</p>
|
|
||||||
{"evidence_refs" in fact && (fact as MafConfirmedFact).evidence_refs?.length > 0 && (
|
|
||||||
<div className="mt-0.5 text-[10px] text-slate-400">
|
|
||||||
证据 {fact.evidence_refs.length} 条
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 动作条目:只展示白名单安全字段。
|
|
||||||
* 绝不展示 target_key / idempotency_key / 原始 result / 原始 arguments。
|
|
||||||
*/
|
|
||||||
function ActionItem({ action }: { action: MafLedgerAction }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded border border-slate-100 bg-slate-50/60 px-2 py-1.5">
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
{action.tool_name && (
|
|
||||||
<span className="text-xs font-medium text-slate-700">{truncate(action.tool_name, 32)}</span>
|
|
||||||
)}
|
|
||||||
{action.status && (
|
|
||||||
<TTag size="small" theme="default" variant="outline">{action.status}</TTag>
|
|
||||||
)}
|
|
||||||
{agentTag(action.agent_id)}
|
|
||||||
</div>
|
|
||||||
{action.text && (
|
|
||||||
<p className="mt-0.5 whitespace-pre-wrap break-words text-[11px] text-slate-500">{action.text}</p>
|
|
||||||
)}
|
|
||||||
{action.external_object_id && (
|
|
||||||
<div className="mt-0.5 text-[10px] text-slate-400">
|
|
||||||
对象:{truncate(String(action.external_object_id), 32)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConflictItem({ conflict }: { conflict: MafLedgerConflict }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded border border-red-100 bg-red-50/40 px-2 py-1.5">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<TTag size="small" theme="danger" variant="light">冲突</TTag>
|
|
||||||
{agentTag(conflict.agent_id)}
|
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 whitespace-pre-wrap break-words text-xs text-slate-700">{conflict.text}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 工具 ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function truncate(text: string, max: number): string {
|
|
||||||
const t = text.trim().replace(/\s+/g, " ");
|
|
||||||
return t.length <= max ? t : `${t.slice(0, max)}…`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 动作条目 key:优先用安全 action_id,回退到 index(不依赖任何敏感字段)。 */
|
|
||||||
function safeKey(a: MafLedgerAction, index: number): string {
|
|
||||||
return a.action_id ?? `idx-${index}`;
|
|
||||||
}
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 事实/结论流程图大图对话框(MAF-06-07:可视层改用本地 `tdesign-react`)。
|
|
||||||
*
|
|
||||||
* MAF 自包含组件:不依赖任何旧会商目录的流程图组件或 xyflow 画布。
|
|
||||||
* 用 TDesign Dialog + Card + Tag + Collapse 把 MafFlowGraphData 渲染成结构化节点视图:
|
|
||||||
* 每个节点一张卡,显示类型徽章、label、group、detail 以及上下游连接(可点击跳转)。
|
|
||||||
*
|
|
||||||
* 这不是拖拽画布,而是可读的「节点 + 连线关系」结构视图,足够用于会商事实/结论的审阅,
|
|
||||||
* 且不引入第二套可视化运行时依赖。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { GitBranch, Maximize2, X } from "lucide-react";
|
|
||||||
import {
|
|
||||||
Button as TButton,
|
|
||||||
Card as TCard,
|
|
||||||
Collapse as TCollapse,
|
|
||||||
Dialog as TDialog,
|
|
||||||
Tag as TTag,
|
|
||||||
} from "tdesign-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
buildMafFlowGraph,
|
|
||||||
MAF_FLOW_NODE_TYPE_LABEL,
|
|
||||||
type MafFlowGraphData,
|
|
||||||
type MafFlowNodeType,
|
|
||||||
} from "../lib/maf-flow-graph";
|
|
||||||
import type { MafCheckpoint } from "../api/types";
|
|
||||||
|
|
||||||
/** 节点类型 → TDesign Tag 主题(用于类型徽章配色)。 */
|
|
||||||
const NODE_TYPE_THEME: Record<MafFlowNodeType, "default" | "primary" | "warning" | "success" | "danger"> = {
|
|
||||||
start: "default",
|
|
||||||
end: "default",
|
|
||||||
stage: "primary",
|
|
||||||
task: "primary",
|
|
||||||
decision: "warning",
|
|
||||||
risk: "danger",
|
|
||||||
deliverable: "success",
|
|
||||||
role: "default",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface MafFlowGraphDialogProps {
|
|
||||||
visible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
checkpoint: MafCheckpoint | null;
|
|
||||||
/** run 仍 live → 显示「绘制中」提示;终态不显示。 */
|
|
||||||
streaming: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MafFlowGraphDialog({ visible, onClose, checkpoint, streaming }: MafFlowGraphDialogProps) {
|
|
||||||
const graph = useMemo(() => buildMafFlowGraph(checkpoint), [checkpoint]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TDialog
|
|
||||||
visible={visible}
|
|
||||||
onClose={onClose}
|
|
||||||
header={
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<GitBranch className="h-4 w-4 text-slate-500" />
|
|
||||||
<span className="text-sm font-semibold text-slate-700">事实/结论流程图</span>
|
|
||||||
{graph && (
|
|
||||||
<span className="text-xs text-slate-400">
|
|
||||||
({graph.nodes.length} 节点 / {graph.edges.length} 连线)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{graph?.truncated && (
|
|
||||||
<TTag size="small" theme="warning" variant="light">已截断</TTag>
|
|
||||||
)}
|
|
||||||
{streaming && (
|
|
||||||
<TTag size="small" theme="primary" variant="light">绘制中</TTag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
width="760px"
|
|
||||||
closeOnEscKeydown
|
|
||||||
footer={null}
|
|
||||||
>
|
|
||||||
{graph ? (
|
|
||||||
<FlowGraphBody graph={graph} />
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center py-10 text-xs text-slate-400">
|
|
||||||
{streaming ? "等待已确认事实 / 结论输出…" : "尚无可绘制的事实或结论"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 节点卡片列表 + 上下游连接。 */
|
|
||||||
function FlowGraphBody({ graph }: { graph: MafFlowGraphData }) {
|
|
||||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const incomingByNode = useMemo(() => {
|
|
||||||
const m = new Map<string, { from: string; label?: string }[]>();
|
|
||||||
for (const e of graph.edges) {
|
|
||||||
const list = m.get(e.to) ?? [];
|
|
||||||
list.push({ from: e.from, label: e.label });
|
|
||||||
m.set(e.to, list);
|
|
||||||
}
|
|
||||||
return m;
|
|
||||||
}, [graph.edges]);
|
|
||||||
const outgoingByNode = useMemo(() => {
|
|
||||||
const m = new Map<string, { to: string; label?: string }[]>();
|
|
||||||
for (const e of graph.edges) {
|
|
||||||
const list = m.get(e.from) ?? [];
|
|
||||||
list.push({ to: e.to, label: e.label });
|
|
||||||
m.set(e.from, list);
|
|
||||||
}
|
|
||||||
return m;
|
|
||||||
}, [graph.edges]);
|
|
||||||
|
|
||||||
const labelOf = (id: string) => graph.nodes.find((n) => n.id === id)?.label ?? id;
|
|
||||||
const focused = focusedId ? graph.nodes.find((n) => n.id === focusedId) ?? null : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-h-[70vh] space-y-2 overflow-y-auto py-1">
|
|
||||||
{graph.title && (
|
|
||||||
<div className="text-xs font-medium text-slate-500">{graph.title}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{graph.truncated && (
|
|
||||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-[11px] text-amber-700">
|
|
||||||
流程图内容过大,已截断展示(最多 {graph.nodes.length} 节点 / {graph.edges.length} 连线)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{graph.nodes.map((n) => {
|
|
||||||
const inc = incomingByNode.get(n.id) ?? [];
|
|
||||||
const out = outgoingByNode.get(n.id) ?? [];
|
|
||||||
const isFocused = focusedId === n.id;
|
|
||||||
const hasRelations = inc.length > 0 || out.length > 0;
|
|
||||||
const relationsId = `relations-${n.id}`;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={n.id}
|
|
||||||
className={`rounded transition ${isFocused ? "ring-1 ring-blue-300" : ""}`}
|
|
||||||
>
|
|
||||||
<TCard bordered>
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
<TTag size="small" theme={NODE_TYPE_THEME[n.type]} variant="light-outline">
|
|
||||||
{n.group?.trim() || MAF_FLOW_NODE_TYPE_LABEL[n.type]}
|
|
||||||
</TTag>
|
|
||||||
<span className="text-sm font-semibold text-slate-800">{n.label}</span>
|
|
||||||
{inc.length > 0 && (
|
|
||||||
<span className="text-[10px] text-slate-400">← {inc.length}</span>
|
|
||||||
)}
|
|
||||||
{out.length > 0 && (
|
|
||||||
<span className="text-[10px] text-slate-400">→ {out.length}</span>
|
|
||||||
)}
|
|
||||||
{hasRelations && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ml-auto inline-flex items-center text-[10px] text-slate-500 hover:text-slate-700"
|
|
||||||
aria-expanded={isFocused}
|
|
||||||
aria-controls={relationsId}
|
|
||||||
onClick={() => setFocusedId(isFocused ? null : n.id)}
|
|
||||||
>
|
|
||||||
{isFocused ? "收起关系" : "展开关系"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{(isFocused || n.detail) && n.detail && (
|
|
||||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-xs text-slate-600">
|
|
||||||
{n.detail}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{isFocused && hasRelations && (
|
|
||||||
<div id={relationsId} className="mt-2 space-y-1 border-t border-slate-100 pt-1.5">
|
|
||||||
{inc.length > 0 && (
|
|
||||||
<div className="text-[11px] text-slate-500">
|
|
||||||
<span className="text-slate-400">上游:</span>
|
|
||||||
{inc.map((e, i) => (
|
|
||||||
<button
|
|
||||||
key={`in-${i}`}
|
|
||||||
type="button"
|
|
||||||
className="mx-0.5 inline-flex items-center gap-0.5 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[10px] text-slate-600 hover:border-blue-300 hover:text-blue-600"
|
|
||||||
onClick={(ev) => { ev.stopPropagation(); setFocusedId(e.from); }}
|
|
||||||
>
|
|
||||||
{labelOf(e.from)}
|
|
||||||
{e.label && <span className="text-slate-400">·{e.label}</span>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{out.length > 0 && (
|
|
||||||
<div className="text-[11px] text-slate-500">
|
|
||||||
<span className="text-slate-400">下游:</span>
|
|
||||||
{out.map((e, i) => (
|
|
||||||
<button
|
|
||||||
key={`out-${i}`}
|
|
||||||
type="button"
|
|
||||||
className="mx-0.5 inline-flex items-center gap-0.5 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[10px] text-slate-600 hover:border-blue-300 hover:text-blue-600"
|
|
||||||
onClick={(ev) => { ev.stopPropagation(); setFocusedId(e.to); }}
|
|
||||||
>
|
|
||||||
{labelOf(e.to)}
|
|
||||||
{e.label && <span className="text-slate-400">·{e.label}</span>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TCard>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{focused && (
|
|
||||||
<div className="sticky bottom-0 flex items-center justify-between rounded border border-slate-200 bg-white/95 px-2 py-1 text-[10px] text-slate-400 backdrop-blur">
|
|
||||||
<span>已选:{focused.label}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-0.5 text-slate-500 hover:text-slate-700"
|
|
||||||
onClick={() => setFocusedId(null)}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" /> 取消选择
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 「大图」触发按钮的封装(内嵌预览面板里点开弹窗用)。 */
|
|
||||||
export function MafFlowGraphOpenButton({
|
|
||||||
onClick,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
onClick: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<TButton
|
|
||||||
theme="default"
|
|
||||||
variant="outline"
|
|
||||||
size="small"
|
|
||||||
icon={<Maximize2 className="h-3.5 w-3.5" />}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
大图
|
|
||||||
</TButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 类型图例(Collapse 折叠壳里展示节点类型含义)。 */
|
|
||||||
export function MafFlowGraphLegend() {
|
|
||||||
return (
|
|
||||||
<TCollapse className="rounded-lg border border-slate-200 bg-white">
|
|
||||||
<TCollapse.Panel value="legend" header="节点类型说明">
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{(Object.keys(MAF_FLOW_NODE_TYPE_LABEL) as MafFlowNodeType[]).map((t) => (
|
|
||||||
<TTag key={t} size="small" theme={NODE_TYPE_THEME[t]} variant="light-outline">
|
|
||||||
{MAF_FLOW_NODE_TYPE_LABEL[t]}
|
|
||||||
</TTag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TCollapse.Panel>
|
|
||||||
</TCollapse>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 功能开关守卫(MAF-06-01:可视层改用本地 `tdesign-react`)。
|
|
||||||
*
|
|
||||||
* 分别处理三种状态:
|
|
||||||
* - enabled=false → 「功能未启用」
|
|
||||||
* - enabled=true && store_available=false → 「功能已开启,但持久化存储未就绪」
|
|
||||||
* - 请求异常 → 「无法连接 MAF 服务」+ 重试按钮
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { DatabaseZap, Loader2, RefreshCw, ShieldAlert, ShieldOff } from "lucide-react";
|
|
||||||
import { Button as TButton } from "tdesign-react";
|
|
||||||
|
|
||||||
import { useMafHealth } from "../api/runs";
|
|
||||||
|
|
||||||
interface MafHealthGateProps {
|
|
||||||
/** enabled 时渲染的子树。 */
|
|
||||||
children: React.ReactNode;
|
|
||||||
/** 探测中时渲染的占位(默认 spinner)。 */
|
|
||||||
fallback?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MafHealthGate({ children, fallback }: MafHealthGateProps) {
|
|
||||||
const { data, isLoading, isError, refetch, isFetching } = useMafHealth();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<>{fallback ?? (
|
|
||||||
<div className="flex h-full w-full items-center justify-center text-slate-400">
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
正在检查 MAF 会商状态…
|
|
||||||
</div>
|
|
||||||
)}</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 请求异常 → 无法连接,给重试按钮
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-8 text-center">
|
|
||||||
<ShieldOff className="h-10 w-10 text-slate-300" />
|
|
||||||
<div className="text-base font-medium text-slate-500">无法连接 MAF 服务</div>
|
|
||||||
<p className="max-w-md text-sm text-slate-400">
|
|
||||||
请求 /api/maf-roundtable/health 失败,请检查后端服务是否正常运行。
|
|
||||||
</p>
|
|
||||||
<TButton
|
|
||||||
theme="default"
|
|
||||||
variant="outline"
|
|
||||||
size="small"
|
|
||||||
loading={isFetching}
|
|
||||||
icon={!isFetching ? <RefreshCw className="h-3.5 w-3.5" /> : undefined}
|
|
||||||
onClick={() => refetch()}
|
|
||||||
>
|
|
||||||
重试
|
|
||||||
</TButton>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 明确 disabled → 功能未启用
|
|
||||||
if (!data?.enabled) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-8 text-center">
|
|
||||||
<ShieldOff className="h-10 w-10 text-slate-300" />
|
|
||||||
<div className="text-base font-medium text-slate-500">MAF 会商功能未启用</div>
|
|
||||||
<p className="max-w-md text-sm text-slate-400">
|
|
||||||
后端未开启多智能体会商功能(maf_roundtable.enabled=false)。
|
|
||||||
请联系管理员在 config.yaml 中启用后重试。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// enabled=true 但 store_available=false → 存储未就绪
|
|
||||||
if (!data.store_available) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-8 text-center">
|
|
||||||
<DatabaseZap className="h-10 w-10 text-amber-400" />
|
|
||||||
<div className="text-base font-medium text-slate-500">持久化存储未就绪</div>
|
|
||||||
<p className="max-w-md text-sm text-slate-400">
|
|
||||||
MAF 会商功能已开启,但持久化存储(数据库)尚未配置或连接失败。
|
|
||||||
写操作将返回 503。请检查数据库配置后重试。
|
|
||||||
</p>
|
|
||||||
<TButton
|
|
||||||
theme="default"
|
|
||||||
variant="outline"
|
|
||||||
size="small"
|
|
||||||
loading={isFetching}
|
|
||||||
icon={!isFetching ? <RefreshCw className="h-3.5 w-3.5" /> : undefined}
|
|
||||||
onClick={() => refetch()}
|
|
||||||
>
|
|
||||||
重新检测
|
|
||||||
</TButton>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeGuardNotReady =
|
|
||||||
data.write_guard_enabled && !data.write_guard_ready;
|
|
||||||
const writeGuardMessage =
|
|
||||||
data.write_guard_status === "missing_secret"
|
|
||||||
? "WriteGuard 未就绪:未注入 MAF_ACTION_SECRET_KEY,外部写入将失败关闭;只读工具和技能仍可用。"
|
|
||||||
: "WriteGuard 未就绪:外部写入将失败关闭,请检查审批桥接配置;只读工具和技能仍可用。";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
|
||||||
{writeGuardNotReady && (
|
|
||||||
<div className="flex shrink-0 items-center gap-2 border-b border-amber-200 bg-amber-50 px-4 py-2 text-xs text-amber-800">
|
|
||||||
<ShieldAlert className="h-4 w-4 shrink-0" />
|
|
||||||
{writeGuardMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="min-h-0 flex-1">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF run 顶部状态条:状态 Tag + token/延迟 + 生命周期操作按钮。
|
|
||||||
*
|
|
||||||
* MAF-06-01:可视层改用本地 `tdesign-react`(Tag / Button)。
|
|
||||||
*
|
|
||||||
* 操作:
|
|
||||||
* - live(pending/running/waiting_approval)→ 取消
|
|
||||||
* - waiting_approval → 恢复(resume)**仅在 canResume=true 时显示**
|
|
||||||
* 后端契约:存在 pending approval 时 /resume 返回 409;故 canResume 由父组件按下述规则计算:
|
|
||||||
* 审批已加载成功 && 无 pending 审批 && 仍处于 waiting_approval。
|
|
||||||
* 审批清零后由后端 dispatcher 自动恢复;仅在自动恢复未生效时才允许手动补偿。
|
|
||||||
* - cancel_failed → 重试取消(仍可点取消)
|
|
||||||
* - failed → 重试(recover)
|
|
||||||
*
|
|
||||||
* 取消/恢复/重试按钮均在 mutation pending 时禁用(防重复点击)。
|
|
||||||
*
|
|
||||||
* 状态 Tag 主题按 run.status 区分。token_usage/latency_ms 展示在右侧。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { AlertTriangle, CheckCircle2, Loader2, RotateCcw, Square, XCircle } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button as TButton, Tag as TTag } from "tdesign-react";
|
|
||||||
|
|
||||||
import { useMafRunActions } from "../api/runs";
|
|
||||||
import { runIsLive, type MafRun, type MafRunStatus } from "../api/types";
|
|
||||||
|
|
||||||
interface MafRunHeaderProps {
|
|
||||||
run: MafRun;
|
|
||||||
/**
|
|
||||||
* 是否允许手动恢复 run。
|
|
||||||
* 规则见 lib/run-resume.ts 的 canManualResume;未提供时默认 false(保守:不显示恢复)。
|
|
||||||
*/
|
|
||||||
canResume?: boolean;
|
|
||||||
/**
|
|
||||||
* 当 canResume=false 且 run 处于 waiting_approval 时,展示给用户的解释文案
|
|
||||||
* (例如「请先完成全部审批」「正在恢复工作流…」)。未提供则用默认兜底。
|
|
||||||
*/
|
|
||||||
resumeBlockedHint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** run 状态 → 中文 + Tag 主题 + 图标。 */
|
|
||||||
function statusMeta(status: MafRunStatus): {
|
|
||||||
label: string;
|
|
||||||
theme: "default" | "primary" | "success" | "warning" | "danger";
|
|
||||||
icon: React.ReactNode;
|
|
||||||
tone: string;
|
|
||||||
} {
|
|
||||||
switch (status) {
|
|
||||||
case "pending":
|
|
||||||
return { label: "排队中", theme: "default", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-slate-500" };
|
|
||||||
case "running":
|
|
||||||
return { label: "进行中", theme: "primary", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-blue-600" };
|
|
||||||
case "waiting_approval":
|
|
||||||
return { label: "等待审批", theme: "warning", icon: <AlertTriangle className="h-3 w-3" />, tone: "text-amber-600" };
|
|
||||||
case "completed":
|
|
||||||
return { label: "已完成", theme: "success", icon: <CheckCircle2 className="h-3 w-3" />, tone: "text-emerald-600" };
|
|
||||||
case "failed":
|
|
||||||
return { label: "失败", theme: "danger", icon: <XCircle className="h-3 w-3" />, tone: "text-red-600" };
|
|
||||||
case "cancelled":
|
|
||||||
return { label: "已取消", theme: "default", icon: <Square className="h-3 w-3" />, tone: "text-slate-500" };
|
|
||||||
case "cancelling":
|
|
||||||
return { label: "取消中", theme: "warning", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-amber-600" };
|
|
||||||
case "cancel_failed":
|
|
||||||
return { label: "取消失败", theme: "danger", icon: <AlertTriangle className="h-3 w-3" />, tone: "text-red-600" };
|
|
||||||
default:
|
|
||||||
return { label: status, theme: "default", icon: null, tone: "text-slate-500" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MafRunHeader({ run, canResume = false, resumeBlockedHint }: MafRunHeaderProps) {
|
|
||||||
const actions = useMafRunActions();
|
|
||||||
const meta = statusMeta(run.status);
|
|
||||||
const live = runIsLive(run.status);
|
|
||||||
|
|
||||||
const onCancel = () => {
|
|
||||||
actions.cancel.mutate(run.id, {
|
|
||||||
onSuccess: () => toast.success("已发起取消"),
|
|
||||||
onError: (e: Error) => toast.error(`取消失败:${e.message}`),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const onResume = () => {
|
|
||||||
actions.resume.mutate(run.id, {
|
|
||||||
onSuccess: () => toast.success("已恢复会商"),
|
|
||||||
onError: (e: Error) => toast.error(`恢复失败:${e.message}`),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const onRecover = () => {
|
|
||||||
actions.recover.mutate(run.id, {
|
|
||||||
onSuccess: () => toast.success("已重试会商"),
|
|
||||||
onError: (e: Error) => toast.error(`重试失败:${e.message}`),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// token 汇总
|
|
||||||
const tokenTotal = run.token_usage
|
|
||||||
? Object.values(run.token_usage).reduce((a, b) => a + (b || 0), 0)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center gap-3 border-b border-slate-200 bg-white px-4 py-2.5">
|
|
||||||
<div className={`flex items-center gap-1.5 ${meta.tone}`}>
|
|
||||||
{meta.icon}
|
|
||||||
<span className="text-sm font-semibold">{meta.label}</span>
|
|
||||||
</div>
|
|
||||||
<TTag size="small" theme="default" variant="outline">
|
|
||||||
{run.workflow_version ?? "maf"}
|
|
||||||
</TTag>
|
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-3 text-xs text-slate-500">
|
|
||||||
{tokenTotal != null && (
|
|
||||||
<span title="累计 token">tokens: {tokenTotal.toLocaleString()}</span>
|
|
||||||
)}
|
|
||||||
{run.latency_ms != null && (
|
|
||||||
<span title="耗时">{(run.latency_ms / 1000).toFixed(1)}s</span>
|
|
||||||
)}
|
|
||||||
{run.last_event_seq > 0 && (
|
|
||||||
<span title="最新事件序号">seq#{run.last_event_seq}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{run.status === "waiting_approval" && canResume && (
|
|
||||||
<TButton
|
|
||||||
theme="default"
|
|
||||||
variant="outline"
|
|
||||||
size="small"
|
|
||||||
loading={actions.resume.isPending}
|
|
||||||
disabled={actions.resume.isPending}
|
|
||||||
onClick={onResume}
|
|
||||||
>
|
|
||||||
恢复
|
|
||||||
</TButton>
|
|
||||||
)}
|
|
||||||
{run.status === "waiting_approval" && !canResume && (
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1 text-[11px] text-slate-400"
|
|
||||||
title="审批清零后由后端自动恢复;自动恢复未生效时才会出现手动恢复按钮"
|
|
||||||
>
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
{resumeBlockedHint ?? "等待审批处理或自动恢复…"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{run.status === "failed" && (
|
|
||||||
<TButton
|
|
||||||
theme="default"
|
|
||||||
variant="outline"
|
|
||||||
size="small"
|
|
||||||
loading={actions.recover.isPending}
|
|
||||||
disabled={actions.recover.isPending}
|
|
||||||
icon={<RotateCcw className="h-3.5 w-3.5" />}
|
|
||||||
onClick={onRecover}
|
|
||||||
>
|
|
||||||
重试
|
|
||||||
</TButton>
|
|
||||||
)}
|
|
||||||
{(live || run.status === "cancel_failed") && (
|
|
||||||
<TButton
|
|
||||||
theme="danger"
|
|
||||||
variant="outline"
|
|
||||||
size="small"
|
|
||||||
loading={actions.cancel.isPending}
|
|
||||||
disabled={actions.cancel.isPending}
|
|
||||||
icon={<Square className="h-3.5 w-3.5" />}
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</TButton>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{run.error_message_safe && (
|
|
||||||
<div className="w-full text-xs text-red-600">
|
|
||||||
错误:{run.error_message_safe}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 席位状态矩阵(MAF-06-07:可视层改用本地 `tdesign-react`)。
|
|
||||||
*
|
|
||||||
* 数据来自 `run.maf_checkpoint.seat_outcomes`({agent_id: {status, execution_stage, ...}})。
|
|
||||||
* 每个席位一行:agent_id + status Tag + execution_stage Tag。
|
|
||||||
*
|
|
||||||
* execution_stage 取值(Phase-3 闭合枚举):
|
|
||||||
* legacy / planner / expert / format_repair / cross_review / cross_review_repair /
|
|
||||||
* finalizer / group_chat
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Tag as TTag } from "tdesign-react";
|
|
||||||
|
|
||||||
import type { MafCheckpoint, MafSeatOutcome } from "../api/types";
|
|
||||||
|
|
||||||
interface MafSeatStatusPanelProps {
|
|
||||||
checkpoint: MafCheckpoint | null;
|
|
||||||
/** 当前轮次(展示用)。 */
|
|
||||||
currentRound?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STAGE_LABELS: Record<string, string> = {
|
|
||||||
legacy: "旧版",
|
|
||||||
planner: "规划",
|
|
||||||
expert: "专家分析",
|
|
||||||
format_repair: "格式修复",
|
|
||||||
cross_review: "交叉评审",
|
|
||||||
cross_review_repair: "评审修复",
|
|
||||||
finalizer: "总结",
|
|
||||||
group_chat: "群聊",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 席位状态 → TDesign Tag 主题。 */
|
|
||||||
function seatStatusTheme(status: string): "default" | "primary" | "success" | "danger" | "warning" {
|
|
||||||
switch (status) {
|
|
||||||
case "completed":
|
|
||||||
return "success";
|
|
||||||
case "running":
|
|
||||||
return "primary";
|
|
||||||
case "failed":
|
|
||||||
return "danger";
|
|
||||||
case "cancelled":
|
|
||||||
return "default";
|
|
||||||
default:
|
|
||||||
return "default";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MafSeatStatusPanel({ checkpoint, currentRound }: MafSeatStatusPanelProps) {
|
|
||||||
const outcomes = checkpoint?.seat_outcomes;
|
|
||||||
const entries: Array<[string, MafSeatOutcome]> = outcomes
|
|
||||||
? Object.entries(outcomes)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="rounded-lg border border-slate-200 bg-white p-3">
|
|
||||||
<header className="mb-2 flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-semibold text-slate-700">席位状态</h3>
|
|
||||||
{typeof currentRound === "number" && (
|
|
||||||
<span className="text-xs text-slate-400">第 {currentRound + 1} 轮</span>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{entries.length === 0 ? (
|
|
||||||
<p className="py-4 text-center text-xs text-slate-400">尚无席位产出</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-1.5">
|
|
||||||
{entries.map(([agentId, outcome]) => (
|
|
||||||
<li
|
|
||||||
key={agentId}
|
|
||||||
className="flex items-center gap-2 rounded-md border border-slate-100 bg-slate-50/60 px-2.5 py-1.5"
|
|
||||||
>
|
|
||||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-slate-700" title={agentId}>
|
|
||||||
{agentId}
|
|
||||||
</span>
|
|
||||||
{outcome.execution_stage && (
|
|
||||||
<TTag size="small" theme="default" variant="light-outline">
|
|
||||||
{STAGE_LABELS[outcome.execution_stage] ?? outcome.execution_stage}
|
|
||||||
</TTag>
|
|
||||||
)}
|
|
||||||
<TTag size="small" theme={seatStatusTheme(outcome.status ?? "unknown")} variant="light">
|
|
||||||
{outcome.status ?? "unknown"}
|
|
||||||
</TTag>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 事件 cursor 轮询 hook —— Phase-4 实时更新的核心。
|
|
||||||
*
|
|
||||||
* 为什么不用 `useQuery + refetchInterval`:
|
|
||||||
* cursor `after_seq` 必须在每次拉取后推进。useQuery 的 queryFn 不持有「上次的
|
|
||||||
* latest_seq」;把 after_seq 塞进 queryKey 会导致每次推进都重置查询、反复重拉全量,
|
|
||||||
* 产生抖动。所以用自定义 interval hook:闭包里用 ref 持有 cursor。
|
|
||||||
*
|
|
||||||
* P0-3 修复:
|
|
||||||
* 1. 终态 run 也拉历史事件(enabled=true 即补拉,不管是否 live)。
|
|
||||||
* 2. 一次超过 PAGE_LIMIT 条事件时,循环翻页直到返回数 < PAGE_LIMIT 或无更大 seq。
|
|
||||||
* 3. inFlightRef 防止网络慢时上一个请求尚未结束、下一个 interval 又开始请求。
|
|
||||||
* 4. 游标只推进到「本次实际收到的最大 seq」,不直接跳到 latest_seq。
|
|
||||||
*
|
|
||||||
* P0 竞态修复:
|
|
||||||
* 用 generationRef 隔离不同 runId/请求代次。切换 run 时 generation++,
|
|
||||||
* 所有异步回调在写 state 前检查 generation 是否仍匹配——旧 run 的慢响应
|
|
||||||
* 到达时 generation 已变,直接丢弃,不会把旧事件写进新页面。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
import { listEvents } from "../api/events";
|
|
||||||
import { useMafRun } from "../api/runs";
|
|
||||||
import { runIsLive, type MafEvent } from "../api/types";
|
|
||||||
|
|
||||||
/** 事件轮询间隔(ms)。1.5s —— 略快于 run 状态轮询,保证事件即时涌现。 */
|
|
||||||
const POLL_INTERVAL = 1500;
|
|
||||||
|
|
||||||
/** 单次拉取上限(后端最大 10000)。1000 足够覆盖一轮多席位的并发事件。 */
|
|
||||||
const PAGE_LIMIT = 1000;
|
|
||||||
|
|
||||||
/** 按 seq 去重合并(新事件覆盖同 seq 旧事件,正常不会发生但做幂等保险)。 */
|
|
||||||
export function mergeBySeq(prev: MafEvent[], incoming: MafEvent[]): MafEvent[] {
|
|
||||||
if (incoming.length === 0) return prev;
|
|
||||||
const bySeq = new Map<number, MafEvent>();
|
|
||||||
for (const e of prev) bySeq.set(e.seq, e);
|
|
||||||
for (const e of incoming) bySeq.set(e.seq, e);
|
|
||||||
return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request-generation gate shared by the hook and its race-condition tests.
|
|
||||||
* Each generation has its own in-flight slot, so a stale request can finish
|
|
||||||
* independently without blocking a newer run's terminal-state backlog pull.
|
|
||||||
*/
|
|
||||||
export interface MafEventRequestCoordinator {
|
|
||||||
readonly currentGeneration: number;
|
|
||||||
nextGeneration: () => number;
|
|
||||||
start: (generation: number) => boolean;
|
|
||||||
finish: (generation: number) => void;
|
|
||||||
isCurrent: (generation: number) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMafEventRequestCoordinator(): MafEventRequestCoordinator {
|
|
||||||
let currentGeneration = 0;
|
|
||||||
const inFlight = new Set<number>();
|
|
||||||
return {
|
|
||||||
get currentGeneration() {
|
|
||||||
return currentGeneration;
|
|
||||||
},
|
|
||||||
nextGeneration() {
|
|
||||||
currentGeneration += 1;
|
|
||||||
return currentGeneration;
|
|
||||||
},
|
|
||||||
start(generation) {
|
|
||||||
if (inFlight.has(generation)) return false;
|
|
||||||
inFlight.add(generation);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
finish(generation) {
|
|
||||||
inFlight.delete(generation);
|
|
||||||
},
|
|
||||||
isCurrent(generation) {
|
|
||||||
return generation === currentGeneration;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseMafEventsResult {
|
|
||||||
/** 按 seq 升序的事件列表(已去重)。 */
|
|
||||||
events: MafEvent[];
|
|
||||||
/** 当前已拉到的最大 seq(供调试/展示)。 */
|
|
||||||
latestSeq: number;
|
|
||||||
/** 手动重置(清空事件、cursor 归零、立即补拉)。 */
|
|
||||||
reset: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 轮询某 run 的事件流。
|
|
||||||
*
|
|
||||||
* @param runId run id;undefined/null 时不轮询
|
|
||||||
* @param opts.enabled 是否启用(有 run 即 true,无论终态/live——终态也要补拉历史)
|
|
||||||
* @param opts.polling 是否启动定时器(一般传 `runIsLive(run.status)`)
|
|
||||||
*/
|
|
||||||
export function useMafEvents(
|
|
||||||
runId: string | undefined | null,
|
|
||||||
opts: { enabled: boolean; polling: boolean },
|
|
||||||
): UseMafEventsResult {
|
|
||||||
const { enabled, polling } = opts;
|
|
||||||
const [events, setEvents] = useState<MafEvent[]>([]);
|
|
||||||
const [latestSeq, setLatestSeq] = useState(0);
|
|
||||||
const afterSeqRef = useRef(0);
|
|
||||||
const requestCoordinatorRef = useRef(createMafEventRequestCoordinator());
|
|
||||||
const [resetSignal, setResetSignal] = useState(0);
|
|
||||||
const initializedRef = useRef(false);
|
|
||||||
const lastRunIdRef = useRef<string | undefined | null>(runId);
|
|
||||||
const lastResetSignalRef = useRef(resetSignal);
|
|
||||||
|
|
||||||
const reset = useCallback(() => setResetSignal((n) => n + 1), []);
|
|
||||||
|
|
||||||
// Every runId/enabled/polling/resetSignal change starts a new request
|
|
||||||
// generation. Only run changes and explicit reset clear the accumulated
|
|
||||||
// data; toggling enabled/polling must cancel old work without losing the
|
|
||||||
// already accepted backlog.
|
|
||||||
useEffect(() => {
|
|
||||||
const identityChanged =
|
|
||||||
!initializedRef.current ||
|
|
||||||
lastRunIdRef.current !== runId ||
|
|
||||||
lastResetSignalRef.current !== resetSignal;
|
|
||||||
requestCoordinatorRef.current.nextGeneration();
|
|
||||||
if (identityChanged) {
|
|
||||||
setEvents([]);
|
|
||||||
setLatestSeq(0);
|
|
||||||
afterSeqRef.current = 0;
|
|
||||||
}
|
|
||||||
lastRunIdRef.current = runId;
|
|
||||||
lastResetSignalRef.current = resetSignal;
|
|
||||||
initializedRef.current = true;
|
|
||||||
}, [runId, enabled, polling, resetSignal]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!runId || !enabled) return;
|
|
||||||
const myGeneration = requestCoordinatorRef.current.currentGeneration;
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 afterSeqRef.current 开始循环翻页拉取,直到:
|
|
||||||
* - 返回事件数 < PAGE_LIMIT(没有更多了)
|
|
||||||
* - 本页最大 seq <= cursor(无进展,防死循环)
|
|
||||||
* - generation 已变(切换了 run,丢弃结果)
|
|
||||||
*/
|
|
||||||
async function fetchBacklog() {
|
|
||||||
if (!requestCoordinatorRef.current.start(myGeneration)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
let cursor = afterSeqRef.current;
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-constant-condition
|
|
||||||
while (true) {
|
|
||||||
if (
|
|
||||||
cancelled ||
|
|
||||||
!requestCoordinatorRef.current.isCurrent(myGeneration)
|
|
||||||
) return;
|
|
||||||
const res = await listEvents(runId!, cursor, PAGE_LIMIT);
|
|
||||||
// 响应回来后再次检查代次(网络慢时可能已切换 run)
|
|
||||||
if (
|
|
||||||
cancelled ||
|
|
||||||
!requestCoordinatorRef.current.isCurrent(myGeneration)
|
|
||||||
) return;
|
|
||||||
|
|
||||||
const page = res.events;
|
|
||||||
|
|
||||||
// 本页最大 seq(只推进到实际收到的最大 seq,不跳 latest_seq)
|
|
||||||
let maxSeq = cursor;
|
|
||||||
for (const e of page) {
|
|
||||||
if (e.seq > maxSeq) maxSeq = e.seq;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (page.length > 0) {
|
|
||||||
setEvents((prev) => mergeBySeq(prev, page));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 无进展 → 停
|
|
||||||
if (maxSeq <= cursor) break;
|
|
||||||
|
|
||||||
cursor = maxSeq;
|
|
||||||
afterSeqRef.current = cursor;
|
|
||||||
setLatestSeq(cursor);
|
|
||||||
|
|
||||||
// 不满一页 → 没有更多了
|
|
||||||
if (page.length < PAGE_LIMIT) break;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 单次拉取失败(网络抖动/瞬断)静默——下次 interval 重试。
|
|
||||||
} finally {
|
|
||||||
// Never clear a newer generation's in-flight marker from a stale
|
|
||||||
// request. This is what lets a terminal-state refetch start while
|
|
||||||
// the old live poll is still unresolved.
|
|
||||||
requestCoordinatorRef.current.finish(myGeneration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 进入即补拉历史(after_seq=0 全量,终态 run 也走这里)
|
|
||||||
void fetchBacklog();
|
|
||||||
|
|
||||||
// polling=true 才启动定时器
|
|
||||||
if (!polling) {
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}
|
|
||||||
const timer = window.setInterval(() => void fetchBacklog(), POLL_INTERVAL);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
window.clearInterval(timer);
|
|
||||||
};
|
|
||||||
}, [runId, enabled, polling, resetSignal]);
|
|
||||||
|
|
||||||
return { events, latestSeq, reset };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 组合 hook:同时跟踪 run 状态 + 轮询事件。
|
|
||||||
*
|
|
||||||
* 用法:`const { run, events } = useMafRunWithEvents(runId)`。
|
|
||||||
* run live 时自动轮询事件;终态时 run 仍按 refetchInterval 停,事件补拉历史后停。
|
|
||||||
*/
|
|
||||||
export function useMafRunWithEvents(runId: string | undefined | null) {
|
|
||||||
const runQuery = useMafRun(runId);
|
|
||||||
const live = runIsLive(runQuery.data?.status);
|
|
||||||
const hasRun = !!runQuery.data;
|
|
||||||
const { events, latestSeq, reset } = useMafEvents(runId, {
|
|
||||||
enabled: hasRun,
|
|
||||||
polling: live,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
run: runQuery.data,
|
|
||||||
isLoadingRun: runQuery.isLoading,
|
|
||||||
runError: runQuery.error,
|
|
||||||
events,
|
|
||||||
latestSeq,
|
|
||||||
resetEvents: reset,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 多智能体会商前端 —— barrel 导出。
|
|
||||||
*
|
|
||||||
* 对外只暴露页面(路由用)和必要的 hook/类型;内部 api/lib 组件按需从子路径导入。
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { default as MafSessionsPage } from "./pages/MafSessionsPage";
|
|
||||||
export { default as MafRunDetailPage } from "./pages/MafRunDetailPage";
|
|
||||||
|
|
||||||
// 常用 hook(页面/外部消费)
|
|
||||||
export { useMafHealth, useMafRun, useMafRuns, useMafRunActions } from "./api/runs";
|
|
||||||
export { useMafSessions, useMafSession, useMafSessionActions } from "./api/sessions";
|
|
||||||
export { useMafApprovals, useMafActions, useMafApprovalActions } from "./api/approvals";
|
|
||||||
export { useMafEvents, useMafRunWithEvents } from "./hooks/use-maf-events";
|
|
||||||
|
|
||||||
// 类型
|
|
||||||
export type {
|
|
||||||
MafRun,
|
|
||||||
MafRunStatus,
|
|
||||||
MafSession,
|
|
||||||
MafEvent,
|
|
||||||
MafEventType,
|
|
||||||
MafApproval,
|
|
||||||
MafAction,
|
|
||||||
MafCheckpoint,
|
|
||||||
MafFactLedger,
|
|
||||||
MafHealthResponse,
|
|
||||||
} from "./api/types";
|
|
||||||
export { runIsLive } from "./api/types";
|
|
||||||
|
|
||||||
// 纯函数(可单测)
|
|
||||||
export { foldEventsToTimeline } from "./lib/event-timeline";
|
|
||||||
export type { TimelineEntry, TimelineEntryKind } from "./lib/event-timeline";
|
|
||||||
export { buildMafFlowGraph, buildMafFactFlowGraph, extractConclusionFlow } from "./lib/maf-flow-graph";
|
|
||||||
|
|
||||||
// 组件
|
|
||||||
export { MafHealthGate } from "./components/MafHealthGate";
|
|
||||||
export { MafRunHeader } from "./components/MafRunHeader";
|
|
||||||
export { MafEventTimeline } from "./components/MafEventTimeline";
|
|
||||||
export { MafSeatStatusPanel } from "./components/MafSeatStatusPanel";
|
|
||||||
export { MafFactFlowPanel } from "./components/MafFactFlowPanel";
|
|
||||||
export { MafApprovalCard } from "./components/MafApprovalCard";
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
/**
|
|
||||||
* 把 MafAction[] 按后端 status 归类成审计面板的可读分桶。
|
|
||||||
*
|
|
||||||
* 对齐后端 schemas.py 的 MafActionStatus 与文档约定的展示语义:
|
|
||||||
* pending / waiting_approval → 等待人工决定
|
|
||||||
* approved → 已批准,等待或正在恢复
|
|
||||||
* rejected / expired → 已拒绝或已过期,不会执行
|
|
||||||
* executed → 已执行
|
|
||||||
* failed → 已确认失败
|
|
||||||
* unknown → 结果未知,禁止自动重试
|
|
||||||
*
|
|
||||||
* 纯函数:输入 actions[],输出有序分桶(稳定排序,便于稳定渲染与单测)。
|
|
||||||
* 未知 status 落到「结果未知」桶(保守,不静默吞掉任何动作)。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { MafAction } from "../api/types";
|
|
||||||
|
|
||||||
export type ActionBucketKey =
|
|
||||||
| "waiting"
|
|
||||||
| "approved"
|
|
||||||
| "rejected_or_expired"
|
|
||||||
| "executed"
|
|
||||||
| "failed"
|
|
||||||
| "unknown";
|
|
||||||
|
|
||||||
export interface ActionBucket {
|
|
||||||
key: ActionBucketKey;
|
|
||||||
/** 该桶的人类可读标题。 */
|
|
||||||
label: string;
|
|
||||||
/** 该桶的简短说明(展示在标题下方)。 */
|
|
||||||
hint: string;
|
|
||||||
items: MafAction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 后端 status → 桶 key。未知 status 落到 unknown(保守,不静默吞掉)。 */
|
|
||||||
export function classifyActionStatus(status: string | null | undefined): ActionBucketKey {
|
|
||||||
switch (status) {
|
|
||||||
case "pending":
|
|
||||||
case "waiting_approval":
|
|
||||||
return "waiting";
|
|
||||||
case "approved":
|
|
||||||
return "approved";
|
|
||||||
case "rejected":
|
|
||||||
case "expired":
|
|
||||||
return "rejected_or_expired";
|
|
||||||
case "executed":
|
|
||||||
return "executed";
|
|
||||||
case "failed":
|
|
||||||
return "failed";
|
|
||||||
case "unknown":
|
|
||||||
default:
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 桶的展示元信息(顺序即渲染顺序)。 */
|
|
||||||
export const ACTION_BUCKET_META: Record<ActionBucketKey, { label: string; hint: string }> = {
|
|
||||||
waiting: { label: "等待人工决定", hint: "pending / waiting_approval" },
|
|
||||||
approved: { label: "已批准,等待或正在恢复", hint: "approved" },
|
|
||||||
rejected_or_expired: { label: "已拒绝或已过期,不会执行", hint: "rejected / expired" },
|
|
||||||
executed: { label: "已执行", hint: "executed" },
|
|
||||||
failed: { label: "已确认失败", hint: "failed" },
|
|
||||||
unknown: { label: "结果未知,禁止自动重试", hint: "unknown" },
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 桶顺序(渲染顺序)。 */
|
|
||||||
const BUCKET_ORDER: ActionBucketKey[] = [
|
|
||||||
"waiting",
|
|
||||||
"approved",
|
|
||||||
"executed",
|
|
||||||
"failed",
|
|
||||||
"rejected_or_expired",
|
|
||||||
"unknown",
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把 actions 按 status 归类成有序分桶。空桶也会返回(便于 UI 显示「0」计数),
|
|
||||||
* 调用方可自行决定是否隐藏空桶。
|
|
||||||
*/
|
|
||||||
export function classifyActionsByStatus(actions: MafAction[] | null | undefined): ActionBucket[] {
|
|
||||||
const byKey = new Map<ActionBucketKey, MafAction[]>();
|
|
||||||
for (const k of BUCKET_ORDER) byKey.set(k, []);
|
|
||||||
for (const a of actions ?? []) {
|
|
||||||
const key = classifyActionStatus(a.status);
|
|
||||||
byKey.get(key)!.push(a);
|
|
||||||
}
|
|
||||||
return BUCKET_ORDER.map((key) => ({
|
|
||||||
key,
|
|
||||||
label: ACTION_BUCKET_META[key].label,
|
|
||||||
hint: ACTION_BUCKET_META[key].hint,
|
|
||||||
items: byKey.get(key)!,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 审批卡片按钮的禁用判定 —— 纯函数。
|
|
||||||
*
|
|
||||||
* 审批卡片的「批准/拒绝」按钮必须在 mutation 进行中(pending)或已批准等待恢复(resuming)
|
|
||||||
* 时禁用,防止用户双击导致重复决策。把判定抽成纯函数,作为组件与测试的共同事实源:
|
|
||||||
* 组件 import 它设置 disabled,测试 import 它验证守卫,避免测试复制守卫逻辑、
|
|
||||||
* 组件改坏却仍通过。
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface DecisionButtonState {
|
|
||||||
/** mutation 是否在进行中(useMutation.isPending)。 */
|
|
||||||
pending: boolean;
|
|
||||||
/** 已批准、正在等待后端 dispatcher.resume() 自动恢复期间。 */
|
|
||||||
resuming: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 审批决策按钮是否应被禁用。
|
|
||||||
*
|
|
||||||
* 规则:pending 或 resuming 任一为 true → 禁用(防重复提交 + 防恢复期间二次点击)。
|
|
||||||
* 这是「pending 审批双击只发一次决策」的唯一事实源。
|
|
||||||
*/
|
|
||||||
export function isDecisionButtonDisabled(state: DecisionButtonState): boolean {
|
|
||||||
return state.pending || state.resuming;
|
|
||||||
}
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
/**
|
|
||||||
* 把 MAF 原始事件流(按 seq 升序)折叠成时间线可读条目。
|
|
||||||
*
|
|
||||||
* 后端事件分类见 app/maf_roundtable/event_normalizer.py:
|
|
||||||
* agent.message.delta —— 流式 token 片段,需聚合为一条消息
|
|
||||||
* agent.message.completed —— 一轮完整回复(含 content)
|
|
||||||
* tool.* —— 工具生命周期,折叠为单条「调用 → 结果」
|
|
||||||
* action.waiting_approval —— 审批锚点(右侧审批卡片入口)
|
|
||||||
* run.* / skill.* —— 状态/生命周期条目
|
|
||||||
*
|
|
||||||
* 设计:**纯函数**,无 React、无副作用,便于单测。输入 events[](已去重升序),
|
|
||||||
* 输出 TimelineEntry[](时间线渲染顺序)。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { MafEvent } from "../api/types";
|
|
||||||
|
|
||||||
export type TimelineEntryKind =
|
|
||||||
| "message" // 智能体消息(delta 聚合 或 completed)
|
|
||||||
| "tool" // 工具调用生命周期(requested→completed/failed 折叠)
|
|
||||||
| "skill" // 技能生命周期
|
|
||||||
| "approval" // 审批锚点(waiting/approved/rejected)
|
|
||||||
| "artifact" // 产出物
|
|
||||||
| "run" // run 生命周期(started/completed/failed/cancelled)
|
|
||||||
| "checkpoint" // 工作流检查点
|
|
||||||
| "turn"; // 席位轮次开始
|
|
||||||
|
|
||||||
export interface TimelineEntry {
|
|
||||||
/** 用于 React key,全局唯一。 */
|
|
||||||
key: string;
|
|
||||||
kind: TimelineEntryKind;
|
|
||||||
/** 原始 event_type(供组件按类型配色/图标)。 */
|
|
||||||
eventType: string;
|
|
||||||
/** 触发该条目的 agent_id(席位/智能体标识)。 */
|
|
||||||
agentId: string | null;
|
|
||||||
/** 轮次索引(便于分组)。 */
|
|
||||||
turnIndex: number | null;
|
|
||||||
seq: number;
|
|
||||||
createdAt: string | null;
|
|
||||||
/** 主标题(工具名 / 状态文案 / 席位名)。 */
|
|
||||||
title: string;
|
|
||||||
/** 正文(消息内容 / 工具结果摘要 / 错误信息)。 */
|
|
||||||
body?: string;
|
|
||||||
/** 关联的二级信息(action_id/approval_id/tool_call_id 等)。 */
|
|
||||||
meta?: Record<string, unknown> | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** delta 聚合的分组键:(agent_id, turn_index) —— 同一席位同一轮的 delta 拼成一条。 */
|
|
||||||
function deltaGroupKey(ev: MafEvent): string {
|
|
||||||
return `delta:${ev.agent_id ?? ""}:${ev.turn_index ?? 0}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 折叠事件流为时间线条目。
|
|
||||||
*
|
|
||||||
* 聚合规则:
|
|
||||||
* 1. `agent.message.delta` 按 (agent,turn) 分组拼接 → 一条 message(取最大 seq 为排序键)。
|
|
||||||
* 若同组有 `agent.message.completed`,则用 completed 的完整 content 覆盖。
|
|
||||||
* 2. `tool.requested`/`tool.started`/`tool.completed`/`tool.failed` 按 tool_call_id 折叠
|
|
||||||
* 为一条 tool 条目(最终状态 + 名称)。
|
|
||||||
* 3. 其余每事件一条。
|
|
||||||
*/
|
|
||||||
export function foldEventsToTimeline(events: MafEvent[]): TimelineEntry[] {
|
|
||||||
const entries: TimelineEntry[] = [];
|
|
||||||
// delta 分组缓冲:groupKey → { texts[], lastSeq, agent, turn, createdAt }
|
|
||||||
const deltaBuf = new Map<
|
|
||||||
string,
|
|
||||||
{ texts: string[]; seq: number; ev: MafEvent; completed?: MafEvent }
|
|
||||||
>();
|
|
||||||
// tool 折叠:tool_call_id → entry(先建占位,后续更新)
|
|
||||||
const toolMap = new Map<string, TimelineEntry>();
|
|
||||||
|
|
||||||
for (const ev of events) {
|
|
||||||
const p = ev.payload ?? {};
|
|
||||||
switch (ev.event_type) {
|
|
||||||
case "agent.message.delta": {
|
|
||||||
const text = typeof p.text === "string" ? p.text : "";
|
|
||||||
if (!text) break;
|
|
||||||
const k = deltaGroupKey(ev);
|
|
||||||
const cur = deltaBuf.get(k);
|
|
||||||
if (cur) {
|
|
||||||
cur.texts.push(text);
|
|
||||||
if (ev.seq > cur.seq) {
|
|
||||||
cur.seq = ev.seq;
|
|
||||||
cur.ev = ev;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
deltaBuf.set(k, { texts: [text], seq: ev.seq, ev });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "agent.message.completed": {
|
|
||||||
const k = deltaGroupKey(ev);
|
|
||||||
const cur = deltaBuf.get(k);
|
|
||||||
const content = typeof p.content === "string" ? p.content : "";
|
|
||||||
if (cur) {
|
|
||||||
cur.completed = ev;
|
|
||||||
if (content) cur.texts = [content]; // completed 的完整内容覆盖 delta 拼接
|
|
||||||
if (ev.seq > cur.seq) cur.seq = ev.seq;
|
|
||||||
} else {
|
|
||||||
deltaBuf.set(k, {
|
|
||||||
texts: content ? [content] : [],
|
|
||||||
seq: ev.seq,
|
|
||||||
ev,
|
|
||||||
completed: ev,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "tool.requested":
|
|
||||||
case "tool.started":
|
|
||||||
case "tool.completed":
|
|
||||||
case "tool.failed": {
|
|
||||||
const tcId = typeof p.tool_call_id === "string" ? p.tool_call_id : "";
|
|
||||||
const toolName = typeof p.tool_name === "string" ? p.tool_name : "";
|
|
||||||
const mapKey = tcId || `${ev.event_type}:${ev.seq}`;
|
|
||||||
const status =
|
|
||||||
ev.event_type === "tool.failed"
|
|
||||||
? "failed"
|
|
||||||
: ev.event_type === "tool.completed"
|
|
||||||
? "completed"
|
|
||||||
: ev.event_type === "tool.started"
|
|
||||||
? "started"
|
|
||||||
: "requested";
|
|
||||||
const existing = toolMap.get(mapKey);
|
|
||||||
if (existing) {
|
|
||||||
// 更新到最新状态(seq 更大者覆盖)
|
|
||||||
if (ev.seq >= existing.seq) {
|
|
||||||
existing.seq = ev.seq;
|
|
||||||
existing.title = toolName || existing.title;
|
|
||||||
existing.body =
|
|
||||||
typeof p.content_preview === "string"
|
|
||||||
? p.content_preview
|
|
||||||
: existing.body;
|
|
||||||
existing.meta = { ...existing.meta, ...p, status };
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toolMap.set(mapKey, {
|
|
||||||
key: `tool:${mapKey}`,
|
|
||||||
kind: "tool",
|
|
||||||
eventType: ev.event_type,
|
|
||||||
agentId: ev.agent_id,
|
|
||||||
turnIndex: ev.turn_index,
|
|
||||||
seq: ev.seq,
|
|
||||||
createdAt: ev.created_at,
|
|
||||||
title: toolName || "工具调用",
|
|
||||||
body: typeof p.content_preview === "string" ? p.content_preview : undefined,
|
|
||||||
meta: { ...p, status },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
// 其它事件一一映射(含 approval/run/skill/artifact/checkpoint/turn)。
|
|
||||||
const entry = mapSingleEvent(ev);
|
|
||||||
if (entry) entries.push(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// delta 缓冲落盘
|
|
||||||
for (const { texts, seq, ev, completed } of deltaBuf.values()) {
|
|
||||||
entries.push({
|
|
||||||
key: `msg:${deltaGroupKey(ev)}`,
|
|
||||||
kind: "message",
|
|
||||||
eventType: completed ? "agent.message.completed" : "agent.message.delta",
|
|
||||||
agentId: ev.agent_id,
|
|
||||||
turnIndex: ev.turn_index,
|
|
||||||
seq,
|
|
||||||
createdAt: ev.created_at,
|
|
||||||
title: ev.agent_id ?? "智能体",
|
|
||||||
body: texts.join("").trim() || undefined,
|
|
||||||
meta: completed?.payload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// tool 折叠落盘
|
|
||||||
for (const entry of toolMap.values()) entries.push(entry);
|
|
||||||
|
|
||||||
// 按 seq 升序(聚合条目用其最大 seq,自然排到出现时刻之后)
|
|
||||||
entries.sort((a, b) => a.seq - b.seq);
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 把单个非聚合事件映射成 TimelineEntry。 */
|
|
||||||
function mapSingleEvent(ev: MafEvent): TimelineEntry | null {
|
|
||||||
const p = ev.payload ?? {};
|
|
||||||
const base = {
|
|
||||||
key: `${ev.event_type}:${ev.id}`,
|
|
||||||
agentId: ev.agent_id,
|
|
||||||
turnIndex: ev.turn_index,
|
|
||||||
seq: ev.seq,
|
|
||||||
createdAt: ev.created_at,
|
|
||||||
};
|
|
||||||
switch (ev.event_type) {
|
|
||||||
case "agent.turn_started":
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
kind: "turn",
|
|
||||||
eventType: ev.event_type,
|
|
||||||
title: `${ev.agent_id ?? "席位"} 开始第 ${(ev.turn_index ?? 0) + 1} 轮`,
|
|
||||||
meta: p,
|
|
||||||
};
|
|
||||||
case "run.started":
|
|
||||||
return { ...base, kind: "run", eventType: ev.event_type, title: "会商开始", meta: p };
|
|
||||||
case "run.completed":
|
|
||||||
return { ...base, kind: "run", eventType: ev.event_type, title: "会商完成", meta: p };
|
|
||||||
case "run.failed":
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
kind: "run",
|
|
||||||
eventType: ev.event_type,
|
|
||||||
title: "会商失败",
|
|
||||||
body: typeof p.message === "string" ? p.message : undefined,
|
|
||||||
meta: p,
|
|
||||||
};
|
|
||||||
case "run.cancelled":
|
|
||||||
return { ...base, kind: "run", eventType: ev.event_type, title: "会商已取消", meta: p };
|
|
||||||
case "skill.selected":
|
|
||||||
case "skill.started":
|
|
||||||
case "skill.completed":
|
|
||||||
case "skill.failed": {
|
|
||||||
const skillName = typeof p.skill_name === "string" ? p.skill_name : "技能";
|
|
||||||
const phase = ev.event_type.split(".")[1];
|
|
||||||
const labelMap: Record<string, string> = {
|
|
||||||
selected: "选择",
|
|
||||||
started: "开始",
|
|
||||||
completed: "完成",
|
|
||||||
failed: "失败",
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
kind: "skill",
|
|
||||||
eventType: ev.event_type,
|
|
||||||
title: `技能「${skillName}」${labelMap[phase] ?? phase}`,
|
|
||||||
body:
|
|
||||||
typeof p.result_preview === "string"
|
|
||||||
? p.result_preview
|
|
||||||
: typeof p.error === "string"
|
|
||||||
? p.error
|
|
||||||
: undefined,
|
|
||||||
meta: p,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case "artifact.created":
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
kind: "artifact",
|
|
||||||
eventType: ev.event_type,
|
|
||||||
title: `产出:${typeof p.title === "string" ? p.title : typeof p.artifact_type === "string" ? p.artifact_type : "产物"}`,
|
|
||||||
meta: p,
|
|
||||||
};
|
|
||||||
case "action.waiting_approval":
|
|
||||||
case "action.approved":
|
|
||||||
case "action.rejected": {
|
|
||||||
const phase = ev.event_type.split(".")[1];
|
|
||||||
const labelMap: Record<string, string> = {
|
|
||||||
waiting_approval: "等待审批",
|
|
||||||
approved: "已批准",
|
|
||||||
rejected: "已拒绝",
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
kind: "approval",
|
|
||||||
eventType: ev.event_type,
|
|
||||||
title: `写工具「${typeof p.tool_name === "string" ? p.tool_name : "未知"}」${labelMap[phase] ?? phase}`,
|
|
||||||
body: typeof p.summary === "string" ? p.summary : undefined,
|
|
||||||
meta: p,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case "workflow.checkpointed":
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
kind: "checkpoint",
|
|
||||||
eventType: ev.event_type,
|
|
||||||
title: "工作流已检查点",
|
|
||||||
body: typeof p.reason === "string" ? p.reason : undefined,
|
|
||||||
meta: p,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
// 兜底:未知事件类型仍展示,避免遗漏。
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
kind: "run",
|
|
||||||
eventType: ev.event_type,
|
|
||||||
title: ev.event_type,
|
|
||||||
meta: p,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 时间线条目的人类可读时间(HH:MM:SS)。null/无效返回空串。 */
|
|
||||||
export function shortTime(iso: string | null): string {
|
|
||||||
if (!iso) return "";
|
|
||||||
try {
|
|
||||||
const d = new Date(iso);
|
|
||||||
if (Number.isNaN(d.getTime())) return "";
|
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
|
||||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,434 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 事实/结论流程图 —— 纯函数层(MAF 自包含,不依赖任何旧会商目录)。
|
|
||||||
*
|
|
||||||
* 本文件是 MAF 模块独有的图数据层:定义自己的类型、自己解析 flow-json、自己构造
|
|
||||||
* confirmed_facts 证据图。不 import 任何旧会商目录的代码,保证新旧会商页面可独立演进。
|
|
||||||
*
|
|
||||||
* 两个数据源,优先级合并:
|
|
||||||
* 1. `final_result.content` 里的 ```flow-json 块(次源,结论流程图)—— 若 final_result
|
|
||||||
* 含 flow-json,解析后作为「结论流程图」返回(模型精心编排的层级图)。
|
|
||||||
* 2. `confirmed_facts`(主源,事实证据图)—— 每条已确认事实 → task 节点;其
|
|
||||||
* evidence_refs 连到对应证据节点(evidence → role / tool_results → deliverable)。
|
|
||||||
*
|
|
||||||
* 设计:**纯函数**,输入 checkpoint(宽松类型),输出 MafFlowGraphData | null。
|
|
||||||
* null 表示无任何可绘制数据,由组件显示空态。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { MafCheckpoint, MafFactLedger } from "../api/types";
|
|
||||||
|
|
||||||
// ── 类型(MAF 独有,不复用旧会商)────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type MafFlowNodeType =
|
|
||||||
| "start"
|
|
||||||
| "end"
|
|
||||||
| "stage"
|
|
||||||
| "task"
|
|
||||||
| "decision"
|
|
||||||
| "risk"
|
|
||||||
| "deliverable"
|
|
||||||
| "role";
|
|
||||||
|
|
||||||
export interface MafFlowGraphNode {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
type: MafFlowNodeType;
|
|
||||||
detail?: string;
|
|
||||||
/** 层级/分组名(如 任务 / 目的 / 行为体),由模型在 flow-json 里给出。 */
|
|
||||||
group?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MafFlowGraphEdge {
|
|
||||||
from: string;
|
|
||||||
to: string;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MafFlowGraphData {
|
|
||||||
title: string;
|
|
||||||
nodes: MafFlowGraphNode[];
|
|
||||||
edges: MafFlowGraphEdge[];
|
|
||||||
/** 是否因规模上限截断了节点/边/源文本(弱模型超大输出保护)。 */
|
|
||||||
truncated?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 节点类型 → 中文标签(图例 / 类型徽章)。 */
|
|
||||||
export const MAF_FLOW_NODE_TYPE_LABEL: Record<MafFlowNodeType, string> = {
|
|
||||||
start: "开始",
|
|
||||||
end: "结束",
|
|
||||||
stage: "阶段",
|
|
||||||
task: "任务",
|
|
||||||
decision: "判断",
|
|
||||||
risk: "风险",
|
|
||||||
deliverable: "交付物",
|
|
||||||
role: "角色",
|
|
||||||
};
|
|
||||||
|
|
||||||
const VALID_TYPES = new Set<string>(Object.keys(MAF_FLOW_NODE_TYPE_LABEL));
|
|
||||||
|
|
||||||
export const MAF_FLOW_FENCE_OPEN = "```flow-json";
|
|
||||||
|
|
||||||
// ── 规模上限(弱模型超大输出保护)─────────────────────────────────────────
|
|
||||||
|
|
||||||
const MAX_FLOW_SOURCE_CHARS = 100_000;
|
|
||||||
const MAX_FLOW_NODES = 200;
|
|
||||||
const MAX_FLOW_EDGES = 400;
|
|
||||||
const MAX_FLOW_LABEL_CHARS = 160;
|
|
||||||
const MAX_FLOW_DETAIL_CHARS = 2_000;
|
|
||||||
const MAX_FLOW_TITLE_CHARS = 160;
|
|
||||||
|
|
||||||
/** 把文本截断到 max 字符(超出时追加 …),用于 node label / detail / graph title。 */
|
|
||||||
function clampText(text: string, max: number): string {
|
|
||||||
if (text.length <= max) return text;
|
|
||||||
return `${text.slice(0, max)}…`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── flow-json 解析(容错半截 JSON / 裸换行 / 尾逗号)─────────────────────────
|
|
||||||
|
|
||||||
function asString(v: unknown): string {
|
|
||||||
return typeof v === "string" ? v.trim() : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 容错解析单个对象切片:模型输出常带裸换行 / 尾逗号 / 中文标点导致整对象 JSON.parse
|
|
||||||
* 失败、节点被整个丢弃。这里用正则逐字段抽取,最大限度救回节点/边。
|
|
||||||
*/
|
|
||||||
function looseParseObject(slice: string): Record<string, unknown> | null {
|
|
||||||
const pickStr = (...keys: string[]): string | undefined => {
|
|
||||||
for (const k of keys) {
|
|
||||||
const m = new RegExp(`"${k}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(slice);
|
|
||||||
if (m) {
|
|
||||||
const raw = m[1];
|
|
||||||
try {
|
|
||||||
return JSON.parse(`"${raw.replace(/\r?\n/g, "\\n")}"`) as string;
|
|
||||||
} catch {
|
|
||||||
return raw.replace(/\\n/g, "\n").replace(/\\"/g, '"');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
const obj: Record<string, unknown> = {};
|
|
||||||
const id = pickStr("id");
|
|
||||||
if (id) obj.id = id;
|
|
||||||
const label = pickStr("label", "name");
|
|
||||||
if (label) obj.label = label;
|
|
||||||
const type = pickStr("type");
|
|
||||||
if (type) obj.type = type;
|
|
||||||
const group = pickStr("group", "level", "tier", "layer");
|
|
||||||
if (group) obj.group = group;
|
|
||||||
const detail = pickStr("detail", "description");
|
|
||||||
if (detail) obj.detail = detail;
|
|
||||||
const toStr = pickStr("to", "target");
|
|
||||||
if (toStr) obj.to = toStr;
|
|
||||||
const fm = /"(?:from|prev)"\s*:\s*\[([\s\S]*?)\]/.exec(slice);
|
|
||||||
if (fm) {
|
|
||||||
const inner = fm[1];
|
|
||||||
const ids: unknown[] = [];
|
|
||||||
const objRe = /\{[^{}]*?"id"\s*:\s*"([^"]+)"[^{}]*?\}/g;
|
|
||||||
let om: RegExpExecArray | null;
|
|
||||||
while ((om = objRe.exec(inner)) !== null) ids.push({ id: om[1] });
|
|
||||||
if (ids.length === 0) {
|
|
||||||
const sRe = /"([^"]+)"/g;
|
|
||||||
let sm: RegExpExecArray | null;
|
|
||||||
while ((sm = sRe.exec(inner)) !== null) ids.push(sm[1]);
|
|
||||||
}
|
|
||||||
if (ids.length > 0) obj.from = ids;
|
|
||||||
} else {
|
|
||||||
const fromStr = pickStr("from", "source");
|
|
||||||
if (fromStr) obj.from = fromStr;
|
|
||||||
}
|
|
||||||
return Object.keys(obj).length > 0 ? obj : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 平衡括号扫描:从 `key`(如 `"nodes"`)后的 `[` 开始,把已完整闭合的顶层 `{…}`
|
|
||||||
* 逐个解析;跳过字符串内的括号与转义;数组未闭合(流式半截)时收集到哪算哪。
|
|
||||||
*/
|
|
||||||
function collectObjects(text: string, key: string): Record<string, unknown>[] {
|
|
||||||
const keyIdx = text.indexOf(key);
|
|
||||||
if (keyIdx < 0) return [];
|
|
||||||
const arrStart = text.indexOf("[", keyIdx);
|
|
||||||
if (arrStart < 0) return [];
|
|
||||||
const out: Record<string, unknown>[] = [];
|
|
||||||
let depth = 0;
|
|
||||||
let objStart = -1;
|
|
||||||
let inStr = false;
|
|
||||||
let esc = false;
|
|
||||||
for (let i = arrStart + 1; i < text.length; i++) {
|
|
||||||
const ch = text[i];
|
|
||||||
if (inStr) {
|
|
||||||
if (esc) esc = false;
|
|
||||||
else if (ch === "\\") esc = true;
|
|
||||||
else if (ch === '"') inStr = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (ch === '"') {
|
|
||||||
inStr = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (ch === "{") {
|
|
||||||
if (depth === 0) objStart = i;
|
|
||||||
depth++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (ch === "}") {
|
|
||||||
depth--;
|
|
||||||
if (depth === 0 && objStart >= 0) {
|
|
||||||
const objSlice = text.slice(objStart, i + 1);
|
|
||||||
let parsed: Record<string, unknown> | null = null;
|
|
||||||
try {
|
|
||||||
const j = JSON.parse(objSlice) as unknown;
|
|
||||||
if (j && typeof j === "object" && !Array.isArray(j)) {
|
|
||||||
parsed = j as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
parsed = looseParseObject(objSlice);
|
|
||||||
}
|
|
||||||
if (parsed) out.push(parsed);
|
|
||||||
objStart = -1;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (ch === "]" && depth === 0) break;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toNode(raw: Record<string, unknown>): MafFlowGraphNode | null {
|
|
||||||
const id = asString(raw.id);
|
|
||||||
const labelRaw = asString(raw.label) || asString(raw.name);
|
|
||||||
if (!id || !labelRaw) return null;
|
|
||||||
const rawType = asString(raw.type).toLowerCase();
|
|
||||||
const type = (VALID_TYPES.has(rawType) ? rawType : "task") as MafFlowNodeType;
|
|
||||||
const detailRaw = asString(raw.detail) || asString(raw.description) || undefined;
|
|
||||||
const group =
|
|
||||||
asString(raw.group) || asString(raw.level) || asString(raw.tier) || asString(raw.layer) || undefined;
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
label: clampText(labelRaw, MAX_FLOW_LABEL_CHARS),
|
|
||||||
type,
|
|
||||||
detail: detailRaw ? clampText(detailRaw, MAX_FLOW_DETAIL_CHARS) : undefined,
|
|
||||||
group,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function edgesFromNode(raw: Record<string, unknown>, nodeId: string): MafFlowGraphEdge[] {
|
|
||||||
const from = raw.from ?? raw.prev;
|
|
||||||
if (!Array.isArray(from)) return [];
|
|
||||||
const out: MafFlowGraphEdge[] = [];
|
|
||||||
for (const entry of from) {
|
|
||||||
if (typeof entry === "string" && entry.trim()) {
|
|
||||||
out.push({ from: entry.trim(), to: nodeId });
|
|
||||||
} else if (entry && typeof entry === "object") {
|
|
||||||
const o = entry as Record<string, unknown>;
|
|
||||||
const fromId = asString(o.id) || asString(o.from);
|
|
||||||
if (!fromId) continue;
|
|
||||||
const label = asString(o.label) || undefined;
|
|
||||||
out.push({ from: fromId, to: nodeId, label });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toEdge(raw: Record<string, unknown>): MafFlowGraphEdge | null {
|
|
||||||
const from = asString(raw.from) || asString(raw.source);
|
|
||||||
const to = asString(raw.to) || asString(raw.target);
|
|
||||||
if (!from || !to) return null;
|
|
||||||
const label = asString(raw.label) || undefined;
|
|
||||||
return { from, to, label };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从(可能流式半截的)回复文本里提取最后一个 ```flow-json 块并解析。
|
|
||||||
* 没有 fence 或一个完整节点都没有 → null。
|
|
||||||
*/
|
|
||||||
export function extractMafFlowGraph(text: string): MafFlowGraphData | null {
|
|
||||||
const start = text.lastIndexOf(MAF_FLOW_FENCE_OPEN);
|
|
||||||
if (start < 0) return null;
|
|
||||||
let body = text.slice(start + MAF_FLOW_FENCE_OPEN.length);
|
|
||||||
const close = body.indexOf("```");
|
|
||||||
if (close >= 0) body = body.slice(0, close);
|
|
||||||
|
|
||||||
let truncated = false;
|
|
||||||
if (body.length > MAX_FLOW_SOURCE_CHARS) {
|
|
||||||
body = body.slice(0, MAX_FLOW_SOURCE_CHARS);
|
|
||||||
truncated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const titleMatch = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(body);
|
|
||||||
let title = "";
|
|
||||||
if (titleMatch) {
|
|
||||||
try {
|
|
||||||
title = JSON.parse(`"${titleMatch[1]}"`) as string;
|
|
||||||
} catch {
|
|
||||||
title = titleMatch[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (title.length > MAX_FLOW_TITLE_CHARS) {
|
|
||||||
title = clampText(title, MAX_FLOW_TITLE_CHARS);
|
|
||||||
truncated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawNodes = collectObjects(body, '"nodes"');
|
|
||||||
const nodes: MafFlowGraphNode[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const edges: MafFlowGraphEdge[] = [];
|
|
||||||
for (const raw of rawNodes) {
|
|
||||||
const node = toNode(raw);
|
|
||||||
if (!node || seen.has(node.id)) continue;
|
|
||||||
seen.add(node.id);
|
|
||||||
nodes.push(node);
|
|
||||||
edges.push(...edgesFromNode(raw, node.id));
|
|
||||||
}
|
|
||||||
for (const raw of collectObjects(body, '"edges"')) {
|
|
||||||
const edge = toEdge(raw);
|
|
||||||
if (edge) edges.push(edge);
|
|
||||||
}
|
|
||||||
if (nodes.length === 0) return null;
|
|
||||||
|
|
||||||
if (nodes.length > MAX_FLOW_NODES) {
|
|
||||||
truncated = true;
|
|
||||||
}
|
|
||||||
const cappedNodes = nodes.slice(0, MAX_FLOW_NODES);
|
|
||||||
const cappedNodeIds = new Set(cappedNodes.map((n) => n.id));
|
|
||||||
|
|
||||||
const edgeKeys = new Set<string>();
|
|
||||||
const validEdges = edges.filter((e) => {
|
|
||||||
if (!cappedNodeIds.has(e.from) || !cappedNodeIds.has(e.to) || e.from === e.to) return false;
|
|
||||||
const key = `${e.from}->${e.to}`;
|
|
||||||
if (edgeKeys.has(key)) return false;
|
|
||||||
edgeKeys.add(key);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
let cappedEdges = validEdges;
|
|
||||||
if (validEdges.length > MAX_FLOW_EDGES) {
|
|
||||||
cappedEdges = validEdges.slice(0, MAX_FLOW_EDGES);
|
|
||||||
truncated = true;
|
|
||||||
}
|
|
||||||
return { title, nodes: cappedNodes, edges: cappedEdges, truncated };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── confirmed_facts 证据图 ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** 截断长文本用于节点 label(detail 保留全文)。 */
|
|
||||||
function truncate(text: string, max = 28): string {
|
|
||||||
const t = text.trim().replace(/\s+/g, " ");
|
|
||||||
return t.length <= max ? t : `${t.slice(0, max)}…`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 尝试从 final_result.content 解析 flow-json 结论图。
|
|
||||||
* 有则优先返回(模型编排的结论图比纯事实更有结构)。
|
|
||||||
*/
|
|
||||||
export function extractConclusionFlow(checkpoint: MafCheckpoint | null): MafFlowGraphData | null {
|
|
||||||
const content = checkpoint?.final_result?.content;
|
|
||||||
if (!content || typeof content !== "string") return null;
|
|
||||||
const graph = extractMafFlowGraph(content);
|
|
||||||
return graph && graph.nodes.length > 0 ? graph : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把 confirmed_facts + evidence + tool_results 编织成事实证据图。
|
|
||||||
*
|
|
||||||
* 布局:start(根) ──► evidence/tool(role/deliverable) ──► fact(task)
|
|
||||||
*/
|
|
||||||
export function buildMafFactFlowGraph(checkpoint: MafCheckpoint | null): MafFlowGraphData | null {
|
|
||||||
const ledger: MafFactLedger | undefined = checkpoint?.fact_ledger;
|
|
||||||
const confirmed = ledger?.confirmed_facts ?? [];
|
|
||||||
if (confirmed.length === 0) return null;
|
|
||||||
|
|
||||||
const nodes: MafFlowGraphNode[] = [];
|
|
||||||
const edges: MafFlowGraphEdge[] = [];
|
|
||||||
const nodeIds = new Set<string>();
|
|
||||||
|
|
||||||
const addNode = (n: MafFlowGraphNode) => {
|
|
||||||
if (!nodeIds.has(n.id)) {
|
|
||||||
nodeIds.add(n.id);
|
|
||||||
nodes.push(n);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const addEdge = (from: string, to: string, label?: string) => {
|
|
||||||
if (from === to) return;
|
|
||||||
edges.push({ from, to, label });
|
|
||||||
};
|
|
||||||
|
|
||||||
// 证据索引:ref → 来源类型。
|
|
||||||
const evidenceByText = new Map<string, "evidence" | "tool">();
|
|
||||||
for (const e of ledger?.evidence ?? []) {
|
|
||||||
const ref = typeof e.ref === "string" ? e.ref : typeof e.text === "string" ? e.text : "";
|
|
||||||
if (ref) evidenceByText.set(ref, "evidence");
|
|
||||||
}
|
|
||||||
for (const tr of ledger?.tool_results ?? []) {
|
|
||||||
if (tr.status !== "completed") continue;
|
|
||||||
const name = tr.tool_name ?? "";
|
|
||||||
const turn = tr.turn_index;
|
|
||||||
const ref =
|
|
||||||
name && turn != null ? `tool:${name}:${turn}` : name ? `tool:${name}` : "";
|
|
||||||
if (ref) evidenceByText.set(ref, "tool");
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROOT_ID = "root";
|
|
||||||
addNode({ id: ROOT_ID, label: "已确认事实", type: "start" });
|
|
||||||
|
|
||||||
for (const fact of confirmed) {
|
|
||||||
const factId = `fact:${fact.candidate_id}`;
|
|
||||||
addNode({
|
|
||||||
id: factId,
|
|
||||||
label: truncate(fact.text),
|
|
||||||
type: "task",
|
|
||||||
detail: clampText(fact.text, MAX_FLOW_DETAIL_CHARS),
|
|
||||||
group: fact.confirmed_by ? `确认方:${fact.confirmed_by}` : "已确认",
|
|
||||||
});
|
|
||||||
|
|
||||||
if ((fact.evidence_refs ?? []).length === 0) {
|
|
||||||
addEdge(ROOT_ID, factId);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const ref of fact.evidence_refs) {
|
|
||||||
const refStr = String(ref);
|
|
||||||
const evKind = evidenceByText.get(refStr) ?? "evidence";
|
|
||||||
const evId = `ev:${refStr}`;
|
|
||||||
addNode({
|
|
||||||
id: evId,
|
|
||||||
label: truncate(refStr.replace(/^tool:/, ""), 24),
|
|
||||||
type: evKind === "tool" ? "deliverable" : "role",
|
|
||||||
detail: clampText(refStr, MAX_FLOW_DETAIL_CHARS),
|
|
||||||
group: evKind === "tool" ? "工具证据" : "外部证据",
|
|
||||||
});
|
|
||||||
addEdge(ROOT_ID, evId);
|
|
||||||
addEdge(evId, factId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const dedupEdges = edges.filter((e) => {
|
|
||||||
const k = `${e.from}->${e.to}`;
|
|
||||||
if (seen.has(k)) return false;
|
|
||||||
seen.add(k);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
let truncated = false;
|
|
||||||
let cappedNodes = nodes;
|
|
||||||
let cappedEdges = dedupEdges;
|
|
||||||
if (nodes.length > MAX_FLOW_NODES) {
|
|
||||||
truncated = true;
|
|
||||||
cappedNodes = nodes.slice(0, MAX_FLOW_NODES);
|
|
||||||
const cappedNodeIds = new Set(cappedNodes.map((n) => n.id));
|
|
||||||
cappedEdges = dedupEdges.filter((e) => cappedNodeIds.has(e.from) && cappedNodeIds.has(e.to));
|
|
||||||
}
|
|
||||||
if (cappedEdges.length > MAX_FLOW_EDGES) {
|
|
||||||
truncated = true;
|
|
||||||
cappedEdges = cappedEdges.slice(0, MAX_FLOW_EDGES);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { title: "事实证据图", nodes: cappedNodes, edges: cappedEdges, truncated };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 合并两源:结论 flow-json 优先;否则用事实证据图;都没有 → null(空态)。
|
|
||||||
*/
|
|
||||||
export function buildMafFlowGraph(checkpoint: MafCheckpoint | null): MafFlowGraphData | null {
|
|
||||||
return extractConclusionFlow(checkpoint) ?? buildMafFactFlowGraph(checkpoint);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
/**
|
|
||||||
* 工具风险等级 → TDesign Tag 主题/标签(MAF 审批卡 + 动作审计面板共用)。
|
|
||||||
*
|
|
||||||
* 后端实际使用 R0–R4(见 app/maf_roundtable 的工具元数据分类):
|
|
||||||
* R0/R1 → 低风险(success)
|
|
||||||
* R2 → 中风险(warning)
|
|
||||||
* R3/R4 → 高风险(danger)
|
|
||||||
* 兜底兼容旧的 low/medium/high 字面量;未登记的未知等级落到 default(保守展示)。
|
|
||||||
*
|
|
||||||
* 这是单一事实源:组件和测试都从这里 import,避免测试在文件里复制映射、
|
|
||||||
* 实现坏掉却仍通过。
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type RiskTheme = "success" | "warning" | "danger" | "default";
|
|
||||||
|
|
||||||
const RISK_THEME_MAP: Record<string, RiskTheme> = {
|
|
||||||
R0: "success",
|
|
||||||
R1: "success",
|
|
||||||
R2: "warning",
|
|
||||||
R3: "danger",
|
|
||||||
R4: "danger",
|
|
||||||
// 兜底:后端可能返回旧值
|
|
||||||
low: "success",
|
|
||||||
medium: "warning",
|
|
||||||
high: "danger",
|
|
||||||
};
|
|
||||||
|
|
||||||
const RISK_LABEL_MAP: Record<string, string> = {
|
|
||||||
R0: "R0 · 低",
|
|
||||||
R1: "R1 · 低",
|
|
||||||
R2: "R2 · 中",
|
|
||||||
R3: "R3 · 高",
|
|
||||||
R4: "R4 · 高",
|
|
||||||
low: "low · 低",
|
|
||||||
medium: "medium · 中",
|
|
||||||
high: "high · 高",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 风险等级 → TDesign Tag 主题;未知等级落到 default(不放过任何未知风险)。 */
|
|
||||||
export function riskThemeFor(level: string | null | undefined): RiskTheme {
|
|
||||||
if (!level) return "default";
|
|
||||||
return RISK_THEME_MAP[level] ?? "default";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 风险等级 → 人类可读标签;未知等级原样返回(让审阅者看到原始值)。 */
|
|
||||||
export function riskLabelFor(level: string | null | undefined): string {
|
|
||||||
if (!level) return "—";
|
|
||||||
return RISK_LABEL_MAP[level] ?? level;
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF run 手动恢复(resume)的前置判定 —— 纯函数。
|
|
||||||
*
|
|
||||||
* 后端契约(app/maf_roundtable):当 run 处于 waiting_approval 且仍存在 pending 审批时,
|
|
||||||
* 调用 POST /runs/{id}/resume 会返回 409。因此前端必须:
|
|
||||||
* 1. 仍有 pending 审批时,禁止手动恢复(让用户先处理审批);
|
|
||||||
* 2. 审批列表尚在加载时,禁止恢复(不知道是否还有 pending);
|
|
||||||
* 3. 审批清零且 run 仍为 waiting_approval 时(说明后端 dispatcher 自动恢复未生效或失败),
|
|
||||||
* 才允许手动恢复作为补偿操作。
|
|
||||||
*
|
|
||||||
* 单一事实源:MafRunHeader 与测试都从这里 import,避免测试复制判定逻辑。
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface CanManualResumeParams {
|
|
||||||
runStatus: string | null | undefined;
|
|
||||||
/** 审批列表是否仍在加载(首次或轮询中)。 */
|
|
||||||
approvalsLoading: boolean;
|
|
||||||
/** 审批列表是否成功加载(isLoading=false 且无 error)。 */
|
|
||||||
approvalsSuccess: boolean;
|
|
||||||
/** 当前 pending 审批数量。 */
|
|
||||||
pendingCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否允许手动恢复 run。
|
|
||||||
*
|
|
||||||
* 规则:
|
|
||||||
* - run 必须处于 waiting_approval(其它状态走各自的生命周期按钮);
|
|
||||||
* - 审批必须已成功加载(approvalsSuccess && !approvalsLoading);
|
|
||||||
* - 不存在任何 pending 审批(pendingCount === 0)。
|
|
||||||
*
|
|
||||||
* 满足以上三项 → true(显示「恢复」按钮作为补偿操作)。
|
|
||||||
*/
|
|
||||||
export function canManualResume({
|
|
||||||
runStatus,
|
|
||||||
approvalsLoading,
|
|
||||||
approvalsSuccess,
|
|
||||||
pendingCount,
|
|
||||||
}: CanManualResumeParams): boolean {
|
|
||||||
if (runStatus !== "waiting_approval") return false;
|
|
||||||
if (approvalsLoading) return false;
|
|
||||||
if (!approvalsSuccess) return false;
|
|
||||||
if (pendingCount > 0) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 安全展示 —— 统一的二次脱敏纯函数(审批卡 + 动作审计面板共用)。
|
|
||||||
*
|
|
||||||
* 设计:把「敏感键删除(不显示成 ***)+ 递归 + 深度/数组/字段/字符串上限」收敛成
|
|
||||||
* 单一事实源。审批卡和动作审计面板都 import safeDisplayJson,禁止各自再实现私有脱敏,
|
|
||||||
* 避免两边规则漂移(一边漏脱敏、另一边不漏)。
|
|
||||||
*
|
|
||||||
* 规则:
|
|
||||||
* - 敏感键直接删除,绝不渲染成 target_key: "***"(连键名都不能透露);
|
|
||||||
* - 键名忽略大小写匹配 SENSITIVE_KEY_RE;
|
|
||||||
* - 递归处理对象/数组,最大深度 4(>4 返回占位);
|
|
||||||
* - 单数组最多 20 项;单对象最多 30 个非敏感字段;
|
|
||||||
* - 单字符串最多 120 字;
|
|
||||||
* - 返回 hiddenCount / truncated,供页面显示「已隐藏 N 项敏感字段」「内容已截断」,
|
|
||||||
* 但绝不透露具体敏感字段名。
|
|
||||||
*/
|
|
||||||
|
|
||||||
const SENSITIVE_KEY_RE =
|
|
||||||
/(secret|token|password|passwd|credential|auth|cookie|target_key|idempotency|api_key|apikey|private_key)/i;
|
|
||||||
|
|
||||||
const MAX_DEPTH = 4;
|
|
||||||
const MAX_ARRAY_ITEMS = 20;
|
|
||||||
const MAX_OBJECT_FIELDS = 30;
|
|
||||||
const MAX_STRING_CHARS = 120;
|
|
||||||
|
|
||||||
export interface SafeDisplayResult {
|
|
||||||
/** 脱敏 + 截断后的 JSON 文本;无可展示内容时为空串(调用方据此隐藏整块面板)。 */
|
|
||||||
text: string;
|
|
||||||
/** 被删除的敏感字段总数(递归累计)。 */
|
|
||||||
hiddenCount: number;
|
|
||||||
/** 是否发生任何截断(深度/数组/字段数/字符串长度)。 */
|
|
||||||
truncated: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEmptyValue(v: unknown): boolean {
|
|
||||||
if (v == null) return true;
|
|
||||||
if (typeof v === "string") return v.length === 0;
|
|
||||||
if (Array.isArray(v)) return v.length === 0;
|
|
||||||
if (typeof v === "object") return Object.keys(v as Record<string, unknown>).length === 0;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把任意值脱敏 + 截断后序列化成可展示 JSON 文本。
|
|
||||||
*
|
|
||||||
* 全部字段被敏感过滤清空(或输入本身为空)→ text="",调用方应隐藏对应面板。
|
|
||||||
*/
|
|
||||||
export function safeDisplayJson(value: unknown): SafeDisplayResult {
|
|
||||||
let hiddenCount = 0;
|
|
||||||
let truncated = false;
|
|
||||||
|
|
||||||
function sanitize(input: unknown, depth: number): unknown {
|
|
||||||
if (depth > MAX_DEPTH) {
|
|
||||||
truncated = true;
|
|
||||||
return "[内容层级过深]";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof input === "string") {
|
|
||||||
if (input.length > MAX_STRING_CHARS) {
|
|
||||||
truncated = true;
|
|
||||||
return `${input.slice(0, MAX_STRING_CHARS)}…`;
|
|
||||||
}
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(input)) {
|
|
||||||
if (input.length > MAX_ARRAY_ITEMS) truncated = true;
|
|
||||||
return input.slice(0, MAX_ARRAY_ITEMS).map((item) => sanitize(item, depth + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input && typeof input === "object") {
|
|
||||||
const result: Record<string, unknown> = {};
|
|
||||||
let fieldCount = 0;
|
|
||||||
for (const [key, item] of Object.entries(input as Record<string, unknown>)) {
|
|
||||||
if (SENSITIVE_KEY_RE.test(key)) {
|
|
||||||
hiddenCount += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (fieldCount >= MAX_OBJECT_FIELDS) {
|
|
||||||
truncated = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
result[key] = sanitize(item, depth + 1);
|
|
||||||
fieldCount += 1;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleaned = sanitize(value, 0);
|
|
||||||
|
|
||||||
let text: string;
|
|
||||||
if (isEmptyValue(cleaned)) {
|
|
||||||
text = "";
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
text = JSON.stringify(cleaned, null, 2);
|
|
||||||
} catch {
|
|
||||||
text = "[unserializable]";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { text, hiddenCount, truncated };
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 会商创建表单的纯逻辑层。
|
|
||||||
*
|
|
||||||
* 把「从 health 默认值初始化表单」「校验最大轮数」「经理互斥过滤席位」「构建只含改写项的
|
|
||||||
* 请求体」等逻辑抽成纯函数,作为组件与测试的共同事实源。组件不得在内部复制这些逻辑,
|
|
||||||
* 测试必须 import 这里的真实函数,避免「实现坏掉、测试仍通过」。
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface HealthDefaults {
|
|
||||||
default_mode?: string;
|
|
||||||
default_write_policy?: string;
|
|
||||||
default_max_rounds?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionFormState {
|
|
||||||
title: string;
|
|
||||||
taskText: string;
|
|
||||||
mode: string;
|
|
||||||
managerAgentId: string;
|
|
||||||
seatAgentIds: string[];
|
|
||||||
modelName: string;
|
|
||||||
reasoningEffort: string;
|
|
||||||
/** 文本输入,空串表示「使用默认」。 */
|
|
||||||
maxRounds: string;
|
|
||||||
writePolicy: string;
|
|
||||||
showAdvanced: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据 health 返回的后端默认值创建一份干净的初始表单。
|
|
||||||
* - mode 默认取 health.default_mode(兜底 structured);
|
|
||||||
* - writePolicy 默认取 health.default_write_policy(空串表示使用默认);
|
|
||||||
* - maxRounds 默认取 health.default_max_rounds 的字符串(空串表示使用默认)。
|
|
||||||
*
|
|
||||||
* 注意:mode 即便来自 health 也只用作 UI 初始展示;提交时是否真的传回后端,
|
|
||||||
* 由 buildSessionOverrides 按「是否被改写」决定(见下)。
|
|
||||||
*/
|
|
||||||
export function createInitialSessionForm(
|
|
||||||
health: HealthDefaults | null | undefined,
|
|
||||||
): SessionFormState {
|
|
||||||
return {
|
|
||||||
title: "",
|
|
||||||
taskText: "",
|
|
||||||
mode: health?.default_mode ?? "structured",
|
|
||||||
managerAgentId: "",
|
|
||||||
seatAgentIds: [],
|
|
||||||
modelName: "",
|
|
||||||
reasoningEffort: "",
|
|
||||||
maxRounds:
|
|
||||||
typeof health?.default_max_rounds === "number"
|
|
||||||
? String(health.default_max_rounds)
|
|
||||||
: "",
|
|
||||||
writePolicy: health?.default_write_policy ?? "",
|
|
||||||
showAdvanced: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MaxRoundsValidation {
|
|
||||||
valid: boolean;
|
|
||||||
value?: number;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验最大轮数输入。空串视为有效(= 使用后端默认)。必须是 1–64 的整数。
|
|
||||||
*/
|
|
||||||
export function validateMaxRounds(input: string): MaxRoundsValidation {
|
|
||||||
const trimmed = input.trim();
|
|
||||||
if (!trimmed) return { valid: true };
|
|
||||||
const n = Number(trimmed);
|
|
||||||
if (!Number.isInteger(n) || n < 1 || n > 64) {
|
|
||||||
return { valid: false, error: "最大轮数必须为 1–64 的整数" };
|
|
||||||
}
|
|
||||||
return { valid: true, value: n };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 经理与席位互斥(后端参与者唯一约束):切换经理时,从已选席位中移除该 agent。
|
|
||||||
*/
|
|
||||||
export function filterSeatsForManager(seats: string[], managerId: string): string[] {
|
|
||||||
if (!managerId) return seats;
|
|
||||||
return seats.filter((id) => id !== managerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 切换某个席位的选择(toggle)。若该 agent 已是经理,则拒绝(返回原数组)。
|
|
||||||
*/
|
|
||||||
export function toggleSeat(
|
|
||||||
seats: string[],
|
|
||||||
agentId: string,
|
|
||||||
managerAgentId: string,
|
|
||||||
): string[] {
|
|
||||||
if (agentId === managerAgentId) return seats;
|
|
||||||
return seats.includes(agentId)
|
|
||||||
? seats.filter((id) => id !== agentId)
|
|
||||||
: [...seats, agentId];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 展示一行只读的后端默认摘要(高级项折叠时用)。值只来自 health,绝不硬编码。
|
|
||||||
*/
|
|
||||||
export function defaultsSummaryLine(health: HealthDefaults | null | undefined): string {
|
|
||||||
if (!health) return "正在读取后端默认配置…";
|
|
||||||
return `当前后端默认:${health.default_mode} | ${health.default_write_policy} | 最大 ${health.default_max_rounds} 轮`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionOverrides {
|
|
||||||
mode?: "structured" | "group_chat";
|
|
||||||
writePolicy?: "auto" | "confirm_high_risk" | "confirm_all_writes";
|
|
||||||
maxRounds?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 只产出「用户相对后端默认确实改写了」的字段。
|
|
||||||
*
|
|
||||||
* 设计:用户没改写 → 不传 → 后端决定(符合「最好不传对应字段」)。
|
|
||||||
* 模式必须是 structured/group_chat 之一才算有效改写;写入策略必须是合法枚举值;
|
|
||||||
* 最大轮数走 validateMaxRounds 校验。
|
|
||||||
*/
|
|
||||||
export function buildSessionOverrides(
|
|
||||||
form: Pick<SessionFormState, "mode" | "writePolicy" | "maxRounds">,
|
|
||||||
defaults: HealthDefaults | null | undefined,
|
|
||||||
): { overrides: SessionOverrides; maxRoundsError?: string } {
|
|
||||||
const result: SessionOverrides = {};
|
|
||||||
let maxRoundsError: string | undefined;
|
|
||||||
|
|
||||||
// 模式:仅当与后端默认不同且合法时传
|
|
||||||
if (form.mode !== (defaults?.default_mode ?? "structured")) {
|
|
||||||
if (form.mode === "structured" || form.mode === "group_chat") {
|
|
||||||
result.mode = form.mode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入策略:仅当与后端默认不同且合法时传
|
|
||||||
const policyDefault = defaults?.default_write_policy ?? "";
|
|
||||||
if (form.writePolicy !== policyDefault) {
|
|
||||||
if (
|
|
||||||
form.writePolicy === "auto" ||
|
|
||||||
form.writePolicy === "confirm_high_risk" ||
|
|
||||||
form.writePolicy === "confirm_all_writes"
|
|
||||||
) {
|
|
||||||
result.writePolicy = form.writePolicy;
|
|
||||||
}
|
|
||||||
// 空串 = 使用默认,不传
|
|
||||||
}
|
|
||||||
|
|
||||||
// 最大轮数:空串不传;非空走校验
|
|
||||||
const roundsValidation = validateMaxRounds(form.maxRounds);
|
|
||||||
if (form.maxRounds.trim()) {
|
|
||||||
if (!roundsValidation.valid) {
|
|
||||||
maxRoundsError = roundsValidation.error;
|
|
||||||
} else if (roundsValidation.value !== undefined) {
|
|
||||||
const defaultRounds =
|
|
||||||
typeof defaults?.default_max_rounds === "number"
|
|
||||||
? defaults.default_max_rounds
|
|
||||||
: null;
|
|
||||||
if (defaultRounds === null || roundsValidation.value !== defaultRounds) {
|
|
||||||
result.maxRounds = roundsValidation.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { overrides: result, maxRoundsError };
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 单 run 工作区(路由 runs/:runId)。
|
|
||||||
*
|
|
||||||
* MAF-06-01/07/08/09:可视层改用本地 `tdesign-react`。
|
|
||||||
*
|
|
||||||
* 三栏布局(桌面)/ 纵向堆叠(窄屏):
|
|
||||||
* 左:事件时间线(MafEventTimeline)—— run live 时 cursor 轮询事件
|
|
||||||
* 中:席位状态(MafSeatStatusPanel)+ 事实/结论流程图(MafFactFlowPanel)+ 最终结论
|
|
||||||
* 右:审批卡片(MafApprovalCard)—— 仅 pending 审批时显示
|
|
||||||
*
|
|
||||||
* 顶部:MafRunHeader(状态 Tag + 操作)。整体由 MafHealthGate 守卫。
|
|
||||||
* 刷新/切页/跨页返回时从数据库事件恢复,不依赖 React 内存保存运行状态(MAF-06-09)。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { Link, useParams } from "react-router-dom";
|
|
||||||
import { ArrowLeft, Loader2, ShieldQuestion } from "lucide-react";
|
|
||||||
import { Button as TButton, Tag as TTag } from "tdesign-react";
|
|
||||||
|
|
||||||
import { useDesensitizeSubtree } from "@/lib/use-desensitize-subtree";
|
|
||||||
|
|
||||||
import { useMafActions, useMafApprovals } from "../api/approvals";
|
|
||||||
import { runIsLive, type MafAction } from "../api/types";
|
|
||||||
import { useMafRunWithEvents } from "../hooks/use-maf-events";
|
|
||||||
import { canManualResume } from "../lib/run-resume";
|
|
||||||
import { foldEventsToTimeline } from "../lib/event-timeline";
|
|
||||||
import { MafActionAuditPanel } from "../components/MafActionAuditPanel";
|
|
||||||
import { MafApprovalCard } from "../components/MafApprovalCard";
|
|
||||||
import { MafEventTimeline } from "../components/MafEventTimeline";
|
|
||||||
import { MafFactFlowPanel } from "../components/MafFactFlowPanel";
|
|
||||||
import { MafFactLedgerPanel } from "../components/MafFactLedgerPanel";
|
|
||||||
import { MafHealthGate } from "../components/MafHealthGate";
|
|
||||||
import { MafRunHeader } from "../components/MafRunHeader";
|
|
||||||
import { MafSeatStatusPanel } from "../components/MafSeatStatusPanel";
|
|
||||||
|
|
||||||
export default function MafRunDetailPage() {
|
|
||||||
const { runId } = useParams<{ runId: string }>();
|
|
||||||
return (
|
|
||||||
<MafHealthGate>
|
|
||||||
{runId ? <RunBody runId={runId} /> : <div className="p-6 text-slate-400">缺少 runId</div>}
|
|
||||||
</MafHealthGate>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RunBody({ runId }: { runId: string }) {
|
|
||||||
const { run, isLoadingRun, runError, events } = useMafRunWithEvents(runId);
|
|
||||||
const headerRef = useDesensitizeSubtree<HTMLDivElement>();
|
|
||||||
|
|
||||||
const live = runIsLive(run?.status);
|
|
||||||
const approvalsQuery = useMafApprovals(runId, live);
|
|
||||||
const actionsQuery = useMafActions(runId, live);
|
|
||||||
const pendingApprovals = (approvalsQuery.data?.approvals ?? []).filter(
|
|
||||||
(a) => a.status === "pending",
|
|
||||||
);
|
|
||||||
// 手动恢复前置判定(见 lib/run-resume.ts):
|
|
||||||
// run 处于 waiting_approval && 审批已加载成功 && 无 pending 审批时才允许手动恢复。
|
|
||||||
// 其余情况(有 pending、加载中、加载失败)一律禁止,避免 /resume 撞后端 409。
|
|
||||||
const allowManualResume = canManualResume({
|
|
||||||
runStatus: run?.status,
|
|
||||||
approvalsLoading: approvalsQuery.isLoading,
|
|
||||||
approvalsSuccess: approvalsQuery.isSuccess,
|
|
||||||
pendingCount: pendingApprovals.length,
|
|
||||||
});
|
|
||||||
// 阻断恢复时的提示文案(让用户知道为什么不能点)
|
|
||||||
const resumeBlockedHint = approvalsQuery.isError
|
|
||||||
? "无法确认审批状态,请稍后重试"
|
|
||||||
: pendingApprovals.length > 0
|
|
||||||
? `请先完成全部审批(剩余 ${pendingApprovals.length})`
|
|
||||||
: approvalsQuery.isLoading
|
|
||||||
? "审批加载中…"
|
|
||||||
: "正在等待自动恢复…";
|
|
||||||
const actionsById = useMemo(() => {
|
|
||||||
const m = new Map<string, MafAction>();
|
|
||||||
for (const a of actionsQuery.data?.actions ?? []) m.set(a.id, a);
|
|
||||||
return m;
|
|
||||||
}, [actionsQuery.data]);
|
|
||||||
|
|
||||||
const timeline = useMemo(() => foldEventsToTimeline(events), [events]);
|
|
||||||
|
|
||||||
if (isLoadingRun) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center text-slate-400">
|
|
||||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" /> 加载会商…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (runError || !run) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-slate-500">
|
|
||||||
<p>加载失败:{(runError as Error)?.message ?? "run 不存在或无权访问"}</p>
|
|
||||||
<Link to="/page/maf">
|
|
||||||
<TButton theme="default" variant="outline" size="small" icon={<ArrowLeft className="h-3.5 w-3.5" />}>
|
|
||||||
返回会话列表
|
|
||||||
</TButton>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalResult = run.maf_checkpoint?.final_result;
|
|
||||||
const showFinalResult = !!finalResult?.content;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
|
||||||
<div ref={headerRef}>
|
|
||||||
<MafRunHeader
|
|
||||||
run={run}
|
|
||||||
canResume={allowManualResume}
|
|
||||||
resumeBlockedHint={resumeBlockedHint}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid flex-1 min-h-0 grid-cols-1 gap-2 overflow-hidden p-2 lg:grid-cols-[1fr_360px_320px]">
|
|
||||||
{/* 左:时间线 */}
|
|
||||||
<section className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
|
|
||||||
<MafEventTimeline entries={timeline} streaming={live} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 中:席位 + 流程图 + 最终结论 */}
|
|
||||||
<section className="flex min-h-0 flex-col gap-2 overflow-y-auto">
|
|
||||||
<MafSeatStatusPanel
|
|
||||||
checkpoint={run.maf_checkpoint}
|
|
||||||
currentRound={typeof run.maf_checkpoint?.current_round === "number"
|
|
||||||
? run.maf_checkpoint.current_round
|
|
||||||
: undefined}
|
|
||||||
/>
|
|
||||||
<MafFactFlowPanel checkpoint={run.maf_checkpoint} streaming={live} />
|
|
||||||
<MafFactLedgerPanel checkpoint={run.maf_checkpoint} />
|
|
||||||
|
|
||||||
{/* 最终结论(MAF-06-07:区分建议动作与已执行动作) */}
|
|
||||||
{showFinalResult && (
|
|
||||||
<div className="rounded-lg border border-slate-200 bg-white p-3">
|
|
||||||
<div className="mb-1.5 flex items-center gap-1.5">
|
|
||||||
<h3 className="text-sm font-semibold text-slate-700">最终结论</h3>
|
|
||||||
{finalResult?.degraded && (
|
|
||||||
<TTag size="small" theme="warning" variant="light-outline">降级输出</TTag>
|
|
||||||
)}
|
|
||||||
{finalResult?.partial_failure && (
|
|
||||||
<TTag size="small" theme="danger" variant="light-outline">部分失败</TTag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* final_result.content 可能含 flow-json 块;这里只展示文字部分(流程图已在上方画布) */}
|
|
||||||
<div className="prose prose-sm max-h-72 max-w-none overflow-auto text-slate-700 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
|
||||||
<pre className="whitespace-pre-wrap break-words font-sans text-xs">
|
|
||||||
{stripFlowJson(finalResult!.content!)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
{finalResult?.note && (
|
|
||||||
<div className="mt-2 text-[11px] text-slate-400">{finalResult.note}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 右:审批 */}
|
|
||||||
<section className="flex min-h-0 flex-col gap-2 overflow-y-auto">
|
|
||||||
<div className="flex items-center gap-1.5 px-1 text-xs font-medium uppercase tracking-wide text-slate-400">
|
|
||||||
<ShieldQuestion className="h-3.5 w-3.5" />
|
|
||||||
写工具审批
|
|
||||||
{pendingApprovals.length > 0 && (
|
|
||||||
<TTag size="small" theme="danger" variant="light">{pendingApprovals.length}</TTag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{pendingApprovals.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-dashed border-slate-200 bg-white/50 p-4 text-center text-xs text-slate-400">
|
|
||||||
{live ? "暂无待审批的写操作" : "本次会商无待审批项"}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
pendingApprovals.map((approval) => (
|
|
||||||
<MafApprovalCard
|
|
||||||
key={approval.id}
|
|
||||||
approval={approval}
|
|
||||||
action={approval.action_id ? actionsById.get(approval.action_id) : undefined}
|
|
||||||
runId={runId}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MafActionAuditPanel runId={runId} live={live} />
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 把 final_result.content 里的 ```flow-json 块替换成占位提示(流程图已在画布展示)。 */
|
|
||||||
function stripFlowJson(text: string): string {
|
|
||||||
const FENCE = "```flow-json";
|
|
||||||
const start = text.lastIndexOf(FENCE);
|
|
||||||
if (start < 0) return text;
|
|
||||||
const after = text.slice(start + FENCE.length);
|
|
||||||
const close = after.indexOf("```");
|
|
||||||
const tail = close >= 0 ? after.slice(close + 3) : "";
|
|
||||||
return `${text.slice(0, start).trimEnd()}\n\n[结构化流程数据已在上方「事实/结论流程图」画布展示]${tail}`;
|
|
||||||
}
|
|
||||||
@@ -1,642 +0,0 @@
|
|||||||
/**
|
|
||||||
* MAF 会商会话列表页(路由 index)。
|
|
||||||
*
|
|
||||||
* 左:会话列表(按创建时间倒序);右:选中会话的 run 列表 + 「启动会商」。
|
|
||||||
* 点击某条 run → 跳 `/page/maf/runs/:runId`。
|
|
||||||
*
|
|
||||||
* 可视 UI 统一使用本地 `tdesign-react`(MAF-06-01/02/03)。
|
|
||||||
* 新建弹窗包含 task_text(必填)、模式、经理智能体、席位智能体多选、
|
|
||||||
* 可选高级项(模型/推理强度/最大轮数/写入策略)。智能体列表复用 @/core/agents。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { Loader2, MessageSquarePlus, Play, Trash2 } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
|
||||||
Button as TButton,
|
|
||||||
Checkbox as TCheckbox,
|
|
||||||
Dialog as TDialog,
|
|
||||||
Input as TInput,
|
|
||||||
Select as TSelect,
|
|
||||||
Tag as TTag,
|
|
||||||
Textarea as TTextarea,
|
|
||||||
} from "tdesign-react";
|
|
||||||
|
|
||||||
import { useAgents } from "@/core/agents";
|
|
||||||
import { useDesensitizeSubtree } from "@/lib/use-desensitize-subtree";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
import { useMafSessionActions, useMafSessions, type CreateSessionBody, type ParticipantInput } from "../api/sessions";
|
|
||||||
import { useMafHealth, useMafRuns, useMafRunActions } from "../api/runs";
|
|
||||||
import { runIsLive, type MafSessionStatus } from "../api/types";
|
|
||||||
import {
|
|
||||||
buildSessionOverrides,
|
|
||||||
createInitialSessionForm,
|
|
||||||
defaultsSummaryLine,
|
|
||||||
filterSeatsForManager,
|
|
||||||
toggleSeat,
|
|
||||||
validateMaxRounds,
|
|
||||||
type SessionFormState,
|
|
||||||
} from "../lib/session-form";
|
|
||||||
import { MafHealthGate } from "../components/MafHealthGate";
|
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
|
||||||
draft: "草稿",
|
|
||||||
running: "会商中",
|
|
||||||
waiting_approval: "等待审批",
|
|
||||||
waiting_input: "等待输入",
|
|
||||||
completed: "已完成",
|
|
||||||
failed: "失败",
|
|
||||||
cancelled: "已取消",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** session 状态 → TDesign Tag 主题。 */
|
|
||||||
function statusTagTheme(status: string): "default" | "primary" | "warning" | "success" | "danger" {
|
|
||||||
switch (status) {
|
|
||||||
case "running":
|
|
||||||
return "primary";
|
|
||||||
case "waiting_approval":
|
|
||||||
case "waiting_input":
|
|
||||||
return "warning";
|
|
||||||
case "completed":
|
|
||||||
return "success";
|
|
||||||
case "failed":
|
|
||||||
case "cancelled":
|
|
||||||
return "danger";
|
|
||||||
default:
|
|
||||||
return "default";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MafSessionsPage() {
|
|
||||||
return (
|
|
||||||
<MafHealthGate>
|
|
||||||
<SessionsBody />
|
|
||||||
</MafHealthGate>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionsBody() {
|
|
||||||
const sessionsQuery = useMafSessions({ limit: 100 });
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const headerRef = useDesensitizeSubtree<HTMLDivElement>();
|
|
||||||
|
|
||||||
const sessions = sessionsQuery.data?.sessions ?? [];
|
|
||||||
const active = selectedId
|
|
||||||
? sessions.find((s) => s.id === selectedId)
|
|
||||||
: sessions[0];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full">
|
|
||||||
{/* 左:会话列表 */}
|
|
||||||
<aside className="flex w-72 shrink-0 flex-col border-r border-slate-200 bg-white">
|
|
||||||
<div ref={headerRef} className="flex items-center justify-between border-b border-slate-100 px-3 py-2.5">
|
|
||||||
<h2 className="text-sm font-semibold text-slate-700">会话</h2>
|
|
||||||
<TButton
|
|
||||||
theme="default"
|
|
||||||
variant="outline"
|
|
||||||
size="small"
|
|
||||||
icon={<MessageSquarePlus className="h-3.5 w-3.5" />}
|
|
||||||
onClick={() => setCreateOpen(true)}
|
|
||||||
>
|
|
||||||
新建
|
|
||||||
</TButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{sessionsQuery.isLoading && (
|
|
||||||
<div className="flex items-center justify-center py-8 text-slate-400">
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> 加载中…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sessionsQuery.isError && (
|
|
||||||
<div className="p-3 text-xs text-red-500">
|
|
||||||
加载失败:{(sessionsQuery.error as Error)?.message ?? "未知错误"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!sessionsQuery.isLoading && sessions.length === 0 && (
|
|
||||||
<div className="p-4 text-center text-xs text-slate-400">
|
|
||||||
暂无会话,点击「新建」开始
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
{sessions.map((s) => (
|
|
||||||
<li key={s.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedId(s.id)}
|
|
||||||
className={cn(
|
|
||||||
"w-full border-b border-slate-50 px-3 py-2.5 text-left transition hover:bg-slate-50",
|
|
||||||
active?.id === s.id && "bg-blue-50/60",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-slate-700">
|
|
||||||
{s.title || "未命名会话"}
|
|
||||||
</span>
|
|
||||||
<TTag size="small" theme={statusTagTheme(s.status)} variant="light-outline">
|
|
||||||
{STATUS_LABEL[s.status] ?? s.status}
|
|
||||||
</TTag>
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] text-slate-400">
|
|
||||||
<TTag size="small" theme="default" variant="outline">{s.mode}</TTag>
|
|
||||||
<span className="truncate">{s.task_text || "无任务说明"}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* 右:选中会话的 run 列表 */}
|
|
||||||
<main className="flex flex-1 flex-col overflow-hidden bg-slate-50/40">
|
|
||||||
{active ? (
|
|
||||||
<SessionDetail sessionId={active.id} title={active.title} mode={active.mode} status={active.status} />
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center text-sm text-slate-400">
|
|
||||||
选择左侧会话查看详情,或新建一个
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<CreateSessionDialog open={createOpen} onOpenChange={setCreateOpen} onCreated={(id) => setSelectedId(id)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionDetail({
|
|
||||||
sessionId,
|
|
||||||
title,
|
|
||||||
mode,
|
|
||||||
status,
|
|
||||||
}: {
|
|
||||||
sessionId: string;
|
|
||||||
title: string;
|
|
||||||
mode: string;
|
|
||||||
status: MafSessionStatus;
|
|
||||||
}) {
|
|
||||||
const runsQuery = useMafRuns(sessionId);
|
|
||||||
const actions = useMafRunActions();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const runs = runsQuery.data?.runs ?? [];
|
|
||||||
|
|
||||||
// 存在运行中 run 时禁用「启动会商」
|
|
||||||
const liveRun = runs.find((r) => runIsLive(r.status));
|
|
||||||
|
|
||||||
const onStart = () => {
|
|
||||||
actions.start.mutate(
|
|
||||||
{ sessionId },
|
|
||||||
{
|
|
||||||
onSuccess: (run) => navigate(`/page/maf/runs/${run.id}`),
|
|
||||||
onError: (e: Error) => toast.error(`启动失败:${e.message}`),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<header className="flex items-center justify-between border-b border-slate-200 bg-white px-4 py-2.5">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h1 className="truncate text-base font-semibold text-slate-800">{title || "未命名会话"}</h1>
|
|
||||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-400">
|
|
||||||
<TTag size="small" theme="default" variant="outline">{mode}</TTag>
|
|
||||||
<TTag size="small" theme={statusTagTheme(status)} variant="light-outline">
|
|
||||||
{STATUS_LABEL[status] ?? status}
|
|
||||||
</TTag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{liveRun && (
|
|
||||||
<TButton
|
|
||||||
theme="default"
|
|
||||||
variant="outline"
|
|
||||||
size="small"
|
|
||||||
icon={<Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500" />}
|
|
||||||
onClick={() => navigate(`/page/maf/runs/${liveRun.id}`)}
|
|
||||||
>
|
|
||||||
已有会商进行中
|
|
||||||
</TButton>
|
|
||||||
)}
|
|
||||||
<TButton
|
|
||||||
theme="primary"
|
|
||||||
size="small"
|
|
||||||
loading={actions.start.isPending}
|
|
||||||
disabled={!!liveRun}
|
|
||||||
icon={<Play className="h-3.5 w-3.5" />}
|
|
||||||
onClick={onStart}
|
|
||||||
>
|
|
||||||
启动会商
|
|
||||||
</TButton>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
|
||||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-slate-400">历史 run</h3>
|
|
||||||
{runsQuery.isLoading && (
|
|
||||||
<div className="flex items-center py-6 text-slate-400">
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> 加载中…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{runs.length === 0 && !runsQuery.isLoading && (
|
|
||||||
<p className="py-6 text-center text-xs text-slate-400">暂无会商记录,点击「启动会商」开始</p>
|
|
||||||
)}
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{runs.map((run) => (
|
|
||||||
<li key={run.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate(`/page/maf/runs/${run.id}`)}
|
|
||||||
className="flex w-full items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-left transition hover:border-blue-300 hover:shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs font-mono text-slate-400">{run.id.slice(0, 12)}…</span>
|
|
||||||
{runIsLive(run.status) && (
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin text-blue-500" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-[11px] text-slate-400">
|
|
||||||
{run.started_at ? new Date(run.started_at).toLocaleString() : "未开始"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<TTag size="small" theme="default" variant="outline">{run.status}</TTag>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateSessionDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onCreated,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (v: boolean) => void;
|
|
||||||
onCreated: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const health = useMafHealth();
|
|
||||||
const sessionActions = useMafSessionActions();
|
|
||||||
const { agents, isLoading: agentsLoading, error: agentsError } = useAgents();
|
|
||||||
|
|
||||||
// 表单状态:单一对象,由 createInitialSessionForm 根据 health 默认值初始化。
|
|
||||||
const [form, setForm] = useState<SessionFormState>(() =>
|
|
||||||
createInitialSessionForm(health.data),
|
|
||||||
);
|
|
||||||
const [maxRoundsError, setMaxRoundsError] = useState<string>("");
|
|
||||||
|
|
||||||
// 仅在弹窗「由关闭变打开」时重置表单(干净表单 + 从 health 重新取默认值)。
|
|
||||||
// 不在 health.data 变化时重置——避免打开中途 health 刷新把用户已填内容冲掉。
|
|
||||||
const prevOpenRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && !prevOpenRef.current) {
|
|
||||||
setForm(createInitialSessionForm(health.data));
|
|
||||||
setMaxRoundsError("");
|
|
||||||
}
|
|
||||||
prevOpenRef.current = open;
|
|
||||||
}, [open, health.data]);
|
|
||||||
|
|
||||||
// 切换经理时,自动从已选席位中移除(后端参与者唯一约束)
|
|
||||||
useEffect(() => {
|
|
||||||
setForm((prev) => {
|
|
||||||
const nextSeats = filterSeatsForManager(
|
|
||||||
prev.seatAgentIds,
|
|
||||||
prev.managerAgentId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return nextSeats.length === prev.seatAgentIds.length
|
|
||||||
? prev
|
|
||||||
: { ...prev, seatAgentIds: nextSeats };
|
|
||||||
});
|
|
||||||
}, [form.managerAgentId]);
|
|
||||||
|
|
||||||
const onToggleSeat = (agentId: string) => {
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
seatAgentIds: toggleSeat(prev.seatAgentIds, agentId, prev.managerAgentId),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const patch = (p: Partial<SessionFormState>) => setForm((prev) => ({ ...prev, ...p }));
|
|
||||||
|
|
||||||
const healthDefaults = health.data;
|
|
||||||
const healthReady = !!healthDefaults;
|
|
||||||
|
|
||||||
const onSubmit = () => {
|
|
||||||
if (!healthReady) {
|
|
||||||
toast.error("后端默认配置尚未就绪,请稍后重试");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!form.taskText.trim()) {
|
|
||||||
toast.error("请填写任务说明");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!form.managerAgentId) {
|
|
||||||
toast.error("请选择经理智能体");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (form.seatAgentIds.length === 0) {
|
|
||||||
toast.error("请至少选择一个席位智能体");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只产出「相对后端默认确实改写了」的高级字段;未改写 → 不传 → 后端决定。
|
|
||||||
const { overrides, maxRoundsError: roundsErr } = buildSessionOverrides(form, healthDefaults);
|
|
||||||
if (roundsErr) {
|
|
||||||
setMaxRoundsError(roundsErr);
|
|
||||||
toast.error(roundsErr);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setMaxRoundsError("");
|
|
||||||
|
|
||||||
// 构建 participants
|
|
||||||
const participants: ParticipantInput[] = [];
|
|
||||||
const managerAgent = agents.find((a) => a.id === form.managerAgentId);
|
|
||||||
participants.push({
|
|
||||||
agent_id: form.managerAgentId,
|
|
||||||
display_name: managerAgent?.name ?? "",
|
|
||||||
role: "manager",
|
|
||||||
sort_order: 0,
|
|
||||||
});
|
|
||||||
form.seatAgentIds.forEach((agentId, idx) => {
|
|
||||||
const agent = agents.find((a) => a.id === agentId);
|
|
||||||
participants.push({
|
|
||||||
agent_id: agentId,
|
|
||||||
display_name: agent?.name ?? "",
|
|
||||||
role: "seat",
|
|
||||||
sort_order: idx + 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const body: CreateSessionBody = {
|
|
||||||
title: form.title.trim(),
|
|
||||||
task_text: form.taskText.trim(),
|
|
||||||
manager_agent_id: form.managerAgentId,
|
|
||||||
participants,
|
|
||||||
};
|
|
||||||
// 仅传被改写的模式 / 写入策略 / 最大轮数(来自 buildSessionOverrides)
|
|
||||||
if (overrides.mode) body.mode = overrides.mode;
|
|
||||||
if (overrides.writePolicy) body.write_policy = overrides.writePolicy;
|
|
||||||
if (overrides.maxRounds != null) body.max_rounds = overrides.maxRounds;
|
|
||||||
// 模型名 / 推理强度:非空才传
|
|
||||||
if (form.modelName.trim()) body.model_name = form.modelName.trim();
|
|
||||||
if (form.reasoningEffort.trim()) body.reasoning_effort = form.reasoningEffort.trim();
|
|
||||||
|
|
||||||
sessionActions.create.mutate(body, {
|
|
||||||
onSuccess: (s) => {
|
|
||||||
toast.success("会话已创建");
|
|
||||||
// 关闭弹窗;下次打开会由 open-effect 重新重置表单
|
|
||||||
onOpenChange(false);
|
|
||||||
onCreated(s.id);
|
|
||||||
},
|
|
||||||
onError: (e: Error) => toast.error(`创建失败:${e.message}`),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const agentOptions = agents.map((a) => ({ label: a.name, value: a.id }));
|
|
||||||
// 实时校验最大轮数(展示错误,不阻塞输入)
|
|
||||||
const liveMaxRoundsError = (() => {
|
|
||||||
if (!form.maxRounds.trim()) return "";
|
|
||||||
const v = validateMaxRounds(form.maxRounds);
|
|
||||||
return v.valid ? "" : v.error ?? "";
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TDialog
|
|
||||||
visible={open}
|
|
||||||
onClose={() => onOpenChange(false)}
|
|
||||||
header="新建 MAF 会商会话"
|
|
||||||
width="560px"
|
|
||||||
closeOnEscKeydown
|
|
||||||
footer={
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<TButton theme="default" variant="outline" onClick={() => onOpenChange(false)}>
|
|
||||||
取消
|
|
||||||
</TButton>
|
|
||||||
<TButton
|
|
||||||
theme="primary"
|
|
||||||
loading={sessionActions.create.isPending}
|
|
||||||
disabled={!healthReady}
|
|
||||||
onClick={onSubmit}
|
|
||||||
>
|
|
||||||
{healthReady ? "创建" : "加载后端默认…"}
|
|
||||||
</TButton>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-3 py-2">
|
|
||||||
{/* health 尚未就绪:禁用创建并提示 */}
|
|
||||||
{!healthReady && (
|
|
||||||
<div className="flex items-center gap-1.5 rounded-md border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-700">
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
正在获取后端默认配置,创建按钮暂不可点…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 标题 */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-slate-600">会商标题</label>
|
|
||||||
<TInput
|
|
||||||
value={form.title}
|
|
||||||
onChange={(val: string) => patch({ title: val })}
|
|
||||||
placeholder="例如:某任务多视角研讨"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 任务说明(必填) */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-slate-600">
|
|
||||||
任务说明 <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<TTextarea
|
|
||||||
value={form.taskText}
|
|
||||||
onChange={(val: string) => patch({ taskText: val })}
|
|
||||||
placeholder="描述本次会商要解决的问题、背景和目标(必填)"
|
|
||||||
autosize={{ minRows: 3, maxRows: 8 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 模式 */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-slate-600">会商模式</label>
|
|
||||||
<TSelect
|
|
||||||
value={form.mode}
|
|
||||||
onChange={(val) => patch({ mode: String(val) })}
|
|
||||||
options={[
|
|
||||||
{ label: "structured(规划→专家→评审→总结)", value: "structured" },
|
|
||||||
{ label: "group_chat(协调员动态点名)", value: "group_chat" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 经理智能体 */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-slate-600">
|
|
||||||
经理智能体 <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
{agentsLoading ? (
|
|
||||||
<div className="flex h-9 items-center px-2 text-xs text-slate-400">
|
|
||||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> 加载智能体…
|
|
||||||
</div>
|
|
||||||
) : agentsError ? (
|
|
||||||
<div className="flex h-9 items-center px-2 text-xs text-red-500">
|
|
||||||
加载失败:{(agentsError as Error)?.message ?? "未知错误"}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<TSelect
|
|
||||||
value={form.managerAgentId}
|
|
||||||
onChange={(val) => patch({ managerAgentId: String(val) })}
|
|
||||||
options={agentOptions}
|
|
||||||
placeholder="请选择…"
|
|
||||||
filterable
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 席位智能体多选 */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-slate-600">
|
|
||||||
席位智能体(至少一个) <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div className="max-h-40 overflow-y-auto rounded-md border border-slate-200 p-2">
|
|
||||||
{agentsLoading && (
|
|
||||||
<div className="flex items-center justify-center py-2 text-xs text-slate-400">
|
|
||||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> 加载中…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{agentsError && (
|
|
||||||
<div className="py-2 text-center text-xs text-red-500">
|
|
||||||
加载失败:{(agentsError as Error)?.message ?? "未知错误"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!agentsLoading && !agentsError && agents.length === 0 && (
|
|
||||||
<div className="py-2 text-center text-xs text-slate-400">暂无可用智能体</div>
|
|
||||||
)}
|
|
||||||
{!agentsLoading && !agentsError && agents.map((a) => (
|
|
||||||
<label key={a.id} className="flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 hover:bg-slate-50">
|
|
||||||
<TCheckbox
|
|
||||||
checked={form.seatAgentIds.includes(a.id)}
|
|
||||||
onChange={() => onToggleSeat(a.id)}
|
|
||||||
disabled={a.id === form.managerAgentId}
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-slate-700">{a.name}</span>
|
|
||||||
{a.id === form.managerAgentId && (
|
|
||||||
<span className="text-[10px] text-slate-400">(已选为经理)</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{form.seatAgentIds.length > 0 && (
|
|
||||||
<div className="mt-1 text-[10px] text-slate-400">
|
|
||||||
已选 {form.seatAgentIds.length} 个席位
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 高级项折叠 */}
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => patch({ showAdvanced: !form.showAdvanced })}
|
|
||||||
className="text-xs text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
{form.showAdvanced ? "收起高级选项" : "展开高级选项"}
|
|
||||||
</button>
|
|
||||||
{/* 高级项未展开时,显示一行只读后端默认摘要(值只来自 health,绝不硬编码) */}
|
|
||||||
{!form.showAdvanced && (
|
|
||||||
<div className="mt-1 text-[10px] text-slate-400">
|
|
||||||
{defaultsSummaryLine(healthDefaults)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{form.showAdvanced && (
|
|
||||||
<div className="mt-2 space-y-2 rounded-md border border-slate-100 bg-slate-50/50 p-2">
|
|
||||||
<div>
|
|
||||||
<label className="mb-0.5 block text-[11px] text-slate-500">模型名称</label>
|
|
||||||
<TInput
|
|
||||||
value={form.modelName}
|
|
||||||
onChange={(val: string) => patch({ modelName: val })}
|
|
||||||
placeholder="留空使用默认"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-0.5 block text-[11px] text-slate-500">推理强度</label>
|
|
||||||
<TSelect
|
|
||||||
value={form.reasoningEffort}
|
|
||||||
onChange={(val) => patch({ reasoningEffort: String(val) })}
|
|
||||||
size="small"
|
|
||||||
options={[
|
|
||||||
{ label: "留空使用默认", value: "" },
|
|
||||||
{ label: "low", value: "low" },
|
|
||||||
{ label: "medium", value: "medium" },
|
|
||||||
{ label: "high", value: "high" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-0.5 block text-[11px] text-slate-500">最大轮数(1–64)</label>
|
|
||||||
<TInput
|
|
||||||
value={form.maxRounds}
|
|
||||||
onChange={(val: string) => patch({ maxRounds: val })}
|
|
||||||
placeholder="留空使用默认"
|
|
||||||
type="number"
|
|
||||||
size="small"
|
|
||||||
status={liveMaxRoundsError ? "warning" : "default"}
|
|
||||||
/>
|
|
||||||
{(liveMaxRoundsError || maxRoundsError) && (
|
|
||||||
<div className="mt-0.5 text-[10px] text-red-500">
|
|
||||||
{liveMaxRoundsError || maxRoundsError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-0.5 block text-[11px] text-slate-500">写入策略</label>
|
|
||||||
<TSelect
|
|
||||||
value={form.writePolicy}
|
|
||||||
onChange={(val) => patch({ writePolicy: String(val) })}
|
|
||||||
size="small"
|
|
||||||
options={[
|
|
||||||
{ label: "默认", value: "" },
|
|
||||||
{ label: "auto(自动执行)", value: "auto" },
|
|
||||||
{ label: "confirm_high_risk(高风险确认)", value: "confirm_high_risk" },
|
|
||||||
{ label: "confirm_all_writes(全部确认)", value: "confirm_all_writes" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除会话按钮(预留导出)。
|
|
||||||
export function DeleteSessionButton({ sessionId }: { sessionId: string }) {
|
|
||||||
const actions = useMafSessionActions();
|
|
||||||
return (
|
|
||||||
<TButton
|
|
||||||
theme="danger"
|
|
||||||
variant="outline"
|
|
||||||
size="small"
|
|
||||||
loading={actions.remove.isPending}
|
|
||||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
|
||||||
onClick={() => {
|
|
||||||
actions.remove.mutate(sessionId, {
|
|
||||||
onSuccess: () => toast.success("已删除"),
|
|
||||||
onError: (e: Error) => toast.error(`删除失败:${e.message}`),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</TButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loaded 100 of 542 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user