Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c933a8f19 | ||
|
|
ea9dcff9c0 | ||
|
|
8bc8b0e082 | ||
|
|
4f7f07504a | ||
|
|
3ca5528640 | ||
|
|
f9b6d7e322 |
No files matched your search
+23
-14
@@ -88,23 +88,28 @@ skills/
|
||||
hermes-agent-main/
|
||||
.gitignore
|
||||
offline-backend-20260512/backend/tests/*
|
||||
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_gauss_database.py
|
||||
# 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_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 generated-file library aggregation and filtering regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_artifact_library.py
|
||||
# Keep Web App site artifact (multi-page site) regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_web_app_sites.py
|
||||
# Keep the model configuration regression coverage versioned even though
|
||||
# this deployment repository otherwise ignores newly added backend tests.
|
||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
||||
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
|
||||
# Keep strategy-selection built-in agent seed and safety-contract regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_strategy_selection_seed.py
|
||||
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
||||
# 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
|
||||
@@ -143,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.
|
||||
@@ -179,10 +188,10 @@ docs/backups
|
||||
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
|
||||
!frontend-web/src/components/workspace/skills/
|
||||
|
||||
# uv lockfile (not tracked per project preference)
|
||||
offline-backend-20260512/backend/uv.lock
|
||||
|
||||
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
|
||||
# uv lockfile (not tracked per project preference)
|
||||
offline-backend-20260512/backend/uv.lock
|
||||
|
||||
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
|
||||
|
||||
# Local-only scratch directory (not committed)
|
||||
0622/
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Web App 产物模式:多页面站点 / 流程图 / 地图(可降级 + 独立页面风格)
|
||||
|
||||
## 总体架构
|
||||
|
||||
让智能体在沙箱里产出**完整多页面站点**(site.json 清单 + src/pages + public 数据),平台统一构建(预置 node_modules,永不 npm install)与受控静态托管,前端新注册一个**风格独立**的「站点预览」路由页。
|
||||
|
||||
**两条铁律(本次新增的核心原则)**:
|
||||
1. **零侵入**:新页面不复用现有工作台风格/布局组件,走独立路由 + 独立样式表;对已有代码的修改仅限「一个带开关的入口按钮」。
|
||||
2. **可降级**:沙箱/模板/node/构建链任何一环部署失败,Web App 功能整体退回"不可用"状态并优雅提示,智能体回退到现有单页 HTML 模式,**现有功能零影响**。
|
||||
|
||||
```
|
||||
智能体(技能 web-app)
|
||||
0. 检查可用性:/mnt/web-app-template 缺失 → 回退现有单页 HTML 模式(行为与今天完全一致)
|
||||
1. scaffold → outputs/my-site/(排除 node_modules/dist)
|
||||
2. 写 src/pages/*.tsx、public/data.geojson、编辑 site.json
|
||||
3. build_site.py build → 软链/junction 预置 node_modules → vite build → dist/(失败自修复 ≤3 次)
|
||||
4. present_files site.json
|
||||
平台(Gateway,全部能力探测 + 开关保护)
|
||||
GET /api/web-app/capability # 可用性探测(配置/模板/node_modules/node 四项检查)
|
||||
GET /api/threads/{tid}/sites # 站点列表 + 构建状态
|
||||
POST /api/threads/{tid}/sites/{name}/build # 触发构建
|
||||
GET /api/threads/{tid}/sites/{name}/build-status
|
||||
POST /api/threads/{tid}/sites/{name}/preview-url → 签发短时效签名 URL
|
||||
GET /api/public/site-preview/{token}/{path} # 静态托管 dist/
|
||||
前端(独立路由 + 独立风格)
|
||||
#/sites/:threadId/:siteName # SiteStudioPage:全新视觉风格,无现有 chrome
|
||||
产物面板 site.json →「打开站点预览」按钮(仅 capability 可用时渲染)
|
||||
```
|
||||
|
||||
已确认决策:混合构建(技能自修复 + 平台按钮兜底);预览工作台先行;GeoJSON 优先 + 可配置底图。
|
||||
|
||||
---
|
||||
|
||||
## 一、页面风格:独立设计系统(不用现有风格)
|
||||
|
||||
新页面 `SiteStudioPage` 做成**深色"预览工作室"**风格,视觉上与现有工作台完全区隔:
|
||||
|
||||
- **独立路由**:直接注册在 `App.tsx` 顶层(`/share/:shareCode`、`/login` 同款先例),**不进** `PageLayout`/`WorkspaceLayout`/`Header`/`PageSidebar`——零现有 chrome 继承,零布局耦合。
|
||||
- **独立样式**:新建 `src/pages/site-studio/site-studio.css`(自建 design tokens:深空灰底 `#0f1117` 系、靛蓝/青渐变强调色、玻璃拟态面板、细腻边框与阴影、系统字体栈),所有类名 `site-studio-` 前缀,**只被该页面 import**,不碰 tailwind 配置、全局 CSS、主题 provider。
|
||||
- **布局**:
|
||||
- 顶部悬浮工具栏:站点标题 + 构建状态胶囊(成功/构建中/失败三态,带呼吸灯动画)+ 设备切换分段控件(桌面/平板/手机)+ 刷新/重新构建/新窗口打开;
|
||||
- 左侧窄导航栏:site.json 的页面列表(图标 + 标题,点击设 iframe hash 切页),可折叠成纯图标模式;
|
||||
- 中央:**浏览器壳**造型(圆角 + 顶部仿 URL 地址条 + 红黄绿窗控点缀)内嵌 iframe,设备切换时宽度平滑过渡动画;
|
||||
- 底部可折叠**构建控制台**(等宽字体日志、错误红色高亮、构建时长);
|
||||
- 加载/失败/未启用态都有设计过的插画式空状态(纯 CSS/SVG,不引组件库新依赖)。
|
||||
- 登录守卫:轻量检查 stored auth,缺失跳 `#/login`(复用既有跳转工具,不动鉴权体系)。
|
||||
|
||||
## 二、降级与故障隔离矩阵(部署失败不影响已有功能)
|
||||
|
||||
| 故障点 | 行为 | 对现有功能影响 |
|
||||
|---|---|---|
|
||||
| config `web_app.enabled: false`(默认关) | capability 返回不可用;入口按钮不渲染;技能第 0 步探测失败即回退单页 HTML | 无 |
|
||||
| 模板目录/node_modules 未预置(发版遗漏) | 同上:capability 四项检查(配置/模板/依赖/node 二进制)逐项报告原因 | 无 |
|
||||
| sandbox.mounts 加了模板挂载但目录缺失 | 已核实 `local_sandbox_provider.py:81-90` 对不存在宿主路径**静默跳过仅告警**;Aio provider 同样按"存在才挂"实现 | 无(沿用既有容错语义) |
|
||||
| vite build 失败/超时 | 子进程隔离(`asyncio.to_thread` + 超时强杀 + 每站点并发锁),错误收进 JSON;预览页显示日志;智能体读报错自修复 | 无(不进 gateway 请求主链路) |
|
||||
| 签名 URL 过期/伪造 | 403 JSON,页面自动重签 | 无 |
|
||||
| 前端新页面任何异常 | 独立路由独立样式,最坏就是该页白屏,error boundary 兜底 | 无 |
|
||||
| 产物面板入口按钮 | 唯一改动点:capability 请求失败/不可用 → 按钮不渲染,**原 UI 一字不变** | 无 |
|
||||
| 新 router 注册 | 纯增量 include_router,异常全部在路由内转结构化响应 | 无 |
|
||||
|
||||
技能回退语义(关键):SKILL.md 第 0 步强制探测 `ls /mnt/web-app-template/scripts/build_site.py`——探测失败时明确指示"改用现有方式产出单个 xxx.html",保证部署不完整时智能体产物质量与今天一致。
|
||||
|
||||
## P1 站点模板(`offline-backend-20260512/backend/templates/web-app-template/`,源码入库,node_modules gitignore 由发版仓库预置)
|
||||
|
||||
- Vite + React + react-router-dom(**HashRouter**:静态托管路径下无需服务端 fallback)+ **纯 CSS design tokens**(不用 Tailwind v4,规避 oklch 在 chrome100 老内核失效)+ build target es2018。
|
||||
- 依赖:react 系、@xyflow/react、maplibre-gl、echarts、mermaid、pmtiles、vite/@vitejs/plugin-react(lockfile 锁死版本)。
|
||||
- 内置组件:`AppShell`(读 site.json 导航)、`FlowCanvas`(xyflow 流程图封装,自动分层布局)、`GeoMap`(**默认无底图模式**:背景 + GeoJSON 点线面离线开箱即用;site.json 可配 tilesUrl/PMTiles)、`ChartPanel`(echarts)、`MermaidBlock`(静态图)。
|
||||
- `router.tsx` 用 `import.meta.glob('../pages/*.tsx')` 自动注册路由——智能体丢页面文件即生效,零手动接线。
|
||||
- `site.json`:`{ title, description, nav[], map:{tilesUrl?, tilesType?, pmtiles?}, theme? }`。
|
||||
- Makefile 加 `make webapp-template`(开发机一次性 pnpm install);发版仓库构建脚本负责镜像内预置 node_modules(文档写明)。
|
||||
- config.yaml `sandbox.mounts` 增模板只读挂载(`templates/web-app-template → /mnt/web-app-template`,缺失自动跳过已验证);AioSandboxProvider `_get_thread_mounts` 小扩展:同样"存在才挂"地补读 config 自定义 mounts。
|
||||
|
||||
## P2 构建脚本 + 技能包(`skills/public/web-app/`)
|
||||
|
||||
- `SKILL.md`:触发词(多页面网站/站点/web app/带流程图或地图的网页);工作流 = ①可用性探测(失败即回退单页 HTML)②scaffold ③配置 site.json ④写页面 ⑤数据放 public/ ⑥build(失败读 JSON 报错修复重试 ≤3)⑦present_files site.json ⑧引导点「站点预览」;硬约束:不手写 dist、一律 `/mnt/user-data/...` 虚拟路径。
|
||||
- `scripts/build_site.py`(纯标准库):`check`(能力探测 JSON)/ `scaffold`(copytree 忽略 node_modules、dist)/ `build`(定位 node → 软链 node_modules 进站点:Linux symlink、Windows `mklink /J` junction 免管理员 → `node vite.js build` → 结构化 JSON 输出,180s 超时)/ `status`。
|
||||
- 模板定位顺序:env `DEERFLOW_WEBAPP_TEMPLATE` → `/mnt/web-app-template`(沙箱内)→ 后端相对路径(宿主侧)。
|
||||
|
||||
## P3 后端:纯引擎 + 路由(全部能力开关保护)
|
||||
|
||||
- `packages/harness/deerflow/webapp/`(纯引擎,不 import app.*,对齐 `address_scan.py` 先例):站点发现(outputs/* 下 site.json + dist 状态)、构建执行(子进程 + 锁 + 超时)、HMAC token(`tid|site|exp`,密钥首用生成持久化 `{base_dir}/.web-app-secret` 0600)。
|
||||
- `app/gateway/routers/sites.py`(app.py 注册):
|
||||
- `GET /api/web-app/capability`:enabled + 模板 + node_modules + node 四项检查,逐项给原因(前端与技能共用);
|
||||
- 列表/build/build-status/preview-url:`@require_permission("threads", "read", owner_check=True)`,能力不可用时返回结构化 `{"available": false, "reason"}` 而非 5xx;
|
||||
- `GET /api/public/site-preview/{token}/{path}`:验签 + 过期 → `aresolve_thread_virtual_path` 且**限定站点 dist/ 根**(复用遍历防护)→ 正确 MIME(.js/.mjs→text/javascript、.geojson→application/json、.wasm)→ **HTML inline** + `CSP: default-src 'self' data: blob:; worker-src 'self' blob:`(maplibre worker 需要)+ `X-Content-Type-Options: nosniff` + `Cache-Control: no-cache` + ETag(mtime)。
|
||||
- config.yaml + `deerflow/config/configuration.py` 加 `web_app:`(enabled **默认 false**、build_timeout、token_ttl、template_dir、node 覆盖)——与 `workflows` 同款总开关模式。
|
||||
- 测试 `tests/test_web_app_sites.py`:capability 降级路径、token 签发/过期/伪造、路径穿越拒绝、MIME、站点发现(构建子进程 mock)。
|
||||
|
||||
## P4 前端
|
||||
|
||||
- `src/core/sites/api.ts`:capability/sites/build/status/mintPreviewUrl + TanStack Query hooks(照 `light-apps.ts` 模式)。
|
||||
- `src/pages/site-studio/SiteStudioPage.tsx` + `site-studio.css` + `App.tsx` 注册顶层路由 `/sites/:threadId/:siteName`(独立风格见第一节;iframe `sandbox="allow-scripts allow-forms allow-popups"` 无 allow-same-origin,延续既有安全契约;token 过期自动重签;设备切换;重新构建 → 轮询 build-status → cache-bust 刷新)。
|
||||
- `artifact-file-detail.tsx` **唯一既有文件改动**:产物路径以 site.json 结尾且 capability 可用时渲染「打开站点预览」按钮(navigate 到新路由);capability 请求失败/超时一律不渲染,原 UI 不变。
|
||||
- token URL 构造等纯函数补单测;`pnpm typecheck` 通过。
|
||||
|
||||
## P5 测试与文档
|
||||
|
||||
- 后端 `make test` + ruff;前端 typecheck + 单测;`build_site.py` 本机真实跑通 scaffold→build→静态服务器自测 dist。
|
||||
- 文档:AGENTS.md 增「Web App 产物」小节(含降级矩阵);发版文档补 node_modules 预置说明。
|
||||
|
||||
## 安全边界
|
||||
|
||||
短时效签名 URL(默认 2h)只覆盖该站点 dist/ 静态读;dist/ 根限定 + 复用 `aresolve_thread_virtual_path` 遍历防护;HTML inline 仅在 site-preview 路径开放(不影响 artifacts 接口既有强制 attachment);CSP + nosniff + iframe sandbox 三重纵深。
|
||||
|
||||
## 二期(本期不做)
|
||||
|
||||
源码编辑 + 保存即重建;跨对话站点 gallery;PMTiles 底图资产打包;`visualizations/` 内联图表(进行中,互补不冲突)的底图配置统一(其 map-renderer.tsx 硬编码 Google 瓦片属离线隐患,另行处理)。
|
||||
|
||||
## 风险
|
||||
|
||||
- Windows 开发机 junction 链接免管理员,node 前端机必有;生产镜像已含 nodejs+npm。
|
||||
- Aio provider 自定义 mounts 扩展是小改动,按"存在才挂"实现即可,不影响现有挂载。
|
||||
- dist/ 出现在产物目录扫描(文件噪音)——一期保持现状,二期优化。
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
@@ -0,0 +1,31 @@
|
||||
# 日常使用复查(2026-09-14)
|
||||
|
||||
范围:ji 分支上继续检查知识库管理、助手知识库、我的项目、项目问答和官方入口。沿用本地隔离后端数据目录 `.runtime/ji-acceptance`,浏览器检查 464×642 与 1440×900 两种尺寸;未向生产库写入验收项目。
|
||||
|
||||
## 发现与修复
|
||||
|
||||
**首屏白屏的直接业务触发点也已修复:** `LlmWikiKnowledgeSelector` 在懒加载/未加载知识目录时,每次渲染创建新的空数组,推荐问题 effect 随之回写父页状态,形成无限更新。将目录空结果和默认助手库选择稳定化后,重新从登录进入新对话成功,欢迎引导、输入框及知识空间下拉均可操作(截图 06)。仅升级依赖并不能消除这个业务循环;上述修复与依赖兼容更新共同保留。
|
||||
|
||||
1. **首次进入:高影响,已修复依赖。** 本轮首次登录后出现整页空白,控制台为 ScrollArea `Maximum update depth exceeded`。安装版本 1.2.10 使用不稳定引用回调;升级并锁定 1.2.18,同步 package-lock,核对源码使用稳定 setState 引用。冷启动仍复现组合引用循环,进一步发现各基础组件带入 Slot 1.2.3/1.2.4;统一锁定 Slot 1.3.3,npm 与 pnpm 均配置覆盖间接依赖。依据 [Radix 发布记录](https://www.radix-ui.com/primitives/docs/overview/releases) 的 React 19 无限重渲染及 Slot 引用修复。
|
||||
2. **新建项目:已修复。** 手机表单出现两个菜单按钮(截图 01)。由外层提供菜单时不再显示内部按钮;官方入口也去掉无效的内部侧栏按钮。
|
||||
3. **项目历史:已修复。** 我的项目侧栏链接跳到旧项目空间(截图 02),导致返回目的地变化。现在保留我的项目路径,普通聊天与旧项目空间保持原路由。手机点历史后菜单自动收起。官方侧栏原本把项目聊天打开为普通聊天,现按项目归属进入项目工作区;`/official/workspace/projects/groups` 根路径回到普通问答项目列表。
|
||||
4. **项目设置与失败恢复:已修复。** 默认智能体更新后,同步新对话选择器;项目对话权限/归属加载失败、普通问答项目列表失败均有重试入口。
|
||||
5. **助手知识检索:已修复。** 连续检索或切换库时,以递增请求序号隔离过期成功/失败/结束回调,防止旧结果覆盖新库;新检索清除旧结果;空输入禁止提交。无结果原本只有小标签(截图 03),现在说明可调整关键词并提供浏览目录按钮。输入框补充可访问名称。
|
||||
6. **窄屏输入栏:已修复。** 464px 截图中发送箭头被工具栏挤出右边缘(截图 07);工具区允许换行、发送区不收缩,截图 08 确认按钮完整可见。项目问答网络错误也增加“重新加载对话”,只重建读取状态,不自动重发消息。
|
||||
|
||||
## 复测记录
|
||||
|
||||
- 具名项目创建成功;新建项目对话,通过真实模型收到“项目问答回归通过”;刷新保留消息与项目归属。
|
||||
- 修复工具栏后,在窄屏实际点击发送并收到“窄屏发送通过”;首屏知识空间菜单完成加载、选择助手库后显示已选择 1 个,未再出现循环白屏。
|
||||
- 手机项目历史链接确认留在 `qa/projects/groups`;桌面官方 groups 根路径跳回正确列表,官方项目历史进入相同项目。
|
||||
- 官方和常规项目切换时偶发网络 `Failed to fetch`,刷新/重新挂载恢复历史消息;没有将其描述为已排除的网络问题,新增恢复按钮未进行独立网络故障注入测试。
|
||||
- 助手知识库列表、阅读、检索无结果、返回入口实际操作;空查询按钮禁用。检索竞态由代码检查修复,未使用浏览器网络故障注入。
|
||||
- 后端 22 个相关文件重新运行:**273 passed**,涵盖知识库管理/导入/检索、Wiki、编码与向量、项目归属隔离、策略持久化及调用边界。
|
||||
- 前端项目与策略测试:**33 passed**,新增项目历史保持入口、旧项目和普通聊天/创建向导回归。
|
||||
- 生产构建通过。全仓类型检查仍有 **59 个既有错误**,与上一轮错误列表一致;不宣称全仓类型检查通过。
|
||||
|
||||
## 证据与边界
|
||||
|
||||
本轮截图与日志存放在 `frontend-web/.runtime/daily-usage-audit/`:`01-create-before.png`、`02-history-before.png`、`03-knowledge-search.png`、`04-official-desktop.png`、`05-official-project-after.png`(网络失败状态)、后续 after 截图,以及 tests/build/typecheck 日志。后端日志为 `.runtime/ji-acceptance/daily-regression.txt`。
|
||||
|
||||
上一轮真实文件入库、向量命中、方案生成与保存的验证详见 `ji-knowledge-projects-acceptance.md`。本轮不重复付费生成完整方案;没有进行生产全量语料质量评估、负载测试、断网故障注入或完整无障碍合规审计。已检查可见导航、表单名称、窄屏显示和主要失败恢复,不能据此承诺所有模块没有缺陷。代码未提交、推送或部署。
|
||||
@@ -0,0 +1,37 @@
|
||||
# ji 分支知识库与我的项目验收
|
||||
|
||||
日期:2026-09-14。基线:`ji` / `f62a512a`,已从 origin fast-forward 更新。
|
||||
|
||||
## 修复范围
|
||||
|
||||
1. 简洁模式进入我的项目保留功能导航,覆盖列表、策略工作台和问答项目;移动端有菜单展开入口。
|
||||
2. 策略结果卡片的模型思考前置;检查意图、评判、方案、展开视图保持过程在正文前。
|
||||
3. 普通问答改为真实项目分组:先填写项目名称创建项目,再在项目中创建多条问答。服务端持久归属与权限校验沿用 analysis-projects;不自动迁移或标记历史普通聊天。官方入口支持对应 groups 子路由。
|
||||
4. 修复方案列表要求预先选中策略导致无法进入的问题;任务变化后已有方案仍可查看;请求序号避免旧请求失败覆盖成功结果。
|
||||
5. 修复知识库管理目录缓存导致上游删除后仍显示的问题;保留检索端缓存。
|
||||
6. 项目编辑保留知识库绑定,助手知识库补充普通用户空态。
|
||||
|
||||
## 自动化
|
||||
|
||||
- 后端 22 个相关测试文件:273 passed(知识库、Wiki、向量、编码器、项目归属、策略配置与持久化、边界等)。
|
||||
- 前端策略与项目测试:31 passed。新增用例覆盖未选择策略、已有方案变更任务、未完成评判及全部策略失败。
|
||||
- `tests/test_knowledge_live_embedding.py`:真实接口 1 passed;默认跳过,设置 `KNOWLEDGE_TEST_EMBEDDING_KEY` 后运行。默认服务 SiliconFlow、模型 BAAI/bge-m3;可用 `KNOWLEDGE_TEST_EMBEDDING_URL` / `KNOWLEDGE_TEST_EMBEDDING_MODEL` 更换。
|
||||
- `pnpm build`:通过。
|
||||
- `pnpm typecheck`:未完全通过,59 个错误位于此次未改动文件(nextra 缺失、Markdown 插件类型、旧 JSX 类型及旧问答页等);修改文件无类型错误。不能将生产构建通过等同于全仓类型检查通过。
|
||||
|
||||
## 真实接口与浏览器
|
||||
|
||||
测试使用 `.runtime/ji-acceptance` 独立数据目录,保留测试数据便于复查,未操作业务数据库已有项目。
|
||||
|
||||
- BAAI/bge-m3:中英文 3 条输入,均返回 1024 维有效向量。
|
||||
- 实际检索服务:相关正文排名、指定库范围、草稿排除、概览查询通过。
|
||||
- 两个项目 A/B:A 创建两条对话,B 列表为零;跨项目读取 A 的对话返回 404。
|
||||
- 浏览器:简洁模式完整菜单可见;新建具名普通问答项目;项目内真实发送并收到回答;第二条对话独立;回列表仍是一张项目卡,刷新后保留。
|
||||
- 方案整理:真实模型返回 1539 字符正文,含 agent_done / all_done,流正常结束。
|
||||
- 方案入口:完成评判但尚未勾选策略的测试项目出现在方案列表。浏览器进入后勾选策略、生成 2755 字符正文、保存成功;服务端校验 synthesisStatus=done 且 solutionSavedAt 非空,刷新后正文与导出按钮恢复。
|
||||
- 官方入口:`/official/workspace/projects/groups/new` 正常展示具名问答项目表单,官方侧栏保留。
|
||||
- 助手知识库:创建、重命名、列表、拒绝 .py 上传通过;上传测试文本任务 completed;生成 Wiki 页面,向量检索命中 1 条,详情 HTTP 200。
|
||||
|
||||
## 限制
|
||||
|
||||
真实模型测试使用最小合成读书活动/设备资料,未对生产全量知识语料进行质量评估。API 密钥仅用于测试进程,不提交配置。仓库原有未跟踪 docx 与 packages1.zip 保持不变。未提交、推送代码,也未执行离线发布。
|
||||
@@ -33,3 +33,17 @@ python -m pytest tests/test_analysis_projects.py tests/test_deep_research_report
|
||||
涵盖内存与 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`。不在源码或文档中保存接口密钥。
|
||||
Generated
+355
-350
@@ -28,10 +28,10 @@
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-scroll-area": "1.2.18",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-slot": "1.3.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
@@ -69,7 +69,9 @@
|
||||
"gsap": "^3.13.0",
|
||||
"hast": "^1.0.0",
|
||||
"katex": "^0.16.28",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"maplibre-gl": "^6.9.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"motion": "^12.26.2",
|
||||
"nanoid": "^5.1.6",
|
||||
@@ -1702,6 +1704,92 @@
|
||||
"@lezer/lr": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
|
||||
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
|
||||
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
|
||||
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/geojson-vt": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
|
||||
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "26.4.2",
|
||||
"resolved": "https://registry.npmmirror.com/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.2.tgz",
|
||||
"integrity": "sha512-6J0vZqMZvRAJKtdWJdDGHEh1YJ2ZHG08/GOur8gCArYhO8ZkM//OXqtI2AAEz4jc2G+Wq4MfcePg8qNK5TZ4Kg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-format": "dist/gl-style-format.mjs",
|
||||
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/mlt": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/@maplibre/mlt/-/mlt-1.2.1.tgz",
|
||||
"integrity": "sha512-5n5dgolE2EYxwCKgx8vlwURCB8A+kyfJJyThlYimjlGgOcOe2Bhw9VxxnnHC91OC9PgHg+nVdgVPTYPkIo62vg==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
|
||||
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
@@ -1787,23 +1875,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-avatar": {
|
||||
"version": "1.1.11",
|
||||
"license": "MIT",
|
||||
@@ -1893,23 +1964,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||
@@ -1971,23 +2025,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
@@ -2086,23 +2123,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||
@@ -2165,23 +2185,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu": {
|
||||
"version": "2.1.16",
|
||||
"license": "MIT",
|
||||
@@ -2245,23 +2248,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
||||
@@ -2322,23 +2308,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card": {
|
||||
"version": "1.1.15",
|
||||
"license": "MIT",
|
||||
@@ -2404,23 +2373,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-icons": {
|
||||
"version": "1.3.2",
|
||||
"license": "MIT",
|
||||
@@ -2520,23 +2472,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||
@@ -2604,23 +2539,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
||||
@@ -2666,23 +2584,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||
@@ -2816,36 +2717,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area": {
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz",
|
||||
"integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
"@radix-ui/number": "1.1.3",
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-context": "1.2.2",
|
||||
"@radix-ui/react-direction": "1.1.4",
|
||||
"@radix-ui/react-presence": "1.1.10",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.4",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -2862,10 +2748,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/number": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz",
|
||||
"integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
|
||||
"integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz",
|
||||
"integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
@@ -2876,12 +2790,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz",
|
||||
"integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz",
|
||||
"integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -2898,13 +2828,49 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz",
|
||||
"integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
"@radix-ui/react-slot": "1.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz",
|
||||
"integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
|
||||
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
@@ -2992,23 +2958,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator": {
|
||||
"version": "1.1.8",
|
||||
"license": "MIT",
|
||||
@@ -3031,10 +2980,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.4",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
|
||||
"integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
"@radix-ui/react-compose-refs": "1.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -3046,6 +2997,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch": {
|
||||
"version": "1.2.6",
|
||||
"license": "MIT",
|
||||
@@ -3109,23 +3075,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs": {
|
||||
"version": "1.1.13",
|
||||
"license": "MIT",
|
||||
@@ -3190,23 +3139,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle": {
|
||||
"version": "1.1.10",
|
||||
"license": "MIT",
|
||||
@@ -3293,23 +3225,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
@@ -3332,23 +3247,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.2.8",
|
||||
"license": "MIT",
|
||||
@@ -3417,23 +3315,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
@@ -3622,23 +3503,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||
@@ -5557,6 +5421,15 @@
|
||||
"version": "1.2.1",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.1.0.tgz",
|
||||
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||
@@ -6459,6 +6332,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/earcut/-/earcut-3.2.3.tgz",
|
||||
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.349",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
|
||||
@@ -6853,6 +6732,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmmirror.com/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -7351,6 +7236,12 @@
|
||||
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
@@ -7390,6 +7281,12 @@
|
||||
"katex": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/kdbush": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/kdbush/-/kdbush-4.1.0.tgz",
|
||||
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/khroma": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
|
||||
@@ -7432,6 +7329,12 @@
|
||||
"resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz",
|
||||
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmmirror.com/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
@@ -7766,6 +7669,39 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "6.9.0",
|
||||
"resolved": "https://registry.npmmirror.com/maplibre-gl/-/maplibre-gl-6.9.0.tgz",
|
||||
"integrity": "sha512-vFMwMK0Zs+NM/rOMSdtu8bO30DIexhBEVi5KC6f70/XtI+L/K2wC3LsDCAXFZ4s8ik5gAuDugfCNbpllpdJ9bA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.2.0",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"@maplibre/geojson-vt": "^6.1.1",
|
||||
"@maplibre/maplibre-gl-style-spec": "^26.4.2",
|
||||
"@maplibre/mlt": "^1.2.1",
|
||||
"@maplibre/vt-pbf": "^4.3.2",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"bidi-js": "^1.1.0",
|
||||
"earcut": "^3.2.3",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"kdbush": "^4.1.0",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^5.1.2",
|
||||
"potpack": "^2.1.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14.0",
|
||||
"npm": ">=8.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.1",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||
@@ -8713,6 +8649,15 @@
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
@@ -8771,6 +8716,12 @@
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/murmurhash-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mustache": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
|
||||
@@ -8943,6 +8894,18 @@
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/pbf/-/pbf-5.1.2.tgz",
|
||||
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
@@ -9035,6 +8998,12 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/potpack/-/potpack-2.1.0.tgz",
|
||||
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/prismjs": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||
@@ -9218,6 +9187,12 @@
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmmirror.com/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
@@ -9235,6 +9210,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
@@ -9618,6 +9599,24 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||
@@ -9971,6 +9970,12 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyqueue": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tiptap-markdown": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz",
|
||||
|
||||
@@ -11,6 +11,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"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Keep indirect Radix primitives on the React 19 ref-stability fix too.
|
||||
overrides:
|
||||
'@radix-ui/react-slot': 1.3.3
|
||||
@@ -40,6 +40,10 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
|
||||
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
|
||||
|
||||
// 地图瓦片地址模板:普通问答地图和生成的 HTML 网页地图共同使用。
|
||||
// 后续接入内网瓦片服务时,只需替换此项并保留 {z}/{x}/{y} 占位符。
|
||||
VITE_MAP_TILE_URL: "http://119.254.86.251:8023/tiles/{z}/{x}/{y}.png?v=zh-z14-13",
|
||||
|
||||
// routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。
|
||||
// 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。
|
||||
VITE_ROUTE_LOGIN_PASSWORD: "",
|
||||
|
||||
@@ -36,6 +36,7 @@ import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
|
||||
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
|
||||
import EmbedChatPage from "./pages/EmbedChatPage";
|
||||
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
||||
import SiteStudioPage from "./pages/site-studio/SiteStudioPage";
|
||||
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
|
||||
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
||||
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
@@ -108,10 +113,12 @@ 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();
|
||||
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
|
||||
@@ -353,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;
|
||||
@@ -398,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");
|
||||
};
|
||||
@@ -461,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -499,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;
|
||||
@@ -506,7 +550,8 @@ const ChatBox: React.FC<{
|
||||
if (
|
||||
wasEnabled ||
|
||||
!artifactDirectoryModeEnabled ||
|
||||
availableArtifacts.length === 0
|
||||
availableArtifacts.length === 0 ||
|
||||
isWordDocumentFile(availableArtifacts[availableArtifacts.length - 1]!)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -746,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>
|
||||
);
|
||||
};
|
||||
@@ -758,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>
|
||||
);
|
||||
}
|
||||
@@ -100,25 +100,10 @@ import {
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
|
||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||
import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
|
||||
function getResolvedMode(
|
||||
mode: InputMode | undefined,
|
||||
supportsThinking: boolean,
|
||||
): InputMode {
|
||||
if (!supportsThinking && mode !== "flash") {
|
||||
return "flash";
|
||||
}
|
||||
if (mode) {
|
||||
return mode;
|
||||
}
|
||||
return supportsThinking ? "pro" : "flash";
|
||||
}
|
||||
import { resolveInputMode, type InputMode } from "./input-mode";
|
||||
import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
export function InputBox({
|
||||
className,
|
||||
@@ -136,7 +121,8 @@ export function InputBox({
|
||||
onStop,
|
||||
promptPrefix,
|
||||
agentSelector,
|
||||
knowledgeSelector,
|
||||
knowledgeSelector,
|
||||
knowledgeSuggestedQuestions = [],
|
||||
extraModels = [],
|
||||
assistantId,
|
||||
hideAdminRecommendedQuestions,
|
||||
@@ -200,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[];
|
||||
}) {
|
||||
@@ -238,8 +226,9 @@ export function InputBox({
|
||||
|
||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||
const userPreferences = useUserPreferences();
|
||||
const showAdminRecommendedQuestions =
|
||||
!hideAdminRecommendedQuestions &&
|
||||
const showAdminRecommendedQuestions =
|
||||
!hideAdminRecommendedQuestions &&
|
||||
knowledgeSuggestedQuestions.length === 0 &&
|
||||
!userPreferences.hide_admin_prompt_prefix &&
|
||||
recommendedQuestions.length > 0;
|
||||
|
||||
@@ -256,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 &&
|
||||
@@ -303,7 +292,7 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name,
|
||||
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
||||
mode: resolveInputMode(context.mode, model.supports_thinking ?? false),
|
||||
reasoning_effort: context.reasoning_effort,
|
||||
execution_profile: model.execution_profile,
|
||||
});
|
||||
@@ -319,7 +308,7 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: ordinaryModel?.name ?? context.model_name,
|
||||
mode: getResolvedMode(
|
||||
mode: resolveInputMode(
|
||||
mode,
|
||||
ordinaryModel?.supports_thinking ?? supportThinking,
|
||||
),
|
||||
@@ -348,7 +337,7 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: ragModel.name,
|
||||
mode: getResolvedMode(
|
||||
mode: resolveInputMode(
|
||||
context.mode,
|
||||
ragModel.supports_thinking ?? false,
|
||||
),
|
||||
@@ -427,7 +416,7 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: resolvedModelName,
|
||||
mode: getResolvedMode(
|
||||
mode: resolveInputMode(
|
||||
context.mode,
|
||||
selectedModel?.supports_thinking ?? false,
|
||||
),
|
||||
@@ -599,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
|
||||
@@ -716,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>
|
||||
@@ -1235,7 +1246,7 @@ export function InputBox({
|
||||
</PromptInputActionMenu>
|
||||
)}
|
||||
</PromptInputTools>
|
||||
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
||||
<PromptInputTools className={cn("ml-auto shrink-0 gap-1.5", compact && "gap-2")}>
|
||||
{!frontendRagActive && agentSelector && (
|
||||
<AgentSelector
|
||||
selectedAgentId={agentSelector.selectedAgentId}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// Node's type-stripping test runner requires the explicit TypeScript suffix.
|
||||
// @ts-expect-error The application compiler intentionally uses extensionless imports.
|
||||
import { resolveInputMode } from "./input-mode.ts";
|
||||
|
||||
test("keeps plan and subagent modes for models without native thinking", () => {
|
||||
assert.equal(resolveInputMode("pro", false), "pro");
|
||||
assert.equal(resolveInputMode("ultra", false), "ultra");
|
||||
});
|
||||
|
||||
test("falls back only when native thinking mode is unsupported", () => {
|
||||
assert.equal(resolveInputMode("thinking", false), "flash");
|
||||
assert.equal(resolveInputMode("thinking", true), "thinking");
|
||||
});
|
||||
|
||||
test("keeps the existing defaults when no mode has been chosen", () => {
|
||||
assert.equal(resolveInputMode(undefined, false), "flash");
|
||||
assert.equal(resolveInputMode(undefined, true), "pro");
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
/**
|
||||
* Keep plan/subagent modes available even when the selected model has no
|
||||
* native thinking switch. The backend already falls back to non-thinking for
|
||||
* that model, while `pro` and `ultra` still control planning and subagents.
|
||||
* Only the model-specific `thinking` mode requires `supports_thinking`.
|
||||
*/
|
||||
export function resolveInputMode(
|
||||
mode: InputMode | undefined,
|
||||
supportsThinking: boolean,
|
||||
): InputMode {
|
||||
if (mode === "thinking" && !supportsThinking) {
|
||||
return "flash";
|
||||
}
|
||||
if (mode) {
|
||||
return mode;
|
||||
}
|
||||
return supportsThinking ? "pro" : "flash";
|
||||
}
|
||||
@@ -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,
|
||||
@@ -381,6 +408,7 @@ export function LlmWikiKnowledgeSelector({
|
||||
assistantValue?: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
onAssistantChange?: (value: string[]) => void;
|
||||
onSuggestedQuestionsChange?: (questions: string[]) => void;
|
||||
source?: SelectorSource;
|
||||
agentId?: string | null;
|
||||
autoSelectAll?: boolean;
|
||||
@@ -397,17 +425,30 @@ export function LlmWikiKnowledgeSelector({
|
||||
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);
|
||||
@@ -423,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) {
|
||||
@@ -452,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
|
||||
@@ -491,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
|
||||
|
||||
@@ -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) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"'": "'",
|
||||
'"': """,
|
||||
})[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 }];
|
||||
}
|
||||
@@ -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,7 @@ 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");
|
||||
@@ -85,7 +89,7 @@ export function WorkspaceSidebar({
|
||||
{isGeneralQa ? (
|
||||
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
||||
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
||||
<GeneralQaSidebar />
|
||||
<GeneralQaSidebar wxCommonAgents={wxCommonAgents} />
|
||||
) : (
|
||||
<>
|
||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -560,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;
|
||||
@@ -576,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;
|
||||
|
||||
@@ -30,7 +30,7 @@ test("matches only workspace chats_iframe routes", () => {
|
||||
assert.equal(isChatsIframeWorkspacePath("/landing"), false);
|
||||
});
|
||||
|
||||
test("forces light theme for landing and chats_iframe routes only", () => {
|
||||
test("forces the dedicated presentation themes for landing, iframe, and wx chat routes", () => {
|
||||
assert.equal(resolveForcedThemeForPath("/landing"), "light");
|
||||
assert.equal(
|
||||
resolveForcedThemeForPath("/page/workspace/chats_iframe/new"),
|
||||
@@ -41,6 +41,14 @@ test("forces light theme for landing and chats_iframe routes only", () => {
|
||||
"light",
|
||||
);
|
||||
assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined);
|
||||
assert.equal(
|
||||
resolveForcedThemeForPath("/page/workspace/wx/chats/new"),
|
||||
"dark",
|
||||
);
|
||||
assert.equal(
|
||||
resolveForcedThemeForPath("/page/workspace/wx/agents/agent-1/chats/new"),
|
||||
"dark",
|
||||
);
|
||||
});
|
||||
|
||||
test("recognizes an explicit chatMode=iframe independently of the legacy marker", () => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { resolveEmbedThemeMode } from "../embed/theme-mode.mjs";
|
||||
const CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new";
|
||||
const IFRAME_LOGIN_SOURCE = "iframe";
|
||||
const STANDARD_CHATS_PATH = "/page/workspace/chats";
|
||||
/** Dedicated full chat surface whose presentation is always deep blue. */
|
||||
export const WX_FULL_CHAT_PATH_PREFIX = "/page/workspace/wx/";
|
||||
|
||||
function toSearchParams(search: string): URLSearchParams {
|
||||
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||
@@ -328,6 +330,10 @@ export function resolveForcedThemeForPath(
|
||||
return "light";
|
||||
}
|
||||
|
||||
if (pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX)) {
|
||||
return "dark";
|
||||
}
|
||||
|
||||
const isLoginPath = pathname === "/login" || pathname.startsWith("/login/");
|
||||
if (
|
||||
isChatsIframeWorkspacePath(pathname) ||
|
||||
|
||||
@@ -0,0 +1,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),
|
||||
});
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
loadPublishSquares,
|
||||
loadSkillCompressionSettings,
|
||||
loadStrategyProjectSettings,
|
||||
loadWxCommonAgents,
|
||||
updateAgentModuleConfig,
|
||||
updateAppearanceSettings,
|
||||
updateCitationDisplaySettings,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
updatePublishSquares,
|
||||
updateSkillCompressionSettings,
|
||||
updateStrategyProjectSettings,
|
||||
updateWxCommonAgents,
|
||||
type AppearanceUpdate,
|
||||
type CitationDisplayUpdate,
|
||||
type LeaderboardSettingsUpdate,
|
||||
@@ -137,13 +139,14 @@ export function useUpdateMemoryRuntimeSettings() {
|
||||
|
||||
/** 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -231,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),
|
||||
@@ -240,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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -163,7 +163,12 @@ 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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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 {"&":"&","<":"<",">":">",'\"':""","'":"'"}[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" : "";
|
||||
}
|
||||
@@ -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";
|
||||
/** 不收集素材,直接用通用知识写作:勾选后跳过整个素材检索流程(含素材确认暂停点),
|
||||
|
||||
@@ -373,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,
|
||||
);
|
||||
@@ -1029,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`,
|
||||
);
|
||||
}}
|
||||
@@ -1079,6 +1080,7 @@ export default function AgentChatPage({
|
||||
className="w-full"
|
||||
isNewThread
|
||||
hideAdminRecommendedQuestions
|
||||
knowledgeSuggestedQuestions={knowledgeSuggestedQuestions}
|
||||
threadId={threadId}
|
||||
assistantId={agent_id}
|
||||
autoFocus
|
||||
@@ -1099,6 +1101,7 @@ export default function AgentChatPage({
|
||||
assistantValue={selectedAssistantKnowledgeBaseIds}
|
||||
onChange={changeKnowledgeSelection}
|
||||
onAssistantChange={changeAssistantKnowledgeSelection}
|
||||
onSuggestedQuestionsChange={setKnowledgeSuggestedQuestions}
|
||||
autoSelectAll={
|
||||
isNewThread &&
|
||||
pendingKnowledgeSelection.current === null &&
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
Link,
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
@@ -49,7 +50,12 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProjectConversation } from "./analysis-projects/ProjectConversation";
|
||||
|
||||
const BASE = "/page/workspace/projects";
|
||||
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",
|
||||
@@ -59,6 +65,11 @@ const defaultReport: ProjectReportConfig = {
|
||||
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
|
||||
@@ -300,11 +311,12 @@ function ProjectForm({
|
||||
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 ?? "qa");
|
||||
const [mode, setMode] = useState<ProjectMode>(project?.mode ?? initialMode ?? "qa");
|
||||
const [agentId, setAgentId] = useState<string | null>(
|
||||
project?.agent_id ?? null,
|
||||
);
|
||||
@@ -332,8 +344,8 @@ function ProjectForm({
|
||||
? report
|
||||
: { ...defaultReport, custom_outline: "" },
|
||||
strategy_config: strategy,
|
||||
knowledge_base_ids: [],
|
||||
assistant_knowledge_base_ids: [],
|
||||
knowledge_base_ids: project?.knowledge_base_ids ?? [],
|
||||
assistant_knowledge_base_ids: project?.assistant_knowledge_base_ids ?? [],
|
||||
};
|
||||
return project
|
||||
? updateAnalysisProject(project.id, fields)
|
||||
@@ -355,7 +367,7 @@ function ProjectForm({
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<SidebarTrigger />
|
||||
<ProjectSidebarTrigger />
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>
|
||||
<ArrowLeft className="size-4" />
|
||||
返回
|
||||
@@ -376,7 +388,7 @@ function ProjectForm({
|
||||
</label>
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="text-sm font-medium">项目问答模式</legend>
|
||||
{project ? (
|
||||
{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">
|
||||
@@ -466,13 +478,14 @@ function ProjectForm({
|
||||
}
|
||||
|
||||
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">
|
||||
<SidebarTrigger />
|
||||
<ProjectSidebarTrigger />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">项目空间</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
@@ -525,6 +538,7 @@ function ProjectList() {
|
||||
}
|
||||
|
||||
function WorkspaceContent({ project }: { project: AnalysisProject }) {
|
||||
const { basePath: BASE, listPath } = useContext(ProjectNavigation);
|
||||
const location = useLocation(),
|
||||
navigate = useNavigate(),
|
||||
queryClient = useQueryClient();
|
||||
@@ -533,6 +547,7 @@ function WorkspaceContent({ project }: { project: AnalysisProject }) {
|
||||
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
|
||||
@@ -546,8 +561,8 @@ function WorkspaceContent({ project }: { project: AnalysisProject }) {
|
||||
agent_id: project.agent_id,
|
||||
report_config: project.report_config,
|
||||
strategy_config: { lenses },
|
||||
knowledge_base_ids: [],
|
||||
assistant_knowledge_base_ids: [],
|
||||
knowledge_base_ids: project?.knowledge_base_ids ?? [],
|
||||
assistant_knowledge_base_ids: project?.assistant_knowledge_base_ids ?? [],
|
||||
}),
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData([...projectKeys(), project.id], updated);
|
||||
@@ -595,9 +610,9 @@ function WorkspaceContent({ project }: { project: AnalysisProject }) {
|
||||
<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">
|
||||
<SidebarTrigger />
|
||||
<Link to={BASE} className="text-sm text-muted-foreground">
|
||||
项目空间
|
||||
<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">
|
||||
@@ -725,7 +740,7 @@ function WorkspaceContent({ project }: { project: AnalysisProject }) {
|
||||
access.isLoading ? (
|
||||
<Loading />
|
||||
) : access.error ? (
|
||||
<ErrorState error={access.error} />
|
||||
<ErrorState error={access.error} retry={() => void access.refetch()} />
|
||||
) : (
|
||||
<ProjectConversation
|
||||
key={threadId}
|
||||
@@ -745,7 +760,7 @@ function WorkspaceContent({ project }: { project: AnalysisProject }) {
|
||||
<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">
|
||||
@@ -789,20 +804,28 @@ function ProjectWorkspace() {
|
||||
);
|
||||
}
|
||||
function NewProject() {
|
||||
const { listPath } = useContext(ProjectNavigation);
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<ProjectForm onCancel={() => navigate(BASE)} />
|
||||
<ProjectForm onCancel={() => navigate(listPath)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default function AnalysisProjectsPage() {
|
||||
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 (
|
||||
<Routes key={userId ?? "anonymous"}>
|
||||
<Route index element={<ProjectList />} />
|
||||
<Route path="new" element={<NewProject />} />
|
||||
<Route path=":projectId/*" element={<ProjectWorkspace />} />
|
||||
</Routes>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
@@ -146,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);
|
||||
@@ -367,6 +369,33 @@ export default function ChatPage() {
|
||||
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
|
||||
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
|
||||
const frontendRagSelected = isFrontendRagProfile(settings.context);
|
||||
// 排查高并发时普通对话输入框意外禁用:流式运行本身不应成为禁用原因。
|
||||
const inputDisabledReasons = useMemo(() => {
|
||||
const reasons: string[] = [];
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") reasons.push("静态站点模式");
|
||||
if (isUploading) reasons.push("正在上传附件");
|
||||
if (agentHandoffPending) reasons.push("正在创建智能体会话");
|
||||
if (knowledgeSelectionLoading) reasons.push("正在恢复会话知识库配置");
|
||||
if (singleRoundRag.isLoading) reasons.push("单轮 RAG 执行中");
|
||||
if (multiRoundRag.isLoading) reasons.push("多轮 RAG 执行中");
|
||||
return reasons;
|
||||
}, [
|
||||
agentHandoffPending,
|
||||
isUploading,
|
||||
knowledgeSelectionLoading,
|
||||
multiRoundRag.isLoading,
|
||||
singleRoundRag.isLoading,
|
||||
]);
|
||||
const inputDisabled = inputDisabledReasons.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
console.info("[普通对话输入框状态]", {
|
||||
禁用: inputDisabled,
|
||||
原因: inputDisabledReasons,
|
||||
threadId: threadId ?? null,
|
||||
新建对话: isNewThread,
|
||||
});
|
||||
}, [inputDisabled, inputDisabledReasons, isNewThread, threadId]);
|
||||
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
|
||||
// 流式 stub),成功且真实产物落地后自动撤出。
|
||||
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
|
||||
@@ -711,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;
|
||||
@@ -922,14 +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
|
||||
}
|
||||
disabled={inputDisabled}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
@@ -950,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" ||
|
||||
@@ -959,6 +984,7 @@ export default function ChatPage() {
|
||||
label="知识空间"
|
||||
/>
|
||||
}
|
||||
knowledgeSuggestedQuestions={knowledgeSuggestedQuestions}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -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";
|
||||
@@ -99,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} />;
|
||||
@@ -119,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"
|
||||
|
||||
@@ -31,7 +31,7 @@ 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";
|
||||
@@ -171,6 +171,7 @@ export function WorkspaceLayout({
|
||||
insetOverflowHidden = false,
|
||||
pageSidebarDark = false,
|
||||
forceWorkspaceSidebarCollapsed = false,
|
||||
wxCommonAgents = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
pageSidebarCollapsed: boolean;
|
||||
@@ -186,12 +187,15 @@ 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") ||
|
||||
pathname.startsWith("/page/workspace/wx/") ||
|
||||
pathname.startsWith("/page/workspace/projects");
|
||||
const showCompactTopActions =
|
||||
headerHidden &&
|
||||
@@ -244,6 +248,7 @@ export function WorkspaceLayout({
|
||||
<WorkspaceSidebar
|
||||
compactChrome={compactWorkspaceSidebar}
|
||||
forceCollapsed={forceWorkspaceSidebarCollapsed}
|
||||
wxCommonAgents={wxCommonAgents}
|
||||
/>
|
||||
)}
|
||||
<SidebarInset
|
||||
@@ -413,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);
|
||||
|
||||
@@ -459,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
|
||||
@@ -549,6 +672,50 @@ 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={
|
||||
|
||||
@@ -35,10 +35,12 @@ function Conversation({
|
||||
project,
|
||||
threadId,
|
||||
agentId,
|
||||
onReload,
|
||||
}: {
|
||||
project: AnalysisProject;
|
||||
threadId: string;
|
||||
agentId: string | null;
|
||||
onReload: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
@@ -181,11 +183,14 @@ function Conversation({
|
||||
</div>
|
||||
)}
|
||||
{!!thread.error && (
|
||||
<p role="alert" className="mb-2 text-sm text-destructive">
|
||||
<div role="alert" className="mb-2 text-sm text-destructive">
|
||||
{thread.error instanceof Error
|
||||
? thread.error.message
|
||||
: "本轮问答失败,请重试"}
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" className="ml-3" onClick={onReload}>
|
||||
重新加载对话
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<InputBox
|
||||
threadId={threadId}
|
||||
@@ -228,12 +233,13 @@ export function ProjectConversation(props: {
|
||||
agentId: string | null;
|
||||
}) {
|
||||
// Remount all conversation-scoped providers together when switching threads.
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
return (
|
||||
<SubtasksProvider>
|
||||
<SubtasksProvider key={reloadKey}>
|
||||
<DocumentRewritePresentationProvider>
|
||||
<ArtifactsProvider>
|
||||
<PromptInputProvider>
|
||||
<Conversation {...props} />
|
||||
<Conversation {...props} onReload={() => setReloadKey((key) => key + 1)} />
|
||||
</PromptInputProvider>
|
||||
</ArtifactsProvider>
|
||||
</DocumentRewritePresentationProvider>
|
||||
|
||||
@@ -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,
|
||||
@@ -237,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);
|
||||
@@ -330,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
|
||||
@@ -550,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>
|
||||
</>
|
||||
) : (
|
||||
@@ -1230,22 +1252,37 @@ export function WorkflowConfigButton({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="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}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
@@ -27,7 +33,8 @@ export interface AssistantWikiPage {
|
||||
slug: string;
|
||||
title: string;
|
||||
page_type: string;
|
||||
content_markdown: string;
|
||||
/** Omitted by the lightweight directory endpoint; present on page detail. */
|
||||
content_markdown?: string;
|
||||
summary: string;
|
||||
current_revision_id?: string | null;
|
||||
updated_at: string;
|
||||
@@ -67,7 +74,7 @@ export interface AssistantWikiRevision {
|
||||
export interface AssistantKnowledgeSource {
|
||||
id: string;
|
||||
base_id: string;
|
||||
source_type: "weknora" | "skill" | "local_file";
|
||||
source_type: "weknora" | "skill" | "file";
|
||||
source_name: string;
|
||||
source_key: string;
|
||||
status: string;
|
||||
@@ -221,26 +228,71 @@ function withBaseParam(baseId?: string | null) {
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function listAssistantWikiPages(q = "", baseId?: string | null) {
|
||||
const params = withBaseParam(baseId);
|
||||
params.set("limit", "500");
|
||||
if (q.trim()) params.set("q", q.trim());
|
||||
export async function listAssistantWikiPages(
|
||||
q = "",
|
||||
baseId?: string | null,
|
||||
includeContent = true,
|
||||
) {
|
||||
const pages: AssistantWikiPage[] = [];
|
||||
let offset: number | null = 0;
|
||||
do {
|
||||
params.set("offset", String(offset));
|
||||
const batch: { pages: AssistantWikiPage[]; next_offset?: number | null } =
|
||||
await readJson(await apiFetch(`${BASE()}/wiki-pages?${params}`));
|
||||
const batch = await listAssistantWikiPageBatch(
|
||||
q,
|
||||
baseId,
|
||||
includeContent,
|
||||
offset,
|
||||
);
|
||||
pages.push(...batch.pages);
|
||||
offset = batch.next_offset ?? null;
|
||||
} while (offset !== null);
|
||||
return pages;
|
||||
}
|
||||
|
||||
export type AssistantKnowledgeRetrievalMode =
|
||||
| "empty"
|
||||
| "keyword"
|
||||
| "overview"
|
||||
| "vector";
|
||||
|
||||
type AssistantWikiPageBatch = {
|
||||
pages: AssistantWikiPage[];
|
||||
next_offset?: number | null;
|
||||
};
|
||||
|
||||
async function listAssistantWikiPageBatch(
|
||||
q = "",
|
||||
baseId?: string | null,
|
||||
includeContent = true,
|
||||
offset = 0,
|
||||
): Promise<AssistantWikiPageBatch> {
|
||||
const params = withBaseParam(baseId);
|
||||
params.set("limit", "500");
|
||||
params.set("include_content", String(includeContent));
|
||||
params.set("offset", String(offset));
|
||||
if (q.trim()) params.set("q", q.trim());
|
||||
return readJson<AssistantWikiPageBatch>(
|
||||
await apiFetch(`${BASE()}/wiki-pages?${params}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAssistantWikiPage(
|
||||
slug: string,
|
||||
baseId: string,
|
||||
) {
|
||||
const params = withBaseParam(baseId);
|
||||
const safeSlug = slug.split("/").map(encodeURIComponent).join("/");
|
||||
return readJson<AssistantWikiPage>(
|
||||
await apiFetch(`${BASE()}/wiki-pages/${safeSlug}?${params}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function searchAssistantWiki(q: string, baseId: string) {
|
||||
return readJson<{
|
||||
results: (AssistantWikiPage & { score: number; retrieval_mode: string })[];
|
||||
retrieval_mode: string;
|
||||
results: (AssistantWikiPage & {
|
||||
score: number;
|
||||
retrieval_mode: Exclude<AssistantKnowledgeRetrievalMode, "empty">;
|
||||
})[];
|
||||
retrieval_mode: AssistantKnowledgeRetrievalMode;
|
||||
}>(
|
||||
await apiFetch(
|
||||
`${BASE()}/search?${new URLSearchParams({ q, base_id: baseId })}`,
|
||||
@@ -447,13 +499,60 @@ export function useAssistantKnowledgeBases(enabled = true) {
|
||||
queryKey: [KEY, "bases"],
|
||||
queryFn: listAssistantKnowledgeBases,
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssistantWikiPages(query = "", baseId?: string | null) {
|
||||
export function useAssistantWikiPages(
|
||||
query = "",
|
||||
baseId?: string | null,
|
||||
options: { includeContent?: boolean; enabled?: boolean } = {},
|
||||
) {
|
||||
const includeContent = options.includeContent ?? true;
|
||||
const enabled = (options.enabled ?? true) && !!baseId;
|
||||
const result = useInfiniteQuery({
|
||||
queryKey: [KEY, "pages", baseId ?? "global", query, includeContent],
|
||||
queryFn: ({ pageParam }) =>
|
||||
listAssistantWikiPageBatch(
|
||||
query,
|
||||
baseId,
|
||||
includeContent,
|
||||
pageParam,
|
||||
),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => lastPage.next_offset ?? undefined,
|
||||
enabled,
|
||||
});
|
||||
|
||||
// Resolve the first 500 lightweight directory entries immediately so the
|
||||
// active article can load, then fill the rest of a very large directory in
|
||||
// the background instead of blocking the whole reader on serial pagination.
|
||||
useEffect(() => {
|
||||
if (enabled && result.hasNextPage && !result.isFetchingNextPage) {
|
||||
void result.fetchNextPage();
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
result.fetchNextPage,
|
||||
result.hasNextPage,
|
||||
result.isFetchingNextPage,
|
||||
]);
|
||||
|
||||
return {
|
||||
...result,
|
||||
data: result.data?.pages.flatMap((batch) => batch.pages),
|
||||
};
|
||||
}
|
||||
|
||||
export function useAssistantWikiPage(
|
||||
slug?: string | null,
|
||||
baseId?: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "pages", baseId ?? "global", query],
|
||||
queryFn: () => listAssistantWikiPages(query, baseId),
|
||||
queryKey: [KEY, "page", baseId, slug],
|
||||
queryFn: () => getAssistantWikiPage(slug!, baseId!),
|
||||
enabled: enabled && !!baseId && !!slug,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -465,10 +564,14 @@ export function useAssistantKnowledgeSources(baseId?: string | null, enabled = t
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssistantImportJobs(baseId?: string | null) {
|
||||
export function useAssistantImportJobs(
|
||||
baseId?: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "jobs", baseId ?? "global"],
|
||||
queryFn: () => listAssistantImportJobs(baseId),
|
||||
enabled: enabled && !!baseId,
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data ?? []).some((job) =>
|
||||
["queued", "running"].includes(job.status),
|
||||
@@ -492,7 +595,20 @@ export function useAssistantKnowledgeActions() {
|
||||
}),
|
||||
updateBase: useMutation({
|
||||
mutationFn: updateAssistantKnowledgeBase,
|
||||
onSuccess: invalidate,
|
||||
onSuccess: (updated) => {
|
||||
client.setQueryData<AssistantKnowledgeBase[]>(
|
||||
[KEY, "bases"],
|
||||
(current) =>
|
||||
current?.map((base) =>
|
||||
base.id === updated.id ? { ...base, ...updated } : base,
|
||||
),
|
||||
);
|
||||
client.setQueriesData<AssistantKnowledgeBase>(
|
||||
{ queryKey: [KEY, "base"] },
|
||||
(current) =>
|
||||
current?.id === updated.id ? { ...current, ...updated } : current,
|
||||
);
|
||||
},
|
||||
}),
|
||||
deleteBase: useMutation({
|
||||
mutationFn: deleteAssistantKnowledgeBase,
|
||||
|
||||
@@ -335,6 +335,10 @@ export async function updateSession(
|
||||
title?: string;
|
||||
status?: "collecting" | "awaiting_report";
|
||||
collectorMaterials?: Record<string, unknown>[];
|
||||
planOutline?: boolean;
|
||||
modelName?: string | null;
|
||||
force?: boolean;
|
||||
customOutline?: string;
|
||||
},
|
||||
): Promise<DeepResearchSession> {
|
||||
const res = await apiFetch(`${BASE()}/sessions/${id}`, {
|
||||
@@ -349,11 +353,42 @@ export async function updateSession(
|
||||
collectorMaterials: patch.collectorMaterials,
|
||||
}
|
||||
: {}),
|
||||
...(patch.planOutline ? { planOutline: true } : {}),
|
||||
...(patch.modelName ? { modelName: patch.modelName } : {}),
|
||||
...(patch.force ? { force: true } : {}),
|
||||
...(patch.customOutline !== undefined ? { customOutline: patch.customOutline } : {}),
|
||||
}),
|
||||
});
|
||||
return normalizeSession(await readJson<WireRecord>(res, "updateSession"));
|
||||
}
|
||||
|
||||
export async function planIntentOutline(
|
||||
sessionId: string,
|
||||
opts?: {
|
||||
modelName?: string | null;
|
||||
collectorMaterials?: Record<string, unknown>[];
|
||||
force?: boolean;
|
||||
},
|
||||
): Promise<{ outline: string; source: string; reused: boolean }> {
|
||||
// Use the existing session PATCH. A dedicated POST /intent-outline is easy
|
||||
// to miss when uvicorn --reload watches the file but does not recycle the worker.
|
||||
const session = await updateSession(sessionId, {
|
||||
planOutline: true,
|
||||
modelName: opts?.modelName,
|
||||
collectorMaterials: opts?.collectorMaterials,
|
||||
force: opts?.force,
|
||||
});
|
||||
const outline = String(session.config?.custom_outline ?? "").trim();
|
||||
if (!outline) {
|
||||
throw new Error("未能根据课题生成报告结构,请手动填写或直接开始撰写。");
|
||||
}
|
||||
return {
|
||||
outline,
|
||||
source: "intent",
|
||||
reused: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteSession(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${BASE()}/sessions/${id}`, { method: "DELETE" });
|
||||
if (!res.ok && res.status !== 204) {
|
||||
@@ -1173,11 +1208,30 @@ export function useResearchActions() {
|
||||
sessionId,
|
||||
status,
|
||||
collectorMaterials,
|
||||
customOutline,
|
||||
}: {
|
||||
sessionId: string;
|
||||
status: "collecting" | "awaiting_report";
|
||||
collectorMaterials?: Record<string, unknown>[];
|
||||
}) => updateSession(sessionId, { status, collectorMaterials }),
|
||||
customOutline?: string;
|
||||
}) => updateSession(sessionId, { status, collectorMaterials, customOutline }),
|
||||
onSuccess: (_result, { sessionId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] });
|
||||
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSION_KEY, sessionId] });
|
||||
},
|
||||
}),
|
||||
planIntentOutline: useMutation({
|
||||
mutationFn: ({
|
||||
sessionId,
|
||||
modelName,
|
||||
collectorMaterials,
|
||||
force,
|
||||
}: {
|
||||
sessionId: string;
|
||||
modelName?: string | null;
|
||||
collectorMaterials?: Record<string, unknown>[];
|
||||
force?: boolean;
|
||||
}) => planIntentOutline(sessionId, { modelName, collectorMaterials, force }),
|
||||
onSuccess: (_result, { sessionId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] });
|
||||
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSION_KEY, sessionId] });
|
||||
|
||||
@@ -1068,7 +1068,13 @@ export function useLlmWikiKnowledgeBases(scope: LlmWikiScope, enabled = true) {
|
||||
queryKey: knowledgeBasesKey(userId, scope),
|
||||
queryFn: () => listLlmWikiKnowledgeBases(scope),
|
||||
enabled,
|
||||
refetchInterval: 15_000,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data ?? []).some(
|
||||
(item) => item.is_processing || item.processing_count > 0,
|
||||
)
|
||||
? 15_000
|
||||
: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1339,7 +1345,23 @@ export function useLlmWikiActions() {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}) => updateLlmWikiKnowledgeBase(id, input),
|
||||
onSuccess: invalidateKnowledge,
|
||||
// Renaming a base only changes the base record. Writing the mutation
|
||||
// result through avoids making the edit dialog wait for the unrelated
|
||||
// documents, Wiki pages, graph, and settings requests under `llmwiki`.
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueriesData<LlmWikiKnowledgeBase[]>(
|
||||
{ queryKey: ["llmwiki", "knowledge-bases"] },
|
||||
(current) =>
|
||||
current?.map((base) =>
|
||||
base.id === updated.id ? { ...base, ...updated } : base,
|
||||
),
|
||||
);
|
||||
queryClient.setQueriesData<LlmWikiKnowledgeBase>(
|
||||
{ queryKey: ["llmwiki", "knowledge-base", updated.id] },
|
||||
(current) =>
|
||||
current?.id === updated.id ? { ...current, ...updated } : current,
|
||||
);
|
||||
},
|
||||
}),
|
||||
remove: useMutation({
|
||||
mutationFn: deleteLlmWikiKnowledgeBase,
|
||||
|
||||
+9
-4
@@ -319,11 +319,14 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
details.data.can_write && !details.data.is_conversation_deposit;
|
||||
const canEditCurrentWikiArticle =
|
||||
canWrite && !!detailsWikiEnabled && !!wikiSlug;
|
||||
const canManageWiki = canWrite && !!detailsWikiEnabled;
|
||||
// A new/empty WeKnora base does not always expose wiki_enabled until its
|
||||
// first document has been processed. Manual Wiki management is still a
|
||||
// valid writable-base operation and must remain reachable in that state.
|
||||
const canManageWiki = canWrite;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
{detailsWikiEnabled && (
|
||||
{(detailsWikiEnabled || canManageWiki) && (
|
||||
<div className="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{canEditCurrentWikiArticle && (
|
||||
@@ -344,7 +347,9 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki/view`)}>查看 Wiki 索引</Button>
|
||||
{detailsWikiEnabled && (
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki/view`)}>查看 Wiki 索引</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -356,7 +361,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
}
|
||||
>
|
||||
<Archive className="mr-2 size-4" />
|
||||
数据包任务
|
||||
导入 / 导出数据包
|
||||
</Button>
|
||||
)}
|
||||
{canManageWiki && <Button
|
||||
|
||||
+16
-9
@@ -59,6 +59,8 @@ export default function WeKnoraPackageTransfersPage() {
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.some((job) => RUNNING.has(job.status)) ? 3000 : false,
|
||||
});
|
||||
const exportActive =
|
||||
jobs.data?.some((job) => RUNNING.has(job.status)) ?? false;
|
||||
const createExport = useMutation({
|
||||
mutationFn: () => createLlmWikiExportPackage(noteId),
|
||||
onSuccess: () => {
|
||||
@@ -92,36 +94,42 @@ export default function WeKnoraPackageTransfersPage() {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-xl font-semibold">
|
||||
知识库数据包任务
|
||||
导入 / 导出知识库数据包
|
||||
</h1>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{knowledge.data?.name || "正在读取知识库"} ·
|
||||
大文件在后台整理,不占用详情页面
|
||||
数据包任务在后台处理,无需停留本页
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => createExport.mutate()}
|
||||
disabled={createExport.isPending || !knowledge.data}
|
||||
disabled={
|
||||
createExport.isPending || exportActive || !knowledge.data
|
||||
}
|
||||
>
|
||||
{createExport.isPending ? (
|
||||
{createExport.isPending || exportActive ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<PackagePlus className="mr-2 size-4" />
|
||||
)}
|
||||
新建导出任务
|
||||
{createExport.isPending || exportActive
|
||||
? "数据包生成中"
|
||||
: "新建导出任务"}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<WikiPackageTransfers id={noteId} />
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0 border-b">
|
||||
<div>
|
||||
<CardTitle className="text-base">
|
||||
向量化数据包导出记录
|
||||
Wiki 与向量数据包导出记录
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
每次生成独立记录;即使文件达到数
|
||||
GB,也只在后台落盘完成后提供下载。
|
||||
导出生成后的 Wiki、向量和关联元数据,供普通知识库或助手知识库导入;
|
||||
不包含原始文档文件。大文件在后台整理完成后提供下载。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -214,7 +222,6 @@ export default function WeKnoraPackageTransfersPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<WikiPackageTransfers id={noteId} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
+131
-17
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookOpenText,
|
||||
Download,
|
||||
FileDown,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import {
|
||||
buildWeKnoraWikiPageUrl,
|
||||
buildWeKnoraWikiPagePath,
|
||||
parseWeKnoraPreviewUrl,
|
||||
type WeKnoraSourceTarget,
|
||||
@@ -56,6 +58,11 @@ type SourceContextResponse = {
|
||||
success?: boolean;
|
||||
data?: {
|
||||
document_id?: string;
|
||||
chunks?: Array<{
|
||||
id?: string;
|
||||
content?: string;
|
||||
is_focus?: boolean;
|
||||
}>;
|
||||
wiki_page?: {
|
||||
slug?: string;
|
||||
} | null;
|
||||
@@ -65,6 +72,7 @@ type SourceContextResponse = {
|
||||
type DetailTarget = WeKnoraSourceTarget & {
|
||||
documentId?: string;
|
||||
wikiSlug?: string;
|
||||
focusChunkContent?: string;
|
||||
};
|
||||
|
||||
type PreviewState =
|
||||
@@ -80,6 +88,7 @@ type CachedSourceDetail = {
|
||||
documentId: string;
|
||||
document: LlmWikiDocument | null;
|
||||
wikiPage: LlmWikiWikiPage | null;
|
||||
focusChunkContent: string;
|
||||
preview: PreviewState;
|
||||
previewError: string;
|
||||
};
|
||||
@@ -87,11 +96,10 @@ type CachedSourceDetail = {
|
||||
const sourceDetailCache = new Map<string, CachedSourceDetail>();
|
||||
|
||||
function buildDrawerWikiPageUrl(knowledgeBaseId: string, wikiSlug: string) {
|
||||
const params = new URLSearchParams({
|
||||
knowledge_base_id: knowledgeBaseId,
|
||||
wiki_slug: wikiSlug,
|
||||
});
|
||||
return `/weknora-source-preview?${params.toString()}`;
|
||||
// Use a real application URL even inside the drawer. The click handler
|
||||
// below still opens it in-place, while a missed handler safely navigates to
|
||||
// the normal Wiki page instead of a nonexistent preview route.
|
||||
return buildWeKnoraWikiPageUrl(knowledgeBaseId, wikiSlug);
|
||||
}
|
||||
|
||||
async function getAvailableWikiPage(
|
||||
@@ -138,6 +146,9 @@ async function resolveTargetDetail(target: WeKnoraSourceTarget): Promise<DetailT
|
||||
}
|
||||
const documentId = payload.data?.document_id?.trim();
|
||||
const wikiSlug = payload.data?.wiki_page?.slug?.trim();
|
||||
const focusChunkContent = payload.data?.chunks
|
||||
?.find((chunk) => chunk.is_focus || chunk.id === target.chunkId)
|
||||
?.content?.trim();
|
||||
if (!documentId && !wikiSlug) {
|
||||
if (target.documentId) return target;
|
||||
throw new Error("当前引用缺少 Wiki 页面或文档标识,无法打开详情");
|
||||
@@ -146,6 +157,7 @@ async function resolveTargetDetail(target: WeKnoraSourceTarget): Promise<DetailT
|
||||
...target,
|
||||
documentId: documentId || target.documentId,
|
||||
wikiSlug: wikiSlug || target.wikiSlug,
|
||||
focusChunkContent: focusChunkContent || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -533,12 +545,17 @@ export function WeKnoraSourceDrawer({
|
||||
target,
|
||||
title,
|
||||
quote = "",
|
||||
inlineContent = "",
|
||||
inlineKnowledgeBaseId = "",
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
target: WeKnoraSourceTarget | null;
|
||||
title?: string;
|
||||
quote?: string;
|
||||
/** Render a supplied Wiki Index in this same drawer without fetching a page. */
|
||||
inlineContent?: string;
|
||||
inlineKnowledgeBaseId?: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -552,7 +569,16 @@ export function WeKnoraSourceDrawer({
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [wikiDownload, setWikiDownload] = useState<"md" | "word" | null>(null);
|
||||
const [resolvedDocumentId, setResolvedDocumentId] = useState("");
|
||||
const [focusChunkContent, setFocusChunkContent] = useState("");
|
||||
// Entity links are opened inside this drawer rather than through the router.
|
||||
// Keep their previous pages locally so users can traverse the Wiki links
|
||||
// without losing the page from which they came.
|
||||
const [wikiNavigationHistory, setWikiNavigationHistory] = useState<LlmWikiWikiPage[]>([]);
|
||||
const wikiNavigationRequestRef = useRef(0);
|
||||
const [activeWikiTarget, setActiveWikiTarget] = useState<WeKnoraSourceTarget | null>(null);
|
||||
const [showInlineIndex, setShowInlineIndex] = useState(false);
|
||||
const hasInlineContent = Boolean(inlineContent.trim());
|
||||
const currentWikiTarget = activeWikiTarget ?? target;
|
||||
|
||||
const targetKey = useMemo(() => {
|
||||
if (!target) return "";
|
||||
@@ -566,7 +592,7 @@ export function WeKnoraSourceDrawer({
|
||||
}, [quote, target]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !target) {
|
||||
if (!open || (!target && !hasInlineContent)) {
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
setDocument(null);
|
||||
@@ -576,6 +602,27 @@ export function WeKnoraSourceDrawer({
|
||||
setPreview({ kind: "none" });
|
||||
setFullscreen(false);
|
||||
setResolvedDocumentId("");
|
||||
setFocusChunkContent("");
|
||||
setWikiNavigationHistory([]);
|
||||
setActiveWikiTarget(null);
|
||||
setShowInlineIndex(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasInlineContent && !target) {
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
setDocument(null);
|
||||
setWikiPage(null);
|
||||
setPreview({ kind: "none" });
|
||||
setPreviewError("");
|
||||
setPreviewLoading(false);
|
||||
setFullscreen(false);
|
||||
setResolvedDocumentId("");
|
||||
setFocusChunkContent("");
|
||||
setWikiNavigationHistory([]);
|
||||
setActiveWikiTarget(null);
|
||||
setShowInlineIndex(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -585,6 +632,7 @@ export function WeKnoraSourceDrawer({
|
||||
setDocument(cached.document);
|
||||
setResolvedDocumentId(cached.documentId);
|
||||
setWikiPage(cached.wikiPage);
|
||||
setFocusChunkContent(cached.focusChunkContent);
|
||||
setPreview(cached.preview);
|
||||
setPreviewError(cached.previewError);
|
||||
setLoading(false);
|
||||
@@ -599,9 +647,12 @@ export function WeKnoraSourceDrawer({
|
||||
setPreviewError("");
|
||||
setPreviewLoading(false);
|
||||
setPreview({ kind: "none" });
|
||||
setFocusChunkContent("");
|
||||
setWikiNavigationHistory([]);
|
||||
|
||||
void (async () => {
|
||||
const detailTarget = await resolveTargetDetail(target);
|
||||
if (!cancelled) setFocusChunkContent(detailTarget.focusChunkContent || "");
|
||||
const isWikiTarget = Boolean(detailTarget.wikiSlug);
|
||||
const previewDocumentId = isWikiTarget ? "" : detailTarget.documentId || "";
|
||||
if (!cancelled) setResolvedDocumentId(previewDocumentId);
|
||||
@@ -677,6 +728,7 @@ export function WeKnoraSourceDrawer({
|
||||
documentId: previewDocumentId,
|
||||
document: loadedDocument,
|
||||
wikiPage: loadedWikiPage,
|
||||
focusChunkContent: detailTarget.focusChunkContent || "",
|
||||
preview: loadedPreview,
|
||||
previewError: loadedPreviewError,
|
||||
});
|
||||
@@ -693,7 +745,7 @@ export function WeKnoraSourceDrawer({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, target, targetKey]);
|
||||
}, [hasInlineContent, open, target, targetKey]);
|
||||
|
||||
const resolvedTitle =
|
||||
wikiPage?.title ||
|
||||
@@ -702,24 +754,35 @@ export function WeKnoraSourceDrawer({
|
||||
title ||
|
||||
"文档详情";
|
||||
const summary = cleanText(wikiPage?.summary || document?.description);
|
||||
const wikiContent = stripWikiFrontmatter(cleanText(wikiPage?.content));
|
||||
const wikiDisplayContent = wikiPage && target
|
||||
const wikiContent = stripWikiFrontmatter(cleanText(
|
||||
wikiPage?.content || (hasInlineContent ? inlineContent : ""),
|
||||
));
|
||||
const wikiDisplayContent = wikiPage && currentWikiTarget
|
||||
? renderableWikiMarkdown(
|
||||
resolvedTitle,
|
||||
wikiContent,
|
||||
target.knowledgeBaseId,
|
||||
currentWikiTarget.knowledgeBaseId,
|
||||
buildDrawerWikiPageUrl,
|
||||
)
|
||||
: "";
|
||||
: hasInlineContent
|
||||
? inlineKnowledgeBaseId
|
||||
? renderableWikiMarkdown(
|
||||
resolvedTitle,
|
||||
wikiContent,
|
||||
inlineKnowledgeBaseId,
|
||||
buildDrawerWikiPageUrl,
|
||||
)
|
||||
: wikiContent
|
||||
: "";
|
||||
const wikiMarkdown = wikiPage
|
||||
? wikiLinkMarkdown(
|
||||
exportableWikiMarkdown(resolvedTitle, summary, wikiContent),
|
||||
target?.knowledgeBaseId || "",
|
||||
currentWikiTarget?.knowledgeBaseId || "",
|
||||
)
|
||||
: "";
|
||||
const resolvedWikiSlug = wikiPage?.slug || target?.wikiSlug || "";
|
||||
const wikiWorkspacePath = target && resolvedWikiSlug
|
||||
? buildWeKnoraWikiPagePath(target.knowledgeBaseId, resolvedWikiSlug)
|
||||
const resolvedWikiSlug = wikiPage?.slug || currentWikiTarget?.wikiSlug || "";
|
||||
const wikiWorkspacePath = currentWikiTarget && resolvedWikiSlug
|
||||
? buildWeKnoraWikiPagePath(currentWikiTarget.knowledgeBaseId, resolvedWikiSlug)
|
||||
: "";
|
||||
|
||||
const openWikiPageInDrawer = async (linkedTarget: WeKnoraSourceTarget) => {
|
||||
@@ -733,6 +796,11 @@ export function WeKnoraSourceDrawer({
|
||||
linkedTarget.wikiSlug,
|
||||
);
|
||||
if (requestId === wikiNavigationRequestRef.current) {
|
||||
setActiveWikiTarget(linkedTarget);
|
||||
setShowInlineIndex(false);
|
||||
if (wikiPage) {
|
||||
setWikiNavigationHistory((history) => [...history, wikiPage]);
|
||||
}
|
||||
setWikiPage(page);
|
||||
setDocument(null);
|
||||
setResolvedDocumentId("");
|
||||
@@ -748,6 +816,33 @@ export function WeKnoraSourceDrawer({
|
||||
}
|
||||
};
|
||||
|
||||
const returnToPreviousWikiPage = () => {
|
||||
if (wikiNavigationHistory.length === 0 && hasInlineContent && wikiPage) {
|
||||
wikiNavigationRequestRef.current += 1;
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setWikiPage(null);
|
||||
setActiveWikiTarget(null);
|
||||
setShowInlineIndex(true);
|
||||
return;
|
||||
}
|
||||
setWikiNavigationHistory((history) => {
|
||||
const previousPage = history.at(-1);
|
||||
if (!previousPage) return history;
|
||||
// Invalidate a pending entity-link request: an older response must not
|
||||
// overwrite the page the user just returned to.
|
||||
wikiNavigationRequestRef.current += 1;
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setDocument(null);
|
||||
setResolvedDocumentId("");
|
||||
setPreview({ kind: "none" });
|
||||
setPreviewError("");
|
||||
setWikiPage(previousPage);
|
||||
return history.slice(0, -1);
|
||||
});
|
||||
};
|
||||
|
||||
const downloadOriginal = async () => {
|
||||
if (!target || !resolvedDocumentId) return;
|
||||
setDownloading(true);
|
||||
@@ -806,12 +901,12 @@ export function WeKnoraSourceDrawer({
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-64 flex-1 items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-700">
|
||||
{wikiPage ? <BookOpenText className="size-5" /> : <FileText className="size-5" />}
|
||||
{wikiPage || hasInlineContent ? <BookOpenText className="size-5" /> : <FileText className="size-5" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-base font-semibold text-foreground">{resolvedTitle}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline">{displayType(document, wikiPage)}</Badge>
|
||||
<Badge variant="outline">{hasInlineContent && !wikiPage ? "Wiki 索引" : displayType(document, wikiPage)}</Badge>
|
||||
{formatDate(document?.created_at || wikiPage?.created_at) && (
|
||||
<span>{formatDate(document?.created_at || wikiPage?.created_at)}</span>
|
||||
)}
|
||||
@@ -820,6 +915,17 @@ export function WeKnoraSourceDrawer({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{(wikiNavigationHistory.length > 0 || (hasInlineContent && wikiPage && !showInlineIndex)) && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={returnToPreviousWikiPage}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
返回上一级
|
||||
</Button>
|
||||
)}
|
||||
{wikiPage && (
|
||||
<>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => void downloadWiki("md")} disabled={wikiDownload !== null}>
|
||||
@@ -879,6 +985,14 @@ export function WeKnoraSourceDrawer({
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{focusChunkContent && (
|
||||
<section className="border-b bg-amber-50/70 px-5 py-4 dark:bg-amber-950/20">
|
||||
<div className="mb-2 text-sm font-medium text-foreground">命中片段</div>
|
||||
<div className="rounded-md border border-amber-200/80 bg-background px-3 py-3 text-sm leading-7 text-foreground whitespace-pre-wrap dark:border-amber-900/70">
|
||||
{focusChunkContent}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{summary && (
|
||||
<section className="border-b px-5 py-4">
|
||||
<div className="mb-2 text-sm font-medium text-foreground">摘要</div>
|
||||
|
||||
+29
-2
@@ -708,6 +708,14 @@ export function WeKnoraWikiManagementPage() {
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
加载文章
|
||||
</div>
|
||||
) : pages.isError && pagesList.length === 0 ? (
|
||||
<div className="flex h-40 flex-col items-center justify-center gap-2 px-3 text-center text-sm text-muted-foreground">
|
||||
<span>Wiki 目录暂时读取失败,不影响手工新建文章</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void pages.refetch()}>
|
||||
<RefreshCw className="mr-2 size-3.5" />
|
||||
重试读取
|
||||
</Button>
|
||||
</div>
|
||||
) : filteredPages.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
|
||||
{query.trim() ? "没有匹配的 Wiki 文章" : "暂无 Wiki 文章"}
|
||||
@@ -721,8 +729,27 @@ export function WeKnoraWikiManagementPage() {
|
||||
</aside>
|
||||
<section className="flex min-h-0 flex-col overflow-hidden">
|
||||
{!creating && !selectedSlug ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
选择左侧文章,或点击新建文章
|
||||
<div className="flex flex-1 items-center justify-center px-6">
|
||||
<div className="max-w-md rounded-2xl border border-dashed bg-muted/15 px-8 py-10 text-center">
|
||||
<FilePlus2 className="mx-auto size-9 text-muted-foreground/60" />
|
||||
<div className="mt-4 text-sm font-medium text-foreground">
|
||||
{pagesList.length === 0 ? "还没有 Wiki 文章" : "请选择一篇 Wiki 文章"}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
{pagesList.length === 0
|
||||
? "没有上传文件也可以直接创建 Wiki 内容。"
|
||||
: "从左侧目录选择文章进行编辑,或继续新建文章。"}
|
||||
</p>
|
||||
{canWrite && (
|
||||
<Button className="mt-5" size="sm" onClick={startCreate}>
|
||||
<FilePlus2 className="mr-2 size-4" />
|
||||
{pagesList.length === 0 ? "新建第一篇文章" : "新建文章"}
|
||||
</Button>
|
||||
)}
|
||||
{!canWrite && (
|
||||
<p className="mt-4 text-xs text-muted-foreground">当前知识库为只读,无法新建文章。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : detail.isLoading && !creating ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
|
||||
+94
-9
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
Archive,
|
||||
ArrowLeft,
|
||||
BookOpenText,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
FilePlus2,
|
||||
FileText,
|
||||
Folder,
|
||||
Loader2,
|
||||
@@ -18,6 +20,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
buildAppHashUrl,
|
||||
buildPublicKnowledgeWikiPath,
|
||||
@@ -121,6 +124,7 @@ export function WeKnoraWikiPageView({
|
||||
const [searchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const publicBackground = usePublicBackground();
|
||||
const requestedWikiSlug = searchParams.get("wikiSlug")?.trim() || "";
|
||||
const assistantBases = useAssistantKnowledgeBases(!publicEmbed);
|
||||
@@ -136,6 +140,10 @@ export function WeKnoraWikiPageView({
|
||||
!!noteId && publicEmbed,
|
||||
);
|
||||
const knowledgeBase = publicEmbed ? publicKnowledgeBase : privateKnowledgeBase;
|
||||
const canManageWiki =
|
||||
!publicEmbed &&
|
||||
knowledgeBase.data?.can_write === true &&
|
||||
knowledgeBase.data?.is_conversation_deposit !== true;
|
||||
const privatePages = useLlmWikiWikiPages(
|
||||
noteId || null,
|
||||
1,
|
||||
@@ -397,18 +405,81 @@ export function WeKnoraWikiPageView({
|
||||
{pages.error instanceof Error ? pages.error.message : "公开 Wiki 服务暂时不可用"}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => void pages.refetch()}>
|
||||
<RefreshCw className="mr-2 size-4" />重试
|
||||
</Button>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{canManageWiki && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
navigate(`${LIST_PATH}/${encodeURIComponent(noteId)}/wiki`)
|
||||
}
|
||||
>
|
||||
<FilePlus2 className="mr-2 size-4" />
|
||||
进入 Wiki 管理
|
||||
</Button>
|
||||
)}
|
||||
<Button variant={canManageWiki ? "outline" : "default"} onClick={() => void pages.refetch()}>
|
||||
<RefreshCw className="mr-2 size-4" />重试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 text-sm text-muted-foreground">
|
||||
该知识库暂无可展示的 Wiki 页面
|
||||
{!publicEmbed && (
|
||||
<><Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button><Button onClick={() => navigate(`${LIST_PATH}/${encodeURIComponent(noteId)}?view=weknora`)}>进入知识库管理,导入资料</Button></>
|
||||
)}
|
||||
<div className="flex h-full items-center justify-center bg-background px-6">
|
||||
<div className="w-full max-w-xl rounded-2xl border border-dashed bg-card px-6 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">
|
||||
{canManageWiki
|
||||
? "无需先上传文件:可以直接进入 Wiki 管理手工新建文章,也可以先导入资料后自动生成 Wiki。"
|
||||
: publicEmbed
|
||||
? "当前没有可公开阅读的 Wiki 页面,请稍后再试。"
|
||||
: "当前没有可阅读的 Wiki 页面;如需新增内容,请联系有写权限的管理员。"}
|
||||
</p>
|
||||
{!publicEmbed && (
|
||||
<div className="mt-6 flex flex-wrap justify-center gap-2">
|
||||
{canManageWiki && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`${LIST_PATH}/${encodeURIComponent(noteId)}/wiki`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<FilePlus2 className="mr-2 size-4" />
|
||||
进入 Wiki 管理,新建文章
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`${LIST_PATH}/${encodeURIComponent(noteId)}?view=weknora`,
|
||||
)
|
||||
}
|
||||
>
|
||||
导入资料
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`${LIST_PATH}/${encodeURIComponent(noteId)}/packages`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Archive className="mr-2 size-4" />
|
||||
导入 JSONL 数据包
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" onClick={() => navigate(LIST_PATH)}>
|
||||
返回知识库
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -491,8 +562,22 @@ export function WeKnoraWikiPageView({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => navigate(`${LIST_PATH}/${encodeURIComponent(noteId)}?view=weknora`)}>知识库管理</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`${LIST_PATH}/${encodeURIComponent(noteId)}/packages`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Archive className="mr-2 size-4" />
|
||||
导入 / 导出数据包
|
||||
</Button>
|
||||
)}
|
||||
{globalBase && isDeposited && <Button variant="outline" size="sm" onClick={() => navigate(`/page/workspace/knowledge/assistant?${new URLSearchParams({ base_id: globalBase.id, wikiSlug: pageData.slug })}`)}>在知识梳理中查看</Button>}
|
||||
<Button variant="outline" size="sm" onClick={() => void download("md")} disabled={downloading !== null}>
|
||||
{downloading === "md" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
||||
|
||||
+119
-58
@@ -5,6 +5,7 @@ import {
|
||||
FileText,
|
||||
Folder,
|
||||
Link2,
|
||||
Loader2,
|
||||
Network,
|
||||
Search,
|
||||
X,
|
||||
@@ -50,6 +51,9 @@ export function WikiLibraryReader({
|
||||
pageHref,
|
||||
mappingId,
|
||||
initiallyCollapseTree = false,
|
||||
activePageLoading = false,
|
||||
activePageError,
|
||||
onRetryActivePage,
|
||||
}: {
|
||||
pages: LibraryWikiPage[];
|
||||
activePage: LibraryWikiPage | null;
|
||||
@@ -57,6 +61,9 @@ export function WikiLibraryReader({
|
||||
pageHref: (slug: string) => string;
|
||||
mappingId?: string;
|
||||
initiallyCollapseTree?: boolean;
|
||||
activePageLoading?: boolean;
|
||||
activePageError?: string | null;
|
||||
onRetryActivePage?: () => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [kind, setKind] = useState("");
|
||||
@@ -65,6 +72,7 @@ export function WikiLibraryReader({
|
||||
const [graph, setGraph] = useState(false);
|
||||
const [directoryOpen, setDirectoryOpen] = useState(false);
|
||||
const initialTreeCollapseApplied = useRef(false);
|
||||
const knownTreeBranches = useRef<Set<string>>(new Set());
|
||||
const navigate = (slug: string) => {
|
||||
onNavigate(slug);
|
||||
setDirectoryOpen(false);
|
||||
@@ -104,6 +112,15 @@ export function WikiLibraryReader({
|
||||
[pages, kind, tag, query],
|
||||
);
|
||||
const bySlug = useMemo(() => new Map(pages.map((p) => [p.slug, p])), [pages]);
|
||||
const initialOpenBranches = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
activePage?.slug
|
||||
? tree.parentKeysBySlug.get(activePage.slug) || []
|
||||
: [],
|
||||
),
|
||||
[activePage?.slug, tree],
|
||||
);
|
||||
useEffect(() => {
|
||||
const ancestors: string[] = [];
|
||||
const branches: string[] = [];
|
||||
@@ -123,21 +140,21 @@ export function WikiLibraryReader({
|
||||
});
|
||||
collectBranches(tree.roots);
|
||||
visit(tree.roots, []);
|
||||
if (
|
||||
initiallyCollapseTree &&
|
||||
!initialTreeCollapseApplied.current &&
|
||||
tree.roots.length
|
||||
) {
|
||||
initialTreeCollapseApplied.current = true;
|
||||
setCollapsed(
|
||||
new Set(branches.filter((key) => !ancestors.includes(key))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setCollapsed(
|
||||
(current) =>
|
||||
new Set([...current].filter((key) => !ancestors.includes(key))),
|
||||
);
|
||||
const ancestorSet = new Set(ancestors);
|
||||
setCollapsed((current) => {
|
||||
const next = new Set(current);
|
||||
if (initiallyCollapseTree) {
|
||||
for (const key of branches) {
|
||||
if (!knownTreeBranches.current.has(key) && !ancestorSet.has(key)) {
|
||||
next.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of ancestors) next.delete(key);
|
||||
return next;
|
||||
});
|
||||
for (const key of branches) knownTreeBranches.current.add(key);
|
||||
if (tree.roots.length) initialTreeCollapseApplied.current = true;
|
||||
}, [activePage?.slug, initiallyCollapseTree, tree]);
|
||||
const links = useMemo(() => {
|
||||
const outgoing = new Set(activePage?.out_links || []);
|
||||
@@ -160,7 +177,17 @@ export function WikiLibraryReader({
|
||||
}, [activePage, pages, bySlug]);
|
||||
const renderNode = (node: WikiTreeNode, depth = 0): React.ReactNode => {
|
||||
const active = activePage?.slug === node.page?.slug;
|
||||
const open = !!query || !collapsed.has(node.key);
|
||||
const awaitingInitialCollapse =
|
||||
initiallyCollapseTree && !initialTreeCollapseApplied.current;
|
||||
const awaitingNewBranchCollapse =
|
||||
initiallyCollapseTree &&
|
||||
node.children.length > 0 &&
|
||||
!knownTreeBranches.current.has(node.key);
|
||||
const open =
|
||||
!!query ||
|
||||
(awaitingInitialCollapse || awaitingNewBranchCollapse
|
||||
? initialOpenBranches.has(node.key)
|
||||
: !collapsed.has(node.key));
|
||||
const toggle = () =>
|
||||
setCollapsed((current) => {
|
||||
const next = new Set(current);
|
||||
@@ -353,49 +380,67 @@ export function WikiLibraryReader({
|
||||
</div>
|
||||
</header>
|
||||
<section className="px-4 py-6 md:px-7 md:py-8 lg:px-10">
|
||||
<MarkdownContent
|
||||
content={rewriteProtectedWikiImageSources(
|
||||
renderableWikiMarkdown(
|
||||
activePage.title,
|
||||
activePage.content,
|
||||
mappingId || "assistant",
|
||||
(_id, slug) => pageHref(slug),
|
||||
pages,
|
||||
).replace(/^# [^\n]*(?:\n|$)/, ""),
|
||||
)}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
components={
|
||||
mappingId
|
||||
? {
|
||||
img: (props) => (
|
||||
<WikiProtectedImage
|
||||
{...props}
|
||||
knowledgeBaseId={mappingId}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
linkClassName={(href) =>
|
||||
pages.some((p) => pageHref(p.slug) === href)
|
||||
? "wiki-content-link !text-primary border-b border-dashed"
|
||||
: undefined
|
||||
}
|
||||
onLinkClick={(event, href) => {
|
||||
const target = pages.find(
|
||||
(p) =>
|
||||
pageHref(p.slug) === href ||
|
||||
href === p.slug ||
|
||||
href === `/${p.slug}`,
|
||||
);
|
||||
if (target) {
|
||||
event.preventDefault();
|
||||
navigate(target.slug);
|
||||
{activePageLoading ? (
|
||||
<div className="flex min-h-40 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载正文…
|
||||
</div>
|
||||
) : activePageError ? (
|
||||
<div className="flex min-h-40 flex-col items-center justify-center gap-3 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
正文加载失败:{activePageError}
|
||||
</p>
|
||||
{onRetryActivePage && (
|
||||
<Button size="sm" variant="outline" onClick={onRetryActivePage}>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownContent
|
||||
content={rewriteProtectedWikiImageSources(
|
||||
renderableWikiMarkdown(
|
||||
activePage.title,
|
||||
activePage.content,
|
||||
mappingId || "assistant",
|
||||
(_id, slug) => pageHref(slug),
|
||||
pages,
|
||||
).replace(/^# [^\n]*(?:\n|$)/, ""),
|
||||
)}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
components={
|
||||
mappingId
|
||||
? {
|
||||
img: (props) => (
|
||||
<WikiProtectedImage
|
||||
{...props}
|
||||
knowledgeBaseId={mappingId}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}}
|
||||
/>
|
||||
linkClassName={(href) =>
|
||||
pages.some((p) => pageHref(p.slug) === href)
|
||||
? "wiki-content-link !text-primary border-b border-dashed"
|
||||
: undefined
|
||||
}
|
||||
onLinkClick={(event, href) => {
|
||||
const target = pages.find(
|
||||
(p) =>
|
||||
pageHref(p.slug) === href ||
|
||||
href === p.slug ||
|
||||
href === `/${p.slug}`,
|
||||
);
|
||||
if (target) {
|
||||
event.preventDefault();
|
||||
navigate(target.slug);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
<section className="border-t px-4 py-5 md:px-7">
|
||||
<Button
|
||||
@@ -460,6 +505,22 @@ export function WikiLibraryReader({
|
||||
))}
|
||||
</section>
|
||||
</article>
|
||||
) : activePageLoading ? (
|
||||
<div className="flex min-h-40 items-center justify-center gap-2 py-20 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载正文…
|
||||
</div>
|
||||
) : activePageError ? (
|
||||
<div className="flex min-h-40 flex-col items-center justify-center gap-3 py-20 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
正文加载失败:{activePageError}
|
||||
</p>
|
||||
{onRetryActivePage && (
|
||||
<Button size="sm" variant="outline" onClick={onRetryActivePage}>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-20 text-center text-sm text-muted-foreground">
|
||||
选择一个 Wiki 页面开始阅读。完成向量化的数据会自动沉淀到知识梳理。
|
||||
|
||||
+15
-5
@@ -29,6 +29,9 @@ export function WikiPackageTransfers({
|
||||
? 3000
|
||||
: false,
|
||||
});
|
||||
const hasActiveJob =
|
||||
jobs.data?.some((job) => ["queued", "running"].includes(job.status)) ??
|
||||
false;
|
||||
const action = useMutation({
|
||||
mutationFn: (file?: File) =>
|
||||
assistant
|
||||
@@ -54,36 +57,43 @@ export function WikiPackageTransfers({
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{assistant
|
||||
? "保留正文、分类、标签、链接和原始向量;后台整理完成后下载。"
|
||||
: "选择助手知识库导出的文件,导入当前空库。模型一致时直接复用向量。"}
|
||||
: "兼容普通知识库和助手知识库导出的 JSONL 数据包;导入当前空库时直接还原 Wiki 与向量,不调用大模型,也不会恢复原始文档。"}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".jsonl,.ndjson"
|
||||
ref={input}
|
||||
disabled={action.isPending || hasActiveJob}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) action.mutate(f);
|
||||
if (f && !action.isPending && !hasActiveJob) action.mutate(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={action.isPending}
|
||||
disabled={action.isPending || hasActiveJob}
|
||||
onClick={() =>
|
||||
assistant ? action.mutate(undefined) : input.current?.click()
|
||||
}
|
||||
>
|
||||
{action.isPending ? (
|
||||
{action.isPending || hasActiveJob ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : assistant ? (
|
||||
<Download className="mr-2 size-4" />
|
||||
) : (
|
||||
<Upload className="mr-2 size-4" />
|
||||
)}
|
||||
{assistant ? "生成导出包" : "上传并导入"}
|
||||
{action.isPending || hasActiveJob
|
||||
? assistant
|
||||
? "数据包生成中"
|
||||
: "数据包导入中"
|
||||
: assistant
|
||||
? "生成导出包"
|
||||
: "上传并导入"}
|
||||
</Button>
|
||||
</div>
|
||||
{jobs.error && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { isSolutionReadyProject } from "./project-availability";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAnalysisProjects, type AnalysisProject } from "@/core/analysis-projects/api";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
@@ -32,26 +33,11 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { apiFetch, getAPIClient } from "@/core/api";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import {
|
||||
useStrategyProjectSettings,
|
||||
useUpdateStrategyProjectSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import { useThreads } from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import {
|
||||
myProjectConversationTypeOfThread,
|
||||
myProjectThreadMetadata,
|
||||
withMyProjectConversationMarker,
|
||||
} from "@/core/threads/project-metadata";
|
||||
import {
|
||||
agentRefOfThread,
|
||||
isBootstrapThread,
|
||||
textOfMessage,
|
||||
titleOfThread,
|
||||
} from "@/core/threads/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { AgentConfigDialog } from "./AgentConfigDialog";
|
||||
@@ -111,211 +97,16 @@ function stageLabel(project: StrategyProjectSummary): string {
|
||||
return "意图理解";
|
||||
}
|
||||
|
||||
function visibleQaMessages(thread: AgentThread) {
|
||||
return Array.isArray(thread.values?.messages)
|
||||
? thread.values.messages.filter(
|
||||
(message) =>
|
||||
(message.type === "human" || message.type === "ai") &&
|
||||
message.additional_kwargs?.hide_from_ui !== true,
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
type QaThreadLoadResult = {
|
||||
key: string;
|
||||
status: "loading" | "ready" | "error";
|
||||
values?: AgentThreadState;
|
||||
};
|
||||
|
||||
const qaThreadStateRequests = new Map<string, Promise<AgentThreadState>>();
|
||||
const QA_THREAD_STATE_TIMEOUT_MS = 10_000;
|
||||
|
||||
function loadQaThreadState(threadId: string, cacheKey: string) {
|
||||
const pending = qaThreadStateRequests.get(cacheKey);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = (async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
QA_THREAD_STATE_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/state`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`线程内容加载失败(${response.status})`);
|
||||
}
|
||||
const state = (await response.json()) as { values?: AgentThreadState };
|
||||
if (!state.values) {
|
||||
throw new Error("线程状态中没有可用内容");
|
||||
}
|
||||
return state.values;
|
||||
} catch (reason) {
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error("线程内容加载超时");
|
||||
}
|
||||
throw reason;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
})()
|
||||
.finally(() => {
|
||||
if (qaThreadStateRequests.get(cacheKey) === request) {
|
||||
qaThreadStateRequests.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
qaThreadStateRequests.set(cacheKey, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
function QaProjectCard({
|
||||
thread,
|
||||
onOpen,
|
||||
}: {
|
||||
thread: AgentThread;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const cardRef = useRef<HTMLElement | null>(null);
|
||||
const threadStateKey = `${thread.thread_id}:${thread.updated_at ?? ""}`;
|
||||
const [loadResult, setLoadResult] = useState<QaThreadLoadResult | null>(null);
|
||||
const currentLoad = loadResult?.key === threadStateKey ? loadResult : null;
|
||||
const loadStatus = currentLoad?.status ?? "idle";
|
||||
const displayThread = useMemo<AgentThread>(() => {
|
||||
if (!currentLoad?.values) return thread;
|
||||
return {
|
||||
...thread,
|
||||
values: {
|
||||
...thread.values,
|
||||
...currentLoad.values,
|
||||
},
|
||||
} as AgentThread;
|
||||
}, [currentLoad?.values, thread]);
|
||||
|
||||
useEffect(() => {
|
||||
const card = cardRef.current;
|
||||
if (!card) return;
|
||||
|
||||
let active = true;
|
||||
let requested = false;
|
||||
const load = () => {
|
||||
if (requested) return;
|
||||
requested = true;
|
||||
setLoadResult({ key: threadStateKey, status: "loading" });
|
||||
void loadQaThreadState(thread.thread_id, threadStateKey).then(
|
||||
(values) => {
|
||||
if (active) {
|
||||
setLoadResult({ key: threadStateKey, status: "ready", values });
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (active) {
|
||||
setLoadResult({ key: threadStateKey, status: "error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
load();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||
observer.disconnect();
|
||||
load();
|
||||
},
|
||||
{ rootMargin: "480px 0px", threshold: 0 },
|
||||
);
|
||||
observer.observe(card);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [thread.thread_id, threadStateKey]);
|
||||
|
||||
const messages = visibleQaMessages(displayThread);
|
||||
const lastQuestionIndex = messages.reduce(
|
||||
(latest, message, index) => (message.type === "human" ? index : latest),
|
||||
-1,
|
||||
);
|
||||
const lastQuestion = lastQuestionIndex >= 0 ? messages[lastQuestionIndex] : undefined;
|
||||
const lastAnswer =
|
||||
lastQuestionIndex >= 0
|
||||
? messages
|
||||
.slice(lastQuestionIndex + 1)
|
||||
.find((message) => message.type === "ai")
|
||||
: undefined;
|
||||
const question = lastQuestion ? textOfMessage(lastQuestion)?.trim() : "";
|
||||
const answer = lastAnswer ? textOfMessage(lastAnswer)?.trim() : "";
|
||||
const roundCount = messages.filter((message) => message.type === "human").length;
|
||||
const contentPending = loadStatus === "idle" || loadStatus === "loading";
|
||||
const contentFailed = loadStatus === "error";
|
||||
|
||||
return (
|
||||
<article ref={cardRef} className="group overflow-hidden rounded-2xl border bg-card shadow-sm transition-all hover:-translate-y-0.5 hover:border-sky-400/45 hover:shadow-lg dark:border-[#244d78] dark:bg-[#06274b]">
|
||||
<div className="w-full p-5 text-left">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-sky-500/10 text-sky-600 dark:text-sky-300">
|
||||
<MessageSquareText className="size-5" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{loadStatus === "loading" && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<LoaderCircle className="size-3 animate-spin" /> 加载内容
|
||||
</span>
|
||||
)}
|
||||
<Badge variant="outline" className="bg-background/70">普通问答</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mt-4 line-clamp-2 text-base font-semibold leading-6">
|
||||
{titleOfThread(thread, "未命名问答")}
|
||||
</h2>
|
||||
<div className="mt-3 overflow-hidden rounded-xl border bg-muted/20 dark:border-[#173d68] dark:bg-[#031b38]">
|
||||
<div className="border-b border-sky-500/10 bg-sky-500/5 px-3 py-2.5 dark:border-[#173d68]">
|
||||
<span className="mb-1 block text-[10px] font-medium text-sky-600 dark:text-sky-300">最近提问</span>
|
||||
<p className="line-clamp-2 text-xs leading-5 text-foreground/85">
|
||||
{contentPending
|
||||
? "正在加载最近问答…"
|
||||
: contentFailed
|
||||
? "对话内容暂时加载失败"
|
||||
: question || "打开对话查看问题内容"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 py-2.5">
|
||||
<span className="mb-1 block text-[10px] text-muted-foreground">DeerFlow 回答</span>
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{contentPending
|
||||
? "正在加载完整对话内容…"
|
||||
: contentFailed
|
||||
? "可直接进入对话查看并继续提问。"
|
||||
: answer || "尚未形成回答,进入后可以继续提问。"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t px-5 py-3 dark:border-[#173d68]">
|
||||
<span className="flex items-center gap-3 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{contentPending
|
||||
? "正在读取轮数"
|
||||
: contentFailed
|
||||
? "轮数暂不可用"
|
||||
: `${roundCount} 轮问答`}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5"><Clock3 className="size-3.5" />{formatTime(thread.updated_at ?? null)}</span>
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={onOpen}>继续问答 <ArrowRight /></Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
function QaProjectCard({ project, onOpen }: { project: AnalysisProject; onOpen: () => void }) {
|
||||
return <article className="flex flex-col rounded-xl border bg-card p-5 shadow-sm">
|
||||
<Badge variant="secondary" className="w-fit">普通问答项目</Badge>
|
||||
<h2 className="mt-4 text-base font-semibold">{project.name}</h2>
|
||||
<p className="mt-2 line-clamp-3 text-sm text-muted-foreground">{project.background || "在项目中新建问答,所有对话按项目独立保存。"}</p>
|
||||
<div className="mt-auto flex items-center justify-between gap-3 pt-5">
|
||||
<span className="text-xs text-muted-foreground">更新于 {formatTime(project.updated_at)}</span>
|
||||
<Button variant="outline" onClick={onOpen}>进入项目 <ArrowRight /></Button>
|
||||
</div>
|
||||
</article>;
|
||||
}
|
||||
|
||||
function ProjectCard({
|
||||
@@ -432,34 +223,6 @@ function ProjectCard({
|
||||
);
|
||||
}
|
||||
|
||||
function isSolutionReadyProject(project: StrategyProjectListItem): boolean {
|
||||
const snapshot = project.snapshot;
|
||||
if (!snapshot || snapshot.workflowStale) return false;
|
||||
|
||||
const selectedAngles = snapshot.angleCatalog.filter((angle) =>
|
||||
snapshot.taskDraft.selectedAngleIds.includes(angle.id),
|
||||
);
|
||||
const strategiesSettled = Boolean(
|
||||
selectedAngles.length &&
|
||||
selectedAngles.every((angle) =>
|
||||
["done", "error"].includes(
|
||||
snapshot.outputs[angle.agentId]?.status ?? "idle",
|
||||
),
|
||||
),
|
||||
);
|
||||
const hasSelectedResult = snapshot.selectedStrategyIds.some(
|
||||
(agentId) =>
|
||||
snapshot.outputs[agentId]?.status === "done" &&
|
||||
Boolean(snapshot.outputs[agentId]?.content.trim()),
|
||||
);
|
||||
|
||||
return (
|
||||
strategiesSettled &&
|
||||
snapshot.judgeStatus === "done" &&
|
||||
Boolean(snapshot.judgeContent.trim()) &&
|
||||
hasSelectedResult
|
||||
);
|
||||
}
|
||||
|
||||
export default function StrategyProjectsPage({
|
||||
basePath = "/page/strategy/qa/projects",
|
||||
@@ -467,7 +230,6 @@ export default function StrategyProjectsPage({
|
||||
basePath?: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const rawCategory = searchParams.get("type");
|
||||
const category: ProjectCategory =
|
||||
@@ -496,43 +258,30 @@ export default function StrategyProjectsPage({
|
||||
const demoEnabled =
|
||||
strategyProjectSettings?.taiwan_strait_demo_enabled === true;
|
||||
const demoAvailable = canCreateTaiwanDemo(createAngles);
|
||||
const {
|
||||
data: qaThreads = [],
|
||||
isLoading: qaLoading,
|
||||
error: qaError,
|
||||
} = useThreads({
|
||||
sortBy: "updated_at",
|
||||
sortOrder: "desc",
|
||||
select: ["thread_id", "updated_at", "values", "metadata"],
|
||||
});
|
||||
const ordinaryThreads = useMemo(
|
||||
() =>
|
||||
qaThreads.filter((thread) => {
|
||||
if (isBootstrapThread(thread)) return false;
|
||||
const projectType = myProjectConversationTypeOfThread(thread);
|
||||
if (projectType && projectType !== "qa") return false;
|
||||
if (projectType === "qa") return true;
|
||||
const agentRef = agentRefOfThread(thread);
|
||||
return !agentRef || agentRef === "lead_agent" || agentRef === "default";
|
||||
}),
|
||||
[qaThreads],
|
||||
);
|
||||
const { data: qaData, isLoading: qaLoading, error: qaError, refetch: refetchQa } = useAnalysisProjects();
|
||||
const qaProjects = useMemo(() => (qaData?.projects ?? []).filter((project) => project.mode === "qa"), [qaData]);
|
||||
|
||||
const load = async () => {
|
||||
const loadSequence = useRef(0);
|
||||
const load = useCallback(async () => {
|
||||
const sequence = ++loadSequence.current;
|
||||
setLoading(true);
|
||||
setLoadError("");
|
||||
try {
|
||||
setProjects(await listStrategyProjects());
|
||||
const next = await listStrategyProjects();
|
||||
if (sequence !== loadSequence.current) return;
|
||||
setProjects(next);
|
||||
setLoadError("");
|
||||
} catch (reason) {
|
||||
setLoadError(reason instanceof Error ? reason.message : "项目列表加载失败");
|
||||
if (sequence === loadSequence.current) setLoadError(reason instanceof Error ? reason.message : "项目列表加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (sequence === loadSequence.current) setLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
return () => { loadSequence.current += 1; };
|
||||
}, [load]);
|
||||
|
||||
// The global switch controls only whether users can create/load the preset.
|
||||
// Existing demo projects remain visible like normal saved work, so hiding an
|
||||
@@ -547,16 +296,10 @@ export default function StrategyProjectsPage({
|
||||
project.brief.toLowerCase().includes(keyword),
|
||||
);
|
||||
}, [availableProjects, query]);
|
||||
const filteredQaThreads = useMemo(() => {
|
||||
const filteredQaProjects = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
if (!keyword) return ordinaryThreads;
|
||||
return ordinaryThreads.filter((thread) => {
|
||||
if (titleOfThread(thread, "").toLowerCase().includes(keyword)) return true;
|
||||
return visibleQaMessages(thread).some((message) =>
|
||||
(textOfMessage(message) ?? "").toLowerCase().includes(keyword),
|
||||
);
|
||||
});
|
||||
}, [ordinaryThreads, query]);
|
||||
return qaProjects.filter((project) => `${project.name} ${project.background}`.toLowerCase().includes(keyword));
|
||||
}, [qaProjects, query]);
|
||||
|
||||
const create = async (kind: "blank" | "demo") => {
|
||||
if (creating) return;
|
||||
@@ -618,50 +361,8 @@ export default function StrategyProjectsPage({
|
||||
const solutionReadyProjects = availableProjects.filter(isSolutionReadyProject);
|
||||
const solutionProjects = filtered.filter(isSolutionReadyProject);
|
||||
const visibleStrategyProjects = category === "solution" ? solutionProjects : filtered;
|
||||
const isOfficial = basePath.startsWith("/official/");
|
||||
const newQaPath = withMyProjectConversationMarker(
|
||||
isOfficial
|
||||
? "/official/workspace/chats/new"
|
||||
: "/page/strategy/chats/new",
|
||||
"qa",
|
||||
);
|
||||
|
||||
const openQaThread = async (thread: (typeof ordinaryThreads)[number]) => {
|
||||
if (
|
||||
myProjectConversationTypeOfThread({ metadata: thread.metadata }) !== "qa"
|
||||
) {
|
||||
try {
|
||||
await getAPIClient().threads.update(thread.thread_id, {
|
||||
metadata: myProjectThreadMetadata("qa"),
|
||||
});
|
||||
// Every recent-chat surface uses its own search parameters. Mark all
|
||||
// cached lists stale so the badge is visible immediately after routing.
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["threads", "search"],
|
||||
});
|
||||
} catch {
|
||||
toast.warning("对话已打开,但“我的项目”标记保存失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
const agentRef = agentRefOfThread(thread);
|
||||
const agentPath =
|
||||
agentRef && agentRef !== "lead_agent" && agentRef !== "default"
|
||||
? `/agents/${encodeURIComponent(agentRef)}`
|
||||
: "";
|
||||
if (isOfficial) {
|
||||
navigate(
|
||||
agentPath
|
||||
? `/official/workspace${agentPath}/chats/${encodeURIComponent(thread.thread_id)}`
|
||||
: `/official/workspace/chats/${encodeURIComponent(thread.thread_id)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
navigate(
|
||||
agentPath
|
||||
? `/page/strategy${agentPath}/chats/${encodeURIComponent(thread.thread_id)}`
|
||||
: `/page/strategy/chats/${encodeURIComponent(thread.thread_id)}`,
|
||||
);
|
||||
};
|
||||
const qaBasePath = `${basePath}/groups`;
|
||||
const newQaPath = `${qaBasePath}/new`;
|
||||
|
||||
const switchCategory = (next: ProjectCategory) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
@@ -757,7 +458,7 @@ export default function StrategyProjectsPage({
|
||||
</header>
|
||||
|
||||
<nav
|
||||
className="mt-4 grid grid-cols-4 overflow-hidden rounded-2xl border bg-background shadow-sm dark:border-[#173d68] dark:bg-[#021a37]"
|
||||
className="mt-4 grid grid-cols-2 sm:grid-cols-4 overflow-hidden rounded-2xl border bg-background shadow-sm dark:border-[#173d68] dark:bg-[#021a37]"
|
||||
aria-label="项目类型筛选"
|
||||
>
|
||||
{([
|
||||
@@ -765,15 +466,15 @@ export default function StrategyProjectsPage({
|
||||
id: "all" as const,
|
||||
label: "全部项目",
|
||||
description: "集中查看所有工作",
|
||||
count: ordinaryThreads.length + availableProjects.length,
|
||||
count: qaProjects.length + availableProjects.length,
|
||||
icon: FolderKanban,
|
||||
accent: "slate",
|
||||
},
|
||||
{
|
||||
id: "qa" as const,
|
||||
label: "普通问答",
|
||||
description: "问答卡片与历史对话",
|
||||
count: ordinaryThreads.length,
|
||||
description: "按项目分组管理问答",
|
||||
count: qaProjects.length,
|
||||
icon: MessageSquareText,
|
||||
accent: "sky",
|
||||
},
|
||||
@@ -862,7 +563,7 @@ export default function StrategyProjectsPage({
|
||||
{category === "all"
|
||||
? "普通问答与策略项目按类型集中展示"
|
||||
: category === "qa"
|
||||
? "每个对话以问答卡片展示"
|
||||
? "先创建项目,再在项目内新建多个问答"
|
||||
: category === "strategy"
|
||||
? "按最近更新时间排序"
|
||||
: "从策略评判结果继续整理综合方案"}
|
||||
@@ -904,14 +605,15 @@ export default function StrategyProjectsPage({
|
||||
<p className="mt-1.5 max-w-lg text-xs leading-5 text-muted-foreground">
|
||||
{loadError || (qaError instanceof Error ? qaError.message : "请稍后重试")}
|
||||
</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => { void load(); void refetchQa(); }}>重试</Button>
|
||||
</div>
|
||||
) : filteredQaThreads.length || filtered.length ? (
|
||||
) : filteredQaProjects.length || filtered.length ? (
|
||||
<div className="mt-5 grid gap-4 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
{filteredQaThreads.map((thread) => (
|
||||
{filteredQaProjects.map((project) => (
|
||||
<QaProjectCard
|
||||
key={`qa:${thread.thread_id}`}
|
||||
thread={thread}
|
||||
onOpen={() => void openQaThread(thread)}
|
||||
key={`qa:${project.id}`}
|
||||
project={project}
|
||||
onOpen={() => navigate(`${qaBasePath}/${project.id}`)}
|
||||
/>
|
||||
))}
|
||||
{filtered.map((project) => (
|
||||
@@ -938,7 +640,7 @@ export default function StrategyProjectsPage({
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-muted-foreground">
|
||||
{query
|
||||
? "换一个关键词试试,或清空搜索查看全部项目。"
|
||||
: "新建普通问答或策略优选项目后,它们会统一展示在这里。"}
|
||||
: "新建问答项目或策略优选项目后,它们会统一展示在这里。"}
|
||||
</p>
|
||||
{!query && (
|
||||
<Button className="mt-5" onClick={() => setCreateDialogOpen(true)}>
|
||||
@@ -957,19 +659,20 @@ export default function StrategyProjectsPage({
|
||||
<MessageSquareText className="size-8 text-muted-foreground" />
|
||||
<h3 className="mt-3 text-sm font-semibold">普通问答加载失败</h3>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">{qaError instanceof Error ? qaError.message : "请稍后重试"}</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => void refetchQa()}>重试</Button>
|
||||
</div>
|
||||
) : filteredQaThreads.length ? (
|
||||
) : filteredQaProjects.length ? (
|
||||
<div className="mt-5 grid gap-4 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
{filteredQaThreads.map((thread) => (
|
||||
<QaProjectCard key={thread.thread_id} thread={thread} onOpen={() => void openQaThread(thread)} />
|
||||
{filteredQaProjects.map((project) => (
|
||||
<QaProjectCard key={project.id} project={project} onOpen={() => navigate(`${qaBasePath}/${project.id}`)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 flex min-h-72 flex-col items-center justify-center rounded-2xl border border-dashed bg-background/75 px-6 text-center dark:border-[#244d78] dark:bg-[#021a37]/80">
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-sky-500/10 text-sky-600 dark:text-sky-300"><MessageSquareText className="size-6" /></div>
|
||||
<h3 className="mt-4 text-base font-semibold">还没有普通问答</h3>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-muted-foreground">新建一次问答后,它会作为卡片回到这里,方便继续查看和追问。</p>
|
||||
<Button className="mt-5" onClick={() => navigate(newQaPath)}><MessageSquarePlus /> 新建普通问答</Button>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-muted-foreground">先新建一个项目,再进入项目创建问答;项目外的历史对话不会自动加入。</p>
|
||||
<Button className="mt-5" onClick={() => navigate(newQaPath)}><MessageSquarePlus /> 新建问答项目</Button>
|
||||
</div>
|
||||
)
|
||||
) : loading ? (
|
||||
@@ -1087,7 +790,7 @@ export default function StrategyProjectsPage({
|
||||
</span>
|
||||
<strong className="mt-5 text-base">普通问答</strong>
|
||||
<span className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
新建一个 DeerFlow 对话,后续以问答卡片形式回看和继续追问。
|
||||
新建问答项目,在项目内创建和管理多个对话,按项目分组保存。
|
||||
</span>
|
||||
<span className="mt-auto flex items-center gap-1 pt-5 text-xs font-medium text-sky-600 dark:text-sky-300">
|
||||
开始问答 <ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
|
||||
@@ -1089,6 +1089,7 @@ function StrategyCard({
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-2 px-4 py-3">
|
||||
<LiveTrace reasoning={output.reasoning} steps={output.steps} running={output.status === "running"} compact />
|
||||
{output.preservedFromPrevious && (
|
||||
<div className="flex items-start justify-between gap-2 rounded-lg border border-amber-300/70 bg-amber-50/80 px-3 py-2 text-[11px] leading-5 text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
<span>
|
||||
@@ -1124,7 +1125,6 @@ function StrategyCard({
|
||||
等待并行生成
|
||||
</div>
|
||||
)}
|
||||
<LiveTrace reasoning={output.reasoning} steps={output.steps} running={output.status === "running"} compact />
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-10 items-center justify-between gap-2 border-t px-4 py-2 dark:border-[#173d68]">
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isSolutionReadyProject } from "./project-availability";
|
||||
import type { StrategyProjectSnapshot, StrategyProjectSummary } from "./projects";
|
||||
|
||||
function project() {
|
||||
return {
|
||||
hasSolution: false,
|
||||
snapshot: {
|
||||
workflowStale: false,
|
||||
angleCatalog: [{ id: "a", agentId: "agent-a" }],
|
||||
taskDraft: { selectedAngleIds: ["a"] },
|
||||
outputs: { "agent-a": { status: "done", content: "可用策略" } },
|
||||
judgeStatus: "done",
|
||||
judgeContent: "评判完成",
|
||||
selectedStrategyIds: [],
|
||||
},
|
||||
} as unknown as StrategyProjectSummary & { snapshot: StrategyProjectSnapshot };
|
||||
}
|
||||
|
||||
test("evaluated projects are accessible before selecting candidates", () => {
|
||||
assert.equal(isSolutionReadyProject(project()), true);
|
||||
});
|
||||
test("saved solutions remain accessible after task changes", () => {
|
||||
const item = project();
|
||||
item.hasSolution = true;
|
||||
item.snapshot.workflowStale = true;
|
||||
assert.equal(isSolutionReadyProject(item), true);
|
||||
});
|
||||
test("unfinished evaluations cannot enter solution generation", () => {
|
||||
const item = project();
|
||||
item.snapshot.judgeStatus = "running";
|
||||
assert.equal(isSolutionReadyProject(item), false);
|
||||
});
|
||||
test("failed candidates without any usable strategy cannot generate", () => {
|
||||
const item = project();
|
||||
item.snapshot.outputs["agent-a"]!.status = "error";
|
||||
item.snapshot.outputs["agent-a"]!.content = "";
|
||||
assert.equal(isSolutionReadyProject(item), false);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { StrategyProjectSnapshot, StrategyProjectSummary } from "./projects";
|
||||
|
||||
export function isSolutionReadyProject(project: StrategyProjectSummary & { snapshot?: StrategyProjectSnapshot }): boolean {
|
||||
const snapshot = project.snapshot;
|
||||
if (project.hasSolution) return true;
|
||||
if (!snapshot || snapshot.workflowStale) return false;
|
||||
|
||||
const selectedAngles = snapshot.angleCatalog.filter((angle) =>
|
||||
snapshot.taskDraft.selectedAngleIds.includes(angle.id),
|
||||
);
|
||||
const strategiesSettled = Boolean(
|
||||
selectedAngles.length &&
|
||||
selectedAngles.every((angle) =>
|
||||
["done", "error"].includes(
|
||||
snapshot.outputs[angle.agentId]?.status ?? "idle",
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
strategiesSettled &&
|
||||
selectedAngles.some((angle) => {
|
||||
const output = snapshot.outputs[angle.agentId];
|
||||
return output?.status === "done" && Boolean(output.content.trim());
|
||||
}) &&
|
||||
snapshot.judgeStatus === "done" &&
|
||||
Boolean(snapshot.judgeContent.trim())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,24 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
### Ordinary Q&A map visualization
|
||||
|
||||
The ordinary lead-agent prompt includes `_ORDINARY_QA_VISUALIZATION_SECTION` only for normal, non-bootstrap, non-writing, non-notebook, non-scheduled runs and defines the highest-priority output-carrier gate. Only an explicit current-request ask for HTML, a webpage/page, dashboard, large-screen view, or `.html` file permits reading `page-report-generation`, calling `page_report_template`, or writing HTML. “Draw a flowchart/chart/map” and “make a visualization” never imply HTML: ordinary flowcharts must be inline Mermaid, ordinary charts keep the established message-inline path, and requested maps emit strict JSON in a `map-json` fence. Ambiguity defaults to inline output. Once the HTML gate passes, delivery is mandatory as a sandbox-generated `/mnt/user-data/outputs/*.html` artifact opened with `present_files`; a full inline `html` fence is forbidden as a substitute. The shared frontend `MarkdownContent` keeps its exact legacy branch when no map-shaped fence exists; dedicated `map-json` fences and complete generic `json` fences that contain map fields and pass the full map schema are validated and lazy-rendered from `components/workspace/visualizations/`. Map/loading/error cards use the full message-content width so their edges align with the step panel. Non-map and incomplete generic JSON retain the ordinary code-block path.
|
||||
|
||||
HTML artifacts are single-page by default. Omitted `preferred_page_count` is normalized to `1` during direct validation. A count greater than one is allowed only when the current user directly asks for multiple pages or gives a page count; content length and report chapter count never imply multi-page intent. When a template is explicitly requested, prepare strictly excludes profiles from the wrong page-count mode, persists `requested_page_count` and `page_mode`, and validate reloads them. Validation rejects multiple detected page views (`data-page`, tabpanels, `.report-page`, or `.view`) in single-page mode, or a single view when multi-page was explicitly requested.
|
||||
|
||||
Ordinary HTML generation is from scratch by default: do not read `page-template-library`, call catalog/prepare, or select a bundled layout unless the current user explicitly asks to use a template. The agent writes the final path directly under `/mnt/user-data/outputs/`, which lets frontend live preview open on the first `write_file`; it then validates and presents the same file without a bash/cp copy. Single-page HTML means a scrollable responsive document, not a fixed single-screen canvas. Direct validate derives `layout_mode` and page count from `page_brief` / `preferred_page_count`; it rejects `html/body overflow:hidden`, fixed 1920×1080 viewport scaling, undersized G6 containers, and long LR diagrams. Explicit template requests may use the shared `light-document` / `dark-document` profile backed by `report-document-responsive-template.html`; prepare creates its editable `working_copy` directly in outputs and persists the same constraints.
|
||||
|
||||
### Generated HTML artifact visualization runtimes
|
||||
|
||||
The same normal lead-agent prompt includes `_HTML_ARTIFACT_VISUALIZATION_SECTION`, but its contents are executable only after the explicit HTML-intent gate above passes; visualization content alone never implies this intent. New generated HTML explicitly loads browser distributions from Gateway `/api/public/html-runtime/`: `g6.min.js`, `echarts.min.js`, `leaflet.js/css`, followed by `deerflow-runtime.js`. The files are package resources under `packages/harness/deerflow/html_runtime/assets/` and ship with the harness wheel, so HTML execution does not depend on frontend Vite/node_modules assets or a public CDN. `config.yaml -> html_runtime.base_url` is the browser-reachable absolute runtime base written into generated source (localhost default; deployments must replace it with the real Gateway origin). Flowcharts use paired `data-df-flow` JSON blocks and AntV G6, not Mermaid; charts use `data-df-chart`; maps use Leaflet-backed `data-df-map`. `deerflow-runtime.js` initializes all three contracts and receives the tile template from `html_runtime.map_tile_url` through its public dynamic response. Frontend runtime replacement is bypassed whenever this explicit Gateway runtime is present. This remains separate from ordinary message rendering; ordinary `map-json` keeps its existing frontend renderer and configuration, and ordinary `flow-json`/`chart-json` must not be reintroduced.
|
||||
|
||||
The G6 runtime must not mutate author layout by auto-switching long LR graphs to TB or by increasing the target element height after render. Layout quality is decided before delivery: keep a flow to at most eight nodes where possible; otherwise split it or declare TB with at least 600px. Each flow gets its own document row and a parent that can grow naturally, preventing canvas overlap with later sections.
|
||||
|
||||
Markdown-to-HTML conversion must inventory source Mermaid/flowcharts, charts, and maps before editing. Direct `page_report_template validate` derives `required_visualizations` from its `required_features` and `page_brief`; an explicit-template prepare also persists a machine-readable `visualization_contract` in the per-output brief, which validate reloads and merges with its arguments. Required flowcharts fail validation unless the HTML contains `g6.min.js`, `deerflow-runtime.js`, a `.df-flow` container, and `data-df-flow`; embedded Mermaid and inline `new G6.Graph(...)` are errors. Required charts and maps analogously enforce the ECharts/Leaflet assets, runtime, containers, and declarative JSON blocks. A failed report must be repaired and revalidated before `present_files`.
|
||||
|
||||
G6 nodes use light semantic rounded cards with matching borders, dark high-contrast text, centered type-prefixed labels, subtle shadows, and non-animated initial rendering (avoids a faded first frame). More than seven nodes automatically switch an `LR` request to layered `TB` layout and expand a short container to 720px, preventing long workflows from shrinking into illegible shapes; drag/zoom remain enabled.
|
||||
|
||||
### 问答记忆运行时总开关
|
||||
|
||||
问答记忆由 `system_settings.json -> memory.enabled` 控制,默认 `false`,并且是问答运行时的权威总开关。关闭时 `lead_agent` 不挂载 `MemoryMiddleware`,工具注册不暴露 memory/hindsight 工具,prompt 不读取记忆,因此也不会触发 V1 的延迟提取模型调用。`config.yaml -> memory` 仅保留 provider/version/限额等参数,不能自行启用记忆。管理员通过 `GET/PUT /api/system-settings/memory` 和「设置 → 问答记忆」修改开关;下一次创建的问答立即按新值构建。
|
||||
@@ -27,6 +45,17 @@ DeerFlow is a LangGraph-based AI super agent system with a full-stack architectu
|
||||
|
||||
**Runtime**:
|
||||
- `make dev`, Docker dev, and production all run the agent runtime in Gateway via `RunManager` + `run_agent()` + `StreamBridge` (`packages/harness/deerflow/runtime/`). Nginx exposes that runtime at `/api/langgraph/*` and rewrites it to Gateway's native `/api/*` routers.
|
||||
- Persistence observability: `deerflow.persistence.engine` emits a parameter-free `[SQL写入耗时]` INFO log for every DML/DDL statement, including elapsed milliseconds and a compact SQL summary. Correlate it with `[upstream-timing]` when load-testing concurrent agents.
|
||||
- Database JSON codec observability: the SQLAlchemy `json_serializer` / `json_deserializer` emit parameter-free `[JSON编解码耗时]` INFO logs for `dumps` and `loads`.
|
||||
- Checkpoint observability: `make_checkpointer` returns a transparent timed proxy that emits parameter-free `[检查点读写耗时]` INFO logs for `aput`, `aget`, and `aget_tuple`. These timings cover the complete checkpoint call, including pool wait, SQL, locks, and network latency. `[JSON编解码耗时]` additionally logs UTF-8 payload byte count (`序列化后字节数` / `反序列化输入字节数`) without payload content.
|
||||
- PostgreSQL pool isolation: ORM capacity is `database.pool_size + database.max_overflow`; checkpointer and Store have separate `DEER_FLOW_CHECKPOINTER_PG_POOL_{MIN,MAX}` / `DEER_FLOW_STORE_PG_POOL_{MIN,MAX}` controls, with legacy `DEER_FLOW_PG_POOL_{MIN,MAX}` as fallback. Connections identify themselves as `deerflow-orm`, `deerflow-checkpointer`, or `deerflow-store` in `pg_stat_activity`. `POST /api/threads` no longer blocks on a redundant empty checkpoint: it persists `thread_meta`, the first graph run creates the first checkpoint, and `GET /api/threads/{id}/state` returns an empty state for a metadata-backed thread before that run.
|
||||
- PostgreSQL ORM connections set `idle_in_transaction_session_timeout=10s`. This is a server-side safety net for cancelled asyncpg operations whose protocol becomes invalid and whose client-side termination fails; abandoned transactions are reaped before they can accumulate to `max_connections`.
|
||||
- Checkpointer read/write isolation: PostgreSQL uses separate saver pools. `DEER_FLOW_CHECKPOINTER_WRITE_POOL_{MIN,MAX}` (default `1/8`) serves `aput`, pending writes, copy/delete/prune; `DEER_FLOW_CHECKPOINTER_READ_POOL_{MIN,MAX}` (default `1/4`) serves `aget`, `aget_tuple`, `alist`, and delta history. PostgreSQL application names are `deerflow-checkpointer-write` / `deerflow-checkpointer-read`, so large checkpoint writes cannot consume the connections reserved for history-state reads.
|
||||
- Thread-pool observability: Gateway emits independent `[线程池监控][AnyIO]`, `[线程池监控][asyncio]`, `[线程池监控][子智能体]`, `[线程池监控][MCP]`, and `[线程池监控][记忆]` records every 2 seconds by default, avoiding PowerShell line wrapping. AnyIO reports total/active/available tokens and waiting tasks; the instrumented `asyncio.to_thread` default executor additionally reports maximum/live threads, active/queued tasks, and submitted/completed totals. Use `THREAD_POOL_MONITOR_ENABLED` / `THREAD_POOL_MONITOR_INTERVAL_SECONDS` to control it.
|
||||
- The repository-level Windows daemon launcher (`scripts/start-backend.ps1`) intentionally starts a single Uvicorn process without `--reload`, through `scripts/run-backend.py`, which installs `WindowsSelectorEventLoopPolicy` before Uvicorn creates the loop. Psycopg async pools do not support the Windows Proactor loop. Repeated reload-supervisor restarts can also leave an orphan owning port 8001, causing diagnostics to hit stale code/configuration; developers who explicitly need hot reload should run `make dev` from the backend directory.
|
||||
- Ordinary-chat composer observability: the browser console logs `[普通对话输入框状态]` on each enabled/disabled transition, including the exact Chinese reason list. A running stream alone must not disable the textarea.
|
||||
- Web-search load testing: `web_search_tool` is currently a temporary hard-coded stub: it bypasses the network, blocks its synchronous worker for 4 seconds, and then returns five fixed results, each containing about 10,000 `token` tokens (about 50,000 tokens per call). It logs `[Web检索压测]`. The earlier `stress_min_content_chars` / `stress_delay_seconds` path remains below the temporary return until this stub is removed.
|
||||
- Local no-cost load model: `压测模拟模型(本地,无模型费用)` (`deerflow.models.stress_mock:StressMockChatModel`) first emits a standard `web_search` call, then after the tool response delays 1 second and streams an 1,800-character Chinese answer in 90-character chunks every 0.15 seconds. Use it to exercise the real LangGraph tool loop, checkpoints, JSON, SSE and frontend rendering without an upstream LLM.
|
||||
|
||||
**Project Structure**:
|
||||
```
|
||||
@@ -90,7 +119,7 @@ When making code changes, you MUST update the relevant documentation:
|
||||
|
||||
### Workflow Studio stream and canvas compatibility
|
||||
|
||||
- `PatchedChatOpenAI` accepts provider response fields `reasoning_content` and `reasoning`, normalizing both into `AIMessage.additional_kwargs.reasoning_content`. `app/gateway/workflow_agent_runner.py` is the boundary between LangGraph token chunks and durable workflow events. Provider reasoning in `AIMessage.additional_kwargs.reasoning_content`/`reasoning` is emitted to the UI as balanced inline `<think>` blocks; only visible answer content becomes the node's `text` output for downstream nodes. It must also emit every original `[AIMessage|ToolMessage, metadata]` tuple as `node.message.data.messages` with no whitelist, preview or length crop. Tool results and `additional_kwargs.clarification` are needed by the Studio's DeerFlow-compatible message UI and must survive durable SSE replay.
|
||||
- `PatchedChatOpenAI` accepts provider response fields `reasoning_content` and `reasoning`, normalizing both into `AIMessage.additional_kwargs.reasoning_content`. For OpenAI-compatible models that declare `supports_thinking: true` without an explicit `when_thinking_enabled`, `create_chat_model` sends `extra_body.enable_thinking=true` whenever the frontend enables thinking. `app/gateway/workflow_agent_runner.py` is the boundary between LangGraph token chunks and durable workflow events. Provider reasoning in `AIMessage.additional_kwargs.reasoning_content`/`reasoning` is emitted to the UI as balanced inline `<think>` blocks; only visible answer content becomes the node's `text` output for downstream nodes. It must also emit every original `[AIMessage|ToolMessage, metadata]` tuple as `node.message.data.messages` with no whitelist, preview or length crop. Tool results and `additional_kwargs.clarification` are needed by the Studio's DeerFlow-compatible message UI and must survive durable SSE replay.
|
||||
- An agent `ask_clarification` ToolMessage pauses the workflow at that agent node (`awaiting_input`), not as a terminal run. The pause records the source `nodeRunId`/`toolCallId`; resuming turns the submitted reply into the next user message on the same `wf-{run}-{node}` LangGraph thread. Keep the raw ToolMessage authoritative; do not invent a second card payload.
|
||||
- `app/gateway/workflow_proposal_planner.py` converts the workflow controller's selected catalog roles into per-agent `mission`/`deliverable`/`scope`/`handoff` contracts. Only a selected visible `agentId` may contribute a contract; every omitted or malformed field falls back to a deterministic role-aware contract. Preserve this prompt-level worker boundary when adding strategies: researchers must not silently become final writers, and sequential roles must receive their upstream handoff binding plus an explicit next handoff.
|
||||
- `app/gateway/routers/workflows_coze_compat.py` must return both `creator.self: true` and editable VCS metadata for an owned standalone workflow. Coze Playground derives its preview/read-only mode from those fields, so omitting `creator.self` disables node dragging even when the standalone page passes `readonly={false}`.
|
||||
@@ -345,7 +374,8 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
|
||||
15. **DeferredToolFilterMiddleware** - Hides deferred tool schemas from the bound model until tool search is enabled (optional)
|
||||
16. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if `subagent_enabled`)
|
||||
17. **LoopDetectionMiddleware** - Detects repeated tool-call loops; hard-stop responses clear both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer
|
||||
18. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
|
||||
18. **ContextWindowGuardMiddleware** - Final transient request-size guard; enforces the selected model's `max_input_tokens` after every context/tool injection
|
||||
19. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
|
||||
8a. **ToolMetricsMiddleware** - Appended immediately after `ToolErrorHandlingMiddleware` in `_build_runtime_middlewares`, so it sits *deepest* in the `wrap_tool_call` chain. Records one `tool_call_metrics` row per tool/skill/MCP invocation ? timing duration and bucketing failures into a coarse `error_category` (rate_limited / ip_blocked / auth_error / timeout / network_error / not_found / empty_result / tool_error). It catches both raised exceptions (before #8 converts them) and *self-handled* errors returned as error `ToolMessage`s (search tools that swallow HTTP 429/403). It also captures the targeted Agent Skill into `tool_call_metrics.skill_name`: the lead agent invokes a skill by `read_file`-ing its `/mnt/skills/<category>/<name>/SKILL.md` path (per the `<skill_system>` prompt block ? there is no dedicated "use skill" tool), so `skill_name` is recovered by scanning every tool call's string arguments for the `skills/{public,custom}/{name}/` path shape (`_SKILL_PATH_RE`), plus the explicit `name` argument of `skill_view`/`skill_manage`/`skill_archive`. `skill_view`-specific success detection handles its swallowed failures (`Skill 'x' not found.` returned as a plain string rather than raised). Regression coverage: `tests/test_tool_metrics_middleware.py`. Writes are best-effort fire-and-forget. **Per-run skill merging**: `SqlToolMetricsStore.record` upserts skill rows by `(run_id, skill_name)` instead of inserting one row per call ? a skill touched by several tool calls / retries inside one run collapses to a single row. Any success in the run wins (a skill that failed then finally succeeded is recorded as one `success`); a skill that only ever failed yields one `error` row whose `error_message` accumulates every attempt's message; durations are summed. Non-skill tool calls, and skill calls with no `run_id`, keep one row each. The read?merge?write is guarded by a per-event-loop `asyncio.Lock`. Surfaced by the admin `GET /api/admin/tool-metrics` router (which has a `kind=skill` filter and a per-skill `/summary` breakdown).
|
||||
9. **PromptPrefixMiddleware** - Injects the admin-configured prompt prefix into the model request only. The prefix is tagged onto the first human message's `additional_kwargs['prompt_prefix']` by the Gateway (`app.gateway.services`) and prepended to content here at call time, so the persisted/displayed message stays prefix-free
|
||||
10. **SummarizationMiddleware** (`DeerFlowSummarizationMiddleware`) - Context reduction when approaching token limits (optional, if enabled). **Non-destructive**: overrides the stock `before_model` (which persists a `RemoveMessage(REMOVE_ALL_MESSAGES)`) to a no-op and instead compresses **transiently** in `wrap_model_call` ? the model request is replaced with `[summary, *recent]` while the full original conversation stays in state (so the frontend keeps rendering the user's real Q&A). The `summary` message is `hide_from_ui` and never persisted; summaries are cached **per-thread in a process-wide, thread-safe LRU** (`_GLOBAL_SUMMARY_CACHE`, bounded to 512 threads, sticky boundary). The cache is module-level — **not** on the middleware instance — because `make_lead_agent` rebuilds a fresh middleware on every run; an instance cache would be cold each turn and force a blocking summary LLM call on every turn of a long conversation. Keyed by `thread_id` (resolved like `ThreadDataMiddleware`), so a summary computed on one turn is reused on the next, paying for an LLM call only on the first threshold crossing or a genuine re-compaction. **Threshold-first gating**: `_plan_compression` returns passthrough (no compression at all) whenever the *full* current conversation is still under the trigger — the cache/summary substitution only engages once the real context crosses the configured trigger. **Stickiness headroom** (`_apply_stickiness_headroom`, `_KEEP_HEADROOM_FRACTION=0.75`): after the `keep` policy picks a cutoff, the kept suffix is forced below ~75% of *every* trigger before summarizing. Without it a `keep` in *messages* (e.g. 20) can itself exceed a *token* trigger (e.g. 60000) when recent messages are large (tool outputs / pasted docs), leaving the "compressed" context still over the trigger so it re-summarizes on every follow-up question; the headroom makes the sticky boundary actually stick (re-compaction only after genuinely new content). When the genuine (blocking) summarize path runs, it emits a `{"type":"context_compacting","message":"正在压缩上下文…"}` event on LangGraph's `custom` stream channel (the cheap reuse path stays silent); the chat frontend's `onCustomEvent` renders it as a transient toast. Tests: `tests/test_summarization_middleware.py`
|
||||
@@ -357,7 +387,8 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
|
||||
16. **DeferredToolFilterMiddleware** - Hides deferred tool schemas from the bound model until tool search is enabled (optional)
|
||||
17. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if `subagent_enabled`)
|
||||
18. **LoopDetectionMiddleware** - Detects repeated tool-call loops; hard-stop responses clear both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer
|
||||
19. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
|
||||
19. **ContextWindowGuardMiddleware** - Final transient request-size guard; enforces the selected model's `max_input_tokens` after every context/tool injection
|
||||
20. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
|
||||
|
||||
### Configuration System
|
||||
|
||||
@@ -413,7 +444,7 @@ FastAPI application on port 8001 with health checks at `GET|HEAD /health`, `/api
|
||||
| **Threads search** (`POST /threads/search`, LangGraph-compat) | Conversation listing. **System threads are excluded by default**: rows whose `metadata.system` is truthy (scheduler runs, roundtable workers) never crowd the user's list; a caller whose `metadata` filter explicitly references `thread_type`/`system` (e.g. studio notebooks) opts out of the exclusion. New `query` field = case-insensitive title (`display_name`) substring search, used by the chats page's server-side pagination. `POST /threads/count` takes the same request body (limit/offset ignored) and returns `{total}` under identical filtering/exclusion semantics ? powers the chats page's ??/??? display without fetching rows (`ThreadMetaStore.count`). `ThreadMetaStore.search` gained `exclude_system`/`query` params; JSON-blob filters batch-scan until the requested `limit`/`offset` window is full (so pagination over a heavily-filtered set never under-fills), with a `thread_id` tie-breaker on the `updated_at DESC` ordering for stable OFFSET paging. Tests: `tests/test_thread_meta_search.py` |
|
||||
| **Artifacts** (`/api/threads/{id}/artifacts`, `/api/artifact-library`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types. `GET /api/artifact-library` scans every current-user normal thread's authoritative `outputs/` directory (including files omitted from `present_files`), excludes uploads and image/audio/video media, joins the owning conversation title/agent route metadata, and exposes search/filter/pagination with a bounded 15-second per-user cache plus `refresh=true` bypass. Frontend route `/page/workspace/library` links each item back to the source chat with `?artifact=<path>` so the chat opens the existing artifact panel directly. |
|
||||
| **Word export** (`POST /api/writing/export/docx`) | Shared Markdown → DOCX generator used by conversation, artifact, scheduled-task, Deep Research, and AI-writing download surfaces. `app/gateway/word_export.py` builds OOXML with no Python conversion dependency: A4 portrait, 3.7/3.5/2.8/2.6 cm margins, real multilevel heading numbering (`一、` / `(一)` / `1.`), two-character first-line indent on heading levels 1–3, formal body/table styles, page numbers beginning at 1 with odd pages left-aligned and even pages right-aligned, and embedded `app/gateway/assets/fonts/方正小标宋简体.ttf`. Before numbering it removes complete manual heading prefixes (including `2.1` / `2.1.1`) and drops Markdown horizontal rules from Word output. The ordinary-Q&A Lead Agent can emit the matching Markdown heading forms and avoid unordered bullet lists when the admin enables `prompt_prefix.ordinary_qa_markdown_format_enabled` in `system_settings.json`; the switch defaults off, while writing setup, writing mode, notebook, scheduled runs, and SOUL-driven custom agents keep their independent prompts. The font loader validates its internal family, SHA-256, and OpenType embedding permission (`OS/2.fsType`) before export. Tests: `tests/test_word_export.py`, `tests/test_es_query_routing_prompt.py`. |
|
||||
| **Deep Research** (`/api/deep-research`) | Durable, user-owned research sessions/jobs with replayable SSE events and hidden-thread artifacts. `basic`/`quick` use the compact runner; `detailed` creates bounded subtopics, searches/writes sections concurrently, and assembles a report; `deep` recursively searches follow-up questions under a deterministic breadth/depth query budget. `multi_agent` is a LangGraph 1.x editor/researcher/writer/reviewer graph: its initial plan emits `awaiting_input`, the recorder stores the interrupt and releases the lease, and `POST /jobs/{id}/resume` rebuilds the graph from the persisted approved/revised plan. A bounded `custom_outline` is frozen into the session; headings/list entries deterministically supply the detailed and multi-agent plan, and the shared writer receives it only as a guarded content constraint. Completed reports support owner-scoped follow-up (`GET /sessions/{id}/messages`, `POST /sessions/{id}/chat`): context is only the current report, selected sources and recent messages; source-id citations are persisted, while `allow_new_research=true` is explicitly rejected. Owners may change that evidence set only after the job stops via `PATCH /sessions/{id}/sources/{source_id}`; this applies to future follow-ups rather than rewriting a completed report. Optional report illustrations are gated by `config.yaml -> deep_research.image_generation`: the Harness `OpenAICompatibleImageProvider` requires base64 output, the shared runner step writes only bounded `research-image-01..04.(png|jpg|webp)` artifacts and Markdown uses private `deep-research://` links. `GET /sessions/{id}/artifacts/{name}` checks session ownership before serving an image; an unavailable provider yields a warning but never fails the text report. Runners live in `packages/harness/deerflow/agents/deep_research/runners/`, may never import `app.*`, and use only the `AdapterBundle`. |
|
||||
| **Deep Research** (`/api/deep-research`) | Durable, user-owned research sessions/jobs with replayable SSE events and hidden-thread artifacts. `basic`/`quick` use the compact runner; `detailed` creates bounded subtopics, searches/writes sections concurrently, and assembles a report; `deep` recursively searches follow-up questions under a deterministic breadth/depth query budget. `multi_agent` is a LangGraph 1.x editor/researcher/writer/reviewer graph: its initial plan emits `awaiting_input`, the recorder stores the interrupt and releases the lease, and `POST /jobs/{id}/resume` rebuilds the graph from the persisted approved/revised plan. A bounded `custom_outline` is frozen into the session; headings/list entries deterministically supply the detailed and multi-agent plan, and the shared writer receives it only as a guarded content constraint. When `custom_outline` is empty (the workbench default — the user did not pick a report structure), chat collection first calls `POST /sessions/{id}/intent-outline` after the collector turn ends: the chosen model plans a Markdown skeleton from the topic plus collected materials, the outline is frozen into the session snapshot and shown in the「开始撰写」form for editing. If that step is skipped or fails, `basic`/`quick` still call `generate_intent_outline` at write time, emit `plan_created` (`source: intent`), then write through the same shared writer; generation failure is a recoverable `intent_outline_fallback` warning and does not abort the report. Completed reports support owner-scoped follow-up (`GET /sessions/{id}/messages`, `POST /sessions/{id}/chat`): context is only the current report, selected sources and recent messages; source-id citations are persisted, while `allow_new_research=true` is explicitly rejected. Owners may change that evidence set only after the job stops via `PATCH /sessions/{id}/sources/{source_id}`; this applies to future follow-ups rather than rewriting a completed report. Optional report illustrations are gated by `config.yaml -> deep_research.image_generation`: the Harness `OpenAICompatibleImageProvider` requires base64 output, the shared runner step writes only bounded `research-image-01..04.(png|jpg|webp)` artifacts and Markdown uses private `deep-research://` links. `GET /sessions/{id}/artifacts/{name}` checks session ownership before serving an image; an unavailable provider yields a warning but never fails the text report. Runners live in `packages/harness/deerflow/agents/deep_research/runners/`, may never import `app.*`, and use only the `AdapterBundle`. |
|
||||
| **Gateway auth** | Owner-scoped Deep Research routes await the platform's asynchronous `get_current_user` resolver before they read or write persistence, ensuring each session and job operation receives a concrete user ID. Deep Research artifacts are always resolved from a whitelisted filename via `/mnt/user-data/outputs/<name>` before mapping to a host path, preserving the sandbox guard on native Windows and Linux hosts. |
|
||||
| **Suggestions** (`/api/threads/{id}/suggestions`) | `POST /` - generate follow-up questions; rich list/block model content is normalized before JSON parsing |
|
||||
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /../messages` - thread messages with feedback; `GET /../token-usage` - aggregate tokens |
|
||||
@@ -583,7 +614,11 @@ documents in the reference panel. Tests:
|
||||
|
||||
### Agent Module Tag Config (zzJC / zzXD 按类型聚合)
|
||||
|
||||
Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊天记录」). An admin picks which agent **tags** belong to a module; the module's left sidebar then shows only agents under those tags (empty/unconfigured = show all visible agents). Stored in `system_settings.json` → `agent_modules` (`AgentModuleSettings`: `modules: {module_key → [tag_id, ...]}`); pure helpers `get_module_tag_ids` / `set_module_tag_ids` (de-dupe + strip). Endpoints (in `app/gateway/routers/system_settings.py`): `GET /api/system-settings/agent-modules/{key}` (readable by any user — the sidebar needs it to filter), `PUT /api/system-settings/agent-modules/{key}` (admin only, full replacement of the tag-id list). Module keys are frontend-defined (`AGENT_MODULES` in `components/workspace/agents/agent-module-sidebar.tsx`, keyed by the module's landing agent id: `6bf`→`zzjc`, `10d69…`→`zzxd`). Tests: `tests/test_agent_modules.py`. Frontend: `core/system-settings` (`useAgentModuleConfig`/`useUpdateAgentModuleConfig`), admin `AgentModuleSettingsDialog` (gear button in the agent-chat header).
|
||||
Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊天记录」). An admin picks which agent **tags** belong to a module. The module's sidebar then shows only agents under those tags (empty/unconfigured = show all visible agents). Stored in `system_settings.json` → `agent_modules` (`AgentModuleSettings`: `modules: {module_key → [tag_id, ...]}`); pure helpers `get_module_tag_ids` / `set_module_tag_ids` (de-dupe + strip). Endpoints (in `app/gateway/routers/system_settings.py`): `GET /api/system-settings/agent-modules/{key}` (readable by any user — the sidebar needs it to filter), `PUT /api/system-settings/agent-modules/{key}` (full replacement; admin only). Module keys are frontend-defined (`AGENT_MODULES` in `components/workspace/agents/agent-module-sidebar.tsx`). Tests: `tests/test_agent_modules.py`. Frontend: `core/system-settings` (`useAgentModuleConfig`/`useUpdateAgentModuleConfig`) and `AgentModuleSettingsDialog`.
|
||||
|
||||
### wx common-agent category
|
||||
|
||||
The fixed-deep-blue wx full-chat route (`#/page/workspace/wx/chats/new`) hides the global header and page sidebar while retaining its workspace common-agent panel. When its optional category is unset, the panel preserves the original recommendation logic (pinned → mine → square, deduplicated). A saved category selects one Agent Gallery category: `mine`, `sentiment`, or `square:<square_id>` (for example the configured `默认广场`). It is stored in `system_settings.json` → `wx_common_agents` (`WxCommonAgentsSettings`), validated against the effective publish-square list, and exposed by `GET`/`PUT /api/system-settings/wx-common-agents`. The `wx` account and administrators can update or clear it; all users can read it. The frontend uses `useWxCommonAgents` and displays the settings gear only to wx. Tests: `tests/test_wx_common_agents.py`.
|
||||
|
||||
### Business-mapping authorization
|
||||
|
||||
@@ -1153,6 +1188,22 @@ Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` me
|
||||
|
||||
### Conditional WeKnora LLMWiki
|
||||
|
||||
**Selected knowledge-space policy:** if either `llmwiki_knowledge_base_ids` or
|
||||
`assistant_knowledge_base_ids` is non-empty, Gateway sets
|
||||
`knowledge_space_strict`. The lead agent must fail closed: no Web/browser
|
||||
tools, MCP, external retrieval skills, ACP, deep-research pipeline, or
|
||||
subagents. Only the pre-retrieved selected-space context and the read-only
|
||||
`llmwiki_search` compatibility tool may retrieve knowledge; that tool supports
|
||||
both ordinary and assistant Wiki stores and cannot accept IDs from the model.
|
||||
Ordinary Wiki retrieval runs vector and local lexical channels concurrently,
|
||||
uses RRF, reports raw vector threshold diagnostics, and continues serving a
|
||||
coherent last-successful snapshot after a later sync is cancelled/failed.
|
||||
`config.yaml` defaults the Wiki encoder to SiliconFlow `BAAI/bge-m3`; keep
|
||||
`SILICONFLOW_API_KEY` outside Git. The frontend derives four starter questions
|
||||
from the selected space names and hides generic recommendations while these
|
||||
questions are present. Design and remaining graph/profile phases are in
|
||||
`docs/KNOWLEDGE_SPACE_RETRIEVAL_AND_WIKI_EXPANSION_ZH.md`.
|
||||
|
||||
**Embedding ownership:** WeKnora's per-knowledge-base `embedding_model_id`
|
||||
indexes uploaded source documents; `llmwiki.local_wiki_index.embedding` indexes
|
||||
the generated Wiki-page mirror inside DeerFlow. Do not couple these two model
|
||||
@@ -1367,6 +1418,16 @@ Automatic conversation summarization when approaching token limits:
|
||||
OpenAI-compatible normalizer is a second line of defence and also merges
|
||||
adjacent leading system messages.
|
||||
- Keeps recent messages while summarizing older ones.
|
||||
- **The model input hard guard is independent and always on when configured.**
|
||||
Set `models[].max_input_tokens` (or `profile.max_input_tokens`) to the safe
|
||||
input ceiling; `max_tokens` is output-only. `ContextWindowGuardMiddleware`
|
||||
is mounted at the innermost end of both lead-agent and subagent model-call
|
||||
chains, after memory/RAG/image/custom injections. It counts the complete
|
||||
request (system prompt, messages/reasoning, tool schemas and response format),
|
||||
then transiently removes old complete turns and truncates oversized bodies as
|
||||
a last resort without mutating checkpoint state. Provider context-length
|
||||
errors are non-retriable so a tokenizer mismatch cannot trigger repeated
|
||||
submission of the same oversized request.
|
||||
|
||||
Tests: `tests/test_summarization_toggle.py` (gating + whitelist),
|
||||
`tests/test_summarization_middleware.py` (cache behavior).
|
||||
@@ -1638,6 +1699,14 @@ page is successfully rebuilt. `WikiRetrievalService` partitions mixed requests
|
||||
per mapping, so ready libraries stay local while incomplete libraries use that
|
||||
Wiki-only fallback.
|
||||
|
||||
Library-level overview requests (for example, “此知识库都讲了什么” or “这个知识空间有哪些内容”) are
|
||||
recognized once by `local_index.overview` and shared by local vector and remote Wiki-page retrieval. A completed
|
||||
local index returns representative page bodies without embedding the broad query or applying `min_similarity`; an
|
||||
incomplete index asks the remote page API for its directory rather than attempting a wording match that can return
|
||||
zero. In both paths directory, index, and summary pages are ineligible, and a page summary must never substitute
|
||||
for its body. `AssistantKnowledgeRepository` implements the same user-facing overview semantics independently for
|
||||
assistant knowledge bases.
|
||||
|
||||
The frontend must keep Wiki citations visibly distinct from raw-document
|
||||
citations while hiding provider-brand wording from user-facing labels, status
|
||||
text, and errors. Any click on a Wiki source card opens the shared right-side
|
||||
@@ -1818,6 +1887,37 @@ custom bases. Deletion removes the custom base's Wiki pages, vectors, entities,
|
||||
relations, import audit rows, and package files; the global base is never
|
||||
deletable because it owns full-library aggregation.
|
||||
|
||||
Assistant-knowledge reads keep the landing and article paths size-bounded.
|
||||
`list_bases()` is metadata/statistics-only and must never call the lazy global
|
||||
alignment pass: imports align their snapshot before commit, while content reads
|
||||
and search retain `_ensure_base_aligned`. The Wiki directory calls
|
||||
`GET /api/assistant-knowledge/wiki-pages?include_content=false`; repository
|
||||
`load_only` projection must exclude both `content_markdown` and `search_text`
|
||||
(response-only deletion is insufficient), and the frontend loads only the
|
||||
active slug through `GET /wiki-pages/{slug}`. The explicit index sorts into the
|
||||
first directory batch; that batch unlocks the reader immediately while later
|
||||
500-row batches append in the background. A URL-provided slug must fetch in
|
||||
parallel with the directory. An existing base with zero sources and zero Wiki
|
||||
pages is a valid, reachable state: keep the Wiki management surface mounted and
|
||||
show upload/package-import actions instead of requiring a document or redirecting
|
||||
away. The ordinary Wiki empty reader must likewise link writable users to the
|
||||
manual Wiki editor. Do not enable global pages, sources, or import-job queries
|
||||
while the landing route has no `base_id`.
|
||||
|
||||
The assistant-base switcher is a hot metadata path. `list_bases()` must load all
|
||||
four counters with one grouped statement rather than four serial counts per
|
||||
base, and a metadata-only rename must not rescan content tables. Assistant
|
||||
vector search validates base ids without loading counters, checks vector
|
||||
fingerprint/dimensions before encoding, scans only vector blobs plus minimal
|
||||
page identity in matrix batches, and hydrates bodies only for final top results.
|
||||
Chinese lexical fallback must extract bounded meaningful subphrases rather than
|
||||
treating the entire no-space question as one SQL token.
|
||||
|
||||
The shared WeKnora runtime client retains its authenticated session and caches
|
||||
the upstream knowledge-base directory for 15 seconds, coalescing concurrent
|
||||
scope requests. Knowledge-base and document mutations invalidate that cache;
|
||||
generation checks prevent an older in-flight list response from repopulating it.
|
||||
|
||||
### Wiki package and local encoder invariants (2026-09)
|
||||
|
||||
Ordinary knowledge detail landing is globally configured by `ordinary_knowledge_landing` (`wiki` default / `weknora`), with admin-only PUT `/api/system-settings/ordinary-knowledge-landing`. Explicit document/graph/article links bypass this default. Wiki reading and the embedded WeKnora view provide reciprocal navigation; `?view=weknora` explicitly selects the iframe and must not loop back to the default Wiki route.
|
||||
@@ -1891,6 +1991,41 @@ Ordinary knowledge detail landing is globally configured by `ordinary_knowledge_
|
||||
`tests/test_wiki_package_roundtrip.py`, `test_llmwiki_wiki_sync.py` and
|
||||
`test_assistant_knowledge_repository.py`.
|
||||
|
||||
## Skill-list performance invariant
|
||||
|
||||
`GET /api/skills` must obtain the process-level storage without passing an
|
||||
explicit `app_config`; the factory treats explicit configs as isolated
|
||||
instances. `SkillStorage.load_skills()` caches parsed `SKILL.md` metadata behind a
|
||||
path/mtime/size signature (rechecked at most once per second per process) and returns independent dataclass copies before
|
||||
applying live enabled-state configuration. `GET /api/skills` executes discovery
|
||||
with `asyncio.to_thread`, overlaps independent ORM reads, resolves owner names
|
||||
with concurrent lookups only for owners present in the response, and performs the display-adapter table check at most
|
||||
once per SQLAlchemy engine. Preserve the Chinese `[技能列表耗时]` phase log when
|
||||
changing this path.
|
||||
The list path must load `.usage.json` once with `all_entries()` and pass each
|
||||
entry into response conversion; never restore per-skill `get_entry()` calls.
|
||||
|
||||
## Username-login performance invariant
|
||||
|
||||
`POST /api/v1/auth/login/username` reuses the account loaded for the personal
|
||||
password gate when calling `_get_or_create_user_by_username`; do not restore a
|
||||
second identical user lookup. Login must never cache a password, user object,
|
||||
approval state, login response, or JWT. `_sync_user_position` may cache only a
|
||||
bounded successful-completion timestamp for 5 minutes and coalesce an
|
||||
in-flight sync for the same user. Position-management mutations continue to
|
||||
call `sync_user`/`sync_position` directly and therefore bypass the login
|
||||
cooldown. Preserve the Chinese `[登录耗时]` phase log.
|
||||
|
||||
## Agent-list performance invariant
|
||||
|
||||
Paginated `/api/agents` results use a five-second, 256-entry micro-cache keyed
|
||||
by user id plus all visibility/filter/pagination/sort inputs. Never share a
|
||||
cached `square` response across users because cards include favorites and pins,
|
||||
and pin state affects page ordering. Identical in-flight requests are
|
||||
coalesced; cached Pydantic responses are deep-copied before return. Cache misses
|
||||
fetch extras, tags, favorites, pins and owner usernames concurrently. Preserve
|
||||
the Chinese `[智能体列表耗时]` log and its `命中/未命中/合并等待` status.
|
||||
|
||||
## openGauss / GaussDB persistence invariant
|
||||
|
||||
`database.backend=gauss` is an application-ORM backend, parallel to `mysql`.
|
||||
@@ -1903,3 +2038,17 @@ configured SQLite backend; the PostgreSQL checkpointer schema is not part of the
|
||||
Gauss compatibility contract. Treat both `postgresql` and `opengauss` dialect
|
||||
names as timezone-aware PostgreSQL-family engines in portable types and
|
||||
dialect-specific session settings.
|
||||
|
||||
## 2026-09-14 我的项目与知识库验收修复
|
||||
|
||||
- 简洁模式的「我的项目」列表、策略工作台和普通问答项目恢复工作区功能导航;窄屏提供展开菜单按钮。
|
||||
- 普通问答先创建具名项目,再在项目内创建多个对话。列表读取 `/api/analysis-projects` 的 `qa` 项目,不再读取、标记或自动归入全局普通聊天。
|
||||
- 项目设置更新保留既有知识库绑定。原「项目空间」与官方入口继续可用;我的项目下问答路由为 `qa/projects/groups/*`。
|
||||
- 策略生成卡片统一先显示思考/执行过程,再显示正文;评判、方案及展开视图沿用相同顺序。
|
||||
- 完成评判但尚未勾选策略的项目可以从方案列表进入,在工作台选择后生成;已保存方案在任务修改后仍可打开。旧列表请求不再覆盖新请求状态。
|
||||
- 知识库管理页读取上游最新目录,避免远端删除后命中旧缓存;检索调用仍共享短期目录缓存。
|
||||
- 普通用户访问尚未初始化的助手知识库时显示明确空态及刷新按钮。
|
||||
|
||||
验证:后端相关回归 273 项、前端策略/项目回归 31 项通过;真实 BAAI/bge-m3 编码、指定库向量检索与草稿隔离通过;本地隔离服务的问答项目创建/分组、真实问答、方案整理、助手库创建/编辑/文件入库/向量检索/详情均通过。完整验收记录见仓库 `docs/ji-knowledge-projects-acceptance.md`。不在源码或文档中保存接口密钥。
|
||||
|
||||
2026-09-14 日常使用复查:补修项目历史入口与移动侧栏、默认智能体同步、项目加载重试、助手知识检索过期响应隔离及无结果指引;更新 ScrollArea 的 React 19 兼容修复。后端再次 273 项通过,前端 33 项通过。范围及限制见仓库 docs/daily-usage-audit-20260914.md。
|
||||
@@ -1,8 +1,8 @@
|
||||
install:
|
||||
uv sync
|
||||
|
||||
dev:
|
||||
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload --reload-dir app --reload-dir packages --reload-exclude "$(CURDIR)/.deer-flow" --reload-exclude "$(CURDIR)/.runtime"
|
||||
dev:
|
||||
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload --reload-dir app --reload-dir packages --reload-exclude "$(CURDIR)/.deer-flow" --reload-exclude "$(CURDIR)/.runtime"
|
||||
|
||||
gateway:
|
||||
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001
|
||||
@@ -16,3 +16,7 @@ lint:
|
||||
|
||||
format:
|
||||
uvx ruff check . --fix && uvx ruff format .
|
||||
|
||||
# Web App 站点模板依赖预置(开发机执行一次;离线发版由部署仓库在构建阶段完成)
|
||||
webapp-template:
|
||||
cd templates/web-app-template && pnpm install && pnpm build
|
||||
@@ -8,6 +8,15 @@
|
||||
|
||||
DeerFlow is a LangGraph-based AI super agent with sandbox execution, persistent memory, and extensible tool integration. The backend enables AI agents to execute code, browse the web, manage files, delegate tasks to subagents, and retain context across conversations - all in isolated, per-thread environments.
|
||||
|
||||
The dedicated wx chat entry (`#/page/workspace/wx/chats/new`) is fixed to the
|
||||
deep-blue presentation and has no global header or page sidebar. Its retained
|
||||
workspace panel keeps its original recommendation logic until wx selects an
|
||||
Agent Gallery category: `我的`, a configured publish square such as
|
||||
`默认广场`, or `舆情分析`. The shared optional selection is stored in
|
||||
`system_settings.json` → `wx_common_agents`; the `wx` account and
|
||||
administrators may update or clear it through
|
||||
`/api/system-settings/wx-common-agents`.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
@@ -47,6 +56,50 @@ DeerFlow is a LangGraph-based AI super agent with sandbox execution, persistent
|
||||
|
||||
Health probes are always unauthenticated, even when API authentication is enabled: `GET|HEAD /health`, `/api/health`, and `/api/langgraph/health` all return the Gateway health status without requiring a cookie or bearer token.
|
||||
|
||||
The skill directory endpoint (`GET /api/skills`) keeps parsed `SKILL.md`
|
||||
metadata in a process-local cache whose file signature is rechecked at most
|
||||
once per second and invalidated automatically when a
|
||||
skill file's path, modification time, or size changes. Directory discovery is
|
||||
run outside the asyncio event loop, so large skill libraries do not block
|
||||
unrelated Gateway requests. The backend log marker `[技能列表耗时]` reports the
|
||||
file-discovery, database-query, and response-assembly phases.
|
||||
Skill usage counters are loaded from `.usage.json` once per list request rather
|
||||
than rereading the same sidecar for every returned skill.
|
||||
|
||||
Username login never caches passwords, user authorization records, login
|
||||
responses, or JWTs. It reuses the user row already loaded by the current
|
||||
request and keeps only a bounded, 5-minute timestamp cache for successful
|
||||
position-subscription synchronization. Concurrent logins for one user share
|
||||
one synchronization task. `[登录耗时]` logs expose the lookup, token, and
|
||||
position-sync phases.
|
||||
|
||||
Paginated agent galleries (`GET /api/agents?scope=...`) use a bounded five-second
|
||||
per-user micro-cache and coalesce identical in-flight requests. Cache keys
|
||||
include every filter, page and sort field so `mine`/favorites/pins never leak
|
||||
between users. At most 256 responses are retained, while extras, tags,
|
||||
favorites, pins and owner names are fetched concurrently on cache misses.
|
||||
`[智能体列表耗时]` reports cache status and request latency.
|
||||
|
||||
Database write observability: every ORM DML/DDL statement logs a parameter-free `[SQL写入耗时]` record with elapsed milliseconds, row count, batch flag, and a truncated SQL operation summary. Use these entries with `[upstream-timing]` when diagnosing concurrent agent pressure.
|
||||
Database JSON serialization also logs parameter-free `[JSON编解码耗时]` entries for `dumps` and `loads` with elapsed milliseconds.
|
||||
LangGraph checkpoint access logs parameter-free `[检查点读写耗时]` entries for `aput`, `aget`, and `aget_tuple`; this is the end-to-end checkpoint access time, including database pool wait, SQL, lock wait, and network round-trip. `[JSON编解码耗时]` records the duration plus UTF-8 byte count (`序列化后字节数` for dumps and `反序列化输入字节数` for loads), without logging payload content.
|
||||
Thread-pool observability emits independent, Chinese `[线程池监控][AnyIO]`, `[线程池监控][asyncio]`, `[线程池监控][子智能体]`, `[线程池监控][MCP]`, and `[线程池监控][记忆]` records every 2 seconds by default. This prevents Windows PowerShell redirection from wrapping a long combined record. The `asyncio.to_thread` default pool reports maximum/live threads, active/queued tasks, and submitted/completed totals. Configure it with `THREAD_POOL_MONITOR_ENABLED` and `THREAD_POOL_MONITOR_INTERVAL_SECONDS`.
|
||||
|
||||
PostgreSQL connections are budgeted independently: the ORM uses `database.pool_size + database.max_overflow`, while the checkpointer and LangGraph Store use `DEER_FLOW_CHECKPOINTER_PG_POOL_{MIN,MAX}` and `DEER_FLOW_STORE_PG_POOL_{MIN,MAX}` (the legacy shared `DEER_FLOW_PG_POOL_{MIN,MAX}` remains a fallback). Keep their combined per-worker ceiling comfortably below PostgreSQL `max_connections`. New thread creation writes metadata only; its first graph run creates the first checkpoint, and `/state` returns an empty state before that run. This prevents a new-chat request from waiting behind large checkpoint writes.
|
||||
PostgreSQL ORM connections also set `idle_in_transaction_session_timeout=10s`: if cancellation corrupts an asyncpg protocol session and client-side termination fails, PostgreSQL reaps the abandoned transaction instead of allowing it to exhaust `max_connections`.
|
||||
The PostgreSQL checkpointer reserves independent read/write capacity. `DEER_FLOW_CHECKPOINTER_WRITE_POOL_{MIN,MAX}` defaults to `1/8`, while `DEER_FLOW_CHECKPOINTER_READ_POOL_{MIN,MAX}` defaults to `1/4`. `aget`, `aget_tuple`, and `alist` use `deerflow-checkpointer-read`; writes and destructive maintenance use `deerflow-checkpointer-write`. This prevents large `aput` bursts from occupying every connection needed to open history state.
|
||||
|
||||
Lead-agent LangGraph compilation uses a process-local LRU template cache. Runs with the same model, agent, tool/skill surface, and graph-shaping context reuse compiled graph internals while receiving a shallow-copied instance for per-run checkpointer/store/interrupt attributes. Agent config or SOUL changes alter the cache key; skill refreshes clear the cache immediately. `DEER_FLOW_GRAPH_CACHE_SIZE` controls the per-worker entry limit (default `64`). Operational logs use the Chinese prefix `[智能体图缓存]` for hits, misses, build time, and invalidation.
|
||||
|
||||
The Gateway asyncio default executor is sized for blocking I/O workloads such as requests-based WebSearch. `DEER_FLOW_ASYNCIO_THREAD_POOL_SIZE` controls its per-worker maximum and defaults to `200`; threads are created lazily rather than all at startup. Monitor `[线程池监控][asyncio]` and lower this value when per-process memory or downstream connection capacity is constrained.
|
||||
|
||||
Model latency logs use the Chinese prefix `[大模型调用耗时]`. For each model call they report the delay from dialogue submission to the outgoing model request, model-request to first streamed chunk, dialogue submission to that chunk, and completion/failure totals. `外层运行` correlates these lines with the SSE run; `模型调用序号` distinguishes later model calls after tools.
|
||||
|
||||
On Windows, `scripts/start-backend.ps1` runs the background Gateway without Uvicorn hot reload and selects `WindowsSelectorEventLoopPolicy` before startup. This keeps one process responsible for port 8001, prevents an orphaned reload supervisor from serving stale code or stale database settings, and lets psycopg's async PostgreSQL pool operate on its supported Windows loop. Use `make dev` directly from the backend directory when interactive hot reload is specifically required.
|
||||
For ordinary-chat composer investigations, the browser console emits `[普通对话输入框状态]` whenever its disabled state changes, including the Chinese reason list; a streaming conversation by itself is not a disable reason.
|
||||
For the current web-search load test, `web_search_tool` is intentionally hard-coded to bypass the network, blocks its synchronous worker for 4 seconds, and then returns five fixed results, each containing approximately 10,000 `token` tokens (about 50,000 tokens per call); it logs `[Web检索压测]`. The older `stress_min_content_chars` / `stress_delay_seconds` knobs remain below that return path until this temporary test stub is removed.
|
||||
`压测模拟模型(本地,无模型费用)` is a deterministic local chat model for end-to-end load tests: its first turn emits a real `web_search` tool call, and after the tool result it waits 1 second and streams a 1,800-character Chinese answer in 90-character chunks every 0.15 seconds. It therefore exercises LangGraph's tool loop, the fixed WebSearch wait/large payload, checkpointing, JSON serialization, SSE, and frontend rendering without calling an upstream LLM.
|
||||
|
||||
---
|
||||
|
||||
## Core Components
|
||||
@@ -69,10 +122,22 @@ The single LangGraph agent (`lead_agent`) is the runtime entry point, created vi
|
||||
- **Subagent delegation** for parallel task execution
|
||||
- **System prompt** with skills injection, memory context, and working directory guidance
|
||||
|
||||
普通问答采用严格的输出载体门禁:只有本次请求明确要求 HTML、网页、页面、看板、大屏或 `.html` 文件时,才允许读取网页生成技能、调用页面模板工具或创建 HTML;“画流程图”“画图表”“画地图”“做可视化”本身都不构成 HTML 意图。普通流程图直接在消息中输出 Mermaid,普通图表沿用既有消息内方式,普通地图输出 `map-json`。明确的 HTML 请求必须进入沙箱生成 `/mnt/user-data/outputs/*.html` 并通过 `present_files` 打开右侧制品预览,禁止用聊天中的完整 HTML 代码块代替文件。前端负责本地校验和渲染,并兼容模型误写成普通 `json` 围栏的合法地图契约;只有包含地图字段且通过完整 schema 校验的 JSON 才会按地图渲染,其他 JSON 保持原代码块。地图卡片占满消息内容区,与步骤条左右边界对齐;格式错误时显示可读错误与原始数据,不中断整条消息。
|
||||
|
||||
生成 `.html` 网页、看板或大屏时使用独立的离线绘图契约:流程图使用 `data-df-flow` + AntV G6(不使用 Mermaid),图表与地图使用 `data-df-chart` / `data-df-map` 严格 JSON 数据块。Gateway 从公开路径 `/api/public/html-runtime/` 提供浏览器版 `g6.min.js`、`echarts.min.js`、`leaflet.js/css` 和统一初始化脚本 `deerflow-runtime.js`,生成的 HTML 源码会直接引用这些后台文件,不依赖前端 React 构建产物或公网 CDN。这些文件作为 `packages/harness/deerflow/html_runtime/assets/` 的包资源随 harness wheel 一起发布。`config.yaml -> html_runtime.base_url` 必须填写浏览器可访问的 Gateway Runtime 地址(本机默认 `http://localhost:8001/api/public/html-runtime`),地图瓦片位于同段的 `map_tile_url`;内网部署时把二者替换为实际 Gateway 和瓦片服务地址。该能力不改变普通问答消息列表,普通问答地图仍走原有 `map-json` 前端渲染。
|
||||
|
||||
从已有 Markdown 报告生成 HTML 时,必须根据材料盘点 Mermaid/流程图、图表及地图,并在 `page_report_template validate` 的 `required_features` 和 `page_brief` 中如实声明。流程图必须包含 `g6.min.js`、`deerflow-runtime.js`、`.df-flow` 与 `data-df-flow`,并拒绝 Mermaid、手写 HTML/CSS 流程节点以及页面内自行初始化 `G6.Graph`;图表和地图同样检查 ECharts/Leaflet 的资源与声明式数据块。用户明确要求模板时,prepare 还会把这些要求持久化到同名 brief,validate 会自动恢复并与调用参数合并。校验失败时不得交付,必须修复后重新校验。
|
||||
|
||||
HTML 默认采用单页面:validate 在 `preferred_page_count` 省略时按 `1` 处理;只有用户直接提出“多页面 / 多页 / 多个页面 / 分页门户”或指定页数时,模型才可传入大于 `1` 的页数。用户明确要求模板时,prepare 会严格排除页数模式不符的模板,并把 `requested_page_count` 与 `page_mode` 写入 brief。单页模式检测到多个 `data-page`、tabpanel、`.report-page` 或 `.view` 页面视图时验证失败,内容长度和 Markdown 章节数不构成多页理由。
|
||||
|
||||
HTML 流程图节点采用浅色语义圆角卡片、同色系描边和深色高对比文字,节点文字居中并带类型前缀。运行时严格尊重页面声明的 `LR` / `TB` 方向和容器高度,不再自行改方向或把容器撑穿后续区块;单图超过 8 个节点时,页面生成规则要求拆图,或使用至少 600px 高的 `TB` 布局。画布保留拖动和缩放能力。
|
||||
|
||||
普通 HTML 默认不读取模板库、不选择模板,而是直接用 `write_file` 在 `/mnt/user-data/outputs/` 从空白文件生成;写入开始即可自动打开右侧沙箱。只有用户明确要求使用模板时才调用 catalog/prepare,此时可选 `light-document` / `dark-document` 响应式文档模板,且 `working_copy` 与 `final_output` 是同一路径。普通页面自然纵向滚动、主体限制最大宽度、移动端降为单列;只有 `page_brief` 明确包含大屏、驾驶舱、单屏或固定画布意图时才允许屏幕式布局。文档模式校验会拒绝 `html/body overflow:hidden`、1920×1080 整页缩放、过矮流程图和过长横向流程。
|
||||
|
||||
### Workflow Studio runtime
|
||||
|
||||
- Candidate workflow graphs remain editable after they are loaded into the Coze-compatible canvas; the compatibility response explicitly marks the current owner as editable so node dragging is not mistaken for a read-only preview.
|
||||
- OpenAI-compatible model responses accept both `reasoning_content` and `reasoning`; both are normalized into the existing reasoning display path. Workflow-agent SSE forwards provider reasoning as an inline `<think>…</think>` trace for the UI while keeping the executable node output limited to the visible answer text. Every native LangGraph `[AIMessage|ToolMessage, metadata]` frame is additionally persisted and sent as `node.message.data.messages` without projection or length cropping, so the Studio can replay DeerFlow's tool-result, search-result and `ask_clarification` cards exactly after reconnecting.
|
||||
- OpenAI-compatible model responses accept both `reasoning_content` and `reasoning`; both are normalized into the existing reasoning display path. Models marked `supports_thinking: true` also receive `enable_thinking: true` when thinking mode is active unless an explicit `when_thinking_enabled` configuration overrides it. Workflow-agent SSE forwards provider reasoning as an inline `<think>…</think>` trace for the UI while keeping the executable node output limited to the visible answer text. Every native LangGraph `[AIMessage|ToolMessage, metadata]` frame is additionally persisted and sent as `node.message.data.messages` without projection or length cropping, so the Studio can replay DeerFlow's tool-result, search-result and `ask_clarification` cards exactly after reconnecting.
|
||||
|
||||
### AgentScope report collaboration (in progress)
|
||||
|
||||
@@ -325,7 +390,10 @@ multi-agent review revision emits `report_reset` before the replacement draft.
|
||||
Every session may also provide a bounded `custom_outline`. Markdown headings or
|
||||
numbered entries become the deterministic section plan for `detailed` and
|
||||
`multi_agent`; `basic` and `deep` receive the same outline as a guarded
|
||||
report-writing constraint. The outline is frozen in the session snapshot and
|
||||
report-writing constraint. An empty outline (the workbench default) is planned after chat collection
|
||||
via `POST /sessions/{id}/intent-outline` and filled into the write-confirmation
|
||||
form; `basic`/`quick` still generate a topic-specific skeleton at write time
|
||||
if the form is still empty. The outline is frozen in the session snapshot and
|
||||
never treated as a system instruction.
|
||||
|
||||
All owner-scoped Deep Research routes resolve the platform's asynchronous
|
||||
@@ -532,6 +600,8 @@ models:
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: gpt-4o
|
||||
api_key: $OPENAI_API_KEY
|
||||
# Complete input request ceiling; independent from the output max_tokens.
|
||||
max_input_tokens: 128000
|
||||
supports_thinking: false
|
||||
supports_vision: true
|
||||
|
||||
@@ -563,6 +633,13 @@ non-streaming calls. DeerFlow retries transient upstream responses instead of
|
||||
accepting an empty assistant turn, while genuine quota/balance failures are
|
||||
classified as non-retriable and surfaced immediately instead of being retried.
|
||||
|
||||
For models with a finite context window, set `max_input_tokens` to a safe value
|
||||
below the advertised window (for example `160000` for a 200k-token deployment).
|
||||
Immediately before every lead-agent or subagent model call, DeerFlow applies
|
||||
this ceiling to the complete payload and transiently removes/truncates the
|
||||
oldest context if needed. `max_tokens` remains the maximum generated output;
|
||||
it does not cap the input. See [Context Summarization](docs/summarization.md).
|
||||
|
||||
### Running
|
||||
|
||||
**Full Application** (from project root):
|
||||
@@ -946,10 +1023,13 @@ BGE-M3。模型列表响应只暴露名称、provider、状态等白名单字段
|
||||
## DeerFlow-local LLMWiki Wiki vector index
|
||||
|
||||
LLMWiki can mirror processed WeKnora Wiki pages into DeerFlow and search only
|
||||
processed Wiki content. A knowledge base uses exact NumPy cosine similarity
|
||||
only after its whole Wiki has completed one coherent vectorization pass; until
|
||||
then internal search calls WeKnora's Wiki-page search/list API and reads the
|
||||
matched Wiki pages. It never falls back to raw documents or chunks. Configure
|
||||
processed Wiki content. Retrieval now runs local vector and local lexical Wiki
|
||||
recall in parallel and merges independently ranked channels with RRF. A failed
|
||||
or cancelled incremental sync keeps serving the last coherent vector snapshot;
|
||||
only `rebuild_required`, a fingerprint mismatch, or an actually incomplete
|
||||
snapshot takes it offline. Before a first complete snapshot, internal search
|
||||
uses local Wiki keyword recall plus WeKnora's Wiki-page search/list API. It
|
||||
never falls back to raw documents or chunks. Configure
|
||||
`llmwiki.local_wiki_index.embedding`, then enable the local index in
|
||||
`config.yaml`. `auto_sync` defaults to true: the scheduler performs one
|
||||
immediate incremental scan at startup and then scans every configured interval
|
||||
@@ -967,6 +1047,12 @@ from the first successful response.
|
||||
Embedding failures log the upstream HTTP status and a bounded response body.
|
||||
The client never sends a `dimensions` request parameter.
|
||||
|
||||
The deployment default uses SiliconFlow's OpenAI-compatible endpoint with
|
||||
`BAAI/bge-m3` (1024 dimensions); set `SILICONFLOW_API_KEY` in `.env.local` or
|
||||
the process environment. Provider/model/base URL/dimensions are part of the
|
||||
fingerprint, so switching providers intentionally schedules a rebuild while
|
||||
keyword/Wiki fallback remains available.
|
||||
|
||||
The migration creates `llmwiki_wiki_pages`, `llmwiki_wiki_vectors`, and
|
||||
`llmwiki_wiki_sync_states`. Wiki Markdown and normalized float32 vectors are
|
||||
stored in DeerFlow's configured SQL database. Multi-worker sync is protected by
|
||||
@@ -976,7 +1062,8 @@ after the full-library readiness gate succeeds. Knowledge-base owners can click
|
||||
/api/llmwiki/knowledge-bases/{id}/vectorize`) and follow its status at
|
||||
`GET /api/llmwiki/knowledge-bases/{id}/wiki-index-status`. The response includes
|
||||
aggregate `progress` plus a `pages` list with every Wiki page's index state,
|
||||
vector-section count, timestamps, and last error. The management page renders
|
||||
vector-section count, timestamps, and last error, plus configured/indexed
|
||||
fingerprints, stale-serving state, and the effective retrieval mode. The management page renders
|
||||
the percentage and labels each article as 已向量化 / 待向量化 / 失败, so it is
|
||||
clear exactly what is searchable. Existing chunk citations remain readable
|
||||
while all new retrieval citations open Wiki pages.
|
||||
@@ -1169,6 +1256,13 @@ Wiki 向量并通过落盘导出包自动沉淀到「知识梳理」。内置技
|
||||
包保留正文、别名/标签、目录分类、双向链接、实体关系及 float32 向量。
|
||||
回流时通过 WeKnora API 重建目录/页面,向量进入 DeerFlow 本地 Wiki 索引,
|
||||
**不直接写 WeKnora 的底层向量或图数据库**。普通与助手 Wiki 使用同一个阅读组件。
|
||||
助手知识库入口只读取库统计,不再因服务重启同步扫描全库;进入具体库时先加载不含
|
||||
正文/搜索文本的轻量 Wiki 目录,当前文章正文再按 slug 单页读取,因此目录条目很多时
|
||||
也能先打开页面,且不会在浏览器和 Gateway 中同时保留全库的多份长文本。首批目录
|
||||
返回后即可阅读默认索引,后续目录分页在后台补齐;直接链接的文章会与目录并行加载。
|
||||
新建且尚未导入文件的助手知识库仍可正常进入 Wiki 管理页:空目录是合法状态,页面
|
||||
明确提供上传文件或导入普通知识库数据包的入口,不以“已有来源/Wiki 页面”作为路由
|
||||
前置条件。普通知识库的空 Wiki 阅读页同样可直接进入 Wiki 管理并手工创建首篇文章。
|
||||
|
||||
小批量 CPU 编码可配置如下,无需额外部署 HTTP 编码服务:
|
||||
|
||||
@@ -1215,6 +1309,20 @@ provider/mode,不再把“不是 local”当成状态接口错误。每次激
|
||||
问答编码查询本身仍需可用的同一模型。WeKnora 的原始文档向量不能当作生成后
|
||||
Wiki 正文的向量使用,两种文本需要分别建立各自的首次索引。
|
||||
|
||||
普通知识库检索会将“此知识库都讲了什么”“这个知识空间有哪些内容”等库级问法识别为概览请求:
|
||||
完整本地索引直接返回若干代表性 **正文**,不让宽泛提问被 `min_similarity` 过滤成零结果;尚未完成
|
||||
索引的库则通过 Wiki 页面目录取得同类正文。目录、索引和摘要页不作为概览或回退候选,也不会以页面摘要
|
||||
冒充正文命中。
|
||||
|
||||
知识空间列表的热路径也做了收敛:助手知识库的来源/Wiki/实体/关系数量由每库 4 次串行
|
||||
统计改为整批 1 条聚合查询,单纯改名不再扫描内容表;普通知识库复用 WeKnora 登录态,
|
||||
并用 15 秒短缓存合并多个页面/权限范围同时发起的目录请求,增删改库及文档变化后立即
|
||||
失效。前端关闭知识空间选择器时不再无条件拉取全量列表,也不再固定每 15 秒轮询空闲库。
|
||||
|
||||
助手向量检索先校验编码指纹和维度,再按 256 条矩阵化计算;全量扫描阶段只读取向量与
|
||||
最小页面标识,最终候选才回查正文。中文无空格问句的关键词降级会拆出有效短语,因此
|
||||
模型不兼容或暂时不可用时也不会把整句当成唯一关键词而轻易返回 0。
|
||||
|
||||
Wiki 页面新增、编辑、删除及文档上传/重解析都会立即触发一次增量检查;WeKnora
|
||||
后台稍后才完成的 Wiki 抽取由 30 秒周期补扫接管。每次成功向量化后自动把该普通
|
||||
知识库的最新完整快照同步到「知识梳理总库」,包括服务重启前已有库和新建库;来源
|
||||
@@ -1228,6 +1336,14 @@ Wiki 页面新增、编辑、删除及文档上传/重解析都会立即触发
|
||||
同步时自动归并。助手知识检索排除目录、索引和摘要页;关键词只查标题与知识正文,
|
||||
向量检索返回实际命中的正文片段,问答上下文和检索卡片均不再用摘要代替知识。
|
||||
|
||||
聊天中一旦选择普通或助手知识空间,本轮进入严格知识空间模式:Gateway 与 Lead Agent
|
||||
双层关闭 Web、浏览器、检索型 MCP、外部检索技能、ACP 与子智能体,不允许模型用常识
|
||||
补齐库内没有的事实。`llmwiki_search` 兼容名现在同时支持普通与助手知识空间,并且只能
|
||||
读取线程上下文中的已选 ID。普通 Wiki 同时执行向量与标题/摘要/正文关键词召回并以 RRF
|
||||
融合;低于向量阈值时返回关键词结果以及 raw top score、阈值过滤数和 `fallback_reason`。
|
||||
新对话选择空间后,输入框会根据库名展示 4 个可直接提问的问题;知识空间建议存在时不再
|
||||
混排全局通用推荐问题。
|
||||
|
||||
大包采用流式 JSONL 落盘、同盘临时 SQLite 分阶段读取及异步导入,避免一次性将
|
||||
整个包加载进内存。部署需预留包、暂存索引和业务数据库空间,并配置代理上传大小
|
||||
与超时。当前不宣称断点续传或 GB 级压力验收已完成。
|
||||
@@ -1263,3 +1379,17 @@ For a production image, build with `--build-arg UV_EXTRAS=gauss`.
|
||||
# 问答记忆开关
|
||||
|
||||
问答记忆默认关闭。关闭时,普通问答不会加载记忆中间件、不会注入历史记忆,也不会在回答结束后调用模型提取或写入记忆。管理员可在「设置 → 问答记忆」中显式开启;开关保存在运行目录的 `system_settings.json`,新部署或没有该字段时始终按关闭处理。
|
||||
|
||||
## 2026-09-14 我的项目与知识库验收修复
|
||||
|
||||
- 简洁模式的「我的项目」列表、策略工作台和普通问答项目恢复工作区功能导航;窄屏提供展开菜单按钮。
|
||||
- 普通问答先创建具名项目,再在项目内创建多个对话。列表读取 `/api/analysis-projects` 的 `qa` 项目,不再读取、标记或自动归入全局普通聊天。
|
||||
- 项目设置更新保留既有知识库绑定。原「项目空间」与官方入口继续可用;我的项目下问答路由为 `qa/projects/groups/*`。
|
||||
- 策略生成卡片统一先显示思考/执行过程,再显示正文;评判、方案及展开视图沿用相同顺序。
|
||||
- 完成评判但尚未勾选策略的项目可以从方案列表进入,在工作台选择后生成;已保存方案在任务修改后仍可打开。旧列表请求不再覆盖新请求状态。
|
||||
- 知识库管理页读取上游最新目录,避免远端删除后命中旧缓存;检索调用仍共享短期目录缓存。
|
||||
- 普通用户访问尚未初始化的助手知识库时显示明确空态及刷新按钮。
|
||||
|
||||
验证:后端相关回归 273 项、前端策略/项目回归 31 项通过;真实 BAAI/bge-m3 编码、指定库向量检索与草稿隔离通过;本地隔离服务的问答项目创建/分组、真实问答、方案整理、助手库创建/编辑/文件入库/向量检索/详情均通过。完整验收记录见仓库 `docs/ji-knowledge-projects-acceptance.md`。不在源码或文档中保存接口密钥。
|
||||
|
||||
2026-09-14 日常使用复查:补修项目历史入口与移动侧栏、默认智能体同步、项目加载重试、助手知识检索过期响应隔离及无结果指引;更新 ScrollArea 的 React 19 兼容修复。后端再次 273 项通过,前端 33 项通过。范围及限制见仓库 docs/daily-usage-audit-20260914.md。
|
||||
@@ -5,10 +5,13 @@ import queue
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import AsyncGenerator
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from time import perf_counter
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -47,6 +50,7 @@ from app.gateway.routers import (
|
||||
fixed_questions,
|
||||
flow_extract,
|
||||
html_page_favorites,
|
||||
html_runtime,
|
||||
intent,
|
||||
knowledge,
|
||||
knowledge_ingest,
|
||||
@@ -105,6 +109,7 @@ from app.gateway.routers import (
|
||||
uploads,
|
||||
user_preferences,
|
||||
user_prompts,
|
||||
web_app_sites,
|
||||
workflow_data_sources,
|
||||
workflow_embed,
|
||||
workflow_planning,
|
||||
@@ -529,6 +534,13 @@ _CONCURRENCY_SAMPLE_RETENTION_HOURS = _env_float(
|
||||
"CONCURRENCY_SAMPLE_RETENTION_HOURS",
|
||||
168.0, # 7 days
|
||||
)
|
||||
# Thread-pool monitoring is independent from the concurrency chart: it does no
|
||||
# database work, so it remains useful even when chart sampling is disabled.
|
||||
_THREAD_POOL_MONITOR_ENABLED = _env_bool("THREAD_POOL_MONITOR_ENABLED", True)
|
||||
_THREAD_POOL_MONITOR_INTERVAL_SECONDS = _env_float(
|
||||
"THREAD_POOL_MONITOR_INTERVAL_SECONDS",
|
||||
2.0,
|
||||
)
|
||||
# Hour (local Beijing time, 0-23) after which the scheduler force-recomputes the
|
||||
# *previous* day's snapshot once, so records that landed near midnight are fully
|
||||
# captured. Default 1 ? "???? 1 ????????".
|
||||
@@ -927,6 +939,175 @@ async def _concurrency_sampler_loop(app: FastAPI) -> None:
|
||||
_release_singleton_lock(lock_handle)
|
||||
|
||||
|
||||
def _thread_pool_snapshot(executor: Any | None) -> dict[str, int | None]:
|
||||
"""Return best-effort diagnostics for a CPython ThreadPoolExecutor."""
|
||||
if executor is None:
|
||||
return {
|
||||
"max_workers": None,
|
||||
"live_threads": None,
|
||||
"waiting_tasks": None,
|
||||
"active_tasks": None,
|
||||
"submitted_tasks": None,
|
||||
"completed_tasks": None,
|
||||
}
|
||||
try:
|
||||
waiting_tasks = executor._work_queue.qsize() # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
waiting_tasks = None
|
||||
try:
|
||||
live_threads = len(executor._threads) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
live_threads = None
|
||||
snapshot = {
|
||||
"max_workers": getattr(executor, "_max_workers", None),
|
||||
"live_threads": live_threads,
|
||||
"waiting_tasks": waiting_tasks,
|
||||
"active_tasks": None,
|
||||
"submitted_tasks": None,
|
||||
"completed_tasks": None,
|
||||
}
|
||||
metrics = getattr(executor, "metrics_snapshot", None)
|
||||
if callable(metrics):
|
||||
snapshot.update(metrics())
|
||||
return snapshot
|
||||
|
||||
|
||||
class _InstrumentedThreadPoolExecutor(ThreadPoolExecutor):
|
||||
"""Default executor with task-state counters for ``asyncio.to_thread``."""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._metrics_lock = threading.Lock()
|
||||
self._active_tasks = 0
|
||||
self._queued_tasks = 0
|
||||
self._submitted_tasks = 0
|
||||
self._completed_tasks = 0
|
||||
|
||||
def submit(self, fn: Any, /, *args: Any, **kwargs: Any): # type: ignore[override]
|
||||
with self._metrics_lock:
|
||||
self._queued_tasks += 1
|
||||
self._submitted_tasks += 1
|
||||
|
||||
def tracked() -> Any:
|
||||
with self._metrics_lock:
|
||||
self._queued_tasks -= 1
|
||||
self._active_tasks += 1
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
finally:
|
||||
with self._metrics_lock:
|
||||
self._active_tasks -= 1
|
||||
self._completed_tasks += 1
|
||||
|
||||
try:
|
||||
return super().submit(tracked)
|
||||
except Exception:
|
||||
with self._metrics_lock:
|
||||
self._queued_tasks -= 1
|
||||
raise
|
||||
|
||||
def metrics_snapshot(self) -> dict[str, int]:
|
||||
with self._metrics_lock:
|
||||
return {
|
||||
"active_tasks": self._active_tasks,
|
||||
"waiting_tasks": self._queued_tasks,
|
||||
"submitted_tasks": self._submitted_tasks,
|
||||
"completed_tasks": self._completed_tasks,
|
||||
}
|
||||
|
||||
|
||||
def _install_instrumented_default_executor(app: FastAPI) -> None:
|
||||
"""Install tracking before startup code can submit ``asyncio.to_thread`` work."""
|
||||
loop = asyncio.get_running_loop()
|
||||
existing = getattr(loop, "_default_executor", None)
|
||||
if existing is not None:
|
||||
app.state.default_thread_executor_instrumented = False
|
||||
logger.warning("asyncio 默认线程池已提前创建,无法统计活跃任务数")
|
||||
return
|
||||
|
||||
# Most default-executor work in Gateway is blocking network/file I/O
|
||||
# (notably requests-based WebSearch), so the CPU-oriented stdlib default is
|
||||
# too small under high chat concurrency. Keep it configurable for smaller
|
||||
# deployments while using the validated 100-conversation setting here.
|
||||
max_workers = max(1, int(os.getenv("DEER_FLOW_ASYNCIO_THREAD_POOL_SIZE", "200")))
|
||||
executor = _InstrumentedThreadPoolExecutor(
|
||||
max_workers=max_workers,
|
||||
thread_name_prefix="gateway-default",
|
||||
)
|
||||
loop.set_default_executor(executor)
|
||||
app.state.default_thread_executor_instrumented = True
|
||||
logger.info("已安装 asyncio 默认线程池任务监控:最大线程=%d", max_workers)
|
||||
|
||||
|
||||
async def _thread_pool_monitor_loop() -> None:
|
||||
"""Log Chinese, best-effort snapshots of thread limits and waiting work."""
|
||||
if not _THREAD_POOL_MONITOR_ENABLED:
|
||||
logger.info("线程池监控已由 THREAD_POOL_MONITOR_ENABLED 关闭")
|
||||
return
|
||||
|
||||
interval = max(1.0, _THREAD_POOL_MONITOR_INTERVAL_SECONDS)
|
||||
logger.info("线程池监控已启动:采样间隔=%.1f秒", interval)
|
||||
while True:
|
||||
try:
|
||||
# Installed AnyIO exposes borrowed_tokens as its active-token
|
||||
# count, rather than an active_tokens attribute.
|
||||
limiter = anyio.to_thread.current_default_thread_limiter()
|
||||
limiter_stats = limiter.statistics()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
asyncio_default = _thread_pool_snapshot(getattr(loop, "_default_executor", None))
|
||||
|
||||
from deerflow.agents.memory.v1.updater import _SYNC_MEMORY_UPDATER_EXECUTOR
|
||||
from deerflow.mcp.tools import _SYNC_TOOL_EXECUTOR
|
||||
from deerflow.subagents.executor import _scheduler_pool
|
||||
|
||||
subagent = _thread_pool_snapshot(_scheduler_pool)
|
||||
mcp = _thread_pool_snapshot(_SYNC_TOOL_EXECUTOR)
|
||||
memory = _thread_pool_snapshot(_SYNC_MEMORY_UPDATER_EXECUTOR)
|
||||
# Emit compact, independently searchable records. PowerShell's
|
||||
# native-command redirection can physically wrap a very long log
|
||||
# line, which otherwise hides the latter metrics from grep/tail.
|
||||
logger.info(
|
||||
"[线程池监控][AnyIO] 总=%s 活跃=%s 可用=%s 等待=%s",
|
||||
limiter.total_tokens,
|
||||
limiter.borrowed_tokens,
|
||||
limiter.available_tokens,
|
||||
limiter_stats.tasks_waiting,
|
||||
)
|
||||
logger.info(
|
||||
"[线程池监控][asyncio] 最大=%s 存活=%s 活跃=%s 等待=%s 提交=%s 完成=%s",
|
||||
asyncio_default["max_workers"],
|
||||
asyncio_default["live_threads"],
|
||||
asyncio_default["active_tasks"],
|
||||
asyncio_default["waiting_tasks"],
|
||||
asyncio_default["submitted_tasks"],
|
||||
asyncio_default["completed_tasks"],
|
||||
)
|
||||
logger.info(
|
||||
"[线程池监控][子智能体] 最大=%s 存活=%s 等待=%s",
|
||||
subagent["max_workers"],
|
||||
subagent["live_threads"],
|
||||
subagent["waiting_tasks"],
|
||||
)
|
||||
logger.info(
|
||||
"[线程池监控][MCP] 最大=%s 存活=%s 等待=%s",
|
||||
mcp["max_workers"],
|
||||
mcp["live_threads"],
|
||||
mcp["waiting_tasks"],
|
||||
)
|
||||
logger.info(
|
||||
"[线程池监控][记忆] 最大=%s 存活=%s 等待=%s",
|
||||
memory["max_workers"],
|
||||
memory["live_threads"],
|
||||
memory["waiting_tasks"],
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("线程池监控采样失败(不影响服务)")
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
def _warn_if_multi_worker() -> None:
|
||||
"""?? uvicorn / gunicorn ? worker ???? ERROR ???
|
||||
|
||||
@@ -984,6 +1165,8 @@ def _warn_if_multi_worker() -> None:
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan handler."""
|
||||
|
||||
_install_instrumented_default_executor(app)
|
||||
|
||||
# ?????? worker ?? ?? ?? AI ?? / ? agent ???????
|
||||
# ??????? worker ???????? ERROR ?????
|
||||
_warn_if_multi_worker()
|
||||
@@ -1269,6 +1452,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
except Exception:
|
||||
logger.exception("Concurrency sampler failed to start (non-fatal)")
|
||||
|
||||
try:
|
||||
app.state.thread_pool_monitor_task = asyncio.create_task(_thread_pool_monitor_loop())
|
||||
except Exception:
|
||||
logger.exception("线程池监控启动失败(不影响服务)")
|
||||
|
||||
# Start IM channel service if any channels are configured
|
||||
try:
|
||||
from app.channels.service import start_channel_service
|
||||
@@ -1352,6 +1540,16 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
except Exception:
|
||||
logger.exception("Concurrency sampler shutdown error")
|
||||
|
||||
thread_pool_monitor_task = getattr(app.state, "thread_pool_monitor_task", None)
|
||||
if thread_pool_monitor_task is not None:
|
||||
thread_pool_monitor_task.cancel()
|
||||
try:
|
||||
await thread_pool_monitor_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("线程池监控停止失败")
|
||||
|
||||
# ?? 7 ????AI ?? graph ???? checkpointer / in-flight task?
|
||||
# thread ??? LangGraph Server ? checkpointer ???lifespan ???
|
||||
# ``langgraph_runtime()`` ? contextmanager ???????????????
|
||||
@@ -1618,6 +1816,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Artifacts API is mounted at /api/threads/{thread_id}/artifacts
|
||||
app.include_router(artifacts.router)
|
||||
|
||||
# Web App site artifacts (multi-page site / flowchart / map) with signed
|
||||
# public static hosting at /api/public/site-preview/{token}/
|
||||
app.include_router(web_app_sites.router)
|
||||
|
||||
# Uploads API is mounted at /api/threads/{thread_id}/uploads
|
||||
app.include_router(uploads.router)
|
||||
|
||||
@@ -1762,6 +1964,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Public embed Q&A status polling (/api/public/embed/*)
|
||||
app.include_router(public_embed.router)
|
||||
app.include_router(public_config.router)
|
||||
app.include_router(html_runtime.router)
|
||||
app.include_router(html_page_favorites.router)
|
||||
|
||||
# Product knowledge base API is mounted at /api/knowledge
|
||||
|
||||
@@ -367,10 +367,14 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
# Skill distillation and the single global assistant knowledge base.
|
||||
# Both repositories live in ``deerflow.*``; only this composition layer
|
||||
# wires them to Gateway executors and remote adapters.
|
||||
from deerflow.persistence.assistant_knowledge import make_assistant_knowledge_store
|
||||
from deerflow.persistence.assistant_knowledge import (
|
||||
make_assistant_knowledge_store,
|
||||
register_assistant_knowledge_store,
|
||||
)
|
||||
from deerflow.persistence.skill_knowledge import make_skill_knowledge_store
|
||||
|
||||
app.state.assistant_knowledge_store = make_assistant_knowledge_store(sf)
|
||||
register_assistant_knowledge_store(app.state.assistant_knowledge_store)
|
||||
app.state.skill_knowledge_store = make_skill_knowledge_store(sf)
|
||||
if app.state.skill_knowledge_store is not None and app.state.assistant_knowledge_store is not None:
|
||||
from app.gateway.skill_knowledge_job_dispatcher import SkillKnowledgeJobDispatcher
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
"""LLMWiki retrieval helpers for normal DeerFlow chat runs.
|
||||
"""Knowledge-space selection helpers for normal DeerFlow chat runs.
|
||||
|
||||
The public chat API accepts DeerFlow-local LLMWiki knowledge-base mapping ids.
|
||||
When WeKnora mode is enabled, this module resolves those local ids under the
|
||||
current DeerFlow user's permissions, searches WeKnora once before the model
|
||||
call, and attaches both:
|
||||
The public chat API accepts DeerFlow-local knowledge-base ids. Selecting a
|
||||
space is a scope decision, not an instruction to retrieve immediately: the
|
||||
agent receives the selected ids and decides whether to call ``llmwiki_search``
|
||||
for the user's actual question. This keeps unnecessary retrieval out of the
|
||||
request path and leaves the tool result in the normal conversation trace.
|
||||
|
||||
* a hidden prompt with numbered context snippets, and
|
||||
* structured citation sources for the existing reference panel.
|
||||
|
||||
The helper lives in ``app.gateway`` because it needs Gateway auth state and the
|
||||
DeerFlow-owned mapping store. Harness code remains app-import free.
|
||||
The helper lives in ``app.gateway`` because it needs Gateway request shapes.
|
||||
Harness code remains app-import free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,14 +18,11 @@ from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi import Request
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from app.gateway.deps import get_optional_user_from_request
|
||||
from app.gateway.utils import sanitize_log_param
|
||||
from deerflow.config.agents_config import load_agent_config, validate_agent_id
|
||||
from deerflow.integrations.weknora.local_index.search import WikiIndexError
|
||||
from deerflow.integrations.weknora.runtime import get_resolved_llmwiki_runtime
|
||||
from deerflow.persistence.llmwiki import LlmWikiStore
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
@@ -249,11 +244,15 @@ def _source_from_assistant_result(result: dict[str, Any], index: int) -> dict[st
|
||||
|
||||
def _build_prompt(query: str, sources: list[dict[str, Any]]) -> str:
|
||||
if not sources:
|
||||
return "当前用户选择了知识库,但 DeerFlow 未检索到可用片段。请直接说明没有检索到可用背景资料,不要编造知识库内容或引用编号。"
|
||||
return (
|
||||
"当前用户选择了知识空间,本轮只能使用所选知识空间,禁止使用 Web、MCP、技能或其他外部检索来源。"
|
||||
"DeerFlow 未检索到可用片段;请直接说明所选知识空间中暂未找到相关资料,不要用外部知识补答,"
|
||||
"不要编造知识库内容或引用编号。"
|
||||
)
|
||||
|
||||
lines = [
|
||||
"当前用户选择了知识库。DeerFlow 已在模型回答前自动检索到以下背景资料。",
|
||||
"请优先依据这些资料回答;如果资料不足,请明确说明不足之处,不要编造。",
|
||||
"本轮只能依据所选知识空间回答,禁止使用 Web、MCP、技能或其他外部检索来源;如果资料不足,请明确说明不足之处,不要编造或用常识补齐。",
|
||||
"引用规则:正文关键结论必须使用 [1]、[2] 这样的编号引用下方资料;只能引用存在的编号;不要在正文末尾重复输出完整参考文献列表,前端会展示引用卡片。",
|
||||
"",
|
||||
f"用户问题:{query}",
|
||||
@@ -278,108 +277,90 @@ def _build_prompt(query: str, sources: list[dict[str, Any]]) -> str:
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def _build_selection_prompt(knowledge_bases: list[tuple[str, str]], assistant_count: int) -> str:
|
||||
"""Explain the active user-selected scope without retrieving its contents."""
|
||||
|
||||
lines = [
|
||||
"<selected_knowledge_space>",
|
||||
"本轮用户已勾选知识库。用户说“这里”“这个库”“库里”“里面”等指代时,默认就是指下列已选知识库,不要要求用户重复说明。",
|
||||
]
|
||||
if knowledge_bases:
|
||||
lines.append("已选 LLMWiki 知识库:" + "、".join(name for _, name in knowledge_bases))
|
||||
lines.append(
|
||||
"调用 llmwiki_search 时必须传入 knowledge_base_id,且每次只能传下列其中一个 ID;"
|
||||
"要分别比较多个库时,为每个 ID 发起一条工具调用,不能把库名仅写在 query 中:"
|
||||
+ "、".join(f"{name}({mapping_id})" for mapping_id, name in knowledge_bases)
|
||||
)
|
||||
if knowledge_bases:
|
||||
lines.append(
|
||||
"HIGH-PRIORITY TOOL POLICY: llmwiki_search already returns the full content of its matched Wiki page. NEVER call llmwiki_read_wiki_page with the same knowledge_base_id + wiki_slug as a search hit; it duplicates the same information. Use llmwiki_read_wiki_page only for a DISTINCT wiki_slug found in the search hit's related_entity_slugs or inline [[slug|label]] links. Never invent a slug."
|
||||
)
|
||||
lines.append(
|
||||
"<llmwiki_tool_workflow>\n"
|
||||
"Use the two LLMWiki tools as one deliberate research workflow, not as alternatives:\n"
|
||||
"1. llmwiki_search = locate the entry page and obtain its full page content, wiki_slug, and related_entity_slugs. Use that returned content as the entry overview.\n"
|
||||
"1a. llmwiki_browse_wiki_index = browse a knowledge base's Wiki directory and its maintained Index introduction. This is MANDATORY as the first and ONLY LLMWiki tool call when the user asks what a library/knowledge base contains, asks for the library's summary/overview, or asks for its directory/index/catalog. Do not use llmwiki_search or llmwiki_read_wiki_page for those overview questions. If multiple knowledge bases are selected, call llmwiki_browse_wiki_index once for EACH selected knowledge_base_id, then compare their returned Index introductions and directory entries. It returns exactly one complete Wiki Index card—the same Index summary and below-summary directory content shown in the knowledge-base management UI. Only use search/page reading in a later turn when the user explicitly asks for detail about a specific topic or entry.\n"
|
||||
"2. Do NOT read the search-hit page itself: llmwiki_read_wiki_page with the same knowledge_base_id + wiki_slug is forbidden because it duplicates the search result.\n"
|
||||
"3. llmwiki_read_wiki_page = recursive deep-research tool for DISTINCT linked pages only. After every page read, inspect that page's in_links, out_links, and related_entity_slugs; if a directly relevant person, company, organization, concept, event, or relationship is linked, read that distinct page too. Continue this graph traversal in repeated waves until NO relevant unvisited links remain. Do not stop merely because the current evidence appears sufficient to answer the user. Prefer linked-page reads over guessing from a snippet.\n"
|
||||
"4. Only after the relevant graph is exhausted may you synthesize. Cite and distinguish what each page establishes.\n"
|
||||
"DETAILED KNOWLEDGE-BASE ANSWERING: selected-space questions require multi-page, multi-round research by default. Before writing a final answer, repeatedly call llmwiki_read_wiki_page to deepen the evidence. Do not say that you could continue checking, that the search result is already enough, or that you are considering another page: call the tool instead. Answer only after the relevant linked pages have actually been explored.\n"
|
||||
"AVOID REDUNDANT READS: the search-hit page itself is never a valid llmwiki_read_wiki_page target in this workflow. Treat it as the entry overview and read only distinct linked pages. Never read the same knowledge_base_id + wiki_slug twice in one turn.\n"
|
||||
"RECURSION SAFETY: maintain a visited set of wiki_slug values; never revisit a page, never invent a slug, and follow only links that materially help answer the question. Do not expand unrelated links merely because they exist.\n"
|
||||
"</llmwiki_tool_workflow>"
|
||||
)
|
||||
if assistant_count:
|
||||
lines.append(f"另有 {assistant_count} 个已选助手知识库。")
|
||||
lines.extend(
|
||||
[
|
||||
"回答涉及该知识库的内容、范围、目录或事实时,先调用 llmwiki_search 检索;不要把“已选知识库”本身误当作检索结果。",
|
||||
"【高优先级工具策略】本轮 llmwiki_read_wiki_page 已可用。llmwiki_search 返回任何可用 wiki_slug 后,默认应在回答前调用该工具读取页面;"
|
||||
"凡是实体、概念、关系、比较、因果、范围或需要展开细节的问题必须调用。只有纯标题或一行片段即可回答的简单查询,才允许仅使用搜索片段。"
|
||||
"该工具的 knowledge_base_id 必须与该检索结果的 knowledge_base_id 相同,wiki_slug 使用检索结果的 wiki_slug,或上一页返回的 in_links/out_links 中的 slug。"
|
||||
"只在确有实体页/关系页可读时调用,不能虚构 wiki_slug。",
|
||||
"只在本轮存在知识库勾选时适用此规则。",
|
||||
"</selected_knowledge_space>",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def build_llmwiki_rag_context(
|
||||
*,
|
||||
request: Request,
|
||||
body: Any,
|
||||
graph_input: dict[str, Any],
|
||||
) -> LlmWikiRagContext:
|
||||
"""Resolve selected knowledge-space ids without pre-retrieving content.
|
||||
|
||||
``llmwiki_search`` is deliberately agent-driven. It validates the
|
||||
selection and performs the scoped local Wiki search only if the model calls
|
||||
it, avoiding a duplicate search before the first model turn.
|
||||
"""
|
||||
# ``llmwiki_search`` is a synchronous LangChain tool that delegates its
|
||||
# async retrieval work back to this Gateway event loop.
|
||||
from deerflow.tools.builtins.llmwiki_search_tool import register_llmwiki_search_runtime_loop
|
||||
|
||||
register_llmwiki_search_runtime_loop()
|
||||
selected_ids = resolve_requested_llmwiki_knowledge_base_ids(body)
|
||||
assistant_selected_ids = resolve_requested_assistant_knowledge_base_ids(body)
|
||||
if not selected_ids and not assistant_selected_ids:
|
||||
return LlmWikiRagContext([], [], 0, "", [], "")
|
||||
|
||||
query = _latest_human_query(graph_input)
|
||||
if not query:
|
||||
return LlmWikiRagContext(selected_ids, assistant_selected_ids, 0, "", [], "")
|
||||
|
||||
sources: list[dict[str, Any]] = []
|
||||
|
||||
runtime = get_resolved_llmwiki_runtime(getattr(request.app.state, "config", None))
|
||||
local_config = request.app.state.config.llmwiki.local_wiki_index
|
||||
user_id, is_admin = await _actor(request)
|
||||
|
||||
if selected_ids:
|
||||
llmwiki_available = (local_config.enabled and local_config.internal_search_enabled) or runtime.weknora_enabled
|
||||
store = _store(request)
|
||||
if not llmwiki_available or store is None:
|
||||
if not assistant_selected_ids:
|
||||
return LlmWikiRagContext(selected_ids, assistant_selected_ids, 0, "", [], query)
|
||||
else:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for mapping_id in selected_ids:
|
||||
row = await store.get_authorized(mapping_id, user_id, write=False, is_admin=is_admin)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail=f"Knowledge base {mapping_id} not found")
|
||||
rows.append(row)
|
||||
search_service = getattr(request.app.state, "llmwiki_retrieval_service", None)
|
||||
if search_service is None:
|
||||
if not assistant_selected_ids:
|
||||
return LlmWikiRagContext(
|
||||
selected_ids,
|
||||
assistant_selected_ids,
|
||||
0,
|
||||
"Wiki 检索暂不可用。请明确说明系统检索不可用,不要表述为资料不存在。",
|
||||
[],
|
||||
query,
|
||||
)
|
||||
try:
|
||||
payload = (
|
||||
await search_service.search(query, rows, top_k_pages=_MAX_SOURCES)
|
||||
if search_service is not None
|
||||
else {"results": []}
|
||||
)
|
||||
except WikiIndexError as exc:
|
||||
logger.warning(
|
||||
"Wiki-only pre-retrieval failed user=%s kb_count=%s code=%s",
|
||||
sanitize_log_param(user_id),
|
||||
len(rows),
|
||||
exc.code,
|
||||
)
|
||||
if not assistant_selected_ids:
|
||||
return LlmWikiRagContext(
|
||||
selected_ids,
|
||||
assistant_selected_ids,
|
||||
0,
|
||||
f"Wiki 检索暂不可用({exc.code})。请明确说明系统检索状态,不要声称知识库没有资料。",
|
||||
[],
|
||||
query,
|
||||
)
|
||||
payload = {"results": []}
|
||||
mapping_by_id = {str(row["id"]): row for row in rows}
|
||||
for result in payload.get("results") or []:
|
||||
mapping = mapping_by_id.get(str(result.get("knowledge_base_id") or ""))
|
||||
if mapping is None:
|
||||
continue
|
||||
source = _source_from_result(result, mapping, len(sources) + 1)
|
||||
if not str(source.get("content") or "").strip():
|
||||
continue
|
||||
sources.append(source)
|
||||
if len(sources) >= _MAX_SOURCES:
|
||||
break
|
||||
|
||||
if assistant_selected_ids and len(sources) < _MAX_SOURCES:
|
||||
assistant_store = _assistant_store(request)
|
||||
if assistant_store is not None:
|
||||
rows = await assistant_store.search_pages(
|
||||
base_ids=assistant_selected_ids,
|
||||
query=query,
|
||||
limit=_MAX_SOURCES - len(sources),
|
||||
include_chunks=True,
|
||||
embedding_client=getattr(request.app.state, "llmwiki_embedding", None),
|
||||
)
|
||||
for row in rows:
|
||||
sources.append(_source_from_assistant_result(row, len(sources) + 1))
|
||||
if len(sources) >= _MAX_SOURCES:
|
||||
break
|
||||
|
||||
selected_knowledge_bases: list[tuple[str, str]] = []
|
||||
store = _store(request)
|
||||
if selected_ids and store is not None:
|
||||
user_id, is_admin = await _actor(request)
|
||||
for mapping_id in selected_ids:
|
||||
row = await store.get_authorized(mapping_id, user_id, write=False, is_admin=is_admin)
|
||||
if row is not None:
|
||||
name = str(row.get("name") or "").strip()
|
||||
if name:
|
||||
selected_knowledge_bases.append((str(row.get("id") or mapping_id), name[:120]))
|
||||
prompt = _build_selection_prompt(selected_knowledge_bases, len(assistant_selected_ids)) if (selected_ids or assistant_selected_ids) else ""
|
||||
return LlmWikiRagContext(
|
||||
selected_ids,
|
||||
assistant_selected_ids,
|
||||
len(sources),
|
||||
_build_prompt(query, sources),
|
||||
sources,
|
||||
query,
|
||||
0,
|
||||
prompt,
|
||||
[],
|
||||
_latest_human_query(graph_input),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
description: AI 写作流程的素材收集专家,负责为写作任务生成检索关键词、用配置的技能编排检索、并对检索回来的素材按相关度排序筛选
|
||||
description: AI 写作流程的素材收集专家,负责为写作任务生成检索关键词、从全部启用技能中自由编排检索、并对检索回来的素材按相关度排序筛选
|
||||
id: ai-writing-researcher
|
||||
name: 素材收集专家
|
||||
# 通用检索使用的技能(按顺序编排)。默认配置内置「知识库检索」;
|
||||
# admin/用户可在智能体管理页替换为其它检索技能或追加多个。
|
||||
# 通用检索不再使用这里的 skills 白名单:素材收集从全部启用技能里由模型按
|
||||
# 写作意图自由挑选(内置「知识库检索」始终保底)。此字段仅保留兼容旧版。
|
||||
skills:
|
||||
- knowledge-base-search
|
||||
@@ -1,11 +1,10 @@
|
||||
"""深度研究「收集员」内置 agent 的启动补建 seeder。
|
||||
"""深度研究内置 agent 的启动补建 seeder。
|
||||
|
||||
与 ``_ai_writing_seed.py`` / ``_roundtable_seed.py`` 完全同构:资源文件
|
||||
(``_deep_research_seed_assets/deep-research-collector/``)是发布默认值的
|
||||
唯一可信源,seeder 只在 ``.deer-flow/agents/deep-research-collector/``
|
||||
目录缺失时字节级复制,**绝不覆盖**已存在目录(尊重 admin 在智能体管理页
|
||||
的现场修改)。线上调优 SOUL 后请把改动写回资源文件,否则新机器补建出来
|
||||
的还是旧版。
|
||||
(``_deep_research_seed_assets/<agent-id>/``)是发布默认值的唯一可信源,
|
||||
seeder 只在 ``.deer-flow/agents/<agent-id>/`` 目录缺失时字节级复制,
|
||||
**绝不覆盖**已存在目录(尊重 admin 在智能体管理页的现场修改)。线上调优
|
||||
SOUL 后请把改动写回资源文件,否则新机器补建出来的还是旧版。
|
||||
|
||||
调用点:启动 lifespan(``app/gateway/app.py``),必须在 ``_sync_legacy_agents``
|
||||
**之前**——后者会把目录 upsert 进 agents 表(`user_id IS NULL` 的内置
|
||||
@@ -22,6 +21,11 @@ from deerflow.config.paths import get_paths
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEEP_RESEARCH_COLLECTOR_AGENT_ID = "deep-research-collector"
|
||||
DEEP_RESEARCH_PLANNER_AGENT_ID = "deep-research-planner"
|
||||
_DEEP_RESEARCH_AGENT_IDS = (
|
||||
DEEP_RESEARCH_COLLECTOR_AGENT_ID,
|
||||
DEEP_RESEARCH_PLANNER_AGENT_ID,
|
||||
)
|
||||
|
||||
_ASSETS_DIR = Path(__file__).parent / "_deep_research_seed_assets"
|
||||
_SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
|
||||
@@ -57,8 +61,8 @@ def _ensure_one(agent_id: str) -> bool:
|
||||
|
||||
|
||||
def ensure_deep_research_collector_agent() -> list[str]:
|
||||
"""Seed the deep-research collector agent if its on-disk directory is
|
||||
missing. Idempotent — fast no-op when it exists.
|
||||
"""Seed built-in deep-research agents if their on-disk directories are
|
||||
missing. Idempotent — fast no-op when they exist.
|
||||
|
||||
Returns the list of agent ids just created (empty when nothing needed
|
||||
seeding). Errors are logged but never raised — a seed failure must not
|
||||
@@ -66,13 +70,18 @@ def ensure_deep_research_collector_agent() -> list[str]:
|
||||
"""
|
||||
created: list[str] = []
|
||||
try:
|
||||
if _ensure_one(DEEP_RESEARCH_COLLECTOR_AGENT_ID):
|
||||
created.append(DEEP_RESEARCH_COLLECTOR_AGENT_ID)
|
||||
for agent_id in _DEEP_RESEARCH_AGENT_IDS:
|
||||
if _ensure_one(agent_id):
|
||||
created.append(agent_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to seed deep-research collector agent")
|
||||
logger.exception("Failed to seed deep-research agents")
|
||||
if created:
|
||||
logger.info("Seeded missing deep-research collector agent: %s", ", ".join(created))
|
||||
logger.info("Seeded missing deep-research agents: %s", ", ".join(created))
|
||||
return created
|
||||
|
||||
|
||||
__all__ = ["DEEP_RESEARCH_COLLECTOR_AGENT_ID", "ensure_deep_research_collector_agent"]
|
||||
__all__ = [
|
||||
"DEEP_RESEARCH_COLLECTOR_AGENT_ID",
|
||||
"DEEP_RESEARCH_PLANNER_AGENT_ID",
|
||||
"ensure_deep_research_collector_agent",
|
||||
]
|
||||
Loaded 100 of 249 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user