Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c933a8f19 | ||
|
|
ea9dcff9c0 | ||
|
|
8bc8b0e082 | ||
|
|
4f7f07504a | ||
|
|
1244fb1f2f | ||
|
|
3ca5528640 | ||
|
|
250db4de04 | ||
|
|
f9b6d7e322 | ||
|
|
6edb6fa7aa | ||
|
|
cf9fe58905 | ||
|
|
56bb4f141d | ||
|
|
75f288b241 | ||
|
|
bf42a48fcc | ||
|
|
5e953c9fcb | ||
|
|
814a44f0b9 | ||
|
|
b80433f091 | ||
|
|
5c75e1af16 | ||
|
|
438772f0b7 | ||
|
|
dea4697ea0 | ||
|
|
7dbac9cc56 | ||
|
|
a723a9ca02 | ||
|
|
e58da3fbdf | ||
|
|
60e2c72514 | ||
|
|
1eae02f782 | ||
|
|
db333a4c33 | ||
|
|
f109a95535 | ||
|
|
49d9ced04f | ||
|
|
c2153ec238 | ||
|
|
6b724bd00d | ||
|
|
65d6b61d5d | ||
|
|
1c891df8fb | ||
|
|
6d326a21cd | ||
|
|
f5bf7897b9 | ||
|
|
fbd8dec48f | ||
|
|
dd0ee282df | ||
|
|
553f72604a | ||
|
|
9455160101 | ||
|
|
519e4c0c01 | ||
|
|
dbf62e5889 | ||
|
|
aeb9a55497 | ||
|
|
051e66f7d8 | ||
|
|
1706a24182 | ||
|
|
38857c425b | ||
|
|
99f1ca6029 | ||
|
|
a020458eeb | ||
|
|
8e49fd2e95 | ||
|
|
57b6a89f20 | ||
|
|
ac92f29db1 | ||
|
|
f0ddade8a7 | ||
|
|
fbf9031e25 | ||
|
|
2254841822 | ||
|
|
74fc849241 | ||
|
|
dd560ceb46 | ||
|
|
a4f95a4fa9 | ||
|
|
b92a26cdd4 | ||
|
|
7066798ab0 | ||
|
|
222595d6c4 | ||
|
|
3384f2e51c | ||
|
|
ff80cc110d | ||
|
|
2ab3ab8444 | ||
|
|
f7e6ed65bd | ||
|
|
4964967295 | ||
|
|
49c01cb72b | ||
|
|
9afb9750e5 | ||
|
|
17cf849361 | ||
|
|
4ac7bc21cd | ||
|
|
832e180c52 | ||
|
|
875001a8c0 | ||
|
|
36653c53a6 | ||
|
|
37517c2778 | ||
|
|
1b43205c1b |
No files matched your search
+67
-27
@@ -87,39 +87,77 @@ offline-backend-20260512/backend/skill_evolution.json
|
||||
skills/
|
||||
hermes-agent-main/
|
||||
.gitignore
|
||||
offline-backend-20260512/backend/tests/*
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
||||
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
||||
# Keep formal Word OOXML and embedded-font regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_word_export.py
|
||||
# Keep the model configuration regression coverage versioned even though
|
||||
# this deployment repository otherwise ignores newly added backend tests.
|
||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
||||
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
|
||||
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_fixed_questions.py
|
||||
# Keep configurable es_query system-prompt routing regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py
|
||||
# Keep the local LLMWiki vector-index and external API regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
|
||||
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
|
||||
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
|
||||
# Deep-research reliability regression coverage.
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
|
||||
offline-backend-20260512/backend/tests/*
|
||||
# Keep wx common-agent category configuration regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_wx_common_agents.py
|
||||
# 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_knowledge_live_embedding.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 Web App site artifact (multi-page site) regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_web_app_sites.py
|
||||
# Keep the model configuration regression coverage versioned even though
|
||||
# this deployment repository otherwise ignores newly added backend tests.
|
||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
||||
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
|
||||
# Keep 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
|
||||
deploy
|
||||
# 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
|
||||
# Adaptive context recovery, large-payload and real-adapter streaming regressions.
|
||||
!offline-backend-20260512/backend/tests/test_context_recovery.py
|
||||
!offline-backend-20260512/backend/tests/test_context_recovery_transport.py
|
||||
!offline-backend-20260512/backend/tests/test_summarization_multiturn.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
|
||||
deploy
|
||||
.history
|
||||
|
||||
# Cloned third-party repos (local only)
|
||||
@@ -153,6 +191,8 @@ 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/
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Web App 产物模式:多页面站点 / 流程图 / 地图(可降级 + 独立页面风格)
|
||||
|
||||
## 总体架构
|
||||
|
||||
让智能体在沙箱里产出**完整多页面站点**(site.json 清单 + src/pages + public 数据),平台统一构建(预置 node_modules,永不 npm install)与受控静态托管,前端新注册一个**风格独立**的「站点预览」路由页。
|
||||
|
||||
**两条铁律(本次新增的核心原则)**:
|
||||
1. **零侵入**:新页面不复用现有工作台风格/布局组件,走独立路由 + 独立样式表;对已有代码的修改仅限「一个带开关的入口按钮」。
|
||||
2. **可降级**:沙箱/模板/node/构建链任何一环部署失败,Web App 功能整体退回"不可用"状态并优雅提示,智能体回退到现有单页 HTML 模式,**现有功能零影响**。
|
||||
|
||||
```
|
||||
智能体(技能 web-app)
|
||||
0. 检查可用性:/mnt/web-app-template 缺失 → 回退现有单页 HTML 模式(行为与今天完全一致)
|
||||
1. scaffold → outputs/my-site/(排除 node_modules/dist)
|
||||
2. 写 src/pages/*.tsx、public/data.geojson、编辑 site.json
|
||||
3. build_site.py build → 软链/junction 预置 node_modules → vite build → dist/(失败自修复 ≤3 次)
|
||||
4. present_files site.json
|
||||
平台(Gateway,全部能力探测 + 开关保护)
|
||||
GET /api/web-app/capability # 可用性探测(配置/模板/node_modules/node 四项检查)
|
||||
GET /api/threads/{tid}/sites # 站点列表 + 构建状态
|
||||
POST /api/threads/{tid}/sites/{name}/build # 触发构建
|
||||
GET /api/threads/{tid}/sites/{name}/build-status
|
||||
POST /api/threads/{tid}/sites/{name}/preview-url → 签发短时效签名 URL
|
||||
GET /api/public/site-preview/{token}/{path} # 静态托管 dist/
|
||||
前端(独立路由 + 独立风格)
|
||||
#/sites/:threadId/:siteName # SiteStudioPage:全新视觉风格,无现有 chrome
|
||||
产物面板 site.json →「打开站点预览」按钮(仅 capability 可用时渲染)
|
||||
```
|
||||
|
||||
已确认决策:混合构建(技能自修复 + 平台按钮兜底);预览工作台先行;GeoJSON 优先 + 可配置底图。
|
||||
|
||||
---
|
||||
|
||||
## 一、页面风格:独立设计系统(不用现有风格)
|
||||
|
||||
新页面 `SiteStudioPage` 做成**深色"预览工作室"**风格,视觉上与现有工作台完全区隔:
|
||||
|
||||
- **独立路由**:直接注册在 `App.tsx` 顶层(`/share/:shareCode`、`/login` 同款先例),**不进** `PageLayout`/`WorkspaceLayout`/`Header`/`PageSidebar`——零现有 chrome 继承,零布局耦合。
|
||||
- **独立样式**:新建 `src/pages/site-studio/site-studio.css`(自建 design tokens:深空灰底 `#0f1117` 系、靛蓝/青渐变强调色、玻璃拟态面板、细腻边框与阴影、系统字体栈),所有类名 `site-studio-` 前缀,**只被该页面 import**,不碰 tailwind 配置、全局 CSS、主题 provider。
|
||||
- **布局**:
|
||||
- 顶部悬浮工具栏:站点标题 + 构建状态胶囊(成功/构建中/失败三态,带呼吸灯动画)+ 设备切换分段控件(桌面/平板/手机)+ 刷新/重新构建/新窗口打开;
|
||||
- 左侧窄导航栏:site.json 的页面列表(图标 + 标题,点击设 iframe hash 切页),可折叠成纯图标模式;
|
||||
- 中央:**浏览器壳**造型(圆角 + 顶部仿 URL 地址条 + 红黄绿窗控点缀)内嵌 iframe,设备切换时宽度平滑过渡动画;
|
||||
- 底部可折叠**构建控制台**(等宽字体日志、错误红色高亮、构建时长);
|
||||
- 加载/失败/未启用态都有设计过的插画式空状态(纯 CSS/SVG,不引组件库新依赖)。
|
||||
- 登录守卫:轻量检查 stored auth,缺失跳 `#/login`(复用既有跳转工具,不动鉴权体系)。
|
||||
|
||||
## 二、降级与故障隔离矩阵(部署失败不影响已有功能)
|
||||
|
||||
| 故障点 | 行为 | 对现有功能影响 |
|
||||
|---|---|---|
|
||||
| config `web_app.enabled: false`(默认关) | capability 返回不可用;入口按钮不渲染;技能第 0 步探测失败即回退单页 HTML | 无 |
|
||||
| 模板目录/node_modules 未预置(发版遗漏) | 同上:capability 四项检查(配置/模板/依赖/node 二进制)逐项报告原因 | 无 |
|
||||
| sandbox.mounts 加了模板挂载但目录缺失 | 已核实 `local_sandbox_provider.py:81-90` 对不存在宿主路径**静默跳过仅告警**;Aio provider 同样按"存在才挂"实现 | 无(沿用既有容错语义) |
|
||||
| vite build 失败/超时 | 子进程隔离(`asyncio.to_thread` + 超时强杀 + 每站点并发锁),错误收进 JSON;预览页显示日志;智能体读报错自修复 | 无(不进 gateway 请求主链路) |
|
||||
| 签名 URL 过期/伪造 | 403 JSON,页面自动重签 | 无 |
|
||||
| 前端新页面任何异常 | 独立路由独立样式,最坏就是该页白屏,error boundary 兜底 | 无 |
|
||||
| 产物面板入口按钮 | 唯一改动点:capability 请求失败/不可用 → 按钮不渲染,**原 UI 一字不变** | 无 |
|
||||
| 新 router 注册 | 纯增量 include_router,异常全部在路由内转结构化响应 | 无 |
|
||||
|
||||
技能回退语义(关键):SKILL.md 第 0 步强制探测 `ls /mnt/web-app-template/scripts/build_site.py`——探测失败时明确指示"改用现有方式产出单个 xxx.html",保证部署不完整时智能体产物质量与今天一致。
|
||||
|
||||
## P1 站点模板(`offline-backend-20260512/backend/templates/web-app-template/`,源码入库,node_modules gitignore 由发版仓库预置)
|
||||
|
||||
- Vite + React + react-router-dom(**HashRouter**:静态托管路径下无需服务端 fallback)+ **纯 CSS design tokens**(不用 Tailwind v4,规避 oklch 在 chrome100 老内核失效)+ build target es2018。
|
||||
- 依赖:react 系、@xyflow/react、maplibre-gl、echarts、mermaid、pmtiles、vite/@vitejs/plugin-react(lockfile 锁死版本)。
|
||||
- 内置组件:`AppShell`(读 site.json 导航)、`FlowCanvas`(xyflow 流程图封装,自动分层布局)、`GeoMap`(**默认无底图模式**:背景 + GeoJSON 点线面离线开箱即用;site.json 可配 tilesUrl/PMTiles)、`ChartPanel`(echarts)、`MermaidBlock`(静态图)。
|
||||
- `router.tsx` 用 `import.meta.glob('../pages/*.tsx')` 自动注册路由——智能体丢页面文件即生效,零手动接线。
|
||||
- `site.json`:`{ title, description, nav[], map:{tilesUrl?, tilesType?, pmtiles?}, theme? }`。
|
||||
- Makefile 加 `make webapp-template`(开发机一次性 pnpm install);发版仓库构建脚本负责镜像内预置 node_modules(文档写明)。
|
||||
- config.yaml `sandbox.mounts` 增模板只读挂载(`templates/web-app-template → /mnt/web-app-template`,缺失自动跳过已验证);AioSandboxProvider `_get_thread_mounts` 小扩展:同样"存在才挂"地补读 config 自定义 mounts。
|
||||
|
||||
## P2 构建脚本 + 技能包(`skills/public/web-app/`)
|
||||
|
||||
- `SKILL.md`:触发词(多页面网站/站点/web app/带流程图或地图的网页);工作流 = ①可用性探测(失败即回退单页 HTML)②scaffold ③配置 site.json ④写页面 ⑤数据放 public/ ⑥build(失败读 JSON 报错修复重试 ≤3)⑦present_files site.json ⑧引导点「站点预览」;硬约束:不手写 dist、一律 `/mnt/user-data/...` 虚拟路径。
|
||||
- `scripts/build_site.py`(纯标准库):`check`(能力探测 JSON)/ `scaffold`(copytree 忽略 node_modules、dist)/ `build`(定位 node → 软链 node_modules 进站点:Linux symlink、Windows `mklink /J` junction 免管理员 → `node vite.js build` → 结构化 JSON 输出,180s 超时)/ `status`。
|
||||
- 模板定位顺序:env `DEERFLOW_WEBAPP_TEMPLATE` → `/mnt/web-app-template`(沙箱内)→ 后端相对路径(宿主侧)。
|
||||
|
||||
## P3 后端:纯引擎 + 路由(全部能力开关保护)
|
||||
|
||||
- `packages/harness/deerflow/webapp/`(纯引擎,不 import app.*,对齐 `address_scan.py` 先例):站点发现(outputs/* 下 site.json + dist 状态)、构建执行(子进程 + 锁 + 超时)、HMAC token(`tid|site|exp`,密钥首用生成持久化 `{base_dir}/.web-app-secret` 0600)。
|
||||
- `app/gateway/routers/sites.py`(app.py 注册):
|
||||
- `GET /api/web-app/capability`:enabled + 模板 + node_modules + node 四项检查,逐项给原因(前端与技能共用);
|
||||
- 列表/build/build-status/preview-url:`@require_permission("threads", "read", owner_check=True)`,能力不可用时返回结构化 `{"available": false, "reason"}` 而非 5xx;
|
||||
- `GET /api/public/site-preview/{token}/{path}`:验签 + 过期 → `aresolve_thread_virtual_path` 且**限定站点 dist/ 根**(复用遍历防护)→ 正确 MIME(.js/.mjs→text/javascript、.geojson→application/json、.wasm)→ **HTML inline** + `CSP: default-src 'self' data: blob:; worker-src 'self' blob:`(maplibre worker 需要)+ `X-Content-Type-Options: nosniff` + `Cache-Control: no-cache` + ETag(mtime)。
|
||||
- config.yaml + `deerflow/config/configuration.py` 加 `web_app:`(enabled **默认 false**、build_timeout、token_ttl、template_dir、node 覆盖)——与 `workflows` 同款总开关模式。
|
||||
- 测试 `tests/test_web_app_sites.py`:capability 降级路径、token 签发/过期/伪造、路径穿越拒绝、MIME、站点发现(构建子进程 mock)。
|
||||
|
||||
## P4 前端
|
||||
|
||||
- `src/core/sites/api.ts`:capability/sites/build/status/mintPreviewUrl + TanStack Query hooks(照 `light-apps.ts` 模式)。
|
||||
- `src/pages/site-studio/SiteStudioPage.tsx` + `site-studio.css` + `App.tsx` 注册顶层路由 `/sites/:threadId/:siteName`(独立风格见第一节;iframe `sandbox="allow-scripts allow-forms allow-popups"` 无 allow-same-origin,延续既有安全契约;token 过期自动重签;设备切换;重新构建 → 轮询 build-status → cache-bust 刷新)。
|
||||
- `artifact-file-detail.tsx` **唯一既有文件改动**:产物路径以 site.json 结尾且 capability 可用时渲染「打开站点预览」按钮(navigate 到新路由);capability 请求失败/超时一律不渲染,原 UI 不变。
|
||||
- token URL 构造等纯函数补单测;`pnpm typecheck` 通过。
|
||||
|
||||
## P5 测试与文档
|
||||
|
||||
- 后端 `make test` + ruff;前端 typecheck + 单测;`build_site.py` 本机真实跑通 scaffold→build→静态服务器自测 dist。
|
||||
- 文档:AGENTS.md 增「Web App 产物」小节(含降级矩阵);发版文档补 node_modules 预置说明。
|
||||
|
||||
## 安全边界
|
||||
|
||||
短时效签名 URL(默认 2h)只覆盖该站点 dist/ 静态读;dist/ 根限定 + 复用 `aresolve_thread_virtual_path` 遍历防护;HTML inline 仅在 site-preview 路径开放(不影响 artifacts 接口既有强制 attachment);CSP + nosniff + iframe sandbox 三重纵深。
|
||||
|
||||
## 二期(本期不做)
|
||||
|
||||
源码编辑 + 保存即重建;跨对话站点 gallery;PMTiles 底图资产打包;`visualizations/` 内联图表(进行中,互补不冲突)的底图配置统一(其 map-renderer.tsx 硬编码 Google 瓦片属离线隐患,另行处理)。
|
||||
|
||||
## 风险
|
||||
|
||||
- Windows 开发机 junction 链接免管理员,node 前端机必有;生产镜像已含 nodejs+npm。
|
||||
- Aio provider 自定义 mounts 扩展是小改动,按"存在才挂"实现即可,不影响现有挂载。
|
||||
- dist/ 出现在产物目录扫描(文件噪音)——一期保持现状,二期优化。
|
||||
@@ -80,6 +80,8 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph
|
||||
|
||||
**Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI).
|
||||
|
||||
**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:
|
||||
@@ -94,15 +96,17 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:<stage>` 窗口事件强制重放(忽略已看过记忆)。
|
||||
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
|
||||
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
|
||||
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
|
||||
- **按钮管理 (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`.
|
||||
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
|
||||
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
||||
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
|
||||
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
|
||||
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations staLine truncated
|
||||
- **agentfx 任务深链(新系统 iframe 嵌入的智能体问答,3qfx 单智能体 lite 模式的独立复制)**: goPath=`agentfx`(`${loginBase}?password=…&taskId=…&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue`)。与 3qfx 的差异:①任务详情不走外部 consumer,改调**本系统 Gateway** `GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=`(`apiFetch` 带刚登录的 DeerFlow 态;任务数据来自 TaskCOP 任务表,与任务列表/报告导入同一份);②**固定使用内置智能体** `agentfx-analyst`(不走业务映射);③路由 `task-management/plan-task-agentfx/agent/:agent_id/chats`,页面为独立复制的 `AgentFxWorkspace.tsx`(`AgentFxWorkspace`/`AgentFxIndexResolver`/`AgentFxChat` —— 左「当前任务」卡 + 1:N 对话列表 + 新建对话,`AgentChatPage taskBind` 自动发开场白;无 TaskJumpBar/清除记录/闸门/xdfx 行动卡)。模块 `core/auth/agentfx-deeplink.ts`(`isAgentfxGoPath`/`fetchAgentfxTaskDetail`/`resolveAgentfxDeeplinkTarget`);任务详情整包仍存共享键 `login.copTaskDetail`(按标签页隔离),`AgentChatPage` 的 taskBind 自恢复对 agentfx 生效。`LoginPage` 在既有深链分支前用 `isAgentfxGoPath` 分流。**内置「任务研判报告助手」**:后端 seeder `app/gateway/routers/_agentfx_seed.py` + `_agentfx_seed_assets/`(config.yaml + SOUL.md,目录存在不覆盖;app.py lifespan 在 `_sync_legacy_agents` 之前调用,自动进 agents 表 user_id IS NULL)——SOUL 规定工作流「搜集信息(`web_search`,**首步不可跳过**)→ `write_file` 生成 `/mnt/user-data/outputs/report.md` + `task-reports.json`(14 个 categoryType 的 `{contentJson, categoryType}` 数组,contentJson 为 JSON 字符串)→ 聊天总结 → 引导入库」,硬性约束:categoryType 逐字用 14 个全称(禁缩写自造如「DQ关键事件」)、JSON 直接写汉字禁 `\uXXXX` 转义、每类必须有实质内容不许空数组/纯占位、文件一律用 `/mnt/user-data/...` 虚拟路径(防宿主路径泄漏进 tool args)。**检索依赖 `config.yaml tools.web_search`(DDG,enabled 已开;内网需换 configurable_search 时改回并填 endpoint)**。任务工作区输入框在 `taskCtxOn` 时隐藏「写作/记忆/参考文献」(InputBox `hideContextTools` prop)与「知识空间」(不传 knowledgeSelector);消息流里 write_file 文件卡片只显示文件名(`artifactDisplayName` 收敛宿主/虚拟路径)。**入库两条路径**:(a) **前端协助卡** —— `AgentChatPage`(taskBind 且非 taskGate)检测线程 artifacts 出现 `task-reports.json`(按文件名后缀匹配,兼容本地沙箱记录的宿主路径)且流式结束时,向 displayThread 尾部注入 `additional_kwargs.task_report_import_approval` 虚拟 ai 消息(`core/messages/utils.ts` 分组 `assistant:task-report-import` + `MessageList` 的 `taskReportImportSlot` 插槽渲染 `TaskReportImportCard`),点「开始入库」前端校验(`strategy-components/lib/agentfx-report-schema.ts`,14 类 + contentJson 合法 JSON + **\u 转义归一化还原为汉字**,与技能脚本同规则)后 `importTaskReport`(`strategy-components/api/task-reports.ts`)POST 导入接口整体替换;任务已有详情**不抑制**卡片(入库即整体替换,卡片文案说明覆盖语义),导入成功按 thread 记 sessionStorage(刷新不重复询问)、「暂不入库」为会话内记忆;(b) **入库技能** `task-report-import`(seeder 一并复制到 `skills/public/`,public 类技能 extensions_config 缺省即启用)—— 用户聊天里说「入库」时智能体按 SKILL.md 用 bash 跑 `scripts/task_report_import.py`(纯标准库:同样的校验规则 + 转义归一化;**默认关闭 SSL 证书校验** `ssl._create_unverified_context` + 忽略代理,适配内网自签证书;鉴权 `--token`/env `AGENTFX_IMPORT_TOKEN`→`DEERFLOW_AUTH_TOKEN`→`TOOLS_TOKEN` 优先,缺省经公共端点 `POST /api/parallel-agents/auth/token` 以 `agentfx-import` 用户现签 token——内网既有鉴权旁路;gateway 地址 env `AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:{DEER_FLOW_GATEWAY_PORT}`);`--strict` 可要求 14 类齐全,默认允许部分导入并在 stdout JSON 的 `missing_categories` 里列缺。入库成功后任务 TaskCOP 状态自动置 25(已完成分析)。**入库成功通知宿主**(iframe 嵌入场景):两条路径成功后都会 `window.top.postMessage({source: "magent-web", type: "task-report:saved", taskId: String(taskId)}, "*")`(`notifyTaskReportSaved`,`agentfx-report-schema.ts`)——卡片路径在 `TaskReportImportCard` 导入成功回调里直发;技能路径由 `AgentChatPage` 从消息流检测 `task_report_import.py` 的成功 stdout(`"success": true` + `task_id`)代发——**仅在回合结束后检测**(`!thread.isLoading` 才扫描;流式过程中 messages Line truncated
|
||||
- **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 提示。
|
||||
|
||||
- **Web App 产物模式(多页面站点 / 流程图 / 地图,默认关闭、全链路可降级)**: 让智能体产出**完整站点**而非单个 xxx.html。沙箱技能 `skills/public/web-app` 用模板(`backend/templates/web-app-template`,经 `config.yaml sandbox.mounts` 只读挂载到 `/mnt/web-app-template`,目录缺失自动跳过)脚手架到 `outputs/<site>/`(site.json 清单 + `src/pages/*.tsx` 文件名即路由 + `public/` 数据),再跑 `scripts/build_site.py build` 用**预置 node_modules**(symlink/junction 链接,离线永不 npm install)执行 vite build 出 `dist/`;内置组件 `FlowCanvas`(@xyflow/react)/`GeoMap`(MapLibre,默认无底图 GeoJSON 模式,site.json 可配 tilesUrl/PMTiles)/`ChartPanel`(ECharts)/`MermaidBlock`。平台侧 `app/gateway/routers/web_app_sites.py` + 纯引擎 `deerflow/webapp/`(发现/子进程构建/HMAC 签名):`GET /api/web-app/capability`(enabled/模板/依赖/node 四项探测)、`GET|POST /api/threads/{tid}/sites[...]{build,build-status,preview-url}`,静态托管走 `GET /api/public/site-preview/{token}/{path}`(`/api/public/` 免鉴权命名空间,**唯一允许 HTML inline 的出口**——签名令牌 + dist/ 根限定 + 按请求真实 origin 显式声明的 CSP(iframe sandbox 不透明 origin 下 'self' 失效)+ nosniff;artifacts 接口既有的强制 attachment 不变)。前端:**独立风格**预览工作台 `pages/site-studio/SiteStudioPage.tsx`(顶层路由 `#/sites/:threadId/:siteName`,`App.tsx` 注册,不进 PageLayout/WorkspaceLayout;自带 site-studio.css 深色设计体系,零全局样式污染;iframe `sandbox="allow-scripts allow-forms allow-popups allow-modals"` 无 allow-same-origin)+ 产物面板 `artifact-file-detail.tsx` 里 site.json 的「站点预览」按钮(capability 可用才渲染,唯一既有文件改动)。**降级铁律**:默认**完全关闭**(`sandbox.mounts` 的模板挂载条目与文件末尾的 `web_app:` 段在 config.yaml 里**均为注释状态**);开启需**同时放开两处**(挂载让沙箱技能可探测 `/mnt/web-app-template`,`web_app.enabled: true` 开平台 API——只开一处会得到"生成了看不了"或"看不了也不生成"的中间态)。`build_site.py check` 在宿主侧会读 config.yaml 的 `web_app.enabled`(env `DEERFLOW_WEBAPP_ENABLED`/`DEERFLOW_WEBAPP_CONFIG` 可覆盖;沙箱内读不到配置时"挂载即视为开")。任何一环(配置/模板/node_modules/node)缺失 → capability 不可用 + 技能第 0 步探测失败即**回退单页 HTML 模式** + 入口按钮不渲染 + 构建失败只收进任务状态,现有功能零影响。配置 `web_app:`(enabled/template_dir/build_timeout_seconds/preview_token_ttl_seconds/node_path);测试 `tests/test_web_app_sites.py`;开发机 `make webapp-template` 预置依赖,离线发版由 deerflow-offline-deployment 仓库在 CI 预置 node_modules 进镜像。
|
||||
|
||||
### Configuration
|
||||
|
||||
Backend config lives in `offline-backend-20260512/backend/config.yaml`. Values starting with `$` resolve as environment variables (e.g., `$OPENAI_API_KEY`). MCP servers and skills are configured in `extensions_config.json` in the same directory.
|
||||
|
||||
@@ -73,14 +73,19 @@ 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:
|
||||
|
||||
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
|
||||
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
|
||||
- **普通问答地图可视化**: shared `MarkdownContent` recognizes only dedicated `map-json` fences and otherwise preserves the legacy Markdown/Streamdown path byte-for-byte. `components/workspace/visualizations/` validates the bounded map contract with Zod, lazy-loads MapLibre, and provides loading/error/text fallbacks so malformed or unreachable maps never blank the message. The lead-agent prompt emits `map-json` only when the user explicitly asks for a map; flowcharts and other graphics continue through the existing Markdown/Mermaid path. The raster tile template comes from the single post-build setting `runtime-config.js` → `VITE_MAP_TILE_URL` (Google by default); replace it with the intranet `{z}/{x}/{y}` endpoint for disconnected deployments.
|
||||
- **HTML 成品离线绘图运行时**: 新生成的 `.html` 源码直接引用 Gateway `/api/public/html-runtime/` 下的浏览器版 `g6.min.js`、`echarts.min.js`、`leaflet.js/css` 和 `deerflow-runtime.js`;文件随后台源码存放于 `vendor/html-runtime/`,不再依赖前端 Vite/node_modules 产物或公网 CDN。`config.yaml -> html_runtime.base_url` 是写入新 HTML 的浏览器可访问绝对地址,`html_runtime.map_tile_url` 是初始化脚本读取的瓦片模板。流程图使用 `data-df-flow` + G6(不用 Mermaid),图表使用 `data-df-chart`,地图使用 Leaflet `data-df-map`。普通问答保持独立:消息列表 `map-json` 继续由前端原逻辑渲染,普通流程图/图表仍走 Markdown/Mermaid。
|
||||
- **定时任务「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`.
|
||||
- **白名单管理 (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`.
|
||||
|
||||
Binary file not shown.
@@ -5,6 +5,9 @@ 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));
|
||||
@@ -12,6 +15,14 @@ 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;
|
||||
@@ -69,10 +80,7 @@ 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));
|
||||
@@ -85,6 +93,10 @@ function apiCandidatesForWebBase(webBase, currentApiBase) {
|
||||
// Ignore invalid candidates.
|
||||
}
|
||||
|
||||
add(currentApiBase);
|
||||
add(DEER_CLIPPER_DEFAULTS.apiBaseUrl);
|
||||
add(LEGACY_LOCAL_API_BASE_URL);
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
@@ -93,6 +105,12 @@ 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;
|
||||
}
|
||||
@@ -201,9 +219,12 @@ async function discoverServiceBases(settings, force = false) {
|
||||
settings.webBaseUrl !== next.webBaseUrl ||
|
||||
settings.lastServiceSyncSource !== next.lastServiceSyncSource
|
||||
) {
|
||||
await chrome.storage.sync.set({
|
||||
apiBaseUrl: next.apiBaseUrl,
|
||||
webBaseUrl: next.webBaseUrl,
|
||||
// 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,
|
||||
lastServiceSyncAt: next.lastServiceSyncAt,
|
||||
lastServiceSyncSource: next.lastServiceSyncSource,
|
||||
});
|
||||
@@ -826,19 +847,50 @@ async function pollOnce() {
|
||||
}
|
||||
}
|
||||
|
||||
async function startup() {
|
||||
async function initializePolling() {
|
||||
let settings = await getSettings();
|
||||
settings = await discoverServiceBases(settings);
|
||||
settings = await syncAuthToken(settings);
|
||||
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);
|
||||
}
|
||||
await refreshAlarm(settings);
|
||||
await scheduleNextPoll(1000);
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => void startup());
|
||||
chrome.runtime.onStartup.addListener(() => void startup());
|
||||
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.storage.onChanged.addListener((changes, areaName) => {
|
||||
if (areaName === "sync") {
|
||||
void startup();
|
||||
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));
|
||||
}
|
||||
});
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
@@ -902,4 +954,4 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
return true;
|
||||
});
|
||||
|
||||
void startup();
|
||||
void startup().catch((error) => console.warn("zncm startup failed", error));
|
||||
@@ -0,0 +1,130 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const BACKGROUND_PATH = path.join(__dirname, "background.js");
|
||||
|
||||
function createHarness() {
|
||||
let storageChangedListener;
|
||||
let alarmCreateCount = 0;
|
||||
const settings = {
|
||||
apiBaseUrl: "http://localhost:8001",
|
||||
webBaseUrl: "http://localhost:5174",
|
||||
autoDiscoverServiceUrl: false,
|
||||
accessToken: "",
|
||||
tokenSyncEnabled: false,
|
||||
taskPollingEnabled: true,
|
||||
workerId: "test-worker",
|
||||
taskPollIntervalSeconds: 5,
|
||||
};
|
||||
|
||||
const chrome = {
|
||||
alarms: {
|
||||
clear: async () => {},
|
||||
create: async () => {
|
||||
alarmCreateCount += 1;
|
||||
},
|
||||
onAlarm: { addListener: () => {} },
|
||||
},
|
||||
cookies: { get: async () => null },
|
||||
runtime: {
|
||||
onInstalled: { addListener: () => {} },
|
||||
onMessage: { addListener: () => {} },
|
||||
onStartup: { addListener: () => {} },
|
||||
},
|
||||
storage: {
|
||||
local: {
|
||||
get: async (defaults) => ({ ...defaults }),
|
||||
set: async () => {},
|
||||
},
|
||||
session: { get: async () => ({ actSessions: {} }), set: async () => {} },
|
||||
sync: {
|
||||
get: async () => ({ ...settings }),
|
||||
set: async (updates) => Object.assign(settings, updates),
|
||||
},
|
||||
onChanged: {
|
||||
addListener: (listener) => {
|
||||
storageChangedListener = listener;
|
||||
},
|
||||
},
|
||||
},
|
||||
tabs: { query: async () => [] },
|
||||
};
|
||||
|
||||
const context = {
|
||||
chrome,
|
||||
console,
|
||||
fetch: async () => {
|
||||
throw new Error("offline test");
|
||||
},
|
||||
importScripts: () => {},
|
||||
setTimeout: () => 1,
|
||||
clearTimeout: () => {},
|
||||
URL,
|
||||
DEER_CLIPPER_DEFAULTS: settings,
|
||||
deerNormalizeBaseUrl: (value) => String(value || "").replace(/\/+$/, ""),
|
||||
deerMakeWorkerId: () => "test-worker",
|
||||
deerAuthHeaders: () => ({}),
|
||||
deerExtractAuthTokenFromPage: () => ({ token: "" }),
|
||||
};
|
||||
context.globalThis = context;
|
||||
vm.runInNewContext(fs.readFileSync(BACKGROUND_PATH, "utf8"), context, {
|
||||
filename: BACKGROUND_PATH,
|
||||
});
|
||||
|
||||
return {
|
||||
emitStorageChange(changes) {
|
||||
storageChangedListener(changes, "sync");
|
||||
},
|
||||
getAlarmCreateCount: () => alarmCreateCount,
|
||||
resetAlarmCreateCount: () => {
|
||||
alarmCreateCount = 0;
|
||||
},
|
||||
getApiCandidates: (webBase, currentApiBase) =>
|
||||
vm.runInContext(`apiCandidatesForWebBase(${JSON.stringify(webBase)}, ${JSON.stringify(currentApiBase)})`, context),
|
||||
};
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
test("self-written sync metadata does not restart browser task polling", async () => {
|
||||
const harness = createHarness();
|
||||
await settle();
|
||||
harness.resetAlarmCreateCount();
|
||||
|
||||
harness.emitStorageChange({
|
||||
lastTokenSyncAt: { oldValue: "before", newValue: "after" },
|
||||
});
|
||||
await settle();
|
||||
|
||||
assert.equal(harness.getAlarmCreateCount(), 0);
|
||||
});
|
||||
|
||||
test("polling configuration changes restart browser task polling", async () => {
|
||||
const harness = createHarness();
|
||||
await settle();
|
||||
harness.resetAlarmCreateCount();
|
||||
|
||||
harness.emitStorageChange({
|
||||
taskPollingEnabled: { oldValue: false, newValue: true },
|
||||
});
|
||||
await settle();
|
||||
|
||||
assert.equal(harness.getAlarmCreateCount(), 1);
|
||||
});
|
||||
|
||||
test("service discovery prefers the API paired with the open zncm tab", async () => {
|
||||
const harness = createHarness();
|
||||
|
||||
assert.deepEqual(
|
||||
Array.from(harness.getApiCandidates("http://localhost:5174", "http://47.94.209.59:2026")),
|
||||
[
|
||||
"http://localhost:5174",
|
||||
"http://localhost:8001",
|
||||
"http://47.94.209.59:2026",
|
||||
"http://localhost:8001",
|
||||
].filter((value, index, values) => values.indexOf(value) === index),
|
||||
);
|
||||
});
|
||||
@@ -7,6 +7,8 @@
|
||||
const HOST_ID = "zncm-page-assistant-host";
|
||||
const state = {
|
||||
enabled: true,
|
||||
taskPollingEnabled: true,
|
||||
webBaseUrl: "",
|
||||
open: false,
|
||||
busy: false,
|
||||
threadId: "",
|
||||
@@ -709,20 +711,58 @@
|
||||
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" || !changes.pageAssistantEnabled) {
|
||||
if (areaName !== "sync") {
|
||||
return;
|
||||
}
|
||||
state.enabled = Boolean(changes.pageAssistantEnabled.newValue);
|
||||
ensureWidget();
|
||||
if (state.enabled) {
|
||||
setStatus("页面问答框已开启。");
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Wiki 数据流转与本地编码验收(2026-09-03)
|
||||
|
||||
## 结论
|
||||
|
||||
本地 BGE-M3 小批量编码已安装、配置并实际运行。已有 Wiki 的向量化、自动沉淀、
|
||||
普通库文件导出、助手库异步导入、助手库文件导出、空普通库回流及两端向量检索已实测通过。
|
||||
**技能自动生成 Wiki 尚未完成验收**:WeKnora 的两个生成模型都返回 HTTP 402
|
||||
`Insufficient Balance`。不能把后半段通过等同于全链路通过。
|
||||
|
||||
本次修改运行在本地 DeerFlow 开发服务(前端 5175、后端 8001),连接用户提供的远端
|
||||
WeKnora;没有把业务代码发布到服务器,没有提交 Git,也没有直接写 WeKnora 图数据库。
|
||||
|
||||
## 本地编码配置
|
||||
|
||||
- 实现:FastEmbed / ONNX Runtime CPU,官方 `BAAI/bge-m3` 权重。
|
||||
- 配置名:`bge-embedding-m3`,1024 维,每批 2 条,2 个 CPU 推理线程。
|
||||
- 运行依赖:FastEmbed 随标准后端依赖安装;模型必须提前放在离线目录中,仍按需加载,不影响主服务启动。
|
||||
- 权重:约 2.3 GB,持久缓存 `C:\Users\ji\.cache\deerflow\embeddings`,不进入业务仓库。
|
||||
- 配置:`llmwiki.local_wiki_index.embedding.provider: local`;可用
|
||||
`local_model_path` 指定离线权重目录,详见后端 README。
|
||||
- 查询向量在本地实时编码。已有 Wiki 在内容哈希、模型指纹匹配且向量非空时直接复用。
|
||||
不同模型或维度不能混用;导入不静默重新编码。
|
||||
- 原始文档分段向量与模型生成后的 Wiki 文本不同,不能相互冒用。本次新建的是 Wiki
|
||||
正文的 BGE-M3 索引,不是把既有 512 维文档向量改名为 1024 维。
|
||||
|
||||
## 实际数据验收
|
||||
|
||||
来源是已有普通知识库“图片测试111”,没有改写其 Wiki 内容。
|
||||
|
||||
| 阶段 | Wiki | Wiki 向量 | 实体 | 引用关系 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| 普通知识库导出 | 19 | 101 | 19 | 108 |
|
||||
| 助手知识库导入后导出 | 19 | 101 | 19 | 108 |
|
||||
| 新建普通知识库回流后导出 | 19 | 101 | 19 | 108 |
|
||||
| 知识梳理总库导出(7 个来源记录) | 19 | 101 | 19 | 108 |
|
||||
|
||||
逐项断言通过:
|
||||
|
||||
1. 三份包的 Wiki slug、标题、正文、摘要、页面类型、别名、分类路径、出链一致。
|
||||
2. 101 条向量的原始 float32 字节、分段编号、维度和模型指纹完全一致。
|
||||
3. 将导入前后实体 ID 映射回原 Wiki slug 后,108 条关系的端点及谓词集合完全一致。
|
||||
4. 总库导出没有因多个来源重复同步而重复导出同一当前页面的分段向量。
|
||||
5. 元数据回归测试另覆盖 tags、目录恢复、原始文档片段不计入 Wiki 数量、原生技能无
|
||||
ownership 记录时可被选中、短 Wiki 不漏向量、模型指纹不兼容时不假装向量检索。
|
||||
|
||||
普通包约 1.01 MB,助手包约 0.79 MB,回流包约 0.83 MB。普通包还带 2 个原始文档及
|
||||
108 个原始片段;助手导出提供 Wiki、Wiki 向量和关系,不重新上传原始文档触发二次生成。
|
||||
|
||||
### 检索
|
||||
|
||||
实际查询:“军方用什么工具评估极端天气带来的风险?”
|
||||
|
||||
- 回流普通库 `/api/llmwiki/search` 返回 `mode=vector`,首条“国防气候评估工具”。
|
||||
- 助手库 `/api/assistant-knowledge/search` 返回 `retrieval_mode=vector`,首条同上;
|
||||
首个命中分段余弦相似度约 0.6343。
|
||||
- 浏览器实际提交总库检索,显示“向量检索 · 余弦相似度”和相同命中;不是只检查返回字段。
|
||||
- 普通库采用已有页面级聚合评分,因此页面最终分数与单分段余弦值不必相等。
|
||||
- 回流的向量写入 **DeerFlow 管理的本地 Wiki 索引**。未写入 WeKnora 原生向量数据库,
|
||||
本次结论适用于本系统问答检索,不能声称原生 WeKnora 自身的检索也已完成该向量导入。
|
||||
|
||||
### 页面
|
||||
|
||||
普通与助手库复用同一 Wiki 阅读组件:目录树、类型/标签筛选、分类面包屑、别名标签、
|
||||
正文内链、出链、反向链接及邻接关系视图。窄窗口可收起目录。
|
||||
|
||||
浏览器验证了“索引 → 国防气候评估工具”、展开关系视图、助手 Wiki 跳回普通库来源,
|
||||
以及普通 Wiki 的“在知识梳理中查看”反向跳转。
|
||||
|
||||
### 验收数据位置
|
||||
|
||||
- 原有普通库:`48f669dc-433e-4aab-b5ea-897b6f7a3d2f`
|
||||
- 知识梳理总库:`c3fd9841-0718-44dc-860e-aae3020fd485`
|
||||
- 最终助手测试库:`489be905-08c8-40fe-8d60-42ac21dd945a`
|
||||
- 最终普通回流测试库:`428162aa-03f6-4c61-8994-360f2f39e0c9`
|
||||
|
||||
新建测试库名称均以“Wiki链路验收-0903-1826”开头,保留用于复核;未删除用户已有库。
|
||||
本地 `.runtime/wiki-live-result.json` 保存验收结果,`.runtime/wiki_live_acceptance.py`
|
||||
保存本次验收流程,均为忽略的运行产物,不包含认证口令。
|
||||
|
||||
## 这次实际修复的问题
|
||||
|
||||
- 内置技能只有磁盘目录、没有归属记录时,被错误判定为不存在。
|
||||
- 技能应上传去代码后的知识正文,让 WeKnora 生成 Wiki,而不是伪造一个 `skill` 类型页面。
|
||||
- Wiki 生成等待使用实际 stats 接口;零页结果明确失败,不无期限等待。
|
||||
- 向量化完成后自动导出沉淀到总库,并阻断导出回调重复触发全量同步。
|
||||
- 短摘要和短正文同时存在时,旧分段阈值会将整页过滤为零向量。
|
||||
- 分类路径必须先创建 WeKnora folder 并绑定 folder_id;只传 category_path 会被服务端清空。
|
||||
- Wiki 的标签/分类/别名/原始引用保留;图节点绑定真实 Wiki,避免生成重复的空实体页面。
|
||||
- 保留归一化 float32 原始字节;导入中不能用 `section_index or index`,因为合法编号 0 会被改写。
|
||||
- 总库多个来源保留审计,但检索与导出只使用当前 Wiki 版本对应的向量,避免旧贡献污染检索。
|
||||
- 包解析改为同盘临时 SQLite,上传先落盘再异步导入,导出完成后才提供文件下载。
|
||||
|
||||
## 阻塞与未通过的范围
|
||||
|
||||
1. 技能 `knowledge-base-ingest` 已送入专门创建的普通测试库,但远端
|
||||
`deepseek-v4-flash` 和 `qwen3.6-flash` 均因余额不足失败。WeKnora 即使失败也可能把
|
||||
文档 parse_status 标为 completed。需恢复可用生成模型后重新运行技能全链路验收。
|
||||
2. 已采用磁盘暂存和有界批处理,但本次只有约 1 MB 实测,**未做数 GB 压测、断点续传、
|
||||
进程中断恢复验收**。大包部署仍需磁盘容量、代理 body size 和超时配置。
|
||||
3. 当前实体去重主要按类型及规范化名称;这次没有完成跨别名、歧义实体的模型对齐及
|
||||
多来源事实冲突合并验收,不能视为这一历史需求已全面完成。
|
||||
4. 重复上传仍会保留独立来源记录。向量导出已消除旧版本重复,但上传身份幂等、部分远端
|
||||
写入失败后的无损重试、跨实例图片资源打包仍需独立验证。
|
||||
5. 当前版本已补充自动增量链路:服务启动立即补扫,之后默认每 30 秒扫描全部普通
|
||||
知识库;Wiki 抽取完成后无需手工点击向量化,并自动以来源快照覆盖同步到知识梳理总库。
|
||||
6. 回流当前只允许新建空 Wiki 普通库,避免覆盖已有页面。普通库现有页面列表的超大规模
|
||||
分页与助手问答的最终生成回答不属于这轮通过结论。
|
||||
|
||||
## 自动化检查
|
||||
|
||||
- 后端相关测试:118 passed(assistant repository、Wiki sync、package roundtrip、
|
||||
WeKnora 接口、向量检索与知识库管理)。
|
||||
- 修改的 Python 功能文件 Ruff 检查通过;app.py 既有启动顺序导致的 E402 不在此次改动中处理。
|
||||
- `git diff --check` 通过(只有仓库换行提示)。
|
||||
- 前端完整 `pnpm typecheck` 未通过:57 个既有/本轮范围外错误,涉及 nextra 缺失、Markdown
|
||||
插件类型及其他模块;本轮新建/修改的 Wiki 组件、助手 API、技能面板没有出现在错误清单中。
|
||||
浏览器实际页面与跳转已验证,但不能声称全项目构建通过。
|
||||
@@ -0,0 +1,719 @@
|
||||
# AgentScope 2.0 多智能体报告协作工作台——前端实施计划
|
||||
|
||||
> 文档状态:前端代码已落地、契约冻结;联调等待后端运行中干预与报告改写
|
||||
> 编写日期:2026-09-02;进度更新日期:2026-09-06
|
||||
> 目标项目:`F:\react01\deeflow-code\deerflow-server\frontend-web`
|
||||
> 视觉与交互参考:`F:\react01\coze-frontend\frontend\apps\coze-studio`
|
||||
> 参考页面:`http://localhost:3000/work_flow?space_id=1&workflow_id=wf_88cb9f4c6642`
|
||||
> 配套后端计划:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md`
|
||||
> 本文只规划新增页面,不修改现有多智能体会商页面,不复用旧 Workflow Studio 执行状态。
|
||||
|
||||
## 0. 当前进度
|
||||
|
||||
`frontend-web/src/report-collaboration/` 已实现两栏工作台、会话/方案/run API 客户端、mock driver、SSE reducer、`MessageList` 适配、方案卡、画布、节点抽屉、Composer、报告预览与历史。`RC-FE-001`~`013` 的页面骨架与契约层视为完成。
|
||||
|
||||
后端已通规划前路径(澄清 → 出方案 → 选定 → `queued` run),见后端计划 §0。前端真实执行体验仍受阻:
|
||||
|
||||
- `GET /runs/{id}/stream` 已挂载(`RC-BE-011`);dispatcher/租约已落地(`RC-BE-012`),默认 `worker_enabled=false` 仍不自动领取 queued run。
|
||||
- 报告改写 / 恢复接口已由后端 `RC-BE-015` 挂载。
|
||||
- 质量角色已由 `QualityRoleKernel` 跑通(`RC-BE-013`);真实 AgentScope 模型/`web_search` 仍待后续票。
|
||||
|
||||
联调顺序:先打开 `report_collaboration.enabled` 验证规划前 REST 与 `GET /runs/{id}/stream` 重放;画布自动执行需同时打开 `worker_enabled`(默认关)。
|
||||
|
||||
## 1. 前端目标
|
||||
|
||||
新增独立的「报告协作」工作台。用户可以通过对话提出任意报告需求,查看系统生成的多个协作方案,点击方案卡片在流程图中预览协作结构,确认后观察多智能体执行,并在全过程通过统一对话框提出修改意见。
|
||||
|
||||
前端必须满足:
|
||||
|
||||
1. 页面采用“左侧对话、右侧流程画布”的固定两栏布局,画布、工具栏、弹窗和抽屉参考 Coze Studio 页面风格。
|
||||
2. 消息列表必须复用 DeerFlow 现有 `MessageList`,不复制 Coze 的会话消息实现。
|
||||
3. 所有执行过程、AgentScope 团队消息、智能体回复、工具调用及其原始返回、错误、协助卡、返工和最终报告入口都通过现有消息协议按时间顺序显示在左侧对话区,不设置独立执行结果面板,也不另做“阶段摘要”替代实时消息。
|
||||
4. 候选方案、流程节点、对话进度和报告使用同一份后端事实,不各自推断状态。
|
||||
5. 用户可在规划中、运行中、等待输入和报告完成后继续对话。
|
||||
6. 用户协助卡的出现和提交都不能在前端被解释为“节点已经完成”。
|
||||
7. 页面刷新、SSE 断线重连、历史会话切换后能恢复到一致状态。
|
||||
|
||||
暂定:
|
||||
|
||||
- 页面名称:`报告协作`。
|
||||
- 路由:`/page/workspace/report-collaboration`。
|
||||
- 会话路由:`/page/workspace/report-collaboration/:sessionId`。
|
||||
- 默认候选方案数量:3。
|
||||
- 点击画布节点时打开覆盖式节点说明抽屉;抽屉只展示节点职责和合同,不展示节点执行结果。
|
||||
|
||||
---
|
||||
|
||||
## 2. 范围与非目标
|
||||
|
||||
### 2.1 本期范围
|
||||
|
||||
- 新建、查看、重命名、删除协作报告会话。
|
||||
- 使用对话澄清报告需求。
|
||||
- 显示 2~3 个候选方案卡片。
|
||||
- 卡片与右侧流程图预览联动。
|
||||
- 节点说明抽屉:职责、所依据的信息类型、预期产出、工具、资料要求和验收条件。
|
||||
- 实时展示节点排队、运行、校验、等待用户、完成、返工、失败和取消。
|
||||
- 在左侧对话时间线中直接展示每个智能体的流式文本、团队对话、工具调用和工具返回,并展示审稿意见、报告文件和报告版本。
|
||||
- 并行阶段用横向的智能体选择器切换当前查看对象;选择器只控制同一份消息流的聚焦范围,不创建另一套执行结果视图。
|
||||
- 运行期间发送补充要求、重新检索、重新分析、节点重跑等指令。
|
||||
- 完成后进行报告问答、章节重写、全文润色和版本恢复。
|
||||
- 报告模板选择、Markdown 预览、Markdown/Word 下载。
|
||||
- 响应式布局、键盘操作、加载反馈和错误恢复。
|
||||
|
||||
### 2.2 明确不做
|
||||
|
||||
- 不引入 `@coze-workflow/playground` 或 `@coze-arch/coze-design`。
|
||||
- 不加载 Coze workflow definition、run 或 node run。
|
||||
- 不允许前端拖拽连线后直接改变执行逻辑。
|
||||
- 不新增第二套消息气泡、Markdown、引用和文件卡渲染器。
|
||||
- 不展示模型原始 chain-of-thought、系统/内部 prompt、密钥和被标记为内部控制信息的字段。
|
||||
- 不把用户有权查看的 AgentScope TeamSay、智能体文本、工具调用或工具返回压缩成“正在检索”等摘要卡;这些原始业务消息必须进入复用的 `MessageList`。敏感字段只能按统一脱敏策略替换,不能以摘要替代。
|
||||
- 不增加第三栏、常驻详情栏、节点执行结果栏或独立执行时间线。
|
||||
- 节点抽屉不展示节点生成正文、工具返回、证据列表、错误堆栈或 attempt 历史;这些过程统一在左侧 `MessageList` 的原生消息/工具步骤中查看。
|
||||
- 不改造现有圆桌、多智能体会商、深度研究和 AI 写作页面。
|
||||
- 不把浏览器状态作为业务事实源。
|
||||
|
||||
---
|
||||
|
||||
## 3. 参考页面行为映射
|
||||
|
||||
| 目标行为 | Coze 参考文件 | DeerFlow 目标组件 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 两栏骨架 | `workflow-studio-layout.tsx` 的面板比例与分隔条 | `ReportCollaborationLayout.tsx` | 左对话、右画布;只保留一条可调整分隔线 |
|
||||
| 顶栏 | `workflow-studio-header.tsx` | `ReportCollaborationHeader.tsx` | 替换为会话、模板、报告、停止等操作 |
|
||||
| 候选卡片 | `workflow-proposal-cards.tsx` | `ReportPlanCards.tsx` | 点击预览,明确确认后才执行 |
|
||||
| 右侧画布 | `workflow-canvas.tsx` | `ReportFlowCanvas.tsx` | 使用 `@xyflow/react` 重建,不引入 Coze Playground |
|
||||
| 左侧消息区 | `run-conversation-panel.tsx` | `ReportConversationPanel.tsx` | 外层风格参考 Coze,内部复用 DeerFlow `MessageList` |
|
||||
| 节点说明抽屉 | Coze 节点配置面板的视觉与抽屉行为 | `NodeInspectorDrawer.tsx` | 点击画布节点打开,只展示职责与输入输出合同 |
|
||||
| 全程执行反馈 | 参考其进度和状态表达 | `AgentStageSelector` + DeerFlow `MessageList` | 选择智能体后直接显示其流式消息、TeamSay、工具调用与返回;不再合成为阶段摘要 |
|
||||
| 状态栏 | `connection-status.tsx` | `ConnectionStatusBar.tsx` | 展示连接、重连和事件游标 |
|
||||
| 对话历史 | `left-panel-tabs.tsx` | `ConversationSidebarTabs.tsx` | 对话与历史,不增加“节点资源”Tab |
|
||||
| 异步反馈 | 规划进度、运行状态 | MessageList 步骤条 + 画布状态 | 操作超过 300ms 必须有反馈 |
|
||||
|
||||
主要参考源:
|
||||
|
||||
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\workflow-studio-layout.tsx`
|
||||
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\workflow-studio-composition.tsx`
|
||||
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\workflow-studio-header.tsx`
|
||||
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\run-conversation\workflow-proposal-cards.tsx`
|
||||
- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\styles.module.less`
|
||||
|
||||
若直接复制 Apache-2.0 源码片段或样式,必须保留版权头并维护来源说明;默认做行为映射和视觉重建。
|
||||
|
||||
---
|
||||
|
||||
## 4. 页面结构
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ 返回 报告协作 / 会话标题 保存状态 模板 报告 停止/继续 更多 │
|
||||
├────────────────────────────────┬─────────────────────────────────────────┤
|
||||
│ 对话 / 历史 │ 协作流程图 │
|
||||
│ │ │
|
||||
│ DeerFlow MessageList │ @xyflow/react │
|
||||
│ · 并行时:[协调者][市场][政策] │ · 节点状态与活动连线 │
|
||||
│ · 用户与协调者/成员消息 │ · 缩放、适配视图、小地图 │
|
||||
│ · 原生 ToolCall / ToolMessage │ · 点击节点打开覆盖式说明抽屉 │
|
||||
│ · 协助卡、报告文件和版本 │ │
|
||||
│ │ │
|
||||
│ 统一输入框 │ │
|
||||
├────────────────────────────────┴─────────────────────────────────────────┤
|
||||
│ 已连接 · run_xxx · 事件 128 │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
节点抽屉覆盖在右侧画布之上,不占用第三栏:
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 节点名称 关闭 × │
|
||||
│ 角色 / 当前状态 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 节点职责 │
|
||||
│ 要根据什么:所需输入、上游类型、资料要求 │
|
||||
│ 要产出什么:成果类型、格式、验收条件 │
|
||||
│ 可用工具与边界 │
|
||||
│ 上下游关系 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 在对话中对此节点提出意见 │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
抽屉明确不展示实际执行结果、生成正文、工具返回、来源明细、错误堆栈或 attempt 时间线;这些信息只在左侧原生消息流中展示。
|
||||
|
||||
布局默认值:
|
||||
|
||||
- 顶栏高度:56px。
|
||||
- 左侧面板:默认 480px,最小 360px,最大 720px。
|
||||
- 右侧画布:最小 480px,使用剩余空间。
|
||||
- 节点说明抽屉:覆盖画布右侧,建议宽度 420px;关闭后完整归还画布空间。
|
||||
- 状态栏:24~28px。
|
||||
- 低于 960px:左侧对话和右侧画布改为顶部按钮切换的单面板模式;节点说明仍使用覆盖式 Drawer。
|
||||
- 两栏分隔条:支持 pointer 拖动、方向键、Home、End。
|
||||
|
||||
### 4.1 原生消息流与并行智能体切换
|
||||
|
||||
“复用 `MessageList`”在本页面的含义是复用它现有的 assistant 文本、`tool_calls`、`tool` 消息、引用、文件和协助卡渲染语义;不把 AgentScope 的运行过程先解析成专用步骤条、进度摘要或阶段结果卡。
|
||||
|
||||
单个智能体:
|
||||
|
||||
- `agent.reply.started` 立即创建一条稳定的 assistant 消息;`message.delta` 持续更新同一 `messageId`,所以用户会先看到流式状态、再看到逐字回复,而不是等待节点结束。
|
||||
- 工具调用创建/更新该 assistant 消息的 `tool_calls`;工具返回创建匹配 `tool_call_id` 的原生 `tool` 消息。现有 `MessageGroup` 负责把两者配对显示,不再另写工具过程渲染器。
|
||||
- AgentScope TeamSay 作为带 `agentRunId` 的普通 assistant 消息进入同一流,按到达顺序显示。它不是节点完成的证据,节点状态仍由右侧画布和后端状态机决定。
|
||||
|
||||
并行智能体:
|
||||
|
||||
```text
|
||||
多角度并行研究 3/4 进行中
|
||||
[ 协调者 ] [ 市场规模 ✓ ] [ 品牌竞争 ● ] [ 海外市场 ● ] [ 政策风险 ! ]
|
||||
└──────────────────── 当前选中智能体的原生 MessageList ────────────────────┘
|
||||
```
|
||||
|
||||
- `AgentStageSelector` 只在并行阶段出现,横向放置在左侧对话区顶部;使用现有图标体系的头像/角色图标、名称、状态文字和非纯颜色状态标识。默认选中第一位已开始运行的成员;用户手动切换后保持选择,不因其他成员返回而抢走阅读焦点。
|
||||
- 所有成员的事件都持续接收、按每个 `agentRunId` 持久化和更新,即使当前未选中;切回即可看到完整历史与正在更新的同一条消息,不丢 token、不重新请求、不把结果拼成摘要。
|
||||
- 当前选择只将共享用户消息、协调者广播消息和目标成员消息组成一个 `BaseStream<AgentThreadState>` 交给同一个 `MessageList`。切换标签仅切换该视图的消息范围,不改变 run、节点或消息的后端事实。
|
||||
- 某成员请求用户协助时,其标签显示“待回复”徽标,选择器显示待处理数;点击徽标切到该成员,原生协助工具消息仍由现有 `MessageList` 协助卡渲染。其他成员可继续运行。
|
||||
- 节点抽屉打开某并行成员时,同步聚焦对应标签;抽屉仍只展示合同。用户从抽屉或输入框发送反馈时带 `targetNodeId`/`agentRunId`,后端在安全点生效。
|
||||
|
||||
这不是把完整日志另做成调试面板:对话区展示的是模型和工具的真实业务消息;只有 chain-of-thought、内部提示词及按权限必须脱敏的字段不进入该消息流。
|
||||
|
||||
### 4.2 页面主要状态
|
||||
|
||||
| 状态 | 左侧对话区 | 右侧流程画布 | 主要操作 |
|
||||
|---|---|---|---|
|
||||
| `empty` | 欢迎语和输入框 | 空态 | 输入需求、选模板 |
|
||||
| `clarifying` | 澄清问题和协助卡 | 需求理解节点 | 回答或补充 |
|
||||
| `planning` | 用户消息和规划进度 | 骨架或需求图 | 继续补充,等待安全点生效 |
|
||||
| `proposal_ready` | 2~3 张方案卡 | 点击卡片预览对应图 | 切换、修改、采用 |
|
||||
| `running` | 协调者与成员原生消息、工具过程和用户反馈 | 实时节点状态 | 干预、停止、在对话中要求重跑 |
|
||||
| `awaiting_input` | 等待用户卡片、原因和影响 | 对应节点显示等待 | 回答、取消 |
|
||||
| `reviewing` | 审稿意见和返工过程 | 评审/返工节点状态 | 接受或补充要求 |
|
||||
| `completed` | 总结、报告文件卡、版本和后续对话 | 完整执行图 | 问答、改写、导出 |
|
||||
| `failed` | 错误说明和恢复建议 | 失败节点 | 通过对话重试或取消 |
|
||||
| `cancelled` | 已保留成果说明 | 未完成节点取消 | 从已有成果继续或新建方案 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端目录与组件边界
|
||||
|
||||
建议新增:
|
||||
|
||||
```text
|
||||
frontend-web/src/report-collaboration/
|
||||
api/
|
||||
client.ts
|
||||
sessions.ts
|
||||
plans.ts
|
||||
runs.ts
|
||||
reports.ts
|
||||
types.ts
|
||||
components/
|
||||
ReportCollaborationLayout.tsx
|
||||
ReportCollaborationHeader.tsx
|
||||
ConversationSidebarTabs.tsx
|
||||
ReportConversationPanel.tsx
|
||||
AgentStageSelector.tsx
|
||||
ReportComposer.tsx
|
||||
ReportPlanCards.tsx
|
||||
ReportFlowCanvas.tsx
|
||||
ReportFlowNode.tsx
|
||||
NodeInspectorDrawer.tsx
|
||||
ReportPreviewDialog.tsx
|
||||
InterventionCard.tsx
|
||||
SessionHistoryPanel.tsx
|
||||
ConnectionStatusBar.tsx
|
||||
hooks/
|
||||
useReportCollaborationSession.ts
|
||||
useReportCollaborationStream.ts
|
||||
useReportCollaborationMessages.ts
|
||||
useReportPlanSelection.ts
|
||||
useReportPanelLayout.ts
|
||||
state/
|
||||
event-normalizer.ts
|
||||
event-reducer.ts
|
||||
selectors.ts
|
||||
message-adapter.ts
|
||||
pages/
|
||||
ReportCollaborationPage.tsx
|
||||
styles/
|
||||
report-collaboration.css
|
||||
tests/
|
||||
```
|
||||
|
||||
修改范围:
|
||||
|
||||
- `frontend-web/src/pages/WorkspaceRoutes.tsx`:增加页面和会话路由。
|
||||
- `frontend-web/src/components/workspace/workspace-nav-chat-list.tsx`:增加导航入口。
|
||||
- `frontend-web/src/components/workspace/messages/message-list.tsx`:仅允许增加一个向后兼容的通用业务卡片插槽。
|
||||
- `frontend-web/src/core/messages/utils.ts`:如确需识别通用业务卡类型,只做集中式扩展。
|
||||
|
||||
不得把新功能放进:
|
||||
|
||||
- `frontend-web/src/roundtable-planning/`
|
||||
- `frontend-web/src/open-canvas/`
|
||||
- Coze 项目的 `workflow-standalone/`
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据来源与前端状态
|
||||
|
||||
### 6.1 三层状态
|
||||
|
||||
前端状态分为:
|
||||
|
||||
1. 服务器快照:session、原生 messages、plans、run、nodes、artifacts、report versions;每条消息带稳定的 `agentRunId`、`nodeRunId`、`phaseId` 和可见性标记,其中 artifacts 只用于生成左侧消息卡和报告文件,不形成独立结果面板。
|
||||
2. durable SSE reducer:从 `seq` 连续应用事件,更新服务器快照投影。
|
||||
3. 纯展示状态:预览中的方案、选中的节点、聚焦的并行 `agentRunId`、两栏宽度、节点抽屉开关、窄屏当前视图和画布位置。
|
||||
|
||||
纯展示状态不能改变执行结果。点击卡片只改变 `previewPlanId`;只有点击“采用方案”才改变服务器 `selectedPlanId`。
|
||||
|
||||
### 6.2 前端事件包
|
||||
|
||||
```ts
|
||||
interface CollaborationEvent<T = unknown> {
|
||||
eventId: string;
|
||||
sessionId: string;
|
||||
runId?: string;
|
||||
seq: number;
|
||||
type: string;
|
||||
timestamp: string;
|
||||
data: T;
|
||||
}
|
||||
```
|
||||
|
||||
必须处理:
|
||||
|
||||
- 快照先到、事件后到。
|
||||
- 事件重复。
|
||||
- 事件短暂乱序。
|
||||
- SSE 断线后从 `after_seq` 重放。
|
||||
- 页面切换时旧连接晚到事件。
|
||||
- token delta 多次更新同一消息。
|
||||
- TeamSay、assistant 文本、tool-call 参数 delta、tool 返回 delta 分别更新各自稳定消息,不得等节点完成后批量生成摘要。
|
||||
- 并行成员的消息可交错到达;Reducer 必须按 `agentRunId + messageId` 更新,而不是按当前被选中的标签丢弃未聚焦成员的事件。
|
||||
- 节点重新打开后出现新 attempt。
|
||||
|
||||
Reducer 以 `sessionId + runId + seq` 去重;消息行以 `messageId`、工具结果以 `toolCallId`、标签以 `agentRunId` 使用后端稳定 ID,禁止使用数组下标。
|
||||
|
||||
---
|
||||
|
||||
## 7. 分步实施计划
|
||||
|
||||
### FE-0:冻结参考视觉与行为基线
|
||||
|
||||
任务:
|
||||
|
||||
1. 在 1440×900、1280×800、1024×768 打开参考页面。
|
||||
2. 保存初始态、方案态、运行态和节点说明抽屉四组截图。
|
||||
3. 记录顶栏、两栏比例、间距、圆角、边框、阴影、画布工具栏和状态色。
|
||||
4. 形成“参考组件—目标组件—允许差异”表。
|
||||
5. 明确消息列表保持 DeerFlow 原样,不纳入像素对齐。
|
||||
|
||||
交付物:
|
||||
|
||||
- 视觉基线截图。
|
||||
- 视觉 token 表。
|
||||
- 页面行为清单。
|
||||
|
||||
验收:参考的每个用户可见状态都有截图或明确说明不存在。
|
||||
|
||||
### FE-1:路由、菜单和静态页面骨架
|
||||
|
||||
任务:
|
||||
|
||||
1. 增加两个路由和菜单项。
|
||||
2. 创建 `ReportCollaborationPage`。
|
||||
3. 创建顶栏、左侧对话栏、右侧画布和底部连接栏。
|
||||
4. 实现可调整宽度的两栏布局,以及“专注对话/专注画布”切换。
|
||||
5. 实现窄屏单面板切换与覆盖式节点 Drawer。
|
||||
|
||||
验收:
|
||||
|
||||
- 不连接后端也能呈现完整空态。
|
||||
- 375~1440px 不出现页面级横向滚动。
|
||||
- 对话/画布切换不会卸载会话状态或清空输入内容。
|
||||
- 键盘可以调整两栏分隔条。
|
||||
|
||||
### FE-2:页面设计系统与 Coze 风格重建
|
||||
|
||||
建立页面专用语义变量:
|
||||
|
||||
```text
|
||||
--rc-bg-page
|
||||
--rc-bg-panel
|
||||
--rc-bg-canvas
|
||||
--rc-border
|
||||
--rc-text-primary
|
||||
--rc-text-secondary
|
||||
--rc-accent
|
||||
--rc-running
|
||||
--rc-waiting
|
||||
--rc-success
|
||||
--rc-danger
|
||||
--rc-panel-shadow
|
||||
--rc-card-radius
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 从 DeerFlow 主题 token 派生,不在组件中散落十六进制颜色。
|
||||
- 使用 Lucide SVG 图标,不使用 emoji。
|
||||
- hover/focus/展开动画控制在 150~300ms。
|
||||
- 支持 `prefers-reduced-motion`。
|
||||
- 交互状态不能只靠颜色表达。
|
||||
- 正文对比度达到 4.5:1。
|
||||
- 操作超过 300ms 显示 spinner、骨架或流式状态。
|
||||
|
||||
验收:除 MessageList 内部外,两栏布局、密度、卡片、画布工具栏、弹窗和节点抽屉达到参考基线。
|
||||
|
||||
### FE-3:API 类型和 TanStack Query 层
|
||||
|
||||
实现:
|
||||
|
||||
- session CRUD。
|
||||
- messages 查询与发送。
|
||||
- plan request、plans 查询、select。
|
||||
- run 创建、查询、停止、重试。
|
||||
- command 创建、确认、取消。
|
||||
- artifacts、sources、reports、versions 查询。
|
||||
|
||||
每个写请求携带:
|
||||
|
||||
- `idempotencyKey`
|
||||
- `expectedRevision`
|
||||
|
||||
冲突行为:
|
||||
|
||||
- `409`:刷新当前快照并提示用户重新确认。
|
||||
- `422`:在对应操作附近显示具体错误。
|
||||
- `429`:显示预算/并发限制,不自动重试。
|
||||
- `5xx`:保留用户输入,允许使用同一幂等键重试。
|
||||
|
||||
验收:API 层不包含页面展示逻辑,类型与后端 OpenAPI 一致。
|
||||
|
||||
### FE-4:SSE、快照和事件 Reducer
|
||||
|
||||
实现 `useReportCollaborationStream`:
|
||||
|
||||
1. 先拉 session snapshot。
|
||||
2. 使用 snapshot 的 `lastSeq` 建立 SSE。
|
||||
3. 校验每个事件 session/run 身份。
|
||||
4. 按 seq 应用事件并去重。
|
||||
5. 出现序号缺口时暂停 live apply,补拉缺失事件。
|
||||
6. 断线指数退避并支持 `Last-Event-ID`。
|
||||
7. 终态后完成最后一次事件补拉再关闭连接。
|
||||
|
||||
Reducer 必须覆盖:
|
||||
|
||||
- 消息流增量。
|
||||
- 计划候选和 revision。
|
||||
- node/node attempt 状态。
|
||||
- artifact 创建和校验。
|
||||
- command 分类和执行。
|
||||
- report delta 和正式版本。
|
||||
- 错误、取消、接管恢复。
|
||||
- AgentScope 原始可见消息和工具事件:`message.created/delta/completed`、`tool_call.created/delta/completed`、`tool_result.created/delta/completed`、`team_message.created/delta/completed`。
|
||||
|
||||
验收:重复事件、乱序事件和重连都不产生重复消息、重复节点或状态倒退。
|
||||
|
||||
### FE-5:复用 DeerFlow `MessageList`
|
||||
|
||||
复用:
|
||||
|
||||
- `frontend-web/src/components/workspace/messages/message-list.tsx`
|
||||
- `frontend-web/src/components/workspace/messages/message-list-item.tsx`
|
||||
- `frontend-web/src/components/workspace/messages/message-group.tsx`
|
||||
- `frontend-web/src/components/workspace/messages/context.ts`
|
||||
|
||||
实现无损协议适配 `message-adapter.ts`:
|
||||
|
||||
1. 将已持久化的 `CollaborationMessage` 一对一映射为 LangGraph SDK `Message[]`,不将文本或工具结果二次概括。
|
||||
2. 构造最小 `BaseStream<AgentThreadState>` 外观:`messages`、`isLoading`、`isThreadLoading`、`values`。
|
||||
3. 协调者、成员 AgentScope TeamSay 和成员回复都映射为普通 assistant 消息;保留 `messageId`、文本 chunk、角色/成员名称、`agentRunId`、`nodeRunId`、`phaseId`、来源事件 ID 和完成态。
|
||||
4. AgentScope 工具调用映射为该 assistant 消息的标准 `tool_calls`;调用结果映射为标准 `tool` 消息,并严格复用原有 `tool_call_id` 配对。工具参数、返回正文及增量保持原文,只有后端明确标注的脱敏字段被替换。
|
||||
5. 将来源、文件、报告 Markdown delta 和正式版本继续映射为既有引用、文件卡和普通 assistant 消息;规划卡、用户确认卡和报告替换卡仍通过一个通用业务卡片插槽渲染。
|
||||
6. 错误、恢复建议、命令影响范围和报告版本变化也必须进入同一时间线;错误堆栈以可读错误消息替代,但不能吞掉已发生的工具/成员消息。
|
||||
7. `selectMessagesForAgentRun(allMessages, focusedAgentRunId)` 只在客户端从已同步消息中选出共享消息与目标成员消息;并行选择器不得重新拉取、重新排序或改写消息。
|
||||
|
||||
约束:
|
||||
|
||||
- 不使用 LangGraph `useThreadStream` 发送消息。
|
||||
- 新页面输入框调用报告协作 API。
|
||||
- 不增加多个页面专用 MessageList props。
|
||||
- 普通聊天、AI 写作、深度研究等未传插槽时必须零变化。
|
||||
- 不另建执行结果列表、节点结果面板或成果时间线。
|
||||
- 不用“运行中”“已检索 N 条”等专用摘要替代真实消息。`MessageList` 本身的流式状态和原生工具步骤是唯一过程视图。
|
||||
|
||||
验收:
|
||||
|
||||
- 历史和流式消息使用同一行稳定更新。
|
||||
- 单成员首次开始回复、每次工具启动和工具返回均在 300ms 内出现可见的原生消息行或原生工具步骤;不允许持续数秒只有静态加载圈。
|
||||
- 并行标签切换后显示目标成员的完整已持久化消息和实时 delta;未选中成员持续接收,标签上的运行/待回复状态及时更新。
|
||||
- 用户上翻阅读时不强制滚底。
|
||||
- 用户发送新消息后滚到最新位置。
|
||||
- 卡片显示成功不改变后端节点状态。
|
||||
|
||||
### FE-6:需求澄清和候选方案卡片
|
||||
|
||||
`ReportPlanCards` 展示:
|
||||
|
||||
- 推荐标签、策略、标题、摘要和推荐理由。
|
||||
- 分析角度和参与角色。
|
||||
- 关键步骤与质量门。
|
||||
- 预计时间、成本等级和资料范围。
|
||||
- 方案验证错误。
|
||||
|
||||
交互:
|
||||
|
||||
- 点击卡片或“查看流程”只切换预览。
|
||||
- “采用此方案”调用 select API。
|
||||
- “开始协作”再次校验并创建 run。
|
||||
- 方案调整后显示新 revision;旧 revision 只读。
|
||||
- 过期 revision 返回 409 后不静默覆盖。
|
||||
|
||||
验收:连续快速切换三张卡时,画布最终显示最后一次选择,不闪回旧图。
|
||||
|
||||
### FE-7:协作流程画布
|
||||
|
||||
技术:`@xyflow/react`。可以借鉴但不直接耦合:
|
||||
|
||||
- `frontend-web/src/components/ai-elements/canvas.tsx`
|
||||
- `frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx`
|
||||
|
||||
节点显示:
|
||||
|
||||
- 节点名、角色名和分析角度。
|
||||
- 状态文字、图标和颜色。
|
||||
- 等待用户、返工、失败和 superseded 标记。
|
||||
|
||||
画布能力:
|
||||
|
||||
- 自动布局。
|
||||
- 拖动节点只保存浏览器展示位置。
|
||||
- 缩放、适配视图、方向切换、小地图。
|
||||
- 活动边低强度流动动画。
|
||||
- 点击节点打开覆盖在画布上的节点说明抽屉。
|
||||
- 节点重跑、补充或纠偏一律通过左侧对话完成;抽屉只提供“在对话中对此节点提出意见”入口。
|
||||
- 方案切换后清理不属于新方案的本地坐标。
|
||||
|
||||
验收:
|
||||
|
||||
- 30 个节点内流畅操作。
|
||||
- 单个 node event 不导致整个 React Flow 重挂。
|
||||
- `awaiting_input`、`reopened`、`superseded` 不与 `completed` 混淆。
|
||||
|
||||
### FE-8:节点说明抽屉与对话定位
|
||||
|
||||
点击画布节点打开 `NodeInspectorDrawer`。抽屉只展示计划合同:
|
||||
|
||||
1. 节点名称、角色、分析角度和当前状态标签。
|
||||
2. 节点职责:负责解决什么问题。
|
||||
3. 要根据什么:所需输入类型、上游节点类型、资料范围和时效要求。
|
||||
4. 要产出什么:成果类型、结构要求和验收条件。
|
||||
5. 可用工具、禁止事项、预算上限。
|
||||
6. 上游与下游关系。
|
||||
|
||||
抽屉不展示:
|
||||
|
||||
- 节点实际生成内容。
|
||||
- 工具调用返回值。
|
||||
- EvidenceBundle 或其他成果物正文。
|
||||
- attempt 执行时间线。
|
||||
- 错误堆栈和调试信息。
|
||||
- 报告正文。
|
||||
|
||||
交互:
|
||||
|
||||
- “在对话中对此节点提出意见”关闭抽屉、聚焦左侧 Composer,并绑定 `targetNodeId` 上下文。
|
||||
- 若该节点对应正在并行运行的成员,同时绑定该成员的 `agentRunId` 并切换左侧选择器,使用户发送前能先看到该成员的真实对话与工具过程。
|
||||
- 用户仍需在对话区输入“重新检索”“重新分析”等要求;抽屉不直接发起业务变更。
|
||||
- 状态标签来自后端节点状态,但不加载节点结果接口。
|
||||
- 关闭抽屉不清空 selected node;再次点击同一节点可恢复说明。
|
||||
|
||||
报告预览不使用常驻第三栏。最终报告、章节候选和版本先作为左侧 MessageList 中的普通消息/文件卡出现;点击文件卡时复用 `ArtifactFileDetail` 或现有 Markdown 能力打开大尺寸 Dialog,关闭后回到两栏工作台。
|
||||
|
||||
验收:
|
||||
|
||||
- 抽屉宽度不改变两栏布局,不挤压左侧对话区。
|
||||
- 抽屉只读取节点合同和图结构,不请求节点执行结果。
|
||||
- 所有原生成员消息、工具过程、错误和报告入口都能在左侧对话时间线找到。
|
||||
|
||||
### FE-9:全过程对话干预
|
||||
|
||||
`ReportComposer` 根据状态显示不同提示:
|
||||
|
||||
- 空闲:输入报告需求。
|
||||
- 规划中:补充要求将在规划安全点应用。
|
||||
- 运行中:协调者正在判断影响范围。
|
||||
- 等待用户:优先回答等待问题。
|
||||
- 完成后:可问答、重写、润色或重新研究。
|
||||
|
||||
发送流程:
|
||||
|
||||
1. 生成 idempotency key。
|
||||
2. 插入 pending 用户消息;带 `targetNodeId`/`agentRunId` 时在相关成员消息流和共享上下文中引用同一条用户消息,不重复持久化。
|
||||
3. 显示“正在识别修改范围”。
|
||||
4. `command.classified` 后展示意图和影响节点。
|
||||
5. 高成本或低置信度命令显示确认卡。
|
||||
6. `command.accepted` 后显示排队或等待安全点。
|
||||
7. 完成后关联新 node attempt 或 report version。
|
||||
8. 失败时保留原消息和重试入口。
|
||||
|
||||
典型映射:
|
||||
|
||||
| 用户输入 | 预期操作 |
|
||||
|---|---|
|
||||
| “增加政策风险角度” | 新增角度节点和受影响下游 |
|
||||
| “资料太旧,重新找” | 重新检索指定证据包 |
|
||||
| “竞争格局分析不到位” | 复用证据,重新分析 |
|
||||
| “重写第二章” | 只生成章节替换候选 |
|
||||
| “语气更正式” | 新报告版本,不重新研究 |
|
||||
| “为什么得出这个结论” | 只读问答,不修改报告 |
|
||||
|
||||
验收:
|
||||
|
||||
- 双击发送不创建重复命令。
|
||||
- 选中“品牌竞争分析师”后发送补充要求,只影响该成员及依赖分析,其他并行成员仍保持消息流和运行状态。
|
||||
- 运行中反馈不会让 run 或 node 误变 completed。
|
||||
- “重写第二章”不会触发全量检索。
|
||||
- “资料太旧”不会只做文字润色。
|
||||
|
||||
### FE-10:会话历史、报告版本与导出
|
||||
|
||||
- History Tab 显示标题、状态、更新时间、当前版本。
|
||||
- 新建会话不删除旧会话。
|
||||
- 会话切换时先关闭旧 SSE,再加载新快照。
|
||||
- 报告修改先生成候选,确认后创建新版本。
|
||||
- 版本恢复创建新的头版本,不删除历史。
|
||||
- 下载复用现有 Markdown/Word 能力。
|
||||
- 文件名来自报告标题,不暴露宿主机或沙箱路径。
|
||||
|
||||
验收:历史会话可完整恢复左侧消息流程、右侧节点状态和报告版本;不额外恢复节点结果面板。
|
||||
|
||||
### FE-11:测试、视觉回归和性能
|
||||
|
||||
单元测试:
|
||||
|
||||
- event normalizer/reducer。
|
||||
- message adapter。
|
||||
- plan revision 和选择状态。
|
||||
- node status 和 available actions。
|
||||
- command pending/idempotency。
|
||||
|
||||
组件测试:
|
||||
|
||||
- 空态、澄清、规划、候选、运行、等待、完成、失败、取消。
|
||||
- 卡片选择、节点点击、只读说明 Drawer、确认弹窗和报告预览 Dialog。
|
||||
- MessageList 通用业务卡插槽零回归。
|
||||
- 单成员流式文本、TeamSay、工具调用与工具返回的原样重放;并行成员交错到达、切换标签、切回后不中断 delta。
|
||||
- 非当前标签成员发起协助时显示待回复徽标,点击后使用原生协助卡回复到正确节点。
|
||||
|
||||
E2E:
|
||||
|
||||
1. 新建会话并输入新能源汽车报告要求。
|
||||
2. 选择候选并开始。
|
||||
3. 运行中增加风险角度。
|
||||
4. 重新检索海外最新资料。
|
||||
5. 完成后重写第二章。
|
||||
6. 刷新、断网重连、切换会话。
|
||||
7. 停止和重试失败节点。
|
||||
|
||||
性能门:
|
||||
|
||||
- 30 节点画布不卡顿。
|
||||
- 长消息列表使用现有优化策略。
|
||||
- SSE 高频 delta 合帧后再 setState。
|
||||
- 画布和消息区使用独立 selector,避免互相触发整树重渲染。
|
||||
- 节点抽屉只订阅节点合同和状态,不能因阶段产出更新而反复重渲染。
|
||||
- 只渲染当前聚焦成员和共享消息的可视窗口;非当前成员的消息仍写入 store,但不因 token delta 重渲染当前 `MessageList`。
|
||||
|
||||
---
|
||||
|
||||
## 8. 后端接口依赖
|
||||
|
||||
前端开发前,后端必须冻结以下接口:
|
||||
|
||||
```text
|
||||
POST /api/report-collaboration/sessions
|
||||
GET /api/report-collaboration/sessions
|
||||
GET /api/report-collaboration/sessions/{session_id}
|
||||
PATCH /api/report-collaboration/sessions/{session_id}
|
||||
DELETE /api/report-collaboration/sessions/{session_id}
|
||||
|
||||
POST /api/report-collaboration/sessions/{session_id}/messages
|
||||
GET /api/report-collaboration/sessions/{session_id}/messages
|
||||
POST /api/report-collaboration/sessions/{session_id}/plan-requests
|
||||
GET /api/report-collaboration/sessions/{session_id}/plans
|
||||
POST /api/report-collaboration/sessions/{session_id}/plans/{plan_id}/select
|
||||
POST /api/report-collaboration/sessions/{session_id}/runs
|
||||
|
||||
GET /api/report-collaboration/runs/{run_id}
|
||||
GET /api/report-collaboration/runs/{run_id}/events
|
||||
GET /api/report-collaboration/runs/{run_id}/stream
|
||||
POST /api/report-collaboration/runs/{run_id}/commands
|
||||
POST /api/report-collaboration/runs/{run_id}/cancel
|
||||
GET /api/report-collaboration/runs/{run_id}/nodes/{node_id}/contract
|
||||
|
||||
GET /api/report-collaboration/sessions/{session_id}/reports
|
||||
POST /api/report-collaboration/sessions/{session_id}/report-rewrites
|
||||
POST /api/report-collaboration/sessions/{session_id}/report-rewrites/{id}/apply
|
||||
```
|
||||
|
||||
前后端共享枚举:
|
||||
|
||||
- session status。
|
||||
- run status。
|
||||
- node/node attempt status。
|
||||
- command intent/status。
|
||||
- event type/version。
|
||||
- artifact type/schema version。
|
||||
- report version/change type。
|
||||
|
||||
---
|
||||
|
||||
## 9. 前端开发顺序
|
||||
|
||||
页面与契约层已完成(代码在 `frontend-web/src/report-collaboration/`):
|
||||
|
||||
1. `RC-FE-001`:视觉截图与行为基线。
|
||||
2. `RC-FE-002`:路由、菜单和左对话/右画布两栏空壳。
|
||||
3. `RC-FE-003`:页面 token、弹窗、抽屉和响应式。
|
||||
4. `RC-FE-004`:API 类型和 mock server。
|
||||
5. `RC-FE-005`:SSE reducer 与快照恢复。
|
||||
6. `RC-FE-006`:MessageList adapter 和通用卡片插槽。
|
||||
7. `RC-FE-007`:需求澄清与候选方案卡。
|
||||
8. `RC-FE-008`:流程画布和自定义节点。
|
||||
9. `RC-FE-009`:只读节点说明抽屉与对话目标定位。
|
||||
10. `RC-FE-010`:统一 Composer 和命令确认。
|
||||
11. `RC-FE-011`:报告预览、版本和导出。
|
||||
12. `RC-FE-012`:历史、重连和异常恢复。
|
||||
13. `RC-FE-013`:组件/E2E/视觉/性能验收。
|
||||
|
||||
依赖规则:
|
||||
|
||||
- 契约已冻结为 `src/report-collaboration/api/types.ts`;后端 `RC-BE-001` 已对齐。
|
||||
- 真实 SSE 联调:后端 `RC-BE-011` 已挂载 `GET /runs/{id}/stream`;默认无后台循环时流只会重放已入库事件。
|
||||
- 报告版本操作已接后端 `RC-BE-015`(改写候选 / apply / restore)。
|
||||
- 执行画布实时状态依赖打开 `worker_enabled`;质量角色内核已在 `RC-BE-013` 落地,真实成员见 `RC-BE-017`。
|
||||
|
||||
---
|
||||
|
||||
## 10. 前端完成定义
|
||||
|
||||
页面与 mock 联调路径已落地。真实后端可联调 `GET /runs/{id}/stream`、领取/恢复(`RC-BE-012`,默认不挂 worker)、质量角色(`RC-BE-013`)以及报告改写/恢复(`RC-BE-015`)。此前以 mock driver 覆盖交互,不以「前端未编码」计未完成。
|
||||
|
||||
前端只有同时满足以下条件才算完成:
|
||||
|
||||
1. 页面结构与 Coze 参考页面逐状态验收通过。
|
||||
2. 消息区复用 DeerFlow `MessageList`,其他聊天页面无回归。
|
||||
3. 候选卡、左侧对话流程、右侧流程图和报告来自同一 session/run/event 状态。
|
||||
4. 点击候选只预览,明确确认后才执行。
|
||||
5. 用户可以在规划、运行、完成后三个阶段继续对话。
|
||||
6. 协助卡显示或提交不会导致前端误标完成。
|
||||
7. 节点点击只打开说明抽屉,可查看职责、所需输入、预期输出、工具和验收条件,不展示执行结果。
|
||||
8. 节点重开、返工和 superseded 状态表达正确。
|
||||
9. SSE 重连、刷新和历史切换无重复消息和状态倒退。
|
||||
10. 报告修改以候选和版本形式完成,不直接覆盖正式报告。
|
||||
11. 页面始终只有对话与画布两个常驻区域,所有执行过程和产出入口都能在对话时间线中找到。
|
||||
12. 响应式、键盘、对比度、加载反馈和错误恢复通过验收。
|
||||
@@ -0,0 +1,481 @@
|
||||
# 报告协作工作台 — 前端对接指南
|
||||
|
||||
> **适用版本:** RC-BE-000~018 已落地(2026-09-06)
|
||||
> **配套文档:**
|
||||
> - [前端实施计划](./agentscope-多智能体报告协作工作台-前端实施计划.md)
|
||||
> - [后端实施计划](./agentscope-多智能体报告协作工作台-后端实施计划.md)
|
||||
> - 模块 README:`frontend-web/src/report-collaboration/README.md`
|
||||
|
||||
本文说明**前端如何对接真实后端**:环境配置、API 契约、用户流程、状态机、错误处理与联调验收。前端 API 客户端与页面已实现,通常**无需再写一套 HTTP 层**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论速览
|
||||
|
||||
| 问题 | 答案 |
|
||||
|------|------|
|
||||
| 前端还要写接口吗? | **不用**。`src/report-collaboration/api/` 已封装 REST + SSE,页面通过 `getReportCollaborationDriver()` 调用。 |
|
||||
| 为什么调不通? | 最常见:`config.yaml` 里 `report_collaboration.enabled: false` → 全部 **503**;或 `worker_enabled: false` → run 不入队执行。 |
|
||||
| Mock 和真后端怎么切? | **Mock 已移除**。前端永远请求真实后端(`deerflow.rc-mock` / `VITE_RC_MOCK` 开关已删除);联调需 `enabled: true`。 |
|
||||
| 字段会和 MySQL 对不上吗? | 前端只认 JSON 契约(`api/types.ts`),不直连表。缺列表现为后端 500/SQL 错误,部署后需**重启 Gateway** 跑 `create_all` + 列同步。 |
|
||||
| 从哪进页面? | `/page/workspace/report-collaboration`(新会话)或 `/page/workspace/report-collaboration/:sessionId`。侧边栏入口默认注释,可直接 URL 访问。 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构:前端已有什么
|
||||
|
||||
```
|
||||
ReportCollaborationPage
|
||||
└─ useReportCollaborationSession(唯一数据入口)
|
||||
├─ driver.getSnapshot() ← REST 冷启动
|
||||
├─ driver.openRunStream() ← SSE 实时事件
|
||||
├─ driver.sendMessage / createCommand / startRun …
|
||||
└─ event-reducer ← 事件 → UI 状态
|
||||
|
||||
ReportConversationPanel
|
||||
└─ MessageList(message-adapter 映射 CollaborationMessage → LangGraph Message)
|
||||
```
|
||||
|
||||
### 2.1 目录结构
|
||||
|
||||
```text
|
||||
frontend-web/src/report-collaboration/
|
||||
api/
|
||||
types.ts # 冻结契约(snake_case,与后端 Pydantic 一致)
|
||||
client.ts # rcFetch:鉴权、幂等键、乐观锁、错误映射
|
||||
sessions.ts # 会话 CRUD、消息
|
||||
plans.ts # 方案请求 / 列表 / 选定
|
||||
runs.ts # Run、SSE、命令、节点合同
|
||||
reports.ts # 报告版本、改写、恢复
|
||||
driver.ts # 真实后端统一门面(Mock 驱动已移除)
|
||||
state/
|
||||
event-normalizer.ts
|
||||
event-reducer.ts
|
||||
selectors.ts
|
||||
message-adapter.ts
|
||||
hooks/
|
||||
useReportCollaborationSession.ts # 快照 → SSE → reducer → 动作
|
||||
useReportCollaborationStream.ts # 断线重连、after_seq 续接
|
||||
useReportCollaborationMessages.ts
|
||||
components/ …
|
||||
pages/ReportCollaborationPage.tsx
|
||||
```
|
||||
|
||||
### 2.2 事实源原则
|
||||
|
||||
- **冷启动:** `GET /sessions/{id}` 返回 `SessionSnapshot`(session、messages、plans、run、nodes、agent_runs、commands、report_versions、last_seq)。
|
||||
- **运行中:** `GET /runs/{id}/stream?after_seq=N` 订阅 durable SSE。
|
||||
- **断线 / 序号缺口:** `GET /runs/{id}/events?after_seq=N` REST 补拉。
|
||||
- **澄清 / 规划阶段(尚无 run):** 每 2.5s 轮询快照(后端事件主要在 run 流上)。
|
||||
|
||||
纯展示状态(预览方案、聚焦成员、面板宽度)**不**写入服务器,仅存前端。
|
||||
|
||||
---
|
||||
|
||||
## 3. 后端启用与部署
|
||||
|
||||
### 3.1 必改配置
|
||||
|
||||
文件:`offline-backend-20260512/backend/config.yaml`
|
||||
|
||||
```yaml
|
||||
report_collaboration:
|
||||
enabled: false # 联调必须改为 true
|
||||
worker_enabled: false # 要看成员执行必须 true
|
||||
planner_model: null # 建议填可用模型名
|
||||
coordinator_model: null
|
||||
research_model: null
|
||||
writer_model: null
|
||||
reviewer_model: null
|
||||
# … token_budget、max_concurrent_runs_per_user 等见同文件
|
||||
```
|
||||
|
||||
| 配置 | 现象 |
|
||||
|------|------|
|
||||
| `enabled: false` | 所有路由 **503**,`code: REPORT_COLLABORATION_DISABLED` |
|
||||
| `enabled: true`,`worker_enabled: false` | REST/SSE 可用,run 入队但不自动执行(测 API 可用,无成员流式输出) |
|
||||
| 两者 `true` + 模型配置完整 | 完整联调 |
|
||||
|
||||
### 3.2 MySQL / 表结构
|
||||
|
||||
生产环境 Alembic 自动升级默认关闭,启动时走 `create_all` + `_ensure_orm_columns_sync`。
|
||||
|
||||
- 迁移脚本(可选手动跑):`20260905_01`、`20260906_01`、`20260907_01`
|
||||
- ORM:`packages/harness/deerflow/persistence/report_collaboration/model.py`(13 张表)
|
||||
- **部署后务必重启 Gateway**,观察日志是否有 `Failed to auto-add column report_collaboration_*`
|
||||
|
||||
已有 RC-003 时代旧表会通过列同步补上 `template_snapshot_json`、`budget_snapshot_json`、`usage_json`、`budget_exhausted_reason` 等可空列,并创建 `rewrite_proposals`、`audits`。
|
||||
|
||||
### 3.3 前端环境变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `VITE_BACKEND_BASE_URL` | Gateway 地址,默认 `http://127.0.0.1:8001` |
|
||||
|
||||
请求经 `apiFetch`,自动携带登录 JWT(`credentials: "include"`)。**必须先登录 DeerFlow**。
|
||||
|
||||
---
|
||||
|
||||
## 4. API 基址与请求约定
|
||||
|
||||
### 4.1 Base URL
|
||||
|
||||
```text
|
||||
{VITE_BACKEND_BASE_URL}/api/report-collaboration
|
||||
```
|
||||
|
||||
代码常量:`REPORT_COLLABORATION_BASE()`(`api/client.ts`)。
|
||||
|
||||
### 4.2 请求头
|
||||
|
||||
| Header | 何时携带 | 说明 |
|
||||
|--------|----------|------|
|
||||
| `Authorization` | 全部 | 由 `apiFetch` 注入 |
|
||||
| `X-Idempotency-Key` | 写操作 | 格式 `rc-{uuid}`;重复提交返回首次结果 |
|
||||
| `X-Expected-Revision` | 选方案、开 run、改会话等 | 会话 `requirement_revision` 或方案 `revision` |
|
||||
| `Accept: text/event-stream` | SSE | `openRunStream` |
|
||||
|
||||
### 4.3 错误响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "REPORT_COLLABORATION_DISABLED",
|
||||
"message": "报告协作工作台未启用",
|
||||
"detail": { }
|
||||
}
|
||||
```
|
||||
|
||||
前端映射(`api/client.ts`):
|
||||
|
||||
| HTTP | 异常类 | 典型场景 |
|
||||
|------|--------|----------|
|
||||
| 409 | `ConflictError` | revision 过期、active run 冲突 |
|
||||
| 422 | `ValidationError` | 参数/语义错误、缺幂等键 |
|
||||
| 429 | `RateLimitError` | 预算耗尽、每用户并发 run 上限 |
|
||||
| 5xx | `ServerError` | 可带同一幂等键重试 |
|
||||
| 0 | `NetworkError` | fetch 失败 |
|
||||
|
||||
### 4.4 完整路由清单(与前端 client 对齐)
|
||||
|
||||
| 方法 | 路径 | 前端模块 |
|
||||
|------|------|----------|
|
||||
| POST | `/sessions` | `sessions.createSession` |
|
||||
| GET | `/sessions` | `sessions.listSessions` |
|
||||
| GET | `/sessions/{id}` | `sessions.getSessionSnapshot` |
|
||||
| PATCH | `/sessions/{id}` | `sessions.updateSession` |
|
||||
| DELETE | `/sessions/{id}` | `sessions.deleteSession` |
|
||||
| GET | `/sessions/{id}/messages` | `sessions.listMessages` |
|
||||
| POST | `/sessions/{id}/messages` | `sessions.sendMessage` |
|
||||
| POST | `/sessions/{id}/plan-requests` | `plans.requestPlans` |
|
||||
| GET | `/sessions/{id}/plans` | `plans.listPlans` |
|
||||
| POST | `/sessions/{id}/plans/{planId}/select` | `plans.selectPlan` |
|
||||
| POST | `/sessions/{id}/runs` | `runs.startRun` |
|
||||
| GET | `/sessions/{id}/reports` | `reports.listReportVersions` |
|
||||
| POST | `/sessions/{id}/report-rewrites` | `reports.createRewrite` |
|
||||
| POST | `/sessions/{id}/report-rewrites/{id}/apply` | `reports.applyRewrite` |
|
||||
| POST | `/sessions/{id}/report-versions/{id}/restore` | `reports.restoreReportVersion` |
|
||||
| GET | `/runs/{id}` | `runs.getRun` |
|
||||
| GET | `/runs/{id}/events` | `runs.listRunEvents` |
|
||||
| GET | `/runs/{id}/stream` | `runs.openRunStream` |
|
||||
| POST | `/runs/{id}/cancel` | `runs.cancelRun` |
|
||||
| POST | `/runs/{id}/commands` | `runs.createCommand` |
|
||||
| POST | `/runs/{id}/commands/{id}/confirm` | `runs.confirmCommand` |
|
||||
| POST | `/runs/{id}/commands/{id}/cancel` | `runs.cancelCommand` |
|
||||
| GET | `/runs/{id}/nodes/{nodeId}/contract` | `runs.getNodeContract` |
|
||||
| GET | `/runs/{id}/artifacts` | (后端已暴露,前端按需接) |
|
||||
| GET | `/runs/{id}/sources` | (后端已暴露,前端按需接) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 用户流程与 API 时序
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant FE as 前端 RC
|
||||
participant API as Gateway
|
||||
participant W as Worker
|
||||
|
||||
U->>FE: 输入需求
|
||||
FE->>API: POST /sessions
|
||||
FE->>API: POST /sessions/{id}/messages
|
||||
API-->>FE: 澄清(快照 messages)
|
||||
U->>FE: 回答澄清
|
||||
FE->>API: POST /messages
|
||||
FE->>API: POST /plan-requests
|
||||
API-->>FE: 候选方案(快照 plans)
|
||||
U->>FE: 采用方案
|
||||
FE->>API: POST /plans/{id}/select
|
||||
U->>FE: 开始协作
|
||||
FE->>API: POST /runs
|
||||
FE->>API: GET /runs/{id}/stream
|
||||
W-->>API: 持久化事件
|
||||
API-->>FE: SSE
|
||||
U->>FE: 运行中干预
|
||||
FE->>API: POST /commands → confirm
|
||||
```
|
||||
|
||||
### 5.1 阶段 0:进入页面
|
||||
|
||||
| 路由 | 行为 |
|
||||
|------|------|
|
||||
| `/page/workspace/report-collaboration` | 空态;`POST /sessions` 后跳转带 `sessionId` |
|
||||
| `/page/workspace/report-collaboration/:sessionId` | 加载快照;有 `location.state.rcInitialMessage` 时自动发首条消息 |
|
||||
|
||||
路由注册:`frontend-web/src/pages/WorkspaceRoutes.tsx`。
|
||||
侧边栏入口已在 `workspace-nav-chat-list.tsx` 中放开(「报告协作」项),也可直接访问 URL。
|
||||
|
||||
### 5.2 阶段 1:需求澄清(无 run)
|
||||
|
||||
```http
|
||||
POST /api/report-collaboration/sessions
|
||||
POST /api/report-collaboration/sessions/{sessionId}/messages
|
||||
Content-Type: application/json
|
||||
|
||||
{ "text": "请写一份…", "target_node_id": null, "agent_run_id": null }
|
||||
```
|
||||
|
||||
- `useReportCollaborationSession.send()`:当 `session.status` 不是 `running` / `awaiting_input` / `reviewing` 时走 `sendMessage`。
|
||||
- 会话状态:`empty` → `clarifying` → `planning` → `proposal_ready`。
|
||||
- 澄清问题以 `CollaborationMessage` 进入时间线;业务卡通过 `MessageList` 的 `businessCardSlot` 渲染(`additional_kwargs.report_collaboration_card`)。
|
||||
|
||||
### 5.3 阶段 2:候选方案
|
||||
|
||||
```http
|
||||
POST /api/report-collaboration/sessions/{sessionId}/plan-requests
|
||||
POST /api/report-collaboration/sessions/{sessionId}/plans/{planId}/select
|
||||
```
|
||||
|
||||
- **预览方案**(`previewPlanId`):纯前端,不调 API。
|
||||
- **采用方案**:`select` 写 `selected_plan_id`,**不**创建 run。
|
||||
- 409:方案 revision 过期 → 前端 `refreshSnapshot`,提示用户重选。
|
||||
- 右栏 `ReportFlowCanvas` 使用 `PlanNode` / `PlanEdge` 渲染 DAG。
|
||||
|
||||
### 5.4 阶段 3:开始执行与 SSE
|
||||
|
||||
```http
|
||||
POST /api/report-collaboration/sessions/{sessionId}/runs
|
||||
{ "plan_id": "plan-xxx" }
|
||||
|
||||
GET /api/report-collaboration/runs/{runId}/stream?after_seq=0
|
||||
Accept: text/event-stream
|
||||
```
|
||||
|
||||
SSE 每条 `data:` 为 JSON:
|
||||
|
||||
```typescript
|
||||
interface CollaborationEvent {
|
||||
eventId: string;
|
||||
sessionId: string;
|
||||
runId?: string | null;
|
||||
seq: number;
|
||||
type: CollaborationEventType;
|
||||
timestamp: string;
|
||||
data: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
常用 `type`(完整列表见 `api/types.ts`):
|
||||
|
||||
| 类型 | 含义 |
|
||||
|------|------|
|
||||
| `message.created` / `message.delta` / `message.completed` | 对话镜像流式 |
|
||||
| `tool_call.*` / `tool_result.*` | 工具调用与返回 |
|
||||
| `team_message.*` | 成员 / 协调者广播 |
|
||||
| `plan.proposed` / `plan.selected` | 方案生命周期 |
|
||||
| `run.status.changed` | Run 状态 |
|
||||
| `node.status.changed` / `node.progress` | 节点执行 |
|
||||
| `command.confirmation.required` | 需用户确认的干预 |
|
||||
| `report.version.created` | 报告版本落盘 |
|
||||
| `heartbeat` | 保活 |
|
||||
|
||||
### 5.5 阶段 4:运行中干预
|
||||
|
||||
```http
|
||||
POST /api/report-collaboration/runs/{runId}/commands
|
||||
POST /api/report-collaboration/runs/{runId}/commands/{commandId}/confirm
|
||||
POST /api/report-collaboration/runs/{runId}/commands/{commandId}/cancel
|
||||
POST /api/report-collaboration/runs/{runId}/cancel
|
||||
```
|
||||
|
||||
- Run 活跃时 `send()` 自动走 `createCommand`(而非 `sendMessage`)。
|
||||
- 可从节点抽屉 / `AgentStageSelector` 传入 `target_node_id`、`agent_run_id`。
|
||||
- `InterventionCard` 展示 `confirmation_required` 命令;确认后调 `confirmCommand`。
|
||||
|
||||
`CommandIntent` 枚举(后端分类,前端展示 reason/impact):`answer_question`、`update_requirements`、`research_again`、`rewrite_section`、`rerun_node`、`replan`、`cancel` 等。
|
||||
|
||||
### 5.6 阶段 5:报告版本与改写
|
||||
|
||||
```http
|
||||
GET /api/report-collaboration/sessions/{sessionId}/reports
|
||||
POST /api/report-collaboration/sessions/{sessionId}/report-rewrites
|
||||
POST /api/report-collaboration/sessions/{sessionId}/report-rewrites/{rewriteId}/apply
|
||||
POST /api/report-collaboration/sessions/{sessionId}/report-versions/{versionId}/restore
|
||||
```
|
||||
|
||||
- `createRewrite` 只生成候选,不直接覆盖正式版本。
|
||||
- `applyRewrite` / `restoreReportVersion` 产生新的 head 版本,历史保留。
|
||||
- `ReportPreviewDialog` 展示 `ReportVersion.markdown`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端状态机(接入方须知)
|
||||
|
||||
**不要**在页面里用零散 REST 字段拼 UI 状态。统一路径:
|
||||
|
||||
```text
|
||||
SessionSnapshot → applySessionSnapshot()
|
||||
CollaborationEvent[] → normalizeCollaborationEvent() → applyCollaborationEvents()
|
||||
→ selectors(derivePagePhase、selectPlanForCanvas、…)
|
||||
```
|
||||
|
||||
### 6.1 核心 Selector
|
||||
|
||||
| Selector | 用途 |
|
||||
|----------|------|
|
||||
| `derivePagePhase` | Composer 占位、方案卡、空态 |
|
||||
| `selectPlanForCanvas` | 画布 DAG(含预览方案) |
|
||||
| `selectAgentTabs` | 并行成员 Tab |
|
||||
| `selectNodeStatusMap` | 节点颜色 / 状态 |
|
||||
| `selectPendingConfirmationCommands` | 干预确认卡 |
|
||||
| `selectHeadReportVersion` | 报告预览入口 |
|
||||
| `selectStreamingAgentRunIds` | 流式成员指示 |
|
||||
|
||||
### 6.2 SSE 连接策略(`useReportCollaborationStream`)
|
||||
|
||||
- Run 非终态时连接;`completed` / `failed` / `cancelled` 后关流并 REST 最终补拉。
|
||||
- 断线:指数退避重连(1s~30s),`after_seq` 取 reducer 内 `lastSeq`。
|
||||
- `hasSeqGap`:暂停 live 应用,先 `listRunEvents` 回填。
|
||||
|
||||
### 6.3 乐观消息
|
||||
|
||||
用户发送时插入 `metadata.optimistic: true` 的 pending 消息;服务端镜像带相同 `idempotency_key` 时 reducer 原地替换。
|
||||
|
||||
---
|
||||
|
||||
## 7. 消息与 MessageList 复用
|
||||
|
||||
`useReportCollaborationMessages` 将 `CollaborationMessage[]` 转为 `@langchain/langgraph-sdk` 的 `Message[]`,并构造合成 `BaseStream` 供 `MessageList` 消费。
|
||||
|
||||
| 来源 | 渲染 |
|
||||
|------|------|
|
||||
| `human` / `ai` 文本 | 普通气泡 |
|
||||
| `tool_calls` + `tool` | 现有工具卡 |
|
||||
| `team_message` 事件 | 带 `name` 的成员发言 |
|
||||
| 业务卡 | `businessCardSlot`(方案组、命令确认、报告可用) |
|
||||
|
||||
分组键:`core/messages/utils.ts` 中 `assistant:task-report-import` 同级扩展 `assistant:business-card`,判据 `additional_kwargs.report_collaboration_card`。
|
||||
|
||||
### 7.1 在其他页面嵌入 RC(最小集)
|
||||
|
||||
1. `useReportCollaborationSession(sessionId)`
|
||||
2. `ThreadContext.Provider` + `MessageList` + `businessCardSlot`
|
||||
3. 引入 `report-collaboration/styles/report-collaboration.css`
|
||||
|
||||
---
|
||||
|
||||
## 8. 单测
|
||||
|
||||
Mock 驱动(原 `api/mock.ts`)与 `deerflow.rc-mock` / `VITE_RC_MOCK` 开关已移除,前端只请求真实后端;本地演示请直接联调后端。
|
||||
|
||||
单测:
|
||||
|
||||
```bash
|
||||
cd frontend-web
|
||||
pnpm test:report-collaboration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 联调验收清单
|
||||
|
||||
### 9.1 准备
|
||||
|
||||
- [ ] `report_collaboration.enabled: true`
|
||||
- [ ] `report_collaboration.worker_enabled: true`
|
||||
- [ ] 五个 `*_model` 已配置且可用
|
||||
- [ ] Gateway 已重启(MySQL 表/列就绪)
|
||||
- [ ] 前端已登录(JWT 有效)
|
||||
|
||||
### 9.2 探活
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer <token>" \
|
||||
http://127.0.0.1:8001/api/report-collaboration/sessions
|
||||
```
|
||||
|
||||
期望:**200** + `{ "sessions": [...] }`,而非 503。
|
||||
|
||||
### 9.3 页面流程(浏览器 Network)
|
||||
|
||||
1. 打开 `/#/page/workspace/report-collaboration`
|
||||
2. 输入需求 → `POST .../sessions` **200**
|
||||
3. 跳转 → `GET .../sessions/{id}` **200**,响应含 `SessionSnapshot`
|
||||
4. 首条消息 → `POST .../messages` **200**
|
||||
5. 请求方案 → `POST .../plan-requests` **200**
|
||||
6. 采用方案 → `POST .../plans/{id}/select` **200**
|
||||
7. 开始协作 → `POST .../runs` **200**
|
||||
8. `GET .../runs/{id}/stream` 持续收到 SSE(`message.delta`、`node.status.changed` 等)
|
||||
9. 刷新页面 → 消息与 `last_seq` 续接正确
|
||||
10. 运行中输入「重新检索」→ `POST .../commands` → 确认卡 → `confirm` **200**
|
||||
|
||||
### 9.4 常见失败
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
|------|------|------|
|
||||
| 全部 503 | `enabled: false` | 改配置并重启 |
|
||||
| run 一直 queued | `worker_enabled: false` | 改为 true |
|
||||
| 409 选方案 | revision 过期 | 刷新后重选(前端已自动 refresh) |
|
||||
| 429 | 预算/并发上限 | 调大配置或取消其他 run |
|
||||
| 500 + SQL | 表缺列 | 重启 Gateway,查列同步日志 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 契约与类型维护
|
||||
|
||||
- **单一事实源:** `frontend-web/src/report-collaboration/api/types.ts`
|
||||
- **后端镜像:** `offline-backend-20260512/backend/app/report_collaboration/contracts/`
|
||||
- 修改字段须同步两份实施计划与本指南,并跑前后端相关测试。
|
||||
|
||||
`SessionSnapshot` 形状(摘要):
|
||||
|
||||
```typescript
|
||||
interface SessionSnapshot {
|
||||
session: ReportSession;
|
||||
messages: CollaborationMessage[];
|
||||
plans: ReportPlanCandidate[];
|
||||
run: ReportRun | null;
|
||||
nodes: CollaborationNodeState[];
|
||||
agent_runs: AgentRun[];
|
||||
commands: CollaborationCommand[];
|
||||
report_versions: ReportVersion[];
|
||||
last_seq: number;
|
||||
}
|
||||
```
|
||||
|
||||
`ReportRun` 可选字段(RC-BE-016):`usage`、`budget_exhausted_reason`。
|
||||
|
||||
---
|
||||
|
||||
## 11. 后续工作(非对接阻塞)
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| RC-BE-019 | 灰度发布与离线部署文档 |
|
||||
| 侧边栏入口 | 已放开(`workspace-nav-chat-list.tsx` 的「报告协作」项) |
|
||||
| iframe 嵌入 | RC 页尚未专门适配 `embed=1`,可参考现有深链模式扩展 |
|
||||
| 真人盲评 | RC-BE-018 为确定性评测集,上线前仍需计划 §11.4 人工评审 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 相关代码索引
|
||||
|
||||
| 用途 | 路径 |
|
||||
|------|------|
|
||||
| API 客户端 | `frontend-web/src/report-collaboration/api/` |
|
||||
| 会话 Hook | `frontend-web/src/report-collaboration/hooks/useReportCollaborationSession.ts` |
|
||||
| 页面 | `frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx` |
|
||||
| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
|
||||
| 后端路由 | `offline-backend-20260512/backend/app/gateway/routers/report_collaboration.py` |
|
||||
| 配置 | `offline-backend-20260512/backend/config.yaml` → `report_collaboration` |
|
||||
| 持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_collaboration/` |
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,31 @@
|
||||
# 日常使用复查(2026-09-14)
|
||||
|
||||
范围:ji 分支上继续检查知识库管理、助手知识库、我的项目、项目问答和官方入口。沿用本地隔离后端数据目录 `.runtime/ji-acceptance`,浏览器检查 464×642 与 1440×900 两种尺寸;未向生产库写入验收项目。
|
||||
|
||||
## 发现与修复
|
||||
|
||||
**首屏白屏的直接业务触发点也已修复:** `LlmWikiKnowledgeSelector` 在懒加载/未加载知识目录时,每次渲染创建新的空数组,推荐问题 effect 随之回写父页状态,形成无限更新。将目录空结果和默认助手库选择稳定化后,重新从登录进入新对话成功,欢迎引导、输入框及知识空间下拉均可操作(截图 06)。仅升级依赖并不能消除这个业务循环;上述修复与依赖兼容更新共同保留。
|
||||
|
||||
1. **首次进入:高影响,已修复依赖。** 本轮首次登录后出现整页空白,控制台为 ScrollArea `Maximum update depth exceeded`。安装版本 1.2.10 使用不稳定引用回调;升级并锁定 1.2.18,同步 package-lock,核对源码使用稳定 setState 引用。冷启动仍复现组合引用循环,进一步发现各基础组件带入 Slot 1.2.3/1.2.4;统一锁定 Slot 1.3.3,npm 与 pnpm 均配置覆盖间接依赖。依据 [Radix 发布记录](https://www.radix-ui.com/primitives/docs/overview/releases) 的 React 19 无限重渲染及 Slot 引用修复。
|
||||
2. **新建项目:已修复。** 手机表单出现两个菜单按钮(截图 01)。由外层提供菜单时不再显示内部按钮;官方入口也去掉无效的内部侧栏按钮。
|
||||
3. **项目历史:已修复。** 我的项目侧栏链接跳到旧项目空间(截图 02),导致返回目的地变化。现在保留我的项目路径,普通聊天与旧项目空间保持原路由。手机点历史后菜单自动收起。官方侧栏原本把项目聊天打开为普通聊天,现按项目归属进入项目工作区;`/official/workspace/projects/groups` 根路径回到普通问答项目列表。
|
||||
4. **项目设置与失败恢复:已修复。** 默认智能体更新后,同步新对话选择器;项目对话权限/归属加载失败、普通问答项目列表失败均有重试入口。
|
||||
5. **助手知识检索:已修复。** 连续检索或切换库时,以递增请求序号隔离过期成功/失败/结束回调,防止旧结果覆盖新库;新检索清除旧结果;空输入禁止提交。无结果原本只有小标签(截图 03),现在说明可调整关键词并提供浏览目录按钮。输入框补充可访问名称。
|
||||
6. **窄屏输入栏:已修复。** 464px 截图中发送箭头被工具栏挤出右边缘(截图 07);工具区允许换行、发送区不收缩,截图 08 确认按钮完整可见。项目问答网络错误也增加“重新加载对话”,只重建读取状态,不自动重发消息。
|
||||
|
||||
## 复测记录
|
||||
|
||||
- 具名项目创建成功;新建项目对话,通过真实模型收到“项目问答回归通过”;刷新保留消息与项目归属。
|
||||
- 修复工具栏后,在窄屏实际点击发送并收到“窄屏发送通过”;首屏知识空间菜单完成加载、选择助手库后显示已选择 1 个,未再出现循环白屏。
|
||||
- 手机项目历史链接确认留在 `qa/projects/groups`;桌面官方 groups 根路径跳回正确列表,官方项目历史进入相同项目。
|
||||
- 官方和常规项目切换时偶发网络 `Failed to fetch`,刷新/重新挂载恢复历史消息;没有将其描述为已排除的网络问题,新增恢复按钮未进行独立网络故障注入测试。
|
||||
- 助手知识库列表、阅读、检索无结果、返回入口实际操作;空查询按钮禁用。检索竞态由代码检查修复,未使用浏览器网络故障注入。
|
||||
- 后端 22 个相关文件重新运行:**273 passed**,涵盖知识库管理/导入/检索、Wiki、编码与向量、项目归属隔离、策略持久化及调用边界。
|
||||
- 前端项目与策略测试:**33 passed**,新增项目历史保持入口、旧项目和普通聊天/创建向导回归。
|
||||
- 生产构建通过。全仓类型检查仍有 **59 个既有错误**,与上一轮错误列表一致;不宣称全仓类型检查通过。
|
||||
|
||||
## 证据与边界
|
||||
|
||||
本轮截图与日志存放在 `frontend-web/.runtime/daily-usage-audit/`:`01-create-before.png`、`02-history-before.png`、`03-knowledge-search.png`、`04-official-desktop.png`、`05-official-project-after.png`(网络失败状态)、后续 after 截图,以及 tests/build/typecheck 日志。后端日志为 `.runtime/ji-acceptance/daily-regression.txt`。
|
||||
|
||||
上一轮真实文件入库、向量命中、方案生成与保存的验证详见 `ji-knowledge-projects-acceptance.md`。本轮不重复付费生成完整方案;没有进行生产全量语料质量评估、负载测试、断网故障注入或完整无障碍合规审计。已检查可见导航、表单名称、窄屏显示和主要失败恢复,不能据此承诺所有模块没有缺陷。代码未提交、推送或部署。
|
||||
@@ -0,0 +1,37 @@
|
||||
# ji 分支知识库与我的项目验收
|
||||
|
||||
日期:2026-09-14。基线:`ji` / `f62a512a`,已从 origin fast-forward 更新。
|
||||
|
||||
## 修复范围
|
||||
|
||||
1. 简洁模式进入我的项目保留功能导航,覆盖列表、策略工作台和问答项目;移动端有菜单展开入口。
|
||||
2. 策略结果卡片的模型思考前置;检查意图、评判、方案、展开视图保持过程在正文前。
|
||||
3. 普通问答改为真实项目分组:先填写项目名称创建项目,再在项目中创建多条问答。服务端持久归属与权限校验沿用 analysis-projects;不自动迁移或标记历史普通聊天。官方入口支持对应 groups 子路由。
|
||||
4. 修复方案列表要求预先选中策略导致无法进入的问题;任务变化后已有方案仍可查看;请求序号避免旧请求失败覆盖成功结果。
|
||||
5. 修复知识库管理目录缓存导致上游删除后仍显示的问题;保留检索端缓存。
|
||||
6. 项目编辑保留知识库绑定,助手知识库补充普通用户空态。
|
||||
|
||||
## 自动化
|
||||
|
||||
- 后端 22 个相关测试文件:273 passed(知识库、Wiki、向量、编码器、项目归属、策略配置与持久化、边界等)。
|
||||
- 前端策略与项目测试:31 passed。新增用例覆盖未选择策略、已有方案变更任务、未完成评判及全部策略失败。
|
||||
- `tests/test_knowledge_live_embedding.py`:真实接口 1 passed;默认跳过,设置 `KNOWLEDGE_TEST_EMBEDDING_KEY` 后运行。默认服务 SiliconFlow、模型 BAAI/bge-m3;可用 `KNOWLEDGE_TEST_EMBEDDING_URL` / `KNOWLEDGE_TEST_EMBEDDING_MODEL` 更换。
|
||||
- `pnpm build`:通过。
|
||||
- `pnpm typecheck`:未完全通过,59 个错误位于此次未改动文件(nextra 缺失、Markdown 插件类型、旧 JSX 类型及旧问答页等);修改文件无类型错误。不能将生产构建通过等同于全仓类型检查通过。
|
||||
|
||||
## 真实接口与浏览器
|
||||
|
||||
测试使用 `.runtime/ji-acceptance` 独立数据目录,保留测试数据便于复查,未操作业务数据库已有项目。
|
||||
|
||||
- BAAI/bge-m3:中英文 3 条输入,均返回 1024 维有效向量。
|
||||
- 实际检索服务:相关正文排名、指定库范围、草稿排除、概览查询通过。
|
||||
- 两个项目 A/B:A 创建两条对话,B 列表为零;跨项目读取 A 的对话返回 404。
|
||||
- 浏览器:简洁模式完整菜单可见;新建具名普通问答项目;项目内真实发送并收到回答;第二条对话独立;回列表仍是一张项目卡,刷新后保留。
|
||||
- 方案整理:真实模型返回 1539 字符正文,含 agent_done / all_done,流正常结束。
|
||||
- 方案入口:完成评判但尚未勾选策略的测试项目出现在方案列表。浏览器进入后勾选策略、生成 2755 字符正文、保存成功;服务端校验 synthesisStatus=done 且 solutionSavedAt 非空,刷新后正文与导出按钮恢复。
|
||||
- 官方入口:`/official/workspace/projects/groups/new` 正常展示具名问答项目表单,官方侧栏保留。
|
||||
- 助手知识库:创建、重命名、列表、拒绝 .py 上传通过;上传测试文本任务 completed;生成 Wiki 页面,向量检索命中 1 条,详情 HTTP 200。
|
||||
|
||||
## 限制
|
||||
|
||||
真实模型测试使用最小合成读书活动/设备资料,未对生产全量知识语料进行质量评估。API 密钥仅用于测试进程,不提交配置。仓库原有未跟踪 docx 与 packages1.zip 保持不变。未提交、推送代码,也未执行离线发布。
|
||||
@@ -0,0 +1,304 @@
|
||||
# OpenVibeCoding 内网可视化应用生成平台:分步开发方案
|
||||
|
||||
> 文档状态:待实施
|
||||
>
|
||||
> 目标环境:现有 Ubuntu 22.04 Docker 服务器;对外可用端口限定为 `8020-8030`。
|
||||
>
|
||||
> 本文基于 2026-08-31 的只读巡检编写。未在目标服务器上安装、停止或修改任何服务。
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
将 OpenVibeCoding 改造成一个使用公司内网模型、在公司服务器 Docker 沙箱中生成并运行可视化应用的工作台。用户通过对话生成项目,查看并修改文件,在浏览器中预览运行结果。
|
||||
|
||||
第一期只服务以下项目类型:
|
||||
|
||||
- 数据大屏、数据看板、海报和展示型页面;
|
||||
- React + Vite + TypeScript 前端;
|
||||
- ECharts 图表、流程图;
|
||||
- 可选的轻量 Python FastAPI 接口;
|
||||
- 项目文件浏览、编辑、运行日志和实时预览。
|
||||
|
||||
第一期明确不做:
|
||||
|
||||
- 腾讯云 CloudBase、SCF、TCR、CodeBuddy、云端一键部署;
|
||||
- 任意语言、任意 npm/PyPI 包的无限制安装;
|
||||
- 用户自定义 Dockerfile、Docker Socket 挂载、宿主机命令执行;
|
||||
- 直接将沙箱暴露为独立公网端口;
|
||||
- 对不可信代码提供“绝对安全”的隔离承诺。
|
||||
|
||||
## 2. 已确认的服务器基线
|
||||
|
||||
目标服务器当前具备:
|
||||
|
||||
| 项目 | 巡检结果 | 判断 |
|
||||
|---|---:|---|
|
||||
| 操作系统 | Ubuntu 22.04.5 LTS | 支持 Docker 方案 |
|
||||
| CPU | 32 vCPU | 足够第一期 |
|
||||
| 内存 | 125 GiB;巡检时可用约 106 GiB | 足够第一期 |
|
||||
| 根磁盘 | 2 TB;巡检时可用约 1.6 TB | 足够 Harbor、镜像与工作区 |
|
||||
| Docker | 29.1.3,Docker Compose 2.40.3 | 已满足 |
|
||||
| 负载 | 约 0.5 | 当前有余量 |
|
||||
| `8020-8030` | 巡检时无监听、无 Docker 端口映射 | 可规划使用 |
|
||||
|
||||
该服务器已承载 DeerFlow、Coze、LiteLLM、数据库和其他容器;`80`、`8000`、`8005`、`8006`、`3306`、`5432` 等端口已经被使用。因此,本项目不能占用现有端口、网络、数据库或容器名称。
|
||||
|
||||
该服务器有公网 IP,且已有多项服务绑定在 `0.0.0.0`。本方案将它视作“公网可达的共享主机”处理;云安全组和宿主机防火墙必须额外核验,不能仅凭端口监听结果假设安全。
|
||||
|
||||
## 3. 总体架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U[用户浏览器] -->|:8020| G[App Builder Gateway]
|
||||
U -->|:8022 /preview/<sandboxId>/| P[Preview Router]
|
||||
|
||||
G --> O[OpenVibe Web / Agent Server]
|
||||
O --> M[现有内网模型网关]
|
||||
O --> DB[(独立 PostgreSQL)]
|
||||
O --> R[(独立 Redis)]
|
||||
O --> SM[Local Sandbox Manager]
|
||||
|
||||
SM --> D[Docker Engine]
|
||||
D --> S1[visual-ui Sandbox]
|
||||
D --> S2[visual-fullstack Sandbox]
|
||||
P --> S1
|
||||
P --> S2
|
||||
|
||||
D --> H[Harbor :8023]
|
||||
S1 --> N[内网 pnpm/npm 源或预置离线依赖]
|
||||
S2 --> N
|
||||
```
|
||||
|
||||
设计原则:
|
||||
|
||||
1. 保留 OpenVibe 的对话、任务、文件树、工具卡片和 OpenCode ACP 代码代理体验。
|
||||
2. 模型请求仅由 OpenVibe 服务端发往既有内网模型网关;API Key 不进入沙箱。
|
||||
3. 每个项目运行于独立 Docker 容器和独立工作区;沙箱只运行项目代码,不承担平台控制逻辑。
|
||||
4. 浏览器预览只经过统一预览路由,不为每个项目映射新的公网端口。
|
||||
5. 先用固定模板和固定依赖清单保证稳定,后续再按白名单增加依赖。
|
||||
|
||||
## 4. 端口规划
|
||||
|
||||
端口虽然可用,但应尽量少暴露。除 8020、8022 和按需开放的 8023 外,其余服务只加入 Docker 内部网络。
|
||||
|
||||
| 端口 | 服务 | 绑定范围 | 用途 |
|
||||
|---:|---|---|---|
|
||||
| 8020 | `appbuilder-gateway` | 公网访问面或 VPN 白名单 | 控制台页面、`/api`、SSE/WebSocket |
|
||||
| 8021 | 预留 | 仅 `127.0.0.1` | 平台调试;第一期不对外开放 |
|
||||
| 8022 | `preview-router` | 公网访问面或 VPN 白名单 | 统一预览入口:`/preview/<sandboxId>/` |
|
||||
| 8023 | Harbor | 仅沙箱节点、构建节点和管理员网段 | 私有镜像仓库 HTTPS Registry |
|
||||
| 8024 | 预留 | 不对外开放 | 后续 npm 私服或运维服务 |
|
||||
| 8025-8030 | 预留 | 不对外开放 | 扩展、迁移或故障切换 |
|
||||
|
||||
### 4.1 预览路由规则
|
||||
|
||||
暂时没有可用泛域名时,使用路径型预览:
|
||||
|
||||
```text
|
||||
http(s)://<服务器地址>:8022/preview/<sandboxId>/
|
||||
```
|
||||
|
||||
项目模板的 Vite `base` 和 HMR 路径必须由启动脚本注入为该前缀,确保图片、JS/CSS 资源及热更新不会错误请求到根路径。后续若获得 `*.preview.company.local` 内网泛域名,可切换为子域名预览,用户体验更好。
|
||||
|
||||
## 5. 镜像、模板与离线依赖策略
|
||||
|
||||
### 5.1 标准镜像
|
||||
|
||||
第一期维护两种版本化镜像,不允许用户自行构建 Dockerfile:
|
||||
|
||||
| 镜像 | 用途 | 预置内容 |
|
||||
|---|---|---|
|
||||
| `appbuilder/visual-ui:<version>` | 大屏、海报、纯前端 | Node.js 20、pnpm、React、Vite、TypeScript、ECharts、流程图库、统一 UI 库、Tailwind、图标与中文字体 |
|
||||
| `appbuilder/visual-fullstack:<version>` | 前端加接口 | 上述前端依赖 + Python 3.12、FastAPI、Uvicorn、Pydantic |
|
||||
|
||||
流程图库第一期统一选择一种(建议 React Flow),避免同时生成多种流程图语法和样式。地图、背景图、字体等资源也应作为受版本控制的本地资产打包,不能依赖 Google Fonts 或公网 CDN。
|
||||
|
||||
### 5.2 项目模板
|
||||
|
||||
每次创建项目时从镜像中的模板初始化到 `/workspace`:
|
||||
|
||||
```text
|
||||
templates/
|
||||
visual-screen/ # React + ECharts + React Flow + Mock 数据
|
||||
visual-screen-api/ # visual-screen + FastAPI + /api 反向代理
|
||||
poster-page/ # 海报、营销展示、静态动效
|
||||
```
|
||||
|
||||
`visual-screen-api` 约定:前端只请求相对路径 `/api/*`;预览网关将其转发到同一沙箱的 FastAPI `8000` 端口。没有后端时保留 Mock 数据模块,生成的页面仍可运行。
|
||||
|
||||
### 5.3 依赖预置方式
|
||||
|
||||
不能仅把安装命令写进 Dockerfile 后在用户创建项目时再联网执行。正确做法是:
|
||||
|
||||
1. 在受控构建环境中锁定 `package.json`、`pnpm-lock.yaml`、`requirements.lock`。
|
||||
2. 构建镜像时执行 `pnpm fetch`,在镜像保存只读 pnpm store;Python 包保存到 `/opt/wheelhouse`。
|
||||
3. 沙箱初始化使用 `pnpm install --offline --frozen-lockfile` 和 `pip install --no-index --find-links=/opt/wheelhouse`。
|
||||
4. 第一期开启“固定依赖白名单”:AI 只能使用镜像已安装的依赖;新依赖先由管理员审核、加入锁文件和下一版基础镜像。
|
||||
5. 第二期部署 Verdaccio/Nexus 等内部制品源,作为受控补充而非允许访问公网 npm/PyPI。
|
||||
|
||||
## 6. OpenVibe 改造边界
|
||||
|
||||
### 6.1 保留
|
||||
|
||||
- 前端交互:对话、任务状态、文件浏览与修改、预览、日志;
|
||||
- OpenCode ACP 代码代理;
|
||||
- 项目与会话领域模型;
|
||||
- 代码代理的流式事件协议。
|
||||
|
||||
### 6.2 替换或关闭
|
||||
|
||||
| OpenVibe 当前能力 | 第一期开源/内网实现 |
|
||||
|---|---|
|
||||
| CodeBuddy | 只启用 OpenCode,配置内网 OpenAI 兼容模型网关 |
|
||||
| CloudBase、SCF 沙箱 | 新增 `LocalDockerSandboxManager` |
|
||||
| TCR | Harbor |
|
||||
| CloudBase 环境、用户环境池 | Docker 容器、工作区卷、沙箱记录 |
|
||||
| CloudBase 消息与事件持久化 | 独立 PostgreSQL;Redis 用于临时队列与状态 |
|
||||
| CloudBase 存储、函数、MCP、资源管理后台 | 第一期开关关闭或移除入口 |
|
||||
| CloudBase 项目模板和系统提示词 | 替换为大屏/海报/FastAPI 模板与受控依赖提示词 |
|
||||
|
||||
重点改造点是将 OpenVibe 的 `scf-sandbox-manager` 和 CloudBase 生命周期调用替换为本地 Docker 生命周期。为降低前端和 OpenCode 工具改动范围,新管理器应适配现有的文件、命令、日志、健康检查与工作区信息契约,而不是改写所有 Agent 工具。
|
||||
|
||||
## 7. 沙箱安全基线
|
||||
|
||||
每个沙箱容器必须满足:
|
||||
|
||||
- 非 root 用户运行;禁止 `privileged`,禁止 Docker Socket,禁止宿主机目录挂载;
|
||||
- `--cap-drop=ALL`、`no-new-privileges`、`pids-limit`;
|
||||
- 首期默认限制:`1 vCPU`、`3 GB 内存`、`10 GB 工作区`、60 分钟空闲回收;
|
||||
- 使用独立 Docker network;默认无公网出口,只允许访问预置依赖、必要的内网 DNS 和平台回调;
|
||||
- 数据库、Redis、Harbor 管理接口、宿主机管理端口不在沙箱网络可访问范围;
|
||||
- 运行命令、容器生命周期、文件变更和预览访问写入审计日志;
|
||||
- 平台服务由受限服务账号调用 Docker API;用户代码永远不直接获得 Docker 权限。
|
||||
|
||||
第一期为“内部可信用户的容器隔离”。若后续允许大量非可信代码或文件上传,应迁移为 Kubernetes 节点,并评估 gVisor/Kata 等更强运行时隔离。
|
||||
|
||||
## 8. 分步实施计划
|
||||
|
||||
### 阶段 0:部署前隔离与基线确认
|
||||
|
||||
**工作项**
|
||||
|
||||
1. 为本项目建立独立目录、Docker Compose Project、卷和网络,命名统一为 `appbuilder-*`。
|
||||
2. 确认云安全组、iptables/nftables 规则:仅允许管理员 SSH;8020、8022、8023 仅向 VPN 或指定办公出口开放。
|
||||
3. 不复用当前服务器上已有的 MySQL、PostgreSQL、Redis、Nginx 配置;建立独立实例或独立命名空间。
|
||||
4. 创建内部 CA 证书或确定可信 HTTPS 证书方案;Docker 构建/运行节点信任 Harbor 证书。
|
||||
5. 轮换目标服务器 root 密码,改用 SSH 密钥和受限运维账号;关闭 root 密码远程登录。
|
||||
|
||||
**验收**
|
||||
|
||||
- `8020-8030` 无端口冲突;
|
||||
- 新容器、网络和数据卷均不影响既有 DeerFlow 等服务;
|
||||
- 业务数据库和沙箱端口无法从非授权公网地址访问。
|
||||
|
||||
### 阶段 1:内网基础设施与镜像供应链
|
||||
|
||||
**工作项**
|
||||
|
||||
1. 在独立 Compose 项目中部署 Harbor,主入口为 `8023`,镜像数据使用独立持久化目录。
|
||||
2. 构建并推送 `visual-ui`、`visual-fullstack` 两种基础镜像;记录镜像版本、SBOM 和依赖锁文件。
|
||||
3. 完成三个模板及离线依赖初始化脚本。
|
||||
4. 先不启动漏洞扫描;严格断网环境下后续通过离线包维护漏洞库。
|
||||
5. 预留内部 npm/PyPI 源接入方式;第一期仅使用预置依赖。
|
||||
|
||||
**验收**
|
||||
|
||||
- Docker daemon 能从 Harbor 拉取指定版本镜像;
|
||||
- 在禁止公网访问的测试网络中,三个模板均能启动并构建;
|
||||
- 一个 ECharts 页面、一个流程图页面和一个 FastAPI `/api/health` 均可运行。
|
||||
|
||||
### 阶段 2:OpenVibe 去 CloudBase 化与内网模型接入
|
||||
|
||||
**工作项**
|
||||
|
||||
1. Fork 固定版本的 OpenVibeCoding,建立 `local-platform` 改造分支,记录上游 commit 与许可证。
|
||||
2. 只保留 OpenCode Agent;将 provider 指向现有内网模型网关,模型凭证只保存在服务端环境变量或密钥服务。
|
||||
3. 将基础实体、任务、消息、流式事件的持久化迁移为独立 PostgreSQL;不再调用 CloudBase 集合。
|
||||
4. 关闭 CloudBase 环境、函数、存储、数据库、资源仪表盘和相关 MCP 入口。
|
||||
5. 将系统提示词和项目创建默认项改为“可视化页面 + 受控模板/依赖”。
|
||||
|
||||
**验收**
|
||||
|
||||
- 不设置任何腾讯云、CloudBase、CodeBuddy、TCR 环境变量时,平台可启动;
|
||||
- 用户发起“生成 ECharts 大屏”后,OpenCode 能调用内网模型并生成项目文件;
|
||||
- 服务端日志和沙箱环境中均没有模型 API Key。
|
||||
|
||||
### 阶段 3:本地沙箱管理器与预览路由
|
||||
|
||||
**工作项**
|
||||
|
||||
1. 实现 `LocalDockerSandboxManager`:创建、启动、停止、超时回收、查询状态和删除容器。
|
||||
2. 每个沙箱分配工作区卷、随机内部容器地址、模板类型和资源限制;在数据库记录 `sandbox_id`、项目、用户、镜像版本、过期时间和状态。
|
||||
3. 实现或适配沙箱内 runner:文件读写、受限命令执行、日志流、健康检查、前端端口和 FastAPI 端口发现。
|
||||
4. 实现 `preview-router`:由平台根据 `sandboxId` 维护路由,将 `/preview/<sandboxId>/` 代理到对应内部容器;支持 WebSocket/HMR。
|
||||
5. 增加容器异常退出、端口未就绪、构建失败、超时与手动重启的可见状态。
|
||||
|
||||
**验收**
|
||||
|
||||
- 两个用户创建的项目存在于不同容器和不同工作区;
|
||||
- 修改 ECharts 配置文件后,预览地址显示修改结果;
|
||||
- FastAPI 模板的 `/api/health` 可通过前端相对路径访问;
|
||||
- 沙箱不能访问 Docker Socket、宿主机数据库和非白名单网络;
|
||||
- 空闲超时后容器被回收,项目文件仍可恢复。
|
||||
|
||||
### 阶段 4:产品能力收口
|
||||
|
||||
**工作项**
|
||||
|
||||
1. 在文件面板支持安全的文件查看、编辑、保存、目录树刷新与下载;限制文件大小和可编辑扩展名。
|
||||
2. 在对话中增加模板选择和能力提示:大屏、流程图、海报、可选 FastAPI。
|
||||
3. 为 Agent 增加依赖白名单、图表主题、中文字体、Mock 数据、接口契约等明确提示词。
|
||||
4. 增加“运行/停止/重启/删除沙箱”和日志面板;仅项目所有者或管理员有相应权限。
|
||||
5. 增加项目快照、模板版本记录和基础审计日志。
|
||||
|
||||
**验收**
|
||||
|
||||
- 从一句自然语言需求到可预览项目的完整链路可完成;
|
||||
- 用户可查看并手动修改文件,刷新后改动保留;
|
||||
- 页面覆盖 ECharts、流程图、响应式大屏和可选 FastAPI 接口;
|
||||
- 非拥有者不能读取、修改或预览另一用户的项目。
|
||||
|
||||
### 阶段 5:上线前安全与运行验收
|
||||
|
||||
**工作项**
|
||||
|
||||
1. 压测 6 个并发沙箱,并记录 CPU、内存、磁盘、容器启动时间、首屏预览时间和模型等待时间。
|
||||
2. 验证镜像保留策略、工作区配额、容器回收、数据库备份和 Harbor 备份恢复。
|
||||
3. 执行越权、路径穿越、命令注入、网络探测、资源耗尽和模型密钥泄露测试。
|
||||
4. 建立日常运维操作:新增基础依赖、构建镜像、发布模板、回滚镜像、清理工作区和审计查询。
|
||||
|
||||
**验收**
|
||||
|
||||
- 并发运行时既有 DeerFlow 等服务不受明显影响;
|
||||
- 任一沙箱异常退出不会影响其他项目或平台控制服务;
|
||||
- 备份可恢复项目元数据、工作区和 Harbor 镜像;
|
||||
- 仅开放计划中的入口端口,内部组件无不必要公网监听。
|
||||
|
||||
## 9. 首期资源与并发建议
|
||||
|
||||
由于该主机已有多项业务,首期不应按理论最大值分配:
|
||||
|
||||
| 组件 | 首期限制 |
|
||||
|---|---|
|
||||
| Harbor | 8 vCPU、16 GB 内存、500 GB 数据盘配额 |
|
||||
| OpenVibe 控制服务 | 4 vCPU、8-16 GB 内存 |
|
||||
| 单个 UI 沙箱 | 1 vCPU、3 GB 内存、10 GB 工作区 |
|
||||
| 单个 Fullstack 沙箱 | 1.5 vCPU、4 GB 内存、12 GB 工作区 |
|
||||
| 沙箱并发 | 先限制为 6;稳定后再逐步上调 |
|
||||
|
||||
当并发超过 10 个,或要面向更广泛用户开放时,应优先增加独立沙箱节点,而不是继续在本共享主机上加容器。
|
||||
|
||||
## 10. 实施顺序与交付物
|
||||
|
||||
建议按以下提交/交付顺序推进,每一步都可独立验收和回退:
|
||||
|
||||
1. `docs: confirm appbuilder network, ports and security baseline`
|
||||
2. `infra: add Harbor and versioned visual sandbox images`
|
||||
3. `infra: add offline templates and dependency locks`
|
||||
4. `feat: replace OpenVibe CloudBase persistence and model provider`
|
||||
5. `feat: add local Docker sandbox manager`
|
||||
6. `feat: add sandbox runner and path-based preview router`
|
||||
7. `feat: add visual-screen and FastAPI project workflows`
|
||||
8. `test: add sandbox isolation, preview and recovery coverage`
|
||||
9. `ops: add backup, cleanup, monitoring and rollout runbook`
|
||||
|
||||
达到阶段 3 即可交付“内网模型驱动、生成并预览大屏/海报页面”的 PoC;达到阶段 5 后再作为受控的内部多人平台发布。
|
||||
@@ -17,6 +17,9 @@ 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后端接口
|
||||
|
||||
@@ -60,3 +60,9 @@
|
||||
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。
|
||||
# VITE_COMPACT_MENU_TYPE=A
|
||||
# VITE_GITHUB_OAUTH_TOKEN=
|
||||
|
||||
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
|
||||
# true 时,普通登录后进入同一 Vite 应用中隔离的 /official/*。
|
||||
# 已指定 redirect 或 taskId/goPath 的登录仍保持原来的优先级。
|
||||
# 仅 .env.development 默认 true;其余环境保持 false。
|
||||
# VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
|
||||
@@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=false
|
||||
VITE_BACKEND_BASE_URL=http://10.19.200.5:2026
|
||||
VITE_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,6 +14,9 @@ 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,6 +17,9 @@ 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后端接口
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# 项目空间
|
||||
|
||||
入口:全量与简洁导航中的「项目空间」,路由 `/page/workspace/projects`。
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. 新建项目,填写名称,在问答、策略智能、方案生成中选择一种模式。
|
||||
2. 选择项目默认智能体,填写目标、约束等项目说明。新建项目对话时仍可换用另一个可访问的智能体。
|
||||
3. 创建后进入嵌入式问答工作区,左侧保留普通聊天记录;项目对话也会出现在聊天记录中并返回所属项目。
|
||||
4. 方案生成可从「工作流配置 · 报告结构」选择、启停、编辑、删除结构,选择后自动填充项目报告结构配置;也可直接编辑大纲及固定/弹性约束。
|
||||
5. 策略智能项目保存默认 Strategy Lens;每轮依次进行问题重构、各 Lens 独立分析、策略辩论和方案生成,并分为「意图理解」「多视角策略分析」「完整策略方案」三栏。右栏以文档形式给出执行摘要、目标原则、分阶段行动、资源配置、风险应对、评估复盘、形成依据与备选策略切换条件;原始对话和引用仍可切换查看。
|
||||
6. 策略智能对话底部提供改变约束、增删视角、重新推演和挑战当前策略快捷动作;项目主页面可直接添加或删除 Lens。
|
||||
|
||||
模式在创建后不可修改,前后端都不提供修改入口。项目、对话归属和智能体访问权限在服务端校验;方案结构及每条对话所选智能体也以服务端配置为准。
|
||||
|
||||
## 代码与运行
|
||||
|
||||
- `src/pages/AnalysisProjectsPage.tsx`:项目列表、新建、设置、Lens 配置、历史与工作台布局。
|
||||
- `src/pages/analysis-projects/ProjectConversation.tsx`:现有聊天、研究进度、方案画布的项目级组合。
|
||||
- `src/core/analysis-projects/api.ts`:Gateway 项目 API 与按账号隔离的缓存。
|
||||
- 后端:`/api/analysis-projects`,对话仍通过现有 `/api/threads/{thread_id}/runs/*` 流式接口发送;服务端从不可变归属读取项目配置。
|
||||
|
||||
启动新版 Gateway 后自动创建项目表。生产持久化依赖已配置的数据库;memory 模式中的项目随服务重启丢失。前端沿用仓库的 Vite 开发与构建命令。
|
||||
|
||||
## 验证范围
|
||||
|
||||
后端回归命令:在 `offline-backend-20260512/backend` 执行
|
||||
|
||||
```shell
|
||||
python -m pytest tests/test_analysis_projects.py tests/test_deep_research_report_tool.py tests/test_writing_mode_report_middleware.py -q
|
||||
```
|
||||
|
||||
涵盖内存与 SQLite 存储、跨用户隔离、禁止修改模式、运行时覆盖伪造模式与知识库、更新配置、对话归属与失败清理、固定方案模板及知识库资料进入研究管线。
|
||||
|
||||
部署前还需使用实际知识库和模型验证三类问答、流式断线续接及方案后续修改。接口模拟与单元测试不能替代真实模型联调。
|
||||
|
||||
## 2026-09-14 我的项目与知识库验收修复
|
||||
|
||||
日常使用复查:我的项目侧栏历史保持 `qa/projects/groups` 上下文,官方侧栏同样识别项目归属;手机选择历史后收起菜单;项目表单不重复显示侧栏按钮。设置保存后默认智能体同步到新对话选择器。读取项目对话失败提供重试,项目列表失败可原地重试。助手知识检索隔离过期响应,清空输入时禁用检索,无结果提供目录入口。ScrollArea 固定为包含 React 19 引用回调修复的 1.2.18。详细记录见 `docs/daily-usage-audit-20260914.md`。
|
||||
|
||||
- 简洁模式的「我的项目」列表、策略工作台和普通问答项目恢复工作区功能导航;窄屏提供展开菜单按钮。
|
||||
- 普通问答先创建具名项目,再在项目内创建多个对话。列表读取 `/api/analysis-projects` 的 `qa` 项目,不再读取、标记或自动归入全局普通聊天。
|
||||
- 项目设置更新保留既有知识库绑定。原「项目空间」与官方入口继续可用;我的项目下问答路由为 `qa/projects/groups/*`。
|
||||
- 策略生成卡片统一先显示思考/执行过程,再显示正文;评判、方案及展开视图沿用相同顺序。
|
||||
- 完成评判但尚未勾选策略的项目可以从方案列表进入,在工作台选择后生成;已保存方案在任务修改后仍可打开。旧列表请求不再覆盖新请求状态。
|
||||
- 知识库管理页读取上游最新目录,避免远端删除后命中旧缓存;检索调用仍共享短期目录缓存。
|
||||
- 普通用户访问尚未初始化的助手知识库时显示明确空态及刷新按钮。
|
||||
|
||||
验证:后端相关回归 273 项、前端策略/项目回归 31 项通过;真实 BAAI/bge-m3 编码、指定库向量检索与草稿隔离通过;本地隔离服务的问答项目创建/分组、真实问答、方案整理、助手库创建/编辑/文件入库/向量检索/详情均通过。完整验收记录见仓库 `docs/ji-knowledge-projects-acceptance.md`。不在源码或文档中保存接口密钥。
|
||||
@@ -0,0 +1,7 @@
|
||||
# 技能知识归纳与“知识梳理”助手知识库
|
||||
|
||||
管理员在「技能管理 → 技能知识归纳」中选择一个或多个技能、一个或多个目标,系统按“技能 × 目标”创建后台任务。界面提供总体进度、单项失败重试、实体/关系审核、绑定重归纳、自动同步和两种解绑动作。技能列表通过一次 bindings 查询展示归纳目标,避免逐技能请求。
|
||||
|
||||
扫描仅处理知识资料。代码、脚本、锁文件、密钥文件以及 Markdown 代码块不会进入上传、摘要、提示词或知识指纹;ZIP 只读取逐项验证通过的知识成员。自动监听只比较该知识指纹,所以纯代码改动不会产生 stale 状态。
|
||||
|
||||
知识库页面新增「普通知识库 / 助手知识库」并行入口。管理员可将系统默认入口设为 v1(普通 WeKnora)或 v2(全局“知识梳理”)。助手库无需 WeKnora 即可初始化,所有用户可只读查看和搜索 Wiki;管理员可上传本地文件、从普通库导入、重新导入或删除来源、查看版本并回滚。来源删除只移除对应贡献,共享实体与关系继续保留。
|
||||
File diff suppressed because it is too large.
Load diff
Generated
+355
-350
@@ -28,10 +28,10 @@
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-scroll-area": "1.2.18",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-slot": "1.3.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
@@ -69,7 +69,9 @@
|
||||
"gsap": "^3.13.0",
|
||||
"hast": "^1.0.0",
|
||||
"katex": "^0.16.28",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"maplibre-gl": "^6.9.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"motion": "^12.26.2",
|
||||
"nanoid": "^5.1.6",
|
||||
@@ -1702,6 +1704,92 @@
|
||||
"@lezer/lr": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
|
||||
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
|
||||
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
|
||||
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/geojson-vt": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
|
||||
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "26.4.2",
|
||||
"resolved": "https://registry.npmmirror.com/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.2.tgz",
|
||||
"integrity": "sha512-6J0vZqMZvRAJKtdWJdDGHEh1YJ2ZHG08/GOur8gCArYhO8ZkM//OXqtI2AAEz4jc2G+Wq4MfcePg8qNK5TZ4Kg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-format": "dist/gl-style-format.mjs",
|
||||
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/mlt": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/@maplibre/mlt/-/mlt-1.2.1.tgz",
|
||||
"integrity": "sha512-5n5dgolE2EYxwCKgx8vlwURCB8A+kyfJJyThlYimjlGgOcOe2Bhw9VxxnnHC91OC9PgHg+nVdgVPTYPkIo62vg==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
|
||||
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
@@ -1787,23 +1875,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-avatar": {
|
||||
"version": "1.1.11",
|
||||
"license": "MIT",
|
||||
@@ -1893,23 +1964,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||
@@ -1971,23 +2025,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
@@ -2086,23 +2123,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||
@@ -2165,23 +2185,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu": {
|
||||
"version": "2.1.16",
|
||||
"license": "MIT",
|
||||
@@ -2245,23 +2248,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
||||
@@ -2322,23 +2308,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card": {
|
||||
"version": "1.1.15",
|
||||
"license": "MIT",
|
||||
@@ -2404,23 +2373,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-icons": {
|
||||
"version": "1.3.2",
|
||||
"license": "MIT",
|
||||
@@ -2520,23 +2472,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||
@@ -2604,23 +2539,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
||||
@@ -2666,23 +2584,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||
@@ -2816,36 +2717,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area": {
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz",
|
||||
"integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
"@radix-ui/number": "1.1.3",
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-context": "1.2.2",
|
||||
"@radix-ui/react-direction": "1.1.4",
|
||||
"@radix-ui/react-presence": "1.1.10",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.4",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -2862,10 +2748,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/number": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz",
|
||||
"integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
|
||||
"integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz",
|
||||
"integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
@@ -2876,12 +2790,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz",
|
||||
"integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz",
|
||||
"integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -2898,13 +2828,49 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz",
|
||||
"integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
"@radix-ui/react-slot": "1.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz",
|
||||
"integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
|
||||
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
@@ -2992,23 +2958,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator": {
|
||||
"version": "1.1.8",
|
||||
"license": "MIT",
|
||||
@@ -3031,10 +2980,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.4",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
|
||||
"integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
"@radix-ui/react-compose-refs": "1.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -3046,6 +2997,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch": {
|
||||
"version": "1.2.6",
|
||||
"license": "MIT",
|
||||
@@ -3109,23 +3075,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs": {
|
||||
"version": "1.1.13",
|
||||
"license": "MIT",
|
||||
@@ -3190,23 +3139,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle": {
|
||||
"version": "1.1.10",
|
||||
"license": "MIT",
|
||||
@@ -3293,23 +3225,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
@@ -3332,23 +3247,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.2.8",
|
||||
"license": "MIT",
|
||||
@@ -3417,23 +3315,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
@@ -3622,23 +3503,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||
@@ -5557,6 +5421,15 @@
|
||||
"version": "1.2.1",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.1.0.tgz",
|
||||
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||
@@ -6459,6 +6332,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/earcut/-/earcut-3.2.3.tgz",
|
||||
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.349",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
|
||||
@@ -6853,6 +6732,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmmirror.com/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -7351,6 +7236,12 @@
|
||||
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
@@ -7390,6 +7281,12 @@
|
||||
"katex": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/kdbush": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/kdbush/-/kdbush-4.1.0.tgz",
|
||||
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/khroma": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
|
||||
@@ -7432,6 +7329,12 @@
|
||||
"resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz",
|
||||
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmmirror.com/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
@@ -7766,6 +7669,39 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "6.9.0",
|
||||
"resolved": "https://registry.npmmirror.com/maplibre-gl/-/maplibre-gl-6.9.0.tgz",
|
||||
"integrity": "sha512-vFMwMK0Zs+NM/rOMSdtu8bO30DIexhBEVi5KC6f70/XtI+L/K2wC3LsDCAXFZ4s8ik5gAuDugfCNbpllpdJ9bA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.2.0",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"@maplibre/geojson-vt": "^6.1.1",
|
||||
"@maplibre/maplibre-gl-style-spec": "^26.4.2",
|
||||
"@maplibre/mlt": "^1.2.1",
|
||||
"@maplibre/vt-pbf": "^4.3.2",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"bidi-js": "^1.1.0",
|
||||
"earcut": "^3.2.3",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"kdbush": "^4.1.0",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^5.1.2",
|
||||
"potpack": "^2.1.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14.0",
|
||||
"npm": ">=8.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.1",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||
@@ -8713,6 +8649,15 @@
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
@@ -8771,6 +8716,12 @@
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/murmurhash-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mustache": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
|
||||
@@ -8943,6 +8894,18 @@
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/pbf/-/pbf-5.1.2.tgz",
|
||||
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
@@ -9035,6 +8998,12 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/potpack/-/potpack-2.1.0.tgz",
|
||||
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/prismjs": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||
@@ -9218,6 +9187,12 @@
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmmirror.com/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
@@ -9235,6 +9210,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
@@ -9618,6 +9599,24 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||
@@ -9971,6 +9970,12 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyqueue": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tiptap-markdown": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz",
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:input-mode": "node --experimental-strip-types --test src/components/workspace/input-mode.test.ts",
|
||||
"test:html-preview": "pnpm exec esbuild src/core/utils/legacy-html-preview.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/legacy-html-preview.test.mjs && node --test .runtime/unit-tests/legacy-html-preview.test.mjs",
|
||||
"test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts",
|
||||
"test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
|
||||
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
|
||||
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
|
||||
},
|
||||
@@ -35,10 +39,10 @@
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-scroll-area": "1.2.18",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-slot": "1.3.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
@@ -76,7 +80,9 @@
|
||||
"gsap": "^3.13.0",
|
||||
"hast": "^1.0.0",
|
||||
"katex": "^0.16.28",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"maplibre-gl": "^6.9.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"motion": "^12.26.2",
|
||||
"nanoid": "^5.1.6",
|
||||
@@ -120,5 +126,8 @@
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-source-identifier": "^1.1.2"
|
||||
},
|
||||
"overrides": {
|
||||
"@radix-ui/react-slot": "1.3.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.26.2"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Keep indirect Radix primitives on the React 19 ref-stability fix too.
|
||||
overrides:
|
||||
'@radix-ui/react-slot': 1.3.3
|
||||
@@ -40,6 +40,10 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
|
||||
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
|
||||
|
||||
// 地图瓦片地址模板:普通问答地图和生成的 HTML 网页地图共同使用。
|
||||
// 后续接入内网瓦片服务时,只需替换此项并保留 {z}/{x}/{y} 占位符。
|
||||
VITE_MAP_TILE_URL: "http://119.254.86.251:8023/tiles/{z}/{x}/{y}.png?v=zh-z14-13",
|
||||
|
||||
// routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。
|
||||
// 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。
|
||||
VITE_ROUTE_LOGIN_PASSWORD: "",
|
||||
|
||||
@@ -36,6 +36,7 @@ import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
|
||||
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
|
||||
import EmbedChatPage from "./pages/EmbedChatPage";
|
||||
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
||||
import SiteStudioPage from "./pages/site-studio/SiteStudioPage";
|
||||
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
|
||||
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
||||
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
} 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/",
|
||||
@@ -203,6 +205,9 @@ export function App() {
|
||||
path="/roundtable-dashboard"
|
||||
element={<DashboardStandalonePage />}
|
||||
/>
|
||||
<Route path="/official/*" element={<OfficialDeerFlowEntry />} />
|
||||
{/* Web App 站点预览工作室:独立路由 + 独立视觉风格(无主应用 chrome) */}
|
||||
<Route path="/sites/:threadId/:siteName" element={<SiteStudioPage />} />
|
||||
<Route path="/page/*" element={pageElement} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2064"
|
||||
height="2591" viewBox="0 0 2064 2591">
|
||||
<path fill-rule="nonzero"
|
||||
d="M 68.582031 0 C 66.890625 0 65.300781 1 64.617188 2.640625 C 49.46875 38.800781 33.71875 76.199219 33.628906 106.539062 C 33.550781 134.230469 46.445312 155.730469 59.949219 175.71875 L 59.949219 175.730469 C 52.414062 175.671875 44.992188 175.371094 37.472656 174.171875 C 27.210938 172.53125 16.585938 169.199219 5.585938 165.738281 C 5.15625 165.609375 4.722656 165.539062 4.296875 165.539062 C 2.824219 165.539062 1.417969 166.300781 0.625 167.609375 C -0.402344 169.300781 -0.140625 171.46875 1.257812 172.871094 L 1.261719 172.878906 C 11.230469 182.871094 21.8125 193.53125 36.980469 200.050781 C 51.5625 206.320312 70.167969 208.628906 88.085938 210.78125 L 88.027344 210.78125 C 127.152344 253.410156 166.617188 296.300781 215.761719 334.941406 C 230.558594 346.578125 246.292969 357.878906 261.785156 369 C 277.171875 380.050781 292.441406 391.011719 306.660156 402.191406 C 326.132812 417.511719 343.5625 433.179688 356.773438 450.019531 C 377.808594 476.828125 388.449219 506.960938 395.4375 538.628906 L 395.4375 538.640625 C 372.683594 510.710938 349.328125 483.929688 324.855469 469.339844 C 295.957031 452.121094 265.800781 452 236.960938 451.808594 L 236.933594 451.820312 C 235.792969 451.808594 234.707031 452.261719 233.898438 453.070312 C 233.085938 453.871094 232.632812 454.96875 232.636719 456.121094 C 232.722656 494.011719 232.699219 533.539062 256.304688 567.480469 C 279.351562 600.621094 324.738281 628.019531 369.191406 654.679688 L 369.132812 654.691406 C 371.476562 683.539062 373.40625 710.691406 367.109375 728.03125 C 365.828125 731.558594 364.191406 734.71875 362.058594 738.820312 C 358.910156 744.871094 354.964844 752.441406 350.417969 764.960938 C 342.890625 785.699219 333.769531 819.839844 323.648438 883.109375 C 303.347656 1010.019531 278.722656 1255.949219 308.882812 1409.269531 C 326.265625 1497.660156 361.828125 1555.210938 393.511719 1606.519531 C 417.042969 1644.632812 438.207031 1678.960938 448.835938 1720.09375 C 466.921875 1790.089844 454.648438 1880.246094 444.5 1954.386719 C 440.613281 1982.78125 437.042969 2008.839844 435.726562 2030.261719 C 433.410156 2067.929688 438.066406 2091.179688 442.304688 2112.347656 L 442.304688 2112.355469 C 445.910156 2130.347656 449.152344 2146.589844 447.757812 2169.28125 C 447.429688 2174.613281 446.835938 2180.359375 445.933594 2186.535156 L 445.933594 2186.542969 C 436.582031 2250.328125 393.613281 2362.808594 362.105469 2416.636719 C 339.761719 2454.804688 323.546875 2462.851562 309.597656 2469.925781 C 303.980469 2472.777344 298.261719 2475.660156 292.691406 2480.238281 C 273.378906 2496.113281 256.457031 2532.078125 240.183594 2566.417969 C 239.578125 2567.703125 239.640625 2569.203125 240.363281 2570.429688 C 241.082031 2571.65625 242.359375 2572.449219 243.777344 2572.542969 L 243.789062 2572.542969 L 243.882812 2572.550781 L 243.882812 2572.554688 C 261.582031 2573.746094 279.460938 2574.957031 296.386719 2574.957031 C 323.671875 2574.957031 349.332031 2571.84375 368.355469 2559.785156 C 392.640625 2544.394531 405.109375 2514.960938 414.488281 2493.074219 C 417.011719 2487.183594 419.289062 2481.871094 421.546875 2477.308594 C 432.054688 2456.039062 441.753906 2451.664062 451.855469 2446.960938 C 453.054688 2446.402344 453.929688 2445.320312 454.226562 2444.042969 L 454.238281 2443.996094 L 454.238281 2443.988281 C 476.210938 2348.800781 498.035156 2254.347656 524.300781 2196.363281 L 524.300781 2196.367188 C 536.363281 2169.730469 549.363281 2150.796875 562.695312 2131.402344 C 576.28125 2111.625 590.1875 2091.402344 603.300781 2062.445312 L 603.304688 2062.453125 C 605.183594 2058.292969 607.054688 2053.949219 608.894531 2049.421875 C 625.066406 2009.675781 639.746094 1954.289062 653.585938 1902.101562 C 663.0625 1866.367188 672.15625 1832.09375 681.085938 1805.085938 C 699.308594 1749.992188 716.863281 1725.09375 734.371094 1705.144531 L 734.421875 1705.148438 C 727.285156 1740.746094 721.597656 1776.628906 729.320312 1807.472656 C 733.351562 1823.570312 740.996094 1838.160156 749.066406 1853.613281 C 758.828125 1872.296875 769.433594 1892.636719 776.359375 1919.675781 C 783.289062 1946.71875 786.574219 1980.605469 789.527344 2011.140625 L 789.527344 2011.148438 C 791.875 2035.402344 794.023438 2057.78125 797.746094 2072.316406 L 797.746094 2072.320312 C 797.929688 2073.023438 798.128906 2073.769531 798.316406 2074.441406 L 798.320312 2074.441406 C 802.132812 2087.941406 807.617188 2093.914062 813.515625 2100.324219 C 821.132812 2108.597656 830.351562 2118.441406 839.742188 2151.671875 L 839.738281 2151.675781 C 840.105469 2152.96875 840.402344 2154.039062 840.765625 2155.398438 C 857.484375 2217.601562 874.097656 2354.589844 872.078125 2423.390625 C 870.472656 2478.15625 857.140625 2488.390625 845.339844 2498.136719 C 842.320312 2500.632812 839.085938 2503.296875 836.09375 2506.566406 C 820.847656 2523.230469 812.652344 2554.886719 804.800781 2584.753906 C 804.457031 2586.0625 804.75 2587.453125 805.589844 2588.515625 C 806.40625 Line truncated
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 19 KiB |
@@ -56,12 +56,11 @@ import {
|
||||
type ChangeEventHandler,
|
||||
Children,
|
||||
type ClipboardEventHandler,
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type FormEvent,
|
||||
type FormEventHandler,
|
||||
Fragment,
|
||||
type HTMLAttributes,
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type FormEvent,
|
||||
type FormEventHandler,
|
||||
type HTMLAttributes,
|
||||
type KeyboardEventHandler,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
@@ -124,9 +123,11 @@ export const usePromptInputController = () => {
|
||||
return ctx;
|
||||
};
|
||||
|
||||
// Optional variants (do NOT throw). Useful for dual-mode components.
|
||||
const useOptionalPromptInputController = () =>
|
||||
useContext(PromptInputController);
|
||||
// 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);
|
||||
|
||||
export const useProviderAttachments = () => {
|
||||
const ctx = useContext(ProviderAttachmentsContext);
|
||||
@@ -403,14 +404,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) => (
|
||||
<Fragment key={file.id}>
|
||||
<div className="max-w-60">{children(file)}</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
{...props}
|
||||
>
|
||||
{attachments.files.map((file) => (
|
||||
<div key={file.id} className="max-w-60">
|
||||
{children(file)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview";
|
||||
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`)
|
||||
* navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is
|
||||
* a real document URL, so fragment links scroll within the frame as expected —
|
||||
* all without enabling scripts (the sandbox stays script-free for security; the
|
||||
* server already sanitizes script vectors and this is defense-in-depth).
|
||||
* The sandbox permits scripts so the preview can use the app-bundled HTML
|
||||
* visualization runtimes, while still withholding same-origin privileges.
|
||||
*
|
||||
* The `charset=utf-8` on the blob type is required: a blob: URL whose
|
||||
* Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on
|
||||
@@ -26,7 +26,9 @@ export function HtmlPreviewFrame({
|
||||
const [url, setUrl] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
const blob = new Blob([prepareGeneratedHtml(html ?? "")], {
|
||||
const blob = new Blob([
|
||||
prepareGeneratedHtml(html ?? ""),
|
||||
], {
|
||||
type: "text/html;charset=utf-8",
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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";
|
||||
@@ -29,7 +30,11 @@ export function Hero({ className }: { className?: string }) {
|
||||
/>
|
||||
</div>
|
||||
<FlickeringGrid
|
||||
className="absolute inset-0 z-0 translate-y-8 mask-[url(/images/deer.svg)] mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"
|
||||
className="absolute inset-0 z-0 translate-y-8 mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"
|
||||
style={{
|
||||
WebkitMaskImage: `url("${deerMaskUrl}")`,
|
||||
maskImage: `url("${deerMaskUrl}")`,
|
||||
}}
|
||||
squareSize={4}
|
||||
gridGap={4}
|
||||
color={"white"}
|
||||
|
||||
@@ -56,7 +56,7 @@ type PageSidebarProps = {
|
||||
headerHidden?: boolean;
|
||||
};
|
||||
|
||||
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
|
||||
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "encoder" | "about";
|
||||
|
||||
const hasActiveChild = (item: MenuItem, pathname: string): boolean =>
|
||||
menuItemContainsPath(item, pathname);
|
||||
@@ -270,6 +270,12 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
||||
模型配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
||||
<Cpu className="size-4" />
|
||||
本地编码器
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/settings/model-concurrency")}
|
||||
@@ -585,6 +591,12 @@ function LegacyAccordionSidebar({
|
||||
模型配置
|
||||
</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,5 +1,4 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -21,12 +20,12 @@ export function Breadcrumb({
|
||||
)}
|
||||
>
|
||||
{segments.map((segment, i) => (
|
||||
<Fragment key={`${segment}-${i}`}>
|
||||
<div className="contents" key={`${segment}-${i}`}>
|
||||
<span className="hidden shrink-0 whitespace-nowrap sm:inline">
|
||||
{segment}
|
||||
</span>
|
||||
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
|
||||
</Fragment>
|
||||
</div>
|
||||
))}
|
||||
<span
|
||||
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
|
||||
|
||||
@@ -51,6 +51,10 @@ 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";
|
||||
@@ -69,6 +73,7 @@ import {
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||
import {
|
||||
bootstrapResumePath,
|
||||
isBootstrapThread,
|
||||
@@ -479,6 +484,7 @@ 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);
|
||||
@@ -505,6 +511,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
)}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : projectType ? (
|
||||
<MyProjectThreadIcon type={projectType} />
|
||||
) : (
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
@@ -516,9 +524,15 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
{pinned && (
|
||||
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
<span className="min-w-0 flex-1 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" />
|
||||
)}
|
||||
|
||||
@@ -152,6 +152,7 @@ export function AgentDetailPanel({
|
||||
positionName,
|
||||
skillsIcon,
|
||||
skillsTitle,
|
||||
belowSkillsSlot,
|
||||
showCommonQuestions = false,
|
||||
jobResponsibilities,
|
||||
panelTitle,
|
||||
@@ -181,6 +182,8 @@ export function AgentDetailPanel({
|
||||
positionName?: string;
|
||||
skillsIcon?: ReactNode;
|
||||
skillsTitle?: string;
|
||||
/** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */
|
||||
belowSkillsSlot?: ReactNode;
|
||||
showCommonQuestions?: boolean;
|
||||
/**
|
||||
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
|
||||
@@ -940,6 +943,9 @@ export function AgentDetailPanel({
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
|
||||
{belowSkillsSlot}
|
||||
|
||||
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
|
||||
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,12 @@ 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 { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -45,6 +50,7 @@ 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}
|
||||
@@ -57,15 +63,20 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<MessageSquareIcon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{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">
|
||||
{desensitize(titleOfThread(thread, "新对话"))}
|
||||
</span>
|
||||
{projectType && <MyProjectThreadBadge type={projectType} />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -223,22 +223,19 @@ const LAYOUT_OPTIONS = [
|
||||
* 界面模式(全量 / 简洁)下拉切换。换肤旁并排,图标 + 下拉:
|
||||
* - 简洁模式 ChatPage 顶栏(全局顶栏被隐藏)。
|
||||
* - 全量模式 Header 右上角。
|
||||
* 用户未显式设置过界面模式时(mode === undefined),当前态按调用方所在的实际界面
|
||||
* 兜底——`fallback` 指明这一回退值(Header 传 "full",ChatPage 传 "compact")。
|
||||
* 当前态为有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认)——
|
||||
* useLayoutMode 总会返回具体值,不再有 undefined 兜底。
|
||||
*/
|
||||
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 current = mode ?? fallback;
|
||||
const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon;
|
||||
const TriggerIcon = mode === "full" ? PanelLeftIcon : LayoutIcon;
|
||||
|
||||
if (env.FORCE_COLLAPSED_SIDEBAR || isHideChromeActive()) return null;
|
||||
|
||||
@@ -262,7 +259,7 @@ export function LayoutModeSwitcher({
|
||||
<DropdownMenuSeparator />
|
||||
{LAYOUT_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const active = current === option.id;
|
||||
const active = mode === option.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ExternalLinkIcon,
|
||||
EyeIcon,
|
||||
FileType2Icon,
|
||||
GlobeIcon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
SaveIcon,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -56,8 +58,9 @@ import {
|
||||
} from "@/open-canvas/api/rewrite";
|
||||
import { publishArtifactToKnowledge } from "@/core/agents/api";
|
||||
import { useAgent } from "@/core/agents/hooks";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { copyTextToClipboard } from "@/core/clipboard";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { copyTextToClipboard } from "@/core/clipboard";
|
||||
import { siteStudioRoute, useWebAppCapability } from "@/core/sites/api";
|
||||
import {
|
||||
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
||||
DOCUMENT_REWRITE_STARTED_EVENT,
|
||||
@@ -85,7 +88,7 @@ import {
|
||||
import { isArtifactApiPath } from "@/core/artifacts/streaming-write-preview";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
|
||||
import {
|
||||
checkCodeFile,
|
||||
getFileName,
|
||||
@@ -93,6 +96,7 @@ import {
|
||||
} from "@/core/utils/files";
|
||||
import {
|
||||
downloadMermaidSvgFile,
|
||||
downloadSvgElementAsPng,
|
||||
isMermaidFile,
|
||||
openMermaidSvgInNewTab,
|
||||
renderMermaidToSvg,
|
||||
@@ -208,6 +212,22 @@ function findScrollableArtifactElement(root: HTMLElement | null) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别 Web App 站点清单:`outputs/<site>/site.json`(兼容 `write-file:` 虚拟 URL
|
||||
* 与查询串)→ 返回站点目录名;其它文件返回 null(按钮不渲染,零行为变化)。
|
||||
*/
|
||||
function extractSiteStudioTarget(filepath: string): string | null {
|
||||
const normalized = filepath.replace(/^write-file:/, "").split("?")[0];
|
||||
if (!/(^|\/)site\.json$/.test(normalized)) return null;
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
const idx = parts.lastIndexOf("site.json");
|
||||
if (idx <= 0) return null;
|
||||
const siteName = parts[idx - 1];
|
||||
if (!siteName || siteName === "outputs") return null;
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(siteName)) return null;
|
||||
return siteName;
|
||||
}
|
||||
|
||||
export function ArtifactFileDetail({
|
||||
className,
|
||||
filepath: filepathFromProps,
|
||||
@@ -267,6 +287,14 @@ export function ArtifactFileDetail({
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
const { isMock, thread, publicShareCode } = useThread();
|
||||
const navigate = useNavigate();
|
||||
// Web App 站点清单(outputs/<site>/site.json)→「站点预览」入口。
|
||||
// capability 不可用(未启用/未部署)时按钮不渲染,原 UI 一字不变。
|
||||
const siteStudioTarget = useMemo(
|
||||
() => extractSiteStudioTarget(filepathFromProps),
|
||||
[filepathFromProps],
|
||||
);
|
||||
const webAppCapabilityQuery = useWebAppCapability(!!siteStudioTarget);
|
||||
const isWriteFileVirtual = useMemo(() => {
|
||||
return filepathFromProps.startsWith("write-file:");
|
||||
}, [filepathFromProps]);
|
||||
@@ -433,6 +461,10 @@ export function ArtifactFileDetail({
|
||||
!useWriteFileStreaming &&
|
||||
content === undefined &&
|
||||
isArtifactContentLoading;
|
||||
// `write_file` 的 path 会先于正文抵达。它仍是正常的流式过程,不能显示成一块
|
||||
// 空白编辑器,更不能误导用户去点击重试真实文件;下一帧内容到达后会自然切换为代码视图。
|
||||
const streamingWriteContentPending =
|
||||
useWriteFileStreaming && writeFileInFlight && !content;
|
||||
const artifactContentUnavailable =
|
||||
isCodeFile &&
|
||||
!useWriteFileStreaming &&
|
||||
@@ -1422,7 +1454,9 @@ export function ArtifactFileDetail({
|
||||
toast.error("文件内容为空,暂时无法在新窗口打开");
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([prepareGeneratedHtml(source)], {
|
||||
const blob = new Blob([
|
||||
prepareGeneratedHtml(source),
|
||||
], {
|
||||
type: "text/html;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -1449,7 +1483,11 @@ export function ArtifactFileDetail({
|
||||
: language === "markdown"
|
||||
? "text/markdown;charset=utf-8"
|
||||
: "text/plain;charset=utf-8";
|
||||
const blob = new Blob([source], { type: blobType });
|
||||
const downloadSource =
|
||||
language === "html"
|
||||
? prepareGeneratedHtml(source)
|
||||
: source;
|
||||
const blob = new Blob([downloadSource], { type: blobType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
@@ -1642,6 +1680,16 @@ export function ArtifactFileDetail({
|
||||
onClick={handleOpenInNewWindow}
|
||||
/>
|
||||
)}
|
||||
{siteStudioTarget && webAppCapabilityQuery.data?.available && (
|
||||
<ArtifactAction
|
||||
icon={GlobeIcon}
|
||||
label="站点预览"
|
||||
tooltip="打开站点预览工作室(多页面站点 / 流程图 / 地图)"
|
||||
onClick={() =>
|
||||
navigate(siteStudioRoute(threadId, siteStudioTarget))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ArtifactAction
|
||||
icon={DownloadIcon}
|
||||
label={t.common.download}
|
||||
@@ -1766,10 +1814,10 @@ export function ArtifactFileDetail({
|
||||
activeCursor={isApplyingEdit}
|
||||
/>
|
||||
)}
|
||||
{artifactContentPending && viewMode === "code" && (
|
||||
{(artifactContentPending || streamingWriteContentPending) && viewMode === "code" && (
|
||||
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 text-sm">
|
||||
<LoaderIcon aria-hidden="true" className="size-5 animate-spin" />
|
||||
正在加载文档内容…
|
||||
{streamingWriteContentPending ? "正在接收文件内容…" : "正在加载文档内容…"}
|
||||
</div>
|
||||
)}
|
||||
{artifactContentUnavailable && viewMode === "code" && (
|
||||
@@ -1787,7 +1835,7 @@ export function ArtifactFileDetail({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isCodeFile && viewMode === "code" && !artifactContentPending && !artifactContentUnavailable && (
|
||||
{isCodeFile && viewMode === "code" && !artifactContentPending && !streamingWriteContentPending && !artifactContentUnavailable && (
|
||||
<div className="size-full">
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
@@ -2007,6 +2055,9 @@ export function ArtifactFilePreview({
|
||||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
||||
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
|
||||
const [mermaidError, setMermaidError] = useState<string | null>(null);
|
||||
const markdownPreviewRef = useRef<HTMLDivElement>(null);
|
||||
const mermaidChartRef = useRef<HTMLDivElement>(null);
|
||||
useStreamdownMermaidPngDownload(markdownPreviewRef);
|
||||
const researchSourcesQuery = useResearchSources(
|
||||
language === "markdown" ? (researchSessionId ?? null) : null,
|
||||
);
|
||||
@@ -2086,7 +2137,9 @@ export function ArtifactFilePreview({
|
||||
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
|
||||
// Intercept in-page anchor clicks so they scroll within the iframe instead
|
||||
// of navigating the sandboxed blob: document (which blanks the frame).
|
||||
const blob = new Blob([prepareGeneratedHtml(content ?? "")], {
|
||||
const blob = new Blob([
|
||||
prepareGeneratedHtml(content ?? ""),
|
||||
], {
|
||||
type: "text/html;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -2131,6 +2184,7 @@ export function ArtifactFilePreview({
|
||||
if (language === "markdown") {
|
||||
return (
|
||||
<div
|
||||
ref={markdownPreviewRef}
|
||||
data-artifact-preview-scroll
|
||||
data-markdown-anchor-root
|
||||
className={cn(
|
||||
@@ -2195,10 +2249,42 @@ export function ArtifactFilePreview({
|
||||
)}
|
||||
>
|
||||
{mermaidSvg ? (
|
||||
<div
|
||||
className="mx-auto flex min-h-full items-start justify-center rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"
|
||||
dangerouslySetInnerHTML={{ __html: mermaidSvg }}
|
||||
/>
|
||||
<div className="relative mx-auto flex min-h-full items-start justify-center rounded-2xl border border-slate-200 bg-white p-4 pt-12 shadow-sm">
|
||||
<div className="absolute right-3 top-3 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground rounded-md p-1"
|
||||
title="下载图片"
|
||||
onClick={() => {
|
||||
const svg = mermaidChartRef.current?.querySelector("svg");
|
||||
if (!(svg instanceof SVGSVGElement)) {
|
||||
toast.error("流程图尚未渲染完成,请稍后再下载");
|
||||
return;
|
||||
}
|
||||
void downloadSvgElementAsPng(svg, "flowchart.png").catch((error) => {
|
||||
console.error("Failed to download mermaid image:", error);
|
||||
toast.error(error instanceof Error ? error.message : "下载流程图失败");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground rounded-md p-1"
|
||||
title="新窗口打开"
|
||||
onClick={() => {
|
||||
void openMermaidSvgInNewTab(content ?? "").catch((error) => {
|
||||
console.error("Failed to open mermaid preview:", error);
|
||||
toast.error(error instanceof Error ? error.message : "打开流程图失败");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div ref={mermaidChartRef} dangerouslySetInnerHTML={{ __html: mermaidSvg }} />
|
||||
</div>
|
||||
) : mermaidError ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getFileExtensionDisplayName,
|
||||
getFileIcon,
|
||||
getFileName,
|
||||
isWordDocumentFile,
|
||||
} from "@/core/utils/files";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -93,12 +94,12 @@ export function ArtifactFileList({
|
||||
|
||||
return (
|
||||
<ul className={cn("flex w-full flex-col gap-4", className)}>
|
||||
{files.map((file) => (
|
||||
<Card
|
||||
key={file}
|
||||
className="relative cursor-pointer p-3"
|
||||
onClick={() => handleClick(file)}
|
||||
>
|
||||
{files.map((file) => (
|
||||
<Card
|
||||
key={file}
|
||||
className={cn("relative p-3", !isWordDocumentFile(file) && "cursor-pointer")}
|
||||
onClick={isWordDocumentFile(file) ? undefined : () => handleClick(file)}
|
||||
>
|
||||
<CardHeader className="grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 pr-2 pl-1">
|
||||
<CardTitle className="relative min-w-0 pl-8 leading-tight [overflow-wrap:anywhere] break-words">
|
||||
<div className="min-w-0">{getFileName(file)}</div>
|
||||
@@ -133,7 +134,7 @@ export function ArtifactFileList({
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Card>
|
||||
))}
|
||||
<SkillInstallDialog
|
||||
open={!!installFile}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
@@ -15,14 +15,19 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import {
|
||||
artifactPathname,
|
||||
listThreadArtifacts,
|
||||
mergeArtifactPaths,
|
||||
} from "@/core/artifacts/listing";
|
||||
import {
|
||||
latestInFlightWriteFilePreview,
|
||||
latestPreparedHtmlArtifact,
|
||||
} 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 { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||
import {
|
||||
@@ -33,8 +38,10 @@ import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { isWordDocumentFile } from "@/core/utils/files";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
||||
|
||||
import {
|
||||
ArtifactFileDetail,
|
||||
@@ -81,10 +88,12 @@ const ChatBox: React.FC<{
|
||||
children: React.ReactNode;
|
||||
threadId: string;
|
||||
disableArtifacts?: boolean;
|
||||
writingMode?: boolean;
|
||||
}> = ({
|
||||
children,
|
||||
threadId,
|
||||
disableArtifacts = false,
|
||||
writingMode,
|
||||
}) => {
|
||||
const {
|
||||
isMock,
|
||||
@@ -92,18 +101,29 @@ const ChatBox: React.FC<{
|
||||
knowledgeReferencesEnabled = false,
|
||||
} = 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);
|
||||
const manualReferenceBatchRef = useRef(false);
|
||||
const referenceDebugSignatureRef = useRef<string>("");
|
||||
const referenceBatchesRef = useRef<BackendReferenceBatch[]>([]);
|
||||
const [sideTab, setSideTab] = useState("document");
|
||||
const [referencesOpen, setReferencesOpen] = useState(true);
|
||||
const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null);
|
||||
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||
const [wikiCitationSource, setWikiCitationSource] = useState<BackendReferenceSource | null>(null);
|
||||
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||
const [settings] = useLocalSettings();
|
||||
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
|
||||
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
|
||||
const writingModeEnabled = writingMode ?? !!settings.context.writing_mode;
|
||||
const artifactDirectoryModeEnabled =
|
||||
settings.display.artifactDirectoryModeEnabled ?? false;
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
@@ -118,9 +138,8 @@ const ChatBox: React.FC<{
|
||||
// batches through DeerFlow's backend APIs.
|
||||
const hasFrontendRag = useMemo(
|
||||
() =>
|
||||
(thread.values.messages ?? []).some(
|
||||
(message) =>
|
||||
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
|
||||
(thread.values.messages ?? []).some((message) =>
|
||||
isSingleRoundRagMessage(message),
|
||||
),
|
||||
[thread.values.messages],
|
||||
);
|
||||
@@ -130,7 +149,7 @@ const ChatBox: React.FC<{
|
||||
const { skills } = useSkills();
|
||||
|
||||
const canScanArtifactDirectory =
|
||||
artifactDirectoryModeEnabled &&
|
||||
(artifactDirectoryModeEnabled || !!requestedArtifact) &&
|
||||
!disableArtifacts &&
|
||||
!isMock &&
|
||||
!!threadId &&
|
||||
@@ -148,17 +167,21 @@ const ChatBox: React.FC<{
|
||||
);
|
||||
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
|
||||
const availableArtifacts = useMemo(
|
||||
() =>
|
||||
artifactDirectoryModeEnabled
|
||||
? mergeArtifactPaths(
|
||||
checkpointArtifacts,
|
||||
(artifactListingQuery.data?.files ?? []).map((file) => file.path),
|
||||
)
|
||||
: checkpointArtifacts,
|
||||
() => {
|
||||
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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -184,6 +207,7 @@ const ChatBox: React.FC<{
|
||||
deselect,
|
||||
selectedArtifact,
|
||||
selectedArtifactKey,
|
||||
openStreamedArtifact,
|
||||
} = useArtifacts();
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
@@ -201,6 +225,29 @@ const ChatBox: React.FC<{
|
||||
thread.isLoading,
|
||||
);
|
||||
}, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
||||
useEffect(() => {
|
||||
if (
|
||||
disableArtifacts ||
|
||||
!requestedArtifact ||
|
||||
!threadId ||
|
||||
threadId === "new"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const requestKey = `${threadId}:${artifactPathname(requestedArtifact)}`;
|
||||
if (openedRequestedArtifactRef.current === requestKey) return;
|
||||
openedRequestedArtifactRef.current = requestKey;
|
||||
selectArtifact(requestedArtifact);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
}, [
|
||||
disableArtifacts,
|
||||
requestedArtifact,
|
||||
selectArtifact,
|
||||
setArtifactsOpen,
|
||||
threadId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldFetchBackendReferenceBatches) {
|
||||
setBackendReferenceBatches([]);
|
||||
@@ -313,6 +360,9 @@ const ChatBox: React.FC<{
|
||||
latestReferenceBatch;
|
||||
const referenceSources = activeReferenceBatch?.sources ?? [];
|
||||
const hasCitationReferences = referenceSources.length > 0;
|
||||
useEffect(() => {
|
||||
referenceBatchesRef.current = referenceBatches;
|
||||
}, [referenceBatches]);
|
||||
useEffect(() => {
|
||||
if (!shouldFetchBackendReferenceBatches || !activeReferenceBatch) {
|
||||
return;
|
||||
@@ -358,6 +408,16 @@ const ChatBox: React.FC<{
|
||||
window.setTimeout(() => {
|
||||
setPinnedReferenceId(detail?.referenceId ?? null);
|
||||
}, 0);
|
||||
const referenceIndex = Number(detail?.referenceId);
|
||||
const batch = referenceBatchesRef.current.find((item) => item.id === detail?.batchId)
|
||||
?? referenceBatchesRef.current.at(-1);
|
||||
const source = Number.isInteger(referenceIndex) && referenceIndex > 0
|
||||
? batch?.sources.find((item) => item.index === referenceIndex)
|
||||
?? batch?.sources[referenceIndex - 1]
|
||||
: undefined;
|
||||
if (source?.skillName?.startsWith("llmwiki_") || source?.type?.startsWith("llmwiki_")) {
|
||||
setWikiCitationSource(source);
|
||||
}
|
||||
setReferencesOpen(true);
|
||||
setSideTab("references");
|
||||
};
|
||||
@@ -398,9 +458,12 @@ const ChatBox: React.FC<{
|
||||
((artifactDirectoryModeEnabled && threadChanged) ||
|
||||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
|
||||
) {
|
||||
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
const newest = nextArtifacts[nextArtifacts.length - 1]!;
|
||||
if (shouldAutoOpenWritingArtifact(newest, thread.messages, writingModeEnabled)) {
|
||||
selectArtifact(newest);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
}
|
||||
}
|
||||
|
||||
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
|
||||
@@ -418,7 +481,12 @@ const ChatBox: React.FC<{
|
||||
) {
|
||||
if (thread?.values?.artifacts?.length > 0) {
|
||||
setAutoSelectFirstArtifact(false);
|
||||
selectArtifact(thread.values.artifacts[0]!);
|
||||
const firstPreviewableArtifact = thread.values.artifacts.find(
|
||||
(artifact) => !isWordDocumentFile(artifact),
|
||||
);
|
||||
if (firstPreviewableArtifact) {
|
||||
selectArtifact(firstPreviewableArtifact);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -432,8 +500,49 @@ const ChatBox: React.FC<{
|
||||
setArtifacts,
|
||||
availableArtifacts,
|
||||
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]);
|
||||
|
||||
// page_report_template(prepare) creates the output HTML before content
|
||||
// editing starts. Open that real artifact immediately; this also covers
|
||||
// models that use str_replace or whose write_file arguments arrive at once.
|
||||
const preparedHtmlPreview = useMemo(
|
||||
() =>
|
||||
disableArtifacts || !thread.isLoading
|
||||
? null
|
||||
: latestPreparedHtmlArtifact(thread.messages ?? []),
|
||||
[disableArtifacts, thread.isLoading, thread.messages],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!preparedHtmlPreview) return;
|
||||
openStreamedArtifact(
|
||||
preparedHtmlPreview.artifactPath,
|
||||
preparedHtmlPreview.previewKey,
|
||||
);
|
||||
setSideTab("document");
|
||||
}, [openStreamedArtifact, preparedHtmlPreview]);
|
||||
|
||||
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
|
||||
useEffect(() => {
|
||||
const wasEnabled = directoryModeWasEnabledRef.current;
|
||||
@@ -441,7 +550,8 @@ const ChatBox: React.FC<{
|
||||
if (
|
||||
wasEnabled ||
|
||||
!artifactDirectoryModeEnabled ||
|
||||
availableArtifacts.length === 0
|
||||
availableArtifacts.length === 0 ||
|
||||
isWordDocumentFile(availableArtifacts[availableArtifacts.length - 1]!)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -681,6 +791,17 @@ const ChatBox: React.FC<{
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<WeKnoraSourceDrawer
|
||||
open={wikiCitationSource !== null}
|
||||
target={null}
|
||||
title={wikiCitationSource?.title || "Wiki 文档"}
|
||||
quote={wikiCitationSource?.content || wikiCitationSource?.snippet || ""}
|
||||
inlineContent={wikiCitationSource?.content || wikiCitationSource?.snippet || ""}
|
||||
inlineKnowledgeBaseId={String(wikiCitationSource?.raw?.knowledge_base_id || "")}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setWikiCitationSource(null);
|
||||
}}
|
||||
/>
|
||||
</ReferenceBatchesContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -693,19 +814,23 @@ function ReferencePanel({
|
||||
sources: Parameters<typeof SkillDisplaySources>[0]["sources"];
|
||||
}) {
|
||||
const orderedSources = useMemo(() => {
|
||||
if (!highlightedReferenceId) {
|
||||
return sources;
|
||||
}
|
||||
const index = Number(highlightedReferenceId);
|
||||
if (!Number.isFinite(index)) {
|
||||
return sources;
|
||||
}
|
||||
const selected = sources.find((source) => source.index === index);
|
||||
if (!selected) {
|
||||
return sources;
|
||||
}
|
||||
return [selected, ...sources.filter((source) => source.index !== index)];
|
||||
}, [highlightedReferenceId, sources]);
|
||||
// This is presentation-only. Keep every source's original `index`, since
|
||||
// inline citations resolve through that index (for example [3] must still
|
||||
// open the third source returned by retrieval even when its card is shown
|
||||
// below a higher-scoring result).
|
||||
return sources
|
||||
.map((source, originalOrder) => ({ source, originalOrder }))
|
||||
.sort((left, right) => {
|
||||
const leftScore = typeof left.source.score === "number" && Number.isFinite(left.source.score)
|
||||
? left.source.score
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
const rightScore = typeof right.source.score === "number" && Number.isFinite(right.source.score)
|
||||
? right.source.score
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
return rightScore - leftScore || left.originalOrder - right.originalOrder;
|
||||
})
|
||||
.map(({ source }) => source);
|
||||
}, [sources]);
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col overflow-hidden">
|
||||
@@ -942,8 +1067,7 @@ function buildTurnReferenceBatches(
|
||||
// GeneralQA's single-RAG stream sends `docs` before the answer. Make
|
||||
// those cards available immediately, while keeping normal streaming turns
|
||||
// on their existing "wait for an answer" behavior.
|
||||
const isFrontendRagTurn =
|
||||
isSingleRoundRagMessage(assistant) || isMultiRoundRagMessage(assistant);
|
||||
const isFrontendRagTurn = isSingleRoundRagMessage(assistant);
|
||||
if (nextHumanIndex === -1 && isLoading && !isFrontendRagTurn) {
|
||||
continue;
|
||||
}
|
||||
@@ -994,7 +1118,7 @@ function findFrontendRagAssistant(
|
||||
const message = messages[index];
|
||||
if (
|
||||
message?.type === "ai" &&
|
||||
(isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message))
|
||||
isSingleRoundRagMessage(message)
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
|
||||
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
|
||||
import { useOptionalPromptInputController } from "@/components/ai-elements/prompt-input";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
/**
|
||||
@@ -11,7 +11,11 @@ export function useSpecificChatMode() {
|
||||
const { t } = useI18n();
|
||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const promptInputController = usePromptInputController();
|
||||
// A regular history item can navigate here while Vite Fast Refresh is
|
||||
// reconciling the previous page tree. In that short window the page may see
|
||||
// a stale provider context. This hook only pre-fills the skill-mode draft,
|
||||
// so it must not make an ordinary chat page fail to render.
|
||||
const promptInputController = useOptionalPromptInputController();
|
||||
const inputInitialValue = useMemo(() => {
|
||||
if (threadIdFromPath !== "new" || searchParams.get("mode") !== "skill") {
|
||||
return undefined;
|
||||
@@ -19,16 +23,21 @@ export function useSpecificChatMode() {
|
||||
return t.inputBox.createSkillPrompt;
|
||||
}, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]);
|
||||
const lastInitialValueRef = useRef<string | undefined>(undefined);
|
||||
const setInputRef = useRef(promptInputController.textInput.setInput);
|
||||
setInputRef.current = promptInputController.textInput.setInput;
|
||||
const setInputRef = useRef<((value: string) => void) | undefined>(
|
||||
promptInputController?.textInput.setInput,
|
||||
);
|
||||
setInputRef.current = promptInputController?.textInput.setInput;
|
||||
useEffect(() => {
|
||||
if (
|
||||
inputInitialValue &&
|
||||
inputInitialValue !== lastInitialValueRef.current
|
||||
) {
|
||||
lastInitialValueRef.current = inputInitialValue;
|
||||
setTimeout(() => {
|
||||
setInputRef.current(inputInitialValue);
|
||||
setTimeout(() => {
|
||||
const setInput = setInputRef.current;
|
||||
if (!setInput) return;
|
||||
|
||||
setInput(inputInitialValue);
|
||||
const textarea = document.querySelector("textarea");
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
|
||||
@@ -19,17 +19,16 @@ const TOOLBOX_ICON_TONES = [
|
||||
* 简洁模式右侧工具箱。
|
||||
*
|
||||
* 菜单来源与左侧简洁菜单、设置和更多完全一致,由管理员在菜单管理中把项目
|
||||
* 放到 ``toolbox`` 泳道。点击右侧竖排按钮后展开单列竖向抽屉。
|
||||
* 放到 ``toolbox`` 泳道。默认展开常显(不再藏进右侧按钮后面);点 X 或 Esc
|
||||
* 收起后右侧出现竖排按钮,可再次展开。
|
||||
*/
|
||||
export function CompactToolbox() {
|
||||
const { toolboxItems } = useWorkspaceCompactNavItems();
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(true);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => setOpen(false), [pathname]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
@@ -41,7 +40,6 @@ export function CompactToolbox() {
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
closeRef.current?.focus();
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
@@ -73,22 +71,8 @@ export function CompactToolbox() {
|
||||
<span className="text-xs font-semibold leading-4">工具箱</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="关闭工具箱抽屉"
|
||||
aria-hidden={!open}
|
||||
onClick={closeDrawer}
|
||||
className={cn(
|
||||
"fixed inset-0 z-[90] bg-black/20 transition-opacity duration-200",
|
||||
open ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0",
|
||||
)}
|
||||
/>
|
||||
|
||||
<aside
|
||||
id="compact-toolbox-panel"
|
||||
role="dialog"
|
||||
aria-modal={open}
|
||||
aria-label="工具箱"
|
||||
aria-hidden={!open}
|
||||
inert={!open}
|
||||
@@ -102,10 +86,9 @@ export function CompactToolbox() {
|
||||
<header className="relative flex h-12 shrink-0 items-center justify-center px-2">
|
||||
<h2 className="text-sm font-semibold">工具箱</h2>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
aria-label="关闭工具箱"
|
||||
title="关闭"
|
||||
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"
|
||||
>
|
||||
@@ -131,10 +114,7 @@ export function CompactToolbox() {
|
||||
? "bg-primary/12 text-primary ring-1 ring-inset ring-primary/25"
|
||||
: "bg-muted/55 text-card-foreground hover:bg-primary/8 hover:text-primary",
|
||||
)}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
navigate(item.path ?? "/page/workspace/chats/new");
|
||||
}}
|
||||
onClick={() => navigate(item.path ?? "/page/workspace/chats/new")}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* 共享的 WorkspaceNavChatList / FrequentAgentsList / RecentChatList 里塞会越来越难
|
||||
* 维护,故单独成一个组件,只在 `/page/workspace/chats*` 的全量布局下挂载,互不影响。
|
||||
*
|
||||
* 三块结构(自上而下):
|
||||
* 可挂在 workspace / strategy 两个普通问答路由下;导航目标由 RouteBaseProvider
|
||||
* 决定。三块结构(自上而下):
|
||||
* 1. 「新对话」胶囊按钮 + 收起整列的圆形按钮;
|
||||
* 2. 「常用智能体」—— 彩色图标卡(名称 + 两行简介,命中为选中态:主色描边 + 右侧主色条);
|
||||
* 3. 「最近的对话」—— 计数徽标 + 折叠、历史搜索框、会话列表(悬浮「更多」菜单:
|
||||
@@ -28,13 +29,14 @@ import {
|
||||
PinOff,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Share2,
|
||||
Star,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -59,7 +61,12 @@ 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 { getAccountDisplayName } from "@/core/auth";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
@@ -74,6 +81,7 @@ import {
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||
import {
|
||||
isBootstrapThread,
|
||||
isHiddenFromRecentChats,
|
||||
@@ -85,6 +93,14 @@ import {
|
||||
titleOfThread,
|
||||
} from "@/core/threads/utils";
|
||||
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import {
|
||||
usePublishSquares,
|
||||
useUpdateWxCommonAgents,
|
||||
useWxCommonAgents,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import { SENTIMENT_AGENT_ID, isSentimentAgentVisible } from "@/core/sentiment-agent/config";
|
||||
import { readSentimentAgentProfile } from "@/core/sentiment-agent/profile";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
@@ -93,11 +109,6 @@ 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;
|
||||
|
||||
@@ -110,38 +121,151 @@ function SectionLabel({
|
||||
count,
|
||||
collapsed,
|
||||
onToggle,
|
||||
onOpenSettings,
|
||||
}: {
|
||||
label: string;
|
||||
count?: number;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span>{label}</span>
|
||||
{count != null && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 transition-transform duration-200",
|
||||
collapsed && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex w-full items-center px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex min-w-0 flex-1 items-center justify-between transition-colors hover:text-muted-foreground select-none"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate">{label}</span>
|
||||
{count != null && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 transition-transform duration-200",
|
||||
collapsed && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
className="ml-1 inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground"
|
||||
aria-label="设置智能体类型"
|
||||
title="设置智能体类型"
|
||||
>
|
||||
<Settings className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GeneralQaSidebar() {
|
||||
/** Gallery category values persisted for the dedicated wx full-chat route. */
|
||||
const WX_AGENT_TYPE_MINE = "mine";
|
||||
const WX_AGENT_TYPE_SENTIMENT = "sentiment";
|
||||
const WX_AGENT_TYPE_SQUARE_PREFIX = "square:";
|
||||
const WX_AGENT_TYPE_DEFAULT = "__default__";
|
||||
|
||||
type CommonAgent = Pick<Agent, "id" | "name" | "description">;
|
||||
|
||||
type WxAgentTypeOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function wxSquareAgentType(squareId: string) {
|
||||
return `${WX_AGENT_TYPE_SQUARE_PREFIX}${squareId}`;
|
||||
}
|
||||
|
||||
function wxSquareIdOf(agentType: string): string | undefined {
|
||||
return agentType.startsWith(WX_AGENT_TYPE_SQUARE_PREFIX)
|
||||
? agentType.slice(WX_AGENT_TYPE_SQUARE_PREFIX.length)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function WxAgentTypeSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
agentType,
|
||||
options,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
agentType: string | null;
|
||||
options: WxAgentTypeOption[];
|
||||
}) {
|
||||
const [selectedType, setSelectedType] = useState(
|
||||
agentType ?? WX_AGENT_TYPE_DEFAULT,
|
||||
);
|
||||
const { mutateAsync: updateWxCommonAgents, isPending } =
|
||||
useUpdateWxCommonAgents();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setSelectedType(agentType ?? WX_AGENT_TYPE_DEFAULT);
|
||||
}, [agentType, open]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await updateWxCommonAgents(
|
||||
selectedType === WX_AGENT_TYPE_DEFAULT ? null : selectedType,
|
||||
);
|
||||
toast.success("常用智能体类型已更新");
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存智能体类型失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>设置常用智能体类型</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<label htmlFor="wx-common-agent-type" className="text-sm font-medium">
|
||||
智能体类型
|
||||
</label>
|
||||
<select
|
||||
id="wx-common-agent-type"
|
||||
value={selectedType}
|
||||
onChange={(event) => setSelectedType(event.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isPending}>
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function GeneralQaSidebar({
|
||||
wxCommonAgents,
|
||||
}: {
|
||||
/** Enables wx's shared Agent Gallery category selection. */
|
||||
wxCommonAgents?: boolean;
|
||||
} = {}) {
|
||||
const { t } = useI18n();
|
||||
const routeBase = useRouteBase();
|
||||
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
@@ -160,7 +284,60 @@ export function GeneralQaSidebar() {
|
||||
setAgentsCollapsed((v) => {
|
||||
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
|
||||
return !v;
|
||||
});
|
||||
});
|
||||
|
||||
const [agentTypeSettingsOpen, setAgentTypeSettingsOpen] = useState(false);
|
||||
const { data: wxCommonAgentSettings } = useWxCommonAgents({
|
||||
enabled: !!wxCommonAgents,
|
||||
});
|
||||
const { data: publishSquares } = usePublishSquares({
|
||||
enabled: !!wxCommonAgents,
|
||||
});
|
||||
const isWxManager =
|
||||
wxCommonAgents &&
|
||||
getAccountDisplayName().toLowerCase() === "wx";
|
||||
const sentimentVisible = isSentimentAgentVisible();
|
||||
const wxCategoryOptions = useMemo<WxAgentTypeOption[]>(() => {
|
||||
const options: WxAgentTypeOption[] = [
|
||||
{ value: WX_AGENT_TYPE_MINE, label: "我的" },
|
||||
...(publishSquares?.squares ?? []).map((square) => ({
|
||||
value: wxSquareAgentType(square.id),
|
||||
label: square.name,
|
||||
})),
|
||||
];
|
||||
if (sentimentVisible) {
|
||||
options.push({ value: WX_AGENT_TYPE_SENTIMENT, label: "舆情分析" });
|
||||
}
|
||||
return options;
|
||||
}, [publishSquares?.squares, sentimentVisible]);
|
||||
const wxAgentTypeOptions = useMemo<WxAgentTypeOption[]>(
|
||||
() => [
|
||||
{ value: WX_AGENT_TYPE_DEFAULT, label: "默认推荐(未设置)" },
|
||||
...wxCategoryOptions,
|
||||
],
|
||||
[wxCategoryOptions],
|
||||
);
|
||||
const configuredWxAgentType = wxCommonAgentSettings?.agent_type ?? null;
|
||||
const wxUsesCategoryFilter = !!configuredWxAgentType && wxCategoryOptions.some(
|
||||
(option) => option.value === configuredWxAgentType,
|
||||
);
|
||||
const wxSquareId =
|
||||
wxCommonAgents && wxUsesCategoryFilter && configuredWxAgentType
|
||||
? wxSquareIdOf(configuredWxAgentType)
|
||||
: undefined;
|
||||
const wxShowsSentiment =
|
||||
wxCommonAgents &&
|
||||
wxUsesCategoryFilter &&
|
||||
configuredWxAgentType === WX_AGENT_TYPE_SENTIMENT;
|
||||
const [sentimentProfile, setSentimentProfile] = useState(() =>
|
||||
readSentimentAgentProfile(),
|
||||
);
|
||||
useEffect(() => {
|
||||
const refresh = () => setSentimentProfile(readSentimentAgentProfile());
|
||||
window.addEventListener("sentiment-agent-profile-changed", refresh);
|
||||
return () =>
|
||||
window.removeEventListener("sentiment-agent-profile-changed", refresh);
|
||||
}, []);
|
||||
const toggleChats = () =>
|
||||
setChatsCollapsed((v) => {
|
||||
localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v));
|
||||
@@ -168,9 +345,38 @@ export function GeneralQaSidebar() {
|
||||
});
|
||||
|
||||
// ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)──
|
||||
const { agents: mine } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
|
||||
const { agents: square } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
|
||||
const frequent = useMemo<Agent[]>(() => {
|
||||
const { agents: mine } = useAgentsPaginated({
|
||||
scope: "mine",
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
enabled: !wxUsesCategoryFilter,
|
||||
});
|
||||
const { agents: square } = useAgentsPaginated({
|
||||
scope: "square",
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
enabled: !wxUsesCategoryFilter,
|
||||
});
|
||||
const { agents: wxAgents } = useAgentsPaginated({
|
||||
scope: wxSquareId ? "square" : "mine",
|
||||
page: 1,
|
||||
pageSize: MAX_FREQUENT,
|
||||
square: wxSquareId,
|
||||
enabled: wxUsesCategoryFilter && !wxShowsSentiment,
|
||||
});
|
||||
const frequent = useMemo<CommonAgent[]>(() => {
|
||||
if (wxUsesCategoryFilter) {
|
||||
if (wxShowsSentiment) {
|
||||
return [
|
||||
{
|
||||
id: SENTIMENT_AGENT_ID,
|
||||
name: sentimentProfile.name,
|
||||
description: sentimentProfile.description,
|
||||
},
|
||||
];
|
||||
}
|
||||
return wxAgents.slice(0, MAX_FREQUENT);
|
||||
}
|
||||
const out: Agent[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (agent: Agent | undefined) => {
|
||||
@@ -182,36 +388,49 @@ export function GeneralQaSidebar() {
|
||||
mine.forEach(add);
|
||||
square.forEach(add);
|
||||
return out;
|
||||
}, [mine, square]);
|
||||
}, [mine, sentimentProfile, square, wxAgents, wxShowsSentiment, wxUsesCategoryFilter]);
|
||||
|
||||
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
|
||||
const searchParams = useSearchParams();
|
||||
const activeAgentId = searchParams.get("agent");
|
||||
const currentSearch = searchParams.toString();
|
||||
const newChatHref = buildFullChatNavigationPath(NEW_CHAT_PATH, currentSearch);
|
||||
const moreAgentsHref = buildFullChatNavigationPath(MORE_AGENTS_HREF, currentSearch);
|
||||
const moreChatsHref = buildFullChatNavigationPath(MORE_CHATS_HREF, currentSearch);
|
||||
const chatsBasePath = `${routeBase}/chats`;
|
||||
const newChatPath = `${chatsBasePath}/new`;
|
||||
const newChatHref = buildFullChatNavigationPath(newChatPath, currentSearch);
|
||||
const moreAgentsHref = buildFullChatNavigationPath(
|
||||
`${routeBase}/agents`,
|
||||
currentSearch,
|
||||
);
|
||||
const moreChatsHref = buildFullChatNavigationPath(chatsBasePath, currentSearch);
|
||||
|
||||
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
|
||||
// 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。
|
||||
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
|
||||
const handleSelectAgent = useCallback(
|
||||
(agentId: string) => {
|
||||
if (agentId === SENTIMENT_AGENT_ID) {
|
||||
navigate(
|
||||
buildFullChatNavigationPath(
|
||||
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats/new`,
|
||||
currentSearch,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
navigate(
|
||||
buildFullChatNavigationPath(
|
||||
`${NEW_CHAT_PATH}?agent=${encodeURIComponent(agentId)}`,
|
||||
routeBase === "/page/workspace" || wxCommonAgents
|
||||
? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
|
||||
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
|
||||
currentSearch,
|
||||
),
|
||||
);
|
||||
},
|
||||
[currentSearch, navigate],
|
||||
[currentSearch, navigate, newChatPath, routeBase, wxCommonAgents],
|
||||
);
|
||||
|
||||
// ── 最近的对话取数 + 搜索 ──
|
||||
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } = useParams<{
|
||||
thread_id: string;
|
||||
agent_id?: string;
|
||||
}>();
|
||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||
const { data: threads = [] } = useThreads();
|
||||
const { mutate: deleteThread } = useDeleteThread();
|
||||
const { mutate: renameThread } = useRenameThread();
|
||||
@@ -224,6 +443,14 @@ 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)会话也展示(带机器人图标、点击回创建页);
|
||||
@@ -254,21 +481,22 @@ export function GeneralQaSidebar() {
|
||||
deleteThread({ threadId });
|
||||
if (threadId === threadIdFromPath) {
|
||||
const threadIndex = threads.findIndex((th) => th.thread_id === threadId);
|
||||
let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath });
|
||||
let nextThreadPath = newChatPath;
|
||||
if (threadIndex > -1) {
|
||||
if (threads[threadIndex + 1]) {
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
|
||||
nextThreadPath = pathForThread(threads[threadIndex + 1]!);
|
||||
} else if (threads[threadIndex - 1]) {
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
|
||||
nextThreadPath = pathForThread(threads[threadIndex - 1]!);
|
||||
}
|
||||
}
|
||||
void router.push(buildFullChatNavigationPath(nextThreadPath, currentSearch));
|
||||
}
|
||||
},
|
||||
[
|
||||
agentIdFromPath,
|
||||
currentSearch,
|
||||
deleteThread,
|
||||
newChatPath,
|
||||
pathForThread,
|
||||
router,
|
||||
threadIdFromPath,
|
||||
threads,
|
||||
@@ -343,17 +571,24 @@ export function GeneralQaSidebar() {
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||||
{/* ── 常用智能体 ── */}
|
||||
{frequent.length > 0 && (
|
||||
{(frequent.length > 0 || isWxManager) && (
|
||||
<div className="pt-2">
|
||||
<SectionLabel
|
||||
label="常用智能体"
|
||||
count={wxUsesCategoryFilter ? frequent.length : undefined}
|
||||
collapsed={agentsCollapsed}
|
||||
onToggle={toggleAgents}
|
||||
onOpenSettings={isWxManager ? () => setAgentTypeSettingsOpen(true) : undefined}
|
||||
/>
|
||||
{!agentsCollapsed && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
{frequent.map((agent) => {
|
||||
const isActive = activeAgentId === agent.id;
|
||||
const isActive =
|
||||
activeAgentId === agent.id ||
|
||||
(agent.id === SENTIMENT_AGENT_ID &&
|
||||
pathname.startsWith(
|
||||
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats`,
|
||||
));
|
||||
const { from, to, foreground } = agentColor(agent.id, theme);
|
||||
return (
|
||||
<button
|
||||
@@ -467,11 +702,12 @@ export function GeneralQaSidebar() {
|
||||
) : (
|
||||
filteredThreads.map((thread) => {
|
||||
const href = buildFullChatNavigationPath(
|
||||
listPathOfThread(thread),
|
||||
pathForThread(thread),
|
||||
currentSearch,
|
||||
);
|
||||
const isActive = href === pathname;
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const projectType = myProjectConversationTypeOfThread(thread);
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
return (
|
||||
@@ -496,6 +732,8 @@ export function GeneralQaSidebar() {
|
||||
)}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : projectType ? (
|
||||
<MyProjectThreadIcon type={projectType} />
|
||||
) : (
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
@@ -507,9 +745,15 @@ export function GeneralQaSidebar() {
|
||||
{pinned && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
<span className="min-w-0 flex-1 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" />
|
||||
)}
|
||||
@@ -640,6 +884,14 @@ export function GeneralQaSidebar() {
|
||||
if (!o) setShareThreadId(null);
|
||||
}}
|
||||
/>
|
||||
{wxCommonAgents && isWxManager && (
|
||||
<WxAgentTypeSettingsDialog
|
||||
open={agentTypeSettingsOpen}
|
||||
onOpenChange={setAgentTypeSettingsOpen}
|
||||
agentType={wxUsesCategoryFilter ? configuredWxAgentType : null}
|
||||
options={wxAgentTypeOptions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
BrainCircuitIcon,
|
||||
BookMarkedIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
@@ -101,25 +100,10 @@ import {
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
|
||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||
import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
|
||||
function getResolvedMode(
|
||||
mode: InputMode | undefined,
|
||||
supportsThinking: boolean,
|
||||
): InputMode {
|
||||
if (!supportsThinking && mode !== "flash") {
|
||||
return "flash";
|
||||
}
|
||||
if (mode) {
|
||||
return mode;
|
||||
}
|
||||
return supportsThinking ? "pro" : "flash";
|
||||
}
|
||||
import { resolveInputMode, type InputMode } from "./input-mode";
|
||||
import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
export function InputBox({
|
||||
className,
|
||||
@@ -137,7 +121,8 @@ export function InputBox({
|
||||
onStop,
|
||||
promptPrefix,
|
||||
agentSelector,
|
||||
knowledgeSelector,
|
||||
knowledgeSelector,
|
||||
knowledgeSuggestedQuestions = [],
|
||||
extraModels = [],
|
||||
assistantId,
|
||||
hideAdminRecommendedQuestions,
|
||||
@@ -150,7 +135,7 @@ export function InputBox({
|
||||
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
|
||||
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
|
||||
compact?: boolean;
|
||||
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
|
||||
/** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等
|
||||
* 受控场景用;文件/模型等其他按钮不受影响)。 */
|
||||
hideContextTools?: boolean;
|
||||
disabled?: boolean;
|
||||
@@ -161,7 +146,6 @@ export function InputBox({
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
memory_injection_enabled?: boolean;
|
||||
};
|
||||
extraHeader?: React.ReactNode;
|
||||
isNewThread?: boolean;
|
||||
@@ -178,7 +162,6 @@ export function InputBox({
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
memory_injection_enabled?: boolean;
|
||||
},
|
||||
) => void;
|
||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||
@@ -203,8 +186,10 @@ export function InputBox({
|
||||
selectedAgentId: string | null;
|
||||
onAgentChange: (agentId: string | null) => void;
|
||||
};
|
||||
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
|
||||
knowledgeSelector?: ReactNode;
|
||||
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
|
||||
knowledgeSelector?: ReactNode;
|
||||
/** High-value starter questions derived from the currently selected spaces. */
|
||||
knowledgeSuggestedQuestions?: string[];
|
||||
/** Page-scoped model choices implemented outside the DeerFlow model API. */
|
||||
extraModels?: Model[];
|
||||
}) {
|
||||
@@ -241,8 +226,9 @@ export function InputBox({
|
||||
|
||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||
const userPreferences = useUserPreferences();
|
||||
const showAdminRecommendedQuestions =
|
||||
!hideAdminRecommendedQuestions &&
|
||||
const showAdminRecommendedQuestions =
|
||||
!hideAdminRecommendedQuestions &&
|
||||
knowledgeSuggestedQuestions.length === 0 &&
|
||||
!userPreferences.hide_admin_prompt_prefix &&
|
||||
recommendedQuestions.length > 0;
|
||||
|
||||
@@ -259,7 +245,7 @@ export function InputBox({
|
||||
const fallbackModel = currentModel ?? modelOptions[0]!;
|
||||
const supportsThinking = fallbackModel.supports_thinking ?? false;
|
||||
const nextModelName = fallbackModel.name;
|
||||
const nextMode = getResolvedMode(context.mode, supportsThinking);
|
||||
const nextMode = resolveInputMode(context.mode, supportsThinking);
|
||||
|
||||
if (
|
||||
context.model_name === nextModelName &&
|
||||
@@ -306,7 +292,7 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name,
|
||||
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
||||
mode: resolveInputMode(context.mode, model.supports_thinking ?? false),
|
||||
reasoning_effort: context.reasoning_effort,
|
||||
execution_profile: model.execution_profile,
|
||||
});
|
||||
@@ -322,7 +308,7 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: ordinaryModel?.name ?? context.model_name,
|
||||
mode: getResolvedMode(
|
||||
mode: resolveInputMode(
|
||||
mode,
|
||||
ordinaryModel?.supports_thinking ?? supportThinking,
|
||||
),
|
||||
@@ -351,7 +337,7 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: ragModel.name,
|
||||
mode: getResolvedMode(
|
||||
mode: resolveInputMode(
|
||||
context.mode,
|
||||
ragModel.supports_thinking ?? false,
|
||||
),
|
||||
@@ -391,14 +377,6 @@ export function InputBox({
|
||||
});
|
||||
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
||||
|
||||
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
||||
const handleMemoryInjectionToggle = useCallback(() => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
memory_injection_enabled: !memoryInjectionEnabled,
|
||||
});
|
||||
}, [context, memoryInjectionEnabled, onContextChange]);
|
||||
|
||||
const ragModeLocked = isThreadRagModeLocked(threadId);
|
||||
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
|
||||
const handleRagModeToggle = useCallback(() => {
|
||||
@@ -438,7 +416,7 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: resolvedModelName,
|
||||
mode: getResolvedMode(
|
||||
mode: resolveInputMode(
|
||||
context.mode,
|
||||
selectedModel?.supports_thinking ?? false,
|
||||
),
|
||||
@@ -610,13 +588,35 @@ export function InputBox({
|
||||
|
||||
return (
|
||||
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
||||
{extraHeader && (
|
||||
{extraHeader && (
|
||||
<div className="flex items-center justify-center">
|
||||
{extraHeader}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && knowledgeSuggestedQuestions.length > 0 && (
|
||||
<div className="px-4 mb-[10px]">
|
||||
<div className="mb-2 text-center text-xs text-muted-foreground">基于已选知识空间,试试这样问</div>
|
||||
<div className="flex flex-wrap justify-center gap-2.5">
|
||||
{knowledgeSuggestedQuestions.slice(0, 4).map((question) => (
|
||||
<button
|
||||
key={question}
|
||||
type="button"
|
||||
className="max-w-full rounded-full border border-primary/20 bg-primary/5 px-4 py-2 text-left text-sm text-foreground transition-colors hover:bg-primary/10"
|
||||
onClick={() => {
|
||||
textInput.setInput(question);
|
||||
setFollowupsHidden(true);
|
||||
setTimeout(() => requestFormSubmit(), 0);
|
||||
}}
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
||||
{recommendedQuestions.map((q) => (
|
||||
<button
|
||||
@@ -727,12 +727,12 @@ export function InputBox({
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter
|
||||
className={cn(
|
||||
"flex items-center border-t-0 px-2 pt-1 pb-1.5",
|
||||
"flex flex-wrap items-center gap-y-2 border-t-0 px-2 pt-1 pb-1.5",
|
||||
compact && "gap-2 px-3 pt-1.5 pb-3",
|
||||
)}
|
||||
>
|
||||
<PromptInputTools
|
||||
className={cn("gap-1.5", compact && "flex-1 flex-wrap gap-2")}
|
||||
className={cn("min-w-0 flex-1 flex-wrap gap-1.5", compact && "gap-2")}
|
||||
>
|
||||
{/* TODO: Add more connectors here
|
||||
<PromptInputActionMenu>
|
||||
@@ -752,7 +752,7 @@ export function InputBox({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!frontendRagActive && !hideContextTools && <Tooltip
|
||||
{!frontendRagActive && !hideContextTools && <Tooltip
|
||||
content={
|
||||
context.writing_mode
|
||||
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
|
||||
@@ -807,32 +807,6 @@ export function InputBox({
|
||||
<span className="text-xs font-normal">文件</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!frontendRagActive && !hideContextTools && <Tooltip
|
||||
content={
|
||||
memoryInjectionEnabled
|
||||
? "记忆注入已开启:对话前会注入历史记忆"
|
||||
: "记忆注入已关闭:本次对话不注入记忆"
|
||||
}
|
||||
>
|
||||
<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 && !frontendRagActive && !hideContextTools && <Tooltip
|
||||
content={
|
||||
ragModeEnabled
|
||||
@@ -1272,7 +1246,7 @@ export function InputBox({
|
||||
</PromptInputActionMenu>
|
||||
)}
|
||||
</PromptInputTools>
|
||||
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
||||
<PromptInputTools className={cn("ml-auto shrink-0 gap-1.5", compact && "gap-2")}>
|
||||
{!frontendRagActive && agentSelector && (
|
||||
<AgentSelector
|
||||
selectedAgentId={agentSelector.selectedAgentId}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// Node's type-stripping test runner requires the explicit TypeScript suffix.
|
||||
// @ts-expect-error The application compiler intentionally uses extensionless imports.
|
||||
import { resolveInputMode } from "./input-mode.ts";
|
||||
|
||||
test("keeps plan and subagent modes for models without native thinking", () => {
|
||||
assert.equal(resolveInputMode("pro", false), "pro");
|
||||
assert.equal(resolveInputMode("ultra", false), "ultra");
|
||||
});
|
||||
|
||||
test("falls back only when native thinking mode is unsupported", () => {
|
||||
assert.equal(resolveInputMode("thinking", false), "flash");
|
||||
assert.equal(resolveInputMode("thinking", true), "thinking");
|
||||
});
|
||||
|
||||
test("keeps the existing defaults when no mode has been chosen", () => {
|
||||
assert.equal(resolveInputMode(undefined, false), "flash");
|
||||
assert.equal(resolveInputMode(undefined, true), "pro");
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
/**
|
||||
* Keep plan/subagent modes available even when the selected model has no
|
||||
* native thinking switch. The backend already falls back to non-thinking for
|
||||
* that model, while `pro` and `ultra` still control planning and subagents.
|
||||
* Only the model-specific `thinking` mode requires `supports_thinking`.
|
||||
*/
|
||||
export function resolveInputMode(
|
||||
mode: InputMode | undefined,
|
||||
supportsThinking: boolean,
|
||||
): InputMode {
|
||||
if (mode === "thinking" && !supportsThinking) {
|
||||
return "flash";
|
||||
}
|
||||
if (mode) {
|
||||
return mode;
|
||||
}
|
||||
return supportsThinking ? "pro" : "flash";
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BookOpen, Database } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||
import {
|
||||
loadKnowledgeBaseProductVersion,
|
||||
loadOrdinaryKnowledgeLanding,
|
||||
saveOrdinaryKnowledgeLanding,
|
||||
type OrdinaryKnowledgeLanding,
|
||||
type KnowledgeBaseProductVersion,
|
||||
updateKnowledgeBaseProductVersion,
|
||||
} from "@/core/system-settings/api";
|
||||
|
||||
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
|
||||
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
|
||||
|
||||
export function useOrdinaryKnowledgeLanding() {
|
||||
return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 });
|
||||
}
|
||||
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
|
||||
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
|
||||
|
||||
export function useKnowledgeBaseProductVersion() {
|
||||
return useQuery({ queryKey: KNOWLEDGE_PRODUCT_KEY, queryFn: loadKnowledgeBaseProductVersion, staleTime: 60_000 });
|
||||
}
|
||||
|
||||
export function KnowledgeProductNav() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const client = useQueryClient();
|
||||
const version = useKnowledgeBaseProductVersion();
|
||||
const landing = useOrdinaryKnowledgeLanding();
|
||||
const saveLanding = useMutation({
|
||||
mutationFn: saveOrdinaryKnowledgeLanding,
|
||||
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const save = useMutation({
|
||||
mutationFn: updateKnowledgeBaseProductVersion,
|
||||
onSuccess: (value) => {
|
||||
client.setQueryData(KNOWLEDGE_PRODUCT_KEY, value);
|
||||
toast.success(`默认知识库体验已切换为 ${value}`);
|
||||
},
|
||||
});
|
||||
const assistant = location.pathname.includes("/knowledge/assistant");
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
|
||||
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||
<BookOpen />普通知识库
|
||||
</Button>
|
||||
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
||||
<Database />助手知识库
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
普通知识库默认页面
|
||||
<Select disabled={landing.isLoading || saveLanding.isPending} value={landing.data ?? "wiki"} onValueChange={(value) => saveLanding.mutate(value as OrdinaryKnowledgeLanding)}>
|
||||
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
|
||||
</Select>
|
||||
默认产品版本
|
||||
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
|
||||
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="v1">v1 普通知识库</SelectItem><SelectItem value="v2">v2 助手知识库</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
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,
|
||||
@@ -31,6 +35,27 @@ import {
|
||||
|
||||
type SelectorSource = "all" | "agent";
|
||||
type SelectorVariant = "popover" | "panel";
|
||||
const EMPTY_IDS: string[] = [];
|
||||
|
||||
export function buildKnowledgeSpaceQuestions(names: string[]): string[] {
|
||||
const cleanNames = names.map((name) => name.trim()).filter(Boolean);
|
||||
if (cleanNames.length === 0) return [];
|
||||
if (cleanNames.length === 1) {
|
||||
const name = cleanNames[0]!;
|
||||
return [
|
||||
`请概括「${name}」知识空间的主要内容、适用场景和资料边界。`,
|
||||
`「${name}」包含哪些核心主题?请按重要性说明并引用来源。`,
|
||||
`请梳理「${name}」中的关键实体、概念及它们之间的关系。`,
|
||||
`根据「${name}」,有哪些值得关注的问题、风险或行动建议?`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`请综合所选 ${cleanNames.length} 个知识空间,总结共同主题、差异和互补信息。`,
|
||||
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点,并引用各自来源。`,
|
||||
"请梳理所选知识空间中的关键实体、概念及跨库关系。",
|
||||
"所选知识空间中有哪些相互冲突或证据不足的结论?",
|
||||
];
|
||||
}
|
||||
|
||||
function groupRows(rows: LlmWikiKnowledgeBase[]) {
|
||||
return [
|
||||
@@ -66,10 +91,26 @@ function SpaceIcon({ item }: { item: LlmWikiKnowledgeBase }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantSpaceIcon({ item }: { item: AssistantKnowledgeBase }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-md",
|
||||
item.is_global ? "bg-amber-500/10 text-amber-600 dark:text-amber-400" : "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
)}
|
||||
>
|
||||
<Database className="size-3.5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionContent({
|
||||
rows,
|
||||
assistantRows,
|
||||
value,
|
||||
assistantValue,
|
||||
onChange,
|
||||
onAssistantChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
loading,
|
||||
@@ -79,8 +120,11 @@ function SelectionContent({
|
||||
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;
|
||||
@@ -90,6 +134,7 @@ function SelectionContent({
|
||||
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;
|
||||
@@ -97,11 +142,28 @@ function SelectionContent({
|
||||
`${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 (
|
||||
<>
|
||||
@@ -127,7 +189,7 @@ function SelectionContent({
|
||||
<div className="m-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
|
||||
{error instanceof Error ? error.message : "知识空间读取失败"}
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
) : !hasRows ? (
|
||||
<div className="flex h-28 items-center justify-center px-4 text-center text-xs text-muted-foreground">
|
||||
{search
|
||||
? "没有匹配的知识空间"
|
||||
@@ -136,91 +198,186 @@ function SelectionContent({
|
||||
: "当前账号暂无可用知识空间"}
|
||||
</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>
|
||||
<>
|
||||
{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>
|
||||
) : (
|
||||
<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>
|
||||
))
|
||||
<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 ? `本对话已选 ${value.length} 个` : `已选择 ${value.length} 个`}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
|
||||
</div>
|
||||
{selectedCount > 0 && (
|
||||
<div className="mt-0.5 text-[10px] text-primary">仅检索已选知识空间,不联网</div>
|
||||
)}
|
||||
</div>
|
||||
{readOnly ? (
|
||||
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange(filtered.map((item) => item.id))}>
|
||||
<Button
|
||||
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([])}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => {
|
||||
onChange([]);
|
||||
onAssistantChange([]);
|
||||
}}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
@@ -232,7 +389,10 @@ function SelectionContent({
|
||||
|
||||
export function LlmWikiKnowledgeSelector({
|
||||
value,
|
||||
assistantValue = EMPTY_IDS,
|
||||
onChange,
|
||||
onAssistantChange = () => undefined,
|
||||
onSuggestedQuestionsChange,
|
||||
source = "all",
|
||||
agentId,
|
||||
autoSelectAll = false,
|
||||
@@ -240,10 +400,15 @@ export function LlmWikiKnowledgeSelector({
|
||||
readOnly = false,
|
||||
className,
|
||||
label = "知识空间",
|
||||
panelTitle = "绑定知识空间",
|
||||
panelDescription = "新建该智能体对话时,只能从已绑定空间中选择本次检索范围。",
|
||||
variant = "popover",
|
||||
}: {
|
||||
value: string[];
|
||||
assistantValue?: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
onAssistantChange?: (value: string[]) => void;
|
||||
onSuggestedQuestionsChange?: (questions: string[]) => void;
|
||||
source?: SelectorSource;
|
||||
agentId?: string | null;
|
||||
autoSelectAll?: boolean;
|
||||
@@ -252,33 +417,79 @@ export function LlmWikiKnowledgeSelector({
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
label?: string;
|
||||
panelTitle?: string;
|
||||
panelDescription?: string;
|
||||
variant?: SelectorVariant;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const weknoraEnabled = runtime.data?.provider === "weknora";
|
||||
const shouldLoadOptions =
|
||||
variant === "panel" ||
|
||||
open ||
|
||||
autoSelectAll ||
|
||||
value.length > 0 ||
|
||||
assistantValue.length > 0;
|
||||
const all = useLlmWikiKnowledgeBases(
|
||||
"all",
|
||||
runtime.data?.provider === "weknora",
|
||||
weknoraEnabled && shouldLoadOptions,
|
||||
);
|
||||
const agent = useAgentLlmWikiKnowledgeBases(
|
||||
agentId,
|
||||
runtime.data?.provider === "weknora" && source === "agent" && !!agentId,
|
||||
weknoraEnabled && shouldLoadOptions && source === "agent" && !!agentId,
|
||||
);
|
||||
const rows = all.data ?? [];
|
||||
const assistantBases = useAssistantKnowledgeBases(
|
||||
shouldLoadOptions ||
|
||||
runtime.isError ||
|
||||
(!runtime.isLoading && !weknoraEnabled),
|
||||
);
|
||||
// Stable empty results are essential: suggested questions are reported to
|
||||
// the parent, so a fresh [] on every render creates an effect/update loop
|
||||
// before lazy knowledge queries have loaded (or when they are disabled).
|
||||
const rows = useMemo(() => weknoraEnabled ? (all.data ?? []) : [], [all.data, weknoraEnabled]);
|
||||
const assistantRows = useMemo(() => assistantBases.data ?? [], [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 = all.isLoading || (source === "agent" && !!agentId && agent.isLoading);
|
||||
const error = all.error ?? (source === "agent" ? agent.error : null);
|
||||
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);
|
||||
const selectedNames = useMemo(
|
||||
() => value.map((id) => rows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
|
||||
[rows, value],
|
||||
);
|
||||
const selectedAssistantNames = useMemo(
|
||||
() => assistantValue.map((id) => assistantRows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
|
||||
[assistantRows, assistantValue],
|
||||
);
|
||||
const allSelectedNames = useMemo(
|
||||
() => [...selectedNames, ...selectedAssistantNames],
|
||||
[selectedAssistantNames, selectedNames],
|
||||
);
|
||||
const suggestedQuestions = useMemo(
|
||||
() => buildKnowledgeSpaceQuestions(allSelectedNames),
|
||||
[allSelectedNames],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onSuggestedQuestionsChange?.(suggestedQuestions);
|
||||
}, [onSuggestedQuestionsChange, suggestedQuestions]);
|
||||
|
||||
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));
|
||||
@@ -288,19 +499,35 @@ export function LlmWikiKnowledgeSelector({
|
||||
if (next.length !== value.length || next.some((id, index) => id !== value[index])) {
|
||||
onChange(next);
|
||||
}
|
||||
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, source, value]);
|
||||
}, [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 || runtime.data?.provider !== "weknora") return null;
|
||||
if (
|
||||
!runtime.isLoading &&
|
||||
(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}
|
||||
@@ -317,26 +544,24 @@ export function LlmWikiKnowledgeSelector({
|
||||
<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" />绑定知识空间
|
||||
<Database className="size-4 text-primary" />{panelTitle}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
|
||||
{panelDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">{value.length} 个</Badge>
|
||||
<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 selectedLabel = selectedNames.length > 0
|
||||
? selectedNames.join("、")
|
||||
: value.length > 0
|
||||
? `已选 ${value.length} 个知识空间`
|
||||
const selectedCount = value.length + assistantValue.length;
|
||||
const selectedLabel = allSelectedNames.length > 0
|
||||
? allSelectedNames.join("、")
|
||||
: selectedCount > 0
|
||||
? `已选 ${selectedCount} 个知识空间`
|
||||
: label;
|
||||
|
||||
return (
|
||||
@@ -348,20 +573,20 @@ export function LlmWikiKnowledgeSelector({
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 max-w-52 gap-1.5 rounded-full border-0 bg-transparent px-2.5 text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground",
|
||||
value.length > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary",
|
||||
selectedCount > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
title={value.length > 0 ? selectedLabel : undefined}
|
||||
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>
|
||||
{value.length > 0 && (
|
||||
{selectedCount > 0 && (
|
||||
<span className="flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
|
||||
{value.length}
|
||||
{selectedCount}
|
||||
</span>
|
||||
)}
|
||||
{value.length > 0 && value.length === rows.length && <Check className="size-3" />}
|
||||
{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">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import type {
|
||||
AnchorHTMLAttributes,
|
||||
HTMLAttributes,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
MessageResponse,
|
||||
type MessageResponseProps,
|
||||
} from "@/components/ai-elements/message";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
|
||||
import {
|
||||
createMarkdownAnchorResolver,
|
||||
isInPageAnchor,
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
} from "@/core/utils/markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { VisualizationBlock } from "../visualizations/visualization-block";
|
||||
import { splitVisualizationContent } from "../visualizations/visualization-spec";
|
||||
|
||||
import { CitationLink } from "../citations/citation-link";
|
||||
|
||||
function isExternalUrl(href: string | undefined): boolean {
|
||||
@@ -50,6 +53,7 @@ export type MarkdownContentProps = {
|
||||
/** Renders markdown content. */
|
||||
export function MarkdownContent({
|
||||
content,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
className,
|
||||
remarkPlugins = streamdownPlugins.remarkPlugins,
|
||||
@@ -57,7 +61,10 @@ export function MarkdownContent({
|
||||
linkClassName,
|
||||
onLinkClick,
|
||||
}: MarkdownContentProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
||||
const visualizationSegments = useMemo(() => splitVisualizationContent(content), [content]);
|
||||
useStreamdownMermaidPngDownload(rootRef);
|
||||
const components = useMemo(() => {
|
||||
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
|
||||
const Tag = `h${level}` as const;
|
||||
@@ -137,17 +144,51 @@ export function MarkdownContent({
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
// Preserve the exact legacy rendering path for ordinary Markdown. Heavy
|
||||
// visualization renderers are lazy-loaded only when a dedicated fence exists.
|
||||
if (
|
||||
visualizationSegments.length === 1 &&
|
||||
visualizationSegments[0]?.type === "markdown"
|
||||
) {
|
||||
return (
|
||||
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
||||
<MessageResponse
|
||||
className={className}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
mermaidConfig={streamdownPlugins.mermaidConfig}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</MessageResponse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-markdown-anchor-root className="contents">
|
||||
<MessageResponse
|
||||
className={className}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
mermaidConfig={streamdownPlugins.mermaidConfig}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</MessageResponse>
|
||||
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
||||
{visualizationSegments.map((segment, index) =>
|
||||
segment.type === "visualization" ? (
|
||||
<VisualizationBlock
|
||||
key={`visualization-${index}`}
|
||||
language={segment.language}
|
||||
source={segment.source}
|
||||
complete={segment.complete}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
) : segment.content ? (
|
||||
<MessageResponse
|
||||
key={`markdown-${index}`}
|
||||
className={className}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
mermaidConfig={streamdownPlugins.mermaidConfig}
|
||||
components={components}
|
||||
>
|
||||
{segment.content}
|
||||
</MessageResponse>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,8 +40,10 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
buildWriteFileVirtualUrl,
|
||||
hasCompleteArtifactExtension,
|
||||
buildWriteFileVirtualUrl,
|
||||
hasCompleteArtifactExtension,
|
||||
isAutoPreviewableArtifactPath,
|
||||
languageOfStreamingArtifact,
|
||||
normalizeStreamingArtifactPath,
|
||||
provisionalWriteFilePath,
|
||||
} from "@/core/artifacts/streaming-write-preview";
|
||||
@@ -51,12 +53,13 @@ import {
|
||||
resolveSearchQuery,
|
||||
type SearchResultItem,
|
||||
} from "@/core/messages/search-results";
|
||||
import {
|
||||
extractReasoningContentFromMessage,
|
||||
findToolCallResult,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
extractReasoningContentFromMessage,
|
||||
findToolCallResult,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
applyInlineCitations,
|
||||
buildSkillDisplayMap,
|
||||
@@ -67,6 +70,7 @@ import {
|
||||
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";
|
||||
@@ -78,7 +82,6 @@ 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,
|
||||
@@ -92,7 +95,7 @@ export function MessageGroup({
|
||||
isLoading?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
ragModeEnabled?: boolean;
|
||||
}) {
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [showAbove, setShowAbove] = useState(
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
@@ -100,12 +103,19 @@ export function MessageGroup({
|
||||
const [showLastThinking, setShowLastThinking] = useState(
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
const steps = useMemo(() => convertToSteps(messages), [messages]);
|
||||
const { skills } = useSkills();
|
||||
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 lastToolCallStep = useMemo(() => {
|
||||
const filteredSteps = steps.filter((step) => step.type === "toolCall");
|
||||
return filteredSteps[filteredSteps.length - 1];
|
||||
@@ -127,19 +137,6 @@ 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;
|
||||
@@ -189,7 +186,6 @@ export function MessageGroup({
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
onCitationClick={handleCitationClick}
|
||||
/>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
@@ -199,9 +195,10 @@ export function MessageGroup({
|
||||
{...step}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
displaySources={displaySources}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
),
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{lastToolCallStep && (
|
||||
<FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}>
|
||||
@@ -211,8 +208,9 @@ export function MessageGroup({
|
||||
isLast={true}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
displaySources={displaySources}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
/>
|
||||
</FlipDisplay>
|
||||
)}
|
||||
</ChainOfThoughtContent>
|
||||
@@ -251,7 +249,6 @@ export function MessageGroup({
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
onCitationClick={handleCitationClick}
|
||||
/>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
@@ -260,12 +257,6 @@ export function MessageGroup({
|
||||
</>
|
||||
)}
|
||||
</ChainOfThought>
|
||||
<SingleRoundRagCitationDialog
|
||||
source={selectedCitation}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedCitation(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -275,14 +266,12 @@ function StepReasoningMarkdown({
|
||||
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 }),
|
||||
@@ -292,19 +281,18 @@ function StepReasoningMarkdown({
|
||||
if (displaySources.length === 0) return undefined;
|
||||
return {
|
||||
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (href?.startsWith("#reference-")) {
|
||||
return (
|
||||
<CitationLink
|
||||
href={href}
|
||||
{...props}
|
||||
onReferenceClick={onCitationClick}
|
||||
/>
|
||||
);
|
||||
if (href?.startsWith("#reference-")) {
|
||||
return (
|
||||
<CitationLink
|
||||
href={href}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <a href={href} {...props} />;
|
||||
},
|
||||
};
|
||||
}, [displaySources.length, onCitationClick]);
|
||||
}, [displaySources.length]);
|
||||
|
||||
return (
|
||||
<MarkdownContent
|
||||
@@ -324,7 +312,8 @@ function ToolCall({
|
||||
result,
|
||||
isLast = false,
|
||||
isLoading = false,
|
||||
skillDisplayMap = new Map(),
|
||||
skillDisplayMap = new Map(),
|
||||
displaySources = [],
|
||||
ragModeEnabled = false,
|
||||
}: {
|
||||
id?: string;
|
||||
@@ -334,7 +323,8 @@ function ToolCall({
|
||||
result?: string | Record<string, unknown>;
|
||||
isLast?: boolean;
|
||||
isLoading?: boolean;
|
||||
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
|
||||
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
ragModeEnabled?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
@@ -343,21 +333,28 @@ function ToolCall({
|
||||
? 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,
|
||||
}))
|
||||
: [];
|
||||
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
|
||||
const wikiSearchLabel = name === "llmwiki_search"
|
||||
? wikiQuery
|
||||
? `Wiki 文档检索 "${wikiQuery}"`
|
||||
: "Wiki 文档检索"
|
||||
: null;
|
||||
const displayItems =
|
||||
displayMatch && result !== undefined
|
||||
? syncDisplaySourceIndexes(rankDisplaySources(normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: item.displayMode ?? displayMatch.display.mode,
|
||||
}))), displaySources)
|
||||
: [];
|
||||
const isLlmWikiTool = name === "llmwiki_search" || name === "llmwiki_read_wiki_page" || name === "llmwiki_browse_wiki_index";
|
||||
const wikiQuery = isLlmWikiTool ? resolveSearchQuery(args, result) : "";
|
||||
const wikiSearchLabel = name === "llmwiki_search"
|
||||
? wikiQuery
|
||||
? `Wiki 文档检索 "${wikiQuery}"`
|
||||
: "Wiki 文档检索"
|
||||
: name === "llmwiki_read_wiki_page"
|
||||
? wikiQuery
|
||||
? `读取 Wiki 页面「${wikiQuery}」`
|
||||
: "读取 Wiki 页面"
|
||||
: name === "llmwiki_browse_wiki_index"
|
||||
? "读取 Wiki 索引与知识库摘要"
|
||||
: null;
|
||||
if (displayItems.length > 0) {
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
@@ -383,12 +380,12 @@ function ToolCall({
|
||||
|
||||
const searchItems = normalizeSearchResults(result);
|
||||
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
||||
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: name,
|
||||
mode: "citation" as const,
|
||||
}));
|
||||
const defaultItems = syncDisplaySourceIndexes(rankDisplaySources(normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: name,
|
||||
mode: "citation" as const,
|
||||
}))), displaySources);
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
|
||||
<DisplayResultsList results={defaultItems} />
|
||||
@@ -754,7 +751,6 @@ function ArtifactWriteToolCall({
|
||||
args,
|
||||
result,
|
||||
description,
|
||||
isLast,
|
||||
isLoading,
|
||||
}: {
|
||||
id?: string;
|
||||
@@ -793,19 +789,21 @@ function ArtifactWriteToolCall({
|
||||
!artifactPath ||
|
||||
!previewKey ||
|
||||
!isLoading ||
|
||||
!isLast ||
|
||||
result !== undefined
|
||||
result !== undefined ||
|
||||
!sourcePath ||
|
||||
!languageOfStreamingArtifact(sourcePath) ||
|
||||
!isAutoPreviewableArtifactPath(sourcePath)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
openStreamedArtifact(artifactPath, previewKey);
|
||||
}, [
|
||||
artifactPath,
|
||||
isLast,
|
||||
isLoading,
|
||||
openStreamedArtifact,
|
||||
previewKey,
|
||||
result,
|
||||
sourcePath,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -847,9 +845,21 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
|
||||
|
||||
type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
||||
|
||||
function convertToSteps(messages: Message[]): CoTStep[] {
|
||||
const steps: CoTStep[] = [];
|
||||
for (const message of messages) {
|
||||
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||
const steps: CoTStep[] = [];
|
||||
// A streamed ToolMessage normally carries tool_call_id. If a provider
|
||||
// omits it, the legacy name-based fallback is safe only for a unique tool
|
||||
// name. Reusing one llmwiki_search result for several parallel calls makes
|
||||
// every card appear to come from the same knowledge base.
|
||||
const toolCallNameCounts = new Map<string, number>();
|
||||
for (const message of messages) {
|
||||
if (message.type !== "ai") continue;
|
||||
for (const toolCall of message.tool_calls ?? []) {
|
||||
const key = toolCall.name.toLowerCase();
|
||||
toolCallNameCounts.set(key, (toolCallNameCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
for (const message of messages) {
|
||||
if (message.type === "ai") {
|
||||
const reasoning = extractReasoningContentFromMessage(message);
|
||||
if (reasoning) {
|
||||
@@ -865,6 +875,22 @@ function convertToSteps(messages: Message[]): 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,
|
||||
@@ -874,11 +900,12 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
||||
};
|
||||
const toolCallId = tool_call.id;
|
||||
if (toolCallId) {
|
||||
const toolCallResult = findToolCallResult(
|
||||
toolCallId,
|
||||
messages,
|
||||
tool_call.name,
|
||||
);
|
||||
const toolCallResult = findToolCallResult(
|
||||
toolCallId,
|
||||
messages,
|
||||
tool_call.name,
|
||||
{ allowNameFallback: toolCallNameCounts.get(tool_call.name.toLowerCase()) === 1 },
|
||||
);
|
||||
if (toolCallResult !== undefined) {
|
||||
if (typeof toolCallResult === "string") {
|
||||
try {
|
||||
@@ -923,9 +950,27 @@ function WebSearchResultsList({ results }: { results: SearchResultItem[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const orderedResults = useMemo(
|
||||
() =>
|
||||
results
|
||||
.map((item, originalOrder) => ({ item, originalOrder }))
|
||||
.sort((left, right) => {
|
||||
const leftScore =
|
||||
typeof left.item.score === "number" && Number.isFinite(left.item.score)
|
||||
? left.item.score
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
const rightScore =
|
||||
typeof right.item.score === "number" && Number.isFinite(right.item.score)
|
||||
? right.item.score
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
return rightScore - leftScore || left.originalOrder - right.originalOrder;
|
||||
})
|
||||
.map(({ item }) => item),
|
||||
[results],
|
||||
);
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
|
||||
<CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs">
|
||||
<ChevronDownIcon
|
||||
@@ -937,13 +982,52 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
||||
<span>{results.length} 条真实返回结果</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 flex flex-col gap-2">
|
||||
{results.map((item) => (
|
||||
<SourceCard key={`${item.skillName}-${item.index}`} source={item} />
|
||||
))}
|
||||
{orderedResults.map((item) => (
|
||||
<SourceCard
|
||||
key={`${item.skillName}-${item.index}`}
|
||||
source={item}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function rankDisplaySources(
|
||||
sources: RuntimeDisplaySource[],
|
||||
): RuntimeDisplaySource[] {
|
||||
return sources
|
||||
.map((source, originalOrder) => ({ source, originalOrder }))
|
||||
.sort((left, right) => {
|
||||
const leftScore =
|
||||
typeof left.source.score === "number" && Number.isFinite(left.source.score)
|
||||
? left.source.score
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
const rightScore =
|
||||
typeof right.source.score === "number" && Number.isFinite(right.source.score)
|
||||
? right.source.score
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
return rightScore - leftScore || left.originalOrder - right.originalOrder;
|
||||
})
|
||||
.map(({ source }, index) => ({ ...source, index: index + 1 }));
|
||||
}
|
||||
|
||||
function syncDisplaySourceIndexes(
|
||||
sources: RuntimeDisplaySource[],
|
||||
globalSources: RuntimeDisplaySource[],
|
||||
): RuntimeDisplaySource[] {
|
||||
const indexByCitationId = new Map(
|
||||
globalSources.flatMap((source) => {
|
||||
const id = typeof source.raw?.citation_id === "string" ? source.raw.citation_id : "";
|
||||
return id ? [[id, source.index] as const] : [];
|
||||
}),
|
||||
);
|
||||
return sources.map((source) => {
|
||||
const id = typeof source.raw?.citation_id === "string" ? source.raw.citation_id : "";
|
||||
const index = id ? indexByCitationId.get(id) : undefined;
|
||||
return index ? { ...source, index } : source;
|
||||
});
|
||||
}
|
||||
|
||||
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
FileIcon,
|
||||
Loader2Icon,
|
||||
PencilIcon,
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
memo,
|
||||
@@ -77,9 +75,9 @@ import { humanMessagePlugins } from '@/core/streamdown';
|
||||
import {
|
||||
getMultiRoundRagTrace,
|
||||
isMultiRoundRagMessage,
|
||||
type MultiRoundRagRound,
|
||||
} from '@/core/threads/use-multi-round-rag';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { WeKnoraSourceDrawer } from '@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer';
|
||||
|
||||
import { CitationLink } from '../citations/citation-link';
|
||||
import { CopyButton } from '../copy-button';
|
||||
@@ -90,6 +88,7 @@ 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;
|
||||
@@ -159,9 +158,9 @@ export function MessageListItem({
|
||||
className,
|
||||
fitRowToContent = false,
|
||||
anchorId,
|
||||
message,
|
||||
questionMarkdown,
|
||||
isLoading,
|
||||
message,
|
||||
questionMarkdown,
|
||||
isLoading,
|
||||
threadId,
|
||||
tokenUsageEnabled = false,
|
||||
displaySources = [],
|
||||
@@ -178,9 +177,9 @@ export function MessageListItem({
|
||||
* message. `scroll-mt` keeps the target clear of the overlay header.
|
||||
*/
|
||||
anchorId?: string;
|
||||
message: Message;
|
||||
/** Nearest preceding user question, used by the current-Q&A Word export. */
|
||||
questionMarkdown?: string;
|
||||
message: Message;
|
||||
/** Nearest preceding user question, used by the current-Q&A Word export. */
|
||||
questionMarkdown?: string;
|
||||
isLoading?: boolean;
|
||||
threadId: string;
|
||||
tokenUsageEnabled?: boolean;
|
||||
@@ -330,12 +329,12 @@ export function MessageListItem({
|
||||
<div className="flex gap-1">
|
||||
<CopyButton clipboardData={messageMarkdown} />
|
||||
{messageMarkdown && (
|
||||
<DownloadWordButton
|
||||
markdown={messageMarkdown}
|
||||
fileName="message"
|
||||
title="问答回答"
|
||||
questionMarkdown={questionMarkdown}
|
||||
/>
|
||||
<DownloadWordButton
|
||||
markdown={messageMarkdown}
|
||||
fileName="message"
|
||||
title="问答回答"
|
||||
questionMarkdown={questionMarkdown}
|
||||
/>
|
||||
)}
|
||||
{canSaveAssistantEdit && (
|
||||
<Tooltip content="编辑消息">
|
||||
@@ -380,12 +379,12 @@ function HumanMessageBlock({
|
||||
messageId?: string;
|
||||
text: string;
|
||||
pageContext?: EmbeddedPageContext | null;
|
||||
filesList: React.ReactNode;
|
||||
filesList: 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;
|
||||
deleteButton?: ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -629,9 +628,7 @@ function MessageContent_({
|
||||
href={href}
|
||||
{...props}
|
||||
onReferenceClick={
|
||||
isSingleRoundRag || isMultiRoundRag
|
||||
? handleCitationClick
|
||||
: undefined
|
||||
isSingleRoundRag ? handleCitationClick : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -658,7 +655,6 @@ function MessageContent_({
|
||||
}),
|
||||
[
|
||||
handleCitationClick,
|
||||
isMultiRoundRag,
|
||||
isSingleRoundRag,
|
||||
resolveArtifact,
|
||||
threadId,
|
||||
@@ -697,9 +693,12 @@ function MessageContent_({
|
||||
? stripArtifactTag(stripUploadedFilesTag(rawContent))
|
||||
: '';
|
||||
}
|
||||
if (isMultiRoundRag) {
|
||||
return stripIncompleteCitationMarker(rawContent ?? '');
|
||||
}
|
||||
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
|
||||
return stripIncompleteCitationMarker(withCitations);
|
||||
}, [rawContent, isHuman, displaySources]);
|
||||
}, [rawContent, isHuman, isMultiRoundRag, displaySources]);
|
||||
const markdownRehypePlugins = useMemo(
|
||||
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
|
||||
[rehypePlugins],
|
||||
@@ -734,7 +733,7 @@ function MessageContent_({
|
||||
}
|
||||
|
||||
// Reasoning-only AI message (no main response content yet)
|
||||
if (!isHuman && reasoningContent && !rawContent && !multiRoundTrace) {
|
||||
if (!isHuman && reasoningContent && !rawContent && !isMultiRoundRag) {
|
||||
return (
|
||||
<AIElementMessageContent className={className}>
|
||||
<Reasoning isStreaming={isLoading}>
|
||||
@@ -755,7 +754,11 @@ 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-');
|
||||
!!onMessageDeleted &&
|
||||
!!message.id &&
|
||||
!message.id.startsWith('opt-') &&
|
||||
!isMultiRoundRag &&
|
||||
!isSingleRoundRag;
|
||||
return (
|
||||
<HumanMessageBlock
|
||||
messageId={message.id}
|
||||
@@ -764,8 +767,10 @@ function MessageContent_({
|
||||
filesList={filesList}
|
||||
messageTime={messageTime}
|
||||
components={components}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onEditSave={onEditSave}
|
||||
onEditResubmit={
|
||||
isMultiRoundRag || isSingleRoundRag ? undefined : onEditResubmit
|
||||
}
|
||||
onEditSave={isMultiRoundRag || isSingleRoundRag ? undefined : onEditSave}
|
||||
deleteButton={
|
||||
canDelete ? (
|
||||
<DeleteMessageButton
|
||||
@@ -785,14 +790,12 @@ function MessageContent_({
|
||||
<AIElementMessageContent className={className}>
|
||||
{filesList}
|
||||
<div data-reference-batch-id={message.id} className="contents">
|
||||
{multiRoundTrace ? (
|
||||
<MultiRoundRagContent
|
||||
trace={multiRoundTrace}
|
||||
{isMultiRoundRag ? (
|
||||
<MultiRoundRagAssistantBody
|
||||
content={contentToDisplay}
|
||||
displaySources={displaySources}
|
||||
thinkingTrace={multiRoundTrace}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={markdownRehypePlugins}
|
||||
components={components}
|
||||
/>
|
||||
) : (
|
||||
<AssistantDisplayContent
|
||||
@@ -805,7 +808,7 @@ function MessageContent_({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(isSingleRoundRag || isMultiRoundRag) && displaySources.length > 0 && (
|
||||
{isSingleRoundRag && displaySources.length > 0 && (
|
||||
<FrontendRagSourcesPanel
|
||||
sources={displaySources}
|
||||
onSelect={setSelectedCitation}
|
||||
@@ -820,7 +823,7 @@ function MessageContent_({
|
||||
<MessageTimeStamp time={messageTime} align="left" />
|
||||
) : null}
|
||||
</AIElementMessageContent>
|
||||
{(isSingleRoundRag || isMultiRoundRag) && (
|
||||
{isSingleRoundRag && (
|
||||
<SingleRoundRagCitationDialog
|
||||
source={selectedCitation}
|
||||
onOpenChange={(open) => {
|
||||
@@ -833,11 +836,9 @@ function MessageContent_({
|
||||
}
|
||||
|
||||
/**
|
||||
* 单轮/多轮 RAG 专属的「引用来源」折叠面板。
|
||||
* 单轮 RAG 专属的「引用来源」折叠面板。
|
||||
*
|
||||
* 参考 GeneralQA 的引用来源区:默认折叠,列出全部参考资料,点击任一条打开详情
|
||||
* 弹窗(复用 SingleRoundRagCitationDialog)。这样即使模型正文里没有 `[1]` 编号,
|
||||
* 用户也能看到本次回答依据的资料。
|
||||
* 多轮 RAG 的检索卡片已经画在思考过程里,正文下面不再重复一份来源列表。
|
||||
*/
|
||||
function FrontendRagSourcesPanel({
|
||||
sources,
|
||||
@@ -890,11 +891,11 @@ function FrontendRagSourcesPanel({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{source.snippet && (
|
||||
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
|
||||
{source.snippet}
|
||||
</p>
|
||||
)}
|
||||
{(source.content || source.snippet) && (
|
||||
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
|
||||
{source.content || source.snippet}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
@@ -908,8 +909,26 @@ export function SingleRoundRagCitationDialog({
|
||||
}: {
|
||||
source: RuntimeDisplaySource | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
|
||||
}) {
|
||||
const isLlmWikiSource =
|
||||
source?.skillName?.startsWith('llmwiki_') ||
|
||||
source?.type?.startsWith('llmwiki_') ||
|
||||
source?.raw?.type === 'llmwiki_wiki_index_intro';
|
||||
if (isLlmWikiSource) {
|
||||
const content = source?.content || source?.snippet || '';
|
||||
return (
|
||||
<WeKnoraSourceDrawer
|
||||
open={!!source}
|
||||
target={null}
|
||||
title={source?.title || 'Wiki 文档'}
|
||||
quote={content}
|
||||
inlineContent={content}
|
||||
inlineKnowledgeBaseId={String(source?.raw?.knowledge_base_id || '')}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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">
|
||||
@@ -941,349 +960,6 @@ export function SingleRoundRagCitationDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function MultiRoundRagContent({
|
||||
trace,
|
||||
content,
|
||||
displaySources,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
components,
|
||||
}: {
|
||||
trace: NonNullable<ReturnType<typeof getMultiRoundRagTrace>>;
|
||||
content: string;
|
||||
displaySources: RuntimeDisplaySource[];
|
||||
isLoading: boolean;
|
||||
rehypePlugins: MarkdownContentProps['rehypePlugins'];
|
||||
components: MarkdownContentProps['components'];
|
||||
}) {
|
||||
const [open, setOpen] = useState(!trace.isComplete);
|
||||
useEffect(() => {
|
||||
// Keep the active turn expanded so each new retrieval is visible as it
|
||||
// arrives, then match GeneralQA by collapsing the completed trace. The
|
||||
// header still carries the total round count for quick scanning.
|
||||
setOpen(!trace.isComplete);
|
||||
}, [trace.isComplete]);
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
|
||||
<section className="border-border/70 overflow-hidden rounded-xl border bg-muted/20">
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/50 flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{trace.isComplete ? (
|
||||
<SearchIcon className="text-primary size-4 shrink-0" />
|
||||
) : (
|
||||
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium">多轮检索思考过程</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{trace.isComplete
|
||||
? `共 ${trace.rounds.length} 轮推理与检索,可展开查看细节`
|
||||
: '正在分析问题并检索资料'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={cn('text-muted-foreground size-4 transition-transform', !open && '-rotate-90')}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-border/60 space-y-3 border-t px-4 py-3">
|
||||
{trace.rounds.map((round, index) => (
|
||||
<div
|
||||
key={round.id}
|
||||
className={cn(
|
||||
'relative pl-5',
|
||||
index < trace.rounds.length - 1 && 'pb-1',
|
||||
)}
|
||||
>
|
||||
{index < trace.rounds.length - 1 && (
|
||||
<span className="bg-border absolute bottom-0 left-[7px] top-8 w-px" />
|
||||
)}
|
||||
<span className="border-primary bg-background absolute left-0 top-4 size-3.5 rounded-full border-2" />
|
||||
<MultiRoundRagRoundCard
|
||||
index={index + 1}
|
||||
round={round}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="border-border/70 rounded-xl border p-4">
|
||||
<div className="mb-3 text-sm font-semibold">最终回答</div>
|
||||
{content ? (
|
||||
<AssistantDisplayContent
|
||||
content={content}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
renderSkillDisplayBlocks={false}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在生成最终回答…
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiRoundRagRoundCard({
|
||||
index,
|
||||
round,
|
||||
displaySources,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
components,
|
||||
}: {
|
||||
index: number;
|
||||
round: MultiRoundRagRound;
|
||||
displaySources: RuntimeDisplaySource[];
|
||||
isLoading: boolean;
|
||||
rehypePlugins: MarkdownContentProps['rehypePlugins'];
|
||||
components: MarkdownContentProps['components'];
|
||||
}) {
|
||||
const [open, setOpen] = useState(!round.query || round.isSearching);
|
||||
useEffect(() => {
|
||||
// A newly created round stays expanded to expose its thought and loading
|
||||
// query. Once that query has returned, retain only the round header until
|
||||
// the user asks to inspect the completed detail again.
|
||||
setOpen(!round.query || round.isSearching);
|
||||
}, [round.id, round.isSearching, round.query]);
|
||||
const thoughtContent = useMemo(
|
||||
() =>
|
||||
applyInlineCitations(round.thought, displaySources, {
|
||||
appendSummary: false,
|
||||
}),
|
||||
[displaySources, round.thought],
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<article className="border-border/60 bg-background/80 overflow-hidden rounded-xl border shadow-sm">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2.5 text-left text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
|
||||
>
|
||||
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
|
||||
{index}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{round.title}</span>
|
||||
{round.isSearching ? (
|
||||
<span className="bg-primary/10 text-primary inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
|
||||
<Loader2Icon className="size-3 motion-reduce:animate-none animate-spin" />
|
||||
检索中
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground shrink-0 text-[11px]">已完成</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
|
||||
!open && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border/50 border-t px-3 pb-3">
|
||||
<div className="pt-3">
|
||||
{round.thought && (
|
||||
<MultiRoundRagDetailSection
|
||||
title="思考内容"
|
||||
icon={<SparklesIcon className="text-primary size-4" />}
|
||||
>
|
||||
<MarkdownContent
|
||||
content={thoughtContent}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
className="text-muted-foreground text-sm"
|
||||
/>
|
||||
</MultiRoundRagDetailSection>
|
||||
)}
|
||||
{round.body && (
|
||||
<section className="border-border/50 mt-3 rounded-lg border p-3">
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
|
||||
<MarkdownContent
|
||||
content={round.body}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
className="text-sm"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{(round.query || round.isSearching) && (
|
||||
<div
|
||||
className={cn(
|
||||
'border-primary/20 bg-primary/5 text-primary mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1.5 text-xs',
|
||||
round.isSearching && 'animate-pulse motion-reduce:animate-none',
|
||||
)}
|
||||
aria-live={round.isSearching ? 'polite' : undefined}
|
||||
>
|
||||
{round.isSearching ? (
|
||||
<Loader2Icon className="size-3.5 shrink-0 motion-reduce:animate-none animate-spin" />
|
||||
) : (
|
||||
<SearchIcon className="size-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="shrink-0 text-muted-foreground">检索词</span>
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
{round.query || '正在生成检索词…'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(round.query || round.isSearching || round.retrievals.length > 0) && (
|
||||
<MultiRoundRagDetailSection
|
||||
defaultOpen={!round.isSearching}
|
||||
title="检索内容"
|
||||
icon={<FileIcon className="text-primary size-3.5" />}
|
||||
suffix={<span className="text-muted-foreground text-xs">{round.retrievals.length} 条</span>}
|
||||
>
|
||||
{round.isSearching ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 py-1 text-sm">
|
||||
<Loader2Icon className="text-primary size-4 motion-reduce:animate-none animate-spin" />
|
||||
正在召回相关内容…
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{round.retrievals.length > 0 ? (
|
||||
round.retrievals.map((item, resultIndex) => (
|
||||
<MultiRoundRagResultCard
|
||||
key={`${item.id ?? item.title}-${resultIndex}`}
|
||||
item={item}
|
||||
index={resultIndex + 1}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-muted-foreground py-1 text-sm">
|
||||
本轮未召回相关资料。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</MultiRoundRagDetailSection>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</article>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiRoundRagDetailSection({
|
||||
title,
|
||||
icon,
|
||||
suffix,
|
||||
defaultOpen = true,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
suffix?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
useEffect(() => setOpen(defaultOpen), [defaultOpen]);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<section className="border-border/50 bg-muted/20 mt-3 overflow-hidden rounded-lg border">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
|
||||
>
|
||||
{icon}
|
||||
<span className="min-w-0 flex-1 text-sm font-medium">{title}</span>
|
||||
{suffix}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
|
||||
!open && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</section>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiRoundRagResultCard({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: MultiRoundRagRound['retrievals'][number];
|
||||
index: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(index === 1);
|
||||
const sourceMeta = [item.source, item.time].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<div className="border-border/60 hover:border-primary/30 bg-background rounded-lg border transition-colors">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
|
||||
>
|
||||
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-semibold">
|
||||
{index}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={item.title}>
|
||||
{item.title}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
|
||||
!open && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
|
||||
<p className="text-muted-foreground text-sm leading-6 whitespace-pre-wrap">
|
||||
{item.content || '暂无资料摘要'}
|
||||
</p>
|
||||
{(sourceMeta || item.url) && (
|
||||
<div className="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
{sourceMeta && <span>{sourceMeta}</span>}
|
||||
{item.url && (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
查看原文
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSingleRoundRagSourceUrl(
|
||||
source: RuntimeDisplaySource | null,
|
||||
): string | undefined {
|
||||
|
||||
@@ -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 } from "react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
||||
import {
|
||||
@@ -28,12 +28,12 @@ import {
|
||||
hasToolCalls,
|
||||
isHiddenFromUIMessage,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
@@ -136,6 +136,8 @@ export function MessageList({
|
||||
onSetupReportStructureReject,
|
||||
researchApprovalSlot,
|
||||
taskReportImportSlot,
|
||||
businessCardSlot,
|
||||
speakerShell,
|
||||
onOpenArtifact,
|
||||
onDownloadArtifact,
|
||||
afterDocumentRewriteItems,
|
||||
@@ -176,6 +178,19 @@ export function MessageList({
|
||||
* 「是否入库」卡片。不传该 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>;
|
||||
@@ -300,23 +315,19 @@ export function MessageList({
|
||||
}
|
||||
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 [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) || isMultiRoundRagMessage(message),
|
||||
),
|
||||
() => messages.some((message) => isSingleRoundRagMessage(message)),
|
||||
[messages],
|
||||
);
|
||||
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
|
||||
@@ -331,7 +342,7 @@ 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 消息里(紧挨在
|
||||
// 单轮 RAG 的参考资料存放在一条 hide_from_ui 的 tool 消息里(紧挨在
|
||||
// assistant 消息之前)。allMessages 会把 hide_from_ui 消息过滤掉,导致通用
|
||||
// 的 collectRuntimeDisplaySources 扫描不到它们。这里直接在原始消息流里按
|
||||
// assistant 消息提取其对应的参考资料,保证 `[1]` 等编号能解析成可点击引用。
|
||||
@@ -342,7 +353,7 @@ export function MessageList({
|
||||
const msg = raw[i];
|
||||
if (!msg || msg.type !== "ai" || !msg.id) continue;
|
||||
const kwargs = msg.additional_kwargs;
|
||||
if (!kwargs || (kwargs.single_round_rag !== true && kwargs.multi_round_rag !== true)) {
|
||||
if (!kwargs || kwargs.single_round_rag !== true) {
|
||||
continue;
|
||||
}
|
||||
let sources: RuntimeDisplaySource[] = [];
|
||||
@@ -353,19 +364,13 @@ export function MessageList({
|
||||
if (prev.type === "human" || prev.type === "ai") break;
|
||||
if (prev.type !== "tool") continue;
|
||||
const prevKwargs = prev.additional_kwargs;
|
||||
if (
|
||||
!prevKwargs ||
|
||||
(prevKwargs.single_round_rag !== true && prevKwargs.multi_round_rag !== true)
|
||||
) {
|
||||
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 ??
|
||||
(prevKwargs.multi_round_rag === true
|
||||
? "multi_round_rag_references"
|
||||
: "knowledge_base_search"),
|
||||
(prev as { name?: unknown }).name ?? "knowledge_base_search",
|
||||
);
|
||||
sources = results.map((item, index) =>
|
||||
toRuntimeDisplaySource(item, index + 1, toolName),
|
||||
@@ -381,6 +386,7 @@ export function MessageList({
|
||||
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 ?? [];
|
||||
@@ -536,7 +542,7 @@ export function MessageList({
|
||||
let didRenderRewriteAboveFile = false;
|
||||
const grouped = groupMessages(messages, (group) => {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return group.messages.map((msg) => {
|
||||
const items = group.messages.map((msg) => {
|
||||
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
|
||||
// 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。
|
||||
const messageIsLoading =
|
||||
@@ -563,7 +569,12 @@ export function MessageList({
|
||||
questionMarkdown={
|
||||
msg.id ? questionMarkdownByAssistantId.get(msg.id) : undefined
|
||||
}
|
||||
isLoading={messageIsLoading}
|
||||
isLoading={
|
||||
isMultiRoundRagMessage(msg)
|
||||
? thread.isLoading &&
|
||||
msg.id === messages[messages.length - 1]?.id
|
||||
: messageIsLoading
|
||||
}
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
@@ -577,6 +588,14 @@ export function MessageList({
|
||||
/>
|
||||
);
|
||||
});
|
||||
if (speakerShell && group.type === "assistant") {
|
||||
return (
|
||||
<div key={group.id} className="w-full">
|
||||
{speakerShell(group.messages, items)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return items;
|
||||
} else if (group.type === "assistant:clarification") {
|
||||
const message = group.messages[0];
|
||||
if (message && hasContent(message)) {
|
||||
@@ -692,6 +711,14 @@ export function MessageList({
|
||||
{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) {
|
||||
@@ -786,13 +813,13 @@ export function MessageList({
|
||||
)) {
|
||||
if (hasReasoning(message)) {
|
||||
results.push(
|
||||
<MessageGroup
|
||||
key={"thinking-group-" + message.id}
|
||||
messages={[message]}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(message)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>,
|
||||
<MessageGroup
|
||||
key={"thinking-group-" + message.id}
|
||||
messages={[message]}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(message)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
results.push(
|
||||
@@ -838,14 +865,14 @@ export function MessageList({
|
||||
const processingAi = [...group.messages]
|
||||
.reverse()
|
||||
.find((message) => message.type === "ai");
|
||||
return (
|
||||
<div key={"group-" + group.id} className="w-full">
|
||||
<MessageGroup
|
||||
messages={group.messages}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(processingAi)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
const processingBody = (
|
||||
<div className="w-full">
|
||||
<MessageGroup
|
||||
messages={group.messages}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(processingAi)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={thread.isLoading}
|
||||
@@ -853,6 +880,11 @@ export function MessageList({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div key={"group-" + group.id} className="w-full">
|
||||
{speakerShell ? speakerShell(group.messages, processingBody) : processingBody}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
const leftoverRewriteItems = didRenderRewriteAboveFile
|
||||
? rewriteItemsBelowFile
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import {
|
||||
BookOpenIcon,
|
||||
ChevronDownIcon,
|
||||
FileTextIcon,
|
||||
Loader2Icon,
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import {
|
||||
Message as AIElementMessage,
|
||||
MessageContent as AIElementMessageContent,
|
||||
} from "@/components/ai-elements/message";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import type { MessageResponseProps } from "@/components/ai-elements/message";
|
||||
import type {
|
||||
MultiRoundRagChatMessage,
|
||||
MultiRoundRagRetrieval,
|
||||
MultiRoundRagRound,
|
||||
MultiRoundRagTrace,
|
||||
} from "@/core/threads/use-multi-round-rag";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
|
||||
export function MultiRoundRagMessageList({
|
||||
className,
|
||||
messages,
|
||||
isLoading,
|
||||
paddingBottom = 160,
|
||||
}: {
|
||||
className?: string;
|
||||
messages: MultiRoundRagChatMessage[];
|
||||
isLoading: boolean;
|
||||
paddingBottom?: number;
|
||||
}) {
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
|
||||
return (
|
||||
<Conversation className={cn("flex size-full flex-col justify-center", className)}>
|
||||
<ConversationContent
|
||||
className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-10"
|
||||
>
|
||||
{messages.map((message, index) => {
|
||||
const isLastAssistant =
|
||||
message.role === "assistant" && index === messages.length - 1;
|
||||
return (
|
||||
<AIElementMessage
|
||||
key={message.id}
|
||||
className="group/conversation-message relative w-full"
|
||||
from={message.role === "user" ? "user" : "assistant"}
|
||||
>
|
||||
{message.role === "user" ? (
|
||||
<AIElementMessageContent className="w-fit">
|
||||
<p className="whitespace-pre-wrap text-[15px] leading-7">
|
||||
{message.content}
|
||||
</p>
|
||||
</AIElementMessageContent>
|
||||
) : (
|
||||
<AIElementMessageContent className="w-full">
|
||||
<MultiRoundRagAssistantBody
|
||||
content={
|
||||
typeof message.content === "string" ? message.content : ""
|
||||
}
|
||||
thinkingTrace={message.thinkingTrace}
|
||||
isLoading={isLastAssistant && isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
/>
|
||||
</AIElementMessageContent>
|
||||
)}
|
||||
</AIElementMessage>
|
||||
);
|
||||
})}
|
||||
<div style={{ height: `${paddingBottom}px` }} />
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton
|
||||
className="pointer-events-auto z-20 size-9 shadow-md bottom-40"
|
||||
aria-label="回到最新"
|
||||
title="回到最新"
|
||||
/>
|
||||
</Conversation>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiRoundRagAssistantBody({
|
||||
content,
|
||||
thinkingTrace,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
}: {
|
||||
content: string;
|
||||
thinkingTrace?: MultiRoundRagTrace | null;
|
||||
isLoading: boolean;
|
||||
rehypePlugins: MessageResponseProps["rehypePlugins"];
|
||||
}) {
|
||||
const [selected, setSelected] = useState<MultiRoundRagRetrieval | null>(
|
||||
null,
|
||||
);
|
||||
const trace = thinkingTrace;
|
||||
const answer = trace?.finalAnswerMarkdown || content;
|
||||
|
||||
return (
|
||||
<>
|
||||
{trace && (
|
||||
<MultiRoundRagTraceView
|
||||
trace={trace}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
onSelectRetrieval={setSelected}
|
||||
/>
|
||||
)}
|
||||
<section className="mt-4">
|
||||
<div className="text-foreground mb-2 text-sm font-medium">最终回答</div>
|
||||
{answer ? (
|
||||
<MarkdownContent
|
||||
content={answer}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
className="my-0 text-[15px] leading-7"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在生成思考过程与检索内容...
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<RetrievalDetailDialog
|
||||
item={selected}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelected(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiRoundRagTraceView({
|
||||
trace,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
onSelectRetrieval,
|
||||
}: {
|
||||
trace: MultiRoundRagTrace;
|
||||
isLoading: boolean;
|
||||
rehypePlugins: MessageResponseProps["rehypePlugins"];
|
||||
onSelectRetrieval: (item: MultiRoundRagRetrieval) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(!trace.thinkingComplete);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="not-prose w-full min-w-0 max-w-5xl"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors"
|
||||
>
|
||||
{trace.thinkingComplete ? (
|
||||
<SparklesIcon className="text-primary size-4 shrink-0" />
|
||||
) : (
|
||||
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
|
||||
)}
|
||||
<span>{trace.title || "思考过程"}</span>
|
||||
<span className="text-muted-foreground/70 text-xs">
|
||||
共 {trace.rounds.length} 轮推理与检索
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn("ml-auto size-4 transition-transform", !open && "-rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-3 space-y-3">
|
||||
{trace.rounds.map((round, index) => (
|
||||
<div
|
||||
key={round.id}
|
||||
className={cn("relative pl-5", index < trace.rounds.length - 1 && "pb-1")}
|
||||
>
|
||||
{index < trace.rounds.length - 1 && (
|
||||
<span className="bg-primary/25 absolute bottom-0 left-[7px] top-8 w-px" />
|
||||
)}
|
||||
<span className="border-primary bg-background absolute left-0 top-4 size-3.5 rounded-full border-2" />
|
||||
<MultiRoundRagRoundCard
|
||||
index={index + 1}
|
||||
round={round}
|
||||
isLoading={isLoading}
|
||||
isActiveRound={isLoading && index === trace.rounds.length - 1}
|
||||
rehypePlugins={rehypePlugins}
|
||||
onSelectRetrieval={onSelectRetrieval}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiRoundRagRoundCard({
|
||||
index,
|
||||
round,
|
||||
isLoading,
|
||||
isActiveRound,
|
||||
rehypePlugins,
|
||||
onSelectRetrieval,
|
||||
}: {
|
||||
index: number;
|
||||
round: MultiRoundRagRound;
|
||||
isLoading: boolean;
|
||||
isActiveRound: boolean;
|
||||
rehypePlugins: MessageResponseProps["rehypePlugins"];
|
||||
onSelectRetrieval: (item: MultiRoundRagRetrieval) => void;
|
||||
}) {
|
||||
const query = round.query.trim();
|
||||
const searching = Boolean(query && round.queryLoading);
|
||||
const showQuery = Boolean(query);
|
||||
const showRetrievals = showQuery || round.retrievals.length > 0;
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<article className="overflow-hidden rounded-lg border border-border/70 bg-background">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/40 flex min-h-11 w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors"
|
||||
>
|
||||
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-medium">
|
||||
{index}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{round.title}</span>
|
||||
{searching ? (
|
||||
<span className="text-primary inline-flex items-center gap-1 text-[11px]">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
检索中
|
||||
</span>
|
||||
) : isActiveRound ? null : (
|
||||
<span className="text-muted-foreground text-[11px]">已完成</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className={cn("text-muted-foreground size-4 shrink-0 transition-transform", !open && "-rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border/60 space-y-3 border-t px-3 pb-3 pt-3">
|
||||
{round.thoughtMarkdown && (
|
||||
<section>
|
||||
<div className="text-muted-foreground mb-1.5 flex items-center gap-2 text-sm font-medium">
|
||||
<SparklesIcon className="size-4" />
|
||||
思考内容
|
||||
</div>
|
||||
<MarkdownContent
|
||||
content={round.thoughtMarkdown}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
className="text-muted-foreground my-0 text-sm leading-6"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{showQuery && (
|
||||
<div
|
||||
className="border-primary/20 bg-primary/5 text-primary inline-flex max-w-full items-center gap-2 rounded-full border px-3 py-1.5"
|
||||
aria-live={searching ? "polite" : undefined}
|
||||
>
|
||||
{searching ? (
|
||||
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<SearchIcon className="size-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="text-primary/80 shrink-0 text-xs">
|
||||
{round.queryTitle || "检索词"}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-xs font-medium">
|
||||
{query}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showRetrievals && (
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<FileTextIcon className="text-muted-foreground size-4" />
|
||||
检索内容
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
{round.retrievals.length} 条
|
||||
</span>
|
||||
</div>
|
||||
{searching ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 rounded-lg border border-border/70 px-3 py-3 text-sm">
|
||||
<Loader2Icon className="text-primary size-4 animate-spin" />
|
||||
正在召回相关内容...
|
||||
</div>
|
||||
) : (
|
||||
<RetrievalCardList
|
||||
items={round.retrievals}
|
||||
onSelect={onSelectRetrieval}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{(round.extractInfo || round.extractContent || round.extractLoading) && (
|
||||
<section className="rounded-lg border border-border/70 px-3 py-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<BookOpenIcon className="text-muted-foreground size-4" />
|
||||
阅读内容
|
||||
{round.extractLoading && <Loader2Icon className="text-primary size-3.5 animate-spin" />}
|
||||
</div>
|
||||
{round.extractInfo && (
|
||||
<p className="text-muted-foreground text-sm leading-6">{round.extractInfo}</p>
|
||||
)}
|
||||
{round.extractContent && (
|
||||
<p className="mt-2 text-sm leading-6 whitespace-pre-wrap">{round.extractContent}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{round.textidContent && (
|
||||
<section className="rounded-lg border border-border/70 px-3 py-3 text-sm leading-6 whitespace-pre-wrap">
|
||||
{round.textidContent}
|
||||
</section>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</article>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function RetrievalCardList({
|
||||
items,
|
||||
onSelect,
|
||||
}: {
|
||||
items: MultiRoundRagRetrieval[];
|
||||
onSelect: (item: MultiRoundRagRetrieval) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return <p className="text-muted-foreground py-1 text-sm">本轮未召回相关资料。</p>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<RetrievalCard
|
||||
key={`${item.id ?? item.title}-${index}`}
|
||||
item={item}
|
||||
index={index + 1}
|
||||
defaultOpen={index === 0}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RetrievalCard({
|
||||
item,
|
||||
index,
|
||||
defaultOpen,
|
||||
onSelect,
|
||||
}: {
|
||||
item: MultiRoundRagRetrieval;
|
||||
index: number;
|
||||
defaultOpen: boolean;
|
||||
onSelect: (item: MultiRoundRagRetrieval) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const meta = retrievalMeta(item);
|
||||
|
||||
if (item.type === "user_behavior") {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/70 px-3 py-2 text-sm">
|
||||
<span className="text-muted-foreground mr-2 font-medium">{index}.</span>
|
||||
{item.title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 bg-background">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/40 flex min-h-11 w-full items-center gap-2 px-3 py-2.5 text-left transition-colors"
|
||||
>
|
||||
<span className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-medium">
|
||||
{index}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium" title={item.title}>
|
||||
{item.title}
|
||||
</div>
|
||||
{meta && (
|
||||
<div className="text-muted-foreground truncate text-xs">{meta}</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={cn("text-muted-foreground size-4 shrink-0 transition-transform", !open && "-rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border/60 border-t px-3 pb-3 pt-2.5">
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground line-clamp-3 w-full text-left text-sm leading-6 whitespace-pre-wrap"
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
{item.content || "暂无资料摘要"}
|
||||
</button>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function retrievalMeta(item: MultiRoundRagRetrieval): string {
|
||||
if (item.type === "more") return `时间:${item.time || "-"}`;
|
||||
if (item.type === "task") return `关键词:${item.keywords || "-"}`;
|
||||
if (item.type === "hx") return `平台:${item.platform || "-"} 粉丝数:${item.fans ?? "-"}`;
|
||||
if (
|
||||
item.type === "taiwan_news" ||
|
||||
item.type === "world_news" ||
|
||||
item.type === "messaage_comment" ||
|
||||
item.type === "america_news"
|
||||
) {
|
||||
return `平台:${item.platform || "-"}${item.comment_count ? ` 评论数:${item.comment_count}` : ""}`;
|
||||
}
|
||||
if (item.type === "get_time_data") return `时间:${item.time || "-"} 热度:${item.hot_num ?? "-"}`;
|
||||
return [item.source, item.time].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function RetrievalDetailDialog({
|
||||
item,
|
||||
onOpenChange,
|
||||
}: {
|
||||
item: MultiRoundRagRetrieval | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const content = item?.content;
|
||||
return (
|
||||
<Dialog open={!!item} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
|
||||
<DialogHeader className="pr-8">
|
||||
<DialogTitle className="leading-relaxed">{item?.title || "引用资料"}</DialogTitle>
|
||||
<DialogDescription className="space-y-1 text-xs">
|
||||
{item?.time && <span className="block">时间:{item.time}</span>}
|
||||
{item?.source && <span className="block">来源:{item.source}</span>}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="bg-muted/50 max-h-[min(26rem,calc(100vh-16rem))] overflow-auto rounded-xl p-3 text-sm leading-7 whitespace-pre-wrap">
|
||||
{content || "暂无引用内容"}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export function ModelNameSelect({
|
||||
className,
|
||||
triggerClassName,
|
||||
defaultLabel = "默认模型",
|
||||
title = "选择用于本次操作的大模型(默认使用系统配置的模型)",
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (modelName: string | null) => void;
|
||||
@@ -32,6 +33,7 @@ export function ModelNameSelect({
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
defaultLabel?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const { models } = useModels();
|
||||
return (
|
||||
@@ -42,7 +44,7 @@ export function ModelNameSelect({
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
|
||||
title="选择用于本次操作的大模型(默认使用系统配置的模型)"
|
||||
title={title}
|
||||
>
|
||||
<SelectValue placeholder={defaultLabel} />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -47,9 +47,14 @@ import {
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import {
|
||||
MyProjectThreadBadge,
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
@@ -63,7 +68,8 @@ import {
|
||||
useToggleThreadFavorite,
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||
import {
|
||||
isBootstrapThread,
|
||||
isHiddenFromRecentChats,
|
||||
@@ -92,7 +98,8 @@ export function RecentChatList({
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const pathname = usePathname();
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(
|
||||
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
|
||||
);
|
||||
@@ -134,15 +141,15 @@ export function RecentChatList({
|
||||
});
|
||||
if (threadIndex > -1) {
|
||||
if (threads[threadIndex + 1]) {
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!, pathname);
|
||||
} else if (threads[threadIndex - 1]) {
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!, pathname);
|
||||
}
|
||||
}
|
||||
void router.push(nextThreadPath);
|
||||
}
|
||||
},
|
||||
[agentIdFromPath, deleteThread, router, threadIdFromPath, threads],
|
||||
[agentIdFromPath, deleteThread, pathname, router, threadIdFromPath, threads],
|
||||
);
|
||||
|
||||
const handleRenameClick = useCallback(
|
||||
@@ -243,9 +250,10 @@ export function RecentChatList({
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{shownThreads.map((thread) => {
|
||||
const href = listPathOfThread(thread);
|
||||
const href = listPathOfThread(thread, pathname);
|
||||
const isActive = href === pathname;
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const projectType = myProjectConversationTypeOfThread(thread);
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
return (
|
||||
@@ -267,7 +275,8 @@ export function RecentChatList({
|
||||
>
|
||||
<Link
|
||||
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||
href={href}
|
||||
href={href}
|
||||
onClick={() => { if (isMobile) setOpenMobile(false); }}
|
||||
>
|
||||
{isBoot ? (
|
||||
<Bot
|
||||
@@ -279,8 +288,10 @@ export function RecentChatList({
|
||||
}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : (
|
||||
compact && (
|
||||
) : projectType ? (
|
||||
<MyProjectThreadIcon type={projectType} />
|
||||
) : (
|
||||
compact && (
|
||||
<MessageSquare
|
||||
className={
|
||||
"size-3.5 shrink-0 " +
|
||||
@@ -294,9 +305,15 @@ export function RecentChatList({
|
||||
{pinned && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
{projectType && (
|
||||
<MyProjectThreadBadge
|
||||
className="mr-3"
|
||||
type={projectType}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { ChevronDown, Loader2, MoreHorizontal, Pencil, Share2, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ShareRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
|
||||
import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
|
||||
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
|
||||
import type { DraftMeta } from "@/roundtable-planning/api/drafts";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
|
||||
const ROUNDTABLE_PATH = "/page/workspace/roundtable/planning";
|
||||
|
||||
function openDraft(
|
||||
draft: DraftMeta,
|
||||
isOnPage: boolean,
|
||||
router: ReturnType<typeof useRouter>,
|
||||
) {
|
||||
if (isOnPage) {
|
||||
// 已在会商页面:直接派事件让页面加载该草稿(仿 AI 写作的 open-session)。
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("roundtable:open-draft", { detail: draft.id }),
|
||||
);
|
||||
} else {
|
||||
// 不在该页面:把草稿 id 暂存到 sessionStorage,跳转后由页面消费。
|
||||
sessionStorage.setItem("roundtable:pending-draft", draft.id);
|
||||
router.push(ROUNDTABLE_PATH);
|
||||
}
|
||||
}
|
||||
|
||||
/** 把草稿的后台作业状态 / 步骤映射成侧边栏小标签(仿页面内 HistoryDropdown)。 */
|
||||
function StatusBadge({ draft }: { draft: DraftMeta }) {
|
||||
if (draft.status === "running") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-primary">
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||
进行中
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (draft.status === "awaiting_input") {
|
||||
return <span className="text-[10px] font-medium text-amber-500">待澄清</span>;
|
||||
}
|
||||
if (draft.status === "error") {
|
||||
return <span className="text-[10px] font-medium text-rose-500">出错</span>;
|
||||
}
|
||||
// 非进行中/待澄清/出错时,展示本次会商所用业务链(无链条 → 自由模式)。
|
||||
return (
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
{draft.chainTitle || "自由模式"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecentRoundtableList() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { drafts, loading, refresh, rename, remove } = useRoundtableDrafts();
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => localStorage.getItem("sidebar:recent-roundtable-collapsed") === "true",
|
||||
);
|
||||
|
||||
// 重命名对话框状态。
|
||||
const [renameId, setRenameId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
// 分享对话框 —— 正在分享的草稿 id。
|
||||
const [shareId, setShareId] = useState<string | null>(null);
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
const next = !collapsed;
|
||||
setCollapsed(next);
|
||||
localStorage.setItem("sidebar:recent-roundtable-collapsed", String(next));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const handleRenameSubmit = useCallback(async () => {
|
||||
const id = renameId;
|
||||
const title = renameValue.trim();
|
||||
if (!id || !title) {
|
||||
setRenameId(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await rename(id, title);
|
||||
toast.success("已重命名");
|
||||
} catch {
|
||||
toast.error("重命名失败,请重试");
|
||||
} finally {
|
||||
setRenameId(null);
|
||||
setRenameValue("");
|
||||
}
|
||||
}, [renameId, renameValue, rename]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (draft: DraftMeta) => {
|
||||
if (!window.confirm("确定删除这条会商记录吗?此操作不可恢复。")) return;
|
||||
try {
|
||||
await remove(draft.id);
|
||||
toast.success("已删除会商记录");
|
||||
} catch {
|
||||
toast.error("删除失败,请重试");
|
||||
}
|
||||
},
|
||||
[remove],
|
||||
);
|
||||
|
||||
if (!loading && drafts.length === 0) return null;
|
||||
|
||||
const isOnPage = pathname === ROUNDTABLE_PATH;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
|
||||
<SidebarGroupLabel
|
||||
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
<span>多智能体会商</span>
|
||||
<ChevronDown
|
||||
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent
|
||||
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
|
||||
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
|
||||
}`}
|
||||
>
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{loading && drafts.length === 0 ? (
|
||||
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
|
||||
) : (
|
||||
drafts.slice(0, 10).map((draft) => (
|
||||
<SidebarMenuItem key={draft.id} className="group/side-menu-item">
|
||||
<SidebarMenuButton
|
||||
className={
|
||||
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
|
||||
"hover:text-foreground hover:bg-sidebar-accent/60"
|
||||
}
|
||||
onClick={() => openDraft(draft, isOnPage, router)}
|
||||
>
|
||||
<div className="flex flex-col items-start min-w-0 w-full">
|
||||
<span className="text-[13px] truncate w-full leading-snug">
|
||||
{draft.title || "未命名草稿"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<StatusBadge draft={draft} />
|
||||
<span className="text-[10px] text-muted-foreground">·</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatRelative(draft.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction
|
||||
showOnHover
|
||||
className="bg-background/50 hover:bg-background top-2"
|
||||
>
|
||||
<MoreHorizontal />
|
||||
<span className="sr-only">更多</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-44 rounded-lg"
|
||||
side="right"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setRenameId(draft.id);
|
||||
setRenameValue(draft.title || "");
|
||||
}}
|
||||
>
|
||||
<Pencil className="text-muted-foreground" />
|
||||
<span>重命名</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setShareId(draft.id)}>
|
||||
<Share2 className="text-muted-foreground" />
|
||||
<span>分享</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => void handleDelete(draft)}>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
<span>删除</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* 重命名对话框 */}
|
||||
<Dialog open={renameId !== null} onOpenChange={(o) => !o && setRenameId(null)}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>重命名会商</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
placeholder="输入新名称"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !isIMEComposing(e)) {
|
||||
e.preventDefault();
|
||||
void handleRenameSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRenameId(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => void handleRenameSubmit()}>保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 分享对话框 */}
|
||||
<ShareRoundtableDraftDialog
|
||||
draftId={shareId}
|
||||
open={shareId !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setShareId(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
formatRelative,
|
||||
STATUS_LABELS,
|
||||
} from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
|
||||
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
|
||||
import type { AIWritingSessionSummary } from "@/open-canvas/api/ai-writing-sessions";
|
||||
|
||||
const AI_WRITING_PATH = "/page/workspace/ai-writing";
|
||||
|
||||
function openSession(
|
||||
session: AIWritingSessionSummary,
|
||||
isOnPage: boolean,
|
||||
router: ReturnType<typeof useRouter>,
|
||||
) {
|
||||
if (isOnPage) {
|
||||
// 已在 AI 写作页面,直接通过自定义事件通知面板
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("ai-writing:open-session", { detail: session }),
|
||||
);
|
||||
} else {
|
||||
// 不在该页面,把 session 存入 sessionStorage 后跳转
|
||||
sessionStorage.setItem("ai-writing:pending-session", JSON.stringify(session));
|
||||
router.push(AI_WRITING_PATH);
|
||||
}
|
||||
}
|
||||
|
||||
export function RecentWritingList() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { sessions, loading, refresh } = useAIWritingSessions();
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => localStorage.getItem("sidebar:recent-writing-collapsed") === "true",
|
||||
);
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
const next = !collapsed;
|
||||
setCollapsed(next);
|
||||
localStorage.setItem("sidebar:recent-writing-collapsed", String(next));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
if (!loading && sessions.length === 0) return null;
|
||||
|
||||
const isOnPage = pathname === AI_WRITING_PATH;
|
||||
|
||||
return (
|
||||
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
|
||||
<SidebarGroupLabel
|
||||
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
<span>最近写作</span>
|
||||
<ChevronDown
|
||||
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent
|
||||
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
|
||||
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
|
||||
}`}
|
||||
>
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{loading && sessions.length === 0 ? (
|
||||
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
|
||||
) : (
|
||||
sessions.slice(0, 10).map((session) => {
|
||||
const { label, color } = STATUS_LABELS[session.status] ?? {
|
||||
label: session.status,
|
||||
color: "text-muted-foreground",
|
||||
};
|
||||
return (
|
||||
<SidebarMenuItem key={session.id} className="group/side-menu-item">
|
||||
<SidebarMenuButton
|
||||
className={
|
||||
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
|
||||
"hover:text-foreground hover:bg-sidebar-accent/60"
|
||||
}
|
||||
onClick={() => openSession(session, isOnPage, router)}
|
||||
>
|
||||
<div className="flex flex-col items-start min-w-0 w-full">
|
||||
<span className="text-[13px] truncate w-full leading-snug">
|
||||
{session.title}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<span className={cn("text-[10px] font-medium", color)}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">·</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatRelative(session.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
@@ -188,7 +188,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 ?? "full";
|
||||
const globalLayoutMode = appearance?.default_layout_mode ?? "compact";
|
||||
const globalDefaultLabel = useMemo(() => {
|
||||
const customTheme = (appearance?.custom_themes ?? []).find(
|
||||
(preset) => preset.id === globalDefault,
|
||||
@@ -201,7 +201,7 @@ 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 ?? [];
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { BrainIcon, Loader2Icon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useMemoryRuntimeSettings,
|
||||
useUpdateMemoryRuntimeSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
export function MemoryRuntimeSettingsPage() {
|
||||
const { data, isLoading, isError } = useMemoryRuntimeSettings();
|
||||
const update = useUpdateMemoryRuntimeSettings();
|
||||
const enabled = data?.enabled ?? false;
|
||||
|
||||
const handleChange = async (checked: boolean) => {
|
||||
try {
|
||||
await update.mutateAsync({ enabled: checked });
|
||||
toast.success(checked ? "问答记忆已开启" : "问答记忆已关闭");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold">
|
||||
<BrainIcon className="size-5" />
|
||||
问答记忆
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
全局控制问答是否读取和写入长期记忆。系统默认永久关闭,只有管理员在这里显式开启后才会生效。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">启用问答记忆</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
关闭时不会加载记忆中间件、不会向提示词注入记忆,也不会在回答结束后调用模型提取记忆。
|
||||
</p>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={isError || update.isPending}
|
||||
onCheckedChange={handleChange}
|
||||
aria-label="启用问答记忆"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<p className="text-destructive text-sm">
|
||||
无法读取记忆开关,系统将按关闭处理。
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
修改对下一次发起的问答生效;已生成的历史记忆文件不会被自动删除。
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { BellIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||
import { BellIcon, BrainIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -13,17 +13,20 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
|
||||
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 { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-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";
|
||||
|
||||
type SettingsSection =
|
||||
| "account"
|
||||
| "appearance"
|
||||
| "memory"
|
||||
| "tools"
|
||||
| "about";
|
||||
| "memory"
|
||||
| "tools"
|
||||
| "encoder"
|
||||
| "about";
|
||||
|
||||
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
||||
defaultSection?: SettingsSection;
|
||||
@@ -31,7 +34,8 @@ type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
||||
|
||||
export function SettingsDialog(props: SettingsDialogProps) {
|
||||
const { defaultSection = "appearance", ...dialogProps } = props;
|
||||
const { t } = useI18n();
|
||||
const { t } = useI18n();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<SettingsSection>(defaultSection);
|
||||
|
||||
@@ -57,13 +61,13 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
// },
|
||||
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
||||
// 记忆已移至侧边栏入口 (/page/workspace/memory)
|
||||
// {
|
||||
// id: "memory",
|
||||
// label: t.settings.sections.memory,
|
||||
// icon: BrainIcon,
|
||||
// },
|
||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
...(isAdmin
|
||||
? [{ id: "memory", label: "问答记忆", icon: BrainIcon }]
|
||||
: []),
|
||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
...(isAdmin
|
||||
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
|
||||
: []),
|
||||
{
|
||||
id: "account",
|
||||
label: "个人信息",
|
||||
@@ -75,7 +79,8 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
t.settings.sections.appearance,
|
||||
t.settings.sections.memory,
|
||||
t.settings.sections.tools,
|
||||
t.settings.sections.about,
|
||||
t.settings.sections.about,
|
||||
isAdmin,
|
||||
],
|
||||
);
|
||||
return (
|
||||
@@ -124,8 +129,11 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
{activeSection === "appearance" && (
|
||||
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
|
||||
)}
|
||||
{activeSection === "memory" && <MemorySettingsPage />}
|
||||
{activeSection === "tools" && <ToolSettingsPage />}
|
||||
{activeSection === "memory" && isAdmin && (
|
||||
<MemoryRuntimeSettingsPage />
|
||||
)}
|
||||
{activeSection === "tools" && <ToolSettingsPage />}
|
||||
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
|
||||
{activeSection === "about" && <AboutSettingsPage />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -93,6 +93,7 @@ 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";
|
||||
@@ -101,9 +102,16 @@ import { cn } from "@/lib/utils";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { SkillStateSheet } from "./skill-state-sheet";
|
||||
|
||||
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
export function SkillSettingsPage({
|
||||
onClose,
|
||||
embedded = false,
|
||||
}: {
|
||||
onClose?: () => void;
|
||||
embedded?: boolean;
|
||||
} = {}) {
|
||||
const { t } = useI18n();
|
||||
if (embedded) return <SkillSettingsList onClose={onClose} />;
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t.settings.skills.title}
|
||||
description={t.settings.skills.description}
|
||||
@@ -601,6 +609,7 @@ export function SkillRow({
|
||||
isAdmin,
|
||||
layout = "row",
|
||||
onPublisherClick,
|
||||
knowledgeBindings = [],
|
||||
}: {
|
||||
skill: Skill;
|
||||
currentUserId: string | null;
|
||||
@@ -610,6 +619,8 @@ 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();
|
||||
@@ -858,6 +869,20 @@ 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>
|
||||
);
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||
import {
|
||||
resolveWeKnoraSourceTarget,
|
||||
type WeKnoraSourceTarget,
|
||||
} from "@/core/llmwiki/source-links";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
@@ -56,6 +59,7 @@ export function SourceCard({
|
||||
}) {
|
||||
const [relatedOpen, setRelatedOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [linkedTarget, setLinkedTarget] = useState<WeKnoraSourceTarget | null>(null);
|
||||
const [settings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const previewTarget = resolveWeKnoraSourceTarget({
|
||||
@@ -65,7 +69,11 @@ export function SourceCard({
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||||
const isWikiIndexSource =
|
||||
source.type === "llmwiki_wiki_index_intro" ||
|
||||
source.raw?.type === "llmwiki_wiki_index_intro";
|
||||
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
|
||||
const opensWikiDrawer = Boolean(previewTarget) || isWikiIndexSource;
|
||||
const matchedHeading = isWikiSource
|
||||
? String(
|
||||
(source.raw?.matched_heading as string | undefined) ||
|
||||
@@ -78,7 +86,9 @@ export function SourceCard({
|
||||
const summary = isWikiSource && typeof source.raw?.summary === "string"
|
||||
? source.raw.summary
|
||||
: "";
|
||||
const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
|
||||
// A retrieval card represents the matched passage. A document/Wiki summary
|
||||
// is useful only when the result did not include the actual passage.
|
||||
const snippet = plainTextReferencePreview(content || summary).slice(0, 280);
|
||||
const score =
|
||||
typeof source.score === "number"
|
||||
? source.score <= 1
|
||||
@@ -95,9 +105,13 @@ export function SourceCard({
|
||||
score ? `相关度 ${score}` : null,
|
||||
].filter(Boolean);
|
||||
const isCitation = source.mode === "citation";
|
||||
const showReferenceNumber = isCitation;
|
||||
// Tool-trace cards are evidence previews, not the answer's reference list.
|
||||
// Only the right-side reference panel receives `referenceTarget`, so keep
|
||||
// citation numbers there and avoid presenting a second, misleading number
|
||||
// in the middle retrieval trace.
|
||||
const showReferenceNumber = isCitation && referenceTarget;
|
||||
const openSource = () => {
|
||||
if (previewTarget) {
|
||||
if (opensWikiDrawer) {
|
||||
setDetailOpen(true);
|
||||
} else {
|
||||
setRelatedOpen(true);
|
||||
@@ -174,16 +188,18 @@ export function SourceCard({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{previewTarget && (
|
||||
{(opensWikiDrawer || linkedTarget) && (
|
||||
<WeKnoraSourceDrawer
|
||||
open={detailOpen}
|
||||
target={previewTarget}
|
||||
target={linkedTarget ?? previewTarget}
|
||||
title={source.title}
|
||||
quote={content}
|
||||
inlineContent={isWikiIndexSource ? content : ""}
|
||||
inlineKnowledgeBaseId={String(source.raw?.knowledge_base_id || "")}
|
||||
onOpenChange={setDetailOpen}
|
||||
/>
|
||||
)}
|
||||
{!previewTarget && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
|
||||
{!opensWikiDrawer && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
|
||||
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-3 pr-8">
|
||||
@@ -235,6 +251,18 @@ export function SourceCard({
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
linkClassName={(href) =>
|
||||
resolveWeKnoraSourceTarget({ url: href })?.wikiSlug
|
||||
? "font-medium text-red-600 decoration-red-300 hover:text-red-700 hover:decoration-red-600 dark:text-red-400"
|
||||
: ""
|
||||
}
|
||||
onLinkClick={(event, href) => {
|
||||
const target = resolveWeKnoraSourceTarget({ url: href });
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
setLinkedTarget(target);
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { CheckCircle2, Database, Loader2, RefreshCw, Search, Sparkles } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useAllSkillsAdmin, useSkills } from "@/core/skills/hooks";
|
||||
import { useLlmWikiKnowledgeBases, useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||
import {
|
||||
type SkillKnowledgeTarget,
|
||||
useSkillKnowledgeActions,
|
||||
useSkillKnowledgeBindings,
|
||||
useSkillKnowledgeJobs,
|
||||
} from "@/strategy-components/api/skill-knowledge";
|
||||
|
||||
const PHASE_LABELS: Record<string, string> = {
|
||||
scanning: "扫描知识文件",
|
||||
parsing: "解析资料",
|
||||
extracting: "抽取实体关系",
|
||||
awaiting_review: "等待审核",
|
||||
merging: "合并实体关系",
|
||||
writing_wiki: "写入 Wiki",
|
||||
writing_sources: "上传知识资料",
|
||||
generating_wiki: "分析并生成分类 Wiki",
|
||||
vectorizing_wiki: "Wiki 向量化",
|
||||
syncing_assistant: "同步知识梳理总库",
|
||||
activating: "激活新版本",
|
||||
completed: "完成",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
queued: "等待处理",
|
||||
running: "处理中",
|
||||
awaiting_review: "等待管理员审核",
|
||||
completed: "已完成",
|
||||
partial_failed: "部分失败",
|
||||
failed: "失败",
|
||||
canceled: "已取消",
|
||||
};
|
||||
|
||||
export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean }) {
|
||||
const visibleSkills = useSkills();
|
||||
const allSkills = useAllSkillsAdmin(adminMode);
|
||||
const skills = adminMode ? allSkills.skills : visibleSkills.skills;
|
||||
const isLoading = adminMode ? allSkills.isLoading : visibleSkills.isLoading;
|
||||
const liveSkills = useMemo(() => skills.filter((skill) => (skill.state ?? "active") === "active"), [skills]);
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
||||
const [skillQuery, setSkillQuery] = useState("");
|
||||
const [selectedTargetKey, setSelectedTargetKey] = useState<string | null>(null);
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora");
|
||||
const bindings = useSkillKnowledgeBindings(undefined, !adminMode);
|
||||
const jobs = useSkillKnowledgeJobs(!adminMode);
|
||||
const actions = useSkillKnowledgeActions();
|
||||
|
||||
const targets = useMemo<SkillKnowledgeTarget[]>(() => {
|
||||
return (knowledgeBases.data ?? [])
|
||||
.filter((kb) => {
|
||||
const strategy = kb.indexing_strategy ?? {};
|
||||
const capabilities = kb.capabilities ?? {};
|
||||
const wikiEnabled =
|
||||
kb.type === "wiki" ||
|
||||
kb.wiki_index_enabled === true ||
|
||||
strategy.wiki_enabled === true ||
|
||||
capabilities.wiki_enabled === true ||
|
||||
(kb.wiki_config && Object.keys(kb.wiki_config).length > 0);
|
||||
return kb.can_write && kb.remote_status !== "missing" && !kb.is_conversation_deposit && kb.type !== "faq" && wikiEnabled;
|
||||
})
|
||||
.map((kb) => ({
|
||||
target_type: "weknora",
|
||||
target_mode: "wiki",
|
||||
target_id: kb.id,
|
||||
target_name: kb.name,
|
||||
remote_business_key: `weknora:kb:${kb.id}`,
|
||||
}));
|
||||
}, [knowledgeBases.data]);
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
const needle = skillQuery.trim().toLowerCase();
|
||||
if (!needle) return liveSkills;
|
||||
return liveSkills.filter((skill) => {
|
||||
const tagText = (skill.tags ?? []).map((tag) => tag.name).join(" ");
|
||||
const haystack = [
|
||||
skill.name,
|
||||
skill.name_zh,
|
||||
skill.description,
|
||||
skill.category,
|
||||
tagText,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.toLowerCase();
|
||||
return haystack.includes(needle);
|
||||
});
|
||||
}, [liveSkills, skillQuery]);
|
||||
|
||||
const filteredSkillNames = useMemo(() => filteredSkills.map((skill) => skill.name), [filteredSkills]);
|
||||
const allFilteredSelected = filteredSkillNames.length > 0 && filteredSkillNames.every((name) => selectedSkills.includes(name));
|
||||
|
||||
const toggleFilteredSkills = () => {
|
||||
if (!filteredSkillNames.length) return;
|
||||
setSelectedSkills((current) => {
|
||||
if (allFilteredSelected) {
|
||||
const filtered = new Set(filteredSkillNames);
|
||||
return current.filter((name) => !filtered.has(name));
|
||||
}
|
||||
return [...current, ...filteredSkillNames.filter((name) => !current.includes(name))];
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTargetKey && !targets.some((target) => `${target.target_type}:${target.target_id}` === selectedTargetKey)) {
|
||||
setSelectedTargetKey(null);
|
||||
}
|
||||
}, [selectedTargetKey, targets]);
|
||||
|
||||
const toggle = (values: string[], value: string, setter: (next: string[]) => void) =>
|
||||
setter(values.includes(value) ? values.filter((item) => item !== value) : [...values, value]);
|
||||
|
||||
const submit = async () => {
|
||||
const selectedTargets = targets.filter((target) =>
|
||||
selectedTargetKey === `${target.target_type}:${target.target_id}`,
|
||||
);
|
||||
if (!selectedSkills.length || !selectedTargets.length) {
|
||||
toast.error("请至少选择一个技能和一个目标知识库");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const job = await actions.createJob.mutateAsync({
|
||||
skill_names: selectedSkills,
|
||||
targets: selectedTargets,
|
||||
review_mode: "off",
|
||||
});
|
||||
toast.success(`已创建 ${job.total_count} 个归纳项`);
|
||||
setSelectedSkills([]);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid size-full min-h-0 gap-4 overflow-auto p-4 lg:grid-cols-[minmax(320px,0.9fr)_minmax(420px,1.1fr)] lg:overflow-hidden lg:p-6">
|
||||
<section className="flex min-h-0 flex-col rounded-lg border bg-card">
|
||||
<div className="border-b p-4">
|
||||
<h2 className="font-semibold">归纳技能知识</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">选择技能和一个目标知识库后,系统会在后台提取 Markdown 等知识资料,忽略 Python 等代码文件,自动生成 Wiki、关系和向量索引。</p>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-5 p-4">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">第一步:选择技能(已选 {selectedSkills.length})</span>
|
||||
<Button variant="ghost" size="sm" onClick={toggleFilteredSkills}>
|
||||
{allFilteredSelected ? "清空当前结果" : "选择当前结果"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative mb-2">
|
||||
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={skillQuery}
|
||||
onChange={(event) => setSkillQuery(event.target.value)}
|
||||
placeholder="按技能中文名、英文名、描述、分类或标签搜索"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-60 space-y-1 overflow-y-auto rounded-md border p-2">
|
||||
{isLoading ? <Loader2 className="m-4 size-5 animate-spin" /> : filteredSkills.map((skill) => (
|
||||
<label key={skill.name} className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-muted">
|
||||
<Checkbox checked={selectedSkills.includes(skill.name)} onCheckedChange={() => toggle(selectedSkills, skill.name, setSelectedSkills)} />
|
||||
<span className="truncate text-sm">{skill.name_zh || skill.name}</span>
|
||||
<span className="ml-auto truncate text-xs text-muted-foreground">{skill.name}</span>
|
||||
{(bindings.data ?? []).some((binding) => binding.skill_name === skill.name) && (
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px]">
|
||||
{(bindings.data ?? []).some((binding) => binding.skill_name === skill.name && `${binding.target_type}:${binding.target_id}` === selectedTargetKey)
|
||||
? "已在目标库"
|
||||
: `已归纳 ${(bindings.data ?? []).filter((binding) => binding.skill_name === skill.name).length} 库`}
|
||||
</Badge>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{!isLoading && !filteredSkills.length && (
|
||||
<p className="p-3 text-xs text-muted-foreground">没有匹配的技能,请换个关键词。</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
当前显示 {filteredSkills.length}/{liveSkills.length} 个技能。
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">第二步:选择一个目标知识库</span>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-md border p-2">
|
||||
{targets.map((target) => {
|
||||
const key = `${target.target_type}:${target.target_id}`;
|
||||
return (
|
||||
<label key={key} className="flex cursor-pointer items-center gap-2 rounded px-2 py-2 hover:bg-muted">
|
||||
<Checkbox
|
||||
checked={selectedTargetKey === key}
|
||||
onCheckedChange={() => setSelectedTargetKey((current) => (current === key ? null : key))}
|
||||
/>
|
||||
<Database className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm">{target.target_name}</span>
|
||||
<Badge variant="outline" className="ml-auto text-[10px]">普通 Wiki 库</Badge>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
|
||||
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
|
||||
<p className="p-3 text-xs text-destructive">
|
||||
读取普通知识库失败:
|
||||
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
|
||||
</p>
|
||||
)}
|
||||
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
|
||||
<p className="p-3 text-xs text-muted-foreground">
|
||||
{runtime.data?.provider !== "weknora"
|
||||
? "当前未配置普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
||||
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/30 p-3 text-xs leading-5 text-muted-foreground">
|
||||
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及同步到“知识梳理”总库均在后台完成。
|
||||
</div>
|
||||
<Button className="w-full" disabled={actions.createJob.isPending || !selectedSkills.length || !selectedTargetKey} onClick={() => void submit()}>
|
||||
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
|
||||
开始归纳 {selectedSkills.length ? `${selectedSkills.length} 个技能` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-0 flex-col gap-4 overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col rounded-lg border bg-card">
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div><h2 className="font-semibold">我的归纳任务</h2><p className="text-xs text-muted-foreground">任务会自动刷新,失败项可直接重试</p></div>
|
||||
<Button size="icon" variant="ghost" onClick={() => void jobs.refetch()}><RefreshCw className="size-4" /></Button>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-3 p-4">
|
||||
{(jobs.data ?? []).map((job) => {
|
||||
const countedDone = job.success_count + job.partial_failed_count + job.failed_count;
|
||||
const itemDone = (job.items ?? []).filter((item) =>
|
||||
["completed", "partial_failed", "failed", "canceled"].includes(item.status),
|
||||
).length;
|
||||
const done = Math.max(countedDone, itemDone);
|
||||
const progress = job.total_count ? Math.round((done / job.total_count) * 100) : 0;
|
||||
return (
|
||||
<div key={job.id} className="rounded-md border p-3">
|
||||
<div className="flex items-center gap-2 text-sm"><Badge variant={job.status === "completed" ? "default" : "secondary"}>{STATUS_LABELS[job.status] || job.status}</Badge><span>{done}/{job.total_count} 项</span><span className="ml-auto text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString()}</span></div>
|
||||
<Progress className="my-2" value={progress} />
|
||||
{job.items?.map((item) => (
|
||||
<div key={item.id} className="mt-2 flex items-center gap-2 border-t pt-2 text-xs">
|
||||
<span className="max-w-36 truncate font-medium">{item.skill_name}</span>
|
||||
<span className="text-muted-foreground">{PHASE_LABELS[item.phase] || STATUS_LABELS[item.status] || item.phase}</span>
|
||||
<span className="ml-auto">{item.progress}%</span>
|
||||
{item.status === "failed" && <Button size="sm" variant="outline" onClick={() => actions.retry.mutate(item.id)}>重试</Button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!jobs.data?.length && <p className="py-10 text-center text-sm text-muted-foreground">暂无归纳任务</p>}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-auto rounded-lg border bg-card p-3 text-xs">
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground"><CheckCircle2 className="size-4 text-emerald-600" />已归纳关联:{bindings.data?.length ?? 0}</div>
|
||||
<div className="space-y-2">{bindings.data?.map((binding) => <div key={binding.id} className="flex flex-wrap items-center gap-2 rounded border p-2"><span className="max-w-40 truncate font-medium">{binding.skill_name}</span><span className="max-w-48 truncate text-muted-foreground">已归纳到 {binding.target_name}</span><Badge variant="outline">{STATUS_LABELS[binding.status] || binding.status}</Badge><Button size="sm" variant="outline" className="ml-auto" disabled={actions.resync.isPending} onClick={() => actions.resync.mutate(binding.id)}>重新归纳</Button><Button size="sm" variant="ghost" onClick={() => { if (window.confirm("解除关联并保留目标知识内容?")) actions.detach.mutate({ id: binding.id, deleteContributions: false }); }}>解除关联</Button>{adminMode && <Button size="sm" variant="destructive" onClick={() => { if (window.confirm("解除关联并删除此技能贡献?共享知识仍会保留其他来源。")) actions.detach.mutate({ id: binding.id, deleteContributions: true }); }}>删除贡献</Button>}</div>)}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,16 +3,18 @@ import { useEffect } from "react";
|
||||
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useThreadChat } from "./chats";
|
||||
import { FlipDisplay } from "./flip-display";
|
||||
|
||||
export function ThreadTitle({
|
||||
threadId,
|
||||
thread,
|
||||
}: {
|
||||
export function ThreadTitle({
|
||||
className,
|
||||
threadId,
|
||||
thread,
|
||||
}: {
|
||||
className?: string;
|
||||
threadId: string;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
@@ -47,9 +49,9 @@ export function ThreadTitle({
|
||||
if (!thread.values?.title) {
|
||||
return null;
|
||||
}
|
||||
const visibleTitle = desensitize(displayTitle);
|
||||
return (
|
||||
<FlipDisplay uniqueKey={threadId} className="min-w-0 w-full">
|
||||
const visibleTitle = desensitize(displayTitle);
|
||||
return (
|
||||
<FlipDisplay uniqueKey={threadId} className={cn("min-w-0 w-full", className)}>
|
||||
<span className="block truncate" title={visibleTitle}>
|
||||
{visibleTitle}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as maplibregl from "maplibre-gl";
|
||||
import type { StyleSpecification } from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import { AlertTriangle, MapPinned, RefreshCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getMapTileAttribution, getMapTileUrl } from "@/core/utils/map-tile-config";
|
||||
|
||||
import type { MapSpec } from "./visualization-spec";
|
||||
import { VisualizationCard } from "./visualization-card";
|
||||
|
||||
function createStyle(): StyleSpecification {
|
||||
return {
|
||||
version: 8,
|
||||
sources: {
|
||||
"google-raster": {
|
||||
type: "raster",
|
||||
tiles: [getMapTileUrl()],
|
||||
tileSize: 256,
|
||||
attribution: getMapTileAttribution(),
|
||||
},
|
||||
},
|
||||
layers: [{ id: "google-raster", type: "raster", source: "google-raster" }],
|
||||
};
|
||||
}
|
||||
|
||||
function allCoordinates(spec: MapSpec): Array<[number, number]> {
|
||||
return [
|
||||
...spec.markers.map((marker) => marker.coordinate),
|
||||
...spec.lines.flatMap((line) => line.coordinates),
|
||||
...spec.polygons.flatMap((polygon) => polygon.coordinates),
|
||||
];
|
||||
}
|
||||
|
||||
function defaultCenter(spec: MapSpec): [number, number] {
|
||||
if (spec.center) return spec.center;
|
||||
const coordinates = allCoordinates(spec);
|
||||
if (coordinates.length === 0) return [104.1954, 35.8617];
|
||||
const total = coordinates.reduce(([lng, lat], coordinate) => [lng + coordinate[0], lat + coordinate[1]], [0, 0]);
|
||||
return [total[0] / coordinates.length, total[1] / coordinates.length];
|
||||
}
|
||||
|
||||
export default function MapRenderer({ spec }: { spec: MapSpec }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
const [tileFailed, setTileFailed] = useState(false);
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
const coordinates = useMemo(() => allCoordinates(spec), [spec]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
setTileFailed(false);
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container,
|
||||
style: createStyle(),
|
||||
center: defaultCenter(spec),
|
||||
zoom: spec.zoom ?? (coordinates.length > 1 ? 4 : 8),
|
||||
minZoom: 1,
|
||||
maxZoom: 20,
|
||||
attributionControl: { compact: true },
|
||||
});
|
||||
mapRef.current = map;
|
||||
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
|
||||
|
||||
const markers: maplibregl.Marker[] = [];
|
||||
for (const marker of spec.markers) {
|
||||
const element = document.createElement("button");
|
||||
element.type = "button";
|
||||
element.className = "size-5 rounded-full border-2 border-white bg-primary shadow-md focus:outline-none focus:ring-2 focus:ring-primary/50";
|
||||
element.title = marker.name;
|
||||
element.setAttribute("aria-label", marker.name);
|
||||
const popup = new maplibregl.Popup({ offset: 14 }).setHTML(
|
||||
`<div style="max-width:240px"><strong>${escapeHtml(marker.name)}</strong>${marker.description ? `<p style="margin:6px 0 0">${escapeHtml(marker.description)}</p>` : ""}</div>`,
|
||||
);
|
||||
markers.push(new maplibregl.Marker({ element }).setLngLat(marker.coordinate).setPopup(popup).addTo(map));
|
||||
}
|
||||
|
||||
let baseTileLoaded = false;
|
||||
const onSourceData = (event: maplibregl.MapSourceDataEvent) => {
|
||||
if (event.sourceId !== "google-raster" || event.sourceDataType !== "content") return;
|
||||
baseTileLoaded = true;
|
||||
setTileFailed(false);
|
||||
};
|
||||
const onError = (event: maplibregl.ErrorEvent) => {
|
||||
const message = event.error?.message ?? "";
|
||||
if (!baseTileLoaded && /tile|fetch|network|request|http/i.test(message)) setTileFailed(true);
|
||||
};
|
||||
map.on("sourcedata", onSourceData);
|
||||
map.on("error", onError);
|
||||
|
||||
map.on("load", () => {
|
||||
if (spec.lines.length > 0) {
|
||||
map.addSource("visual-lines", {
|
||||
type: "geojson",
|
||||
data: {
|
||||
type: "FeatureCollection",
|
||||
features: spec.lines.map((line) => ({
|
||||
type: "Feature",
|
||||
properties: { name: line.name ?? "" },
|
||||
geometry: { type: "LineString", coordinates: line.coordinates },
|
||||
})),
|
||||
},
|
||||
});
|
||||
map.addLayer({
|
||||
id: "visual-lines",
|
||||
type: "line",
|
||||
source: "visual-lines",
|
||||
paint: { "line-color": "#2563eb", "line-width": 3, "line-opacity": 0.85 },
|
||||
});
|
||||
}
|
||||
|
||||
if (spec.polygons.length > 0) {
|
||||
map.addSource("visual-polygons", {
|
||||
type: "geojson",
|
||||
data: {
|
||||
type: "FeatureCollection",
|
||||
features: spec.polygons.map((polygon) => ({
|
||||
type: "Feature",
|
||||
properties: { name: polygon.name ?? "" },
|
||||
geometry: { type: "Polygon", coordinates: [[...polygon.coordinates, polygon.coordinates[0]]] },
|
||||
})),
|
||||
},
|
||||
});
|
||||
map.addLayer({
|
||||
id: "visual-polygons-fill",
|
||||
type: "fill",
|
||||
source: "visual-polygons",
|
||||
paint: { "fill-color": "#2563eb", "fill-opacity": 0.18 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: "visual-polygons-border",
|
||||
type: "line",
|
||||
source: "visual-polygons",
|
||||
paint: { "line-color": "#2563eb", "line-width": 2 },
|
||||
});
|
||||
}
|
||||
|
||||
if (!spec.center && coordinates.length > 1) {
|
||||
const bounds = coordinates.reduce(
|
||||
(current, coordinate) => current.extend(coordinate),
|
||||
new maplibregl.LngLatBounds(coordinates[0], coordinates[0]),
|
||||
);
|
||||
map.fitBounds(bounds, { padding: 52, maxZoom: 12, duration: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
map.off("sourcedata", onSourceData);
|
||||
map.off("error", onError);
|
||||
markers.forEach((marker) => marker.remove());
|
||||
mapRef.current = null;
|
||||
map.remove();
|
||||
};
|
||||
}, [coordinates, reloadToken, spec]);
|
||||
|
||||
return (
|
||||
<VisualizationCard>
|
||||
<div className="flex min-h-12 items-center gap-2 border-b px-3 py-2">
|
||||
<MapPinned aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
||||
<span className="truncate text-sm font-medium">{spec.title}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{spec.markers.length} 个地点</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div ref={containerRef} className="h-[420px] w-full" role="img" aria-label={`${spec.title},包含 ${spec.markers.length} 个地点`} />
|
||||
{tileFailed ? (
|
||||
<div className="absolute inset-x-3 top-3 z-10 flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50/95 p-3 text-xs text-amber-900 shadow-sm" role="status">
|
||||
<AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
||||
<span className="flex-1">地图底图当前不可访问;请检查网络。地点和线路数据不会丢失。</span>
|
||||
<Button
|
||||
aria-label="重新加载地图"
|
||||
className="shrink-0 text-amber-950 hover:bg-amber-100"
|
||||
onClick={() => {
|
||||
setTileFailed(false);
|
||||
setReloadToken((value) => value + 1);
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{spec.markers.length > 0 ? (
|
||||
<details className="border-t px-3 py-2 text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer select-none">查看地点列表</summary>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{spec.markers.map((marker, index) => (
|
||||
<li key={`${marker.name}-${index}`}>
|
||||
<span className="font-medium text-foreground">{marker.name}</span>
|
||||
<span>({marker.coordinate[0]},{marker.coordinate[1]})</span>
|
||||
{marker.description ? <span>:{marker.description}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
</VisualizationCard>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>'"]/g, (character) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"'": "'",
|
||||
'"': """,
|
||||
})[character] ?? character);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Component, lazy, Suspense, useMemo, type ErrorInfo, type ReactNode } from "react";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
parseVisualizationSpec,
|
||||
type VisualizationLanguage,
|
||||
} from "./visualization-spec";
|
||||
|
||||
const MapRenderer = lazy(() => import("./map-renderer"));
|
||||
|
||||
type VisualizationBlockProps = {
|
||||
language: VisualizationLanguage;
|
||||
source: string;
|
||||
complete: boolean;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
function LoadingCard({ label = "正在生成地图内容…" }: { label?: string }) {
|
||||
return (
|
||||
<div
|
||||
className="my-4 flex min-h-32 w-full max-w-none items-center justify-center rounded-xl border bg-muted/20 text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
<Loader2 aria-hidden="true" className="mr-2 size-4 animate-spin" />
|
||||
<span className="text-sm">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorCard({ message, source }: { message: string; source?: string }) {
|
||||
return (
|
||||
<div
|
||||
className="my-4 w-full max-w-none rounded-xl border border-destructive/30 bg-destructive/5 p-4"
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">地图内容暂时无法显示</div>
|
||||
<div className="mt-1 text-xs opacity-80">{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
{source ? (
|
||||
<details className="mt-3 text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer select-none">查看原始数据</summary>
|
||||
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded-lg bg-muted/50 p-3">
|
||||
{source}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
class VisualizationErrorBoundary extends Component<
|
||||
{ children: ReactNode; source: string },
|
||||
{ error: string | null }
|
||||
> {
|
||||
state = { error: null as string | null };
|
||||
|
||||
static getDerivedStateFromError(error: unknown) {
|
||||
return { error: error instanceof Error ? error.message : "地图渲染失败" };
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown, info: ErrorInfo) {
|
||||
console.error("Map renderer failed", error, info.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return <ErrorCard message={this.state.error} source={this.props.source} />;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export function VisualizationBlock({
|
||||
language,
|
||||
source,
|
||||
complete,
|
||||
isLoading,
|
||||
}: VisualizationBlockProps) {
|
||||
const result = useMemo(
|
||||
() => parseVisualizationSpec(language, source),
|
||||
[language, source],
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
if (isLoading && !complete) return <LoadingCard />;
|
||||
return <ErrorCard message={result.error} source={source} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<VisualizationErrorBoundary key={`${language}-${source.length}`} source={source}>
|
||||
<Suspense fallback={<LoadingCard label="正在加载本地地图组件…" />}>
|
||||
<MapRenderer spec={result.spec} />
|
||||
</Suspense>
|
||||
</VisualizationErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function VisualizationCard({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"my-4 w-full max-w-none overflow-hidden rounded-xl border bg-background shadow-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// @ts-expect-error Node's type-stripping test runner requires the explicit .ts suffix.
|
||||
import { parseVisualizationSpec, splitVisualizationContent } from "./visualization-spec.ts";
|
||||
|
||||
test("ordinary markdown remains one unchanged segment", () => {
|
||||
const content = "普通回答\n\n```ts\nconst value = 1;\n```";
|
||||
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
|
||||
});
|
||||
|
||||
test("splits a dedicated map block while keeping surrounding markdown", () => {
|
||||
const content = [
|
||||
"下面是地图:\n",
|
||||
"```map-json",
|
||||
'{"title":"地点","markers":[{"name":"北京","coordinate":[116.4,39.9]}]}',
|
||||
"```",
|
||||
"\n地图结束。",
|
||||
].join("\n");
|
||||
const segments = splitVisualizationContent(content);
|
||||
assert.equal(segments.length, 3);
|
||||
assert.equal(segments[0]?.type, "markdown");
|
||||
assert.equal(segments[1]?.type, "visualization");
|
||||
assert.equal(segments[2]?.type, "markdown");
|
||||
});
|
||||
|
||||
test("accepts the map contract", () => {
|
||||
const map = parseVisualizationSpec(
|
||||
"map-json",
|
||||
JSON.stringify({ markers: [{ name: "北京", coordinate: [116.4, 39.9] }] }),
|
||||
);
|
||||
assert.equal(map.ok, true);
|
||||
});
|
||||
|
||||
test("renders a valid map contract from a generic json fence", () => {
|
||||
const content = [
|
||||
"下面是地图:",
|
||||
"```json",
|
||||
'{"title":"城市分布","markers":[{"name":"北京","coordinate":[116.4,39.9]}]}',
|
||||
"```",
|
||||
].join("\n");
|
||||
const segments = splitVisualizationContent(content);
|
||||
const visual = segments.find((segment) => segment.type === "visualization");
|
||||
assert.ok(visual && visual.type === "visualization");
|
||||
assert.equal(visual.language, "map-json");
|
||||
});
|
||||
|
||||
test("keeps non-map json fences on the ordinary Markdown path", () => {
|
||||
const content = '```json\n{"name":"普通数据","items":[1,2,3]}\n```';
|
||||
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
|
||||
});
|
||||
|
||||
test("keeps unfinished generic json fences on the ordinary Markdown path", () => {
|
||||
const content = '```json\n{"markers":[';
|
||||
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
|
||||
});
|
||||
|
||||
test("rejects invalid map coordinates", () => {
|
||||
const result = parseVisualizationSpec(
|
||||
"map-json",
|
||||
JSON.stringify({ markers: [{ name: "错误地点", coordinate: [200, 100] }] }),
|
||||
);
|
||||
assert.equal(result.ok, false);
|
||||
});
|
||||
|
||||
test("flow and chart fences stay on the ordinary Markdown path", () => {
|
||||
const content = "```flow-json\n{}\n```\n```chart-json\n{}\n```";
|
||||
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
|
||||
});
|
||||
|
||||
test("keeps an unfinished map fence for streaming fallback", () => {
|
||||
const segments = splitVisualizationContent("说明\n```map-json\n{\"title\":\"地点\"");
|
||||
const visual = segments.find((segment) => segment.type === "visualization");
|
||||
assert.ok(visual && visual.type === "visualization");
|
||||
assert.equal(visual.complete, false);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const VISUALIZATION_LANGUAGES = ["map-json"] as const;
|
||||
|
||||
export type VisualizationLanguage = (typeof VISUALIZATION_LANGUAGES)[number];
|
||||
|
||||
const safeText = z.string().trim().min(1).max(500);
|
||||
const optionalText = z.string().trim().max(2_000).optional();
|
||||
|
||||
const longitude = z.number().finite().min(-180).max(180);
|
||||
const latitude = z.number().finite().min(-90).max(90);
|
||||
const coordinateSchema = z.tuple([longitude, latitude]);
|
||||
|
||||
const mapSchema = z.object({
|
||||
type: z.literal("map").default("map"),
|
||||
title: z.string().trim().max(200).default("地图"),
|
||||
center: coordinateSchema.optional(),
|
||||
zoom: z.number().finite().min(1).max(20).optional(),
|
||||
markers: z
|
||||
.array(
|
||||
z.object({
|
||||
name: safeText,
|
||||
coordinate: coordinateSchema,
|
||||
description: optionalText,
|
||||
}),
|
||||
)
|
||||
.max(500)
|
||||
.default([]),
|
||||
lines: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().max(120).optional(),
|
||||
coordinates: z.array(coordinateSchema).min(2).max(2_000),
|
||||
}),
|
||||
)
|
||||
.max(100)
|
||||
.default([]),
|
||||
polygons: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().max(120).optional(),
|
||||
coordinates: z.array(coordinateSchema).min(3).max(2_000),
|
||||
}),
|
||||
)
|
||||
.max(100)
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export type MapSpec = z.infer<typeof mapSchema>;
|
||||
|
||||
export type VisualizationParseResult =
|
||||
| { ok: true; spec: MapSpec }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function normalizeLanguageType(
|
||||
language: VisualizationLanguage,
|
||||
value: unknown,
|
||||
): unknown {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||
const record = { ...(value as Record<string, unknown>) };
|
||||
if (language === "map-json") record.type = "map";
|
||||
return record;
|
||||
}
|
||||
|
||||
function formatZodError(error: z.ZodError): string {
|
||||
const issue = error.issues[0];
|
||||
if (!issue) return "地图数据格式不正确";
|
||||
const path = issue.path.length > 0 ? `${issue.path.join(".")}:` : "";
|
||||
return `${path}${issue.message}`;
|
||||
}
|
||||
|
||||
export function parseVisualizationSpec(
|
||||
language: VisualizationLanguage,
|
||||
source: string,
|
||||
): VisualizationParseResult {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(source);
|
||||
} catch {
|
||||
return { ok: false, error: "地图数据尚未完整或不是有效 JSON" };
|
||||
}
|
||||
|
||||
const normalized = normalizeLanguageType(language, raw);
|
||||
if (!normalized || typeof normalized !== "object" || Array.isArray(normalized)) {
|
||||
return { ok: false, error: "地图数据必须是 JSON 对象" };
|
||||
}
|
||||
|
||||
const parsed = mapSchema.safeParse(normalized);
|
||||
if (!parsed.success) return { ok: false, error: formatZodError(parsed.error) };
|
||||
return { ok: true, spec: parsed.data };
|
||||
}
|
||||
|
||||
export type VisualizationSegment =
|
||||
| { type: "markdown"; content: string }
|
||||
| {
|
||||
type: "visualization";
|
||||
language: VisualizationLanguage;
|
||||
source: string;
|
||||
complete: boolean;
|
||||
};
|
||||
|
||||
const FENCE_PATTERN = /(^|\n)```(map-json|json)[ \t]*\r?\n/gi;
|
||||
|
||||
function isMapJsonSource(source: string): boolean {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(source);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const hasMapField = ["center", "zoom", "markers", "lines", "polygons"].some(
|
||||
(field) => Object.prototype.hasOwnProperty.call(record, field),
|
||||
);
|
||||
return hasMapField && parseVisualizationSpec("map-json", source).ok;
|
||||
}
|
||||
|
||||
/** Split only our dedicated fences. With no match, the caller keeps the exact legacy renderer path. */
|
||||
export function splitVisualizationContent(content: string): VisualizationSegment[] {
|
||||
const segments: VisualizationSegment[] = [];
|
||||
let cursor = 0;
|
||||
FENCE_PATTERN.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = FENCE_PATTERN.exec(content)) !== null) {
|
||||
const blockStart = match.index + match[1].length;
|
||||
if (blockStart > cursor) {
|
||||
segments.push({ type: "markdown", content: content.slice(cursor, blockStart) });
|
||||
}
|
||||
const sourceStart = FENCE_PATTERN.lastIndex;
|
||||
const closeIndex = content.indexOf("```", sourceStart);
|
||||
const complete = closeIndex >= 0;
|
||||
const source = content.slice(sourceStart, complete ? closeIndex : content.length).trim();
|
||||
const matchedLanguage = match[2].toLowerCase();
|
||||
if (matchedLanguage === "json" && (!complete || !isMapJsonSource(source))) {
|
||||
if (!complete) break;
|
||||
FENCE_PATTERN.lastIndex = closeIndex + 3;
|
||||
continue;
|
||||
}
|
||||
segments.push({
|
||||
type: "visualization",
|
||||
language: "map-json",
|
||||
source,
|
||||
complete,
|
||||
});
|
||||
cursor = complete ? closeIndex + 3 : content.length;
|
||||
FENCE_PATTERN.lastIndex = cursor;
|
||||
if (!complete) break;
|
||||
}
|
||||
|
||||
if (cursor < content.length) {
|
||||
segments.push({ type: "markdown", content: content.slice(cursor) });
|
||||
}
|
||||
return segments.length > 0 ? segments : [{ type: "markdown", content }];
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { BookOpen, BotIcon, BrainIcon, FileSearch, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, FolderKanban, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||
|
||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
applyCompactMenuOverrides,
|
||||
type CompactMenuItem,
|
||||
} from "@/core/page-layout/compact-menu-overrides";
|
||||
import { type MenuItem } from "@/core/page-layout/sidebar-menu";
|
||||
import {
|
||||
MARKDOWN_DOCX_MENU_ITEM,
|
||||
type MenuItem,
|
||||
} from "@/core/page-layout/sidebar-menu";
|
||||
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
|
||||
|
||||
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
||||
@@ -51,6 +54,13 @@ export type WorkspaceCompactNavItem = MenuItem & {
|
||||
};
|
||||
|
||||
export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
{
|
||||
id: "workspace-projects",
|
||||
label: "项目空间",
|
||||
icon: BookOpen,
|
||||
path: "/page/workspace/projects",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/projects"),
|
||||
},
|
||||
{
|
||||
id: "workspace-new-chat",
|
||||
label: "新对话",
|
||||
@@ -65,6 +75,22 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
path: "/page/workspace/chats",
|
||||
match: (pathname) => pathname === "/page/workspace/chats",
|
||||
},
|
||||
{
|
||||
id: "strategy-projects",
|
||||
label: "我的项目",
|
||||
icon: FolderKanban,
|
||||
path: "/page/strategy/qa/projects",
|
||||
match: (pathname) =>
|
||||
pathname.startsWith("/page/strategy/qa/projects") ||
|
||||
pathname.startsWith("/page/strategy/qa/strategy-selection"),
|
||||
},
|
||||
{
|
||||
id: "workspace-library",
|
||||
label: "资料库",
|
||||
icon: Library,
|
||||
path: "/page/workspace/library",
|
||||
match: (pathname) => pathname === "/page/workspace/library",
|
||||
},
|
||||
{
|
||||
id: "workspace-agents",
|
||||
label: "智能体",
|
||||
@@ -79,13 +105,6 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
path: "/page/workspace/skills",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/skills"),
|
||||
},
|
||||
{
|
||||
id: "workspace-memory",
|
||||
label: "记忆",
|
||||
icon: BrainIcon,
|
||||
path: "/page/workspace/memory",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/memory"),
|
||||
},
|
||||
{
|
||||
id: "workspace-ai-writing",
|
||||
label: "AI写作",
|
||||
@@ -101,6 +120,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/deep-research"),
|
||||
},
|
||||
// {
|
||||
// id: "workspace-report-collaboration",
|
||||
// label: "报告协作",
|
||||
// icon: FileText,
|
||||
// path: "/page/workspace/report-collaboration",
|
||||
// match: (pathname) => pathname.startsWith("/page/workspace/report-collaboration"),
|
||||
// },
|
||||
// {
|
||||
// id: "workspace-enterprise-research",
|
||||
// label: "企业深度研究",
|
||||
// icon: FileSearch,
|
||||
@@ -126,7 +152,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
label: "知识库管理",
|
||||
icon: BookOpen,
|
||||
path: "/page/workspace/knowledge",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/knowledge"),
|
||||
match: (pathname) =>
|
||||
pathname.startsWith("/page/workspace/knowledge") &&
|
||||
!pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
||||
},
|
||||
{
|
||||
...MARKDOWN_DOCX_MENU_ITEM,
|
||||
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ export function WorkspaceNavMenu() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
|
||||
"appearance" | "memory" | "tools" | "about"
|
||||
"appearance" | "memory" | "tools" | "encoder" | "about"
|
||||
>("appearance");
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -189,6 +189,17 @@ export function WorkspaceNavMenu() {
|
||||
<Settings2Icon />
|
||||
{t.common.settings}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSettingsDefaultSection("encoder");
|
||||
setSettingsOpen(true);
|
||||
}}
|
||||
>
|
||||
<CpuIcon />
|
||||
本地编码器
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
|
||||
@@ -14,8 +14,6 @@ import { usePathname } from "next/navigation";
|
||||
|
||||
import { GeneralQaSidebar } from "./general-qa-sidebar";
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
import { RecentRoundtableList } from "./recent-roundtable-list";
|
||||
import { RecentWritingList } from "./recent-writing-list";
|
||||
import { WorkspaceHeader } from "./workspace-header";
|
||||
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||
@@ -23,11 +21,14 @@ import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||
export function WorkspaceSidebar({
|
||||
compactChrome = false,
|
||||
forceCollapsed = false,
|
||||
wxCommonAgents = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar> & {
|
||||
compactChrome?: boolean;
|
||||
forceCollapsed?: boolean;
|
||||
/** Enables wx's shared Agent Gallery category selection. */
|
||||
wxCommonAgents?: boolean;
|
||||
}) {
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { theme } = useTheme();
|
||||
@@ -36,26 +37,30 @@ export function WorkspaceSidebar({
|
||||
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
|
||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
||||
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
||||
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
|
||||
|
||||
if (forceCollapsed) {
|
||||
return (
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="icon"
|
||||
className={cn(
|
||||
"bg-sidebar [&_[data-slot=sidebar-inner]]:bg-sidebar",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList />
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const isGeneralQa =
|
||||
wxCommonAgents ||
|
||||
pathname.startsWith("/page/workspace/projects") ||
|
||||
pathname.startsWith("/page/workspace/chats") ||
|
||||
pathname.startsWith("/page/strategy/chats");
|
||||
|
||||
if (forceCollapsed) {
|
||||
return (
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="icon"
|
||||
className={cn(
|
||||
"bg-sidebar [&_[data-slot=sidebar-inner]]:bg-sidebar",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList />
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
|
||||
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
|
||||
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
|
||||
@@ -84,7 +89,7 @@ export function WorkspaceSidebar({
|
||||
{isGeneralQa ? (
|
||||
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
||||
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
||||
<GeneralQaSidebar />
|
||||
<GeneralQaSidebar wxCommonAgents={wxCommonAgents} />
|
||||
) : (
|
||||
<>
|
||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||
@@ -123,10 +128,10 @@ export function WorkspaceSidebar({
|
||||
compact={compactChrome}
|
||||
/>
|
||||
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
|
||||
{/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐:
|
||||
仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */}
|
||||
{isSidebarOpen && !compactChrome && <RecentRoundtableList />}
|
||||
{isSidebarOpen && !compactChrome && <RecentWritingList />}
|
||||
{/* 「多智能体会商」「最近写作」历史入口已移至「对话历史」页
|
||||
(/page/workspace/chats 的 Tabs,2026-08 需求):简洁模式(本分支,
|
||||
无全局导航栏/页面侧栏时的工作区侧栏)不再在左下角展示这两个分组,
|
||||
全量模式本就不展示。 */}
|
||||
</SidebarContent>
|
||||
{!compactChrome && (
|
||||
<SidebarFooter>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export type ProjectMode = "qa" | "strategy" | "solution";
|
||||
export type ProjectReportConfig = {
|
||||
custom_outline: string;
|
||||
structure_mode: "fixed" | "adaptive";
|
||||
retrieval_directions: string[];
|
||||
retrieval_skills: string[];
|
||||
};
|
||||
export type ProjectStrategyConfig = { lenses: string[] };
|
||||
export const DEFAULT_STRATEGY_LENSES = [
|
||||
"增长视角",
|
||||
"风险视角",
|
||||
"竞争视角",
|
||||
"资源视角",
|
||||
"长期价值视角",
|
||||
] as const;
|
||||
export type ProjectFields = {
|
||||
name: string;
|
||||
background: string;
|
||||
knowledge_base_ids: string[];
|
||||
assistant_knowledge_base_ids: string[];
|
||||
agent_id: string | null;
|
||||
report_config: ProjectReportConfig;
|
||||
strategy_config: ProjectStrategyConfig;
|
||||
};
|
||||
export type AnalysisProject = ProjectFields & {
|
||||
id: string;
|
||||
mode: ProjectMode;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
solution_template: { id: string; name: string; outline: string } | null;
|
||||
};
|
||||
export type ProjectThread = {
|
||||
thread_id: string;
|
||||
title?: string;
|
||||
status: string;
|
||||
updated_at: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
export const PROJECT_MODES = {
|
||||
qa: { name: "问答模式", description: "围绕项目资料问答、解释与总结" },
|
||||
strategy: {
|
||||
name: "策略智能模式",
|
||||
description: "选择分析视角,开展多视角推演、策略辩论与综合决策",
|
||||
},
|
||||
solution: {
|
||||
name: "方案生成模式",
|
||||
description: "选择报告结构,沿用研究管线生成方案",
|
||||
},
|
||||
} as const;
|
||||
export const PROJECT_OUTLINE = [
|
||||
"背景与依据",
|
||||
"目标与约束",
|
||||
"实施路径与重点任务",
|
||||
"资源配置与时间计划",
|
||||
"风险与应对措施",
|
||||
"评估指标与参考依据",
|
||||
];
|
||||
export const projectKeys = () =>
|
||||
["analysis-projects", getStoredAuth()?.user_id ?? "default"] as const;
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/analysis-projects${path}`,
|
||||
{
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...init?.headers },
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const error = (await response.json().catch(() => ({}))) as {
|
||||
detail?: unknown;
|
||||
};
|
||||
throw new Error(
|
||||
typeof error.detail === "string"
|
||||
? error.detail
|
||||
: `项目操作失败(${response.status})`,
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
export function useAnalysisProjects() {
|
||||
return useQuery({
|
||||
queryKey: projectKeys(),
|
||||
queryFn: () => request<{ projects: AnalysisProject[] }>(""),
|
||||
});
|
||||
}
|
||||
export function useAnalysisProject(id: string) {
|
||||
return useQuery({
|
||||
queryKey: [...projectKeys(), id],
|
||||
queryFn: () => request<AnalysisProject>(`/${encodeURIComponent(id)}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
export function useProjectThreads(id: string) {
|
||||
return useQuery({
|
||||
queryKey: [...projectKeys(), id, "threads"],
|
||||
queryFn: () =>
|
||||
request<{ threads: ProjectThread[] }>(
|
||||
`/${encodeURIComponent(id)}/threads`,
|
||||
),
|
||||
enabled: !!id,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
}
|
||||
export function useProjectThreadAccess(projectId: string, threadId?: string) {
|
||||
return useQuery({
|
||||
queryKey: [...projectKeys(), projectId, "thread", threadId],
|
||||
queryFn: () =>
|
||||
request<ProjectThread>(
|
||||
`/${encodeURIComponent(projectId)}/threads/${encodeURIComponent(threadId!)}`,
|
||||
),
|
||||
enabled: !!threadId,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
export const createAnalysisProject = (
|
||||
fields: ProjectFields & { mode: ProjectMode },
|
||||
) =>
|
||||
request<AnalysisProject>("", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(fields),
|
||||
});
|
||||
export const updateAnalysisProject = (id: string, fields: ProjectFields) =>
|
||||
request<AnalysisProject>(`/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(fields),
|
||||
});
|
||||
export const createProjectThread = (id: string, agentId?: string | null) =>
|
||||
request<ProjectThread>(`/${encodeURIComponent(id)}/threads`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(agentId === undefined ? {} : { agent_id: agentId }),
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
export const STRATEGY_COLUMNS = [
|
||||
"意图理解",
|
||||
"多视角策略分析",
|
||||
"完整策略方案",
|
||||
] as const;
|
||||
const STRATEGY_HEADING_INDEX: Record<string, number> = {
|
||||
意图理解: 0,
|
||||
意图理解与分析角度: 0,
|
||||
多视角策略分析: 1,
|
||||
推荐策略: 1,
|
||||
完整策略方案: 2,
|
||||
综合结果: 2,
|
||||
结论: 2,
|
||||
};
|
||||
/** Parse only user-facing text. Never render tool payloads or reasoning fields. */
|
||||
export function splitStrategySections(text: string): [string, string, string] {
|
||||
const parts: string[][] = [[], [], []];
|
||||
let section = 0;
|
||||
let fence = false;
|
||||
for (const line of text.replace(/\r\n/g, "\n").split("\n")) {
|
||||
if (/^\s*```/.test(line)) fence = !fence;
|
||||
const heading =
|
||||
!fence &&
|
||||
line.match(
|
||||
/^\s*#{1,2}\s+(?:[一二三123][、..]\s*)?(意图理解与分析角度|意图理解|多视角策略分析|推荐策略|完整策略方案|综合结果|结论)\s*$/,
|
||||
);
|
||||
if (heading) {
|
||||
section = STRATEGY_HEADING_INDEX[heading[1]!] ?? section;
|
||||
continue;
|
||||
}
|
||||
parts[section]!.push(line);
|
||||
}
|
||||
return parts.map((lines) => lines.join("\n").trim()) as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
}
|
||||
|
||||
export type StrategySubsection = { title: string; content: string };
|
||||
export type StrategyClarification = {
|
||||
question: string;
|
||||
context?: string;
|
||||
options: { id: string; label: string; description?: string }[];
|
||||
allow_custom: boolean;
|
||||
};
|
||||
export function pendingStrategyClarification(
|
||||
messages: Message[],
|
||||
): StrategyClarification | null {
|
||||
let pending: StrategyClarification | null = null;
|
||||
for (const message of messages) {
|
||||
const raw = message.additional_kwargs?.clarification;
|
||||
if (raw && typeof raw === "object") {
|
||||
const data = raw as Record<string, unknown>;
|
||||
const question = typeof data.question === "string" ? data.question : "";
|
||||
if (!question) continue;
|
||||
pending = {
|
||||
question,
|
||||
context: typeof data.context === "string" ? data.context : undefined,
|
||||
options: Array.isArray(data.options)
|
||||
? data.options.flatMap((item) => {
|
||||
if (!item || typeof item !== "object") return [];
|
||||
const option = item as Record<string, unknown>;
|
||||
if (typeof option.label !== "string") return [];
|
||||
return [{
|
||||
id: typeof option.id === "string" ? option.id : option.label,
|
||||
label: option.label,
|
||||
description:
|
||||
typeof option.description === "string"
|
||||
? option.description
|
||||
: undefined,
|
||||
}];
|
||||
})
|
||||
: [],
|
||||
allow_custom: data.allow_custom !== false,
|
||||
};
|
||||
} else if (pending && message.type === "human") {
|
||||
pending = null;
|
||||
}
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
export function splitStrategySubsections(text: string): StrategySubsection[] {
|
||||
const sections: StrategySubsection[] = [];
|
||||
let current: StrategySubsection | null = null;
|
||||
let fence = false;
|
||||
for (const line of text.replace(/\r\n/g, "\n").split("\n")) {
|
||||
if (/^\s*```/.test(line)) fence = !fence;
|
||||
const heading = !fence && line.match(/^\s*###\s+(.+?)\s*$/);
|
||||
if (heading) {
|
||||
current = { title: heading[1]!, content: "" };
|
||||
sections.push(current);
|
||||
} else if (current) {
|
||||
current.content += `${current.content ? "\n" : ""}${line}`;
|
||||
}
|
||||
}
|
||||
return sections.map((item) => ({ ...item, content: item.content.trim() }));
|
||||
}
|
||||
|
||||
export function strategyRounds(messages: Message[]) {
|
||||
const rounds: { id: string; question: string; answer: string }[] = [];
|
||||
for (const message of messages) {
|
||||
if (
|
||||
message.additional_kwargs?.hide_from_ui ||
|
||||
!["human", "ai"].includes(message.type)
|
||||
)
|
||||
continue;
|
||||
const text =
|
||||
typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content
|
||||
.filter(
|
||||
(item): item is Extract<typeof item, { type: "text" }> =>
|
||||
item.type === "text" &&
|
||||
"text" in item &&
|
||||
typeof item.text === "string",
|
||||
)
|
||||
.map((item) => item.text)
|
||||
.join("\n");
|
||||
if (message.type === "human") {
|
||||
if (text.trim())
|
||||
rounds.push({
|
||||
id: message.id || `round-${rounds.length}`,
|
||||
question: text,
|
||||
answer: "",
|
||||
});
|
||||
} else if (text.trim()) {
|
||||
if (!rounds.length)
|
||||
rounds.push({
|
||||
id: message.id || "round-0",
|
||||
question: "策略分析",
|
||||
answer: "",
|
||||
});
|
||||
rounds[rounds.length - 1]!.answer +=
|
||||
`${rounds[rounds.length - 1]!.answer ? "\n\n" : ""}${text}`;
|
||||
}
|
||||
}
|
||||
return rounds;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { apiFetch } from "../api";
|
||||
import { getFileName } from "../utils/files";
|
||||
import { apiFetch } from "../api";
|
||||
import { downloadMarkdownAsDocx } from "../scheduled-tasks/markdown-docx";
|
||||
import { prepareGeneratedHtml } from "../utils/legacy-html-preview";
|
||||
import { getFileName } from "../utils/files";
|
||||
|
||||
import { urlOfArtifact } from "./utils";
|
||||
|
||||
@@ -39,7 +41,7 @@ async function errorMessageFromResponse(res: Response): Promise<string> {
|
||||
return res.statusText || "Request failed";
|
||||
}
|
||||
|
||||
export async function downloadArtifactFile({
|
||||
export async function downloadArtifactFile({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
@@ -59,12 +61,23 @@ export async function downloadArtifactFile({
|
||||
const filename =
|
||||
filenameFromContentDisposition(cd) ?? getFileName(filepath);
|
||||
|
||||
const blob = await res.blob();
|
||||
const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
|
||||
const downloadBlob =
|
||||
contentType && blob.type !== contentType
|
||||
? new Blob([await blob.arrayBuffer()], { type: contentType })
|
||||
: blob;
|
||||
const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
|
||||
const isHtml = contentType === "text/html" || /\.html?$/i.test(filename);
|
||||
let downloadBlob: Blob;
|
||||
if (isHtml) {
|
||||
downloadBlob = new Blob(
|
||||
[
|
||||
prepareGeneratedHtml(await res.text()),
|
||||
],
|
||||
{ type: "text/html;charset=utf-8" },
|
||||
);
|
||||
} else {
|
||||
const blob = await res.blob();
|
||||
downloadBlob =
|
||||
contentType && blob.type !== contentType
|
||||
? new Blob([await blob.arrayBuffer()], { type: contentType })
|
||||
: blob;
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(downloadBlob);
|
||||
const a = document.createElement("a");
|
||||
a.href = objectUrl;
|
||||
@@ -73,8 +86,26 @@ export async function downloadArtifactFile({
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
||||
}
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
||||
}
|
||||
|
||||
export async function downloadArtifactMarkdownAsWord({
|
||||
filepath,
|
||||
threadId,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
}): Promise<void> {
|
||||
const url = urlOfArtifact({ filepath, threadId, download: false });
|
||||
const res = await apiFetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(await errorMessageFromResponse(res));
|
||||
}
|
||||
|
||||
const fileName = getFileName(filepath);
|
||||
const title = fileName.replace(/\.(md|markdown)$/i, "");
|
||||
await downloadMarkdownAsDocx(await res.text(), fileName, title);
|
||||
}
|
||||
|
||||
export async function openArtifactInNewTab({
|
||||
filepath,
|
||||
@@ -92,7 +123,16 @@ export async function openArtifactInNewTab({
|
||||
if (!res.ok) {
|
||||
throw new Error(await errorMessageFromResponse(res));
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
|
||||
const isHtml = contentType === "text/html" || /\.html?$/i.test(filepath);
|
||||
const blob = isHtml
|
||||
? new Blob(
|
||||
[
|
||||
prepareGeneratedHtml(await res.text()),
|
||||
],
|
||||
{ type: "text/html;charset=utf-8" },
|
||||
)
|
||||
: await res.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const w = window.open(objectUrl, "_blank", "noopener,noreferrer");
|
||||
if (w) w.opener = null;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export type ArtifactLibraryKind =
|
||||
| "all"
|
||||
| "document"
|
||||
| "spreadsheet"
|
||||
| "text_data"
|
||||
| "archive"
|
||||
| "other";
|
||||
|
||||
export type ArtifactLibrarySource = "all" | "general_qa" | "agent";
|
||||
|
||||
export interface ArtifactLibraryItem {
|
||||
threadId: string;
|
||||
threadTitle: string;
|
||||
agentId: string | null;
|
||||
agentName: string | null;
|
||||
threadType: string | null;
|
||||
sourceType: Exclude<ArtifactLibrarySource, "all">;
|
||||
path: string;
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
mimeType: string | null;
|
||||
modifiedAt: string;
|
||||
fileKind: Exclude<ArtifactLibraryKind, "all">;
|
||||
}
|
||||
|
||||
export interface ArtifactLibraryResponse {
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
items: ArtifactLibraryItem[];
|
||||
}
|
||||
|
||||
export async function fetchArtifactLibrary({
|
||||
query,
|
||||
fileKind,
|
||||
sourceType,
|
||||
offset,
|
||||
limit,
|
||||
refresh = false,
|
||||
signal,
|
||||
}: {
|
||||
query?: string;
|
||||
fileKind: ArtifactLibraryKind;
|
||||
sourceType: ArtifactLibrarySource;
|
||||
offset: number;
|
||||
limit: number;
|
||||
refresh?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ArtifactLibraryResponse> {
|
||||
const search = new URLSearchParams({
|
||||
file_kind: fileKind,
|
||||
source_type: sourceType,
|
||||
offset: String(offset),
|
||||
limit: String(limit),
|
||||
});
|
||||
const normalizedQuery = query?.trim();
|
||||
if (normalizedQuery) search.set("query", normalizedQuery);
|
||||
if (refresh) search.set("refresh", "true");
|
||||
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/artifact-library?${search.toString()}`,
|
||||
{ method: "GET", signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Failed to load artifact library (${response.status})${detail ? `: ${detail}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
total?: number;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
items?: Array<{
|
||||
thread_id: string;
|
||||
thread_title: string;
|
||||
agent_id?: string | null;
|
||||
agent_name?: string | null;
|
||||
thread_type?: string | null;
|
||||
source_type?: Exclude<ArtifactLibrarySource, "all">;
|
||||
path: string;
|
||||
name: string;
|
||||
size_bytes: number;
|
||||
mime_type?: string | null;
|
||||
modified_at: string;
|
||||
file_kind: Exclude<ArtifactLibraryKind, "all">;
|
||||
}>;
|
||||
};
|
||||
|
||||
return {
|
||||
total: data.total ?? 0,
|
||||
offset: data.offset ?? offset,
|
||||
limit: data.limit ?? limit,
|
||||
items: (data.items ?? []).map((item) => ({
|
||||
threadId: item.thread_id,
|
||||
threadTitle: item.thread_title,
|
||||
agentId: item.agent_id ?? null,
|
||||
agentName: item.agent_name ?? item.agent_id ?? null,
|
||||
threadType: item.thread_type ?? null,
|
||||
sourceType: item.source_type ?? (item.agent_id ? "agent" : "general_qa"),
|
||||
path: item.path,
|
||||
name: item.name,
|
||||
sizeBytes: item.size_bytes,
|
||||
mimeType: item.mime_type ?? null,
|
||||
modifiedAt: item.modified_at,
|
||||
fileKind: item.file_kind,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
isAutoPreviewableArtifactPath,
|
||||
isArtifactApiPath,
|
||||
isProvisionalStreamingArtifactPath,
|
||||
provisionalWriteFilePath,
|
||||
@@ -30,3 +31,10 @@ test("isArtifactApiPath rejects write-file URLs and placeholder names", () => {
|
||||
assert.equal(isArtifactApiPath("mnt/user-data/outputs/report.md"), true);
|
||||
assert.equal(isArtifactApiPath("/mnt/user-data/outputs/report.md"), true);
|
||||
});
|
||||
|
||||
test("isAutoPreviewableArtifactPath limits automatic previews to outputs and provisional drafts", () => {
|
||||
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/outputs/report.html"), true);
|
||||
assert.equal(isAutoPreviewableArtifactPath("未命名文件.html"), true);
|
||||
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/workspace/scan_structure.py"), false);
|
||||
assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/workspace/draft.html"), false);
|
||||
});
|
||||
@@ -59,6 +59,23 @@ export function isArtifactApiPath(path: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a streamed write is a user-facing deliverable that may open the
|
||||
* artifact panel automatically. Agents often use ``workspace/`` for helper
|
||||
* scripts and intermediate data; previewing those files is noisy and can show
|
||||
* an empty sandbox before the real output exists.
|
||||
*/
|
||||
export function isAutoPreviewableArtifactPath(path: string): boolean {
|
||||
if (isProvisionalStreamingArtifactPath(path)) {
|
||||
return true;
|
||||
}
|
||||
const normalized = normalizeStreamingArtifactPath(path)
|
||||
.split(/[?#]/, 1)[0]
|
||||
?.replace(/^\/+/, "")
|
||||
.replace(/\\/g, "/");
|
||||
return normalized === "mnt/user-data/outputs" || normalized?.startsWith("mnt/user-data/outputs/");
|
||||
}
|
||||
|
||||
export function languageOfStreamingArtifact(
|
||||
path: string,
|
||||
): StreamingArtifactLanguage | null {
|
||||
|
||||
@@ -1,7 +1,331 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { isWriteFileInFlight, resolveWriteFilePath } from "./write-file-in-flight";
|
||||
import {
|
||||
isWriteFileInFlight,
|
||||
latestInFlightWriteFilePreview,
|
||||
latestPreparedHtmlArtifact,
|
||||
resolveWriteFilePath,
|
||||
} from "./write-file-in-flight";
|
||||
|
||||
test("latestPreparedHtmlArtifact opens the output created by template prepare", () => {
|
||||
const preview = latestPreparedHtmlArtifact([
|
||||
{
|
||||
id: "m1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "tpl1",
|
||||
name: "page_report_template",
|
||||
args: { action: "prepare" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
tool_call_id: "tpl1",
|
||||
content: JSON.stringify({
|
||||
ok: true,
|
||||
working_copy: "/mnt/user-data/outputs/人工智能发展报告.html",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(preview, {
|
||||
artifactPath: "/mnt/user-data/outputs/人工智能发展报告.html",
|
||||
previewKey: "page-template:tpl1",
|
||||
});
|
||||
});
|
||||
|
||||
test("latestPreparedHtmlArtifact ignores workspace and failed prepares", () => {
|
||||
assert.equal(
|
||||
latestPreparedHtmlArtifact([
|
||||
{
|
||||
id: "m1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "tpl1",
|
||||
name: "page_report_template",
|
||||
args: { action: "prepare" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
tool_call_id: "tpl1",
|
||||
content: JSON.stringify({
|
||||
ok: true,
|
||||
working_copy: "/mnt/user-data/workspace/draft.html",
|
||||
}),
|
||||
},
|
||||
]),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("latestInFlightWriteFilePreview opens a write_file that is not the last tool call", () => {
|
||||
const preview = latestInFlightWriteFilePreview([
|
||||
{
|
||||
id: "m1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "w1",
|
||||
name: "write_file",
|
||||
args: {
|
||||
path: "/mnt/user-data/outputs/report.md",
|
||||
content: "# 标题\n",
|
||||
},
|
||||
},
|
||||
{ id: "p1", name: "present_files", args: { paths: ["/mnt/user-data/outputs/report.md"] } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.ok(preview);
|
||||
assert.equal(preview?.previewKey, "m1:w1");
|
||||
assert.match(preview?.artifactPath ?? "", /write-file:/);
|
||||
assert.match(preview?.artifactPath ?? "", /report\.md/);
|
||||
});
|
||||
|
||||
test("latestInFlightWriteFilePreview keeps markdown when a later json write is in flight", () => {
|
||||
const preview = latestInFlightWriteFilePreview([
|
||||
{
|
||||
id: "m1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "w1",
|
||||
name: "write_file",
|
||||
args: {
|
||||
path: "/mnt/user-data/outputs/report.md",
|
||||
content: "# 标题\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "w2",
|
||||
name: "write_file",
|
||||
args: {
|
||||
path: "/mnt/user-data/outputs/task-reports.json",
|
||||
content: "[{",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.ok(preview);
|
||||
assert.equal(preview?.previewKey, "m1:w1");
|
||||
assert.match(preview?.artifactPath ?? "", /report\.md/);
|
||||
});
|
||||
|
||||
test("latestInFlightWriteFilePreview skips intercepted custom outputs markdown in writing mode", () => {
|
||||
const messages = [
|
||||
{
|
||||
id: "m1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "w1",
|
||||
name: "write_file",
|
||||
args: {
|
||||
path: "/mnt/user-data/outputs/ai-agent-architecture-paper.md",
|
||||
content: "# 草稿\n",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
assert.equal(latestInFlightWriteFilePreview(messages, true), null);
|
||||
});
|
||||
|
||||
test("latestInFlightWriteFilePreview keeps custom outputs markdown live in normal mode", () => {
|
||||
const preview = latestInFlightWriteFilePreview([
|
||||
{
|
||||
id: "m1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "w1",
|
||||
name: "write_file",
|
||||
args: {
|
||||
path: "/mnt/user-data/outputs/ai-agent-architecture-paper.md",
|
||||
content: "# 草稿\n",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.ok(preview);
|
||||
assert.match(preview?.artifactPath ?? "", /ai-agent-architecture-paper\.md/);
|
||||
});
|
||||
|
||||
test("latestInFlightWriteFilePreview skips report.md while the first-turn pipeline is active", () => {
|
||||
const preview = latestInFlightWriteFilePreview(
|
||||
[
|
||||
{ type: "human", content: "写一篇论文" },
|
||||
{
|
||||
id: "m-pipe",
|
||||
type: "ai",
|
||||
tool_calls: [{ id: "p1", name: "deep_research_report", args: { topic: "课题" } }],
|
||||
},
|
||||
{
|
||||
id: "m-write",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "w1",
|
||||
name: "write_file",
|
||||
args: {
|
||||
path: "/mnt/user-data/outputs/report.md",
|
||||
content: "# 我自己写\n",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
true,
|
||||
);
|
||||
assert.equal(preview, null);
|
||||
});
|
||||
|
||||
test("latestInFlightWriteFilePreview still opens the injected research stub", () => {
|
||||
const preview = latestInFlightWriteFilePreview(
|
||||
[
|
||||
{
|
||||
id: "research-report-j1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "research-report-j1-write",
|
||||
name: "write_file",
|
||||
args: {
|
||||
path: "/mnt/user-data/outputs/report.md",
|
||||
content: "# 报告\n",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
true,
|
||||
);
|
||||
assert.ok(preview);
|
||||
assert.match(preview?.artifactPath ?? "", /report\.md/);
|
||||
});
|
||||
|
||||
test("writing mode still previews follow-up-turn writes after a later user message", () => {
|
||||
// 续写轮(成功之后用户又发了新消息):真实 write_file 编辑照常预览。
|
||||
const preview = latestInFlightWriteFilePreview(
|
||||
[
|
||||
{ type: "human", content: "写一篇论文" },
|
||||
{
|
||||
id: "m-pipe",
|
||||
type: "ai",
|
||||
tool_calls: [{ id: "p1", name: "deep_research_report", args: { topic: "课题" } }],
|
||||
},
|
||||
{
|
||||
id: "m-result",
|
||||
type: "tool",
|
||||
tool_call_id: "p1",
|
||||
name: "deep_research_report",
|
||||
content: "研究报告已完成:已写入 report.md 并展示给用户。",
|
||||
},
|
||||
{ type: "human", content: "把第二章改写得更学术一些" },
|
||||
{
|
||||
id: "m-edit",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "w2",
|
||||
name: "write_file",
|
||||
args: {
|
||||
path: "/mnt/user-data/outputs/report.md",
|
||||
content: "# 修订后的报告\n",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
true,
|
||||
);
|
||||
assert.ok(preview);
|
||||
assert.equal(preview?.previewKey, "m-edit:w2");
|
||||
});
|
||||
|
||||
test("normal mode keeps previewing pathless streaming drafts", () => {
|
||||
const preview = latestInFlightWriteFilePreview([
|
||||
{
|
||||
id: "m1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{ id: "w1", name: "write_file", args: { content: "# 没有路径的流式草稿\n" } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.ok(preview);
|
||||
assert.match(preview?.artifactPath ?? "", /\.md/);
|
||||
});
|
||||
|
||||
test("latestInFlightWriteFilePreview ignores workspace helper files", () => {
|
||||
const preview = latestInFlightWriteFilePreview([
|
||||
{
|
||||
id: "m1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "w1",
|
||||
name: "write_file",
|
||||
args: {
|
||||
path: "/mnt/user-data/workspace/scan_structure.py",
|
||||
content: "print('scan')",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "w2",
|
||||
name: "write_file",
|
||||
args: {
|
||||
path: "/mnt/user-data/workspace/draft.html",
|
||||
content: "<!doctype html><html></html>",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.equal(preview, null);
|
||||
});
|
||||
|
||||
test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => {
|
||||
const preview = latestInFlightWriteFilePreview([
|
||||
{
|
||||
id: "m1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "w1",
|
||||
name: "write_file",
|
||||
args: {
|
||||
path: "/mnt/user-data/outputs/task-reports.json",
|
||||
content: "[{}]",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.equal(preview, null);
|
||||
});
|
||||
|
||||
test("latestInFlightWriteFilePreview skips write_file that already has a tool result", () => {
|
||||
const preview = latestInFlightWriteFilePreview([
|
||||
{
|
||||
id: "m1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{ id: "w1", name: "write_file", args: { path: "/mnt/user-data/outputs/report.md" } },
|
||||
],
|
||||
},
|
||||
{ type: "tool", tool_call_id: "w1" },
|
||||
]);
|
||||
assert.equal(preview, null);
|
||||
});
|
||||
|
||||
test("resolveWriteFilePath reads the virtual URL pathname", () => {
|
||||
assert.equal(
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { isInterceptedWritingReportWrite } from "../threads/writing-mode";
|
||||
import { isImageFile } from "../utils/files";
|
||||
|
||||
import {
|
||||
buildWriteFileVirtualUrl,
|
||||
hasCompleteArtifactExtension,
|
||||
isAutoPreviewableArtifactPath,
|
||||
languageOfStreamingArtifact,
|
||||
normalizeStreamingArtifactPath,
|
||||
provisionalWriteFilePath,
|
||||
} from "./streaming-write-preview";
|
||||
|
||||
export function isWriteFileStreamingUrl(filepath: string): boolean {
|
||||
if (!filepath.startsWith("write-file:")) {
|
||||
return false;
|
||||
@@ -27,8 +37,12 @@ export function resolveWriteFilePath(filepath: string): string {
|
||||
|
||||
type WriteFileThreadLike = {
|
||||
messages: Array<{
|
||||
id?: string;
|
||||
type?: string;
|
||||
name?: string;
|
||||
content?: unknown;
|
||||
tool_call_id?: string;
|
||||
additional_kwargs?: Record<string, unknown>;
|
||||
tool_calls?: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
@@ -37,6 +51,70 @@ type WriteFileThreadLike = {
|
||||
}>;
|
||||
};
|
||||
|
||||
function parseToolJson(content: unknown): Record<string, unknown> | null {
|
||||
let value = content;
|
||||
if (Array.isArray(value)) {
|
||||
const textBlock = value.find(
|
||||
(item) =>
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"text" in item &&
|
||||
typeof (item as { text?: unknown }).text === "string",
|
||||
) as { text?: string } | undefined;
|
||||
value = textBlock?.text;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
value = JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A successful page-template prepare already creates a previewable HTML file
|
||||
* under outputs. Open it before the first edit arrives so the user sees the
|
||||
* sandbox as soon as page generation starts, even when the model edits via
|
||||
* str_replace or emits a complete write_file call in one stream frame.
|
||||
*/
|
||||
export function latestPreparedHtmlArtifact(
|
||||
messages: WriteFileThreadLike["messages"],
|
||||
): { artifactPath: string; previewKey: string } | null {
|
||||
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
||||
const message = messages[messageIndex];
|
||||
if (message?.type !== "ai" || !message.tool_calls) continue;
|
||||
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
|
||||
const toolCall = message.tool_calls[toolIndex];
|
||||
if (toolCall?.name !== "page_report_template" || !toolCall.id) continue;
|
||||
const args = toolCall.args as { action?: unknown } | undefined;
|
||||
if (String(args?.action ?? "").toLowerCase() !== "prepare") continue;
|
||||
const resultMessage = messages.find(
|
||||
(candidate) =>
|
||||
candidate.type === "tool" && candidate.tool_call_id === toolCall.id,
|
||||
);
|
||||
const result = parseToolJson(resultMessage?.content);
|
||||
if (result?.ok !== true) continue;
|
||||
const candidate =
|
||||
typeof result.working_copy === "string"
|
||||
? result.working_copy
|
||||
: typeof result.final_output === "string"
|
||||
? result.final_output
|
||||
: "";
|
||||
const artifactPath = candidate.trim();
|
||||
if (!/^\/mnt\/user-data\/outputs\/.+\.html?$/i.test(artifactPath)) continue;
|
||||
return {
|
||||
artifactPath,
|
||||
previewKey: `page-template:${toolCall.id}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasToolResult(thread: WriteFileThreadLike, toolCallId?: string) {
|
||||
if (!toolCallId) {
|
||||
return false;
|
||||
@@ -75,3 +153,70 @@ export function isWriteFileInFlight(
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest in-flight `write_file` that the sandbox can live-preview.
|
||||
* Scans all messages (not just the last CoT step) so a write that is
|
||||
* followed by `present_files` / bash in the same turn still opens the panel
|
||||
* while arguments stream in — same idea as position-roundtable's
|
||||
* `latestStreamingWriteFile`.
|
||||
*/
|
||||
export function latestInFlightWriteFilePreview(
|
||||
messages: WriteFileThreadLike["messages"],
|
||||
writingMode = false,
|
||||
): { artifactPath: string; previewKey: string } | null {
|
||||
const thread = { messages };
|
||||
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
||||
const message = messages[messageIndex];
|
||||
if (message?.type !== "ai" || !message.id || !message.tool_calls) {
|
||||
continue;
|
||||
}
|
||||
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
|
||||
const toolCall = message.tool_calls[toolIndex];
|
||||
if (toolCall?.name !== "write_file" || !toolCall.id) {
|
||||
continue;
|
||||
}
|
||||
if (hasToolResult(thread, toolCall.id)) {
|
||||
continue;
|
||||
}
|
||||
const args = toolCall.args as { path?: unknown; content?: unknown } | undefined;
|
||||
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
|
||||
: content.trim()
|
||||
? provisionalWriteFilePath(content)
|
||||
: null;
|
||||
if (!sourcePath) {
|
||||
continue;
|
||||
}
|
||||
// Writing mode rewrites custom outputs/*.md to deep_research_report.
|
||||
// Opening that filename would 404-poll until report.md lands. Normal
|
||||
// mode never skips: the write is a real deliverable, not a rewrite.
|
||||
if (isInterceptedWritingReportWrite(sourcePath, message.id, messages, writingMode)) {
|
||||
continue;
|
||||
}
|
||||
// JSON (e.g. agentfx task-reports.json) is too large to live-preview:
|
||||
// switching the sandbox onto it freezes the main thread, then the rest
|
||||
// of the turn appears to dump at once. Keep showing markdown instead.
|
||||
if (!languageOfStreamingArtifact(sourcePath)) {
|
||||
continue;
|
||||
}
|
||||
if (!isAutoPreviewableArtifactPath(sourcePath)) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
artifactPath: buildWriteFileVirtualUrl({
|
||||
path: sourcePath,
|
||||
messageId: message.id,
|
||||
toolCallId: toolCall.id,
|
||||
}),
|
||||
previewKey: `${message.id}:${toolCall.id}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* ``GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=``(带 DeerFlow 登录态,任务数据
|
||||
* 来自 TaskCOP 任务表,即任务列表 / 报告导入用的同一份)。
|
||||
* 2. **固定使用内置智能体** ``agentfx-analyst``(后端 seeder ``_agentfx_seed.py`` 启动时补建:
|
||||
* 搜集信息 → 生成 report.md + task-reports.json(14 类)→ 总结 → 前端「入库」协助卡 /
|
||||
* 搜集信息 → hits 落盘 → task-report-build 一条命令转换成 14 类 task-reports.json → 写 report.md → 总结 → 前端「入库」协助卡 /
|
||||
* task-report-import 技能入库),不走「配置管理」业务映射。
|
||||
* 3. 路由挂在 ``plan-task-agentfx`` 前缀下,页面代码见 [[AgentFxWorkspace]]。
|
||||
*
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
@@ -125,5 +125,9 @@ export function AuthSyncWatcher({
|
||||
};
|
||||
}, [disabled, location.pathname, navigate, queryClient]);
|
||||
|
||||
return children ? <Fragment key={revision}>{children}</Fragment> : null;
|
||||
// A keyed Fragment is transformed by the development tagger, which injects
|
||||
// data-lov-id. React.Fragment only accepts key and children, so use a
|
||||
// display:contents element to preserve the remount behavior without adding
|
||||
// a layout box.
|
||||
return children ? <div className="contents" key={revision}>{children}</div> : null;
|
||||
}
|
||||
@@ -109,8 +109,8 @@ export interface TaskButton {
|
||||
/** 追加的查询参数名(如 ``id`` / ``task_id``);空则用 ``id``。 */
|
||||
idParam: string;
|
||||
/**
|
||||
* ``url`` 追加 id 时取任务id 还是行动id。仅 xdfx(行动分析)二者不同:深链原始 ``?taskId=``
|
||||
* 实为行动id,任务id 由 action 接口解析。其它业务两者相同。
|
||||
* ``url`` / ``host`` 追加 id 时取任务id 还是行动id。仅 xdfx(行动分析)二者不同:深链原始
|
||||
* ``?taskId=`` 实为行动id,任务id 由 action 接口解析。其它业务两者相同。
|
||||
*/
|
||||
idKind: TaskButtonIdKind;
|
||||
/** ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口。仅 ``url`` 类型有意义。 */
|
||||
@@ -327,7 +327,8 @@ export interface RunTaskButtonCtx {
|
||||
* 执行一个跳转按钮:
|
||||
* - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。
|
||||
* - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。
|
||||
* - ``host``:向 ``window.parent`` 发 ``HOST_NAVIGATE``,由宿主系统跳转(仅 iframe 嵌入时生效)。
|
||||
* - ``host``:向 ``window.parent`` 发 ``HOST_NAVIGATE``,由宿主系统跳转(仅 iframe 嵌入时生效;
|
||||
* 追加 id 时同样按 ``idKind`` 取任务id / 行动id)。
|
||||
*/
|
||||
export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise<void> {
|
||||
const fail = (m: string) => ctx.onError?.(m);
|
||||
@@ -355,9 +356,11 @@ export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Pro
|
||||
}
|
||||
const queryParams: Record<string, number> = {};
|
||||
if (btn.appendTaskId) {
|
||||
const id = parseHostTaskId(ctx.taskId);
|
||||
// 行动分析(xdfx)可选发行动id;与 url 外链同规则:行动id 缺失时回退任务id。
|
||||
const raw = btn.idKind === "action" ? ctx.actionId || ctx.taskId : ctx.taskId;
|
||||
const id = parseHostTaskId(raw);
|
||||
if (id === null) {
|
||||
fail("任务id不是数字,无法跳转宿主系统");
|
||||
fail(`${btn.idKind === "action" ? "行动id" : "任务id"}不是数字,无法跳转宿主系统`);
|
||||
return;
|
||||
}
|
||||
queryParams[(btn.idParam || "id").trim() || "id"] = id;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/**
|
||||
* embed-session.ts 浏览上下文隔离单测。
|
||||
* 运行方式(项目无 vitest):
|
||||
* npx esbuild src/core/embed/embed-session.test.ts --bundle --format=esm \
|
||||
* --platform=node --outfile=.tmp-tests/embed-session.test.mjs
|
||||
* node --test .tmp-tests/embed-session.test.mjs
|
||||
@@ -82,14 +81,11 @@ test("nested embed=1 does not hide chrome on the same-origin top window", () =>
|
||||
asTop();
|
||||
syncEmbedSessionFromSearch("");
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isChromeHidden(), false);
|
||||
|
||||
asIframe();
|
||||
syncEmbedSessionFromSearch("?embed=1&theme=blue&hideJump=0");
|
||||
assert.equal(isEmbedActive(), true);
|
||||
assert.equal(isChromeHidden(), true);
|
||||
assert.ok(storage.getItem("embed.session.nested"));
|
||||
assert.equal(storage.getItem("embed.session"), null);
|
||||
|
||||
asTop();
|
||||
assert.equal(isEmbedActive(), false);
|
||||
@@ -97,11 +93,9 @@ test("nested embed=1 does not hide chrome on the same-origin top window", () =>
|
||||
});
|
||||
});
|
||||
|
||||
test("top parent reclaims pollution from an old nested iframe (no topOwned)", () => {
|
||||
test("top ignores old nested embed.session without clearing it", () => {
|
||||
withSharedTab(({ storage, asTop }) => {
|
||||
asTop();
|
||||
syncEmbedSessionFromSearch("");
|
||||
// 模拟未升级的老版内层:仍写共用键且没有 topOwned。
|
||||
storage.setItem(
|
||||
"embed.session",
|
||||
JSON.stringify({
|
||||
@@ -113,15 +107,14 @@ test("top parent reclaims pollution from an old nested iframe (no topOwned)", ()
|
||||
isolate: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// 外层点菜单重算布局时会读 session —— 必须立刻自愈。
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isChromeHidden(), false);
|
||||
assert.equal(storage.getItem("embed.session"), null);
|
||||
// 内层老版仍要读到 embed=true
|
||||
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
|
||||
});
|
||||
});
|
||||
|
||||
test("top sync after old nested pollution does not mark owner", () => {
|
||||
test("top sync does not wipe unowned embed.session", () => {
|
||||
withSharedTab(({ storage, asTop }) => {
|
||||
asTop();
|
||||
storage.setItem(
|
||||
@@ -137,12 +130,11 @@ test("top sync after old nested pollution does not mark owner", () => {
|
||||
);
|
||||
syncEmbedSessionFromSearch("");
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isChromeHidden(), false);
|
||||
assert.equal(storage.getItem("embed.session"), null);
|
||||
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
|
||||
});
|
||||
});
|
||||
|
||||
test("nested iframe reclaims a polluted shared embed.session key", () => {
|
||||
test("nested iframe leaves the shared embed.session for old inner pages", () => {
|
||||
withSharedTab(({ storage, asTop, asIframe }) => {
|
||||
asTop();
|
||||
storage.setItem(
|
||||
@@ -159,11 +151,11 @@ test("nested iframe reclaims a polluted shared embed.session key", () => {
|
||||
|
||||
asIframe();
|
||||
syncEmbedSessionFromSearch("?embed=1");
|
||||
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
|
||||
assert.ok(storage.getItem("embed.session.nested"));
|
||||
|
||||
asTop();
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isChromeHidden(), false);
|
||||
assert.equal(storage.getItem("embed.session"), null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,31 +176,7 @@ test("nested iframe keeps the top window's own embed session", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("nested reclaim strips embed but keeps top isolate", () => {
|
||||
withSharedTab(({ storage, asTop, asIframe }) => {
|
||||
asTop();
|
||||
storage.setItem(
|
||||
"embed.session",
|
||||
JSON.stringify({
|
||||
embed: true,
|
||||
theme: "light",
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
isolate: true,
|
||||
}),
|
||||
);
|
||||
|
||||
asIframe();
|
||||
syncEmbedSessionFromSearch("?embed=1");
|
||||
|
||||
asTop();
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isIsolatedSession(), true);
|
||||
});
|
||||
});
|
||||
|
||||
test("top reclaim strips embed but keeps isolate without nested help", () => {
|
||||
test("top ignore strips chrome in memory but keeps isolate", () => {
|
||||
withSharedTab(({ storage, asTop }) => {
|
||||
asTop();
|
||||
storage.setItem(
|
||||
@@ -224,5 +192,6 @@ test("top reclaim strips embed but keeps isolate without nested help", () => {
|
||||
);
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isIsolatedSession(), true);
|
||||
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
|
||||
});
|
||||
});
|
||||
@@ -16,34 +16,17 @@ export { resolveEmbedThemeMode } from "./theme-mode.mjs";
|
||||
* 录不同账号**而**不覆盖**当前标签页(localStorage)的登录态——`embed` 已隐含此隔离,`isolate`
|
||||
* 则是把同一套隔离单独提供给**普通(带 chrome)标签页**,不去 chrome、不强制主题、不动跳转栏。
|
||||
*
|
||||
* 三者可单独使用,也可叠加:`hideJump` **只**隐藏跳转栏,不动 chrome / 主题;`embed` 不影响跳转栏;
|
||||
* `isolate` 只切换鉴权存储,不影响 chrome / 主题 / 跳转栏。
|
||||
*
|
||||
* 隔离要点(核心诉求:同一浏览器另开咱们正常系统页,**绝不能**被这些开关影响):
|
||||
* - 用 **sessionStorage**(按标签页隔离、绝不跨标签)持有这些 flag,而**不用** localStorage
|
||||
* (跨标签共享 → 会污染正常页)。
|
||||
* - 主题走 next-themes 的 `forcedTheme`(见 App.tsx `AppProviders`)——只在本标签页生效、
|
||||
* 不写 localStorage `strategy-theme`;嵌入侧绝不调 `setTheme`。
|
||||
* - 去 chrome / 去跳转栏均由本模块的 sessionStorage flag 驱动,不碰 `persistSidebarEnabled` 等
|
||||
* localStorage 偏好。
|
||||
*
|
||||
* 同源陷阱:sessionStorage **不是**「一个 iframe 一份」。规范按「顶层标签页 × origin」
|
||||
* 分区,同一标签里所有同源文档(含嵌套 iframe、换路径/换端口反向代理后仍同源的另一套部署)
|
||||
* **共用一份**。全量模式任务管理用 iframe 套外部页、外部页再套咱们 `embed=1` 深链时,内层
|
||||
* 若写入同一个 `embed.session` 键,外层点左侧菜单重算 `PageLayout` 就会把导航栏/侧边栏藏掉,
|
||||
* 路由看起来却没变。因此:
|
||||
* - 顶层窗口写 `embed.session`,嵌套 iframe(新版)写 `embed.session.nested`;
|
||||
* - 顶层只信任带 `topOwned` 的会话(本窗自己从 URL 置位);老版嵌套页仍写共用键时,
|
||||
* 外层父页会在读取/同步时清掉无主污染,即使内层未升级也能自愈。
|
||||
* 同源陷阱:sessionStorage 按「顶层标签页 × origin」分区,同一标签里所有同源文档共用一份。
|
||||
* 全量父页套外部系统、外部再套咱们老版 `embed=1` 深链时,内层会写共用的 `embed.session`。
|
||||
* **禁止**在顶层父页清掉这份键——内层老版还靠它藏导航栏。顶层只在「本窗自己从 URL 认领」时
|
||||
* 才把 embed 当成自己的;否则读取时忽略 chrome 标志、且不写回存储。
|
||||
* 嵌套新版写 `embed.session.nested`,与顶层键分开。深链问答/会商/大屏绘制另外按路由去 chrome
|
||||
* (见 `isHostNestedSurfacePath`),不依赖这份会话。
|
||||
*/
|
||||
|
||||
const EMBED_SESSION_KEY = "embed.session";
|
||||
/** 嵌套 iframe(`window !== top`)专用,避免与顶层全量页共用 `embed.session`。 */
|
||||
const EMBED_SESSION_NESTED_KEY = "embed.session.nested";
|
||||
/**
|
||||
* 顶层窗口认领「本页自己的去 chrome 会话」的内存标记(配合 session 里的 `topOwned`)。
|
||||
* 嵌套同源 iframe 看到此标记就不会清掉顶层合法的 embed/hideChrome 持久化。
|
||||
*/
|
||||
const TOP_CHROME_OWNER_FLAG = "__deerflowTopChromeEmbed";
|
||||
|
||||
type TopChromeOwnerWindow = Window & { [TOP_CHROME_OWNER_FLAG]?: boolean };
|
||||
@@ -61,7 +44,6 @@ function getSessionStorage(): Storage | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前文档是否为顶层窗口(跨源 `window.top` 比较是安全的;访问被拒则视为嵌套)。 */
|
||||
function isTopLevelWindow(): boolean {
|
||||
const w = getWindow();
|
||||
if (!w) return true;
|
||||
@@ -78,28 +60,14 @@ function embedSessionKey(): string {
|
||||
|
||||
export type EmbedTheme = "light" | "dark";
|
||||
|
||||
/** 本标签页的嵌入会话(按需置位的若干独立开关)。 */
|
||||
export interface EmbedSession {
|
||||
/** 嵌入模式:去全局 chrome + 强制主题。 */
|
||||
embed: boolean;
|
||||
/** 嵌入模式下强制的主题(仅 embed 为真时有意义)。 */
|
||||
theme: EmbedTheme;
|
||||
/** 隐藏深链底部「快捷跳转」栏(TaskJumpBar:底部按钮 + 右侧悬浮菜单)。 */
|
||||
hideJump: boolean;
|
||||
/** Only hide the global Header while retaining PageSidebar and normal app behavior. */
|
||||
hideHeader: boolean;
|
||||
/**
|
||||
* 仅去全局 chrome(Header 导航栏 + PageSidebar 侧边栏),**不**强制主题、**不**隔离鉴权。
|
||||
* 区别于 `embed`:嵌入态会额外强制主题 + 走 sessionStorage 隔离登录身份;`hideChrome` 只想要
|
||||
* 一个干净的全屏页面(如「大屏绘制」`?hideChrome=1`),其余行为与正常标签页完全一致。
|
||||
*/
|
||||
hideChrome: boolean;
|
||||
/** 独立会话:鉴权身份按本标签页隔离(走 sessionStorage),用于同浏览器登录不同账号互不覆盖。 */
|
||||
isolate: boolean;
|
||||
/**
|
||||
* 仅顶层窗口自己从 URL 置位去 chrome 时写入。缺省/false 表示可能是同源嵌套 iframe
|
||||
* (含未升级的老版内层)写脏的共用键,顶层父页应忽略并清掉。
|
||||
*/
|
||||
/** 仅顶层窗口自己从 URL 置位去 chrome 时写入。缺省表示可能是同源嵌套页写的共用键。 */
|
||||
topOwned?: boolean;
|
||||
}
|
||||
|
||||
@@ -142,49 +110,12 @@ function emptySession(): EmbedSession {
|
||||
};
|
||||
}
|
||||
|
||||
function stripChromeFlags(session: EmbedSession): EmbedSession {
|
||||
return {
|
||||
...session,
|
||||
embed: false,
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
topOwned: false,
|
||||
};
|
||||
}
|
||||
|
||||
function persistTopSession(next: EmbedSession): void {
|
||||
const store = getSessionStorage();
|
||||
if (!store) return;
|
||||
if (
|
||||
!next.embed &&
|
||||
!next.hideJump &&
|
||||
!next.hideHeader &&
|
||||
!next.hideChrome &&
|
||||
!next.isolate
|
||||
) {
|
||||
store.removeItem(EMBED_SESSION_KEY);
|
||||
return;
|
||||
}
|
||||
const payload: EmbedSession = {
|
||||
embed: next.embed,
|
||||
theme: next.theme,
|
||||
hideJump: next.hideJump,
|
||||
hideHeader: next.hideHeader,
|
||||
hideChrome: next.hideChrome,
|
||||
isolate: next.isolate,
|
||||
};
|
||||
if (next.topOwned) payload.topOwned = true;
|
||||
store.setItem(EMBED_SESSION_KEY, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function isTopChromeOwned(session?: EmbedSession | null): boolean {
|
||||
const w = getWindow();
|
||||
if (w?.[TOP_CHROME_OWNER_FLAG] === true) return true;
|
||||
return session?.topOwned === true;
|
||||
}
|
||||
|
||||
/** 顶层窗口从自己的 URL 置位去 chrome 后打标(内存 + session.topOwned)。 */
|
||||
function markTopChromeOwner(session: EmbedSession): void {
|
||||
const w = getWindow();
|
||||
if (!w || !isTopLevelWindow()) return;
|
||||
@@ -192,31 +123,25 @@ function markTopChromeOwner(session: EmbedSession): void {
|
||||
w[TOP_CHROME_OWNER_FLAG] = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶层父页自愈:老版同源嵌套 iframe 仍会写共用 `embed.session`(无 `topOwned`)。
|
||||
* 若本窗从未从自己的 URL 认领过嵌入态,则清掉去 chrome 标志,保留 isolate。
|
||||
*/
|
||||
function reclaimUnownedTopChrome(): void {
|
||||
if (!isTopLevelWindow()) return;
|
||||
function readStoredSession(): EmbedSession | null {
|
||||
try {
|
||||
const store = getSessionStorage();
|
||||
if (!store) return;
|
||||
const cur = parseEmbedSession(store.getItem(EMBED_SESSION_KEY));
|
||||
if (!cur || !sessionHidesChrome(cur)) return;
|
||||
if (isTopChromeOwned(cur)) {
|
||||
const w = getWindow();
|
||||
if (w) w[TOP_CHROME_OWNER_FLAG] = true;
|
||||
return;
|
||||
}
|
||||
persistTopSession(stripChromeFlags(cur));
|
||||
return parseEmbedSession(getSessionStorage()?.getItem(embedSessionKey()) ?? null);
|
||||
} catch {
|
||||
/* noop */
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readSharedTopSession(): EmbedSession | null {
|
||||
try {
|
||||
return parseEmbedSession(getSessionStorage()?.getItem(EMBED_SESSION_KEY) ?? null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧代码把嵌套 iframe 的 embed 写进了共用的 `embed.session`。嵌套侧启动时把这份
|
||||
* 遗产迁到 nested 键;若顶层并未认领去 chrome,再把顶层键里的 embed/hideChrome 清掉。
|
||||
* 旧版嵌套页写过共用键时,新版嵌套页复制一份到 nested 键自己用;**不删除**共用键,
|
||||
* 避免老版内层丢掉 embed 后把导航栏放出来。
|
||||
*/
|
||||
function migrateLegacySharedSessionIfNested(): void {
|
||||
if (isTopLevelWindow()) return;
|
||||
@@ -230,30 +155,12 @@ function migrateLegacySharedSessionIfNested(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function reclaimPollutedTopChrome(): void {
|
||||
if (isTopLevelWindow()) return;
|
||||
try {
|
||||
const top = getWindow()?.top as TopChromeOwnerWindow | null | undefined;
|
||||
const store = getSessionStorage();
|
||||
if (!store) return;
|
||||
const cur = parseEmbedSession(store.getItem(EMBED_SESSION_KEY));
|
||||
if (!cur) return;
|
||||
// 顶层已认领(内存标记或 session.topOwned)则不动。
|
||||
if (top?.[TOP_CHROME_OWNER_FLAG] === true || cur.topOwned === true) return;
|
||||
persistTopSession(stripChromeFlags(cur));
|
||||
} catch {
|
||||
/* 跨源 top:sessionStorage 本就不与外层其它 origin 共享,无需回收 */
|
||||
}
|
||||
}
|
||||
|
||||
/** URL 布尔参数的三态:显式开 / 显式关 / 未带。 */
|
||||
type FlagParam = "on" | "off" | null;
|
||||
|
||||
function toSearchParams(search: string): URLSearchParams {
|
||||
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||
}
|
||||
|
||||
/** 读某个布尔型 query 参数的三态。`1/true/yes` → on;`0/false/no` → off;缺省/其它 → null。 */
|
||||
function readFlagParam(search: string, key: string): FlagParam {
|
||||
const v = (toSearchParams(search).get(key) ?? "").trim().toLowerCase();
|
||||
if (!v) return null;
|
||||
@@ -262,32 +169,26 @@ function readFlagParam(search: string, key: string): FlagParam {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 读 URL 里的 `embed` 三态。 */
|
||||
export function readEmbedParam(search: string): FlagParam {
|
||||
return readFlagParam(search, "embed");
|
||||
}
|
||||
|
||||
/** 读 URL 里的 `hideJump` 三态。 */
|
||||
export function readHideJumpParam(search: string): FlagParam {
|
||||
return readFlagParam(search, "hideJump");
|
||||
}
|
||||
|
||||
/** Read the URL's `hideHeader` three-state flag (hide only the global Header). */
|
||||
export function readHideHeaderParam(search: string): FlagParam {
|
||||
return readFlagParam(search, "hideHeader");
|
||||
}
|
||||
|
||||
/** 读 URL 里的 `hideChrome` 三态(仅去全局导航栏 + 侧边栏)。 */
|
||||
export function readHideChromeParam(search: string): FlagParam {
|
||||
return readFlagParam(search, "hideChrome");
|
||||
}
|
||||
|
||||
/** 读 URL 里的 `isolate` 三态(独立会话登录)。 */
|
||||
export function readIsolateParam(search: string): FlagParam {
|
||||
return readFlagParam(search, "isolate");
|
||||
}
|
||||
|
||||
/** 取 URL query 里的 `theme` 值(embed 主题来源)。 */
|
||||
export function readEmbedThemeParam(search: string): string {
|
||||
return (toSearchParams(search).get("theme") ?? "").trim();
|
||||
}
|
||||
@@ -301,34 +202,32 @@ function searchEnablesChromeHide(search: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** 读本浏览上下文的嵌入会话;无 → null。顶层与嵌套 iframe 使用不同的 sessionStorage 键。 */
|
||||
/**
|
||||
* 读本浏览上下文的嵌入会话。顶层若看到无主(非 topOwned)的 chrome 标志,视为同源
|
||||
* 嵌套页写的,**读取时忽略、不写回**,以免清掉内层老版仍在用的 `embed.session`。
|
||||
*/
|
||||
export function readEmbedSession(): EmbedSession | null {
|
||||
try {
|
||||
// 顶层每次读取前清掉老版内层写脏的无主 embed,保证点菜单重算布局时不会去 chrome。
|
||||
reclaimUnownedTopChrome();
|
||||
return parseEmbedSession(getSessionStorage()?.getItem(embedSessionKey()) ?? null);
|
||||
} catch {
|
||||
return null;
|
||||
const s = readStoredSession();
|
||||
if (!s) return null;
|
||||
if (isTopLevelWindow() && sessionHidesChrome(s) && !isTopChromeOwned(s)) {
|
||||
return {
|
||||
...s,
|
||||
embed: false,
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
topOwned: false,
|
||||
};
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */
|
||||
function writeEmbedSession(next: EmbedSession): void {
|
||||
try {
|
||||
const store = getSessionStorage();
|
||||
if (!store) return;
|
||||
const key = embedSessionKey();
|
||||
if (
|
||||
!next.embed &&
|
||||
!next.hideJump &&
|
||||
!next.hideHeader &&
|
||||
!next.hideChrome &&
|
||||
!next.isolate
|
||||
) {
|
||||
store.removeItem(key);
|
||||
return;
|
||||
}
|
||||
const payload: EmbedSession = {
|
||||
let payload: EmbedSession = {
|
||||
embed: next.embed,
|
||||
theme: next.theme,
|
||||
hideJump: next.hideJump,
|
||||
@@ -337,37 +236,56 @@ function writeEmbedSession(next: EmbedSession): void {
|
||||
isolate: next.isolate,
|
||||
};
|
||||
if (next.topOwned) payload.topOwned = true;
|
||||
|
||||
// 顶层未认领时,写 isolate 等字段不得覆盖内层老版仍占用的 chrome 标志。
|
||||
if (
|
||||
isTopLevelWindow() &&
|
||||
key === EMBED_SESSION_KEY &&
|
||||
!payload.topOwned &&
|
||||
getWindow()?.[TOP_CHROME_OWNER_FLAG] !== true
|
||||
) {
|
||||
const shared = readSharedTopSession();
|
||||
if (shared && sessionHidesChrome(shared) && !shared.topOwned) {
|
||||
payload = {
|
||||
...payload,
|
||||
embed: shared.embed,
|
||||
theme: shared.theme,
|
||||
hideJump: shared.hideJump,
|
||||
hideHeader: shared.hideHeader,
|
||||
hideChrome: shared.hideChrome,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!payload.embed &&
|
||||
!payload.hideJump &&
|
||||
!payload.hideHeader &&
|
||||
!payload.hideChrome &&
|
||||
!payload.isolate
|
||||
) {
|
||||
store.removeItem(key);
|
||||
return;
|
||||
}
|
||||
store.setItem(key, JSON.stringify(payload));
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
/** 本标签页是否处于嵌入模式(驱动去全局 chrome + 强制主题)。 */
|
||||
export function isEmbedActive(): boolean {
|
||||
return readEmbedSession()?.embed === true;
|
||||
}
|
||||
|
||||
/** 本标签页是否要隐藏深链底部「快捷跳转」栏(TaskJumpBar)。 */
|
||||
export function isJumpBarHidden(): boolean {
|
||||
return readEmbedSession()?.hideJump === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this tab should hide its global Header.
|
||||
* `embed` and `hideChrome` also hide the Header, while `hideHeader` alone
|
||||
* deliberately leaves PageSidebar visible.
|
||||
*/
|
||||
export function isHeaderHidden(): boolean {
|
||||
const session = readEmbedSession();
|
||||
return session?.embed === true || session?.hideChrome === true || session?.hideHeader === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本标签页是否要去全局 chrome(Header 导航栏 + PageSidebar 侧边栏)。
|
||||
* `embed` 已隐含去 chrome,故这里取「embed || hideChrome」之并——任一置位都隐藏。
|
||||
* 与 `isEmbedActive()` 的差别:`hideChrome` 不强制主题、不隔离鉴权,只去 chrome。
|
||||
*/
|
||||
export function isChromeHidden(): boolean {
|
||||
const s = readEmbedSession();
|
||||
return s?.embed === true || s?.hideChrome === true;
|
||||
@@ -377,28 +295,38 @@ export function isHideChromeActive(): boolean {
|
||||
return readEmbedSession()?.hideChrome === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本标签页是否处于独立会话态:鉴权身份按标签页隔离(走 sessionStorage)。
|
||||
* `embed` 已隐含同样的隔离,故 `authStorage()` 取「embed || isolate」之并;这里只判 `isolate`,
|
||||
* 供需要区分「纯独立会话(带 chrome)」与「嵌入态(去 chrome)」的场合使用。
|
||||
*/
|
||||
export function isIsolatedSession(): boolean {
|
||||
return readEmbedSession()?.isolate === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 URL 参数同步 sessionStorage 会话(AppProviders 在每次 search 变化时调用一次)。
|
||||
* **合并语义**:on 置位、off 清位、未带则沿用既有值——这样登录后跳到不带参数的干净路由时,
|
||||
* 之前置位的开关不会被抹掉。
|
||||
*
|
||||
* 顶层额外规则:只有本窗 URL 显式开过嵌套开关时才把会话标为 `topOwned`;未认领的
|
||||
* chrome 标志一律丢弃(挡住老版同源嵌套 iframe 对共用键的污染)。
|
||||
*/
|
||||
export function syncEmbedSessionFromSearch(search: string): void {
|
||||
migrateLegacySharedSessionIfNested();
|
||||
if (isTopLevelWindow()) reclaimUnownedTopChrome();
|
||||
if (!isTopLevelWindow()) {
|
||||
migrateLegacySharedSessionIfNested();
|
||||
applySearchToSession(search, readStoredSession() ?? emptySession(), false);
|
||||
return;
|
||||
}
|
||||
|
||||
const cur = readEmbedSession() ?? emptySession();
|
||||
const stored = readStoredSession() ?? emptySession();
|
||||
if (searchEnablesChromeHide(search)) {
|
||||
applySearchToSession(search, stored, true);
|
||||
return;
|
||||
}
|
||||
if (isTopChromeOwned(stored)) {
|
||||
applySearchToSession(search, stored, true);
|
||||
return;
|
||||
}
|
||||
// 无主共用键:只处理 isolate URL,绝不把 chrome 标志写成 false 覆盖内层。
|
||||
const isolateParam = readIsolateParam(search);
|
||||
if (isolateParam === null) return;
|
||||
const isolate = isolateParam === "on";
|
||||
writeEmbedSession({ ...stored, isolate, topOwned: false });
|
||||
}
|
||||
|
||||
function applySearchToSession(
|
||||
search: string,
|
||||
cur: EmbedSession,
|
||||
topOwned: boolean,
|
||||
): void {
|
||||
const next: EmbedSession = { ...cur };
|
||||
|
||||
const embedParam = readEmbedParam(search);
|
||||
@@ -425,27 +353,11 @@ export function syncEmbedSessionFromSearch(search: string): void {
|
||||
if (isolateParam === "on") next.isolate = true;
|
||||
else if (isolateParam === "off") next.isolate = false;
|
||||
|
||||
if (isTopLevelWindow()) {
|
||||
if (searchEnablesChromeHide(search)) {
|
||||
next.topOwned = true;
|
||||
} else if (!isTopChromeOwned(next)) {
|
||||
// 未认领:丢掉可能由老版内层写脏的 chrome 标志,保留 isolate。
|
||||
Object.assign(next, stripChromeFlags(next), { isolate: next.isolate });
|
||||
} else {
|
||||
next.topOwned = true;
|
||||
}
|
||||
}
|
||||
|
||||
next.topOwned = topOwned;
|
||||
writeEmbedSession(next);
|
||||
if (isTopLevelWindow()) markTopChromeOwner(next);
|
||||
else reclaimPollutedTopChrome();
|
||||
if (topOwned) markTopChromeOwner(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从当前 HashRouter URL(`#/path?a=b`)取出 query 串。
|
||||
* 供「在 React 渲染之前」同步启用嵌入会话(main.tsx)——必须早于任何鉴权写入,
|
||||
* 否则首帧 LoginPage 的 effect 可能抢在 AppProviders 同步 effect 之前把登录参数写进 localStorage。
|
||||
*/
|
||||
function currentHashSearch(): string {
|
||||
const w = getWindow();
|
||||
if (!w) return "";
|
||||
@@ -454,18 +366,10 @@ function currentHashSearch(): string {
|
||||
return i >= 0 ? hash.slice(i) : "";
|
||||
}
|
||||
|
||||
/** main.tsx 在渲染前同步调用一次:据当前 URL 启用/关闭本标签页嵌入会话。 */
|
||||
export function syncEmbedSessionFromCurrentUrl(): void {
|
||||
syncEmbedSessionFromSearch(currentHashSearch());
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析「应当强制的嵌入主题」供 `AppProviders` 的 `forcedTheme` 用:
|
||||
* - URL 显式 `embed=1` → 用 URL `theme`(首帧即生效,登录页转场不闪烁);
|
||||
* - URL 显式 `embed=0` → 不强制(undefined);
|
||||
* - URL 未带 → 回退本标签页嵌入会话主题(仅 embed 置位时)。
|
||||
* 非嵌入标签页恒返回 undefined → 完全不影响正常系统页的主题。
|
||||
*/
|
||||
export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined {
|
||||
const param = readEmbedParam(search);
|
||||
if (param === "on") return resolveEmbedThemeMode(readEmbedThemeParam(search));
|
||||
@@ -474,12 +378,7 @@ export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined
|
||||
return session?.embed ? session.theme : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时直接置位/清除本标签页的独立会话标志(供「外观」设置里的开关用,区别于 URL 参数驱动)。
|
||||
* 仅切换标志;登录态的「localStorage 快照搬进 sessionStorage」由 [[scoped-storage]]
|
||||
* `migrateAuthToSession()` 负责,调用顺序见 appearance 设置页。
|
||||
*/
|
||||
export function setIsolatedSession(on: boolean): void {
|
||||
const cur = readEmbedSession() ?? emptySession();
|
||||
writeEmbedSession({ ...cur, isolate: on });
|
||||
const stored = readStoredSession() ?? emptySession();
|
||||
writeEmbedSession({ ...stored, isolate: on });
|
||||
}
|
||||
@@ -38,30 +38,46 @@ export function buildAppHashUrl(path: string): string {
|
||||
}
|
||||
|
||||
export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget | null {
|
||||
if (!url?.startsWith("/weknora-source-preview?")) {
|
||||
return null;
|
||||
if (!url) return null;
|
||||
|
||||
// Older stored messages use this private marker URL. Keep parsing it so old
|
||||
// conversations remain clickable, but never generate it for new links: it
|
||||
// is not a real route and becomes a 404 if an event handler is unavailable.
|
||||
if (url.startsWith("/weknora-source-preview?")) {
|
||||
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();
|
||||
const documentId =
|
||||
params.get("document_id")?.trim() ||
|
||||
params.get("knowledge_id")?.trim() ||
|
||||
"";
|
||||
const wikiSlug =
|
||||
params.get("wiki_slug")?.trim() ||
|
||||
params.get("wikiSlug")?.trim() ||
|
||||
params.get("slug")?.trim() ||
|
||||
"";
|
||||
return knowledgeBaseId && (wikiSlug || chunkId || documentId)
|
||||
? {
|
||||
knowledgeBaseId,
|
||||
chunkId,
|
||||
documentId: documentId || undefined,
|
||||
wikiSlug: wikiSlug || undefined,
|
||||
tab: wikiSlug ? "wiki" : "documents",
|
||||
}
|
||||
: 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();
|
||||
const documentId =
|
||||
params.get("document_id")?.trim() ||
|
||||
params.get("knowledge_id")?.trim() ||
|
||||
"";
|
||||
const wikiSlug =
|
||||
params.get("wiki_slug")?.trim() ||
|
||||
params.get("wikiSlug")?.trim() ||
|
||||
params.get("slug")?.trim() ||
|
||||
"";
|
||||
return knowledgeBaseId && (wikiSlug || chunkId || documentId)
|
||||
? {
|
||||
knowledgeBaseId,
|
||||
chunkId,
|
||||
documentId: documentId || undefined,
|
||||
wikiSlug: wikiSlug || undefined,
|
||||
tab: wikiSlug ? "wiki" : "documents",
|
||||
}
|
||||
|
||||
// New drawer links use the same real HashRouter URL as the normal Wiki
|
||||
// reader. This doubles as a safe browser-navigation fallback when React's
|
||||
// Markdown click handler is not mounted (for example after a deployment).
|
||||
const route = url.includes("#") ? url.slice(url.indexOf("#") + 1) : url;
|
||||
const match = route.match(/^\/page\/workspace\/knowledge\/([^/?]+)\/wiki\/view(?:\?([^#]*))?$/);
|
||||
if (!match) return null;
|
||||
const knowledgeBaseId = decodeURIComponent(match[1] || "").trim();
|
||||
const wikiSlug = new URLSearchParams(match[2] || "").get("wikiSlug")?.trim();
|
||||
return knowledgeBaseId && wikiSlug
|
||||
? { knowledgeBaseId, wikiSlug, tab: "wiki" }
|
||||
: null;
|
||||
}
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ export function buildWikiIndexCatalogMarkdown(
|
||||
buildPageUrl: WikiPageUrlBuilder = buildWeKnoraWikiPageUrl,
|
||||
indexGroups: WikiIndexGroup[] = [],
|
||||
): string {
|
||||
if (indexGroups.length > 0) {
|
||||
if (indexGroups.some((group) => group.items.length > 0)) {
|
||||
const sections: string[] = [];
|
||||
for (const group of indexGroups) {
|
||||
if (group.items.length === 0) continue;
|
||||
|
||||
@@ -20,6 +20,11 @@ export type SearchResultItem = {
|
||||
published_date?: string;
|
||||
publishedDate?: string;
|
||||
date?: string;
|
||||
/** Retrieval score supplied by the search provider, when available. */
|
||||
score?: number;
|
||||
/** Original retrieval payload type, retained for specialised result renderers. */
|
||||
type?: string;
|
||||
knowledge_base_id?: string;
|
||||
};
|
||||
|
||||
const RESULT_LIST_KEYS = [
|
||||
@@ -196,6 +201,25 @@ function pickFromLayers(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pickNumberFromLayers(
|
||||
layers: Record<string, unknown>[],
|
||||
aliases: readonly string[],
|
||||
): number | undefined {
|
||||
for (const layer of layers) {
|
||||
for (const alias of aliases) {
|
||||
const value = layer[alias];
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pickByKeyPattern(
|
||||
layers: Record<string, unknown>[],
|
||||
pattern: RegExp,
|
||||
@@ -326,6 +350,12 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
||||
const recUuid = pickFromLayers(layers, ["recUuid", "rec_uuid"]);
|
||||
const uuid = pickFromLayers(layers, ["uuid"]);
|
||||
const docId = pickFromLayers(layers, ["docId", "doc_id", "documentId"]);
|
||||
const score = pickNumberFromLayers(layers, [
|
||||
"score",
|
||||
"relevance_score",
|
||||
"rerank_score",
|
||||
"similarity",
|
||||
]);
|
||||
|
||||
const reserved = new Set<string>();
|
||||
for (const value of [title, content, url, source, author, date]) {
|
||||
@@ -363,6 +393,9 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
||||
published_date: date,
|
||||
publishedDate: date,
|
||||
date,
|
||||
score,
|
||||
type: pickString(record.type),
|
||||
knowledge_base_id: pickString(record.knowledge_base_id, record.knowledgeBaseId, record.mapping_id),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,83 @@ test("write_file tool after 用户协助 opens a processing group instead of err
|
||||
]);
|
||||
});
|
||||
|
||||
test("writing-mode progress merges into the deep_research_report bar so the summary sits below", () => {
|
||||
const types = groupTypes([
|
||||
{ type: "human", id: "h1", content: "写一篇论文" } as Message,
|
||||
{
|
||||
type: "ai",
|
||||
id: "a1",
|
||||
content: "",
|
||||
tool_calls: [{ id: "c1", name: "deep_research_report", args: { query: "多智能体" } }],
|
||||
} as Message,
|
||||
{
|
||||
type: "tool",
|
||||
id: "t1",
|
||||
name: "deep_research_report",
|
||||
content: "ok",
|
||||
tool_call_id: "c1",
|
||||
} as Message,
|
||||
{
|
||||
type: "ai",
|
||||
id: "progress",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{ id: "p1", name: "deep_research_progress", args: { label: "撰写研究报告", status: "complete" } },
|
||||
],
|
||||
} as Message,
|
||||
{
|
||||
type: "ai",
|
||||
id: "write",
|
||||
content: "",
|
||||
tool_calls: [{ id: "w1", name: "write_file", args: { path: "/mnt/user-data/outputs/report.md" } }],
|
||||
} as Message,
|
||||
{
|
||||
type: "ai",
|
||||
id: "summary",
|
||||
content: "已生成多智能体论文,详见 report.md。",
|
||||
} as Message,
|
||||
]);
|
||||
|
||||
assert.deepEqual(types, ["human", "assistant:processing", "assistant"]);
|
||||
});
|
||||
|
||||
test("a lone deep-research progress bar still opens its own group", () => {
|
||||
const types = groupTypes([
|
||||
{
|
||||
type: "ai",
|
||||
id: "progress",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{ id: "p1", name: "deep_research_progress", args: { label: "撰写研究报告", status: "active" } },
|
||||
],
|
||||
} as Message,
|
||||
]);
|
||||
|
||||
assert.deepEqual(types, ["assistant:processing"]);
|
||||
});
|
||||
|
||||
test("multi-round RAG assistant rows render even while the final answer is still empty", () => {
|
||||
const types = groupTypes([
|
||||
{ type: "human", id: "h1", content: "检索一下", additional_kwargs: { multi_round_rag: true } } as Message,
|
||||
{
|
||||
type: "ai",
|
||||
id: "a1",
|
||||
content: "",
|
||||
additional_kwargs: {
|
||||
multi_round_rag: true,
|
||||
multi_round_rag_trace: {
|
||||
title: "多轮检索思考过程",
|
||||
rounds: [{ id: "r1", title: "思考过程", thoughtMarkdown: "正在拆解问题", query: "", queryShow: true, retrievals: [] }],
|
||||
finalAnswerMarkdown: "",
|
||||
thinkingComplete: false,
|
||||
},
|
||||
},
|
||||
} as Message,
|
||||
]);
|
||||
|
||||
assert.deepEqual(types, ["human", "assistant"]);
|
||||
});
|
||||
|
||||
test("tool messages still attach to an open processing group", () => {
|
||||
const types = groupTypes([
|
||||
{
|
||||
|
||||
@@ -28,6 +28,8 @@ interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant:
|
||||
|
||||
interface AssistantTaskReportImportGroup extends GenericMessageGroup<"assistant:task-report-import"> {}
|
||||
|
||||
interface AssistantBusinessCardGroup extends GenericMessageGroup<"assistant:business-card"> {}
|
||||
|
||||
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
||||
|
||||
type MessageGroup =
|
||||
@@ -41,6 +43,7 @@ type MessageGroup =
|
||||
| AssistantSetupReportStructureApprovalGroup
|
||||
| AssistantResearchApprovalGroup
|
||||
| AssistantTaskReportImportGroup
|
||||
| AssistantBusinessCardGroup
|
||||
| AssistantSubagentGroup;
|
||||
|
||||
export function groupMessages<T>(
|
||||
@@ -65,7 +68,8 @@ export function groupMessages<T>(
|
||||
last.type !== "assistant:setup-agent-approval" &&
|
||||
last.type !== "assistant:setup-writing-approval" &&
|
||||
last.type !== "assistant:setup-report-structure-approval" &&
|
||||
last.type !== "assistant:research-approval"
|
||||
last.type !== "assistant:research-approval" &&
|
||||
last.type !== "assistant:business-card"
|
||||
) {
|
||||
return last;
|
||||
}
|
||||
@@ -137,6 +141,16 @@ export function groupMessages<T>(
|
||||
}
|
||||
|
||||
if (message.type === "ai") {
|
||||
// Multi-round RAG keeps rounds/retrievals on additional_kwargs while the
|
||||
// visible answer is still empty. The default rule below only opens an
|
||||
// assistant bubble when hasContent() is true, which would swallow the
|
||||
// live thinking cards until the final answer arrives (or forever, if the
|
||||
// turn is think/search-only). Force a normal assistant row so
|
||||
// MessageListItem can render MultiRoundRagAssistantBody immediately.
|
||||
if (message.additional_kwargs?.multi_round_rag === true) {
|
||||
groups.push({ id: message.id, type: "assistant", messages: [message] });
|
||||
continue;
|
||||
}
|
||||
// Deep-research interaction card: virtual ai messages injected by the
|
||||
// workbench (additional_kwargs.setup_research_approval) render through
|
||||
// the host-provided slot. Writing phases use ordinary virtual tool calls
|
||||
@@ -159,6 +173,17 @@ export function groupMessages<T>(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// 通用业务卡(报告协作方案组/命令确认/报告入口等):虚拟 ai 消息
|
||||
// (additional_kwargs.report_collaboration_card),由宿主注入并经
|
||||
// businessCardSlot 渲染;不传插槽的页面零渲染、零行为变化。
|
||||
if (hasBusinessCard(message)) {
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:business-card",
|
||||
messages: [message],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (hasPresentFiles(message)) {
|
||||
groups.push({
|
||||
id: message.id,
|
||||
@@ -166,14 +191,19 @@ export function groupMessages<T>(
|
||||
messages: [message],
|
||||
});
|
||||
} else if (hasOnlyDeepResearchProgress(message)) {
|
||||
// Each deep-research writing bar is its own group. Merging consecutive
|
||||
// processing messages would pin a new report's in-progress steps onto
|
||||
// the parent bar, which sits above the original file card.
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:processing",
|
||||
messages: [message],
|
||||
});
|
||||
// 写作模式把进度条插在同一次 `deep_research_report` 之后,必须并进
|
||||
// 当前 processing 组,总结才能排在整条步骤条下面。新一轮写作前面
|
||||
// 会有 human/assistant 隔开,不会误并到上一轮的条上。
|
||||
const lastGroup = groups[groups.length - 1];
|
||||
if (lastGroup?.type === "assistant:processing" && groupHasDeepResearchReport(lastGroup)) {
|
||||
lastGroup.messages.push(message);
|
||||
} else {
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:processing",
|
||||
messages: [message],
|
||||
});
|
||||
}
|
||||
} else if (hasSubagent(message)) {
|
||||
groups.push({
|
||||
id: message.id,
|
||||
@@ -334,12 +364,18 @@ export function extractReasoningContentFromMessage(message: Message) {
|
||||
if (message.type !== "ai") {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
message.additional_kwargs &&
|
||||
"reasoning_content" in message.additional_kwargs
|
||||
) {
|
||||
return message.additional_kwargs.reasoning_content as string | null;
|
||||
}
|
||||
if (
|
||||
message.additional_kwargs &&
|
||||
"reasoning_content" in message.additional_kwargs
|
||||
) {
|
||||
return message.additional_kwargs.reasoning_content as string | null;
|
||||
}
|
||||
if (
|
||||
message.additional_kwargs &&
|
||||
typeof message.additional_kwargs.reasoning === "string"
|
||||
) {
|
||||
return message.additional_kwargs.reasoning;
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
const part = message.content[0];
|
||||
if (part && "thinking" in part) {
|
||||
@@ -355,8 +391,9 @@ export function extractReasoningContentFromMessage(message: Message) {
|
||||
export function removeReasoningContentFromMessage(message: Message) {
|
||||
if (message.type !== "ai" || !message.additional_kwargs) {
|
||||
return;
|
||||
}
|
||||
delete message.additional_kwargs.reasoning_content;
|
||||
}
|
||||
delete message.additional_kwargs.reasoning_content;
|
||||
delete message.additional_kwargs.reasoning;
|
||||
}
|
||||
|
||||
export function extractURLFromImageURLContent(
|
||||
@@ -391,9 +428,12 @@ export function hasReasoning(message: Message) {
|
||||
if (message.type !== "ai") {
|
||||
return false;
|
||||
}
|
||||
if (typeof message.additional_kwargs?.reasoning_content === "string") {
|
||||
return true;
|
||||
}
|
||||
if (typeof message.additional_kwargs?.reasoning_content === "string") {
|
||||
return true;
|
||||
}
|
||||
if (typeof message.additional_kwargs?.reasoning === "string") {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
const part = message.content[0];
|
||||
// Compatible with the Anthropic gateway
|
||||
@@ -426,6 +466,14 @@ export function hasOnlyDeepResearchProgress(message: Message) {
|
||||
);
|
||||
}
|
||||
|
||||
function groupHasDeepResearchReport(group: MessageGroup): boolean {
|
||||
return group.messages.some(
|
||||
(message) =>
|
||||
message.type === "ai" &&
|
||||
Boolean(message.tool_calls?.some((toolCall) => toolCall.name === "deep_research_report")),
|
||||
);
|
||||
}
|
||||
|
||||
export function isClarificationToolMessage(message: Message) {
|
||||
return message.type === "tool" && message.name === "ask_clarification";
|
||||
}
|
||||
@@ -474,6 +522,19 @@ export function hasTaskReportImportApproval(message: Message) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用业务卡(报告协作等页面):宿主注入的虚拟 ai 消息
|
||||
* (additional_kwargs.report_collaboration_card),经 MessageList 的
|
||||
* businessCardSlot 渲染。集中在此识别,业务卡 payload 结构由各页面自定义。
|
||||
*/
|
||||
export function hasBusinessCard(message: Message) {
|
||||
return (
|
||||
message.type === "ai" &&
|
||||
typeof message.additional_kwargs?.report_collaboration_card === "object" &&
|
||||
message.additional_kwargs.report_collaboration_card !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function extractPresentFilesFromMessage(message: Message) {
|
||||
if (message.type !== "ai" || !hasPresentFiles(message)) {
|
||||
return [];
|
||||
@@ -499,11 +560,12 @@ export function hasSubagent(message: AIMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function findToolCallResult(
|
||||
toolCallId: string,
|
||||
messages: Message[],
|
||||
toolName?: string,
|
||||
): string | Record<string, unknown> | undefined {
|
||||
export function findToolCallResult(
|
||||
toolCallId: string,
|
||||
messages: Message[],
|
||||
toolName?: string,
|
||||
options?: { allowNameFallback?: boolean },
|
||||
): string | Record<string, unknown> | undefined {
|
||||
for (const message of messages) {
|
||||
if (message.type !== "tool" || message.tool_call_id !== toolCallId) {
|
||||
continue;
|
||||
@@ -515,7 +577,7 @@ export function findToolCallResult(
|
||||
}
|
||||
|
||||
// updates 流里偶发缺少 tool_call_id,但 content 已是检索结构
|
||||
if (toolName) {
|
||||
if (toolName && options?.allowNameFallback !== false) {
|
||||
for (const message of messages) {
|
||||
if (message.type !== "tool" || message.name !== toolName) {
|
||||
continue;
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface ManagedModel {
|
||||
base_url: string | null;
|
||||
request_timeout: number | null;
|
||||
max_retries: number | null;
|
||||
max_input_tokens: number | null;
|
||||
max_tokens: number | null;
|
||||
temperature: number | null;
|
||||
stream_usage: boolean | null;
|
||||
@@ -44,6 +45,7 @@ export interface ModelSavePayload {
|
||||
base_url?: string | null;
|
||||
request_timeout?: number | null;
|
||||
max_retries?: number | null;
|
||||
max_input_tokens?: number | null;
|
||||
max_tokens?: number | null;
|
||||
temperature?: number | null;
|
||||
stream_usage?: boolean | null;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Hash-router entry used by the customized Vite application for the isolated
|
||||
* official-style UI. Keeping this prefix distinct from `/page/*` prevents its
|
||||
* routes and mock state from altering the customized application.
|
||||
*/
|
||||
export const OFFICIAL_DEERFLOW_ROUTE_PREFIX = "/official";
|
||||
|
||||
/** The official-style workspace home shown after a normal login. */
|
||||
export const OFFICIAL_DEERFLOW_HOME_ROUTE =
|
||||
`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`;
|
||||
|
||||
/** Existing customized application's normal post-login destination. */
|
||||
export const CUSTOMIZED_DEERFLOW_HOME_ROUTE = "/page/workspace/chats/new";
|
||||
@@ -30,7 +30,7 @@ test("matches only workspace chats_iframe routes", () => {
|
||||
assert.equal(isChatsIframeWorkspacePath("/landing"), false);
|
||||
});
|
||||
|
||||
test("forces light theme for landing and chats_iframe routes only", () => {
|
||||
test("forces the dedicated presentation themes for landing, iframe, and wx chat routes", () => {
|
||||
assert.equal(resolveForcedThemeForPath("/landing"), "light");
|
||||
assert.equal(
|
||||
resolveForcedThemeForPath("/page/workspace/chats_iframe/new"),
|
||||
@@ -41,6 +41,14 @@ test("forces light theme for landing and chats_iframe routes only", () => {
|
||||
"light",
|
||||
);
|
||||
assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined);
|
||||
assert.equal(
|
||||
resolveForcedThemeForPath("/page/workspace/wx/chats/new"),
|
||||
"dark",
|
||||
);
|
||||
assert.equal(
|
||||
resolveForcedThemeForPath("/page/workspace/wx/agents/agent-1/chats/new"),
|
||||
"dark",
|
||||
);
|
||||
});
|
||||
|
||||
test("recognizes an explicit chatMode=iframe independently of the legacy marker", () => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { resolveEmbedThemeMode } from "../embed/theme-mode.mjs";
|
||||
const CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new";
|
||||
const IFRAME_LOGIN_SOURCE = "iframe";
|
||||
const STANDARD_CHATS_PATH = "/page/workspace/chats";
|
||||
/** Dedicated full chat surface whose presentation is always deep blue. */
|
||||
export const WX_FULL_CHAT_PATH_PREFIX = "/page/workspace/wx/";
|
||||
|
||||
function toSearchParams(search: string): URLSearchParams {
|
||||
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||
@@ -328,6 +330,10 @@ export function resolveForcedThemeForPath(
|
||||
return "light";
|
||||
}
|
||||
|
||||
if (pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX)) {
|
||||
return "dark";
|
||||
}
|
||||
|
||||
const isLoginPath = pathname === "/login" || pathname.startsWith("/login/");
|
||||
if (
|
||||
isChatsIframeWorkspacePath(pathname) ||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* host-nested-surface 深链嵌套页路径判定。
|
||||
* npx esbuild src/core/page-layout/host-nested-surface.test.ts --bundle --format=esm \
|
||||
* --platform=node --outfile=.tmp-tests/host-nested-surface.test.mjs
|
||||
* node --test .tmp-tests/host-nested-surface.test.mjs
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { isHostNestedSurfacePath } from "./host-nested-surface";
|
||||
|
||||
test("matches deeplink single-agent workspaces", () => {
|
||||
assert.equal(
|
||||
isHostNestedSurfacePath(
|
||||
"/page/strategy/task-management/plan-task-sentiment/agent/abc/chats",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isHostNestedSurfacePath(
|
||||
"/page/strategy/task-management/plan-task/plan-task-fx/agent/abc/chats/thread-1",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isHostNestedSurfacePath(
|
||||
"/page/strategy/task-management/plan-task/plan-task-action/agent/abc/chats/new",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isHostNestedSurfacePath(
|
||||
"/page/strategy/task-management/plan-task-agentfx/agent/agentfx-analyst/chats",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("matches deeplink roundtable and dashboard-agent", () => {
|
||||
assert.equal(
|
||||
isHostNestedSurfacePath(
|
||||
"/page/strategy/task-management/plan-task/plan-task-fx/roundtable",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isHostNestedSurfacePath("/page/strategy/dashboard-agent"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not match full-mode pages we use ourselves", () => {
|
||||
assert.equal(
|
||||
isHostNestedSurfacePath("/page/strategy/task-management/xk-task-jc"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isHostNestedSurfacePath("/page/strategy/task-management/plan-task-sentiment"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isHostNestedSurfacePath("/page/strategy/qa/multi-agent-roundtable"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isHostNestedSurfacePath("/page/workspace/roundtable/planning"),
|
||||
false,
|
||||
);
|
||||
assert.equal(isHostNestedSurfacePath("/page/workspace/chats/new"), false);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 外部系统 iframe 深链套用的页面:单智能体问答、多智能体会商、大屏绘制。
|
||||
* 这些路由只给宿主嵌套,不走全量 Header / PageSidebar;chrome 由路径决定,
|
||||
* 不依赖(也不改写)同源 iframe 共用的 `embed.session`。
|
||||
*/
|
||||
|
||||
/** 任务管理下的深链会商(goPath=`*-A`)。 */
|
||||
const TASK_ROUNDTABLE_RE =
|
||||
/\/task-management\/(?:plan-task-sentiment|plan-task\/plan-task-fx|plan-task\/plan-task-action)\/roundtable(?:\/|$)/;
|
||||
|
||||
/** 任务管理下的深链单智能体问答(含 agentfx)。 */
|
||||
const TASK_AGENT_CHATS_RE =
|
||||
/\/task-management\/(?:plan-task-sentiment|plan-task\/plan-task-fx|plan-task\/plan-task-action|plan-task-agentfx)\/agent\/[^/]+\/chats/;
|
||||
|
||||
export function isHostNestedSurfacePath(pathname: string): boolean {
|
||||
const path = (pathname || "").split("?")[0] || "";
|
||||
if (path.includes("/strategy/dashboard-agent")) return true;
|
||||
if (TASK_ROUNDTABLE_RE.test(path)) return true;
|
||||
if (TASK_AGENT_CHATS_RE.test(path)) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FileBox,
|
||||
FileSearch,
|
||||
FileText,
|
||||
FolderKanban,
|
||||
Gauge,
|
||||
Grid3X3,
|
||||
HelpCircle,
|
||||
@@ -48,12 +49,23 @@ export type MenuItem = {
|
||||
path?: string;
|
||||
/** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */
|
||||
aliasPaths?: string[];
|
||||
/** 从当前菜单项自身的 path / aliasPaths 前缀匹配中排除的路由。 */
|
||||
excludePaths?: string[];
|
||||
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
|
||||
userPath?: string;
|
||||
adminOnly?: boolean;
|
||||
children?: MenuItem[];
|
||||
};
|
||||
|
||||
/** Markdown 转 Word 功能的菜单定义,供全量与简洁模式共用。 */
|
||||
export const MARKDOWN_DOCX_MENU_PATH = "/page/workspace/knowledge/markdown-editor";
|
||||
export const MARKDOWN_DOCX_MENU_ITEM: MenuItem & { path: string } = {
|
||||
id: "resource-markdown-docx-editor",
|
||||
label: "md转word",
|
||||
icon: FileText,
|
||||
path: MARKDOWN_DOCX_MENU_PATH,
|
||||
};
|
||||
|
||||
export const sidebarMenuItems: MenuItem[] = [
|
||||
{
|
||||
id: "qa-center",
|
||||
@@ -61,24 +73,33 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
|
||||
{ id: "analysis-projects", label: "项目空间", icon: Layout, path: "/page/workspace/projects" },
|
||||
{ id: "artifact-library", label: "资料库", icon: Library, path: "/page/workspace/library" },
|
||||
{
|
||||
id: "multi-agent-roundtable",
|
||||
label: "多智能体会商",
|
||||
icon: Users,
|
||||
path: "/page/strategy/qa/multi-agent-roundtable",
|
||||
},
|
||||
{
|
||||
id: "strategy-projects",
|
||||
label: "我的项目",
|
||||
icon: FolderKanban,
|
||||
path: "/page/strategy/qa/projects",
|
||||
aliasPaths: ["/page/strategy/qa/strategy-selection"],
|
||||
},
|
||||
// {
|
||||
// id: "position-roundtable",
|
||||
// label: "岗位会商",
|
||||
// icon: Briefcase,
|
||||
// path: "/page/strategy/qa/position-roundtable",
|
||||
// },
|
||||
{
|
||||
id: "dashboard-agent",
|
||||
label: "大屏绘制",
|
||||
icon: Gauge,
|
||||
path: "/page/strategy/dashboard-agent",
|
||||
},
|
||||
// {
|
||||
// id: "dashboard-agent",
|
||||
// label: "大屏绘制",
|
||||
// icon: Gauge,
|
||||
// path: "/page/strategy/dashboard-agent",
|
||||
// },
|
||||
// { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
|
||||
{
|
||||
id: "qa-zzjc",
|
||||
@@ -88,12 +109,12 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
},
|
||||
{ id: "qa-zzxd", label: "XD应用", icon: Layout, path: "/page/strategy/qa/zzxd" },
|
||||
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
|
||||
{
|
||||
id: "qa-research-chat",
|
||||
label: "课题研究(对话版)",
|
||||
icon: FileText,
|
||||
path: "/page/canvas/ai-writing-chat",
|
||||
},
|
||||
// {
|
||||
// id: "qa-research-chat",
|
||||
// label: "课题研究(对话版)",
|
||||
// icon: FileText,
|
||||
// path: "/page/canvas/ai-writing-chat",
|
||||
// },
|
||||
{
|
||||
id: "qa-deep-research",
|
||||
label: "深度研究",
|
||||
@@ -257,12 +278,7 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
},
|
||||
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
|
||||
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
||||
{
|
||||
id: "resource-markdown-docx-editor",
|
||||
label: "md转word",
|
||||
icon: FileText,
|
||||
path: "/page/workspace/knowledge/markdown-editor",
|
||||
},
|
||||
MARKDOWN_DOCX_MENU_ITEM,
|
||||
{
|
||||
id: "mgmt-tasks",
|
||||
label: "定时任务",
|
||||
@@ -329,6 +345,7 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
label: "知识库管理",
|
||||
icon: BookOpen,
|
||||
path: "/page/workspace/knowledge",
|
||||
excludePaths: [MARKDOWN_DOCX_MENU_PATH],
|
||||
},
|
||||
{
|
||||
id: "resource-knowledge-base",
|
||||
@@ -364,6 +381,9 @@ function routeMatchesPathname(pathname: string, route?: string): boolean {
|
||||
|
||||
/** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */
|
||||
export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean {
|
||||
if (item.excludePaths?.some((path) => routeMatchesPathname(pathname, path))) {
|
||||
return false;
|
||||
}
|
||||
if (routeMatchesPathname(pathname, item.path)) return true;
|
||||
return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import { replaceMermaidFencesForWordExport } from "@/core/utils/mermaid";
|
||||
|
||||
const DOCX_MEDIA_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
@@ -38,13 +39,15 @@ export async function markdownToDocxBlob(
|
||||
const source = markdown?.trim();
|
||||
if (!source) throw new Error("文档内容为空");
|
||||
|
||||
const prepared = await replaceMermaidFencesForWordExport(source);
|
||||
const endpoint = `${getBackendBaseURL()}/api/writing/export/docx`;
|
||||
const response = await apiFetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: title?.trim() || undefined,
|
||||
markdown: source,
|
||||
markdown: prepared.markdown,
|
||||
embedded_images: prepared.embeddedImages,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw await responseError(response);
|
||||
|
||||
Loaded 100 of 818 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user