Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c555b92089 | ||
|
|
aa3742b103 | ||
|
|
1aeee86984 | ||
|
|
a786cdcad0 | ||
|
|
f9a5b5bc1e | ||
|
|
428588f1c4 | ||
|
|
1a080311bd | ||
|
|
ddf83f7728 | ||
|
|
d93cc62806 | ||
|
|
4fcd96af29 | ||
|
|
99642fca6c | ||
|
|
df408831e5 | ||
|
|
889a97a04d | ||
|
|
a55992e31c | ||
|
|
f82363d81f | ||
|
|
21c4c898e7 | ||
|
|
bd4131b4e2 | ||
|
|
e690475e6e |
No files matched your search
@@ -1,20 +0,0 @@
|
||||
import os
|
||||
|
||||
import paramiko
|
||||
|
||||
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect(
|
||||
"119.254.86.251",
|
||||
port=22322,
|
||||
username="root",
|
||||
password=os.environ["TARGET_SSH_PASSWORD"],
|
||||
timeout=15,
|
||||
auth_timeout=15,
|
||||
banner_timeout=15,
|
||||
)
|
||||
_, stdout, stderr = client.exec_command(os.environ["TARGET_SSH_COMMAND"], timeout=30)
|
||||
print(stdout.read().decode(errors="replace"), end="")
|
||||
print(stderr.read().decode(errors="replace"), end="")
|
||||
client.close()
|
||||
+12
-89
@@ -1,9 +1,5 @@
|
||||
# Workspace/editor
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
.vscode/
|
||||
frontend-web/.vscode/
|
||||
node_modules/
|
||||
# Claude Code local-only settings (per-user permissions, not shared)
|
||||
@@ -57,7 +53,6 @@ offline-backend-20260505/bundle/stop_backend.sh
|
||||
|
||||
# offline-backend-20260512 runtime artifacts (large files, local config, data)
|
||||
offline-backend-20260512/.env.example
|
||||
offline-backend-20260512/.env
|
||||
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
|
||||
offline-backend-20260512/SHA256SUMS
|
||||
offline-backend-20260512/config.yaml.example
|
||||
@@ -69,8 +64,6 @@ offline-backend-20260512/wheelhouse/
|
||||
offline-backend-20260512/backend/.deer-flow/data/*.db
|
||||
offline-backend-20260512/backend/.deer-flow/data/*.db-*
|
||||
offline-backend-20260512/backend/.deer-flow/*
|
||||
offline-backend-20260512/backend/.env
|
||||
offline-backend-20260512/backend/.env.local
|
||||
offline-backend-20260512/start_backend.sh
|
||||
offline-backend-20260512/stop_backend.sh
|
||||
offline-backend-20260512/check_backend.sh
|
||||
@@ -88,68 +81,9 @@ skills/
|
||||
hermes-agent-main/
|
||||
.gitignore
|
||||
offline-backend-20260512/backend/tests/*
|
||||
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_gauss_database.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py
|
||||
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
||||
# Keep formal Word OOXML and embedded-font regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_word_export.py
|
||||
# Keep generated-file library aggregation and filtering regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_artifact_library.py
|
||||
# Keep the model configuration regression coverage versioned even though
|
||||
# this deployment repository otherwise ignores newly added backend tests.
|
||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
||||
# Keep vLLM reasoning/reasoning_content field compatibility regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_reasoning_fields.py
|
||||
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
|
||||
# Keep strategy-selection built-in agent seed and safety-contract regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_strategy_selection_seed.py
|
||||
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_fixed_questions.py
|
||||
# Keep configurable es_query system-prompt routing regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py
|
||||
# Keep the local LLMWiki vector-index and external API regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_encoder_runtime.py
|
||||
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
|
||||
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
|
||||
# Deep-research reliability regression coverage.
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_start_job.py
|
||||
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
|
||||
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
|
||||
# Independent enterprise-research persistence, HTTP and race regressions.
|
||||
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
|
||||
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
|
||||
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
|
||||
# Roundtable coordinator/seat capability isolation regressions.
|
||||
!offline-backend-20260512/backend/tests/test_roundtable_role_boundary.py
|
||||
# Workflow Studio: schemas / definitions / engine / run-lifecycle regressions
|
||||
# plus the concurrency remediation suite (atomic resume, draft CAS, publish
|
||||
# counter, event seq) — required in CI per docs/WORKFLOW_STUDIO_BACKEND_REMEDIATION_ZH.md P1-06.
|
||||
!offline-backend-20260512/backend/tests/test_workflow_schemas.py
|
||||
!offline-backend-20260512/backend/tests/test_workflow_definitions.py
|
||||
!offline-backend-20260512/backend/tests/test_workflow_engine.py
|
||||
!offline-backend-20260512/backend/tests/test_workflow_runs.py
|
||||
!offline-backend-20260512/backend/tests/test_workflow_concurrency.py
|
||||
!offline-backend-20260512/backend/tests/test_workflow_schema_rebuild.py
|
||||
# Writing-mode first-report pipeline enforcement regressions.
|
||||
!offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py
|
||||
!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py
|
||||
# LLM call timeout retry classification regression coverage.
|
||||
!offline-backend-20260512/backend/tests/test_llm_error_handling_middleware.py
|
||||
# Writing-mode pipeline conversation-material regressions.
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_report_tool.py
|
||||
# Report collaboration (RC-BE-000–018) regression coverage.
|
||||
!offline-backend-20260512/backend/tests/test_report_collaboration_*.py
|
||||
!offline-backend-20260512/backend/tests/test_ensure_orm_columns_sync.py
|
||||
# MAF roundtable tests must be tracked (excepted from the broad tests/* ignore above)
|
||||
!offline-backend-20260512/backend/tests/test_maf_*.py
|
||||
!offline-backend-20260512/backend/tests/maf_test_helpers.py
|
||||
deploy
|
||||
.history
|
||||
|
||||
@@ -159,22 +93,13 @@ git-clone/
|
||||
# Repo-root offline dependency pack (local only)
|
||||
/dist/
|
||||
|
||||
# Frontend offline pack working directories
|
||||
frontend-web/.offline-pack-build/
|
||||
frontend-web/.offline-extract-test/
|
||||
frontend-web-node_modules-offline-*.tar.gz
|
||||
|
||||
# Local dependency caches and backend scratch archives
|
||||
/.pnpm-store/
|
||||
/offline-backend-20260512/backend/backend*.zip
|
||||
|
||||
# Local offline bundles and scratch tooling
|
||||
/*.tar.gz
|
||||
/offline-backend-20260512/*.zip
|
||||
/tmp/
|
||||
|
||||
.codegraph
|
||||
codegraph.mdc
|
||||
# Frontend offline pack working directories
|
||||
frontend-web/.offline-pack-build/
|
||||
frontend-web/.offline-extract-test/
|
||||
frontend-web-node_modules-offline-*.tar.gz
|
||||
|
||||
.codegraph
|
||||
codegraph.mdc
|
||||
|
||||
docs/aidev-docs
|
||||
docs/backups
|
||||
@@ -184,8 +109,6 @@ docs/backups
|
||||
# uv lockfile (not tracked per project preference)
|
||||
offline-backend-20260512/backend/uv.lock
|
||||
|
||||
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
|
||||
|
||||
# Local-only scratch directory (not committed)
|
||||
0622/
|
||||
|
||||
@@ -193,4 +116,4 @@ offline-backend-20260512/backend/uv.lock
|
||||
__pycache__/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-python.python",
|
||||
"ms-python.debugpy"
|
||||
]
|
||||
}
|
||||
Vendored
-66
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "DeerFlow: Backend FastAPI (8001)",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "uvicorn",
|
||||
"args": [
|
||||
"app.gateway.app:app",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"8001"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/offline-backend-20260512/backend",
|
||||
"python": "${workspaceFolder}/offline-backend-20260512/backend/.venv/Scripts/python.exe",
|
||||
"envFile": "${workspaceFolder}/offline-backend-20260512/.env",
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/offline-backend-20260512/backend;${workspaceFolder}/offline-backend-20260512/backend/.deer-flow/python-vendor",
|
||||
"GATEWAY_CORS_ORIGINS": "http://localhost:5174,http://127.0.0.1:5174,http://localhost:8080,http://127.0.0.1:8080",
|
||||
"UV_LINK_MODE": "copy",
|
||||
"WEKNORA_ADMIN_PASSWORD": "1qaz@WSX"
|
||||
},
|
||||
"console": "integratedTerminal",
|
||||
"redirectOutput": true,
|
||||
"justMyCode": false,
|
||||
"subProcess": true
|
||||
},
|
||||
{
|
||||
"name": "DeerFlow: Frontend Vite (5174)",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm.cmd run dev -- --host 0.0.0.0 --port 5174",
|
||||
"cwd": "${workspaceFolder}/frontend-web",
|
||||
"env": {
|
||||
"VITE_BACKEND_BASE_URL": "http://127.0.0.1:8001",
|
||||
"VITE_LANGGRAPH_BASE_URL": "http://127.0.0.1:8001/api"
|
||||
},
|
||||
"serverReadyAction": {
|
||||
"pattern": "Local:\\s+(https?://[^\\s]+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "openExternally"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "DeerFlow: Open Frontend in Chrome",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "DeerFlow: VS Code Start",
|
||||
"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
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/offline-backend-20260512/backend/.venv/Scripts/python.exe",
|
||||
"python.terminal.activateEnvironment": true,
|
||||
"typescript.tsdk": "frontend-web/node_modules/typescript/lib",
|
||||
"npm.packageManager": "npm"
|
||||
}
|
||||
Vendored
-175
@@ -1,175 +0,0 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "DeerFlow: VS Code Start",
|
||||
"type": "process",
|
||||
"command": "powershell.exe",
|
||||
"args": [
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
"${workspaceFolder}/scripts/vscode-start.ps1",
|
||||
"-OpenBrowser"
|
||||
],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"clear": true
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow: Start Full Stack and Wait",
|
||||
"type": "process",
|
||||
"command": "powershell.exe",
|
||||
"args": [
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
"${workspaceFolder}/scripts/start-all-and-wait-frontend.ps1"
|
||||
],
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow: Start Full Stack",
|
||||
"type": "process",
|
||||
"command": "powershell.exe",
|
||||
"args": [
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
"${workspaceFolder}/scripts/start-all.ps1"
|
||||
],
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow: Stop Full Stack",
|
||||
"type": "process",
|
||||
"command": "powershell.exe",
|
||||
"args": [
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
"${workspaceFolder}/scripts/stop-all.ps1"
|
||||
],
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow: Status",
|
||||
"type": "process",
|
||||
"command": "powershell.exe",
|
||||
"args": [
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
"${workspaceFolder}/scripts/status.ps1"
|
||||
],
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow: Check Local Dev Environment",
|
||||
"type": "shell",
|
||||
"command": "$backendPython = '${workspaceFolder}\\offline-backend-20260512\\backend\\.venv\\Scripts\\python.exe'; if (-not (Test-Path -LiteralPath $backendPython)) { throw 'Backend virtual environment is missing. Run uv sync in offline-backend-20260512/backend first.' }; & $backendPython -c \"import uvicorn; print('Backend Python OK')\"; npm --prefix '${workspaceFolder}\\frontend-web' exec vite -- --version",
|
||||
"options": {
|
||||
"shell": {
|
||||
"executable": "powershell.exe",
|
||||
"args": [
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command"
|
||||
]
|
||||
}
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow: Install Frontend Dependencies",
|
||||
"type": "process",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"ci",
|
||||
"--no-audit",
|
||||
"--no-fund"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend-web"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow: Frontend Typecheck",
|
||||
"type": "process",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"typecheck"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend-web"
|
||||
},
|
||||
"problemMatcher": "$tsc"
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow: Frontend Build",
|
||||
"type": "process",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend-web"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow: Backend Tests",
|
||||
"type": "process",
|
||||
"command": "${workspaceFolder}/offline-backend-20260512/backend/.venv/Scripts/python.exe",
|
||||
"args": [
|
||||
"-m",
|
||||
"pytest",
|
||||
"tests/",
|
||||
"-v"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/offline-backend-20260512/backend",
|
||||
"env": {
|
||||
"PYTHONPATH": ".",
|
||||
"WEKNORA_ADMIN_PASSWORD": "1qaz@WSX"
|
||||
}
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "DeerFlow: WeKnora Backend Tests",
|
||||
"type": "process",
|
||||
"command": "${workspaceFolder}/offline-backend-20260512/backend/.venv/Scripts/python.exe",
|
||||
"args": [
|
||||
"-m",
|
||||
"pytest",
|
||||
"tests/test_llmwiki_weknora.py",
|
||||
"-v"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/offline-backend-20260512/backend",
|
||||
"env": {
|
||||
"PYTHONPATH": ".",
|
||||
"WEKNORA_ADMIN_PASSWORD": "1qaz@WSX"
|
||||
}
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
# 深度研究第三轮:检索稳定性修复 + 参考文献确定性 + 页面对齐 AI 写作改造
|
||||
|
||||
## A. 两个 bug 的排查结论与修复
|
||||
|
||||
### A1. 第二次检索「网络波动」——确认是真问题(DDG 限流 + 错误语义混淆 + 零重试)
|
||||
|
||||
**排查结论**(用户判断"确实是网络波动"基本正确,但代码层面放大了它):
|
||||
- 「网络波动」文案不存在于任何代码中——是收集员 LLM 看到 `{"error": "No results found"}` 信封后自己编的解释
|
||||
- 真实根因:DDG html 端点对同 IP 快速连续请求限流(403/429 挑战页)→ `DDGSException` → `ddg_search/tools.py::_search_text` 把异常吞成 `[]` → 上层把「被限流」和「真没结果」混成同一个错误信封;工具层完全没有重试
|
||||
|
||||
**修复**(`packages/harness/deerflow/community/ddg_search/tools.py`):
|
||||
1. `_search_text` 对异常加 2 次重试 + 指数退避(约 1.5s / 3s + 抖动)
|
||||
2. 错误信封区分场景:真无结果 → `no results`;多次尝试仍失败 → `web search failed after N attempts (rate limited or network), 换一组检索词稍后重试`——让模型不再瞎猜「网络波动」
|
||||
3. 收集员 SOUL.md 行为准则加一条(同步改种子资产 + 已安装副本 `.deer-flow/agents/deep-research-collector/`):搜索返回 error 信封时先换检索词重试一轮,而不是直接告知用户网络异常
|
||||
|
||||
### A2. 参考文献缺失——代码确有隐患(当前是模型自发行为,时有时无)
|
||||
|
||||
**排查结论**:后端没有任何「参考来源」追加逻辑;四个 runner 的报告 prompt 只要求内联 `[N]` 引用。出现过的参考列表是模型看到上下文头 `[N] 标题(URL)` 后**自发**补写的——弱模型下不可靠,与"信息够不够"无必然关系。
|
||||
|
||||
**修复**(prompt 指示 + 确定性兜底双保险):
|
||||
1. 四个 runner(basic / detailed / deep / multi_agent)的报告 prompt 各加一条:报告最后附「## 参考来源」,按 `[N] 标题. URL` 列出正文实际引用过的来源
|
||||
2. `basic.py` 新增共享函数 `ensure_reference_section(report, context_list, materials)`:报告缺少参考小节时,解析正文引用的 `[N]` 编号,按压缩上下文顺序生成编号列表追加(无编号时列全部来源,封顶 20 条;已有参考小节则不重复追加)。在各 runner 最终 `write_text("report.md", ...)` 前调用(约 7 个调用点,跳过零材料 fallback)
|
||||
3. 新增测试 `tests/test_deep_research_references.py`:helper 单测(正常追加/编号映射/不重复追加/无 URL 来源)+ runner 集成断言
|
||||
|
||||
## B. 页面改造(核心需求):左列复用 AI 写作对话形态,右列改真沙箱
|
||||
|
||||
### B1. 左列 = AI 写作式对话收集面板(仿 WritingSetupChat)
|
||||
|
||||
- **空状态欢迎屏**(无消息时隐藏头部,展示欢迎卡片,WritingWelcome 式)+ 有消息后 `MessageList`(沿用现有绑 collector 线程的接线)+ 首 token 前「正在整理…」占位条
|
||||
- **输入框换成白卡片 PromptInput 套件**(与 AI 写作完全同款组件):卡片内一行「当前对话模型」Select(沿用 ModelNameSelect)+ 高级配置入口;下方 PromptInputTextarea + PromptInputFooter + PromptInputSubmit(`status` ready/streaming,流式中变停止按钮)。经典模式入口保留在高级配置对话框
|
||||
- **配置卡片进对话流**(对标 AI 写作「识别出意图后出现表单」):
|
||||
- `message-list.tsx` 最小扩展(约 15 行):新增分组 `assistant:research-approval`(识别 `additional_kwargs.setup_research_approval`)+ 可选 prop `researchApprovalSlot?: ReactNode`;不传 slot 或无该载荷时零行为变化——普通对话/AI 写作完全不受影响
|
||||
- workbench:收集回合结束(awaiting_report,即"意图识别完成")时向 `displayThread` 尾部注入一条虚拟 ai 消息(WritingSetupChat 的 displayThread 同款手法),slot 传现有 `ReportConfigCard` → 卡片作为助手下一轮出现在对话流里
|
||||
- 写作进度卡、完成卡、摘要气泡、追问列表保留在列表下方卡片区(报告正文移到右侧沙箱)
|
||||
|
||||
### B2. 右列 = 工作区真沙箱(Step2SandboxLayout 的 stub 线程模式)
|
||||
|
||||
- 新建 `deep-research/ResearchSandboxPanel.tsx`(约 100 行,含关闭/重开)+ `useReportSandbox.ts` hook(仿 `useStep3Report` 的 stub 机制):
|
||||
- threadStub:`messages = [{type:'ai', tool_calls:[{name:'write_file', args:{path:'/mnt/user-data/outputs/report.md', content: <流式报告>}}]}]`,`isLoading = job 运行中 && phase !== 'summarizing'`
|
||||
- `report_delta` 首帧 → `openArtifact(write-file: 虚拟URL)` 自动展开右栏;`run.report` 每帧 upsert stub(带防抹空守卫);刷新/切换会话用 `session.reportMarkdown` 重建
|
||||
- `ArtifactFileDetail` 自带能力全部免费获得:**流式期 code 视图自动滚底 → 报告写完 isLoading 翻 false 自动切 preview + 脉冲提示**(正是「生成完直接切预览」)、Streamdown 渲染的 Markdown(比现在的好看)、另存 Word/复制/下载、默认走展示层脱敏
|
||||
- 布局:ResizablePanelGroup chat 60 / sandbox 40(对齐 ChatBox/圆桌),沙箱可关闭、头部按钮重开
|
||||
- **删除自定义 `ResearchSandbox` 组件**;legacy 会话与经典模式共用同一右栏(`run.report` 数据源相同,行为一致)
|
||||
- 已知小限制(可接受):报告配图模式下沙箱内图片可能无法经 artifacts API 解析(配图默认关闭;真需要时后续把产物落 thread artifacts)
|
||||
|
||||
### B3. 验证
|
||||
|
||||
- 后端:`PYTHONPATH=. uv run --no-sync pytest tests/ -k deep_research -q --ignore=tests/test_notification_templates.py` 全绿 + ruff;改完配置/SOUL 后 touch 触发 dev 服务重载
|
||||
- 前端:`pnpm typecheck` 零新增错误(基线 51)
|
||||
- 手动走查:连续两次检索不再误报、弱模型报告末尾必带参考来源、页面全流程(欢迎屏 → 对话收集 → 流内配置卡 → 沙箱流式 → 自动预览 → 摘要/追问)
|
||||
|
||||
## 涉及文件
|
||||
|
||||
**后端**:`community/ddg_search/tools.py`、`deep_research/runners/{basic,detailed,deep,multi_agent}.py`、收集员 SOUL.md(种子 + 已安装副本)、`tests/test_deep_research_references.py`(新)
|
||||
**前端**:`components/workspace/messages/message-list.tsx`(可选 slot 分组)、`pages/deep-research/DeepResearchWorkbench.tsx`(大改)、`pages/deep-research/ResearchSandboxPanel.tsx`(新)、`pages/deep-research/useReportSandbox.ts`(新)
|
||||
|
||||
## 明确不动
|
||||
|
||||
- AI 写作 / 普通对话现有组件行为(message-list.tsx 仅加可选分组,缺省零变化)
|
||||
- 报告写作管线的写作逻辑本身(prompt 加一条要求 + 末尾确定性追加参考小节,均为附加式)
|
||||
- 深度研究后端会话/任务/事件模型(本轮不动)
|
||||
@@ -0,0 +1,122 @@
|
||||
# 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` 零改动
|
||||
@@ -11,13 +11,6 @@ This is a customized offline deployment of **DeerFlow** — a LangGraph-based AI
|
||||
|
||||
Managed together via shell scripts in `scripts/`.
|
||||
|
||||
## Offline Release Repository
|
||||
|
||||
Linux AMD64 离线发版脚本、wheelhouse、`deploy.env` 模板和部署文档统一维护在
|
||||
`knowledge-base-bj/deerflow-offline-deployment`,不要把离线镜像或发版资产提交到
|
||||
本业务源码仓库。正式发版从该仓库执行 `scripts/build-latest-master.sh`,脚本会读取
|
||||
本仓库最新 `master` 的干净源码快照。
|
||||
|
||||
## Commands
|
||||
|
||||
### Full Application (from repo root)
|
||||
@@ -80,8 +73,6 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph
|
||||
|
||||
**Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI).
|
||||
|
||||
**Workflow Studio** (`deerflow/workflows/` + `/api/workflows*`) is a second, independent execution path beside the lead agent: a persisted DAG scheduler that runs published workflow graphs, streams a durable event log over SSE, and supports human-input pauses, cancellation, and crash recovery via lease reclaim. Coze Studio supplies the canvas UI only; LangGraph is used only *inside* an `agent`/`skill` node. Master switch and all limits live in `config.yaml` → `workflows`. See the backend `AGENTS.md`/`CLAUDE.md`「Workflow Studio」and `docs/WORKFLOW_STUDIO_BACKEND_DEV_ZH.md`.
|
||||
|
||||
### Frontend
|
||||
|
||||
The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
@@ -90,19 +81,15 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
|
||||
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
|
||||
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
|
||||
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** uses a separate overlay: every compact workspace menu item can be placed in `sidebar`(左侧菜单)、`settings`(设置和更多)or `toolbox`(右侧工具箱); `CompactToolbox` (`components/workspace/compact-toolbox.tsx`, mounted by `PageRoutes.tsx`) opens only when the square right-edge trigger is clicked, then slides a compact, content-height right-side drawer over a backdrop above global dropdowns; configured items render top-to-bottom as centered square cards with a large icon above the label while the effective layout is compact. `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`.
|
||||
- **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答经 Gateway `POST /api/sentiment-agent/stream` 转发外部 AG-UI 网关(不经过 LangGraph),会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`)并随请求传给后台;后台按传入地址转发、关闭 TLS 校验,失败时把完整错误回传。核心模块 `src/core/sentiment-agent/`。
|
||||
- **简洁模式用户引导 (Compact-mode onboarding tour)**: 简洁模式(外观设置的「全量/简洁」= compact,无全局导航栏 + 页面侧边栏)新增 TDesign React `Guide` 组件驱动的分步引导。组件 `components/workspace/compact-mode-guide.tsx`(挂在 `ChatPage`):9 步 —— 欢迎弹窗(dialog 模式)→ 左侧功能导航 → 聊天记录 → 设置和更多 → 换肤 → 界面模式(切回全量)→ 常用提示词栏 → 提示词管理弹窗 → 输入框;高亮目标由各组件上的 `data-compact-guide` 标记定位(`workspace-nav-chat-list` / `recent-chat-list` / `workspace-nav-menu` / `appearance-controls` / `custom-prompt-menu.tsx` 的 `CustomPromptBar`(提示词栏,仅新对话落地态渲染)与其 `ManagePromptsDialog`(`prompt-dialog` 锚点)/ ChatPage 输入框容器),**启动时按目标是否存在过滤步骤**(如新用户无聊天记录、侧栏收起时自动剔除该步 —— Guide 对缺失元素会抛错,不能盲传)。**提示词管理弹窗步骤是动态目标**:弹窗平时不渲染,引导切到该步前先广播 `PROMPT_DIALOG_GUIDE_EVENT`(`custom-prompt-menu.tsx` 导出)让 `CustomPromptBar` 打开 `ManagePromptsDialog` 并**接管**(`owner: true` 期间 ESC/点遮罩不关闭,防止高亮目标消失导致 Guide 报错),轮询锚点真正挂载后才切步;离开该步 / 跳过 / 完成 / 中途卸载时以 `owner: false` 收回弹窗,4 秒打不开则跳过该步防卡死。**TDesign Guide × Radix 模态互操作**:Radix 模态弹窗打开时会把 `body` 置 `pointer-events:none`(滚动锁定),Guide 直接挂在 body 下的遮罩/高亮/弹层会一并继承失活(引导按钮点不动),`styles/tdesign-theme.css` 末尾对 `.t-guide__overlay/__highlight/__popup/__wrapper` 显式 `pointer-events:auto` 兜底。触发:① 自动 —— 简洁模式新对话落地页自动演示,直到用户显式点「完成」或「跳过」才写 localStorage `deerflow.compact-guide-finished` 记忆(浏览器级);**中途刷新/离开不记忆,下次进入还会再演示**。② 手动 —— 「设置和更多」下拉新增「界面使用引导」(该下拉仅存在于简洁模式工作区侧栏 footer):已在问答页时广播 `compact-guide:start` 窗口事件直接启动,否则写 sessionStorage 请求并导航 `/chats/new` 由挂载的引导消费(消费在定时器真正启动时才执行,规避 StrictMode effect 双跑吞请求)。`eligible` 排除 embed=1 / hideChrome=1 / chats_iframe / FORCE_COLLAPSED_SIDEBAR(无界)等同样无全局 chrome 的特殊环境;启动延迟 800ms 等侧栏宽度过渡稳定后再测量高亮框。
|
||||
- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:<stage>` 窗口事件强制重放(忽略已看过记忆)。
|
||||
- **菜单管理 (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).
|
||||
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
|
||||
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
|
||||
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id, or action id when `idKind:"action"` — same selector as url buttons, xdfx only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url **or host** 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`.
|
||||
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
|
||||
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
||||
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
|
||||
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
|
||||
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations staLine truncated
|
||||
- **agentfx 任务深链(新系统 iframe 嵌入的智能体问答,3qfx 单智能体 lite 模式的独立复制)**: goPath=`agentfx`(`${loginBase}?password=…&taskId=…&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue`)。与 3qfx 的差异:①任务详情不走外部 consumer,改调**本系统 Gateway** `GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=`(`apiFetch` 带刚登录的 DeerFlow 态;任务数据来自 TaskCOP 任务表,与任务列表/报告导入同一份);②**固定使用内置智能体** `agentfx-analyst`(不走业务映射);③路由 `task-management/plan-task-agentfx/agent/:agent_id/chats`,页面为独立复制的 `AgentFxWorkspace.tsx`(`AgentFxWorkspace`/`AgentFxIndexResolver`/`AgentFxChat` —— 左「当前任务」卡 + 1:N 对话列表 + 新建对话,`AgentChatPage taskBind` 自动发开场白;无 TaskJumpBar/清除记录/闸门/xdfx 行动卡)。模块 `core/auth/agentfx-deeplink.ts`(`isAgentfxGoPath`/`fetchAgentfxTaskDetail`/`resolveAgentfxDeeplinkTarget`);任务详情整包仍存共享键 `login.copTaskDetail`(按标签页隔离),`AgentChatPage` 的 taskBind 自恢复对 agentfx 生效。`LoginPage` 在既有深链分支前用 `isAgentfxGoPath` 分流。**内置「任务研判报告助手」**:后端 seeder `app/gateway/routers/_agentfx_seed.py` + `_agentfx_seed_assets/`(config.yaml + SOUL.md,目录存在不覆盖;app.py lifespan 在 `_sync_legacy_agents` 之前调用,自动进 agents 表 user_id IS NULL)——SOUL 规定工作流「搜集信息(`web_search`,**首步不可跳过**)→ 每次检索 JSON 原样落盘 `/mnt/user-data/workspace/hits/qN.json` → **一条命令** `task-report-build`(group `all`)技能函数转换成 14 类 `task-reports.json`(模型禁止手写该 JSON;分页技能 `task-report-{enemy,our,env,judge}` 仅用于事后补跑单页;五者都是共享引擎 `task-report-lib/convert_lib.py` 的瘦 CLI,`resolve_hit_files` 对虚拟绝对路径读空的情况自动回退到相对写法并在 `warnings` 里列出尝试过的路径,免得智能体自己写脚本排查)→ `write_file` 生成 `/mnt/user-data/outputs/report.md` → 聊天总结 → 引导入库」,硬性约束:categoryType 逐字用 14 个全称(禁缩写自造如「DQ关键事件」)、JSON 直接写汉字禁 `\uXXXX` 转义、每类必须有实质内容不许空数组/纯占位、文件一律用 `/mnt/user-data/...` 虚拟路径(防宿主路径泄漏进 tool args)。**检索依赖 `config.yaml tools.web_search`(DDG,enabled 已开;内网需换 configurable_search 时改回并填 endpoint)**。任务工作区输入框在 `taskCtxOn` 时隐藏「写作/记忆/参考文献」(InputBox `hideContextTools` prop)与「知识空间」(不传 knowledgeSelector);消息流里 write_file 文件卡片只显示文件名(`artifactDisplayName` 收敛宿主/虚拟路径)。**入库两条路径**:(a) **前端协助卡** —— `AgentChatPage`(taskBind 且非 taskGate)检测线程 artifacts 出现 `task-reports.json`(按文件名后缀匹配,兼容本地沙箱记录的宿主路径)且流式结束时,向 displayThread 尾部注入 `additional_kwargs.task_report_import_approval` 虚拟 ai 消息(`core/messages/utils.ts` 分组 `assistant:task-report-import` + `MessageList` 的 `taskReportImportSlot` 插槽渲染 `TaskReportImportCard`),点「开始入库」前端校验(`strategy-components/lib/agentfx-report-schema.ts`,14 类 + contentJson 合法 JSON + **\u 转义归一化还原为汉字**,与技能脚本同规则)后 `importTaskReport`(`strategy-components/api/task-reports.ts`)POST 导入接口整体替换;任务已有详情**不抑制**卡片(入库即整体替换,卡片文案说明覆盖语义),导入成功按 thread 记 sessionStorage(刷新不重复询问)、「暂不入库」为会话内记忆;(b) **入库技能** `task-report-import`(seeder 一并复制到 `skills/public/`,public 类技能 extensions_config 缺省即启用)—— 用户聊天里说「入库」时智能体按 SKILL.md 用 bash 跑 `scripts/task_report_import.py`(纯标准库:同样的校验规则 + 转义归一化;**默认关闭 SSL 证书校验** `ssl._create_unverified_context` + 忽略代理,适配内网自签证书;鉴权 `--token`/env `AGENTFX_IMPORT_TOKEN`→`DEERFLOW_AUTH_TOKEN`→`TOOLS_TOKEN` 优先,缺省经公共端点 `POST /api/parallel-agents/auth/token` 以 `agentfx-import` 用户现签 token——内网既有鉴权旁路;gateway 地址 env `AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:{DEER_FLOW_GATEWAY_PORT}`);`--strict` 可要求 14 类齐全,默认允许部分导入并在 stdout JSON 的 `missing_categories` 里列缺。入库成功后任务 TaskCOP 状态自动置 25(已完成分析)。**入库成功通知宿主**(iframe 嵌入场景):两条路径成功后都会 `window.top.postMessage({source: "magent-web", type: "Line truncated
|
||||
- **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -73,8 +73,6 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph
|
||||
|
||||
**Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI).
|
||||
|
||||
**Workflow Studio** (`deerflow/workflows/` + `/api/workflows*`) is a second, independent execution path beside the lead agent: a persisted DAG scheduler that runs published workflow graphs, streams a durable event log over SSE, and supports human-input pauses, cancellation, and crash recovery via lease reclaim. Coze Studio supplies the canvas UI only; LangGraph is used only *inside* an `agent`/`skill` node. Master switch and all limits live in `config.yaml` → `workflows`. See the backend `CLAUDE.md`「Workflow Studio」and `docs/WORKFLOW_STUDIO_BACKEND_DEV_ZH.md`.
|
||||
|
||||
### Frontend
|
||||
|
||||
The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
@@ -83,15 +81,14 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
|
||||
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API.
|
||||
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
|
||||
- **策略优选**: route `/page/strategy/qa/strategy-selection`, sidebar 「问答管理 → 策略优选」. The standalone `src/strategy-selection/` workbench has two horizontal split stages: Step 1 keeps intent-understanding conversation cards on the left and an editable task draft plus angle selection on the right; Step 2 keeps multiplexed parallel strategy cards and automatic judging on the left while the right stays dedicated to the selected-strategy basket and final Markdown synthesis. It consumes the existing `POST /api/parallel-agents/run/stream` protocol, isolates output by stable agent id, supports expanded per-agent dialogs, user selection, Markdown editing/copy/download, and shared server-side DOCX export. A clearly marked neutral Taiwan-Strait risk-management demo is loaded by default for offline UI acceptance. Administrators can edit the SOUL and skills of the nine built-in functional agents through the page dialog; the backend seeds `strategy-selection-{intent,angle-*,judge,solution}` only when their directories are missing, preserving later admin changes. Pure parsing/SSE tests live in `src/strategy-selection/lib.test.ts`; backend seed tests live in `tests/test_strategy_selection_seed.py`.
|
||||
- **轻应用中心 (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). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`.
|
||||
- **菜单管理 (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).
|
||||
- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`.
|
||||
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
|
||||
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
|
||||
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, openMode, loginParams, appendAuth, authTokenParam, authNameParam, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route (e.g. `/task/item-aiceh/step-analysis-audience`), `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). Independent-tab clicks toast and do nothing. **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=<JWT>&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」Line truncated
|
||||
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, openMode, loginParams, appendAuth, authTokenParam, authNameParam, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=<JWT>&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」页,在 **both** 单智能体 (`TaskFxWorkspace`) 与 多智能体会商 (`RoundtablePlanningPage` 深链 rwfx-A) 都显示(共用 `TaskJumpBar`)。**`runTaskButton` 的 `url` 类型支持应用内路由**:target 以单个 `/` 开头时按内部路由处理——`self` 走 react-router 同窗导航(不刷新),`blank` 拼成当前源的 hash 路由新标签打开;外链行为不变。 The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三Line truncated
|
||||
- **实时并发监控 (Concurrency / System-Pressure Monitor, admin-only)**: a live panel mounted at the **top of the admin 排行榜 page** (`components/workspace/admin/concurrency-monitor.tsx` `ConcurrencyMonitorPanel`, rendered in `pages/AdminUserLeaderboardPage.tsx`) showing **当前并发的大模型调用数 + 列表(用户/智能体/类型/已运行时长)**, each row with a 「停止」button (and a 「全部停止」) that **truly aborts** that run's in-flight model stream via `RunManager.cancel` (`POST /api/admin/active-runs/{run_id}/cancel`) — to relieve pressure when 使用人数过多. Two line charts (minute/hour toggle): **并发量折线图** (peak/avg, `GET /api/admin/active-runs/concurrency`, fed by a lightweight background sampler into `concurrency_samples`) and **大模型出字速度折线图** (tokens/sec, `GET /api/admin/active-runs/token-speed`, aggregated from `llm_call_metrics`) so a dip pinpoints where the bottleneck is. A **「后台采样」Switch** (`system_settings.json → concurrency_monitor.enabled`, **default off / opt-in**, `GET|PUT /api/admin/active-runs/settings`) turns the background sampling on/off **at runtime, no restart** (the live concurrency count + 「停止」 work even while sampling is off — only the 并发量 history chart needs it on); everything is designed to **never impact production** (one tiny INSERT/15s, retention-purged, best-effort). Client/hooks: `core/admin/active-runs.ts` + `core/admin/hooks.ts` (`useActiveRuns`/`useConcurrencySeries`/`useTokenSpeedSeries`/`useCancel*`/`useMonitorSettings`). See the backend CLAUDE.md「Admin Concurrency Monitor」.
|
||||
- **会话冲突 409 中文化**: when a user submits a new question while the previous turn is still streaming, the backend's `RunManager.create_or_reject` rejects with HTTP 409 (`services.py` now returns a **Chinese** detail「当前对话还在生成回答…」). `core/threads/hooks.ts` `onError` detects the conflict (`isActiveRunConflictError`: 409 / "already has an active run" / the Chinese text). A premature SSE close (SDK treats it as success and restores the send button while the backend run is still occupying the thread) is recovered by `confirmOrRejoin`: hold the stop button, `joinStream` the live run, and on 409 auto-reconnect instead of a fake AI bubble. `sse_consumer` no longer ends a resumable (`on_disconnect=continue`) stream on a false-positive `is_disconnected()`.
|
||||
- **会话冲突 409 中文化**: when a user submits a new question while the previous turn is still streaming, the backend's `RunManager.create_or_reject` rejects with HTTP 409 (`services.py` now returns a **Chinese** detail「当前对话还在生成回答…」). `core/threads/hooks.ts` `onError` detects the conflict (`isActiveRunConflictError`: 409 / "already has an active run" / the Chinese text) and shows a friendly `toast.info` instead of a raw error.
|
||||
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
|
||||
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
||||
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
|
||||
|
||||
@@ -5,9 +5,6 @@ const LEGACY_LOCAL_API_BASE_URL = "http://localhost:8001";
|
||||
const LEGACY_LOCAL_WEB_BASE_URL = "http://localhost:5174";
|
||||
let pollTimer = null;
|
||||
let isPolling = false;
|
||||
let startupPromise = null;
|
||||
|
||||
const POLLING_SCHEDULE_SETTING_KEYS = new Set(["taskPollingEnabled", "taskPollIntervalSeconds"]);
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -15,14 +12,6 @@ function delay(ms) {
|
||||
|
||||
async function getSettings() {
|
||||
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
|
||||
const discovered = await chrome.storage.local.get({
|
||||
discoveredApiBaseUrl: "",
|
||||
discoveredWebBaseUrl: "",
|
||||
});
|
||||
if (settings.autoDiscoverServiceUrl) {
|
||||
settings.apiBaseUrl = discovered.discoveredApiBaseUrl || settings.apiBaseUrl;
|
||||
settings.webBaseUrl = discovered.discoveredWebBaseUrl || settings.webBaseUrl;
|
||||
}
|
||||
const updates = {};
|
||||
if (!settings.apiBaseUrl || settings.apiBaseUrl === LEGACY_LOCAL_API_BASE_URL) {
|
||||
updates.apiBaseUrl = DEER_CLIPPER_DEFAULTS.apiBaseUrl;
|
||||
@@ -80,7 +69,10 @@ function apiCandidatesForWebBase(webBase, currentApiBase) {
|
||||
}
|
||||
};
|
||||
|
||||
add(currentApiBase);
|
||||
add(webBase);
|
||||
add(DEER_CLIPPER_DEFAULTS.apiBaseUrl);
|
||||
add(LEGACY_LOCAL_API_BASE_URL);
|
||||
|
||||
try {
|
||||
const url = new URL(normalizeServiceBase(webBase));
|
||||
@@ -93,10 +85,6 @@ function apiCandidatesForWebBase(webBase, currentApiBase) {
|
||||
// Ignore invalid candidates.
|
||||
}
|
||||
|
||||
add(currentApiBase);
|
||||
add(DEER_CLIPPER_DEFAULTS.apiBaseUrl);
|
||||
add(LEGACY_LOCAL_API_BASE_URL);
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
@@ -105,12 +93,6 @@ async function browserContextStatus(apiBaseUrl) {
|
||||
const response = await fetch(`${apiBaseUrl}/api/browser-context/status`, {
|
||||
credentials: "include",
|
||||
});
|
||||
// The gateway may enforce authentication globally before this otherwise
|
||||
// public discovery endpoint is reached. A 401/403 still proves that this
|
||||
// candidate is the zncm API paired with the open frontend tab.
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { enabled: true, authenticationRequired: true };
|
||||
}
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
@@ -219,12 +201,9 @@ async function discoverServiceBases(settings, force = false) {
|
||||
settings.webBaseUrl !== next.webBaseUrl ||
|
||||
settings.lastServiceSyncSource !== next.lastServiceSyncSource
|
||||
) {
|
||||
// Discovered endpoints are runtime state, not user-authored preferences.
|
||||
// Keep them in storage.local so Chrome sync write quotas cannot break task
|
||||
// polling. The options page may still persist explicit user choices.
|
||||
await chrome.storage.local.set({
|
||||
discoveredApiBaseUrl: next.apiBaseUrl,
|
||||
discoveredWebBaseUrl: next.webBaseUrl,
|
||||
await chrome.storage.sync.set({
|
||||
apiBaseUrl: next.apiBaseUrl,
|
||||
webBaseUrl: next.webBaseUrl,
|
||||
lastServiceSyncAt: next.lastServiceSyncAt,
|
||||
lastServiceSyncSource: next.lastServiceSyncSource,
|
||||
});
|
||||
@@ -847,50 +826,19 @@ async function pollOnce() {
|
||||
}
|
||||
}
|
||||
|
||||
async function initializePolling() {
|
||||
async function startup() {
|
||||
let settings = await getSettings();
|
||||
try {
|
||||
settings = await discoverServiceBases(settings);
|
||||
} catch (error) {
|
||||
// Service discovery persists diagnostic metadata in storage.sync. Chrome can
|
||||
// temporarily reject those writes after its quota is reached; polling must
|
||||
// still start with the last known settings.
|
||||
console.warn("zncm service discovery failed during startup", error);
|
||||
}
|
||||
try {
|
||||
settings = await syncAuthToken(settings);
|
||||
} catch (error) {
|
||||
// A failed status/token write must not prevent alarms from being installed.
|
||||
// pollOnce() will retry token synchronization before claiming each task.
|
||||
console.warn("zncm token sync failed during startup", error);
|
||||
}
|
||||
settings = await discoverServiceBases(settings);
|
||||
settings = await syncAuthToken(settings);
|
||||
await refreshAlarm(settings);
|
||||
await scheduleNextPoll(1000);
|
||||
}
|
||||
|
||||
function startup() {
|
||||
if (!startupPromise) {
|
||||
startupPromise = initializePolling().finally(() => {
|
||||
startupPromise = null;
|
||||
});
|
||||
}
|
||||
return startupPromise;
|
||||
}
|
||||
|
||||
async function restartPollingSchedule() {
|
||||
const settings = await getSettings();
|
||||
await refreshAlarm(settings);
|
||||
await scheduleNextPoll(0);
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => void startup().catch((error) => console.warn("zncm startup failed", error)));
|
||||
chrome.runtime.onStartup.addListener(() => void startup().catch((error) => console.warn("zncm startup failed", error)));
|
||||
chrome.runtime.onInstalled.addListener(() => void startup());
|
||||
chrome.runtime.onStartup.addListener(() => void startup());
|
||||
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||
if (
|
||||
areaName === "sync" &&
|
||||
Object.keys(changes || {}).some((key) => POLLING_SCHEDULE_SETTING_KEYS.has(key))
|
||||
) {
|
||||
void restartPollingSchedule().catch((error) => console.warn("zncm polling reschedule failed", error));
|
||||
if (areaName === "sync") {
|
||||
void startup();
|
||||
}
|
||||
});
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
@@ -954,4 +902,4 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
return true;
|
||||
});
|
||||
|
||||
void startup().catch((error) => console.warn("zncm startup failed", error));
|
||||
void startup();
|
||||
@@ -1,130 +0,0 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const BACKGROUND_PATH = path.join(__dirname, "background.js");
|
||||
|
||||
function createHarness() {
|
||||
let storageChangedListener;
|
||||
let alarmCreateCount = 0;
|
||||
const settings = {
|
||||
apiBaseUrl: "http://localhost:8001",
|
||||
webBaseUrl: "http://localhost:5174",
|
||||
autoDiscoverServiceUrl: false,
|
||||
accessToken: "",
|
||||
tokenSyncEnabled: false,
|
||||
taskPollingEnabled: true,
|
||||
workerId: "test-worker",
|
||||
taskPollIntervalSeconds: 5,
|
||||
};
|
||||
|
||||
const chrome = {
|
||||
alarms: {
|
||||
clear: async () => {},
|
||||
create: async () => {
|
||||
alarmCreateCount += 1;
|
||||
},
|
||||
onAlarm: { addListener: () => {} },
|
||||
},
|
||||
cookies: { get: async () => null },
|
||||
runtime: {
|
||||
onInstalled: { addListener: () => {} },
|
||||
onMessage: { addListener: () => {} },
|
||||
onStartup: { addListener: () => {} },
|
||||
},
|
||||
storage: {
|
||||
local: {
|
||||
get: async (defaults) => ({ ...defaults }),
|
||||
set: async () => {},
|
||||
},
|
||||
session: { get: async () => ({ actSessions: {} }), set: async () => {} },
|
||||
sync: {
|
||||
get: async () => ({ ...settings }),
|
||||
set: async (updates) => Object.assign(settings, updates),
|
||||
},
|
||||
onChanged: {
|
||||
addListener: (listener) => {
|
||||
storageChangedListener = listener;
|
||||
},
|
||||
},
|
||||
},
|
||||
tabs: { query: async () => [] },
|
||||
};
|
||||
|
||||
const context = {
|
||||
chrome,
|
||||
console,
|
||||
fetch: async () => {
|
||||
throw new Error("offline test");
|
||||
},
|
||||
importScripts: () => {},
|
||||
setTimeout: () => 1,
|
||||
clearTimeout: () => {},
|
||||
URL,
|
||||
DEER_CLIPPER_DEFAULTS: settings,
|
||||
deerNormalizeBaseUrl: (value) => String(value || "").replace(/\/+$/, ""),
|
||||
deerMakeWorkerId: () => "test-worker",
|
||||
deerAuthHeaders: () => ({}),
|
||||
deerExtractAuthTokenFromPage: () => ({ token: "" }),
|
||||
};
|
||||
context.globalThis = context;
|
||||
vm.runInNewContext(fs.readFileSync(BACKGROUND_PATH, "utf8"), context, {
|
||||
filename: BACKGROUND_PATH,
|
||||
});
|
||||
|
||||
return {
|
||||
emitStorageChange(changes) {
|
||||
storageChangedListener(changes, "sync");
|
||||
},
|
||||
getAlarmCreateCount: () => alarmCreateCount,
|
||||
resetAlarmCreateCount: () => {
|
||||
alarmCreateCount = 0;
|
||||
},
|
||||
getApiCandidates: (webBase, currentApiBase) =>
|
||||
vm.runInContext(`apiCandidatesForWebBase(${JSON.stringify(webBase)}, ${JSON.stringify(currentApiBase)})`, context),
|
||||
};
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
test("self-written sync metadata does not restart browser task polling", async () => {
|
||||
const harness = createHarness();
|
||||
await settle();
|
||||
harness.resetAlarmCreateCount();
|
||||
|
||||
harness.emitStorageChange({
|
||||
lastTokenSyncAt: { oldValue: "before", newValue: "after" },
|
||||
});
|
||||
await settle();
|
||||
|
||||
assert.equal(harness.getAlarmCreateCount(), 0);
|
||||
});
|
||||
|
||||
test("polling configuration changes restart browser task polling", async () => {
|
||||
const harness = createHarness();
|
||||
await settle();
|
||||
harness.resetAlarmCreateCount();
|
||||
|
||||
harness.emitStorageChange({
|
||||
taskPollingEnabled: { oldValue: false, newValue: true },
|
||||
});
|
||||
await settle();
|
||||
|
||||
assert.equal(harness.getAlarmCreateCount(), 1);
|
||||
});
|
||||
|
||||
test("service discovery prefers the API paired with the open zncm tab", async () => {
|
||||
const harness = createHarness();
|
||||
|
||||
assert.deepEqual(
|
||||
Array.from(harness.getApiCandidates("http://localhost:5174", "http://47.94.209.59:2026")),
|
||||
[
|
||||
"http://localhost:5174",
|
||||
"http://localhost:8001",
|
||||
"http://47.94.209.59:2026",
|
||||
"http://localhost:8001",
|
||||
].filter((value, index, values) => values.indexOf(value) === index),
|
||||
);
|
||||
});
|
||||
@@ -7,8 +7,6 @@
|
||||
const HOST_ID = "zncm-page-assistant-host";
|
||||
const state = {
|
||||
enabled: true,
|
||||
taskPollingEnabled: true,
|
||||
webBaseUrl: "",
|
||||
open: false,
|
||||
busy: false,
|
||||
threadId: "",
|
||||
@@ -711,58 +709,20 @@
|
||||
async function loadSettings() {
|
||||
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
|
||||
state.enabled = Boolean(settings.pageAssistantEnabled);
|
||||
state.taskPollingEnabled = Boolean(settings.taskPollingEnabled);
|
||||
state.webBaseUrl = deerNormalizeBaseUrl(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl);
|
||||
ensureWidget();
|
||||
}
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||
if (areaName !== "sync") {
|
||||
if (areaName !== "sync" || !changes.pageAssistantEnabled) {
|
||||
return;
|
||||
}
|
||||
if (changes.pageAssistantEnabled) {
|
||||
state.enabled = Boolean(changes.pageAssistantEnabled.newValue);
|
||||
ensureWidget();
|
||||
if (state.enabled) {
|
||||
setStatus("页面问答框已开启。");
|
||||
}
|
||||
}
|
||||
if (changes.taskPollingEnabled) {
|
||||
state.taskPollingEnabled = Boolean(changes.taskPollingEnabled.newValue);
|
||||
}
|
||||
if (changes.webBaseUrl) {
|
||||
state.webBaseUrl = deerNormalizeBaseUrl(changes.webBaseUrl.newValue || DEER_CLIPPER_DEFAULTS.webBaseUrl);
|
||||
state.enabled = Boolean(changes.pageAssistantEnabled.newValue);
|
||||
ensureWidget();
|
||||
if (state.enabled) {
|
||||
setStatus("页面问答框已开启。");
|
||||
}
|
||||
});
|
||||
|
||||
function isZncmPage() {
|
||||
const appPath = `${location.pathname}${location.hash}`.toLowerCase();
|
||||
if (
|
||||
appPath.includes("/page/workspace") ||
|
||||
appPath.includes("/chats/") ||
|
||||
appPath.includes("#/login")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const configured = new URL(state.webBaseUrl);
|
||||
return configured.origin === location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// MV3 service workers may be suspended between their short polling timers,
|
||||
// while chrome.alarms cannot reliably run more often than every 30 seconds.
|
||||
// A visible zncm tab provides a cheap wake-up signal so browser_act requests
|
||||
// are normally claimed within the backend/model timeout window.
|
||||
setInterval(() => {
|
||||
if (!state.taskPollingEnabled || !isZncmPage()) {
|
||||
return;
|
||||
}
|
||||
chrome.runtime.sendMessage({ type: "zncm-poll-now" }).catch(() => {});
|
||||
}, 3000);
|
||||
|
||||
setInterval(() => {
|
||||
if (!state.enabled || !ui.pageTitle) {
|
||||
return;
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
# Wiki 数据流转与本地编码验收(2026-09-03)
|
||||
|
||||
## 结论
|
||||
|
||||
本地 BGE-M3 小批量编码已安装、配置并实际运行。已有 Wiki 的向量化、自动沉淀、
|
||||
普通库文件导出、助手库异步导入、助手库文件导出、空普通库回流及两端向量检索已实测通过。
|
||||
**技能自动生成 Wiki 尚未完成验收**:WeKnora 的两个生成模型都返回 HTTP 402
|
||||
`Insufficient Balance`。不能把后半段通过等同于全链路通过。
|
||||
|
||||
本次修改运行在本地 DeerFlow 开发服务(前端 5175、后端 8001),连接用户提供的远端
|
||||
WeKnora;没有把业务代码发布到服务器,没有提交 Git,也没有直接写 WeKnora 图数据库。
|
||||
|
||||
## 本地编码配置
|
||||
|
||||
- 实现:FastEmbed / ONNX Runtime CPU,官方 `BAAI/bge-m3` 权重。
|
||||
- 配置名:`bge-embedding-m3`,1024 维,每批 2 条,2 个 CPU 推理线程。
|
||||
- 运行依赖:FastEmbed 随标准后端依赖安装;模型必须提前放在离线目录中,仍按需加载,不影响主服务启动。
|
||||
- 权重:约 2.3 GB,持久缓存 `C:\Users\ji\.cache\deerflow\embeddings`,不进入业务仓库。
|
||||
- 配置:`llmwiki.local_wiki_index.embedding.provider: local`;可用
|
||||
`local_model_path` 指定离线权重目录,详见后端 README。
|
||||
- 查询向量在本地实时编码。已有 Wiki 在内容哈希、模型指纹匹配且向量非空时直接复用。
|
||||
不同模型或维度不能混用;导入不静默重新编码。
|
||||
- 原始文档分段向量与模型生成后的 Wiki 文本不同,不能相互冒用。本次新建的是 Wiki
|
||||
正文的 BGE-M3 索引,不是把既有 512 维文档向量改名为 1024 维。
|
||||
|
||||
## 实际数据验收
|
||||
|
||||
来源是已有普通知识库“图片测试111”,没有改写其 Wiki 内容。
|
||||
|
||||
| 阶段 | Wiki | Wiki 向量 | 实体 | 引用关系 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| 普通知识库导出 | 19 | 101 | 19 | 108 |
|
||||
| 助手知识库导入后导出 | 19 | 101 | 19 | 108 |
|
||||
| 新建普通知识库回流后导出 | 19 | 101 | 19 | 108 |
|
||||
| 知识梳理总库导出(7 个来源记录) | 19 | 101 | 19 | 108 |
|
||||
|
||||
逐项断言通过:
|
||||
|
||||
1. 三份包的 Wiki slug、标题、正文、摘要、页面类型、别名、分类路径、出链一致。
|
||||
2. 101 条向量的原始 float32 字节、分段编号、维度和模型指纹完全一致。
|
||||
3. 将导入前后实体 ID 映射回原 Wiki slug 后,108 条关系的端点及谓词集合完全一致。
|
||||
4. 总库导出没有因多个来源重复同步而重复导出同一当前页面的分段向量。
|
||||
5. 元数据回归测试另覆盖 tags、目录恢复、原始文档片段不计入 Wiki 数量、原生技能无
|
||||
ownership 记录时可被选中、短 Wiki 不漏向量、模型指纹不兼容时不假装向量检索。
|
||||
|
||||
普通包约 1.01 MB,助手包约 0.79 MB,回流包约 0.83 MB。普通包还带 2 个原始文档及
|
||||
108 个原始片段;助手导出提供 Wiki、Wiki 向量和关系,不重新上传原始文档触发二次生成。
|
||||
|
||||
### 检索
|
||||
|
||||
实际查询:“军方用什么工具评估极端天气带来的风险?”
|
||||
|
||||
- 回流普通库 `/api/llmwiki/search` 返回 `mode=vector`,首条“国防气候评估工具”。
|
||||
- 助手库 `/api/assistant-knowledge/search` 返回 `retrieval_mode=vector`,首条同上;
|
||||
首个命中分段余弦相似度约 0.6343。
|
||||
- 浏览器实际提交总库检索,显示“向量检索 · 余弦相似度”和相同命中;不是只检查返回字段。
|
||||
- 普通库采用已有页面级聚合评分,因此页面最终分数与单分段余弦值不必相等。
|
||||
- 回流的向量写入 **DeerFlow 管理的本地 Wiki 索引**。未写入 WeKnora 原生向量数据库,
|
||||
本次结论适用于本系统问答检索,不能声称原生 WeKnora 自身的检索也已完成该向量导入。
|
||||
|
||||
### 页面
|
||||
|
||||
普通与助手库复用同一 Wiki 阅读组件:目录树、类型/标签筛选、分类面包屑、别名标签、
|
||||
正文内链、出链、反向链接及邻接关系视图。窄窗口可收起目录。
|
||||
|
||||
浏览器验证了“索引 → 国防气候评估工具”、展开关系视图、助手 Wiki 跳回普通库来源,
|
||||
以及普通 Wiki 的“在知识梳理中查看”反向跳转。
|
||||
|
||||
### 验收数据位置
|
||||
|
||||
- 原有普通库:`48f669dc-433e-4aab-b5ea-897b6f7a3d2f`
|
||||
- 知识梳理总库:`c3fd9841-0718-44dc-860e-aae3020fd485`
|
||||
- 最终助手测试库:`489be905-08c8-40fe-8d60-42ac21dd945a`
|
||||
- 最终普通回流测试库:`428162aa-03f6-4c61-8994-360f2f39e0c9`
|
||||
|
||||
新建测试库名称均以“Wiki链路验收-0903-1826”开头,保留用于复核;未删除用户已有库。
|
||||
本地 `.runtime/wiki-live-result.json` 保存验收结果,`.runtime/wiki_live_acceptance.py`
|
||||
保存本次验收流程,均为忽略的运行产物,不包含认证口令。
|
||||
|
||||
## 这次实际修复的问题
|
||||
|
||||
- 内置技能只有磁盘目录、没有归属记录时,被错误判定为不存在。
|
||||
- 技能应上传去代码后的知识正文,让 WeKnora 生成 Wiki,而不是伪造一个 `skill` 类型页面。
|
||||
- Wiki 生成等待使用实际 stats 接口;零页结果明确失败,不无期限等待。
|
||||
- 向量化完成后自动导出沉淀到总库,并阻断导出回调重复触发全量同步。
|
||||
- 短摘要和短正文同时存在时,旧分段阈值会将整页过滤为零向量。
|
||||
- 分类路径必须先创建 WeKnora folder 并绑定 folder_id;只传 category_path 会被服务端清空。
|
||||
- Wiki 的标签/分类/别名/原始引用保留;图节点绑定真实 Wiki,避免生成重复的空实体页面。
|
||||
- 保留归一化 float32 原始字节;导入中不能用 `section_index or index`,因为合法编号 0 会被改写。
|
||||
- 总库多个来源保留审计,但检索与导出只使用当前 Wiki 版本对应的向量,避免旧贡献污染检索。
|
||||
- 包解析改为同盘临时 SQLite,上传先落盘再异步导入,导出完成后才提供文件下载。
|
||||
|
||||
## 阻塞与未通过的范围
|
||||
|
||||
1. 技能 `knowledge-base-ingest` 已送入专门创建的普通测试库,但远端
|
||||
`deepseek-v4-flash` 和 `qwen3.6-flash` 均因余额不足失败。WeKnora 即使失败也可能把
|
||||
文档 parse_status 标为 completed。需恢复可用生成模型后重新运行技能全链路验收。
|
||||
2. 已采用磁盘暂存和有界批处理,但本次只有约 1 MB 实测,**未做数 GB 压测、断点续传、
|
||||
进程中断恢复验收**。大包部署仍需磁盘容量、代理 body size 和超时配置。
|
||||
3. 当前实体去重主要按类型及规范化名称;这次没有完成跨别名、歧义实体的模型对齐及
|
||||
多来源事实冲突合并验收,不能视为这一历史需求已全面完成。
|
||||
4. 重复上传仍会保留独立来源记录。向量导出已消除旧版本重复,但上传身份幂等、部分远端
|
||||
写入失败后的无损重试、跨实例图片资源打包仍需独立验证。
|
||||
5. 当前版本已补充自动增量链路:服务启动立即补扫,之后默认每 30 秒扫描全部普通
|
||||
知识库;Wiki 抽取完成后无需手工点击向量化,并自动以来源快照覆盖同步到知识梳理总库。
|
||||
6. 回流当前只允许新建空 Wiki 普通库,避免覆盖已有页面。普通库现有页面列表的超大规模
|
||||
分页与助手问答的最终生成回答不属于这轮通过结论。
|
||||
|
||||
## 自动化检查
|
||||
|
||||
- 后端相关测试:118 passed(assistant repository、Wiki sync、package roundtrip、
|
||||
WeKnora 接口、向量检索与知识库管理)。
|
||||
- 修改的 Python 功能文件 Ruff 检查通过;app.py 既有启动顺序导致的 E402 不在此次改动中处理。
|
||||
- `git diff --check` 通过(只有仓库换行提示)。
|
||||
- 前端完整 `pnpm typecheck` 未通过:57 个既有/本轮范围外错误,涉及 nextra 缺失、Markdown
|
||||
插件类型及其他模块;本轮新建/修改的 Wiki 组件、助手 API、技能面板没有出现在错误清单中。
|
||||
浏览器实际页面与跳转已验证,但不能声称全项目构建通过。
|
||||
@@ -1,719 +0,0 @@
|
||||
# AgentScope 2.0 多智能体报告协作工作台——前端实施计划
|
||||
|
||||
> 文档状态:前端代码已落地、契约冻结;联调等待后端运行中干预与报告改写
|
||||
> 编写日期:2026-09-02;进度更新日期:2026-09-06
|
||||
> 目标项目:`F:\react01\deeflow-code\deerflow-server\frontend-web`
|
||||
> 视觉与交互参考:`F:\react01\coze-frontend\frontend\apps\coze-studio`
|
||||
> 参考页面:`http://localhost:3000/work_flow?space_id=1&workflow_id=wf_88cb9f4c6642`
|
||||
> 配套后端计划:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md`
|
||||
> 本文只规划新增页面,不修改现有多智能体会商页面,不复用旧 Workflow Studio 执行状态。
|
||||
|
||||
## 0. 当前进度
|
||||
|
||||
`frontend-web/src/report-collaboration/` 已实现两栏工作台、会话/方案/run API 客户端、mock driver、SSE reducer、`MessageList` 适配、方案卡、画布、节点抽屉、Composer、报告预览与历史。`RC-FE-001`~`013` 的页面骨架与契约层视为完成。
|
||||
|
||||
后端已通规划前路径(澄清 → 出方案 → 选定 → `queued` run),见后端计划 §0。前端真实执行体验仍受阻:
|
||||
|
||||
- `GET /runs/{id}/stream` 已挂载(`RC-BE-011`);dispatcher/租约已落地(`RC-BE-012`),默认 `worker_enabled=false` 仍不自动领取 queued run。
|
||||
- 报告改写 / 恢复接口已由后端 `RC-BE-015` 挂载。
|
||||
- 质量角色已由 `QualityRoleKernel` 跑通(`RC-BE-013`);真实 AgentScope 模型/`web_search` 仍待后续票。
|
||||
|
||||
联调顺序:先打开 `report_collaboration.enabled` 验证规划前 REST 与 `GET /runs/{id}/stream` 重放;画布自动执行需同时打开 `worker_enabled`(默认关)。
|
||||
|
||||
## 1. 前端目标
|
||||
|
||||
新增独立的「报告协作」工作台。用户可以通过对话提出任意报告需求,查看系统生成的多个协作方案,点击方案卡片在流程图中预览协作结构,确认后观察多智能体执行,并在全过程通过统一对话框提出修改意见。
|
||||
|
||||
前端必须满足:
|
||||
|
||||
1. 页面采用“左侧对话、右侧流程画布”的固定两栏布局,画布、工具栏、弹窗和抽屉参考 Coze Studio 页面风格。
|
||||
2. 消息列表必须复用 DeerFlow 现有 `MessageList`,不复制 Coze 的会话消息实现。
|
||||
3. 所有执行过程、AgentScope 团队消息、智能体回复、工具调用及其原始返回、错误、协助卡、返工和最终报告入口都通过现有消息协议按时间顺序显示在左侧对话区,不设置独立执行结果面板,也不另做“阶段摘要”替代实时消息。
|
||||
4. 候选方案、流程节点、对话进度和报告使用同一份后端事实,不各自推断状态。
|
||||
5. 用户可在规划中、运行中、等待输入和报告完成后继续对话。
|
||||
6. 用户协助卡的出现和提交都不能在前端被解释为“节点已经完成”。
|
||||
7. 页面刷新、SSE 断线重连、历史会话切换后能恢复到一致状态。
|
||||
|
||||
暂定:
|
||||
|
||||
- 页面名称:`报告协作`。
|
||||
- 路由:`/page/workspace/report-collaboration`。
|
||||
- 会话路由:`/page/workspace/report-collaboration/:sessionId`。
|
||||
- 默认候选方案数量:3。
|
||||
- 点击画布节点时打开覆盖式节点说明抽屉;抽屉只展示节点职责和合同,不展示节点执行结果。
|
||||
|
||||
---
|
||||
|
||||
## 2. 范围与非目标
|
||||
|
||||
### 2.1 本期范围
|
||||
|
||||
- 新建、查看、重命名、删除协作报告会话。
|
||||
- 使用对话澄清报告需求。
|
||||
- 显示 2~3 个候选方案卡片。
|
||||
- 卡片与右侧流程图预览联动。
|
||||
- 节点说明抽屉:职责、所依据的信息类型、预期产出、工具、资料要求和验收条件。
|
||||
- 实时展示节点排队、运行、校验、等待用户、完成、返工、失败和取消。
|
||||
- 在左侧对话时间线中直接展示每个智能体的流式文本、团队对话、工具调用和工具返回,并展示审稿意见、报告文件和报告版本。
|
||||
- 并行阶段用横向的智能体选择器切换当前查看对象;选择器只控制同一份消息流的聚焦范围,不创建另一套执行结果视图。
|
||||
- 运行期间发送补充要求、重新检索、重新分析、节点重跑等指令。
|
||||
- 完成后进行报告问答、章节重写、全文润色和版本恢复。
|
||||
- 报告模板选择、Markdown 预览、Markdown/Word 下载。
|
||||
- 响应式布局、键盘操作、加载反馈和错误恢复。
|
||||
|
||||
### 2.2 明确不做
|
||||
|
||||
- 不引入 `@coze-workflow/playground` 或 `@coze-arch/coze-design`。
|
||||
- 不加载 Coze workflow definition、run 或 node run。
|
||||
- 不允许前端拖拽连线后直接改变执行逻辑。
|
||||
- 不新增第二套消息气泡、Markdown、引用和文件卡渲染器。
|
||||
- 不展示模型原始 chain-of-thought、系统/内部 prompt、密钥和被标记为内部控制信息的字段。
|
||||
- 不把用户有权查看的 AgentScope TeamSay、智能体文本、工具调用或工具返回压缩成“正在检索”等摘要卡;这些原始业务消息必须进入复用的 `MessageList`。敏感字段只能按统一脱敏策略替换,不能以摘要替代。
|
||||
- 不增加第三栏、常驻详情栏、节点执行结果栏或独立执行时间线。
|
||||
- 节点抽屉不展示节点生成正文、工具返回、证据列表、错误堆栈或 attempt 历史;这些过程统一在左侧 `MessageList` 的原生消息/工具步骤中查看。
|
||||
- 不改造现有圆桌、多智能体会商、深度研究和 AI 写作页面。
|
||||
- 不把浏览器状态作为业务事实源。
|
||||
|
||||
---
|
||||
|
||||
## 3. 参考页面行为映射
|
||||
|
||||
| 目标行为 | Coze 参考文件 | DeerFlow 目标组件 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 两栏骨架 | `workflow-studio-layout.tsx` 的面板比例与分隔条 | `ReportCollaborationLayout.tsx` | 左对话、右画布;只保留一条可调整分隔线 |
|
||||
| 顶栏 | `workflow-studio-header.tsx` | `ReportCollaborationHeader.tsx` | 替换为会话、模板、报告、停止等操作 |
|
||||
| 候选卡片 | `workflow-proposal-cards.tsx` | `ReportPlanCards.tsx` | 点击预览,明确确认后才执行 |
|
||||
| 右侧画布 | `workflow-canvas.tsx` | `ReportFlowCanvas.tsx` | 使用 `@xyflow/react` 重建,不引入 Coze Playground |
|
||||
| 左侧消息区 | `run-conversation-panel.tsx` | `ReportConversationPanel.tsx` | 外层风格参考 Coze,内部复用 DeerFlow `MessageList` |
|
||||
| 节点说明抽屉 | Coze 节点配置面板的视觉与抽屉行为 | `NodeInspectorDrawer.tsx` | 点击画布节点打开,只展示职责与输入输出合同 |
|
||||
| 全程执行反馈 | 参考其进度和状态表达 | `AgentStageSelector` + DeerFlow `MessageList` | 选择智能体后直接显示其流式消息、TeamSay、工具调用与返回;不再合成为阶段摘要 |
|
||||
| 状态栏 | `connection-status.tsx` | `ConnectionStatusBar.tsx` | 展示连接、重连和事件游标 |
|
||||
| 对话历史 | `left-panel-tabs.tsx` | `ConversationSidebarTabs.tsx` | 对话与历史,不增加“节点资源”Tab |
|
||||
| 异步反馈 | 规划进度、运行状态 | MessageList 步骤条 + 画布状态 | 操作超过 300ms 必须有反馈 |
|
||||
|
||||
主要参考源:
|
||||
|
||||
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\workflow-studio-layout.tsx`
|
||||
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\workflow-studio-composition.tsx`
|
||||
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\workflow-studio-header.tsx`
|
||||
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\run-conversation\workflow-proposal-cards.tsx`
|
||||
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\styles.module.less`
|
||||
|
||||
若直接复制 Apache-2.0 源码片段或样式,必须保留版权头并维护来源说明;默认做行为映射和视觉重建。
|
||||
|
||||
---
|
||||
|
||||
## 4. 页面结构
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ 返回 报告协作 / 会话标题 保存状态 模板 报告 停止/继续 更多 │
|
||||
├────────────────────────────────┬─────────────────────────────────────────┤
|
||||
│ 对话 / 历史 │ 协作流程图 │
|
||||
│ │ │
|
||||
│ DeerFlow MessageList │ @xyflow/react │
|
||||
│ · 并行时:[协调者][市场][政策] │ · 节点状态与活动连线 │
|
||||
│ · 用户与协调者/成员消息 │ · 缩放、适配视图、小地图 │
|
||||
│ · 原生 ToolCall / ToolMessage │ · 点击节点打开覆盖式说明抽屉 │
|
||||
│ · 协助卡、报告文件和版本 │ │
|
||||
│ │ │
|
||||
│ 统一输入框 │ │
|
||||
├────────────────────────────────┴─────────────────────────────────────────┤
|
||||
│ 已连接 · run_xxx · 事件 128 │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
节点抽屉覆盖在右侧画布之上,不占用第三栏:
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 节点名称 关闭 × │
|
||||
│ 角色 / 当前状态 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 节点职责 │
|
||||
│ 要根据什么:所需输入、上游类型、资料要求 │
|
||||
│ 要产出什么:成果类型、格式、验收条件 │
|
||||
│ 可用工具与边界 │
|
||||
│ 上下游关系 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 在对话中对此节点提出意见 │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
抽屉明确不展示实际执行结果、生成正文、工具返回、来源明细、错误堆栈或 attempt 时间线;这些信息只在左侧原生消息流中展示。
|
||||
|
||||
布局默认值:
|
||||
|
||||
- 顶栏高度:56px。
|
||||
- 左侧面板:默认 480px,最小 360px,最大 720px。
|
||||
- 右侧画布:最小 480px,使用剩余空间。
|
||||
- 节点说明抽屉:覆盖画布右侧,建议宽度 420px;关闭后完整归还画布空间。
|
||||
- 状态栏:24~28px。
|
||||
- 低于 960px:左侧对话和右侧画布改为顶部按钮切换的单面板模式;节点说明仍使用覆盖式 Drawer。
|
||||
- 两栏分隔条:支持 pointer 拖动、方向键、Home、End。
|
||||
|
||||
### 4.1 原生消息流与并行智能体切换
|
||||
|
||||
“复用 `MessageList`”在本页面的含义是复用它现有的 assistant 文本、`tool_calls`、`tool` 消息、引用、文件和协助卡渲染语义;不把 AgentScope 的运行过程先解析成专用步骤条、进度摘要或阶段结果卡。
|
||||
|
||||
单个智能体:
|
||||
|
||||
- `agent.reply.started` 立即创建一条稳定的 assistant 消息;`message.delta` 持续更新同一 `messageId`,所以用户会先看到流式状态、再看到逐字回复,而不是等待节点结束。
|
||||
- 工具调用创建/更新该 assistant 消息的 `tool_calls`;工具返回创建匹配 `tool_call_id` 的原生 `tool` 消息。现有 `MessageGroup` 负责把两者配对显示,不再另写工具过程渲染器。
|
||||
- AgentScope TeamSay 作为带 `agentRunId` 的普通 assistant 消息进入同一流,按到达顺序显示。它不是节点完成的证据,节点状态仍由右侧画布和后端状态机决定。
|
||||
|
||||
并行智能体:
|
||||
|
||||
```text
|
||||
多角度并行研究 3/4 进行中
|
||||
[ 协调者 ] [ 市场规模 ✓ ] [ 品牌竞争 ● ] [ 海外市场 ● ] [ 政策风险 ! ]
|
||||
└──────────────────── 当前选中智能体的原生 MessageList ────────────────────┘
|
||||
```
|
||||
|
||||
- `AgentStageSelector` 只在并行阶段出现,横向放置在左侧对话区顶部;使用现有图标体系的头像/角色图标、名称、状态文字和非纯颜色状态标识。默认选中第一位已开始运行的成员;用户手动切换后保持选择,不因其他成员返回而抢走阅读焦点。
|
||||
- 所有成员的事件都持续接收、按每个 `agentRunId` 持久化和更新,即使当前未选中;切回即可看到完整历史与正在更新的同一条消息,不丢 token、不重新请求、不把结果拼成摘要。
|
||||
- 当前选择只将共享用户消息、协调者广播消息和目标成员消息组成一个 `BaseStream<AgentThreadState>` 交给同一个 `MessageList`。切换标签仅切换该视图的消息范围,不改变 run、节点或消息的后端事实。
|
||||
- 某成员请求用户协助时,其标签显示“待回复”徽标,选择器显示待处理数;点击徽标切到该成员,原生协助工具消息仍由现有 `MessageList` 协助卡渲染。其他成员可继续运行。
|
||||
- 节点抽屉打开某并行成员时,同步聚焦对应标签;抽屉仍只展示合同。用户从抽屉或输入框发送反馈时带 `targetNodeId`/`agentRunId`,后端在安全点生效。
|
||||
|
||||
这不是把完整日志另做成调试面板:对话区展示的是模型和工具的真实业务消息;只有 chain-of-thought、内部提示词及按权限必须脱敏的字段不进入该消息流。
|
||||
|
||||
### 4.2 页面主要状态
|
||||
|
||||
| 状态 | 左侧对话区 | 右侧流程画布 | 主要操作 |
|
||||
|---|---|---|---|
|
||||
| `empty` | 欢迎语和输入框 | 空态 | 输入需求、选模板 |
|
||||
| `clarifying` | 澄清问题和协助卡 | 需求理解节点 | 回答或补充 |
|
||||
| `planning` | 用户消息和规划进度 | 骨架或需求图 | 继续补充,等待安全点生效 |
|
||||
| `proposal_ready` | 2~3 张方案卡 | 点击卡片预览对应图 | 切换、修改、采用 |
|
||||
| `running` | 协调者与成员原生消息、工具过程和用户反馈 | 实时节点状态 | 干预、停止、在对话中要求重跑 |
|
||||
| `awaiting_input` | 等待用户卡片、原因和影响 | 对应节点显示等待 | 回答、取消 |
|
||||
| `reviewing` | 审稿意见和返工过程 | 评审/返工节点状态 | 接受或补充要求 |
|
||||
| `completed` | 总结、报告文件卡、版本和后续对话 | 完整执行图 | 问答、改写、导出 |
|
||||
| `failed` | 错误说明和恢复建议 | 失败节点 | 通过对话重试或取消 |
|
||||
| `cancelled` | 已保留成果说明 | 未完成节点取消 | 从已有成果继续或新建方案 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端目录与组件边界
|
||||
|
||||
建议新增:
|
||||
|
||||
```text
|
||||
frontend-web/src/report-collaboration/
|
||||
api/
|
||||
client.ts
|
||||
sessions.ts
|
||||
plans.ts
|
||||
runs.ts
|
||||
reports.ts
|
||||
types.ts
|
||||
components/
|
||||
ReportCollaborationLayout.tsx
|
||||
ReportCollaborationHeader.tsx
|
||||
ConversationSidebarTabs.tsx
|
||||
ReportConversationPanel.tsx
|
||||
AgentStageSelector.tsx
|
||||
ReportComposer.tsx
|
||||
ReportPlanCards.tsx
|
||||
ReportFlowCanvas.tsx
|
||||
ReportFlowNode.tsx
|
||||
NodeInspectorDrawer.tsx
|
||||
ReportPreviewDialog.tsx
|
||||
InterventionCard.tsx
|
||||
SessionHistoryPanel.tsx
|
||||
ConnectionStatusBar.tsx
|
||||
hooks/
|
||||
useReportCollaborationSession.ts
|
||||
useReportCollaborationStream.ts
|
||||
useReportCollaborationMessages.ts
|
||||
useReportPlanSelection.ts
|
||||
useReportPanelLayout.ts
|
||||
state/
|
||||
event-normalizer.ts
|
||||
event-reducer.ts
|
||||
selectors.ts
|
||||
message-adapter.ts
|
||||
pages/
|
||||
ReportCollaborationPage.tsx
|
||||
styles/
|
||||
report-collaboration.css
|
||||
tests/
|
||||
```
|
||||
|
||||
修改范围:
|
||||
|
||||
- `frontend-web/src/pages/WorkspaceRoutes.tsx`:增加页面和会话路由。
|
||||
- `frontend-web/src/components/workspace/workspace-nav-chat-list.tsx`:增加导航入口。
|
||||
- `frontend-web/src/components/workspace/messages/message-list.tsx`:仅允许增加一个向后兼容的通用业务卡片插槽。
|
||||
- `frontend-web/src/core/messages/utils.ts`:如确需识别通用业务卡类型,只做集中式扩展。
|
||||
|
||||
不得把新功能放进:
|
||||
|
||||
- `frontend-web/src/roundtable-planning/`
|
||||
- `frontend-web/src/open-canvas/`
|
||||
- Coze 项目的 `workflow-standalone/`
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据来源与前端状态
|
||||
|
||||
### 6.1 三层状态
|
||||
|
||||
前端状态分为:
|
||||
|
||||
1. 服务器快照:session、原生 messages、plans、run、nodes、artifacts、report versions;每条消息带稳定的 `agentRunId`、`nodeRunId`、`phaseId` 和可见性标记,其中 artifacts 只用于生成左侧消息卡和报告文件,不形成独立结果面板。
|
||||
2. durable SSE reducer:从 `seq` 连续应用事件,更新服务器快照投影。
|
||||
3. 纯展示状态:预览中的方案、选中的节点、聚焦的并行 `agentRunId`、两栏宽度、节点抽屉开关、窄屏当前视图和画布位置。
|
||||
|
||||
纯展示状态不能改变执行结果。点击卡片只改变 `previewPlanId`;只有点击“采用方案”才改变服务器 `selectedPlanId`。
|
||||
|
||||
### 6.2 前端事件包
|
||||
|
||||
```ts
|
||||
interface CollaborationEvent<T = unknown> {
|
||||
eventId: string;
|
||||
sessionId: string;
|
||||
runId?: string;
|
||||
seq: number;
|
||||
type: string;
|
||||
timestamp: string;
|
||||
data: T;
|
||||
}
|
||||
```
|
||||
|
||||
必须处理:
|
||||
|
||||
- 快照先到、事件后到。
|
||||
- 事件重复。
|
||||
- 事件短暂乱序。
|
||||
- SSE 断线后从 `after_seq` 重放。
|
||||
- 页面切换时旧连接晚到事件。
|
||||
- token delta 多次更新同一消息。
|
||||
- TeamSay、assistant 文本、tool-call 参数 delta、tool 返回 delta 分别更新各自稳定消息,不得等节点完成后批量生成摘要。
|
||||
- 并行成员的消息可交错到达;Reducer 必须按 `agentRunId + messageId` 更新,而不是按当前被选中的标签丢弃未聚焦成员的事件。
|
||||
- 节点重新打开后出现新 attempt。
|
||||
|
||||
Reducer 以 `sessionId + runId + seq` 去重;消息行以 `messageId`、工具结果以 `toolCallId`、标签以 `agentRunId` 使用后端稳定 ID,禁止使用数组下标。
|
||||
|
||||
---
|
||||
|
||||
## 7. 分步实施计划
|
||||
|
||||
### FE-0:冻结参考视觉与行为基线
|
||||
|
||||
任务:
|
||||
|
||||
1. 在 1440×900、1280×800、1024×768 打开参考页面。
|
||||
2. 保存初始态、方案态、运行态和节点说明抽屉四组截图。
|
||||
3. 记录顶栏、两栏比例、间距、圆角、边框、阴影、画布工具栏和状态色。
|
||||
4. 形成“参考组件—目标组件—允许差异”表。
|
||||
5. 明确消息列表保持 DeerFlow 原样,不纳入像素对齐。
|
||||
|
||||
交付物:
|
||||
|
||||
- 视觉基线截图。
|
||||
- 视觉 token 表。
|
||||
- 页面行为清单。
|
||||
|
||||
验收:参考的每个用户可见状态都有截图或明确说明不存在。
|
||||
|
||||
### FE-1:路由、菜单和静态页面骨架
|
||||
|
||||
任务:
|
||||
|
||||
1. 增加两个路由和菜单项。
|
||||
2. 创建 `ReportCollaborationPage`。
|
||||
3. 创建顶栏、左侧对话栏、右侧画布和底部连接栏。
|
||||
4. 实现可调整宽度的两栏布局,以及“专注对话/专注画布”切换。
|
||||
5. 实现窄屏单面板切换与覆盖式节点 Drawer。
|
||||
|
||||
验收:
|
||||
|
||||
- 不连接后端也能呈现完整空态。
|
||||
- 375~1440px 不出现页面级横向滚动。
|
||||
- 对话/画布切换不会卸载会话状态或清空输入内容。
|
||||
- 键盘可以调整两栏分隔条。
|
||||
|
||||
### FE-2:页面设计系统与 Coze 风格重建
|
||||
|
||||
建立页面专用语义变量:
|
||||
|
||||
```text
|
||||
--rc-bg-page
|
||||
--rc-bg-panel
|
||||
--rc-bg-canvas
|
||||
--rc-border
|
||||
--rc-text-primary
|
||||
--rc-text-secondary
|
||||
--rc-accent
|
||||
--rc-running
|
||||
--rc-waiting
|
||||
--rc-success
|
||||
--rc-danger
|
||||
--rc-panel-shadow
|
||||
--rc-card-radius
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 从 DeerFlow 主题 token 派生,不在组件中散落十六进制颜色。
|
||||
- 使用 Lucide SVG 图标,不使用 emoji。
|
||||
- hover/focus/展开动画控制在 150~300ms。
|
||||
- 支持 `prefers-reduced-motion`。
|
||||
- 交互状态不能只靠颜色表达。
|
||||
- 正文对比度达到 4.5:1。
|
||||
- 操作超过 300ms 显示 spinner、骨架或流式状态。
|
||||
|
||||
验收:除 MessageList 内部外,两栏布局、密度、卡片、画布工具栏、弹窗和节点抽屉达到参考基线。
|
||||
|
||||
### FE-3:API 类型和 TanStack Query 层
|
||||
|
||||
实现:
|
||||
|
||||
- session CRUD。
|
||||
- messages 查询与发送。
|
||||
- plan request、plans 查询、select。
|
||||
- run 创建、查询、停止、重试。
|
||||
- command 创建、确认、取消。
|
||||
- artifacts、sources、reports、versions 查询。
|
||||
|
||||
每个写请求携带:
|
||||
|
||||
- `idempotencyKey`
|
||||
- `expectedRevision`
|
||||
|
||||
冲突行为:
|
||||
|
||||
- `409`:刷新当前快照并提示用户重新确认。
|
||||
- `422`:在对应操作附近显示具体错误。
|
||||
- `429`:显示预算/并发限制,不自动重试。
|
||||
- `5xx`:保留用户输入,允许使用同一幂等键重试。
|
||||
|
||||
验收:API 层不包含页面展示逻辑,类型与后端 OpenAPI 一致。
|
||||
|
||||
### FE-4:SSE、快照和事件 Reducer
|
||||
|
||||
实现 `useReportCollaborationStream`:
|
||||
|
||||
1. 先拉 session snapshot。
|
||||
2. 使用 snapshot 的 `lastSeq` 建立 SSE。
|
||||
3. 校验每个事件 session/run 身份。
|
||||
4. 按 seq 应用事件并去重。
|
||||
5. 出现序号缺口时暂停 live apply,补拉缺失事件。
|
||||
6. 断线指数退避并支持 `Last-Event-ID`。
|
||||
7. 终态后完成最后一次事件补拉再关闭连接。
|
||||
|
||||
Reducer 必须覆盖:
|
||||
|
||||
- 消息流增量。
|
||||
- 计划候选和 revision。
|
||||
- node/node attempt 状态。
|
||||
- artifact 创建和校验。
|
||||
- command 分类和执行。
|
||||
- report delta 和正式版本。
|
||||
- 错误、取消、接管恢复。
|
||||
- AgentScope 原始可见消息和工具事件:`message.created/delta/completed`、`tool_call.created/delta/completed`、`tool_result.created/delta/completed`、`team_message.created/delta/completed`。
|
||||
|
||||
验收:重复事件、乱序事件和重连都不产生重复消息、重复节点或状态倒退。
|
||||
|
||||
### FE-5:复用 DeerFlow `MessageList`
|
||||
|
||||
复用:
|
||||
|
||||
- `frontend-web/src/components/workspace/messages/message-list.tsx`
|
||||
- `frontend-web/src/components/workspace/messages/message-list-item.tsx`
|
||||
- `frontend-web/src/components/workspace/messages/message-group.tsx`
|
||||
- `frontend-web/src/components/workspace/messages/context.ts`
|
||||
|
||||
实现无损协议适配 `message-adapter.ts`:
|
||||
|
||||
1. 将已持久化的 `CollaborationMessage` 一对一映射为 LangGraph SDK `Message[]`,不将文本或工具结果二次概括。
|
||||
2. 构造最小 `BaseStream<AgentThreadState>` 外观:`messages`、`isLoading`、`isThreadLoading`、`values`。
|
||||
3. 协调者、成员 AgentScope TeamSay 和成员回复都映射为普通 assistant 消息;保留 `messageId`、文本 chunk、角色/成员名称、`agentRunId`、`nodeRunId`、`phaseId`、来源事件 ID 和完成态。
|
||||
4. AgentScope 工具调用映射为该 assistant 消息的标准 `tool_calls`;调用结果映射为标准 `tool` 消息,并严格复用原有 `tool_call_id` 配对。工具参数、返回正文及增量保持原文,只有后端明确标注的脱敏字段被替换。
|
||||
5. 将来源、文件、报告 Markdown delta 和正式版本继续映射为既有引用、文件卡和普通 assistant 消息;规划卡、用户确认卡和报告替换卡仍通过一个通用业务卡片插槽渲染。
|
||||
6. 错误、恢复建议、命令影响范围和报告版本变化也必须进入同一时间线;错误堆栈以可读错误消息替代,但不能吞掉已发生的工具/成员消息。
|
||||
7. `selectMessagesForAgentRun(allMessages, focusedAgentRunId)` 只在客户端从已同步消息中选出共享消息与目标成员消息;并行选择器不得重新拉取、重新排序或改写消息。
|
||||
|
||||
约束:
|
||||
|
||||
- 不使用 LangGraph `useThreadStream` 发送消息。
|
||||
- 新页面输入框调用报告协作 API。
|
||||
- 不增加多个页面专用 MessageList props。
|
||||
- 普通聊天、AI 写作、深度研究等未传插槽时必须零变化。
|
||||
- 不另建执行结果列表、节点结果面板或成果时间线。
|
||||
- 不用“运行中”“已检索 N 条”等专用摘要替代真实消息。`MessageList` 本身的流式状态和原生工具步骤是唯一过程视图。
|
||||
|
||||
验收:
|
||||
|
||||
- 历史和流式消息使用同一行稳定更新。
|
||||
- 单成员首次开始回复、每次工具启动和工具返回均在 300ms 内出现可见的原生消息行或原生工具步骤;不允许持续数秒只有静态加载圈。
|
||||
- 并行标签切换后显示目标成员的完整已持久化消息和实时 delta;未选中成员持续接收,标签上的运行/待回复状态及时更新。
|
||||
- 用户上翻阅读时不强制滚底。
|
||||
- 用户发送新消息后滚到最新位置。
|
||||
- 卡片显示成功不改变后端节点状态。
|
||||
|
||||
### FE-6:需求澄清和候选方案卡片
|
||||
|
||||
`ReportPlanCards` 展示:
|
||||
|
||||
- 推荐标签、策略、标题、摘要和推荐理由。
|
||||
- 分析角度和参与角色。
|
||||
- 关键步骤与质量门。
|
||||
- 预计时间、成本等级和资料范围。
|
||||
- 方案验证错误。
|
||||
|
||||
交互:
|
||||
|
||||
- 点击卡片或“查看流程”只切换预览。
|
||||
- “采用此方案”调用 select API。
|
||||
- “开始协作”再次校验并创建 run。
|
||||
- 方案调整后显示新 revision;旧 revision 只读。
|
||||
- 过期 revision 返回 409 后不静默覆盖。
|
||||
|
||||
验收:连续快速切换三张卡时,画布最终显示最后一次选择,不闪回旧图。
|
||||
|
||||
### FE-7:协作流程画布
|
||||
|
||||
技术:`@xyflow/react`。可以借鉴但不直接耦合:
|
||||
|
||||
- `frontend-web/src/components/ai-elements/canvas.tsx`
|
||||
- `frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx`
|
||||
|
||||
节点显示:
|
||||
|
||||
- 节点名、角色名和分析角度。
|
||||
- 状态文字、图标和颜色。
|
||||
- 等待用户、返工、失败和 superseded 标记。
|
||||
|
||||
画布能力:
|
||||
|
||||
- 自动布局。
|
||||
- 拖动节点只保存浏览器展示位置。
|
||||
- 缩放、适配视图、方向切换、小地图。
|
||||
- 活动边低强度流动动画。
|
||||
- 点击节点打开覆盖在画布上的节点说明抽屉。
|
||||
- 节点重跑、补充或纠偏一律通过左侧对话完成;抽屉只提供“在对话中对此节点提出意见”入口。
|
||||
- 方案切换后清理不属于新方案的本地坐标。
|
||||
|
||||
验收:
|
||||
|
||||
- 30 个节点内流畅操作。
|
||||
- 单个 node event 不导致整个 React Flow 重挂。
|
||||
- `awaiting_input`、`reopened`、`superseded` 不与 `completed` 混淆。
|
||||
|
||||
### FE-8:节点说明抽屉与对话定位
|
||||
|
||||
点击画布节点打开 `NodeInspectorDrawer`。抽屉只展示计划合同:
|
||||
|
||||
1. 节点名称、角色、分析角度和当前状态标签。
|
||||
2. 节点职责:负责解决什么问题。
|
||||
3. 要根据什么:所需输入类型、上游节点类型、资料范围和时效要求。
|
||||
4. 要产出什么:成果类型、结构要求和验收条件。
|
||||
5. 可用工具、禁止事项、预算上限。
|
||||
6. 上游与下游关系。
|
||||
|
||||
抽屉不展示:
|
||||
|
||||
- 节点实际生成内容。
|
||||
- 工具调用返回值。
|
||||
- EvidenceBundle 或其他成果物正文。
|
||||
- attempt 执行时间线。
|
||||
- 错误堆栈和调试信息。
|
||||
- 报告正文。
|
||||
|
||||
交互:
|
||||
|
||||
- “在对话中对此节点提出意见”关闭抽屉、聚焦左侧 Composer,并绑定 `targetNodeId` 上下文。
|
||||
- 若该节点对应正在并行运行的成员,同时绑定该成员的 `agentRunId` 并切换左侧选择器,使用户发送前能先看到该成员的真实对话与工具过程。
|
||||
- 用户仍需在对话区输入“重新检索”“重新分析”等要求;抽屉不直接发起业务变更。
|
||||
- 状态标签来自后端节点状态,但不加载节点结果接口。
|
||||
- 关闭抽屉不清空 selected node;再次点击同一节点可恢复说明。
|
||||
|
||||
报告预览不使用常驻第三栏。最终报告、章节候选和版本先作为左侧 MessageList 中的普通消息/文件卡出现;点击文件卡时复用 `ArtifactFileDetail` 或现有 Markdown 能力打开大尺寸 Dialog,关闭后回到两栏工作台。
|
||||
|
||||
验收:
|
||||
|
||||
- 抽屉宽度不改变两栏布局,不挤压左侧对话区。
|
||||
- 抽屉只读取节点合同和图结构,不请求节点执行结果。
|
||||
- 所有原生成员消息、工具过程、错误和报告入口都能在左侧对话时间线找到。
|
||||
|
||||
### FE-9:全过程对话干预
|
||||
|
||||
`ReportComposer` 根据状态显示不同提示:
|
||||
|
||||
- 空闲:输入报告需求。
|
||||
- 规划中:补充要求将在规划安全点应用。
|
||||
- 运行中:协调者正在判断影响范围。
|
||||
- 等待用户:优先回答等待问题。
|
||||
- 完成后:可问答、重写、润色或重新研究。
|
||||
|
||||
发送流程:
|
||||
|
||||
1. 生成 idempotency key。
|
||||
2. 插入 pending 用户消息;带 `targetNodeId`/`agentRunId` 时在相关成员消息流和共享上下文中引用同一条用户消息,不重复持久化。
|
||||
3. 显示“正在识别修改范围”。
|
||||
4. `command.classified` 后展示意图和影响节点。
|
||||
5. 高成本或低置信度命令显示确认卡。
|
||||
6. `command.accepted` 后显示排队或等待安全点。
|
||||
7. 完成后关联新 node attempt 或 report version。
|
||||
8. 失败时保留原消息和重试入口。
|
||||
|
||||
典型映射:
|
||||
|
||||
| 用户输入 | 预期操作 |
|
||||
|---|---|
|
||||
| “增加政策风险角度” | 新增角度节点和受影响下游 |
|
||||
| “资料太旧,重新找” | 重新检索指定证据包 |
|
||||
| “竞争格局分析不到位” | 复用证据,重新分析 |
|
||||
| “重写第二章” | 只生成章节替换候选 |
|
||||
| “语气更正式” | 新报告版本,不重新研究 |
|
||||
| “为什么得出这个结论” | 只读问答,不修改报告 |
|
||||
|
||||
验收:
|
||||
|
||||
- 双击发送不创建重复命令。
|
||||
- 选中“品牌竞争分析师”后发送补充要求,只影响该成员及依赖分析,其他并行成员仍保持消息流和运行状态。
|
||||
- 运行中反馈不会让 run 或 node 误变 completed。
|
||||
- “重写第二章”不会触发全量检索。
|
||||
- “资料太旧”不会只做文字润色。
|
||||
|
||||
### FE-10:会话历史、报告版本与导出
|
||||
|
||||
- History Tab 显示标题、状态、更新时间、当前版本。
|
||||
- 新建会话不删除旧会话。
|
||||
- 会话切换时先关闭旧 SSE,再加载新快照。
|
||||
- 报告修改先生成候选,确认后创建新版本。
|
||||
- 版本恢复创建新的头版本,不删除历史。
|
||||
- 下载复用现有 Markdown/Word 能力。
|
||||
- 文件名来自报告标题,不暴露宿主机或沙箱路径。
|
||||
|
||||
验收:历史会话可完整恢复左侧消息流程、右侧节点状态和报告版本;不额外恢复节点结果面板。
|
||||
|
||||
### FE-11:测试、视觉回归和性能
|
||||
|
||||
单元测试:
|
||||
|
||||
- event normalizer/reducer。
|
||||
- message adapter。
|
||||
- plan revision 和选择状态。
|
||||
- node status 和 available actions。
|
||||
- command pending/idempotency。
|
||||
|
||||
组件测试:
|
||||
|
||||
- 空态、澄清、规划、候选、运行、等待、完成、失败、取消。
|
||||
- 卡片选择、节点点击、只读说明 Drawer、确认弹窗和报告预览 Dialog。
|
||||
- MessageList 通用业务卡插槽零回归。
|
||||
- 单成员流式文本、TeamSay、工具调用与工具返回的原样重放;并行成员交错到达、切换标签、切回后不中断 delta。
|
||||
- 非当前标签成员发起协助时显示待回复徽标,点击后使用原生协助卡回复到正确节点。
|
||||
|
||||
E2E:
|
||||
|
||||
1. 新建会话并输入新能源汽车报告要求。
|
||||
2. 选择候选并开始。
|
||||
3. 运行中增加风险角度。
|
||||
4. 重新检索海外最新资料。
|
||||
5. 完成后重写第二章。
|
||||
6. 刷新、断网重连、切换会话。
|
||||
7. 停止和重试失败节点。
|
||||
|
||||
性能门:
|
||||
|
||||
- 30 节点画布不卡顿。
|
||||
- 长消息列表使用现有优化策略。
|
||||
- SSE 高频 delta 合帧后再 setState。
|
||||
- 画布和消息区使用独立 selector,避免互相触发整树重渲染。
|
||||
- 节点抽屉只订阅节点合同和状态,不能因阶段产出更新而反复重渲染。
|
||||
- 只渲染当前聚焦成员和共享消息的可视窗口;非当前成员的消息仍写入 store,但不因 token delta 重渲染当前 `MessageList`。
|
||||
|
||||
---
|
||||
|
||||
## 8. 后端接口依赖
|
||||
|
||||
前端开发前,后端必须冻结以下接口:
|
||||
|
||||
```text
|
||||
POST /api/report-collaboration/sessions
|
||||
GET /api/report-collaboration/sessions
|
||||
GET /api/report-collaboration/sessions/{session_id}
|
||||
PATCH /api/report-collaboration/sessions/{session_id}
|
||||
DELETE /api/report-collaboration/sessions/{session_id}
|
||||
|
||||
POST /api/report-collaboration/sessions/{session_id}/messages
|
||||
GET /api/report-collaboration/sessions/{session_id}/messages
|
||||
POST /api/report-collaboration/sessions/{session_id}/plan-requests
|
||||
GET /api/report-collaboration/sessions/{session_id}/plans
|
||||
POST /api/report-collaboration/sessions/{session_id}/plans/{plan_id}/select
|
||||
POST /api/report-collaboration/sessions/{session_id}/runs
|
||||
|
||||
GET /api/report-collaboration/runs/{run_id}
|
||||
GET /api/report-collaboration/runs/{run_id}/events
|
||||
GET /api/report-collaboration/runs/{run_id}/stream
|
||||
POST /api/report-collaboration/runs/{run_id}/commands
|
||||
POST /api/report-collaboration/runs/{run_id}/cancel
|
||||
GET /api/report-collaboration/runs/{run_id}/nodes/{node_id}/contract
|
||||
|
||||
GET /api/report-collaboration/sessions/{session_id}/reports
|
||||
POST /api/report-collaboration/sessions/{session_id}/report-rewrites
|
||||
POST /api/report-collaboration/sessions/{session_id}/report-rewrites/{id}/apply
|
||||
```
|
||||
|
||||
前后端共享枚举:
|
||||
|
||||
- session status。
|
||||
- run status。
|
||||
- node/node attempt status。
|
||||
- command intent/status。
|
||||
- event type/version。
|
||||
- artifact type/schema version。
|
||||
- report version/change type。
|
||||
|
||||
---
|
||||
|
||||
## 9. 前端开发顺序
|
||||
|
||||
页面与契约层已完成(代码在 `frontend-web/src/report-collaboration/`):
|
||||
|
||||
1. `RC-FE-001`:视觉截图与行为基线。
|
||||
2. `RC-FE-002`:路由、菜单和左对话/右画布两栏空壳。
|
||||
3. `RC-FE-003`:页面 token、弹窗、抽屉和响应式。
|
||||
4. `RC-FE-004`:API 类型和 mock server。
|
||||
5. `RC-FE-005`:SSE reducer 与快照恢复。
|
||||
6. `RC-FE-006`:MessageList adapter 和通用卡片插槽。
|
||||
7. `RC-FE-007`:需求澄清与候选方案卡。
|
||||
8. `RC-FE-008`:流程画布和自定义节点。
|
||||
9. `RC-FE-009`:只读节点说明抽屉与对话目标定位。
|
||||
10. `RC-FE-010`:统一 Composer 和命令确认。
|
||||
11. `RC-FE-011`:报告预览、版本和导出。
|
||||
12. `RC-FE-012`:历史、重连和异常恢复。
|
||||
13. `RC-FE-013`:组件/E2E/视觉/性能验收。
|
||||
|
||||
依赖规则:
|
||||
|
||||
- 契约已冻结为 `src/report-collaboration/api/types.ts`;后端 `RC-BE-001` 已对齐。
|
||||
- 真实 SSE 联调:后端 `RC-BE-011` 已挂载 `GET /runs/{id}/stream`;默认无后台循环时流只会重放已入库事件。
|
||||
- 报告版本操作已接后端 `RC-BE-015`(改写候选 / apply / restore)。
|
||||
- 执行画布实时状态依赖打开 `worker_enabled`;质量角色内核已在 `RC-BE-013` 落地,真实成员见 `RC-BE-017`。
|
||||
|
||||
---
|
||||
|
||||
## 10. 前端完成定义
|
||||
|
||||
页面与 mock 联调路径已落地。真实后端可联调 `GET /runs/{id}/stream`、领取/恢复(`RC-BE-012`,默认不挂 worker)、质量角色(`RC-BE-013`)以及报告改写/恢复(`RC-BE-015`)。此前以 mock driver 覆盖交互,不以「前端未编码」计未完成。
|
||||
|
||||
前端只有同时满足以下条件才算完成:
|
||||
|
||||
1. 页面结构与 Coze 参考页面逐状态验收通过。
|
||||
2. 消息区复用 DeerFlow `MessageList`,其他聊天页面无回归。
|
||||
3. 候选卡、左侧对话流程、右侧流程图和报告来自同一 session/run/event 状态。
|
||||
4. 点击候选只预览,明确确认后才执行。
|
||||
5. 用户可以在规划、运行、完成后三个阶段继续对话。
|
||||
6. 协助卡显示或提交不会导致前端误标完成。
|
||||
7. 节点点击只打开说明抽屉,可查看职责、所需输入、预期输出、工具和验收条件,不展示执行结果。
|
||||
8. 节点重开、返工和 superseded 状态表达正确。
|
||||
9. SSE 重连、刷新和历史切换无重复消息和状态倒退。
|
||||
10. 报告修改以候选和版本形式完成,不直接覆盖正式报告。
|
||||
11. 页面始终只有对话与画布两个常驻区域,所有执行过程和产出入口都能在对话时间线中找到。
|
||||
12. 响应式、键盘、对比度、加载反馈和错误恢复通过验收。
|
||||
@@ -1,481 +0,0 @@
|
||||
# 报告协作工作台 — 前端对接指南
|
||||
|
||||
> **适用版本:** RC-BE-000~018 已落地(2026-09-06)
|
||||
> **配套文档:**
|
||||
> - [前端实施计划](./agentscope-多智能体报告协作工作台-前端实施计划.md)
|
||||
> - [后端实施计划](./agentscope-多智能体报告协作工作台-后端实施计划.md)
|
||||
> - 模块 README:`frontend-web/src/report-collaboration/README.md`
|
||||
|
||||
本文说明**前端如何对接真实后端**:环境配置、API 契约、用户流程、状态机、错误处理与联调验收。前端 API 客户端与页面已实现,通常**无需再写一套 HTTP 层**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论速览
|
||||
|
||||
| 问题 | 答案 |
|
||||
|------|------|
|
||||
| 前端还要写接口吗? | **不用**。`src/report-collaboration/api/` 已封装 REST + SSE,页面通过 `getReportCollaborationDriver()` 调用。 |
|
||||
| 为什么调不通? | 最常见:`config.yaml` 里 `report_collaboration.enabled: false` → 全部 **503**;或 `worker_enabled: false` → run 不入队执行。 |
|
||||
| Mock 和真后端怎么切? | **Mock 已移除**。前端永远请求真实后端(`deerflow.rc-mock` / `VITE_RC_MOCK` 开关已删除);联调需 `enabled: true`。 |
|
||||
| 字段会和 MySQL 对不上吗? | 前端只认 JSON 契约(`api/types.ts`),不直连表。缺列表现为后端 500/SQL 错误,部署后需**重启 Gateway** 跑 `create_all` + 列同步。 |
|
||||
| 从哪进页面? | `/page/workspace/report-collaboration`(新会话)或 `/page/workspace/report-collaboration/:sessionId`。侧边栏入口默认注释,可直接 URL 访问。 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构:前端已有什么
|
||||
|
||||
```
|
||||
ReportCollaborationPage
|
||||
└─ useReportCollaborationSession(唯一数据入口)
|
||||
├─ driver.getSnapshot() ← REST 冷启动
|
||||
├─ driver.openRunStream() ← SSE 实时事件
|
||||
├─ driver.sendMessage / createCommand / startRun …
|
||||
└─ event-reducer ← 事件 → UI 状态
|
||||
|
||||
ReportConversationPanel
|
||||
└─ MessageList(message-adapter 映射 CollaborationMessage → LangGraph Message)
|
||||
```
|
||||
|
||||
### 2.1 目录结构
|
||||
|
||||
```text
|
||||
frontend-web/src/report-collaboration/
|
||||
api/
|
||||
types.ts # 冻结契约(snake_case,与后端 Pydantic 一致)
|
||||
client.ts # rcFetch:鉴权、幂等键、乐观锁、错误映射
|
||||
sessions.ts # 会话 CRUD、消息
|
||||
plans.ts # 方案请求 / 列表 / 选定
|
||||
runs.ts # Run、SSE、命令、节点合同
|
||||
reports.ts # 报告版本、改写、恢复
|
||||
driver.ts # 真实后端统一门面(Mock 驱动已移除)
|
||||
state/
|
||||
event-normalizer.ts
|
||||
event-reducer.ts
|
||||
selectors.ts
|
||||
message-adapter.ts
|
||||
hooks/
|
||||
useReportCollaborationSession.ts # 快照 → SSE → reducer → 动作
|
||||
useReportCollaborationStream.ts # 断线重连、after_seq 续接
|
||||
useReportCollaborationMessages.ts
|
||||
components/ …
|
||||
pages/ReportCollaborationPage.tsx
|
||||
```
|
||||
|
||||
### 2.2 事实源原则
|
||||
|
||||
- **冷启动:** `GET /sessions/{id}` 返回 `SessionSnapshot`(session、messages、plans、run、nodes、agent_runs、commands、report_versions、last_seq)。
|
||||
- **运行中:** `GET /runs/{id}/stream?after_seq=N` 订阅 durable SSE。
|
||||
- **断线 / 序号缺口:** `GET /runs/{id}/events?after_seq=N` REST 补拉。
|
||||
- **澄清 / 规划阶段(尚无 run):** 每 2.5s 轮询快照(后端事件主要在 run 流上)。
|
||||
|
||||
纯展示状态(预览方案、聚焦成员、面板宽度)**不**写入服务器,仅存前端。
|
||||
|
||||
---
|
||||
|
||||
## 3. 后端启用与部署
|
||||
|
||||
### 3.1 必改配置
|
||||
|
||||
文件:`offline-backend-20260512/backend/config.yaml`
|
||||
|
||||
```yaml
|
||||
report_collaboration:
|
||||
enabled: false # 联调必须改为 true
|
||||
worker_enabled: false # 要看成员执行必须 true
|
||||
planner_model: null # 建议填可用模型名
|
||||
coordinator_model: null
|
||||
research_model: null
|
||||
writer_model: null
|
||||
reviewer_model: null
|
||||
# … token_budget、max_concurrent_runs_per_user 等见同文件
|
||||
```
|
||||
|
||||
| 配置 | 现象 |
|
||||
|------|------|
|
||||
| `enabled: false` | 所有路由 **503**,`code: REPORT_COLLABORATION_DISABLED` |
|
||||
| `enabled: true`,`worker_enabled: false` | REST/SSE 可用,run 入队但不自动执行(测 API 可用,无成员流式输出) |
|
||||
| 两者 `true` + 模型配置完整 | 完整联调 |
|
||||
|
||||
### 3.2 MySQL / 表结构
|
||||
|
||||
生产环境 Alembic 自动升级默认关闭,启动时走 `create_all` + `_ensure_orm_columns_sync`。
|
||||
|
||||
- 迁移脚本(可选手动跑):`20260905_01`、`20260906_01`、`20260907_01`
|
||||
- ORM:`packages/harness/deerflow/persistence/report_collaboration/model.py`(13 张表)
|
||||
- **部署后务必重启 Gateway**,观察日志是否有 `Failed to auto-add column report_collaboration_*`
|
||||
|
||||
已有 RC-003 时代旧表会通过列同步补上 `template_snapshot_json`、`budget_snapshot_json`、`usage_json`、`budget_exhausted_reason` 等可空列,并创建 `rewrite_proposals`、`audits`。
|
||||
|
||||
### 3.3 前端环境变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `VITE_BACKEND_BASE_URL` | Gateway 地址,默认 `http://127.0.0.1:8001` |
|
||||
|
||||
请求经 `apiFetch`,自动携带登录 JWT(`credentials: "include"`)。**必须先登录 DeerFlow**。
|
||||
|
||||
---
|
||||
|
||||
## 4. API 基址与请求约定
|
||||
|
||||
### 4.1 Base URL
|
||||
|
||||
```text
|
||||
{VITE_BACKEND_BASE_URL}/api/report-collaboration
|
||||
```
|
||||
|
||||
代码常量:`REPORT_COLLABORATION_BASE()`(`api/client.ts`)。
|
||||
|
||||
### 4.2 请求头
|
||||
|
||||
| Header | 何时携带 | 说明 |
|
||||
|--------|----------|------|
|
||||
| `Authorization` | 全部 | 由 `apiFetch` 注入 |
|
||||
| `X-Idempotency-Key` | 写操作 | 格式 `rc-{uuid}`;重复提交返回首次结果 |
|
||||
| `X-Expected-Revision` | 选方案、开 run、改会话等 | 会话 `requirement_revision` 或方案 `revision` |
|
||||
| `Accept: text/event-stream` | SSE | `openRunStream` |
|
||||
|
||||
### 4.3 错误响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "REPORT_COLLABORATION_DISABLED",
|
||||
"message": "报告协作工作台未启用",
|
||||
"detail": { }
|
||||
}
|
||||
```
|
||||
|
||||
前端映射(`api/client.ts`):
|
||||
|
||||
| HTTP | 异常类 | 典型场景 |
|
||||
|------|--------|----------|
|
||||
| 409 | `ConflictError` | revision 过期、active run 冲突 |
|
||||
| 422 | `ValidationError` | 参数/语义错误、缺幂等键 |
|
||||
| 429 | `RateLimitError` | 预算耗尽、每用户并发 run 上限 |
|
||||
| 5xx | `ServerError` | 可带同一幂等键重试 |
|
||||
| 0 | `NetworkError` | fetch 失败 |
|
||||
|
||||
### 4.4 完整路由清单(与前端 client 对齐)
|
||||
|
||||
| 方法 | 路径 | 前端模块 |
|
||||
|------|------|----------|
|
||||
| POST | `/sessions` | `sessions.createSession` |
|
||||
| GET | `/sessions` | `sessions.listSessions` |
|
||||
| GET | `/sessions/{id}` | `sessions.getSessionSnapshot` |
|
||||
| PATCH | `/sessions/{id}` | `sessions.updateSession` |
|
||||
| DELETE | `/sessions/{id}` | `sessions.deleteSession` |
|
||||
| GET | `/sessions/{id}/messages` | `sessions.listMessages` |
|
||||
| POST | `/sessions/{id}/messages` | `sessions.sendMessage` |
|
||||
| POST | `/sessions/{id}/plan-requests` | `plans.requestPlans` |
|
||||
| GET | `/sessions/{id}/plans` | `plans.listPlans` |
|
||||
| POST | `/sessions/{id}/plans/{planId}/select` | `plans.selectPlan` |
|
||||
| POST | `/sessions/{id}/runs` | `runs.startRun` |
|
||||
| GET | `/sessions/{id}/reports` | `reports.listReportVersions` |
|
||||
| POST | `/sessions/{id}/report-rewrites` | `reports.createRewrite` |
|
||||
| POST | `/sessions/{id}/report-rewrites/{id}/apply` | `reports.applyRewrite` |
|
||||
| POST | `/sessions/{id}/report-versions/{id}/restore` | `reports.restoreReportVersion` |
|
||||
| GET | `/runs/{id}` | `runs.getRun` |
|
||||
| GET | `/runs/{id}/events` | `runs.listRunEvents` |
|
||||
| GET | `/runs/{id}/stream` | `runs.openRunStream` |
|
||||
| POST | `/runs/{id}/cancel` | `runs.cancelRun` |
|
||||
| POST | `/runs/{id}/commands` | `runs.createCommand` |
|
||||
| POST | `/runs/{id}/commands/{id}/confirm` | `runs.confirmCommand` |
|
||||
| POST | `/runs/{id}/commands/{id}/cancel` | `runs.cancelCommand` |
|
||||
| GET | `/runs/{id}/nodes/{nodeId}/contract` | `runs.getNodeContract` |
|
||||
| GET | `/runs/{id}/artifacts` | (后端已暴露,前端按需接) |
|
||||
| GET | `/runs/{id}/sources` | (后端已暴露,前端按需接) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 用户流程与 API 时序
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant FE as 前端 RC
|
||||
participant API as Gateway
|
||||
participant W as Worker
|
||||
|
||||
U->>FE: 输入需求
|
||||
FE->>API: POST /sessions
|
||||
FE->>API: POST /sessions/{id}/messages
|
||||
API-->>FE: 澄清(快照 messages)
|
||||
U->>FE: 回答澄清
|
||||
FE->>API: POST /messages
|
||||
FE->>API: POST /plan-requests
|
||||
API-->>FE: 候选方案(快照 plans)
|
||||
U->>FE: 采用方案
|
||||
FE->>API: POST /plans/{id}/select
|
||||
U->>FE: 开始协作
|
||||
FE->>API: POST /runs
|
||||
FE->>API: GET /runs/{id}/stream
|
||||
W-->>API: 持久化事件
|
||||
API-->>FE: SSE
|
||||
U->>FE: 运行中干预
|
||||
FE->>API: POST /commands → confirm
|
||||
```
|
||||
|
||||
### 5.1 阶段 0:进入页面
|
||||
|
||||
| 路由 | 行为 |
|
||||
|------|------|
|
||||
| `/page/workspace/report-collaboration` | 空态;`POST /sessions` 后跳转带 `sessionId` |
|
||||
| `/page/workspace/report-collaboration/:sessionId` | 加载快照;有 `location.state.rcInitialMessage` 时自动发首条消息 |
|
||||
|
||||
路由注册:`frontend-web/src/pages/WorkspaceRoutes.tsx`。
|
||||
侧边栏入口已在 `workspace-nav-chat-list.tsx` 中放开(「报告协作」项),也可直接访问 URL。
|
||||
|
||||
### 5.2 阶段 1:需求澄清(无 run)
|
||||
|
||||
```http
|
||||
POST /api/report-collaboration/sessions
|
||||
POST /api/report-collaboration/sessions/{sessionId}/messages
|
||||
Content-Type: application/json
|
||||
|
||||
{ "text": "请写一份…", "target_node_id": null, "agent_run_id": null }
|
||||
```
|
||||
|
||||
- `useReportCollaborationSession.send()`:当 `session.status` 不是 `running` / `awaiting_input` / `reviewing` 时走 `sendMessage`。
|
||||
- 会话状态:`empty` → `clarifying` → `planning` → `proposal_ready`。
|
||||
- 澄清问题以 `CollaborationMessage` 进入时间线;业务卡通过 `MessageList` 的 `businessCardSlot` 渲染(`additional_kwargs.report_collaboration_card`)。
|
||||
|
||||
### 5.3 阶段 2:候选方案
|
||||
|
||||
```http
|
||||
POST /api/report-collaboration/sessions/{sessionId}/plan-requests
|
||||
POST /api/report-collaboration/sessions/{sessionId}/plans/{planId}/select
|
||||
```
|
||||
|
||||
- **预览方案**(`previewPlanId`):纯前端,不调 API。
|
||||
- **采用方案**:`select` 写 `selected_plan_id`,**不**创建 run。
|
||||
- 409:方案 revision 过期 → 前端 `refreshSnapshot`,提示用户重选。
|
||||
- 右栏 `ReportFlowCanvas` 使用 `PlanNode` / `PlanEdge` 渲染 DAG。
|
||||
|
||||
### 5.4 阶段 3:开始执行与 SSE
|
||||
|
||||
```http
|
||||
POST /api/report-collaboration/sessions/{sessionId}/runs
|
||||
{ "plan_id": "plan-xxx" }
|
||||
|
||||
GET /api/report-collaboration/runs/{runId}/stream?after_seq=0
|
||||
Accept: text/event-stream
|
||||
```
|
||||
|
||||
SSE 每条 `data:` 为 JSON:
|
||||
|
||||
```typescript
|
||||
interface CollaborationEvent {
|
||||
eventId: string;
|
||||
sessionId: string;
|
||||
runId?: string | null;
|
||||
seq: number;
|
||||
type: CollaborationEventType;
|
||||
timestamp: string;
|
||||
data: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
常用 `type`(完整列表见 `api/types.ts`):
|
||||
|
||||
| 类型 | 含义 |
|
||||
|------|------|
|
||||
| `message.created` / `message.delta` / `message.completed` | 对话镜像流式 |
|
||||
| `tool_call.*` / `tool_result.*` | 工具调用与返回 |
|
||||
| `team_message.*` | 成员 / 协调者广播 |
|
||||
| `plan.proposed` / `plan.selected` | 方案生命周期 |
|
||||
| `run.status.changed` | Run 状态 |
|
||||
| `node.status.changed` / `node.progress` | 节点执行 |
|
||||
| `command.confirmation.required` | 需用户确认的干预 |
|
||||
| `report.version.created` | 报告版本落盘 |
|
||||
| `heartbeat` | 保活 |
|
||||
|
||||
### 5.5 阶段 4:运行中干预
|
||||
|
||||
```http
|
||||
POST /api/report-collaboration/runs/{runId}/commands
|
||||
POST /api/report-collaboration/runs/{runId}/commands/{commandId}/confirm
|
||||
POST /api/report-collaboration/runs/{runId}/commands/{commandId}/cancel
|
||||
POST /api/report-collaboration/runs/{runId}/cancel
|
||||
```
|
||||
|
||||
- Run 活跃时 `send()` 自动走 `createCommand`(而非 `sendMessage`)。
|
||||
- 可从节点抽屉 / `AgentStageSelector` 传入 `target_node_id`、`agent_run_id`。
|
||||
- `InterventionCard` 展示 `confirmation_required` 命令;确认后调 `confirmCommand`。
|
||||
|
||||
`CommandIntent` 枚举(后端分类,前端展示 reason/impact):`answer_question`、`update_requirements`、`research_again`、`rewrite_section`、`rerun_node`、`replan`、`cancel` 等。
|
||||
|
||||
### 5.6 阶段 5:报告版本与改写
|
||||
|
||||
```http
|
||||
GET /api/report-collaboration/sessions/{sessionId}/reports
|
||||
POST /api/report-collaboration/sessions/{sessionId}/report-rewrites
|
||||
POST /api/report-collaboration/sessions/{sessionId}/report-rewrites/{rewriteId}/apply
|
||||
POST /api/report-collaboration/sessions/{sessionId}/report-versions/{versionId}/restore
|
||||
```
|
||||
|
||||
- `createRewrite` 只生成候选,不直接覆盖正式版本。
|
||||
- `applyRewrite` / `restoreReportVersion` 产生新的 head 版本,历史保留。
|
||||
- `ReportPreviewDialog` 展示 `ReportVersion.markdown`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端状态机(接入方须知)
|
||||
|
||||
**不要**在页面里用零散 REST 字段拼 UI 状态。统一路径:
|
||||
|
||||
```text
|
||||
SessionSnapshot → applySessionSnapshot()
|
||||
CollaborationEvent[] → normalizeCollaborationEvent() → applyCollaborationEvents()
|
||||
→ selectors(derivePagePhase、selectPlanForCanvas、…)
|
||||
```
|
||||
|
||||
### 6.1 核心 Selector
|
||||
|
||||
| Selector | 用途 |
|
||||
|----------|------|
|
||||
| `derivePagePhase` | Composer 占位、方案卡、空态 |
|
||||
| `selectPlanForCanvas` | 画布 DAG(含预览方案) |
|
||||
| `selectAgentTabs` | 并行成员 Tab |
|
||||
| `selectNodeStatusMap` | 节点颜色 / 状态 |
|
||||
| `selectPendingConfirmationCommands` | 干预确认卡 |
|
||||
| `selectHeadReportVersion` | 报告预览入口 |
|
||||
| `selectStreamingAgentRunIds` | 流式成员指示 |
|
||||
|
||||
### 6.2 SSE 连接策略(`useReportCollaborationStream`)
|
||||
|
||||
- Run 非终态时连接;`completed` / `failed` / `cancelled` 后关流并 REST 最终补拉。
|
||||
- 断线:指数退避重连(1s~30s),`after_seq` 取 reducer 内 `lastSeq`。
|
||||
- `hasSeqGap`:暂停 live 应用,先 `listRunEvents` 回填。
|
||||
|
||||
### 6.3 乐观消息
|
||||
|
||||
用户发送时插入 `metadata.optimistic: true` 的 pending 消息;服务端镜像带相同 `idempotency_key` 时 reducer 原地替换。
|
||||
|
||||
---
|
||||
|
||||
## 7. 消息与 MessageList 复用
|
||||
|
||||
`useReportCollaborationMessages` 将 `CollaborationMessage[]` 转为 `@langchain/langgraph-sdk` 的 `Message[]`,并构造合成 `BaseStream` 供 `MessageList` 消费。
|
||||
|
||||
| 来源 | 渲染 |
|
||||
|------|------|
|
||||
| `human` / `ai` 文本 | 普通气泡 |
|
||||
| `tool_calls` + `tool` | 现有工具卡 |
|
||||
| `team_message` 事件 | 带 `name` 的成员发言 |
|
||||
| 业务卡 | `businessCardSlot`(方案组、命令确认、报告可用) |
|
||||
|
||||
分组键:`core/messages/utils.ts` 中 `assistant:task-report-import` 同级扩展 `assistant:business-card`,判据 `additional_kwargs.report_collaboration_card`。
|
||||
|
||||
### 7.1 在其他页面嵌入 RC(最小集)
|
||||
|
||||
1. `useReportCollaborationSession(sessionId)`
|
||||
2. `ThreadContext.Provider` + `MessageList` + `businessCardSlot`
|
||||
3. 引入 `report-collaboration/styles/report-collaboration.css`
|
||||
|
||||
---
|
||||
|
||||
## 8. 单测
|
||||
|
||||
Mock 驱动(原 `api/mock.ts`)与 `deerflow.rc-mock` / `VITE_RC_MOCK` 开关已移除,前端只请求真实后端;本地演示请直接联调后端。
|
||||
|
||||
单测:
|
||||
|
||||
```bash
|
||||
cd frontend-web
|
||||
pnpm test:report-collaboration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 联调验收清单
|
||||
|
||||
### 9.1 准备
|
||||
|
||||
- [ ] `report_collaboration.enabled: true`
|
||||
- [ ] `report_collaboration.worker_enabled: true`
|
||||
- [ ] 五个 `*_model` 已配置且可用
|
||||
- [ ] Gateway 已重启(MySQL 表/列就绪)
|
||||
- [ ] 前端已登录(JWT 有效)
|
||||
|
||||
### 9.2 探活
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer <token>" \
|
||||
http://127.0.0.1:8001/api/report-collaboration/sessions
|
||||
```
|
||||
|
||||
期望:**200** + `{ "sessions": [...] }`,而非 503。
|
||||
|
||||
### 9.3 页面流程(浏览器 Network)
|
||||
|
||||
1. 打开 `/#/page/workspace/report-collaboration`
|
||||
2. 输入需求 → `POST .../sessions` **200**
|
||||
3. 跳转 → `GET .../sessions/{id}` **200**,响应含 `SessionSnapshot`
|
||||
4. 首条消息 → `POST .../messages` **200**
|
||||
5. 请求方案 → `POST .../plan-requests` **200**
|
||||
6. 采用方案 → `POST .../plans/{id}/select` **200**
|
||||
7. 开始协作 → `POST .../runs` **200**
|
||||
8. `GET .../runs/{id}/stream` 持续收到 SSE(`message.delta`、`node.status.changed` 等)
|
||||
9. 刷新页面 → 消息与 `last_seq` 续接正确
|
||||
10. 运行中输入「重新检索」→ `POST .../commands` → 确认卡 → `confirm` **200**
|
||||
|
||||
### 9.4 常见失败
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
|------|------|------|
|
||||
| 全部 503 | `enabled: false` | 改配置并重启 |
|
||||
| run 一直 queued | `worker_enabled: false` | 改为 true |
|
||||
| 409 选方案 | revision 过期 | 刷新后重选(前端已自动 refresh) |
|
||||
| 429 | 预算/并发上限 | 调大配置或取消其他 run |
|
||||
| 500 + SQL | 表缺列 | 重启 Gateway,查列同步日志 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 契约与类型维护
|
||||
|
||||
- **单一事实源:** `frontend-web/src/report-collaboration/api/types.ts`
|
||||
- **后端镜像:** `offline-backend-20260512/backend/app/report_collaboration/contracts/`
|
||||
- 修改字段须同步两份实施计划与本指南,并跑前后端相关测试。
|
||||
|
||||
`SessionSnapshot` 形状(摘要):
|
||||
|
||||
```typescript
|
||||
interface SessionSnapshot {
|
||||
session: ReportSession;
|
||||
messages: CollaborationMessage[];
|
||||
plans: ReportPlanCandidate[];
|
||||
run: ReportRun | null;
|
||||
nodes: CollaborationNodeState[];
|
||||
agent_runs: AgentRun[];
|
||||
commands: CollaborationCommand[];
|
||||
report_versions: ReportVersion[];
|
||||
last_seq: number;
|
||||
}
|
||||
```
|
||||
|
||||
`ReportRun` 可选字段(RC-BE-016):`usage`、`budget_exhausted_reason`。
|
||||
|
||||
---
|
||||
|
||||
## 11. 后续工作(非对接阻塞)
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| RC-BE-019 | 灰度发布与离线部署文档 |
|
||||
| 侧边栏入口 | 已放开(`workspace-nav-chat-list.tsx` 的「报告协作」项) |
|
||||
| iframe 嵌入 | RC 页尚未专门适配 `embed=1`,可参考现有深链模式扩展 |
|
||||
| 真人盲评 | RC-BE-018 为确定性评测集,上线前仍需计划 §11.4 人工评审 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 相关代码索引
|
||||
|
||||
| 用途 | 路径 |
|
||||
|------|------|
|
||||
| API 客户端 | `frontend-web/src/report-collaboration/api/` |
|
||||
| 会话 Hook | `frontend-web/src/report-collaboration/hooks/useReportCollaborationSession.ts` |
|
||||
| 页面 | `frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx` |
|
||||
| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
|
||||
| 后端路由 | `offline-backend-20260512/backend/app/gateway/routers/report_collaboration.py` |
|
||||
| 配置 | `offline-backend-20260512/backend/config.yaml` → `report_collaboration` |
|
||||
| 持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_collaboration/` |
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,304 +0,0 @@
|
||||
# OpenVibeCoding 内网可视化应用生成平台:分步开发方案
|
||||
|
||||
> 文档状态:待实施
|
||||
>
|
||||
> 目标环境:现有 Ubuntu 22.04 Docker 服务器;对外可用端口限定为 `8020-8030`。
|
||||
>
|
||||
> 本文基于 2026-08-31 的只读巡检编写。未在目标服务器上安装、停止或修改任何服务。
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
将 OpenVibeCoding 改造成一个使用公司内网模型、在公司服务器 Docker 沙箱中生成并运行可视化应用的工作台。用户通过对话生成项目,查看并修改文件,在浏览器中预览运行结果。
|
||||
|
||||
第一期只服务以下项目类型:
|
||||
|
||||
- 数据大屏、数据看板、海报和展示型页面;
|
||||
- React + Vite + TypeScript 前端;
|
||||
- ECharts 图表、流程图;
|
||||
- 可选的轻量 Python FastAPI 接口;
|
||||
- 项目文件浏览、编辑、运行日志和实时预览。
|
||||
|
||||
第一期明确不做:
|
||||
|
||||
- 腾讯云 CloudBase、SCF、TCR、CodeBuddy、云端一键部署;
|
||||
- 任意语言、任意 npm/PyPI 包的无限制安装;
|
||||
- 用户自定义 Dockerfile、Docker Socket 挂载、宿主机命令执行;
|
||||
- 直接将沙箱暴露为独立公网端口;
|
||||
- 对不可信代码提供“绝对安全”的隔离承诺。
|
||||
|
||||
## 2. 已确认的服务器基线
|
||||
|
||||
目标服务器当前具备:
|
||||
|
||||
| 项目 | 巡检结果 | 判断 |
|
||||
|---|---:|---|
|
||||
| 操作系统 | Ubuntu 22.04.5 LTS | 支持 Docker 方案 |
|
||||
| CPU | 32 vCPU | 足够第一期 |
|
||||
| 内存 | 125 GiB;巡检时可用约 106 GiB | 足够第一期 |
|
||||
| 根磁盘 | 2 TB;巡检时可用约 1.6 TB | 足够 Harbor、镜像与工作区 |
|
||||
| Docker | 29.1.3,Docker Compose 2.40.3 | 已满足 |
|
||||
| 负载 | 约 0.5 | 当前有余量 |
|
||||
| `8020-8030` | 巡检时无监听、无 Docker 端口映射 | 可规划使用 |
|
||||
|
||||
该服务器已承载 DeerFlow、Coze、LiteLLM、数据库和其他容器;`80`、`8000`、`8005`、`8006`、`3306`、`5432` 等端口已经被使用。因此,本项目不能占用现有端口、网络、数据库或容器名称。
|
||||
|
||||
该服务器有公网 IP,且已有多项服务绑定在 `0.0.0.0`。本方案将它视作“公网可达的共享主机”处理;云安全组和宿主机防火墙必须额外核验,不能仅凭端口监听结果假设安全。
|
||||
|
||||
## 3. 总体架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U[用户浏览器] -->|:8020| G[App Builder Gateway]
|
||||
U -->|:8022 /preview/<sandboxId>/| P[Preview Router]
|
||||
|
||||
G --> O[OpenVibe Web / Agent Server]
|
||||
O --> M[现有内网模型网关]
|
||||
O --> DB[(独立 PostgreSQL)]
|
||||
O --> R[(独立 Redis)]
|
||||
O --> SM[Local Sandbox Manager]
|
||||
|
||||
SM --> D[Docker Engine]
|
||||
D --> S1[visual-ui Sandbox]
|
||||
D --> S2[visual-fullstack Sandbox]
|
||||
P --> S1
|
||||
P --> S2
|
||||
|
||||
D --> H[Harbor :8023]
|
||||
S1 --> N[内网 pnpm/npm 源或预置离线依赖]
|
||||
S2 --> N
|
||||
```
|
||||
|
||||
设计原则:
|
||||
|
||||
1. 保留 OpenVibe 的对话、任务、文件树、工具卡片和 OpenCode ACP 代码代理体验。
|
||||
2. 模型请求仅由 OpenVibe 服务端发往既有内网模型网关;API Key 不进入沙箱。
|
||||
3. 每个项目运行于独立 Docker 容器和独立工作区;沙箱只运行项目代码,不承担平台控制逻辑。
|
||||
4. 浏览器预览只经过统一预览路由,不为每个项目映射新的公网端口。
|
||||
5. 先用固定模板和固定依赖清单保证稳定,后续再按白名单增加依赖。
|
||||
|
||||
## 4. 端口规划
|
||||
|
||||
端口虽然可用,但应尽量少暴露。除 8020、8022 和按需开放的 8023 外,其余服务只加入 Docker 内部网络。
|
||||
|
||||
| 端口 | 服务 | 绑定范围 | 用途 |
|
||||
|---:|---|---|---|
|
||||
| 8020 | `appbuilder-gateway` | 公网访问面或 VPN 白名单 | 控制台页面、`/api`、SSE/WebSocket |
|
||||
| 8021 | 预留 | 仅 `127.0.0.1` | 平台调试;第一期不对外开放 |
|
||||
| 8022 | `preview-router` | 公网访问面或 VPN 白名单 | 统一预览入口:`/preview/<sandboxId>/` |
|
||||
| 8023 | Harbor | 仅沙箱节点、构建节点和管理员网段 | 私有镜像仓库 HTTPS Registry |
|
||||
| 8024 | 预留 | 不对外开放 | 后续 npm 私服或运维服务 |
|
||||
| 8025-8030 | 预留 | 不对外开放 | 扩展、迁移或故障切换 |
|
||||
|
||||
### 4.1 预览路由规则
|
||||
|
||||
暂时没有可用泛域名时,使用路径型预览:
|
||||
|
||||
```text
|
||||
http(s)://<服务器地址>:8022/preview/<sandboxId>/
|
||||
```
|
||||
|
||||
项目模板的 Vite `base` 和 HMR 路径必须由启动脚本注入为该前缀,确保图片、JS/CSS 资源及热更新不会错误请求到根路径。后续若获得 `*.preview.company.local` 内网泛域名,可切换为子域名预览,用户体验更好。
|
||||
|
||||
## 5. 镜像、模板与离线依赖策略
|
||||
|
||||
### 5.1 标准镜像
|
||||
|
||||
第一期维护两种版本化镜像,不允许用户自行构建 Dockerfile:
|
||||
|
||||
| 镜像 | 用途 | 预置内容 |
|
||||
|---|---|---|
|
||||
| `appbuilder/visual-ui:<version>` | 大屏、海报、纯前端 | Node.js 20、pnpm、React、Vite、TypeScript、ECharts、流程图库、统一 UI 库、Tailwind、图标与中文字体 |
|
||||
| `appbuilder/visual-fullstack:<version>` | 前端加接口 | 上述前端依赖 + Python 3.12、FastAPI、Uvicorn、Pydantic |
|
||||
|
||||
流程图库第一期统一选择一种(建议 React Flow),避免同时生成多种流程图语法和样式。地图、背景图、字体等资源也应作为受版本控制的本地资产打包,不能依赖 Google Fonts 或公网 CDN。
|
||||
|
||||
### 5.2 项目模板
|
||||
|
||||
每次创建项目时从镜像中的模板初始化到 `/workspace`:
|
||||
|
||||
```text
|
||||
templates/
|
||||
visual-screen/ # React + ECharts + React Flow + Mock 数据
|
||||
visual-screen-api/ # visual-screen + FastAPI + /api 反向代理
|
||||
poster-page/ # 海报、营销展示、静态动效
|
||||
```
|
||||
|
||||
`visual-screen-api` 约定:前端只请求相对路径 `/api/*`;预览网关将其转发到同一沙箱的 FastAPI `8000` 端口。没有后端时保留 Mock 数据模块,生成的页面仍可运行。
|
||||
|
||||
### 5.3 依赖预置方式
|
||||
|
||||
不能仅把安装命令写进 Dockerfile 后在用户创建项目时再联网执行。正确做法是:
|
||||
|
||||
1. 在受控构建环境中锁定 `package.json`、`pnpm-lock.yaml`、`requirements.lock`。
|
||||
2. 构建镜像时执行 `pnpm fetch`,在镜像保存只读 pnpm store;Python 包保存到 `/opt/wheelhouse`。
|
||||
3. 沙箱初始化使用 `pnpm install --offline --frozen-lockfile` 和 `pip install --no-index --find-links=/opt/wheelhouse`。
|
||||
4. 第一期开启“固定依赖白名单”:AI 只能使用镜像已安装的依赖;新依赖先由管理员审核、加入锁文件和下一版基础镜像。
|
||||
5. 第二期部署 Verdaccio/Nexus 等内部制品源,作为受控补充而非允许访问公网 npm/PyPI。
|
||||
|
||||
## 6. OpenVibe 改造边界
|
||||
|
||||
### 6.1 保留
|
||||
|
||||
- 前端交互:对话、任务状态、文件浏览与修改、预览、日志;
|
||||
- OpenCode ACP 代码代理;
|
||||
- 项目与会话领域模型;
|
||||
- 代码代理的流式事件协议。
|
||||
|
||||
### 6.2 替换或关闭
|
||||
|
||||
| OpenVibe 当前能力 | 第一期开源/内网实现 |
|
||||
|---|---|
|
||||
| CodeBuddy | 只启用 OpenCode,配置内网 OpenAI 兼容模型网关 |
|
||||
| CloudBase、SCF 沙箱 | 新增 `LocalDockerSandboxManager` |
|
||||
| TCR | Harbor |
|
||||
| CloudBase 环境、用户环境池 | Docker 容器、工作区卷、沙箱记录 |
|
||||
| CloudBase 消息与事件持久化 | 独立 PostgreSQL;Redis 用于临时队列与状态 |
|
||||
| CloudBase 存储、函数、MCP、资源管理后台 | 第一期开关关闭或移除入口 |
|
||||
| CloudBase 项目模板和系统提示词 | 替换为大屏/海报/FastAPI 模板与受控依赖提示词 |
|
||||
|
||||
重点改造点是将 OpenVibe 的 `scf-sandbox-manager` 和 CloudBase 生命周期调用替换为本地 Docker 生命周期。为降低前端和 OpenCode 工具改动范围,新管理器应适配现有的文件、命令、日志、健康检查与工作区信息契约,而不是改写所有 Agent 工具。
|
||||
|
||||
## 7. 沙箱安全基线
|
||||
|
||||
每个沙箱容器必须满足:
|
||||
|
||||
- 非 root 用户运行;禁止 `privileged`,禁止 Docker Socket,禁止宿主机目录挂载;
|
||||
- `--cap-drop=ALL`、`no-new-privileges`、`pids-limit`;
|
||||
- 首期默认限制:`1 vCPU`、`3 GB 内存`、`10 GB 工作区`、60 分钟空闲回收;
|
||||
- 使用独立 Docker network;默认无公网出口,只允许访问预置依赖、必要的内网 DNS 和平台回调;
|
||||
- 数据库、Redis、Harbor 管理接口、宿主机管理端口不在沙箱网络可访问范围;
|
||||
- 运行命令、容器生命周期、文件变更和预览访问写入审计日志;
|
||||
- 平台服务由受限服务账号调用 Docker API;用户代码永远不直接获得 Docker 权限。
|
||||
|
||||
第一期为“内部可信用户的容器隔离”。若后续允许大量非可信代码或文件上传,应迁移为 Kubernetes 节点,并评估 gVisor/Kata 等更强运行时隔离。
|
||||
|
||||
## 8. 分步实施计划
|
||||
|
||||
### 阶段 0:部署前隔离与基线确认
|
||||
|
||||
**工作项**
|
||||
|
||||
1. 为本项目建立独立目录、Docker Compose Project、卷和网络,命名统一为 `appbuilder-*`。
|
||||
2. 确认云安全组、iptables/nftables 规则:仅允许管理员 SSH;8020、8022、8023 仅向 VPN 或指定办公出口开放。
|
||||
3. 不复用当前服务器上已有的 MySQL、PostgreSQL、Redis、Nginx 配置;建立独立实例或独立命名空间。
|
||||
4. 创建内部 CA 证书或确定可信 HTTPS 证书方案;Docker 构建/运行节点信任 Harbor 证书。
|
||||
5. 轮换目标服务器 root 密码,改用 SSH 密钥和受限运维账号;关闭 root 密码远程登录。
|
||||
|
||||
**验收**
|
||||
|
||||
- `8020-8030` 无端口冲突;
|
||||
- 新容器、网络和数据卷均不影响既有 DeerFlow 等服务;
|
||||
- 业务数据库和沙箱端口无法从非授权公网地址访问。
|
||||
|
||||
### 阶段 1:内网基础设施与镜像供应链
|
||||
|
||||
**工作项**
|
||||
|
||||
1. 在独立 Compose 项目中部署 Harbor,主入口为 `8023`,镜像数据使用独立持久化目录。
|
||||
2. 构建并推送 `visual-ui`、`visual-fullstack` 两种基础镜像;记录镜像版本、SBOM 和依赖锁文件。
|
||||
3. 完成三个模板及离线依赖初始化脚本。
|
||||
4. 先不启动漏洞扫描;严格断网环境下后续通过离线包维护漏洞库。
|
||||
5. 预留内部 npm/PyPI 源接入方式;第一期仅使用预置依赖。
|
||||
|
||||
**验收**
|
||||
|
||||
- Docker daemon 能从 Harbor 拉取指定版本镜像;
|
||||
- 在禁止公网访问的测试网络中,三个模板均能启动并构建;
|
||||
- 一个 ECharts 页面、一个流程图页面和一个 FastAPI `/api/health` 均可运行。
|
||||
|
||||
### 阶段 2:OpenVibe 去 CloudBase 化与内网模型接入
|
||||
|
||||
**工作项**
|
||||
|
||||
1. Fork 固定版本的 OpenVibeCoding,建立 `local-platform` 改造分支,记录上游 commit 与许可证。
|
||||
2. 只保留 OpenCode Agent;将 provider 指向现有内网模型网关,模型凭证只保存在服务端环境变量或密钥服务。
|
||||
3. 将基础实体、任务、消息、流式事件的持久化迁移为独立 PostgreSQL;不再调用 CloudBase 集合。
|
||||
4. 关闭 CloudBase 环境、函数、存储、数据库、资源仪表盘和相关 MCP 入口。
|
||||
5. 将系统提示词和项目创建默认项改为“可视化页面 + 受控模板/依赖”。
|
||||
|
||||
**验收**
|
||||
|
||||
- 不设置任何腾讯云、CloudBase、CodeBuddy、TCR 环境变量时,平台可启动;
|
||||
- 用户发起“生成 ECharts 大屏”后,OpenCode 能调用内网模型并生成项目文件;
|
||||
- 服务端日志和沙箱环境中均没有模型 API Key。
|
||||
|
||||
### 阶段 3:本地沙箱管理器与预览路由
|
||||
|
||||
**工作项**
|
||||
|
||||
1. 实现 `LocalDockerSandboxManager`:创建、启动、停止、超时回收、查询状态和删除容器。
|
||||
2. 每个沙箱分配工作区卷、随机内部容器地址、模板类型和资源限制;在数据库记录 `sandbox_id`、项目、用户、镜像版本、过期时间和状态。
|
||||
3. 实现或适配沙箱内 runner:文件读写、受限命令执行、日志流、健康检查、前端端口和 FastAPI 端口发现。
|
||||
4. 实现 `preview-router`:由平台根据 `sandboxId` 维护路由,将 `/preview/<sandboxId>/` 代理到对应内部容器;支持 WebSocket/HMR。
|
||||
5. 增加容器异常退出、端口未就绪、构建失败、超时与手动重启的可见状态。
|
||||
|
||||
**验收**
|
||||
|
||||
- 两个用户创建的项目存在于不同容器和不同工作区;
|
||||
- 修改 ECharts 配置文件后,预览地址显示修改结果;
|
||||
- FastAPI 模板的 `/api/health` 可通过前端相对路径访问;
|
||||
- 沙箱不能访问 Docker Socket、宿主机数据库和非白名单网络;
|
||||
- 空闲超时后容器被回收,项目文件仍可恢复。
|
||||
|
||||
### 阶段 4:产品能力收口
|
||||
|
||||
**工作项**
|
||||
|
||||
1. 在文件面板支持安全的文件查看、编辑、保存、目录树刷新与下载;限制文件大小和可编辑扩展名。
|
||||
2. 在对话中增加模板选择和能力提示:大屏、流程图、海报、可选 FastAPI。
|
||||
3. 为 Agent 增加依赖白名单、图表主题、中文字体、Mock 数据、接口契约等明确提示词。
|
||||
4. 增加“运行/停止/重启/删除沙箱”和日志面板;仅项目所有者或管理员有相应权限。
|
||||
5. 增加项目快照、模板版本记录和基础审计日志。
|
||||
|
||||
**验收**
|
||||
|
||||
- 从一句自然语言需求到可预览项目的完整链路可完成;
|
||||
- 用户可查看并手动修改文件,刷新后改动保留;
|
||||
- 页面覆盖 ECharts、流程图、响应式大屏和可选 FastAPI 接口;
|
||||
- 非拥有者不能读取、修改或预览另一用户的项目。
|
||||
|
||||
### 阶段 5:上线前安全与运行验收
|
||||
|
||||
**工作项**
|
||||
|
||||
1. 压测 6 个并发沙箱,并记录 CPU、内存、磁盘、容器启动时间、首屏预览时间和模型等待时间。
|
||||
2. 验证镜像保留策略、工作区配额、容器回收、数据库备份和 Harbor 备份恢复。
|
||||
3. 执行越权、路径穿越、命令注入、网络探测、资源耗尽和模型密钥泄露测试。
|
||||
4. 建立日常运维操作:新增基础依赖、构建镜像、发布模板、回滚镜像、清理工作区和审计查询。
|
||||
|
||||
**验收**
|
||||
|
||||
- 并发运行时既有 DeerFlow 等服务不受明显影响;
|
||||
- 任一沙箱异常退出不会影响其他项目或平台控制服务;
|
||||
- 备份可恢复项目元数据、工作区和 Harbor 镜像;
|
||||
- 仅开放计划中的入口端口,内部组件无不必要公网监听。
|
||||
|
||||
## 9. 首期资源与并发建议
|
||||
|
||||
由于该主机已有多项业务,首期不应按理论最大值分配:
|
||||
|
||||
| 组件 | 首期限制 |
|
||||
|---|---|
|
||||
| Harbor | 8 vCPU、16 GB 内存、500 GB 数据盘配额 |
|
||||
| OpenVibe 控制服务 | 4 vCPU、8-16 GB 内存 |
|
||||
| 单个 UI 沙箱 | 1 vCPU、3 GB 内存、10 GB 工作区 |
|
||||
| 单个 Fullstack 沙箱 | 1.5 vCPU、4 GB 内存、12 GB 工作区 |
|
||||
| 沙箱并发 | 先限制为 6;稳定后再逐步上调 |
|
||||
|
||||
当并发超过 10 个,或要面向更广泛用户开放时,应优先增加独立沙箱节点,而不是继续在本共享主机上加容器。
|
||||
|
||||
## 10. 实施顺序与交付物
|
||||
|
||||
建议按以下提交/交付顺序推进,每一步都可独立验收和回退:
|
||||
|
||||
1. `docs: confirm appbuilder network, ports and security baseline`
|
||||
2. `infra: add Harbor and versioned visual sandbox images`
|
||||
3. `infra: add offline templates and dependency locks`
|
||||
4. `feat: replace OpenVibe CloudBase persistence and model provider`
|
||||
5. `feat: add local Docker sandbox manager`
|
||||
6. `feat: add sandbox runner and path-based preview router`
|
||||
7. `feat: add visual-screen and FastAPI project workflows`
|
||||
8. `test: add sandbox isolation, preview and recovery coverage`
|
||||
9. `ops: add backup, cleanup, monitoring and rollout runbook`
|
||||
|
||||
达到阶段 3 即可交付“内网模型驱动、生成并预览大屏/海报页面”的 PoC;达到阶段 5 后再作为受控的内部多人平台发布。
|
||||
@@ -8,7 +8,7 @@ VITE_USE_REAL_DATA=true
|
||||
VITE_MOCK_COP_TASK_DETAIL=true
|
||||
|
||||
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。
|
||||
VITE_MOCK_SQ_REPORT=false
|
||||
VITE_MOCK_SQ_REPORT=true
|
||||
|
||||
# 深链任务详情(cop-task-detail:任务名称/描述)数据源:true 走本地假数据(真实接口未就绪时调试用);联调通过后置 false。
|
||||
VITE_MOCK_COP_TASK_DETAIL=true
|
||||
@@ -17,9 +17,6 @@ VITE_MOCK_COP_TASK_DETAIL=true
|
||||
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
|
||||
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
||||
|
||||
# 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。
|
||||
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||
# CMZS后端接口
|
||||
|
||||
@@ -56,16 +56,4 @@
|
||||
# VITE_STATIC_WEBSITE_ONLY=
|
||||
# 部署级紧凑导航:true 时固定收起工作台左侧栏,并隐藏换肤与全量/简洁模式切换。
|
||||
# VITE_FORCE_COLLAPSED_SIDEBAR=false
|
||||
# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。
|
||||
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。
|
||||
# VITE_COMPACT_MENU_TYPE=A
|
||||
# 问答页「极速」模式:true=在模式菜单显示并直连大模型;false 或未设=隐藏。
|
||||
# 部署后优先改 dist/runtime-config.js 的 VITE_TURBO_MODE_VISIBLE,刷新即可。
|
||||
# VITE_TURBO_MODE_VISIBLE=false
|
||||
# VITE_GITHUB_OAUTH_TOKEN=
|
||||
|
||||
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
|
||||
# true 时,普通登录后进入同一 Vite 应用中隔离的 /official/*。
|
||||
# 已指定 redirect 或 taskId/goPath 的登录仍保持原来的优先级。
|
||||
# 仅 .env.development 默认 true;其余环境保持 false。
|
||||
# VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
|
||||
@@ -17,9 +17,6 @@ VITE_MOCK_COP_TASK_DETAIL=false
|
||||
VITE_BACKEND_BASE_URL=http://10.19.200.5:2026
|
||||
VITE_LANGGRAPH_BASE_URL=http://10.19.200.5:2026/api
|
||||
|
||||
# 内网环境保持当前定制版默认入口。
|
||||
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||
# CMZS后端接口
|
||||
|
||||
@@ -14,9 +14,6 @@ VITE_MOCK_SQ_REPORT=false
|
||||
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
|
||||
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
|
||||
|
||||
# 线上保持当前定制版默认入口。
|
||||
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
|
||||
# CMZS后端接口
|
||||
|
||||
@@ -17,9 +17,6 @@ VITE_MOCK_COP_TASK_DETAIL=false
|
||||
VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow
|
||||
VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api
|
||||
|
||||
# SIT 环境保持当前定制版默认入口。
|
||||
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
|
||||
# CMZS后端接口
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,198 @@
|
||||
# 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] 任何人可根据本文档判断新版是否「像原页面」。
|
||||
@@ -0,0 +1,680 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## 阶段 4 实施记录(当前代码基线)
|
||||
|
||||
本轮完成阶段 4(R3-Q:席位任务合同、总控验收、打回/改派/受控缺口、阶段质量闸门)的服务端实现。范围仅限 V2 structured 派活(`dispatch_contract_enabled` 且 `quality_gate_enabled`);旧 DeerFlow、旧 roundtable、AI 写作、group_chat 与 quality 关闭的 V2 路径行为不变。
|
||||
|
||||
### 纯策略层(`app/maf_roundtable/quality_contracts.py`,无 I/O、无 SDK)
|
||||
|
||||
- `MafTaskContract`:objective / expected_deliverable / acceptance_criteria / required / max_rework_attempts(默认 2)/ fallback_policy(`reassign_then_gap` / `controlled_gap` / `ask_user`),由校验后的派发 assignment 服务端冻结。
|
||||
- 确定性检查 `check_delivery_deterministic`:empty_content、schema_invalid、missing_conclusion、below_min_length、missing_evidence、duplicate_content;任一失败即不可 accept(总控模型自评不是验收证据)。
|
||||
- 总控验收 schema `MafManagerReviewOutput`(decision ∈ accept / return_for_rework / reassign / ask_user / controlled_gap + reason + revision_requirements),`finalize_review_decision` 服务端终审:确定性失败永不 accept;返工受合同上限约束;改派受 `max_reassignments` 约束;耗尽后只能 `controlled_gap`(带风险标记)或 `ask_user` 暂停,绝不伪装成功;总控输出缺失/非法时确定性降级。
|
||||
|
||||
### 持久化
|
||||
|
||||
- 新表 `maf_roundtable_seat_tasks`(`model.py` + `seat_tasks.py` + store facade 注册):task_key 稳定(`NN:agent_id`),改派只变 `agent_id` 不新建任务;`(run_id, task_key)` 唯一;状态机 CHECK(assigned/delivering/submitted/manager_review/accepted/rework/reassigned/exhausted/controlled_gap)。
|
||||
- `maf_roundtable_seat_runs.execution_stage` 闭合枚举新增 `rework`、`manager_review`(seat_runs 常量 + ORM CHECK + 迁移脚本同步)。
|
||||
- 迁移脚本 `scripts/migrate_maf_roundtable_stage4.py`(建表 + SQLite 重建 seat_runs 以放宽 CHECK,PG/MySQL drop+add,幂等,`validate_schema`)。
|
||||
|
||||
### 执行器与接线
|
||||
|
||||
- `QualitySeatTasksExecutor`(id=`quality_seat_tasks`)替换 V2 structured 的席位 fan-out:有界质量轮(初稿 + 最多 2 次返工 + 每次改派 1 轮),每轮「交付(复用 SeatExecutor,静默 ctx 防消息重入图)→ 确定性检查 → 批处理总控验收 → 服务端终审落库」,`ask_user` 置 `waiting_input`;改派席位须为服务端候选且未占用其他任务(一个席位同时只承担一个任务,避免四元组冲突)。
|
||||
- `QualityGateExecutor`(id=`quality_gate`):所有 required 任务达到 accepted / controlled_gap 终态才放行(`passed` / `passed_with_gaps`);非终态 required 任务确定性阻塞(硬失败安全网);`waiting_input` 路由到 `WaitingInputTerminalExecutor`(不跑 cross_review/finalizer)。
|
||||
- 最终报告:受控缺口注入综合 prompt 的「风险/待补信息」章节并逐条要求不得写成已验证事实;`final_result.quality_decision` 有缺口即为 `controlled_gap`,`final_result.controlled_gaps` 可追溯。
|
||||
- 配置:`quality_gate_enabled`(默认 True,V2 会话可用 `maf_v2.quality_gate_enabled` 显式关闭)、`max_rework_attempts`、`max_reassignments`、`min_delivery_chars`、`min_delivery_evidence`、`default_fallback_policy`。
|
||||
- 事件:`seat.delivery.submitted`、`manager.review.decision`(带 task_key/attempt/source)、`seat.rework.assigned`、`seat.reassigned`、`stage.quality_gate`;conversation 投影白名单与安全键已扩展。
|
||||
|
||||
### 测试
|
||||
|
||||
- `tests/test_maf_quality_contracts.py`(34 例纯策略:合同冻结、确定性检查、验收解析、终审规则矩阵)。
|
||||
- `tests/test_maf_phase4_quality_gate.py`(6 例 Runner 级端到端:好交付接受、短答→2 返工→1 改派→受控缺口(含 DB/事件/最终报告断言)、ask_user→waiting_input、质量闸门阻塞、缺失验收的确定性降级、事件落库)。
|
||||
- `tests/test_maf_migration_stage4.py`(3 例迁移:建表+CHECK 放宽、幂等、旧数据保留、新阶段可写入)。
|
||||
- 回归:全量 MAF 测试 459 通过;`ruff check` 全绿。
|
||||
|
||||
仍按阶段 5/8 前置条件保持阻塞:真实 UAT 写入系统、最小权限凭据、外部核验 API、补偿协议和生产 `MAF_ACTION_SECRET_KEY`。前端对话页的质量反馈(派活卡显示尝试次数、「总控审核中」、打回/改派/缺口卡片、席位条状态区分)属阶段 7 的前端工作,尚未实施。
|
||||
@@ -1,15 +0,0 @@
|
||||
# 岗位会商沙箱文档
|
||||
|
||||
本目录存放**岗位会商文件预览沙箱**的对接与排障说明,独立于 `src/position-roundtable/docs/`,避免与模块内其它文档互相覆盖。
|
||||
|
||||
| 文档 | 说明 |
|
||||
| --- | --- |
|
||||
| [沙箱打开关闭与步骤条.md](./沙箱打开关闭与步骤条.md) | **详细版**:打开/关闭完整时序、步骤条关系、对接接口字段说明、必守规则、历史 bug 根因、排障手册、自测清单、接线示例与代码索引 |
|
||||
|
||||
模块总览仍见:`frontend-web/src/position-roundtable/README.md`。
|
||||
|
||||
建议读法:
|
||||
|
||||
- **从零对接新页面**:正文第 3、5、6、7、8、12、15、16 节
|
||||
- **线上排障**:第 13、14 节
|
||||
- **只要一页纸**:文末「附录 A」
|
||||
@@ -1,988 +0,0 @@
|
||||
# 岗位会商 · 沙箱打开 / 关闭与步骤条(详细对接与排障指南)
|
||||
|
||||
> **文档位置**:`frontend-web/docs/position-roundtable-sandbox/`
|
||||
> **读者**:下次重做或对接岗位会商页面的同事;线上「沙箱不实时 / 卡死 / 第二次不刷新」时的排障同学。
|
||||
> **最后依据**:当前仓库 `PositionRoundtablePage` + `PositionNodeChat` 实现(含临时名打开、外部 store、前沿节流、按 tool-call 重挂)。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [文档用途与读法](#1-文档用途与读法)
|
||||
2. [背景:为什么岗位会商要单独做沙箱](#2-背景为什么岗位会商要单独做沙箱)
|
||||
3. [用户可见的正确行为(验收标准)](#3-用户可见的正确行为验收标准)
|
||||
4. [整体架构与数据流](#4-整体架构与数据流)
|
||||
5. [职责划分:谁拥有什么状态](#5-职责划分谁拥有什么状态)
|
||||
6. [对接接口详解](#6-对接接口详解)
|
||||
7. [打开沙箱的完整步骤](#7-打开沙箱的完整步骤)
|
||||
8. [关闭沙箱的完整步骤](#8-关闭沙箱的完整步骤)
|
||||
9. [步骤条与沙箱的关系](#9-步骤条与沙箱的关系)
|
||||
10. [流式内容如何进入沙箱(性能关键)](#10-流式内容如何进入沙箱性能关键)
|
||||
11. [与普通对话页的对照](#11-与普通对话页的对照)
|
||||
12. [重做页面时的必守规则](#12-重做页面时的必守规则)
|
||||
13. [历史问题与根因(务必读)](#13-历史问题与根因务必读)
|
||||
14. [排障手册(按症状)](#14-排障手册按症状)
|
||||
15. [对接自测清单](#15-对接自测清单)
|
||||
16. [最小接线示例](#16-最小接线示例)
|
||||
17. [关键代码索引](#17-关键代码索引)
|
||||
18. [术语表](#18-术语表)
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档用途与读法
|
||||
|
||||
### 1.1 这份文档解决什么问题
|
||||
|
||||
岗位会商右侧「文件预览沙箱」看起来像普通对话页的产物预览,但实现路径完全不同。历史上这块反复出过:
|
||||
|
||||
- 沙箱不实时打开,要等全文写完才弹;
|
||||
- 打开了但内容冻住,生成完才一口气灌进去;
|
||||
- 整页卡顿,连消息列表文字都不实时;
|
||||
- 同轮「重新生成」第二次写入时,步骤条变了,沙箱却不动;
|
||||
- 第一次正常,关掉后再问就不实时。
|
||||
|
||||
这些 bug **特别容易在「重做页面 / 换布局 / 自己重写开沙箱逻辑」时复现**。本文把正确对接方式、时序、接口、禁区和排障步骤写全,方便下一位同事直接按文档接线,而不是再靠猜。
|
||||
|
||||
### 1.2 建议读法
|
||||
|
||||
| 你的目标 | 先读哪些章节 |
|
||||
| --- | --- |
|
||||
| 从零接一个新页面壳 | 第 3、5、6、7、8、12、15、16 节 |
|
||||
| 只改对话子组件探测逻辑 | 第 7、9、12、13 节 |
|
||||
| 线上排障 | 第 13、14 节,必要时回看第 10 节 |
|
||||
| 理解为什么不能用页面 state | 第 2、10、13 节 |
|
||||
|
||||
### 1.3 对照代码(以仓库为准)
|
||||
|
||||
| 角色 | 路径 |
|
||||
| --- | --- |
|
||||
| 页面壳(唯一沙箱宿主) | `frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx` |
|
||||
| 节点对话(探测 write_file、上报预览) | `frontend-web/src/position-roundtable/components/PositionNodeChat.tsx` |
|
||||
| 收口对话 | `frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx` |
|
||||
| 预览 UI(共享组件,勿分叉逻辑) | `frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx` |
|
||||
| 流式正文读取 | `frontend-web/src/core/artifacts/loader.ts` → `loadArtifactContentFromToolCall` |
|
||||
| 普通对话页对照(步骤条自动开) | `frontend-web/src/components/workspace/messages/message-group.tsx` |
|
||||
| 模块总览 | `frontend-web/src/position-roundtable/README.md` |
|
||||
|
||||
---
|
||||
|
||||
## 2. 背景:为什么岗位会商要单独做沙箱
|
||||
|
||||
### 2.1 普通对话页怎么开沙箱
|
||||
|
||||
普通对话页(`ChatPage`)大致是:
|
||||
|
||||
```text
|
||||
ArtifactsProvider(页面级)
|
||||
└─ ChatBox / MessageList
|
||||
└─ message-group 渲染 write_file 步骤
|
||||
└─ 发现 path 且 autoOpen → artifacts.select + setOpen(true)
|
||||
└─ 右侧 ArtifactFileDetail 读同一套 ThreadContext
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- 对话和预览共享同一套 `ThreadContext`(同一个 `useThreadStream`)。
|
||||
- 步骤条组件自己就能触发打开。
|
||||
- 流式 `content` 本来就在当前 React 树的 thread 里,不需要「把 thread 镜像到别的地方」。
|
||||
|
||||
### 2.2 岗位会商为什么不能照搬
|
||||
|
||||
岗位会商页面同时有:
|
||||
|
||||
- 左侧任务 / 意图 / 流程图;
|
||||
- 中间某个节点的对话(`PositionNodeChat`);
|
||||
- 右侧配置抽屉;
|
||||
- **以及一个页面级单例沙箱**,它是对话区的**兄弟节点**,不在 `PositionNodeChat` 的 ThreadContext 里面。
|
||||
|
||||
因此:
|
||||
|
||||
1. 沙箱拿不到节点对话内部的 `thread`,除非节点主动上报;
|
||||
2. 若把每次流式 thread 写进**页面级 `useState`**,整页(任务卡、意图、流程图、对话)都会按流式频率重渲染,主线程会被打爆;
|
||||
3. 页面必须自己决定「开 / 关 / 抑制自动再开 / 切节点时清理」,不能让多个子组件各自 `setOpen`。
|
||||
|
||||
所以岗位会商采用:
|
||||
|
||||
```text
|
||||
对话子组件探测 + 上报
|
||||
→ 页面壳统一打开唯一 ArtifactFileDetail
|
||||
→ 流式 thread 走外部 store,只让沙箱子树订阅
|
||||
```
|
||||
|
||||
### 2.3 和「多智能体会商 Step2 沙箱」的区别
|
||||
|
||||
仓库里还有 `frontend-web/docs/roundtable-step2-sandbox-progress.md`,那是另一套圆桌 Step2 沙箱进度文档,**不是**岗位会商这条链路。对接岗位会商时以本文为准。
|
||||
|
||||
---
|
||||
|
||||
## 3. 用户可见的正确行为(验收标准)
|
||||
|
||||
重做或对接完成后,下列行为必须全部成立(用户感知对齐普通对话页):
|
||||
|
||||
| # | 行为 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| A | 步骤条刚出现「写入文件」时,沙箱应已打开 | **不必等**步骤条上出现文件名 |
|
||||
| B | 沙箱源码边写边滚;消息列表也实时出字 | 不能整页卡死、不能「生成完一口气刷」 |
|
||||
| C | content 先于 path 时:先临时名打开,path 到了再改名 | 不闪屏、不重挂 |
|
||||
| D | 同轮第二次同路径 `write_file`(重新生成) | 沙箱清空并重新流式 |
|
||||
| E | 整轮结束后自动切 Markdown 预览 | 「正在生成,完成后自动预览」消失 |
|
||||
| F | 用户手动关闭后,同一次写入不再自动弹 | 下一轮新写入仍可自动开 |
|
||||
| G | 切岗位 / 切节点 / 新会话 / 切历史 | 沙箱关掉且不串内容 |
|
||||
|
||||
**不要误判的正常现象:**
|
||||
|
||||
- 流式期间沙箱停在「源码 / 编辑」视图,结束后才切「预览」——这是 `ArtifactFileDetail` 对 markdown/html 的既定行为,与普通对话页相同,**不是**打开时机 bug。
|
||||
- 步骤条长时间显示「写入文件」、没有路径,但沙箱已经打开并在滚动——content-first 模型下这是**预期**。
|
||||
|
||||
---
|
||||
|
||||
## 4. 整体架构与数据流
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ PositionRoundtablePage(页面壳) │
|
||||
│ │
|
||||
│ state: sandboxArtifact | null │
|
||||
│ ref: sandboxArtifactRef / closedSandboxKeyRef / latestSnapshotRef │
|
||||
│ store: createSandboxThreadStore() ←── 禁止改成 useState │
|
||||
│ │
|
||||
│ handlePreviewArtifact(artifact) ←──────────────────────────────────┐ │
|
||||
│ handleNodeThreadSnapshot(...) ←───────────────────────────────┐ │ │
|
||||
│ closeSandbox() │ │ │
|
||||
│ │ │ │
|
||||
│ {sandboxOpen && ( │ │ │
|
||||
│ SandboxThreadProvider(store) │ │ │
|
||||
│ └─ ArtifactFileDetail key=sandboxCloseKey(...) │ │ │
|
||||
│ )} │ │ │
|
||||
└───────────────────────────────────────────────────────────────────│──│───┘
|
||||
│ │
|
||||
┌───────────────────────────────────────────────────────────────────│──│───┐
|
||||
│ PositionNodeChat / PositionBuiltinAgentChat │ │ │
|
||||
│ │ │ │
|
||||
│ useThreadStream → thread.messages / thread.isLoading │ │ │
|
||||
│ │ │ │ │
|
||||
│ ├─ MessageList → 步骤条(只展示 tool call) │ │ │
|
||||
│ │ │ │ │
|
||||
│ ├─ latestStreamingWriteFile(messages) │ │ │
|
||||
│ │ → onPreviewArtifact({..., auto:true}) ───────────────────┘ │
|
||||
│ │ │
|
||||
│ └─ useEffect → onThreadSnapshot({threadId, thread}) ───────────────┘
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
一句话:
|
||||
|
||||
- **谁打开**:对话子组件决定「该不该开、开哪次交付」;
|
||||
- **谁真正开面板**:页面壳;
|
||||
- **内容怎么更新**:子组件持续上报 thread 快照 → 页面壳 store → 只有沙箱子树重渲染。
|
||||
|
||||
---
|
||||
|
||||
## 5. 职责划分:谁拥有什么状态
|
||||
|
||||
### 5.1 页面壳必须拥有
|
||||
|
||||
| 状态 / 能力 | 为什么必须在页面壳 |
|
||||
| --- | --- |
|
||||
| `sandboxArtifact` | 唯一预览目标;切节点时要清 |
|
||||
| `closedSandboxKeyRef` | 用户关过后的抑制;跨子组件生命周期 |
|
||||
| `sandboxThreadStore` | 流式内容通道;不能进大页面 state |
|
||||
| `closeSandbox()` | 切岗位 / 切节点 / 新会话统一入口 |
|
||||
| 唯一 `ArtifactFileDetail` | 避免双实例、关闭失效、深度异常 |
|
||||
|
||||
### 5.2 对话子组件必须拥有
|
||||
|
||||
| 状态 / 能力 | 作用 |
|
||||
| --- | --- |
|
||||
| `autoPreviewRunArmedRef`(armed) | 只有本轮用户发送后才允许自动开 |
|
||||
| `autoPreviewBaselineCallKeysRef`(baseline) | 忽略发送前已有的历史 write_file |
|
||||
| `activeWriteFilePreviewRef` | 记录当前打开的 previewKey / path / toolCallId |
|
||||
| `autoPreviewedArtifactKeyRef` | 同一次 identity 去重,避免每 chunk 重复上报 |
|
||||
| `latestStreamingWriteFile` | 从 streaming messages 探测可预览的 write |
|
||||
| `onThreadSnapshot` 上报 | 把 thread 喂给页面壳 |
|
||||
|
||||
### 5.3 明确禁止
|
||||
|
||||
1. 在 `PositionNodeChat` 内再挂一套 `ArtifactFileDetail`。
|
||||
2. 让步骤条组件直接 `setOpen(true)` 作为岗位会商主路径(对照普通页可以,岗位会商不行)。
|
||||
3. 把 `onThreadSnapshot` 落到 `PositionRoundtablePage` 的 `useState`。
|
||||
4. 用「整个 threadId」做关闭抑制。
|
||||
5. 用「path 相同」复用 `previewKey`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 对接接口详解
|
||||
|
||||
### 6.1 `PositionArtifactPreview`
|
||||
|
||||
```ts
|
||||
type PositionArtifactPreview = {
|
||||
threadId: string; // 必填:LangGraph thread id
|
||||
path: string; // 必填:真实路径,或 write-file: 虚拟 URL,或临时文件名路径
|
||||
name: string; // 展示名(文件名)
|
||||
mimeType: string | null; // 如 text/markdown;帮助预览语言判断
|
||||
previewKey?: string; // 强烈建议:一次交付身份,推荐 `${messageId}:${toolCallId}`
|
||||
auto?: boolean; // true = 流式自动打开;缺省/false = 用户点击打开
|
||||
};
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 详细含义 |
|
||||
| --- | --- |
|
||||
| `threadId` | 沙箱用来匹配 `onThreadSnapshot` 的线程;也对关闭抑制键有贡献 |
|
||||
| `path` | 传给 `ArtifactFileDetail.filepath`。流式阶段通常是 `write-file:...` 虚拟 URL;落盘后的手动打开可以是真实 artifacts 路径 |
|
||||
| `name` | UI 展示用;临时名阶段可能是 `未命名文件.md` |
|
||||
| `mimeType` | 当 path 还不稳定时,帮助把语言判成 markdown/html,避免落成 TEXT |
|
||||
| `previewKey` | **一次交付的身份**。同一次 tool call 的 path 升级要保持不变;新的 tool call 必须变 |
|
||||
| `auto` | 自动打开才走关闭抑制;手动打开应清抑制 |
|
||||
|
||||
### 6.2 `onPreviewArtifact(artifact)` —— 对话 → 页面
|
||||
|
||||
页面壳 `handlePreviewArtifact` 当前逻辑顺序:
|
||||
|
||||
1. 校验 `threadId`、`path`,缺则 toast 并 return。
|
||||
2. 计算 `artifactKey = sandboxCloseKey(artifact)`。
|
||||
3. 若 `artifact.auto === true` 且 `closedSandboxKeyRef.current === artifactKey` → **抑制,直接 return**。
|
||||
4. 清 `closedSandboxKeyRef`(手动打开或新的自动打开)。
|
||||
5. `ArtifactsContext.select(path, true)` + `setOpen(true)`。
|
||||
6. 判断是否同一预览 / 是否仅 path upgrade:
|
||||
- `isSamePreview`:threadId + path + previewKey 全同 → 不重复 setState。
|
||||
- `isPathUpgrade`:previewKey 相同、path 不同 → 更新 artifact,**不要**再折叠右侧抽屉。
|
||||
7. 若不是 same preview:
|
||||
- `setSandboxArtifact(artifact)`;
|
||||
- **同步** `sandboxArtifactRef.current = artifact`(关键!flush 会立刻读 ref);
|
||||
- `sandboxSnapshotAppliedAtRef.current = 0`(允许立刻推首帧内容)。
|
||||
8. 若 store 里已有别的 thread 快照,先 `publish(null)`。
|
||||
9. 若 `latestNodeThreadSnapshotRef` 的 threadId 匹配,立刻 `queueSandboxThreadSnapshot`。
|
||||
10. 非 path upgrade 时 `setRightPanelOpen(false)`。
|
||||
|
||||
`sandboxCloseKey`:
|
||||
|
||||
```ts
|
||||
if (artifact.previewKey) {
|
||||
return `${artifact.threadId}:write:${artifact.previewKey}`;
|
||||
}
|
||||
return `${artifact.threadId}:${normalizePreviewArtifactPath(artifact.path)}`;
|
||||
```
|
||||
|
||||
`ArtifactFileDetail` 的 React `key` 必须用这个 key:
|
||||
**新 previewKey → 组件重挂 → 视觉上「清空再生成」。**
|
||||
|
||||
### 6.3 `onThreadSnapshot({ threadId, thread })` —— 对话 → 页面
|
||||
|
||||
每次 `thread` 引用变化(消息 chunk 到达)子组件都会调:
|
||||
|
||||
```ts
|
||||
onThreadSnapshot?.({ threadId: activeThreadId, thread });
|
||||
```
|
||||
|
||||
页面壳应:
|
||||
|
||||
1. 计算 `signature = sandboxThreadSignature(thread)`(用 isLoading、messages 长度、最近 write_file 的 path/content 长度/尾部等拼签名,用于去重)。
|
||||
2. 写入 `latestNodeThreadSnapshotRef`(即使沙箱还没开,打开瞬间也要用)。
|
||||
3. 若当前没有打开的沙箱,或打开的沙箱 threadId 不一致 → **只更新 ref,不 publish**。
|
||||
4. 若一致 → `queueSandboxThreadSnapshot`(前沿节流后 `store.publish`)。
|
||||
|
||||
### 6.4 虚拟 URL 与正文读取
|
||||
|
||||
流式阶段 path 形如:
|
||||
|
||||
```text
|
||||
write-file:${sourcePath}?message_id=${messageId}&tool_call_id=${toolCallId}&language=markdown
|
||||
```
|
||||
|
||||
`loadArtifactContentFromToolCall`:
|
||||
|
||||
1. 解析 URL 的 `message_id`、`tool_call_id`;
|
||||
2. 在 `thread.messages` 里找对应 AI message 的 tool call;
|
||||
3. 返回 `toolCall.args.content`。
|
||||
|
||||
结论:
|
||||
|
||||
- 沙箱跟的是 **tool call 身份**,不是磁盘文件是否已经写完;
|
||||
- 没有真实 path 也能显示正文;
|
||||
- 临时文件名只影响展示名和语言猜测。
|
||||
|
||||
### 6.5 对话子组件 → 页面壳的其它相关回调
|
||||
|
||||
| 回调 | 用途 |
|
||||
| --- | --- |
|
||||
| `onPreviewArtifact` | 打开 / 更新沙箱 |
|
||||
| `onThreadSnapshot` | 推送流式 thread |
|
||||
| 页面壳 `closeSandbox` 传给 `ArtifactFileDetail.onClose` | 用户点关闭 |
|
||||
|
||||
手动从流程图 / 产物列表打开时,也可以直接调 `handlePreviewArtifact`(通常不带 `auto`,或不带 previewKey)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 打开沙箱的完整步骤
|
||||
|
||||
### 7.1 武装(arm):只有「本轮用户提交」才允许自动开
|
||||
|
||||
在 `PositionNodeChat.handleSubmit` 真正 `sendMessage` 之前:
|
||||
|
||||
```text
|
||||
activeWriteFilePreviewRef = null
|
||||
autoPreviewBaselineCallKeysRef = writeFileCallKeys(thread.messages) // 历史 write 的 messageId:toolCallId
|
||||
autoPreviewRunArmedRef = true
|
||||
→ sendMessage(...)
|
||||
```
|
||||
|
||||
发送失败要把 `armed` 设回 `false`。
|
||||
|
||||
为什么要 baseline?
|
||||
|
||||
线程里往往已经有上几次问答写过的 `write_file`。如果只看「messages 里有没有 write_file」,一提问就会把旧文件再打开一次。baseline 的含义是:**只有这次提交之后新出现的 tool call 才能自动开沙箱**。
|
||||
|
||||
### 7.2 探测 `write_file`:不要等完整 path
|
||||
|
||||
`write_file` 参数是一整段 JSON 流,**键顺序由模型决定**。
|
||||
|
||||
| 模型吐参顺序 | 步骤条表现 | 错误做法的后果 |
|
||||
| --- | --- | --- |
|
||||
| path / description 先到,再 content | 很快显示描述和文件名 | 普通对话页常见;等 path 也能开 |
|
||||
| **content 先到,最后才 path** | 长时间只有「写入文件」,文件名很晚才出现 | 若硬等完整 path,沙箱会晚几十秒甚至一分钟 |
|
||||
|
||||
岗位会商报告类 agent **经常是第二种**。
|
||||
|
||||
`latestStreamingWriteFile` 规则:
|
||||
|
||||
1. 倒序扫描 AI messages 的 `tool_calls`;
|
||||
2. 工具名是 `write_file` 或 `str_replace`;
|
||||
3. **有完整扩展名的 path**(`hasCompleteArtifactExtension`)→ 用真实路径;
|
||||
4. **尚无可用 path,但 `write_file` 的 content 已有非空字符** → 临时文件名:
|
||||
- 正文头像 HTML → `未命名文件.html`
|
||||
- 否则 → `未命名文件.md`
|
||||
5. `str_replace` 必须等真实 path(它改已有文件);
|
||||
6. 组装:
|
||||
- `key = messageId:toolCallId`
|
||||
- `path = write-file:虚拟URL`(write_file)或真实 path(str_replace)
|
||||
- `mimeType`:markdown 则 `text/markdown`
|
||||
|
||||
为什么扩展名必须完整?
|
||||
|
||||
流式 path 会经历 `/a/b/report.m` → `/a/b/report.md`。若在 `.m` 时就当真实文件打开,预览语言可能被永久判成 TEXT。
|
||||
|
||||
### 7.3 上报 effect 的四个门闩
|
||||
|
||||
自动上报 `onPreviewArtifact` 前必须同时满足:
|
||||
|
||||
1. `thread.isLoading === true`
|
||||
2. 探测到 streaming artifact,且其 key **不在** baseline
|
||||
3. 已有 `previewThreadId`
|
||||
4. `autoPreviewRunArmedRef === true`
|
||||
|
||||
然后处理 previewKey:
|
||||
|
||||
```text
|
||||
若与 activePreview 是同一次 tool call(含 tool-start 前缀兼容)
|
||||
→ 复用旧 previewKey // 用于临时名 → 真实 path
|
||||
否则
|
||||
→ previewKey = 新的 messageId:toolCallId
|
||||
```
|
||||
|
||||
去重键:
|
||||
|
||||
```text
|
||||
`${previewThreadId}:${previewKey}:${sourcePath}`
|
||||
```
|
||||
|
||||
把 `sourcePath` 算进去,是为了:**同 previewKey 下临时名变成真实 path 时,还要再报一次**(path upgrade),但不会每来一个 content chunk 都报。
|
||||
|
||||
### 7.4 线程绑定竞态(不要误 disarm)
|
||||
|
||||
首轮对话常出现:
|
||||
|
||||
1. 用户发送(本地 draft threadId / 稍后 SDK 创建真实 id);
|
||||
2. 流已经开始、armed=true;
|
||||
3. 会话 API 把 `node.threadId` 从 `null` 写成真实 id。
|
||||
|
||||
这是「本节点绑定自己的新线程」,**不是**切节点。若绑定 effect 无脑清空 armed / baseline,首轮会永远开不了沙箱。
|
||||
|
||||
正确:识别 `isOwnThreadBinding`(同 nodeKey、旧 threadId 为空、新 threadId 等于当前 boundThreadId)时 **直接 return,不清 armed**。
|
||||
|
||||
### 7.5 path upgrade(改名)≠ 第二次交付
|
||||
|
||||
同一次 tool call:
|
||||
|
||||
```text
|
||||
T1 content 到达 → path=未命名文件.md, previewKey=msg:tc1 → 打开
|
||||
T2 真实 path 到达 → path=/mnt/.../报告.md, previewKey=msg:tc1 → 只改名
|
||||
```
|
||||
|
||||
页面壳看到相同 previewKey、不同 path → `isPathUpgrade`:
|
||||
|
||||
- 更新 `sandboxArtifact`;
|
||||
- **不**重挂(React key 仍基于 previewKey);
|
||||
- **不**再折叠右侧抽屉。
|
||||
|
||||
### 7.6 同路径二次写入(重新生成)
|
||||
|
||||
同一次问答里模型可能:
|
||||
|
||||
```text
|
||||
1) write_file → 步骤条「写入文件」→ 打开沙箱
|
||||
2) 再 write_file 同 path,description=「重新生成优化版…」
|
||||
```
|
||||
|
||||
这是**两次 tool call**,必须两个 previewKey。
|
||||
若错误地「path 相同就复用 previewKey」:
|
||||
|
||||
- 步骤条出现第二条;
|
||||
- 沙箱 React key 不变;
|
||||
- 用户看到「重新生成了,但沙箱没变化」。
|
||||
|
||||
正确:身份只看 tool call。
|
||||
|
||||
### 7.7 时序总图(content-first)
|
||||
|
||||
```text
|
||||
T0 用户发送 → armed=true,拍 baseline
|
||||
T1 步骤条出现「写入文件」(尚无 path)
|
||||
T1+ args.content 首字符到达 → 临时名打开沙箱,源码开始滚动
|
||||
… store 约每 160ms 推一次 thread 快照 …
|
||||
T2 args.path 拼完整(含 .md)→ path upgrade,标题改真实文件名(不重挂)
|
||||
T3 write 结束 / isLoading=false → 自动切 Markdown 预览
|
||||
```
|
||||
|
||||
### 7.8 时序总图(同轮二次写入)
|
||||
|
||||
```text
|
||||
T1 write_file#1 → previewKey=msg:tc1 → 打开沙箱 A
|
||||
T2 #1 结束(可能短暂切预览)
|
||||
T3 write_file#2(同 path,新 description)→ previewKey=msg:tc2
|
||||
→ key 变 → 组件重挂清空 → 跟 #2 重新流式
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 关闭沙箱的完整步骤
|
||||
|
||||
### 8.1 用户手动关闭
|
||||
|
||||
`closeSandbox()` 应做:
|
||||
|
||||
1. `closedSandboxKeyRef = sandboxCloseKey(current)` —— 抑制**这一次**自动再开;
|
||||
2. 关闭 `ArtifactsContext.open`(若开着);
|
||||
3. 清节流定时器、pending 快照;
|
||||
4. `sandboxSnapshotAppliedAtRef = 0`;
|
||||
5. `sandboxArtifactRef = null` + `setSandboxArtifact(null)`;
|
||||
6. `sandboxThreadStore.publish(null)`。
|
||||
|
||||
抑制与 path upgrade 的关系:
|
||||
|
||||
- 关掉临时名面板后,同一次 tool call 升级真实 path **仍被抑制**(同一 previewKey)——符合「用户不要看这次」;
|
||||
- 下一次新的 `write_file`(新 previewKey)**可以**再自动开。
|
||||
|
||||
### 8.2 场景切换时强制关闭
|
||||
|
||||
以下操作应先调用 `closeSandbox()`,避免串台:
|
||||
|
||||
- 切岗位;
|
||||
- 切节点;
|
||||
- 切收口阶段;
|
||||
- 新建会话;
|
||||
- 切换历史会话。
|
||||
|
||||
是否写入 `closedSandboxKeyRef` 可按产品决定;至少必须清面板和 store。
|
||||
|
||||
### 8.3 错误的抑制粒度(历史坑)
|
||||
|
||||
若做成「关过一次就整个 thread 不再自动开」:
|
||||
|
||||
- 第一次实时开正常;
|
||||
- 用户关掉;
|
||||
- 再问一次 → 同 thread 被永久抑制 → 看起来像「第二次不实时 / 要等生成完」。
|
||||
|
||||
正确粒度:**单次交付的 `sandboxCloseKey`(含 previewKey)**。
|
||||
|
||||
---
|
||||
|
||||
## 9. 步骤条与沙箱的关系
|
||||
|
||||
### 9.1 步骤条从哪来
|
||||
|
||||
步骤条由 `MessageList` → `message-group.tsx` 渲染 tool call:
|
||||
|
||||
- 标题:优先 `args.description`;没有则 i18n 兜底「写入文件」;
|
||||
- 子行:有 `args.path` 才显示路径;
|
||||
- 普通对话页还会在「有 path + autoOpen」时自己 `select/setOpen`。
|
||||
|
||||
岗位会商里,步骤条**仍然只负责展示**;打开沙箱走 `onPreviewArtifact`,不要让两套逻辑抢。
|
||||
|
||||
### 9.2 对照表
|
||||
|
||||
| 你看到的步骤条 | 含义 | 沙箱应处于 |
|
||||
| --- | --- | --- |
|
||||
| 仅「写入文件」,无路径 | tool call 已开始;path/description 可能还没流到 | **应已打开**(临时名),源码在滚 |
|
||||
| 「写入文件」+ 路径 | path 已可用 | 已打开;若刚从临时名升级则只改名 |
|
||||
| 带自定义 description 的标题 + 路径 | 模型给了 description | 同上 |
|
||||
| 第二条「重新生成…」+ 同路径 | 新的 write_file(新 toolCallId) | **应重挂**并重新流式 |
|
||||
| 顶部「正在生成,完成后自动预览」 | 仍在 code 视图 | 正常,等 isLoading 结束切预览 |
|
||||
| 生成结束,出现文件卡片 / present_files | 工具结果已落 | 通常已是预览模式;可手动再点开 |
|
||||
|
||||
### 9.3 常见误解
|
||||
|
||||
**误解 1:**「步骤条出现文件名 = 打开沙箱的正确时机」
|
||||
→ 错。那是 path 到达的时机。content-first 时正确打开时机更早:content 首字节 / 工具调用已开始。
|
||||
|
||||
**误解 2:**「步骤条还在写『写入文件』,沙箱却开了,是 bug」
|
||||
→ 通常不是。两边数据源相同,但沙箱允许无 path 打开,步骤条标题可能还没 description。
|
||||
|
||||
**误解 3:**「等生成完从编辑切预览 = 沙箱打开晚了」
|
||||
→ 错。那是 `ArtifactFileDetail` 的 viewMode 策略。
|
||||
|
||||
---
|
||||
|
||||
## 10. 流式内容如何进入沙箱(性能关键)
|
||||
|
||||
### 10.1 为什么不能用页面 `useState` 存线程快照
|
||||
|
||||
错误写法:
|
||||
|
||||
```ts
|
||||
setSandboxThreadSnapshot(snapshot) // 页面级 useState
|
||||
```
|
||||
|
||||
后果:
|
||||
|
||||
- 消息 chunk 约每 30ms 一次,节流后仍约 6 次/秒;
|
||||
- 每次都重渲染整页:任务卡、意图结果、流程图、节点对话……;
|
||||
- 主线程占满 → 消息列表停绘、沙箱冻住;
|
||||
- 纯尾随 `setTimeout(160)` 会被饿死,十几秒不 flush。
|
||||
|
||||
这同时解释了两个历史现象:
|
||||
|
||||
1. 「沙箱打开了但内容不更新」;
|
||||
2. 「修完节流后整页更卡、消息也不实时」——因为节流终于开始真正 setState 了。
|
||||
|
||||
### 10.2 正确做法:外部 store + 子树订阅
|
||||
|
||||
```ts
|
||||
createSandboxThreadStore()
|
||||
subscribe / getSnapshot / publish
|
||||
|
||||
SandboxThreadProvider
|
||||
useSyncExternalStore(store.subscribe, store.getSnapshot)
|
||||
→ ThreadContext.Provider value={{ thread, isMock:false }}
|
||||
→ ArtifactFileDetail
|
||||
```
|
||||
|
||||
页面壳本身**不**因 snapshot 变化而重渲染;只有 Provider 及其子树重渲染。
|
||||
|
||||
### 10.3 节流必须是前沿(leading-edge)
|
||||
|
||||
`SANDBOX_SNAPSHOT_MIN_INTERVAL_MS = 160`:
|
||||
|
||||
```text
|
||||
queue(snapshot):
|
||||
pending = snapshot
|
||||
if 距上次 publish >= 160ms:
|
||||
立刻 flush(同步 publish)
|
||||
else:
|
||||
若没有在途 timer,则 setTimeout(剩余时间) 做补尾
|
||||
flush:
|
||||
清 timer
|
||||
校验 pending.threadId === sandboxArtifactRef.threadId
|
||||
publish(pending)
|
||||
```
|
||||
|
||||
**不要**改回:
|
||||
|
||||
```ts
|
||||
if (timer != null) return;
|
||||
timer = setTimeout(flush, 160);
|
||||
```
|
||||
|
||||
那是纯尾随;忙主线程下会假死。
|
||||
|
||||
### 10.4 signature 去重
|
||||
|
||||
`sandboxThreadSignature` 用最近 write_file 的 path、content 长度、content 尾 32 字等拼签名。
|
||||
store.publish 时若 threadId+signature 未变,则不通知订阅者,减少无效渲染。
|
||||
|
||||
### 10.5 打开瞬间的首帧
|
||||
|
||||
`handlePreviewArtifact` 里往往会立刻 `queueSandboxThreadSnapshot(latestSnapshot)`。
|
||||
此时 React 的 `setSandboxArtifact` 可能还没提交,若 flush 只读旧的 `sandboxArtifactRef`,会因 threadId 不匹配把首帧丢掉。
|
||||
|
||||
所以打开新预览时必须:
|
||||
|
||||
```ts
|
||||
sandboxArtifactRef.current = artifact; // 同步
|
||||
sandboxSnapshotAppliedAtRef.current = 0;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 与普通对话页的对照
|
||||
|
||||
| 点 | 普通对话页 | 岗位会商 |
|
||||
| --- | --- | --- |
|
||||
| 谁触发打开 | `message-group` 内 `artifacts.select` | 子组件 `onPreviewArtifact` → 页面壳 |
|
||||
| 是否要求 path | 是(有 path 才自动开) | 否(write_file + content 可临时名开) |
|
||||
| thread 从哪来 | 同树 ThreadContext | `onThreadSnapshot` + 外部 store |
|
||||
| 关闭抑制 | 主要靠 ArtifactsContext / 用户操作 | `closedSandboxKeyRef` 按 previewKey |
|
||||
| 同 path 重写 | 依赖新的 tool call / 选择逻辑 | **必须**新 previewKey 以重挂 |
|
||||
| 结束后切预览 | ArtifactFileDetail 同一套 | 同一套 |
|
||||
|
||||
对齐目标是**用户感知一致**,不是代码路径完全相同。
|
||||
|
||||
普通对话页自动开的核心条件(对照用):
|
||||
|
||||
```ts
|
||||
artifacts && isLoading && isLast && autoOpen && autoSelect && path && !result
|
||||
```
|
||||
|
||||
它也要求 path,所以 content-first 时普通页同样会偏晚——只是很多主聊天示例 agent 先吐 path,观感更好。岗位会商报告 agent 更常 content-first,所以必须做临时名提前开。
|
||||
|
||||
---
|
||||
|
||||
## 12. 重做页面时的必守规则
|
||||
|
||||
按优先级:
|
||||
|
||||
1. **流式 thread 快照禁止进页面级 `useState`** → 用 store + `useSyncExternalStore`。
|
||||
2. **节流用前沿 + 补尾**,不要纯尾随 timer。
|
||||
3. **不要等完整 path 才开**;`write_file` + content 即可临时名打开。
|
||||
4. **扩展名完整前不要把 path 当最终 path**。
|
||||
5. **previewKey 按 tool call,不按 path**。
|
||||
6. **打开时同步写 `sandboxArtifactRef`,并重置节流时间戳**。
|
||||
7. **主路径扫描 `thread.messages`,不要只靠 `onToolStart`**。
|
||||
8. **path upgrade 保持 previewKey,不要当第二次交付**。
|
||||
9. **关闭抑制按 sandboxCloseKey,不要按整 thread**。
|
||||
10. **沙箱单例在页面壳;子组件只上报**。
|
||||
11. **本节点 threadId 回写不要 disarm**。
|
||||
12. **「结束后才切预览」不要当打开 bug 去「修」掉共享组件**,除非产品明确要求边生成边渲染 Markdown(需单独评估性能)。
|
||||
|
||||
---
|
||||
|
||||
## 13. 历史问题与根因(务必读)
|
||||
|
||||
下面每个都在真实联调里出现过。重做时若踩中,优先回来对照。
|
||||
|
||||
### 13.1 沙箱要等文件名出来才开(晚几十秒)
|
||||
|
||||
**现象:** 步骤条早就「写入文件」,沙箱很晚才开;普通对话页对比显得「实时」。
|
||||
|
||||
**根因:** 探测逻辑硬等完整 `path`;而该 agent 先流 `content`、最后才流 `path`。
|
||||
|
||||
**修复:** content 首批字符即可用临时名打开;path 到了再 upgrade。
|
||||
|
||||
### 13.2 沙箱打开了,内容冻住,生成完才灌满
|
||||
|
||||
**现象:** 面板开了,但里面一直是空/旧内容;日志里 queue 很多,flush 很少。
|
||||
|
||||
**根因:** 纯尾随 160ms 定时器在忙主线程下被饿死。
|
||||
|
||||
**修复:** 前沿节流——到期就在流回调里同步 flush。
|
||||
|
||||
### 13.3 修完「内容更新」后,整页卡顿、消息也不实时
|
||||
|
||||
**现象:** 沙箱开始更新了,但页面更卡,消息列表也「攒到最后刷」。
|
||||
|
||||
**根因:** flush 走的是页面 `useState`,每 160ms 整页重渲染。以前 timer 饿死时反而「不卡」。
|
||||
|
||||
**修复:** 快照改外部 store,只让沙箱子树订阅。
|
||||
|
||||
### 13.4 第二次「重新生成」步骤有了,沙箱不变
|
||||
|
||||
**现象:** 同轮第二条二条 write 步骤,沙箱仍显示第一次完整结果。
|
||||
|
||||
**根因:** 用「path 相同」复用了第一次 previewKey,React key 不变,组件不重挂。
|
||||
|
||||
**修复:** previewKey 只按 tool call;同 path 新 call 必须新 key。
|
||||
|
||||
### 13.5 第一次实时,关掉后再问就不实时
|
||||
|
||||
**现象:** 关过沙箱后,下一轮要等生成完才开,或不开。
|
||||
|
||||
**根因:** 抑制绑在整个 threadId,或把 path upgrade / 新 call 误判成同一次。
|
||||
|
||||
**修复:** 抑制键 = `sandboxCloseKey(previewKey)`;仅抑制那一次交付。
|
||||
|
||||
### 13.6 首轮永远不开
|
||||
|
||||
**现象:** 新建节点第一次提问,armed 后很快又不满足条件。
|
||||
|
||||
**根因:** `node.threadId` 从 null 回写真实 id 时,绑定 effect 误 disarm。
|
||||
|
||||
**修复:** `isOwnThreadBinding` 时不清 armed。
|
||||
|
||||
### 13.7 预览变成纯文本
|
||||
|
||||
**现象:** 明明是 md,却按 TEXT 打开。
|
||||
|
||||
**根因:** 在 `.m` 阶段就当完整 path;或临时名没有 `.md` / 没传 language、mimeType;或 path 带空格。
|
||||
|
||||
**修复:** `hasCompleteArtifactExtension`;临时名带扩展名;URL 加 `language=markdown`;path trim。
|
||||
|
||||
### 13.8 依赖 onToolStart,线上偶发不开
|
||||
|
||||
**现象:** 本地偶发正常,联调经常不开。
|
||||
|
||||
**根因:** 后端 StreamBridge / worker 可能过滤 tools/events 类 stream mode。
|
||||
|
||||
**修复:** 主路径必须是 messages 里渐进出现的 `tool_calls`;onToolStart 只能当补充。
|
||||
|
||||
---
|
||||
|
||||
## 14. 排障手册(按症状)
|
||||
|
||||
排障时建议先看 Chrome Performance / React 重渲染是否整页抖,再看消息里 tool_calls 的 args 是 path-first 还是 content-first。
|
||||
|
||||
### 14.1 沙箱开得很晚
|
||||
|
||||
检查顺序:
|
||||
|
||||
1. `latestStreamingWriteFile` 是否仍要求完整 path?
|
||||
2. armed 是否被 threadId 回写清掉?
|
||||
3. 是否命中 `closedSandboxKey`?
|
||||
4. 新 call 是否被 baseline 当成历史?
|
||||
5. `previewThreadId` 是否为空(线程还没绑上)?
|
||||
|
||||
### 14.2 打开了但内容空白 / 冻住
|
||||
|
||||
1. 快照是否进了页面 useState?
|
||||
2. 节流是否纯尾随?
|
||||
3. `SandboxThreadProvider` 是否拿到带 messages 的 thread?
|
||||
4. 虚拟 URL 的 message_id / tool_call_id 是否对得上?
|
||||
5. 打开时 `sandboxArtifactRef` 是否同步?(首帧是否被 drop)
|
||||
6. signature 是否因实现错误一直不变,导致 publish 被去重?
|
||||
|
||||
### 14.3 整页卡、消息列表也不实时
|
||||
|
||||
1. 搜页面壳是否还有 `setSandboxThreadSnapshot` / 类似大对象 state。
|
||||
2. 是否在每 chunk `console.debug` 打大对象(DevTools 打开时更卡)。
|
||||
3. 是否错误地让页面壳订阅了 store(页面壳不应 `useSyncExternalStore` 这份 store;只有 Provider 订)。
|
||||
|
||||
### 14.4 重新生成不刷新
|
||||
|
||||
1. 第二次 write 的 toolCallId 是否变了?
|
||||
2. previewKey 是否仍用 path 复用?
|
||||
3. `ArtifactFileDetail` 的 `key=` 是否基于 sandboxCloseKey/previewKey?
|
||||
|
||||
### 14.5 关后再问异常
|
||||
|
||||
1. 打印 `closedSandboxKeyRef` 与新 artifact 的 sandboxCloseKey。
|
||||
2. 确认新一轮 send 时 armed/baseline 已重置。
|
||||
3. 确认抑制不是整 thread。
|
||||
|
||||
### 14.6 语言 / 预览模式不对
|
||||
|
||||
1. 看打开时的 path / mimeType / language 查询参数。
|
||||
2. 确认不是在扩展名不完整时打开。
|
||||
3. 确认「源码→预览」发生在 isLoading false 之后(属正常)。
|
||||
|
||||
---
|
||||
|
||||
## 15. 对接自测清单
|
||||
|
||||
重做页面后至少跑完:
|
||||
|
||||
### 功能
|
||||
|
||||
- [ ] 首轮:步骤条「写入文件」出现时,沙箱是否已开并开始出字?
|
||||
- [ ] content-first agent:无文件名阶段是否也能开?
|
||||
- [ ] path 到达后:是否只改名、不整页闪、不重挂?
|
||||
- [ ] 同轮第二次同路径 write:是否清空并重新流式?
|
||||
- [ ] 结束后是否自动切 Markdown 预览?
|
||||
- [ ] 手动点步骤条路径 / 产物卡片:能否打开正确文件?
|
||||
|
||||
### 关闭与抑制
|
||||
|
||||
- [ ] 流式中手动关闭:本次是否不再自动弹?
|
||||
- [ ] 关闭后再发一轮新问题:是否仍可自动开?
|
||||
- [ ] path upgrade 是否不会绕过「用户刚关闭」的抑制?
|
||||
|
||||
### 场景切换
|
||||
|
||||
- [ ] 切节点:沙箱关闭,旧内容不残留。
|
||||
- [ ] 切岗位:同上。
|
||||
- [ ] 新建会话 / 切历史:同上。
|
||||
|
||||
### 性能
|
||||
|
||||
- [ ] 流式中消息列表是否实时出字?
|
||||
- [ ] 页面是否明显掉帧 / 卡死?
|
||||
- [ ] React DevTools 中,大页面壳是否在每 160ms 整页 update?(不应)
|
||||
|
||||
---
|
||||
|
||||
## 16. 最小接线示例
|
||||
|
||||
### 16.1 页面壳(示意)
|
||||
|
||||
```tsx
|
||||
const [sandboxArtifact, setSandboxArtifact] = useState<PositionArtifactPreview | null>(null);
|
||||
const sandboxArtifactRef = useRef<PositionArtifactPreview | null>(null);
|
||||
const closedSandboxKeyRef = useRef<string | null>(null);
|
||||
const latestNodeThreadSnapshotRef = useRef<SandboxThreadSnapshot | null>(null);
|
||||
const store = useMemo(createSandboxThreadStore, []);
|
||||
// pending + timer + appliedAt 用于前沿节流(见 PositionRoundtablePage)
|
||||
|
||||
function handlePreviewArtifact(artifact: PositionArtifactPreview) {
|
||||
if (!artifact.threadId || !artifact.path) return;
|
||||
const key = sandboxCloseKey(artifact);
|
||||
if (artifact.auto && closedSandboxKeyRef.current === key) return;
|
||||
closedSandboxKeyRef.current = null;
|
||||
|
||||
artifactsCtx?.select(artifact.path, true);
|
||||
artifactsCtx?.setOpen(true);
|
||||
|
||||
const prev = sandboxArtifactRef.current;
|
||||
const isSame =
|
||||
prev?.threadId === artifact.threadId &&
|
||||
prev.path === artifact.path &&
|
||||
prev.previewKey === artifact.previewKey;
|
||||
const isPathUpgrade =
|
||||
!isSame &&
|
||||
Boolean(artifact.previewKey) &&
|
||||
prev?.threadId === artifact.threadId &&
|
||||
prev.previewKey === artifact.previewKey;
|
||||
|
||||
if (!isSame) {
|
||||
sandboxArtifactRef.current = artifact; // 必须同步
|
||||
setSandboxArtifact(artifact);
|
||||
sandboxSnapshotAppliedAtRef.current = 0;
|
||||
}
|
||||
|
||||
if (store.getSnapshot()?.threadId !== artifact.threadId) {
|
||||
store.publish(null);
|
||||
}
|
||||
const latest = latestNodeThreadSnapshotRef.current;
|
||||
if (latest?.threadId === artifact.threadId) {
|
||||
queueSandboxThreadSnapshot(latest);
|
||||
}
|
||||
if (!isPathUpgrade) setRightPanelOpen(false);
|
||||
}
|
||||
|
||||
function handleNodeThreadSnapshot(snapshot: { threadId: string; thread: BaseStream<AgentThreadState> }) {
|
||||
const next = { ...snapshot, signature: sandboxThreadSignature(snapshot.thread) };
|
||||
latestNodeThreadSnapshotRef.current = next;
|
||||
if (sandboxArtifactRef.current?.threadId !== snapshot.threadId) return;
|
||||
queueSandboxThreadSnapshot(next);
|
||||
}
|
||||
|
||||
function closeSandbox() {
|
||||
if (sandboxArtifactRef.current) {
|
||||
closedSandboxKeyRef.current = sandboxCloseKey(sandboxArtifactRef.current);
|
||||
}
|
||||
// clear timer / pending / appliedAt
|
||||
sandboxArtifactRef.current = null;
|
||||
setSandboxArtifact(null);
|
||||
store.publish(null);
|
||||
artifactsCtx?.setOpen(false);
|
||||
}
|
||||
|
||||
// 渲染
|
||||
{sandboxArtifact && (
|
||||
<SandboxThreadProvider
|
||||
store={store}
|
||||
threadId={sandboxArtifact.threadId}
|
||||
fallbackThread={stub}
|
||||
>
|
||||
<ArtifactFileDetail
|
||||
key={sandboxCloseKey(sandboxArtifact)}
|
||||
filepath={sandboxArtifact.path}
|
||||
threadId={sandboxArtifact.threadId}
|
||||
mimeType={sandboxArtifact.mimeType}
|
||||
onClose={closeSandbox}
|
||||
allowVirtualMarkdownEdit
|
||||
/>
|
||||
</SandboxThreadProvider>
|
||||
)}
|
||||
```
|
||||
|
||||
### 16.2 对话子组件(示意)
|
||||
|
||||
```tsx
|
||||
// send 前
|
||||
activeWriteFilePreviewRef.current = null;
|
||||
autoPreviewBaselineCallKeysRef.current = writeFileCallKeys(thread.messages);
|
||||
autoPreviewRunArmedRef.current = true;
|
||||
await sendMessage(...);
|
||||
|
||||
// 探测
|
||||
const streaming = thread.isLoading ? latestStreamingWriteFile(thread.messages) : null;
|
||||
const next =
|
||||
streaming && !autoPreviewBaselineCallKeysRef.current.has(streaming.key)
|
||||
? streaming
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!thread.isLoading || !next || !previewThreadId || !autoPreviewRunArmedRef.current) return;
|
||||
|
||||
// previewKey:同 tool call 复用,否则用 next.key
|
||||
// artifactKey = threadId:previewKey:sourcePath 去重
|
||||
onPreviewArtifact?.({
|
||||
threadId: previewThreadId,
|
||||
path: next.path,
|
||||
name: next.name,
|
||||
mimeType: next.mimeType,
|
||||
previewKey,
|
||||
auto: true,
|
||||
});
|
||||
}, [next, previewThreadId, thread.isLoading, onPreviewArtifact]);
|
||||
|
||||
useEffect(() => {
|
||||
onThreadSnapshot?.({ threadId: activeThreadId, thread });
|
||||
}, [activeThreadId, thread, onThreadSnapshot]);
|
||||
```
|
||||
|
||||
**强烈建议:** 探测 / armed / baseline / 临时名逻辑直接复用或抽取自 `PositionNodeChat`,不要从零重写。
|
||||
|
||||
---
|
||||
|
||||
## 17. 关键代码索引
|
||||
|
||||
| 主题 | 位置 |
|
||||
| --- | --- |
|
||||
| 临时名 / 完整扩展名 / latestStreamingWriteFile | `PositionNodeChat.tsx` 中 `provisionalWriteFilePath`、`hasCompleteArtifactExtension`、`latestStreamingWriteFile` |
|
||||
| armed / baseline / 上报 effect | `PositionNodeChat.tsx` `handleSubmit` + streaming preview `useEffect` |
|
||||
| 同 tool call 判断 / 二次写入重挂 | 同上,`isSameToolCall` / `previewKey` |
|
||||
| 线程绑定保护 | `PositionNodeChat.tsx` node binding `useEffect` 中 `isOwnThreadBinding` |
|
||||
| sandboxCloseKey / store / 前沿节流 | `PositionRoundtablePage.tsx` `sandboxCloseKey`、`createSandboxThreadStore`、`queueSandboxThreadSnapshot` |
|
||||
| 打开 / path upgrade / 抑制 | `PositionRoundtablePage.tsx` `handlePreviewArtifact` |
|
||||
| 关闭 | `PositionRoundtablePage.tsx` `closeSandbox` |
|
||||
| 虚拟 URL 读 content | `core/artifacts/loader.ts` `loadArtifactContentFromToolCall` |
|
||||
| 源码/预览切换 | `artifact-file-detail.tsx` 中 `viewMode` + `thread.isLoading` |
|
||||
| 普通页步骤条自动开 | `message-group.tsx` `write_file` 分支 |
|
||||
|
||||
相关提交(背景):
|
||||
|
||||
- `fix(frontend): 岗位会商沙箱在 write_file 流式阶段实时打开并刷新`
|
||||
— 临时名打开、外部 store、前沿节流
|
||||
- `fix(frontend): 同路径二次 write_file 时重挂岗位会商沙箱`
|
||||
— previewKey 按 tool call,禁止按 path 复用
|
||||
|
||||
---
|
||||
|
||||
## 18. 术语表
|
||||
|
||||
| 术语 | 含义 |
|
||||
| --- | --- |
|
||||
| armed | 本轮用户发送后,允许自动打开沙箱的开关 |
|
||||
| baseline | send 前已有的 write tool call 集合,用于忽略历史产物 |
|
||||
| previewKey | 一次交付的稳定身份,推荐 `messageId:toolCallId` |
|
||||
| sandboxCloseKey | 关闭抑制键,同时用作 `ArtifactFileDetail` 的 React key |
|
||||
| provisional path / 临时名 | 尚无真实 path 时的 `未命名文件.md` / `.html` |
|
||||
| path upgrade | 同 previewKey 下临时名升级为真实 path |
|
||||
| sandboxThreadStore | 流式线程外部 store,避免整页重渲染 |
|
||||
| write-file: URL | 用 message_id + tool_call_id 定位流式 content 的虚拟路径 |
|
||||
| leading-edge throttle / 前沿节流 | 到期立即在回调里 publish,而不是只挂尾随 timer |
|
||||
| content-first / path-first | 模型流式 JSON 参数时 content 与 path 哪个先到 |
|
||||
| isOwnThreadBinding | 本节点把自己的新 threadId 写回 props,不是切节点 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:给对接同学的一页纸摘要
|
||||
|
||||
1. 页面壳只挂一个沙箱;子组件只调用 `onPreviewArtifact` / `onThreadSnapshot`。
|
||||
2. 自动打开时机 ≈ 步骤条出现「写入文件」时,而不是出现文件名时。
|
||||
3. `write_file` 可以没有 path:临时名打开,path 到了再改名。
|
||||
4. 流式 thread 进外部 store,进页面 state 会卡死。
|
||||
5. 节流要前沿;previewKey 看 tool call;关闭抑制看 sandboxCloseKey。
|
||||
6. 同路径重新生成 = 新 previewKey = 沙箱重挂。
|
||||
7. 出问题先对照第 13、14 节,再看代码索引第 17 节。
|
||||
@@ -1,7 +0,0 @@
|
||||
# 技能知识归纳与“知识梳理”助手知识库
|
||||
|
||||
管理员在「技能管理 → 技能知识归纳」中选择一个或多个技能、一个或多个目标,系统按“技能 × 目标”创建后台任务。界面提供总体进度、单项失败重试、实体/关系审核、绑定重归纳、自动同步和两种解绑动作。技能列表通过一次 bindings 查询展示归纳目标,避免逐技能请求。
|
||||
|
||||
扫描仅处理知识资料。代码、脚本、锁文件、密钥文件以及 Markdown 代码块不会进入上传、摘要、提示词或知识指纹;ZIP 只读取逐项验证通过的知识成员。自动监听只比较该知识指纹,所以纯代码改动不会产生 stale 状态。
|
||||
|
||||
知识库页面新增「普通知识库 / 助手知识库」并行入口。管理员可将系统默认入口设为 v1(普通 WeKnora)或 v2(全局“知识梳理”)。助手库无需 WeKnora 即可初始化,所有用户可只读查看和搜索 Wiki;管理员可上传本地文件、从普通库导入、重新导入或删除来源、查看版本并回滚。来源删除只移除对应贡献,共享实体与关系继续保留。
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,822 +0,0 @@
|
||||
# 沙箱统一写作管线:可实现开发方案
|
||||
|
||||
> 状态:设计稿;本文件只定义实施方案,不包含本轮业务代码改动。
|
||||
> 覆盖范围:普通沙箱 Markdown、深度研究 `report.md`、现有 Markdown 编辑器的选区/段落改写。
|
||||
|
||||
## 1. 目标与结论
|
||||
|
||||
目标不是再增加一个“AI 写作页面”,而是将现有的四类能力统一为一个安全、可流式、可追溯的**文档改写管线**:
|
||||
|
||||
1. Markdown 编辑器选区改写;
|
||||
2. Markdown 编辑器段落改写;
|
||||
3. 深度研究报告的局部候选改写;
|
||||
4. 沙箱中 Markdown 文件的全文改写。
|
||||
|
||||
该管线负责固定原文版本、构造上下文、逐 token 输出、校验 Markdown、生成可核验 diff、按策略提交,以及在全文覆盖时保存可撤销版本。
|
||||
|
||||
结论:需求可行,现有工程已有足够的复用基础:
|
||||
|
||||
- `frontend-web/src/open-canvas/components/artifact/MarkdownAiEditor.tsx` 已具备局部改写弹窗与人工“替换/插入/重试/追问”交互。
|
||||
- `frontend-web/src/open-canvas/hooks/useWritingRewrite.ts` 与 `frontend-web/src/open-canvas/api/rewrite.ts` 已具备 SSE 文本流消费。
|
||||
- `offline-backend-20260512/backend/app/gateway/routers/writing.py` 已具备模型流式读取实现。
|
||||
- `offline-backend-20260512/backend/app/gateway/routers/deep_research.py` 已具备报告哈希、候选内容和用户确认应用机制。
|
||||
- `frontend-web/src/components/workspace/messages/message-list.tsx`、`MessageGroup.tsx` 已可承载普通对话的工具步骤条、流式消息和文件卡片。
|
||||
- `frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx` 已经通过虚拟消息把研究进度接入普通 `MessageList`。
|
||||
|
||||
因此应该复用这些基础,建设一个执行内核;不能为全文改写另建独立聊天列表或独立写作流。
|
||||
|
||||
## 2. 产品边界与交互策略
|
||||
|
||||
### 2.1 支持范围
|
||||
|
||||
第一版仅对 `.md`、`.markdown` 文件显示“AI 全文改写”按钮。
|
||||
|
||||
不支持 PDF、DOCX、HTML、图片和二进制文件;不在改写过程中发起新的联网搜索;超大文件不静默截断后覆盖,而是提示用户选择章节改写。
|
||||
|
||||
### 2.2 范围、模式和提交策略
|
||||
|
||||
| 入口 | 文本范围 | 管线模式 | 提交策略 | 展示位置 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 编辑器气泡菜单 | 当前选区 | `selection` | `preview_only` | 现有改写预览弹窗 |
|
||||
| 编辑器段落菜单 | 当前段落 | `paragraph` | `preview_only` | 现有改写预览弹窗 |
|
||||
| 深度研究“改写第一段/章节” | 被解析出的报告片段 | `report_section` | `manual_apply` | 普通消息列表候选稿 + 确认卡 |
|
||||
| 文件右上角“AI 全文改写” | 当前完整 Markdown | `document` | `auto_commit_after_validation` | 普通消息列表步骤条 + 右侧沙箱 |
|
||||
|
||||
统一内核中的枚举:
|
||||
|
||||
~~~python
|
||||
class RewriteMode(str, Enum):
|
||||
SELECTION = "selection"
|
||||
PARAGRAPH = "paragraph"
|
||||
REPORT_SECTION = "report_section"
|
||||
DOCUMENT = "document"
|
||||
|
||||
class CommitPolicy(str, Enum):
|
||||
PREVIEW_ONLY = "preview_only"
|
||||
MANUAL_APPLY = "manual_apply"
|
||||
AUTO_COMMIT_AFTER_VALIDATION = "auto_commit_after_validation"
|
||||
~~~
|
||||
|
||||
这三类策略必须严格区分:
|
||||
|
||||
- `preview_only`:只生成候选,前端编辑器确认后才改本地 TipTap 文档;不写真实沙箱文件。
|
||||
- `manual_apply`:后端保留基准哈希和候选片段,用户点确认后才将该片段应用进研究报告。
|
||||
- `auto_commit_after_validation`:全文生成完成并校验通过后,后端进行一次带哈希校验的原子覆盖;同时保存可撤销版本。
|
||||
|
||||
### 2.3 局部编辑器:保持当前体验
|
||||
|
||||
现有文件:
|
||||
|
||||
- `frontend-web/src/open-canvas/components/artifact/MarkdownAiEditor.tsx`
|
||||
- `frontend-web/src/open-canvas/hooks/useWritingRewrite.ts`
|
||||
- `frontend-web/src/open-canvas/api/rewrite.ts`
|
||||
- `offline-backend-20260512/backend/app/gateway/routers/writing.py`
|
||||
|
||||
局部改写仍然是:
|
||||
|
||||
1. 选中文字或打开段落菜单;
|
||||
2. 选择润色、扩写、缩写、改写等动作;
|
||||
3. 在现有弹窗中流式出现候选;
|
||||
4. 用户手动选择替换、插入、重试或继续追问。
|
||||
|
||||
不要让局部改写每次都往普通消息列表插完整步骤条,否则频繁编辑会淹没聊天历史。它只共享底层管线,不共享全文改写的消息展示方式。
|
||||
|
||||
需要补充本地版本保护:发起请求时保存当前完整编辑器文本哈希、选区 `from/to` 和选区文本;用户点击“替换原文”前再次检查该范围仍匹配。若用户已修改原文,提示“原文已变化,请重新生成候选稿”,禁止将过期候选替换到错误位置。
|
||||
|
||||
### 2.4 右上角“AI 全文改写”
|
||||
|
||||
宿主文件:
|
||||
|
||||
- `frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx`
|
||||
|
||||
新增独立图标按钮,位置在右上角文件操作区,靠近保存、复制、下载一类操作。它**不能**替代页面中间当前 Sparkles 编辑模式:
|
||||
|
||||
| 控件 | 职责 |
|
||||
| --- | --- |
|
||||
| 中部 Sparkles/编辑模式 | 打开 `MarkdownAiEditor`,处理选区和段落 |
|
||||
| 新增右上角 AI 全文改写 | 对整个 Markdown 发起后台全文重写任务 |
|
||||
|
||||
建议显示条件:
|
||||
|
||||
~~~ts
|
||||
const canUseFullDocumentRewrite =
|
||||
isMarkdownFile &&
|
||||
canEditArtifact &&
|
||||
!isSkillFile &&
|
||||
!isMock &&
|
||||
!useWriteFileStreaming &&
|
||||
!hasLocalEdits;
|
||||
~~~
|
||||
|
||||
若 `hasLocalEdits` 为真,按钮置灰并提示“请先保存当前修改后再进行全文改写”。后端全文任务从真实文件读取快照,不能在用户本地还有未保存修改时读取旧版本并最终覆盖新修改。
|
||||
|
||||
用户体验:
|
||||
|
||||
1. 点击按钮,打开简洁配置弹窗。
|
||||
2. 弹窗显示文件名、原文字数;用户填写改写要求、选择模型、选择风格。
|
||||
3. 点击开始,左侧普通消息列表创建一组步骤条。
|
||||
4. 右侧沙箱自动切到源码视图,先显示“正在准备临时草稿,原文件尚未修改”。
|
||||
5. 新 Markdown 从空临时草稿开始逐 token 写入右侧代码区域。
|
||||
6. 校验通过后,后端原子替换真实内容;前端再同步真实 Artifact 缓存。
|
||||
7. 左侧保留最终对比总结、查看 diff 按钮和撤销按钮。
|
||||
|
||||
任务进行中,同一个文件的保存、直接编辑、局部编辑和再次全文改写必须禁用;其他文件不受影响。
|
||||
|
||||
### 2.5 深度研究报告的约束
|
||||
|
||||
普通 Markdown 和深度研究报告使用同一管线,但不能使用同一上下文策略:
|
||||
|
||||
| 能力 | 普通沙箱 Markdown | 深度研究 report.md |
|
||||
| --- | --- | --- |
|
||||
| 输入上下文 | 原文 + 用户指令 | 原文 + 当前会话已选资料 |
|
||||
| 联网 | 不联网 | 不联网 |
|
||||
| 事实约束 | 不新增无依据事实 | 只能引用已选资料,禁止编造 |
|
||||
| 引用校验 | 可选 | 必须校验来源 ID 白名单 |
|
||||
| 最终存储 | Artifact 文件 | 研究 session 的 `report_markdown` |
|
||||
| 局部改写 | 编辑器确认 | 现有确认卡片 |
|
||||
|
||||
深度研究右侧的 `report.md` 是由 `ResearchSandboxPanel.tsx` 以虚拟 `write_file` 方式展示的;它不能当作普通磁盘 Artifact 直接写入。全文任务必须通过研究 session 更新 `report_markdown`,并使 `report_html` 缓存失效。
|
||||
|
||||
## 3. 总体架构
|
||||
|
||||
~~~mermaid
|
||||
flowchart LR
|
||||
A["MarkdownAiEditor<br/>选区 / 段落"] --> P
|
||||
B["ArtifactFileDetail<br/>AI 全文改写"] --> P
|
||||
C["DeepResearch<br/>局部 / 全文"] --> P
|
||||
|
||||
P["Document Rewrite Pipeline<br/>冻结版本 → 构造上下文 → 流式生成<br/>校验 → 差异总结 → 按策略提交"]
|
||||
|
||||
P --> I["InlineDocumentSource"]
|
||||
P --> F["ArtifactDocumentSource"]
|
||||
P --> R["DeepResearchReportSource"]
|
||||
|
||||
P --> E["可恢复 SSE 事件 / Job"]
|
||||
E --> L["普通 MessageList"]
|
||||
E --> S["右侧沙箱临时草稿"]
|
||||
P --> V["内容哈希、版本快照、原子提交"]
|
||||
~~~
|
||||
|
||||
设计原则:
|
||||
|
||||
1. 纯执行内核不依赖 FastAPI、路由、用户身份或操作系统路径。
|
||||
2. 鉴权、线程路径解析、研究 session 获取只在 `app` 层的来源适配器中完成。
|
||||
3. 完整文档任务使用持久化后台 Job;局部弹窗可维持单次 SSE,避免过度持久化。
|
||||
4. 全文流式过程中只写临时草稿;真实内容只在最终 compare-and-swap 成功时改变。
|
||||
|
||||
不要直接把现有完整“AI 写作 Agent”塞进改写按钮。那个流程适合从主题写新文章,可能包含大纲、资料和章节生命周期;本需求是严格以当前文档为基准改写。第一版复用它的流式视觉表达和消息列表能力,但使用专门的文档改写执行内核。
|
||||
|
||||
## 4. 后端模块设计
|
||||
|
||||
### 4.1 目录与职责
|
||||
|
||||
建议新增:
|
||||
|
||||
~~~text
|
||||
offline-backend-20260512/backend/
|
||||
├─ packages/harness/deerflow/
|
||||
│ ├─ document_rewrite/
|
||||
│ │ ├─ __init__.py
|
||||
│ │ ├─ models.py # 请求、事件、校验、差异领域模型
|
||||
│ │ ├─ ports.py # Source / Model / Store 协议
|
||||
│ │ ├─ pipeline.py # 统一编排,不导入 app.*
|
||||
│ │ ├─ prompt_builder.py # 普通与深度研究提示词
|
||||
│ │ ├─ validation.py # Markdown / 引用 / 截断检查
|
||||
│ │ └─ diff_summary.py # 字数、标题、块、引用差异
|
||||
│ └─ persistence/
|
||||
│ └─ document_rewrites/
|
||||
│ ├─ base.py
|
||||
│ ├─ model.py
|
||||
│ └─ sql.py
|
||||
├─ app/gateway/
|
||||
│ ├─ routers/document_rewrites.py
|
||||
│ ├─ document_rewrite_sources.py
|
||||
│ └─ document_rewrite_executor.py
|
||||
└─ packages/harness/deerflow/persistence/migrations/versions/
|
||||
└─ <next_revision>_document_rewrites.py
|
||||
~~~
|
||||
|
||||
`packages/harness/deerflow/document_rewrite` 不能导入 `app.*`,以符合项目 harness→app import firewall。`app/gateway/document_rewrite_sources.py` 负责把已鉴权的 Artifact/研究会话包装成内核端口。
|
||||
|
||||
### 4.2 来源端口
|
||||
|
||||
~~~python
|
||||
class DocumentSource(Protocol):
|
||||
async def read_snapshot(self) -> DocumentSnapshot: ...
|
||||
async def read_current_hash(self) -> str: ...
|
||||
async def commit_if_unchanged(
|
||||
self,
|
||||
*,
|
||||
expected_hash: str,
|
||||
content: str,
|
||||
metadata: CommitMetadata,
|
||||
) -> CommitResult: ...
|
||||
async def restore_if_unchanged(
|
||||
self,
|
||||
*,
|
||||
expected_hash: str,
|
||||
content: str,
|
||||
) -> CommitResult: ...
|
||||
~~~
|
||||
|
||||
三种实现:
|
||||
|
||||
| 适配器 | 使用场景 | 读取方式 | 提交方式 |
|
||||
| --- | --- | --- | --- |
|
||||
| `InlineDocumentSource` | 选区/段落候选 | 前端编辑器快照 | 不提交 |
|
||||
| `ArtifactDocumentSource` | 普通 Markdown | 复用 Artifact 权限与虚拟路径解析 | 同目录临时文件 + `os.replace` |
|
||||
| `DeepResearchReportSource` | 研究报告 | session `report_markdown` + 已选来源 | 数据库哈希条件更新 |
|
||||
|
||||
`ArtifactDocumentSource` 必须复用 `app/gateway/routers/artifacts.py` 中已有的线程访问校验、虚拟路径解析逻辑,绝不允许请求体传入任意本机绝对路径。
|
||||
|
||||
### 4.3 持久化 Job、事件和版本
|
||||
|
||||
全文改写使用持久化 Job,参考 `app/gateway/deep_research_job_executor.py` 的后台任务、租约、取消监听和事件重放模式。
|
||||
|
||||
#### document_rewrite_jobs
|
||||
|
||||
| 字段 | 作用 |
|
||||
| --- | --- |
|
||||
| `id` | `drw_xxx` Job ID |
|
||||
| `user_id` | 发起人 |
|
||||
| `source_kind` | `artifact` / `deep_research_report` |
|
||||
| `thread_id`、`artifact_path` | 普通文件来源 |
|
||||
| `research_session_id` | 研究报告来源 |
|
||||
| `presentation_thread_id` | 普通消息列表归属 |
|
||||
| `mode`、`commit_policy` | 范围和提交语义 |
|
||||
| `instruction`、`style`、`model_name` | 用户配置 |
|
||||
| `base_sha256` | 原文冻结哈希 |
|
||||
| `draft_content`、`draft_sha256` | 临时流式草稿 |
|
||||
| `status`、`stage` | 生命周期 |
|
||||
| `validation_json`、`diff_summary_json` | 可展示结果 |
|
||||
| `committed_sha256` | 成功提交后的哈希 |
|
||||
| `error_message`、时间字段 | 错误、审计、取消 |
|
||||
|
||||
#### document_rewrite_events
|
||||
|
||||
至少包含 `job_id`、`seq`、`event_type`、`stage`、`payload_json`、`created_at`。`seq` 用于断线重连时通过 `after_seq` 重放。
|
||||
|
||||
不必将每个 token 单独入库:每 250ms 或每积累 4KB 持久化一次 `draft_content`;状态转换、警告、完成、冲突、取消必须立即写事件。重连时先发送最新草稿快照,再发送快照序号后的增量事件。
|
||||
|
||||
#### document_rewrite_versions
|
||||
|
||||
保存真实全文覆盖前的原文版本,字段包含 `job_id`、`source_identity`、`version_no`、`content`、`sha256`、`reason`、`created_at`。初版每个文档保留最近 20 个版本即可。
|
||||
|
||||
索引要求:
|
||||
|
||||
- `(user_id, presentation_thread_id, created_at DESC)`:恢复历史消息。
|
||||
- 真实来源标识 + 活跃状态:防止同一文档并行两个全文改写。
|
||||
|
||||
### 4.4 Job 状态机
|
||||
|
||||
~~~mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> queued
|
||||
queued --> snapshotting
|
||||
snapshotting --> generating
|
||||
generating --> validating
|
||||
validating --> comparing
|
||||
comparing --> committing
|
||||
committing --> completed
|
||||
completed --> reverted
|
||||
|
||||
queued --> cancelled
|
||||
generating --> cancelled
|
||||
snapshotting --> failed
|
||||
generating --> failed
|
||||
validating --> failed
|
||||
committing --> conflict
|
||||
committing --> failed
|
||||
~~~
|
||||
|
||||
状态约束:
|
||||
|
||||
- 同一真实文档只允许一个活跃 Job。
|
||||
- 生成期间取消、异常、断线,真实文档绝不能变化。
|
||||
- 提交前重新读取当前哈希;与 `base_sha256` 不一致时进入 `conflict`,草稿保留但不覆盖。
|
||||
|
||||
### 4.5 API 与 SSE 契约
|
||||
|
||||
创建全文任务:
|
||||
|
||||
~~~http
|
||||
POST /api/document-rewrites
|
||||
Content-Type: application/json
|
||||
~~~
|
||||
|
||||
普通 Artifact 请求:
|
||||
|
||||
~~~json
|
||||
{
|
||||
"source": {
|
||||
"kind": "artifact",
|
||||
"thread_id": "thread_xxx",
|
||||
"path": "/mnt/user-data/outputs/report.md"
|
||||
},
|
||||
"presentation_thread_id": "thread_xxx",
|
||||
"mode": "document",
|
||||
"instruction": "统一为正式行业分析语气,减少重复,不新增外部事实",
|
||||
"style": "formal_analysis",
|
||||
"model_name": "system_default"
|
||||
}
|
||||
~~~
|
||||
|
||||
深度研究请求:
|
||||
|
||||
~~~json
|
||||
{
|
||||
"source": {
|
||||
"kind": "deep_research_report",
|
||||
"session_id": "drs_xxx"
|
||||
},
|
||||
"presentation_thread_id": "collector_thread_xxx",
|
||||
"mode": "document",
|
||||
"instruction": "重新组织全文逻辑,保留全部可用引用",
|
||||
"style": "formal_analysis",
|
||||
"model_name": "system_default"
|
||||
}
|
||||
~~~
|
||||
|
||||
其他接口:
|
||||
|
||||
~~~text
|
||||
GET /api/document-rewrites/{job_id}
|
||||
GET /api/document-rewrites/{job_id}/stream?after_seq=42
|
||||
POST /api/document-rewrites/{job_id}/cancel
|
||||
POST /api/document-rewrites/{job_id}/undo
|
||||
GET /api/document-rewrites?presentation_thread_id={thread_id}
|
||||
~~~
|
||||
|
||||
SSE 事件:
|
||||
|
||||
| 事件 | 关键数据 | 前端动作 |
|
||||
| --- | --- | --- |
|
||||
| `job_started` | 文件名、模式 | 建立步骤条 |
|
||||
| `snapshot_fixed` | 原文字数、哈希 | 完成“固定原文版本” |
|
||||
| `requirements_resolved` | 模型、风格、要求 | 完成“理解改写要求” |
|
||||
| `rewrite_delta` | `delta`、累计字符数 | 追加右侧临时草稿 |
|
||||
| `validation_started` | 校验项 | 进入校验步骤 |
|
||||
| `validation_completed` | passed、warnings | 显示校验结果 |
|
||||
| `comparison_ready` | `DiffSummary` | 渲染总结数据 |
|
||||
| `commit_started` | 无 | 显示安全替换中 |
|
||||
| `committed` | 最终哈希、版本 ID | 刷新真实文件缓存 |
|
||||
| `completed` | 完整结果 | 固化历史步骤条 |
|
||||
| `conflict` / `error` / `cancelled` | 原因 | 恢复原文显示,不保存草稿 |
|
||||
|
||||
局部改写兼容方案:
|
||||
|
||||
- `POST /api/writing/rewrite` 的请求和 SSE 格式先不变。
|
||||
- `writing.py` 内部逐步把 prompt、模型流、基础 Markdown 校验下沉给统一管线的 `preview_only` 分支。
|
||||
- 深度研究 `/sessions/{id}/rewrite/stream` 和确认候选接口也先保持 URL、前端行为不变,再内部复用 `manual_apply` 分支。
|
||||
|
||||
这样可以先交付全文改写,不会一次性破坏已有编辑器和深度研究流程。
|
||||
|
||||
### 4.6 模型、提示词与校验
|
||||
|
||||
管线只依赖一个流式模型端口:
|
||||
|
||||
~~~python
|
||||
class StreamingRewriteModel(Protocol):
|
||||
async def stream_text(
|
||||
self,
|
||||
*,
|
||||
messages: list[BaseMessage],
|
||||
model_name: str | None,
|
||||
) -> AsyncIterator[TextChunk]: ...
|
||||
~~~
|
||||
|
||||
第一版可以有两个 adapter:
|
||||
|
||||
- 普通文档复用 `writing.py` 的 chat model `astream`;
|
||||
- 深度研究复用 `deep_research.py` 使用的 `DeerFlowCompletionBackend.stream_complete`。
|
||||
|
||||
先统一编排,不强制立即统一所有模型配置实现。
|
||||
|
||||
普通 Markdown 系统约束:
|
||||
|
||||
~~~text
|
||||
仅根据原文与用户要求改写,不联网,不补充未给出的事实。
|
||||
保留有效链接、代码块、表格、标题语义和用户明确要求保留的内容。
|
||||
只输出完整 Markdown 正文;不得输出解释、前缀或“改写如下”。
|
||||
~~~
|
||||
|
||||
深度研究额外约束:
|
||||
|
||||
~~~text
|
||||
不得联网。事实、数字、结论和引用只能来自当前 session 已选资料。
|
||||
只允许使用给出的 source_id;资料不足时保持谨慎表述,禁止自行补全。
|
||||
~~~
|
||||
|
||||
校验规则:
|
||||
|
||||
| 类别 | 硬失败 | 告警但允许完成 |
|
||||
| --- | --- | --- |
|
||||
| 正文 | 空文档、仅空白、无可见内容 | 改写后与原文完全一致 |
|
||||
| Markdown | 未闭合围栏、明确截断 | 标题层级跳级、章节数明显减少 |
|
||||
| 深度研究来源 | 未选来源 ID、非法引用 | 引用数量下降、可能需核验的数字断言 |
|
||||
|
||||
## 5. 安全提交、对比与撤销
|
||||
|
||||
### 5.1 临时草稿不等于真实文件
|
||||
|
||||
用户视觉上看到“先清空再逐 token 写入”,但真实文件的处理必须是:
|
||||
|
||||
1. 读取原文并保存内容哈希、版本快照。
|
||||
2. 右侧只展示内存/Job 中的 `draft_content`。
|
||||
3. 生成完成后做校验和 diff。
|
||||
4. 提交前再次读取真实来源,并比较 `SHA-256`。
|
||||
5. 哈希一致才原子替换;否则进入冲突,不覆盖。
|
||||
|
||||
普通 Artifact 使用同目录临时文件、刷盘和 `os.replace`;深度研究通过数据库事务内的“旧哈希条件更新”提交。任何失败、取消或断流都保留真实原文。
|
||||
|
||||
### 5.2 撤销
|
||||
|
||||
撤销流程:
|
||||
|
||||
1. 读取该 Job 的 `before_full_rewrite` 快照。
|
||||
2. 确认当前真实内容哈希仍等于该 Job 的 `committed_sha256`。
|
||||
3. 若用户在改写后又手工编辑,返回冲突,不盲目覆盖。
|
||||
4. 先保存当前内容为 `before_undo` 版本。
|
||||
5. 原子恢复旧版本,Job 状态改为 `reverted`,消息列表卡片同步显示“已撤销”。
|
||||
|
||||
### 5.3 结构化 DiffSummary
|
||||
|
||||
后端产出结构化差异,前端仅渲染,避免生成空泛结论:
|
||||
|
||||
~~~ts
|
||||
type DiffSummary = {
|
||||
sourceDisplayName: string;
|
||||
instruction: string;
|
||||
modelName: string;
|
||||
originalCharCount: number;
|
||||
rewrittenCharCount: number;
|
||||
charChangeRatio: number;
|
||||
changedBlockCount: number;
|
||||
headings: {
|
||||
added: string[];
|
||||
removed: string[];
|
||||
reordered: string[];
|
||||
levelWarnings: string[];
|
||||
};
|
||||
citations?: {
|
||||
beforeCount: number;
|
||||
afterCount: number;
|
||||
retainedCount: number;
|
||||
removedIds: string[];
|
||||
invalidIds: string[];
|
||||
};
|
||||
requestedOptimizations: string[];
|
||||
validationWarnings: string[];
|
||||
};
|
||||
~~~
|
||||
|
||||
推荐总结展示:
|
||||
|
||||
~~~text
|
||||
已完成《新能源汽车市场分析报告》的整体改写。
|
||||
|
||||
- 改写范围:全文;要求为“统一正式分析语气、减少重复”;模型:默认模型。
|
||||
- 数量变化:原文 8,260 字,改写后 8,110 字,减少 1.8%;调整 14 个内容块。
|
||||
- 结构变化:标题层级保持不变;未新增或删除一级标题。
|
||||
- 已执行的优化:按要求合并重复表述、统一语气、调整段落衔接。
|
||||
- 保留情况:保留 35 条已选来源引用,未新增外部来源。
|
||||
- 风险提示:无。
|
||||
|
||||
[查看前后对比] [撤销到改写前版本]
|
||||
~~~
|
||||
|
||||
“已执行的优化”仅来自用户要求和可计算的结构差异;不能无依据宣称“文章更专业、更严谨”。
|
||||
|
||||
## 6. 前端实现设计
|
||||
|
||||
### 6.1 新增模块
|
||||
|
||||
~~~text
|
||||
frontend-web/src/core/document-rewrite/
|
||||
├─ types.ts
|
||||
├─ api.ts
|
||||
├─ sse.ts
|
||||
├─ use-document-rewrite-job.ts
|
||||
├─ document-rewrite-provider.tsx
|
||||
└─ document-rewrite-presentation.ts
|
||||
|
||||
frontend-web/src/components/workspace/document-rewrite/
|
||||
├─ DocumentRewriteDialog.tsx
|
||||
├─ DocumentRewriteProgressStep.tsx
|
||||
├─ DocumentRewriteSummaryCard.tsx
|
||||
└─ DocumentDiffDialog.tsx
|
||||
~~~
|
||||
|
||||
职责:
|
||||
|
||||
| 模块 | 职责 |
|
||||
| --- | --- |
|
||||
| `api.ts` | 创建、查询、取消、撤销 Job |
|
||||
| `sse.ts` | SSE 解析、`after_seq` 重连 |
|
||||
| `use-document-rewrite-job.ts` | Job 状态、草稿、rAF 合并 token |
|
||||
| Provider | 同时向文件面板与消息列表提供同一任务状态 |
|
||||
| `DocumentRewriteDialog` | 改写要求、模型、风格配置 |
|
||||
| ProgressStep | 普通消息列表内的五步步骤条 |
|
||||
| SummaryCard | 结构化总结、diff、撤销动作 |
|
||||
| DiffDialog | 原文与改写后双栏/行级对比 |
|
||||
|
||||
模型选择使用项目已有的 `tdesign-react` 选择组件,放在全文改写配置弹窗中;局部编辑器保留当前模型传递方式,后续再统一选择器。
|
||||
|
||||
### 6.2 流式平滑:每帧合并 delta
|
||||
|
||||
不能因为 SSE 是 token 流就每 token 调一次 `setState`。应当网络层完整读取、渲染层按动画帧合并:
|
||||
|
||||
~~~ts
|
||||
const pendingDeltaRef = useRef("");
|
||||
const frameRef = useRef<number | null>(null);
|
||||
|
||||
function onRewriteDelta(delta: string) {
|
||||
pendingDeltaRef.current += delta;
|
||||
if (frameRef.current !== null) return;
|
||||
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
const pending = pendingDeltaRef.current;
|
||||
pendingDeltaRef.current = "";
|
||||
frameRef.current = null;
|
||||
setDraftContent((previous) => previous + pending);
|
||||
});
|
||||
}
|
||||
~~~
|
||||
|
||||
完成、失败、取消前必须 flush 最后一段缓存。右侧代码编辑器只消费 `draftContent`;真实 Artifact 查询缓存直到 `committed` 事件才更新。
|
||||
|
||||
### 6.3 ArtifactFileDetail 改造
|
||||
|
||||
在 `artifact-file-detail.tsx` 增加:
|
||||
|
||||
~~~ts
|
||||
type DocumentRewriteTarget =
|
||||
| {
|
||||
kind: "artifact";
|
||||
threadId: string;
|
||||
path: string;
|
||||
presentationThreadId: string;
|
||||
}
|
||||
| {
|
||||
kind: "deep_research_report";
|
||||
sessionId: string;
|
||||
presentationThreadId: string;
|
||||
};
|
||||
~~~
|
||||
|
||||
改造步骤:
|
||||
|
||||
1. 加入右上角全文改写图标按钮和 `DocumentRewriteDialog`。
|
||||
2. 有活跃任务时强制 `viewMode = "code"`。
|
||||
3. 内容优先级为:`activeRewrite.draftContent ?? draftContent ?? artifactContent`。
|
||||
4. 活跃任务中代码只读,禁用保存、局部编辑和重复全文改写。
|
||||
5. 收到 `committed` 后用现有 `useUpdateArtifactContent` 或等价 Query cache 更新,使真实文件立即同步。
|
||||
6. 收到 `conflict/error/cancelled` 时废弃临时草稿显示,回到真实文件内容。
|
||||
|
||||
### 6.4 ResearchSandboxPanel 接入
|
||||
|
||||
相关文件:
|
||||
|
||||
- `frontend-web/src/pages/deep-research/ResearchSandboxPanel.tsx`
|
||||
- `frontend-web/src/pages/deep-research/useReportSandbox.ts`
|
||||
- `frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx`
|
||||
|
||||
改造:
|
||||
|
||||
1. `ResearchSandboxPanel` 为虚拟 `report.md` 传入 `kind: "deep_research_report"` target。
|
||||
2. `useReportSandbox` 接受 `externalDraftOverride`:
|
||||
|
||||
~~~ts
|
||||
const visibleReport = activeDocumentRewrite?.isActive
|
||||
? activeDocumentRewrite.draftContent
|
||||
: reportMarkdown;
|
||||
~~~
|
||||
|
||||
3. 流式期间只显示临时草稿,不提前写 session 报告。
|
||||
4. `committed` 后刷新/替换 session 的 `report_markdown`;失败后恢复旧报告。
|
||||
5. 当前“改写第一段 → 候选消息 → 确认应用”逻辑继续保留,不因全文改写而改变。
|
||||
|
||||
### 6.5 普通消息列表接入
|
||||
|
||||
复用:
|
||||
|
||||
- `frontend-web/src/components/workspace/messages/message-list.tsx`
|
||||
- `frontend-web/src/components/workspace/messages/MessageGroup.tsx`
|
||||
- `DeepResearchWorkbench.tsx` 中的虚拟消息注入模式。
|
||||
|
||||
`DocumentRewritePresentationProvider` 按 `presentation_thread_id` 读取 Job 历史并投影为:
|
||||
|
||||
1. 用户消息:“AI 全文改写《xxx.md》”;
|
||||
2. AI 工具调用:`document_rewrite_progress`;
|
||||
3. AI 富消息:`document_rewrite_summary`。
|
||||
|
||||
`MessageGroup.tsx` 增加 `document_rewrite_progress` 分支,渲染 `DocumentRewriteProgressStep`。最终摘要渲染 `DocumentRewriteSummaryCard`,其内含“查看前后对比”和“撤销”。
|
||||
|
||||
全文步骤固定为:
|
||||
|
||||
| 步骤 | 展示内容 |
|
||||
| --- | --- |
|
||||
| 固定原文版本 | 文件名、原文字数、快照已保存 |
|
||||
| 理解改写要求 | 改写目标、模型、风格 |
|
||||
| 流式重写 Markdown | 正在写入右侧 xxx.md、累计字数 |
|
||||
| 校验文档 | 非空、标题、围栏、截断;报告还含来源检查 |
|
||||
| 对比原文并生成说明 | 差异、风险、撤销入口 |
|
||||
|
||||
步骤条必须在完成后保留,不能因为流结束被隐藏。
|
||||
|
||||
## 7. 核心执行伪代码
|
||||
|
||||
~~~python
|
||||
async def run_document_rewrite(job_id: str) -> None:
|
||||
job = await jobs.acquire_lease(job_id)
|
||||
source = source_factory.create(job) # 已完成鉴权的来源适配器
|
||||
|
||||
await emit("snapshotting")
|
||||
snapshot = await source.read_snapshot()
|
||||
base_hash = sha256(snapshot.content)
|
||||
await jobs.freeze_snapshot(job_id, snapshot, base_hash)
|
||||
await emit("snapshot_fixed", char_count=count_chars(snapshot.content))
|
||||
|
||||
messages = prompt_builder.build(
|
||||
mode=job.mode,
|
||||
source_kind=job.source_kind,
|
||||
original=snapshot.content,
|
||||
instruction=job.instruction,
|
||||
style=job.style,
|
||||
research_sources=snapshot.allowed_sources,
|
||||
)
|
||||
await emit("requirements_resolved", ...)
|
||||
|
||||
draft = ""
|
||||
async for chunk in model.stream_text(messages=messages, model_name=job.model_name):
|
||||
if await jobs.cancel_requested(job_id):
|
||||
await jobs.mark_cancelled(job_id)
|
||||
await emit("cancelled")
|
||||
return
|
||||
draft += chunk.text
|
||||
await jobs.append_draft_periodically(job_id, draft)
|
||||
await emit("rewrite_delta", delta=chunk.text)
|
||||
|
||||
await emit("validation_started")
|
||||
validation = validate_markdown(
|
||||
draft,
|
||||
source_kind=job.source_kind,
|
||||
allowed_citations=snapshot.allowed_citation_ids,
|
||||
)
|
||||
if validation.has_fatal_error:
|
||||
await jobs.mark_failed(job_id, validation.message)
|
||||
await emit("error", ...)
|
||||
return
|
||||
await emit("validation_completed", ...)
|
||||
|
||||
diff = build_diff_summary(snapshot.content, draft, validation)
|
||||
await jobs.save_diff(job_id, diff)
|
||||
await emit("comparison_ready", diff)
|
||||
|
||||
if job.commit_policy != AUTO_COMMIT_AFTER_VALIDATION:
|
||||
await jobs.mark_candidate_ready(job_id)
|
||||
return
|
||||
|
||||
await emit("commit_started")
|
||||
await versions.save_before_rewrite(job, snapshot)
|
||||
result = await source.commit_if_unchanged(
|
||||
expected_hash=base_hash,
|
||||
content=draft,
|
||||
metadata=CommitMetadata(job_id=job_id),
|
||||
)
|
||||
if not result.ok:
|
||||
await jobs.mark_conflict(job_id)
|
||||
await emit("conflict", ...)
|
||||
return
|
||||
|
||||
await jobs.mark_completed(job_id, result.sha256)
|
||||
await emit("committed", ...)
|
||||
await emit("completed", diff)
|
||||
~~~
|
||||
|
||||
## 8. 实施顺序
|
||||
|
||||
### 阶段 A:后端内核与安全基础
|
||||
|
||||
1. 新增领域模型、来源端口、校验、确定性 diff。
|
||||
2. 新增 Job、事件、版本数据表与 migration。
|
||||
3. 参考深度研究 executor 完成后台任务、取消、SSE 重放。
|
||||
4. 实现普通 Artifact 读取、哈希比较、原子提交和撤销 API。
|
||||
5. 编写接口测试。
|
||||
|
||||
验收:可以通过 API 对普通 Markdown 创建全文任务;取消、失败、冲突均不改原文件;成功后可撤销。
|
||||
|
||||
### 阶段 B:普通沙箱全文 UI
|
||||
|
||||
1. 新增 `core/document-rewrite` 前端状态、SSE、rAF hook。
|
||||
2. 改 `ArtifactFileDetail`:右上角按钮、配置弹窗、临时草稿、禁用态。
|
||||
3. 接入普通 `MessageList` 的步骤条、总结卡、diff 和撤销。
|
||||
4. 支持刷新后恢复 Job 状态与已完成历史。
|
||||
|
||||
验收:右侧可平滑逐 token 写入;完成后替换真实 Markdown;左侧步骤和总结保留;撤销成功恢复。
|
||||
|
||||
### 阶段 C:深度研究接入
|
||||
|
||||
1. 实现 `DeepResearchReportSource`。
|
||||
2. 由 `ResearchSandboxPanel` 传入报告 target。
|
||||
3. `useReportSandbox` 支持外部临时草稿。
|
||||
4. 对深度研究添加引用白名单校验。
|
||||
5. 保持现有报告局部候选确认卡不变,再逐步下沉公共 prompt/校验逻辑。
|
||||
|
||||
验收:报告全文改写不触发联网搜索,不允许新来源;局部报告改写依然先确认再应用。
|
||||
|
||||
### 阶段 D:局部改写内核迁移
|
||||
|
||||
1. 保持 `/api/writing/rewrite` 契约不变。
|
||||
2. 将 `writing.py` 转为 `InlineDocumentSource + preview_only` 的兼容 facade。
|
||||
3. 给 `useWritingRewrite` 增加原文快照失效检查。
|
||||
4. 回归替换、插入、重试、继续追问。
|
||||
|
||||
## 9. 测试与验收
|
||||
|
||||
建议新增后端测试:
|
||||
|
||||
~~~text
|
||||
offline-backend-20260512/backend/tests/
|
||||
├─ test_document_rewrite_pipeline.py
|
||||
├─ test_document_rewrite_api.py
|
||||
├─ test_document_rewrite_versions.py
|
||||
└─ test_document_rewrite_deep_research.py
|
||||
~~~
|
||||
|
||||
必须覆盖:
|
||||
|
||||
- Job 状态、SSE 顺序和断线恢复;
|
||||
- 空输出、围栏不闭合、截断、非法深度研究引用;
|
||||
- 同一文件并发任务;
|
||||
- 提交前手工修改导致的冲突;
|
||||
- 取消/异常不改真实文件;
|
||||
- 正常撤销、撤销前手工编辑导致的冲突;
|
||||
- 旧 `/api/writing/rewrite`、深度研究 `/rewrite/stream` 兼容回归。
|
||||
|
||||
前端必须覆盖:
|
||||
|
||||
- 多个 delta 在一帧合并,最终内容不丢失;
|
||||
- `committed` 前 Artifact 缓存不被临时草稿污染;
|
||||
- 失败/取消/冲突后右侧恢复原文;
|
||||
- 活跃任务禁用同文件编辑、保存、重复改写;
|
||||
- 刷新后历史步骤条与总结仍存在;
|
||||
- 深度研究虚拟 `report.md` 可用全文改写;
|
||||
- 局部编辑器原文变化后不能应用过期候选。
|
||||
|
||||
执行基线:
|
||||
|
||||
~~~powershell
|
||||
cd offline-backend-20260512/backend
|
||||
$env:PYTHONPATH = "."
|
||||
uv run --no-sync pytest tests/test_document_rewrite_pipeline.py -v
|
||||
uv run --no-sync pytest tests/test_document_rewrite_api.py -v
|
||||
uv run --no-sync ruff check app packages/harness
|
||||
|
||||
cd ../../frontend-web
|
||||
pnpm typecheck
|
||||
pnpm build
|
||||
~~~
|
||||
|
||||
## 10. 风险控制
|
||||
|
||||
| 风险 | 控制措施 |
|
||||
| --- | --- |
|
||||
| 用户误以为真实文件已经清空 | 明确显示“临时草稿,原文件尚未修改” |
|
||||
| 流式显示仍然卡顿 | 网络完整读取 + `requestAnimationFrame` 合并渲染 |
|
||||
| 改写中用户手动编辑 | `SHA-256` compare-and-swap,冲突即拒绝覆盖 |
|
||||
| 输出 Markdown 不完整 | 非空、围栏、截断和标题检查 |
|
||||
| 深度研究幻觉 | 来源白名单、禁止联网、引用 ID 校验 |
|
||||
| 文档过大 | 第一版拒绝并提示按章节改写,不隐式截断 |
|
||||
| 局部编辑污染聊天历史 | 局部只在编辑器弹窗展示,不创建完整步骤条 |
|
||||
| 历史记录只存在浏览器内存 | 全文 Job/Event/Diff 持久化,按展示线程恢复 |
|
||||
|
||||
## 11. 复用点清单
|
||||
|
||||
| 现有实现 | 在新方案中的用途 |
|
||||
| --- | --- |
|
||||
| `MarkdownAiEditor.tsx` | 保留局部编辑菜单、预览弹窗、确认动作 |
|
||||
| `useWritingRewrite.ts` | 保留局部流控制,增加快照失效保护 |
|
||||
| `open-canvas/api/rewrite.ts` | 保持兼容,逐步接入统一内核 |
|
||||
| `routers/writing.py` | 复用当前模型流式读取,抽取 prompt/流逻辑 |
|
||||
| `routers/deep_research.py` | 复用报告来源、哈希候选、来源约束 |
|
||||
| `deep_research_job_executor.py` | 复用后台 Job、取消、事件重放模式 |
|
||||
| `artifact-file-detail.tsx` | 复用沙箱文件操作、代码/预览、缓存同步 |
|
||||
| `useUpdateArtifactContent` | 完成后更新普通 Artifact 缓存 |
|
||||
| `ResearchSandboxPanel.tsx` | 复用研究报告的右侧沙箱宿主 |
|
||||
| `useReportSandbox.ts` | 加入临时草稿覆盖显示 |
|
||||
| `MessageList.tsx / MessageGroup.tsx` | 复用普通消息步骤条、总结消息、文件卡片能力 |
|
||||
| `DeepResearchWorkbench.tsx` | 复用向普通消息列表投影过程事件的模式 |
|
||||
|
||||
## 12. 完成定义
|
||||
|
||||
完成后应满足:
|
||||
|
||||
1. 局部编辑依旧是编辑器弹窗中的人工确认操作。
|
||||
2. 全文改写在普通消息列表展示完整且不隐藏的步骤条。
|
||||
3. 右侧沙箱可平滑地逐 token 展示临时 Markdown 草稿。
|
||||
4. 任何中断、取消、校验失败和冲突都不损坏真实文件。
|
||||
5. 完成后用户可以看到量化差异、结构变化、来源保留和风险提示。
|
||||
6. 普通 Markdown 与深度研究报告都可全文改写,但研究报告绝不绕过已选来源约束。
|
||||
7. 每一次真实全文覆盖都有版本快照,并且能够安全撤销。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
|
||||
"test:maf": "vitest run --config vitest.maf.config.ts",
|
||||
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
|
||||
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
|
||||
},
|
||||
@@ -107,19 +107,23 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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/gsap": "^3.0.0",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"jsdom": "^30.0.1",
|
||||
"lightningcss": "^1.30.2",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.0.15",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.8.2",
|
||||
"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"
|
||||
}
|
||||
@@ -32,10 +32,10 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
VITE_BRAND_WELCOME_DARK_URL: "",
|
||||
|
||||
// DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。
|
||||
// VITE_BACKEND_BASE_URL: "https://ch1.b.uat.4.cn/deerflow",
|
||||
// VITE_BACKEND_BASE_URL: "http://10.19.200.5:2026",
|
||||
|
||||
// LangGraph 流式对话接口地址:普通问答、智能体对话等流式能力使用。
|
||||
// VITE_LANGGRAPH_BASE_URL: "https://ch1.b.uat.4.cn/deerflow/api",
|
||||
// VITE_LANGGRAPH_BASE_URL: "http://10.19.200.5:2026/api",
|
||||
|
||||
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
|
||||
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
|
||||
@@ -50,19 +50,6 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// CMZS 后端接口:任务管理、任务分析、三情分析、行动分析等业务接口。
|
||||
VITE_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm",
|
||||
|
||||
// 8BF 嵌入页专用任务系统接口。部署到其他系统时只需修改这一项,
|
||||
// 不影响原多智能体会商使用的任务详情接口。
|
||||
VITE_8BF_TASK_API_BASE_URL: "https://ch1.b.uat.4.cn/zl_consumer",
|
||||
|
||||
// 8BF 会商页右上角「返回」按钮的跳转地址(当前窗口 window.location.href)。
|
||||
// 部署后直接改 dist/runtime-config.js 即可,无需重新打包。留空则点击时提示未配置。
|
||||
VITE_8BF_ROUNDTABLE_BACK_URL: "",
|
||||
|
||||
// 任务深链「宿主路由」按钮的 postMessage targetOrigin(HOST_NAVIGATE)。
|
||||
// 填宿主系统 origin,例如 https://localhost:4912;生产改成他们线上地址。
|
||||
// 留空时尝试用 iframe 的 ancestorOrigins / referrer 自动探测,探测失败则不发送。
|
||||
VITE_HOST_NAVIGATE_ORIGIN: "",
|
||||
|
||||
// 佳来模型对话接口。
|
||||
VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt",
|
||||
|
||||
@@ -103,7 +90,7 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
VITE_AIPI_FRONTEND_URL: "http://47.88.25.99:3000",
|
||||
|
||||
// Consumer 接口:深链任务详情 cop-task-detail、研判情报报告 selectSqReport 等使用。
|
||||
VITE_AIPI_CONSUMER_URL: "https://ch1.b.uat.4.cn/consumer",
|
||||
VITE_AIPI_CONSUMER_URL: "http://10.19.200.5:8002/consumer",
|
||||
|
||||
// A 系统跳转地址:人物、组织、事件等列表项打开链接使用。
|
||||
VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain",
|
||||
@@ -143,30 +130,6 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// 隐藏系统 Logo、换肤按钮以及“全量/简洁模式”切换。修改后刷新页面即可生效。
|
||||
VITE_FORCE_COLLAPSED_SIDEBAR: "false",
|
||||
|
||||
// 简介模式左侧菜单类型:与菜单管理页「简介模式」里的类型 A / B 对应。
|
||||
// 两套网页部署时分别填 "A" 或 "B"。不填、留空、或旧配置没有这一项,都按类型 A,
|
||||
// 继续读取原来的简介菜单数据,无需改旧页面。
|
||||
VITE_COMPACT_MENU_TYPE: "A",
|
||||
|
||||
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
|
||||
VITE_GITHUB_OAUTH_TOKEN: "",
|
||||
|
||||
// 是否在问答输入框「模式」菜单显示「极速」:"true" 显示,"false" 隐藏。
|
||||
// 默认关闭。开启后可直连大模型流式问答,不走智能体工具与技能。
|
||||
// 部署后改 dist/runtime-config.js 再刷新页面即可,无需重新打包。
|
||||
VITE_TURBO_MODE_VISIBLE: "true",
|
||||
|
||||
// 是否在智能体管理页显示「舆情分析」类型:"true" 显示,"false" 隐藏。
|
||||
// 关闭后,该智能体的专用对话地址也会自动跳回智能体管理页。
|
||||
VITE_SENTIMENT_AGENT_VISIBLE: "true",
|
||||
|
||||
// 舆情分析智能体:外部流式问答接口地址(POST,SSE 流式返回)。
|
||||
// 前端会把该地址随请求传给后台转发。留空时该智能体对话页会提示未配置。
|
||||
VITE_SENTIMENT_AGENT_API_URL: "http://alg.gw.y.uat.cn/a6_report_agent/alg/stream/report-agent",
|
||||
|
||||
// 舆情分析智能体:请求头 Authorization Basic Auth 用户名。
|
||||
VITE_SENTIMENT_AGENT_AUTH_USERNAME: "1054841959975223296",
|
||||
|
||||
// 舆情分析智能体:请求头 Authorization Basic Auth 密码。
|
||||
VITE_SENTIMENT_AGENT_AUTH_PASSWORD: "LzqqMTUxMTIwMjQxNjQ2MjY5MDUduC",
|
||||
};
|
||||
@@ -31,16 +31,6 @@ const requiredLegacyFallbacks = [
|
||||
pattern: /<InputGroup className="h-auto min-h-16">/,
|
||||
description: "Prompt textarea must not rely on :has() for its parent height",
|
||||
},
|
||||
{
|
||||
file: "src/core/utils/mermaid.ts",
|
||||
pattern: /htmlLabels:\s*false[\s\S]*secure:\s*\[[\s\S]*"htmlLabels"/,
|
||||
description: "Sandbox flowcharts must use native SVG labels in legacy browsers",
|
||||
},
|
||||
{
|
||||
file: "src/core/utils/legacy-html-preview.ts",
|
||||
pattern: /export function downlevelModernColors[\s\S]*function injectLegacyChartShim/,
|
||||
description: "Generated HTML charts must downlevel modern CSS colors for Chrome 109",
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of cssFiles) {
|
||||
|
||||
+11
-24
@@ -38,21 +38,14 @@ import EmbedChatPage from "./pages/EmbedChatPage";
|
||||
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
||||
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
|
||||
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
||||
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
|
||||
import {
|
||||
isPositionRoundtableStandaloneMode,
|
||||
POSITION_ROUNDTABLE_ROUTE,
|
||||
} from "@/position-roundtable/demo/position-roundtable-demo-config";
|
||||
import { ChatRuntime } from "./pages/WorkspaceRoutes";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
import OfficialDeerFlowEntry from "./pages/OfficialDeerFlowEntry";
|
||||
|
||||
const PUBLIC_EMBED_PREFIXES = [
|
||||
"/share/",
|
||||
"/public/",
|
||||
"/roundtable-share/",
|
||||
"/embed/knowledge/",
|
||||
];
|
||||
const PUBLIC_EMBED_PREFIXES = ["/share/", "/public/", "/roundtable-share/"];
|
||||
|
||||
function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
const locale = useMemo(() => detectLocale(), []);
|
||||
@@ -109,17 +102,16 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
forcedTheme={forcedTheme}
|
||||
>
|
||||
<QueryClientProvider>
|
||||
<AuthSyncWatcher disabled={positionStandaloneMode}>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
{!positionStandaloneMode && <DesensitizeWordsLoader />}
|
||||
<FontScaleApplier />
|
||||
{children}
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</AuthSyncWatcher>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
{!positionStandaloneMode && <AuthSyncWatcher />}
|
||||
{!positionStandaloneMode && <DesensitizeWordsLoader />}
|
||||
<FontScaleApplier />
|
||||
{children}
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>
|
||||
</NextThemesProvider>
|
||||
);
|
||||
@@ -196,15 +188,10 @@ export function App() {
|
||||
}
|
||||
/>
|
||||
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
|
||||
<Route
|
||||
path="/embed/knowledge/:noteId"
|
||||
element={<WeKnoraWikiPageView publicEmbed />}
|
||||
/>
|
||||
<Route
|
||||
path="/roundtable-dashboard"
|
||||
element={<DashboardStandalonePage />}
|
||||
/>
|
||||
<Route path="/official/*" element={<OfficialDeerFlowEntry />} />
|
||||
<Route path="/page/*" element={pageElement} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2064"
|
||||
height="2591" viewBox="0 0 2064 2591">
|
||||
<path fill-rule="nonzero"
|
||||
d="M 68.582031 0 C 66.890625 0 65.300781 1 64.617188 2.640625 C 49.46875 38.800781 33.71875 76.199219 33.628906 106.539062 C 33.550781 134.230469 46.445312 155.730469 59.949219 175.71875 L 59.949219 175.730469 C 52.414062 175.671875 44.992188 175.371094 37.472656 174.171875 C 27.210938 172.53125 16.585938 169.199219 5.585938 165.738281 C 5.15625 165.609375 4.722656 165.539062 4.296875 165.539062 C 2.824219 165.539062 1.417969 166.300781 0.625 167.609375 C -0.402344 169.300781 -0.140625 171.46875 1.257812 172.871094 L 1.261719 172.878906 C 11.230469 182.871094 21.8125 193.53125 36.980469 200.050781 C 51.5625 206.320312 70.167969 208.628906 88.085938 210.78125 L 88.027344 210.78125 C 127.152344 253.410156 166.617188 296.300781 215.761719 334.941406 C 230.558594 346.578125 246.292969 357.878906 261.785156 369 C 277.171875 380.050781 292.441406 391.011719 306.660156 402.191406 C 326.132812 417.511719 343.5625 433.179688 356.773438 450.019531 C 377.808594 476.828125 388.449219 506.960938 395.4375 538.628906 L 395.4375 538.640625 C 372.683594 510.710938 349.328125 483.929688 324.855469 469.339844 C 295.957031 452.121094 265.800781 452 236.960938 451.808594 L 236.933594 451.820312 C 235.792969 451.808594 234.707031 452.261719 233.898438 453.070312 C 233.085938 453.871094 232.632812 454.96875 232.636719 456.121094 C 232.722656 494.011719 232.699219 533.539062 256.304688 567.480469 C 279.351562 600.621094 324.738281 628.019531 369.191406 654.679688 L 369.132812 654.691406 C 371.476562 683.539062 373.40625 710.691406 367.109375 728.03125 C 365.828125 731.558594 364.191406 734.71875 362.058594 738.820312 C 358.910156 744.871094 354.964844 752.441406 350.417969 764.960938 C 342.890625 785.699219 333.769531 819.839844 323.648438 883.109375 C 303.347656 1010.019531 278.722656 1255.949219 308.882812 1409.269531 C 326.265625 1497.660156 361.828125 1555.210938 393.511719 1606.519531 C 417.042969 1644.632812 438.207031 1678.960938 448.835938 1720.09375 C 466.921875 1790.089844 454.648438 1880.246094 444.5 1954.386719 C 440.613281 1982.78125 437.042969 2008.839844 435.726562 2030.261719 C 433.410156 2067.929688 438.066406 2091.179688 442.304688 2112.347656 L 442.304688 2112.355469 C 445.910156 2130.347656 449.152344 2146.589844 447.757812 2169.28125 C 447.429688 2174.613281 446.835938 2180.359375 445.933594 2186.535156 L 445.933594 2186.542969 C 436.582031 2250.328125 393.613281 2362.808594 362.105469 2416.636719 C 339.761719 2454.804688 323.546875 2462.851562 309.597656 2469.925781 C 303.980469 2472.777344 298.261719 2475.660156 292.691406 2480.238281 C 273.378906 2496.113281 256.457031 2532.078125 240.183594 2566.417969 C 239.578125 2567.703125 239.640625 2569.203125 240.363281 2570.429688 C 241.082031 2571.65625 242.359375 2572.449219 243.777344 2572.542969 L 243.789062 2572.542969 L 243.882812 2572.550781 L 243.882812 2572.554688 C 261.582031 2573.746094 279.460938 2574.957031 296.386719 2574.957031 C 323.671875 2574.957031 349.332031 2571.84375 368.355469 2559.785156 C 392.640625 2544.394531 405.109375 2514.960938 414.488281 2493.074219 C 417.011719 2487.183594 419.289062 2481.871094 421.546875 2477.308594 C 432.054688 2456.039062 441.753906 2451.664062 451.855469 2446.960938 C 453.054688 2446.402344 453.929688 2445.320312 454.226562 2444.042969 L 454.238281 2443.996094 L 454.238281 2443.988281 C 476.210938 2348.800781 498.035156 2254.347656 524.300781 2196.363281 L 524.300781 2196.367188 C 536.363281 2169.730469 549.363281 2150.796875 562.695312 2131.402344 C 576.28125 2111.625 590.1875 2091.402344 603.300781 2062.445312 L 603.304688 2062.453125 C 605.183594 2058.292969 607.054688 2053.949219 608.894531 2049.421875 C 625.066406 2009.675781 639.746094 1954.289062 653.585938 1902.101562 C 663.0625 1866.367188 672.15625 1832.09375 681.085938 1805.085938 C 699.308594 1749.992188 716.863281 1725.09375 734.371094 1705.144531 L 734.421875 1705.148438 C 727.285156 1740.746094 721.597656 1776.628906 729.320312 1807.472656 C 733.351562 1823.570312 740.996094 1838.160156 749.066406 1853.613281 C 758.828125 1872.296875 769.433594 1892.636719 776.359375 1919.675781 C 783.289062 1946.71875 786.574219 1980.605469 789.527344 2011.140625 L 789.527344 2011.148438 C 791.875 2035.402344 794.023438 2057.78125 797.746094 2072.316406 L 797.746094 2072.320312 C 797.929688 2073.023438 798.128906 2073.769531 798.316406 2074.441406 L 798.320312 2074.441406 C 802.132812 2087.941406 807.617188 2093.914062 813.515625 2100.324219 C 821.132812 2108.597656 830.351562 2118.441406 839.742188 2151.671875 L 839.738281 2151.675781 C 840.105469 2152.96875 840.402344 2154.039062 840.765625 2155.398438 C 857.484375 2217.601562 874.097656 2354.589844 872.078125 2423.390625 C 870.472656 2478.15625 857.140625 2488.390625 845.339844 2498.136719 C 842.320312 2500.632812 839.085938 2503.296875 836.09375 2506.566406 C 820.847656 2523.230469 812.652344 2554.886719 804.800781 2584.753906 C 804.457031 2586.0625 804.75 2587.453125 805.589844 2588.515625 C 806.40625 Line truncated
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 19 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="12" viewBox="0 0 14 12" fill="none" aria-hidden="true">
|
||||
<path d="M13 6H1.5M1.5 6L6.2 1.4M1.5 6L6.2 10.6" stroke="#A2E1F9" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 258 B |
@@ -56,11 +56,12 @@ import {
|
||||
type ChangeEventHandler,
|
||||
Children,
|
||||
type ClipboardEventHandler,
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type FormEvent,
|
||||
type FormEventHandler,
|
||||
type HTMLAttributes,
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type FormEvent,
|
||||
type FormEventHandler,
|
||||
Fragment,
|
||||
type HTMLAttributes,
|
||||
type KeyboardEventHandler,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
@@ -123,11 +124,9 @@ export const usePromptInputController = () => {
|
||||
return ctx;
|
||||
};
|
||||
|
||||
// Optional variants (do NOT throw). Useful for dual-mode components and for
|
||||
// Fast Refresh transitions where a page can briefly retain the previous
|
||||
// provider context instance.
|
||||
export const useOptionalPromptInputController = () =>
|
||||
useContext(PromptInputController);
|
||||
// Optional variants (do NOT throw). Useful for dual-mode components.
|
||||
const useOptionalPromptInputController = () =>
|
||||
useContext(PromptInputController);
|
||||
|
||||
export const useProviderAttachments = () => {
|
||||
const ctx = useContext(ProviderAttachmentsContext);
|
||||
@@ -404,14 +403,14 @@ export function PromptInputAttachments({
|
||||
return (
|
||||
<div
|
||||
className={cn("flex w-full flex-wrap items-center gap-2 p-3", className)}
|
||||
{...props}
|
||||
>
|
||||
{attachments.files.map((file) => (
|
||||
<div key={file.id} className="max-w-60">
|
||||
{children(file)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{...props}
|
||||
>
|
||||
{attachments.files.map((file) => (
|
||||
<Fragment key={file.id}>
|
||||
<div className="max-w-60">{children(file)}</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ export const ReasoningTrigger = memo(
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: ReactNode;
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
@@ -178,11 +178,7 @@ export const ReasoningContent = memo(
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{typeof children === "string" ? (
|
||||
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
|
||||
</CollapsibleContent>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { FileOutput } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import responsibilityIcon from "@/assets/position-roundtable/责任岗位.svg";
|
||||
import resultTimeIcon from "@/assets/position-roundtable/时间.svg";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import "./analysis-results.css";
|
||||
import type { AnalysisResultCardItem } from "./types";
|
||||
|
||||
function formatResultTime(value: string | undefined, fallback: string): string {
|
||||
if (!value) return fallback;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function AnalysisResultCardList({
|
||||
items,
|
||||
emptyTitle = "暂无交付产物",
|
||||
emptyDescription = "第二步研讨与第三步总结生成 Markdown 后,产物会显示在这里。",
|
||||
padded = true,
|
||||
onOpen,
|
||||
}: {
|
||||
items: AnalysisResultCardItem[];
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
padded?: boolean;
|
||||
onOpen: (item: AnalysisResultCardItem) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="position-roundtable-analysis-results-empty flex h-full min-h-[260px] flex-col items-center justify-center p-6 text-center">
|
||||
<FileOutput className="mb-3 size-8 text-muted-foreground/50" />
|
||||
<p className="text-sm font-semibold">{emptyTitle}</p>
|
||||
<p className="mt-1 max-w-[240px] text-xs leading-5 text-muted-foreground">
|
||||
{emptyDescription}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("analysis-result-card-list", padded && "p-[15px]")}>
|
||||
{items.map((item, index) => {
|
||||
const displayedTime = formatResultTime(
|
||||
item.updatedAt,
|
||||
item.fallbackTime || "刚刚",
|
||||
);
|
||||
const displayedProductName = item.delivered ? item.productName : "未交付";
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="position-roundtable-analysis-result-card"
|
||||
onClick={() => {
|
||||
if (!item.delivered) {
|
||||
toast.info(
|
||||
item.unavailableMessage ||
|
||||
`「${desensitize(item.agentName)}」尚未交付可预览的 Markdown 文件。`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
onOpen(item);
|
||||
}}
|
||||
>
|
||||
<div className="position-roundtable-analysis-result-card-body">
|
||||
<div
|
||||
className={cn(
|
||||
"position-roundtable-analysis-result-tag",
|
||||
`position-roundtable-analysis-result-tag--${(index % 6) + 1}`,
|
||||
)}
|
||||
aria-label={desensitize(item.agentName)}
|
||||
>
|
||||
<span>{desensitize(item.agentName)}</span>
|
||||
</div>
|
||||
<p className="position-roundtable-analysis-result-product">
|
||||
{desensitize(displayedProductName)}
|
||||
</p>
|
||||
<footer className="position-roundtable-analysis-result-meta">
|
||||
<span>
|
||||
<img src={responsibilityIcon} alt="" aria-hidden="true" />
|
||||
{desensitize(item.positionName)}
|
||||
</span>
|
||||
<i aria-hidden="true" />
|
||||
<span>
|
||||
<img src={resultTimeIcon} alt="" aria-hidden="true" />
|
||||
{displayedTime}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FileOutput, Loader2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { ArtifactFilePreview } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { loadArtifactContent } from "@/core/artifacts/loader";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { Button as TButton } from "tdesign-react";
|
||||
|
||||
import type { AnalysisResultCardItem } from "./types";
|
||||
|
||||
function isArtifactErrorContent(content: string | null | undefined): boolean {
|
||||
const trimmed = content?.trim();
|
||||
if (!trimmed || !trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
const detail = parsed.detail ?? parsed.message ?? parsed.error;
|
||||
return typeof detail === "string" && detail.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackContent(item: AnalysisResultCardItem | null): string {
|
||||
return (
|
||||
item?.artifactContent?.trim() ||
|
||||
item?.summary?.trim() ||
|
||||
"暂无产物正文"
|
||||
);
|
||||
}
|
||||
|
||||
export function AnalysisResultDetailDialog({
|
||||
item,
|
||||
onOpenChange,
|
||||
extraBody,
|
||||
footerStart,
|
||||
}: {
|
||||
item: AnalysisResultCardItem | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
extraBody?: ReactNode;
|
||||
footerStart?: ReactNode;
|
||||
}) {
|
||||
const artifactThreadId = item?.artifactThreadId || null;
|
||||
const artifactPathValue = item?.artifactPath || "";
|
||||
const artifactContentQuery = useQuery({
|
||||
queryKey: [
|
||||
"analysis-results",
|
||||
"deliverable-artifact-content",
|
||||
artifactThreadId,
|
||||
artifactPathValue,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!artifactThreadId || !artifactPathValue) return "";
|
||||
const result = await loadArtifactContent({
|
||||
filepath: artifactPathValue,
|
||||
threadId: artifactThreadId,
|
||||
isMock: false,
|
||||
});
|
||||
return result.content;
|
||||
},
|
||||
enabled: Boolean(item && artifactThreadId && artifactPathValue),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const loadedArtifactContent =
|
||||
typeof artifactContentQuery.data === "string" ? artifactContentQuery.data : "";
|
||||
const loadedArtifactIsError = isArtifactErrorContent(loadedArtifactContent);
|
||||
const artifactContent =
|
||||
loadedArtifactContent.trim() && !loadedArtifactIsError ? loadedArtifactContent : null;
|
||||
const displayedContent = artifactContent || fallbackContent(item);
|
||||
const artifactLoadFailed =
|
||||
(artifactContentQuery.isError || loadedArtifactIsError) &&
|
||||
!item?.artifactContent?.trim();
|
||||
|
||||
return (
|
||||
<Dialog open={Boolean(item)} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
overlayClassName="!z-[6000]"
|
||||
className="!z-[6001] flex max-h-[86vh] w-[900px] !max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0"
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<FileOutput className="size-4 text-primary" />
|
||||
交付产物详情
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!item ? null : (
|
||||
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
|
||||
{desensitize(item.productName)}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{desensitize(item.agentName)} · {desensitize(item.positionName)}
|
||||
</p>
|
||||
</div>
|
||||
{extraBody}
|
||||
<div className="mt-4 rounded-xl border border-slate-200 bg-white p-3 dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/65">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-bold text-slate-700 dark:text-[#e8edf5]">产物内容</p>
|
||||
</div>
|
||||
<div className="h-[52vh] min-h-[320px] overflow-hidden rounded-lg border border-slate-100 bg-slate-50/60 dark:border-[#2b8ffe]/20 dark:bg-[#021d3d]/70">
|
||||
{artifactContentQuery.isLoading ? (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground dark:text-[#8aa0c4]">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载 Markdown 文件内容…
|
||||
</div>
|
||||
) : (
|
||||
<ArtifactFilePreview
|
||||
content={displayedContent}
|
||||
language="markdown"
|
||||
threadId={artifactThreadId ?? ""}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{artifactLoadFailed && (
|
||||
<p className="mt-2 text-[11px] text-amber-600 dark:text-amber-300">
|
||||
Markdown 文件内容加载失败,当前展示的是已保存正文。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="shrink-0 border-t px-5 py-3 dark:border-[#2b8ffe]/25">
|
||||
{footerStart}
|
||||
<TButton variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</TButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
.analysis-result-card-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-card {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 102px;
|
||||
box-sizing: border-box;
|
||||
padding: 40px 12px 12px;
|
||||
border: 1px solid rgba(85, 166, 254, 0.1);
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 98, 217, 0.1);
|
||||
color: #ffffff;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-card:hover,
|
||||
.position-roundtable-analysis-result-card:focus-visible {
|
||||
border-color: #0062d9;
|
||||
background: rgba(0, 98, 217, 0.3);
|
||||
box-shadow: 0 4px 14px rgba(0, 44, 110, 0.18);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-card-body {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-tag {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: -12px;
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
max-width: calc(100% + 12px);
|
||||
height: 26px;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0 22px 0 12px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px 0 0 0;
|
||||
background: rgba(212, 120, 0, 0.5);
|
||||
color: #ffffff;
|
||||
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: normal;
|
||||
white-space: nowrap;
|
||||
clip-path: polygon(0 0, 100% 0, calc(100% - 14px) 100%, 0 100%);
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-tag--1 { background: rgba(212, 120, 0, 0.5); }
|
||||
.position-roundtable-analysis-result-tag--2 { background: rgba(207, 19, 34, 0.5); }
|
||||
.position-roundtable-analysis-result-tag--3 { background: rgba(29, 171, 123, 0.5); }
|
||||
.position-roundtable-analysis-result-tag--4 { background: rgba(132, 85, 230, 0.5); }
|
||||
.position-roundtable-analysis-result-tag--5 { background: rgba(201, 181, 0, 0.5); }
|
||||
.position-roundtable-analysis-result-tag--6 { background: rgba(69, 195, 243, 0.5); }
|
||||
|
||||
.position-roundtable-analysis-result-tag span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-product {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: #ffffff;
|
||||
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-meta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
color: #80b7ff;
|
||||
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-meta span {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-meta img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-result-meta i {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
background: rgba(128, 183, 255, 0.45);
|
||||
}
|
||||
|
||||
.position-roundtable-analysis-results-empty {
|
||||
color: #80b7ff;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { AnalysisResultCardList } from "./AnalysisResultCardList";
|
||||
export { AnalysisResultDetailDialog } from "./AnalysisResultDetailDialog";
|
||||
export type { AnalysisResultCardItem } from "./types";
|
||||
@@ -1,17 +0,0 @@
|
||||
export interface AnalysisResultCardItem {
|
||||
id: string;
|
||||
agentName: string;
|
||||
productName: string;
|
||||
positionName: string;
|
||||
updatedAt?: string;
|
||||
/** Shown when `updatedAt` is missing. */
|
||||
fallbackTime?: string;
|
||||
/** Clickable preview is available. */
|
||||
delivered: boolean;
|
||||
artifactPath?: string;
|
||||
artifactMimeType?: string | null;
|
||||
artifactThreadId?: string | null;
|
||||
artifactContent?: string | null;
|
||||
summary?: string;
|
||||
unavailableMessage?: string;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview";
|
||||
import { injectInPageAnchorHandler } from "@/core/utils/markdown";
|
||||
|
||||
/**
|
||||
* Sandboxed preview of a generated HTML document.
|
||||
@@ -26,7 +26,7 @@ export function HtmlPreviewFrame({
|
||||
const [url, setUrl] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
const blob = new Blob([prepareGeneratedHtml(html ?? "")], {
|
||||
const blob = new Blob([injectInPageAnchorHandler(html ?? "")], {
|
||||
type: "text/html;charset=utf-8",
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import deerMaskUrl from "@/assets/deer.svg";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FlickeringGrid } from "@/components/ui/flickering-grid";
|
||||
import Galaxy from "@/components/ui/galaxy";
|
||||
@@ -30,11 +29,7 @@ export function Hero({ className }: { className?: string }) {
|
||||
/>
|
||||
</div>
|
||||
<FlickeringGrid
|
||||
className="absolute inset-0 z-0 translate-y-8 mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"
|
||||
style={{
|
||||
WebkitMaskImage: `url("${deerMaskUrl}")`,
|
||||
maskImage: `url("${deerMaskUrl}")`,
|
||||
}}
|
||||
className="absolute inset-0 z-0 translate-y-8 mask-[url(/images/deer.svg)] mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"
|
||||
squareSize={4}
|
||||
gridGap={4}
|
||||
color={"white"}
|
||||
|
||||
@@ -2,15 +2,14 @@ import { useEffect, useState } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
Bell,
|
||||
Cpu,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Gauge,
|
||||
ListTree,
|
||||
MessageSquareText,
|
||||
MousePointerClick,
|
||||
BriefcaseBusiness,
|
||||
Settings,
|
||||
@@ -56,7 +55,7 @@ type PageSidebarProps = {
|
||||
headerHidden?: boolean;
|
||||
};
|
||||
|
||||
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "encoder" | "about";
|
||||
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
|
||||
|
||||
const hasActiveChild = (item: MenuItem, pathname: string): boolean =>
|
||||
menuItemContainsPath(item, pathname);
|
||||
@@ -240,10 +239,10 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/fixed-questions")}
|
||||
onClick={() => navigate("/page/strategy/admin/notification-templates")}
|
||||
>
|
||||
<MessageSquareText className="size-4" />
|
||||
问题配置
|
||||
<FileText className="size-4" />
|
||||
通知模板管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
@@ -262,20 +261,6 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
||||
白名单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/models")}
|
||||
>
|
||||
<Cpu className="size-4" />
|
||||
模型配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
||||
<Cpu className="size-4" />
|
||||
本地编码器
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/settings/model-concurrency")}
|
||||
@@ -574,9 +559,9 @@ function LegacyAccordionSidebar({
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/fixed-questions")}>
|
||||
<MessageSquareText className="size-4" />
|
||||
问题配置
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/notification-templates")}>
|
||||
<FileText className="size-4" />
|
||||
通知模板管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
@@ -585,18 +570,6 @@ function LegacyAccordionSidebar({
|
||||
岗位角色配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/workspace/admin/models")}>
|
||||
<Cpu className="size-4" />
|
||||
模型配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
||||
<Cpu className="size-4" />
|
||||
本地编码器
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
|
||||
<ShieldCheck className="size-4" />
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
|
||||
import { languages } from "@codemirror/language-data";
|
||||
import { basicLightInit } from "@uiw/codemirror-theme-basic";
|
||||
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
|
||||
const lightTheme = basicLightInit({ settings: { background: "transparent", fontSize: "14px" } });
|
||||
const darkTheme = monokaiInit({
|
||||
settings: { background: "transparent", gutterBackground: "transparent", fontSize: "14px" },
|
||||
});
|
||||
|
||||
/** Markdown-aware source editor shared by Wiki create/edit dialogs. */
|
||||
export function MarkdownCodeEditor({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const extensions = useMemo(
|
||||
() => [markdown({ base: markdownLanguage, codeLanguages: languages })],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("grid min-h-[46vh] overflow-hidden rounded-lg border bg-background lg:grid-cols-2", className)}>
|
||||
<section className="flex min-h-0 flex-col border-b lg:border-r lg:border-b-0">
|
||||
<div className="flex h-9 shrink-0 items-center border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground">
|
||||
Markdown 编辑
|
||||
</div>
|
||||
<CodeMirror
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
theme={resolvedTheme === "dark" ? darkTheme : lightTheme}
|
||||
extensions={extensions}
|
||||
basicSetup={{
|
||||
foldGutter: true,
|
||||
highlightActiveLine: true,
|
||||
highlightActiveLineGutter: false,
|
||||
lineNumbers: true,
|
||||
}}
|
||||
className="min-h-[42vh] flex-1 overflow-auto font-mono text-sm [&_.cm-editor]:min-h-[42vh] [&_.cm-focused]:outline-none!"
|
||||
/>
|
||||
</section>
|
||||
<section className="flex min-h-0 flex-col bg-muted/10">
|
||||
<div className="flex h-9 shrink-0 items-center border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground">
|
||||
实时渲染
|
||||
</div>
|
||||
<div className="min-h-[42vh] flex-1 overflow-auto p-4">
|
||||
<MarkdownContent
|
||||
content={value || "*暂无正文*"}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
SlidersHorizontal,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -19,7 +18,7 @@ function HeaderButton({
|
||||
variant = "outline",
|
||||
className,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: "outline" | "primary";
|
||||
@@ -53,7 +52,6 @@ export function WikiHeader({
|
||||
onCreate,
|
||||
onManageFolders,
|
||||
onManageTemplates,
|
||||
extraActions,
|
||||
}: {
|
||||
onToggleSidebar: () => void;
|
||||
onImportFile: () => void;
|
||||
@@ -63,7 +61,6 @@ export function WikiHeader({
|
||||
onCreate: () => void;
|
||||
onManageFolders: () => void;
|
||||
onManageTemplates: () => void;
|
||||
extraActions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
|
||||
@@ -77,12 +74,11 @@ export function WikiHeader({
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
<div className="text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
知识库管理
|
||||
问答梳理
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{extraActions}
|
||||
<HeaderButton
|
||||
icon={<Upload className="size-4" />}
|
||||
label="导入文件"
|
||||
|
||||
@@ -204,7 +204,7 @@ export function noteFolderSegments(note: KnowledgeNote): string[] {
|
||||
|
||||
/** Breadcrumb segments for a note (folders only; the title is appended by the caller). */
|
||||
export function noteBreadcrumbSegments(note: KnowledgeNote): string[] {
|
||||
return ["知识库管理", ...noteFolderSegments(note).map(localizeFolderName)];
|
||||
return ["问答梳理", ...noteFolderSegments(note).map(localizeFolderName)];
|
||||
}
|
||||
|
||||
function sortTree(nodes: TreeNode[]): void {
|
||||
|
||||
@@ -835,14 +835,11 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
);
|
||||
|
||||
const cardLayout = (
|
||||
<Card
|
||||
className="group flex min-w-0 flex-col transition-shadow hover:shadow-md"
|
||||
data-agent-guide="agent-card"
|
||||
>
|
||||
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
|
||||
<CardHeader className="min-w-0 pb-2">
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<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">
|
||||
@@ -896,11 +893,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
);
|
||||
|
||||
const listLayout = (
|
||||
<div
|
||||
className="group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]"
|
||||
data-agent-guide="agent-card"
|
||||
>
|
||||
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<div className="group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<BotIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
@@ -986,7 +980,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
? "正在加载 SOUL.md..."
|
||||
: "输入 SOUL.md 内容"
|
||||
}
|
||||
className="max-h-[45vh] min-h-48 resize-y text-xs"
|
||||
className="max-h-[45vh] min-h-48 resize-y text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -51,10 +51,6 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import {
|
||||
MyProjectThreadBadge,
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useAgentsPaginated } from "@/core/agents";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
@@ -73,11 +69,9 @@ import {
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||
import {
|
||||
bootstrapResumePath,
|
||||
isBootstrapThread,
|
||||
isHiddenFromRecentChats,
|
||||
isThreadFavorite,
|
||||
isThreadPinned,
|
||||
sortThreadsPinnedFirst,
|
||||
@@ -378,10 +372,17 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
|
||||
|
||||
// 排除调度/会商/报告结构&写作配置向导等系统会话,与首页一致;
|
||||
// 智能体创建(bootstrap)会话保留展示(带机器人图标、点击回创建页);置顶优先。
|
||||
// 排除调度/会商类系统会话,与首页一致;智能体创建(bootstrap)会话保留展示
|
||||
// (带机器人图标、点击回创建页);置顶优先。
|
||||
const visibleThreads = useMemo(
|
||||
() => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
|
||||
() =>
|
||||
sortThreadsPinnedFirst(
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "roundtable",
|
||||
),
|
||||
),
|
||||
[threads],
|
||||
);
|
||||
|
||||
@@ -484,7 +485,6 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
) : (
|
||||
filteredThreads.map((thread) => {
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const projectType = myProjectConversationTypeOfThread(thread);
|
||||
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
|
||||
const isActive = href === pathname;
|
||||
const pinned = isThreadPinned(thread);
|
||||
@@ -511,8 +511,6 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
)}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : projectType ? (
|
||||
<MyProjectThreadIcon type={projectType} />
|
||||
) : (
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
@@ -524,15 +522,9 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
{pinned && (
|
||||
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
<span className="truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
{projectType && (
|
||||
<MyProjectThreadBadge
|
||||
className="mr-4"
|
||||
type={projectType}
|
||||
/>
|
||||
)}
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
)}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
BriefcaseBusinessIcon,
|
||||
CheckIcon,
|
||||
DatabaseIcon,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
@@ -45,7 +43,6 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
||||
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
|
||||
import { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats";
|
||||
import { useArtifactsOptional } from "@/components/workspace/artifacts";
|
||||
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
|
||||
@@ -83,7 +80,6 @@ interface AgentDraft {
|
||||
templateName: string;
|
||||
/** 会商席位「技能识别强化」开关。 */
|
||||
seatSkillDirective: boolean;
|
||||
knowledgeBaseIds: string[];
|
||||
}
|
||||
|
||||
function draftFromAgent(agent: Agent): AgentDraft {
|
||||
@@ -97,7 +93,6 @@ function draftFromAgent(agent: Agent): AgentDraft {
|
||||
featured: agent.featured_order != null,
|
||||
templateName: agent.template_name ?? "",
|
||||
seatSkillDirective: agent.seat_skill_directive ?? false,
|
||||
knowledgeBaseIds: agent.llmwiki_knowledge_base_ids ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,8 +139,6 @@ export function AgentDetailPanel({
|
||||
onAskQuestion,
|
||||
disabled,
|
||||
defaultCollapsed,
|
||||
collapsed: collapsedProp,
|
||||
onCollapsedChange,
|
||||
showRecentChats,
|
||||
compactAppearanceActions,
|
||||
avatarSrc,
|
||||
@@ -153,7 +146,6 @@ export function AgentDetailPanel({
|
||||
skillsIcon,
|
||||
skillsTitle,
|
||||
showCommonQuestions = false,
|
||||
jobResponsibilities,
|
||||
panelTitle,
|
||||
embedded,
|
||||
hideStatusBadges = false,
|
||||
@@ -165,14 +157,6 @@ export function AgentDetailPanel({
|
||||
disabled?: boolean;
|
||||
/** 初始收起(任务工作区进入时默认收起智能体信息面板)。 */
|
||||
defaultCollapsed?: boolean;
|
||||
/**
|
||||
* 受控折叠态:传入时由父级持有状态(智能体问答页把展开/收起按钮放在顶部
|
||||
* 「新对话」右侧)。此时收起 = 整列宽度动画归零(不保留细条),面板内置的
|
||||
* 收起按钮不再渲染。不传则沿用面板内置状态 + 细条收起交互(会商等工作区)。
|
||||
*/
|
||||
collapsed?: boolean;
|
||||
/** 受控折叠态变化回调(配合 `collapsed` 使用)。 */
|
||||
onCollapsedChange?: (collapsed: boolean) => void;
|
||||
/** 在面板底部展示「有关此智能体的最近对话」(仅智能体问答页开启,任务工作区不展示)。 */
|
||||
showRecentChats?: boolean;
|
||||
/** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */
|
||||
@@ -182,11 +166,6 @@ export function AgentDetailPanel({
|
||||
skillsIcon?: ReactNode;
|
||||
skillsTitle?: string;
|
||||
showCommonQuestions?: boolean;
|
||||
/**
|
||||
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
|
||||
* 不传则隐藏该区块。此字段来自业务链,不随智能体本身编辑。
|
||||
*/
|
||||
jobResponsibilities?: string | null;
|
||||
/** Overrides the standard panel heading for an embedded consumer. */
|
||||
panelTitle?: string;
|
||||
/** Renders one fixed panel instead of the responsive collapsible rail. */
|
||||
@@ -197,15 +176,7 @@ export function AgentDetailPanel({
|
||||
editIcon?: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
// 折叠态:受控(collapsedProp 传入,智能体问答页)优先;否则面板内置(会商等工作区)。
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(
|
||||
defaultCollapsed ?? false,
|
||||
);
|
||||
const collapsed = collapsedProp ?? internalCollapsed;
|
||||
const setCollapsed = (next: boolean) => {
|
||||
if (collapsedProp === undefined) setInternalCollapsed(next);
|
||||
onCollapsedChange?.(next);
|
||||
};
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<AgentDraft | null>(null);
|
||||
@@ -255,10 +226,9 @@ export function AgentDetailPanel({
|
||||
|
||||
const isSaving = updateAgent.isPending || setFeatured.isPending;
|
||||
|
||||
// 对话生成文件(md/txt 等 artifact)并首次展开预览时,自动收起智能体
|
||||
// 信息面板给预览让位。目录内切换文件不能再次改写用户的手动选择:否则用户
|
||||
// 刚展开智能体信息,点另一个文件时面板又会突然收起,看起来就像收起按钮被
|
||||
// 移除了。编辑中也不打断用户。
|
||||
// 对话生成文件(md/txt 等 artifact)并在右侧展开预览时,自动收起智能体
|
||||
// 信息面板给预览让位;切换到新文件时同样收起。编辑中不打断用户,用户随时
|
||||
// 可手动重新展开。
|
||||
//
|
||||
// 关键:判定用 open + selectedArtifact,而非 artifacts.length。流式写文件时
|
||||
// message-group 一开始就调用 select()+setOpen(true) 打开预览并选中该文件,但
|
||||
@@ -267,19 +237,20 @@ export function AgentDetailPanel({
|
||||
const artifactsCtx = useArtifactsOptional();
|
||||
const artifactCount = artifactsCtx?.artifacts.length ?? 0;
|
||||
const selectedArtifact = artifactsCtx?.selectedArtifact ?? null;
|
||||
// preOpen:流式自动开沙箱的两阶段标记 —— 沙箱真正展开(open 翻转)前就
|
||||
// 先收起本面板,让收起布局先落地,避免与沙箱展开同帧叠加造成卡顿。
|
||||
const artifactPanelVisible =
|
||||
(!!artifactsCtx?.open || !!artifactsCtx?.preOpen) &&
|
||||
(!!selectedArtifact || artifactCount > 0);
|
||||
!!artifactsCtx?.open && (!!selectedArtifact || artifactCount > 0);
|
||||
const prevArtifactPanelVisible = useRef(false);
|
||||
const prevSelectedArtifact = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const opened = artifactPanelVisible && !prevArtifactPanelVisible.current;
|
||||
const switched =
|
||||
artifactPanelVisible && selectedArtifact !== prevSelectedArtifact.current;
|
||||
prevArtifactPanelVisible.current = artifactPanelVisible;
|
||||
if (opened && !editing) {
|
||||
prevSelectedArtifact.current = selectedArtifact;
|
||||
if ((opened || switched) && !editing) {
|
||||
setCollapsed(true);
|
||||
}
|
||||
}, [artifactPanelVisible, editing]);
|
||||
}, [artifactPanelVisible, selectedArtifact, editing]);
|
||||
|
||||
const displayName = desensitize(agent?.name ?? agentName);
|
||||
const questions = useMemo(
|
||||
@@ -363,11 +334,6 @@ export function AgentDetailPanel({
|
||||
request.template_name = draft.templateName.trim();
|
||||
if (draft.seatSkillDirective !== (agent.seat_skill_directive ?? false))
|
||||
request.seat_skill_directive = draft.seatSkillDirective;
|
||||
if (
|
||||
JSON.stringify(draft.knowledgeBaseIds) !==
|
||||
JSON.stringify(agent.llmwiki_knowledge_base_ids ?? [])
|
||||
)
|
||||
request.llmwiki_knowledge_base_ids = draft.knowledgeBaseIds;
|
||||
if (nextPublished !== agent.published) request.published = nextPublished;
|
||||
if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? []))
|
||||
request.skills = draft.skills;
|
||||
@@ -664,13 +630,6 @@ export function AgentDetailPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!editing && (agent.llmwiki_knowledge_base_ids?.length ?? 0) > 0 && (
|
||||
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-xs">
|
||||
<DatabaseIcon className="size-3 shrink-0" />
|
||||
LLMWiki 知识库:{agent.llmwiki_knowledge_base_ids?.length ?? 0} 个
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Publish / featured toggles — only meaningful in edit mode. */}
|
||||
{editing && draft && (
|
||||
<div className="mt-3 space-y-2">
|
||||
@@ -688,14 +647,6 @@ export function AgentDetailPanel({
|
||||
配置后,对话生成的 Markdown 文件可一键发布到知识入库接口。
|
||||
</p>
|
||||
</div>
|
||||
<LlmWikiKnowledgeSelector
|
||||
source="all"
|
||||
value={draft.knowledgeBaseIds}
|
||||
onChange={(knowledgeBaseIds) =>
|
||||
setDraft({ ...draft, knowledgeBaseIds })
|
||||
}
|
||||
variant="panel"
|
||||
/>
|
||||
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
|
||||
<div className="pr-3">
|
||||
<p className="text-sm font-medium">会商技能识别强化</p>
|
||||
@@ -745,23 +696,6 @@ export function AgentDetailPanel({
|
||||
</section>
|
||||
|
||||
|
||||
{/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */}
|
||||
{jobResponsibilities !== undefined && (
|
||||
<Section
|
||||
icon={<BriefcaseBusinessIcon className="size-3.5" />}
|
||||
title="岗位职责"
|
||||
>
|
||||
{jobResponsibilities?.trim() ? (
|
||||
<p className="text-muted-foreground text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{desensitize(jobResponsibilities)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground/60 text-sm">暂无岗位职责</p>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
|
||||
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
|
||||
{(showCommonQuestions || editing || questions.length > 0 || canEdit) && (
|
||||
<Section
|
||||
@@ -947,18 +881,14 @@ export function AgentDetailPanel({
|
||||
|
||||
const panelChrome = (collapseButton: ReactNode) => (
|
||||
<>
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||
{/* 标题不参与压缩(此前被简洁模式外观控件挤成单字);空间不足时只
|
||||
裁剪 compactAppearanceActions,编辑等主操作与标题始终完整。 */}
|
||||
<span className="shrink-0 text-sm font-semibold whitespace-nowrap">
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<span className="shrink-0 whitespace-nowrap text-sm font-semibold">
|
||||
{panelTitle ?? "智能体信息"}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-1">
|
||||
<div className="flex shrink-0 items-center gap-1">{headerActions}</div>
|
||||
<div className="flex min-w-0 items-center justify-end gap-1 overflow-hidden">
|
||||
{compactAppearanceActions}
|
||||
</div>
|
||||
{collapseButton && <div className="shrink-0">{collapseButton}</div>}
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{headerActions}
|
||||
{compactAppearanceActions}
|
||||
{collapseButton}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
|
||||
@@ -967,48 +897,21 @@ export function AgentDetailPanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Desktop (lg+) ────────────────────────────────────────────────── */}
|
||||
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
|
||||
{embedded ? (
|
||||
<aside className="bg-card/40 flex h-full w-full min-w-0 shrink-0 flex-col border-l">
|
||||
{panelChrome(null)}
|
||||
</aside>
|
||||
) : collapsedProp !== undefined ? (
|
||||
/* 受控模式(智能体问答页):收起时整列宽度动画归零(含左边框),不再保留细条;
|
||||
展开态的「收起」按钮在面板右上角,收起态的「展开」按钮在页面
|
||||
header「新对话」按钮右侧。 */
|
||||
<div
|
||||
className={cn(
|
||||
"hidden h-full shrink-0 overflow-hidden transition-[width] duration-300 ease-in-out lg:block",
|
||||
collapsed ? "w-0" : "w-[360px]",
|
||||
)}
|
||||
>
|
||||
<aside className="bg-card/40 flex h-full w-[360px] flex-col border-l">
|
||||
{panelChrome(
|
||||
<Tooltip content="收起">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setCollapsed(true)}
|
||||
aria-label="收起智能体信息"
|
||||
aria-expanded={true}
|
||||
>
|
||||
<PanelRightCloseIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>,
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
) : collapsed ? (
|
||||
<div className="hidden shrink-0 items-start border-l px-2 pt-3 lg:flex">
|
||||
<div className="flex items-center gap-1">
|
||||
{compactAppearanceActions}
|
||||
<Tooltip content="展开智能体信息">
|
||||
<Tooltip content="智能体信息">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setCollapsed(false)}
|
||||
aria-label="展开智能体信息"
|
||||
aria-expanded={false}
|
||||
>
|
||||
<PanelRightOpenIcon className="size-4" />
|
||||
</Button>
|
||||
@@ -1024,7 +927,6 @@ export function AgentDetailPanel({
|
||||
size="icon-sm"
|
||||
onClick={() => setCollapsed(true)}
|
||||
aria-label="收起智能体信息"
|
||||
aria-expanded={true}
|
||||
>
|
||||
<PanelRightCloseIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownUpIcon, BotIcon, Compass, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
|
||||
import { ArrowDownUpIcon, BotIcon, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -16,16 +16,9 @@ import { getStoredAuth } from "@/core/auth";
|
||||
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
||||
import type { Agent, AgentSort } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
|
||||
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
|
||||
|
||||
import { AgentCard } from "./agent-card";
|
||||
import { SentimentAgentCard } from "./sentiment-agent-card";
|
||||
import {
|
||||
AgentGuide,
|
||||
isAgentGuideEnvironment,
|
||||
startAgentGuide,
|
||||
} from "./agent-guide";
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
@@ -37,7 +30,6 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
|
||||
export function AgentGallery() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const sentimentAgentVisible = isSentimentAgentVisible();
|
||||
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [viewMode, setViewMode] = useViewMode("agent");
|
||||
@@ -49,9 +41,6 @@ export function AgentGallery() {
|
||||
// Each configured publish square (发布广场) becomes its own browse tab; the
|
||||
// backend always returns at least one (the built-in default).
|
||||
const squareTabs = publishSquares?.squares ?? [];
|
||||
// 默认广场固定排在「我的」之后;舆情分析是依附在默认广场后的前端虚拟类型,
|
||||
// 其余自定义广场继续保持后端给出的顺序。
|
||||
const [defaultSquare, ...additionalSquares] = squareTabs;
|
||||
// Publisher filter: click a card's 发布者 to keep only that user's agents.
|
||||
// {id} is the owner user_id (server filter), {label} the desensitized name.
|
||||
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
|
||||
@@ -81,7 +70,6 @@ export function AgentGallery() {
|
||||
activeTab !== "mine" &&
|
||||
activeTab !== "private" &&
|
||||
activeTab !== "builtin" &&
|
||||
activeTab !== "sentiment" &&
|
||||
squareTabs.length > 0 &&
|
||||
!squareTabs.some((sq) => sq.id === activeTab)
|
||||
) {
|
||||
@@ -102,13 +90,9 @@ export function AgentGallery() {
|
||||
|
||||
// Only the active tab fetches. "广场" maps to the server "square" scope
|
||||
// (built-in/published, never private), "我的" to "mine" (owned + favorited),
|
||||
// "内置" to "builtin" (admin-only, user_id IS NULL). 「舆情分析」是前端虚拟
|
||||
// 类型,不查后端列表。
|
||||
// "内置" to "builtin" (admin-only, user_id IS NULL).
|
||||
const isSquareTab =
|
||||
activeTab !== "mine" &&
|
||||
activeTab !== "private" &&
|
||||
activeTab !== "builtin" &&
|
||||
activeTab !== "sentiment";
|
||||
activeTab !== "mine" && activeTab !== "private" && activeTab !== "builtin";
|
||||
const mainScope =
|
||||
activeTab === "mine"
|
||||
? "mine"
|
||||
@@ -128,7 +112,7 @@ export function AgentGallery() {
|
||||
sort,
|
||||
page: mainPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
enabled: activeTab !== "private" && activeTab !== "sentiment",
|
||||
enabled: activeTab !== "private",
|
||||
owner: publisher?.id,
|
||||
// Each square tab fetches only that square's agents.
|
||||
square: isSquareTab ? activeTab : undefined,
|
||||
@@ -164,16 +148,9 @@ export function AgentGallery() {
|
||||
// password has been verified. Until then we show the gate (or the lock
|
||||
// prompt) and hide the keyword/tag search bar — there is no list to filter.
|
||||
const privateUnlocked = privateFeatureUsable && !!privatePassword;
|
||||
const showSearchBar =
|
||||
activeTab !== "sentiment" &&
|
||||
(activeTab !== "private" || privateUnlocked);
|
||||
const showSearchBar = activeTab !== "private" || privateUnlocked;
|
||||
|
||||
const activeTotal =
|
||||
activeTab === "sentiment"
|
||||
? 1
|
||||
: activeTab === "private"
|
||||
? privateTotal
|
||||
: mainTotal;
|
||||
const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
|
||||
const setTabPage = (tab: string, page: number) =>
|
||||
setPages((prev) => ({ ...prev, [tab]: page }));
|
||||
|
||||
@@ -351,22 +328,15 @@ export function AgentGallery() {
|
||||
请输入访问密码,验证通过后才能查看其中的智能体。
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
className="flex flex-col gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleSubmitPassword();
|
||||
}}
|
||||
>
|
||||
<label className="sr-only" htmlFor="private-square-access-code">
|
||||
访问密码
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
id="private-square-access-code"
|
||||
type="password"
|
||||
placeholder="访问密码"
|
||||
value={passwordInput}
|
||||
onChange={(e) => setPasswordInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSubmitPassword();
|
||||
}}
|
||||
// This is a one-off access code, not a login credential. Tell
|
||||
// browsers / password managers not to save or autofill it (which
|
||||
// would also stop them from spilling a saved account into the
|
||||
@@ -377,8 +347,8 @@ export function AgentGallery() {
|
||||
data-lpignore="true"
|
||||
data-form-type="other"
|
||||
/>
|
||||
<Button type="submit">进入私密广场</Button>
|
||||
</form>
|
||||
<Button onClick={handleSubmitPassword}>进入私密广场</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -403,22 +373,10 @@ export function AgentGallery() {
|
||||
{t.agents.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="重新播放智能体使用引导"
|
||||
onClick={() => startAgentGuide("list")}
|
||||
>
|
||||
<Compass className="mr-1.5 h-4 w-4" />
|
||||
使用引导
|
||||
</Button>
|
||||
<Button onClick={handleNewAgent} data-agent-guide="new-agent-btn">
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
{t.agents.newAgent}
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleNewAgent}>
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
{t.agents.newAgent}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
@@ -438,27 +396,9 @@ export function AgentGallery() {
|
||||
>
|
||||
<div className="mx-6 mt-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<TabsList
|
||||
className="w-auto max-w-full flex-wrap justify-start self-start"
|
||||
data-agent-guide="tabs"
|
||||
>
|
||||
<TabsList className="w-auto max-w-full flex-wrap justify-start self-start">
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
{defaultSquare && (
|
||||
<TabsTrigger
|
||||
key={defaultSquare.id}
|
||||
value={defaultSquare.id}
|
||||
title={defaultSquare.description || defaultSquare.name}
|
||||
>
|
||||
{defaultSquare.name}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{sentimentAgentVisible && (
|
||||
/* 舆情分析:前端虚拟类型,直连外部舆情问答接口。 */
|
||||
<TabsTrigger value="sentiment" title="舆情分析智能体(外部接口对接)">
|
||||
舆情分析
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{additionalSquares.map((sq) => (
|
||||
{squareTabs.map((sq) => (
|
||||
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
|
||||
{sq.name}
|
||||
</TabsTrigger>
|
||||
@@ -495,8 +435,7 @@ export function AgentGallery() {
|
||||
</div>
|
||||
</div>
|
||||
{showSearchBar && (
|
||||
<div data-agent-guide="search">
|
||||
<TagSearchBar
|
||||
<TagSearchBar
|
||||
// Remount per tab so the internal keyword input / 展开 state also
|
||||
// resets when the parent-owned filters are cleared on tab switch.
|
||||
key={activeTab}
|
||||
@@ -527,7 +466,6 @@ export function AgentGallery() {
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{publisher && (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -574,25 +512,6 @@ export function AgentGallery() {
|
||||
})}
|
||||
</TabsContent>
|
||||
|
||||
{/* 舆情分析类型:固定一张虚拟智能体卡片(名称/简介可编辑),
|
||||
接口地址与鉴权在 runtime-config.js 配置。 */}
|
||||
{sentimentAgentVisible && (
|
||||
<TabsContent
|
||||
value="sentiment"
|
||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||
>
|
||||
{viewMode === "list" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<SentimentAgentCard layout="list" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<SentimentAgentCard />
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
{renderListBody({
|
||||
@@ -615,11 +534,6 @@ export function AgentGallery() {
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
<AgentGuide
|
||||
stage="list"
|
||||
eligible={isAgentGuideEnvironment()}
|
||||
autoStart={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Guide, type GuideStep } from "tdesign-react";
|
||||
|
||||
import { env } from "@/env";
|
||||
import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
|
||||
import { isWujieEnv } from "@/core/embed/wujie";
|
||||
|
||||
/**
|
||||
* 智能体使用引导(三段式,基于 TDesign React 的 Guide 组件),完整覆盖
|
||||
* 「浏览管理 → 创建与配置 → 发起问答」流程:
|
||||
*
|
||||
* - list :智能体列表页(/page/workspace/agents)——分区、搜索排序、卡片操作、
|
||||
* 创建入口;完成 / 跳过就地结束,不跳转创建页(创建页有自己的一段
|
||||
* 引导,点「创建智能体」进入时自动演示)。
|
||||
* - create :创建页(/page/workspace/agents/new)——对话式创建、基本信息、模型、
|
||||
* 知识空间、SOUL.md、技能、保存。
|
||||
* - chat :智能体对话页(/page/workspace/agents/:id/chats/...)——侧栏切换与
|
||||
* 历史、常用问题、输入框、右侧详情面板。
|
||||
*
|
||||
* 记忆语义与简洁模式引导(compact-mode-guide.tsx)一致:每个阶段各自记一个
|
||||
* localStorage 标记,**只有用户显式点「完成」或「跳过」才写入**;中途刷新/离开
|
||||
* 不记忆,下次进入该页面还会自动演示。各页头部的「使用引导」按钮可随时强制重放。
|
||||
*
|
||||
* 自动演示范围:只有 create 段(进入新建页)与 chat 段自动弹;list 段挂在
|
||||
* 智能体列表页但不自动演示(`autoStart={false}`,避免一进列表页就弹引导),
|
||||
* 仅由头部「使用引导」按钮手动触发。
|
||||
*
|
||||
* 高亮目标通过 `data-agent-guide` 标记在各组件上;启动时按目标是否存在过滤步骤
|
||||
* (如新用户无智能体卡片、无预设常用问题时自动剔除该步)——Guide 对找不到的
|
||||
* 目标元素会抛错,不能盲传。
|
||||
*/
|
||||
|
||||
export type AgentGuideStage = "list" | "create" | "chat";
|
||||
|
||||
const finishedKey = (stage: AgentGuideStage) =>
|
||||
`deerflow.agent-guide.${stage}.finished`;
|
||||
|
||||
const startEventName = (stage: AgentGuideStage) =>
|
||||
`agent-guide:start:${stage}`;
|
||||
|
||||
/** 手动触发某阶段的引导(各页面头部「使用引导」按钮调用,忽略已看过记忆)。 */
|
||||
export function startAgentGuide(stage: AgentGuideStage) {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent(startEventName(stage)));
|
||||
}
|
||||
}
|
||||
|
||||
/** 环境闸门:iframe 嵌入 / 去 chrome / 强制收起侧栏(无界)等环境不弹引导。 */
|
||||
export function isAgentGuideEnvironment(): boolean {
|
||||
return (
|
||||
!env.FORCE_COLLAPSED_SIDEBAR &&
|
||||
!isEmbedActive() &&
|
||||
!isHideChromeActive() &&
|
||||
!isWujieEnv()
|
||||
);
|
||||
}
|
||||
|
||||
/** 引导步骤高亮目标选择器;各目标元素由所在组件打上同名 data 标记。 */
|
||||
const TARGET = {
|
||||
// list 阶段(智能体列表页)
|
||||
tabs: '[data-agent-guide="tabs"]',
|
||||
search: '[data-agent-guide="search"]',
|
||||
agentCard: '[data-agent-guide="agent-card"]',
|
||||
newAgentBtn: '[data-agent-guide="new-agent-btn"]',
|
||||
// create 阶段(创建页)
|
||||
createChatInput: '[data-agent-guide="create-chat-input"]',
|
||||
createBasic: '[data-agent-guide="create-basic"]',
|
||||
createModel: '[data-agent-guide="create-model"]',
|
||||
createKnowledge: '[data-agent-guide="create-knowledge"]',
|
||||
createSoul: '[data-agent-guide="create-soul"]',
|
||||
createSkills: '[data-agent-guide="create-skills"]',
|
||||
createSaveBtn: '[data-agent-guide="create-save-btn"]',
|
||||
// chat 阶段(智能体对话页)
|
||||
chatSidebar: '[data-agent-guide="chat-sidebar"]',
|
||||
chatQuestions: '[data-agent-guide="chat-questions"]',
|
||||
chatInput: '[data-agent-guide="chat-input"]',
|
||||
chatDetail: '[data-agent-guide="chat-detail"]',
|
||||
} as const;
|
||||
|
||||
// 等布局稳定后再启动:Guide 只在步骤切换时测量目标位置,起步太早会把高亮框
|
||||
// 定位到过渡中的坐标且不会自行纠正。
|
||||
const START_DELAY_MS = 800;
|
||||
|
||||
interface StepSpec {
|
||||
/** 高亮目标选择器;缺省(dialog 步骤)以 body 兜底。 */
|
||||
selector?: string;
|
||||
mode: "popup" | "dialog";
|
||||
placement: NonNullable<GuideStep["placement"]>;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const LIST_STEPS: StepSpec[] = [
|
||||
{
|
||||
mode: "dialog",
|
||||
placement: "center",
|
||||
title: "欢迎使用智能体",
|
||||
body: "这里是你的智能体中心。接下来花几分钟走完整个流程:浏览与管理智能体 → 创建与配置 → 发起问答。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.tabs,
|
||||
mode: "popup",
|
||||
placement: "bottom",
|
||||
title: "分区浏览",
|
||||
body: "「我的」放着你创建和收藏的智能体;各「广场」是他人发布共享的,可直接使用或收藏;「私密广场」需要密码访问(管理员还可以查看「内置智能体」)。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.search,
|
||||
mode: "popup",
|
||||
placement: "bottom",
|
||||
title: "搜索与排序",
|
||||
body: "支持关键词、标签搜索,可按最新创建、最近更新、名称排序;右侧按钮可在卡片 / 列表两种视图间切换。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.agentCard,
|
||||
mode: "popup",
|
||||
placement: "top",
|
||||
title: "智能体卡片",
|
||||
body: "卡片展示名称、简介、模型与技能徽章;底部操作栏支持收藏、置顶、打标签、发布到广场、复制、编辑、删除;点「对话」直接进入该智能体的问答。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.newAgentBtn,
|
||||
mode: "popup",
|
||||
placement: "bottom-left",
|
||||
title: "创建智能体",
|
||||
body: "准备好了就点这里,开始创建你的专属智能体;进入创建页后还会有一段配置引导。",
|
||||
},
|
||||
];
|
||||
|
||||
const CREATE_STEPS: StepSpec[] = [
|
||||
{
|
||||
mode: "dialog",
|
||||
placement: "center",
|
||||
title: "创建智能体",
|
||||
body: "页面分两栏:左侧直接与模型对话,让它帮你配置;右侧表单手动填写。两边内容实时联动,选顺手的方式即可。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.createChatInput,
|
||||
mode: "popup",
|
||||
placement: "top",
|
||||
title: "对话式创建",
|
||||
body: "在这里描述你想要的智能体,例如「帮我做一个周报助手,语气正式、要点条目化」。模型会追问细节并给出配置方案,你确认后方案自动填入右侧表单,可继续微调。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.createBasic,
|
||||
mode: "popup",
|
||||
placement: "left",
|
||||
title: "基本信息",
|
||||
body: "「名称」显示在卡片和对话页;「描述」用一句话说明它擅长什么,方便日后挑选。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.createModel,
|
||||
mode: "popup",
|
||||
placement: "left",
|
||||
title: "运行模型与入库",
|
||||
body: "「模型」选择该智能体被使用时调用的模型;「入库模板名」配置后,对话生成的文档可一键发布入库(不需要可留空)。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.createKnowledge,
|
||||
mode: "popup",
|
||||
placement: "left",
|
||||
title: "知识空间",
|
||||
body: "为智能体挂载知识库,回答时会优先检索其中的资料,让结果更贴合你的业务数据。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.createSoul,
|
||||
mode: "popup",
|
||||
placement: "left",
|
||||
title: "SOUL.md · 人设",
|
||||
body: "智能体的「灵魂文件」——写清角色定位、能力边界、工作步骤和输出格式。这份提示词直接决定回答质量,是最值得花时间打磨的配置。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.createSkills,
|
||||
mode: "popup",
|
||||
placement: "left",
|
||||
title: "技能挂载",
|
||||
body: "从可用技能中勾选要赋予的能力,已选技能会作为工具在对话中被调用;全部不选则是纯对话型智能体。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.createSaveBtn,
|
||||
mode: "popup",
|
||||
placement: "top",
|
||||
title: "创建并保存",
|
||||
body: "配置完成后点这里保存(左侧对话只是试用场,内容不会自动保存)。保存成功会回到智能体列表。",
|
||||
},
|
||||
{
|
||||
mode: "dialog",
|
||||
placement: "center",
|
||||
title: "开始问答",
|
||||
body: "回到列表页后,点智能体卡片上的「对话」按钮即可开始问答;进入对话页还会有最后一段引导。",
|
||||
},
|
||||
];
|
||||
|
||||
const CHAT_STEPS: StepSpec[] = [
|
||||
{
|
||||
mode: "dialog",
|
||||
placement: "center",
|
||||
title: "智能体问答",
|
||||
body: "一切就绪!这里就是与智能体对话的工作区,快速认识一下。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.chatSidebar,
|
||||
mode: "popup",
|
||||
placement: "right",
|
||||
title: "切换与历史",
|
||||
body: "左栏顶部可在「我的 / 广场」智能体之间切换;下方是当前智能体的历史会话,点击任意一条继续上次的对话。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.chatQuestions,
|
||||
mode: "popup",
|
||||
placement: "bottom",
|
||||
title: "常用问题",
|
||||
body: "智能体预设的开场问题,点击即可直接提问,快速体验它的能力。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.chatInput,
|
||||
mode: "popup",
|
||||
placement: "top",
|
||||
title: "输入框",
|
||||
body: "输入问题后发送即可开始问答;新对话里还可以挂载知识空间,让回答优先检索知识库。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.chatDetail,
|
||||
mode: "popup",
|
||||
placement: "left",
|
||||
title: "智能体详情",
|
||||
body: "右侧面板展示当前智能体的配置(人设、技能、常用问题),可以直接修改并即时生效;里面的快捷提问也能一键发送。",
|
||||
},
|
||||
];
|
||||
|
||||
const STAGE_STEPS: Record<AgentGuideStage, StepSpec[]> = {
|
||||
list: LIST_STEPS,
|
||||
create: CREATE_STEPS,
|
||||
chat: CHAT_STEPS,
|
||||
};
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== "undefined";
|
||||
}
|
||||
|
||||
function isStageFinished(stage: AgentGuideStage): boolean {
|
||||
return (
|
||||
isBrowser() && localStorage.getItem(finishedKey(stage)) === "1"
|
||||
);
|
||||
}
|
||||
|
||||
function markStageFinished(stage: AgentGuideStage) {
|
||||
if (isBrowser()) localStorage.setItem(finishedKey(stage), "1");
|
||||
}
|
||||
|
||||
function buildSteps(stage: AgentGuideStage): GuideStep[] {
|
||||
if (!isBrowser()) return [];
|
||||
return STAGE_STEPS[stage]
|
||||
.filter((spec) => !spec.selector || document.querySelector(spec.selector))
|
||||
.map((spec) => ({
|
||||
element: spec.selector ?? (() => document.body),
|
||||
mode: spec.mode,
|
||||
placement: spec.placement,
|
||||
title: spec.title,
|
||||
body: spec.body,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能体三段引导的运行组件:`stage` 决定步骤内容,`eligible` 为 false 时完全
|
||||
* 静默(不监听、不自动弹)。挂在三个页面各自的作用域内;`autoStart` 为 false
|
||||
* 时仅响应手动触发(列表页不自动弹,进新建页才自动演示)。
|
||||
*/
|
||||
export function AgentGuide({
|
||||
stage,
|
||||
eligible,
|
||||
autoStart = true,
|
||||
}: {
|
||||
stage: AgentGuideStage;
|
||||
eligible: boolean;
|
||||
autoStart?: boolean;
|
||||
}) {
|
||||
const [current, setCurrent] = useState(-1);
|
||||
const stepsRef = useRef<GuideStep[]>([]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
const steps = buildSteps(stage);
|
||||
if (steps.length === 0) return;
|
||||
stepsRef.current = steps;
|
||||
setCurrent(0);
|
||||
}, [stage]);
|
||||
|
||||
// 自动演示:没显式结束过(没点过完成/跳过)时,每次进入该页都演示;是否
|
||||
// 「已看过」只由 finish 回调(完成/跳过)写入。
|
||||
const autoTriedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!eligible || !autoStart || autoTriedRef.current) return;
|
||||
autoTriedRef.current = true;
|
||||
if (isStageFinished(stage)) return;
|
||||
const timer = setTimeout(start, START_DELAY_MS);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
// 组件在计时窗口内卸载(用户迅速离开页面)则下次进入再演示。
|
||||
autoTriedRef.current = false;
|
||||
};
|
||||
}, [eligible, autoStart, stage, start]);
|
||||
|
||||
// 手动触发:页面头部「使用引导」按钮广播事件,忽略已看过记忆强制重放。
|
||||
useEffect(() => {
|
||||
if (!eligible) return;
|
||||
const handler = () => start();
|
||||
window.addEventListener(startEventName(stage), handler);
|
||||
return () =>
|
||||
window.removeEventListener(startEventName(stage), handler);
|
||||
}, [eligible, stage, start]);
|
||||
|
||||
// 完成 / 跳过都是用户显式结束引导,就地关闭并记忆「已看过」,不跳转任何页面。
|
||||
const finish = useCallback(() => {
|
||||
markStageFinished(stage);
|
||||
setCurrent(-1);
|
||||
}, [stage]);
|
||||
|
||||
if (!eligible) return null;
|
||||
|
||||
return (
|
||||
<Guide
|
||||
current={current}
|
||||
steps={stepsRef.current}
|
||||
onChange={setCurrent}
|
||||
onFinish={finish}
|
||||
onSkip={finish}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,13 +4,8 @@ import { MessageSquareIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import {
|
||||
MyProjectThreadBadge,
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { useThreadsPage } from "@/core/threads/hooks";
|
||||
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
|
||||
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -27,9 +22,12 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
||||
pageSize: RECENT_LIMIT,
|
||||
metadata: { agent_id: agentId },
|
||||
});
|
||||
// 该智能体的会话里同样排除引导 / 系统 / 配置向导会话。
|
||||
// 该智能体的会话里同样排除调度 / 引导 / 会商类系统会话。
|
||||
const threads = (data?.threads ?? []).filter(
|
||||
(th) => !isBootstrapThread(th) && !isHiddenFromRecentChats(th),
|
||||
(th) =>
|
||||
th.metadata?.thread_type !== "bootstrap" &&
|
||||
th.metadata?.thread_type !== "scheduler" &&
|
||||
th.metadata?.thread_type !== "roundtable",
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -50,7 +48,6 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
||||
{threads.map((thread) => {
|
||||
const href = pathOfThread(thread);
|
||||
const isActive = href === pathname;
|
||||
const projectType = myProjectConversationTypeOfThread(thread);
|
||||
return (
|
||||
<Link
|
||||
key={thread.thread_id}
|
||||
@@ -63,20 +60,15 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{projectType ? (
|
||||
<MyProjectThreadIcon type={projectType} />
|
||||
) : (
|
||||
<MessageSquareIcon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
<MessageSquareIcon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{desensitize(titleOfThread(thread, "新对话"))}
|
||||
</span>
|
||||
{projectType && <MyProjectThreadBadge type={projectType} />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, MessageSquareIcon, Radio, SquarePenIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { SENTIMENT_AGENT_ID } from "@/core/sentiment-agent/config";
|
||||
import { readSentimentAgentProfile } from "@/core/sentiment-agent/profile";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SentimentAgentProfileDialog } from "./sentiment-agent-profile-dialog";
|
||||
|
||||
/**
|
||||
* 「舆情分析」类型下的虚拟智能体卡片:只存在于前端(不落库 /api/agents),
|
||||
* 对话经 Gateway 转发外部 AG-UI 网关。名称 / 简介可编辑(localStorage),
|
||||
* 视觉与普通智能体卡片保持一致(网格 / 列表两种布局)。
|
||||
*/
|
||||
export function SentimentAgentCard({
|
||||
layout = "grid",
|
||||
className,
|
||||
}: {
|
||||
layout?: "grid" | "list";
|
||||
className?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [profile, setProfile] = useState(() => readSentimentAgentProfile());
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
// 编辑弹窗也可能挂在对话页,保存后广播事件,这里即时刷新卡片。
|
||||
useEffect(() => {
|
||||
const onChange = () => setProfile(readSentimentAgentProfile());
|
||||
window.addEventListener("sentiment-agent-profile-changed", onChange);
|
||||
return () =>
|
||||
window.removeEventListener("sentiment-agent-profile-changed", onChange);
|
||||
}, []);
|
||||
|
||||
const handleChat = () => {
|
||||
router.push(`/page/workspace/agents/${SENTIMENT_AGENT_ID}/chats/new`);
|
||||
};
|
||||
|
||||
const editButton = (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setEditOpen(true)}
|
||||
title="编辑名称与简介"
|
||||
aria-label="编辑名称与简介"
|
||||
>
|
||||
<SquarePenIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
const chatButton = (
|
||||
<Button
|
||||
size="sm"
|
||||
className={cn(layout === "list" ? "shrink-0" : "w-full")}
|
||||
onClick={handleChat}
|
||||
>
|
||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
对话
|
||||
</Button>
|
||||
);
|
||||
|
||||
const badge = (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Radio className="size-3" />
|
||||
舆情分析
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const dialog = (
|
||||
<SentimentAgentProfileDialog
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
if (layout === "list") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<BotIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="min-w-0 truncate text-sm font-medium"
|
||||
title={desensitize(profile.name)}
|
||||
>
|
||||
{desensitize(profile.name)}
|
||||
</span>
|
||||
{badge}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 truncate text-xs">
|
||||
{desensitize(profile.description)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{editButton}
|
||||
{chatButton}
|
||||
</div>
|
||||
{dialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"group flex min-w-0 flex-col transition-shadow hover:shadow-md",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<CardHeader className="min-w-0 pb-2">
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<BotIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<CardTitle
|
||||
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
|
||||
title={desensitize(profile.name)}
|
||||
>
|
||||
{desensitize(profile.name)}
|
||||
</CardTitle>
|
||||
<div className="mt-0.5">{badge}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end gap-1">
|
||||
{editButton}
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription className="mt-2 line-clamp-2 text-sm">
|
||||
{desensitize(profile.description)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-2">
|
||||
{chatButton}
|
||||
</CardFooter>
|
||||
{dialog}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
DEFAULT_SENTIMENT_AGENT_PROFILE,
|
||||
type SentimentAgentProfile,
|
||||
readSentimentAgentProfile,
|
||||
saveSentimentAgentProfile,
|
||||
} from "@/core/sentiment-agent/profile";
|
||||
|
||||
/**
|
||||
* 舆情分析智能体的「编辑信息」弹窗:修改名称 / 简介并保存到 localStorage。
|
||||
* 智能体管理页的卡片和对话页顶栏共用。
|
||||
*/
|
||||
export function SentimentAgentProfileDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const profile = readSentimentAgentProfile();
|
||||
setName(profile.name);
|
||||
setDescription(profile.description);
|
||||
}, [open]);
|
||||
|
||||
const handleSave = () => {
|
||||
const next: SentimentAgentProfile = {
|
||||
name: name.trim() || DEFAULT_SENTIMENT_AGENT_PROFILE.name,
|
||||
description:
|
||||
description.trim() || DEFAULT_SENTIMENT_AGENT_PROFILE.description,
|
||||
};
|
||||
saveSentimentAgentProfile(next);
|
||||
toast.success("舆情分析智能体信息已更新");
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑舆情分析智能体</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="sentiment-agent-name" className="text-sm font-medium">
|
||||
名称
|
||||
</label>
|
||||
<Input
|
||||
id="sentiment-agent-name"
|
||||
value={name}
|
||||
maxLength={50}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="智能体名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="sentiment-agent-description"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
简介
|
||||
</label>
|
||||
<Textarea
|
||||
id="sentiment-agent-description"
|
||||
value={description}
|
||||
maxLength={300}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="智能体简介(展示在卡片与对话页)"
|
||||
className="min-h-[96px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
仅修改本浏览器展示的名称与简介;接口地址与鉴权请在部署目录的
|
||||
runtime-config.js 中配置。
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave}>保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -165,26 +165,10 @@ export function SkillPickerSheet({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const validation = await validateUpload.mutateAsync({
|
||||
file,
|
||||
targetCategory: "custom",
|
||||
});
|
||||
const validation = await validateUpload.mutateAsync(file);
|
||||
if (validation.conflict) {
|
||||
const owner = validation.owner_username ?? validation.owner_user_id;
|
||||
if (validation.owned_by_other && owner) {
|
||||
toast.error(
|
||||
`技能「${validation.skill_name}」已由用户「${owner}」上传,不能覆盖。`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (validation.owned_by_me && validation.can_overwrite) {
|
||||
toast.warning(
|
||||
`您已上传过同名技能「${validation.skill_name}」,可到「技能」设置页重复上传并覆盖原版本。`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
toast.warning(
|
||||
`已存在同名技能「${validation.skill_name}」,当前不可覆盖。`,
|
||||
`已存在同名技能「${validation.skill_name}」,请到「技能」设置页上传以选择是否覆盖。`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -14,12 +14,11 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
|
||||
import type { CompactShortcutRouteParam } from "@/core/system-settings/api";
|
||||
import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { isHideChromeActive } from "@/core/embed/embed-session";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildLightAppUrl, resolveRouteParamValue } from "@/strategy-components/lib/light-app";
|
||||
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
|
||||
|
||||
/**
|
||||
* 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用,
|
||||
@@ -48,34 +47,6 @@ const triggerToneClass = (tone: AppearanceControlTone) =>
|
||||
? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground";
|
||||
|
||||
function appendShortcutPathParams(url: string, params: CompactShortcutRouteParam[]) {
|
||||
const pathSegments = params
|
||||
.filter((param) => param.paramType === "path")
|
||||
.map((param) =>
|
||||
resolveRouteParamValue({ ...param, paramType: "login" }).trim(),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.map((value) => encodeURIComponent(value));
|
||||
if (pathSegments.length === 0) return url;
|
||||
|
||||
const hashIndex = url.indexOf("#");
|
||||
if (hashIndex < 0) {
|
||||
const [path, query = ""] = url.split("?", 2);
|
||||
return `${path.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
const beforeHash = url.slice(0, hashIndex);
|
||||
const fragment = url.slice(hashIndex + 1);
|
||||
const [routePath, query = ""] = fragment.split("?", 2);
|
||||
return `${beforeHash}#${routePath.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
function isQueryShortcutParam(
|
||||
param: CompactShortcutRouteParam,
|
||||
): param is CompactShortcutRouteParam & { paramType: "login" | "theme" } {
|
||||
return param.paramType === "login" || param.paramType === "theme";
|
||||
}
|
||||
|
||||
export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) {
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
const buttons = (appearance?.compact_shortcut_buttons ?? [])
|
||||
@@ -98,10 +69,9 @@ export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }
|
||||
)}
|
||||
title={button.label}
|
||||
onClick={() => {
|
||||
const routeParams = (button.route_params ?? []).map((param) => ({ ...param }));
|
||||
const url = buildLightAppUrl({
|
||||
url: appendShortcutPathParams(button.url, routeParams),
|
||||
routeParams: routeParams.filter(isQueryShortcutParam),
|
||||
url: button.url,
|
||||
routeParams: button.route_params.map((param) => ({ ...param })),
|
||||
});
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
@@ -152,7 +122,6 @@ export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
data-compact-guide="theme-switcher"
|
||||
onMouseEnter={() => {
|
||||
cancelClose();
|
||||
setOpen(true);
|
||||
@@ -223,19 +192,22 @@ const LAYOUT_OPTIONS = [
|
||||
* 界面模式(全量 / 简洁)下拉切换。换肤旁并排,图标 + 下拉:
|
||||
* - 简洁模式 ChatPage 顶栏(全局顶栏被隐藏)。
|
||||
* - 全量模式 Header 右上角。
|
||||
* 当前态为有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认)——
|
||||
* useLayoutMode 总会返回具体值,不再有 undefined 兜底。
|
||||
* 用户未显式设置过界面模式时(mode === undefined),当前态按调用方所在的实际界面
|
||||
* 兜底——`fallback` 指明这一回退值(Header 传 "full",ChatPage 传 "compact")。
|
||||
*/
|
||||
export function LayoutModeSwitcher({
|
||||
tone,
|
||||
fallback = "compact",
|
||||
navigateTo = "/page/workspace/chats/new",
|
||||
}: {
|
||||
tone: AppearanceControlTone;
|
||||
fallback?: "full" | "compact";
|
||||
navigateTo?: string | null;
|
||||
}) {
|
||||
const [mode, setLayoutMode] = useLayoutMode();
|
||||
const navigate = useNavigate();
|
||||
const TriggerIcon = mode === "full" ? PanelLeftIcon : LayoutIcon;
|
||||
const current = mode ?? fallback;
|
||||
const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon;
|
||||
|
||||
if (env.FORCE_COLLAPSED_SIDEBAR || isHideChromeActive()) return null;
|
||||
|
||||
@@ -249,7 +221,6 @@ export function LayoutModeSwitcher({
|
||||
className={cn("size-8 shrink-0 rounded-sm", triggerToneClass(tone))}
|
||||
title="界面模式"
|
||||
aria-label="界面模式"
|
||||
data-compact-guide="layout-mode"
|
||||
>
|
||||
<TriggerIcon className="size-4" />
|
||||
</Button>
|
||||
@@ -259,7 +230,7 @@ export function LayoutModeSwitcher({
|
||||
<DropdownMenuSeparator />
|
||||
{LAYOUT_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const active = mode === option.id;
|
||||
const active = current === option.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
RefreshCwIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { artifactPathname, artifactRelativePath } from "@/core/artifacts/listing";
|
||||
import { getFileIcon, getFileName } from "@/core/utils/files";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type DirectoryNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
directories: DirectoryNode[];
|
||||
files: Array<{ artifact: string; name: string; relativePath: string }>;
|
||||
};
|
||||
|
||||
function buildDirectoryTree(artifacts: string[]): DirectoryNode {
|
||||
const root: DirectoryNode = {
|
||||
name: "生成文件",
|
||||
path: "",
|
||||
directories: [],
|
||||
files: [],
|
||||
};
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
const relativePath = artifactRelativePath(artifact);
|
||||
const parts = relativePath.split("/").filter(Boolean);
|
||||
if (parts.length === 0) continue;
|
||||
|
||||
let current = root;
|
||||
for (const directoryName of parts.slice(0, -1)) {
|
||||
let child = current.directories.find((node) => node.name === directoryName);
|
||||
if (!child) {
|
||||
child = {
|
||||
name: directoryName,
|
||||
path: current.path ? `${current.path}/${directoryName}` : directoryName,
|
||||
directories: [],
|
||||
files: [],
|
||||
};
|
||||
current.directories.push(child);
|
||||
}
|
||||
current = child;
|
||||
}
|
||||
|
||||
current.files.push({
|
||||
artifact,
|
||||
name: parts.at(-1) ?? getFileName(artifact),
|
||||
relativePath,
|
||||
});
|
||||
}
|
||||
|
||||
const sort = (node: DirectoryNode) => {
|
||||
node.directories.sort((left, right) =>
|
||||
left.name.localeCompare(right.name, "zh-CN"),
|
||||
);
|
||||
node.files.sort((left, right) =>
|
||||
left.name.localeCompare(right.name, "zh-CN"),
|
||||
);
|
||||
node.directories.forEach(sort);
|
||||
};
|
||||
sort(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function DirectoryBranch({
|
||||
node,
|
||||
depth,
|
||||
collapsedDirectories,
|
||||
onToggleDirectory,
|
||||
selectedArtifact,
|
||||
onSelect,
|
||||
}: {
|
||||
node: DirectoryNode;
|
||||
depth: number;
|
||||
collapsedDirectories: Set<string>;
|
||||
onToggleDirectory: (path: string) => void;
|
||||
selectedArtifact: string | null;
|
||||
onSelect: (artifact: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{node.directories.map((directory) => {
|
||||
const collapsed = collapsedDirectories.has(directory.path);
|
||||
return (
|
||||
<div key={directory.path}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:bg-muted/70 hover:text-foreground flex h-8 w-full items-center gap-1.5 rounded-md pr-2 text-left text-xs"
|
||||
style={{ paddingLeft: `${8 + depth * 14}px` }}
|
||||
onClick={() => onToggleDirectory(directory.path)}
|
||||
title={directory.path}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRightIcon className="size-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronDownIcon className="size-3.5 shrink-0" />
|
||||
)}
|
||||
{collapsed ? (
|
||||
<FolderIcon className="size-4 shrink-0 text-amber-500" />
|
||||
) : (
|
||||
<FolderOpenIcon className="size-4 shrink-0 text-amber-500" />
|
||||
)}
|
||||
<span className="truncate">{directory.name}</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<DirectoryBranch
|
||||
node={directory}
|
||||
depth={depth + 1}
|
||||
collapsedDirectories={collapsedDirectories}
|
||||
onToggleDirectory={onToggleDirectory}
|
||||
selectedArtifact={selectedArtifact}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{node.files.map((file) => {
|
||||
const selected =
|
||||
!!selectedArtifact &&
|
||||
artifactPathname(selectedArtifact) === artifactPathname(file.artifact);
|
||||
return (
|
||||
<button
|
||||
key={file.relativePath}
|
||||
type="button"
|
||||
className={cn(
|
||||
"hover:bg-muted/70 flex min-h-8 w-full items-center gap-2 rounded-md py-1 pr-2 text-left text-xs",
|
||||
selected
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-foreground/85",
|
||||
)}
|
||||
style={{ paddingLeft: `${27 + depth * 14}px` }}
|
||||
onClick={() => onSelect(file.artifact)}
|
||||
title={file.relativePath}
|
||||
aria-current={selected ? "page" : undefined}
|
||||
>
|
||||
<span className="shrink-0">{getFileIcon(file.artifact, "size-4")}</span>
|
||||
<span className="truncate">{file.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtifactDirectory({
|
||||
className,
|
||||
artifacts,
|
||||
selectedArtifact,
|
||||
onSelect,
|
||||
onRefresh,
|
||||
refreshing = false,
|
||||
}: {
|
||||
className?: string;
|
||||
artifacts: string[];
|
||||
selectedArtifact: string | null;
|
||||
onSelect: (artifact: string) => void;
|
||||
onRefresh?: () => void;
|
||||
refreshing?: boolean;
|
||||
}) {
|
||||
const tree = useMemo(() => buildDirectoryTree(artifacts), [artifacts]);
|
||||
const [collapsedDirectories, setCollapsedDirectories] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const toggleDirectory = (path: string) => {
|
||||
setCollapsedDirectories((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"bg-muted/20 flex min-h-0 w-48 shrink-0 flex-col border-r xl:w-56",
|
||||
className,
|
||||
)}
|
||||
aria-label="生成文件目录"
|
||||
>
|
||||
<div className="flex h-12 shrink-0 items-center justify-between gap-2 border-b px-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold">生成文件</div>
|
||||
<div className="text-muted-foreground text-[11px]">{artifacts.length} 个文件</div>
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="size-7 shrink-0"
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
title="刷新文件目录"
|
||||
aria-label="刷新文件目录"
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-3.5", refreshing && "animate-spin")} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{artifacts.length > 0 ? (
|
||||
<DirectoryBranch
|
||||
node={tree}
|
||||
depth={0}
|
||||
collapsedDirectories={collapsedDirectories}
|
||||
onToggleDirectory={toggleDirectory}
|
||||
selectedArtifact={selectedArtifact}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground px-2 py-8 text-center text-xs leading-5">
|
||||
生成的文件会显示在这里
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,5 @@
|
||||
import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react";
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -28,17 +28,11 @@ export function ArtifactFileList({
|
||||
files,
|
||||
threadId,
|
||||
onOpenArtifact,
|
||||
onDownloadArtifact,
|
||||
fileDescriptions,
|
||||
}: {
|
||||
className?: string;
|
||||
files: string[];
|
||||
threadId: string;
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
/** Optional host download action for virtual/session-owned artifacts. */
|
||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||
/** Optional per-file secondary text, such as the report structure used. */
|
||||
fileDescriptions?: Record<string, ReactNode>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { isMock, publicShareCode } = useThread();
|
||||
@@ -71,14 +65,10 @@ export function ArtifactFileList({
|
||||
async (e: React.MouseEvent, filepath: string) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (downloadingFile) return;
|
||||
setDownloadingFile(filepath);
|
||||
try {
|
||||
if (onDownloadArtifact) {
|
||||
await onDownloadArtifact(filepath);
|
||||
} else {
|
||||
await downloadArtifactFile({ filepath, threadId, isMock, publicShareCode });
|
||||
}
|
||||
if (downloadingFile) return;
|
||||
setDownloadingFile(filepath);
|
||||
try {
|
||||
await downloadArtifactFile({ filepath, threadId, isMock, publicShareCode });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
@@ -88,7 +78,7 @@ export function ArtifactFileList({
|
||||
setDownloadingFile(null);
|
||||
}
|
||||
},
|
||||
[threadId, isMock, publicShareCode, downloadingFile, onDownloadArtifact],
|
||||
[threadId, isMock, publicShareCode, downloadingFile],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -105,10 +95,10 @@ export function ArtifactFileList({
|
||||
<div className="absolute top-2 -left-0.5">
|
||||
{getFileIcon(file, "size-6")}
|
||||
</div>
|
||||
</CardTitle>
|
||||
<CardDescription className="min-w-0 pl-8 text-xs">
|
||||
{fileDescriptions?.[file] ?? `${getFileExtensionDisplayName(file)} file`}
|
||||
</CardDescription>
|
||||
</CardTitle>
|
||||
<CardDescription className="min-w-0 pl-8 text-xs">
|
||||
{getFileExtensionDisplayName(file)} file
|
||||
</CardDescription>
|
||||
<CardAction className="row-span-1 self-center">
|
||||
{file.endsWith(".skill") && (
|
||||
<Button
|
||||
|
||||
@@ -1,247 +1,103 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { useSidebarSafe } from "@/components/ui/sidebar";
|
||||
import { env } from "@/env";
|
||||
|
||||
export function artifactPreviewKey(artifact: string): string {
|
||||
if (!artifact.startsWith("write-file:")) {
|
||||
return artifact.trim();
|
||||
}
|
||||
try {
|
||||
const url = new URL(artifact);
|
||||
const messageId = url.searchParams.get("message_id");
|
||||
const toolCallId = url.searchParams.get("tool_call_id");
|
||||
if (messageId && toolCallId) {
|
||||
return `${messageId}:${toolCallId}`;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the complete value for malformed legacy virtual URLs.
|
||||
}
|
||||
return artifact;
|
||||
}
|
||||
|
||||
export interface ArtifactsContextType {
|
||||
artifacts: string[];
|
||||
setArtifacts: (artifacts: string[]) => void;
|
||||
|
||||
selectedArtifact: string | null;
|
||||
selectedArtifactKey: string | null;
|
||||
autoSelect: boolean;
|
||||
select: (artifact: string, autoSelect?: boolean) => void;
|
||||
/** Open/update a streamed write without overriding a manual close of that call. */
|
||||
openStreamedArtifact: (artifact: string, previewKey: string) => boolean;
|
||||
deselect: () => void;
|
||||
|
||||
open: boolean;
|
||||
autoOpen: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
/**
|
||||
* 流式写文件自动开沙箱的两阶段标记:openStreamedArtifact 先收起两侧侧栏
|
||||
* (阶段一),等收起布局落定后再真正翻转 open(阶段二)。两阶段之间为
|
||||
* true —— 右侧智能体信息面板据此提前收起,避免与沙箱展开同帧叠加造成卡顿。
|
||||
*/
|
||||
preOpen: boolean;
|
||||
}
|
||||
|
||||
const ArtifactsContext = createContext<ArtifactsContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface ArtifactsProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// 流式写文件自动开沙箱的分步延迟:t=0 收起左侧聊天记录侧栏,延迟
|
||||
// STREAMED_PREOPEN_DELAY_MS 后再收右侧智能体信息面板(两次收起错开、
|
||||
// 不做「左边收完再收右边」的判断,只给固定延迟,避免收起瞬间同帧叠加
|
||||
// 卡顿),到 STREAMED_OPEN_DELAY_MS(右侧 300ms 收起动画结束)才展开沙箱。
|
||||
const STREAMED_PREOPEN_DELAY_MS = 200;
|
||||
const STREAMED_OPEN_DELAY_MS = 500;
|
||||
|
||||
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
const [artifacts, setArtifactsState] = useState<string[]>([]);
|
||||
const setArtifacts = useCallback((next: string[]) => {
|
||||
const normalized = Array.isArray(next) ? next : [];
|
||||
setArtifactsState((previous) =>
|
||||
previous.length === normalized.length &&
|
||||
previous.every((artifact, index) => artifact === normalized[index])
|
||||
? previous
|
||||
: normalized,
|
||||
);
|
||||
}, []);
|
||||
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
|
||||
const [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null);
|
||||
const [autoSelect, setAutoSelect] = useState(true);
|
||||
const [open, setOpenState] = useState(
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
const [preOpen, setPreOpen] = useState(false);
|
||||
const [autoOpen, setAutoOpen] = useState(true);
|
||||
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
|
||||
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
|
||||
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
|
||||
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
|
||||
const autoOpenRef = useRef(autoOpen);
|
||||
const openStateRef = useRef(open);
|
||||
const preOpenTimerRef = useRef<number | null>(null);
|
||||
const openTimerRef = useRef<number | null>(null);
|
||||
const sidebar = useSidebarSafe();
|
||||
|
||||
const applyOpen = useCallback((next: boolean) => {
|
||||
openStateRef.current = next;
|
||||
setOpenState((previous) => (previous === next ? previous : next));
|
||||
}, []);
|
||||
|
||||
// 取消尚未触发的分步自动开沙箱(用户手动关闭 / 切换会话时)。
|
||||
const cancelPendingStreamedOpen = useCallback(() => {
|
||||
if (preOpenTimerRef.current !== null) {
|
||||
window.clearTimeout(preOpenTimerRef.current);
|
||||
preOpenTimerRef.current = null;
|
||||
}
|
||||
if (openTimerRef.current !== null) {
|
||||
window.clearTimeout(openTimerRef.current);
|
||||
openTimerRef.current = null;
|
||||
}
|
||||
setPreOpen(false);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (preOpenTimerRef.current !== null) {
|
||||
window.clearTimeout(preOpenTimerRef.current);
|
||||
preOpenTimerRef.current = null;
|
||||
}
|
||||
if (openTimerRef.current !== null) {
|
||||
window.clearTimeout(openTimerRef.current);
|
||||
openTimerRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const select = useCallback(
|
||||
(artifact: string, autoSelect = false) => {
|
||||
const previewKey = artifactPreviewKey(artifact);
|
||||
selectedArtifactKeyRef.current = previewKey;
|
||||
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
|
||||
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
|
||||
sidebar?.setOpen(false);
|
||||
}
|
||||
if (!autoSelect) {
|
||||
setAutoSelect((previous) => (previous ? false : previous));
|
||||
}
|
||||
},
|
||||
[sidebar],
|
||||
);
|
||||
|
||||
const openStreamedArtifact = useCallback(
|
||||
(artifact: string, previewKey: string) => {
|
||||
if (suppressedAutoOpenKeyRef.current === previewKey) {
|
||||
return false;
|
||||
}
|
||||
selectedArtifactKeyRef.current = previewKey;
|
||||
autoOpenRef.current = true;
|
||||
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
|
||||
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
|
||||
setAutoSelect((previous) => (previous ? previous : true));
|
||||
setAutoOpen((previous) => (previous ? previous : true));
|
||||
// 阶段一(t=0):立即收起左侧聊天记录侧栏。
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
|
||||
sidebar?.setOpen(false);
|
||||
}
|
||||
if (openStateRef.current) {
|
||||
// 沙箱已展开(如连续写多个文件):只需切换选中文件,无需分步。
|
||||
cancelPendingStreamedOpen();
|
||||
return true;
|
||||
}
|
||||
if (openTimerRef.current === null) {
|
||||
// 阶段二(t=+200ms):延迟固定时长再置 preOpen,右侧智能体信息
|
||||
// 面板此时才开始收起 —— 与左侧错开,避免两次收起同帧叠加卡顿。
|
||||
preOpenTimerRef.current = window.setTimeout(() => {
|
||||
preOpenTimerRef.current = null;
|
||||
setPreOpen(true);
|
||||
}, STREAMED_PREOPEN_DELAY_MS);
|
||||
// 阶段三(t=+500ms,右侧 300ms 收起动画结束):翻转 open 展开
|
||||
// 沙箱,把「聊天列变窄 + 沙箱内容挂载」与侧栏收起彻底分帧。
|
||||
openTimerRef.current = window.setTimeout(() => {
|
||||
openTimerRef.current = null;
|
||||
applyOpen(true);
|
||||
setPreOpen(false);
|
||||
}, STREAMED_OPEN_DELAY_MS);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[applyOpen, cancelPendingStreamedOpen, sidebar],
|
||||
);
|
||||
|
||||
const deselect = useCallback(() => {
|
||||
selectedArtifactKeyRef.current = null;
|
||||
setSelectedArtifact((previous) => (previous === null ? previous : null));
|
||||
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
|
||||
setAutoSelect((previous) => (previous ? previous : true));
|
||||
cancelPendingStreamedOpen();
|
||||
applyOpen(false);
|
||||
suppressedAutoOpenKeyRef.current = null;
|
||||
}, [applyOpen, cancelPendingStreamedOpen]);
|
||||
|
||||
const setOpen = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
// 两阶段等待期间用户手动关闭:取消尚未触发的自动展开。
|
||||
cancelPendingStreamedOpen();
|
||||
if (autoOpenRef.current) {
|
||||
autoOpenRef.current = false;
|
||||
setAutoOpen((previous) => (previous ? false : previous));
|
||||
setAutoSelect((previous) => (previous ? false : previous));
|
||||
}
|
||||
suppressedAutoOpenKeyRef.current = selectedArtifactKeyRef.current;
|
||||
}
|
||||
applyOpen(isOpen);
|
||||
},
|
||||
[applyOpen, cancelPendingStreamedOpen],
|
||||
);
|
||||
|
||||
const value: ArtifactsContextType = {
|
||||
artifacts,
|
||||
setArtifacts,
|
||||
|
||||
open,
|
||||
preOpen,
|
||||
autoOpen,
|
||||
autoSelect,
|
||||
setOpen,
|
||||
|
||||
selectedArtifact,
|
||||
selectedArtifactKey,
|
||||
select,
|
||||
openStreamedArtifact,
|
||||
deselect,
|
||||
};
|
||||
|
||||
return (
|
||||
<ArtifactsContext.Provider value={value}>
|
||||
{children}
|
||||
</ArtifactsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useArtifacts() {
|
||||
const context = useContext(ArtifactsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useArtifacts must be used within an ArtifactsProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useArtifactsOptional(): ArtifactsContextType | null {
|
||||
return useContext(ArtifactsContext) ?? null;
|
||||
}
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { useSidebarSafe } from "@/components/ui/sidebar";
|
||||
import { env } from "@/env";
|
||||
|
||||
export interface ArtifactsContextType {
|
||||
artifacts: string[];
|
||||
setArtifacts: (artifacts: string[]) => void;
|
||||
|
||||
selectedArtifact: string | null;
|
||||
autoSelect: boolean;
|
||||
select: (artifact: string, autoSelect?: boolean) => void;
|
||||
deselect: () => void;
|
||||
|
||||
open: boolean;
|
||||
autoOpen: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const ArtifactsContext = createContext<ArtifactsContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface ArtifactsProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
const [artifacts, setArtifactsState] = useState<string[]>([]);
|
||||
const setArtifacts = useCallback((next: string[]) => {
|
||||
setArtifactsState(Array.isArray(next) ? next : []);
|
||||
}, []);
|
||||
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
|
||||
const [autoSelect, setAutoSelect] = useState(true);
|
||||
const [open, setOpen] = useState(
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
const [autoOpen, setAutoOpen] = useState(true);
|
||||
const sidebar = useSidebarSafe();
|
||||
|
||||
const select = useCallback(
|
||||
(artifact: string, autoSelect = false) => {
|
||||
setSelectedArtifact(artifact);
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
|
||||
sidebar?.setOpen(false);
|
||||
}
|
||||
if (!autoSelect) {
|
||||
setAutoSelect(false);
|
||||
}
|
||||
},
|
||||
[sidebar, setSelectedArtifact, setAutoSelect],
|
||||
);
|
||||
|
||||
const deselect = useCallback(() => {
|
||||
setSelectedArtifact(null);
|
||||
setAutoSelect(true);
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const value: ArtifactsContextType = {
|
||||
artifacts,
|
||||
setArtifacts,
|
||||
|
||||
open,
|
||||
autoOpen,
|
||||
autoSelect,
|
||||
setOpen: (isOpen: boolean) => {
|
||||
if (!isOpen && autoOpen) {
|
||||
setAutoOpen(false);
|
||||
setAutoSelect(false);
|
||||
}
|
||||
setOpen(isOpen);
|
||||
},
|
||||
|
||||
selectedArtifact,
|
||||
select,
|
||||
deselect,
|
||||
};
|
||||
|
||||
return (
|
||||
<ArtifactsContext.Provider value={value}>
|
||||
{children}
|
||||
</ArtifactsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useArtifacts() {
|
||||
const context = useContext(ArtifactsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useArtifacts must be used within an ArtifactsProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useArtifactsOptional(): ArtifactsContextType | null {
|
||||
return useContext(ArtifactsContext) ?? null;
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FilePenLineIcon, ImagePlusIcon, SparklesIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, 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 { Switch } from "@/components/ui/switch";
|
||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
|
||||
import { useReportStructures, REPORT_STRUCTURE_MODE_OPTIONS, normalizeReportStructureMode, type ReportStructureMode } from "@/strategy-components/api/report-structures";
|
||||
|
||||
const KEEP_CURRENT_REPORT_STRUCTURE = "__keep_current_report_structure__";
|
||||
const DEFAULT_REWRITE_INSTRUCTION = "统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。";
|
||||
const DEFAULT_GENERATE_INSTRUCTION = "基于已选资料按所选报告结构生成一篇新的完整报告;保持事实可核验,不补充没有依据的信息。";
|
||||
|
||||
export interface DocumentRewriteDialogInput {
|
||||
instruction: string;
|
||||
style: DocumentRewriteStyle;
|
||||
modelName: string | null;
|
||||
reportOutline: string;
|
||||
structureMode: "fixed" | "adaptive";
|
||||
generateImages: boolean;
|
||||
maxGeneratedImages: number;
|
||||
}
|
||||
|
||||
const STYLE_OPTIONS: { value: DocumentRewriteStyle; label: string; description: string }[] = [
|
||||
{
|
||||
value: "formal_analysis",
|
||||
label: "正式分析",
|
||||
description: "客观、严谨,适合报告与研究材料。",
|
||||
},
|
||||
{
|
||||
value: "professional",
|
||||
label: "专业正式",
|
||||
description: "适合业务文档和说明材料。",
|
||||
},
|
||||
{
|
||||
value: "concise",
|
||||
label: "精炼简洁",
|
||||
description: "减少重复,保留核心信息。",
|
||||
},
|
||||
{
|
||||
value: "readable",
|
||||
label: "清晰易读",
|
||||
description: "优化段落衔接和阅读节奏。",
|
||||
},
|
||||
];
|
||||
|
||||
export function DocumentRewriteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
fileName,
|
||||
charCount,
|
||||
imageGenerationAvailable = false,
|
||||
maxGeneratedImages = 4,
|
||||
initialReportOutline,
|
||||
operationLabel,
|
||||
operationDescription,
|
||||
submitLabel,
|
||||
operation,
|
||||
onStart,
|
||||
onRewriteOriginal,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
fileName: string;
|
||||
charCount: number;
|
||||
imageGenerationAvailable?: boolean;
|
||||
maxGeneratedImages?: number;
|
||||
/** A saved report structure selected before the dialog was opened. */
|
||||
initialReportOutline?: string;
|
||||
/** Optional copy for flows that create a new document instead of replacing one. */
|
||||
operationLabel?: string;
|
||||
operationDescription?: string;
|
||||
submitLabel?: string;
|
||||
/** ``generate`` creates an independent report; ``rewrite`` edits this report in place. */
|
||||
operation?: "rewrite" | "generate";
|
||||
onStart: (input: DocumentRewriteDialogInput) => void;
|
||||
/** Optional secondary action for generation flows that rewrites the source report in place. */
|
||||
onRewriteOriginal?: (input: DocumentRewriteDialogInput) => void;
|
||||
}) {
|
||||
const isNewReportGeneration = operation === "generate";
|
||||
const [instruction, setInstruction] = useState(DEFAULT_REWRITE_INSTRUCTION);
|
||||
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
|
||||
const [modelName, setModelName] = useState<string | null>(null);
|
||||
const [reportStructureId, setReportStructureId] = useState(KEEP_CURRENT_REPORT_STRUCTURE);
|
||||
const [structureMode, setStructureMode] = useState<ReportStructureMode>("adaptive");
|
||||
const [generateImages, setGenerateImages] = useState(false);
|
||||
const [imageCount, setImageCount] = useState("2");
|
||||
const reportStructuresQuery = useReportStructures({ status: "enabled" });
|
||||
const reportStructures = reportStructuresQuery.data ?? [];
|
||||
const selectedReportStructure = reportStructures.find((item) => item.id === reportStructureId);
|
||||
const initialReportStructureId = useMemo(() => {
|
||||
const outline = initialReportOutline?.trim();
|
||||
if (!outline) return null;
|
||||
return reportStructures.find((item) => item.content.trim() === outline)?.id ?? null;
|
||||
}, [initialReportOutline, reportStructures]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setInstruction(isNewReportGeneration ? DEFAULT_GENERATE_INSTRUCTION : DEFAULT_REWRITE_INSTRUCTION);
|
||||
setStyle("formal_analysis");
|
||||
setModelName(null);
|
||||
setReportStructureId(KEEP_CURRENT_REPORT_STRUCTURE);
|
||||
setStructureMode("adaptive");
|
||||
setGenerateImages(false);
|
||||
setImageCount("2");
|
||||
}, [isNewReportGeneration, open]);
|
||||
|
||||
// The structures query may resolve after the dialog opens. Only seed the
|
||||
// value while the user still has the default selection, so a manual choice
|
||||
// is never overwritten by a query refresh.
|
||||
useEffect(() => {
|
||||
if (!open || !initialReportStructureId) return;
|
||||
setReportStructureId((current) => (
|
||||
current === KEEP_CURRENT_REPORT_STRUCTURE ? initialReportStructureId : current
|
||||
));
|
||||
}, [initialReportStructureId, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedReportStructure) return;
|
||||
setStructureMode(selectedReportStructure.structureMode);
|
||||
}, [selectedReportStructure?.id, selectedReportStructure?.structureMode]);
|
||||
|
||||
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
|
||||
const canStart = instruction.trim().length > 0;
|
||||
const startWith = (handler: (input: DocumentRewriteDialogInput) => void) => {
|
||||
if (!canStart) return;
|
||||
handler({
|
||||
instruction: instruction.trim(),
|
||||
style,
|
||||
modelName,
|
||||
reportOutline: selectedReportStructure?.content ?? "",
|
||||
structureMode,
|
||||
generateImages,
|
||||
maxGeneratedImages: Math.min(
|
||||
Math.max(Number(imageCount) || 1, 1),
|
||||
Math.max(Math.min(maxGeneratedImages, 4), 1),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<SparklesIcon className="size-5 text-primary" />
|
||||
{operationLabel ?? "AI 全文改写"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{operationDescription ?? "将以当前文件快照生成临时草稿。校验通过前不会覆盖原 Markdown。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-lg border bg-muted/35 px-3 py-2.5 text-sm">
|
||||
<div className="font-medium">{fileName}</div>
|
||||
<div className="text-muted-foreground mt-1">
|
||||
{isNewReportGeneration ? "来源报告" : "原文"}约 {charCount.toLocaleString()} 字
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
{isNewReportGeneration ? "生成要求" : "改写要求"}
|
||||
<Textarea
|
||||
value={instruction}
|
||||
onChange={(event) => setInstruction(event.target.value)}
|
||||
placeholder={isNewReportGeneration
|
||||
? "例如:突出政策影响,并按所选结构组织章节;仅使用已选资料。"
|
||||
: "例如:统一为正式行业分析语气,减少重复,不新增外部事实。"}
|
||||
rows={4}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
{isNewReportGeneration ? "报告风格" : "改写风格"}
|
||||
<Select value={style} onValueChange={(value) => setStyle(value as DocumentRewriteStyle)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</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">
|
||||
报告结构
|
||||
<Select value={reportStructureId} onValueChange={setReportStructureId}>
|
||||
<SelectTrigger aria-label="报告结构">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={KEEP_CURRENT_REPORT_STRUCTURE}>沿用当前结构</SelectItem>
|
||||
{reportStructuresQuery.isLoading && (
|
||||
<SelectItem value="__report_structures_loading__" disabled>
|
||||
正在加载结构…
|
||||
</SelectItem>
|
||||
)}
|
||||
{reportStructuresQuery.isError && (
|
||||
<SelectItem value="__report_structures_error__" disabled>
|
||||
报告结构库加载失败
|
||||
</SelectItem>
|
||||
)}
|
||||
{reportStructures.map((structure) => (
|
||||
<SelectItem key={structure.id} value={structure.id}>
|
||||
{structure.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{selectedReportStructure?.name || "默认保留原文的章节安排。"}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
结构约束
|
||||
<Select
|
||||
value={structureMode}
|
||||
onValueChange={(value) => setStructureMode(normalizeReportStructureMode(value))}
|
||||
>
|
||||
<SelectTrigger aria-label="结构约束">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REPORT_STRUCTURE_MODE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{REPORT_STRUCTURE_MODE_OPTIONS.find((option) => option.value === structureMode)?.description}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
模型
|
||||
<ModelNameSelect
|
||||
value={modelName}
|
||||
onChange={setModelName}
|
||||
triggerClassName="h-10 text-sm"
|
||||
defaultLabel="系统默认模型"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
仅影响本次{isNewReportGeneration ? "新报告生成" : "全文改写"}。
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{imageGenerationAvailable && (
|
||||
<div className="rounded-lg border bg-muted/20 p-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<label className="flex min-w-0 cursor-pointer items-start gap-3" htmlFor="document-rewrite-images">
|
||||
<span className="bg-primary/10 text-primary mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
|
||||
<ImagePlusIcon className="size-4" />
|
||||
</span>
|
||||
<span className="grid gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{isNewReportGeneration ? "为新报告补充配图" : "改写后补充配图"}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs leading-5">
|
||||
根据章节生成并保存为当前沙箱的本地图片,不使用外部图片链接。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<Switch
|
||||
aria-label={isNewReportGeneration ? "为新报告补充配图" : "改写后补充配图"}
|
||||
checked={generateImages}
|
||||
id="document-rewrite-images"
|
||||
onCheckedChange={setGenerateImages}
|
||||
/>
|
||||
</div>
|
||||
{generateImages && (
|
||||
<div className="mt-3 flex items-center justify-between gap-3 border-t pt-3">
|
||||
<span className="text-muted-foreground text-xs">每次会额外消耗图片模型额度</span>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
配图数量
|
||||
<Select value={imageCount} onValueChange={setImageCount}>
|
||||
<SelectTrigger className="h-8 w-20 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: Math.min(Math.max(maxGeneratedImages, 1), 4) }, (_, index) => index + 1).map((count) => (
|
||||
<SelectItem key={count} value={String(count)}>{count} 张</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
{isNewReportGeneration && onRewriteOriginal && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canStart}
|
||||
onClick={() => startWith(onRewriteOriginal)}
|
||||
>
|
||||
<FilePenLineIcon />
|
||||
改写原文
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={!canStart}
|
||||
onClick={() => startWith(onStart)}
|
||||
>
|
||||
<SparklesIcon />
|
||||
{submitLabel ?? "开始改写"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from "./artifact-file-detail";
|
||||
export * from "./artifact-directory";
|
||||
export * from "./artifact-file-list";
|
||||
export * from "./artifact-file-detail";
|
||||
export * from "./artifact-file-list";
|
||||
export * from "./artifact-trigger";
|
||||
export * from "./context";
|
||||
@@ -117,7 +117,7 @@ export function SpreadsheetPreview({
|
||||
{Array.from({ length: columnCount }, (_, columnIndex) => (
|
||||
<th
|
||||
key={columnIndex}
|
||||
className="border-border bg-muted/60 text-foreground border px-3 py-1.5 text-left font-semibold whitespace-pre-wrap"
|
||||
className="border-border bg-muted/60 text-foreground sticky top-0 border px-3 py-1.5 text-left font-medium whitespace-pre-wrap"
|
||||
>
|
||||
{desensitize(headerRow?.[columnIndex] ?? "")}
|
||||
</th>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
@@ -14,17 +13,8 @@ import {
|
||||
} from "@/components/ui/resizable";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import {
|
||||
artifactPathname,
|
||||
listThreadArtifacts,
|
||||
mergeArtifactPaths,
|
||||
} from "@/core/artifacts/listing";
|
||||
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
|
||||
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||
import {
|
||||
@@ -40,7 +30,6 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
ArtifactFileDetail,
|
||||
ArtifactDirectory,
|
||||
ArtifactFileList,
|
||||
useArtifacts,
|
||||
} from "../artifacts";
|
||||
@@ -49,7 +38,6 @@ import { SkillDisplaySources } from "../skill-display-sources";
|
||||
|
||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||
const DIRECTORY_OPEN_MODE = { chat: 48, artifacts: 52 };
|
||||
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
||||
|
||||
type BackendReferenceSource = Parameters<typeof SkillDisplaySources>[0]["sources"][number];
|
||||
@@ -88,19 +76,9 @@ const ChatBox: React.FC<{
|
||||
threadId,
|
||||
disableArtifacts = false,
|
||||
}) => {
|
||||
const {
|
||||
isMock,
|
||||
thread,
|
||||
knowledgeReferencesEnabled = false,
|
||||
} = useThread();
|
||||
const { thread } = useThread();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const requestedArtifact = useMemo(() => {
|
||||
const value = searchParams.get("artifact")?.trim();
|
||||
return value ? artifactPathname(value) : null;
|
||||
}, [searchParams]);
|
||||
const threadIdRef = useRef(threadId);
|
||||
const openedRequestedArtifactRef = useRef<string | null>(null);
|
||||
const artifactsCountRef = useRef(0);
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const latestReferenceBatchIdRef = useRef<string | null>(null);
|
||||
@@ -112,83 +90,10 @@ const ChatBox: React.FC<{
|
||||
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||
const [settings] = useLocalSettings();
|
||||
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
|
||||
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
|
||||
const writingModeEnabled = !!settings.context.writing_mode;
|
||||
const artifactDirectoryModeEnabled =
|
||||
settings.display.artifactDirectoryModeEnabled ?? false;
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
const ragModeEnabled =
|
||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
knowledgeReferencesEnabled;
|
||||
// Frontend RAG modes write their own hidden source tool messages. They use
|
||||
// the existing reference panel, but must not request or write reference
|
||||
// batches through DeerFlow's backend APIs.
|
||||
const hasFrontendRag = useMemo(
|
||||
() =>
|
||||
(thread.values.messages ?? []).some((message) =>
|
||||
isSingleRoundRagMessage(message),
|
||||
),
|
||||
[thread.values.messages],
|
||||
);
|
||||
const referencesEnabled = ragModeEnabled || hasFrontendRag;
|
||||
const shouldFetchBackendReferenceBatches =
|
||||
referencesEnabled && !hasFrontendRag && !!threadId && threadId !== "new";
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
const { skills } = useSkills();
|
||||
|
||||
const canScanArtifactDirectory =
|
||||
(artifactDirectoryModeEnabled || !!requestedArtifact) &&
|
||||
!disableArtifacts &&
|
||||
!isMock &&
|
||||
!!threadId &&
|
||||
threadId !== "new";
|
||||
const artifactListingQuery = useQuery({
|
||||
queryKey: ["artifact-directory", threadId],
|
||||
queryFn: ({ signal }) => listThreadArtifacts(threadId, signal),
|
||||
enabled: canScanArtifactDirectory,
|
||||
refetchInterval: thread.isLoading ? 1_500 : false,
|
||||
retry: false,
|
||||
});
|
||||
const checkpointArtifacts = useMemo(
|
||||
() => (disableArtifacts ? [] : (thread.values.artifacts ?? [])),
|
||||
[disableArtifacts, thread.values.artifacts],
|
||||
);
|
||||
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
|
||||
const availableArtifacts = useMemo(
|
||||
() => {
|
||||
if (!artifactDirectoryModeEnabled && !requestedArtifact) {
|
||||
return checkpointArtifacts;
|
||||
}
|
||||
const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map(
|
||||
(file) => file.path,
|
||||
);
|
||||
if (requestedArtifact) discoveredArtifacts.push(requestedArtifact);
|
||||
return mergeArtifactPaths(checkpointArtifacts, discoveredArtifacts);
|
||||
},
|
||||
[
|
||||
artifactDirectoryModeEnabled,
|
||||
artifactListingQuery.data?.files,
|
||||
checkpointArtifacts,
|
||||
requestedArtifact,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canScanArtifactDirectory || thread.isLoading) return;
|
||||
// Run once after streaming settles as well as when checkpoint artifacts
|
||||
// change, so files written without present_files are not missed by the
|
||||
// last polling tick.
|
||||
void artifactListingQuery.refetch();
|
||||
}, [
|
||||
artifactListingQuery.refetch,
|
||||
canScanArtifactDirectory,
|
||||
checkpointArtifactSignature,
|
||||
thread.isLoading,
|
||||
]);
|
||||
|
||||
const {
|
||||
artifacts,
|
||||
open: artifactsOpen,
|
||||
@@ -197,17 +102,15 @@ const ChatBox: React.FC<{
|
||||
select: selectArtifact,
|
||||
deselect,
|
||||
selectedArtifact,
|
||||
selectedArtifactKey,
|
||||
openStreamedArtifact,
|
||||
} = useArtifacts();
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[referencesEnabled, skills],
|
||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[ragModeEnabled, skills],
|
||||
);
|
||||
const fallbackReferenceBatches = useMemo(() => {
|
||||
if (!referencesEnabled) {
|
||||
if (!ragModeEnabled) {
|
||||
return [];
|
||||
}
|
||||
return buildTurnReferenceBatches(
|
||||
@@ -215,32 +118,9 @@ const ChatBox: React.FC<{
|
||||
skillDisplayMap,
|
||||
thread.isLoading,
|
||||
);
|
||||
}, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
||||
}, [ragModeEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
||||
useEffect(() => {
|
||||
if (
|
||||
disableArtifacts ||
|
||||
!requestedArtifact ||
|
||||
!threadId ||
|
||||
threadId === "new"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const requestKey = `${threadId}:${artifactPathname(requestedArtifact)}`;
|
||||
if (openedRequestedArtifactRef.current === requestKey) return;
|
||||
openedRequestedArtifactRef.current = requestKey;
|
||||
selectArtifact(requestedArtifact);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
}, [
|
||||
disableArtifacts,
|
||||
requestedArtifact,
|
||||
selectArtifact,
|
||||
setArtifactsOpen,
|
||||
threadId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldFetchBackendReferenceBatches) {
|
||||
if (!ragModeEnabled) {
|
||||
setBackendReferenceBatches([]);
|
||||
return;
|
||||
}
|
||||
@@ -276,12 +156,7 @@ const ChatBox: React.FC<{
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [
|
||||
shouldFetchBackendReferenceBatches,
|
||||
thread.isLoading,
|
||||
threadId,
|
||||
thread.values.messages,
|
||||
]);
|
||||
}, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]);
|
||||
const adaptedBackendReferenceBatches = useMemo(
|
||||
() =>
|
||||
adaptBackendReferenceBatches(
|
||||
@@ -292,22 +167,15 @@ const ChatBox: React.FC<{
|
||||
);
|
||||
const referenceBatches = useMemo(
|
||||
() => {
|
||||
if (
|
||||
shouldFetchBackendReferenceBatches &&
|
||||
adaptedBackendReferenceBatches.length > 0
|
||||
) {
|
||||
if (adaptedBackendReferenceBatches.length > 0) {
|
||||
return adaptedBackendReferenceBatches;
|
||||
}
|
||||
return fallbackReferenceBatches;
|
||||
},
|
||||
[
|
||||
adaptedBackendReferenceBatches,
|
||||
fallbackReferenceBatches,
|
||||
shouldFetchBackendReferenceBatches,
|
||||
],
|
||||
[adaptedBackendReferenceBatches, fallbackReferenceBatches],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!shouldFetchBackendReferenceBatches) {
|
||||
if (!ragModeEnabled) {
|
||||
return;
|
||||
}
|
||||
const signature = JSON.stringify({
|
||||
@@ -334,14 +202,7 @@ const ChatBox: React.FC<{
|
||||
fallbackBatches: debugReferenceBatches(fallbackReferenceBatches),
|
||||
finalBatches: debugReferenceBatches(referenceBatches),
|
||||
});
|
||||
}, [
|
||||
adaptedBackendReferenceBatches,
|
||||
backendReferenceBatches,
|
||||
fallbackReferenceBatches,
|
||||
referenceBatches,
|
||||
shouldFetchBackendReferenceBatches,
|
||||
threadId,
|
||||
]);
|
||||
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]);
|
||||
const latestReferenceBatch =
|
||||
referenceBatches.length > 0
|
||||
? referenceBatches[referenceBatches.length - 1]!
|
||||
@@ -352,7 +213,7 @@ const ChatBox: React.FC<{
|
||||
const referenceSources = activeReferenceBatch?.sources ?? [];
|
||||
const hasCitationReferences = referenceSources.length > 0;
|
||||
useEffect(() => {
|
||||
if (!shouldFetchBackendReferenceBatches || !activeReferenceBatch) {
|
||||
if (!ragModeEnabled || !activeReferenceBatch) {
|
||||
return;
|
||||
}
|
||||
logReferenceDebug("frontend-active-reference-batch-payload", threadId, {
|
||||
@@ -361,7 +222,7 @@ const ChatBox: React.FC<{
|
||||
sourceCount: activeReferenceBatch.sources.length,
|
||||
sources: activeReferenceBatch.sources.map(debugReferenceSource),
|
||||
});
|
||||
}, [activeReferenceBatch, shouldFetchBackendReferenceBatches, threadId]);
|
||||
}, [activeReferenceBatch, ragModeEnabled, threadId]);
|
||||
useEffect(() => {
|
||||
if (hasCitationReferences) {
|
||||
setReferencesOpen(true);
|
||||
@@ -405,8 +266,7 @@ const ChatBox: React.FC<{
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const threadChanged = threadIdRef.current !== threadId;
|
||||
if (threadChanged) {
|
||||
if (threadIdRef.current !== threadId) {
|
||||
threadIdRef.current = threadId;
|
||||
artifactsCountRef.current = 0;
|
||||
deselect();
|
||||
@@ -420,28 +280,19 @@ const ChatBox: React.FC<{
|
||||
}
|
||||
|
||||
// Update artifacts from the current thread
|
||||
const nextArtifacts = availableArtifacts;
|
||||
const nextArtifacts = disableArtifacts ? [] : (thread.values.artifacts ?? []);
|
||||
const hadArtifacts = artifactsCountRef.current > 0;
|
||||
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
|
||||
artifactsCountRef.current = nextArtifacts.length;
|
||||
const artifactsChanged =
|
||||
artifacts.length !== nextArtifacts.length ||
|
||||
artifacts.some((artifact, index) => artifact !== nextArtifacts[index]);
|
||||
if (artifactsChanged) {
|
||||
setArtifacts(nextArtifacts);
|
||||
}
|
||||
setArtifacts(nextArtifacts);
|
||||
if (
|
||||
!disableArtifacts &&
|
||||
nextArtifacts.length > 0 &&
|
||||
((artifactDirectoryModeEnabled && threadChanged) ||
|
||||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
|
||||
(hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) &&
|
||||
!selectedArtifact
|
||||
) {
|
||||
const newest = nextArtifacts[nextArtifacts.length - 1]!;
|
||||
if (shouldAutoOpenWritingArtifact(newest, thread.messages, writingModeEnabled)) {
|
||||
selectArtifact(newest);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
}
|
||||
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
}
|
||||
|
||||
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
|
||||
@@ -464,61 +315,13 @@ const ChatBox: React.FC<{
|
||||
}
|
||||
}, [
|
||||
threadId,
|
||||
artifactDirectoryModeEnabled,
|
||||
autoSelectFirstArtifact,
|
||||
deselect,
|
||||
selectArtifact,
|
||||
selectedArtifact,
|
||||
artifacts,
|
||||
setArtifacts,
|
||||
availableArtifacts,
|
||||
thread.values.artifacts,
|
||||
disableArtifacts,
|
||||
thread.messages,
|
||||
writingModeEnabled,
|
||||
]);
|
||||
|
||||
// Live sandbox: open as soon as any in-flight write_file has a path or
|
||||
// the first content bytes — not only when that tool is the last CoT step.
|
||||
// Agentfx (and other agents) often emit write_file + present_files/bash in
|
||||
// the same turn; the collapsed earlier write would otherwise never auto-open.
|
||||
const streamingWritePreview = useMemo(
|
||||
() =>
|
||||
disableArtifacts || !thread.isLoading
|
||||
? null
|
||||
: latestInFlightWriteFilePreview(thread.messages ?? [], writingModeEnabled),
|
||||
[disableArtifacts, thread.isLoading, thread.messages, writingModeEnabled],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!streamingWritePreview) return;
|
||||
openStreamedArtifact(
|
||||
streamingWritePreview.artifactPath,
|
||||
streamingWritePreview.previewKey,
|
||||
);
|
||||
setSideTab("document");
|
||||
}, [openStreamedArtifact, streamingWritePreview]);
|
||||
|
||||
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
|
||||
useEffect(() => {
|
||||
const wasEnabled = directoryModeWasEnabledRef.current;
|
||||
directoryModeWasEnabledRef.current = artifactDirectoryModeEnabled;
|
||||
if (
|
||||
wasEnabled ||
|
||||
!artifactDirectoryModeEnabled ||
|
||||
availableArtifacts.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!selectedArtifact) {
|
||||
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!);
|
||||
}
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
}, [
|
||||
artifactDirectoryModeEnabled,
|
||||
availableArtifacts,
|
||||
selectArtifact,
|
||||
selectedArtifact,
|
||||
setArtifactsOpen,
|
||||
]);
|
||||
|
||||
const artifactPanelOpen = useMemo(() => {
|
||||
@@ -531,10 +334,7 @@ const ChatBox: React.FC<{
|
||||
return artifactsOpen;
|
||||
}, [artifactsOpen, artifacts, disableArtifacts]);
|
||||
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
|
||||
const showTabs =
|
||||
hasCitationReferences &&
|
||||
(!!selectedArtifact ||
|
||||
(artifactDirectoryModeEnabled && artifacts.length > 0));
|
||||
const showTabs = !!selectedArtifact && hasCitationReferences;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedArtifact) {
|
||||
@@ -554,103 +354,12 @@ const ChatBox: React.FC<{
|
||||
useEffect(() => {
|
||||
if (layoutRef.current) {
|
||||
if (sidePanelOpen) {
|
||||
layoutRef.current.setLayout(
|
||||
artifactDirectoryModeEnabled && artifactPanelOpen
|
||||
? DIRECTORY_OPEN_MODE
|
||||
: OPEN_MODE,
|
||||
);
|
||||
layoutRef.current.setLayout(OPEN_MODE);
|
||||
} else {
|
||||
layoutRef.current.setLayout(CLOSE_MODE);
|
||||
}
|
||||
}
|
||||
}, [artifactDirectoryModeEnabled, artifactPanelOpen, sidePanelOpen]);
|
||||
|
||||
const artifactDocumentPanel = artifactDirectoryModeEnabled ? (
|
||||
<div className="flex size-full min-w-0 overflow-hidden rounded-lg border">
|
||||
<ArtifactDirectory
|
||||
artifacts={artifacts}
|
||||
selectedArtifact={selectedArtifact}
|
||||
onSelect={(artifact) => {
|
||||
selectArtifact(artifact);
|
||||
setArtifactsOpen(true);
|
||||
}}
|
||||
onRefresh={
|
||||
canScanArtifactDirectory
|
||||
? () => void artifactListingQuery.refetch()
|
||||
: undefined
|
||||
}
|
||||
refreshing={artifactListingQuery.isFetching}
|
||||
/>
|
||||
<div className="relative min-w-0 flex-1 overflow-hidden">
|
||||
{selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
key={selectedArtifactKey ?? selectedArtifact}
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
allowVirtualMarkdownEdit
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => setArtifactsOpen(false)}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title="请选择文件"
|
||||
description="从左侧目录中选择一个文件进行查看"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
key={selectedArtifactKey ?? selectedArtifact}
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
allowVirtualMarkdownEdit
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => setArtifactsOpen(false)}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{artifacts.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title="No artifact selected"
|
||||
description="Select an artifact to view its details"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||
<header className="shrink-0">
|
||||
<h2 className="text-lg font-medium">Artifacts</h2>
|
||||
</header>
|
||||
<main className="min-h-0 grow">
|
||||
<ArtifactFileList
|
||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||
files={artifacts}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [sidePanelOpen]);
|
||||
|
||||
if (disableArtifacts) {
|
||||
return (
|
||||
@@ -710,7 +419,12 @@ const ChatBox: React.FC<{
|
||||
</Button>
|
||||
</div>
|
||||
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
{artifactDocumentPanel}
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
allowVirtualMarkdownEdit
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
||||
<ReferencePanel
|
||||
@@ -719,8 +433,13 @@ const ChatBox: React.FC<{
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : artifactPanelOpen ? (
|
||||
artifactDocumentPanel
|
||||
) : selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
allowVirtualMarkdownEdit
|
||||
/>
|
||||
) : hasCitationReferences ? (
|
||||
<div className="relative size-full overflow-hidden">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
@@ -740,7 +459,41 @@ const ChatBox: React.FC<{
|
||||
sources={referenceSources}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{thread.values.artifacts?.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title="No artifact selected"
|
||||
description="Select an artifact to view its details"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||
<header className="shrink-0">
|
||||
<h2 className="text-lg font-medium">Artifacts</h2>
|
||||
</header>
|
||||
<main className="min-h-0 grow">
|
||||
<ArtifactFileList
|
||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||
files={thread.values.artifacts ?? []}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
@@ -989,10 +742,11 @@ function buildTurnReferenceBatches(
|
||||
continue;
|
||||
}
|
||||
const nextHumanIndex = findNextHumanIndex(messages, index + 1);
|
||||
if (nextHumanIndex === -1 && isLoading) {
|
||||
continue;
|
||||
}
|
||||
const endIndex = nextHumanIndex === -1 ? messages.length : nextHumanIndex;
|
||||
const assistant =
|
||||
findLastAssistantAnswer(messages, index + 1, endIndex) ??
|
||||
findFrontendRagAssistant(messages, index + 1, endIndex);
|
||||
const assistant = findLastAssistantAnswer(messages, index + 1, endIndex);
|
||||
if (!assistant?.id) {
|
||||
continue;
|
||||
}
|
||||
@@ -1002,13 +756,6 @@ function buildTurnReferenceBatches(
|
||||
nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id,
|
||||
includeBeforeMessage: false,
|
||||
}).filter((source) => source.mode !== "none");
|
||||
// GeneralQA's single-RAG stream sends `docs` before the answer. Make
|
||||
// those cards available immediately, while keeping normal streaming turns
|
||||
// on their existing "wait for an answer" behavior.
|
||||
const isFrontendRagTurn = isSingleRoundRagMessage(assistant);
|
||||
if (nextHumanIndex === -1 && isLoading && !isFrontendRagTurn) {
|
||||
continue;
|
||||
}
|
||||
if (sources.length > 0) {
|
||||
batches.push({
|
||||
id: assistant.id,
|
||||
@@ -1047,23 +794,6 @@ function findLastAssistantAnswer(
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFrontendRagAssistant(
|
||||
messages: Message[],
|
||||
start: number,
|
||||
end: number,
|
||||
): Message | null {
|
||||
for (let index = end - 1; index >= start; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (
|
||||
message?.type === "ai" &&
|
||||
isSingleRoundRagMessage(message)
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasAssistantAnswerContent(message: Message): boolean {
|
||||
const content = message.content;
|
||||
if (typeof content === "string") {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
|
||||
import { useOptionalPromptInputController } from "@/components/ai-elements/prompt-input";
|
||||
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
/**
|
||||
@@ -11,11 +11,7 @@ export function useSpecificChatMode() {
|
||||
const { t } = useI18n();
|
||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
// A regular history item can navigate here while Vite Fast Refresh is
|
||||
// reconciling the previous page tree. In that short window the page may see
|
||||
// a stale provider context. This hook only pre-fills the skill-mode draft,
|
||||
// so it must not make an ordinary chat page fail to render.
|
||||
const promptInputController = useOptionalPromptInputController();
|
||||
const promptInputController = usePromptInputController();
|
||||
const inputInitialValue = useMemo(() => {
|
||||
if (threadIdFromPath !== "new" || searchParams.get("mode") !== "skill") {
|
||||
return undefined;
|
||||
@@ -23,21 +19,16 @@ export function useSpecificChatMode() {
|
||||
return t.inputBox.createSkillPrompt;
|
||||
}, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]);
|
||||
const lastInitialValueRef = useRef<string | undefined>(undefined);
|
||||
const setInputRef = useRef<((value: string) => void) | undefined>(
|
||||
promptInputController?.textInput.setInput,
|
||||
);
|
||||
setInputRef.current = promptInputController?.textInput.setInput;
|
||||
const setInputRef = useRef(promptInputController.textInput.setInput);
|
||||
setInputRef.current = promptInputController.textInput.setInput;
|
||||
useEffect(() => {
|
||||
if (
|
||||
inputInitialValue &&
|
||||
inputInitialValue !== lastInitialValueRef.current
|
||||
) {
|
||||
lastInitialValueRef.current = inputInitialValue;
|
||||
setTimeout(() => {
|
||||
const setInput = setInputRef.current;
|
||||
if (!setInput) return;
|
||||
|
||||
setInput(inputInitialValue);
|
||||
setTimeout(() => {
|
||||
setInputRef.current(inputInitialValue);
|
||||
const textarea = document.querySelector("textarea");
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
|
||||
@@ -8,13 +8,8 @@ export function CitationLink({
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
onReferenceClick,
|
||||
onClick,
|
||||
...props
|
||||
}: ComponentProps<"a"> & {
|
||||
/** Lets a message renderer show an in-place source preview instead of scrolling. */
|
||||
onReferenceClick?: (referenceId: string) => void;
|
||||
}) {
|
||||
}: ComponentProps<"a">) {
|
||||
const childrenText = normalizeCitationText(children);
|
||||
const displayText = childrenText ?? "";
|
||||
const referenceId = href?.startsWith("#reference-")
|
||||
@@ -33,11 +28,6 @@ export function CitationLink({
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (onReferenceClick) {
|
||||
onReferenceClick(referenceId);
|
||||
onClick?.(event);
|
||||
return;
|
||||
}
|
||||
const batchId =
|
||||
event.currentTarget.closest<HTMLElement>("[data-reference-batch-id]")
|
||||
?.dataset.referenceBatchId;
|
||||
@@ -51,7 +41,6 @@ export function CitationLink({
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
};
|
||||
window.setTimeout(scroll, 240);
|
||||
onClick?.(event);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
@@ -69,7 +58,6 @@ export function CitationLink({
|
||||
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
>
|
||||
{displayText || children}
|
||||
|
||||
@@ -11,11 +11,12 @@ import { basicLightInit } from "@uiw/codemirror-theme-basic";
|
||||
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { useTheme } from "next-themes";
|
||||
import { memo, useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useThread } from "./messages/context";
|
||||
const customDarkTheme = monokaiInit({
|
||||
settings: {
|
||||
background: "transparent",
|
||||
@@ -33,7 +34,7 @@ const customLightTheme = basicLightInit({
|
||||
},
|
||||
});
|
||||
|
||||
export const CodeEditor = memo(function CodeEditor({
|
||||
export function CodeEditor({
|
||||
className,
|
||||
placeholder,
|
||||
value,
|
||||
@@ -42,7 +43,6 @@ export const CodeEditor = memo(function CodeEditor({
|
||||
disabled,
|
||||
autoFocus,
|
||||
settings,
|
||||
isStreaming = false,
|
||||
streamingReadonlyFallback = true,
|
||||
}: {
|
||||
className?: string;
|
||||
@@ -53,10 +53,11 @@ export const CodeEditor = memo(function CodeEditor({
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
settings?: unknown;
|
||||
/** Parent-owned streaming flag so this editor does not subscribe to `thread`. */
|
||||
isStreaming?: boolean;
|
||||
streamingReadonlyFallback?: boolean;
|
||||
}) {
|
||||
const {
|
||||
thread: { isLoading },
|
||||
} = useThread();
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
@@ -80,7 +81,7 @@ export const CodeEditor = memo(function CodeEditor({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isStreaming && streamingReadonlyFallback ? (
|
||||
{isLoading && streamingReadonlyFallback ? (
|
||||
<Textarea
|
||||
className={cn(
|
||||
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
|
||||
@@ -115,4 +116,4 @@ export const CodeEditor = memo(function CodeEditor({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Guide, type GuideStep } from "tdesign-react";
|
||||
|
||||
import { PROMPT_DIALOG_GUIDE_EVENT } from "@/components/workspace/custom-prompt-menu";
|
||||
|
||||
/**
|
||||
* 简洁模式(隐藏全局导航栏 + 页面侧边栏的布局)新手引导,基于 TDesign React 的
|
||||
* Guide 组件(https://tdesign.tencent.com/react/components/guide)。
|
||||
*
|
||||
* 触发方式:
|
||||
* - 自动:简洁模式问答落地页自动演示,直到用户显式点「完成」或「跳过」才写
|
||||
* localStorage 记忆;中途刷新/离开不记忆,下次进入还会再演示。
|
||||
* - 手动:左侧「设置和更多」下拉的「界面使用引导」。问答页内通过
|
||||
* COMPACT_GUIDE_START_EVENT 直接启动;其它页面先落 sessionStorage 再导航到
|
||||
* /chats/new,由本组件在挂载后消费。
|
||||
*
|
||||
* 高亮目标通过 `data-compact-guide` 标记在各组件上(见 TARGET 选择器)。启动时逐个
|
||||
* 检查目标是否存在(新用户无聊天记录、侧栏被收起等场景对应区块不渲染),缺失的
|
||||
* 步骤直接剔除 —— Guide 对找不到的目标元素会抛错,不能盲传。唯一例外是提示词
|
||||
* 管理弹窗步骤:弹窗由引导在切到该步时才打开(PROMPT_DIALOG_GUIDE_EVENT 事件),
|
||||
* 存在性检查落在它的入口(提示词栏)上,且必须等弹窗锚点真正挂载后才切步。
|
||||
*/
|
||||
|
||||
const COMPACT_GUIDE_DONE_KEY = "deerflow.compact-guide-finished";
|
||||
const COMPACT_GUIDE_PENDING_KEY = "deerflow.compact-guide-pending";
|
||||
|
||||
/** 同页手动触发引导的窗口事件名(「设置和更多」→「界面使用引导」)。 */
|
||||
export const COMPACT_GUIDE_START_EVENT = "compact-guide:start";
|
||||
|
||||
/** 引导步骤高亮目标选择器;各目标元素由所在组件打上同名 data 标记。 */
|
||||
const TARGET = {
|
||||
nav: '[data-compact-guide="sidebar-nav"]',
|
||||
recentChats: '[data-compact-guide="recent-chats"]',
|
||||
settingsMenu: '[data-compact-guide="settings-menu"]',
|
||||
themeSwitcher: '[data-compact-guide="theme-switcher"]',
|
||||
layoutMode: '[data-compact-guide="layout-mode"]',
|
||||
promptBar: '[data-compact-guide="prompt-bar"]',
|
||||
promptDialog: '[data-compact-guide="prompt-dialog"]',
|
||||
promptInput: '[data-compact-guide="prompt-input"]',
|
||||
} as const;
|
||||
|
||||
// 等侧栏宽度过渡 / 落地态布局稳定后再启动:Guide 只在步骤切换时测量目标位置,
|
||||
// 起步太早会把高亮框定位到过渡中的坐标且不会自行纠正。
|
||||
const START_DELAY_MS = 800;
|
||||
|
||||
// 引导打开提示词管理弹窗后等待其锚点挂载的最长时间,超时视为弹窗打不开,
|
||||
// 跳过该步避免引导卡死。
|
||||
const PROMPT_DIALOG_TIMEOUT_MS = 4000;
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== "undefined";
|
||||
}
|
||||
|
||||
export function isCompactGuideDone(): boolean {
|
||||
return isBrowser() && localStorage.getItem(COMPACT_GUIDE_DONE_KEY) === "1";
|
||||
}
|
||||
|
||||
function markCompactGuideDone() {
|
||||
if (isBrowser()) localStorage.setItem(COMPACT_GUIDE_DONE_KEY, "1");
|
||||
}
|
||||
|
||||
/** 跨页触发:非问答页点击「界面使用引导」时先记录请求,导航到 /chats/new 后消费。 */
|
||||
export function requestCompactGuideStart() {
|
||||
if (isBrowser()) sessionStorage.setItem(COMPACT_GUIDE_PENDING_KEY, "1");
|
||||
}
|
||||
|
||||
function hasCompactGuideRequest(): boolean {
|
||||
return (
|
||||
isBrowser() &&
|
||||
sessionStorage.getItem(COMPACT_GUIDE_PENDING_KEY) === "1"
|
||||
);
|
||||
}
|
||||
|
||||
function consumeCompactGuideRequest() {
|
||||
if (isBrowser()) sessionStorage.removeItem(COMPACT_GUIDE_PENDING_KEY);
|
||||
}
|
||||
|
||||
interface StepSpec {
|
||||
/** 高亮目标选择器;缺省(欢迎步骤 dialog 模式)以 body 兜底。 */
|
||||
selector?: string;
|
||||
/**
|
||||
* 目标元素在引导过程中才渲染时(提示词管理弹窗由引导动态打开),存在性检查
|
||||
* 改查这个已存在的「入口」元素 —— Guide 只在切到某步时才解析其元素,所以
|
||||
* 该步可以留在步骤列表里,等锚点真正挂载后再切过去。
|
||||
*/
|
||||
dependsOn?: string;
|
||||
mode: "popup" | "dialog";
|
||||
placement: NonNullable<GuideStep["placement"]>;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const STEP_SPECS: StepSpec[] = [
|
||||
{
|
||||
mode: "dialog",
|
||||
placement: "center",
|
||||
title: "欢迎使用简洁模式",
|
||||
body: "当前已隐藏顶部导航栏与全局侧边栏,为您留出更大的工作区。接下来花一分钟认识一下界面布局。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.nav,
|
||||
mode: "popup",
|
||||
placement: "right",
|
||||
title: "功能导航",
|
||||
body: "新对话、智能体、技能、AI 写作、深度研究等主要入口都在左侧这里,点击即可切换(菜单项由管理员统一配置)。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.recentChats,
|
||||
mode: "popup",
|
||||
placement: "right",
|
||||
title: "聊天记录",
|
||||
body: "最近的对话会保留在这里,点击任意一条即可继续上次的问答。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.settingsMenu,
|
||||
mode: "popup",
|
||||
placement: "right-top",
|
||||
title: "设置和更多",
|
||||
body: "通知、设置、用户排行榜,以及菜单管理、技能管理等管理入口都收纳在这个菜单里。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.themeSwitcher,
|
||||
mode: "popup",
|
||||
placement: "bottom-left",
|
||||
title: "主题换肤",
|
||||
body: "在这里一键切换红色、蓝色、深蓝等多套界面主题。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.layoutMode,
|
||||
mode: "popup",
|
||||
placement: "bottom-left",
|
||||
title: "切回全量模式",
|
||||
body: "需要顶部导航栏和全局侧边栏时,从这里随时切换回「全量模式」。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.promptBar,
|
||||
mode: "popup",
|
||||
placement: "top",
|
||||
title: "常用提示词",
|
||||
body: "输入框上方是常用提示词栏:点击任意提示词即可一键填入输入框,省去重复输入。最右侧的齿轮按钮用来管理这些提示词,点击「下一步」带你看一看。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.promptDialog,
|
||||
dependsOn: TARGET.promptBar,
|
||||
mode: "popup",
|
||||
placement: "top",
|
||||
title: "管理常用提示词",
|
||||
body: "这就是齿轮按钮打开的管理弹窗:填写名称与提示词内容、选择关联的智能体后保存,即可添加自己的常用提示词(与账号绑定、跨设备可用);下方列表中已保存的提示词可随时编辑、删除;顶部开关可让本账号只用自己保存的提示词。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.promptInput,
|
||||
mode: "popup",
|
||||
placement: "top",
|
||||
title: "开始提问",
|
||||
body: "在输入框中输入问题,可先选择智能体与知识空间,发送后即可开始问答。",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 简洁模式引导。挂在 ChatPage:`eligible` 表示当前处于简洁模式(无全局
|
||||
* chrome)且非嵌入/强制收起等特殊环境;`autoStart` 表示处于新对话落地态,
|
||||
* 满足「首次进入」条件时自动演示一遍。
|
||||
*/
|
||||
export function CompactModeGuide({
|
||||
eligible,
|
||||
autoStart = false,
|
||||
}: {
|
||||
eligible: boolean;
|
||||
autoStart?: boolean;
|
||||
}) {
|
||||
const [current, setCurrent] = useState(-1);
|
||||
const specsRef = useRef<StepSpec[]>([]);
|
||||
const stepsRef = useRef<GuideStep[]>([]);
|
||||
const pollTimerRef = useRef<number | null>(null);
|
||||
// 引导是否处于「已要求打开并接管提示词管理弹窗」状态(事件已发出 owner:true)。
|
||||
const promptDialogTakenRef = useRef(false);
|
||||
|
||||
const clearPollTimer = useCallback(() => {
|
||||
if (pollTimerRef.current !== null) {
|
||||
window.clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setPromptDialog = useCallback(
|
||||
(open: boolean, takeOver: boolean) => {
|
||||
promptDialogTakenRef.current = takeOver;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(PROMPT_DIALOG_GUIDE_EVENT, {
|
||||
detail: { open, owner: takeOver },
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const releasePromptDialog = useCallback(() => {
|
||||
if (!promptDialogTakenRef.current) return;
|
||||
setPromptDialog(false, false);
|
||||
}, [setPromptDialog]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
clearPollTimer();
|
||||
releasePromptDialog();
|
||||
const specs = STEP_SPECS.filter(
|
||||
(spec) =>
|
||||
!spec.selector || document.querySelector(spec.dependsOn ?? spec.selector),
|
||||
);
|
||||
if (specs.length === 0) return;
|
||||
specsRef.current = specs;
|
||||
stepsRef.current = specs.map((spec) => ({
|
||||
element: spec.selector ?? (() => document.body),
|
||||
mode: spec.mode,
|
||||
placement: spec.placement,
|
||||
title: spec.title,
|
||||
body: spec.body,
|
||||
}));
|
||||
setCurrent(0);
|
||||
}, [clearPollTimer, releasePromptDialog]);
|
||||
|
||||
// 自动引导:没显式结束过(没点过完成/跳过)时,每次落地页都演示。是否「已看过」
|
||||
// 只由下方 finish 回调(完成/跳过)写入 —— 这里启动时绝不能提前写,否则中途刷新
|
||||
// 又会被当成已看过。
|
||||
const autoTriedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!eligible || !autoStart || autoTriedRef.current) return;
|
||||
autoTriedRef.current = true;
|
||||
if (isCompactGuideDone()) return;
|
||||
const timer = setTimeout(start, START_DELAY_MS);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
// 组件在计时窗口内卸载(用户迅速离开页面)则下次落地再演示。
|
||||
autoTriedRef.current = false;
|
||||
};
|
||||
}, [eligible, autoStart, start]);
|
||||
|
||||
// 跨页手动触发:非问答页点「界面使用引导」→ 记录请求 → 导航到 /chats/new → 此处启动。
|
||||
// 请求只在定时器真正启动引导时才消费 —— StrictMode 下 effect 会 mount→cleanup→mount
|
||||
// 双跑,若在检查时就删除,第二次跑就拿不到请求了。
|
||||
useEffect(() => {
|
||||
if (!eligible) return;
|
||||
if (!hasCompactGuideRequest()) return;
|
||||
const timer = setTimeout(() => {
|
||||
consumeCompactGuideRequest();
|
||||
start();
|
||||
}, START_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [eligible, start]);
|
||||
|
||||
// 同页手动触发:已在问答页时「界面使用引导」直接广播事件启动。
|
||||
useEffect(() => {
|
||||
if (!eligible) return;
|
||||
const handler = () => start();
|
||||
window.addEventListener(COMPACT_GUIDE_START_EVENT, handler);
|
||||
return () => window.removeEventListener(COMPACT_GUIDE_START_EVENT, handler);
|
||||
}, [eligible, start]);
|
||||
|
||||
// 中途卸载(用户离开页面)也要归还弹窗控制权并停止等待。
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearPollTimer();
|
||||
releasePromptDialog();
|
||||
};
|
||||
}, [clearPollTimer, releasePromptDialog]);
|
||||
|
||||
// 受控切步:进入提示词弹窗步骤前先打开弹窗并等锚点挂载(Guide 切步时才解析
|
||||
// 元素,元素必须已存在);离开该步 / 跳过 / 完成时收回弹窗。跳过和完成时
|
||||
// onChange 也会收到 -1,与 finish 回调各自幂等。
|
||||
const goTo = (next: number) => {
|
||||
const specs = specsRef.current;
|
||||
clearPollTimer();
|
||||
if (next < 0 || next >= specs.length) {
|
||||
releasePromptDialog();
|
||||
setCurrent(-1);
|
||||
return;
|
||||
}
|
||||
if (specs[next].selector === TARGET.promptDialog) {
|
||||
setPromptDialog(true, true);
|
||||
const startedAt = Date.now();
|
||||
pollTimerRef.current = window.setInterval(() => {
|
||||
if (document.querySelector(TARGET.promptDialog)) {
|
||||
clearPollTimer();
|
||||
setCurrent(next);
|
||||
} else if (Date.now() - startedAt > PROMPT_DIALOG_TIMEOUT_MS) {
|
||||
clearPollTimer();
|
||||
releasePromptDialog();
|
||||
goTo(next + 1 >= specs.length ? -1 : next + 1);
|
||||
}
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
releasePromptDialog();
|
||||
setCurrent(next);
|
||||
};
|
||||
|
||||
// 完成 / 跳过是用户显式结束引导 —— 只有这两种情况才记忆「已看过」;中途刷新或
|
||||
// 离开不记忆,下次进入简洁模式问答页还会再演示。
|
||||
const finish = useCallback(() => {
|
||||
clearPollTimer();
|
||||
releasePromptDialog();
|
||||
markCompactGuideDone();
|
||||
setCurrent(-1);
|
||||
}, [clearPollTimer, releasePromptDialog]);
|
||||
|
||||
if (!eligible) return null;
|
||||
|
||||
return (
|
||||
<Guide
|
||||
current={current}
|
||||
steps={stepsRef.current}
|
||||
onChange={goTo}
|
||||
onFinish={finish}
|
||||
onSkip={finish}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Wrench, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useWorkspaceCompactNavItems } from "./workspace-nav-chat-list";
|
||||
|
||||
const TOOLBOX_ICON_TONES = [
|
||||
"bg-blue-500/12 text-blue-600 dark:text-blue-400",
|
||||
"bg-violet-500/12 text-violet-600 dark:text-violet-400",
|
||||
"bg-emerald-500/12 text-emerald-600 dark:text-emerald-400",
|
||||
"bg-amber-500/14 text-amber-600 dark:text-amber-400",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 简洁模式右侧工具箱。
|
||||
*
|
||||
* 菜单来源与左侧简洁菜单、设置和更多完全一致,由管理员在菜单管理中把项目
|
||||
* 放到 ``toolbox`` 泳道。默认展开常显(不再藏进右侧按钮后面);点 X 或 Esc
|
||||
* 收起后右侧出现竖排按钮,可再次展开。
|
||||
*/
|
||||
export function CompactToolbox() {
|
||||
const { toolboxItems } = useWorkspaceCompactNavItems();
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(true);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (toolboxItems.length === 0) return null;
|
||||
|
||||
const closeDrawer = () => {
|
||||
setOpen(false);
|
||||
requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-label="打开工具箱"
|
||||
aria-controls="compact-toolbox-panel"
|
||||
aria-expanded={open}
|
||||
title="打开工具箱"
|
||||
onClick={() => setOpen(true)}
|
||||
className={cn(
|
||||
"fixed right-0 top-1/2 z-[80] flex size-16 -translate-y-1/2 flex-col items-center justify-center gap-1 rounded-l-2xl border border-r-0 border-primary/25 bg-primary text-primary-foreground shadow-xl transition-[transform,opacity,background-color] duration-200 hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
open && "pointer-events-none translate-x-full opacity-0",
|
||||
)}
|
||||
>
|
||||
<Wrench className="size-6 shrink-0" />
|
||||
<span className="text-xs font-semibold leading-4">工具箱</span>
|
||||
</button>
|
||||
|
||||
<aside
|
||||
id="compact-toolbox-panel"
|
||||
aria-label="工具箱"
|
||||
aria-hidden={!open}
|
||||
inert={!open}
|
||||
className={cn(
|
||||
"fixed right-0 top-1/2 z-[100] flex max-h-[calc(100vh-32px)] w-32 max-w-[calc(100vw-48px)] -translate-y-1/2 flex-col overflow-hidden rounded-l-2xl bg-background text-foreground shadow-2xl transition-transform duration-300 ease-out",
|
||||
open
|
||||
? "pointer-events-auto translate-x-0"
|
||||
: "pointer-events-none translate-x-full",
|
||||
)}
|
||||
>
|
||||
<header className="relative flex h-12 shrink-0 items-center justify-center px-2">
|
||||
<h2 className="text-sm font-semibold">工具箱</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="收起工具箱"
|
||||
title="收起"
|
||||
onClick={closeDrawer}
|
||||
className="absolute right-2 flex size-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<nav
|
||||
aria-label="工具箱菜单"
|
||||
className="flex min-h-0 flex-col items-center gap-2.5 overflow-y-auto bg-muted/20 p-2"
|
||||
>
|
||||
{toolboxItems.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
const active = item.match(pathname);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"group flex h-24 w-24 aspect-square shrink-0 flex-col items-center justify-center gap-1.5 rounded-xl p-2 text-center transition-[color,background-color,transform] hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
active
|
||||
? "bg-primary/12 text-primary ring-1 ring-inset ring-primary/25"
|
||||
: "bg-muted/55 text-card-foreground hover:bg-primary/8 hover:text-primary",
|
||||
)}
|
||||
onClick={() => navigate(item.path ?? "/page/workspace/chats/new")}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform group-hover:scale-105",
|
||||
active
|
||||
? "bg-primary text-primary-foreground"
|
||||
: TOOLBOX_ICON_TONES[index % TOOLBOX_ICON_TONES.length],
|
||||
)}
|
||||
>
|
||||
<Icon className="size-6" strokeWidth={1.8} />
|
||||
</span>
|
||||
<span
|
||||
className="line-clamp-2 w-full text-center text-xs font-medium leading-4"
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -42,13 +42,6 @@ import {
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* 简洁模式引导控制提示词管理弹窗的窗口事件。detail 为
|
||||
* `{ open: boolean; owner?: boolean }`:`owner` 表示弹窗由引导接管,期间 ESC /
|
||||
* 点击遮罩等关闭请求一律忽略,直到引导以 `owner: false` 收回控制权。
|
||||
*/
|
||||
export const PROMPT_DIALOG_GUIDE_EVENT = "compact-guide:prompt-dialog";
|
||||
|
||||
export function CustomPromptBar({
|
||||
onInsert,
|
||||
className,
|
||||
@@ -63,23 +56,10 @@ export function CustomPromptBar({
|
||||
(prompt) => !prompt.agent_id || prompt.agent_id === currentAgentId,
|
||||
);
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
const guideOwnsDialogRef = useRef(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canLeft, setCanLeft] = useState(false);
|
||||
const [canRight, setCanRight] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const detail = (
|
||||
event as CustomEvent<{ open?: boolean; owner?: boolean }>
|
||||
).detail;
|
||||
guideOwnsDialogRef.current = Boolean(detail?.owner);
|
||||
setManageOpen(Boolean(detail?.open));
|
||||
};
|
||||
window.addEventListener(PROMPT_DIALOG_GUIDE_EVENT, handler);
|
||||
return () => window.removeEventListener(PROMPT_DIALOG_GUIDE_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
const updateArrows = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
@@ -101,10 +81,7 @@ export function CustomPromptBar({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn("flex min-w-0 items-center gap-1", className)}
|
||||
data-compact-guide="prompt-bar"
|
||||
>
|
||||
<div className={cn("flex min-w-0 items-center gap-1", className)}>
|
||||
{prompts.length > 0 && (
|
||||
<>
|
||||
{overflowing && (
|
||||
@@ -171,14 +148,7 @@ export function CustomPromptBar({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ManagePromptsDialog
|
||||
open={manageOpen}
|
||||
onOpenChange={(next) => {
|
||||
// 引导演示期间接管弹窗:ESC / 点击遮罩不关闭,避免高亮目标消失导致引导报错。
|
||||
if (!next && guideOwnsDialogRef.current) return;
|
||||
setManageOpen(next);
|
||||
}}
|
||||
/>
|
||||
<ManagePromptsDialog open={manageOpen} onOpenChange={setManageOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -287,10 +257,7 @@ function ManagePromptsDialog({
|
||||
onOpenChange(next);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-h-[85vh] overflow-y-auto sm:max-w-xl"
|
||||
data-compact-guide="prompt-dialog"
|
||||
>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>管理常用提示词</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -12,11 +12,10 @@ export function FlipDisplay({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("relative min-w-0 overflow-hidden", className)}>
|
||||
<div className={cn("relative overflow-hidden", className)}>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={uniqueKey}
|
||||
className="min-w-0 max-w-full"
|
||||
initial={{ y: 8, opacity: 0 }}
|
||||
animate={{ y: 2, opacity: 1 }}
|
||||
exit={{ y: -8, opacity: 0 }}
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
* 共享的 WorkspaceNavChatList / FrequentAgentsList / RecentChatList 里塞会越来越难
|
||||
* 维护,故单独成一个组件,只在 `/page/workspace/chats*` 的全量布局下挂载,互不影响。
|
||||
*
|
||||
* 可挂在 workspace / strategy 两个普通问答路由下;导航目标由 RouteBaseProvider
|
||||
* 决定。三块结构(自上而下):
|
||||
* 三块结构(自上而下):
|
||||
* 1. 「新对话」胶囊按钮 + 收起整列的圆形按钮;
|
||||
* 2. 「常用智能体」—— 彩色图标卡(名称 + 两行简介,命中为选中态:主色描边 + 右侧主色条);
|
||||
* 3. 「最近的对话」—— 计数徽标 + 折叠、历史搜索框、会话列表(悬浮「更多」菜单:
|
||||
@@ -60,10 +59,6 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import {
|
||||
MyProjectThreadBadge,
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { useAgentsPaginated, type Agent } from "@/core/agents";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -79,10 +74,8 @@ import {
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||
import {
|
||||
isBootstrapThread,
|
||||
isHiddenFromRecentChats,
|
||||
isThreadFavorite,
|
||||
isThreadPinned,
|
||||
listPathOfThread,
|
||||
@@ -91,7 +84,6 @@ import {
|
||||
titleOfThread,
|
||||
} from "@/core/threads/utils";
|
||||
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
@@ -100,6 +92,11 @@ import { cn } from "@/lib/utils";
|
||||
import { agentColor } from "./agent-icon-color";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
|
||||
const NEW_CHAT_PATH = "/page/workspace/chats/new";
|
||||
/** 「更多」常用智能体跳转目标:智能体管理页。 */
|
||||
const MORE_AGENTS_HREF = "/page/workspace/agents";
|
||||
/** 「更多最近对话」跳转目标。 */
|
||||
const MORE_CHATS_HREF = "/page/workspace/chats";
|
||||
/** 常用智能体最多展示个数。 */
|
||||
const MAX_FREQUENT = 4;
|
||||
|
||||
@@ -144,7 +141,6 @@ function SectionLabel({
|
||||
|
||||
export function GeneralQaSidebar() {
|
||||
const { t } = useI18n();
|
||||
const routeBase = useRouteBase();
|
||||
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
@@ -191,14 +187,9 @@ export function GeneralQaSidebar() {
|
||||
const searchParams = useSearchParams();
|
||||
const activeAgentId = searchParams.get("agent");
|
||||
const currentSearch = searchParams.toString();
|
||||
const chatsBasePath = `${routeBase}/chats`;
|
||||
const newChatPath = `${chatsBasePath}/new`;
|
||||
const newChatHref = buildFullChatNavigationPath(newChatPath, currentSearch);
|
||||
const moreAgentsHref = buildFullChatNavigationPath(
|
||||
`${routeBase}/agents`,
|
||||
currentSearch,
|
||||
);
|
||||
const moreChatsHref = buildFullChatNavigationPath(chatsBasePath, currentSearch);
|
||||
const newChatHref = buildFullChatNavigationPath(NEW_CHAT_PATH, currentSearch);
|
||||
const moreAgentsHref = buildFullChatNavigationPath(MORE_AGENTS_HREF, currentSearch);
|
||||
const moreChatsHref = buildFullChatNavigationPath(MORE_CHATS_HREF, currentSearch);
|
||||
|
||||
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
|
||||
// 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。
|
||||
@@ -207,18 +198,19 @@ export function GeneralQaSidebar() {
|
||||
(agentId: string) => {
|
||||
navigate(
|
||||
buildFullChatNavigationPath(
|
||||
routeBase === "/page/workspace"
|
||||
? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
|
||||
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
|
||||
`${NEW_CHAT_PATH}?agent=${encodeURIComponent(agentId)}`,
|
||||
currentSearch,
|
||||
),
|
||||
);
|
||||
},
|
||||
[currentSearch, navigate, newChatPath, routeBase],
|
||||
[currentSearch, navigate],
|
||||
);
|
||||
|
||||
// ── 最近的对话取数 + 搜索 ──
|
||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } = useParams<{
|
||||
thread_id: string;
|
||||
agent_id?: string;
|
||||
}>();
|
||||
const { data: threads = [] } = useThreads();
|
||||
const { mutate: deleteThread } = useDeleteThread();
|
||||
const { mutate: renameThread } = useRenameThread();
|
||||
@@ -231,19 +223,17 @@ export function GeneralQaSidebar() {
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
|
||||
|
||||
const pathForThread = useCallback(
|
||||
(thread: AgentThread) =>
|
||||
isBootstrapThread(thread)
|
||||
? listPathOfThread(thread)
|
||||
: pathOfThread(thread, undefined, routeBase),
|
||||
[routeBase],
|
||||
);
|
||||
|
||||
const visibleThreads = useMemo(
|
||||
() =>
|
||||
// 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页);
|
||||
// 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
|
||||
sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
|
||||
// 仅排除调度 / 会商类系统会话。
|
||||
sortThreadsPinnedFirst(
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "roundtable",
|
||||
),
|
||||
),
|
||||
[threads],
|
||||
);
|
||||
|
||||
@@ -269,22 +259,21 @@ export function GeneralQaSidebar() {
|
||||
deleteThread({ threadId });
|
||||
if (threadId === threadIdFromPath) {
|
||||
const threadIndex = threads.findIndex((th) => th.thread_id === threadId);
|
||||
let nextThreadPath = newChatPath;
|
||||
let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath });
|
||||
if (threadIndex > -1) {
|
||||
if (threads[threadIndex + 1]) {
|
||||
nextThreadPath = pathForThread(threads[threadIndex + 1]!);
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
|
||||
} else if (threads[threadIndex - 1]) {
|
||||
nextThreadPath = pathForThread(threads[threadIndex - 1]!);
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
|
||||
}
|
||||
}
|
||||
void router.push(buildFullChatNavigationPath(nextThreadPath, currentSearch));
|
||||
}
|
||||
},
|
||||
[
|
||||
agentIdFromPath,
|
||||
currentSearch,
|
||||
deleteThread,
|
||||
newChatPath,
|
||||
pathForThread,
|
||||
router,
|
||||
threadIdFromPath,
|
||||
threads,
|
||||
@@ -483,12 +472,11 @@ export function GeneralQaSidebar() {
|
||||
) : (
|
||||
filteredThreads.map((thread) => {
|
||||
const href = buildFullChatNavigationPath(
|
||||
pathForThread(thread),
|
||||
listPathOfThread(thread),
|
||||
currentSearch,
|
||||
);
|
||||
const isActive = href === pathname;
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const projectType = myProjectConversationTypeOfThread(thread);
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
return (
|
||||
@@ -513,8 +501,6 @@ export function GeneralQaSidebar() {
|
||||
)}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : projectType ? (
|
||||
<MyProjectThreadIcon type={projectType} />
|
||||
) : (
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
@@ -526,15 +512,9 @@ export function GeneralQaSidebar() {
|
||||
{pinned && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
<span className="truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
{projectType && (
|
||||
<MyProjectThreadBadge
|
||||
className="mr-4"
|
||||
type={projectType}
|
||||
/>
|
||||
)}
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
)}
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
BookMarkedIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
FolderTreeIcon,
|
||||
GaugeIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
PaperclipIcon,
|
||||
@@ -26,7 +24,6 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
@@ -64,12 +61,10 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { Model } from "@/core/models/types";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { AgentThreadContext } from "@/core/threads";
|
||||
import { isTurboModeVisible } from "@/core/threads/use-direct-chat";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -100,7 +95,7 @@ import {
|
||||
lockThreadRagMode,
|
||||
useLocalSettings,
|
||||
} from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
|
||||
|
||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||
import { useThread } from "./messages/context";
|
||||
@@ -139,13 +134,9 @@ export function InputBox({
|
||||
onStop,
|
||||
promptPrefix,
|
||||
agentSelector,
|
||||
knowledgeSelector,
|
||||
extraModels = [],
|
||||
assistantId,
|
||||
hideAdminRecommendedQuestions,
|
||||
compact,
|
||||
hideContextTools,
|
||||
hideMemoryTool = true,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||
assistantId?: string | null;
|
||||
@@ -153,14 +144,6 @@ export function InputBox({
|
||||
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
|
||||
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
|
||||
compact?: boolean;
|
||||
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
|
||||
* 受控场景用;文件/模型等其他按钮不受影响)。 */
|
||||
hideContextTools?: boolean;
|
||||
/**
|
||||
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
|
||||
* 写作和参考文献开关不受影响。
|
||||
*/
|
||||
hideMemoryTool?: boolean;
|
||||
disabled?: boolean;
|
||||
context: Omit<
|
||||
AgentThreadContext,
|
||||
@@ -211,34 +194,15 @@ export function InputBox({
|
||||
selectedAgentId: string | null;
|
||||
onAgentChange: (agentId: string | null) => void;
|
||||
};
|
||||
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
|
||||
knowledgeSelector?: ReactNode;
|
||||
/** Page-scoped model choices implemented outside the DeerFlow model API. */
|
||||
extraModels?: Model[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [localSettings, setLocalSettings] = useLocalSettings();
|
||||
const searchParams = useSearchParams();
|
||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||
const { models } = useModels();
|
||||
const singleRoundRagActive =
|
||||
context.execution_profile === "single_round_rag";
|
||||
const multiRoundRagActive =
|
||||
context.execution_profile === "multi_round_rag";
|
||||
const turboModeVisible = isTurboModeVisible();
|
||||
const directChatActive =
|
||||
turboModeVisible && context.execution_profile === "direct";
|
||||
const frontendRagActive = singleRoundRagActive || multiRoundRagActive;
|
||||
const bypassAgentActive = frontendRagActive || directChatActive;
|
||||
const modelOptions = useMemo(() => {
|
||||
return singleRoundRagActive ? extraModels : models;
|
||||
}, [extraModels, models, singleRoundRagActive]);
|
||||
const { thread, isMock } = useThread();
|
||||
const { textInput } = usePromptInputController();
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
|
||||
const [followups, setFollowups] = useState<string[]>([]);
|
||||
const [followupsHidden, setFollowupsHidden] = useState(false);
|
||||
@@ -259,51 +223,16 @@ export function InputBox({
|
||||
recommendedQuestions.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
// Frontend-only execution profiles are not model options.
|
||||
// Do not let the normal model fallback clear them on every render.
|
||||
if (!turboModeVisible && context.execution_profile === "direct") {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
execution_profile: undefined,
|
||||
});
|
||||
if (models.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (multiRoundRagActive) {
|
||||
return;
|
||||
}
|
||||
if (directChatActive) {
|
||||
if (models.length === 0) {
|
||||
return;
|
||||
}
|
||||
const currentModel = models.find((m) => m.name === context.model_name);
|
||||
const fallbackModel = currentModel ?? models[0]!;
|
||||
if (
|
||||
context.model_name === fallbackModel.name &&
|
||||
context.execution_profile === "direct"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: fallbackModel.name,
|
||||
execution_profile: "direct",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (modelOptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
const currentModel = modelOptions.find((m) => m.name === context.model_name);
|
||||
const fallbackModel = currentModel ?? modelOptions[0]!;
|
||||
const currentModel = models.find((m) => m.name === context.model_name);
|
||||
const fallbackModel = currentModel ?? models[0]!;
|
||||
const supportsThinking = fallbackModel.supports_thinking ?? false;
|
||||
const nextModelName = fallbackModel.name;
|
||||
const nextMode = getResolvedMode(context.mode, supportsThinking);
|
||||
|
||||
if (
|
||||
context.model_name === nextModelName &&
|
||||
context.mode === nextMode &&
|
||||
context.execution_profile === fallbackModel.execution_profile
|
||||
) {
|
||||
if (context.model_name === nextModelName && context.mode === nextMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -311,19 +240,17 @@ export function InputBox({
|
||||
...context,
|
||||
model_name: nextModelName,
|
||||
mode: nextMode,
|
||||
execution_profile: fallbackModel.execution_profile,
|
||||
});
|
||||
}, [context, directChatActive, modelOptions, models, multiRoundRagActive, onContextChange, turboModeVisible]);
|
||||
}, [context, models, onContextChange]);
|
||||
|
||||
const selectedModel = useMemo(() => {
|
||||
if (modelOptions.length === 0) {
|
||||
if (models.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return modelOptions.find((m) => m.name === context.model_name) ?? modelOptions[0];
|
||||
}, [context.model_name, modelOptions]);
|
||||
return models.find((m) => m.name === context.model_name) ?? models[0];
|
||||
}, [context.model_name, models]);
|
||||
|
||||
const resolvedModelName = selectedModel?.name;
|
||||
const selectedSingleRoundRag = singleRoundRagActive;
|
||||
|
||||
const supportThinking = useMemo(
|
||||
() => selectedModel?.supports_thinking ?? false,
|
||||
@@ -337,7 +264,7 @@ export function InputBox({
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
(model_name: string) => {
|
||||
const model = modelOptions.find((m) => m.name === model_name);
|
||||
const model = models.find((m) => m.name === model_name);
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
@@ -346,27 +273,17 @@ export function InputBox({
|
||||
model_name,
|
||||
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
||||
reasoning_effort: context.reasoning_effort,
|
||||
execution_profile:
|
||||
context.execution_profile === "direct"
|
||||
? "direct"
|
||||
: model.execution_profile,
|
||||
});
|
||||
setModelDialogOpen(false);
|
||||
},
|
||||
[onContextChange, context, modelOptions],
|
||||
[onContextChange, context, models],
|
||||
);
|
||||
|
||||
const handleModeSelect = useCallback(
|
||||
(mode: InputMode) => {
|
||||
const ordinaryModel =
|
||||
models.find((model) => model.name === context.model_name) ?? models[0];
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: ordinaryModel?.name ?? context.model_name,
|
||||
mode: getResolvedMode(
|
||||
mode,
|
||||
ordinaryModel?.supports_thinking ?? supportThinking,
|
||||
),
|
||||
mode: getResolvedMode(mode, supportThinking),
|
||||
reasoning_effort:
|
||||
mode === "ultra"
|
||||
? "high"
|
||||
@@ -375,48 +292,11 @@ export function InputBox({
|
||||
: mode === "thinking"
|
||||
? "low"
|
||||
: "minimal",
|
||||
execution_profile: undefined,
|
||||
});
|
||||
},
|
||||
[onContextChange, context, models, supportThinking],
|
||||
[onContextChange, context, supportThinking],
|
||||
);
|
||||
|
||||
const handleDirectChatSelect = useCallback(() => {
|
||||
const ordinaryModel =
|
||||
models.find((model) => model.name === context.model_name) ?? models[0];
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: ordinaryModel?.name ?? context.model_name,
|
||||
execution_profile: "direct",
|
||||
});
|
||||
}, [context, models, onContextChange]);
|
||||
|
||||
const handleSingleRoundRagSelect = useCallback(() => {
|
||||
const ragModel =
|
||||
extraModels.find((model) => model.name === context.model_name) ??
|
||||
extraModels[0];
|
||||
if (!ragModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: ragModel.name,
|
||||
mode: getResolvedMode(
|
||||
context.mode,
|
||||
ragModel.supports_thinking ?? false,
|
||||
),
|
||||
execution_profile: "single_round_rag",
|
||||
});
|
||||
}, [context, extraModels, onContextChange]);
|
||||
|
||||
const handleMultiRoundRagSelect = useCallback(() => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
execution_profile: "multi_round_rag",
|
||||
});
|
||||
}, [context, onContextChange]);
|
||||
|
||||
const handleReasoningEffortSelect = useCallback(
|
||||
(effort: "minimal" | "low" | "medium" | "high") => {
|
||||
onContextChange?.({
|
||||
@@ -434,14 +314,6 @@ export function InputBox({
|
||||
});
|
||||
}, [context, onContextChange]);
|
||||
|
||||
const artifactDirectoryModeEnabled =
|
||||
localSettings.display.artifactDirectoryModeEnabled ?? false;
|
||||
const handleArtifactDirectoryModeToggle = useCallback(() => {
|
||||
setLocalSettings("display", {
|
||||
artifactDirectoryModeEnabled: !artifactDirectoryModeEnabled,
|
||||
});
|
||||
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
||||
|
||||
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
||||
const handleMemoryInjectionToggle = useCallback(() => {
|
||||
onContextChange?.({
|
||||
@@ -493,7 +365,6 @@ export function InputBox({
|
||||
context.mode,
|
||||
selectedModel?.supports_thinking ?? false,
|
||||
),
|
||||
execution_profile: selectedModel?.execution_profile,
|
||||
});
|
||||
setTimeout(() => onSubmit?.(message), 0);
|
||||
return;
|
||||
@@ -509,7 +380,6 @@ export function InputBox({
|
||||
resolvedModelName,
|
||||
ragModeEnabled,
|
||||
selectedModel?.supports_thinking,
|
||||
selectedModel?.execution_profile,
|
||||
status,
|
||||
threadId,
|
||||
],
|
||||
@@ -569,7 +439,6 @@ export function InputBox({
|
||||
const showFollowups =
|
||||
!disabled &&
|
||||
!isNewThread &&
|
||||
!selectedSingleRoundRag &&
|
||||
!followupsHidden &&
|
||||
(followupsLoading || followups.length > 0);
|
||||
|
||||
@@ -595,19 +464,19 @@ export function InputBox({
|
||||
return;
|
||||
}
|
||||
|
||||
if (disabled || isMock || selectedSingleRoundRag) {
|
||||
if (disabled || isMock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleMessages = thread.messages.filter((m) => !isHiddenFromUIMessage(m));
|
||||
const lastAi = [...visibleMessages].reverse().find((m) => m.type === "ai");
|
||||
const visibleMessages = thread.messages.filter((m) => !isHiddenFromUIMessage(m));
|
||||
const lastAi = [...visibleMessages].reverse().find((m) => m.type === "ai");
|
||||
const lastAiId = lastAi?.id ?? null;
|
||||
if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) {
|
||||
return;
|
||||
}
|
||||
lastGeneratedForAiIdRef.current = lastAiId;
|
||||
|
||||
const recent = visibleMessages
|
||||
const recent = visibleMessages
|
||||
.filter((m) => m.type === "human" || m.type === "ai")
|
||||
.map((m) => {
|
||||
const role = m.type === "human" ? "user" : "assistant";
|
||||
@@ -657,7 +526,7 @@ export function InputBox({
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
|
||||
}, [context.model_name, disabled, isMock, status, thread.messages, threadId]);
|
||||
|
||||
return (
|
||||
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
||||
@@ -744,7 +613,7 @@ export function InputBox({
|
||||
: "*:data-[slot='input-group']:rounded-3xl",
|
||||
// 紧凑落地态(CM 参考图):白卡 + 细边 + 轻阴影 + 主色聚焦环(无旋转彩边)
|
||||
compact && isNewThread
|
||||
? "rounded-2xl border border-gray-200/70 bg-white overflow-hidden shadow-[0_4.5px_22px_rgba(0,0,0,0.025)] transition-all duration-300 focus-within:border-[rgb(var(--legacy-primary-rgb)/35%)] focus-within:ring-4 focus-within:ring-[rgb(var(--legacy-primary-rgb)/5%)] focus-within:shadow-[0_10px_35px_rgb(var(--legacy-primary-rgb)/4%)] dark:border-slate-700 dark:bg-slate-800"
|
||||
? "rounded-2xl border border-gray-200/70 bg-white overflow-hidden shadow-[0_4.5px_22px_rgba(0,0,0,0.025)] transition-all duration-300 focus-within:border-[rgb(var(--legacy-primary-rgb)/35%)] focus-within:ring-4 focus-within:ring-[rgb(var(--legacy-primary-rgb)/5%)] focus-within:shadow-[0_10px_35px_rgb(var(--legacy-primary-rgb)/4%)] dark:border-slate-700 dark:bg-slate-800"
|
||||
: // landing 态:透明,让外层 gemini-gradient-border 提供白卡 + 旋转描边
|
||||
// 对话态:常规白卡 + 中性边框 + 轻阴影
|
||||
isNewThread
|
||||
@@ -794,71 +663,40 @@ export function InputBox({
|
||||
/>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu> */}
|
||||
{!bypassAgentActive && (
|
||||
<AddAttachmentsButton
|
||||
className={cn(
|
||||
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
compact &&
|
||||
"gap-1.5! rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!bypassAgentActive && !hideContextTools && <Tooltip
|
||||
<AddAttachmentsButton
|
||||
className={cn(
|
||||
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
compact &&
|
||||
"gap-1.5! rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
)}
|
||||
/>
|
||||
<Tooltip
|
||||
content={
|
||||
context.writing_mode
|
||||
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
|
||||
: "开启写作模式:对话照常进行,交付时由深度研究管线撰写报告并保存为 Markdown 进入画布"
|
||||
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
|
||||
: "开启写作模式:最终输出保存为 Markdown 文件并进入画布"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
context.writing_mode
|
||||
? "border border-amber-300 bg-amber-50 text-amber-700 ring-1 ring-amber-300/35 hover:bg-amber-100 dark:border-amber-300/70 dark:bg-amber-400/20 dark:text-amber-100 dark:ring-amber-300/35 dark:shadow-[0_0_0_1px_rgba(251,191,36,0.18),0_0_18px_rgba(251,191,36,0.18)] dark:hover:bg-amber-400/25"
|
||||
: compact
|
||||
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
onClick={handleWritingModeToggle}
|
||||
>
|
||||
{context.writing_mode && (
|
||||
<CheckIcon className="size-3 text-amber-700 dark:text-amber-100" />
|
||||
)}
|
||||
<FileTextIcon className="size-3" />
|
||||
<span className="text-xs font-normal">写作</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!bypassAgentActive && <Tooltip
|
||||
content={
|
||||
artifactDirectoryModeEnabled
|
||||
? "文件模式已开启:右侧会显示当前对话生成的全部文件目录"
|
||||
: "开启文件模式:在右侧目录中浏览当前对话生成的全部文件"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
aria-pressed={artifactDirectoryModeEnabled}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
artifactDirectoryModeEnabled
|
||||
? "border border-sky-300 bg-sky-50 text-sky-700 hover:bg-sky-100 dark:border-sky-600 dark:bg-sky-950/45 dark:text-sky-200"
|
||||
: compact
|
||||
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
onClick={handleArtifactDirectoryModeToggle}
|
||||
>
|
||||
{artifactDirectoryModeEnabled && (
|
||||
<CheckIcon className="size-3 text-sky-700 dark:text-sky-200" />
|
||||
)}
|
||||
<FolderTreeIcon className="size-3" />
|
||||
<span className="text-xs font-normal">文件</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!bypassAgentActive && !hideContextTools && !hideMemoryTool && <Tooltip
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
context.writing_mode
|
||||
? "border border-amber-300 bg-amber-50 text-amber-700 ring-1 ring-amber-300/35 hover:bg-amber-100 dark:border-amber-300/70 dark:bg-amber-400/20 dark:text-amber-100 dark:ring-amber-300/35 dark:shadow-[0_0_0_1px_rgba(251,191,36,0.18),0_0_18px_rgba(251,191,36,0.18)] dark:hover:bg-amber-400/25"
|
||||
: compact
|
||||
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
onClick={handleWritingModeToggle}
|
||||
>
|
||||
{context.writing_mode && (
|
||||
<CheckIcon className="size-3 text-amber-700 dark:text-amber-100" />
|
||||
)}
|
||||
<FileTextIcon className="size-3" />
|
||||
<span className="text-xs font-normal">写作</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
memoryInjectionEnabled
|
||||
? "记忆注入已开启:对话前会注入历史记忆"
|
||||
@@ -867,24 +705,24 @@ export function InputBox({
|
||||
>
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
memoryInjectionEnabled
|
||||
? "border border-blue-300 bg-blue-50 text-blue-700 ring-1 ring-blue-300/35 hover:bg-blue-100 dark:border-cyan-300/70 dark:bg-cyan-400/20 dark:text-cyan-50 dark:ring-cyan-300/35 dark:shadow-[0_0_0_1px_rgba(34,211,238,0.18),0_0_18px_rgba(34,211,238,0.18)] dark:hover:bg-cyan-400/25"
|
||||
: compact
|
||||
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
onClick={handleMemoryInjectionToggle}
|
||||
>
|
||||
{memoryInjectionEnabled && (
|
||||
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" />
|
||||
)}
|
||||
<BrainCircuitIcon className="size-3" />
|
||||
<span className="text-xs font-normal">记忆</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{referenceModeEntryEnabled && !bypassAgentActive && !hideContextTools && <Tooltip
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
memoryInjectionEnabled
|
||||
? "border border-blue-300 bg-blue-50 text-blue-700 ring-1 ring-blue-300/35 hover:bg-blue-100 dark:border-cyan-300/70 dark:bg-cyan-400/20 dark:text-cyan-50 dark:ring-cyan-300/35 dark:shadow-[0_0_0_1px_rgba(34,211,238,0.18),0_0_18px_rgba(34,211,238,0.18)] dark:hover:bg-cyan-400/25"
|
||||
: compact
|
||||
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
onClick={handleMemoryInjectionToggle}
|
||||
>
|
||||
{memoryInjectionEnabled && (
|
||||
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" />
|
||||
)}
|
||||
<BrainCircuitIcon className="size-3" />
|
||||
<span className="text-xs font-normal">记忆</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
ragModeEnabled
|
||||
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
||||
@@ -913,8 +751,8 @@ export function InputBox({
|
||||
<BookMarkedIcon className="size-3" />
|
||||
<span className="text-xs font-normal">参考文献</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!bypassAgentActive && <Tooltip
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
contextCompressionEnabled
|
||||
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
|
||||
@@ -942,7 +780,7 @@ export function InputBox({
|
||||
<ShrinkIcon className="size-3" />
|
||||
<span className="text-xs font-normal">上下文压缩</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
</Tooltip>
|
||||
<PromptInputActionMenu>
|
||||
<ModeHoverGuide
|
||||
mode={
|
||||
@@ -953,26 +791,13 @@ export function InputBox({
|
||||
? context.mode
|
||||
: "flash"
|
||||
}
|
||||
tooltipContent={
|
||||
directChatActive
|
||||
? `${t.inputBox.turboMode}:${t.inputBox.turboModeDescription}`
|
||||
: singleRoundRagActive
|
||||
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
|
||||
: multiRoundRagActive
|
||||
? "多轮 RAG 问答:多次检索并展示检索过程与参考资料"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<PromptInputActionMenuTrigger
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
|
||||
compact &&
|
||||
(directChatActive
|
||||
? "border-sky-200 bg-sky-50/60 text-sky-700 hover:bg-sky-100 dark:border-sky-700 dark:bg-sky-950/40 dark:text-sky-200"
|
||||
: frontendRagActive
|
||||
? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||
: context.mode === "thinking"
|
||||
(context.mode === "thinking"
|
||||
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
|
||||
: context.mode === "pro"
|
||||
? "border-violet-200 bg-violet-50/60 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200"
|
||||
@@ -980,37 +805,26 @@ export function InputBox({
|
||||
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
|
||||
: "border-transparent bg-muted/40 hover:bg-muted hover:text-foreground"),
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{directChatActive ? (
|
||||
<GaugeIcon className="size-3" />
|
||||
) : frontendRagActive ? (
|
||||
<BookMarkedIcon className="size-3" />
|
||||
) : context.mode === "flash" ? (
|
||||
<ZapIcon className="size-3" />
|
||||
) : context.mode === "thinking" ? (
|
||||
>
|
||||
<div>
|
||||
{context.mode === "flash" && <ZapIcon className="size-3" />}
|
||||
{context.mode === "thinking" && (
|
||||
<LightbulbIcon className="size-3" />
|
||||
) : context.mode === "pro" ? (
|
||||
)}
|
||||
{context.mode === "pro" && (
|
||||
<GraduationCapIcon className="size-3" />
|
||||
) : context.mode === "ultra" ? (
|
||||
)}
|
||||
{context.mode === "ultra" && (
|
||||
<RocketIcon className="size-3 text-[#dabb5e]" />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs font-normal",
|
||||
context.mode === "ultra" && !directChatActive && !frontendRagActive
|
||||
? "golden-text"
|
||||
: "",
|
||||
context.mode === "ultra" ? "golden-text" : "",
|
||||
)}
|
||||
>
|
||||
{directChatActive
|
||||
? t.inputBox.turboMode
|
||||
: frontendRagActive
|
||||
? multiRoundRagActive
|
||||
? "多轮 RAG"
|
||||
: "单轮 RAG"
|
||||
: (context.mode === "flash" && t.inputBox.flashMode) ||
|
||||
{(context.mode === "flash" && t.inputBox.flashMode) ||
|
||||
(context.mode === "thinking" &&
|
||||
t.inputBox.reasoningMode) ||
|
||||
(context.mode === "pro" && t.inputBox.proMode) ||
|
||||
@@ -1024,39 +838,9 @@ export function InputBox({
|
||||
{t.inputBox.mode}
|
||||
</DropdownMenuLabel>
|
||||
<PromptInputActionMenu>
|
||||
{turboModeVisible && (
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
directChatActive
|
||||
? "bg-sky-50 text-sky-800 hover:bg-sky-100 focus:bg-sky-100 dark:bg-sky-950/40 dark:text-sky-200 dark:hover:bg-sky-950/60 dark:focus:bg-sky-950/60"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={handleDirectChatSelect}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<GaugeIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
directChatActive && "text-sky-700 dark:text-sky-200",
|
||||
)}
|
||||
/>
|
||||
{t.inputBox.turboMode}
|
||||
</div>
|
||||
<div className="pl-7 text-xs">
|
||||
{t.inputBox.turboModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{directChatActive ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
)}
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
!bypassAgentActive && context.mode === "flash"
|
||||
context.mode === "flash"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
@@ -1067,7 +851,7 @@ export function InputBox({
|
||||
<ZapIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
!bypassAgentActive && context.mode === "flash" &&
|
||||
context.mode === "flash" &&
|
||||
"text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
@@ -1077,7 +861,7 @@ export function InputBox({
|
||||
{t.inputBox.flashModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{!bypassAgentActive && context.mode === "flash" ? (
|
||||
{context.mode === "flash" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
@@ -1086,7 +870,7 @@ export function InputBox({
|
||||
{supportThinking && (
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
!bypassAgentActive && context.mode === "thinking"
|
||||
context.mode === "thinking"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
@@ -1097,7 +881,7 @@ export function InputBox({
|
||||
<LightbulbIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
!bypassAgentActive && context.mode === "thinking" &&
|
||||
context.mode === "thinking" &&
|
||||
"text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
@@ -1107,7 +891,7 @@ export function InputBox({
|
||||
{t.inputBox.reasoningModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{!bypassAgentActive && context.mode === "thinking" ? (
|
||||
{context.mode === "thinking" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
@@ -1116,7 +900,7 @@ export function InputBox({
|
||||
)}
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
!bypassAgentActive && context.mode === "pro"
|
||||
context.mode === "pro"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
@@ -1127,7 +911,7 @@ export function InputBox({
|
||||
<GraduationCapIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
!bypassAgentActive && context.mode === "pro" &&
|
||||
context.mode === "pro" &&
|
||||
"text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
@@ -1137,7 +921,7 @@ export function InputBox({
|
||||
{t.inputBox.proModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{!bypassAgentActive && context.mode === "pro" ? (
|
||||
{context.mode === "pro" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
@@ -1145,7 +929,7 @@ export function InputBox({
|
||||
</PromptInputActionMenuItem>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
!bypassAgentActive && context.mode === "ultra"
|
||||
context.mode === "ultra"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
@@ -1156,16 +940,12 @@ export function InputBox({
|
||||
<RocketIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
!bypassAgentActive &&
|
||||
context.mode === "ultra" &&
|
||||
"text-[#dabb5e]",
|
||||
context.mode === "ultra" && "text-[#dabb5e]",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
!bypassAgentActive &&
|
||||
context.mode === "ultra" &&
|
||||
"golden-text",
|
||||
context.mode === "ultra" && "golden-text",
|
||||
)}
|
||||
>
|
||||
{t.inputBox.ultraMode}
|
||||
@@ -1175,68 +955,7 @@ export function InputBox({
|
||||
{t.inputBox.ultraModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{!bypassAgentActive && context.mode === "ultra" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
{extraModels.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="my-2" />
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
selectedSingleRoundRag
|
||||
? "bg-emerald-50 text-emerald-800 hover:bg-emerald-100 focus:bg-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-200 dark:hover:bg-emerald-950/60 dark:focus:bg-emerald-950/60"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={handleSingleRoundRagSelect}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<BookMarkedIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
selectedSingleRoundRag && "text-emerald-700 dark:text-emerald-200",
|
||||
)}
|
||||
/>
|
||||
单轮 RAG 问答
|
||||
</div>
|
||||
<div className="pl-7 text-xs">
|
||||
基于知识库检索生成答案,并展示思考内容与参考资料
|
||||
</div>
|
||||
</div>
|
||||
{selectedSingleRoundRag ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
</>
|
||||
)}
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
multiRoundRagActive
|
||||
? "bg-emerald-50 text-emerald-800 hover:bg-emerald-100 focus:bg-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-200 dark:hover:bg-emerald-950/60 dark:focus:bg-emerald-950/60"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={handleMultiRoundRagSelect}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<BookMarkedIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
multiRoundRagActive && "text-emerald-700 dark:text-emerald-200",
|
||||
)}
|
||||
/>
|
||||
多轮 RAG 问答
|
||||
</div>
|
||||
<div className="pl-7 text-xs">
|
||||
多次检索、展示过程与参考资料,适合复杂问题
|
||||
</div>
|
||||
</div>
|
||||
{multiRoundRagActive ? (
|
||||
{context.mode === "ultra" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
@@ -1246,7 +965,7 @@ export function InputBox({
|
||||
</DropdownMenuGroup>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
{!bypassAgentActive && supportReasoningEffort && context.mode !== "flash" && (
|
||||
{supportReasoningEffort && context.mode !== "flash" && (
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
|
||||
<div className="text-xs font-normal">
|
||||
@@ -1364,7 +1083,7 @@ export function InputBox({
|
||||
)}
|
||||
</PromptInputTools>
|
||||
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
||||
{!bypassAgentActive && agentSelector && (
|
||||
{agentSelector && (
|
||||
<AgentSelector
|
||||
selectedAgentId={agentSelector.selectedAgentId}
|
||||
onAgentChange={agentSelector.onAgentChange}
|
||||
@@ -1372,8 +1091,7 @@ export function InputBox({
|
||||
compact={compact}
|
||||
/>
|
||||
)}
|
||||
{!bypassAgentActive && knowledgeSelector}
|
||||
{!bypassAgentActive && promptPrefix && (
|
||||
{promptPrefix && (
|
||||
<Tooltip
|
||||
content={
|
||||
promptPrefix.on
|
||||
@@ -1401,7 +1119,7 @@ export function InputBox({
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!multiRoundRagActive && <ModelSelector
|
||||
<ModelSelector
|
||||
open={modelDialogOpen}
|
||||
onOpenChange={setModelDialogOpen}
|
||||
>
|
||||
@@ -1428,7 +1146,7 @@ export function InputBox({
|
||||
<ModelSelectorContent>
|
||||
<ModelSelectorInput placeholder={t.inputBox.searchModels} />
|
||||
<ModelSelectorList>
|
||||
{modelOptions.map((m) => (
|
||||
{models.map((m) => (
|
||||
<ModelSelectorItem
|
||||
key={m.name}
|
||||
value={m.name}
|
||||
@@ -1437,9 +1155,7 @@ export function InputBox({
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<ModelSelectorName>{m.display_name}</ModelSelectorName>
|
||||
<span className="text-muted-foreground truncate text-[10px]">
|
||||
{selectedSingleRoundRag
|
||||
? `单轮知识库问答 · ${m.name}`
|
||||
: m.model}
|
||||
{m.model}
|
||||
</span>
|
||||
</div>
|
||||
{m.name === context.model_name ? (
|
||||
@@ -1451,12 +1167,12 @@ export function InputBox({
|
||||
))}
|
||||
</ModelSelectorList>
|
||||
</ModelSelectorContent>
|
||||
</ModelSelector>}
|
||||
</ModelSelector>
|
||||
<PromptInputSubmit
|
||||
className={cn(
|
||||
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
|
||||
compact &&
|
||||
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted",
|
||||
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted",
|
||||
)}
|
||||
disabled={disabled}
|
||||
variant="outline"
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BookOpen, Database } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||
import {
|
||||
loadKnowledgeBaseProductVersion,
|
||||
loadOrdinaryKnowledgeLanding,
|
||||
saveOrdinaryKnowledgeLanding,
|
||||
type OrdinaryKnowledgeLanding,
|
||||
type KnowledgeBaseProductVersion,
|
||||
updateKnowledgeBaseProductVersion,
|
||||
} from "@/core/system-settings/api";
|
||||
|
||||
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
|
||||
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
|
||||
|
||||
export function useOrdinaryKnowledgeLanding() {
|
||||
return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 });
|
||||
}
|
||||
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
|
||||
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
|
||||
|
||||
export function useKnowledgeBaseProductVersion() {
|
||||
return useQuery({ queryKey: KNOWLEDGE_PRODUCT_KEY, queryFn: loadKnowledgeBaseProductVersion, staleTime: 60_000 });
|
||||
}
|
||||
|
||||
export function KnowledgeProductNav() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const client = useQueryClient();
|
||||
const version = useKnowledgeBaseProductVersion();
|
||||
const landing = useOrdinaryKnowledgeLanding();
|
||||
const saveLanding = useMutation({
|
||||
mutationFn: saveOrdinaryKnowledgeLanding,
|
||||
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const save = useMutation({
|
||||
mutationFn: updateKnowledgeBaseProductVersion,
|
||||
onSuccess: (value) => {
|
||||
client.setQueryData(KNOWLEDGE_PRODUCT_KEY, value);
|
||||
toast.success(`默认知识库体验已切换为 ${value}`);
|
||||
},
|
||||
});
|
||||
const assistant = location.pathname.includes("/knowledge/assistant");
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
|
||||
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||
<BookOpen />普通知识库
|
||||
</Button>
|
||||
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
||||
<Database />助手知识库
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
普通知识库默认页面
|
||||
<Select disabled={landing.isLoading || saveLanding.isPending} value={landing.data ?? "wiki"} onValueChange={(value) => saveLanding.mutate(value as OrdinaryKnowledgeLanding)}>
|
||||
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
|
||||
</Select>
|
||||
默认产品版本
|
||||
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
|
||||
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="v1">v1 普通知识库</SelectItem><SelectItem value="v2">v2 助手知识库</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,536 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Check,
|
||||
Database,
|
||||
Folder,
|
||||
Globe2,
|
||||
Loader2,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type AssistantKnowledgeBase,
|
||||
useAssistantKnowledgeBases,
|
||||
} from "@/strategy-components/api/assistant-knowledge";
|
||||
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 AssistantSpaceIcon({ item }: { item: AssistantKnowledgeBase }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-md",
|
||||
item.is_global ? "bg-amber-500/10 text-amber-600 dark:text-amber-400" : "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
)}
|
||||
>
|
||||
<Database className="size-3.5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionContent({
|
||||
rows,
|
||||
assistantRows,
|
||||
value,
|
||||
assistantValue,
|
||||
onChange,
|
||||
onAssistantChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
loading,
|
||||
error,
|
||||
source,
|
||||
variant,
|
||||
readOnly,
|
||||
}: {
|
||||
rows: LlmWikiKnowledgeBase[];
|
||||
assistantRows: AssistantKnowledgeBase[];
|
||||
value: string[];
|
||||
assistantValue: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
onAssistantChange: (value: string[]) => void;
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
loading: boolean;
|
||||
error: unknown;
|
||||
source: SelectorSource;
|
||||
variant: SelectorVariant;
|
||||
readOnly: boolean;
|
||||
}) {
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
const selectedAssistant = useMemo(() => new Set(assistantValue), [assistantValue]);
|
||||
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 filteredAssistant = useMemo(() => {
|
||||
const query = search.trim().toLocaleLowerCase();
|
||||
if (!query) return assistantRows;
|
||||
return assistantRows.filter((item) =>
|
||||
`${item.name} ${item.description ?? ""}`.toLocaleLowerCase().includes(query),
|
||||
);
|
||||
}, [assistantRows, search]);
|
||||
const groups = useMemo(() => groupRows(filtered), [filtered]);
|
||||
const toggle = (id: string) => {
|
||||
if (readOnly) return;
|
||||
onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]);
|
||||
};
|
||||
const toggleAssistant = (id: string) => {
|
||||
if (readOnly) return;
|
||||
onAssistantChange(
|
||||
selectedAssistant.has(id)
|
||||
? assistantValue.filter((item) => item !== id)
|
||||
: [...assistantValue, id],
|
||||
);
|
||||
};
|
||||
const selectedCount = value.length + assistantValue.length;
|
||||
const hasRows = groups.length > 0 || filteredAssistant.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
) : !hasRows ? (
|
||||
<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)}
|
||||
disabled={readOnly}
|
||||
onCheckedChange={() => toggle(item.id)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
className={cn(
|
||||
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
|
||||
selected.has(item.id) && "bg-primary/8 focus:bg-primary/12",
|
||||
)}
|
||||
>
|
||||
{rowContent}
|
||||
</DropdownMenuCheckboxItem>
|
||||
) : (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={selected.has(item.id)}
|
||||
disabled={readOnly}
|
||||
onClick={() => toggle(item.id)}
|
||||
className={cn(
|
||||
"my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary",
|
||||
selected.has(item.id) && "bg-primary/8",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"flex size-4 shrink-0 items-center justify-center rounded border",
|
||||
selected.has(item.id) && "border-primary bg-primary text-primary-foreground",
|
||||
)}>
|
||||
{selected.has(item.id) && <Check className="size-3" />}
|
||||
</span>
|
||||
{rowContent}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{filteredAssistant.length > 0 && (
|
||||
<div>
|
||||
{(groups.length > 0) && (
|
||||
variant === "popover"
|
||||
? <DropdownMenuSeparator className="my-1.5" />
|
||||
: <div className="my-1.5 border-t" />
|
||||
)}
|
||||
{variant === "popover" ? (
|
||||
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
|
||||
<Database className="size-3" />助手知识库
|
||||
</DropdownMenuLabel>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
|
||||
<Database className="size-3" />助手知识库
|
||||
</div>
|
||||
)}
|
||||
{filteredAssistant.map((item) => {
|
||||
const rowContent = (
|
||||
<>
|
||||
<AssistantSpaceIcon item={item} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium">{item.name}</div>
|
||||
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
|
||||
Wiki · {item.page_count ?? 0} 页 · {item.entity_count ?? 0} 实体
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="h-4 px-1 text-[9px]">
|
||||
{item.is_global ? "总库" : "助手"}
|
||||
</Badge>
|
||||
</>
|
||||
);
|
||||
return variant === "popover" ? (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={`assistant:${item.id}`}
|
||||
checked={selectedAssistant.has(item.id)}
|
||||
disabled={readOnly}
|
||||
onCheckedChange={() => toggleAssistant(item.id)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
className={cn(
|
||||
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
|
||||
selectedAssistant.has(item.id) && "bg-primary/8 focus:bg-primary/12",
|
||||
)}
|
||||
>
|
||||
{rowContent}
|
||||
</DropdownMenuCheckboxItem>
|
||||
) : (
|
||||
<button
|
||||
key={`assistant:${item.id}`}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={selectedAssistant.has(item.id)}
|
||||
disabled={readOnly}
|
||||
onClick={() => toggleAssistant(item.id)}
|
||||
className={cn(
|
||||
"my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary",
|
||||
selectedAssistant.has(item.id) && "bg-primary/8",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"flex size-4 shrink-0 items-center justify-center rounded border",
|
||||
selectedAssistant.has(item.id) && "border-primary bg-primary text-primary-foreground",
|
||||
)}>
|
||||
{selectedAssistant.has(item.id) && <Check className="size-3" />}
|
||||
</span>
|
||||
{rowContent}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div 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">
|
||||
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
|
||||
</span>
|
||||
{readOnly ? (
|
||||
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => {
|
||||
onChange(filtered.map((item) => item.id));
|
||||
onAssistantChange(filteredAssistant.map((item) => item.id));
|
||||
}}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => {
|
||||
onChange([]);
|
||||
onAssistantChange([]);
|
||||
}}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LlmWikiKnowledgeSelector({
|
||||
value,
|
||||
assistantValue = [],
|
||||
onChange,
|
||||
onAssistantChange = () => undefined,
|
||||
source = "all",
|
||||
agentId,
|
||||
autoSelectAll = false,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
className,
|
||||
label = "知识空间",
|
||||
variant = "popover",
|
||||
}: {
|
||||
value: string[];
|
||||
assistantValue?: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
onAssistantChange?: (value: string[]) => void;
|
||||
source?: SelectorSource;
|
||||
agentId?: string | null;
|
||||
autoSelectAll?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Keep the selected knowledge spaces visible without allowing the thread scope to change. */
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
label?: string;
|
||||
variant?: SelectorVariant;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const weknoraEnabled = runtime.data?.provider === "weknora";
|
||||
const all = useLlmWikiKnowledgeBases(
|
||||
"all",
|
||||
weknoraEnabled,
|
||||
);
|
||||
const agent = useAgentLlmWikiKnowledgeBases(
|
||||
agentId,
|
||||
weknoraEnabled && source === "agent" && !!agentId,
|
||||
);
|
||||
const assistantBases = useAssistantKnowledgeBases();
|
||||
const rows = weknoraEnabled ? (all.data ?? []) : [];
|
||||
const assistantRows = assistantBases.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 = runtime.isLoading || (weknoraEnabled && all.isLoading) || assistantBases.isLoading || (source === "agent" && !!agentId && agent.isLoading);
|
||||
const error = (weknoraEnabled ? all.error ?? (source === "agent" ? agent.error : null) : null) ?? assistantBases.error;
|
||||
const availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]);
|
||||
const assistantAvailableIds = useMemo(
|
||||
() => new Set(assistantRows.map((item) => item.id)),
|
||||
[assistantRows],
|
||||
);
|
||||
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
|
||||
const initialized = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtime.isLoading && !weknoraEnabled) {
|
||||
if (value.length > 0) onChange([]);
|
||||
return;
|
||||
}
|
||||
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, runtime.isLoading, source, value, weknoraEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assistantBases.data) return;
|
||||
const valid = assistantValue.filter((id) => assistantAvailableIds.has(id));
|
||||
if (valid.length !== assistantValue.length || valid.some((id, index) => id !== assistantValue[index])) {
|
||||
onAssistantChange(valid);
|
||||
}
|
||||
}, [assistantAvailableIds, assistantBases.data, assistantValue, onAssistantChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setSearch("");
|
||||
}, [open]);
|
||||
|
||||
if ((runtime.isError || !weknoraEnabled) && !assistantBases.isLoading && assistantRows.length === 0) return null;
|
||||
|
||||
const content = (
|
||||
<SelectionContent
|
||||
rows={rows}
|
||||
assistantRows={assistantRows}
|
||||
value={value}
|
||||
assistantValue={assistantValue}
|
||||
onChange={onChange}
|
||||
onAssistantChange={onAssistantChange}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
loading={loading}
|
||||
error={error}
|
||||
source={source}
|
||||
variant={variant}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
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 + assistantValue.length} 个</Badge>
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedNames = value
|
||||
.map((id) => rows.find((item) => item.id === id)?.name)
|
||||
.filter((name): name is string => !!name);
|
||||
const selectedAssistantNames = assistantValue
|
||||
.map((id) => assistantRows.find((item) => item.id === id)?.name)
|
||||
.filter((name): name is string => !!name);
|
||||
const allSelectedNames = [...selectedNames, ...selectedAssistantNames];
|
||||
const selectedCount = value.length + assistantValue.length;
|
||||
const selectedLabel = allSelectedNames.length > 0
|
||||
? allSelectedNames.join("、")
|
||||
: selectedCount > 0
|
||||
? `已选 ${selectedCount} 个知识空间`
|
||||
: label;
|
||||
|
||||
return (
|
||||
<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",
|
||||
selectedCount > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
title={selectedCount > 0 ? selectedLabel : undefined}
|
||||
>
|
||||
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <Database className="size-3.5" />}
|
||||
<span className="truncate text-xs font-normal">{selectedLabel}</span>
|
||||
{selectedCount > 0 && (
|
||||
<span className="flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
|
||||
{selectedCount}
|
||||
</span>
|
||||
)}
|
||||
{selectedCount > 0 && selectedCount === rows.length + assistantRows.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;
|
||||
@@ -4,17 +4,11 @@ import { createContext, useContext } from "react";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
|
||||
export interface ThreadContextType {
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
isMock?: boolean;
|
||||
/**
|
||||
* True when the active conversation has selected LLMWiki knowledge bases.
|
||||
* Knowledge-base Q&A should always expose its source documents, independent
|
||||
* from the admin-controlled manual reference-mode button.
|
||||
*/
|
||||
knowledgeReferencesEnabled?: boolean;
|
||||
/**
|
||||
* When set, the conversation is rendered from a public read-only share.
|
||||
export interface ThreadContextType {
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
isMock?: boolean;
|
||||
/**
|
||||
* When set, the conversation is rendered from a public read-only share.
|
||||
* Artifact/image URLs then resolve to the unauthenticated
|
||||
* `/api/public/shares/{code}/files/...` endpoint instead of the
|
||||
* auth-gated thread artifact route.
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type DiffRow = {
|
||||
left: { text: string; kind: "same" | "removed" } | null;
|
||||
right: { text: string; kind: "same" | "added" } | null;
|
||||
};
|
||||
|
||||
function buildLineDiff(before: string, after: string): DiffRow[] {
|
||||
const left = before.split("\n");
|
||||
const right = after.split("\n");
|
||||
// A large quadratic matrix would make the dialog itself a performance risk.
|
||||
// Documents above this threshold still get an honest two-column comparison,
|
||||
// just without expensive alignment of every unchanged line.
|
||||
if (left.length * right.length > 1_000_000) {
|
||||
const rows: DiffRow[] = [];
|
||||
const length = Math.max(left.length, right.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const beforeLine = left[index];
|
||||
const afterLine = right[index];
|
||||
const same = beforeLine === afterLine;
|
||||
rows.push({
|
||||
left: beforeLine === undefined ? null : { text: beforeLine, kind: same ? "same" : "removed" },
|
||||
right: afterLine === undefined ? null : { text: afterLine, kind: same ? "same" : "added" },
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const table = Array.from({ length: left.length + 1 }, () => new Uint16Array(right.length + 1));
|
||||
for (let i = left.length - 1; i >= 0; i -= 1) {
|
||||
for (let j = right.length - 1; j >= 0; j -= 1) {
|
||||
table[i][j] = left[i] === right[j]
|
||||
? table[i + 1][j + 1] + 1
|
||||
: Math.max(table[i + 1][j], table[i][j + 1]);
|
||||
}
|
||||
}
|
||||
const rows: DiffRow[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < left.length || j < right.length) {
|
||||
if (i < left.length && j < right.length && left[i] === right[j]) {
|
||||
rows.push({ left: { text: left[i], kind: "same" }, right: { text: right[j], kind: "same" } });
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (j < right.length && (i === left.length || table[i][j + 1] >= table[i + 1][j])) {
|
||||
rows.push({ left: null, right: { text: right[j], kind: "added" } });
|
||||
j += 1;
|
||||
} else {
|
||||
rows.push({ left: { text: left[i], kind: "removed" }, right: null });
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function DiffCell({
|
||||
value,
|
||||
line,
|
||||
}: {
|
||||
value: DiffRow["left"] | DiffRow["right"];
|
||||
line: number | null;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-w-0 grid-cols-[3rem_minmax(0,1fr)] border-b font-mono text-xs leading-5",
|
||||
value?.kind === "removed" && "bg-red-50 text-red-950 dark:bg-red-950/30 dark:text-red-100",
|
||||
value?.kind === "added" && "bg-emerald-50 text-emerald-950 dark:bg-emerald-950/30 dark:text-emerald-100",
|
||||
!value && "bg-muted/30",
|
||||
)}
|
||||
>
|
||||
<span className="select-none border-r px-2 text-right text-muted-foreground">{line ?? ""}</span>
|
||||
<pre className="min-w-0 whitespace-pre-wrap break-words px-3">{value?.text ?? ""}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentRewriteDiffDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
fileName,
|
||||
original,
|
||||
rewritten,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
fileName: string;
|
||||
original: string;
|
||||
rewritten: string;
|
||||
}) {
|
||||
const rows = useMemo(() => buildLineDiff(original, rewritten), [original, rewritten]);
|
||||
let leftLine = 0;
|
||||
let rightLine = 0;
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[85vh] max-w-[calc(100vw-2rem)] flex-col p-0 sm:max-w-[min(94vw,1440px)]">
|
||||
<DialogHeader className="border-b px-6 py-4">
|
||||
<DialogTitle>改写前后对比 · {fileName}</DialogTitle>
|
||||
<DialogDescription>红色为删除或调整前内容,绿色为新增或改写后内容。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x overflow-auto" role="region" aria-label="Markdown 改写前后对比">
|
||||
<section aria-label="改写前原文">
|
||||
<div className="sticky top-0 z-10 border-b bg-background px-4 py-2 text-sm font-medium">改写前</div>
|
||||
{rows.map((row, index) => {
|
||||
const line = row.left ? ++leftLine : null;
|
||||
return <DiffCell key={`left-${index}`} value={row.left} line={line} />;
|
||||
})}
|
||||
</section>
|
||||
<section aria-label="改写后内容">
|
||||
<div className="sticky top-0 z-10 border-b bg-background px-4 py-2 text-sm font-medium">改写后</div>
|
||||
{rows.map((row, index) => {
|
||||
const line = row.right ? ++rightLine : null;
|
||||
return <DiffCell key={`right-${index}`} value={row.right} line={line} />;
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,600 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronsDownIcon,
|
||||
FilePenLineIcon,
|
||||
LightbulbIcon,
|
||||
Loader2Icon,
|
||||
Undo2Icon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { updateArtifactContent } from "@/core/artifacts/api";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import {
|
||||
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
|
||||
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
||||
DOCUMENT_REWRITE_UNDONE_EVENT,
|
||||
type DocumentRewritePresentation,
|
||||
useDocumentRewritePresentation,
|
||||
} from "@/core/document-rewrite/context";
|
||||
import {
|
||||
clearArtifactRewriteFallback,
|
||||
loadArtifactRewriteFallback,
|
||||
sha256DocumentContent,
|
||||
} from "@/core/document-rewrite/local-fallback";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
|
||||
|
||||
const REWRITE_STEPS = [
|
||||
{ key: "snapshot", label: "固定原文版本" },
|
||||
{ key: "requirements", label: "理解改写要求" },
|
||||
{ key: "writing", label: "流式重写 Markdown" },
|
||||
{ key: "illustrations", label: "生成文档配图" },
|
||||
{ key: "validation", label: "校验文档" },
|
||||
{ key: "comparison", label: "对比原文并生成说明" },
|
||||
] as const;
|
||||
|
||||
const GENERATE_STEPS = [
|
||||
{ key: "snapshot", label: "固定来源报告快照" },
|
||||
{ key: "requirements", label: "确认用户生成需求" },
|
||||
{ key: "writing", label: "流式生成 Markdown" },
|
||||
{ key: "illustrations", label: "生成文档配图" },
|
||||
{ key: "validation", label: "校验新报告" },
|
||||
{ key: "comparison", label: "生成报告说明" },
|
||||
] as const;
|
||||
|
||||
function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
|
||||
switch (stage) {
|
||||
case "snapshot":
|
||||
return 0;
|
||||
case "requirements":
|
||||
return 1;
|
||||
case "writing":
|
||||
return 2;
|
||||
case "illustrating":
|
||||
return 3;
|
||||
case "validation":
|
||||
return 4;
|
||||
case "committing":
|
||||
case "completed":
|
||||
return 6;
|
||||
case "conflict":
|
||||
case "error":
|
||||
case "cancelled":
|
||||
return 5;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminal(stage: DocumentRewritePresentation["stage"]) {
|
||||
return ["completed", "conflict", "error", "cancelled"].includes(stage);
|
||||
}
|
||||
|
||||
function RewriteThinkingTrace({ text, active }: { text: string; active: boolean }) {
|
||||
const [open, setOpen] = useState(active);
|
||||
const [autoScrollPaused, setAutoScrollPaused] = useState(false);
|
||||
const contentRef = useRef<HTMLPreElement>(null);
|
||||
const autoScrollEnabledRef = useRef(true);
|
||||
const previousActiveRef = useRef(active);
|
||||
const lastAutoScrollTopRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && text) setOpen(true);
|
||||
}, [active, text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && !previousActiveRef.current) {
|
||||
autoScrollEnabledRef.current = true;
|
||||
lastAutoScrollTopRef.current = null;
|
||||
setAutoScrollPaused(false);
|
||||
}
|
||||
previousActiveRef.current = active;
|
||||
}, [active]);
|
||||
|
||||
const scrollToLatestThinking = useCallback(() => {
|
||||
if (!autoScrollEnabledRef.current) return;
|
||||
const content = contentRef.current;
|
||||
if (!content) return;
|
||||
content.scrollTop = content.scrollHeight;
|
||||
lastAutoScrollTopRef.current = content.scrollTop;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!active || !open || !text) return;
|
||||
scrollToLatestThinking();
|
||||
}, [active, open, scrollToLatestThinking, text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !open || !text) return;
|
||||
const content = contentRef.current;
|
||||
if (!content) return;
|
||||
let frame: number | null = null;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
scrollToLatestThinking();
|
||||
});
|
||||
});
|
||||
observer.observe(content, { childList: true, characterData: true, subtree: true });
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [active, open, scrollToLatestThinking, text]);
|
||||
|
||||
const pauseAutoScrollOnManualScroll = (event: React.UIEvent<HTMLPreElement>) => {
|
||||
if (!active || !autoScrollEnabledRef.current) return;
|
||||
const lastAutoScrollTop = lastAutoScrollTopRef.current;
|
||||
if (
|
||||
lastAutoScrollTop === null ||
|
||||
Math.abs(event.currentTarget.scrollTop - lastAutoScrollTop) > 1
|
||||
) {
|
||||
autoScrollEnabledRef.current = false;
|
||||
setAutoScrollPaused(true);
|
||||
}
|
||||
};
|
||||
|
||||
const resumeAutoScroll = () => {
|
||||
autoScrollEnabledRef.current = true;
|
||||
setAutoScrollPaused(false);
|
||||
requestAnimationFrame(scrollToLatestThinking);
|
||||
};
|
||||
|
||||
if (!text.trim()) return null;
|
||||
return (
|
||||
<Collapsible className="mt-2 max-w-3xl" onOpenChange={setOpen} open={open}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
aria-label={open ? "收起模型思考" : "展开模型思考"}
|
||||
className="h-7 gap-1.5 px-2 text-xs"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
|
||||
<span>模型思考</span>
|
||||
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
|
||||
<ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
|
||||
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
|
||||
</p>
|
||||
<div className="relative mt-1">
|
||||
<pre
|
||||
className="bg-muted/45 text-muted-foreground max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words"
|
||||
onScroll={pauseAutoScrollOnManualScroll}
|
||||
ref={contentRef}
|
||||
>
|
||||
{text}
|
||||
</pre>
|
||||
{active && autoScrollPaused && (
|
||||
<Button
|
||||
aria-label="恢复思考内容自动下滑"
|
||||
className="absolute top-1/2 left-1/2 h-9 -translate-x-1/2 -translate-y-1/2 gap-1.5 bg-background/95 px-3 text-xs shadow-md backdrop-blur-sm hover:bg-background"
|
||||
onClick={resumeAutoScroll}
|
||||
size="sm"
|
||||
title="恢复自动下滑"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ChevronsDownIcon className="size-3.5" />
|
||||
自动下滑
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentRewriteTimeline({
|
||||
item,
|
||||
}: {
|
||||
item: DocumentRewritePresentation;
|
||||
}) {
|
||||
const isNewReportGeneration = item.operation === "generate";
|
||||
const steps = isNewReportGeneration ? GENERATE_STEPS : REWRITE_STEPS;
|
||||
const current = stepPosition(item.stage);
|
||||
const failed = ["conflict", "error", "cancelled"].includes(item.stage);
|
||||
const completed = item.stage === "completed";
|
||||
const thinkingStep = ["snapshot", "requirements"].includes(item.stage)
|
||||
? "requirements"
|
||||
: "writing";
|
||||
const presentation = useDocumentRewritePresentation();
|
||||
const [undoing, setUndoing] = useState(false);
|
||||
const [diffOpen, setDiffOpen] = useState(false);
|
||||
|
||||
const reapplying = Boolean(item.revertedAt);
|
||||
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
|
||||
const canUseLocalFallback =
|
||||
!reapplying &&
|
||||
!activeVersionId &&
|
||||
item.source.kind === "artifact" &&
|
||||
typeof item.source.path === "string" &&
|
||||
item.localFallbackAvailable &&
|
||||
typeof item.committedHash === "string";
|
||||
|
||||
const handleUndo = async () => {
|
||||
if ((!activeVersionId && !canUseLocalFallback) || undoing) return;
|
||||
if (canUseLocalFallback && item.source.kind === "artifact" && item.source.path && item.committedHash) {
|
||||
setUndoing(true);
|
||||
try {
|
||||
const fallback = loadArtifactRewriteFallback({
|
||||
threadId: item.source.id,
|
||||
path: item.source.path,
|
||||
committedHash: item.committedHash,
|
||||
});
|
||||
if (!fallback) {
|
||||
console.error("本地 AI 全文改写撤销备份不可用,未修改当前文件。");
|
||||
return;
|
||||
}
|
||||
const currentResponse = await apiFetch(
|
||||
urlOfArtifact({ filepath: item.source.path, threadId: item.source.id }),
|
||||
);
|
||||
if (!currentResponse.ok) {
|
||||
throw new Error(`读取当前文件失败(${currentResponse.status})。`);
|
||||
}
|
||||
const currentHash = await sha256DocumentContent(await currentResponse.text());
|
||||
if (!currentHash || currentHash !== fallback.committedHash) {
|
||||
console.error("当前文件已在改写后发生变化,已跳过本地 AI 全文改写撤销。");
|
||||
return;
|
||||
}
|
||||
const guard = new CustomEvent<{
|
||||
source: DocumentRewritePresentation["source"];
|
||||
rewrittenContent: string | null;
|
||||
unchanged: boolean;
|
||||
}>(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, {
|
||||
detail: {
|
||||
source: item.source,
|
||||
rewrittenContent: item.rewrittenContent,
|
||||
unchanged: false,
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(guard);
|
||||
if (!guard.detail.unchanged) {
|
||||
console.error("沙箱中存在改写后的未保存编辑,已跳过本地 AI 全文改写撤销。");
|
||||
return;
|
||||
}
|
||||
await updateArtifactContent({
|
||||
filepath: item.source.path,
|
||||
threadId: item.source.id,
|
||||
content: fallback.originalContent,
|
||||
});
|
||||
clearArtifactRewriteFallback(item.source.id, item.source.path);
|
||||
presentation?.update(item.id, {
|
||||
localFallbackAvailable: false,
|
||||
revertedAt: new Date().toISOString(),
|
||||
});
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(DOCUMENT_REWRITE_UNDONE_EVENT, {
|
||||
detail: { source: item.source, content: fallback.originalContent },
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("本地 AI 全文改写撤销失败:", error);
|
||||
} finally {
|
||||
setUndoing(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeVersionId) return;
|
||||
const baseUrl = getBackendBaseURL();
|
||||
const endpoint =
|
||||
item.source.kind === "artifact"
|
||||
? `${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(
|
||||
isNewReportGeneration
|
||||
? reapplying
|
||||
? "已重新应用本次生成的新报告版本。"
|
||||
: "已恢复本次生成前的新报告快照。"
|
||||
: 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">
|
||||
{isNewReportGeneration ? "生成新报告" : "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 ? (isNewReportGeneration ? "未生成新报告" : "未覆盖原文件") : "进行中"}
|
||||
</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 illustrationSkipped = step.key === "illustrations" && !item.generateImages;
|
||||
const illustrationFailed =
|
||||
step.key === "illustrations" &&
|
||||
item.generateImages &&
|
||||
completed &&
|
||||
item.generatedImageCount === 0;
|
||||
const done = !illustrationSkipped && !illustrationFailed && (completed || index < current);
|
||||
return (
|
||||
<div className="flex items-start gap-3" key={step.key}>
|
||||
<div
|
||||
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()} 字{isNewReportGeneration ? "来源报告" : "原文"}快照。
|
||||
</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">
|
||||
{isNewReportGeneration
|
||||
? "正在确认新报告的结构、资料范围与生成要求…"
|
||||
: "正在识别改写目标、保留约束与执行方式…"}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{isNewReportGeneration ? "已根据所选结构确定生成计划。" : "已根据改写要求确定执行计划。"}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{step.key === "writing" && (
|
||||
item.writtenCharCount > 0 ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
正在写入右侧沙箱,已{isNewReportGeneration ? "生成" : "改写"} {item.writtenCharCount.toLocaleString()} 字。
|
||||
</p>
|
||||
) : active ? (
|
||||
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
|
||||
{isNewReportGeneration
|
||||
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
|
||||
: "正在等待模型开始输出;右侧将在首个字符到达后显示草稿。"}
|
||||
</p>
|
||||
) : null
|
||||
)}
|
||||
{step.key === "illustrations" && (
|
||||
illustrationSkipped ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
本次未启用配图,保留原文已有图片与链接。
|
||||
</p>
|
||||
) : active ? (
|
||||
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
|
||||
{item.illustrationTarget
|
||||
? `正在为「${item.illustrationTarget}」生成配图,已完成 ${item.generatedImageCount}/${item.maxGeneratedImages} 张。`
|
||||
: `正在准备 ${item.maxGeneratedImages} 张文档配图…`}
|
||||
</p>
|
||||
) : item.generatedImageCount > 0 ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
已生成并保存 {item.generatedImageCount} 张本地配图。
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
未成功生成配图,文字改写结果仍已保留。
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{step.key === thinkingStep && (
|
||||
<RewriteThinkingTrace active={active} text={item.thinkingText} />
|
||||
)}
|
||||
{step.key === "validation" && item.warnings.length > 0 && (
|
||||
<p className="mt-0.5 text-xs text-amber-700 dark:text-amber-300">
|
||||
{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">{isNewReportGeneration ? "生成说明" : "改写说明"}</div>
|
||||
<p>
|
||||
{isNewReportGeneration ? "来源报告" : "原文"} {item.summary.originalCharCount.toLocaleString()} 字,
|
||||
{isNewReportGeneration ? "新报告" : "改写后"}{" "}
|
||||
{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.requestedImageCount ? (
|
||||
<p className="text-muted-foreground">
|
||||
文档配图:请求 {item.summary.requestedImageCount} 张,已保存 {item.summary.generatedImageCount ?? 0} 张本地图片。
|
||||
</p>
|
||||
) : null}
|
||||
{item.summary.headingChanges && (
|
||||
item.summary.headingChanges.reordered ||
|
||||
item.summary.headingChanges.added.length > 0 ||
|
||||
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>
|
||||
)}
|
||||
{isNewReportGeneration && completed && item.source.kind === "deep_research_report" && (
|
||||
<Button
|
||||
className="mt-3 mr-2"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT, {
|
||||
detail: { sessionId: item.source.id },
|
||||
}),
|
||||
);
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<FilePenLineIcon className="mr-1.5 size-3.5" />
|
||||
改写新报告
|
||||
</Button>
|
||||
)}
|
||||
{completed && (activeVersionId || canUseLocalFallback) && (
|
||||
<Button
|
||||
className="mt-3"
|
||||
disabled={undoing}
|
||||
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" />}
|
||||
{isNewReportGeneration
|
||||
? reapplying
|
||||
? "重新应用本次生成"
|
||||
: "撤销本次生成"
|
||||
: reapplying
|
||||
? "重新应用此次改写"
|
||||
: "撤销此次改写"}
|
||||
</Button>
|
||||
)}
|
||||
{item.revertedAt && (
|
||||
<p className="mt-2 text-xs text-emerald-700 dark:text-emerald-300">
|
||||
已恢复为{isNewReportGeneration ? "生成前" : "改写前"}版本。
|
||||
{item.redoVersionId ? `可重新应用本次${isNewReportGeneration ? "生成" : "改写"}。` : ""}
|
||||
</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,14 +1,8 @@
|
||||
import { FileText, Loader2, MessagesSquare } from "lucide-react";
|
||||
import { FileText, Loader2 } from "lucide-react";
|
||||
import { useState, type ComponentProps } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
|
||||
import { Tooltip } from "../tooltip";
|
||||
@@ -17,84 +11,29 @@ export function DownloadWordButton({
|
||||
markdown,
|
||||
fileName,
|
||||
title,
|
||||
questionMarkdown,
|
||||
...props
|
||||
}: ComponentProps<typeof Button> & {
|
||||
markdown: string;
|
||||
fileName?: string;
|
||||
title?: string;
|
||||
/** When present, the button offers both answer-only and current Q&A export. */
|
||||
questionMarkdown?: string;
|
||||
}) {
|
||||
const [busyScope, setBusyScope] = useState<"answer" | "qa" | null>(null);
|
||||
const handleDownload = async (scope: "answer" | "qa" = "answer") => {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const handleDownload = async () => {
|
||||
const source = markdown?.trim();
|
||||
if (!source) {
|
||||
toast.error("无可下载的 Markdown 内容");
|
||||
return;
|
||||
}
|
||||
const question = questionMarkdown?.trim();
|
||||
setBusyScope(scope);
|
||||
setBusy(true);
|
||||
try {
|
||||
if (scope === "qa" && question) {
|
||||
const qaMarkdown = `## 问题\n\n${question}\n\n## 回答\n\n${source}`;
|
||||
await downloadMarkdownAsDocx(
|
||||
qaMarkdown,
|
||||
`${fileName || "message"}-问答`,
|
||||
"问答记录",
|
||||
);
|
||||
} else {
|
||||
await downloadMarkdownAsDocx(
|
||||
source,
|
||||
fileName || "message",
|
||||
title || (question ? "问答回答" : undefined),
|
||||
);
|
||||
}
|
||||
await downloadMarkdownAsDocx(source, fileName || "message", title);
|
||||
toast.success("Word 文件已生成");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "生成 Word 失败");
|
||||
} finally {
|
||||
setBusyScope(null);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const busy = busyScope !== null;
|
||||
const icon = busy ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<FileText size={12} />
|
||||
);
|
||||
|
||||
if (questionMarkdown?.trim()) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Tooltip content="下载为 Word">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => void handleDownload("answer")}>
|
||||
<FileText className="text-muted-foreground" />
|
||||
仅下载当前回答
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void handleDownload("qa")}>
|
||||
<MessagesSquare className="text-muted-foreground" />
|
||||
下载当前问题和回答
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content="下载为 Word">
|
||||
<Button
|
||||
@@ -102,10 +41,14 @@ export function DownloadWordButton({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => void handleDownload("answer")}
|
||||
onClick={handleDownload}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
{busy ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<FileText size={12} />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef } from "react";
|
||||
import type {
|
||||
AnchorHTMLAttributes,
|
||||
HTMLAttributes,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import {
|
||||
MessageResponse,
|
||||
type MessageResponseProps,
|
||||
} from "@/components/ai-elements/message";
|
||||
import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import {
|
||||
createMarkdownAnchorResolver,
|
||||
isInPageAnchor,
|
||||
@@ -39,12 +34,6 @@ export type MarkdownContentProps = {
|
||||
className?: string;
|
||||
remarkPlugins?: MessageResponseProps["remarkPlugins"];
|
||||
components?: MessageResponseProps["components"];
|
||||
linkClassName?: (href: string, label: string) => string | undefined;
|
||||
onLinkClick?: (
|
||||
event: ReactMouseEvent<HTMLAnchorElement>,
|
||||
href: string,
|
||||
label: string,
|
||||
) => void;
|
||||
};
|
||||
|
||||
/** Renders markdown content. */
|
||||
@@ -54,12 +43,8 @@ export function MarkdownContent({
|
||||
className,
|
||||
remarkPlugins = streamdownPlugins.remarkPlugins,
|
||||
components: componentsFromProps,
|
||||
linkClassName,
|
||||
onLinkClick,
|
||||
}: MarkdownContentProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
||||
useStreamdownMermaidPngDownload(rootRef);
|
||||
const components = useMemo(() => {
|
||||
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
|
||||
const Tag = `h${level}` as const;
|
||||
@@ -108,7 +93,6 @@ export function MarkdownContent({
|
||||
);
|
||||
}
|
||||
const finalHref = resolvedHref ?? props.href;
|
||||
const label = extractTextFromChildren(props.children);
|
||||
return (
|
||||
<a
|
||||
{...rest}
|
||||
@@ -116,7 +100,6 @@ export function MarkdownContent({
|
||||
className={cn(
|
||||
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
||||
className,
|
||||
linkClassName?.(finalHref || "", label),
|
||||
)}
|
||||
target={target ?? (external ? "_blank" : undefined)}
|
||||
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
|
||||
@@ -127,7 +110,6 @@ export function MarkdownContent({
|
||||
event.preventDefault();
|
||||
scrollToInPageAnchor(event.currentTarget, finalHref);
|
||||
}
|
||||
onLinkClick?.(event, finalHref || "", label);
|
||||
rest.onClick?.(event);
|
||||
}}
|
||||
/>
|
||||
@@ -135,17 +117,16 @@ export function MarkdownContent({
|
||||
},
|
||||
...componentsFromProps,
|
||||
};
|
||||
}, [anchorResolver, componentsFromProps, linkClassName, onLinkClick]);
|
||||
}, [anchorResolver, componentsFromProps]);
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
||||
<div data-markdown-anchor-root className="contents">
|
||||
<MessageResponse
|
||||
className={className}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
mermaidConfig={streamdownPlugins.mermaidConfig}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import {
|
||||
BookOpenTextIcon,
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronsDownIcon,
|
||||
ChevronUp,
|
||||
FolderOpenIcon,
|
||||
GlobeIcon,
|
||||
LightbulbIcon,
|
||||
ListTodoIcon,
|
||||
Loader2Icon,
|
||||
MessageCircleQuestionMarkIcon,
|
||||
NotebookPenIcon,
|
||||
SearchIcon,
|
||||
SquareTerminalIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type AnchorHTMLAttributes } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
ChainOfThought,
|
||||
@@ -39,14 +36,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
buildWriteFileVirtualUrl,
|
||||
hasCompleteArtifactExtension,
|
||||
isAutoPreviewableArtifactPath,
|
||||
languageOfStreamingArtifact,
|
||||
normalizeStreamingArtifactPath,
|
||||
provisionalWriteFilePath,
|
||||
} from "@/core/artifacts/streaming-write-preview";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
normalizeSearchResults,
|
||||
@@ -61,41 +50,33 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
applyInlineCitations,
|
||||
buildSkillDisplayMap,
|
||||
findDisplayConfigForTool,
|
||||
isDisplaySamplePayload,
|
||||
normalizeDefaultDisplaySources,
|
||||
shouldUseDefaultCitationMode,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
buildSkillDisplayMap,
|
||||
findDisplayConfigForTool,
|
||||
isDisplaySamplePayload,
|
||||
normalizeDefaultDisplaySources,
|
||||
shouldUseDefaultCitationMode,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
||||
import { isInterceptedWritingReportWrite } from "@/core/threads/writing-mode";
|
||||
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useArtifactsOptional } from "../artifacts";
|
||||
import { CitationLink } from "../citations/citation-link";
|
||||
import { FlipDisplay } from "../flip-display";
|
||||
import { Tooltip } from "../tooltip";
|
||||
import { SourceCard } from "../skill-display-sources";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { SingleRoundRagCitationDialog } from "./message-list-item";
|
||||
|
||||
export function MessageGroup({
|
||||
className,
|
||||
messages,
|
||||
isLoading = false,
|
||||
displaySources = [],
|
||||
ragModeEnabled = false,
|
||||
}: {
|
||||
className?: string;
|
||||
messages: Message[];
|
||||
isLoading?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
ragModeEnabled?: boolean;
|
||||
export function MessageGroup({
|
||||
className,
|
||||
messages,
|
||||
isLoading = false,
|
||||
}: {
|
||||
className?: string;
|
||||
messages: Message[];
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [showAbove, setShowAbove] = useState(
|
||||
@@ -104,18 +85,12 @@ export function MessageGroup({
|
||||
const [showLastThinking, setShowLastThinking] = useState(
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
const steps = useMemo(() => convertToSteps(messages), [messages]);
|
||||
const [settings] = useLocalSettings();
|
||||
// 写作模式的「拦截 outputs/*.md 手写」只在写作模式生效;普通模式的
|
||||
// write_file / read_file 步骤条必须照旧显示。
|
||||
const writingModeEnabled = !!settings.context.writing_mode;
|
||||
const steps = useMemo(
|
||||
() => convertToSteps(messages, writingModeEnabled),
|
||||
[messages, writingModeEnabled],
|
||||
);
|
||||
const { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[ragModeEnabled, skills],
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[settings.display.ragModeEnabled, skills],
|
||||
);
|
||||
const lastToolCallStep = useMemo(() => {
|
||||
const filteredSteps = steps.filter((step) => step.type === "toolCall");
|
||||
@@ -138,29 +113,11 @@ export function MessageGroup({
|
||||
}
|
||||
}, [lastToolCallStep, steps]);
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
const [selectedCitation, setSelectedCitation] =
|
||||
useState<RuntimeDisplaySource | null>(null);
|
||||
const handleCitationClick = useCallback(
|
||||
(referenceId: string) => {
|
||||
const referenceIndex = Number(referenceId);
|
||||
if (!Number.isInteger(referenceIndex) || referenceIndex < 1) return;
|
||||
const source =
|
||||
displaySources.find((item) => item.index === referenceIndex) ??
|
||||
displaySources[referenceIndex - 1];
|
||||
if (source) setSelectedCitation(source);
|
||||
},
|
||||
[displaySources],
|
||||
);
|
||||
const reasoningId =
|
||||
lastReasoningStep?.id ??
|
||||
messages.find((message) => message.type === "ai")?.id;
|
||||
return (
|
||||
<>
|
||||
<ChainOfThought
|
||||
className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
|
||||
data-reference-batch-id={reasoningId}
|
||||
open={true}
|
||||
>
|
||||
<ChainOfThought
|
||||
className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
|
||||
open={true}
|
||||
>
|
||||
{aboveLastToolCallSteps.length > 0 && (
|
||||
<Button
|
||||
key="above"
|
||||
@@ -195,12 +152,10 @@ export function MessageGroup({
|
||||
<ChainOfThoughtStep
|
||||
key={step.id}
|
||||
label={
|
||||
<StepReasoningMarkdown
|
||||
<MarkdownContent
|
||||
content={step.reasoning ?? ""}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
onCitationClick={handleCitationClick}
|
||||
/>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
@@ -210,7 +165,7 @@ export function MessageGroup({
|
||||
{...step}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
ragModeEnabled={settings.display.ragModeEnabled}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
@@ -222,7 +177,7 @@ export function MessageGroup({
|
||||
isLast={true}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
ragModeEnabled={settings.display.ragModeEnabled}
|
||||
/>
|
||||
</FlipDisplay>
|
||||
)}
|
||||
@@ -257,12 +212,10 @@ export function MessageGroup({
|
||||
<ChainOfThoughtStep
|
||||
key={lastReasoningStep.id}
|
||||
label={
|
||||
<StepReasoningMarkdown
|
||||
<MarkdownContent
|
||||
content={lastReasoningStep.reasoning ?? ""}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
onCitationClick={handleCitationClick}
|
||||
/>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
@@ -270,60 +223,7 @@ export function MessageGroup({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ChainOfThought>
|
||||
<SingleRoundRagCitationDialog
|
||||
source={selectedCitation}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedCitation(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StepReasoningMarkdown({
|
||||
content,
|
||||
displaySources,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
onCitationClick,
|
||||
}: {
|
||||
content: string;
|
||||
displaySources: RuntimeDisplaySource[];
|
||||
isLoading: boolean;
|
||||
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onCitationClick: (referenceId: string) => void;
|
||||
}) {
|
||||
const markdown = useMemo(
|
||||
() =>
|
||||
applyInlineCitations(content, displaySources, { appendSummary: false }),
|
||||
[content, displaySources],
|
||||
);
|
||||
const components = useMemo(() => {
|
||||
if (displaySources.length === 0) return undefined;
|
||||
return {
|
||||
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (href?.startsWith("#reference-")) {
|
||||
return (
|
||||
<CitationLink
|
||||
href={href}
|
||||
{...props}
|
||||
onReferenceClick={onCitationClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <a href={href} {...props} />;
|
||||
},
|
||||
};
|
||||
}, [displaySources.length, onCitationClick]);
|
||||
|
||||
return (
|
||||
<MarkdownContent
|
||||
content={markdown}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
/>
|
||||
</ChainOfThought>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -349,35 +249,31 @@ function ToolCall({
|
||||
ragModeEnabled?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const displayMatch =
|
||||
result !== undefined && isDisplaySamplePayload(result)
|
||||
? null
|
||||
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
|
||||
null);
|
||||
const artifacts = useArtifactsOptional();
|
||||
|
||||
const displayMatch =
|
||||
result !== undefined && isDisplaySamplePayload(result)
|
||||
? null
|
||||
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
|
||||
null);
|
||||
const displayItems =
|
||||
displayMatch && result !== undefined
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: item.displayMode ?? displayMatch.display.mode,
|
||||
}))
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: item.displayMode ?? displayMatch.display.mode,
|
||||
}))
|
||||
: [];
|
||||
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
|
||||
const wikiSearchLabel = name === "llmwiki_search"
|
||||
? wikiQuery
|
||||
? `Wiki 文档检索 "${wikiQuery}"`
|
||||
: "Wiki 文档检索"
|
||||
: null;
|
||||
if (displayItems.length > 0) {
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
label={
|
||||
wikiSearchLabel || (displayMatch?.display.mode === "citation" || !displayMatch
|
||||
? "检索参考来源"
|
||||
: `获取 ${displayItems.length} 条结果`)
|
||||
}
|
||||
label={
|
||||
displayMatch?.display.mode === "citation" || !displayMatch
|
||||
? "检索参考来源"
|
||||
: `获取 ${displayItems.length} 条结果`
|
||||
}
|
||||
icon={SearchIcon}
|
||||
>
|
||||
<DisplayResultsList results={displayItems} />
|
||||
@@ -385,15 +281,8 @@ 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);
|
||||
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) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
@@ -401,7 +290,7 @@ function ToolCall({
|
||||
mode: "citation" as const,
|
||||
}));
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
|
||||
<ChainOfThoughtStep key={id} label="检索参考来源" icon={SearchIcon}>
|
||||
<DisplayResultsList results={defaultItems} />
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
@@ -412,10 +301,8 @@ function ToolCall({
|
||||
if (query) {
|
||||
label =
|
||||
name === "web_search"
|
||||
? `知识库检索 "${query}"`
|
||||
? t.toolCalls.searchOnWebFor(query)
|
||||
: t.toolCalls.searchFor(query);
|
||||
} else if (name === "web_search") {
|
||||
label = "知识库检索";
|
||||
}
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
|
||||
@@ -535,17 +422,43 @@ function ToolCall({
|
||||
if (!description) {
|
||||
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 (
|
||||
<ArtifactWriteToolCall
|
||||
id={id}
|
||||
messageId={messageId}
|
||||
name={name}
|
||||
args={args}
|
||||
result={result}
|
||||
description={description}
|
||||
isLast={isLast}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
className="cursor-pointer"
|
||||
label={description}
|
||||
icon={NotebookPenIcon}
|
||||
onClick={() => {
|
||||
if (!artifactPath || !artifacts) {
|
||||
return;
|
||||
}
|
||||
artifacts.select(artifactPath);
|
||||
artifacts.setOpen(true);
|
||||
}}
|
||||
>
|
||||
{path && (
|
||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||
{path}
|
||||
</ChainOfThoughtSearchResult>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "bash") {
|
||||
const description: string | undefined = (args as { description: string })
|
||||
@@ -610,237 +523,6 @@ 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 thinking = typeof args.thinking === "string" ? args.thinking.trim() : "";
|
||||
const active = args.status !== "complete";
|
||||
const [thinkingOpen, setThinkingOpen] = useState(false);
|
||||
const [autoScrollPaused, setAutoScrollPaused] = useState(false);
|
||||
const thinkingContentRef = useRef<HTMLPreElement>(null);
|
||||
const autoScrollEnabledRef = useRef(true);
|
||||
const programmaticScrollRef = useRef(false);
|
||||
const previousActiveRef = useRef(active);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && thinking) setThinkingOpen(true);
|
||||
}, [active, thinking]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && !previousActiveRef.current) {
|
||||
autoScrollEnabledRef.current = true;
|
||||
setAutoScrollPaused(false);
|
||||
}
|
||||
previousActiveRef.current = active;
|
||||
}, [active]);
|
||||
|
||||
const scrollToLatestThinking = useCallback(() => {
|
||||
if (!autoScrollEnabledRef.current) return;
|
||||
const content = thinkingContentRef.current;
|
||||
if (!content) return;
|
||||
// Scroll events do not identify their initiator reliably across browsers.
|
||||
// Mark this assignment so it never disables following as if it were a
|
||||
// user drag; a user-originated scroll away from the bottom still pauses it.
|
||||
programmaticScrollRef.current = true;
|
||||
content.scrollTop = content.scrollHeight;
|
||||
requestAnimationFrame(() => {
|
||||
programmaticScrollRef.current = false;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!active || !thinkingOpen || !thinking) return;
|
||||
scrollToLatestThinking();
|
||||
// A second frame covers the browser's delayed reflow of a growing <pre>.
|
||||
const frame = requestAnimationFrame(scrollToLatestThinking);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [active, thinking, thinkingOpen, scrollToLatestThinking]);
|
||||
|
||||
const pauseAutoScrollOnManualScroll = (event: React.UIEvent<HTMLPreElement>) => {
|
||||
if (!active || !autoScrollEnabledRef.current || programmaticScrollRef.current) return;
|
||||
const { clientHeight, scrollHeight, scrollTop } = event.currentTarget;
|
||||
// Being at the bottom is still the follow state. Only an actual move away
|
||||
// from it (wheel, touch, keyboard or scrollbar drag) pauses auto-follow.
|
||||
if (scrollTop + clientHeight >= scrollHeight - 4) return;
|
||||
autoScrollEnabledRef.current = false;
|
||||
setAutoScrollPaused(true);
|
||||
};
|
||||
|
||||
const resumeAutoScroll = () => {
|
||||
autoScrollEnabledRef.current = true;
|
||||
setAutoScrollPaused(false);
|
||||
requestAnimationFrame(scrollToLatestThinking);
|
||||
};
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
status={active ? "active" : "complete"}
|
||||
icon={active
|
||||
? <Loader2Icon className="text-primary size-4 animate-spin" />
|
||||
: <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />}
|
||||
label={
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||
<span>{label}</span>
|
||||
<span className={active ? "text-primary text-xs" : "text-muted-foreground text-xs"}>
|
||||
{active ? "进行中" : "已完成"}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<div className="text-muted-foreground text-xs leading-5">
|
||||
{detail}
|
||||
</div>
|
||||
)}
|
||||
{thinking && (
|
||||
<Collapsible className="mt-2" onOpenChange={setThinkingOpen} open={thinkingOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
aria-label={thinkingOpen ? "收起模型思考" : "展开模型思考"}
|
||||
className="h-7 gap-1.5 px-2 text-xs"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
|
||||
<span>模型思考</span>
|
||||
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
|
||||
<ChevronDownIcon className={cn("size-3.5 transition-transform", thinkingOpen && "rotate-180")} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
|
||||
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
|
||||
</p>
|
||||
<div className="relative mt-1">
|
||||
<pre
|
||||
className="bg-muted/45 text-muted-foreground max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words"
|
||||
onScroll={pauseAutoScrollOnManualScroll}
|
||||
ref={thinkingContentRef}
|
||||
>
|
||||
{thinking}
|
||||
</pre>
|
||||
{active && autoScrollPaused && (
|
||||
<Button
|
||||
aria-label="恢复思考内容自动下滑"
|
||||
className="absolute top-1/2 left-1/2 h-9 -translate-x-1/2 -translate-y-1/2 gap-1.5 bg-background/95 px-3 text-xs shadow-md backdrop-blur-sm hover:bg-background"
|
||||
onClick={resumeAutoScroll}
|
||||
size="sm"
|
||||
title="恢复自动下滑"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ChevronsDownIcon className="size-3.5" />
|
||||
自动下滑
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
/** 文件卡片只显示文件名:宿主泄漏路径(F:\...\outputs\report.md)与虚拟路径
|
||||
* (/mnt/user-data/outputs/report.md)统一收敛到最后一段,避免把服务端磁盘结构暴露给用户。 */
|
||||
function artifactDisplayName(path: string): string {
|
||||
const filename = path.replace(/\\/g, "/").split("/").filter(Boolean).at(-1);
|
||||
return filename || path;
|
||||
}
|
||||
|
||||
function ArtifactWriteToolCall({
|
||||
id,
|
||||
messageId,
|
||||
name,
|
||||
args,
|
||||
result,
|
||||
description,
|
||||
isLoading,
|
||||
}: {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
name: "write_file" | "str_replace";
|
||||
args: Record<string, unknown>;
|
||||
result?: string | Record<string, unknown>;
|
||||
description: string;
|
||||
isLast: boolean;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const artifacts = useArtifactsOptional();
|
||||
const openStreamedArtifact = artifacts?.openStreamedArtifact;
|
||||
const rawPath = typeof args.path === "string" ? args.path : "";
|
||||
const content = typeof args.content === "string" ? args.content : "";
|
||||
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
|
||||
const hasUsablePath =
|
||||
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
|
||||
const sourcePath = hasUsablePath
|
||||
? normalizedPath
|
||||
: name === "write_file" && content.trim()
|
||||
? provisionalWriteFilePath(content)
|
||||
: null;
|
||||
const previewKey = messageId && id ? `${messageId}:${id}` : null;
|
||||
// Never select a bare provisional name like `未命名文件.md` — the artifacts
|
||||
// API only serves `/mnt/user-data/...` paths and would 400. Wait until the
|
||||
// tool call has ids so we can open a write-file: memory preview instead.
|
||||
const artifactPath =
|
||||
sourcePath && name === "write_file" && messageId && id
|
||||
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!openStreamedArtifact ||
|
||||
!artifactPath ||
|
||||
!previewKey ||
|
||||
!isLoading ||
|
||||
result !== undefined ||
|
||||
!sourcePath ||
|
||||
!languageOfStreamingArtifact(sourcePath) ||
|
||||
!isAutoPreviewableArtifactPath(sourcePath)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
openStreamedArtifact(artifactPath, previewKey);
|
||||
}, [
|
||||
artifactPath,
|
||||
isLoading,
|
||||
openStreamedArtifact,
|
||||
previewKey,
|
||||
result,
|
||||
sourcePath,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
className={cn(artifactPath && "cursor-pointer")}
|
||||
label={description}
|
||||
icon={NotebookPenIcon}
|
||||
onClick={() => {
|
||||
if (!artifactPath || !artifacts) return;
|
||||
artifacts.select(artifactPath);
|
||||
artifacts.setOpen(true);
|
||||
}}
|
||||
>
|
||||
{(normalizedPath || sourcePath) && (
|
||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
|
||||
</ChainOfThoughtSearchResult>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
interface GenericCoTStep<T extends string = string> {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
@@ -859,7 +541,7 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
|
||||
|
||||
type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
||||
|
||||
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||
function convertToSteps(messages: Message[]): CoTStep[] {
|
||||
const steps: CoTStep[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.type === "ai") {
|
||||
@@ -877,22 +559,6 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||
if (tool_call.name === "task") {
|
||||
continue;
|
||||
}
|
||||
const writePath =
|
||||
typeof (tool_call.args as { path?: unknown } | undefined)?.path === "string"
|
||||
? (tool_call.args as { path: string }).path
|
||||
: "";
|
||||
if (
|
||||
(
|
||||
tool_call.name === "write_file" ||
|
||||
tool_call.name === "str_replace" ||
|
||||
tool_call.name === "read_file" ||
|
||||
tool_call.name === "read_multiple_files" ||
|
||||
tool_call.name === "present_files"
|
||||
) &&
|
||||
isInterceptedWritingReportWrite(writePath, message.id, messages, writingMode)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const step: CoTToolCallStep = {
|
||||
id: tool_call.id,
|
||||
messageId: message.id,
|
||||
@@ -962,7 +628,7 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
<span>{results.length} 条真实返回结果</span>
|
||||
<span>{results.length} 条真实返回结果</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 flex flex-col gap-2">
|
||||
{results.map((item) => (
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import type { Message } from '@langchain/langgraph-sdk';
|
||||
import {
|
||||
BookMarkedIcon,
|
||||
ChevronDownIcon,
|
||||
ClockIcon,
|
||||
ExternalLinkIcon,
|
||||
FileIcon,
|
||||
Loader2Icon,
|
||||
PencilIcon,
|
||||
} from 'lucide-react';
|
||||
import { ClockIcon, FileIcon, Loader2Icon, PencilIcon } from 'lucide-react';
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@@ -17,7 +9,6 @@ import {
|
||||
useState,
|
||||
type AnchorHTMLAttributes,
|
||||
type ImgHTMLAttributes,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
|
||||
@@ -36,18 +27,6 @@ import {
|
||||
import { Task, TaskTrigger } from '@/components/ai-elements/task';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
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 {
|
||||
UploadedFilePreviewDialog,
|
||||
@@ -56,7 +35,6 @@ import {
|
||||
import { resolveArtifactURL } from '@/core/artifacts/utils';
|
||||
import { useChatAskTimes } from '@/core/chat-timestamps';
|
||||
import { getBackendBaseURL } from '@/core/config';
|
||||
import { readRuntimeEnv } from '@/env';
|
||||
import { useI18n } from '@/core/i18n/hooks';
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
@@ -72,10 +50,6 @@ import {
|
||||
type RuntimeDisplaySource,
|
||||
} from '@/core/skill-display/runtime';
|
||||
import { humanMessagePlugins } from '@/core/streamdown';
|
||||
import {
|
||||
getMultiRoundRagTrace,
|
||||
isMultiRoundRagMessage,
|
||||
} from '@/core/threads/use-multi-round-rag';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { CitationLink } from '../citations/citation-link';
|
||||
@@ -87,7 +61,6 @@ import { DeleteMessageButton } from './delete-message-button';
|
||||
import { DownloadWordButton } from './download-word-button';
|
||||
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
|
||||
import { MessageTokenUsage } from './message-token-usage';
|
||||
import { MultiRoundRagAssistantBody } from './multi-round-rag-message-list';
|
||||
|
||||
type EmbeddedPageContext = {
|
||||
title?: string;
|
||||
@@ -158,17 +131,16 @@ export function MessageListItem({
|
||||
fitRowToContent = false,
|
||||
anchorId,
|
||||
message,
|
||||
questionMarkdown,
|
||||
isLoading,
|
||||
threadId,
|
||||
tokenUsageEnabled = false,
|
||||
displaySources = [],
|
||||
extraRehypePlugins,
|
||||
onEditResubmit,
|
||||
onEditSave,
|
||||
onMessageDeleted,
|
||||
turnTime,
|
||||
}: {
|
||||
extraRehypePlugins,
|
||||
onEditResubmit,
|
||||
onEditSave,
|
||||
onMessageDeleted,
|
||||
turnTime,
|
||||
}: {
|
||||
className?: string;
|
||||
fitRowToContent?: boolean;
|
||||
/**
|
||||
@@ -177,16 +149,14 @@ export function MessageListItem({
|
||||
*/
|
||||
anchorId?: string;
|
||||
message: Message;
|
||||
/** Nearest preceding user question, used by the current-Q&A Word export. */
|
||||
questionMarkdown?: string;
|
||||
isLoading?: boolean;
|
||||
threadId: string;
|
||||
tokenUsageEnabled?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||
/**
|
||||
* Durable ask time of the turn this message belongs to (the preceding
|
||||
* question's `client_ts`). Used to time-stamp the AI answer, which carries no
|
||||
@@ -194,102 +164,102 @@ export function MessageListItem({
|
||||
* wall clock at render time and appear to "drift" to now on every reload.
|
||||
*/
|
||||
turnTime?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const isHuman = message.type === 'human';
|
||||
const messageMarkdown =
|
||||
extractContentFromMessage(message) ??
|
||||
extractReasoningContentFromMessage(message) ??
|
||||
'';
|
||||
const [assistantEditing, setAssistantEditing] = useState(false);
|
||||
const [assistantDraft, setAssistantDraft] = useState(messageMarkdown);
|
||||
const [assistantSaving, setAssistantSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assistantEditing) {
|
||||
setAssistantDraft(messageMarkdown);
|
||||
}
|
||||
}, [assistantEditing, messageMarkdown]);
|
||||
|
||||
const canSaveAssistantEdit =
|
||||
!isLoading &&
|
||||
!isHuman &&
|
||||
!!onEditSave &&
|
||||
!!message.id &&
|
||||
!message.id.startsWith('opt-') &&
|
||||
!!messageMarkdown;
|
||||
const confirmAssistantEdit = useCallback(async () => {
|
||||
const value = assistantDraft.trim();
|
||||
if (!value || !message.id || !onEditSave || assistantSaving) {
|
||||
return;
|
||||
}
|
||||
setAssistantSaving(true);
|
||||
try {
|
||||
await onEditSave(message.id, value);
|
||||
setAssistantEditing(false);
|
||||
} catch {
|
||||
// The page-level save callback owns user-facing error toasts. Keep the
|
||||
// editor open so the user can adjust and retry.
|
||||
} finally {
|
||||
setAssistantSaving(false);
|
||||
}
|
||||
}, [assistantDraft, assistantSaving, message.id, onEditSave]);
|
||||
|
||||
if (!isHuman && assistantEditing) {
|
||||
return (
|
||||
<AIElementMessage
|
||||
id={anchorId}
|
||||
className={cn(
|
||||
'group/conversation-message relative',
|
||||
fitRowToContent ? 'max-w-full w-fit' : 'w-full',
|
||||
anchorId && 'scroll-mt-20',
|
||||
className,
|
||||
)}
|
||||
from="assistant"
|
||||
>
|
||||
<AIElementMessageContent className="w-full">
|
||||
<div className="flex w-full flex-col gap-2 rounded-2xl border border-primary/20 bg-primary/5 p-3">
|
||||
<Textarea
|
||||
value={assistantDraft}
|
||||
autoFocus
|
||||
onChange={(event) => setAssistantDraft(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
void confirmAssistantEdit();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setAssistantEditing(false);
|
||||
}
|
||||
}}
|
||||
className="min-h-32 resize-y text-[15px] leading-7"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={assistantSaving}
|
||||
onClick={() => setAssistantEditing(false)}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!assistantDraft.trim() || assistantSaving}
|
||||
onClick={() => void confirmAssistantEdit()}
|
||||
>
|
||||
{assistantSaving ? "保存中..." : t.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AIElementMessageContent>
|
||||
</AIElementMessage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AIElementMessage
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const isHuman = message.type === 'human';
|
||||
const messageMarkdown =
|
||||
extractContentFromMessage(message) ??
|
||||
extractReasoningContentFromMessage(message) ??
|
||||
'';
|
||||
const [assistantEditing, setAssistantEditing] = useState(false);
|
||||
const [assistantDraft, setAssistantDraft] = useState(messageMarkdown);
|
||||
const [assistantSaving, setAssistantSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assistantEditing) {
|
||||
setAssistantDraft(messageMarkdown);
|
||||
}
|
||||
}, [assistantEditing, messageMarkdown]);
|
||||
|
||||
const canSaveAssistantEdit =
|
||||
!isLoading &&
|
||||
!isHuman &&
|
||||
!!onEditSave &&
|
||||
!!message.id &&
|
||||
!message.id.startsWith('opt-') &&
|
||||
!!messageMarkdown;
|
||||
const confirmAssistantEdit = useCallback(async () => {
|
||||
const value = assistantDraft.trim();
|
||||
if (!value || !message.id || !onEditSave || assistantSaving) {
|
||||
return;
|
||||
}
|
||||
setAssistantSaving(true);
|
||||
try {
|
||||
await onEditSave(message.id, value);
|
||||
setAssistantEditing(false);
|
||||
} catch {
|
||||
// The page-level save callback owns user-facing error toasts. Keep the
|
||||
// editor open so the user can adjust and retry.
|
||||
} finally {
|
||||
setAssistantSaving(false);
|
||||
}
|
||||
}, [assistantDraft, assistantSaving, message.id, onEditSave]);
|
||||
|
||||
if (!isHuman && assistantEditing) {
|
||||
return (
|
||||
<AIElementMessage
|
||||
id={anchorId}
|
||||
className={cn(
|
||||
'group/conversation-message relative',
|
||||
fitRowToContent ? 'max-w-full w-fit' : 'w-full',
|
||||
anchorId && 'scroll-mt-20',
|
||||
className,
|
||||
)}
|
||||
from="assistant"
|
||||
>
|
||||
<AIElementMessageContent className="w-full">
|
||||
<div className="flex w-full flex-col gap-2 rounded-2xl border border-primary/20 bg-primary/5 p-3">
|
||||
<Textarea
|
||||
value={assistantDraft}
|
||||
autoFocus
|
||||
onChange={(event) => setAssistantDraft(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
void confirmAssistantEdit();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setAssistantEditing(false);
|
||||
}
|
||||
}}
|
||||
className="min-h-32 resize-y text-[15px] leading-7"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={assistantSaving}
|
||||
onClick={() => setAssistantEditing(false)}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!assistantDraft.trim() || assistantSaving}
|
||||
onClick={() => void confirmAssistantEdit()}
|
||||
>
|
||||
{assistantSaving ? "保存中..." : t.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AIElementMessageContent>
|
||||
</AIElementMessage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AIElementMessage
|
||||
id={anchorId}
|
||||
className={cn(
|
||||
'group/conversation-message relative',
|
||||
@@ -307,12 +277,12 @@ export function MessageListItem({
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onEditSave={isLoading ? undefined : onEditSave}
|
||||
onMessageDeleted={onMessageDeleted}
|
||||
turnTime={turnTime}
|
||||
/>
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onEditSave={isLoading ? undefined : onEditSave}
|
||||
onMessageDeleted={onMessageDeleted}
|
||||
turnTime={turnTime}
|
||||
/>
|
||||
{/*
|
||||
Human messages carry their own inline copy/edit toolbar inside
|
||||
MessageContent (above the timestamp). Only assistant answers use the
|
||||
@@ -327,30 +297,28 @@ export function MessageListItem({
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<CopyButton clipboardData={messageMarkdown} />
|
||||
{messageMarkdown && (
|
||||
<DownloadWordButton
|
||||
markdown={messageMarkdown}
|
||||
fileName="message"
|
||||
title="问答回答"
|
||||
questionMarkdown={questionMarkdown}
|
||||
/>
|
||||
)}
|
||||
{canSaveAssistantEdit && (
|
||||
<Tooltip content="编辑消息">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="size-6"
|
||||
onClick={() => setAssistantEditing(true)}
|
||||
>
|
||||
<PencilIcon size={11} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</MessageToolbar>
|
||||
)}
|
||||
{messageMarkdown && (
|
||||
<DownloadWordButton
|
||||
markdown={messageMarkdown}
|
||||
fileName="message"
|
||||
/>
|
||||
)}
|
||||
{canSaveAssistantEdit && (
|
||||
<Tooltip content="编辑消息">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="size-6"
|
||||
onClick={() => setAssistantEditing(true)}
|
||||
>
|
||||
<PencilIcon size={11} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</MessageToolbar>
|
||||
)}
|
||||
</AIElementMessage>
|
||||
);
|
||||
}
|
||||
@@ -364,31 +332,31 @@ export function MessageListItem({
|
||||
* the question; confirming sends the edited text as a brand-new turn (the
|
||||
* original question stays in the thread).
|
||||
*/
|
||||
function HumanMessageBlock({
|
||||
messageId,
|
||||
text,
|
||||
pageContext,
|
||||
filesList,
|
||||
messageTime,
|
||||
components,
|
||||
onEditResubmit,
|
||||
onEditSave,
|
||||
deleteButton,
|
||||
}: {
|
||||
messageId?: string;
|
||||
text: string;
|
||||
pageContext?: EmbeddedPageContext | null;
|
||||
filesList: ReactNode;
|
||||
messageTime: { label: string; full: string } | null;
|
||||
components: MarkdownContentProps["components"];
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||
deleteButton?: ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(text);
|
||||
const [saving, setSaving] = useState(false);
|
||||
function HumanMessageBlock({
|
||||
messageId,
|
||||
text,
|
||||
pageContext,
|
||||
filesList,
|
||||
messageTime,
|
||||
components,
|
||||
onEditResubmit,
|
||||
onEditSave,
|
||||
deleteButton,
|
||||
}: {
|
||||
messageId?: string;
|
||||
text: string;
|
||||
pageContext?: EmbeddedPageContext | null;
|
||||
filesList: React.ReactNode;
|
||||
messageTime: { label: string; full: string } | null;
|
||||
components: MarkdownContentProps["components"];
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||
deleteButton?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(text);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
@@ -396,27 +364,27 @@ function HumanMessageBlock({
|
||||
}
|
||||
}, [text, editing]);
|
||||
|
||||
const confirmEdit = useCallback(async () => {
|
||||
const value = draft.trim();
|
||||
if (!value || saving) {
|
||||
return;
|
||||
}
|
||||
if (onEditSave && messageId && !messageId.startsWith("opt-")) {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onEditSave(messageId, value);
|
||||
setEditing(false);
|
||||
} catch {
|
||||
// The page-level save callback owns user-facing error toasts. Keep the
|
||||
// editor open so the user can adjust and retry.
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
onEditResubmit?.(value);
|
||||
setEditing(false);
|
||||
}, [draft, messageId, onEditResubmit, onEditSave, saving]);
|
||||
const confirmEdit = useCallback(async () => {
|
||||
const value = draft.trim();
|
||||
if (!value || saving) {
|
||||
return;
|
||||
}
|
||||
if (onEditSave && messageId && !messageId.startsWith("opt-")) {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onEditSave(messageId, value);
|
||||
setEditing(false);
|
||||
} catch {
|
||||
// The page-level save callback owns user-facing error toasts. Keep the
|
||||
// editor open so the user can adjust and retry.
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
onEditResubmit?.(value);
|
||||
setEditing(false);
|
||||
}, [draft, messageId, onEditResubmit, onEditSave, saving]);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
@@ -429,10 +397,10 @@ function HumanMessageBlock({
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
void confirmEdit();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
void confirmEdit();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
@@ -440,22 +408,22 @@ function HumanMessageBlock({
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={saving}
|
||||
onClick={() => setEditing(false)}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void confirmEdit()}
|
||||
disabled={!draft.trim() || saving}
|
||||
>
|
||||
{saving ? "保存中..." : onEditSave ? t.common.save : t.common.confirm}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={saving}
|
||||
onClick={() => setEditing(false)}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void confirmEdit()}
|
||||
disabled={!draft.trim() || saving}
|
||||
>
|
||||
{saving ? "保存中..." : onEditSave ? t.common.save : t.common.confirm}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -483,11 +451,11 @@ function HumanMessageBlock({
|
||||
{(text || deleteButton) && (
|
||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/conversation-message:opacity-100">
|
||||
{text && <CopyButton clipboardData={text} className="size-6" />}
|
||||
{text && (onEditResubmit || onEditSave) && (
|
||||
<Tooltip content={onEditSave ? "编辑消息" : t.inputBox.editQuestion}>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
{text && (onEditResubmit || onEditSave) && (
|
||||
<Tooltip content={onEditSave ? "编辑消息" : t.inputBox.editQuestion}>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="size-6"
|
||||
onClick={() => setEditing(true)}
|
||||
@@ -555,24 +523,24 @@ function MessageContent_({
|
||||
threadId,
|
||||
tokenUsageEnabled = false,
|
||||
displaySources = [],
|
||||
extraRehypePlugins,
|
||||
onEditResubmit,
|
||||
onEditSave,
|
||||
onMessageDeleted,
|
||||
turnTime,
|
||||
}: {
|
||||
extraRehypePlugins,
|
||||
onEditResubmit,
|
||||
onEditSave,
|
||||
onMessageDeleted,
|
||||
turnTime,
|
||||
}: {
|
||||
className?: string;
|
||||
message: Message;
|
||||
isLoading?: boolean;
|
||||
threadId: string;
|
||||
tokenUsageEnabled?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||
turnTime?: string;
|
||||
}) {
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||
turnTime?: string;
|
||||
}) {
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
|
||||
// Streamdown splits content into per-block processors; calling the factory
|
||||
@@ -595,26 +563,7 @@ function MessageContent_({
|
||||
);
|
||||
const askTimes = useChatAskTimes();
|
||||
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 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(
|
||||
() => ({
|
||||
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
@@ -622,15 +571,7 @@ function MessageContent_({
|
||||
),
|
||||
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (href?.startsWith('#reference-')) {
|
||||
return (
|
||||
<CitationLink
|
||||
href={href}
|
||||
{...props}
|
||||
onReferenceClick={
|
||||
isSingleRoundRag ? handleCitationClick : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
return <CitationLink href={href} {...props} />;
|
||||
}
|
||||
if (
|
||||
typeof props.children === 'string' &&
|
||||
@@ -652,12 +593,7 @@ function MessageContent_({
|
||||
return <a {...props} href={href} />;
|
||||
},
|
||||
}),
|
||||
[
|
||||
handleCitationClick,
|
||||
isSingleRoundRag,
|
||||
resolveArtifact,
|
||||
threadId,
|
||||
],
|
||||
[threadId, resolveArtifact],
|
||||
);
|
||||
|
||||
// Question carries its own `client_ts` (round-trips through the checkpoint).
|
||||
@@ -692,16 +628,9 @@ function MessageContent_({
|
||||
? stripArtifactTag(stripUploadedFilesTag(rawContent))
|
||||
: '';
|
||||
}
|
||||
if (isMultiRoundRag) {
|
||||
return stripIncompleteCitationMarker(rawContent ?? '');
|
||||
}
|
||||
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
|
||||
return stripIncompleteCitationMarker(withCitations);
|
||||
}, [rawContent, isHuman, isMultiRoundRag, displaySources]);
|
||||
const markdownRehypePlugins = useMemo(
|
||||
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
|
||||
[rehypePlugins],
|
||||
);
|
||||
}, [rawContent, isHuman, displaySources]);
|
||||
const parsedHumanContent = useMemo(
|
||||
() =>
|
||||
isHuman
|
||||
@@ -732,7 +661,7 @@ function MessageContent_({
|
||||
}
|
||||
|
||||
// Reasoning-only AI message (no main response content yet)
|
||||
if (!isHuman && reasoningContent && !rawContent && !isMultiRoundRag) {
|
||||
if (!isHuman && reasoningContent && !rawContent) {
|
||||
return (
|
||||
<AIElementMessageContent className={className}>
|
||||
<Reasoning isStreaming={isLoading}>
|
||||
@@ -753,24 +682,18 @@ function MessageContent_({
|
||||
// it exists server-side (real, non-optimistic id). Deleting it cascades to
|
||||
// its answer on the backend.
|
||||
const canDelete =
|
||||
!!onMessageDeleted &&
|
||||
!!message.id &&
|
||||
!message.id.startsWith('opt-') &&
|
||||
!isMultiRoundRag &&
|
||||
!isSingleRoundRag;
|
||||
!!onMessageDeleted && !!message.id && !message.id.startsWith('opt-');
|
||||
return (
|
||||
<HumanMessageBlock
|
||||
messageId={message.id}
|
||||
text={parsedHumanContent.content}
|
||||
pageContext={parsedHumanContent.pageContext}
|
||||
filesList={filesList}
|
||||
messageTime={messageTime}
|
||||
components={components}
|
||||
onEditResubmit={
|
||||
isMultiRoundRag || isSingleRoundRag ? undefined : onEditResubmit
|
||||
}
|
||||
onEditSave={isMultiRoundRag || isSingleRoundRag ? undefined : onEditSave}
|
||||
deleteButton={
|
||||
<HumanMessageBlock
|
||||
messageId={message.id}
|
||||
text={parsedHumanContent.content}
|
||||
pageContext={parsedHumanContent.pageContext}
|
||||
filesList={filesList}
|
||||
messageTime={messageTime}
|
||||
components={components}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onEditSave={onEditSave}
|
||||
deleteButton={
|
||||
canDelete ? (
|
||||
<DeleteMessageButton
|
||||
threadId={threadId}
|
||||
@@ -785,185 +708,30 @@ function MessageContent_({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AIElementMessageContent className={className}>
|
||||
{filesList}
|
||||
<div data-reference-batch-id={message.id} className="contents">
|
||||
{isMultiRoundRag ? (
|
||||
<MultiRoundRagAssistantBody
|
||||
content={contentToDisplay}
|
||||
thinkingTrace={multiRoundTrace}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={markdownRehypePlugins}
|
||||
/>
|
||||
) : (
|
||||
<AssistantDisplayContent
|
||||
content={contentToDisplay}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
renderSkillDisplayBlocks={false}
|
||||
rehypePlugins={markdownRehypePlugins}
|
||||
components={components}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isSingleRoundRag && displaySources.length > 0 && (
|
||||
<FrontendRagSourcesPanel
|
||||
sources={displaySources}
|
||||
onSelect={setSelectedCitation}
|
||||
/>
|
||||
)}
|
||||
<MessageTokenUsage
|
||||
enabled={tokenUsageEnabled}
|
||||
<AIElementMessageContent className={className}>
|
||||
{filesList}
|
||||
<div data-reference-batch-id={message.id} className="contents">
|
||||
<AssistantDisplayContent
|
||||
content={contentToDisplay}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
message={message}
|
||||
renderSkillDisplayBlocks={false}
|
||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
|
||||
components={components}
|
||||
/>
|
||||
{!isLoading && contentToDisplay ? (
|
||||
<MessageTimeStamp time={messageTime} align="left" />
|
||||
) : null}
|
||||
</AIElementMessageContent>
|
||||
{isSingleRoundRag && (
|
||||
<SingleRoundRagCitationDialog
|
||||
source={selectedCitation}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedCitation(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
<MessageTokenUsage
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={isLoading}
|
||||
message={message}
|
||||
/>
|
||||
{!isLoading && contentToDisplay ? (
|
||||
<MessageTimeStamp time={messageTime} align="left" />
|
||||
) : null}
|
||||
</AIElementMessageContent>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单轮 RAG 专属的「引用来源」折叠面板。
|
||||
*
|
||||
* 多轮 RAG 的检索卡片已经画在思考过程里,正文下面不再重复一份来源列表。
|
||||
*/
|
||||
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 max-w-5xl">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export 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 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({
|
||||
pageContext,
|
||||
}: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { CheckIcon, PenLineIcon, SendIcon, XIcon } from "lucide-react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
||||
import {
|
||||
@@ -12,10 +12,6 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
|
||||
import {
|
||||
DOCUMENT_REWRITE_STARTED_EVENT,
|
||||
useDocumentRewritePresentation,
|
||||
} from "@/core/document-rewrite/context";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
@@ -31,17 +27,13 @@ import {
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import type { Subtask } from "@/core/tasks";
|
||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
@@ -51,7 +43,6 @@ import { StreamingIndicator } from "../streaming-indicator";
|
||||
|
||||
import { ReferenceBatchesContext, ThreadContext } from "./context";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
|
||||
import { MessageGroup } from "./message-group";
|
||||
import { MessageListItem } from "./message-list-item";
|
||||
import {
|
||||
@@ -93,22 +84,6 @@ function ScrollToBottomOnUserMessage({
|
||||
}
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDocumentRewriteStarted = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ threadId?: string }>).detail;
|
||||
if (detail?.threadId !== threadId) return;
|
||||
// The presentation card is inserted by a context update in the same
|
||||
// interaction. Two frames let React commit it before the smooth scroll.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
void scrollToBottom("smooth");
|
||||
});
|
||||
});
|
||||
};
|
||||
window.addEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
|
||||
return () => window.removeEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
|
||||
}, [scrollToBottom, threadId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -122,26 +97,18 @@ export function MessageList({
|
||||
tokenUsageEnabled = false,
|
||||
extraRehypePlugins,
|
||||
scrollButtonClassName,
|
||||
onClarificationSubmit,
|
||||
onEditResubmit,
|
||||
onEditSave,
|
||||
messageContentOverrides,
|
||||
externallyHiddenMessageIds,
|
||||
onSetupAgentApprove,
|
||||
onSetupAgentReject,
|
||||
onSetupWritingApprove,
|
||||
onSetupWritingReject,
|
||||
onSetupWritingStart,
|
||||
onSetupReportStructureApprove,
|
||||
onSetupReportStructureReject,
|
||||
researchApprovalSlot,
|
||||
taskReportImportSlot,
|
||||
businessCardSlot,
|
||||
speakerShell,
|
||||
onOpenArtifact,
|
||||
onDownloadArtifact,
|
||||
afterDocumentRewriteItems,
|
||||
}: {
|
||||
onClarificationSubmit,
|
||||
onEditResubmit,
|
||||
onEditSave,
|
||||
messageContentOverrides,
|
||||
externallyHiddenMessageIds,
|
||||
onSetupAgentApprove,
|
||||
onSetupAgentReject,
|
||||
onSetupWritingApprove,
|
||||
onSetupWritingReject,
|
||||
onSetupWritingStart,
|
||||
onOpenArtifact,
|
||||
}: {
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
fitMessageRowsToContent?: boolean;
|
||||
@@ -152,58 +119,25 @@ export function MessageList({
|
||||
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
|
||||
scrollButtonClassName?: string;
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||
messageContentOverrides?: Record<string, string>;
|
||||
externallyHiddenMessageIds?: Set<string>;
|
||||
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||
messageContentOverrides?: Record<string, string>;
|
||||
externallyHiddenMessageIds?: Set<string>;
|
||||
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
|
||||
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
onSetupReportStructureApprove?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
|
||||
onSetupReportStructureReject?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
|
||||
/**
|
||||
* 深度研究的交互卡插槽(报告配置、计划确认、失败重试):workbench 向
|
||||
* displayThread 注入带 `additional_kwargs.setup_research_approval` 的虚拟 ai
|
||||
* 消息。撰写步骤和完成摘要不走此插槽,而是分别复用原生 tool-call 步骤条和
|
||||
* assistant 消息行;不传该 slot 时普通对话/AI 写作零行为变化。
|
||||
*/
|
||||
researchApprovalSlot?: (message: Message) => React.ReactNode;
|
||||
/**
|
||||
* agentfx「入库」协助卡插槽:任务工作区宿主(AgentChatPage taskBind)向 displayThread
|
||||
* 注入带 `additional_kwargs.task_report_import_approval` 的虚拟 ai 消息,由本插槽渲染
|
||||
* 「是否入库」卡片。不传该 slot 时(普通对话等)该分组不渲染,零行为变化。
|
||||
*/
|
||||
taskReportImportSlot?: (message: Message) => React.ReactNode;
|
||||
/**
|
||||
* 通用业务卡插槽(报告协作工作台等):宿主向 displayThread 注入带
|
||||
* `additional_kwargs.report_collaboration_card` 的虚拟 ai 消息(方案组 /
|
||||
* 命令确认 / 报告入口),由本插槽渲染。不传该 slot 时(普通对话等)该
|
||||
* 分组不渲染,零行为变化;卡 payload 结构由各页面在 message-adapter
|
||||
* 层定义,MessageList 不感知具体业务字段。
|
||||
*/
|
||||
businessCardSlot?: (message: Message) => React.ReactNode;
|
||||
/**
|
||||
* 可选说话人壳(报告协作对齐会商第二步):包住 assistant / processing 分组。
|
||||
* 不传则问答页零变化。
|
||||
*/
|
||||
speakerShell?: (messages: Message[], children: ReactNode) => ReactNode;
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
/** Host override for downloading virtual/session-owned files in a present-files card. */
|
||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||
/** Optional host content placed immediately after document-rewrite timelines. */
|
||||
afterDocumentRewriteItems?: React.ReactNode;
|
||||
}) {
|
||||
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
}) {
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const rehypePlugins = useMemo(
|
||||
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
||||
[baseRehypePlugins, extraRehypePlugins],
|
||||
);
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const documentRewritePresentation = useDocumentRewritePresentation();
|
||||
const { t } = useI18n();
|
||||
const threadContext = useContext(ThreadContext);
|
||||
// Read-only contexts (public share, mock previews) must not offer deletion.
|
||||
@@ -229,34 +163,33 @@ export function MessageList({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const allMessages = useMemo(() => {
|
||||
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
|
||||
return thread.messages
|
||||
.filter((m) => !isHiddenFromUIMessage(m))
|
||||
.map((message) => {
|
||||
const override =
|
||||
hasOverrides && message.id
|
||||
? messageContentOverrides?.[message.id]
|
||||
: undefined;
|
||||
return override === undefined
|
||||
? message
|
||||
: ({ ...message, content: override } as Message);
|
||||
});
|
||||
}, [messageContentOverrides, thread.messages]);
|
||||
const messages = useMemo(
|
||||
() => {
|
||||
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
|
||||
return hiddenMessageIds.size === 0 && externalHidden.size === 0
|
||||
? allMessages
|
||||
: allMessages.filter(
|
||||
(m) =>
|
||||
!m.id ||
|
||||
(!hiddenMessageIds.has(m.id) && !externalHidden.has(m.id)),
|
||||
);
|
||||
},
|
||||
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
||||
);
|
||||
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
|
||||
const allMessages = useMemo(() => {
|
||||
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
|
||||
return thread.messages
|
||||
.filter((m) => !isHiddenFromUIMessage(m))
|
||||
.map((message) => {
|
||||
const override =
|
||||
hasOverrides && message.id
|
||||
? messageContentOverrides?.[message.id]
|
||||
: undefined;
|
||||
return override === undefined
|
||||
? message
|
||||
: ({ ...message, content: override } as Message);
|
||||
});
|
||||
}, [messageContentOverrides, thread.messages]);
|
||||
const messages = useMemo(
|
||||
() => {
|
||||
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
|
||||
return hiddenMessageIds.size === 0 && externalHidden.size === 0
|
||||
? allMessages
|
||||
: allMessages.filter(
|
||||
(m) =>
|
||||
!m.id ||
|
||||
(!hiddenMessageIds.has(m.id) && !externalHidden.has(m.id)),
|
||||
);
|
||||
},
|
||||
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
||||
);
|
||||
|
||||
const askTimes = useChatAskTimes();
|
||||
|
||||
@@ -301,40 +234,13 @@ export function MessageList({
|
||||
return map;
|
||||
}, [messages, askTimes]);
|
||||
|
||||
// Associate each assistant answer with the nearest preceding user question.
|
||||
// This powers the per-answer Word menu without changing message persistence.
|
||||
const questionMarkdownByAssistantId = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
let currentQuestion = "";
|
||||
for (const message of messages) {
|
||||
if (message.type === "human") {
|
||||
currentQuestion = extractContentFromMessage(message)?.trim() ?? "";
|
||||
} else if (message.type === "ai" && message.id && currentQuestion) {
|
||||
map.set(message.id, currentQuestion);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [messages]);
|
||||
|
||||
const [settings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
const knowledgeReferencesEnabled =
|
||||
threadContext?.knowledgeReferencesEnabled === true;
|
||||
const ragModeEnabled =
|
||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
knowledgeReferencesEnabled;
|
||||
const hasSingleRoundRag = useMemo(
|
||||
() => messages.some((message) => isSingleRoundRagMessage(message)),
|
||||
[messages],
|
||||
);
|
||||
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
const { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[referencesEnabled, skills],
|
||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[ragModeEnabled, skills],
|
||||
);
|
||||
// Same per-turn reference batches the right-side panel renders (backend
|
||||
// batches preferred). Inline [n] tokens must resolve against these so badge
|
||||
@@ -342,74 +248,6 @@ export function MessageList({
|
||||
// frontend tool-message scan below comes up empty (e.g. after a reload).
|
||||
const referenceBatches = useContext(ReferenceBatchesContext);
|
||||
|
||||
// 单轮 RAG 的参考资料存放在一条 hide_from_ui 的 tool 消息里(紧挨在
|
||||
// assistant 消息之前)。allMessages 会把 hide_from_ui 消息过滤掉,导致通用
|
||||
// 的 collectRuntimeDisplaySources 扫描不到它们。这里直接在原始消息流里按
|
||||
// assistant 消息提取其对应的参考资料,保证 `[1]` 等编号能解析成可点击引用。
|
||||
const frontendRagSourcesByAiId = useMemo(() => {
|
||||
const map = new Map<string, RuntimeDisplaySource[]>();
|
||||
const raw = thread.messages;
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const msg = raw[i];
|
||||
if (!msg || msg.type !== "ai" || !msg.id) continue;
|
||||
const kwargs = msg.additional_kwargs;
|
||||
if (!kwargs || kwargs.single_round_rag !== true) {
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
const results = extractFrontendRagResults(prev.content);
|
||||
if (results && results.length > 0) {
|
||||
const toolName = String(
|
||||
(prev as { name?: unknown }).name ?? "knowledge_base_search",
|
||||
);
|
||||
sources = results.map((item, index) =>
|
||||
toRuntimeDisplaySource(item, index + 1, toolName),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
map.set(msg.id, sources);
|
||||
}
|
||||
return map;
|
||||
}, [thread.messages]);
|
||||
|
||||
const resolveDisplaySources = useCallback(
|
||||
(msg: Message | undefined): RuntimeDisplaySource[] => {
|
||||
if (!referencesEnabled || !msg || msg.type !== "ai") return [];
|
||||
if (isMultiRoundRagMessage(msg)) return [];
|
||||
const batchSources =
|
||||
referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
|
||||
?.sources ?? [];
|
||||
const frontendRagSources = msg.id
|
||||
? frontendRagSourcesByAiId.get(msg.id) ?? []
|
||||
: [];
|
||||
if (batchSources.length > 0) return batchSources;
|
||||
if (frontendRagSources.length > 0) return frontendRagSources;
|
||||
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
|
||||
beforeMessageId: msg.id,
|
||||
includeBeforeMessage: true,
|
||||
});
|
||||
},
|
||||
[
|
||||
frontendRagSourcesByAiId,
|
||||
messages,
|
||||
referenceBatches,
|
||||
referencesEnabled,
|
||||
skillDisplayMap,
|
||||
],
|
||||
);
|
||||
|
||||
// Track which clarification messages are still awaiting an answer.
|
||||
// Pending = clarification tool messages that appear after the last human message
|
||||
// (no user reply has been sent for them yet).
|
||||
@@ -526,35 +364,31 @@ export function MessageList({
|
||||
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-(--container-width-lg)",
|
||||
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
|
||||
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
||||
{(() => {
|
||||
const rewriteItemsAboveFile = documentRewriteItems.filter(
|
||||
(item) => item.operation !== "generate",
|
||||
);
|
||||
const rewriteItemsBelowFile = documentRewriteItems.filter(
|
||||
(item) => item.operation === "generate",
|
||||
);
|
||||
let didRenderRewriteAboveFile = false;
|
||||
const grouped = groupMessages(messages, (group) => {
|
||||
{groupMessages(messages, (group) => {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
const items = 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;
|
||||
return group.messages.map((msg) => {
|
||||
const isAssistantMessage =
|
||||
referencesEnabled &&
|
||||
ragModeEnabled &&
|
||||
group.type === "assistant" &&
|
||||
msg.type === "ai";
|
||||
const batchSources = isAssistantMessage
|
||||
? referenceBatches.find(
|
||||
(batch) => batch.assistant_message_id === msg.id,
|
||||
)?.sources ?? []
|
||||
: [];
|
||||
const displaySources = isAssistantMessage
|
||||
? resolveDisplaySources(msg)
|
||||
? batchSources.length > 0
|
||||
? batchSources
|
||||
: collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
|
||||
beforeMessageId: msg.id,
|
||||
includeBeforeMessage: true,
|
||||
})
|
||||
: [];
|
||||
return (
|
||||
<MessageListItem
|
||||
@@ -566,36 +400,20 @@ export function MessageList({
|
||||
: undefined
|
||||
}
|
||||
message={msg}
|
||||
questionMarkdown={
|
||||
msg.id ? questionMarkdownByAssistantId.get(msg.id) : undefined
|
||||
}
|
||||
isLoading={
|
||||
isMultiRoundRagMessage(msg)
|
||||
? thread.isLoading &&
|
||||
msg.id === messages[messages.length - 1]?.id
|
||||
: messageIsLoading
|
||||
}
|
||||
isLoading={thread.isLoading}
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onEditSave={onEditSave}
|
||||
onMessageDeleted={
|
||||
allowDelete ? handleMessageDeleted : undefined
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onEditSave={onEditSave}
|
||||
onMessageDeleted={
|
||||
allowDelete ? handleMessageDeleted : undefined
|
||||
}
|
||||
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
|
||||
/>
|
||||
);
|
||||
});
|
||||
if (speakerShell && group.type === "assistant") {
|
||||
return (
|
||||
<div key={group.id} className="w-full">
|
||||
{speakerShell(group.messages, items)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return items;
|
||||
} else if (group.type === "assistant:clarification") {
|
||||
const message = group.messages[0];
|
||||
if (message && hasContent(message)) {
|
||||
@@ -677,48 +495,6 @@ export function MessageList({
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:setup-report-structure-approval") {
|
||||
const message = group.messages[0];
|
||||
return message ? (
|
||||
<div key={group.id} className="w-full">
|
||||
<SetupReportStructureApprovalCard
|
||||
disabled={thread.isLoading}
|
||||
message={message}
|
||||
onApprove={onSetupReportStructureApprove}
|
||||
onReject={onSetupReportStructureReject}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={thread.isLoading}
|
||||
messages={group.messages}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:research-approval") {
|
||||
// 深度研究交互卡(配置/计划确认/失败重试):宿主不传渲染函数时该
|
||||
// 分组不渲染。撰写步骤和完成摘要会经过普通的 processing/assistant 分组。
|
||||
const message = group.messages[0];
|
||||
return researchApprovalSlot && message ? (
|
||||
<div key={group.id} className="w-full pb-2">
|
||||
{researchApprovalSlot(message)}
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:task-report-import") {
|
||||
// agentfx「入库」协助卡:宿主不传渲染函数时该分组不渲染。
|
||||
const message = group.messages[0];
|
||||
return taskReportImportSlot && message ? (
|
||||
<div key={group.id} className="w-full pb-2">
|
||||
{taskReportImportSlot(message)}
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:business-card") {
|
||||
// 通用业务卡(报告协作):宿主不传渲染函数时该分组不渲染。
|
||||
const message = group.messages[0];
|
||||
return businessCardSlot && message ? (
|
||||
<div key={group.id} className="w-full pb-2">
|
||||
{businessCardSlot(message)}
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:present-files") {
|
||||
const files: string[] = [];
|
||||
for (const message of group.messages) {
|
||||
@@ -727,15 +503,8 @@ export function MessageList({
|
||||
files.push(...presentFiles);
|
||||
}
|
||||
}
|
||||
const rewriteAbove = !didRenderRewriteAboveFile
|
||||
? rewriteItemsAboveFile
|
||||
: [];
|
||||
didRenderRewriteAboveFile = true;
|
||||
return (
|
||||
<div className="w-full" key={group.id}>
|
||||
{rewriteAbove.map((item) => (
|
||||
<DocumentRewriteTimeline key={`rewrite-above-${item.id}`} item={item} />
|
||||
))}
|
||||
{group.messages[0] && hasContent(group.messages[0]) && (
|
||||
<MarkdownContent
|
||||
content={extractContentFromMessage(group.messages[0])}
|
||||
@@ -744,12 +513,11 @@ export function MessageList({
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
<ArtifactFileList
|
||||
files={files}
|
||||
threadId={threadId}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
onDownloadArtifact={onDownloadArtifact}
|
||||
/>
|
||||
<ArtifactFileList
|
||||
files={files}
|
||||
threadId={threadId}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={thread.isLoading}
|
||||
@@ -817,8 +585,6 @@ export function MessageList({
|
||||
key={"thinking-group-" + message.id}
|
||||
messages={[message]}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(message)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -862,16 +628,11 @@ export function MessageList({
|
||||
message.type === "ai" &&
|
||||
(hasToolCalls(message) ? true : !hasContent(message)),
|
||||
);
|
||||
const processingAi = [...group.messages]
|
||||
.reverse()
|
||||
.find((message) => message.type === "ai");
|
||||
const processingBody = (
|
||||
<div className="w-full">
|
||||
return (
|
||||
<div key={"group-" + group.id} className="w-full">
|
||||
<MessageGroup
|
||||
messages={group.messages}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(processingAi)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
@@ -880,25 +641,7 @@ export function MessageList({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div key={"group-" + group.id} className="w-full">
|
||||
{speakerShell ? speakerShell(group.messages, processingBody) : processingBody}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
const leftoverRewriteItems = didRenderRewriteAboveFile
|
||||
? rewriteItemsBelowFile
|
||||
: documentRewriteItems;
|
||||
return (
|
||||
<>
|
||||
{grouped}
|
||||
{leftoverRewriteItems.map((item) => (
|
||||
<DocumentRewriteTimeline key={item.id} item={item} />
|
||||
))}
|
||||
{afterDocumentRewriteItems}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
})}
|
||||
{thread.isLoading && <StreamingIndicator className="my-4" />}
|
||||
<div style={{ height: `${paddingBottom}px` }} />
|
||||
</MessageMetricsProvider>
|
||||
@@ -956,15 +699,6 @@ export type SetupWritingApprovalPayload = {
|
||||
outline?: string;
|
||||
};
|
||||
|
||||
export type SetupReportStructureApprovalPayload = {
|
||||
title?: string;
|
||||
name?: string;
|
||||
content?: string;
|
||||
structure_mode?: string;
|
||||
retrieval_directions?: string[];
|
||||
retrieval_skills?: string[];
|
||||
};
|
||||
|
||||
function findPreviousHumanMessageId(
|
||||
messages: Message[],
|
||||
beforeMessageId?: string,
|
||||
@@ -983,46 +717,6 @@ function findPreviousHumanMessageId(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 从单轮/多轮 RAG 的 tool 消息体里取出 `content.results`。 */
|
||||
function extractFrontendRagResults(content: unknown): unknown[] | null {
|
||||
if (content && typeof content === "object" && !Array.isArray(content)) {
|
||||
const results = (content as { results?: unknown }).results;
|
||||
if (Array.isArray(results)) return results;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 把单轮/多轮 RAG 的一条参考资料归一化为展示用的 RuntimeDisplaySource。 */
|
||||
function toRuntimeDisplaySource(
|
||||
item: unknown,
|
||||
index: number,
|
||||
skillName: string,
|
||||
): RuntimeDisplaySource {
|
||||
const record =
|
||||
item && typeof item === "object" && !Array.isArray(item)
|
||||
? (item as Record<string, unknown>)
|
||||
: {};
|
||||
const raw =
|
||||
(record.raw as Record<string, unknown> | undefined) ?? record;
|
||||
const content = String(record.content ?? record.snippet ?? "");
|
||||
return {
|
||||
id: typeof record.id === "string" && record.id ? record.id : undefined,
|
||||
title: String(record.title ?? `参考资料 ${index}`),
|
||||
type: String(record.type ?? "result"),
|
||||
snippet: String(record.snippet ?? content),
|
||||
content,
|
||||
source:
|
||||
typeof record.source === "string" && record.source ? record.source : undefined,
|
||||
author: undefined,
|
||||
time: typeof record.time === "string" && record.time ? record.time : undefined,
|
||||
url: typeof record.url === "string" && record.url ? record.url : undefined,
|
||||
raw,
|
||||
index,
|
||||
skillName,
|
||||
mode: "citation",
|
||||
};
|
||||
}
|
||||
|
||||
function getSetupAgentApprovalPayload(
|
||||
message: unknown,
|
||||
): SetupAgentApprovalPayload | null {
|
||||
@@ -1335,147 +1029,6 @@ function SetupWritingApprovalCard({
|
||||
);
|
||||
}
|
||||
|
||||
function getSetupReportStructureApprovalPayload(
|
||||
message: unknown,
|
||||
): SetupReportStructureApprovalPayload | null {
|
||||
if (typeof message !== "object" || message === null) {
|
||||
return null;
|
||||
}
|
||||
const additionalKwargs = Reflect.get(message, "additional_kwargs");
|
||||
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
|
||||
return null;
|
||||
}
|
||||
const approval = Reflect.get(additionalKwargs, "setup_report_structure_approval");
|
||||
if (typeof approval !== "object" || approval === null) {
|
||||
return null;
|
||||
}
|
||||
return approval as SetupReportStructureApprovalPayload;
|
||||
}
|
||||
|
||||
const STRUCTURE_MODE_LABELS: Record<string, string> = {
|
||||
fixed: "固定大纲",
|
||||
adaptive: "弹性大纲",
|
||||
};
|
||||
|
||||
function SetupReportStructureApprovalCard({
|
||||
disabled,
|
||||
message,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
message: unknown;
|
||||
onApprove?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
|
||||
onReject?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
|
||||
}) {
|
||||
const payload = getSetupReportStructureApprovalPayload(message);
|
||||
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(null);
|
||||
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const approvalPayload = payload;
|
||||
const busy = disabled || submitting !== null;
|
||||
const directions = approvalPayload.retrieval_directions ?? [];
|
||||
const skills = approvalPayload.retrieval_skills ?? [];
|
||||
|
||||
async function run(action: "approve" | "reject") {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
setSubmitting(action);
|
||||
try {
|
||||
if (action === "approve") {
|
||||
await onApprove?.(approvalPayload);
|
||||
} else {
|
||||
await onReject?.(approvalPayload);
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs font-medium">报告结构建议</div>
|
||||
<div className="text-sm font-medium">确认后将应用到右侧表单,再点保存入库。</div>
|
||||
</div>
|
||||
|
||||
{approvalPayload.title ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">标题</div>
|
||||
<p className="text-sm font-medium">{approvalPayload.title}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{approvalPayload.name ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">描述</div>
|
||||
<p className="text-sm leading-6">{approvalPayload.name}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1.5">
|
||||
{approvalPayload.structure_mode ? (
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-muted-foreground text-xs">结构约束</div>
|
||||
<p className="text-sm font-medium">
|
||||
{STRUCTURE_MODE_LABELS[approvalPayload.structure_mode] ?? approvalPayload.structure_mode}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{directions.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-muted-foreground text-xs">检索方向</div>
|
||||
<p className="text-sm font-medium">{directions.join("、")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{skills.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-muted-foreground text-xs">检索来源</div>
|
||||
<p className="text-sm font-medium">{skills.join("、")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{approvalPayload.content ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">报告大纲</div>
|
||||
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
|
||||
{approvalPayload.content}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy || !onReject}
|
||||
onClick={() => void run("reject")}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
忽略
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={busy || !onApprove}
|
||||
onClick={() => void run("approve")}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
应用到表单
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getClarificationPayload(message: unknown): ClarificationPayload | null {
|
||||
if (typeof message !== "object" || message === null) {
|
||||
return null;
|
||||
|
||||
@@ -1,472 +0,0 @@
|
||||
import {
|
||||
BookOpenIcon,
|
||||
ChevronDownIcon,
|
||||
FileTextIcon,
|
||||
Loader2Icon,
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import {
|
||||
Message as AIElementMessage,
|
||||
MessageContent as AIElementMessageContent,
|
||||
} from "@/components/ai-elements/message";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import type { MessageResponseProps } from "@/components/ai-elements/message";
|
||||
import type {
|
||||
MultiRoundRagChatMessage,
|
||||
MultiRoundRagRetrieval,
|
||||
MultiRoundRagRound,
|
||||
MultiRoundRagTrace,
|
||||
} from "@/core/threads/use-multi-round-rag";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
|
||||
export function MultiRoundRagMessageList({
|
||||
className,
|
||||
messages,
|
||||
isLoading,
|
||||
paddingBottom = 160,
|
||||
}: {
|
||||
className?: string;
|
||||
messages: MultiRoundRagChatMessage[];
|
||||
isLoading: boolean;
|
||||
paddingBottom?: number;
|
||||
}) {
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
|
||||
return (
|
||||
<Conversation className={cn("flex size-full flex-col justify-center", className)}>
|
||||
<ConversationContent
|
||||
className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-10"
|
||||
>
|
||||
{messages.map((message, index) => {
|
||||
const isLastAssistant =
|
||||
message.role === "assistant" && index === messages.length - 1;
|
||||
return (
|
||||
<AIElementMessage
|
||||
key={message.id}
|
||||
className="group/conversation-message relative w-full"
|
||||
from={message.role === "user" ? "user" : "assistant"}
|
||||
>
|
||||
{message.role === "user" ? (
|
||||
<AIElementMessageContent className="w-fit">
|
||||
<p className="whitespace-pre-wrap text-[15px] leading-7">
|
||||
{message.content}
|
||||
</p>
|
||||
</AIElementMessageContent>
|
||||
) : (
|
||||
<AIElementMessageContent className="w-full">
|
||||
<MultiRoundRagAssistantBody
|
||||
content={
|
||||
typeof message.content === "string" ? message.content : ""
|
||||
}
|
||||
thinkingTrace={message.thinkingTrace}
|
||||
isLoading={isLastAssistant && isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
/>
|
||||
</AIElementMessageContent>
|
||||
)}
|
||||
</AIElementMessage>
|
||||
);
|
||||
})}
|
||||
<div style={{ height: `${paddingBottom}px` }} />
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton
|
||||
className="pointer-events-auto z-20 size-9 shadow-md bottom-40"
|
||||
aria-label="回到最新"
|
||||
title="回到最新"
|
||||
/>
|
||||
</Conversation>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiRoundRagAssistantBody({
|
||||
content,
|
||||
thinkingTrace,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
}: {
|
||||
content: string;
|
||||
thinkingTrace?: MultiRoundRagTrace | null;
|
||||
isLoading: boolean;
|
||||
rehypePlugins: MessageResponseProps["rehypePlugins"];
|
||||
}) {
|
||||
const [selected, setSelected] = useState<MultiRoundRagRetrieval | null>(
|
||||
null,
|
||||
);
|
||||
const trace = thinkingTrace;
|
||||
const answer = trace?.finalAnswerMarkdown || content;
|
||||
|
||||
return (
|
||||
<>
|
||||
{trace && (
|
||||
<MultiRoundRagTraceView
|
||||
trace={trace}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
onSelectRetrieval={setSelected}
|
||||
/>
|
||||
)}
|
||||
<section className="mt-4">
|
||||
<div className="text-foreground mb-2 text-sm font-medium">最终回答</div>
|
||||
{answer ? (
|
||||
<MarkdownContent
|
||||
content={answer}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
className="my-0 text-[15px] leading-7"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在生成思考过程与检索内容...
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<RetrievalDetailDialog
|
||||
item={selected}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelected(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiRoundRagTraceView({
|
||||
trace,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
onSelectRetrieval,
|
||||
}: {
|
||||
trace: MultiRoundRagTrace;
|
||||
isLoading: boolean;
|
||||
rehypePlugins: MessageResponseProps["rehypePlugins"];
|
||||
onSelectRetrieval: (item: MultiRoundRagRetrieval) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(!trace.thinkingComplete);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="not-prose w-full min-w-0 max-w-5xl"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors"
|
||||
>
|
||||
{trace.thinkingComplete ? (
|
||||
<SparklesIcon className="text-primary size-4 shrink-0" />
|
||||
) : (
|
||||
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
|
||||
)}
|
||||
<span>{trace.title || "思考过程"}</span>
|
||||
<span className="text-muted-foreground/70 text-xs">
|
||||
共 {trace.rounds.length} 轮推理与检索
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn("ml-auto size-4 transition-transform", !open && "-rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-3 space-y-3">
|
||||
{trace.rounds.map((round, index) => (
|
||||
<div
|
||||
key={round.id}
|
||||
className={cn("relative pl-5", index < trace.rounds.length - 1 && "pb-1")}
|
||||
>
|
||||
{index < trace.rounds.length - 1 && (
|
||||
<span className="bg-primary/25 absolute bottom-0 left-[7px] top-8 w-px" />
|
||||
)}
|
||||
<span className="border-primary bg-background absolute left-0 top-4 size-3.5 rounded-full border-2" />
|
||||
<MultiRoundRagRoundCard
|
||||
index={index + 1}
|
||||
round={round}
|
||||
isLoading={isLoading}
|
||||
isActiveRound={isLoading && index === trace.rounds.length - 1}
|
||||
rehypePlugins={rehypePlugins}
|
||||
onSelectRetrieval={onSelectRetrieval}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiRoundRagRoundCard({
|
||||
index,
|
||||
round,
|
||||
isLoading,
|
||||
isActiveRound,
|
||||
rehypePlugins,
|
||||
onSelectRetrieval,
|
||||
}: {
|
||||
index: number;
|
||||
round: MultiRoundRagRound;
|
||||
isLoading: boolean;
|
||||
isActiveRound: boolean;
|
||||
rehypePlugins: MessageResponseProps["rehypePlugins"];
|
||||
onSelectRetrieval: (item: MultiRoundRagRetrieval) => void;
|
||||
}) {
|
||||
const query = round.query.trim();
|
||||
const searching = Boolean(query && round.queryLoading);
|
||||
const showQuery = Boolean(query);
|
||||
const showRetrievals = showQuery || round.retrievals.length > 0;
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<article className="overflow-hidden rounded-lg border border-border/70 bg-background">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/40 flex min-h-11 w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors"
|
||||
>
|
||||
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-medium">
|
||||
{index}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{round.title}</span>
|
||||
{searching ? (
|
||||
<span className="text-primary inline-flex items-center gap-1 text-[11px]">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
检索中
|
||||
</span>
|
||||
) : isActiveRound ? null : (
|
||||
<span className="text-muted-foreground text-[11px]">已完成</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className={cn("text-muted-foreground size-4 shrink-0 transition-transform", !open && "-rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border/60 space-y-3 border-t px-3 pb-3 pt-3">
|
||||
{round.thoughtMarkdown && (
|
||||
<section>
|
||||
<div className="text-muted-foreground mb-1.5 flex items-center gap-2 text-sm font-medium">
|
||||
<SparklesIcon className="size-4" />
|
||||
思考内容
|
||||
</div>
|
||||
<MarkdownContent
|
||||
content={round.thoughtMarkdown}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
className="text-muted-foreground my-0 text-sm leading-6"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{showQuery && (
|
||||
<div
|
||||
className="border-primary/20 bg-primary/5 text-primary inline-flex max-w-full items-center gap-2 rounded-full border px-3 py-1.5"
|
||||
aria-live={searching ? "polite" : undefined}
|
||||
>
|
||||
{searching ? (
|
||||
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<SearchIcon className="size-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="text-primary/80 shrink-0 text-xs">
|
||||
{round.queryTitle || "检索词"}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-xs font-medium">
|
||||
{query}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showRetrievals && (
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<FileTextIcon className="text-muted-foreground size-4" />
|
||||
检索内容
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
{round.retrievals.length} 条
|
||||
</span>
|
||||
</div>
|
||||
{searching ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 rounded-lg border border-border/70 px-3 py-3 text-sm">
|
||||
<Loader2Icon className="text-primary size-4 animate-spin" />
|
||||
正在召回相关内容...
|
||||
</div>
|
||||
) : (
|
||||
<RetrievalCardList
|
||||
items={round.retrievals}
|
||||
onSelect={onSelectRetrieval}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{(round.extractInfo || round.extractContent || round.extractLoading) && (
|
||||
<section className="rounded-lg border border-border/70 px-3 py-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<BookOpenIcon className="text-muted-foreground size-4" />
|
||||
阅读内容
|
||||
{round.extractLoading && <Loader2Icon className="text-primary size-3.5 animate-spin" />}
|
||||
</div>
|
||||
{round.extractInfo && (
|
||||
<p className="text-muted-foreground text-sm leading-6">{round.extractInfo}</p>
|
||||
)}
|
||||
{round.extractContent && (
|
||||
<p className="mt-2 text-sm leading-6 whitespace-pre-wrap">{round.extractContent}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{round.textidContent && (
|
||||
<section className="rounded-lg border border-border/70 px-3 py-3 text-sm leading-6 whitespace-pre-wrap">
|
||||
{round.textidContent}
|
||||
</section>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</article>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function RetrievalCardList({
|
||||
items,
|
||||
onSelect,
|
||||
}: {
|
||||
items: MultiRoundRagRetrieval[];
|
||||
onSelect: (item: MultiRoundRagRetrieval) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return <p className="text-muted-foreground py-1 text-sm">本轮未召回相关资料。</p>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<RetrievalCard
|
||||
key={`${item.id ?? item.title}-${index}`}
|
||||
item={item}
|
||||
index={index + 1}
|
||||
defaultOpen={index === 0}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RetrievalCard({
|
||||
item,
|
||||
index,
|
||||
defaultOpen,
|
||||
onSelect,
|
||||
}: {
|
||||
item: MultiRoundRagRetrieval;
|
||||
index: number;
|
||||
defaultOpen: boolean;
|
||||
onSelect: (item: MultiRoundRagRetrieval) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const meta = retrievalMeta(item);
|
||||
|
||||
if (item.type === "user_behavior") {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/70 px-3 py-2 text-sm">
|
||||
<span className="text-muted-foreground mr-2 font-medium">{index}.</span>
|
||||
{item.title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 bg-background">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/40 flex min-h-11 w-full items-center gap-2 px-3 py-2.5 text-left transition-colors"
|
||||
>
|
||||
<span className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-medium">
|
||||
{index}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium" title={item.title}>
|
||||
{item.title}
|
||||
</div>
|
||||
{meta && (
|
||||
<div className="text-muted-foreground truncate text-xs">{meta}</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={cn("text-muted-foreground size-4 shrink-0 transition-transform", !open && "-rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border/60 border-t px-3 pb-3 pt-2.5">
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground line-clamp-3 w-full text-left text-sm leading-6 whitespace-pre-wrap"
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
{item.content || "暂无资料摘要"}
|
||||
</button>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function retrievalMeta(item: MultiRoundRagRetrieval): string {
|
||||
if (item.type === "more") return `时间:${item.time || "-"}`;
|
||||
if (item.type === "task") return `关键词:${item.keywords || "-"}`;
|
||||
if (item.type === "hx") return `平台:${item.platform || "-"} 粉丝数:${item.fans ?? "-"}`;
|
||||
if (
|
||||
item.type === "taiwan_news" ||
|
||||
item.type === "world_news" ||
|
||||
item.type === "messaage_comment" ||
|
||||
item.type === "america_news"
|
||||
) {
|
||||
return `平台:${item.platform || "-"}${item.comment_count ? ` 评论数:${item.comment_count}` : ""}`;
|
||||
}
|
||||
if (item.type === "get_time_data") return `时间:${item.time || "-"} 热度:${item.hot_num ?? "-"}`;
|
||||
return [item.source, item.time].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function RetrievalDetailDialog({
|
||||
item,
|
||||
onOpenChange,
|
||||
}: {
|
||||
item: MultiRoundRagRetrieval | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const content = item?.content;
|
||||
return (
|
||||
<Dialog open={!!item} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
|
||||
<DialogHeader className="pr-8">
|
||||
<DialogTitle className="leading-relaxed">{item?.title || "引用资料"}</DialogTitle>
|
||||
<DialogDescription className="space-y-1 text-xs">
|
||||
{item?.time && <span className="block">时间:{item.time}</span>}
|
||||
{item?.source && <span className="block">来源:{item.source}</span>}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="bg-muted/50 max-h-[min(26rem,calc(100vh-16rem))] overflow-auto rounded-xl p-3 text-sm leading-7 whitespace-pre-wrap">
|
||||
{content || "暂无引用内容"}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -46,23 +46,20 @@ function getModeDescriptionKey(
|
||||
}
|
||||
}
|
||||
|
||||
export function ModeHoverGuide({
|
||||
mode,
|
||||
children,
|
||||
showTitle = true,
|
||||
tooltipContent,
|
||||
}: {
|
||||
mode: AgentMode;
|
||||
children: React.ReactNode;
|
||||
/** When true, tooltip shows "ModeName: Description". When false, only description. */
|
||||
showTitle?: boolean;
|
||||
/** Overrides the standard mode tooltip for page-specific execution modes. */
|
||||
tooltipContent?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const label = t.inputBox[getModeLabelKey(mode)];
|
||||
const description = t.inputBox[getModeDescriptionKey(mode)];
|
||||
const content = tooltipContent ?? (showTitle ? `${label}: ${description}` : description);
|
||||
export function ModeHoverGuide({
|
||||
mode,
|
||||
children,
|
||||
showTitle = true,
|
||||
}: {
|
||||
mode: AgentMode;
|
||||
children: React.ReactNode;
|
||||
/** When true, tooltip shows "ModeName: Description". When false, only description. */
|
||||
showTitle?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const label = t.inputBox[getModeLabelKey(mode)];
|
||||
const description = t.inputBox[getModeDescriptionKey(mode)];
|
||||
const content = showTitle ? `${label}: ${description}` : description;
|
||||
|
||||
return <Tooltip content={content}>{children}</Tooltip>;
|
||||
}
|
||||
@@ -25,7 +25,6 @@ export function ModelNameSelect({
|
||||
className,
|
||||
triggerClassName,
|
||||
defaultLabel = "默认模型",
|
||||
title = "选择用于本次操作的大模型(默认使用系统配置的模型)",
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (modelName: string | null) => void;
|
||||
@@ -33,7 +32,6 @@ export function ModelNameSelect({
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
defaultLabel?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const { models } = useModels();
|
||||
return (
|
||||
@@ -44,7 +42,7 @@ export function ModelNameSelect({
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
|
||||
title={title}
|
||||
title="选择用于本次操作的大模型(默认使用系统配置的模型)"
|
||||
>
|
||||
<SelectValue placeholder={defaultLabel} />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { FolderKanban } from "lucide-react";
|
||||
|
||||
import {
|
||||
MY_PROJECT_CONVERSATION_LABELS,
|
||||
type MyProjectConversationType,
|
||||
} from "@/core/threads/project-metadata";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const BADGE_STYLES: Record<MyProjectConversationType, string> = {
|
||||
qa: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-700/70 dark:bg-sky-950/55 dark:text-sky-300",
|
||||
strategy:
|
||||
"border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-700/70 dark:bg-violet-950/55 dark:text-violet-300",
|
||||
solution:
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700/70 dark:bg-emerald-950/55 dark:text-emerald-300",
|
||||
};
|
||||
|
||||
export function MyProjectThreadIcon({
|
||||
type,
|
||||
className,
|
||||
}: {
|
||||
type: MyProjectConversationType;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<FolderKanban
|
||||
aria-label={`我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
|
||||
className={cn("size-3.5 shrink-0 text-violet-500", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MyProjectThreadBadge({
|
||||
type,
|
||||
className,
|
||||
}: {
|
||||
type: MyProjectConversationType;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded border px-1 py-0.5 text-[9px] font-medium leading-none",
|
||||
BADGE_STYLES[type],
|
||||
className,
|
||||
)}
|
||||
title={`来自我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
|
||||
>
|
||||
{MY_PROJECT_CONVERSATION_LABELS[type]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -49,11 +49,7 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import {
|
||||
MyProjectThreadBadge,
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
@@ -67,11 +63,9 @@ import {
|
||||
useToggleThreadFavorite,
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import {
|
||||
isBootstrapThread,
|
||||
isHiddenFromRecentChats,
|
||||
isThreadFavorite,
|
||||
isThreadPinned,
|
||||
listPathOfThread,
|
||||
@@ -199,9 +193,13 @@ export function RecentChatList({
|
||||
);
|
||||
|
||||
// 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页);
|
||||
// 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
|
||||
// 仅排除调度 / 会商类系统会话。
|
||||
const visibleThreads = sortThreadsPinnedFirst(
|
||||
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "roundtable",
|
||||
),
|
||||
);
|
||||
|
||||
if (visibleThreads.length === 0) {
|
||||
@@ -211,10 +209,7 @@ export function RecentChatList({
|
||||
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup
|
||||
className="px-2 pt-3 mt-2 border-t border-sidebar-border/60"
|
||||
data-compact-guide="recent-chats"
|
||||
>
|
||||
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
|
||||
<SidebarGroupLabel
|
||||
className={cn(
|
||||
"text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase select-none flex items-center justify-between transition-colors",
|
||||
@@ -250,8 +245,7 @@ export function RecentChatList({
|
||||
{shownThreads.map((thread) => {
|
||||
const href = listPathOfThread(thread);
|
||||
const isActive = href === pathname;
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const projectType = myProjectConversationTypeOfThread(thread);
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
return (
|
||||
@@ -285,10 +279,8 @@ export function RecentChatList({
|
||||
}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : projectType ? (
|
||||
<MyProjectThreadIcon type={projectType} />
|
||||
) : (
|
||||
compact && (
|
||||
) : (
|
||||
compact && (
|
||||
<MessageSquare
|
||||
className={
|
||||
"size-3.5 shrink-0 " +
|
||||
@@ -302,15 +294,9 @@ export function RecentChatList({
|
||||
{pinned && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
{projectType && (
|
||||
<MyProjectThreadBadge
|
||||
className="mr-3"
|
||||
type={projectType}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { ChevronDown, Loader2, MoreHorizontal, Pencil, Share2, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ShareRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
|
||||
import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
|
||||
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
|
||||
import type { DraftMeta } from "@/roundtable-planning/api/drafts";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
|
||||
const ROUNDTABLE_PATH = "/page/workspace/roundtable/planning";
|
||||
|
||||
function openDraft(
|
||||
draft: DraftMeta,
|
||||
isOnPage: boolean,
|
||||
router: ReturnType<typeof useRouter>,
|
||||
) {
|
||||
if (isOnPage) {
|
||||
// 已在会商页面:直接派事件让页面加载该草稿(仿 AI 写作的 open-session)。
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("roundtable:open-draft", { detail: draft.id }),
|
||||
);
|
||||
} else {
|
||||
// 不在该页面:把草稿 id 暂存到 sessionStorage,跳转后由页面消费。
|
||||
sessionStorage.setItem("roundtable:pending-draft", draft.id);
|
||||
router.push(ROUNDTABLE_PATH);
|
||||
}
|
||||
}
|
||||
|
||||
/** 把草稿的后台作业状态 / 步骤映射成侧边栏小标签(仿页面内 HistoryDropdown)。 */
|
||||
function StatusBadge({ draft }: { draft: DraftMeta }) {
|
||||
if (draft.status === "running") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-primary">
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||
进行中
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (draft.status === "awaiting_input") {
|
||||
return <span className="text-[10px] font-medium text-amber-500">待澄清</span>;
|
||||
}
|
||||
if (draft.status === "error") {
|
||||
return <span className="text-[10px] font-medium text-rose-500">出错</span>;
|
||||
}
|
||||
// 非进行中/待澄清/出错时,展示本次会商所用业务链(无链条 → 自由模式)。
|
||||
return (
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
{draft.chainTitle || "自由模式"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecentRoundtableList() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { drafts, loading, refresh, rename, remove } = useRoundtableDrafts();
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => localStorage.getItem("sidebar:recent-roundtable-collapsed") === "true",
|
||||
);
|
||||
|
||||
// 重命名对话框状态。
|
||||
const [renameId, setRenameId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
// 分享对话框 —— 正在分享的草稿 id。
|
||||
const [shareId, setShareId] = useState<string | null>(null);
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
const next = !collapsed;
|
||||
setCollapsed(next);
|
||||
localStorage.setItem("sidebar:recent-roundtable-collapsed", String(next));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const handleRenameSubmit = useCallback(async () => {
|
||||
const id = renameId;
|
||||
const title = renameValue.trim();
|
||||
if (!id || !title) {
|
||||
setRenameId(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await rename(id, title);
|
||||
toast.success("已重命名");
|
||||
} catch {
|
||||
toast.error("重命名失败,请重试");
|
||||
} finally {
|
||||
setRenameId(null);
|
||||
setRenameValue("");
|
||||
}
|
||||
}, [renameId, renameValue, rename]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (draft: DraftMeta) => {
|
||||
if (!window.confirm("确定删除这条会商记录吗?此操作不可恢复。")) return;
|
||||
try {
|
||||
await remove(draft.id);
|
||||
toast.success("已删除会商记录");
|
||||
} catch {
|
||||
toast.error("删除失败,请重试");
|
||||
}
|
||||
},
|
||||
[remove],
|
||||
);
|
||||
|
||||
if (!loading && drafts.length === 0) return null;
|
||||
|
||||
const isOnPage = pathname === ROUNDTABLE_PATH;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
|
||||
<SidebarGroupLabel
|
||||
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
<span>多智能体会商</span>
|
||||
<ChevronDown
|
||||
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent
|
||||
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
|
||||
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
|
||||
}`}
|
||||
>
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{loading && drafts.length === 0 ? (
|
||||
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
|
||||
) : (
|
||||
drafts.slice(0, 10).map((draft) => (
|
||||
<SidebarMenuItem key={draft.id} className="group/side-menu-item">
|
||||
<SidebarMenuButton
|
||||
className={
|
||||
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
|
||||
"hover:text-foreground hover:bg-sidebar-accent/60"
|
||||
}
|
||||
onClick={() => openDraft(draft, isOnPage, router)}
|
||||
>
|
||||
<div className="flex flex-col items-start min-w-0 w-full">
|
||||
<span className="text-[13px] truncate w-full leading-snug">
|
||||
{draft.title || "未命名草稿"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<StatusBadge draft={draft} />
|
||||
<span className="text-[10px] text-muted-foreground">·</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatRelative(draft.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction
|
||||
showOnHover
|
||||
className="bg-background/50 hover:bg-background top-2"
|
||||
>
|
||||
<MoreHorizontal />
|
||||
<span className="sr-only">更多</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-44 rounded-lg"
|
||||
side="right"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setRenameId(draft.id);
|
||||
setRenameValue(draft.title || "");
|
||||
}}
|
||||
>
|
||||
<Pencil className="text-muted-foreground" />
|
||||
<span>重命名</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setShareId(draft.id)}>
|
||||
<Share2 className="text-muted-foreground" />
|
||||
<span>分享</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => void handleDelete(draft)}>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
<span>删除</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* 重命名对话框 */}
|
||||
<Dialog open={renameId !== null} onOpenChange={(o) => !o && setRenameId(null)}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>重命名会商</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
placeholder="输入新名称"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !isIMEComposing(e)) {
|
||||
e.preventDefault();
|
||||
void handleRenameSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRenameId(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => void handleRenameSubmit()}>保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 分享对话框 */}
|
||||
<ShareRoundtableDraftDialog
|
||||
draftId={shareId}
|
||||
open={shareId !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setShareId(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
formatRelative,
|
||||
STATUS_LABELS,
|
||||
} from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
|
||||
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
|
||||
import type { AIWritingSessionSummary } from "@/open-canvas/api/ai-writing-sessions";
|
||||
|
||||
const AI_WRITING_PATH = "/page/workspace/ai-writing";
|
||||
|
||||
function openSession(
|
||||
session: AIWritingSessionSummary,
|
||||
isOnPage: boolean,
|
||||
router: ReturnType<typeof useRouter>,
|
||||
) {
|
||||
if (isOnPage) {
|
||||
// 已在 AI 写作页面,直接通过自定义事件通知面板
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("ai-writing:open-session", { detail: session }),
|
||||
);
|
||||
} else {
|
||||
// 不在该页面,把 session 存入 sessionStorage 后跳转
|
||||
sessionStorage.setItem("ai-writing:pending-session", JSON.stringify(session));
|
||||
router.push(AI_WRITING_PATH);
|
||||
}
|
||||
}
|
||||
|
||||
export function RecentWritingList() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { sessions, loading, refresh } = useAIWritingSessions();
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => localStorage.getItem("sidebar:recent-writing-collapsed") === "true",
|
||||
);
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
const next = !collapsed;
|
||||
setCollapsed(next);
|
||||
localStorage.setItem("sidebar:recent-writing-collapsed", String(next));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
if (!loading && sessions.length === 0) return null;
|
||||
|
||||
const isOnPage = pathname === AI_WRITING_PATH;
|
||||
|
||||
return (
|
||||
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
|
||||
<SidebarGroupLabel
|
||||
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
<span>最近写作</span>
|
||||
<ChevronDown
|
||||
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent
|
||||
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
|
||||
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
|
||||
}`}
|
||||
>
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{loading && sessions.length === 0 ? (
|
||||
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
|
||||
) : (
|
||||
sessions.slice(0, 10).map((session) => {
|
||||
const { label, color } = STATUS_LABELS[session.status] ?? {
|
||||
label: session.status,
|
||||
color: "text-muted-foreground",
|
||||
};
|
||||
return (
|
||||
<SidebarMenuItem key={session.id} className="group/side-menu-item">
|
||||
<SidebarMenuButton
|
||||
className={
|
||||
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
|
||||
"hover:text-foreground hover:bg-sidebar-accent/60"
|
||||
}
|
||||
onClick={() => openSession(session, isOnPage, router)}
|
||||
>
|
||||
<div className="flex flex-col items-start min-w-0 w-full">
|
||||
<span className="text-[13px] truncate w-full leading-snug">
|
||||
{session.title}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<span className={cn("text-[10px] font-medium", color)}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">·</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatRelative(session.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,6 @@ import type {
|
||||
CompactShortcutRouteParam,
|
||||
} from "@/core/system-settings/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
import {
|
||||
APP_THEME_COLORS,
|
||||
LOGIN_SOURCE_OPTIONS,
|
||||
@@ -78,7 +77,10 @@ const languageOptions: { value: Locale; label: string }[] = [
|
||||
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue";
|
||||
|
||||
function makeShortcutId() {
|
||||
return createClientId("shortcut");
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function newShortcutParam(): CompactShortcutRouteParam {
|
||||
@@ -117,7 +119,7 @@ function normalizeShortcutDraft(
|
||||
? button.route_params
|
||||
: [newShortcutParam()]
|
||||
).map((param) => ({
|
||||
key: param.paramType === "path" ? "" : param.key || "",
|
||||
key: param.key || "",
|
||||
paramType: param.paramType || "login",
|
||||
source: param.source || "access_token",
|
||||
customValue: param.customValue ?? "",
|
||||
@@ -132,18 +134,8 @@ function cleanShortcutParams(
|
||||
params: CompactShortcutRouteParam[],
|
||||
): CompactShortcutRouteParam[] {
|
||||
return params
|
||||
.filter((param) => param.paramType === "path" || param.key.trim())
|
||||
.filter((param) => param.key.trim())
|
||||
.map((param) => {
|
||||
if (param.paramType === "path") {
|
||||
const source = param.source || "access_token";
|
||||
return {
|
||||
key: "",
|
||||
paramType: "path" as const,
|
||||
source,
|
||||
customValue:
|
||||
source === "other" ? (param.customValue ?? "").trim() : undefined,
|
||||
};
|
||||
}
|
||||
const base: CompactShortcutRouteParam = {
|
||||
key: param.key.trim(),
|
||||
paramType: param.paramType,
|
||||
@@ -188,7 +180,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
const globalDefault = appearance?.default_theme ?? "light";
|
||||
const globalSkillView = appearance?.default_skill_view ?? "list";
|
||||
const globalAgentView = appearance?.default_agent_view ?? "card";
|
||||
const globalLayoutMode = appearance?.default_layout_mode ?? "compact";
|
||||
const globalLayoutMode = appearance?.default_layout_mode ?? "full";
|
||||
const globalDefaultLabel = useMemo(() => {
|
||||
const customTheme = (appearance?.custom_themes ?? []).find(
|
||||
(preset) => preset.id === globalDefault,
|
||||
@@ -201,12 +193,10 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
if (globalDefault === "lightyellow") return "浅黄色";
|
||||
return "浅蓝";
|
||||
}, [appearance?.custom_themes, globalDefault]);
|
||||
// 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则内置默认简洁模式)。
|
||||
// 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则会话级回退)。
|
||||
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
setShortcutDraft(
|
||||
@@ -366,13 +356,6 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
return;
|
||||
}
|
||||
for (const param of item.route_params) {
|
||||
if (param.paramType === "path") {
|
||||
if (param.source === "other" && !param.customValue?.trim()) {
|
||||
toast.error("路径参数自定义值不可为空");
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!param.key.trim()) {
|
||||
toast.error("路由参数键名不可为空");
|
||||
return;
|
||||
@@ -567,7 +550,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
title="顶部快捷按钮(管理员)"
|
||||
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停,以及登录、主题和路径参数。"
|
||||
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停和与应用管理一致的登录/主题参数。"
|
||||
>
|
||||
<CompactShortcutSettings
|
||||
buttons={shortcutDraft}
|
||||
@@ -847,45 +830,23 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
aria-label="回答速度指示器"
|
||||
/>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">参考文献模式入口</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||
管理员配置。开启后,普通问答和 iframe 问答输入区会显示「参考文献」按钮;默认关闭。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={referenceModeEntryEnabled}
|
||||
disabled={updateCitationDisplay.isPending}
|
||||
onCheckedChange={(checked) =>
|
||||
updateCitationDisplay.mutate({
|
||||
reference_mode_enabled: checked,
|
||||
})
|
||||
}
|
||||
aria-label="参考文献模式入口"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{referenceModeEntryEnabled && (
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">RAG 引用模式</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.display.ragModeEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings("display", { ragModeEnabled: checked })
|
||||
}
|
||||
aria-label="RAG 引用模式"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">RAG 引用模式</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.display.ragModeEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings("display", { ragModeEnabled: checked })
|
||||
}
|
||||
aria-label="RAG 引用模式"
|
||||
/>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3">
|
||||
<div className="text-sm font-medium">参考文献去除词</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-2xl text-xs leading-relaxed">
|
||||
@@ -956,7 +917,7 @@ function CompactShortcutSettings({
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
已配置 {buttons.length} 个按钮;支持追加登录、主题及路径参数。
|
||||
已配置 {buttons.length} 个按钮;只有启用且填写了跳转链接的按钮会显示。
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onAdd}>
|
||||
@@ -1053,7 +1014,7 @@ function CompactShortcutSettings({
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/20 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<ExternalLink className="size-4" />
|
||||
路由参数
|
||||
@@ -1069,13 +1030,13 @@ function CompactShortcutSettings({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{button.route_params.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed bg-background/70 p-4 text-center text-xs">
|
||||
不追加额外参数。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{button.route_params.map((param, paramIndex) => {
|
||||
{button.route_params.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed bg-background/70 p-4 text-center text-xs">
|
||||
不追加额外参数。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{button.route_params.map((param, paramIndex) => {
|
||||
const themeMap =
|
||||
param.paramType === "theme"
|
||||
? parseThemeMap(param.themeValue)
|
||||
@@ -1085,36 +1046,26 @@ function CompactShortcutSettings({
|
||||
key={`${button.id}-${paramIndex}`}
|
||||
className="space-y-2 rounded-md border bg-background p-2"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2",
|
||||
param.paramType === "path"
|
||||
? "md:grid-cols-[130px_minmax(180px,1fr)_32px]"
|
||||
: "md:grid-cols-[minmax(120px,1fr)_130px_minmax(180px,1.4fr)_32px]",
|
||||
)}
|
||||
>
|
||||
{param.paramType !== "path" && (
|
||||
<Input
|
||||
value={param.key}
|
||||
onChange={(event) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
key: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="参数名 key"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
<div className="grid gap-2 md:grid-cols-[minmax(120px,1fr)_130px_minmax(180px,1.4fr)_32px]">
|
||||
<Input
|
||||
value={param.key}
|
||||
onChange={(event) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
key: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="参数名 key"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Select
|
||||
value={param.paramType}
|
||||
onValueChange={(value) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
paramType: value as "login" | "theme" | "path",
|
||||
key: value === "path" ? "" : param.key,
|
||||
paramType: value as "login" | "theme",
|
||||
source:
|
||||
value === "theme"
|
||||
? undefined
|
||||
: param.source || "access_token",
|
||||
value === "login"
|
||||
? param.source || "access_token"
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -1124,10 +1075,9 @@ function CompactShortcutSettings({
|
||||
<SelectContent>
|
||||
<SelectItem value="login">登录参数</SelectItem>
|
||||
<SelectItem value="theme">主题参数</SelectItem>
|
||||
<SelectItem value="path">路径参数</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(param.paramType === "login" || param.paramType === "path") ? (
|
||||
{param.paramType === "login" ? (
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<Select
|
||||
value={param.source || "access_token"}
|
||||
@@ -1153,11 +1103,6 @@ function CompactShortcutSettings({
|
||||
</Select>
|
||||
{param.source === "other" && (
|
||||
<Input
|
||||
// This config value becomes a query parameter;
|
||||
// it is not a login form field. A password input
|
||||
// makes Chrome invoke password-manager heuristics
|
||||
// whenever the settings dialog opens.
|
||||
type="text"
|
||||
value={param.customValue ?? ""}
|
||||
onChange={(event) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
@@ -1166,8 +1111,6 @@ function CompactShortcutSettings({
|
||||
}
|
||||
placeholder="自定义值"
|
||||
className="w-32 text-xs"
|
||||
autoComplete="off"
|
||||
aria-label="自定义参数值"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1190,12 +1133,6 @@ function CompactShortcutSettings({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{param.paramType === "path" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
不填写参数名,按添加顺序拼到链接或 Hash 路由的路径末尾。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{param.paramType === "theme" && (
|
||||
<div className="grid gap-2 rounded-md bg-muted/40 p-2 md:grid-cols-3">
|
||||
{APP_THEME_COLORS.map((color) => (
|
||||
@@ -1229,9 +1166,9 @@ function CompactShortcutSettings({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
customThemeTextContrast,
|
||||
type CustomThemeAdvancedKey,
|
||||
} from "@/core/theme/custom-theme";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MAX_CUSTOM_THEMES = 12;
|
||||
@@ -128,7 +127,10 @@ const ADVANCED_COLOR_FIELDS: Array<{
|
||||
];
|
||||
|
||||
function makeThemeId(): `custom-theme-${string}` {
|
||||
const suffix = createClientId().replace(/[^a-z0-9-]/gi, "").toLowerCase();
|
||||
const suffix =
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "").toLowerCase()
|
||||
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
||||
return `custom-theme-${suffix}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { BellIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||
import { BellIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -14,19 +14,16 @@ import { AboutSettingsPage } from "@/components/workspace/settings/about-setting
|
||||
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
|
||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
|
||||
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
|
||||
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SettingsSection =
|
||||
| "account"
|
||||
| "appearance"
|
||||
| "memory"
|
||||
| "tools"
|
||||
| "encoder"
|
||||
| "about";
|
||||
| "memory"
|
||||
| "tools"
|
||||
| "about";
|
||||
|
||||
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
||||
defaultSection?: SettingsSection;
|
||||
@@ -34,8 +31,7 @@ type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
||||
|
||||
export function SettingsDialog(props: SettingsDialogProps) {
|
||||
const { defaultSection = "appearance", ...dialogProps } = props;
|
||||
const { t } = useI18n();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const { t } = useI18n();
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<SettingsSection>(defaultSection);
|
||||
|
||||
@@ -67,10 +63,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
// label: t.settings.sections.memory,
|
||||
// icon: BrainIcon,
|
||||
// },
|
||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
...(isAdmin
|
||||
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
|
||||
: []),
|
||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
{
|
||||
id: "account",
|
||||
label: "个人信息",
|
||||
@@ -82,8 +75,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
t.settings.sections.appearance,
|
||||
t.settings.sections.memory,
|
||||
t.settings.sections.tools,
|
||||
t.settings.sections.about,
|
||||
isAdmin,
|
||||
t.settings.sections.about,
|
||||
],
|
||||
);
|
||||
return (
|
||||
@@ -133,8 +125,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
|
||||
)}
|
||||
{activeSection === "memory" && <MemorySettingsPage />}
|
||||
{activeSection === "tools" && <ToolSettingsPage />}
|
||||
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
|
||||
{activeSection === "tools" && <ToolSettingsPage />}
|
||||
{activeSection === "about" && <AboutSettingsPage />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -71,9 +71,8 @@ import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||
import {
|
||||
useDeleteBuiltinSkill,
|
||||
useDeleteCustomSkill,
|
||||
import {
|
||||
useDeleteCustomSkill,
|
||||
useDuplicateSkill,
|
||||
useGenerateMissingSkillNameZh,
|
||||
useGenerateSkillNameZh,
|
||||
@@ -93,7 +92,6 @@ import {
|
||||
useViewMode,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge";
|
||||
import { formatDateTime } from "@/core/utils/datetime";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
@@ -102,16 +100,9 @@ import { cn } from "@/lib/utils";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { SkillStateSheet } from "./skill-state-sheet";
|
||||
|
||||
export function SkillSettingsPage({
|
||||
onClose,
|
||||
embedded = false,
|
||||
}: {
|
||||
onClose?: () => void;
|
||||
embedded?: boolean;
|
||||
} = {}) {
|
||||
const { t } = useI18n();
|
||||
if (embedded) return <SkillSettingsList onClose={onClose} />;
|
||||
return (
|
||||
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t.settings.skills.title}
|
||||
description={t.settings.skills.description}
|
||||
@@ -609,7 +600,6 @@ export function SkillRow({
|
||||
isAdmin,
|
||||
layout = "row",
|
||||
onPublisherClick,
|
||||
knowledgeBindings = [],
|
||||
}: {
|
||||
skill: Skill;
|
||||
currentUserId: string | null;
|
||||
@@ -619,8 +609,6 @@ export function SkillRow({
|
||||
/** Click the 发布者 line to filter the list to that publisher. Receives the
|
||||
* owner_user_id + its desensitized display label. */
|
||||
onPublisherClick?: (publisher: { id: string; label: string }) => void;
|
||||
/** Admin list can inject its single batched binding query here (never N+1). */
|
||||
knowledgeBindings?: SkillKnowledgeBinding[];
|
||||
}) {
|
||||
const isCard = layout === "card";
|
||||
const { t } = useI18n();
|
||||
@@ -628,10 +616,9 @@ export function SkillRow({
|
||||
const { data: cardPublishSquares } = usePublishSquares();
|
||||
const setPinned = useSetSkillPinned();
|
||||
const setFeatured = useSetSkillFeatured();
|
||||
const setAlwaysOn = useSetSkillAlwaysOn();
|
||||
const { data: compression } = useSkillCompressionSettings();
|
||||
const deleteCustom = useDeleteCustomSkill();
|
||||
const deleteBuiltin = useDeleteBuiltinSkill();
|
||||
const setAlwaysOn = useSetSkillAlwaysOn();
|
||||
const { data: compression } = useSkillCompressionSettings();
|
||||
const deleteSkill = useDeleteCustomSkill();
|
||||
const duplicateSkillMutation = useDuplicateSkill();
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [tagPickerOpen, setTagPickerOpen] = useState(false);
|
||||
@@ -647,9 +634,8 @@ export function SkillRow({
|
||||
const isOwned =
|
||||
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
||||
const isFromOthers = isCustom && !isBuiltin && !isOwned;
|
||||
// Admin can manage custom skills from any owner; non-admin only their own.
|
||||
const canManage = isCustom && (isOwned || isAdmin);
|
||||
const canDelete = isAdmin || isOwned;
|
||||
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own.
|
||||
const canManage = isCustom && (isOwned || isAdmin);
|
||||
const canTogglePublish = canManage && !isBuiltin;
|
||||
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
|
||||
const canSetAlwaysOn = isAdmin || isOwned;
|
||||
@@ -700,15 +686,11 @@ export function SkillRow({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
if (isBuiltin) {
|
||||
await deleteBuiltin.mutateAsync(skill.name);
|
||||
} else {
|
||||
await deleteCustom.mutateAsync(skill.name);
|
||||
}
|
||||
toast.success(t.settings.skills.deleteSuccess);
|
||||
setConfirmDeleteOpen(false);
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteSkill.mutateAsync(skill.name);
|
||||
toast.success(t.settings.skills.deleteSuccess);
|
||||
setConfirmDeleteOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -869,20 +851,6 @@ export function SkillRow({
|
||||
常驻
|
||||
</Badge>
|
||||
)}
|
||||
{knowledgeBindings.slice(0, 3).map((binding) => (
|
||||
<Badge
|
||||
key={binding.id}
|
||||
variant="outline"
|
||||
className="border-indigo-500/30 text-indigo-700 dark:text-indigo-300 text-xs"
|
||||
title={`${binding.target_name} · ${binding.status}${binding.remote_business_key ? ` · ${binding.remote_business_key}` : ""}`}
|
||||
>
|
||||
知识库:{binding.target_name || binding.target_id}
|
||||
{binding.status === "stale" ? "(待更新)" : ""}
|
||||
</Badge>
|
||||
))}
|
||||
{knowledgeBindings.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs">+{knowledgeBindings.length - 3}</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -950,7 +918,7 @@ export function SkillRow({
|
||||
>
|
||||
<TagsIcon className="size-3.5" />
|
||||
</Button>
|
||||
{canManage && (
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -971,12 +939,12 @@ export function SkillRow({
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
skill.pinned && "text-emerald-600 hover:text-emerald-700",
|
||||
)}
|
||||
disabled={setPinned.isPending}
|
||||
@@ -1043,11 +1011,11 @@ export function SkillRow({
|
||||
>
|
||||
<CopyIcon className="size-3.5" />
|
||||
</Button>
|
||||
{canDelete && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||
onClick={() => setConfirmDeleteOpen(true)}
|
||||
title={t.settings.skills.deleteAction}
|
||||
>
|
||||
@@ -1141,18 +1109,16 @@ export function SkillRow({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmDeleteOpen(false)}
|
||||
disabled={deleteCustom.isPending || deleteBuiltin.isPending}
|
||||
disabled={deleteSkill.isPending}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={deleteCustom.isPending || deleteBuiltin.isPending}
|
||||
>
|
||||
{deleteCustom.isPending || deleteBuiltin.isPending
|
||||
? t.common.loading
|
||||
: t.common.delete}
|
||||
disabled={deleteSkill.isPending}
|
||||
>
|
||||
{deleteSkill.isPending ? t.common.loading : t.common.delete}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -1226,24 +1192,7 @@ function InstallSkillDialog({
|
||||
const validateMutation = useValidateSkillUpload();
|
||||
const installMutation = useInstallSkillUpload();
|
||||
const setNameZhMutation = useSetSkillNameZh();
|
||||
const generateNameZhMutation = useGenerateSkillNameZh();
|
||||
|
||||
const validateFile = useCallback(
|
||||
async (selected: File, category: "custom" | "public") => {
|
||||
setValidation(null);
|
||||
setValidationError(null);
|
||||
try {
|
||||
const result = await validateMutation.mutateAsync({
|
||||
file: selected,
|
||||
targetCategory: category,
|
||||
});
|
||||
setValidation(result);
|
||||
} catch (err) {
|
||||
setValidationError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
},
|
||||
[validateMutation],
|
||||
);
|
||||
const generateNameZhMutation = useGenerateSkillNameZh();
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setFile(null);
|
||||
@@ -1282,12 +1231,19 @@ function InstallSkillDialog({
|
||||
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
|
||||
toast.error(dialog.rejectInvalidExt);
|
||||
return;
|
||||
}
|
||||
setFile(selected);
|
||||
await validateFile(selected, targetCategory);
|
||||
},
|
||||
[dialog.rejectInvalidExt, targetCategory, validateFile],
|
||||
);
|
||||
}
|
||||
setFile(selected);
|
||||
setValidation(null);
|
||||
setValidationError(null);
|
||||
try {
|
||||
const result = await validateMutation.mutateAsync(selected);
|
||||
setValidation(result);
|
||||
} catch (err) {
|
||||
setValidationError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
},
|
||||
[dialog.rejectInvalidExt, validateMutation],
|
||||
);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
@@ -1364,29 +1320,12 @@ function InstallSkillDialog({
|
||||
}
|
||||
}, [dialog.nameZhSaved, installedName, nameZhDraft, onClose, reset, setNameZhMutation]);
|
||||
|
||||
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
|
||||
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
|
||||
// Admin targeting public directory can overwrite an existing public skill.
|
||||
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
|
||||
const canInstall = !!validation && !validationError && validation.can_install_as_is;
|
||||
const canOverwrite = !!validation && !validationError && validation.can_overwrite;
|
||||
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
|
||||
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
|
||||
const customConflictMessage = !validation
|
||||
? ""
|
||||
: otherCustomConflict
|
||||
? dialog.conflictOwnedByOther
|
||||
.replace("{name}", validation.skill_name)
|
||||
.replace(
|
||||
"{owner}",
|
||||
validation.owner_username ?? validation.owner_user_id ?? "—",
|
||||
)
|
||||
: validation.owned_by_me
|
||||
? (ownCustomCanOverwrite
|
||||
? dialog.conflictOwnedByMe
|
||||
: dialog.conflictOwnedByMeWrongTarget
|
||||
).replace("{name}", validation.skill_name)
|
||||
: dialog.conflictCustom.replace("{name}", validation.skill_name);
|
||||
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
|
||||
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
|
||||
// Admin targeting public directory can overwrite an existing public skill.
|
||||
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
|
||||
const canInstall = !!validation && !validationError && !isCustomConflict && (adminCanOverwritePublic ? false : !isPublicConflict);
|
||||
const canOverwrite = !!validation && !validationError && (!!isCustomConflict || adminCanOverwritePublic);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||
@@ -1505,12 +1444,9 @@ function InstallSkillDialog({
|
||||
<input
|
||||
type="radio"
|
||||
name="targetCategory"
|
||||
value={cat}
|
||||
checked={targetCategory === cat}
|
||||
onChange={() => {
|
||||
setTargetCategory(cat);
|
||||
if (file) void validateFile(file, cat);
|
||||
}}
|
||||
value={cat}
|
||||
checked={targetCategory === cat}
|
||||
onChange={() => setTargetCategory(cat)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
|
||||
@@ -1568,18 +1504,15 @@ function InstallSkillDialog({
|
||||
</ul>
|
||||
</Field>
|
||||
)}
|
||||
{isCustomConflict && (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 flex items-start gap-2 text-xs",
|
||||
ownCustomCanOverwrite ? "text-amber-600" : "text-destructive",
|
||||
)}
|
||||
>
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{desensitize(customConflictMessage)}
|
||||
</span>
|
||||
</div>
|
||||
{isCustomConflict && (
|
||||
<div className="text-amber-600 mt-1 flex items-start gap-2 text-xs">
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{desensitize(
|
||||
dialog.conflictCustom.replace("{name}", validation.skill_name),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isPublicConflict && (
|
||||
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookOpenTextIcon, ChevronRightIcon, ExternalLinkIcon, FileTextIcon } from "lucide-react";
|
||||
import { ExternalLinkIcon, FileTextIcon, LoaderCircle } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -9,14 +9,12 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function SkillDisplaySources({
|
||||
highlightedReferenceId,
|
||||
@@ -54,31 +52,17 @@ export function SourceCard({
|
||||
source: RuntimeDisplaySource;
|
||||
referenceTarget?: boolean;
|
||||
}) {
|
||||
const [relatedOpen, setRelatedOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [context, setContext] = useState<WeKnoraSourceContext | null>(null);
|
||||
const [contextLoading, setContextLoading] = useState(false);
|
||||
const [contextError, setContextError] = useState<string | null>(null);
|
||||
const [settings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const previewTarget = resolveWeKnoraSourceTarget({
|
||||
...(source.raw || {}),
|
||||
url: source.url,
|
||||
});
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||||
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
|
||||
const matchedHeading = isWikiSource
|
||||
? String(
|
||||
(source.raw?.matched_heading as string | undefined) ||
|
||||
(source.raw?.matched_sections as Array<{ heading?: string }> | undefined)?.find(
|
||||
(section) => section.heading,
|
||||
)?.heading ||
|
||||
"",
|
||||
).trim()
|
||||
: "";
|
||||
const summary = isWikiSource && typeof source.raw?.summary === "string"
|
||||
? source.raw.summary
|
||||
: "";
|
||||
const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
|
||||
const snippet = compactReferenceText(content).slice(0, 280);
|
||||
const paragraphs = segmentReferenceText(content);
|
||||
const score =
|
||||
typeof source.score === "number"
|
||||
? source.score <= 1
|
||||
@@ -87,43 +71,64 @@ export function SourceCard({
|
||||
: null;
|
||||
const meta = [
|
||||
source.source,
|
||||
isWikiSource ? "检索对象:Wiki 文档" : null,
|
||||
matchedHeading ? `命中章节:${matchedHeading}` : null,
|
||||
source.author ? `作者 ${source.author}` : null,
|
||||
!isWikiSource && source.type && source.type !== "result" ? source.type : null,
|
||||
source.type && source.type !== "result" ? source.type : null,
|
||||
source.time,
|
||||
score ? `相关度 ${score}` : null,
|
||||
].filter(Boolean);
|
||||
const isCitation = source.mode === "citation";
|
||||
const showReferenceNumber = isCitation;
|
||||
const openSource = () => {
|
||||
if (previewTarget) {
|
||||
setDetailOpen(true);
|
||||
} else {
|
||||
setRelatedOpen(true);
|
||||
const previewTarget = parseWeKnoraPreviewUrl(source.url);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !previewTarget) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let cancelled = false;
|
||||
setContext(null);
|
||||
setContextError(null);
|
||||
setContextLoading(true);
|
||||
const params = new URLSearchParams({
|
||||
knowledge_base_id: previewTarget.knowledgeBaseId,
|
||||
chunk_id: previewTarget.chunkId,
|
||||
});
|
||||
void apiFetch(`${getBackendBaseURL()}/api/weknora/source-context?${params}`)
|
||||
.then(async (response) => {
|
||||
const payload = (await response.json().catch(() => null)) as unknown;
|
||||
if (!response.ok || !isWeKnoraSourceContextResponse(payload)) {
|
||||
throw new Error(readPreviewError(payload, response.status));
|
||||
}
|
||||
if (!cancelled) {
|
||||
setContext(payload.data);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!cancelled) {
|
||||
setContextError(error instanceof Error ? error.message : "无法加载原文上下文");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setContextLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
<button
|
||||
id={referenceTarget ? `reference-${source.index}` : undefined}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
className={cn(
|
||||
"group bg-card/60 hover:bg-card hover:border-primary/40 focus-visible:ring-primary/30 w-full scroll-mt-12 rounded-xl border px-3.5 py-3 text-left shadow-sm transition-all focus-visible:ring-2 focus-visible:outline-none",
|
||||
"target:border-amber-300 target:bg-amber-50/80",
|
||||
highlighted &&
|
||||
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
|
||||
"cursor-pointer",
|
||||
)}
|
||||
onClick={openSource}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openSource();
|
||||
}
|
||||
}}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{showReferenceNumber && (
|
||||
@@ -151,40 +156,20 @@ export function SourceCard({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{(source.url || isWikiSource) && (
|
||||
{source.url && (
|
||||
previewTarget ? (
|
||||
<span className={cn(
|
||||
"mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium",
|
||||
isWikiSource
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}>
|
||||
{isWikiSource ? <BookOpenTextIcon className="size-3.5" /> : <FileTextIcon className="size-3.5" />}
|
||||
{isWikiSource ? "Wiki 文档" : "知识库文档"}
|
||||
<span className="text-primary bg-primary/10 mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium">
|
||||
<FileTextIcon className="size-3.5" />
|
||||
查看原文
|
||||
</span>
|
||||
) : (
|
||||
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{previewTarget && (
|
||||
<div className="mt-3 flex items-center justify-between border-t pt-2 text-xs text-muted-foreground">
|
||||
<span>{isWikiSource ? "点击卡片查看完整 Wiki" : "点击卡片查看详情"}</span>
|
||||
<ChevronRightIcon className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{previewTarget && (
|
||||
<WeKnoraSourceDrawer
|
||||
open={detailOpen}
|
||||
target={previewTarget}
|
||||
title={source.title}
|
||||
quote={content}
|
||||
onOpenChange={setDetailOpen}
|
||||
/>
|
||||
)}
|
||||
{!previewTarget && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
|
||||
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
|
||||
</button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-3 pr-8">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -220,23 +205,19 @@ export function SourceCard({
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{content ? (
|
||||
<div className="max-h-[62vh] overflow-y-auto rounded-xl border bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
|
||||
{isWikiSource && (
|
||||
<div className="mb-4 flex items-center gap-2 border-b pb-3 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
<BookOpenTextIcon className="size-4" />
|
||||
本次检索命中的 Wiki 文档内容
|
||||
</div>
|
||||
)}
|
||||
<article className="mx-auto max-w-3xl rounded-lg bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={content}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
</article>
|
||||
{previewTarget ? (
|
||||
<WeKnoraSourceContextPanel
|
||||
context={context}
|
||||
loading={contextLoading}
|
||||
error={contextError}
|
||||
/>
|
||||
) : paragraphs.length > 0 ? (
|
||||
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
<p key={index} className="whitespace-pre-wrap">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{source.url && !previewTarget && (
|
||||
@@ -245,11 +226,123 @@ export function SourceCard({
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type WeKnoraPreviewTarget = {
|
||||
knowledgeBaseId: string;
|
||||
chunkId: string;
|
||||
};
|
||||
|
||||
type WeKnoraSourceContext = {
|
||||
knowledge_base_name?: string;
|
||||
document_title?: string;
|
||||
document_file_name?: string;
|
||||
focus_chunk_id: string;
|
||||
chunks: Array<{
|
||||
id: string;
|
||||
chunk_index?: number;
|
||||
content: string;
|
||||
is_focus: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
function parseWeKnoraPreviewUrl(url?: string): WeKnoraPreviewTarget | null {
|
||||
if (!url?.startsWith("/weknora-source-preview?")) {
|
||||
return null;
|
||||
}
|
||||
const queryIndex = url.indexOf("?");
|
||||
const params = new URLSearchParams(url.slice(queryIndex + 1));
|
||||
const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
|
||||
const chunkId = params.get("chunk_id")?.trim();
|
||||
return knowledgeBaseId && chunkId ? { knowledgeBaseId, chunkId } : null;
|
||||
}
|
||||
|
||||
function isWeKnoraSourceContextResponse(
|
||||
value: unknown,
|
||||
): value is { success: true; data: WeKnoraSourceContext } {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const data = (value as { data?: unknown }).data;
|
||||
return (
|
||||
(value as { success?: unknown }).success === true &&
|
||||
!!data &&
|
||||
typeof data === "object" &&
|
||||
Array.isArray((data as WeKnoraSourceContext).chunks)
|
||||
);
|
||||
}
|
||||
|
||||
function readPreviewError(payload: unknown, status: number): string {
|
||||
if (payload && typeof payload === "object") {
|
||||
const detail = (payload as { detail?: unknown }).detail;
|
||||
if (typeof detail === "string" && detail) {
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
return status === 404 ? "原文已不可用" : "无法加载原文上下文";
|
||||
}
|
||||
|
||||
function WeKnoraSourceContextPanel({
|
||||
context,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
context: WeKnoraSourceContext | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex min-h-36 items-center justify-center gap-2 text-sm">
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
正在加载原文上下文…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="border-destructive/30 bg-destructive/5 text-destructive rounded-lg border p-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1">
|
||||
<div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs">
|
||||
<span>{context.knowledge_base_name || "WeKnora 知识库"}</span>
|
||||
{context.document_title && <span> · {context.document_title}</span>}
|
||||
</div>
|
||||
{context.chunks.map((chunk) => (
|
||||
<section
|
||||
key={chunk.id}
|
||||
className={cn(
|
||||
"rounded-lg border p-3 text-sm leading-relaxed",
|
||||
chunk.is_focus
|
||||
? "border-primary/50 bg-primary/5 shadow-sm"
|
||||
: "bg-muted/20",
|
||||
)}
|
||||
>
|
||||
<div className="text-muted-foreground mb-2 flex items-center gap-2 text-xs">
|
||||
<span>分段 {typeof chunk.chunk_index === "number" ? chunk.chunk_index + 1 : "-"}</span>
|
||||
{chunk.is_focus && (
|
||||
<span className="bg-primary text-primary-foreground rounded px-1.5 py-0.5 font-medium">
|
||||
检索命中
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap">{chunk.content}</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function cleanReferenceText(
|
||||
raw: string | undefined,
|
||||
cleanupWords: string[],
|
||||
@@ -274,18 +367,44 @@ function compactReferenceText(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function plainTextReferencePreview(text: string): string {
|
||||
return compactReferenceText(
|
||||
text
|
||||
.replace(/^---[\s\S]*?---\s*/m, "")
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||
.replace(/\[\[[^\]\s|]+[|\s]+([^\]]+)\]\]/g, "$1")
|
||||
.replace(/\[\[([^\]]+)\]\]/g, "$1")
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.replace(/^\s*[-*+]\s+/gm, "")
|
||||
.replace(/^\s*\d+[.)]\s+/gm, "")
|
||||
.replace(/[>*_`~|]/g, " "),
|
||||
);
|
||||
function segmentReferenceText(text: string): string[] {
|
||||
const explicit = text
|
||||
.split(/\n{2,}/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const base = explicit.length > 1 ? explicit : splitLongParagraph(text);
|
||||
return base.slice(0, 80);
|
||||
}
|
||||
|
||||
function splitLongParagraph(text: string): string[] {
|
||||
const normalized = text
|
||||
.split(/\n+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (normalized.length <= 360) {
|
||||
return normalized ? [normalized] : [];
|
||||
}
|
||||
|
||||
const sentences = normalized
|
||||
.split(/(?<=[。!?!?;;])\s*/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
if (sentences.length <= 1) {
|
||||
return normalized.match(/[\s\S]{1,360}/g) ?? [];
|
||||
}
|
||||
|
||||
const paragraphs: string[] = [];
|
||||
let current = "";
|
||||
for (const sentence of sentences) {
|
||||
if ((current + sentence).length > 360 && current) {
|
||||
paragraphs.push(current.trim());
|
||||
current = "";
|
||||
}
|
||||
current += sentence;
|
||||
}
|
||||
if (current.trim()) {
|
||||
paragraphs.push(current.trim());
|
||||
}
|
||||
return paragraphs;
|
||||
}
|
||||
Loaded 100 of 1348 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user