Compare commits

..
2 Commits
Author SHA1 Message Date
gozeros b638b541d0 初始化 2026-09-07 18:24:55 +08:00
wsk 34474bd5fc Initial commit 2026-09-07 17:37:11 +08:00
627 changed files with 2155 additions and 60072 deletions

No files matched your search

-203
View File
@@ -1,203 +0,0 @@
# Workspace/editor
.vscode/*
!.vscode/launch.json
!.vscode/tasks.json
!.vscode/extensions.json
!.vscode/settings.json
frontend-web/.vscode/
node_modules/
# Claude Code local-only settings (per-user permissions, not shared)
.claude/settings.local.json
# Local runtime logs and pid files
.runtime/
logs/
offline-backend-20260512/backend/logs/
# Frontend local/runtime artifacts
frontend-web/.env
frontend-web/.env.local
frontend-web/.env.*.local
frontend-web/node_modules/
frontend-web/.pnpm-store/
frontend-web/tsconfig.tsbuildinfo
frontend-web/vite-dev.log
frontend-web/vite-dev.err.log
# Frontend local packages/archives/docs (non-source)
frontend-web/pnpm-lock.yaml
frontend-web/README.md
frontend-web/doc.md
frontend-web/src.zip
frontend-web/src9.zip
frontend-web/src-v1.zip
frontend-web/src-*.rar
frontend-web/dist
# Frontend build artifacts
frontend-web/dist/
frontend-web/dist-scheduled-check/
# Offline backend bundles and local deployment artifacts
offline-backend-20260505.zip
offline-backend-20260505/output/
offline-backend-20260505/bundle/.env
offline-backend-20260505/bundle/.env.example
offline-backend-20260505/bundle/DEPLOY_BACKEND_OFFLINE_ZH.md
offline-backend-20260505/bundle/SHA256SUMS
offline-backend-20260505/bundle/config.yaml
offline-backend-20260505/bundle/config.yaml.example
offline-backend-20260505/bundle/data/
offline-backend-20260505/bundle/deerflow-backend-offline-image-20260505.tar
offline-backend-20260505/bundle/extensions_config.json
offline-backend-20260505/bundle/frontend-vite.zip
offline-backend-20260505/bundle/skills/
offline-backend-20260505/bundle/start_backend.0509.sh
offline-backend-20260505/bundle/start_backend.sh
offline-backend-20260505/bundle/stop_backend.sh
# offline-backend-20260512 runtime artifacts (large files, local config, data)
offline-backend-20260512/.env.example
offline-backend-20260512/.env
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
offline-backend-20260512/SHA256SUMS
offline-backend-20260512/config.yaml.example
offline-backend-20260512/data/
offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
offline-backend-20260512/extensions_config.json
offline-backend-20260512/skills/
offline-backend-20260512/wheelhouse/
offline-backend-20260512/backend/.deer-flow/data/*.db
offline-backend-20260512/backend/.deer-flow/data/*.db-*
offline-backend-20260512/backend/.deer-flow/*
offline-backend-20260512/backend/.env
offline-backend-20260512/backend/.env.local
offline-backend-20260512/start_backend.sh
offline-backend-20260512/stop_backend.sh
offline-backend-20260512/check_backend.sh
hermes-agent-main/
skills/*
offline-backend-20260512/backend/.deer-flow/*
# Per-user skill_evolution overrides — runtime data written by the API
offline-backend-20260512/backend/skill_evolution.json
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.
!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
# Keep the local LLMWiki vector-index and external API regression coverage versioned.
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
!offline-backend-20260512/backend/tests/test_llmwiki_encoder_runtime.py
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
# Deep-research reliability regression coverage.
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py
!offline-backend-20260512/backend/tests/test_deep_research_start_job.py
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
# Independent enterprise-research persistence, HTTP and race regressions.
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
# Roundtable coordinator/seat capability isolation regressions.
!offline-backend-20260512/backend/tests/test_roundtable_role_boundary.py
# Workflow Studio: schemas / definitions / engine / run-lifecycle regressions
# plus the concurrency remediation suite (atomic resume, draft CAS, publish
# counter, event seq) — required in CI per docs/WORKFLOW_STUDIO_BACKEND_REMEDIATION_ZH.md P1-06.
!offline-backend-20260512/backend/tests/test_workflow_schemas.py
!offline-backend-20260512/backend/tests/test_workflow_definitions.py
!offline-backend-20260512/backend/tests/test_workflow_engine.py
!offline-backend-20260512/backend/tests/test_workflow_runs.py
!offline-backend-20260512/backend/tests/test_workflow_concurrency.py
!offline-backend-20260512/backend/tests/test_workflow_schema_rebuild.py
# Writing-mode first-report pipeline enforcement regressions.
!offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py
!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.
!offline-backend-20260512/backend/tests/test_report_collaboration_*.py
!offline-backend-20260512/backend/tests/test_ensure_orm_columns_sync.py
deploy
.history
# Cloned third-party repos (local only)
git-clone/
# Repo-root offline dependency pack (local only)
/dist/
# Frontend offline pack working directories
frontend-web/.offline-pack-build/
frontend-web/.offline-extract-test/
frontend-web-node_modules-offline-*.tar.gz
# Local dependency caches and backend scratch archives
/.pnpm-store/
/offline-backend-20260512/backend/backend*.zip
# Local offline bundles and scratch tooling
/*.tar.gz
/offline-backend-20260512/*.zip
/tmp/
.codegraph
codegraph.mdc
docs/aidev-docs
docs/backups
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
!frontend-web/src/components/workspace/skills/
# uv lockfile (not tracked per project preference)
offline-backend-20260512/backend/uv.lock
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
# Local-only scratch directory (not committed)
0622/
# Python bytecode cache
__pycache__/
# misc
.DS_Store
@@ -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/ 出现在产物目录扫描(文件噪音)——一期保持现状,二期优化。
+1 -3
View File
@@ -96,7 +96,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:<stage>` 窗口事件强制重放(忽略已看过记忆)。 - **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide.<stage>.finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:<stage>` 窗口事件强制重放(忽略已看过记忆)。
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. - **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 - **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id, or action id when `idKind:"action"` — same selector as url buttons, xdfx only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url **or host** button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. - **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` - **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/` - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
@@ -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 - **agentfx 任务深链(新系统 iframe 嵌入的智能体问答,3qfx 单智能体 lite 模式的独立复制)**: goPath=`agentfx`(`${loginBase}?password=…&taskId=…&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue`)。与 3qfx 的差异:①任务详情不走外部 consumer,改调**本系统 Gateway** `GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=`(`apiFetch` 带刚登录的 DeerFlow 态;任务数据来自 TaskCOP 任务表,与任务列表/报告导入同一份);②**固定使用内置智能体** `agentfx-analyst`(不走业务映射);③路由 `task-management/plan-task-agentfx/agent/:agent_id/chats`,页面为独立复制的 `AgentFxWorkspace.tsx`(`AgentFxWorkspace`/`AgentFxIndexResolver`/`AgentFxChat` —— 左「当前任务」卡 + 1:N 对话列表 + 新建对话,`AgentChatPage taskBind` 自动发开场白;无 TaskJumpBar/清除记录/闸门/xdfx 行动卡)。模块 `core/auth/agentfx-deeplink.ts`(`isAgentfxGoPath`/`fetchAgentfxTaskDetail`/`resolveAgentfxDeeplinkTarget`);任务详情整包仍存共享键 `login.copTaskDetail`(按标签页隔离),`AgentChatPage` 的 taskBind 自恢复对 agentfx 生效。`LoginPage` 在既有深链分支前用 `isAgentfxGoPath` 分流。**内置「任务研判报告助手」**:后端 seeder `app/gateway/routers/_agentfx_seed.py` + `_agentfx_seed_assets/`(config.yaml + SOUL.md,目录存在不覆盖;app.py lifespan 在 `_sync_legacy_agents` 之前调用,自动进 agents 表 user_id IS NULL)——SOUL 规定工作流「搜集信息(`web_search`,**首步不可跳过**)→ 每次检索 JSON 原样落盘 `/mnt/user-data/workspace/hits/qN.json` → **一条命令** `task-report-build`(group `all`)技能函数转换成 14 类 `task-reports.json`(模型禁止手写该 JSON;分页技能 `task-report-{enemy,our,env,judge}` 仅用于事后补跑单页;五者都是共享引擎 `task-report-lib/convert_lib.py` 的瘦 CLI,`resolve_hit_files` 对虚拟绝对路径读空的情况自动回退到相对写法并在 `warnings` 里列出尝试过的路径,免得智能体自己写脚本排查)→ `write_file` 生成 `/mnt/user-data/outputs/report.md` → 聊天总结 → 引导入库」,硬性约束:categoryType 逐字用 14 个全称(禁缩写自造如「DQ关键事件」)、JSON 直接写汉字禁 `\uXXXX` 转义、每类必须有实质内容不许空数组/纯占位、文件一律用 `/mnt/user-data/...` 虚拟路径(防宿主路径泄漏进 tool args)。**检索依赖 `config.yaml tools.web_search`(DDG,enabled 已开;内网需换 configurable_search 时改回并填 endpoint)**。任务工作区输入框在 `taskCtxOn` 时隐藏「写作/记忆/参考文献」(InputBox `hideContextTools` prop)与「知识空间」(不传 knowledgeSelector);消息流里 write_file 文件卡片只显示文件名(`artifactDisplayName` 收敛宿主/虚拟路径)。**入库两条路径**:(a) **前端协助卡** —— `AgentChatPage`(taskBind 且非 taskGate)检测线程 artifacts 出现 `task-reports.json`(按文件名后缀匹配,兼容本地沙箱记录的宿主路径)且流式结束时,向 displayThread 尾部注入 `additional_kwargs.task_report_import_approval` 虚拟 ai 消息(`core/messages/utils.ts` 分组 `assistant:task-report-import` + `MessageList` 的 `taskReportImportSlot` 插槽渲染 `TaskReportImportCard`),点「开始入库」前端校验(`strategy-components/lib/agentfx-report-schema.ts`,14 类 + contentJson 合法 JSON + **\u 转义归一化还原为汉字**,与技能脚本同规则)后 `importTaskReport`(`strategy-components/api/task-reports.ts`)POST 导入接口整体替换;任务已有详情**不抑制**卡片(入库即整体替换,卡片文案说明覆盖语义),导入成功按 thread 记 sessionStorage(刷新不重复询问)、「暂不入库」为会话内记忆;(b) **入库技能** `task-report-import`(seeder 一并复制到 `skills/public/`,public 类技能 extensions_config 缺省即启用)—— 用户聊天里说「入库」时智能体按 SKILL.md 用 bash 跑 `scripts/task_report_import.py`(纯标准库:同样的校验规则 + 转义归一化;**默认关闭 SSL 证书校验** `ssl._create_unverified_context` + 忽略代理,适配内网自签证书;鉴权 `--token`/env `AGENTFX_IMPORT_TOKEN`→`DEERFLOW_AUTH_TOKEN`→`TOOLS_TOKEN` 优先,缺省经公共端点 `POST /api/parallel-agents/auth/token` 以 `agentfx-import` 用户现签 token——内网既有鉴权旁路;gateway 地址 env `AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:{DEER_FLOW_GATEWAY_PORT}`);`--strict` 可要求 14 类齐全,默认允许部分导入并在 stdout JSON 的 `missing_categories` 里列缺。入库成功后任务 TaskCOP 状态自动置 25(已完成分析)。**入库成功通知宿主**(iframe 嵌入场景):两条路径成功后都会 `window.top.postMessage({source: "magent-web", type: "Line truncated
- **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。 - **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。
- **Web App 产物模式(多页面站点 / 流程图 / 地图,默认关闭、全链路可降级)**: 让智能体产出**完整站点**而非单个 xxx.html。沙箱技能 `skills/public/web-app` 用模板(`backend/templates/web-app-template`,经 `config.yaml sandbox.mounts` 只读挂载到 `/mnt/web-app-template`,目录缺失自动跳过)脚手架到 `outputs/<site>/`(site.json 清单 + `src/pages/*.tsx` 文件名即路由 + `public/` 数据),再跑 `scripts/build_site.py build` 用**预置 node_modules**(symlink/junction 链接,离线永不 npm install)执行 vite build 出 `dist/`;内置组件 `FlowCanvas`(@xyflow/react)/`GeoMap`(MapLibre,默认无底图 GeoJSON 模式,site.json 可配 tilesUrl/PMTiles)/`ChartPanel`(ECharts)/`MermaidBlock`。平台侧 `app/gateway/routers/web_app_sites.py` + 纯引擎 `deerflow/webapp/`(发现/子进程构建/HMAC 签名):`GET /api/web-app/capability`(enabled/模板/依赖/node 四项探测)、`GET|POST /api/threads/{tid}/sites[...]{build,build-status,preview-url}`,静态托管走 `GET /api/public/site-preview/{token}/{path}`(`/api/public/` 免鉴权命名空间,**唯一允许 HTML inline 的出口**——签名令牌 + dist/ 根限定 + 按请求真实 origin 显式声明的 CSP(iframe sandbox 不透明 origin 下 'self' 失效)+ nosniff;artifacts 接口既有的强制 attachment 不变)。前端:**独立风格**预览工作台 `pages/site-studio/SiteStudioPage.tsx`(顶层路由 `#/sites/:threadId/:siteName`,`App.tsx` 注册,不进 PageLayout/WorkspaceLayout;自带 site-studio.css 深色设计体系,零全局样式污染;iframe `sandbox="allow-scripts allow-forms allow-popups allow-modals"` 无 allow-same-origin)+ 产物面板 `artifact-file-detail.tsx` 里 site.json 的「站点预览」按钮(capability 可用才渲染,唯一既有文件改动)。**降级铁律**:默认**完全关闭**(`sandbox.mounts` 的模板挂载条目与文件末尾的 `web_app:` 段在 config.yaml 里**均为注释状态**);开启需**同时放开两处**(挂载让沙箱技能可探测 `/mnt/web-app-template`,`web_app.enabled: true` 开平台 API——只开一处会得到"生成了看不了"或"看不了也不生成"的中间态)。`build_site.py check` 在宿主侧会读 config.yaml 的 `web_app.enabled`(env `DEERFLOW_WEBAPP_ENABLED`/`DEERFLOW_WEBAPP_CONFIG` 可覆盖;沙箱内读不到配置时"挂载即视为开")。任何一环(配置/模板/node_modules/node)缺失 → capability 不可用 + 技能第 0 步探测失败即**回退单页 HTML 模式** + 入口按钮不渲染 + 构建失败只收进任务状态,现有功能零影响。配置 `web_app:`(enabled/template_dir/build_timeout_seconds/preview_token_ttl_seconds/node_path);测试 `tests/test_web_app_sites.py`;开发机 `make webapp-template` 预置依赖,离线发版由 deerflow-offline-deployment 仓库在 CI 预置 node_modules 进镜像。
### Configuration ### Configuration
Backend config lives in `offline-backend-20260512/backend/config.yaml`. Values starting with `$` resolve as environment variables (e.g., `$OPENAI_API_KEY`). MCP servers and skills are configured in `extensions_config.json` in the same directory. Backend config lives in `offline-backend-20260512/backend/config.yaml`. Values starting with `$` resolve as environment variables (e.g., `$OPENAI_API_KEY`). MCP servers and skills are configured in `extensions_config.json` in the same directory.
-3
View File
@@ -81,11 +81,8 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx` - **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps) - **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
- **普通问答地图可视化**: shared `MarkdownContent` recognizes only dedicated `map-json` fences and otherwise preserves the legacy Markdown/Streamdown path byte-for-byte. `components/workspace/visualizations/` validates the bounded map contract with Zod, lazy-loads MapLibre, and provides loading/error/text fallbacks so malformed or unreachable maps never blank the message. The lead-agent prompt emits `map-json` only when the user explicitly asks for a map; flowcharts and other graphics continue through the existing Markdown/Mermaid path. The raster tile template comes from the single post-build setting `runtime-config.js` → `VITE_MAP_TILE_URL` (Google by default); replace it with the intranet `{z}/{x}/{y}` endpoint for disconnected deployments.
- **HTML 成品离线绘图运行时**: 新生成的 `.html` 源码直接引用 Gateway `/api/public/html-runtime/` 下的浏览器版 `g6.min.js`、`echarts.min.js`、`leaflet.js/css` 和 `deerflow-runtime.js`;文件随后台源码存放于 `vendor/html-runtime/`,不再依赖前端 Vite/node_modules 产物或公网 CDN。`config.yaml -> html_runtime.base_url` 是写入新 HTML 的浏览器可访问绝对地址,`html_runtime.map_tile_url` 是初始化脚本读取的瓦片模板。流程图使用 `data-df-flow` + G6(不用 Mermaid),图表使用 `data-df-chart`,地图使用 Leaflet `data-df-map`。普通问答保持独立:消息列表 `map-json` 继续由前端原逻辑渲染,普通流程图/图表仍走 Markdown/Mermaid。
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API. - **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API.
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`. - **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
- **策略优选**: route `/page/strategy/qa/strategy-selection`, sidebar 「问答管理 → 策略优选」. The standalone `src/strategy-selection/` workbench has two horizontal split stages: Step 1 keeps intent-understanding conversation cards on the left and an editable task draft plus angle selection on the right; Step 2 keeps multiplexed parallel strategy cards and automatic judging on the left while the right stays dedicated to the selected-strategy basket and final Markdown synthesis. It consumes the existing `POST /api/parallel-agents/run/stream` protocol, isolates output by stable agent id, supports expanded per-agent dialogs, user selection, Markdown editing/copy/download, and shared server-side DOCX export. A clearly marked neutral Taiwan-Strait risk-management demo is loaded by default for offline UI acceptance. Administrators can edit the SOUL and skills of the nine built-in functional agents through the page dialog; the backend seeds `strategy-selection-{intent,angle-*,judge,solution}` only when their directories are missing, preserving later admin changes. Pure parsing/SSE tests live in `src/strategy-selection/lib.test.ts`; backend seed tests live in `tests/test_strategy_selection_seed.py`.
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`. - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`.
- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`. - **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`.
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
# deerflow-code
deerflow-code
Submodule _skills_src/ai-skills deleted from cde1c8385e.
Submodule _skills_src/anthropics-skills deleted from 5754626092.
Submodule _skills_src/awesome-claude-skills deleted from 92568c1eda.
Submodule _skills_src/ce-kit deleted from d4973c4827.
Submodule _skills_src/csm deleted from 3fa16a94e0.
Submodule _skills_src/superpowers deleted from 6fd4507659.
Submodule _skills_src/tapestry-skills deleted from 80e1dc56df.
-31
View File
@@ -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`。本轮不重复付费生成完整方案;没有进行生产全量语料质量评估、负载测试、断网故障注入或完整无障碍合规审计。已检查可见导航、表单名称、窄屏显示和主要失败恢复,不能据此承诺所有模块没有缺陷。代码未提交、推送或部署。
-37
View File
@@ -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 保持不变。未提交、推送代码,也未执行离线发布。
+1 -1
View File
@@ -18,7 +18,7 @@ VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。 # 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false VITE_USE_OFFICIAL_DEERFLOW_ENTRY=true
# 轻应用「新建笔记」系列接口 # 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
-49
View File
@@ -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`。不在源码或文档中保存接口密钥。
+350 -355
View File
@@ -28,10 +28,10 @@
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "1.2.18", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "1.3.3", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
@@ -69,9 +69,7 @@
"gsap": "^3.13.0", "gsap": "^3.13.0",
"hast": "^1.0.0", "hast": "^1.0.0",
"katex": "^0.16.28", "katex": "^0.16.28",
"leaflet": "^1.9.4",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"maplibre-gl": "^6.9.0",
"mermaid": "^11.15.0", "mermaid": "^11.15.0",
"motion": "^12.26.2", "motion": "^12.26.2",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
@@ -1704,92 +1702,6 @@
"@lezer/lr": "^1.4.0" "@lezer/lr": "^1.4.0"
} }
}, },
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
"license": "MIT",
"engines": {
"node": ">= 22"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.0.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "6.1.1",
"resolved": "https://registry.npmmirror.com/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.1.0"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "26.4.2",
"resolved": "https://registry.npmmirror.com/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.2.tgz",
"integrity": "sha512-6J0vZqMZvRAJKtdWJdDGHEh1YJ2ZHG08/GOur8gCArYhO8ZkM//OXqtI2AAEz4jc2G+Wq4MfcePg8qNK5TZ4Kg==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
"@mapbox/unitbezier": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/mlt": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/@maplibre/mlt/-/mlt-1.2.1.tgz",
"integrity": "sha512-5n5dgolE2EYxwCKgx8vlwURCB8A+kyfJJyThlYimjlGgOcOe2Bhw9VxxnnHC91OC9PgHg+nVdgVPTYPkIo62vg==",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0"
}
},
"node_modules/@maplibre/vt-pbf": {
"version": "4.3.2",
"resolved": "https://registry.npmmirror.com/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.1.0"
}
},
"node_modules/@marijn/find-cluster-break": { "node_modules/@marijn/find-cluster-break": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
@@ -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": { "node_modules/@radix-ui/react-avatar": {
"version": "1.1.11", "version": "1.1.11",
"license": "MIT", "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": { "node_modules/@radix-ui/react-collection": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
@@ -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": { "node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
@@ -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": { "node_modules/@radix-ui/react-direction": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
@@ -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": { "node_modules/@radix-ui/react-dropdown-menu": {
"version": "2.1.16", "version": "2.1.16",
"license": "MIT", "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": { "node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
@@ -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": { "node_modules/@radix-ui/react-hover-card": {
"version": "1.1.15", "version": "1.1.15",
"license": "MIT", "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": { "node_modules/@radix-ui/react-icons": {
"version": "1.3.2", "version": "1.3.2",
"license": "MIT", "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": { "node_modules/@radix-ui/react-popper": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
@@ -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": { "node_modules/@radix-ui/react-portal": {
"version": "1.1.9", "version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
@@ -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": { "node_modules/@radix-ui/react-presence": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
@@ -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": { "node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.18", "version": "1.2.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz",
"integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/number": "1.1.3", "@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.7", "@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.2.2", "@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.4", "@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.10", "@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.4" "@radix-ui/react-use-layout-effect": "1.1.1"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@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": { "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": {
"version": "1.2.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
@@ -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": { "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": {
"version": "2.1.10", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-slot": "1.3.3" "@radix-ui/react-slot": "1.2.3"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -2851,26 +2898,13 @@
} }
} }
}, },
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-callback-ref": { "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": {
"version": "1.1.4", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT", "dependencies": {
"peerDependencies": { "@radix-ui/react-compose-refs": "1.1.2"
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
}, },
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
@@ -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": { "node_modules/@radix-ui/react-separator": {
"version": "1.1.8", "version": "1.1.8",
"license": "MIT", "license": "MIT",
@@ -2980,12 +3031,10 @@
} }
}, },
"node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-slot": {
"version": "1.3.3", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
"integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-compose-refs": "1.1.5" "@radix-ui/react-compose-refs": "1.1.2"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@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": { "node_modules/@radix-ui/react-switch": {
"version": "1.2.6", "version": "1.2.6",
"license": "MIT", "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": { "node_modules/@radix-ui/react-tabs": {
"version": "1.1.13", "version": "1.1.13",
"license": "MIT", "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": { "node_modules/@radix-ui/react-toggle": {
"version": "1.1.10", "version": "1.1.10",
"license": "MIT", "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": { "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
@@ -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": { "node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8", "version": "1.2.8",
"license": "MIT", "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": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -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": { "node_modules/@radix-ui/rect": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
@@ -5421,15 +5557,6 @@
"version": "1.2.1", "version": "1.2.1",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.2", "version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
@@ -6332,12 +6459,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/earcut": {
"version": "3.2.3",
"resolved": "https://registry.npmmirror.com/earcut/-/earcut-3.2.3.tgz",
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
"license": "ISC"
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.349", "version": "1.5.349",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
@@ -6732,12 +6853,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmmirror.com/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -7236,12 +7351,6 @@
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="
}, },
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/json5": { "node_modules/json5": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -7281,12 +7390,6 @@
"katex": "cli.js" "katex": "cli.js"
} }
}, },
"node_modules/kdbush": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/kdbush/-/kdbush-4.1.0.tgz",
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
"license": "ISC"
},
"node_modules/khroma": { "node_modules/khroma": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
@@ -7329,12 +7432,6 @@
"resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz",
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==" "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="
}, },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmmirror.com/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/lie": { "node_modules/lie": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
@@ -7669,39 +7766,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/maplibre-gl": {
"version": "6.9.0",
"resolved": "https://registry.npmmirror.com/maplibre-gl/-/maplibre-gl-6.9.0.tgz",
"integrity": "sha512-vFMwMK0Zs+NM/rOMSdtu8bO30DIexhBEVi5KC6f70/XtI+L/K2wC3LsDCAXFZ4s8ik5gAuDugfCNbpllpdJ9bA==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.2.0",
"@mapbox/unitbezier": "^1.0.0",
"@mapbox/vector-tile": "^3.0.0",
"@maplibre/geojson-vt": "^6.1.1",
"@maplibre/maplibre-gl-style-spec": "^26.4.2",
"@maplibre/mlt": "^1.2.1",
"@maplibre/vt-pbf": "^4.3.2",
"@types/geojson": "^7946.0.16",
"bidi-js": "^1.1.0",
"earcut": "^3.2.3",
"gl-matrix": "^3.4.4",
"kdbush": "^4.1.0",
"murmurhash-js": "^1.0.0",
"pbf": "^5.1.2",
"potpack": "^2.1.0",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/markdown-it": { "node_modules/markdown-it": {
"version": "14.1.1", "version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
@@ -8649,15 +8713,6 @@
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
}, },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mitt": { "node_modules/mitt": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
@@ -8716,12 +8771,6 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
}, },
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/mustache": { "node_modules/mustache": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
@@ -8894,18 +8943,6 @@
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
}, },
"node_modules/pbf": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/pbf/-/pbf-5.1.2.tgz",
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/performance-now": { "node_modules/performance-now": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
@@ -8998,12 +9035,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
"node_modules/prismjs": { "node_modules/prismjs": {
"version": "1.30.0", "version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
@@ -9187,12 +9218,6 @@
"prosemirror-transform": "^1.1.0" "prosemirror-transform": "^1.1.0"
} }
}, },
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmmirror.com/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -9210,12 +9235,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/raf": { "node_modules/raf": {
"version": "3.4.1", "version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
@@ -9599,24 +9618,6 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/robust-predicates": { "node_modules/robust-predicates": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
@@ -9970,12 +9971,6 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/tiptap-markdown": { "node_modules/tiptap-markdown": {
"version": "0.9.0", "version": "0.9.0",
"resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz", "resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz",
+2 -10
View File
@@ -11,9 +11,6 @@
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network", "build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test:input-mode": "node --experimental-strip-types --test src/components/workspace/input-mode.test.ts",
"test:html-preview": "pnpm exec esbuild src/core/utils/legacy-html-preview.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/legacy-html-preview.test.mjs && node --test .runtime/unit-tests/legacy-html-preview.test.mjs",
"test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts",
"test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs", "test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
"check:chrome109-css": "node scripts/check-chrome109-css.mjs", "check:chrome109-css": "node scripts/check-chrome109-css.mjs",
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs" "check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
@@ -39,10 +36,10 @@
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "1.2.18", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "1.3.3", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
@@ -80,9 +77,7 @@
"gsap": "^3.13.0", "gsap": "^3.13.0",
"hast": "^1.0.0", "hast": "^1.0.0",
"katex": "^0.16.28", "katex": "^0.16.28",
"leaflet": "^1.9.4",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"maplibre-gl": "^6.9.0",
"mermaid": "^11.15.0", "mermaid": "^11.15.0",
"motion": "^12.26.2", "motion": "^12.26.2",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
@@ -126,8 +121,5 @@
"vite": "^6.0.0", "vite": "^6.0.0",
"vite-plugin-source-identifier": "^1.1.2" "vite-plugin-source-identifier": "^1.1.2"
}, },
"overrides": {
"@radix-ui/react-slot": "1.3.3"
},
"packageManager": "pnpm@10.26.2" "packageManager": "pnpm@10.26.2"
} }
-3
View File
@@ -1,3 +0,0 @@
# Keep indirect Radix primitives on the React 19 ref-stability fix too.
overrides:
'@radix-ui/react-slot': 1.3.3
-4
View File
@@ -40,10 +40,6 @@ window.__CM_RUNTIME_CONFIG__ = {
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。 // Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
VITE_CANVAS_LANGGRAPH_BASE_URL: "", VITE_CANVAS_LANGGRAPH_BASE_URL: "",
// 地图瓦片地址模板:普通问答地图和生成的 HTML 网页地图共同使用。
// 后续接入内网瓦片服务时,只需替换此项并保留 {z}/{x}/{y} 占位符。
VITE_MAP_TILE_URL: "http://119.254.86.251:8023/tiles/{z}/{x}/{y}.png?v=zh-z14-13",
// routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。 // routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。
// 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。 // 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。
VITE_ROUTE_LOGIN_PASSWORD: "", VITE_ROUTE_LOGIN_PASSWORD: "",
-3
View File
@@ -36,7 +36,6 @@ import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage"; import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
import EmbedChatPage from "./pages/EmbedChatPage"; import EmbedChatPage from "./pages/EmbedChatPage";
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage"; import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
import SiteStudioPage from "./pages/site-studio/SiteStudioPage";
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage"; import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage"; import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView"; import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
@@ -206,8 +205,6 @@ export function App() {
element={<DashboardStandalonePage />} element={<DashboardStandalonePage />}
/> />
<Route path="/official/*" element={<OfficialDeerFlowEntry />} /> <Route path="/official/*" element={<OfficialDeerFlowEntry />} />
{/* Web App 站点预览工作室:独立路由 + 独立视觉风格(无主应用 chrome) */}
<Route path="/sites/:threadId/:siteName" element={<SiteStudioPage />} />
<Route path="/page/*" element={pageElement} /> <Route path="/page/*" element={pageElement} />
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
@@ -9,8 +9,8 @@ import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview";
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`) * is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`)
* navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is * navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is
* a real document URL, so fragment links scroll within the frame as expected — * a real document URL, so fragment links scroll within the frame as expected —
* The sandbox permits scripts so the preview can use the app-bundled HTML * all without enabling scripts (the sandbox stays script-free for security; the
* visualization runtimes, while still withholding same-origin privileges. * 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 * The `charset=utf-8` on the blob type is required: a blob: URL whose
* Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on * Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on
@@ -26,9 +26,7 @@ export function HtmlPreviewFrame({
const [url, setUrl] = useState<string>(); const [url, setUrl] = useState<string>();
useEffect(() => { useEffect(() => {
const blob = new Blob([ const blob = new Blob([prepareGeneratedHtml(html ?? "")], {
prepareGeneratedHtml(html ?? ""),
], {
type: "text/html;charset=utf-8", type: "text/html;charset=utf-8",
}); });
const objectUrl = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
@@ -1,4 +1,5 @@
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { Fragment } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -20,12 +21,12 @@ export function Breadcrumb({
)} )}
> >
{segments.map((segment, i) => ( {segments.map((segment, i) => (
<div className="contents" key={`${segment}-${i}`}> <Fragment key={`${segment}-${i}`}>
<span className="hidden shrink-0 whitespace-nowrap sm:inline"> <span className="hidden shrink-0 whitespace-nowrap sm:inline">
{segment} {segment}
</span> </span>
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" /> <ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
</div> </Fragment>
))} ))}
<span <span
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300" className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
@@ -51,10 +51,6 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs"; import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useAgentsPaginated } from "@/core/agents"; import { useAgentsPaginated } from "@/core/agents";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
@@ -73,7 +69,6 @@ import {
useToggleThreadPin, useToggleThreadPin,
} from "@/core/threads/hooks"; } from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import { import {
bootstrapResumePath, bootstrapResumePath,
isBootstrapThread, isBootstrapThread,
@@ -484,7 +479,6 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
) : ( ) : (
filteredThreads.map((thread) => { filteredThreads.map((thread) => {
const isBoot = isBootstrapThread(thread); const isBoot = isBootstrapThread(thread);
const projectType = myProjectConversationTypeOfThread(thread);
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread); const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
const isActive = href === pathname; const isActive = href === pathname;
const pinned = isThreadPinned(thread); const pinned = isThreadPinned(thread);
@@ -511,8 +505,6 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
)} )}
aria-label="智能体创建会话" aria-label="智能体创建会话"
/> />
) : projectType ? (
<MyProjectThreadIcon type={projectType} />
) : ( ) : (
<MessageSquare <MessageSquare
className={cn( className={cn(
@@ -524,15 +516,9 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
{pinned && ( {pinned && (
<Pin className="text-muted-foreground/70 size-3 shrink-0" /> <Pin className="text-muted-foreground/70 size-3 shrink-0" />
)} )}
<span className="min-w-0 flex-1 truncate"> <span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))} {desensitize(titleOfThread(thread, t.pages.newChat))}
</span> </span>
{projectType && (
<MyProjectThreadBadge
className="mr-4"
type={projectType}
/>
)}
{isActive && ( {isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" /> <span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)} )}
@@ -152,7 +152,6 @@ export function AgentDetailPanel({
positionName, positionName,
skillsIcon, skillsIcon,
skillsTitle, skillsTitle,
belowSkillsSlot,
showCommonQuestions = false, showCommonQuestions = false,
jobResponsibilities, jobResponsibilities,
panelTitle, panelTitle,
@@ -182,8 +181,6 @@ export function AgentDetailPanel({
positionName?: string; positionName?: string;
skillsIcon?: ReactNode; skillsIcon?: ReactNode;
skillsTitle?: string; skillsTitle?: string;
/** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */
belowSkillsSlot?: ReactNode;
showCommonQuestions?: boolean; showCommonQuestions?: boolean;
/** /**
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示; * 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
@@ -943,9 +940,6 @@ export function AgentDetailPanel({
/> />
</Section> </Section>
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
{belowSkillsSlot}
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */} {/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />} {showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
</div> </div>
@@ -4,12 +4,7 @@ import { MessageSquareIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useThreadsPage } from "@/core/threads/hooks"; import { useThreadsPage } from "@/core/threads/hooks";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils"; import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -50,7 +45,6 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
{threads.map((thread) => { {threads.map((thread) => {
const href = pathOfThread(thread); const href = pathOfThread(thread);
const isActive = href === pathname; const isActive = href === pathname;
const projectType = myProjectConversationTypeOfThread(thread);
return ( return (
<Link <Link
key={thread.thread_id} key={thread.thread_id}
@@ -63,20 +57,15 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
: "text-muted-foreground hover:bg-accent hover:text-foreground", : "text-muted-foreground hover:bg-accent hover:text-foreground",
)} )}
> >
{projectType ? (
<MyProjectThreadIcon type={projectType} />
) : (
<MessageSquareIcon <MessageSquareIcon
className={cn( className={cn(
"size-3.5 shrink-0", "size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70", isActive ? "text-primary" : "text-muted-foreground/70",
)} )}
/> />
)} <span className="truncate">
<span className="min-w-0 flex-1 truncate">
{desensitize(titleOfThread(thread, "新对话"))} {desensitize(titleOfThread(thread, "新对话"))}
</span> </span>
{projectType && <MyProjectThreadBadge type={projectType} />}
</Link> </Link>
); );
})} })}
@@ -8,7 +8,6 @@ import {
ExternalLinkIcon, ExternalLinkIcon,
EyeIcon, EyeIcon,
FileType2Icon, FileType2Icon,
GlobeIcon,
LoaderIcon, LoaderIcon,
PackageIcon, PackageIcon,
SaveIcon, SaveIcon,
@@ -18,7 +17,6 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useNavigate } from "react-router-dom";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react"; import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -60,7 +58,6 @@ import { publishArtifactToKnowledge } from "@/core/agents/api";
import { useAgent } from "@/core/agents/hooks"; import { useAgent } from "@/core/agents/hooks";
import { apiFetch } from "@/core/api"; import { apiFetch } from "@/core/api";
import { copyTextToClipboard } from "@/core/clipboard"; import { copyTextToClipboard } from "@/core/clipboard";
import { siteStudioRoute, useWebAppCapability } from "@/core/sites/api";
import { import {
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
DOCUMENT_REWRITE_STARTED_EVENT, DOCUMENT_REWRITE_STARTED_EVENT,
@@ -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({ export function ArtifactFileDetail({
className, className,
filepath: filepathFromProps, filepath: filepathFromProps,
@@ -287,14 +268,6 @@ export function ArtifactFileDetail({
const { t } = useI18n(); const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts(); const { artifacts, setOpen, select } = useArtifacts();
const { isMock, thread, publicShareCode } = useThread(); const { isMock, thread, publicShareCode } = useThread();
const navigate = useNavigate();
// Web App 站点清单(outputs/<site>/site.json)→「站点预览」入口。
// capability 不可用(未启用/未部署)时按钮不渲染,原 UI 一字不变。
const siteStudioTarget = useMemo(
() => extractSiteStudioTarget(filepathFromProps),
[filepathFromProps],
);
const webAppCapabilityQuery = useWebAppCapability(!!siteStudioTarget);
const isWriteFileVirtual = useMemo(() => { const isWriteFileVirtual = useMemo(() => {
return filepathFromProps.startsWith("write-file:"); return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]); }, [filepathFromProps]);
@@ -1454,9 +1427,7 @@ export function ArtifactFileDetail({
toast.error("文件内容为空,暂时无法在新窗口打开"); toast.error("文件内容为空,暂时无法在新窗口打开");
return; return;
} }
const blob = new Blob([ const blob = new Blob([prepareGeneratedHtml(source)], {
prepareGeneratedHtml(source),
], {
type: "text/html;charset=utf-8", type: "text/html;charset=utf-8",
}); });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -1483,11 +1454,7 @@ export function ArtifactFileDetail({
: language === "markdown" : language === "markdown"
? "text/markdown;charset=utf-8" ? "text/markdown;charset=utf-8"
: "text/plain;charset=utf-8"; : "text/plain;charset=utf-8";
const downloadSource = const blob = new Blob([source], { type: blobType });
language === "html"
? prepareGeneratedHtml(source)
: source;
const blob = new Blob([downloadSource], { type: blobType });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
@@ -1680,16 +1647,6 @@ export function ArtifactFileDetail({
onClick={handleOpenInNewWindow} onClick={handleOpenInNewWindow}
/> />
)} )}
{siteStudioTarget && webAppCapabilityQuery.data?.available && (
<ArtifactAction
icon={GlobeIcon}
label="站点预览"
tooltip="打开站点预览工作室(多页面站点 / 流程图 / 地图)"
onClick={() =>
navigate(siteStudioRoute(threadId, siteStudioTarget))
}
/>
)}
<ArtifactAction <ArtifactAction
icon={DownloadIcon} icon={DownloadIcon}
label={t.common.download} label={t.common.download}
@@ -2137,9 +2094,7 @@ export function ArtifactFilePreview({
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。 // <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
// Intercept in-page anchor clicks so they scroll within the iframe instead // Intercept in-page anchor clicks so they scroll within the iframe instead
// of navigating the sandboxed blob: document (which blanks the frame). // of navigating the sandboxed blob: document (which blanks the frame).
const blob = new Blob([ const blob = new Blob([prepareGeneratedHtml(content ?? "")], {
prepareGeneratedHtml(content ?? ""),
], {
type: "text/html;charset=utf-8", type: "text/html;charset=utf-8",
}); });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -16,7 +16,6 @@ import {
getFileExtensionDisplayName, getFileExtensionDisplayName,
getFileIcon, getFileIcon,
getFileName, getFileName,
isWordDocumentFile,
} from "@/core/utils/files"; } from "@/core/utils/files";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -97,8 +96,8 @@ export function ArtifactFileList({
{files.map((file) => ( {files.map((file) => (
<Card <Card
key={file} key={file}
className={cn("relative p-3", !isWordDocumentFile(file) && "cursor-pointer")} className="relative cursor-pointer p-3"
onClick={isWordDocumentFile(file) ? undefined : () => handleClick(file)} onClick={() => handleClick(file)}
> >
<CardHeader className="grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 pr-2 pl-1"> <CardHeader className="grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 pr-2 pl-1">
<CardTitle className="relative min-w-0 pl-8 leading-tight [overflow-wrap:anywhere] break-words"> <CardTitle className="relative min-w-0 pl-8 leading-tight [overflow-wrap:anywhere] break-words">
@@ -19,10 +19,7 @@ import {
listThreadArtifacts, listThreadArtifacts,
mergeArtifactPaths, mergeArtifactPaths,
} from "@/core/artifacts/listing"; } from "@/core/artifacts/listing";
import { import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
latestInFlightWriteFilePreview,
latestPreparedHtmlArtifact,
} from "@/core/artifacts/write-file-in-flight";
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode"; import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
@@ -38,10 +35,8 @@ import {
buildSkillDisplayMap, buildSkillDisplayMap,
collectRuntimeDisplaySources, collectRuntimeDisplaySources,
} from "@/core/skill-display/runtime"; } from "@/core/skill-display/runtime";
import { isWordDocumentFile } from "@/core/utils/files";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import { import {
ArtifactFileDetail, ArtifactFileDetail,
@@ -88,12 +83,10 @@ const ChatBox: React.FC<{
children: React.ReactNode; children: React.ReactNode;
threadId: string; threadId: string;
disableArtifacts?: boolean; disableArtifacts?: boolean;
writingMode?: boolean;
}> = ({ }> = ({
children, children,
threadId, threadId,
disableArtifacts = false, disableArtifacts = false,
writingMode,
}) => { }) => {
const { const {
isMock, isMock,
@@ -113,17 +106,15 @@ const ChatBox: React.FC<{
const latestReferenceBatchIdRef = useRef<string | null>(null); const latestReferenceBatchIdRef = useRef<string | null>(null);
const manualReferenceBatchRef = useRef(false); const manualReferenceBatchRef = useRef(false);
const referenceDebugSignatureRef = useRef<string>(""); const referenceDebugSignatureRef = useRef<string>("");
const referenceBatchesRef = useRef<BackendReferenceBatch[]>([]);
const [sideTab, setSideTab] = useState("document"); const [sideTab, setSideTab] = useState("document");
const [referencesOpen, setReferencesOpen] = useState(true); const [referencesOpen, setReferencesOpen] = useState(true);
const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null); const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null);
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null); const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
const [wikiCitationSource, setWikiCitationSource] = useState<BackendReferenceSource | null>(null);
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]); const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
const [settings] = useLocalSettings(); const [settings] = useLocalSettings();
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持 // 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。 // 原有行为(新产物自动打开 + in-flight 预览不过滤)。
const writingModeEnabled = writingMode ?? !!settings.context.writing_mode; const writingModeEnabled = !!settings.context.writing_mode;
const artifactDirectoryModeEnabled = const artifactDirectoryModeEnabled =
settings.display.artifactDirectoryModeEnabled ?? false; settings.display.artifactDirectoryModeEnabled ?? false;
const { data: citationDisplay } = useCitationDisplaySettings(); const { data: citationDisplay } = useCitationDisplaySettings();
@@ -360,9 +351,6 @@ const ChatBox: React.FC<{
latestReferenceBatch; latestReferenceBatch;
const referenceSources = activeReferenceBatch?.sources ?? []; const referenceSources = activeReferenceBatch?.sources ?? [];
const hasCitationReferences = referenceSources.length > 0; const hasCitationReferences = referenceSources.length > 0;
useEffect(() => {
referenceBatchesRef.current = referenceBatches;
}, [referenceBatches]);
useEffect(() => { useEffect(() => {
if (!shouldFetchBackendReferenceBatches || !activeReferenceBatch) { if (!shouldFetchBackendReferenceBatches || !activeReferenceBatch) {
return; return;
@@ -408,16 +396,6 @@ const ChatBox: React.FC<{
window.setTimeout(() => { window.setTimeout(() => {
setPinnedReferenceId(detail?.referenceId ?? null); setPinnedReferenceId(detail?.referenceId ?? null);
}, 0); }, 0);
const referenceIndex = Number(detail?.referenceId);
const batch = referenceBatchesRef.current.find((item) => item.id === detail?.batchId)
?? referenceBatchesRef.current.at(-1);
const source = Number.isInteger(referenceIndex) && referenceIndex > 0
? batch?.sources.find((item) => item.index === referenceIndex)
?? batch?.sources[referenceIndex - 1]
: undefined;
if (source?.skillName?.startsWith("llmwiki_") || source?.type?.startsWith("llmwiki_")) {
setWikiCitationSource(source);
}
setReferencesOpen(true); setReferencesOpen(true);
setSideTab("references"); setSideTab("references");
}; };
@@ -481,12 +459,7 @@ const ChatBox: React.FC<{
) { ) {
if (thread?.values?.artifacts?.length > 0) { if (thread?.values?.artifacts?.length > 0) {
setAutoSelectFirstArtifact(false); setAutoSelectFirstArtifact(false);
const firstPreviewableArtifact = thread.values.artifacts.find( selectArtifact(thread.values.artifacts[0]!);
(artifact) => !isWordDocumentFile(artifact),
);
if (firstPreviewableArtifact) {
selectArtifact(firstPreviewableArtifact);
}
} }
} }
}, [ }, [
@@ -524,25 +497,6 @@ const ChatBox: React.FC<{
setSideTab("document"); setSideTab("document");
}, [openStreamedArtifact, streamingWritePreview]); }, [openStreamedArtifact, streamingWritePreview]);
// page_report_template(prepare) creates the output HTML before content
// editing starts. Open that real artifact immediately; this also covers
// models that use str_replace or whose write_file arguments arrive at once.
const preparedHtmlPreview = useMemo(
() =>
disableArtifacts || !thread.isLoading
? null
: latestPreparedHtmlArtifact(thread.messages ?? []),
[disableArtifacts, thread.isLoading, thread.messages],
);
useEffect(() => {
if (!preparedHtmlPreview) return;
openStreamedArtifact(
preparedHtmlPreview.artifactPath,
preparedHtmlPreview.previewKey,
);
setSideTab("document");
}, [openStreamedArtifact, preparedHtmlPreview]);
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled); const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
useEffect(() => { useEffect(() => {
const wasEnabled = directoryModeWasEnabledRef.current; const wasEnabled = directoryModeWasEnabledRef.current;
@@ -550,8 +504,7 @@ const ChatBox: React.FC<{
if ( if (
wasEnabled || wasEnabled ||
!artifactDirectoryModeEnabled || !artifactDirectoryModeEnabled ||
availableArtifacts.length === 0 || availableArtifacts.length === 0
isWordDocumentFile(availableArtifacts[availableArtifacts.length - 1]!)
) { ) {
return; return;
} }
@@ -791,17 +744,6 @@ const ChatBox: React.FC<{
</div> </div>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
<WeKnoraSourceDrawer
open={wikiCitationSource !== null}
target={null}
title={wikiCitationSource?.title || "Wiki 文档"}
quote={wikiCitationSource?.content || wikiCitationSource?.snippet || ""}
inlineContent={wikiCitationSource?.content || wikiCitationSource?.snippet || ""}
inlineKnowledgeBaseId={String(wikiCitationSource?.raw?.knowledge_base_id || "")}
onOpenChange={(open) => {
if (!open) setWikiCitationSource(null);
}}
/>
</ReferenceBatchesContext.Provider> </ReferenceBatchesContext.Provider>
); );
}; };
@@ -814,23 +756,19 @@ function ReferencePanel({
sources: Parameters<typeof SkillDisplaySources>[0]["sources"]; sources: Parameters<typeof SkillDisplaySources>[0]["sources"];
}) { }) {
const orderedSources = useMemo(() => { const orderedSources = useMemo(() => {
// This is presentation-only. Keep every source's original `index`, since if (!highlightedReferenceId) {
// inline citations resolve through that index (for example [3] must still return sources;
// open the third source returned by retrieval even when its card is shown }
// below a higher-scoring result). const index = Number(highlightedReferenceId);
return sources if (!Number.isFinite(index)) {
.map((source, originalOrder) => ({ source, originalOrder })) return sources;
.sort((left, right) => { }
const leftScore = typeof left.source.score === "number" && Number.isFinite(left.source.score) const selected = sources.find((source) => source.index === index);
? left.source.score if (!selected) {
: Number.NEGATIVE_INFINITY; return sources;
const rightScore = typeof right.source.score === "number" && Number.isFinite(right.source.score) }
? right.source.score return [selected, ...sources.filter((source) => source.index !== index)];
: Number.NEGATIVE_INFINITY; }, [highlightedReferenceId, sources]);
return rightScore - leftScore || left.originalOrder - right.originalOrder;
})
.map(({ source }) => source);
}, [sources]);
return ( return (
<div className="flex size-full flex-col overflow-hidden"> <div className="flex size-full flex-col overflow-hidden">
@@ -29,14 +29,13 @@ import {
PinOff, PinOff,
Plus, Plus,
Search, Search,
Settings,
Share2, Share2,
Star, Star,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation"; import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -61,12 +60,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs"; import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useAgentsPaginated, type Agent } from "@/core/agents"; import { useAgentsPaginated, type Agent } from "@/core/agents";
import { getAccountDisplayName } from "@/core/auth";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
@@ -81,7 +75,6 @@ import {
useToggleThreadPin, useToggleThreadPin,
} from "@/core/threads/hooks"; } from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import { import {
isBootstrapThread, isBootstrapThread,
isHiddenFromRecentChats, isHiddenFromRecentChats,
@@ -94,13 +87,6 @@ import {
} from "@/core/threads/utils"; } from "@/core/threads/utils";
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route"; import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
import { useRouteBase } from "@/core/route-base"; import { useRouteBase } from "@/core/route-base";
import {
usePublishSquares,
useUpdateWxCommonAgents,
useWxCommonAgents,
} from "@/core/system-settings/hooks";
import { SENTIMENT_AGENT_ID, isSentimentAgentVisible } from "@/core/sentiment-agent/config";
import { readSentimentAgentProfile } from "@/core/sentiment-agent/profile";
import { env } from "@/env"; import { env } from "@/env";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime"; import { isIMEComposing } from "@/lib/ime";
@@ -121,23 +107,20 @@ function SectionLabel({
count, count,
collapsed, collapsed,
onToggle, onToggle,
onOpenSettings,
}: { }: {
label: string; label: string;
count?: number; count?: number;
collapsed: boolean; collapsed: boolean;
onToggle: () => void; onToggle: () => void;
onOpenSettings?: () => void;
}) { }) {
return ( return (
<div className="flex w-full items-center px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80">
<button <button
type="button" type="button"
onClick={onToggle} onClick={onToggle}
className="flex min-w-0 flex-1 items-center justify-between transition-colors hover:text-muted-foreground select-none" 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 min-w-0 items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="truncate">{label}</span> <span>{label}</span>
{count != null && ( {count != null && (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums"> <span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
{count} {count}
@@ -151,119 +134,10 @@ function SectionLabel({
)} )}
/> />
</button> </button>
{onOpenSettings && (
<button
type="button"
onClick={onOpenSettings}
className="ml-1 inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground"
aria-label="设置智能体类型"
title="设置智能体类型"
>
<Settings className="size-3.5" />
</button>
)}
</div>
); );
} }
/** Gallery category values persisted for the dedicated wx full-chat route. */ export function GeneralQaSidebar() {
const WX_AGENT_TYPE_MINE = "mine";
const WX_AGENT_TYPE_SENTIMENT = "sentiment";
const WX_AGENT_TYPE_SQUARE_PREFIX = "square:";
const WX_AGENT_TYPE_DEFAULT = "__default__";
type CommonAgent = Pick<Agent, "id" | "name" | "description">;
type WxAgentTypeOption = {
value: string;
label: string;
};
function wxSquareAgentType(squareId: string) {
return `${WX_AGENT_TYPE_SQUARE_PREFIX}${squareId}`;
}
function wxSquareIdOf(agentType: string): string | undefined {
return agentType.startsWith(WX_AGENT_TYPE_SQUARE_PREFIX)
? agentType.slice(WX_AGENT_TYPE_SQUARE_PREFIX.length)
: undefined;
}
function WxAgentTypeSettingsDialog({
open,
onOpenChange,
agentType,
options,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
agentType: string | null;
options: WxAgentTypeOption[];
}) {
const [selectedType, setSelectedType] = useState(
agentType ?? WX_AGENT_TYPE_DEFAULT,
);
const { mutateAsync: updateWxCommonAgents, isPending } =
useUpdateWxCommonAgents();
useEffect(() => {
if (open) setSelectedType(agentType ?? WX_AGENT_TYPE_DEFAULT);
}, [agentType, open]);
const handleSave = async () => {
try {
await updateWxCommonAgents(
selectedType === WX_AGENT_TYPE_DEFAULT ? null : selectedType,
);
toast.success("常用智能体类型已更新");
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存智能体类型失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>设置常用智能体类型</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<label htmlFor="wx-common-agent-type" className="text-sm font-medium">
智能体类型
</label>
<select
id="wx-common-agent-type"
value={selectedType}
onChange={(event) => setSelectedType(event.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleSave} disabled={isPending}>
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function GeneralQaSidebar({
wxCommonAgents,
}: {
/** Enables wx's shared Agent Gallery category selection. */
wxCommonAgents?: boolean;
} = {}) {
const { t } = useI18n(); const { t } = useI18n();
const routeBase = useRouteBase(); const routeBase = useRouteBase();
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。 // 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
@@ -285,59 +159,6 @@ export function GeneralQaSidebar({
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v)); localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
return !v; return !v;
}); });
const [agentTypeSettingsOpen, setAgentTypeSettingsOpen] = useState(false);
const { data: wxCommonAgentSettings } = useWxCommonAgents({
enabled: !!wxCommonAgents,
});
const { data: publishSquares } = usePublishSquares({
enabled: !!wxCommonAgents,
});
const isWxManager =
wxCommonAgents &&
getAccountDisplayName().toLowerCase() === "wx";
const sentimentVisible = isSentimentAgentVisible();
const wxCategoryOptions = useMemo<WxAgentTypeOption[]>(() => {
const options: WxAgentTypeOption[] = [
{ value: WX_AGENT_TYPE_MINE, label: "我的" },
...(publishSquares?.squares ?? []).map((square) => ({
value: wxSquareAgentType(square.id),
label: square.name,
})),
];
if (sentimentVisible) {
options.push({ value: WX_AGENT_TYPE_SENTIMENT, label: "舆情分析" });
}
return options;
}, [publishSquares?.squares, sentimentVisible]);
const wxAgentTypeOptions = useMemo<WxAgentTypeOption[]>(
() => [
{ value: WX_AGENT_TYPE_DEFAULT, label: "默认推荐(未设置)" },
...wxCategoryOptions,
],
[wxCategoryOptions],
);
const configuredWxAgentType = wxCommonAgentSettings?.agent_type ?? null;
const wxUsesCategoryFilter = !!configuredWxAgentType && wxCategoryOptions.some(
(option) => option.value === configuredWxAgentType,
);
const wxSquareId =
wxCommonAgents && wxUsesCategoryFilter && configuredWxAgentType
? wxSquareIdOf(configuredWxAgentType)
: undefined;
const wxShowsSentiment =
wxCommonAgents &&
wxUsesCategoryFilter &&
configuredWxAgentType === WX_AGENT_TYPE_SENTIMENT;
const [sentimentProfile, setSentimentProfile] = useState(() =>
readSentimentAgentProfile(),
);
useEffect(() => {
const refresh = () => setSentimentProfile(readSentimentAgentProfile());
window.addEventListener("sentiment-agent-profile-changed", refresh);
return () =>
window.removeEventListener("sentiment-agent-profile-changed", refresh);
}, []);
const toggleChats = () => const toggleChats = () =>
setChatsCollapsed((v) => { setChatsCollapsed((v) => {
localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v)); localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v));
@@ -345,38 +166,9 @@ export function GeneralQaSidebar({
}); });
// ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)── // ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)──
const { agents: mine } = useAgentsPaginated({ const { agents: mine } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
scope: "mine", const { agents: square } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
page: 1, const frequent = useMemo<Agent[]>(() => {
pageSize: 20,
enabled: !wxUsesCategoryFilter,
});
const { agents: square } = useAgentsPaginated({
scope: "square",
page: 1,
pageSize: 20,
enabled: !wxUsesCategoryFilter,
});
const { agents: wxAgents } = useAgentsPaginated({
scope: wxSquareId ? "square" : "mine",
page: 1,
pageSize: MAX_FREQUENT,
square: wxSquareId,
enabled: wxUsesCategoryFilter && !wxShowsSentiment,
});
const frequent = useMemo<CommonAgent[]>(() => {
if (wxUsesCategoryFilter) {
if (wxShowsSentiment) {
return [
{
id: SENTIMENT_AGENT_ID,
name: sentimentProfile.name,
description: sentimentProfile.description,
},
];
}
return wxAgents.slice(0, MAX_FREQUENT);
}
const out: Agent[] = []; const out: Agent[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
const add = (agent: Agent | undefined) => { const add = (agent: Agent | undefined) => {
@@ -388,7 +180,7 @@ export function GeneralQaSidebar({
mine.forEach(add); mine.forEach(add);
square.forEach(add); square.forEach(add);
return out; return out;
}, [mine, sentimentProfile, square, wxAgents, wxShowsSentiment, wxUsesCategoryFilter]); }, [mine, square]);
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。 // 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -408,25 +200,16 @@ export function GeneralQaSidebar({
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。 // 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
const handleSelectAgent = useCallback( const handleSelectAgent = useCallback(
(agentId: string) => { (agentId: string) => {
if (agentId === SENTIMENT_AGENT_ID) {
navigate( navigate(
buildFullChatNavigationPath( buildFullChatNavigationPath(
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats/new`, routeBase === "/page/workspace"
currentSearch,
),
);
return;
}
navigate(
buildFullChatNavigationPath(
routeBase === "/page/workspace" || wxCommonAgents
? `${newChatPath}?agent=${encodeURIComponent(agentId)}` ? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`, : `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
currentSearch, currentSearch,
), ),
); );
}, },
[currentSearch, navigate, newChatPath, routeBase, wxCommonAgents], [currentSearch, navigate, newChatPath, routeBase],
); );
// ── 最近的对话取数 + 搜索 ── // ── 最近的对话取数 + 搜索 ──
@@ -571,24 +354,17 @@ export function GeneralQaSidebar({
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2"> <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{/* ── 常用智能体 ── */} {/* ── 常用智能体 ── */}
{(frequent.length > 0 || isWxManager) && ( {frequent.length > 0 && (
<div className="pt-2"> <div className="pt-2">
<SectionLabel <SectionLabel
label="常用智能体" label="常用智能体"
count={wxUsesCategoryFilter ? frequent.length : undefined}
collapsed={agentsCollapsed} collapsed={agentsCollapsed}
onToggle={toggleAgents} onToggle={toggleAgents}
onOpenSettings={isWxManager ? () => setAgentTypeSettingsOpen(true) : undefined}
/> />
{!agentsCollapsed && ( {!agentsCollapsed && (
<div className="mt-1 flex flex-col gap-1"> <div className="mt-1 flex flex-col gap-1">
{frequent.map((agent) => { {frequent.map((agent) => {
const isActive = const isActive = activeAgentId === agent.id;
activeAgentId === agent.id ||
(agent.id === SENTIMENT_AGENT_ID &&
pathname.startsWith(
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats`,
));
const { from, to, foreground } = agentColor(agent.id, theme); const { from, to, foreground } = agentColor(agent.id, theme);
return ( return (
<button <button
@@ -707,7 +483,6 @@ export function GeneralQaSidebar({
); );
const isActive = href === pathname; const isActive = href === pathname;
const isBoot = isBootstrapThread(thread); const isBoot = isBootstrapThread(thread);
const projectType = myProjectConversationTypeOfThread(thread);
const pinned = isThreadPinned(thread); const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread); const favorited = isThreadFavorite(thread);
return ( return (
@@ -732,8 +507,6 @@ export function GeneralQaSidebar({
)} )}
aria-label="智能体创建会话" aria-label="智能体创建会话"
/> />
) : projectType ? (
<MyProjectThreadIcon type={projectType} />
) : ( ) : (
<MessageSquare <MessageSquare
className={cn( className={cn(
@@ -745,15 +518,9 @@ export function GeneralQaSidebar({
{pinned && ( {pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" /> <Pin className="size-3 shrink-0 text-muted-foreground/70" />
)} )}
<span className="min-w-0 flex-1 truncate"> <span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))} {desensitize(titleOfThread(thread, t.pages.newChat))}
</span> </span>
{projectType && (
<MyProjectThreadBadge
className="mr-4"
type={projectType}
/>
)}
{isActive && ( {isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" /> <span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)} )}
@@ -884,14 +651,6 @@ export function GeneralQaSidebar({
if (!o) setShareThreadId(null); if (!o) setShareThreadId(null);
}} }}
/> />
{wxCommonAgents && isWxManager && (
<WxAgentTypeSettingsDialog
open={agentTypeSettingsOpen}
onOpenChange={setAgentTypeSettingsOpen}
agentType={wxUsesCategoryFilter ? configuredWxAgentType : null}
options={wxAgentTypeOptions}
/>
)}
</div> </div>
); );
} }
@@ -2,6 +2,7 @@
import type { ChatStatus } from "ai"; import type { ChatStatus } from "ai";
import { import {
BrainCircuitIcon,
BookMarkedIcon, BookMarkedIcon,
CheckIcon, CheckIcon,
FileTextIcon, FileTextIcon,
@@ -100,11 +101,26 @@ import {
import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu"; import { CustomPromptBar } from "./custom-prompt-menu";
import { resolveInputMode, type InputMode } from "./input-mode";
import { useThread } from "./messages/context"; import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide"; import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip"; import { Tooltip } from "./tooltip";
type InputMode = "flash" | "thinking" | "pro" | "ultra";
function getResolvedMode(
mode: InputMode | undefined,
supportsThinking: boolean,
): InputMode {
if (!supportsThinking && mode !== "flash") {
return "flash";
}
if (mode) {
return mode;
}
return supportsThinking ? "pro" : "flash";
}
export function InputBox({ export function InputBox({
className, className,
disabled, disabled,
@@ -122,12 +138,12 @@ export function InputBox({
promptPrefix, promptPrefix,
agentSelector, agentSelector,
knowledgeSelector, knowledgeSelector,
knowledgeSuggestedQuestions = [],
extraModels = [], extraModels = [],
assistantId, assistantId,
hideAdminRecommendedQuestions, hideAdminRecommendedQuestions,
compact, compact,
hideContextTools, hideContextTools,
hideMemoryTool = true,
...props ...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & { }: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null; assistantId?: string | null;
@@ -135,9 +151,14 @@ export function InputBox({
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框, /** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */ * 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
compact?: boolean; compact?: boolean;
/** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等 /** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
* 受控场景用;文件/模型等其他按钮不受影响)。 */ * 受控场景用;文件/模型等其他按钮不受影响)。 */
hideContextTools?: boolean; hideContextTools?: boolean;
/**
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
* 写作和参考文献开关不受影响。
*/
hideMemoryTool?: boolean;
disabled?: boolean; disabled?: boolean;
context: Omit< context: Omit<
AgentThreadContext, AgentThreadContext,
@@ -146,6 +167,7 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined; mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean; writing_mode?: boolean;
memory_injection_enabled?: boolean;
}; };
extraHeader?: React.ReactNode; extraHeader?: React.ReactNode;
isNewThread?: boolean; isNewThread?: boolean;
@@ -162,6 +184,7 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined; mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean; writing_mode?: boolean;
memory_injection_enabled?: boolean;
}, },
) => void; ) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void; onFollowupsVisibilityChange?: (visible: boolean) => void;
@@ -188,8 +211,6 @@ export function InputBox({
}; };
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */ /** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
knowledgeSelector?: ReactNode; knowledgeSelector?: ReactNode;
/** High-value starter questions derived from the currently selected spaces. */
knowledgeSuggestedQuestions?: string[];
/** Page-scoped model choices implemented outside the DeerFlow model API. */ /** Page-scoped model choices implemented outside the DeerFlow model API. */
extraModels?: Model[]; extraModels?: Model[];
}) { }) {
@@ -228,7 +249,6 @@ export function InputBox({
const userPreferences = useUserPreferences(); const userPreferences = useUserPreferences();
const showAdminRecommendedQuestions = const showAdminRecommendedQuestions =
!hideAdminRecommendedQuestions && !hideAdminRecommendedQuestions &&
knowledgeSuggestedQuestions.length === 0 &&
!userPreferences.hide_admin_prompt_prefix && !userPreferences.hide_admin_prompt_prefix &&
recommendedQuestions.length > 0; recommendedQuestions.length > 0;
@@ -245,7 +265,7 @@ export function InputBox({
const fallbackModel = currentModel ?? modelOptions[0]!; const fallbackModel = currentModel ?? modelOptions[0]!;
const supportsThinking = fallbackModel.supports_thinking ?? false; const supportsThinking = fallbackModel.supports_thinking ?? false;
const nextModelName = fallbackModel.name; const nextModelName = fallbackModel.name;
const nextMode = resolveInputMode(context.mode, supportsThinking); const nextMode = getResolvedMode(context.mode, supportsThinking);
if ( if (
context.model_name === nextModelName && context.model_name === nextModelName &&
@@ -292,7 +312,7 @@ export function InputBox({
onContextChange?.({ onContextChange?.({
...context, ...context,
model_name, model_name,
mode: resolveInputMode(context.mode, model.supports_thinking ?? false), mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: context.reasoning_effort, reasoning_effort: context.reasoning_effort,
execution_profile: model.execution_profile, execution_profile: model.execution_profile,
}); });
@@ -308,7 +328,7 @@ export function InputBox({
onContextChange?.({ onContextChange?.({
...context, ...context,
model_name: ordinaryModel?.name ?? context.model_name, model_name: ordinaryModel?.name ?? context.model_name,
mode: resolveInputMode( mode: getResolvedMode(
mode, mode,
ordinaryModel?.supports_thinking ?? supportThinking, ordinaryModel?.supports_thinking ?? supportThinking,
), ),
@@ -337,7 +357,7 @@ export function InputBox({
onContextChange?.({ onContextChange?.({
...context, ...context,
model_name: ragModel.name, model_name: ragModel.name,
mode: resolveInputMode( mode: getResolvedMode(
context.mode, context.mode,
ragModel.supports_thinking ?? false, ragModel.supports_thinking ?? false,
), ),
@@ -377,6 +397,14 @@ export function InputBox({
}); });
}, [artifactDirectoryModeEnabled, setLocalSettings]); }, [artifactDirectoryModeEnabled, setLocalSettings]);
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
const handleMemoryInjectionToggle = useCallback(() => {
onContextChange?.({
...context,
memory_injection_enabled: !memoryInjectionEnabled,
});
}, [context, memoryInjectionEnabled, onContextChange]);
const ragModeLocked = isThreadRagModeLocked(threadId); const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked; const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
const handleRagModeToggle = useCallback(() => { const handleRagModeToggle = useCallback(() => {
@@ -416,7 +444,7 @@ export function InputBox({
onContextChange?.({ onContextChange?.({
...context, ...context,
model_name: resolvedModelName, model_name: resolvedModelName,
mode: resolveInputMode( mode: getResolvedMode(
context.mode, context.mode,
selectedModel?.supports_thinking ?? false, selectedModel?.supports_thinking ?? false,
), ),
@@ -594,28 +622,6 @@ export function InputBox({
</div> </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]"> <div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
{recommendedQuestions.map((q) => ( {recommendedQuestions.map((q) => (
@@ -727,12 +733,12 @@ export function InputBox({
</PromptInputBody> </PromptInputBody>
<PromptInputFooter <PromptInputFooter
className={cn( 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", compact && "gap-2 px-3 pt-1.5 pb-3",
)} )}
> >
<PromptInputTools <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 {/* TODO: Add more connectors here
<PromptInputActionMenu> <PromptInputActionMenu>
@@ -807,6 +813,32 @@ export function InputBox({
<span className="text-xs font-normal">文件</span> <span className="text-xs font-normal">文件</span>
</PromptInputButton> </PromptInputButton>
</Tooltip>} </Tooltip>}
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
content={
memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆"
: "记忆注入已关闭:本次对话不注入记忆"
}
>
<PromptInputButton
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
memoryInjectionEnabled
? "border border-blue-300 bg-blue-50 text-blue-700 ring-1 ring-blue-300/35 hover:bg-blue-100 dark:border-cyan-300/70 dark:bg-cyan-400/20 dark:text-cyan-50 dark:ring-cyan-300/35 dark:shadow-[0_0_0_1px_rgba(34,211,238,0.18),0_0_18px_rgba(34,211,238,0.18)] dark:hover:bg-cyan-400/25"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleMemoryInjectionToggle}
>
{memoryInjectionEnabled && (
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" />
)}
<BrainCircuitIcon className="size-3" />
<span className="text-xs font-normal">记忆</span>
</PromptInputButton>
</Tooltip>}
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip {referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
content={ content={
ragModeEnabled ragModeEnabled
@@ -1246,7 +1278,7 @@ export function InputBox({
</PromptInputActionMenu> </PromptInputActionMenu>
)} )}
</PromptInputTools> </PromptInputTools>
<PromptInputTools className={cn("ml-auto shrink-0 gap-1.5", compact && "gap-2")}> <PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
{!frontendRagActive && agentSelector && ( {!frontendRagActive && agentSelector && (
<AgentSelector <AgentSelector
selectedAgentId={agentSelector.selectedAgentId} selectedAgentId={agentSelector.selectedAgentId}
@@ -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 SelectorSource = "all" | "agent";
type SelectorVariant = "popover" | "panel"; type SelectorVariant = "popover" | "panel";
const EMPTY_IDS: string[] = [];
export function buildKnowledgeSpaceQuestions(names: string[]): string[] {
const cleanNames = names.map((name) => name.trim()).filter(Boolean);
if (cleanNames.length === 0) return [];
if (cleanNames.length === 1) {
const name = cleanNames[0]!;
return [
`请概括「${name}」知识空间的主要内容、适用场景和资料边界。`,
`「${name}」包含哪些核心主题?请按重要性说明并引用来源。`,
`请梳理「${name}」中的关键实体、概念及它们之间的关系。`,
`根据「${name}」,有哪些值得关注的问题、风险或行动建议?`,
];
}
return [
`请综合所选 ${cleanNames.length} 个知识空间,总结共同主题、差异和互补信息。`,
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点,并引用各自来源。`,
"请梳理所选知识空间中的关键实体、概念及跨库关系。",
"所选知识空间中有哪些相互冲突或证据不足的结论?",
];
}
function groupRows(rows: LlmWikiKnowledgeBase[]) { function groupRows(rows: LlmWikiKnowledgeBase[]) {
return [ return [
@@ -344,14 +323,9 @@ function SelectionContent({
</div> </div>
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2"> <div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
<div> <span className="text-[11px] text-muted-foreground">
<div className="text-[11px] text-muted-foreground">
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`} {readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
</div> </span>
{selectedCount > 0 && (
<div className="mt-0.5 text-[10px] text-primary">仅检索已选知识空间,不联网</div>
)}
</div>
{readOnly ? ( {readOnly ? (
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span> <span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
) : ( ) : (
@@ -389,10 +363,9 @@ function SelectionContent({
export function LlmWikiKnowledgeSelector({ export function LlmWikiKnowledgeSelector({
value, value,
assistantValue = EMPTY_IDS, assistantValue = [],
onChange, onChange,
onAssistantChange = () => undefined, onAssistantChange = () => undefined,
onSuggestedQuestionsChange,
source = "all", source = "all",
agentId, agentId,
autoSelectAll = false, autoSelectAll = false,
@@ -400,15 +373,12 @@ export function LlmWikiKnowledgeSelector({
readOnly = false, readOnly = false,
className, className,
label = "知识空间", label = "知识空间",
panelTitle = "绑定知识空间",
panelDescription = "新建该智能体对话时,只能从已绑定空间中选择本次检索范围。",
variant = "popover", variant = "popover",
}: { }: {
value: string[]; value: string[];
assistantValue?: string[]; assistantValue?: string[];
onChange: (value: string[]) => void; onChange: (value: string[]) => void;
onAssistantChange?: (value: string[]) => void; onAssistantChange?: (value: string[]) => void;
onSuggestedQuestionsChange?: (questions: string[]) => void;
source?: SelectorSource; source?: SelectorSource;
agentId?: string | null; agentId?: string | null;
autoSelectAll?: boolean; autoSelectAll?: boolean;
@@ -417,38 +387,23 @@ export function LlmWikiKnowledgeSelector({
readOnly?: boolean; readOnly?: boolean;
className?: string; className?: string;
label?: string; label?: string;
panelTitle?: string;
panelDescription?: string;
variant?: SelectorVariant; variant?: SelectorVariant;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const runtime = useLlmWikiRuntime(); const runtime = useLlmWikiRuntime();
const weknoraEnabled = runtime.data?.provider === "weknora"; const weknoraEnabled = runtime.data?.provider === "weknora";
const shouldLoadOptions =
variant === "panel" ||
open ||
autoSelectAll ||
value.length > 0 ||
assistantValue.length > 0;
const all = useLlmWikiKnowledgeBases( const all = useLlmWikiKnowledgeBases(
"all", "all",
weknoraEnabled && shouldLoadOptions, weknoraEnabled,
); );
const agent = useAgentLlmWikiKnowledgeBases( const agent = useAgentLlmWikiKnowledgeBases(
agentId, agentId,
weknoraEnabled && shouldLoadOptions && source === "agent" && !!agentId, weknoraEnabled && source === "agent" && !!agentId,
); );
const assistantBases = useAssistantKnowledgeBases( const assistantBases = useAssistantKnowledgeBases();
shouldLoadOptions || const rows = weknoraEnabled ? (all.data ?? []) : [];
runtime.isError || const assistantRows = assistantBases.data ?? [];
(!runtime.isLoading && !weknoraEnabled),
);
// Stable empty results are essential: suggested questions are reported to
// the parent, so a fresh [] on every render creates an effect/update loop
// before lazy knowledge queries have loaded (or when they are disabled).
const rows = useMemo(() => weknoraEnabled ? (all.data ?? []) : [], [all.data, weknoraEnabled]);
const assistantRows = useMemo(() => assistantBases.data ?? [], [assistantBases.data]);
const defaultIds = useMemo(() => { const defaultIds = useMemo(() => {
if (source !== "agent") { if (source !== "agent") {
return rows.map((item) => item.id); return rows.map((item) => item.id);
@@ -464,26 +419,6 @@ export function LlmWikiKnowledgeSelector({
); );
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`; const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
const initialized = useRef<string | null>(null); const initialized = useRef<string | null>(null);
const selectedNames = useMemo(
() => value.map((id) => rows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
[rows, value],
);
const selectedAssistantNames = useMemo(
() => assistantValue.map((id) => assistantRows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
[assistantRows, assistantValue],
);
const allSelectedNames = useMemo(
() => [...selectedNames, ...selectedAssistantNames],
[selectedAssistantNames, selectedNames],
);
const suggestedQuestions = useMemo(
() => buildKnowledgeSpaceQuestions(allSelectedNames),
[allSelectedNames],
);
useEffect(() => {
onSuggestedQuestionsChange?.(suggestedQuestions);
}, [onSuggestedQuestionsChange, suggestedQuestions]);
useEffect(() => { useEffect(() => {
if (!runtime.isLoading && !weknoraEnabled) { if (!runtime.isLoading && !weknoraEnabled) {
@@ -513,12 +448,7 @@ export function LlmWikiKnowledgeSelector({
if (!open) setSearch(""); if (!open) setSearch("");
}, [open]); }, [open]);
if ( if ((runtime.isError || !weknoraEnabled) && !assistantBases.isLoading && assistantRows.length === 0) return null;
!runtime.isLoading &&
(runtime.isError || !weknoraEnabled) &&
!assistantBases.isLoading &&
assistantRows.length === 0
) return null;
const content = ( const content = (
<SelectionContent <SelectionContent
@@ -544,10 +474,10 @@ export function LlmWikiKnowledgeSelector({
<div className="flex items-start justify-between gap-4 border-b px-4 py-3"> <div className="flex items-start justify-between gap-4 border-b px-4 py-3">
<div> <div>
<div className="flex items-center gap-2 text-sm font-semibold"> <div className="flex items-center gap-2 text-sm font-semibold">
<Database className="size-4 text-primary" />{panelTitle} <Database className="size-4 text-primary" />绑定知识空间
</div> </div>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{panelDescription} 新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
</p> </p>
</div> </div>
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge> <Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
@@ -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 selectedCount = value.length + assistantValue.length;
const selectedLabel = allSelectedNames.length > 0 const selectedLabel = allSelectedNames.length > 0
? allSelectedNames.join("、") ? allSelectedNames.join("、")
@@ -20,9 +20,6 @@ import {
} from "@/core/utils/markdown"; } from "@/core/utils/markdown";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { VisualizationBlock } from "../visualizations/visualization-block";
import { splitVisualizationContent } from "../visualizations/visualization-spec";
import { CitationLink } from "../citations/citation-link"; import { CitationLink } from "../citations/citation-link";
function isExternalUrl(href: string | undefined): boolean { function isExternalUrl(href: string | undefined): boolean {
@@ -53,7 +50,6 @@ export type MarkdownContentProps = {
/** Renders markdown content. */ /** Renders markdown content. */
export function MarkdownContent({ export function MarkdownContent({
content, content,
isLoading,
rehypePlugins, rehypePlugins,
className, className,
remarkPlugins = streamdownPlugins.remarkPlugins, remarkPlugins = streamdownPlugins.remarkPlugins,
@@ -63,7 +59,6 @@ export function MarkdownContent({
}: MarkdownContentProps) { }: MarkdownContentProps) {
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]); const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
const visualizationSegments = useMemo(() => splitVisualizationContent(content), [content]);
useStreamdownMermaidPngDownload(rootRef); useStreamdownMermaidPngDownload(rootRef);
const components = useMemo(() => { const components = useMemo(() => {
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => { const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
@@ -144,12 +139,6 @@ export function MarkdownContent({
if (!content) return null; if (!content) return null;
// Preserve the exact legacy rendering path for ordinary Markdown. Heavy
// visualization renderers are lazy-loaded only when a dedicated fence exists.
if (
visualizationSegments.length === 1 &&
visualizationSegments[0]?.type === "markdown"
) {
return ( return (
<div ref={rootRef} data-markdown-anchor-root className="contents"> <div ref={rootRef} data-markdown-anchor-root className="contents">
<MessageResponse <MessageResponse
@@ -163,32 +152,4 @@ export function MarkdownContent({
</MessageResponse> </MessageResponse>
</div> </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,
)}
</div>
);
} }
@@ -82,6 +82,7 @@ import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources"; import { SourceCard } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content"; import { MarkdownContent } from "./markdown-content";
import { SingleRoundRagCitationDialog } from "./message-list-item";
export function MessageGroup({ export function MessageGroup({
className, className,
@@ -137,6 +138,19 @@ export function MessageGroup({
} }
}, [lastToolCallStep, steps]); }, [lastToolCallStep, steps]);
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const [selectedCitation, setSelectedCitation] =
useState<RuntimeDisplaySource | null>(null);
const handleCitationClick = useCallback(
(referenceId: string) => {
const referenceIndex = Number(referenceId);
if (!Number.isInteger(referenceIndex) || referenceIndex < 1) return;
const source =
displaySources.find((item) => item.index === referenceIndex) ??
displaySources[referenceIndex - 1];
if (source) setSelectedCitation(source);
},
[displaySources],
);
const reasoningId = const reasoningId =
lastReasoningStep?.id ?? lastReasoningStep?.id ??
messages.find((message) => message.type === "ai")?.id; messages.find((message) => message.type === "ai")?.id;
@@ -186,6 +200,7 @@ export function MessageGroup({
displaySources={displaySources} displaySources={displaySources}
isLoading={isLoading} isLoading={isLoading}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
onCitationClick={handleCitationClick}
/> />
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
@@ -195,7 +210,6 @@ export function MessageGroup({
{...step} {...step}
isLoading={isLoading} isLoading={isLoading}
skillDisplayMap={skillDisplayMap} skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled} ragModeEnabled={ragModeEnabled}
/> />
), ),
@@ -208,7 +222,6 @@ export function MessageGroup({
isLast={true} isLast={true}
isLoading={isLoading} isLoading={isLoading}
skillDisplayMap={skillDisplayMap} skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled} ragModeEnabled={ragModeEnabled}
/> />
</FlipDisplay> </FlipDisplay>
@@ -249,6 +262,7 @@ export function MessageGroup({
displaySources={displaySources} displaySources={displaySources}
isLoading={isLoading} isLoading={isLoading}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
onCitationClick={handleCitationClick}
/> />
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
@@ -257,6 +271,12 @@ export function MessageGroup({
</> </>
)} )}
</ChainOfThought> </ChainOfThought>
<SingleRoundRagCitationDialog
source={selectedCitation}
onOpenChange={(open) => {
if (!open) setSelectedCitation(null);
}}
/>
</> </>
); );
} }
@@ -266,11 +286,13 @@ function StepReasoningMarkdown({
displaySources, displaySources,
isLoading, isLoading,
rehypePlugins, rehypePlugins,
onCitationClick,
}: { }: {
content: string; content: string;
displaySources: RuntimeDisplaySource[]; displaySources: RuntimeDisplaySource[];
isLoading: boolean; isLoading: boolean;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>; rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onCitationClick: (referenceId: string) => void;
}) { }) {
const markdown = useMemo( const markdown = useMemo(
() => () =>
@@ -286,13 +308,14 @@ function StepReasoningMarkdown({
<CitationLink <CitationLink
href={href} href={href}
{...props} {...props}
onReferenceClick={onCitationClick}
/> />
); );
} }
return <a href={href} {...props} />; return <a href={href} {...props} />;
}, },
}; };
}, [displaySources.length]); }, [displaySources.length, onCitationClick]);
return ( return (
<MarkdownContent <MarkdownContent
@@ -313,7 +336,6 @@ function ToolCall({
isLast = false, isLast = false,
isLoading = false, isLoading = false,
skillDisplayMap = new Map(), skillDisplayMap = new Map(),
displaySources = [],
ragModeEnabled = false, ragModeEnabled = false,
}: { }: {
id?: string; id?: string;
@@ -324,7 +346,6 @@ function ToolCall({
isLast?: boolean; isLast?: boolean;
isLoading?: boolean; isLoading?: boolean;
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>; skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean; ragModeEnabled?: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
@@ -335,25 +356,18 @@ function ToolCall({
null); null);
const displayItems = const displayItems =
displayMatch && result !== undefined displayMatch && result !== undefined
? syncDisplaySourceIndexes(rankDisplaySources(normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({ ? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item, ...item,
index: index + 1, index: index + 1,
skillName: displayMatch.skillName, skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode, mode: item.displayMode ?? displayMatch.display.mode,
}))), displaySources) }))
: []; : [];
const isLlmWikiTool = name === "llmwiki_search" || name === "llmwiki_read_wiki_page" || name === "llmwiki_browse_wiki_index"; const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
const wikiQuery = isLlmWikiTool ? resolveSearchQuery(args, result) : "";
const wikiSearchLabel = name === "llmwiki_search" const wikiSearchLabel = name === "llmwiki_search"
? wikiQuery ? wikiQuery
? `Wiki 文档检索 "${wikiQuery}"` ? `Wiki 文档检索 "${wikiQuery}"`
: "Wiki 文档检索" : "Wiki 文档检索"
: name === "llmwiki_read_wiki_page"
? wikiQuery
? `读取 Wiki 页面「${wikiQuery}」`
: "读取 Wiki 页面"
: name === "llmwiki_browse_wiki_index"
? "读取 Wiki 索引与知识库摘要"
: null; : null;
if (displayItems.length > 0) { if (displayItems.length > 0) {
return ( return (
@@ -380,12 +394,12 @@ function ToolCall({
const searchItems = normalizeSearchResults(result); const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) { if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
const defaultItems = syncDisplaySourceIndexes(rankDisplaySources(normalizeDefaultDisplaySources(result).map((item, index) => ({ const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
...item, ...item,
index: index + 1, index: index + 1,
skillName: name, skillName: name,
mode: "citation" as const, mode: "citation" as const,
}))), displaySources); }));
return ( return (
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}> <ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
<DisplayResultsList results={defaultItems} /> <DisplayResultsList results={defaultItems} />
@@ -847,18 +861,6 @@ type CoTStep = CoTReasoningStep | CoTToolCallStep;
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] { function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
const steps: 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) { for (const message of messages) {
if (message.type === "ai") { if (message.type === "ai") {
const reasoning = extractReasoningContentFromMessage(message); const reasoning = extractReasoningContentFromMessage(message);
@@ -904,7 +906,6 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
toolCallId, toolCallId,
messages, messages,
tool_call.name, tool_call.name,
{ allowNameFallback: toolCallNameCounts.get(tool_call.name.toLowerCase()) === 1 },
); );
if (toolCallResult !== undefined) { if (toolCallResult !== undefined) {
if (typeof toolCallResult === "string") { if (typeof toolCallResult === "string") {
@@ -952,24 +953,6 @@ function WebSearchResultsList({ results }: { results: SearchResultItem[] }) {
function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) { function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
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 ( return (
<Collapsible open={open} onOpenChange={setOpen} className="w-full"> <Collapsible open={open} onOpenChange={setOpen} className="w-full">
<CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs"> <CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs">
@@ -982,53 +965,14 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
<span>{results.length} 条真实返回结果</span> <span>{results.length} 条真实返回结果</span>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="mt-2 flex flex-col gap-2"> <CollapsibleContent className="mt-2 flex flex-col gap-2">
{orderedResults.map((item) => ( {results.map((item) => (
<SourceCard <SourceCard key={`${item.skillName}-${item.index}`} source={item} />
key={`${item.skillName}-${item.index}`}
source={item}
/>
))} ))}
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
); );
} }
function rankDisplaySources(
sources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
return sources
.map((source, originalOrder) => ({ source, originalOrder }))
.sort((left, right) => {
const leftScore =
typeof left.source.score === "number" && Number.isFinite(left.source.score)
? left.source.score
: Number.NEGATIVE_INFINITY;
const rightScore =
typeof right.source.score === "number" && Number.isFinite(right.source.score)
? right.source.score
: Number.NEGATIVE_INFINITY;
return rightScore - leftScore || left.originalOrder - right.originalOrder;
})
.map(({ source }, index) => ({ ...source, index: index + 1 }));
}
function syncDisplaySourceIndexes(
sources: RuntimeDisplaySource[],
globalSources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
const indexByCitationId = new Map(
globalSources.flatMap((source) => {
const id = typeof source.raw?.citation_id === "string" ? source.raw.citation_id : "";
return id ? [[id, source.index] as const] : [];
}),
);
return sources.map((source) => {
const id = typeof source.raw?.citation_id === "string" ? source.raw.citation_id : "";
const index = id ? indexByCitationId.get(id) : undefined;
return index ? { ...source, index } : source;
});
}
function SearchResultCard({ item }: { item: SearchResultItem }) { function SearchResultCard({ item }: { item: SearchResultItem }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const content = item.snippet ?? item.content; const content = item.snippet ?? item.content;
@@ -77,7 +77,6 @@ import {
isMultiRoundRagMessage, isMultiRoundRagMessage,
} from '@/core/threads/use-multi-round-rag'; } from '@/core/threads/use-multi-round-rag';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { WeKnoraSourceDrawer } from '@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer';
import { CitationLink } from '../citations/citation-link'; import { CitationLink } from '../citations/citation-link';
import { CopyButton } from '../copy-button'; import { CopyButton } from '../copy-button';
@@ -891,9 +890,9 @@ function FrontendRagSourcesPanel({
</span> </span>
)} )}
</div> </div>
{(source.content || source.snippet) && ( {source.snippet && (
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed"> <p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
{source.content || source.snippet} {source.snippet}
</p> </p>
)} )}
</button> </button>
@@ -910,24 +909,6 @@ export function SingleRoundRagCitationDialog({
source: RuntimeDisplaySource | null; source: RuntimeDisplaySource | null;
onOpenChange: (open: boolean) => void; 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 ( return (
<Dialog open={!!source} onOpenChange={onOpenChange}> <Dialog open={!!source} onOpenChange={onOpenChange}>
@@ -25,7 +25,6 @@ export function ModelNameSelect({
className, className,
triggerClassName, triggerClassName,
defaultLabel = "默认模型", defaultLabel = "默认模型",
title = "选择用于本次操作的大模型(默认使用系统配置的模型)",
}: { }: {
value: string | null; value: string | null;
onChange: (modelName: string | null) => void; onChange: (modelName: string | null) => void;
@@ -33,7 +32,6 @@ export function ModelNameSelect({
className?: string; className?: string;
triggerClassName?: string; triggerClassName?: string;
defaultLabel?: string; defaultLabel?: string;
title?: string;
}) { }) {
const { models } = useModels(); const { models } = useModels();
return ( return (
@@ -44,7 +42,7 @@ export function ModelNameSelect({
> >
<SelectTrigger <SelectTrigger
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)} className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
title={title} title="选择用于本次操作的大模型(默认使用系统配置的模型)"
> >
<SelectValue placeholder={defaultLabel} /> <SelectValue placeholder={defaultLabel} />
</SelectTrigger> </SelectTrigger>
@@ -1,51 +0,0 @@
import { FolderKanban } from "lucide-react";
import {
MY_PROJECT_CONVERSATION_LABELS,
type MyProjectConversationType,
} from "@/core/threads/project-metadata";
import { cn } from "@/lib/utils";
const BADGE_STYLES: Record<MyProjectConversationType, string> = {
qa: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-700/70 dark:bg-sky-950/55 dark:text-sky-300",
strategy:
"border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-700/70 dark:bg-violet-950/55 dark:text-violet-300",
solution:
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700/70 dark:bg-emerald-950/55 dark:text-emerald-300",
};
export function MyProjectThreadIcon({
type,
className,
}: {
type: MyProjectConversationType;
className?: string;
}) {
return (
<FolderKanban
aria-label={`我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
className={cn("size-3.5 shrink-0 text-violet-500", className)}
/>
);
}
export function MyProjectThreadBadge({
type,
className,
}: {
type: MyProjectConversationType;
className?: string;
}) {
return (
<span
className={cn(
"shrink-0 rounded border px-1 py-0.5 text-[9px] font-medium leading-none",
BADGE_STYLES[type],
className,
)}
title={`来自我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
>
{MY_PROJECT_CONVERSATION_LABELS[type]}
</span>
);
}
@@ -48,13 +48,8 @@ import {
SidebarMenuAction, SidebarMenuAction,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs"; import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
@@ -69,7 +64,6 @@ import {
useToggleThreadPin, useToggleThreadPin,
} from "@/core/threads/hooks"; } from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
import { import {
isBootstrapThread, isBootstrapThread,
isHiddenFromRecentChats, isHiddenFromRecentChats,
@@ -99,7 +93,6 @@ export function RecentChatList({
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { isMobile, setOpenMobile } = useSidebar();
const [internalCollapsed, setInternalCollapsed] = useState( const [internalCollapsed, setInternalCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true", () => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
); );
@@ -141,15 +134,15 @@ export function RecentChatList({
}); });
if (threadIndex > -1) { if (threadIndex > -1) {
if (threads[threadIndex + 1]) { if (threads[threadIndex + 1]) {
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!, pathname); nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
} else if (threads[threadIndex - 1]) { } else if (threads[threadIndex - 1]) {
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!, pathname); nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
} }
} }
void router.push(nextThreadPath); void router.push(nextThreadPath);
} }
}, },
[agentIdFromPath, deleteThread, pathname, router, threadIdFromPath, threads], [agentIdFromPath, deleteThread, router, threadIdFromPath, threads],
); );
const handleRenameClick = useCallback( const handleRenameClick = useCallback(
@@ -250,10 +243,9 @@ export function RecentChatList({
<SidebarMenu> <SidebarMenu>
<div className="flex w-full flex-col gap-0.5"> <div className="flex w-full flex-col gap-0.5">
{shownThreads.map((thread) => { {shownThreads.map((thread) => {
const href = listPathOfThread(thread, pathname); const href = listPathOfThread(thread);
const isActive = href === pathname; const isActive = href === pathname;
const isBoot = isBootstrapThread(thread); const isBoot = isBootstrapThread(thread);
const projectType = myProjectConversationTypeOfThread(thread);
const pinned = isThreadPinned(thread); const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread); const favorited = isThreadFavorite(thread);
return ( return (
@@ -276,7 +268,6 @@ export function RecentChatList({
<Link <Link
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden" className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={href} href={href}
onClick={() => { if (isMobile) setOpenMobile(false); }}
> >
{isBoot ? ( {isBoot ? (
<Bot <Bot
@@ -288,8 +279,6 @@ export function RecentChatList({
} }
aria-label="智能体创建会话" aria-label="智能体创建会话"
/> />
) : projectType ? (
<MyProjectThreadIcon type={projectType} />
) : ( ) : (
compact && ( compact && (
<MessageSquare <MessageSquare
@@ -305,15 +294,9 @@ export function RecentChatList({
{pinned && ( {pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" /> <Pin className="size-3 shrink-0 text-muted-foreground/70" />
)} )}
<span className="min-w-0 flex-1 truncate"> <span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))} {desensitize(titleOfThread(thread, t.pages.newChat))}
</span> </span>
{projectType && (
<MyProjectThreadBadge
className="mr-3"
type={projectType}
/>
)}
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && ( {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
@@ -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"; "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 { useEffect, useMemo, useState } from "react";
import { import {
@@ -13,7 +13,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page"; import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page"; import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page"; import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { 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 { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
@@ -61,12 +61,15 @@ export function SettingsDialog(props: SettingsDialogProps) {
// }, // },
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon }, // { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon }, // { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
...(isAdmin // 记忆已移至侧边栏入口 (/page/workspace/memory)
? [{ id: "memory", label: "问答记忆", icon: BrainIcon }] // {
: []), // id: "memory",
// label: t.settings.sections.memory,
// icon: BrainIcon,
// },
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon }, { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin ...(isAdmin
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }] ? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }]
: []), : []),
{ {
id: "account", id: "account",
@@ -129,9 +132,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "appearance" && ( {activeSection === "appearance" && (
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} /> <AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
)} )}
{activeSection === "memory" && isAdmin && ( {activeSection === "memory" && <MemorySettingsPage />}
<MemoryRuntimeSettingsPage />
)}
{activeSection === "tools" && <ToolSettingsPage />} {activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />} {activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
{activeSection === "about" && <AboutSettingsPage />} {activeSection === "about" && <AboutSettingsPage />}
@@ -9,10 +9,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime"; import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
resolveWeKnoraSourceTarget,
type WeKnoraSourceTarget,
} from "@/core/llmwiki/source-links";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { reasoningPlugins } from "@/core/streamdown"; import { reasoningPlugins } from "@/core/streamdown";
@@ -59,7 +56,6 @@ export function SourceCard({
}) { }) {
const [relatedOpen, setRelatedOpen] = useState(false); const [relatedOpen, setRelatedOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
const [linkedTarget, setLinkedTarget] = useState<WeKnoraSourceTarget | null>(null);
const [settings] = useLocalSettings(); const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings(); const { data: citationDisplay } = useCitationDisplaySettings();
const previewTarget = resolveWeKnoraSourceTarget({ const previewTarget = resolveWeKnoraSourceTarget({
@@ -69,11 +65,7 @@ export function SourceCard({
const cleanupWords = const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? []; citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const content = cleanReferenceText(source.content || source.snippet, cleanupWords); const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
const isWikiIndexSource =
source.type === "llmwiki_wiki_index_intro" ||
source.raw?.type === "llmwiki_wiki_index_intro";
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki"; const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
const opensWikiDrawer = Boolean(previewTarget) || isWikiIndexSource;
const matchedHeading = isWikiSource const matchedHeading = isWikiSource
? String( ? String(
(source.raw?.matched_heading as string | undefined) || (source.raw?.matched_heading as string | undefined) ||
@@ -86,9 +78,7 @@ export function SourceCard({
const summary = isWikiSource && typeof source.raw?.summary === "string" const summary = isWikiSource && typeof source.raw?.summary === "string"
? source.raw.summary ? source.raw.summary
: ""; : "";
// A retrieval card represents the matched passage. A document/Wiki summary const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
// is useful only when the result did not include the actual passage.
const snippet = plainTextReferencePreview(content || summary).slice(0, 280);
const score = const score =
typeof source.score === "number" typeof source.score === "number"
? source.score <= 1 ? source.score <= 1
@@ -105,13 +95,9 @@ export function SourceCard({
score ? `相关度 ${score}` : null, score ? `相关度 ${score}` : null,
].filter(Boolean); ].filter(Boolean);
const isCitation = source.mode === "citation"; const isCitation = source.mode === "citation";
// Tool-trace cards are evidence previews, not the answer's reference list. const showReferenceNumber = isCitation;
// Only the right-side reference panel receives `referenceTarget`, so keep
// citation numbers there and avoid presenting a second, misleading number
// in the middle retrieval trace.
const showReferenceNumber = isCitation && referenceTarget;
const openSource = () => { const openSource = () => {
if (opensWikiDrawer) { if (previewTarget) {
setDetailOpen(true); setDetailOpen(true);
} else { } else {
setRelatedOpen(true); setRelatedOpen(true);
@@ -188,18 +174,16 @@ export function SourceCard({
</div> </div>
)} )}
</div> </div>
{(opensWikiDrawer || linkedTarget) && ( {previewTarget && (
<WeKnoraSourceDrawer <WeKnoraSourceDrawer
open={detailOpen} open={detailOpen}
target={linkedTarget ?? previewTarget} target={previewTarget}
title={source.title} title={source.title}
quote={content} quote={content}
inlineContent={isWikiIndexSource ? content : ""}
inlineKnowledgeBaseId={String(source.raw?.knowledge_base_id || "")}
onOpenChange={setDetailOpen} onOpenChange={setDetailOpen}
/> />
)} )}
{!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"> <DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
<DialogHeader> <DialogHeader>
<div className="flex items-start justify-between gap-3 pr-8"> <div className="flex items-start justify-between gap-3 pr-8">
@@ -251,18 +235,6 @@ export function SourceCard({
isLoading={false} isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins} remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins} rehypePlugins={reasoningPlugins.rehypePlugins}
linkClassName={(href) =>
resolveWeKnoraSourceTarget({ url: href })?.wikiSlug
? "font-medium text-red-600 decoration-red-300 hover:text-red-700 hover:decoration-red-600 dark:text-red-400"
: ""
}
onLinkClick={(event, href) => {
const target = resolveWeKnoraSourceTarget({ url: href });
if (!target) return;
event.preventDefault();
setLinkedTarget(target);
setDetailOpen(true);
}}
/> />
</article> </article>
</div> </div>
@@ -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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"'": "&#39;",
'"': "&quot;",
})[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 }];
}
@@ -14,7 +14,7 @@ import {
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, FolderKanban, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react"; import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle"; import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header"; import { SidebarCollapseButton } from "./workspace-header";
@@ -24,10 +24,7 @@ import {
applyCompactMenuOverrides, applyCompactMenuOverrides,
type CompactMenuItem, type CompactMenuItem,
} from "@/core/page-layout/compact-menu-overrides"; } from "@/core/page-layout/compact-menu-overrides";
import { import { type MenuItem } from "@/core/page-layout/sidebar-menu";
MARKDOWN_DOCX_MENU_ITEM,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides"; import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标 // 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
@@ -54,13 +51,6 @@ export type WorkspaceCompactNavItem = MenuItem & {
}; };
export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
{
id: "workspace-projects",
label: "项目空间",
icon: BookOpen,
path: "/page/workspace/projects",
match: (pathname) => pathname.startsWith("/page/workspace/projects"),
},
{ {
id: "workspace-new-chat", id: "workspace-new-chat",
label: "新对话", label: "新对话",
@@ -75,15 +65,6 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/chats", path: "/page/workspace/chats",
match: (pathname) => pathname === "/page/workspace/chats", match: (pathname) => pathname === "/page/workspace/chats",
}, },
{
id: "strategy-projects",
label: "我的项目",
icon: FolderKanban,
path: "/page/strategy/qa/projects",
match: (pathname) =>
pathname.startsWith("/page/strategy/qa/projects") ||
pathname.startsWith("/page/strategy/qa/strategy-selection"),
},
{ {
id: "workspace-library", id: "workspace-library",
label: "资料库", label: "资料库",
@@ -105,6 +86,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/skills", path: "/page/workspace/skills",
match: (pathname) => pathname.startsWith("/page/workspace/skills"), match: (pathname) => pathname.startsWith("/page/workspace/skills"),
}, },
{
id: "workspace-memory",
label: "记忆",
icon: BrainIcon,
path: "/page/workspace/memory",
match: (pathname) => pathname.startsWith("/page/workspace/memory"),
},
{ {
id: "workspace-ai-writing", id: "workspace-ai-writing",
label: "AI写作", label: "AI写作",
@@ -152,13 +140,7 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
label: "知识库管理", label: "知识库管理",
icon: BookOpen, icon: BookOpen,
path: "/page/workspace/knowledge", path: "/page/workspace/knowledge",
match: (pathname) => match: (pathname) => pathname.startsWith("/page/workspace/knowledge"),
pathname.startsWith("/page/workspace/knowledge") &&
!pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
},
{
...MARKDOWN_DOCX_MENU_ITEM,
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
}, },
]; ];
@@ -21,14 +21,11 @@ import { WorkspaceNavMenu } from "./workspace-nav-menu";
export function WorkspaceSidebar({ export function WorkspaceSidebar({
compactChrome = false, compactChrome = false,
forceCollapsed = false, forceCollapsed = false,
wxCommonAgents = false,
className, className,
...props ...props
}: React.ComponentProps<typeof Sidebar> & { }: React.ComponentProps<typeof Sidebar> & {
compactChrome?: boolean; compactChrome?: boolean;
forceCollapsed?: boolean; forceCollapsed?: boolean;
/** Enables wx's shared Agent Gallery category selection. */
wxCommonAgents?: boolean;
}) { }) {
const { open: isSidebarOpen } = useSidebar(); const { open: isSidebarOpen } = useSidebar();
const { theme } = useTheme(); const { theme } = useTheme();
@@ -38,8 +35,6 @@ export function WorkspaceSidebar({
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是 // 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。 // 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa = const isGeneralQa =
wxCommonAgents ||
pathname.startsWith("/page/workspace/projects") ||
pathname.startsWith("/page/workspace/chats") || pathname.startsWith("/page/workspace/chats") ||
pathname.startsWith("/page/strategy/chats"); pathname.startsWith("/page/strategy/chats");
@@ -89,7 +84,7 @@ export function WorkspaceSidebar({
{isGeneralQa ? ( {isGeneralQa ? (
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成 // 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。 // GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
<GeneralQaSidebar wxCommonAgents={wxCommonAgents} /> <GeneralQaSidebar />
) : ( ) : (
<> <>
<WorkspaceNavChatList showCollapseTrigger compact /> <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;
}
+3 -24
View File
@@ -1,6 +1,5 @@
import { apiFetch } from "../api"; import { apiFetch } from "../api";
import { downloadMarkdownAsDocx } from "../scheduled-tasks/markdown-docx"; import { downloadMarkdownAsDocx } from "../scheduled-tasks/markdown-docx";
import { prepareGeneratedHtml } from "../utils/legacy-html-preview";
import { getFileName } from "../utils/files"; import { getFileName } from "../utils/files";
import { urlOfArtifact } from "./utils"; import { urlOfArtifact } from "./utils";
@@ -61,23 +60,12 @@ export async function downloadArtifactFile({
const filename = const filename =
filenameFromContentDisposition(cd) ?? getFileName(filepath); 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(); const blob = await res.blob();
downloadBlob = const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
const downloadBlob =
contentType && blob.type !== contentType contentType && blob.type !== contentType
? new Blob([await blob.arrayBuffer()], { type: contentType }) ? new Blob([await blob.arrayBuffer()], { type: contentType })
: blob; : blob;
}
const objectUrl = URL.createObjectURL(downloadBlob); const objectUrl = URL.createObjectURL(downloadBlob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = objectUrl; a.href = objectUrl;
@@ -123,16 +111,7 @@ export async function openArtifactInNewTab({
if (!res.ok) { if (!res.ok) {
throw new Error(await errorMessageFromResponse(res)); throw new Error(await errorMessageFromResponse(res));
} }
const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim(); const blob = await res.blob();
const isHtml = contentType === "text/html" || /\.html?$/i.test(filepath);
const blob = isHtml
? new Blob(
[
prepareGeneratedHtml(await res.text()),
],
{ type: "text/html;charset=utf-8" },
)
: await res.blob();
const objectUrl = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
const w = window.open(objectUrl, "_blank", "noopener,noreferrer"); const w = window.open(objectUrl, "_blank", "noopener,noreferrer");
if (w) w.opener = null; if (w) w.opener = null;
@@ -4,66 +4,9 @@ import test from "node:test";
import { import {
isWriteFileInFlight, isWriteFileInFlight,
latestInFlightWriteFilePreview, latestInFlightWriteFilePreview,
latestPreparedHtmlArtifact,
resolveWriteFilePath, resolveWriteFilePath,
} from "./write-file-in-flight"; } from "./write-file-in-flight";
test("latestPreparedHtmlArtifact opens the output created by template prepare", () => {
const preview = latestPreparedHtmlArtifact([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "tpl1",
name: "page_report_template",
args: { action: "prepare" },
},
],
},
{
type: "tool",
tool_call_id: "tpl1",
content: JSON.stringify({
ok: true,
working_copy: "/mnt/user-data/outputs/人工智能发展报告.html",
}),
},
]);
assert.deepEqual(preview, {
artifactPath: "/mnt/user-data/outputs/人工智能发展报告.html",
previewKey: "page-template:tpl1",
});
});
test("latestPreparedHtmlArtifact ignores workspace and failed prepares", () => {
assert.equal(
latestPreparedHtmlArtifact([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "tpl1",
name: "page_report_template",
args: { action: "prepare" },
},
],
},
{
type: "tool",
tool_call_id: "tpl1",
content: JSON.stringify({
ok: true,
working_copy: "/mnt/user-data/workspace/draft.html",
}),
},
]),
null,
);
});
test("latestInFlightWriteFilePreview opens a write_file that is not the last tool call", () => { test("latestInFlightWriteFilePreview opens a write_file that is not the last tool call", () => {
const preview = latestInFlightWriteFilePreview([ const preview = latestInFlightWriteFilePreview([
{ {
@@ -51,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) { function hasToolResult(thread: WriteFileThreadLike, toolCallId?: string) {
if (!toolCallId) { if (!toolCallId) {
return false; return false;
+2 -6
View File
@@ -1,6 +1,6 @@
"use client"; "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 { useLocation, useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -125,9 +125,5 @@ export function AuthSyncWatcher({
}; };
}, [disabled, location.pathname, navigate, queryClient]); }, [disabled, location.pathname, navigate, queryClient]);
// A keyed Fragment is transformed by the development tagger, which injects return children ? <Fragment key={revision}>{children}</Fragment> : null;
// 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;
} }
+5 -8
View File
@@ -109,8 +109,8 @@ export interface TaskButton {
/** 追加的查询参数名(如 ``id`` / ``task_id``);空则用 ``id``。 */ /** 追加的查询参数名(如 ``id`` / ``task_id``);空则用 ``id``。 */
idParam: string; idParam: string;
/** /**
* ``url`` / ``host`` 追加 id 时取任务id 还是行动id。仅 xdfx(行动分析)二者不同:深链原始 * ``url`` 追加 id 时取任务id 还是行动id。仅 xdfx(行动分析)二者不同:深链原始 ``?taskId=``
* ``?taskId=`` 实为行动id,任务id 由 action 接口解析。其它业务两者相同。 * 实为行动id,任务id 由 action 接口解析。其它业务两者相同。
*/ */
idKind: TaskButtonIdKind; idKind: TaskButtonIdKind;
/** ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口。仅 ``url`` 类型有意义。 */ /** ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口。仅 ``url`` 类型有意义。 */
@@ -327,8 +327,7 @@ export interface RunTaskButtonCtx {
* 执行一个跳转按钮: * 执行一个跳转按钮:
* - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。 * - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。
* - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。 * - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。
* - ``host``:向 ``window.parent`` 发 ``HOST_NAVIGATE``,由宿主系统跳转(仅 iframe 嵌入时生效; * - ``host``:向 ``window.parent`` 发 ``HOST_NAVIGATE``,由宿主系统跳转(仅 iframe 嵌入时生效)。
* 追加 id 时同样按 ``idKind`` 取任务id / 行动id)。
*/ */
export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise<void> { export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise<void> {
const fail = (m: string) => ctx.onError?.(m); const fail = (m: string) => ctx.onError?.(m);
@@ -356,11 +355,9 @@ export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Pro
} }
const queryParams: Record<string, number> = {}; const queryParams: Record<string, number> = {};
if (btn.appendTaskId) { if (btn.appendTaskId) {
// 行动分析(xdfx)可选发行动id;与 url 外链同规则:行动id 缺失时回退任务id。 const id = parseHostTaskId(ctx.taskId);
const raw = btn.idKind === "action" ? ctx.actionId || ctx.taskId : ctx.taskId;
const id = parseHostTaskId(raw);
if (id === null) { if (id === null) {
fail(`${btn.idKind === "action" ? "行动id" : "任务id"}不是数字,无法跳转宿主系统`); fail("任务id不是数字,无法跳转宿主系统");
return; return;
} }
queryParams[(btn.idParam || "id").trim() || "id"] = id; queryParams[(btn.idParam || "id").trim() || "id"] = id;
+3 -19
View File
@@ -38,12 +38,9 @@ export function buildAppHashUrl(path: string): string {
} }
export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget | null { export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget | null {
if (!url) return null; if (!url?.startsWith("/weknora-source-preview?")) {
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 queryIndex = url.indexOf("?");
const params = new URLSearchParams(url.slice(queryIndex + 1)); const params = new URLSearchParams(url.slice(queryIndex + 1));
const knowledgeBaseId = params.get("knowledge_base_id")?.trim(); const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
@@ -66,19 +63,6 @@ export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget
tab: wikiSlug ? "wiki" : "documents", tab: wikiSlug ? "wiki" : "documents",
} }
: null; : 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" }
: null;
} }
export function resolveWeKnoraSourceTarget(source: KnowledgeSourceLike): WeKnoraSourceTarget | null { export function resolveWeKnoraSourceTarget(source: KnowledgeSourceLike): WeKnoraSourceTarget | null {
@@ -20,11 +20,6 @@ export type SearchResultItem = {
published_date?: string; published_date?: string;
publishedDate?: string; publishedDate?: string;
date?: string; date?: string;
/** Retrieval score supplied by the search provider, when available. */
score?: number;
/** Original retrieval payload type, retained for specialised result renderers. */
type?: string;
knowledge_base_id?: string;
}; };
const RESULT_LIST_KEYS = [ const RESULT_LIST_KEYS = [
@@ -201,25 +196,6 @@ function pickFromLayers(
return undefined; return undefined;
} }
function pickNumberFromLayers(
layers: Record<string, unknown>[],
aliases: readonly string[],
): number | undefined {
for (const layer of layers) {
for (const alias of aliases) {
const value = layer[alias];
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
}
}
return undefined;
}
function pickByKeyPattern( function pickByKeyPattern(
layers: Record<string, unknown>[], layers: Record<string, unknown>[],
pattern: RegExp, pattern: RegExp,
@@ -350,12 +326,6 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
const recUuid = pickFromLayers(layers, ["recUuid", "rec_uuid"]); const recUuid = pickFromLayers(layers, ["recUuid", "rec_uuid"]);
const uuid = pickFromLayers(layers, ["uuid"]); const uuid = pickFromLayers(layers, ["uuid"]);
const docId = pickFromLayers(layers, ["docId", "doc_id", "documentId"]); const docId = pickFromLayers(layers, ["docId", "doc_id", "documentId"]);
const score = pickNumberFromLayers(layers, [
"score",
"relevance_score",
"rerank_score",
"similarity",
]);
const reserved = new Set<string>(); const reserved = new Set<string>();
for (const value of [title, content, url, source, author, date]) { for (const value of [title, content, url, source, author, date]) {
@@ -393,9 +363,6 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
published_date: date, published_date: date,
publishedDate: date, publishedDate: date,
date, date,
score,
type: pickString(record.type),
knowledge_base_id: pickString(record.knowledge_base_id, record.knowledgeBaseId, record.mapping_id),
}; };
} }
+1 -12
View File
@@ -370,12 +370,6 @@ export function extractReasoningContentFromMessage(message: Message) {
) { ) {
return message.additional_kwargs.reasoning_content as string | null; return message.additional_kwargs.reasoning_content as string | null;
} }
if (
message.additional_kwargs &&
typeof message.additional_kwargs.reasoning === "string"
) {
return message.additional_kwargs.reasoning;
}
if (Array.isArray(message.content)) { if (Array.isArray(message.content)) {
const part = message.content[0]; const part = message.content[0];
if (part && "thinking" in part) { if (part && "thinking" in part) {
@@ -393,7 +387,6 @@ export function removeReasoningContentFromMessage(message: Message) {
return; return;
} }
delete message.additional_kwargs.reasoning_content; delete message.additional_kwargs.reasoning_content;
delete message.additional_kwargs.reasoning;
} }
export function extractURLFromImageURLContent( export function extractURLFromImageURLContent(
@@ -431,9 +424,6 @@ export function hasReasoning(message: Message) {
if (typeof message.additional_kwargs?.reasoning_content === "string") { if (typeof message.additional_kwargs?.reasoning_content === "string") {
return true; return true;
} }
if (typeof message.additional_kwargs?.reasoning === "string") {
return true;
}
if (Array.isArray(message.content)) { if (Array.isArray(message.content)) {
const part = message.content[0]; const part = message.content[0];
// Compatible with the Anthropic gateway // Compatible with the Anthropic gateway
@@ -564,7 +554,6 @@ export function findToolCallResult(
toolCallId: string, toolCallId: string,
messages: Message[], messages: Message[],
toolName?: string, toolName?: string,
options?: { allowNameFallback?: boolean },
): string | Record<string, unknown> | undefined { ): string | Record<string, unknown> | undefined {
for (const message of messages) { for (const message of messages) {
if (message.type !== "tool" || message.tool_call_id !== toolCallId) { if (message.type !== "tool" || message.tool_call_id !== toolCallId) {
@@ -577,7 +566,7 @@ export function findToolCallResult(
} }
// updates 流里偶发缺少 tool_call_id,但 content 已是检索结构 // updates 流里偶发缺少 tool_call_id,但 content 已是检索结构
if (toolName && options?.allowNameFallback !== false) { if (toolName) {
for (const message of messages) { for (const message of messages) {
if (message.type !== "tool" || message.name !== toolName) { if (message.type !== "tool" || message.name !== toolName) {
continue; continue;
@@ -10,7 +10,6 @@ export interface ManagedModel {
base_url: string | null; base_url: string | null;
request_timeout: number | null; request_timeout: number | null;
max_retries: number | null; max_retries: number | null;
max_input_tokens: number | null;
max_tokens: number | null; max_tokens: number | null;
temperature: number | null; temperature: number | null;
stream_usage: boolean | null; stream_usage: boolean | null;
@@ -45,7 +44,6 @@ export interface ModelSavePayload {
base_url?: string | null; base_url?: string | null;
request_timeout?: number | null; request_timeout?: number | null;
max_retries?: number | null; max_retries?: number | null;
max_input_tokens?: number | null;
max_tokens?: number | null; max_tokens?: number | null;
temperature?: number | null; temperature?: number | null;
stream_usage?: boolean | null; stream_usage?: boolean | null;
@@ -30,7 +30,7 @@ test("matches only workspace chats_iframe routes", () => {
assert.equal(isChatsIframeWorkspacePath("/landing"), false); assert.equal(isChatsIframeWorkspacePath("/landing"), false);
}); });
test("forces 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("/landing"), "light");
assert.equal( assert.equal(
resolveForcedThemeForPath("/page/workspace/chats_iframe/new"), resolveForcedThemeForPath("/page/workspace/chats_iframe/new"),
@@ -41,14 +41,6 @@ test("forces the dedicated presentation themes for landing, iframe, and wx chat
"light", "light",
); );
assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined); assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined);
assert.equal(
resolveForcedThemeForPath("/page/workspace/wx/chats/new"),
"dark",
);
assert.equal(
resolveForcedThemeForPath("/page/workspace/wx/agents/agent-1/chats/new"),
"dark",
);
}); });
test("recognizes an explicit chatMode=iframe independently of the legacy marker", () => { test("recognizes an explicit chatMode=iframe independently of the legacy marker", () => {
@@ -3,8 +3,6 @@ import { resolveEmbedThemeMode } from "../embed/theme-mode.mjs";
const CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new"; const CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new";
const IFRAME_LOGIN_SOURCE = "iframe"; const IFRAME_LOGIN_SOURCE = "iframe";
const STANDARD_CHATS_PATH = "/page/workspace/chats"; const STANDARD_CHATS_PATH = "/page/workspace/chats";
/** Dedicated full chat surface whose presentation is always deep blue. */
export const WX_FULL_CHAT_PATH_PREFIX = "/page/workspace/wx/";
function toSearchParams(search: string): URLSearchParams { function toSearchParams(search: string): URLSearchParams {
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`); return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
@@ -330,10 +328,6 @@ export function resolveForcedThemeForPath(
return "light"; return "light";
} }
if (pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX)) {
return "dark";
}
const isLoginPath = pathname === "/login" || pathname.startsWith("/login/"); const isLoginPath = pathname === "/login" || pathname.startsWith("/login/");
if ( if (
isChatsIframeWorkspacePath(pathname) || isChatsIframeWorkspacePath(pathname) ||
@@ -21,7 +21,6 @@ import {
FileBox, FileBox,
FileSearch, FileSearch,
FileText, FileText,
FolderKanban,
Gauge, Gauge,
Grid3X3, Grid3X3,
HelpCircle, HelpCircle,
@@ -49,23 +48,12 @@ export type MenuItem = {
path?: string; path?: string;
/** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */ /** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */
aliasPaths?: string[]; aliasPaths?: string[];
/** 从当前菜单项自身的 path / aliasPaths 前缀匹配中排除的路由。 */
excludePaths?: string[];
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */ /** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
userPath?: string; userPath?: string;
adminOnly?: boolean; adminOnly?: boolean;
children?: MenuItem[]; children?: MenuItem[];
}; };
/** Markdown 转 Word 功能的菜单定义,供全量与简洁模式共用。 */
export const MARKDOWN_DOCX_MENU_PATH = "/page/workspace/knowledge/markdown-editor";
export const MARKDOWN_DOCX_MENU_ITEM: MenuItem & { path: string } = {
id: "resource-markdown-docx-editor",
label: "md转word",
icon: FileText,
path: MARKDOWN_DOCX_MENU_PATH,
};
export const sidebarMenuItems: MenuItem[] = [ export const sidebarMenuItems: MenuItem[] = [
{ {
id: "qa-center", id: "qa-center",
@@ -73,7 +61,6 @@ export const sidebarMenuItems: MenuItem[] = [
icon: HelpCircle, icon: HelpCircle,
children: [ children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" }, { id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{ id: "analysis-projects", label: "项目空间", icon: Layout, path: "/page/workspace/projects" },
{ id: "artifact-library", label: "资料库", icon: Library, path: "/page/workspace/library" }, { id: "artifact-library", label: "资料库", icon: Library, path: "/page/workspace/library" },
{ {
id: "multi-agent-roundtable", id: "multi-agent-roundtable",
@@ -81,13 +68,6 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Users, icon: Users,
path: "/page/strategy/qa/multi-agent-roundtable", path: "/page/strategy/qa/multi-agent-roundtable",
}, },
{
id: "strategy-projects",
label: "我的项目",
icon: FolderKanban,
path: "/page/strategy/qa/projects",
aliasPaths: ["/page/strategy/qa/strategy-selection"],
},
// { // {
// id: "position-roundtable", // id: "position-roundtable",
// label: "岗位会商", // label: "岗位会商",
@@ -278,7 +258,12 @@ export const sidebarMenuItems: MenuItem[] = [
}, },
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" }, { id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" }, { id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
MARKDOWN_DOCX_MENU_ITEM, {
id: "resource-markdown-docx-editor",
label: "md转word",
icon: FileText,
path: "/page/workspace/knowledge/markdown-editor",
},
{ {
id: "mgmt-tasks", id: "mgmt-tasks",
label: "定时任务", label: "定时任务",
@@ -345,7 +330,6 @@ export const sidebarMenuItems: MenuItem[] = [
label: "知识库管理", label: "知识库管理",
icon: BookOpen, icon: BookOpen,
path: "/page/workspace/knowledge", path: "/page/workspace/knowledge",
excludePaths: [MARKDOWN_DOCX_MENU_PATH],
}, },
{ {
id: "resource-knowledge-base", id: "resource-knowledge-base",
@@ -381,9 +365,6 @@ function routeMatchesPathname(pathname: string, route?: string): boolean {
/** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */ /** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */
export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean { export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean {
if (item.excludePaths?.some((path) => routeMatchesPathname(pathname, path))) {
return false;
}
if (routeMatchesPathname(pathname, item.path)) return true; if (routeMatchesPathname(pathname, item.path)) return true;
return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false; return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false;
} }
-244
View File
@@ -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),
});
}
+5 -55
View File
@@ -249,42 +249,7 @@ export function collectRuntimeDisplaySources(
() => ++citationIndex, () => ++citationIndex,
); );
} }
return rankLlmWikiSources(sources); return sources;
}
/**
* The Wiki tool exposes a numeric retrieval score. Give Wiki sources their
* citation numbers in that ranked order so the answer text, reference panel,
* and tool trace all refer to the same item. Non-Wiki sources keep their
* existing positions and indexes.
*/
function rankLlmWikiSources(
sources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
const wikiSlots = sources
.map((source, position) => ({ source, position }))
.filter(({ source }) => source.skillName === "llmwiki_search");
if (wikiSlots.length < 2) return sources;
const ranked = [...wikiSlots].sort((left, right) => {
const leftScore =
typeof left.source.score === "number" && Number.isFinite(left.source.score)
? left.source.score
: Number.NEGATIVE_INFINITY;
const rightScore =
typeof right.source.score === "number" && Number.isFinite(right.source.score)
? right.source.score
: Number.NEGATIVE_INFINITY;
return rightScore - leftScore || left.position - right.position;
});
const rankedBySlot = new Map(
wikiSlots.map(({ position }, rank) => [position, ranked[rank].source]),
);
return sources.map((source, position) => {
const rankedSource = rankedBySlot.get(position);
return rankedSource ? { ...rankedSource, index: source.index } : source;
});
} }
function appendDisplaySources( function appendDisplaySources(
@@ -503,8 +468,7 @@ export function shouldUseDefaultCitationMode(
normalizedName.includes("search") || normalizedName.includes("search") ||
normalizedName.includes("retriev") || normalizedName.includes("retriev") ||
normalizedName.includes("knowledge") || normalizedName.includes("knowledge") ||
normalizedName.includes("query") || normalizedName.includes("query");
normalizedName.includes("wiki");
const isSkillInvocation = !!( const isSkillInvocation = !!(
toolArgs && toolArgs &&
canInferSkillFromToolArgs(toolName) && canInferSkillFromToolArgs(toolName) &&
@@ -531,13 +495,12 @@ export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceI
item.id ?? item.id ??
item.url, item.url,
title: item.title ?? item.source ?? `结果 ${index + 1}`, title: item.title ?? item.source ?? `结果 ${index + 1}`,
type: item.type ?? "result", type: "result",
snippet: item.snippet ?? item.content ?? "", snippet: item.snippet ?? item.content ?? "",
content: item.content ?? item.snippet, content: item.content ?? item.snippet,
source: item.source, source: item.source,
author: item.author, author: item.author,
time: item.published_date ?? item.publishedDate ?? item.date, time: item.published_date ?? item.publishedDate ?? item.date,
score: item.score,
url: item.url, url: item.url,
raw: item as unknown as Record<string, unknown>, raw: item as unknown as Record<string, unknown>,
})); }));
@@ -556,12 +519,6 @@ export function applyInlineCitations(
return content; return content;
} }
const byIndex = new Map(referenceSources.map((source) => [source.index, source])); const byIndex = new Map(referenceSources.map((source) => [source.index, source]));
const byCitationId = new Map(
referenceSources.flatMap((source) => {
const id = typeof source.raw?.citation_id === "string" ? source.raw.citation_id.trim() : "";
return id ? [[id, source] as const] : [];
}),
);
const byUrl = new Map<string, RuntimeDisplaySource>(); const byUrl = new Map<string, RuntimeDisplaySource>();
const byTitle = new Map<string, RuntimeDisplaySource>(); const byTitle = new Map<string, RuntimeDisplaySource>();
for (const source of referenceSources) { for (const source of referenceSources) {
@@ -573,13 +530,13 @@ export function applyInlineCitations(
} }
} }
const normalized = replaceStableCitationTokens(normalizeExistingCitationLinks( const normalized = normalizeExistingCitationLinks(
content, content,
byIndex, byIndex,
byUrl, byUrl,
byTitle, byTitle,
referenceSources, referenceSources,
), byCitationId); );
const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g; const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g;
let cursor = 0; let cursor = 0;
@@ -613,13 +570,6 @@ export function applyInlineCitations(
return appendCitationSummary(next, referenceSources); return appendCitationSummary(next, referenceSources);
} }
function replaceStableCitationTokens(content: string, byCitationId: Map<string, RuntimeDisplaySource>): string {
return content.replace(/\[\[cite:([^\]]+)\]\]/gi, (match, rawId: string) => {
const source = byCitationId.get(rawId.trim());
return source ? citationLinkMarkdown(source) : match;
});
}
function replaceCitationTokens( function replaceCitationTokens(
content: string, content: string,
byIndex: Map<number, RuntimeDisplaySource>, byIndex: Map<number, RuntimeDisplaySource>,
-637
View File
@@ -1,637 +0,0 @@
import { getBackendBaseURL } from "@/core/config";
import { apiFetch } from "@/core/api";
import type { Skill, SkillDisplayConfig, SkillState } from "./type";
export interface ListSkillsParams {
search?: string;
tagIds?: string[];
sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc";
/** Keep only skills filed under this publish square (发布广场). */
square?: string;
/** ``"mine"`` restricts to the skills the caller's 岗位 (position) makes visible. */
scope?: "mine";
}
export async function loadSkills(params: ListSkillsParams = {}) {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
if (params.sort) qs.set("sort", params.sort);
if (params.square) qs.set("square", params.square);
if (params.scope) qs.set("scope", params.scope);
const query = qs.toString();
const skills = await apiFetch(
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
);
const json = await skills.json();
return json.skills as Skill[];
}
export async function loadAllSkillsAdmin() {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills?admin=true`,
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to load skills");
}
const json = await response.json();
return json.skills as Skill[];
}
export async function getSkill(skillName: string): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to load skill");
}
return response.json() as Promise<Skill>;
}
export async function enableSkill(skillName: string, enabled: boolean) {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${skillName}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
enabled,
}),
},
);
return response.json();
}
export async function setSkillAlwaysOn(
skillName: string,
alwaysOn: boolean,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/always-on`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ always_on: alwaysOn }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill 常驻 flag");
}
return response.json() as Promise<Skill>;
}
export async function setSkillDisplay(
skillName: string,
display: SkillDisplayConfig | null,
sample?: Record<string, unknown> | null,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ display, sample }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill display adapter");
}
return response.json() as Promise<Skill>;
}
export interface SkillDisplaySampleResponse {
skill_name: string;
sample: Record<string, unknown>;
}
export interface PersistedSkillDisplaySampleResponse {
skill_name: string;
sample: Record<string, unknown> | null;
}
export async function getPersistedSkillDisplaySample(
skillName: string,
): Promise<PersistedSkillDisplaySampleResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to get persisted skill display sample");
}
return response.json() as Promise<PersistedSkillDisplaySampleResponse>;
}
export async function getSkillDisplaySample(
skillName: string,
): Promise<SkillDisplaySampleResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
{ method: "POST" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to get skill display sample");
}
return response.json() as Promise<SkillDisplaySampleResponse>;
}
export interface InstallSkillRequest {
thread_id: string;
path: string;
skill_name?: string;
}
export interface InstallSkillResponse {
success: boolean;
skill_name: string;
message: string;
overwritten?: boolean;
}
export interface PreviewSkillInstallRequest {
thread_id: string;
path: string;
}
export interface PreviewSkillInstallResponse {
ok: boolean;
skill_name: string;
description: string;
license: string | null;
allowed_tools: string[];
can_install_as_is: boolean;
owned_by_me: boolean;
owned_by_other: boolean;
builtin_conflict: boolean;
owner_username?: string | null;
owner_user_id?: string | null;
message: string;
warnings: string[];
}
export interface ValidateSkillUploadResponse {
ok: boolean;
skill_name: string;
description: string;
license: string | null;
allowed_tools: string[];
conflict: boolean;
conflict_kind: "public" | "custom" | null;
can_install_as_is: boolean;
can_overwrite: boolean;
owned_by_me: boolean;
owned_by_other: boolean;
builtin_conflict: boolean;
owner_username?: string | null;
owner_user_id?: string | null;
message: string;
warnings: string[];
}
export async function installSkill(
request: InstallSkillRequest,
): Promise<InstallSkillResponse> {
const response = await apiFetch(`${getBackendBaseURL()}/api/skills/install`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (!response.ok) {
// Handle HTTP error responses (4xx, 5xx)
const errorData = await response.json().catch(() => ({}));
const errorMessage =
errorData.detail ?? `HTTP ${response.status}: ${response.statusText}`;
return {
success: false,
skill_name: "",
message: errorMessage,
};
}
return response.json();
}
export async function previewSkillInstall(
request: PreviewSkillInstallRequest,
): Promise<PreviewSkillInstallResponse> {
const response = await apiFetch(`${getBackendBaseURL()}/api/skills/install-preview`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw await parseDetailedError(response, "Failed to preview skill install");
}
return response.json() as Promise<PreviewSkillInstallResponse>;
}
async function parseDetailedError(response: Response, fallback: string): Promise<Error> {
const data = await response.json().catch(() => ({}));
const detail = (data as { detail?: unknown }).detail;
const message = typeof detail === "string" && detail ? detail : `${fallback} (HTTP ${response.status})`;
return new Error(message);
}
export async function validateSkillUpload(
file: File,
targetCategory: "public" | "custom" = "custom",
): Promise<ValidateSkillUploadResponse> {
const form = new FormData();
form.append("file", file, file.name);
const params = new URLSearchParams({ target_category: targetCategory });
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/validate?${params.toString()}`,
{
method: "POST",
body: form,
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Validation failed");
}
return response.json() as Promise<ValidateSkillUploadResponse>;
}
export async function installSkillUpload(
file: File,
options: { overwrite?: boolean; targetCategory?: "public" | "custom" } = {},
): Promise<InstallSkillResponse> {
const form = new FormData();
form.append("file", file, file.name);
const params = new URLSearchParams();
if (options.overwrite) params.set("overwrite", "true");
if (options.targetCategory) params.set("target_category", options.targetCategory);
const query = params.size > 0 ? `?${params.toString()}` : "";
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/install-upload${query}`,
{ method: "POST", body: form },
);
if (!response.ok) {
throw await parseDetailedError(response, "Install failed");
}
return response.json() as Promise<InstallSkillResponse>;
}
export async function duplicateSkill(
sourceName: string,
newName: string,
): Promise<InstallSkillResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(sourceName)}/duplicate`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_name: newName }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to duplicate skill");
}
return response.json() as Promise<InstallSkillResponse>;
}
export interface ReconcileCustomSkillsResponse {
adopted: string[];
already_registered: string[];
}
export async function reconcileCustomSkills(): Promise<ReconcileCustomSkillsResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/reconcile-custom`,
{ method: "POST" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to reconcile skills directory");
}
return response.json() as Promise<ReconcileCustomSkillsResponse>;
}
export interface SetSkillPublishedResponse {
name: string;
owner_user_id: string | null;
published: boolean;
square_id?: string;
}
export async function setSkillPublished(
skillName: string,
published: boolean,
squareId?: string,
): Promise<SetSkillPublishedResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/published`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
squareId != null ? { published, square_id: squareId } : { published },
),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update published state");
}
return response.json() as Promise<SetSkillPublishedResponse>;
}
export async function setSkillState(
skillName: string,
state: SkillState,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/state`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill state");
}
return response.json() as Promise<Skill>;
}
export async function setSkillPinned(
skillName: string,
pinned: boolean,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/pinned`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pinned }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill pinned state");
}
return response.json() as Promise<Skill>;
}
export async function setSkillFeatured(
skillName: string,
featured: boolean,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/featured`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ featured }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill featured state");
}
return response.json() as Promise<Skill>;
}
export interface SkillContentResponse {
name: string;
category: string;
content: string;
}
export async function getSkillContent(
skillName: string,
): Promise<SkillContentResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/content`,
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to load skill content");
}
return response.json() as Promise<SkillContentResponse>;
}
export async function deleteCustomSkill(skillName: string): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
{ method: "DELETE" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete skill");
}
}
/** Delete a built-in skill. The backend permits this only for administrators. */
export async function deleteBuiltinSkill(skillName: string): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
{ method: "DELETE" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete built-in skill");
}
}
export interface SkillFileNode {
name: string;
type: "file" | "dir";
path: string;
size?: number | null;
mtime?: number | null;
children?: SkillFileNode[] | null;
}
export interface SkillFilesResponse {
name: string;
category: "public" | "custom";
editable: boolean;
root: SkillFileNode;
}
export interface SkillFileContentResponse {
name: string;
category: "public" | "custom";
path: string;
mime: string;
size: number;
mtime: number;
is_text: boolean;
editable: boolean;
content?: string | null;
}
export interface SkillFileSaveResponse {
name: string;
path: string;
size: number;
mtime: number;
}
export async function getSkillFiles(skillName: string): Promise<SkillFilesResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files`,
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to load skill files");
}
return response.json() as Promise<SkillFilesResponse>;
}
export async function getSkillFileContent(
skillName: string,
path: string,
): Promise<SkillFileContentResponse> {
const url =
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content` +
`?path=${encodeURIComponent(path)}`;
const response = await apiFetch(url);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to read skill file");
}
return response.json() as Promise<SkillFileContentResponse>;
}
/** URL suitable for direct `<img>` / `<iframe>` / `<a download>` embedding. */
export function getSkillFileDownloadURL(skillName: string, path: string): string {
return (
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/download` +
`?path=${encodeURIComponent(path)}`
);
}
export async function saveSkillFile(
skillName: string,
path: string,
content: string,
): Promise<SkillFileSaveResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, content }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to save skill file");
}
return response.json() as Promise<SkillFileSaveResponse>;
}
export async function deleteSkillFile(
skillName: string,
path: string,
): Promise<void> {
const url =
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content` +
`?path=${encodeURIComponent(path)}`;
const response = await apiFetch(url, { method: "DELETE" });
if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete skill file");
}
}
export interface SkillNameZhResponse {
name: string;
name_zh: string | null;
}
export interface GenerateMissingSkillNameZhResponse {
generated: SkillNameZhResponse[];
failed: string[];
skipped: number;
/** Representative model error when some skills failed due to the LLM. */
model_error?: string | null;
}
/**
* Set a skill's Chinese display name. An empty string clears it (falls back to
* the English name).
*/
export async function setSkillNameZh(
skillName: string,
nameZh: string,
): Promise<SkillNameZhResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/name-zh`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name_zh: nameZh }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to set skill Chinese name");
}
return response.json() as Promise<SkillNameZhResponse>;
}
/** Ask the LLM to generate and persist a Chinese name for a single skill. */
export async function generateSkillNameZh(
skillName: string,
modelName?: string | null,
): Promise<SkillNameZhResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/name-zh/generate`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_name: modelName ?? null }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to generate skill Chinese name");
}
return response.json() as Promise<SkillNameZhResponse>;
}
/** One-click (admin): generate Chinese names for every skill that lacks one. */
export async function generateMissingSkillNameZh(
modelName?: string | null,
): Promise<GenerateMissingSkillNameZhResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/name-zh/generate-missing`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_name: modelName ?? null }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to bulk-generate skill Chinese names");
}
return response.json() as Promise<GenerateMissingSkillNameZhResponse>;
}
export interface SetSkillDetailResponse {
name: string;
detail: string | null;
}
/**
* Update a skill's long-form detail text. Owner/admin for custom skills,
* admin-only for built-in skills (backend enforces). An empty string clears it.
*/
export async function setSkillDetail(
skillName: string,
detail: string,
): Promise<SetSkillDetailResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/detail`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ detail }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill detail");
}
return response.json() as Promise<SetSkillDetailResponse>;
}
-379
View File
@@ -1,379 +0,0 @@
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
deleteBuiltinSkill,
deleteCustomSkill,
deleteSkillFile,
duplicateSkill,
enableSkill,
generateMissingSkillNameZh,
generateSkillNameZh,
getSkill,
getSkillContent,
getSkillFileContent,
getSkillFiles,
installSkillUpload,
reconcileCustomSkills,
saveSkillFile,
setSkillAlwaysOn,
setSkillDetail,
setSkillNameZh,
setSkillDisplay,
setSkillPinned,
setSkillFeatured,
setSkillPublished,
setSkillState,
validateSkillUpload,
} from "./api";
import type { ListSkillsParams } from "./api";
import type { Skill, SkillState } from "./type";
import { loadAllSkillsAdmin, loadSkills } from ".";
// Stable empty fallback. Returning `data ?? []` inline would hand out a fresh
// array reference on every render while the query is loading/errored, which
// makes downstream `useMemo([skills])` / `useEffect([skills])` consumers churn
// forever (observed as a "Maximum update depth exceeded" loop on NewAgentPage).
const EMPTY_SKILLS: Skill[] = [];
export function useSkills(params: ListSkillsParams = {}) {
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const scope = params.scope;
const { data, isLoading, error } = useQuery({
queryKey: ["skills", search, [...tagIds].sort(), sort, scope ?? "all"],
queryFn: () => loadSkills({ search, tagIds, sort, scope }),
placeholderData: keepPreviousData,
});
return { skills: data ?? EMPTY_SKILLS, isLoading, error };
}
/**
* Admin-only listing — every skill in the system (built-in + every user's
* custom skills, including unpublished ones owned by other users). Backend
* 403s if the caller is not ``system_role === "admin"``.
*/
export function useAllSkillsAdmin(enabled: boolean) {
const { data, isLoading, error } = useQuery({
queryKey: ["skills", "admin", "all"],
queryFn: () => loadAllSkillsAdmin(),
enabled,
staleTime: 30_000,
});
return { skills: data ?? EMPTY_SKILLS, isLoading, error };
}
export function useSkillContent(skillName: string | null) {
return useQuery({
queryKey: ["skill-content", skillName],
queryFn: () => getSkillContent(skillName!),
enabled: !!skillName,
});
}
export function useSkill(skillName: string | null) {
return useQuery({
queryKey: ["skill", skillName],
queryFn: () => getSkill(skillName!),
enabled: !!skillName,
});
}
export function useSetSkillDetail() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, detail }: { skillName: string; detail: string }) =>
setSkillDetail(skillName, detail),
onSuccess: (_data, { skillName }) => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
export function useSetSkillNameZh() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, nameZh }: { skillName: string; nameZh: string }) =>
setSkillNameZh(skillName, nameZh),
onSuccess: (_data, { skillName }) => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
export function useGenerateSkillNameZh() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, modelName }: { skillName: string; modelName?: string | null }) =>
generateSkillNameZh(skillName, modelName),
onSuccess: (_data, { skillName }) => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
export function useGenerateMissingSkillNameZh() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (modelName?: string | null) => generateMissingSkillNameZh(modelName),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillDisplay() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skillName,
display,
sample,
}: {
skillName: string;
display: Parameters<typeof setSkillDisplay>[1];
sample?: Parameters<typeof setSkillDisplay>[2];
}) => setSkillDisplay(skillName, display, sample),
onSuccess: (data, { skillName }) => {
queryClient.setQueryData(["skill", skillName], data);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
Array.isArray(current)
? current.map((item) => (item.name === skillName ? { ...item, ...data } : item))
: current,
);
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
export function useEnableSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
skillName,
enabled,
}: {
skillName: string;
enabled: boolean;
}) => {
await enableSkill(skillName, enabled);
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillAlwaysOn() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, alwaysOn }: { skillName: string; alwaysOn: boolean }) =>
setSkillAlwaysOn(skillName, alwaysOn),
onSuccess: (data, { skillName }) => {
queryClient.setQueryData(["skill", skillName], data);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
Array.isArray(current)
? current.map((item) => (item.name === skillName ? { ...item, ...data } : item))
: current,
);
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
export function useValidateSkillUpload() {
return useMutation({
mutationFn: ({
file,
targetCategory,
}: {
file: File;
targetCategory?: "public" | "custom";
}) => validateSkillUpload(file, targetCategory),
});
}
export function useInstallSkillUpload() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ file, overwrite, targetCategory }: { file: File; overwrite?: boolean; targetCategory?: "public" | "custom" }) =>
installSkillUpload(file, { overwrite, targetCategory }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useReconcileCustomSkills() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => reconcileCustomSkills(),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillPublished() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skillName,
published,
squareId,
}: {
skillName: string;
published: boolean;
squareId?: string;
}) => setSkillPublished(skillName, published, squareId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
// Unpublishing triggers the backend cascade that strips this skill
// from every referencing agent's config.yaml, so the cached agent
// list (agent.skills) is now stale.
void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
export function useSetSkillState() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, state }: { skillName: string; state: SkillState }) =>
setSkillState(skillName, state),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillPinned() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, pinned }: { skillName: string; pinned: boolean }) =>
setSkillPinned(skillName, pinned),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useSetSkillFeatured() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, featured }: { skillName: string; featured: boolean }) =>
setSkillFeatured(skillName, featured),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useDuplicateSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
sourceName,
newName,
}: {
sourceName: string;
newName: string;
}) => duplicateSkill(sourceName, newName),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useDeleteCustomSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (skillName: string) => deleteCustomSkill(skillName),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
// Deletion triggers the backend cascade that strips this skill from
// every referencing agent's config.yaml, so the cached agent list
// (agent.skills) is now stale.
void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
export function useSkillFiles(skillName: string | null) {
return useQuery({
queryKey: ["skill-files", skillName],
queryFn: () => getSkillFiles(skillName!),
enabled: !!skillName,
});
}
export function useSkillFileContent(skillName: string | null, path: string | null) {
return useQuery({
queryKey: ["skill-file", skillName, path],
queryFn: () => getSkillFileContent(skillName!, path!),
enabled: !!skillName && !!path,
});
}
export function useSaveSkillFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skillName,
path,
content,
}: {
skillName: string;
path: string;
content: string;
}) => saveSkillFile(skillName, path, content),
onSuccess: (_data, { skillName, path }) => {
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
void queryClient.invalidateQueries({ queryKey: ["skill-file", skillName, path] });
// SKILL.md edits also affect the source-tab view and skill metadata.
if (path === "SKILL.md") {
void queryClient.invalidateQueries({ queryKey: ["skill-content", skillName] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
}
},
});
}
export function useDeleteSkillFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skillName,
path,
}: {
skillName: string;
path: string;
}) => deleteSkillFile(skillName, path),
onSuccess: (_data, { skillName, path }) => {
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
void queryClient.removeQueries({ queryKey: ["skill-file", skillName, path] });
},
});
}
export function useDeleteBuiltinSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteBuiltinSkill,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["admin-skills"] });
},
});
}
-3
View File
@@ -1,3 +0,0 @@
export * from "./api";
export * from "./type";
export * from "./utils";
-88
View File
@@ -1,88 +0,0 @@
import type { Tag } from "@/core/tags";
export type SkillState = "active" | "stale" | "archived";
export type SkillSource = "agent" | "upload";
export type SkillDisplayMode = "citation" | "list" | "table" | "card" | "custom" | "none";
export type SkillLinkStrategy = "auto" | "url" | "id";
export type SkillCitationToken = "doubleBracket" | "singleBracket";
export interface SkillDisplayTableColumn {
label: string;
field: string;
width?: string | null;
}
export interface SkillDisplayResultSetConfig {
key: string;
label?: string | null;
enabled: boolean;
mode: SkillDisplayMode;
resultPath: string;
titleField?: string | null;
summaryField?: string | null;
contentField?: string | null;
sourceField?: string | null;
authorField?: string | null;
typeField?: string | null;
timeField?: string | null;
scoreField?: string | null;
urlField?: string | null;
idField?: string | null;
linkStrategy: SkillLinkStrategy;
linkTemplate?: string | null;
citationToken: SkillCitationToken;
maxItems: number;
customInstruction: string;
tableColumns?: SkillDisplayTableColumn[];
}
export interface SkillDisplayConfig extends Omit<SkillDisplayResultSetConfig, "key" | "label"> {
resultSets?: SkillDisplayResultSetConfig[];
}
export interface Skill {
name: string;
// 用户配置或大模型生成的中文名称;为空时界面回退到使用英文 name。
name_zh?: string | null;
description: string;
category: string;
license: string;
enabled: boolean;
// 常驻(免压缩) —— 开启技能压缩后,标记此项的技能仍以全文形式注入系统提示词,
// 而非被压缩成索引 + search_skills 检索。
always_on?: boolean;
// Multitenancy fields — owner_user_id is null for built-in / public skills.
// ``builtin`` mirrors that nullness for explicit branching on the UI side.
owner_user_id?: string | null;
// Publisher username (email local part) resolved from owner_user_id by the
// backend. Null for built-in / public skills with no human owner.
owner_username?: string | null;
published?: boolean;
/** Publish square (发布广场) the skill is filed under once published. "" = default square. */
square_id?: string;
builtin?: boolean;
// True when this skill is in the caller's 我的技能 collection — e.g. granted by
// their 岗位 (position). Owned skills are detected separately via owner_user_id.
favorited?: boolean;
// Long-form detail/introduction authored by the skill owner or an admin.
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
detail?: string | null;
display?: SkillDisplayConfig | null;
displaySample?: Record<string, unknown> | null;
// Lifecycle fields — populated by curator. The main list filters on these
// so stale / archived skills appear in dedicated drawers instead.
state?: SkillState;
pinned?: boolean;
// 置顶 —— 仅影响列表排序,与 pinned(防自动归档)正交。
featured?: boolean;
// Provenance — distinguishes skills created by an agent during a chat
// (``agent``) from skills the user uploaded / authored manually (``upload``).
source?: SkillSource;
created_at?: string | null;
last_activity_at?: string | null;
use_count?: number;
view_count?: number;
patch_count?: number;
// Tags assigned to this skill (admin-managed).
tags?: Tag[];
}
-22
View File
@@ -1,22 +0,0 @@
import type { Skill } from "./type";
/**
* 智能体技能白名单的候选池过滤。
*
* 编辑智能体时,普通用户只能从「内置技能 + 自己的技能 + 已发布到广场的
* 技能」里挑选,其他用户未发布的自定义技能不可选;管理员仍可使用全部
* 已启用技能。
*/
export function isAgentSkillCandidate(
skill: Skill,
currentUserId: string | null,
isAdmin = false,
): boolean {
if (!skill.enabled) return false;
if (isAdmin) return true;
if ((skill.state ?? "active") !== "active") return false;
const builtin = skill.builtin ?? !skill.owner_user_id;
const ownedByMe =
!!skill.owner_user_id && skill.owner_user_id === currentUserId;
return builtin || ownedByMe || !!skill.published;
}
@@ -13,44 +13,6 @@ export type KnowledgeBaseProductVersion = "v1" | "v2";
export type OrdinaryKnowledgeLanding = "wiki" | "weknora"; export type OrdinaryKnowledgeLanding = "wiki" | "weknora";
export interface StrategyProjectSettings {
taiwan_strait_demo_enabled: boolean;
}
export interface StrategyProjectSettingsUpdate {
taiwan_strait_demo_enabled?: boolean;
}
export async function loadStrategyProjectSettings(): Promise<StrategyProjectSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/strategy-projects`,
);
if (!response.ok) {
throw new Error(`无法读取策略项目设置(HTTP ${response.status})`);
}
return (await response.json()) as StrategyProjectSettings;
}
export async function updateStrategyProjectSettings(
body: StrategyProjectSettingsUpdate,
): Promise<StrategyProjectSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/strategy-projects`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "保存策略项目设置失败" }));
throw new Error(detail.detail ?? "保存策略项目设置失败");
}
return (await response.json()) as StrategyProjectSettings;
}
export async function loadOrdinaryKnowledgeLanding(): Promise<OrdinaryKnowledgeLanding> { export async function loadOrdinaryKnowledgeLanding(): Promise<OrdinaryKnowledgeLanding> {
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`); const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`);
if (!response.ok) throw new Error("无法读取普通知识库默认页面设置"); if (!response.ok) throw new Error("无法读取普通知识库默认页面设置");
@@ -314,42 +276,6 @@ export interface PublishSquaresUpdate {
default_square_id?: string; default_square_id?: string;
} }
/** Agent Gallery category selected for the wx route's common-agent panel. */
export interface WxCommonAgentsSettings {
/** `null` keeps the original common-agent recommendation logic. */
agent_type: string | null;
}
export async function loadWxCommonAgents(): Promise<WxCommonAgentsSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/wx-common-agents`,
);
if (!response.ok) {
throw new Error(`Failed to load wx common agents: ${response.status}`);
}
return (await response.json()) as WxCommonAgentsSettings;
}
export async function updateWxCommonAgents(
agentType: string | null,
): Promise<WxCommonAgentsSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/wx-common-agents`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_type: agentType }),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update wx common agents" }));
throw new Error(detail.detail ?? "Failed to update wx common agents");
}
return (await response.json()) as WxCommonAgentsSettings;
}
export async function loadPublishSquares(): Promise<PublishSquaresSettings> { export async function loadPublishSquares(): Promise<PublishSquaresSettings> {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/publish-squares`, `${getBackendBaseURL()}/api/system-settings/publish-squares`,
@@ -637,48 +563,6 @@ export interface ModelConcurrencyUpdate {
user_model_limits?: Record<string, number>; user_model_limits?: Record<string, number>;
} }
export interface MemoryRuntimeSettings {
enabled: boolean;
}
export async function loadMemoryRuntimeSettings(): Promise<MemoryRuntimeSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/memory`,
);
if (!response.ok) {
throw new Error(`Failed to load memory runtime setting: ${response.status}`);
}
return (await response.json()) as MemoryRuntimeSettings;
}
export async function updateMemoryRuntimeSettings(
body: MemoryRuntimeSettings,
): Promise<MemoryRuntimeSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/memory`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update memory runtime setting" }));
throw new Error(detail.detail ?? "Failed to update memory runtime setting");
}
const data = (await response.json()) as MemoryRuntimeSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-问答记忆开关",
url: "/api/system-settings/memory",
reqParam: body,
returnParam: data,
});
return data;
}
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> { export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/skill-compression`, `${getBackendBaseURL()}/api/system-settings/skill-compression`,
+2 -78
View File
@@ -8,38 +8,30 @@ import {
loadCitationDisplaySettings, loadCitationDisplaySettings,
loadLeaderboardSettings, loadLeaderboardSettings,
loadModelConcurrencySettings, loadModelConcurrencySettings,
loadMemoryRuntimeSettings,
loadPluginManagementSettings, loadPluginManagementSettings,
loadPrivateSquareStatus, loadPrivateSquareStatus,
loadPromptPrefixSettings, loadPromptPrefixSettings,
loadPublishSquares, loadPublishSquares,
loadSkillCompressionSettings, loadSkillCompressionSettings,
loadStrategyProjectSettings,
loadWxCommonAgents,
updateAgentModuleConfig, updateAgentModuleConfig,
updateAppearanceSettings, updateAppearanceSettings,
updateCitationDisplaySettings, updateCitationDisplaySettings,
updateLeaderboardSettings, updateLeaderboardSettings,
updateModelConcurrencySettings, updateModelConcurrencySettings,
updateMemoryRuntimeSettings,
updatePluginManagementSettings, updatePluginManagementSettings,
updatePrivateSquareSettings, updatePrivateSquareSettings,
updatePromptPrefixSettings, updatePromptPrefixSettings,
updatePublishSquares, updatePublishSquares,
updateSkillCompressionSettings, updateSkillCompressionSettings,
updateStrategyProjectSettings,
updateWxCommonAgents,
type AppearanceUpdate, type AppearanceUpdate,
type CitationDisplayUpdate, type CitationDisplayUpdate,
type LeaderboardSettingsUpdate, type LeaderboardSettingsUpdate,
type ModelConcurrencyUpdate, type ModelConcurrencyUpdate,
type MemoryRuntimeSettings,
type PluginManagementUpdate, type PluginManagementUpdate,
type PrivateSquareUpdate, type PrivateSquareUpdate,
type PromptPrefixUpdate, type PromptPrefixUpdate,
type PublishSquaresUpdate, type PublishSquaresUpdate,
type SkillCompressionUpdate, type SkillCompressionUpdate,
type StrategyProjectSettingsUpdate,
type LayoutMode, type LayoutMode,
type ViewMode, type ViewMode,
} from "./api"; } from "./api";
@@ -52,30 +44,7 @@ const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const; const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const; const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const; const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
const MEMORY_RUNTIME_KEY = ["systemSettings", "memoryRuntime"] as const;
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const; const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
const STRATEGY_PROJECTS_KEY = ["systemSettings", "strategyProjects"] as const;
export function useStrategyProjectSettings() {
return useQuery({
queryKey: STRATEGY_PROJECTS_KEY,
queryFn: () => loadStrategyProjectSettings(),
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdateStrategyProjectSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: StrategyProjectSettingsUpdate) =>
updateStrategyProjectSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(STRATEGY_PROJECTS_KEY, data);
},
});
}
export function usePromptPrefixSettings() { export function usePromptPrefixSettings() {
return useQuery({ return useQuery({
@@ -117,36 +86,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 configured publish squares (发布广场). Readable by any logged-in user —
* the gallery sub-tabs and the publish-flow square picker both need it. */ * the gallery sub-tabs and the publish-flow square picker both need it. */
export function usePublishSquares({ enabled = true }: { enabled?: boolean } = {}) { export function usePublishSquares() {
return useQuery({ return useQuery({
queryKey: PUBLISH_SQUARES_KEY, queryKey: PUBLISH_SQUARES_KEY,
queryFn: () => loadPublishSquares(), queryFn: () => loadPublishSquares(),
staleTime: 60_000, staleTime: 60_000,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: false, retry: false,
enabled,
}); });
} }
@@ -234,9 +182,7 @@ export function useLayoutMode(): [LayoutMode, (mode: LayoutMode) => void] {
const agentModuleKey = (key: string) => const agentModuleKey = (key: string) =>
["systemSettings", "agentModule", key] as const; ["systemSettings", "agentModule", key] as const;
export function useAgentModuleConfig( export function useAgentModuleConfig(key: string) {
key: string,
) {
return useQuery({ return useQuery({
queryKey: agentModuleKey(key), queryKey: agentModuleKey(key),
queryFn: () => loadAgentModuleConfig(key), queryFn: () => loadAgentModuleConfig(key),
@@ -245,28 +191,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) { export function useUpdateAgentModuleConfig(key: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
+11 -10
View File
@@ -826,14 +826,20 @@ export function useThreadStream({
return; 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 ( if (
typeof event === "object" && typeof event === "object" &&
event !== null && event !== null &&
"type" in event && "type" in event &&
event.type === "context_compacting" event.type === "context_compacting" &&
"message" in event &&
typeof event.message === "string" &&
event.message.trim()
) { ) {
return; const e = event as { type: "context_compacting"; message: string };
toast(e.message);
} }
}, },
onError(error) { onError(error) {
@@ -881,13 +887,6 @@ export function useThreadStream({
// made every click look like a crash and could loop indefinitely when a // made every click look like a crash and could loop indefinitely when a
// different tab's cookie kept overriding this tab's Bearer token. // different tab's cookie kept overriding this tab's Bearer token.
if (isThreadNotAccessibleError(message)) { if (isThreadNotAccessibleError(message)) {
const 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(); const userLabel = buildCurrentUserLabel();
toast.info( toast.info(
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`, `当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
@@ -1592,6 +1591,8 @@ export function useThreadStream({
: mergedContext.mode === "thinking" : mergedContext.mode === "thinking"
? "low" ? "low"
: undefined), : undefined),
memory_injection_enabled:
mergedContext.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled, rag_mode_enabled: ragModeEnabled,
summarization_enabled: contextCompressionEnabled, summarization_enabled: contextCompressionEnabled,
thread_id: threadId, thread_id: threadId,
@@ -1,46 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
myProjectConversationTypeFromSearchParams,
myProjectConversationTypeOfThread,
myProjectThreadMetadata,
withMyProjectConversationMarker,
} from "./project-metadata";
test("project conversation type is read from persisted metadata", () => {
assert.equal(
myProjectConversationTypeOfThread({
metadata: myProjectThreadMetadata("strategy"),
}),
"strategy",
);
assert.equal(
myProjectConversationTypeOfThread({
metadata: { project_source: "elsewhere", project_type: "qa" },
}),
null,
);
});
test("metadata wins over the compatibility context fallback", () => {
assert.equal(
myProjectConversationTypeOfThread({
metadata: myProjectThreadMetadata("solution"),
context: myProjectThreadMetadata("qa"),
}),
"solution",
);
});
test("new-chat route marker round-trips all supported project types", () => {
for (const type of ["qa", "strategy", "solution"] as const) {
const path = withMyProjectConversationMarker(
"/page/strategy/chats/new?keep=1",
type,
);
const params = new URLSearchParams(path.split("?", 2)[1]);
assert.equal(params.get("keep"), "1");
assert.equal(myProjectConversationTypeFromSearchParams(params), type);
}
});
@@ -1,94 +0,0 @@
/**
* Stable marker for conversations entered from 「我的项目」.
*
* Keep this separate from ``project_id``: that key belongs to DeerFlow's
* folder-style conversation projects, while this marker describes the product
* mode that created (or explicitly continued) a conversation.
*/
export const MY_PROJECT_THREAD_SOURCE = "my-projects" as const;
export const MY_PROJECT_THREAD_SOURCE_KEY = "project_source" as const;
export const MY_PROJECT_THREAD_TYPE_KEY = "project_type" as const;
export const MY_PROJECT_QUERY_SOURCE_KEY = "projectSource" as const;
export const MY_PROJECT_QUERY_TYPE_KEY = "projectType" as const;
export type MyProjectConversationType = "qa" | "strategy" | "solution";
export const MY_PROJECT_CONVERSATION_LABELS: Record<
MyProjectConversationType,
string
> = {
qa: "普通问答",
strategy: "策略优选",
solution: "方案生成",
};
const MY_PROJECT_CONVERSATION_TYPES = new Set<MyProjectConversationType>([
"qa",
"strategy",
"solution",
]);
function typeFromRecord(
value: Record<string, unknown> | null | undefined,
): MyProjectConversationType | null {
if (value?.[MY_PROJECT_THREAD_SOURCE_KEY] !== MY_PROJECT_THREAD_SOURCE) {
return null;
}
const type = value[MY_PROJECT_THREAD_TYPE_KEY];
return typeof type === "string" &&
MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType)
? (type as MyProjectConversationType)
: null;
}
export function myProjectThreadMetadata(
type: MyProjectConversationType,
): Record<string, unknown> {
return {
[MY_PROJECT_THREAD_SOURCE_KEY]: MY_PROJECT_THREAD_SOURCE,
[MY_PROJECT_THREAD_TYPE_KEY]: type,
};
}
/** Read persisted metadata first; context is only a compatibility fallback. */
export function myProjectConversationTypeOfThread(thread: {
metadata?: Record<string, unknown> | null;
context?: Record<string, unknown> | null;
}): MyProjectConversationType | null {
return typeFromRecord(thread.metadata) ?? typeFromRecord(thread.context);
}
export function myProjectConversationTypeFromSearchParams(params: {
get(name: string): string | null;
}): MyProjectConversationType | null {
if (params.get(MY_PROJECT_QUERY_SOURCE_KEY) !== MY_PROJECT_THREAD_SOURCE) {
return null;
}
const type = params.get(MY_PROJECT_QUERY_TYPE_KEY);
return typeof type === "string" &&
MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType)
? (type as MyProjectConversationType)
: null;
}
/** Add an explicit, one-shot marker to a new-conversation route. */
export function withMyProjectConversationMarker(
path: string,
type: MyProjectConversationType,
): string {
const hashIndex = path.indexOf("#");
const hash = hashIndex >= 0 ? path.slice(hashIndex) : "";
const pathWithoutHash = hashIndex >= 0 ? path.slice(0, hashIndex) : path;
const queryIndex = pathWithoutHash.indexOf("?");
const pathname =
queryIndex >= 0 ? pathWithoutHash.slice(0, queryIndex) : pathWithoutHash;
const params = new URLSearchParams(
queryIndex >= 0 ? pathWithoutHash.slice(queryIndex + 1) : "",
);
params.set(MY_PROJECT_QUERY_SOURCE_KEY, MY_PROJECT_THREAD_SOURCE);
params.set(MY_PROJECT_QUERY_TYPE_KEY, type);
const search = params.toString();
return `${pathname}${search ? `?${search}` : ""}${hash}`;
}
@@ -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");
});
+1
View File
@@ -19,6 +19,7 @@ export interface AgentThreadContext extends Record<string, unknown> {
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean; writing_mode?: boolean;
writing_artifact_path?: string; writing_artifact_path?: string;
memory_injection_enabled?: boolean;
rag_mode_enabled?: boolean; rag_mode_enabled?: boolean;
/** Per-request switch for context compression (上下文压缩). Default off. */ /** Per-request switch for context compression (上下文压缩). Default off. */
summarization_enabled?: boolean; summarization_enabled?: boolean;
+7 -30
View File
@@ -45,17 +45,10 @@ export function pathOfThread(
base = "/page/workspace", base = "/page/workspace",
) { ) {
const threadId = typeof thread === "string" ? thread : thread.thread_id; const threadId = typeof thread === "string" ? thread : thread.thread_id;
const projectId =
typeof thread === "string"
? undefined
: thread.metadata?.analysis_project_id;
if (typeof projectId === "string" && projectId) {
return `${base}/projects/${encodeURIComponent(projectId)}/chats/${encodeURIComponent(threadId)}`;
}
const agentRef = const agentRef =
typeof thread === "string" typeof thread === "string"
? pickAgentRef(context, null) ? pickAgentRef(context, null)
: (agentRefOfThread(thread) ?? pickAgentRef(context, null)); : agentRefOfThread(thread) ?? pickAgentRef(context, null);
return agentRef return agentRef
? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}` ? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
@@ -127,9 +120,7 @@ export function isSetupWizardThread(thread: {
metadata?: Record<string, unknown> | null; metadata?: Record<string, unknown> | null;
}): boolean { }): boolean {
const threadType = thread.metadata?.thread_type; const threadType = thread.metadata?.thread_type;
return ( return threadType === "writing_setup" || threadType === "report_structure_setup";
threadType === "writing_setup" || threadType === "report_structure_setup"
);
} }
/** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */ /** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */
@@ -139,10 +130,7 @@ export function isHiddenFromRecentChats(thread: {
const metadata = thread.metadata; const metadata = thread.metadata;
if (metadata?.system === true) return true; if (metadata?.system === true) return true;
const threadType = metadata?.thread_type; const threadType = metadata?.thread_type;
return ( return typeof threadType === "string" && HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType);
typeof threadType === "string" &&
HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType)
);
} }
/** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */ /** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */
@@ -163,27 +151,18 @@ export function bootstrapResumePath(threadId: string): string {
} }
/** 列表项点击目标:bootstrap 会话回创建页恢复,其余走普通问答路由。 */ /** 列表项点击目标:bootstrap 会话回创建页恢复,其余走普通问答路由。 */
export function listPathOfThread(thread: AgentThread, currentPath = ""): string { export function listPathOfThread(thread: AgentThread): string {
const projectId = thread.metadata?.analysis_project_id;
if (typeof projectId === "string" && projectId &&
(currentPath === "/page/strategy/qa/projects" || currentPath.startsWith("/page/strategy/qa/projects/"))) {
return `/page/strategy/qa/projects/groups/${encodeURIComponent(projectId)}/chats/${encodeURIComponent(thread.thread_id)}`;
}
return isBootstrapThread(thread) return isBootstrapThread(thread)
? bootstrapResumePath(thread.thread_id) ? bootstrapResumePath(thread.thread_id)
: pathOfThread(thread); : pathOfThread(thread);
} }
export function isThreadPinned(thread: AgentThread): boolean { export function isThreadPinned(thread: AgentThread): boolean {
return ( return (thread.metadata as Record<string, unknown> | null)?.is_pinned === true;
(thread.metadata as Record<string, unknown> | null)?.is_pinned === true
);
} }
export function isThreadFavorite(thread: AgentThread): boolean { export function isThreadFavorite(thread: AgentThread): boolean {
return ( return (thread.metadata as Record<string, unknown> | null)?.is_favorite === true;
(thread.metadata as Record<string, unknown> | null)?.is_favorite === true
);
} }
/** /**
@@ -200,9 +179,7 @@ export function sortThreadsPinnedFirst(threads: AgentThread[]): AgentThread[] {
return [...pinned, ...rest]; return [...pinned, ...rest];
} }
export function sanitizeThreadTitleForDisplay( export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
title: string | null | undefined,
) {
if (!title) return "Untitled"; if (!title) return "Untitled";
const cleaned = title 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", () => { test("a later user turn releases report.md live edits", () => {
const followUp = [ const followUp = [
{ type: "human", content: "写一篇论文" }, { type: "human", content: "写一篇论文" },
@@ -1,7 +1,5 @@
/** Only the writing-mode deliverable counts as a follow-up edit target. */ /** Only the writing-mode deliverable counts as a follow-up edit target. */
import { isWordDocumentFile } from "@/core/utils/files";
const OUTPUTS_PREFIX = "/mnt/user-data/outputs"; const OUTPUTS_PREFIX = "/mnt/user-data/outputs";
export function normalizeWritingPath(path: string | null | undefined): string { export function normalizeWritingPath(path: string | null | undefined): string {
@@ -107,11 +105,6 @@ export function shouldAutoOpenWritingArtifact(
if (!path) { if (!path) {
return false; return false;
} }
// DOC/DOCX are delivered as download-only files. They have no meaningful
// sandbox preview, so never let a completed Word artifact open the panel.
if (isWordDocumentFile(path)) {
return false;
}
if (!writingMode) { if (!writingMode) {
return true; return true;
} }
-7
View File
@@ -152,13 +152,6 @@ export function getFileExtension(filepath: string) {
return filepath.split(".").pop()!.toLocaleLowerCase(); 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([ const IMAGE_EXTENSIONS = new Set([
"png", "png",
"jpg", "jpg",
@@ -46,26 +46,3 @@ test("prepareGeneratedHtml injects the ES5 chart size shim and anchor handler",
assert.match(out, /getElementsByTagName\("canvas"\)/); assert.match(out, /getElementsByTagName\("canvas"\)/);
assert.doesNotMatch(out, /\?\.|=>|`/); assert.doesNotMatch(out, /\?\.|=>|`/);
}); });
test("does not inject visualization runtimes into an ordinary HTML page", () => {
const html = `<!doctype html><html><body><h1>普通页面</h1></body></html>`;
const out = prepareGeneratedHtml(html);
assert.doesNotMatch(out, /data-df-html-runtime/);
assert.doesNotMatch(out, /\/assets\/(?:mermaid|echarts|leaflet)/);
});
test("keeps the explicit Gateway HTML runtime used by new pages", () => {
const html = `<!doctype html><html><head>
<link rel="stylesheet" href="/api/public/html-runtime/leaflet.css">
<script defer src="/api/public/html-runtime/leaflet.js"></script>
<script defer src="/api/public/html-runtime/deerflow-runtime.js"></script>
</head><body><div id="map" class="df-map"></div>
<script type="application/json" data-df-map="map">{"markers":[]}</script></body></html>`;
const out = prepareGeneratedHtml(html);
assert.match(out, /\/api\/public\/html-runtime\/leaflet\.js/);
assert.match(out, /\/api\/public\/html-runtime\/deerflow-runtime\.js/);
assert.doesNotMatch(out, /data-df-html-runtime=/);
assert.doesNotMatch(out, /localhost\/assets\/leaflet/);
});
@@ -13,144 +13,8 @@ import { injectInPageAnchorHandler } from "./markdown";
const MAX_PASSES = 12; const MAX_PASSES = 12;
export type GeneratedHtmlRuntimeAssets = { export function prepareGeneratedHtml(html: string): string {
g6ScriptUrl: string; return injectInPageAnchorHandler(injectLegacyChartShim(downlevelModernColors(html ?? "")));
mermaidScriptUrl: string;
echartsScriptUrl: string;
echartsChinaGeoJson: unknown;
mapTileUrl: string;
mapTileAttribution: string;
leafletScriptUrl: string;
leafletStyleText: string;
};
export function prepareGeneratedHtml(
html: string,
): string {
const prepared = injectLegacyChartShim(downlevelModernColors(html ?? ""));
return injectInPageAnchorHandler(prepared);
}
const KNOWN_RUNTIME_SCRIPT_RE = /<script\b([^>]*?)\bsrc\s*=\s*(["'])([^"']+)\2([^>]*)>\s*<\/script\s*>/gi;
const KNOWN_RUNTIME_STYLE_RE = /<link\b([^>]*?)\bhref\s*=\s*(["'])([^"']+)\2([^>]*)>/gi;
function runtimeKind(url: string): "g6" | "mermaid" | "echarts" | "leaflet" | null {
if (/(?:@antv\/g6|(?:^|\/)g6(?:\.min)?\.js)/i.test(url)) return "g6";
if (/mermaid(?:\.min)?\.(?:js|mjs)|\bmermaid@/i.test(url)) return "mermaid";
if (/echarts(?:\.min)?\.js|\becharts@/i.test(url)) return "echarts";
if (/leaflet(?:\.min)?\.(?:js|css)|\bleaflet@/i.test(url)) return "leaflet";
return null;
}
function escapeHtmlAttribute(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function safeJsonForScript(value: unknown): string {
return JSON.stringify(value).replace(/</g, "\\u003c");
}
function safeStyleText(value: string): string {
return value.replace(/<\/style/gi, "<\\/style");
}
function injectAfterHeadOpen(html: string, tags: string): string {
if (!tags) return html;
if (/<head\b[^>]*>/i.test(html)) {
return html.replace(/<head\b[^>]*>/i, (head) => `${head}${tags}`);
}
if (/<html\b[^>]*>/i.test(html)) {
return html.replace(/<html\b[^>]*>/i, (root) => `${root}<head>${tags}</head>`);
}
return `<head>${tags}</head>${html}`;
}
function injectBeforeBodyClose(html: string, tags: string): string {
if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, `${tags}</body>`);
return html + tags;
}
/**
* Replace CDN visualization runtimes with app-bundled assets and initialize the
* declarative contracts used by generated HTML pages. The iframe remains
* sandboxed without allow-same-origin; only script/style URLs are substituted.
*/
export function injectGeneratedHtmlRuntimes(
html: string,
assets: GeneratedHtmlRuntimeAssets,
): string {
// New generated pages load their browser runtimes directly from Gateway.
// Leave those explicit source references untouched; this fallback only
// applies to older artifacts that used the declarative blocks without them.
if (/\/api\/public\/html-runtime\/deerflow-runtime\.js/i.test(html)) return html;
if (!html || html.includes("data-df-html-runtime")) return html;
const needsG6 = /data-df-flow\s*=|\bG6\s*\.\s*Graph\b|@antv\/g6|(?:^|[/'"])[gG]6(?:\.min)?\.js/i.test(html);
const needsMermaid = /class\s*=\s*["'][^"']*\bmermaid\b|\bmermaid\s*\.|mermaid(?:\.min)?\.(?:js|mjs)|\bmermaid@/i.test(html);
const needsEcharts = /data-df-chart\s*=|\becharts\s*\.|echarts(?:\.min)?\.js|\becharts@/i.test(html);
const needsLeaflet = /data-df-map\s*=|\bL\s*\.\s*map\s*\(|leaflet(?:\.min)?\.(?:js|css)|\bleaflet@/i.test(html);
const needsEchartsChinaMap = needsEcharts && /\bmap\s*:\s*["']china["']|["']map["']\s*:\s*["']china["']/i.test(html);
if (!needsG6 && !needsMermaid && !needsEcharts && !needsLeaflet) return html;
let prepared = html.replace(
KNOWN_RUNTIME_SCRIPT_RE,
(tag, _before: string, _quote: string, url: string) =>
runtimeKind(url) ? "" : tag,
);
prepared = prepared.replace(
KNOWN_RUNTIME_STYLE_RE,
(tag, _before: string, _quote: string, url: string) =>
runtimeKind(url) === "leaflet" ? "" : tag,
);
const headTags = [
needsLeaflet
? `<style data-df-html-runtime="leaflet-css">${safeStyleText(assets.leafletStyleText)}</style>`
: "",
needsG6
? `<script data-df-html-runtime="g6" src="${escapeHtmlAttribute(assets.g6ScriptUrl)}"></script>`
: "",
needsMermaid
? `<script data-df-html-runtime="mermaid" src="${escapeHtmlAttribute(assets.mermaidScriptUrl)}"></script>`
: "",
needsEcharts
? `<script data-df-html-runtime="echarts" src="${escapeHtmlAttribute(assets.echartsScriptUrl)}"></script>`
: "",
needsEchartsChinaMap
? `<script data-df-html-runtime="echarts-china-map">if(window.echarts){window.echarts.registerMap("china",${safeJsonForScript(assets.echartsChinaGeoJson)});}</script>`
: "",
needsLeaflet
? `<script data-df-html-runtime="leaflet" src="${escapeHtmlAttribute(assets.leafletScriptUrl)}"></script>`
: "",
`<style data-df-html-runtime="styles">.df-flow,.df-chart,.df-map{position:relative;min-height:360px;width:100%}.df-runtime-error{box-sizing:border-box;margin:12px 0;padding:12px 14px;border:1px solid #fca5a5;border-radius:8px;background:#fef2f2;color:#991b1b;font:14px/1.5 sans-serif}.df-flow-summary{margin:10px 0;color:inherit;font:13px/1.6 sans-serif}.df-flow-summary summary{cursor:pointer;font-weight:600}.df-flow-summary ol{margin:8px 0 0;padding-left:24px}.df-map-tile-error{position:absolute;z-index:1000;left:12px;right:12px;bottom:12px;padding:9px 12px;border-radius:7px;background:rgba(127,29,29,.92);color:#fff;font:13px/1.4 sans-serif;pointer-events:none}</style>`,
].join("");
prepared = injectAfterHeadOpen(prepared, headTags);
return injectBeforeBodyClose(
prepared,
generatedHtmlRuntimeBootstrap(assets.mapTileUrl, assets.mapTileAttribution),
);
}
function generatedHtmlRuntimeBootstrap(mapTileUrl: string, mapTileAttribution: string): string {
return `<script data-df-html-runtime="bootstrap">(function(){
function ready(fn){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",fn);else fn();}
function errorBox(target,message){var box=document.createElement("div");box.className="df-runtime-error";box.setAttribute("role","alert");box.textContent=message;if(target&&target.parentNode)target.parentNode.insertBefore(box,target.nextSibling);}
function byId(id){return id?document.getElementById(id):null;}
function jsonBlocks(name){return document.querySelectorAll('script[type="application/json"]['+name+']');}
function parse(block){try{return JSON.parse(block.textContent||"{}");}catch(e){errorBox(block,"图形数据格式错误:"+(e&&e.message?e.message:"无法解析 JSON"));return null;}}
function positiveSize(target){if(!target)return false;if(target.clientHeight<40)target.style.minHeight="360px";if(target.clientWidth<40)target.style.width="100%";return true;}
function initFlows(){var blocks=jsonBlocks("data-df-flow");for(var i=0;i<blocks.length;i++){var block=blocks[i],target=byId(block.getAttribute("data-df-flow")),data=parse(block);if(!target||!data)continue;if(!window.G6||!window.G6.Graph){errorBox(target,"流程图运行库加载失败,请刷新页面后重试");continue;}try{positiveSize(target);var nodes=data.nodes||[],edges=data.edges||[];target.setAttribute("role","img");target.setAttribute("aria-label",(data.title||"流程图")+",包含 "+nodes.length+" 个步骤和 "+edges.length+" 条连线");var colors={start:["#0f766e","#2dd4bf"],end:["#7c3aed","#a78bfa"],decision:["#b45309","#f59e0b"],risk:["#b91c1c","#f87171"],process:["#1d4ed8","#60a5fa"],stage:["#4338ca","#818cf8"],task:["#334155","#94a3b8"]};var shapes={start:"ellipse",end:"ellipse",decision:"diamond",risk:"hexagon",process:"rect",stage:"rect",task:"rect"};var graphData={nodes:[],edges:[]};for(var n=0;n<nodes.length;n++){var node=nodes[n],pair=colors[node.type]||colors.task;graphData.nodes.push({id:String(node.id),data:{label:node.label||node.id,description:node.description||"",fill:node.color||pair[0],stroke:pair[1],shape:shapes[node.type]||"rect"}});}for(var e=0;e<edges.length;e++){var edge=edges[e];graphData.edges.push({source:String(edge.source),target:String(edge.target),data:{label:edge.label||""}});}var graph=new window.G6.Graph({container:target,data:graphData,autoFit:"view",padding:32,animation:!(window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches),layout:{type:"dagre",rankdir:data.direction==="TB"?"TB":"LR",nodesep:36,ranksep:72},node:{type:function(d){return d.data.shape;},style:{size:[176,68],radius:14,fill:function(d){return d.data.fill;},stroke:function(d){return d.data.stroke;},lineWidth:2,shadowColor:"rgba(15,23,42,.28)",shadowBlur:12,labelText:function(d){return d.data.label+(d.data.description?"\n"+d.data.description:"");},labelFill:"#f8fafc",labelFontSize:14,labelFontWeight:600,labelLineHeight:20,labelWordWrap:true,labelMaxWidth:148}},edge:{type:"polyline",style:{stroke:"#94a3b8",lineWidth:2,radius:12,endArrow:true,labelText:function(d){return d.data.label;},labelFill:"#475569",labelFontSize:12,labelBackground:true,labelBackgroundFill:"#f8fafc",labelBackgroundRadius:6,labelPadding:[4,7]}},behaviors:["drag-canvas","zoom-canvas","drag-element"]});var rendered=graph.render();if(rendered&&rendered.catch)rendered.catch(function(err){errorBox(target,"流程图渲染失败:"+(err&&err.message?err.message:"请检查流程数据"));});var summary=document.createElement("details");summary.className="df-flow-summary";var heading=document.createElement("summary");heading.textContent="查看流程步骤";summary.appendChild(heading);var list=document.createElement("ol");for(var s=0;s<nodes.length;s++){var item=document.createElement("li");item.textContent=(nodes[s].label||nodes[s].id)+(nodes[s].description?" — "+nodes[s].description:"");list.appendChild(item);}summary.appendChild(list);target.parentNode.insertBefore(summary,target.nextSibling);(function(instance){window.addEventListener("resize",function(){instance.resize();});})(graph);}catch(err){errorBox(target,"流程图渲染失败:"+(err&&err.message?err.message:"请检查流程数据"));}}}
function initMermaid(){var nodes=document.querySelectorAll(".mermaid");if(!nodes.length)return;if(!window.mermaid){for(var i=0;i<nodes.length;i++)errorBox(nodes[i],"流程图库加载失败,请刷新页面后重试");return;}try{window.mermaid.initialize({startOnLoad:false,securityLevel:"strict",theme:"default"});var result=window.mermaid.run({nodes:nodes});if(result&&result.catch)result.catch(function(e){errorBox(nodes[0],"流程图渲染失败:"+(e&&e.message?e.message:"请检查 Mermaid 语法"));});}catch(e){errorBox(nodes[0],"流程图渲染失败:"+(e&&e.message?e.message:"请检查 Mermaid 语法"));}}
function initCharts(){var blocks=jsonBlocks("data-df-chart");for(var i=0;i<blocks.length;i++){var block=blocks[i],target=byId(block.getAttribute("data-df-chart")),option=parse(block);if(!target||!option)continue;if(!window.echarts){errorBox(target,"图表运行库加载失败,请刷新页面后重试");continue;}try{positiveSize(target);target.setAttribute("role","img");target.setAttribute("aria-label",option.title&&option.title.text?option.title.text:"数据图表");var chart=window.echarts.init(target);chart.setOption(option);(function(instance){window.addEventListener("resize",function(){instance.resize();});})(chart);}catch(e){errorBox(target,"图表渲染失败:"+(e&&e.message?e.message:"请检查图表数据"));}}}
function lngLat(value){if(!value||value.length<2)return null;var lng=Number(value[0]),lat=Number(value[1]);if(!isFinite(lng)||!isFinite(lat)||lng<-180||lng>180||lat<-90||lat>90)return null;return [lat,lng];}
function escapeText(value){return String(value==null?"":value).replace(/[&<>\"']/g,function(ch){return {"&":"&amp;","<":"&lt;",">":"&gt;",'\"':"&quot;","'":"&#39;"}[ch];});}
function initMaps(){var blocks=jsonBlocks("data-df-map");for(var i=0;i<blocks.length;i++){var block=blocks[i],target=byId(block.getAttribute("data-df-map")),data=parse(block);if(!target||!data)continue;if(!window.L){errorBox(target,"地图运行库加载失败,请刷新页面后重试");continue;}try{positiveSize(target);target.setAttribute("role","region");target.setAttribute("aria-label",data.title||"地图");var map=window.L.map(target,{zoomControl:true});var tileErrors=0;window.L.tileLayer(${safeJsonForScript(mapTileUrl)},{maxZoom:20,attribution:${safeJsonForScript(mapTileAttribution)}}).on("tileerror",function(){tileErrors+=1;if(tileErrors===3){var warning=document.createElement("div");warning.className="df-map-tile-error";warning.setAttribute("role","status");warning.textContent="地图底图加载失败;地点、路线和区域数据仍可查看";target.appendChild(warning);}}).addTo(map);var bounds=[];var markers=data.markers||[];for(var m=0;m<markers.length;m++){var marker=markers[m],point=lngLat(marker.coordinate);if(!point)continue;bounds.push(point);var dot=window.L.circleMarker(point,{radius:7,color:"#1d4ed8",weight:2,fillColor:"#3b82f6",fillOpacity:.9}).addTo(map);var popup="<strong>"+escapeText(marker.name||"地点")+"</strong>"+(marker.description?"<br>"+escapeText(marker.description):"");dot.bindPopup(popup);dot.bindTooltip(escapeText(marker.name||"地点"));}var lines=data.lines||[];for(var l=0;l<lines.length;l++){var linePoints=[];var rawLine=lines[l].coordinates||[];for(var p=0;p<rawLine.length;p++){var lp=lngLat(rawLine[p]);if(lp){linePoints.push(lp);bounds.push(lp);}}if(linePoints.length>1)window.L.polyline(linePoints,{color:"#2563eb",weight:3,opacity:.85}).addTo(map);}var polygons=data.polygons||[];for(var g=0;g<polygons.length;g++){var polygonPoints=[];var rawPolygon=polygons[g].coordinates||[];for(var q=0;q<rawPolygon.length;q++){var pp=lngLat(rawPolygon[q]);if(pp){polygonPoints.push(pp);bounds.push(pp);}}if(polygonPoints.length>2)window.L.polygon(polygonPoints,{color:"#7c3aed",weight:2,fillColor:"#8b5cf6",fillOpacity:.2}).addTo(map);}var center=lngLat(data.center);if(center)map.setView(center,Number(data.zoom)||6);else if(bounds.length)map.fitBounds(bounds,{padding:[28,28],maxZoom:Number(data.zoom)||10});else map.setView([35.8617,104.1954],Number(data.zoom)||4);(function(instance){setTimeout(function(){instance.invalidateSize();},0);})(map);}catch(e){errorBox(target,"地图渲染失败:"+(e&&e.message?e.message:"请检查地图数据"));}}}
ready(function(){initFlows();initMermaid();initCharts();initMaps();});
})();</script>`;
} }
export function downlevelModernColors(html: string): string { export function downlevelModernColors(html: string): string {
@@ -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" : "";
}
@@ -116,14 +116,6 @@ export interface EightBfTaskProcessReportReceipt {
id: string; id: string;
} }
/**
* 外部任务系统要求 8BF 会商页提交的过程产物携带 eightTask=1,
* 用于与岗位会商等其它入口的产物区分来源。
*/
function processReportBody(input: EightBfTaskProcessReportInput): string {
return JSON.stringify({ ...input, eightTask: 1 });
}
/** /**
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用 * 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。 * `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
@@ -165,10 +157,7 @@ async function externalTaskError(response: Response, fallback: string): Promise<
} }
function findProcessReportId(payload: unknown): string { function findProcessReportId(payload: unknown): string {
if (payload == null) return ""; if (!payload || typeof payload !== "object") return "";
// 外部任务系统的实际返回形如 {code:200, data:307, msg:"success"}:
// `data` 直接就是新增记录的 id(数字 / 字符串),不再包一层对象。
if (typeof payload !== "object") return firstText(payload);
const source = payload as Record<string, unknown>; const source = payload as Record<string, unknown>;
const direct = firstText( const direct = firstText(
source.id, source.id,
@@ -181,10 +170,9 @@ function findProcessReportId(payload: unknown): string {
} }
/** /**
* Create the external task system's process report **and submit it in one * Create the external task system's process report in its unsubmitted state.
* call**(submitStatus=1). Response shape is `{code:200, data:<id>, msg}` — * The local gateway stores the returned id so a refresh can later submit the
* `data` is the new record id. The local gateway stores that id so a refresh * same report instead of creating a duplicate one.
* can still reconcile the same report instead of creating a duplicate.
*/ */
export async function createEightBfTaskProcessReport( export async function createEightBfTaskProcessReport(
input: EightBfTaskProcessReportInput, input: EightBfTaskProcessReportInput,
@@ -192,7 +180,7 @@ export async function createEightBfTaskProcessReport(
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, { const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: processReportBody(input), body: JSON.stringify(input),
}); });
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败"); if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
const id = findProcessReportId(await response.json().catch(() => null)); const id = findProcessReportId(await response.json().catch(() => null));
@@ -200,7 +188,7 @@ export async function createEightBfTaskProcessReport(
return { id }; 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( export async function updateEightBfTaskProcessReport(
reportId: string, reportId: string,
input: EightBfTaskProcessReportInput, input: EightBfTaskProcessReportInput,
@@ -210,7 +198,7 @@ export async function updateEightBfTaskProcessReport(
{ {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: processReportBody(input), body: JSON.stringify(input),
}, },
); );
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败"); if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
@@ -31,8 +31,6 @@ import {
syncPositionOptionsFromRoles, syncPositionOptionsFromRoles,
} from "@/position-roundtable/lib/positions"; } from "@/position-roundtable/lib/positions";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts"; import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent";
import { updateChain } from "@/roundtable-planning/api/chains";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent"; import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import "@/position-roundtable/styles/intent-understanding.css"; import "@/position-roundtable/styles/intent-understanding.css";
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel"; import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
@@ -47,8 +45,6 @@ export interface EightBfStep1PanelProps extends Step1PanelProps {
chain: BusinessChain | null; chain: BusinessChain | null;
canConfigureChain: boolean; canConfigureChain: boolean;
onConfigureChain: () => void; onConfigureChain: () => void;
/** 业务链被本面板更新(产物 position 等)后回调,页面据此刷新链配置。 */
onChainUpdated?: (chain: BusinessChain) => void;
selectedModel: string; selectedModel: string;
modelOptions: Array<{ label: string; value: string }>; modelOptions: Array<{ label: string; value: string }>;
onSelectedModelChange: (model: string) => void; onSelectedModelChange: (model: string) => void;
@@ -87,7 +83,6 @@ export function EightBfStep1Panel({
chain, chain,
canConfigureChain, canConfigureChain,
onConfigureChain, onConfigureChain,
onChainUpdated,
selectedModel, selectedModel,
modelOptions, modelOptions,
onSelectedModelChange, onSelectedModelChange,
@@ -101,8 +96,6 @@ export function EightBfStep1Panel({
}: EightBfStep1PanelProps) { }: EightBfStep1PanelProps) {
const [rightPanelOpen, setRightPanelOpen] = useState(true); const [rightPanelOpen, setRightPanelOpen] = useState(true);
const [rightTab, setRightTab] = useState<"agents" | "results">("agents"); const [rightTab, setRightTab] = useState<"agents" | "results">("agents");
// 第一步内置任务理解智能体:其「常用问题」显示在输入框左上角,点击直接发送。
const { agent: intentAgent } = useAgent(EIGHTBF_INTENT_AGENT_ID);
const openPanel = (tab: "agents" | "results") => { const openPanel = (tab: "agents" | "results") => {
if (rightPanelOpen && rightTab === tab) { if (rightPanelOpen && rightTab === tab) {
@@ -157,7 +150,6 @@ export function EightBfStep1Panel({
chain={chain} chain={chain}
canConfigureChain={canConfigureChain} canConfigureChain={canConfigureChain}
onConfigureChain={onConfigureChain} onConfigureChain={onConfigureChain}
onChainUpdated={onChainUpdated}
onCollapse={() => setRightPanelOpen(false)} onCollapse={() => setRightPanelOpen(false)}
step2Dialogues={step2Dialogues} step2Dialogues={step2Dialogues}
step2ThreadIds={step2ThreadIds} step2ThreadIds={step2ThreadIds}
@@ -175,8 +167,6 @@ export function EightBfStep1Panel({
modelOptions={modelOptions} modelOptions={modelOptions}
selectedModel={selectedModel} selectedModel={selectedModel}
onSelectedModelChange={onSelectedModelChange} onSelectedModelChange={onSelectedModelChange}
commonQuestions={intentAgent?.common_questions ?? null}
sendCommonQuestionOnClick
/> />
)} )}
intentReady={intentReady} intentReady={intentReady}
@@ -304,7 +294,6 @@ function EightBfAgentWorkspace({
chain, chain,
canConfigureChain, canConfigureChain,
onConfigureChain, onConfigureChain,
onChainUpdated,
onCollapse, onCollapse,
step2Dialogues, step2Dialogues,
step2ThreadIds, step2ThreadIds,
@@ -316,7 +305,6 @@ function EightBfAgentWorkspace({
chain: BusinessChain | null; chain: BusinessChain | null;
canConfigureChain: boolean; canConfigureChain: boolean;
onConfigureChain: () => void; onConfigureChain: () => void;
onChainUpdated?: (chain: BusinessChain) => void;
onCollapse: () => void; onCollapse: () => void;
step2Dialogues: unknown[]; step2Dialogues: unknown[];
step2ThreadIds: ThreadIdMap | null; step2ThreadIds: ThreadIdMap | null;
@@ -343,26 +331,8 @@ function EightBfAgentWorkspace({
summaryReport: step3Summary, summaryReport: step3Summary,
}); });
// 第一步绑定的内置任务理解智能体:不在业务链席位里,固定置顶展示,
// 供管理员在本面板查看 / 配置其提示词与常用问题。
const intentAgent = useMemo(
() => agents.find((agent) => agent.id === EIGHTBF_INTENT_AGENT_ID) ?? null,
[agents],
);
const intentAgentMatchesKeyword = useMemo(() => {
const keyword = agentKeyword.trim().toLowerCase();
if (!keyword) return true;
const name = (intentAgent?.name || "8BF 任务理解").toLowerCase();
const description = (intentAgent?.description || "").toLowerCase();
return name.includes(keyword) || description.includes(keyword);
}, [agentKeyword, intentAgent]);
useEffect(() => { useEffect(() => {
if ( if (selectedAgentId && !seats.some((seat) => seat.agent_id === selectedAgentId)) {
selectedAgentId &&
selectedAgentId !== EIGHTBF_INTENT_AGENT_ID &&
!seats.some((seat) => seat.agent_id === selectedAgentId)
) {
setSelectedAgentId(null); setSelectedAgentId(null);
} }
}, [seats, selectedAgentId]); }, [seats, selectedAgentId]);
@@ -442,12 +412,7 @@ function EightBfAgentWorkspace({
) : ( ) : (
<AgentDetailPanel <AgentDetailPanel
agent={selectedAgent} agent={selectedAgent}
agentName={ agentName={selectedAgent?.name ?? selectedSeat?.name ?? ""}
selectedAgent?.name
?? (selectedAgentId === EIGHTBF_INTENT_AGENT_ID
? "8BF 任务理解"
: selectedSeat?.name ?? "")
}
onAskQuestion={() => toast.info("请在中间对话区向任务理解智能体发起问题")} onAskQuestion={() => toast.info("请在中间对话区向任务理解智能体发起问题")}
showRecentChats={false} showRecentChats={false}
avatarSrc={agentConfigLogo} avatarSrc={agentConfigLogo}
@@ -455,13 +420,6 @@ function EightBfAgentWorkspace({
jobResponsibilities={selectedSeat?.job_responsibilities ?? ""} jobResponsibilities={selectedSeat?.job_responsibilities ?? ""}
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />} skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
skillsTitle="技能列表" skillsTitle="技能列表"
belowSkillsSlot={chain && selectedSeat ? (
<SeatArtifactPositionEditor
chain={chain}
seat={selectedSeat}
onUpdated={onChainUpdated}
/>
) : undefined}
showCommonQuestions showCommonQuestions
panelTitle="智能体配置" panelTitle="智能体配置"
embedded embedded
@@ -510,7 +468,7 @@ function EightBfAgentWorkspace({
</header> </header>
{isLoading && seats.length > 0 ? ( {isLoading && seats.length > 0 ? (
<div className="eightbf-roundtable__agent-selector-empty">正在加载智能体…</div> <div className="eightbf-roundtable__agent-selector-empty">正在加载智能体…</div>
) : filteredSeatAgents.length === 0 && !intentAgentMatchesKeyword ? ( ) : filteredSeatAgents.length === 0 ? (
<div className="eightbf-roundtable__agent-selector-empty"> <div className="eightbf-roundtable__agent-selector-empty">
<p>{seatAgents.length ? "没有符合条件的智能体" : "当前岗位尚未配置智能体"}</p> <p>{seatAgents.length ? "没有符合条件的智能体" : "当前岗位尚未配置智能体"}</p>
{!seatAgents.length && canConfigureChain && ( {!seatAgents.length && canConfigureChain && (
@@ -519,13 +477,6 @@ function EightBfAgentWorkspace({
</div> </div>
) : ( ) : (
<div className="eightbf-roundtable__agent-selector-list"> <div className="eightbf-roundtable__agent-selector-list">
{intentAgentMatchesKeyword && (
<EightBfAgentCard
name={intentAgent?.name || "8BF 任务理解"}
description={intentAgent?.description || "第一步「意图识别」的内置任务理解智能体"}
onSelect={() => setSelectedAgentId(EIGHTBF_INTENT_AGENT_ID)}
/>
)}
{filteredSeatAgents.map(({ seat, agent }) => ( {filteredSeatAgents.map(({ seat, agent }) => (
<EightBfAgentRow <EightBfAgentRow
key={seat.agent_id} key={seat.agent_id}
@@ -552,19 +503,6 @@ function EightBfAgentRow({
}) { }) {
const name = agent?.name || seat.name || "未命名智能体"; const name = agent?.name || seat.name || "未命名智能体";
const description = agent?.description || seat.description || "智能体助手"; const description = agent?.description || seat.description || "智能体助手";
return <EightBfAgentCard name={name} description={description} onSelect={onSelect} />;
}
/** 智能体列表卡片:席位智能体与置顶的内置任务理解智能体共用同一展示结构。 */
function EightBfAgentCard({
name,
description,
onSelect,
}: {
name: string;
description: string;
onSelect: () => void;
}) {
const avatarText = firstAgentAvatarChar(name); const avatarText = firstAgentAvatarChar(name);
return ( return (
<button type="button" className="eightbf-roundtable__agent-selector-card" onClick={onSelect}> <button type="button" className="eightbf-roundtable__agent-selector-card" onClick={onSelect}>
@@ -626,82 +564,3 @@ function arrayValue(...values: unknown[]): string[] {
} }
return []; return [];
} }
/**
* 「智能体配置」面板里、技能列表下方的「产物提交 position」编辑器。
*
* 直接读写该席位在业务链上配置的 artifact_position(保存即 PUT 业务链的 seats),
* 用户无需去业务链编辑页找链配 position,也不易配错链。只有业务链 owner / 管理员
* 能保存(后端校验);提交产物时按席位取这里保存的值,留空按 0。
*/
function SeatArtifactPositionEditor({
chain,
seat,
onUpdated,
}: {
chain: BusinessChain;
seat: ChainSeat;
onUpdated?: (chain: BusinessChain) => void;
}) {
const [value, setValue] = useState(seat.artifact_position?.toString() ?? "");
const [saving, setSaving] = useState(false);
// 席位切换 / 外部链数据刷新时,回填链上的当前值。
useEffect(() => {
setValue(seat.artifact_position?.toString() ?? "");
}, [seat.agent_id, seat.artifact_position]);
const trimmed = value.trim();
const parsed = trimmed === "" ? null : Number.parseInt(trimmed, 10);
const nextValue = parsed !== null && Number.isFinite(parsed) ? parsed : null;
const dirty = nextValue !== (seat.artifact_position ?? null);
const handleSave = async () => {
if (saving || !dirty) return;
setSaving(true);
try {
const seats = chain.seats.map((s) =>
s.agent_id === seat.agent_id ? { ...s, artifact_position: nextValue } : s,
);
const updated = await updateChain(chain.id, { seats });
if (!updated) throw new Error("业务链已不存在,可能被删除");
onUpdated?.(updated);
toast.success(`已保存「${seat.name || seat.agent_id}」的产物 position`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存产物 position 失败");
} finally {
setSaving(false);
}
};
return (
<div className="mt-3 rounded-lg border border-[#2b8ffe]/30 bg-[#042b59]/60 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-[#aaceff]">产物提交 position</span>
<div className="flex items-center gap-1.5">
<input
type="number"
inputMode="numeric"
value={value}
placeholder="0"
aria-label="产物提交 position"
onChange={(event) => setValue(event.target.value)}
className="h-7 w-20 rounded-md border border-[#2b8ffe]/40 bg-[#032680]/60 px-2 text-xs font-semibold tabular-nums text-[#e8f2ff] outline-none focus:border-[#2b8ffe] [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<button
type="button"
disabled={!dirty || saving}
onClick={() => void handleSave()}
className="inline-flex h-7 items-center gap-1 rounded-md border border-[#2b8ffe]/50 bg-[#0a3c8f]/70 px-2.5 text-xs font-semibold text-[#e8f2ff] transition hover:bg-[#0a3c8f] disabled:cursor-not-allowed disabled:opacity-50"
>
{saving && <Loader2 className="size-3 animate-spin" />}
保存
</button>
</div>
</div>
<p className="mt-1.5 text-[10px] leading-relaxed text-[#8aa0c4]">
该席位提交过程产物时传给任务系统的 position 值,留空按 0 提交。保存会写入业务链「{chain.title}」,仅链所有者 / 管理员可保存。
</p>
</div>
);
}
@@ -3,7 +3,6 @@ import { useSearchParams } from "react-router-dom";
import { Loader2, Save, X } from "lucide-react"; import { Loader2, Save, X } from "lucide-react";
import { getAccountDisplayName, getStoredAuth } from "@/core/auth"; import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent";
import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard"; import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard";
import { import {
EightBfStep1Panel, EightBfStep1Panel,
@@ -77,9 +76,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
const [selectedChainId, setSelectedChainId] = useState(""); const [selectedChainId, setSelectedChainId] = useState("");
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// 第二步「人工干预」:开启后每走一步暂停等待用户输入;关闭自动推进。会话内状态,
// 新建会话 / 刷新后回到默认关闭(与现状一致)。
const [step2Intervention, setStep2Intervention] = useState(false);
const configuredChain = useMemo( const configuredChain = useMemo(
() => chains.find((chain) => chain.id === mapping?.chainId) ?? null, () => chains.find((chain) => chain.id === mapping?.chainId) ?? null,
[chains, mapping?.chainId], [chains, mapping?.chainId],
@@ -88,50 +84,14 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
() => buildEightBfTaskContext(task, taskDictionaries), () => buildEightBfTaskContext(task, taskDictionaries),
[task, taskDictionaries], [task, taskDictionaries],
); );
// 席位「产物提交 position」有效取值映射(提交产物时按席位智能体取值):
// 1) 绑定链的席位 —— 键收录小写 agent_id + 小写席位名(会话侧席位标识在不同
// 表面可能以 id 或名称出现,双键匹配避免查不到);
// 2) 其它公共链上同一 agent_id 配置的值(历史会话的席位可能来自切换绑定前的
// 旧链,agent_id 跨链唯一指向同一智能体,可安全回退;**名称不跨链匹配**,
// 避免不同链的同名席位互相误配)。
// 绑定链优先(先写入),未配置的席位不进 map(提交时回退默认 0)。
const seatArtifactPositions = useMemo(() => {
const map: Record<string, number> = {};
const put = (key: string, value: number) => {
const normalized = key.trim().toLowerCase();
if (normalized && !(normalized in map)) map[normalized] = value;
};
for (const seat of configuredChain?.seats ?? []) {
if (typeof seat.artifact_position === "number" && Number.isFinite(seat.artifact_position)) {
put(seat.agent_id, seat.artifact_position);
if (seat.name?.trim()) put(seat.name, seat.artifact_position);
}
}
for (const chain of chains) {
if (chain.id === configuredChain?.id) continue;
for (const seat of chain.seats ?? []) {
if (typeof seat.artifact_position === "number" && Number.isFinite(seat.artifact_position)) {
put(seat.agent_id, seat.artifact_position);
}
}
}
return map;
}, [chains, configuredChain]);
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>( const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
() => taskId ? { () => taskId ? {
taskId, taskId,
position: 0, position: 0,
positionFor: (target) => {
const agentId = target.agentId?.trim();
if (!agentId) return null;
return seatArtifactPositions[agentId.toLowerCase()]
?? seatArtifactPositions[target.agentName?.trim().toLowerCase() ?? ""]
?? null;
},
createProcessReport: createEightBfTaskProcessReport, createProcessReport: createEightBfTaskProcessReport,
updateProcessReport: updateEightBfTaskProcessReport, updateProcessReport: updateEightBfTaskProcessReport,
} : undefined, } : undefined,
[taskId, seatArtifactPositions], [taskId],
); );
useEffect(() => { useEffect(() => {
@@ -235,12 +195,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
if (canConfigureChain) setIsSettingsOpen(true); if (canConfigureChain) setIsSettingsOpen(true);
}, [canConfigureChain]); }, [canConfigureChain]);
// 「智能体配置」面板里保存产物 position 后,把最新链数据写回页面状态,
// 让 seatArtifactPositions / 提交产物取值即时生效(无需刷新页面)。
const handleChainUpdated = useCallback((updated: BusinessChain) => {
setChains((prev) => prev.map((chain) => (chain.id === updated.id ? updated : chain)));
}, []);
const toolbarSlot = ( const toolbarSlot = (
<> <>
{canConfigureChain && isSettingsOpen && ( {canConfigureChain && isSettingsOpen && (
@@ -306,12 +260,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
// 无 taskId 进入时每次都开全新会话(不回显 anonymous 键下的最新草稿);带 taskId 保持回显现状。 // 无 taskId 进入时每次都开全新会话(不回显 anonymous 键下的最新草稿);带 taskId 保持回显现状。
skipInitialDraftRestore={!taskId} skipInitialDraftRestore={!taskId}
taskContext={taskContext as Record<string, unknown> | null} taskContext={taskContext as Record<string, unknown> | null}
// 第一步绑定 8BF 专属内置意图智能体(roundtable-intent 的同源复制,
// 提示词独立可调优),不再与通用多智能体会商共用一个 SOUL。
intentAgentId={EIGHTBF_INTENT_AGENT_ID}
// 页头「人工干预」开关:控制第二步是否每步暂停等待用户输入。
step2InterventionEnabled={step2Intervention}
onStep2InterventionChange={setStep2Intervention}
entryRailTaskId={taskId || null} entryRailTaskId={taskId || null}
entryRailTaskCardSlot={taskId ? ( entryRailTaskCardSlot={taskId ? (
<EightBfTaskCard <EightBfTaskCard
@@ -335,7 +283,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
chain={configuredChain} chain={configuredChain}
canConfigureChain={canConfigureChain} canConfigureChain={canConfigureChain}
onConfigureChain={openChainSettings} onConfigureChain={openChainSettings}
onChainUpdated={handleChainUpdated}
/> />
)} )}
renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => ( renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => (
@@ -386,8 +333,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
} }
rootClassName="eightbf-roundtable" rootClassName="eightbf-roundtable"
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig} taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
// 会商「智能体配置」抽屉里只读展示各席位在业务链上配置的产物提交 position。
seatArtifactPositions={seatArtifactPositions}
/> />
</div> </div>
); );
@@ -48,7 +48,7 @@ const MAX_INTENT_LENGTH = 500;
const MAX_SAMPLE_TEXT_LENGTH = 120000; const MAX_SAMPLE_TEXT_LENGTH = 120000;
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [ const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
{ value: 'general', label: '通用检索', desc: '素材收集专家自由选用全部技能检索' }, { value: 'general', label: '通用检索', desc: '素材收集专家按配置技能检索' },
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库检索接口' }, { value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库检索接口' },
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' }, { value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
]; ];
@@ -152,7 +152,7 @@ interface MaterialConfirmCardProps {
type MaterialSource = NonNullable<UserInterventionPayload['materialSource']>; type MaterialSource = NonNullable<UserInterventionPayload['materialSource']>;
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [ const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
{ value: 'general', label: '通用检索', desc: '素材收集专家自由选用全部技能检索' }, { value: 'general', label: '通用检索', desc: '素材收集专家按配置技能检索' },
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库接口' }, { value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库接口' },
{ value: 'notebook', label: '我的空间', desc: '检索当前笔记空间' }, { value: 'notebook', label: '我的空间', desc: '检索当前笔记空间' },
]; ];
@@ -218,7 +218,7 @@ function MaterialConfirmCard({
async function handleReSearch() { async function handleReSearch() {
setSubmitting(true); setSubmitting(true);
// 带输入 = 后端从全部启用技能中匹配检索、新素材追加到列表; // 带输入 = 后端在素材收集专家配置的技能中匹配检索、新素材追加到列表;
// 无输入 = 重新生成检索词、覆盖式重检 // 无输入 = 重新生成检索词、覆盖式重检
await onSubmit({ await onSubmit({
action: 're_search', action: 're_search',
@@ -257,7 +257,7 @@ export interface UserInterventionPayload {
approvedMaterialIds?: string[]; approvedMaterialIds?: string[];
/** @deprecated 旧版多关键词追加。新版统一用 userQuery 单输入框 */ /** @deprecated 旧版多关键词追加。新版统一用 userQuery 单输入框 */
extraKeywords?: string[]; extraKeywords?: string[];
/** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端从全部启用技能中匹配最相关的技能检索,新素材**追加**到列表。 */ /** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端在素材收集专家配置的技能中匹配最相关的技能检索,新素材**追加**到列表。 */
userQuery?: string; userQuery?: string;
/** PAUSE-1 素材确认:本次补充/重新检索使用的检索方式。 */ /** PAUSE-1 素材确认:本次补充/重新检索使用的检索方式。 */
materialSource?: "general" | "knowledge_base" | "notebook"; materialSource?: "general" | "knowledge_base" | "notebook";
@@ -298,7 +298,7 @@ export interface AIWritingRequest {
writingMode: "strict" | "loose"; writingMode: "strict" | "loose";
/** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认 true。 */ /** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认 true。 */
addReferences?: boolean; addReferences?: boolean;
/** 检索类型:general=通用检索(素材收集专家从全部启用技能自由挑选检索,内置知识库检索保底)/ /** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/
* knowledge_base=知识库检索(直连内网知识库 ES 接口,不走技能编排)/ notebook=我的空间 */ * knowledge_base=知识库检索(直连内网知识库 ES 接口,不走技能编排)/ notebook=我的空间 */
materialSource: "general" | "knowledge_base" | "notebook"; materialSource: "general" | "knowledge_base" | "notebook";
/** 不收集素材,直接用通用知识写作:勾选后跳过整个素材检索流程(含素材确认暂停点), /** 不收集素材,直接用通用知识写作:勾选后跳过整个素材检索流程(含素材确认暂停点),
+1 -4
View File
@@ -373,7 +373,6 @@ export default function AgentChatPage({
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>( const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>(
() => pendingAssistantKnowledgeSelection.current ?? [], () => pendingAssistantKnowledgeSelection.current ?? [],
); );
const [knowledgeSuggestedQuestions, setKnowledgeSuggestedQuestions] = useState<string[]>([]);
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState( const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(
() => pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null, () => pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null,
); );
@@ -1030,7 +1029,7 @@ export default function AgentChatPage({
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。 // 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
router.push( router.push(
isEmbedded isEmbedded
? `${routeBase}/chats/new?agent=${agent_id}` ? `/page/workspace/chats/new?agent=${agent_id}`
: `${agentChatsBase}/new`, : `${agentChatsBase}/new`,
); );
}} }}
@@ -1080,7 +1079,6 @@ export default function AgentChatPage({
className="w-full" className="w-full"
isNewThread isNewThread
hideAdminRecommendedQuestions hideAdminRecommendedQuestions
knowledgeSuggestedQuestions={knowledgeSuggestedQuestions}
threadId={threadId} threadId={threadId}
assistantId={agent_id} assistantId={agent_id}
autoFocus autoFocus
@@ -1101,7 +1099,6 @@ export default function AgentChatPage({
assistantValue={selectedAssistantKnowledgeBaseIds} assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection} onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection} onAssistantChange={changeAssistantKnowledgeSelection}
onSuggestedQuestionsChange={setKnowledgeSuggestedQuestions}
autoSelectAll={ autoSelectAll={
isNewThread && isNewThread &&
pendingKnowledgeSelection.current === null && pendingKnowledgeSelection.current === null &&
@@ -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>
);
}
+26 -173
View File
@@ -37,10 +37,8 @@ import {
useAssistantKnowledgeActions, useAssistantKnowledgeActions,
useAssistantKnowledgeBases, useAssistantKnowledgeBases,
useAssistantKnowledgeSources, useAssistantKnowledgeSources,
useAssistantWikiPage,
useAssistantWikiPages, useAssistantWikiPages,
type AssistantKnowledgeBase, type AssistantKnowledgeBase,
type AssistantWikiPage,
} from "@/strategy-components/api/assistant-knowledge"; } from "@/strategy-components/api/assistant-knowledge";
import { import {
WikiLibraryReader, WikiLibraryReader,
@@ -48,41 +46,24 @@ import {
} from "@/strategy-components/components/resource-management/llmwiki/WikiLibraryReader"; } from "@/strategy-components/components/resource-management/llmwiki/WikiLibraryReader";
import { WikiPackageTransfers } from "@/strategy-components/components/resource-management/llmwiki/WikiPackageTransfers"; import { WikiPackageTransfers } from "@/strategy-components/components/resource-management/llmwiki/WikiPackageTransfers";
function toLibraryPage(page: AssistantWikiPage): LibraryWikiPage {
return {
...page,
content: page.content_markdown || "",
status: "published",
aliases: page.aliases || [],
source_refs: page.source_refs || [],
in_links: page.in_links || [],
out_links: page.out_links || [],
version: 1,
};
}
export default function AssistantKnowledgePage() { export default function AssistantKnowledgePage() {
const isAdmin = getStoredAuth()?.system_role === "admin"; const isAdmin = getStoredAuth()?.system_role === "admin";
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const baseId = params.get("base_id"); const baseId = params.get("base_id");
const slug = params.get("wikiSlug"); const slug = params.get("wikiSlug");
const [tab, setTab] = useState("wiki");
const bases = useAssistantKnowledgeBases(); const bases = useAssistantKnowledgeBases();
const actions = useAssistantKnowledgeActions(); const actions = useAssistantKnowledgeActions();
const pages = useAssistantWikiPages("", baseId, { const pages = useAssistantWikiPages("", baseId);
includeContent: false, const sources = useAssistantKnowledgeSources(baseId);
enabled: !!baseId, const jobs = useAssistantImportJobs(baseId);
});
const sources = useAssistantKnowledgeSources(baseId, !!baseId);
const jobs = useAssistantImportJobs(baseId, !!baseId);
const client = useQueryClient(); const client = useQueryClient();
const [tab, setTab] = useState("wiki");
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editingBase, setEditingBase] = const [editingBase, setEditingBase] =
useState<AssistantKnowledgeBase | null>(null); useState<AssistantKnowledgeBase | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const searchSequence = useRef(0);
const [searchResult, setSearchResult] = useState<Awaited< const [searchResult, setSearchResult] = useState<Awaited<
ReturnType<typeof searchAssistantWiki> ReturnType<typeof searchAssistantWiki>
> | null>(null); > | null>(null);
@@ -90,27 +71,24 @@ export default function AssistantKnowledgePage() {
const documentUpload = useRef<HTMLInputElement>(null); const documentUpload = useRef<HTMLInputElement>(null);
const base = bases.data?.find((b) => b.id === baseId); const base = bases.data?.find((b) => b.id === baseId);
const libraryPages = useMemo<LibraryWikiPage[]>( const libraryPages = useMemo<LibraryWikiPage[]>(
() => (pages.data || []).map(toLibraryPage), () =>
(pages.data || []).map((p) => ({
...p,
content: p.content_markdown,
status: "published",
aliases: p.aliases || [],
source_refs: p.source_refs || [],
in_links: p.in_links || [],
out_links: p.out_links || [],
version: 1,
})),
[pages.data], [pages.data],
); );
const defaultDirectoryPage = const active =
libraryPages.find((p) => p.slug === slug) ||
libraryPages.find((p) => p.page_type === "index") || libraryPages.find((p) => p.page_type === "index") ||
libraryPages[0] || libraryPages[0] ||
null; null;
const activeDirectoryPage = slug
? libraryPages.find((p) => p.slug === slug) || null
: defaultDirectoryPage;
const activeSlug = slug || defaultDirectoryPage?.slug || null;
const activePage = useAssistantWikiPage(
activeSlug,
baseId,
!!activeSlug,
);
const active = useMemo(
() =>
activePage.data ? toLibraryPage(activePage.data) : activeDirectoryPage,
[activeDirectoryPage, activePage.data],
);
const navigatePage = (nextSlug: string) => { const navigatePage = (nextSlug: string) => {
setParams({ base_id: baseId!, wikiSlug: nextSlug }); setParams({ base_id: baseId!, wikiSlug: nextSlug });
setTab("wiki"); setTab("wiki");
@@ -119,38 +97,17 @@ export default function AssistantKnowledgePage() {
.filter((j) => j.status === "completed") .filter((j) => j.status === "completed")
.map((j) => j.id) .map((j) => j.id)
.join(","); .join(",");
const packageImportActive = (jobs.data || []).some(
(job) =>
job.trigger === "manual_package_upload" &&
["queued", "running"].includes(job.status),
);
const packageImportBusy =
actions.uploadWeKnoraPackage.isPending || packageImportActive;
const finishedJobsSnapshot = useRef({ baseId: null as string | null, ids: "" });
useEffect(() => { useEffect(() => {
if (!baseId || !jobs.data) return;
if (finishedJobsSnapshot.current.baseId !== baseId) {
finishedJobsSnapshot.current = { baseId, ids: finishedJobs };
return;
}
if (finishedJobsSnapshot.current.ids === finishedJobs) return;
finishedJobsSnapshot.current.ids = finishedJobs;
void client.invalidateQueries({ void client.invalidateQueries({
queryKey: ["assistant-knowledge", "pages"], queryKey: ["assistant-knowledge", "pages"],
}); });
void client.invalidateQueries({
queryKey: ["assistant-knowledge", "page"],
});
void client.invalidateQueries({ void client.invalidateQueries({
queryKey: ["assistant-knowledge", "bases"], queryKey: ["assistant-knowledge", "bases"],
}); });
}, [baseId, finishedJobs, jobs.data, client]); }, [finishedJobs, client]);
useEffect(() => { useEffect(() => {
++searchSequence.current;
setSearching(false);
setSearchResult(null); setSearchResult(null);
setQuery(""); setQuery("");
return () => { ++searchSequence.current; };
}, [baseId]); }, [baseId]);
const create = async () => { const create = async () => {
if (!name.trim()) return; if (!name.trim()) return;
@@ -165,16 +122,13 @@ export default function AssistantKnowledgePage() {
}; };
const search = async () => { const search = async () => {
if (!query.trim() || !baseId) return; if (!query.trim() || !baseId) return;
const sequence = ++searchSequence.current;
setSearching(true); setSearching(true);
setSearchResult(null);
try { try {
const result = await searchAssistantWiki(query.trim(), baseId); setSearchResult(await searchAssistantWiki(query, baseId));
if (sequence === searchSequence.current) setSearchResult(result);
} catch (e) { } catch (e) {
if (sequence === searchSequence.current) toast.error((e as Error).message); toast.error((e as Error).message);
} finally { } finally {
if (sequence === searchSequence.current) setSearching(false); setSearching(false);
} }
}; };
const refresh = () => const refresh = () =>
@@ -300,17 +254,6 @@ export default function AssistantKnowledgePage() {
初始化知识梳理总库 初始化知识梳理总库
</Button> </Button>
)} )}
{!bases.isLoading && !bases.error && !bases.data?.length && !isAdmin && (
<div className="rounded-xl border border-dashed p-8 text-center">
<p className="font-medium">暂无助手知识库</p>
<p className="mt-2 text-sm text-muted-foreground">
请联系管理员创建知识库并导入内容,完成后即可在这里查看和检索。
</p>
<Button variant="outline" className="mt-4" onClick={() => void bases.refetch()}>
<RefreshCw className="mr-2 size-4" />刷新
</Button>
</div>
)}
</main> </main>
) : ( ) : (
<> <>
@@ -374,17 +317,11 @@ export default function AssistantKnowledgePage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
disabled={packageImportBusy} disabled={actions.uploadWeKnoraPackage.isPending}
onClick={() => upload.current?.click()} onClick={() => upload.current?.click()}
> >
{packageImportBusy ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Upload className="mr-2 size-4" /> <Upload className="mr-2 size-4" />
)} 导入 普通知识库数据包
{packageImportBusy
? "数据包导入中"
: "导入 普通知识库数据包"}
</Button> </Button>
)} )}
</div> </div>
@@ -405,22 +342,17 @@ export default function AssistantKnowledgePage() {
ref={upload} ref={upload}
type="file" type="file"
accept=".jsonl,.ndjson" accept=".jsonl,.ndjson"
disabled={packageImportBusy}
className="hidden" className="hidden"
onChange={async (e) => { onChange={async (e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
e.target.value = ""; e.target.value = "";
if (!file) return; if (!file) return;
setTab("transfer");
if (packageImportBusy) return;
try { try {
await actions.uploadWeKnoraPackage.mutateAsync({ await actions.uploadWeKnoraPackage.mutateAsync({
file, file,
assistantBaseId: baseId, assistantBaseId: baseId,
}); });
toast.success( toast.success("文件已上传,后台正在导入");
"数据包已上传并在后台导入,请在任务记录中查看进度,无需再次上传",
);
} catch (err) { } catch (err) {
toast.error((err as Error).message); toast.error((err as Error).message);
} }
@@ -445,68 +377,6 @@ export default function AssistantKnowledgePage() {
</TabsList> </TabsList>
</div> </div>
<TabsContent value="wiki" className="mt-0 flex min-h-0 flex-1"> <TabsContent value="wiki" className="mt-0 flex min-h-0 flex-1">
{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 <WikiLibraryReader
key={baseId} key={baseId}
pages={libraryPages} pages={libraryPages}
@@ -521,20 +391,11 @@ export default function AssistantKnowledgePage() {
active?.origins?.find((o) => o.mapping_id)?.mapping_id active?.origins?.find((o) => o.mapping_id)?.mapping_id
} }
initiallyCollapseTree initiallyCollapseTree
activePageLoading={
!!activeSlug && !activePage.data && activePage.isPending
}
activePageError={
activePage.data ? undefined : activePage.error?.message
}
onRetryActivePage={() => void activePage.refetch()}
/> />
)}
</TabsContent> </TabsContent>
<TabsContent value="search" className="overflow-auto p-6"> <TabsContent value="search" className="overflow-auto p-6">
<div className="mb-5 flex max-w-3xl gap-2"> <div className="mb-5 flex max-w-3xl gap-2">
<Input <Input
aria-label="检索助手知识库"
placeholder="用自然语言检索知识,例如:如何处理设备过热?" placeholder="用自然语言检索知识,例如:如何处理设备过热?"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
@@ -542,7 +403,7 @@ export default function AssistantKnowledgePage() {
if (e.key === "Enter") void search(); if (e.key === "Enter") void search();
}} }}
/> />
<Button disabled={searching || !query.trim()} onClick={() => void search()}> <Button disabled={searching} onClick={() => void search()}>
{searching ? ( {searching ? (
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
) : ( ) : (
@@ -556,18 +417,10 @@ export default function AssistantKnowledgePage() {
<Badge variant="outline"> <Badge variant="outline">
{searchResult.retrieval_mode === "vector" {searchResult.retrieval_mode === "vector"
? "知识正文 / 片段 · 向量检索" ? "知识正文 / 片段 · 向量检索"
: searchResult.retrieval_mode === "overview"
? "知识正文 · 知识库概览"
: searchResult.retrieval_mode === "empty" : searchResult.retrieval_mode === "empty"
? "没有匹配结果" ? "没有匹配结果"
: "知识正文 / 片段 · 关键词检索"} : "知识正文 / 片段 · 关键词检索(暂无匹配模型的向量)"}
</Badge> </Badge>
{searchResult.results.length === 0 && (
<div role="status" className="mt-4 rounded-xl border border-dashed p-5 text-sm text-muted-foreground">
<p>没有找到相关知识。试试更简短的关键词,或浏览 Wiki 目录确认资料是否已入库。</p>
<Button variant="outline" className="mt-3" onClick={() => setTab("wiki")}>浏览 Wiki 目录</Button>
</div>
)}
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
{searchResult.results.map((p) => ( {searchResult.results.map((p) => (
<button <button
+23 -63
View File
@@ -40,7 +40,6 @@ import { CompactWelcome } from "@/components/workspace/compact-welcome";
import { import {
buildFullChatNavigationPath, buildFullChatNavigationPath,
isChatsIframeWorkspacePath, isChatsIframeWorkspacePath,
WX_FULL_CHAT_PATH_PREFIX,
} from "@/core/page-layout/chats-iframe-route"; } from "@/core/page-layout/chats-iframe-route";
import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session"; import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
import { isWujieEnv } from "@/core/embed/wujie"; import { isWujieEnv } from "@/core/embed/wujie";
@@ -65,10 +64,6 @@ import {
useThreadSettings, useThreadSettings,
} from "@/core/settings"; } from "@/core/settings";
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks"; import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import {
myProjectConversationTypeFromSearchParams,
myProjectThreadMetadata,
} from "@/core/threads/project-metadata";
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed"; import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream"; import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode"; import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
@@ -147,7 +142,6 @@ export default function ChatPage() {
const [knowledgeRagEnabled] = useKnowledgeRagEnabled(); const [knowledgeRagEnabled] = useKnowledgeRagEnabled();
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]); const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>([]); const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>([]);
const [knowledgeSuggestedQuestions, setKnowledgeSuggestedQuestions] = useState<string[]>([]);
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(false); const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(false);
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false); const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
const knowledgeSelectionThread = useRef<string | null>(null); const knowledgeSelectionThread = useRef<string | null>(null);
@@ -291,28 +285,16 @@ export default function ChatPage() {
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]); }, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const projectConversationType =
myProjectConversationTypeFromSearchParams(searchParams);
const newThreadMetadata = useMemo<Record<string, unknown> | undefined>(() => {
const metadata: Record<string, unknown> = projectConversationType
? myProjectThreadMetadata(projectConversationType)
: {};
if (knowledgeSelectionReady) {
metadata.llmwiki_knowledge_base_ids = selectedKnowledgeBaseIds;
metadata.assistant_knowledge_base_ids = selectedAssistantKnowledgeBaseIds;
}
return Object.keys(metadata).length > 0 ? metadata : undefined;
}, [
knowledgeSelectionReady,
projectConversationType,
selectedAssistantKnowledgeBaseIds,
selectedKnowledgeBaseIds,
]);
const [thread, sendMessage, isUploading] = useThreadStream({ const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId, threadId: isNewThread ? undefined : threadId,
context: streamContext, context: streamContext,
threadMetadata: newThreadMetadata, threadMetadata: knowledgeSelectionReady
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: undefined,
isMock, isMock,
onStart: (createdThreadId) => { onStart: (createdThreadId) => {
knowledgeSelectionThread.current = createdThreadId; knowledgeSelectionThread.current = createdThreadId;
@@ -369,33 +351,6 @@ export default function ChatPage() {
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context); const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context); const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
const frontendRagSelected = isFrontendRagProfile(settings.context); const frontendRagSelected = isFrontendRagProfile(settings.context);
// 排查高并发时普通对话输入框意外禁用:流式运行本身不应成为禁用原因。
const inputDisabledReasons = useMemo(() => {
const reasons: string[] = [];
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") reasons.push("静态站点模式");
if (isUploading) reasons.push("正在上传附件");
if (agentHandoffPending) reasons.push("正在创建智能体会话");
if (knowledgeSelectionLoading) reasons.push("正在恢复会话知识库配置");
if (singleRoundRag.isLoading) reasons.push("单轮 RAG 执行中");
if (multiRoundRag.isLoading) reasons.push("多轮 RAG 执行中");
return reasons;
}, [
agentHandoffPending,
isUploading,
knowledgeSelectionLoading,
multiRoundRag.isLoading,
singleRoundRag.isLoading,
]);
const inputDisabled = inputDisabledReasons.length > 0;
useEffect(() => {
console.info("[普通对话输入框状态]", {
禁用: inputDisabled,
原因: inputDisabledReasons,
threadId: threadId ?? null,
新建对话: isNewThread,
});
}, [inputDisabled, inputDisabledReasons, isNewThread, threadId]);
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md // 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
// 流式 stub),成功且真实产物落地后自动撤出。 // 流式 stub),成功且真实产物落地后自动撤出。
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread); const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
@@ -450,9 +405,7 @@ export default function ChatPage() {
try { try {
let targetThreadId = threadId; let targetThreadId = threadId;
if (isNewThread) { if (isNewThread) {
const created = await getAPIClient(isMock).threads.create({ const created = await getAPIClient(isMock).threads.create({});
metadata: newThreadMetadata ?? {},
});
targetThreadId = created.thread_id; targetThreadId = created.thread_id;
setThreadId(targetThreadId); setThreadId(targetThreadId);
setIsNewThread(false); setIsNewThread(false);
@@ -512,8 +465,13 @@ export default function ChatPage() {
try { try {
const created = await getAPIClient(isMock).threads.create({ const created = await getAPIClient(isMock).threads.create({
metadata: { metadata: {
...newThreadMetadata,
agent_id: selectedAgentId, agent_id: selectedAgentId,
...(knowledgeSelectionReady
? {
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
}
: {}),
}, },
}); });
sessionStorage.setItem( sessionStorage.setItem(
@@ -532,7 +490,7 @@ export default function ChatPage() {
); );
saveLastAgentId(selectedAgentId); saveLastAgentId(selectedAgentId);
router.push( router.push(
`${routeBase}/agents/${encodeURIComponent(selectedAgentId)}/chats/${encodeURIComponent(created.thread_id)}`, `/page/workspace/agents/${selectedAgentId}/chats/${created.thread_id}`,
); );
} catch (err) { } catch (err) {
agentHandoffPendingRef.current = false; agentHandoffPendingRef.current = false;
@@ -591,7 +549,6 @@ export default function ChatPage() {
selectedAssistantKnowledgeBaseIds, selectedAssistantKnowledgeBaseIds,
selectedKnowledgeBaseIds, selectedKnowledgeBaseIds,
knowledgeSelectionReady, knowledgeSelectionReady,
newThreadMetadata,
agentHandoffPending, agentHandoffPending,
isMock, isMock,
router, router,
@@ -740,9 +697,7 @@ export default function ChatPage() {
// 用 useLayoutMode 的有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认), // 用 useLayoutMode 的有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认),
// 与 PageRoutes 的 chrome 判定同源。非紧凑(嵌入/standalone)保持原样。 // 与 PageRoutes 的 chrome 判定同源。非紧凑(嵌入/standalone)保持原样。
const chromeByPreference = const chromeByPreference =
fullChatMode || fullChatMode || effectiveLayoutMode === "full" ? true : false;
pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX) ||
effectiveLayoutMode === "full";
const compactChrome = const compactChrome =
chromeByPreference && !isChatsIframeWorkspacePath(pathname); chromeByPreference && !isChatsIframeWorkspacePath(pathname);
const compactLanding = isLandingState && compactChrome; const compactLanding = isLandingState && compactChrome;
@@ -953,7 +908,14 @@ export default function ChatPage() {
} }
context={settings.context} context={settings.context}
extraModels={SINGLE_ROUND_RAG_MODELS} extraModels={SINGLE_ROUND_RAG_MODELS}
disabled={inputDisabled} disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading ||
agentHandoffPending ||
knowledgeSelectionLoading ||
singleRoundRag.isLoading ||
multiRoundRag.isLoading
}
onContextChange={(context) => onContextChange={(context) =>
setSettings("context", context) setSettings("context", context)
} }
@@ -974,7 +936,6 @@ export default function ChatPage() {
assistantValue={selectedAssistantKnowledgeBaseIds} assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection} onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection} onAssistantChange={changeAssistantKnowledgeSelection}
onSuggestedQuestionsChange={setKnowledgeSuggestedQuestions}
autoSelectAll={isLandingState && !!selectedAgentId} autoSelectAll={isLandingState && !!selectedAgentId}
disabled={ disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
@@ -984,7 +945,6 @@ export default function ChatPage() {
label="知识空间" label="知识空间"
/> />
} }
knowledgeSuggestedQuestions={knowledgeSuggestedQuestions}
onFollowupsVisibilityChange={setShowFollowups} onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onStop={handleStop} onStop={handleStop}
+18
View File
@@ -2,6 +2,7 @@
import { import {
BookMarkedIcon, BookMarkedIcon,
BrainCircuitIcon,
FileTextIcon, FileTextIcon,
GraduationCapIcon, GraduationCapIcon,
ShrinkIcon, ShrinkIcon,
@@ -399,6 +400,13 @@ export default function IframeChatPage() {
}); });
}, [setSettings, settings.context.writing_mode]); }, [setSettings, settings.context.writing_mode]);
const toggleMemoryInjection = useCallback(() => {
setSettings("context", {
memory_injection_enabled:
!(settings.context.memory_injection_enabled ?? true),
});
}, [setSettings, settings.context.memory_injection_enabled]);
const toggleRagMode = useCallback(() => { const toggleRagMode = useCallback(() => {
if (ragModeLocked) { if (ragModeLocked) {
return; return;
@@ -523,6 +531,9 @@ export default function IframeChatPage() {
const modeButtons: ResolvedIframeModeButton[] = buildIframeModeButtons({ const modeButtons: ResolvedIframeModeButton[] = buildIframeModeButtons({
agentSelected: !!selectedAgentId, agentSelected: !!selectedAgentId,
writingModeEnabled: !!settings.context.writing_mode, writingModeEnabled: !!settings.context.writing_mode,
memoryInjectionEnabled: Boolean(
settings.context.memory_injection_enabled ?? true,
),
referenceModeEntryEnabled, referenceModeEntryEnabled,
ragModeEnabled, ragModeEnabled,
ragModeLocked, ragModeLocked,
@@ -544,6 +555,13 @@ export default function IframeChatPage() {
onClick: toggleWritingMode, onClick: toggleWritingMode,
activeClassName: "text-amber-600 hover:text-amber-700", activeClassName: "text-amber-600 hover:text-amber-700",
}; };
case "memory":
return {
...button,
icon: BrainCircuitIcon,
onClick: toggleMemoryInjection,
activeClassName: "text-blue-600 hover:text-blue-700",
};
case "references": case "references":
return { return {
...button, ...button,
+10 -8
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { Fragment, useMemo, useState } from "react";
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { Copy, Info, KeyRound, Link2, Search, ShieldCheck, UserCheck, X } from "lucide-react"; import { Copy, Info, KeyRound, Link2, Search, ShieldCheck, UserCheck, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -201,8 +201,9 @@ export default function LoginWhitelistPage() {
const allowed = user.approved || isUserAdmin; const allowed = user.approved || isUserAdmin;
const hasPwd = Boolean(user.login_password); const hasPwd = Boolean(user.login_password);
const isEditing = editingId === user.id; const isEditing = editingId === user.id;
return [ return (
<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"> <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="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-slate-800">{user.email}</span> <span className="truncate text-sm font-medium text-slate-800">{user.email}</span>
@@ -259,10 +260,10 @@ export default function LoginWhitelistPage() {
onCheckedChange={(v) => handleToggleApproval(user.id, v)} onCheckedChange={(v) => handleToggleApproval(user.id, v)}
/> />
</div> </div>
</li>, </li>
isEditing && ( {isEditing && (
<li key={`${user.id}-password`} className="bg-slate-50/70 px-4 py-4"> <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="rounded-xl border border-slate-200/70 bg-white p-4 shadow-sm">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-600"> <div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-600">
<KeyRound className="size-3.5 text-slate-400" /> <KeyRound className="size-3.5 text-slate-400" />
@@ -327,8 +328,9 @@ export default function LoginWhitelistPage() {
)} )}
</div> </div>
</li> </li>
) )}
]; </Fragment>
);
})} })}
</ul> </ul>
)} )}
@@ -86,7 +86,6 @@ const emptyDraft = (): ModelDraft => ({
base_url: "", base_url: "",
request_timeout: 30, request_timeout: 30,
max_retries: 2, max_retries: 2,
max_input_tokens: undefined,
max_tokens: undefined, max_tokens: undefined,
temperature: undefined, temperature: undefined,
stream_usage: true, stream_usage: true,
@@ -144,7 +143,6 @@ function buildPayload(draft: ModelDraft): ModelSavePayload {
base_url: optionalString(draft.base_url), base_url: optionalString(draft.base_url),
request_timeout: toNumber(draft.request_timeout), request_timeout: toNumber(draft.request_timeout),
max_retries: toNumber(draft.max_retries), max_retries: toNumber(draft.max_retries),
max_input_tokens: toNumber(draft.max_input_tokens),
max_tokens: toNumber(draft.max_tokens), max_tokens: toNumber(draft.max_tokens),
temperature: toNumber(draft.temperature), temperature: toNumber(draft.temperature),
stream_usage: draft.stream_usage, stream_usage: draft.stream_usage,
@@ -381,7 +379,7 @@ function ModelEditor({ model, onClose }: { model: ManagedModel | null; onClose:
</section> </section>
<div className="flex items-center justify-between border-t pt-4"><TButton theme="default" variant="outline" loading={test.isPending} onClick={() => void handleTest()}><WrenchIcon className="mr-1.5 size-4" />测试连接</TButton><TButton theme="default" variant="text" onClick={() => setAdvanced((value) => !value)}><Settings2Icon className="mr-1.5 size-4" />高级配置 <ChevronDownIcon className={`ml-1 size-4 transition-transform ${advanced ? "rotate-180" : ""}`} /></TButton></div> <div className="flex items-center justify-between border-t pt-4"><TButton theme="default" variant="outline" loading={test.isPending} onClick={() => void handleTest()}><WrenchIcon className="mr-1.5 size-4" />测试连接</TButton><TButton theme="default" variant="text" onClick={() => setAdvanced((value) => !value)}><Settings2Icon className="mr-1.5 size-4" />高级配置 <ChevronDownIcon className={`ml-1 size-4 transition-transform ${advanced ? "rotate-180" : ""}`} /></TButton></div>
{testResult && <div className={`rounded-lg border px-3 py-2 text-sm ${testResult.success ? "border-primary/20 bg-primary/5 text-foreground" : "border-destructive/25 bg-destructive/5 text-destructive"}`}><div className="font-medium">{testResult.success ? "连接成功" : "连接失败"}{testResult.success && testResult.latency ? ` · ${testResult.latency} ms` : ""}</div><p className="mt-1 break-words text-xs opacity-80">{testResult.preview || testResult.message}</p></div>} {testResult && <div className={`rounded-lg border px-3 py-2 text-sm ${testResult.success ? "border-primary/20 bg-primary/5 text-foreground" : "border-destructive/25 bg-destructive/5 text-destructive"}`}><div className="font-medium">{testResult.success ? "连接成功" : "连接失败"}{testResult.success && testResult.latency ? ` · ${testResult.latency} ms` : ""}</div><p className="mt-1 break-words text-xs opacity-80">{testResult.preview || testResult.message}</p></div>}
{advanced && <section className="space-y-4 rounded-lg border border-dashed border-border bg-muted/10 p-4"><div className="grid gap-4 md:grid-cols-3"><Field label="请求超时(秒)"><InputNumber value={draft.request_timeout ?? ""} min={1} max={600} onChange={(value) => patch({ request_timeout: toNumber(value) })} /></Field><Field label="最大重试次数"><InputNumber value={draft.max_retries ?? ""} min={0} max={20} onChange={(value) => patch({ max_retries: toNumber(value) })} /></Field><Field label="最大输入 Token"><InputNumber value={draft.max_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> </div>
</Dialog> </Dialog>
); );
@@ -5,8 +5,6 @@ import {
ChevronDown, ChevronDown,
CircleHelp, CircleHelp,
Clock3, Clock3,
FolderKanban,
Globe,
LoaderCircle, LoaderCircle,
MessageSquareText, MessageSquareText,
PanelLeftClose, PanelLeftClose,
@@ -21,15 +19,8 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { type FormEvent, useCallback, useMemo, useState } from "react"; import { type FormEvent, useCallback, useMemo, useState } from "react";
import { Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import AnalysisProjectsPage from "./AnalysisProjectsPage";
import { SidebarProvider } from "@/components/ui/sidebar";
import { Toaster } from "sonner";
import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useAgent, useAgents } from "@/core/agents"; import { useAgent, useAgents } from "@/core/agents";
import { getAccountDisplayName } from "@/core/auth"; import { getAccountDisplayName } from "@/core/auth";
import { import {
@@ -38,44 +29,28 @@ import {
useScheduledTaskRuns, useScheduledTaskRuns,
useScheduledTasks, useScheduledTasks,
} from "@/core/scheduled-tasks"; } from "@/core/scheduled-tasks";
import { useLocalSettings } from "@/core/settings";
import { import {
useAppearanceSettings, useAppearanceSettings,
useCitationDisplaySettings, useCitationDisplaySettings,
} from "@/core/system-settings/hooks"; } from "@/core/system-settings/hooks";
import { useThreadStream, useThreads } from "@/core/threads/hooks"; import { useThreadStream, useThreads } from "@/core/threads/hooks";
import {
myProjectConversationTypeOfThread,
myProjectConversationTypeFromSearchParams,
myProjectThreadMetadata,
} from "@/core/threads/project-metadata";
import { textOfMessage, titleOfThread } from "@/core/threads/utils"; import { textOfMessage, titleOfThread } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid"; import { uuid } from "@/core/utils/uuid";
import { import {
OFFICIAL_DEERFLOW_HOME_ROUTE, OFFICIAL_DEERFLOW_HOME_ROUTE,
OFFICIAL_DEERFLOW_ROUTE_PREFIX, OFFICIAL_DEERFLOW_ROUTE_PREFIX,
} from "@/core/official-deerflow/routes"; } from "@/core/official-deerflow/routes";
import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
import OfficialWebAppsPage from "@/pages/OfficialWebAppsPage";
import "./official-deerflow-entry.css"; import "./official-deerflow-entry.css";
type OfficialPage = type OfficialPage = "chat" | "agents" | "agent-chat" | "scheduled" | "settings";
| "chat"
| "projects"
| "web-apps"
| "agents"
| "agent-chat"
| "scheduled"
| "settings";
function pageFromPath(pathname: string): OfficialPage { function pageFromPath(pathname: string): OfficialPage {
const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length); const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length);
if (path.startsWith("/workspace/agents/") && path.includes("/chats")) { if (path.startsWith("/workspace/agents/") && path.includes("/chats")) {
return "agent-chat"; return "agent-chat";
} }
if (path.startsWith("/workspace/projects")) return "projects";
if (path.startsWith("/workspace/web-app")) return "web-apps";
if (path.startsWith("/workspace/agents")) return "agents"; if (path.startsWith("/workspace/agents")) return "agents";
if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled"; if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled";
if (path.startsWith("/workspace/settings")) return "settings"; if (path.startsWith("/workspace/settings")) return "settings";
@@ -103,11 +78,6 @@ function parseScheduledTaskId(pathname: string) {
return match ? decodeURIComponent(match[1]!) : null; return match ? decodeURIComponent(match[1]!) : null;
} }
function parseProjectId(pathname: string) {
const match = pathname.match(/\/workspace\/projects\/([^/]+)/);
return match ? decodeURIComponent(match[1]!) : null;
}
function readableError(error: unknown, fallback: string) { function readableError(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback; return error instanceof Error && error.message ? error.message : fallback;
} }
@@ -189,36 +159,20 @@ function ChatWorkspace({
routeThreadId: string; routeThreadId: string;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [sendError, setSendError] = useState<string | null>(null); const [sendError, setSendError] = useState<string | null>(null);
const isNewThread = routeThreadId === "new"; const isNewThread = routeThreadId === "new";
const projectConversationType = isNewThread
? myProjectConversationTypeFromSearchParams(
new URLSearchParams(location.search),
)
: null;
// A new conversation uses the same client-generated id approach as the // A new conversation uses the same client-generated id approach as the
// existing ChatPage. The backend owns the persisted id once streaming starts. // existing ChatPage. The backend owns the persisted id once streaming starts.
const [draftThreadId] = useState<string>(() => uuid() as string); const [draftThreadId] = useState<string>(() => uuid() as string);
const activeThreadId = isNewThread ? draftThreadId : routeThreadId; const activeThreadId = isNewThread ? draftThreadId : routeThreadId;
const { agent } = useAgent(agentId, { enabled: !!agentId }); const { agent } = useAgent(agentId, { enabled: !!agentId });
const [localSettings] = useLocalSettings();
const [thread, sendMessage, isUploading] = useThreadStream({ const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : routeThreadId, threadId: isNewThread ? undefined : routeThreadId,
assistantId: agentId ?? undefined, assistantId: agentId ?? undefined,
threadMetadata: projectConversationType
? myProjectThreadMetadata(projectConversationType)
: undefined,
context: { context: {
// This compact official shell has no model/mode controls of its own. ...localSettings.context,
// Do not inherit a hidden selection saved by the customized workspace
// (for example an obsolete or quota-exhausted model); an omitted model
// lets the backend resolve the current system default or the agent's
// globally configured model.
model_name: undefined,
mode: undefined,
reasoning_effort: undefined,
writing_mode: false,
...(agentId ? { agent_id: agentId } : {}), ...(agentId ? { agent_id: agentId } : {}),
}, },
onStart: (createdThreadId) => { onStart: (createdThreadId) => {
@@ -704,10 +658,6 @@ export default function OfficialDeerFlowEntry() {
() => parseScheduledTaskId(location.pathname), () => parseScheduledTaskId(location.pathname),
[location.pathname], [location.pathname],
); );
const projectId = useMemo(
() => parseProjectId(location.pathname),
[location.pathname],
);
const { data: threads = [], isLoading: threadsLoading, error: threadsError } = const { data: threads = [], isLoading: threadsLoading, error: threadsError } =
useThreads({ useThreads({
limit: 20, limit: 20,
@@ -716,13 +666,10 @@ export default function OfficialDeerFlowEntry() {
select: ["thread_id", "updated_at", "values", "metadata"], select: ["thread_id", "updated_at", "values", "metadata"],
}); });
const go = (path: string) => navigate(path); const go = (path: string) => navigate(path);
const projectBasePath = `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/projects`;
const nav = [ const nav = [
{ icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, pages: ["chat"] }, { icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, pages: ["chat"] },
{ icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] }, { icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] },
{ icon: FolderKanban, label: "我的项目", path: projectBasePath, pages: ["projects"] },
{ icon: Globe, label: "Web 应用", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/web-app`, pages: ["web-apps"] },
{ icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] }, { icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] },
{ icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] }, { icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] },
] as const; ] as const;
@@ -759,18 +706,11 @@ export default function OfficialDeerFlowEntry() {
) : threadsError ? ( ) : threadsError ? (
<p className="official-sidebar-error">对话列表加载失败</p> <p className="official-sidebar-error">对话列表加载失败</p>
) : threads.length ? ( ) : threads.length ? (
threads.map((chat) => { threads.map((chat) => (
const projectType = myProjectConversationTypeOfThread(chat);
return (
<button <button
className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`} className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`}
key={chat.thread_id} key={chat.thread_id}
onClick={() => { 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" const agentId = typeof chat.context?.agent_id === "string"
? chat.context.agent_id ? chat.context.agent_id
: typeof chat.metadata?.agent_id === "string" : typeof chat.metadata?.agent_id === "string"
@@ -784,23 +724,11 @@ export default function OfficialDeerFlowEntry() {
}} }}
type="button" type="button"
> >
{projectType ? (
<MyProjectThreadIcon className="size-[15px]" type={projectType} />
) : (
<MessageSquareText aria-hidden="true" size={15} /> <MessageSquareText aria-hidden="true" size={15} />
)} <span>{titleOfThread(chat, "未命名对话")}</span>
<span className="official-recent-chat-copy">
<span className="official-recent-chat-title">
{titleOfThread(chat, "未命名对话")}
</span>
{projectType && (
<MyProjectThreadBadge type={projectType} />
)}
</span>
<small>{formatTimestamp(chat.updated_at)}</small> <small>{formatTimestamp(chat.updated_at)}</small>
</button> </button>
); ))
})
) : ( ) : (
<p className="official-sidebar-empty">暂无历史对话</p> <p className="official-sidebar-empty">暂无历史对话</p>
)} )}
@@ -826,24 +754,6 @@ export default function OfficialDeerFlowEntry() {
routeThreadId={chatRoute.threadId} routeThreadId={chatRoute.threadId}
/> />
)} )}
{page === "projects" && (
(location.pathname === `${projectBasePath}/groups` || location.pathname.startsWith(`${projectBasePath}/groups/`)) ? (
<SidebarProvider className="h-full min-h-0">
<Routes>
<Route path="workspace/projects/groups/*" element={<AnalysisProjectsPage basePath={`${projectBasePath}/groups`} listPath={`${projectBasePath}?type=qa`} mode="qa" showSidebarTrigger={false} />} />
</Routes>
</SidebarProvider>
) : projectId ? (
<StrategySelectionPage
key={projectId}
projectBasePath={projectBasePath}
projectId={projectId}
/>
) : (
<StrategyProjectsPage basePath={projectBasePath} />
)
)}
{page === "web-apps" && <OfficialWebAppsPage />}
{page === "agents" && ( {page === "agents" && (
<AgentsPage <AgentsPage
openAgent={(agentId) => openAgent={(agentId) =>
@@ -861,7 +771,6 @@ export default function OfficialDeerFlowEntry() {
{page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />} {page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />}
{page === "settings" && <SettingsPage />} {page === "settings" && <SettingsPage />}
</div> </div>
<Toaster position="top-center" />
</main> </main>
); );
} }
Loaded 100 of 627 files, more files were not shown because too many files have changed in this diff. Show more