Compare commits

..
6 Commits
Author SHA1 Message Date
gozeros 1c933a8f19 sync snapshot from LAN repo (source tjlnew @ 9774c311) 2026-11-05 11:02:38 +08:00
gozeros ea9dcff9c0 sync update from LAN repo (source master @ ca48c4ec) 2026-11-05 10:53:54 +08:00
gozeros 8bc8b0e082 sync: 鍚屾灞€鍩熺綉鏈€鏂颁唬鐮?(source master @ b69568e4) 2026-10-21 14:51:31 +08:00
董宏凯 4f7f07504a Merge branch 'master-nj' into 'master'
fix(buttons): 行动分析宿主路由支持切换任务id/行动id

See merge request knowledge-base-bj/deerflow-code!39
2026-09-07 09:53:09 +00:00
吉鑫升 3ca5528640 feat(knowledge): support standalone file ingestion and configurable wiki entry
Use neutral knowledge-service labels and sanitize user-facing task errors.
2026-09-07 17:07:27 +08:00
董宏凯 f9b6d7e322 Merge branch 'master-nj' into 'master'
feat: 新增报告协作页,并强化 Deep Research 运行诊断与报告兜底

See merge request knowledge-base-bj/deerflow-code!38
2026-09-07 06:58:42 +00:00
370 changed files with 42832 additions and 3445 deletions

No files matched your search

+21 -9
View File
@@ -88,20 +88,27 @@ skills/
hermes-agent-main/ hermes-agent-main/
.gitignore .gitignore
offline-backend-20260512/backend/tests/* offline-backend-20260512/backend/tests/*
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned. # Keep wx common-agent category configuration regressions versioned.
!offline-backend-20260512/backend/tests/test_gauss_database.py !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_weknora.py
!offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.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 !offline-backend-20260512/backend/tests/test_health_auth_bypass.py
# Keep formal Word OOXML and embedded-font regression coverage versioned. # Keep formal Word OOXML and embedded-font regression coverage versioned.
!offline-backend-20260512/backend/tests/test_word_export.py !offline-backend-20260512/backend/tests/test_word_export.py
# Keep generated-file library aggregation and filtering regression coverage versioned. # Keep generated-file library aggregation and filtering regression coverage versioned.
!offline-backend-20260512/backend/tests/test_artifact_library.py !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 # Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests. # this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py !offline-backend-20260512/backend/tests/test_model_management.py
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned. # Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
!offline-backend-20260512/backend/tests/test_forced_research_agent.py !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. # Keep fixed-question exact-match and streaming regression coverage versioned.
!offline-backend-20260512/backend/tests/test_fixed_questions.py !offline-backend-20260512/backend/tests/test_fixed_questions.py
# Keep configurable es_query system-prompt routing regression coverage versioned. # Keep configurable es_query system-prompt routing regression coverage versioned.
@@ -110,6 +117,7 @@ offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py !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_vector_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.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_external_llmwiki_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py !offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py !offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
@@ -140,6 +148,10 @@ offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py !offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py
# LLM call timeout retry classification regression coverage. # LLM call timeout retry classification regression coverage.
!offline-backend-20260512/backend/tests/test_llm_error_handling_middleware.py !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. # Writing-mode pipeline conversation-material regressions.
!offline-backend-20260512/backend/tests/test_deep_research_report_tool.py !offline-backend-20260512/backend/tests/test_deep_research_report_tool.py
# Report collaboration (RC-BE-000–018) regression coverage. # Report collaboration (RC-BE-000–018) regression coverage.
@@ -176,10 +188,10 @@ docs/backups
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above) # Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
!frontend-web/src/components/workspace/skills/ !frontend-web/src/components/workspace/skills/
# uv lockfile (not tracked per project preference) # uv lockfile (not tracked per project preference)
offline-backend-20260512/backend/uv.lock offline-backend-20260512/backend/uv.lock
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py !offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
# Local-only scratch directory (not committed) # Local-only scratch directory (not committed)
0622/ 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/ 出现在产物目录扫描(文件噪音)——一期保持现状,二期优化。
+2
View File
@@ -105,6 +105,8 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **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 - **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 提示。 - **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 ### 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. 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.
+3
View File
@@ -81,8 +81,11 @@ 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` - **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) - **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. - **定时任务「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`. - **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). - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`. - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** 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`. - **白名单管理 (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.
+31
View File
@@ -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`。本轮不重复付费生成完整方案;没有进行生产全量语料质量评估、负载测试、断网故障注入或完整无障碍合规审计。已检查可见导航、表单名称、窄屏显示和主要失败恢复,不能据此承诺所有模块没有缺陷。代码未提交、推送或部署。
+37
View File
@@ -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 保持不变。未提交、推送代码,也未执行离线发布。
+1 -1
View File
@@ -18,7 +18,7 @@ VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。 # 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=true VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
# 轻应用「新建笔记」系列接口 # 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
+49
View File
@@ -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`。不在源码或文档中保存接口密钥。
+355 -350
View File
@@ -28,10 +28,10 @@
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-progress": "^1.1.8", "@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-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@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-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
@@ -69,7 +69,9 @@
"gsap": "^3.13.0", "gsap": "^3.13.0",
"hast": "^1.0.0", "hast": "^1.0.0",
"katex": "^0.16.28", "katex": "^0.16.28",
"leaflet": "^1.9.4",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"maplibre-gl": "^6.9.0",
"mermaid": "^11.15.0", "mermaid": "^11.15.0",
"motion": "^12.26.2", "motion": "^12.26.2",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
@@ -1702,6 +1704,92 @@
"@lezer/lr": "^1.4.0" "@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": { "node_modules/@marijn/find-cluster-break": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", "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": { "node_modules/@radix-ui/react-avatar": {
"version": "1.1.11", "version": "1.1.11",
"license": "MIT", "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": { "node_modules/@radix-ui/react-collection": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", "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": { "node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", "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": { "node_modules/@radix-ui/react-direction": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", "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": { "node_modules/@radix-ui/react-dropdown-menu": {
"version": "2.1.16", "version": "2.1.16",
"license": "MIT", "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": { "node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", "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": { "node_modules/@radix-ui/react-hover-card": {
"version": "1.1.15", "version": "1.1.15",
"license": "MIT", "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": { "node_modules/@radix-ui/react-icons": {
"version": "1.3.2", "version": "1.3.2",
"license": "MIT", "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": { "node_modules/@radix-ui/react-popper": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", "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": { "node_modules/@radix-ui/react-portal": {
"version": "1.1.9", "version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", "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": { "node_modules/@radix-ui/react-presence": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", "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": { "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", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/number": "1.1.1", "@radix-ui/number": "1.1.3",
"@radix-ui/primitive": "1.1.3", "@radix-ui/primitive": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-context": "1.1.2", "@radix-ui/react-context": "1.2.2",
"@radix-ui/react-direction": "1.1.1", "@radix-ui/react-direction": "1.1.4",
"@radix-ui/react-presence": "1.1.5", "@radix-ui/react-presence": "1.1.10",
"@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-primitive": "2.1.10",
"@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-callback-ref": "1.1.4",
"@radix-ui/react-use-layout-effect": "1.1.1" "@radix-ui/react-use-layout-effect": "1.1.4"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@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": { "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": {
"version": "1.1.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==",
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" "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": { "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-direction": {
"version": "2.1.3", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "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": { "dependencies": {
"@radix-ui/react-slot": "1.2.3" "@radix-ui/react-use-layout-effect": "1.1.4"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -2898,13 +2828,49 @@
} }
} }
}, },
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": {
"version": "1.2.3", "version": "2.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==",
"license": "MIT",
"dependencies": { "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": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" "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": { "node_modules/@radix-ui/react-separator": {
"version": "1.1.8", "version": "1.1.8",
"license": "MIT", "license": "MIT",
@@ -3031,10 +2980,12 @@
} }
}, },
"node_modules/@radix-ui/react-slot": { "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", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-compose-refs": "1.1.2" "@radix-ui/react-compose-refs": "1.1.5"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@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": { "node_modules/@radix-ui/react-switch": {
"version": "1.2.6", "version": "1.2.6",
"license": "MIT", "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": { "node_modules/@radix-ui/react-tabs": {
"version": "1.1.13", "version": "1.1.13",
"license": "MIT", "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": { "node_modules/@radix-ui/react-toggle": {
"version": "1.1.10", "version": "1.1.10",
"license": "MIT", "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": { "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "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": { "node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8", "version": "1.2.8",
"license": "MIT", "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": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", "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": { "node_modules/@radix-ui/rect": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
@@ -5557,6 +5421,15 @@
"version": "1.2.1", "version": "1.2.1",
"license": "BSD-2-Clause" "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": { "node_modules/browserslist": {
"version": "4.28.2", "version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
@@ -6459,6 +6332,12 @@
"node": ">= 0.4" "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": { "node_modules/electron-to-chromium": {
"version": "1.5.349", "version": "1.5.349",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
@@ -6853,6 +6732,12 @@
"node": ">= 0.4" "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": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "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", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" "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": { "node_modules/json5": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -7390,6 +7281,12 @@
"katex": "cli.js" "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": { "node_modules/khroma": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", "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", "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz",
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==" "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": { "node_modules/lie": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
@@ -7766,6 +7669,39 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/markdown-it": {
"version": "14.1.1", "version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", "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", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" "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": { "node_modules/mitt": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "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", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" "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": { "node_modules/mustache": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", "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", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" "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": { "node_modules/performance-now": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "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": "^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": { "node_modules/prismjs": {
"version": "1.30.0", "version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
@@ -9218,6 +9187,12 @@
"prosemirror-transform": "^1.1.0" "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": { "node_modules/proxy-from-env": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -9235,6 +9210,12 @@
"node": ">=6" "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": { "node_modules/raf": {
"version": "3.4.1", "version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
@@ -9618,6 +9599,24 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/robust-predicates": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
@@ -9971,6 +9970,12 @@
"url": "https://github.com/sponsors/SuperchupuDev" "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": { "node_modules/tiptap-markdown": {
"version": "0.9.0", "version": "0.9.0",
"resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz", "resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz",
+10 -2
View File
@@ -11,6 +11,9 @@
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network", "build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test: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", "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:chrome109-css": "node scripts/check-chrome109-css.mjs",
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs" "check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
@@ -36,10 +39,10 @@
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-progress": "^1.1.8", "@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-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@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-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
@@ -77,7 +80,9 @@
"gsap": "^3.13.0", "gsap": "^3.13.0",
"hast": "^1.0.0", "hast": "^1.0.0",
"katex": "^0.16.28", "katex": "^0.16.28",
"leaflet": "^1.9.4",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"maplibre-gl": "^6.9.0",
"mermaid": "^11.15.0", "mermaid": "^11.15.0",
"motion": "^12.26.2", "motion": "^12.26.2",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
@@ -121,5 +126,8 @@
"vite": "^6.0.0", "vite": "^6.0.0",
"vite-plugin-source-identifier": "^1.1.2" "vite-plugin-source-identifier": "^1.1.2"
}, },
"overrides": {
"@radix-ui/react-slot": "1.3.3"
},
"packageManager": "pnpm@10.26.2" "packageManager": "pnpm@10.26.2"
} }
+3
View File
@@ -0,0 +1,3 @@
# Keep indirect Radix primitives on the React 19 ref-stability fix too.
overrides:
'@radix-ui/react-slot': 1.3.3
+4
View File
@@ -40,6 +40,10 @@ window.__CM_RUNTIME_CONFIG__ = {
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。 // Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
VITE_CANVAS_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。 // routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。
// 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。 // 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。
VITE_ROUTE_LOGIN_PASSWORD: "", VITE_ROUTE_LOGIN_PASSWORD: "",
+3
View File
@@ -36,6 +36,7 @@ import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage"; import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
import EmbedChatPage from "./pages/EmbedChatPage"; import EmbedChatPage from "./pages/EmbedChatPage";
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage"; import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
import SiteStudioPage from "./pages/site-studio/SiteStudioPage";
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage"; import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage"; import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView"; import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
@@ -205,6 +206,8 @@ export function App() {
element={<DashboardStandalonePage />} element={<DashboardStandalonePage />}
/> />
<Route path="/official/*" element={<OfficialDeerFlowEntry />} /> <Route path="/official/*" element={<OfficialDeerFlowEntry />} />
{/* Web App 站点预览工作室:独立路由 + 独立视觉风格(无主应用 chrome) */}
<Route path="/sites/:threadId/:siteName" element={<SiteStudioPage />} />
<Route path="/page/*" element={pageElement} /> <Route path="/page/*" element={pageElement} />
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
@@ -9,8 +9,8 @@ import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview";
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`) * 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 * 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 — * 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 * The sandbox permits scripts so the preview can use the app-bundled HTML
* server already sanitizes script vectors and this is defense-in-depth). * visualization runtimes, while still withholding same-origin privileges.
* *
* The `charset=utf-8` on the blob type is required: a blob: URL whose * 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 * 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>(); const [url, setUrl] = useState<string>();
useEffect(() => { useEffect(() => {
const blob = new Blob([prepareGeneratedHtml(html ?? "")], { const blob = new Blob([
prepareGeneratedHtml(html ?? ""),
], {
type: "text/html;charset=utf-8", type: "text/html;charset=utf-8",
}); });
const objectUrl = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
@@ -1,5 +1,4 @@
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { Fragment } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -21,12 +20,12 @@ export function Breadcrumb({
)} )}
> >
{segments.map((segment, i) => ( {segments.map((segment, i) => (
<Fragment key={`${segment}-${i}`}> <div className="contents" key={`${segment}-${i}`}>
<span className="hidden shrink-0 whitespace-nowrap sm:inline"> <span className="hidden shrink-0 whitespace-nowrap sm:inline">
{segment} {segment}
</span> </span>
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" /> <ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
</Fragment> </div>
))} ))}
<span <span
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300" className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
@@ -51,6 +51,10 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
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 { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useAgentsPaginated } from "@/core/agents"; import { useAgentsPaginated } from "@/core/agents";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
@@ -69,6 +73,7 @@ import {
useToggleThreadPin, useToggleThreadPin,
} from "@/core/threads/hooks"; } 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 { import {
bootstrapResumePath, bootstrapResumePath,
isBootstrapThread, isBootstrapThread,
@@ -479,6 +484,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
) : ( ) : (
filteredThreads.map((thread) => { filteredThreads.map((thread) => {
const isBoot = isBootstrapThread(thread); const isBoot = isBootstrapThread(thread);
const projectType = myProjectConversationTypeOfThread(thread);
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread); const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
const isActive = href === pathname; const isActive = href === pathname;
const pinned = isThreadPinned(thread); const pinned = isThreadPinned(thread);
@@ -505,6 +511,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
)} )}
aria-label="智能体创建会话" aria-label="智能体创建会话"
/> />
) : projectType ? (
<MyProjectThreadIcon type={projectType} />
) : ( ) : (
<MessageSquare <MessageSquare
className={cn( className={cn(
@@ -516,9 +524,15 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
{pinned && ( {pinned && (
<Pin className="text-muted-foreground/70 size-3 shrink-0" /> <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))} {desensitize(titleOfThread(thread, t.pages.newChat))}
</span> </span>
{projectType && (
<MyProjectThreadBadge
className="mr-4"
type={projectType}
/>
)}
{isActive && ( {isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" /> <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, positionName,
skillsIcon, skillsIcon,
skillsTitle, skillsTitle,
belowSkillsSlot,
showCommonQuestions = false, showCommonQuestions = false,
jobResponsibilities, jobResponsibilities,
panelTitle, panelTitle,
@@ -181,6 +182,8 @@ export function AgentDetailPanel({
positionName?: string; positionName?: string;
skillsIcon?: ReactNode; skillsIcon?: ReactNode;
skillsTitle?: string; skillsTitle?: string;
/** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */
belowSkillsSlot?: ReactNode;
showCommonQuestions?: boolean; showCommonQuestions?: boolean;
/** /**
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示; * 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
@@ -940,6 +943,9 @@ export function AgentDetailPanel({
/> />
</Section> </Section>
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
{belowSkillsSlot}
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */} {/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />} {showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
</div> </div>
@@ -4,7 +4,12 @@ import { MessageSquareIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useThreadsPage } from "@/core/threads/hooks"; import { useThreadsPage } from "@/core/threads/hooks";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils"; import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -45,6 +50,7 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
{threads.map((thread) => { {threads.map((thread) => {
const href = pathOfThread(thread); const href = pathOfThread(thread);
const isActive = href === pathname; const isActive = href === pathname;
const projectType = myProjectConversationTypeOfThread(thread);
return ( return (
<Link <Link
key={thread.thread_id} key={thread.thread_id}
@@ -57,15 +63,20 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
: "text-muted-foreground hover:bg-accent hover:text-foreground", : "text-muted-foreground hover:bg-accent hover:text-foreground",
)} )}
> >
<MessageSquareIcon {projectType ? (
className={cn( <MyProjectThreadIcon type={projectType} />
"size-3.5 shrink-0", ) : (
isActive ? "text-primary" : "text-muted-foreground/70", <MessageSquareIcon
)} className={cn(
/> "size-3.5 shrink-0",
<span className="truncate"> isActive ? "text-primary" : "text-muted-foreground/70",
)}
/>
)}
<span className="min-w-0 flex-1 truncate">
{desensitize(titleOfThread(thread, "新对话"))} {desensitize(titleOfThread(thread, "新对话"))}
</span> </span>
{projectType && <MyProjectThreadBadge type={projectType} />}
</Link> </Link>
); );
})} })}
@@ -8,6 +8,7 @@ import {
ExternalLinkIcon, ExternalLinkIcon,
EyeIcon, EyeIcon,
FileType2Icon, FileType2Icon,
GlobeIcon,
LoaderIcon, LoaderIcon,
PackageIcon, PackageIcon,
SaveIcon, SaveIcon,
@@ -17,6 +18,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useNavigate } from "react-router-dom";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react"; import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -56,8 +58,9 @@ import {
} from "@/open-canvas/api/rewrite"; } from "@/open-canvas/api/rewrite";
import { publishArtifactToKnowledge } from "@/core/agents/api"; import { publishArtifactToKnowledge } from "@/core/agents/api";
import { useAgent } from "@/core/agents/hooks"; import { useAgent } from "@/core/agents/hooks";
import { apiFetch } from "@/core/api"; import { apiFetch } from "@/core/api";
import { copyTextToClipboard } from "@/core/clipboard"; import { copyTextToClipboard } from "@/core/clipboard";
import { siteStudioRoute, useWebAppCapability } from "@/core/sites/api";
import { import {
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
DOCUMENT_REWRITE_STARTED_EVENT, DOCUMENT_REWRITE_STARTED_EVENT,
@@ -209,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({ export function ArtifactFileDetail({
className, className,
filepath: filepathFromProps, filepath: filepathFromProps,
@@ -268,6 +287,14 @@ export function ArtifactFileDetail({
const { t } = useI18n(); const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts(); const { artifacts, setOpen, select } = useArtifacts();
const { isMock, thread, publicShareCode } = useThread(); 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(() => { const isWriteFileVirtual = useMemo(() => {
return filepathFromProps.startsWith("write-file:"); return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]); }, [filepathFromProps]);
@@ -1427,7 +1454,9 @@ export function ArtifactFileDetail({
toast.error("文件内容为空,暂时无法在新窗口打开"); toast.error("文件内容为空,暂时无法在新窗口打开");
return; return;
} }
const blob = new Blob([prepareGeneratedHtml(source)], { const blob = new Blob([
prepareGeneratedHtml(source),
], {
type: "text/html;charset=utf-8", type: "text/html;charset=utf-8",
}); });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -1454,7 +1483,11 @@ export function ArtifactFileDetail({
: language === "markdown" : language === "markdown"
? "text/markdown;charset=utf-8" ? "text/markdown;charset=utf-8"
: "text/plain;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 url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
@@ -1647,6 +1680,16 @@ export function ArtifactFileDetail({
onClick={handleOpenInNewWindow} onClick={handleOpenInNewWindow}
/> />
)} )}
{siteStudioTarget && webAppCapabilityQuery.data?.available && (
<ArtifactAction
icon={GlobeIcon}
label="站点预览"
tooltip="打开站点预览工作室(多页面站点 / 流程图 / 地图)"
onClick={() =>
navigate(siteStudioRoute(threadId, siteStudioTarget))
}
/>
)}
<ArtifactAction <ArtifactAction
icon={DownloadIcon} icon={DownloadIcon}
label={t.common.download} label={t.common.download}
@@ -2094,7 +2137,9 @@ export function ArtifactFilePreview({
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。 // <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
// Intercept in-page anchor clicks so they scroll within the iframe instead // Intercept in-page anchor clicks so they scroll within the iframe instead
// of navigating the sandboxed blob: document (which blanks the frame). // 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", type: "text/html;charset=utf-8",
}); });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -16,6 +16,7 @@ import {
getFileExtensionDisplayName, getFileExtensionDisplayName,
getFileIcon, getFileIcon,
getFileName, getFileName,
isWordDocumentFile,
} from "@/core/utils/files"; } from "@/core/utils/files";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -93,12 +94,12 @@ export function ArtifactFileList({
return ( return (
<ul className={cn("flex w-full flex-col gap-4", className)}> <ul className={cn("flex w-full flex-col gap-4", className)}>
{files.map((file) => ( {files.map((file) => (
<Card <Card
key={file} key={file}
className="relative cursor-pointer p-3" className={cn("relative p-3", !isWordDocumentFile(file) && "cursor-pointer")}
onClick={() => handleClick(file)} 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"> <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"> <CardTitle className="relative min-w-0 pl-8 leading-tight [overflow-wrap:anywhere] break-words">
<div className="min-w-0">{getFileName(file)}</div> <div className="min-w-0">{getFileName(file)}</div>
@@ -133,7 +134,7 @@ export function ArtifactFileList({
</Button> </Button>
</CardAction> </CardAction>
</CardHeader> </CardHeader>
</Card> </Card>
))} ))}
<SkillInstallDialog <SkillInstallDialog
open={!!installFile} open={!!installFile}
@@ -19,7 +19,10 @@ import {
listThreadArtifacts, listThreadArtifacts,
mergeArtifactPaths, mergeArtifactPaths,
} from "@/core/artifacts/listing"; } from "@/core/artifacts/listing";
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight"; import {
latestInFlightWriteFilePreview,
latestPreparedHtmlArtifact,
} from "@/core/artifacts/write-file-in-flight";
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode"; import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
@@ -35,8 +38,10 @@ import {
buildSkillDisplayMap, buildSkillDisplayMap,
collectRuntimeDisplaySources, collectRuntimeDisplaySources,
} from "@/core/skill-display/runtime"; } from "@/core/skill-display/runtime";
import { isWordDocumentFile } from "@/core/utils/files";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import { import {
ArtifactFileDetail, ArtifactFileDetail,
@@ -83,10 +88,12 @@ const ChatBox: React.FC<{
children: React.ReactNode; children: React.ReactNode;
threadId: string; threadId: string;
disableArtifacts?: boolean; disableArtifacts?: boolean;
writingMode?: boolean;
}> = ({ }> = ({
children, children,
threadId, threadId,
disableArtifacts = false, disableArtifacts = false,
writingMode,
}) => { }) => {
const { const {
isMock, isMock,
@@ -106,15 +113,17 @@ const ChatBox: React.FC<{
const latestReferenceBatchIdRef = useRef<string | null>(null); const latestReferenceBatchIdRef = useRef<string | null>(null);
const manualReferenceBatchRef = useRef(false); const manualReferenceBatchRef = useRef(false);
const referenceDebugSignatureRef = useRef<string>(""); const referenceDebugSignatureRef = useRef<string>("");
const referenceBatchesRef = useRef<BackendReferenceBatch[]>([]);
const [sideTab, setSideTab] = useState("document"); const [sideTab, setSideTab] = useState("document");
const [referencesOpen, setReferencesOpen] = useState(true); const [referencesOpen, setReferencesOpen] = useState(true);
const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null); const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null);
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null); const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
const [wikiCitationSource, setWikiCitationSource] = useState<BackendReferenceSource | null>(null);
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]); const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
const [settings] = useLocalSettings(); const [settings] = useLocalSettings();
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持 // 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。 // 原有行为(新产物自动打开 + in-flight 预览不过滤)。
const writingModeEnabled = !!settings.context.writing_mode; const writingModeEnabled = writingMode ?? !!settings.context.writing_mode;
const artifactDirectoryModeEnabled = const artifactDirectoryModeEnabled =
settings.display.artifactDirectoryModeEnabled ?? false; settings.display.artifactDirectoryModeEnabled ?? false;
const { data: citationDisplay } = useCitationDisplaySettings(); const { data: citationDisplay } = useCitationDisplaySettings();
@@ -351,6 +360,9 @@ const ChatBox: React.FC<{
latestReferenceBatch; latestReferenceBatch;
const referenceSources = activeReferenceBatch?.sources ?? []; const referenceSources = activeReferenceBatch?.sources ?? [];
const hasCitationReferences = referenceSources.length > 0; const hasCitationReferences = referenceSources.length > 0;
useEffect(() => {
referenceBatchesRef.current = referenceBatches;
}, [referenceBatches]);
useEffect(() => { useEffect(() => {
if (!shouldFetchBackendReferenceBatches || !activeReferenceBatch) { if (!shouldFetchBackendReferenceBatches || !activeReferenceBatch) {
return; return;
@@ -396,6 +408,16 @@ const ChatBox: React.FC<{
window.setTimeout(() => { window.setTimeout(() => {
setPinnedReferenceId(detail?.referenceId ?? null); setPinnedReferenceId(detail?.referenceId ?? null);
}, 0); }, 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); setReferencesOpen(true);
setSideTab("references"); setSideTab("references");
}; };
@@ -459,7 +481,12 @@ const ChatBox: React.FC<{
) { ) {
if (thread?.values?.artifacts?.length > 0) { if (thread?.values?.artifacts?.length > 0) {
setAutoSelectFirstArtifact(false); setAutoSelectFirstArtifact(false);
selectArtifact(thread.values.artifacts[0]!); const firstPreviewableArtifact = thread.values.artifacts.find(
(artifact) => !isWordDocumentFile(artifact),
);
if (firstPreviewableArtifact) {
selectArtifact(firstPreviewableArtifact);
}
} }
} }
}, [ }, [
@@ -497,6 +524,25 @@ const ChatBox: React.FC<{
setSideTab("document"); setSideTab("document");
}, [openStreamedArtifact, streamingWritePreview]); }, [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); const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
useEffect(() => { useEffect(() => {
const wasEnabled = directoryModeWasEnabledRef.current; const wasEnabled = directoryModeWasEnabledRef.current;
@@ -504,7 +550,8 @@ const ChatBox: React.FC<{
if ( if (
wasEnabled || wasEnabled ||
!artifactDirectoryModeEnabled || !artifactDirectoryModeEnabled ||
availableArtifacts.length === 0 availableArtifacts.length === 0 ||
isWordDocumentFile(availableArtifacts[availableArtifacts.length - 1]!)
) { ) {
return; return;
} }
@@ -744,6 +791,17 @@ const ChatBox: React.FC<{
</div> </div>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </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> </ReferenceBatchesContext.Provider>
); );
}; };
@@ -756,19 +814,23 @@ function ReferencePanel({
sources: Parameters<typeof SkillDisplaySources>[0]["sources"]; sources: Parameters<typeof SkillDisplaySources>[0]["sources"];
}) { }) {
const orderedSources = useMemo(() => { const orderedSources = useMemo(() => {
if (!highlightedReferenceId) { // This is presentation-only. Keep every source's original `index`, since
return sources; // inline citations resolve through that index (for example [3] must still
} // open the third source returned by retrieval even when its card is shown
const index = Number(highlightedReferenceId); // below a higher-scoring result).
if (!Number.isFinite(index)) { return sources
return sources; .map((source, originalOrder) => ({ source, originalOrder }))
} .sort((left, right) => {
const selected = sources.find((source) => source.index === index); const leftScore = typeof left.source.score === "number" && Number.isFinite(left.source.score)
if (!selected) { ? left.source.score
return sources; : Number.NEGATIVE_INFINITY;
} const rightScore = typeof right.source.score === "number" && Number.isFinite(right.source.score)
return [selected, ...sources.filter((source) => source.index !== index)]; ? right.source.score
}, [highlightedReferenceId, sources]); : Number.NEGATIVE_INFINITY;
return rightScore - leftScore || left.originalOrder - right.originalOrder;
})
.map(({ source }) => source);
}, [sources]);
return ( return (
<div className="flex size-full flex-col overflow-hidden"> <div className="flex size-full flex-col overflow-hidden">
@@ -29,13 +29,14 @@ import {
PinOff, PinOff,
Plus, Plus,
Search, Search,
Settings,
Share2, Share2,
Star, Star,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation"; 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 { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -60,7 +61,12 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
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 { useAgentsPaginated, type Agent } from "@/core/agents"; import { useAgentsPaginated, type Agent } from "@/core/agents";
import { getAccountDisplayName } from "@/core/auth";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
@@ -75,6 +81,7 @@ import {
useToggleThreadPin, useToggleThreadPin,
} from "@/core/threads/hooks"; } 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 { import {
isBootstrapThread, isBootstrapThread,
isHiddenFromRecentChats, isHiddenFromRecentChats,
@@ -87,6 +94,13 @@ import {
} from "@/core/threads/utils"; } from "@/core/threads/utils";
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route"; import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
import { useRouteBase } from "@/core/route-base"; 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 { env } from "@/env";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime"; import { isIMEComposing } from "@/lib/ime";
@@ -107,37 +121,149 @@ function SectionLabel({
count, count,
collapsed, collapsed,
onToggle, onToggle,
onOpenSettings,
}: { }: {
label: string; label: string;
count?: number; count?: number;
collapsed: boolean; collapsed: boolean;
onToggle: () => void; onToggle: () => void;
onOpenSettings?: () => void;
}) { }) {
return ( return (
<button <div className="flex w-full items-center px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80">
type="button" <button
onClick={onToggle} type="button"
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" onClick={onToggle}
> className="flex min-w-0 flex-1 items-center justify-between transition-colors hover:text-muted-foreground select-none"
<span className="flex items-center gap-1.5"> >
<span>{label}</span> <span className="flex min-w-0 items-center gap-1.5">
{count != null && ( <span className="truncate">{label}</span>
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums"> {count != null && (
{count} <span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
</span> {count}
)} </span>
</span> )}
<ChevronDown </span>
className={cn( <ChevronDown
"size-3.5 shrink-0 transition-transform duration-200", className={cn(
collapsed && "-rotate-90", "size-3.5 shrink-0 transition-transform duration-200",
)} collapsed && "-rotate-90",
/> )}
</button> />
</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 { t } = useI18n();
const routeBase = useRouteBase(); const routeBase = useRouteBase();
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。 // 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
@@ -158,7 +284,60 @@ export function GeneralQaSidebar() {
setAgentsCollapsed((v) => { setAgentsCollapsed((v) => {
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v)); localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
return !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 = () => const toggleChats = () =>
setChatsCollapsed((v) => { setChatsCollapsed((v) => {
localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v)); localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v));
@@ -166,9 +345,38 @@ export function GeneralQaSidebar() {
}); });
// ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)── // ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)──
const { agents: mine } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 }); const { agents: mine } = useAgentsPaginated({
const { agents: square } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 }); scope: "mine",
const frequent = useMemo<Agent[]>(() => { 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 out: Agent[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
const add = (agent: Agent | undefined) => { const add = (agent: Agent | undefined) => {
@@ -180,7 +388,7 @@ export function GeneralQaSidebar() {
mine.forEach(add); mine.forEach(add);
square.forEach(add); square.forEach(add);
return out; return out;
}, [mine, square]); }, [mine, sentimentProfile, square, wxAgents, wxShowsSentiment, wxUsesCategoryFilter]);
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。 // 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -200,16 +408,25 @@ export function GeneralQaSidebar() {
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。 // 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
const handleSelectAgent = useCallback( const handleSelectAgent = useCallback(
(agentId: string) => { (agentId: string) => {
if (agentId === SENTIMENT_AGENT_ID) {
navigate(
buildFullChatNavigationPath(
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats/new`,
currentSearch,
),
);
return;
}
navigate( navigate(
buildFullChatNavigationPath( buildFullChatNavigationPath(
routeBase === "/page/workspace" routeBase === "/page/workspace" || wxCommonAgents
? `${newChatPath}?agent=${encodeURIComponent(agentId)}` ? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`, : `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
currentSearch, currentSearch,
), ),
); );
}, },
[currentSearch, navigate, newChatPath, routeBase], [currentSearch, navigate, newChatPath, routeBase, wxCommonAgents],
); );
// ── 最近的对话取数 + 搜索 ── // ── 最近的对话取数 + 搜索 ──
@@ -354,17 +571,24 @@ export function GeneralQaSidebar() {
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2"> <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"> <div className="pt-2">
<SectionLabel <SectionLabel
label="常用智能体" label="常用智能体"
count={wxUsesCategoryFilter ? frequent.length : undefined}
collapsed={agentsCollapsed} collapsed={agentsCollapsed}
onToggle={toggleAgents} onToggle={toggleAgents}
onOpenSettings={isWxManager ? () => setAgentTypeSettingsOpen(true) : undefined}
/> />
{!agentsCollapsed && ( {!agentsCollapsed && (
<div className="mt-1 flex flex-col gap-1"> <div className="mt-1 flex flex-col gap-1">
{frequent.map((agent) => { {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); const { from, to, foreground } = agentColor(agent.id, theme);
return ( return (
<button <button
@@ -483,6 +707,7 @@ export function GeneralQaSidebar() {
); );
const isActive = href === pathname; const isActive = href === pathname;
const isBoot = isBootstrapThread(thread); const isBoot = isBootstrapThread(thread);
const projectType = myProjectConversationTypeOfThread(thread);
const pinned = isThreadPinned(thread); const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread); const favorited = isThreadFavorite(thread);
return ( return (
@@ -507,6 +732,8 @@ export function GeneralQaSidebar() {
)} )}
aria-label="智能体创建会话" aria-label="智能体创建会话"
/> />
) : projectType ? (
<MyProjectThreadIcon type={projectType} />
) : ( ) : (
<MessageSquare <MessageSquare
className={cn( className={cn(
@@ -518,9 +745,15 @@ export function GeneralQaSidebar() {
{pinned && ( {pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" /> <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))} {desensitize(titleOfThread(thread, t.pages.newChat))}
</span> </span>
{projectType && (
<MyProjectThreadBadge
className="mr-4"
type={projectType}
/>
)}
{isActive && ( {isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" /> <span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)} )}
@@ -651,6 +884,14 @@ export function GeneralQaSidebar() {
if (!o) setShareThreadId(null); if (!o) setShareThreadId(null);
}} }}
/> />
{wxCommonAgents && isWxManager && (
<WxAgentTypeSettingsDialog
open={agentTypeSettingsOpen}
onOpenChange={setAgentTypeSettingsOpen}
agentType={wxUsesCategoryFilter ? configuredWxAgentType : null}
options={wxAgentTypeOptions}
/>
)}
</div> </div>
); );
} }
@@ -2,7 +2,6 @@
import type { ChatStatus } from "ai"; import type { ChatStatus } from "ai";
import { import {
BrainCircuitIcon,
BookMarkedIcon, BookMarkedIcon,
CheckIcon, CheckIcon,
FileTextIcon, FileTextIcon,
@@ -101,25 +100,10 @@ import {
import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu"; import { CustomPromptBar } from "./custom-prompt-menu";
import { useThread } from "./messages/context"; import { resolveInputMode, type InputMode } from "./input-mode";
import { ModeHoverGuide } from "./mode-hover-guide"; import { useThread } from "./messages/context";
import { Tooltip } from "./tooltip"; 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";
}
export function InputBox({ export function InputBox({
className, className,
@@ -137,13 +121,13 @@ export function InputBox({
onStop, onStop,
promptPrefix, promptPrefix,
agentSelector, agentSelector,
knowledgeSelector, knowledgeSelector,
knowledgeSuggestedQuestions = [],
extraModels = [], extraModels = [],
assistantId, assistantId,
hideAdminRecommendedQuestions, hideAdminRecommendedQuestions,
compact, compact,
hideContextTools, hideContextTools,
hideMemoryTool = true,
...props ...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & { }: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null; assistantId?: string | null;
@@ -151,14 +135,9 @@ export function InputBox({
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框, /** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */ * 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
compact?: boolean; compact?: boolean;
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等 /** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等
* 受控场景用;文件/模型等其他按钮不受影响)。 */ * 受控场景用;文件/模型等其他按钮不受影响)。 */
hideContextTools?: boolean; hideContextTools?: boolean;
/**
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
* 写作和参考文献开关不受影响。
*/
hideMemoryTool?: boolean;
disabled?: boolean; disabled?: boolean;
context: Omit< context: Omit<
AgentThreadContext, AgentThreadContext,
@@ -167,7 +146,6 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined; mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean; writing_mode?: boolean;
memory_injection_enabled?: boolean;
}; };
extraHeader?: React.ReactNode; extraHeader?: React.ReactNode;
isNewThread?: boolean; isNewThread?: boolean;
@@ -184,7 +162,6 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined; mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean; writing_mode?: boolean;
memory_injection_enabled?: boolean;
}, },
) => void; ) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void; onFollowupsVisibilityChange?: (visible: boolean) => void;
@@ -209,8 +186,10 @@ export function InputBox({
selectedAgentId: string | null; selectedAgentId: string | null;
onAgentChange: (agentId: string | null) => void; onAgentChange: (agentId: string | null) => void;
}; };
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */ /** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
knowledgeSelector?: ReactNode; knowledgeSelector?: ReactNode;
/** High-value starter questions derived from the currently selected spaces. */
knowledgeSuggestedQuestions?: string[];
/** Page-scoped model choices implemented outside the DeerFlow model API. */ /** Page-scoped model choices implemented outside the DeerFlow model API. */
extraModels?: Model[]; extraModels?: Model[];
}) { }) {
@@ -247,8 +226,9 @@ export function InputBox({
const { questions: recommendedQuestions } = useRecommendedQuestions(); const { questions: recommendedQuestions } = useRecommendedQuestions();
const userPreferences = useUserPreferences(); const userPreferences = useUserPreferences();
const showAdminRecommendedQuestions = const showAdminRecommendedQuestions =
!hideAdminRecommendedQuestions && !hideAdminRecommendedQuestions &&
knowledgeSuggestedQuestions.length === 0 &&
!userPreferences.hide_admin_prompt_prefix && !userPreferences.hide_admin_prompt_prefix &&
recommendedQuestions.length > 0; recommendedQuestions.length > 0;
@@ -265,7 +245,7 @@ export function InputBox({
const fallbackModel = currentModel ?? modelOptions[0]!; const fallbackModel = currentModel ?? modelOptions[0]!;
const supportsThinking = fallbackModel.supports_thinking ?? false; const supportsThinking = fallbackModel.supports_thinking ?? false;
const nextModelName = fallbackModel.name; const nextModelName = fallbackModel.name;
const nextMode = getResolvedMode(context.mode, supportsThinking); const nextMode = resolveInputMode(context.mode, supportsThinking);
if ( if (
context.model_name === nextModelName && context.model_name === nextModelName &&
@@ -312,7 +292,7 @@ export function InputBox({
onContextChange?.({ onContextChange?.({
...context, ...context,
model_name, model_name,
mode: getResolvedMode(context.mode, model.supports_thinking ?? false), mode: resolveInputMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: context.reasoning_effort, reasoning_effort: context.reasoning_effort,
execution_profile: model.execution_profile, execution_profile: model.execution_profile,
}); });
@@ -328,7 +308,7 @@ export function InputBox({
onContextChange?.({ onContextChange?.({
...context, ...context,
model_name: ordinaryModel?.name ?? context.model_name, model_name: ordinaryModel?.name ?? context.model_name,
mode: getResolvedMode( mode: resolveInputMode(
mode, mode,
ordinaryModel?.supports_thinking ?? supportThinking, ordinaryModel?.supports_thinking ?? supportThinking,
), ),
@@ -357,7 +337,7 @@ export function InputBox({
onContextChange?.({ onContextChange?.({
...context, ...context,
model_name: ragModel.name, model_name: ragModel.name,
mode: getResolvedMode( mode: resolveInputMode(
context.mode, context.mode,
ragModel.supports_thinking ?? false, ragModel.supports_thinking ?? false,
), ),
@@ -397,14 +377,6 @@ export function InputBox({
}); });
}, [artifactDirectoryModeEnabled, setLocalSettings]); }, [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 ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked; const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
const handleRagModeToggle = useCallback(() => { const handleRagModeToggle = useCallback(() => {
@@ -444,7 +416,7 @@ export function InputBox({
onContextChange?.({ onContextChange?.({
...context, ...context,
model_name: resolvedModelName, model_name: resolvedModelName,
mode: getResolvedMode( mode: resolveInputMode(
context.mode, context.mode,
selectedModel?.supports_thinking ?? false, selectedModel?.supports_thinking ?? false,
), ),
@@ -616,13 +588,35 @@ export function InputBox({
return ( return (
<div ref={promptRootRef} className="relative flex flex-col gap-6"> <div ref={promptRootRef} className="relative flex flex-col gap-6">
{extraHeader && ( {extraHeader && (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
{extraHeader} {extraHeader}
</div> </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]"> <div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
{recommendedQuestions.map((q) => ( {recommendedQuestions.map((q) => (
<button <button
@@ -733,12 +727,12 @@ export function InputBox({
</PromptInputBody> </PromptInputBody>
<PromptInputFooter <PromptInputFooter
className={cn( 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", compact && "gap-2 px-3 pt-1.5 pb-3",
)} )}
> >
<PromptInputTools <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 {/* TODO: Add more connectors here
<PromptInputActionMenu> <PromptInputActionMenu>
@@ -813,32 +807,6 @@ export function InputBox({
<span className="text-xs font-normal">文件</span> <span className="text-xs font-normal">文件</span>
</PromptInputButton> </PromptInputButton>
</Tooltip>} </Tooltip>}
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <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 {referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
content={ content={
ragModeEnabled ragModeEnabled
@@ -1278,7 +1246,7 @@ export function InputBox({
</PromptInputActionMenu> </PromptInputActionMenu>
)} )}
</PromptInputTools> </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 && ( {!frontendRagActive && agentSelector && (
<AgentSelector <AgentSelector
selectedAgentId={agentSelector.selectedAgentId} 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";
}
@@ -6,13 +6,22 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import { import {
loadKnowledgeBaseProductVersion, loadKnowledgeBaseProductVersion,
loadOrdinaryKnowledgeLanding,
saveOrdinaryKnowledgeLanding,
type OrdinaryKnowledgeLanding,
type KnowledgeBaseProductVersion, type KnowledgeBaseProductVersion,
updateKnowledgeBaseProductVersion, updateKnowledgeBaseProductVersion,
} from "@/core/system-settings/api"; } from "@/core/system-settings/api";
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const; 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_VALUE = "normal";
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`; export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
@@ -25,6 +34,14 @@ export function KnowledgeProductNav() {
const navigate = useNavigate(); const navigate = useNavigate();
const client = useQueryClient(); const client = useQueryClient();
const version = useKnowledgeBaseProductVersion(); 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 isAdmin = getStoredAuth()?.system_role === "admin";
const save = useMutation({ const save = useMutation({
mutationFn: updateKnowledgeBaseProductVersion, mutationFn: updateKnowledgeBaseProductVersion,
@@ -36,15 +53,20 @@ export function KnowledgeProductNav() {
const assistant = location.pathname.includes("/knowledge/assistant"); const assistant = location.pathname.includes("/knowledge/assistant");
return ( return (
<div className="flex items-center gap-2 border-b bg-card px-6 py-2"> <div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
<Button size="sm" variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}> <Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
<BookOpen />普通知识库 <BookOpen />普通知识库
</Button> </Button>
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}> <Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
<Database />助手知识库 <Database />助手知识库
</Button> </Button>
{isAdmin && ( {isAdmin && (
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground"> <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)}> <Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
@@ -35,6 +35,27 @@ import {
type SelectorSource = "all" | "agent"; type SelectorSource = "all" | "agent";
type SelectorVariant = "popover" | "panel"; 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[]) { function groupRows(rows: LlmWikiKnowledgeBase[]) {
return [ return [
@@ -323,9 +344,14 @@ function SelectionContent({
</div> </div>
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2"> <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"> <div>
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`} <div className="text-[11px] text-muted-foreground">
</span> {readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
</div>
{selectedCount > 0 && (
<div className="mt-0.5 text-[10px] text-primary">仅检索已选知识空间,不联网</div>
)}
</div>
{readOnly ? ( {readOnly ? (
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span> <span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
) : ( ) : (
@@ -363,9 +389,10 @@ function SelectionContent({
export function LlmWikiKnowledgeSelector({ export function LlmWikiKnowledgeSelector({
value, value,
assistantValue = [], assistantValue = EMPTY_IDS,
onChange, onChange,
onAssistantChange = () => undefined, onAssistantChange = () => undefined,
onSuggestedQuestionsChange,
source = "all", source = "all",
agentId, agentId,
autoSelectAll = false, autoSelectAll = false,
@@ -373,12 +400,15 @@ export function LlmWikiKnowledgeSelector({
readOnly = false, readOnly = false,
className, className,
label = "知识空间", label = "知识空间",
panelTitle = "绑定知识空间",
panelDescription = "新建该智能体对话时,只能从已绑定空间中选择本次检索范围。",
variant = "popover", variant = "popover",
}: { }: {
value: string[]; value: string[];
assistantValue?: string[]; assistantValue?: string[];
onChange: (value: string[]) => void; onChange: (value: string[]) => void;
onAssistantChange?: (value: string[]) => void; onAssistantChange?: (value: string[]) => void;
onSuggestedQuestionsChange?: (questions: string[]) => void;
source?: SelectorSource; source?: SelectorSource;
agentId?: string | null; agentId?: string | null;
autoSelectAll?: boolean; autoSelectAll?: boolean;
@@ -387,23 +417,38 @@ export function LlmWikiKnowledgeSelector({
readOnly?: boolean; readOnly?: boolean;
className?: string; className?: string;
label?: string; label?: string;
panelTitle?: string;
panelDescription?: string;
variant?: SelectorVariant; variant?: SelectorVariant;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const runtime = useLlmWikiRuntime(); const runtime = useLlmWikiRuntime();
const weknoraEnabled = runtime.data?.provider === "weknora"; const weknoraEnabled = runtime.data?.provider === "weknora";
const shouldLoadOptions =
variant === "panel" ||
open ||
autoSelectAll ||
value.length > 0 ||
assistantValue.length > 0;
const all = useLlmWikiKnowledgeBases( const all = useLlmWikiKnowledgeBases(
"all", "all",
weknoraEnabled, weknoraEnabled && shouldLoadOptions,
); );
const agent = useAgentLlmWikiKnowledgeBases( const agent = useAgentLlmWikiKnowledgeBases(
agentId, agentId,
weknoraEnabled && source === "agent" && !!agentId, weknoraEnabled && shouldLoadOptions && source === "agent" && !!agentId,
); );
const assistantBases = useAssistantKnowledgeBases(); const assistantBases = useAssistantKnowledgeBases(
const rows = weknoraEnabled ? (all.data ?? []) : []; shouldLoadOptions ||
const assistantRows = assistantBases.data ?? []; 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(() => { const defaultIds = useMemo(() => {
if (source !== "agent") { if (source !== "agent") {
return rows.map((item) => item.id); return rows.map((item) => item.id);
@@ -419,6 +464,26 @@ export function LlmWikiKnowledgeSelector({
); );
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`; const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
const initialized = useRef<string | null>(null); 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(() => { useEffect(() => {
if (!runtime.isLoading && !weknoraEnabled) { if (!runtime.isLoading && !weknoraEnabled) {
@@ -448,7 +513,12 @@ export function LlmWikiKnowledgeSelector({
if (!open) setSearch(""); if (!open) setSearch("");
}, [open]); }, [open]);
if ((runtime.isError || !weknoraEnabled) && !assistantBases.isLoading && assistantRows.length === 0) return null; if (
!runtime.isLoading &&
(runtime.isError || !weknoraEnabled) &&
!assistantBases.isLoading &&
assistantRows.length === 0
) return null;
const content = ( const content = (
<SelectionContent <SelectionContent
@@ -474,10 +544,10 @@ export function LlmWikiKnowledgeSelector({
<div className="flex items-start justify-between gap-4 border-b px-4 py-3"> <div className="flex items-start justify-between gap-4 border-b px-4 py-3">
<div> <div>
<div className="flex items-center gap-2 text-sm font-semibold"> <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> </div>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。 {panelDescription}
</p> </p>
</div> </div>
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge> <Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
@@ -487,13 +557,6 @@ export function LlmWikiKnowledgeSelector({
); );
} }
const selectedNames = value
.map((id) => rows.find((item) => item.id === id)?.name)
.filter((name): name is string => !!name);
const selectedAssistantNames = assistantValue
.map((id) => assistantRows.find((item) => item.id === id)?.name)
.filter((name): name is string => !!name);
const allSelectedNames = [...selectedNames, ...selectedAssistantNames];
const selectedCount = value.length + assistantValue.length; const selectedCount = value.length + assistantValue.length;
const selectedLabel = allSelectedNames.length > 0 const selectedLabel = allSelectedNames.length > 0
? allSelectedNames.join("、") ? allSelectedNames.join("、")
@@ -20,6 +20,9 @@ import {
} from "@/core/utils/markdown"; } from "@/core/utils/markdown";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { VisualizationBlock } from "../visualizations/visualization-block";
import { splitVisualizationContent } from "../visualizations/visualization-spec";
import { CitationLink } from "../citations/citation-link"; import { CitationLink } from "../citations/citation-link";
function isExternalUrl(href: string | undefined): boolean { function isExternalUrl(href: string | undefined): boolean {
@@ -50,6 +53,7 @@ export type MarkdownContentProps = {
/** Renders markdown content. */ /** Renders markdown content. */
export function MarkdownContent({ export function MarkdownContent({
content, content,
isLoading,
rehypePlugins, rehypePlugins,
className, className,
remarkPlugins = streamdownPlugins.remarkPlugins, remarkPlugins = streamdownPlugins.remarkPlugins,
@@ -59,6 +63,7 @@ export function MarkdownContent({
}: MarkdownContentProps) { }: MarkdownContentProps) {
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]); const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
const visualizationSegments = useMemo(() => splitVisualizationContent(content), [content]);
useStreamdownMermaidPngDownload(rootRef); useStreamdownMermaidPngDownload(rootRef);
const components = useMemo(() => { const components = useMemo(() => {
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => { const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
@@ -139,17 +144,51 @@ export function MarkdownContent({
if (!content) return null; 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 ( return (
<div ref={rootRef} data-markdown-anchor-root className="contents"> <div ref={rootRef} data-markdown-anchor-root className="contents">
<MessageResponse {visualizationSegments.map((segment, index) =>
className={className} segment.type === "visualization" ? (
remarkPlugins={remarkPlugins} <VisualizationBlock
rehypePlugins={rehypePlugins} key={`visualization-${index}`}
mermaidConfig={streamdownPlugins.mermaidConfig} language={segment.language}
components={components} source={segment.source}
> complete={segment.complete}
{content} isLoading={isLoading}
</MessageResponse> />
) : segment.content ? (
<MessageResponse
key={`markdown-${index}`}
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
mermaidConfig={streamdownPlugins.mermaidConfig}
components={components}
>
{segment.content}
</MessageResponse>
) : null,
)}
</div> </div>
); );
} }
@@ -82,7 +82,6 @@ import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources"; import { SourceCard } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content"; import { MarkdownContent } from "./markdown-content";
import { SingleRoundRagCitationDialog } from "./message-list-item";
export function MessageGroup({ export function MessageGroup({
className, className,
@@ -138,19 +137,6 @@ export function MessageGroup({
} }
}, [lastToolCallStep, steps]); }, [lastToolCallStep, steps]);
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); 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 = const reasoningId =
lastReasoningStep?.id ?? lastReasoningStep?.id ??
messages.find((message) => message.type === "ai")?.id; messages.find((message) => message.type === "ai")?.id;
@@ -200,7 +186,6 @@ export function MessageGroup({
displaySources={displaySources} displaySources={displaySources}
isLoading={isLoading} isLoading={isLoading}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
onCitationClick={handleCitationClick}
/> />
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
@@ -208,8 +193,9 @@ export function MessageGroup({
<ToolCall <ToolCall
key={step.id} key={step.id}
{...step} {...step}
isLoading={isLoading} isLoading={isLoading}
skillDisplayMap={skillDisplayMap} skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled} ragModeEnabled={ragModeEnabled}
/> />
), ),
@@ -220,8 +206,9 @@ export function MessageGroup({
key={lastToolCallStep.id} key={lastToolCallStep.id}
{...lastToolCallStep} {...lastToolCallStep}
isLast={true} isLast={true}
isLoading={isLoading} isLoading={isLoading}
skillDisplayMap={skillDisplayMap} skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled} ragModeEnabled={ragModeEnabled}
/> />
</FlipDisplay> </FlipDisplay>
@@ -262,7 +249,6 @@ export function MessageGroup({
displaySources={displaySources} displaySources={displaySources}
isLoading={isLoading} isLoading={isLoading}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
onCitationClick={handleCitationClick}
/> />
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
@@ -271,12 +257,6 @@ export function MessageGroup({
</> </>
)} )}
</ChainOfThought> </ChainOfThought>
<SingleRoundRagCitationDialog
source={selectedCitation}
onOpenChange={(open) => {
if (!open) setSelectedCitation(null);
}}
/>
</> </>
); );
} }
@@ -286,14 +266,12 @@ function StepReasoningMarkdown({
displaySources, displaySources,
isLoading, isLoading,
rehypePlugins, rehypePlugins,
onCitationClick, }: {
}: {
content: string; content: string;
displaySources: RuntimeDisplaySource[]; displaySources: RuntimeDisplaySource[];
isLoading: boolean; isLoading: boolean;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>; rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onCitationClick: (referenceId: string) => void; }) {
}) {
const markdown = useMemo( const markdown = useMemo(
() => () =>
applyInlineCitations(content, displaySources, { appendSummary: false }), applyInlineCitations(content, displaySources, { appendSummary: false }),
@@ -303,19 +281,18 @@ function StepReasoningMarkdown({
if (displaySources.length === 0) return undefined; if (displaySources.length === 0) return undefined;
return { return {
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => { a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("#reference-")) { if (href?.startsWith("#reference-")) {
return ( return (
<CitationLink <CitationLink
href={href} href={href}
{...props} {...props}
onReferenceClick={onCitationClick} />
/> );
);
} }
return <a href={href} {...props} />; return <a href={href} {...props} />;
}, },
}; };
}, [displaySources.length, onCitationClick]); }, [displaySources.length]);
return ( return (
<MarkdownContent <MarkdownContent
@@ -335,7 +312,8 @@ function ToolCall({
result, result,
isLast = false, isLast = false,
isLoading = false, isLoading = false,
skillDisplayMap = new Map(), skillDisplayMap = new Map(),
displaySources = [],
ragModeEnabled = false, ragModeEnabled = false,
}: { }: {
id?: string; id?: string;
@@ -345,7 +323,8 @@ function ToolCall({
result?: string | Record<string, unknown>; result?: string | Record<string, unknown>;
isLast?: boolean; isLast?: boolean;
isLoading?: boolean; isLoading?: boolean;
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>; skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean; ragModeEnabled?: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
@@ -354,21 +333,28 @@ function ToolCall({
? null ? null
: (findDisplayConfigForTool(name, skillDisplayMap, args) ?? : (findDisplayConfigForTool(name, skillDisplayMap, args) ??
null); null);
const displayItems = const displayItems =
displayMatch && result !== undefined displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({ ? syncDisplaySourceIndexes(rankDisplaySources(normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item, ...item,
index: index + 1, index: index + 1,
skillName: displayMatch.skillName, skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode, mode: item.displayMode ?? displayMatch.display.mode,
})) }))), displaySources)
: []; : [];
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : ""; const isLlmWikiTool = name === "llmwiki_search" || name === "llmwiki_read_wiki_page" || name === "llmwiki_browse_wiki_index";
const wikiSearchLabel = name === "llmwiki_search" const wikiQuery = isLlmWikiTool ? resolveSearchQuery(args, result) : "";
? wikiQuery const wikiSearchLabel = name === "llmwiki_search"
? `Wiki 文档检索 "${wikiQuery}"` ? wikiQuery
: "Wiki 文档检索" ? `Wiki 文档检索 "${wikiQuery}"`
: null; : "Wiki 文档检索"
: name === "llmwiki_read_wiki_page"
? wikiQuery
? `读取 Wiki 页面「${wikiQuery}」`
: "读取 Wiki 页面"
: name === "llmwiki_browse_wiki_index"
? "读取 Wiki 索引与知识库摘要"
: null;
if (displayItems.length > 0) { if (displayItems.length > 0) {
return ( return (
<ChainOfThoughtStep <ChainOfThoughtStep
@@ -394,12 +380,12 @@ function ToolCall({
const searchItems = normalizeSearchResults(result); const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) { if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({ const defaultItems = syncDisplaySourceIndexes(rankDisplaySources(normalizeDefaultDisplaySources(result).map((item, index) => ({
...item, ...item,
index: index + 1, index: index + 1,
skillName: name, skillName: name,
mode: "citation" as const, mode: "citation" as const,
})); }))), displaySources);
return ( return (
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}> <ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
<DisplayResultsList results={defaultItems} /> <DisplayResultsList results={defaultItems} />
@@ -859,9 +845,21 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
type CoTStep = CoTReasoningStep | CoTToolCallStep; type CoTStep = CoTReasoningStep | CoTToolCallStep;
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] { function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
const steps: CoTStep[] = []; const steps: CoTStep[] = [];
for (const message of messages) { // 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") { if (message.type === "ai") {
const reasoning = extractReasoningContentFromMessage(message); const reasoning = extractReasoningContentFromMessage(message);
if (reasoning) { if (reasoning) {
@@ -902,11 +900,12 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
}; };
const toolCallId = tool_call.id; const toolCallId = tool_call.id;
if (toolCallId) { if (toolCallId) {
const toolCallResult = findToolCallResult( const toolCallResult = findToolCallResult(
toolCallId, toolCallId,
messages, messages,
tool_call.name, tool_call.name,
); { allowNameFallback: toolCallNameCounts.get(tool_call.name.toLowerCase()) === 1 },
);
if (toolCallResult !== undefined) { if (toolCallResult !== undefined) {
if (typeof toolCallResult === "string") { if (typeof toolCallResult === "string") {
try { try {
@@ -951,9 +950,27 @@ function WebSearchResultsList({ results }: { results: SearchResultItem[] }) {
); );
} }
function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) { function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( 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"> <Collapsible open={open} onOpenChange={setOpen} className="w-full">
<CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs"> <CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs">
<ChevronDownIcon <ChevronDownIcon
@@ -965,13 +982,52 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
<span>{results.length} 条真实返回结果</span> <span>{results.length} 条真实返回结果</span>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="mt-2 flex flex-col gap-2"> <CollapsibleContent className="mt-2 flex flex-col gap-2">
{results.map((item) => ( {orderedResults.map((item) => (
<SourceCard key={`${item.skillName}-${item.index}`} source={item} /> <SourceCard
))} key={`${item.skillName}-${item.index}`}
source={item}
/>
))}
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </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 }) { function SearchResultCard({ item }: { item: SearchResultItem }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -76,7 +76,8 @@ import {
getMultiRoundRagTrace, getMultiRoundRagTrace,
isMultiRoundRagMessage, isMultiRoundRagMessage,
} from '@/core/threads/use-multi-round-rag'; } 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 { CitationLink } from '../citations/citation-link';
import { CopyButton } from '../copy-button'; import { CopyButton } from '../copy-button';
@@ -890,11 +891,11 @@ function FrontendRagSourcesPanel({
</span> </span>
)} )}
</div> </div>
{source.snippet && ( {(source.content || source.snippet) && (
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed"> <p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
{source.snippet} {source.content || source.snippet}
</p> </p>
)} )}
</button> </button>
))} ))}
</CollapsibleContent> </CollapsibleContent>
@@ -908,8 +909,26 @@ export function SingleRoundRagCitationDialog({
}: { }: {
source: RuntimeDisplaySource | null; source: RuntimeDisplaySource | null;
onOpenChange: (open: boolean) => void; 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 ( return (
<Dialog open={!!source} onOpenChange={onOpenChange}> <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"> <DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
@@ -25,6 +25,7 @@ export function ModelNameSelect({
className, className,
triggerClassName, triggerClassName,
defaultLabel = "默认模型", defaultLabel = "默认模型",
title = "选择用于本次操作的大模型(默认使用系统配置的模型)",
}: { }: {
value: string | null; value: string | null;
onChange: (modelName: string | null) => void; onChange: (modelName: string | null) => void;
@@ -32,6 +33,7 @@ export function ModelNameSelect({
className?: string; className?: string;
triggerClassName?: string; triggerClassName?: string;
defaultLabel?: string; defaultLabel?: string;
title?: string;
}) { }) {
const { models } = useModels(); const { models } = useModels();
return ( return (
@@ -42,7 +44,7 @@ export function ModelNameSelect({
> >
<SelectTrigger <SelectTrigger
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)} className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
title="选择用于本次操作的大模型(默认使用系统配置的模型)" title={title}
> >
<SelectValue placeholder={defaultLabel} /> <SelectValue placeholder={defaultLabel} />
</SelectTrigger> </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, SidebarMenu,
SidebarMenuAction, SidebarMenuAction,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"; } 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 { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
@@ -63,7 +68,8 @@ import {
useToggleThreadFavorite, useToggleThreadFavorite,
useToggleThreadPin, useToggleThreadPin,
} from "@/core/threads/hooks"; } 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 { import {
isBootstrapThread, isBootstrapThread,
isHiddenFromRecentChats, isHiddenFromRecentChats,
@@ -92,7 +98,8 @@ export function RecentChatList({
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { isMobile, setOpenMobile } = useSidebar();
const [internalCollapsed, setInternalCollapsed] = useState( const [internalCollapsed, setInternalCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true", () => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
); );
@@ -134,15 +141,15 @@ export function RecentChatList({
}); });
if (threadIndex > -1) { if (threadIndex > -1) {
if (threads[threadIndex + 1]) { if (threads[threadIndex + 1]) {
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!); nextThreadPath = listPathOfThread(threads[threadIndex + 1]!, pathname);
} else if (threads[threadIndex - 1]) { } else if (threads[threadIndex - 1]) {
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!); nextThreadPath = listPathOfThread(threads[threadIndex - 1]!, pathname);
} }
} }
void router.push(nextThreadPath); void router.push(nextThreadPath);
} }
}, },
[agentIdFromPath, deleteThread, router, threadIdFromPath, threads], [agentIdFromPath, deleteThread, pathname, router, threadIdFromPath, threads],
); );
const handleRenameClick = useCallback( const handleRenameClick = useCallback(
@@ -243,9 +250,10 @@ export function RecentChatList({
<SidebarMenu> <SidebarMenu>
<div className="flex w-full flex-col gap-0.5"> <div className="flex w-full flex-col gap-0.5">
{shownThreads.map((thread) => { {shownThreads.map((thread) => {
const href = listPathOfThread(thread); const href = listPathOfThread(thread, pathname);
const isActive = href === pathname; const isActive = href === pathname;
const isBoot = isBootstrapThread(thread); const isBoot = isBootstrapThread(thread);
const projectType = myProjectConversationTypeOfThread(thread);
const pinned = isThreadPinned(thread); const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread); const favorited = isThreadFavorite(thread);
return ( return (
@@ -267,7 +275,8 @@ export function RecentChatList({
> >
<Link <Link
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden" 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 ? ( {isBoot ? (
<Bot <Bot
@@ -279,8 +288,10 @@ export function RecentChatList({
} }
aria-label="智能体创建会话" aria-label="智能体创建会话"
/> />
) : ( ) : projectType ? (
compact && ( <MyProjectThreadIcon type={projectType} />
) : (
compact && (
<MessageSquare <MessageSquare
className={ className={
"size-3.5 shrink-0 " + "size-3.5 shrink-0 " +
@@ -294,9 +305,15 @@ export function RecentChatList({
{pinned && ( {pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" /> <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))} {desensitize(titleOfThread(thread, t.pages.newChat))}
</span> </span>
{projectType && (
<MyProjectThreadBadge
className="mr-3"
type={projectType}
/>
)}
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && ( {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
@@ -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"; "use client";
import { BellIcon, CpuIcon, 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 { useEffect, useMemo, useState } from "react";
import { import {
@@ -13,7 +13,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page"; import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page"; import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page"; import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page"; import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page"; import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
@@ -61,15 +61,12 @@ export function SettingsDialog(props: SettingsDialogProps) {
// }, // },
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon }, // { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon }, // { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
// 记忆已移至侧边栏入口 (/page/workspace/memory) ...(isAdmin
// { ? [{ id: "memory", label: "问答记忆", icon: BrainIcon }]
// id: "memory", : []),
// label: t.settings.sections.memory,
// icon: BrainIcon,
// },
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon }, { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin ...(isAdmin
? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }] ? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
: []), : []),
{ {
id: "account", id: "account",
@@ -132,7 +129,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "appearance" && ( {activeSection === "appearance" && (
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} /> <AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
)} )}
{activeSection === "memory" && <MemorySettingsPage />} {activeSection === "memory" && isAdmin && (
<MemoryRuntimeSettingsPage />
)}
{activeSection === "tools" && <ToolSettingsPage />} {activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />} {activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
{activeSection === "about" && <AboutSettingsPage />} {activeSection === "about" && <AboutSettingsPage />}
@@ -9,7 +9,10 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime"; 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 { useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { reasoningPlugins } from "@/core/streamdown"; import { reasoningPlugins } from "@/core/streamdown";
@@ -56,6 +59,7 @@ export function SourceCard({
}) { }) {
const [relatedOpen, setRelatedOpen] = useState(false); const [relatedOpen, setRelatedOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
const [linkedTarget, setLinkedTarget] = useState<WeKnoraSourceTarget | null>(null);
const [settings] = useLocalSettings(); const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings(); const { data: citationDisplay } = useCitationDisplaySettings();
const previewTarget = resolveWeKnoraSourceTarget({ const previewTarget = resolveWeKnoraSourceTarget({
@@ -65,7 +69,11 @@ export function SourceCard({
const cleanupWords = const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? []; citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const content = cleanReferenceText(source.content || source.snippet, cleanupWords); 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 isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
const opensWikiDrawer = Boolean(previewTarget) || isWikiIndexSource;
const matchedHeading = isWikiSource const matchedHeading = isWikiSource
? String( ? String(
(source.raw?.matched_heading as string | undefined) || (source.raw?.matched_heading as string | undefined) ||
@@ -78,7 +86,9 @@ export function SourceCard({
const summary = isWikiSource && typeof source.raw?.summary === "string" const summary = isWikiSource && typeof source.raw?.summary === "string"
? source.raw.summary ? 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 = const score =
typeof source.score === "number" typeof source.score === "number"
? source.score <= 1 ? source.score <= 1
@@ -95,9 +105,13 @@ export function SourceCard({
score ? `相关度 ${score}` : null, score ? `相关度 ${score}` : null,
].filter(Boolean); ].filter(Boolean);
const isCitation = source.mode === "citation"; 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 = () => { const openSource = () => {
if (previewTarget) { if (opensWikiDrawer) {
setDetailOpen(true); setDetailOpen(true);
} else { } else {
setRelatedOpen(true); setRelatedOpen(true);
@@ -174,16 +188,18 @@ export function SourceCard({
</div> </div>
)} )}
</div> </div>
{previewTarget && ( {(opensWikiDrawer || linkedTarget) && (
<WeKnoraSourceDrawer <WeKnoraSourceDrawer
open={detailOpen} open={detailOpen}
target={previewTarget} target={linkedTarget ?? previewTarget}
title={source.title} title={source.title}
quote={content} quote={content}
inlineContent={isWikiIndexSource ? content : ""}
inlineKnowledgeBaseId={String(source.raw?.knowledge_base_id || "")}
onOpenChange={setDetailOpen} 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"> <DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
<DialogHeader> <DialogHeader>
<div className="flex items-start justify-between gap-3 pr-8"> <div className="flex items-start justify-between gap-3 pr-8">
@@ -235,6 +251,18 @@ export function SourceCard({
isLoading={false} isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins} remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins} 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> </article>
</div> </div>
@@ -205,21 +205,21 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
/> />
<Database className="size-4 text-muted-foreground" /> <Database className="size-4 text-muted-foreground" />
<span className="text-sm">{target.target_name}</span> <span className="text-sm">{target.target_name}</span>
<Badge variant="outline" className="ml-auto text-[10px]">WeKnora Wiki</Badge> <Badge variant="outline" className="ml-auto text-[10px]">普通 Wiki 库</Badge>
</label> </label>
); );
})} })}
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />} {(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && ( {!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
<p className="p-3 text-xs text-destructive"> <p className="p-3 text-xs text-destructive">
读取 WeKnora 普通知识库失败: 读取普通知识库失败:
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)} {knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
</p> </p>
)} )}
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && ( {!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
<p className="p-3 text-xs text-muted-foreground"> <p className="p-3 text-xs text-muted-foreground">
{runtime.data?.provider !== "weknora" {runtime.data?.provider !== "weknora"
? "当前未配置 WeKnora 普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。" ? "当前未配置普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"} : "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
</p> </p>
)} )}
@@ -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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"'": "&#39;",
'"': "&quot;",
})[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, SidebarMenuItem,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, Library, 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 { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header"; import { SidebarCollapseButton } from "./workspace-header";
@@ -24,7 +24,10 @@ import {
applyCompactMenuOverrides, applyCompactMenuOverrides,
type CompactMenuItem, type CompactMenuItem,
} from "@/core/page-layout/compact-menu-overrides"; } 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"; import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标 // 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
@@ -51,6 +54,13 @@ export type WorkspaceCompactNavItem = MenuItem & {
}; };
export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [ 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", id: "workspace-new-chat",
label: "新对话", label: "新对话",
@@ -65,6 +75,15 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/chats", path: "/page/workspace/chats",
match: (pathname) => pathname === "/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", id: "workspace-library",
label: "资料库", label: "资料库",
@@ -86,13 +105,6 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/skills", path: "/page/workspace/skills",
match: (pathname) => pathname.startsWith("/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", id: "workspace-ai-writing",
label: "AI写作", label: "AI写作",
@@ -140,7 +152,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
label: "知识库管理", label: "知识库管理",
icon: BookOpen, icon: BookOpen,
path: "/page/workspace/knowledge", 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),
}, },
]; ];
@@ -18,15 +18,18 @@ import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list"; import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu"; import { WorkspaceNavMenu } from "./workspace-nav-menu";
export function WorkspaceSidebar({ export function WorkspaceSidebar({
compactChrome = false, compactChrome = false,
forceCollapsed = false, forceCollapsed = false,
className, wxCommonAgents = false,
...props className,
}: React.ComponentProps<typeof Sidebar> & { ...props
compactChrome?: boolean; }: React.ComponentProps<typeof Sidebar> & {
forceCollapsed?: boolean; compactChrome?: boolean;
}) { forceCollapsed?: boolean;
/** Enables wx's shared Agent Gallery category selection. */
wxCommonAgents?: boolean;
}) {
const { open: isSidebarOpen } = useSidebar(); const { open: isSidebarOpen } = useSidebar();
const { theme } = useTheme(); const { theme } = useTheme();
const pathname = usePathname(); const pathname = usePathname();
@@ -35,6 +38,8 @@ export function WorkspaceSidebar({
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是 // 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。 // 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa = const isGeneralQa =
wxCommonAgents ||
pathname.startsWith("/page/workspace/projects") ||
pathname.startsWith("/page/workspace/chats") || pathname.startsWith("/page/workspace/chats") ||
pathname.startsWith("/page/strategy/chats"); pathname.startsWith("/page/strategy/chats");
@@ -84,7 +89,7 @@ export function WorkspaceSidebar({
{isGeneralQa ? ( {isGeneralQa ? (
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成 // 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。 // GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
<GeneralQaSidebar /> <GeneralQaSidebar wxCommonAgents={wxCommonAgents} />
) : ( ) : (
<> <>
<WorkspaceNavChatList showCollapseTrigger compact /> <WorkspaceNavChatList showCollapseTrigger compact />
@@ -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;
}
+28 -7
View File
@@ -1,5 +1,6 @@
import { apiFetch } from "../api"; import { apiFetch } from "../api";
import { downloadMarkdownAsDocx } from "../scheduled-tasks/markdown-docx"; import { downloadMarkdownAsDocx } from "../scheduled-tasks/markdown-docx";
import { prepareGeneratedHtml } from "../utils/legacy-html-preview";
import { getFileName } from "../utils/files"; import { getFileName } from "../utils/files";
import { urlOfArtifact } from "./utils"; import { urlOfArtifact } from "./utils";
@@ -60,12 +61,23 @@ export async function downloadArtifactFile({
const filename = const filename =
filenameFromContentDisposition(cd) ?? getFileName(filepath); filenameFromContentDisposition(cd) ?? getFileName(filepath);
const blob = await res.blob(); const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim(); const isHtml = contentType === "text/html" || /\.html?$/i.test(filename);
const downloadBlob = let downloadBlob: Blob;
contentType && blob.type !== contentType if (isHtml) {
? new Blob([await blob.arrayBuffer()], { type: contentType }) downloadBlob = new Blob(
: 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 objectUrl = URL.createObjectURL(downloadBlob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = objectUrl; a.href = objectUrl;
@@ -111,7 +123,16 @@ export async function openArtifactInNewTab({
if (!res.ok) { if (!res.ok) {
throw new Error(await errorMessageFromResponse(res)); 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 objectUrl = URL.createObjectURL(blob);
const w = window.open(objectUrl, "_blank", "noopener,noreferrer"); const w = window.open(objectUrl, "_blank", "noopener,noreferrer");
if (w) w.opener = null; if (w) w.opener = null;
@@ -4,9 +4,66 @@ import test from "node:test";
import { import {
isWriteFileInFlight, isWriteFileInFlight,
latestInFlightWriteFilePreview, latestInFlightWriteFilePreview,
latestPreparedHtmlArtifact,
resolveWriteFilePath, resolveWriteFilePath,
} from "./write-file-in-flight"; } 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", () => { test("latestInFlightWriteFilePreview opens a write_file that is not the last tool call", () => {
const preview = latestInFlightWriteFilePreview([ const preview = latestInFlightWriteFilePreview([
{ {
@@ -51,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) { function hasToolResult(thread: WriteFileThreadLike, toolCallId?: string) {
if (!toolCallId) { if (!toolCallId) {
return false; return false;
+6 -2
View File
@@ -1,6 +1,6 @@
"use client"; "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 { useLocation, useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -125,5 +125,9 @@ export function AuthSyncWatcher({
}; };
}, [disabled, location.pathname, navigate, queryClient]); }, [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;
} }
+39 -23
View File
@@ -38,30 +38,46 @@ export function buildAppHashUrl(path: string): string {
} }
export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget | null { export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget | null {
if (!url?.startsWith("/weknora-source-preview?")) { if (!url) return null;
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)); // New drawer links use the same real HashRouter URL as the normal Wiki
const knowledgeBaseId = params.get("knowledge_base_id")?.trim(); // reader. This doubles as a safe browser-navigation fallback when React's
const chunkId = params.get("chunk_id")?.trim(); // Markdown click handler is not mounted (for example after a deployment).
const documentId = const route = url.includes("#") ? url.slice(url.indexOf("#") + 1) : url;
params.get("document_id")?.trim() || const match = route.match(/^\/page\/workspace\/knowledge\/([^/?]+)\/wiki\/view(?:\?([^#]*))?$/);
params.get("knowledge_id")?.trim() || if (!match) return null;
""; const knowledgeBaseId = decodeURIComponent(match[1] || "").trim();
const wikiSlug = const wikiSlug = new URLSearchParams(match[2] || "").get("wikiSlug")?.trim();
params.get("wiki_slug")?.trim() || return knowledgeBaseId && wikiSlug
params.get("wikiSlug")?.trim() || ? { knowledgeBaseId, wikiSlug, tab: "wiki" }
params.get("slug")?.trim() ||
"";
return knowledgeBaseId && (wikiSlug || chunkId || documentId)
? {
knowledgeBaseId,
chunkId,
documentId: documentId || undefined,
wikiSlug: wikiSlug || undefined,
tab: wikiSlug ? "wiki" : "documents",
}
: null; : null;
} }
@@ -20,6 +20,11 @@ export type SearchResultItem = {
published_date?: string; published_date?: string;
publishedDate?: string; publishedDate?: string;
date?: 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 = [ const RESULT_LIST_KEYS = [
@@ -196,6 +201,25 @@ function pickFromLayers(
return undefined; 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( function pickByKeyPattern(
layers: Record<string, unknown>[], layers: Record<string, unknown>[],
pattern: RegExp, pattern: RegExp,
@@ -326,6 +350,12 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
const recUuid = pickFromLayers(layers, ["recUuid", "rec_uuid"]); const recUuid = pickFromLayers(layers, ["recUuid", "rec_uuid"]);
const uuid = pickFromLayers(layers, ["uuid"]); const uuid = pickFromLayers(layers, ["uuid"]);
const docId = pickFromLayers(layers, ["docId", "doc_id", "documentId"]); const docId = pickFromLayers(layers, ["docId", "doc_id", "documentId"]);
const score = pickNumberFromLayers(layers, [
"score",
"relevance_score",
"rerank_score",
"similarity",
]);
const reserved = new Set<string>(); const reserved = new Set<string>();
for (const value of [title, content, url, source, author, date]) { for (const value of [title, content, url, source, author, date]) {
@@ -363,6 +393,9 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
published_date: date, published_date: date,
publishedDate: date, publishedDate: date,
date, date,
score,
type: pickString(record.type),
knowledge_base_id: pickString(record.knowledge_base_id, record.knowledgeBaseId, record.mapping_id),
}; };
} }
+28 -17
View File
@@ -364,12 +364,18 @@ export function extractReasoningContentFromMessage(message: Message) {
if (message.type !== "ai") { if (message.type !== "ai") {
return null; return null;
} }
if ( if (
message.additional_kwargs && message.additional_kwargs &&
"reasoning_content" in message.additional_kwargs "reasoning_content" in message.additional_kwargs
) { ) {
return message.additional_kwargs.reasoning_content as string | null; 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)) { if (Array.isArray(message.content)) {
const part = message.content[0]; const part = message.content[0];
if (part && "thinking" in part) { if (part && "thinking" in part) {
@@ -385,8 +391,9 @@ export function extractReasoningContentFromMessage(message: Message) {
export function removeReasoningContentFromMessage(message: Message) { export function removeReasoningContentFromMessage(message: Message) {
if (message.type !== "ai" || !message.additional_kwargs) { if (message.type !== "ai" || !message.additional_kwargs) {
return; return;
} }
delete message.additional_kwargs.reasoning_content; delete message.additional_kwargs.reasoning_content;
delete message.additional_kwargs.reasoning;
} }
export function extractURLFromImageURLContent( export function extractURLFromImageURLContent(
@@ -421,9 +428,12 @@ export function hasReasoning(message: Message) {
if (message.type !== "ai") { if (message.type !== "ai") {
return false; return false;
} }
if (typeof message.additional_kwargs?.reasoning_content === "string") { if (typeof message.additional_kwargs?.reasoning_content === "string") {
return true; return true;
} }
if (typeof message.additional_kwargs?.reasoning === "string") {
return true;
}
if (Array.isArray(message.content)) { if (Array.isArray(message.content)) {
const part = message.content[0]; const part = message.content[0];
// Compatible with the Anthropic gateway // Compatible with the Anthropic gateway
@@ -550,11 +560,12 @@ export function hasSubagent(message: AIMessage) {
return false; return false;
} }
export function findToolCallResult( export function findToolCallResult(
toolCallId: string, toolCallId: string,
messages: Message[], messages: Message[],
toolName?: string, toolName?: string,
): string | Record<string, unknown> | undefined { options?: { allowNameFallback?: boolean },
): string | Record<string, unknown> | undefined {
for (const message of messages) { for (const message of messages) {
if (message.type !== "tool" || message.tool_call_id !== toolCallId) { if (message.type !== "tool" || message.tool_call_id !== toolCallId) {
continue; continue;
@@ -566,7 +577,7 @@ export function findToolCallResult(
} }
// updates 流里偶发缺少 tool_call_id,但 content 已是检索结构 // updates 流里偶发缺少 tool_call_id,但 content 已是检索结构
if (toolName) { if (toolName && options?.allowNameFallback !== false) {
for (const message of messages) { for (const message of messages) {
if (message.type !== "tool" || message.name !== toolName) { if (message.type !== "tool" || message.name !== toolName) {
continue; continue;
@@ -10,6 +10,7 @@ export interface ManagedModel {
base_url: string | null; base_url: string | null;
request_timeout: number | null; request_timeout: number | null;
max_retries: number | null; max_retries: number | null;
max_input_tokens: number | null;
max_tokens: number | null; max_tokens: number | null;
temperature: number | null; temperature: number | null;
stream_usage: boolean | null; stream_usage: boolean | null;
@@ -44,6 +45,7 @@ export interface ModelSavePayload {
base_url?: string | null; base_url?: string | null;
request_timeout?: number | null; request_timeout?: number | null;
max_retries?: number | null; max_retries?: number | null;
max_input_tokens?: number | null;
max_tokens?: number | null; max_tokens?: number | null;
temperature?: number | null; temperature?: number | null;
stream_usage?: boolean | null; stream_usage?: boolean | null;
@@ -30,7 +30,7 @@ test("matches only workspace chats_iframe routes", () => {
assert.equal(isChatsIframeWorkspacePath("/landing"), false); 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("/landing"), "light");
assert.equal( assert.equal(
resolveForcedThemeForPath("/page/workspace/chats_iframe/new"), resolveForcedThemeForPath("/page/workspace/chats_iframe/new"),
@@ -41,6 +41,14 @@ test("forces light theme for landing and chats_iframe routes only", () => {
"light", "light",
); );
assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined); 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", () => { 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 CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new";
const IFRAME_LOGIN_SOURCE = "iframe"; const IFRAME_LOGIN_SOURCE = "iframe";
const STANDARD_CHATS_PATH = "/page/workspace/chats"; 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 { function toSearchParams(search: string): URLSearchParams {
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`); return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
@@ -328,6 +330,10 @@ export function resolveForcedThemeForPath(
return "light"; return "light";
} }
if (pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX)) {
return "dark";
}
const isLoginPath = pathname === "/login" || pathname.startsWith("/login/"); const isLoginPath = pathname === "/login" || pathname.startsWith("/login/");
if ( if (
isChatsIframeWorkspacePath(pathname) || isChatsIframeWorkspacePath(pathname) ||
@@ -21,6 +21,7 @@ import {
FileBox, FileBox,
FileSearch, FileSearch,
FileText, FileText,
FolderKanban,
Gauge, Gauge,
Grid3X3, Grid3X3,
HelpCircle, HelpCircle,
@@ -48,12 +49,23 @@ export type MenuItem = {
path?: string; path?: string;
/** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */ /** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */
aliasPaths?: string[]; aliasPaths?: string[];
/** 从当前菜单项自身的 path / aliasPaths 前缀匹配中排除的路由。 */
excludePaths?: string[];
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */ /** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
userPath?: string; userPath?: string;
adminOnly?: boolean; adminOnly?: boolean;
children?: MenuItem[]; 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[] = [ export const sidebarMenuItems: MenuItem[] = [
{ {
id: "qa-center", id: "qa-center",
@@ -61,6 +73,7 @@ export const sidebarMenuItems: MenuItem[] = [
icon: HelpCircle, icon: HelpCircle,
children: [ children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" }, { 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: "artifact-library", label: "资料库", icon: Library, path: "/page/workspace/library" },
{ {
id: "multi-agent-roundtable", id: "multi-agent-roundtable",
@@ -68,6 +81,13 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Users, icon: Users,
path: "/page/strategy/qa/multi-agent-roundtable", 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", // id: "position-roundtable",
// label: "岗位会商", // label: "岗位会商",
@@ -258,12 +278,7 @@ export const sidebarMenuItems: MenuItem[] = [
}, },
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" }, { id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" }, { id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
{ MARKDOWN_DOCX_MENU_ITEM,
id: "resource-markdown-docx-editor",
label: "md转word",
icon: FileText,
path: "/page/workspace/knowledge/markdown-editor",
},
{ {
id: "mgmt-tasks", id: "mgmt-tasks",
label: "定时任务", label: "定时任务",
@@ -330,6 +345,7 @@ export const sidebarMenuItems: MenuItem[] = [
label: "知识库管理", label: "知识库管理",
icon: BookOpen, icon: BookOpen,
path: "/page/workspace/knowledge", path: "/page/workspace/knowledge",
excludePaths: [MARKDOWN_DOCX_MENU_PATH],
}, },
{ {
id: "resource-knowledge-base", id: "resource-knowledge-base",
@@ -365,6 +381,9 @@ function routeMatchesPathname(pathname: string, route?: string): boolean {
/** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */ /** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */
export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean { 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; if (routeMatchesPathname(pathname, item.path)) return true;
return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false; return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false;
} }
+244
View File
@@ -0,0 +1,244 @@
/**
* Web App 站点产物 API 客户端:能力探测 / 站点列表 / 构建 / 预览签名。
*
* 所有接口在后端能力不可用时返回 `{ available: false, reasons }`(HTTP 200),
* 调用方据此优雅降级——入口按钮不渲染、预览页显示未启用态。
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export interface WebAppCapabilityCheck {
ok: boolean;
detail: string;
}
export interface WebAppCapability {
available: boolean;
enabled: boolean;
template_dir: string;
node_bin: string;
checks: Record<string, WebAppCapabilityCheck>;
reasons?: string[];
}
export interface SiteNavItem {
page: string;
label: string;
}
export interface SiteDistInfo {
exists: boolean;
file_count?: number;
total_bytes?: number;
mtime?: number | null;
}
export interface SiteBuildResult {
success?: boolean;
error?: string;
duration_ms?: number;
stdout_tail?: string;
stderr_tail?: string;
finished_at?: number;
}
export interface SiteBuildJob {
job_id: string;
status: "running" | "success" | "failed";
started_at?: number;
finished_at?: number | null;
error?: string;
result?: SiteBuildResult;
}
export interface SiteInfo {
name: string;
title: string;
description: string;
nav: SiteNavItem[];
map: Record<string, unknown>;
manifest_mtime: number | null;
dist: SiteDistInfo;
last_build: SiteBuildResult | null;
build_job?: SiteBuildJob;
}
export interface SiteListResponse {
available: boolean;
enabled?: boolean;
reasons?: string[];
sites?: SiteInfo[];
}
/** 跨对话站点 gallery 条目:站点信息 + 来源会话 */
export interface SiteGalleryItem extends SiteInfo {
thread_id: string;
thread_title: string;
build_job?: SiteBuildJob;
}
export interface SiteGalleryResponse {
available: boolean;
enabled?: boolean;
reasons?: string[];
sites?: SiteGalleryItem[];
}
export interface SiteBuildStatusResponse {
available: boolean;
job_id?: string;
status?: "running" | "success" | "failed" | "idle";
started_at?: number;
finished_at?: number | null;
error?: string;
result?: SiteBuildResult;
last_build?: SiteBuildResult | null;
}
export interface SitePreviewUrlResponse {
available: boolean;
reasons?: string[];
url_path?: string;
token?: string;
expires_at?: number;
}
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
let detail = `${ctx} 失败(HTTP ${res.status})`;
try {
const body = (await res.json()) as { detail?: string };
if (body?.detail) detail = String(body.detail);
} catch {
// keep default
}
throw new Error(detail);
}
return (await res.json()) as T;
}
const baseUrl = () => `${getBackendBaseURL()}/api`;
export async function fetchWebAppCapability(): Promise<WebAppCapability> {
const res = await apiFetch(`${baseUrl()}/web-app/capability`);
return readJson<WebAppCapability>(res, "站点能力探测");
}
export async function listSites(threadId: string): Promise<SiteListResponse> {
const res = await apiFetch(`${baseUrl()}/threads/${encodeURIComponent(threadId)}/sites`);
return readJson<SiteListResponse>(res, "站点列表");
}
export async function listAllSites(): Promise<SiteGalleryResponse> {
const res = await apiFetch(`${baseUrl()}/web-app/sites`);
return readJson<SiteGalleryResponse>(res, "站点 gallery");
}
export async function buildSite(
threadId: string,
siteName: string,
): Promise<{ available: boolean; job_id?: string; status?: string; reasons?: string[] }> {
const res = await apiFetch(
`${baseUrl()}/threads/${encodeURIComponent(threadId)}/sites/${encodeURIComponent(siteName)}/build`,
{ method: "POST" },
);
return readJson(res, "触发构建");
}
export async function fetchSiteBuildStatus(
threadId: string,
siteName: string,
): Promise<SiteBuildStatusResponse> {
const res = await apiFetch(
`${baseUrl()}/threads/${encodeURIComponent(threadId)}/sites/${encodeURIComponent(siteName)}/build-status`,
);
return readJson<SiteBuildStatusResponse>(res, "构建状态");
}
export async function mintSitePreviewUrl(
threadId: string,
siteName: string,
): Promise<SitePreviewUrlResponse> {
const res = await apiFetch(
`${baseUrl()}/threads/${encodeURIComponent(threadId)}/sites/${encodeURIComponent(siteName)}/preview-url`,
{ method: "POST" },
);
return readJson<SitePreviewUrlResponse>(res, "签发预览地址");
}
/** 站点预览页路由(HashRouter 顶层,独立风格页面) */
export function siteStudioRoute(threadId: string, siteName: string): string {
return `/sites/${encodeURIComponent(threadId)}/${encodeURIComponent(siteName)}`;
}
const CAPABILITY_KEY = ["web-app", "capability"] as const;
const SITES_KEY = (threadId: string) => ["web-app", "sites", threadId] as const;
export function useWebAppCapability(enabled = true) {
return useQuery({
queryKey: CAPABILITY_KEY,
queryFn: fetchWebAppCapability,
enabled,
staleTime: 60_000,
gcTime: 5 * 60_000,
retry: 1,
refetchOnWindowFocus: false,
});
}
export function useSites(threadId: string | undefined, options?: { refetchInterval?: number }) {
return useQuery({
queryKey: SITES_KEY(threadId ?? ""),
queryFn: () => listSites(threadId as string),
enabled: !!threadId,
refetchInterval: options?.refetchInterval ?? false,
refetchOnWindowFocus: false,
});
}
export function useAllSites() {
return useQuery({
queryKey: ["web-app", "all-sites"] as const,
queryFn: listAllSites,
staleTime: 15_000,
refetchOnWindowFocus: false,
});
}
export function useSiteBuildStatus(
threadId: string | undefined,
siteName: string | undefined,
options?: {
refetchInterval?: number | false | ((query: { state: { data: SiteBuildStatusResponse | undefined } }) => number | false);
},
) {
return useQuery({
queryKey: ["web-app", "build-status", threadId ?? "", siteName ?? ""] as const,
queryFn: () => fetchSiteBuildStatus(threadId as string, siteName as string),
enabled: !!threadId && !!siteName,
refetchInterval: options?.refetchInterval ?? false,
refetchOnWindowFocus: false,
});
}
export function useBuildSiteMutation(threadId: string | undefined, siteName: string | undefined) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => buildSite(threadId as string, siteName as string),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: SITES_KEY(threadId ?? "") });
void queryClient.invalidateQueries({
queryKey: ["web-app", "build-status", threadId ?? "", siteName ?? ""],
});
},
});
}
export function useMintPreviewUrlMutation(threadId: string | undefined, siteName: string | undefined) {
return useMutation({
mutationFn: () => mintSitePreviewUrl(threadId as string, siteName as string),
});
}
+55 -5
View File
@@ -249,7 +249,42 @@ export function collectRuntimeDisplaySources(
() => ++citationIndex, () => ++citationIndex,
); );
} }
return sources; return rankLlmWikiSources(sources);
}
/**
* The Wiki tool exposes a numeric retrieval score. Give Wiki sources their
* citation numbers in that ranked order so the answer text, reference panel,
* and tool trace all refer to the same item. Non-Wiki sources keep their
* existing positions and indexes.
*/
function rankLlmWikiSources(
sources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
const wikiSlots = sources
.map((source, position) => ({ source, position }))
.filter(({ source }) => source.skillName === "llmwiki_search");
if (wikiSlots.length < 2) return sources;
const ranked = [...wikiSlots].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.position - right.position;
});
const rankedBySlot = new Map(
wikiSlots.map(({ position }, rank) => [position, ranked[rank].source]),
);
return sources.map((source, position) => {
const rankedSource = rankedBySlot.get(position);
return rankedSource ? { ...rankedSource, index: source.index } : source;
});
} }
function appendDisplaySources( function appendDisplaySources(
@@ -468,7 +503,8 @@ export function shouldUseDefaultCitationMode(
normalizedName.includes("search") || normalizedName.includes("search") ||
normalizedName.includes("retriev") || normalizedName.includes("retriev") ||
normalizedName.includes("knowledge") || normalizedName.includes("knowledge") ||
normalizedName.includes("query"); normalizedName.includes("query") ||
normalizedName.includes("wiki");
const isSkillInvocation = !!( const isSkillInvocation = !!(
toolArgs && toolArgs &&
canInferSkillFromToolArgs(toolName) && canInferSkillFromToolArgs(toolName) &&
@@ -495,12 +531,13 @@ export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceI
item.id ?? item.id ??
item.url, item.url,
title: item.title ?? item.source ?? `结果 ${index + 1}`, title: item.title ?? item.source ?? `结果 ${index + 1}`,
type: "result", type: item.type ?? "result",
snippet: item.snippet ?? item.content ?? "", snippet: item.snippet ?? item.content ?? "",
content: item.content ?? item.snippet, content: item.content ?? item.snippet,
source: item.source, source: item.source,
author: item.author, author: item.author,
time: item.published_date ?? item.publishedDate ?? item.date, time: item.published_date ?? item.publishedDate ?? item.date,
score: item.score,
url: item.url, url: item.url,
raw: item as unknown as Record<string, unknown>, raw: item as unknown as Record<string, unknown>,
})); }));
@@ -519,6 +556,12 @@ export function applyInlineCitations(
return content; return content;
} }
const byIndex = new Map(referenceSources.map((source) => [source.index, source])); const byIndex = new Map(referenceSources.map((source) => [source.index, source]));
const byCitationId = new Map(
referenceSources.flatMap((source) => {
const id = typeof source.raw?.citation_id === "string" ? source.raw.citation_id.trim() : "";
return id ? [[id, source] as const] : [];
}),
);
const byUrl = new Map<string, RuntimeDisplaySource>(); const byUrl = new Map<string, RuntimeDisplaySource>();
const byTitle = new Map<string, RuntimeDisplaySource>(); const byTitle = new Map<string, RuntimeDisplaySource>();
for (const source of referenceSources) { for (const source of referenceSources) {
@@ -530,13 +573,13 @@ export function applyInlineCitations(
} }
} }
const normalized = normalizeExistingCitationLinks( const normalized = replaceStableCitationTokens(normalizeExistingCitationLinks(
content, content,
byIndex, byIndex,
byUrl, byUrl,
byTitle, byTitle,
referenceSources, referenceSources,
); ), byCitationId);
const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g; const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g;
let cursor = 0; let cursor = 0;
@@ -570,6 +613,13 @@ export function applyInlineCitations(
return appendCitationSummary(next, referenceSources); return appendCitationSummary(next, referenceSources);
} }
function replaceStableCitationTokens(content: string, byCitationId: Map<string, RuntimeDisplaySource>): string {
return content.replace(/\[\[cite:([^\]]+)\]\]/gi, (match, rawId: string) => {
const source = byCitationId.get(rawId.trim());
return source ? citationLinkMarkdown(source) : match;
});
}
function replaceCitationTokens( function replaceCitationTokens(
content: string, content: string,
byIndex: Map<number, RuntimeDisplaySource>, byIndex: Map<number, RuntimeDisplaySource>,
@@ -11,6 +11,60 @@ export interface PromptPrefixSettings {
export type KnowledgeBaseProductVersion = "v1" | "v2"; export type KnowledgeBaseProductVersion = "v1" | "v2";
export type OrdinaryKnowledgeLanding = "wiki" | "weknora";
export interface StrategyProjectSettings {
taiwan_strait_demo_enabled: boolean;
}
export interface StrategyProjectSettingsUpdate {
taiwan_strait_demo_enabled?: boolean;
}
export async function loadStrategyProjectSettings(): Promise<StrategyProjectSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/strategy-projects`,
);
if (!response.ok) {
throw new Error(`无法读取策略项目设置(HTTP ${response.status})`);
}
return (await response.json()) as StrategyProjectSettings;
}
export async function updateStrategyProjectSettings(
body: StrategyProjectSettingsUpdate,
): Promise<StrategyProjectSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/strategy-projects`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "保存策略项目设置失败" }));
throw new Error(detail.detail ?? "保存策略项目设置失败");
}
return (await response.json()) as StrategyProjectSettings;
}
export async function loadOrdinaryKnowledgeLanding(): Promise<OrdinaryKnowledgeLanding> {
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`);
if (!response.ok) throw new Error("无法读取普通知识库默认页面设置");
return ((await response.json()) as { mode: OrdinaryKnowledgeLanding }).mode;
}
export async function saveOrdinaryKnowledgeLanding(mode: OrdinaryKnowledgeLanding): Promise<OrdinaryKnowledgeLanding> {
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode }),
});
if (!response.ok) throw new Error("保存失败,仅管理员可配置普通知识库默认页面");
return ((await response.json()) as { mode: OrdinaryKnowledgeLanding }).mode;
}
export async function loadKnowledgeBaseProductVersion(): Promise<KnowledgeBaseProductVersion> { export async function loadKnowledgeBaseProductVersion(): Promise<KnowledgeBaseProductVersion> {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`, `${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`,
@@ -260,6 +314,42 @@ export interface PublishSquaresUpdate {
default_square_id?: string; default_square_id?: string;
} }
/** Agent Gallery category selected for the wx route's common-agent panel. */
export interface WxCommonAgentsSettings {
/** `null` keeps the original common-agent recommendation logic. */
agent_type: string | null;
}
export async function loadWxCommonAgents(): Promise<WxCommonAgentsSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/wx-common-agents`,
);
if (!response.ok) {
throw new Error(`Failed to load wx common agents: ${response.status}`);
}
return (await response.json()) as WxCommonAgentsSettings;
}
export async function updateWxCommonAgents(
agentType: string | null,
): Promise<WxCommonAgentsSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/wx-common-agents`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_type: agentType }),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update wx common agents" }));
throw new Error(detail.detail ?? "Failed to update wx common agents");
}
return (await response.json()) as WxCommonAgentsSettings;
}
export async function loadPublishSquares(): Promise<PublishSquaresSettings> { export async function loadPublishSquares(): Promise<PublishSquaresSettings> {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/publish-squares`, `${getBackendBaseURL()}/api/system-settings/publish-squares`,
@@ -547,6 +637,48 @@ export interface ModelConcurrencyUpdate {
user_model_limits?: Record<string, number>; user_model_limits?: Record<string, number>;
} }
export interface MemoryRuntimeSettings {
enabled: boolean;
}
export async function loadMemoryRuntimeSettings(): Promise<MemoryRuntimeSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/memory`,
);
if (!response.ok) {
throw new Error(`Failed to load memory runtime setting: ${response.status}`);
}
return (await response.json()) as MemoryRuntimeSettings;
}
export async function updateMemoryRuntimeSettings(
body: MemoryRuntimeSettings,
): Promise<MemoryRuntimeSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/memory`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update memory runtime setting" }));
throw new Error(detail.detail ?? "Failed to update memory runtime setting");
}
const data = (await response.json()) as MemoryRuntimeSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-问答记忆开关",
url: "/api/system-settings/memory",
reqParam: body,
returnParam: data,
});
return data;
}
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> { export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/skill-compression`, `${getBackendBaseURL()}/api/system-settings/skill-compression`,
+78 -2
View File
@@ -8,30 +8,38 @@ import {
loadCitationDisplaySettings, loadCitationDisplaySettings,
loadLeaderboardSettings, loadLeaderboardSettings,
loadModelConcurrencySettings, loadModelConcurrencySettings,
loadMemoryRuntimeSettings,
loadPluginManagementSettings, loadPluginManagementSettings,
loadPrivateSquareStatus, loadPrivateSquareStatus,
loadPromptPrefixSettings, loadPromptPrefixSettings,
loadPublishSquares, loadPublishSquares,
loadSkillCompressionSettings, loadSkillCompressionSettings,
loadStrategyProjectSettings,
loadWxCommonAgents,
updateAgentModuleConfig, updateAgentModuleConfig,
updateAppearanceSettings, updateAppearanceSettings,
updateCitationDisplaySettings, updateCitationDisplaySettings,
updateLeaderboardSettings, updateLeaderboardSettings,
updateModelConcurrencySettings, updateModelConcurrencySettings,
updateMemoryRuntimeSettings,
updatePluginManagementSettings, updatePluginManagementSettings,
updatePrivateSquareSettings, updatePrivateSquareSettings,
updatePromptPrefixSettings, updatePromptPrefixSettings,
updatePublishSquares, updatePublishSquares,
updateSkillCompressionSettings, updateSkillCompressionSettings,
updateStrategyProjectSettings,
updateWxCommonAgents,
type AppearanceUpdate, type AppearanceUpdate,
type CitationDisplayUpdate, type CitationDisplayUpdate,
type LeaderboardSettingsUpdate, type LeaderboardSettingsUpdate,
type ModelConcurrencyUpdate, type ModelConcurrencyUpdate,
type MemoryRuntimeSettings,
type PluginManagementUpdate, type PluginManagementUpdate,
type PrivateSquareUpdate, type PrivateSquareUpdate,
type PromptPrefixUpdate, type PromptPrefixUpdate,
type PublishSquaresUpdate, type PublishSquaresUpdate,
type SkillCompressionUpdate, type SkillCompressionUpdate,
type StrategyProjectSettingsUpdate,
type LayoutMode, type LayoutMode,
type ViewMode, type ViewMode,
} from "./api"; } from "./api";
@@ -44,7 +52,30 @@ const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const; const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const; const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const; const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
const MEMORY_RUNTIME_KEY = ["systemSettings", "memoryRuntime"] as const;
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const; const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
const STRATEGY_PROJECTS_KEY = ["systemSettings", "strategyProjects"] as const;
export function useStrategyProjectSettings() {
return useQuery({
queryKey: STRATEGY_PROJECTS_KEY,
queryFn: () => loadStrategyProjectSettings(),
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdateStrategyProjectSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: StrategyProjectSettingsUpdate) =>
updateStrategyProjectSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(STRATEGY_PROJECTS_KEY, data);
},
});
}
export function usePromptPrefixSettings() { export function usePromptPrefixSettings() {
return useQuery({ return useQuery({
@@ -86,15 +117,36 @@ export function useUpdatePrivateSquareSettings() {
}); });
} }
export function useMemoryRuntimeSettings() {
return useQuery({
queryKey: MEMORY_RUNTIME_KEY,
queryFn: () => loadMemoryRuntimeSettings(),
staleTime: 10_000,
refetchOnWindowFocus: false,
});
}
export function useUpdateMemoryRuntimeSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: MemoryRuntimeSettings) =>
updateMemoryRuntimeSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(MEMORY_RUNTIME_KEY, data);
},
});
}
/** The configured publish squares (发布广场). Readable by any logged-in user — /** The configured publish squares (发布广场). Readable by any logged-in user —
* the gallery sub-tabs and the publish-flow square picker both need it. */ * the gallery sub-tabs and the publish-flow square picker both need it. */
export function usePublishSquares() { export function usePublishSquares({ enabled = true }: { enabled?: boolean } = {}) {
return useQuery({ return useQuery({
queryKey: PUBLISH_SQUARES_KEY, queryKey: PUBLISH_SQUARES_KEY,
queryFn: () => loadPublishSquares(), queryFn: () => loadPublishSquares(),
staleTime: 60_000, staleTime: 60_000,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: false, retry: false,
enabled,
}); });
} }
@@ -182,7 +234,9 @@ export function useLayoutMode(): [LayoutMode, (mode: LayoutMode) => void] {
const agentModuleKey = (key: string) => const agentModuleKey = (key: string) =>
["systemSettings", "agentModule", key] as const; ["systemSettings", "agentModule", key] as const;
export function useAgentModuleConfig(key: string) { export function useAgentModuleConfig(
key: string,
) {
return useQuery({ return useQuery({
queryKey: agentModuleKey(key), queryKey: agentModuleKey(key),
queryFn: () => loadAgentModuleConfig(key), queryFn: () => loadAgentModuleConfig(key),
@@ -191,6 +245,28 @@ export function useAgentModuleConfig(key: string) {
}); });
} }
const WX_COMMON_AGENTS_KEY = ["systemSettings", "wxCommonAgents"] as const;
export function useWxCommonAgents({ enabled = true }: { enabled?: boolean } = {}) {
return useQuery({
queryKey: WX_COMMON_AGENTS_KEY,
queryFn: loadWxCommonAgents,
enabled,
staleTime: 60_000,
refetchOnWindowFocus: false,
});
}
export function useUpdateWxCommonAgents() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateWxCommonAgents,
onSuccess: (data) => {
queryClient.setQueryData(WX_COMMON_AGENTS_KEY, data);
},
});
}
export function useUpdateAgentModuleConfig(key: string) { export function useUpdateAgentModuleConfig(key: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
+12 -13
View File
@@ -826,20 +826,14 @@ export function useThreadStream({
return; return;
} }
// Context compression about to run a (blocking) summarization LLM call — // Background compaction stays silent, including events from older backends.
// surface a transient hint so the wait before the answer streams isn't a
// silent dead air. Emitted by DeerFlowSummarizationMiddleware.
if ( if (
typeof event === "object" && typeof event === "object" &&
event !== null && event !== null &&
"type" in event && "type" in event &&
event.type === "context_compacting" && event.type === "context_compacting"
"message" in event &&
typeof event.message === "string" &&
event.message.trim()
) { ) {
const e = event as { type: "context_compacting"; message: string }; return;
toast(e.message);
} }
}, },
onError(error) { onError(error) {
@@ -886,8 +880,15 @@ export function useThreadStream({
// Move to a fresh chat without a full browser reload; the old reload path // Move to a fresh chat without a full browser reload; the old reload path
// made every click look like a crash and could loop indefinitely when a // made every click look like a crash and could loop indefinitely when a
// different tab's cookie kept overriding this tab's Bearer token. // different tab's cookie kept overriding this tab's Bearer token.
if (isThreadNotAccessibleError(message)) { if (isThreadNotAccessibleError(message)) {
const userLabel = buildCurrentUserLabel(); const projectHash = typeof window !== "undefined" ? window.location.hash : "";
const projectMatch = projectHash.match(/#\/page\/workspace\/projects\/([^/]+)\//);
if (projectMatch) {
toast.info("当前项目对话不存在或无权访问,已返回项目空间。");
navigate("/page/workspace/projects", { replace: true });
return;
}
const userLabel = buildCurrentUserLabel();
toast.info( toast.info(
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`, `当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
); );
@@ -1591,8 +1592,6 @@ export function useThreadStream({
: mergedContext.mode === "thinking" : mergedContext.mode === "thinking"
? "low" ? "low"
: undefined), : undefined),
memory_injection_enabled:
mergedContext.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled, rag_mode_enabled: ragModeEnabled,
summarization_enabled: contextCompressionEnabled, summarization_enabled: contextCompressionEnabled,
thread_id: threadId, thread_id: threadId,
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
myProjectConversationTypeFromSearchParams,
myProjectConversationTypeOfThread,
myProjectThreadMetadata,
withMyProjectConversationMarker,
} from "./project-metadata";
test("project conversation type is read from persisted metadata", () => {
assert.equal(
myProjectConversationTypeOfThread({
metadata: myProjectThreadMetadata("strategy"),
}),
"strategy",
);
assert.equal(
myProjectConversationTypeOfThread({
metadata: { project_source: "elsewhere", project_type: "qa" },
}),
null,
);
});
test("metadata wins over the compatibility context fallback", () => {
assert.equal(
myProjectConversationTypeOfThread({
metadata: myProjectThreadMetadata("solution"),
context: myProjectThreadMetadata("qa"),
}),
"solution",
);
});
test("new-chat route marker round-trips all supported project types", () => {
for (const type of ["qa", "strategy", "solution"] as const) {
const path = withMyProjectConversationMarker(
"/page/strategy/chats/new?keep=1",
type,
);
const params = new URLSearchParams(path.split("?", 2)[1]);
assert.equal(params.get("keep"), "1");
assert.equal(myProjectConversationTypeFromSearchParams(params), type);
}
});
@@ -0,0 +1,94 @@
/**
* Stable marker for conversations entered from 「我的项目」.
*
* Keep this separate from ``project_id``: that key belongs to DeerFlow's
* folder-style conversation projects, while this marker describes the product
* mode that created (or explicitly continued) a conversation.
*/
export const MY_PROJECT_THREAD_SOURCE = "my-projects" as const;
export const MY_PROJECT_THREAD_SOURCE_KEY = "project_source" as const;
export const MY_PROJECT_THREAD_TYPE_KEY = "project_type" as const;
export const MY_PROJECT_QUERY_SOURCE_KEY = "projectSource" as const;
export const MY_PROJECT_QUERY_TYPE_KEY = "projectType" as const;
export type MyProjectConversationType = "qa" | "strategy" | "solution";
export const MY_PROJECT_CONVERSATION_LABELS: Record<
MyProjectConversationType,
string
> = {
qa: "普通问答",
strategy: "策略优选",
solution: "方案生成",
};
const MY_PROJECT_CONVERSATION_TYPES = new Set<MyProjectConversationType>([
"qa",
"strategy",
"solution",
]);
function typeFromRecord(
value: Record<string, unknown> | null | undefined,
): MyProjectConversationType | null {
if (value?.[MY_PROJECT_THREAD_SOURCE_KEY] !== MY_PROJECT_THREAD_SOURCE) {
return null;
}
const type = value[MY_PROJECT_THREAD_TYPE_KEY];
return typeof type === "string" &&
MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType)
? (type as MyProjectConversationType)
: null;
}
export function myProjectThreadMetadata(
type: MyProjectConversationType,
): Record<string, unknown> {
return {
[MY_PROJECT_THREAD_SOURCE_KEY]: MY_PROJECT_THREAD_SOURCE,
[MY_PROJECT_THREAD_TYPE_KEY]: type,
};
}
/** Read persisted metadata first; context is only a compatibility fallback. */
export function myProjectConversationTypeOfThread(thread: {
metadata?: Record<string, unknown> | null;
context?: Record<string, unknown> | null;
}): MyProjectConversationType | null {
return typeFromRecord(thread.metadata) ?? typeFromRecord(thread.context);
}
export function myProjectConversationTypeFromSearchParams(params: {
get(name: string): string | null;
}): MyProjectConversationType | null {
if (params.get(MY_PROJECT_QUERY_SOURCE_KEY) !== MY_PROJECT_THREAD_SOURCE) {
return null;
}
const type = params.get(MY_PROJECT_QUERY_TYPE_KEY);
return typeof type === "string" &&
MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType)
? (type as MyProjectConversationType)
: null;
}
/** Add an explicit, one-shot marker to a new-conversation route. */
export function withMyProjectConversationMarker(
path: string,
type: MyProjectConversationType,
): string {
const hashIndex = path.indexOf("#");
const hash = hashIndex >= 0 ? path.slice(hashIndex) : "";
const pathWithoutHash = hashIndex >= 0 ? path.slice(0, hashIndex) : path;
const queryIndex = pathWithoutHash.indexOf("?");
const pathname =
queryIndex >= 0 ? pathWithoutHash.slice(0, queryIndex) : pathWithoutHash;
const params = new URLSearchParams(
queryIndex >= 0 ? pathWithoutHash.slice(queryIndex + 1) : "",
);
params.set(MY_PROJECT_QUERY_SOURCE_KEY, MY_PROJECT_THREAD_SOURCE);
params.set(MY_PROJECT_QUERY_TYPE_KEY, type);
const search = params.toString();
return `${pathname}${search ? `?${search}` : ""}${hash}`;
}
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import test from "node:test";
import { listPathOfThread } from "./utils";
import type { AgentThread } from "./types";
const projectThread = {
thread_id: "chat-1",
metadata: { analysis_project_id: "project-1" },
} as unknown as AgentThread;
test("project history keeps the My Projects workspace and return destination", () => {
for (const current of ["/page/strategy/qa/projects", "/page/strategy/qa/projects/groups/project-1/chats/chat-1"]) {
assert.equal(listPathOfThread(projectThread, current), "/page/strategy/qa/projects/groups/project-1/chats/chat-1");
}
});
test("legacy project history and ordinary chat routes remain available", () => {
assert.equal(listPathOfThread(projectThread), "/page/workspace/projects/project-1/chats/chat-1");
assert.equal(listPathOfThread({ thread_id: "chat-2", metadata: {} } as unknown as AgentThread, "/page/strategy/qa/projects"), "/page/workspace/chats/chat-2");
assert.equal(listPathOfThread({ thread_id: "setup", metadata: { thread_type: "bootstrap" } } as unknown as AgentThread), "/page/workspace/agents/new/setup");
});
-1
View File
@@ -19,7 +19,6 @@ export interface AgentThreadContext extends Record<string, unknown> {
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean; writing_mode?: boolean;
writing_artifact_path?: string; writing_artifact_path?: string;
memory_injection_enabled?: boolean;
rag_mode_enabled?: boolean; rag_mode_enabled?: boolean;
/** Per-request switch for context compression (上下文压缩). Default off. */ /** Per-request switch for context compression (上下文压缩). Default off. */
summarization_enabled?: boolean; summarization_enabled?: boolean;
+30 -7
View File
@@ -45,10 +45,17 @@ export function pathOfThread(
base = "/page/workspace", base = "/page/workspace",
) { ) {
const threadId = typeof thread === "string" ? thread : thread.thread_id; const threadId = typeof thread === "string" ? thread : thread.thread_id;
const projectId =
typeof thread === "string"
? undefined
: thread.metadata?.analysis_project_id;
if (typeof projectId === "string" && projectId) {
return `${base}/projects/${encodeURIComponent(projectId)}/chats/${encodeURIComponent(threadId)}`;
}
const agentRef = const agentRef =
typeof thread === "string" typeof thread === "string"
? pickAgentRef(context, null) ? pickAgentRef(context, null)
: agentRefOfThread(thread) ?? pickAgentRef(context, null); : (agentRefOfThread(thread) ?? pickAgentRef(context, null));
return agentRef return agentRef
? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}` ? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
@@ -120,7 +127,9 @@ export function isSetupWizardThread(thread: {
metadata?: Record<string, unknown> | null; metadata?: Record<string, unknown> | null;
}): boolean { }): boolean {
const threadType = thread.metadata?.thread_type; const threadType = thread.metadata?.thread_type;
return threadType === "writing_setup" || threadType === "report_structure_setup"; return (
threadType === "writing_setup" || threadType === "report_structure_setup"
);
} }
/** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */ /** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */
@@ -130,7 +139,10 @@ export function isHiddenFromRecentChats(thread: {
const metadata = thread.metadata; const metadata = thread.metadata;
if (metadata?.system === true) return true; if (metadata?.system === true) return true;
const threadType = metadata?.thread_type; const threadType = metadata?.thread_type;
return typeof threadType === "string" && HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType); return (
typeof threadType === "string" &&
HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType)
);
} }
/** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */ /** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */
@@ -151,18 +163,27 @@ export function bootstrapResumePath(threadId: string): string {
} }
/** 列表项点击目标:bootstrap 会话回创建页恢复,其余走普通问答路由。 */ /** 列表项点击目标:bootstrap 会话回创建页恢复,其余走普通问答路由。 */
export function listPathOfThread(thread: AgentThread): string { export function listPathOfThread(thread: AgentThread, currentPath = ""): string {
const projectId = thread.metadata?.analysis_project_id;
if (typeof projectId === "string" && projectId &&
(currentPath === "/page/strategy/qa/projects" || currentPath.startsWith("/page/strategy/qa/projects/"))) {
return `/page/strategy/qa/projects/groups/${encodeURIComponent(projectId)}/chats/${encodeURIComponent(thread.thread_id)}`;
}
return isBootstrapThread(thread) return isBootstrapThread(thread)
? bootstrapResumePath(thread.thread_id) ? bootstrapResumePath(thread.thread_id)
: pathOfThread(thread); : pathOfThread(thread);
} }
export function isThreadPinned(thread: AgentThread): boolean { export function isThreadPinned(thread: AgentThread): boolean {
return (thread.metadata as Record<string, unknown> | null)?.is_pinned === true; return (
(thread.metadata as Record<string, unknown> | null)?.is_pinned === true
);
} }
export function isThreadFavorite(thread: AgentThread): boolean { export function isThreadFavorite(thread: AgentThread): boolean {
return (thread.metadata as Record<string, unknown> | null)?.is_favorite === true; return (
(thread.metadata as Record<string, unknown> | null)?.is_favorite === true
);
} }
/** /**
@@ -179,7 +200,9 @@ export function sortThreadsPinnedFirst(threads: AgentThread[]): AgentThread[] {
return [...pinned, ...rest]; return [...pinned, ...rest];
} }
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) { export function sanitizeThreadTitleForDisplay(
title: string | null | undefined,
) {
if (!title) return "Untitled"; if (!title) return "Untitled";
const cleaned = title const cleaned = title
@@ -113,6 +113,17 @@ test("normal mode auto-opens every new artifact like before writing mode existed
); );
}); });
test("Word artifacts stay download-only in every mode", () => {
assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/fixed.docx"),
false,
);
assert.equal(
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/source.doc", [], true),
false,
);
});
test("a later user turn releases report.md live edits", () => { test("a later user turn releases report.md live edits", () => {
const followUp = [ const followUp = [
{ type: "human", content: "写一篇论文" }, { type: "human", content: "写一篇论文" },
@@ -1,5 +1,7 @@
/** Only the writing-mode deliverable counts as a follow-up edit target. */ /** Only the writing-mode deliverable counts as a follow-up edit target. */
import { isWordDocumentFile } from "@/core/utils/files";
const OUTPUTS_PREFIX = "/mnt/user-data/outputs"; const OUTPUTS_PREFIX = "/mnt/user-data/outputs";
export function normalizeWritingPath(path: string | null | undefined): string { export function normalizeWritingPath(path: string | null | undefined): string {
@@ -105,6 +107,11 @@ export function shouldAutoOpenWritingArtifact(
if (!path) { if (!path) {
return false; return false;
} }
// DOC/DOCX are delivered as download-only files. They have no meaningful
// sandbox preview, so never let a completed Word artifact open the panel.
if (isWordDocumentFile(path)) {
return false;
}
if (!writingMode) { if (!writingMode) {
return true; return true;
} }
+12 -5
View File
@@ -148,11 +148,18 @@ export function getFileName(filepath: string) {
return filepath.split("/").pop()!; return filepath.split("/").pop()!;
} }
export function getFileExtension(filepath: string) { export function getFileExtension(filepath: string) {
return filepath.split(".").pop()!.toLocaleLowerCase(); return filepath.split(".").pop()!.toLocaleLowerCase();
} }
const IMAGE_EXTENSIONS = new Set([ /** Word files are download-only artifacts: browsers cannot preview them reliably. */
export function isWordDocumentFile(filepath: string): boolean {
const pathname = filepath.split(/[?#]/, 1)[0] ?? filepath;
const extension = getFileExtension(pathname);
return extension === "doc" || extension === "docx";
}
const IMAGE_EXTENSIONS = new Set([
"png", "png",
"jpg", "jpg",
"jpeg", "jpeg",
@@ -46,3 +46,26 @@ test("prepareGeneratedHtml injects the ES5 chart size shim and anchor handler",
assert.match(out, /getElementsByTagName\("canvas"\)/); assert.match(out, /getElementsByTagName\("canvas"\)/);
assert.doesNotMatch(out, /\?\.|=>|`/); assert.doesNotMatch(out, /\?\.|=>|`/);
}); });
test("does not inject visualization runtimes into an ordinary HTML page", () => {
const html = `<!doctype html><html><body><h1>普通页面</h1></body></html>`;
const out = prepareGeneratedHtml(html);
assert.doesNotMatch(out, /data-df-html-runtime/);
assert.doesNotMatch(out, /\/assets\/(?:mermaid|echarts|leaflet)/);
});
test("keeps the explicit Gateway HTML runtime used by new pages", () => {
const html = `<!doctype html><html><head>
<link rel="stylesheet" href="/api/public/html-runtime/leaflet.css">
<script defer src="/api/public/html-runtime/leaflet.js"></script>
<script defer src="/api/public/html-runtime/deerflow-runtime.js"></script>
</head><body><div id="map" class="df-map"></div>
<script type="application/json" data-df-map="map">{"markers":[]}</script></body></html>`;
const out = prepareGeneratedHtml(html);
assert.match(out, /\/api\/public\/html-runtime\/leaflet\.js/);
assert.match(out, /\/api\/public\/html-runtime\/deerflow-runtime\.js/);
assert.doesNotMatch(out, /data-df-html-runtime=/);
assert.doesNotMatch(out, /localhost\/assets\/leaflet/);
});
@@ -13,8 +13,144 @@ import { injectInPageAnchorHandler } from "./markdown";
const MAX_PASSES = 12; const MAX_PASSES = 12;
export function prepareGeneratedHtml(html: string): string { export type GeneratedHtmlRuntimeAssets = {
return injectInPageAnchorHandler(injectLegacyChartShim(downlevelModernColors(html ?? ""))); g6ScriptUrl: string;
mermaidScriptUrl: string;
echartsScriptUrl: string;
echartsChinaGeoJson: unknown;
mapTileUrl: string;
mapTileAttribution: string;
leafletScriptUrl: string;
leafletStyleText: string;
};
export function prepareGeneratedHtml(
html: string,
): string {
const prepared = injectLegacyChartShim(downlevelModernColors(html ?? ""));
return injectInPageAnchorHandler(prepared);
}
const KNOWN_RUNTIME_SCRIPT_RE = /<script\b([^>]*?)\bsrc\s*=\s*(["'])([^"']+)\2([^>]*)>\s*<\/script\s*>/gi;
const KNOWN_RUNTIME_STYLE_RE = /<link\b([^>]*?)\bhref\s*=\s*(["'])([^"']+)\2([^>]*)>/gi;
function runtimeKind(url: string): "g6" | "mermaid" | "echarts" | "leaflet" | null {
if (/(?:@antv\/g6|(?:^|\/)g6(?:\.min)?\.js)/i.test(url)) return "g6";
if (/mermaid(?:\.min)?\.(?:js|mjs)|\bmermaid@/i.test(url)) return "mermaid";
if (/echarts(?:\.min)?\.js|\becharts@/i.test(url)) return "echarts";
if (/leaflet(?:\.min)?\.(?:js|css)|\bleaflet@/i.test(url)) return "leaflet";
return null;
}
function escapeHtmlAttribute(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function safeJsonForScript(value: unknown): string {
return JSON.stringify(value).replace(/</g, "\\u003c");
}
function safeStyleText(value: string): string {
return value.replace(/<\/style/gi, "<\\/style");
}
function injectAfterHeadOpen(html: string, tags: string): string {
if (!tags) return html;
if (/<head\b[^>]*>/i.test(html)) {
return html.replace(/<head\b[^>]*>/i, (head) => `${head}${tags}`);
}
if (/<html\b[^>]*>/i.test(html)) {
return html.replace(/<html\b[^>]*>/i, (root) => `${root}<head>${tags}</head>`);
}
return `<head>${tags}</head>${html}`;
}
function injectBeforeBodyClose(html: string, tags: string): string {
if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, `${tags}</body>`);
return html + tags;
}
/**
* Replace CDN visualization runtimes with app-bundled assets and initialize the
* declarative contracts used by generated HTML pages. The iframe remains
* sandboxed without allow-same-origin; only script/style URLs are substituted.
*/
export function injectGeneratedHtmlRuntimes(
html: string,
assets: GeneratedHtmlRuntimeAssets,
): string {
// New generated pages load their browser runtimes directly from Gateway.
// Leave those explicit source references untouched; this fallback only
// applies to older artifacts that used the declarative blocks without them.
if (/\/api\/public\/html-runtime\/deerflow-runtime\.js/i.test(html)) return html;
if (!html || html.includes("data-df-html-runtime")) return html;
const needsG6 = /data-df-flow\s*=|\bG6\s*\.\s*Graph\b|@antv\/g6|(?:^|[/'"])[gG]6(?:\.min)?\.js/i.test(html);
const needsMermaid = /class\s*=\s*["'][^"']*\bmermaid\b|\bmermaid\s*\.|mermaid(?:\.min)?\.(?:js|mjs)|\bmermaid@/i.test(html);
const needsEcharts = /data-df-chart\s*=|\becharts\s*\.|echarts(?:\.min)?\.js|\becharts@/i.test(html);
const needsLeaflet = /data-df-map\s*=|\bL\s*\.\s*map\s*\(|leaflet(?:\.min)?\.(?:js|css)|\bleaflet@/i.test(html);
const needsEchartsChinaMap = needsEcharts && /\bmap\s*:\s*["']china["']|["']map["']\s*:\s*["']china["']/i.test(html);
if (!needsG6 && !needsMermaid && !needsEcharts && !needsLeaflet) return html;
let prepared = html.replace(
KNOWN_RUNTIME_SCRIPT_RE,
(tag, _before: string, _quote: string, url: string) =>
runtimeKind(url) ? "" : tag,
);
prepared = prepared.replace(
KNOWN_RUNTIME_STYLE_RE,
(tag, _before: string, _quote: string, url: string) =>
runtimeKind(url) === "leaflet" ? "" : tag,
);
const headTags = [
needsLeaflet
? `<style data-df-html-runtime="leaflet-css">${safeStyleText(assets.leafletStyleText)}</style>`
: "",
needsG6
? `<script data-df-html-runtime="g6" src="${escapeHtmlAttribute(assets.g6ScriptUrl)}"></script>`
: "",
needsMermaid
? `<script data-df-html-runtime="mermaid" src="${escapeHtmlAttribute(assets.mermaidScriptUrl)}"></script>`
: "",
needsEcharts
? `<script data-df-html-runtime="echarts" src="${escapeHtmlAttribute(assets.echartsScriptUrl)}"></script>`
: "",
needsEchartsChinaMap
? `<script data-df-html-runtime="echarts-china-map">if(window.echarts){window.echarts.registerMap("china",${safeJsonForScript(assets.echartsChinaGeoJson)});}</script>`
: "",
needsLeaflet
? `<script data-df-html-runtime="leaflet" src="${escapeHtmlAttribute(assets.leafletScriptUrl)}"></script>`
: "",
`<style data-df-html-runtime="styles">.df-flow,.df-chart,.df-map{position:relative;min-height:360px;width:100%}.df-runtime-error{box-sizing:border-box;margin:12px 0;padding:12px 14px;border:1px solid #fca5a5;border-radius:8px;background:#fef2f2;color:#991b1b;font:14px/1.5 sans-serif}.df-flow-summary{margin:10px 0;color:inherit;font:13px/1.6 sans-serif}.df-flow-summary summary{cursor:pointer;font-weight:600}.df-flow-summary ol{margin:8px 0 0;padding-left:24px}.df-map-tile-error{position:absolute;z-index:1000;left:12px;right:12px;bottom:12px;padding:9px 12px;border-radius:7px;background:rgba(127,29,29,.92);color:#fff;font:13px/1.4 sans-serif;pointer-events:none}</style>`,
].join("");
prepared = injectAfterHeadOpen(prepared, headTags);
return injectBeforeBodyClose(
prepared,
generatedHtmlRuntimeBootstrap(assets.mapTileUrl, assets.mapTileAttribution),
);
}
function generatedHtmlRuntimeBootstrap(mapTileUrl: string, mapTileAttribution: string): string {
return `<script data-df-html-runtime="bootstrap">(function(){
function ready(fn){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",fn);else fn();}
function errorBox(target,message){var box=document.createElement("div");box.className="df-runtime-error";box.setAttribute("role","alert");box.textContent=message;if(target&&target.parentNode)target.parentNode.insertBefore(box,target.nextSibling);}
function byId(id){return id?document.getElementById(id):null;}
function jsonBlocks(name){return document.querySelectorAll('script[type="application/json"]['+name+']');}
function parse(block){try{return JSON.parse(block.textContent||"{}");}catch(e){errorBox(block,"图形数据格式错误:"+(e&&e.message?e.message:"无法解析 JSON"));return null;}}
function positiveSize(target){if(!target)return false;if(target.clientHeight<40)target.style.minHeight="360px";if(target.clientWidth<40)target.style.width="100%";return true;}
function initFlows(){var blocks=jsonBlocks("data-df-flow");for(var i=0;i<blocks.length;i++){var block=blocks[i],target=byId(block.getAttribute("data-df-flow")),data=parse(block);if(!target||!data)continue;if(!window.G6||!window.G6.Graph){errorBox(target,"流程图运行库加载失败,请刷新页面后重试");continue;}try{positiveSize(target);var nodes=data.nodes||[],edges=data.edges||[];target.setAttribute("role","img");target.setAttribute("aria-label",(data.title||"流程图")+",包含 "+nodes.length+" 个步骤和 "+edges.length+" 条连线");var colors={start:["#0f766e","#2dd4bf"],end:["#7c3aed","#a78bfa"],decision:["#b45309","#f59e0b"],risk:["#b91c1c","#f87171"],process:["#1d4ed8","#60a5fa"],stage:["#4338ca","#818cf8"],task:["#334155","#94a3b8"]};var shapes={start:"ellipse",end:"ellipse",decision:"diamond",risk:"hexagon",process:"rect",stage:"rect",task:"rect"};var graphData={nodes:[],edges:[]};for(var n=0;n<nodes.length;n++){var node=nodes[n],pair=colors[node.type]||colors.task;graphData.nodes.push({id:String(node.id),data:{label:node.label||node.id,description:node.description||"",fill:node.color||pair[0],stroke:pair[1],shape:shapes[node.type]||"rect"}});}for(var e=0;e<edges.length;e++){var edge=edges[e];graphData.edges.push({source:String(edge.source),target:String(edge.target),data:{label:edge.label||""}});}var graph=new window.G6.Graph({container:target,data:graphData,autoFit:"view",padding:32,animation:!(window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches),layout:{type:"dagre",rankdir:data.direction==="TB"?"TB":"LR",nodesep:36,ranksep:72},node:{type:function(d){return d.data.shape;},style:{size:[176,68],radius:14,fill:function(d){return d.data.fill;},stroke:function(d){return d.data.stroke;},lineWidth:2,shadowColor:"rgba(15,23,42,.28)",shadowBlur:12,labelText:function(d){return d.data.label+(d.data.description?"\n"+d.data.description:"");},labelFill:"#f8fafc",labelFontSize:14,labelFontWeight:600,labelLineHeight:20,labelWordWrap:true,labelMaxWidth:148}},edge:{type:"polyline",style:{stroke:"#94a3b8",lineWidth:2,radius:12,endArrow:true,labelText:function(d){return d.data.label;},labelFill:"#475569",labelFontSize:12,labelBackground:true,labelBackgroundFill:"#f8fafc",labelBackgroundRadius:6,labelPadding:[4,7]}},behaviors:["drag-canvas","zoom-canvas","drag-element"]});var rendered=graph.render();if(rendered&&rendered.catch)rendered.catch(function(err){errorBox(target,"流程图渲染失败:"+(err&&err.message?err.message:"请检查流程数据"));});var summary=document.createElement("details");summary.className="df-flow-summary";var heading=document.createElement("summary");heading.textContent="查看流程步骤";summary.appendChild(heading);var list=document.createElement("ol");for(var s=0;s<nodes.length;s++){var item=document.createElement("li");item.textContent=(nodes[s].label||nodes[s].id)+(nodes[s].description?" — "+nodes[s].description:"");list.appendChild(item);}summary.appendChild(list);target.parentNode.insertBefore(summary,target.nextSibling);(function(instance){window.addEventListener("resize",function(){instance.resize();});})(graph);}catch(err){errorBox(target,"流程图渲染失败:"+(err&&err.message?err.message:"请检查流程数据"));}}}
function initMermaid(){var nodes=document.querySelectorAll(".mermaid");if(!nodes.length)return;if(!window.mermaid){for(var i=0;i<nodes.length;i++)errorBox(nodes[i],"流程图库加载失败,请刷新页面后重试");return;}try{window.mermaid.initialize({startOnLoad:false,securityLevel:"strict",theme:"default"});var result=window.mermaid.run({nodes:nodes});if(result&&result.catch)result.catch(function(e){errorBox(nodes[0],"流程图渲染失败:"+(e&&e.message?e.message:"请检查 Mermaid 语法"));});}catch(e){errorBox(nodes[0],"流程图渲染失败:"+(e&&e.message?e.message:"请检查 Mermaid 语法"));}}
function initCharts(){var blocks=jsonBlocks("data-df-chart");for(var i=0;i<blocks.length;i++){var block=blocks[i],target=byId(block.getAttribute("data-df-chart")),option=parse(block);if(!target||!option)continue;if(!window.echarts){errorBox(target,"图表运行库加载失败,请刷新页面后重试");continue;}try{positiveSize(target);target.setAttribute("role","img");target.setAttribute("aria-label",option.title&&option.title.text?option.title.text:"数据图表");var chart=window.echarts.init(target);chart.setOption(option);(function(instance){window.addEventListener("resize",function(){instance.resize();});})(chart);}catch(e){errorBox(target,"图表渲染失败:"+(e&&e.message?e.message:"请检查图表数据"));}}}
function lngLat(value){if(!value||value.length<2)return null;var lng=Number(value[0]),lat=Number(value[1]);if(!isFinite(lng)||!isFinite(lat)||lng<-180||lng>180||lat<-90||lat>90)return null;return [lat,lng];}
function escapeText(value){return String(value==null?"":value).replace(/[&<>\"']/g,function(ch){return {"&":"&amp;","<":"&lt;",">":"&gt;",'\"':"&quot;","'":"&#39;"}[ch];});}
function initMaps(){var blocks=jsonBlocks("data-df-map");for(var i=0;i<blocks.length;i++){var block=blocks[i],target=byId(block.getAttribute("data-df-map")),data=parse(block);if(!target||!data)continue;if(!window.L){errorBox(target,"地图运行库加载失败,请刷新页面后重试");continue;}try{positiveSize(target);target.setAttribute("role","region");target.setAttribute("aria-label",data.title||"地图");var map=window.L.map(target,{zoomControl:true});var tileErrors=0;window.L.tileLayer(${safeJsonForScript(mapTileUrl)},{maxZoom:20,attribution:${safeJsonForScript(mapTileAttribution)}}).on("tileerror",function(){tileErrors+=1;if(tileErrors===3){var warning=document.createElement("div");warning.className="df-map-tile-error";warning.setAttribute("role","status");warning.textContent="地图底图加载失败;地点、路线和区域数据仍可查看";target.appendChild(warning);}}).addTo(map);var bounds=[];var markers=data.markers||[];for(var m=0;m<markers.length;m++){var marker=markers[m],point=lngLat(marker.coordinate);if(!point)continue;bounds.push(point);var dot=window.L.circleMarker(point,{radius:7,color:"#1d4ed8",weight:2,fillColor:"#3b82f6",fillOpacity:.9}).addTo(map);var popup="<strong>"+escapeText(marker.name||"地点")+"</strong>"+(marker.description?"<br>"+escapeText(marker.description):"");dot.bindPopup(popup);dot.bindTooltip(escapeText(marker.name||"地点"));}var lines=data.lines||[];for(var l=0;l<lines.length;l++){var linePoints=[];var rawLine=lines[l].coordinates||[];for(var p=0;p<rawLine.length;p++){var lp=lngLat(rawLine[p]);if(lp){linePoints.push(lp);bounds.push(lp);}}if(linePoints.length>1)window.L.polyline(linePoints,{color:"#2563eb",weight:3,opacity:.85}).addTo(map);}var polygons=data.polygons||[];for(var g=0;g<polygons.length;g++){var polygonPoints=[];var rawPolygon=polygons[g].coordinates||[];for(var q=0;q<rawPolygon.length;q++){var pp=lngLat(rawPolygon[q]);if(pp){polygonPoints.push(pp);bounds.push(pp);}}if(polygonPoints.length>2)window.L.polygon(polygonPoints,{color:"#7c3aed",weight:2,fillColor:"#8b5cf6",fillOpacity:.2}).addTo(map);}var center=lngLat(data.center);if(center)map.setView(center,Number(data.zoom)||6);else if(bounds.length)map.fitBounds(bounds,{padding:[28,28],maxZoom:Number(data.zoom)||10});else map.setView([35.8617,104.1954],Number(data.zoom)||4);(function(instance){setTimeout(function(){instance.invalidateSize();},0);})(map);}catch(e){errorBox(target,"地图渲染失败:"+(e&&e.message?e.message:"请检查地图数据"));}}}
ready(function(){initFlows();initMermaid();initCharts();initMaps();});
})();</script>`;
} }
export function downlevelModernColors(html: string): string { export function downlevelModernColors(html: string): string {
@@ -0,0 +1,13 @@
import { readRuntimeEnv } from "@/env";
export const DEFAULT_MAP_TILE_URL =
"https://mt0.google.com/vt/lyrs=m&hl=zh-CN&x={x}&y={y}&z={z}";
/** Browser-side tile template shared by chat maps and generated HTML maps. */
export function getMapTileUrl(): string {
return readRuntimeEnv("VITE_MAP_TILE_URL")?.trim() || DEFAULT_MAP_TILE_URL;
}
export function getMapTileAttribution(tileUrl = getMapTileUrl()): string {
return /(?:^|\.)google\.(?:com|cn)\b/i.test(tileUrl) ? "Google" : "";
}
@@ -116,6 +116,14 @@ export interface EightBfTaskProcessReportReceipt {
id: string; id: string;
} }
/**
* 外部任务系统要求 8BF 会商页提交的过程产物携带 eightTask=1,
* 用于与岗位会商等其它入口的产物区分来源。
*/
function processReportBody(input: EightBfTaskProcessReportInput): string {
return JSON.stringify({ ...input, eightTask: 1 });
}
/** /**
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用 * 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。 * `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
@@ -157,7 +165,10 @@ async function externalTaskError(response: Response, fallback: string): Promise<
} }
function findProcessReportId(payload: unknown): string { function findProcessReportId(payload: unknown): string {
if (!payload || typeof payload !== "object") return ""; if (payload == null) return "";
// 外部任务系统的实际返回形如 {code:200, data:307, msg:"success"}:
// `data` 直接就是新增记录的 id(数字 / 字符串),不再包一层对象。
if (typeof payload !== "object") return firstText(payload);
const source = payload as Record<string, unknown>; const source = payload as Record<string, unknown>;
const direct = firstText( const direct = firstText(
source.id, source.id,
@@ -170,9 +181,10 @@ function findProcessReportId(payload: unknown): string {
} }
/** /**
* Create the external task system's process report in its unsubmitted state. * Create the external task system's process report **and submit it in one
* The local gateway stores the returned id so a refresh can later submit the * call**(submitStatus=1). Response shape is `{code:200, data:<id>, msg}` —
* same report instead of creating a duplicate one. * `data` is the new record id. The local gateway stores that id so a refresh
* can still reconcile the same report instead of creating a duplicate.
*/ */
export async function createEightBfTaskProcessReport( export async function createEightBfTaskProcessReport(
input: EightBfTaskProcessReportInput, input: EightBfTaskProcessReportInput,
@@ -180,7 +192,7 @@ export async function createEightBfTaskProcessReport(
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, { const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(input), body: processReportBody(input),
}); });
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败"); if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
const id = findProcessReportId(await response.json().catch(() => null)); const id = findProcessReportId(await response.json().catch(() => null));
@@ -188,7 +200,7 @@ export async function createEightBfTaskProcessReport(
return { id }; return { id };
} }
/** Submit an already-registered process report to the external task system. */ /** Flip an already-registered (submitStatus=0) process report to submitted. Only used to resume legacy ledger rows; the normal path submits directly on create. */
export async function updateEightBfTaskProcessReport( export async function updateEightBfTaskProcessReport(
reportId: string, reportId: string,
input: EightBfTaskProcessReportInput, input: EightBfTaskProcessReportInput,
@@ -198,7 +210,7 @@ export async function updateEightBfTaskProcessReport(
{ {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(input), body: processReportBody(input),
}, },
); );
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败"); if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
@@ -32,6 +32,7 @@ import {
} from "@/position-roundtable/lib/positions"; } from "@/position-roundtable/lib/positions";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts"; import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent"; import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent";
import { updateChain } from "@/roundtable-planning/api/chains";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent"; import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import "@/position-roundtable/styles/intent-understanding.css"; import "@/position-roundtable/styles/intent-understanding.css";
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel"; import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
@@ -46,6 +47,8 @@ export interface EightBfStep1PanelProps extends Step1PanelProps {
chain: BusinessChain | null; chain: BusinessChain | null;
canConfigureChain: boolean; canConfigureChain: boolean;
onConfigureChain: () => void; onConfigureChain: () => void;
/** 业务链被本面板更新(产物 position 等)后回调,页面据此刷新链配置。 */
onChainUpdated?: (chain: BusinessChain) => void;
selectedModel: string; selectedModel: string;
modelOptions: Array<{ label: string; value: string }>; modelOptions: Array<{ label: string; value: string }>;
onSelectedModelChange: (model: string) => void; onSelectedModelChange: (model: string) => void;
@@ -84,6 +87,7 @@ export function EightBfStep1Panel({
chain, chain,
canConfigureChain, canConfigureChain,
onConfigureChain, onConfigureChain,
onChainUpdated,
selectedModel, selectedModel,
modelOptions, modelOptions,
onSelectedModelChange, onSelectedModelChange,
@@ -153,6 +157,7 @@ export function EightBfStep1Panel({
chain={chain} chain={chain}
canConfigureChain={canConfigureChain} canConfigureChain={canConfigureChain}
onConfigureChain={onConfigureChain} onConfigureChain={onConfigureChain}
onChainUpdated={onChainUpdated}
onCollapse={() => setRightPanelOpen(false)} onCollapse={() => setRightPanelOpen(false)}
step2Dialogues={step2Dialogues} step2Dialogues={step2Dialogues}
step2ThreadIds={step2ThreadIds} step2ThreadIds={step2ThreadIds}
@@ -299,6 +304,7 @@ function EightBfAgentWorkspace({
chain, chain,
canConfigureChain, canConfigureChain,
onConfigureChain, onConfigureChain,
onChainUpdated,
onCollapse, onCollapse,
step2Dialogues, step2Dialogues,
step2ThreadIds, step2ThreadIds,
@@ -310,6 +316,7 @@ function EightBfAgentWorkspace({
chain: BusinessChain | null; chain: BusinessChain | null;
canConfigureChain: boolean; canConfigureChain: boolean;
onConfigureChain: () => void; onConfigureChain: () => void;
onChainUpdated?: (chain: BusinessChain) => void;
onCollapse: () => void; onCollapse: () => void;
step2Dialogues: unknown[]; step2Dialogues: unknown[];
step2ThreadIds: ThreadIdMap | null; step2ThreadIds: ThreadIdMap | null;
@@ -448,6 +455,13 @@ function EightBfAgentWorkspace({
jobResponsibilities={selectedSeat?.job_responsibilities ?? ""} jobResponsibilities={selectedSeat?.job_responsibilities ?? ""}
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />} skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
skillsTitle="技能列表" skillsTitle="技能列表"
belowSkillsSlot={chain && selectedSeat ? (
<SeatArtifactPositionEditor
chain={chain}
seat={selectedSeat}
onUpdated={onChainUpdated}
/>
) : undefined}
showCommonQuestions showCommonQuestions
panelTitle="智能体配置" panelTitle="智能体配置"
embedded embedded
@@ -612,3 +626,82 @@ function arrayValue(...values: unknown[]): string[] {
} }
return []; return [];
} }
/**
* 「智能体配置」面板里、技能列表下方的「产物提交 position」编辑器。
*
* 直接读写该席位在业务链上配置的 artifact_position(保存即 PUT 业务链的 seats),
* 用户无需去业务链编辑页找链配 position,也不易配错链。只有业务链 owner / 管理员
* 能保存(后端校验);提交产物时按席位取这里保存的值,留空按 0。
*/
function SeatArtifactPositionEditor({
chain,
seat,
onUpdated,
}: {
chain: BusinessChain;
seat: ChainSeat;
onUpdated?: (chain: BusinessChain) => void;
}) {
const [value, setValue] = useState(seat.artifact_position?.toString() ?? "");
const [saving, setSaving] = useState(false);
// 席位切换 / 外部链数据刷新时,回填链上的当前值。
useEffect(() => {
setValue(seat.artifact_position?.toString() ?? "");
}, [seat.agent_id, seat.artifact_position]);
const trimmed = value.trim();
const parsed = trimmed === "" ? null : Number.parseInt(trimmed, 10);
const nextValue = parsed !== null && Number.isFinite(parsed) ? parsed : null;
const dirty = nextValue !== (seat.artifact_position ?? null);
const handleSave = async () => {
if (saving || !dirty) return;
setSaving(true);
try {
const seats = chain.seats.map((s) =>
s.agent_id === seat.agent_id ? { ...s, artifact_position: nextValue } : s,
);
const updated = await updateChain(chain.id, { seats });
if (!updated) throw new Error("业务链已不存在,可能被删除");
onUpdated?.(updated);
toast.success(`已保存「${seat.name || seat.agent_id}」的产物 position`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存产物 position 失败");
} finally {
setSaving(false);
}
};
return (
<div className="mt-3 rounded-lg border border-[#2b8ffe]/30 bg-[#042b59]/60 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-[#aaceff]">产物提交 position</span>
<div className="flex items-center gap-1.5">
<input
type="number"
inputMode="numeric"
value={value}
placeholder="0"
aria-label="产物提交 position"
onChange={(event) => setValue(event.target.value)}
className="h-7 w-20 rounded-md border border-[#2b8ffe]/40 bg-[#032680]/60 px-2 text-xs font-semibold tabular-nums text-[#e8f2ff] outline-none focus:border-[#2b8ffe] [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<button
type="button"
disabled={!dirty || saving}
onClick={() => void handleSave()}
className="inline-flex h-7 items-center gap-1 rounded-md border border-[#2b8ffe]/50 bg-[#0a3c8f]/70 px-2.5 text-xs font-semibold text-[#e8f2ff] transition hover:bg-[#0a3c8f] disabled:cursor-not-allowed disabled:opacity-50"
>
{saving && <Loader2 className="size-3 animate-spin" />}
保存
</button>
</div>
</div>
<p className="mt-1.5 text-[10px] leading-relaxed text-[#8aa0c4]">
该席位提交过程产物时传给任务系统的 position 值,留空按 0 提交。保存会写入业务链「{chain.title}」,仅链所有者 / 管理员可保存。
</p>
</div>
);
}
@@ -88,14 +88,50 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
() => buildEightBfTaskContext(task, taskDictionaries), () => buildEightBfTaskContext(task, taskDictionaries),
[task, taskDictionaries], [task, taskDictionaries],
); );
// 席位「产物提交 position」有效取值映射(提交产物时按席位智能体取值):
// 1) 绑定链的席位 —— 键收录小写 agent_id + 小写席位名(会话侧席位标识在不同
// 表面可能以 id 或名称出现,双键匹配避免查不到);
// 2) 其它公共链上同一 agent_id 配置的值(历史会话的席位可能来自切换绑定前的
// 旧链,agent_id 跨链唯一指向同一智能体,可安全回退;**名称不跨链匹配**,
// 避免不同链的同名席位互相误配)。
// 绑定链优先(先写入),未配置的席位不进 map(提交时回退默认 0)。
const seatArtifactPositions = useMemo(() => {
const map: Record<string, number> = {};
const put = (key: string, value: number) => {
const normalized = key.trim().toLowerCase();
if (normalized && !(normalized in map)) map[normalized] = value;
};
for (const seat of configuredChain?.seats ?? []) {
if (typeof seat.artifact_position === "number" && Number.isFinite(seat.artifact_position)) {
put(seat.agent_id, seat.artifact_position);
if (seat.name?.trim()) put(seat.name, seat.artifact_position);
}
}
for (const chain of chains) {
if (chain.id === configuredChain?.id) continue;
for (const seat of chain.seats ?? []) {
if (typeof seat.artifact_position === "number" && Number.isFinite(seat.artifact_position)) {
put(seat.agent_id, seat.artifact_position);
}
}
}
return map;
}, [chains, configuredChain]);
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>( const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
() => taskId ? { () => taskId ? {
taskId, taskId,
position: 0, position: 0,
positionFor: (target) => {
const agentId = target.agentId?.trim();
if (!agentId) return null;
return seatArtifactPositions[agentId.toLowerCase()]
?? seatArtifactPositions[target.agentName?.trim().toLowerCase() ?? ""]
?? null;
},
createProcessReport: createEightBfTaskProcessReport, createProcessReport: createEightBfTaskProcessReport,
updateProcessReport: updateEightBfTaskProcessReport, updateProcessReport: updateEightBfTaskProcessReport,
} : undefined, } : undefined,
[taskId], [taskId, seatArtifactPositions],
); );
useEffect(() => { useEffect(() => {
@@ -199,6 +235,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
if (canConfigureChain) setIsSettingsOpen(true); if (canConfigureChain) setIsSettingsOpen(true);
}, [canConfigureChain]); }, [canConfigureChain]);
// 「智能体配置」面板里保存产物 position 后,把最新链数据写回页面状态,
// 让 seatArtifactPositions / 提交产物取值即时生效(无需刷新页面)。
const handleChainUpdated = useCallback((updated: BusinessChain) => {
setChains((prev) => prev.map((chain) => (chain.id === updated.id ? updated : chain)));
}, []);
const toolbarSlot = ( const toolbarSlot = (
<> <>
{canConfigureChain && isSettingsOpen && ( {canConfigureChain && isSettingsOpen && (
@@ -293,6 +335,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
chain={configuredChain} chain={configuredChain}
canConfigureChain={canConfigureChain} canConfigureChain={canConfigureChain}
onConfigureChain={openChainSettings} onConfigureChain={openChainSettings}
onChainUpdated={handleChainUpdated}
/> />
)} )}
renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => ( renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => (
@@ -343,6 +386,8 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
} }
rootClassName="eightbf-roundtable" rootClassName="eightbf-roundtable"
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig} taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
// 会商「智能体配置」抽屉里只读展示各席位在业务链上配置的产物提交 position。
seatArtifactPositions={seatArtifactPositions}
/> />
</div> </div>
); );
@@ -0,0 +1,4 @@
/** Neutral product terminology for system messages; never apply to knowledge content or API identifiers. */
export function userFacingKnowledgeMessage(value: string): string {
return value.replace(/weknora/gi, "知识服务");
}
@@ -48,7 +48,7 @@ const MAX_INTENT_LENGTH = 500;
const MAX_SAMPLE_TEXT_LENGTH = 120000; const MAX_SAMPLE_TEXT_LENGTH = 120000;
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [ const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
{ value: 'general', label: '通用检索', desc: '素材收集专家按配置技能检索' }, { value: 'general', label: '通用检索', desc: '素材收集专家自由选用全部技能检索' },
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库检索接口' }, { value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库检索接口' },
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' }, { value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
]; ];
@@ -152,7 +152,7 @@ interface MaterialConfirmCardProps {
type MaterialSource = NonNullable<UserInterventionPayload['materialSource']>; type MaterialSource = NonNullable<UserInterventionPayload['materialSource']>;
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [ const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
{ value: 'general', label: '通用检索', desc: '素材收集专家按配置技能检索' }, { value: 'general', label: '通用检索', desc: '素材收集专家自由选用全部技能检索' },
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库接口' }, { value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库接口' },
{ value: 'notebook', label: '我的空间', desc: '检索当前笔记空间' }, { value: 'notebook', label: '我的空间', desc: '检索当前笔记空间' },
]; ];
@@ -218,7 +218,7 @@ function MaterialConfirmCard({
async function handleReSearch() { async function handleReSearch() {
setSubmitting(true); setSubmitting(true);
// 带输入 = 后端在素材收集专家配置的技能中匹配检索、新素材追加到列表; // 带输入 = 后端从全部启用技能中匹配检索、新素材追加到列表;
// 无输入 = 重新生成检索词、覆盖式重检 // 无输入 = 重新生成检索词、覆盖式重检
await onSubmit({ await onSubmit({
action: 're_search', action: 're_search',
@@ -257,7 +257,7 @@ export interface UserInterventionPayload {
approvedMaterialIds?: string[]; approvedMaterialIds?: string[];
/** @deprecated 旧版多关键词追加。新版统一用 userQuery 单输入框 */ /** @deprecated 旧版多关键词追加。新版统一用 userQuery 单输入框 */
extraKeywords?: string[]; extraKeywords?: string[];
/** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端在素材收集专家配置的技能中匹配最相关的技能检索,新素材**追加**到列表。 */ /** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端从全部启用技能中匹配最相关的技能检索,新素材**追加**到列表。 */
userQuery?: string; userQuery?: string;
/** PAUSE-1 素材确认:本次补充/重新检索使用的检索方式。 */ /** PAUSE-1 素材确认:本次补充/重新检索使用的检索方式。 */
materialSource?: "general" | "knowledge_base" | "notebook"; materialSource?: "general" | "knowledge_base" | "notebook";
@@ -298,7 +298,7 @@ export interface AIWritingRequest {
writingMode: "strict" | "loose"; writingMode: "strict" | "loose";
/** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认 true。 */ /** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认 true。 */
addReferences?: boolean; addReferences?: boolean;
/** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/ /** 检索类型:general=通用检索(素材收集专家从全部启用技能自由挑选检索,内置知识库检索保底)/
* knowledge_base=知识库检索(直连内网知识库 ES 接口,不走技能编排)/ notebook=我的空间 */ * knowledge_base=知识库检索(直连内网知识库 ES 接口,不走技能编排)/ notebook=我的空间 */
materialSource: "general" | "knowledge_base" | "notebook"; materialSource: "general" | "knowledge_base" | "notebook";
/** 不收集素材,直接用通用知识写作:勾选后跳过整个素材检索流程(含素材确认暂停点), /** 不收集素材,直接用通用知识写作:勾选后跳过整个素材检索流程(含素材确认暂停点),
+4 -1
View File
@@ -373,6 +373,7 @@ export default function AgentChatPage({
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>( const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>(
() => pendingAssistantKnowledgeSelection.current ?? [], () => pendingAssistantKnowledgeSelection.current ?? [],
); );
const [knowledgeSuggestedQuestions, setKnowledgeSuggestedQuestions] = useState<string[]>([]);
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState( const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(
() => pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null, () => pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null,
); );
@@ -1029,7 +1030,7 @@ export default function AgentChatPage({
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。 // 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
router.push( router.push(
isEmbedded isEmbedded
? `/page/workspace/chats/new?agent=${agent_id}` ? `${routeBase}/chats/new?agent=${agent_id}`
: `${agentChatsBase}/new`, : `${agentChatsBase}/new`,
); );
}} }}
@@ -1079,6 +1080,7 @@ export default function AgentChatPage({
className="w-full" className="w-full"
isNewThread isNewThread
hideAdminRecommendedQuestions hideAdminRecommendedQuestions
knowledgeSuggestedQuestions={knowledgeSuggestedQuestions}
threadId={threadId} threadId={threadId}
assistantId={agent_id} assistantId={agent_id}
autoFocus autoFocus
@@ -1099,6 +1101,7 @@ export default function AgentChatPage({
assistantValue={selectedAssistantKnowledgeBaseIds} assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection} onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection} onAssistantChange={changeAssistantKnowledgeSelection}
onSuggestedQuestionsChange={setKnowledgeSuggestedQuestions}
autoSelectAll={ autoSelectAll={
isNewThread && isNewThread &&
pendingKnowledgeSelection.current === null && pendingKnowledgeSelection.current === null &&
@@ -0,0 +1,831 @@
import { createContext, useContext, useEffect, useState } from "react";
import {
Link,
Navigate,
Route,
Routes,
useLocation,
useMatch,
useNavigate,
useParams,
} from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
Bot,
FolderOpen,
Loader2,
LockKeyhole,
Plus,
Settings,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { useAgentSelector } from "@/core/agents/hooks";
import { WorkflowConfigButton } from "./deep-research/WorkflowConfigDialog";
import {
createAnalysisProject,
createProjectThread,
DEFAULT_STRATEGY_LENSES,
PROJECT_MODES,
PROJECT_OUTLINE,
projectKeys,
updateAnalysisProject,
useAnalysisProject,
useAnalysisProjects,
useProjectThreadAccess,
useProjectThreads,
type AnalysisProject,
type ProjectFields,
type ProjectMode,
type ProjectReportConfig,
type ProjectStrategyConfig,
} from "@/core/analysis-projects/api";
import { cn } from "@/lib/utils";
import { ProjectConversation } from "./analysis-projects/ProjectConversation";
const ProjectNavigation = createContext({
basePath: "/page/workspace/projects",
listPath: "/page/workspace/projects",
mode: undefined as ProjectMode | undefined,
showSidebarTrigger: true,
});
const defaultReport: ProjectReportConfig = {
custom_outline: PROJECT_OUTLINE.map((s, i) => `## ${i + 1}. ${s}`).join("\n"),
structure_mode: "fixed",
retrieval_directions: [],
retrieval_skills: [],
};
const defaultStrategy: ProjectStrategyConfig = {
lenses: [...DEFAULT_STRATEGY_LENSES],
};
function ProjectSidebarTrigger() {
const { showSidebarTrigger } = useContext(ProjectNavigation);
return showSidebarTrigger ? <SidebarTrigger aria-label="展开功能菜单" /> : null;
}
function ErrorState({ error, retry }: { error: unknown; retry?: () => void }) {
return (
<div
role="alert"
className="m-4 rounded-lg border border-destructive/30 p-4 text-sm"
>
<p>{error instanceof Error ? error.message : "读取失败,请重试"}</p>
{retry && (
<Button variant="outline" className="mt-3" onClick={retry}>
重试
</Button>
)}
</div>
);
}
function Loading() {
return (
<div className="flex flex-1 items-center justify-center p-10">
<Loader2 className="mr-2 size-4 animate-spin" />
加载中…
</div>
);
}
function AgentSelect({
value,
onChange,
label = "选择智能体",
}: {
value: string | null;
onChange: (id: string | null) => void;
label?: string;
}) {
const { agents, isLoading, error } = useAgentSelector();
return (
<label className="flex min-w-0 flex-col gap-2 text-sm">
<span>{label}</span>
<select
aria-label={label}
className="h-9 max-w-full rounded-md border bg-background px-3 text-sm"
value={value ?? ""}
onChange={(e) => onChange(e.target.value || null)}
>
<option value="">通用助手</option>
{value && !agents.some((a) => a.id === value) && (
<option value={value}>所选智能体(加载中或已不可用)</option>
)}
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
{isLoading && (
<span className="text-xs text-muted-foreground">加载智能体…</span>
)}
{error && (
<span role="alert" className="text-xs text-destructive">
智能体列表加载失败,请刷新重试
</span>
)}
</label>
);
}
function ReportConfiguration({
value,
onChange,
}: {
value: ProjectReportConfig;
onChange: (next: ProjectReportConfig) => void;
}) {
return (
<section className="space-y-4 rounded-xl border p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-sm font-medium">工作流配置 · 报告结构</h2>
<p className="mt-1 text-xs text-muted-foreground">
选择结构后自动填充;结构库支持新增、启停、编辑和删除。
</p>
</div>
<WorkflowConfigButton
inlineCreate
appliedContent={value.custom_outline}
onApply={(
content,
structureMode,
retrievalDirections,
retrievalSkills,
) =>
onChange({
custom_outline: content,
structure_mode: structureMode,
retrieval_directions: retrievalDirections,
retrieval_skills: retrievalSkills,
})
}
/>
</div>
<label className="block space-y-2 text-sm">
<span>报告结构配置</span>
<Textarea
aria-label="报告结构配置"
rows={8}
value={value.custom_outline}
onChange={(e) =>
onChange({ ...value, custom_outline: e.target.value })
}
placeholder="选择报告结构,或输入 Markdown 章节大纲"
/>
</label>
<label className="flex items-center gap-3 text-sm">
<span>结构约束</span>
<select
aria-label="结构约束"
className="h-9 rounded-md border bg-background px-3"
value={value.structure_mode}
onChange={(e) =>
onChange({
...value,
structure_mode: e.target.value as "fixed" | "adaptive",
})
}
>
<option value="fixed">固定大纲</option>
<option value="adaptive">弹性大纲</option>
</select>
</label>
<p className="text-xs text-muted-foreground">
检索方向:
{value.retrieval_directions.join("、") || "由智能体根据问题确定"}
;检索技能:{value.retrieval_skills.join("、") || "沿用智能体配置"}
</p>
<p className="text-xs text-muted-foreground">
项目保存当前结构副本。结构库变更后可重新选择应用,也可直接修改本项目大纲;保存后用于下一轮方案生成。
</p>
</section>
);
}
function StrategyConfiguration({
value,
onChange,
}: {
value: ProjectStrategyConfig;
onChange: (next: ProjectStrategyConfig) => void;
}) {
const [draft, setDraft] = useState("");
const toggle = (lens: string) => {
const selected = value.lenses.includes(lens);
if (selected && value.lenses.length === 1) return;
onChange({
lenses: selected
? value.lenses.filter((item) => item !== lens)
: [...value.lenses, lens].slice(0, 10),
});
};
const add = () => {
const lens = draft.trim().replace(/视角$/, "") + "视角";
if (!draft.trim() || value.lenses.includes(lens) || value.lenses.length >= 10)
return;
onChange({ lenses: [...value.lenses, lens] });
setDraft("");
};
return (
<section className="space-y-4 rounded-xl border p-4">
<div>
<h2 className="text-sm font-medium">Strategy Lens · 默认分析视角</h2>
<p className="mt-1 text-xs text-muted-foreground">
每个视角独立形成判断与候选策略,再进入策略辩论和综合决策。至少保留一个,最多十个。
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{DEFAULT_STRATEGY_LENSES.map((lens) => (
<label
key={lens}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm",
value.lenses.includes(lens) && "border-primary bg-primary/5",
)}
>
<input
type="checkbox"
checked={value.lenses.includes(lens)}
onChange={() => toggle(lens)}
className="accent-primary"
/>
{lens}
</label>
))}
</div>
<div className="flex flex-wrap gap-2">
{value.lenses
.filter(
(lens) =>
!DEFAULT_STRATEGY_LENSES.includes(
lens as (typeof DEFAULT_STRATEGY_LENSES)[number],
),
)
.map((lens) => (
<Badge key={lens} variant="secondary" className="gap-1 py-1">
{lens}
<button
type="button"
aria-label={`删除视角 ${lens}`}
onClick={() => toggle(lens)}
>
<X className="size-3" />
</button>
</Badge>
))}
</div>
<div className="flex gap-2">
<Input
aria-label="自定义分析视角"
maxLength={38}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
add();
}
}}
placeholder="例如:用户体验、供应链、敌方"
/>
<Button type="button" variant="outline" onClick={add}>
添加视角
</Button>
</div>
</section>
);
}
function ProjectForm({
project,
onCancel,
}: {
project?: AnalysisProject;
onCancel: () => void;
}) {
const { basePath: BASE, mode: initialMode } = useContext(ProjectNavigation);
const navigate = useNavigate(),
queryClient = useQueryClient();
const [name, setName] = useState(project?.name ?? ""),
[background, setBackground] = useState(project?.background ?? "");
const [mode, setMode] = useState<ProjectMode>(project?.mode ?? initialMode ?? "qa");
const [agentId, setAgentId] = useState<string | null>(
project?.agent_id ?? null,
);
const [report, setReport] = useState<ProjectReportConfig>({
...defaultReport,
...project?.report_config,
custom_outline:
project?.report_config?.custom_outline || defaultReport.custom_outline,
});
const [strategy, setStrategy] = useState<ProjectStrategyConfig>({
...defaultStrategy,
...project?.strategy_config,
lenses: project?.strategy_config?.lenses?.length
? project.strategy_config.lenses
: defaultStrategy.lenses,
});
const save = useMutation({
mutationFn: () => {
const fields: ProjectFields = {
name: name.trim(),
background: background.trim(),
agent_id: agentId,
report_config:
mode === "solution"
? report
: { ...defaultReport, custom_outline: "" },
strategy_config: strategy,
knowledge_base_ids: project?.knowledge_base_ids ?? [],
assistant_knowledge_base_ids: project?.assistant_knowledge_base_ids ?? [],
};
return project
? updateAnalysisProject(project.id, fields)
: createAnalysisProject({ ...fields, mode });
},
onSuccess: async (result) => {
queryClient.setQueryData([...projectKeys(), result.id], result);
await queryClient.invalidateQueries({ queryKey: projectKeys() });
toast.success(project ? "项目配置已保存" : "项目已创建");
navigate(`${BASE}/${result.id}`);
},
});
return (
<form
className="mx-auto w-full max-w-4xl space-y-6 p-5 md:p-7"
onSubmit={(e) => {
e.preventDefault();
if (!save.isPending && name.trim()) save.mutate();
}}
>
<div className="flex items-center gap-3">
<ProjectSidebarTrigger />
<Button type="button" variant="ghost" onClick={onCancel}>
<ArrowLeft className="size-4" />
返回
</Button>
<h1 className="text-xl font-semibold">
{project ? "项目设置" : "新建项目"}
</h1>
</div>
<label className="block space-y-2 text-sm">
<span>项目名称</span>
<Input
required
maxLength={160}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如:区域市场背景研究"
/>
</label>
<fieldset className="space-y-3">
<legend className="text-sm font-medium">项目问答模式</legend>
{project || initialMode ? (
<div className="flex items-center gap-3 rounded-lg border p-4">
<Badge variant="secondary">{PROJECT_MODES[mode].name}</Badge>
<span className="text-xs text-muted-foreground">
创建时已确定,不可更改
</span>
</div>
) : (
<>
<div className="grid gap-3 lg:grid-cols-3">
{(Object.keys(PROJECT_MODES) as ProjectMode[]).map((value) => (
<label
key={value}
className={cn(
"flex cursor-pointer gap-3 rounded-xl border p-4",
mode === value && "border-primary bg-primary/5",
)}
>
<input
type="radio"
name="project-mode"
checked={mode === value}
onChange={() => setMode(value)}
className="mt-1 accent-primary"
/>
<span>
<span className="text-sm font-medium">
{PROJECT_MODES[value].name}
</span>
<span className="mt-2 block text-xs leading-5 text-muted-foreground">
{PROJECT_MODES[value].description}
</span>
</span>
</label>
))}
</div>
<p className="text-xs text-muted-foreground">
模式创建后不可更改,项目内所有对话使用同一模式。
</p>
</>
)}
</fieldset>
<section className="space-y-2">
<AgentSelect
value={agentId}
onChange={setAgentId}
label="项目默认智能体"
/>
<p className="text-xs text-muted-foreground">
沿用智能体的能力、技能和知识配置。新建对话时可以选择其他智能体。
</p>
</section>
<label className="block space-y-2 text-sm">
<span>项目说明</span>
<Textarea
value={background}
maxLength={12000}
onChange={(e) => setBackground(e.target.value)}
rows={3}
placeholder="填写项目目标、业务背景与约束…"
/>
</label>
{mode === "solution" && (
<ReportConfiguration value={report} onChange={setReport} />
)}
{mode === "strategy" && (
<StrategyConfiguration value={strategy} onChange={setStrategy} />
)}
{save.error && <ErrorState error={save.error} />}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={onCancel}>
取消
</Button>
<Button
type="submit"
disabled={
save.isPending ||
!name.trim() ||
(mode === "solution" && !report.custom_outline.trim())
}
>
{save.isPending && <Loader2 className="size-4 animate-spin" />}
{project ? "保存设置" : "创建项目"}
</Button>
</div>
</form>
);
}
function ProjectList() {
const { basePath: BASE } = useContext(ProjectNavigation);
const query = useAnalysisProjects();
return (
<div className="h-full overflow-y-auto p-5 md:p-7">
<div className="mx-auto max-w-6xl">
<header className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<ProjectSidebarTrigger />
<div>
<h1 className="text-xl font-semibold">项目空间</h1>
<p className="mt-1 text-sm text-muted-foreground">
按项目归类对话,使用熟悉的问答工作区。
</p>
</div>
</div>
<Button asChild>
<Link to={`${BASE}/new`}>
<Plus className="size-4" />
新建项目
</Link>
</Button>
</header>
{query.isLoading ? (
<Loading />
) : query.error ? (
<ErrorState error={query.error} retry={() => void query.refetch()} />
) : (
<div className="mt-6 grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
{query.data?.projects.map((project) => (
<article
key={project.id}
className="rounded-xl border bg-card p-5"
>
<Badge variant="secondary">
{PROJECT_MODES[project.mode].name}
</Badge>
<h2 className="mt-4 text-lg font-medium">{project.name}</h2>
<p className="mt-2 line-clamp-2 min-h-10 text-sm text-muted-foreground">
{project.background ||
PROJECT_MODES[project.mode].description}
</p>
<Button asChild variant="outline" className="mt-4 w-full">
<Link to={`${BASE}/${project.id}`}>进入项目</Link>
</Button>
</article>
))}
</div>
)}
{!query.isLoading && !query.error && !query.data?.projects.length && (
<div className="mt-8 rounded-xl border border-dashed p-10 text-center text-muted-foreground">
<FolderOpen className="mx-auto mb-3 size-8" />
创建项目,将相关问题和分析放在一起。
</div>
)}
</div>
</div>
);
}
function WorkspaceContent({ project }: { project: AnalysisProject }) {
const { basePath: BASE, listPath } = useContext(ProjectNavigation);
const location = useLocation(),
navigate = useNavigate(),
queryClient = useQueryClient();
const threadsQuery = useProjectThreads(project.id),
match = useMatch(`${BASE}/:projectId/chats/:thread_id`);
const threadId = match?.params.thread_id,
access = useProjectThreadAccess(project.id, threadId);
const [agentId, setAgentId] = useState<string | null>(project.agent_id);
useEffect(() => { setAgentId(project.agent_id); }, [project.agent_id]);
const [lensDraft, setLensDraft] = useState("");
const [addingLens, setAddingLens] = useState(false);
const strategyLenses = project.strategy_config?.lenses?.length
? project.strategy_config.lenses
: [...DEFAULT_STRATEGY_LENSES];
const saveLenses = useMutation({
mutationFn: (lenses: string[]) =>
updateAnalysisProject(project.id, {
name: project.name,
background: project.background,
agent_id: project.agent_id,
report_config: project.report_config,
strategy_config: { lenses },
knowledge_base_ids: project?.knowledge_base_ids ?? [],
assistant_knowledge_base_ids: project?.assistant_knowledge_base_ids ?? [],
}),
onSuccess: (updated) => {
queryClient.setQueryData([...projectKeys(), project.id], updated);
void queryClient.invalidateQueries({ queryKey: projectKeys() });
},
onError: (error) => toast.error(error.message),
});
const addLens = () => {
const raw = lensDraft.trim();
const lens = raw ? `${raw.replace(/视角$/, "")}视角` : "";
if (!lens || strategyLenses.includes(lens) || strategyLenses.length >= 10)
return;
saveLenses.mutate([...strategyLenses, lens]);
setLensDraft("");
setAddingLens(false);
};
const create = useMutation({
mutationFn: () => createProjectThread(project.id, agentId),
onSuccess: async (thread) => {
await queryClient.invalidateQueries({
queryKey: [...projectKeys(), project.id, "threads"],
});
await queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
navigate(`${BASE}/${project.id}/chats/${thread.thread_id}`);
},
onError: (e) => toast.error(e.message),
});
const threads = threadsQuery.data?.threads ?? [];
useEffect(() => {
if (threadsQuery.data) {
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
}
}, [queryClient, threadsQuery.data]);
if (location.pathname.endsWith("/settings"))
return (
<div className="h-full overflow-y-auto">
<ProjectForm
key={project.updated_at}
project={project}
onCancel={() => navigate(`${BASE}/${project.id}`)}
/>
</div>
);
return (
<div className="flex h-full min-h-0 flex-col">
<header className="shrink-0 border-b bg-background px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<ProjectSidebarTrigger />
<Link to={listPath} className="text-sm text-muted-foreground">
{listPath.includes("?type=qa") ? "我的项目" : "项目空间"}
</Link>
<span className="text-muted-foreground">/</span>
<h1 className="min-w-0 flex-1 truncate font-semibold">
{project.name}
</h1>
<Badge variant="secondary">{PROJECT_MODES[project.mode].name}</Badge>
<LockKeyhole
className="size-3 text-muted-foreground"
aria-label="项目模式已锁定"
/>
<Button size="sm" variant="ghost" asChild>
<Link to={`${BASE}/${project.id}/settings`}>
<Settings className="size-4" />
{project.mode === "solution"
? "报告结构与设置"
: project.mode === "strategy"
? "分析视角与设置"
: "项目设置"}
</Link>
</Button>
</div>
<div className="mt-3 flex flex-wrap items-end gap-3">
<div className="w-48">
<AgentSelect
value={agentId}
onChange={setAgentId}
label="新对话使用的智能体"
/>
</div>
<Button
size="sm"
disabled={create.isPending}
onClick={() => create.mutate()}
>
<Plus className="size-4" />
新建对话
</Button>
<label className="ml-auto flex max-w-full items-center gap-2 text-xs text-muted-foreground">
<span>项目对话</span>
<select
aria-label="项目对话"
className="h-9 max-w-56 rounded-md border bg-background px-2 text-sm"
value={threadId ?? ""}
onChange={(e) =>
navigate(
e.target.value
? `${BASE}/${project.id}/chats/${e.target.value}`
: `${BASE}/${project.id}`,
)
}
>
<option value="">选择历史对话</option>
{threads.map((t) => (
<option key={t.thread_id} value={t.thread_id}>
{t.title || "新对话"}
</option>
))}
</select>
</label>
</div>
{project.mode === "strategy" && (
<div className="mt-3 flex flex-wrap items-center gap-2 border-t pt-3">
<span className="text-xs font-medium text-muted-foreground">
分析视角
</span>
{strategyLenses.map((lens) => (
<Badge key={lens} variant="outline" className="gap-1 bg-background">
{lens}
<button
type="button"
aria-label={`删除视角 ${lens}`}
disabled={strategyLenses.length === 1 || saveLenses.isPending}
onClick={() =>
saveLenses.mutate(
strategyLenses.filter((item) => item !== lens),
)
}
>
<X className="size-3" />
</button>
</Badge>
))}
{addingLens ? (
<div className="flex items-center gap-1">
<Input
autoFocus
aria-label="添加分析视角"
className="h-7 w-40 text-xs"
value={lensDraft}
maxLength={38}
placeholder="如:就业安全"
onChange={(event) => setLensDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") addLens();
if (event.key === "Escape") setAddingLens(false);
}}
/>
<Button type="button" size="sm" className="h-7" onClick={addLens}>
确定
</Button>
</div>
) : (
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-xs"
disabled={strategyLenses.length >= 10}
onClick={() => setAddingLens(true)}
>
<Plus className="size-3" />
添加视角
</Button>
)}
</div>
)}
{threadsQuery.error && (
<ErrorState
error={threadsQuery.error}
retry={() => void threadsQuery.refetch()}
/>
)}
</header>
{threadId ? (
access.isLoading ? (
<Loading />
) : access.error ? (
<ErrorState error={access.error} retry={() => void access.refetch()} />
) : (
<ProjectConversation
key={threadId}
project={project}
threadId={threadId}
agentId={
typeof access.data?.metadata?.agent_id === "string"
? access.data.metadata.agent_id
: null
}
/>
)
) : (
<div className="min-h-0 flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-3xl py-8">
<Bot className="mb-4 size-8 text-primary" />
<h2 className="text-xl font-medium">在项目中继续问答</h2>
<p className="mt-3 text-sm text-muted-foreground">
{project.background || PROJECT_MODES[project.mode].description}
<br />选择智能体后新建对话,或从项目对话列表继续已有问答。
</p>
{project.mode === "strategy" && (
<div className="mt-6 grid gap-3 md:grid-cols-3">
{[
"01 意图理解",
"02 多视角策略分析与辩论",
"03 完整策略方案与执行路线",
].map((s) => (
<div className="rounded-xl border p-5 text-sm" key={s}>
{s}
</div>
))}
</div>
)}
<div className="mt-6 divide-y rounded-xl border">
{threads.map((t) => (
<Link
key={t.thread_id}
to={`${BASE}/${project.id}/chats/${t.thread_id}`}
className="block px-4 py-3 text-sm hover:bg-muted/40"
>
{t.title || "新对话"}
</Link>
))}
</div>
</div>
</div>
)}
</div>
);
}
function ProjectWorkspace() {
const { projectId = "" } = useParams();
const query = useAnalysisProject(projectId);
return query.isLoading ? (
<Loading />
) : query.error || !query.data ? (
<ErrorState error={query.error} retry={() => void query.refetch()} />
) : (
<WorkspaceContent key={query.data.id} project={query.data} />
);
}
function NewProject() {
const { listPath } = useContext(ProjectNavigation);
const navigate = useNavigate();
return (
<div className="h-full overflow-y-auto">
<ProjectForm onCancel={() => navigate(listPath)} />
</div>
);
}
export default function AnalysisProjectsPage({
basePath = "/page/workspace/projects",
listPath = basePath,
mode,
showSidebarTrigger = true,
}: { basePath?: string; listPath?: string; mode?: ProjectMode; showSidebarTrigger?: boolean } = {}) {
const { userId } = useLiveAccount();
return (
<ProjectNavigation.Provider value={{ basePath, listPath, mode, showSidebarTrigger }}>
<Routes key={userId ?? "anonymous"}>
<Route index element={mode ? <Navigate to={listPath} replace /> : <ProjectList />} />
<Route path="new" element={<NewProject />} />
<Route path=":projectId/*" element={<ProjectWorkspace />} />
</Routes>
</ProjectNavigation.Provider>
);
}
+225 -46
View File
@@ -1,3 +1,4 @@
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
import { import {
ArrowLeft, ArrowLeft,
BookOpenText, BookOpenText,
@@ -36,8 +37,10 @@ import {
useAssistantKnowledgeActions, useAssistantKnowledgeActions,
useAssistantKnowledgeBases, useAssistantKnowledgeBases,
useAssistantKnowledgeSources, useAssistantKnowledgeSources,
useAssistantWikiPage,
useAssistantWikiPages, useAssistantWikiPages,
type AssistantKnowledgeBase, type AssistantKnowledgeBase,
type AssistantWikiPage,
} from "@/strategy-components/api/assistant-knowledge"; } from "@/strategy-components/api/assistant-knowledge";
import { import {
WikiLibraryReader, WikiLibraryReader,
@@ -45,48 +48,69 @@ import {
} from "@/strategy-components/components/resource-management/llmwiki/WikiLibraryReader"; } from "@/strategy-components/components/resource-management/llmwiki/WikiLibraryReader";
import { WikiPackageTransfers } from "@/strategy-components/components/resource-management/llmwiki/WikiPackageTransfers"; import { WikiPackageTransfers } from "@/strategy-components/components/resource-management/llmwiki/WikiPackageTransfers";
function toLibraryPage(page: AssistantWikiPage): LibraryWikiPage {
return {
...page,
content: page.content_markdown || "",
status: "published",
aliases: page.aliases || [],
source_refs: page.source_refs || [],
in_links: page.in_links || [],
out_links: page.out_links || [],
version: 1,
};
}
export default function AssistantKnowledgePage() { export default function AssistantKnowledgePage() {
const isAdmin = getStoredAuth()?.system_role === "admin"; const isAdmin = getStoredAuth()?.system_role === "admin";
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const baseId = params.get("base_id"); const baseId = params.get("base_id");
const slug = params.get("wikiSlug"); const slug = params.get("wikiSlug");
const [tab, setTab] = useState("wiki");
const bases = useAssistantKnowledgeBases(); const bases = useAssistantKnowledgeBases();
const actions = useAssistantKnowledgeActions(); const actions = useAssistantKnowledgeActions();
const pages = useAssistantWikiPages("", baseId); const pages = useAssistantWikiPages("", baseId, {
const sources = useAssistantKnowledgeSources(baseId); includeContent: false,
const jobs = useAssistantImportJobs(baseId); enabled: !!baseId,
});
const sources = useAssistantKnowledgeSources(baseId, !!baseId);
const jobs = useAssistantImportJobs(baseId, !!baseId);
const client = useQueryClient(); const client = useQueryClient();
const [tab, setTab] = useState("wiki");
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editingBase, setEditingBase] = const [editingBase, setEditingBase] =
useState<AssistantKnowledgeBase | null>(null); useState<AssistantKnowledgeBase | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const searchSequence = useRef(0);
const [searchResult, setSearchResult] = useState<Awaited< const [searchResult, setSearchResult] = useState<Awaited<
ReturnType<typeof searchAssistantWiki> ReturnType<typeof searchAssistantWiki>
> | null>(null); > | null>(null);
const upload = useRef<HTMLInputElement>(null); const upload = useRef<HTMLInputElement>(null);
const documentUpload = useRef<HTMLInputElement>(null);
const base = bases.data?.find((b) => b.id === baseId); const base = bases.data?.find((b) => b.id === baseId);
const libraryPages = useMemo<LibraryWikiPage[]>( const libraryPages = useMemo<LibraryWikiPage[]>(
() => () => (pages.data || []).map(toLibraryPage),
(pages.data || []).map((p) => ({
...p,
content: p.content_markdown,
status: "published",
aliases: p.aliases || [],
source_refs: p.source_refs || [],
in_links: p.in_links || [],
out_links: p.out_links || [],
version: 1,
})),
[pages.data], [pages.data],
); );
const active = const defaultDirectoryPage =
libraryPages.find((p) => p.slug === slug) ||
libraryPages.find((p) => p.page_type === "index") || libraryPages.find((p) => p.page_type === "index") ||
libraryPages[0] || libraryPages[0] ||
null; null;
const activeDirectoryPage = slug
? libraryPages.find((p) => p.slug === slug) || null
: defaultDirectoryPage;
const activeSlug = slug || defaultDirectoryPage?.slug || null;
const activePage = useAssistantWikiPage(
activeSlug,
baseId,
!!activeSlug,
);
const active = useMemo(
() =>
activePage.data ? toLibraryPage(activePage.data) : activeDirectoryPage,
[activeDirectoryPage, activePage.data],
);
const navigatePage = (nextSlug: string) => { const navigatePage = (nextSlug: string) => {
setParams({ base_id: baseId!, wikiSlug: nextSlug }); setParams({ base_id: baseId!, wikiSlug: nextSlug });
setTab("wiki"); setTab("wiki");
@@ -95,17 +119,38 @@ export default function AssistantKnowledgePage() {
.filter((j) => j.status === "completed") .filter((j) => j.status === "completed")
.map((j) => j.id) .map((j) => j.id)
.join(","); .join(",");
const packageImportActive = (jobs.data || []).some(
(job) =>
job.trigger === "manual_package_upload" &&
["queued", "running"].includes(job.status),
);
const packageImportBusy =
actions.uploadWeKnoraPackage.isPending || packageImportActive;
const finishedJobsSnapshot = useRef({ baseId: null as string | null, ids: "" });
useEffect(() => { useEffect(() => {
if (!baseId || !jobs.data) return;
if (finishedJobsSnapshot.current.baseId !== baseId) {
finishedJobsSnapshot.current = { baseId, ids: finishedJobs };
return;
}
if (finishedJobsSnapshot.current.ids === finishedJobs) return;
finishedJobsSnapshot.current.ids = finishedJobs;
void client.invalidateQueries({ void client.invalidateQueries({
queryKey: ["assistant-knowledge", "pages"], queryKey: ["assistant-knowledge", "pages"],
}); });
void client.invalidateQueries({
queryKey: ["assistant-knowledge", "page"],
});
void client.invalidateQueries({ void client.invalidateQueries({
queryKey: ["assistant-knowledge", "bases"], queryKey: ["assistant-knowledge", "bases"],
}); });
}, [finishedJobs, client]); }, [baseId, finishedJobs, jobs.data, client]);
useEffect(() => { useEffect(() => {
++searchSequence.current;
setSearching(false);
setSearchResult(null); setSearchResult(null);
setQuery(""); setQuery("");
return () => { ++searchSequence.current; };
}, [baseId]); }, [baseId]);
const create = async () => { const create = async () => {
if (!name.trim()) return; if (!name.trim()) return;
@@ -120,13 +165,16 @@ export default function AssistantKnowledgePage() {
}; };
const search = async () => { const search = async () => {
if (!query.trim() || !baseId) return; if (!query.trim() || !baseId) return;
const sequence = ++searchSequence.current;
setSearching(true); setSearching(true);
setSearchResult(null);
try { try {
setSearchResult(await searchAssistantWiki(query, baseId)); const result = await searchAssistantWiki(query.trim(), baseId);
if (sequence === searchSequence.current) setSearchResult(result);
} catch (e) { } catch (e) {
toast.error((e as Error).message); if (sequence === searchSequence.current) toast.error((e as Error).message);
} finally { } finally {
setSearching(false); if (sequence === searchSequence.current) setSearching(false);
} }
}; };
const refresh = () => const refresh = () =>
@@ -159,8 +207,7 @@ export default function AssistantKnowledgePage() {
<div> <div>
<h1 className="text-2xl font-semibold">助手知识库</h1> <h1 className="text-2xl font-semibold">助手知识库</h1>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
浏览分类 选择或新建知识库,上传文档后自动生成分类 Wiki、关联知识和向量索引。可独立使用,也可导入 普通知识库数据包;各库知识汇入知识梳理总库。
Wiki、关联知识与来源。普通知识库向量化完成后自动沉淀到知识梳理。
</p> </p>
</div> </div>
{isAdmin && ( {isAdmin && (
@@ -253,6 +300,17 @@ export default function AssistantKnowledgePage() {
初始化知识梳理总库 初始化知识梳理总库
</Button> </Button>
)} )}
{!bases.isLoading && !bases.error && !bases.data?.length && !isAdmin && (
<div className="rounded-xl border border-dashed p-8 text-center">
<p className="font-medium">暂无助手知识库</p>
<p className="mt-2 text-sm text-muted-foreground">
请联系管理员创建知识库并导入内容,完成后即可在这里查看和检索。
</p>
<Button variant="outline" className="mt-4" onClick={() => void bases.refetch()}>
<RefreshCw className="mr-2 size-4" />刷新
</Button>
</div>
)}
</main> </main>
) : ( ) : (
<> <>
@@ -306,33 +364,63 @@ export default function AssistantKnowledgePage() {
删除 删除
</Button> </Button>
)} )}
{isAdmin && (
<Button size="sm" disabled={actions.uploadDocument.isPending} onClick={() => documentUpload.current?.click()}>
{actions.uploadDocument.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Upload className="mr-2 size-4" />}
上传文件生成 Wiki
</Button>
)}
{isAdmin && ( {isAdmin && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
disabled={actions.uploadWeKnoraPackage.isPending} disabled={packageImportBusy}
onClick={() => upload.current?.click()} onClick={() => upload.current?.click()}
> >
<Upload className="mr-2 size-4" /> {packageImportBusy ? (
导入数据包 <Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Upload className="mr-2 size-4" />
)}
{packageImportBusy
? "数据包导入中"
: "导入 普通知识库数据包"}
</Button> </Button>
)} )}
</div> </div>
<input ref={documentUpload} type="file" multiple accept=".md,.markdown,.txt,.csv,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.odt,.ods,.odp" className="hidden"
onChange={async (event) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
setTab("transfer");
for (const file of files) {
if (file.size > 100 * 1024 * 1024) { toast.error(`${file.name} 超过 100 MB,请拆分后上传`); continue; }
try {
await actions.uploadDocument.mutateAsync({ file, assistantBaseId: baseId });
toast.success(`${file.name} 已上传,后台正在解析、生成 Wiki 并向量化`);
} catch (error) { toast.error(`${file.name}:${(error as Error).message}`); }
}
}} />
<input <input
ref={upload} ref={upload}
type="file" type="file"
accept=".jsonl,.ndjson" accept=".jsonl,.ndjson"
disabled={packageImportBusy}
className="hidden" className="hidden"
onChange={async (e) => { onChange={async (e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
e.target.value = ""; e.target.value = "";
if (!file) return; if (!file) return;
setTab("transfer");
if (packageImportBusy) return;
try { try {
await actions.uploadWeKnoraPackage.mutateAsync({ await actions.uploadWeKnoraPackage.mutateAsync({
file, file,
assistantBaseId: baseId, assistantBaseId: baseId,
}); });
toast.success("文件已上传,后台正在导入"); toast.success(
"数据包已上传并在后台导入,请在任务记录中查看进度,无需再次上传",
);
} catch (err) { } catch (err) {
toast.error((err as Error).message); toast.error((err as Error).message);
} }
@@ -357,25 +445,96 @@ export default function AssistantKnowledgePage() {
</TabsList> </TabsList>
</div> </div>
<TabsContent value="wiki" className="mt-0 flex min-h-0 flex-1"> <TabsContent value="wiki" className="mt-0 flex min-h-0 flex-1">
<WikiLibraryReader {pages.isPending ? (
key={baseId} <div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
pages={libraryPages} <Loader2 className="mr-2 size-5 animate-spin" />
activePage={active} 正在读取 Wiki 目录
onNavigate={navigatePage} </div>
pageHref={(s) => ) : pages.isError ? (
buildAppHashUrl( <div className="flex flex-1 items-center justify-center px-6">
`/page/workspace/knowledge/assistant?${new URLSearchParams({ base_id: baseId, wikiSlug: s })}`, <div className="max-w-lg rounded-2xl border border-destructive/30 bg-destructive/5 px-8 py-10 text-center">
) <div className="font-medium text-destructive">Wiki 目录读取失败</div>
} <p className="mt-2 text-sm text-muted-foreground">
mappingId={ {pages.error instanceof Error ? pages.error.message : "请稍后重试"}
active?.origins?.find((o) => o.mapping_id)?.mapping_id </p>
} <Button className="mt-5" variant="outline" onClick={() => void pages.refetch()}>
initiallyCollapseTree <RefreshCw className="mr-2 size-4" />
/> 重试读取
</Button>
</div>
</div>
) : libraryPages.length === 0 ? (
<div className="flex flex-1 items-center justify-center px-6">
<div className="w-full max-w-xl rounded-2xl border border-dashed bg-card px-8 py-10 text-center shadow-sm">
<div className="mx-auto flex size-12 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
<BookOpenText className="size-6" />
</div>
<div className="mt-4 font-medium text-foreground">Wiki 管理已就绪</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
当前知识库还没有文件和 Wiki 页面。进入知识库不要求先上传文件;导入内容后,系统会在这里生成目录、页面和向量索引。
</p>
{isAdmin ? (
<div className="mt-6 flex flex-wrap justify-center gap-2">
<Button
disabled={actions.uploadDocument.isPending}
onClick={() => documentUpload.current?.click()}
>
{actions.uploadDocument.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Upload className="mr-2 size-4" />
)}
上传文件生成 Wiki
</Button>
<Button
variant="outline"
disabled={packageImportBusy}
onClick={() => upload.current?.click()}
>
{packageImportBusy ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Upload className="mr-2 size-4" />
)}
导入普通知识库数据包
</Button>
</div>
) : (
<p className="mt-5 text-xs text-muted-foreground">
当前账号可正常进入和查看该知识库;请联系管理员导入首批内容。
</p>
)}
</div>
</div>
) : (
<WikiLibraryReader
key={baseId}
pages={libraryPages}
activePage={active}
onNavigate={navigatePage}
pageHref={(s) =>
buildAppHashUrl(
`/page/workspace/knowledge/assistant?${new URLSearchParams({ base_id: baseId, wikiSlug: s })}`,
)
}
mappingId={
active?.origins?.find((o) => o.mapping_id)?.mapping_id
}
initiallyCollapseTree
activePageLoading={
!!activeSlug && !activePage.data && activePage.isPending
}
activePageError={
activePage.data ? undefined : activePage.error?.message
}
onRetryActivePage={() => void activePage.refetch()}
/>
)}
</TabsContent> </TabsContent>
<TabsContent value="search" className="overflow-auto p-6"> <TabsContent value="search" className="overflow-auto p-6">
<div className="mb-5 flex max-w-3xl gap-2"> <div className="mb-5 flex max-w-3xl gap-2">
<Input <Input
aria-label="检索助手知识库"
placeholder="用自然语言检索知识,例如:如何处理设备过热?" placeholder="用自然语言检索知识,例如:如何处理设备过热?"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
@@ -383,7 +542,7 @@ export default function AssistantKnowledgePage() {
if (e.key === "Enter") void search(); if (e.key === "Enter") void search();
}} }}
/> />
<Button disabled={searching} onClick={() => void search()}> <Button disabled={searching || !query.trim()} onClick={() => void search()}>
{searching ? ( {searching ? (
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
) : ( ) : (
@@ -397,10 +556,18 @@ export default function AssistantKnowledgePage() {
<Badge variant="outline"> <Badge variant="outline">
{searchResult.retrieval_mode === "vector" {searchResult.retrieval_mode === "vector"
? "知识正文 / 片段 · 向量检索" ? "知识正文 / 片段 · 向量检索"
: searchResult.retrieval_mode === "overview"
? "知识正文 · 知识库概览"
: searchResult.retrieval_mode === "empty" : searchResult.retrieval_mode === "empty"
? "没有匹配结果" ? "没有匹配结果"
: "知识正文 / 片段 · 关键词检索(暂无匹配模型的向量)"} : "知识正文 / 片段 · 关键词检索"}
</Badge> </Badge>
{searchResult.results.length === 0 && (
<div role="status" className="mt-4 rounded-xl border border-dashed p-5 text-sm text-muted-foreground">
<p>没有找到相关知识。试试更简短的关键词,或浏览 Wiki 目录确认资料是否已入库。</p>
<Button variant="outline" className="mt-3" onClick={() => setTab("wiki")}>浏览 Wiki 目录</Button>
</div>
)}
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
{searchResult.results.map((p) => ( {searchResult.results.map((p) => (
<button <button
@@ -441,7 +608,8 @@ export default function AssistantKnowledgePage() {
<p className="mt-2 text-xs text-muted-foreground"> <p className="mt-2 text-xs text-muted-foreground">
上次同步:{new Date(s.updated_at).toLocaleString()} 上次同步:{new Date(s.updated_at).toLocaleString()}
</p> </p>
{isAdmin && ( {s.source_type === "file" && <p className="mt-2 text-xs text-muted-foreground">本地上传文档 · 处理进度及重试请查看导入记录</p>}
{isAdmin && s.source_type === "weknora" && (
<Button <Button
className="mt-4" className="mt-4"
size="sm" size="sm"
@@ -469,8 +637,10 @@ export default function AssistantKnowledgePage() {
> >
<WikiPackageTransfers id={baseId} assistant /> <WikiPackageTransfers id={baseId} assistant />
<h2 className="text-base font-semibold">导入与同步记录</h2> <h2 className="text-base font-semibold">导入与同步记录</h2>
<p className="text-sm text-muted-foreground">可直接上传文档,独立生成 Wiki 并进行向量检索,无需配置普通知识库服务。支持 PDF、Word、Excel、PowerPoint、Markdown、TXT、CSV,单文件最大 100 MB。生成失败时可配置语言模型或编码器后重试。</p>
{jobs.data?.map((j) => ( {jobs.data?.map((j) => (
<div key={j.id} className="rounded-xl border p-4"> <div key={j.id} className="rounded-xl border p-4">
<p className="mb-2 font-medium">{sources.data?.find((source) => source.id === j.source_id)?.source_name || (j.trigger === "local_file_upload" ? "上传文档" : "知识数据同步")}</p>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Badge variant="outline"> <Badge variant="outline">
{{ {{
@@ -484,6 +654,9 @@ export default function AssistantKnowledgePage() {
{new Date(j.created_at).toLocaleString()} {new Date(j.created_at).toLocaleString()}
</span> </span>
</div> </div>
<p className="mt-2 text-sm text-muted-foreground">{({ uploading: "接收文件", parsing: "解析文档", generating_wiki: "生成 Wiki", vectorizing: "正文向量化", importing: "保存知识并同步总库", queued: "等待处理", interrupted: "处理已中断" } as Record<string, string>)[j.phase] || j.phase}</p>
{j.counts?.parts_total > 0 && <p className="text-xs text-muted-foreground">文档分段:{j.counts.parts_done || 0} / {j.counts.parts_total}</p>}
{j.counts?.vectors_total > 0 && <p className="text-xs text-muted-foreground">向量化:{j.counts.vectors || 0} / {j.counts.vectors_total}</p>}
<p className="mt-3 text-sm"> <p className="mt-3 text-sm">
{j.counts?.wiki_pages || 0} Wiki ·{" "} {j.counts?.wiki_pages || 0} Wiki ·{" "}
{j.counts?.vectors || 0} 向量 · {j.counts?.entities || 0}{" "} {j.counts?.vectors || 0} 向量 · {j.counts?.entities || 0}{" "}
@@ -491,9 +664,15 @@ export default function AssistantKnowledgePage() {
</p> </p>
{j.last_error_message && ( {j.last_error_message && (
<p className="mt-2 text-sm text-destructive"> <p className="mt-2 text-sm text-destructive">
{j.last_error_message} {userFacingKnowledgeMessage(j.last_error_message)}
</p> </p>
)} )}
{j.trigger === "local_file_upload" && j.status !== "completed" && (
<Button className="mt-2" size="sm" variant="outline" disabled={actions.retryDocument.isPending} onClick={async () => {
try { await actions.retryDocument.mutateAsync(j.id); toast.success("已继续处理,复用已有解析和向量结果"); }
catch (error) { toast.error((error as Error).message); }
}}>重试 / 恢复处理</Button>
)}
</div> </div>
))} ))}
</TabsContent> </TabsContent>
+63 -23
View File
@@ -40,6 +40,7 @@ import { CompactWelcome } from "@/components/workspace/compact-welcome";
import { import {
buildFullChatNavigationPath, buildFullChatNavigationPath,
isChatsIframeWorkspacePath, isChatsIframeWorkspacePath,
WX_FULL_CHAT_PATH_PREFIX,
} from "@/core/page-layout/chats-iframe-route"; } from "@/core/page-layout/chats-iframe-route";
import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session"; import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
import { isWujieEnv } from "@/core/embed/wujie"; import { isWujieEnv } from "@/core/embed/wujie";
@@ -64,6 +65,10 @@ import {
useThreadSettings, useThreadSettings,
} from "@/core/settings"; } from "@/core/settings";
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks"; import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import {
myProjectConversationTypeFromSearchParams,
myProjectThreadMetadata,
} from "@/core/threads/project-metadata";
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed"; import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream"; import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode"; import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
@@ -142,6 +147,7 @@ export default function ChatPage() {
const [knowledgeRagEnabled] = useKnowledgeRagEnabled(); const [knowledgeRagEnabled] = useKnowledgeRagEnabled();
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]); const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>([]); const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>([]);
const [knowledgeSuggestedQuestions, setKnowledgeSuggestedQuestions] = useState<string[]>([]);
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(false); const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(false);
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false); const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
const knowledgeSelectionThread = useRef<string | null>(null); const knowledgeSelectionThread = useRef<string | null>(null);
@@ -285,16 +291,28 @@ export default function ChatPage() {
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]); }, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const projectConversationType =
myProjectConversationTypeFromSearchParams(searchParams);
const newThreadMetadata = useMemo<Record<string, unknown> | undefined>(() => {
const metadata: Record<string, unknown> = projectConversationType
? myProjectThreadMetadata(projectConversationType)
: {};
if (knowledgeSelectionReady) {
metadata.llmwiki_knowledge_base_ids = selectedKnowledgeBaseIds;
metadata.assistant_knowledge_base_ids = selectedAssistantKnowledgeBaseIds;
}
return Object.keys(metadata).length > 0 ? metadata : undefined;
}, [
knowledgeSelectionReady,
projectConversationType,
selectedAssistantKnowledgeBaseIds,
selectedKnowledgeBaseIds,
]);
const [thread, sendMessage, isUploading] = useThreadStream({ const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId, threadId: isNewThread ? undefined : threadId,
context: streamContext, context: streamContext,
threadMetadata: knowledgeSelectionReady threadMetadata: newThreadMetadata,
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: undefined,
isMock, isMock,
onStart: (createdThreadId) => { onStart: (createdThreadId) => {
knowledgeSelectionThread.current = createdThreadId; knowledgeSelectionThread.current = createdThreadId;
@@ -351,6 +369,33 @@ export default function ChatPage() {
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context); const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context); const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
const frontendRagSelected = isFrontendRagProfile(settings.context); const frontendRagSelected = isFrontendRagProfile(settings.context);
// 排查高并发时普通对话输入框意外禁用:流式运行本身不应成为禁用原因。
const inputDisabledReasons = useMemo(() => {
const reasons: string[] = [];
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") reasons.push("静态站点模式");
if (isUploading) reasons.push("正在上传附件");
if (agentHandoffPending) reasons.push("正在创建智能体会话");
if (knowledgeSelectionLoading) reasons.push("正在恢复会话知识库配置");
if (singleRoundRag.isLoading) reasons.push("单轮 RAG 执行中");
if (multiRoundRag.isLoading) reasons.push("多轮 RAG 执行中");
return reasons;
}, [
agentHandoffPending,
isUploading,
knowledgeSelectionLoading,
multiRoundRag.isLoading,
singleRoundRag.isLoading,
]);
const inputDisabled = inputDisabledReasons.length > 0;
useEffect(() => {
console.info("[普通对话输入框状态]", {
禁用: inputDisabled,
原因: inputDisabledReasons,
threadId: threadId ?? null,
新建对话: isNewThread,
});
}, [inputDisabled, inputDisabledReasons, isNewThread, threadId]);
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md // 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
// 流式 stub),成功且真实产物落地后自动撤出。 // 流式 stub),成功且真实产物落地后自动撤出。
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread); const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
@@ -405,7 +450,9 @@ export default function ChatPage() {
try { try {
let targetThreadId = threadId; let targetThreadId = threadId;
if (isNewThread) { if (isNewThread) {
const created = await getAPIClient(isMock).threads.create({}); const created = await getAPIClient(isMock).threads.create({
metadata: newThreadMetadata ?? {},
});
targetThreadId = created.thread_id; targetThreadId = created.thread_id;
setThreadId(targetThreadId); setThreadId(targetThreadId);
setIsNewThread(false); setIsNewThread(false);
@@ -465,13 +512,8 @@ export default function ChatPage() {
try { try {
const created = await getAPIClient(isMock).threads.create({ const created = await getAPIClient(isMock).threads.create({
metadata: { metadata: {
...newThreadMetadata,
agent_id: selectedAgentId, agent_id: selectedAgentId,
...(knowledgeSelectionReady
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: {}),
}, },
}); });
sessionStorage.setItem( sessionStorage.setItem(
@@ -490,7 +532,7 @@ export default function ChatPage() {
); );
saveLastAgentId(selectedAgentId); saveLastAgentId(selectedAgentId);
router.push( router.push(
`/page/workspace/agents/${selectedAgentId}/chats/${created.thread_id}`, `${routeBase}/agents/${encodeURIComponent(selectedAgentId)}/chats/${encodeURIComponent(created.thread_id)}`,
); );
} catch (err) { } catch (err) {
agentHandoffPendingRef.current = false; agentHandoffPendingRef.current = false;
@@ -549,6 +591,7 @@ export default function ChatPage() {
selectedAssistantKnowledgeBaseIds, selectedAssistantKnowledgeBaseIds,
selectedKnowledgeBaseIds, selectedKnowledgeBaseIds,
knowledgeSelectionReady, knowledgeSelectionReady,
newThreadMetadata,
agentHandoffPending, agentHandoffPending,
isMock, isMock,
router, router,
@@ -697,7 +740,9 @@ export default function ChatPage() {
// 用 useLayoutMode 的有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认), // 用 useLayoutMode 的有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认),
// 与 PageRoutes 的 chrome 判定同源。非紧凑(嵌入/standalone)保持原样。 // 与 PageRoutes 的 chrome 判定同源。非紧凑(嵌入/standalone)保持原样。
const chromeByPreference = const chromeByPreference =
fullChatMode || effectiveLayoutMode === "full" ? true : false; fullChatMode ||
pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX) ||
effectiveLayoutMode === "full";
const compactChrome = const compactChrome =
chromeByPreference && !isChatsIframeWorkspacePath(pathname); chromeByPreference && !isChatsIframeWorkspacePath(pathname);
const compactLanding = isLandingState && compactChrome; const compactLanding = isLandingState && compactChrome;
@@ -908,14 +953,7 @@ export default function ChatPage() {
} }
context={settings.context} context={settings.context}
extraModels={SINGLE_ROUND_RAG_MODELS} extraModels={SINGLE_ROUND_RAG_MODELS}
disabled={ disabled={inputDisabled}
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading ||
agentHandoffPending ||
knowledgeSelectionLoading ||
singleRoundRag.isLoading ||
multiRoundRag.isLoading
}
onContextChange={(context) => onContextChange={(context) =>
setSettings("context", context) setSettings("context", context)
} }
@@ -936,6 +974,7 @@ export default function ChatPage() {
assistantValue={selectedAssistantKnowledgeBaseIds} assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection} onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection} onAssistantChange={changeAssistantKnowledgeSelection}
onSuggestedQuestionsChange={setKnowledgeSuggestedQuestions}
autoSelectAll={isLandingState && !!selectedAgentId} autoSelectAll={isLandingState && !!selectedAgentId}
disabled={ disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
@@ -945,6 +984,7 @@ export default function ChatPage() {
label="知识空间" label="知识空间"
/> />
} }
knowledgeSuggestedQuestions={knowledgeSuggestedQuestions}
onFollowupsVisibilityChange={setShowFollowups} onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onStop={handleStop} onStop={handleStop}
@@ -1,3 +1,4 @@
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react"; import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { import {
AlertCircle, AlertCircle,
@@ -211,7 +212,7 @@ export default function EnterpriseResearchPage() {
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task)); setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
} }
if (event.type === "completed") toast.success("企业研究报告已完成"); if (event.type === "completed") toast.success("企业研究报告已完成");
if (event.type === "failed") toast.error(event.job.error_message || "报告生成失败"); if (event.type === "failed") toast.error(userFacingKnowledgeMessage(event.job.error_message || "报告生成失败"));
} }
}, },
}).catch((error: unknown) => { }).catch((error: unknown) => {
@@ -290,7 +291,7 @@ export default function EnterpriseResearchPage() {
return; return;
} }
if (!isWeKnoraReady) { if (!isWeKnoraReady) {
toast.error("知识服务尚未启用,请先在系统中配置 WeKnora"); toast.error("知识服务尚未启用,请先在系统中配置知识服务");
return; return;
} }
@@ -442,7 +443,7 @@ export default function EnterpriseResearchPage() {
<section> <section>
<Badge variant="secondary" className="mb-3"><Sparkles className="size-3" />阶段一:证据编排</Badge> <Badge variant="secondary" className="mb-3"><Sparkles className="size-3" />阶段一:证据编排</Badge>
<h2 className="text-xl font-semibold tracking-tight">建立一个可追溯的研究任务</h2> <h2 className="text-xl font-semibold tracking-tight">建立一个可追溯的研究任务</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 WeKnora 知识库,不会修改任何知识库或既有报告。</p> <p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 普通知识库,不会修改任何知识库或既有报告。</p>
</section> </section>
<Card> <Card>
@@ -609,12 +610,12 @@ function TaskOverview({
<CardContent><ol className="space-y-4">{template.stages.map((stage, index) => { <CardContent><ol className="space-y-4">{template.stages.map((stage, index) => {
const complete = task.status !== "collecting" && (index < 2 || task.sources.length > 0); const complete = task.status !== "collecting" && (index < 2 || task.sources.length > 0);
const current = task.status === "collecting" && index === 1; const current = task.status === "collecting" && index === 1;
return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用 WeKnora 检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>; return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用知识服务检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>;
})}</ol></CardContent> })}</ol></CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 WeKnora 知识库。</CardDescription></CardHeader> <CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 普通知识库。</CardDescription></CardHeader>
<CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{task.warning}</div> : null}</CardContent> <CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{userFacingKnowledgeMessage(task.warning)}</div> : null}</CardContent>
</Card> </Card>
{canPlan ? <Card> {canPlan ? <Card>
<CardHeader className="gap-1"><CardTitle className="text-base">研究计划与选用资料</CardTitle><CardDescription>计划和资料选择会保存到当前任务;后续报告只能使用这里确认过的证据。</CardDescription></CardHeader> <CardHeader className="gap-1"><CardTitle className="text-base">研究计划与选用资料</CardTitle><CardDescription>计划和资料选择会保存到当前任务;后续报告只能使用这里确认过的证据。</CardDescription></CardHeader>
-18
View File
@@ -2,7 +2,6 @@
import { import {
BookMarkedIcon, BookMarkedIcon,
BrainCircuitIcon,
FileTextIcon, FileTextIcon,
GraduationCapIcon, GraduationCapIcon,
ShrinkIcon, ShrinkIcon,
@@ -400,13 +399,6 @@ export default function IframeChatPage() {
}); });
}, [setSettings, settings.context.writing_mode]); }, [setSettings, settings.context.writing_mode]);
const toggleMemoryInjection = useCallback(() => {
setSettings("context", {
memory_injection_enabled:
!(settings.context.memory_injection_enabled ?? true),
});
}, [setSettings, settings.context.memory_injection_enabled]);
const toggleRagMode = useCallback(() => { const toggleRagMode = useCallback(() => {
if (ragModeLocked) { if (ragModeLocked) {
return; return;
@@ -531,9 +523,6 @@ export default function IframeChatPage() {
const modeButtons: ResolvedIframeModeButton[] = buildIframeModeButtons({ const modeButtons: ResolvedIframeModeButton[] = buildIframeModeButtons({
agentSelected: !!selectedAgentId, agentSelected: !!selectedAgentId,
writingModeEnabled: !!settings.context.writing_mode, writingModeEnabled: !!settings.context.writing_mode,
memoryInjectionEnabled: Boolean(
settings.context.memory_injection_enabled ?? true,
),
referenceModeEntryEnabled, referenceModeEntryEnabled,
ragModeEnabled, ragModeEnabled,
ragModeLocked, ragModeLocked,
@@ -555,13 +544,6 @@ export default function IframeChatPage() {
onClick: toggleWritingMode, onClick: toggleWritingMode,
activeClassName: "text-amber-600 hover:text-amber-700", activeClassName: "text-amber-600 hover:text-amber-700",
}; };
case "memory":
return {
...button,
icon: BrainCircuitIcon,
onClick: toggleMemoryInjection,
activeClassName: "text-blue-600 hover:text-blue-700",
};
case "references": case "references":
return { return {
...button, ...button,
@@ -91,7 +91,7 @@ export default function LlmWikiKnowledgePage({
return <Navigate to="/page/workspace/knowledge/assistant" replace />; return <Navigate to="/page/workspace/knowledge/assistant" replace />;
} }
if (runtime.isError || runtime.data?.provider !== "weknora") { if (runtime.isError || runtime.data?.provider !== "weknora" || !runtime.data?.api_base_url?.trim()) {
if (mode === "list" || mode === "detail") { if (mode === "list" || mode === "detail") {
return <Navigate to="/page/workspace/knowledge/assistant" replace />; return <Navigate to="/page/workspace/knowledge/assistant" replace />;
} }
+8 -10
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Fragment, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { Copy, Info, KeyRound, Link2, Search, ShieldCheck, UserCheck, X } from "lucide-react"; import { Copy, Info, KeyRound, Link2, Search, ShieldCheck, UserCheck, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -201,9 +201,8 @@ export default function LoginWhitelistPage() {
const allowed = user.approved || isUserAdmin; const allowed = user.approved || isUserAdmin;
const hasPwd = Boolean(user.login_password); const hasPwd = Boolean(user.login_password);
const isEditing = editingId === user.id; const isEditing = editingId === user.id;
return ( return [
<Fragment key={user.id}> <li key={`${user.id}-summary`} className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-3 px-4 py-3 transition-colors hover:bg-slate-50/60">
<li className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-3 px-4 py-3 transition-colors hover:bg-slate-50/60">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-slate-800">{user.email}</span> <span className="truncate text-sm font-medium text-slate-800">{user.email}</span>
@@ -260,10 +259,10 @@ export default function LoginWhitelistPage() {
onCheckedChange={(v) => handleToggleApproval(user.id, v)} onCheckedChange={(v) => handleToggleApproval(user.id, v)}
/> />
</div> </div>
</li> </li>,
{isEditing && ( isEditing && (
<li className="bg-slate-50/70 px-4 py-4"> <li key={`${user.id}-password`} className="bg-slate-50/70 px-4 py-4">
<div className="rounded-xl border border-slate-200/70 bg-white p-4 shadow-sm"> <div className="rounded-xl border border-slate-200/70 bg-white p-4 shadow-sm">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-600"> <div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-600">
<KeyRound className="size-3.5 text-slate-400" /> <KeyRound className="size-3.5 text-slate-400" />
@@ -328,9 +327,8 @@ export default function LoginWhitelistPage() {
)} )}
</div> </div>
</li> </li>
)} )
</Fragment> ];
);
})} })}
</ul> </ul>
)} )}
@@ -86,6 +86,7 @@ const emptyDraft = (): ModelDraft => ({
base_url: "", base_url: "",
request_timeout: 30, request_timeout: 30,
max_retries: 2, max_retries: 2,
max_input_tokens: undefined,
max_tokens: undefined, max_tokens: undefined,
temperature: undefined, temperature: undefined,
stream_usage: true, stream_usage: true,
@@ -143,6 +144,7 @@ function buildPayload(draft: ModelDraft): ModelSavePayload {
base_url: optionalString(draft.base_url), base_url: optionalString(draft.base_url),
request_timeout: toNumber(draft.request_timeout), request_timeout: toNumber(draft.request_timeout),
max_retries: toNumber(draft.max_retries), max_retries: toNumber(draft.max_retries),
max_input_tokens: toNumber(draft.max_input_tokens),
max_tokens: toNumber(draft.max_tokens), max_tokens: toNumber(draft.max_tokens),
temperature: toNumber(draft.temperature), temperature: toNumber(draft.temperature),
stream_usage: draft.stream_usage, stream_usage: draft.stream_usage,
@@ -379,7 +381,7 @@ function ModelEditor({ model, onClose }: { model: ManagedModel | null; onClose:
</section> </section>
<div className="flex items-center justify-between border-t pt-4"><TButton theme="default" variant="outline" loading={test.isPending} onClick={() => void handleTest()}><WrenchIcon className="mr-1.5 size-4" />测试连接</TButton><TButton theme="default" variant="text" onClick={() => setAdvanced((value) => !value)}><Settings2Icon className="mr-1.5 size-4" />高级配置 <ChevronDownIcon className={`ml-1 size-4 transition-transform ${advanced ? "rotate-180" : ""}`} /></TButton></div> <div className="flex items-center justify-between border-t pt-4"><TButton theme="default" variant="outline" loading={test.isPending} onClick={() => void handleTest()}><WrenchIcon className="mr-1.5 size-4" />测试连接</TButton><TButton theme="default" variant="text" onClick={() => setAdvanced((value) => !value)}><Settings2Icon className="mr-1.5 size-4" />高级配置 <ChevronDownIcon className={`ml-1 size-4 transition-transform ${advanced ? "rotate-180" : ""}`} /></TButton></div>
{testResult && <div className={`rounded-lg border px-3 py-2 text-sm ${testResult.success ? "border-primary/20 bg-primary/5 text-foreground" : "border-destructive/25 bg-destructive/5 text-destructive"}`}><div className="font-medium">{testResult.success ? "连接成功" : "连接失败"}{testResult.success && testResult.latency ? ` · ${testResult.latency} ms` : ""}</div><p className="mt-1 break-words text-xs opacity-80">{testResult.preview || testResult.message}</p></div>} {testResult && <div className={`rounded-lg border px-3 py-2 text-sm ${testResult.success ? "border-primary/20 bg-primary/5 text-foreground" : "border-destructive/25 bg-destructive/5 text-destructive"}`}><div className="font-medium">{testResult.success ? "连接成功" : "连接失败"}{testResult.success && testResult.latency ? ` · ${testResult.latency} ms` : ""}</div><p className="mt-1 break-words text-xs opacity-80">{testResult.preview || testResult.message}</p></div>}
{advanced && <section className="space-y-4 rounded-lg border border-dashed border-border bg-muted/10 p-4"><div className="grid gap-4 md:grid-cols-3"><Field label="请求超时(秒)"><InputNumber value={draft.request_timeout ?? ""} min={1} max={600} onChange={(value) => patch({ request_timeout: toNumber(value) })} /></Field><Field label="最大重试次数"><InputNumber value={draft.max_retries ?? ""} min={0} max={20} onChange={(value) => patch({ max_retries: toNumber(value) })} /></Field><Field label="最大输出 Token"><InputNumber value={draft.max_tokens ?? ""} min={1} max={1_000_000} onChange={(value) => patch({ max_tokens: toNumber(value) })} /></Field><Field label="温度"><InputNumber value={draft.temperature ?? ""} min={0} max={2} step={0.1} decimalPlaces={1} onChange={(value) => patch({ temperature: toNumber(value) })} /></Field><span className="flex items-end gap-2 pb-1 text-sm font-medium"><Switch value={draft.stream_usage ?? true} onChange={(value) => patch({ stream_usage: Boolean(value) })} />流式 Token 统计</span></div><Field label="额外参数 JSON"><Textarea value={draft.extra_settings_text} autosize={{ minRows: 4, maxRows: 10 }} placeholder={'{\n "extra_body": {}\n}'} onChange={(value) => patch({ extra_settings_text: String(value) })} /><p className="text-xs font-normal text-muted-foreground">保留并编辑配置文件中未在基础表单展示的兼容参数。必须为 JSON 对象。</p></Field></section>} {advanced && <section className="space-y-4 rounded-lg border border-dashed border-border bg-muted/10 p-4"><div className="grid gap-4 md:grid-cols-3"><Field label="请求超时(秒)"><InputNumber value={draft.request_timeout ?? ""} min={1} max={600} onChange={(value) => patch({ request_timeout: toNumber(value) })} /></Field><Field label="最大重试次数"><InputNumber value={draft.max_retries ?? ""} min={0} max={20} onChange={(value) => patch({ max_retries: toNumber(value) })} /></Field><Field label="最大输入 Token"><InputNumber value={draft.max_input_tokens ?? ""} min={1} max={10_000_000} placeholder="可不填,不设默认上限" onChange={(value) => patch({ max_input_tokens: toNumber(value) })} /></Field><Field label="最大输出 Token"><InputNumber value={draft.max_tokens ?? ""} min={1} max={1_000_000} placeholder="单次回复上限" onChange={(value) => patch({ max_tokens: toNumber(value) })} /></Field><Field label="温度"><InputNumber value={draft.temperature ?? ""} min={0} max={2} step={0.1} decimalPlaces={1} onChange={(value) => patch({ temperature: toNumber(value) })} /></Field><span className="flex items-end gap-2 pb-1 text-sm font-medium"><Switch value={draft.stream_usage ?? true} onChange={(value) => patch({ stream_usage: Boolean(value) })} />流式 Token 统计</span></div><p className="text-xs text-muted-foreground">最大输入 Token 可选填,不设默认值;填写后提前裁剪;模型返回上下文超限时自动缩短重试。最大输出 Token 限制单次回复长度。</p><Field label="额外参数 JSON"><Textarea value={draft.extra_settings_text} autosize={{ minRows: 4, maxRows: 10 }} placeholder={'{\n "extra_body": {}\n}'} onChange={(value) => patch({ extra_settings_text: String(value) })} /><p className="text-xs font-normal text-muted-foreground">保留并编辑配置文件中未在基础表单展示的兼容参数。必须为 JSON 对象。</p></Field></section>}
</div> </div>
</Dialog> </Dialog>
); );
+119 -28
View File
@@ -5,6 +5,8 @@ import {
ChevronDown, ChevronDown,
CircleHelp, CircleHelp,
Clock3, Clock3,
FolderKanban,
Globe,
LoaderCircle, LoaderCircle,
MessageSquareText, MessageSquareText,
PanelLeftClose, PanelLeftClose,
@@ -19,8 +21,15 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { type FormEvent, useCallback, useMemo, useState } from "react"; import { type FormEvent, useCallback, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { Route, Routes, useLocation, useNavigate } from "react-router-dom";
import AnalysisProjectsPage from "./AnalysisProjectsPage";
import { SidebarProvider } from "@/components/ui/sidebar";
import { Toaster } from "sonner";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useAgent, useAgents } from "@/core/agents"; import { useAgent, useAgents } from "@/core/agents";
import { getAccountDisplayName } from "@/core/auth"; import { getAccountDisplayName } from "@/core/auth";
import { import {
@@ -29,28 +38,44 @@ import {
useScheduledTaskRuns, useScheduledTaskRuns,
useScheduledTasks, useScheduledTasks,
} from "@/core/scheduled-tasks"; } from "@/core/scheduled-tasks";
import { useLocalSettings } from "@/core/settings";
import { import {
useAppearanceSettings, useAppearanceSettings,
useCitationDisplaySettings, useCitationDisplaySettings,
} from "@/core/system-settings/hooks"; } from "@/core/system-settings/hooks";
import { useThreadStream, useThreads } from "@/core/threads/hooks"; import { useThreadStream, useThreads } from "@/core/threads/hooks";
import {
myProjectConversationTypeOfThread,
myProjectConversationTypeFromSearchParams,
myProjectThreadMetadata,
} from "@/core/threads/project-metadata";
import { textOfMessage, titleOfThread } from "@/core/threads/utils"; import { textOfMessage, titleOfThread } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid"; import { uuid } from "@/core/utils/uuid";
import { import {
OFFICIAL_DEERFLOW_HOME_ROUTE, OFFICIAL_DEERFLOW_HOME_ROUTE,
OFFICIAL_DEERFLOW_ROUTE_PREFIX, OFFICIAL_DEERFLOW_ROUTE_PREFIX,
} from "@/core/official-deerflow/routes"; } from "@/core/official-deerflow/routes";
import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
import OfficialWebAppsPage from "@/pages/OfficialWebAppsPage";
import "./official-deerflow-entry.css"; import "./official-deerflow-entry.css";
type OfficialPage = "chat" | "agents" | "agent-chat" | "scheduled" | "settings"; type OfficialPage =
| "chat"
| "projects"
| "web-apps"
| "agents"
| "agent-chat"
| "scheduled"
| "settings";
function pageFromPath(pathname: string): OfficialPage { function pageFromPath(pathname: string): OfficialPage {
const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length); const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length);
if (path.startsWith("/workspace/agents/") && path.includes("/chats")) { if (path.startsWith("/workspace/agents/") && path.includes("/chats")) {
return "agent-chat"; return "agent-chat";
} }
if (path.startsWith("/workspace/projects")) return "projects";
if (path.startsWith("/workspace/web-app")) return "web-apps";
if (path.startsWith("/workspace/agents")) return "agents"; if (path.startsWith("/workspace/agents")) return "agents";
if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled"; if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled";
if (path.startsWith("/workspace/settings")) return "settings"; if (path.startsWith("/workspace/settings")) return "settings";
@@ -78,6 +103,11 @@ function parseScheduledTaskId(pathname: string) {
return match ? decodeURIComponent(match[1]!) : null; return match ? decodeURIComponent(match[1]!) : null;
} }
function parseProjectId(pathname: string) {
const match = pathname.match(/\/workspace\/projects\/([^/]+)/);
return match ? decodeURIComponent(match[1]!) : null;
}
function readableError(error: unknown, fallback: string) { function readableError(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback; return error instanceof Error && error.message ? error.message : fallback;
} }
@@ -159,20 +189,36 @@ function ChatWorkspace({
routeThreadId: string; routeThreadId: string;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [sendError, setSendError] = useState<string | null>(null); const [sendError, setSendError] = useState<string | null>(null);
const isNewThread = routeThreadId === "new"; const isNewThread = routeThreadId === "new";
const projectConversationType = isNewThread
? myProjectConversationTypeFromSearchParams(
new URLSearchParams(location.search),
)
: null;
// A new conversation uses the same client-generated id approach as the // A new conversation uses the same client-generated id approach as the
// existing ChatPage. The backend owns the persisted id once streaming starts. // existing ChatPage. The backend owns the persisted id once streaming starts.
const [draftThreadId] = useState<string>(() => uuid() as string); const [draftThreadId] = useState<string>(() => uuid() as string);
const activeThreadId = isNewThread ? draftThreadId : routeThreadId; const activeThreadId = isNewThread ? draftThreadId : routeThreadId;
const { agent } = useAgent(agentId, { enabled: !!agentId }); const { agent } = useAgent(agentId, { enabled: !!agentId });
const [localSettings] = useLocalSettings();
const [thread, sendMessage, isUploading] = useThreadStream({ const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : routeThreadId, threadId: isNewThread ? undefined : routeThreadId,
assistantId: agentId ?? undefined, assistantId: agentId ?? undefined,
threadMetadata: projectConversationType
? myProjectThreadMetadata(projectConversationType)
: undefined,
context: { context: {
...localSettings.context, // This compact official shell has no model/mode controls of its own.
// Do not inherit a hidden selection saved by the customized workspace
// (for example an obsolete or quota-exhausted model); an omitted model
// lets the backend resolve the current system default or the agent's
// globally configured model.
model_name: undefined,
mode: undefined,
reasoning_effort: undefined,
writing_mode: false,
...(agentId ? { agent_id: agentId } : {}), ...(agentId ? { agent_id: agentId } : {}),
}, },
onStart: (createdThreadId) => { onStart: (createdThreadId) => {
@@ -658,6 +704,10 @@ export default function OfficialDeerFlowEntry() {
() => parseScheduledTaskId(location.pathname), () => parseScheduledTaskId(location.pathname),
[location.pathname], [location.pathname],
); );
const projectId = useMemo(
() => parseProjectId(location.pathname),
[location.pathname],
);
const { data: threads = [], isLoading: threadsLoading, error: threadsError } = const { data: threads = [], isLoading: threadsLoading, error: threadsError } =
useThreads({ useThreads({
limit: 20, limit: 20,
@@ -666,10 +716,13 @@ export default function OfficialDeerFlowEntry() {
select: ["thread_id", "updated_at", "values", "metadata"], select: ["thread_id", "updated_at", "values", "metadata"],
}); });
const go = (path: string) => navigate(path); const go = (path: string) => navigate(path);
const projectBasePath = `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/projects`;
const nav = [ const nav = [
{ icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, pages: ["chat"] }, { icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, pages: ["chat"] },
{ icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] }, { icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] },
{ icon: FolderKanban, label: "我的项目", path: projectBasePath, pages: ["projects"] },
{ icon: Globe, label: "Web 应用", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/web-app`, pages: ["web-apps"] },
{ icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] }, { icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] },
{ icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] }, { icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] },
] as const; ] as const;
@@ -706,29 +759,48 @@ export default function OfficialDeerFlowEntry() {
) : threadsError ? ( ) : threadsError ? (
<p className="official-sidebar-error">对话列表加载失败</p> <p className="official-sidebar-error">对话列表加载失败</p>
) : threads.length ? ( ) : threads.length ? (
threads.map((chat) => ( threads.map((chat) => {
<button const projectType = myProjectConversationTypeOfThread(chat);
className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`} return (
key={chat.thread_id} <button
onClick={() => { className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`}
const agentId = typeof chat.context?.agent_id === "string" key={chat.thread_id}
? chat.context.agent_id onClick={() => {
: typeof chat.metadata?.agent_id === "string" const projectId = chat.metadata?.analysis_project_id;
? chat.metadata.agent_id if (typeof projectId === "string" && projectId) {
: null; go(`${projectBasePath}/groups/${encodeURIComponent(projectId)}/chats/${encodeURIComponent(chat.thread_id)}`);
go( return;
agentId }
? `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/${encodeURIComponent(chat.thread_id)}` const agentId = typeof chat.context?.agent_id === "string"
: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(chat.thread_id)}`, ? chat.context.agent_id
); : typeof chat.metadata?.agent_id === "string"
}} ? chat.metadata.agent_id
type="button" : null;
> go(
<MessageSquareText aria-hidden="true" size={15} /> agentId
<span>{titleOfThread(chat, "未命名对话")}</span> ? `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/${encodeURIComponent(chat.thread_id)}`
<small>{formatTimestamp(chat.updated_at)}</small> : `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(chat.thread_id)}`,
</button> );
)) }}
type="button"
>
{projectType ? (
<MyProjectThreadIcon className="size-[15px]" type={projectType} />
) : (
<MessageSquareText aria-hidden="true" size={15} />
)}
<span className="official-recent-chat-copy">
<span className="official-recent-chat-title">
{titleOfThread(chat, "未命名对话")}
</span>
{projectType && (
<MyProjectThreadBadge type={projectType} />
)}
</span>
<small>{formatTimestamp(chat.updated_at)}</small>
</button>
);
})
) : ( ) : (
<p className="official-sidebar-empty">暂无历史对话</p> <p className="official-sidebar-empty">暂无历史对话</p>
)} )}
@@ -754,6 +826,24 @@ export default function OfficialDeerFlowEntry() {
routeThreadId={chatRoute.threadId} routeThreadId={chatRoute.threadId}
/> />
)} )}
{page === "projects" && (
(location.pathname === `${projectBasePath}/groups` || location.pathname.startsWith(`${projectBasePath}/groups/`)) ? (
<SidebarProvider className="h-full min-h-0">
<Routes>
<Route path="workspace/projects/groups/*" element={<AnalysisProjectsPage basePath={`${projectBasePath}/groups`} listPath={`${projectBasePath}?type=qa`} mode="qa" showSidebarTrigger={false} />} />
</Routes>
</SidebarProvider>
) : projectId ? (
<StrategySelectionPage
key={projectId}
projectBasePath={projectBasePath}
projectId={projectId}
/>
) : (
<StrategyProjectsPage basePath={projectBasePath} />
)
)}
{page === "web-apps" && <OfficialWebAppsPage />}
{page === "agents" && ( {page === "agents" && (
<AgentsPage <AgentsPage
openAgent={(agentId) => openAgent={(agentId) =>
@@ -771,6 +861,7 @@ export default function OfficialDeerFlowEntry() {
{page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />} {page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />}
{page === "settings" && <SettingsPage />} {page === "settings" && <SettingsPage />}
</div> </div>
<Toaster position="top-center" />
</main> </main>
); );
} }
@@ -0,0 +1,140 @@
/**
* official 工作区的「Web 应用」入口页:跨对话列出当前用户生成的
* Web App 站点,点击进入站点预览工作室(#/sites/:threadId/:siteName)。
*
* capability 不可用(未启用 / 模板依赖未预置)时展示设计过的空状态,
* 不影响该工作区的其它页面。
*/
import { Bot, Globe, LoaderCircle, MessageSquareText, RefreshCw } from "lucide-react";
import { useNavigate } from "react-router-dom";
import {
OFFICIAL_DEERFLOW_ROUTE_PREFIX,
} from "@/core/official-deerflow/routes";
import { siteStudioRoute, useAllSites, type SiteGalleryItem } from "@/core/sites/api";
const AVATAR_TONES = ["violet", "sky", "amber"] as const;
export default function OfficialWebAppsPage() {
const navigate = useNavigate();
const { data, isLoading, error, refetch, isRefetching } = useAllSites();
const available = data?.available === true;
const sites = data?.sites ?? [];
return (
<section className="official-content-page">
<header className="official-page-header">
<div>
<p className="official-eyebrow">工作区</p>
<h1>Web 应用</h1>
<p>对话中生成的多页面站点(含流程图 / 地图 / 图表)会集中出现在这里。</p>
</div>
<button
className="official-outline-button"
disabled={isRefetching}
onClick={() => void refetch()}
type="button"
>
{isRefetching ? <LoaderCircle aria-hidden="true" size={17} /> : <RefreshCw aria-hidden="true" size={17} />}
重新扫描
</button>
</header>
{!available ? (
<div className="official-empty-panel">
<div className="official-empty-icon"><Globe aria-hidden="true" size={24} /></div>
<h2>站点产物模式未启用</h2>
<p>
当前环境未开启 Web App 站点模式(配置开关或模板依赖未就绪),
不影响其它功能;智能体会按单页 HTML 方式产出网页。
</p>
{data?.reasons?.length ? (
<p className="official-query-error">{data.reasons.join(";")}</p>
) : null}
</div>
) : error ? (
<p className="official-query-error" role="alert">
站点列表加载失败:{error instanceof Error ? error.message : "请稍后重试"}
</p>
) : isLoading ? (
<div className="official-loading-state">
<LoaderCircle aria-hidden="true" size={18} /> 正在扫描站点…
</div>
) : sites.length ? (
<div className="official-agent-grid">
{sites.map((site, index) => (
<SiteCard
key={`${site.thread_id}:${site.name}`}
site={site}
tone={AVATAR_TONES[index % AVATAR_TONES.length]}
onOpen={() =>
navigate(siteStudioRoute(site.thread_id, site.name))
}
onOpenThread={() =>
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(site.thread_id)}`)
}
/>
))}
</div>
) : (
<div className="official-empty-panel">
<div className="official-empty-icon"><Bot aria-hidden="true" size={24} /></div>
<h2>还没有生成的站点</h2>
<p>在对话里让智能体「做一个多页面网站 / 带流程图或地图的网页」,生成的站点会出现在这里。</p>
<button
className="official-secondary-button"
onClick={() => navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`)}
type="button"
>
<MessageSquareText aria-hidden="true" size={16} />
开始对话
</button>
</div>
)}
</section>
);
}
function SiteCard({
site,
tone,
onOpen,
onOpenThread,
}: {
site: SiteGalleryItem;
tone: (typeof AVATAR_TONES)[number];
onOpen: () => void;
onOpenThread: () => void;
}) {
const building = site.build_job?.status === "running";
const failed = site.last_build?.success === false || site.build_job?.status === "failed";
const built = site.dist.exists === true;
const statusLabel = building ? "构建中" : built ? (failed ? "构建失败" : "可预览") : "未构建";
return (
<article className="official-agent-card">
<div className={`official-agent-avatar ${tone}`}>
<Globe aria-hidden="true" size={22} />
</div>
<h2>{site.title}</h2>
<p>{site.description || site.name}</p>
<small>
来源:{site.thread_title}
{" · "}
<span>{statusLabel}</span>
{built && site.dist.total_bytes ? ` · ${Math.max(1, Math.round(site.dist.total_bytes / 1024))} KB` : ""}
</small>
<button onClick={onOpen} type="button">
打开站点预览
</button>
<button
onClick={onOpenThread}
style={{ background: "transparent", border: "none", color: "inherit", opacity: 0.7, cursor: "pointer", fontSize: 12, marginTop: 6 }}
type="button"
>
查看来源对话 →
</button>
</article>
);
}
+19 -3
View File
@@ -4,7 +4,10 @@ import { Navigate, Route, Routes } from "react-router-dom";
import { PageSidebar } from "@/components/page-sidebar"; import { PageSidebar } from "@/components/page-sidebar";
import { PageSidebarDark } from "@/components/page-sidebar-dark"; import { PageSidebarDark } from "@/components/page-sidebar-dark";
import { CompactToolbox } from "@/components/workspace/compact-toolbox"; import { CompactToolbox } from "@/components/workspace/compact-toolbox";
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route"; import {
isChatsIframeWorkspacePath,
WX_FULL_CHAT_PATH_PREFIX,
} from "@/core/page-layout/chats-iframe-route";
import { isWujieEnv } from "@/core/embed/wujie"; import { isWujieEnv } from "@/core/embed/wujie";
import { import {
isEmbedActive, isEmbedActive,
@@ -36,6 +39,9 @@ function PageLayout() {
const location = useLocation(); const location = useLocation();
const { theme } = useTheme(); const { theme } = useTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
// This dedicated surface is self-contained: it keeps the workspace's
// common-agent panel, while omitting the global header and page navigation.
const isWxFullChat = location.pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX);
// 浅色非嵌入:侧边栏为新版「二级导航图标卡片」窄栏(96px);wujie 嵌入仍走旧版手风琴(64/256px)。 // 浅色非嵌入:侧边栏为新版「二级导航图标卡片」窄栏(96px);wujie 嵌入仍走旧版手风琴(64/256px)。
const inWujie = useMemo(() => isWujieEnv(), []); const inWujie = useMemo(() => isWujieEnv(), []);
const lightNarrowSidebar = !isDark && !inWujie; const lightNarrowSidebar = !isDark && !inWujie;
@@ -47,7 +53,13 @@ function PageLayout() {
// 不再回退到会话级 sidebarEnabled 旧默认)。「全量↔简洁」始终能真正控制顶栏+侧边栏。 // 不再回退到会话级 sidebarEnabled 旧默认)。「全量↔简洁」始终能真正控制顶栏+侧边栏。
const [layoutMode] = useLayoutMode(); const [layoutMode] = useLayoutMode();
const chromeByPreference = const chromeByPreference =
forceCollapsedSidebar ? false : layoutMode === "compact" ? false : true; forceCollapsedSidebar
? false
: isWxFullChat
? true
: layoutMode === "compact"
? false
: true;
// iframe 嵌入模式(外部系统 `&embed=1`,按标签页 sessionStorage):去掉全局导航栏 + 侧边栏, // iframe 嵌入模式(外部系统 `&embed=1`,按标签页 sessionStorage):去掉全局导航栏 + 侧边栏,
// 只留问答/会商工作区本体。按 location 重算以跟随页内导航;非嵌入标签页恒为 false,不受影响。 // 只留问答/会商工作区本体。按 location 重算以跟随页内导航;非嵌入标签页恒为 false,不受影响。
const embedActive = useMemo( const embedActive = useMemo(
@@ -90,6 +102,7 @@ function PageLayout() {
const pageSidebarAllowed = const pageSidebarAllowed =
chromeByPreference && chromeByPreference &&
!isChatsIframeWorkspacePath(location.pathname) && !isChatsIframeWorkspacePath(location.pathname) &&
!isWxFullChat &&
!embedActive && !embedActive &&
!chromeHidden && !chromeHidden &&
!hostNestedSurface; !hostNestedSurface;
@@ -98,6 +111,7 @@ function PageLayout() {
!embedActive && !embedActive &&
!chromeHidden && !chromeHidden &&
!hostNestedSurface && !hostNestedSurface &&
!isWxFullChat &&
!isChatsIframeWorkspacePath(location.pathname); !isChatsIframeWorkspacePath(location.pathname);
// h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100% // h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100%
@@ -151,7 +165,9 @@ function PageLayout() {
// Full-chat embedding has no global PageSidebar, but it // Full-chat embedding has no global PageSidebar, but it
// still needs the full GeneralQaSidebar (common agents and // still needs the full GeneralQaSidebar (common agents and
// conversation history) inside the workspace. // conversation history) inside the workspace.
compactWorkspaceSidebar={pageSidebarAllowed || fullChatMode} compactWorkspaceSidebar={
pageSidebarAllowed || fullChatMode || isWxFullChat
}
compactActionsHidden={hideChromeActive || headerHidden} compactActionsHidden={hideChromeActive || headerHidden}
pageSidebarDark={isDark} pageSidebarDark={isDark}
forceWorkspaceSidebarCollapsed={ forceWorkspaceSidebarCollapsed={
+45 -4
View File
@@ -1,7 +1,11 @@
import { Navigate, Route, Routes } from "react-router-dom"; import type { ReactNode } from "react";
import { useLayoutMode } from "@/core/system-settings/hooks";
import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
import AnalysisProjectsPage from "./AnalysisProjectsPage";
import { Navigate, Route, Routes, useParams } from "react-router-dom";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar"; import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { getAccountDisplayName, getStoredAuth } from "@/core/auth"; import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config"; import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
@@ -62,7 +66,6 @@ import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
import LightAppNewNotePage from "./LightAppNewNotePage"; import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage"; import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage"; import LightAppNoteQAPage from "./LightAppNoteQAPage";
import MemoryPage from "./MemoryPage";
import NewAgentPage from "./NewAgentPage"; import NewAgentPage from "./NewAgentPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage"; import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage"; import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
@@ -78,6 +81,8 @@ import UsersPage from "./UsersPage";
import LlmMetricsPage from "./LlmMetricsPage"; import LlmMetricsPage from "./LlmMetricsPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage"; import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage"; import AdminUserActivityPage from "./AdminUserActivityPage";
import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
import { ChatRuntime } from "./WorkspaceRoutes"; import { ChatRuntime } from "./WorkspaceRoutes";
/** /**
@@ -98,6 +103,28 @@ function StrategyChatThreadLayout() {
); );
} }
function StrategyProjectsLayout({ children }: { children: ReactNode }) {
const [layoutMode] = useLayoutMode();
const showNavigation =
layoutMode === "compact" && !isEmbedActive() && !isHideChromeActive();
return (
<SidebarProvider defaultOpen className="relative h-full min-h-0 w-full overflow-hidden [&_[data-slot=sidebar-container]]:absolute [&_[data-slot=sidebar-container]]:left-0 [&_[data-slot=sidebar-container]]:top-0">
{showNavigation && <WorkspaceSidebar />}
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
{showNavigation && (
<div className="p-2 md:hidden"><SidebarTrigger aria-label="展开功能菜单" /></div>
)}
{children}
</SidebarInset>
</SidebarProvider>
);
}
function StrategyProjectWorkbenchRoute() {
const { project_id: projectId = "" } = useParams<{ project_id: string }>();
return <StrategySelectionPage key={projectId} projectId={projectId} />;
}
export default function StrategyRoutes() { export default function StrategyRoutes() {
const storedAuth = getStoredAuth(); const storedAuth = getStoredAuth();
const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin"; const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin";
@@ -110,6 +137,21 @@ export default function StrategyRoutes() {
<Route index element={<Navigate to="qa/general" replace />} /> <Route index element={<Navigate to="qa/general" replace />} />
<Route path="qa/general" element={<GeneralQA />} /> <Route path="qa/general" element={<GeneralQA />} />
<Route
path="qa/projects"
element={
<StrategyProjectsLayout><StrategyProjectsPage basePath="/page/strategy/qa/projects" /></StrategyProjectsLayout>
}
/>
<Route path="qa/projects/groups/*" element={<StrategyProjectsLayout><AnalysisProjectsPage basePath="/page/strategy/qa/projects/groups" listPath="/page/strategy/qa/projects?type=qa" mode="qa" showSidebarTrigger={false} /></StrategyProjectsLayout>} />
<Route
path="qa/projects/:project_id"
element={<StrategyProjectsLayout><StrategyProjectWorkbenchRoute /></StrategyProjectsLayout>}
/>
<Route
path="qa/strategy-selection"
element={<Navigate to="/page/strategy/qa/projects" replace />}
/>
<Route <Route
path="qa/expert-qa" path="qa/expert-qa"
element={ element={
@@ -402,7 +444,6 @@ export default function StrategyRoutes() {
<Route path="skills" element={<SkillsPage />} /> <Route path="skills" element={<SkillsPage />} />
<Route path="skills/evolution" element={<SkillEvolutionPage />} /> <Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
<Route <Route
path="business-chain-mapping" path="business-chain-mapping"
element={ element={
+184 -16
View File
@@ -31,10 +31,11 @@ import { SubtasksProvider } from "@/core/tasks/context";
import { ArtifactsProvider } from "@/components/workspace/artifacts"; import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context"; import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
import { RouteBaseProvider } from "@/core/route-base"; import { RouteBaseProvider, useRouteBase } from "@/core/route-base";
import { agentRefOfThread } from "@/core/threads/utils"; import { agentRefOfThread } from "@/core/threads/utils";
import ChatsPage from "./ChatsPage"; import ChatsPage from "./ChatsPage";
import AnalysisProjectsPage from "./AnalysisProjectsPage";
import ChatPage from "./ChatPage"; import ChatPage from "./ChatPage";
import ArtifactLibraryPage from "./ArtifactLibraryPage"; import ArtifactLibraryPage from "./ArtifactLibraryPage";
import IframeChatPage from "./IframeChatPage"; import IframeChatPage from "./IframeChatPage";
@@ -55,7 +56,6 @@ import WeKnoraPackageTransfersPage from "@/strategy-components/components/resour
import MarkdownDocxEditorPage from "./MarkdownDocxEditorPage"; import MarkdownDocxEditorPage from "./MarkdownDocxEditorPage";
import LlmMetricsPage from "./LlmMetricsPage"; import LlmMetricsPage from "./LlmMetricsPage";
import CheckpointMigrationPage from "./CheckpointMigrationPage"; import CheckpointMigrationPage from "./CheckpointMigrationPage";
import MemoryPage from "./MemoryPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage"; import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage"; import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
import ModelConcurrencySettingsPage from "./ModelConcurrencySettingsPage"; import ModelConcurrencySettingsPage from "./ModelConcurrencySettingsPage";
@@ -130,7 +130,6 @@ function shouldShowCompactTopActions(pathname: string): boolean {
return ( return (
pathname === "/page/workspace/agents" || pathname === "/page/workspace/agents" ||
pathname === "/page/workspace/skills" || pathname === "/page/workspace/skills" ||
pathname === "/page/workspace/memory" ||
pathname === "/page/workspace/library" || pathname === "/page/workspace/library" ||
pathname === "/page/workspace/knowledge" || pathname === "/page/workspace/knowledge" ||
pathname.startsWith("/page/workspace/knowledge/") pathname.startsWith("/page/workspace/knowledge/")
@@ -172,6 +171,7 @@ export function WorkspaceLayout({
insetOverflowHidden = false, insetOverflowHidden = false,
pageSidebarDark = false, pageSidebarDark = false,
forceWorkspaceSidebarCollapsed = false, forceWorkspaceSidebarCollapsed = false,
wxCommonAgents = false,
}: { }: {
children: ReactNode; children: ReactNode;
pageSidebarCollapsed: boolean; pageSidebarCollapsed: boolean;
@@ -187,11 +187,16 @@ export function WorkspaceLayout({
pageSidebarDark?: boolean; pageSidebarDark?: boolean;
/** Deployment-level compact mode: keep only the icon menu rail. */ /** Deployment-level compact mode: keep only the icon menu rail. */
forceWorkspaceSidebarCollapsed?: boolean; forceWorkspaceSidebarCollapsed?: boolean;
/** Enables wx's shared Agent Gallery category selection. */
wxCommonAgents?: boolean;
}) { }) {
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页 // 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。 // (如智能体对话,为聊天区更宽而默认收起)维持原默认。
const pathname = useLocation().pathname; const pathname = useLocation().pathname;
const isGeneralQa = pathname.startsWith("/page/workspace/chats"); const isGeneralQa =
pathname.startsWith("/page/workspace/chats") ||
pathname.startsWith("/page/workspace/wx/") ||
pathname.startsWith("/page/workspace/projects");
const showCompactTopActions = const showCompactTopActions =
headerHidden && headerHidden &&
!compactActionsHidden && !compactActionsHidden &&
@@ -243,6 +248,7 @@ export function WorkspaceLayout({
<WorkspaceSidebar <WorkspaceSidebar
compactChrome={compactWorkspaceSidebar} compactChrome={compactWorkspaceSidebar}
forceCollapsed={forceWorkspaceSidebarCollapsed} forceCollapsed={forceWorkspaceSidebarCollapsed}
wxCommonAgents={wxCommonAgents}
/> />
)} )}
<SidebarInset <SidebarInset
@@ -412,6 +418,7 @@ function ChatThreadEntryRoute({
function AgentAwareChatThreadContent() { function AgentAwareChatThreadContent() {
const { thread_id: threadId } = useParams<{ thread_id: string }>(); const { thread_id: threadId } = useParams<{ thread_id: string }>();
const location = useLocation(); const location = useLocation();
const routeBase = useRouteBase();
const [agentId, setAgentId] = useState<string | null>(null); const [agentId, setAgentId] = useState<string | null>(null);
const [resolved, setResolved] = useState(false); const [resolved, setResolved] = useState(false);
@@ -458,16 +465,133 @@ function AgentAwareChatThreadContent() {
if (threadId && agentId) { if (threadId && agentId) {
return ( return (
<Navigate <Navigate
replace replace
to={`/page/workspace/agents/${encodeURIComponent(agentId)}/chats/${threadId}${location.search}`} to={`${routeBase}/agents/${encodeURIComponent(agentId)}/chats/${threadId}${location.search}`}
/> />
); );
} }
return <ChatPage />; return <ChatPage />;
} }
const WX_CHAT_ROUTE_BASE = "/page/workspace/wx";
type WorkspaceRouteLayoutProps = {
pageSidebarCollapsed: boolean;
pageSidebarHidden: boolean;
workspaceSidebarHidden: boolean;
headerHidden: boolean;
compactWorkspaceSidebar: boolean;
compactActionsHidden: boolean;
pageSidebarDark: boolean;
forceWorkspaceSidebarCollapsed: boolean;
};
/**
* Dedicated full-layout chat page for the wx workflow. It shares the normal
* chat composition but keeps its own route namespace and category-filtered
* common-agents sidebar.
*/
function WxChatLayout({
layoutProps,
children,
}: {
layoutProps: WorkspaceRouteLayoutProps;
children: ReactNode;
}) {
return (
<RouteBaseProvider value={WX_CHAT_ROUTE_BASE}>
<WorkspaceLayout
{...layoutProps}
defaultHistorySidebarOpen
wxCommonAgents
>
{children}
</WorkspaceLayout>
</RouteBaseProvider>
);
}
function WxNewChatEntryRoute({
layoutProps,
}: {
layoutProps: WorkspaceRouteLayoutProps;
}) {
return (
<WxChatLayout layoutProps={layoutProps}>
<ChatRuntime>
<NewChatContent />
</ChatRuntime>
</WxChatLayout>
);
}
function WxChatThreadEntryRoute({
layoutProps,
}: {
layoutProps: WorkspaceRouteLayoutProps;
}) {
return (
<WxChatLayout layoutProps={layoutProps}>
<ChatRuntime>
<AgentAwareChatThreadContent />
</ChatRuntime>
</WxChatLayout>
);
}
function WxAgentChatEntryRoute({
layoutProps,
}: {
layoutProps: WorkspaceRouteLayoutProps;
}) {
const { agent_id: agentId } = useParams<{ agent_id: string }>();
return (
<WxChatLayout layoutProps={layoutProps}>
<ChatRuntime>
<AgentChatPage key={agentId} />
</ChatRuntime>
</WxChatLayout>
);
}
function WxSentimentAgentChatEntryRoute({
layoutProps,
}: {
layoutProps: WorkspaceRouteLayoutProps;
}) {
return (
<WxChatLayout layoutProps={layoutProps}>
<SentimentAgentChatPage />
</WxChatLayout>
);
}
function WxChatsIndexEntryRoute({
layoutProps,
}: {
layoutProps: WorkspaceRouteLayoutProps;
}) {
return (
<WxChatLayout layoutProps={layoutProps}>
<ChatsPage compactChrome />
</WxChatLayout>
);
}
function WxAgentsEntryRoute({
layoutProps,
}: {
layoutProps: WorkspaceRouteLayoutProps;
}) {
return (
<WxChatLayout layoutProps={layoutProps}>
<AgentsPage />
</WxChatLayout>
);
}
/** /**
* `chats_iframe` is reserved for the Brainy postMessage protocol. `from=iframe` * `chats_iframe` is reserved for the Brainy postMessage protocol. `from=iframe`
* and `chatMode=iframe` opt into this page; ordinary iframe/Wujie embedding * and `chatMode=iframe` opt into this page; ordinary iframe/Wujie embedding
@@ -548,6 +672,58 @@ export default function WorkspaceRoutes({
<RouteBaseProvider value="/page/workspace"> <RouteBaseProvider value="/page/workspace">
<Routes> <Routes>
<Route index element={<Navigate to="chats/new" replace />} /> <Route index element={<Navigate to="chats/new" replace />} />
<Route path="wx" element={<Navigate to="chats/new" replace />} />
<Route
path="wx/chats"
element={<WxChatsIndexEntryRoute layoutProps={layoutProps} />}
/>
<Route
path="wx/chats/new"
element={<WxNewChatEntryRoute layoutProps={layoutProps} />}
/>
<Route
path="wx/chats/:thread_id"
element={<WxChatThreadEntryRoute layoutProps={layoutProps} />}
/>
<Route
path="wx/agents"
element={<WxAgentsEntryRoute layoutProps={layoutProps} />}
/>
<Route
path="wx/agents/sentiment-analysis/chats"
element={
isSentimentAgentVisible() ? (
<Navigate
to="/page/workspace/wx/agents/sentiment-analysis/chats/new"
replace
/>
) : (
<Navigate to="/page/workspace/wx/chats/new" replace />
)
}
/>
<Route
path="wx/agents/sentiment-analysis/chats/:thread_id"
element={
isSentimentAgentVisible() ? (
<WxSentimentAgentChatEntryRoute layoutProps={layoutProps} />
) : (
<Navigate to="/page/workspace/wx/chats/new" replace />
)
}
/>
<Route
path="wx/agents/:agent_id/chats/:thread_id"
element={<WxAgentChatEntryRoute layoutProps={layoutProps} />}
/>
<Route
path="projects/*"
element={
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen insetOverflowHidden>
<AnalysisProjectsPage />
</WorkspaceLayout>
}
/>
<Route <Route
path="chats" path="chats"
element={ element={
@@ -684,14 +860,6 @@ export default function WorkspaceRoutes({
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
<Route
path="memory"
element={
<WorkspaceLayout {...layoutProps}>
<MemoryPage />
</WorkspaceLayout>
}
/>
<Route <Route
path="ai-writing/*" path="ai-writing/*"
element={ element={
@@ -0,0 +1,248 @@
import { useCallback, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { MessageSquare, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAgent } from "@/core/agents/hooks";
import { StrategyBoard } from "./StrategyBoard";
import {
PromptInputProvider,
type PromptInputMessage,
} from "@/components/ai-elements/prompt-input";
import {
ArtifactsProvider,
ArtifactTrigger,
} from "@/components/workspace/artifacts";
import { ChatBox } from "@/components/workspace/chats";
import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
import { SubtasksProvider } from "@/core/tasks/context";
import { useThreadSettings } from "@/core/settings";
import {
stopActiveThreadBeforeClarificationReply,
useThreadStream,
} from "@/core/threads/hooks";
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import {
PROJECT_MODES,
projectKeys,
type AnalysisProject,
} from "@/core/analysis-projects/api";
function Conversation({
project,
threadId,
agentId,
onReload,
}: {
project: AnalysisProject;
threadId: string;
agentId: string | null;
onReload: () => void;
}) {
const queryClient = useQueryClient();
const [settings, setSettings] = useThreadSettings(threadId);
const { agent } = useAgent(agentId);
const [showMessages, setShowMessages] = useState(false);
const context = useMemo(
() => ({
model_name: settings.context.model_name,
mode: settings.context.mode,
reasoning_effort: settings.context.reasoning_effort,
writing_mode: project.mode === "solution",
agent_id: agentId ?? undefined,
}),
[
settings.context.model_name,
settings.context.mode,
settings.context.reasoning_effort,
project,
agentId,
],
);
const [thread, sendMessage, isUploading] = useThreadStream({
threadId,
context,
assistantId: agentId || "lead_agent",
runOptions: { onDisconnect: "continue", multitaskStrategy: "reject" },
onFinish: () => {
void queryClient.invalidateQueries({
queryKey: [...projectKeys(), project.id, "threads"],
});
},
});
const { displayThread } = useResearchWritingFeed(threadId, thread);
const submit = useCallback(
(message: PromptInputMessage) => {
if (!thread.isLoading && !isUploading)
void sendMessage(threadId, message);
},
[thread.isLoading, isUploading, sendMessage, threadId],
);
const clarify = useCallback(
async (answer: string) => {
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] });
},
[threadId, thread, sendMessage],
);
const hasMessages = displayThread.messages.length > 0;
const strategyActions = [
["改变约束", "请列出当前策略最关键的可调整约束,并询问我希望修改哪一项。"],
["增加视角", "请建议一个当前尚未覆盖的分析视角;说明价值,并在加入该视角后重新推演。"],
["删除视角", "请指出当前贡献最低或重复的分析视角;说明影响,并在移除后重新推演。"],
["重新推演", "请基于当前对话中的全部条件,按完整 Lens 分析、策略辩论和综合流程重新推演。"],
["挑战当前策略", "请从最有力的反方立场挑战当前推荐策略,识别失败条件,并给出必要的调整。"],
] as const;
return (
<ThreadContext.Provider
value={{
thread: displayThread,
knowledgeReferencesEnabled: true,
}}
>
<div className="min-h-0 flex-1 overflow-hidden">
<ChatBox threadId={threadId} writingMode={project.mode === "solution"}>
<div className="relative flex h-full min-h-0 min-w-0 flex-col">
<div className="flex h-11 shrink-0 items-center justify-between border-b px-4">
<span className="truncate text-sm text-muted-foreground">
{displayThread.values.title || "项目对话"}
<span className="ml-2 text-xs">
· {agent?.name || (agentId ? "所选智能体" : "通用助手")}
</span>
</span>
<div className="flex items-center gap-2">
{project.mode === "strategy" && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowMessages((value) => !value)}
>
{showMessages ? "三层策略视图" : "对话与引用"}
</Button>
)}
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</div>
<div className="min-h-0 flex-1">
{project.mode === "strategy" && !showMessages ? (
<StrategyBoard
messages={displayThread.messages}
loading={thread.isLoading}
lenses={project.strategy_config?.lenses ?? []}
onClarificationSubmit={clarify}
/>
) : hasMessages ? (
<MessageList
threadId={threadId}
thread={displayThread}
className="h-full"
paddingBottom={200}
onClarificationSubmit={clarify}
onEditResubmit={(text) => submit({ text, files: [] })}
/>
) : (
<div className="flex h-full flex-col items-center justify-center px-6 pb-44 text-center">
<MessageSquare className="mb-4 size-8 text-primary" />
<h2 className="text-lg font-medium">
{PROJECT_MODES[project.mode].name}
</h2>
<p className="mt-3 max-w-md text-sm text-muted-foreground">
{PROJECT_MODES[project.mode].description}
。在下方输入问题或要求开始。
</p>
{project.mode === "solution" && (
<p className="mt-2 text-xs text-muted-foreground">
按项目所选报告结构生成,正文完成后自动打开方案画布。
</p>
)}
</div>
)}
</div>
<div className="absolute right-0 bottom-0 left-0 z-20 bg-gradient-to-t from-background via-background to-transparent px-4 pt-4 pb-3">
<div className="mx-auto max-w-3xl">
{project.mode === "strategy" && (
<div className="mb-2 flex gap-1.5 overflow-x-auto pb-1" aria-label="策略快捷操作">
{strategyActions.map(([label, prompt]) => (
<Button
key={label}
type="button"
variant="outline"
size="sm"
className="h-7 shrink-0 rounded-full bg-background/90 px-2.5 text-xs"
disabled={thread.isLoading || isUploading}
onClick={() => submit({ text: prompt, files: [] })}
>
<Sparkles className="mr-1 size-3" />
{label}
</Button>
))}
</div>
)}
{!!thread.error && (
<div role="alert" className="mb-2 text-sm text-destructive">
{thread.error instanceof Error
? thread.error.message
: "本轮问答失败,请重试"}
<Button type="button" variant="outline" size="sm" className="ml-3" onClick={onReload}>
重新加载对话
</Button>
</div>
)}
<InputBox
threadId={threadId}
context={context}
hideContextTools
hideAdminRecommendedQuestions
isNewThread={false}
disabled={isUploading}
status={thread.isLoading ? "streaming" : "ready"}
onSubmit={submit}
onStop={() => void thread.stop()}
onContextChange={(next) =>
setSettings("context", {
...settings.context,
model_name:
typeof next.model_name === "string"
? next.model_name
: undefined,
mode: next.mode,
reasoning_effort: next.reasoning_effort,
})
}
/>
<p className="mt-2 text-center text-xs text-muted-foreground">
项目内持续问答 ·{" "}
{agent?.name || (agentId ? "所选智能体" : "通用助手")}
</p>
</div>
</div>
</div>
</ChatBox>
</div>
</ThreadContext.Provider>
);
}
export function ProjectConversation(props: {
project: AnalysisProject;
threadId: string;
agentId: string | null;
}) {
// Remount all conversation-scoped providers together when switching threads.
const [reloadKey, setReloadKey] = useState(0);
return (
<SubtasksProvider key={reloadKey}>
<DocumentRewritePresentationProvider>
<ArtifactsProvider>
<PromptInputProvider>
<Conversation {...props} onReload={() => setReloadKey((key) => key + 1)} />
</PromptInputProvider>
</ArtifactsProvider>
</DocumentRewritePresentationProvider>
</SubtasksProvider>
);
}
@@ -0,0 +1,215 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useEffect, useState } from "react";
import {
ArrowRight,
BrainCircuit,
Compass,
GitBranch,
Loader2,
Medal,
MessagesSquare,
} from "lucide-react";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { reasoningPlugins } from "@/core/streamdown";
import {
splitStrategySections,
splitStrategySubsections,
pendingStrategyClarification,
strategyRounds,
STRATEGY_COLUMNS,
} from "@/core/analysis-projects/strategy";
const descriptions = [
"确认 AI 理解的问题、目标、约束、变量与启用视角",
"各 Lens 独立判断并形成策略,再显式分析观点冲突",
"形成可导出、可执行的策略文档,包含路线图、资源、风险与评估机制",
];
const icons = [Compass, GitBranch, Medal];
export function StrategyBoard({
messages,
loading,
lenses,
onClarificationSubmit,
}: {
messages: Message[];
loading: boolean;
lenses: string[];
onClarificationSubmit: (answer: string) => void | Promise<void>;
}) {
const rounds = strategyRounds(messages);
const clarification = pendingStrategyClarification(messages);
const [customAnswer, setCustomAnswer] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => setCustomAnswer(""), [clarification?.question]);
const answerClarification = async (answer: string) => {
if (!answer.trim() || submitting) return;
setSubmitting(true);
try {
await onClarificationSubmit(answer.trim());
} finally {
setSubmitting(false);
}
};
const displayed = rounds.length
? rounds
: [{ id: "empty", question: "从一个问题开始策略分析", answer: "" }];
return (
<div
className="h-full overflow-y-auto px-4 pt-4 pb-56"
data-testid="strategy-board"
>
{displayed.map((round, index) => {
const parts = splitStrategySections(round.answer);
const active = loading && index === displayed.length - 1;
return (
<article key={round.id} className="mb-6">
<div className="mb-3 flex items-center gap-2">
<span className="rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground">
{index + 1}
</span>
<h2 className="min-w-0 flex-1 whitespace-pre-wrap break-words text-sm font-medium">
{round.question}
</h2>
{active && (
<Loader2 className="size-4 shrink-0 animate-spin text-primary" />
)}
</div>
<div className="grid items-start gap-3 lg:grid-cols-[minmax(220px,0.75fr)_minmax(340px,1.35fr)_minmax(320px,1.2fr)]">
{STRATEGY_COLUMNS.map((title, column) => {
const Icon = icons[column]!;
const subsections = splitStrategySubsections(parts[column]!);
return (
<section
key={title}
aria-label={title}
className="min-w-0 overflow-hidden rounded-xl border bg-card"
>
<header className="border-b bg-muted/30 px-4 py-3">
<h3 className="flex items-center gap-2 text-sm font-medium">
<Icon className="size-4 text-primary" />
{title}
</h3>
<p className="mt-2 text-xs leading-5 text-muted-foreground">
{descriptions[column]}
</p>
</header>
<div className="min-h-44 break-words p-4 text-sm">
{column === 0 && clarification && (
<div className="mb-4 rounded-lg border border-amber-300/70 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-950/30">
<p className="text-xs font-medium text-amber-800 dark:text-amber-300">
待确认条件 · 回答后继续多视角推演
</p>
{clarification.context && (
<p className="mt-2 text-xs leading-5 text-muted-foreground">
{clarification.context}
</p>
)}
<p className="mt-2 text-sm font-medium">{clarification.question}</p>
<div className="mt-3 grid gap-2">
{clarification.options.map((option) => (
<Button
key={option.id}
type="button"
variant="outline"
size="sm"
className="h-auto justify-start whitespace-normal bg-background py-2 text-left"
disabled={submitting || loading}
onClick={() => void answerClarification(option.label)}
>
{option.label}
</Button>
))}
</div>
{clarification.allow_custom && (
<div className="mt-2 flex gap-2">
<Input
aria-label="补充澄清条件"
className="h-8 bg-background text-xs"
value={customAnswer}
onChange={(event) => setCustomAnswer(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void answerClarification(customAnswer);
}}
placeholder="输入其他情况"
/>
<Button
type="button"
size="sm"
className="h-8"
disabled={!customAnswer.trim() || submitting || loading}
onClick={() => void answerClarification(customAnswer)}
>
继续
</Button>
</div>
)}
</div>
)}
{column === 1 && (
<div className="mb-4 flex flex-wrap items-center gap-1.5 rounded-lg bg-muted/40 p-2 text-[11px] text-muted-foreground">
<BrainCircuit className="size-3.5 text-primary" />
<span>当前问题</span>
<ArrowRight className="size-3" />
<span>Lens Manager</span>
<ArrowRight className="size-3" />
{lenses.map((lens) => (
<span key={lens} className="rounded-full border bg-background px-2 py-0.5">
{lens}
</span>
))}
</div>
)}
{parts[column] ? (
column === 1 && subsections.length ? (
<div className="grid gap-3 xl:grid-cols-2">
{subsections.map((section) => {
const debate = /策略辩论/.test(section.title);
return (
<section
key={section.title}
className={debate ? "rounded-lg border border-primary/20 bg-primary/5 p-3 xl:col-span-2" : "rounded-lg border bg-background p-3"}
>
<h4 className="mb-2 flex items-center gap-2 font-medium">
{debate ? <MessagesSquare className="size-4 text-primary" /> : <Compass className="size-4 text-primary" />}
{section.title.replace(/^Lens[::]\s*/, "")}
</h4>
<MarkdownContent
content={section.content}
isLoading={active}
rehypePlugins={reasoningPlugins.rehypePlugins}
className="!h-auto !w-full min-w-0 text-xs [&_table]:text-xs"
/>
</section>
);
})}
</div>
) : (
<MarkdownContent
content={parts[column]!}
isLoading={active}
rehypePlugins={reasoningPlugins.rehypePlugins}
className="!h-auto !w-full min-w-0 text-sm [&_table]:text-xs"
/>
)
) : (
<p className="py-8 text-center text-xs text-muted-foreground">
{active
? "等待本层分析结果…"
: rounds.length
? "本轮尚未输出该层内容"
: "在下方输入问题后开始"}
</p>
)}
</div>
</section>
);
})}
</div>
</article>
);
})}
</div>
);
}
@@ -144,6 +144,16 @@ import {
import { ResearchSandboxPanel } from "./ResearchSandboxPanel"; import { ResearchSandboxPanel } from "./ResearchSandboxPanel";
import { WorkflowConfigButton } from "./WorkflowConfigDialog"; import { WorkflowConfigButton } from "./WorkflowConfigDialog";
import { REPORT_OUTPUT_PATH, useReportSandbox } from "./useReportSandbox"; import { REPORT_OUTPUT_PATH, useReportSandbox } from "./useReportSandbox";
import {
PLANNER_AGENT_ID,
OUTLINE_PLANNER_OPTIONS,
buildCollectAfterPlanPrompt,
hasCollectAfterPlanTurn,
hasCollectorToolTurn,
isCollectAfterPlanPrompt,
normalizeOutlinePlanner,
outlineFromFirstAssistant,
} from "./outline-plan";
const COLLECTOR_AGENT_ID = "deep-research-collector"; const COLLECTOR_AGENT_ID = "deep-research-collector";
/** TDesign Select sentinel: do not send a per-session model override. */ /** TDesign Select sentinel: do not send a per-session model override. */
@@ -637,6 +647,8 @@ function DeepResearchWorkbenchInner() {
const collectorPersistChain = useRef(Promise.resolve()); const collectorPersistChain = useRef(Promise.resolve());
const collectorSessionIdRef = useRef<string | null>(null); const collectorSessionIdRef = useRef<string | null>(null);
const followUpAbortRef = useRef<AbortController | null>(null); const followUpAbortRef = useRef<AbortController | null>(null);
const outlinePlanKeyRef = useRef("");
const pendingCollectAfterPlanRef = useRef("");
const refetchSession = sessionQuery.refetch; const refetchSession = sessionQuery.refetch;
const refetchSessions = sessions.refetch; const refetchSessions = sessions.refetch;
@@ -1095,6 +1107,17 @@ function DeepResearchWorkbenchInner() {
// ── collector-agent conversation stream (normal-chat machinery) ─────────── // ── collector-agent conversation stream (normal-chat machinery) ───────────
const outlinePlannerMode = session
? normalizeOutlinePlanner(session.config?.outline_planner)
: advancedOptions.outlinePlanner;
const sessionOutline = (
session && Object.prototype.hasOwnProperty.call(reportCardOutlineOverrides, session.id)
? reportCardOutlineOverrides[session.id]
: (typeof session?.config?.custom_outline === "string" ? session.config.custom_outline : "")
|| advancedOptions.customOutline
).trim();
const [activeAgentId, setActiveAgentId] = useState(COLLECTOR_AGENT_ID);
const collectorContext = useMemo( const collectorContext = useMemo(
() => { () => {
const skills = normalizeRetrievalSkills( const skills = normalizeRetrievalSkills(
@@ -1105,16 +1128,16 @@ function DeepResearchWorkbenchInner() {
return { return {
...localSettings.context, ...localSettings.context,
model_name: selectedModel ?? localSettings.context.model_name, model_name: selectedModel ?? localSettings.context.model_name,
agent_id: COLLECTOR_AGENT_ID, agent_id: activeAgentId,
...(skills.length > 0 ? { extra_skills: skills } : {}), ...(skills.length > 0 ? { extra_skills: skills } : {}),
}; };
}, },
[advancedOptions.retrievalSkills, localSettings.context, selectedModel, session?.config?.retrieval_skills], [activeAgentId, advancedOptions.retrievalSkills, localSettings.context, selectedModel, session?.config?.retrieval_skills],
); );
const [collectorThread, collectorSendMessage] = useThreadStream({ const [collectorThread, collectorSendMessage] = useThreadStream({
threadId: isChatSession ? collectorThreadId : undefined, threadId: isChatSession ? collectorThreadId : undefined,
assistantId: COLLECTOR_AGENT_ID, assistantId: activeAgentId,
context: collectorContext, context: collectorContext,
}); });
@@ -1125,32 +1148,48 @@ function DeepResearchWorkbenchInner() {
() => hasPendingClarification(collectorThread.messages), () => hasPendingClarification(collectorThread.messages),
[collectorThread.messages], [collectorThread.messages],
); );
const collectionAlreadyStarted = hasCollectAfterPlanTurn(collectorThread.messages, extractContentFromMessage)
|| hasCollectorToolTurn(collectorThread.messages);
const isPlanningOutline = outlinePlannerMode === "agent"
&& !sessionOutline
&& !collectionAlreadyStarted
&& isChatSession;
const sendCollectorMessage = useCallback( const sendCollectorMessage = useCallback(
async (text: string, options?: { silentStatusPatch?: boolean }) => { async (text: string, options?: {
silentStatusPatch?: boolean;
hideFromUI?: boolean;
withDirections?: boolean;
withSkills?: boolean;
}) => {
const threadId = session?.runtimeThreadId; const threadId = session?.runtimeThreadId;
const trimmed = text.trim(); const trimmed = text.trim();
if (!threadId || !trimmed) return; if (!threadId || !trimmed) return;
if (!options?.silentStatusPatch && session && session.status !== "collecting") { if (!options?.silentStatusPatch && session && session.status !== "collecting") {
void actions.updateStatus.mutate({ sessionId: session.id, status: "collecting" }); void actions.updateStatus.mutate({ sessionId: session.id, status: "collecting" });
} }
const isFirstTurn = (collectorThread.messages?.length ?? 0) === 0; const useDirections = options?.withDirections === true
const payload = isFirstTurn || (options?.withDirections !== false && (collectorThread.messages?.length ?? 0) === 0 && !isPlanningOutline);
? composeCollectorQueryWithDirections( const useSkills = options?.withSkills === true || (options?.withSkills !== false && useDirections);
trimmed, const payload = composeCollectorQueryWithDirections(
advancedOptions.retrievalDirections.length > 0 trimmed,
useDirections
? (advancedOptions.retrievalDirections.length > 0
? advancedOptions.retrievalDirections ? advancedOptions.retrievalDirections
: session?.config?.retrieval_directions, : session?.config?.retrieval_directions)
advancedOptions.retrievalSkills.length > 0 : [],
useSkills
? (advancedOptions.retrievalSkills.length > 0
? advancedOptions.retrievalSkills ? advancedOptions.retrievalSkills
: session?.config?.retrieval_skills, : session?.config?.retrieval_skills)
) : [],
: trimmed; );
try { try {
await collectorSendMessage( await collectorSendMessage(
threadId, threadId,
{ text: payload, files: [] }, { text: payload, files: [] },
selectedModel ? { model_name: selectedModel } : undefined, selectedModel ? { model_name: selectedModel } : undefined,
options?.hideFromUI ? { additionalKwargs: { hide_from_ui: true } } : undefined,
); );
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "发送失败,请重试"); toast.error(error instanceof Error ? error.message : "发送失败,请重试");
@@ -1162,6 +1201,7 @@ function DeepResearchWorkbenchInner() {
advancedOptions.retrievalSkills, advancedOptions.retrievalSkills,
collectorSendMessage, collectorSendMessage,
collectorThread.messages?.length, collectorThread.messages?.length,
isPlanningOutline,
selectedModel, selectedModel,
session, session,
], ],
@@ -1176,26 +1216,46 @@ function DeepResearchWorkbenchInner() {
[collectorThread, sendCollectorMessage, session?.runtimeThreadId], [collectorThread, sendCollectorMessage, session?.runtimeThreadId],
); );
// The very first research topic: auto-sent into the freshly pre-built useEffect(() => {
// collector thread once the session (and its checkpoint) is ready. if (!session?.id) {
setActiveAgentId(COLLECTOR_AGENT_ID);
return;
}
const shouldPlanFirst = normalizeOutlinePlanner(session.config?.outline_planner) === "agent"
&& !String(session.config?.custom_outline ?? "").trim()
&& !Object.prototype.hasOwnProperty.call(reportCardOutlineOverrides, session.id);
setActiveAgentId(shouldPlanFirst ? PLANNER_AGENT_ID : COLLECTOR_AGENT_ID);
}, [reportCardOutlineOverrides, session?.config?.custom_outline, session?.config?.outline_planner, session?.id]);
// The very first research topic: auto-sent once the runtime thread is ready.
// 「提纲预编制」先发给规划员;规划完成后再切到收集员。
useEffect(() => { useEffect(() => {
const query = pendingInitialQuery.current; const query = pendingInitialQuery.current;
if (!query || !isChatSession || !collectorThreadId) return; if (!query || !isChatSession || !collectorThreadId) return;
if (collectorThreadLoading || collectorStreaming || collectorMessagesLength > 0) return; if (collectorThreadLoading || collectorStreaming || collectorMessagesLength > 0) return;
const planFirst = outlinePlannerMode === "agent" && !sessionOutline;
if (planFirst && activeAgentId !== PLANNER_AGENT_ID) return;
if (!planFirst && activeAgentId !== COLLECTOR_AGENT_ID) return;
pendingInitialQuery.current = null; pendingInitialQuery.current = null;
if (session && session.status === "draft") { if (session && session.status === "draft") {
void actions.updateStatus.mutate({ sessionId: session.id, status: "collecting" }); void actions.updateStatus.mutate({ sessionId: session.id, status: "collecting" });
} }
void sendCollectorMessage(query, { silentStatusPatch: true }); void sendCollectorMessage(query, {
silentStatusPatch: true,
withDirections: !planFirst,
});
}, [ }, [
actions.updateStatus, actions.updateStatus,
activeAgentId,
collectorMessagesLength, collectorMessagesLength,
collectorStreaming, collectorStreaming,
collectorThreadLoading, collectorThreadLoading,
collectorThreadId, collectorThreadId,
isChatSession, isChatSession,
outlinePlannerMode,
sendCollectorMessage, sendCollectorMessage,
session, session,
sessionOutline,
]); ]);
// Persist each completed search as it lands in the step list, instead of // Persist each completed search as it lands in the step list, instead of
@@ -1258,45 +1318,120 @@ function DeepResearchWorkbenchInner() {
session?.status, session?.status,
]); ]);
// Turn finished without a pending clarification → surface the config card. // Turn finished without a pending clarification → either ask the collector
// to plan the outline in the message list, or surface the config card.
// Requiring a non-human tail closes the window between the optimistic user // Requiring a non-human tail closes the window between the optimistic user
// message and the run flag flipping, where the turn would look "ended". // message and the run flag flipping, where the turn would look "ended".
const collectorMessages = collectorThread.messages; const collectorMessages = collectorThread.messages;
const collectorTailIsHuman = collectorMessagesLength > 0 const collectorTailIsHuman = collectorMessagesLength > 0
&& collectorMessages?.[collectorMessagesLength - 1]?.type === "human"; && collectorMessages?.[collectorMessagesLength - 1]?.type === "human";
const showReportConfigCard = isAwaitingReport && !isPlanningOutline;
const persistLeftoverCollectorMaterials = useCallback(() => {
if (!session) return [] as Record<string, unknown>[];
const leftover = snapshotCollectorMaterials(collectorThread.messages, {
limit: COLLECTOR_PERSIST_LIMIT,
}).filter((item) => {
const materialKey = collectorMaterialKey(item);
if (!materialKey || persistedCollectorMaterialKeys.current.has(materialKey)) return false;
persistedCollectorMaterialKeys.current.add(materialKey);
return true;
});
persistedCollectorKey.current = `${session.id}:${persistedCollectorMaterialKeys.current.size}`;
return leftover;
}, [collectorThread.messages, session]);
const applyPlannerOutline = useCallback((outline: string) => {
if (!session || !outline.trim()) return;
const next = outline.trim();
setReportCardOutlineOverrides((current) => (
current[session.id] === next ? current : { ...current, [session.id]: next }
));
setAdvancedOptions((current) => (
current.customOutline === next ? current : { ...current, customOutline: next }
));
void actions.updateStatus.mutate({
sessionId: session.id,
status: session.status === "awaiting_report" ? "awaiting_report" : "collecting",
customOutline: next,
});
}, [actions.updateStatus, session]);
useEffect(() => {
if (!isPlanningOutline || collectorStreaming || collectorTailIsHuman) return;
if (!session || collectorMessagesLength === 0) return;
const planned = outlineFromFirstAssistant(collectorMessages, extractContentFromMessage);
if (!planned) return;
applyPlannerOutline(planned);
}, [
applyPlannerOutline,
collectorMessages,
collectorMessagesLength,
collectorStreaming,
collectorTailIsHuman,
isPlanningOutline,
session,
]);
useEffect(() => {
if (!isChatSession || !session || collectorStreaming || collectorThreadLoading) return;
if (outlinePlannerMode !== "agent" || collectionAlreadyStarted) return;
if (!sessionOutline || activeAgentId !== COLLECTOR_AGENT_ID) return;
if (collectorPendingClarification || collectorTailIsHuman) return;
const planned = sessionOutline || outlineFromFirstAssistant(collectorMessages, extractContentFromMessage);
if (!planned) return;
const key = `${session.id}:collect:${planned}`;
if (pendingCollectAfterPlanRef.current === key) return;
pendingCollectAfterPlanRef.current = key;
void sendCollectorMessage(buildCollectAfterPlanPrompt(session.query, planned), {
silentStatusPatch: true,
hideFromUI: true,
withDirections: false,
withSkills: true,
});
}, [
activeAgentId,
collectionAlreadyStarted,
collectorMessages,
collectorPendingClarification,
collectorStreaming,
collectorTailIsHuman,
collectorThreadLoading,
isChatSession,
outlinePlannerMode,
sendCollectorMessage,
session,
sessionOutline,
]);
useEffect(() => { useEffect(() => {
if (!isChatSession || collectorStreaming || collectorMessagesLength === 0) return; if (!isChatSession || collectorStreaming || collectorMessagesLength === 0) return;
if (collectorTailIsHuman) return; if (collectorTailIsHuman) return;
const status = session?.status; const status = session?.status;
if (status !== "collecting" && status !== "draft") return; if (status !== "collecting" && status !== "draft") return;
if (collectorPendingClarification) return; if (collectorPendingClarification || isPlanningOutline) return;
if (outlinePlannerMode === "agent" && !collectionAlreadyStarted) return;
const key = `${session?.id ?? ""}:${collectorMessagesLength}`; const key = `${session?.id ?? ""}:${collectorMessagesLength}`;
if (finishedTurnKey.current === key) return; if (finishedTurnKey.current === key) return;
finishedTurnKey.current = key; finishedTurnKey.current = key;
if (session) { if (!session) return;
const leftover = snapshotCollectorMaterials(collectorThread.messages, { const leftover = persistLeftoverCollectorMaterials();
limit: COLLECTOR_PERSIST_LIMIT, void actions.updateStatus.mutate({
}).filter((item) => { sessionId: session.id,
const materialKey = collectorMaterialKey(item); status: "awaiting_report",
if (!materialKey || persistedCollectorMaterialKeys.current.has(materialKey)) return false; ...(leftover.length ? { collectorMaterials: leftover } : {}),
persistedCollectorMaterialKeys.current.add(materialKey); });
return true;
});
persistedCollectorKey.current = `${session.id}:${persistedCollectorMaterialKeys.current.size}`;
void actions.updateStatus.mutate({
sessionId: session.id,
status: "awaiting_report",
...(leftover.length ? { collectorMaterials: leftover } : {}),
});
}
}, [ }, [
actions.updateStatus, actions.updateStatus,
collectionAlreadyStarted,
collectorMessagesLength, collectorMessagesLength,
collectorPendingClarification, collectorPendingClarification,
collectorStreaming, collectorStreaming,
collectorTailIsHuman, collectorTailIsHuman,
collectorThread.messages,
isChatSession, isChatSession,
isPlanningOutline,
outlinePlannerMode,
persistLeftoverCollectorMaterials,
session, session,
]); ]);
@@ -1349,7 +1484,9 @@ function DeepResearchWorkbenchInner() {
// 普通消息列表的 tool-call 分组,摘要从生成开始就是普通 assistant 消息并随 SSE // 普通消息列表的 tool-call 分组,摘要从生成开始就是普通 assistant 消息并随 SSE
// 增量刷新。这样两者都使用 Q&A 原有的消息流与步骤条,而不是在列表中再起一套卡片式会话。 // 增量刷新。这样两者都使用 Q&A 原有的消息流与步骤条,而不是在列表中再起一套卡片式会话。
const collectorDisplayThread = useMemo(() => { const collectorDisplayThread = useMemo(() => {
const original = collectorThread.messages ?? []; const original = (collectorThread.messages ?? []).filter((message) => (
message.type !== "human" || !isCollectAfterPlanPrompt(extractContentFromMessage(message))
));
const base = [...original]; const base = [...original];
const followUps = messages.data ?? []; const followUps = messages.data ?? [];
const sessionId = session?.id ?? "draft"; const sessionId = session?.id ?? "draft";
@@ -1492,7 +1629,7 @@ function DeepResearchWorkbenchInner() {
} }
}; };
if (isAwaitingReport) pushActionCard("config"); if (showReportConfigCard) pushActionCard("config");
if (generatedReportVariants.length > 0 && session) { if (generatedReportVariants.length > 0 && session) {
// Keep the original conversation intact, then append each independent // Keep the original conversation intact, then append each independent
// generation in chronological order beneath it. Persisted children make // generation in chronological order beneath it. Persisted children make
@@ -1537,14 +1674,13 @@ function DeepResearchWorkbenchInner() {
}); });
} }
if (base.length === original.length) return collectorThread;
return { ...collectorThread, messages: base } as typeof collectorThread; return { ...collectorThread, messages: base } as typeof collectorThread;
}, [ }, [
collectorThread, collectorThread,
generatedReportVariants, generatedReportVariants,
inlineGeneratedReport, inlineGeneratedReport,
inlineGenerationSession, inlineGenerationSession,
isAwaitingReport, showReportConfigCard,
isRunning, isRunning,
isReportVariant, isReportVariant,
materialCount, materialCount,
@@ -1766,6 +1902,10 @@ function DeepResearchWorkbenchInner() {
const handleNewResearch = useCallback(() => { const handleNewResearch = useCallback(() => {
followUpAbortRef.current?.abort(); followUpAbortRef.current?.abort();
followUpAbortRef.current = null; followUpAbortRef.current = null;
setAdvancedOptions(DEFAULT_ADVANCED_OPTIONS);
outlinePlanKeyRef.current = "";
pendingCollectAfterPlanRef.current = "";
setActiveAgentId(COLLECTOR_AGENT_ID);
setSelectedId(null); setSelectedId(null);
setDownloadTargetSession(null); setDownloadTargetSession(null);
setInlineGeneratedReport(null); setInlineGeneratedReport(null);
@@ -2352,6 +2492,7 @@ function DeepResearchWorkbenchInner() {
} else if (session && isChatSession) { } else if (session && isChatSession) {
if (jobWriting) composerPlaceholder = "报告撰写中;完成后可继续追问(撰写中点提交 = 停止)"; if (jobWriting) composerPlaceholder = "报告撰写中;完成后可继续追问(撰写中点提交 = 停止)";
else if (isFollowUp) composerPlaceholder = "基于当前报告继续追问,例如:哪些结论还需要补充验证?"; else if (isFollowUp) composerPlaceholder = "基于当前报告继续追问,例如:哪些结论还需要补充验证?";
else if (isPlanningOutline) composerPlaceholder = "正在根据课题拟定报告大纲,完成后将开始收集资料";
else if (collectorMessagesLength > 0) composerPlaceholder = "补充要求或新的检索方向,例如:再查一下 2025 年的数据"; else if (collectorMessagesLength > 0) composerPlaceholder = "补充要求或新的检索方向,例如:再查一下 2025 年的数据";
} }
// streaming 状态下提交按钮变「停止」:点击 → 停止当前流(写作 job / 收集员)。 // streaming 状态下提交按钮变「停止」:点击 → 停止当前流(写作 job / 收集员)。
@@ -2693,6 +2834,30 @@ function DeepResearchWorkbenchInner() {
</Select> </Select>
</> </>
)} )}
{!advancedOptions.customOutline.trim() && (
<Select
value={outlinePlannerMode}
onValueChange={(next) => setAdvancedOptions((current) => (
current.outlinePlanner === next ? current : { ...current, outlinePlanner: normalizeOutlinePlanner(next) }
))}
disabled={Boolean(session) || Boolean(isFollowUp) || jobWriting || collectorStreaming}
>
<SelectTrigger
className="h-7 min-w-[9rem] max-w-[12rem] border border-input bg-background px-2 text-xs shadow-xs outline-none"
aria-label="提纲生成策略"
title={OUTLINE_PLANNER_OPTIONS.find((item) => item.value === outlinePlannerMode)?.hint}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{OUTLINE_PLANNER_OPTIONS.map((item) => (
<SelectItem key={item.value} value={item.value} title={item.hint}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
{composerClassic && imageGenerationAvailable && !isFollowUp && ( {composerClassic && imageGenerationAvailable && !isFollowUp && (
@@ -32,6 +32,10 @@ import {
type ResearchMode, type ResearchMode,
type ResearchTone, type ResearchTone,
} from "@/strategy-components/api/deep-research"; } from "@/strategy-components/api/deep-research";
import {
type OutlinePlannerMode,
normalizeOutlinePlanner,
} from "./outline-plan";
import { import {
DEFAULT_REPORT_STRUCTURE_MODE, DEFAULT_REPORT_STRUCTURE_MODE,
normalizeReportStructureMode, normalizeReportStructureMode,
@@ -76,6 +80,7 @@ export type AdvancedResearchOptions = {
structureMode: ReportStructureMode; structureMode: ReportStructureMode;
retrievalDirections: string[]; retrievalDirections: string[];
retrievalSkills: string[]; retrievalSkills: string[];
outlinePlanner: OutlinePlannerMode;
}; };
/** /**
@@ -195,8 +200,16 @@ export const PROGRESSIVE_REPORT_OUTLINE = `# 报告标题
> 写作要求:除引言外,每个二级小节写 2–5 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。`; > 写作要求:除引言外,每个二级小节写 2–5 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。`;
const CUSTOM_REPORT_STRUCTURE_VALUE = "__custom_report_structure__"; const CUSTOM_REPORT_STRUCTURE_VALUE = "__custom_report_structure__";
const AUTO_REPORT_STRUCTURE_VALUE = "__auto_intent_outline__";
const REPORT_STRUCTURE_VALUE_PREFIX = "structure:"; const REPORT_STRUCTURE_VALUE_PREFIX = "structure:";
function resolveSessionOutline(config?: Record<string, unknown>): string {
if (!config) return "";
if (typeof config.custom_outline === "string") return config.custom_outline;
if (Object.prototype.hasOwnProperty.call(config, "custom_outline")) return "";
return "";
}
function matchingReportStructureId( function matchingReportStructureId(
outline: string, outline: string,
structures: Array<{ id: string; content: string }>, structures: Array<{ id: string; content: string }>,
@@ -233,12 +246,14 @@ export function ReportStructureSelect({
const selectedStructureId = matchingReportStructureId(value, structures); const selectedStructureId = matchingReportStructureId(value, structures);
const templateValue = selectedStructureId const templateValue = selectedStructureId
? `${REPORT_STRUCTURE_VALUE_PREFIX}${selectedStructureId}` ? `${REPORT_STRUCTURE_VALUE_PREFIX}${selectedStructureId}`
: CUSTOM_REPORT_STRUCTURE_VALUE; : value.trim()
? CUSTOM_REPORT_STRUCTURE_VALUE
: AUTO_REPORT_STRUCTURE_VALUE;
const selectTemplate = (next: string) => { const selectTemplate = (next: string) => {
if (next === CUSTOM_REPORT_STRUCTURE_VALUE) { if (next === AUTO_REPORT_STRUCTURE_VALUE || next === CUSTOM_REPORT_STRUCTURE_VALUE) {
// “自定义” is an intentional blank starting point. Saved structures are // Empty outline means the writer will generate a topic-specific structure
// always available as one-click scaffolds whenever the user wants them. // after collection. “自定义” stays a blank editor starting point.
onChange(""); onChange("");
onStructureModeChange?.(DEFAULT_REPORT_STRUCTURE_MODE); onStructureModeChange?.(DEFAULT_REPORT_STRUCTURE_MODE);
onRetrievalDirectionsChange?.([]); onRetrievalDirectionsChange?.([]);
@@ -272,6 +287,7 @@ export function ReportStructureSelect({
{structuresQuery.isError && ( {structuresQuery.isError && (
<SelectItem value="__report_structures_error__" disabled>报告结构库加载失败</SelectItem> <SelectItem value="__report_structures_error__" disabled>报告结构库加载失败</SelectItem>
)} )}
<SelectItem value={AUTO_REPORT_STRUCTURE_VALUE}>根据课题自动生成</SelectItem>
{structures.map((structure) => ( {structures.map((structure) => (
<SelectItem key={structure.id} value={`${REPORT_STRUCTURE_VALUE_PREFIX}${structure.id}`}> <SelectItem key={structure.id} value={`${REPORT_STRUCTURE_VALUE_PREFIX}${structure.id}`}>
{structure.title} {structure.title}
@@ -283,7 +299,9 @@ export function ReportStructureSelect({
<p className="mt-1 text-[11px] leading-4 text-muted-foreground"> <p className="mt-1 text-[11px] leading-4 text-muted-foreground">
{selectedStructureId {selectedStructureId
? "已选工作流结构;可在高级配置中继续编辑章节内容。" ? "已选工作流结构;可在高级配置中继续编辑章节内容。"
: "当前使用自定义结构;可在高级配置中编辑章节内容。"} : value.trim()
? "当前为课题规划/自定义结构,可在下方直接改章节。"
: "未选用预设结构时,可在输入区选择提纲预编制或成文结构生成。"}
</p> </p>
</div> </div>
); );
@@ -397,10 +415,11 @@ export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
maxRevisionLoops: "2", maxRevisionLoops: "2",
maxGeneratedImages: "2", maxGeneratedImages: "2",
thinkingEnabled: false, thinkingEnabled: false,
customOutline: DEFAULT_REPORT_OUTLINE, customOutline: "",
structureMode: DEFAULT_REPORT_STRUCTURE_MODE, structureMode: DEFAULT_REPORT_STRUCTURE_MODE,
retrievalDirections: [], retrievalDirections: [],
retrievalSkills: [], retrievalSkills: [],
outlinePlanner: "agent",
}; };
/** Seed the card's editable options from the frozen session config snapshot. */ /** Seed the card's editable options from the frozen session config snapshot. */
@@ -422,12 +441,11 @@ export function advancedOptionsFromConfig(config?: Record<string, unknown>): Adv
maxRevisionLoops: numberish("max_revision_loops", DEFAULT_ADVANCED_OPTIONS.maxRevisionLoops), maxRevisionLoops: numberish("max_revision_loops", DEFAULT_ADVANCED_OPTIONS.maxRevisionLoops),
maxGeneratedImages: numberish("max_generated_images", DEFAULT_ADVANCED_OPTIONS.maxGeneratedImages), maxGeneratedImages: numberish("max_generated_images", DEFAULT_ADVANCED_OPTIONS.maxGeneratedImages),
thinkingEnabled: config?.thinking_enabled === true, thinkingEnabled: config?.thinking_enabled === true,
customOutline: typeof config?.custom_outline === "string" && config.custom_outline.trim() customOutline: resolveSessionOutline(config),
? config.custom_outline
: DEFAULT_REPORT_OUTLINE,
structureMode: normalizeReportStructureMode(config?.structure_mode), structureMode: normalizeReportStructureMode(config?.structure_mode),
retrievalDirections: normalizeRetrievalDirections(config?.retrieval_directions), retrievalDirections: normalizeRetrievalDirections(config?.retrieval_directions),
retrievalSkills: normalizeRetrievalSkills(config?.retrieval_skills), retrievalSkills: normalizeRetrievalSkills(config?.retrieval_skills),
outlinePlanner: normalizeOutlinePlanner(config?.outline_planner),
}; };
} }
@@ -464,10 +482,11 @@ export function buildReportCardConfig(
config.max_revision_loops = boundedInteger(options.maxRevisionLoops, 2, 0, 5); config.max_revision_loops = boundedInteger(options.maxRevisionLoops, 2, 0, 5);
} }
if (generateImages) config.max_generated_images = boundedInteger(options.maxGeneratedImages, 2, 1, 4); if (generateImages) config.max_generated_images = boundedInteger(options.maxGeneratedImages, 2, 1, 4);
if (options.customOutline.trim()) config.custom_outline = options.customOutline.trim().slice(0, 4_000); config.custom_outline = options.customOutline.trim().slice(0, 4_000);
config.structure_mode = normalizeReportStructureMode(options.structureMode); config.structure_mode = normalizeReportStructureMode(options.structureMode);
config.retrieval_directions = normalizeRetrievalDirections(options.retrievalDirections); config.retrieval_directions = normalizeRetrievalDirections(options.retrievalDirections);
config.retrieval_skills = normalizeRetrievalSkills(options.retrievalSkills); config.retrieval_skills = normalizeRetrievalSkills(options.retrievalSkills);
config.outline_planner = options.outlinePlanner;
return config; return config;
} }
@@ -490,6 +509,7 @@ export function ReportConfigCard({
imageGenerationAvailable, imageGenerationAvailable,
imageLimit, imageLimit,
submitting, submitting,
onRetryPlanOutline,
onConfirm, onConfirm,
onContinueCollecting, onContinueCollecting,
}: { }: {
@@ -507,6 +527,7 @@ export function ReportConfigCard({
imageGenerationAvailable: boolean; imageGenerationAvailable: boolean;
imageLimit: number; imageLimit: number;
submitting: boolean; submitting: boolean;
onRetryPlanOutline?: () => void;
onConfirm: (config: Record<string, unknown>, extras?: { writeWithoutMaterials?: boolean }) => void; onConfirm: (config: Record<string, unknown>, extras?: { writeWithoutMaterials?: boolean }) => void;
onContinueCollecting: () => void; onContinueCollecting: () => void;
}) { }) {
@@ -571,6 +592,9 @@ export function ReportConfigCard({
<p className="text-sm font-semibold">资料收集完成,请确认报告写法</p> <p className="text-sm font-semibold">资料收集完成,请确认报告写法</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground"> <p className="mt-1 text-xs leading-5 text-muted-foreground">
下方选项沿用原研究配置并可调整;确认后将基于以上对话收集到的资料撰写报告。 下方选项沿用原研究配置并可调整;确认后将基于以上对话收集到的资料撰写报告。
{options.customOutline.trim()
? "报告结构已由收集员在对话中规划并填入下方,可继续增删改章节后再撰写。"
: "可在此填写或改章节;确认后按此结构写作。"}
{modelLabel ? <>写作模型:<span className="font-medium text-foreground">{modelLabel}</span>。</> : null} {modelLabel ? <>写作模型:<span className="font-medium text-foreground">{modelLabel}</span>。</> : null}
</p> </p>
<div className={`mt-2 inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] ${writeWithoutMaterials ? "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-200" : "border-primary/15 bg-primary/[0.06] text-primary"}`}> <div className={`mt-2 inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] ${writeWithoutMaterials ? "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-200" : "border-primary/15 bg-primary/[0.06] text-primary"}`}>
@@ -624,6 +648,35 @@ export function ReportConfigCard({
)} )}
</div> </div>
<div className="mt-3">
<div className="mb-1.5 flex items-center justify-between gap-2">
<span className="text-xs font-medium">报告结构(可编辑)</span>
{onRetryPlanOutline ? (
<button
type="button"
className="text-[11px] text-primary hover:underline disabled:opacity-50"
disabled={submitting}
onClick={onRetryPlanOutline}
>
重新规划
</button>
) : null}
</div>
<div className="relative">
<Textarea
id="report-card-outline-main"
value={options.customOutline}
onChange={(event) => update("customOutline", event.target.value)}
maxLength={4000}
className="min-h-32 resize-y font-mono text-xs"
placeholder={"例如:\n# 引言\n# 一、问题与事实\n## 1.1 范围与依据\n# 结论"}
/>
</div>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
支持 Markdown 标题。规划结果可增删改章节,确认撰写后按此结构写作。
</p>
</div>
<label className="mt-3 flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4"> <label className="mt-3 flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4">
<input <input
type="checkbox" type="checkbox"
@@ -682,18 +735,9 @@ export function ReportConfigCard({
{mode === "multi_agent" && ( {mode === "multi_agent" && (
<CardNumber label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(v) => update("maxRevisionLoops", v)} /> <CardNumber label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(v) => update("maxRevisionLoops", v)} />
)} )}
<div className="sm:col-span-2"> <p className="text-[11px] leading-4 text-muted-foreground sm:col-span-2">
<ReportOutlineEditor 报告结构正文已在上方表单,可继续选用工作流模板覆盖,或直接改章节标题。
id="report-card-outline" </p>
value={options.customOutline}
onChange={(value) => update("customOutline", value)}
structureMode={options.structureMode}
onStructureModeChange={(mode) => update("structureMode", mode)}
onRetrievalDirectionsChange={(directions) => update("retrievalDirections", directions)}
onRetrievalSkillsChange={(skills) => update("retrievalSkills", skills)}
showStructureSelect={false}
/>
</div>
<div className="flex justify-end sm:col-span-2"> <div className="flex justify-end sm:col-span-2">
<WorkflowConfigButton <WorkflowConfigButton
appliedContent={options.customOutline} appliedContent={options.customOutline}
@@ -69,6 +69,7 @@ import {
MAX_RETRIEVAL_DIRECTIONS, MAX_RETRIEVAL_DIRECTIONS,
MAX_RETRIEVAL_DIRECTION_CHARS, MAX_RETRIEVAL_DIRECTION_CHARS,
MAX_RETRIEVAL_SKILLS, MAX_RETRIEVAL_SKILLS,
DEFAULT_REPORT_STRUCTURE_MODE,
REPORT_STRUCTURE_MODE_OPTIONS, REPORT_STRUCTURE_MODE_OPTIONS,
normalizeReportStructureMode, normalizeReportStructureMode,
normalizeRetrievalDirections, normalizeRetrievalDirections,
@@ -175,10 +176,12 @@ export function WorkflowConfigDialog({
appliedContent, appliedContent,
/** 点击卡片选择结构时回填内容与结构约束。 */ /** 点击卡片选择结构时回填内容与结构约束。 */
onApply, onApply,
inlineCreate = false,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
appliedContent?: string; appliedContent?: string;
inlineCreate?: boolean;
onApply?: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void; onApply?: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
}) { }) {
const [statusFilter, setStatusFilter] = useState<ReportStructureStatusFilter>("all"); const [statusFilter, setStatusFilter] = useState<ReportStructureStatusFilter>("all");
@@ -235,11 +238,21 @@ export function WorkflowConfigDialog({
[detail, detailOutlineTitle], [detail, detailOutlineTitle],
); );
const handleClear = () => {
onApply?.("", DEFAULT_REPORT_STRUCTURE_MODE, [], []);
toast.success("已取消选用报告结构,撰写时将根据课题自动生成大纲");
};
const handleSelect = (structure: ReportStructure) => { const handleSelect = (structure: ReportStructure) => {
if (!structure.enabled) { if (!structure.enabled) {
toast.error(`「${structure.title}」已停用,请先启用后再选用`); toast.error(`「${structure.title}」已停用,请先启用后再选用`);
return; return;
} }
// 再次点击当前选用的卡片即取消勾选。
if (structure.id === appliedStructureId) {
handleClear();
return;
}
// 点击卡片只做「选择」:内容回填后留在列表视图(卡片高亮为当前使用), // 点击卡片只做「选择」:内容回填后留在列表视图(卡片高亮为当前使用),
// 详情视图通过卡片上的「查看大纲结构」眼睛图标进入。 // 详情视图通过卡片上的「查看大纲结构」眼睛图标进入。
onApply?.(structure.content, structure.structureMode, structure.retrievalDirections, structure.retrievalSkills); onApply?.(structure.content, structure.structureMode, structure.retrievalDirections, structure.retrievalSkills);
@@ -328,7 +341,7 @@ export function WorkflowConfigDialog({
</h2> </h2>
</div> </div>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400 sm:text-[13px]"> <p className="mt-1 text-xs text-slate-500 dark:text-slate-400 sm:text-[13px]">
点击卡片选择报告结构(自动填充到报告结构配置);点击卡片上的眼睛图标查看大纲详情;可启停、编辑、删除。 点击卡片选择报告结构(自动填充到报告结构配置);再次点击已选用的卡片或点「取消选用」可取消勾选,撰写时将按课题自动生成大纲。
</p> </p>
</div> </div>
<Button <Button
@@ -400,6 +413,11 @@ export function WorkflowConfigDialog({
<Button <Button
className="h-10 gap-1.5 rounded-xl bg-[#F5222D] px-4 text-xs font-semibold text-white shadow-xs transition-all duration-150 hover:bg-[#BC0D1E] hover:shadow active:bg-[#A8071A] dark:bg-[#1677ff] dark:hover:bg-[#4096ff] dark:active:bg-[#0958d9] sm:text-sm" className="h-10 gap-1.5 rounded-xl bg-[#F5222D] px-4 text-xs font-semibold text-white shadow-xs transition-all duration-150 hover:bg-[#BC0D1E] hover:shadow active:bg-[#A8071A] dark:bg-[#1677ff] dark:hover:bg-[#4096ff] dark:active:bg-[#0958d9] sm:text-sm"
onClick={() => { onClick={() => {
if (inlineCreate) {
setEditorTarget(null);
setEditorOpen(true);
return;
}
onOpenChange(false); onOpenChange(false);
const base = location.pathname.replace(/\/+$/, ""); const base = location.pathname.replace(/\/+$/, "");
navigate(`${base}/structures/new`); navigate(`${base}/structures/new`);
@@ -543,17 +561,28 @@ export function WorkflowConfigDialog({
</span> </span>
</span> </span>
) : ( ) : (
<span className="text-slate-400">未选择</span> <span className="text-slate-400">未选择,撰写时按课题自动生成</span>
)} )}
</div> </div>
<Button <div className="flex shrink-0 items-center gap-2">
variant="ghost" {appliedStructureId ? (
className="h-9 rounded-xl px-4 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-[#1c2d4a]" <Button
onClick={() => onOpenChange(false)} variant="ghost"
> className="h-9 rounded-xl px-4 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-[#1c2d4a]"
完成 onClick={handleClear}
</Button> >
取消选用
</Button>
) : null}
<Button
variant="ghost"
className="h-9 rounded-xl px-4 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-[#1c2d4a]"
onClick={() => onOpenChange(false)}
>
完成
</Button>
</div>
</div> </div>
</> </>
) : ( ) : (
@@ -1192,6 +1221,7 @@ export function WorkflowConfigButton({
variant = "outline", variant = "outline",
size = "sm", size = "sm",
disabled = false, disabled = false,
inlineCreate = false,
}: { }: {
appliedContent?: string; appliedContent?: string;
onApply: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void; onApply: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
@@ -1199,6 +1229,7 @@ export function WorkflowConfigButton({
variant?: React.ComponentProps<typeof Button>["variant"]; variant?: React.ComponentProps<typeof Button>["variant"];
size?: React.ComponentProps<typeof Button>["size"]; size?: React.ComponentProps<typeof Button>["size"];
disabled?: boolean; disabled?: boolean;
inlineCreate?: boolean;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const location = useLocation(); const location = useLocation();
@@ -1221,22 +1252,39 @@ export function WorkflowConfigButton({
return ( return (
<> <>
<Button <div className={cn("inline-flex items-center", isSelected && "rounded-md bg-accent text-accent-foreground dark:bg-accent/50")}>
aria-label={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"} <Button
className={cn( type="button"
className, aria-label={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
isSelected && "bg-accent text-accent-foreground dark:bg-accent/50", className={cn(className, isSelected && "bg-transparent hover:bg-transparent")}
)} disabled={disabled}
disabled={disabled} onClick={() => setOpen(true)}
onClick={() => setOpen(true)} size={size}
size={size} title={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
title={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"} variant={variant}
variant={variant} >
> <Workflow className="mr-1.5 h-3.5 w-3.5" />
<Workflow className="mr-1.5 h-3.5 w-3.5" /> <span className="max-w-40 truncate">{buttonLabel}</span>
<span className="max-w-40 truncate">{buttonLabel}</span> </Button>
</Button> {isSelected ? (
<button
type="button"
aria-label={`取消选用工作流:${selectedWorkflowTitle}`}
className="mr-1 inline-flex size-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
disabled={disabled}
title="取消选用报告结构"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onApply("", DEFAULT_REPORT_STRUCTURE_MODE, [], []);
}}
>
<X className="h-3 w-3" />
</button>
) : null}
</div>
<WorkflowConfigDialog <WorkflowConfigDialog
inlineCreate={inlineCreate}
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
appliedContent={appliedContent} appliedContent={appliedContent}
Loaded 100 of 370 files, more files were not shown because too many files have changed in this diff. Show more