Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c933a8f19 | ||
|
|
ea9dcff9c0 | ||
|
|
8bc8b0e082 | ||
|
|
4f7f07504a | ||
|
|
3ca5528640 | ||
|
|
f9b6d7e322 |
No files matched your search
+21
-9
@@ -88,20 +88,27 @@ skills/
|
|||||||
hermes-agent-main/
|
hermes-agent-main/
|
||||||
.gitignore
|
.gitignore
|
||||||
offline-backend-20260512/backend/tests/*
|
offline-backend-20260512/backend/tests/*
|
||||||
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned.
|
# Keep wx common-agent category configuration regressions versioned.
|
||||||
!offline-backend-20260512/backend/tests/test_gauss_database.py
|
!offline-backend-20260512/backend/tests/test_wx_common_agents.py
|
||||||
|
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned.
|
||||||
|
!offline-backend-20260512/backend/tests/test_gauss_database.py
|
||||||
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
||||||
!offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py
|
!offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_knowledge_live_embedding.py
|
||||||
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
||||||
# Keep formal Word OOXML and embedded-font regression coverage versioned.
|
# Keep formal Word OOXML and embedded-font regression coverage versioned.
|
||||||
!offline-backend-20260512/backend/tests/test_word_export.py
|
!offline-backend-20260512/backend/tests/test_word_export.py
|
||||||
# Keep generated-file library aggregation and filtering regression coverage versioned.
|
# Keep generated-file library aggregation and filtering regression coverage versioned.
|
||||||
!offline-backend-20260512/backend/tests/test_artifact_library.py
|
!offline-backend-20260512/backend/tests/test_artifact_library.py
|
||||||
|
# Keep Web App site artifact (multi-page site) regression coverage versioned.
|
||||||
|
!offline-backend-20260512/backend/tests/test_web_app_sites.py
|
||||||
# Keep the model configuration regression coverage versioned even though
|
# Keep the model configuration regression coverage versioned even though
|
||||||
# this deployment repository otherwise ignores newly added backend tests.
|
# this deployment repository otherwise ignores newly added backend tests.
|
||||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
!offline-backend-20260512/backend/tests/test_model_management.py
|
||||||
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
|
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
|
||||||
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
|
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
|
||||||
|
# Keep strategy-selection built-in agent seed and safety-contract regressions versioned.
|
||||||
|
!offline-backend-20260512/backend/tests/test_strategy_selection_seed.py
|
||||||
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
||||||
!offline-backend-20260512/backend/tests/test_fixed_questions.py
|
!offline-backend-20260512/backend/tests/test_fixed_questions.py
|
||||||
# Keep configurable es_query system-prompt routing regression coverage versioned.
|
# Keep configurable es_query system-prompt routing regression coverage versioned.
|
||||||
@@ -110,6 +117,7 @@ offline-backend-20260512/backend/tests/*
|
|||||||
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py
|
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py
|
||||||
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py
|
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py
|
||||||
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
|
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_llmwiki_encoder_runtime.py
|
||||||
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
|
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
|
||||||
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
|
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
|
||||||
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
|
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
|
||||||
@@ -140,6 +148,10 @@ offline-backend-20260512/backend/tests/*
|
|||||||
!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py
|
!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py
|
||||||
# LLM call timeout retry classification regression coverage.
|
# LLM call timeout retry classification regression coverage.
|
||||||
!offline-backend-20260512/backend/tests/test_llm_error_handling_middleware.py
|
!offline-backend-20260512/backend/tests/test_llm_error_handling_middleware.py
|
||||||
|
# Adaptive context recovery, large-payload and real-adapter streaming regressions.
|
||||||
|
!offline-backend-20260512/backend/tests/test_context_recovery.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_context_recovery_transport.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_summarization_multiturn.py
|
||||||
# Writing-mode pipeline conversation-material regressions.
|
# Writing-mode pipeline conversation-material regressions.
|
||||||
!offline-backend-20260512/backend/tests/test_deep_research_report_tool.py
|
!offline-backend-20260512/backend/tests/test_deep_research_report_tool.py
|
||||||
# Report collaboration (RC-BE-000–018) regression coverage.
|
# Report collaboration (RC-BE-000–018) regression coverage.
|
||||||
@@ -176,10 +188,10 @@ docs/backups
|
|||||||
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
|
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
|
||||||
!frontend-web/src/components/workspace/skills/
|
!frontend-web/src/components/workspace/skills/
|
||||||
|
|
||||||
# uv lockfile (not tracked per project preference)
|
# uv lockfile (not tracked per project preference)
|
||||||
offline-backend-20260512/backend/uv.lock
|
offline-backend-20260512/backend/uv.lock
|
||||||
|
|
||||||
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
|
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
|
||||||
|
|
||||||
# Local-only scratch directory (not committed)
|
# Local-only scratch directory (not committed)
|
||||||
0622/
|
0622/
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Web App 产物模式:多页面站点 / 流程图 / 地图(可降级 + 独立页面风格)
|
||||||
|
|
||||||
|
## 总体架构
|
||||||
|
|
||||||
|
让智能体在沙箱里产出**完整多页面站点**(site.json 清单 + src/pages + public 数据),平台统一构建(预置 node_modules,永不 npm install)与受控静态托管,前端新注册一个**风格独立**的「站点预览」路由页。
|
||||||
|
|
||||||
|
**两条铁律(本次新增的核心原则)**:
|
||||||
|
1. **零侵入**:新页面不复用现有工作台风格/布局组件,走独立路由 + 独立样式表;对已有代码的修改仅限「一个带开关的入口按钮」。
|
||||||
|
2. **可降级**:沙箱/模板/node/构建链任何一环部署失败,Web App 功能整体退回"不可用"状态并优雅提示,智能体回退到现有单页 HTML 模式,**现有功能零影响**。
|
||||||
|
|
||||||
|
```
|
||||||
|
智能体(技能 web-app)
|
||||||
|
0. 检查可用性:/mnt/web-app-template 缺失 → 回退现有单页 HTML 模式(行为与今天完全一致)
|
||||||
|
1. scaffold → outputs/my-site/(排除 node_modules/dist)
|
||||||
|
2. 写 src/pages/*.tsx、public/data.geojson、编辑 site.json
|
||||||
|
3. build_site.py build → 软链/junction 预置 node_modules → vite build → dist/(失败自修复 ≤3 次)
|
||||||
|
4. present_files site.json
|
||||||
|
平台(Gateway,全部能力探测 + 开关保护)
|
||||||
|
GET /api/web-app/capability # 可用性探测(配置/模板/node_modules/node 四项检查)
|
||||||
|
GET /api/threads/{tid}/sites # 站点列表 + 构建状态
|
||||||
|
POST /api/threads/{tid}/sites/{name}/build # 触发构建
|
||||||
|
GET /api/threads/{tid}/sites/{name}/build-status
|
||||||
|
POST /api/threads/{tid}/sites/{name}/preview-url → 签发短时效签名 URL
|
||||||
|
GET /api/public/site-preview/{token}/{path} # 静态托管 dist/
|
||||||
|
前端(独立路由 + 独立风格)
|
||||||
|
#/sites/:threadId/:siteName # SiteStudioPage:全新视觉风格,无现有 chrome
|
||||||
|
产物面板 site.json →「打开站点预览」按钮(仅 capability 可用时渲染)
|
||||||
|
```
|
||||||
|
|
||||||
|
已确认决策:混合构建(技能自修复 + 平台按钮兜底);预览工作台先行;GeoJSON 优先 + 可配置底图。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、页面风格:独立设计系统(不用现有风格)
|
||||||
|
|
||||||
|
新页面 `SiteStudioPage` 做成**深色"预览工作室"**风格,视觉上与现有工作台完全区隔:
|
||||||
|
|
||||||
|
- **独立路由**:直接注册在 `App.tsx` 顶层(`/share/:shareCode`、`/login` 同款先例),**不进** `PageLayout`/`WorkspaceLayout`/`Header`/`PageSidebar`——零现有 chrome 继承,零布局耦合。
|
||||||
|
- **独立样式**:新建 `src/pages/site-studio/site-studio.css`(自建 design tokens:深空灰底 `#0f1117` 系、靛蓝/青渐变强调色、玻璃拟态面板、细腻边框与阴影、系统字体栈),所有类名 `site-studio-` 前缀,**只被该页面 import**,不碰 tailwind 配置、全局 CSS、主题 provider。
|
||||||
|
- **布局**:
|
||||||
|
- 顶部悬浮工具栏:站点标题 + 构建状态胶囊(成功/构建中/失败三态,带呼吸灯动画)+ 设备切换分段控件(桌面/平板/手机)+ 刷新/重新构建/新窗口打开;
|
||||||
|
- 左侧窄导航栏:site.json 的页面列表(图标 + 标题,点击设 iframe hash 切页),可折叠成纯图标模式;
|
||||||
|
- 中央:**浏览器壳**造型(圆角 + 顶部仿 URL 地址条 + 红黄绿窗控点缀)内嵌 iframe,设备切换时宽度平滑过渡动画;
|
||||||
|
- 底部可折叠**构建控制台**(等宽字体日志、错误红色高亮、构建时长);
|
||||||
|
- 加载/失败/未启用态都有设计过的插画式空状态(纯 CSS/SVG,不引组件库新依赖)。
|
||||||
|
- 登录守卫:轻量检查 stored auth,缺失跳 `#/login`(复用既有跳转工具,不动鉴权体系)。
|
||||||
|
|
||||||
|
## 二、降级与故障隔离矩阵(部署失败不影响已有功能)
|
||||||
|
|
||||||
|
| 故障点 | 行为 | 对现有功能影响 |
|
||||||
|
|---|---|---|
|
||||||
|
| config `web_app.enabled: false`(默认关) | capability 返回不可用;入口按钮不渲染;技能第 0 步探测失败即回退单页 HTML | 无 |
|
||||||
|
| 模板目录/node_modules 未预置(发版遗漏) | 同上:capability 四项检查(配置/模板/依赖/node 二进制)逐项报告原因 | 无 |
|
||||||
|
| sandbox.mounts 加了模板挂载但目录缺失 | 已核实 `local_sandbox_provider.py:81-90` 对不存在宿主路径**静默跳过仅告警**;Aio provider 同样按"存在才挂"实现 | 无(沿用既有容错语义) |
|
||||||
|
| vite build 失败/超时 | 子进程隔离(`asyncio.to_thread` + 超时强杀 + 每站点并发锁),错误收进 JSON;预览页显示日志;智能体读报错自修复 | 无(不进 gateway 请求主链路) |
|
||||||
|
| 签名 URL 过期/伪造 | 403 JSON,页面自动重签 | 无 |
|
||||||
|
| 前端新页面任何异常 | 独立路由独立样式,最坏就是该页白屏,error boundary 兜底 | 无 |
|
||||||
|
| 产物面板入口按钮 | 唯一改动点:capability 请求失败/不可用 → 按钮不渲染,**原 UI 一字不变** | 无 |
|
||||||
|
| 新 router 注册 | 纯增量 include_router,异常全部在路由内转结构化响应 | 无 |
|
||||||
|
|
||||||
|
技能回退语义(关键):SKILL.md 第 0 步强制探测 `ls /mnt/web-app-template/scripts/build_site.py`——探测失败时明确指示"改用现有方式产出单个 xxx.html",保证部署不完整时智能体产物质量与今天一致。
|
||||||
|
|
||||||
|
## P1 站点模板(`offline-backend-20260512/backend/templates/web-app-template/`,源码入库,node_modules gitignore 由发版仓库预置)
|
||||||
|
|
||||||
|
- Vite + React + react-router-dom(**HashRouter**:静态托管路径下无需服务端 fallback)+ **纯 CSS design tokens**(不用 Tailwind v4,规避 oklch 在 chrome100 老内核失效)+ build target es2018。
|
||||||
|
- 依赖:react 系、@xyflow/react、maplibre-gl、echarts、mermaid、pmtiles、vite/@vitejs/plugin-react(lockfile 锁死版本)。
|
||||||
|
- 内置组件:`AppShell`(读 site.json 导航)、`FlowCanvas`(xyflow 流程图封装,自动分层布局)、`GeoMap`(**默认无底图模式**:背景 + GeoJSON 点线面离线开箱即用;site.json 可配 tilesUrl/PMTiles)、`ChartPanel`(echarts)、`MermaidBlock`(静态图)。
|
||||||
|
- `router.tsx` 用 `import.meta.glob('../pages/*.tsx')` 自动注册路由——智能体丢页面文件即生效,零手动接线。
|
||||||
|
- `site.json`:`{ title, description, nav[], map:{tilesUrl?, tilesType?, pmtiles?}, theme? }`。
|
||||||
|
- Makefile 加 `make webapp-template`(开发机一次性 pnpm install);发版仓库构建脚本负责镜像内预置 node_modules(文档写明)。
|
||||||
|
- config.yaml `sandbox.mounts` 增模板只读挂载(`templates/web-app-template → /mnt/web-app-template`,缺失自动跳过已验证);AioSandboxProvider `_get_thread_mounts` 小扩展:同样"存在才挂"地补读 config 自定义 mounts。
|
||||||
|
|
||||||
|
## P2 构建脚本 + 技能包(`skills/public/web-app/`)
|
||||||
|
|
||||||
|
- `SKILL.md`:触发词(多页面网站/站点/web app/带流程图或地图的网页);工作流 = ①可用性探测(失败即回退单页 HTML)②scaffold ③配置 site.json ④写页面 ⑤数据放 public/ ⑥build(失败读 JSON 报错修复重试 ≤3)⑦present_files site.json ⑧引导点「站点预览」;硬约束:不手写 dist、一律 `/mnt/user-data/...` 虚拟路径。
|
||||||
|
- `scripts/build_site.py`(纯标准库):`check`(能力探测 JSON)/ `scaffold`(copytree 忽略 node_modules、dist)/ `build`(定位 node → 软链 node_modules 进站点:Linux symlink、Windows `mklink /J` junction 免管理员 → `node vite.js build` → 结构化 JSON 输出,180s 超时)/ `status`。
|
||||||
|
- 模板定位顺序:env `DEERFLOW_WEBAPP_TEMPLATE` → `/mnt/web-app-template`(沙箱内)→ 后端相对路径(宿主侧)。
|
||||||
|
|
||||||
|
## P3 后端:纯引擎 + 路由(全部能力开关保护)
|
||||||
|
|
||||||
|
- `packages/harness/deerflow/webapp/`(纯引擎,不 import app.*,对齐 `address_scan.py` 先例):站点发现(outputs/* 下 site.json + dist 状态)、构建执行(子进程 + 锁 + 超时)、HMAC token(`tid|site|exp`,密钥首用生成持久化 `{base_dir}/.web-app-secret` 0600)。
|
||||||
|
- `app/gateway/routers/sites.py`(app.py 注册):
|
||||||
|
- `GET /api/web-app/capability`:enabled + 模板 + node_modules + node 四项检查,逐项给原因(前端与技能共用);
|
||||||
|
- 列表/build/build-status/preview-url:`@require_permission("threads", "read", owner_check=True)`,能力不可用时返回结构化 `{"available": false, "reason"}` 而非 5xx;
|
||||||
|
- `GET /api/public/site-preview/{token}/{path}`:验签 + 过期 → `aresolve_thread_virtual_path` 且**限定站点 dist/ 根**(复用遍历防护)→ 正确 MIME(.js/.mjs→text/javascript、.geojson→application/json、.wasm)→ **HTML inline** + `CSP: default-src 'self' data: blob:; worker-src 'self' blob:`(maplibre worker 需要)+ `X-Content-Type-Options: nosniff` + `Cache-Control: no-cache` + ETag(mtime)。
|
||||||
|
- config.yaml + `deerflow/config/configuration.py` 加 `web_app:`(enabled **默认 false**、build_timeout、token_ttl、template_dir、node 覆盖)——与 `workflows` 同款总开关模式。
|
||||||
|
- 测试 `tests/test_web_app_sites.py`:capability 降级路径、token 签发/过期/伪造、路径穿越拒绝、MIME、站点发现(构建子进程 mock)。
|
||||||
|
|
||||||
|
## P4 前端
|
||||||
|
|
||||||
|
- `src/core/sites/api.ts`:capability/sites/build/status/mintPreviewUrl + TanStack Query hooks(照 `light-apps.ts` 模式)。
|
||||||
|
- `src/pages/site-studio/SiteStudioPage.tsx` + `site-studio.css` + `App.tsx` 注册顶层路由 `/sites/:threadId/:siteName`(独立风格见第一节;iframe `sandbox="allow-scripts allow-forms allow-popups"` 无 allow-same-origin,延续既有安全契约;token 过期自动重签;设备切换;重新构建 → 轮询 build-status → cache-bust 刷新)。
|
||||||
|
- `artifact-file-detail.tsx` **唯一既有文件改动**:产物路径以 site.json 结尾且 capability 可用时渲染「打开站点预览」按钮(navigate 到新路由);capability 请求失败/超时一律不渲染,原 UI 不变。
|
||||||
|
- token URL 构造等纯函数补单测;`pnpm typecheck` 通过。
|
||||||
|
|
||||||
|
## P5 测试与文档
|
||||||
|
|
||||||
|
- 后端 `make test` + ruff;前端 typecheck + 单测;`build_site.py` 本机真实跑通 scaffold→build→静态服务器自测 dist。
|
||||||
|
- 文档:AGENTS.md 增「Web App 产物」小节(含降级矩阵);发版文档补 node_modules 预置说明。
|
||||||
|
|
||||||
|
## 安全边界
|
||||||
|
|
||||||
|
短时效签名 URL(默认 2h)只覆盖该站点 dist/ 静态读;dist/ 根限定 + 复用 `aresolve_thread_virtual_path` 遍历防护;HTML inline 仅在 site-preview 路径开放(不影响 artifacts 接口既有强制 attachment);CSP + nosniff + iframe sandbox 三重纵深。
|
||||||
|
|
||||||
|
## 二期(本期不做)
|
||||||
|
|
||||||
|
源码编辑 + 保存即重建;跨对话站点 gallery;PMTiles 底图资产打包;`visualizations/` 内联图表(进行中,互补不冲突)的底图配置统一(其 map-renderer.tsx 硬编码 Google 瓦片属离线隐患,另行处理)。
|
||||||
|
|
||||||
|
## 风险
|
||||||
|
|
||||||
|
- Windows 开发机 junction 链接免管理员,node 前端机必有;生产镜像已含 nodejs+npm。
|
||||||
|
- Aio provider 自定义 mounts 扩展是小改动,按"存在才挂"实现即可,不影响现有挂载。
|
||||||
|
- dist/ 出现在产物目录扫描(文件噪音)——一期保持现状,二期优化。
|
||||||
@@ -105,6 +105,8 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
|||||||
- **agentfx 任务深链(新系统 iframe 嵌入的智能体问答,3qfx 单智能体 lite 模式的独立复制)**: goPath=`agentfx`(`${loginBase}?password=…&taskId=…&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue`)。与 3qfx 的差异:①任务详情不走外部 consumer,改调**本系统 Gateway** `GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=`(`apiFetch` 带刚登录的 DeerFlow 态;任务数据来自 TaskCOP 任务表,与任务列表/报告导入同一份);②**固定使用内置智能体** `agentfx-analyst`(不走业务映射);③路由 `task-management/plan-task-agentfx/agent/:agent_id/chats`,页面为独立复制的 `AgentFxWorkspace.tsx`(`AgentFxWorkspace`/`AgentFxIndexResolver`/`AgentFxChat` —— 左「当前任务」卡 + 1:N 对话列表 + 新建对话,`AgentChatPage taskBind` 自动发开场白;无 TaskJumpBar/清除记录/闸门/xdfx 行动卡)。模块 `core/auth/agentfx-deeplink.ts`(`isAgentfxGoPath`/`fetchAgentfxTaskDetail`/`resolveAgentfxDeeplinkTarget`);任务详情整包仍存共享键 `login.copTaskDetail`(按标签页隔离),`AgentChatPage` 的 taskBind 自恢复对 agentfx 生效。`LoginPage` 在既有深链分支前用 `isAgentfxGoPath` 分流。**内置「任务研判报告助手」**:后端 seeder `app/gateway/routers/_agentfx_seed.py` + `_agentfx_seed_assets/`(config.yaml + SOUL.md,目录存在不覆盖;app.py lifespan 在 `_sync_legacy_agents` 之前调用,自动进 agents 表 user_id IS NULL)——SOUL 规定工作流「搜集信息(`web_search`,**首步不可跳过**)→ 每次检索 JSON 原样落盘 `/mnt/user-data/workspace/hits/qN.json` → **一条命令** `task-report-build`(group `all`)技能函数转换成 14 类 `task-reports.json`(模型禁止手写该 JSON;分页技能 `task-report-{enemy,our,env,judge}` 仅用于事后补跑单页;五者都是共享引擎 `task-report-lib/convert_lib.py` 的瘦 CLI,`resolve_hit_files` 对虚拟绝对路径读空的情况自动回退到相对写法并在 `warnings` 里列出尝试过的路径,免得智能体自己写脚本排查)→ `write_file` 生成 `/mnt/user-data/outputs/report.md` → 聊天总结 → 引导入库」,硬性约束:categoryType 逐字用 14 个全称(禁缩写自造如「DQ关键事件」)、JSON 直接写汉字禁 `\uXXXX` 转义、每类必须有实质内容不许空数组/纯占位、文件一律用 `/mnt/user-data/...` 虚拟路径(防宿主路径泄漏进 tool args)。**检索依赖 `config.yaml tools.web_search`(DDG,enabled 已开;内网需换 configurable_search 时改回并填 endpoint)**。任务工作区输入框在 `taskCtxOn` 时隐藏「写作/记忆/参考文献」(InputBox `hideContextTools` prop)与「知识空间」(不传 knowledgeSelector);消息流里 write_file 文件卡片只显示文件名(`artifactDisplayName` 收敛宿主/虚拟路径)。**入库两条路径**:(a) **前端协助卡** —— `AgentChatPage`(taskBind 且非 taskGate)检测线程 artifacts 出现 `task-reports.json`(按文件名后缀匹配,兼容本地沙箱记录的宿主路径)且流式结束时,向 displayThread 尾部注入 `additional_kwargs.task_report_import_approval` 虚拟 ai 消息(`core/messages/utils.ts` 分组 `assistant:task-report-import` + `MessageList` 的 `taskReportImportSlot` 插槽渲染 `TaskReportImportCard`),点「开始入库」前端校验(`strategy-components/lib/agentfx-report-schema.ts`,14 类 + contentJson 合法 JSON + **\u 转义归一化还原为汉字**,与技能脚本同规则)后 `importTaskReport`(`strategy-components/api/task-reports.ts`)POST 导入接口整体替换;任务已有详情**不抑制**卡片(入库即整体替换,卡片文案说明覆盖语义),导入成功按 thread 记 sessionStorage(刷新不重复询问)、「暂不入库」为会话内记忆;(b) **入库技能** `task-report-import`(seeder 一并复制到 `skills/public/`,public 类技能 extensions_config 缺省即启用)—— 用户聊天里说「入库」时智能体按 SKILL.md 用 bash 跑 `scripts/task_report_import.py`(纯标准库:同样的校验规则 + 转义归一化;**默认关闭 SSL 证书校验** `ssl._create_unverified_context` + 忽略代理,适配内网自签证书;鉴权 `--token`/env `AGENTFX_IMPORT_TOKEN`→`DEERFLOW_AUTH_TOKEN`→`TOOLS_TOKEN` 优先,缺省经公共端点 `POST /api/parallel-agents/auth/token` 以 `agentfx-import` 用户现签 token——内网既有鉴权旁路;gateway 地址 env `AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:{DEER_FLOW_GATEWAY_PORT}`);`--strict` 可要求 14 类齐全,默认允许部分导入并在 stdout JSON 的 `missing_categories` 里列缺。入库成功后任务 TaskCOP 状态自动置 25(已完成分析)。**入库成功通知宿主**(iframe 嵌入场景):两条路径成功后都会 `window.top.postMessage({source: "magent-web", type: "Line truncated
|
- **agentfx 任务深链(新系统 iframe 嵌入的智能体问答,3qfx 单智能体 lite 模式的独立复制)**: goPath=`agentfx`(`${loginBase}?password=…&taskId=…&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue`)。与 3qfx 的差异:①任务详情不走外部 consumer,改调**本系统 Gateway** `GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=`(`apiFetch` 带刚登录的 DeerFlow 态;任务数据来自 TaskCOP 任务表,与任务列表/报告导入同一份);②**固定使用内置智能体** `agentfx-analyst`(不走业务映射);③路由 `task-management/plan-task-agentfx/agent/:agent_id/chats`,页面为独立复制的 `AgentFxWorkspace.tsx`(`AgentFxWorkspace`/`AgentFxIndexResolver`/`AgentFxChat` —— 左「当前任务」卡 + 1:N 对话列表 + 新建对话,`AgentChatPage taskBind` 自动发开场白;无 TaskJumpBar/清除记录/闸门/xdfx 行动卡)。模块 `core/auth/agentfx-deeplink.ts`(`isAgentfxGoPath`/`fetchAgentfxTaskDetail`/`resolveAgentfxDeeplinkTarget`);任务详情整包仍存共享键 `login.copTaskDetail`(按标签页隔离),`AgentChatPage` 的 taskBind 自恢复对 agentfx 生效。`LoginPage` 在既有深链分支前用 `isAgentfxGoPath` 分流。**内置「任务研判报告助手」**:后端 seeder `app/gateway/routers/_agentfx_seed.py` + `_agentfx_seed_assets/`(config.yaml + SOUL.md,目录存在不覆盖;app.py lifespan 在 `_sync_legacy_agents` 之前调用,自动进 agents 表 user_id IS NULL)——SOUL 规定工作流「搜集信息(`web_search`,**首步不可跳过**)→ 每次检索 JSON 原样落盘 `/mnt/user-data/workspace/hits/qN.json` → **一条命令** `task-report-build`(group `all`)技能函数转换成 14 类 `task-reports.json`(模型禁止手写该 JSON;分页技能 `task-report-{enemy,our,env,judge}` 仅用于事后补跑单页;五者都是共享引擎 `task-report-lib/convert_lib.py` 的瘦 CLI,`resolve_hit_files` 对虚拟绝对路径读空的情况自动回退到相对写法并在 `warnings` 里列出尝试过的路径,免得智能体自己写脚本排查)→ `write_file` 生成 `/mnt/user-data/outputs/report.md` → 聊天总结 → 引导入库」,硬性约束:categoryType 逐字用 14 个全称(禁缩写自造如「DQ关键事件」)、JSON 直接写汉字禁 `\uXXXX` 转义、每类必须有实质内容不许空数组/纯占位、文件一律用 `/mnt/user-data/...` 虚拟路径(防宿主路径泄漏进 tool args)。**检索依赖 `config.yaml tools.web_search`(DDG,enabled 已开;内网需换 configurable_search 时改回并填 endpoint)**。任务工作区输入框在 `taskCtxOn` 时隐藏「写作/记忆/参考文献」(InputBox `hideContextTools` prop)与「知识空间」(不传 knowledgeSelector);消息流里 write_file 文件卡片只显示文件名(`artifactDisplayName` 收敛宿主/虚拟路径)。**入库两条路径**:(a) **前端协助卡** —— `AgentChatPage`(taskBind 且非 taskGate)检测线程 artifacts 出现 `task-reports.json`(按文件名后缀匹配,兼容本地沙箱记录的宿主路径)且流式结束时,向 displayThread 尾部注入 `additional_kwargs.task_report_import_approval` 虚拟 ai 消息(`core/messages/utils.ts` 分组 `assistant:task-report-import` + `MessageList` 的 `taskReportImportSlot` 插槽渲染 `TaskReportImportCard`),点「开始入库」前端校验(`strategy-components/lib/agentfx-report-schema.ts`,14 类 + contentJson 合法 JSON + **\u 转义归一化还原为汉字**,与技能脚本同规则)后 `importTaskReport`(`strategy-components/api/task-reports.ts`)POST 导入接口整体替换;任务已有详情**不抑制**卡片(入库即整体替换,卡片文案说明覆盖语义),导入成功按 thread 记 sessionStorage(刷新不重复询问)、「暂不入库」为会话内记忆;(b) **入库技能** `task-report-import`(seeder 一并复制到 `skills/public/`,public 类技能 extensions_config 缺省即启用)—— 用户聊天里说「入库」时智能体按 SKILL.md 用 bash 跑 `scripts/task_report_import.py`(纯标准库:同样的校验规则 + 转义归一化;**默认关闭 SSL 证书校验** `ssl._create_unverified_context` + 忽略代理,适配内网自签证书;鉴权 `--token`/env `AGENTFX_IMPORT_TOKEN`→`DEERFLOW_AUTH_TOKEN`→`TOOLS_TOKEN` 优先,缺省经公共端点 `POST /api/parallel-agents/auth/token` 以 `agentfx-import` 用户现签 token——内网既有鉴权旁路;gateway 地址 env `AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:{DEER_FLOW_GATEWAY_PORT}`);`--strict` 可要求 14 类齐全,默认允许部分导入并在 stdout JSON 的 `missing_categories` 里列缺。入库成功后任务 TaskCOP 状态自动置 25(已完成分析)。**入库成功通知宿主**(iframe 嵌入场景):两条路径成功后都会 `window.top.postMessage({source: "magent-web", type: "Line truncated
|
||||||
- **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。
|
- **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。
|
||||||
|
|
||||||
|
- **Web App 产物模式(多页面站点 / 流程图 / 地图,默认关闭、全链路可降级)**: 让智能体产出**完整站点**而非单个 xxx.html。沙箱技能 `skills/public/web-app` 用模板(`backend/templates/web-app-template`,经 `config.yaml sandbox.mounts` 只读挂载到 `/mnt/web-app-template`,目录缺失自动跳过)脚手架到 `outputs/<site>/`(site.json 清单 + `src/pages/*.tsx` 文件名即路由 + `public/` 数据),再跑 `scripts/build_site.py build` 用**预置 node_modules**(symlink/junction 链接,离线永不 npm install)执行 vite build 出 `dist/`;内置组件 `FlowCanvas`(@xyflow/react)/`GeoMap`(MapLibre,默认无底图 GeoJSON 模式,site.json 可配 tilesUrl/PMTiles)/`ChartPanel`(ECharts)/`MermaidBlock`。平台侧 `app/gateway/routers/web_app_sites.py` + 纯引擎 `deerflow/webapp/`(发现/子进程构建/HMAC 签名):`GET /api/web-app/capability`(enabled/模板/依赖/node 四项探测)、`GET|POST /api/threads/{tid}/sites[...]{build,build-status,preview-url}`,静态托管走 `GET /api/public/site-preview/{token}/{path}`(`/api/public/` 免鉴权命名空间,**唯一允许 HTML inline 的出口**——签名令牌 + dist/ 根限定 + 按请求真实 origin 显式声明的 CSP(iframe sandbox 不透明 origin 下 'self' 失效)+ nosniff;artifacts 接口既有的强制 attachment 不变)。前端:**独立风格**预览工作台 `pages/site-studio/SiteStudioPage.tsx`(顶层路由 `#/sites/:threadId/:siteName`,`App.tsx` 注册,不进 PageLayout/WorkspaceLayout;自带 site-studio.css 深色设计体系,零全局样式污染;iframe `sandbox="allow-scripts allow-forms allow-popups allow-modals"` 无 allow-same-origin)+ 产物面板 `artifact-file-detail.tsx` 里 site.json 的「站点预览」按钮(capability 可用才渲染,唯一既有文件改动)。**降级铁律**:默认**完全关闭**(`sandbox.mounts` 的模板挂载条目与文件末尾的 `web_app:` 段在 config.yaml 里**均为注释状态**);开启需**同时放开两处**(挂载让沙箱技能可探测 `/mnt/web-app-template`,`web_app.enabled: true` 开平台 API——只开一处会得到"生成了看不了"或"看不了也不生成"的中间态)。`build_site.py check` 在宿主侧会读 config.yaml 的 `web_app.enabled`(env `DEERFLOW_WEBAPP_ENABLED`/`DEERFLOW_WEBAPP_CONFIG` 可覆盖;沙箱内读不到配置时"挂载即视为开")。任何一环(配置/模板/node_modules/node)缺失 → capability 不可用 + 技能第 0 步探测失败即**回退单页 HTML 模式** + 入口按钮不渲染 + 构建失败只收进任务状态,现有功能零影响。配置 `web_app:`(enabled/template_dir/build_timeout_seconds/preview_token_ttl_seconds/node_path);测试 `tests/test_web_app_sites.py`;开发机 `make webapp-template` 预置依赖,离线发版由 deerflow-offline-deployment 仓库在 CI 预置 node_modules 进镜像。
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
Backend config lives in `offline-backend-20260512/backend/config.yaml`. Values starting with `$` resolve as environment variables (e.g., `$OPENAI_API_KEY`). MCP servers and skills are configured in `extensions_config.json` in the same directory.
|
Backend config lives in `offline-backend-20260512/backend/config.yaml`. Values starting with `$` resolve as environment variables (e.g., `$OPENAI_API_KEY`). MCP servers and skills are configured in `extensions_config.json` in the same directory.
|
||||||
|
|||||||
@@ -81,8 +81,11 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
|||||||
|
|
||||||
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
|
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
|
||||||
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
|
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
|
||||||
|
- **普通问答地图可视化**: shared `MarkdownContent` recognizes only dedicated `map-json` fences and otherwise preserves the legacy Markdown/Streamdown path byte-for-byte. `components/workspace/visualizations/` validates the bounded map contract with Zod, lazy-loads MapLibre, and provides loading/error/text fallbacks so malformed or unreachable maps never blank the message. The lead-agent prompt emits `map-json` only when the user explicitly asks for a map; flowcharts and other graphics continue through the existing Markdown/Mermaid path. The raster tile template comes from the single post-build setting `runtime-config.js` → `VITE_MAP_TILE_URL` (Google by default); replace it with the intranet `{z}/{x}/{y}` endpoint for disconnected deployments.
|
||||||
|
- **HTML 成品离线绘图运行时**: 新生成的 `.html` 源码直接引用 Gateway `/api/public/html-runtime/` 下的浏览器版 `g6.min.js`、`echarts.min.js`、`leaflet.js/css` 和 `deerflow-runtime.js`;文件随后台源码存放于 `vendor/html-runtime/`,不再依赖前端 Vite/node_modules 产物或公网 CDN。`config.yaml -> html_runtime.base_url` 是写入新 HTML 的浏览器可访问绝对地址,`html_runtime.map_tile_url` 是初始化脚本读取的瓦片模板。流程图使用 `data-df-flow` + G6(不用 Mermaid),图表使用 `data-df-chart`,地图使用 Leaflet `data-df-map`。普通问答保持独立:消息列表 `map-json` 继续由前端原逻辑渲染,普通流程图/图表仍走 Markdown/Mermaid。
|
||||||
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API.
|
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API.
|
||||||
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
|
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
|
||||||
|
- **策略优选**: route `/page/strategy/qa/strategy-selection`, sidebar 「问答管理 → 策略优选」. The standalone `src/strategy-selection/` workbench has two horizontal split stages: Step 1 keeps intent-understanding conversation cards on the left and an editable task draft plus angle selection on the right; Step 2 keeps multiplexed parallel strategy cards and automatic judging on the left while the right stays dedicated to the selected-strategy basket and final Markdown synthesis. It consumes the existing `POST /api/parallel-agents/run/stream` protocol, isolates output by stable agent id, supports expanded per-agent dialogs, user selection, Markdown editing/copy/download, and shared server-side DOCX export. A clearly marked neutral Taiwan-Strait risk-management demo is loaded by default for offline UI acceptance. Administrators can edit the SOUL and skills of the nine built-in functional agents through the page dialog; the backend seeds `strategy-selection-{intent,angle-*,judge,solution}` only when their directories are missing, preserving later admin changes. Pure parsing/SSE tests live in `src/strategy-selection/lib.test.ts`; backend seed tests live in `tests/test_strategy_selection_seed.py`.
|
||||||
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
|
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
|
||||||
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`.
|
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`.
|
||||||
- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`.
|
- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`.
|
||||||
|
|||||||
Binary file not shown.
@@ -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 保持不变。未提交、推送代码,也未执行离线发布。
|
||||||
@@ -18,7 +18,7 @@ VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
|
|||||||
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
||||||
|
|
||||||
# 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。
|
# 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。
|
||||||
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=true
|
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
|
||||||
|
|
||||||
# 轻应用「新建笔记」系列接口
|
# 轻应用「新建笔记」系列接口
|
||||||
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# 项目空间
|
||||||
|
|
||||||
|
入口:全量与简洁导航中的「项目空间」,路由 `/page/workspace/projects`。
|
||||||
|
|
||||||
|
## 使用流程
|
||||||
|
|
||||||
|
1. 新建项目,填写名称,在问答、策略智能、方案生成中选择一种模式。
|
||||||
|
2. 选择项目默认智能体,填写目标、约束等项目说明。新建项目对话时仍可换用另一个可访问的智能体。
|
||||||
|
3. 创建后进入嵌入式问答工作区,左侧保留普通聊天记录;项目对话也会出现在聊天记录中并返回所属项目。
|
||||||
|
4. 方案生成可从「工作流配置 · 报告结构」选择、启停、编辑、删除结构,选择后自动填充项目报告结构配置;也可直接编辑大纲及固定/弹性约束。
|
||||||
|
5. 策略智能项目保存默认 Strategy Lens;每轮依次进行问题重构、各 Lens 独立分析、策略辩论和方案生成,并分为「意图理解」「多视角策略分析」「完整策略方案」三栏。右栏以文档形式给出执行摘要、目标原则、分阶段行动、资源配置、风险应对、评估复盘、形成依据与备选策略切换条件;原始对话和引用仍可切换查看。
|
||||||
|
6. 策略智能对话底部提供改变约束、增删视角、重新推演和挑战当前策略快捷动作;项目主页面可直接添加或删除 Lens。
|
||||||
|
|
||||||
|
模式在创建后不可修改,前后端都不提供修改入口。项目、对话归属和智能体访问权限在服务端校验;方案结构及每条对话所选智能体也以服务端配置为准。
|
||||||
|
|
||||||
|
## 代码与运行
|
||||||
|
|
||||||
|
- `src/pages/AnalysisProjectsPage.tsx`:项目列表、新建、设置、Lens 配置、历史与工作台布局。
|
||||||
|
- `src/pages/analysis-projects/ProjectConversation.tsx`:现有聊天、研究进度、方案画布的项目级组合。
|
||||||
|
- `src/core/analysis-projects/api.ts`:Gateway 项目 API 与按账号隔离的缓存。
|
||||||
|
- 后端:`/api/analysis-projects`,对话仍通过现有 `/api/threads/{thread_id}/runs/*` 流式接口发送;服务端从不可变归属读取项目配置。
|
||||||
|
|
||||||
|
启动新版 Gateway 后自动创建项目表。生产持久化依赖已配置的数据库;memory 模式中的项目随服务重启丢失。前端沿用仓库的 Vite 开发与构建命令。
|
||||||
|
|
||||||
|
## 验证范围
|
||||||
|
|
||||||
|
后端回归命令:在 `offline-backend-20260512/backend` 执行
|
||||||
|
|
||||||
|
```shell
|
||||||
|
python -m pytest tests/test_analysis_projects.py tests/test_deep_research_report_tool.py tests/test_writing_mode_report_middleware.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
涵盖内存与 SQLite 存储、跨用户隔离、禁止修改模式、运行时覆盖伪造模式与知识库、更新配置、对话归属与失败清理、固定方案模板及知识库资料进入研究管线。
|
||||||
|
|
||||||
|
部署前还需使用实际知识库和模型验证三类问答、流式断线续接及方案后续修改。接口模拟与单元测试不能替代真实模型联调。
|
||||||
|
|
||||||
|
## 2026-09-14 我的项目与知识库验收修复
|
||||||
|
|
||||||
|
日常使用复查:我的项目侧栏历史保持 `qa/projects/groups` 上下文,官方侧栏同样识别项目归属;手机选择历史后收起菜单;项目表单不重复显示侧栏按钮。设置保存后默认智能体同步到新对话选择器。读取项目对话失败提供重试,项目列表失败可原地重试。助手知识检索隔离过期响应,清空输入时禁用检索,无结果提供目录入口。ScrollArea 固定为包含 React 19 引用回调修复的 1.2.18。详细记录见 `docs/daily-usage-audit-20260914.md`。
|
||||||
|
|
||||||
|
- 简洁模式的「我的项目」列表、策略工作台和普通问答项目恢复工作区功能导航;窄屏提供展开菜单按钮。
|
||||||
|
- 普通问答先创建具名项目,再在项目内创建多个对话。列表读取 `/api/analysis-projects` 的 `qa` 项目,不再读取、标记或自动归入全局普通聊天。
|
||||||
|
- 项目设置更新保留既有知识库绑定。原「项目空间」与官方入口继续可用;我的项目下问答路由为 `qa/projects/groups/*`。
|
||||||
|
- 策略生成卡片统一先显示思考/执行过程,再显示正文;评判、方案及展开视图沿用相同顺序。
|
||||||
|
- 完成评判但尚未勾选策略的项目可以从方案列表进入,在工作台选择后生成;已保存方案在任务修改后仍可打开。旧列表请求不再覆盖新请求状态。
|
||||||
|
- 知识库管理页读取上游最新目录,避免远端删除后命中旧缓存;检索调用仍共享短期目录缓存。
|
||||||
|
- 普通用户访问尚未初始化的助手知识库时显示明确空态及刷新按钮。
|
||||||
|
|
||||||
|
验证:后端相关回归 273 项、前端策略/项目回归 31 项通过;真实 BAAI/bge-m3 编码、指定库向量检索与草稿隔离通过;本地隔离服务的问答项目创建/分组、真实问答、方案整理、助手库创建/编辑/文件入库/向量检索/详情均通过。完整验收记录见仓库 `docs/ji-knowledge-projects-acceptance.md`。不在源码或文档中保存接口密钥。
|
||||||
Generated
+355
-350
@@ -28,10 +28,10 @@
|
|||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "1.2.18",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "1.3.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
@@ -69,7 +69,9 @@
|
|||||||
"gsap": "^3.13.0",
|
"gsap": "^3.13.0",
|
||||||
"hast": "^1.0.0",
|
"hast": "^1.0.0",
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.28",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"maplibre-gl": "^6.9.0",
|
||||||
"mermaid": "^11.15.0",
|
"mermaid": "^11.15.0",
|
||||||
"motion": "^12.26.2",
|
"motion": "^12.26.2",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
@@ -1702,6 +1704,92 @@
|
|||||||
"@lezer/lr": "^1.4.0"
|
"@lezer/lr": "^1.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/point-geometry": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/tiny-sdf": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/unitbezier": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/vector-tile": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "~1.1.0",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"pbf": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/geojson-vt": {
|
||||||
|
"version": "6.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
|
||||||
|
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"kdbush": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||||
|
"version": "26.4.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.2.tgz",
|
||||||
|
"integrity": "sha512-6J0vZqMZvRAJKtdWJdDGHEh1YJ2ZHG08/GOur8gCArYhO8ZkM//OXqtI2AAEz4jc2G+Wq4MfcePg8qNK5TZ4Kg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
|
||||||
|
"@mapbox/unitbezier": "^1.0.0",
|
||||||
|
"json-stringify-pretty-compact": "^4.0.0",
|
||||||
|
"minimist": "^1.2.8",
|
||||||
|
"quickselect": "^3.0.0",
|
||||||
|
"tinyqueue": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"gl-style-format": "dist/gl-style-format.mjs",
|
||||||
|
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||||
|
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/mlt": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@maplibre/mlt/-/mlt-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-5n5dgolE2EYxwCKgx8vlwURCB8A+kyfJJyThlYimjlGgOcOe2Bhw9VxxnnHC91OC9PgHg+nVdgVPTYPkIo62vg==",
|
||||||
|
"license": "(MIT OR Apache-2.0)",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/vt-pbf": {
|
||||||
|
"version": "4.3.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
|
||||||
|
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"pbf": "^5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@marijn/find-cluster-break": {
|
"node_modules/@marijn/find-cluster-break": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||||
@@ -1787,23 +1875,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-avatar": {
|
"node_modules/@radix-ui/react-avatar": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -1893,23 +1964,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-collection": {
|
"node_modules/@radix-ui/react-collection": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||||
@@ -1971,23 +2025,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-compose-refs": {
|
"node_modules/@radix-ui/react-compose-refs": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||||
@@ -2086,23 +2123,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-direction": {
|
"node_modules/@radix-ui/react-direction": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||||
@@ -2165,23 +2185,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-dropdown-menu": {
|
"node_modules/@radix-ui/react-dropdown-menu": {
|
||||||
"version": "2.1.16",
|
"version": "2.1.16",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -2245,23 +2248,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-focus-guards": {
|
"node_modules/@radix-ui/react-focus-guards": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
||||||
@@ -2322,23 +2308,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-hover-card": {
|
"node_modules/@radix-ui/react-hover-card": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.15",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -2404,23 +2373,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-icons": {
|
"node_modules/@radix-ui/react-icons": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -2520,23 +2472,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-popper": {
|
"node_modules/@radix-ui/react-popper": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||||
@@ -2604,23 +2539,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-portal": {
|
"node_modules/@radix-ui/react-portal": {
|
||||||
"version": "1.1.9",
|
"version": "1.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
||||||
@@ -2666,23 +2584,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-presence": {
|
"node_modules/@radix-ui/react-presence": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||||
@@ -2816,36 +2717,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-scroll-area": {
|
"node_modules/@radix-ui/react-scroll-area": {
|
||||||
"version": "1.2.10",
|
"version": "1.2.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz",
|
||||||
|
"integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/number": "1.1.1",
|
"@radix-ui/number": "1.1.3",
|
||||||
"@radix-ui/primitive": "1.1.3",
|
"@radix-ui/primitive": "1.1.7",
|
||||||
"@radix-ui/react-compose-refs": "1.1.2",
|
"@radix-ui/react-compose-refs": "1.1.5",
|
||||||
"@radix-ui/react-context": "1.1.2",
|
"@radix-ui/react-context": "1.2.2",
|
||||||
"@radix-ui/react-direction": "1.1.1",
|
"@radix-ui/react-direction": "1.1.4",
|
||||||
"@radix-ui/react-presence": "1.1.5",
|
"@radix-ui/react-presence": "1.1.10",
|
||||||
"@radix-ui/react-primitive": "2.1.3",
|
"@radix-ui/react-primitive": "2.1.10",
|
||||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
"@radix-ui/react-use-callback-ref": "1.1.4",
|
||||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@@ -2862,10 +2748,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/number": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": {
|
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": {
|
||||||
"version": "1.1.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz",
|
||||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
"integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==",
|
||||||
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
@@ -2876,12 +2790,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": {
|
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-direction": {
|
||||||
"version": "2.1.3",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz",
|
||||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
"integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": {
|
||||||
|
"version": "1.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz",
|
||||||
|
"integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-slot": "1.2.3"
|
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@@ -2898,13 +2828,49 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": {
|
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": {
|
||||||
"version": "1.2.3",
|
"version": "2.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz",
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
"integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
"@radix-ui/react-slot": "1.3.3"
|
||||||
},
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-layout-effect": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
|
||||||
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
@@ -2992,23 +2958,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-separator": {
|
"node_modules/@radix-ui/react-separator": {
|
||||||
"version": "1.1.8",
|
"version": "1.1.8",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -3031,10 +2980,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-slot": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.2.4",
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
"@radix-ui/react-compose-refs": "1.1.5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@@ -3046,6 +2997,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-switch": {
|
"node_modules/@radix-ui/react-switch": {
|
||||||
"version": "1.2.6",
|
"version": "1.2.6",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -3109,23 +3075,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-tabs": {
|
"node_modules/@radix-ui/react-tabs": {
|
||||||
"version": "1.1.13",
|
"version": "1.1.13",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -3190,23 +3139,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-toggle": {
|
"node_modules/@radix-ui/react-toggle": {
|
||||||
"version": "1.1.10",
|
"version": "1.1.10",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -3293,23 +3225,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": {
|
"node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
@@ -3332,23 +3247,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-tooltip": {
|
"node_modules/@radix-ui/react-tooltip": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -3417,23 +3315,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
@@ -3622,23 +3503,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/rect": {
|
"node_modules/@radix-ui/rect": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||||
@@ -5557,6 +5421,15 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/bidi-js": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.2",
|
"version": "4.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||||
@@ -6459,6 +6332,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/earcut": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/earcut/-/earcut-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.349",
|
"version": "1.5.349",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
|
||||||
@@ -6853,6 +6732,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gl-matrix": {
|
||||||
|
"version": "3.4.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||||
|
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/gopd": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@@ -7351,6 +7236,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||||
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="
|
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/json-stringify-pretty-compact": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/json5": {
|
"node_modules/json5": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
@@ -7390,6 +7281,12 @@
|
|||||||
"katex": "cli.js"
|
"katex": "cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kdbush": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/kdbush/-/kdbush-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/khroma": {
|
"node_modules/khroma": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
|
||||||
@@ -7432,6 +7329,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz",
|
||||||
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="
|
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/lie": {
|
"node_modules/lie": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
@@ -7766,6 +7669,39 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/maplibre-gl": {
|
||||||
|
"version": "6.9.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/maplibre-gl/-/maplibre-gl-6.9.0.tgz",
|
||||||
|
"integrity": "sha512-vFMwMK0Zs+NM/rOMSdtu8bO30DIexhBEVi5KC6f70/XtI+L/K2wC3LsDCAXFZ4s8ik5gAuDugfCNbpllpdJ9bA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
|
"@mapbox/tiny-sdf": "^2.2.0",
|
||||||
|
"@mapbox/unitbezier": "^1.0.0",
|
||||||
|
"@mapbox/vector-tile": "^3.0.0",
|
||||||
|
"@maplibre/geojson-vt": "^6.1.1",
|
||||||
|
"@maplibre/maplibre-gl-style-spec": "^26.4.2",
|
||||||
|
"@maplibre/mlt": "^1.2.1",
|
||||||
|
"@maplibre/vt-pbf": "^4.3.2",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"bidi-js": "^1.1.0",
|
||||||
|
"earcut": "^3.2.3",
|
||||||
|
"gl-matrix": "^3.4.4",
|
||||||
|
"kdbush": "^4.1.0",
|
||||||
|
"murmurhash-js": "^1.0.0",
|
||||||
|
"pbf": "^5.1.2",
|
||||||
|
"potpack": "^2.1.0",
|
||||||
|
"quickselect": "^3.0.0",
|
||||||
|
"tinyqueue": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.14.0",
|
||||||
|
"npm": ">=8.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/markdown-it": {
|
"node_modules/markdown-it": {
|
||||||
"version": "14.1.1",
|
"version": "14.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||||
@@ -8713,6 +8649,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
|
||||||
},
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mitt": {
|
"node_modules/mitt": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||||
@@ -8771,6 +8716,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/murmurhash-js": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/mustache": {
|
"node_modules/mustache": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
|
||||||
@@ -8943,6 +8894,18 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
|
||||||
},
|
},
|
||||||
|
"node_modules/pbf": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pbf/-/pbf-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-protobuf-schema": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pbf": "bin/pbf"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/performance-now": {
|
"node_modules/performance-now": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||||
@@ -9035,6 +8998,12 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/potpack": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/potpack/-/potpack-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/prismjs": {
|
"node_modules/prismjs": {
|
||||||
"version": "1.30.0",
|
"version": "1.30.0",
|
||||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||||
@@ -9218,6 +9187,12 @@
|
|||||||
"prosemirror-transform": "^1.1.0"
|
"prosemirror-transform": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/protocol-buffers-schema": {
|
||||||
|
"version": "3.6.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||||
|
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
@@ -9235,6 +9210,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/quickselect": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/quickselect/-/quickselect-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/raf": {
|
"node_modules/raf": {
|
||||||
"version": "3.4.1",
|
"version": "3.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||||
@@ -9618,6 +9599,24 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/resolve-protobuf-schema": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"protocol-buffers-schema": "^3.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/robust-predicates": {
|
"node_modules/robust-predicates": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||||
@@ -9971,6 +9970,12 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyqueue": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/tiptap-markdown": {
|
"node_modules/tiptap-markdown": {
|
||||||
"version": "0.9.0",
|
"version": "0.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
|
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test:input-mode": "node --experimental-strip-types --test src/components/workspace/input-mode.test.ts",
|
||||||
|
"test:html-preview": "pnpm exec esbuild src/core/utils/legacy-html-preview.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/legacy-html-preview.test.mjs && node --test .runtime/unit-tests/legacy-html-preview.test.mjs",
|
||||||
|
"test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts",
|
||||||
"test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
|
"test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
|
||||||
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
|
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
|
||||||
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
|
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
|
||||||
@@ -36,10 +39,10 @@
|
|||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "1.2.18",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "1.3.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
@@ -77,7 +80,9 @@
|
|||||||
"gsap": "^3.13.0",
|
"gsap": "^3.13.0",
|
||||||
"hast": "^1.0.0",
|
"hast": "^1.0.0",
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.28",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"maplibre-gl": "^6.9.0",
|
||||||
"mermaid": "^11.15.0",
|
"mermaid": "^11.15.0",
|
||||||
"motion": "^12.26.2",
|
"motion": "^12.26.2",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
@@ -121,5 +126,8 @@
|
|||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
"vite-plugin-source-identifier": "^1.1.2"
|
"vite-plugin-source-identifier": "^1.1.2"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@radix-ui/react-slot": "1.3.3"
|
||||||
|
},
|
||||||
"packageManager": "pnpm@10.26.2"
|
"packageManager": "pnpm@10.26.2"
|
||||||
}
|
}
|
||||||
@@ -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。
|
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
|
||||||
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
|
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
|
||||||
|
|
||||||
|
// 地图瓦片地址模板:普通问答地图和生成的 HTML 网页地图共同使用。
|
||||||
|
// 后续接入内网瓦片服务时,只需替换此项并保留 {z}/{x}/{y} 占位符。
|
||||||
|
VITE_MAP_TILE_URL: "http://119.254.86.251:8023/tiles/{z}/{x}/{y}.png?v=zh-z14-13",
|
||||||
|
|
||||||
// routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。
|
// routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。
|
||||||
// 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。
|
// 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。
|
||||||
VITE_ROUTE_LOGIN_PASSWORD: "",
|
VITE_ROUTE_LOGIN_PASSWORD: "",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
|
|||||||
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
|
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
|
||||||
import EmbedChatPage from "./pages/EmbedChatPage";
|
import EmbedChatPage from "./pages/EmbedChatPage";
|
||||||
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
||||||
|
import SiteStudioPage from "./pages/site-studio/SiteStudioPage";
|
||||||
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
|
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
|
||||||
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
||||||
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
|
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
|
||||||
@@ -205,6 +206,8 @@ export function App() {
|
|||||||
element={<DashboardStandalonePage />}
|
element={<DashboardStandalonePage />}
|
||||||
/>
|
/>
|
||||||
<Route path="/official/*" element={<OfficialDeerFlowEntry />} />
|
<Route path="/official/*" element={<OfficialDeerFlowEntry />} />
|
||||||
|
{/* Web App 站点预览工作室:独立路由 + 独立视觉风格(无主应用 chrome) */}
|
||||||
|
<Route path="/sites/:threadId/:siteName" element={<SiteStudioPage />} />
|
||||||
<Route path="/page/*" element={pageElement} />
|
<Route path="/page/*" element={pageElement} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview";
|
|||||||
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`)
|
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`)
|
||||||
* navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is
|
* navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is
|
||||||
* a real document URL, so fragment links scroll within the frame as expected —
|
* a real document URL, so fragment links scroll within the frame as expected —
|
||||||
* all without enabling scripts (the sandbox stays script-free for security; the
|
* The sandbox permits scripts so the preview can use the app-bundled HTML
|
||||||
* server already sanitizes script vectors and this is defense-in-depth).
|
* visualization runtimes, while still withholding same-origin privileges.
|
||||||
*
|
*
|
||||||
* The `charset=utf-8` on the blob type is required: a blob: URL whose
|
* The `charset=utf-8` on the blob type is required: a blob: URL whose
|
||||||
* Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on
|
* Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on
|
||||||
@@ -26,7 +26,9 @@ export function HtmlPreviewFrame({
|
|||||||
const [url, setUrl] = useState<string>();
|
const [url, setUrl] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const blob = new Blob([prepareGeneratedHtml(html ?? "")], {
|
const blob = new Blob([
|
||||||
|
prepareGeneratedHtml(html ?? ""),
|
||||||
|
], {
|
||||||
type: "text/html;charset=utf-8",
|
type: "text/html;charset=utf-8",
|
||||||
});
|
});
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { Fragment } from "react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -21,12 +20,12 @@ export function Breadcrumb({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{segments.map((segment, i) => (
|
{segments.map((segment, i) => (
|
||||||
<Fragment key={`${segment}-${i}`}>
|
<div className="contents" key={`${segment}-${i}`}>
|
||||||
<span className="hidden shrink-0 whitespace-nowrap sm:inline">
|
<span className="hidden shrink-0 whitespace-nowrap sm:inline">
|
||||||
{segment}
|
{segment}
|
||||||
</span>
|
</span>
|
||||||
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
|
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
|
||||||
</Fragment>
|
</div>
|
||||||
))}
|
))}
|
||||||
<span
|
<span
|
||||||
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
|
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||||
|
import {
|
||||||
|
MyProjectThreadBadge,
|
||||||
|
MyProjectThreadIcon,
|
||||||
|
} from "@/components/workspace/my-project-thread-indicator";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { useAgentsPaginated } from "@/core/agents";
|
import { useAgentsPaginated } from "@/core/agents";
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
@@ -69,6 +73,7 @@ import {
|
|||||||
useToggleThreadPin,
|
useToggleThreadPin,
|
||||||
} from "@/core/threads/hooks";
|
} from "@/core/threads/hooks";
|
||||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||||
|
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||||
import {
|
import {
|
||||||
bootstrapResumePath,
|
bootstrapResumePath,
|
||||||
isBootstrapThread,
|
isBootstrapThread,
|
||||||
@@ -479,6 +484,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
|||||||
) : (
|
) : (
|
||||||
filteredThreads.map((thread) => {
|
filteredThreads.map((thread) => {
|
||||||
const isBoot = isBootstrapThread(thread);
|
const isBoot = isBootstrapThread(thread);
|
||||||
|
const projectType = myProjectConversationTypeOfThread(thread);
|
||||||
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
|
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
const pinned = isThreadPinned(thread);
|
const pinned = isThreadPinned(thread);
|
||||||
@@ -505,6 +511,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
|||||||
)}
|
)}
|
||||||
aria-label="智能体创建会话"
|
aria-label="智能体创建会话"
|
||||||
/>
|
/>
|
||||||
|
) : projectType ? (
|
||||||
|
<MyProjectThreadIcon type={projectType} />
|
||||||
) : (
|
) : (
|
||||||
<MessageSquare
|
<MessageSquare
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -516,9 +524,15 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
|||||||
{pinned && (
|
{pinned && (
|
||||||
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
|
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="truncate">
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||||
</span>
|
</span>
|
||||||
|
{projectType && (
|
||||||
|
<MyProjectThreadBadge
|
||||||
|
className="mr-4"
|
||||||
|
type={projectType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export function AgentDetailPanel({
|
|||||||
positionName,
|
positionName,
|
||||||
skillsIcon,
|
skillsIcon,
|
||||||
skillsTitle,
|
skillsTitle,
|
||||||
|
belowSkillsSlot,
|
||||||
showCommonQuestions = false,
|
showCommonQuestions = false,
|
||||||
jobResponsibilities,
|
jobResponsibilities,
|
||||||
panelTitle,
|
panelTitle,
|
||||||
@@ -181,6 +182,8 @@ export function AgentDetailPanel({
|
|||||||
positionName?: string;
|
positionName?: string;
|
||||||
skillsIcon?: ReactNode;
|
skillsIcon?: ReactNode;
|
||||||
skillsTitle?: string;
|
skillsTitle?: string;
|
||||||
|
/** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */
|
||||||
|
belowSkillsSlot?: ReactNode;
|
||||||
showCommonQuestions?: boolean;
|
showCommonQuestions?: boolean;
|
||||||
/**
|
/**
|
||||||
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
|
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
|
||||||
@@ -940,6 +943,9 @@ export function AgentDetailPanel({
|
|||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
|
||||||
|
{belowSkillsSlot}
|
||||||
|
|
||||||
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
|
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
|
||||||
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
|
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import { MessageSquareIcon } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MyProjectThreadBadge,
|
||||||
|
MyProjectThreadIcon,
|
||||||
|
} from "@/components/workspace/my-project-thread-indicator";
|
||||||
import { useThreadsPage } from "@/core/threads/hooks";
|
import { useThreadsPage } from "@/core/threads/hooks";
|
||||||
|
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||||
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
|
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -45,6 +50,7 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
|||||||
{threads.map((thread) => {
|
{threads.map((thread) => {
|
||||||
const href = pathOfThread(thread);
|
const href = pathOfThread(thread);
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
|
const projectType = myProjectConversationTypeOfThread(thread);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={thread.thread_id}
|
key={thread.thread_id}
|
||||||
@@ -57,15 +63,20 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
|||||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MessageSquareIcon
|
{projectType ? (
|
||||||
className={cn(
|
<MyProjectThreadIcon type={projectType} />
|
||||||
"size-3.5 shrink-0",
|
) : (
|
||||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
<MessageSquareIcon
|
||||||
)}
|
className={cn(
|
||||||
/>
|
"size-3.5 shrink-0",
|
||||||
<span className="truncate">
|
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{desensitize(titleOfThread(thread, "新对话"))}
|
{desensitize(titleOfThread(thread, "新对话"))}
|
||||||
</span>
|
</span>
|
||||||
|
{projectType && <MyProjectThreadBadge type={projectType} />}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
FileType2Icon,
|
FileType2Icon,
|
||||||
|
GlobeIcon,
|
||||||
LoaderIcon,
|
LoaderIcon,
|
||||||
PackageIcon,
|
PackageIcon,
|
||||||
SaveIcon,
|
SaveIcon,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -56,8 +58,9 @@ import {
|
|||||||
} from "@/open-canvas/api/rewrite";
|
} from "@/open-canvas/api/rewrite";
|
||||||
import { publishArtifactToKnowledge } from "@/core/agents/api";
|
import { publishArtifactToKnowledge } from "@/core/agents/api";
|
||||||
import { useAgent } from "@/core/agents/hooks";
|
import { useAgent } from "@/core/agents/hooks";
|
||||||
import { apiFetch } from "@/core/api";
|
import { apiFetch } from "@/core/api";
|
||||||
import { copyTextToClipboard } from "@/core/clipboard";
|
import { copyTextToClipboard } from "@/core/clipboard";
|
||||||
|
import { siteStudioRoute, useWebAppCapability } from "@/core/sites/api";
|
||||||
import {
|
import {
|
||||||
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
||||||
DOCUMENT_REWRITE_STARTED_EVENT,
|
DOCUMENT_REWRITE_STARTED_EVENT,
|
||||||
@@ -209,6 +212,22 @@ function findScrollableArtifactElement(root: HTMLElement | null) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 识别 Web App 站点清单:`outputs/<site>/site.json`(兼容 `write-file:` 虚拟 URL
|
||||||
|
* 与查询串)→ 返回站点目录名;其它文件返回 null(按钮不渲染,零行为变化)。
|
||||||
|
*/
|
||||||
|
function extractSiteStudioTarget(filepath: string): string | null {
|
||||||
|
const normalized = filepath.replace(/^write-file:/, "").split("?")[0];
|
||||||
|
if (!/(^|\/)site\.json$/.test(normalized)) return null;
|
||||||
|
const parts = normalized.split("/").filter(Boolean);
|
||||||
|
const idx = parts.lastIndexOf("site.json");
|
||||||
|
if (idx <= 0) return null;
|
||||||
|
const siteName = parts[idx - 1];
|
||||||
|
if (!siteName || siteName === "outputs") return null;
|
||||||
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(siteName)) return null;
|
||||||
|
return siteName;
|
||||||
|
}
|
||||||
|
|
||||||
export function ArtifactFileDetail({
|
export function ArtifactFileDetail({
|
||||||
className,
|
className,
|
||||||
filepath: filepathFromProps,
|
filepath: filepathFromProps,
|
||||||
@@ -268,6 +287,14 @@ export function ArtifactFileDetail({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { artifacts, setOpen, select } = useArtifacts();
|
const { artifacts, setOpen, select } = useArtifacts();
|
||||||
const { isMock, thread, publicShareCode } = useThread();
|
const { isMock, thread, publicShareCode } = useThread();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
// Web App 站点清单(outputs/<site>/site.json)→「站点预览」入口。
|
||||||
|
// capability 不可用(未启用/未部署)时按钮不渲染,原 UI 一字不变。
|
||||||
|
const siteStudioTarget = useMemo(
|
||||||
|
() => extractSiteStudioTarget(filepathFromProps),
|
||||||
|
[filepathFromProps],
|
||||||
|
);
|
||||||
|
const webAppCapabilityQuery = useWebAppCapability(!!siteStudioTarget);
|
||||||
const isWriteFileVirtual = useMemo(() => {
|
const isWriteFileVirtual = useMemo(() => {
|
||||||
return filepathFromProps.startsWith("write-file:");
|
return filepathFromProps.startsWith("write-file:");
|
||||||
}, [filepathFromProps]);
|
}, [filepathFromProps]);
|
||||||
@@ -1427,7 +1454,9 @@ export function ArtifactFileDetail({
|
|||||||
toast.error("文件内容为空,暂时无法在新窗口打开");
|
toast.error("文件内容为空,暂时无法在新窗口打开");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const blob = new Blob([prepareGeneratedHtml(source)], {
|
const blob = new Blob([
|
||||||
|
prepareGeneratedHtml(source),
|
||||||
|
], {
|
||||||
type: "text/html;charset=utf-8",
|
type: "text/html;charset=utf-8",
|
||||||
});
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -1454,7 +1483,11 @@ export function ArtifactFileDetail({
|
|||||||
: language === "markdown"
|
: language === "markdown"
|
||||||
? "text/markdown;charset=utf-8"
|
? "text/markdown;charset=utf-8"
|
||||||
: "text/plain;charset=utf-8";
|
: "text/plain;charset=utf-8";
|
||||||
const blob = new Blob([source], { type: blobType });
|
const downloadSource =
|
||||||
|
language === "html"
|
||||||
|
? prepareGeneratedHtml(source)
|
||||||
|
: source;
|
||||||
|
const blob = new Blob([downloadSource], { type: blobType });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
@@ -1647,6 +1680,16 @@ export function ArtifactFileDetail({
|
|||||||
onClick={handleOpenInNewWindow}
|
onClick={handleOpenInNewWindow}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{siteStudioTarget && webAppCapabilityQuery.data?.available && (
|
||||||
|
<ArtifactAction
|
||||||
|
icon={GlobeIcon}
|
||||||
|
label="站点预览"
|
||||||
|
tooltip="打开站点预览工作室(多页面站点 / 流程图 / 地图)"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(siteStudioRoute(threadId, siteStudioTarget))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ArtifactAction
|
<ArtifactAction
|
||||||
icon={DownloadIcon}
|
icon={DownloadIcon}
|
||||||
label={t.common.download}
|
label={t.common.download}
|
||||||
@@ -2094,7 +2137,9 @@ export function ArtifactFilePreview({
|
|||||||
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
|
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
|
||||||
// Intercept in-page anchor clicks so they scroll within the iframe instead
|
// Intercept in-page anchor clicks so they scroll within the iframe instead
|
||||||
// of navigating the sandboxed blob: document (which blanks the frame).
|
// of navigating the sandboxed blob: document (which blanks the frame).
|
||||||
const blob = new Blob([prepareGeneratedHtml(content ?? "")], {
|
const blob = new Blob([
|
||||||
|
prepareGeneratedHtml(content ?? ""),
|
||||||
|
], {
|
||||||
type: "text/html;charset=utf-8",
|
type: "text/html;charset=utf-8",
|
||||||
});
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
getFileExtensionDisplayName,
|
getFileExtensionDisplayName,
|
||||||
getFileIcon,
|
getFileIcon,
|
||||||
getFileName,
|
getFileName,
|
||||||
|
isWordDocumentFile,
|
||||||
} from "@/core/utils/files";
|
} from "@/core/utils/files";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -93,12 +94,12 @@ export function ArtifactFileList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className={cn("flex w-full flex-col gap-4", className)}>
|
<ul className={cn("flex w-full flex-col gap-4", className)}>
|
||||||
{files.map((file) => (
|
{files.map((file) => (
|
||||||
<Card
|
<Card
|
||||||
key={file}
|
key={file}
|
||||||
className="relative cursor-pointer p-3"
|
className={cn("relative p-3", !isWordDocumentFile(file) && "cursor-pointer")}
|
||||||
onClick={() => handleClick(file)}
|
onClick={isWordDocumentFile(file) ? undefined : () => handleClick(file)}
|
||||||
>
|
>
|
||||||
<CardHeader className="grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 pr-2 pl-1">
|
<CardHeader className="grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 pr-2 pl-1">
|
||||||
<CardTitle className="relative min-w-0 pl-8 leading-tight [overflow-wrap:anywhere] break-words">
|
<CardTitle className="relative min-w-0 pl-8 leading-tight [overflow-wrap:anywhere] break-words">
|
||||||
<div className="min-w-0">{getFileName(file)}</div>
|
<div className="min-w-0">{getFileName(file)}</div>
|
||||||
@@ -133,7 +134,7 @@ export function ArtifactFileList({
|
|||||||
</Button>
|
</Button>
|
||||||
</CardAction>
|
</CardAction>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
<SkillInstallDialog
|
<SkillInstallDialog
|
||||||
open={!!installFile}
|
open={!!installFile}
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ import {
|
|||||||
listThreadArtifacts,
|
listThreadArtifacts,
|
||||||
mergeArtifactPaths,
|
mergeArtifactPaths,
|
||||||
} from "@/core/artifacts/listing";
|
} from "@/core/artifacts/listing";
|
||||||
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
|
import {
|
||||||
|
latestInFlightWriteFilePreview,
|
||||||
|
latestPreparedHtmlArtifact,
|
||||||
|
} from "@/core/artifacts/write-file-in-flight";
|
||||||
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
|
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||||
@@ -35,8 +38,10 @@ import {
|
|||||||
buildSkillDisplayMap,
|
buildSkillDisplayMap,
|
||||||
collectRuntimeDisplaySources,
|
collectRuntimeDisplaySources,
|
||||||
} from "@/core/skill-display/runtime";
|
} from "@/core/skill-display/runtime";
|
||||||
|
import { isWordDocumentFile } from "@/core/utils/files";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ArtifactFileDetail,
|
ArtifactFileDetail,
|
||||||
@@ -83,10 +88,12 @@ const ChatBox: React.FC<{
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
disableArtifacts?: boolean;
|
disableArtifacts?: boolean;
|
||||||
|
writingMode?: boolean;
|
||||||
}> = ({
|
}> = ({
|
||||||
children,
|
children,
|
||||||
threadId,
|
threadId,
|
||||||
disableArtifacts = false,
|
disableArtifacts = false,
|
||||||
|
writingMode,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
isMock,
|
isMock,
|
||||||
@@ -106,15 +113,17 @@ const ChatBox: React.FC<{
|
|||||||
const latestReferenceBatchIdRef = useRef<string | null>(null);
|
const latestReferenceBatchIdRef = useRef<string | null>(null);
|
||||||
const manualReferenceBatchRef = useRef(false);
|
const manualReferenceBatchRef = useRef(false);
|
||||||
const referenceDebugSignatureRef = useRef<string>("");
|
const referenceDebugSignatureRef = useRef<string>("");
|
||||||
|
const referenceBatchesRef = useRef<BackendReferenceBatch[]>([]);
|
||||||
const [sideTab, setSideTab] = useState("document");
|
const [sideTab, setSideTab] = useState("document");
|
||||||
const [referencesOpen, setReferencesOpen] = useState(true);
|
const [referencesOpen, setReferencesOpen] = useState(true);
|
||||||
const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null);
|
const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null);
|
||||||
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||||
|
const [wikiCitationSource, setWikiCitationSource] = useState<BackendReferenceSource | null>(null);
|
||||||
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
|
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
|
||||||
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
|
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
|
||||||
const writingModeEnabled = !!settings.context.writing_mode;
|
const writingModeEnabled = writingMode ?? !!settings.context.writing_mode;
|
||||||
const artifactDirectoryModeEnabled =
|
const artifactDirectoryModeEnabled =
|
||||||
settings.display.artifactDirectoryModeEnabled ?? false;
|
settings.display.artifactDirectoryModeEnabled ?? false;
|
||||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
@@ -351,6 +360,9 @@ const ChatBox: React.FC<{
|
|||||||
latestReferenceBatch;
|
latestReferenceBatch;
|
||||||
const referenceSources = activeReferenceBatch?.sources ?? [];
|
const referenceSources = activeReferenceBatch?.sources ?? [];
|
||||||
const hasCitationReferences = referenceSources.length > 0;
|
const hasCitationReferences = referenceSources.length > 0;
|
||||||
|
useEffect(() => {
|
||||||
|
referenceBatchesRef.current = referenceBatches;
|
||||||
|
}, [referenceBatches]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldFetchBackendReferenceBatches || !activeReferenceBatch) {
|
if (!shouldFetchBackendReferenceBatches || !activeReferenceBatch) {
|
||||||
return;
|
return;
|
||||||
@@ -396,6 +408,16 @@ const ChatBox: React.FC<{
|
|||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
setPinnedReferenceId(detail?.referenceId ?? null);
|
setPinnedReferenceId(detail?.referenceId ?? null);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
const referenceIndex = Number(detail?.referenceId);
|
||||||
|
const batch = referenceBatchesRef.current.find((item) => item.id === detail?.batchId)
|
||||||
|
?? referenceBatchesRef.current.at(-1);
|
||||||
|
const source = Number.isInteger(referenceIndex) && referenceIndex > 0
|
||||||
|
? batch?.sources.find((item) => item.index === referenceIndex)
|
||||||
|
?? batch?.sources[referenceIndex - 1]
|
||||||
|
: undefined;
|
||||||
|
if (source?.skillName?.startsWith("llmwiki_") || source?.type?.startsWith("llmwiki_")) {
|
||||||
|
setWikiCitationSource(source);
|
||||||
|
}
|
||||||
setReferencesOpen(true);
|
setReferencesOpen(true);
|
||||||
setSideTab("references");
|
setSideTab("references");
|
||||||
};
|
};
|
||||||
@@ -459,7 +481,12 @@ const ChatBox: React.FC<{
|
|||||||
) {
|
) {
|
||||||
if (thread?.values?.artifacts?.length > 0) {
|
if (thread?.values?.artifacts?.length > 0) {
|
||||||
setAutoSelectFirstArtifact(false);
|
setAutoSelectFirstArtifact(false);
|
||||||
selectArtifact(thread.values.artifacts[0]!);
|
const firstPreviewableArtifact = thread.values.artifacts.find(
|
||||||
|
(artifact) => !isWordDocumentFile(artifact),
|
||||||
|
);
|
||||||
|
if (firstPreviewableArtifact) {
|
||||||
|
selectArtifact(firstPreviewableArtifact);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -497,6 +524,25 @@ const ChatBox: React.FC<{
|
|||||||
setSideTab("document");
|
setSideTab("document");
|
||||||
}, [openStreamedArtifact, streamingWritePreview]);
|
}, [openStreamedArtifact, streamingWritePreview]);
|
||||||
|
|
||||||
|
// page_report_template(prepare) creates the output HTML before content
|
||||||
|
// editing starts. Open that real artifact immediately; this also covers
|
||||||
|
// models that use str_replace or whose write_file arguments arrive at once.
|
||||||
|
const preparedHtmlPreview = useMemo(
|
||||||
|
() =>
|
||||||
|
disableArtifacts || !thread.isLoading
|
||||||
|
? null
|
||||||
|
: latestPreparedHtmlArtifact(thread.messages ?? []),
|
||||||
|
[disableArtifacts, thread.isLoading, thread.messages],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!preparedHtmlPreview) return;
|
||||||
|
openStreamedArtifact(
|
||||||
|
preparedHtmlPreview.artifactPath,
|
||||||
|
preparedHtmlPreview.previewKey,
|
||||||
|
);
|
||||||
|
setSideTab("document");
|
||||||
|
}, [openStreamedArtifact, preparedHtmlPreview]);
|
||||||
|
|
||||||
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
|
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wasEnabled = directoryModeWasEnabledRef.current;
|
const wasEnabled = directoryModeWasEnabledRef.current;
|
||||||
@@ -504,7 +550,8 @@ const ChatBox: React.FC<{
|
|||||||
if (
|
if (
|
||||||
wasEnabled ||
|
wasEnabled ||
|
||||||
!artifactDirectoryModeEnabled ||
|
!artifactDirectoryModeEnabled ||
|
||||||
availableArtifacts.length === 0
|
availableArtifacts.length === 0 ||
|
||||||
|
isWordDocumentFile(availableArtifacts[availableArtifacts.length - 1]!)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -744,6 +791,17 @@ const ChatBox: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
<WeKnoraSourceDrawer
|
||||||
|
open={wikiCitationSource !== null}
|
||||||
|
target={null}
|
||||||
|
title={wikiCitationSource?.title || "Wiki 文档"}
|
||||||
|
quote={wikiCitationSource?.content || wikiCitationSource?.snippet || ""}
|
||||||
|
inlineContent={wikiCitationSource?.content || wikiCitationSource?.snippet || ""}
|
||||||
|
inlineKnowledgeBaseId={String(wikiCitationSource?.raw?.knowledge_base_id || "")}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setWikiCitationSource(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</ReferenceBatchesContext.Provider>
|
</ReferenceBatchesContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -756,19 +814,23 @@ function ReferencePanel({
|
|||||||
sources: Parameters<typeof SkillDisplaySources>[0]["sources"];
|
sources: Parameters<typeof SkillDisplaySources>[0]["sources"];
|
||||||
}) {
|
}) {
|
||||||
const orderedSources = useMemo(() => {
|
const orderedSources = useMemo(() => {
|
||||||
if (!highlightedReferenceId) {
|
// This is presentation-only. Keep every source's original `index`, since
|
||||||
return sources;
|
// inline citations resolve through that index (for example [3] must still
|
||||||
}
|
// open the third source returned by retrieval even when its card is shown
|
||||||
const index = Number(highlightedReferenceId);
|
// below a higher-scoring result).
|
||||||
if (!Number.isFinite(index)) {
|
return sources
|
||||||
return sources;
|
.map((source, originalOrder) => ({ source, originalOrder }))
|
||||||
}
|
.sort((left, right) => {
|
||||||
const selected = sources.find((source) => source.index === index);
|
const leftScore = typeof left.source.score === "number" && Number.isFinite(left.source.score)
|
||||||
if (!selected) {
|
? left.source.score
|
||||||
return sources;
|
: Number.NEGATIVE_INFINITY;
|
||||||
}
|
const rightScore = typeof right.source.score === "number" && Number.isFinite(right.source.score)
|
||||||
return [selected, ...sources.filter((source) => source.index !== index)];
|
? right.source.score
|
||||||
}, [highlightedReferenceId, sources]);
|
: Number.NEGATIVE_INFINITY;
|
||||||
|
return rightScore - leftScore || left.originalOrder - right.originalOrder;
|
||||||
|
})
|
||||||
|
.map(({ source }) => source);
|
||||||
|
}, [sources]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full flex-col overflow-hidden">
|
<div className="flex size-full flex-col overflow-hidden">
|
||||||
|
|||||||
@@ -29,13 +29,14 @@ import {
|
|||||||
PinOff,
|
PinOff,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
|
Settings,
|
||||||
Share2,
|
Share2,
|
||||||
Star,
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -60,7 +61,12 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||||
|
import {
|
||||||
|
MyProjectThreadBadge,
|
||||||
|
MyProjectThreadIcon,
|
||||||
|
} from "@/components/workspace/my-project-thread-indicator";
|
||||||
import { useAgentsPaginated, type Agent } from "@/core/agents";
|
import { useAgentsPaginated, type Agent } from "@/core/agents";
|
||||||
|
import { getAccountDisplayName } from "@/core/auth";
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
@@ -75,6 +81,7 @@ import {
|
|||||||
useToggleThreadPin,
|
useToggleThreadPin,
|
||||||
} from "@/core/threads/hooks";
|
} from "@/core/threads/hooks";
|
||||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||||
|
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||||
import {
|
import {
|
||||||
isBootstrapThread,
|
isBootstrapThread,
|
||||||
isHiddenFromRecentChats,
|
isHiddenFromRecentChats,
|
||||||
@@ -87,6 +94,13 @@ import {
|
|||||||
} from "@/core/threads/utils";
|
} from "@/core/threads/utils";
|
||||||
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
|
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
|
||||||
import { useRouteBase } from "@/core/route-base";
|
import { useRouteBase } from "@/core/route-base";
|
||||||
|
import {
|
||||||
|
usePublishSquares,
|
||||||
|
useUpdateWxCommonAgents,
|
||||||
|
useWxCommonAgents,
|
||||||
|
} from "@/core/system-settings/hooks";
|
||||||
|
import { SENTIMENT_AGENT_ID, isSentimentAgentVisible } from "@/core/sentiment-agent/config";
|
||||||
|
import { readSentimentAgentProfile } from "@/core/sentiment-agent/profile";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { isIMEComposing } from "@/lib/ime";
|
import { isIMEComposing } from "@/lib/ime";
|
||||||
@@ -107,37 +121,149 @@ function SectionLabel({
|
|||||||
count,
|
count,
|
||||||
collapsed,
|
collapsed,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onOpenSettings,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
onOpenSettings?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<div className="flex w-full items-center px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80">
|
||||||
type="button"
|
<button
|
||||||
onClick={onToggle}
|
type="button"
|
||||||
className="flex w-full items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none"
|
onClick={onToggle}
|
||||||
>
|
className="flex min-w-0 flex-1 items-center justify-between transition-colors hover:text-muted-foreground select-none"
|
||||||
<span className="flex items-center gap-1.5">
|
>
|
||||||
<span>{label}</span>
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
{count != null && (
|
<span className="truncate">{label}</span>
|
||||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
|
{count != null && (
|
||||||
{count}
|
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
|
||||||
</span>
|
{count}
|
||||||
)}
|
</span>
|
||||||
</span>
|
)}
|
||||||
<ChevronDown
|
</span>
|
||||||
className={cn(
|
<ChevronDown
|
||||||
"size-3.5 shrink-0 transition-transform duration-200",
|
className={cn(
|
||||||
collapsed && "-rotate-90",
|
"size-3.5 shrink-0 transition-transform duration-200",
|
||||||
)}
|
collapsed && "-rotate-90",
|
||||||
/>
|
)}
|
||||||
</button>
|
/>
|
||||||
|
</button>
|
||||||
|
{onOpenSettings && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
className="ml-1 inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
aria-label="设置智能体类型"
|
||||||
|
title="设置智能体类型"
|
||||||
|
>
|
||||||
|
<Settings className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GeneralQaSidebar() {
|
/** Gallery category values persisted for the dedicated wx full-chat route. */
|
||||||
|
const WX_AGENT_TYPE_MINE = "mine";
|
||||||
|
const WX_AGENT_TYPE_SENTIMENT = "sentiment";
|
||||||
|
const WX_AGENT_TYPE_SQUARE_PREFIX = "square:";
|
||||||
|
const WX_AGENT_TYPE_DEFAULT = "__default__";
|
||||||
|
|
||||||
|
type CommonAgent = Pick<Agent, "id" | "name" | "description">;
|
||||||
|
|
||||||
|
type WxAgentTypeOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function wxSquareAgentType(squareId: string) {
|
||||||
|
return `${WX_AGENT_TYPE_SQUARE_PREFIX}${squareId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wxSquareIdOf(agentType: string): string | undefined {
|
||||||
|
return agentType.startsWith(WX_AGENT_TYPE_SQUARE_PREFIX)
|
||||||
|
? agentType.slice(WX_AGENT_TYPE_SQUARE_PREFIX.length)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WxAgentTypeSettingsDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
agentType,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
agentType: string | null;
|
||||||
|
options: WxAgentTypeOption[];
|
||||||
|
}) {
|
||||||
|
const [selectedType, setSelectedType] = useState(
|
||||||
|
agentType ?? WX_AGENT_TYPE_DEFAULT,
|
||||||
|
);
|
||||||
|
const { mutateAsync: updateWxCommonAgents, isPending } =
|
||||||
|
useUpdateWxCommonAgents();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setSelectedType(agentType ?? WX_AGENT_TYPE_DEFAULT);
|
||||||
|
}, [agentType, open]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await updateWxCommonAgents(
|
||||||
|
selectedType === WX_AGENT_TYPE_DEFAULT ? null : selectedType,
|
||||||
|
);
|
||||||
|
toast.success("常用智能体类型已更新");
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "保存智能体类型失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[420px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>设置常用智能体类型</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2 py-2">
|
||||||
|
<label htmlFor="wx-common-agent-type" className="text-sm font-medium">
|
||||||
|
智能体类型
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="wx-common-agent-type"
|
||||||
|
value={selectedType}
|
||||||
|
onChange={(event) => setSelectedType(event.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={isPending}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GeneralQaSidebar({
|
||||||
|
wxCommonAgents,
|
||||||
|
}: {
|
||||||
|
/** Enables wx's shared Agent Gallery category selection. */
|
||||||
|
wxCommonAgents?: boolean;
|
||||||
|
} = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const routeBase = useRouteBase();
|
const routeBase = useRouteBase();
|
||||||
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
|
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
|
||||||
@@ -158,7 +284,60 @@ export function GeneralQaSidebar() {
|
|||||||
setAgentsCollapsed((v) => {
|
setAgentsCollapsed((v) => {
|
||||||
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
|
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
|
||||||
return !v;
|
return !v;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [agentTypeSettingsOpen, setAgentTypeSettingsOpen] = useState(false);
|
||||||
|
const { data: wxCommonAgentSettings } = useWxCommonAgents({
|
||||||
|
enabled: !!wxCommonAgents,
|
||||||
|
});
|
||||||
|
const { data: publishSquares } = usePublishSquares({
|
||||||
|
enabled: !!wxCommonAgents,
|
||||||
|
});
|
||||||
|
const isWxManager =
|
||||||
|
wxCommonAgents &&
|
||||||
|
getAccountDisplayName().toLowerCase() === "wx";
|
||||||
|
const sentimentVisible = isSentimentAgentVisible();
|
||||||
|
const wxCategoryOptions = useMemo<WxAgentTypeOption[]>(() => {
|
||||||
|
const options: WxAgentTypeOption[] = [
|
||||||
|
{ value: WX_AGENT_TYPE_MINE, label: "我的" },
|
||||||
|
...(publishSquares?.squares ?? []).map((square) => ({
|
||||||
|
value: wxSquareAgentType(square.id),
|
||||||
|
label: square.name,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
if (sentimentVisible) {
|
||||||
|
options.push({ value: WX_AGENT_TYPE_SENTIMENT, label: "舆情分析" });
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}, [publishSquares?.squares, sentimentVisible]);
|
||||||
|
const wxAgentTypeOptions = useMemo<WxAgentTypeOption[]>(
|
||||||
|
() => [
|
||||||
|
{ value: WX_AGENT_TYPE_DEFAULT, label: "默认推荐(未设置)" },
|
||||||
|
...wxCategoryOptions,
|
||||||
|
],
|
||||||
|
[wxCategoryOptions],
|
||||||
|
);
|
||||||
|
const configuredWxAgentType = wxCommonAgentSettings?.agent_type ?? null;
|
||||||
|
const wxUsesCategoryFilter = !!configuredWxAgentType && wxCategoryOptions.some(
|
||||||
|
(option) => option.value === configuredWxAgentType,
|
||||||
|
);
|
||||||
|
const wxSquareId =
|
||||||
|
wxCommonAgents && wxUsesCategoryFilter && configuredWxAgentType
|
||||||
|
? wxSquareIdOf(configuredWxAgentType)
|
||||||
|
: undefined;
|
||||||
|
const wxShowsSentiment =
|
||||||
|
wxCommonAgents &&
|
||||||
|
wxUsesCategoryFilter &&
|
||||||
|
configuredWxAgentType === WX_AGENT_TYPE_SENTIMENT;
|
||||||
|
const [sentimentProfile, setSentimentProfile] = useState(() =>
|
||||||
|
readSentimentAgentProfile(),
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const refresh = () => setSentimentProfile(readSentimentAgentProfile());
|
||||||
|
window.addEventListener("sentiment-agent-profile-changed", refresh);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener("sentiment-agent-profile-changed", refresh);
|
||||||
|
}, []);
|
||||||
const toggleChats = () =>
|
const toggleChats = () =>
|
||||||
setChatsCollapsed((v) => {
|
setChatsCollapsed((v) => {
|
||||||
localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v));
|
localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v));
|
||||||
@@ -166,9 +345,38 @@ export function GeneralQaSidebar() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)──
|
// ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)──
|
||||||
const { agents: mine } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
|
const { agents: mine } = useAgentsPaginated({
|
||||||
const { agents: square } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
|
scope: "mine",
|
||||||
const frequent = useMemo<Agent[]>(() => {
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
enabled: !wxUsesCategoryFilter,
|
||||||
|
});
|
||||||
|
const { agents: square } = useAgentsPaginated({
|
||||||
|
scope: "square",
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
enabled: !wxUsesCategoryFilter,
|
||||||
|
});
|
||||||
|
const { agents: wxAgents } = useAgentsPaginated({
|
||||||
|
scope: wxSquareId ? "square" : "mine",
|
||||||
|
page: 1,
|
||||||
|
pageSize: MAX_FREQUENT,
|
||||||
|
square: wxSquareId,
|
||||||
|
enabled: wxUsesCategoryFilter && !wxShowsSentiment,
|
||||||
|
});
|
||||||
|
const frequent = useMemo<CommonAgent[]>(() => {
|
||||||
|
if (wxUsesCategoryFilter) {
|
||||||
|
if (wxShowsSentiment) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: SENTIMENT_AGENT_ID,
|
||||||
|
name: sentimentProfile.name,
|
||||||
|
description: sentimentProfile.description,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return wxAgents.slice(0, MAX_FREQUENT);
|
||||||
|
}
|
||||||
const out: Agent[] = [];
|
const out: Agent[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const add = (agent: Agent | undefined) => {
|
const add = (agent: Agent | undefined) => {
|
||||||
@@ -180,7 +388,7 @@ export function GeneralQaSidebar() {
|
|||||||
mine.forEach(add);
|
mine.forEach(add);
|
||||||
square.forEach(add);
|
square.forEach(add);
|
||||||
return out;
|
return out;
|
||||||
}, [mine, square]);
|
}, [mine, sentimentProfile, square, wxAgents, wxShowsSentiment, wxUsesCategoryFilter]);
|
||||||
|
|
||||||
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
|
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -200,16 +408,25 @@ export function GeneralQaSidebar() {
|
|||||||
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
|
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
|
||||||
const handleSelectAgent = useCallback(
|
const handleSelectAgent = useCallback(
|
||||||
(agentId: string) => {
|
(agentId: string) => {
|
||||||
|
if (agentId === SENTIMENT_AGENT_ID) {
|
||||||
|
navigate(
|
||||||
|
buildFullChatNavigationPath(
|
||||||
|
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats/new`,
|
||||||
|
currentSearch,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
navigate(
|
navigate(
|
||||||
buildFullChatNavigationPath(
|
buildFullChatNavigationPath(
|
||||||
routeBase === "/page/workspace"
|
routeBase === "/page/workspace" || wxCommonAgents
|
||||||
? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
|
? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
|
||||||
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
|
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
|
||||||
currentSearch,
|
currentSearch,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[currentSearch, navigate, newChatPath, routeBase],
|
[currentSearch, navigate, newChatPath, routeBase, wxCommonAgents],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── 最近的对话取数 + 搜索 ──
|
// ── 最近的对话取数 + 搜索 ──
|
||||||
@@ -354,17 +571,24 @@ export function GeneralQaSidebar() {
|
|||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||||||
{/* ── 常用智能体 ── */}
|
{/* ── 常用智能体 ── */}
|
||||||
{frequent.length > 0 && (
|
{(frequent.length > 0 || isWxManager) && (
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<SectionLabel
|
<SectionLabel
|
||||||
label="常用智能体"
|
label="常用智能体"
|
||||||
|
count={wxUsesCategoryFilter ? frequent.length : undefined}
|
||||||
collapsed={agentsCollapsed}
|
collapsed={agentsCollapsed}
|
||||||
onToggle={toggleAgents}
|
onToggle={toggleAgents}
|
||||||
|
onOpenSettings={isWxManager ? () => setAgentTypeSettingsOpen(true) : undefined}
|
||||||
/>
|
/>
|
||||||
{!agentsCollapsed && (
|
{!agentsCollapsed && (
|
||||||
<div className="mt-1 flex flex-col gap-1">
|
<div className="mt-1 flex flex-col gap-1">
|
||||||
{frequent.map((agent) => {
|
{frequent.map((agent) => {
|
||||||
const isActive = activeAgentId === agent.id;
|
const isActive =
|
||||||
|
activeAgentId === agent.id ||
|
||||||
|
(agent.id === SENTIMENT_AGENT_ID &&
|
||||||
|
pathname.startsWith(
|
||||||
|
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats`,
|
||||||
|
));
|
||||||
const { from, to, foreground } = agentColor(agent.id, theme);
|
const { from, to, foreground } = agentColor(agent.id, theme);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -483,6 +707,7 @@ export function GeneralQaSidebar() {
|
|||||||
);
|
);
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
const isBoot = isBootstrapThread(thread);
|
const isBoot = isBootstrapThread(thread);
|
||||||
|
const projectType = myProjectConversationTypeOfThread(thread);
|
||||||
const pinned = isThreadPinned(thread);
|
const pinned = isThreadPinned(thread);
|
||||||
const favorited = isThreadFavorite(thread);
|
const favorited = isThreadFavorite(thread);
|
||||||
return (
|
return (
|
||||||
@@ -507,6 +732,8 @@ export function GeneralQaSidebar() {
|
|||||||
)}
|
)}
|
||||||
aria-label="智能体创建会话"
|
aria-label="智能体创建会话"
|
||||||
/>
|
/>
|
||||||
|
) : projectType ? (
|
||||||
|
<MyProjectThreadIcon type={projectType} />
|
||||||
) : (
|
) : (
|
||||||
<MessageSquare
|
<MessageSquare
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -518,9 +745,15 @@ export function GeneralQaSidebar() {
|
|||||||
{pinned && (
|
{pinned && (
|
||||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||||
)}
|
)}
|
||||||
<span className="truncate">
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||||
</span>
|
</span>
|
||||||
|
{projectType && (
|
||||||
|
<MyProjectThreadBadge
|
||||||
|
className="mr-4"
|
||||||
|
type={projectType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||||
)}
|
)}
|
||||||
@@ -651,6 +884,14 @@ export function GeneralQaSidebar() {
|
|||||||
if (!o) setShareThreadId(null);
|
if (!o) setShareThreadId(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{wxCommonAgents && isWxManager && (
|
||||||
|
<WxAgentTypeSettingsDialog
|
||||||
|
open={agentTypeSettingsOpen}
|
||||||
|
onOpenChange={setAgentTypeSettingsOpen}
|
||||||
|
agentType={wxUsesCategoryFilter ? configuredWxAgentType : null}
|
||||||
|
options={wxAgentTypeOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import type { ChatStatus } from "ai";
|
import type { ChatStatus } from "ai";
|
||||||
import {
|
import {
|
||||||
BrainCircuitIcon,
|
|
||||||
BookMarkedIcon,
|
BookMarkedIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
@@ -101,25 +100,10 @@ import {
|
|||||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||||
|
|
||||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||||
import { useThread } from "./messages/context";
|
import { resolveInputMode, type InputMode } from "./input-mode";
|
||||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
import { useThread } from "./messages/context";
|
||||||
import { Tooltip } from "./tooltip";
|
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||||
|
import { Tooltip } from "./tooltip";
|
||||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
|
||||||
|
|
||||||
|
|
||||||
function getResolvedMode(
|
|
||||||
mode: InputMode | undefined,
|
|
||||||
supportsThinking: boolean,
|
|
||||||
): InputMode {
|
|
||||||
if (!supportsThinking && mode !== "flash") {
|
|
||||||
return "flash";
|
|
||||||
}
|
|
||||||
if (mode) {
|
|
||||||
return mode;
|
|
||||||
}
|
|
||||||
return supportsThinking ? "pro" : "flash";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InputBox({
|
export function InputBox({
|
||||||
className,
|
className,
|
||||||
@@ -137,13 +121,13 @@ export function InputBox({
|
|||||||
onStop,
|
onStop,
|
||||||
promptPrefix,
|
promptPrefix,
|
||||||
agentSelector,
|
agentSelector,
|
||||||
knowledgeSelector,
|
knowledgeSelector,
|
||||||
|
knowledgeSuggestedQuestions = [],
|
||||||
extraModels = [],
|
extraModels = [],
|
||||||
assistantId,
|
assistantId,
|
||||||
hideAdminRecommendedQuestions,
|
hideAdminRecommendedQuestions,
|
||||||
compact,
|
compact,
|
||||||
hideContextTools,
|
hideContextTools,
|
||||||
hideMemoryTool = true,
|
|
||||||
...props
|
...props
|
||||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||||
assistantId?: string | null;
|
assistantId?: string | null;
|
||||||
@@ -151,14 +135,9 @@ export function InputBox({
|
|||||||
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
|
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
|
||||||
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
|
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
|
/** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等
|
||||||
* 受控场景用;文件/模型等其他按钮不受影响)。 */
|
* 受控场景用;文件/模型等其他按钮不受影响)。 */
|
||||||
hideContextTools?: boolean;
|
hideContextTools?: boolean;
|
||||||
/**
|
|
||||||
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
|
|
||||||
* 写作和参考文献开关不受影响。
|
|
||||||
*/
|
|
||||||
hideMemoryTool?: boolean;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
context: Omit<
|
context: Omit<
|
||||||
AgentThreadContext,
|
AgentThreadContext,
|
||||||
@@ -167,7 +146,6 @@ export function InputBox({
|
|||||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||||
writing_mode?: boolean;
|
writing_mode?: boolean;
|
||||||
memory_injection_enabled?: boolean;
|
|
||||||
};
|
};
|
||||||
extraHeader?: React.ReactNode;
|
extraHeader?: React.ReactNode;
|
||||||
isNewThread?: boolean;
|
isNewThread?: boolean;
|
||||||
@@ -184,7 +162,6 @@ export function InputBox({
|
|||||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||||
writing_mode?: boolean;
|
writing_mode?: boolean;
|
||||||
memory_injection_enabled?: boolean;
|
|
||||||
},
|
},
|
||||||
) => void;
|
) => void;
|
||||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||||
@@ -209,8 +186,10 @@ export function InputBox({
|
|||||||
selectedAgentId: string | null;
|
selectedAgentId: string | null;
|
||||||
onAgentChange: (agentId: string | null) => void;
|
onAgentChange: (agentId: string | null) => void;
|
||||||
};
|
};
|
||||||
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
|
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
|
||||||
knowledgeSelector?: ReactNode;
|
knowledgeSelector?: ReactNode;
|
||||||
|
/** High-value starter questions derived from the currently selected spaces. */
|
||||||
|
knowledgeSuggestedQuestions?: string[];
|
||||||
/** Page-scoped model choices implemented outside the DeerFlow model API. */
|
/** Page-scoped model choices implemented outside the DeerFlow model API. */
|
||||||
extraModels?: Model[];
|
extraModels?: Model[];
|
||||||
}) {
|
}) {
|
||||||
@@ -247,8 +226,9 @@ export function InputBox({
|
|||||||
|
|
||||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||||
const userPreferences = useUserPreferences();
|
const userPreferences = useUserPreferences();
|
||||||
const showAdminRecommendedQuestions =
|
const showAdminRecommendedQuestions =
|
||||||
!hideAdminRecommendedQuestions &&
|
!hideAdminRecommendedQuestions &&
|
||||||
|
knowledgeSuggestedQuestions.length === 0 &&
|
||||||
!userPreferences.hide_admin_prompt_prefix &&
|
!userPreferences.hide_admin_prompt_prefix &&
|
||||||
recommendedQuestions.length > 0;
|
recommendedQuestions.length > 0;
|
||||||
|
|
||||||
@@ -265,7 +245,7 @@ export function InputBox({
|
|||||||
const fallbackModel = currentModel ?? modelOptions[0]!;
|
const fallbackModel = currentModel ?? modelOptions[0]!;
|
||||||
const supportsThinking = fallbackModel.supports_thinking ?? false;
|
const supportsThinking = fallbackModel.supports_thinking ?? false;
|
||||||
const nextModelName = fallbackModel.name;
|
const nextModelName = fallbackModel.name;
|
||||||
const nextMode = getResolvedMode(context.mode, supportsThinking);
|
const nextMode = resolveInputMode(context.mode, supportsThinking);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
context.model_name === nextModelName &&
|
context.model_name === nextModelName &&
|
||||||
@@ -312,7 +292,7 @@ export function InputBox({
|
|||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
model_name,
|
model_name,
|
||||||
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
mode: resolveInputMode(context.mode, model.supports_thinking ?? false),
|
||||||
reasoning_effort: context.reasoning_effort,
|
reasoning_effort: context.reasoning_effort,
|
||||||
execution_profile: model.execution_profile,
|
execution_profile: model.execution_profile,
|
||||||
});
|
});
|
||||||
@@ -328,7 +308,7 @@ export function InputBox({
|
|||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
model_name: ordinaryModel?.name ?? context.model_name,
|
model_name: ordinaryModel?.name ?? context.model_name,
|
||||||
mode: getResolvedMode(
|
mode: resolveInputMode(
|
||||||
mode,
|
mode,
|
||||||
ordinaryModel?.supports_thinking ?? supportThinking,
|
ordinaryModel?.supports_thinking ?? supportThinking,
|
||||||
),
|
),
|
||||||
@@ -357,7 +337,7 @@ export function InputBox({
|
|||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
model_name: ragModel.name,
|
model_name: ragModel.name,
|
||||||
mode: getResolvedMode(
|
mode: resolveInputMode(
|
||||||
context.mode,
|
context.mode,
|
||||||
ragModel.supports_thinking ?? false,
|
ragModel.supports_thinking ?? false,
|
||||||
),
|
),
|
||||||
@@ -397,14 +377,6 @@ export function InputBox({
|
|||||||
});
|
});
|
||||||
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
||||||
|
|
||||||
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
|
||||||
const handleMemoryInjectionToggle = useCallback(() => {
|
|
||||||
onContextChange?.({
|
|
||||||
...context,
|
|
||||||
memory_injection_enabled: !memoryInjectionEnabled,
|
|
||||||
});
|
|
||||||
}, [context, memoryInjectionEnabled, onContextChange]);
|
|
||||||
|
|
||||||
const ragModeLocked = isThreadRagModeLocked(threadId);
|
const ragModeLocked = isThreadRagModeLocked(threadId);
|
||||||
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
|
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
|
||||||
const handleRagModeToggle = useCallback(() => {
|
const handleRagModeToggle = useCallback(() => {
|
||||||
@@ -444,7 +416,7 @@ export function InputBox({
|
|||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
model_name: resolvedModelName,
|
model_name: resolvedModelName,
|
||||||
mode: getResolvedMode(
|
mode: resolveInputMode(
|
||||||
context.mode,
|
context.mode,
|
||||||
selectedModel?.supports_thinking ?? false,
|
selectedModel?.supports_thinking ?? false,
|
||||||
),
|
),
|
||||||
@@ -616,13 +588,35 @@ export function InputBox({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
||||||
{extraHeader && (
|
{extraHeader && (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
{extraHeader}
|
{extraHeader}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
{isNewThread && searchParams.get("mode") !== "skill" && knowledgeSuggestedQuestions.length > 0 && (
|
||||||
|
<div className="px-4 mb-[10px]">
|
||||||
|
<div className="mb-2 text-center text-xs text-muted-foreground">基于已选知识空间,试试这样问</div>
|
||||||
|
<div className="flex flex-wrap justify-center gap-2.5">
|
||||||
|
{knowledgeSuggestedQuestions.slice(0, 4).map((question) => (
|
||||||
|
<button
|
||||||
|
key={question}
|
||||||
|
type="button"
|
||||||
|
className="max-w-full rounded-full border border-primary/20 bg-primary/5 px-4 py-2 text-left text-sm text-foreground transition-colors hover:bg-primary/10"
|
||||||
|
onClick={() => {
|
||||||
|
textInput.setInput(question);
|
||||||
|
setFollowupsHidden(true);
|
||||||
|
setTimeout(() => requestFormSubmit(), 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{question}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||||
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
||||||
{recommendedQuestions.map((q) => (
|
{recommendedQuestions.map((q) => (
|
||||||
<button
|
<button
|
||||||
@@ -733,12 +727,12 @@ export function InputBox({
|
|||||||
</PromptInputBody>
|
</PromptInputBody>
|
||||||
<PromptInputFooter
|
<PromptInputFooter
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center border-t-0 px-2 pt-1 pb-1.5",
|
"flex flex-wrap items-center gap-y-2 border-t-0 px-2 pt-1 pb-1.5",
|
||||||
compact && "gap-2 px-3 pt-1.5 pb-3",
|
compact && "gap-2 px-3 pt-1.5 pb-3",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<PromptInputTools
|
<PromptInputTools
|
||||||
className={cn("gap-1.5", compact && "flex-1 flex-wrap gap-2")}
|
className={cn("min-w-0 flex-1 flex-wrap gap-1.5", compact && "gap-2")}
|
||||||
>
|
>
|
||||||
{/* TODO: Add more connectors here
|
{/* TODO: Add more connectors here
|
||||||
<PromptInputActionMenu>
|
<PromptInputActionMenu>
|
||||||
@@ -813,32 +807,6 @@ export function InputBox({
|
|||||||
<span className="text-xs font-normal">文件</span>
|
<span className="text-xs font-normal">文件</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>}
|
</Tooltip>}
|
||||||
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
|
|
||||||
content={
|
|
||||||
memoryInjectionEnabled
|
|
||||||
? "记忆注入已开启:对话前会注入历史记忆"
|
|
||||||
: "记忆注入已关闭:本次对话不注入记忆"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PromptInputButton
|
|
||||||
className={cn(
|
|
||||||
"gap-1.5! rounded-full px-3! shadow-none",
|
|
||||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
|
||||||
memoryInjectionEnabled
|
|
||||||
? "border border-blue-300 bg-blue-50 text-blue-700 ring-1 ring-blue-300/35 hover:bg-blue-100 dark:border-cyan-300/70 dark:bg-cyan-400/20 dark:text-cyan-50 dark:ring-cyan-300/35 dark:shadow-[0_0_0_1px_rgba(34,211,238,0.18),0_0_18px_rgba(34,211,238,0.18)] dark:hover:bg-cyan-400/25"
|
|
||||||
: compact
|
|
||||||
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
||||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
|
||||||
)}
|
|
||||||
onClick={handleMemoryInjectionToggle}
|
|
||||||
>
|
|
||||||
{memoryInjectionEnabled && (
|
|
||||||
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" />
|
|
||||||
)}
|
|
||||||
<BrainCircuitIcon className="size-3" />
|
|
||||||
<span className="text-xs font-normal">记忆</span>
|
|
||||||
</PromptInputButton>
|
|
||||||
</Tooltip>}
|
|
||||||
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
|
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
|
||||||
content={
|
content={
|
||||||
ragModeEnabled
|
ragModeEnabled
|
||||||
@@ -1278,7 +1246,7 @@ export function InputBox({
|
|||||||
</PromptInputActionMenu>
|
</PromptInputActionMenu>
|
||||||
)}
|
)}
|
||||||
</PromptInputTools>
|
</PromptInputTools>
|
||||||
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
<PromptInputTools className={cn("ml-auto shrink-0 gap-1.5", compact && "gap-2")}>
|
||||||
{!frontendRagActive && agentSelector && (
|
{!frontendRagActive && agentSelector && (
|
||||||
<AgentSelector
|
<AgentSelector
|
||||||
selectedAgentId={agentSelector.selectedAgentId}
|
selectedAgentId={agentSelector.selectedAgentId}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
// Node's type-stripping test runner requires the explicit TypeScript suffix.
|
||||||
|
// @ts-expect-error The application compiler intentionally uses extensionless imports.
|
||||||
|
import { resolveInputMode } from "./input-mode.ts";
|
||||||
|
|
||||||
|
test("keeps plan and subagent modes for models without native thinking", () => {
|
||||||
|
assert.equal(resolveInputMode("pro", false), "pro");
|
||||||
|
assert.equal(resolveInputMode("ultra", false), "ultra");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back only when native thinking mode is unsupported", () => {
|
||||||
|
assert.equal(resolveInputMode("thinking", false), "flash");
|
||||||
|
assert.equal(resolveInputMode("thinking", true), "thinking");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the existing defaults when no mode has been chosen", () => {
|
||||||
|
assert.equal(resolveInputMode(undefined, false), "flash");
|
||||||
|
assert.equal(resolveInputMode(undefined, true), "pro");
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep plan/subagent modes available even when the selected model has no
|
||||||
|
* native thinking switch. The backend already falls back to non-thinking for
|
||||||
|
* that model, while `pro` and `ultra` still control planning and subagents.
|
||||||
|
* Only the model-specific `thinking` mode requires `supports_thinking`.
|
||||||
|
*/
|
||||||
|
export function resolveInputMode(
|
||||||
|
mode: InputMode | undefined,
|
||||||
|
supportsThinking: boolean,
|
||||||
|
): InputMode {
|
||||||
|
if (mode === "thinking" && !supportsThinking) {
|
||||||
|
return "flash";
|
||||||
|
}
|
||||||
|
if (mode) {
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
return supportsThinking ? "pro" : "flash";
|
||||||
|
}
|
||||||
@@ -6,13 +6,22 @@ import { toast } from "sonner";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||||
import {
|
import {
|
||||||
loadKnowledgeBaseProductVersion,
|
loadKnowledgeBaseProductVersion,
|
||||||
|
loadOrdinaryKnowledgeLanding,
|
||||||
|
saveOrdinaryKnowledgeLanding,
|
||||||
|
type OrdinaryKnowledgeLanding,
|
||||||
type KnowledgeBaseProductVersion,
|
type KnowledgeBaseProductVersion,
|
||||||
updateKnowledgeBaseProductVersion,
|
updateKnowledgeBaseProductVersion,
|
||||||
} from "@/core/system-settings/api";
|
} from "@/core/system-settings/api";
|
||||||
|
|
||||||
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
|
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
|
||||||
|
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
|
||||||
|
|
||||||
|
export function useOrdinaryKnowledgeLanding() {
|
||||||
|
return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 });
|
||||||
|
}
|
||||||
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
|
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
|
||||||
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
|
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
|
||||||
|
|
||||||
@@ -25,6 +34,14 @@ export function KnowledgeProductNav() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const version = useKnowledgeBaseProductVersion();
|
const version = useKnowledgeBaseProductVersion();
|
||||||
|
const landing = useOrdinaryKnowledgeLanding();
|
||||||
|
const saveLanding = useMutation({
|
||||||
|
mutationFn: saveOrdinaryKnowledgeLanding,
|
||||||
|
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
const runtime = useLlmWikiRuntime();
|
||||||
|
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: updateKnowledgeBaseProductVersion,
|
mutationFn: updateKnowledgeBaseProductVersion,
|
||||||
@@ -36,15 +53,20 @@ export function KnowledgeProductNav() {
|
|||||||
const assistant = location.pathname.includes("/knowledge/assistant");
|
const assistant = location.pathname.includes("/knowledge/assistant");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 border-b bg-card px-6 py-2">
|
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
|
||||||
<Button size="sm" variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||||
<BookOpen />普通知识库
|
<BookOpen />普通知识库
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
||||||
<Database />助手知识库
|
<Database />助手知识库
|
||||||
</Button>
|
</Button>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
普通知识库默认页面
|
||||||
|
<Select disabled={landing.isLoading || saveLanding.isPending} value={landing.data ?? "wiki"} onValueChange={(value) => saveLanding.mutate(value as OrdinaryKnowledgeLanding)}>
|
||||||
|
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
|
||||||
|
</Select>
|
||||||
默认产品版本
|
默认产品版本
|
||||||
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
|
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
|
||||||
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
||||||
|
|||||||
@@ -35,6 +35,27 @@ import {
|
|||||||
|
|
||||||
type SelectorSource = "all" | "agent";
|
type SelectorSource = "all" | "agent";
|
||||||
type SelectorVariant = "popover" | "panel";
|
type SelectorVariant = "popover" | "panel";
|
||||||
|
const EMPTY_IDS: string[] = [];
|
||||||
|
|
||||||
|
export function buildKnowledgeSpaceQuestions(names: string[]): string[] {
|
||||||
|
const cleanNames = names.map((name) => name.trim()).filter(Boolean);
|
||||||
|
if (cleanNames.length === 0) return [];
|
||||||
|
if (cleanNames.length === 1) {
|
||||||
|
const name = cleanNames[0]!;
|
||||||
|
return [
|
||||||
|
`请概括「${name}」知识空间的主要内容、适用场景和资料边界。`,
|
||||||
|
`「${name}」包含哪些核心主题?请按重要性说明并引用来源。`,
|
||||||
|
`请梳理「${name}」中的关键实体、概念及它们之间的关系。`,
|
||||||
|
`根据「${name}」,有哪些值得关注的问题、风险或行动建议?`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
`请综合所选 ${cleanNames.length} 个知识空间,总结共同主题、差异和互补信息。`,
|
||||||
|
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点,并引用各自来源。`,
|
||||||
|
"请梳理所选知识空间中的关键实体、概念及跨库关系。",
|
||||||
|
"所选知识空间中有哪些相互冲突或证据不足的结论?",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function groupRows(rows: LlmWikiKnowledgeBase[]) {
|
function groupRows(rows: LlmWikiKnowledgeBase[]) {
|
||||||
return [
|
return [
|
||||||
@@ -323,9 +344,14 @@ function SelectionContent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
|
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
|
||||||
<span className="text-[11px] text-muted-foreground">
|
<div>
|
||||||
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
|
<div className="text-[11px] text-muted-foreground">
|
||||||
</span>
|
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
|
||||||
|
</div>
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<div className="mt-0.5 text-[10px] text-primary">仅检索已选知识空间,不联网</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
|
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -363,9 +389,10 @@ function SelectionContent({
|
|||||||
|
|
||||||
export function LlmWikiKnowledgeSelector({
|
export function LlmWikiKnowledgeSelector({
|
||||||
value,
|
value,
|
||||||
assistantValue = [],
|
assistantValue = EMPTY_IDS,
|
||||||
onChange,
|
onChange,
|
||||||
onAssistantChange = () => undefined,
|
onAssistantChange = () => undefined,
|
||||||
|
onSuggestedQuestionsChange,
|
||||||
source = "all",
|
source = "all",
|
||||||
agentId,
|
agentId,
|
||||||
autoSelectAll = false,
|
autoSelectAll = false,
|
||||||
@@ -373,12 +400,15 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
readOnly = false,
|
readOnly = false,
|
||||||
className,
|
className,
|
||||||
label = "知识空间",
|
label = "知识空间",
|
||||||
|
panelTitle = "绑定知识空间",
|
||||||
|
panelDescription = "新建该智能体对话时,只能从已绑定空间中选择本次检索范围。",
|
||||||
variant = "popover",
|
variant = "popover",
|
||||||
}: {
|
}: {
|
||||||
value: string[];
|
value: string[];
|
||||||
assistantValue?: string[];
|
assistantValue?: string[];
|
||||||
onChange: (value: string[]) => void;
|
onChange: (value: string[]) => void;
|
||||||
onAssistantChange?: (value: string[]) => void;
|
onAssistantChange?: (value: string[]) => void;
|
||||||
|
onSuggestedQuestionsChange?: (questions: string[]) => void;
|
||||||
source?: SelectorSource;
|
source?: SelectorSource;
|
||||||
agentId?: string | null;
|
agentId?: string | null;
|
||||||
autoSelectAll?: boolean;
|
autoSelectAll?: boolean;
|
||||||
@@ -387,23 +417,38 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
panelTitle?: string;
|
||||||
|
panelDescription?: string;
|
||||||
variant?: SelectorVariant;
|
variant?: SelectorVariant;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const runtime = useLlmWikiRuntime();
|
const runtime = useLlmWikiRuntime();
|
||||||
const weknoraEnabled = runtime.data?.provider === "weknora";
|
const weknoraEnabled = runtime.data?.provider === "weknora";
|
||||||
|
const shouldLoadOptions =
|
||||||
|
variant === "panel" ||
|
||||||
|
open ||
|
||||||
|
autoSelectAll ||
|
||||||
|
value.length > 0 ||
|
||||||
|
assistantValue.length > 0;
|
||||||
const all = useLlmWikiKnowledgeBases(
|
const all = useLlmWikiKnowledgeBases(
|
||||||
"all",
|
"all",
|
||||||
weknoraEnabled,
|
weknoraEnabled && shouldLoadOptions,
|
||||||
);
|
);
|
||||||
const agent = useAgentLlmWikiKnowledgeBases(
|
const agent = useAgentLlmWikiKnowledgeBases(
|
||||||
agentId,
|
agentId,
|
||||||
weknoraEnabled && source === "agent" && !!agentId,
|
weknoraEnabled && shouldLoadOptions && source === "agent" && !!agentId,
|
||||||
);
|
);
|
||||||
const assistantBases = useAssistantKnowledgeBases();
|
const assistantBases = useAssistantKnowledgeBases(
|
||||||
const rows = weknoraEnabled ? (all.data ?? []) : [];
|
shouldLoadOptions ||
|
||||||
const assistantRows = assistantBases.data ?? [];
|
runtime.isError ||
|
||||||
|
(!runtime.isLoading && !weknoraEnabled),
|
||||||
|
);
|
||||||
|
// Stable empty results are essential: suggested questions are reported to
|
||||||
|
// the parent, so a fresh [] on every render creates an effect/update loop
|
||||||
|
// before lazy knowledge queries have loaded (or when they are disabled).
|
||||||
|
const rows = useMemo(() => weknoraEnabled ? (all.data ?? []) : [], [all.data, weknoraEnabled]);
|
||||||
|
const assistantRows = useMemo(() => assistantBases.data ?? [], [assistantBases.data]);
|
||||||
const defaultIds = useMemo(() => {
|
const defaultIds = useMemo(() => {
|
||||||
if (source !== "agent") {
|
if (source !== "agent") {
|
||||||
return rows.map((item) => item.id);
|
return rows.map((item) => item.id);
|
||||||
@@ -419,6 +464,26 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
);
|
);
|
||||||
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
|
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
|
||||||
const initialized = useRef<string | null>(null);
|
const initialized = useRef<string | null>(null);
|
||||||
|
const selectedNames = useMemo(
|
||||||
|
() => value.map((id) => rows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
|
||||||
|
[rows, value],
|
||||||
|
);
|
||||||
|
const selectedAssistantNames = useMemo(
|
||||||
|
() => assistantValue.map((id) => assistantRows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
|
||||||
|
[assistantRows, assistantValue],
|
||||||
|
);
|
||||||
|
const allSelectedNames = useMemo(
|
||||||
|
() => [...selectedNames, ...selectedAssistantNames],
|
||||||
|
[selectedAssistantNames, selectedNames],
|
||||||
|
);
|
||||||
|
const suggestedQuestions = useMemo(
|
||||||
|
() => buildKnowledgeSpaceQuestions(allSelectedNames),
|
||||||
|
[allSelectedNames],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onSuggestedQuestionsChange?.(suggestedQuestions);
|
||||||
|
}, [onSuggestedQuestionsChange, suggestedQuestions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!runtime.isLoading && !weknoraEnabled) {
|
if (!runtime.isLoading && !weknoraEnabled) {
|
||||||
@@ -448,7 +513,12 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
if (!open) setSearch("");
|
if (!open) setSearch("");
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
if ((runtime.isError || !weknoraEnabled) && !assistantBases.isLoading && assistantRows.length === 0) return null;
|
if (
|
||||||
|
!runtime.isLoading &&
|
||||||
|
(runtime.isError || !weknoraEnabled) &&
|
||||||
|
!assistantBases.isLoading &&
|
||||||
|
assistantRows.length === 0
|
||||||
|
) return null;
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<SelectionContent
|
<SelectionContent
|
||||||
@@ -474,10 +544,10 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
<div className="flex items-start justify-between gap-4 border-b px-4 py-3">
|
<div className="flex items-start justify-between gap-4 border-b px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
<Database className="size-4 text-primary" />绑定知识空间
|
<Database className="size-4 text-primary" />{panelTitle}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
|
{panelDescription}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
|
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
|
||||||
@@ -487,13 +557,6 @@ export function LlmWikiKnowledgeSelector({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedNames = value
|
|
||||||
.map((id) => rows.find((item) => item.id === id)?.name)
|
|
||||||
.filter((name): name is string => !!name);
|
|
||||||
const selectedAssistantNames = assistantValue
|
|
||||||
.map((id) => assistantRows.find((item) => item.id === id)?.name)
|
|
||||||
.filter((name): name is string => !!name);
|
|
||||||
const allSelectedNames = [...selectedNames, ...selectedAssistantNames];
|
|
||||||
const selectedCount = value.length + assistantValue.length;
|
const selectedCount = value.length + assistantValue.length;
|
||||||
const selectedLabel = allSelectedNames.length > 0
|
const selectedLabel = allSelectedNames.length > 0
|
||||||
? allSelectedNames.join("、")
|
? allSelectedNames.join("、")
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import {
|
|||||||
} from "@/core/utils/markdown";
|
} from "@/core/utils/markdown";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { VisualizationBlock } from "../visualizations/visualization-block";
|
||||||
|
import { splitVisualizationContent } from "../visualizations/visualization-spec";
|
||||||
|
|
||||||
import { CitationLink } from "../citations/citation-link";
|
import { CitationLink } from "../citations/citation-link";
|
||||||
|
|
||||||
function isExternalUrl(href: string | undefined): boolean {
|
function isExternalUrl(href: string | undefined): boolean {
|
||||||
@@ -50,6 +53,7 @@ export type MarkdownContentProps = {
|
|||||||
/** Renders markdown content. */
|
/** Renders markdown content. */
|
||||||
export function MarkdownContent({
|
export function MarkdownContent({
|
||||||
content,
|
content,
|
||||||
|
isLoading,
|
||||||
rehypePlugins,
|
rehypePlugins,
|
||||||
className,
|
className,
|
||||||
remarkPlugins = streamdownPlugins.remarkPlugins,
|
remarkPlugins = streamdownPlugins.remarkPlugins,
|
||||||
@@ -59,6 +63,7 @@ export function MarkdownContent({
|
|||||||
}: MarkdownContentProps) {
|
}: MarkdownContentProps) {
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
||||||
|
const visualizationSegments = useMemo(() => splitVisualizationContent(content), [content]);
|
||||||
useStreamdownMermaidPngDownload(rootRef);
|
useStreamdownMermaidPngDownload(rootRef);
|
||||||
const components = useMemo(() => {
|
const components = useMemo(() => {
|
||||||
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
|
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
|
||||||
@@ -139,17 +144,51 @@ export function MarkdownContent({
|
|||||||
|
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
|
|
||||||
|
// Preserve the exact legacy rendering path for ordinary Markdown. Heavy
|
||||||
|
// visualization renderers are lazy-loaded only when a dedicated fence exists.
|
||||||
|
if (
|
||||||
|
visualizationSegments.length === 1 &&
|
||||||
|
visualizationSegments[0]?.type === "markdown"
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
||||||
|
<MessageResponse
|
||||||
|
className={className}
|
||||||
|
remarkPlugins={remarkPlugins}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
mermaidConfig={streamdownPlugins.mermaidConfig}
|
||||||
|
components={components}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</MessageResponse>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
||||||
<MessageResponse
|
{visualizationSegments.map((segment, index) =>
|
||||||
className={className}
|
segment.type === "visualization" ? (
|
||||||
remarkPlugins={remarkPlugins}
|
<VisualizationBlock
|
||||||
rehypePlugins={rehypePlugins}
|
key={`visualization-${index}`}
|
||||||
mermaidConfig={streamdownPlugins.mermaidConfig}
|
language={segment.language}
|
||||||
components={components}
|
source={segment.source}
|
||||||
>
|
complete={segment.complete}
|
||||||
{content}
|
isLoading={isLoading}
|
||||||
</MessageResponse>
|
/>
|
||||||
|
) : segment.content ? (
|
||||||
|
<MessageResponse
|
||||||
|
key={`markdown-${index}`}
|
||||||
|
className={className}
|
||||||
|
remarkPlugins={remarkPlugins}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
mermaidConfig={streamdownPlugins.mermaidConfig}
|
||||||
|
components={components}
|
||||||
|
>
|
||||||
|
{segment.content}
|
||||||
|
</MessageResponse>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,6 @@ import { Tooltip } from "../tooltip";
|
|||||||
import { SourceCard } from "../skill-display-sources";
|
import { SourceCard } from "../skill-display-sources";
|
||||||
|
|
||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent } from "./markdown-content";
|
||||||
import { SingleRoundRagCitationDialog } from "./message-list-item";
|
|
||||||
|
|
||||||
export function MessageGroup({
|
export function MessageGroup({
|
||||||
className,
|
className,
|
||||||
@@ -138,19 +137,6 @@ export function MessageGroup({
|
|||||||
}
|
}
|
||||||
}, [lastToolCallStep, steps]);
|
}, [lastToolCallStep, steps]);
|
||||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||||
const [selectedCitation, setSelectedCitation] =
|
|
||||||
useState<RuntimeDisplaySource | null>(null);
|
|
||||||
const handleCitationClick = useCallback(
|
|
||||||
(referenceId: string) => {
|
|
||||||
const referenceIndex = Number(referenceId);
|
|
||||||
if (!Number.isInteger(referenceIndex) || referenceIndex < 1) return;
|
|
||||||
const source =
|
|
||||||
displaySources.find((item) => item.index === referenceIndex) ??
|
|
||||||
displaySources[referenceIndex - 1];
|
|
||||||
if (source) setSelectedCitation(source);
|
|
||||||
},
|
|
||||||
[displaySources],
|
|
||||||
);
|
|
||||||
const reasoningId =
|
const reasoningId =
|
||||||
lastReasoningStep?.id ??
|
lastReasoningStep?.id ??
|
||||||
messages.find((message) => message.type === "ai")?.id;
|
messages.find((message) => message.type === "ai")?.id;
|
||||||
@@ -200,7 +186,6 @@ export function MessageGroup({
|
|||||||
displaySources={displaySources}
|
displaySources={displaySources}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
rehypePlugins={rehypePlugins}
|
rehypePlugins={rehypePlugins}
|
||||||
onCitationClick={handleCitationClick}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
></ChainOfThoughtStep>
|
></ChainOfThoughtStep>
|
||||||
@@ -208,8 +193,9 @@ export function MessageGroup({
|
|||||||
<ToolCall
|
<ToolCall
|
||||||
key={step.id}
|
key={step.id}
|
||||||
{...step}
|
{...step}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
skillDisplayMap={skillDisplayMap}
|
skillDisplayMap={skillDisplayMap}
|
||||||
|
displaySources={displaySources}
|
||||||
ragModeEnabled={ragModeEnabled}
|
ragModeEnabled={ragModeEnabled}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -220,8 +206,9 @@ export function MessageGroup({
|
|||||||
key={lastToolCallStep.id}
|
key={lastToolCallStep.id}
|
||||||
{...lastToolCallStep}
|
{...lastToolCallStep}
|
||||||
isLast={true}
|
isLast={true}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
skillDisplayMap={skillDisplayMap}
|
skillDisplayMap={skillDisplayMap}
|
||||||
|
displaySources={displaySources}
|
||||||
ragModeEnabled={ragModeEnabled}
|
ragModeEnabled={ragModeEnabled}
|
||||||
/>
|
/>
|
||||||
</FlipDisplay>
|
</FlipDisplay>
|
||||||
@@ -262,7 +249,6 @@ export function MessageGroup({
|
|||||||
displaySources={displaySources}
|
displaySources={displaySources}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
rehypePlugins={rehypePlugins}
|
rehypePlugins={rehypePlugins}
|
||||||
onCitationClick={handleCitationClick}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
></ChainOfThoughtStep>
|
></ChainOfThoughtStep>
|
||||||
@@ -271,12 +257,6 @@ export function MessageGroup({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ChainOfThought>
|
</ChainOfThought>
|
||||||
<SingleRoundRagCitationDialog
|
|
||||||
source={selectedCitation}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setSelectedCitation(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -286,14 +266,12 @@ function StepReasoningMarkdown({
|
|||||||
displaySources,
|
displaySources,
|
||||||
isLoading,
|
isLoading,
|
||||||
rehypePlugins,
|
rehypePlugins,
|
||||||
onCitationClick,
|
}: {
|
||||||
}: {
|
|
||||||
content: string;
|
content: string;
|
||||||
displaySources: RuntimeDisplaySource[];
|
displaySources: RuntimeDisplaySource[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
onCitationClick: (referenceId: string) => void;
|
}) {
|
||||||
}) {
|
|
||||||
const markdown = useMemo(
|
const markdown = useMemo(
|
||||||
() =>
|
() =>
|
||||||
applyInlineCitations(content, displaySources, { appendSummary: false }),
|
applyInlineCitations(content, displaySources, { appendSummary: false }),
|
||||||
@@ -303,19 +281,18 @@ function StepReasoningMarkdown({
|
|||||||
if (displaySources.length === 0) return undefined;
|
if (displaySources.length === 0) return undefined;
|
||||||
return {
|
return {
|
||||||
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||||
if (href?.startsWith("#reference-")) {
|
if (href?.startsWith("#reference-")) {
|
||||||
return (
|
return (
|
||||||
<CitationLink
|
<CitationLink
|
||||||
href={href}
|
href={href}
|
||||||
{...props}
|
{...props}
|
||||||
onReferenceClick={onCitationClick}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return <a href={href} {...props} />;
|
return <a href={href} {...props} />;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [displaySources.length, onCitationClick]);
|
}, [displaySources.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MarkdownContent
|
<MarkdownContent
|
||||||
@@ -335,7 +312,8 @@ function ToolCall({
|
|||||||
result,
|
result,
|
||||||
isLast = false,
|
isLast = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
skillDisplayMap = new Map(),
|
skillDisplayMap = new Map(),
|
||||||
|
displaySources = [],
|
||||||
ragModeEnabled = false,
|
ragModeEnabled = false,
|
||||||
}: {
|
}: {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -345,7 +323,8 @@ function ToolCall({
|
|||||||
result?: string | Record<string, unknown>;
|
result?: string | Record<string, unknown>;
|
||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
|
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
|
||||||
|
displaySources?: RuntimeDisplaySource[];
|
||||||
ragModeEnabled?: boolean;
|
ragModeEnabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -354,21 +333,28 @@ function ToolCall({
|
|||||||
? null
|
? null
|
||||||
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
|
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
|
||||||
null);
|
null);
|
||||||
const displayItems =
|
const displayItems =
|
||||||
displayMatch && result !== undefined
|
displayMatch && result !== undefined
|
||||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
? syncDisplaySourceIndexes(rankDisplaySources(normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||||
...item,
|
...item,
|
||||||
index: index + 1,
|
index: index + 1,
|
||||||
skillName: displayMatch.skillName,
|
skillName: displayMatch.skillName,
|
||||||
mode: item.displayMode ?? displayMatch.display.mode,
|
mode: item.displayMode ?? displayMatch.display.mode,
|
||||||
}))
|
}))), displaySources)
|
||||||
: [];
|
: [];
|
||||||
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
|
const isLlmWikiTool = name === "llmwiki_search" || name === "llmwiki_read_wiki_page" || name === "llmwiki_browse_wiki_index";
|
||||||
const wikiSearchLabel = name === "llmwiki_search"
|
const wikiQuery = isLlmWikiTool ? resolveSearchQuery(args, result) : "";
|
||||||
? wikiQuery
|
const wikiSearchLabel = name === "llmwiki_search"
|
||||||
? `Wiki 文档检索 "${wikiQuery}"`
|
? wikiQuery
|
||||||
: "Wiki 文档检索"
|
? `Wiki 文档检索 "${wikiQuery}"`
|
||||||
: null;
|
: "Wiki 文档检索"
|
||||||
|
: name === "llmwiki_read_wiki_page"
|
||||||
|
? wikiQuery
|
||||||
|
? `读取 Wiki 页面「${wikiQuery}」`
|
||||||
|
: "读取 Wiki 页面"
|
||||||
|
: name === "llmwiki_browse_wiki_index"
|
||||||
|
? "读取 Wiki 索引与知识库摘要"
|
||||||
|
: null;
|
||||||
if (displayItems.length > 0) {
|
if (displayItems.length > 0) {
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
@@ -394,12 +380,12 @@ function ToolCall({
|
|||||||
|
|
||||||
const searchItems = normalizeSearchResults(result);
|
const searchItems = normalizeSearchResults(result);
|
||||||
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
||||||
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
|
const defaultItems = syncDisplaySourceIndexes(rankDisplaySources(normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||||
...item,
|
...item,
|
||||||
index: index + 1,
|
index: index + 1,
|
||||||
skillName: name,
|
skillName: name,
|
||||||
mode: "citation" as const,
|
mode: "citation" as const,
|
||||||
}));
|
}))), displaySources);
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
|
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
|
||||||
<DisplayResultsList results={defaultItems} />
|
<DisplayResultsList results={defaultItems} />
|
||||||
@@ -859,9 +845,21 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
|
|||||||
|
|
||||||
type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
||||||
|
|
||||||
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||||
const steps: CoTStep[] = [];
|
const steps: CoTStep[] = [];
|
||||||
for (const message of messages) {
|
// A streamed ToolMessage normally carries tool_call_id. If a provider
|
||||||
|
// omits it, the legacy name-based fallback is safe only for a unique tool
|
||||||
|
// name. Reusing one llmwiki_search result for several parallel calls makes
|
||||||
|
// every card appear to come from the same knowledge base.
|
||||||
|
const toolCallNameCounts = new Map<string, number>();
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.type !== "ai") continue;
|
||||||
|
for (const toolCall of message.tool_calls ?? []) {
|
||||||
|
const key = toolCall.name.toLowerCase();
|
||||||
|
toolCallNameCounts.set(key, (toolCallNameCounts.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const message of messages) {
|
||||||
if (message.type === "ai") {
|
if (message.type === "ai") {
|
||||||
const reasoning = extractReasoningContentFromMessage(message);
|
const reasoning = extractReasoningContentFromMessage(message);
|
||||||
if (reasoning) {
|
if (reasoning) {
|
||||||
@@ -902,11 +900,12 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
|||||||
};
|
};
|
||||||
const toolCallId = tool_call.id;
|
const toolCallId = tool_call.id;
|
||||||
if (toolCallId) {
|
if (toolCallId) {
|
||||||
const toolCallResult = findToolCallResult(
|
const toolCallResult = findToolCallResult(
|
||||||
toolCallId,
|
toolCallId,
|
||||||
messages,
|
messages,
|
||||||
tool_call.name,
|
tool_call.name,
|
||||||
);
|
{ allowNameFallback: toolCallNameCounts.get(tool_call.name.toLowerCase()) === 1 },
|
||||||
|
);
|
||||||
if (toolCallResult !== undefined) {
|
if (toolCallResult !== undefined) {
|
||||||
if (typeof toolCallResult === "string") {
|
if (typeof toolCallResult === "string") {
|
||||||
try {
|
try {
|
||||||
@@ -951,9 +950,27 @@ function WebSearchResultsList({ results }: { results: SearchResultItem[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
return (
|
const orderedResults = useMemo(
|
||||||
|
() =>
|
||||||
|
results
|
||||||
|
.map((item, originalOrder) => ({ item, originalOrder }))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftScore =
|
||||||
|
typeof left.item.score === "number" && Number.isFinite(left.item.score)
|
||||||
|
? left.item.score
|
||||||
|
: Number.NEGATIVE_INFINITY;
|
||||||
|
const rightScore =
|
||||||
|
typeof right.item.score === "number" && Number.isFinite(right.item.score)
|
||||||
|
? right.item.score
|
||||||
|
: Number.NEGATIVE_INFINITY;
|
||||||
|
return rightScore - leftScore || left.originalOrder - right.originalOrder;
|
||||||
|
})
|
||||||
|
.map(({ item }) => item),
|
||||||
|
[results],
|
||||||
|
);
|
||||||
|
return (
|
||||||
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
|
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
|
||||||
<CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs">
|
<CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs">
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
@@ -965,13 +982,52 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
|||||||
<span>{results.length} 条真实返回结果</span>
|
<span>{results.length} 条真实返回结果</span>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="mt-2 flex flex-col gap-2">
|
<CollapsibleContent className="mt-2 flex flex-col gap-2">
|
||||||
{results.map((item) => (
|
{orderedResults.map((item) => (
|
||||||
<SourceCard key={`${item.skillName}-${item.index}`} source={item} />
|
<SourceCard
|
||||||
))}
|
key={`${item.skillName}-${item.index}`}
|
||||||
|
source={item}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rankDisplaySources(
|
||||||
|
sources: RuntimeDisplaySource[],
|
||||||
|
): RuntimeDisplaySource[] {
|
||||||
|
return sources
|
||||||
|
.map((source, originalOrder) => ({ source, originalOrder }))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftScore =
|
||||||
|
typeof left.source.score === "number" && Number.isFinite(left.source.score)
|
||||||
|
? left.source.score
|
||||||
|
: Number.NEGATIVE_INFINITY;
|
||||||
|
const rightScore =
|
||||||
|
typeof right.source.score === "number" && Number.isFinite(right.source.score)
|
||||||
|
? right.source.score
|
||||||
|
: Number.NEGATIVE_INFINITY;
|
||||||
|
return rightScore - leftScore || left.originalOrder - right.originalOrder;
|
||||||
|
})
|
||||||
|
.map(({ source }, index) => ({ ...source, index: index + 1 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDisplaySourceIndexes(
|
||||||
|
sources: RuntimeDisplaySource[],
|
||||||
|
globalSources: RuntimeDisplaySource[],
|
||||||
|
): RuntimeDisplaySource[] {
|
||||||
|
const indexByCitationId = new Map(
|
||||||
|
globalSources.flatMap((source) => {
|
||||||
|
const id = typeof source.raw?.citation_id === "string" ? source.raw.citation_id : "";
|
||||||
|
return id ? [[id, source.index] as const] : [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return sources.map((source) => {
|
||||||
|
const id = typeof source.raw?.citation_id === "string" ? source.raw.citation_id : "";
|
||||||
|
const index = id ? indexByCitationId.get(id) : undefined;
|
||||||
|
return index ? { ...source, index } : source;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ import {
|
|||||||
getMultiRoundRagTrace,
|
getMultiRoundRagTrace,
|
||||||
isMultiRoundRagMessage,
|
isMultiRoundRagMessage,
|
||||||
} from '@/core/threads/use-multi-round-rag';
|
} from '@/core/threads/use-multi-round-rag';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { WeKnoraSourceDrawer } from '@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer';
|
||||||
|
|
||||||
import { CitationLink } from '../citations/citation-link';
|
import { CitationLink } from '../citations/citation-link';
|
||||||
import { CopyButton } from '../copy-button';
|
import { CopyButton } from '../copy-button';
|
||||||
@@ -890,11 +891,11 @@ function FrontendRagSourcesPanel({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{source.snippet && (
|
{(source.content || source.snippet) && (
|
||||||
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
|
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
|
||||||
{source.snippet}
|
{source.content || source.snippet}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
@@ -908,8 +909,26 @@ export function SingleRoundRagCitationDialog({
|
|||||||
}: {
|
}: {
|
||||||
source: RuntimeDisplaySource | null;
|
source: RuntimeDisplaySource | null;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
|
const isLlmWikiSource =
|
||||||
|
source?.skillName?.startsWith('llmwiki_') ||
|
||||||
|
source?.type?.startsWith('llmwiki_') ||
|
||||||
|
source?.raw?.type === 'llmwiki_wiki_index_intro';
|
||||||
|
if (isLlmWikiSource) {
|
||||||
|
const content = source?.content || source?.snippet || '';
|
||||||
|
return (
|
||||||
|
<WeKnoraSourceDrawer
|
||||||
|
open={!!source}
|
||||||
|
target={null}
|
||||||
|
title={source?.title || 'Wiki 文档'}
|
||||||
|
quote={content}
|
||||||
|
inlineContent={content}
|
||||||
|
inlineKnowledgeBaseId={String(source?.raw?.knowledge_base_id || '')}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
|
||||||
return (
|
return (
|
||||||
<Dialog open={!!source} onOpenChange={onOpenChange}>
|
<Dialog open={!!source} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
|
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function ModelNameSelect({
|
|||||||
className,
|
className,
|
||||||
triggerClassName,
|
triggerClassName,
|
||||||
defaultLabel = "默认模型",
|
defaultLabel = "默认模型",
|
||||||
|
title = "选择用于本次操作的大模型(默认使用系统配置的模型)",
|
||||||
}: {
|
}: {
|
||||||
value: string | null;
|
value: string | null;
|
||||||
onChange: (modelName: string | null) => void;
|
onChange: (modelName: string | null) => void;
|
||||||
@@ -32,6 +33,7 @@ export function ModelNameSelect({
|
|||||||
className?: string;
|
className?: string;
|
||||||
triggerClassName?: string;
|
triggerClassName?: string;
|
||||||
defaultLabel?: string;
|
defaultLabel?: string;
|
||||||
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
const { models } = useModels();
|
const { models } = useModels();
|
||||||
return (
|
return (
|
||||||
@@ -42,7 +44,7 @@ export function ModelNameSelect({
|
|||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
|
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
|
||||||
title="选择用于本次操作的大模型(默认使用系统配置的模型)"
|
title={title}
|
||||||
>
|
>
|
||||||
<SelectValue placeholder={defaultLabel} />
|
<SelectValue placeholder={defaultLabel} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { FolderKanban } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MY_PROJECT_CONVERSATION_LABELS,
|
||||||
|
type MyProjectConversationType,
|
||||||
|
} from "@/core/threads/project-metadata";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const BADGE_STYLES: Record<MyProjectConversationType, string> = {
|
||||||
|
qa: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-700/70 dark:bg-sky-950/55 dark:text-sky-300",
|
||||||
|
strategy:
|
||||||
|
"border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-700/70 dark:bg-violet-950/55 dark:text-violet-300",
|
||||||
|
solution:
|
||||||
|
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700/70 dark:bg-emerald-950/55 dark:text-emerald-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MyProjectThreadIcon({
|
||||||
|
type,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
type: MyProjectConversationType;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FolderKanban
|
||||||
|
aria-label={`我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
|
||||||
|
className={cn("size-3.5 shrink-0 text-violet-500", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MyProjectThreadBadge({
|
||||||
|
type,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
type: MyProjectConversationType;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded border px-1 py-0.5 text-[9px] font-medium leading-none",
|
||||||
|
BADGE_STYLES[type],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
title={`来自我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
|
||||||
|
>
|
||||||
|
{MY_PROJECT_CONVERSATION_LABELS[type]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,9 +47,14 @@ import {
|
|||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuAction,
|
SidebarMenuAction,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||||
|
import {
|
||||||
|
MyProjectThreadBadge,
|
||||||
|
MyProjectThreadIcon,
|
||||||
|
} from "@/components/workspace/my-project-thread-indicator";
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
@@ -63,7 +68,8 @@ import {
|
|||||||
useToggleThreadFavorite,
|
useToggleThreadFavorite,
|
||||||
useToggleThreadPin,
|
useToggleThreadPin,
|
||||||
} from "@/core/threads/hooks";
|
} from "@/core/threads/hooks";
|
||||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||||
|
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||||
import {
|
import {
|
||||||
isBootstrapThread,
|
isBootstrapThread,
|
||||||
isHiddenFromRecentChats,
|
isHiddenFromRecentChats,
|
||||||
@@ -92,7 +98,8 @@ export function RecentChatList({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { isMobile, setOpenMobile } = useSidebar();
|
||||||
const [internalCollapsed, setInternalCollapsed] = useState(
|
const [internalCollapsed, setInternalCollapsed] = useState(
|
||||||
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
|
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
|
||||||
);
|
);
|
||||||
@@ -134,15 +141,15 @@ export function RecentChatList({
|
|||||||
});
|
});
|
||||||
if (threadIndex > -1) {
|
if (threadIndex > -1) {
|
||||||
if (threads[threadIndex + 1]) {
|
if (threads[threadIndex + 1]) {
|
||||||
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
|
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!, pathname);
|
||||||
} else if (threads[threadIndex - 1]) {
|
} else if (threads[threadIndex - 1]) {
|
||||||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
|
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!, pathname);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void router.push(nextThreadPath);
|
void router.push(nextThreadPath);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[agentIdFromPath, deleteThread, router, threadIdFromPath, threads],
|
[agentIdFromPath, deleteThread, pathname, router, threadIdFromPath, threads],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRenameClick = useCallback(
|
const handleRenameClick = useCallback(
|
||||||
@@ -243,9 +250,10 @@ export function RecentChatList({
|
|||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<div className="flex w-full flex-col gap-0.5">
|
<div className="flex w-full flex-col gap-0.5">
|
||||||
{shownThreads.map((thread) => {
|
{shownThreads.map((thread) => {
|
||||||
const href = listPathOfThread(thread);
|
const href = listPathOfThread(thread, pathname);
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
const isBoot = isBootstrapThread(thread);
|
const isBoot = isBootstrapThread(thread);
|
||||||
|
const projectType = myProjectConversationTypeOfThread(thread);
|
||||||
const pinned = isThreadPinned(thread);
|
const pinned = isThreadPinned(thread);
|
||||||
const favorited = isThreadFavorite(thread);
|
const favorited = isThreadFavorite(thread);
|
||||||
return (
|
return (
|
||||||
@@ -267,7 +275,8 @@ export function RecentChatList({
|
|||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||||
href={href}
|
href={href}
|
||||||
|
onClick={() => { if (isMobile) setOpenMobile(false); }}
|
||||||
>
|
>
|
||||||
{isBoot ? (
|
{isBoot ? (
|
||||||
<Bot
|
<Bot
|
||||||
@@ -279,8 +288,10 @@ export function RecentChatList({
|
|||||||
}
|
}
|
||||||
aria-label="智能体创建会话"
|
aria-label="智能体创建会话"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : projectType ? (
|
||||||
compact && (
|
<MyProjectThreadIcon type={projectType} />
|
||||||
|
) : (
|
||||||
|
compact && (
|
||||||
<MessageSquare
|
<MessageSquare
|
||||||
className={
|
className={
|
||||||
"size-3.5 shrink-0 " +
|
"size-3.5 shrink-0 " +
|
||||||
@@ -294,9 +305,15 @@ export function RecentChatList({
|
|||||||
{pinned && (
|
{pinned && (
|
||||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||||
)}
|
)}
|
||||||
<span className="truncate">
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||||
</span>
|
</span>
|
||||||
|
{projectType && (
|
||||||
|
<MyProjectThreadBadge
|
||||||
|
className="mr-3"
|
||||||
|
type={projectType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { BrainIcon, Loader2Icon } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
useMemoryRuntimeSettings,
|
||||||
|
useUpdateMemoryRuntimeSettings,
|
||||||
|
} from "@/core/system-settings/hooks";
|
||||||
|
|
||||||
|
export function MemoryRuntimeSettingsPage() {
|
||||||
|
const { data, isLoading, isError } = useMemoryRuntimeSettings();
|
||||||
|
const update = useUpdateMemoryRuntimeSettings();
|
||||||
|
const enabled = data?.enabled ?? false;
|
||||||
|
|
||||||
|
const handleChange = async (checked: boolean) => {
|
||||||
|
try {
|
||||||
|
await update.mutateAsync({ enabled: checked });
|
||||||
|
toast.success(checked ? "问答记忆已开启" : "问答记忆已关闭");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="flex items-center gap-2 text-lg font-semibold">
|
||||||
|
<BrainIcon className="size-5" />
|
||||||
|
问答记忆
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
全局控制问答是否读取和写入长期记忆。系统默认永久关闭,只有管理员在这里显式开启后才会生效。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium">启用问答记忆</div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
关闭时不会加载记忆中间件、不会向提示词注入记忆,也不会在回答结束后调用模型提取记忆。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Switch
|
||||||
|
checked={enabled}
|
||||||
|
disabled={isError || update.isPending}
|
||||||
|
onCheckedChange={handleChange}
|
||||||
|
aria-label="启用问答记忆"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<p className="text-destructive text-sm">
|
||||||
|
无法读取记忆开关,系统将按关闭处理。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
修改对下一次发起的问答生效;已生成的历史记忆文件不会被自动删除。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BellIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
|
import { BellIcon, BrainIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +13,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||||||
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
|
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
|
||||||
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
|
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
|
||||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||||
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
|
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
|
||||||
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
@@ -61,15 +61,12 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
// },
|
// },
|
||||||
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||||
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
||||||
// 记忆已移至侧边栏入口 (/page/workspace/memory)
|
...(isAdmin
|
||||||
// {
|
? [{ id: "memory", label: "问答记忆", icon: BrainIcon }]
|
||||||
// id: "memory",
|
: []),
|
||||||
// label: t.settings.sections.memory,
|
|
||||||
// icon: BrainIcon,
|
|
||||||
// },
|
|
||||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||||
...(isAdmin
|
...(isAdmin
|
||||||
? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }]
|
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
id: "account",
|
id: "account",
|
||||||
@@ -132,7 +129,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
{activeSection === "appearance" && (
|
{activeSection === "appearance" && (
|
||||||
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
|
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
|
||||||
)}
|
)}
|
||||||
{activeSection === "memory" && <MemorySettingsPage />}
|
{activeSection === "memory" && isAdmin && (
|
||||||
|
<MemoryRuntimeSettingsPage />
|
||||||
|
)}
|
||||||
{activeSection === "tools" && <ToolSettingsPage />}
|
{activeSection === "tools" && <ToolSettingsPage />}
|
||||||
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
|
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
|
||||||
{activeSection === "about" && <AboutSettingsPage />}
|
{activeSection === "about" && <AboutSettingsPage />}
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||||
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
import {
|
||||||
|
resolveWeKnoraSourceTarget,
|
||||||
|
type WeKnoraSourceTarget,
|
||||||
|
} from "@/core/llmwiki/source-links";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||||
import { reasoningPlugins } from "@/core/streamdown";
|
import { reasoningPlugins } from "@/core/streamdown";
|
||||||
@@ -56,6 +59,7 @@ export function SourceCard({
|
|||||||
}) {
|
}) {
|
||||||
const [relatedOpen, setRelatedOpen] = useState(false);
|
const [relatedOpen, setRelatedOpen] = useState(false);
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [linkedTarget, setLinkedTarget] = useState<WeKnoraSourceTarget | null>(null);
|
||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
const previewTarget = resolveWeKnoraSourceTarget({
|
const previewTarget = resolveWeKnoraSourceTarget({
|
||||||
@@ -65,7 +69,11 @@ export function SourceCard({
|
|||||||
const cleanupWords =
|
const cleanupWords =
|
||||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||||||
|
const isWikiIndexSource =
|
||||||
|
source.type === "llmwiki_wiki_index_intro" ||
|
||||||
|
source.raw?.type === "llmwiki_wiki_index_intro";
|
||||||
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
|
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
|
||||||
|
const opensWikiDrawer = Boolean(previewTarget) || isWikiIndexSource;
|
||||||
const matchedHeading = isWikiSource
|
const matchedHeading = isWikiSource
|
||||||
? String(
|
? String(
|
||||||
(source.raw?.matched_heading as string | undefined) ||
|
(source.raw?.matched_heading as string | undefined) ||
|
||||||
@@ -78,7 +86,9 @@ export function SourceCard({
|
|||||||
const summary = isWikiSource && typeof source.raw?.summary === "string"
|
const summary = isWikiSource && typeof source.raw?.summary === "string"
|
||||||
? source.raw.summary
|
? source.raw.summary
|
||||||
: "";
|
: "";
|
||||||
const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
|
// A retrieval card represents the matched passage. A document/Wiki summary
|
||||||
|
// is useful only when the result did not include the actual passage.
|
||||||
|
const snippet = plainTextReferencePreview(content || summary).slice(0, 280);
|
||||||
const score =
|
const score =
|
||||||
typeof source.score === "number"
|
typeof source.score === "number"
|
||||||
? source.score <= 1
|
? source.score <= 1
|
||||||
@@ -95,9 +105,13 @@ export function SourceCard({
|
|||||||
score ? `相关度 ${score}` : null,
|
score ? `相关度 ${score}` : null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const isCitation = source.mode === "citation";
|
const isCitation = source.mode === "citation";
|
||||||
const showReferenceNumber = isCitation;
|
// Tool-trace cards are evidence previews, not the answer's reference list.
|
||||||
|
// Only the right-side reference panel receives `referenceTarget`, so keep
|
||||||
|
// citation numbers there and avoid presenting a second, misleading number
|
||||||
|
// in the middle retrieval trace.
|
||||||
|
const showReferenceNumber = isCitation && referenceTarget;
|
||||||
const openSource = () => {
|
const openSource = () => {
|
||||||
if (previewTarget) {
|
if (opensWikiDrawer) {
|
||||||
setDetailOpen(true);
|
setDetailOpen(true);
|
||||||
} else {
|
} else {
|
||||||
setRelatedOpen(true);
|
setRelatedOpen(true);
|
||||||
@@ -174,16 +188,18 @@ export function SourceCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{previewTarget && (
|
{(opensWikiDrawer || linkedTarget) && (
|
||||||
<WeKnoraSourceDrawer
|
<WeKnoraSourceDrawer
|
||||||
open={detailOpen}
|
open={detailOpen}
|
||||||
target={previewTarget}
|
target={linkedTarget ?? previewTarget}
|
||||||
title={source.title}
|
title={source.title}
|
||||||
quote={content}
|
quote={content}
|
||||||
|
inlineContent={isWikiIndexSource ? content : ""}
|
||||||
|
inlineKnowledgeBaseId={String(source.raw?.knowledge_base_id || "")}
|
||||||
onOpenChange={setDetailOpen}
|
onOpenChange={setDetailOpen}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!previewTarget && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
|
{!opensWikiDrawer && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
|
||||||
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
|
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-start justify-between gap-3 pr-8">
|
<div className="flex items-start justify-between gap-3 pr-8">
|
||||||
@@ -235,6 +251,18 @@ export function SourceCard({
|
|||||||
isLoading={false}
|
isLoading={false}
|
||||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||||
|
linkClassName={(href) =>
|
||||||
|
resolveWeKnoraSourceTarget({ url: href })?.wikiSlug
|
||||||
|
? "font-medium text-red-600 decoration-red-300 hover:text-red-700 hover:decoration-red-600 dark:text-red-400"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onLinkClick={(event, href) => {
|
||||||
|
const target = resolveWeKnoraSourceTarget({ url: href });
|
||||||
|
if (!target) return;
|
||||||
|
event.preventDefault();
|
||||||
|
setLinkedTarget(target);
|
||||||
|
setDetailOpen(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -205,21 +205,21 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
|
|||||||
/>
|
/>
|
||||||
<Database className="size-4 text-muted-foreground" />
|
<Database className="size-4 text-muted-foreground" />
|
||||||
<span className="text-sm">{target.target_name}</span>
|
<span className="text-sm">{target.target_name}</span>
|
||||||
<Badge variant="outline" className="ml-auto text-[10px]">WeKnora Wiki</Badge>
|
<Badge variant="outline" className="ml-auto text-[10px]">普通 Wiki 库</Badge>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
|
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
|
||||||
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
|
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
|
||||||
<p className="p-3 text-xs text-destructive">
|
<p className="p-3 text-xs text-destructive">
|
||||||
读取 WeKnora 普通知识库失败:
|
读取普通知识库失败:
|
||||||
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
|
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
|
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
|
||||||
<p className="p-3 text-xs text-muted-foreground">
|
<p className="p-3 text-xs text-muted-foreground">
|
||||||
{runtime.data?.provider !== "weknora"
|
{runtime.data?.provider !== "weknora"
|
||||||
? "当前未配置 WeKnora 普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
? "当前未配置普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
||||||
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
|
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import * as maplibregl from "maplibre-gl";
|
||||||
|
import type { StyleSpecification } from "maplibre-gl";
|
||||||
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
|
import { AlertTriangle, MapPinned, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { getMapTileAttribution, getMapTileUrl } from "@/core/utils/map-tile-config";
|
||||||
|
|
||||||
|
import type { MapSpec } from "./visualization-spec";
|
||||||
|
import { VisualizationCard } from "./visualization-card";
|
||||||
|
|
||||||
|
function createStyle(): StyleSpecification {
|
||||||
|
return {
|
||||||
|
version: 8,
|
||||||
|
sources: {
|
||||||
|
"google-raster": {
|
||||||
|
type: "raster",
|
||||||
|
tiles: [getMapTileUrl()],
|
||||||
|
tileSize: 256,
|
||||||
|
attribution: getMapTileAttribution(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [{ id: "google-raster", type: "raster", source: "google-raster" }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function allCoordinates(spec: MapSpec): Array<[number, number]> {
|
||||||
|
return [
|
||||||
|
...spec.markers.map((marker) => marker.coordinate),
|
||||||
|
...spec.lines.flatMap((line) => line.coordinates),
|
||||||
|
...spec.polygons.flatMap((polygon) => polygon.coordinates),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultCenter(spec: MapSpec): [number, number] {
|
||||||
|
if (spec.center) return spec.center;
|
||||||
|
const coordinates = allCoordinates(spec);
|
||||||
|
if (coordinates.length === 0) return [104.1954, 35.8617];
|
||||||
|
const total = coordinates.reduce(([lng, lat], coordinate) => [lng + coordinate[0], lat + coordinate[1]], [0, 0]);
|
||||||
|
return [total[0] / coordinates.length, total[1] / coordinates.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MapRenderer({ spec }: { spec: MapSpec }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
|
const [tileFailed, setTileFailed] = useState(false);
|
||||||
|
const [reloadToken, setReloadToken] = useState(0);
|
||||||
|
const coordinates = useMemo(() => allCoordinates(spec), [spec]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
setTileFailed(false);
|
||||||
|
|
||||||
|
const map = new maplibregl.Map({
|
||||||
|
container,
|
||||||
|
style: createStyle(),
|
||||||
|
center: defaultCenter(spec),
|
||||||
|
zoom: spec.zoom ?? (coordinates.length > 1 ? 4 : 8),
|
||||||
|
minZoom: 1,
|
||||||
|
maxZoom: 20,
|
||||||
|
attributionControl: { compact: true },
|
||||||
|
});
|
||||||
|
mapRef.current = map;
|
||||||
|
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
|
||||||
|
|
||||||
|
const markers: maplibregl.Marker[] = [];
|
||||||
|
for (const marker of spec.markers) {
|
||||||
|
const element = document.createElement("button");
|
||||||
|
element.type = "button";
|
||||||
|
element.className = "size-5 rounded-full border-2 border-white bg-primary shadow-md focus:outline-none focus:ring-2 focus:ring-primary/50";
|
||||||
|
element.title = marker.name;
|
||||||
|
element.setAttribute("aria-label", marker.name);
|
||||||
|
const popup = new maplibregl.Popup({ offset: 14 }).setHTML(
|
||||||
|
`<div style="max-width:240px"><strong>${escapeHtml(marker.name)}</strong>${marker.description ? `<p style="margin:6px 0 0">${escapeHtml(marker.description)}</p>` : ""}</div>`,
|
||||||
|
);
|
||||||
|
markers.push(new maplibregl.Marker({ element }).setLngLat(marker.coordinate).setPopup(popup).addTo(map));
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseTileLoaded = false;
|
||||||
|
const onSourceData = (event: maplibregl.MapSourceDataEvent) => {
|
||||||
|
if (event.sourceId !== "google-raster" || event.sourceDataType !== "content") return;
|
||||||
|
baseTileLoaded = true;
|
||||||
|
setTileFailed(false);
|
||||||
|
};
|
||||||
|
const onError = (event: maplibregl.ErrorEvent) => {
|
||||||
|
const message = event.error?.message ?? "";
|
||||||
|
if (!baseTileLoaded && /tile|fetch|network|request|http/i.test(message)) setTileFailed(true);
|
||||||
|
};
|
||||||
|
map.on("sourcedata", onSourceData);
|
||||||
|
map.on("error", onError);
|
||||||
|
|
||||||
|
map.on("load", () => {
|
||||||
|
if (spec.lines.length > 0) {
|
||||||
|
map.addSource("visual-lines", {
|
||||||
|
type: "geojson",
|
||||||
|
data: {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: spec.lines.map((line) => ({
|
||||||
|
type: "Feature",
|
||||||
|
properties: { name: line.name ?? "" },
|
||||||
|
geometry: { type: "LineString", coordinates: line.coordinates },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: "visual-lines",
|
||||||
|
type: "line",
|
||||||
|
source: "visual-lines",
|
||||||
|
paint: { "line-color": "#2563eb", "line-width": 3, "line-opacity": 0.85 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spec.polygons.length > 0) {
|
||||||
|
map.addSource("visual-polygons", {
|
||||||
|
type: "geojson",
|
||||||
|
data: {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: spec.polygons.map((polygon) => ({
|
||||||
|
type: "Feature",
|
||||||
|
properties: { name: polygon.name ?? "" },
|
||||||
|
geometry: { type: "Polygon", coordinates: [[...polygon.coordinates, polygon.coordinates[0]]] },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: "visual-polygons-fill",
|
||||||
|
type: "fill",
|
||||||
|
source: "visual-polygons",
|
||||||
|
paint: { "fill-color": "#2563eb", "fill-opacity": 0.18 },
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: "visual-polygons-border",
|
||||||
|
type: "line",
|
||||||
|
source: "visual-polygons",
|
||||||
|
paint: { "line-color": "#2563eb", "line-width": 2 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!spec.center && coordinates.length > 1) {
|
||||||
|
const bounds = coordinates.reduce(
|
||||||
|
(current, coordinate) => current.extend(coordinate),
|
||||||
|
new maplibregl.LngLatBounds(coordinates[0], coordinates[0]),
|
||||||
|
);
|
||||||
|
map.fitBounds(bounds, { padding: 52, maxZoom: 12, duration: 0 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off("sourcedata", onSourceData);
|
||||||
|
map.off("error", onError);
|
||||||
|
markers.forEach((marker) => marker.remove());
|
||||||
|
mapRef.current = null;
|
||||||
|
map.remove();
|
||||||
|
};
|
||||||
|
}, [coordinates, reloadToken, spec]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VisualizationCard>
|
||||||
|
<div className="flex min-h-12 items-center gap-2 border-b px-3 py-2">
|
||||||
|
<MapPinned aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
||||||
|
<span className="truncate text-sm font-medium">{spec.title}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">{spec.markers.length} 个地点</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<div ref={containerRef} className="h-[420px] w-full" role="img" aria-label={`${spec.title},包含 ${spec.markers.length} 个地点`} />
|
||||||
|
{tileFailed ? (
|
||||||
|
<div className="absolute inset-x-3 top-3 z-10 flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50/95 p-3 text-xs text-amber-900 shadow-sm" role="status">
|
||||||
|
<AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<span className="flex-1">地图底图当前不可访问;请检查网络。地点和线路数据不会丢失。</span>
|
||||||
|
<Button
|
||||||
|
aria-label="重新加载地图"
|
||||||
|
className="shrink-0 text-amber-950 hover:bg-amber-100"
|
||||||
|
onClick={() => {
|
||||||
|
setTileFailed(false);
|
||||||
|
setReloadToken((value) => value + 1);
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<RefreshCw aria-hidden="true" />
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{spec.markers.length > 0 ? (
|
||||||
|
<details className="border-t px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
<summary className="cursor-pointer select-none">查看地点列表</summary>
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{spec.markers.map((marker, index) => (
|
||||||
|
<li key={`${marker.name}-${index}`}>
|
||||||
|
<span className="font-medium text-foreground">{marker.name}</span>
|
||||||
|
<span>({marker.coordinate[0]},{marker.coordinate[1]})</span>
|
||||||
|
{marker.description ? <span>:{marker.description}</span> : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</VisualizationCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value.replace(/[&<>'"]/g, (character) => ({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
"'": "'",
|
||||||
|
'"': """,
|
||||||
|
})[character] ?? character);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Component, lazy, Suspense, useMemo, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseVisualizationSpec,
|
||||||
|
type VisualizationLanguage,
|
||||||
|
} from "./visualization-spec";
|
||||||
|
|
||||||
|
const MapRenderer = lazy(() => import("./map-renderer"));
|
||||||
|
|
||||||
|
type VisualizationBlockProps = {
|
||||||
|
language: VisualizationLanguage;
|
||||||
|
source: string;
|
||||||
|
complete: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function LoadingCard({ label = "正在生成地图内容…" }: { label?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="my-4 flex min-h-32 w-full max-w-none items-center justify-center rounded-xl border bg-muted/20 text-muted-foreground"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<Loader2 aria-hidden="true" className="mr-2 size-4 animate-spin" />
|
||||||
|
<span className="text-sm">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorCard({ message, source }: { message: string; source?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="my-4 w-full max-w-none rounded-xl border border-destructive/30 bg-destructive/5 p-4"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2 text-sm text-destructive">
|
||||||
|
<AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">地图内容暂时无法显示</div>
|
||||||
|
<div className="mt-1 text-xs opacity-80">{message}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{source ? (
|
||||||
|
<details className="mt-3 text-xs text-muted-foreground">
|
||||||
|
<summary className="cursor-pointer select-none">查看原始数据</summary>
|
||||||
|
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded-lg bg-muted/50 p-3">
|
||||||
|
{source}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class VisualizationErrorBoundary extends Component<
|
||||||
|
{ children: ReactNode; source: string },
|
||||||
|
{ error: string | null }
|
||||||
|
> {
|
||||||
|
state = { error: null as string | null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: unknown) {
|
||||||
|
return { error: error instanceof Error ? error.message : "地图渲染失败" };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: unknown, info: ErrorInfo) {
|
||||||
|
console.error("Map renderer failed", error, info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.error) {
|
||||||
|
return <ErrorCard message={this.state.error} source={this.props.source} />;
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VisualizationBlock({
|
||||||
|
language,
|
||||||
|
source,
|
||||||
|
complete,
|
||||||
|
isLoading,
|
||||||
|
}: VisualizationBlockProps) {
|
||||||
|
const result = useMemo(
|
||||||
|
() => parseVisualizationSpec(language, source),
|
||||||
|
[language, source],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
if (isLoading && !complete) return <LoadingCard />;
|
||||||
|
return <ErrorCard message={result.error} source={source} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VisualizationErrorBoundary key={`${language}-${source.length}`} source={source}>
|
||||||
|
<Suspense fallback={<LoadingCard label="正在加载本地地图组件…" />}>
|
||||||
|
<MapRenderer spec={result.spec} />
|
||||||
|
</Suspense>
|
||||||
|
</VisualizationErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function VisualizationCard({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
"my-4 w-full max-w-none overflow-hidden rounded-xl border bg-background shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
// @ts-expect-error Node's type-stripping test runner requires the explicit .ts suffix.
|
||||||
|
import { parseVisualizationSpec, splitVisualizationContent } from "./visualization-spec.ts";
|
||||||
|
|
||||||
|
test("ordinary markdown remains one unchanged segment", () => {
|
||||||
|
const content = "普通回答\n\n```ts\nconst value = 1;\n```";
|
||||||
|
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("splits a dedicated map block while keeping surrounding markdown", () => {
|
||||||
|
const content = [
|
||||||
|
"下面是地图:\n",
|
||||||
|
"```map-json",
|
||||||
|
'{"title":"地点","markers":[{"name":"北京","coordinate":[116.4,39.9]}]}',
|
||||||
|
"```",
|
||||||
|
"\n地图结束。",
|
||||||
|
].join("\n");
|
||||||
|
const segments = splitVisualizationContent(content);
|
||||||
|
assert.equal(segments.length, 3);
|
||||||
|
assert.equal(segments[0]?.type, "markdown");
|
||||||
|
assert.equal(segments[1]?.type, "visualization");
|
||||||
|
assert.equal(segments[2]?.type, "markdown");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts the map contract", () => {
|
||||||
|
const map = parseVisualizationSpec(
|
||||||
|
"map-json",
|
||||||
|
JSON.stringify({ markers: [{ name: "北京", coordinate: [116.4, 39.9] }] }),
|
||||||
|
);
|
||||||
|
assert.equal(map.ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders a valid map contract from a generic json fence", () => {
|
||||||
|
const content = [
|
||||||
|
"下面是地图:",
|
||||||
|
"```json",
|
||||||
|
'{"title":"城市分布","markers":[{"name":"北京","coordinate":[116.4,39.9]}]}',
|
||||||
|
"```",
|
||||||
|
].join("\n");
|
||||||
|
const segments = splitVisualizationContent(content);
|
||||||
|
const visual = segments.find((segment) => segment.type === "visualization");
|
||||||
|
assert.ok(visual && visual.type === "visualization");
|
||||||
|
assert.equal(visual.language, "map-json");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps non-map json fences on the ordinary Markdown path", () => {
|
||||||
|
const content = '```json\n{"name":"普通数据","items":[1,2,3]}\n```';
|
||||||
|
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps unfinished generic json fences on the ordinary Markdown path", () => {
|
||||||
|
const content = '```json\n{"markers":[';
|
||||||
|
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid map coordinates", () => {
|
||||||
|
const result = parseVisualizationSpec(
|
||||||
|
"map-json",
|
||||||
|
JSON.stringify({ markers: [{ name: "错误地点", coordinate: [200, 100] }] }),
|
||||||
|
);
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("flow and chart fences stay on the ordinary Markdown path", () => {
|
||||||
|
const content = "```flow-json\n{}\n```\n```chart-json\n{}\n```";
|
||||||
|
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps an unfinished map fence for streaming fallback", () => {
|
||||||
|
const segments = splitVisualizationContent("说明\n```map-json\n{\"title\":\"地点\"");
|
||||||
|
const visual = segments.find((segment) => segment.type === "visualization");
|
||||||
|
assert.ok(visual && visual.type === "visualization");
|
||||||
|
assert.equal(visual.complete, false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const VISUALIZATION_LANGUAGES = ["map-json"] as const;
|
||||||
|
|
||||||
|
export type VisualizationLanguage = (typeof VISUALIZATION_LANGUAGES)[number];
|
||||||
|
|
||||||
|
const safeText = z.string().trim().min(1).max(500);
|
||||||
|
const optionalText = z.string().trim().max(2_000).optional();
|
||||||
|
|
||||||
|
const longitude = z.number().finite().min(-180).max(180);
|
||||||
|
const latitude = z.number().finite().min(-90).max(90);
|
||||||
|
const coordinateSchema = z.tuple([longitude, latitude]);
|
||||||
|
|
||||||
|
const mapSchema = z.object({
|
||||||
|
type: z.literal("map").default("map"),
|
||||||
|
title: z.string().trim().max(200).default("地图"),
|
||||||
|
center: coordinateSchema.optional(),
|
||||||
|
zoom: z.number().finite().min(1).max(20).optional(),
|
||||||
|
markers: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: safeText,
|
||||||
|
coordinate: coordinateSchema,
|
||||||
|
description: optionalText,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.max(500)
|
||||||
|
.default([]),
|
||||||
|
lines: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().trim().max(120).optional(),
|
||||||
|
coordinates: z.array(coordinateSchema).min(2).max(2_000),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.max(100)
|
||||||
|
.default([]),
|
||||||
|
polygons: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().trim().max(120).optional(),
|
||||||
|
coordinates: z.array(coordinateSchema).min(3).max(2_000),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.max(100)
|
||||||
|
.default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MapSpec = z.infer<typeof mapSchema>;
|
||||||
|
|
||||||
|
export type VisualizationParseResult =
|
||||||
|
| { ok: true; spec: MapSpec }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
function normalizeLanguageType(
|
||||||
|
language: VisualizationLanguage,
|
||||||
|
value: unknown,
|
||||||
|
): unknown {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||||
|
const record = { ...(value as Record<string, unknown>) };
|
||||||
|
if (language === "map-json") record.type = "map";
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatZodError(error: z.ZodError): string {
|
||||||
|
const issue = error.issues[0];
|
||||||
|
if (!issue) return "地图数据格式不正确";
|
||||||
|
const path = issue.path.length > 0 ? `${issue.path.join(".")}:` : "";
|
||||||
|
return `${path}${issue.message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseVisualizationSpec(
|
||||||
|
language: VisualizationLanguage,
|
||||||
|
source: string,
|
||||||
|
): VisualizationParseResult {
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(source);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "地图数据尚未完整或不是有效 JSON" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeLanguageType(language, raw);
|
||||||
|
if (!normalized || typeof normalized !== "object" || Array.isArray(normalized)) {
|
||||||
|
return { ok: false, error: "地图数据必须是 JSON 对象" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = mapSchema.safeParse(normalized);
|
||||||
|
if (!parsed.success) return { ok: false, error: formatZodError(parsed.error) };
|
||||||
|
return { ok: true, spec: parsed.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VisualizationSegment =
|
||||||
|
| { type: "markdown"; content: string }
|
||||||
|
| {
|
||||||
|
type: "visualization";
|
||||||
|
language: VisualizationLanguage;
|
||||||
|
source: string;
|
||||||
|
complete: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FENCE_PATTERN = /(^|\n)```(map-json|json)[ \t]*\r?\n/gi;
|
||||||
|
|
||||||
|
function isMapJsonSource(source: string): boolean {
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(source);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
|
||||||
|
const record = raw as Record<string, unknown>;
|
||||||
|
const hasMapField = ["center", "zoom", "markers", "lines", "polygons"].some(
|
||||||
|
(field) => Object.prototype.hasOwnProperty.call(record, field),
|
||||||
|
);
|
||||||
|
return hasMapField && parseVisualizationSpec("map-json", source).ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split only our dedicated fences. With no match, the caller keeps the exact legacy renderer path. */
|
||||||
|
export function splitVisualizationContent(content: string): VisualizationSegment[] {
|
||||||
|
const segments: VisualizationSegment[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
FENCE_PATTERN.lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = FENCE_PATTERN.exec(content)) !== null) {
|
||||||
|
const blockStart = match.index + match[1].length;
|
||||||
|
if (blockStart > cursor) {
|
||||||
|
segments.push({ type: "markdown", content: content.slice(cursor, blockStart) });
|
||||||
|
}
|
||||||
|
const sourceStart = FENCE_PATTERN.lastIndex;
|
||||||
|
const closeIndex = content.indexOf("```", sourceStart);
|
||||||
|
const complete = closeIndex >= 0;
|
||||||
|
const source = content.slice(sourceStart, complete ? closeIndex : content.length).trim();
|
||||||
|
const matchedLanguage = match[2].toLowerCase();
|
||||||
|
if (matchedLanguage === "json" && (!complete || !isMapJsonSource(source))) {
|
||||||
|
if (!complete) break;
|
||||||
|
FENCE_PATTERN.lastIndex = closeIndex + 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
segments.push({
|
||||||
|
type: "visualization",
|
||||||
|
language: "map-json",
|
||||||
|
source,
|
||||||
|
complete,
|
||||||
|
});
|
||||||
|
cursor = complete ? closeIndex + 3 : content.length;
|
||||||
|
FENCE_PATTERN.lastIndex = cursor;
|
||||||
|
if (!complete) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor < content.length) {
|
||||||
|
segments.push({ type: "markdown", content: content.slice(cursor) });
|
||||||
|
}
|
||||||
|
return segments.length > 0 ? segments : [{ type: "markdown", content }];
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, FolderKanban, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||||
|
|
||||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||||
import { SidebarCollapseButton } from "./workspace-header";
|
import { SidebarCollapseButton } from "./workspace-header";
|
||||||
@@ -24,7 +24,10 @@ import {
|
|||||||
applyCompactMenuOverrides,
|
applyCompactMenuOverrides,
|
||||||
type CompactMenuItem,
|
type CompactMenuItem,
|
||||||
} from "@/core/page-layout/compact-menu-overrides";
|
} from "@/core/page-layout/compact-menu-overrides";
|
||||||
import { type MenuItem } from "@/core/page-layout/sidebar-menu";
|
import {
|
||||||
|
MARKDOWN_DOCX_MENU_ITEM,
|
||||||
|
type MenuItem,
|
||||||
|
} from "@/core/page-layout/sidebar-menu";
|
||||||
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
|
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
|
||||||
|
|
||||||
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
||||||
@@ -51,6 +54,13 @@ export type WorkspaceCompactNavItem = MenuItem & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||||
|
{
|
||||||
|
id: "workspace-projects",
|
||||||
|
label: "项目空间",
|
||||||
|
icon: BookOpen,
|
||||||
|
path: "/page/workspace/projects",
|
||||||
|
match: (pathname) => pathname.startsWith("/page/workspace/projects"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "workspace-new-chat",
|
id: "workspace-new-chat",
|
||||||
label: "新对话",
|
label: "新对话",
|
||||||
@@ -65,6 +75,15 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
|||||||
path: "/page/workspace/chats",
|
path: "/page/workspace/chats",
|
||||||
match: (pathname) => pathname === "/page/workspace/chats",
|
match: (pathname) => pathname === "/page/workspace/chats",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "strategy-projects",
|
||||||
|
label: "我的项目",
|
||||||
|
icon: FolderKanban,
|
||||||
|
path: "/page/strategy/qa/projects",
|
||||||
|
match: (pathname) =>
|
||||||
|
pathname.startsWith("/page/strategy/qa/projects") ||
|
||||||
|
pathname.startsWith("/page/strategy/qa/strategy-selection"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "workspace-library",
|
id: "workspace-library",
|
||||||
label: "资料库",
|
label: "资料库",
|
||||||
@@ -86,13 +105,6 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
|||||||
path: "/page/workspace/skills",
|
path: "/page/workspace/skills",
|
||||||
match: (pathname) => pathname.startsWith("/page/workspace/skills"),
|
match: (pathname) => pathname.startsWith("/page/workspace/skills"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "workspace-memory",
|
|
||||||
label: "记忆",
|
|
||||||
icon: BrainIcon,
|
|
||||||
path: "/page/workspace/memory",
|
|
||||||
match: (pathname) => pathname.startsWith("/page/workspace/memory"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "workspace-ai-writing",
|
id: "workspace-ai-writing",
|
||||||
label: "AI写作",
|
label: "AI写作",
|
||||||
@@ -140,7 +152,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
|||||||
label: "知识库管理",
|
label: "知识库管理",
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
path: "/page/workspace/knowledge",
|
path: "/page/workspace/knowledge",
|
||||||
match: (pathname) => pathname.startsWith("/page/workspace/knowledge"),
|
match: (pathname) =>
|
||||||
|
pathname.startsWith("/page/workspace/knowledge") &&
|
||||||
|
!pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...MARKDOWN_DOCX_MENU_ITEM,
|
||||||
|
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -18,15 +18,18 @@ import { WorkspaceHeader } from "./workspace-header";
|
|||||||
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
||||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||||
|
|
||||||
export function WorkspaceSidebar({
|
export function WorkspaceSidebar({
|
||||||
compactChrome = false,
|
compactChrome = false,
|
||||||
forceCollapsed = false,
|
forceCollapsed = false,
|
||||||
className,
|
wxCommonAgents = false,
|
||||||
...props
|
className,
|
||||||
}: React.ComponentProps<typeof Sidebar> & {
|
...props
|
||||||
compactChrome?: boolean;
|
}: React.ComponentProps<typeof Sidebar> & {
|
||||||
forceCollapsed?: boolean;
|
compactChrome?: boolean;
|
||||||
}) {
|
forceCollapsed?: boolean;
|
||||||
|
/** Enables wx's shared Agent Gallery category selection. */
|
||||||
|
wxCommonAgents?: boolean;
|
||||||
|
}) {
|
||||||
const { open: isSidebarOpen } = useSidebar();
|
const { open: isSidebarOpen } = useSidebar();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -35,6 +38,8 @@ export function WorkspaceSidebar({
|
|||||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
||||||
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
||||||
const isGeneralQa =
|
const isGeneralQa =
|
||||||
|
wxCommonAgents ||
|
||||||
|
pathname.startsWith("/page/workspace/projects") ||
|
||||||
pathname.startsWith("/page/workspace/chats") ||
|
pathname.startsWith("/page/workspace/chats") ||
|
||||||
pathname.startsWith("/page/strategy/chats");
|
pathname.startsWith("/page/strategy/chats");
|
||||||
|
|
||||||
@@ -84,7 +89,7 @@ export function WorkspaceSidebar({
|
|||||||
{isGeneralQa ? (
|
{isGeneralQa ? (
|
||||||
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
||||||
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
||||||
<GeneralQaSidebar />
|
<GeneralQaSidebar wxCommonAgents={wxCommonAgents} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { apiFetch } from "@/core/api";
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
|
export type ProjectMode = "qa" | "strategy" | "solution";
|
||||||
|
export type ProjectReportConfig = {
|
||||||
|
custom_outline: string;
|
||||||
|
structure_mode: "fixed" | "adaptive";
|
||||||
|
retrieval_directions: string[];
|
||||||
|
retrieval_skills: string[];
|
||||||
|
};
|
||||||
|
export type ProjectStrategyConfig = { lenses: string[] };
|
||||||
|
export const DEFAULT_STRATEGY_LENSES = [
|
||||||
|
"增长视角",
|
||||||
|
"风险视角",
|
||||||
|
"竞争视角",
|
||||||
|
"资源视角",
|
||||||
|
"长期价值视角",
|
||||||
|
] as const;
|
||||||
|
export type ProjectFields = {
|
||||||
|
name: string;
|
||||||
|
background: string;
|
||||||
|
knowledge_base_ids: string[];
|
||||||
|
assistant_knowledge_base_ids: string[];
|
||||||
|
agent_id: string | null;
|
||||||
|
report_config: ProjectReportConfig;
|
||||||
|
strategy_config: ProjectStrategyConfig;
|
||||||
|
};
|
||||||
|
export type AnalysisProject = ProjectFields & {
|
||||||
|
id: string;
|
||||||
|
mode: ProjectMode;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
solution_template: { id: string; name: string; outline: string } | null;
|
||||||
|
};
|
||||||
|
export type ProjectThread = {
|
||||||
|
thread_id: string;
|
||||||
|
title?: string;
|
||||||
|
status: string;
|
||||||
|
updated_at: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
export const PROJECT_MODES = {
|
||||||
|
qa: { name: "问答模式", description: "围绕项目资料问答、解释与总结" },
|
||||||
|
strategy: {
|
||||||
|
name: "策略智能模式",
|
||||||
|
description: "选择分析视角,开展多视角推演、策略辩论与综合决策",
|
||||||
|
},
|
||||||
|
solution: {
|
||||||
|
name: "方案生成模式",
|
||||||
|
description: "选择报告结构,沿用研究管线生成方案",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
export const PROJECT_OUTLINE = [
|
||||||
|
"背景与依据",
|
||||||
|
"目标与约束",
|
||||||
|
"实施路径与重点任务",
|
||||||
|
"资源配置与时间计划",
|
||||||
|
"风险与应对措施",
|
||||||
|
"评估指标与参考依据",
|
||||||
|
];
|
||||||
|
export const projectKeys = () =>
|
||||||
|
["analysis-projects", getStoredAuth()?.user_id ?? "default"] as const;
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/analysis-projects${path}`,
|
||||||
|
{
|
||||||
|
...init,
|
||||||
|
headers: { "Content-Type": "application/json", ...init?.headers },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = (await response.json().catch(() => ({}))) as {
|
||||||
|
detail?: unknown;
|
||||||
|
};
|
||||||
|
throw new Error(
|
||||||
|
typeof error.detail === "string"
|
||||||
|
? error.detail
|
||||||
|
: `项目操作失败(${response.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
export function useAnalysisProjects() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: projectKeys(),
|
||||||
|
queryFn: () => request<{ projects: AnalysisProject[] }>(""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function useAnalysisProject(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [...projectKeys(), id],
|
||||||
|
queryFn: () => request<AnalysisProject>(`/${encodeURIComponent(id)}`),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function useProjectThreads(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [...projectKeys(), id, "threads"],
|
||||||
|
queryFn: () =>
|
||||||
|
request<{ threads: ProjectThread[] }>(
|
||||||
|
`/${encodeURIComponent(id)}/threads`,
|
||||||
|
),
|
||||||
|
enabled: !!id,
|
||||||
|
refetchInterval: 15000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function useProjectThreadAccess(projectId: string, threadId?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [...projectKeys(), projectId, "thread", threadId],
|
||||||
|
queryFn: () =>
|
||||||
|
request<ProjectThread>(
|
||||||
|
`/${encodeURIComponent(projectId)}/threads/${encodeURIComponent(threadId!)}`,
|
||||||
|
),
|
||||||
|
enabled: !!threadId,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export const createAnalysisProject = (
|
||||||
|
fields: ProjectFields & { mode: ProjectMode },
|
||||||
|
) =>
|
||||||
|
request<AnalysisProject>("", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(fields),
|
||||||
|
});
|
||||||
|
export const updateAnalysisProject = (id: string, fields: ProjectFields) =>
|
||||||
|
request<AnalysisProject>(`/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(fields),
|
||||||
|
});
|
||||||
|
export const createProjectThread = (id: string, agentId?: string | null) =>
|
||||||
|
request<ProjectThread>(`/${encodeURIComponent(id)}/threads`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(agentId === undefined ? {} : { agent_id: agentId }),
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
|
|
||||||
|
export const STRATEGY_COLUMNS = [
|
||||||
|
"意图理解",
|
||||||
|
"多视角策略分析",
|
||||||
|
"完整策略方案",
|
||||||
|
] as const;
|
||||||
|
const STRATEGY_HEADING_INDEX: Record<string, number> = {
|
||||||
|
意图理解: 0,
|
||||||
|
意图理解与分析角度: 0,
|
||||||
|
多视角策略分析: 1,
|
||||||
|
推荐策略: 1,
|
||||||
|
完整策略方案: 2,
|
||||||
|
综合结果: 2,
|
||||||
|
结论: 2,
|
||||||
|
};
|
||||||
|
/** Parse only user-facing text. Never render tool payloads or reasoning fields. */
|
||||||
|
export function splitStrategySections(text: string): [string, string, string] {
|
||||||
|
const parts: string[][] = [[], [], []];
|
||||||
|
let section = 0;
|
||||||
|
let fence = false;
|
||||||
|
for (const line of text.replace(/\r\n/g, "\n").split("\n")) {
|
||||||
|
if (/^\s*```/.test(line)) fence = !fence;
|
||||||
|
const heading =
|
||||||
|
!fence &&
|
||||||
|
line.match(
|
||||||
|
/^\s*#{1,2}\s+(?:[一二三123][、..]\s*)?(意图理解与分析角度|意图理解|多视角策略分析|推荐策略|完整策略方案|综合结果|结论)\s*$/,
|
||||||
|
);
|
||||||
|
if (heading) {
|
||||||
|
section = STRATEGY_HEADING_INDEX[heading[1]!] ?? section;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
parts[section]!.push(line);
|
||||||
|
}
|
||||||
|
return parts.map((lines) => lines.join("\n").trim()) as [
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StrategySubsection = { title: string; content: string };
|
||||||
|
export type StrategyClarification = {
|
||||||
|
question: string;
|
||||||
|
context?: string;
|
||||||
|
options: { id: string; label: string; description?: string }[];
|
||||||
|
allow_custom: boolean;
|
||||||
|
};
|
||||||
|
export function pendingStrategyClarification(
|
||||||
|
messages: Message[],
|
||||||
|
): StrategyClarification | null {
|
||||||
|
let pending: StrategyClarification | null = null;
|
||||||
|
for (const message of messages) {
|
||||||
|
const raw = message.additional_kwargs?.clarification;
|
||||||
|
if (raw && typeof raw === "object") {
|
||||||
|
const data = raw as Record<string, unknown>;
|
||||||
|
const question = typeof data.question === "string" ? data.question : "";
|
||||||
|
if (!question) continue;
|
||||||
|
pending = {
|
||||||
|
question,
|
||||||
|
context: typeof data.context === "string" ? data.context : undefined,
|
||||||
|
options: Array.isArray(data.options)
|
||||||
|
? data.options.flatMap((item) => {
|
||||||
|
if (!item || typeof item !== "object") return [];
|
||||||
|
const option = item as Record<string, unknown>;
|
||||||
|
if (typeof option.label !== "string") return [];
|
||||||
|
return [{
|
||||||
|
id: typeof option.id === "string" ? option.id : option.label,
|
||||||
|
label: option.label,
|
||||||
|
description:
|
||||||
|
typeof option.description === "string"
|
||||||
|
? option.description
|
||||||
|
: undefined,
|
||||||
|
}];
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
allow_custom: data.allow_custom !== false,
|
||||||
|
};
|
||||||
|
} else if (pending && message.type === "human") {
|
||||||
|
pending = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
export function splitStrategySubsections(text: string): StrategySubsection[] {
|
||||||
|
const sections: StrategySubsection[] = [];
|
||||||
|
let current: StrategySubsection | null = null;
|
||||||
|
let fence = false;
|
||||||
|
for (const line of text.replace(/\r\n/g, "\n").split("\n")) {
|
||||||
|
if (/^\s*```/.test(line)) fence = !fence;
|
||||||
|
const heading = !fence && line.match(/^\s*###\s+(.+?)\s*$/);
|
||||||
|
if (heading) {
|
||||||
|
current = { title: heading[1]!, content: "" };
|
||||||
|
sections.push(current);
|
||||||
|
} else if (current) {
|
||||||
|
current.content += `${current.content ? "\n" : ""}${line}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sections.map((item) => ({ ...item, content: item.content.trim() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strategyRounds(messages: Message[]) {
|
||||||
|
const rounds: { id: string; question: string; answer: string }[] = [];
|
||||||
|
for (const message of messages) {
|
||||||
|
if (
|
||||||
|
message.additional_kwargs?.hide_from_ui ||
|
||||||
|
!["human", "ai"].includes(message.type)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const text =
|
||||||
|
typeof message.content === "string"
|
||||||
|
? message.content
|
||||||
|
: message.content
|
||||||
|
.filter(
|
||||||
|
(item): item is Extract<typeof item, { type: "text" }> =>
|
||||||
|
item.type === "text" &&
|
||||||
|
"text" in item &&
|
||||||
|
typeof item.text === "string",
|
||||||
|
)
|
||||||
|
.map((item) => item.text)
|
||||||
|
.join("\n");
|
||||||
|
if (message.type === "human") {
|
||||||
|
if (text.trim())
|
||||||
|
rounds.push({
|
||||||
|
id: message.id || `round-${rounds.length}`,
|
||||||
|
question: text,
|
||||||
|
answer: "",
|
||||||
|
});
|
||||||
|
} else if (text.trim()) {
|
||||||
|
if (!rounds.length)
|
||||||
|
rounds.push({
|
||||||
|
id: message.id || "round-0",
|
||||||
|
question: "策略分析",
|
||||||
|
answer: "",
|
||||||
|
});
|
||||||
|
rounds[rounds.length - 1]!.answer +=
|
||||||
|
`${rounds[rounds.length - 1]!.answer ? "\n\n" : ""}${text}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rounds;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { apiFetch } from "../api";
|
import { apiFetch } from "../api";
|
||||||
import { downloadMarkdownAsDocx } from "../scheduled-tasks/markdown-docx";
|
import { downloadMarkdownAsDocx } from "../scheduled-tasks/markdown-docx";
|
||||||
|
import { prepareGeneratedHtml } from "../utils/legacy-html-preview";
|
||||||
import { getFileName } from "../utils/files";
|
import { getFileName } from "../utils/files";
|
||||||
|
|
||||||
import { urlOfArtifact } from "./utils";
|
import { urlOfArtifact } from "./utils";
|
||||||
@@ -60,12 +61,23 @@ export async function downloadArtifactFile({
|
|||||||
const filename =
|
const filename =
|
||||||
filenameFromContentDisposition(cd) ?? getFileName(filepath);
|
filenameFromContentDisposition(cd) ?? getFileName(filepath);
|
||||||
|
|
||||||
const blob = await res.blob();
|
const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
|
||||||
const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
|
const isHtml = contentType === "text/html" || /\.html?$/i.test(filename);
|
||||||
const downloadBlob =
|
let downloadBlob: Blob;
|
||||||
contentType && blob.type !== contentType
|
if (isHtml) {
|
||||||
? new Blob([await blob.arrayBuffer()], { type: contentType })
|
downloadBlob = new Blob(
|
||||||
: blob;
|
[
|
||||||
|
prepareGeneratedHtml(await res.text()),
|
||||||
|
],
|
||||||
|
{ type: "text/html;charset=utf-8" },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const blob = await res.blob();
|
||||||
|
downloadBlob =
|
||||||
|
contentType && blob.type !== contentType
|
||||||
|
? new Blob([await blob.arrayBuffer()], { type: contentType })
|
||||||
|
: blob;
|
||||||
|
}
|
||||||
const objectUrl = URL.createObjectURL(downloadBlob);
|
const objectUrl = URL.createObjectURL(downloadBlob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = objectUrl;
|
a.href = objectUrl;
|
||||||
@@ -111,7 +123,16 @@ export async function openArtifactInNewTab({
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(await errorMessageFromResponse(res));
|
throw new Error(await errorMessageFromResponse(res));
|
||||||
}
|
}
|
||||||
const blob = await res.blob();
|
const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
|
||||||
|
const isHtml = contentType === "text/html" || /\.html?$/i.test(filepath);
|
||||||
|
const blob = isHtml
|
||||||
|
? new Blob(
|
||||||
|
[
|
||||||
|
prepareGeneratedHtml(await res.text()),
|
||||||
|
],
|
||||||
|
{ type: "text/html;charset=utf-8" },
|
||||||
|
)
|
||||||
|
: await res.blob();
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
const w = window.open(objectUrl, "_blank", "noopener,noreferrer");
|
const w = window.open(objectUrl, "_blank", "noopener,noreferrer");
|
||||||
if (w) w.opener = null;
|
if (w) w.opener = null;
|
||||||
|
|||||||
@@ -4,9 +4,66 @@ import test from "node:test";
|
|||||||
import {
|
import {
|
||||||
isWriteFileInFlight,
|
isWriteFileInFlight,
|
||||||
latestInFlightWriteFilePreview,
|
latestInFlightWriteFilePreview,
|
||||||
|
latestPreparedHtmlArtifact,
|
||||||
resolveWriteFilePath,
|
resolveWriteFilePath,
|
||||||
} from "./write-file-in-flight";
|
} from "./write-file-in-flight";
|
||||||
|
|
||||||
|
test("latestPreparedHtmlArtifact opens the output created by template prepare", () => {
|
||||||
|
const preview = latestPreparedHtmlArtifact([
|
||||||
|
{
|
||||||
|
id: "m1",
|
||||||
|
type: "ai",
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: "tpl1",
|
||||||
|
name: "page_report_template",
|
||||||
|
args: { action: "prepare" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
tool_call_id: "tpl1",
|
||||||
|
content: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
working_copy: "/mnt/user-data/outputs/人工智能发展报告.html",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(preview, {
|
||||||
|
artifactPath: "/mnt/user-data/outputs/人工智能发展报告.html",
|
||||||
|
previewKey: "page-template:tpl1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("latestPreparedHtmlArtifact ignores workspace and failed prepares", () => {
|
||||||
|
assert.equal(
|
||||||
|
latestPreparedHtmlArtifact([
|
||||||
|
{
|
||||||
|
id: "m1",
|
||||||
|
type: "ai",
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: "tpl1",
|
||||||
|
name: "page_report_template",
|
||||||
|
args: { action: "prepare" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
tool_call_id: "tpl1",
|
||||||
|
content: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
working_copy: "/mnt/user-data/workspace/draft.html",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("latestInFlightWriteFilePreview opens a write_file that is not the last tool call", () => {
|
test("latestInFlightWriteFilePreview opens a write_file that is not the last tool call", () => {
|
||||||
const preview = latestInFlightWriteFilePreview([
|
const preview = latestInFlightWriteFilePreview([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -51,6 +51,70 @@ type WriteFileThreadLike = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function parseToolJson(content: unknown): Record<string, unknown> | null {
|
||||||
|
let value = content;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const textBlock = value.find(
|
||||||
|
(item) =>
|
||||||
|
item &&
|
||||||
|
typeof item === "object" &&
|
||||||
|
"text" in item &&
|
||||||
|
typeof (item as { text?: unknown }).text === "string",
|
||||||
|
) as { text?: string } | undefined;
|
||||||
|
value = textBlock?.text;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
try {
|
||||||
|
value = JSON.parse(value) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A successful page-template prepare already creates a previewable HTML file
|
||||||
|
* under outputs. Open it before the first edit arrives so the user sees the
|
||||||
|
* sandbox as soon as page generation starts, even when the model edits via
|
||||||
|
* str_replace or emits a complete write_file call in one stream frame.
|
||||||
|
*/
|
||||||
|
export function latestPreparedHtmlArtifact(
|
||||||
|
messages: WriteFileThreadLike["messages"],
|
||||||
|
): { artifactPath: string; previewKey: string } | null {
|
||||||
|
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
||||||
|
const message = messages[messageIndex];
|
||||||
|
if (message?.type !== "ai" || !message.tool_calls) continue;
|
||||||
|
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
|
||||||
|
const toolCall = message.tool_calls[toolIndex];
|
||||||
|
if (toolCall?.name !== "page_report_template" || !toolCall.id) continue;
|
||||||
|
const args = toolCall.args as { action?: unknown } | undefined;
|
||||||
|
if (String(args?.action ?? "").toLowerCase() !== "prepare") continue;
|
||||||
|
const resultMessage = messages.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.type === "tool" && candidate.tool_call_id === toolCall.id,
|
||||||
|
);
|
||||||
|
const result = parseToolJson(resultMessage?.content);
|
||||||
|
if (result?.ok !== true) continue;
|
||||||
|
const candidate =
|
||||||
|
typeof result.working_copy === "string"
|
||||||
|
? result.working_copy
|
||||||
|
: typeof result.final_output === "string"
|
||||||
|
? result.final_output
|
||||||
|
: "";
|
||||||
|
const artifactPath = candidate.trim();
|
||||||
|
if (!/^\/mnt\/user-data\/outputs\/.+\.html?$/i.test(artifactPath)) continue;
|
||||||
|
return {
|
||||||
|
artifactPath,
|
||||||
|
previewKey: `page-template:${toolCall.id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function hasToolResult(thread: WriteFileThreadLike, toolCallId?: string) {
|
function hasToolResult(thread: WriteFileThreadLike, toolCallId?: string) {
|
||||||
if (!toolCallId) {
|
if (!toolCallId) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -125,5 +125,9 @@ export function AuthSyncWatcher({
|
|||||||
};
|
};
|
||||||
}, [disabled, location.pathname, navigate, queryClient]);
|
}, [disabled, location.pathname, navigate, queryClient]);
|
||||||
|
|
||||||
return children ? <Fragment key={revision}>{children}</Fragment> : null;
|
// A keyed Fragment is transformed by the development tagger, which injects
|
||||||
|
// data-lov-id. React.Fragment only accepts key and children, so use a
|
||||||
|
// display:contents element to preserve the remount behavior without adding
|
||||||
|
// a layout box.
|
||||||
|
return children ? <div className="contents" key={revision}>{children}</div> : null;
|
||||||
}
|
}
|
||||||
@@ -38,30 +38,46 @@ export function buildAppHashUrl(path: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget | null {
|
export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget | null {
|
||||||
if (!url?.startsWith("/weknora-source-preview?")) {
|
if (!url) return null;
|
||||||
return null;
|
|
||||||
|
// Older stored messages use this private marker URL. Keep parsing it so old
|
||||||
|
// conversations remain clickable, but never generate it for new links: it
|
||||||
|
// is not a real route and becomes a 404 if an event handler is unavailable.
|
||||||
|
if (url.startsWith("/weknora-source-preview?")) {
|
||||||
|
const queryIndex = url.indexOf("?");
|
||||||
|
const params = new URLSearchParams(url.slice(queryIndex + 1));
|
||||||
|
const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
|
||||||
|
const chunkId = params.get("chunk_id")?.trim();
|
||||||
|
const documentId =
|
||||||
|
params.get("document_id")?.trim() ||
|
||||||
|
params.get("knowledge_id")?.trim() ||
|
||||||
|
"";
|
||||||
|
const wikiSlug =
|
||||||
|
params.get("wiki_slug")?.trim() ||
|
||||||
|
params.get("wikiSlug")?.trim() ||
|
||||||
|
params.get("slug")?.trim() ||
|
||||||
|
"";
|
||||||
|
return knowledgeBaseId && (wikiSlug || chunkId || documentId)
|
||||||
|
? {
|
||||||
|
knowledgeBaseId,
|
||||||
|
chunkId,
|
||||||
|
documentId: documentId || undefined,
|
||||||
|
wikiSlug: wikiSlug || undefined,
|
||||||
|
tab: wikiSlug ? "wiki" : "documents",
|
||||||
|
}
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
const queryIndex = url.indexOf("?");
|
|
||||||
const params = new URLSearchParams(url.slice(queryIndex + 1));
|
// New drawer links use the same real HashRouter URL as the normal Wiki
|
||||||
const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
|
// reader. This doubles as a safe browser-navigation fallback when React's
|
||||||
const chunkId = params.get("chunk_id")?.trim();
|
// Markdown click handler is not mounted (for example after a deployment).
|
||||||
const documentId =
|
const route = url.includes("#") ? url.slice(url.indexOf("#") + 1) : url;
|
||||||
params.get("document_id")?.trim() ||
|
const match = route.match(/^\/page\/workspace\/knowledge\/([^/?]+)\/wiki\/view(?:\?([^#]*))?$/);
|
||||||
params.get("knowledge_id")?.trim() ||
|
if (!match) return null;
|
||||||
"";
|
const knowledgeBaseId = decodeURIComponent(match[1] || "").trim();
|
||||||
const wikiSlug =
|
const wikiSlug = new URLSearchParams(match[2] || "").get("wikiSlug")?.trim();
|
||||||
params.get("wiki_slug")?.trim() ||
|
return knowledgeBaseId && wikiSlug
|
||||||
params.get("wikiSlug")?.trim() ||
|
? { knowledgeBaseId, wikiSlug, tab: "wiki" }
|
||||||
params.get("slug")?.trim() ||
|
|
||||||
"";
|
|
||||||
return knowledgeBaseId && (wikiSlug || chunkId || documentId)
|
|
||||||
? {
|
|
||||||
knowledgeBaseId,
|
|
||||||
chunkId,
|
|
||||||
documentId: documentId || undefined,
|
|
||||||
wikiSlug: wikiSlug || undefined,
|
|
||||||
tab: wikiSlug ? "wiki" : "documents",
|
|
||||||
}
|
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export type SearchResultItem = {
|
|||||||
published_date?: string;
|
published_date?: string;
|
||||||
publishedDate?: string;
|
publishedDate?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
|
/** Retrieval score supplied by the search provider, when available. */
|
||||||
|
score?: number;
|
||||||
|
/** Original retrieval payload type, retained for specialised result renderers. */
|
||||||
|
type?: string;
|
||||||
|
knowledge_base_id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const RESULT_LIST_KEYS = [
|
const RESULT_LIST_KEYS = [
|
||||||
@@ -196,6 +201,25 @@ function pickFromLayers(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickNumberFromLayers(
|
||||||
|
layers: Record<string, unknown>[],
|
||||||
|
aliases: readonly string[],
|
||||||
|
): number | undefined {
|
||||||
|
for (const layer of layers) {
|
||||||
|
for (const alias of aliases) {
|
||||||
|
const value = layer[alias];
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function pickByKeyPattern(
|
function pickByKeyPattern(
|
||||||
layers: Record<string, unknown>[],
|
layers: Record<string, unknown>[],
|
||||||
pattern: RegExp,
|
pattern: RegExp,
|
||||||
@@ -326,6 +350,12 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
|||||||
const recUuid = pickFromLayers(layers, ["recUuid", "rec_uuid"]);
|
const recUuid = pickFromLayers(layers, ["recUuid", "rec_uuid"]);
|
||||||
const uuid = pickFromLayers(layers, ["uuid"]);
|
const uuid = pickFromLayers(layers, ["uuid"]);
|
||||||
const docId = pickFromLayers(layers, ["docId", "doc_id", "documentId"]);
|
const docId = pickFromLayers(layers, ["docId", "doc_id", "documentId"]);
|
||||||
|
const score = pickNumberFromLayers(layers, [
|
||||||
|
"score",
|
||||||
|
"relevance_score",
|
||||||
|
"rerank_score",
|
||||||
|
"similarity",
|
||||||
|
]);
|
||||||
|
|
||||||
const reserved = new Set<string>();
|
const reserved = new Set<string>();
|
||||||
for (const value of [title, content, url, source, author, date]) {
|
for (const value of [title, content, url, source, author, date]) {
|
||||||
@@ -363,6 +393,9 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
|||||||
published_date: date,
|
published_date: date,
|
||||||
publishedDate: date,
|
publishedDate: date,
|
||||||
date,
|
date,
|
||||||
|
score,
|
||||||
|
type: pickString(record.type),
|
||||||
|
knowledge_base_id: pickString(record.knowledge_base_id, record.knowledgeBaseId, record.mapping_id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -364,12 +364,18 @@ export function extractReasoningContentFromMessage(message: Message) {
|
|||||||
if (message.type !== "ai") {
|
if (message.type !== "ai") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
message.additional_kwargs &&
|
message.additional_kwargs &&
|
||||||
"reasoning_content" in message.additional_kwargs
|
"reasoning_content" in message.additional_kwargs
|
||||||
) {
|
) {
|
||||||
return message.additional_kwargs.reasoning_content as string | null;
|
return message.additional_kwargs.reasoning_content as string | null;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
message.additional_kwargs &&
|
||||||
|
typeof message.additional_kwargs.reasoning === "string"
|
||||||
|
) {
|
||||||
|
return message.additional_kwargs.reasoning;
|
||||||
|
}
|
||||||
if (Array.isArray(message.content)) {
|
if (Array.isArray(message.content)) {
|
||||||
const part = message.content[0];
|
const part = message.content[0];
|
||||||
if (part && "thinking" in part) {
|
if (part && "thinking" in part) {
|
||||||
@@ -385,8 +391,9 @@ export function extractReasoningContentFromMessage(message: Message) {
|
|||||||
export function removeReasoningContentFromMessage(message: Message) {
|
export function removeReasoningContentFromMessage(message: Message) {
|
||||||
if (message.type !== "ai" || !message.additional_kwargs) {
|
if (message.type !== "ai" || !message.additional_kwargs) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
delete message.additional_kwargs.reasoning_content;
|
delete message.additional_kwargs.reasoning_content;
|
||||||
|
delete message.additional_kwargs.reasoning;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractURLFromImageURLContent(
|
export function extractURLFromImageURLContent(
|
||||||
@@ -421,9 +428,12 @@ export function hasReasoning(message: Message) {
|
|||||||
if (message.type !== "ai") {
|
if (message.type !== "ai") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (typeof message.additional_kwargs?.reasoning_content === "string") {
|
if (typeof message.additional_kwargs?.reasoning_content === "string") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (typeof message.additional_kwargs?.reasoning === "string") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (Array.isArray(message.content)) {
|
if (Array.isArray(message.content)) {
|
||||||
const part = message.content[0];
|
const part = message.content[0];
|
||||||
// Compatible with the Anthropic gateway
|
// Compatible with the Anthropic gateway
|
||||||
@@ -550,11 +560,12 @@ export function hasSubagent(message: AIMessage) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findToolCallResult(
|
export function findToolCallResult(
|
||||||
toolCallId: string,
|
toolCallId: string,
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
toolName?: string,
|
toolName?: string,
|
||||||
): string | Record<string, unknown> | undefined {
|
options?: { allowNameFallback?: boolean },
|
||||||
|
): string | Record<string, unknown> | undefined {
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
if (message.type !== "tool" || message.tool_call_id !== toolCallId) {
|
if (message.type !== "tool" || message.tool_call_id !== toolCallId) {
|
||||||
continue;
|
continue;
|
||||||
@@ -566,7 +577,7 @@ export function findToolCallResult(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// updates 流里偶发缺少 tool_call_id,但 content 已是检索结构
|
// updates 流里偶发缺少 tool_call_id,但 content 已是检索结构
|
||||||
if (toolName) {
|
if (toolName && options?.allowNameFallback !== false) {
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
if (message.type !== "tool" || message.name !== toolName) {
|
if (message.type !== "tool" || message.name !== toolName) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface ManagedModel {
|
|||||||
base_url: string | null;
|
base_url: string | null;
|
||||||
request_timeout: number | null;
|
request_timeout: number | null;
|
||||||
max_retries: number | null;
|
max_retries: number | null;
|
||||||
|
max_input_tokens: number | null;
|
||||||
max_tokens: number | null;
|
max_tokens: number | null;
|
||||||
temperature: number | null;
|
temperature: number | null;
|
||||||
stream_usage: boolean | null;
|
stream_usage: boolean | null;
|
||||||
@@ -44,6 +45,7 @@ export interface ModelSavePayload {
|
|||||||
base_url?: string | null;
|
base_url?: string | null;
|
||||||
request_timeout?: number | null;
|
request_timeout?: number | null;
|
||||||
max_retries?: number | null;
|
max_retries?: number | null;
|
||||||
|
max_input_tokens?: number | null;
|
||||||
max_tokens?: number | null;
|
max_tokens?: number | null;
|
||||||
temperature?: number | null;
|
temperature?: number | null;
|
||||||
stream_usage?: boolean | null;
|
stream_usage?: boolean | null;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ test("matches only workspace chats_iframe routes", () => {
|
|||||||
assert.equal(isChatsIframeWorkspacePath("/landing"), false);
|
assert.equal(isChatsIframeWorkspacePath("/landing"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("forces light theme for landing and chats_iframe routes only", () => {
|
test("forces the dedicated presentation themes for landing, iframe, and wx chat routes", () => {
|
||||||
assert.equal(resolveForcedThemeForPath("/landing"), "light");
|
assert.equal(resolveForcedThemeForPath("/landing"), "light");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolveForcedThemeForPath("/page/workspace/chats_iframe/new"),
|
resolveForcedThemeForPath("/page/workspace/chats_iframe/new"),
|
||||||
@@ -41,6 +41,14 @@ test("forces light theme for landing and chats_iframe routes only", () => {
|
|||||||
"light",
|
"light",
|
||||||
);
|
);
|
||||||
assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined);
|
assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined);
|
||||||
|
assert.equal(
|
||||||
|
resolveForcedThemeForPath("/page/workspace/wx/chats/new"),
|
||||||
|
"dark",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveForcedThemeForPath("/page/workspace/wx/agents/agent-1/chats/new"),
|
||||||
|
"dark",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("recognizes an explicit chatMode=iframe independently of the legacy marker", () => {
|
test("recognizes an explicit chatMode=iframe independently of the legacy marker", () => {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { resolveEmbedThemeMode } from "../embed/theme-mode.mjs";
|
|||||||
const CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new";
|
const CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new";
|
||||||
const IFRAME_LOGIN_SOURCE = "iframe";
|
const IFRAME_LOGIN_SOURCE = "iframe";
|
||||||
const STANDARD_CHATS_PATH = "/page/workspace/chats";
|
const STANDARD_CHATS_PATH = "/page/workspace/chats";
|
||||||
|
/** Dedicated full chat surface whose presentation is always deep blue. */
|
||||||
|
export const WX_FULL_CHAT_PATH_PREFIX = "/page/workspace/wx/";
|
||||||
|
|
||||||
function toSearchParams(search: string): URLSearchParams {
|
function toSearchParams(search: string): URLSearchParams {
|
||||||
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||||
@@ -328,6 +330,10 @@ export function resolveForcedThemeForPath(
|
|||||||
return "light";
|
return "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX)) {
|
||||||
|
return "dark";
|
||||||
|
}
|
||||||
|
|
||||||
const isLoginPath = pathname === "/login" || pathname.startsWith("/login/");
|
const isLoginPath = pathname === "/login" || pathname.startsWith("/login/");
|
||||||
if (
|
if (
|
||||||
isChatsIframeWorkspacePath(pathname) ||
|
isChatsIframeWorkspacePath(pathname) ||
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
FileBox,
|
FileBox,
|
||||||
FileSearch,
|
FileSearch,
|
||||||
FileText,
|
FileText,
|
||||||
|
FolderKanban,
|
||||||
Gauge,
|
Gauge,
|
||||||
Grid3X3,
|
Grid3X3,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
@@ -48,12 +49,23 @@ export type MenuItem = {
|
|||||||
path?: string;
|
path?: string;
|
||||||
/** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */
|
/** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */
|
||||||
aliasPaths?: string[];
|
aliasPaths?: string[];
|
||||||
|
/** 从当前菜单项自身的 path / aliasPaths 前缀匹配中排除的路由。 */
|
||||||
|
excludePaths?: string[];
|
||||||
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
|
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
|
||||||
userPath?: string;
|
userPath?: string;
|
||||||
adminOnly?: boolean;
|
adminOnly?: boolean;
|
||||||
children?: MenuItem[];
|
children?: MenuItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Markdown 转 Word 功能的菜单定义,供全量与简洁模式共用。 */
|
||||||
|
export const MARKDOWN_DOCX_MENU_PATH = "/page/workspace/knowledge/markdown-editor";
|
||||||
|
export const MARKDOWN_DOCX_MENU_ITEM: MenuItem & { path: string } = {
|
||||||
|
id: "resource-markdown-docx-editor",
|
||||||
|
label: "md转word",
|
||||||
|
icon: FileText,
|
||||||
|
path: MARKDOWN_DOCX_MENU_PATH,
|
||||||
|
};
|
||||||
|
|
||||||
export const sidebarMenuItems: MenuItem[] = [
|
export const sidebarMenuItems: MenuItem[] = [
|
||||||
{
|
{
|
||||||
id: "qa-center",
|
id: "qa-center",
|
||||||
@@ -61,6 +73,7 @@ export const sidebarMenuItems: MenuItem[] = [
|
|||||||
icon: HelpCircle,
|
icon: HelpCircle,
|
||||||
children: [
|
children: [
|
||||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
|
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
|
||||||
|
{ id: "analysis-projects", label: "项目空间", icon: Layout, path: "/page/workspace/projects" },
|
||||||
{ id: "artifact-library", label: "资料库", icon: Library, path: "/page/workspace/library" },
|
{ id: "artifact-library", label: "资料库", icon: Library, path: "/page/workspace/library" },
|
||||||
{
|
{
|
||||||
id: "multi-agent-roundtable",
|
id: "multi-agent-roundtable",
|
||||||
@@ -68,6 +81,13 @@ export const sidebarMenuItems: MenuItem[] = [
|
|||||||
icon: Users,
|
icon: Users,
|
||||||
path: "/page/strategy/qa/multi-agent-roundtable",
|
path: "/page/strategy/qa/multi-agent-roundtable",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "strategy-projects",
|
||||||
|
label: "我的项目",
|
||||||
|
icon: FolderKanban,
|
||||||
|
path: "/page/strategy/qa/projects",
|
||||||
|
aliasPaths: ["/page/strategy/qa/strategy-selection"],
|
||||||
|
},
|
||||||
// {
|
// {
|
||||||
// id: "position-roundtable",
|
// id: "position-roundtable",
|
||||||
// label: "岗位会商",
|
// label: "岗位会商",
|
||||||
@@ -258,12 +278,7 @@ export const sidebarMenuItems: MenuItem[] = [
|
|||||||
},
|
},
|
||||||
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
|
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
|
||||||
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
||||||
{
|
MARKDOWN_DOCX_MENU_ITEM,
|
||||||
id: "resource-markdown-docx-editor",
|
|
||||||
label: "md转word",
|
|
||||||
icon: FileText,
|
|
||||||
path: "/page/workspace/knowledge/markdown-editor",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "mgmt-tasks",
|
id: "mgmt-tasks",
|
||||||
label: "定时任务",
|
label: "定时任务",
|
||||||
@@ -330,6 +345,7 @@ export const sidebarMenuItems: MenuItem[] = [
|
|||||||
label: "知识库管理",
|
label: "知识库管理",
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
path: "/page/workspace/knowledge",
|
path: "/page/workspace/knowledge",
|
||||||
|
excludePaths: [MARKDOWN_DOCX_MENU_PATH],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "resource-knowledge-base",
|
id: "resource-knowledge-base",
|
||||||
@@ -365,6 +381,9 @@ function routeMatchesPathname(pathname: string, route?: string): boolean {
|
|||||||
|
|
||||||
/** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */
|
/** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */
|
||||||
export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean {
|
export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean {
|
||||||
|
if (item.excludePaths?.some((path) => routeMatchesPathname(pathname, path))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (routeMatchesPathname(pathname, item.path)) return true;
|
if (routeMatchesPathname(pathname, item.path)) return true;
|
||||||
return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false;
|
return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
() => ++citationIndex,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return sources;
|
return rankLlmWikiSources(sources);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Wiki tool exposes a numeric retrieval score. Give Wiki sources their
|
||||||
|
* citation numbers in that ranked order so the answer text, reference panel,
|
||||||
|
* and tool trace all refer to the same item. Non-Wiki sources keep their
|
||||||
|
* existing positions and indexes.
|
||||||
|
*/
|
||||||
|
function rankLlmWikiSources(
|
||||||
|
sources: RuntimeDisplaySource[],
|
||||||
|
): RuntimeDisplaySource[] {
|
||||||
|
const wikiSlots = sources
|
||||||
|
.map((source, position) => ({ source, position }))
|
||||||
|
.filter(({ source }) => source.skillName === "llmwiki_search");
|
||||||
|
if (wikiSlots.length < 2) return sources;
|
||||||
|
|
||||||
|
const ranked = [...wikiSlots].sort((left, right) => {
|
||||||
|
const leftScore =
|
||||||
|
typeof left.source.score === "number" && Number.isFinite(left.source.score)
|
||||||
|
? left.source.score
|
||||||
|
: Number.NEGATIVE_INFINITY;
|
||||||
|
const rightScore =
|
||||||
|
typeof right.source.score === "number" && Number.isFinite(right.source.score)
|
||||||
|
? right.source.score
|
||||||
|
: Number.NEGATIVE_INFINITY;
|
||||||
|
return rightScore - leftScore || left.position - right.position;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rankedBySlot = new Map(
|
||||||
|
wikiSlots.map(({ position }, rank) => [position, ranked[rank].source]),
|
||||||
|
);
|
||||||
|
return sources.map((source, position) => {
|
||||||
|
const rankedSource = rankedBySlot.get(position);
|
||||||
|
return rankedSource ? { ...rankedSource, index: source.index } : source;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendDisplaySources(
|
function appendDisplaySources(
|
||||||
@@ -468,7 +503,8 @@ export function shouldUseDefaultCitationMode(
|
|||||||
normalizedName.includes("search") ||
|
normalizedName.includes("search") ||
|
||||||
normalizedName.includes("retriev") ||
|
normalizedName.includes("retriev") ||
|
||||||
normalizedName.includes("knowledge") ||
|
normalizedName.includes("knowledge") ||
|
||||||
normalizedName.includes("query");
|
normalizedName.includes("query") ||
|
||||||
|
normalizedName.includes("wiki");
|
||||||
const isSkillInvocation = !!(
|
const isSkillInvocation = !!(
|
||||||
toolArgs &&
|
toolArgs &&
|
||||||
canInferSkillFromToolArgs(toolName) &&
|
canInferSkillFromToolArgs(toolName) &&
|
||||||
@@ -495,12 +531,13 @@ export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceI
|
|||||||
item.id ??
|
item.id ??
|
||||||
item.url,
|
item.url,
|
||||||
title: item.title ?? item.source ?? `结果 ${index + 1}`,
|
title: item.title ?? item.source ?? `结果 ${index + 1}`,
|
||||||
type: "result",
|
type: item.type ?? "result",
|
||||||
snippet: item.snippet ?? item.content ?? "",
|
snippet: item.snippet ?? item.content ?? "",
|
||||||
content: item.content ?? item.snippet,
|
content: item.content ?? item.snippet,
|
||||||
source: item.source,
|
source: item.source,
|
||||||
author: item.author,
|
author: item.author,
|
||||||
time: item.published_date ?? item.publishedDate ?? item.date,
|
time: item.published_date ?? item.publishedDate ?? item.date,
|
||||||
|
score: item.score,
|
||||||
url: item.url,
|
url: item.url,
|
||||||
raw: item as unknown as Record<string, unknown>,
|
raw: item as unknown as Record<string, unknown>,
|
||||||
}));
|
}));
|
||||||
@@ -519,6 +556,12 @@ export function applyInlineCitations(
|
|||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
const byIndex = new Map(referenceSources.map((source) => [source.index, source]));
|
const byIndex = new Map(referenceSources.map((source) => [source.index, source]));
|
||||||
|
const byCitationId = new Map(
|
||||||
|
referenceSources.flatMap((source) => {
|
||||||
|
const id = typeof source.raw?.citation_id === "string" ? source.raw.citation_id.trim() : "";
|
||||||
|
return id ? [[id, source] as const] : [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
const byUrl = new Map<string, RuntimeDisplaySource>();
|
const byUrl = new Map<string, RuntimeDisplaySource>();
|
||||||
const byTitle = new Map<string, RuntimeDisplaySource>();
|
const byTitle = new Map<string, RuntimeDisplaySource>();
|
||||||
for (const source of referenceSources) {
|
for (const source of referenceSources) {
|
||||||
@@ -530,13 +573,13 @@ export function applyInlineCitations(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = normalizeExistingCitationLinks(
|
const normalized = replaceStableCitationTokens(normalizeExistingCitationLinks(
|
||||||
content,
|
content,
|
||||||
byIndex,
|
byIndex,
|
||||||
byUrl,
|
byUrl,
|
||||||
byTitle,
|
byTitle,
|
||||||
referenceSources,
|
referenceSources,
|
||||||
);
|
), byCitationId);
|
||||||
|
|
||||||
const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g;
|
const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g;
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
@@ -570,6 +613,13 @@ export function applyInlineCitations(
|
|||||||
return appendCitationSummary(next, referenceSources);
|
return appendCitationSummary(next, referenceSources);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replaceStableCitationTokens(content: string, byCitationId: Map<string, RuntimeDisplaySource>): string {
|
||||||
|
return content.replace(/\[\[cite:([^\]]+)\]\]/gi, (match, rawId: string) => {
|
||||||
|
const source = byCitationId.get(rawId.trim());
|
||||||
|
return source ? citationLinkMarkdown(source) : match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function replaceCitationTokens(
|
function replaceCitationTokens(
|
||||||
content: string,
|
content: string,
|
||||||
byIndex: Map<number, RuntimeDisplaySource>,
|
byIndex: Map<number, RuntimeDisplaySource>,
|
||||||
|
|||||||
@@ -11,6 +11,60 @@ export interface PromptPrefixSettings {
|
|||||||
|
|
||||||
export type KnowledgeBaseProductVersion = "v1" | "v2";
|
export type KnowledgeBaseProductVersion = "v1" | "v2";
|
||||||
|
|
||||||
|
export type OrdinaryKnowledgeLanding = "wiki" | "weknora";
|
||||||
|
|
||||||
|
export interface StrategyProjectSettings {
|
||||||
|
taiwan_strait_demo_enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrategyProjectSettingsUpdate {
|
||||||
|
taiwan_strait_demo_enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadStrategyProjectSettings(): Promise<StrategyProjectSettings> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/system-settings/strategy-projects`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`无法读取策略项目设置(HTTP ${response.status})`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as StrategyProjectSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateStrategyProjectSettings(
|
||||||
|
body: StrategyProjectSettingsUpdate,
|
||||||
|
): Promise<StrategyProjectSettings> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/system-settings/strategy-projects`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ detail: "保存策略项目设置失败" }));
|
||||||
|
throw new Error(detail.detail ?? "保存策略项目设置失败");
|
||||||
|
}
|
||||||
|
return (await response.json()) as StrategyProjectSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadOrdinaryKnowledgeLanding(): Promise<OrdinaryKnowledgeLanding> {
|
||||||
|
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`);
|
||||||
|
if (!response.ok) throw new Error("无法读取普通知识库默认页面设置");
|
||||||
|
return ((await response.json()) as { mode: OrdinaryKnowledgeLanding }).mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveOrdinaryKnowledgeLanding(mode: OrdinaryKnowledgeLanding): Promise<OrdinaryKnowledgeLanding> {
|
||||||
|
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`, {
|
||||||
|
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("保存失败,仅管理员可配置普通知识库默认页面");
|
||||||
|
return ((await response.json()) as { mode: OrdinaryKnowledgeLanding }).mode;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadKnowledgeBaseProductVersion(): Promise<KnowledgeBaseProductVersion> {
|
export async function loadKnowledgeBaseProductVersion(): Promise<KnowledgeBaseProductVersion> {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`,
|
`${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`,
|
||||||
@@ -260,6 +314,42 @@ export interface PublishSquaresUpdate {
|
|||||||
default_square_id?: string;
|
default_square_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Agent Gallery category selected for the wx route's common-agent panel. */
|
||||||
|
export interface WxCommonAgentsSettings {
|
||||||
|
/** `null` keeps the original common-agent recommendation logic. */
|
||||||
|
agent_type: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadWxCommonAgents(): Promise<WxCommonAgentsSettings> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/system-settings/wx-common-agents`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load wx common agents: ${response.status}`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as WxCommonAgentsSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWxCommonAgents(
|
||||||
|
agentType: string | null,
|
||||||
|
): Promise<WxCommonAgentsSettings> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/system-settings/wx-common-agents`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ agent_type: agentType }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ detail: "Failed to update wx common agents" }));
|
||||||
|
throw new Error(detail.detail ?? "Failed to update wx common agents");
|
||||||
|
}
|
||||||
|
return (await response.json()) as WxCommonAgentsSettings;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadPublishSquares(): Promise<PublishSquaresSettings> {
|
export async function loadPublishSquares(): Promise<PublishSquaresSettings> {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/system-settings/publish-squares`,
|
`${getBackendBaseURL()}/api/system-settings/publish-squares`,
|
||||||
@@ -547,6 +637,48 @@ export interface ModelConcurrencyUpdate {
|
|||||||
user_model_limits?: Record<string, number>;
|
user_model_limits?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MemoryRuntimeSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadMemoryRuntimeSettings(): Promise<MemoryRuntimeSettings> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/system-settings/memory`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load memory runtime setting: ${response.status}`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as MemoryRuntimeSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMemoryRuntimeSettings(
|
||||||
|
body: MemoryRuntimeSettings,
|
||||||
|
): Promise<MemoryRuntimeSettings> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/system-settings/memory`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ detail: "Failed to update memory runtime setting" }));
|
||||||
|
throw new Error(detail.detail ?? "Failed to update memory runtime setting");
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as MemoryRuntimeSettings;
|
||||||
|
writeYLog({
|
||||||
|
optType: OptType.edit,
|
||||||
|
optDetail: "CM助手-全量-系统设置-问答记忆开关",
|
||||||
|
url: "/api/system-settings/memory",
|
||||||
|
reqParam: body,
|
||||||
|
returnParam: data,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
|
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
|
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
|
||||||
|
|||||||
@@ -8,30 +8,38 @@ import {
|
|||||||
loadCitationDisplaySettings,
|
loadCitationDisplaySettings,
|
||||||
loadLeaderboardSettings,
|
loadLeaderboardSettings,
|
||||||
loadModelConcurrencySettings,
|
loadModelConcurrencySettings,
|
||||||
|
loadMemoryRuntimeSettings,
|
||||||
loadPluginManagementSettings,
|
loadPluginManagementSettings,
|
||||||
loadPrivateSquareStatus,
|
loadPrivateSquareStatus,
|
||||||
loadPromptPrefixSettings,
|
loadPromptPrefixSettings,
|
||||||
loadPublishSquares,
|
loadPublishSquares,
|
||||||
loadSkillCompressionSettings,
|
loadSkillCompressionSettings,
|
||||||
|
loadStrategyProjectSettings,
|
||||||
|
loadWxCommonAgents,
|
||||||
updateAgentModuleConfig,
|
updateAgentModuleConfig,
|
||||||
updateAppearanceSettings,
|
updateAppearanceSettings,
|
||||||
updateCitationDisplaySettings,
|
updateCitationDisplaySettings,
|
||||||
updateLeaderboardSettings,
|
updateLeaderboardSettings,
|
||||||
updateModelConcurrencySettings,
|
updateModelConcurrencySettings,
|
||||||
|
updateMemoryRuntimeSettings,
|
||||||
updatePluginManagementSettings,
|
updatePluginManagementSettings,
|
||||||
updatePrivateSquareSettings,
|
updatePrivateSquareSettings,
|
||||||
updatePromptPrefixSettings,
|
updatePromptPrefixSettings,
|
||||||
updatePublishSquares,
|
updatePublishSquares,
|
||||||
updateSkillCompressionSettings,
|
updateSkillCompressionSettings,
|
||||||
|
updateStrategyProjectSettings,
|
||||||
|
updateWxCommonAgents,
|
||||||
type AppearanceUpdate,
|
type AppearanceUpdate,
|
||||||
type CitationDisplayUpdate,
|
type CitationDisplayUpdate,
|
||||||
type LeaderboardSettingsUpdate,
|
type LeaderboardSettingsUpdate,
|
||||||
type ModelConcurrencyUpdate,
|
type ModelConcurrencyUpdate,
|
||||||
|
type MemoryRuntimeSettings,
|
||||||
type PluginManagementUpdate,
|
type PluginManagementUpdate,
|
||||||
type PrivateSquareUpdate,
|
type PrivateSquareUpdate,
|
||||||
type PromptPrefixUpdate,
|
type PromptPrefixUpdate,
|
||||||
type PublishSquaresUpdate,
|
type PublishSquaresUpdate,
|
||||||
type SkillCompressionUpdate,
|
type SkillCompressionUpdate,
|
||||||
|
type StrategyProjectSettingsUpdate,
|
||||||
type LayoutMode,
|
type LayoutMode,
|
||||||
type ViewMode,
|
type ViewMode,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
@@ -44,7 +52,30 @@ const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
|
|||||||
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
|
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
|
||||||
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
|
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
|
||||||
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
|
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
|
||||||
|
const MEMORY_RUNTIME_KEY = ["systemSettings", "memoryRuntime"] as const;
|
||||||
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
|
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
|
||||||
|
const STRATEGY_PROJECTS_KEY = ["systemSettings", "strategyProjects"] as const;
|
||||||
|
|
||||||
|
export function useStrategyProjectSettings() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: STRATEGY_PROJECTS_KEY,
|
||||||
|
queryFn: () => loadStrategyProjectSettings(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateStrategyProjectSettings() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: StrategyProjectSettingsUpdate) =>
|
||||||
|
updateStrategyProjectSettings(body),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(STRATEGY_PROJECTS_KEY, data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function usePromptPrefixSettings() {
|
export function usePromptPrefixSettings() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -86,15 +117,36 @@ export function useUpdatePrivateSquareSettings() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useMemoryRuntimeSettings() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: MEMORY_RUNTIME_KEY,
|
||||||
|
queryFn: () => loadMemoryRuntimeSettings(),
|
||||||
|
staleTime: 10_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateMemoryRuntimeSettings() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: MemoryRuntimeSettings) =>
|
||||||
|
updateMemoryRuntimeSettings(body),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(MEMORY_RUNTIME_KEY, data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** The configured publish squares (发布广场). Readable by any logged-in user —
|
/** The configured publish squares (发布广场). Readable by any logged-in user —
|
||||||
* the gallery sub-tabs and the publish-flow square picker both need it. */
|
* the gallery sub-tabs and the publish-flow square picker both need it. */
|
||||||
export function usePublishSquares() {
|
export function usePublishSquares({ enabled = true }: { enabled?: boolean } = {}) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: PUBLISH_SQUARES_KEY,
|
queryKey: PUBLISH_SQUARES_KEY,
|
||||||
queryFn: () => loadPublishSquares(),
|
queryFn: () => loadPublishSquares(),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
|
enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +234,9 @@ export function useLayoutMode(): [LayoutMode, (mode: LayoutMode) => void] {
|
|||||||
const agentModuleKey = (key: string) =>
|
const agentModuleKey = (key: string) =>
|
||||||
["systemSettings", "agentModule", key] as const;
|
["systemSettings", "agentModule", key] as const;
|
||||||
|
|
||||||
export function useAgentModuleConfig(key: string) {
|
export function useAgentModuleConfig(
|
||||||
|
key: string,
|
||||||
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: agentModuleKey(key),
|
queryKey: agentModuleKey(key),
|
||||||
queryFn: () => loadAgentModuleConfig(key),
|
queryFn: () => loadAgentModuleConfig(key),
|
||||||
@@ -191,6 +245,28 @@ export function useAgentModuleConfig(key: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WX_COMMON_AGENTS_KEY = ["systemSettings", "wxCommonAgents"] as const;
|
||||||
|
|
||||||
|
export function useWxCommonAgents({ enabled = true }: { enabled?: boolean } = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: WX_COMMON_AGENTS_KEY,
|
||||||
|
queryFn: loadWxCommonAgents,
|
||||||
|
enabled,
|
||||||
|
staleTime: 60_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateWxCommonAgents() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: updateWxCommonAgents,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(WX_COMMON_AGENTS_KEY, data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useUpdateAgentModuleConfig(key: string) {
|
export function useUpdateAgentModuleConfig(key: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -826,20 +826,14 @@ export function useThreadStream({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Context compression about to run a (blocking) summarization LLM call —
|
// Background compaction stays silent, including events from older backends.
|
||||||
// surface a transient hint so the wait before the answer streams isn't a
|
|
||||||
// silent dead air. Emitted by DeerFlowSummarizationMiddleware.
|
|
||||||
if (
|
if (
|
||||||
typeof event === "object" &&
|
typeof event === "object" &&
|
||||||
event !== null &&
|
event !== null &&
|
||||||
"type" in event &&
|
"type" in event &&
|
||||||
event.type === "context_compacting" &&
|
event.type === "context_compacting"
|
||||||
"message" in event &&
|
|
||||||
typeof event.message === "string" &&
|
|
||||||
event.message.trim()
|
|
||||||
) {
|
) {
|
||||||
const e = event as { type: "context_compacting"; message: string };
|
return;
|
||||||
toast(e.message);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError(error) {
|
onError(error) {
|
||||||
@@ -886,8 +880,15 @@ export function useThreadStream({
|
|||||||
// Move to a fresh chat without a full browser reload; the old reload path
|
// Move to a fresh chat without a full browser reload; the old reload path
|
||||||
// made every click look like a crash and could loop indefinitely when a
|
// made every click look like a crash and could loop indefinitely when a
|
||||||
// different tab's cookie kept overriding this tab's Bearer token.
|
// different tab's cookie kept overriding this tab's Bearer token.
|
||||||
if (isThreadNotAccessibleError(message)) {
|
if (isThreadNotAccessibleError(message)) {
|
||||||
const userLabel = buildCurrentUserLabel();
|
const projectHash = typeof window !== "undefined" ? window.location.hash : "";
|
||||||
|
const projectMatch = projectHash.match(/#\/page\/workspace\/projects\/([^/]+)\//);
|
||||||
|
if (projectMatch) {
|
||||||
|
toast.info("当前项目对话不存在或无权访问,已返回项目空间。");
|
||||||
|
navigate("/page/workspace/projects", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userLabel = buildCurrentUserLabel();
|
||||||
toast.info(
|
toast.info(
|
||||||
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
|
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
|
||||||
);
|
);
|
||||||
@@ -1591,8 +1592,6 @@ export function useThreadStream({
|
|||||||
: mergedContext.mode === "thinking"
|
: mergedContext.mode === "thinking"
|
||||||
? "low"
|
? "low"
|
||||||
: undefined),
|
: undefined),
|
||||||
memory_injection_enabled:
|
|
||||||
mergedContext.memory_injection_enabled ?? true,
|
|
||||||
rag_mode_enabled: ragModeEnabled,
|
rag_mode_enabled: ragModeEnabled,
|
||||||
summarization_enabled: contextCompressionEnabled,
|
summarization_enabled: contextCompressionEnabled,
|
||||||
thread_id: threadId,
|
thread_id: threadId,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
myProjectConversationTypeFromSearchParams,
|
||||||
|
myProjectConversationTypeOfThread,
|
||||||
|
myProjectThreadMetadata,
|
||||||
|
withMyProjectConversationMarker,
|
||||||
|
} from "./project-metadata";
|
||||||
|
|
||||||
|
test("project conversation type is read from persisted metadata", () => {
|
||||||
|
assert.equal(
|
||||||
|
myProjectConversationTypeOfThread({
|
||||||
|
metadata: myProjectThreadMetadata("strategy"),
|
||||||
|
}),
|
||||||
|
"strategy",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
myProjectConversationTypeOfThread({
|
||||||
|
metadata: { project_source: "elsewhere", project_type: "qa" },
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("metadata wins over the compatibility context fallback", () => {
|
||||||
|
assert.equal(
|
||||||
|
myProjectConversationTypeOfThread({
|
||||||
|
metadata: myProjectThreadMetadata("solution"),
|
||||||
|
context: myProjectThreadMetadata("qa"),
|
||||||
|
}),
|
||||||
|
"solution",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("new-chat route marker round-trips all supported project types", () => {
|
||||||
|
for (const type of ["qa", "strategy", "solution"] as const) {
|
||||||
|
const path = withMyProjectConversationMarker(
|
||||||
|
"/page/strategy/chats/new?keep=1",
|
||||||
|
type,
|
||||||
|
);
|
||||||
|
const params = new URLSearchParams(path.split("?", 2)[1]);
|
||||||
|
assert.equal(params.get("keep"), "1");
|
||||||
|
assert.equal(myProjectConversationTypeFromSearchParams(params), type);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Stable marker for conversations entered from 「我的项目」.
|
||||||
|
*
|
||||||
|
* Keep this separate from ``project_id``: that key belongs to DeerFlow's
|
||||||
|
* folder-style conversation projects, while this marker describes the product
|
||||||
|
* mode that created (or explicitly continued) a conversation.
|
||||||
|
*/
|
||||||
|
export const MY_PROJECT_THREAD_SOURCE = "my-projects" as const;
|
||||||
|
|
||||||
|
export const MY_PROJECT_THREAD_SOURCE_KEY = "project_source" as const;
|
||||||
|
export const MY_PROJECT_THREAD_TYPE_KEY = "project_type" as const;
|
||||||
|
|
||||||
|
export const MY_PROJECT_QUERY_SOURCE_KEY = "projectSource" as const;
|
||||||
|
export const MY_PROJECT_QUERY_TYPE_KEY = "projectType" as const;
|
||||||
|
|
||||||
|
export type MyProjectConversationType = "qa" | "strategy" | "solution";
|
||||||
|
|
||||||
|
export const MY_PROJECT_CONVERSATION_LABELS: Record<
|
||||||
|
MyProjectConversationType,
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
qa: "普通问答",
|
||||||
|
strategy: "策略优选",
|
||||||
|
solution: "方案生成",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MY_PROJECT_CONVERSATION_TYPES = new Set<MyProjectConversationType>([
|
||||||
|
"qa",
|
||||||
|
"strategy",
|
||||||
|
"solution",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function typeFromRecord(
|
||||||
|
value: Record<string, unknown> | null | undefined,
|
||||||
|
): MyProjectConversationType | null {
|
||||||
|
if (value?.[MY_PROJECT_THREAD_SOURCE_KEY] !== MY_PROJECT_THREAD_SOURCE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const type = value[MY_PROJECT_THREAD_TYPE_KEY];
|
||||||
|
return typeof type === "string" &&
|
||||||
|
MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType)
|
||||||
|
? (type as MyProjectConversationType)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function myProjectThreadMetadata(
|
||||||
|
type: MyProjectConversationType,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
[MY_PROJECT_THREAD_SOURCE_KEY]: MY_PROJECT_THREAD_SOURCE,
|
||||||
|
[MY_PROJECT_THREAD_TYPE_KEY]: type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read persisted metadata first; context is only a compatibility fallback. */
|
||||||
|
export function myProjectConversationTypeOfThread(thread: {
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
context?: Record<string, unknown> | null;
|
||||||
|
}): MyProjectConversationType | null {
|
||||||
|
return typeFromRecord(thread.metadata) ?? typeFromRecord(thread.context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function myProjectConversationTypeFromSearchParams(params: {
|
||||||
|
get(name: string): string | null;
|
||||||
|
}): MyProjectConversationType | null {
|
||||||
|
if (params.get(MY_PROJECT_QUERY_SOURCE_KEY) !== MY_PROJECT_THREAD_SOURCE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const type = params.get(MY_PROJECT_QUERY_TYPE_KEY);
|
||||||
|
return typeof type === "string" &&
|
||||||
|
MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType)
|
||||||
|
? (type as MyProjectConversationType)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add an explicit, one-shot marker to a new-conversation route. */
|
||||||
|
export function withMyProjectConversationMarker(
|
||||||
|
path: string,
|
||||||
|
type: MyProjectConversationType,
|
||||||
|
): string {
|
||||||
|
const hashIndex = path.indexOf("#");
|
||||||
|
const hash = hashIndex >= 0 ? path.slice(hashIndex) : "";
|
||||||
|
const pathWithoutHash = hashIndex >= 0 ? path.slice(0, hashIndex) : path;
|
||||||
|
const queryIndex = pathWithoutHash.indexOf("?");
|
||||||
|
const pathname =
|
||||||
|
queryIndex >= 0 ? pathWithoutHash.slice(0, queryIndex) : pathWithoutHash;
|
||||||
|
const params = new URLSearchParams(
|
||||||
|
queryIndex >= 0 ? pathWithoutHash.slice(queryIndex + 1) : "",
|
||||||
|
);
|
||||||
|
params.set(MY_PROJECT_QUERY_SOURCE_KEY, MY_PROJECT_THREAD_SOURCE);
|
||||||
|
params.set(MY_PROJECT_QUERY_TYPE_KEY, type);
|
||||||
|
const search = params.toString();
|
||||||
|
return `${pathname}${search ? `?${search}` : ""}${hash}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { listPathOfThread } from "./utils";
|
||||||
|
import type { AgentThread } from "./types";
|
||||||
|
|
||||||
|
const projectThread = {
|
||||||
|
thread_id: "chat-1",
|
||||||
|
metadata: { analysis_project_id: "project-1" },
|
||||||
|
} as unknown as AgentThread;
|
||||||
|
|
||||||
|
test("project history keeps the My Projects workspace and return destination", () => {
|
||||||
|
for (const current of ["/page/strategy/qa/projects", "/page/strategy/qa/projects/groups/project-1/chats/chat-1"]) {
|
||||||
|
assert.equal(listPathOfThread(projectThread, current), "/page/strategy/qa/projects/groups/project-1/chats/chat-1");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy project history and ordinary chat routes remain available", () => {
|
||||||
|
assert.equal(listPathOfThread(projectThread), "/page/workspace/projects/project-1/chats/chat-1");
|
||||||
|
assert.equal(listPathOfThread({ thread_id: "chat-2", metadata: {} } as unknown as AgentThread, "/page/strategy/qa/projects"), "/page/workspace/chats/chat-2");
|
||||||
|
assert.equal(listPathOfThread({ thread_id: "setup", metadata: { thread_type: "bootstrap" } } as unknown as AgentThread), "/page/workspace/agents/new/setup");
|
||||||
|
});
|
||||||
@@ -19,7 +19,6 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
|||||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||||
writing_mode?: boolean;
|
writing_mode?: boolean;
|
||||||
writing_artifact_path?: string;
|
writing_artifact_path?: string;
|
||||||
memory_injection_enabled?: boolean;
|
|
||||||
rag_mode_enabled?: boolean;
|
rag_mode_enabled?: boolean;
|
||||||
/** Per-request switch for context compression (上下文压缩). Default off. */
|
/** Per-request switch for context compression (上下文压缩). Default off. */
|
||||||
summarization_enabled?: boolean;
|
summarization_enabled?: boolean;
|
||||||
|
|||||||
@@ -45,10 +45,17 @@ export function pathOfThread(
|
|||||||
base = "/page/workspace",
|
base = "/page/workspace",
|
||||||
) {
|
) {
|
||||||
const threadId = typeof thread === "string" ? thread : thread.thread_id;
|
const threadId = typeof thread === "string" ? thread : thread.thread_id;
|
||||||
|
const projectId =
|
||||||
|
typeof thread === "string"
|
||||||
|
? undefined
|
||||||
|
: thread.metadata?.analysis_project_id;
|
||||||
|
if (typeof projectId === "string" && projectId) {
|
||||||
|
return `${base}/projects/${encodeURIComponent(projectId)}/chats/${encodeURIComponent(threadId)}`;
|
||||||
|
}
|
||||||
const agentRef =
|
const agentRef =
|
||||||
typeof thread === "string"
|
typeof thread === "string"
|
||||||
? pickAgentRef(context, null)
|
? pickAgentRef(context, null)
|
||||||
: agentRefOfThread(thread) ?? pickAgentRef(context, null);
|
: (agentRefOfThread(thread) ?? pickAgentRef(context, null));
|
||||||
|
|
||||||
return agentRef
|
return agentRef
|
||||||
? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
|
? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
|
||||||
@@ -120,7 +127,9 @@ export function isSetupWizardThread(thread: {
|
|||||||
metadata?: Record<string, unknown> | null;
|
metadata?: Record<string, unknown> | null;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const threadType = thread.metadata?.thread_type;
|
const threadType = thread.metadata?.thread_type;
|
||||||
return threadType === "writing_setup" || threadType === "report_structure_setup";
|
return (
|
||||||
|
threadType === "writing_setup" || threadType === "report_structure_setup"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */
|
/** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */
|
||||||
@@ -130,7 +139,10 @@ export function isHiddenFromRecentChats(thread: {
|
|||||||
const metadata = thread.metadata;
|
const metadata = thread.metadata;
|
||||||
if (metadata?.system === true) return true;
|
if (metadata?.system === true) return true;
|
||||||
const threadType = metadata?.thread_type;
|
const threadType = metadata?.thread_type;
|
||||||
return typeof threadType === "string" && HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType);
|
return (
|
||||||
|
typeof threadType === "string" &&
|
||||||
|
HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */
|
/** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */
|
||||||
@@ -151,18 +163,27 @@ export function bootstrapResumePath(threadId: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 列表项点击目标:bootstrap 会话回创建页恢复,其余走普通问答路由。 */
|
/** 列表项点击目标:bootstrap 会话回创建页恢复,其余走普通问答路由。 */
|
||||||
export function listPathOfThread(thread: AgentThread): string {
|
export function listPathOfThread(thread: AgentThread, currentPath = ""): string {
|
||||||
|
const projectId = thread.metadata?.analysis_project_id;
|
||||||
|
if (typeof projectId === "string" && projectId &&
|
||||||
|
(currentPath === "/page/strategy/qa/projects" || currentPath.startsWith("/page/strategy/qa/projects/"))) {
|
||||||
|
return `/page/strategy/qa/projects/groups/${encodeURIComponent(projectId)}/chats/${encodeURIComponent(thread.thread_id)}`;
|
||||||
|
}
|
||||||
return isBootstrapThread(thread)
|
return isBootstrapThread(thread)
|
||||||
? bootstrapResumePath(thread.thread_id)
|
? bootstrapResumePath(thread.thread_id)
|
||||||
: pathOfThread(thread);
|
: pathOfThread(thread);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isThreadPinned(thread: AgentThread): boolean {
|
export function isThreadPinned(thread: AgentThread): boolean {
|
||||||
return (thread.metadata as Record<string, unknown> | null)?.is_pinned === true;
|
return (
|
||||||
|
(thread.metadata as Record<string, unknown> | null)?.is_pinned === true
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isThreadFavorite(thread: AgentThread): boolean {
|
export function isThreadFavorite(thread: AgentThread): boolean {
|
||||||
return (thread.metadata as Record<string, unknown> | null)?.is_favorite === true;
|
return (
|
||||||
|
(thread.metadata as Record<string, unknown> | null)?.is_favorite === true
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -179,7 +200,9 @@ export function sortThreadsPinnedFirst(threads: AgentThread[]): AgentThread[] {
|
|||||||
return [...pinned, ...rest];
|
return [...pinned, ...rest];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
|
export function sanitizeThreadTitleForDisplay(
|
||||||
|
title: string | null | undefined,
|
||||||
|
) {
|
||||||
if (!title) return "Untitled";
|
if (!title) return "Untitled";
|
||||||
|
|
||||||
const cleaned = title
|
const cleaned = title
|
||||||
|
|||||||
@@ -113,6 +113,17 @@ test("normal mode auto-opens every new artifact like before writing mode existed
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Word artifacts stay download-only in every mode", () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/fixed.docx"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/source.doc", [], true),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("a later user turn releases report.md live edits", () => {
|
test("a later user turn releases report.md live edits", () => {
|
||||||
const followUp = [
|
const followUp = [
|
||||||
{ type: "human", content: "写一篇论文" },
|
{ type: "human", content: "写一篇论文" },
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/** Only the writing-mode deliverable counts as a follow-up edit target. */
|
/** Only the writing-mode deliverable counts as a follow-up edit target. */
|
||||||
|
|
||||||
|
import { isWordDocumentFile } from "@/core/utils/files";
|
||||||
|
|
||||||
const OUTPUTS_PREFIX = "/mnt/user-data/outputs";
|
const OUTPUTS_PREFIX = "/mnt/user-data/outputs";
|
||||||
|
|
||||||
export function normalizeWritingPath(path: string | null | undefined): string {
|
export function normalizeWritingPath(path: string | null | undefined): string {
|
||||||
@@ -105,6 +107,11 @@ export function shouldAutoOpenWritingArtifact(
|
|||||||
if (!path) {
|
if (!path) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// DOC/DOCX are delivered as download-only files. They have no meaningful
|
||||||
|
// sandbox preview, so never let a completed Word artifact open the panel.
|
||||||
|
if (isWordDocumentFile(path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!writingMode) {
|
if (!writingMode) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,11 +148,18 @@ export function getFileName(filepath: string) {
|
|||||||
return filepath.split("/").pop()!;
|
return filepath.split("/").pop()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFileExtension(filepath: string) {
|
export function getFileExtension(filepath: string) {
|
||||||
return filepath.split(".").pop()!.toLocaleLowerCase();
|
return filepath.split(".").pop()!.toLocaleLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = new Set([
|
/** Word files are download-only artifacts: browsers cannot preview them reliably. */
|
||||||
|
export function isWordDocumentFile(filepath: string): boolean {
|
||||||
|
const pathname = filepath.split(/[?#]/, 1)[0] ?? filepath;
|
||||||
|
const extension = getFileExtension(pathname);
|
||||||
|
return extension === "doc" || extension === "docx";
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_EXTENSIONS = new Set([
|
||||||
"png",
|
"png",
|
||||||
"jpg",
|
"jpg",
|
||||||
"jpeg",
|
"jpeg",
|
||||||
|
|||||||
@@ -46,3 +46,26 @@ test("prepareGeneratedHtml injects the ES5 chart size shim and anchor handler",
|
|||||||
assert.match(out, /getElementsByTagName\("canvas"\)/);
|
assert.match(out, /getElementsByTagName\("canvas"\)/);
|
||||||
assert.doesNotMatch(out, /\?\.|=>|`/);
|
assert.doesNotMatch(out, /\?\.|=>|`/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("does not inject visualization runtimes into an ordinary HTML page", () => {
|
||||||
|
const html = `<!doctype html><html><body><h1>普通页面</h1></body></html>`;
|
||||||
|
const out = prepareGeneratedHtml(html);
|
||||||
|
|
||||||
|
assert.doesNotMatch(out, /data-df-html-runtime/);
|
||||||
|
assert.doesNotMatch(out, /\/assets\/(?:mermaid|echarts|leaflet)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the explicit Gateway HTML runtime used by new pages", () => {
|
||||||
|
const html = `<!doctype html><html><head>
|
||||||
|
<link rel="stylesheet" href="/api/public/html-runtime/leaflet.css">
|
||||||
|
<script defer src="/api/public/html-runtime/leaflet.js"></script>
|
||||||
|
<script defer src="/api/public/html-runtime/deerflow-runtime.js"></script>
|
||||||
|
</head><body><div id="map" class="df-map"></div>
|
||||||
|
<script type="application/json" data-df-map="map">{"markers":[]}</script></body></html>`;
|
||||||
|
const out = prepareGeneratedHtml(html);
|
||||||
|
|
||||||
|
assert.match(out, /\/api\/public\/html-runtime\/leaflet\.js/);
|
||||||
|
assert.match(out, /\/api\/public\/html-runtime\/deerflow-runtime\.js/);
|
||||||
|
assert.doesNotMatch(out, /data-df-html-runtime=/);
|
||||||
|
assert.doesNotMatch(out, /localhost\/assets\/leaflet/);
|
||||||
|
});
|
||||||
@@ -13,8 +13,144 @@ import { injectInPageAnchorHandler } from "./markdown";
|
|||||||
|
|
||||||
const MAX_PASSES = 12;
|
const MAX_PASSES = 12;
|
||||||
|
|
||||||
export function prepareGeneratedHtml(html: string): string {
|
export type GeneratedHtmlRuntimeAssets = {
|
||||||
return injectInPageAnchorHandler(injectLegacyChartShim(downlevelModernColors(html ?? "")));
|
g6ScriptUrl: string;
|
||||||
|
mermaidScriptUrl: string;
|
||||||
|
echartsScriptUrl: string;
|
||||||
|
echartsChinaGeoJson: unknown;
|
||||||
|
mapTileUrl: string;
|
||||||
|
mapTileAttribution: string;
|
||||||
|
leafletScriptUrl: string;
|
||||||
|
leafletStyleText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function prepareGeneratedHtml(
|
||||||
|
html: string,
|
||||||
|
): string {
|
||||||
|
const prepared = injectLegacyChartShim(downlevelModernColors(html ?? ""));
|
||||||
|
return injectInPageAnchorHandler(prepared);
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWN_RUNTIME_SCRIPT_RE = /<script\b([^>]*?)\bsrc\s*=\s*(["'])([^"']+)\2([^>]*)>\s*<\/script\s*>/gi;
|
||||||
|
const KNOWN_RUNTIME_STYLE_RE = /<link\b([^>]*?)\bhref\s*=\s*(["'])([^"']+)\2([^>]*)>/gi;
|
||||||
|
|
||||||
|
function runtimeKind(url: string): "g6" | "mermaid" | "echarts" | "leaflet" | null {
|
||||||
|
if (/(?:@antv\/g6|(?:^|\/)g6(?:\.min)?\.js)/i.test(url)) return "g6";
|
||||||
|
if (/mermaid(?:\.min)?\.(?:js|mjs)|\bmermaid@/i.test(url)) return "mermaid";
|
||||||
|
if (/echarts(?:\.min)?\.js|\becharts@/i.test(url)) return "echarts";
|
||||||
|
if (/leaflet(?:\.min)?\.(?:js|css)|\bleaflet@/i.test(url)) return "leaflet";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtmlAttribute(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.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 {
|
export function downlevelModernColors(html: string): string {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { readRuntimeEnv } from "@/env";
|
||||||
|
|
||||||
|
export const DEFAULT_MAP_TILE_URL =
|
||||||
|
"https://mt0.google.com/vt/lyrs=m&hl=zh-CN&x={x}&y={y}&z={z}";
|
||||||
|
|
||||||
|
/** Browser-side tile template shared by chat maps and generated HTML maps. */
|
||||||
|
export function getMapTileUrl(): string {
|
||||||
|
return readRuntimeEnv("VITE_MAP_TILE_URL")?.trim() || DEFAULT_MAP_TILE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMapTileAttribution(tileUrl = getMapTileUrl()): string {
|
||||||
|
return /(?:^|\.)google\.(?:com|cn)\b/i.test(tileUrl) ? "Google" : "";
|
||||||
|
}
|
||||||
@@ -116,6 +116,14 @@ export interface EightBfTaskProcessReportReceipt {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 外部任务系统要求 8BF 会商页提交的过程产物携带 eightTask=1,
|
||||||
|
* 用于与岗位会商等其它入口的产物区分来源。
|
||||||
|
*/
|
||||||
|
function processReportBody(input: EightBfTaskProcessReportInput): string {
|
||||||
|
return JSON.stringify({ ...input, eightTask: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
|
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
|
||||||
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
|
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
|
||||||
@@ -157,7 +165,10 @@ async function externalTaskError(response: Response, fallback: string): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findProcessReportId(payload: unknown): string {
|
function findProcessReportId(payload: unknown): string {
|
||||||
if (!payload || typeof payload !== "object") return "";
|
if (payload == null) return "";
|
||||||
|
// 外部任务系统的实际返回形如 {code:200, data:307, msg:"success"}:
|
||||||
|
// `data` 直接就是新增记录的 id(数字 / 字符串),不再包一层对象。
|
||||||
|
if (typeof payload !== "object") return firstText(payload);
|
||||||
const source = payload as Record<string, unknown>;
|
const source = payload as Record<string, unknown>;
|
||||||
const direct = firstText(
|
const direct = firstText(
|
||||||
source.id,
|
source.id,
|
||||||
@@ -170,9 +181,10 @@ function findProcessReportId(payload: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the external task system's process report in its unsubmitted state.
|
* Create the external task system's process report **and submit it in one
|
||||||
* The local gateway stores the returned id so a refresh can later submit the
|
* call**(submitStatus=1). Response shape is `{code:200, data:<id>, msg}` —
|
||||||
* same report instead of creating a duplicate one.
|
* `data` is the new record id. The local gateway stores that id so a refresh
|
||||||
|
* can still reconcile the same report instead of creating a duplicate.
|
||||||
*/
|
*/
|
||||||
export async function createEightBfTaskProcessReport(
|
export async function createEightBfTaskProcessReport(
|
||||||
input: EightBfTaskProcessReportInput,
|
input: EightBfTaskProcessReportInput,
|
||||||
@@ -180,7 +192,7 @@ export async function createEightBfTaskProcessReport(
|
|||||||
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
|
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(input),
|
body: processReportBody(input),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
|
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
|
||||||
const id = findProcessReportId(await response.json().catch(() => null));
|
const id = findProcessReportId(await response.json().catch(() => null));
|
||||||
@@ -188,7 +200,7 @@ export async function createEightBfTaskProcessReport(
|
|||||||
return { id };
|
return { id };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Submit an already-registered process report to the external task system. */
|
/** Flip an already-registered (submitStatus=0) process report to submitted. Only used to resume legacy ledger rows; the normal path submits directly on create. */
|
||||||
export async function updateEightBfTaskProcessReport(
|
export async function updateEightBfTaskProcessReport(
|
||||||
reportId: string,
|
reportId: string,
|
||||||
input: EightBfTaskProcessReportInput,
|
input: EightBfTaskProcessReportInput,
|
||||||
@@ -198,7 +210,7 @@ export async function updateEightBfTaskProcessReport(
|
|||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(input),
|
body: processReportBody(input),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
|
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
} from "@/position-roundtable/lib/positions";
|
} from "@/position-roundtable/lib/positions";
|
||||||
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
|
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
|
||||||
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent";
|
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent";
|
||||||
|
import { updateChain } from "@/roundtable-planning/api/chains";
|
||||||
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||||
import "@/position-roundtable/styles/intent-understanding.css";
|
import "@/position-roundtable/styles/intent-understanding.css";
|
||||||
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
|
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
|
||||||
@@ -46,6 +47,8 @@ export interface EightBfStep1PanelProps extends Step1PanelProps {
|
|||||||
chain: BusinessChain | null;
|
chain: BusinessChain | null;
|
||||||
canConfigureChain: boolean;
|
canConfigureChain: boolean;
|
||||||
onConfigureChain: () => void;
|
onConfigureChain: () => void;
|
||||||
|
/** 业务链被本面板更新(产物 position 等)后回调,页面据此刷新链配置。 */
|
||||||
|
onChainUpdated?: (chain: BusinessChain) => void;
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
modelOptions: Array<{ label: string; value: string }>;
|
modelOptions: Array<{ label: string; value: string }>;
|
||||||
onSelectedModelChange: (model: string) => void;
|
onSelectedModelChange: (model: string) => void;
|
||||||
@@ -84,6 +87,7 @@ export function EightBfStep1Panel({
|
|||||||
chain,
|
chain,
|
||||||
canConfigureChain,
|
canConfigureChain,
|
||||||
onConfigureChain,
|
onConfigureChain,
|
||||||
|
onChainUpdated,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
modelOptions,
|
modelOptions,
|
||||||
onSelectedModelChange,
|
onSelectedModelChange,
|
||||||
@@ -153,6 +157,7 @@ export function EightBfStep1Panel({
|
|||||||
chain={chain}
|
chain={chain}
|
||||||
canConfigureChain={canConfigureChain}
|
canConfigureChain={canConfigureChain}
|
||||||
onConfigureChain={onConfigureChain}
|
onConfigureChain={onConfigureChain}
|
||||||
|
onChainUpdated={onChainUpdated}
|
||||||
onCollapse={() => setRightPanelOpen(false)}
|
onCollapse={() => setRightPanelOpen(false)}
|
||||||
step2Dialogues={step2Dialogues}
|
step2Dialogues={step2Dialogues}
|
||||||
step2ThreadIds={step2ThreadIds}
|
step2ThreadIds={step2ThreadIds}
|
||||||
@@ -299,6 +304,7 @@ function EightBfAgentWorkspace({
|
|||||||
chain,
|
chain,
|
||||||
canConfigureChain,
|
canConfigureChain,
|
||||||
onConfigureChain,
|
onConfigureChain,
|
||||||
|
onChainUpdated,
|
||||||
onCollapse,
|
onCollapse,
|
||||||
step2Dialogues,
|
step2Dialogues,
|
||||||
step2ThreadIds,
|
step2ThreadIds,
|
||||||
@@ -310,6 +316,7 @@ function EightBfAgentWorkspace({
|
|||||||
chain: BusinessChain | null;
|
chain: BusinessChain | null;
|
||||||
canConfigureChain: boolean;
|
canConfigureChain: boolean;
|
||||||
onConfigureChain: () => void;
|
onConfigureChain: () => void;
|
||||||
|
onChainUpdated?: (chain: BusinessChain) => void;
|
||||||
onCollapse: () => void;
|
onCollapse: () => void;
|
||||||
step2Dialogues: unknown[];
|
step2Dialogues: unknown[];
|
||||||
step2ThreadIds: ThreadIdMap | null;
|
step2ThreadIds: ThreadIdMap | null;
|
||||||
@@ -448,6 +455,13 @@ function EightBfAgentWorkspace({
|
|||||||
jobResponsibilities={selectedSeat?.job_responsibilities ?? ""}
|
jobResponsibilities={selectedSeat?.job_responsibilities ?? ""}
|
||||||
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
|
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
|
||||||
skillsTitle="技能列表"
|
skillsTitle="技能列表"
|
||||||
|
belowSkillsSlot={chain && selectedSeat ? (
|
||||||
|
<SeatArtifactPositionEditor
|
||||||
|
chain={chain}
|
||||||
|
seat={selectedSeat}
|
||||||
|
onUpdated={onChainUpdated}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
showCommonQuestions
|
showCommonQuestions
|
||||||
panelTitle="智能体配置"
|
panelTitle="智能体配置"
|
||||||
embedded
|
embedded
|
||||||
@@ -612,3 +626,82 @@ function arrayValue(...values: unknown[]): string[] {
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「智能体配置」面板里、技能列表下方的「产物提交 position」编辑器。
|
||||||
|
*
|
||||||
|
* 直接读写该席位在业务链上配置的 artifact_position(保存即 PUT 业务链的 seats),
|
||||||
|
* 用户无需去业务链编辑页找链配 position,也不易配错链。只有业务链 owner / 管理员
|
||||||
|
* 能保存(后端校验);提交产物时按席位取这里保存的值,留空按 0。
|
||||||
|
*/
|
||||||
|
function SeatArtifactPositionEditor({
|
||||||
|
chain,
|
||||||
|
seat,
|
||||||
|
onUpdated,
|
||||||
|
}: {
|
||||||
|
chain: BusinessChain;
|
||||||
|
seat: ChainSeat;
|
||||||
|
onUpdated?: (chain: BusinessChain) => void;
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState(seat.artifact_position?.toString() ?? "");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 席位切换 / 外部链数据刷新时,回填链上的当前值。
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(seat.artifact_position?.toString() ?? "");
|
||||||
|
}, [seat.agent_id, seat.artifact_position]);
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const parsed = trimmed === "" ? null : Number.parseInt(trimmed, 10);
|
||||||
|
const nextValue = parsed !== null && Number.isFinite(parsed) ? parsed : null;
|
||||||
|
const dirty = nextValue !== (seat.artifact_position ?? null);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (saving || !dirty) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const seats = chain.seats.map((s) =>
|
||||||
|
s.agent_id === seat.agent_id ? { ...s, artifact_position: nextValue } : s,
|
||||||
|
);
|
||||||
|
const updated = await updateChain(chain.id, { seats });
|
||||||
|
if (!updated) throw new Error("业务链已不存在,可能被删除");
|
||||||
|
onUpdated?.(updated);
|
||||||
|
toast.success(`已保存「${seat.name || seat.agent_id}」的产物 position`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "保存产物 position 失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded-lg border border-[#2b8ffe]/30 bg-[#042b59]/60 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-semibold text-[#aaceff]">产物提交 position</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={value}
|
||||||
|
placeholder="0"
|
||||||
|
aria-label="产物提交 position"
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
className="h-7 w-20 rounded-md border border-[#2b8ffe]/40 bg-[#032680]/60 px-2 text-xs font-semibold tabular-nums text-[#e8f2ff] outline-none focus:border-[#2b8ffe] [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!dirty || saving}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
className="inline-flex h-7 items-center gap-1 rounded-md border border-[#2b8ffe]/50 bg-[#0a3c8f]/70 px-2.5 text-xs font-semibold text-[#e8f2ff] transition hover:bg-[#0a3c8f] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving && <Loader2 className="size-3 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-[10px] leading-relaxed text-[#8aa0c4]">
|
||||||
|
该席位提交过程产物时传给任务系统的 position 值,留空按 0 提交。保存会写入业务链「{chain.title}」,仅链所有者 / 管理员可保存。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -88,14 +88,50 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
() => buildEightBfTaskContext(task, taskDictionaries),
|
() => buildEightBfTaskContext(task, taskDictionaries),
|
||||||
[task, taskDictionaries],
|
[task, taskDictionaries],
|
||||||
);
|
);
|
||||||
|
// 席位「产物提交 position」有效取值映射(提交产物时按席位智能体取值):
|
||||||
|
// 1) 绑定链的席位 —— 键收录小写 agent_id + 小写席位名(会话侧席位标识在不同
|
||||||
|
// 表面可能以 id 或名称出现,双键匹配避免查不到);
|
||||||
|
// 2) 其它公共链上同一 agent_id 配置的值(历史会话的席位可能来自切换绑定前的
|
||||||
|
// 旧链,agent_id 跨链唯一指向同一智能体,可安全回退;**名称不跨链匹配**,
|
||||||
|
// 避免不同链的同名席位互相误配)。
|
||||||
|
// 绑定链优先(先写入),未配置的席位不进 map(提交时回退默认 0)。
|
||||||
|
const seatArtifactPositions = useMemo(() => {
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
const put = (key: string, value: number) => {
|
||||||
|
const normalized = key.trim().toLowerCase();
|
||||||
|
if (normalized && !(normalized in map)) map[normalized] = value;
|
||||||
|
};
|
||||||
|
for (const seat of configuredChain?.seats ?? []) {
|
||||||
|
if (typeof seat.artifact_position === "number" && Number.isFinite(seat.artifact_position)) {
|
||||||
|
put(seat.agent_id, seat.artifact_position);
|
||||||
|
if (seat.name?.trim()) put(seat.name, seat.artifact_position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const chain of chains) {
|
||||||
|
if (chain.id === configuredChain?.id) continue;
|
||||||
|
for (const seat of chain.seats ?? []) {
|
||||||
|
if (typeof seat.artifact_position === "number" && Number.isFinite(seat.artifact_position)) {
|
||||||
|
put(seat.agent_id, seat.artifact_position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [chains, configuredChain]);
|
||||||
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
|
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
|
||||||
() => taskId ? {
|
() => taskId ? {
|
||||||
taskId,
|
taskId,
|
||||||
position: 0,
|
position: 0,
|
||||||
|
positionFor: (target) => {
|
||||||
|
const agentId = target.agentId?.trim();
|
||||||
|
if (!agentId) return null;
|
||||||
|
return seatArtifactPositions[agentId.toLowerCase()]
|
||||||
|
?? seatArtifactPositions[target.agentName?.trim().toLowerCase() ?? ""]
|
||||||
|
?? null;
|
||||||
|
},
|
||||||
createProcessReport: createEightBfTaskProcessReport,
|
createProcessReport: createEightBfTaskProcessReport,
|
||||||
updateProcessReport: updateEightBfTaskProcessReport,
|
updateProcessReport: updateEightBfTaskProcessReport,
|
||||||
} : undefined,
|
} : undefined,
|
||||||
[taskId],
|
[taskId, seatArtifactPositions],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -199,6 +235,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
if (canConfigureChain) setIsSettingsOpen(true);
|
if (canConfigureChain) setIsSettingsOpen(true);
|
||||||
}, [canConfigureChain]);
|
}, [canConfigureChain]);
|
||||||
|
|
||||||
|
// 「智能体配置」面板里保存产物 position 后,把最新链数据写回页面状态,
|
||||||
|
// 让 seatArtifactPositions / 提交产物取值即时生效(无需刷新页面)。
|
||||||
|
const handleChainUpdated = useCallback((updated: BusinessChain) => {
|
||||||
|
setChains((prev) => prev.map((chain) => (chain.id === updated.id ? updated : chain)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toolbarSlot = (
|
const toolbarSlot = (
|
||||||
<>
|
<>
|
||||||
{canConfigureChain && isSettingsOpen && (
|
{canConfigureChain && isSettingsOpen && (
|
||||||
@@ -293,6 +335,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
chain={configuredChain}
|
chain={configuredChain}
|
||||||
canConfigureChain={canConfigureChain}
|
canConfigureChain={canConfigureChain}
|
||||||
onConfigureChain={openChainSettings}
|
onConfigureChain={openChainSettings}
|
||||||
|
onChainUpdated={handleChainUpdated}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => (
|
renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => (
|
||||||
@@ -343,6 +386,8 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
}
|
}
|
||||||
rootClassName="eightbf-roundtable"
|
rootClassName="eightbf-roundtable"
|
||||||
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
|
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
|
||||||
|
// 会商「智能体配置」抽屉里只读展示各席位在业务链上配置的产物提交 position。
|
||||||
|
seatArtifactPositions={seatArtifactPositions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** Neutral product terminology for system messages; never apply to knowledge content or API identifiers. */
|
||||||
|
export function userFacingKnowledgeMessage(value: string): string {
|
||||||
|
return value.replace(/weknora/gi, "知识服务");
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ const MAX_INTENT_LENGTH = 500;
|
|||||||
const MAX_SAMPLE_TEXT_LENGTH = 120000;
|
const MAX_SAMPLE_TEXT_LENGTH = 120000;
|
||||||
|
|
||||||
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
|
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
|
||||||
{ value: 'general', label: '通用检索', desc: '素材收集专家按配置技能检索' },
|
{ value: 'general', label: '通用检索', desc: '素材收集专家自由选用全部技能检索' },
|
||||||
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库检索接口' },
|
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库检索接口' },
|
||||||
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
|
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ interface MaterialConfirmCardProps {
|
|||||||
type MaterialSource = NonNullable<UserInterventionPayload['materialSource']>;
|
type MaterialSource = NonNullable<UserInterventionPayload['materialSource']>;
|
||||||
|
|
||||||
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
|
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
|
||||||
{ value: 'general', label: '通用检索', desc: '素材收集专家按配置技能检索' },
|
{ value: 'general', label: '通用检索', desc: '素材收集专家自由选用全部技能检索' },
|
||||||
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库接口' },
|
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库接口' },
|
||||||
{ value: 'notebook', label: '我的空间', desc: '检索当前笔记空间' },
|
{ value: 'notebook', label: '我的空间', desc: '检索当前笔记空间' },
|
||||||
];
|
];
|
||||||
@@ -218,7 +218,7 @@ function MaterialConfirmCard({
|
|||||||
|
|
||||||
async function handleReSearch() {
|
async function handleReSearch() {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
// 带输入 = 后端在素材收集专家配置的技能中匹配检索、新素材追加到列表;
|
// 带输入 = 后端从全部启用技能中匹配检索、新素材追加到列表;
|
||||||
// 无输入 = 重新生成检索词、覆盖式重检
|
// 无输入 = 重新生成检索词、覆盖式重检
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
action: 're_search',
|
action: 're_search',
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ export interface UserInterventionPayload {
|
|||||||
approvedMaterialIds?: string[];
|
approvedMaterialIds?: string[];
|
||||||
/** @deprecated 旧版多关键词追加。新版统一用 userQuery 单输入框 */
|
/** @deprecated 旧版多关键词追加。新版统一用 userQuery 单输入框 */
|
||||||
extraKeywords?: string[];
|
extraKeywords?: string[];
|
||||||
/** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端在素材收集专家配置的技能中匹配最相关的技能检索,新素材**追加**到列表。 */
|
/** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端从全部启用技能中匹配最相关的技能检索,新素材**追加**到列表。 */
|
||||||
userQuery?: string;
|
userQuery?: string;
|
||||||
/** PAUSE-1 素材确认:本次补充/重新检索使用的检索方式。 */
|
/** PAUSE-1 素材确认:本次补充/重新检索使用的检索方式。 */
|
||||||
materialSource?: "general" | "knowledge_base" | "notebook";
|
materialSource?: "general" | "knowledge_base" | "notebook";
|
||||||
@@ -298,7 +298,7 @@ export interface AIWritingRequest {
|
|||||||
writingMode: "strict" | "loose";
|
writingMode: "strict" | "loose";
|
||||||
/** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认 true。 */
|
/** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认 true。 */
|
||||||
addReferences?: boolean;
|
addReferences?: boolean;
|
||||||
/** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/
|
/** 检索类型:general=通用检索(素材收集专家从全部启用技能自由挑选检索,内置知识库检索保底)/
|
||||||
* knowledge_base=知识库检索(直连内网知识库 ES 接口,不走技能编排)/ notebook=我的空间 */
|
* knowledge_base=知识库检索(直连内网知识库 ES 接口,不走技能编排)/ notebook=我的空间 */
|
||||||
materialSource: "general" | "knowledge_base" | "notebook";
|
materialSource: "general" | "knowledge_base" | "notebook";
|
||||||
/** 不收集素材,直接用通用知识写作:勾选后跳过整个素材检索流程(含素材确认暂停点),
|
/** 不收集素材,直接用通用知识写作:勾选后跳过整个素材检索流程(含素材确认暂停点),
|
||||||
|
|||||||
@@ -373,6 +373,7 @@ export default function AgentChatPage({
|
|||||||
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>(
|
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>(
|
||||||
() => pendingAssistantKnowledgeSelection.current ?? [],
|
() => pendingAssistantKnowledgeSelection.current ?? [],
|
||||||
);
|
);
|
||||||
|
const [knowledgeSuggestedQuestions, setKnowledgeSuggestedQuestions] = useState<string[]>([]);
|
||||||
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(
|
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(
|
||||||
() => pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null,
|
() => pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null,
|
||||||
);
|
);
|
||||||
@@ -1029,7 +1030,7 @@ export default function AgentChatPage({
|
|||||||
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
|
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
|
||||||
router.push(
|
router.push(
|
||||||
isEmbedded
|
isEmbedded
|
||||||
? `/page/workspace/chats/new?agent=${agent_id}`
|
? `${routeBase}/chats/new?agent=${agent_id}`
|
||||||
: `${agentChatsBase}/new`,
|
: `${agentChatsBase}/new`,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -1079,6 +1080,7 @@ export default function AgentChatPage({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
isNewThread
|
isNewThread
|
||||||
hideAdminRecommendedQuestions
|
hideAdminRecommendedQuestions
|
||||||
|
knowledgeSuggestedQuestions={knowledgeSuggestedQuestions}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
assistantId={agent_id}
|
assistantId={agent_id}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -1099,6 +1101,7 @@ export default function AgentChatPage({
|
|||||||
assistantValue={selectedAssistantKnowledgeBaseIds}
|
assistantValue={selectedAssistantKnowledgeBaseIds}
|
||||||
onChange={changeKnowledgeSelection}
|
onChange={changeKnowledgeSelection}
|
||||||
onAssistantChange={changeAssistantKnowledgeSelection}
|
onAssistantChange={changeAssistantKnowledgeSelection}
|
||||||
|
onSuggestedQuestionsChange={setKnowledgeSuggestedQuestions}
|
||||||
autoSelectAll={
|
autoSelectAll={
|
||||||
isNewThread &&
|
isNewThread &&
|
||||||
pendingKnowledgeSelection.current === null &&
|
pendingKnowledgeSelection.current === null &&
|
||||||
|
|||||||
@@ -0,0 +1,831 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Link,
|
||||||
|
Navigate,
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
useLocation,
|
||||||
|
useMatch,
|
||||||
|
useNavigate,
|
||||||
|
useParams,
|
||||||
|
} from "react-router-dom";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Bot,
|
||||||
|
FolderOpen,
|
||||||
|
Loader2,
|
||||||
|
LockKeyhole,
|
||||||
|
Plus,
|
||||||
|
Settings,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
|
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||||
|
import { useAgentSelector } from "@/core/agents/hooks";
|
||||||
|
import { WorkflowConfigButton } from "./deep-research/WorkflowConfigDialog";
|
||||||
|
import {
|
||||||
|
createAnalysisProject,
|
||||||
|
createProjectThread,
|
||||||
|
DEFAULT_STRATEGY_LENSES,
|
||||||
|
PROJECT_MODES,
|
||||||
|
PROJECT_OUTLINE,
|
||||||
|
projectKeys,
|
||||||
|
updateAnalysisProject,
|
||||||
|
useAnalysisProject,
|
||||||
|
useAnalysisProjects,
|
||||||
|
useProjectThreadAccess,
|
||||||
|
useProjectThreads,
|
||||||
|
type AnalysisProject,
|
||||||
|
type ProjectFields,
|
||||||
|
type ProjectMode,
|
||||||
|
type ProjectReportConfig,
|
||||||
|
type ProjectStrategyConfig,
|
||||||
|
} from "@/core/analysis-projects/api";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ProjectConversation } from "./analysis-projects/ProjectConversation";
|
||||||
|
|
||||||
|
const ProjectNavigation = createContext({
|
||||||
|
basePath: "/page/workspace/projects",
|
||||||
|
listPath: "/page/workspace/projects",
|
||||||
|
mode: undefined as ProjectMode | undefined,
|
||||||
|
showSidebarTrigger: true,
|
||||||
|
});
|
||||||
|
const defaultReport: ProjectReportConfig = {
|
||||||
|
custom_outline: PROJECT_OUTLINE.map((s, i) => `## ${i + 1}. ${s}`).join("\n"),
|
||||||
|
structure_mode: "fixed",
|
||||||
|
retrieval_directions: [],
|
||||||
|
retrieval_skills: [],
|
||||||
|
};
|
||||||
|
const defaultStrategy: ProjectStrategyConfig = {
|
||||||
|
lenses: [...DEFAULT_STRATEGY_LENSES],
|
||||||
|
};
|
||||||
|
function ProjectSidebarTrigger() {
|
||||||
|
const { showSidebarTrigger } = useContext(ProjectNavigation);
|
||||||
|
return showSidebarTrigger ? <SidebarTrigger aria-label="展开功能菜单" /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorState({ error, retry }: { error: unknown; retry?: () => void }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="m-4 rounded-lg border border-destructive/30 p-4 text-sm"
|
||||||
|
>
|
||||||
|
<p>{error instanceof Error ? error.message : "读取失败,请重试"}</p>
|
||||||
|
{retry && (
|
||||||
|
<Button variant="outline" className="mt-3" onClick={retry}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-10">
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
加载中…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label = "选择智能体",
|
||||||
|
}: {
|
||||||
|
value: string | null;
|
||||||
|
onChange: (id: string | null) => void;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
const { agents, isLoading, error } = useAgentSelector();
|
||||||
|
return (
|
||||||
|
<label className="flex min-w-0 flex-col gap-2 text-sm">
|
||||||
|
<span>{label}</span>
|
||||||
|
<select
|
||||||
|
aria-label={label}
|
||||||
|
className="h-9 max-w-full rounded-md border bg-background px-3 text-sm"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => onChange(e.target.value || null)}
|
||||||
|
>
|
||||||
|
<option value="">通用助手</option>
|
||||||
|
{value && !agents.some((a) => a.id === value) && (
|
||||||
|
<option value={value}>所选智能体(加载中或已不可用)</option>
|
||||||
|
)}
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<option key={agent.id} value={agent.id}>
|
||||||
|
{agent.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{isLoading && (
|
||||||
|
<span className="text-xs text-muted-foreground">加载智能体…</span>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<span role="alert" className="text-xs text-destructive">
|
||||||
|
智能体列表加载失败,请刷新重试
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportConfiguration({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: ProjectReportConfig;
|
||||||
|
onChange: (next: ProjectReportConfig) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="space-y-4 rounded-xl border p-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium">工作流配置 · 报告结构</h2>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
选择结构后自动填充;结构库支持新增、启停、编辑和删除。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<WorkflowConfigButton
|
||||||
|
inlineCreate
|
||||||
|
appliedContent={value.custom_outline}
|
||||||
|
onApply={(
|
||||||
|
content,
|
||||||
|
structureMode,
|
||||||
|
retrievalDirections,
|
||||||
|
retrievalSkills,
|
||||||
|
) =>
|
||||||
|
onChange({
|
||||||
|
custom_outline: content,
|
||||||
|
structure_mode: structureMode,
|
||||||
|
retrieval_directions: retrievalDirections,
|
||||||
|
retrieval_skills: retrievalSkills,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="block space-y-2 text-sm">
|
||||||
|
<span>报告结构配置</span>
|
||||||
|
<Textarea
|
||||||
|
aria-label="报告结构配置"
|
||||||
|
rows={8}
|
||||||
|
value={value.custom_outline}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...value, custom_outline: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="选择报告结构,或输入 Markdown 章节大纲"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 text-sm">
|
||||||
|
<span>结构约束</span>
|
||||||
|
<select
|
||||||
|
aria-label="结构约束"
|
||||||
|
className="h-9 rounded-md border bg-background px-3"
|
||||||
|
value={value.structure_mode}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
structure_mode: e.target.value as "fixed" | "adaptive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="fixed">固定大纲</option>
|
||||||
|
<option value="adaptive">弹性大纲</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
检索方向:
|
||||||
|
{value.retrieval_directions.join("、") || "由智能体根据问题确定"}
|
||||||
|
;检索技能:{value.retrieval_skills.join("、") || "沿用智能体配置"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
项目保存当前结构副本。结构库变更后可重新选择应用,也可直接修改本项目大纲;保存后用于下一轮方案生成。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StrategyConfiguration({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: ProjectStrategyConfig;
|
||||||
|
onChange: (next: ProjectStrategyConfig) => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const toggle = (lens: string) => {
|
||||||
|
const selected = value.lenses.includes(lens);
|
||||||
|
if (selected && value.lenses.length === 1) return;
|
||||||
|
onChange({
|
||||||
|
lenses: selected
|
||||||
|
? value.lenses.filter((item) => item !== lens)
|
||||||
|
: [...value.lenses, lens].slice(0, 10),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const add = () => {
|
||||||
|
const lens = draft.trim().replace(/视角$/, "") + "视角";
|
||||||
|
if (!draft.trim() || value.lenses.includes(lens) || value.lenses.length >= 10)
|
||||||
|
return;
|
||||||
|
onChange({ lenses: [...value.lenses, lens] });
|
||||||
|
setDraft("");
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<section className="space-y-4 rounded-xl border p-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium">Strategy Lens · 默认分析视角</h2>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
每个视角独立形成判断与候选策略,再进入策略辩论和综合决策。至少保留一个,最多十个。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{DEFAULT_STRATEGY_LENSES.map((lens) => (
|
||||||
|
<label
|
||||||
|
key={lens}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm",
|
||||||
|
value.lenses.includes(lens) && "border-primary bg-primary/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value.lenses.includes(lens)}
|
||||||
|
onChange={() => toggle(lens)}
|
||||||
|
className="accent-primary"
|
||||||
|
/>
|
||||||
|
{lens}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{value.lenses
|
||||||
|
.filter(
|
||||||
|
(lens) =>
|
||||||
|
!DEFAULT_STRATEGY_LENSES.includes(
|
||||||
|
lens as (typeof DEFAULT_STRATEGY_LENSES)[number],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((lens) => (
|
||||||
|
<Badge key={lens} variant="secondary" className="gap-1 py-1">
|
||||||
|
{lens}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`删除视角 ${lens}`}
|
||||||
|
onClick={() => toggle(lens)}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
aria-label="自定义分析视角"
|
||||||
|
maxLength={38}
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
add();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="例如:用户体验、供应链、敌方"
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" onClick={add}>
|
||||||
|
添加视角
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectForm({
|
||||||
|
project,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
project?: AnalysisProject;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const { basePath: BASE, mode: initialMode } = useContext(ProjectNavigation);
|
||||||
|
const navigate = useNavigate(),
|
||||||
|
queryClient = useQueryClient();
|
||||||
|
const [name, setName] = useState(project?.name ?? ""),
|
||||||
|
[background, setBackground] = useState(project?.background ?? "");
|
||||||
|
const [mode, setMode] = useState<ProjectMode>(project?.mode ?? initialMode ?? "qa");
|
||||||
|
const [agentId, setAgentId] = useState<string | null>(
|
||||||
|
project?.agent_id ?? null,
|
||||||
|
);
|
||||||
|
const [report, setReport] = useState<ProjectReportConfig>({
|
||||||
|
...defaultReport,
|
||||||
|
...project?.report_config,
|
||||||
|
custom_outline:
|
||||||
|
project?.report_config?.custom_outline || defaultReport.custom_outline,
|
||||||
|
});
|
||||||
|
const [strategy, setStrategy] = useState<ProjectStrategyConfig>({
|
||||||
|
...defaultStrategy,
|
||||||
|
...project?.strategy_config,
|
||||||
|
lenses: project?.strategy_config?.lenses?.length
|
||||||
|
? project.strategy_config.lenses
|
||||||
|
: defaultStrategy.lenses,
|
||||||
|
});
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const fields: ProjectFields = {
|
||||||
|
name: name.trim(),
|
||||||
|
background: background.trim(),
|
||||||
|
agent_id: agentId,
|
||||||
|
report_config:
|
||||||
|
mode === "solution"
|
||||||
|
? report
|
||||||
|
: { ...defaultReport, custom_outline: "" },
|
||||||
|
strategy_config: strategy,
|
||||||
|
knowledge_base_ids: project?.knowledge_base_ids ?? [],
|
||||||
|
assistant_knowledge_base_ids: project?.assistant_knowledge_base_ids ?? [],
|
||||||
|
};
|
||||||
|
return project
|
||||||
|
? updateAnalysisProject(project.id, fields)
|
||||||
|
: createAnalysisProject({ ...fields, mode });
|
||||||
|
},
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
queryClient.setQueryData([...projectKeys(), result.id], result);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: projectKeys() });
|
||||||
|
toast.success(project ? "项目配置已保存" : "项目已创建");
|
||||||
|
navigate(`${BASE}/${result.id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="mx-auto w-full max-w-4xl space-y-6 p-5 md:p-7"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!save.isPending && name.trim()) save.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ProjectSidebarTrigger />
|
||||||
|
<Button type="button" variant="ghost" onClick={onCancel}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
返回
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-xl font-semibold">
|
||||||
|
{project ? "项目设置" : "新建项目"}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<label className="block space-y-2 text-sm">
|
||||||
|
<span>项目名称</span>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
maxLength={160}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="例如:区域市场背景研究"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<fieldset className="space-y-3">
|
||||||
|
<legend className="text-sm font-medium">项目问答模式</legend>
|
||||||
|
{project || initialMode ? (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border p-4">
|
||||||
|
<Badge variant="secondary">{PROJECT_MODES[mode].name}</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
创建时已确定,不可更改
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-3 lg:grid-cols-3">
|
||||||
|
{(Object.keys(PROJECT_MODES) as ProjectMode[]).map((value) => (
|
||||||
|
<label
|
||||||
|
key={value}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer gap-3 rounded-xl border p-4",
|
||||||
|
mode === value && "border-primary bg-primary/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="project-mode"
|
||||||
|
checked={mode === value}
|
||||||
|
onChange={() => setMode(value)}
|
||||||
|
className="mt-1 accent-primary"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{PROJECT_MODES[value].name}
|
||||||
|
</span>
|
||||||
|
<span className="mt-2 block text-xs leading-5 text-muted-foreground">
|
||||||
|
{PROJECT_MODES[value].description}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
模式创建后不可更改,项目内所有对话使用同一模式。
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
<section className="space-y-2">
|
||||||
|
<AgentSelect
|
||||||
|
value={agentId}
|
||||||
|
onChange={setAgentId}
|
||||||
|
label="项目默认智能体"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
沿用智能体的能力、技能和知识配置。新建对话时可以选择其他智能体。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<label className="block space-y-2 text-sm">
|
||||||
|
<span>项目说明</span>
|
||||||
|
<Textarea
|
||||||
|
value={background}
|
||||||
|
maxLength={12000}
|
||||||
|
onChange={(e) => setBackground(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="填写项目目标、业务背景与约束…"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{mode === "solution" && (
|
||||||
|
<ReportConfiguration value={report} onChange={setReport} />
|
||||||
|
)}
|
||||||
|
{mode === "strategy" && (
|
||||||
|
<StrategyConfiguration value={strategy} onChange={setStrategy} />
|
||||||
|
)}
|
||||||
|
{save.error && <ErrorState error={save.error} />}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
save.isPending ||
|
||||||
|
!name.trim() ||
|
||||||
|
(mode === "solution" && !report.custom_outline.trim())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{save.isPending && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
{project ? "保存设置" : "创建项目"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectList() {
|
||||||
|
const { basePath: BASE } = useContext(ProjectNavigation);
|
||||||
|
const query = useAnalysisProjects();
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto p-5 md:p-7">
|
||||||
|
<div className="mx-auto max-w-6xl">
|
||||||
|
<header className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ProjectSidebarTrigger />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">项目空间</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
按项目归类对话,使用熟悉的问答工作区。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to={`${BASE}/new`}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
新建项目
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<Loading />
|
||||||
|
) : query.error ? (
|
||||||
|
<ErrorState error={query.error} retry={() => void query.refetch()} />
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{query.data?.projects.map((project) => (
|
||||||
|
<article
|
||||||
|
key={project.id}
|
||||||
|
className="rounded-xl border bg-card p-5"
|
||||||
|
>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{PROJECT_MODES[project.mode].name}
|
||||||
|
</Badge>
|
||||||
|
<h2 className="mt-4 text-lg font-medium">{project.name}</h2>
|
||||||
|
<p className="mt-2 line-clamp-2 min-h-10 text-sm text-muted-foreground">
|
||||||
|
{project.background ||
|
||||||
|
PROJECT_MODES[project.mode].description}
|
||||||
|
</p>
|
||||||
|
<Button asChild variant="outline" className="mt-4 w-full">
|
||||||
|
<Link to={`${BASE}/${project.id}`}>进入项目</Link>
|
||||||
|
</Button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!query.isLoading && !query.error && !query.data?.projects.length && (
|
||||||
|
<div className="mt-8 rounded-xl border border-dashed p-10 text-center text-muted-foreground">
|
||||||
|
<FolderOpen className="mx-auto mb-3 size-8" />
|
||||||
|
创建项目,将相关问题和分析放在一起。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceContent({ project }: { project: AnalysisProject }) {
|
||||||
|
const { basePath: BASE, listPath } = useContext(ProjectNavigation);
|
||||||
|
const location = useLocation(),
|
||||||
|
navigate = useNavigate(),
|
||||||
|
queryClient = useQueryClient();
|
||||||
|
const threadsQuery = useProjectThreads(project.id),
|
||||||
|
match = useMatch(`${BASE}/:projectId/chats/:thread_id`);
|
||||||
|
const threadId = match?.params.thread_id,
|
||||||
|
access = useProjectThreadAccess(project.id, threadId);
|
||||||
|
const [agentId, setAgentId] = useState<string | null>(project.agent_id);
|
||||||
|
useEffect(() => { setAgentId(project.agent_id); }, [project.agent_id]);
|
||||||
|
const [lensDraft, setLensDraft] = useState("");
|
||||||
|
const [addingLens, setAddingLens] = useState(false);
|
||||||
|
const strategyLenses = project.strategy_config?.lenses?.length
|
||||||
|
? project.strategy_config.lenses
|
||||||
|
: [...DEFAULT_STRATEGY_LENSES];
|
||||||
|
const saveLenses = useMutation({
|
||||||
|
mutationFn: (lenses: string[]) =>
|
||||||
|
updateAnalysisProject(project.id, {
|
||||||
|
name: project.name,
|
||||||
|
background: project.background,
|
||||||
|
agent_id: project.agent_id,
|
||||||
|
report_config: project.report_config,
|
||||||
|
strategy_config: { lenses },
|
||||||
|
knowledge_base_ids: project?.knowledge_base_ids ?? [],
|
||||||
|
assistant_knowledge_base_ids: project?.assistant_knowledge_base_ids ?? [],
|
||||||
|
}),
|
||||||
|
onSuccess: (updated) => {
|
||||||
|
queryClient.setQueryData([...projectKeys(), project.id], updated);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: projectKeys() });
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
const addLens = () => {
|
||||||
|
const raw = lensDraft.trim();
|
||||||
|
const lens = raw ? `${raw.replace(/视角$/, "")}视角` : "";
|
||||||
|
if (!lens || strategyLenses.includes(lens) || strategyLenses.length >= 10)
|
||||||
|
return;
|
||||||
|
saveLenses.mutate([...strategyLenses, lens]);
|
||||||
|
setLensDraft("");
|
||||||
|
setAddingLens(false);
|
||||||
|
};
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: () => createProjectThread(project.id, agentId),
|
||||||
|
onSuccess: async (thread) => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [...projectKeys(), project.id, "threads"],
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||||
|
navigate(`${BASE}/${project.id}/chats/${thread.thread_id}`);
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
const threads = threadsQuery.data?.threads ?? [];
|
||||||
|
useEffect(() => {
|
||||||
|
if (threadsQuery.data) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||||
|
}
|
||||||
|
}, [queryClient, threadsQuery.data]);
|
||||||
|
if (location.pathname.endsWith("/settings"))
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
<ProjectForm
|
||||||
|
key={project.updated_at}
|
||||||
|
project={project}
|
||||||
|
onCancel={() => navigate(`${BASE}/${project.id}`)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
<header className="shrink-0 border-b bg-background px-4 py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<ProjectSidebarTrigger />
|
||||||
|
<Link to={listPath} className="text-sm text-muted-foreground">
|
||||||
|
{listPath.includes("?type=qa") ? "我的项目" : "项目空间"}
|
||||||
|
</Link>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<h1 className="min-w-0 flex-1 truncate font-semibold">
|
||||||
|
{project.name}
|
||||||
|
</h1>
|
||||||
|
<Badge variant="secondary">{PROJECT_MODES[project.mode].name}</Badge>
|
||||||
|
<LockKeyhole
|
||||||
|
className="size-3 text-muted-foreground"
|
||||||
|
aria-label="项目模式已锁定"
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="ghost" asChild>
|
||||||
|
<Link to={`${BASE}/${project.id}/settings`}>
|
||||||
|
<Settings className="size-4" />
|
||||||
|
{project.mode === "solution"
|
||||||
|
? "报告结构与设置"
|
||||||
|
: project.mode === "strategy"
|
||||||
|
? "分析视角与设置"
|
||||||
|
: "项目设置"}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap items-end gap-3">
|
||||||
|
<div className="w-48">
|
||||||
|
<AgentSelect
|
||||||
|
value={agentId}
|
||||||
|
onChange={setAgentId}
|
||||||
|
label="新对话使用的智能体"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={create.isPending}
|
||||||
|
onClick={() => create.mutate()}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
新建对话
|
||||||
|
</Button>
|
||||||
|
<label className="ml-auto flex max-w-full items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>项目对话</span>
|
||||||
|
<select
|
||||||
|
aria-label="项目对话"
|
||||||
|
className="h-9 max-w-56 rounded-md border bg-background px-2 text-sm"
|
||||||
|
value={threadId ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
navigate(
|
||||||
|
e.target.value
|
||||||
|
? `${BASE}/${project.id}/chats/${e.target.value}`
|
||||||
|
: `${BASE}/${project.id}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">选择历史对话</option>
|
||||||
|
{threads.map((t) => (
|
||||||
|
<option key={t.thread_id} value={t.thread_id}>
|
||||||
|
{t.title || "新对话"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{project.mode === "strategy" && (
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2 border-t pt-3">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
分析视角
|
||||||
|
</span>
|
||||||
|
{strategyLenses.map((lens) => (
|
||||||
|
<Badge key={lens} variant="outline" className="gap-1 bg-background">
|
||||||
|
{lens}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`删除视角 ${lens}`}
|
||||||
|
disabled={strategyLenses.length === 1 || saveLenses.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
saveLenses.mutate(
|
||||||
|
strategyLenses.filter((item) => item !== lens),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{addingLens ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
aria-label="添加分析视角"
|
||||||
|
className="h-7 w-40 text-xs"
|
||||||
|
value={lensDraft}
|
||||||
|
maxLength={38}
|
||||||
|
placeholder="如:就业安全"
|
||||||
|
onChange={(event) => setLensDraft(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") addLens();
|
||||||
|
if (event.key === "Escape") setAddingLens(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="button" size="sm" className="h-7" onClick={addLens}>
|
||||||
|
确定
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={strategyLenses.length >= 10}
|
||||||
|
onClick={() => setAddingLens(true)}
|
||||||
|
>
|
||||||
|
<Plus className="size-3" />
|
||||||
|
添加视角
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{threadsQuery.error && (
|
||||||
|
<ErrorState
|
||||||
|
error={threadsQuery.error}
|
||||||
|
retry={() => void threadsQuery.refetch()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
{threadId ? (
|
||||||
|
access.isLoading ? (
|
||||||
|
<Loading />
|
||||||
|
) : access.error ? (
|
||||||
|
<ErrorState error={access.error} retry={() => void access.refetch()} />
|
||||||
|
) : (
|
||||||
|
<ProjectConversation
|
||||||
|
key={threadId}
|
||||||
|
project={project}
|
||||||
|
threadId={threadId}
|
||||||
|
agentId={
|
||||||
|
typeof access.data?.metadata?.agent_id === "string"
|
||||||
|
? access.data.metadata.agent_id
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-6">
|
||||||
|
<div className="mx-auto max-w-3xl py-8">
|
||||||
|
<Bot className="mb-4 size-8 text-primary" />
|
||||||
|
<h2 className="text-xl font-medium">在项目中继续问答</h2>
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
|
{project.background || PROJECT_MODES[project.mode].description}
|
||||||
|
<br />选择智能体后新建对话,或从项目对话列表继续已有问答。
|
||||||
|
</p>
|
||||||
|
{project.mode === "strategy" && (
|
||||||
|
<div className="mt-6 grid gap-3 md:grid-cols-3">
|
||||||
|
{[
|
||||||
|
"01 意图理解",
|
||||||
|
"02 多视角策略分析与辩论",
|
||||||
|
"03 完整策略方案与执行路线",
|
||||||
|
].map((s) => (
|
||||||
|
<div className="rounded-xl border p-5 text-sm" key={s}>
|
||||||
|
{s}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 divide-y rounded-xl border">
|
||||||
|
{threads.map((t) => (
|
||||||
|
<Link
|
||||||
|
key={t.thread_id}
|
||||||
|
to={`${BASE}/${project.id}/chats/${t.thread_id}`}
|
||||||
|
className="block px-4 py-3 text-sm hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
{t.title || "新对话"}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function ProjectWorkspace() {
|
||||||
|
const { projectId = "" } = useParams();
|
||||||
|
const query = useAnalysisProject(projectId);
|
||||||
|
return query.isLoading ? (
|
||||||
|
<Loading />
|
||||||
|
) : query.error || !query.data ? (
|
||||||
|
<ErrorState error={query.error} retry={() => void query.refetch()} />
|
||||||
|
) : (
|
||||||
|
<WorkspaceContent key={query.data.id} project={query.data} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function NewProject() {
|
||||||
|
const { listPath } = useContext(ProjectNavigation);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
<ProjectForm onCancel={() => navigate(listPath)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default function AnalysisProjectsPage({
|
||||||
|
basePath = "/page/workspace/projects",
|
||||||
|
listPath = basePath,
|
||||||
|
mode,
|
||||||
|
showSidebarTrigger = true,
|
||||||
|
}: { basePath?: string; listPath?: string; mode?: ProjectMode; showSidebarTrigger?: boolean } = {}) {
|
||||||
|
const { userId } = useLiveAccount();
|
||||||
|
return (
|
||||||
|
<ProjectNavigation.Provider value={{ basePath, listPath, mode, showSidebarTrigger }}>
|
||||||
|
<Routes key={userId ?? "anonymous"}>
|
||||||
|
<Route index element={mode ? <Navigate to={listPath} replace /> : <ProjectList />} />
|
||||||
|
<Route path="new" element={<NewProject />} />
|
||||||
|
<Route path=":projectId/*" element={<ProjectWorkspace />} />
|
||||||
|
</Routes>
|
||||||
|
</ProjectNavigation.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
BookOpenText,
|
BookOpenText,
|
||||||
@@ -36,8 +37,10 @@ import {
|
|||||||
useAssistantKnowledgeActions,
|
useAssistantKnowledgeActions,
|
||||||
useAssistantKnowledgeBases,
|
useAssistantKnowledgeBases,
|
||||||
useAssistantKnowledgeSources,
|
useAssistantKnowledgeSources,
|
||||||
|
useAssistantWikiPage,
|
||||||
useAssistantWikiPages,
|
useAssistantWikiPages,
|
||||||
type AssistantKnowledgeBase,
|
type AssistantKnowledgeBase,
|
||||||
|
type AssistantWikiPage,
|
||||||
} from "@/strategy-components/api/assistant-knowledge";
|
} from "@/strategy-components/api/assistant-knowledge";
|
||||||
import {
|
import {
|
||||||
WikiLibraryReader,
|
WikiLibraryReader,
|
||||||
@@ -45,48 +48,69 @@ import {
|
|||||||
} from "@/strategy-components/components/resource-management/llmwiki/WikiLibraryReader";
|
} from "@/strategy-components/components/resource-management/llmwiki/WikiLibraryReader";
|
||||||
import { WikiPackageTransfers } from "@/strategy-components/components/resource-management/llmwiki/WikiPackageTransfers";
|
import { WikiPackageTransfers } from "@/strategy-components/components/resource-management/llmwiki/WikiPackageTransfers";
|
||||||
|
|
||||||
|
function toLibraryPage(page: AssistantWikiPage): LibraryWikiPage {
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
content: page.content_markdown || "",
|
||||||
|
status: "published",
|
||||||
|
aliases: page.aliases || [],
|
||||||
|
source_refs: page.source_refs || [],
|
||||||
|
in_links: page.in_links || [],
|
||||||
|
out_links: page.out_links || [],
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function AssistantKnowledgePage() {
|
export default function AssistantKnowledgePage() {
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
const baseId = params.get("base_id");
|
const baseId = params.get("base_id");
|
||||||
const slug = params.get("wikiSlug");
|
const slug = params.get("wikiSlug");
|
||||||
|
const [tab, setTab] = useState("wiki");
|
||||||
const bases = useAssistantKnowledgeBases();
|
const bases = useAssistantKnowledgeBases();
|
||||||
const actions = useAssistantKnowledgeActions();
|
const actions = useAssistantKnowledgeActions();
|
||||||
const pages = useAssistantWikiPages("", baseId);
|
const pages = useAssistantWikiPages("", baseId, {
|
||||||
const sources = useAssistantKnowledgeSources(baseId);
|
includeContent: false,
|
||||||
const jobs = useAssistantImportJobs(baseId);
|
enabled: !!baseId,
|
||||||
|
});
|
||||||
|
const sources = useAssistantKnowledgeSources(baseId, !!baseId);
|
||||||
|
const jobs = useAssistantImportJobs(baseId, !!baseId);
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const [tab, setTab] = useState("wiki");
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editingBase, setEditingBase] =
|
const [editingBase, setEditingBase] =
|
||||||
useState<AssistantKnowledgeBase | null>(null);
|
useState<AssistantKnowledgeBase | null>(null);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
|
const searchSequence = useRef(0);
|
||||||
const [searchResult, setSearchResult] = useState<Awaited<
|
const [searchResult, setSearchResult] = useState<Awaited<
|
||||||
ReturnType<typeof searchAssistantWiki>
|
ReturnType<typeof searchAssistantWiki>
|
||||||
> | null>(null);
|
> | null>(null);
|
||||||
const upload = useRef<HTMLInputElement>(null);
|
const upload = useRef<HTMLInputElement>(null);
|
||||||
|
const documentUpload = useRef<HTMLInputElement>(null);
|
||||||
const base = bases.data?.find((b) => b.id === baseId);
|
const base = bases.data?.find((b) => b.id === baseId);
|
||||||
const libraryPages = useMemo<LibraryWikiPage[]>(
|
const libraryPages = useMemo<LibraryWikiPage[]>(
|
||||||
() =>
|
() => (pages.data || []).map(toLibraryPage),
|
||||||
(pages.data || []).map((p) => ({
|
|
||||||
...p,
|
|
||||||
content: p.content_markdown,
|
|
||||||
status: "published",
|
|
||||||
aliases: p.aliases || [],
|
|
||||||
source_refs: p.source_refs || [],
|
|
||||||
in_links: p.in_links || [],
|
|
||||||
out_links: p.out_links || [],
|
|
||||||
version: 1,
|
|
||||||
})),
|
|
||||||
[pages.data],
|
[pages.data],
|
||||||
);
|
);
|
||||||
const active =
|
const defaultDirectoryPage =
|
||||||
libraryPages.find((p) => p.slug === slug) ||
|
|
||||||
libraryPages.find((p) => p.page_type === "index") ||
|
libraryPages.find((p) => p.page_type === "index") ||
|
||||||
libraryPages[0] ||
|
libraryPages[0] ||
|
||||||
null;
|
null;
|
||||||
|
const activeDirectoryPage = slug
|
||||||
|
? libraryPages.find((p) => p.slug === slug) || null
|
||||||
|
: defaultDirectoryPage;
|
||||||
|
const activeSlug = slug || defaultDirectoryPage?.slug || null;
|
||||||
|
const activePage = useAssistantWikiPage(
|
||||||
|
activeSlug,
|
||||||
|
baseId,
|
||||||
|
!!activeSlug,
|
||||||
|
);
|
||||||
|
const active = useMemo(
|
||||||
|
() =>
|
||||||
|
activePage.data ? toLibraryPage(activePage.data) : activeDirectoryPage,
|
||||||
|
[activeDirectoryPage, activePage.data],
|
||||||
|
);
|
||||||
const navigatePage = (nextSlug: string) => {
|
const navigatePage = (nextSlug: string) => {
|
||||||
setParams({ base_id: baseId!, wikiSlug: nextSlug });
|
setParams({ base_id: baseId!, wikiSlug: nextSlug });
|
||||||
setTab("wiki");
|
setTab("wiki");
|
||||||
@@ -95,17 +119,38 @@ export default function AssistantKnowledgePage() {
|
|||||||
.filter((j) => j.status === "completed")
|
.filter((j) => j.status === "completed")
|
||||||
.map((j) => j.id)
|
.map((j) => j.id)
|
||||||
.join(",");
|
.join(",");
|
||||||
|
const packageImportActive = (jobs.data || []).some(
|
||||||
|
(job) =>
|
||||||
|
job.trigger === "manual_package_upload" &&
|
||||||
|
["queued", "running"].includes(job.status),
|
||||||
|
);
|
||||||
|
const packageImportBusy =
|
||||||
|
actions.uploadWeKnoraPackage.isPending || packageImportActive;
|
||||||
|
const finishedJobsSnapshot = useRef({ baseId: null as string | null, ids: "" });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!baseId || !jobs.data) return;
|
||||||
|
if (finishedJobsSnapshot.current.baseId !== baseId) {
|
||||||
|
finishedJobsSnapshot.current = { baseId, ids: finishedJobs };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (finishedJobsSnapshot.current.ids === finishedJobs) return;
|
||||||
|
finishedJobsSnapshot.current.ids = finishedJobs;
|
||||||
void client.invalidateQueries({
|
void client.invalidateQueries({
|
||||||
queryKey: ["assistant-knowledge", "pages"],
|
queryKey: ["assistant-knowledge", "pages"],
|
||||||
});
|
});
|
||||||
|
void client.invalidateQueries({
|
||||||
|
queryKey: ["assistant-knowledge", "page"],
|
||||||
|
});
|
||||||
void client.invalidateQueries({
|
void client.invalidateQueries({
|
||||||
queryKey: ["assistant-knowledge", "bases"],
|
queryKey: ["assistant-knowledge", "bases"],
|
||||||
});
|
});
|
||||||
}, [finishedJobs, client]);
|
}, [baseId, finishedJobs, jobs.data, client]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
++searchSequence.current;
|
||||||
|
setSearching(false);
|
||||||
setSearchResult(null);
|
setSearchResult(null);
|
||||||
setQuery("");
|
setQuery("");
|
||||||
|
return () => { ++searchSequence.current; };
|
||||||
}, [baseId]);
|
}, [baseId]);
|
||||||
const create = async () => {
|
const create = async () => {
|
||||||
if (!name.trim()) return;
|
if (!name.trim()) return;
|
||||||
@@ -120,13 +165,16 @@ export default function AssistantKnowledgePage() {
|
|||||||
};
|
};
|
||||||
const search = async () => {
|
const search = async () => {
|
||||||
if (!query.trim() || !baseId) return;
|
if (!query.trim() || !baseId) return;
|
||||||
|
const sequence = ++searchSequence.current;
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
|
setSearchResult(null);
|
||||||
try {
|
try {
|
||||||
setSearchResult(await searchAssistantWiki(query, baseId));
|
const result = await searchAssistantWiki(query.trim(), baseId);
|
||||||
|
if (sequence === searchSequence.current) setSearchResult(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error((e as Error).message);
|
if (sequence === searchSequence.current) toast.error((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
setSearching(false);
|
if (sequence === searchSequence.current) setSearching(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const refresh = () =>
|
const refresh = () =>
|
||||||
@@ -159,8 +207,7 @@ export default function AssistantKnowledgePage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">助手知识库</h1>
|
<h1 className="text-2xl font-semibold">助手知识库</h1>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
浏览分类
|
选择或新建知识库,上传文档后自动生成分类 Wiki、关联知识和向量索引。可独立使用,也可导入 普通知识库数据包;各库知识汇入知识梳理总库。
|
||||||
Wiki、关联知识与来源。普通知识库向量化完成后自动沉淀到知识梳理。
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
@@ -253,6 +300,17 @@ export default function AssistantKnowledgePage() {
|
|||||||
初始化知识梳理总库
|
初始化知识梳理总库
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{!bases.isLoading && !bases.error && !bases.data?.length && !isAdmin && (
|
||||||
|
<div className="rounded-xl border border-dashed p-8 text-center">
|
||||||
|
<p className="font-medium">暂无助手知识库</p>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
请联系管理员创建知识库并导入内容,完成后即可在这里查看和检索。
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" className="mt-4" onClick={() => void bases.refetch()}>
|
||||||
|
<RefreshCw className="mr-2 size-4" />刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -306,33 +364,63 @@ export default function AssistantKnowledgePage() {
|
|||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<Button size="sm" disabled={actions.uploadDocument.isPending} onClick={() => documentUpload.current?.click()}>
|
||||||
|
{actions.uploadDocument.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Upload className="mr-2 size-4" />}
|
||||||
|
上传文件生成 Wiki
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={actions.uploadWeKnoraPackage.isPending}
|
disabled={packageImportBusy}
|
||||||
onClick={() => upload.current?.click()}
|
onClick={() => upload.current?.click()}
|
||||||
>
|
>
|
||||||
<Upload className="mr-2 size-4" />
|
{packageImportBusy ? (
|
||||||
导入数据包
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
{packageImportBusy
|
||||||
|
? "数据包导入中"
|
||||||
|
: "导入 普通知识库数据包"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<input ref={documentUpload} type="file" multiple accept=".md,.markdown,.txt,.csv,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.odt,.ods,.odp" className="hidden"
|
||||||
|
onChange={async (event) => {
|
||||||
|
const files = Array.from(event.target.files || []);
|
||||||
|
event.target.value = "";
|
||||||
|
setTab("transfer");
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.size > 100 * 1024 * 1024) { toast.error(`${file.name} 超过 100 MB,请拆分后上传`); continue; }
|
||||||
|
try {
|
||||||
|
await actions.uploadDocument.mutateAsync({ file, assistantBaseId: baseId });
|
||||||
|
toast.success(`${file.name} 已上传,后台正在解析、生成 Wiki 并向量化`);
|
||||||
|
} catch (error) { toast.error(`${file.name}:${(error as Error).message}`); }
|
||||||
|
}
|
||||||
|
}} />
|
||||||
<input
|
<input
|
||||||
ref={upload}
|
ref={upload}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".jsonl,.ndjson"
|
accept=".jsonl,.ndjson"
|
||||||
|
disabled={packageImportBusy}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={async (e) => {
|
onChange={async (e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
setTab("transfer");
|
||||||
|
if (packageImportBusy) return;
|
||||||
try {
|
try {
|
||||||
await actions.uploadWeKnoraPackage.mutateAsync({
|
await actions.uploadWeKnoraPackage.mutateAsync({
|
||||||
file,
|
file,
|
||||||
assistantBaseId: baseId,
|
assistantBaseId: baseId,
|
||||||
});
|
});
|
||||||
toast.success("文件已上传,后台正在导入");
|
toast.success(
|
||||||
|
"数据包已上传并在后台导入,请在任务记录中查看进度,无需再次上传",
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error((err as Error).message);
|
toast.error((err as Error).message);
|
||||||
}
|
}
|
||||||
@@ -357,25 +445,96 @@ export default function AssistantKnowledgePage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
<TabsContent value="wiki" className="mt-0 flex min-h-0 flex-1">
|
<TabsContent value="wiki" className="mt-0 flex min-h-0 flex-1">
|
||||||
<WikiLibraryReader
|
{pages.isPending ? (
|
||||||
key={baseId}
|
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||||
pages={libraryPages}
|
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||||
activePage={active}
|
正在读取 Wiki 目录
|
||||||
onNavigate={navigatePage}
|
</div>
|
||||||
pageHref={(s) =>
|
) : pages.isError ? (
|
||||||
buildAppHashUrl(
|
<div className="flex flex-1 items-center justify-center px-6">
|
||||||
`/page/workspace/knowledge/assistant?${new URLSearchParams({ base_id: baseId, wikiSlug: s })}`,
|
<div className="max-w-lg rounded-2xl border border-destructive/30 bg-destructive/5 px-8 py-10 text-center">
|
||||||
)
|
<div className="font-medium text-destructive">Wiki 目录读取失败</div>
|
||||||
}
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
mappingId={
|
{pages.error instanceof Error ? pages.error.message : "请稍后重试"}
|
||||||
active?.origins?.find((o) => o.mapping_id)?.mapping_id
|
</p>
|
||||||
}
|
<Button className="mt-5" variant="outline" onClick={() => void pages.refetch()}>
|
||||||
initiallyCollapseTree
|
<RefreshCw className="mr-2 size-4" />
|
||||||
/>
|
重试读取
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : libraryPages.length === 0 ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center px-6">
|
||||||
|
<div className="w-full max-w-xl rounded-2xl border border-dashed bg-card px-8 py-10 text-center shadow-sm">
|
||||||
|
<div className="mx-auto flex size-12 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
|
||||||
|
<BookOpenText className="size-6" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 font-medium text-foreground">Wiki 管理已就绪</div>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||||
|
当前知识库还没有文件和 Wiki 页面。进入知识库不要求先上传文件;导入内容后,系统会在这里生成目录、页面和向量索引。
|
||||||
|
</p>
|
||||||
|
{isAdmin ? (
|
||||||
|
<div className="mt-6 flex flex-wrap justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
disabled={actions.uploadDocument.isPending}
|
||||||
|
onClick={() => documentUpload.current?.click()}
|
||||||
|
>
|
||||||
|
{actions.uploadDocument.isPending ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
上传文件生成 Wiki
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={packageImportBusy}
|
||||||
|
onClick={() => upload.current?.click()}
|
||||||
|
>
|
||||||
|
{packageImportBusy ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
导入普通知识库数据包
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-5 text-xs text-muted-foreground">
|
||||||
|
当前账号可正常进入和查看该知识库;请联系管理员导入首批内容。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<WikiLibraryReader
|
||||||
|
key={baseId}
|
||||||
|
pages={libraryPages}
|
||||||
|
activePage={active}
|
||||||
|
onNavigate={navigatePage}
|
||||||
|
pageHref={(s) =>
|
||||||
|
buildAppHashUrl(
|
||||||
|
`/page/workspace/knowledge/assistant?${new URLSearchParams({ base_id: baseId, wikiSlug: s })}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
mappingId={
|
||||||
|
active?.origins?.find((o) => o.mapping_id)?.mapping_id
|
||||||
|
}
|
||||||
|
initiallyCollapseTree
|
||||||
|
activePageLoading={
|
||||||
|
!!activeSlug && !activePage.data && activePage.isPending
|
||||||
|
}
|
||||||
|
activePageError={
|
||||||
|
activePage.data ? undefined : activePage.error?.message
|
||||||
|
}
|
||||||
|
onRetryActivePage={() => void activePage.refetch()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="search" className="overflow-auto p-6">
|
<TabsContent value="search" className="overflow-auto p-6">
|
||||||
<div className="mb-5 flex max-w-3xl gap-2">
|
<div className="mb-5 flex max-w-3xl gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
aria-label="检索助手知识库"
|
||||||
placeholder="用自然语言检索知识,例如:如何处理设备过热?"
|
placeholder="用自然语言检索知识,例如:如何处理设备过热?"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
@@ -383,7 +542,7 @@ export default function AssistantKnowledgePage() {
|
|||||||
if (e.key === "Enter") void search();
|
if (e.key === "Enter") void search();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button disabled={searching} onClick={() => void search()}>
|
<Button disabled={searching || !query.trim()} onClick={() => void search()}>
|
||||||
{searching ? (
|
{searching ? (
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
@@ -397,10 +556,18 @@ export default function AssistantKnowledgePage() {
|
|||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{searchResult.retrieval_mode === "vector"
|
{searchResult.retrieval_mode === "vector"
|
||||||
? "知识正文 / 片段 · 向量检索"
|
? "知识正文 / 片段 · 向量检索"
|
||||||
|
: searchResult.retrieval_mode === "overview"
|
||||||
|
? "知识正文 · 知识库概览"
|
||||||
: searchResult.retrieval_mode === "empty"
|
: searchResult.retrieval_mode === "empty"
|
||||||
? "没有匹配结果"
|
? "没有匹配结果"
|
||||||
: "知识正文 / 片段 · 关键词检索(暂无匹配模型的向量)"}
|
: "知识正文 / 片段 · 关键词检索"}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{searchResult.results.length === 0 && (
|
||||||
|
<div role="status" className="mt-4 rounded-xl border border-dashed p-5 text-sm text-muted-foreground">
|
||||||
|
<p>没有找到相关知识。试试更简短的关键词,或浏览 Wiki 目录确认资料是否已入库。</p>
|
||||||
|
<Button variant="outline" className="mt-3" onClick={() => setTab("wiki")}>浏览 Wiki 目录</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
{searchResult.results.map((p) => (
|
{searchResult.results.map((p) => (
|
||||||
<button
|
<button
|
||||||
@@ -441,7 +608,8 @@ export default function AssistantKnowledgePage() {
|
|||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
上次同步:{new Date(s.updated_at).toLocaleString()}
|
上次同步:{new Date(s.updated_at).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
{isAdmin && (
|
{s.source_type === "file" && <p className="mt-2 text-xs text-muted-foreground">本地上传文档 · 处理进度及重试请查看导入记录</p>}
|
||||||
|
{isAdmin && s.source_type === "weknora" && (
|
||||||
<Button
|
<Button
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -469,8 +637,10 @@ export default function AssistantKnowledgePage() {
|
|||||||
>
|
>
|
||||||
<WikiPackageTransfers id={baseId} assistant />
|
<WikiPackageTransfers id={baseId} assistant />
|
||||||
<h2 className="text-base font-semibold">导入与同步记录</h2>
|
<h2 className="text-base font-semibold">导入与同步记录</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">可直接上传文档,独立生成 Wiki 并进行向量检索,无需配置普通知识库服务。支持 PDF、Word、Excel、PowerPoint、Markdown、TXT、CSV,单文件最大 100 MB。生成失败时可配置语言模型或编码器后重试。</p>
|
||||||
{jobs.data?.map((j) => (
|
{jobs.data?.map((j) => (
|
||||||
<div key={j.id} className="rounded-xl border p-4">
|
<div key={j.id} className="rounded-xl border p-4">
|
||||||
|
<p className="mb-2 font-medium">{sources.data?.find((source) => source.id === j.source_id)?.source_name || (j.trigger === "local_file_upload" ? "上传文档" : "知识数据同步")}</p>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{{
|
{{
|
||||||
@@ -484,6 +654,9 @@ export default function AssistantKnowledgePage() {
|
|||||||
{new Date(j.created_at).toLocaleString()}
|
{new Date(j.created_at).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">{({ uploading: "接收文件", parsing: "解析文档", generating_wiki: "生成 Wiki", vectorizing: "正文向量化", importing: "保存知识并同步总库", queued: "等待处理", interrupted: "处理已中断" } as Record<string, string>)[j.phase] || j.phase}</p>
|
||||||
|
{j.counts?.parts_total > 0 && <p className="text-xs text-muted-foreground">文档分段:{j.counts.parts_done || 0} / {j.counts.parts_total}</p>}
|
||||||
|
{j.counts?.vectors_total > 0 && <p className="text-xs text-muted-foreground">向量化:{j.counts.vectors || 0} / {j.counts.vectors_total}</p>}
|
||||||
<p className="mt-3 text-sm">
|
<p className="mt-3 text-sm">
|
||||||
{j.counts?.wiki_pages || 0} Wiki ·{" "}
|
{j.counts?.wiki_pages || 0} Wiki ·{" "}
|
||||||
{j.counts?.vectors || 0} 向量 · {j.counts?.entities || 0}{" "}
|
{j.counts?.vectors || 0} 向量 · {j.counts?.entities || 0}{" "}
|
||||||
@@ -491,9 +664,15 @@ export default function AssistantKnowledgePage() {
|
|||||||
</p>
|
</p>
|
||||||
{j.last_error_message && (
|
{j.last_error_message && (
|
||||||
<p className="mt-2 text-sm text-destructive">
|
<p className="mt-2 text-sm text-destructive">
|
||||||
{j.last_error_message}
|
{userFacingKnowledgeMessage(j.last_error_message)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{j.trigger === "local_file_upload" && j.status !== "completed" && (
|
||||||
|
<Button className="mt-2" size="sm" variant="outline" disabled={actions.retryDocument.isPending} onClick={async () => {
|
||||||
|
try { await actions.retryDocument.mutateAsync(j.id); toast.success("已继续处理,复用已有解析和向量结果"); }
|
||||||
|
catch (error) { toast.error((error as Error).message); }
|
||||||
|
}}>重试 / 恢复处理</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { CompactWelcome } from "@/components/workspace/compact-welcome";
|
|||||||
import {
|
import {
|
||||||
buildFullChatNavigationPath,
|
buildFullChatNavigationPath,
|
||||||
isChatsIframeWorkspacePath,
|
isChatsIframeWorkspacePath,
|
||||||
|
WX_FULL_CHAT_PATH_PREFIX,
|
||||||
} from "@/core/page-layout/chats-iframe-route";
|
} from "@/core/page-layout/chats-iframe-route";
|
||||||
import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
|
import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
|
||||||
import { isWujieEnv } from "@/core/embed/wujie";
|
import { isWujieEnv } from "@/core/embed/wujie";
|
||||||
@@ -64,6 +65,10 @@ import {
|
|||||||
useThreadSettings,
|
useThreadSettings,
|
||||||
} from "@/core/settings";
|
} from "@/core/settings";
|
||||||
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
|
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
|
||||||
|
import {
|
||||||
|
myProjectConversationTypeFromSearchParams,
|
||||||
|
myProjectThreadMetadata,
|
||||||
|
} from "@/core/threads/project-metadata";
|
||||||
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
||||||
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
||||||
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
||||||
@@ -142,6 +147,7 @@ export default function ChatPage() {
|
|||||||
const [knowledgeRagEnabled] = useKnowledgeRagEnabled();
|
const [knowledgeRagEnabled] = useKnowledgeRagEnabled();
|
||||||
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
|
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
|
||||||
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>([]);
|
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>([]);
|
||||||
|
const [knowledgeSuggestedQuestions, setKnowledgeSuggestedQuestions] = useState<string[]>([]);
|
||||||
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(false);
|
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(false);
|
||||||
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
|
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
|
||||||
const knowledgeSelectionThread = useRef<string | null>(null);
|
const knowledgeSelectionThread = useRef<string | null>(null);
|
||||||
@@ -285,16 +291,28 @@ export default function ChatPage() {
|
|||||||
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
|
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
|
||||||
|
|
||||||
const { showNotification } = useNotification();
|
const { showNotification } = useNotification();
|
||||||
|
const projectConversationType =
|
||||||
|
myProjectConversationTypeFromSearchParams(searchParams);
|
||||||
|
const newThreadMetadata = useMemo<Record<string, unknown> | undefined>(() => {
|
||||||
|
const metadata: Record<string, unknown> = projectConversationType
|
||||||
|
? myProjectThreadMetadata(projectConversationType)
|
||||||
|
: {};
|
||||||
|
if (knowledgeSelectionReady) {
|
||||||
|
metadata.llmwiki_knowledge_base_ids = selectedKnowledgeBaseIds;
|
||||||
|
metadata.assistant_knowledge_base_ids = selectedAssistantKnowledgeBaseIds;
|
||||||
|
}
|
||||||
|
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
||||||
|
}, [
|
||||||
|
knowledgeSelectionReady,
|
||||||
|
projectConversationType,
|
||||||
|
selectedAssistantKnowledgeBaseIds,
|
||||||
|
selectedKnowledgeBaseIds,
|
||||||
|
]);
|
||||||
|
|
||||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||||
threadId: isNewThread ? undefined : threadId,
|
threadId: isNewThread ? undefined : threadId,
|
||||||
context: streamContext,
|
context: streamContext,
|
||||||
threadMetadata: knowledgeSelectionReady
|
threadMetadata: newThreadMetadata,
|
||||||
? {
|
|
||||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
|
||||||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
isMock,
|
isMock,
|
||||||
onStart: (createdThreadId) => {
|
onStart: (createdThreadId) => {
|
||||||
knowledgeSelectionThread.current = createdThreadId;
|
knowledgeSelectionThread.current = createdThreadId;
|
||||||
@@ -351,6 +369,33 @@ export default function ChatPage() {
|
|||||||
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
|
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
|
||||||
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
|
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
|
||||||
const frontendRagSelected = isFrontendRagProfile(settings.context);
|
const frontendRagSelected = isFrontendRagProfile(settings.context);
|
||||||
|
// 排查高并发时普通对话输入框意外禁用:流式运行本身不应成为禁用原因。
|
||||||
|
const inputDisabledReasons = useMemo(() => {
|
||||||
|
const reasons: string[] = [];
|
||||||
|
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") reasons.push("静态站点模式");
|
||||||
|
if (isUploading) reasons.push("正在上传附件");
|
||||||
|
if (agentHandoffPending) reasons.push("正在创建智能体会话");
|
||||||
|
if (knowledgeSelectionLoading) reasons.push("正在恢复会话知识库配置");
|
||||||
|
if (singleRoundRag.isLoading) reasons.push("单轮 RAG 执行中");
|
||||||
|
if (multiRoundRag.isLoading) reasons.push("多轮 RAG 执行中");
|
||||||
|
return reasons;
|
||||||
|
}, [
|
||||||
|
agentHandoffPending,
|
||||||
|
isUploading,
|
||||||
|
knowledgeSelectionLoading,
|
||||||
|
multiRoundRag.isLoading,
|
||||||
|
singleRoundRag.isLoading,
|
||||||
|
]);
|
||||||
|
const inputDisabled = inputDisabledReasons.length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.info("[普通对话输入框状态]", {
|
||||||
|
禁用: inputDisabled,
|
||||||
|
原因: inputDisabledReasons,
|
||||||
|
threadId: threadId ?? null,
|
||||||
|
新建对话: isNewThread,
|
||||||
|
});
|
||||||
|
}, [inputDisabled, inputDisabledReasons, isNewThread, threadId]);
|
||||||
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
|
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
|
||||||
// 流式 stub),成功且真实产物落地后自动撤出。
|
// 流式 stub),成功且真实产物落地后自动撤出。
|
||||||
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
|
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
|
||||||
@@ -405,7 +450,9 @@ export default function ChatPage() {
|
|||||||
try {
|
try {
|
||||||
let targetThreadId = threadId;
|
let targetThreadId = threadId;
|
||||||
if (isNewThread) {
|
if (isNewThread) {
|
||||||
const created = await getAPIClient(isMock).threads.create({});
|
const created = await getAPIClient(isMock).threads.create({
|
||||||
|
metadata: newThreadMetadata ?? {},
|
||||||
|
});
|
||||||
targetThreadId = created.thread_id;
|
targetThreadId = created.thread_id;
|
||||||
setThreadId(targetThreadId);
|
setThreadId(targetThreadId);
|
||||||
setIsNewThread(false);
|
setIsNewThread(false);
|
||||||
@@ -465,13 +512,8 @@ export default function ChatPage() {
|
|||||||
try {
|
try {
|
||||||
const created = await getAPIClient(isMock).threads.create({
|
const created = await getAPIClient(isMock).threads.create({
|
||||||
metadata: {
|
metadata: {
|
||||||
|
...newThreadMetadata,
|
||||||
agent_id: selectedAgentId,
|
agent_id: selectedAgentId,
|
||||||
...(knowledgeSelectionReady
|
|
||||||
? {
|
|
||||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
|
||||||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
@@ -490,7 +532,7 @@ export default function ChatPage() {
|
|||||||
);
|
);
|
||||||
saveLastAgentId(selectedAgentId);
|
saveLastAgentId(selectedAgentId);
|
||||||
router.push(
|
router.push(
|
||||||
`/page/workspace/agents/${selectedAgentId}/chats/${created.thread_id}`,
|
`${routeBase}/agents/${encodeURIComponent(selectedAgentId)}/chats/${encodeURIComponent(created.thread_id)}`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
agentHandoffPendingRef.current = false;
|
agentHandoffPendingRef.current = false;
|
||||||
@@ -549,6 +591,7 @@ export default function ChatPage() {
|
|||||||
selectedAssistantKnowledgeBaseIds,
|
selectedAssistantKnowledgeBaseIds,
|
||||||
selectedKnowledgeBaseIds,
|
selectedKnowledgeBaseIds,
|
||||||
knowledgeSelectionReady,
|
knowledgeSelectionReady,
|
||||||
|
newThreadMetadata,
|
||||||
agentHandoffPending,
|
agentHandoffPending,
|
||||||
isMock,
|
isMock,
|
||||||
router,
|
router,
|
||||||
@@ -697,7 +740,9 @@ export default function ChatPage() {
|
|||||||
// 用 useLayoutMode 的有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认),
|
// 用 useLayoutMode 的有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认),
|
||||||
// 与 PageRoutes 的 chrome 判定同源。非紧凑(嵌入/standalone)保持原样。
|
// 与 PageRoutes 的 chrome 判定同源。非紧凑(嵌入/standalone)保持原样。
|
||||||
const chromeByPreference =
|
const chromeByPreference =
|
||||||
fullChatMode || effectiveLayoutMode === "full" ? true : false;
|
fullChatMode ||
|
||||||
|
pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX) ||
|
||||||
|
effectiveLayoutMode === "full";
|
||||||
const compactChrome =
|
const compactChrome =
|
||||||
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
|
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
|
||||||
const compactLanding = isLandingState && compactChrome;
|
const compactLanding = isLandingState && compactChrome;
|
||||||
@@ -908,14 +953,7 @@ export default function ChatPage() {
|
|||||||
}
|
}
|
||||||
context={settings.context}
|
context={settings.context}
|
||||||
extraModels={SINGLE_ROUND_RAG_MODELS}
|
extraModels={SINGLE_ROUND_RAG_MODELS}
|
||||||
disabled={
|
disabled={inputDisabled}
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
|
||||||
isUploading ||
|
|
||||||
agentHandoffPending ||
|
|
||||||
knowledgeSelectionLoading ||
|
|
||||||
singleRoundRag.isLoading ||
|
|
||||||
multiRoundRag.isLoading
|
|
||||||
}
|
|
||||||
onContextChange={(context) =>
|
onContextChange={(context) =>
|
||||||
setSettings("context", context)
|
setSettings("context", context)
|
||||||
}
|
}
|
||||||
@@ -936,6 +974,7 @@ export default function ChatPage() {
|
|||||||
assistantValue={selectedAssistantKnowledgeBaseIds}
|
assistantValue={selectedAssistantKnowledgeBaseIds}
|
||||||
onChange={changeKnowledgeSelection}
|
onChange={changeKnowledgeSelection}
|
||||||
onAssistantChange={changeAssistantKnowledgeSelection}
|
onAssistantChange={changeAssistantKnowledgeSelection}
|
||||||
|
onSuggestedQuestionsChange={setKnowledgeSuggestedQuestions}
|
||||||
autoSelectAll={isLandingState && !!selectedAgentId}
|
autoSelectAll={isLandingState && !!selectedAgentId}
|
||||||
disabled={
|
disabled={
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||||
@@ -945,6 +984,7 @@ export default function ChatPage() {
|
|||||||
label="知识空间"
|
label="知识空间"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
knowledgeSuggestedQuestions={knowledgeSuggestedQuestions}
|
||||||
onFollowupsVisibilityChange={setShowFollowups}
|
onFollowupsVisibilityChange={setShowFollowups}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onStop={handleStop}
|
onStop={handleStop}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||||
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -211,7 +212,7 @@ export default function EnterpriseResearchPage() {
|
|||||||
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
|
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
|
||||||
}
|
}
|
||||||
if (event.type === "completed") toast.success("企业研究报告已完成");
|
if (event.type === "completed") toast.success("企业研究报告已完成");
|
||||||
if (event.type === "failed") toast.error(event.job.error_message || "报告生成失败");
|
if (event.type === "failed") toast.error(userFacingKnowledgeMessage(event.job.error_message || "报告生成失败"));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}).catch((error: unknown) => {
|
}).catch((error: unknown) => {
|
||||||
@@ -290,7 +291,7 @@ export default function EnterpriseResearchPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isWeKnoraReady) {
|
if (!isWeKnoraReady) {
|
||||||
toast.error("知识服务尚未启用,请先在系统中配置 WeKnora");
|
toast.error("知识服务尚未启用,请先在系统中配置知识服务");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,7 +443,7 @@ export default function EnterpriseResearchPage() {
|
|||||||
<section>
|
<section>
|
||||||
<Badge variant="secondary" className="mb-3"><Sparkles className="size-3" />阶段一:证据编排</Badge>
|
<Badge variant="secondary" className="mb-3"><Sparkles className="size-3" />阶段一:证据编排</Badge>
|
||||||
<h2 className="text-xl font-semibold tracking-tight">建立一个可追溯的研究任务</h2>
|
<h2 className="text-xl font-semibold tracking-tight">建立一个可追溯的研究任务</h2>
|
||||||
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 WeKnora 知识库,不会修改任何知识库或既有报告。</p>
|
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 普通知识库,不会修改任何知识库或既有报告。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -609,12 +610,12 @@ function TaskOverview({
|
|||||||
<CardContent><ol className="space-y-4">{template.stages.map((stage, index) => {
|
<CardContent><ol className="space-y-4">{template.stages.map((stage, index) => {
|
||||||
const complete = task.status !== "collecting" && (index < 2 || task.sources.length > 0);
|
const complete = task.status !== "collecting" && (index < 2 || task.sources.length > 0);
|
||||||
const current = task.status === "collecting" && index === 1;
|
const current = task.status === "collecting" && index === 1;
|
||||||
return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用 WeKnora 检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>;
|
return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用知识服务检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>;
|
||||||
})}</ol></CardContent>
|
})}</ol></CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 WeKnora 知识库。</CardDescription></CardHeader>
|
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 普通知识库。</CardDescription></CardHeader>
|
||||||
<CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{task.warning}</div> : null}</CardContent>
|
<CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{userFacingKnowledgeMessage(task.warning)}</div> : null}</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{canPlan ? <Card>
|
{canPlan ? <Card>
|
||||||
<CardHeader className="gap-1"><CardTitle className="text-base">研究计划与选用资料</CardTitle><CardDescription>计划和资料选择会保存到当前任务;后续报告只能使用这里确认过的证据。</CardDescription></CardHeader>
|
<CardHeader className="gap-1"><CardTitle className="text-base">研究计划与选用资料</CardTitle><CardDescription>计划和资料选择会保存到当前任务;后续报告只能使用这里确认过的证据。</CardDescription></CardHeader>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
BookMarkedIcon,
|
BookMarkedIcon,
|
||||||
BrainCircuitIcon,
|
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
ShrinkIcon,
|
ShrinkIcon,
|
||||||
@@ -400,13 +399,6 @@ export default function IframeChatPage() {
|
|||||||
});
|
});
|
||||||
}, [setSettings, settings.context.writing_mode]);
|
}, [setSettings, settings.context.writing_mode]);
|
||||||
|
|
||||||
const toggleMemoryInjection = useCallback(() => {
|
|
||||||
setSettings("context", {
|
|
||||||
memory_injection_enabled:
|
|
||||||
!(settings.context.memory_injection_enabled ?? true),
|
|
||||||
});
|
|
||||||
}, [setSettings, settings.context.memory_injection_enabled]);
|
|
||||||
|
|
||||||
const toggleRagMode = useCallback(() => {
|
const toggleRagMode = useCallback(() => {
|
||||||
if (ragModeLocked) {
|
if (ragModeLocked) {
|
||||||
return;
|
return;
|
||||||
@@ -531,9 +523,6 @@ export default function IframeChatPage() {
|
|||||||
const modeButtons: ResolvedIframeModeButton[] = buildIframeModeButtons({
|
const modeButtons: ResolvedIframeModeButton[] = buildIframeModeButtons({
|
||||||
agentSelected: !!selectedAgentId,
|
agentSelected: !!selectedAgentId,
|
||||||
writingModeEnabled: !!settings.context.writing_mode,
|
writingModeEnabled: !!settings.context.writing_mode,
|
||||||
memoryInjectionEnabled: Boolean(
|
|
||||||
settings.context.memory_injection_enabled ?? true,
|
|
||||||
),
|
|
||||||
referenceModeEntryEnabled,
|
referenceModeEntryEnabled,
|
||||||
ragModeEnabled,
|
ragModeEnabled,
|
||||||
ragModeLocked,
|
ragModeLocked,
|
||||||
@@ -555,13 +544,6 @@ export default function IframeChatPage() {
|
|||||||
onClick: toggleWritingMode,
|
onClick: toggleWritingMode,
|
||||||
activeClassName: "text-amber-600 hover:text-amber-700",
|
activeClassName: "text-amber-600 hover:text-amber-700",
|
||||||
};
|
};
|
||||||
case "memory":
|
|
||||||
return {
|
|
||||||
...button,
|
|
||||||
icon: BrainCircuitIcon,
|
|
||||||
onClick: toggleMemoryInjection,
|
|
||||||
activeClassName: "text-blue-600 hover:text-blue-700",
|
|
||||||
};
|
|
||||||
case "references":
|
case "references":
|
||||||
return {
|
return {
|
||||||
...button,
|
...button,
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export default function LlmWikiKnowledgePage({
|
|||||||
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
|
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runtime.isError || runtime.data?.provider !== "weknora") {
|
if (runtime.isError || runtime.data?.provider !== "weknora" || !runtime.data?.api_base_url?.trim()) {
|
||||||
if (mode === "list" || mode === "detail") {
|
if (mode === "list" || mode === "detail") {
|
||||||
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
|
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Fragment, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
import { Copy, Info, KeyRound, Link2, Search, ShieldCheck, UserCheck, X } from "lucide-react";
|
import { Copy, Info, KeyRound, Link2, Search, ShieldCheck, UserCheck, X } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -201,9 +201,8 @@ export default function LoginWhitelistPage() {
|
|||||||
const allowed = user.approved || isUserAdmin;
|
const allowed = user.approved || isUserAdmin;
|
||||||
const hasPwd = Boolean(user.login_password);
|
const hasPwd = Boolean(user.login_password);
|
||||||
const isEditing = editingId === user.id;
|
const isEditing = editingId === user.id;
|
||||||
return (
|
return [
|
||||||
<Fragment key={user.id}>
|
<li key={`${user.id}-summary`} className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-3 px-4 py-3 transition-colors hover:bg-slate-50/60">
|
||||||
<li className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-3 px-4 py-3 transition-colors hover:bg-slate-50/60">
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate text-sm font-medium text-slate-800">{user.email}</span>
|
<span className="truncate text-sm font-medium text-slate-800">{user.email}</span>
|
||||||
@@ -260,10 +259,10 @@ export default function LoginWhitelistPage() {
|
|||||||
onCheckedChange={(v) => handleToggleApproval(user.id, v)}
|
onCheckedChange={(v) => handleToggleApproval(user.id, v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>,
|
||||||
|
|
||||||
{isEditing && (
|
isEditing && (
|
||||||
<li className="bg-slate-50/70 px-4 py-4">
|
<li key={`${user.id}-password`} className="bg-slate-50/70 px-4 py-4">
|
||||||
<div className="rounded-xl border border-slate-200/70 bg-white p-4 shadow-sm">
|
<div className="rounded-xl border border-slate-200/70 bg-white p-4 shadow-sm">
|
||||||
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-600">
|
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-600">
|
||||||
<KeyRound className="size-3.5 text-slate-400" />
|
<KeyRound className="size-3.5 text-slate-400" />
|
||||||
@@ -328,9 +327,8 @@ export default function LoginWhitelistPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)
|
||||||
</Fragment>
|
];
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ const emptyDraft = (): ModelDraft => ({
|
|||||||
base_url: "",
|
base_url: "",
|
||||||
request_timeout: 30,
|
request_timeout: 30,
|
||||||
max_retries: 2,
|
max_retries: 2,
|
||||||
|
max_input_tokens: undefined,
|
||||||
max_tokens: undefined,
|
max_tokens: undefined,
|
||||||
temperature: undefined,
|
temperature: undefined,
|
||||||
stream_usage: true,
|
stream_usage: true,
|
||||||
@@ -143,6 +144,7 @@ function buildPayload(draft: ModelDraft): ModelSavePayload {
|
|||||||
base_url: optionalString(draft.base_url),
|
base_url: optionalString(draft.base_url),
|
||||||
request_timeout: toNumber(draft.request_timeout),
|
request_timeout: toNumber(draft.request_timeout),
|
||||||
max_retries: toNumber(draft.max_retries),
|
max_retries: toNumber(draft.max_retries),
|
||||||
|
max_input_tokens: toNumber(draft.max_input_tokens),
|
||||||
max_tokens: toNumber(draft.max_tokens),
|
max_tokens: toNumber(draft.max_tokens),
|
||||||
temperature: toNumber(draft.temperature),
|
temperature: toNumber(draft.temperature),
|
||||||
stream_usage: draft.stream_usage,
|
stream_usage: draft.stream_usage,
|
||||||
@@ -379,7 +381,7 @@ function ModelEditor({ model, onClose }: { model: ManagedModel | null; onClose:
|
|||||||
</section>
|
</section>
|
||||||
<div className="flex items-center justify-between border-t pt-4"><TButton theme="default" variant="outline" loading={test.isPending} onClick={() => void handleTest()}><WrenchIcon className="mr-1.5 size-4" />测试连接</TButton><TButton theme="default" variant="text" onClick={() => setAdvanced((value) => !value)}><Settings2Icon className="mr-1.5 size-4" />高级配置 <ChevronDownIcon className={`ml-1 size-4 transition-transform ${advanced ? "rotate-180" : ""}`} /></TButton></div>
|
<div className="flex items-center justify-between border-t pt-4"><TButton theme="default" variant="outline" loading={test.isPending} onClick={() => void handleTest()}><WrenchIcon className="mr-1.5 size-4" />测试连接</TButton><TButton theme="default" variant="text" onClick={() => setAdvanced((value) => !value)}><Settings2Icon className="mr-1.5 size-4" />高级配置 <ChevronDownIcon className={`ml-1 size-4 transition-transform ${advanced ? "rotate-180" : ""}`} /></TButton></div>
|
||||||
{testResult && <div className={`rounded-lg border px-3 py-2 text-sm ${testResult.success ? "border-primary/20 bg-primary/5 text-foreground" : "border-destructive/25 bg-destructive/5 text-destructive"}`}><div className="font-medium">{testResult.success ? "连接成功" : "连接失败"}{testResult.success && testResult.latency ? ` · ${testResult.latency} ms` : ""}</div><p className="mt-1 break-words text-xs opacity-80">{testResult.preview || testResult.message}</p></div>}
|
{testResult && <div className={`rounded-lg border px-3 py-2 text-sm ${testResult.success ? "border-primary/20 bg-primary/5 text-foreground" : "border-destructive/25 bg-destructive/5 text-destructive"}`}><div className="font-medium">{testResult.success ? "连接成功" : "连接失败"}{testResult.success && testResult.latency ? ` · ${testResult.latency} ms` : ""}</div><p className="mt-1 break-words text-xs opacity-80">{testResult.preview || testResult.message}</p></div>}
|
||||||
{advanced && <section className="space-y-4 rounded-lg border border-dashed border-border bg-muted/10 p-4"><div className="grid gap-4 md:grid-cols-3"><Field label="请求超时(秒)"><InputNumber value={draft.request_timeout ?? ""} min={1} max={600} onChange={(value) => patch({ request_timeout: toNumber(value) })} /></Field><Field label="最大重试次数"><InputNumber value={draft.max_retries ?? ""} min={0} max={20} onChange={(value) => patch({ max_retries: toNumber(value) })} /></Field><Field label="最大输出 Token"><InputNumber value={draft.max_tokens ?? ""} min={1} max={1_000_000} onChange={(value) => patch({ max_tokens: toNumber(value) })} /></Field><Field label="温度"><InputNumber value={draft.temperature ?? ""} min={0} max={2} step={0.1} decimalPlaces={1} onChange={(value) => patch({ temperature: toNumber(value) })} /></Field><span className="flex items-end gap-2 pb-1 text-sm font-medium"><Switch value={draft.stream_usage ?? true} onChange={(value) => patch({ stream_usage: Boolean(value) })} />流式 Token 统计</span></div><Field label="额外参数 JSON"><Textarea value={draft.extra_settings_text} autosize={{ minRows: 4, maxRows: 10 }} placeholder={'{\n "extra_body": {}\n}'} onChange={(value) => patch({ extra_settings_text: String(value) })} /><p className="text-xs font-normal text-muted-foreground">保留并编辑配置文件中未在基础表单展示的兼容参数。必须为 JSON 对象。</p></Field></section>}
|
{advanced && <section className="space-y-4 rounded-lg border border-dashed border-border bg-muted/10 p-4"><div className="grid gap-4 md:grid-cols-3"><Field label="请求超时(秒)"><InputNumber value={draft.request_timeout ?? ""} min={1} max={600} onChange={(value) => patch({ request_timeout: toNumber(value) })} /></Field><Field label="最大重试次数"><InputNumber value={draft.max_retries ?? ""} min={0} max={20} onChange={(value) => patch({ max_retries: toNumber(value) })} /></Field><Field label="最大输入 Token"><InputNumber value={draft.max_input_tokens ?? ""} min={1} max={10_000_000} placeholder="可不填,不设默认上限" onChange={(value) => patch({ max_input_tokens: toNumber(value) })} /></Field><Field label="最大输出 Token"><InputNumber value={draft.max_tokens ?? ""} min={1} max={1_000_000} placeholder="单次回复上限" onChange={(value) => patch({ max_tokens: toNumber(value) })} /></Field><Field label="温度"><InputNumber value={draft.temperature ?? ""} min={0} max={2} step={0.1} decimalPlaces={1} onChange={(value) => patch({ temperature: toNumber(value) })} /></Field><span className="flex items-end gap-2 pb-1 text-sm font-medium"><Switch value={draft.stream_usage ?? true} onChange={(value) => patch({ stream_usage: Boolean(value) })} />流式 Token 统计</span></div><p className="text-xs text-muted-foreground">最大输入 Token 可选填,不设默认值;填写后提前裁剪;模型返回上下文超限时自动缩短重试。最大输出 Token 限制单次回复长度。</p><Field label="额外参数 JSON"><Textarea value={draft.extra_settings_text} autosize={{ minRows: 4, maxRows: 10 }} placeholder={'{\n "extra_body": {}\n}'} onChange={(value) => patch({ extra_settings_text: String(value) })} /><p className="text-xs font-normal text-muted-foreground">保留并编辑配置文件中未在基础表单展示的兼容参数。必须为 JSON 对象。</p></Field></section>}
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
Clock3,
|
Clock3,
|
||||||
|
FolderKanban,
|
||||||
|
Globe,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
@@ -19,8 +21,15 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type FormEvent, useCallback, useMemo, useState } from "react";
|
import { type FormEvent, useCallback, useMemo, useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import AnalysisProjectsPage from "./AnalysisProjectsPage";
|
||||||
|
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MyProjectThreadBadge,
|
||||||
|
MyProjectThreadIcon,
|
||||||
|
} from "@/components/workspace/my-project-thread-indicator";
|
||||||
import { useAgent, useAgents } from "@/core/agents";
|
import { useAgent, useAgents } from "@/core/agents";
|
||||||
import { getAccountDisplayName } from "@/core/auth";
|
import { getAccountDisplayName } from "@/core/auth";
|
||||||
import {
|
import {
|
||||||
@@ -29,28 +38,44 @@ import {
|
|||||||
useScheduledTaskRuns,
|
useScheduledTaskRuns,
|
||||||
useScheduledTasks,
|
useScheduledTasks,
|
||||||
} from "@/core/scheduled-tasks";
|
} from "@/core/scheduled-tasks";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
|
||||||
import {
|
import {
|
||||||
useAppearanceSettings,
|
useAppearanceSettings,
|
||||||
useCitationDisplaySettings,
|
useCitationDisplaySettings,
|
||||||
} from "@/core/system-settings/hooks";
|
} from "@/core/system-settings/hooks";
|
||||||
import { useThreadStream, useThreads } from "@/core/threads/hooks";
|
import { useThreadStream, useThreads } from "@/core/threads/hooks";
|
||||||
|
import {
|
||||||
|
myProjectConversationTypeOfThread,
|
||||||
|
myProjectConversationTypeFromSearchParams,
|
||||||
|
myProjectThreadMetadata,
|
||||||
|
} from "@/core/threads/project-metadata";
|
||||||
import { textOfMessage, titleOfThread } from "@/core/threads/utils";
|
import { textOfMessage, titleOfThread } from "@/core/threads/utils";
|
||||||
import { uuid } from "@/core/utils/uuid";
|
import { uuid } from "@/core/utils/uuid";
|
||||||
import {
|
import {
|
||||||
OFFICIAL_DEERFLOW_HOME_ROUTE,
|
OFFICIAL_DEERFLOW_HOME_ROUTE,
|
||||||
OFFICIAL_DEERFLOW_ROUTE_PREFIX,
|
OFFICIAL_DEERFLOW_ROUTE_PREFIX,
|
||||||
} from "@/core/official-deerflow/routes";
|
} from "@/core/official-deerflow/routes";
|
||||||
|
import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
|
||||||
|
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
|
||||||
|
import OfficialWebAppsPage from "@/pages/OfficialWebAppsPage";
|
||||||
|
|
||||||
import "./official-deerflow-entry.css";
|
import "./official-deerflow-entry.css";
|
||||||
|
|
||||||
type OfficialPage = "chat" | "agents" | "agent-chat" | "scheduled" | "settings";
|
type OfficialPage =
|
||||||
|
| "chat"
|
||||||
|
| "projects"
|
||||||
|
| "web-apps"
|
||||||
|
| "agents"
|
||||||
|
| "agent-chat"
|
||||||
|
| "scheduled"
|
||||||
|
| "settings";
|
||||||
|
|
||||||
function pageFromPath(pathname: string): OfficialPage {
|
function pageFromPath(pathname: string): OfficialPage {
|
||||||
const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length);
|
const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length);
|
||||||
if (path.startsWith("/workspace/agents/") && path.includes("/chats")) {
|
if (path.startsWith("/workspace/agents/") && path.includes("/chats")) {
|
||||||
return "agent-chat";
|
return "agent-chat";
|
||||||
}
|
}
|
||||||
|
if (path.startsWith("/workspace/projects")) return "projects";
|
||||||
|
if (path.startsWith("/workspace/web-app")) return "web-apps";
|
||||||
if (path.startsWith("/workspace/agents")) return "agents";
|
if (path.startsWith("/workspace/agents")) return "agents";
|
||||||
if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled";
|
if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled";
|
||||||
if (path.startsWith("/workspace/settings")) return "settings";
|
if (path.startsWith("/workspace/settings")) return "settings";
|
||||||
@@ -78,6 +103,11 @@ function parseScheduledTaskId(pathname: string) {
|
|||||||
return match ? decodeURIComponent(match[1]!) : null;
|
return match ? decodeURIComponent(match[1]!) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseProjectId(pathname: string) {
|
||||||
|
const match = pathname.match(/\/workspace\/projects\/([^/]+)/);
|
||||||
|
return match ? decodeURIComponent(match[1]!) : null;
|
||||||
|
}
|
||||||
|
|
||||||
function readableError(error: unknown, fallback: string) {
|
function readableError(error: unknown, fallback: string) {
|
||||||
return error instanceof Error && error.message ? error.message : fallback;
|
return error instanceof Error && error.message ? error.message : fallback;
|
||||||
}
|
}
|
||||||
@@ -159,20 +189,36 @@ function ChatWorkspace({
|
|||||||
routeThreadId: string;
|
routeThreadId: string;
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [sendError, setSendError] = useState<string | null>(null);
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const isNewThread = routeThreadId === "new";
|
const isNewThread = routeThreadId === "new";
|
||||||
|
const projectConversationType = isNewThread
|
||||||
|
? myProjectConversationTypeFromSearchParams(
|
||||||
|
new URLSearchParams(location.search),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
// A new conversation uses the same client-generated id approach as the
|
// A new conversation uses the same client-generated id approach as the
|
||||||
// existing ChatPage. The backend owns the persisted id once streaming starts.
|
// existing ChatPage. The backend owns the persisted id once streaming starts.
|
||||||
const [draftThreadId] = useState<string>(() => uuid() as string);
|
const [draftThreadId] = useState<string>(() => uuid() as string);
|
||||||
const activeThreadId = isNewThread ? draftThreadId : routeThreadId;
|
const activeThreadId = isNewThread ? draftThreadId : routeThreadId;
|
||||||
const { agent } = useAgent(agentId, { enabled: !!agentId });
|
const { agent } = useAgent(agentId, { enabled: !!agentId });
|
||||||
const [localSettings] = useLocalSettings();
|
|
||||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||||
threadId: isNewThread ? undefined : routeThreadId,
|
threadId: isNewThread ? undefined : routeThreadId,
|
||||||
assistantId: agentId ?? undefined,
|
assistantId: agentId ?? undefined,
|
||||||
|
threadMetadata: projectConversationType
|
||||||
|
? myProjectThreadMetadata(projectConversationType)
|
||||||
|
: undefined,
|
||||||
context: {
|
context: {
|
||||||
...localSettings.context,
|
// This compact official shell has no model/mode controls of its own.
|
||||||
|
// Do not inherit a hidden selection saved by the customized workspace
|
||||||
|
// (for example an obsolete or quota-exhausted model); an omitted model
|
||||||
|
// lets the backend resolve the current system default or the agent's
|
||||||
|
// globally configured model.
|
||||||
|
model_name: undefined,
|
||||||
|
mode: undefined,
|
||||||
|
reasoning_effort: undefined,
|
||||||
|
writing_mode: false,
|
||||||
...(agentId ? { agent_id: agentId } : {}),
|
...(agentId ? { agent_id: agentId } : {}),
|
||||||
},
|
},
|
||||||
onStart: (createdThreadId) => {
|
onStart: (createdThreadId) => {
|
||||||
@@ -658,6 +704,10 @@ export default function OfficialDeerFlowEntry() {
|
|||||||
() => parseScheduledTaskId(location.pathname),
|
() => parseScheduledTaskId(location.pathname),
|
||||||
[location.pathname],
|
[location.pathname],
|
||||||
);
|
);
|
||||||
|
const projectId = useMemo(
|
||||||
|
() => parseProjectId(location.pathname),
|
||||||
|
[location.pathname],
|
||||||
|
);
|
||||||
const { data: threads = [], isLoading: threadsLoading, error: threadsError } =
|
const { data: threads = [], isLoading: threadsLoading, error: threadsError } =
|
||||||
useThreads({
|
useThreads({
|
||||||
limit: 20,
|
limit: 20,
|
||||||
@@ -666,10 +716,13 @@ export default function OfficialDeerFlowEntry() {
|
|||||||
select: ["thread_id", "updated_at", "values", "metadata"],
|
select: ["thread_id", "updated_at", "values", "metadata"],
|
||||||
});
|
});
|
||||||
const go = (path: string) => navigate(path);
|
const go = (path: string) => navigate(path);
|
||||||
|
const projectBasePath = `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/projects`;
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, pages: ["chat"] },
|
{ icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, pages: ["chat"] },
|
||||||
{ icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] },
|
{ icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] },
|
||||||
|
{ icon: FolderKanban, label: "我的项目", path: projectBasePath, pages: ["projects"] },
|
||||||
|
{ icon: Globe, label: "Web 应用", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/web-app`, pages: ["web-apps"] },
|
||||||
{ icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] },
|
{ icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] },
|
||||||
{ icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] },
|
{ icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -706,29 +759,48 @@ export default function OfficialDeerFlowEntry() {
|
|||||||
) : threadsError ? (
|
) : threadsError ? (
|
||||||
<p className="official-sidebar-error">对话列表加载失败</p>
|
<p className="official-sidebar-error">对话列表加载失败</p>
|
||||||
) : threads.length ? (
|
) : threads.length ? (
|
||||||
threads.map((chat) => (
|
threads.map((chat) => {
|
||||||
<button
|
const projectType = myProjectConversationTypeOfThread(chat);
|
||||||
className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`}
|
return (
|
||||||
key={chat.thread_id}
|
<button
|
||||||
onClick={() => {
|
className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`}
|
||||||
const agentId = typeof chat.context?.agent_id === "string"
|
key={chat.thread_id}
|
||||||
? chat.context.agent_id
|
onClick={() => {
|
||||||
: typeof chat.metadata?.agent_id === "string"
|
const projectId = chat.metadata?.analysis_project_id;
|
||||||
? chat.metadata.agent_id
|
if (typeof projectId === "string" && projectId) {
|
||||||
: null;
|
go(`${projectBasePath}/groups/${encodeURIComponent(projectId)}/chats/${encodeURIComponent(chat.thread_id)}`);
|
||||||
go(
|
return;
|
||||||
agentId
|
}
|
||||||
? `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/${encodeURIComponent(chat.thread_id)}`
|
const agentId = typeof chat.context?.agent_id === "string"
|
||||||
: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(chat.thread_id)}`,
|
? chat.context.agent_id
|
||||||
);
|
: typeof chat.metadata?.agent_id === "string"
|
||||||
}}
|
? chat.metadata.agent_id
|
||||||
type="button"
|
: null;
|
||||||
>
|
go(
|
||||||
<MessageSquareText aria-hidden="true" size={15} />
|
agentId
|
||||||
<span>{titleOfThread(chat, "未命名对话")}</span>
|
? `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/${encodeURIComponent(chat.thread_id)}`
|
||||||
<small>{formatTimestamp(chat.updated_at)}</small>
|
: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(chat.thread_id)}`,
|
||||||
</button>
|
);
|
||||||
))
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{projectType ? (
|
||||||
|
<MyProjectThreadIcon className="size-[15px]" type={projectType} />
|
||||||
|
) : (
|
||||||
|
<MessageSquareText aria-hidden="true" size={15} />
|
||||||
|
)}
|
||||||
|
<span className="official-recent-chat-copy">
|
||||||
|
<span className="official-recent-chat-title">
|
||||||
|
{titleOfThread(chat, "未命名对话")}
|
||||||
|
</span>
|
||||||
|
{projectType && (
|
||||||
|
<MyProjectThreadBadge type={projectType} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<small>{formatTimestamp(chat.updated_at)}</small>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
<p className="official-sidebar-empty">暂无历史对话</p>
|
<p className="official-sidebar-empty">暂无历史对话</p>
|
||||||
)}
|
)}
|
||||||
@@ -754,6 +826,24 @@ export default function OfficialDeerFlowEntry() {
|
|||||||
routeThreadId={chatRoute.threadId}
|
routeThreadId={chatRoute.threadId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{page === "projects" && (
|
||||||
|
(location.pathname === `${projectBasePath}/groups` || location.pathname.startsWith(`${projectBasePath}/groups/`)) ? (
|
||||||
|
<SidebarProvider className="h-full min-h-0">
|
||||||
|
<Routes>
|
||||||
|
<Route path="workspace/projects/groups/*" element={<AnalysisProjectsPage basePath={`${projectBasePath}/groups`} listPath={`${projectBasePath}?type=qa`} mode="qa" showSidebarTrigger={false} />} />
|
||||||
|
</Routes>
|
||||||
|
</SidebarProvider>
|
||||||
|
) : projectId ? (
|
||||||
|
<StrategySelectionPage
|
||||||
|
key={projectId}
|
||||||
|
projectBasePath={projectBasePath}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StrategyProjectsPage basePath={projectBasePath} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{page === "web-apps" && <OfficialWebAppsPage />}
|
||||||
{page === "agents" && (
|
{page === "agents" && (
|
||||||
<AgentsPage
|
<AgentsPage
|
||||||
openAgent={(agentId) =>
|
openAgent={(agentId) =>
|
||||||
@@ -771,6 +861,7 @@ export default function OfficialDeerFlowEntry() {
|
|||||||
{page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />}
|
{page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />}
|
||||||
{page === "settings" && <SettingsPage />}
|
{page === "settings" && <SettingsPage />}
|
||||||
</div>
|
</div>
|
||||||
|
<Toaster position="top-center" />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* official 工作区的「Web 应用」入口页:跨对话列出当前用户生成的
|
||||||
|
* Web App 站点,点击进入站点预览工作室(#/sites/:threadId/:siteName)。
|
||||||
|
*
|
||||||
|
* capability 不可用(未启用 / 模板依赖未预置)时展示设计过的空状态,
|
||||||
|
* 不影响该工作区的其它页面。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Bot, Globe, LoaderCircle, MessageSquareText, RefreshCw } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
OFFICIAL_DEERFLOW_ROUTE_PREFIX,
|
||||||
|
} from "@/core/official-deerflow/routes";
|
||||||
|
import { siteStudioRoute, useAllSites, type SiteGalleryItem } from "@/core/sites/api";
|
||||||
|
|
||||||
|
const AVATAR_TONES = ["violet", "sky", "amber"] as const;
|
||||||
|
|
||||||
|
export default function OfficialWebAppsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, isLoading, error, refetch, isRefetching } = useAllSites();
|
||||||
|
const available = data?.available === true;
|
||||||
|
const sites = data?.sites ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="official-content-page">
|
||||||
|
<header className="official-page-header">
|
||||||
|
<div>
|
||||||
|
<p className="official-eyebrow">工作区</p>
|
||||||
|
<h1>Web 应用</h1>
|
||||||
|
<p>对话中生成的多页面站点(含流程图 / 地图 / 图表)会集中出现在这里。</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="official-outline-button"
|
||||||
|
disabled={isRefetching}
|
||||||
|
onClick={() => void refetch()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isRefetching ? <LoaderCircle aria-hidden="true" size={17} /> : <RefreshCw aria-hidden="true" size={17} />}
|
||||||
|
重新扫描
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!available ? (
|
||||||
|
<div className="official-empty-panel">
|
||||||
|
<div className="official-empty-icon"><Globe aria-hidden="true" size={24} /></div>
|
||||||
|
<h2>站点产物模式未启用</h2>
|
||||||
|
<p>
|
||||||
|
当前环境未开启 Web App 站点模式(配置开关或模板依赖未就绪),
|
||||||
|
不影响其它功能;智能体会按单页 HTML 方式产出网页。
|
||||||
|
</p>
|
||||||
|
{data?.reasons?.length ? (
|
||||||
|
<p className="official-query-error">{data.reasons.join(";")}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<p className="official-query-error" role="alert">
|
||||||
|
站点列表加载失败:{error instanceof Error ? error.message : "请稍后重试"}
|
||||||
|
</p>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className="official-loading-state">
|
||||||
|
<LoaderCircle aria-hidden="true" size={18} /> 正在扫描站点…
|
||||||
|
</div>
|
||||||
|
) : sites.length ? (
|
||||||
|
<div className="official-agent-grid">
|
||||||
|
{sites.map((site, index) => (
|
||||||
|
<SiteCard
|
||||||
|
key={`${site.thread_id}:${site.name}`}
|
||||||
|
site={site}
|
||||||
|
tone={AVATAR_TONES[index % AVATAR_TONES.length]}
|
||||||
|
onOpen={() =>
|
||||||
|
navigate(siteStudioRoute(site.thread_id, site.name))
|
||||||
|
}
|
||||||
|
onOpenThread={() =>
|
||||||
|
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(site.thread_id)}`)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="official-empty-panel">
|
||||||
|
<div className="official-empty-icon"><Bot aria-hidden="true" size={24} /></div>
|
||||||
|
<h2>还没有生成的站点</h2>
|
||||||
|
<p>在对话里让智能体「做一个多页面网站 / 带流程图或地图的网页」,生成的站点会出现在这里。</p>
|
||||||
|
<button
|
||||||
|
className="official-secondary-button"
|
||||||
|
onClick={() => navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<MessageSquareText aria-hidden="true" size={16} />
|
||||||
|
开始对话
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SiteCard({
|
||||||
|
site,
|
||||||
|
tone,
|
||||||
|
onOpen,
|
||||||
|
onOpenThread,
|
||||||
|
}: {
|
||||||
|
site: SiteGalleryItem;
|
||||||
|
tone: (typeof AVATAR_TONES)[number];
|
||||||
|
onOpen: () => void;
|
||||||
|
onOpenThread: () => void;
|
||||||
|
}) {
|
||||||
|
const building = site.build_job?.status === "running";
|
||||||
|
const failed = site.last_build?.success === false || site.build_job?.status === "failed";
|
||||||
|
const built = site.dist.exists === true;
|
||||||
|
const statusLabel = building ? "构建中" : built ? (failed ? "构建失败" : "可预览") : "未构建";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="official-agent-card">
|
||||||
|
<div className={`official-agent-avatar ${tone}`}>
|
||||||
|
<Globe aria-hidden="true" size={22} />
|
||||||
|
</div>
|
||||||
|
<h2>{site.title}</h2>
|
||||||
|
<p>{site.description || site.name}</p>
|
||||||
|
<small>
|
||||||
|
来源:{site.thread_title}
|
||||||
|
{" · "}
|
||||||
|
<span>{statusLabel}</span>
|
||||||
|
{built && site.dist.total_bytes ? ` · ${Math.max(1, Math.round(site.dist.total_bytes / 1024))} KB` : ""}
|
||||||
|
</small>
|
||||||
|
<button onClick={onOpen} type="button">
|
||||||
|
打开站点预览
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onOpenThread}
|
||||||
|
style={{ background: "transparent", border: "none", color: "inherit", opacity: 0.7, cursor: "pointer", fontSize: 12, marginTop: 6 }}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
查看来源对话 →
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
|||||||
import { PageSidebar } from "@/components/page-sidebar";
|
import { PageSidebar } from "@/components/page-sidebar";
|
||||||
import { PageSidebarDark } from "@/components/page-sidebar-dark";
|
import { PageSidebarDark } from "@/components/page-sidebar-dark";
|
||||||
import { CompactToolbox } from "@/components/workspace/compact-toolbox";
|
import { CompactToolbox } from "@/components/workspace/compact-toolbox";
|
||||||
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
|
import {
|
||||||
|
isChatsIframeWorkspacePath,
|
||||||
|
WX_FULL_CHAT_PATH_PREFIX,
|
||||||
|
} from "@/core/page-layout/chats-iframe-route";
|
||||||
import { isWujieEnv } from "@/core/embed/wujie";
|
import { isWujieEnv } from "@/core/embed/wujie";
|
||||||
import {
|
import {
|
||||||
isEmbedActive,
|
isEmbedActive,
|
||||||
@@ -36,6 +39,9 @@ function PageLayout() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
|
// This dedicated surface is self-contained: it keeps the workspace's
|
||||||
|
// common-agent panel, while omitting the global header and page navigation.
|
||||||
|
const isWxFullChat = location.pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX);
|
||||||
// 浅色非嵌入:侧边栏为新版「二级导航图标卡片」窄栏(96px);wujie 嵌入仍走旧版手风琴(64/256px)。
|
// 浅色非嵌入:侧边栏为新版「二级导航图标卡片」窄栏(96px);wujie 嵌入仍走旧版手风琴(64/256px)。
|
||||||
const inWujie = useMemo(() => isWujieEnv(), []);
|
const inWujie = useMemo(() => isWujieEnv(), []);
|
||||||
const lightNarrowSidebar = !isDark && !inWujie;
|
const lightNarrowSidebar = !isDark && !inWujie;
|
||||||
@@ -47,7 +53,13 @@ function PageLayout() {
|
|||||||
// 不再回退到会话级 sidebarEnabled 旧默认)。「全量↔简洁」始终能真正控制顶栏+侧边栏。
|
// 不再回退到会话级 sidebarEnabled 旧默认)。「全量↔简洁」始终能真正控制顶栏+侧边栏。
|
||||||
const [layoutMode] = useLayoutMode();
|
const [layoutMode] = useLayoutMode();
|
||||||
const chromeByPreference =
|
const chromeByPreference =
|
||||||
forceCollapsedSidebar ? false : layoutMode === "compact" ? false : true;
|
forceCollapsedSidebar
|
||||||
|
? false
|
||||||
|
: isWxFullChat
|
||||||
|
? true
|
||||||
|
: layoutMode === "compact"
|
||||||
|
? false
|
||||||
|
: true;
|
||||||
// iframe 嵌入模式(外部系统 `&embed=1`,按标签页 sessionStorage):去掉全局导航栏 + 侧边栏,
|
// iframe 嵌入模式(外部系统 `&embed=1`,按标签页 sessionStorage):去掉全局导航栏 + 侧边栏,
|
||||||
// 只留问答/会商工作区本体。按 location 重算以跟随页内导航;非嵌入标签页恒为 false,不受影响。
|
// 只留问答/会商工作区本体。按 location 重算以跟随页内导航;非嵌入标签页恒为 false,不受影响。
|
||||||
const embedActive = useMemo(
|
const embedActive = useMemo(
|
||||||
@@ -90,6 +102,7 @@ function PageLayout() {
|
|||||||
const pageSidebarAllowed =
|
const pageSidebarAllowed =
|
||||||
chromeByPreference &&
|
chromeByPreference &&
|
||||||
!isChatsIframeWorkspacePath(location.pathname) &&
|
!isChatsIframeWorkspacePath(location.pathname) &&
|
||||||
|
!isWxFullChat &&
|
||||||
!embedActive &&
|
!embedActive &&
|
||||||
!chromeHidden &&
|
!chromeHidden &&
|
||||||
!hostNestedSurface;
|
!hostNestedSurface;
|
||||||
@@ -98,6 +111,7 @@ function PageLayout() {
|
|||||||
!embedActive &&
|
!embedActive &&
|
||||||
!chromeHidden &&
|
!chromeHidden &&
|
||||||
!hostNestedSurface &&
|
!hostNestedSurface &&
|
||||||
|
!isWxFullChat &&
|
||||||
!isChatsIframeWorkspacePath(location.pathname);
|
!isChatsIframeWorkspacePath(location.pathname);
|
||||||
|
|
||||||
// h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100%
|
// h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100%
|
||||||
@@ -151,7 +165,9 @@ function PageLayout() {
|
|||||||
// Full-chat embedding has no global PageSidebar, but it
|
// Full-chat embedding has no global PageSidebar, but it
|
||||||
// still needs the full GeneralQaSidebar (common agents and
|
// still needs the full GeneralQaSidebar (common agents and
|
||||||
// conversation history) inside the workspace.
|
// conversation history) inside the workspace.
|
||||||
compactWorkspaceSidebar={pageSidebarAllowed || fullChatMode}
|
compactWorkspaceSidebar={
|
||||||
|
pageSidebarAllowed || fullChatMode || isWxFullChat
|
||||||
|
}
|
||||||
compactActionsHidden={hideChromeActive || headerHidden}
|
compactActionsHidden={hideChromeActive || headerHidden}
|
||||||
pageSidebarDark={isDark}
|
pageSidebarDark={isDark}
|
||||||
forceWorkspaceSidebarCollapsed={
|
forceWorkspaceSidebarCollapsed={
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import type { ReactNode } from "react";
|
||||||
|
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||||||
|
import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
|
||||||
|
import AnalysisProjectsPage from "./AnalysisProjectsPage";
|
||||||
|
import { Navigate, Route, Routes, useParams } from "react-router-dom";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||||||
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||||||
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
|
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
|
||||||
@@ -62,7 +66,6 @@ import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
|
|||||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||||
import LightAppNoteListPage from "./LightAppNoteListPage";
|
import LightAppNoteListPage from "./LightAppNoteListPage";
|
||||||
import LightAppNoteQAPage from "./LightAppNoteQAPage";
|
import LightAppNoteQAPage from "./LightAppNoteQAPage";
|
||||||
import MemoryPage from "./MemoryPage";
|
|
||||||
import NewAgentPage from "./NewAgentPage";
|
import NewAgentPage from "./NewAgentPage";
|
||||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||||
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||||
@@ -78,6 +81,8 @@ import UsersPage from "./UsersPage";
|
|||||||
import LlmMetricsPage from "./LlmMetricsPage";
|
import LlmMetricsPage from "./LlmMetricsPage";
|
||||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||||
import AdminUserActivityPage from "./AdminUserActivityPage";
|
import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||||
|
import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
|
||||||
|
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
|
||||||
import { ChatRuntime } from "./WorkspaceRoutes";
|
import { ChatRuntime } from "./WorkspaceRoutes";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,6 +103,28 @@ function StrategyChatThreadLayout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StrategyProjectsLayout({ children }: { children: ReactNode }) {
|
||||||
|
const [layoutMode] = useLayoutMode();
|
||||||
|
const showNavigation =
|
||||||
|
layoutMode === "compact" && !isEmbedActive() && !isHideChromeActive();
|
||||||
|
return (
|
||||||
|
<SidebarProvider defaultOpen className="relative h-full min-h-0 w-full overflow-hidden [&_[data-slot=sidebar-container]]:absolute [&_[data-slot=sidebar-container]]:left-0 [&_[data-slot=sidebar-container]]:top-0">
|
||||||
|
{showNavigation && <WorkspaceSidebar />}
|
||||||
|
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||||
|
{showNavigation && (
|
||||||
|
<div className="p-2 md:hidden"><SidebarTrigger aria-label="展开功能菜单" /></div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StrategyProjectWorkbenchRoute() {
|
||||||
|
const { project_id: projectId = "" } = useParams<{ project_id: string }>();
|
||||||
|
return <StrategySelectionPage key={projectId} projectId={projectId} />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function StrategyRoutes() {
|
export default function StrategyRoutes() {
|
||||||
const storedAuth = getStoredAuth();
|
const storedAuth = getStoredAuth();
|
||||||
const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin";
|
const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin";
|
||||||
@@ -110,6 +137,21 @@ export default function StrategyRoutes() {
|
|||||||
<Route index element={<Navigate to="qa/general" replace />} />
|
<Route index element={<Navigate to="qa/general" replace />} />
|
||||||
|
|
||||||
<Route path="qa/general" element={<GeneralQA />} />
|
<Route path="qa/general" element={<GeneralQA />} />
|
||||||
|
<Route
|
||||||
|
path="qa/projects"
|
||||||
|
element={
|
||||||
|
<StrategyProjectsLayout><StrategyProjectsPage basePath="/page/strategy/qa/projects" /></StrategyProjectsLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="qa/projects/groups/*" element={<StrategyProjectsLayout><AnalysisProjectsPage basePath="/page/strategy/qa/projects/groups" listPath="/page/strategy/qa/projects?type=qa" mode="qa" showSidebarTrigger={false} /></StrategyProjectsLayout>} />
|
||||||
|
<Route
|
||||||
|
path="qa/projects/:project_id"
|
||||||
|
element={<StrategyProjectsLayout><StrategyProjectWorkbenchRoute /></StrategyProjectsLayout>}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="qa/strategy-selection"
|
||||||
|
element={<Navigate to="/page/strategy/qa/projects" replace />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="qa/expert-qa"
|
path="qa/expert-qa"
|
||||||
element={
|
element={
|
||||||
@@ -402,7 +444,6 @@ export default function StrategyRoutes() {
|
|||||||
|
|
||||||
<Route path="skills" element={<SkillsPage />} />
|
<Route path="skills" element={<SkillsPage />} />
|
||||||
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
|
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
|
||||||
<Route path="memory" element={<MemoryPage />} />
|
|
||||||
<Route
|
<Route
|
||||||
path="business-chain-mapping"
|
path="business-chain-mapping"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -31,10 +31,11 @@ import { SubtasksProvider } from "@/core/tasks/context";
|
|||||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||||
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
|
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
|
||||||
|
|
||||||
import { RouteBaseProvider } from "@/core/route-base";
|
import { RouteBaseProvider, useRouteBase } from "@/core/route-base";
|
||||||
import { agentRefOfThread } from "@/core/threads/utils";
|
import { agentRefOfThread } from "@/core/threads/utils";
|
||||||
|
|
||||||
import ChatsPage from "./ChatsPage";
|
import ChatsPage from "./ChatsPage";
|
||||||
|
import AnalysisProjectsPage from "./AnalysisProjectsPage";
|
||||||
import ChatPage from "./ChatPage";
|
import ChatPage from "./ChatPage";
|
||||||
import ArtifactLibraryPage from "./ArtifactLibraryPage";
|
import ArtifactLibraryPage from "./ArtifactLibraryPage";
|
||||||
import IframeChatPage from "./IframeChatPage";
|
import IframeChatPage from "./IframeChatPage";
|
||||||
@@ -55,7 +56,6 @@ import WeKnoraPackageTransfersPage from "@/strategy-components/components/resour
|
|||||||
import MarkdownDocxEditorPage from "./MarkdownDocxEditorPage";
|
import MarkdownDocxEditorPage from "./MarkdownDocxEditorPage";
|
||||||
import LlmMetricsPage from "./LlmMetricsPage";
|
import LlmMetricsPage from "./LlmMetricsPage";
|
||||||
import CheckpointMigrationPage from "./CheckpointMigrationPage";
|
import CheckpointMigrationPage from "./CheckpointMigrationPage";
|
||||||
import MemoryPage from "./MemoryPage";
|
|
||||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||||
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||||
import ModelConcurrencySettingsPage from "./ModelConcurrencySettingsPage";
|
import ModelConcurrencySettingsPage from "./ModelConcurrencySettingsPage";
|
||||||
@@ -130,7 +130,6 @@ function shouldShowCompactTopActions(pathname: string): boolean {
|
|||||||
return (
|
return (
|
||||||
pathname === "/page/workspace/agents" ||
|
pathname === "/page/workspace/agents" ||
|
||||||
pathname === "/page/workspace/skills" ||
|
pathname === "/page/workspace/skills" ||
|
||||||
pathname === "/page/workspace/memory" ||
|
|
||||||
pathname === "/page/workspace/library" ||
|
pathname === "/page/workspace/library" ||
|
||||||
pathname === "/page/workspace/knowledge" ||
|
pathname === "/page/workspace/knowledge" ||
|
||||||
pathname.startsWith("/page/workspace/knowledge/")
|
pathname.startsWith("/page/workspace/knowledge/")
|
||||||
@@ -172,6 +171,7 @@ export function WorkspaceLayout({
|
|||||||
insetOverflowHidden = false,
|
insetOverflowHidden = false,
|
||||||
pageSidebarDark = false,
|
pageSidebarDark = false,
|
||||||
forceWorkspaceSidebarCollapsed = false,
|
forceWorkspaceSidebarCollapsed = false,
|
||||||
|
wxCommonAgents = false,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
pageSidebarCollapsed: boolean;
|
pageSidebarCollapsed: boolean;
|
||||||
@@ -187,11 +187,16 @@ export function WorkspaceLayout({
|
|||||||
pageSidebarDark?: boolean;
|
pageSidebarDark?: boolean;
|
||||||
/** Deployment-level compact mode: keep only the icon menu rail. */
|
/** Deployment-level compact mode: keep only the icon menu rail. */
|
||||||
forceWorkspaceSidebarCollapsed?: boolean;
|
forceWorkspaceSidebarCollapsed?: boolean;
|
||||||
|
/** Enables wx's shared Agent Gallery category selection. */
|
||||||
|
wxCommonAgents?: boolean;
|
||||||
}) {
|
}) {
|
||||||
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
|
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
|
||||||
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
|
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
|
||||||
const pathname = useLocation().pathname;
|
const pathname = useLocation().pathname;
|
||||||
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
|
const isGeneralQa =
|
||||||
|
pathname.startsWith("/page/workspace/chats") ||
|
||||||
|
pathname.startsWith("/page/workspace/wx/") ||
|
||||||
|
pathname.startsWith("/page/workspace/projects");
|
||||||
const showCompactTopActions =
|
const showCompactTopActions =
|
||||||
headerHidden &&
|
headerHidden &&
|
||||||
!compactActionsHidden &&
|
!compactActionsHidden &&
|
||||||
@@ -243,6 +248,7 @@ export function WorkspaceLayout({
|
|||||||
<WorkspaceSidebar
|
<WorkspaceSidebar
|
||||||
compactChrome={compactWorkspaceSidebar}
|
compactChrome={compactWorkspaceSidebar}
|
||||||
forceCollapsed={forceWorkspaceSidebarCollapsed}
|
forceCollapsed={forceWorkspaceSidebarCollapsed}
|
||||||
|
wxCommonAgents={wxCommonAgents}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SidebarInset
|
<SidebarInset
|
||||||
@@ -412,6 +418,7 @@ function ChatThreadEntryRoute({
|
|||||||
function AgentAwareChatThreadContent() {
|
function AgentAwareChatThreadContent() {
|
||||||
const { thread_id: threadId } = useParams<{ thread_id: string }>();
|
const { thread_id: threadId } = useParams<{ thread_id: string }>();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const routeBase = useRouteBase();
|
||||||
const [agentId, setAgentId] = useState<string | null>(null);
|
const [agentId, setAgentId] = useState<string | null>(null);
|
||||||
const [resolved, setResolved] = useState(false);
|
const [resolved, setResolved] = useState(false);
|
||||||
|
|
||||||
@@ -458,16 +465,133 @@ function AgentAwareChatThreadContent() {
|
|||||||
|
|
||||||
if (threadId && agentId) {
|
if (threadId && agentId) {
|
||||||
return (
|
return (
|
||||||
<Navigate
|
<Navigate
|
||||||
replace
|
replace
|
||||||
to={`/page/workspace/agents/${encodeURIComponent(agentId)}/chats/${threadId}${location.search}`}
|
to={`${routeBase}/agents/${encodeURIComponent(agentId)}/chats/${threadId}${location.search}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ChatPage />;
|
return <ChatPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WX_CHAT_ROUTE_BASE = "/page/workspace/wx";
|
||||||
|
|
||||||
|
type WorkspaceRouteLayoutProps = {
|
||||||
|
pageSidebarCollapsed: boolean;
|
||||||
|
pageSidebarHidden: boolean;
|
||||||
|
workspaceSidebarHidden: boolean;
|
||||||
|
headerHidden: boolean;
|
||||||
|
compactWorkspaceSidebar: boolean;
|
||||||
|
compactActionsHidden: boolean;
|
||||||
|
pageSidebarDark: boolean;
|
||||||
|
forceWorkspaceSidebarCollapsed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedicated full-layout chat page for the wx workflow. It shares the normal
|
||||||
|
* chat composition but keeps its own route namespace and category-filtered
|
||||||
|
* common-agents sidebar.
|
||||||
|
*/
|
||||||
|
function WxChatLayout({
|
||||||
|
layoutProps,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
layoutProps: WorkspaceRouteLayoutProps;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<RouteBaseProvider value={WX_CHAT_ROUTE_BASE}>
|
||||||
|
<WorkspaceLayout
|
||||||
|
{...layoutProps}
|
||||||
|
defaultHistorySidebarOpen
|
||||||
|
wxCommonAgents
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</WorkspaceLayout>
|
||||||
|
</RouteBaseProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WxNewChatEntryRoute({
|
||||||
|
layoutProps,
|
||||||
|
}: {
|
||||||
|
layoutProps: WorkspaceRouteLayoutProps;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<WxChatLayout layoutProps={layoutProps}>
|
||||||
|
<ChatRuntime>
|
||||||
|
<NewChatContent />
|
||||||
|
</ChatRuntime>
|
||||||
|
</WxChatLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WxChatThreadEntryRoute({
|
||||||
|
layoutProps,
|
||||||
|
}: {
|
||||||
|
layoutProps: WorkspaceRouteLayoutProps;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<WxChatLayout layoutProps={layoutProps}>
|
||||||
|
<ChatRuntime>
|
||||||
|
<AgentAwareChatThreadContent />
|
||||||
|
</ChatRuntime>
|
||||||
|
</WxChatLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WxAgentChatEntryRoute({
|
||||||
|
layoutProps,
|
||||||
|
}: {
|
||||||
|
layoutProps: WorkspaceRouteLayoutProps;
|
||||||
|
}) {
|
||||||
|
const { agent_id: agentId } = useParams<{ agent_id: string }>();
|
||||||
|
return (
|
||||||
|
<WxChatLayout layoutProps={layoutProps}>
|
||||||
|
<ChatRuntime>
|
||||||
|
<AgentChatPage key={agentId} />
|
||||||
|
</ChatRuntime>
|
||||||
|
</WxChatLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WxSentimentAgentChatEntryRoute({
|
||||||
|
layoutProps,
|
||||||
|
}: {
|
||||||
|
layoutProps: WorkspaceRouteLayoutProps;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<WxChatLayout layoutProps={layoutProps}>
|
||||||
|
<SentimentAgentChatPage />
|
||||||
|
</WxChatLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WxChatsIndexEntryRoute({
|
||||||
|
layoutProps,
|
||||||
|
}: {
|
||||||
|
layoutProps: WorkspaceRouteLayoutProps;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<WxChatLayout layoutProps={layoutProps}>
|
||||||
|
<ChatsPage compactChrome />
|
||||||
|
</WxChatLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WxAgentsEntryRoute({
|
||||||
|
layoutProps,
|
||||||
|
}: {
|
||||||
|
layoutProps: WorkspaceRouteLayoutProps;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<WxChatLayout layoutProps={layoutProps}>
|
||||||
|
<AgentsPage />
|
||||||
|
</WxChatLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `chats_iframe` is reserved for the Brainy postMessage protocol. `from=iframe`
|
* `chats_iframe` is reserved for the Brainy postMessage protocol. `from=iframe`
|
||||||
* and `chatMode=iframe` opt into this page; ordinary iframe/Wujie embedding
|
* and `chatMode=iframe` opt into this page; ordinary iframe/Wujie embedding
|
||||||
@@ -548,6 +672,58 @@ export default function WorkspaceRoutes({
|
|||||||
<RouteBaseProvider value="/page/workspace">
|
<RouteBaseProvider value="/page/workspace">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Navigate to="chats/new" replace />} />
|
<Route index element={<Navigate to="chats/new" replace />} />
|
||||||
|
<Route path="wx" element={<Navigate to="chats/new" replace />} />
|
||||||
|
<Route
|
||||||
|
path="wx/chats"
|
||||||
|
element={<WxChatsIndexEntryRoute layoutProps={layoutProps} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="wx/chats/new"
|
||||||
|
element={<WxNewChatEntryRoute layoutProps={layoutProps} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="wx/chats/:thread_id"
|
||||||
|
element={<WxChatThreadEntryRoute layoutProps={layoutProps} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="wx/agents"
|
||||||
|
element={<WxAgentsEntryRoute layoutProps={layoutProps} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="wx/agents/sentiment-analysis/chats"
|
||||||
|
element={
|
||||||
|
isSentimentAgentVisible() ? (
|
||||||
|
<Navigate
|
||||||
|
to="/page/workspace/wx/agents/sentiment-analysis/chats/new"
|
||||||
|
replace
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Navigate to="/page/workspace/wx/chats/new" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="wx/agents/sentiment-analysis/chats/:thread_id"
|
||||||
|
element={
|
||||||
|
isSentimentAgentVisible() ? (
|
||||||
|
<WxSentimentAgentChatEntryRoute layoutProps={layoutProps} />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/page/workspace/wx/chats/new" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="wx/agents/:agent_id/chats/:thread_id"
|
||||||
|
element={<WxAgentChatEntryRoute layoutProps={layoutProps} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="projects/*"
|
||||||
|
element={
|
||||||
|
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen insetOverflowHidden>
|
||||||
|
<AnalysisProjectsPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="chats"
|
path="chats"
|
||||||
element={
|
element={
|
||||||
@@ -684,14 +860,6 @@ export default function WorkspaceRoutes({
|
|||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="memory"
|
|
||||||
element={
|
|
||||||
<WorkspaceLayout {...layoutProps}>
|
|
||||||
<MemoryPage />
|
|
||||||
</WorkspaceLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="ai-writing/*"
|
path="ai-writing/*"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { MessageSquare, Sparkles } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useAgent } from "@/core/agents/hooks";
|
||||||
|
import { StrategyBoard } from "./StrategyBoard";
|
||||||
|
import {
|
||||||
|
PromptInputProvider,
|
||||||
|
type PromptInputMessage,
|
||||||
|
} from "@/components/ai-elements/prompt-input";
|
||||||
|
import {
|
||||||
|
ArtifactsProvider,
|
||||||
|
ArtifactTrigger,
|
||||||
|
} from "@/components/workspace/artifacts";
|
||||||
|
import { ChatBox } from "@/components/workspace/chats";
|
||||||
|
import { InputBox } from "@/components/workspace/input-box";
|
||||||
|
import { MessageList } from "@/components/workspace/messages";
|
||||||
|
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||||
|
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||||
|
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
|
||||||
|
import { SubtasksProvider } from "@/core/tasks/context";
|
||||||
|
import { useThreadSettings } from "@/core/settings";
|
||||||
|
import {
|
||||||
|
stopActiveThreadBeforeClarificationReply,
|
||||||
|
useThreadStream,
|
||||||
|
} from "@/core/threads/hooks";
|
||||||
|
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
||||||
|
import {
|
||||||
|
PROJECT_MODES,
|
||||||
|
projectKeys,
|
||||||
|
type AnalysisProject,
|
||||||
|
} from "@/core/analysis-projects/api";
|
||||||
|
|
||||||
|
function Conversation({
|
||||||
|
project,
|
||||||
|
threadId,
|
||||||
|
agentId,
|
||||||
|
onReload,
|
||||||
|
}: {
|
||||||
|
project: AnalysisProject;
|
||||||
|
threadId: string;
|
||||||
|
agentId: string | null;
|
||||||
|
onReload: () => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [settings, setSettings] = useThreadSettings(threadId);
|
||||||
|
const { agent } = useAgent(agentId);
|
||||||
|
const [showMessages, setShowMessages] = useState(false);
|
||||||
|
const context = useMemo(
|
||||||
|
() => ({
|
||||||
|
model_name: settings.context.model_name,
|
||||||
|
mode: settings.context.mode,
|
||||||
|
reasoning_effort: settings.context.reasoning_effort,
|
||||||
|
writing_mode: project.mode === "solution",
|
||||||
|
agent_id: agentId ?? undefined,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
settings.context.model_name,
|
||||||
|
settings.context.mode,
|
||||||
|
settings.context.reasoning_effort,
|
||||||
|
project,
|
||||||
|
agentId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||||
|
threadId,
|
||||||
|
context,
|
||||||
|
assistantId: agentId || "lead_agent",
|
||||||
|
runOptions: { onDisconnect: "continue", multitaskStrategy: "reject" },
|
||||||
|
onFinish: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: [...projectKeys(), project.id, "threads"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { displayThread } = useResearchWritingFeed(threadId, thread);
|
||||||
|
const submit = useCallback(
|
||||||
|
(message: PromptInputMessage) => {
|
||||||
|
if (!thread.isLoading && !isUploading)
|
||||||
|
void sendMessage(threadId, message);
|
||||||
|
},
|
||||||
|
[thread.isLoading, isUploading, sendMessage, threadId],
|
||||||
|
);
|
||||||
|
const clarify = useCallback(
|
||||||
|
async (answer: string) => {
|
||||||
|
await stopActiveThreadBeforeClarificationReply(threadId, thread);
|
||||||
|
await sendMessage(threadId, { text: answer, files: [] });
|
||||||
|
},
|
||||||
|
[threadId, thread, sendMessage],
|
||||||
|
);
|
||||||
|
const hasMessages = displayThread.messages.length > 0;
|
||||||
|
const strategyActions = [
|
||||||
|
["改变约束", "请列出当前策略最关键的可调整约束,并询问我希望修改哪一项。"],
|
||||||
|
["增加视角", "请建议一个当前尚未覆盖的分析视角;说明价值,并在加入该视角后重新推演。"],
|
||||||
|
["删除视角", "请指出当前贡献最低或重复的分析视角;说明影响,并在移除后重新推演。"],
|
||||||
|
["重新推演", "请基于当前对话中的全部条件,按完整 Lens 分析、策略辩论和综合流程重新推演。"],
|
||||||
|
["挑战当前策略", "请从最有力的反方立场挑战当前推荐策略,识别失败条件,并给出必要的调整。"],
|
||||||
|
] as const;
|
||||||
|
return (
|
||||||
|
<ThreadContext.Provider
|
||||||
|
value={{
|
||||||
|
thread: displayThread,
|
||||||
|
knowledgeReferencesEnabled: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
|
<ChatBox threadId={threadId} writingMode={project.mode === "solution"}>
|
||||||
|
<div className="relative flex h-full min-h-0 min-w-0 flex-col">
|
||||||
|
<div className="flex h-11 shrink-0 items-center justify-between border-b px-4">
|
||||||
|
<span className="truncate text-sm text-muted-foreground">
|
||||||
|
{displayThread.values.title || "项目对话"}
|
||||||
|
<span className="ml-2 text-xs">
|
||||||
|
· {agent?.name || (agentId ? "所选智能体" : "通用助手")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{project.mode === "strategy" && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowMessages((value) => !value)}
|
||||||
|
>
|
||||||
|
{showMessages ? "三层策略视图" : "对话与引用"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<ExportTrigger threadId={threadId} />
|
||||||
|
<ArtifactTrigger />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
{project.mode === "strategy" && !showMessages ? (
|
||||||
|
<StrategyBoard
|
||||||
|
messages={displayThread.messages}
|
||||||
|
loading={thread.isLoading}
|
||||||
|
lenses={project.strategy_config?.lenses ?? []}
|
||||||
|
onClarificationSubmit={clarify}
|
||||||
|
/>
|
||||||
|
) : hasMessages ? (
|
||||||
|
<MessageList
|
||||||
|
threadId={threadId}
|
||||||
|
thread={displayThread}
|
||||||
|
className="h-full"
|
||||||
|
paddingBottom={200}
|
||||||
|
onClarificationSubmit={clarify}
|
||||||
|
onEditResubmit={(text) => submit({ text, files: [] })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center px-6 pb-44 text-center">
|
||||||
|
<MessageSquare className="mb-4 size-8 text-primary" />
|
||||||
|
<h2 className="text-lg font-medium">
|
||||||
|
{PROJECT_MODES[project.mode].name}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 max-w-md text-sm text-muted-foreground">
|
||||||
|
{PROJECT_MODES[project.mode].description}
|
||||||
|
。在下方输入问题或要求开始。
|
||||||
|
</p>
|
||||||
|
{project.mode === "solution" && (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
按项目所选报告结构生成,正文完成后自动打开方案画布。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="absolute right-0 bottom-0 left-0 z-20 bg-gradient-to-t from-background via-background to-transparent px-4 pt-4 pb-3">
|
||||||
|
<div className="mx-auto max-w-3xl">
|
||||||
|
{project.mode === "strategy" && (
|
||||||
|
<div className="mb-2 flex gap-1.5 overflow-x-auto pb-1" aria-label="策略快捷操作">
|
||||||
|
{strategyActions.map(([label, prompt]) => (
|
||||||
|
<Button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 shrink-0 rounded-full bg-background/90 px-2.5 text-xs"
|
||||||
|
disabled={thread.isLoading || isUploading}
|
||||||
|
onClick={() => submit({ text: prompt, files: [] })}
|
||||||
|
>
|
||||||
|
<Sparkles className="mr-1 size-3" />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!thread.error && (
|
||||||
|
<div role="alert" className="mb-2 text-sm text-destructive">
|
||||||
|
{thread.error instanceof Error
|
||||||
|
? thread.error.message
|
||||||
|
: "本轮问答失败,请重试"}
|
||||||
|
<Button type="button" variant="outline" size="sm" className="ml-3" onClick={onReload}>
|
||||||
|
重新加载对话
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<InputBox
|
||||||
|
threadId={threadId}
|
||||||
|
context={context}
|
||||||
|
hideContextTools
|
||||||
|
hideAdminRecommendedQuestions
|
||||||
|
isNewThread={false}
|
||||||
|
disabled={isUploading}
|
||||||
|
status={thread.isLoading ? "streaming" : "ready"}
|
||||||
|
onSubmit={submit}
|
||||||
|
onStop={() => void thread.stop()}
|
||||||
|
onContextChange={(next) =>
|
||||||
|
setSettings("context", {
|
||||||
|
...settings.context,
|
||||||
|
model_name:
|
||||||
|
typeof next.model_name === "string"
|
||||||
|
? next.model_name
|
||||||
|
: undefined,
|
||||||
|
mode: next.mode,
|
||||||
|
reasoning_effort: next.reasoning_effort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-center text-xs text-muted-foreground">
|
||||||
|
项目内持续问答 ·{" "}
|
||||||
|
{agent?.name || (agentId ? "所选智能体" : "通用助手")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ChatBox>
|
||||||
|
</div>
|
||||||
|
</ThreadContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectConversation(props: {
|
||||||
|
project: AnalysisProject;
|
||||||
|
threadId: string;
|
||||||
|
agentId: string | null;
|
||||||
|
}) {
|
||||||
|
// Remount all conversation-scoped providers together when switching threads.
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
return (
|
||||||
|
<SubtasksProvider key={reloadKey}>
|
||||||
|
<DocumentRewritePresentationProvider>
|
||||||
|
<ArtifactsProvider>
|
||||||
|
<PromptInputProvider>
|
||||||
|
<Conversation {...props} onReload={() => setReloadKey((key) => key + 1)} />
|
||||||
|
</PromptInputProvider>
|
||||||
|
</ArtifactsProvider>
|
||||||
|
</DocumentRewritePresentationProvider>
|
||||||
|
</SubtasksProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
BrainCircuit,
|
||||||
|
Compass,
|
||||||
|
GitBranch,
|
||||||
|
Loader2,
|
||||||
|
Medal,
|
||||||
|
MessagesSquare,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { reasoningPlugins } from "@/core/streamdown";
|
||||||
|
import {
|
||||||
|
splitStrategySections,
|
||||||
|
splitStrategySubsections,
|
||||||
|
pendingStrategyClarification,
|
||||||
|
strategyRounds,
|
||||||
|
STRATEGY_COLUMNS,
|
||||||
|
} from "@/core/analysis-projects/strategy";
|
||||||
|
|
||||||
|
const descriptions = [
|
||||||
|
"确认 AI 理解的问题、目标、约束、变量与启用视角",
|
||||||
|
"各 Lens 独立判断并形成策略,再显式分析观点冲突",
|
||||||
|
"形成可导出、可执行的策略文档,包含路线图、资源、风险与评估机制",
|
||||||
|
];
|
||||||
|
const icons = [Compass, GitBranch, Medal];
|
||||||
|
export function StrategyBoard({
|
||||||
|
messages,
|
||||||
|
loading,
|
||||||
|
lenses,
|
||||||
|
onClarificationSubmit,
|
||||||
|
}: {
|
||||||
|
messages: Message[];
|
||||||
|
loading: boolean;
|
||||||
|
lenses: string[];
|
||||||
|
onClarificationSubmit: (answer: string) => void | Promise<void>;
|
||||||
|
}) {
|
||||||
|
const rounds = strategyRounds(messages);
|
||||||
|
const clarification = pendingStrategyClarification(messages);
|
||||||
|
const [customAnswer, setCustomAnswer] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
useEffect(() => setCustomAnswer(""), [clarification?.question]);
|
||||||
|
const answerClarification = async (answer: string) => {
|
||||||
|
if (!answer.trim() || submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onClarificationSubmit(answer.trim());
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const displayed = rounds.length
|
||||||
|
? rounds
|
||||||
|
: [{ id: "empty", question: "从一个问题开始策略分析", answer: "" }];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h-full overflow-y-auto px-4 pt-4 pb-56"
|
||||||
|
data-testid="strategy-board"
|
||||||
|
>
|
||||||
|
{displayed.map((round, index) => {
|
||||||
|
const parts = splitStrategySections(round.answer);
|
||||||
|
const active = loading && index === displayed.length - 1;
|
||||||
|
return (
|
||||||
|
<article key={round.id} className="mb-6">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<span className="rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<h2 className="min-w-0 flex-1 whitespace-pre-wrap break-words text-sm font-medium">
|
||||||
|
{round.question}
|
||||||
|
</h2>
|
||||||
|
{active && (
|
||||||
|
<Loader2 className="size-4 shrink-0 animate-spin text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid items-start gap-3 lg:grid-cols-[minmax(220px,0.75fr)_minmax(340px,1.35fr)_minmax(320px,1.2fr)]">
|
||||||
|
{STRATEGY_COLUMNS.map((title, column) => {
|
||||||
|
const Icon = icons[column]!;
|
||||||
|
const subsections = splitStrategySubsections(parts[column]!);
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={title}
|
||||||
|
aria-label={title}
|
||||||
|
className="min-w-0 overflow-hidden rounded-xl border bg-card"
|
||||||
|
>
|
||||||
|
<header className="border-b bg-muted/30 px-4 py-3">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Icon className="size-4 text-primary" />
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||||
|
{descriptions[column]}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div className="min-h-44 break-words p-4 text-sm">
|
||||||
|
{column === 0 && clarification && (
|
||||||
|
<div className="mb-4 rounded-lg border border-amber-300/70 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-950/30">
|
||||||
|
<p className="text-xs font-medium text-amber-800 dark:text-amber-300">
|
||||||
|
待确认条件 · 回答后继续多视角推演
|
||||||
|
</p>
|
||||||
|
{clarification.context && (
|
||||||
|
<p className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||||
|
{clarification.context}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-sm font-medium">{clarification.question}</p>
|
||||||
|
<div className="mt-3 grid gap-2">
|
||||||
|
{clarification.options.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto justify-start whitespace-normal bg-background py-2 text-left"
|
||||||
|
disabled={submitting || loading}
|
||||||
|
onClick={() => void answerClarification(option.label)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{clarification.allow_custom && (
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<Input
|
||||||
|
aria-label="补充澄清条件"
|
||||||
|
className="h-8 bg-background text-xs"
|
||||||
|
value={customAnswer}
|
||||||
|
onChange={(event) => setCustomAnswer(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") void answerClarification(customAnswer);
|
||||||
|
}}
|
||||||
|
placeholder="输入其他情况"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
disabled={!customAnswer.trim() || submitting || loading}
|
||||||
|
onClick={() => void answerClarification(customAnswer)}
|
||||||
|
>
|
||||||
|
继续
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{column === 1 && (
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-1.5 rounded-lg bg-muted/40 p-2 text-[11px] text-muted-foreground">
|
||||||
|
<BrainCircuit className="size-3.5 text-primary" />
|
||||||
|
<span>当前问题</span>
|
||||||
|
<ArrowRight className="size-3" />
|
||||||
|
<span>Lens Manager</span>
|
||||||
|
<ArrowRight className="size-3" />
|
||||||
|
{lenses.map((lens) => (
|
||||||
|
<span key={lens} className="rounded-full border bg-background px-2 py-0.5">
|
||||||
|
{lens}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parts[column] ? (
|
||||||
|
column === 1 && subsections.length ? (
|
||||||
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
|
{subsections.map((section) => {
|
||||||
|
const debate = /策略辩论/.test(section.title);
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={section.title}
|
||||||
|
className={debate ? "rounded-lg border border-primary/20 bg-primary/5 p-3 xl:col-span-2" : "rounded-lg border bg-background p-3"}
|
||||||
|
>
|
||||||
|
<h4 className="mb-2 flex items-center gap-2 font-medium">
|
||||||
|
{debate ? <MessagesSquare className="size-4 text-primary" /> : <Compass className="size-4 text-primary" />}
|
||||||
|
{section.title.replace(/^Lens[::]\s*/, "")}
|
||||||
|
</h4>
|
||||||
|
<MarkdownContent
|
||||||
|
content={section.content}
|
||||||
|
isLoading={active}
|
||||||
|
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||||
|
className="!h-auto !w-full min-w-0 text-xs [&_table]:text-xs"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MarkdownContent
|
||||||
|
content={parts[column]!}
|
||||||
|
isLoading={active}
|
||||||
|
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||||
|
className="!h-auto !w-full min-w-0 text-sm [&_table]:text-xs"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||||
|
{active
|
||||||
|
? "等待本层分析结果…"
|
||||||
|
: rounds.length
|
||||||
|
? "本轮尚未输出该层内容"
|
||||||
|
: "在下方输入问题后开始"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -144,6 +144,16 @@ import {
|
|||||||
import { ResearchSandboxPanel } from "./ResearchSandboxPanel";
|
import { ResearchSandboxPanel } from "./ResearchSandboxPanel";
|
||||||
import { WorkflowConfigButton } from "./WorkflowConfigDialog";
|
import { WorkflowConfigButton } from "./WorkflowConfigDialog";
|
||||||
import { REPORT_OUTPUT_PATH, useReportSandbox } from "./useReportSandbox";
|
import { REPORT_OUTPUT_PATH, useReportSandbox } from "./useReportSandbox";
|
||||||
|
import {
|
||||||
|
PLANNER_AGENT_ID,
|
||||||
|
OUTLINE_PLANNER_OPTIONS,
|
||||||
|
buildCollectAfterPlanPrompt,
|
||||||
|
hasCollectAfterPlanTurn,
|
||||||
|
hasCollectorToolTurn,
|
||||||
|
isCollectAfterPlanPrompt,
|
||||||
|
normalizeOutlinePlanner,
|
||||||
|
outlineFromFirstAssistant,
|
||||||
|
} from "./outline-plan";
|
||||||
|
|
||||||
const COLLECTOR_AGENT_ID = "deep-research-collector";
|
const COLLECTOR_AGENT_ID = "deep-research-collector";
|
||||||
/** TDesign Select sentinel: do not send a per-session model override. */
|
/** TDesign Select sentinel: do not send a per-session model override. */
|
||||||
@@ -637,6 +647,8 @@ function DeepResearchWorkbenchInner() {
|
|||||||
const collectorPersistChain = useRef(Promise.resolve());
|
const collectorPersistChain = useRef(Promise.resolve());
|
||||||
const collectorSessionIdRef = useRef<string | null>(null);
|
const collectorSessionIdRef = useRef<string | null>(null);
|
||||||
const followUpAbortRef = useRef<AbortController | null>(null);
|
const followUpAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const outlinePlanKeyRef = useRef("");
|
||||||
|
const pendingCollectAfterPlanRef = useRef("");
|
||||||
|
|
||||||
const refetchSession = sessionQuery.refetch;
|
const refetchSession = sessionQuery.refetch;
|
||||||
const refetchSessions = sessions.refetch;
|
const refetchSessions = sessions.refetch;
|
||||||
@@ -1095,6 +1107,17 @@ function DeepResearchWorkbenchInner() {
|
|||||||
|
|
||||||
// ── collector-agent conversation stream (normal-chat machinery) ───────────
|
// ── collector-agent conversation stream (normal-chat machinery) ───────────
|
||||||
|
|
||||||
|
const outlinePlannerMode = session
|
||||||
|
? normalizeOutlinePlanner(session.config?.outline_planner)
|
||||||
|
: advancedOptions.outlinePlanner;
|
||||||
|
const sessionOutline = (
|
||||||
|
session && Object.prototype.hasOwnProperty.call(reportCardOutlineOverrides, session.id)
|
||||||
|
? reportCardOutlineOverrides[session.id]
|
||||||
|
: (typeof session?.config?.custom_outline === "string" ? session.config.custom_outline : "")
|
||||||
|
|| advancedOptions.customOutline
|
||||||
|
).trim();
|
||||||
|
const [activeAgentId, setActiveAgentId] = useState(COLLECTOR_AGENT_ID);
|
||||||
|
|
||||||
const collectorContext = useMemo(
|
const collectorContext = useMemo(
|
||||||
() => {
|
() => {
|
||||||
const skills = normalizeRetrievalSkills(
|
const skills = normalizeRetrievalSkills(
|
||||||
@@ -1105,16 +1128,16 @@ function DeepResearchWorkbenchInner() {
|
|||||||
return {
|
return {
|
||||||
...localSettings.context,
|
...localSettings.context,
|
||||||
model_name: selectedModel ?? localSettings.context.model_name,
|
model_name: selectedModel ?? localSettings.context.model_name,
|
||||||
agent_id: COLLECTOR_AGENT_ID,
|
agent_id: activeAgentId,
|
||||||
...(skills.length > 0 ? { extra_skills: skills } : {}),
|
...(skills.length > 0 ? { extra_skills: skills } : {}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[advancedOptions.retrievalSkills, localSettings.context, selectedModel, session?.config?.retrieval_skills],
|
[activeAgentId, advancedOptions.retrievalSkills, localSettings.context, selectedModel, session?.config?.retrieval_skills],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [collectorThread, collectorSendMessage] = useThreadStream({
|
const [collectorThread, collectorSendMessage] = useThreadStream({
|
||||||
threadId: isChatSession ? collectorThreadId : undefined,
|
threadId: isChatSession ? collectorThreadId : undefined,
|
||||||
assistantId: COLLECTOR_AGENT_ID,
|
assistantId: activeAgentId,
|
||||||
context: collectorContext,
|
context: collectorContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1125,32 +1148,48 @@ function DeepResearchWorkbenchInner() {
|
|||||||
() => hasPendingClarification(collectorThread.messages),
|
() => hasPendingClarification(collectorThread.messages),
|
||||||
[collectorThread.messages],
|
[collectorThread.messages],
|
||||||
);
|
);
|
||||||
|
const collectionAlreadyStarted = hasCollectAfterPlanTurn(collectorThread.messages, extractContentFromMessage)
|
||||||
|
|| hasCollectorToolTurn(collectorThread.messages);
|
||||||
|
const isPlanningOutline = outlinePlannerMode === "agent"
|
||||||
|
&& !sessionOutline
|
||||||
|
&& !collectionAlreadyStarted
|
||||||
|
&& isChatSession;
|
||||||
|
|
||||||
const sendCollectorMessage = useCallback(
|
const sendCollectorMessage = useCallback(
|
||||||
async (text: string, options?: { silentStatusPatch?: boolean }) => {
|
async (text: string, options?: {
|
||||||
|
silentStatusPatch?: boolean;
|
||||||
|
hideFromUI?: boolean;
|
||||||
|
withDirections?: boolean;
|
||||||
|
withSkills?: boolean;
|
||||||
|
}) => {
|
||||||
const threadId = session?.runtimeThreadId;
|
const threadId = session?.runtimeThreadId;
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!threadId || !trimmed) return;
|
if (!threadId || !trimmed) return;
|
||||||
if (!options?.silentStatusPatch && session && session.status !== "collecting") {
|
if (!options?.silentStatusPatch && session && session.status !== "collecting") {
|
||||||
void actions.updateStatus.mutate({ sessionId: session.id, status: "collecting" });
|
void actions.updateStatus.mutate({ sessionId: session.id, status: "collecting" });
|
||||||
}
|
}
|
||||||
const isFirstTurn = (collectorThread.messages?.length ?? 0) === 0;
|
const useDirections = options?.withDirections === true
|
||||||
const payload = isFirstTurn
|
|| (options?.withDirections !== false && (collectorThread.messages?.length ?? 0) === 0 && !isPlanningOutline);
|
||||||
? composeCollectorQueryWithDirections(
|
const useSkills = options?.withSkills === true || (options?.withSkills !== false && useDirections);
|
||||||
trimmed,
|
const payload = composeCollectorQueryWithDirections(
|
||||||
advancedOptions.retrievalDirections.length > 0
|
trimmed,
|
||||||
|
useDirections
|
||||||
|
? (advancedOptions.retrievalDirections.length > 0
|
||||||
? advancedOptions.retrievalDirections
|
? advancedOptions.retrievalDirections
|
||||||
: session?.config?.retrieval_directions,
|
: session?.config?.retrieval_directions)
|
||||||
advancedOptions.retrievalSkills.length > 0
|
: [],
|
||||||
|
useSkills
|
||||||
|
? (advancedOptions.retrievalSkills.length > 0
|
||||||
? advancedOptions.retrievalSkills
|
? advancedOptions.retrievalSkills
|
||||||
: session?.config?.retrieval_skills,
|
: session?.config?.retrieval_skills)
|
||||||
)
|
: [],
|
||||||
: trimmed;
|
);
|
||||||
try {
|
try {
|
||||||
await collectorSendMessage(
|
await collectorSendMessage(
|
||||||
threadId,
|
threadId,
|
||||||
{ text: payload, files: [] },
|
{ text: payload, files: [] },
|
||||||
selectedModel ? { model_name: selectedModel } : undefined,
|
selectedModel ? { model_name: selectedModel } : undefined,
|
||||||
|
options?.hideFromUI ? { additionalKwargs: { hide_from_ui: true } } : undefined,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : "发送失败,请重试");
|
toast.error(error instanceof Error ? error.message : "发送失败,请重试");
|
||||||
@@ -1162,6 +1201,7 @@ function DeepResearchWorkbenchInner() {
|
|||||||
advancedOptions.retrievalSkills,
|
advancedOptions.retrievalSkills,
|
||||||
collectorSendMessage,
|
collectorSendMessage,
|
||||||
collectorThread.messages?.length,
|
collectorThread.messages?.length,
|
||||||
|
isPlanningOutline,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
session,
|
session,
|
||||||
],
|
],
|
||||||
@@ -1176,26 +1216,46 @@ function DeepResearchWorkbenchInner() {
|
|||||||
[collectorThread, sendCollectorMessage, session?.runtimeThreadId],
|
[collectorThread, sendCollectorMessage, session?.runtimeThreadId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// The very first research topic: auto-sent into the freshly pre-built
|
useEffect(() => {
|
||||||
// collector thread once the session (and its checkpoint) is ready.
|
if (!session?.id) {
|
||||||
|
setActiveAgentId(COLLECTOR_AGENT_ID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const shouldPlanFirst = normalizeOutlinePlanner(session.config?.outline_planner) === "agent"
|
||||||
|
&& !String(session.config?.custom_outline ?? "").trim()
|
||||||
|
&& !Object.prototype.hasOwnProperty.call(reportCardOutlineOverrides, session.id);
|
||||||
|
setActiveAgentId(shouldPlanFirst ? PLANNER_AGENT_ID : COLLECTOR_AGENT_ID);
|
||||||
|
}, [reportCardOutlineOverrides, session?.config?.custom_outline, session?.config?.outline_planner, session?.id]);
|
||||||
|
|
||||||
|
// The very first research topic: auto-sent once the runtime thread is ready.
|
||||||
|
// 「提纲预编制」先发给规划员;规划完成后再切到收集员。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const query = pendingInitialQuery.current;
|
const query = pendingInitialQuery.current;
|
||||||
if (!query || !isChatSession || !collectorThreadId) return;
|
if (!query || !isChatSession || !collectorThreadId) return;
|
||||||
if (collectorThreadLoading || collectorStreaming || collectorMessagesLength > 0) return;
|
if (collectorThreadLoading || collectorStreaming || collectorMessagesLength > 0) return;
|
||||||
|
const planFirst = outlinePlannerMode === "agent" && !sessionOutline;
|
||||||
|
if (planFirst && activeAgentId !== PLANNER_AGENT_ID) return;
|
||||||
|
if (!planFirst && activeAgentId !== COLLECTOR_AGENT_ID) return;
|
||||||
pendingInitialQuery.current = null;
|
pendingInitialQuery.current = null;
|
||||||
if (session && session.status === "draft") {
|
if (session && session.status === "draft") {
|
||||||
void actions.updateStatus.mutate({ sessionId: session.id, status: "collecting" });
|
void actions.updateStatus.mutate({ sessionId: session.id, status: "collecting" });
|
||||||
}
|
}
|
||||||
void sendCollectorMessage(query, { silentStatusPatch: true });
|
void sendCollectorMessage(query, {
|
||||||
|
silentStatusPatch: true,
|
||||||
|
withDirections: !planFirst,
|
||||||
|
});
|
||||||
}, [
|
}, [
|
||||||
actions.updateStatus,
|
actions.updateStatus,
|
||||||
|
activeAgentId,
|
||||||
collectorMessagesLength,
|
collectorMessagesLength,
|
||||||
collectorStreaming,
|
collectorStreaming,
|
||||||
collectorThreadLoading,
|
collectorThreadLoading,
|
||||||
collectorThreadId,
|
collectorThreadId,
|
||||||
isChatSession,
|
isChatSession,
|
||||||
|
outlinePlannerMode,
|
||||||
sendCollectorMessage,
|
sendCollectorMessage,
|
||||||
session,
|
session,
|
||||||
|
sessionOutline,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Persist each completed search as it lands in the step list, instead of
|
// Persist each completed search as it lands in the step list, instead of
|
||||||
@@ -1258,45 +1318,120 @@ function DeepResearchWorkbenchInner() {
|
|||||||
session?.status,
|
session?.status,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Turn finished without a pending clarification → surface the config card.
|
// Turn finished without a pending clarification → either ask the collector
|
||||||
|
// to plan the outline in the message list, or surface the config card.
|
||||||
// Requiring a non-human tail closes the window between the optimistic user
|
// Requiring a non-human tail closes the window between the optimistic user
|
||||||
// message and the run flag flipping, where the turn would look "ended".
|
// message and the run flag flipping, where the turn would look "ended".
|
||||||
const collectorMessages = collectorThread.messages;
|
const collectorMessages = collectorThread.messages;
|
||||||
const collectorTailIsHuman = collectorMessagesLength > 0
|
const collectorTailIsHuman = collectorMessagesLength > 0
|
||||||
&& collectorMessages?.[collectorMessagesLength - 1]?.type === "human";
|
&& collectorMessages?.[collectorMessagesLength - 1]?.type === "human";
|
||||||
|
const showReportConfigCard = isAwaitingReport && !isPlanningOutline;
|
||||||
|
|
||||||
|
const persistLeftoverCollectorMaterials = useCallback(() => {
|
||||||
|
if (!session) return [] as Record<string, unknown>[];
|
||||||
|
const leftover = snapshotCollectorMaterials(collectorThread.messages, {
|
||||||
|
limit: COLLECTOR_PERSIST_LIMIT,
|
||||||
|
}).filter((item) => {
|
||||||
|
const materialKey = collectorMaterialKey(item);
|
||||||
|
if (!materialKey || persistedCollectorMaterialKeys.current.has(materialKey)) return false;
|
||||||
|
persistedCollectorMaterialKeys.current.add(materialKey);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
persistedCollectorKey.current = `${session.id}:${persistedCollectorMaterialKeys.current.size}`;
|
||||||
|
return leftover;
|
||||||
|
}, [collectorThread.messages, session]);
|
||||||
|
|
||||||
|
const applyPlannerOutline = useCallback((outline: string) => {
|
||||||
|
if (!session || !outline.trim()) return;
|
||||||
|
const next = outline.trim();
|
||||||
|
setReportCardOutlineOverrides((current) => (
|
||||||
|
current[session.id] === next ? current : { ...current, [session.id]: next }
|
||||||
|
));
|
||||||
|
setAdvancedOptions((current) => (
|
||||||
|
current.customOutline === next ? current : { ...current, customOutline: next }
|
||||||
|
));
|
||||||
|
void actions.updateStatus.mutate({
|
||||||
|
sessionId: session.id,
|
||||||
|
status: session.status === "awaiting_report" ? "awaiting_report" : "collecting",
|
||||||
|
customOutline: next,
|
||||||
|
});
|
||||||
|
}, [actions.updateStatus, session]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlanningOutline || collectorStreaming || collectorTailIsHuman) return;
|
||||||
|
if (!session || collectorMessagesLength === 0) return;
|
||||||
|
const planned = outlineFromFirstAssistant(collectorMessages, extractContentFromMessage);
|
||||||
|
if (!planned) return;
|
||||||
|
applyPlannerOutline(planned);
|
||||||
|
}, [
|
||||||
|
applyPlannerOutline,
|
||||||
|
collectorMessages,
|
||||||
|
collectorMessagesLength,
|
||||||
|
collectorStreaming,
|
||||||
|
collectorTailIsHuman,
|
||||||
|
isPlanningOutline,
|
||||||
|
session,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isChatSession || !session || collectorStreaming || collectorThreadLoading) return;
|
||||||
|
if (outlinePlannerMode !== "agent" || collectionAlreadyStarted) return;
|
||||||
|
if (!sessionOutline || activeAgentId !== COLLECTOR_AGENT_ID) return;
|
||||||
|
if (collectorPendingClarification || collectorTailIsHuman) return;
|
||||||
|
const planned = sessionOutline || outlineFromFirstAssistant(collectorMessages, extractContentFromMessage);
|
||||||
|
if (!planned) return;
|
||||||
|
const key = `${session.id}:collect:${planned}`;
|
||||||
|
if (pendingCollectAfterPlanRef.current === key) return;
|
||||||
|
pendingCollectAfterPlanRef.current = key;
|
||||||
|
void sendCollectorMessage(buildCollectAfterPlanPrompt(session.query, planned), {
|
||||||
|
silentStatusPatch: true,
|
||||||
|
hideFromUI: true,
|
||||||
|
withDirections: false,
|
||||||
|
withSkills: true,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
activeAgentId,
|
||||||
|
collectionAlreadyStarted,
|
||||||
|
collectorMessages,
|
||||||
|
collectorPendingClarification,
|
||||||
|
collectorStreaming,
|
||||||
|
collectorTailIsHuman,
|
||||||
|
collectorThreadLoading,
|
||||||
|
isChatSession,
|
||||||
|
outlinePlannerMode,
|
||||||
|
sendCollectorMessage,
|
||||||
|
session,
|
||||||
|
sessionOutline,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isChatSession || collectorStreaming || collectorMessagesLength === 0) return;
|
if (!isChatSession || collectorStreaming || collectorMessagesLength === 0) return;
|
||||||
if (collectorTailIsHuman) return;
|
if (collectorTailIsHuman) return;
|
||||||
const status = session?.status;
|
const status = session?.status;
|
||||||
if (status !== "collecting" && status !== "draft") return;
|
if (status !== "collecting" && status !== "draft") return;
|
||||||
if (collectorPendingClarification) return;
|
if (collectorPendingClarification || isPlanningOutline) return;
|
||||||
|
if (outlinePlannerMode === "agent" && !collectionAlreadyStarted) return;
|
||||||
const key = `${session?.id ?? ""}:${collectorMessagesLength}`;
|
const key = `${session?.id ?? ""}:${collectorMessagesLength}`;
|
||||||
if (finishedTurnKey.current === key) return;
|
if (finishedTurnKey.current === key) return;
|
||||||
finishedTurnKey.current = key;
|
finishedTurnKey.current = key;
|
||||||
if (session) {
|
if (!session) return;
|
||||||
const leftover = snapshotCollectorMaterials(collectorThread.messages, {
|
const leftover = persistLeftoverCollectorMaterials();
|
||||||
limit: COLLECTOR_PERSIST_LIMIT,
|
void actions.updateStatus.mutate({
|
||||||
}).filter((item) => {
|
sessionId: session.id,
|
||||||
const materialKey = collectorMaterialKey(item);
|
status: "awaiting_report",
|
||||||
if (!materialKey || persistedCollectorMaterialKeys.current.has(materialKey)) return false;
|
...(leftover.length ? { collectorMaterials: leftover } : {}),
|
||||||
persistedCollectorMaterialKeys.current.add(materialKey);
|
});
|
||||||
return true;
|
|
||||||
});
|
|
||||||
persistedCollectorKey.current = `${session.id}:${persistedCollectorMaterialKeys.current.size}`;
|
|
||||||
void actions.updateStatus.mutate({
|
|
||||||
sessionId: session.id,
|
|
||||||
status: "awaiting_report",
|
|
||||||
...(leftover.length ? { collectorMaterials: leftover } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [
|
}, [
|
||||||
actions.updateStatus,
|
actions.updateStatus,
|
||||||
|
collectionAlreadyStarted,
|
||||||
collectorMessagesLength,
|
collectorMessagesLength,
|
||||||
collectorPendingClarification,
|
collectorPendingClarification,
|
||||||
collectorStreaming,
|
collectorStreaming,
|
||||||
collectorTailIsHuman,
|
collectorTailIsHuman,
|
||||||
collectorThread.messages,
|
|
||||||
isChatSession,
|
isChatSession,
|
||||||
|
isPlanningOutline,
|
||||||
|
outlinePlannerMode,
|
||||||
|
persistLeftoverCollectorMaterials,
|
||||||
session,
|
session,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1349,7 +1484,9 @@ function DeepResearchWorkbenchInner() {
|
|||||||
// 普通消息列表的 tool-call 分组,摘要从生成开始就是普通 assistant 消息并随 SSE
|
// 普通消息列表的 tool-call 分组,摘要从生成开始就是普通 assistant 消息并随 SSE
|
||||||
// 增量刷新。这样两者都使用 Q&A 原有的消息流与步骤条,而不是在列表中再起一套卡片式会话。
|
// 增量刷新。这样两者都使用 Q&A 原有的消息流与步骤条,而不是在列表中再起一套卡片式会话。
|
||||||
const collectorDisplayThread = useMemo(() => {
|
const collectorDisplayThread = useMemo(() => {
|
||||||
const original = collectorThread.messages ?? [];
|
const original = (collectorThread.messages ?? []).filter((message) => (
|
||||||
|
message.type !== "human" || !isCollectAfterPlanPrompt(extractContentFromMessage(message))
|
||||||
|
));
|
||||||
const base = [...original];
|
const base = [...original];
|
||||||
const followUps = messages.data ?? [];
|
const followUps = messages.data ?? [];
|
||||||
const sessionId = session?.id ?? "draft";
|
const sessionId = session?.id ?? "draft";
|
||||||
@@ -1492,7 +1629,7 @@ function DeepResearchWorkbenchInner() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isAwaitingReport) pushActionCard("config");
|
if (showReportConfigCard) pushActionCard("config");
|
||||||
if (generatedReportVariants.length > 0 && session) {
|
if (generatedReportVariants.length > 0 && session) {
|
||||||
// Keep the original conversation intact, then append each independent
|
// Keep the original conversation intact, then append each independent
|
||||||
// generation in chronological order beneath it. Persisted children make
|
// generation in chronological order beneath it. Persisted children make
|
||||||
@@ -1537,14 +1674,13 @@ function DeepResearchWorkbenchInner() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (base.length === original.length) return collectorThread;
|
|
||||||
return { ...collectorThread, messages: base } as typeof collectorThread;
|
return { ...collectorThread, messages: base } as typeof collectorThread;
|
||||||
}, [
|
}, [
|
||||||
collectorThread,
|
collectorThread,
|
||||||
generatedReportVariants,
|
generatedReportVariants,
|
||||||
inlineGeneratedReport,
|
inlineGeneratedReport,
|
||||||
inlineGenerationSession,
|
inlineGenerationSession,
|
||||||
isAwaitingReport,
|
showReportConfigCard,
|
||||||
isRunning,
|
isRunning,
|
||||||
isReportVariant,
|
isReportVariant,
|
||||||
materialCount,
|
materialCount,
|
||||||
@@ -1766,6 +1902,10 @@ function DeepResearchWorkbenchInner() {
|
|||||||
const handleNewResearch = useCallback(() => {
|
const handleNewResearch = useCallback(() => {
|
||||||
followUpAbortRef.current?.abort();
|
followUpAbortRef.current?.abort();
|
||||||
followUpAbortRef.current = null;
|
followUpAbortRef.current = null;
|
||||||
|
setAdvancedOptions(DEFAULT_ADVANCED_OPTIONS);
|
||||||
|
outlinePlanKeyRef.current = "";
|
||||||
|
pendingCollectAfterPlanRef.current = "";
|
||||||
|
setActiveAgentId(COLLECTOR_AGENT_ID);
|
||||||
setSelectedId(null);
|
setSelectedId(null);
|
||||||
setDownloadTargetSession(null);
|
setDownloadTargetSession(null);
|
||||||
setInlineGeneratedReport(null);
|
setInlineGeneratedReport(null);
|
||||||
@@ -2352,6 +2492,7 @@ function DeepResearchWorkbenchInner() {
|
|||||||
} else if (session && isChatSession) {
|
} else if (session && isChatSession) {
|
||||||
if (jobWriting) composerPlaceholder = "报告撰写中;完成后可继续追问(撰写中点提交 = 停止)";
|
if (jobWriting) composerPlaceholder = "报告撰写中;完成后可继续追问(撰写中点提交 = 停止)";
|
||||||
else if (isFollowUp) composerPlaceholder = "基于当前报告继续追问,例如:哪些结论还需要补充验证?";
|
else if (isFollowUp) composerPlaceholder = "基于当前报告继续追问,例如:哪些结论还需要补充验证?";
|
||||||
|
else if (isPlanningOutline) composerPlaceholder = "正在根据课题拟定报告大纲,完成后将开始收集资料";
|
||||||
else if (collectorMessagesLength > 0) composerPlaceholder = "补充要求或新的检索方向,例如:再查一下 2025 年的数据";
|
else if (collectorMessagesLength > 0) composerPlaceholder = "补充要求或新的检索方向,例如:再查一下 2025 年的数据";
|
||||||
}
|
}
|
||||||
// streaming 状态下提交按钮变「停止」:点击 → 停止当前流(写作 job / 收集员)。
|
// streaming 状态下提交按钮变「停止」:点击 → 停止当前流(写作 job / 收集员)。
|
||||||
@@ -2693,6 +2834,30 @@ function DeepResearchWorkbenchInner() {
|
|||||||
</Select>
|
</Select>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{!advancedOptions.customOutline.trim() && (
|
||||||
|
<Select
|
||||||
|
value={outlinePlannerMode}
|
||||||
|
onValueChange={(next) => setAdvancedOptions((current) => (
|
||||||
|
current.outlinePlanner === next ? current : { ...current, outlinePlanner: normalizeOutlinePlanner(next) }
|
||||||
|
))}
|
||||||
|
disabled={Boolean(session) || Boolean(isFollowUp) || jobWriting || collectorStreaming}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="h-7 min-w-[9rem] max-w-[12rem] border border-input bg-background px-2 text-xs shadow-xs outline-none"
|
||||||
|
aria-label="提纲生成策略"
|
||||||
|
title={OUTLINE_PLANNER_OPTIONS.find((item) => item.value === outlinePlannerMode)?.hint}
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{OUTLINE_PLANNER_OPTIONS.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value} title={item.hint}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{composerClassic && imageGenerationAvailable && !isFollowUp && (
|
{composerClassic && imageGenerationAvailable && !isFollowUp && (
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ import {
|
|||||||
type ResearchMode,
|
type ResearchMode,
|
||||||
type ResearchTone,
|
type ResearchTone,
|
||||||
} from "@/strategy-components/api/deep-research";
|
} from "@/strategy-components/api/deep-research";
|
||||||
|
import {
|
||||||
|
type OutlinePlannerMode,
|
||||||
|
normalizeOutlinePlanner,
|
||||||
|
} from "./outline-plan";
|
||||||
import {
|
import {
|
||||||
DEFAULT_REPORT_STRUCTURE_MODE,
|
DEFAULT_REPORT_STRUCTURE_MODE,
|
||||||
normalizeReportStructureMode,
|
normalizeReportStructureMode,
|
||||||
@@ -76,6 +80,7 @@ export type AdvancedResearchOptions = {
|
|||||||
structureMode: ReportStructureMode;
|
structureMode: ReportStructureMode;
|
||||||
retrievalDirections: string[];
|
retrievalDirections: string[];
|
||||||
retrievalSkills: string[];
|
retrievalSkills: string[];
|
||||||
|
outlinePlanner: OutlinePlannerMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -195,8 +200,16 @@ export const PROGRESSIVE_REPORT_OUTLINE = `# 报告标题
|
|||||||
> 写作要求:除引言外,每个二级小节写 2–5 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。`;
|
> 写作要求:除引言外,每个二级小节写 2–5 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。`;
|
||||||
|
|
||||||
const CUSTOM_REPORT_STRUCTURE_VALUE = "__custom_report_structure__";
|
const CUSTOM_REPORT_STRUCTURE_VALUE = "__custom_report_structure__";
|
||||||
|
const AUTO_REPORT_STRUCTURE_VALUE = "__auto_intent_outline__";
|
||||||
const REPORT_STRUCTURE_VALUE_PREFIX = "structure:";
|
const REPORT_STRUCTURE_VALUE_PREFIX = "structure:";
|
||||||
|
|
||||||
|
function resolveSessionOutline(config?: Record<string, unknown>): string {
|
||||||
|
if (!config) return "";
|
||||||
|
if (typeof config.custom_outline === "string") return config.custom_outline;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(config, "custom_outline")) return "";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function matchingReportStructureId(
|
function matchingReportStructureId(
|
||||||
outline: string,
|
outline: string,
|
||||||
structures: Array<{ id: string; content: string }>,
|
structures: Array<{ id: string; content: string }>,
|
||||||
@@ -233,12 +246,14 @@ export function ReportStructureSelect({
|
|||||||
const selectedStructureId = matchingReportStructureId(value, structures);
|
const selectedStructureId = matchingReportStructureId(value, structures);
|
||||||
const templateValue = selectedStructureId
|
const templateValue = selectedStructureId
|
||||||
? `${REPORT_STRUCTURE_VALUE_PREFIX}${selectedStructureId}`
|
? `${REPORT_STRUCTURE_VALUE_PREFIX}${selectedStructureId}`
|
||||||
: CUSTOM_REPORT_STRUCTURE_VALUE;
|
: value.trim()
|
||||||
|
? CUSTOM_REPORT_STRUCTURE_VALUE
|
||||||
|
: AUTO_REPORT_STRUCTURE_VALUE;
|
||||||
|
|
||||||
const selectTemplate = (next: string) => {
|
const selectTemplate = (next: string) => {
|
||||||
if (next === CUSTOM_REPORT_STRUCTURE_VALUE) {
|
if (next === AUTO_REPORT_STRUCTURE_VALUE || next === CUSTOM_REPORT_STRUCTURE_VALUE) {
|
||||||
// “自定义” is an intentional blank starting point. Saved structures are
|
// Empty outline means the writer will generate a topic-specific structure
|
||||||
// always available as one-click scaffolds whenever the user wants them.
|
// after collection. “自定义” stays a blank editor starting point.
|
||||||
onChange("");
|
onChange("");
|
||||||
onStructureModeChange?.(DEFAULT_REPORT_STRUCTURE_MODE);
|
onStructureModeChange?.(DEFAULT_REPORT_STRUCTURE_MODE);
|
||||||
onRetrievalDirectionsChange?.([]);
|
onRetrievalDirectionsChange?.([]);
|
||||||
@@ -272,6 +287,7 @@ export function ReportStructureSelect({
|
|||||||
{structuresQuery.isError && (
|
{structuresQuery.isError && (
|
||||||
<SelectItem value="__report_structures_error__" disabled>报告结构库加载失败</SelectItem>
|
<SelectItem value="__report_structures_error__" disabled>报告结构库加载失败</SelectItem>
|
||||||
)}
|
)}
|
||||||
|
<SelectItem value={AUTO_REPORT_STRUCTURE_VALUE}>根据课题自动生成</SelectItem>
|
||||||
{structures.map((structure) => (
|
{structures.map((structure) => (
|
||||||
<SelectItem key={structure.id} value={`${REPORT_STRUCTURE_VALUE_PREFIX}${structure.id}`}>
|
<SelectItem key={structure.id} value={`${REPORT_STRUCTURE_VALUE_PREFIX}${structure.id}`}>
|
||||||
{structure.title}
|
{structure.title}
|
||||||
@@ -283,7 +299,9 @@ export function ReportStructureSelect({
|
|||||||
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
|
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
|
||||||
{selectedStructureId
|
{selectedStructureId
|
||||||
? "已选工作流结构;可在高级配置中继续编辑章节内容。"
|
? "已选工作流结构;可在高级配置中继续编辑章节内容。"
|
||||||
: "当前使用自定义结构;可在高级配置中编辑章节内容。"}
|
: value.trim()
|
||||||
|
? "当前为课题规划/自定义结构,可在下方直接改章节。"
|
||||||
|
: "未选用预设结构时,可在输入区选择提纲预编制或成文结构生成。"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -397,10 +415,11 @@ export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
|
|||||||
maxRevisionLoops: "2",
|
maxRevisionLoops: "2",
|
||||||
maxGeneratedImages: "2",
|
maxGeneratedImages: "2",
|
||||||
thinkingEnabled: false,
|
thinkingEnabled: false,
|
||||||
customOutline: DEFAULT_REPORT_OUTLINE,
|
customOutline: "",
|
||||||
structureMode: DEFAULT_REPORT_STRUCTURE_MODE,
|
structureMode: DEFAULT_REPORT_STRUCTURE_MODE,
|
||||||
retrievalDirections: [],
|
retrievalDirections: [],
|
||||||
retrievalSkills: [],
|
retrievalSkills: [],
|
||||||
|
outlinePlanner: "agent",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Seed the card's editable options from the frozen session config snapshot. */
|
/** Seed the card's editable options from the frozen session config snapshot. */
|
||||||
@@ -422,12 +441,11 @@ export function advancedOptionsFromConfig(config?: Record<string, unknown>): Adv
|
|||||||
maxRevisionLoops: numberish("max_revision_loops", DEFAULT_ADVANCED_OPTIONS.maxRevisionLoops),
|
maxRevisionLoops: numberish("max_revision_loops", DEFAULT_ADVANCED_OPTIONS.maxRevisionLoops),
|
||||||
maxGeneratedImages: numberish("max_generated_images", DEFAULT_ADVANCED_OPTIONS.maxGeneratedImages),
|
maxGeneratedImages: numberish("max_generated_images", DEFAULT_ADVANCED_OPTIONS.maxGeneratedImages),
|
||||||
thinkingEnabled: config?.thinking_enabled === true,
|
thinkingEnabled: config?.thinking_enabled === true,
|
||||||
customOutline: typeof config?.custom_outline === "string" && config.custom_outline.trim()
|
customOutline: resolveSessionOutline(config),
|
||||||
? config.custom_outline
|
|
||||||
: DEFAULT_REPORT_OUTLINE,
|
|
||||||
structureMode: normalizeReportStructureMode(config?.structure_mode),
|
structureMode: normalizeReportStructureMode(config?.structure_mode),
|
||||||
retrievalDirections: normalizeRetrievalDirections(config?.retrieval_directions),
|
retrievalDirections: normalizeRetrievalDirections(config?.retrieval_directions),
|
||||||
retrievalSkills: normalizeRetrievalSkills(config?.retrieval_skills),
|
retrievalSkills: normalizeRetrievalSkills(config?.retrieval_skills),
|
||||||
|
outlinePlanner: normalizeOutlinePlanner(config?.outline_planner),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,10 +482,11 @@ export function buildReportCardConfig(
|
|||||||
config.max_revision_loops = boundedInteger(options.maxRevisionLoops, 2, 0, 5);
|
config.max_revision_loops = boundedInteger(options.maxRevisionLoops, 2, 0, 5);
|
||||||
}
|
}
|
||||||
if (generateImages) config.max_generated_images = boundedInteger(options.maxGeneratedImages, 2, 1, 4);
|
if (generateImages) config.max_generated_images = boundedInteger(options.maxGeneratedImages, 2, 1, 4);
|
||||||
if (options.customOutline.trim()) config.custom_outline = options.customOutline.trim().slice(0, 4_000);
|
config.custom_outline = options.customOutline.trim().slice(0, 4_000);
|
||||||
config.structure_mode = normalizeReportStructureMode(options.structureMode);
|
config.structure_mode = normalizeReportStructureMode(options.structureMode);
|
||||||
config.retrieval_directions = normalizeRetrievalDirections(options.retrievalDirections);
|
config.retrieval_directions = normalizeRetrievalDirections(options.retrievalDirections);
|
||||||
config.retrieval_skills = normalizeRetrievalSkills(options.retrievalSkills);
|
config.retrieval_skills = normalizeRetrievalSkills(options.retrievalSkills);
|
||||||
|
config.outline_planner = options.outlinePlanner;
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,6 +509,7 @@ export function ReportConfigCard({
|
|||||||
imageGenerationAvailable,
|
imageGenerationAvailable,
|
||||||
imageLimit,
|
imageLimit,
|
||||||
submitting,
|
submitting,
|
||||||
|
onRetryPlanOutline,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onContinueCollecting,
|
onContinueCollecting,
|
||||||
}: {
|
}: {
|
||||||
@@ -507,6 +527,7 @@ export function ReportConfigCard({
|
|||||||
imageGenerationAvailable: boolean;
|
imageGenerationAvailable: boolean;
|
||||||
imageLimit: number;
|
imageLimit: number;
|
||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
|
onRetryPlanOutline?: () => void;
|
||||||
onConfirm: (config: Record<string, unknown>, extras?: { writeWithoutMaterials?: boolean }) => void;
|
onConfirm: (config: Record<string, unknown>, extras?: { writeWithoutMaterials?: boolean }) => void;
|
||||||
onContinueCollecting: () => void;
|
onContinueCollecting: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -571,6 +592,9 @@ export function ReportConfigCard({
|
|||||||
<p className="text-sm font-semibold">资料收集完成,请确认报告写法</p>
|
<p className="text-sm font-semibold">资料收集完成,请确认报告写法</p>
|
||||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||||
下方选项沿用原研究配置并可调整;确认后将基于以上对话收集到的资料撰写报告。
|
下方选项沿用原研究配置并可调整;确认后将基于以上对话收集到的资料撰写报告。
|
||||||
|
{options.customOutline.trim()
|
||||||
|
? "报告结构已由收集员在对话中规划并填入下方,可继续增删改章节后再撰写。"
|
||||||
|
: "可在此填写或改章节;确认后按此结构写作。"}
|
||||||
{modelLabel ? <>写作模型:<span className="font-medium text-foreground">{modelLabel}</span>。</> : null}
|
{modelLabel ? <>写作模型:<span className="font-medium text-foreground">{modelLabel}</span>。</> : null}
|
||||||
</p>
|
</p>
|
||||||
<div className={`mt-2 inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] ${writeWithoutMaterials ? "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-200" : "border-primary/15 bg-primary/[0.06] text-primary"}`}>
|
<div className={`mt-2 inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] ${writeWithoutMaterials ? "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-200" : "border-primary/15 bg-primary/[0.06] text-primary"}`}>
|
||||||
@@ -624,6 +648,35 @@ export function ReportConfigCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-medium">报告结构(可编辑)</span>
|
||||||
|
{onRetryPlanOutline ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[11px] text-primary hover:underline disabled:opacity-50"
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={onRetryPlanOutline}
|
||||||
|
>
|
||||||
|
重新规划
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Textarea
|
||||||
|
id="report-card-outline-main"
|
||||||
|
value={options.customOutline}
|
||||||
|
onChange={(event) => update("customOutline", event.target.value)}
|
||||||
|
maxLength={4000}
|
||||||
|
className="min-h-32 resize-y font-mono text-xs"
|
||||||
|
placeholder={"例如:\n# 引言\n# 一、问题与事实\n## 1.1 范围与依据\n# 结论"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
|
||||||
|
支持 Markdown 标题。规划结果可增删改章节,确认撰写后按此结构写作。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="mt-3 flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4">
|
<label className="mt-3 flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -682,18 +735,9 @@ export function ReportConfigCard({
|
|||||||
{mode === "multi_agent" && (
|
{mode === "multi_agent" && (
|
||||||
<CardNumber label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(v) => update("maxRevisionLoops", v)} />
|
<CardNumber label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(v) => update("maxRevisionLoops", v)} />
|
||||||
)}
|
)}
|
||||||
<div className="sm:col-span-2">
|
<p className="text-[11px] leading-4 text-muted-foreground sm:col-span-2">
|
||||||
<ReportOutlineEditor
|
报告结构正文已在上方表单,可继续选用工作流模板覆盖,或直接改章节标题。
|
||||||
id="report-card-outline"
|
</p>
|
||||||
value={options.customOutline}
|
|
||||||
onChange={(value) => update("customOutline", value)}
|
|
||||||
structureMode={options.structureMode}
|
|
||||||
onStructureModeChange={(mode) => update("structureMode", mode)}
|
|
||||||
onRetrievalDirectionsChange={(directions) => update("retrievalDirections", directions)}
|
|
||||||
onRetrievalSkillsChange={(skills) => update("retrievalSkills", skills)}
|
|
||||||
showStructureSelect={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end sm:col-span-2">
|
<div className="flex justify-end sm:col-span-2">
|
||||||
<WorkflowConfigButton
|
<WorkflowConfigButton
|
||||||
appliedContent={options.customOutline}
|
appliedContent={options.customOutline}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import {
|
|||||||
MAX_RETRIEVAL_DIRECTIONS,
|
MAX_RETRIEVAL_DIRECTIONS,
|
||||||
MAX_RETRIEVAL_DIRECTION_CHARS,
|
MAX_RETRIEVAL_DIRECTION_CHARS,
|
||||||
MAX_RETRIEVAL_SKILLS,
|
MAX_RETRIEVAL_SKILLS,
|
||||||
|
DEFAULT_REPORT_STRUCTURE_MODE,
|
||||||
REPORT_STRUCTURE_MODE_OPTIONS,
|
REPORT_STRUCTURE_MODE_OPTIONS,
|
||||||
normalizeReportStructureMode,
|
normalizeReportStructureMode,
|
||||||
normalizeRetrievalDirections,
|
normalizeRetrievalDirections,
|
||||||
@@ -175,10 +176,12 @@ export function WorkflowConfigDialog({
|
|||||||
appliedContent,
|
appliedContent,
|
||||||
/** 点击卡片选择结构时回填内容与结构约束。 */
|
/** 点击卡片选择结构时回填内容与结构约束。 */
|
||||||
onApply,
|
onApply,
|
||||||
|
inlineCreate = false,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
appliedContent?: string;
|
appliedContent?: string;
|
||||||
|
inlineCreate?: boolean;
|
||||||
onApply?: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
|
onApply?: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const [statusFilter, setStatusFilter] = useState<ReportStructureStatusFilter>("all");
|
const [statusFilter, setStatusFilter] = useState<ReportStructureStatusFilter>("all");
|
||||||
@@ -235,11 +238,21 @@ export function WorkflowConfigDialog({
|
|||||||
[detail, detailOutlineTitle],
|
[detail, detailOutlineTitle],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
onApply?.("", DEFAULT_REPORT_STRUCTURE_MODE, [], []);
|
||||||
|
toast.success("已取消选用报告结构,撰写时将根据课题自动生成大纲");
|
||||||
|
};
|
||||||
|
|
||||||
const handleSelect = (structure: ReportStructure) => {
|
const handleSelect = (structure: ReportStructure) => {
|
||||||
if (!structure.enabled) {
|
if (!structure.enabled) {
|
||||||
toast.error(`「${structure.title}」已停用,请先启用后再选用`);
|
toast.error(`「${structure.title}」已停用,请先启用后再选用`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 再次点击当前选用的卡片即取消勾选。
|
||||||
|
if (structure.id === appliedStructureId) {
|
||||||
|
handleClear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 点击卡片只做「选择」:内容回填后留在列表视图(卡片高亮为当前使用),
|
// 点击卡片只做「选择」:内容回填后留在列表视图(卡片高亮为当前使用),
|
||||||
// 详情视图通过卡片上的「查看大纲结构」眼睛图标进入。
|
// 详情视图通过卡片上的「查看大纲结构」眼睛图标进入。
|
||||||
onApply?.(structure.content, structure.structureMode, structure.retrievalDirections, structure.retrievalSkills);
|
onApply?.(structure.content, structure.structureMode, structure.retrievalDirections, structure.retrievalSkills);
|
||||||
@@ -328,7 +341,7 @@ export function WorkflowConfigDialog({
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400 sm:text-[13px]">
|
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400 sm:text-[13px]">
|
||||||
点击卡片选择报告结构(自动填充到报告结构配置);点击卡片上的眼睛图标查看大纲详情;可启停、编辑、删除。
|
点击卡片选择报告结构(自动填充到报告结构配置);再次点击已选用的卡片或点「取消选用」可取消勾选,撰写时将按课题自动生成大纲。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -400,6 +413,11 @@ export function WorkflowConfigDialog({
|
|||||||
<Button
|
<Button
|
||||||
className="h-10 gap-1.5 rounded-xl bg-[#F5222D] px-4 text-xs font-semibold text-white shadow-xs transition-all duration-150 hover:bg-[#BC0D1E] hover:shadow active:bg-[#A8071A] dark:bg-[#1677ff] dark:hover:bg-[#4096ff] dark:active:bg-[#0958d9] sm:text-sm"
|
className="h-10 gap-1.5 rounded-xl bg-[#F5222D] px-4 text-xs font-semibold text-white shadow-xs transition-all duration-150 hover:bg-[#BC0D1E] hover:shadow active:bg-[#A8071A] dark:bg-[#1677ff] dark:hover:bg-[#4096ff] dark:active:bg-[#0958d9] sm:text-sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (inlineCreate) {
|
||||||
|
setEditorTarget(null);
|
||||||
|
setEditorOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
const base = location.pathname.replace(/\/+$/, "");
|
const base = location.pathname.replace(/\/+$/, "");
|
||||||
navigate(`${base}/structures/new`);
|
navigate(`${base}/structures/new`);
|
||||||
@@ -543,17 +561,28 @@ export function WorkflowConfigDialog({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-slate-400">未选择</span>
|
<span className="text-slate-400">未选择,撰写时按课题自动生成</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
variant="ghost"
|
{appliedStructureId ? (
|
||||||
className="h-9 rounded-xl px-4 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-[#1c2d4a]"
|
<Button
|
||||||
onClick={() => onOpenChange(false)}
|
variant="ghost"
|
||||||
>
|
className="h-9 rounded-xl px-4 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-[#1c2d4a]"
|
||||||
完成
|
onClick={handleClear}
|
||||||
</Button>
|
>
|
||||||
|
取消选用
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 rounded-xl px-4 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-[#1c2d4a]"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
完成
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -1192,6 +1221,7 @@ export function WorkflowConfigButton({
|
|||||||
variant = "outline",
|
variant = "outline",
|
||||||
size = "sm",
|
size = "sm",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
inlineCreate = false,
|
||||||
}: {
|
}: {
|
||||||
appliedContent?: string;
|
appliedContent?: string;
|
||||||
onApply: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
|
onApply: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
|
||||||
@@ -1199,6 +1229,7 @@ export function WorkflowConfigButton({
|
|||||||
variant?: React.ComponentProps<typeof Button>["variant"];
|
variant?: React.ComponentProps<typeof Button>["variant"];
|
||||||
size?: React.ComponentProps<typeof Button>["size"];
|
size?: React.ComponentProps<typeof Button>["size"];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
inlineCreate?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -1221,22 +1252,39 @@ export function WorkflowConfigButton({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<div className={cn("inline-flex items-center", isSelected && "rounded-md bg-accent text-accent-foreground dark:bg-accent/50")}>
|
||||||
aria-label={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
|
<Button
|
||||||
className={cn(
|
type="button"
|
||||||
className,
|
aria-label={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
|
||||||
isSelected && "bg-accent text-accent-foreground dark:bg-accent/50",
|
className={cn(className, isSelected && "bg-transparent hover:bg-transparent")}
|
||||||
)}
|
disabled={disabled}
|
||||||
disabled={disabled}
|
onClick={() => setOpen(true)}
|
||||||
onClick={() => setOpen(true)}
|
size={size}
|
||||||
size={size}
|
title={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
|
||||||
title={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
|
variant={variant}
|
||||||
variant={variant}
|
>
|
||||||
>
|
<Workflow className="mr-1.5 h-3.5 w-3.5" />
|
||||||
<Workflow className="mr-1.5 h-3.5 w-3.5" />
|
<span className="max-w-40 truncate">{buttonLabel}</span>
|
||||||
<span className="max-w-40 truncate">{buttonLabel}</span>
|
</Button>
|
||||||
</Button>
|
{isSelected ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`取消选用工作流:${selectedWorkflowTitle}`}
|
||||||
|
className="mr-1 inline-flex size-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
|
||||||
|
disabled={disabled}
|
||||||
|
title="取消选用报告结构"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onApply("", DEFAULT_REPORT_STRUCTURE_MODE, [], []);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<WorkflowConfigDialog
|
<WorkflowConfigDialog
|
||||||
|
inlineCreate={inlineCreate}
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
appliedContent={appliedContent}
|
appliedContent={appliedContent}
|
||||||
|
|||||||
Loaded 100 of 370 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user