Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19d3cb96dc | ||
|
|
e243ec3a46 | ||
|
|
f4c7d41a60 | ||
|
|
d765afe2ac |
No files matched your search
+7
-14
@@ -88,28 +88,25 @@ skills/
|
||||
hermes-agent-main/
|
||||
.gitignore
|
||||
offline-backend-20260512/backend/tests/*
|
||||
# Keep wx common-agent category configuration regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_wx_common_agents.py
|
||||
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_gauss_database.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py
|
||||
!offline-backend-20260512/backend/tests/test_knowledge_live_embedding.py
|
||||
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
||||
# Keep formal Word OOXML and embedded-font regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_word_export.py
|
||||
# Keep generated-file library aggregation and filtering regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_artifact_library.py
|
||||
# Keep Web App site artifact (multi-page site) regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_web_app_sites.py
|
||||
# Keep the model configuration regression coverage versioned even though
|
||||
# this deployment repository otherwise ignores newly added backend tests.
|
||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
||||
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
|
||||
# Keep strategy-selection built-in agent seed and safety-contract regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_strategy_selection_seed.py
|
||||
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
||||
# Keep vLLM reasoning/reasoning_content field compatibility regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_reasoning_fields.py
|
||||
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
|
||||
# Keep strategy-selection built-in agent seed and safety-contract regressions versioned.
|
||||
!offline-backend-20260512/backend/tests/test_strategy_selection_seed.py
|
||||
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_fixed_questions.py
|
||||
# Keep configurable es_query system-prompt routing regression coverage versioned.
|
||||
!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py
|
||||
@@ -148,10 +145,6 @@ offline-backend-20260512/backend/tests/*
|
||||
!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py
|
||||
# LLM call timeout retry classification regression coverage.
|
||||
!offline-backend-20260512/backend/tests/test_llm_error_handling_middleware.py
|
||||
# Adaptive context recovery, large-payload and real-adapter streaming regressions.
|
||||
!offline-backend-20260512/backend/tests/test_context_recovery.py
|
||||
!offline-backend-20260512/backend/tests/test_context_recovery_transport.py
|
||||
!offline-backend-20260512/backend/tests/test_summarization_multiturn.py
|
||||
# Writing-mode pipeline conversation-material regressions.
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_report_tool.py
|
||||
# Report collaboration (RC-BE-000–018) regression coverage.
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
# 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,8 +105,6 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **agentfx 任务深链(新系统 iframe 嵌入的智能体问答,3qfx 单智能体 lite 模式的独立复制)**: goPath=`agentfx`(`${loginBase}?password=…&taskId=…&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue`)。与 3qfx 的差异:①任务详情不走外部 consumer,改调**本系统 Gateway** `GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=`(`apiFetch` 带刚登录的 DeerFlow 态;任务数据来自 TaskCOP 任务表,与任务列表/报告导入同一份);②**固定使用内置智能体** `agentfx-analyst`(不走业务映射);③路由 `task-management/plan-task-agentfx/agent/:agent_id/chats`,页面为独立复制的 `AgentFxWorkspace.tsx`(`AgentFxWorkspace`/`AgentFxIndexResolver`/`AgentFxChat` —— 左「当前任务」卡 + 1:N 对话列表 + 新建对话,`AgentChatPage taskBind` 自动发开场白;无 TaskJumpBar/清除记录/闸门/xdfx 行动卡)。模块 `core/auth/agentfx-deeplink.ts`(`isAgentfxGoPath`/`fetchAgentfxTaskDetail`/`resolveAgentfxDeeplinkTarget`);任务详情整包仍存共享键 `login.copTaskDetail`(按标签页隔离),`AgentChatPage` 的 taskBind 自恢复对 agentfx 生效。`LoginPage` 在既有深链分支前用 `isAgentfxGoPath` 分流。**内置「任务研判报告助手」**:后端 seeder `app/gateway/routers/_agentfx_seed.py` + `_agentfx_seed_assets/`(config.yaml + SOUL.md,目录存在不覆盖;app.py lifespan 在 `_sync_legacy_agents` 之前调用,自动进 agents 表 user_id IS NULL)——SOUL 规定工作流「搜集信息(`web_search`,**首步不可跳过**)→ 每次检索 JSON 原样落盘 `/mnt/user-data/workspace/hits/qN.json` → **一条命令** `task-report-build`(group `all`)技能函数转换成 14 类 `task-reports.json`(模型禁止手写该 JSON;分页技能 `task-report-{enemy,our,env,judge}` 仅用于事后补跑单页;五者都是共享引擎 `task-report-lib/convert_lib.py` 的瘦 CLI,`resolve_hit_files` 对虚拟绝对路径读空的情况自动回退到相对写法并在 `warnings` 里列出尝试过的路径,免得智能体自己写脚本排查)→ `write_file` 生成 `/mnt/user-data/outputs/report.md` → 聊天总结 → 引导入库」,硬性约束:categoryType 逐字用 14 个全称(禁缩写自造如「DQ关键事件」)、JSON 直接写汉字禁 `\uXXXX` 转义、每类必须有实质内容不许空数组/纯占位、文件一律用 `/mnt/user-data/...` 虚拟路径(防宿主路径泄漏进 tool args)。**检索依赖 `config.yaml tools.web_search`(DDG,enabled 已开;内网需换 configurable_search 时改回并填 endpoint)**。任务工作区输入框在 `taskCtxOn` 时隐藏「写作/记忆/参考文献」(InputBox `hideContextTools` prop)与「知识空间」(不传 knowledgeSelector);消息流里 write_file 文件卡片只显示文件名(`artifactDisplayName` 收敛宿主/虚拟路径)。**入库两条路径**:(a) **前端协助卡** —— `AgentChatPage`(taskBind 且非 taskGate)检测线程 artifacts 出现 `task-reports.json`(按文件名后缀匹配,兼容本地沙箱记录的宿主路径)且流式结束时,向 displayThread 尾部注入 `additional_kwargs.task_report_import_approval` 虚拟 ai 消息(`core/messages/utils.ts` 分组 `assistant:task-report-import` + `MessageList` 的 `taskReportImportSlot` 插槽渲染 `TaskReportImportCard`),点「开始入库」前端校验(`strategy-components/lib/agentfx-report-schema.ts`,14 类 + contentJson 合法 JSON + **\u 转义归一化还原为汉字**,与技能脚本同规则)后 `importTaskReport`(`strategy-components/api/task-reports.ts`)POST 导入接口整体替换;任务已有详情**不抑制**卡片(入库即整体替换,卡片文案说明覆盖语义),导入成功按 thread 记 sessionStorage(刷新不重复询问)、「暂不入库」为会话内记忆;(b) **入库技能** `task-report-import`(seeder 一并复制到 `skills/public/`,public 类技能 extensions_config 缺省即启用)—— 用户聊天里说「入库」时智能体按 SKILL.md 用 bash 跑 `scripts/task_report_import.py`(纯标准库:同样的校验规则 + 转义归一化;**默认关闭 SSL 证书校验** `ssl._create_unverified_context` + 忽略代理,适配内网自签证书;鉴权 `--token`/env `AGENTFX_IMPORT_TOKEN`→`DEERFLOW_AUTH_TOKEN`→`TOOLS_TOKEN` 优先,缺省经公共端点 `POST /api/parallel-agents/auth/token` 以 `agentfx-import` 用户现签 token——内网既有鉴权旁路;gateway 地址 env `AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:{DEER_FLOW_GATEWAY_PORT}`);`--strict` 可要求 14 类齐全,默认允许部分导入并在 stdout JSON 的 `missing_categories` 里列缺。入库成功后任务 TaskCOP 状态自动置 25(已完成分析)。**入库成功通知宿主**(iframe 嵌入场景):两条路径成功后都会 `window.top.postMessage({source: "magent-web", type: "Line truncated
|
||||
- **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。
|
||||
|
||||
- **Web App 产物模式(多页面站点 / 流程图 / 地图,默认关闭、全链路可降级)**: 让智能体产出**完整站点**而非单个 xxx.html。沙箱技能 `skills/public/web-app` 用模板(`backend/templates/web-app-template`,经 `config.yaml sandbox.mounts` 只读挂载到 `/mnt/web-app-template`,目录缺失自动跳过)脚手架到 `outputs/<site>/`(site.json 清单 + `src/pages/*.tsx` 文件名即路由 + `public/` 数据),再跑 `scripts/build_site.py build` 用**预置 node_modules**(symlink/junction 链接,离线永不 npm install)执行 vite build 出 `dist/`;内置组件 `FlowCanvas`(@xyflow/react)/`GeoMap`(MapLibre,默认无底图 GeoJSON 模式,site.json 可配 tilesUrl/PMTiles)/`ChartPanel`(ECharts)/`MermaidBlock`。平台侧 `app/gateway/routers/web_app_sites.py` + 纯引擎 `deerflow/webapp/`(发现/子进程构建/HMAC 签名):`GET /api/web-app/capability`(enabled/模板/依赖/node 四项探测)、`GET|POST /api/threads/{tid}/sites[...]{build,build-status,preview-url}`,静态托管走 `GET /api/public/site-preview/{token}/{path}`(`/api/public/` 免鉴权命名空间,**唯一允许 HTML inline 的出口**——签名令牌 + dist/ 根限定 + 按请求真实 origin 显式声明的 CSP(iframe sandbox 不透明 origin 下 'self' 失效)+ nosniff;artifacts 接口既有的强制 attachment 不变)。前端:**独立风格**预览工作台 `pages/site-studio/SiteStudioPage.tsx`(顶层路由 `#/sites/:threadId/:siteName`,`App.tsx` 注册,不进 PageLayout/WorkspaceLayout;自带 site-studio.css 深色设计体系,零全局样式污染;iframe `sandbox="allow-scripts allow-forms allow-popups allow-modals"` 无 allow-same-origin)+ 产物面板 `artifact-file-detail.tsx` 里 site.json 的「站点预览」按钮(capability 可用才渲染,唯一既有文件改动)。**降级铁律**:默认**完全关闭**(`sandbox.mounts` 的模板挂载条目与文件末尾的 `web_app:` 段在 config.yaml 里**均为注释状态**);开启需**同时放开两处**(挂载让沙箱技能可探测 `/mnt/web-app-template`,`web_app.enabled: true` 开平台 API——只开一处会得到"生成了看不了"或"看不了也不生成"的中间态)。`build_site.py check` 在宿主侧会读 config.yaml 的 `web_app.enabled`(env `DEERFLOW_WEBAPP_ENABLED`/`DEERFLOW_WEBAPP_CONFIG` 可覆盖;沙箱内读不到配置时"挂载即视为开")。任何一环(配置/模板/node_modules/node)缺失 → capability 不可用 + 技能第 0 步探测失败即**回退单页 HTML 模式** + 入口按钮不渲染 + 构建失败只收进任务状态,现有功能零影响。配置 `web_app:`(enabled/template_dir/build_timeout_seconds/preview_token_ttl_seconds/node_path);测试 `tests/test_web_app_sites.py`;开发机 `make webapp-template` 预置依赖,离线发版由 deerflow-offline-deployment 仓库在 CI 预置 node_modules 进镜像。
|
||||
|
||||
### Configuration
|
||||
|
||||
Backend config lives in `offline-backend-20260512/backend/config.yaml`. Values starting with `$` resolve as environment variables (e.g., `$OPENAI_API_KEY`). MCP servers and skills are configured in `extensions_config.json` in the same directory.
|
||||
|
||||
@@ -81,8 +81,6 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
|
||||
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
|
||||
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
|
||||
- **普通问答地图可视化**: shared `MarkdownContent` recognizes only dedicated `map-json` fences and otherwise preserves the legacy Markdown/Streamdown path byte-for-byte. `components/workspace/visualizations/` validates the bounded map contract with Zod, lazy-loads MapLibre, and provides loading/error/text fallbacks so malformed or unreachable maps never blank the message. The lead-agent prompt emits `map-json` only when the user explicitly asks for a map; flowcharts and other graphics continue through the existing Markdown/Mermaid path. The raster tile template comes from the single post-build setting `runtime-config.js` → `VITE_MAP_TILE_URL` (Google by default); replace it with the intranet `{z}/{x}/{y}` endpoint for disconnected deployments.
|
||||
- **HTML 成品离线绘图运行时**: 新生成的 `.html` 源码直接引用 Gateway `/api/public/html-runtime/` 下的浏览器版 `g6.min.js`、`echarts.min.js`、`leaflet.js/css` 和 `deerflow-runtime.js`;文件随后台源码存放于 `vendor/html-runtime/`,不再依赖前端 Vite/node_modules 产物或公网 CDN。`config.yaml -> html_runtime.base_url` 是写入新 HTML 的浏览器可访问绝对地址,`html_runtime.map_tile_url` 是初始化脚本读取的瓦片模板。流程图使用 `data-df-flow` + G6(不用 Mermaid),图表使用 `data-df-chart`,地图使用 Leaflet `data-df-map`。普通问答保持独立:消息列表 `map-json` 继续由前端原逻辑渲染,普通流程图/图表仍走 Markdown/Mermaid。
|
||||
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API.
|
||||
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
|
||||
- **策略优选**: route `/page/strategy/qa/strategy-selection`, sidebar 「问答管理 → 策略优选」. The standalone `src/strategy-selection/` workbench has two horizontal split stages: Step 1 keeps intent-understanding conversation cards on the left and an editable task draft plus angle selection on the right; Step 2 keeps multiplexed parallel strategy cards and automatic judging on the left while the right stays dedicated to the selected-strategy basket and final Markdown synthesis. It consumes the existing `POST /api/parallel-agents/run/stream` protocol, isolates output by stable agent id, supports expanded per-agent dialogs, user selection, Markdown editing/copy/download, and shared server-side DOCX export. A clearly marked neutral Taiwan-Strait risk-management demo is loaded by default for offline UI acceptance. Administrators can edit the SOUL and skills of the nine built-in functional agents through the page dialog; the backend seeds `strategy-selection-{intent,angle-*,judge,solution}` only when their directories are missing, preserving later admin changes. Pure parsing/SSE tests live in `src/strategy-selection/lib.test.ts`; backend seed tests live in `tests/test_strategy_selection_seed.py`.
|
||||
|
||||
Binary file not shown.
@@ -1,31 +0,0 @@
|
||||
# 日常使用复查(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`。本轮不重复付费生成完整方案;没有进行生产全量语料质量评估、负载测试、断网故障注入或完整无障碍合规审计。已检查可见导航、表单名称、窄屏显示和主要失败恢复,不能据此承诺所有模块没有缺陷。代码未提交、推送或部署。
|
||||
@@ -1,37 +0,0 @@
|
||||
# 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 保持不变。未提交、推送代码,也未执行离线发布。
|
||||
@@ -59,6 +59,9 @@
|
||||
# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。
|
||||
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。
|
||||
# VITE_COMPACT_MENU_TYPE=A
|
||||
# 问答页「极速」模式:true=在模式菜单显示并直连大模型;false 或未设=隐藏。
|
||||
# 部署后优先改 dist/runtime-config.js 的 VITE_TURBO_MODE_VISIBLE,刷新即可。
|
||||
# VITE_TURBO_MODE_VISIBLE=false
|
||||
# VITE_GITHUB_OAUTH_TOKEN=
|
||||
|
||||
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# 项目空间
|
||||
|
||||
入口:全量与简洁导航中的「项目空间」,路由 `/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
+350
-355
@@ -28,10 +28,10 @@
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "1.2.18",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "1.3.3",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
@@ -69,9 +69,7 @@
|
||||
"gsap": "^3.13.0",
|
||||
"hast": "^1.0.0",
|
||||
"katex": "^0.16.28",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"maplibre-gl": "^6.9.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"motion": "^12.26.2",
|
||||
"nanoid": "^5.1.6",
|
||||
@@ -1704,92 +1702,6 @@
|
||||
"@lezer/lr": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
|
||||
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
|
||||
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
|
||||
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/geojson-vt": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
|
||||
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "26.4.2",
|
||||
"resolved": "https://registry.npmmirror.com/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.2.tgz",
|
||||
"integrity": "sha512-6J0vZqMZvRAJKtdWJdDGHEh1YJ2ZHG08/GOur8gCArYhO8ZkM//OXqtI2AAEz4jc2G+Wq4MfcePg8qNK5TZ4Kg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-format": "dist/gl-style-format.mjs",
|
||||
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/mlt": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/@maplibre/mlt/-/mlt-1.2.1.tgz",
|
||||
"integrity": "sha512-5n5dgolE2EYxwCKgx8vlwURCB8A+kyfJJyThlYimjlGgOcOe2Bhw9VxxnnHC91OC9PgHg+nVdgVPTYPkIo62vg==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
|
||||
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
@@ -1875,6 +1787,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-avatar": {
|
||||
"version": "1.1.11",
|
||||
"license": "MIT",
|
||||
@@ -1964,6 +1893,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||
@@ -2025,6 +1971,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
@@ -2123,6 +2086,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||
@@ -2185,6 +2165,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu": {
|
||||
"version": "2.1.16",
|
||||
"license": "MIT",
|
||||
@@ -2248,6 +2245,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
||||
@@ -2308,6 +2322,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card": {
|
||||
"version": "1.1.15",
|
||||
"license": "MIT",
|
||||
@@ -2373,6 +2404,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-icons": {
|
||||
"version": "1.3.2",
|
||||
"license": "MIT",
|
||||
@@ -2472,6 +2520,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||
@@ -2539,6 +2604,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
||||
@@ -2584,6 +2666,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||
@@ -2717,21 +2816,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area": {
|
||||
"version": "1.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz",
|
||||
"integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==",
|
||||
"version": "1.2.10",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.3",
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-context": "1.2.2",
|
||||
"@radix-ui/react-direction": "1.1.4",
|
||||
"@radix-ui/react-presence": "1.1.10",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.4",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -2748,38 +2862,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/number": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz",
|
||||
"integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
|
||||
"integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz",
|
||||
"integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==",
|
||||
"license": "MIT",
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
@@ -2790,51 +2876,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz",
|
||||
"integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz",
|
||||
"integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"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-primitive": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz",
|
||||
"integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==",
|
||||
"license": "MIT",
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.3.3"
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -2851,26 +2898,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
|
||||
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
@@ -2958,6 +2992,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator": {
|
||||
"version": "1.1.8",
|
||||
"license": "MIT",
|
||||
@@ -2980,12 +3031,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"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==",
|
||||
"version": "1.2.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5"
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -2997,21 +3046,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch": {
|
||||
"version": "1.2.6",
|
||||
"license": "MIT",
|
||||
@@ -3075,6 +3109,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs": {
|
||||
"version": "1.1.13",
|
||||
"license": "MIT",
|
||||
@@ -3139,6 +3190,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle": {
|
||||
"version": "1.1.10",
|
||||
"license": "MIT",
|
||||
@@ -3225,6 +3293,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
@@ -3247,6 +3332,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.2.8",
|
||||
"license": "MIT",
|
||||
@@ -3315,6 +3417,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
@@ -3503,6 +3622,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||
@@ -5421,15 +5557,6 @@
|
||||
"version": "1.2.1",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.1.0.tgz",
|
||||
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||
@@ -6332,12 +6459,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/earcut/-/earcut-3.2.3.tgz",
|
||||
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.349",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
|
||||
@@ -6732,12 +6853,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmmirror.com/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -7236,12 +7351,6 @@
|
||||
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
@@ -7281,12 +7390,6 @@
|
||||
"katex": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/kdbush": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/kdbush/-/kdbush-4.1.0.tgz",
|
||||
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/khroma": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
|
||||
@@ -7329,12 +7432,6 @@
|
||||
"resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz",
|
||||
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmmirror.com/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
@@ -7669,39 +7766,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "6.9.0",
|
||||
"resolved": "https://registry.npmmirror.com/maplibre-gl/-/maplibre-gl-6.9.0.tgz",
|
||||
"integrity": "sha512-vFMwMK0Zs+NM/rOMSdtu8bO30DIexhBEVi5KC6f70/XtI+L/K2wC3LsDCAXFZ4s8ik5gAuDugfCNbpllpdJ9bA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.2.0",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"@maplibre/geojson-vt": "^6.1.1",
|
||||
"@maplibre/maplibre-gl-style-spec": "^26.4.2",
|
||||
"@maplibre/mlt": "^1.2.1",
|
||||
"@maplibre/vt-pbf": "^4.3.2",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"bidi-js": "^1.1.0",
|
||||
"earcut": "^3.2.3",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"kdbush": "^4.1.0",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^5.1.2",
|
||||
"potpack": "^2.1.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14.0",
|
||||
"npm": ">=8.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.1",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||
@@ -8649,15 +8713,6 @@
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
@@ -8716,12 +8771,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/murmurhash-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mustache": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
|
||||
@@ -8894,18 +8943,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/pbf/-/pbf-5.1.2.tgz",
|
||||
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
@@ -8998,12 +9035,6 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/potpack/-/potpack-2.1.0.tgz",
|
||||
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/prismjs": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||
@@ -9187,12 +9218,6 @@
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmmirror.com/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
@@ -9210,12 +9235,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": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
@@ -9599,24 +9618,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||
@@ -9970,12 +9971,6 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyqueue": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tiptap-markdown": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz",
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:input-mode": "node --experimental-strip-types --test src/components/workspace/input-mode.test.ts",
|
||||
"test:html-preview": "pnpm exec esbuild src/core/utils/legacy-html-preview.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/legacy-html-preview.test.mjs && node --test .runtime/unit-tests/legacy-html-preview.test.mjs",
|
||||
"test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts",
|
||||
"test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
|
||||
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
|
||||
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
|
||||
@@ -39,10 +36,10 @@
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "1.2.18",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "1.3.3",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
@@ -80,9 +77,7 @@
|
||||
"gsap": "^3.13.0",
|
||||
"hast": "^1.0.0",
|
||||
"katex": "^0.16.28",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"maplibre-gl": "^6.9.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"motion": "^12.26.2",
|
||||
"nanoid": "^5.1.6",
|
||||
@@ -126,8 +121,5 @@
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-source-identifier": "^1.1.2"
|
||||
},
|
||||
"overrides": {
|
||||
"@radix-ui/react-slot": "1.3.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.26.2"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# Keep indirect Radix primitives on the React 19 ref-stability fix too.
|
||||
overrides:
|
||||
'@radix-ui/react-slot': 1.3.3
|
||||
@@ -40,10 +40,6 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
|
||||
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
|
||||
|
||||
// 地图瓦片地址模板:普通问答地图和生成的 HTML 网页地图共同使用。
|
||||
// 后续接入内网瓦片服务时,只需替换此项并保留 {z}/{x}/{y} 占位符。
|
||||
VITE_MAP_TILE_URL: "http://119.254.86.251:8023/tiles/{z}/{x}/{y}.png?v=zh-z14-13",
|
||||
|
||||
// routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。
|
||||
// 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。
|
||||
VITE_ROUTE_LOGIN_PASSWORD: "",
|
||||
@@ -155,6 +151,11 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
|
||||
VITE_GITHUB_OAUTH_TOKEN: "",
|
||||
|
||||
// 是否在问答输入框「模式」菜单显示「极速」:"true" 显示,"false" 隐藏。
|
||||
// 默认关闭。开启后可直连大模型流式问答,不走智能体工具与技能。
|
||||
// 部署后改 dist/runtime-config.js 再刷新页面即可,无需重新打包。
|
||||
VITE_TURBO_MODE_VISIBLE: "true",
|
||||
|
||||
// 是否在智能体管理页显示「舆情分析」类型:"true" 显示,"false" 隐藏。
|
||||
// 关闭后,该智能体的专用对话地址也会自动跳回智能体管理页。
|
||||
VITE_SENTIMENT_AGENT_VISIBLE: "true",
|
||||
|
||||
@@ -36,7 +36,6 @@ import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
|
||||
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
|
||||
import EmbedChatPage from "./pages/EmbedChatPage";
|
||||
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
||||
import SiteStudioPage from "./pages/site-studio/SiteStudioPage";
|
||||
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
|
||||
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
||||
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
|
||||
@@ -206,8 +205,6 @@ export function App() {
|
||||
element={<DashboardStandalonePage />}
|
||||
/>
|
||||
<Route path="/official/*" element={<OfficialDeerFlowEntry />} />
|
||||
{/* Web App 站点预览工作室:独立路由 + 独立视觉风格(无主应用 chrome) */}
|
||||
<Route path="/sites/:threadId/:siteName" element={<SiteStudioPage />} />
|
||||
<Route path="/page/*" element={pageElement} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -9,8 +9,8 @@ import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview";
|
||||
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`)
|
||||
* navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is
|
||||
* a real document URL, so fragment links scroll within the frame as expected —
|
||||
* The sandbox permits scripts so the preview can use the app-bundled HTML
|
||||
* visualization runtimes, while still withholding same-origin privileges.
|
||||
* all without enabling scripts (the sandbox stays script-free for security; the
|
||||
* server already sanitizes script vectors and this is defense-in-depth).
|
||||
*
|
||||
* The `charset=utf-8` on the blob type is required: a blob: URL whose
|
||||
* Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on
|
||||
@@ -26,9 +26,7 @@ export function HtmlPreviewFrame({
|
||||
const [url, setUrl] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
const blob = new Blob([
|
||||
prepareGeneratedHtml(html ?? ""),
|
||||
], {
|
||||
const blob = new Blob([prepareGeneratedHtml(html ?? "")], {
|
||||
type: "text/html;charset=utf-8",
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -20,12 +21,12 @@ export function Breadcrumb({
|
||||
)}
|
||||
>
|
||||
{segments.map((segment, i) => (
|
||||
<div className="contents" key={`${segment}-${i}`}>
|
||||
<Fragment key={`${segment}-${i}`}>
|
||||
<span className="hidden shrink-0 whitespace-nowrap sm:inline">
|
||||
{segment}
|
||||
</span>
|
||||
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
<span
|
||||
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
|
||||
|
||||
@@ -152,7 +152,6 @@ export function AgentDetailPanel({
|
||||
positionName,
|
||||
skillsIcon,
|
||||
skillsTitle,
|
||||
belowSkillsSlot,
|
||||
showCommonQuestions = false,
|
||||
jobResponsibilities,
|
||||
panelTitle,
|
||||
@@ -182,8 +181,6 @@ export function AgentDetailPanel({
|
||||
positionName?: string;
|
||||
skillsIcon?: ReactNode;
|
||||
skillsTitle?: string;
|
||||
/** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */
|
||||
belowSkillsSlot?: ReactNode;
|
||||
showCommonQuestions?: boolean;
|
||||
/**
|
||||
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
|
||||
@@ -943,9 +940,6 @@ export function AgentDetailPanel({
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
|
||||
{belowSkillsSlot}
|
||||
|
||||
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
|
||||
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
ExternalLinkIcon,
|
||||
EyeIcon,
|
||||
FileType2Icon,
|
||||
GlobeIcon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
SaveIcon,
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -58,9 +56,8 @@ import {
|
||||
} from "@/open-canvas/api/rewrite";
|
||||
import { publishArtifactToKnowledge } from "@/core/agents/api";
|
||||
import { useAgent } from "@/core/agents/hooks";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { copyTextToClipboard } from "@/core/clipboard";
|
||||
import { siteStudioRoute, useWebAppCapability } from "@/core/sites/api";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { copyTextToClipboard } from "@/core/clipboard";
|
||||
import {
|
||||
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
||||
DOCUMENT_REWRITE_STARTED_EVENT,
|
||||
@@ -212,22 +209,6 @@ function findScrollableArtifactElement(root: HTMLElement | null) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别 Web App 站点清单:`outputs/<site>/site.json`(兼容 `write-file:` 虚拟 URL
|
||||
* 与查询串)→ 返回站点目录名;其它文件返回 null(按钮不渲染,零行为变化)。
|
||||
*/
|
||||
function extractSiteStudioTarget(filepath: string): string | null {
|
||||
const normalized = filepath.replace(/^write-file:/, "").split("?")[0];
|
||||
if (!/(^|\/)site\.json$/.test(normalized)) return null;
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
const idx = parts.lastIndexOf("site.json");
|
||||
if (idx <= 0) return null;
|
||||
const siteName = parts[idx - 1];
|
||||
if (!siteName || siteName === "outputs") return null;
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(siteName)) return null;
|
||||
return siteName;
|
||||
}
|
||||
|
||||
export function ArtifactFileDetail({
|
||||
className,
|
||||
filepath: filepathFromProps,
|
||||
@@ -287,14 +268,6 @@ export function ArtifactFileDetail({
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
const { isMock, thread, publicShareCode } = useThread();
|
||||
const navigate = useNavigate();
|
||||
// Web App 站点清单(outputs/<site>/site.json)→「站点预览」入口。
|
||||
// capability 不可用(未启用/未部署)时按钮不渲染,原 UI 一字不变。
|
||||
const siteStudioTarget = useMemo(
|
||||
() => extractSiteStudioTarget(filepathFromProps),
|
||||
[filepathFromProps],
|
||||
);
|
||||
const webAppCapabilityQuery = useWebAppCapability(!!siteStudioTarget);
|
||||
const isWriteFileVirtual = useMemo(() => {
|
||||
return filepathFromProps.startsWith("write-file:");
|
||||
}, [filepathFromProps]);
|
||||
@@ -1454,9 +1427,7 @@ export function ArtifactFileDetail({
|
||||
toast.error("文件内容为空,暂时无法在新窗口打开");
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([
|
||||
prepareGeneratedHtml(source),
|
||||
], {
|
||||
const blob = new Blob([prepareGeneratedHtml(source)], {
|
||||
type: "text/html;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -1483,11 +1454,7 @@ export function ArtifactFileDetail({
|
||||
: language === "markdown"
|
||||
? "text/markdown;charset=utf-8"
|
||||
: "text/plain;charset=utf-8";
|
||||
const downloadSource =
|
||||
language === "html"
|
||||
? prepareGeneratedHtml(source)
|
||||
: source;
|
||||
const blob = new Blob([downloadSource], { type: blobType });
|
||||
const blob = new Blob([source], { type: blobType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
@@ -1680,16 +1647,6 @@ export function ArtifactFileDetail({
|
||||
onClick={handleOpenInNewWindow}
|
||||
/>
|
||||
)}
|
||||
{siteStudioTarget && webAppCapabilityQuery.data?.available && (
|
||||
<ArtifactAction
|
||||
icon={GlobeIcon}
|
||||
label="站点预览"
|
||||
tooltip="打开站点预览工作室(多页面站点 / 流程图 / 地图)"
|
||||
onClick={() =>
|
||||
navigate(siteStudioRoute(threadId, siteStudioTarget))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ArtifactAction
|
||||
icon={DownloadIcon}
|
||||
label={t.common.download}
|
||||
@@ -2137,9 +2094,7 @@ export function ArtifactFilePreview({
|
||||
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
|
||||
// Intercept in-page anchor clicks so they scroll within the iframe instead
|
||||
// of navigating the sandboxed blob: document (which blanks the frame).
|
||||
const blob = new Blob([
|
||||
prepareGeneratedHtml(content ?? ""),
|
||||
], {
|
||||
const blob = new Blob([prepareGeneratedHtml(content ?? "")], {
|
||||
type: "text/html;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
getFileExtensionDisplayName,
|
||||
getFileIcon,
|
||||
getFileName,
|
||||
isWordDocumentFile,
|
||||
} from "@/core/utils/files";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -94,12 +93,12 @@ export function ArtifactFileList({
|
||||
|
||||
return (
|
||||
<ul className={cn("flex w-full flex-col gap-4", className)}>
|
||||
{files.map((file) => (
|
||||
<Card
|
||||
key={file}
|
||||
className={cn("relative p-3", !isWordDocumentFile(file) && "cursor-pointer")}
|
||||
onClick={isWordDocumentFile(file) ? undefined : () => handleClick(file)}
|
||||
>
|
||||
{files.map((file) => (
|
||||
<Card
|
||||
key={file}
|
||||
className="relative cursor-pointer p-3"
|
||||
onClick={() => handleClick(file)}
|
||||
>
|
||||
<CardHeader className="grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 pr-2 pl-1">
|
||||
<CardTitle className="relative min-w-0 pl-8 leading-tight [overflow-wrap:anywhere] break-words">
|
||||
<div className="min-w-0">{getFileName(file)}</div>
|
||||
@@ -134,7 +133,7 @@ export function ArtifactFileList({
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Card>
|
||||
))}
|
||||
<SkillInstallDialog
|
||||
open={!!installFile}
|
||||
|
||||
@@ -19,10 +19,7 @@ import {
|
||||
listThreadArtifacts,
|
||||
mergeArtifactPaths,
|
||||
} from "@/core/artifacts/listing";
|
||||
import {
|
||||
latestInFlightWriteFilePreview,
|
||||
latestPreparedHtmlArtifact,
|
||||
} from "@/core/artifacts/write-file-in-flight";
|
||||
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
|
||||
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
@@ -38,10 +35,8 @@ import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { isWordDocumentFile } from "@/core/utils/files";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
||||
|
||||
import {
|
||||
ArtifactFileDetail,
|
||||
@@ -88,12 +83,10 @@ const ChatBox: React.FC<{
|
||||
children: React.ReactNode;
|
||||
threadId: string;
|
||||
disableArtifacts?: boolean;
|
||||
writingMode?: boolean;
|
||||
}> = ({
|
||||
children,
|
||||
threadId,
|
||||
disableArtifacts = false,
|
||||
writingMode,
|
||||
}) => {
|
||||
const {
|
||||
isMock,
|
||||
@@ -113,17 +106,15 @@ const ChatBox: React.FC<{
|
||||
const latestReferenceBatchIdRef = useRef<string | null>(null);
|
||||
const manualReferenceBatchRef = useRef(false);
|
||||
const referenceDebugSignatureRef = useRef<string>("");
|
||||
const referenceBatchesRef = useRef<BackendReferenceBatch[]>([]);
|
||||
const [sideTab, setSideTab] = useState("document");
|
||||
const [referencesOpen, setReferencesOpen] = useState(true);
|
||||
const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null);
|
||||
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||
const [wikiCitationSource, setWikiCitationSource] = useState<BackendReferenceSource | null>(null);
|
||||
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||
const [settings] = useLocalSettings();
|
||||
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
|
||||
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
|
||||
const writingModeEnabled = writingMode ?? !!settings.context.writing_mode;
|
||||
const writingModeEnabled = !!settings.context.writing_mode;
|
||||
const artifactDirectoryModeEnabled =
|
||||
settings.display.artifactDirectoryModeEnabled ?? false;
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
@@ -360,9 +351,6 @@ const ChatBox: React.FC<{
|
||||
latestReferenceBatch;
|
||||
const referenceSources = activeReferenceBatch?.sources ?? [];
|
||||
const hasCitationReferences = referenceSources.length > 0;
|
||||
useEffect(() => {
|
||||
referenceBatchesRef.current = referenceBatches;
|
||||
}, [referenceBatches]);
|
||||
useEffect(() => {
|
||||
if (!shouldFetchBackendReferenceBatches || !activeReferenceBatch) {
|
||||
return;
|
||||
@@ -408,16 +396,6 @@ const ChatBox: React.FC<{
|
||||
window.setTimeout(() => {
|
||||
setPinnedReferenceId(detail?.referenceId ?? null);
|
||||
}, 0);
|
||||
const referenceIndex = Number(detail?.referenceId);
|
||||
const batch = referenceBatchesRef.current.find((item) => item.id === detail?.batchId)
|
||||
?? referenceBatchesRef.current.at(-1);
|
||||
const source = Number.isInteger(referenceIndex) && referenceIndex > 0
|
||||
? batch?.sources.find((item) => item.index === referenceIndex)
|
||||
?? batch?.sources[referenceIndex - 1]
|
||||
: undefined;
|
||||
if (source?.skillName?.startsWith("llmwiki_") || source?.type?.startsWith("llmwiki_")) {
|
||||
setWikiCitationSource(source);
|
||||
}
|
||||
setReferencesOpen(true);
|
||||
setSideTab("references");
|
||||
};
|
||||
@@ -481,12 +459,7 @@ const ChatBox: React.FC<{
|
||||
) {
|
||||
if (thread?.values?.artifacts?.length > 0) {
|
||||
setAutoSelectFirstArtifact(false);
|
||||
const firstPreviewableArtifact = thread.values.artifacts.find(
|
||||
(artifact) => !isWordDocumentFile(artifact),
|
||||
);
|
||||
if (firstPreviewableArtifact) {
|
||||
selectArtifact(firstPreviewableArtifact);
|
||||
}
|
||||
selectArtifact(thread.values.artifacts[0]!);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -524,25 +497,6 @@ const ChatBox: React.FC<{
|
||||
setSideTab("document");
|
||||
}, [openStreamedArtifact, streamingWritePreview]);
|
||||
|
||||
// page_report_template(prepare) creates the output HTML before content
|
||||
// editing starts. Open that real artifact immediately; this also covers
|
||||
// models that use str_replace or whose write_file arguments arrive at once.
|
||||
const preparedHtmlPreview = useMemo(
|
||||
() =>
|
||||
disableArtifacts || !thread.isLoading
|
||||
? null
|
||||
: latestPreparedHtmlArtifact(thread.messages ?? []),
|
||||
[disableArtifacts, thread.isLoading, thread.messages],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!preparedHtmlPreview) return;
|
||||
openStreamedArtifact(
|
||||
preparedHtmlPreview.artifactPath,
|
||||
preparedHtmlPreview.previewKey,
|
||||
);
|
||||
setSideTab("document");
|
||||
}, [openStreamedArtifact, preparedHtmlPreview]);
|
||||
|
||||
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
|
||||
useEffect(() => {
|
||||
const wasEnabled = directoryModeWasEnabledRef.current;
|
||||
@@ -550,8 +504,7 @@ const ChatBox: React.FC<{
|
||||
if (
|
||||
wasEnabled ||
|
||||
!artifactDirectoryModeEnabled ||
|
||||
availableArtifacts.length === 0 ||
|
||||
isWordDocumentFile(availableArtifacts[availableArtifacts.length - 1]!)
|
||||
availableArtifacts.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -791,17 +744,6 @@ const ChatBox: React.FC<{
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<WeKnoraSourceDrawer
|
||||
open={wikiCitationSource !== null}
|
||||
target={null}
|
||||
title={wikiCitationSource?.title || "Wiki 文档"}
|
||||
quote={wikiCitationSource?.content || wikiCitationSource?.snippet || ""}
|
||||
inlineContent={wikiCitationSource?.content || wikiCitationSource?.snippet || ""}
|
||||
inlineKnowledgeBaseId={String(wikiCitationSource?.raw?.knowledge_base_id || "")}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setWikiCitationSource(null);
|
||||
}}
|
||||
/>
|
||||
</ReferenceBatchesContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -814,23 +756,19 @@ function ReferencePanel({
|
||||
sources: Parameters<typeof SkillDisplaySources>[0]["sources"];
|
||||
}) {
|
||||
const orderedSources = useMemo(() => {
|
||||
// This is presentation-only. Keep every source's original `index`, since
|
||||
// inline citations resolve through that index (for example [3] must still
|
||||
// open the third source returned by retrieval even when its card is shown
|
||||
// below a higher-scoring result).
|
||||
return sources
|
||||
.map((source, originalOrder) => ({ source, originalOrder }))
|
||||
.sort((left, right) => {
|
||||
const leftScore = typeof left.source.score === "number" && Number.isFinite(left.source.score)
|
||||
? left.source.score
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
const rightScore = typeof right.source.score === "number" && Number.isFinite(right.source.score)
|
||||
? right.source.score
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
return rightScore - leftScore || left.originalOrder - right.originalOrder;
|
||||
})
|
||||
.map(({ source }) => source);
|
||||
}, [sources]);
|
||||
if (!highlightedReferenceId) {
|
||||
return sources;
|
||||
}
|
||||
const index = Number(highlightedReferenceId);
|
||||
if (!Number.isFinite(index)) {
|
||||
return sources;
|
||||
}
|
||||
const selected = sources.find((source) => source.index === index);
|
||||
if (!selected) {
|
||||
return sources;
|
||||
}
|
||||
return [selected, ...sources.filter((source) => source.index !== index)];
|
||||
}, [highlightedReferenceId, sources]);
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col overflow-hidden">
|
||||
|
||||
@@ -29,14 +29,13 @@ import {
|
||||
PinOff,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Share2,
|
||||
Star,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -66,7 +65,6 @@ import {
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { useAgentsPaginated, type Agent } from "@/core/agents";
|
||||
import { getAccountDisplayName } from "@/core/auth";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
@@ -94,13 +92,6 @@ import {
|
||||
} from "@/core/threads/utils";
|
||||
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import {
|
||||
usePublishSquares,
|
||||
useUpdateWxCommonAgents,
|
||||
useWxCommonAgents,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import { SENTIMENT_AGENT_ID, isSentimentAgentVisible } from "@/core/sentiment-agent/config";
|
||||
import { readSentimentAgentProfile } from "@/core/sentiment-agent/profile";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
@@ -121,149 +112,37 @@ function SectionLabel({
|
||||
count,
|
||||
collapsed,
|
||||
onToggle,
|
||||
onOpenSettings,
|
||||
}: {
|
||||
label: string;
|
||||
count?: number;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full items-center px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex min-w-0 flex-1 items-center justify-between transition-colors hover:text-muted-foreground select-none"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate">{label}</span>
|
||||
{count != null && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 transition-transform duration-200",
|
||||
collapsed && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
className="ml-1 inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground"
|
||||
aria-label="设置智能体类型"
|
||||
title="设置智能体类型"
|
||||
>
|
||||
<Settings className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span>{label}</span>
|
||||
{count != null && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 transition-transform duration-200",
|
||||
collapsed && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
} = {}) {
|
||||
export function GeneralQaSidebar() {
|
||||
const { t } = useI18n();
|
||||
const routeBase = useRouteBase();
|
||||
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
|
||||
@@ -284,60 +163,7 @@ export function GeneralQaSidebar({
|
||||
setAgentsCollapsed((v) => {
|
||||
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
|
||||
return !v;
|
||||
});
|
||||
|
||||
const [agentTypeSettingsOpen, setAgentTypeSettingsOpen] = useState(false);
|
||||
const { data: wxCommonAgentSettings } = useWxCommonAgents({
|
||||
enabled: !!wxCommonAgents,
|
||||
});
|
||||
const { data: publishSquares } = usePublishSquares({
|
||||
enabled: !!wxCommonAgents,
|
||||
});
|
||||
const isWxManager =
|
||||
wxCommonAgents &&
|
||||
getAccountDisplayName().toLowerCase() === "wx";
|
||||
const sentimentVisible = isSentimentAgentVisible();
|
||||
const wxCategoryOptions = useMemo<WxAgentTypeOption[]>(() => {
|
||||
const options: WxAgentTypeOption[] = [
|
||||
{ value: WX_AGENT_TYPE_MINE, label: "我的" },
|
||||
...(publishSquares?.squares ?? []).map((square) => ({
|
||||
value: wxSquareAgentType(square.id),
|
||||
label: square.name,
|
||||
})),
|
||||
];
|
||||
if (sentimentVisible) {
|
||||
options.push({ value: WX_AGENT_TYPE_SENTIMENT, label: "舆情分析" });
|
||||
}
|
||||
return options;
|
||||
}, [publishSquares?.squares, sentimentVisible]);
|
||||
const wxAgentTypeOptions = useMemo<WxAgentTypeOption[]>(
|
||||
() => [
|
||||
{ value: WX_AGENT_TYPE_DEFAULT, label: "默认推荐(未设置)" },
|
||||
...wxCategoryOptions,
|
||||
],
|
||||
[wxCategoryOptions],
|
||||
);
|
||||
const configuredWxAgentType = wxCommonAgentSettings?.agent_type ?? null;
|
||||
const wxUsesCategoryFilter = !!configuredWxAgentType && wxCategoryOptions.some(
|
||||
(option) => option.value === configuredWxAgentType,
|
||||
);
|
||||
const wxSquareId =
|
||||
wxCommonAgents && wxUsesCategoryFilter && configuredWxAgentType
|
||||
? wxSquareIdOf(configuredWxAgentType)
|
||||
: undefined;
|
||||
const wxShowsSentiment =
|
||||
wxCommonAgents &&
|
||||
wxUsesCategoryFilter &&
|
||||
configuredWxAgentType === WX_AGENT_TYPE_SENTIMENT;
|
||||
const [sentimentProfile, setSentimentProfile] = useState(() =>
|
||||
readSentimentAgentProfile(),
|
||||
);
|
||||
useEffect(() => {
|
||||
const refresh = () => setSentimentProfile(readSentimentAgentProfile());
|
||||
window.addEventListener("sentiment-agent-profile-changed", refresh);
|
||||
return () =>
|
||||
window.removeEventListener("sentiment-agent-profile-changed", refresh);
|
||||
}, []);
|
||||
});
|
||||
const toggleChats = () =>
|
||||
setChatsCollapsed((v) => {
|
||||
localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v));
|
||||
@@ -345,38 +171,9 @@ export function GeneralQaSidebar({
|
||||
});
|
||||
|
||||
// ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)──
|
||||
const { agents: mine } = useAgentsPaginated({
|
||||
scope: "mine",
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
enabled: !wxUsesCategoryFilter,
|
||||
});
|
||||
const { agents: square } = useAgentsPaginated({
|
||||
scope: "square",
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
enabled: !wxUsesCategoryFilter,
|
||||
});
|
||||
const { agents: wxAgents } = useAgentsPaginated({
|
||||
scope: wxSquareId ? "square" : "mine",
|
||||
page: 1,
|
||||
pageSize: MAX_FREQUENT,
|
||||
square: wxSquareId,
|
||||
enabled: wxUsesCategoryFilter && !wxShowsSentiment,
|
||||
});
|
||||
const frequent = useMemo<CommonAgent[]>(() => {
|
||||
if (wxUsesCategoryFilter) {
|
||||
if (wxShowsSentiment) {
|
||||
return [
|
||||
{
|
||||
id: SENTIMENT_AGENT_ID,
|
||||
name: sentimentProfile.name,
|
||||
description: sentimentProfile.description,
|
||||
},
|
||||
];
|
||||
}
|
||||
return wxAgents.slice(0, MAX_FREQUENT);
|
||||
}
|
||||
const { agents: mine } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
|
||||
const { agents: square } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
|
||||
const frequent = useMemo<Agent[]>(() => {
|
||||
const out: Agent[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (agent: Agent | undefined) => {
|
||||
@@ -388,7 +185,7 @@ export function GeneralQaSidebar({
|
||||
mine.forEach(add);
|
||||
square.forEach(add);
|
||||
return out;
|
||||
}, [mine, sentimentProfile, square, wxAgents, wxShowsSentiment, wxUsesCategoryFilter]);
|
||||
}, [mine, square]);
|
||||
|
||||
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
|
||||
const searchParams = useSearchParams();
|
||||
@@ -408,25 +205,16 @@ export function GeneralQaSidebar({
|
||||
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
|
||||
const handleSelectAgent = useCallback(
|
||||
(agentId: string) => {
|
||||
if (agentId === SENTIMENT_AGENT_ID) {
|
||||
navigate(
|
||||
buildFullChatNavigationPath(
|
||||
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats/new`,
|
||||
currentSearch,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
navigate(
|
||||
buildFullChatNavigationPath(
|
||||
routeBase === "/page/workspace" || wxCommonAgents
|
||||
routeBase === "/page/workspace"
|
||||
? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
|
||||
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
|
||||
currentSearch,
|
||||
),
|
||||
);
|
||||
},
|
||||
[currentSearch, navigate, newChatPath, routeBase, wxCommonAgents],
|
||||
[currentSearch, navigate, newChatPath, routeBase],
|
||||
);
|
||||
|
||||
// ── 最近的对话取数 + 搜索 ──
|
||||
@@ -571,24 +359,17 @@ export function GeneralQaSidebar({
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||||
{/* ── 常用智能体 ── */}
|
||||
{(frequent.length > 0 || isWxManager) && (
|
||||
{frequent.length > 0 && (
|
||||
<div className="pt-2">
|
||||
<SectionLabel
|
||||
label="常用智能体"
|
||||
count={wxUsesCategoryFilter ? frequent.length : undefined}
|
||||
collapsed={agentsCollapsed}
|
||||
onToggle={toggleAgents}
|
||||
onOpenSettings={isWxManager ? () => setAgentTypeSettingsOpen(true) : undefined}
|
||||
/>
|
||||
{!agentsCollapsed && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
{frequent.map((agent) => {
|
||||
const isActive =
|
||||
activeAgentId === agent.id ||
|
||||
(agent.id === SENTIMENT_AGENT_ID &&
|
||||
pathname.startsWith(
|
||||
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats`,
|
||||
));
|
||||
const isActive = activeAgentId === agent.id;
|
||||
const { from, to, foreground } = agentColor(agent.id, theme);
|
||||
return (
|
||||
<button
|
||||
@@ -884,14 +665,6 @@ export function GeneralQaSidebar({
|
||||
if (!o) setShareThreadId(null);
|
||||
}}
|
||||
/>
|
||||
{wxCommonAgents && isWxManager && (
|
||||
<WxAgentTypeSettingsDialog
|
||||
open={agentTypeSettingsOpen}
|
||||
onOpenChange={setAgentTypeSettingsOpen}
|
||||
agentType={wxUsesCategoryFilter ? configuredWxAgentType : null}
|
||||
options={wxAgentTypeOptions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
BrainCircuitIcon,
|
||||
BookMarkedIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
FolderTreeIcon,
|
||||
GaugeIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
PaperclipIcon,
|
||||
@@ -67,6 +69,7 @@ import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { Model } from "@/core/models/types";
|
||||
import type { AgentThreadContext } from "@/core/threads";
|
||||
import { isTurboModeVisible } from "@/core/threads/use-direct-chat";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -92,18 +95,33 @@ import {
|
||||
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import { useUserPreferences } from "@/core/user-preferences";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
useLocalSettings,
|
||||
} from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
|
||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||
import { resolveInputMode, type InputMode } from "./input-mode";
|
||||
import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
useLocalSettings,
|
||||
} from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
|
||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||
import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
|
||||
function getResolvedMode(
|
||||
mode: InputMode | undefined,
|
||||
supportsThinking: boolean,
|
||||
): InputMode {
|
||||
if (!supportsThinking && mode !== "flash") {
|
||||
return "flash";
|
||||
}
|
||||
if (mode) {
|
||||
return mode;
|
||||
}
|
||||
return supportsThinking ? "pro" : "flash";
|
||||
}
|
||||
|
||||
export function InputBox({
|
||||
className,
|
||||
@@ -121,13 +139,13 @@ export function InputBox({
|
||||
onStop,
|
||||
promptPrefix,
|
||||
agentSelector,
|
||||
knowledgeSelector,
|
||||
knowledgeSuggestedQuestions = [],
|
||||
knowledgeSelector,
|
||||
extraModels = [],
|
||||
assistantId,
|
||||
hideAdminRecommendedQuestions,
|
||||
compact,
|
||||
hideContextTools,
|
||||
hideMemoryTool = true,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||
assistantId?: string | null;
|
||||
@@ -135,9 +153,14 @@ export function InputBox({
|
||||
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
|
||||
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
|
||||
compact?: boolean;
|
||||
/** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等
|
||||
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
|
||||
* 受控场景用;文件/模型等其他按钮不受影响)。 */
|
||||
hideContextTools?: boolean;
|
||||
/**
|
||||
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
|
||||
* 写作和参考文献开关不受影响。
|
||||
*/
|
||||
hideMemoryTool?: boolean;
|
||||
disabled?: boolean;
|
||||
context: Omit<
|
||||
AgentThreadContext,
|
||||
@@ -146,6 +169,7 @@ export function InputBox({
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
memory_injection_enabled?: boolean;
|
||||
};
|
||||
extraHeader?: React.ReactNode;
|
||||
isNewThread?: boolean;
|
||||
@@ -162,6 +186,7 @@ export function InputBox({
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
memory_injection_enabled?: boolean;
|
||||
},
|
||||
) => void;
|
||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||
@@ -186,10 +211,8 @@ export function InputBox({
|
||||
selectedAgentId: string | null;
|
||||
onAgentChange: (agentId: string | null) => void;
|
||||
};
|
||||
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
|
||||
knowledgeSelector?: ReactNode;
|
||||
/** High-value starter questions derived from the currently selected spaces. */
|
||||
knowledgeSuggestedQuestions?: string[];
|
||||
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
|
||||
knowledgeSelector?: ReactNode;
|
||||
/** Page-scoped model choices implemented outside the DeerFlow model API. */
|
||||
extraModels?: Model[];
|
||||
}) {
|
||||
@@ -202,16 +225,20 @@ export function InputBox({
|
||||
context.execution_profile === "single_round_rag";
|
||||
const multiRoundRagActive =
|
||||
context.execution_profile === "multi_round_rag";
|
||||
const turboModeVisible = isTurboModeVisible();
|
||||
const directChatActive =
|
||||
turboModeVisible && context.execution_profile === "direct";
|
||||
const frontendRagActive = singleRoundRagActive || multiRoundRagActive;
|
||||
const bypassAgentActive = frontendRagActive || directChatActive;
|
||||
const modelOptions = useMemo(() => {
|
||||
return singleRoundRagActive ? extraModels : models;
|
||||
}, [extraModels, models, singleRoundRagActive]);
|
||||
const { thread, isMock } = useThread();
|
||||
const { textInput } = usePromptInputController();
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
const { thread, isMock } = useThread();
|
||||
const { textInput } = usePromptInputController();
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
|
||||
const [followups, setFollowups] = useState<string[]>([]);
|
||||
const [followupsHidden, setFollowupsHidden] = useState(false);
|
||||
@@ -226,18 +253,43 @@ export function InputBox({
|
||||
|
||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||
const userPreferences = useUserPreferences();
|
||||
const showAdminRecommendedQuestions =
|
||||
!hideAdminRecommendedQuestions &&
|
||||
knowledgeSuggestedQuestions.length === 0 &&
|
||||
const showAdminRecommendedQuestions =
|
||||
!hideAdminRecommendedQuestions &&
|
||||
!userPreferences.hide_admin_prompt_prefix &&
|
||||
recommendedQuestions.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
// Multi-round RAG is a frontend execution profile, not a model option.
|
||||
// Do not let the normal model fallback clear it on every render.
|
||||
// Frontend-only execution profiles are not model options.
|
||||
// Do not let the normal model fallback clear them on every render.
|
||||
if (!turboModeVisible && context.execution_profile === "direct") {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
execution_profile: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (multiRoundRagActive) {
|
||||
return;
|
||||
}
|
||||
if (directChatActive) {
|
||||
if (models.length === 0) {
|
||||
return;
|
||||
}
|
||||
const currentModel = models.find((m) => m.name === context.model_name);
|
||||
const fallbackModel = currentModel ?? models[0]!;
|
||||
if (
|
||||
context.model_name === fallbackModel.name &&
|
||||
context.execution_profile === "direct"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: fallbackModel.name,
|
||||
execution_profile: "direct",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (modelOptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -245,7 +297,7 @@ export function InputBox({
|
||||
const fallbackModel = currentModel ?? modelOptions[0]!;
|
||||
const supportsThinking = fallbackModel.supports_thinking ?? false;
|
||||
const nextModelName = fallbackModel.name;
|
||||
const nextMode = resolveInputMode(context.mode, supportsThinking);
|
||||
const nextMode = getResolvedMode(context.mode, supportsThinking);
|
||||
|
||||
if (
|
||||
context.model_name === nextModelName &&
|
||||
@@ -261,7 +313,7 @@ export function InputBox({
|
||||
mode: nextMode,
|
||||
execution_profile: fallbackModel.execution_profile,
|
||||
});
|
||||
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
|
||||
}, [context, directChatActive, modelOptions, models, multiRoundRagActive, onContextChange, turboModeVisible]);
|
||||
|
||||
const selectedModel = useMemo(() => {
|
||||
if (modelOptions.length === 0) {
|
||||
@@ -292,9 +344,12 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name,
|
||||
mode: resolveInputMode(context.mode, model.supports_thinking ?? false),
|
||||
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
||||
reasoning_effort: context.reasoning_effort,
|
||||
execution_profile: model.execution_profile,
|
||||
execution_profile:
|
||||
context.execution_profile === "direct"
|
||||
? "direct"
|
||||
: model.execution_profile,
|
||||
});
|
||||
setModelDialogOpen(false);
|
||||
},
|
||||
@@ -308,7 +363,7 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: ordinaryModel?.name ?? context.model_name,
|
||||
mode: resolveInputMode(
|
||||
mode: getResolvedMode(
|
||||
mode,
|
||||
ordinaryModel?.supports_thinking ?? supportThinking,
|
||||
),
|
||||
@@ -326,6 +381,16 @@ export function InputBox({
|
||||
[onContextChange, context, models, supportThinking],
|
||||
);
|
||||
|
||||
const handleDirectChatSelect = useCallback(() => {
|
||||
const ordinaryModel =
|
||||
models.find((model) => model.name === context.model_name) ?? models[0];
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: ordinaryModel?.name ?? context.model_name,
|
||||
execution_profile: "direct",
|
||||
});
|
||||
}, [context, models, onContextChange]);
|
||||
|
||||
const handleSingleRoundRagSelect = useCallback(() => {
|
||||
const ragModel =
|
||||
extraModels.find((model) => model.name === context.model_name) ??
|
||||
@@ -337,7 +402,7 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: ragModel.name,
|
||||
mode: resolveInputMode(
|
||||
mode: getResolvedMode(
|
||||
context.mode,
|
||||
ragModel.supports_thinking ?? false,
|
||||
),
|
||||
@@ -377,6 +442,14 @@ export function InputBox({
|
||||
});
|
||||
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
||||
|
||||
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
||||
const handleMemoryInjectionToggle = useCallback(() => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
memory_injection_enabled: !memoryInjectionEnabled,
|
||||
});
|
||||
}, [context, memoryInjectionEnabled, onContextChange]);
|
||||
|
||||
const ragModeLocked = isThreadRagModeLocked(threadId);
|
||||
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
|
||||
const handleRagModeToggle = useCallback(() => {
|
||||
@@ -416,7 +489,7 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: resolvedModelName,
|
||||
mode: resolveInputMode(
|
||||
mode: getResolvedMode(
|
||||
context.mode,
|
||||
selectedModel?.supports_thinking ?? false,
|
||||
),
|
||||
@@ -588,35 +661,13 @@ export function InputBox({
|
||||
|
||||
return (
|
||||
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
||||
{extraHeader && (
|
||||
{extraHeader && (
|
||||
<div className="flex items-center justify-center">
|
||||
{extraHeader}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && 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 && (
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
||||
{recommendedQuestions.map((q) => (
|
||||
<button
|
||||
@@ -727,12 +778,12 @@ export function InputBox({
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-y-2 border-t-0 px-2 pt-1 pb-1.5",
|
||||
"flex items-center border-t-0 px-2 pt-1 pb-1.5",
|
||||
compact && "gap-2 px-3 pt-1.5 pb-3",
|
||||
)}
|
||||
>
|
||||
<PromptInputTools
|
||||
className={cn("min-w-0 flex-1 flex-wrap gap-1.5", compact && "gap-2")}
|
||||
className={cn("gap-1.5", compact && "flex-1 flex-wrap gap-2")}
|
||||
>
|
||||
{/* TODO: Add more connectors here
|
||||
<PromptInputActionMenu>
|
||||
@@ -743,7 +794,7 @@ export function InputBox({
|
||||
/>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu> */}
|
||||
{!frontendRagActive && (
|
||||
{!bypassAgentActive && (
|
||||
<AddAttachmentsButton
|
||||
className={cn(
|
||||
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
@@ -752,7 +803,7 @@ export function InputBox({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!frontendRagActive && !hideContextTools && <Tooltip
|
||||
{!bypassAgentActive && !hideContextTools && <Tooltip
|
||||
content={
|
||||
context.writing_mode
|
||||
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
|
||||
@@ -778,7 +829,7 @@ export function InputBox({
|
||||
<span className="text-xs font-normal">写作</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!frontendRagActive && <Tooltip
|
||||
{!bypassAgentActive && <Tooltip
|
||||
content={
|
||||
artifactDirectoryModeEnabled
|
||||
? "文件模式已开启:右侧会显示当前对话生成的全部文件目录"
|
||||
@@ -807,9 +858,35 @@ export function InputBox({
|
||||
<span className="text-xs font-normal">文件</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
|
||||
content={
|
||||
ragModeEnabled
|
||||
{!bypassAgentActive && !hideContextTools && !hideMemoryTool && <Tooltip
|
||||
content={
|
||||
memoryInjectionEnabled
|
||||
? "记忆注入已开启:对话前会注入历史记忆"
|
||||
: "记忆注入已关闭:本次对话不注入记忆"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
memoryInjectionEnabled
|
||||
? "border border-blue-300 bg-blue-50 text-blue-700 ring-1 ring-blue-300/35 hover:bg-blue-100 dark:border-cyan-300/70 dark:bg-cyan-400/20 dark:text-cyan-50 dark:ring-cyan-300/35 dark:shadow-[0_0_0_1px_rgba(34,211,238,0.18),0_0_18px_rgba(34,211,238,0.18)] dark:hover:bg-cyan-400/25"
|
||||
: compact
|
||||
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
onClick={handleMemoryInjectionToggle}
|
||||
>
|
||||
{memoryInjectionEnabled && (
|
||||
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" />
|
||||
)}
|
||||
<BrainCircuitIcon className="size-3" />
|
||||
<span className="text-xs font-normal">记忆</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{referenceModeEntryEnabled && !bypassAgentActive && !hideContextTools && <Tooltip
|
||||
content={
|
||||
ragModeEnabled
|
||||
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
||||
: "参考文献模式已关闭:点击开启"
|
||||
}
|
||||
@@ -837,7 +914,7 @@ export function InputBox({
|
||||
<span className="text-xs font-normal">参考文献</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!frontendRagActive && <Tooltip
|
||||
{!bypassAgentActive && <Tooltip
|
||||
content={
|
||||
contextCompressionEnabled
|
||||
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
|
||||
@@ -877,7 +954,9 @@ export function InputBox({
|
||||
: "flash"
|
||||
}
|
||||
tooltipContent={
|
||||
singleRoundRagActive
|
||||
directChatActive
|
||||
? `${t.inputBox.turboMode}:${t.inputBox.turboModeDescription}`
|
||||
: singleRoundRagActive
|
||||
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
|
||||
: multiRoundRagActive
|
||||
? "多轮 RAG 问答:多次检索并展示检索过程与参考资料"
|
||||
@@ -889,7 +968,9 @@ export function InputBox({
|
||||
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
|
||||
compact &&
|
||||
(frontendRagActive
|
||||
(directChatActive
|
||||
? "border-sky-200 bg-sky-50/60 text-sky-700 hover:bg-sky-100 dark:border-sky-700 dark:bg-sky-950/40 dark:text-sky-200"
|
||||
: frontendRagActive
|
||||
? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||
: context.mode === "thinking"
|
||||
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
|
||||
@@ -901,7 +982,9 @@ export function InputBox({
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{frontendRagActive ? (
|
||||
{directChatActive ? (
|
||||
<GaugeIcon className="size-3" />
|
||||
) : frontendRagActive ? (
|
||||
<BookMarkedIcon className="size-3" />
|
||||
) : context.mode === "flash" ? (
|
||||
<ZapIcon className="size-3" />
|
||||
@@ -916,10 +999,14 @@ export function InputBox({
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs font-normal",
|
||||
context.mode === "ultra" ? "golden-text" : "",
|
||||
context.mode === "ultra" && !directChatActive && !frontendRagActive
|
||||
? "golden-text"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{frontendRagActive
|
||||
{directChatActive
|
||||
? t.inputBox.turboMode
|
||||
: frontendRagActive
|
||||
? multiRoundRagActive
|
||||
? "多轮 RAG"
|
||||
: "单轮 RAG"
|
||||
@@ -937,9 +1024,39 @@ export function InputBox({
|
||||
{t.inputBox.mode}
|
||||
</DropdownMenuLabel>
|
||||
<PromptInputActionMenu>
|
||||
{turboModeVisible && (
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
!frontendRagActive && context.mode === "flash"
|
||||
directChatActive
|
||||
? "bg-sky-50 text-sky-800 hover:bg-sky-100 focus:bg-sky-100 dark:bg-sky-950/40 dark:text-sky-200 dark:hover:bg-sky-950/60 dark:focus:bg-sky-950/60"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={handleDirectChatSelect}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<GaugeIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
directChatActive && "text-sky-700 dark:text-sky-200",
|
||||
)}
|
||||
/>
|
||||
{t.inputBox.turboMode}
|
||||
</div>
|
||||
<div className="pl-7 text-xs">
|
||||
{t.inputBox.turboModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{directChatActive ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
)}
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
!bypassAgentActive && context.mode === "flash"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
@@ -950,7 +1067,7 @@ export function InputBox({
|
||||
<ZapIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
!frontendRagActive && context.mode === "flash" &&
|
||||
!bypassAgentActive && context.mode === "flash" &&
|
||||
"text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
@@ -960,7 +1077,7 @@ export function InputBox({
|
||||
{t.inputBox.flashModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{!frontendRagActive && context.mode === "flash" ? (
|
||||
{!bypassAgentActive && context.mode === "flash" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
@@ -969,7 +1086,7 @@ export function InputBox({
|
||||
{supportThinking && (
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
!frontendRagActive && context.mode === "thinking"
|
||||
!bypassAgentActive && context.mode === "thinking"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
@@ -980,7 +1097,7 @@ export function InputBox({
|
||||
<LightbulbIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
!frontendRagActive && context.mode === "thinking" &&
|
||||
!bypassAgentActive && context.mode === "thinking" &&
|
||||
"text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
@@ -990,7 +1107,7 @@ export function InputBox({
|
||||
{t.inputBox.reasoningModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{!frontendRagActive && context.mode === "thinking" ? (
|
||||
{!bypassAgentActive && context.mode === "thinking" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
@@ -999,7 +1116,7 @@ export function InputBox({
|
||||
)}
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
!frontendRagActive && context.mode === "pro"
|
||||
!bypassAgentActive && context.mode === "pro"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
@@ -1010,7 +1127,7 @@ export function InputBox({
|
||||
<GraduationCapIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
!frontendRagActive && context.mode === "pro" &&
|
||||
!bypassAgentActive && context.mode === "pro" &&
|
||||
"text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
@@ -1020,7 +1137,7 @@ export function InputBox({
|
||||
{t.inputBox.proModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{!frontendRagActive && context.mode === "pro" ? (
|
||||
{!bypassAgentActive && context.mode === "pro" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
@@ -1028,7 +1145,7 @@ export function InputBox({
|
||||
</PromptInputActionMenuItem>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
!frontendRagActive && context.mode === "ultra"
|
||||
!bypassAgentActive && context.mode === "ultra"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
@@ -1039,14 +1156,14 @@ export function InputBox({
|
||||
<RocketIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
!frontendRagActive &&
|
||||
!bypassAgentActive &&
|
||||
context.mode === "ultra" &&
|
||||
"text-[#dabb5e]",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
!frontendRagActive &&
|
||||
!bypassAgentActive &&
|
||||
context.mode === "ultra" &&
|
||||
"golden-text",
|
||||
)}
|
||||
@@ -1058,7 +1175,7 @@ export function InputBox({
|
||||
{t.inputBox.ultraModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{!frontendRagActive && context.mode === "ultra" ? (
|
||||
{!bypassAgentActive && context.mode === "ultra" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
@@ -1129,7 +1246,7 @@ export function InputBox({
|
||||
</DropdownMenuGroup>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
|
||||
{!bypassAgentActive && supportReasoningEffort && context.mode !== "flash" && (
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
|
||||
<div className="text-xs font-normal">
|
||||
@@ -1246,8 +1363,8 @@ export function InputBox({
|
||||
</PromptInputActionMenu>
|
||||
)}
|
||||
</PromptInputTools>
|
||||
<PromptInputTools className={cn("ml-auto shrink-0 gap-1.5", compact && "gap-2")}>
|
||||
{!frontendRagActive && agentSelector && (
|
||||
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
||||
{!bypassAgentActive && agentSelector && (
|
||||
<AgentSelector
|
||||
selectedAgentId={agentSelector.selectedAgentId}
|
||||
onAgentChange={agentSelector.onAgentChange}
|
||||
@@ -1255,8 +1372,8 @@ export function InputBox({
|
||||
compact={compact}
|
||||
/>
|
||||
)}
|
||||
{!frontendRagActive && knowledgeSelector}
|
||||
{!frontendRagActive && promptPrefix && (
|
||||
{!bypassAgentActive && knowledgeSelector}
|
||||
{!bypassAgentActive && promptPrefix && (
|
||||
<Tooltip
|
||||
content={
|
||||
promptPrefix.on
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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");
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
export type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
/**
|
||||
* Keep plan/subagent modes available even when the selected model has no
|
||||
* native thinking switch. The backend already falls back to non-thinking for
|
||||
* that model, while `pro` and `ultra` still control planning and subagents.
|
||||
* Only the model-specific `thinking` mode requires `supports_thinking`.
|
||||
*/
|
||||
export function resolveInputMode(
|
||||
mode: InputMode | undefined,
|
||||
supportsThinking: boolean,
|
||||
): InputMode {
|
||||
if (mode === "thinking" && !supportsThinking) {
|
||||
return "flash";
|
||||
}
|
||||
if (mode) {
|
||||
return mode;
|
||||
}
|
||||
return supportsThinking ? "pro" : "flash";
|
||||
}
|
||||
@@ -35,27 +35,6 @@ import {
|
||||
|
||||
type SelectorSource = "all" | "agent";
|
||||
type SelectorVariant = "popover" | "panel";
|
||||
const EMPTY_IDS: string[] = [];
|
||||
|
||||
export function buildKnowledgeSpaceQuestions(names: string[]): string[] {
|
||||
const cleanNames = names.map((name) => name.trim()).filter(Boolean);
|
||||
if (cleanNames.length === 0) return [];
|
||||
if (cleanNames.length === 1) {
|
||||
const name = cleanNames[0]!;
|
||||
return [
|
||||
`请概括「${name}」知识空间的主要内容、适用场景和资料边界。`,
|
||||
`「${name}」包含哪些核心主题?请按重要性说明并引用来源。`,
|
||||
`请梳理「${name}」中的关键实体、概念及它们之间的关系。`,
|
||||
`根据「${name}」,有哪些值得关注的问题、风险或行动建议?`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`请综合所选 ${cleanNames.length} 个知识空间,总结共同主题、差异和互补信息。`,
|
||||
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点,并引用各自来源。`,
|
||||
"请梳理所选知识空间中的关键实体、概念及跨库关系。",
|
||||
"所选知识空间中有哪些相互冲突或证据不足的结论?",
|
||||
];
|
||||
}
|
||||
|
||||
function groupRows(rows: LlmWikiKnowledgeBase[]) {
|
||||
return [
|
||||
@@ -344,14 +323,9 @@ function SelectionContent({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
|
||||
</div>
|
||||
{selectedCount > 0 && (
|
||||
<div className="mt-0.5 text-[10px] text-primary">仅检索已选知识空间,不联网</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
|
||||
</span>
|
||||
{readOnly ? (
|
||||
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
|
||||
) : (
|
||||
@@ -389,10 +363,9 @@ function SelectionContent({
|
||||
|
||||
export function LlmWikiKnowledgeSelector({
|
||||
value,
|
||||
assistantValue = EMPTY_IDS,
|
||||
assistantValue = [],
|
||||
onChange,
|
||||
onAssistantChange = () => undefined,
|
||||
onSuggestedQuestionsChange,
|
||||
source = "all",
|
||||
agentId,
|
||||
autoSelectAll = false,
|
||||
@@ -400,15 +373,12 @@ export function LlmWikiKnowledgeSelector({
|
||||
readOnly = false,
|
||||
className,
|
||||
label = "知识空间",
|
||||
panelTitle = "绑定知识空间",
|
||||
panelDescription = "新建该智能体对话时,只能从已绑定空间中选择本次检索范围。",
|
||||
variant = "popover",
|
||||
}: {
|
||||
value: string[];
|
||||
assistantValue?: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
onAssistantChange?: (value: string[]) => void;
|
||||
onSuggestedQuestionsChange?: (questions: string[]) => void;
|
||||
source?: SelectorSource;
|
||||
agentId?: string | null;
|
||||
autoSelectAll?: boolean;
|
||||
@@ -417,38 +387,23 @@ export function LlmWikiKnowledgeSelector({
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
label?: string;
|
||||
panelTitle?: string;
|
||||
panelDescription?: string;
|
||||
variant?: SelectorVariant;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const weknoraEnabled = runtime.data?.provider === "weknora";
|
||||
const shouldLoadOptions =
|
||||
variant === "panel" ||
|
||||
open ||
|
||||
autoSelectAll ||
|
||||
value.length > 0 ||
|
||||
assistantValue.length > 0;
|
||||
const all = useLlmWikiKnowledgeBases(
|
||||
"all",
|
||||
weknoraEnabled && shouldLoadOptions,
|
||||
weknoraEnabled,
|
||||
);
|
||||
const agent = useAgentLlmWikiKnowledgeBases(
|
||||
agentId,
|
||||
weknoraEnabled && shouldLoadOptions && source === "agent" && !!agentId,
|
||||
weknoraEnabled && source === "agent" && !!agentId,
|
||||
);
|
||||
const assistantBases = useAssistantKnowledgeBases(
|
||||
shouldLoadOptions ||
|
||||
runtime.isError ||
|
||||
(!runtime.isLoading && !weknoraEnabled),
|
||||
);
|
||||
// Stable empty results are essential: suggested questions are reported to
|
||||
// the parent, so a fresh [] on every render creates an effect/update loop
|
||||
// before lazy knowledge queries have loaded (or when they are disabled).
|
||||
const rows = useMemo(() => weknoraEnabled ? (all.data ?? []) : [], [all.data, weknoraEnabled]);
|
||||
const assistantRows = useMemo(() => assistantBases.data ?? [], [assistantBases.data]);
|
||||
const assistantBases = useAssistantKnowledgeBases();
|
||||
const rows = weknoraEnabled ? (all.data ?? []) : [];
|
||||
const assistantRows = assistantBases.data ?? [];
|
||||
const defaultIds = useMemo(() => {
|
||||
if (source !== "agent") {
|
||||
return rows.map((item) => item.id);
|
||||
@@ -464,26 +419,6 @@ export function LlmWikiKnowledgeSelector({
|
||||
);
|
||||
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
|
||||
const initialized = useRef<string | null>(null);
|
||||
const selectedNames = useMemo(
|
||||
() => value.map((id) => rows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
|
||||
[rows, value],
|
||||
);
|
||||
const selectedAssistantNames = useMemo(
|
||||
() => assistantValue.map((id) => assistantRows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
|
||||
[assistantRows, assistantValue],
|
||||
);
|
||||
const allSelectedNames = useMemo(
|
||||
() => [...selectedNames, ...selectedAssistantNames],
|
||||
[selectedAssistantNames, selectedNames],
|
||||
);
|
||||
const suggestedQuestions = useMemo(
|
||||
() => buildKnowledgeSpaceQuestions(allSelectedNames),
|
||||
[allSelectedNames],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onSuggestedQuestionsChange?.(suggestedQuestions);
|
||||
}, [onSuggestedQuestionsChange, suggestedQuestions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtime.isLoading && !weknoraEnabled) {
|
||||
@@ -513,12 +448,7 @@ export function LlmWikiKnowledgeSelector({
|
||||
if (!open) setSearch("");
|
||||
}, [open]);
|
||||
|
||||
if (
|
||||
!runtime.isLoading &&
|
||||
(runtime.isError || !weknoraEnabled) &&
|
||||
!assistantBases.isLoading &&
|
||||
assistantRows.length === 0
|
||||
) return null;
|
||||
if ((runtime.isError || !weknoraEnabled) && !assistantBases.isLoading && assistantRows.length === 0) return null;
|
||||
|
||||
const content = (
|
||||
<SelectionContent
|
||||
@@ -544,10 +474,10 @@ export function LlmWikiKnowledgeSelector({
|
||||
<div className="flex items-start justify-between gap-4 border-b px-4 py-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Database className="size-4 text-primary" />{panelTitle}
|
||||
<Database className="size-4 text-primary" />绑定知识空间
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{panelDescription}
|
||||
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
|
||||
@@ -557,6 +487,13 @@ export function LlmWikiKnowledgeSelector({
|
||||
);
|
||||
}
|
||||
|
||||
const selectedNames = value
|
||||
.map((id) => rows.find((item) => item.id === id)?.name)
|
||||
.filter((name): name is string => !!name);
|
||||
const selectedAssistantNames = assistantValue
|
||||
.map((id) => assistantRows.find((item) => item.id === id)?.name)
|
||||
.filter((name): name is string => !!name);
|
||||
const allSelectedNames = [...selectedNames, ...selectedAssistantNames];
|
||||
const selectedCount = value.length + assistantValue.length;
|
||||
const selectedLabel = allSelectedNames.length > 0
|
||||
? allSelectedNames.join("、")
|
||||
|
||||
@@ -20,9 +20,6 @@ import {
|
||||
} from "@/core/utils/markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { VisualizationBlock } from "../visualizations/visualization-block";
|
||||
import { splitVisualizationContent } from "../visualizations/visualization-spec";
|
||||
|
||||
import { CitationLink } from "../citations/citation-link";
|
||||
|
||||
function isExternalUrl(href: string | undefined): boolean {
|
||||
@@ -53,7 +50,6 @@ export type MarkdownContentProps = {
|
||||
/** Renders markdown content. */
|
||||
export function MarkdownContent({
|
||||
content,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
className,
|
||||
remarkPlugins = streamdownPlugins.remarkPlugins,
|
||||
@@ -63,7 +59,6 @@ export function MarkdownContent({
|
||||
}: MarkdownContentProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
||||
const visualizationSegments = useMemo(() => splitVisualizationContent(content), [content]);
|
||||
useStreamdownMermaidPngDownload(rootRef);
|
||||
const components = useMemo(() => {
|
||||
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
|
||||
@@ -144,51 +139,17 @@ export function MarkdownContent({
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
// Preserve the exact legacy rendering path for ordinary Markdown. Heavy
|
||||
// visualization renderers are lazy-loaded only when a dedicated fence exists.
|
||||
if (
|
||||
visualizationSegments.length === 1 &&
|
||||
visualizationSegments[0]?.type === "markdown"
|
||||
) {
|
||||
return (
|
||||
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
||||
<MessageResponse
|
||||
className={className}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
mermaidConfig={streamdownPlugins.mermaidConfig}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</MessageResponse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
||||
{visualizationSegments.map((segment, index) =>
|
||||
segment.type === "visualization" ? (
|
||||
<VisualizationBlock
|
||||
key={`visualization-${index}`}
|
||||
language={segment.language}
|
||||
source={segment.source}
|
||||
complete={segment.complete}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
) : segment.content ? (
|
||||
<MessageResponse
|
||||
key={`markdown-${index}`}
|
||||
className={className}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
mermaidConfig={streamdownPlugins.mermaidConfig}
|
||||
components={components}
|
||||
>
|
||||
{segment.content}
|
||||
</MessageResponse>
|
||||
) : null,
|
||||
)}
|
||||
<MessageResponse
|
||||
className={className}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
mermaidConfig={streamdownPlugins.mermaidConfig}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</MessageResponse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -82,6 +82,7 @@ import { Tooltip } from "../tooltip";
|
||||
import { SourceCard } from "../skill-display-sources";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { SingleRoundRagCitationDialog } from "./message-list-item";
|
||||
|
||||
export function MessageGroup({
|
||||
className,
|
||||
@@ -137,6 +138,19 @@ export function MessageGroup({
|
||||
}
|
||||
}, [lastToolCallStep, steps]);
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
const [selectedCitation, setSelectedCitation] =
|
||||
useState<RuntimeDisplaySource | null>(null);
|
||||
const handleCitationClick = useCallback(
|
||||
(referenceId: string) => {
|
||||
const referenceIndex = Number(referenceId);
|
||||
if (!Number.isInteger(referenceIndex) || referenceIndex < 1) return;
|
||||
const source =
|
||||
displaySources.find((item) => item.index === referenceIndex) ??
|
||||
displaySources[referenceIndex - 1];
|
||||
if (source) setSelectedCitation(source);
|
||||
},
|
||||
[displaySources],
|
||||
);
|
||||
const reasoningId =
|
||||
lastReasoningStep?.id ??
|
||||
messages.find((message) => message.type === "ai")?.id;
|
||||
@@ -186,6 +200,7 @@ export function MessageGroup({
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
onCitationClick={handleCitationClick}
|
||||
/>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
@@ -193,9 +208,8 @@ export function MessageGroup({
|
||||
<ToolCall
|
||||
key={step.id}
|
||||
{...step}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
),
|
||||
@@ -206,9 +220,8 @@ export function MessageGroup({
|
||||
key={lastToolCallStep.id}
|
||||
{...lastToolCallStep}
|
||||
isLast={true}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
</FlipDisplay>
|
||||
@@ -249,6 +262,7 @@ export function MessageGroup({
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
onCitationClick={handleCitationClick}
|
||||
/>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
@@ -257,6 +271,12 @@ export function MessageGroup({
|
||||
</>
|
||||
)}
|
||||
</ChainOfThought>
|
||||
<SingleRoundRagCitationDialog
|
||||
source={selectedCitation}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedCitation(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -266,12 +286,14 @@ function StepReasoningMarkdown({
|
||||
displaySources,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
}: {
|
||||
onCitationClick,
|
||||
}: {
|
||||
content: string;
|
||||
displaySources: RuntimeDisplaySource[];
|
||||
isLoading: boolean;
|
||||
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
}) {
|
||||
onCitationClick: (referenceId: string) => void;
|
||||
}) {
|
||||
const markdown = useMemo(
|
||||
() =>
|
||||
applyInlineCitations(content, displaySources, { appendSummary: false }),
|
||||
@@ -281,18 +303,19 @@ function StepReasoningMarkdown({
|
||||
if (displaySources.length === 0) return undefined;
|
||||
return {
|
||||
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (href?.startsWith("#reference-")) {
|
||||
return (
|
||||
<CitationLink
|
||||
href={href}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
if (href?.startsWith("#reference-")) {
|
||||
return (
|
||||
<CitationLink
|
||||
href={href}
|
||||
{...props}
|
||||
onReferenceClick={onCitationClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <a href={href} {...props} />;
|
||||
},
|
||||
};
|
||||
}, [displaySources.length]);
|
||||
}, [displaySources.length, onCitationClick]);
|
||||
|
||||
return (
|
||||
<MarkdownContent
|
||||
@@ -312,8 +335,7 @@ function ToolCall({
|
||||
result,
|
||||
isLast = false,
|
||||
isLoading = false,
|
||||
skillDisplayMap = new Map(),
|
||||
displaySources = [],
|
||||
skillDisplayMap = new Map(),
|
||||
ragModeEnabled = false,
|
||||
}: {
|
||||
id?: string;
|
||||
@@ -323,8 +345,7 @@ function ToolCall({
|
||||
result?: string | Record<string, unknown>;
|
||||
isLast?: boolean;
|
||||
isLoading?: boolean;
|
||||
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
|
||||
ragModeEnabled?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
@@ -333,28 +354,21 @@ function ToolCall({
|
||||
? null
|
||||
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
|
||||
null);
|
||||
const displayItems =
|
||||
displayMatch && result !== undefined
|
||||
? syncDisplaySourceIndexes(rankDisplaySources(normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: item.displayMode ?? displayMatch.display.mode,
|
||||
}))), displaySources)
|
||||
: [];
|
||||
const isLlmWikiTool = name === "llmwiki_search" || name === "llmwiki_read_wiki_page" || name === "llmwiki_browse_wiki_index";
|
||||
const wikiQuery = isLlmWikiTool ? resolveSearchQuery(args, result) : "";
|
||||
const wikiSearchLabel = name === "llmwiki_search"
|
||||
? wikiQuery
|
||||
? `Wiki 文档检索 "${wikiQuery}"`
|
||||
: "Wiki 文档检索"
|
||||
: name === "llmwiki_read_wiki_page"
|
||||
? wikiQuery
|
||||
? `读取 Wiki 页面「${wikiQuery}」`
|
||||
: "读取 Wiki 页面"
|
||||
: name === "llmwiki_browse_wiki_index"
|
||||
? "读取 Wiki 索引与知识库摘要"
|
||||
: null;
|
||||
const displayItems =
|
||||
displayMatch && result !== undefined
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: item.displayMode ?? displayMatch.display.mode,
|
||||
}))
|
||||
: [];
|
||||
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
|
||||
const wikiSearchLabel = name === "llmwiki_search"
|
||||
? wikiQuery
|
||||
? `Wiki 文档检索 "${wikiQuery}"`
|
||||
: "Wiki 文档检索"
|
||||
: null;
|
||||
if (displayItems.length > 0) {
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
@@ -380,12 +394,12 @@ function ToolCall({
|
||||
|
||||
const searchItems = normalizeSearchResults(result);
|
||||
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
||||
const defaultItems = syncDisplaySourceIndexes(rankDisplaySources(normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: name,
|
||||
mode: "citation" as const,
|
||||
}))), displaySources);
|
||||
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: name,
|
||||
mode: "citation" as const,
|
||||
}));
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
|
||||
<DisplayResultsList results={defaultItems} />
|
||||
@@ -845,21 +859,9 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
|
||||
|
||||
type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
||||
|
||||
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||
const steps: CoTStep[] = [];
|
||||
// A streamed ToolMessage normally carries tool_call_id. If a provider
|
||||
// omits it, the legacy name-based fallback is safe only for a unique tool
|
||||
// name. Reusing one llmwiki_search result for several parallel calls makes
|
||||
// every card appear to come from the same knowledge base.
|
||||
const toolCallNameCounts = new Map<string, number>();
|
||||
for (const message of messages) {
|
||||
if (message.type !== "ai") continue;
|
||||
for (const toolCall of message.tool_calls ?? []) {
|
||||
const key = toolCall.name.toLowerCase();
|
||||
toolCallNameCounts.set(key, (toolCallNameCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
for (const message of messages) {
|
||||
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||
const steps: CoTStep[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.type === "ai") {
|
||||
const reasoning = extractReasoningContentFromMessage(message);
|
||||
if (reasoning) {
|
||||
@@ -900,12 +902,11 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||
};
|
||||
const toolCallId = tool_call.id;
|
||||
if (toolCallId) {
|
||||
const toolCallResult = findToolCallResult(
|
||||
toolCallId,
|
||||
messages,
|
||||
tool_call.name,
|
||||
{ allowNameFallback: toolCallNameCounts.get(tool_call.name.toLowerCase()) === 1 },
|
||||
);
|
||||
const toolCallResult = findToolCallResult(
|
||||
toolCallId,
|
||||
messages,
|
||||
tool_call.name,
|
||||
);
|
||||
if (toolCallResult !== undefined) {
|
||||
if (typeof toolCallResult === "string") {
|
||||
try {
|
||||
@@ -950,27 +951,9 @@ function WebSearchResultsList({ results }: { results: SearchResultItem[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const orderedResults = useMemo(
|
||||
() =>
|
||||
results
|
||||
.map((item, originalOrder) => ({ item, originalOrder }))
|
||||
.sort((left, right) => {
|
||||
const leftScore =
|
||||
typeof left.item.score === "number" && Number.isFinite(left.item.score)
|
||||
? left.item.score
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
const rightScore =
|
||||
typeof right.item.score === "number" && Number.isFinite(right.item.score)
|
||||
? right.item.score
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
return rightScore - leftScore || left.originalOrder - right.originalOrder;
|
||||
})
|
||||
.map(({ item }) => item),
|
||||
[results],
|
||||
);
|
||||
return (
|
||||
function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
|
||||
<CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs">
|
||||
<ChevronDownIcon
|
||||
@@ -982,52 +965,13 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
||||
<span>{results.length} 条真实返回结果</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 flex flex-col gap-2">
|
||||
{orderedResults.map((item) => (
|
||||
<SourceCard
|
||||
key={`${item.skillName}-${item.index}`}
|
||||
source={item}
|
||||
/>
|
||||
))}
|
||||
{results.map((item) => (
|
||||
<SourceCard key={`${item.skillName}-${item.index}`} source={item} />
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function rankDisplaySources(
|
||||
sources: RuntimeDisplaySource[],
|
||||
): RuntimeDisplaySource[] {
|
||||
return sources
|
||||
.map((source, originalOrder) => ({ source, originalOrder }))
|
||||
.sort((left, right) => {
|
||||
const leftScore =
|
||||
typeof left.source.score === "number" && Number.isFinite(left.source.score)
|
||||
? left.source.score
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
const rightScore =
|
||||
typeof right.source.score === "number" && Number.isFinite(right.source.score)
|
||||
? right.source.score
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
return rightScore - leftScore || left.originalOrder - right.originalOrder;
|
||||
})
|
||||
.map(({ source }, index) => ({ ...source, index: index + 1 }));
|
||||
}
|
||||
|
||||
function syncDisplaySourceIndexes(
|
||||
sources: RuntimeDisplaySource[],
|
||||
globalSources: RuntimeDisplaySource[],
|
||||
): RuntimeDisplaySource[] {
|
||||
const indexByCitationId = new Map(
|
||||
globalSources.flatMap((source) => {
|
||||
const id = typeof source.raw?.citation_id === "string" ? source.raw.citation_id : "";
|
||||
return id ? [[id, source.index] as const] : [];
|
||||
}),
|
||||
);
|
||||
return sources.map((source) => {
|
||||
const id = typeof source.raw?.citation_id === "string" ? source.raw.citation_id : "";
|
||||
const index = id ? indexByCitationId.get(id) : undefined;
|
||||
return index ? { ...source, index } : source;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -76,8 +76,7 @@ import {
|
||||
getMultiRoundRagTrace,
|
||||
isMultiRoundRagMessage,
|
||||
} from '@/core/threads/use-multi-round-rag';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { WeKnoraSourceDrawer } from '@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { CitationLink } from '../citations/citation-link';
|
||||
import { CopyButton } from '../copy-button';
|
||||
@@ -891,11 +890,11 @@ function FrontendRagSourcesPanel({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(source.content || source.snippet) && (
|
||||
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
|
||||
{source.content || source.snippet}
|
||||
</p>
|
||||
)}
|
||||
{source.snippet && (
|
||||
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
|
||||
{source.snippet}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
@@ -909,26 +908,8 @@ export function SingleRoundRagCitationDialog({
|
||||
}: {
|
||||
source: RuntimeDisplaySource | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
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);
|
||||
}) {
|
||||
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
|
||||
return (
|
||||
<Dialog open={!!source} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
|
||||
|
||||
@@ -47,8 +47,7 @@ import {
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import {
|
||||
@@ -98,8 +97,7 @@ export function RecentChatList({
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
const pathname = usePathname();
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(
|
||||
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
|
||||
);
|
||||
@@ -141,15 +139,15 @@ export function RecentChatList({
|
||||
});
|
||||
if (threadIndex > -1) {
|
||||
if (threads[threadIndex + 1]) {
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!, pathname);
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
|
||||
} else if (threads[threadIndex - 1]) {
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!, pathname);
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
|
||||
}
|
||||
}
|
||||
void router.push(nextThreadPath);
|
||||
}
|
||||
},
|
||||
[agentIdFromPath, deleteThread, pathname, router, threadIdFromPath, threads],
|
||||
[agentIdFromPath, deleteThread, router, threadIdFromPath, threads],
|
||||
);
|
||||
|
||||
const handleRenameClick = useCallback(
|
||||
@@ -250,7 +248,7 @@ export function RecentChatList({
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{shownThreads.map((thread) => {
|
||||
const href = listPathOfThread(thread, pathname);
|
||||
const href = listPathOfThread(thread);
|
||||
const isActive = href === pathname;
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const projectType = myProjectConversationTypeOfThread(thread);
|
||||
@@ -275,8 +273,7 @@ export function RecentChatList({
|
||||
>
|
||||
<Link
|
||||
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||
href={href}
|
||||
onClick={() => { if (isMobile) setOpenMobile(false); }}
|
||||
href={href}
|
||||
>
|
||||
{isBoot ? (
|
||||
<Bot
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { BrainIcon, Loader2Icon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useMemoryRuntimeSettings,
|
||||
useUpdateMemoryRuntimeSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
export function MemoryRuntimeSettingsPage() {
|
||||
const { data, isLoading, isError } = useMemoryRuntimeSettings();
|
||||
const update = useUpdateMemoryRuntimeSettings();
|
||||
const enabled = data?.enabled ?? false;
|
||||
|
||||
const handleChange = async (checked: boolean) => {
|
||||
try {
|
||||
await update.mutateAsync({ enabled: checked });
|
||||
toast.success(checked ? "问答记忆已开启" : "问答记忆已关闭");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold">
|
||||
<BrainIcon className="size-5" />
|
||||
问答记忆
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
全局控制问答是否读取和写入长期记忆。系统默认永久关闭,只有管理员在这里显式开启后才会生效。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">启用问答记忆</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
关闭时不会加载记忆中间件、不会向提示词注入记忆,也不会在回答结束后调用模型提取记忆。
|
||||
</p>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={isError || update.isPending}
|
||||
onCheckedChange={handleChange}
|
||||
aria-label="启用问答记忆"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<p className="text-destructive text-sm">
|
||||
无法读取记忆开关,系统将按关闭处理。
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
修改对下一次发起的问答生效;已生成的历史记忆文件不会被自动删除。
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { BellIcon, BrainIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||
import { BellIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -13,7 +13,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
|
||||
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
|
||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
|
||||
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
|
||||
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -61,9 +61,12 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
// },
|
||||
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
||||
...(isAdmin
|
||||
? [{ id: "memory", label: "问答记忆", icon: BrainIcon }]
|
||||
: []),
|
||||
// 记忆已移至侧边栏入口 (/page/workspace/memory)
|
||||
// {
|
||||
// id: "memory",
|
||||
// label: t.settings.sections.memory,
|
||||
// icon: BrainIcon,
|
||||
// },
|
||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
...(isAdmin
|
||||
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
|
||||
@@ -129,9 +132,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
{activeSection === "appearance" && (
|
||||
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
|
||||
)}
|
||||
{activeSection === "memory" && isAdmin && (
|
||||
<MemoryRuntimeSettingsPage />
|
||||
)}
|
||||
{activeSection === "memory" && <MemorySettingsPage />}
|
||||
{activeSection === "tools" && <ToolSettingsPage />}
|
||||
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
|
||||
{activeSection === "about" && <AboutSettingsPage />}
|
||||
|
||||
@@ -9,10 +9,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import {
|
||||
resolveWeKnoraSourceTarget,
|
||||
type WeKnoraSourceTarget,
|
||||
} from "@/core/llmwiki/source-links";
|
||||
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
@@ -59,7 +56,6 @@ export function SourceCard({
|
||||
}) {
|
||||
const [relatedOpen, setRelatedOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [linkedTarget, setLinkedTarget] = useState<WeKnoraSourceTarget | null>(null);
|
||||
const [settings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const previewTarget = resolveWeKnoraSourceTarget({
|
||||
@@ -69,11 +65,7 @@ export function SourceCard({
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||||
const isWikiIndexSource =
|
||||
source.type === "llmwiki_wiki_index_intro" ||
|
||||
source.raw?.type === "llmwiki_wiki_index_intro";
|
||||
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
|
||||
const opensWikiDrawer = Boolean(previewTarget) || isWikiIndexSource;
|
||||
const matchedHeading = isWikiSource
|
||||
? String(
|
||||
(source.raw?.matched_heading as string | undefined) ||
|
||||
@@ -86,9 +78,7 @@ export function SourceCard({
|
||||
const summary = isWikiSource && typeof source.raw?.summary === "string"
|
||||
? source.raw.summary
|
||||
: "";
|
||||
// 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 snippet = plainTextReferencePreview(summary || content).slice(0, 280);
|
||||
const score =
|
||||
typeof source.score === "number"
|
||||
? source.score <= 1
|
||||
@@ -105,13 +95,9 @@ export function SourceCard({
|
||||
score ? `相关度 ${score}` : null,
|
||||
].filter(Boolean);
|
||||
const isCitation = source.mode === "citation";
|
||||
// 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 showReferenceNumber = isCitation;
|
||||
const openSource = () => {
|
||||
if (opensWikiDrawer) {
|
||||
if (previewTarget) {
|
||||
setDetailOpen(true);
|
||||
} else {
|
||||
setRelatedOpen(true);
|
||||
@@ -188,18 +174,16 @@ export function SourceCard({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(opensWikiDrawer || linkedTarget) && (
|
||||
{previewTarget && (
|
||||
<WeKnoraSourceDrawer
|
||||
open={detailOpen}
|
||||
target={linkedTarget ?? previewTarget}
|
||||
target={previewTarget}
|
||||
title={source.title}
|
||||
quote={content}
|
||||
inlineContent={isWikiIndexSource ? content : ""}
|
||||
inlineKnowledgeBaseId={String(source.raw?.knowledge_base_id || "")}
|
||||
onOpenChange={setDetailOpen}
|
||||
/>
|
||||
)}
|
||||
{!opensWikiDrawer && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
|
||||
{!previewTarget && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
|
||||
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-3 pr-8">
|
||||
@@ -251,18 +235,6 @@ export function SourceCard({
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
linkClassName={(href) =>
|
||||
resolveWeKnoraSourceTarget({ url: href })?.wikiSlug
|
||||
? "font-medium text-red-600 decoration-red-300 hover:text-red-700 hover:decoration-red-600 dark:text-red-400"
|
||||
: ""
|
||||
}
|
||||
onLinkClick={(event, href) => {
|
||||
const target = resolveWeKnoraSourceTarget({ url: href });
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
setLinkedTarget(target);
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const VISUALIZATION_LANGUAGES = ["map-json"] as const;
|
||||
|
||||
export type VisualizationLanguage = (typeof VISUALIZATION_LANGUAGES)[number];
|
||||
|
||||
const safeText = z.string().trim().min(1).max(500);
|
||||
const optionalText = z.string().trim().max(2_000).optional();
|
||||
|
||||
const longitude = z.number().finite().min(-180).max(180);
|
||||
const latitude = z.number().finite().min(-90).max(90);
|
||||
const coordinateSchema = z.tuple([longitude, latitude]);
|
||||
|
||||
const mapSchema = z.object({
|
||||
type: z.literal("map").default("map"),
|
||||
title: z.string().trim().max(200).default("地图"),
|
||||
center: coordinateSchema.optional(),
|
||||
zoom: z.number().finite().min(1).max(20).optional(),
|
||||
markers: z
|
||||
.array(
|
||||
z.object({
|
||||
name: safeText,
|
||||
coordinate: coordinateSchema,
|
||||
description: optionalText,
|
||||
}),
|
||||
)
|
||||
.max(500)
|
||||
.default([]),
|
||||
lines: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().max(120).optional(),
|
||||
coordinates: z.array(coordinateSchema).min(2).max(2_000),
|
||||
}),
|
||||
)
|
||||
.max(100)
|
||||
.default([]),
|
||||
polygons: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().max(120).optional(),
|
||||
coordinates: z.array(coordinateSchema).min(3).max(2_000),
|
||||
}),
|
||||
)
|
||||
.max(100)
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export type MapSpec = z.infer<typeof mapSchema>;
|
||||
|
||||
export type VisualizationParseResult =
|
||||
| { ok: true; spec: MapSpec }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function normalizeLanguageType(
|
||||
language: VisualizationLanguage,
|
||||
value: unknown,
|
||||
): unknown {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||
const record = { ...(value as Record<string, unknown>) };
|
||||
if (language === "map-json") record.type = "map";
|
||||
return record;
|
||||
}
|
||||
|
||||
function formatZodError(error: z.ZodError): string {
|
||||
const issue = error.issues[0];
|
||||
if (!issue) return "地图数据格式不正确";
|
||||
const path = issue.path.length > 0 ? `${issue.path.join(".")}:` : "";
|
||||
return `${path}${issue.message}`;
|
||||
}
|
||||
|
||||
export function parseVisualizationSpec(
|
||||
language: VisualizationLanguage,
|
||||
source: string,
|
||||
): VisualizationParseResult {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(source);
|
||||
} catch {
|
||||
return { ok: false, error: "地图数据尚未完整或不是有效 JSON" };
|
||||
}
|
||||
|
||||
const normalized = normalizeLanguageType(language, raw);
|
||||
if (!normalized || typeof normalized !== "object" || Array.isArray(normalized)) {
|
||||
return { ok: false, error: "地图数据必须是 JSON 对象" };
|
||||
}
|
||||
|
||||
const parsed = mapSchema.safeParse(normalized);
|
||||
if (!parsed.success) return { ok: false, error: formatZodError(parsed.error) };
|
||||
return { ok: true, spec: parsed.data };
|
||||
}
|
||||
|
||||
export type VisualizationSegment =
|
||||
| { type: "markdown"; content: string }
|
||||
| {
|
||||
type: "visualization";
|
||||
language: VisualizationLanguage;
|
||||
source: string;
|
||||
complete: boolean;
|
||||
};
|
||||
|
||||
const FENCE_PATTERN = /(^|\n)```(map-json|json)[ \t]*\r?\n/gi;
|
||||
|
||||
function isMapJsonSource(source: string): boolean {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(source);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const hasMapField = ["center", "zoom", "markers", "lines", "polygons"].some(
|
||||
(field) => Object.prototype.hasOwnProperty.call(record, field),
|
||||
);
|
||||
return hasMapField && parseVisualizationSpec("map-json", source).ok;
|
||||
}
|
||||
|
||||
/** Split only our dedicated fences. With no match, the caller keeps the exact legacy renderer path. */
|
||||
export function splitVisualizationContent(content: string): VisualizationSegment[] {
|
||||
const segments: VisualizationSegment[] = [];
|
||||
let cursor = 0;
|
||||
FENCE_PATTERN.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = FENCE_PATTERN.exec(content)) !== null) {
|
||||
const blockStart = match.index + match[1].length;
|
||||
if (blockStart > cursor) {
|
||||
segments.push({ type: "markdown", content: content.slice(cursor, blockStart) });
|
||||
}
|
||||
const sourceStart = FENCE_PATTERN.lastIndex;
|
||||
const closeIndex = content.indexOf("```", sourceStart);
|
||||
const complete = closeIndex >= 0;
|
||||
const source = content.slice(sourceStart, complete ? closeIndex : content.length).trim();
|
||||
const matchedLanguage = match[2].toLowerCase();
|
||||
if (matchedLanguage === "json" && (!complete || !isMapJsonSource(source))) {
|
||||
if (!complete) break;
|
||||
FENCE_PATTERN.lastIndex = closeIndex + 3;
|
||||
continue;
|
||||
}
|
||||
segments.push({
|
||||
type: "visualization",
|
||||
language: "map-json",
|
||||
source,
|
||||
complete,
|
||||
});
|
||||
cursor = complete ? closeIndex + 3 : content.length;
|
||||
FENCE_PATTERN.lastIndex = cursor;
|
||||
if (!complete) break;
|
||||
}
|
||||
|
||||
if (cursor < content.length) {
|
||||
segments.push({ type: "markdown", content: content.slice(cursor) });
|
||||
}
|
||||
return segments.length > 0 ? segments : [{ type: "markdown", content }];
|
||||
}
|
||||
@@ -54,13 +54,6 @@ export type WorkspaceCompactNavItem = MenuItem & {
|
||||
};
|
||||
|
||||
export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
{
|
||||
id: "workspace-projects",
|
||||
label: "项目空间",
|
||||
icon: BookOpen,
|
||||
path: "/page/workspace/projects",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/projects"),
|
||||
},
|
||||
{
|
||||
id: "workspace-new-chat",
|
||||
label: "新对话",
|
||||
@@ -105,6 +98,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
path: "/page/workspace/skills",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/skills"),
|
||||
},
|
||||
{
|
||||
id: "workspace-memory",
|
||||
label: "记忆",
|
||||
icon: BrainIcon,
|
||||
path: "/page/workspace/memory",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/memory"),
|
||||
},
|
||||
{
|
||||
id: "workspace-ai-writing",
|
||||
label: "AI写作",
|
||||
|
||||
@@ -18,18 +18,15 @@ import { WorkspaceHeader } from "./workspace-header";
|
||||
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||
|
||||
export function WorkspaceSidebar({
|
||||
compactChrome = false,
|
||||
forceCollapsed = false,
|
||||
wxCommonAgents = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar> & {
|
||||
compactChrome?: boolean;
|
||||
forceCollapsed?: boolean;
|
||||
/** Enables wx's shared Agent Gallery category selection. */
|
||||
wxCommonAgents?: boolean;
|
||||
}) {
|
||||
export function WorkspaceSidebar({
|
||||
compactChrome = false,
|
||||
forceCollapsed = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar> & {
|
||||
compactChrome?: boolean;
|
||||
forceCollapsed?: boolean;
|
||||
}) {
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { theme } = useTheme();
|
||||
const pathname = usePathname();
|
||||
@@ -38,8 +35,6 @@ export function WorkspaceSidebar({
|
||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
||||
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
||||
const isGeneralQa =
|
||||
wxCommonAgents ||
|
||||
pathname.startsWith("/page/workspace/projects") ||
|
||||
pathname.startsWith("/page/workspace/chats") ||
|
||||
pathname.startsWith("/page/strategy/chats");
|
||||
|
||||
@@ -89,7 +84,7 @@ export function WorkspaceSidebar({
|
||||
{isGeneralQa ? (
|
||||
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
||||
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
||||
<GeneralQaSidebar wxCommonAgents={wxCommonAgents} />
|
||||
<GeneralQaSidebar />
|
||||
) : (
|
||||
<>
|
||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
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 }),
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
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,6 +1,5 @@
|
||||
import { apiFetch } from "../api";
|
||||
import { downloadMarkdownAsDocx } from "../scheduled-tasks/markdown-docx";
|
||||
import { prepareGeneratedHtml } from "../utils/legacy-html-preview";
|
||||
import { getFileName } from "../utils/files";
|
||||
|
||||
import { urlOfArtifact } from "./utils";
|
||||
@@ -61,23 +60,12 @@ export async function downloadArtifactFile({
|
||||
const filename =
|
||||
filenameFromContentDisposition(cd) ?? getFileName(filepath);
|
||||
|
||||
const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
|
||||
const isHtml = contentType === "text/html" || /\.html?$/i.test(filename);
|
||||
let downloadBlob: Blob;
|
||||
if (isHtml) {
|
||||
downloadBlob = new Blob(
|
||||
[
|
||||
prepareGeneratedHtml(await res.text()),
|
||||
],
|
||||
{ type: "text/html;charset=utf-8" },
|
||||
);
|
||||
} else {
|
||||
const blob = await res.blob();
|
||||
downloadBlob =
|
||||
contentType && blob.type !== contentType
|
||||
? new Blob([await blob.arrayBuffer()], { type: contentType })
|
||||
: blob;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
|
||||
const downloadBlob =
|
||||
contentType && blob.type !== contentType
|
||||
? new Blob([await blob.arrayBuffer()], { type: contentType })
|
||||
: blob;
|
||||
const objectUrl = URL.createObjectURL(downloadBlob);
|
||||
const a = document.createElement("a");
|
||||
a.href = objectUrl;
|
||||
@@ -123,16 +111,7 @@ export async function openArtifactInNewTab({
|
||||
if (!res.ok) {
|
||||
throw new Error(await errorMessageFromResponse(res));
|
||||
}
|
||||
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 blob = await res.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const w = window.open(objectUrl, "_blank", "noopener,noreferrer");
|
||||
if (w) w.opener = null;
|
||||
|
||||
@@ -4,66 +4,9 @@ import test from "node:test";
|
||||
import {
|
||||
isWriteFileInFlight,
|
||||
latestInFlightWriteFilePreview,
|
||||
latestPreparedHtmlArtifact,
|
||||
resolveWriteFilePath,
|
||||
} from "./write-file-in-flight";
|
||||
|
||||
test("latestPreparedHtmlArtifact opens the output created by template prepare", () => {
|
||||
const preview = latestPreparedHtmlArtifact([
|
||||
{
|
||||
id: "m1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "tpl1",
|
||||
name: "page_report_template",
|
||||
args: { action: "prepare" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
tool_call_id: "tpl1",
|
||||
content: JSON.stringify({
|
||||
ok: true,
|
||||
working_copy: "/mnt/user-data/outputs/人工智能发展报告.html",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(preview, {
|
||||
artifactPath: "/mnt/user-data/outputs/人工智能发展报告.html",
|
||||
previewKey: "page-template:tpl1",
|
||||
});
|
||||
});
|
||||
|
||||
test("latestPreparedHtmlArtifact ignores workspace and failed prepares", () => {
|
||||
assert.equal(
|
||||
latestPreparedHtmlArtifact([
|
||||
{
|
||||
id: "m1",
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "tpl1",
|
||||
name: "page_report_template",
|
||||
args: { action: "prepare" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
tool_call_id: "tpl1",
|
||||
content: JSON.stringify({
|
||||
ok: true,
|
||||
working_copy: "/mnt/user-data/workspace/draft.html",
|
||||
}),
|
||||
},
|
||||
]),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("latestInFlightWriteFilePreview opens a write_file that is not the last tool call", () => {
|
||||
const preview = latestInFlightWriteFilePreview([
|
||||
{
|
||||
|
||||
@@ -51,70 +51,6 @@ type WriteFileThreadLike = {
|
||||
}>;
|
||||
};
|
||||
|
||||
function parseToolJson(content: unknown): Record<string, unknown> | null {
|
||||
let value = content;
|
||||
if (Array.isArray(value)) {
|
||||
const textBlock = value.find(
|
||||
(item) =>
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"text" in item &&
|
||||
typeof (item as { text?: unknown }).text === "string",
|
||||
) as { text?: string } | undefined;
|
||||
value = textBlock?.text;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
value = JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A successful page-template prepare already creates a previewable HTML file
|
||||
* under outputs. Open it before the first edit arrives so the user sees the
|
||||
* sandbox as soon as page generation starts, even when the model edits via
|
||||
* str_replace or emits a complete write_file call in one stream frame.
|
||||
*/
|
||||
export function latestPreparedHtmlArtifact(
|
||||
messages: WriteFileThreadLike["messages"],
|
||||
): { artifactPath: string; previewKey: string } | null {
|
||||
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
||||
const message = messages[messageIndex];
|
||||
if (message?.type !== "ai" || !message.tool_calls) continue;
|
||||
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
|
||||
const toolCall = message.tool_calls[toolIndex];
|
||||
if (toolCall?.name !== "page_report_template" || !toolCall.id) continue;
|
||||
const args = toolCall.args as { action?: unknown } | undefined;
|
||||
if (String(args?.action ?? "").toLowerCase() !== "prepare") continue;
|
||||
const resultMessage = messages.find(
|
||||
(candidate) =>
|
||||
candidate.type === "tool" && candidate.tool_call_id === toolCall.id,
|
||||
);
|
||||
const result = parseToolJson(resultMessage?.content);
|
||||
if (result?.ok !== true) continue;
|
||||
const candidate =
|
||||
typeof result.working_copy === "string"
|
||||
? result.working_copy
|
||||
: typeof result.final_output === "string"
|
||||
? result.final_output
|
||||
: "";
|
||||
const artifactPath = candidate.trim();
|
||||
if (!/^\/mnt\/user-data\/outputs\/.+\.html?$/i.test(artifactPath)) continue;
|
||||
return {
|
||||
artifactPath,
|
||||
previewKey: `page-template:${toolCall.id}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasToolResult(thread: WriteFileThreadLike, toolCallId?: string) {
|
||||
if (!toolCallId) {
|
||||
return false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
@@ -125,9 +125,5 @@ export function AuthSyncWatcher({
|
||||
};
|
||||
}, [disabled, location.pathname, navigate, queryClient]);
|
||||
|
||||
// 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;
|
||||
return children ? <Fragment key={revision}>{children}</Fragment> : null;
|
||||
}
|
||||
@@ -96,6 +96,9 @@ export const enUS: Translations = {
|
||||
mode: "Mode",
|
||||
flashMode: "Flash",
|
||||
flashModeDescription: "Fast and efficient, but may not be accurate",
|
||||
turboMode: "Turbo",
|
||||
turboModeDescription:
|
||||
"Talk to the model directly with no agent tools or skills, for lower first-token latency",
|
||||
reasoningMode: "Reasoning",
|
||||
reasoningModeDescription:
|
||||
"Reasoning before action, balance between time and accuracy",
|
||||
@@ -448,17 +451,17 @@ export const enUS: Translations = {
|
||||
system: "System",
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
lightblue: "Light Blue",
|
||||
lightgreen: "Green",
|
||||
iceblue: "Glacier Blue",
|
||||
lightyellow: "Pale Yellow",
|
||||
lightblue: "Light Blue",
|
||||
lightgreen: "Green",
|
||||
iceblue: "Glacier Blue",
|
||||
lightyellow: "Pale Yellow",
|
||||
systemDescription: "Match the operating system preference automatically.",
|
||||
lightDescription: "Bright palette with higher contrast for daytime.",
|
||||
darkDescription: "Dim palette that reduces glare for focus.",
|
||||
lightblueDescription: "A crisp light palette built around blue.",
|
||||
lightgreenDescription: "A crisp light palette built around fresh green tones.",
|
||||
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
|
||||
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
|
||||
lightblueDescription: "A crisp light palette built around blue.",
|
||||
lightgreenDescription: "A crisp light palette built around fresh green tones.",
|
||||
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
|
||||
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
|
||||
languageTitle: "Language",
|
||||
languageDescription: "Switch between languages.",
|
||||
},
|
||||
@@ -545,13 +548,13 @@ export const enUS: Translations = {
|
||||
filesSaveAction: "Save",
|
||||
filesCancelEditAction: "Cancel",
|
||||
filesDownloadAction: "Download",
|
||||
filesCopyPathAction: "Copy path",
|
||||
filesDiscardConfirm: "Discard unsaved changes?",
|
||||
filesSaveSuccess: "File saved",
|
||||
filesDeleteAction: "Delete",
|
||||
filesDeleteConfirm: "Delete \"{path}\"? This cannot be undone.",
|
||||
filesDeleteSuccess: "File deleted",
|
||||
filesReadOnlyHint: "Only administrators can edit or delete built-in skill files.",
|
||||
filesCopyPathAction: "Copy path",
|
||||
filesDiscardConfirm: "Discard unsaved changes?",
|
||||
filesSaveSuccess: "File saved",
|
||||
filesDeleteAction: "Delete",
|
||||
filesDeleteConfirm: "Delete \"{path}\"? This cannot be undone.",
|
||||
filesDeleteSuccess: "File deleted",
|
||||
filesReadOnlyHint: "Only administrators can edit or delete built-in skill files.",
|
||||
viewDetailAction: "View skill details",
|
||||
stateSheet: {
|
||||
archivedTitle: "Archived skills",
|
||||
@@ -591,26 +594,26 @@ export const enUS: Translations = {
|
||||
chooseFile: "Choose file",
|
||||
validating: "Validating archive…",
|
||||
installing: "Installing…",
|
||||
replace: "Upload again and replace",
|
||||
replace: "Upload again and replace",
|
||||
install: "Install",
|
||||
cancel: "Cancel",
|
||||
nameLabel: "Name",
|
||||
descriptionLabel: "Description",
|
||||
licenseLabel: "License",
|
||||
toolsLabel: "Allowed tools",
|
||||
warningsLabel: "Warnings",
|
||||
conflictOwnedByMe:
|
||||
"You already uploaded a skill named '{name}'. You may upload it again and replace your existing copy (its enabled state will be kept).",
|
||||
conflictOwnedByMeWrongTarget:
|
||||
"The custom skill '{name}' belongs to you. Switch the target to 'Custom skill' to replace it.",
|
||||
conflictOwnedByOther:
|
||||
"Skill '{name}' was uploaded by user '{owner}' and cannot be replaced. Rename the skill before uploading.",
|
||||
conflictCustom:
|
||||
"A custom skill named '{name}' already exists, but its uploader could not be identified. Rename it to avoid overwriting someone else's skill.",
|
||||
warningsLabel: "Warnings",
|
||||
conflictOwnedByMe:
|
||||
"You already uploaded a skill named '{name}'. You may upload it again and replace your existing copy (its enabled state will be kept).",
|
||||
conflictOwnedByMeWrongTarget:
|
||||
"The custom skill '{name}' belongs to you. Switch the target to 'Custom skill' to replace it.",
|
||||
conflictOwnedByOther:
|
||||
"Skill '{name}' was uploaded by user '{owner}' and cannot be replaced. Rename the skill before uploading.",
|
||||
conflictCustom:
|
||||
"A custom skill named '{name}' already exists, but its uploader could not be identified. Rename it to avoid overwriting someone else's skill.",
|
||||
conflictPublic:
|
||||
"A built-in public skill named '{name}' already exists and cannot be overwritten.",
|
||||
conflictPublicAdminHint:
|
||||
"A built-in public skill named '{name}' exists. Click 'Upload again and replace' to overwrite it.",
|
||||
conflictPublicAdminHint:
|
||||
"A built-in public skill named '{name}' exists. Click 'Upload again and replace' to overwrite it.",
|
||||
installSuccess: "Skill '{name}' installed.",
|
||||
installSuccessReplaced: "Skill '{name}' replaced.",
|
||||
rejectInvalidExt: "Only .zip or .skill files are accepted.",
|
||||
|
||||
@@ -91,6 +91,8 @@ export interface Translations {
|
||||
mode: string;
|
||||
flashMode: string;
|
||||
flashModeDescription: string;
|
||||
turboMode: string;
|
||||
turboModeDescription: string;
|
||||
reasoningMode: string;
|
||||
reasoningModeDescription: string;
|
||||
proMode: string;
|
||||
@@ -383,17 +385,17 @@ export interface Translations {
|
||||
system: string;
|
||||
light: string;
|
||||
dark: string;
|
||||
lightblue: string;
|
||||
lightgreen: string;
|
||||
iceblue: string;
|
||||
lightyellow: string;
|
||||
lightblue: string;
|
||||
lightgreen: string;
|
||||
iceblue: string;
|
||||
lightyellow: string;
|
||||
systemDescription: string;
|
||||
lightDescription: string;
|
||||
darkDescription: string;
|
||||
lightblueDescription: string;
|
||||
lightgreenDescription: string;
|
||||
iceblueDescription: string;
|
||||
lightyellowDescription: string;
|
||||
lightblueDescription: string;
|
||||
lightgreenDescription: string;
|
||||
iceblueDescription: string;
|
||||
lightyellowDescription: string;
|
||||
languageTitle: string;
|
||||
languageDescription: string;
|
||||
};
|
||||
@@ -475,13 +477,13 @@ export interface Translations {
|
||||
filesSaveAction: string;
|
||||
filesCancelEditAction: string;
|
||||
filesDownloadAction: string;
|
||||
filesCopyPathAction: string;
|
||||
filesDiscardConfirm: string;
|
||||
filesSaveSuccess: string;
|
||||
filesDeleteAction: string;
|
||||
filesDeleteConfirm: string;
|
||||
filesDeleteSuccess: string;
|
||||
filesReadOnlyHint: string;
|
||||
filesCopyPathAction: string;
|
||||
filesDiscardConfirm: string;
|
||||
filesSaveSuccess: string;
|
||||
filesDeleteAction: string;
|
||||
filesDeleteConfirm: string;
|
||||
filesDeleteSuccess: string;
|
||||
filesReadOnlyHint: string;
|
||||
viewDetailAction: string;
|
||||
stateSheet: {
|
||||
archivedTitle: string;
|
||||
@@ -525,12 +527,12 @@ export interface Translations {
|
||||
nameLabel: string;
|
||||
descriptionLabel: string;
|
||||
licenseLabel: string;
|
||||
toolsLabel: string;
|
||||
warningsLabel: string;
|
||||
conflictOwnedByMe: string;
|
||||
conflictOwnedByMeWrongTarget: string;
|
||||
conflictOwnedByOther: string;
|
||||
conflictCustom: string;
|
||||
toolsLabel: string;
|
||||
warningsLabel: string;
|
||||
conflictOwnedByMe: string;
|
||||
conflictOwnedByMeWrongTarget: string;
|
||||
conflictOwnedByOther: string;
|
||||
conflictCustom: string;
|
||||
conflictPublic: string;
|
||||
conflictPublicAdminHint: string;
|
||||
installSuccess: string;
|
||||
|
||||
@@ -95,6 +95,8 @@ export const zhCN: Translations = {
|
||||
mode: "模式",
|
||||
flashMode: "快速",
|
||||
flashModeDescription: "快速且高效的完成任务,但可能不够精准",
|
||||
turboMode: "极速",
|
||||
turboModeDescription: "直连大模型流式问答,不走智能体工具与技能,首字更快",
|
||||
reasoningMode: "思考",
|
||||
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
|
||||
proMode: "专业问答",
|
||||
@@ -429,17 +431,17 @@ export const zhCN: Translations = {
|
||||
system: "系统",
|
||||
light: "浅色",
|
||||
dark: "深色",
|
||||
lightblue: "浅蓝",
|
||||
lightgreen: "绿色",
|
||||
iceblue: "冰川蓝",
|
||||
lightyellow: "浅黄色",
|
||||
lightblue: "浅蓝",
|
||||
lightgreen: "绿色",
|
||||
iceblue: "冰川蓝",
|
||||
lightyellow: "浅黄色",
|
||||
systemDescription: "自动跟随系统主题。",
|
||||
lightDescription: "更明亮的配色,适合日间使用。",
|
||||
darkDescription: "更暗的配色,减少眩光方便专注。",
|
||||
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
|
||||
lightgreenDescription: "采用清新绿色的浅色配色。",
|
||||
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
|
||||
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
|
||||
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
|
||||
lightgreenDescription: "采用清新绿色的浅色配色。",
|
||||
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
|
||||
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
|
||||
languageTitle: "语言",
|
||||
languageDescription: "在不同语言之间切换。",
|
||||
},
|
||||
@@ -524,11 +526,11 @@ export const zhCN: Translations = {
|
||||
filesDownloadAction: "下载",
|
||||
filesCopyPathAction: "复制路径",
|
||||
filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
|
||||
filesSaveSuccess: "文件已保存",
|
||||
filesDeleteAction: "删除",
|
||||
filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。",
|
||||
filesDeleteSuccess: "文件已删除",
|
||||
filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。",
|
||||
filesSaveSuccess: "文件已保存",
|
||||
filesDeleteAction: "删除",
|
||||
filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。",
|
||||
filesDeleteSuccess: "文件已删除",
|
||||
filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。",
|
||||
viewDetailAction: "查看技能详情",
|
||||
stateSheet: {
|
||||
archivedTitle: "归档的技能",
|
||||
@@ -566,24 +568,24 @@ export const zhCN: Translations = {
|
||||
chooseFile: "选择文件",
|
||||
validating: "正在校验文件…",
|
||||
installing: "安装中…",
|
||||
replace: "重复上传并覆盖",
|
||||
replace: "重复上传并覆盖",
|
||||
install: "安装",
|
||||
cancel: "取消",
|
||||
nameLabel: "名称",
|
||||
descriptionLabel: "描述",
|
||||
licenseLabel: "许可证",
|
||||
toolsLabel: "允许的工具",
|
||||
warningsLabel: "警告",
|
||||
conflictOwnedByMe:
|
||||
"您已上传过同名技能 “{name}”,可重复上传并覆盖原版本(原启用状态保留)。",
|
||||
conflictOwnedByMeWrongTarget:
|
||||
"同名技能 “{name}” 是您上传的自定义技能,请切换到「自定义技能」后覆盖。",
|
||||
conflictOwnedByOther:
|
||||
"技能 “{name}” 已由用户 “{owner}” 上传,不能覆盖,请修改技能名称后再上传。",
|
||||
conflictCustom:
|
||||
"已存在同名自定义技能 “{name}”,但无法确认上传用户,为避免误覆盖请修改技能名称。",
|
||||
warningsLabel: "警告",
|
||||
conflictOwnedByMe:
|
||||
"您已上传过同名技能 “{name}”,可重复上传并覆盖原版本(原启用状态保留)。",
|
||||
conflictOwnedByMeWrongTarget:
|
||||
"同名技能 “{name}” 是您上传的自定义技能,请切换到「自定义技能」后覆盖。",
|
||||
conflictOwnedByOther:
|
||||
"技能 “{name}” 已由用户 “{owner}” 上传,不能覆盖,请修改技能名称后再上传。",
|
||||
conflictCustom:
|
||||
"已存在同名自定义技能 “{name}”,但无法确认上传用户,为避免误覆盖请修改技能名称。",
|
||||
conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
|
||||
conflictPublicAdminHint: `存在同名内置技能 '{name}'。点击「重复上传并覆盖」可替换。`,
|
||||
conflictPublicAdminHint: `存在同名内置技能 '{name}'。点击「重复上传并覆盖」可替换。`,
|
||||
installSuccess: `技能 '{name}' 已安装。`,
|
||||
installSuccessReplaced: `技能 '{name}' 已替换。`,
|
||||
rejectInvalidExt: `只接受 .zip 或 .skill 文件。`,
|
||||
|
||||
@@ -38,46 +38,30 @@ export function buildAppHashUrl(path: string): string {
|
||||
}
|
||||
|
||||
export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget | null {
|
||||
if (!url) return null;
|
||||
|
||||
// Older stored messages use this private marker URL. Keep parsing it so old
|
||||
// conversations remain clickable, but never generate it for new links: it
|
||||
// is not a real route and becomes a 404 if an event handler is unavailable.
|
||||
if (url.startsWith("/weknora-source-preview?")) {
|
||||
const queryIndex = url.indexOf("?");
|
||||
const params = new URLSearchParams(url.slice(queryIndex + 1));
|
||||
const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
|
||||
const chunkId = params.get("chunk_id")?.trim();
|
||||
const documentId =
|
||||
params.get("document_id")?.trim() ||
|
||||
params.get("knowledge_id")?.trim() ||
|
||||
"";
|
||||
const wikiSlug =
|
||||
params.get("wiki_slug")?.trim() ||
|
||||
params.get("wikiSlug")?.trim() ||
|
||||
params.get("slug")?.trim() ||
|
||||
"";
|
||||
return knowledgeBaseId && (wikiSlug || chunkId || documentId)
|
||||
? {
|
||||
knowledgeBaseId,
|
||||
chunkId,
|
||||
documentId: documentId || undefined,
|
||||
wikiSlug: wikiSlug || undefined,
|
||||
tab: wikiSlug ? "wiki" : "documents",
|
||||
}
|
||||
: null;
|
||||
if (!url?.startsWith("/weknora-source-preview?")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// New drawer links use the same real HashRouter URL as the normal Wiki
|
||||
// reader. This doubles as a safe browser-navigation fallback when React's
|
||||
// Markdown click handler is not mounted (for example after a deployment).
|
||||
const route = url.includes("#") ? url.slice(url.indexOf("#") + 1) : url;
|
||||
const match = route.match(/^\/page\/workspace\/knowledge\/([^/?]+)\/wiki\/view(?:\?([^#]*))?$/);
|
||||
if (!match) return null;
|
||||
const knowledgeBaseId = decodeURIComponent(match[1] || "").trim();
|
||||
const wikiSlug = new URLSearchParams(match[2] || "").get("wikiSlug")?.trim();
|
||||
return knowledgeBaseId && wikiSlug
|
||||
? { knowledgeBaseId, wikiSlug, tab: "wiki" }
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,11 +20,6 @@ export type SearchResultItem = {
|
||||
published_date?: string;
|
||||
publishedDate?: string;
|
||||
date?: string;
|
||||
/** Retrieval score supplied by the search provider, when available. */
|
||||
score?: number;
|
||||
/** Original retrieval payload type, retained for specialised result renderers. */
|
||||
type?: string;
|
||||
knowledge_base_id?: string;
|
||||
};
|
||||
|
||||
const RESULT_LIST_KEYS = [
|
||||
@@ -201,25 +196,6 @@ function pickFromLayers(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pickNumberFromLayers(
|
||||
layers: Record<string, unknown>[],
|
||||
aliases: readonly string[],
|
||||
): number | undefined {
|
||||
for (const layer of layers) {
|
||||
for (const alias of aliases) {
|
||||
const value = layer[alias];
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pickByKeyPattern(
|
||||
layers: Record<string, unknown>[],
|
||||
pattern: RegExp,
|
||||
@@ -350,12 +326,6 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
||||
const recUuid = pickFromLayers(layers, ["recUuid", "rec_uuid"]);
|
||||
const uuid = pickFromLayers(layers, ["uuid"]);
|
||||
const docId = pickFromLayers(layers, ["docId", "doc_id", "documentId"]);
|
||||
const score = pickNumberFromLayers(layers, [
|
||||
"score",
|
||||
"relevance_score",
|
||||
"rerank_score",
|
||||
"similarity",
|
||||
]);
|
||||
|
||||
const reserved = new Set<string>();
|
||||
for (const value of [title, content, url, source, author, date]) {
|
||||
@@ -393,9 +363,6 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
||||
published_date: date,
|
||||
publishedDate: date,
|
||||
date,
|
||||
score,
|
||||
type: pickString(record.type),
|
||||
knowledge_base_id: pickString(record.knowledge_base_id, record.knowledgeBaseId, record.mapping_id),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -364,18 +364,17 @@ export function extractReasoningContentFromMessage(message: Message) {
|
||||
if (message.type !== "ai") {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
message.additional_kwargs &&
|
||||
"reasoning_content" in message.additional_kwargs
|
||||
) {
|
||||
return message.additional_kwargs.reasoning_content as string | null;
|
||||
}
|
||||
if (
|
||||
message.additional_kwargs &&
|
||||
typeof message.additional_kwargs.reasoning === "string"
|
||||
) {
|
||||
return message.additional_kwargs.reasoning;
|
||||
}
|
||||
if (message.additional_kwargs) {
|
||||
const reasoningContent = message.additional_kwargs.reasoning_content;
|
||||
if (typeof reasoningContent === "string") {
|
||||
return reasoningContent;
|
||||
}
|
||||
// Newer vLLM puts thinking in `reasoning` instead of `reasoning_content`.
|
||||
const reasoning = message.additional_kwargs.reasoning;
|
||||
if (typeof reasoning === "string") {
|
||||
return reasoning;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
const part = message.content[0];
|
||||
if (part && "thinking" in part) {
|
||||
@@ -391,9 +390,8 @@ export function extractReasoningContentFromMessage(message: Message) {
|
||||
export function removeReasoningContentFromMessage(message: Message) {
|
||||
if (message.type !== "ai" || !message.additional_kwargs) {
|
||||
return;
|
||||
}
|
||||
delete message.additional_kwargs.reasoning_content;
|
||||
delete message.additional_kwargs.reasoning;
|
||||
}
|
||||
delete message.additional_kwargs.reasoning_content;
|
||||
}
|
||||
|
||||
export function extractURLFromImageURLContent(
|
||||
@@ -428,12 +426,12 @@ export function hasReasoning(message: Message) {
|
||||
if (message.type !== "ai") {
|
||||
return false;
|
||||
}
|
||||
if (typeof message.additional_kwargs?.reasoning_content === "string") {
|
||||
return true;
|
||||
}
|
||||
if (typeof message.additional_kwargs?.reasoning === "string") {
|
||||
return true;
|
||||
}
|
||||
if (typeof message.additional_kwargs?.reasoning_content === "string") {
|
||||
return true;
|
||||
}
|
||||
if (typeof message.additional_kwargs?.reasoning === "string") {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
const part = message.content[0];
|
||||
// Compatible with the Anthropic gateway
|
||||
@@ -560,12 +558,11 @@ export function hasSubagent(message: AIMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function findToolCallResult(
|
||||
toolCallId: string,
|
||||
messages: Message[],
|
||||
toolName?: string,
|
||||
options?: { allowNameFallback?: boolean },
|
||||
): string | Record<string, unknown> | undefined {
|
||||
export function findToolCallResult(
|
||||
toolCallId: string,
|
||||
messages: Message[],
|
||||
toolName?: string,
|
||||
): string | Record<string, unknown> | undefined {
|
||||
for (const message of messages) {
|
||||
if (message.type !== "tool" || message.tool_call_id !== toolCallId) {
|
||||
continue;
|
||||
@@ -577,7 +574,7 @@ export function findToolCallResult(
|
||||
}
|
||||
|
||||
// updates 流里偶发缺少 tool_call_id,但 content 已是检索结构
|
||||
if (toolName && options?.allowNameFallback !== false) {
|
||||
if (toolName) {
|
||||
for (const message of messages) {
|
||||
if (message.type !== "tool" || message.name !== toolName) {
|
||||
continue;
|
||||
|
||||
@@ -10,7 +10,6 @@ export interface ManagedModel {
|
||||
base_url: string | null;
|
||||
request_timeout: number | null;
|
||||
max_retries: number | null;
|
||||
max_input_tokens: number | null;
|
||||
max_tokens: number | null;
|
||||
temperature: number | null;
|
||||
stream_usage: boolean | null;
|
||||
@@ -45,7 +44,6 @@ export interface ModelSavePayload {
|
||||
base_url?: string | null;
|
||||
request_timeout?: number | null;
|
||||
max_retries?: number | null;
|
||||
max_input_tokens?: number | null;
|
||||
max_tokens?: number | null;
|
||||
temperature?: number | null;
|
||||
stream_usage?: boolean | null;
|
||||
|
||||
@@ -4,11 +4,11 @@ export interface Model {
|
||||
model: string;
|
||||
display_name: string;
|
||||
description?: string | null;
|
||||
supports_thinking?: boolean;
|
||||
supports_reasoning_effort?: boolean;
|
||||
/** Execution profiles handled by the frontend instead of DeerFlow. */
|
||||
execution_profile?: "single_round_rag" | "multi_round_rag";
|
||||
}
|
||||
supports_thinking?: boolean;
|
||||
supports_reasoning_effort?: boolean;
|
||||
/** Execution profiles handled by the frontend instead of DeerFlow. */
|
||||
execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
|
||||
}
|
||||
|
||||
export interface TokenUsageSettings {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -30,7 +30,7 @@ test("matches only workspace chats_iframe routes", () => {
|
||||
assert.equal(isChatsIframeWorkspacePath("/landing"), false);
|
||||
});
|
||||
|
||||
test("forces the dedicated presentation themes for landing, iframe, and wx chat routes", () => {
|
||||
test("forces light theme for landing and chats_iframe routes only", () => {
|
||||
assert.equal(resolveForcedThemeForPath("/landing"), "light");
|
||||
assert.equal(
|
||||
resolveForcedThemeForPath("/page/workspace/chats_iframe/new"),
|
||||
@@ -41,14 +41,6 @@ test("forces the dedicated presentation themes for landing, iframe, and wx chat
|
||||
"light",
|
||||
);
|
||||
assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined);
|
||||
assert.equal(
|
||||
resolveForcedThemeForPath("/page/workspace/wx/chats/new"),
|
||||
"dark",
|
||||
);
|
||||
assert.equal(
|
||||
resolveForcedThemeForPath("/page/workspace/wx/agents/agent-1/chats/new"),
|
||||
"dark",
|
||||
);
|
||||
});
|
||||
|
||||
test("recognizes an explicit chatMode=iframe independently of the legacy marker", () => {
|
||||
|
||||
@@ -3,8 +3,6 @@ import { resolveEmbedThemeMode } from "../embed/theme-mode.mjs";
|
||||
const CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new";
|
||||
const IFRAME_LOGIN_SOURCE = "iframe";
|
||||
const STANDARD_CHATS_PATH = "/page/workspace/chats";
|
||||
/** Dedicated full chat surface whose presentation is always deep blue. */
|
||||
export const WX_FULL_CHAT_PATH_PREFIX = "/page/workspace/wx/";
|
||||
|
||||
function toSearchParams(search: string): URLSearchParams {
|
||||
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||
@@ -330,10 +328,6 @@ export function resolveForcedThemeForPath(
|
||||
return "light";
|
||||
}
|
||||
|
||||
if (pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX)) {
|
||||
return "dark";
|
||||
}
|
||||
|
||||
const isLoginPath = pathname === "/login" || pathname.startsWith("/login/");
|
||||
if (
|
||||
isChatsIframeWorkspacePath(pathname) ||
|
||||
|
||||
@@ -73,7 +73,6 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
|
||||
{ id: "analysis-projects", label: "项目空间", icon: Layout, path: "/page/workspace/projects" },
|
||||
{ id: "artifact-library", label: "资料库", icon: Library, path: "/page/workspace/library" },
|
||||
{
|
||||
id: "multi-agent-roundtable",
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
/**
|
||||
* 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,42 +249,7 @@ export function collectRuntimeDisplaySources(
|
||||
() => ++citationIndex,
|
||||
);
|
||||
}
|
||||
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;
|
||||
});
|
||||
return sources;
|
||||
}
|
||||
|
||||
function appendDisplaySources(
|
||||
@@ -503,8 +468,7 @@ export function shouldUseDefaultCitationMode(
|
||||
normalizedName.includes("search") ||
|
||||
normalizedName.includes("retriev") ||
|
||||
normalizedName.includes("knowledge") ||
|
||||
normalizedName.includes("query") ||
|
||||
normalizedName.includes("wiki");
|
||||
normalizedName.includes("query");
|
||||
const isSkillInvocation = !!(
|
||||
toolArgs &&
|
||||
canInferSkillFromToolArgs(toolName) &&
|
||||
@@ -531,13 +495,12 @@ export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceI
|
||||
item.id ??
|
||||
item.url,
|
||||
title: item.title ?? item.source ?? `结果 ${index + 1}`,
|
||||
type: item.type ?? "result",
|
||||
type: "result",
|
||||
snippet: item.snippet ?? item.content ?? "",
|
||||
content: item.content ?? item.snippet,
|
||||
source: item.source,
|
||||
author: item.author,
|
||||
time: item.published_date ?? item.publishedDate ?? item.date,
|
||||
score: item.score,
|
||||
url: item.url,
|
||||
raw: item as unknown as Record<string, unknown>,
|
||||
}));
|
||||
@@ -556,12 +519,6 @@ export function applyInlineCitations(
|
||||
return content;
|
||||
}
|
||||
const byIndex = new Map(referenceSources.map((source) => [source.index, source]));
|
||||
const byCitationId = new Map(
|
||||
referenceSources.flatMap((source) => {
|
||||
const id = typeof source.raw?.citation_id === "string" ? source.raw.citation_id.trim() : "";
|
||||
return id ? [[id, source] as const] : [];
|
||||
}),
|
||||
);
|
||||
const byUrl = new Map<string, RuntimeDisplaySource>();
|
||||
const byTitle = new Map<string, RuntimeDisplaySource>();
|
||||
for (const source of referenceSources) {
|
||||
@@ -573,13 +530,13 @@ export function applyInlineCitations(
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = replaceStableCitationTokens(normalizeExistingCitationLinks(
|
||||
const normalized = normalizeExistingCitationLinks(
|
||||
content,
|
||||
byIndex,
|
||||
byUrl,
|
||||
byTitle,
|
||||
referenceSources,
|
||||
), byCitationId);
|
||||
);
|
||||
|
||||
const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g;
|
||||
let cursor = 0;
|
||||
@@ -613,13 +570,6 @@ export function applyInlineCitations(
|
||||
return appendCitationSummary(next, referenceSources);
|
||||
}
|
||||
|
||||
function replaceStableCitationTokens(content: string, byCitationId: Map<string, RuntimeDisplaySource>): string {
|
||||
return content.replace(/\[\[cite:([^\]]+)\]\]/gi, (match, rawId: string) => {
|
||||
const source = byCitationId.get(rawId.trim());
|
||||
return source ? citationLinkMarkdown(source) : match;
|
||||
});
|
||||
}
|
||||
|
||||
function replaceCitationTokens(
|
||||
content: string,
|
||||
byIndex: Map<number, RuntimeDisplaySource>,
|
||||
|
||||
@@ -314,42 +314,6 @@ export interface PublishSquaresUpdate {
|
||||
default_square_id?: string;
|
||||
}
|
||||
|
||||
/** Agent Gallery category selected for the wx route's common-agent panel. */
|
||||
export interface WxCommonAgentsSettings {
|
||||
/** `null` keeps the original common-agent recommendation logic. */
|
||||
agent_type: string | null;
|
||||
}
|
||||
|
||||
export async function loadWxCommonAgents(): Promise<WxCommonAgentsSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/wx-common-agents`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load wx common agents: ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as WxCommonAgentsSettings;
|
||||
}
|
||||
|
||||
export async function updateWxCommonAgents(
|
||||
agentType: string | null,
|
||||
): Promise<WxCommonAgentsSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/wx-common-agents`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ agent_type: agentType }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to update wx common agents" }));
|
||||
throw new Error(detail.detail ?? "Failed to update wx common agents");
|
||||
}
|
||||
return (await response.json()) as WxCommonAgentsSettings;
|
||||
}
|
||||
|
||||
export async function loadPublishSquares(): Promise<PublishSquaresSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/publish-squares`,
|
||||
@@ -637,48 +601,6 @@ export interface ModelConcurrencyUpdate {
|
||||
user_model_limits?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface MemoryRuntimeSettings {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export async function loadMemoryRuntimeSettings(): Promise<MemoryRuntimeSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/memory`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load memory runtime setting: ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as MemoryRuntimeSettings;
|
||||
}
|
||||
|
||||
export async function updateMemoryRuntimeSettings(
|
||||
body: MemoryRuntimeSettings,
|
||||
): Promise<MemoryRuntimeSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/memory`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to update memory runtime setting" }));
|
||||
throw new Error(detail.detail ?? "Failed to update memory runtime setting");
|
||||
}
|
||||
const data = (await response.json()) as MemoryRuntimeSettings;
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "CM助手-全量-系统设置-问答记忆开关",
|
||||
url: "/api/system-settings/memory",
|
||||
reqParam: body,
|
||||
returnParam: data,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
|
||||
|
||||
@@ -8,32 +8,27 @@ import {
|
||||
loadCitationDisplaySettings,
|
||||
loadLeaderboardSettings,
|
||||
loadModelConcurrencySettings,
|
||||
loadMemoryRuntimeSettings,
|
||||
loadPluginManagementSettings,
|
||||
loadPrivateSquareStatus,
|
||||
loadPromptPrefixSettings,
|
||||
loadPublishSquares,
|
||||
loadSkillCompressionSettings,
|
||||
loadStrategyProjectSettings,
|
||||
loadWxCommonAgents,
|
||||
updateAgentModuleConfig,
|
||||
updateAppearanceSettings,
|
||||
updateCitationDisplaySettings,
|
||||
updateLeaderboardSettings,
|
||||
updateModelConcurrencySettings,
|
||||
updateMemoryRuntimeSettings,
|
||||
updatePluginManagementSettings,
|
||||
updatePrivateSquareSettings,
|
||||
updatePromptPrefixSettings,
|
||||
updatePublishSquares,
|
||||
updateSkillCompressionSettings,
|
||||
updateStrategyProjectSettings,
|
||||
updateWxCommonAgents,
|
||||
type AppearanceUpdate,
|
||||
type CitationDisplayUpdate,
|
||||
type LeaderboardSettingsUpdate,
|
||||
type ModelConcurrencyUpdate,
|
||||
type MemoryRuntimeSettings,
|
||||
type PluginManagementUpdate,
|
||||
type PrivateSquareUpdate,
|
||||
type PromptPrefixUpdate,
|
||||
@@ -52,7 +47,6 @@ const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
|
||||
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
|
||||
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
|
||||
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
|
||||
const MEMORY_RUNTIME_KEY = ["systemSettings", "memoryRuntime"] as const;
|
||||
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
|
||||
const STRATEGY_PROJECTS_KEY = ["systemSettings", "strategyProjects"] as const;
|
||||
|
||||
@@ -117,36 +111,15 @@ export function useUpdatePrivateSquareSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useMemoryRuntimeSettings() {
|
||||
return useQuery({
|
||||
queryKey: MEMORY_RUNTIME_KEY,
|
||||
queryFn: () => loadMemoryRuntimeSettings(),
|
||||
staleTime: 10_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateMemoryRuntimeSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: MemoryRuntimeSettings) =>
|
||||
updateMemoryRuntimeSettings(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(MEMORY_RUNTIME_KEY, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** The configured publish squares (发布广场). Readable by any logged-in user —
|
||||
* the gallery sub-tabs and the publish-flow square picker both need it. */
|
||||
export function usePublishSquares({ enabled = true }: { enabled?: boolean } = {}) {
|
||||
export function usePublishSquares() {
|
||||
return useQuery({
|
||||
queryKey: PUBLISH_SQUARES_KEY,
|
||||
queryFn: () => loadPublishSquares(),
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -234,9 +207,7 @@ export function useLayoutMode(): [LayoutMode, (mode: LayoutMode) => void] {
|
||||
const agentModuleKey = (key: string) =>
|
||||
["systemSettings", "agentModule", key] as const;
|
||||
|
||||
export function useAgentModuleConfig(
|
||||
key: string,
|
||||
) {
|
||||
export function useAgentModuleConfig(key: string) {
|
||||
return useQuery({
|
||||
queryKey: agentModuleKey(key),
|
||||
queryFn: () => loadAgentModuleConfig(key),
|
||||
@@ -245,28 +216,6 @@ export function useAgentModuleConfig(
|
||||
});
|
||||
}
|
||||
|
||||
const WX_COMMON_AGENTS_KEY = ["systemSettings", "wxCommonAgents"] as const;
|
||||
|
||||
export function useWxCommonAgents({ enabled = true }: { enabled?: boolean } = {}) {
|
||||
return useQuery({
|
||||
queryKey: WX_COMMON_AGENTS_KEY,
|
||||
queryFn: loadWxCommonAgents,
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateWxCommonAgents() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: updateWxCommonAgents,
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(WX_COMMON_AGENTS_KEY, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAgentModuleConfig(key: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -16,14 +16,14 @@ import { rememberAskTime } from "../chat-timestamps";
|
||||
import { getBackendBaseURL, getLangGraphBaseURL } from "../config";
|
||||
import { useI18n } from "../i18n/hooks";
|
||||
import { OptType, writeYLog } from "../log/y-log";
|
||||
import type { FileInMessage } from "../messages/utils";
|
||||
import type { LocalSettings } from "../settings";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
useLocalSettings,
|
||||
} from "../settings";
|
||||
import { useCitationDisplaySettings } from "../system-settings/hooks";
|
||||
import type { FileInMessage } from "../messages/utils";
|
||||
import type { LocalSettings } from "../settings";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
useLocalSettings,
|
||||
} from "../settings";
|
||||
import { useCitationDisplaySettings } from "../system-settings/hooks";
|
||||
import { useUpdateSubtask } from "../tasks/context";
|
||||
import type { UploadedFileInfo } from "../uploads";
|
||||
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
||||
@@ -571,11 +571,11 @@ export function useThreadStream({
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
}: ThreadStreamOptions) {
|
||||
const { t } = useI18n();
|
||||
const [localSettings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
const { t } = useI18n();
|
||||
const [localSettings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
// Track the thread ID that is currently streaming to handle thread changes during streaming
|
||||
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
|
||||
// Ref to track current thread ID across async callbacks without causing re-renders,
|
||||
@@ -826,14 +826,20 @@ export function useThreadStream({
|
||||
return;
|
||||
}
|
||||
|
||||
// Background compaction stays silent, including events from older backends.
|
||||
// Context compression about to run a (blocking) summarization LLM call —
|
||||
// surface a transient hint so the wait before the answer streams isn't a
|
||||
// silent dead air. Emitted by DeerFlowSummarizationMiddleware.
|
||||
if (
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"type" in event &&
|
||||
event.type === "context_compacting"
|
||||
event.type === "context_compacting" &&
|
||||
"message" in event &&
|
||||
typeof event.message === "string" &&
|
||||
event.message.trim()
|
||||
) {
|
||||
return;
|
||||
const e = event as { type: "context_compacting"; message: string };
|
||||
toast(e.message);
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
@@ -880,15 +886,8 @@ export function useThreadStream({
|
||||
// Move to a fresh chat without a full browser reload; the old reload path
|
||||
// made every click look like a crash and could loop indefinitely when a
|
||||
// different tab's cookie kept overriding this tab's Bearer token.
|
||||
if (isThreadNotAccessibleError(message)) {
|
||||
const 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();
|
||||
if (isThreadNotAccessibleError(message)) {
|
||||
const userLabel = buildCurrentUserLabel();
|
||||
toast.info(
|
||||
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
|
||||
);
|
||||
@@ -1508,21 +1507,21 @@ export function useThreadStream({
|
||||
...context,
|
||||
...extraContext,
|
||||
};
|
||||
const selectedLlmWikiIds = Array.isArray(
|
||||
mergedContext.llmwiki_knowledge_base_ids,
|
||||
)
|
||||
? mergedContext.llmwiki_knowledge_base_ids
|
||||
: [];
|
||||
const selectedAssistantKnowledgeIds = Array.isArray(
|
||||
mergedContext.assistant_knowledge_base_ids,
|
||||
)
|
||||
? mergedContext.assistant_knowledge_base_ids
|
||||
: [];
|
||||
const ragModeEnabled =
|
||||
(referenceModeEntryEnabled && localSettings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
selectedLlmWikiIds.length > 0 ||
|
||||
selectedAssistantKnowledgeIds.length > 0;
|
||||
const selectedLlmWikiIds = Array.isArray(
|
||||
mergedContext.llmwiki_knowledge_base_ids,
|
||||
)
|
||||
? mergedContext.llmwiki_knowledge_base_ids
|
||||
: [];
|
||||
const selectedAssistantKnowledgeIds = Array.isArray(
|
||||
mergedContext.assistant_knowledge_base_ids,
|
||||
)
|
||||
? mergedContext.assistant_knowledge_base_ids
|
||||
: [];
|
||||
const ragModeEnabled =
|
||||
(referenceModeEntryEnabled && localSettings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
selectedLlmWikiIds.length > 0 ||
|
||||
selectedAssistantKnowledgeIds.length > 0;
|
||||
if (ragModeEnabled) {
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
@@ -1580,6 +1579,7 @@ export function useThreadStream({
|
||||
context: {
|
||||
...mergedContext,
|
||||
thinking_enabled: mergedContext.mode !== "flash",
|
||||
thinking_force_disabled: mergedContext.mode === "flash",
|
||||
is_plan_mode:
|
||||
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
|
||||
subagent_enabled: mergedContext.mode === "ultra",
|
||||
@@ -1592,6 +1592,8 @@ export function useThreadStream({
|
||||
: mergedContext.mode === "thinking"
|
||||
? "low"
|
||||
: undefined),
|
||||
memory_injection_enabled:
|
||||
mergedContext.memory_injection_enabled ?? true,
|
||||
rag_mode_enabled: ragModeEnabled,
|
||||
summarization_enabled: contextCompressionEnabled,
|
||||
thread_id: threadId,
|
||||
@@ -1638,18 +1640,18 @@ export function useThreadStream({
|
||||
sendInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[
|
||||
thread,
|
||||
_handleOnStart,
|
||||
t.uploads.uploadingFiles,
|
||||
context,
|
||||
referenceModeEntryEnabled,
|
||||
localSettings.display.ragModeEnabled,
|
||||
queryClient,
|
||||
runOptions,
|
||||
confirmOrRejoin,
|
||||
],
|
||||
);
|
||||
[
|
||||
thread,
|
||||
_handleOnStart,
|
||||
t.uploads.uploadingFiles,
|
||||
context,
|
||||
referenceModeEntryEnabled,
|
||||
localSettings.display.ragModeEnabled,
|
||||
queryClient,
|
||||
runOptions,
|
||||
confirmOrRejoin,
|
||||
],
|
||||
);
|
||||
|
||||
const handleThreadStop = useCallback(async () => {
|
||||
const tid = threadIdRef.current;
|
||||
@@ -1698,7 +1700,7 @@ export function useThreadStream({
|
||||
? undefined
|
||||
: thread.error;
|
||||
|
||||
const mergedThread = {
|
||||
const mergedThread = {
|
||||
...thread,
|
||||
error: visibleThreadError,
|
||||
values: mergedValues,
|
||||
@@ -1708,7 +1710,7 @@ export function useThreadStream({
|
||||
: mergedMessages,
|
||||
isLoading: thread.isLoading || streamHoldOpen,
|
||||
stop: handleThreadStop,
|
||||
} as typeof thread;
|
||||
} as typeof thread;
|
||||
|
||||
// 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。
|
||||
// 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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");
|
||||
});
|
||||
@@ -2,35 +2,38 @@ import type { Message, Thread } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { Todo } from "../todos";
|
||||
|
||||
export interface AgentThreadState extends Record<string, unknown> {
|
||||
title: string;
|
||||
title_provisional?: boolean;
|
||||
messages: Message[];
|
||||
artifacts: string[];
|
||||
todos?: Todo[];
|
||||
}
|
||||
export interface AgentThreadState extends Record<string, unknown> {
|
||||
title: string;
|
||||
title_provisional?: boolean;
|
||||
messages: Message[];
|
||||
artifacts: string[];
|
||||
todos?: Todo[];
|
||||
}
|
||||
|
||||
export interface AgentThreadContext extends Record<string, unknown> {
|
||||
thread_id: string;
|
||||
model_name: string | undefined;
|
||||
thinking_enabled: boolean;
|
||||
/** Flash mode: force vLLM/Qwen ``enable_thinking=false`` even without a per-model disable shape. */
|
||||
thinking_force_disabled?: boolean;
|
||||
is_plan_mode: boolean;
|
||||
subagent_enabled: boolean;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
writing_artifact_path?: string;
|
||||
memory_injection_enabled?: boolean;
|
||||
rag_mode_enabled?: boolean;
|
||||
/** Per-request switch for context compression (上下文压缩). Default off. */
|
||||
summarization_enabled?: boolean;
|
||||
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
|
||||
knowledge_rag_enabled?: boolean;
|
||||
/** DeerFlow LLMWiki mapping ids selected for this conversation. */
|
||||
llmwiki_knowledge_base_ids?: string[];
|
||||
/** Assistant knowledge-base ids selected for this conversation. */
|
||||
assistant_knowledge_base_ids?: string[];
|
||||
/** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */
|
||||
execution_profile?: "single_round_rag" | "multi_round_rag";
|
||||
agent_id?: string;
|
||||
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
|
||||
knowledge_rag_enabled?: boolean;
|
||||
/** DeerFlow LLMWiki mapping ids selected for this conversation. */
|
||||
llmwiki_knowledge_base_ids?: string[];
|
||||
/** Assistant knowledge-base ids selected for this conversation. */
|
||||
assistant_knowledge_base_ids?: string[];
|
||||
/** Frontend-only execution paths that never enter Lead Agent (RAG / 极速直连). */
|
||||
execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
|
||||
agent_id?: string;
|
||||
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||
agent_name?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { readRuntimeFlag } from "@/env";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
|
||||
import { textOfMessage } from "./utils";
|
||||
|
||||
const STORAGE_PREFIX = "workspace.direct-chat.";
|
||||
const MAX_HISTORY_MESSAGES = 20;
|
||||
|
||||
export function isDirectChatProfile(context: unknown): boolean {
|
||||
return (
|
||||
typeof context === "object" &&
|
||||
context !== null &&
|
||||
(context as { execution_profile?: unknown }).execution_profile === "direct"
|
||||
);
|
||||
}
|
||||
|
||||
/** 是否在模式菜单展示「极速」。仅 runtime-config.js 显式为 "true" 时开启,默认关闭。 */
|
||||
export function isTurboModeVisible(): boolean {
|
||||
return readRuntimeFlag("VITE_TURBO_MODE_VISIBLE");
|
||||
}
|
||||
|
||||
function storageKey(threadId: string) {
|
||||
return `${STORAGE_PREFIX}${threadId}`;
|
||||
}
|
||||
|
||||
function readStoredMessages(threadId: string): Message[] {
|
||||
if (typeof window === "undefined" || !threadId) return [];
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(storageKey(threadId));
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? (parsed as Message[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredMessages(threadId: string, messages: Message[]) {
|
||||
if (typeof window === "undefined" || !threadId) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages));
|
||||
} catch {
|
||||
// Quota errors must never interrupt an already-running answer.
|
||||
}
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException
|
||||
? error.name === "AbortError"
|
||||
: error instanceof Error && error.name === "AbortError";
|
||||
}
|
||||
|
||||
function historyFromMessages(messages: Message[]): Array<{
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}> {
|
||||
const history: Array<{ role: "user" | "assistant"; content: string }> = [];
|
||||
for (const message of messages) {
|
||||
if (message.type !== "human" && message.type !== "ai") continue;
|
||||
const content = textOfMessage(message)?.trim();
|
||||
if (!content) continue;
|
||||
history.push({
|
||||
role: message.type === "human" ? "user" : "assistant",
|
||||
content,
|
||||
});
|
||||
}
|
||||
return history.slice(-MAX_HISTORY_MESSAGES);
|
||||
}
|
||||
|
||||
function parseSseBlocks(buffer: string): {
|
||||
events: Array<{ event?: string; data: string }>;
|
||||
rest: string;
|
||||
} {
|
||||
const parts = buffer.split("\n\n");
|
||||
const rest = parts.pop() ?? "";
|
||||
const events: Array<{ event?: string; data: string }> = [];
|
||||
for (const part of parts) {
|
||||
let event: string | undefined;
|
||||
const dataLines: string[] = [];
|
||||
for (const line of part.split("\n")) {
|
||||
if (line.startsWith(":")) continue;
|
||||
if (line.startsWith("event:")) event = line.slice(6).trim();
|
||||
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
|
||||
}
|
||||
if (dataLines.length > 0) {
|
||||
events.push({ event, data: dataLines.join("\n") });
|
||||
}
|
||||
}
|
||||
return { events, rest };
|
||||
}
|
||||
|
||||
async function readErrorDetail(response: Response): Promise<string> {
|
||||
try {
|
||||
const payload: unknown = await response.json();
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"detail" in payload &&
|
||||
typeof (payload as { detail: unknown }).detail === "string"
|
||||
) {
|
||||
return (payload as { detail: string }).detail;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to status text.
|
||||
}
|
||||
return `极速问答失败(${response.status})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frontend-only adapter that talks to Gateway ``POST /api/direct-chat/stream``.
|
||||
* It never submits a LangGraph run or touches the lead-agent stack.
|
||||
*/
|
||||
export function useDirectChatMessages(threadId: string | undefined) {
|
||||
const [messages, setMessages] = useState<Message[]>(() =>
|
||||
readStoredMessages(threadId ?? ""),
|
||||
);
|
||||
const messagesRef = useRef(messages);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readStoredMessages(threadId ?? "");
|
||||
messagesRef.current = stored;
|
||||
setMessages(stored);
|
||||
setError(null);
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesRef.current = messages;
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => () => controllerRef.current?.abort(), []);
|
||||
|
||||
const updateMessages = useCallback(
|
||||
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
|
||||
setMessages((current) => {
|
||||
const next = updater(current);
|
||||
messagesRef.current = next;
|
||||
writeStoredMessages(targetThreadId, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => controllerRef.current?.abort(), []);
|
||||
|
||||
const submit = useCallback(
|
||||
async ({
|
||||
targetThreadId,
|
||||
query,
|
||||
modelName,
|
||||
}: {
|
||||
targetThreadId: string;
|
||||
query: string;
|
||||
modelName?: string;
|
||||
}) => {
|
||||
if (isLoading) return;
|
||||
const question = query.trim();
|
||||
if (!question) return;
|
||||
|
||||
const humanId = createClientId("direct-human");
|
||||
const assistantId = createClientId("direct-ai");
|
||||
const clientTs = new Date().toISOString();
|
||||
const history = historyFromMessages(messagesRef.current);
|
||||
const userMessage: Message = {
|
||||
id: humanId,
|
||||
type: "human",
|
||||
content: [{ type: "text", text: question }],
|
||||
additional_kwargs: { client_ts: clientTs, direct_chat: true },
|
||||
};
|
||||
const initialAssistant: Message = {
|
||||
id: assistantId,
|
||||
type: "ai",
|
||||
content: "",
|
||||
additional_kwargs: { direct_chat: true },
|
||||
};
|
||||
updateMessages(targetThreadId, (current) => [
|
||||
...current,
|
||||
userMessage,
|
||||
initialAssistant,
|
||||
]);
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
let answer = "";
|
||||
|
||||
const refreshAssistant = (content: string) => {
|
||||
updateMessages(targetThreadId, (current) =>
|
||||
current.map((item) =>
|
||||
item.id === assistantId
|
||||
? ({
|
||||
...item,
|
||||
content,
|
||||
additional_kwargs: {
|
||||
...item.additional_kwargs,
|
||||
direct_chat: true,
|
||||
},
|
||||
} as Message)
|
||||
: item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/direct-chat/stream`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model_name: modelName,
|
||||
messages: [...history, { role: "user", content: question }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorDetail(response));
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("极速问答未返回数据流。");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const parsed = parseSseBlocks(buffer);
|
||||
buffer = parsed.rest;
|
||||
for (const block of parsed.events) {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = JSON.parse(block.data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (block.event === "error") {
|
||||
const detail =
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"detail" in payload &&
|
||||
typeof (payload as { detail: unknown }).detail === "string"
|
||||
? (payload as { detail: string }).detail
|
||||
: "极速问答失败。";
|
||||
throw new Error(detail);
|
||||
}
|
||||
if (
|
||||
block.event === "delta" &&
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"text" in payload &&
|
||||
typeof (payload as { text: unknown }).text === "string"
|
||||
) {
|
||||
answer += (payload as { text: string }).text;
|
||||
refreshAssistant(answer);
|
||||
}
|
||||
if (
|
||||
block.event === "result" &&
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"answer" in payload &&
|
||||
typeof (payload as { answer: unknown }).answer === "string"
|
||||
) {
|
||||
answer = (payload as { answer: string }).answer;
|
||||
refreshAssistant(answer);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!answer) {
|
||||
throw new Error("模型未返回任何文本。");
|
||||
}
|
||||
} catch (caught) {
|
||||
if (!isAbortError(caught)) {
|
||||
const message =
|
||||
caught instanceof Error ? caught.message : "极速问答失败。";
|
||||
setError(message);
|
||||
updateMessages(targetThreadId, (current) =>
|
||||
current.map((item) =>
|
||||
item.id === assistantId
|
||||
? ({
|
||||
...item,
|
||||
content: answer || message,
|
||||
additional_kwargs: {
|
||||
...item.additional_kwargs,
|
||||
direct_chat: true,
|
||||
},
|
||||
} as Message)
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (controllerRef.current === controller) controllerRef.current = null;
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[isLoading, updateMessages],
|
||||
);
|
||||
|
||||
return { messages, isLoading, error, submit, stop };
|
||||
}
|
||||
@@ -45,17 +45,10 @@ export function pathOfThread(
|
||||
base = "/page/workspace",
|
||||
) {
|
||||
const threadId = typeof thread === "string" ? thread : thread.thread_id;
|
||||
const projectId =
|
||||
typeof thread === "string"
|
||||
? undefined
|
||||
: thread.metadata?.analysis_project_id;
|
||||
if (typeof projectId === "string" && projectId) {
|
||||
return `${base}/projects/${encodeURIComponent(projectId)}/chats/${encodeURIComponent(threadId)}`;
|
||||
}
|
||||
const agentRef =
|
||||
typeof thread === "string"
|
||||
? pickAgentRef(context, null)
|
||||
: (agentRefOfThread(thread) ?? pickAgentRef(context, null));
|
||||
: agentRefOfThread(thread) ?? pickAgentRef(context, null);
|
||||
|
||||
return agentRef
|
||||
? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
|
||||
@@ -127,9 +120,7 @@ export function isSetupWizardThread(thread: {
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}): boolean {
|
||||
const threadType = thread.metadata?.thread_type;
|
||||
return (
|
||||
threadType === "writing_setup" || threadType === "report_structure_setup"
|
||||
);
|
||||
return threadType === "writing_setup" || threadType === "report_structure_setup";
|
||||
}
|
||||
|
||||
/** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */
|
||||
@@ -139,10 +130,7 @@ export function isHiddenFromRecentChats(thread: {
|
||||
const metadata = thread.metadata;
|
||||
if (metadata?.system === true) return true;
|
||||
const threadType = metadata?.thread_type;
|
||||
return (
|
||||
typeof threadType === "string" &&
|
||||
HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType)
|
||||
);
|
||||
return typeof threadType === "string" && HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType);
|
||||
}
|
||||
|
||||
/** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */
|
||||
@@ -163,27 +151,18 @@ export function bootstrapResumePath(threadId: string): string {
|
||||
}
|
||||
|
||||
/** 列表项点击目标:bootstrap 会话回创建页恢复,其余走普通问答路由。 */
|
||||
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)}`;
|
||||
}
|
||||
export function listPathOfThread(thread: AgentThread): string {
|
||||
return isBootstrapThread(thread)
|
||||
? bootstrapResumePath(thread.thread_id)
|
||||
: pathOfThread(thread);
|
||||
}
|
||||
|
||||
export function isThreadPinned(thread: AgentThread): boolean {
|
||||
return (
|
||||
(thread.metadata as Record<string, unknown> | null)?.is_pinned === true
|
||||
);
|
||||
return (thread.metadata as Record<string, unknown> | null)?.is_pinned === true;
|
||||
}
|
||||
|
||||
export function isThreadFavorite(thread: AgentThread): boolean {
|
||||
return (
|
||||
(thread.metadata as Record<string, unknown> | null)?.is_favorite === true
|
||||
);
|
||||
return (thread.metadata as Record<string, unknown> | null)?.is_favorite === true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,9 +179,7 @@ export function sortThreadsPinnedFirst(threads: AgentThread[]): AgentThread[] {
|
||||
return [...pinned, ...rest];
|
||||
}
|
||||
|
||||
export function sanitizeThreadTitleForDisplay(
|
||||
title: string | null | undefined,
|
||||
) {
|
||||
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
|
||||
if (!title) return "Untitled";
|
||||
|
||||
const cleaned = title
|
||||
|
||||
@@ -113,17 +113,6 @@ test("normal mode auto-opens every new artifact like before writing mode existed
|
||||
);
|
||||
});
|
||||
|
||||
test("Word artifacts stay download-only in every mode", () => {
|
||||
assert.equal(
|
||||
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/fixed.docx"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoOpenWritingArtifact("/mnt/user-data/outputs/source.doc", [], true),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("a later user turn releases report.md live edits", () => {
|
||||
const followUp = [
|
||||
{ type: "human", content: "写一篇论文" },
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
/** Only the writing-mode deliverable counts as a follow-up edit target. */
|
||||
|
||||
import { isWordDocumentFile } from "@/core/utils/files";
|
||||
|
||||
const OUTPUTS_PREFIX = "/mnt/user-data/outputs";
|
||||
|
||||
export function normalizeWritingPath(path: string | null | undefined): string {
|
||||
@@ -107,11 +105,6 @@ export function shouldAutoOpenWritingArtifact(
|
||||
if (!path) {
|
||||
return false;
|
||||
}
|
||||
// DOC/DOCX are delivered as download-only files. They have no meaningful
|
||||
// sandbox preview, so never let a completed Word artifact open the panel.
|
||||
if (isWordDocumentFile(path)) {
|
||||
return false;
|
||||
}
|
||||
if (!writingMode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -148,18 +148,11 @@ export function getFileName(filepath: string) {
|
||||
return filepath.split("/").pop()!;
|
||||
}
|
||||
|
||||
export function getFileExtension(filepath: string) {
|
||||
return filepath.split(".").pop()!.toLocaleLowerCase();
|
||||
}
|
||||
|
||||
/** Word files are download-only artifacts: browsers cannot preview them reliably. */
|
||||
export function isWordDocumentFile(filepath: string): boolean {
|
||||
const pathname = filepath.split(/[?#]/, 1)[0] ?? filepath;
|
||||
const extension = getFileExtension(pathname);
|
||||
return extension === "doc" || extension === "docx";
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
export function getFileExtension(filepath: string) {
|
||||
return filepath.split(".").pop()!.toLocaleLowerCase();
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
|
||||
@@ -46,26 +46,3 @@ test("prepareGeneratedHtml injects the ES5 chart size shim and anchor handler",
|
||||
assert.match(out, /getElementsByTagName\("canvas"\)/);
|
||||
assert.doesNotMatch(out, /\?\.|=>|`/);
|
||||
});
|
||||
|
||||
test("does not inject visualization runtimes into an ordinary HTML page", () => {
|
||||
const html = `<!doctype html><html><body><h1>普通页面</h1></body></html>`;
|
||||
const out = prepareGeneratedHtml(html);
|
||||
|
||||
assert.doesNotMatch(out, /data-df-html-runtime/);
|
||||
assert.doesNotMatch(out, /\/assets\/(?:mermaid|echarts|leaflet)/);
|
||||
});
|
||||
|
||||
test("keeps the explicit Gateway HTML runtime used by new pages", () => {
|
||||
const html = `<!doctype html><html><head>
|
||||
<link rel="stylesheet" href="/api/public/html-runtime/leaflet.css">
|
||||
<script defer src="/api/public/html-runtime/leaflet.js"></script>
|
||||
<script defer src="/api/public/html-runtime/deerflow-runtime.js"></script>
|
||||
</head><body><div id="map" class="df-map"></div>
|
||||
<script type="application/json" data-df-map="map">{"markers":[]}</script></body></html>`;
|
||||
const out = prepareGeneratedHtml(html);
|
||||
|
||||
assert.match(out, /\/api\/public\/html-runtime\/leaflet\.js/);
|
||||
assert.match(out, /\/api\/public\/html-runtime\/deerflow-runtime\.js/);
|
||||
assert.doesNotMatch(out, /data-df-html-runtime=/);
|
||||
assert.doesNotMatch(out, /localhost\/assets\/leaflet/);
|
||||
});
|
||||
@@ -13,144 +13,8 @@ import { injectInPageAnchorHandler } from "./markdown";
|
||||
|
||||
const MAX_PASSES = 12;
|
||||
|
||||
export type GeneratedHtmlRuntimeAssets = {
|
||||
g6ScriptUrl: string;
|
||||
mermaidScriptUrl: string;
|
||||
echartsScriptUrl: string;
|
||||
echartsChinaGeoJson: unknown;
|
||||
mapTileUrl: string;
|
||||
mapTileAttribution: string;
|
||||
leafletScriptUrl: string;
|
||||
leafletStyleText: string;
|
||||
};
|
||||
|
||||
export function prepareGeneratedHtml(
|
||||
html: string,
|
||||
): string {
|
||||
const prepared = injectLegacyChartShim(downlevelModernColors(html ?? ""));
|
||||
return injectInPageAnchorHandler(prepared);
|
||||
}
|
||||
|
||||
const KNOWN_RUNTIME_SCRIPT_RE = /<script\b([^>]*?)\bsrc\s*=\s*(["'])([^"']+)\2([^>]*)>\s*<\/script\s*>/gi;
|
||||
const KNOWN_RUNTIME_STYLE_RE = /<link\b([^>]*?)\bhref\s*=\s*(["'])([^"']+)\2([^>]*)>/gi;
|
||||
|
||||
function runtimeKind(url: string): "g6" | "mermaid" | "echarts" | "leaflet" | null {
|
||||
if (/(?:@antv\/g6|(?:^|\/)g6(?:\.min)?\.js)/i.test(url)) return "g6";
|
||||
if (/mermaid(?:\.min)?\.(?:js|mjs)|\bmermaid@/i.test(url)) return "mermaid";
|
||||
if (/echarts(?:\.min)?\.js|\becharts@/i.test(url)) return "echarts";
|
||||
if (/leaflet(?:\.min)?\.(?:js|css)|\bleaflet@/i.test(url)) return "leaflet";
|
||||
return null;
|
||||
}
|
||||
|
||||
function escapeHtmlAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function safeJsonForScript(value: unknown): string {
|
||||
return JSON.stringify(value).replace(/</g, "\\u003c");
|
||||
}
|
||||
|
||||
function safeStyleText(value: string): string {
|
||||
return value.replace(/<\/style/gi, "<\\/style");
|
||||
}
|
||||
|
||||
function injectAfterHeadOpen(html: string, tags: string): string {
|
||||
if (!tags) return html;
|
||||
if (/<head\b[^>]*>/i.test(html)) {
|
||||
return html.replace(/<head\b[^>]*>/i, (head) => `${head}${tags}`);
|
||||
}
|
||||
if (/<html\b[^>]*>/i.test(html)) {
|
||||
return html.replace(/<html\b[^>]*>/i, (root) => `${root}<head>${tags}</head>`);
|
||||
}
|
||||
return `<head>${tags}</head>${html}`;
|
||||
}
|
||||
|
||||
function injectBeforeBodyClose(html: string, tags: string): string {
|
||||
if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, `${tags}</body>`);
|
||||
return html + tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace CDN visualization runtimes with app-bundled assets and initialize the
|
||||
* declarative contracts used by generated HTML pages. The iframe remains
|
||||
* sandboxed without allow-same-origin; only script/style URLs are substituted.
|
||||
*/
|
||||
export function injectGeneratedHtmlRuntimes(
|
||||
html: string,
|
||||
assets: GeneratedHtmlRuntimeAssets,
|
||||
): string {
|
||||
// New generated pages load their browser runtimes directly from Gateway.
|
||||
// Leave those explicit source references untouched; this fallback only
|
||||
// applies to older artifacts that used the declarative blocks without them.
|
||||
if (/\/api\/public\/html-runtime\/deerflow-runtime\.js/i.test(html)) return html;
|
||||
if (!html || html.includes("data-df-html-runtime")) return html;
|
||||
|
||||
const needsG6 = /data-df-flow\s*=|\bG6\s*\.\s*Graph\b|@antv\/g6|(?:^|[/'"])[gG]6(?:\.min)?\.js/i.test(html);
|
||||
const needsMermaid = /class\s*=\s*["'][^"']*\bmermaid\b|\bmermaid\s*\.|mermaid(?:\.min)?\.(?:js|mjs)|\bmermaid@/i.test(html);
|
||||
const needsEcharts = /data-df-chart\s*=|\becharts\s*\.|echarts(?:\.min)?\.js|\becharts@/i.test(html);
|
||||
const needsLeaflet = /data-df-map\s*=|\bL\s*\.\s*map\s*\(|leaflet(?:\.min)?\.(?:js|css)|\bleaflet@/i.test(html);
|
||||
const needsEchartsChinaMap = needsEcharts && /\bmap\s*:\s*["']china["']|["']map["']\s*:\s*["']china["']/i.test(html);
|
||||
if (!needsG6 && !needsMermaid && !needsEcharts && !needsLeaflet) return html;
|
||||
|
||||
let prepared = html.replace(
|
||||
KNOWN_RUNTIME_SCRIPT_RE,
|
||||
(tag, _before: string, _quote: string, url: string) =>
|
||||
runtimeKind(url) ? "" : tag,
|
||||
);
|
||||
prepared = prepared.replace(
|
||||
KNOWN_RUNTIME_STYLE_RE,
|
||||
(tag, _before: string, _quote: string, url: string) =>
|
||||
runtimeKind(url) === "leaflet" ? "" : tag,
|
||||
);
|
||||
|
||||
const headTags = [
|
||||
needsLeaflet
|
||||
? `<style data-df-html-runtime="leaflet-css">${safeStyleText(assets.leafletStyleText)}</style>`
|
||||
: "",
|
||||
needsG6
|
||||
? `<script data-df-html-runtime="g6" src="${escapeHtmlAttribute(assets.g6ScriptUrl)}"></script>`
|
||||
: "",
|
||||
needsMermaid
|
||||
? `<script data-df-html-runtime="mermaid" src="${escapeHtmlAttribute(assets.mermaidScriptUrl)}"></script>`
|
||||
: "",
|
||||
needsEcharts
|
||||
? `<script data-df-html-runtime="echarts" src="${escapeHtmlAttribute(assets.echartsScriptUrl)}"></script>`
|
||||
: "",
|
||||
needsEchartsChinaMap
|
||||
? `<script data-df-html-runtime="echarts-china-map">if(window.echarts){window.echarts.registerMap("china",${safeJsonForScript(assets.echartsChinaGeoJson)});}</script>`
|
||||
: "",
|
||||
needsLeaflet
|
||||
? `<script data-df-html-runtime="leaflet" src="${escapeHtmlAttribute(assets.leafletScriptUrl)}"></script>`
|
||||
: "",
|
||||
`<style data-df-html-runtime="styles">.df-flow,.df-chart,.df-map{position:relative;min-height:360px;width:100%}.df-runtime-error{box-sizing:border-box;margin:12px 0;padding:12px 14px;border:1px solid #fca5a5;border-radius:8px;background:#fef2f2;color:#991b1b;font:14px/1.5 sans-serif}.df-flow-summary{margin:10px 0;color:inherit;font:13px/1.6 sans-serif}.df-flow-summary summary{cursor:pointer;font-weight:600}.df-flow-summary ol{margin:8px 0 0;padding-left:24px}.df-map-tile-error{position:absolute;z-index:1000;left:12px;right:12px;bottom:12px;padding:9px 12px;border-radius:7px;background:rgba(127,29,29,.92);color:#fff;font:13px/1.4 sans-serif;pointer-events:none}</style>`,
|
||||
].join("");
|
||||
prepared = injectAfterHeadOpen(prepared, headTags);
|
||||
return injectBeforeBodyClose(
|
||||
prepared,
|
||||
generatedHtmlRuntimeBootstrap(assets.mapTileUrl, assets.mapTileAttribution),
|
||||
);
|
||||
}
|
||||
|
||||
function generatedHtmlRuntimeBootstrap(mapTileUrl: string, mapTileAttribution: string): string {
|
||||
return `<script data-df-html-runtime="bootstrap">(function(){
|
||||
function ready(fn){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",fn);else fn();}
|
||||
function errorBox(target,message){var box=document.createElement("div");box.className="df-runtime-error";box.setAttribute("role","alert");box.textContent=message;if(target&&target.parentNode)target.parentNode.insertBefore(box,target.nextSibling);}
|
||||
function byId(id){return id?document.getElementById(id):null;}
|
||||
function jsonBlocks(name){return document.querySelectorAll('script[type="application/json"]['+name+']');}
|
||||
function parse(block){try{return JSON.parse(block.textContent||"{}");}catch(e){errorBox(block,"图形数据格式错误:"+(e&&e.message?e.message:"无法解析 JSON"));return null;}}
|
||||
function positiveSize(target){if(!target)return false;if(target.clientHeight<40)target.style.minHeight="360px";if(target.clientWidth<40)target.style.width="100%";return true;}
|
||||
function initFlows(){var blocks=jsonBlocks("data-df-flow");for(var i=0;i<blocks.length;i++){var block=blocks[i],target=byId(block.getAttribute("data-df-flow")),data=parse(block);if(!target||!data)continue;if(!window.G6||!window.G6.Graph){errorBox(target,"流程图运行库加载失败,请刷新页面后重试");continue;}try{positiveSize(target);var nodes=data.nodes||[],edges=data.edges||[];target.setAttribute("role","img");target.setAttribute("aria-label",(data.title||"流程图")+",包含 "+nodes.length+" 个步骤和 "+edges.length+" 条连线");var colors={start:["#0f766e","#2dd4bf"],end:["#7c3aed","#a78bfa"],decision:["#b45309","#f59e0b"],risk:["#b91c1c","#f87171"],process:["#1d4ed8","#60a5fa"],stage:["#4338ca","#818cf8"],task:["#334155","#94a3b8"]};var shapes={start:"ellipse",end:"ellipse",decision:"diamond",risk:"hexagon",process:"rect",stage:"rect",task:"rect"};var graphData={nodes:[],edges:[]};for(var n=0;n<nodes.length;n++){var node=nodes[n],pair=colors[node.type]||colors.task;graphData.nodes.push({id:String(node.id),data:{label:node.label||node.id,description:node.description||"",fill:node.color||pair[0],stroke:pair[1],shape:shapes[node.type]||"rect"}});}for(var e=0;e<edges.length;e++){var edge=edges[e];graphData.edges.push({source:String(edge.source),target:String(edge.target),data:{label:edge.label||""}});}var graph=new window.G6.Graph({container:target,data:graphData,autoFit:"view",padding:32,animation:!(window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches),layout:{type:"dagre",rankdir:data.direction==="TB"?"TB":"LR",nodesep:36,ranksep:72},node:{type:function(d){return d.data.shape;},style:{size:[176,68],radius:14,fill:function(d){return d.data.fill;},stroke:function(d){return d.data.stroke;},lineWidth:2,shadowColor:"rgba(15,23,42,.28)",shadowBlur:12,labelText:function(d){return d.data.label+(d.data.description?"\n"+d.data.description:"");},labelFill:"#f8fafc",labelFontSize:14,labelFontWeight:600,labelLineHeight:20,labelWordWrap:true,labelMaxWidth:148}},edge:{type:"polyline",style:{stroke:"#94a3b8",lineWidth:2,radius:12,endArrow:true,labelText:function(d){return d.data.label;},labelFill:"#475569",labelFontSize:12,labelBackground:true,labelBackgroundFill:"#f8fafc",labelBackgroundRadius:6,labelPadding:[4,7]}},behaviors:["drag-canvas","zoom-canvas","drag-element"]});var rendered=graph.render();if(rendered&&rendered.catch)rendered.catch(function(err){errorBox(target,"流程图渲染失败:"+(err&&err.message?err.message:"请检查流程数据"));});var summary=document.createElement("details");summary.className="df-flow-summary";var heading=document.createElement("summary");heading.textContent="查看流程步骤";summary.appendChild(heading);var list=document.createElement("ol");for(var s=0;s<nodes.length;s++){var item=document.createElement("li");item.textContent=(nodes[s].label||nodes[s].id)+(nodes[s].description?" — "+nodes[s].description:"");list.appendChild(item);}summary.appendChild(list);target.parentNode.insertBefore(summary,target.nextSibling);(function(instance){window.addEventListener("resize",function(){instance.resize();});})(graph);}catch(err){errorBox(target,"流程图渲染失败:"+(err&&err.message?err.message:"请检查流程数据"));}}}
|
||||
function initMermaid(){var nodes=document.querySelectorAll(".mermaid");if(!nodes.length)return;if(!window.mermaid){for(var i=0;i<nodes.length;i++)errorBox(nodes[i],"流程图库加载失败,请刷新页面后重试");return;}try{window.mermaid.initialize({startOnLoad:false,securityLevel:"strict",theme:"default"});var result=window.mermaid.run({nodes:nodes});if(result&&result.catch)result.catch(function(e){errorBox(nodes[0],"流程图渲染失败:"+(e&&e.message?e.message:"请检查 Mermaid 语法"));});}catch(e){errorBox(nodes[0],"流程图渲染失败:"+(e&&e.message?e.message:"请检查 Mermaid 语法"));}}
|
||||
function initCharts(){var blocks=jsonBlocks("data-df-chart");for(var i=0;i<blocks.length;i++){var block=blocks[i],target=byId(block.getAttribute("data-df-chart")),option=parse(block);if(!target||!option)continue;if(!window.echarts){errorBox(target,"图表运行库加载失败,请刷新页面后重试");continue;}try{positiveSize(target);target.setAttribute("role","img");target.setAttribute("aria-label",option.title&&option.title.text?option.title.text:"数据图表");var chart=window.echarts.init(target);chart.setOption(option);(function(instance){window.addEventListener("resize",function(){instance.resize();});})(chart);}catch(e){errorBox(target,"图表渲染失败:"+(e&&e.message?e.message:"请检查图表数据"));}}}
|
||||
function lngLat(value){if(!value||value.length<2)return null;var lng=Number(value[0]),lat=Number(value[1]);if(!isFinite(lng)||!isFinite(lat)||lng<-180||lng>180||lat<-90||lat>90)return null;return [lat,lng];}
|
||||
function escapeText(value){return String(value==null?"":value).replace(/[&<>\"']/g,function(ch){return {"&":"&","<":"<",">":">",'\"':""","'":"'"}[ch];});}
|
||||
function initMaps(){var blocks=jsonBlocks("data-df-map");for(var i=0;i<blocks.length;i++){var block=blocks[i],target=byId(block.getAttribute("data-df-map")),data=parse(block);if(!target||!data)continue;if(!window.L){errorBox(target,"地图运行库加载失败,请刷新页面后重试");continue;}try{positiveSize(target);target.setAttribute("role","region");target.setAttribute("aria-label",data.title||"地图");var map=window.L.map(target,{zoomControl:true});var tileErrors=0;window.L.tileLayer(${safeJsonForScript(mapTileUrl)},{maxZoom:20,attribution:${safeJsonForScript(mapTileAttribution)}}).on("tileerror",function(){tileErrors+=1;if(tileErrors===3){var warning=document.createElement("div");warning.className="df-map-tile-error";warning.setAttribute("role","status");warning.textContent="地图底图加载失败;地点、路线和区域数据仍可查看";target.appendChild(warning);}}).addTo(map);var bounds=[];var markers=data.markers||[];for(var m=0;m<markers.length;m++){var marker=markers[m],point=lngLat(marker.coordinate);if(!point)continue;bounds.push(point);var dot=window.L.circleMarker(point,{radius:7,color:"#1d4ed8",weight:2,fillColor:"#3b82f6",fillOpacity:.9}).addTo(map);var popup="<strong>"+escapeText(marker.name||"地点")+"</strong>"+(marker.description?"<br>"+escapeText(marker.description):"");dot.bindPopup(popup);dot.bindTooltip(escapeText(marker.name||"地点"));}var lines=data.lines||[];for(var l=0;l<lines.length;l++){var linePoints=[];var rawLine=lines[l].coordinates||[];for(var p=0;p<rawLine.length;p++){var lp=lngLat(rawLine[p]);if(lp){linePoints.push(lp);bounds.push(lp);}}if(linePoints.length>1)window.L.polyline(linePoints,{color:"#2563eb",weight:3,opacity:.85}).addTo(map);}var polygons=data.polygons||[];for(var g=0;g<polygons.length;g++){var polygonPoints=[];var rawPolygon=polygons[g].coordinates||[];for(var q=0;q<rawPolygon.length;q++){var pp=lngLat(rawPolygon[q]);if(pp){polygonPoints.push(pp);bounds.push(pp);}}if(polygonPoints.length>2)window.L.polygon(polygonPoints,{color:"#7c3aed",weight:2,fillColor:"#8b5cf6",fillOpacity:.2}).addTo(map);}var center=lngLat(data.center);if(center)map.setView(center,Number(data.zoom)||6);else if(bounds.length)map.fitBounds(bounds,{padding:[28,28],maxZoom:Number(data.zoom)||10});else map.setView([35.8617,104.1954],Number(data.zoom)||4);(function(instance){setTimeout(function(){instance.invalidateSize();},0);})(map);}catch(e){errorBox(target,"地图渲染失败:"+(e&&e.message?e.message:"请检查地图数据"));}}}
|
||||
ready(function(){initFlows();initMermaid();initCharts();initMaps();});
|
||||
})();</script>`;
|
||||
export function prepareGeneratedHtml(html: string): string {
|
||||
return injectInPageAnchorHandler(injectLegacyChartShim(downlevelModernColors(html ?? "")));
|
||||
}
|
||||
|
||||
export function downlevelModernColors(html: string): string {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { readRuntimeEnv } from "@/env";
|
||||
|
||||
export const DEFAULT_MAP_TILE_URL =
|
||||
"https://mt0.google.com/vt/lyrs=m&hl=zh-CN&x={x}&y={y}&z={z}";
|
||||
|
||||
/** Browser-side tile template shared by chat maps and generated HTML maps. */
|
||||
export function getMapTileUrl(): string {
|
||||
return readRuntimeEnv("VITE_MAP_TILE_URL")?.trim() || DEFAULT_MAP_TILE_URL;
|
||||
}
|
||||
|
||||
export function getMapTileAttribution(tileUrl = getMapTileUrl()): string {
|
||||
return /(?:^|\.)google\.(?:com|cn)\b/i.test(tileUrl) ? "Google" : "";
|
||||
}
|
||||
@@ -165,10 +165,7 @@ async function externalTaskError(response: Response, fallback: string): Promise<
|
||||
}
|
||||
|
||||
function findProcessReportId(payload: unknown): string {
|
||||
if (payload == null) return "";
|
||||
// 外部任务系统的实际返回形如 {code:200, data:307, msg:"success"}:
|
||||
// `data` 直接就是新增记录的 id(数字 / 字符串),不再包一层对象。
|
||||
if (typeof payload !== "object") return firstText(payload);
|
||||
if (!payload || typeof payload !== "object") return "";
|
||||
const source = payload as Record<string, unknown>;
|
||||
const direct = firstText(
|
||||
source.id,
|
||||
@@ -181,10 +178,9 @@ function findProcessReportId(payload: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the external task system's process report **and submit it in one
|
||||
* call**(submitStatus=1). Response shape is `{code:200, data:<id>, msg}` —
|
||||
* `data` is the new record id. The local gateway stores that id so a refresh
|
||||
* can still reconcile the same report instead of creating a duplicate.
|
||||
* Create the external task system's process report in its unsubmitted state.
|
||||
* The local gateway stores the returned id so a refresh can later submit the
|
||||
* same report instead of creating a duplicate one.
|
||||
*/
|
||||
export async function createEightBfTaskProcessReport(
|
||||
input: EightBfTaskProcessReportInput,
|
||||
@@ -200,7 +196,7 @@ export async function createEightBfTaskProcessReport(
|
||||
return { id };
|
||||
}
|
||||
|
||||
/** Flip an already-registered (submitStatus=0) process report to submitted. Only used to resume legacy ledger rows; the normal path submits directly on create. */
|
||||
/** Submit an already-registered process report to the external task system. */
|
||||
export async function updateEightBfTaskProcessReport(
|
||||
reportId: string,
|
||||
input: EightBfTaskProcessReportInput,
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
} from "@/position-roundtable/lib/positions";
|
||||
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
|
||||
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent";
|
||||
import { updateChain } from "@/roundtable-planning/api/chains";
|
||||
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||
import "@/position-roundtable/styles/intent-understanding.css";
|
||||
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
|
||||
@@ -47,8 +46,6 @@ export interface EightBfStep1PanelProps extends Step1PanelProps {
|
||||
chain: BusinessChain | null;
|
||||
canConfigureChain: boolean;
|
||||
onConfigureChain: () => void;
|
||||
/** 业务链被本面板更新(产物 position 等)后回调,页面据此刷新链配置。 */
|
||||
onChainUpdated?: (chain: BusinessChain) => void;
|
||||
selectedModel: string;
|
||||
modelOptions: Array<{ label: string; value: string }>;
|
||||
onSelectedModelChange: (model: string) => void;
|
||||
@@ -87,7 +84,6 @@ export function EightBfStep1Panel({
|
||||
chain,
|
||||
canConfigureChain,
|
||||
onConfigureChain,
|
||||
onChainUpdated,
|
||||
selectedModel,
|
||||
modelOptions,
|
||||
onSelectedModelChange,
|
||||
@@ -157,7 +153,6 @@ export function EightBfStep1Panel({
|
||||
chain={chain}
|
||||
canConfigureChain={canConfigureChain}
|
||||
onConfigureChain={onConfigureChain}
|
||||
onChainUpdated={onChainUpdated}
|
||||
onCollapse={() => setRightPanelOpen(false)}
|
||||
step2Dialogues={step2Dialogues}
|
||||
step2ThreadIds={step2ThreadIds}
|
||||
@@ -304,7 +299,6 @@ function EightBfAgentWorkspace({
|
||||
chain,
|
||||
canConfigureChain,
|
||||
onConfigureChain,
|
||||
onChainUpdated,
|
||||
onCollapse,
|
||||
step2Dialogues,
|
||||
step2ThreadIds,
|
||||
@@ -316,7 +310,6 @@ function EightBfAgentWorkspace({
|
||||
chain: BusinessChain | null;
|
||||
canConfigureChain: boolean;
|
||||
onConfigureChain: () => void;
|
||||
onChainUpdated?: (chain: BusinessChain) => void;
|
||||
onCollapse: () => void;
|
||||
step2Dialogues: unknown[];
|
||||
step2ThreadIds: ThreadIdMap | null;
|
||||
@@ -455,13 +448,6 @@ function EightBfAgentWorkspace({
|
||||
jobResponsibilities={selectedSeat?.job_responsibilities ?? ""}
|
||||
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
|
||||
skillsTitle="技能列表"
|
||||
belowSkillsSlot={chain && selectedSeat ? (
|
||||
<SeatArtifactPositionEditor
|
||||
chain={chain}
|
||||
seat={selectedSeat}
|
||||
onUpdated={onChainUpdated}
|
||||
/>
|
||||
) : undefined}
|
||||
showCommonQuestions
|
||||
panelTitle="智能体配置"
|
||||
embedded
|
||||
@@ -626,82 +612,3 @@ function arrayValue(...values: unknown[]): string[] {
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 「智能体配置」面板里、技能列表下方的「产物提交 position」编辑器。
|
||||
*
|
||||
* 直接读写该席位在业务链上配置的 artifact_position(保存即 PUT 业务链的 seats),
|
||||
* 用户无需去业务链编辑页找链配 position,也不易配错链。只有业务链 owner / 管理员
|
||||
* 能保存(后端校验);提交产物时按席位取这里保存的值,留空按 0。
|
||||
*/
|
||||
function SeatArtifactPositionEditor({
|
||||
chain,
|
||||
seat,
|
||||
onUpdated,
|
||||
}: {
|
||||
chain: BusinessChain;
|
||||
seat: ChainSeat;
|
||||
onUpdated?: (chain: BusinessChain) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(seat.artifact_position?.toString() ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 席位切换 / 外部链数据刷新时,回填链上的当前值。
|
||||
useEffect(() => {
|
||||
setValue(seat.artifact_position?.toString() ?? "");
|
||||
}, [seat.agent_id, seat.artifact_position]);
|
||||
|
||||
const trimmed = value.trim();
|
||||
const parsed = trimmed === "" ? null : Number.parseInt(trimmed, 10);
|
||||
const nextValue = parsed !== null && Number.isFinite(parsed) ? parsed : null;
|
||||
const dirty = nextValue !== (seat.artifact_position ?? null);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (saving || !dirty) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const seats = chain.seats.map((s) =>
|
||||
s.agent_id === seat.agent_id ? { ...s, artifact_position: nextValue } : s,
|
||||
);
|
||||
const updated = await updateChain(chain.id, { seats });
|
||||
if (!updated) throw new Error("业务链已不存在,可能被删除");
|
||||
onUpdated?.(updated);
|
||||
toast.success(`已保存「${seat.name || seat.agent_id}」的产物 position`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存产物 position 失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border border-[#2b8ffe]/30 bg-[#042b59]/60 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-[#aaceff]">产物提交 position</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={value}
|
||||
placeholder="0"
|
||||
aria-label="产物提交 position"
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
className="h-7 w-20 rounded-md border border-[#2b8ffe]/40 bg-[#032680]/60 px-2 text-xs font-semibold tabular-nums text-[#e8f2ff] outline-none focus:border-[#2b8ffe] [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => void handleSave()}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-md border border-[#2b8ffe]/50 bg-[#0a3c8f]/70 px-2.5 text-xs font-semibold text-[#e8f2ff] transition hover:bg-[#0a3c8f] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving && <Loader2 className="size-3 animate-spin" />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[10px] leading-relaxed text-[#8aa0c4]">
|
||||
该席位提交过程产物时传给任务系统的 position 值,留空按 0 提交。保存会写入业务链「{chain.title}」,仅链所有者 / 管理员可保存。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -88,50 +88,29 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
||||
() => buildEightBfTaskContext(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;
|
||||
};
|
||||
// 席位「产物提交 position」映射:业务链里逐席位配置的 artifact_position(agent_id → 值)。
|
||||
// 提交产物时按产出该产物的席位智能体取值;未配置的席位不进 map(提交时回退默认 0)。
|
||||
const artifactPositionByAgent = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
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);
|
||||
}
|
||||
map.set(seat.agent_id, seat.artifact_position);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [chains, configuredChain]);
|
||||
}, [configuredChain]);
|
||||
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
|
||||
() => taskId ? {
|
||||
taskId,
|
||||
position: 0,
|
||||
positionFor: (target) => {
|
||||
const agentId = target.agentId?.trim();
|
||||
if (!agentId) return null;
|
||||
return seatArtifactPositions[agentId.toLowerCase()]
|
||||
?? seatArtifactPositions[target.agentName?.trim().toLowerCase() ?? ""]
|
||||
?? null;
|
||||
return agentId ? artifactPositionByAgent.get(agentId) ?? null : null;
|
||||
},
|
||||
createProcessReport: createEightBfTaskProcessReport,
|
||||
updateProcessReport: updateEightBfTaskProcessReport,
|
||||
} : undefined,
|
||||
[taskId, seatArtifactPositions],
|
||||
[taskId, artifactPositionByAgent],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -235,12 +214,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
||||
if (canConfigureChain) setIsSettingsOpen(true);
|
||||
}, [canConfigureChain]);
|
||||
|
||||
// 「智能体配置」面板里保存产物 position 后,把最新链数据写回页面状态,
|
||||
// 让 seatArtifactPositions / 提交产物取值即时生效(无需刷新页面)。
|
||||
const handleChainUpdated = useCallback((updated: BusinessChain) => {
|
||||
setChains((prev) => prev.map((chain) => (chain.id === updated.id ? updated : chain)));
|
||||
}, []);
|
||||
|
||||
const toolbarSlot = (
|
||||
<>
|
||||
{canConfigureChain && isSettingsOpen && (
|
||||
@@ -335,7 +308,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
||||
chain={configuredChain}
|
||||
canConfigureChain={canConfigureChain}
|
||||
onConfigureChain={openChainSettings}
|
||||
onChainUpdated={handleChainUpdated}
|
||||
/>
|
||||
)}
|
||||
renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => (
|
||||
@@ -386,8 +358,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
||||
}
|
||||
rootClassName="eightbf-roundtable"
|
||||
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
|
||||
// 会商「智能体配置」抽屉里只读展示各席位在业务链上配置的产物提交 position。
|
||||
seatArtifactPositions={seatArtifactPositions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -48,7 +48,7 @@ const MAX_INTENT_LENGTH = 500;
|
||||
const MAX_SAMPLE_TEXT_LENGTH = 120000;
|
||||
|
||||
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
|
||||
{ value: 'general', label: '通用检索', desc: '素材收集专家自由选用全部技能检索' },
|
||||
{ value: 'general', label: '通用检索', desc: '素材收集专家按配置技能检索' },
|
||||
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库检索接口' },
|
||||
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
|
||||
];
|
||||
|
||||
@@ -152,7 +152,7 @@ interface MaterialConfirmCardProps {
|
||||
type MaterialSource = NonNullable<UserInterventionPayload['materialSource']>;
|
||||
|
||||
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
|
||||
{ value: 'general', label: '通用检索', desc: '素材收集专家自由选用全部技能检索' },
|
||||
{ value: 'general', label: '通用检索', desc: '素材收集专家按配置技能检索' },
|
||||
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库接口' },
|
||||
{ value: 'notebook', label: '我的空间', desc: '检索当前笔记空间' },
|
||||
];
|
||||
@@ -218,7 +218,7 @@ function MaterialConfirmCard({
|
||||
|
||||
async function handleReSearch() {
|
||||
setSubmitting(true);
|
||||
// 带输入 = 后端从全部启用技能中匹配检索、新素材追加到列表;
|
||||
// 带输入 = 后端在素材收集专家配置的技能中匹配检索、新素材追加到列表;
|
||||
// 无输入 = 重新生成检索词、覆盖式重检
|
||||
await onSubmit({
|
||||
action: 're_search',
|
||||
|
||||
@@ -257,7 +257,7 @@ export interface UserInterventionPayload {
|
||||
approvedMaterialIds?: string[];
|
||||
/** @deprecated 旧版多关键词追加。新版统一用 userQuery 单输入框 */
|
||||
extraKeywords?: string[];
|
||||
/** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端从全部启用技能中匹配最相关的技能检索,新素材**追加**到列表。 */
|
||||
/** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端在素材收集专家配置的技能中匹配最相关的技能检索,新素材**追加**到列表。 */
|
||||
userQuery?: string;
|
||||
/** PAUSE-1 素材确认:本次补充/重新检索使用的检索方式。 */
|
||||
materialSource?: "general" | "knowledge_base" | "notebook";
|
||||
@@ -298,7 +298,7 @@ export interface AIWritingRequest {
|
||||
writingMode: "strict" | "loose";
|
||||
/** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认 true。 */
|
||||
addReferences?: boolean;
|
||||
/** 检索类型:general=通用检索(素材收集专家从全部启用技能自由挑选检索,内置知识库检索保底)/
|
||||
/** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/
|
||||
* knowledge_base=知识库检索(直连内网知识库 ES 接口,不走技能编排)/ notebook=我的空间 */
|
||||
materialSource: "general" | "knowledge_base" | "notebook";
|
||||
/** 不收集素材,直接用通用知识写作:勾选后跳过整个素材检索流程(含素材确认暂停点),
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useLocation } from "react-router-dom";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -48,6 +49,7 @@ import { useNotification } from "@/core/notification/hooks";
|
||||
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
|
||||
import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks";
|
||||
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
||||
import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from "@/core/threads/use-direct-chat";
|
||||
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
||||
@@ -373,7 +375,6 @@ export default function AgentChatPage({
|
||||
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>(
|
||||
() => pendingAssistantKnowledgeSelection.current ?? [],
|
||||
);
|
||||
const [knowledgeSuggestedQuestions, setKnowledgeSuggestedQuestions] = useState<string[]>([]);
|
||||
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(
|
||||
() => pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null,
|
||||
);
|
||||
@@ -561,9 +562,26 @@ export default function AgentChatPage({
|
||||
},
|
||||
});
|
||||
|
||||
const directChat = useDirectChatMessages(threadId);
|
||||
const directChatSelected =
|
||||
isDirectChatProfile(settings.context) && isTurboModeVisible();
|
||||
|
||||
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
|
||||
// 流式 stub),成功且真实产物落地后自动撤出。
|
||||
const { displayThread } = useResearchWritingFeed(threadId, thread);
|
||||
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
|
||||
const displayThread = useMemo(() => {
|
||||
const base = researchDisplayThread;
|
||||
if (directChat.messages.length === 0 && !directChat.isLoading) {
|
||||
return base;
|
||||
}
|
||||
const messages = [...base.messages, ...directChat.messages];
|
||||
return {
|
||||
...base,
|
||||
values: { ...base.values, messages },
|
||||
messages,
|
||||
isLoading: base.isLoading || directChat.isLoading,
|
||||
} as typeof base;
|
||||
}, [directChat.isLoading, directChat.messages, researchDisplayThread]);
|
||||
|
||||
// ── agentfx「入库」协助卡 ──────────────────────────────────────────────────
|
||||
// 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且
|
||||
@@ -744,6 +762,62 @@ export default function AgentChatPage({
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage, extraContext?: Record<string, unknown>) => {
|
||||
if (directChatSelected) {
|
||||
if (message.files && message.files.length > 0) {
|
||||
toast.error("极速问答暂不支持附件,请移除附件后重试。");
|
||||
return;
|
||||
}
|
||||
setHasStartedConversation(true);
|
||||
saveLastAgentId(agent_id);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
void (async () => {
|
||||
try {
|
||||
let targetThreadId = threadId;
|
||||
if (isNewThread) {
|
||||
const created = await getAPIClient().threads.create({
|
||||
metadata: {
|
||||
agent_id,
|
||||
...(Object.keys(conversationMetadata).length > 0
|
||||
? conversationMetadata
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
targetThreadId = created.thread_id;
|
||||
knowledgeSelectionThread.current = targetThreadId;
|
||||
activeThreadIdRef.current = targetThreadId;
|
||||
setThreadId(targetThreadId);
|
||||
setIsNewThread(false);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#${agentChatsBase}/${targetThreadId}`,
|
||||
);
|
||||
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
|
||||
if (title) {
|
||||
void getAPIClient().threads
|
||||
.updateState(targetThreadId, { values: { title } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
if (!targetThreadId) {
|
||||
toast.error("无法创建会话。");
|
||||
return;
|
||||
}
|
||||
await directChat.submit({
|
||||
targetThreadId,
|
||||
query: message.text,
|
||||
modelName: settings.context.model_name,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "极速问答失败。",
|
||||
);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
setHasStartedConversation(true);
|
||||
// Remember this agent so the new-chat landing screen pre-selects it next
|
||||
// time (covers both direct agent chats and the ChatPage handoff replay).
|
||||
@@ -768,7 +842,7 @@ export default function AgentChatPage({
|
||||
...pageReportThemeContext,
|
||||
});
|
||||
},
|
||||
[sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath, baseMode],
|
||||
[sendMessage, threadId, agent_id, settings.context.writing_mode, settings.context.model_name, writingArtifactPath, baseMode, directChat, directChatSelected, isNewThread, conversationMetadata, agentChatsBase, setThreadId, setIsNewThread],
|
||||
);
|
||||
|
||||
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
|
||||
@@ -853,8 +927,12 @@ export default function AgentChatPage({
|
||||
);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
if (directChat.isLoading) {
|
||||
directChat.stop();
|
||||
return;
|
||||
}
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
}, [directChat, thread]);
|
||||
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
// 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
|
||||
@@ -868,7 +946,7 @@ export default function AgentChatPage({
|
||||
|
||||
const status = thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
: thread.isLoading || directChat.isLoading
|
||||
? "streaming"
|
||||
: "ready";
|
||||
|
||||
@@ -1030,7 +1108,7 @@ export default function AgentChatPage({
|
||||
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
|
||||
router.push(
|
||||
isEmbedded
|
||||
? `${routeBase}/chats/new?agent=${agent_id}`
|
||||
? `/page/workspace/chats/new?agent=${agent_id}`
|
||||
: `${agentChatsBase}/new`,
|
||||
);
|
||||
}}
|
||||
@@ -1080,13 +1158,12 @@ export default function AgentChatPage({
|
||||
className="w-full"
|
||||
isNewThread
|
||||
hideAdminRecommendedQuestions
|
||||
knowledgeSuggestedQuestions={knowledgeSuggestedQuestions}
|
||||
threadId={threadId}
|
||||
assistantId={agent_id}
|
||||
autoFocus
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
@@ -1101,13 +1178,12 @@ export default function AgentChatPage({
|
||||
assistantValue={selectedAssistantKnowledgeBaseIds}
|
||||
onChange={changeKnowledgeSelection}
|
||||
onAssistantChange={changeAssistantKnowledgeSelection}
|
||||
onSuggestedQuestionsChange={setKnowledgeSuggestedQuestions}
|
||||
autoSelectAll={
|
||||
isNewThread &&
|
||||
pendingKnowledgeSelection.current === null &&
|
||||
pendingAssistantKnowledgeSelection.current === null
|
||||
}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
|
||||
label="知识空间"
|
||||
/>
|
||||
)
|
||||
@@ -1178,7 +1254,7 @@ export default function AgentChatPage({
|
||||
assistantId={agent_id}
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
@@ -1193,7 +1269,7 @@ export default function AgentChatPage({
|
||||
assistantValue={selectedAssistantKnowledgeBaseIds}
|
||||
onChange={changeKnowledgeSelection}
|
||||
onAssistantChange={changeAssistantKnowledgeSelection}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
|
||||
readOnly
|
||||
label="知识空间"
|
||||
/>
|
||||
|
||||
@@ -1,831 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -37,10 +37,8 @@ import {
|
||||
useAssistantKnowledgeActions,
|
||||
useAssistantKnowledgeBases,
|
||||
useAssistantKnowledgeSources,
|
||||
useAssistantWikiPage,
|
||||
useAssistantWikiPages,
|
||||
type AssistantKnowledgeBase,
|
||||
type AssistantWikiPage,
|
||||
} from "@/strategy-components/api/assistant-knowledge";
|
||||
import {
|
||||
WikiLibraryReader,
|
||||
@@ -48,41 +46,24 @@ import {
|
||||
} from "@/strategy-components/components/resource-management/llmwiki/WikiLibraryReader";
|
||||
import { WikiPackageTransfers } from "@/strategy-components/components/resource-management/llmwiki/WikiPackageTransfers";
|
||||
|
||||
function toLibraryPage(page: AssistantWikiPage): LibraryWikiPage {
|
||||
return {
|
||||
...page,
|
||||
content: page.content_markdown || "",
|
||||
status: "published",
|
||||
aliases: page.aliases || [],
|
||||
source_refs: page.source_refs || [],
|
||||
in_links: page.in_links || [],
|
||||
out_links: page.out_links || [],
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export default function AssistantKnowledgePage() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [params, setParams] = useSearchParams();
|
||||
const baseId = params.get("base_id");
|
||||
const slug = params.get("wikiSlug");
|
||||
const [tab, setTab] = useState("wiki");
|
||||
const bases = useAssistantKnowledgeBases();
|
||||
const actions = useAssistantKnowledgeActions();
|
||||
const pages = useAssistantWikiPages("", baseId, {
|
||||
includeContent: false,
|
||||
enabled: !!baseId,
|
||||
});
|
||||
const sources = useAssistantKnowledgeSources(baseId, !!baseId);
|
||||
const jobs = useAssistantImportJobs(baseId, !!baseId);
|
||||
const pages = useAssistantWikiPages("", baseId);
|
||||
const sources = useAssistantKnowledgeSources(baseId);
|
||||
const jobs = useAssistantImportJobs(baseId);
|
||||
const client = useQueryClient();
|
||||
const [tab, setTab] = useState("wiki");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingBase, setEditingBase] =
|
||||
useState<AssistantKnowledgeBase | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [searching, setSearching] = useState(false);
|
||||
const searchSequence = useRef(0);
|
||||
const [searchResult, setSearchResult] = useState<Awaited<
|
||||
ReturnType<typeof searchAssistantWiki>
|
||||
> | null>(null);
|
||||
@@ -90,27 +71,24 @@ export default function AssistantKnowledgePage() {
|
||||
const documentUpload = useRef<HTMLInputElement>(null);
|
||||
const base = bases.data?.find((b) => b.id === baseId);
|
||||
const libraryPages = useMemo<LibraryWikiPage[]>(
|
||||
() => (pages.data || []).map(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],
|
||||
);
|
||||
const defaultDirectoryPage =
|
||||
const active =
|
||||
libraryPages.find((p) => p.slug === slug) ||
|
||||
libraryPages.find((p) => p.page_type === "index") ||
|
||||
libraryPages[0] ||
|
||||
null;
|
||||
const activeDirectoryPage = slug
|
||||
? libraryPages.find((p) => p.slug === slug) || null
|
||||
: defaultDirectoryPage;
|
||||
const activeSlug = slug || defaultDirectoryPage?.slug || null;
|
||||
const activePage = useAssistantWikiPage(
|
||||
activeSlug,
|
||||
baseId,
|
||||
!!activeSlug,
|
||||
);
|
||||
const active = useMemo(
|
||||
() =>
|
||||
activePage.data ? toLibraryPage(activePage.data) : activeDirectoryPage,
|
||||
[activeDirectoryPage, activePage.data],
|
||||
);
|
||||
const navigatePage = (nextSlug: string) => {
|
||||
setParams({ base_id: baseId!, wikiSlug: nextSlug });
|
||||
setTab("wiki");
|
||||
@@ -119,38 +97,17 @@ export default function AssistantKnowledgePage() {
|
||||
.filter((j) => j.status === "completed")
|
||||
.map((j) => j.id)
|
||||
.join(",");
|
||||
const packageImportActive = (jobs.data || []).some(
|
||||
(job) =>
|
||||
job.trigger === "manual_package_upload" &&
|
||||
["queued", "running"].includes(job.status),
|
||||
);
|
||||
const packageImportBusy =
|
||||
actions.uploadWeKnoraPackage.isPending || packageImportActive;
|
||||
const finishedJobsSnapshot = useRef({ baseId: null as string | null, ids: "" });
|
||||
useEffect(() => {
|
||||
if (!baseId || !jobs.data) return;
|
||||
if (finishedJobsSnapshot.current.baseId !== baseId) {
|
||||
finishedJobsSnapshot.current = { baseId, ids: finishedJobs };
|
||||
return;
|
||||
}
|
||||
if (finishedJobsSnapshot.current.ids === finishedJobs) return;
|
||||
finishedJobsSnapshot.current.ids = finishedJobs;
|
||||
void client.invalidateQueries({
|
||||
queryKey: ["assistant-knowledge", "pages"],
|
||||
});
|
||||
void client.invalidateQueries({
|
||||
queryKey: ["assistant-knowledge", "page"],
|
||||
});
|
||||
void client.invalidateQueries({
|
||||
queryKey: ["assistant-knowledge", "bases"],
|
||||
});
|
||||
}, [baseId, finishedJobs, jobs.data, client]);
|
||||
}, [finishedJobs, client]);
|
||||
useEffect(() => {
|
||||
++searchSequence.current;
|
||||
setSearching(false);
|
||||
setSearchResult(null);
|
||||
setQuery("");
|
||||
return () => { ++searchSequence.current; };
|
||||
}, [baseId]);
|
||||
const create = async () => {
|
||||
if (!name.trim()) return;
|
||||
@@ -165,16 +122,13 @@ export default function AssistantKnowledgePage() {
|
||||
};
|
||||
const search = async () => {
|
||||
if (!query.trim() || !baseId) return;
|
||||
const sequence = ++searchSequence.current;
|
||||
setSearching(true);
|
||||
setSearchResult(null);
|
||||
try {
|
||||
const result = await searchAssistantWiki(query.trim(), baseId);
|
||||
if (sequence === searchSequence.current) setSearchResult(result);
|
||||
setSearchResult(await searchAssistantWiki(query, baseId));
|
||||
} catch (e) {
|
||||
if (sequence === searchSequence.current) toast.error((e as Error).message);
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
if (sequence === searchSequence.current) setSearching(false);
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
const refresh = () =>
|
||||
@@ -300,17 +254,6 @@ export default function AssistantKnowledgePage() {
|
||||
初始化知识梳理总库
|
||||
</Button>
|
||||
)}
|
||||
{!bases.isLoading && !bases.error && !bases.data?.length && !isAdmin && (
|
||||
<div className="rounded-xl border border-dashed p-8 text-center">
|
||||
<p className="font-medium">暂无助手知识库</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
请联系管理员创建知识库并导入内容,完成后即可在这里查看和检索。
|
||||
</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => void bases.refetch()}>
|
||||
<RefreshCw className="mr-2 size-4" />刷新
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
) : (
|
||||
<>
|
||||
@@ -374,17 +317,11 @@ export default function AssistantKnowledgePage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={packageImportBusy}
|
||||
disabled={actions.uploadWeKnoraPackage.isPending}
|
||||
onClick={() => upload.current?.click()}
|
||||
>
|
||||
{packageImportBusy ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 size-4" />
|
||||
)}
|
||||
{packageImportBusy
|
||||
? "数据包导入中"
|
||||
: "导入 普通知识库数据包"}
|
||||
<Upload className="mr-2 size-4" />
|
||||
导入 普通知识库数据包
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -405,22 +342,17 @@ export default function AssistantKnowledgePage() {
|
||||
ref={upload}
|
||||
type="file"
|
||||
accept=".jsonl,.ndjson"
|
||||
disabled={packageImportBusy}
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setTab("transfer");
|
||||
if (packageImportBusy) return;
|
||||
try {
|
||||
await actions.uploadWeKnoraPackage.mutateAsync({
|
||||
file,
|
||||
assistantBaseId: baseId,
|
||||
});
|
||||
toast.success(
|
||||
"数据包已上传并在后台导入,请在任务记录中查看进度,无需再次上传",
|
||||
);
|
||||
toast.success("文件已上传,后台正在导入");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
@@ -445,96 +377,25 @@ export default function AssistantKnowledgePage() {
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="wiki" className="mt-0 flex min-h-0 flex-1">
|
||||
{pages.isPending ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||
正在读取 Wiki 目录
|
||||
</div>
|
||||
) : pages.isError ? (
|
||||
<div className="flex flex-1 items-center justify-center px-6">
|
||||
<div className="max-w-lg rounded-2xl border border-destructive/30 bg-destructive/5 px-8 py-10 text-center">
|
||||
<div className="font-medium text-destructive">Wiki 目录读取失败</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{pages.error instanceof Error ? pages.error.message : "请稍后重试"}
|
||||
</p>
|
||||
<Button className="mt-5" variant="outline" onClick={() => void pages.refetch()}>
|
||||
<RefreshCw className="mr-2 size-4" />
|
||||
重试读取
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : libraryPages.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center px-6">
|
||||
<div className="w-full max-w-xl rounded-2xl border border-dashed bg-card px-8 py-10 text-center shadow-sm">
|
||||
<div className="mx-auto flex size-12 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
|
||||
<BookOpenText className="size-6" />
|
||||
</div>
|
||||
<div className="mt-4 font-medium text-foreground">Wiki 管理已就绪</div>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
当前知识库还没有文件和 Wiki 页面。进入知识库不要求先上传文件;导入内容后,系统会在这里生成目录、页面和向量索引。
|
||||
</p>
|
||||
{isAdmin ? (
|
||||
<div className="mt-6 flex flex-wrap justify-center gap-2">
|
||||
<Button
|
||||
disabled={actions.uploadDocument.isPending}
|
||||
onClick={() => documentUpload.current?.click()}
|
||||
>
|
||||
{actions.uploadDocument.isPending ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 size-4" />
|
||||
)}
|
||||
上传文件生成 Wiki
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={packageImportBusy}
|
||||
onClick={() => upload.current?.click()}
|
||||
>
|
||||
{packageImportBusy ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 size-4" />
|
||||
)}
|
||||
导入普通知识库数据包
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-5 text-xs text-muted-foreground">
|
||||
当前账号可正常进入和查看该知识库;请联系管理员导入首批内容。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<WikiLibraryReader
|
||||
key={baseId}
|
||||
pages={libraryPages}
|
||||
activePage={active}
|
||||
onNavigate={navigatePage}
|
||||
pageHref={(s) =>
|
||||
buildAppHashUrl(
|
||||
`/page/workspace/knowledge/assistant?${new URLSearchParams({ base_id: baseId, wikiSlug: s })}`,
|
||||
)
|
||||
}
|
||||
mappingId={
|
||||
active?.origins?.find((o) => o.mapping_id)?.mapping_id
|
||||
}
|
||||
initiallyCollapseTree
|
||||
activePageLoading={
|
||||
!!activeSlug && !activePage.data && activePage.isPending
|
||||
}
|
||||
activePageError={
|
||||
activePage.data ? undefined : activePage.error?.message
|
||||
}
|
||||
onRetryActivePage={() => void activePage.refetch()}
|
||||
/>
|
||||
)}
|
||||
<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
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="search" className="overflow-auto p-6">
|
||||
<div className="mb-5 flex max-w-3xl gap-2">
|
||||
<Input
|
||||
aria-label="检索助手知识库"
|
||||
placeholder="用自然语言检索知识,例如:如何处理设备过热?"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
@@ -542,7 +403,7 @@ export default function AssistantKnowledgePage() {
|
||||
if (e.key === "Enter") void search();
|
||||
}}
|
||||
/>
|
||||
<Button disabled={searching || !query.trim()} onClick={() => void search()}>
|
||||
<Button disabled={searching} onClick={() => void search()}>
|
||||
{searching ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
@@ -556,18 +417,10 @@ export default function AssistantKnowledgePage() {
|
||||
<Badge variant="outline">
|
||||
{searchResult.retrieval_mode === "vector"
|
||||
? "知识正文 / 片段 · 向量检索"
|
||||
: searchResult.retrieval_mode === "overview"
|
||||
? "知识正文 · 知识库概览"
|
||||
: searchResult.retrieval_mode === "empty"
|
||||
? "没有匹配结果"
|
||||
: "知识正文 / 片段 · 关键词检索"}
|
||||
: "知识正文 / 片段 · 关键词检索(暂无匹配模型的向量)"}
|
||||
</Badge>
|
||||
{searchResult.results.length === 0 && (
|
||||
<div role="status" className="mt-4 rounded-xl border border-dashed p-5 text-sm text-muted-foreground">
|
||||
<p>没有找到相关知识。试试更简短的关键词,或浏览 Wiki 目录确认资料是否已入库。</p>
|
||||
<Button variant="outline" className="mt-3" onClick={() => setTab("wiki")}>浏览 Wiki 目录</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 space-y-3">
|
||||
{searchResult.results.map((p) => (
|
||||
<button
|
||||
|
||||
@@ -40,7 +40,6 @@ import { CompactWelcome } from "@/components/workspace/compact-welcome";
|
||||
import {
|
||||
buildFullChatNavigationPath,
|
||||
isChatsIframeWorkspacePath,
|
||||
WX_FULL_CHAT_PATH_PREFIX,
|
||||
} from "@/core/page-layout/chats-iframe-route";
|
||||
import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
|
||||
import { isWujieEnv } from "@/core/embed/wujie";
|
||||
@@ -74,6 +73,7 @@ import { clearResearchReportStream } from "@/core/threads/use-research-report-st
|
||||
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
||||
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
|
||||
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
|
||||
import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from "@/core/threads/use-direct-chat";
|
||||
import { useMultiRoundRagMessages, mergeMessagesByTime, toLangGraphMessages } from "@/core/threads/use-multi-round-rag";
|
||||
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
@@ -147,7 +147,6 @@ export default function ChatPage() {
|
||||
const [knowledgeRagEnabled] = useKnowledgeRagEnabled();
|
||||
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
|
||||
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>([]);
|
||||
const [knowledgeSuggestedQuestions, setKnowledgeSuggestedQuestions] = useState<string[]>([]);
|
||||
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(false);
|
||||
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
|
||||
const knowledgeSelectionThread = useRef<string | null>(null);
|
||||
@@ -366,36 +365,12 @@ export default function ChatPage() {
|
||||
});
|
||||
const singleRoundRag = useSingleRoundRagMessages(threadId);
|
||||
const multiRoundRag = useMultiRoundRagMessages(threadId);
|
||||
const directChat = useDirectChatMessages(threadId);
|
||||
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
|
||||
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
|
||||
const frontendRagSelected = isFrontendRagProfile(settings.context);
|
||||
// 排查高并发时普通对话输入框意外禁用:流式运行本身不应成为禁用原因。
|
||||
const 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]);
|
||||
const directChatSelected =
|
||||
isDirectChatProfile(settings.context) && isTurboModeVisible();
|
||||
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
|
||||
// 流式 stub),成功且真实产物落地后自动撤出。
|
||||
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
|
||||
@@ -405,13 +380,15 @@ export default function ChatPage() {
|
||||
if (
|
||||
singleRoundRag.messages.length === 0 &&
|
||||
ragMessages.length === 0 &&
|
||||
directChat.messages.length === 0 &&
|
||||
!singleRoundRag.isLoading &&
|
||||
!multiRoundRag.isLoading
|
||||
!multiRoundRag.isLoading &&
|
||||
!directChat.isLoading
|
||||
) {
|
||||
return base;
|
||||
}
|
||||
const messages = mergeMessagesByTime(
|
||||
[...base.messages, ...singleRoundRag.messages],
|
||||
[...base.messages, ...singleRoundRag.messages, ...directChat.messages],
|
||||
ragMessages,
|
||||
);
|
||||
return {
|
||||
@@ -419,9 +396,14 @@ export default function ChatPage() {
|
||||
values: { ...base.values, messages },
|
||||
messages,
|
||||
isLoading:
|
||||
base.isLoading || singleRoundRag.isLoading || multiRoundRag.isLoading,
|
||||
base.isLoading ||
|
||||
singleRoundRag.isLoading ||
|
||||
multiRoundRag.isLoading ||
|
||||
directChat.isLoading,
|
||||
} as typeof base;
|
||||
}, [
|
||||
directChat.isLoading,
|
||||
directChat.messages,
|
||||
multiRoundRag.isLoading,
|
||||
multiRoundRag.messages,
|
||||
researchDisplayThread,
|
||||
@@ -437,6 +419,52 @@ export default function ChatPage() {
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
if (directChatSelected) {
|
||||
if (message.files && message.files.length > 0) {
|
||||
toast.error("极速问答暂不支持附件,请移除附件后重试。");
|
||||
return;
|
||||
}
|
||||
setHasStartedConversation(true);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
void (async () => {
|
||||
try {
|
||||
let targetThreadId = threadId;
|
||||
if (isNewThread) {
|
||||
const created = await getAPIClient(isMock).threads.create({});
|
||||
targetThreadId = created.thread_id;
|
||||
setThreadId(targetThreadId);
|
||||
setIsNewThread(false);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#${routeBase}/chats/${targetThreadId}`,
|
||||
);
|
||||
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
|
||||
if (title) {
|
||||
void getAPIClient(isMock).threads
|
||||
.updateState(targetThreadId, { values: { title } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
if (!targetThreadId) {
|
||||
toast.error("无法创建会话。");
|
||||
return;
|
||||
}
|
||||
await directChat.submit({
|
||||
targetThreadId,
|
||||
query: message.text,
|
||||
modelName: settings.context.model_name,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "极速问答失败。",
|
||||
);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
if (frontendRagSelected) {
|
||||
if (message.files && message.files.length > 0) {
|
||||
toast.error("RAG 问答暂不支持附件,请移除附件后重试。");
|
||||
@@ -603,6 +631,8 @@ export default function ChatPage() {
|
||||
multiRoundRag,
|
||||
multiRoundRagSelected,
|
||||
frontendRagSelected,
|
||||
directChat,
|
||||
directChatSelected,
|
||||
routeBase,
|
||||
setIsNewThread,
|
||||
setThreadId,
|
||||
@@ -698,6 +728,10 @@ export default function ChatPage() {
|
||||
saveLastAgentId(agentId);
|
||||
}, []);
|
||||
const handleStop = useCallback(async () => {
|
||||
if (directChat.isLoading) {
|
||||
directChat.stop();
|
||||
return;
|
||||
}
|
||||
if (singleRoundRag.isLoading) {
|
||||
singleRoundRag.stop();
|
||||
return;
|
||||
@@ -707,7 +741,7 @@ export default function ChatPage() {
|
||||
return;
|
||||
}
|
||||
await thread.stop();
|
||||
}, [multiRoundRag, singleRoundRag, thread]);
|
||||
}, [directChat, multiRoundRag, singleRoundRag, thread]);
|
||||
|
||||
const promptPrefixProp = useMemo(() => {
|
||||
if (!promptPrefixSettings?.enabled) return undefined;
|
||||
@@ -740,9 +774,7 @@ export default function ChatPage() {
|
||||
// 用 useLayoutMode 的有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认),
|
||||
// 与 PageRoutes 的 chrome 判定同源。非紧凑(嵌入/standalone)保持原样。
|
||||
const chromeByPreference =
|
||||
fullChatMode ||
|
||||
pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX) ||
|
||||
effectiveLayoutMode === "full";
|
||||
fullChatMode || effectiveLayoutMode === "full" ? true : false;
|
||||
const compactChrome =
|
||||
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
|
||||
const compactLanding = isLandingState && compactChrome;
|
||||
@@ -953,7 +985,15 @@ export default function ChatPage() {
|
||||
}
|
||||
context={settings.context}
|
||||
extraModels={SINGLE_ROUND_RAG_MODELS}
|
||||
disabled={inputDisabled}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
isUploading ||
|
||||
agentHandoffPending ||
|
||||
knowledgeSelectionLoading ||
|
||||
singleRoundRag.isLoading ||
|
||||
multiRoundRag.isLoading ||
|
||||
directChat.isLoading
|
||||
}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
@@ -974,7 +1014,6 @@ export default function ChatPage() {
|
||||
assistantValue={selectedAssistantKnowledgeBaseIds}
|
||||
onChange={changeKnowledgeSelection}
|
||||
onAssistantChange={changeAssistantKnowledgeSelection}
|
||||
onSuggestedQuestionsChange={setKnowledgeSuggestedQuestions}
|
||||
autoSelectAll={isLandingState && !!selectedAgentId}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
@@ -984,7 +1023,6 @@ export default function ChatPage() {
|
||||
label="知识空间"
|
||||
/>
|
||||
}
|
||||
knowledgeSuggestedQuestions={knowledgeSuggestedQuestions}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
BookMarkedIcon,
|
||||
BrainCircuitIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
ShrinkIcon,
|
||||
@@ -399,6 +400,13 @@ export default function IframeChatPage() {
|
||||
});
|
||||
}, [setSettings, settings.context.writing_mode]);
|
||||
|
||||
const toggleMemoryInjection = useCallback(() => {
|
||||
setSettings("context", {
|
||||
memory_injection_enabled:
|
||||
!(settings.context.memory_injection_enabled ?? true),
|
||||
});
|
||||
}, [setSettings, settings.context.memory_injection_enabled]);
|
||||
|
||||
const toggleRagMode = useCallback(() => {
|
||||
if (ragModeLocked) {
|
||||
return;
|
||||
@@ -523,6 +531,9 @@ export default function IframeChatPage() {
|
||||
const modeButtons: ResolvedIframeModeButton[] = buildIframeModeButtons({
|
||||
agentSelected: !!selectedAgentId,
|
||||
writingModeEnabled: !!settings.context.writing_mode,
|
||||
memoryInjectionEnabled: Boolean(
|
||||
settings.context.memory_injection_enabled ?? true,
|
||||
),
|
||||
referenceModeEntryEnabled,
|
||||
ragModeEnabled,
|
||||
ragModeLocked,
|
||||
@@ -544,6 +555,13 @@ export default function IframeChatPage() {
|
||||
onClick: toggleWritingMode,
|
||||
activeClassName: "text-amber-600 hover:text-amber-700",
|
||||
};
|
||||
case "memory":
|
||||
return {
|
||||
...button,
|
||||
icon: BrainCircuitIcon,
|
||||
onClick: toggleMemoryInjection,
|
||||
activeClassName: "text-blue-600 hover:text-blue-700",
|
||||
};
|
||||
case "references":
|
||||
return {
|
||||
...button,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { Copy, Info, KeyRound, Link2, Search, ShieldCheck, UserCheck, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -201,8 +201,9 @@ export default function LoginWhitelistPage() {
|
||||
const allowed = user.approved || isUserAdmin;
|
||||
const hasPwd = Boolean(user.login_password);
|
||||
const isEditing = editingId === user.id;
|
||||
return [
|
||||
<li key={`${user.id}-summary`} className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-3 px-4 py-3 transition-colors hover:bg-slate-50/60">
|
||||
return (
|
||||
<Fragment key={user.id}>
|
||||
<li className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-3 px-4 py-3 transition-colors hover:bg-slate-50/60">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-slate-800">{user.email}</span>
|
||||
@@ -259,10 +260,10 @@ export default function LoginWhitelistPage() {
|
||||
onCheckedChange={(v) => handleToggleApproval(user.id, v)}
|
||||
/>
|
||||
</div>
|
||||
</li>,
|
||||
</li>
|
||||
|
||||
isEditing && (
|
||||
<li key={`${user.id}-password`} className="bg-slate-50/70 px-4 py-4">
|
||||
{isEditing && (
|
||||
<li className="bg-slate-50/70 px-4 py-4">
|
||||
<div className="rounded-xl border border-slate-200/70 bg-white p-4 shadow-sm">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-600">
|
||||
<KeyRound className="size-3.5 text-slate-400" />
|
||||
@@ -327,8 +328,9 @@ export default function LoginWhitelistPage() {
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
];
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
@@ -86,7 +86,6 @@ const emptyDraft = (): ModelDraft => ({
|
||||
base_url: "",
|
||||
request_timeout: 30,
|
||||
max_retries: 2,
|
||||
max_input_tokens: undefined,
|
||||
max_tokens: undefined,
|
||||
temperature: undefined,
|
||||
stream_usage: true,
|
||||
@@ -144,7 +143,6 @@ function buildPayload(draft: ModelDraft): ModelSavePayload {
|
||||
base_url: optionalString(draft.base_url),
|
||||
request_timeout: toNumber(draft.request_timeout),
|
||||
max_retries: toNumber(draft.max_retries),
|
||||
max_input_tokens: toNumber(draft.max_input_tokens),
|
||||
max_tokens: toNumber(draft.max_tokens),
|
||||
temperature: toNumber(draft.temperature),
|
||||
stream_usage: draft.stream_usage,
|
||||
@@ -381,7 +379,7 @@ function ModelEditor({ model, onClose }: { model: ManagedModel | null; onClose:
|
||||
</section>
|
||||
<div className="flex items-center justify-between border-t pt-4"><TButton theme="default" variant="outline" loading={test.isPending} onClick={() => void handleTest()}><WrenchIcon className="mr-1.5 size-4" />测试连接</TButton><TButton theme="default" variant="text" onClick={() => setAdvanced((value) => !value)}><Settings2Icon className="mr-1.5 size-4" />高级配置 <ChevronDownIcon className={`ml-1 size-4 transition-transform ${advanced ? "rotate-180" : ""}`} /></TButton></div>
|
||||
{testResult && <div className={`rounded-lg border px-3 py-2 text-sm ${testResult.success ? "border-primary/20 bg-primary/5 text-foreground" : "border-destructive/25 bg-destructive/5 text-destructive"}`}><div className="font-medium">{testResult.success ? "连接成功" : "连接失败"}{testResult.success && testResult.latency ? ` · ${testResult.latency} ms` : ""}</div><p className="mt-1 break-words text-xs opacity-80">{testResult.preview || testResult.message}</p></div>}
|
||||
{advanced && <section className="space-y-4 rounded-lg border border-dashed border-border bg-muted/10 p-4"><div className="grid gap-4 md:grid-cols-3"><Field label="请求超时(秒)"><InputNumber value={draft.request_timeout ?? ""} min={1} max={600} onChange={(value) => patch({ request_timeout: toNumber(value) })} /></Field><Field label="最大重试次数"><InputNumber value={draft.max_retries ?? ""} min={0} max={20} onChange={(value) => patch({ max_retries: toNumber(value) })} /></Field><Field label="最大输入 Token"><InputNumber value={draft.max_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>}
|
||||
{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>}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
CircleHelp,
|
||||
Clock3,
|
||||
FolderKanban,
|
||||
Globe,
|
||||
LoaderCircle,
|
||||
MessageSquareText,
|
||||
PanelLeftClose,
|
||||
@@ -21,9 +20,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { type FormEvent, useCallback, useMemo, useState } from "react";
|
||||
import { Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||
import AnalysisProjectsPage from "./AnalysisProjectsPage";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -56,14 +53,12 @@ import {
|
||||
} from "@/core/official-deerflow/routes";
|
||||
import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
|
||||
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
|
||||
import OfficialWebAppsPage from "@/pages/OfficialWebAppsPage";
|
||||
|
||||
import "./official-deerflow-entry.css";
|
||||
|
||||
type OfficialPage =
|
||||
| "chat"
|
||||
| "projects"
|
||||
| "web-apps"
|
||||
| "agents"
|
||||
| "agent-chat"
|
||||
| "scheduled"
|
||||
@@ -75,7 +70,6 @@ function pageFromPath(pathname: string): OfficialPage {
|
||||
return "agent-chat";
|
||||
}
|
||||
if (path.startsWith("/workspace/projects")) return "projects";
|
||||
if (path.startsWith("/workspace/web-app")) return "web-apps";
|
||||
if (path.startsWith("/workspace/agents")) return "agents";
|
||||
if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled";
|
||||
if (path.startsWith("/workspace/settings")) return "settings";
|
||||
@@ -722,7 +716,6 @@ export default function OfficialDeerFlowEntry() {
|
||||
{ icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, pages: ["chat"] },
|
||||
{ icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] },
|
||||
{ icon: FolderKanban, label: "我的项目", path: projectBasePath, pages: ["projects"] },
|
||||
{ icon: Globe, label: "Web 应用", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/web-app`, pages: ["web-apps"] },
|
||||
{ icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] },
|
||||
{ icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] },
|
||||
] as const;
|
||||
@@ -766,11 +759,6 @@ export default function OfficialDeerFlowEntry() {
|
||||
className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`}
|
||||
key={chat.thread_id}
|
||||
onClick={() => {
|
||||
const projectId = chat.metadata?.analysis_project_id;
|
||||
if (typeof projectId === "string" && projectId) {
|
||||
go(`${projectBasePath}/groups/${encodeURIComponent(projectId)}/chats/${encodeURIComponent(chat.thread_id)}`);
|
||||
return;
|
||||
}
|
||||
const agentId = typeof chat.context?.agent_id === "string"
|
||||
? chat.context.agent_id
|
||||
: typeof chat.metadata?.agent_id === "string"
|
||||
@@ -827,13 +815,7 @@ export default function OfficialDeerFlowEntry() {
|
||||
/>
|
||||
)}
|
||||
{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 ? (
|
||||
projectId ? (
|
||||
<StrategySelectionPage
|
||||
key={projectId}
|
||||
projectBasePath={projectBasePath}
|
||||
@@ -843,7 +825,6 @@ export default function OfficialDeerFlowEntry() {
|
||||
<StrategyProjectsPage basePath={projectBasePath} />
|
||||
)
|
||||
)}
|
||||
{page === "web-apps" && <OfficialWebAppsPage />}
|
||||
{page === "agents" && (
|
||||
<AgentsPage
|
||||
openAgent={(agentId) =>
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* 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,10 +4,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { PageSidebar } from "@/components/page-sidebar";
|
||||
import { PageSidebarDark } from "@/components/page-sidebar-dark";
|
||||
import { CompactToolbox } from "@/components/workspace/compact-toolbox";
|
||||
import {
|
||||
isChatsIframeWorkspacePath,
|
||||
WX_FULL_CHAT_PATH_PREFIX,
|
||||
} from "@/core/page-layout/chats-iframe-route";
|
||||
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
|
||||
import { isWujieEnv } from "@/core/embed/wujie";
|
||||
import {
|
||||
isEmbedActive,
|
||||
@@ -39,9 +36,6 @@ function PageLayout() {
|
||||
const location = useLocation();
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
// This dedicated surface is self-contained: it keeps the workspace's
|
||||
// common-agent panel, while omitting the global header and page navigation.
|
||||
const isWxFullChat = location.pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX);
|
||||
// 浅色非嵌入:侧边栏为新版「二级导航图标卡片」窄栏(96px);wujie 嵌入仍走旧版手风琴(64/256px)。
|
||||
const inWujie = useMemo(() => isWujieEnv(), []);
|
||||
const lightNarrowSidebar = !isDark && !inWujie;
|
||||
@@ -53,13 +47,7 @@ function PageLayout() {
|
||||
// 不再回退到会话级 sidebarEnabled 旧默认)。「全量↔简洁」始终能真正控制顶栏+侧边栏。
|
||||
const [layoutMode] = useLayoutMode();
|
||||
const chromeByPreference =
|
||||
forceCollapsedSidebar
|
||||
? false
|
||||
: isWxFullChat
|
||||
? true
|
||||
: layoutMode === "compact"
|
||||
? false
|
||||
: true;
|
||||
forceCollapsedSidebar ? false : layoutMode === "compact" ? false : true;
|
||||
// iframe 嵌入模式(外部系统 `&embed=1`,按标签页 sessionStorage):去掉全局导航栏 + 侧边栏,
|
||||
// 只留问答/会商工作区本体。按 location 重算以跟随页内导航;非嵌入标签页恒为 false,不受影响。
|
||||
const embedActive = useMemo(
|
||||
@@ -102,7 +90,6 @@ function PageLayout() {
|
||||
const pageSidebarAllowed =
|
||||
chromeByPreference &&
|
||||
!isChatsIframeWorkspacePath(location.pathname) &&
|
||||
!isWxFullChat &&
|
||||
!embedActive &&
|
||||
!chromeHidden &&
|
||||
!hostNestedSurface;
|
||||
@@ -111,7 +98,6 @@ function PageLayout() {
|
||||
!embedActive &&
|
||||
!chromeHidden &&
|
||||
!hostNestedSurface &&
|
||||
!isWxFullChat &&
|
||||
!isChatsIframeWorkspacePath(location.pathname);
|
||||
|
||||
// h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100%
|
||||
@@ -165,9 +151,7 @@ function PageLayout() {
|
||||
// Full-chat embedding has no global PageSidebar, but it
|
||||
// still needs the full GeneralQaSidebar (common agents and
|
||||
// conversation history) inside the workspace.
|
||||
compactWorkspaceSidebar={
|
||||
pageSidebarAllowed || fullChatMode || isWxFullChat
|
||||
}
|
||||
compactWorkspaceSidebar={pageSidebarAllowed || fullChatMode}
|
||||
compactActionsHidden={hideChromeActive || headerHidden}
|
||||
pageSidebarDark={isDark}
|
||||
forceWorkspaceSidebarCollapsed={
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
|
||||
import AnalysisProjectsPage from "./AnalysisProjectsPage";
|
||||
import { Navigate, Route, Routes, useParams } from "react-router-dom";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||||
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||||
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
|
||||
@@ -66,6 +62,7 @@ import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
|
||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||
import LightAppNoteListPage from "./LightAppNoteListPage";
|
||||
import LightAppNoteQAPage from "./LightAppNoteQAPage";
|
||||
import MemoryPage from "./MemoryPage";
|
||||
import NewAgentPage from "./NewAgentPage";
|
||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||
@@ -103,23 +100,6 @@ 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} />;
|
||||
@@ -140,13 +120,12 @@ export default function StrategyRoutes() {
|
||||
<Route
|
||||
path="qa/projects"
|
||||
element={
|
||||
<StrategyProjectsLayout><StrategyProjectsPage basePath="/page/strategy/qa/projects" /></StrategyProjectsLayout>
|
||||
<StrategyProjectsPage basePath="/page/strategy/qa/projects" />
|
||||
}
|
||||
/>
|
||||
<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>}
|
||||
element={<StrategyProjectWorkbenchRoute />}
|
||||
/>
|
||||
<Route
|
||||
path="qa/strategy-selection"
|
||||
@@ -444,6 +423,7 @@ export default function StrategyRoutes() {
|
||||
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
|
||||
<Route path="memory" element={<MemoryPage />} />
|
||||
<Route
|
||||
path="business-chain-mapping"
|
||||
element={
|
||||
|
||||
@@ -31,11 +31,10 @@ import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
|
||||
|
||||
import { RouteBaseProvider, useRouteBase } from "@/core/route-base";
|
||||
import { RouteBaseProvider } from "@/core/route-base";
|
||||
import { agentRefOfThread } from "@/core/threads/utils";
|
||||
|
||||
import ChatsPage from "./ChatsPage";
|
||||
import AnalysisProjectsPage from "./AnalysisProjectsPage";
|
||||
import ChatPage from "./ChatPage";
|
||||
import ArtifactLibraryPage from "./ArtifactLibraryPage";
|
||||
import IframeChatPage from "./IframeChatPage";
|
||||
@@ -56,6 +55,7 @@ import WeKnoraPackageTransfersPage from "@/strategy-components/components/resour
|
||||
import MarkdownDocxEditorPage from "./MarkdownDocxEditorPage";
|
||||
import LlmMetricsPage from "./LlmMetricsPage";
|
||||
import CheckpointMigrationPage from "./CheckpointMigrationPage";
|
||||
import MemoryPage from "./MemoryPage";
|
||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||
import ModelConcurrencySettingsPage from "./ModelConcurrencySettingsPage";
|
||||
@@ -130,6 +130,7 @@ function shouldShowCompactTopActions(pathname: string): boolean {
|
||||
return (
|
||||
pathname === "/page/workspace/agents" ||
|
||||
pathname === "/page/workspace/skills" ||
|
||||
pathname === "/page/workspace/memory" ||
|
||||
pathname === "/page/workspace/library" ||
|
||||
pathname === "/page/workspace/knowledge" ||
|
||||
pathname.startsWith("/page/workspace/knowledge/")
|
||||
@@ -171,7 +172,6 @@ export function WorkspaceLayout({
|
||||
insetOverflowHidden = false,
|
||||
pageSidebarDark = false,
|
||||
forceWorkspaceSidebarCollapsed = false,
|
||||
wxCommonAgents = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
pageSidebarCollapsed: boolean;
|
||||
@@ -187,16 +187,11 @@ export function WorkspaceLayout({
|
||||
pageSidebarDark?: boolean;
|
||||
/** Deployment-level compact mode: keep only the icon menu rail. */
|
||||
forceWorkspaceSidebarCollapsed?: boolean;
|
||||
/** Enables wx's shared Agent Gallery category selection. */
|
||||
wxCommonAgents?: boolean;
|
||||
}) {
|
||||
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
|
||||
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
|
||||
const pathname = useLocation().pathname;
|
||||
const isGeneralQa =
|
||||
pathname.startsWith("/page/workspace/chats") ||
|
||||
pathname.startsWith("/page/workspace/wx/") ||
|
||||
pathname.startsWith("/page/workspace/projects");
|
||||
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
|
||||
const showCompactTopActions =
|
||||
headerHidden &&
|
||||
!compactActionsHidden &&
|
||||
@@ -248,7 +243,6 @@ export function WorkspaceLayout({
|
||||
<WorkspaceSidebar
|
||||
compactChrome={compactWorkspaceSidebar}
|
||||
forceCollapsed={forceWorkspaceSidebarCollapsed}
|
||||
wxCommonAgents={wxCommonAgents}
|
||||
/>
|
||||
)}
|
||||
<SidebarInset
|
||||
@@ -418,7 +412,6 @@ function ChatThreadEntryRoute({
|
||||
function AgentAwareChatThreadContent() {
|
||||
const { thread_id: threadId } = useParams<{ thread_id: string }>();
|
||||
const location = useLocation();
|
||||
const routeBase = useRouteBase();
|
||||
const [agentId, setAgentId] = useState<string | null>(null);
|
||||
const [resolved, setResolved] = useState(false);
|
||||
|
||||
@@ -465,133 +458,16 @@ function AgentAwareChatThreadContent() {
|
||||
|
||||
if (threadId && agentId) {
|
||||
return (
|
||||
<Navigate
|
||||
replace
|
||||
to={`${routeBase}/agents/${encodeURIComponent(agentId)}/chats/${threadId}${location.search}`}
|
||||
/>
|
||||
<Navigate
|
||||
replace
|
||||
to={`/page/workspace/agents/${encodeURIComponent(agentId)}/chats/${threadId}${location.search}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ChatPage />;
|
||||
}
|
||||
|
||||
const WX_CHAT_ROUTE_BASE = "/page/workspace/wx";
|
||||
|
||||
type WorkspaceRouteLayoutProps = {
|
||||
pageSidebarCollapsed: boolean;
|
||||
pageSidebarHidden: boolean;
|
||||
workspaceSidebarHidden: boolean;
|
||||
headerHidden: boolean;
|
||||
compactWorkspaceSidebar: boolean;
|
||||
compactActionsHidden: boolean;
|
||||
pageSidebarDark: boolean;
|
||||
forceWorkspaceSidebarCollapsed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dedicated full-layout chat page for the wx workflow. It shares the normal
|
||||
* chat composition but keeps its own route namespace and category-filtered
|
||||
* common-agents sidebar.
|
||||
*/
|
||||
function WxChatLayout({
|
||||
layoutProps,
|
||||
children,
|
||||
}: {
|
||||
layoutProps: WorkspaceRouteLayoutProps;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<RouteBaseProvider value={WX_CHAT_ROUTE_BASE}>
|
||||
<WorkspaceLayout
|
||||
{...layoutProps}
|
||||
defaultHistorySidebarOpen
|
||||
wxCommonAgents
|
||||
>
|
||||
{children}
|
||||
</WorkspaceLayout>
|
||||
</RouteBaseProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WxNewChatEntryRoute({
|
||||
layoutProps,
|
||||
}: {
|
||||
layoutProps: WorkspaceRouteLayoutProps;
|
||||
}) {
|
||||
return (
|
||||
<WxChatLayout layoutProps={layoutProps}>
|
||||
<ChatRuntime>
|
||||
<NewChatContent />
|
||||
</ChatRuntime>
|
||||
</WxChatLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function WxChatThreadEntryRoute({
|
||||
layoutProps,
|
||||
}: {
|
||||
layoutProps: WorkspaceRouteLayoutProps;
|
||||
}) {
|
||||
return (
|
||||
<WxChatLayout layoutProps={layoutProps}>
|
||||
<ChatRuntime>
|
||||
<AgentAwareChatThreadContent />
|
||||
</ChatRuntime>
|
||||
</WxChatLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function WxAgentChatEntryRoute({
|
||||
layoutProps,
|
||||
}: {
|
||||
layoutProps: WorkspaceRouteLayoutProps;
|
||||
}) {
|
||||
const { agent_id: agentId } = useParams<{ agent_id: string }>();
|
||||
return (
|
||||
<WxChatLayout layoutProps={layoutProps}>
|
||||
<ChatRuntime>
|
||||
<AgentChatPage key={agentId} />
|
||||
</ChatRuntime>
|
||||
</WxChatLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function WxSentimentAgentChatEntryRoute({
|
||||
layoutProps,
|
||||
}: {
|
||||
layoutProps: WorkspaceRouteLayoutProps;
|
||||
}) {
|
||||
return (
|
||||
<WxChatLayout layoutProps={layoutProps}>
|
||||
<SentimentAgentChatPage />
|
||||
</WxChatLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function WxChatsIndexEntryRoute({
|
||||
layoutProps,
|
||||
}: {
|
||||
layoutProps: WorkspaceRouteLayoutProps;
|
||||
}) {
|
||||
return (
|
||||
<WxChatLayout layoutProps={layoutProps}>
|
||||
<ChatsPage compactChrome />
|
||||
</WxChatLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function WxAgentsEntryRoute({
|
||||
layoutProps,
|
||||
}: {
|
||||
layoutProps: WorkspaceRouteLayoutProps;
|
||||
}) {
|
||||
return (
|
||||
<WxChatLayout layoutProps={layoutProps}>
|
||||
<AgentsPage />
|
||||
</WxChatLayout>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `chats_iframe` is reserved for the Brainy postMessage protocol. `from=iframe`
|
||||
* and `chatMode=iframe` opt into this page; ordinary iframe/Wujie embedding
|
||||
@@ -672,58 +548,6 @@ export default function WorkspaceRoutes({
|
||||
<RouteBaseProvider value="/page/workspace">
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="chats/new" replace />} />
|
||||
<Route path="wx" element={<Navigate to="chats/new" replace />} />
|
||||
<Route
|
||||
path="wx/chats"
|
||||
element={<WxChatsIndexEntryRoute layoutProps={layoutProps} />}
|
||||
/>
|
||||
<Route
|
||||
path="wx/chats/new"
|
||||
element={<WxNewChatEntryRoute layoutProps={layoutProps} />}
|
||||
/>
|
||||
<Route
|
||||
path="wx/chats/:thread_id"
|
||||
element={<WxChatThreadEntryRoute layoutProps={layoutProps} />}
|
||||
/>
|
||||
<Route
|
||||
path="wx/agents"
|
||||
element={<WxAgentsEntryRoute layoutProps={layoutProps} />}
|
||||
/>
|
||||
<Route
|
||||
path="wx/agents/sentiment-analysis/chats"
|
||||
element={
|
||||
isSentimentAgentVisible() ? (
|
||||
<Navigate
|
||||
to="/page/workspace/wx/agents/sentiment-analysis/chats/new"
|
||||
replace
|
||||
/>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/wx/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wx/agents/sentiment-analysis/chats/:thread_id"
|
||||
element={
|
||||
isSentimentAgentVisible() ? (
|
||||
<WxSentimentAgentChatEntryRoute layoutProps={layoutProps} />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/wx/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wx/agents/:agent_id/chats/:thread_id"
|
||||
element={<WxAgentChatEntryRoute layoutProps={layoutProps} />}
|
||||
/>
|
||||
<Route
|
||||
path="projects/*"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen insetOverflowHidden>
|
||||
<AnalysisProjectsPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="chats"
|
||||
element={
|
||||
@@ -860,6 +684,14 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="memory"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<MemoryPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="ai-writing/*"
|
||||
element={
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
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,16 +144,6 @@ import {
|
||||
import { ResearchSandboxPanel } from "./ResearchSandboxPanel";
|
||||
import { WorkflowConfigButton } from "./WorkflowConfigDialog";
|
||||
import { REPORT_OUTPUT_PATH, useReportSandbox } from "./useReportSandbox";
|
||||
import {
|
||||
PLANNER_AGENT_ID,
|
||||
OUTLINE_PLANNER_OPTIONS,
|
||||
buildCollectAfterPlanPrompt,
|
||||
hasCollectAfterPlanTurn,
|
||||
hasCollectorToolTurn,
|
||||
isCollectAfterPlanPrompt,
|
||||
normalizeOutlinePlanner,
|
||||
outlineFromFirstAssistant,
|
||||
} from "./outline-plan";
|
||||
|
||||
const COLLECTOR_AGENT_ID = "deep-research-collector";
|
||||
/** TDesign Select sentinel: do not send a per-session model override. */
|
||||
@@ -647,8 +637,6 @@ function DeepResearchWorkbenchInner() {
|
||||
const collectorPersistChain = useRef(Promise.resolve());
|
||||
const collectorSessionIdRef = useRef<string | null>(null);
|
||||
const followUpAbortRef = useRef<AbortController | null>(null);
|
||||
const outlinePlanKeyRef = useRef("");
|
||||
const pendingCollectAfterPlanRef = useRef("");
|
||||
|
||||
const refetchSession = sessionQuery.refetch;
|
||||
const refetchSessions = sessions.refetch;
|
||||
@@ -1107,17 +1095,6 @@ function DeepResearchWorkbenchInner() {
|
||||
|
||||
// ── collector-agent conversation stream (normal-chat machinery) ───────────
|
||||
|
||||
const outlinePlannerMode = session
|
||||
? normalizeOutlinePlanner(session.config?.outline_planner)
|
||||
: advancedOptions.outlinePlanner;
|
||||
const sessionOutline = (
|
||||
session && Object.prototype.hasOwnProperty.call(reportCardOutlineOverrides, session.id)
|
||||
? reportCardOutlineOverrides[session.id]
|
||||
: (typeof session?.config?.custom_outline === "string" ? session.config.custom_outline : "")
|
||||
|| advancedOptions.customOutline
|
||||
).trim();
|
||||
const [activeAgentId, setActiveAgentId] = useState(COLLECTOR_AGENT_ID);
|
||||
|
||||
const collectorContext = useMemo(
|
||||
() => {
|
||||
const skills = normalizeRetrievalSkills(
|
||||
@@ -1128,16 +1105,16 @@ function DeepResearchWorkbenchInner() {
|
||||
return {
|
||||
...localSettings.context,
|
||||
model_name: selectedModel ?? localSettings.context.model_name,
|
||||
agent_id: activeAgentId,
|
||||
agent_id: COLLECTOR_AGENT_ID,
|
||||
...(skills.length > 0 ? { extra_skills: skills } : {}),
|
||||
};
|
||||
},
|
||||
[activeAgentId, advancedOptions.retrievalSkills, localSettings.context, selectedModel, session?.config?.retrieval_skills],
|
||||
[advancedOptions.retrievalSkills, localSettings.context, selectedModel, session?.config?.retrieval_skills],
|
||||
);
|
||||
|
||||
const [collectorThread, collectorSendMessage] = useThreadStream({
|
||||
threadId: isChatSession ? collectorThreadId : undefined,
|
||||
assistantId: activeAgentId,
|
||||
assistantId: COLLECTOR_AGENT_ID,
|
||||
context: collectorContext,
|
||||
});
|
||||
|
||||
@@ -1148,48 +1125,32 @@ function DeepResearchWorkbenchInner() {
|
||||
() => hasPendingClarification(collectorThread.messages),
|
||||
[collectorThread.messages],
|
||||
);
|
||||
const collectionAlreadyStarted = hasCollectAfterPlanTurn(collectorThread.messages, extractContentFromMessage)
|
||||
|| hasCollectorToolTurn(collectorThread.messages);
|
||||
const isPlanningOutline = outlinePlannerMode === "agent"
|
||||
&& !sessionOutline
|
||||
&& !collectionAlreadyStarted
|
||||
&& isChatSession;
|
||||
|
||||
const sendCollectorMessage = useCallback(
|
||||
async (text: string, options?: {
|
||||
silentStatusPatch?: boolean;
|
||||
hideFromUI?: boolean;
|
||||
withDirections?: boolean;
|
||||
withSkills?: boolean;
|
||||
}) => {
|
||||
async (text: string, options?: { silentStatusPatch?: boolean }) => {
|
||||
const threadId = session?.runtimeThreadId;
|
||||
const trimmed = text.trim();
|
||||
if (!threadId || !trimmed) return;
|
||||
if (!options?.silentStatusPatch && session && session.status !== "collecting") {
|
||||
void actions.updateStatus.mutate({ sessionId: session.id, status: "collecting" });
|
||||
}
|
||||
const useDirections = options?.withDirections === true
|
||||
|| (options?.withDirections !== false && (collectorThread.messages?.length ?? 0) === 0 && !isPlanningOutline);
|
||||
const useSkills = options?.withSkills === true || (options?.withSkills !== false && useDirections);
|
||||
const payload = composeCollectorQueryWithDirections(
|
||||
trimmed,
|
||||
useDirections
|
||||
? (advancedOptions.retrievalDirections.length > 0
|
||||
const isFirstTurn = (collectorThread.messages?.length ?? 0) === 0;
|
||||
const payload = isFirstTurn
|
||||
? composeCollectorQueryWithDirections(
|
||||
trimmed,
|
||||
advancedOptions.retrievalDirections.length > 0
|
||||
? advancedOptions.retrievalDirections
|
||||
: session?.config?.retrieval_directions)
|
||||
: [],
|
||||
useSkills
|
||||
? (advancedOptions.retrievalSkills.length > 0
|
||||
: session?.config?.retrieval_directions,
|
||||
advancedOptions.retrievalSkills.length > 0
|
||||
? advancedOptions.retrievalSkills
|
||||
: session?.config?.retrieval_skills)
|
||||
: [],
|
||||
);
|
||||
: session?.config?.retrieval_skills,
|
||||
)
|
||||
: trimmed;
|
||||
try {
|
||||
await collectorSendMessage(
|
||||
threadId,
|
||||
{ text: payload, files: [] },
|
||||
selectedModel ? { model_name: selectedModel } : undefined,
|
||||
options?.hideFromUI ? { additionalKwargs: { hide_from_ui: true } } : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "发送失败,请重试");
|
||||
@@ -1201,7 +1162,6 @@ function DeepResearchWorkbenchInner() {
|
||||
advancedOptions.retrievalSkills,
|
||||
collectorSendMessage,
|
||||
collectorThread.messages?.length,
|
||||
isPlanningOutline,
|
||||
selectedModel,
|
||||
session,
|
||||
],
|
||||
@@ -1216,46 +1176,26 @@ function DeepResearchWorkbenchInner() {
|
||||
[collectorThread, sendCollectorMessage, session?.runtimeThreadId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.id) {
|
||||
setActiveAgentId(COLLECTOR_AGENT_ID);
|
||||
return;
|
||||
}
|
||||
const shouldPlanFirst = normalizeOutlinePlanner(session.config?.outline_planner) === "agent"
|
||||
&& !String(session.config?.custom_outline ?? "").trim()
|
||||
&& !Object.prototype.hasOwnProperty.call(reportCardOutlineOverrides, session.id);
|
||||
setActiveAgentId(shouldPlanFirst ? PLANNER_AGENT_ID : COLLECTOR_AGENT_ID);
|
||||
}, [reportCardOutlineOverrides, session?.config?.custom_outline, session?.config?.outline_planner, session?.id]);
|
||||
|
||||
// The very first research topic: auto-sent once the runtime thread is ready.
|
||||
// 「提纲预编制」先发给规划员;规划完成后再切到收集员。
|
||||
// The very first research topic: auto-sent into the freshly pre-built
|
||||
// collector thread once the session (and its checkpoint) is ready.
|
||||
useEffect(() => {
|
||||
const query = pendingInitialQuery.current;
|
||||
if (!query || !isChatSession || !collectorThreadId) return;
|
||||
if (collectorThreadLoading || collectorStreaming || collectorMessagesLength > 0) return;
|
||||
const planFirst = outlinePlannerMode === "agent" && !sessionOutline;
|
||||
if (planFirst && activeAgentId !== PLANNER_AGENT_ID) return;
|
||||
if (!planFirst && activeAgentId !== COLLECTOR_AGENT_ID) return;
|
||||
pendingInitialQuery.current = null;
|
||||
if (session && session.status === "draft") {
|
||||
void actions.updateStatus.mutate({ sessionId: session.id, status: "collecting" });
|
||||
}
|
||||
void sendCollectorMessage(query, {
|
||||
silentStatusPatch: true,
|
||||
withDirections: !planFirst,
|
||||
});
|
||||
void sendCollectorMessage(query, { silentStatusPatch: true });
|
||||
}, [
|
||||
actions.updateStatus,
|
||||
activeAgentId,
|
||||
collectorMessagesLength,
|
||||
collectorStreaming,
|
||||
collectorThreadLoading,
|
||||
collectorThreadId,
|
||||
isChatSession,
|
||||
outlinePlannerMode,
|
||||
sendCollectorMessage,
|
||||
session,
|
||||
sessionOutline,
|
||||
]);
|
||||
|
||||
// Persist each completed search as it lands in the step list, instead of
|
||||
@@ -1318,120 +1258,45 @@ function DeepResearchWorkbenchInner() {
|
||||
session?.status,
|
||||
]);
|
||||
|
||||
// Turn finished without a pending clarification → either ask the collector
|
||||
// to plan the outline in the message list, or surface the config card.
|
||||
// Turn finished without a pending clarification → surface the config card.
|
||||
// Requiring a non-human tail closes the window between the optimistic user
|
||||
// message and the run flag flipping, where the turn would look "ended".
|
||||
const collectorMessages = collectorThread.messages;
|
||||
const collectorTailIsHuman = collectorMessagesLength > 0
|
||||
&& collectorMessages?.[collectorMessagesLength - 1]?.type === "human";
|
||||
const showReportConfigCard = isAwaitingReport && !isPlanningOutline;
|
||||
|
||||
const persistLeftoverCollectorMaterials = useCallback(() => {
|
||||
if (!session) return [] as Record<string, unknown>[];
|
||||
const leftover = snapshotCollectorMaterials(collectorThread.messages, {
|
||||
limit: COLLECTOR_PERSIST_LIMIT,
|
||||
}).filter((item) => {
|
||||
const materialKey = collectorMaterialKey(item);
|
||||
if (!materialKey || persistedCollectorMaterialKeys.current.has(materialKey)) return false;
|
||||
persistedCollectorMaterialKeys.current.add(materialKey);
|
||||
return true;
|
||||
});
|
||||
persistedCollectorKey.current = `${session.id}:${persistedCollectorMaterialKeys.current.size}`;
|
||||
return leftover;
|
||||
}, [collectorThread.messages, session]);
|
||||
|
||||
const applyPlannerOutline = useCallback((outline: string) => {
|
||||
if (!session || !outline.trim()) return;
|
||||
const next = outline.trim();
|
||||
setReportCardOutlineOverrides((current) => (
|
||||
current[session.id] === next ? current : { ...current, [session.id]: next }
|
||||
));
|
||||
setAdvancedOptions((current) => (
|
||||
current.customOutline === next ? current : { ...current, customOutline: next }
|
||||
));
|
||||
void actions.updateStatus.mutate({
|
||||
sessionId: session.id,
|
||||
status: session.status === "awaiting_report" ? "awaiting_report" : "collecting",
|
||||
customOutline: next,
|
||||
});
|
||||
}, [actions.updateStatus, session]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlanningOutline || collectorStreaming || collectorTailIsHuman) return;
|
||||
if (!session || collectorMessagesLength === 0) return;
|
||||
const planned = outlineFromFirstAssistant(collectorMessages, extractContentFromMessage);
|
||||
if (!planned) return;
|
||||
applyPlannerOutline(planned);
|
||||
}, [
|
||||
applyPlannerOutline,
|
||||
collectorMessages,
|
||||
collectorMessagesLength,
|
||||
collectorStreaming,
|
||||
collectorTailIsHuman,
|
||||
isPlanningOutline,
|
||||
session,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isChatSession || !session || collectorStreaming || collectorThreadLoading) return;
|
||||
if (outlinePlannerMode !== "agent" || collectionAlreadyStarted) return;
|
||||
if (!sessionOutline || activeAgentId !== COLLECTOR_AGENT_ID) return;
|
||||
if (collectorPendingClarification || collectorTailIsHuman) return;
|
||||
const planned = sessionOutline || outlineFromFirstAssistant(collectorMessages, extractContentFromMessage);
|
||||
if (!planned) return;
|
||||
const key = `${session.id}:collect:${planned}`;
|
||||
if (pendingCollectAfterPlanRef.current === key) return;
|
||||
pendingCollectAfterPlanRef.current = key;
|
||||
void sendCollectorMessage(buildCollectAfterPlanPrompt(session.query, planned), {
|
||||
silentStatusPatch: true,
|
||||
hideFromUI: true,
|
||||
withDirections: false,
|
||||
withSkills: true,
|
||||
});
|
||||
}, [
|
||||
activeAgentId,
|
||||
collectionAlreadyStarted,
|
||||
collectorMessages,
|
||||
collectorPendingClarification,
|
||||
collectorStreaming,
|
||||
collectorTailIsHuman,
|
||||
collectorThreadLoading,
|
||||
isChatSession,
|
||||
outlinePlannerMode,
|
||||
sendCollectorMessage,
|
||||
session,
|
||||
sessionOutline,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isChatSession || collectorStreaming || collectorMessagesLength === 0) return;
|
||||
if (collectorTailIsHuman) return;
|
||||
const status = session?.status;
|
||||
if (status !== "collecting" && status !== "draft") return;
|
||||
if (collectorPendingClarification || isPlanningOutline) return;
|
||||
if (outlinePlannerMode === "agent" && !collectionAlreadyStarted) return;
|
||||
if (collectorPendingClarification) return;
|
||||
const key = `${session?.id ?? ""}:${collectorMessagesLength}`;
|
||||
if (finishedTurnKey.current === key) return;
|
||||
finishedTurnKey.current = key;
|
||||
if (!session) return;
|
||||
const leftover = persistLeftoverCollectorMaterials();
|
||||
void actions.updateStatus.mutate({
|
||||
sessionId: session.id,
|
||||
status: "awaiting_report",
|
||||
...(leftover.length ? { collectorMaterials: leftover } : {}),
|
||||
});
|
||||
if (session) {
|
||||
const leftover = snapshotCollectorMaterials(collectorThread.messages, {
|
||||
limit: COLLECTOR_PERSIST_LIMIT,
|
||||
}).filter((item) => {
|
||||
const materialKey = collectorMaterialKey(item);
|
||||
if (!materialKey || persistedCollectorMaterialKeys.current.has(materialKey)) return false;
|
||||
persistedCollectorMaterialKeys.current.add(materialKey);
|
||||
return true;
|
||||
});
|
||||
persistedCollectorKey.current = `${session.id}:${persistedCollectorMaterialKeys.current.size}`;
|
||||
void actions.updateStatus.mutate({
|
||||
sessionId: session.id,
|
||||
status: "awaiting_report",
|
||||
...(leftover.length ? { collectorMaterials: leftover } : {}),
|
||||
});
|
||||
}
|
||||
}, [
|
||||
actions.updateStatus,
|
||||
collectionAlreadyStarted,
|
||||
collectorMessagesLength,
|
||||
collectorPendingClarification,
|
||||
collectorStreaming,
|
||||
collectorTailIsHuman,
|
||||
collectorThread.messages,
|
||||
isChatSession,
|
||||
isPlanningOutline,
|
||||
outlinePlannerMode,
|
||||
persistLeftoverCollectorMaterials,
|
||||
session,
|
||||
]);
|
||||
|
||||
@@ -1484,9 +1349,7 @@ function DeepResearchWorkbenchInner() {
|
||||
// 普通消息列表的 tool-call 分组,摘要从生成开始就是普通 assistant 消息并随 SSE
|
||||
// 增量刷新。这样两者都使用 Q&A 原有的消息流与步骤条,而不是在列表中再起一套卡片式会话。
|
||||
const collectorDisplayThread = useMemo(() => {
|
||||
const original = (collectorThread.messages ?? []).filter((message) => (
|
||||
message.type !== "human" || !isCollectAfterPlanPrompt(extractContentFromMessage(message))
|
||||
));
|
||||
const original = collectorThread.messages ?? [];
|
||||
const base = [...original];
|
||||
const followUps = messages.data ?? [];
|
||||
const sessionId = session?.id ?? "draft";
|
||||
@@ -1629,7 +1492,7 @@ function DeepResearchWorkbenchInner() {
|
||||
}
|
||||
};
|
||||
|
||||
if (showReportConfigCard) pushActionCard("config");
|
||||
if (isAwaitingReport) pushActionCard("config");
|
||||
if (generatedReportVariants.length > 0 && session) {
|
||||
// Keep the original conversation intact, then append each independent
|
||||
// generation in chronological order beneath it. Persisted children make
|
||||
@@ -1674,13 +1537,14 @@ function DeepResearchWorkbenchInner() {
|
||||
});
|
||||
}
|
||||
|
||||
if (base.length === original.length) return collectorThread;
|
||||
return { ...collectorThread, messages: base } as typeof collectorThread;
|
||||
}, [
|
||||
collectorThread,
|
||||
generatedReportVariants,
|
||||
inlineGeneratedReport,
|
||||
inlineGenerationSession,
|
||||
showReportConfigCard,
|
||||
isAwaitingReport,
|
||||
isRunning,
|
||||
isReportVariant,
|
||||
materialCount,
|
||||
@@ -1902,10 +1766,6 @@ function DeepResearchWorkbenchInner() {
|
||||
const handleNewResearch = useCallback(() => {
|
||||
followUpAbortRef.current?.abort();
|
||||
followUpAbortRef.current = null;
|
||||
setAdvancedOptions(DEFAULT_ADVANCED_OPTIONS);
|
||||
outlinePlanKeyRef.current = "";
|
||||
pendingCollectAfterPlanRef.current = "";
|
||||
setActiveAgentId(COLLECTOR_AGENT_ID);
|
||||
setSelectedId(null);
|
||||
setDownloadTargetSession(null);
|
||||
setInlineGeneratedReport(null);
|
||||
@@ -2492,7 +2352,6 @@ function DeepResearchWorkbenchInner() {
|
||||
} else if (session && isChatSession) {
|
||||
if (jobWriting) composerPlaceholder = "报告撰写中;完成后可继续追问(撰写中点提交 = 停止)";
|
||||
else if (isFollowUp) composerPlaceholder = "基于当前报告继续追问,例如:哪些结论还需要补充验证?";
|
||||
else if (isPlanningOutline) composerPlaceholder = "正在根据课题拟定报告大纲,完成后将开始收集资料";
|
||||
else if (collectorMessagesLength > 0) composerPlaceholder = "补充要求或新的检索方向,例如:再查一下 2025 年的数据";
|
||||
}
|
||||
// streaming 状态下提交按钮变「停止」:点击 → 停止当前流(写作 job / 收集员)。
|
||||
@@ -2834,30 +2693,6 @@ function DeepResearchWorkbenchInner() {
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
{!advancedOptions.customOutline.trim() && (
|
||||
<Select
|
||||
value={outlinePlannerMode}
|
||||
onValueChange={(next) => setAdvancedOptions((current) => (
|
||||
current.outlinePlanner === next ? current : { ...current, outlinePlanner: normalizeOutlinePlanner(next) }
|
||||
))}
|
||||
disabled={Boolean(session) || Boolean(isFollowUp) || jobWriting || collectorStreaming}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-7 min-w-[9rem] max-w-[12rem] border border-input bg-background px-2 text-xs shadow-xs outline-none"
|
||||
aria-label="提纲生成策略"
|
||||
title={OUTLINE_PLANNER_OPTIONS.find((item) => item.value === outlinePlannerMode)?.hint}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OUTLINE_PLANNER_OPTIONS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value} title={item.hint}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{composerClassic && imageGenerationAvailable && !isFollowUp && (
|
||||
|
||||
@@ -32,10 +32,6 @@ import {
|
||||
type ResearchMode,
|
||||
type ResearchTone,
|
||||
} from "@/strategy-components/api/deep-research";
|
||||
import {
|
||||
type OutlinePlannerMode,
|
||||
normalizeOutlinePlanner,
|
||||
} from "./outline-plan";
|
||||
import {
|
||||
DEFAULT_REPORT_STRUCTURE_MODE,
|
||||
normalizeReportStructureMode,
|
||||
@@ -80,7 +76,6 @@ export type AdvancedResearchOptions = {
|
||||
structureMode: ReportStructureMode;
|
||||
retrievalDirections: string[];
|
||||
retrievalSkills: string[];
|
||||
outlinePlanner: OutlinePlannerMode;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -200,16 +195,8 @@ export const PROGRESSIVE_REPORT_OUTLINE = `# 报告标题
|
||||
> 写作要求:除引言外,每个二级小节写 2–5 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。`;
|
||||
|
||||
const CUSTOM_REPORT_STRUCTURE_VALUE = "__custom_report_structure__";
|
||||
const AUTO_REPORT_STRUCTURE_VALUE = "__auto_intent_outline__";
|
||||
const REPORT_STRUCTURE_VALUE_PREFIX = "structure:";
|
||||
|
||||
function resolveSessionOutline(config?: Record<string, unknown>): string {
|
||||
if (!config) return "";
|
||||
if (typeof config.custom_outline === "string") return config.custom_outline;
|
||||
if (Object.prototype.hasOwnProperty.call(config, "custom_outline")) return "";
|
||||
return "";
|
||||
}
|
||||
|
||||
function matchingReportStructureId(
|
||||
outline: string,
|
||||
structures: Array<{ id: string; content: string }>,
|
||||
@@ -246,14 +233,12 @@ export function ReportStructureSelect({
|
||||
const selectedStructureId = matchingReportStructureId(value, structures);
|
||||
const templateValue = selectedStructureId
|
||||
? `${REPORT_STRUCTURE_VALUE_PREFIX}${selectedStructureId}`
|
||||
: value.trim()
|
||||
? CUSTOM_REPORT_STRUCTURE_VALUE
|
||||
: AUTO_REPORT_STRUCTURE_VALUE;
|
||||
: CUSTOM_REPORT_STRUCTURE_VALUE;
|
||||
|
||||
const selectTemplate = (next: string) => {
|
||||
if (next === AUTO_REPORT_STRUCTURE_VALUE || next === CUSTOM_REPORT_STRUCTURE_VALUE) {
|
||||
// Empty outline means the writer will generate a topic-specific structure
|
||||
// after collection. “自定义” stays a blank editor starting point.
|
||||
if (next === CUSTOM_REPORT_STRUCTURE_VALUE) {
|
||||
// “自定义” is an intentional blank starting point. Saved structures are
|
||||
// always available as one-click scaffolds whenever the user wants them.
|
||||
onChange("");
|
||||
onStructureModeChange?.(DEFAULT_REPORT_STRUCTURE_MODE);
|
||||
onRetrievalDirectionsChange?.([]);
|
||||
@@ -287,7 +272,6 @@ export function ReportStructureSelect({
|
||||
{structuresQuery.isError && (
|
||||
<SelectItem value="__report_structures_error__" disabled>报告结构库加载失败</SelectItem>
|
||||
)}
|
||||
<SelectItem value={AUTO_REPORT_STRUCTURE_VALUE}>根据课题自动生成</SelectItem>
|
||||
{structures.map((structure) => (
|
||||
<SelectItem key={structure.id} value={`${REPORT_STRUCTURE_VALUE_PREFIX}${structure.id}`}>
|
||||
{structure.title}
|
||||
@@ -299,9 +283,7 @@ export function ReportStructureSelect({
|
||||
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
|
||||
{selectedStructureId
|
||||
? "已选工作流结构;可在高级配置中继续编辑章节内容。"
|
||||
: value.trim()
|
||||
? "当前为课题规划/自定义结构,可在下方直接改章节。"
|
||||
: "未选用预设结构时,可在输入区选择提纲预编制或成文结构生成。"}
|
||||
: "当前使用自定义结构;可在高级配置中编辑章节内容。"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -415,11 +397,10 @@ export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
|
||||
maxRevisionLoops: "2",
|
||||
maxGeneratedImages: "2",
|
||||
thinkingEnabled: false,
|
||||
customOutline: "",
|
||||
customOutline: DEFAULT_REPORT_OUTLINE,
|
||||
structureMode: DEFAULT_REPORT_STRUCTURE_MODE,
|
||||
retrievalDirections: [],
|
||||
retrievalSkills: [],
|
||||
outlinePlanner: "agent",
|
||||
};
|
||||
|
||||
/** Seed the card's editable options from the frozen session config snapshot. */
|
||||
@@ -441,11 +422,12 @@ export function advancedOptionsFromConfig(config?: Record<string, unknown>): Adv
|
||||
maxRevisionLoops: numberish("max_revision_loops", DEFAULT_ADVANCED_OPTIONS.maxRevisionLoops),
|
||||
maxGeneratedImages: numberish("max_generated_images", DEFAULT_ADVANCED_OPTIONS.maxGeneratedImages),
|
||||
thinkingEnabled: config?.thinking_enabled === true,
|
||||
customOutline: resolveSessionOutline(config),
|
||||
customOutline: typeof config?.custom_outline === "string" && config.custom_outline.trim()
|
||||
? config.custom_outline
|
||||
: DEFAULT_REPORT_OUTLINE,
|
||||
structureMode: normalizeReportStructureMode(config?.structure_mode),
|
||||
retrievalDirections: normalizeRetrievalDirections(config?.retrieval_directions),
|
||||
retrievalSkills: normalizeRetrievalSkills(config?.retrieval_skills),
|
||||
outlinePlanner: normalizeOutlinePlanner(config?.outline_planner),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -482,11 +464,10 @@ export function buildReportCardConfig(
|
||||
config.max_revision_loops = boundedInteger(options.maxRevisionLoops, 2, 0, 5);
|
||||
}
|
||||
if (generateImages) config.max_generated_images = boundedInteger(options.maxGeneratedImages, 2, 1, 4);
|
||||
config.custom_outline = options.customOutline.trim().slice(0, 4_000);
|
||||
if (options.customOutline.trim()) config.custom_outline = options.customOutline.trim().slice(0, 4_000);
|
||||
config.structure_mode = normalizeReportStructureMode(options.structureMode);
|
||||
config.retrieval_directions = normalizeRetrievalDirections(options.retrievalDirections);
|
||||
config.retrieval_skills = normalizeRetrievalSkills(options.retrievalSkills);
|
||||
config.outline_planner = options.outlinePlanner;
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -509,7 +490,6 @@ export function ReportConfigCard({
|
||||
imageGenerationAvailable,
|
||||
imageLimit,
|
||||
submitting,
|
||||
onRetryPlanOutline,
|
||||
onConfirm,
|
||||
onContinueCollecting,
|
||||
}: {
|
||||
@@ -527,7 +507,6 @@ export function ReportConfigCard({
|
||||
imageGenerationAvailable: boolean;
|
||||
imageLimit: number;
|
||||
submitting: boolean;
|
||||
onRetryPlanOutline?: () => void;
|
||||
onConfirm: (config: Record<string, unknown>, extras?: { writeWithoutMaterials?: boolean }) => void;
|
||||
onContinueCollecting: () => void;
|
||||
}) {
|
||||
@@ -592,9 +571,6 @@ export function ReportConfigCard({
|
||||
<p className="text-sm font-semibold">资料收集完成,请确认报告写法</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
下方选项沿用原研究配置并可调整;确认后将基于以上对话收集到的资料撰写报告。
|
||||
{options.customOutline.trim()
|
||||
? "报告结构已由收集员在对话中规划并填入下方,可继续增删改章节后再撰写。"
|
||||
: "可在此填写或改章节;确认后按此结构写作。"}
|
||||
{modelLabel ? <>写作模型:<span className="font-medium text-foreground">{modelLabel}</span>。</> : null}
|
||||
</p>
|
||||
<div className={`mt-2 inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] ${writeWithoutMaterials ? "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-200" : "border-primary/15 bg-primary/[0.06] text-primary"}`}>
|
||||
@@ -648,35 +624,6 @@ export function ReportConfigCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium">报告结构(可编辑)</span>
|
||||
{onRetryPlanOutline ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] text-primary hover:underline disabled:opacity-50"
|
||||
disabled={submitting}
|
||||
onClick={onRetryPlanOutline}
|
||||
>
|
||||
重新规划
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
id="report-card-outline-main"
|
||||
value={options.customOutline}
|
||||
onChange={(event) => update("customOutline", event.target.value)}
|
||||
maxLength={4000}
|
||||
className="min-h-32 resize-y font-mono text-xs"
|
||||
placeholder={"例如:\n# 引言\n# 一、问题与事实\n## 1.1 范围与依据\n# 结论"}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
|
||||
支持 Markdown 标题。规划结果可增删改章节,确认撰写后按此结构写作。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="mt-3 flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -735,9 +682,18 @@ export function ReportConfigCard({
|
||||
{mode === "multi_agent" && (
|
||||
<CardNumber label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(v) => update("maxRevisionLoops", v)} />
|
||||
)}
|
||||
<p className="text-[11px] leading-4 text-muted-foreground sm:col-span-2">
|
||||
报告结构正文已在上方表单,可继续选用工作流模板覆盖,或直接改章节标题。
|
||||
</p>
|
||||
<div className="sm:col-span-2">
|
||||
<ReportOutlineEditor
|
||||
id="report-card-outline"
|
||||
value={options.customOutline}
|
||||
onChange={(value) => update("customOutline", value)}
|
||||
structureMode={options.structureMode}
|
||||
onStructureModeChange={(mode) => update("structureMode", mode)}
|
||||
onRetrievalDirectionsChange={(directions) => update("retrievalDirections", directions)}
|
||||
onRetrievalSkillsChange={(skills) => update("retrievalSkills", skills)}
|
||||
showStructureSelect={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end sm:col-span-2">
|
||||
<WorkflowConfigButton
|
||||
appliedContent={options.customOutline}
|
||||
|
||||
@@ -69,7 +69,6 @@ import {
|
||||
MAX_RETRIEVAL_DIRECTIONS,
|
||||
MAX_RETRIEVAL_DIRECTION_CHARS,
|
||||
MAX_RETRIEVAL_SKILLS,
|
||||
DEFAULT_REPORT_STRUCTURE_MODE,
|
||||
REPORT_STRUCTURE_MODE_OPTIONS,
|
||||
normalizeReportStructureMode,
|
||||
normalizeRetrievalDirections,
|
||||
@@ -176,12 +175,10 @@ export function WorkflowConfigDialog({
|
||||
appliedContent,
|
||||
/** 点击卡片选择结构时回填内容与结构约束。 */
|
||||
onApply,
|
||||
inlineCreate = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
appliedContent?: string;
|
||||
inlineCreate?: boolean;
|
||||
onApply?: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
|
||||
}) {
|
||||
const [statusFilter, setStatusFilter] = useState<ReportStructureStatusFilter>("all");
|
||||
@@ -238,21 +235,11 @@ export function WorkflowConfigDialog({
|
||||
[detail, detailOutlineTitle],
|
||||
);
|
||||
|
||||
const handleClear = () => {
|
||||
onApply?.("", DEFAULT_REPORT_STRUCTURE_MODE, [], []);
|
||||
toast.success("已取消选用报告结构,撰写时将根据课题自动生成大纲");
|
||||
};
|
||||
|
||||
const handleSelect = (structure: ReportStructure) => {
|
||||
if (!structure.enabled) {
|
||||
toast.error(`「${structure.title}」已停用,请先启用后再选用`);
|
||||
return;
|
||||
}
|
||||
// 再次点击当前选用的卡片即取消勾选。
|
||||
if (structure.id === appliedStructureId) {
|
||||
handleClear();
|
||||
return;
|
||||
}
|
||||
// 点击卡片只做「选择」:内容回填后留在列表视图(卡片高亮为当前使用),
|
||||
// 详情视图通过卡片上的「查看大纲结构」眼睛图标进入。
|
||||
onApply?.(structure.content, structure.structureMode, structure.retrievalDirections, structure.retrievalSkills);
|
||||
@@ -341,7 +328,7 @@ export function WorkflowConfigDialog({
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400 sm:text-[13px]">
|
||||
点击卡片选择报告结构(自动填充到报告结构配置);再次点击已选用的卡片或点「取消选用」可取消勾选,撰写时将按课题自动生成大纲。
|
||||
点击卡片选择报告结构(自动填充到报告结构配置);点击卡片上的眼睛图标查看大纲详情;可启停、编辑、删除。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -413,11 +400,6 @@ export function WorkflowConfigDialog({
|
||||
<Button
|
||||
className="h-10 gap-1.5 rounded-xl bg-[#F5222D] px-4 text-xs font-semibold text-white shadow-xs transition-all duration-150 hover:bg-[#BC0D1E] hover:shadow active:bg-[#A8071A] dark:bg-[#1677ff] dark:hover:bg-[#4096ff] dark:active:bg-[#0958d9] sm:text-sm"
|
||||
onClick={() => {
|
||||
if (inlineCreate) {
|
||||
setEditorTarget(null);
|
||||
setEditorOpen(true);
|
||||
return;
|
||||
}
|
||||
onOpenChange(false);
|
||||
const base = location.pathname.replace(/\/+$/, "");
|
||||
navigate(`${base}/structures/new`);
|
||||
@@ -561,28 +543,17 @@ export function WorkflowConfigDialog({
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-400">未选择,撰写时按课题自动生成</span>
|
||||
<span className="text-slate-400">未选择</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{appliedStructureId ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-9 rounded-xl px-4 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-[#1c2d4a]"
|
||||
onClick={handleClear}
|
||||
>
|
||||
取消选用
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-9 rounded-xl px-4 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-[#1c2d4a]"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
完成
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
@@ -1221,7 +1192,6 @@ export function WorkflowConfigButton({
|
||||
variant = "outline",
|
||||
size = "sm",
|
||||
disabled = false,
|
||||
inlineCreate = false,
|
||||
}: {
|
||||
appliedContent?: string;
|
||||
onApply: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
|
||||
@@ -1229,7 +1199,6 @@ export function WorkflowConfigButton({
|
||||
variant?: React.ComponentProps<typeof Button>["variant"];
|
||||
size?: React.ComponentProps<typeof Button>["size"];
|
||||
disabled?: boolean;
|
||||
inlineCreate?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
@@ -1252,39 +1221,22 @@ export function WorkflowConfigButton({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("inline-flex items-center", isSelected && "rounded-md bg-accent text-accent-foreground dark:bg-accent/50")}>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
|
||||
className={cn(className, isSelected && "bg-transparent hover:bg-transparent")}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(true)}
|
||||
size={size}
|
||||
title={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
|
||||
variant={variant}
|
||||
>
|
||||
<Workflow className="mr-1.5 h-3.5 w-3.5" />
|
||||
<span className="max-w-40 truncate">{buttonLabel}</span>
|
||||
</Button>
|
||||
{isSelected ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`取消选用工作流:${selectedWorkflowTitle}`}
|
||||
className="mr-1 inline-flex size-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
title="取消选用报告结构"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onApply("", DEFAULT_REPORT_STRUCTURE_MODE, [], []);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
aria-label={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
|
||||
className={cn(
|
||||
className,
|
||||
isSelected && "bg-accent text-accent-foreground dark:bg-accent/50",
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(true)}
|
||||
size={size}
|
||||
title={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
|
||||
variant={variant}
|
||||
>
|
||||
<Workflow className="mr-1.5 h-3.5 w-3.5" />
|
||||
<span className="max-w-40 truncate">{buttonLabel}</span>
|
||||
</Button>
|
||||
<WorkflowConfigDialog
|
||||
inlineCreate={inlineCreate}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
appliedContent={appliedContent}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
export const PLANNER_AGENT_ID = "deep-research-planner";
|
||||
|
||||
export type OutlinePlannerMode = "agent" | "pipeline";
|
||||
|
||||
export const COLLECT_AFTER_PLAN_MARKER = "报告结构已确认,请开始收集资料";
|
||||
/** First collection round after an agent-planned outline: one query per major chapter. */
|
||||
export const OUTLINE_COLLECTION_MAX_QUERIES = 6;
|
||||
|
||||
const HEADING_RE = /^#{1,6}\s+\S/;
|
||||
const FENCE_RE = /```(?:markdown|md)?\s*([\s\S]*?)```/i;
|
||||
const NUM_SUB_RE = /^\d+\.\d+(?:\.\d+)*[、..]?\s*\S/;
|
||||
const PAREN_SUB_RE = /^[((][一二三四五六七八九十]+[))]\s*\S/;
|
||||
const CN_MAJOR_RE = /^(?:第)?[一二三四五六七八九十]+[、..]\s*\S/;
|
||||
const NUM_MAJOR_RE = /^\d+[、..]\s*\S/;
|
||||
|
||||
export const OUTLINE_PLANNER_OPTIONS: Array<{
|
||||
value: OutlinePlannerMode;
|
||||
label: string;
|
||||
hint: string;
|
||||
}> = [
|
||||
{
|
||||
value: "agent",
|
||||
label: "提纲预编制",
|
||||
hint: "开题后先编制研究提纲,再按提纲收集与核验材料",
|
||||
},
|
||||
{
|
||||
value: "pipeline",
|
||||
label: "成文结构生成",
|
||||
hint: "材料齐备后,于成文阶段生成报告结构",
|
||||
},
|
||||
];
|
||||
|
||||
export function normalizeOutlinePlanner(value: unknown): OutlinePlannerMode {
|
||||
const raw = String(value ?? "").trim().toLowerCase();
|
||||
if (
|
||||
raw === "agent"
|
||||
|| raw === "planner"
|
||||
|| raw === "智能体规划"
|
||||
|| raw === "先行规划大纲"
|
||||
|| raw === "提纲预编制"
|
||||
|| raw === "研究提纲预编制"
|
||||
) return "agent";
|
||||
return "pipeline";
|
||||
}
|
||||
|
||||
export function buildCollectAfterPlanPrompt(query: string, outline: string): string {
|
||||
const majors = majorOutlineHeadings(outline);
|
||||
const coverage = majors.length > 0
|
||||
? majors.map((title, index) => `${index + 1}. ${title}`).join("\n")
|
||||
: `按课题拆分,不超过 ${OUTLINE_COLLECTION_MAX_QUERIES} 个检索词。`;
|
||||
return `${COLLECT_AFTER_PLAN_MARKER}。
|
||||
|
||||
课题:${query}
|
||||
|
||||
检索预算:本轮最多 ${OUTLINE_COLLECTION_MAX_QUERIES} 个独立检索词(含空结果换词)。按下列大章各检索一次即可,不要按小章逐条检索,也不要再加交叉验证轮次。材料不足写进收集小结,用户可追加材料。
|
||||
|
||||
大章覆盖:
|
||||
${coverage}
|
||||
|
||||
完整提纲(只用于理解各章要写什么,禁止为每个小章单独检索):
|
||||
${outline}
|
||||
|
||||
请按收集员流程检索并输出收集小结;不要再规划大纲。`;
|
||||
}
|
||||
|
||||
export function majorOutlineHeadings(outline: string): string[] {
|
||||
const headings = outline
|
||||
.split(/\r?\n/)
|
||||
.map((line) => normalizeOutlineHeadingLine(line))
|
||||
.filter((line): line is string => Boolean(line));
|
||||
const h2 = headings
|
||||
.filter((line) => line.startsWith("## ") && !line.startsWith("###"))
|
||||
.map((line) => line.replace(/^##\s+/, "").trim())
|
||||
.filter(Boolean);
|
||||
if (h2.length > 0) return h2.slice(0, OUTLINE_COLLECTION_MAX_QUERIES);
|
||||
const h1 = headings
|
||||
.filter((line) => line.startsWith("# ") && !line.startsWith("##"))
|
||||
.map((line) => line.replace(/^#\s+/, "").trim())
|
||||
.filter(Boolean);
|
||||
return h1.slice(1, 1 + OUTLINE_COLLECTION_MAX_QUERIES);
|
||||
}
|
||||
|
||||
export function isCollectAfterPlanPrompt(text: string): boolean {
|
||||
return text.includes(COLLECT_AFTER_PLAN_MARKER);
|
||||
}
|
||||
|
||||
export function extractOutlineFromMessage(text: string): string {
|
||||
let raw = text.trim();
|
||||
if (!raw) return "";
|
||||
const fenced = raw.match(FENCE_RE);
|
||||
if (fenced?.[1]) raw = fenced[1].trim();
|
||||
const headings = raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => normalizeOutlineHeadingLine(line))
|
||||
.filter((line): line is string => Boolean(line));
|
||||
if (headings.length >= 2) return headings.join("\n");
|
||||
if (raw.startsWith("#")) return raw;
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeOutlineHeadingLine(line: string): string | null {
|
||||
const text = line.trim();
|
||||
if (!text) return null;
|
||||
if (HEADING_RE.test(text)) return text;
|
||||
if (NUM_SUB_RE.test(text) || PAREN_SUB_RE.test(text)) return `### ${text}`;
|
||||
if (CN_MAJOR_RE.test(text) || NUM_MAJOR_RE.test(text)) return `## ${text}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function outlineFromFirstAssistant<T extends { type?: string }>(
|
||||
messages: T[] | undefined,
|
||||
extractContent: (message: T) => string,
|
||||
): string {
|
||||
if (!messages?.length) return "";
|
||||
for (const message of messages) {
|
||||
if (message.type !== "ai") continue;
|
||||
const outline = extractOutlineFromMessage(extractContent(message));
|
||||
if (outline) return outline;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function hasCollectAfterPlanTurn<T extends { type?: string }>(
|
||||
messages: T[] | undefined,
|
||||
extractContent: (message: T) => string,
|
||||
): boolean {
|
||||
return Boolean(messages?.some((message) => (
|
||||
message.type === "human" && isCollectAfterPlanPrompt(extractContent(message))
|
||||
)));
|
||||
}
|
||||
|
||||
export function hasCollectorToolTurn<T extends { type?: string; name?: string }>(
|
||||
messages: T[] | undefined,
|
||||
): boolean {
|
||||
return Boolean(messages?.some((message) => message.type === "tool"));
|
||||
}
|
||||
@@ -4,10 +4,11 @@ import test from "node:test";
|
||||
// @ts-ignore Node's test runner resolves the local .ts module directly.
|
||||
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons.ts";
|
||||
|
||||
test("does not expose a per-chat memory toggle", () => {
|
||||
test("hides the memory toggle while retaining the other iframe chat toolbar controls", () => {
|
||||
const buttons = buildIframeModeButtons({
|
||||
agentSelected: false,
|
||||
writingModeEnabled: false,
|
||||
memoryInjectionEnabled: true,
|
||||
referenceModeEntryEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
ragModeLocked: false,
|
||||
@@ -27,10 +28,27 @@ test("does not expose a per-chat memory toggle", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("can restore the iframe memory toggle when the feature is re-enabled", () => {
|
||||
const buttons = buildIframeModeButtons({
|
||||
agentSelected: false,
|
||||
writingModeEnabled: false,
|
||||
memoryInjectionEnabled: true,
|
||||
hideMemoryButton: false,
|
||||
referenceModeEntryEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
ragModeLocked: false,
|
||||
contextCompressionEnabled: false,
|
||||
proModeEnabled: false,
|
||||
});
|
||||
|
||||
assert.equal(buttons.some((button) => button.key === "memory"), true);
|
||||
});
|
||||
|
||||
test("hides the references toggle when reference mode is disabled globally", () => {
|
||||
const buttons = buildIframeModeButtons({
|
||||
agentSelected: false,
|
||||
writingModeEnabled: false,
|
||||
memoryInjectionEnabled: true,
|
||||
referenceModeEntryEnabled: false,
|
||||
ragModeEnabled: true,
|
||||
ragModeLocked: false,
|
||||
@@ -45,6 +63,7 @@ test("returns only concrete toolbar button configs", () => {
|
||||
const buttons = buildIframeModeButtons({
|
||||
agentSelected: true,
|
||||
writingModeEnabled: false,
|
||||
memoryInjectionEnabled: true,
|
||||
referenceModeEntryEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
ragModeLocked: false,
|
||||
|
||||
@@ -2,6 +2,7 @@ export type IframeModeButtonState = {
|
||||
key:
|
||||
| "agent"
|
||||
| "writing"
|
||||
| "memory"
|
||||
| "references"
|
||||
| "contextCompression"
|
||||
| "pro";
|
||||
@@ -13,6 +14,9 @@ export type IframeModeButtonState = {
|
||||
type BuildIframeModeButtonsInput = {
|
||||
agentSelected: boolean;
|
||||
writingModeEnabled: boolean;
|
||||
memoryInjectionEnabled: boolean;
|
||||
/** 产品当前全局隐藏记忆入口;保留 false 供后续无损恢复。 */
|
||||
hideMemoryButton?: boolean;
|
||||
referenceModeEntryEnabled: boolean;
|
||||
ragModeEnabled: boolean;
|
||||
ragModeLocked: boolean;
|
||||
@@ -23,6 +27,8 @@ type BuildIframeModeButtonsInput = {
|
||||
export function buildIframeModeButtons({
|
||||
agentSelected,
|
||||
writingModeEnabled,
|
||||
memoryInjectionEnabled,
|
||||
hideMemoryButton = true,
|
||||
referenceModeEntryEnabled,
|
||||
ragModeEnabled,
|
||||
ragModeLocked,
|
||||
@@ -35,6 +41,11 @@ export function buildIframeModeButtons({
|
||||
label: "写作",
|
||||
active: writingModeEnabled,
|
||||
},
|
||||
{
|
||||
key: "memory",
|
||||
label: "记忆",
|
||||
active: memoryInjectionEnabled,
|
||||
},
|
||||
{
|
||||
key: "references",
|
||||
label: "参考文献",
|
||||
@@ -58,6 +69,8 @@ export function buildIframeModeButtons({
|
||||
},
|
||||
];
|
||||
return buttons.filter(
|
||||
(button) => referenceModeEntryEnabled || button.key !== "references",
|
||||
(button) =>
|
||||
(referenceModeEntryEnabled || button.key !== "references") &&
|
||||
(!hideMemoryButton || button.key !== "memory"),
|
||||
);
|
||||
}
|
||||
@@ -1,528 +0,0 @@
|
||||
/**
|
||||
* 站点预览工作室(Web App 产物模式的独立预览页)。
|
||||
*
|
||||
* 设计原则:
|
||||
* - 独立路由 + 独立样式(site-studio.css,不依赖主应用工作台风格);
|
||||
* - 能力不可用 / 站点未构建 / 构建失败均有设计过的降级态;
|
||||
* - iframe 指向后端签名静态托管 URL,sandbox 不含 allow-same-origin(安全契约)。
|
||||
*/
|
||||
|
||||
import { Component, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Navigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
Hammer,
|
||||
Lock,
|
||||
Monitor,
|
||||
PackageOpen,
|
||||
RefreshCw,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
} from "lucide-react";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import {
|
||||
useBuildSiteMutation,
|
||||
useMintPreviewUrlMutation,
|
||||
useSiteBuildStatus,
|
||||
useSites,
|
||||
useWebAppCapability,
|
||||
type SiteBuildStatusResponse,
|
||||
type SiteInfo,
|
||||
} from "@/core/sites/api";
|
||||
|
||||
import "./site-studio.css";
|
||||
|
||||
type DeviceMode = "desktop" | "tablet" | "mobile";
|
||||
|
||||
const DEVICE_WIDTH: Record<DeviceMode, string> = {
|
||||
desktop: "100%",
|
||||
tablet: "768px",
|
||||
mobile: "390px",
|
||||
};
|
||||
|
||||
class SiteStudioErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
|
||||
state = { error: null as Error | null };
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div className="site-studio-root">
|
||||
<div className="site-studio-state">
|
||||
<div className="site-studio-state-card">
|
||||
<div className="site-studio-state-art">
|
||||
<Globe size={34} />
|
||||
</div>
|
||||
<h1 className="site-studio-title">预览页出现异常</h1>
|
||||
<p className="site-studio-state-desc site-studio-error-text">
|
||||
{this.state.error.message}
|
||||
</p>
|
||||
<a className="site-studio-btn site-studio-btn--primary" href="#/page/workspace/chats/new">
|
||||
返回工作台
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SiteStudioPage() {
|
||||
return (
|
||||
<SiteStudioErrorBoundary>
|
||||
<SiteStudioContent />
|
||||
</SiteStudioErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteStudioContent() {
|
||||
const { threadId, siteName } = useParams<{ threadId: string; siteName: string }>();
|
||||
const hasAuth = !!getStoredAuth();
|
||||
if (!hasAuth) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
if (!threadId || !siteName) {
|
||||
return <Navigate to="/page/workspace" replace />;
|
||||
}
|
||||
return <SiteStudioView threadId={threadId} siteName={siteName} />;
|
||||
}
|
||||
|
||||
function SiteStudioView({ threadId, siteName }: { threadId: string; siteName: string }) {
|
||||
const capabilityQuery = useWebAppCapability();
|
||||
const capability = capabilityQuery.data;
|
||||
|
||||
const sitesQuery = useSites(threadId, { refetchInterval: 30_000 });
|
||||
const site: SiteInfo | undefined = useMemo(
|
||||
() => sitesQuery.data?.sites?.find((s) => s.name === siteName),
|
||||
[sitesQuery.data, siteName],
|
||||
);
|
||||
|
||||
const [device, setDevice] = useState<DeviceMode>("desktop");
|
||||
const [activePage, setActivePage] = useState("");
|
||||
const [cacheBust, setCacheBust] = useState(() => Date.now());
|
||||
const [previewBase, setPreviewBase] = useState("");
|
||||
const [consoleOpen, setConsoleOpen] = useState(false);
|
||||
const mintingRef = useRef(false);
|
||||
|
||||
const buildStatusQuery = useSiteBuildStatus(threadId, siteName, {
|
||||
refetchInterval: (query) => (query.state.data?.status === "running" ? 1500 : false),
|
||||
});
|
||||
const buildStatus = buildStatusQuery.data;
|
||||
const building = buildStatus?.status === "running";
|
||||
|
||||
const buildMutation = useBuildSiteMutation(threadId, siteName);
|
||||
const mintMutation = useMintPreviewUrlMutation(threadId, siteName);
|
||||
|
||||
const capabilityAvailable = capability?.available === true;
|
||||
const siteReady = capabilityAvailable && !!site && site.dist.exists;
|
||||
|
||||
// 签发/续期预览地址
|
||||
const mintPreview = useCallback(async () => {
|
||||
if (mintingRef.current) return;
|
||||
mintingRef.current = true;
|
||||
try {
|
||||
const res = await mintMutation.mutateAsync();
|
||||
if (res.available && res.url_path) {
|
||||
setPreviewBase(`${getBackendBaseURL()}${res.url_path}`);
|
||||
}
|
||||
} catch {
|
||||
// 签发失败由状态视图兜底
|
||||
} finally {
|
||||
mintingRef.current = false;
|
||||
}
|
||||
}, [mintMutation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (siteReady && !previewBase) {
|
||||
void mintPreview();
|
||||
}
|
||||
}, [siteReady, previewBase, mintPreview]);
|
||||
|
||||
// 构建结束后:刷新站点信息 + 重建预览
|
||||
const prevBuildingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const wasBuilding = prevBuildingRef.current;
|
||||
prevBuildingRef.current = building;
|
||||
if (wasBuilding && !building) {
|
||||
void sitesQuery.refetch();
|
||||
void buildStatusQuery.refetch();
|
||||
setCacheBust(Date.now());
|
||||
setPreviewBase("");
|
||||
}
|
||||
}, [building, sitesQuery, buildStatusQuery]);
|
||||
|
||||
// 默认选中第一个导航页
|
||||
useEffect(() => {
|
||||
if (!activePage && site?.nav?.length) {
|
||||
setActivePage(site.nav[0].page);
|
||||
}
|
||||
}, [activePage, site]);
|
||||
|
||||
const refreshPreview = useCallback(() => {
|
||||
setPreviewBase("");
|
||||
setCacheBust(Date.now());
|
||||
}, []);
|
||||
|
||||
const frameSrc = previewBase
|
||||
? `${previewBase}/index.html?v=${cacheBust}${activePage ? `#/${activePage}` : ""}`
|
||||
: "";
|
||||
|
||||
const openExternal = useCallback(() => {
|
||||
if (frameSrc) {
|
||||
window.open(frameSrc, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}, [frameSrc]);
|
||||
|
||||
// ── 能力探测中 ──
|
||||
if (capabilityQuery.isLoading) {
|
||||
return (
|
||||
<div className="site-studio-root">
|
||||
<StateCard spinner title="正在连接站点服务…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 功能未启用 / 部署不完整 ──
|
||||
if (!capabilityAvailable) {
|
||||
const reasons =
|
||||
capability?.reasons ??
|
||||
Object.entries(capability?.checks ?? {})
|
||||
.filter(([, v]) => !v.ok)
|
||||
.map(([k, v]) => `${k}: ${v.detail}`);
|
||||
return (
|
||||
<div className="site-studio-root">
|
||||
<StateCard
|
||||
icon={<Lock size={34} />}
|
||||
title="站点产物模式未启用"
|
||||
desc="当前环境未开启 Web App 站点模式(配置开关或模板依赖未就绪)。不影响其他功能;智能体会按单页 HTML 方式产出网页。"
|
||||
reasons={capabilityQuery.error ? ["接口不可达:" + capabilityQuery.error.message] : reasons}
|
||||
action={
|
||||
<a className="site-studio-btn" href={`#/page/workspace/chats/${threadId}`}>
|
||||
<ArrowLeft size={15} /> 返回对话
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sitesQuery.isLoading) {
|
||||
return (
|
||||
<div className="site-studio-root">
|
||||
<StateCard spinner title="正在加载站点信息…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 站点不存在 ──
|
||||
if (!site) {
|
||||
return (
|
||||
<div className="site-studio-root">
|
||||
<StateCard
|
||||
icon={<PackageOpen size={34} />}
|
||||
title="未找到该站点"
|
||||
desc={`对话产物中没有名为「${siteName}」的站点(outputs/${siteName}/site.json 不存在)。`}
|
||||
action={
|
||||
<button type="button" className="site-studio-btn" onClick={() => void sitesQuery.refetch()}>
|
||||
<RefreshCw size={15} /> 重新扫描
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusPill =
|
||||
buildStatus?.status === "running"
|
||||
? { className: "running", label: "构建中" }
|
||||
: derivePill(site);
|
||||
|
||||
return (
|
||||
<div className="site-studio-root">
|
||||
<header className="site-studio-topbar">
|
||||
<a className="site-studio-back" href={`#/page/workspace/chats/${threadId}`}>
|
||||
<ArrowLeft size={15} /> 返回对话
|
||||
</a>
|
||||
<div className="site-studio-title-wrap">
|
||||
<h1 className="site-studio-title">{site.title}</h1>
|
||||
<span className="site-studio-subtitle">{site.description || siteName}</span>
|
||||
</div>
|
||||
<span className={`site-studio-pill site-studio-pill--${statusPill.className}`}>
|
||||
<span className="site-studio-dot" />
|
||||
{statusPill.label}
|
||||
</span>
|
||||
<div className="site-studio-seg" role="group" aria-label="设备宽度">
|
||||
<button
|
||||
type="button"
|
||||
title="桌面"
|
||||
aria-label="桌面"
|
||||
className={`site-studio-seg-btn ${device === "desktop" ? "site-studio-seg-btn--active" : ""}`}
|
||||
onClick={() => setDevice("desktop")}
|
||||
>
|
||||
<Monitor size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="平板"
|
||||
aria-label="平板"
|
||||
className={`site-studio-seg-btn ${device === "tablet" ? "site-studio-seg-btn--active" : ""}`}
|
||||
onClick={() => setDevice("tablet")}
|
||||
>
|
||||
<Tablet size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="手机"
|
||||
aria-label="手机"
|
||||
className={`site-studio-seg-btn ${device === "mobile" ? "site-studio-seg-btn--active" : ""}`}
|
||||
onClick={() => setDevice("mobile")}
|
||||
>
|
||||
<Smartphone size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="site-studio-btn"
|
||||
onClick={refreshPreview}
|
||||
disabled={!siteReady}
|
||||
>
|
||||
<RefreshCw size={15} /> 刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="site-studio-btn"
|
||||
onClick={() => void openExternal()}
|
||||
disabled={!siteReady}
|
||||
>
|
||||
<ExternalLink size={15} /> 新窗口
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="site-studio-btn site-studio-btn--primary"
|
||||
onClick={() => buildMutation.mutate()}
|
||||
disabled={buildMutation.isPending || building}
|
||||
>
|
||||
<Hammer size={15} /> {building ? "构建中…" : "重新构建"}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="site-studio-body">
|
||||
<aside className="site-studio-sidenav">
|
||||
<div>
|
||||
<div className="site-studio-sidenav-label">站点页面</div>
|
||||
<nav className="site-studio-navlist">
|
||||
{(site.nav ?? []).map((item, index) => (
|
||||
<button
|
||||
key={`${item.page}-${index}`}
|
||||
type="button"
|
||||
className={`site-studio-navitem ${activePage === item.page ? "site-studio-navitem--active" : ""}`}
|
||||
onClick={() => setActivePage(item.page)}
|
||||
>
|
||||
<span className="site-studio-navindex">{String(index + 1).padStart(2, "0")}</span>
|
||||
{item.label || item.page}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div>
|
||||
<div className="site-studio-sidenav-label">产物</div>
|
||||
<div className="site-studio-sidenav-files">
|
||||
<div>
|
||||
<code>dist/</code>{" "}
|
||||
{site.dist.exists ? `${site.dist.file_count ?? 0} 个文件` : "未构建"}
|
||||
</div>
|
||||
{site.dist.exists && site.dist.total_bytes != null && (
|
||||
<div>{formatBytes(site.dist.total_bytes)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="site-studio-stage">
|
||||
{siteReady ? (
|
||||
<div
|
||||
className="site-studio-frame-shell"
|
||||
style={{ "--ss-frame-w": DEVICE_WIDTH[device] } as React.CSSProperties}
|
||||
>
|
||||
<div className="site-studio-frame-bar">
|
||||
<span className="site-studio-traffic">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<div className="site-studio-urlbox">
|
||||
<Globe size={12} />
|
||||
<span title={frameSrc}>{siteName}/dist/index.html{activePage ? `#/${activePage}` : ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="site-studio-frame-holder">
|
||||
{frameSrc ? (
|
||||
<iframe
|
||||
key={frameSrc}
|
||||
className="site-studio-iframe"
|
||||
src={frameSrc}
|
||||
title={`${site.title} 预览`}
|
||||
sandbox="allow-scripts allow-forms allow-popups allow-modals"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="site-studio-state">
|
||||
<StateCard spinner title="正在签发预览地址…" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<StateCard
|
||||
icon={<PackageOpen size={34} />}
|
||||
title={site.last_build?.success === false ? "构建失败" : "站点尚未构建"}
|
||||
desc={
|
||||
site.last_build?.success === false
|
||||
? "最近一次 vite build 失败,可在下方控制台查看报错,修复源码后重新构建。"
|
||||
: "源码已就绪但还没有 dist/ 构建产物。点击「重新构建」生成可预览的静态文件。"
|
||||
}
|
||||
reasons={site.last_build?.error ? [site.last_build.error] : undefined}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className="site-studio-btn site-studio-btn--primary"
|
||||
onClick={() => buildMutation.mutate()}
|
||||
disabled={buildMutation.isPending || building}
|
||||
>
|
||||
<Hammer size={15} /> {building ? "构建中…" : "立即构建"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer className="site-studio-console">
|
||||
<div
|
||||
className="site-studio-console-head"
|
||||
onClick={() => setConsoleOpen((v) => !v)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") setConsoleOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
{consoleOpen ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
<span className="site-studio-console-title">构建控制台</span>
|
||||
{buildStatus?.status && buildStatus.status !== "idle" && (
|
||||
<span className="site-studio-console-meta">
|
||||
{buildStatus.status}
|
||||
{buildStatus.result?.duration_ms != null &&
|
||||
` · ${(buildStatus.result.duration_ms / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
)}
|
||||
{buildMutation.isError && (
|
||||
<span className="site-studio-console-meta site-studio-error-text">
|
||||
{buildMutation.error instanceof Error ? buildMutation.error.message : "触发失败"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{consoleOpen && <BuildConsole site={site} buildStatus={buildStatus ?? null} />}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function derivePill(site: SiteInfo): { className: string; label: string } {
|
||||
if (!site.dist.exists) {
|
||||
return { className: "none", label: "未构建" };
|
||||
}
|
||||
if (site.last_build?.success === false) {
|
||||
return { className: "failed", label: "构建失败" };
|
||||
}
|
||||
return { className: "success", label: "可预览" };
|
||||
}
|
||||
|
||||
function BuildConsole({
|
||||
site,
|
||||
buildStatus,
|
||||
}: {
|
||||
site: SiteInfo;
|
||||
buildStatus: SiteBuildStatusResponse | null | undefined;
|
||||
}) {
|
||||
const result = buildStatus?.result ?? site.last_build ?? null;
|
||||
if (!result) {
|
||||
return (
|
||||
<div className="site-studio-console-body">
|
||||
<div className="site-studio-console-empty">暂无构建记录。点击「重新构建」生成 dist/ 产物。</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const errorText = buildStatus?.error ?? result.error ?? "";
|
||||
const log = [result.stdout_tail ?? "", result.stderr_tail ?? ""]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.replace(/\u001b\[[0-9;]*m/g, "")
|
||||
.trim();
|
||||
return (
|
||||
<div className="site-studio-console-body">
|
||||
{errorText && <div className="site-studio-log-error">{errorText}</div>}
|
||||
{result.success && !errorText && (
|
||||
<div className="site-studio-log-success">
|
||||
✓ 构建成功
|
||||
{result.duration_ms != null && ` · ${(result.duration_ms / 1000).toFixed(1)}s`}
|
||||
</div>
|
||||
)}
|
||||
{log && <div>{log}</div>}
|
||||
{!log && !errorText && !result.success && <div>(无日志输出)</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StateCard({
|
||||
icon,
|
||||
spinner,
|
||||
title,
|
||||
desc,
|
||||
reasons,
|
||||
action,
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
spinner?: boolean;
|
||||
title: string;
|
||||
desc?: string;
|
||||
reasons?: string[];
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="site-studio-state">
|
||||
<div className="site-studio-state-card">
|
||||
{spinner ? (
|
||||
<div className="site-studio-spinner" />
|
||||
) : (
|
||||
<div className="site-studio-state-art">{icon}</div>
|
||||
)}
|
||||
<h1 className="site-studio-title">{title}</h1>
|
||||
{desc && <p className="site-studio-state-desc">{desc}</p>}
|
||||
{reasons && reasons.length > 0 && (
|
||||
<div className="site-studio-state-reasons">
|
||||
{reasons.map((r, i) => (
|
||||
<div key={i}>· {r}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{action}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
@@ -1,593 +0,0 @@
|
||||
/*
|
||||
* 站点预览工作室 —— 独立视觉体系(与主应用风格完全区隔)。
|
||||
* 深空底 + 靛蓝/青渐变强调 + 玻璃拟态面板;全部类名 site-studio- 前缀,
|
||||
* 仅由 SiteStudioPage 引入,不触碰全局样式与主题变量。
|
||||
*/
|
||||
|
||||
.site-studio-root {
|
||||
--ss-bg: #0b0d13;
|
||||
--ss-bg-2: #0f1118;
|
||||
--ss-panel: rgba(255, 255, 255, 0.04);
|
||||
--ss-panel-strong: rgba(255, 255, 255, 0.07);
|
||||
--ss-border: rgba(255, 255, 255, 0.09);
|
||||
--ss-border-strong: rgba(255, 255, 255, 0.16);
|
||||
--ss-text: #eef1f7;
|
||||
--ss-text-2: #9aa3b8;
|
||||
--ss-text-3: #667085;
|
||||
--ss-accent: #5b7cfa;
|
||||
--ss-accent-2: #35d4c5;
|
||||
--ss-danger: #f26d7d;
|
||||
--ss-success: #3ecf8e;
|
||||
--ss-warn: #f5b95f;
|
||||
--ss-font:
|
||||
"Inter", "HarmonyOS Sans SC", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
--ss-mono: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
||||
--ss-radius: 14px;
|
||||
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background:
|
||||
radial-gradient(1100px 500px at 85% -10%, rgba(91, 124, 250, 0.16), transparent 60%),
|
||||
radial-gradient(900px 460px at -10% 110%, rgba(53, 212, 197, 0.1), transparent 55%),
|
||||
var(--ss-bg);
|
||||
color: var(--ss-text);
|
||||
font-family: var(--ss-font);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ───────── 顶部工具栏 ───────── */
|
||||
|
||||
.site-studio-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--ss-border);
|
||||
background: rgba(11, 13, 19, 0.72);
|
||||
backdrop-filter: blur(14px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.site-studio-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--ss-text-2);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
padding: 7px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--ss-border);
|
||||
background: var(--ss-panel);
|
||||
transition: all 0.16s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.site-studio-back:hover {
|
||||
color: var(--ss-text);
|
||||
border-color: var(--ss-border-strong);
|
||||
background: var(--ss-panel-strong);
|
||||
}
|
||||
|
||||
.site-studio-title-wrap {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.site-studio-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.site-studio-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--ss-text-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.site-studio-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--ss-border);
|
||||
background: var(--ss-panel);
|
||||
color: var(--ss-text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.site-studio-pill .site-studio-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--ss-text-3);
|
||||
}
|
||||
|
||||
.site-studio-pill--success {
|
||||
color: var(--ss-success);
|
||||
border-color: rgba(62, 207, 142, 0.35);
|
||||
}
|
||||
|
||||
.site-studio-pill--success .site-studio-dot {
|
||||
background: var(--ss-success);
|
||||
}
|
||||
|
||||
.site-studio-pill--running {
|
||||
color: var(--ss-accent-2);
|
||||
border-color: rgba(53, 212, 197, 0.35);
|
||||
}
|
||||
|
||||
.site-studio-pill--running .site-studio-dot {
|
||||
background: var(--ss-accent-2);
|
||||
animation: site-studio-breathe 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.site-studio-pill--failed {
|
||||
color: var(--ss-danger);
|
||||
border-color: rgba(242, 109, 125, 0.4);
|
||||
}
|
||||
|
||||
.site-studio-pill--failed .site-studio-dot {
|
||||
background: var(--ss-danger);
|
||||
}
|
||||
|
||||
.site-studio-pill--none {
|
||||
color: var(--ss-warn);
|
||||
border-color: rgba(245, 185, 95, 0.35);
|
||||
}
|
||||
|
||||
.site-studio-pill--none .site-studio-dot {
|
||||
background: var(--ss-warn);
|
||||
}
|
||||
|
||||
@keyframes site-studio-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
}
|
||||
|
||||
/* 设备切换分段控件 */
|
||||
.site-studio-seg {
|
||||
display: inline-flex;
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
border-radius: 11px;
|
||||
border: 1px solid var(--ss-border);
|
||||
background: var(--ss-panel);
|
||||
}
|
||||
|
||||
.site-studio-seg-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--ss-text-3);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.site-studio-seg-btn:hover {
|
||||
color: var(--ss-text);
|
||||
}
|
||||
|
||||
.site-studio-seg-btn--active {
|
||||
background: linear-gradient(135deg, rgba(91, 124, 250, 0.85), rgba(72, 96, 220, 0.85));
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 10px rgba(91, 124, 250, 0.35);
|
||||
}
|
||||
|
||||
/* 主按钮 */
|
||||
.site-studio-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
padding: 7px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--ss-border);
|
||||
background: var(--ss-panel);
|
||||
color: var(--ss-text-2);
|
||||
cursor: pointer;
|
||||
transition: all 0.16s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.site-studio-btn:hover:not(:disabled) {
|
||||
color: var(--ss-text);
|
||||
border-color: var(--ss-border-strong);
|
||||
background: var(--ss-panel-strong);
|
||||
}
|
||||
|
||||
.site-studio-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.site-studio-btn--primary {
|
||||
background: linear-gradient(135deg, #5b7cfa, #465edc);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 18px rgba(91, 124, 250, 0.35);
|
||||
}
|
||||
|
||||
.site-studio-btn--primary:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #6d8bff, #5570ea);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ───────── 主体三栏 ───────── */
|
||||
|
||||
.site-studio-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 左侧页面导航 */
|
||||
.site-studio-sidenav {
|
||||
width: 208px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--ss-border);
|
||||
padding: 16px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
overflow-y: auto;
|
||||
background: rgba(15, 17, 24, 0.5);
|
||||
}
|
||||
|
||||
.site-studio-sidenav-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1.4px;
|
||||
text-transform: uppercase;
|
||||
color: var(--ss-text-3);
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.site-studio-navlist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.site-studio-navitem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--ss-text-2);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
padding: 9px 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.site-studio-navitem:hover {
|
||||
color: var(--ss-text);
|
||||
background: var(--ss-panel);
|
||||
}
|
||||
|
||||
.site-studio-navitem--active {
|
||||
color: var(--ss-text);
|
||||
background: linear-gradient(135deg, rgba(91, 124, 250, 0.2), rgba(53, 212, 197, 0.08));
|
||||
border-color: rgba(91, 124, 250, 0.35);
|
||||
}
|
||||
|
||||
.site-studio-navitem .site-studio-navindex {
|
||||
font-size: 11px;
|
||||
font-family: var(--ss-mono);
|
||||
color: var(--ss-text-3);
|
||||
width: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.site-studio-navitem--active .site-studio-navindex {
|
||||
color: var(--ss-accent);
|
||||
}
|
||||
|
||||
.site-studio-sidenav-files {
|
||||
font-size: 12px;
|
||||
color: var(--ss-text-3);
|
||||
padding: 0 8px;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.site-studio-sidenav-files code {
|
||||
font-family: var(--ss-mono);
|
||||
font-size: 11px;
|
||||
color: var(--ss-text-2);
|
||||
}
|
||||
|
||||
/* 中央预览区 */
|
||||
.site-studio-stage {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 22px 26px 26px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.site-studio-frame-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--ss-frame-w, 100%);
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
min-height: 420px;
|
||||
border-radius: var(--ss-radius);
|
||||
border: 1px solid var(--ss-border-strong);
|
||||
background: #171a23;
|
||||
box-shadow:
|
||||
0 24px 70px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||
overflow: hidden;
|
||||
transition: width 0.35s cubic-bezier(0.25, 0.8, 0.3, 1);
|
||||
}
|
||||
|
||||
.site-studio-frame-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 14px;
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
border-bottom: 1px solid var(--ss-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.site-studio-traffic {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.site-studio-traffic span {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.site-studio-traffic span:nth-child(1) {
|
||||
background: #ff5f57;
|
||||
}
|
||||
|
||||
.site-studio-traffic span:nth-child(2) {
|
||||
background: #febc2e;
|
||||
}
|
||||
|
||||
.site-studio-traffic span:nth-child(3) {
|
||||
background: #28c840;
|
||||
}
|
||||
|
||||
.site-studio-urlbox {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
border: 1px solid var(--ss-border);
|
||||
border-radius: 8px;
|
||||
padding: 5px 12px;
|
||||
font-family: var(--ss-mono);
|
||||
font-size: 11px;
|
||||
color: var(--ss-text-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.site-studio-urlbox svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--ss-accent-2);
|
||||
}
|
||||
|
||||
.site-studio-urlbox span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.site-studio-frame-holder {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.site-studio-iframe {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ───────── 底部构建控制台 ───────── */
|
||||
|
||||
.site-studio-console {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--ss-border);
|
||||
background: rgba(9, 10, 16, 0.85);
|
||||
max-height: 34vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.site-studio-console-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 20px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 12px;
|
||||
color: var(--ss-text-2);
|
||||
}
|
||||
|
||||
.site-studio-console-head:hover {
|
||||
color: var(--ss-text);
|
||||
}
|
||||
|
||||
.site-studio-console-title {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.site-studio-console-meta {
|
||||
color: var(--ss-text-3);
|
||||
font-family: var(--ss-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.site-studio-console-body {
|
||||
overflow: auto;
|
||||
padding: 4px 20px 16px;
|
||||
font-family: var(--ss-mono);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.75;
|
||||
color: var(--ss-text-2);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.site-studio-console-body .site-studio-log-error {
|
||||
color: var(--ss-danger);
|
||||
}
|
||||
|
||||
.site-studio-console-body .site-studio-log-success {
|
||||
color: var(--ss-success);
|
||||
}
|
||||
|
||||
.site-studio-console-empty {
|
||||
color: var(--ss-text-3);
|
||||
font-family: var(--ss-font);
|
||||
font-size: 12px;
|
||||
padding: 8px 0 14px;
|
||||
}
|
||||
|
||||
/* ───────── 状态视图 ───────── */
|
||||
|
||||
.site-studio-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.site-studio-state-card {
|
||||
max-width: 520px;
|
||||
text-align: center;
|
||||
padding: 44px 40px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--ss-border);
|
||||
background: var(--ss-panel);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.site-studio-state-art {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
margin: 0 auto 22px;
|
||||
border-radius: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(91, 124, 250, 0.25), rgba(53, 212, 197, 0.18));
|
||||
border: 1px solid rgba(91, 124, 250, 0.35);
|
||||
color: var(--ss-accent);
|
||||
}
|
||||
|
||||
.site-studio-state-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.site-studio-state-desc {
|
||||
font-size: 13px;
|
||||
color: var(--ss-text-2);
|
||||
line-height: 1.7;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
|
||||
.site-studio-state-reasons {
|
||||
text-align: left;
|
||||
margin: 0 auto 20px;
|
||||
padding: 12px 16px;
|
||||
max-width: 420px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
border: 1px solid var(--ss-border);
|
||||
font-size: 12px;
|
||||
color: var(--ss-text-3);
|
||||
font-family: var(--ss-mono);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.site-studio-spinner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--ss-border-strong);
|
||||
border-top-color: var(--ss-accent);
|
||||
animation: site-studio-spin 0.9s linear infinite;
|
||||
margin: 0 auto 18px;
|
||||
}
|
||||
|
||||
@keyframes site-studio-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.site-studio-error-text {
|
||||
color: var(--ss-danger);
|
||||
font-size: 12px;
|
||||
font-family: var(--ss-mono);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 响应式:窄屏收起左栏 */
|
||||
@media (max-width: 860px) {
|
||||
.site-studio-sidenav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-studio-subtitle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -114,12 +114,10 @@ export function PositionBusinessChainFlow({
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<h2 className={cn("truncate text-base font-semibold text-slate-900 dark:text-white", embedded && "text-lg font-[550] dark:text-white")}>
|
||||
{chain?.title?.trim() || "业务链条"}
|
||||
</h2>
|
||||
{!embedded && !chain && (
|
||||
<h2 className={cn("truncate text-base font-semibold text-slate-900 dark:text-white", embedded && "text-lg font-[550] dark:text-white")}>业务链条</h2>
|
||||
{!embedded && (
|
||||
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
|
||||
{loading ? "正在加载管理员配置…" : "等待管理员配置"}
|
||||
{chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Loaded 100 of 299 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user