Compare commits

..
3 Commits
299 changed files with 28160 additions and 4763 deletions

No files matched your search

+14 -7
View File
@@ -88,25 +88,28 @@ skills/
hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
# Keep wx common-agent category configuration regressions versioned.
!offline-backend-20260512/backend/tests/test_wx_common_agents.py
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned.
!offline-backend-20260512/backend/tests/test_gauss_database.py
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
!offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py
!offline-backend-20260512/backend/tests/test_knowledge_live_embedding.py
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
# Keep formal Word OOXML and embedded-font regression coverage versioned.
!offline-backend-20260512/backend/tests/test_word_export.py
# Keep generated-file library aggregation and filtering regression coverage versioned.
!offline-backend-20260512/backend/tests/test_artifact_library.py
# Keep Web App site artifact (multi-page site) regression coverage versioned.
!offline-backend-20260512/backend/tests/test_web_app_sites.py
# Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py
# Keep vLLM reasoning/reasoning_content field compatibility regressions versioned.
!offline-backend-20260512/backend/tests/test_reasoning_fields.py
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
# Keep strategy-selection built-in agent seed and safety-contract regressions versioned.
!offline-backend-20260512/backend/tests/test_strategy_selection_seed.py
# Keep fixed-question exact-match and streaming regression coverage versioned.
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
# Keep strategy-selection built-in agent seed and safety-contract regressions versioned.
!offline-backend-20260512/backend/tests/test_strategy_selection_seed.py
# Keep fixed-question exact-match and streaming regression coverage versioned.
!offline-backend-20260512/backend/tests/test_fixed_questions.py
# Keep configurable es_query system-prompt routing regression coverage versioned.
!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py
@@ -145,6 +148,10 @@ offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py
# LLM call timeout retry classification regression coverage.
!offline-backend-20260512/backend/tests/test_llm_error_handling_middleware.py
# Adaptive context recovery, large-payload and real-adapter streaming regressions.
!offline-backend-20260512/backend/tests/test_context_recovery.py
!offline-backend-20260512/backend/tests/test_context_recovery_transport.py
!offline-backend-20260512/backend/tests/test_summarization_multiturn.py
# Writing-mode pipeline conversation-material regressions.
!offline-backend-20260512/backend/tests/test_deep_research_report_tool.py
# Report collaboration (RC-BE-000–018) regression coverage.
@@ -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
- **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。
- **Web App 产物模式(多页面站点 / 流程图 / 地图,默认关闭、全链路可降级)**: 让智能体产出**完整站点**而非单个 xxx.html。沙箱技能 `skills/public/web-app` 用模板(`backend/templates/web-app-template`,经 `config.yaml sandbox.mounts` 只读挂载到 `/mnt/web-app-template`,目录缺失自动跳过)脚手架到 `outputs/<site>/`(site.json 清单 + `src/pages/*.tsx` 文件名即路由 + `public/` 数据),再跑 `scripts/build_site.py build` 用**预置 node_modules**(symlink/junction 链接,离线永不 npm install)执行 vite build 出 `dist/`;内置组件 `FlowCanvas`(@xyflow/react)/`GeoMap`(MapLibre,默认无底图 GeoJSON 模式,site.json 可配 tilesUrl/PMTiles)/`ChartPanel`(ECharts)/`MermaidBlock`。平台侧 `app/gateway/routers/web_app_sites.py` + 纯引擎 `deerflow/webapp/`(发现/子进程构建/HMAC 签名):`GET /api/web-app/capability`(enabled/模板/依赖/node 四项探测)、`GET|POST /api/threads/{tid}/sites[...]{build,build-status,preview-url}`,静态托管走 `GET /api/public/site-preview/{token}/{path}`(`/api/public/` 免鉴权命名空间,**唯一允许 HTML inline 的出口**——签名令牌 + dist/ 根限定 + 按请求真实 origin 显式声明的 CSP(iframe sandbox 不透明 origin 下 'self' 失效)+ nosniff;artifacts 接口既有的强制 attachment 不变)。前端:**独立风格**预览工作台 `pages/site-studio/SiteStudioPage.tsx`(顶层路由 `#/sites/:threadId/:siteName`,`App.tsx` 注册,不进 PageLayout/WorkspaceLayout;自带 site-studio.css 深色设计体系,零全局样式污染;iframe `sandbox="allow-scripts allow-forms allow-popups allow-modals"` 无 allow-same-origin)+ 产物面板 `artifact-file-detail.tsx` 里 site.json 的「站点预览」按钮(capability 可用才渲染,唯一既有文件改动)。**降级铁律**:默认**完全关闭**(`sandbox.mounts` 的模板挂载条目与文件末尾的 `web_app:` 段在 config.yaml 里**均为注释状态**);开启需**同时放开两处**(挂载让沙箱技能可探测 `/mnt/web-app-template`,`web_app.enabled: true` 开平台 API——只开一处会得到"生成了看不了"或"看不了也不生成"的中间态)。`build_site.py check` 在宿主侧会读 config.yaml 的 `web_app.enabled`(env `DEERFLOW_WEBAPP_ENABLED`/`DEERFLOW_WEBAPP_CONFIG` 可覆盖;沙箱内读不到配置时"挂载即视为开")。任何一环(配置/模板/node_modules/node)缺失 → capability 不可用 + 技能第 0 步探测失败即**回退单页 HTML 模式** + 入口按钮不渲染 + 构建失败只收进任务状态,现有功能零影响。配置 `web_app:`(enabled/template_dir/build_timeout_seconds/preview_token_ttl_seconds/node_path);测试 `tests/test_web_app_sites.py`;开发机 `make webapp-template` 预置依赖,离线发版由 deerflow-offline-deployment 仓库在 CI 预置 node_modules 进镜像。
### Configuration
Backend config lives in `offline-backend-20260512/backend/config.yaml`. Values starting with `$` resolve as environment variables (e.g., `$OPENAI_API_KEY`). MCP servers and skills are configured in `extensions_config.json` in the same directory.
+2
View File
@@ -81,6 +81,8 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
- **普通问答地图可视化**: shared `MarkdownContent` recognizes only dedicated `map-json` fences and otherwise preserves the legacy Markdown/Streamdown path byte-for-byte. `components/workspace/visualizations/` validates the bounded map contract with Zod, lazy-loads MapLibre, and provides loading/error/text fallbacks so malformed or unreachable maps never blank the message. The lead-agent prompt emits `map-json` only when the user explicitly asks for a map; flowcharts and other graphics continue through the existing Markdown/Mermaid path. The raster tile template comes from the single post-build setting `runtime-config.js` → `VITE_MAP_TILE_URL` (Google by default); replace it with the intranet `{z}/{x}/{y}` endpoint for disconnected deployments.
- **HTML 成品离线绘图运行时**: 新生成的 `.html` 源码直接引用 Gateway `/api/public/html-runtime/` 下的浏览器版 `g6.min.js`、`echarts.min.js`、`leaflet.js/css` 和 `deerflow-runtime.js`;文件随后台源码存放于 `vendor/html-runtime/`,不再依赖前端 Vite/node_modules 产物或公网 CDN。`config.yaml -> html_runtime.base_url` 是写入新 HTML 的浏览器可访问绝对地址,`html_runtime.map_tile_url` 是初始化脚本读取的瓦片模板。流程图使用 `data-df-flow` + G6(不用 Mermaid),图表使用 `data-df-chart`,地图使用 Leaflet `data-df-map`。普通问答保持独立:消息列表 `map-json` 继续由前端原逻辑渲染,普通流程图/图表仍走 Markdown/Mermaid。
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API.
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
- **策略优选**: route `/page/strategy/qa/strategy-selection`, sidebar 「问答管理 → 策略优选」. The standalone `src/strategy-selection/` workbench has two horizontal split stages: Step 1 keeps intent-understanding conversation cards on the left and an editable task draft plus angle selection on the right; Step 2 keeps multiplexed parallel strategy cards and automatic judging on the left while the right stays dedicated to the selected-strategy basket and final Markdown synthesis. It consumes the existing `POST /api/parallel-agents/run/stream` protocol, isolates output by stable agent id, supports expanded per-agent dialogs, user selection, Markdown editing/copy/download, and shared server-side DOCX export. A clearly marked neutral Taiwan-Strait risk-management demo is loaded by default for offline UI acceptance. Administrators can edit the SOUL and skills of the nine built-in functional agents through the page dialog; the backend seeds `strategy-selection-{intent,angle-*,judge,solution}` only when their directories are missing, preserving later admin changes. Pure parsing/SSE tests live in `src/strategy-selection/lib.test.ts`; backend seed tests live in `tests/test_strategy_selection_seed.py`.
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 保持不变。未提交、推送代码,也未执行离线发布。
-3
View File
@@ -59,9 +59,6 @@
# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。
# VITE_COMPACT_MENU_TYPE=A
# 问答页「极速」模式:true=在模式菜单显示并直连大模型;false 或未设=隐藏。
# 部署后优先改 dist/runtime-config.js 的 VITE_TURBO_MODE_VISIBLE,刷新即可。
# VITE_TURBO_MODE_VISIBLE=false
# VITE_GITHUB_OAUTH_TOKEN=
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
+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-icons": "^1.3.2",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-scroll-area": "1.2.18",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-slot": "1.3.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
@@ -69,7 +69,9 @@
"gsap": "^3.13.0",
"hast": "^1.0.0",
"katex": "^0.16.28",
"leaflet": "^1.9.4",
"lucide-react": "^0.562.0",
"maplibre-gl": "^6.9.0",
"mermaid": "^11.15.0",
"motion": "^12.26.2",
"nanoid": "^5.1.6",
@@ -1702,6 +1704,92 @@
"@lezer/lr": "^1.4.0"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
"license": "MIT",
"engines": {
"node": ">= 22"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.0.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "6.1.1",
"resolved": "https://registry.npmmirror.com/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.1.0"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "26.4.2",
"resolved": "https://registry.npmmirror.com/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.2.tgz",
"integrity": "sha512-6J0vZqMZvRAJKtdWJdDGHEh1YJ2ZHG08/GOur8gCArYhO8ZkM//OXqtI2AAEz4jc2G+Wq4MfcePg8qNK5TZ4Kg==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
"@mapbox/unitbezier": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/mlt": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/@maplibre/mlt/-/mlt-1.2.1.tgz",
"integrity": "sha512-5n5dgolE2EYxwCKgx8vlwURCB8A+kyfJJyThlYimjlGgOcOe2Bhw9VxxnnHC91OC9PgHg+nVdgVPTYPkIo62vg==",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0"
}
},
"node_modules/@maplibre/vt-pbf": {
"version": "4.3.2",
"resolved": "https://registry.npmmirror.com/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.1.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
@@ -1787,23 +1875,6 @@
}
}
},
"node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-avatar": {
"version": "1.1.11",
"license": "MIT",
@@ -1893,23 +1964,6 @@
}
}
},
"node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
@@ -1971,23 +2025,6 @@
}
}
},
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
@@ -2086,23 +2123,6 @@
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
@@ -2165,23 +2185,6 @@
}
}
},
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu": {
"version": "2.1.16",
"license": "MIT",
@@ -2245,23 +2248,6 @@
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
@@ -2322,23 +2308,6 @@
}
}
},
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card": {
"version": "1.1.15",
"license": "MIT",
@@ -2404,23 +2373,6 @@
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-icons": {
"version": "1.3.2",
"license": "MIT",
@@ -2520,23 +2472,6 @@
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
@@ -2604,23 +2539,6 @@
}
}
},
"node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
@@ -2666,23 +2584,6 @@
}
}
},
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
@@ -2816,36 +2717,21 @@
}
}
},
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.10",
"version": "1.2.18",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz",
"integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1"
"@radix-ui/number": "1.1.3",
"@radix-ui/primitive": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-context": "1.2.2",
"@radix-ui/react-direction": "1.1.4",
"@radix-ui/react-presence": "1.1.10",
"@radix-ui/react-primitive": "2.1.10",
"@radix-ui/react-use-callback-ref": "1.1.4",
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
@@ -2862,10 +2748,38 @@
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/number": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz",
"integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
"integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
"license": "MIT"
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz",
"integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
@@ -2876,12 +2790,28 @@
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-direction": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz",
"integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz",
"integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
@@ -2898,13 +2828,49 @@
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": {
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz",
"integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
"@radix-ui/react-slot": "1.3.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz",
"integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
@@ -2992,23 +2958,6 @@
}
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.8",
"license": "MIT",
@@ -3031,10 +2980,12 @@
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.4",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
"integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
"@radix-ui/react-compose-refs": "1.1.5"
},
"peerDependencies": {
"@types/react": "*",
@@ -3046,6 +2997,21 @@
}
}
},
"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.2.6",
"license": "MIT",
@@ -3109,23 +3075,6 @@
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"license": "MIT",
@@ -3190,23 +3139,6 @@
}
}
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle": {
"version": "1.1.10",
"license": "MIT",
@@ -3293,23 +3225,6 @@
}
}
},
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
@@ -3332,23 +3247,6 @@
}
}
},
"node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"license": "MIT",
@@ -3417,23 +3315,6 @@
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -3622,23 +3503,6 @@
}
}
},
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
@@ -5557,6 +5421,15 @@
"version": "1.2.1",
"license": "BSD-2-Clause"
},
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
@@ -6459,6 +6332,12 @@
"node": ">= 0.4"
}
},
"node_modules/earcut": {
"version": "3.2.3",
"resolved": "https://registry.npmmirror.com/earcut/-/earcut-3.2.3.tgz",
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
"license": "ISC"
},
"node_modules/electron-to-chromium": {
"version": "1.5.349",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
@@ -6853,6 +6732,12 @@
"node": ">= 0.4"
}
},
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmmirror.com/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -7351,6 +7236,12 @@
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="
},
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -7390,6 +7281,12 @@
"katex": "cli.js"
}
},
"node_modules/kdbush": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/kdbush/-/kdbush-4.1.0.tgz",
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
"license": "ISC"
},
"node_modules/khroma": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
@@ -7432,6 +7329,12 @@
"resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz",
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmmirror.com/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
@@ -7766,6 +7669,39 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/maplibre-gl": {
"version": "6.9.0",
"resolved": "https://registry.npmmirror.com/maplibre-gl/-/maplibre-gl-6.9.0.tgz",
"integrity": "sha512-vFMwMK0Zs+NM/rOMSdtu8bO30DIexhBEVi5KC6f70/XtI+L/K2wC3LsDCAXFZ4s8ik5gAuDugfCNbpllpdJ9bA==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.2.0",
"@mapbox/unitbezier": "^1.0.0",
"@mapbox/vector-tile": "^3.0.0",
"@maplibre/geojson-vt": "^6.1.1",
"@maplibre/maplibre-gl-style-spec": "^26.4.2",
"@maplibre/mlt": "^1.2.1",
"@maplibre/vt-pbf": "^4.3.2",
"@types/geojson": "^7946.0.16",
"bidi-js": "^1.1.0",
"earcut": "^3.2.3",
"gl-matrix": "^3.4.4",
"kdbush": "^4.1.0",
"murmurhash-js": "^1.0.0",
"pbf": "^5.1.2",
"potpack": "^2.1.0",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/markdown-it": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
@@ -8713,6 +8649,15 @@
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
@@ -8771,6 +8716,12 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/mustache": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
@@ -8943,6 +8894,18 @@
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
},
"node_modules/pbf": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/pbf/-/pbf-5.1.2.tgz",
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
@@ -9035,6 +8998,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
@@ -9218,6 +9187,12 @@
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmmirror.com/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -9235,6 +9210,12 @@
"node": ">=6"
}
},
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
@@ -9618,6 +9599,24 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/robust-predicates": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
@@ -9971,6 +9970,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/tiptap-markdown": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz",
+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",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test:input-mode": "node --experimental-strip-types --test src/components/workspace/input-mode.test.ts",
"test:html-preview": "pnpm exec esbuild src/core/utils/legacy-html-preview.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/legacy-html-preview.test.mjs && node --test .runtime/unit-tests/legacy-html-preview.test.mjs",
"test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts",
"test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
@@ -36,10 +39,10 @@
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-scroll-area": "1.2.18",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-slot": "1.3.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
@@ -77,7 +80,9 @@
"gsap": "^3.13.0",
"hast": "^1.0.0",
"katex": "^0.16.28",
"leaflet": "^1.9.4",
"lucide-react": "^0.562.0",
"maplibre-gl": "^6.9.0",
"mermaid": "^11.15.0",
"motion": "^12.26.2",
"nanoid": "^5.1.6",
@@ -121,5 +126,8 @@
"vite": "^6.0.0",
"vite-plugin-source-identifier": "^1.1.2"
},
"overrides": {
"@radix-ui/react-slot": "1.3.3"
},
"packageManager": "pnpm@10.26.2"
}
+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 -5
View File
@@ -40,6 +40,10 @@ window.__CM_RUNTIME_CONFIG__ = {
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
// 地图瓦片地址模板:普通问答地图和生成的 HTML 网页地图共同使用。
// 后续接入内网瓦片服务时,只需替换此项并保留 {z}/{x}/{y} 占位符。
VITE_MAP_TILE_URL: "http://119.254.86.251:8023/tiles/{z}/{x}/{y}.png?v=zh-z14-13",
// routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。
// 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。
VITE_ROUTE_LOGIN_PASSWORD: "",
@@ -151,11 +155,6 @@ window.__CM_RUNTIME_CONFIG__ = {
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
VITE_GITHUB_OAUTH_TOKEN: "",
// 是否在问答输入框「模式」菜单显示「极速」:"true" 显示,"false" 隐藏。
// 默认关闭。开启后可直连大模型流式问答,不走智能体工具与技能。
// 部署后改 dist/runtime-config.js 再刷新页面即可,无需重新打包。
VITE_TURBO_MODE_VISIBLE: "true",
// 是否在智能体管理页显示「舆情分析」类型:"true" 显示,"false" 隐藏。
// 关闭后,该智能体的专用对话地址也会自动跳回智能体管理页。
VITE_SENTIMENT_AGENT_VISIBLE: "true",
+3
View File
@@ -36,6 +36,7 @@ import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
import EmbedChatPage from "./pages/EmbedChatPage";
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
import SiteStudioPage from "./pages/site-studio/SiteStudioPage";
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
@@ -205,6 +206,8 @@ export function App() {
element={<DashboardStandalonePage />}
/>
<Route path="/official/*" element={<OfficialDeerFlowEntry />} />
{/* Web App 站点预览工作室:独立路由 + 独立视觉风格(无主应用 chrome) */}
<Route path="/sites/:threadId/:siteName" element={<SiteStudioPage />} />
<Route path="/page/*" element={pageElement} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
@@ -9,8 +9,8 @@ import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview";
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`)
* navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is
* a real document URL, so fragment links scroll within the frame as expected —
* all without enabling scripts (the sandbox stays script-free for security; the
* server already sanitizes script vectors and this is defense-in-depth).
* The sandbox permits scripts so the preview can use the app-bundled HTML
* visualization runtimes, while still withholding same-origin privileges.
*
* The `charset=utf-8` on the blob type is required: a blob: URL whose
* Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on
@@ -26,7 +26,9 @@ export function HtmlPreviewFrame({
const [url, setUrl] = useState<string>();
useEffect(() => {
const blob = new Blob([prepareGeneratedHtml(html ?? "")], {
const blob = new Blob([
prepareGeneratedHtml(html ?? ""),
], {
type: "text/html;charset=utf-8",
});
const objectUrl = URL.createObjectURL(blob);
@@ -1,5 +1,4 @@
import { ChevronRight } from "lucide-react";
import { Fragment } from "react";
import { cn } from "@/lib/utils";
@@ -21,12 +20,12 @@ export function Breadcrumb({
)}
>
{segments.map((segment, i) => (
<Fragment key={`${segment}-${i}`}>
<div className="contents" key={`${segment}-${i}`}>
<span className="hidden shrink-0 whitespace-nowrap sm:inline">
{segment}
</span>
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
</Fragment>
</div>
))}
<span
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
@@ -152,6 +152,7 @@ export function AgentDetailPanel({
positionName,
skillsIcon,
skillsTitle,
belowSkillsSlot,
showCommonQuestions = false,
jobResponsibilities,
panelTitle,
@@ -181,6 +182,8 @@ export function AgentDetailPanel({
positionName?: string;
skillsIcon?: ReactNode;
skillsTitle?: string;
/** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */
belowSkillsSlot?: ReactNode;
showCommonQuestions?: boolean;
/**
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
@@ -940,6 +943,9 @@ export function AgentDetailPanel({
/>
</Section>
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
{belowSkillsSlot}
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
</div>
@@ -8,6 +8,7 @@ import {
ExternalLinkIcon,
EyeIcon,
FileType2Icon,
GlobeIcon,
LoaderIcon,
PackageIcon,
SaveIcon,
@@ -17,6 +18,7 @@ import {
} from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { useNavigate } from "react-router-dom";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner";
@@ -56,8 +58,9 @@ import {
} from "@/open-canvas/api/rewrite";
import { publishArtifactToKnowledge } from "@/core/agents/api";
import { useAgent } from "@/core/agents/hooks";
import { apiFetch } from "@/core/api";
import { copyTextToClipboard } from "@/core/clipboard";
import { apiFetch } from "@/core/api";
import { copyTextToClipboard } from "@/core/clipboard";
import { siteStudioRoute, useWebAppCapability } from "@/core/sites/api";
import {
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
DOCUMENT_REWRITE_STARTED_EVENT,
@@ -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({
className,
filepath: filepathFromProps,
@@ -268,6 +287,14 @@ export function ArtifactFileDetail({
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
const { isMock, thread, publicShareCode } = useThread();
const navigate = useNavigate();
// Web App 站点清单(outputs/<site>/site.json)→「站点预览」入口。
// capability 不可用(未启用/未部署)时按钮不渲染,原 UI 一字不变。
const siteStudioTarget = useMemo(
() => extractSiteStudioTarget(filepathFromProps),
[filepathFromProps],
);
const webAppCapabilityQuery = useWebAppCapability(!!siteStudioTarget);
const isWriteFileVirtual = useMemo(() => {
return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]);
@@ -1427,7 +1454,9 @@ export function ArtifactFileDetail({
toast.error("文件内容为空,暂时无法在新窗口打开");
return;
}
const blob = new Blob([prepareGeneratedHtml(source)], {
const blob = new Blob([
prepareGeneratedHtml(source),
], {
type: "text/html;charset=utf-8",
});
const url = URL.createObjectURL(blob);
@@ -1454,7 +1483,11 @@ export function ArtifactFileDetail({
: language === "markdown"
? "text/markdown;charset=utf-8"
: "text/plain;charset=utf-8";
const blob = new Blob([source], { type: blobType });
const downloadSource =
language === "html"
? prepareGeneratedHtml(source)
: source;
const blob = new Blob([downloadSource], { type: blobType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@@ -1647,6 +1680,16 @@ export function ArtifactFileDetail({
onClick={handleOpenInNewWindow}
/>
)}
{siteStudioTarget && webAppCapabilityQuery.data?.available && (
<ArtifactAction
icon={GlobeIcon}
label="站点预览"
tooltip="打开站点预览工作室(多页面站点 / 流程图 / 地图)"
onClick={() =>
navigate(siteStudioRoute(threadId, siteStudioTarget))
}
/>
)}
<ArtifactAction
icon={DownloadIcon}
label={t.common.download}
@@ -2094,7 +2137,9 @@ export function ArtifactFilePreview({
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
// Intercept in-page anchor clicks so they scroll within the iframe instead
// of navigating the sandboxed blob: document (which blanks the frame).
const blob = new Blob([prepareGeneratedHtml(content ?? "")], {
const blob = new Blob([
prepareGeneratedHtml(content ?? ""),
], {
type: "text/html;charset=utf-8",
});
const url = URL.createObjectURL(blob);
@@ -16,6 +16,7 @@ import {
getFileExtensionDisplayName,
getFileIcon,
getFileName,
isWordDocumentFile,
} from "@/core/utils/files";
import { cn } from "@/lib/utils";
@@ -93,12 +94,12 @@ export function ArtifactFileList({
return (
<ul className={cn("flex w-full flex-col gap-4", className)}>
{files.map((file) => (
<Card
key={file}
className="relative cursor-pointer p-3"
onClick={() => handleClick(file)}
>
{files.map((file) => (
<Card
key={file}
className={cn("relative p-3", !isWordDocumentFile(file) && "cursor-pointer")}
onClick={isWordDocumentFile(file) ? undefined : () => handleClick(file)}
>
<CardHeader className="grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 pr-2 pl-1">
<CardTitle className="relative min-w-0 pl-8 leading-tight [overflow-wrap:anywhere] break-words">
<div className="min-w-0">{getFileName(file)}</div>
@@ -133,7 +134,7 @@ export function ArtifactFileList({
</Button>
</CardAction>
</CardHeader>
</Card>
</Card>
))}
<SkillInstallDialog
open={!!installFile}
@@ -19,7 +19,10 @@ import {
listThreadArtifacts,
mergeArtifactPaths,
} 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 { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
@@ -35,8 +38,10 @@ import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
} from "@/core/skill-display/runtime";
import { isWordDocumentFile } from "@/core/utils/files";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import {
ArtifactFileDetail,
@@ -83,10 +88,12 @@ const ChatBox: React.FC<{
children: React.ReactNode;
threadId: string;
disableArtifacts?: boolean;
writingMode?: boolean;
}> = ({
children,
threadId,
disableArtifacts = false,
writingMode,
}) => {
const {
isMock,
@@ -106,15 +113,17 @@ const ChatBox: React.FC<{
const latestReferenceBatchIdRef = useRef<string | null>(null);
const manualReferenceBatchRef = useRef(false);
const referenceDebugSignatureRef = useRef<string>("");
const referenceBatchesRef = useRef<BackendReferenceBatch[]>([]);
const [sideTab, setSideTab] = useState("document");
const [referencesOpen, setReferencesOpen] = useState(true);
const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null);
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
const [wikiCitationSource, setWikiCitationSource] = useState<BackendReferenceSource | null>(null);
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
const [settings] = useLocalSettings();
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
const writingModeEnabled = !!settings.context.writing_mode;
const writingModeEnabled = writingMode ?? !!settings.context.writing_mode;
const artifactDirectoryModeEnabled =
settings.display.artifactDirectoryModeEnabled ?? false;
const { data: citationDisplay } = useCitationDisplaySettings();
@@ -351,6 +360,9 @@ const ChatBox: React.FC<{
latestReferenceBatch;
const referenceSources = activeReferenceBatch?.sources ?? [];
const hasCitationReferences = referenceSources.length > 0;
useEffect(() => {
referenceBatchesRef.current = referenceBatches;
}, [referenceBatches]);
useEffect(() => {
if (!shouldFetchBackendReferenceBatches || !activeReferenceBatch) {
return;
@@ -396,6 +408,16 @@ const ChatBox: React.FC<{
window.setTimeout(() => {
setPinnedReferenceId(detail?.referenceId ?? null);
}, 0);
const referenceIndex = Number(detail?.referenceId);
const batch = referenceBatchesRef.current.find((item) => item.id === detail?.batchId)
?? referenceBatchesRef.current.at(-1);
const source = Number.isInteger(referenceIndex) && referenceIndex > 0
? batch?.sources.find((item) => item.index === referenceIndex)
?? batch?.sources[referenceIndex - 1]
: undefined;
if (source?.skillName?.startsWith("llmwiki_") || source?.type?.startsWith("llmwiki_")) {
setWikiCitationSource(source);
}
setReferencesOpen(true);
setSideTab("references");
};
@@ -459,7 +481,12 @@ const ChatBox: React.FC<{
) {
if (thread?.values?.artifacts?.length > 0) {
setAutoSelectFirstArtifact(false);
selectArtifact(thread.values.artifacts[0]!);
const firstPreviewableArtifact = thread.values.artifacts.find(
(artifact) => !isWordDocumentFile(artifact),
);
if (firstPreviewableArtifact) {
selectArtifact(firstPreviewableArtifact);
}
}
}
}, [
@@ -497,6 +524,25 @@ const ChatBox: React.FC<{
setSideTab("document");
}, [openStreamedArtifact, streamingWritePreview]);
// page_report_template(prepare) creates the output HTML before content
// editing starts. Open that real artifact immediately; this also covers
// models that use str_replace or whose write_file arguments arrive at once.
const preparedHtmlPreview = useMemo(
() =>
disableArtifacts || !thread.isLoading
? null
: latestPreparedHtmlArtifact(thread.messages ?? []),
[disableArtifacts, thread.isLoading, thread.messages],
);
useEffect(() => {
if (!preparedHtmlPreview) return;
openStreamedArtifact(
preparedHtmlPreview.artifactPath,
preparedHtmlPreview.previewKey,
);
setSideTab("document");
}, [openStreamedArtifact, preparedHtmlPreview]);
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
useEffect(() => {
const wasEnabled = directoryModeWasEnabledRef.current;
@@ -504,7 +550,8 @@ const ChatBox: React.FC<{
if (
wasEnabled ||
!artifactDirectoryModeEnabled ||
availableArtifacts.length === 0
availableArtifacts.length === 0 ||
isWordDocumentFile(availableArtifacts[availableArtifacts.length - 1]!)
) {
return;
}
@@ -744,6 +791,17 @@ const ChatBox: React.FC<{
</div>
</ResizablePanel>
</ResizablePanelGroup>
<WeKnoraSourceDrawer
open={wikiCitationSource !== null}
target={null}
title={wikiCitationSource?.title || "Wiki 文档"}
quote={wikiCitationSource?.content || wikiCitationSource?.snippet || ""}
inlineContent={wikiCitationSource?.content || wikiCitationSource?.snippet || ""}
inlineKnowledgeBaseId={String(wikiCitationSource?.raw?.knowledge_base_id || "")}
onOpenChange={(open) => {
if (!open) setWikiCitationSource(null);
}}
/>
</ReferenceBatchesContext.Provider>
);
};
@@ -756,19 +814,23 @@ function ReferencePanel({
sources: Parameters<typeof SkillDisplaySources>[0]["sources"];
}) {
const orderedSources = useMemo(() => {
if (!highlightedReferenceId) {
return sources;
}
const index = Number(highlightedReferenceId);
if (!Number.isFinite(index)) {
return sources;
}
const selected = sources.find((source) => source.index === index);
if (!selected) {
return sources;
}
return [selected, ...sources.filter((source) => source.index !== index)];
}, [highlightedReferenceId, sources]);
// This is presentation-only. Keep every source's original `index`, since
// inline citations resolve through that index (for example [3] must still
// open the third source returned by retrieval even when its card is shown
// below a higher-scoring result).
return sources
.map((source, originalOrder) => ({ source, originalOrder }))
.sort((left, right) => {
const leftScore = typeof left.source.score === "number" && Number.isFinite(left.source.score)
? left.source.score
: Number.NEGATIVE_INFINITY;
const rightScore = typeof right.source.score === "number" && Number.isFinite(right.source.score)
? right.source.score
: Number.NEGATIVE_INFINITY;
return rightScore - leftScore || left.originalOrder - right.originalOrder;
})
.map(({ source }) => source);
}, [sources]);
return (
<div className="flex size-full flex-col overflow-hidden">
@@ -29,13 +29,14 @@ import {
PinOff,
Plus,
Search,
Settings,
Share2,
Star,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
@@ -65,6 +66,7 @@ import {
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useAgentsPaginated, type Agent } from "@/core/agents";
import { getAccountDisplayName } from "@/core/auth";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
import {
@@ -92,6 +94,13 @@ import {
} from "@/core/threads/utils";
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
import { useRouteBase } from "@/core/route-base";
import {
usePublishSquares,
useUpdateWxCommonAgents,
useWxCommonAgents,
} from "@/core/system-settings/hooks";
import { SENTIMENT_AGENT_ID, isSentimentAgentVisible } from "@/core/sentiment-agent/config";
import { readSentimentAgentProfile } from "@/core/sentiment-agent/profile";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
@@ -112,37 +121,149 @@ function SectionLabel({
count,
collapsed,
onToggle,
onOpenSettings,
}: {
label: string;
count?: number;
collapsed: boolean;
onToggle: () => void;
onOpenSettings?: () => void;
}) {
return (
<button
type="button"
onClick={onToggle}
className="flex w-full items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none"
>
<span className="flex items-center gap-1.5">
<span>{label}</span>
{count != null && (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
{count}
</span>
)}
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 transition-transform duration-200",
collapsed && "-rotate-90",
)}
/>
</button>
<div className="flex w-full items-center px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80">
<button
type="button"
onClick={onToggle}
className="flex min-w-0 flex-1 items-center justify-between transition-colors hover:text-muted-foreground select-none"
>
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate">{label}</span>
{count != null && (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
{count}
</span>
)}
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 transition-transform duration-200",
collapsed && "-rotate-90",
)}
/>
</button>
{onOpenSettings && (
<button
type="button"
onClick={onOpenSettings}
className="ml-1 inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground"
aria-label="设置智能体类型"
title="设置智能体类型"
>
<Settings className="size-3.5" />
</button>
)}
</div>
);
}
export function GeneralQaSidebar() {
/** Gallery category values persisted for the dedicated wx full-chat route. */
const WX_AGENT_TYPE_MINE = "mine";
const WX_AGENT_TYPE_SENTIMENT = "sentiment";
const WX_AGENT_TYPE_SQUARE_PREFIX = "square:";
const WX_AGENT_TYPE_DEFAULT = "__default__";
type CommonAgent = Pick<Agent, "id" | "name" | "description">;
type WxAgentTypeOption = {
value: string;
label: string;
};
function wxSquareAgentType(squareId: string) {
return `${WX_AGENT_TYPE_SQUARE_PREFIX}${squareId}`;
}
function wxSquareIdOf(agentType: string): string | undefined {
return agentType.startsWith(WX_AGENT_TYPE_SQUARE_PREFIX)
? agentType.slice(WX_AGENT_TYPE_SQUARE_PREFIX.length)
: undefined;
}
function WxAgentTypeSettingsDialog({
open,
onOpenChange,
agentType,
options,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
agentType: string | null;
options: WxAgentTypeOption[];
}) {
const [selectedType, setSelectedType] = useState(
agentType ?? WX_AGENT_TYPE_DEFAULT,
);
const { mutateAsync: updateWxCommonAgents, isPending } =
useUpdateWxCommonAgents();
useEffect(() => {
if (open) setSelectedType(agentType ?? WX_AGENT_TYPE_DEFAULT);
}, [agentType, open]);
const handleSave = async () => {
try {
await updateWxCommonAgents(
selectedType === WX_AGENT_TYPE_DEFAULT ? null : selectedType,
);
toast.success("常用智能体类型已更新");
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存智能体类型失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>设置常用智能体类型</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<label htmlFor="wx-common-agent-type" className="text-sm font-medium">
智能体类型
</label>
<select
id="wx-common-agent-type"
value={selectedType}
onChange={(event) => setSelectedType(event.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleSave} disabled={isPending}>
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function GeneralQaSidebar({
wxCommonAgents,
}: {
/** Enables wx's shared Agent Gallery category selection. */
wxCommonAgents?: boolean;
} = {}) {
const { t } = useI18n();
const routeBase = useRouteBase();
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
@@ -163,7 +284,60 @@ export function GeneralQaSidebar() {
setAgentsCollapsed((v) => {
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
return !v;
});
});
const [agentTypeSettingsOpen, setAgentTypeSettingsOpen] = useState(false);
const { data: wxCommonAgentSettings } = useWxCommonAgents({
enabled: !!wxCommonAgents,
});
const { data: publishSquares } = usePublishSquares({
enabled: !!wxCommonAgents,
});
const isWxManager =
wxCommonAgents &&
getAccountDisplayName().toLowerCase() === "wx";
const sentimentVisible = isSentimentAgentVisible();
const wxCategoryOptions = useMemo<WxAgentTypeOption[]>(() => {
const options: WxAgentTypeOption[] = [
{ value: WX_AGENT_TYPE_MINE, label: "我的" },
...(publishSquares?.squares ?? []).map((square) => ({
value: wxSquareAgentType(square.id),
label: square.name,
})),
];
if (sentimentVisible) {
options.push({ value: WX_AGENT_TYPE_SENTIMENT, label: "舆情分析" });
}
return options;
}, [publishSquares?.squares, sentimentVisible]);
const wxAgentTypeOptions = useMemo<WxAgentTypeOption[]>(
() => [
{ value: WX_AGENT_TYPE_DEFAULT, label: "默认推荐(未设置)" },
...wxCategoryOptions,
],
[wxCategoryOptions],
);
const configuredWxAgentType = wxCommonAgentSettings?.agent_type ?? null;
const wxUsesCategoryFilter = !!configuredWxAgentType && wxCategoryOptions.some(
(option) => option.value === configuredWxAgentType,
);
const wxSquareId =
wxCommonAgents && wxUsesCategoryFilter && configuredWxAgentType
? wxSquareIdOf(configuredWxAgentType)
: undefined;
const wxShowsSentiment =
wxCommonAgents &&
wxUsesCategoryFilter &&
configuredWxAgentType === WX_AGENT_TYPE_SENTIMENT;
const [sentimentProfile, setSentimentProfile] = useState(() =>
readSentimentAgentProfile(),
);
useEffect(() => {
const refresh = () => setSentimentProfile(readSentimentAgentProfile());
window.addEventListener("sentiment-agent-profile-changed", refresh);
return () =>
window.removeEventListener("sentiment-agent-profile-changed", refresh);
}, []);
const toggleChats = () =>
setChatsCollapsed((v) => {
localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v));
@@ -171,9 +345,38 @@ export function GeneralQaSidebar() {
});
// ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)──
const { agents: mine } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
const { agents: square } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
const frequent = useMemo<Agent[]>(() => {
const { agents: mine } = useAgentsPaginated({
scope: "mine",
page: 1,
pageSize: 20,
enabled: !wxUsesCategoryFilter,
});
const { agents: square } = useAgentsPaginated({
scope: "square",
page: 1,
pageSize: 20,
enabled: !wxUsesCategoryFilter,
});
const { agents: wxAgents } = useAgentsPaginated({
scope: wxSquareId ? "square" : "mine",
page: 1,
pageSize: MAX_FREQUENT,
square: wxSquareId,
enabled: wxUsesCategoryFilter && !wxShowsSentiment,
});
const frequent = useMemo<CommonAgent[]>(() => {
if (wxUsesCategoryFilter) {
if (wxShowsSentiment) {
return [
{
id: SENTIMENT_AGENT_ID,
name: sentimentProfile.name,
description: sentimentProfile.description,
},
];
}
return wxAgents.slice(0, MAX_FREQUENT);
}
const out: Agent[] = [];
const seen = new Set<string>();
const add = (agent: Agent | undefined) => {
@@ -185,7 +388,7 @@ export function GeneralQaSidebar() {
mine.forEach(add);
square.forEach(add);
return out;
}, [mine, square]);
}, [mine, sentimentProfile, square, wxAgents, wxShowsSentiment, wxUsesCategoryFilter]);
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
const searchParams = useSearchParams();
@@ -205,16 +408,25 @@ export function GeneralQaSidebar() {
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
const handleSelectAgent = useCallback(
(agentId: string) => {
if (agentId === SENTIMENT_AGENT_ID) {
navigate(
buildFullChatNavigationPath(
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats/new`,
currentSearch,
),
);
return;
}
navigate(
buildFullChatNavigationPath(
routeBase === "/page/workspace"
routeBase === "/page/workspace" || wxCommonAgents
? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
currentSearch,
),
);
},
[currentSearch, navigate, newChatPath, routeBase],
[currentSearch, navigate, newChatPath, routeBase, wxCommonAgents],
);
// ── 最近的对话取数 + 搜索 ──
@@ -359,17 +571,24 @@ export function GeneralQaSidebar() {
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{/* ── 常用智能体 ── */}
{frequent.length > 0 && (
{(frequent.length > 0 || isWxManager) && (
<div className="pt-2">
<SectionLabel
label="常用智能体"
count={wxUsesCategoryFilter ? frequent.length : undefined}
collapsed={agentsCollapsed}
onToggle={toggleAgents}
onOpenSettings={isWxManager ? () => setAgentTypeSettingsOpen(true) : undefined}
/>
{!agentsCollapsed && (
<div className="mt-1 flex flex-col gap-1">
{frequent.map((agent) => {
const isActive = activeAgentId === agent.id;
const isActive =
activeAgentId === agent.id ||
(agent.id === SENTIMENT_AGENT_ID &&
pathname.startsWith(
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats`,
));
const { from, to, foreground } = agentColor(agent.id, theme);
return (
<button
@@ -665,6 +884,14 @@ export function GeneralQaSidebar() {
if (!o) setShareThreadId(null);
}}
/>
{wxCommonAgents && isWxManager && (
<WxAgentTypeSettingsDialog
open={agentTypeSettingsOpen}
onOpenChange={setAgentTypeSettingsOpen}
agentType={wxUsesCategoryFilter ? configuredWxAgentType : null}
options={wxAgentTypeOptions}
/>
)}
</div>
);
}
@@ -2,12 +2,10 @@
import type { ChatStatus } from "ai";
import {
BrainCircuitIcon,
BookMarkedIcon,
CheckIcon,
FileTextIcon,
FolderTreeIcon,
GaugeIcon,
GraduationCapIcon,
LightbulbIcon,
PaperclipIcon,
@@ -69,7 +67,6 @@ import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import type { Model } from "@/core/models/types";
import type { AgentThreadContext } from "@/core/threads";
import { isTurboModeVisible } from "@/core/threads/use-direct-chat";
import { textOfMessage } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -95,33 +92,18 @@ import {
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import { useUserPreferences } from "@/core/user-preferences";
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
type InputMode = "flash" | "thinking" | "pro" | "ultra";
function getResolvedMode(
mode: InputMode | undefined,
supportsThinking: boolean,
): InputMode {
if (!supportsThinking && mode !== "flash") {
return "flash";
}
if (mode) {
return mode;
}
return supportsThinking ? "pro" : "flash";
}
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu";
import { resolveInputMode, type InputMode } from "./input-mode";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
export function InputBox({
className,
@@ -139,13 +121,13 @@ export function InputBox({
onStop,
promptPrefix,
agentSelector,
knowledgeSelector,
knowledgeSelector,
knowledgeSuggestedQuestions = [],
extraModels = [],
assistantId,
hideAdminRecommendedQuestions,
compact,
hideContextTools,
hideMemoryTool = true,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
@@ -153,14 +135,9 @@ export function InputBox({
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
compact?: boolean;
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
/** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等
* 受控场景用;文件/模型等其他按钮不受影响)。 */
hideContextTools?: boolean;
/**
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
* 写作和参考文献开关不受影响。
*/
hideMemoryTool?: boolean;
disabled?: boolean;
context: Omit<
AgentThreadContext,
@@ -169,7 +146,6 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
memory_injection_enabled?: boolean;
};
extraHeader?: React.ReactNode;
isNewThread?: boolean;
@@ -186,7 +162,6 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
memory_injection_enabled?: boolean;
},
) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void;
@@ -211,8 +186,10 @@ export function InputBox({
selectedAgentId: string | null;
onAgentChange: (agentId: string | null) => void;
};
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
knowledgeSelector?: ReactNode;
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
knowledgeSelector?: ReactNode;
/** High-value starter questions derived from the currently selected spaces. */
knowledgeSuggestedQuestions?: string[];
/** Page-scoped model choices implemented outside the DeerFlow model API. */
extraModels?: Model[];
}) {
@@ -225,20 +202,16 @@ export function InputBox({
context.execution_profile === "single_round_rag";
const multiRoundRagActive =
context.execution_profile === "multi_round_rag";
const turboModeVisible = isTurboModeVisible();
const directChatActive =
turboModeVisible && context.execution_profile === "direct";
const frontendRagActive = singleRoundRagActive || multiRoundRagActive;
const bypassAgentActive = frontendRagActive || directChatActive;
const modelOptions = useMemo(() => {
return singleRoundRagActive ? extraModels : models;
}, [extraModels, models, singleRoundRagActive]);
const { thread, isMock } = useThread();
const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null);
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const { thread, isMock } = useThread();
const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null);
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false);
@@ -253,43 +226,18 @@ export function InputBox({
const { questions: recommendedQuestions } = useRecommendedQuestions();
const userPreferences = useUserPreferences();
const showAdminRecommendedQuestions =
!hideAdminRecommendedQuestions &&
const showAdminRecommendedQuestions =
!hideAdminRecommendedQuestions &&
knowledgeSuggestedQuestions.length === 0 &&
!userPreferences.hide_admin_prompt_prefix &&
recommendedQuestions.length > 0;
useEffect(() => {
// Frontend-only execution profiles are not model options.
// Do not let the normal model fallback clear them on every render.
if (!turboModeVisible && context.execution_profile === "direct") {
onContextChange?.({
...context,
execution_profile: undefined,
});
return;
}
// Multi-round RAG is a frontend execution profile, not a model option.
// Do not let the normal model fallback clear it on every render.
if (multiRoundRagActive) {
return;
}
if (directChatActive) {
if (models.length === 0) {
return;
}
const currentModel = models.find((m) => m.name === context.model_name);
const fallbackModel = currentModel ?? models[0]!;
if (
context.model_name === fallbackModel.name &&
context.execution_profile === "direct"
) {
return;
}
onContextChange?.({
...context,
model_name: fallbackModel.name,
execution_profile: "direct",
});
return;
}
if (modelOptions.length === 0) {
return;
}
@@ -297,7 +245,7 @@ export function InputBox({
const fallbackModel = currentModel ?? modelOptions[0]!;
const supportsThinking = fallbackModel.supports_thinking ?? false;
const nextModelName = fallbackModel.name;
const nextMode = getResolvedMode(context.mode, supportsThinking);
const nextMode = resolveInputMode(context.mode, supportsThinking);
if (
context.model_name === nextModelName &&
@@ -313,7 +261,7 @@ export function InputBox({
mode: nextMode,
execution_profile: fallbackModel.execution_profile,
});
}, [context, directChatActive, modelOptions, models, multiRoundRagActive, onContextChange, turboModeVisible]);
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
const selectedModel = useMemo(() => {
if (modelOptions.length === 0) {
@@ -344,12 +292,9 @@ export function InputBox({
onContextChange?.({
...context,
model_name,
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
mode: resolveInputMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: context.reasoning_effort,
execution_profile:
context.execution_profile === "direct"
? "direct"
: model.execution_profile,
execution_profile: model.execution_profile,
});
setModelDialogOpen(false);
},
@@ -363,7 +308,7 @@ export function InputBox({
onContextChange?.({
...context,
model_name: ordinaryModel?.name ?? context.model_name,
mode: getResolvedMode(
mode: resolveInputMode(
mode,
ordinaryModel?.supports_thinking ?? supportThinking,
),
@@ -381,16 +326,6 @@ export function InputBox({
[onContextChange, context, models, supportThinking],
);
const handleDirectChatSelect = useCallback(() => {
const ordinaryModel =
models.find((model) => model.name === context.model_name) ?? models[0];
onContextChange?.({
...context,
model_name: ordinaryModel?.name ?? context.model_name,
execution_profile: "direct",
});
}, [context, models, onContextChange]);
const handleSingleRoundRagSelect = useCallback(() => {
const ragModel =
extraModels.find((model) => model.name === context.model_name) ??
@@ -402,7 +337,7 @@ export function InputBox({
onContextChange?.({
...context,
model_name: ragModel.name,
mode: getResolvedMode(
mode: resolveInputMode(
context.mode,
ragModel.supports_thinking ?? false,
),
@@ -442,14 +377,6 @@ export function InputBox({
});
}, [artifactDirectoryModeEnabled, setLocalSettings]);
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
const handleMemoryInjectionToggle = useCallback(() => {
onContextChange?.({
...context,
memory_injection_enabled: !memoryInjectionEnabled,
});
}, [context, memoryInjectionEnabled, onContextChange]);
const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
const handleRagModeToggle = useCallback(() => {
@@ -489,7 +416,7 @@ export function InputBox({
onContextChange?.({
...context,
model_name: resolvedModelName,
mode: getResolvedMode(
mode: resolveInputMode(
context.mode,
selectedModel?.supports_thinking ?? false,
),
@@ -661,13 +588,35 @@ export function InputBox({
return (
<div ref={promptRootRef} className="relative flex flex-col gap-6">
{extraHeader && (
{extraHeader && (
<div className="flex items-center justify-center">
{extraHeader}
</div>
)}
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
)}
{isNewThread && searchParams.get("mode") !== "skill" && knowledgeSuggestedQuestions.length > 0 && (
<div className="px-4 mb-[10px]">
<div className="mb-2 text-center text-xs text-muted-foreground">基于已选知识空间,试试这样问</div>
<div className="flex flex-wrap justify-center gap-2.5">
{knowledgeSuggestedQuestions.slice(0, 4).map((question) => (
<button
key={question}
type="button"
className="max-w-full rounded-full border border-primary/20 bg-primary/5 px-4 py-2 text-left text-sm text-foreground transition-colors hover:bg-primary/10"
onClick={() => {
textInput.setInput(question);
setFollowupsHidden(true);
setTimeout(() => requestFormSubmit(), 0);
}}
>
{question}
</button>
))}
</div>
</div>
)}
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
{recommendedQuestions.map((q) => (
<button
@@ -778,12 +727,12 @@ export function InputBox({
</PromptInputBody>
<PromptInputFooter
className={cn(
"flex items-center border-t-0 px-2 pt-1 pb-1.5",
"flex flex-wrap items-center gap-y-2 border-t-0 px-2 pt-1 pb-1.5",
compact && "gap-2 px-3 pt-1.5 pb-3",
)}
>
<PromptInputTools
className={cn("gap-1.5", compact && "flex-1 flex-wrap gap-2")}
className={cn("min-w-0 flex-1 flex-wrap gap-1.5", compact && "gap-2")}
>
{/* TODO: Add more connectors here
<PromptInputActionMenu>
@@ -794,7 +743,7 @@ export function InputBox({
/>
</PromptInputActionMenuContent>
</PromptInputActionMenu> */}
{!bypassAgentActive && (
{!frontendRagActive && (
<AddAttachmentsButton
className={cn(
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
@@ -803,7 +752,7 @@ export function InputBox({
)}
/>
)}
{!bypassAgentActive && !hideContextTools && <Tooltip
{!frontendRagActive && !hideContextTools && <Tooltip
content={
context.writing_mode
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
@@ -829,7 +778,7 @@ export function InputBox({
<span className="text-xs font-normal">写作</span>
</PromptInputButton>
</Tooltip>}
{!bypassAgentActive && <Tooltip
{!frontendRagActive && <Tooltip
content={
artifactDirectoryModeEnabled
? "文件模式已开启:右侧会显示当前对话生成的全部文件目录"
@@ -858,35 +807,9 @@ export function InputBox({
<span className="text-xs font-normal">文件</span>
</PromptInputButton>
</Tooltip>}
{!bypassAgentActive && !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 && !bypassAgentActive && !hideContextTools && <Tooltip
content={
ragModeEnabled
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
: "参考文献模式已关闭:点击开启"
}
@@ -914,7 +837,7 @@ export function InputBox({
<span className="text-xs font-normal">参考文献</span>
</PromptInputButton>
</Tooltip>}
{!bypassAgentActive && <Tooltip
{!frontendRagActive && <Tooltip
content={
contextCompressionEnabled
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
@@ -954,9 +877,7 @@ export function InputBox({
: "flash"
}
tooltipContent={
directChatActive
? `${t.inputBox.turboMode}:${t.inputBox.turboModeDescription}`
: singleRoundRagActive
singleRoundRagActive
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
: multiRoundRagActive
? "多轮 RAG 问答:多次检索并展示检索过程与参考资料"
@@ -968,9 +889,7 @@ export function InputBox({
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
compact &&
(directChatActive
? "border-sky-200 bg-sky-50/60 text-sky-700 hover:bg-sky-100 dark:border-sky-700 dark:bg-sky-950/40 dark:text-sky-200"
: frontendRagActive
(frontendRagActive
? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: context.mode === "thinking"
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
@@ -982,9 +901,7 @@ export function InputBox({
)}
>
<div>
{directChatActive ? (
<GaugeIcon className="size-3" />
) : frontendRagActive ? (
{frontendRagActive ? (
<BookMarkedIcon className="size-3" />
) : context.mode === "flash" ? (
<ZapIcon className="size-3" />
@@ -999,14 +916,10 @@ export function InputBox({
<div
className={cn(
"text-xs font-normal",
context.mode === "ultra" && !directChatActive && !frontendRagActive
? "golden-text"
: "",
context.mode === "ultra" ? "golden-text" : "",
)}
>
{directChatActive
? t.inputBox.turboMode
: frontendRagActive
{frontendRagActive
? multiRoundRagActive
? "多轮 RAG"
: "单轮 RAG"
@@ -1024,39 +937,9 @@ export function InputBox({
{t.inputBox.mode}
</DropdownMenuLabel>
<PromptInputActionMenu>
{turboModeVisible && (
<PromptInputActionMenuItem
className={cn(
directChatActive
? "bg-sky-50 text-sky-800 hover:bg-sky-100 focus:bg-sky-100 dark:bg-sky-950/40 dark:text-sky-200 dark:hover:bg-sky-950/60 dark:focus:bg-sky-950/60"
: "text-muted-foreground/65",
)}
onSelect={handleDirectChatSelect}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<GaugeIcon
className={cn(
"mr-2 size-4",
directChatActive && "text-sky-700 dark:text-sky-200",
)}
/>
{t.inputBox.turboMode}
</div>
<div className="pl-7 text-xs">
{t.inputBox.turboModeDescription}
</div>
</div>
{directChatActive ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
)}
<PromptInputActionMenuItem
className={cn(
!bypassAgentActive && context.mode === "flash"
!frontendRagActive && context.mode === "flash"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -1067,7 +950,7 @@ export function InputBox({
<ZapIcon
className={cn(
"mr-2 size-4",
!bypassAgentActive && context.mode === "flash" &&
!frontendRagActive && context.mode === "flash" &&
"text-accent-foreground",
)}
/>
@@ -1077,7 +960,7 @@ export function InputBox({
{t.inputBox.flashModeDescription}
</div>
</div>
{!bypassAgentActive && context.mode === "flash" ? (
{!frontendRagActive && context.mode === "flash" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -1086,7 +969,7 @@ export function InputBox({
{supportThinking && (
<PromptInputActionMenuItem
className={cn(
!bypassAgentActive && context.mode === "thinking"
!frontendRagActive && context.mode === "thinking"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -1097,7 +980,7 @@ export function InputBox({
<LightbulbIcon
className={cn(
"mr-2 size-4",
!bypassAgentActive && context.mode === "thinking" &&
!frontendRagActive && context.mode === "thinking" &&
"text-accent-foreground",
)}
/>
@@ -1107,7 +990,7 @@ export function InputBox({
{t.inputBox.reasoningModeDescription}
</div>
</div>
{!bypassAgentActive && context.mode === "thinking" ? (
{!frontendRagActive && context.mode === "thinking" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -1116,7 +999,7 @@ export function InputBox({
)}
<PromptInputActionMenuItem
className={cn(
!bypassAgentActive && context.mode === "pro"
!frontendRagActive && context.mode === "pro"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -1127,7 +1010,7 @@ export function InputBox({
<GraduationCapIcon
className={cn(
"mr-2 size-4",
!bypassAgentActive && context.mode === "pro" &&
!frontendRagActive && context.mode === "pro" &&
"text-accent-foreground",
)}
/>
@@ -1137,7 +1020,7 @@ export function InputBox({
{t.inputBox.proModeDescription}
</div>
</div>
{!bypassAgentActive && context.mode === "pro" ? (
{!frontendRagActive && context.mode === "pro" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -1145,7 +1028,7 @@ export function InputBox({
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
!bypassAgentActive && context.mode === "ultra"
!frontendRagActive && context.mode === "ultra"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -1156,14 +1039,14 @@ export function InputBox({
<RocketIcon
className={cn(
"mr-2 size-4",
!bypassAgentActive &&
!frontendRagActive &&
context.mode === "ultra" &&
"text-[#dabb5e]",
)}
/>
<div
className={cn(
!bypassAgentActive &&
!frontendRagActive &&
context.mode === "ultra" &&
"golden-text",
)}
@@ -1175,7 +1058,7 @@ export function InputBox({
{t.inputBox.ultraModeDescription}
</div>
</div>
{!bypassAgentActive && context.mode === "ultra" ? (
{!frontendRagActive && context.mode === "ultra" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -1246,7 +1129,7 @@ export function InputBox({
</DropdownMenuGroup>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
{!bypassAgentActive && supportReasoningEffort && context.mode !== "flash" && (
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
<div className="text-xs font-normal">
@@ -1363,8 +1246,8 @@ export function InputBox({
</PromptInputActionMenu>
)}
</PromptInputTools>
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
{!bypassAgentActive && agentSelector && (
<PromptInputTools className={cn("ml-auto shrink-0 gap-1.5", compact && "gap-2")}>
{!frontendRagActive && agentSelector && (
<AgentSelector
selectedAgentId={agentSelector.selectedAgentId}
onAgentChange={agentSelector.onAgentChange}
@@ -1372,8 +1255,8 @@ export function InputBox({
compact={compact}
/>
)}
{!bypassAgentActive && knowledgeSelector}
{!bypassAgentActive && promptPrefix && (
{!frontendRagActive && knowledgeSelector}
{!frontendRagActive && promptPrefix && (
<Tooltip
content={
promptPrefix.on
@@ -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";
}
@@ -35,6 +35,27 @@ import {
type SelectorSource = "all" | "agent";
type SelectorVariant = "popover" | "panel";
const EMPTY_IDS: string[] = [];
export function buildKnowledgeSpaceQuestions(names: string[]): string[] {
const cleanNames = names.map((name) => name.trim()).filter(Boolean);
if (cleanNames.length === 0) return [];
if (cleanNames.length === 1) {
const name = cleanNames[0]!;
return [
`请概括「${name}」知识空间的主要内容、适用场景和资料边界。`,
`「${name}」包含哪些核心主题?请按重要性说明并引用来源。`,
`请梳理「${name}」中的关键实体、概念及它们之间的关系。`,
`根据「${name}」,有哪些值得关注的问题、风险或行动建议?`,
];
}
return [
`请综合所选 ${cleanNames.length} 个知识空间,总结共同主题、差异和互补信息。`,
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点,并引用各自来源。`,
"请梳理所选知识空间中的关键实体、概念及跨库关系。",
"所选知识空间中有哪些相互冲突或证据不足的结论?",
];
}
function groupRows(rows: LlmWikiKnowledgeBase[]) {
return [
@@ -323,9 +344,14 @@ function SelectionContent({
</div>
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
<span className="text-[11px] text-muted-foreground">
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
</span>
<div>
<div className="text-[11px] text-muted-foreground">
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
</div>
{selectedCount > 0 && (
<div className="mt-0.5 text-[10px] text-primary">仅检索已选知识空间,不联网</div>
)}
</div>
{readOnly ? (
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
) : (
@@ -363,9 +389,10 @@ function SelectionContent({
export function LlmWikiKnowledgeSelector({
value,
assistantValue = [],
assistantValue = EMPTY_IDS,
onChange,
onAssistantChange = () => undefined,
onSuggestedQuestionsChange,
source = "all",
agentId,
autoSelectAll = false,
@@ -373,12 +400,15 @@ export function LlmWikiKnowledgeSelector({
readOnly = false,
className,
label = "知识空间",
panelTitle = "绑定知识空间",
panelDescription = "新建该智能体对话时,只能从已绑定空间中选择本次检索范围。",
variant = "popover",
}: {
value: string[];
assistantValue?: string[];
onChange: (value: string[]) => void;
onAssistantChange?: (value: string[]) => void;
onSuggestedQuestionsChange?: (questions: string[]) => void;
source?: SelectorSource;
agentId?: string | null;
autoSelectAll?: boolean;
@@ -387,23 +417,38 @@ export function LlmWikiKnowledgeSelector({
readOnly?: boolean;
className?: string;
label?: string;
panelTitle?: string;
panelDescription?: string;
variant?: SelectorVariant;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const runtime = useLlmWikiRuntime();
const weknoraEnabled = runtime.data?.provider === "weknora";
const shouldLoadOptions =
variant === "panel" ||
open ||
autoSelectAll ||
value.length > 0 ||
assistantValue.length > 0;
const all = useLlmWikiKnowledgeBases(
"all",
weknoraEnabled,
weknoraEnabled && shouldLoadOptions,
);
const agent = useAgentLlmWikiKnowledgeBases(
agentId,
weknoraEnabled && source === "agent" && !!agentId,
weknoraEnabled && shouldLoadOptions && source === "agent" && !!agentId,
);
const assistantBases = useAssistantKnowledgeBases();
const rows = weknoraEnabled ? (all.data ?? []) : [];
const assistantRows = assistantBases.data ?? [];
const assistantBases = useAssistantKnowledgeBases(
shouldLoadOptions ||
runtime.isError ||
(!runtime.isLoading && !weknoraEnabled),
);
// Stable empty results are essential: suggested questions are reported to
// the parent, so a fresh [] on every render creates an effect/update loop
// before lazy knowledge queries have loaded (or when they are disabled).
const rows = useMemo(() => weknoraEnabled ? (all.data ?? []) : [], [all.data, weknoraEnabled]);
const assistantRows = useMemo(() => assistantBases.data ?? [], [assistantBases.data]);
const defaultIds = useMemo(() => {
if (source !== "agent") {
return rows.map((item) => item.id);
@@ -419,6 +464,26 @@ export function LlmWikiKnowledgeSelector({
);
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
const initialized = useRef<string | null>(null);
const selectedNames = useMemo(
() => value.map((id) => rows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
[rows, value],
);
const selectedAssistantNames = useMemo(
() => assistantValue.map((id) => assistantRows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
[assistantRows, assistantValue],
);
const allSelectedNames = useMemo(
() => [...selectedNames, ...selectedAssistantNames],
[selectedAssistantNames, selectedNames],
);
const suggestedQuestions = useMemo(
() => buildKnowledgeSpaceQuestions(allSelectedNames),
[allSelectedNames],
);
useEffect(() => {
onSuggestedQuestionsChange?.(suggestedQuestions);
}, [onSuggestedQuestionsChange, suggestedQuestions]);
useEffect(() => {
if (!runtime.isLoading && !weknoraEnabled) {
@@ -448,7 +513,12 @@ export function LlmWikiKnowledgeSelector({
if (!open) setSearch("");
}, [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 = (
<SelectionContent
@@ -474,10 +544,10 @@ export function LlmWikiKnowledgeSelector({
<div className="flex items-start justify-between gap-4 border-b px-4 py-3">
<div>
<div className="flex items-center gap-2 text-sm font-semibold">
<Database className="size-4 text-primary" />绑定知识空间
<Database className="size-4 text-primary" />{panelTitle}
</div>
<p className="mt-1 text-xs text-muted-foreground">
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
{panelDescription}
</p>
</div>
<Badge variant="secondary" className="shrink-0">{value.length + 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 selectedLabel = allSelectedNames.length > 0
? allSelectedNames.join("、")
@@ -20,6 +20,9 @@ import {
} from "@/core/utils/markdown";
import { cn } from "@/lib/utils";
import { VisualizationBlock } from "../visualizations/visualization-block";
import { splitVisualizationContent } from "../visualizations/visualization-spec";
import { CitationLink } from "../citations/citation-link";
function isExternalUrl(href: string | undefined): boolean {
@@ -50,6 +53,7 @@ export type MarkdownContentProps = {
/** Renders markdown content. */
export function MarkdownContent({
content,
isLoading,
rehypePlugins,
className,
remarkPlugins = streamdownPlugins.remarkPlugins,
@@ -59,6 +63,7 @@ export function MarkdownContent({
}: MarkdownContentProps) {
const rootRef = useRef<HTMLDivElement>(null);
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
const visualizationSegments = useMemo(() => splitVisualizationContent(content), [content]);
useStreamdownMermaidPngDownload(rootRef);
const components = useMemo(() => {
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
@@ -139,17 +144,51 @@ export function MarkdownContent({
if (!content) return null;
// Preserve the exact legacy rendering path for ordinary Markdown. Heavy
// visualization renderers are lazy-loaded only when a dedicated fence exists.
if (
visualizationSegments.length === 1 &&
visualizationSegments[0]?.type === "markdown"
) {
return (
<div ref={rootRef} data-markdown-anchor-root className="contents">
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
mermaidConfig={streamdownPlugins.mermaidConfig}
components={components}
>
{content}
</MessageResponse>
</div>
);
}
return (
<div ref={rootRef} data-markdown-anchor-root className="contents">
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
mermaidConfig={streamdownPlugins.mermaidConfig}
components={components}
>
{content}
</MessageResponse>
{visualizationSegments.map((segment, index) =>
segment.type === "visualization" ? (
<VisualizationBlock
key={`visualization-${index}`}
language={segment.language}
source={segment.source}
complete={segment.complete}
isLoading={isLoading}
/>
) : segment.content ? (
<MessageResponse
key={`markdown-${index}`}
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
mermaidConfig={streamdownPlugins.mermaidConfig}
components={components}
>
{segment.content}
</MessageResponse>
) : null,
)}
</div>
);
}
@@ -82,7 +82,6 @@ import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content";
import { SingleRoundRagCitationDialog } from "./message-list-item";
export function MessageGroup({
className,
@@ -138,19 +137,6 @@ export function MessageGroup({
}
}, [lastToolCallStep, steps]);
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const [selectedCitation, setSelectedCitation] =
useState<RuntimeDisplaySource | null>(null);
const handleCitationClick = useCallback(
(referenceId: string) => {
const referenceIndex = Number(referenceId);
if (!Number.isInteger(referenceIndex) || referenceIndex < 1) return;
const source =
displaySources.find((item) => item.index === referenceIndex) ??
displaySources[referenceIndex - 1];
if (source) setSelectedCitation(source);
},
[displaySources],
);
const reasoningId =
lastReasoningStep?.id ??
messages.find((message) => message.type === "ai")?.id;
@@ -200,7 +186,6 @@ export function MessageGroup({
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
onCitationClick={handleCitationClick}
/>
}
></ChainOfThoughtStep>
@@ -208,8 +193,9 @@ export function MessageGroup({
<ToolCall
key={step.id}
{...step}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled}
/>
),
@@ -220,8 +206,9 @@ export function MessageGroup({
key={lastToolCallStep.id}
{...lastToolCallStep}
isLast={true}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled}
/>
</FlipDisplay>
@@ -262,7 +249,6 @@ export function MessageGroup({
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
onCitationClick={handleCitationClick}
/>
}
></ChainOfThoughtStep>
@@ -271,12 +257,6 @@ export function MessageGroup({
</>
)}
</ChainOfThought>
<SingleRoundRagCitationDialog
source={selectedCitation}
onOpenChange={(open) => {
if (!open) setSelectedCitation(null);
}}
/>
</>
);
}
@@ -286,14 +266,12 @@ function StepReasoningMarkdown({
displaySources,
isLoading,
rehypePlugins,
onCitationClick,
}: {
}: {
content: string;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onCitationClick: (referenceId: string) => void;
}) {
}) {
const markdown = useMemo(
() =>
applyInlineCitations(content, displaySources, { appendSummary: false }),
@@ -303,19 +281,18 @@ function StepReasoningMarkdown({
if (displaySources.length === 0) return undefined;
return {
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("#reference-")) {
return (
<CitationLink
href={href}
{...props}
onReferenceClick={onCitationClick}
/>
);
if (href?.startsWith("#reference-")) {
return (
<CitationLink
href={href}
{...props}
/>
);
}
return <a href={href} {...props} />;
},
};
}, [displaySources.length, onCitationClick]);
}, [displaySources.length]);
return (
<MarkdownContent
@@ -335,7 +312,8 @@ function ToolCall({
result,
isLast = false,
isLoading = false,
skillDisplayMap = new Map(),
skillDisplayMap = new Map(),
displaySources = [],
ragModeEnabled = false,
}: {
id?: string;
@@ -345,7 +323,8 @@ function ToolCall({
result?: string | Record<string, unknown>;
isLast?: boolean;
isLoading?: boolean;
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
}) {
const { t } = useI18n();
@@ -354,21 +333,28 @@ function ToolCall({
? null
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
null);
const displayItems =
displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))
: [];
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
const wikiSearchLabel = name === "llmwiki_search"
? wikiQuery
? `Wiki 文档检索 "${wikiQuery}"`
: "Wiki 文档检索"
: null;
const displayItems =
displayMatch && result !== undefined
? syncDisplaySourceIndexes(rankDisplaySources(normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))), displaySources)
: [];
const isLlmWikiTool = name === "llmwiki_search" || name === "llmwiki_read_wiki_page" || name === "llmwiki_browse_wiki_index";
const wikiQuery = isLlmWikiTool ? resolveSearchQuery(args, result) : "";
const wikiSearchLabel = name === "llmwiki_search"
? wikiQuery
? `Wiki 文档检索 "${wikiQuery}"`
: "Wiki 文档检索"
: name === "llmwiki_read_wiki_page"
? wikiQuery
? `读取 Wiki 页面「${wikiQuery}」`
: "读取 Wiki 页面"
: name === "llmwiki_browse_wiki_index"
? "读取 Wiki 索引与知识库摘要"
: null;
if (displayItems.length > 0) {
return (
<ChainOfThoughtStep
@@ -394,12 +380,12 @@ function ToolCall({
const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
skillName: name,
mode: "citation" as const,
}));
const defaultItems = syncDisplaySourceIndexes(rankDisplaySources(normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
skillName: name,
mode: "citation" as const,
}))), displaySources);
return (
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
<DisplayResultsList results={defaultItems} />
@@ -859,9 +845,21 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
type CoTStep = CoTReasoningStep | CoTToolCallStep;
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
const steps: CoTStep[] = [];
for (const message of messages) {
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
const steps: CoTStep[] = [];
// A streamed ToolMessage normally carries tool_call_id. If a provider
// omits it, the legacy name-based fallback is safe only for a unique tool
// name. Reusing one llmwiki_search result for several parallel calls makes
// every card appear to come from the same knowledge base.
const toolCallNameCounts = new Map<string, number>();
for (const message of messages) {
if (message.type !== "ai") continue;
for (const toolCall of message.tool_calls ?? []) {
const key = toolCall.name.toLowerCase();
toolCallNameCounts.set(key, (toolCallNameCounts.get(key) ?? 0) + 1);
}
}
for (const message of messages) {
if (message.type === "ai") {
const reasoning = extractReasoningContentFromMessage(message);
if (reasoning) {
@@ -902,11 +900,12 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
};
const toolCallId = tool_call.id;
if (toolCallId) {
const toolCallResult = findToolCallResult(
toolCallId,
messages,
tool_call.name,
);
const toolCallResult = findToolCallResult(
toolCallId,
messages,
tool_call.name,
{ allowNameFallback: toolCallNameCounts.get(tool_call.name.toLowerCase()) === 1 },
);
if (toolCallResult !== undefined) {
if (typeof toolCallResult === "string") {
try {
@@ -951,9 +950,27 @@ function WebSearchResultsList({ results }: { results: SearchResultItem[] }) {
);
}
function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
const [open, setOpen] = useState(false);
return (
function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
const [open, setOpen] = useState(false);
const orderedResults = useMemo(
() =>
results
.map((item, originalOrder) => ({ item, originalOrder }))
.sort((left, right) => {
const leftScore =
typeof left.item.score === "number" && Number.isFinite(left.item.score)
? left.item.score
: Number.NEGATIVE_INFINITY;
const rightScore =
typeof right.item.score === "number" && Number.isFinite(right.item.score)
? right.item.score
: Number.NEGATIVE_INFINITY;
return rightScore - leftScore || left.originalOrder - right.originalOrder;
})
.map(({ item }) => item),
[results],
);
return (
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
<CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs">
<ChevronDownIcon
@@ -965,13 +982,52 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
<span>{results.length} 条真实返回结果</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 flex flex-col gap-2">
{results.map((item) => (
<SourceCard key={`${item.skillName}-${item.index}`} source={item} />
))}
{orderedResults.map((item) => (
<SourceCard
key={`${item.skillName}-${item.index}`}
source={item}
/>
))}
</CollapsibleContent>
</Collapsible>
);
}
}
function rankDisplaySources(
sources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
return sources
.map((source, originalOrder) => ({ source, originalOrder }))
.sort((left, right) => {
const leftScore =
typeof left.source.score === "number" && Number.isFinite(left.source.score)
? left.source.score
: Number.NEGATIVE_INFINITY;
const rightScore =
typeof right.source.score === "number" && Number.isFinite(right.source.score)
? right.source.score
: Number.NEGATIVE_INFINITY;
return rightScore - leftScore || left.originalOrder - right.originalOrder;
})
.map(({ source }, index) => ({ ...source, index: index + 1 }));
}
function syncDisplaySourceIndexes(
sources: RuntimeDisplaySource[],
globalSources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
const indexByCitationId = new Map(
globalSources.flatMap((source) => {
const id = typeof source.raw?.citation_id === "string" ? source.raw.citation_id : "";
return id ? [[id, source.index] as const] : [];
}),
);
return sources.map((source) => {
const id = typeof source.raw?.citation_id === "string" ? source.raw.citation_id : "";
const index = id ? indexByCitationId.get(id) : undefined;
return index ? { ...source, index } : source;
});
}
function SearchResultCard({ item }: { item: SearchResultItem }) {
const [open, setOpen] = useState(false);
@@ -76,7 +76,8 @@ import {
getMultiRoundRagTrace,
isMultiRoundRagMessage,
} from '@/core/threads/use-multi-round-rag';
import { cn } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { WeKnoraSourceDrawer } from '@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer';
import { CitationLink } from '../citations/citation-link';
import { CopyButton } from '../copy-button';
@@ -890,11 +891,11 @@ function FrontendRagSourcesPanel({
</span>
)}
</div>
{source.snippet && (
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
{source.snippet}
</p>
)}
{(source.content || source.snippet) && (
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
{source.content || source.snippet}
</p>
)}
</button>
))}
</CollapsibleContent>
@@ -908,8 +909,26 @@ export function SingleRoundRagCitationDialog({
}: {
source: RuntimeDisplaySource | null;
onOpenChange: (open: boolean) => void;
}) {
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
}) {
const isLlmWikiSource =
source?.skillName?.startsWith('llmwiki_') ||
source?.type?.startsWith('llmwiki_') ||
source?.raw?.type === 'llmwiki_wiki_index_intro';
if (isLlmWikiSource) {
const content = source?.content || source?.snippet || '';
return (
<WeKnoraSourceDrawer
open={!!source}
target={null}
title={source?.title || 'Wiki 文档'}
quote={content}
inlineContent={content}
inlineKnowledgeBaseId={String(source?.raw?.knowledge_base_id || '')}
onOpenChange={onOpenChange}
/>
);
}
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
return (
<Dialog open={!!source} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
@@ -47,7 +47,8 @@ import {
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
@@ -97,7 +98,8 @@ export function RecentChatList({
}) {
const { t } = useI18n();
const router = useRouter();
const pathname = usePathname();
const pathname = usePathname();
const { isMobile, setOpenMobile } = useSidebar();
const [internalCollapsed, setInternalCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
);
@@ -139,15 +141,15 @@ export function RecentChatList({
});
if (threadIndex > -1) {
if (threads[threadIndex + 1]) {
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!, pathname);
} else if (threads[threadIndex - 1]) {
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!, pathname);
}
}
void router.push(nextThreadPath);
}
},
[agentIdFromPath, deleteThread, router, threadIdFromPath, threads],
[agentIdFromPath, deleteThread, pathname, router, threadIdFromPath, threads],
);
const handleRenameClick = useCallback(
@@ -248,7 +250,7 @@ export function RecentChatList({
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{shownThreads.map((thread) => {
const href = listPathOfThread(thread);
const href = listPathOfThread(thread, pathname);
const isActive = href === pathname;
const isBoot = isBootstrapThread(thread);
const projectType = myProjectConversationTypeOfThread(thread);
@@ -273,7 +275,8 @@ export function RecentChatList({
>
<Link
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={href}
href={href}
onClick={() => { if (isMobile) setOpenMobile(false); }}
>
{isBoot ? (
<Bot
@@ -0,0 +1,67 @@
"use client";
import { BrainIcon, Loader2Icon } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import {
useMemoryRuntimeSettings,
useUpdateMemoryRuntimeSettings,
} from "@/core/system-settings/hooks";
export function MemoryRuntimeSettingsPage() {
const { data, isLoading, isError } = useMemoryRuntimeSettings();
const update = useUpdateMemoryRuntimeSettings();
const enabled = data?.enabled ?? false;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ enabled: checked });
toast.success(checked ? "问答记忆已开启" : "问答记忆已关闭");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<BrainIcon className="size-5" />
问答记忆
</h3>
<p className="text-muted-foreground text-sm">
全局控制问答是否读取和写入长期记忆。系统默认永久关闭,只有管理员在这里显式开启后才会生效。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">启用问答记忆</div>
<p className="text-muted-foreground text-sm">
关闭时不会加载记忆中间件、不会向提示词注入记忆,也不会在回答结束后调用模型提取记忆。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label="启用问答记忆"
/>
)}
</div>
{isError && (
<p className="text-destructive text-sm">
无法读取记忆开关,系统将按关闭处理。
</p>
)}
<p className="text-muted-foreground text-xs">
修改对下一次发起的问答生效;已生成的历史记忆文件不会被自动删除。
</p>
</section>
);
}
@@ -1,6 +1,6 @@
"use client";
import { BellIcon, 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 {
@@ -13,7 +13,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
@@ -61,12 +61,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
// },
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
// 记忆已移至侧边栏入口 (/page/workspace/memory)
// {
// id: "memory",
// label: t.settings.sections.memory,
// icon: BrainIcon,
// },
...(isAdmin
? [{ id: "memory", label: "问答记忆", icon: BrainIcon }]
: []),
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
@@ -132,7 +129,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "appearance" && (
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
)}
{activeSection === "memory" && <MemorySettingsPage />}
{activeSection === "memory" && isAdmin && (
<MemoryRuntimeSettingsPage />
)}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
{activeSection === "about" && <AboutSettingsPage />}
@@ -9,7 +9,10 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import {
resolveWeKnoraSourceTarget,
type WeKnoraSourceTarget,
} from "@/core/llmwiki/source-links";
import { useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { reasoningPlugins } from "@/core/streamdown";
@@ -56,6 +59,7 @@ export function SourceCard({
}) {
const [relatedOpen, setRelatedOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [linkedTarget, setLinkedTarget] = useState<WeKnoraSourceTarget | null>(null);
const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const previewTarget = resolveWeKnoraSourceTarget({
@@ -65,7 +69,11 @@ export function SourceCard({
const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
const isWikiIndexSource =
source.type === "llmwiki_wiki_index_intro" ||
source.raw?.type === "llmwiki_wiki_index_intro";
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
const opensWikiDrawer = Boolean(previewTarget) || isWikiIndexSource;
const matchedHeading = isWikiSource
? String(
(source.raw?.matched_heading as string | undefined) ||
@@ -78,7 +86,9 @@ export function SourceCard({
const summary = isWikiSource && typeof source.raw?.summary === "string"
? source.raw.summary
: "";
const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
// A retrieval card represents the matched passage. A document/Wiki summary
// is useful only when the result did not include the actual passage.
const snippet = plainTextReferencePreview(content || summary).slice(0, 280);
const score =
typeof source.score === "number"
? source.score <= 1
@@ -95,9 +105,13 @@ export function SourceCard({
score ? `相关度 ${score}` : null,
].filter(Boolean);
const isCitation = source.mode === "citation";
const showReferenceNumber = isCitation;
// Tool-trace cards are evidence previews, not the answer's reference list.
// Only the right-side reference panel receives `referenceTarget`, so keep
// citation numbers there and avoid presenting a second, misleading number
// in the middle retrieval trace.
const showReferenceNumber = isCitation && referenceTarget;
const openSource = () => {
if (previewTarget) {
if (opensWikiDrawer) {
setDetailOpen(true);
} else {
setRelatedOpen(true);
@@ -174,16 +188,18 @@ export function SourceCard({
</div>
)}
</div>
{previewTarget && (
{(opensWikiDrawer || linkedTarget) && (
<WeKnoraSourceDrawer
open={detailOpen}
target={previewTarget}
target={linkedTarget ?? previewTarget}
title={source.title}
quote={content}
inlineContent={isWikiIndexSource ? content : ""}
inlineKnowledgeBaseId={String(source.raw?.knowledge_base_id || "")}
onOpenChange={setDetailOpen}
/>
)}
{!previewTarget && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
{!opensWikiDrawer && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
<DialogHeader>
<div className="flex items-start justify-between gap-3 pr-8">
@@ -235,6 +251,18 @@ export function SourceCard({
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
linkClassName={(href) =>
resolveWeKnoraSourceTarget({ url: href })?.wikiSlug
? "font-medium text-red-600 decoration-red-300 hover:text-red-700 hover:decoration-red-600 dark:text-red-400"
: ""
}
onLinkClick={(event, href) => {
const target = resolveWeKnoraSourceTarget({ url: href });
if (!target) return;
event.preventDefault();
setLinkedTarget(target);
setDetailOpen(true);
}}
/>
</article>
</div>
@@ -0,0 +1,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 }];
}
@@ -54,6 +54,13 @@ export type WorkspaceCompactNavItem = MenuItem & {
};
export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
{
id: "workspace-projects",
label: "项目空间",
icon: BookOpen,
path: "/page/workspace/projects",
match: (pathname) => pathname.startsWith("/page/workspace/projects"),
},
{
id: "workspace-new-chat",
label: "新对话",
@@ -98,13 +105,6 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/skills",
match: (pathname) => pathname.startsWith("/page/workspace/skills"),
},
{
id: "workspace-memory",
label: "记忆",
icon: BrainIcon,
path: "/page/workspace/memory",
match: (pathname) => pathname.startsWith("/page/workspace/memory"),
},
{
id: "workspace-ai-writing",
label: "AI写作",
@@ -18,15 +18,18 @@ import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
export function WorkspaceSidebar({
compactChrome = false,
forceCollapsed = false,
className,
...props
}: React.ComponentProps<typeof Sidebar> & {
compactChrome?: boolean;
forceCollapsed?: boolean;
}) {
export function WorkspaceSidebar({
compactChrome = false,
forceCollapsed = false,
wxCommonAgents = false,
className,
...props
}: React.ComponentProps<typeof Sidebar> & {
compactChrome?: boolean;
forceCollapsed?: boolean;
/** Enables wx's shared Agent Gallery category selection. */
wxCommonAgents?: boolean;
}) {
const { open: isSidebarOpen } = useSidebar();
const { theme } = useTheme();
const pathname = usePathname();
@@ -35,6 +38,8 @@ export function WorkspaceSidebar({
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa =
wxCommonAgents ||
pathname.startsWith("/page/workspace/projects") ||
pathname.startsWith("/page/workspace/chats") ||
pathname.startsWith("/page/strategy/chats");
@@ -84,7 +89,7 @@ export function WorkspaceSidebar({
{isGeneralQa ? (
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
<GeneralQaSidebar />
<GeneralQaSidebar wxCommonAgents={wxCommonAgents} />
) : (
<>
<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 { downloadMarkdownAsDocx } from "../scheduled-tasks/markdown-docx";
import { prepareGeneratedHtml } from "../utils/legacy-html-preview";
import { getFileName } from "../utils/files";
import { urlOfArtifact } from "./utils";
@@ -60,12 +61,23 @@ export async function downloadArtifactFile({
const filename =
filenameFromContentDisposition(cd) ?? getFileName(filepath);
const blob = await res.blob();
const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
const downloadBlob =
contentType && blob.type !== contentType
? new Blob([await blob.arrayBuffer()], { type: contentType })
: blob;
const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
const isHtml = contentType === "text/html" || /\.html?$/i.test(filename);
let downloadBlob: Blob;
if (isHtml) {
downloadBlob = new Blob(
[
prepareGeneratedHtml(await res.text()),
],
{ type: "text/html;charset=utf-8" },
);
} else {
const blob = await res.blob();
downloadBlob =
contentType && blob.type !== contentType
? new Blob([await blob.arrayBuffer()], { type: contentType })
: blob;
}
const objectUrl = URL.createObjectURL(downloadBlob);
const a = document.createElement("a");
a.href = objectUrl;
@@ -111,7 +123,16 @@ export async function openArtifactInNewTab({
if (!res.ok) {
throw new Error(await errorMessageFromResponse(res));
}
const blob = await res.blob();
const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
const isHtml = contentType === "text/html" || /\.html?$/i.test(filepath);
const blob = isHtml
? new Blob(
[
prepareGeneratedHtml(await res.text()),
],
{ type: "text/html;charset=utf-8" },
)
: await res.blob();
const objectUrl = URL.createObjectURL(blob);
const w = window.open(objectUrl, "_blank", "noopener,noreferrer");
if (w) w.opener = null;
@@ -4,9 +4,66 @@ import test from "node:test";
import {
isWriteFileInFlight,
latestInFlightWriteFilePreview,
latestPreparedHtmlArtifact,
resolveWriteFilePath,
} from "./write-file-in-flight";
test("latestPreparedHtmlArtifact opens the output created by template prepare", () => {
const preview = latestPreparedHtmlArtifact([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "tpl1",
name: "page_report_template",
args: { action: "prepare" },
},
],
},
{
type: "tool",
tool_call_id: "tpl1",
content: JSON.stringify({
ok: true,
working_copy: "/mnt/user-data/outputs/人工智能发展报告.html",
}),
},
]);
assert.deepEqual(preview, {
artifactPath: "/mnt/user-data/outputs/人工智能发展报告.html",
previewKey: "page-template:tpl1",
});
});
test("latestPreparedHtmlArtifact ignores workspace and failed prepares", () => {
assert.equal(
latestPreparedHtmlArtifact([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "tpl1",
name: "page_report_template",
args: { action: "prepare" },
},
],
},
{
type: "tool",
tool_call_id: "tpl1",
content: JSON.stringify({
ok: true,
working_copy: "/mnt/user-data/workspace/draft.html",
}),
},
]),
null,
);
});
test("latestInFlightWriteFilePreview opens a write_file that is not the last tool call", () => {
const preview = latestInFlightWriteFilePreview([
{
@@ -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) {
if (!toolCallId) {
return false;
+6 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -125,5 +125,9 @@ export function AuthSyncWatcher({
};
}, [disabled, location.pathname, navigate, queryClient]);
return children ? <Fragment key={revision}>{children}</Fragment> : null;
// A keyed Fragment is transformed by the development tagger, which injects
// data-lov-id. React.Fragment only accepts key and children, so use a
// display:contents element to preserve the remount behavior without adding
// a layout box.
return children ? <div className="contents" key={revision}>{children}</div> : null;
}
+27 -30
View File
@@ -96,9 +96,6 @@ export const enUS: Translations = {
mode: "Mode",
flashMode: "Flash",
flashModeDescription: "Fast and efficient, but may not be accurate",
turboMode: "Turbo",
turboModeDescription:
"Talk to the model directly with no agent tools or skills, for lower first-token latency",
reasoningMode: "Reasoning",
reasoningModeDescription:
"Reasoning before action, balance between time and accuracy",
@@ -451,17 +448,17 @@ export const enUS: Translations = {
system: "System",
light: "Light",
dark: "Dark",
lightblue: "Light Blue",
lightgreen: "Green",
iceblue: "Glacier Blue",
lightyellow: "Pale Yellow",
lightblue: "Light Blue",
lightgreen: "Green",
iceblue: "Glacier Blue",
lightyellow: "Pale Yellow",
systemDescription: "Match the operating system preference automatically.",
lightDescription: "Bright palette with higher contrast for daytime.",
darkDescription: "Dim palette that reduces glare for focus.",
lightblueDescription: "A crisp light palette built around blue.",
lightgreenDescription: "A crisp light palette built around fresh green tones.",
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
lightblueDescription: "A crisp light palette built around blue.",
lightgreenDescription: "A crisp light palette built around fresh green tones.",
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
languageTitle: "Language",
languageDescription: "Switch between languages.",
},
@@ -548,13 +545,13 @@ export const enUS: Translations = {
filesSaveAction: "Save",
filesCancelEditAction: "Cancel",
filesDownloadAction: "Download",
filesCopyPathAction: "Copy path",
filesDiscardConfirm: "Discard unsaved changes?",
filesSaveSuccess: "File saved",
filesDeleteAction: "Delete",
filesDeleteConfirm: "Delete \"{path}\"? This cannot be undone.",
filesDeleteSuccess: "File deleted",
filesReadOnlyHint: "Only administrators can edit or delete built-in skill files.",
filesCopyPathAction: "Copy path",
filesDiscardConfirm: "Discard unsaved changes?",
filesSaveSuccess: "File saved",
filesDeleteAction: "Delete",
filesDeleteConfirm: "Delete \"{path}\"? This cannot be undone.",
filesDeleteSuccess: "File deleted",
filesReadOnlyHint: "Only administrators can edit or delete built-in skill files.",
viewDetailAction: "View skill details",
stateSheet: {
archivedTitle: "Archived skills",
@@ -594,26 +591,26 @@ export const enUS: Translations = {
chooseFile: "Choose file",
validating: "Validating archive…",
installing: "Installing…",
replace: "Upload again and replace",
replace: "Upload again and replace",
install: "Install",
cancel: "Cancel",
nameLabel: "Name",
descriptionLabel: "Description",
licenseLabel: "License",
toolsLabel: "Allowed tools",
warningsLabel: "Warnings",
conflictOwnedByMe:
"You already uploaded a skill named '{name}'. You may upload it again and replace your existing copy (its enabled state will be kept).",
conflictOwnedByMeWrongTarget:
"The custom skill '{name}' belongs to you. Switch the target to 'Custom skill' to replace it.",
conflictOwnedByOther:
"Skill '{name}' was uploaded by user '{owner}' and cannot be replaced. Rename the skill before uploading.",
conflictCustom:
"A custom skill named '{name}' already exists, but its uploader could not be identified. Rename it to avoid overwriting someone else's skill.",
warningsLabel: "Warnings",
conflictOwnedByMe:
"You already uploaded a skill named '{name}'. You may upload it again and replace your existing copy (its enabled state will be kept).",
conflictOwnedByMeWrongTarget:
"The custom skill '{name}' belongs to you. Switch the target to 'Custom skill' to replace it.",
conflictOwnedByOther:
"Skill '{name}' was uploaded by user '{owner}' and cannot be replaced. Rename the skill before uploading.",
conflictCustom:
"A custom skill named '{name}' already exists, but its uploader could not be identified. Rename it to avoid overwriting someone else's skill.",
conflictPublic:
"A built-in public skill named '{name}' already exists and cannot be overwritten.",
conflictPublicAdminHint:
"A built-in public skill named '{name}' exists. Click 'Upload again and replace' to overwrite it.",
conflictPublicAdminHint:
"A built-in public skill named '{name}' exists. Click 'Upload again and replace' to overwrite it.",
installSuccess: "Skill '{name}' installed.",
installSuccessReplaced: "Skill '{name}' replaced.",
rejectInvalidExt: "Only .zip or .skill files are accepted.",
+21 -23
View File
@@ -91,8 +91,6 @@ export interface Translations {
mode: string;
flashMode: string;
flashModeDescription: string;
turboMode: string;
turboModeDescription: string;
reasoningMode: string;
reasoningModeDescription: string;
proMode: string;
@@ -385,17 +383,17 @@ export interface Translations {
system: string;
light: string;
dark: string;
lightblue: string;
lightgreen: string;
iceblue: string;
lightyellow: string;
lightblue: string;
lightgreen: string;
iceblue: string;
lightyellow: string;
systemDescription: string;
lightDescription: string;
darkDescription: string;
lightblueDescription: string;
lightgreenDescription: string;
iceblueDescription: string;
lightyellowDescription: string;
lightblueDescription: string;
lightgreenDescription: string;
iceblueDescription: string;
lightyellowDescription: string;
languageTitle: string;
languageDescription: string;
};
@@ -477,13 +475,13 @@ export interface Translations {
filesSaveAction: string;
filesCancelEditAction: string;
filesDownloadAction: string;
filesCopyPathAction: string;
filesDiscardConfirm: string;
filesSaveSuccess: string;
filesDeleteAction: string;
filesDeleteConfirm: string;
filesDeleteSuccess: string;
filesReadOnlyHint: string;
filesCopyPathAction: string;
filesDiscardConfirm: string;
filesSaveSuccess: string;
filesDeleteAction: string;
filesDeleteConfirm: string;
filesDeleteSuccess: string;
filesReadOnlyHint: string;
viewDetailAction: string;
stateSheet: {
archivedTitle: string;
@@ -527,12 +525,12 @@ export interface Translations {
nameLabel: string;
descriptionLabel: string;
licenseLabel: string;
toolsLabel: string;
warningsLabel: string;
conflictOwnedByMe: string;
conflictOwnedByMeWrongTarget: string;
conflictOwnedByOther: string;
conflictCustom: string;
toolsLabel: string;
warningsLabel: string;
conflictOwnedByMe: string;
conflictOwnedByMeWrongTarget: string;
conflictOwnedByOther: string;
conflictCustom: string;
conflictPublic: string;
conflictPublicAdminHint: string;
installSuccess: string;
+24 -26
View File
@@ -95,8 +95,6 @@ export const zhCN: Translations = {
mode: "模式",
flashMode: "快速",
flashModeDescription: "快速且高效的完成任务,但可能不够精准",
turboMode: "极速",
turboModeDescription: "直连大模型流式问答,不走智能体工具与技能,首字更快",
reasoningMode: "思考",
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
proMode: "专业问答",
@@ -431,17 +429,17 @@ export const zhCN: Translations = {
system: "系统",
light: "浅色",
dark: "深色",
lightblue: "浅蓝",
lightgreen: "绿色",
iceblue: "冰川蓝",
lightyellow: "浅黄色",
lightblue: "浅蓝",
lightgreen: "绿色",
iceblue: "冰川蓝",
lightyellow: "浅黄色",
systemDescription: "自动跟随系统主题。",
lightDescription: "更明亮的配色,适合日间使用。",
darkDescription: "更暗的配色,减少眩光方便专注。",
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
lightgreenDescription: "采用清新绿色的浅色配色。",
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
lightgreenDescription: "采用清新绿色的浅色配色。",
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
languageTitle: "语言",
languageDescription: "在不同语言之间切换。",
},
@@ -526,11 +524,11 @@ export const zhCN: Translations = {
filesDownloadAction: "下载",
filesCopyPathAction: "复制路径",
filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
filesSaveSuccess: "文件已保存",
filesDeleteAction: "删除",
filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。",
filesDeleteSuccess: "文件已删除",
filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。",
filesSaveSuccess: "文件已保存",
filesDeleteAction: "删除",
filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。",
filesDeleteSuccess: "文件已删除",
filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。",
viewDetailAction: "查看技能详情",
stateSheet: {
archivedTitle: "归档的技能",
@@ -568,24 +566,24 @@ export const zhCN: Translations = {
chooseFile: "选择文件",
validating: "正在校验文件…",
installing: "安装中…",
replace: "重复上传并覆盖",
replace: "重复上传并覆盖",
install: "安装",
cancel: "取消",
nameLabel: "名称",
descriptionLabel: "描述",
licenseLabel: "许可证",
toolsLabel: "允许的工具",
warningsLabel: "警告",
conflictOwnedByMe:
"您已上传过同名技能 “{name}”,可重复上传并覆盖原版本(原启用状态保留)。",
conflictOwnedByMeWrongTarget:
"同名技能 “{name}” 是您上传的自定义技能,请切换到「自定义技能」后覆盖。",
conflictOwnedByOther:
"技能 “{name}” 已由用户 “{owner}” 上传,不能覆盖,请修改技能名称后再上传。",
conflictCustom:
"已存在同名自定义技能 “{name}”,但无法确认上传用户,为避免误覆盖请修改技能名称。",
warningsLabel: "警告",
conflictOwnedByMe:
"您已上传过同名技能 “{name}”,可重复上传并覆盖原版本(原启用状态保留)。",
conflictOwnedByMeWrongTarget:
"同名技能 “{name}” 是您上传的自定义技能,请切换到「自定义技能」后覆盖。",
conflictOwnedByOther:
"技能 “{name}” 已由用户 “{owner}” 上传,不能覆盖,请修改技能名称后再上传。",
conflictCustom:
"已存在同名自定义技能 “{name}”,但无法确认上传用户,为避免误覆盖请修改技能名称。",
conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
conflictPublicAdminHint: `存在同名内置技能 '{name}'。点击「重复上传并覆盖」可替换。`,
conflictPublicAdminHint: `存在同名内置技能 '{name}'。点击「重复上传并覆盖」可替换。`,
installSuccess: `技能 '{name}' 已安装。`,
installSuccessReplaced: `技能 '{name}' 已替换。`,
rejectInvalidExt: `只接受 .zip 或 .skill 文件。`,
+39 -23
View File
@@ -38,30 +38,46 @@ export function buildAppHashUrl(path: string): string {
}
export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget | null {
if (!url?.startsWith("/weknora-source-preview?")) {
return null;
if (!url) return null;
// Older stored messages use this private marker URL. Keep parsing it so old
// conversations remain clickable, but never generate it for new links: it
// is not a real route and becomes a 404 if an event handler is unavailable.
if (url.startsWith("/weknora-source-preview?")) {
const queryIndex = url.indexOf("?");
const params = new URLSearchParams(url.slice(queryIndex + 1));
const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
const chunkId = params.get("chunk_id")?.trim();
const documentId =
params.get("document_id")?.trim() ||
params.get("knowledge_id")?.trim() ||
"";
const wikiSlug =
params.get("wiki_slug")?.trim() ||
params.get("wikiSlug")?.trim() ||
params.get("slug")?.trim() ||
"";
return knowledgeBaseId && (wikiSlug || chunkId || documentId)
? {
knowledgeBaseId,
chunkId,
documentId: documentId || undefined,
wikiSlug: wikiSlug || undefined,
tab: wikiSlug ? "wiki" : "documents",
}
: null;
}
const queryIndex = url.indexOf("?");
const params = new URLSearchParams(url.slice(queryIndex + 1));
const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
const chunkId = params.get("chunk_id")?.trim();
const documentId =
params.get("document_id")?.trim() ||
params.get("knowledge_id")?.trim() ||
"";
const wikiSlug =
params.get("wiki_slug")?.trim() ||
params.get("wikiSlug")?.trim() ||
params.get("slug")?.trim() ||
"";
return knowledgeBaseId && (wikiSlug || chunkId || documentId)
? {
knowledgeBaseId,
chunkId,
documentId: documentId || undefined,
wikiSlug: wikiSlug || undefined,
tab: wikiSlug ? "wiki" : "documents",
}
// New drawer links use the same real HashRouter URL as the normal Wiki
// reader. This doubles as a safe browser-navigation fallback when React's
// Markdown click handler is not mounted (for example after a deployment).
const route = url.includes("#") ? url.slice(url.indexOf("#") + 1) : url;
const match = route.match(/^\/page\/workspace\/knowledge\/([^/?]+)\/wiki\/view(?:\?([^#]*))?$/);
if (!match) return null;
const knowledgeBaseId = decodeURIComponent(match[1] || "").trim();
const wikiSlug = new URLSearchParams(match[2] || "").get("wikiSlug")?.trim();
return knowledgeBaseId && wikiSlug
? { knowledgeBaseId, wikiSlug, tab: "wiki" }
: null;
}
@@ -20,6 +20,11 @@ export type SearchResultItem = {
published_date?: string;
publishedDate?: string;
date?: string;
/** Retrieval score supplied by the search provider, when available. */
score?: number;
/** Original retrieval payload type, retained for specialised result renderers. */
type?: string;
knowledge_base_id?: string;
};
const RESULT_LIST_KEYS = [
@@ -196,6 +201,25 @@ function pickFromLayers(
return undefined;
}
function pickNumberFromLayers(
layers: Record<string, unknown>[],
aliases: readonly string[],
): number | undefined {
for (const layer of layers) {
for (const alias of aliases) {
const value = layer[alias];
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
}
}
return undefined;
}
function pickByKeyPattern(
layers: Record<string, unknown>[],
pattern: RegExp,
@@ -326,6 +350,12 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
const recUuid = pickFromLayers(layers, ["recUuid", "rec_uuid"]);
const uuid = pickFromLayers(layers, ["uuid"]);
const docId = pickFromLayers(layers, ["docId", "doc_id", "documentId"]);
const score = pickNumberFromLayers(layers, [
"score",
"relevance_score",
"rerank_score",
"similarity",
]);
const reserved = new Set<string>();
for (const value of [title, content, url, source, author, date]) {
@@ -363,6 +393,9 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
published_date: date,
publishedDate: date,
date,
score,
type: pickString(record.type),
knowledge_base_id: pickString(record.knowledge_base_id, record.knowledgeBaseId, record.mapping_id),
};
}
+28 -25
View File
@@ -364,17 +364,18 @@ export function extractReasoningContentFromMessage(message: Message) {
if (message.type !== "ai") {
return null;
}
if (message.additional_kwargs) {
const reasoningContent = message.additional_kwargs.reasoning_content;
if (typeof reasoningContent === "string") {
return reasoningContent;
}
// Newer vLLM puts thinking in `reasoning` instead of `reasoning_content`.
const reasoning = message.additional_kwargs.reasoning;
if (typeof reasoning === "string") {
return reasoning;
}
}
if (
message.additional_kwargs &&
"reasoning_content" in message.additional_kwargs
) {
return message.additional_kwargs.reasoning_content as string | null;
}
if (
message.additional_kwargs &&
typeof message.additional_kwargs.reasoning === "string"
) {
return message.additional_kwargs.reasoning;
}
if (Array.isArray(message.content)) {
const part = message.content[0];
if (part && "thinking" in part) {
@@ -390,8 +391,9 @@ export function extractReasoningContentFromMessage(message: Message) {
export function removeReasoningContentFromMessage(message: Message) {
if (message.type !== "ai" || !message.additional_kwargs) {
return;
}
delete message.additional_kwargs.reasoning_content;
}
delete message.additional_kwargs.reasoning_content;
delete message.additional_kwargs.reasoning;
}
export function extractURLFromImageURLContent(
@@ -426,12 +428,12 @@ export function hasReasoning(message: Message) {
if (message.type !== "ai") {
return false;
}
if (typeof message.additional_kwargs?.reasoning_content === "string") {
return true;
}
if (typeof message.additional_kwargs?.reasoning === "string") {
return true;
}
if (typeof message.additional_kwargs?.reasoning_content === "string") {
return true;
}
if (typeof message.additional_kwargs?.reasoning === "string") {
return true;
}
if (Array.isArray(message.content)) {
const part = message.content[0];
// Compatible with the Anthropic gateway
@@ -558,11 +560,12 @@ export function hasSubagent(message: AIMessage) {
return false;
}
export function findToolCallResult(
toolCallId: string,
messages: Message[],
toolName?: string,
): string | Record<string, unknown> | undefined {
export function findToolCallResult(
toolCallId: string,
messages: Message[],
toolName?: string,
options?: { allowNameFallback?: boolean },
): string | Record<string, unknown> | undefined {
for (const message of messages) {
if (message.type !== "tool" || message.tool_call_id !== toolCallId) {
continue;
@@ -574,7 +577,7 @@ export function findToolCallResult(
}
// updates 流里偶发缺少 tool_call_id,但 content 已是检索结构
if (toolName) {
if (toolName && options?.allowNameFallback !== false) {
for (const message of messages) {
if (message.type !== "tool" || message.name !== toolName) {
continue;
@@ -10,6 +10,7 @@ export interface ManagedModel {
base_url: string | null;
request_timeout: number | null;
max_retries: number | null;
max_input_tokens: number | null;
max_tokens: number | null;
temperature: number | null;
stream_usage: boolean | null;
@@ -44,6 +45,7 @@ export interface ModelSavePayload {
base_url?: string | null;
request_timeout?: number | null;
max_retries?: number | null;
max_input_tokens?: number | null;
max_tokens?: number | null;
temperature?: number | null;
stream_usage?: boolean | null;
+5 -5
View File
@@ -4,11 +4,11 @@ export interface Model {
model: string;
display_name: string;
description?: string | null;
supports_thinking?: boolean;
supports_reasoning_effort?: boolean;
/** Execution profiles handled by the frontend instead of DeerFlow. */
execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
}
supports_thinking?: boolean;
supports_reasoning_effort?: boolean;
/** Execution profiles handled by the frontend instead of DeerFlow. */
execution_profile?: "single_round_rag" | "multi_round_rag";
}
export interface TokenUsageSettings {
enabled: boolean;
@@ -30,7 +30,7 @@ test("matches only workspace chats_iframe routes", () => {
assert.equal(isChatsIframeWorkspacePath("/landing"), false);
});
test("forces light theme for landing and chats_iframe routes only", () => {
test("forces the dedicated presentation themes for landing, iframe, and wx chat routes", () => {
assert.equal(resolveForcedThemeForPath("/landing"), "light");
assert.equal(
resolveForcedThemeForPath("/page/workspace/chats_iframe/new"),
@@ -41,6 +41,14 @@ test("forces light theme for landing and chats_iframe routes only", () => {
"light",
);
assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined);
assert.equal(
resolveForcedThemeForPath("/page/workspace/wx/chats/new"),
"dark",
);
assert.equal(
resolveForcedThemeForPath("/page/workspace/wx/agents/agent-1/chats/new"),
"dark",
);
});
test("recognizes an explicit chatMode=iframe independently of the legacy marker", () => {
@@ -3,6 +3,8 @@ import { resolveEmbedThemeMode } from "../embed/theme-mode.mjs";
const CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new";
const IFRAME_LOGIN_SOURCE = "iframe";
const STANDARD_CHATS_PATH = "/page/workspace/chats";
/** Dedicated full chat surface whose presentation is always deep blue. */
export const WX_FULL_CHAT_PATH_PREFIX = "/page/workspace/wx/";
function toSearchParams(search: string): URLSearchParams {
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
@@ -328,6 +330,10 @@ export function resolveForcedThemeForPath(
return "light";
}
if (pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX)) {
return "dark";
}
const isLoginPath = pathname === "/login" || pathname.startsWith("/login/");
if (
isChatsIframeWorkspacePath(pathname) ||
@@ -73,6 +73,7 @@ export const sidebarMenuItems: MenuItem[] = [
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{ id: "analysis-projects", label: "项目空间", icon: Layout, path: "/page/workspace/projects" },
{ id: "artifact-library", label: "资料库", icon: Library, path: "/page/workspace/library" },
{
id: "multi-agent-roundtable",
+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,
);
}
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(
@@ -468,7 +503,8 @@ export function shouldUseDefaultCitationMode(
normalizedName.includes("search") ||
normalizedName.includes("retriev") ||
normalizedName.includes("knowledge") ||
normalizedName.includes("query");
normalizedName.includes("query") ||
normalizedName.includes("wiki");
const isSkillInvocation = !!(
toolArgs &&
canInferSkillFromToolArgs(toolName) &&
@@ -495,12 +531,13 @@ export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceI
item.id ??
item.url,
title: item.title ?? item.source ?? `结果 ${index + 1}`,
type: "result",
type: item.type ?? "result",
snippet: item.snippet ?? item.content ?? "",
content: item.content ?? item.snippet,
source: item.source,
author: item.author,
time: item.published_date ?? item.publishedDate ?? item.date,
score: item.score,
url: item.url,
raw: item as unknown as Record<string, unknown>,
}));
@@ -519,6 +556,12 @@ export function applyInlineCitations(
return content;
}
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 byTitle = new Map<string, RuntimeDisplaySource>();
for (const source of referenceSources) {
@@ -530,13 +573,13 @@ export function applyInlineCitations(
}
}
const normalized = normalizeExistingCitationLinks(
const normalized = replaceStableCitationTokens(normalizeExistingCitationLinks(
content,
byIndex,
byUrl,
byTitle,
referenceSources,
);
), byCitationId);
const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g;
let cursor = 0;
@@ -570,6 +613,13 @@ export function applyInlineCitations(
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(
content: string,
byIndex: Map<number, RuntimeDisplaySource>,
@@ -314,6 +314,42 @@ export interface PublishSquaresUpdate {
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> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/publish-squares`,
@@ -601,6 +637,48 @@ export interface ModelConcurrencyUpdate {
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> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
+53 -2
View File
@@ -8,27 +8,32 @@ import {
loadCitationDisplaySettings,
loadLeaderboardSettings,
loadModelConcurrencySettings,
loadMemoryRuntimeSettings,
loadPluginManagementSettings,
loadPrivateSquareStatus,
loadPromptPrefixSettings,
loadPublishSquares,
loadSkillCompressionSettings,
loadStrategyProjectSettings,
loadWxCommonAgents,
updateAgentModuleConfig,
updateAppearanceSettings,
updateCitationDisplaySettings,
updateLeaderboardSettings,
updateModelConcurrencySettings,
updateMemoryRuntimeSettings,
updatePluginManagementSettings,
updatePrivateSquareSettings,
updatePromptPrefixSettings,
updatePublishSquares,
updateSkillCompressionSettings,
updateStrategyProjectSettings,
updateWxCommonAgents,
type AppearanceUpdate,
type CitationDisplayUpdate,
type LeaderboardSettingsUpdate,
type ModelConcurrencyUpdate,
type MemoryRuntimeSettings,
type PluginManagementUpdate,
type PrivateSquareUpdate,
type PromptPrefixUpdate,
@@ -47,6 +52,7 @@ const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] 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 STRATEGY_PROJECTS_KEY = ["systemSettings", "strategyProjects"] as const;
@@ -111,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 gallery sub-tabs and the publish-flow square picker both need it. */
export function usePublishSquares() {
export function usePublishSquares({ enabled = true }: { enabled?: boolean } = {}) {
return useQuery({
queryKey: PUBLISH_SQUARES_KEY,
queryFn: () => loadPublishSquares(),
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
enabled,
});
}
@@ -207,7 +234,9 @@ export function useLayoutMode(): [LayoutMode, (mode: LayoutMode) => void] {
const agentModuleKey = (key: string) =>
["systemSettings", "agentModule", key] as const;
export function useAgentModuleConfig(key: string) {
export function useAgentModuleConfig(
key: string,
) {
return useQuery({
queryKey: agentModuleKey(key),
queryFn: () => loadAgentModuleConfig(key),
@@ -216,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) {
const queryClient = useQueryClient();
return useMutation({
+54 -56
View File
@@ -16,14 +16,14 @@ import { rememberAskTime } from "../chat-timestamps";
import { getBackendBaseURL, getLangGraphBaseURL } from "../config";
import { useI18n } from "../i18n/hooks";
import { OptType, writeYLog } from "../log/y-log";
import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings";
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "../settings";
import { useCitationDisplaySettings } from "../system-settings/hooks";
import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings";
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "../settings";
import { useCitationDisplaySettings } from "../system-settings/hooks";
import { useUpdateSubtask } from "../tasks/context";
import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
@@ -571,11 +571,11 @@ export function useThreadStream({
onToolStart,
onToolEnd,
}: ThreadStreamOptions) {
const { t } = useI18n();
const [localSettings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const { t } = useI18n();
const [localSettings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
// Track the thread ID that is currently streaming to handle thread changes during streaming
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
// Ref to track current thread ID across async callbacks without causing re-renders,
@@ -826,20 +826,14 @@ export function useThreadStream({
return;
}
// Context compression about to run a (blocking) summarization LLM call —
// surface a transient hint so the wait before the answer streams isn't a
// silent dead air. Emitted by DeerFlowSummarizationMiddleware.
// Background compaction stays silent, including events from older backends.
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
event.type === "context_compacting" &&
"message" in event &&
typeof event.message === "string" &&
event.message.trim()
event.type === "context_compacting"
) {
const e = event as { type: "context_compacting"; message: string };
toast(e.message);
return;
}
},
onError(error) {
@@ -886,8 +880,15 @@ export function useThreadStream({
// 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
// different tab's cookie kept overriding this tab's Bearer token.
if (isThreadNotAccessibleError(message)) {
const userLabel = buildCurrentUserLabel();
if (isThreadNotAccessibleError(message)) {
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(
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
);
@@ -1507,21 +1508,21 @@ export function useThreadStream({
...context,
...extraContext,
};
const selectedLlmWikiIds = Array.isArray(
mergedContext.llmwiki_knowledge_base_ids,
)
? mergedContext.llmwiki_knowledge_base_ids
: [];
const selectedAssistantKnowledgeIds = Array.isArray(
mergedContext.assistant_knowledge_base_ids,
)
? mergedContext.assistant_knowledge_base_ids
: [];
const ragModeEnabled =
(referenceModeEntryEnabled && localSettings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
selectedLlmWikiIds.length > 0 ||
selectedAssistantKnowledgeIds.length > 0;
const selectedLlmWikiIds = Array.isArray(
mergedContext.llmwiki_knowledge_base_ids,
)
? mergedContext.llmwiki_knowledge_base_ids
: [];
const selectedAssistantKnowledgeIds = Array.isArray(
mergedContext.assistant_knowledge_base_ids,
)
? mergedContext.assistant_knowledge_base_ids
: [];
const ragModeEnabled =
(referenceModeEntryEnabled && localSettings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
selectedLlmWikiIds.length > 0 ||
selectedAssistantKnowledgeIds.length > 0;
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
@@ -1579,7 +1580,6 @@ export function useThreadStream({
context: {
...mergedContext,
thinking_enabled: mergedContext.mode !== "flash",
thinking_force_disabled: mergedContext.mode === "flash",
is_plan_mode:
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
subagent_enabled: mergedContext.mode === "ultra",
@@ -1592,8 +1592,6 @@ export function useThreadStream({
: mergedContext.mode === "thinking"
? "low"
: undefined),
memory_injection_enabled:
mergedContext.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled,
summarization_enabled: contextCompressionEnabled,
thread_id: threadId,
@@ -1640,18 +1638,18 @@ export function useThreadStream({
sendInFlightRef.current = false;
}
},
[
thread,
_handleOnStart,
t.uploads.uploadingFiles,
context,
referenceModeEntryEnabled,
localSettings.display.ragModeEnabled,
queryClient,
runOptions,
confirmOrRejoin,
],
);
[
thread,
_handleOnStart,
t.uploads.uploadingFiles,
context,
referenceModeEntryEnabled,
localSettings.display.ragModeEnabled,
queryClient,
runOptions,
confirmOrRejoin,
],
);
const handleThreadStop = useCallback(async () => {
const tid = threadIdRef.current;
@@ -1700,7 +1698,7 @@ export function useThreadStream({
? undefined
: thread.error;
const mergedThread = {
const mergedThread = {
...thread,
error: visibleThreadError,
values: mergedValues,
@@ -1710,7 +1708,7 @@ export function useThreadStream({
: mergedMessages,
isLoading: thread.isLoading || streamHoldOpen,
stop: handleThreadStop,
} as typeof thread;
} as typeof thread;
// 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。
// 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话
@@ -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");
});
+16 -19
View File
@@ -2,38 +2,35 @@ import type { Message, Thread } from "@langchain/langgraph-sdk";
import type { Todo } from "../todos";
export interface AgentThreadState extends Record<string, unknown> {
title: string;
title_provisional?: boolean;
messages: Message[];
artifacts: string[];
todos?: Todo[];
}
export interface AgentThreadState extends Record<string, unknown> {
title: string;
title_provisional?: boolean;
messages: Message[];
artifacts: string[];
todos?: Todo[];
}
export interface AgentThreadContext extends Record<string, unknown> {
thread_id: string;
model_name: string | undefined;
thinking_enabled: boolean;
/** Flash mode: force vLLM/Qwen ``enable_thinking=false`` even without a per-model disable shape. */
thinking_force_disabled?: boolean;
is_plan_mode: boolean;
subagent_enabled: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
writing_artifact_path?: string;
memory_injection_enabled?: boolean;
rag_mode_enabled?: boolean;
/** Per-request switch for context compression (上下文压缩). Default off. */
summarization_enabled?: boolean;
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
knowledge_rag_enabled?: boolean;
/** DeerFlow LLMWiki mapping ids selected for this conversation. */
llmwiki_knowledge_base_ids?: string[];
/** Assistant knowledge-base ids selected for this conversation. */
assistant_knowledge_base_ids?: string[];
/** Frontend-only execution paths that never enter Lead Agent (RAG / 极速直连). */
execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
agent_id?: string;
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
knowledge_rag_enabled?: boolean;
/** DeerFlow LLMWiki mapping ids selected for this conversation. */
llmwiki_knowledge_base_ids?: string[];
/** Assistant knowledge-base ids selected for this conversation. */
assistant_knowledge_base_ids?: string[];
/** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */
execution_profile?: "single_round_rag" | "multi_round_rag";
agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string;
}
@@ -1,312 +0,0 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { readRuntimeFlag } from "@/env";
import { createClientId } from "@/lib/client-id";
import { textOfMessage } from "./utils";
const STORAGE_PREFIX = "workspace.direct-chat.";
const MAX_HISTORY_MESSAGES = 20;
export function isDirectChatProfile(context: unknown): boolean {
return (
typeof context === "object" &&
context !== null &&
(context as { execution_profile?: unknown }).execution_profile === "direct"
);
}
/** 是否在模式菜单展示「极速」。仅 runtime-config.js 显式为 "true" 时开启,默认关闭。 */
export function isTurboModeVisible(): boolean {
return readRuntimeFlag("VITE_TURBO_MODE_VISIBLE");
}
function storageKey(threadId: string) {
return `${STORAGE_PREFIX}${threadId}`;
}
function readStoredMessages(threadId: string): Message[] {
if (typeof window === "undefined" || !threadId) return [];
try {
const raw = window.sessionStorage.getItem(storageKey(threadId));
const parsed: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? (parsed as Message[]) : [];
} catch {
return [];
}
}
function writeStoredMessages(threadId: string, messages: Message[]) {
if (typeof window === "undefined" || !threadId) return;
try {
window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages));
} catch {
// Quota errors must never interrupt an already-running answer.
}
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException
? error.name === "AbortError"
: error instanceof Error && error.name === "AbortError";
}
function historyFromMessages(messages: Message[]): Array<{
role: "user" | "assistant";
content: string;
}> {
const history: Array<{ role: "user" | "assistant"; content: string }> = [];
for (const message of messages) {
if (message.type !== "human" && message.type !== "ai") continue;
const content = textOfMessage(message)?.trim();
if (!content) continue;
history.push({
role: message.type === "human" ? "user" : "assistant",
content,
});
}
return history.slice(-MAX_HISTORY_MESSAGES);
}
function parseSseBlocks(buffer: string): {
events: Array<{ event?: string; data: string }>;
rest: string;
} {
const parts = buffer.split("\n\n");
const rest = parts.pop() ?? "";
const events: Array<{ event?: string; data: string }> = [];
for (const part of parts) {
let event: string | undefined;
const dataLines: string[] = [];
for (const line of part.split("\n")) {
if (line.startsWith(":")) continue;
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
}
if (dataLines.length > 0) {
events.push({ event, data: dataLines.join("\n") });
}
}
return { events, rest };
}
async function readErrorDetail(response: Response): Promise<string> {
try {
const payload: unknown = await response.json();
if (
payload &&
typeof payload === "object" &&
"detail" in payload &&
typeof (payload as { detail: unknown }).detail === "string"
) {
return (payload as { detail: string }).detail;
}
} catch {
// Fall through to status text.
}
return `极速问答失败(${response.status})`;
}
/**
* Frontend-only adapter that talks to Gateway ``POST /api/direct-chat/stream``.
* It never submits a LangGraph run or touches the lead-agent stack.
*/
export function useDirectChatMessages(threadId: string | undefined) {
const [messages, setMessages] = useState<Message[]>(() =>
readStoredMessages(threadId ?? ""),
);
const messagesRef = useRef(messages);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
const stored = readStoredMessages(threadId ?? "");
messagesRef.current = stored;
setMessages(stored);
setError(null);
}, [threadId]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
useEffect(() => () => controllerRef.current?.abort(), []);
const updateMessages = useCallback(
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
setMessages((current) => {
const next = updater(current);
messagesRef.current = next;
writeStoredMessages(targetThreadId, next);
return next;
});
},
[],
);
const stop = useCallback(() => controllerRef.current?.abort(), []);
const submit = useCallback(
async ({
targetThreadId,
query,
modelName,
}: {
targetThreadId: string;
query: string;
modelName?: string;
}) => {
if (isLoading) return;
const question = query.trim();
if (!question) return;
const humanId = createClientId("direct-human");
const assistantId = createClientId("direct-ai");
const clientTs = new Date().toISOString();
const history = historyFromMessages(messagesRef.current);
const userMessage: Message = {
id: humanId,
type: "human",
content: [{ type: "text", text: question }],
additional_kwargs: { client_ts: clientTs, direct_chat: true },
};
const initialAssistant: Message = {
id: assistantId,
type: "ai",
content: "",
additional_kwargs: { direct_chat: true },
};
updateMessages(targetThreadId, (current) => [
...current,
userMessage,
initialAssistant,
]);
setError(null);
setIsLoading(true);
const controller = new AbortController();
controllerRef.current = controller;
let answer = "";
const refreshAssistant = (content: string) => {
updateMessages(targetThreadId, (current) =>
current.map((item) =>
item.id === assistantId
? ({
...item,
content,
additional_kwargs: {
...item.additional_kwargs,
direct_chat: true,
},
} as Message)
: item,
),
);
};
try {
const response = await apiFetch(
`${getBackendBaseURL()}/api/direct-chat/stream`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
body: JSON.stringify({
model_name: modelName,
messages: [...history, { role: "user", content: question }],
}),
},
);
if (!response.ok) {
throw new Error(await readErrorDetail(response));
}
if (!response.body) {
throw new Error("极速问答未返回数据流。");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parsed = parseSseBlocks(buffer);
buffer = parsed.rest;
for (const block of parsed.events) {
let payload: unknown = null;
try {
payload = JSON.parse(block.data);
} catch {
continue;
}
if (block.event === "error") {
const detail =
payload &&
typeof payload === "object" &&
"detail" in payload &&
typeof (payload as { detail: unknown }).detail === "string"
? (payload as { detail: string }).detail
: "极速问答失败。";
throw new Error(detail);
}
if (
block.event === "delta" &&
payload &&
typeof payload === "object" &&
"text" in payload &&
typeof (payload as { text: unknown }).text === "string"
) {
answer += (payload as { text: string }).text;
refreshAssistant(answer);
}
if (
block.event === "result" &&
payload &&
typeof payload === "object" &&
"answer" in payload &&
typeof (payload as { answer: unknown }).answer === "string"
) {
answer = (payload as { answer: string }).answer;
refreshAssistant(answer);
}
}
}
if (!answer) {
throw new Error("模型未返回任何文本。");
}
} catch (caught) {
if (!isAbortError(caught)) {
const message =
caught instanceof Error ? caught.message : "极速问答失败。";
setError(message);
updateMessages(targetThreadId, (current) =>
current.map((item) =>
item.id === assistantId
? ({
...item,
content: answer || message,
additional_kwargs: {
...item.additional_kwargs,
direct_chat: true,
},
} as Message)
: item,
),
);
}
} finally {
if (controllerRef.current === controller) controllerRef.current = null;
setIsLoading(false);
}
},
[isLoading, updateMessages],
);
return { messages, isLoading, error, submit, stop };
}
+30 -7
View File
@@ -45,10 +45,17 @@ export function pathOfThread(
base = "/page/workspace",
) {
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 =
typeof thread === "string"
? pickAgentRef(context, null)
: agentRefOfThread(thread) ?? pickAgentRef(context, null);
: (agentRefOfThread(thread) ?? pickAgentRef(context, null));
return agentRef
? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
@@ -120,7 +127,9 @@ export function isSetupWizardThread(thread: {
metadata?: Record<string, unknown> | null;
}): boolean {
const threadType = thread.metadata?.thread_type;
return threadType === "writing_setup" || threadType === "report_structure_setup";
return (
threadType === "writing_setup" || threadType === "report_structure_setup"
);
}
/** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */
@@ -130,7 +139,10 @@ export function isHiddenFromRecentChats(thread: {
const metadata = thread.metadata;
if (metadata?.system === true) return true;
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 过滤时,不要再套一层「最近对话」隐藏。 */
@@ -151,18 +163,27 @@ export function bootstrapResumePath(threadId: string): string {
}
/** 列表项点击目标: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)
? bootstrapResumePath(thread.thread_id)
: pathOfThread(thread);
}
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 {
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];
}
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
export function sanitizeThreadTitleForDisplay(
title: string | null | undefined,
) {
if (!title) return "Untitled";
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", () => {
const followUp = [
{ type: "human", content: "写一篇论文" },
@@ -1,5 +1,7 @@
/** 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";
export function normalizeWritingPath(path: string | null | undefined): string {
@@ -105,6 +107,11 @@ export function shouldAutoOpenWritingArtifact(
if (!path) {
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) {
return true;
}
+12 -5
View File
@@ -148,11 +148,18 @@ export function getFileName(filepath: string) {
return filepath.split("/").pop()!;
}
export function getFileExtension(filepath: string) {
return filepath.split(".").pop()!.toLocaleLowerCase();
}
const IMAGE_EXTENSIONS = new Set([
export function getFileExtension(filepath: string) {
return filepath.split(".").pop()!.toLocaleLowerCase();
}
/** 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",
"jpg",
"jpeg",
@@ -46,3 +46,26 @@ test("prepareGeneratedHtml injects the ES5 chart size shim and anchor handler",
assert.match(out, /getElementsByTagName\("canvas"\)/);
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;
export function prepareGeneratedHtml(html: string): string {
return injectInPageAnchorHandler(injectLegacyChartShim(downlevelModernColors(html ?? "")));
export type GeneratedHtmlRuntimeAssets = {
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 {
@@ -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" : "";
}
@@ -165,7 +165,10 @@ async function externalTaskError(response: Response, fallback: string): Promise<
}
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 direct = firstText(
source.id,
@@ -178,9 +181,10 @@ function findProcessReportId(payload: unknown): string {
}
/**
* Create the external task system's process report in its unsubmitted state.
* The local gateway stores the returned id so a refresh can later submit the
* same report instead of creating a duplicate one.
* Create the external task system's process report **and submit it in one
* call**(submitStatus=1). Response shape is `{code:200, data:<id>, msg}` —
* `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(
input: EightBfTaskProcessReportInput,
@@ -196,7 +200,7 @@ export async function createEightBfTaskProcessReport(
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(
reportId: string,
input: EightBfTaskProcessReportInput,
@@ -32,6 +32,7 @@ import {
} from "@/position-roundtable/lib/positions";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
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 "@/position-roundtable/styles/intent-understanding.css";
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
@@ -46,6 +47,8 @@ export interface EightBfStep1PanelProps extends Step1PanelProps {
chain: BusinessChain | null;
canConfigureChain: boolean;
onConfigureChain: () => void;
/** 业务链被本面板更新(产物 position 等)后回调,页面据此刷新链配置。 */
onChainUpdated?: (chain: BusinessChain) => void;
selectedModel: string;
modelOptions: Array<{ label: string; value: string }>;
onSelectedModelChange: (model: string) => void;
@@ -84,6 +87,7 @@ export function EightBfStep1Panel({
chain,
canConfigureChain,
onConfigureChain,
onChainUpdated,
selectedModel,
modelOptions,
onSelectedModelChange,
@@ -153,6 +157,7 @@ export function EightBfStep1Panel({
chain={chain}
canConfigureChain={canConfigureChain}
onConfigureChain={onConfigureChain}
onChainUpdated={onChainUpdated}
onCollapse={() => setRightPanelOpen(false)}
step2Dialogues={step2Dialogues}
step2ThreadIds={step2ThreadIds}
@@ -299,6 +304,7 @@ function EightBfAgentWorkspace({
chain,
canConfigureChain,
onConfigureChain,
onChainUpdated,
onCollapse,
step2Dialogues,
step2ThreadIds,
@@ -310,6 +316,7 @@ function EightBfAgentWorkspace({
chain: BusinessChain | null;
canConfigureChain: boolean;
onConfigureChain: () => void;
onChainUpdated?: (chain: BusinessChain) => void;
onCollapse: () => void;
step2Dialogues: unknown[];
step2ThreadIds: ThreadIdMap | null;
@@ -448,6 +455,13 @@ function EightBfAgentWorkspace({
jobResponsibilities={selectedSeat?.job_responsibilities ?? ""}
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
skillsTitle="技能列表"
belowSkillsSlot={chain && selectedSeat ? (
<SeatArtifactPositionEditor
chain={chain}
seat={selectedSeat}
onUpdated={onChainUpdated}
/>
) : undefined}
showCommonQuestions
panelTitle="智能体配置"
embedded
@@ -612,3 +626,82 @@ function arrayValue(...values: unknown[]): string[] {
}
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,29 +88,50 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
() => buildEightBfTaskContext(task, taskDictionaries),
[task, taskDictionaries],
);
// 席位「产物提交 position」映射:业务链里逐席位配置的 artifact_position(agent_id → 值)。
// 提交产物时按产出该产物的席位智能体取值;未配置的席位不进 map(提交时回退默认 0)。
const artifactPositionByAgent = useMemo(() => {
const map = new Map<string, number>();
// 席位「产物提交 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)) {
map.set(seat.agent_id, 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;
}, [configuredChain]);
}, [chains, configuredChain]);
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
() => taskId ? {
taskId,
position: 0,
positionFor: (target) => {
const agentId = target.agentId?.trim();
return agentId ? artifactPositionByAgent.get(agentId) ?? null : null;
if (!agentId) return null;
return seatArtifactPositions[agentId.toLowerCase()]
?? seatArtifactPositions[target.agentName?.trim().toLowerCase() ?? ""]
?? null;
},
createProcessReport: createEightBfTaskProcessReport,
updateProcessReport: updateEightBfTaskProcessReport,
} : undefined,
[taskId, artifactPositionByAgent],
[taskId, seatArtifactPositions],
);
useEffect(() => {
@@ -214,6 +235,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
if (canConfigureChain) setIsSettingsOpen(true);
}, [canConfigureChain]);
// 「智能体配置」面板里保存产物 position 后,把最新链数据写回页面状态,
// 让 seatArtifactPositions / 提交产物取值即时生效(无需刷新页面)。
const handleChainUpdated = useCallback((updated: BusinessChain) => {
setChains((prev) => prev.map((chain) => (chain.id === updated.id ? updated : chain)));
}, []);
const toolbarSlot = (
<>
{canConfigureChain && isSettingsOpen && (
@@ -308,6 +335,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
chain={configuredChain}
canConfigureChain={canConfigureChain}
onConfigureChain={openChainSettings}
onChainUpdated={handleChainUpdated}
/>
)}
renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => (
@@ -358,6 +386,8 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
}
rootClassName="eightbf-roundtable"
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
// 会商「智能体配置」抽屉里只读展示各席位在业务链上配置的产物提交 position。
seatArtifactPositions={seatArtifactPositions}
/>
</div>
);
@@ -48,7 +48,7 @@ const MAX_INTENT_LENGTH = 500;
const MAX_SAMPLE_TEXT_LENGTH = 120000;
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: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
];
@@ -152,7 +152,7 @@ interface MaterialConfirmCardProps {
type MaterialSource = NonNullable<UserInterventionPayload['materialSource']>;
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: 'notebook', label: '我的空间', desc: '检索当前笔记空间' },
];
@@ -218,7 +218,7 @@ function MaterialConfirmCard({
async function handleReSearch() {
setSubmitting(true);
// 带输入 = 后端在素材收集专家配置的技能中匹配检索、新素材追加到列表;
// 带输入 = 后端从全部启用技能中匹配检索、新素材追加到列表;
// 无输入 = 重新生成检索词、覆盖式重检
await onSubmit({
action: 're_search',
@@ -257,7 +257,7 @@ export interface UserInterventionPayload {
approvedMaterialIds?: string[];
/** @deprecated 旧版多关键词追加。新版统一用 userQuery 单输入框 */
extraKeywords?: string[];
/** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端在素材收集专家配置的技能中匹配最相关的技能检索,新素材**追加**到列表。 */
/** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端从全部启用技能中匹配最相关的技能检索,新素材**追加**到列表。 */
userQuery?: string;
/** PAUSE-1 素材确认:本次补充/重新检索使用的检索方式。 */
materialSource?: "general" | "knowledge_base" | "notebook";
@@ -298,7 +298,7 @@ export interface AIWritingRequest {
writingMode: "strict" | "loose";
/** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认 true。 */
addReferences?: boolean;
/** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/
/** 检索类型:general=通用检索(素材收集专家从全部启用技能自由挑选检索,内置知识库检索保底)/
* knowledge_base=知识库检索(直连内网知识库 ES 接口,不走技能编排)/ notebook=我的空间 */
materialSource: "general" | "knowledge_base" | "notebook";
/** 不收集素材,直接用通用知识写作:勾选后跳过整个素材检索流程(含素材确认暂停点),
+12 -88
View File
@@ -13,7 +13,6 @@ import { useLocation } from "react-router-dom";
import { useRouteBase } from "@/core/route-base";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
@@ -49,7 +48,6 @@ import { useNotification } from "@/core/notification/hooks";
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks";
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from "@/core/threads/use-direct-chat";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { textOfMessage } from "@/core/threads/utils";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
@@ -375,6 +373,7 @@ export default function AgentChatPage({
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>(
() => pendingAssistantKnowledgeSelection.current ?? [],
);
const [knowledgeSuggestedQuestions, setKnowledgeSuggestedQuestions] = useState<string[]>([]);
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(
() => pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null,
);
@@ -562,26 +561,9 @@ export default function AgentChatPage({
},
});
const directChat = useDirectChatMessages(threadId);
const directChatSelected =
isDirectChatProfile(settings.context) && isTurboModeVisible();
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
// 流式 stub),成功且真实产物落地后自动撤出。
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
const displayThread = useMemo(() => {
const base = researchDisplayThread;
if (directChat.messages.length === 0 && !directChat.isLoading) {
return base;
}
const messages = [...base.messages, ...directChat.messages];
return {
...base,
values: { ...base.values, messages },
messages,
isLoading: base.isLoading || directChat.isLoading,
} as typeof base;
}, [directChat.isLoading, directChat.messages, researchDisplayThread]);
const { displayThread } = useResearchWritingFeed(threadId, thread);
// ── agentfx「入库」协助卡 ──────────────────────────────────────────────────
// 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且
@@ -762,62 +744,6 @@ export default function AgentChatPage({
const handleSubmit = useCallback(
(message: PromptInputMessage, extraContext?: Record<string, unknown>) => {
if (directChatSelected) {
if (message.files && message.files.length > 0) {
toast.error("极速问答暂不支持附件,请移除附件后重试。");
return;
}
setHasStartedConversation(true);
saveLastAgentId(agent_id);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void (async () => {
try {
let targetThreadId = threadId;
if (isNewThread) {
const created = await getAPIClient().threads.create({
metadata: {
agent_id,
...(Object.keys(conversationMetadata).length > 0
? conversationMetadata
: {}),
},
});
targetThreadId = created.thread_id;
knowledgeSelectionThread.current = targetThreadId;
activeThreadIdRef.current = targetThreadId;
setThreadId(targetThreadId);
setIsNewThread(false);
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#${agentChatsBase}/${targetThreadId}`,
);
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
if (title) {
void getAPIClient().threads
.updateState(targetThreadId, { values: { title } })
.catch(() => undefined);
}
}
if (!targetThreadId) {
toast.error("无法创建会话。");
return;
}
await directChat.submit({
targetThreadId,
query: message.text,
modelName: settings.context.model_name,
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : "极速问答失败。",
);
}
})();
return;
}
setHasStartedConversation(true);
// Remember this agent so the new-chat landing screen pre-selects it next
// time (covers both direct agent chats and the ChatPage handoff replay).
@@ -842,7 +768,7 @@ export default function AgentChatPage({
...pageReportThemeContext,
});
},
[sendMessage, threadId, agent_id, settings.context.writing_mode, settings.context.model_name, writingArtifactPath, baseMode, directChat, directChatSelected, isNewThread, conversationMetadata, agentChatsBase, setThreadId, setIsNewThread],
[sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath, baseMode],
);
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
@@ -927,12 +853,8 @@ export default function AgentChatPage({
);
const handleStop = useCallback(async () => {
if (directChat.isLoading) {
directChat.stop();
return;
}
await thread.stop();
}, [directChat, thread]);
}, [thread]);
const isLandingState = isNewThread && !hasStartedConversation;
// 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
@@ -946,7 +868,7 @@ export default function AgentChatPage({
const status = thread.error
? "error"
: thread.isLoading || directChat.isLoading
: thread.isLoading
? "streaming"
: "ready";
@@ -1108,7 +1030,7 @@ export default function AgentChatPage({
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
router.push(
isEmbedded
? `/page/workspace/chats/new?agent=${agent_id}`
? `${routeBase}/chats/new?agent=${agent_id}`
: `${agentChatsBase}/new`,
);
}}
@@ -1158,12 +1080,13 @@ export default function AgentChatPage({
className="w-full"
isNewThread
hideAdminRecommendedQuestions
knowledgeSuggestedQuestions={knowledgeSuggestedQuestions}
threadId={threadId}
assistantId={agent_id}
autoFocus
status={status}
context={settings.context}
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
disabled={isDemoMode || knowledgeSelectionLoading}
onContextChange={(context) =>
setSettings("context", context)
}
@@ -1178,12 +1101,13 @@ export default function AgentChatPage({
assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection}
onSuggestedQuestionsChange={setKnowledgeSuggestedQuestions}
autoSelectAll={
isNewThread &&
pendingKnowledgeSelection.current === null &&
pendingAssistantKnowledgeSelection.current === null
}
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
disabled={isDemoMode || knowledgeSelectionLoading}
label="知识空间"
/>
)
@@ -1254,7 +1178,7 @@ export default function AgentChatPage({
assistantId={agent_id}
status={status}
context={settings.context}
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
disabled={isDemoMode || knowledgeSelectionLoading}
onContextChange={(context) =>
setSettings("context", context)
}
@@ -1269,7 +1193,7 @@ export default function AgentChatPage({
assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection}
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
disabled={isDemoMode || knowledgeSelectionLoading}
readOnly
label="知识空间"
/>
@@ -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>
);
}
+189 -42
View File
@@ -37,8 +37,10 @@ import {
useAssistantKnowledgeActions,
useAssistantKnowledgeBases,
useAssistantKnowledgeSources,
useAssistantWikiPage,
useAssistantWikiPages,
type AssistantKnowledgeBase,
type AssistantWikiPage,
} from "@/strategy-components/api/assistant-knowledge";
import {
WikiLibraryReader,
@@ -46,24 +48,41 @@ import {
} from "@/strategy-components/components/resource-management/llmwiki/WikiLibraryReader";
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() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const [params, setParams] = useSearchParams();
const baseId = params.get("base_id");
const slug = params.get("wikiSlug");
const [tab, setTab] = useState("wiki");
const bases = useAssistantKnowledgeBases();
const actions = useAssistantKnowledgeActions();
const pages = useAssistantWikiPages("", baseId);
const sources = useAssistantKnowledgeSources(baseId);
const jobs = useAssistantImportJobs(baseId);
const pages = useAssistantWikiPages("", baseId, {
includeContent: false,
enabled: !!baseId,
});
const sources = useAssistantKnowledgeSources(baseId, !!baseId);
const jobs = useAssistantImportJobs(baseId, !!baseId);
const client = useQueryClient();
const [tab, setTab] = useState("wiki");
const [createOpen, setCreateOpen] = useState(false);
const [editingBase, setEditingBase] =
useState<AssistantKnowledgeBase | null>(null);
const [name, setName] = useState("");
const [query, setQuery] = useState("");
const [searching, setSearching] = useState(false);
const searchSequence = useRef(0);
const [searchResult, setSearchResult] = useState<Awaited<
ReturnType<typeof searchAssistantWiki>
> | null>(null);
@@ -71,24 +90,27 @@ export default function AssistantKnowledgePage() {
const documentUpload = useRef<HTMLInputElement>(null);
const base = bases.data?.find((b) => b.id === baseId);
const libraryPages = useMemo<LibraryWikiPage[]>(
() =>
(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 || []).map(toLibraryPage),
[pages.data],
);
const active =
libraryPages.find((p) => p.slug === slug) ||
const defaultDirectoryPage =
libraryPages.find((p) => p.page_type === "index") ||
libraryPages[0] ||
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) => {
setParams({ base_id: baseId!, wikiSlug: nextSlug });
setTab("wiki");
@@ -97,17 +119,38 @@ export default function AssistantKnowledgePage() {
.filter((j) => j.status === "completed")
.map((j) => j.id)
.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(() => {
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({
queryKey: ["assistant-knowledge", "pages"],
});
void client.invalidateQueries({
queryKey: ["assistant-knowledge", "page"],
});
void client.invalidateQueries({
queryKey: ["assistant-knowledge", "bases"],
});
}, [finishedJobs, client]);
}, [baseId, finishedJobs, jobs.data, client]);
useEffect(() => {
++searchSequence.current;
setSearching(false);
setSearchResult(null);
setQuery("");
return () => { ++searchSequence.current; };
}, [baseId]);
const create = async () => {
if (!name.trim()) return;
@@ -122,13 +165,16 @@ export default function AssistantKnowledgePage() {
};
const search = async () => {
if (!query.trim() || !baseId) return;
const sequence = ++searchSequence.current;
setSearching(true);
setSearchResult(null);
try {
setSearchResult(await searchAssistantWiki(query, baseId));
const result = await searchAssistantWiki(query.trim(), baseId);
if (sequence === searchSequence.current) setSearchResult(result);
} catch (e) {
toast.error((e as Error).message);
if (sequence === searchSequence.current) toast.error((e as Error).message);
} finally {
setSearching(false);
if (sequence === searchSequence.current) setSearching(false);
}
};
const refresh = () =>
@@ -254,6 +300,17 @@ export default function AssistantKnowledgePage() {
初始化知识梳理总库
</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>
) : (
<>
@@ -317,11 +374,17 @@ export default function AssistantKnowledgePage() {
<Button
variant="outline"
size="sm"
disabled={actions.uploadWeKnoraPackage.isPending}
disabled={packageImportBusy}
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>
)}
</div>
@@ -342,17 +405,22 @@ export default function AssistantKnowledgePage() {
ref={upload}
type="file"
accept=".jsonl,.ndjson"
disabled={packageImportBusy}
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setTab("transfer");
if (packageImportBusy) return;
try {
await actions.uploadWeKnoraPackage.mutateAsync({
file,
assistantBaseId: baseId,
});
toast.success("文件已上传,后台正在导入");
toast.success(
"数据包已上传并在后台导入,请在任务记录中查看进度,无需再次上传",
);
} catch (err) {
toast.error((err as Error).message);
}
@@ -377,25 +445,96 @@ export default function AssistantKnowledgePage() {
</TabsList>
</div>
<TabsContent value="wiki" className="mt-0 flex min-h-0 flex-1">
<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
/>
{pages.isPending ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-5 animate-spin" />
正在读取 Wiki 目录
</div>
) : pages.isError ? (
<div className="flex flex-1 items-center justify-center px-6">
<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">
{pages.error instanceof Error ? pages.error.message : "请稍后重试"}
</p>
<Button className="mt-5" variant="outline" onClick={() => void pages.refetch()}>
<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 value="search" className="overflow-auto p-6">
<div className="mb-5 flex max-w-3xl gap-2">
<Input
aria-label="检索助手知识库"
placeholder="用自然语言检索知识,例如:如何处理设备过热?"
value={query}
onChange={(e) => setQuery(e.target.value)}
@@ -403,7 +542,7 @@ export default function AssistantKnowledgePage() {
if (e.key === "Enter") void search();
}}
/>
<Button disabled={searching} onClick={() => void search()}>
<Button disabled={searching || !query.trim()} onClick={() => void search()}>
{searching ? (
<Loader2 className="size-4 animate-spin" />
) : (
@@ -417,10 +556,18 @@ export default function AssistantKnowledgePage() {
<Badge variant="outline">
{searchResult.retrieval_mode === "vector"
? "知识正文 / 片段 · 向量检索"
: searchResult.retrieval_mode === "overview"
? "知识正文 · 知识库概览"
: searchResult.retrieval_mode === "empty"
? "没有匹配结果"
: "知识正文 / 片段 · 关键词检索(暂无匹配模型的向量)"}
: "知识正文 / 片段 · 关键词检索"}
</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">
{searchResult.results.map((p) => (
<button
+39 -77
View File
@@ -40,6 +40,7 @@ import { CompactWelcome } from "@/components/workspace/compact-welcome";
import {
buildFullChatNavigationPath,
isChatsIframeWorkspacePath,
WX_FULL_CHAT_PATH_PREFIX,
} from "@/core/page-layout/chats-iframe-route";
import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
import { isWujieEnv } from "@/core/embed/wujie";
@@ -73,7 +74,6 @@ import { clearResearchReportStream } from "@/core/threads/use-research-report-st
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from "@/core/threads/use-direct-chat";
import { useMultiRoundRagMessages, mergeMessagesByTime, toLangGraphMessages } from "@/core/threads/use-multi-round-rag";
import { useLayoutMode } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base";
@@ -147,6 +147,7 @@ export default function ChatPage() {
const [knowledgeRagEnabled] = useKnowledgeRagEnabled();
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>([]);
const [knowledgeSuggestedQuestions, setKnowledgeSuggestedQuestions] = useState<string[]>([]);
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(false);
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
const knowledgeSelectionThread = useRef<string | null>(null);
@@ -365,12 +366,36 @@ export default function ChatPage() {
});
const singleRoundRag = useSingleRoundRagMessages(threadId);
const multiRoundRag = useMultiRoundRagMessages(threadId);
const directChat = useDirectChatMessages(threadId);
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
const frontendRagSelected = isFrontendRagProfile(settings.context);
const directChatSelected =
isDirectChatProfile(settings.context) && isTurboModeVisible();
// 排查高并发时普通对话输入框意外禁用:流式运行本身不应成为禁用原因。
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
// 流式 stub),成功且真实产物落地后自动撤出。
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
@@ -380,15 +405,13 @@ export default function ChatPage() {
if (
singleRoundRag.messages.length === 0 &&
ragMessages.length === 0 &&
directChat.messages.length === 0 &&
!singleRoundRag.isLoading &&
!multiRoundRag.isLoading &&
!directChat.isLoading
!multiRoundRag.isLoading
) {
return base;
}
const messages = mergeMessagesByTime(
[...base.messages, ...singleRoundRag.messages, ...directChat.messages],
[...base.messages, ...singleRoundRag.messages],
ragMessages,
);
return {
@@ -396,14 +419,9 @@ export default function ChatPage() {
values: { ...base.values, messages },
messages,
isLoading:
base.isLoading ||
singleRoundRag.isLoading ||
multiRoundRag.isLoading ||
directChat.isLoading,
base.isLoading || singleRoundRag.isLoading || multiRoundRag.isLoading,
} as typeof base;
}, [
directChat.isLoading,
directChat.messages,
multiRoundRag.isLoading,
multiRoundRag.messages,
researchDisplayThread,
@@ -419,52 +437,6 @@ export default function ChatPage() {
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
if (directChatSelected) {
if (message.files && message.files.length > 0) {
toast.error("极速问答暂不支持附件,请移除附件后重试。");
return;
}
setHasStartedConversation(true);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void (async () => {
try {
let targetThreadId = threadId;
if (isNewThread) {
const created = await getAPIClient(isMock).threads.create({});
targetThreadId = created.thread_id;
setThreadId(targetThreadId);
setIsNewThread(false);
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#${routeBase}/chats/${targetThreadId}`,
);
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
if (title) {
void getAPIClient(isMock).threads
.updateState(targetThreadId, { values: { title } })
.catch(() => undefined);
}
}
if (!targetThreadId) {
toast.error("无法创建会话。");
return;
}
await directChat.submit({
targetThreadId,
query: message.text,
modelName: settings.context.model_name,
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : "极速问答失败。",
);
}
})();
return;
}
if (frontendRagSelected) {
if (message.files && message.files.length > 0) {
toast.error("RAG 问答暂不支持附件,请移除附件后重试。");
@@ -631,8 +603,6 @@ export default function ChatPage() {
multiRoundRag,
multiRoundRagSelected,
frontendRagSelected,
directChat,
directChatSelected,
routeBase,
setIsNewThread,
setThreadId,
@@ -728,10 +698,6 @@ export default function ChatPage() {
saveLastAgentId(agentId);
}, []);
const handleStop = useCallback(async () => {
if (directChat.isLoading) {
directChat.stop();
return;
}
if (singleRoundRag.isLoading) {
singleRoundRag.stop();
return;
@@ -741,7 +707,7 @@ export default function ChatPage() {
return;
}
await thread.stop();
}, [directChat, multiRoundRag, singleRoundRag, thread]);
}, [multiRoundRag, singleRoundRag, thread]);
const promptPrefixProp = useMemo(() => {
if (!promptPrefixSettings?.enabled) return undefined;
@@ -774,7 +740,9 @@ export default function ChatPage() {
// 用 useLayoutMode 的有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认),
// 与 PageRoutes 的 chrome 判定同源。非紧凑(嵌入/standalone)保持原样。
const chromeByPreference =
fullChatMode || effectiveLayoutMode === "full" ? true : false;
fullChatMode ||
pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX) ||
effectiveLayoutMode === "full";
const compactChrome =
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
const compactLanding = isLandingState && compactChrome;
@@ -985,15 +953,7 @@ export default function ChatPage() {
}
context={settings.context}
extraModels={SINGLE_ROUND_RAG_MODELS}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading ||
agentHandoffPending ||
knowledgeSelectionLoading ||
singleRoundRag.isLoading ||
multiRoundRag.isLoading ||
directChat.isLoading
}
disabled={inputDisabled}
onContextChange={(context) =>
setSettings("context", context)
}
@@ -1014,6 +974,7 @@ export default function ChatPage() {
assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection}
onSuggestedQuestionsChange={setKnowledgeSuggestedQuestions}
autoSelectAll={isLandingState && !!selectedAgentId}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
@@ -1023,6 +984,7 @@ export default function ChatPage() {
label="知识空间"
/>
}
knowledgeSuggestedQuestions={knowledgeSuggestedQuestions}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
-18
View File
@@ -2,7 +2,6 @@
import {
BookMarkedIcon,
BrainCircuitIcon,
FileTextIcon,
GraduationCapIcon,
ShrinkIcon,
@@ -400,13 +399,6 @@ export default function IframeChatPage() {
});
}, [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(() => {
if (ragModeLocked) {
return;
@@ -531,9 +523,6 @@ export default function IframeChatPage() {
const modeButtons: ResolvedIframeModeButton[] = buildIframeModeButtons({
agentSelected: !!selectedAgentId,
writingModeEnabled: !!settings.context.writing_mode,
memoryInjectionEnabled: Boolean(
settings.context.memory_injection_enabled ?? true,
),
referenceModeEntryEnabled,
ragModeEnabled,
ragModeLocked,
@@ -555,13 +544,6 @@ export default function IframeChatPage() {
onClick: toggleWritingMode,
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":
return {
...button,
+8 -10
View File
@@ -1,6 +1,6 @@
"use client";
import { Fragment, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { Navigate } from "react-router-dom";
import { Copy, Info, KeyRound, Link2, Search, ShieldCheck, UserCheck, X } from "lucide-react";
import { toast } from "sonner";
@@ -201,9 +201,8 @@ export default function LoginWhitelistPage() {
const allowed = user.approved || isUserAdmin;
const hasPwd = Boolean(user.login_password);
const isEditing = editingId === user.id;
return (
<Fragment key={user.id}>
<li className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-3 px-4 py-3 transition-colors hover:bg-slate-50/60">
return [
<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">
<div className="min-w-0">
<div className="flex items-center gap-2">
<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)}
/>
</div>
</li>
</li>,
{isEditing && (
<li className="bg-slate-50/70 px-4 py-4">
isEditing && (
<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="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-600">
<KeyRound className="size-3.5 text-slate-400" />
@@ -328,9 +327,8 @@ export default function LoginWhitelistPage() {
)}
</div>
</li>
)}
</Fragment>
);
)
];
})}
</ul>
)}
@@ -86,6 +86,7 @@ const emptyDraft = (): ModelDraft => ({
base_url: "",
request_timeout: 30,
max_retries: 2,
max_input_tokens: undefined,
max_tokens: undefined,
temperature: undefined,
stream_usage: true,
@@ -143,6 +144,7 @@ function buildPayload(draft: ModelDraft): ModelSavePayload {
base_url: optionalString(draft.base_url),
request_timeout: toNumber(draft.request_timeout),
max_retries: toNumber(draft.max_retries),
max_input_tokens: toNumber(draft.max_input_tokens),
max_tokens: toNumber(draft.max_tokens),
temperature: toNumber(draft.temperature),
stream_usage: draft.stream_usage,
@@ -379,7 +381,7 @@ function ModelEditor({ model, onClose }: { model: ManagedModel | null; onClose:
</section>
<div className="flex items-center justify-between border-t pt-4"><TButton theme="default" variant="outline" loading={test.isPending} onClick={() => void handleTest()}><WrenchIcon className="mr-1.5 size-4" />测试连接</TButton><TButton theme="default" variant="text" onClick={() => setAdvanced((value) => !value)}><Settings2Icon className="mr-1.5 size-4" />高级配置 <ChevronDownIcon className={`ml-1 size-4 transition-transform ${advanced ? "rotate-180" : ""}`} /></TButton></div>
{testResult && <div className={`rounded-lg border px-3 py-2 text-sm ${testResult.success ? "border-primary/20 bg-primary/5 text-foreground" : "border-destructive/25 bg-destructive/5 text-destructive"}`}><div className="font-medium">{testResult.success ? "连接成功" : "连接失败"}{testResult.success && testResult.latency ? ` · ${testResult.latency} ms` : ""}</div><p className="mt-1 break-words text-xs opacity-80">{testResult.preview || testResult.message}</p></div>}
{advanced && <section className="space-y-4 rounded-lg border border-dashed border-border bg-muted/10 p-4"><div className="grid gap-4 md:grid-cols-3"><Field label="请求超时(秒)"><InputNumber value={draft.request_timeout ?? ""} min={1} max={600} onChange={(value) => patch({ request_timeout: toNumber(value) })} /></Field><Field label="最大重试次数"><InputNumber value={draft.max_retries ?? ""} min={0} max={20} onChange={(value) => patch({ max_retries: toNumber(value) })} /></Field><Field label="最大输出 Token"><InputNumber value={draft.max_tokens ?? ""} min={1} max={1_000_000} onChange={(value) => patch({ max_tokens: toNumber(value) })} /></Field><Field label="温度"><InputNumber value={draft.temperature ?? ""} min={0} max={2} step={0.1} decimalPlaces={1} onChange={(value) => patch({ temperature: toNumber(value) })} /></Field><span className="flex items-end gap-2 pb-1 text-sm font-medium"><Switch value={draft.stream_usage ?? true} onChange={(value) => patch({ stream_usage: Boolean(value) })} />流式 Token 统计</span></div><Field label="额外参数 JSON"><Textarea value={draft.extra_settings_text} autosize={{ minRows: 4, maxRows: 10 }} placeholder={'{\n "extra_body": {}\n}'} onChange={(value) => patch({ extra_settings_text: String(value) })} /><p className="text-xs font-normal text-muted-foreground">保留并编辑配置文件中未在基础表单展示的兼容参数。必须为 JSON 对象。</p></Field></section>}
{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>
</Dialog>
);
@@ -6,6 +6,7 @@ import {
CircleHelp,
Clock3,
FolderKanban,
Globe,
LoaderCircle,
MessageSquareText,
PanelLeftClose,
@@ -20,7 +21,9 @@ import {
X,
} from "lucide-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 {
@@ -53,12 +56,14 @@ import {
} 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";
type OfficialPage =
| "chat"
| "projects"
| "web-apps"
| "agents"
| "agent-chat"
| "scheduled"
@@ -70,6 +75,7 @@ function pageFromPath(pathname: string): OfficialPage {
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/scheduled-tasks")) return "scheduled";
if (path.startsWith("/workspace/settings")) return "settings";
@@ -716,6 +722,7 @@ export default function OfficialDeerFlowEntry() {
{ icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, 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: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] },
] as const;
@@ -759,6 +766,11 @@ export default function OfficialDeerFlowEntry() {
className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`}
key={chat.thread_id}
onClick={() => {
const projectId = chat.metadata?.analysis_project_id;
if (typeof projectId === "string" && projectId) {
go(`${projectBasePath}/groups/${encodeURIComponent(projectId)}/chats/${encodeURIComponent(chat.thread_id)}`);
return;
}
const agentId = typeof chat.context?.agent_id === "string"
? chat.context.agent_id
: typeof chat.metadata?.agent_id === "string"
@@ -815,7 +827,13 @@ export default function OfficialDeerFlowEntry() {
/>
)}
{page === "projects" && (
projectId ? (
(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}
@@ -825,6 +843,7 @@ export default function OfficialDeerFlowEntry() {
<StrategyProjectsPage basePath={projectBasePath} />
)
)}
{page === "web-apps" && <OfficialWebAppsPage />}
{page === "agents" && (
<AgentsPage
openAgent={(agentId) =>
@@ -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 { PageSidebarDark } from "@/components/page-sidebar-dark";
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 {
isEmbedActive,
@@ -36,6 +39,9 @@ function PageLayout() {
const location = useLocation();
const { theme } = useTheme();
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)。
const inWujie = useMemo(() => isWujieEnv(), []);
const lightNarrowSidebar = !isDark && !inWujie;
@@ -47,7 +53,13 @@ function PageLayout() {
// 不再回退到会话级 sidebarEnabled 旧默认)。「全量↔简洁」始终能真正控制顶栏+侧边栏。
const [layoutMode] = useLayoutMode();
const chromeByPreference =
forceCollapsedSidebar ? false : layoutMode === "compact" ? false : true;
forceCollapsedSidebar
? false
: isWxFullChat
? true
: layoutMode === "compact"
? false
: true;
// iframe 嵌入模式(外部系统 `&embed=1`,按标签页 sessionStorage):去掉全局导航栏 + 侧边栏,
// 只留问答/会商工作区本体。按 location 重算以跟随页内导航;非嵌入标签页恒为 false,不受影响。
const embedActive = useMemo(
@@ -90,6 +102,7 @@ function PageLayout() {
const pageSidebarAllowed =
chromeByPreference &&
!isChatsIframeWorkspacePath(location.pathname) &&
!isWxFullChat &&
!embedActive &&
!chromeHidden &&
!hostNestedSurface;
@@ -98,6 +111,7 @@ function PageLayout() {
!embedActive &&
!chromeHidden &&
!hostNestedSurface &&
!isWxFullChat &&
!isChatsIframeWorkspacePath(location.pathname);
// 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
// still needs the full GeneralQaSidebar (common agents and
// conversation history) inside the workspace.
compactWorkspaceSidebar={pageSidebarAllowed || fullChatMode}
compactWorkspaceSidebar={
pageSidebarAllowed || fullChatMode || isWxFullChat
}
compactActionsHidden={hideChromeActive || headerHidden}
pageSidebarDark={isDark}
forceWorkspaceSidebarCollapsed={
+25 -5
View File
@@ -1,7 +1,11 @@
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 { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
@@ -62,7 +66,6 @@ import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage";
import MemoryPage from "./MemoryPage";
import NewAgentPage from "./NewAgentPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
@@ -100,6 +103,23 @@ 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} />;
@@ -120,12 +140,13 @@ export default function StrategyRoutes() {
<Route
path="qa/projects"
element={
<StrategyProjectsPage basePath="/page/strategy/qa/projects" />
<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={<StrategyProjectWorkbenchRoute />}
element={<StrategyProjectsLayout><StrategyProjectWorkbenchRoute /></StrategyProjectsLayout>}
/>
<Route
path="qa/strategy-selection"
@@ -423,7 +444,6 @@ export default function StrategyRoutes() {
<Route path="skills" element={<SkillsPage />} />
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
<Route
path="business-chain-mapping"
element={
+184 -16
View File
@@ -31,10 +31,11 @@ import { SubtasksProvider } from "@/core/tasks/context";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
import { RouteBaseProvider } from "@/core/route-base";
import { RouteBaseProvider, useRouteBase } from "@/core/route-base";
import { agentRefOfThread } from "@/core/threads/utils";
import ChatsPage from "./ChatsPage";
import AnalysisProjectsPage from "./AnalysisProjectsPage";
import ChatPage from "./ChatPage";
import ArtifactLibraryPage from "./ArtifactLibraryPage";
import IframeChatPage from "./IframeChatPage";
@@ -55,7 +56,6 @@ import WeKnoraPackageTransfersPage from "@/strategy-components/components/resour
import MarkdownDocxEditorPage from "./MarkdownDocxEditorPage";
import LlmMetricsPage from "./LlmMetricsPage";
import CheckpointMigrationPage from "./CheckpointMigrationPage";
import MemoryPage from "./MemoryPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
import ModelConcurrencySettingsPage from "./ModelConcurrencySettingsPage";
@@ -130,7 +130,6 @@ function shouldShowCompactTopActions(pathname: string): boolean {
return (
pathname === "/page/workspace/agents" ||
pathname === "/page/workspace/skills" ||
pathname === "/page/workspace/memory" ||
pathname === "/page/workspace/library" ||
pathname === "/page/workspace/knowledge" ||
pathname.startsWith("/page/workspace/knowledge/")
@@ -172,6 +171,7 @@ export function WorkspaceLayout({
insetOverflowHidden = false,
pageSidebarDark = false,
forceWorkspaceSidebarCollapsed = false,
wxCommonAgents = false,
}: {
children: ReactNode;
pageSidebarCollapsed: boolean;
@@ -187,11 +187,16 @@ export function WorkspaceLayout({
pageSidebarDark?: boolean;
/** Deployment-level compact mode: keep only the icon menu rail. */
forceWorkspaceSidebarCollapsed?: boolean;
/** Enables wx's shared Agent Gallery category selection. */
wxCommonAgents?: boolean;
}) {
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
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 =
headerHidden &&
!compactActionsHidden &&
@@ -243,6 +248,7 @@ export function WorkspaceLayout({
<WorkspaceSidebar
compactChrome={compactWorkspaceSidebar}
forceCollapsed={forceWorkspaceSidebarCollapsed}
wxCommonAgents={wxCommonAgents}
/>
)}
<SidebarInset
@@ -412,6 +418,7 @@ function ChatThreadEntryRoute({
function AgentAwareChatThreadContent() {
const { thread_id: threadId } = useParams<{ thread_id: string }>();
const location = useLocation();
const routeBase = useRouteBase();
const [agentId, setAgentId] = useState<string | null>(null);
const [resolved, setResolved] = useState(false);
@@ -458,16 +465,133 @@ function AgentAwareChatThreadContent() {
if (threadId && agentId) {
return (
<Navigate
replace
to={`/page/workspace/agents/${encodeURIComponent(agentId)}/chats/${threadId}${location.search}`}
/>
<Navigate
replace
to={`${routeBase}/agents/${encodeURIComponent(agentId)}/chats/${threadId}${location.search}`}
/>
);
}
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`
* and `chatMode=iframe` opt into this page; ordinary iframe/Wujie embedding
@@ -548,6 +672,58 @@ export default function WorkspaceRoutes({
<RouteBaseProvider value="/page/workspace">
<Routes>
<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
path="chats"
element={
@@ -684,14 +860,6 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="memory"
element={
<WorkspaceLayout {...layoutProps}>
<MemoryPage />
</WorkspaceLayout>
}
/>
<Route
path="ai-writing/*"
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 { WorkflowConfigButton } from "./WorkflowConfigDialog";
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";
/** TDesign Select sentinel: do not send a per-session model override. */
@@ -637,6 +647,8 @@ function DeepResearchWorkbenchInner() {
const collectorPersistChain = useRef(Promise.resolve());
const collectorSessionIdRef = useRef<string | null>(null);
const followUpAbortRef = useRef<AbortController | null>(null);
const outlinePlanKeyRef = useRef("");
const pendingCollectAfterPlanRef = useRef("");
const refetchSession = sessionQuery.refetch;
const refetchSessions = sessions.refetch;
@@ -1095,6 +1107,17 @@ function DeepResearchWorkbenchInner() {
// ── 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 skills = normalizeRetrievalSkills(
@@ -1105,16 +1128,16 @@ function DeepResearchWorkbenchInner() {
return {
...localSettings.context,
model_name: selectedModel ?? localSettings.context.model_name,
agent_id: COLLECTOR_AGENT_ID,
agent_id: activeAgentId,
...(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({
threadId: isChatSession ? collectorThreadId : undefined,
assistantId: COLLECTOR_AGENT_ID,
assistantId: activeAgentId,
context: collectorContext,
});
@@ -1125,32 +1148,48 @@ function DeepResearchWorkbenchInner() {
() => hasPendingClarification(collectorThread.messages),
[collectorThread.messages],
);
const collectionAlreadyStarted = hasCollectAfterPlanTurn(collectorThread.messages, extractContentFromMessage)
|| hasCollectorToolTurn(collectorThread.messages);
const isPlanningOutline = outlinePlannerMode === "agent"
&& !sessionOutline
&& !collectionAlreadyStarted
&& isChatSession;
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 trimmed = text.trim();
if (!threadId || !trimmed) return;
if (!options?.silentStatusPatch && session && session.status !== "collecting") {
void actions.updateStatus.mutate({ sessionId: session.id, status: "collecting" });
}
const isFirstTurn = (collectorThread.messages?.length ?? 0) === 0;
const payload = isFirstTurn
? composeCollectorQueryWithDirections(
trimmed,
advancedOptions.retrievalDirections.length > 0
const useDirections = options?.withDirections === true
|| (options?.withDirections !== false && (collectorThread.messages?.length ?? 0) === 0 && !isPlanningOutline);
const useSkills = options?.withSkills === true || (options?.withSkills !== false && useDirections);
const payload = composeCollectorQueryWithDirections(
trimmed,
useDirections
? (advancedOptions.retrievalDirections.length > 0
? advancedOptions.retrievalDirections
: session?.config?.retrieval_directions,
advancedOptions.retrievalSkills.length > 0
: session?.config?.retrieval_directions)
: [],
useSkills
? (advancedOptions.retrievalSkills.length > 0
? advancedOptions.retrievalSkills
: session?.config?.retrieval_skills,
)
: trimmed;
: session?.config?.retrieval_skills)
: [],
);
try {
await collectorSendMessage(
threadId,
{ text: payload, files: [] },
selectedModel ? { model_name: selectedModel } : undefined,
options?.hideFromUI ? { additionalKwargs: { hide_from_ui: true } } : undefined,
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "发送失败,请重试");
@@ -1162,6 +1201,7 @@ function DeepResearchWorkbenchInner() {
advancedOptions.retrievalSkills,
collectorSendMessage,
collectorThread.messages?.length,
isPlanningOutline,
selectedModel,
session,
],
@@ -1176,26 +1216,46 @@ function DeepResearchWorkbenchInner() {
[collectorThread, sendCollectorMessage, session?.runtimeThreadId],
);
// The very first research topic: auto-sent into the freshly pre-built
// collector thread once the session (and its checkpoint) is ready.
useEffect(() => {
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(() => {
const query = pendingInitialQuery.current;
if (!query || !isChatSession || !collectorThreadId) 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;
if (session && session.status === "draft") {
void actions.updateStatus.mutate({ sessionId: session.id, status: "collecting" });
}
void sendCollectorMessage(query, { silentStatusPatch: true });
void sendCollectorMessage(query, {
silentStatusPatch: true,
withDirections: !planFirst,
});
}, [
actions.updateStatus,
activeAgentId,
collectorMessagesLength,
collectorStreaming,
collectorThreadLoading,
collectorThreadId,
isChatSession,
outlinePlannerMode,
sendCollectorMessage,
session,
sessionOutline,
]);
// Persist each completed search as it lands in the step list, instead of
@@ -1258,45 +1318,120 @@ function DeepResearchWorkbenchInner() {
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
// message and the run flag flipping, where the turn would look "ended".
const collectorMessages = collectorThread.messages;
const collectorTailIsHuman = collectorMessagesLength > 0
&& 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(() => {
if (!isChatSession || collectorStreaming || collectorMessagesLength === 0) return;
if (collectorTailIsHuman) return;
const status = session?.status;
if (status !== "collecting" && status !== "draft") return;
if (collectorPendingClarification) return;
if (collectorPendingClarification || isPlanningOutline) return;
if (outlinePlannerMode === "agent" && !collectionAlreadyStarted) return;
const key = `${session?.id ?? ""}:${collectorMessagesLength}`;
if (finishedTurnKey.current === key) return;
finishedTurnKey.current = key;
if (session) {
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}`;
void actions.updateStatus.mutate({
sessionId: session.id,
status: "awaiting_report",
...(leftover.length ? { collectorMaterials: leftover } : {}),
});
}
if (!session) return;
const leftover = persistLeftoverCollectorMaterials();
void actions.updateStatus.mutate({
sessionId: session.id,
status: "awaiting_report",
...(leftover.length ? { collectorMaterials: leftover } : {}),
});
}, [
actions.updateStatus,
collectionAlreadyStarted,
collectorMessagesLength,
collectorPendingClarification,
collectorStreaming,
collectorTailIsHuman,
collectorThread.messages,
isChatSession,
isPlanningOutline,
outlinePlannerMode,
persistLeftoverCollectorMaterials,
session,
]);
@@ -1349,7 +1484,9 @@ function DeepResearchWorkbenchInner() {
// 普通消息列表的 tool-call 分组,摘要从生成开始就是普通 assistant 消息并随 SSE
// 增量刷新。这样两者都使用 Q&A 原有的消息流与步骤条,而不是在列表中再起一套卡片式会话。
const collectorDisplayThread = useMemo(() => {
const original = collectorThread.messages ?? [];
const original = (collectorThread.messages ?? []).filter((message) => (
message.type !== "human" || !isCollectAfterPlanPrompt(extractContentFromMessage(message))
));
const base = [...original];
const followUps = messages.data ?? [];
const sessionId = session?.id ?? "draft";
@@ -1492,7 +1629,7 @@ function DeepResearchWorkbenchInner() {
}
};
if (isAwaitingReport) pushActionCard("config");
if (showReportConfigCard) pushActionCard("config");
if (generatedReportVariants.length > 0 && session) {
// Keep the original conversation intact, then append each independent
// 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;
}, [
collectorThread,
generatedReportVariants,
inlineGeneratedReport,
inlineGenerationSession,
isAwaitingReport,
showReportConfigCard,
isRunning,
isReportVariant,
materialCount,
@@ -1766,6 +1902,10 @@ function DeepResearchWorkbenchInner() {
const handleNewResearch = useCallback(() => {
followUpAbortRef.current?.abort();
followUpAbortRef.current = null;
setAdvancedOptions(DEFAULT_ADVANCED_OPTIONS);
outlinePlanKeyRef.current = "";
pendingCollectAfterPlanRef.current = "";
setActiveAgentId(COLLECTOR_AGENT_ID);
setSelectedId(null);
setDownloadTargetSession(null);
setInlineGeneratedReport(null);
@@ -2352,6 +2492,7 @@ function DeepResearchWorkbenchInner() {
} else if (session && isChatSession) {
if (jobWriting) composerPlaceholder = "报告撰写中;完成后可继续追问(撰写中点提交 = 停止)";
else if (isFollowUp) composerPlaceholder = "基于当前报告继续追问,例如:哪些结论还需要补充验证?";
else if (isPlanningOutline) composerPlaceholder = "正在根据课题拟定报告大纲,完成后将开始收集资料";
else if (collectorMessagesLength > 0) composerPlaceholder = "补充要求或新的检索方向,例如:再查一下 2025 年的数据";
}
// streaming 状态下提交按钮变「停止」:点击 → 停止当前流(写作 job / 收集员)。
@@ -2693,6 +2834,30 @@ function DeepResearchWorkbenchInner() {
</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 className="flex shrink-0 items-center gap-2">
{composerClassic && imageGenerationAvailable && !isFollowUp && (
@@ -32,6 +32,10 @@ import {
type ResearchMode,
type ResearchTone,
} from "@/strategy-components/api/deep-research";
import {
type OutlinePlannerMode,
normalizeOutlinePlanner,
} from "./outline-plan";
import {
DEFAULT_REPORT_STRUCTURE_MODE,
normalizeReportStructureMode,
@@ -76,6 +80,7 @@ export type AdvancedResearchOptions = {
structureMode: ReportStructureMode;
retrievalDirections: string[];
retrievalSkills: string[];
outlinePlanner: OutlinePlannerMode;
};
/**
@@ -195,8 +200,16 @@ export const PROGRESSIVE_REPORT_OUTLINE = `# 报告标题
> 写作要求:除引言外,每个二级小节写 2–5 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。`;
const CUSTOM_REPORT_STRUCTURE_VALUE = "__custom_report_structure__";
const AUTO_REPORT_STRUCTURE_VALUE = "__auto_intent_outline__";
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(
outline: string,
structures: Array<{ id: string; content: string }>,
@@ -233,12 +246,14 @@ export function ReportStructureSelect({
const selectedStructureId = matchingReportStructureId(value, structures);
const templateValue = selectedStructureId
? `${REPORT_STRUCTURE_VALUE_PREFIX}${selectedStructureId}`
: CUSTOM_REPORT_STRUCTURE_VALUE;
: value.trim()
? CUSTOM_REPORT_STRUCTURE_VALUE
: AUTO_REPORT_STRUCTURE_VALUE;
const selectTemplate = (next: string) => {
if (next === CUSTOM_REPORT_STRUCTURE_VALUE) {
// “自定义” is an intentional blank starting point. Saved structures are
// always available as one-click scaffolds whenever the user wants them.
if (next === AUTO_REPORT_STRUCTURE_VALUE || next === CUSTOM_REPORT_STRUCTURE_VALUE) {
// Empty outline means the writer will generate a topic-specific structure
// after collection. “自定义” stays a blank editor starting point.
onChange("");
onStructureModeChange?.(DEFAULT_REPORT_STRUCTURE_MODE);
onRetrievalDirectionsChange?.([]);
@@ -272,6 +287,7 @@ export function ReportStructureSelect({
{structuresQuery.isError && (
<SelectItem value="__report_structures_error__" disabled>报告结构库加载失败</SelectItem>
)}
<SelectItem value={AUTO_REPORT_STRUCTURE_VALUE}>根据课题自动生成</SelectItem>
{structures.map((structure) => (
<SelectItem key={structure.id} value={`${REPORT_STRUCTURE_VALUE_PREFIX}${structure.id}`}>
{structure.title}
@@ -283,7 +299,9 @@ export function ReportStructureSelect({
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
{selectedStructureId
? "已选工作流结构;可在高级配置中继续编辑章节内容。"
: "当前使用自定义结构;可在高级配置中编辑章节内容。"}
: value.trim()
? "当前为课题规划/自定义结构,可在下方直接改章节。"
: "未选用预设结构时,可在输入区选择提纲预编制或成文结构生成。"}
</p>
</div>
);
@@ -397,10 +415,11 @@ export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
maxRevisionLoops: "2",
maxGeneratedImages: "2",
thinkingEnabled: false,
customOutline: DEFAULT_REPORT_OUTLINE,
customOutline: "",
structureMode: DEFAULT_REPORT_STRUCTURE_MODE,
retrievalDirections: [],
retrievalSkills: [],
outlinePlanner: "agent",
};
/** 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),
maxGeneratedImages: numberish("max_generated_images", DEFAULT_ADVANCED_OPTIONS.maxGeneratedImages),
thinkingEnabled: config?.thinking_enabled === true,
customOutline: typeof config?.custom_outline === "string" && config.custom_outline.trim()
? config.custom_outline
: DEFAULT_REPORT_OUTLINE,
customOutline: resolveSessionOutline(config),
structureMode: normalizeReportStructureMode(config?.structure_mode),
retrievalDirections: normalizeRetrievalDirections(config?.retrieval_directions),
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);
}
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.retrieval_directions = normalizeRetrievalDirections(options.retrievalDirections);
config.retrieval_skills = normalizeRetrievalSkills(options.retrievalSkills);
config.outline_planner = options.outlinePlanner;
return config;
}
@@ -490,6 +509,7 @@ export function ReportConfigCard({
imageGenerationAvailable,
imageLimit,
submitting,
onRetryPlanOutline,
onConfirm,
onContinueCollecting,
}: {
@@ -507,6 +527,7 @@ export function ReportConfigCard({
imageGenerationAvailable: boolean;
imageLimit: number;
submitting: boolean;
onRetryPlanOutline?: () => void;
onConfirm: (config: Record<string, unknown>, extras?: { writeWithoutMaterials?: boolean }) => void;
onContinueCollecting: () => void;
}) {
@@ -571,6 +592,9 @@ export function ReportConfigCard({
<p className="text-sm font-semibold">资料收集完成,请确认报告写法</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
下方选项沿用原研究配置并可调整;确认后将基于以上对话收集到的资料撰写报告。
{options.customOutline.trim()
? "报告结构已由收集员在对话中规划并填入下方,可继续增删改章节后再撰写。"
: "可在此填写或改章节;确认后按此结构写作。"}
{modelLabel ? <>写作模型:<span className="font-medium text-foreground">{modelLabel}</span>。</> : null}
</p>
<div className={`mt-2 inline-flex items-center gap-1.5 rounded-md border 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 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">
<input
type="checkbox"
@@ -682,18 +735,9 @@ export function ReportConfigCard({
{mode === "multi_agent" && (
<CardNumber label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(v) => update("maxRevisionLoops", v)} />
)}
<div className="sm:col-span-2">
<ReportOutlineEditor
id="report-card-outline"
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>
<p className="text-[11px] leading-4 text-muted-foreground sm:col-span-2">
报告结构正文已在上方表单,可继续选用工作流模板覆盖,或直接改章节标题。
</p>
<div className="flex justify-end sm:col-span-2">
<WorkflowConfigButton
appliedContent={options.customOutline}
@@ -69,6 +69,7 @@ import {
MAX_RETRIEVAL_DIRECTIONS,
MAX_RETRIEVAL_DIRECTION_CHARS,
MAX_RETRIEVAL_SKILLS,
DEFAULT_REPORT_STRUCTURE_MODE,
REPORT_STRUCTURE_MODE_OPTIONS,
normalizeReportStructureMode,
normalizeRetrievalDirections,
@@ -175,10 +176,12 @@ export function WorkflowConfigDialog({
appliedContent,
/** 点击卡片选择结构时回填内容与结构约束。 */
onApply,
inlineCreate = false,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
appliedContent?: string;
inlineCreate?: boolean;
onApply?: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
}) {
const [statusFilter, setStatusFilter] = useState<ReportStructureStatusFilter>("all");
@@ -235,11 +238,21 @@ export function WorkflowConfigDialog({
[detail, detailOutlineTitle],
);
const handleClear = () => {
onApply?.("", DEFAULT_REPORT_STRUCTURE_MODE, [], []);
toast.success("已取消选用报告结构,撰写时将根据课题自动生成大纲");
};
const handleSelect = (structure: ReportStructure) => {
if (!structure.enabled) {
toast.error(`「${structure.title}」已停用,请先启用后再选用`);
return;
}
// 再次点击当前选用的卡片即取消勾选。
if (structure.id === appliedStructureId) {
handleClear();
return;
}
// 点击卡片只做「选择」:内容回填后留在列表视图(卡片高亮为当前使用),
// 详情视图通过卡片上的「查看大纲结构」眼睛图标进入。
onApply?.(structure.content, structure.structureMode, structure.retrievalDirections, structure.retrievalSkills);
@@ -328,7 +341,7 @@ export function WorkflowConfigDialog({
</h2>
</div>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400 sm:text-[13px]">
点击卡片选择报告结构(自动填充到报告结构配置);点击卡片上的眼睛图标查看大纲详情;可启停、编辑、删除。
点击卡片选择报告结构(自动填充到报告结构配置);再次点击已选用的卡片或点「取消选用」可取消勾选,撰写时将按课题自动生成大纲。
</p>
</div>
<Button
@@ -400,6 +413,11 @@ export function WorkflowConfigDialog({
<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"
onClick={() => {
if (inlineCreate) {
setEditorTarget(null);
setEditorOpen(true);
return;
}
onOpenChange(false);
const base = location.pathname.replace(/\/+$/, "");
navigate(`${base}/structures/new`);
@@ -543,17 +561,28 @@ export function WorkflowConfigDialog({
</span>
</span>
) : (
<span className="text-slate-400">未选择</span>
<span className="text-slate-400">未选择,撰写时按课题自动生成</span>
)}
</div>
<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 className="flex shrink-0 items-center gap-2">
{appliedStructureId ? (
<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={handleClear}
>
取消选用
</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>
</>
) : (
@@ -1192,6 +1221,7 @@ export function WorkflowConfigButton({
variant = "outline",
size = "sm",
disabled = false,
inlineCreate = false,
}: {
appliedContent?: string;
onApply: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
@@ -1199,6 +1229,7 @@ export function WorkflowConfigButton({
variant?: React.ComponentProps<typeof Button>["variant"];
size?: React.ComponentProps<typeof Button>["size"];
disabled?: boolean;
inlineCreate?: boolean;
}) {
const [open, setOpen] = useState(false);
const location = useLocation();
@@ -1221,22 +1252,39 @@ export function WorkflowConfigButton({
return (
<>
<Button
aria-label={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
className={cn(
className,
isSelected && "bg-accent text-accent-foreground dark:bg-accent/50",
)}
disabled={disabled}
onClick={() => setOpen(true)}
size={size}
title={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
variant={variant}
>
<Workflow className="mr-1.5 h-3.5 w-3.5" />
<span className="max-w-40 truncate">{buttonLabel}</span>
</Button>
<div className={cn("inline-flex items-center", isSelected && "rounded-md bg-accent text-accent-foreground dark:bg-accent/50")}>
<Button
type="button"
aria-label={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
className={cn(className, isSelected && "bg-transparent hover:bg-transparent")}
disabled={disabled}
onClick={() => setOpen(true)}
size={size}
title={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
variant={variant}
>
<Workflow className="mr-1.5 h-3.5 w-3.5" />
<span className="max-w-40 truncate">{buttonLabel}</span>
</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
inlineCreate={inlineCreate}
open={open}
onOpenChange={setOpen}
appliedContent={appliedContent}
@@ -0,0 +1,136 @@
export const PLANNER_AGENT_ID = "deep-research-planner";
export type OutlinePlannerMode = "agent" | "pipeline";
export const COLLECT_AFTER_PLAN_MARKER = "报告结构已确认,请开始收集资料";
/** First collection round after an agent-planned outline: one query per major chapter. */
export const OUTLINE_COLLECTION_MAX_QUERIES = 6;
const HEADING_RE = /^#{1,6}\s+\S/;
const FENCE_RE = /```(?:markdown|md)?\s*([\s\S]*?)```/i;
const NUM_SUB_RE = /^\d+\.\d+(?:\.\d+)*[、..]?\s*\S/;
const PAREN_SUB_RE = /^[((][一二三四五六七八九十]+[))]\s*\S/;
const CN_MAJOR_RE = /^(?:第)?[一二三四五六七八九十]+[、..]\s*\S/;
const NUM_MAJOR_RE = /^\d+[、..]\s*\S/;
export const OUTLINE_PLANNER_OPTIONS: Array<{
value: OutlinePlannerMode;
label: string;
hint: string;
}> = [
{
value: "agent",
label: "提纲预编制",
hint: "开题后先编制研究提纲,再按提纲收集与核验材料",
},
{
value: "pipeline",
label: "成文结构生成",
hint: "材料齐备后,于成文阶段生成报告结构",
},
];
export function normalizeOutlinePlanner(value: unknown): OutlinePlannerMode {
const raw = String(value ?? "").trim().toLowerCase();
if (
raw === "agent"
|| raw === "planner"
|| raw === "智能体规划"
|| raw === "先行规划大纲"
|| raw === "提纲预编制"
|| raw === "研究提纲预编制"
) return "agent";
return "pipeline";
}
export function buildCollectAfterPlanPrompt(query: string, outline: string): string {
const majors = majorOutlineHeadings(outline);
const coverage = majors.length > 0
? majors.map((title, index) => `${index + 1}. ${title}`).join("\n")
: `按课题拆分,不超过 ${OUTLINE_COLLECTION_MAX_QUERIES} 个检索词。`;
return `${COLLECT_AFTER_PLAN_MARKER}。
课题:${query}
检索预算:本轮最多 ${OUTLINE_COLLECTION_MAX_QUERIES} 个独立检索词(含空结果换词)。按下列大章各检索一次即可,不要按小章逐条检索,也不要再加交叉验证轮次。材料不足写进收集小结,用户可追加材料。
大章覆盖:
${coverage}
完整提纲(只用于理解各章要写什么,禁止为每个小章单独检索):
${outline}
请按收集员流程检索并输出收集小结;不要再规划大纲。`;
}
export function majorOutlineHeadings(outline: string): string[] {
const headings = outline
.split(/\r?\n/)
.map((line) => normalizeOutlineHeadingLine(line))
.filter((line): line is string => Boolean(line));
const h2 = headings
.filter((line) => line.startsWith("## ") && !line.startsWith("###"))
.map((line) => line.replace(/^##\s+/, "").trim())
.filter(Boolean);
if (h2.length > 0) return h2.slice(0, OUTLINE_COLLECTION_MAX_QUERIES);
const h1 = headings
.filter((line) => line.startsWith("# ") && !line.startsWith("##"))
.map((line) => line.replace(/^#\s+/, "").trim())
.filter(Boolean);
return h1.slice(1, 1 + OUTLINE_COLLECTION_MAX_QUERIES);
}
export function isCollectAfterPlanPrompt(text: string): boolean {
return text.includes(COLLECT_AFTER_PLAN_MARKER);
}
export function extractOutlineFromMessage(text: string): string {
let raw = text.trim();
if (!raw) return "";
const fenced = raw.match(FENCE_RE);
if (fenced?.[1]) raw = fenced[1].trim();
const headings = raw
.split(/\r?\n/)
.map((line) => normalizeOutlineHeadingLine(line))
.filter((line): line is string => Boolean(line));
if (headings.length >= 2) return headings.join("\n");
if (raw.startsWith("#")) return raw;
return "";
}
function normalizeOutlineHeadingLine(line: string): string | null {
const text = line.trim();
if (!text) return null;
if (HEADING_RE.test(text)) return text;
if (NUM_SUB_RE.test(text) || PAREN_SUB_RE.test(text)) return `### ${text}`;
if (CN_MAJOR_RE.test(text) || NUM_MAJOR_RE.test(text)) return `## ${text}`;
return null;
}
export function outlineFromFirstAssistant<T extends { type?: string }>(
messages: T[] | undefined,
extractContent: (message: T) => string,
): string {
if (!messages?.length) return "";
for (const message of messages) {
if (message.type !== "ai") continue;
const outline = extractOutlineFromMessage(extractContent(message));
if (outline) return outline;
}
return "";
}
export function hasCollectAfterPlanTurn<T extends { type?: string }>(
messages: T[] | undefined,
extractContent: (message: T) => string,
): boolean {
return Boolean(messages?.some((message) => (
message.type === "human" && isCollectAfterPlanPrompt(extractContent(message))
)));
}
export function hasCollectorToolTurn<T extends { type?: string; name?: string }>(
messages: T[] | undefined,
): boolean {
return Boolean(messages?.some((message) => message.type === "tool"));
}
@@ -4,11 +4,10 @@ import test from "node:test";
// @ts-ignore Node's test runner resolves the local .ts module directly.
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons.ts";
test("hides the memory toggle while retaining the other iframe chat toolbar controls", () => {
test("does not expose a per-chat memory toggle", () => {
const buttons = buildIframeModeButtons({
agentSelected: false,
writingModeEnabled: false,
memoryInjectionEnabled: true,
referenceModeEntryEnabled: true,
ragModeEnabled: false,
ragModeLocked: false,
@@ -28,27 +27,10 @@ test("hides the memory toggle while retaining the other iframe chat toolbar cont
);
});
test("can restore the iframe memory toggle when the feature is re-enabled", () => {
const buttons = buildIframeModeButtons({
agentSelected: false,
writingModeEnabled: false,
memoryInjectionEnabled: true,
hideMemoryButton: false,
referenceModeEntryEnabled: true,
ragModeEnabled: false,
ragModeLocked: false,
contextCompressionEnabled: false,
proModeEnabled: false,
});
assert.equal(buttons.some((button) => button.key === "memory"), true);
});
test("hides the references toggle when reference mode is disabled globally", () => {
const buttons = buildIframeModeButtons({
agentSelected: false,
writingModeEnabled: false,
memoryInjectionEnabled: true,
referenceModeEntryEnabled: false,
ragModeEnabled: true,
ragModeLocked: false,
@@ -63,7 +45,6 @@ test("returns only concrete toolbar button configs", () => {
const buttons = buildIframeModeButtons({
agentSelected: true,
writingModeEnabled: false,
memoryInjectionEnabled: true,
referenceModeEntryEnabled: true,
ragModeEnabled: false,
ragModeLocked: false,
@@ -2,7 +2,6 @@ export type IframeModeButtonState = {
key:
| "agent"
| "writing"
| "memory"
| "references"
| "contextCompression"
| "pro";
@@ -14,9 +13,6 @@ export type IframeModeButtonState = {
type BuildIframeModeButtonsInput = {
agentSelected: boolean;
writingModeEnabled: boolean;
memoryInjectionEnabled: boolean;
/** 产品当前全局隐藏记忆入口;保留 false 供后续无损恢复。 */
hideMemoryButton?: boolean;
referenceModeEntryEnabled: boolean;
ragModeEnabled: boolean;
ragModeLocked: boolean;
@@ -27,8 +23,6 @@ type BuildIframeModeButtonsInput = {
export function buildIframeModeButtons({
agentSelected,
writingModeEnabled,
memoryInjectionEnabled,
hideMemoryButton = true,
referenceModeEntryEnabled,
ragModeEnabled,
ragModeLocked,
@@ -41,11 +35,6 @@ export function buildIframeModeButtons({
label: "写作",
active: writingModeEnabled,
},
{
key: "memory",
label: "记忆",
active: memoryInjectionEnabled,
},
{
key: "references",
label: "参考文献",
@@ -69,8 +58,6 @@ export function buildIframeModeButtons({
},
];
return buttons.filter(
(button) =>
(referenceModeEntryEnabled || button.key !== "references") &&
(!hideMemoryButton || button.key !== "memory"),
(button) => referenceModeEntryEnabled || button.key !== "references",
);
}
@@ -0,0 +1,528 @@
/**
* 站点预览工作室(Web App 产物模式的独立预览页)。
*
* 设计原则:
* - 独立路由 + 独立样式(site-studio.css,不依赖主应用工作台风格);
* - 能力不可用 / 站点未构建 / 构建失败均有设计过的降级态;
* - iframe 指向后端签名静态托管 URL,sandbox 不含 allow-same-origin(安全契约)。
*/
import { Component, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Navigate, useParams } from "react-router-dom";
import {
ArrowLeft,
ChevronDown,
ChevronUp,
ExternalLink,
Globe,
Hammer,
Lock,
Monitor,
PackageOpen,
RefreshCw,
Smartphone,
Tablet,
} from "lucide-react";
import { getStoredAuth } from "@/core/auth";
import { getBackendBaseURL } from "@/core/config";
import {
useBuildSiteMutation,
useMintPreviewUrlMutation,
useSiteBuildStatus,
useSites,
useWebAppCapability,
type SiteBuildStatusResponse,
type SiteInfo,
} from "@/core/sites/api";
import "./site-studio.css";
type DeviceMode = "desktop" | "tablet" | "mobile";
const DEVICE_WIDTH: Record<DeviceMode, string> = {
desktop: "100%",
tablet: "768px",
mobile: "390px",
};
class SiteStudioErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
state = { error: null as Error | null };
static getDerivedStateFromError(error: Error) {
return { error };
}
render() {
if (this.state.error) {
return (
<div className="site-studio-root">
<div className="site-studio-state">
<div className="site-studio-state-card">
<div className="site-studio-state-art">
<Globe size={34} />
</div>
<h1 className="site-studio-title">预览页出现异常</h1>
<p className="site-studio-state-desc site-studio-error-text">
{this.state.error.message}
</p>
<a className="site-studio-btn site-studio-btn--primary" href="#/page/workspace/chats/new">
返回工作台
</a>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export default function SiteStudioPage() {
return (
<SiteStudioErrorBoundary>
<SiteStudioContent />
</SiteStudioErrorBoundary>
);
}
function SiteStudioContent() {
const { threadId, siteName } = useParams<{ threadId: string; siteName: string }>();
const hasAuth = !!getStoredAuth();
if (!hasAuth) {
return <Navigate to="/login" replace />;
}
if (!threadId || !siteName) {
return <Navigate to="/page/workspace" replace />;
}
return <SiteStudioView threadId={threadId} siteName={siteName} />;
}
function SiteStudioView({ threadId, siteName }: { threadId: string; siteName: string }) {
const capabilityQuery = useWebAppCapability();
const capability = capabilityQuery.data;
const sitesQuery = useSites(threadId, { refetchInterval: 30_000 });
const site: SiteInfo | undefined = useMemo(
() => sitesQuery.data?.sites?.find((s) => s.name === siteName),
[sitesQuery.data, siteName],
);
const [device, setDevice] = useState<DeviceMode>("desktop");
const [activePage, setActivePage] = useState("");
const [cacheBust, setCacheBust] = useState(() => Date.now());
const [previewBase, setPreviewBase] = useState("");
const [consoleOpen, setConsoleOpen] = useState(false);
const mintingRef = useRef(false);
const buildStatusQuery = useSiteBuildStatus(threadId, siteName, {
refetchInterval: (query) => (query.state.data?.status === "running" ? 1500 : false),
});
const buildStatus = buildStatusQuery.data;
const building = buildStatus?.status === "running";
const buildMutation = useBuildSiteMutation(threadId, siteName);
const mintMutation = useMintPreviewUrlMutation(threadId, siteName);
const capabilityAvailable = capability?.available === true;
const siteReady = capabilityAvailable && !!site && site.dist.exists;
// 签发/续期预览地址
const mintPreview = useCallback(async () => {
if (mintingRef.current) return;
mintingRef.current = true;
try {
const res = await mintMutation.mutateAsync();
if (res.available && res.url_path) {
setPreviewBase(`${getBackendBaseURL()}${res.url_path}`);
}
} catch {
// 签发失败由状态视图兜底
} finally {
mintingRef.current = false;
}
}, [mintMutation]);
useEffect(() => {
if (siteReady && !previewBase) {
void mintPreview();
}
}, [siteReady, previewBase, mintPreview]);
// 构建结束后:刷新站点信息 + 重建预览
const prevBuildingRef = useRef(false);
useEffect(() => {
const wasBuilding = prevBuildingRef.current;
prevBuildingRef.current = building;
if (wasBuilding && !building) {
void sitesQuery.refetch();
void buildStatusQuery.refetch();
setCacheBust(Date.now());
setPreviewBase("");
}
}, [building, sitesQuery, buildStatusQuery]);
// 默认选中第一个导航页
useEffect(() => {
if (!activePage && site?.nav?.length) {
setActivePage(site.nav[0].page);
}
}, [activePage, site]);
const refreshPreview = useCallback(() => {
setPreviewBase("");
setCacheBust(Date.now());
}, []);
const frameSrc = previewBase
? `${previewBase}/index.html?v=${cacheBust}${activePage ? `#/${activePage}` : ""}`
: "";
const openExternal = useCallback(() => {
if (frameSrc) {
window.open(frameSrc, "_blank", "noopener,noreferrer");
}
}, [frameSrc]);
// ── 能力探测中 ──
if (capabilityQuery.isLoading) {
return (
<div className="site-studio-root">
<StateCard spinner title="正在连接站点服务…" />
</div>
);
}
// ── 功能未启用 / 部署不完整 ──
if (!capabilityAvailable) {
const reasons =
capability?.reasons ??
Object.entries(capability?.checks ?? {})
.filter(([, v]) => !v.ok)
.map(([k, v]) => `${k}: ${v.detail}`);
return (
<div className="site-studio-root">
<StateCard
icon={<Lock size={34} />}
title="站点产物模式未启用"
desc="当前环境未开启 Web App 站点模式(配置开关或模板依赖未就绪)。不影响其他功能;智能体会按单页 HTML 方式产出网页。"
reasons={capabilityQuery.error ? ["接口不可达:" + capabilityQuery.error.message] : reasons}
action={
<a className="site-studio-btn" href={`#/page/workspace/chats/${threadId}`}>
<ArrowLeft size={15} /> 返回对话
</a>
}
/>
</div>
);
}
if (sitesQuery.isLoading) {
return (
<div className="site-studio-root">
<StateCard spinner title="正在加载站点信息…" />
</div>
);
}
// ── 站点不存在 ──
if (!site) {
return (
<div className="site-studio-root">
<StateCard
icon={<PackageOpen size={34} />}
title="未找到该站点"
desc={`对话产物中没有名为「${siteName}」的站点(outputs/${siteName}/site.json 不存在)。`}
action={
<button type="button" className="site-studio-btn" onClick={() => void sitesQuery.refetch()}>
<RefreshCw size={15} /> 重新扫描
</button>
}
/>
</div>
);
}
const statusPill =
buildStatus?.status === "running"
? { className: "running", label: "构建中" }
: derivePill(site);
return (
<div className="site-studio-root">
<header className="site-studio-topbar">
<a className="site-studio-back" href={`#/page/workspace/chats/${threadId}`}>
<ArrowLeft size={15} /> 返回对话
</a>
<div className="site-studio-title-wrap">
<h1 className="site-studio-title">{site.title}</h1>
<span className="site-studio-subtitle">{site.description || siteName}</span>
</div>
<span className={`site-studio-pill site-studio-pill--${statusPill.className}`}>
<span className="site-studio-dot" />
{statusPill.label}
</span>
<div className="site-studio-seg" role="group" aria-label="设备宽度">
<button
type="button"
title="桌面"
aria-label="桌面"
className={`site-studio-seg-btn ${device === "desktop" ? "site-studio-seg-btn--active" : ""}`}
onClick={() => setDevice("desktop")}
>
<Monitor size={16} />
</button>
<button
type="button"
title="平板"
aria-label="平板"
className={`site-studio-seg-btn ${device === "tablet" ? "site-studio-seg-btn--active" : ""}`}
onClick={() => setDevice("tablet")}
>
<Tablet size={16} />
</button>
<button
type="button"
title="手机"
aria-label="手机"
className={`site-studio-seg-btn ${device === "mobile" ? "site-studio-seg-btn--active" : ""}`}
onClick={() => setDevice("mobile")}
>
<Smartphone size={16} />
</button>
</div>
<button
type="button"
className="site-studio-btn"
onClick={refreshPreview}
disabled={!siteReady}
>
<RefreshCw size={15} /> 刷新
</button>
<button
type="button"
className="site-studio-btn"
onClick={() => void openExternal()}
disabled={!siteReady}
>
<ExternalLink size={15} /> 新窗口
</button>
<button
type="button"
className="site-studio-btn site-studio-btn--primary"
onClick={() => buildMutation.mutate()}
disabled={buildMutation.isPending || building}
>
<Hammer size={15} /> {building ? "构建中…" : "重新构建"}
</button>
</header>
<div className="site-studio-body">
<aside className="site-studio-sidenav">
<div>
<div className="site-studio-sidenav-label">站点页面</div>
<nav className="site-studio-navlist">
{(site.nav ?? []).map((item, index) => (
<button
key={`${item.page}-${index}`}
type="button"
className={`site-studio-navitem ${activePage === item.page ? "site-studio-navitem--active" : ""}`}
onClick={() => setActivePage(item.page)}
>
<span className="site-studio-navindex">{String(index + 1).padStart(2, "0")}</span>
{item.label || item.page}
</button>
))}
</nav>
</div>
<div>
<div className="site-studio-sidenav-label">产物</div>
<div className="site-studio-sidenav-files">
<div>
<code>dist/</code>{" "}
{site.dist.exists ? `${site.dist.file_count ?? 0} 个文件` : "未构建"}
</div>
{site.dist.exists && site.dist.total_bytes != null && (
<div>{formatBytes(site.dist.total_bytes)}</div>
)}
</div>
</div>
</aside>
<main className="site-studio-stage">
{siteReady ? (
<div
className="site-studio-frame-shell"
style={{ "--ss-frame-w": DEVICE_WIDTH[device] } as React.CSSProperties}
>
<div className="site-studio-frame-bar">
<span className="site-studio-traffic">
<span />
<span />
<span />
</span>
<div className="site-studio-urlbox">
<Globe size={12} />
<span title={frameSrc}>{siteName}/dist/index.html{activePage ? `#/${activePage}` : ""}</span>
</div>
</div>
<div className="site-studio-frame-holder">
{frameSrc ? (
<iframe
key={frameSrc}
className="site-studio-iframe"
src={frameSrc}
title={`${site.title} 预览`}
sandbox="allow-scripts allow-forms allow-popups allow-modals"
referrerPolicy="no-referrer"
/>
) : (
<div className="site-studio-state">
<StateCard spinner title="正在签发预览地址…" />
</div>
)}
</div>
</div>
) : (
<StateCard
icon={<PackageOpen size={34} />}
title={site.last_build?.success === false ? "构建失败" : "站点尚未构建"}
desc={
site.last_build?.success === false
? "最近一次 vite build 失败,可在下方控制台查看报错,修复源码后重新构建。"
: "源码已就绪但还没有 dist/ 构建产物。点击「重新构建」生成可预览的静态文件。"
}
reasons={site.last_build?.error ? [site.last_build.error] : undefined}
action={
<button
type="button"
className="site-studio-btn site-studio-btn--primary"
onClick={() => buildMutation.mutate()}
disabled={buildMutation.isPending || building}
>
<Hammer size={15} /> {building ? "构建中…" : "立即构建"}
</button>
}
/>
)}
</main>
</div>
<footer className="site-studio-console">
<div
className="site-studio-console-head"
onClick={() => setConsoleOpen((v) => !v)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setConsoleOpen((v) => !v);
}}
>
{consoleOpen ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
<span className="site-studio-console-title">构建控制台</span>
{buildStatus?.status && buildStatus.status !== "idle" && (
<span className="site-studio-console-meta">
{buildStatus.status}
{buildStatus.result?.duration_ms != null &&
` · ${(buildStatus.result.duration_ms / 1000).toFixed(1)}s`}
</span>
)}
{buildMutation.isError && (
<span className="site-studio-console-meta site-studio-error-text">
{buildMutation.error instanceof Error ? buildMutation.error.message : "触发失败"}
</span>
)}
</div>
{consoleOpen && <BuildConsole site={site} buildStatus={buildStatus ?? null} />}
</footer>
</div>
);
}
function derivePill(site: SiteInfo): { className: string; label: string } {
if (!site.dist.exists) {
return { className: "none", label: "未构建" };
}
if (site.last_build?.success === false) {
return { className: "failed", label: "构建失败" };
}
return { className: "success", label: "可预览" };
}
function BuildConsole({
site,
buildStatus,
}: {
site: SiteInfo;
buildStatus: SiteBuildStatusResponse | null | undefined;
}) {
const result = buildStatus?.result ?? site.last_build ?? null;
if (!result) {
return (
<div className="site-studio-console-body">
<div className="site-studio-console-empty">暂无构建记录。点击「重新构建」生成 dist/ 产物。</div>
</div>
);
}
const errorText = buildStatus?.error ?? result.error ?? "";
const log = [result.stdout_tail ?? "", result.stderr_tail ?? ""]
.filter(Boolean)
.join("\n")
.replace(/\u001b\[[0-9;]*m/g, "")
.trim();
return (
<div className="site-studio-console-body">
{errorText && <div className="site-studio-log-error">{errorText}</div>}
{result.success && !errorText && (
<div className="site-studio-log-success">
✓ 构建成功
{result.duration_ms != null && ` · ${(result.duration_ms / 1000).toFixed(1)}s`}
</div>
)}
{log && <div>{log}</div>}
{!log && !errorText && !result.success && <div>(无日志输出)</div>}
</div>
);
}
function StateCard({
icon,
spinner,
title,
desc,
reasons,
action,
}: {
icon?: ReactNode;
spinner?: boolean;
title: string;
desc?: string;
reasons?: string[];
action?: ReactNode;
}) {
return (
<div className="site-studio-state">
<div className="site-studio-state-card">
{spinner ? (
<div className="site-studio-spinner" />
) : (
<div className="site-studio-state-art">{icon}</div>
)}
<h1 className="site-studio-title">{title}</h1>
{desc && <p className="site-studio-state-desc">{desc}</p>}
{reasons && reasons.length > 0 && (
<div className="site-studio-state-reasons">
{reasons.map((r, i) => (
<div key={i}>· {r}</div>
))}
</div>
)}
{action}
</div>
</div>
);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
@@ -0,0 +1,593 @@
/*
* 站点预览工作室 —— 独立视觉体系(与主应用风格完全区隔)。
* 深空底 + 靛蓝/青渐变强调 + 玻璃拟态面板;全部类名 site-studio- 前缀,
* 仅由 SiteStudioPage 引入,不触碰全局样式与主题变量。
*/
.site-studio-root {
--ss-bg: #0b0d13;
--ss-bg-2: #0f1118;
--ss-panel: rgba(255, 255, 255, 0.04);
--ss-panel-strong: rgba(255, 255, 255, 0.07);
--ss-border: rgba(255, 255, 255, 0.09);
--ss-border-strong: rgba(255, 255, 255, 0.16);
--ss-text: #eef1f7;
--ss-text-2: #9aa3b8;
--ss-text-3: #667085;
--ss-accent: #5b7cfa;
--ss-accent-2: #35d4c5;
--ss-danger: #f26d7d;
--ss-success: #3ecf8e;
--ss-warn: #f5b95f;
--ss-font:
"Inter", "HarmonyOS Sans SC", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
--ss-mono: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
--ss-radius: 14px;
position: fixed;
inset: 0;
z-index: 60;
display: flex;
flex-direction: column;
background:
radial-gradient(1100px 500px at 85% -10%, rgba(91, 124, 250, 0.16), transparent 60%),
radial-gradient(900px 460px at -10% 110%, rgba(53, 212, 197, 0.1), transparent 55%),
var(--ss-bg);
color: var(--ss-text);
font-family: var(--ss-font);
overflow: hidden;
}
/* ───────── 顶部工具栏 ───────── */
.site-studio-topbar {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 20px;
border-bottom: 1px solid var(--ss-border);
background: rgba(11, 13, 19, 0.72);
backdrop-filter: blur(14px);
flex-shrink: 0;
}
.site-studio-back {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--ss-text-2);
font-size: 13px;
text-decoration: none;
padding: 7px 12px;
border-radius: 10px;
border: 1px solid var(--ss-border);
background: var(--ss-panel);
transition: all 0.16s ease;
white-space: nowrap;
}
.site-studio-back:hover {
color: var(--ss-text);
border-color: var(--ss-border-strong);
background: var(--ss-panel-strong);
}
.site-studio-title-wrap {
min-width: 0;
flex: 1;
display: flex;
align-items: baseline;
gap: 10px;
}
.site-studio-title {
font-size: 16px;
font-weight: 700;
letter-spacing: 0.2px;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.site-studio-subtitle {
font-size: 12px;
color: var(--ss-text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.site-studio-pill {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12px;
padding: 4px 12px;
border-radius: 999px;
border: 1px solid var(--ss-border);
background: var(--ss-panel);
color: var(--ss-text-2);
white-space: nowrap;
}
.site-studio-pill .site-studio-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ss-text-3);
}
.site-studio-pill--success {
color: var(--ss-success);
border-color: rgba(62, 207, 142, 0.35);
}
.site-studio-pill--success .site-studio-dot {
background: var(--ss-success);
}
.site-studio-pill--running {
color: var(--ss-accent-2);
border-color: rgba(53, 212, 197, 0.35);
}
.site-studio-pill--running .site-studio-dot {
background: var(--ss-accent-2);
animation: site-studio-breathe 1.6s ease-in-out infinite;
}
.site-studio-pill--failed {
color: var(--ss-danger);
border-color: rgba(242, 109, 125, 0.4);
}
.site-studio-pill--failed .site-studio-dot {
background: var(--ss-danger);
}
.site-studio-pill--none {
color: var(--ss-warn);
border-color: rgba(245, 185, 95, 0.35);
}
.site-studio-pill--none .site-studio-dot {
background: var(--ss-warn);
}
@keyframes site-studio-breathe {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.45;
transform: scale(0.82);
}
}
/* 设备切换分段控件 */
.site-studio-seg {
display: inline-flex;
padding: 3px;
gap: 2px;
border-radius: 11px;
border: 1px solid var(--ss-border);
background: var(--ss-panel);
}
.site-studio-seg-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 30px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--ss-text-3);
cursor: pointer;
transition: all 0.15s ease;
}
.site-studio-seg-btn:hover {
color: var(--ss-text);
}
.site-studio-seg-btn--active {
background: linear-gradient(135deg, rgba(91, 124, 250, 0.85), rgba(72, 96, 220, 0.85));
color: #fff;
box-shadow: 0 2px 10px rgba(91, 124, 250, 0.35);
}
/* 主按钮 */
.site-studio-btn {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-family: inherit;
padding: 7px 14px;
border-radius: 10px;
border: 1px solid var(--ss-border);
background: var(--ss-panel);
color: var(--ss-text-2);
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
}
.site-studio-btn:hover:not(:disabled) {
color: var(--ss-text);
border-color: var(--ss-border-strong);
background: var(--ss-panel-strong);
}
.site-studio-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.site-studio-btn--primary {
background: linear-gradient(135deg, #5b7cfa, #465edc);
border-color: transparent;
color: #fff;
box-shadow: 0 4px 18px rgba(91, 124, 250, 0.35);
}
.site-studio-btn--primary:hover:not(:disabled) {
background: linear-gradient(135deg, #6d8bff, #5570ea);
color: #fff;
}
/* ───────── 主体三栏 ───────── */
.site-studio-body {
flex: 1;
display: flex;
min-height: 0;
}
/* 左侧页面导航 */
.site-studio-sidenav {
width: 208px;
flex-shrink: 0;
border-right: 1px solid var(--ss-border);
padding: 16px 12px;
display: flex;
flex-direction: column;
gap: 14px;
overflow-y: auto;
background: rgba(15, 17, 24, 0.5);
}
.site-studio-sidenav-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 1.4px;
text-transform: uppercase;
color: var(--ss-text-3);
padding: 0 8px;
}
.site-studio-navlist {
display: flex;
flex-direction: column;
gap: 3px;
}
.site-studio-navitem {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
text-align: left;
font-family: inherit;
font-size: 13px;
color: var(--ss-text-2);
background: transparent;
border: 1px solid transparent;
border-radius: 10px;
padding: 9px 10px;
cursor: pointer;
transition: all 0.15s ease;
}
.site-studio-navitem:hover {
color: var(--ss-text);
background: var(--ss-panel);
}
.site-studio-navitem--active {
color: var(--ss-text);
background: linear-gradient(135deg, rgba(91, 124, 250, 0.2), rgba(53, 212, 197, 0.08));
border-color: rgba(91, 124, 250, 0.35);
}
.site-studio-navitem .site-studio-navindex {
font-size: 11px;
font-family: var(--ss-mono);
color: var(--ss-text-3);
width: 18px;
flex-shrink: 0;
}
.site-studio-navitem--active .site-studio-navindex {
color: var(--ss-accent);
}
.site-studio-sidenav-files {
font-size: 12px;
color: var(--ss-text-3);
padding: 0 8px;
line-height: 2;
}
.site-studio-sidenav-files code {
font-family: var(--ss-mono);
font-size: 11px;
color: var(--ss-text-2);
}
/* 中央预览区 */
.site-studio-stage {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
padding: 22px 26px 26px;
overflow: auto;
}
.site-studio-frame-shell {
display: flex;
flex-direction: column;
width: var(--ss-frame-w, 100%);
max-width: 100%;
height: 100%;
min-height: 420px;
border-radius: var(--ss-radius);
border: 1px solid var(--ss-border-strong);
background: #171a23;
box-shadow:
0 24px 70px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
overflow: hidden;
transition: width 0.35s cubic-bezier(0.25, 0.8, 0.3, 1);
}
.site-studio-frame-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 14px;
background: rgba(255, 255, 255, 0.045);
border-bottom: 1px solid var(--ss-border);
flex-shrink: 0;
}
.site-studio-traffic {
display: inline-flex;
gap: 6px;
}
.site-studio-traffic span {
width: 10px;
height: 10px;
border-radius: 50%;
}
.site-studio-traffic span:nth-child(1) {
background: #ff5f57;
}
.site-studio-traffic span:nth-child(2) {
background: #febc2e;
}
.site-studio-traffic span:nth-child(3) {
background: #28c840;
}
.site-studio-urlbox {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
background: rgba(0, 0, 0, 0.32);
border: 1px solid var(--ss-border);
border-radius: 8px;
padding: 5px 12px;
font-family: var(--ss-mono);
font-size: 11px;
color: var(--ss-text-3);
white-space: nowrap;
overflow: hidden;
}
.site-studio-urlbox svg {
flex-shrink: 0;
color: var(--ss-accent-2);
}
.site-studio-urlbox span {
overflow: hidden;
text-overflow: ellipsis;
}
.site-studio-frame-holder {
flex: 1;
min-height: 0;
background: #fff;
position: relative;
}
.site-studio-iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
display: block;
background: #fff;
}
/* ───────── 底部构建控制台 ───────── */
.site-studio-console {
flex-shrink: 0;
border-top: 1px solid var(--ss-border);
background: rgba(9, 10, 16, 0.85);
max-height: 34vh;
display: flex;
flex-direction: column;
}
.site-studio-console-head {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 20px;
cursor: pointer;
user-select: none;
font-size: 12px;
color: var(--ss-text-2);
}
.site-studio-console-head:hover {
color: var(--ss-text);
}
.site-studio-console-title {
font-weight: 600;
letter-spacing: 0.4px;
}
.site-studio-console-meta {
color: var(--ss-text-3);
font-family: var(--ss-mono);
font-size: 11px;
}
.site-studio-console-body {
overflow: auto;
padding: 4px 20px 16px;
font-family: var(--ss-mono);
font-size: 11.5px;
line-height: 1.75;
color: var(--ss-text-2);
white-space: pre-wrap;
word-break: break-all;
}
.site-studio-console-body .site-studio-log-error {
color: var(--ss-danger);
}
.site-studio-console-body .site-studio-log-success {
color: var(--ss-success);
}
.site-studio-console-empty {
color: var(--ss-text-3);
font-family: var(--ss-font);
font-size: 12px;
padding: 8px 0 14px;
}
/* ───────── 状态视图 ───────── */
.site-studio-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
.site-studio-state-card {
max-width: 520px;
text-align: center;
padding: 44px 40px;
border-radius: 20px;
border: 1px solid var(--ss-border);
background: var(--ss-panel);
backdrop-filter: blur(10px);
}
.site-studio-state-art {
width: 76px;
height: 76px;
margin: 0 auto 22px;
border-radius: 22px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(91, 124, 250, 0.25), rgba(53, 212, 197, 0.18));
border: 1px solid rgba(91, 124, 250, 0.35);
color: var(--ss-accent);
}
.site-studio-state-title {
font-size: 17px;
font-weight: 700;
margin: 0 0 10px;
}
.site-studio-state-desc {
font-size: 13px;
color: var(--ss-text-2);
line-height: 1.7;
margin: 0 0 18px;
}
.site-studio-state-reasons {
text-align: left;
margin: 0 auto 20px;
padding: 12px 16px;
max-width: 420px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.28);
border: 1px solid var(--ss-border);
font-size: 12px;
color: var(--ss-text-3);
font-family: var(--ss-mono);
line-height: 1.8;
}
.site-studio-spinner {
width: 30px;
height: 30px;
border-radius: 50%;
border: 3px solid var(--ss-border-strong);
border-top-color: var(--ss-accent);
animation: site-studio-spin 0.9s linear infinite;
margin: 0 auto 18px;
}
@keyframes site-studio-spin {
to {
transform: rotate(360deg);
}
}
.site-studio-error-text {
color: var(--ss-danger);
font-size: 12px;
font-family: var(--ss-mono);
word-break: break-all;
}
/* 响应式:窄屏收起左栏 */
@media (max-width: 860px) {
.site-studio-sidenav {
display: none;
}
.site-studio-subtitle {
display: none;
}
}
@@ -114,10 +114,12 @@ export function PositionBusinessChainFlow({
)}
/>
<div className="min-w-0">
<h2 className={cn("truncate text-base font-semibold text-slate-900 dark:text-white", embedded && "text-lg font-[550] dark:text-white")}>业务链条</h2>
{!embedded && (
<h2 className={cn("truncate text-base font-semibold text-slate-900 dark:text-white", embedded && "text-lg font-[550] dark:text-white")}>
{chain?.title?.trim() || "业务链条"}
</h2>
{!embedded && !chain && (
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
{chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
{loading ? "正在加载管理员配置…" : "等待管理员配置"}
</p>
)}
</div>
Loaded 100 of 299 files, more files were not shown because too many files have changed in this diff. Show more