Compare commits

..
Author SHA1 Message Date
gozeros 1d19260673 sync snapshot from LAN repo (source dhk-v5 @ d1e57306) 2026-11-05 11:03:03 +08:00
1201 changed files with 128219 additions and 29859 deletions

No files matched your search

+2
View File
@@ -11,6 +11,8 @@ dist
# through the offline delivery package, never copied into an image layer.
**/.env
**/.env.*
**/.worker-pool
**/worker-control
offline-backend-20260512/backend/.deer-flow
offline-backend-20260512/backend/logs
offline-backend-20260512/backend/docker-data
+38
View File
@@ -90,14 +90,29 @@ hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_large_file_safety.py
!offline-backend-20260512/backend/tests/test_account_authentication.py
!offline-backend-20260512/backend/tests/test_roundtable_room_limit.py
!offline-backend-20260512/backend/tests/test_admin_audit_retention_control.py
# Seeder-owned Agent Skills are product source; keep them out of the broad
# runtime `skills/` ignore above.
!offline-backend-20260512/backend/app/gateway/routers/_*_seed_assets/skills/
!offline-backend-20260512/backend/app/gateway/routers/_*_seed_assets/skills/**
# Keep startup seed completeness and task-report conversion regressions versioned.
!offline-backend-20260512/backend/tests/test_seed_assets.py
!offline-backend-20260512/backend/tests/test_latency_diagnosis_seed.py
!offline-backend-20260512/backend/tests/test_query_slowdown_diagnosis_seed.py
!offline-backend-20260512/backend/tests/test_elasticsearch_offline_ops_seed.py
!offline-backend-20260512/backend/tests/test_task_report_convert.py
!offline-backend-20260512/backend/tests/test_task_deeplink_details.py
!offline-backend-20260512/backend/tests/test_offline_docs.py
!offline-backend-20260512/backend/tests/test_migration_compatibility.py
# Keep the admin catalog hidden-switch (agent / business chain) store regressions versioned.
!offline-backend-20260512/backend/tests/test_roundtable_chain_hidden.py
# Keep the business-chain seat enrichment (available/live_name) regressions versioned.
!offline-backend-20260512/backend/tests/test_roundtable_chain_seat_enrichment.py
# Keep the dedicated taskId + goPath history Gateway contract versioned.
!offline-backend-20260512/backend/tests/test_task_deeplink_threads_api.py
!offline-backend-20260512/backend/tests/test_thread_clear.py
# Keep project-scoped cross-conversation memory regressions versioned.
!offline-backend-20260512/backend/tests/test_analysis_project_memory.py
!offline-backend-20260512/backend/tests/test_analysis_project_memory_integration.py
@@ -114,6 +129,15 @@ offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_gauss_database.py
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
!offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py
!offline-backend-20260512/backend/tests/test_llmwiki_smart_reasoning.py
!offline-backend-20260512/backend/tests/test_smart_reasoning_retrieval_middleware.py
!offline-backend-20260512/backend/tests/test_knowledge_retrieval_settings.py
!offline-backend-20260512/backend/tests/test_wiki_generation_config.py
!offline-backend-20260512/backend/tests/test_wiki_directory_constraints.py
!offline-backend-20260512/backend/tests/test_llmwiki_parent_directory_routing.py
!offline-backend-20260512/backend/tests/test_llmwiki_loading.py
!offline-backend-20260512/backend/tests/test_weknora_embed_script.py
!offline-backend-20260512/backend/tests/test_weknora_static.py
!offline-backend-20260512/backend/tests/test_llmwiki_advanced_retrieval.py
!offline-backend-20260512/backend/tests/test_knowledge_live_embedding.py
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
@@ -173,6 +197,8 @@ offline-backend-20260512/backend/tests/*
!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
# Per-account question recommendation preferences.
!offline-backend-20260512/backend/tests/test_user_preferences.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
@@ -200,6 +226,7 @@ git-clone/
frontend-web/.offline-pack-build/
frontend-web/.offline-extract-test/
frontend-web-node_modules-offline-*.tar.gz
/deerflow-frontend-dist-*.zip
# Local dependency caches and backend scratch archives
/.pnpm-store/
@@ -225,12 +252,22 @@ offline-backend-20260512/backend/skills/*
offline-backend-20260512/backend/skills/public/*
!offline-backend-20260512/backend/skills/public/analysis-mindmap/
!offline-backend-20260512/backend/skills/public/analysis-mindmap/SKILL.md
# Built-in skill-creator ported from bytedance/deer-flow (product source too).
!offline-backend-20260512/backend/skills/public/skill-creator/
!offline-backend-20260512/backend/skills/public/skill-creator/**
# uv lockfile (not tracked per project preference)
offline-backend-20260512/backend/uv.lock
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
!offline-backend-20260512/backend/tests/test_llmwiki_incremental_sync_scheduler.py
!offline-backend-20260512/backend/tests/test_worker_pool.py
!offline-backend-20260512/backend/tests/test_worker_storage.py
!offline-backend-20260512/backend/tests/test_worker_discovery.py
!offline-backend-20260512/backend/tests/test_worker_diagnostics.py
!offline-backend-20260512/backend/tests/test_worker_deferred_admission.py
!offline-backend-20260512/backend/tests/test_db_loop_isolation.py
!offline-backend-20260512/backend/tests/test_chat_archive_postgres_integration.py
# Local-only scratch directory (not committed)
0622/
@@ -240,3 +277,4 @@ __pycache__/
# misc
.DS_Store
.worker-pool/
@@ -0,0 +1,64 @@
# 业务链选链去掉每次 `listAgents()` 查询
## 背景与思路
选链各环节展示只用到 seat 的 `agent_id + name`(快照已随链条落库,编辑器保存时刷新),额外全量拉 `/api/agents` 只为两件事:**席位存活判断**(existingIds)和**最新改名**(nameById)。存活是实时状态、无法落库保鲜,因此方案是:
- **后端**在返回链条(list/get 等)时,用一次批量 SQL(`agent_store.list_by_ids`,与 `/api/agents` 同一套可见性过滤)给每个 seat 附带 `available: bool` + `live_name: string | null` 两个**只读计算字段**(不落库,seats JSON 存储结构零变化、零迁移);
- **前端**选链全链路改用这两个字段,删除所有选链路径上的 `listAgents()` 调用;左栏点选一条链变为**零网络请求**(链条数据本就在内存里);选链弹窗打开只剩 1 个 `listChains` 小请求。
「只映射目前展示的这些」:仅 name + 存活状态,其他智能体字段一概不存(description 等快照维持现状,运行期配置由后端按 agent_id 自取,本就不依赖前端传参)。
## 后端改动
### 1. `offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py`
- 新增常量 `_FUNCTIONAL_AGENT_EXCLUSIONS`(`roundtable-intent` / `roundtable-recommender` / `roundtable-coordinator` / `roundtable-report` + 前缀 `roundtable-coordinator-`,与前端 `api/recommend.ts` 的 `filterRecommendCandidates` 排除集逐字对齐,保持"功能型内置智能体不可作席位"语义不变)。
- 新增纯函数 `_compute_seat_enrichment(chains, live_records) -> None`:对每条链每个 seat 写入 `available`(live 存在且不在排除集 → True)与 `live_name`(无则 None);配套薄异步封装 `_enrich_chain_seats(request, chains, user_id)`:收集去重小写 agent_id → `get_agent_store(request).list_by_ids(ids, user_id)` 一次查询(router 已 import `get_agent_store`,参照 `multi_agent.py` 的 `_get_agents_batch_direct` 写法)。
- 新增**响应专用** `ChainSeatResponseSchema(ChainSeatSchema)`,加 `available: bool | None`、`live_name: str | None`;`ChainResponse.seats` 改用之。创建/更新请求 schema 不动 → 富化字段永不被持久化(避免 create/update `model_dump()` 把默认值写进 seats JSON)。
- 在所有返回 `ChainResponse`(列表/单条/更新等)的出口调用富化。可见性语义与现状完全一致(`_listing_filter`:本人 / 内置 / 已发布、非私享、非管理员隐藏)。
### 2. 测试 `tests/test_roundtable_chain_seat_enrichment.py`(新建)
镜像 `test_roundtable_chain_stages.py` 的风格:纯函数用 dict 直接测(存活/缺失/排除集/改名刷新/混合多条链);`list_by_ids` 交互用同名小写归一化断言(stub store)。
## 前端改动(frontend-web)
### 3. `src/roundtable-planning/lib/business-chain.ts`
- `ChainSeat` 增加可选 `available?: boolean`(undefined = 旧后端未标注 → 视为存活,与现有"查询失败不判删"降级语义一致)和 `live_name?: string | null`,附注释说明来源与回退顺序。
- `buildChainSeatSelection(chain, candidates?)`:`candidates` 改**可选**;不传时存活 = `seat.available !== false`、名称 = `live_name ?? name ?? agent_id`;传入时保持现行为(测试兼容)。
### 4. `src/roundtable-planning/components/BusinessChainPicker.tsx`
加载 effect 只留 `listChains(scope)`;删 `listAgents()`、`filterRecommendCandidates` 及 `existingIds`/`nameById` state——存活判断用 `seat.available !== false`,tag 名称用 `live_name ?? seat.name ?? agent_id`;已删置灰/删除线/「存活不足 2 个」等 UI 保持不变(改由 `available` 驱动)。`handleUse` 的 `nameOf` 同步改。
### 5. `src/roundtable-planning/components/BusinessChainListDialog.tsx`
`BusinessChainListBody` 删除挂载时的 `listAgents()` effect 与 `nameById` map;`ChainSeatsFlow` 的 seatTag 标签改 `seat.live_name ?? seat.name ?? agentId`。
### 6. `src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(3 处调用点)
- **L486 固定链 effect(8BF 等)**:`Promise.all([getChain, listAgents])` → 仅 `getChain`;席位不足提示改为按 `seat.available === false` 计算(文案不变)。
- **L1356 `handleSelectChainFromSidebar`(左栏点选,本次体验痛点核心)**:删 `listAgents()`,直接 `buildChainSeatSelection(chain)` —— 点选零请求。
- **L1503 C3 深链自动选链**:删 `listAgents()` 分支。
- 清理不再使用的 `listAgents` / `filterRecommendCandidates` import(编辑器页与「智能推荐」tab 的 `listAgents` 是选智能体池所必需,保留不动)。
### 7. 测试 `src/roundtable-planning/lib/business-chain.test.ts`(新建,node:test 风格同 `chain-stages.test.ts`)
覆盖无 candidates 分支:`available` 缺省视为存活、`available:false` 过滤、不足 2 席返回 null、`live_name` 优先于快照名、dag 分支不受影响;package.json 加 `test:business-chain` 脚本。
### 8. 文档 `frontend-web/docs/multi-agent-business-chain-dev.md`
补一段:链条响应 seat 附带 `available`/`live_name`(后端读时计算、不落库);选链前端不再调 `listAgents()`。
## 兼容性
- 新后端 + 旧前端:响应多两个字段,旧代码忽略,无影响。
- 旧后端 + 新前端:`available` undefined → 全按存活处理,行为等于现在"agents 查询失败"的降级路径;若智能体真被删,Step 2 init 的后端 404 硬校验仍兜底(现状即有)。
- 存量链条 seats JSON 无需迁移;8BF / 岗位协同 / official-task-analysis 等消费 seat 快照的模块零改动。
## 验证
- 后端:`PYTHONPATH=. uv run pytest tests/test_roundtable_chain_seat_enrichment.py tests/test_roundtable_chain_stages.py tests/test_roundtable_chain_hidden.py -v` + `make lint`(ruff)。
- 前端:`pnpm typecheck` + `node --experimental-strip-types --test src/roundtable-planning/lib/business-chain.test.ts`。
+5 -3
View File
@@ -88,6 +88,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
- **知识库深度研究(普通知识库限定问答)**: 每个可读知识库从卡片或详情页进入 `/page/workspace/knowledge/:noteId/research`。它不复用原“深度研究”流程,而是把普通 Lead Agent 问答在服务端强制绑定到单个知识库、经典检索并关闭工具/记忆;私有会话与草稿仅创建者可见。首个完整回答自动成为 Markdown 草稿,后续回答须显式“应用为文档”。可写用户保存时在该知识库 Wiki 下创建保留根目录「深度研究」,用独立 publication id 的稳定 slug 创建/覆盖文章;文件名独立于问答标题。书架文章随知识库读权限共享,但“更多”及原问答仅创建者可见;只读用户可问答和整理草稿,不能发布。前端核心页 `pages/KnowledgeResearchPage.tsx`,client `strategy-components/api/knowledge-research.ts`;后端 REST 在 `app/gateway/routers/llmwiki.py`,运行时约束 `app/gateway/knowledge_research.py`,持久化 `deerflow/persistence/knowledge_research.py`。
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
- **轻应用中心 (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 defaults + personal layouts)**: `MenuManagementPage` (`pages/MenuManagementPage.tsx`) is available from「设置和更多」and the global Header account dropdown. Administrators edit the shared default menu; ordinary users edit only their own full/compact menu snapshot. For users with a saved snapshot, known node ids keep the personal `label`/`disabled`/`parentId`/`sortOrder` (or compact placement/order), while administrator defaults for node ids absent from that snapshot flow through—so newly-added code menus and iframe light apps follow the current default without overwriting older personal choices. Temporarily absent dynamic nodes are preserved when an unrelated personal edit is saved;「恢复默认」deletes only that user's snapshot. The static tree (`core/page-layout/sidebar-menu.ts`) remains the source of icons/routes and administrator defaults remain sparse per-node deltas. Merge/snapshot helpers live in `core/page-layout/menu-overrides.ts` and `compact-menu-overrides.ts`; full-menu writes carry the complete effective parent hierarchy for cycle/strict three-level validation while persisting only the sparse admin delta or full personal snapshot. Backend `/api/menu-overrides` uses `menu_overrides` (default) + `user_menu_override_configs` (per-user snapshot); `/api/compact-menu-overrides` uses `compact_menu_overrides` (type `A` / `B` defaults) + `user_compact_menu_override_configs` (per-user/type snapshots). Each website selects its compact type via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`; `CompactToolbox` renders `toolbox` items.
@@ -98,12 +99,13 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **技能上下线 (Skill Availability, owner/admin)**: the global `Skill.enabled` flag is the online/offline switch. Taking a skill offline persists `skills.<name>.enabled=false` in `extensions_config.json` but does **not** delete its directory or remove it from existing agent/workflow configurations; the row remains visible and bringing it online restores those references. Administrators may toggle every skill from 「技能管理」; ordinary users may toggle only their own non-built-in `custom` skills from 「技能 → 技能列表 → 我的」. Offline skills must be absent from every model-facing skill catalogue (`lead` prompt, `search_skills`, `skill_list`, `skill_view`, subagents, AI writing/deep research/roundtable and Workflow run-start validation), from agent skill pickers, and from direct sandbox access: `read_file`/`ls`/`glob`/`grep`/`bash` reject offline, unknown, root-wide and category-wide `/mnt/skills` paths. The backend mutation is `PUT /api/skills/{name}`, authorizes either an administrator or the persisted owner of the target custom skill, patches the raw document so MCP placeholders and unrelated extension fields survive, uses atomic replacement when supported plus a Docker single-file-bind fallback, and refreshes the skill prompt cache. Launchers keep the runtime extensions file in a writable persisted directory. Existing agent/strategy-agent references stay visible as 「已下线」 and may be removed but not newly selected.
- **技能地址管理 (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`.
- **单智能体任务深链聊天记录(2026-10-19)**: `3qfx` / `rwfx` / `xdfx` 的会话历史使用独立 `task_deeplink_threads` 索引表及 `GET /api/task-deeplink-threads?task_id=&go_path=`,前端由 `useTaskDeeplinkThreads` 读取,不再筛选通用聊天列表。LangGraph thread/checkpoint 仍是消息权威数据;专用表仅按 `(task_id, go_path)` 定位 thread。`3qfx/rwfx` 按任务 id,`xdfx` 按 URL 原始行动 id;新 thread 写 `metadata.{taskRecordId,taskGoPath,thread_type:"task_deeplink"}` 并自动登记,通用最近对话默认隐藏。迁移 `20261019_01` 只建表、**不迁移/回填旧数据**。
- **上下文近 3 轮滑窗(管理员全局开关)**: 「设置 → 上下文管理」可开启 `system_settings.json -> conversation_context.recent_turn_window_enabled`(默认关闭;`GET/PUT /api/system-settings/conversation-context`,PUT 仅管理员)。开启后 `RecentTurnsMiddleware` 只改变单次模型请求,保留含当前轮在内的最近 3 个真实用户回合及其 assistant/tool 链;checkpoint 与前端完整聊天记录不删。该模式优先于聊天框「上下文压缩」,且不会调用摘要模型;关闭后沿用原压缩行为。实现 `deerflow/agents/middlewares/recent_turns_middleware.py`,回归 `tests/test_summarization_multiturn.py` / `tests/test_prompt_prefix.py`。
- **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
- **UI**: Radix UI primitives + TailwindCSS v3 + shadcn-style components in `src/components/ui/`
- **麒麟 Chromium ~80 兼容 (Kylin legacy Chrome)**: Tailwind 自 v4 降级到 v3(v4 的 `@layer` 需要 Chrome 99+,麒麟系统自带 Chromium 80 多整站裸奔)。要点:① `tailwind.config.ts` —— 语义色走六套主题(`:root/.dark/.lightblue/.lightgreen/.iceblue/.lightyellow`)维护的 `--legacy-*-rgb` 通道变量,透明度修饰符(`bg-primary/10`)用 `rgb(var()/<alpha-value>)` 原生支持;border/input/sidebar-border/ring 用 `--*-alpha` 变量还原 v4 color-mix 叠乘语义;`xs/sm` 刻度(shadow/blur/rounded)与裸 `ring`(1px+currentColor)按 v4 值覆盖,类名零改动;streamdown 运行时拼类名的白名单在 `safelist`;v4 独有工具类(outline-hidden/text-shadow-sm/field-sizing-*)以插件手写兜底。② `darkMode: "class"` 输出 `:is(.dark *)`,由 `scripts/postcss-dark-descendant.mjs` 在 postcss 阶段改写为 `.dark X` 后代形式(v3.4 的 `["selector", ...]` 堆叠变体会生成非法选择器、无 darkMode 配置会退化成媒体查询,均不可用)。③ 手写 CSS 禁用 `:is()`(复杂参数 lightningcss 无法安全降级,globals/roundtable/intent 已全部展开为逗号选择器);`:has()`(105+)按渐进增强放行,唯一功能性依赖(input-group 焦点/校验环)在 globals.css 有 `@supports not selector(:has(*))` 回退。④ vite `build.target` + lightningcss targets = chrome80(转译 `:is()`/逻辑属性/媒体 range 语法、厂商前缀);`src/polyfills.ts`(core-js 精选 API)在 main.tsx 首位引入。⑤ 构建后跑 `pnpm check:legacy-css` 防回归(产物不得含 @layer/@property/:is()/未守护 color-mix)。
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations staLine truncated
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent/taskType`; opening text maps `taskType` 1/2/3 to 打击/塑势/防御) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskILine 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 提示。
+45 -3
View File
@@ -1,5 +1,40 @@
# CLAUDE.md
## Retrieved files in chat (2026-10-07)
The shared `MessageGroup` renders `RetrievedFileList` below its reasoning/tool
timeline, so retrieved originals remain visible when old tool steps collapse and
do not depend on a model calling `present_files` or enabling reference mode.
The file list starts collapsed, keeps the latest retrieved file visible with the
total count, and expands the remaining files in reverse retrieval order.
`core/messages/retrieved-files.ts` derives these cards only from completed skill
invocations (a known skill tool name or a skill script path in an execution tool).
Generic reads, listings, report generation tools, uploads and outputs are excluded;
workspace paths require a structured retrieval/file container and exclude paths
written in the same message group. It accepts structured file links and WeKnora identifiers such as `knowledge_id` and
`document_file_name`. It handles JSON wrappers, MCP text blocks and bounded JSON
following console logs, deduplicates by file identity and caps one group at 100
files / 2,000 visited payload nodes. Do not extract file cards from assistant prose,
failed tool calls, arbitrary document text or `/mnt/skills` / host filesystem paths.
The admin-only Settings → Appearance switch「技能检索文件展示」persists globally as
`citation_display.retrieved_files_enabled` (default true). The existing citation-display
PUT endpoint enforces administrator authorization. The UI waits for the setting
before collecting files, shares its query cache, and refreshes active clients every
60 seconds/on stale window focus. Disabling it hides retrieved cards, not normal artifacts.
Thread files reuse the authorized artifact endpoints and uploaded-file reader;
knowledge files reuse the authorized LLMWiki original-file API and
`WeKnoraSourceDrawer`. `/api/llmwiki/sources/resolve` maps provider knowledge-base
ids to local mapping ids only after checking the viewer's existing read access.
External links use HTTP(S) only, credential-free fetches for
inline preview, and a native original-file link for download/fallback; never send
the DeerFlow bearer token to them. External download behavior depends on the
source's response headers and browser cross-origin rules. Unknown formats or
unavailable Office sidecars fall back to downloading the original. Public shares
use their existing file route and do not query private knowledge document APIs.
Closing the shared preview dialog aborts in-flight requests and releases blob URLs.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
@@ -77,18 +112,20 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph
### Frontend
- **账号登录、自主注册与单点登录**:`/login` 提供用户名/邮箱与密码登录,`/register` 提供自主注册。管理员在「设置 → 登录与注册」管理 `system_settings.json.authentication` 的四个开关:账号密码登录、自主注册、单点登录(同一账号仅一处在线)默认关闭;既有外部系统免登 `sso_enabled` 默认开启。已保存配置保持不变。开启密码登录后,私有页面先经 `/api/v1/auth/me` 验证,后端 Gateway/LangGraph 同步拒绝旧免密 JWT;合法登录在 JWT 写入 `auth_method`。未设置个人密码的账号使用配置中的系统口令,未自定义时为 `123ewq`;修改密码也须验证当前个人密码或默认口令。开启 `single_session_enabled` 后,每次成功登录原子递增 `token_version`,旧会话下一次请求即失效;前端每 15 秒及窗口激活时检查并退出,失效页禁止自动重登录,避免互踢。关闭单处限制时允许多个登录会话。外部免登参数兼容 hash 前后两种位置(hash 参数优先)。公开配置 `GET /api/v1/auth/options`,管理员读写 `GET/PUT /api/system-settings/authentication`。个人信息下提供修改密码;`LoginWhitelistPage.tsx` 提供管理员重置。密码与口令共用,更新后旧密码/链接失效并撤销旧会话;当前用户改密或管理员重置自身须保存返回的新 Bearer token。新密码不在用户列表回传明文,管理员仅在本次重置后临时复制链接。注册与新建外部账号均为普通用户,禁止依据名称自动授予管理员权限。
The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
- **普通问答地图可视化**: shared `MarkdownContent` recognizes only dedicated `map-json` fences and otherwise preserves the legacy Markdown/Streamdown path byte-for-byte. `components/workspace/visualizations/` validates the bounded map contract with Zod, lazy-loads MapLibre, and provides loading/error/text fallbacks so malformed or unreachable maps never blank the message. The lead-agent prompt emits `map-json` only when the user explicitly asks for a map; flowcharts and other graphics continue through the existing Markdown/Mermaid path. The raster tile template comes from the single post-build setting `runtime-config.js` → `VITE_MAP_TILE_URL` (Google by default); replace it with the intranet `{z}/{x}/{y}` endpoint for disconnected deployments.
- **HTML 成品离线绘图运行时**: 新生成的 `.html` 源码直接引用 Gateway `/api/public/html-runtime/` 下的浏览器版 `g6.min.js`、`echarts.min.js`、`leaflet.js/css` 和 `deerflow-runtime.js`;文件随后台源码存放于 `vendor/html-runtime/`,不再依赖前端 Vite/node_modules 产物或公网 CDN。`config.yaml -> html_runtime.base_url` 是写入新 HTML 的浏览器可访问绝对地址,`html_runtime.map_tile_url` 是初始化脚本读取的瓦片模板。用户上传的栅格图若要作为页面背景,使用同一 Gateway 源下的签名地址 `/api/public/html-images/{token}` 写入 `background-image`。流程图使用 `data-df-flow` + G6(不用 Mermaid),图表使用 `data-df-chart`,地图使用 Leaflet `data-df-map`。普通问答保持独立:消息列表 `map-json` 继续由前端原逻辑渲染,普通流程图/图表仍走 Markdown/Mermaid。
- **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` 是地图网页模式读取的瓦片模板。设置 → 外观的管理员选项「HTML 地图」可选 `echarts`(本地 GeoJSON,`data-df-geo`,默认)或 `leaflet`(地图网页,`data-df-map`);运行时同时保留两种初始化,已生成页面不因切换失效。用户上传的栅格图若要作为页面背景,使用同一 Gateway 源下的签名地址 `/api/public/html-images/{token}` 写入 `background-image`。流程图使用 `data-df-flow` + G6(不用 Mermaid),图表使用 `data-df-chart`。普通问答保持独立:消息列表 `map-json` 继续由前端原逻辑渲染,普通流程图/图表仍走 Markdown/Mermaid。
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API.
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
- **策略优选**: route `/page/strategy/qa/strategy-selection`, sidebar 「问答管理 → 策略优选」. The standalone `src/strategy-selection/` workbench has two horizontal split stages: Step 1 keeps intent-understanding conversation cards on the left and an editable task draft plus angle selection on the right; Step 2 keeps multiplexed parallel strategy cards and automatic judging on the left while the right stays dedicated to the selected-strategy basket and final Markdown synthesis. It consumes the existing `POST /api/parallel-agents/run/stream` protocol, isolates output by stable agent id, supports expanded per-agent dialogs, user selection, Markdown editing/copy/download, and shared server-side DOCX export. A clearly marked neutral Taiwan-Strait risk-management demo is loaded by default for offline UI acceptance. Administrators can edit the SOUL and skills of the nine built-in functional agents through the page dialog; the backend seeds `strategy-selection-{intent,angle-*,judge,solution}` only when their directories are missing, preserving later admin changes. Pure parsing/SSE tests live in `src/strategy-selection/lib.test.ts`; backend seed tests live in `tests/test_strategy_selection_seed.py`.
- **轻应用中心 (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`.
- **白名单管理 (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`. **账号级登录口令与密码**已统一,个人口令优先于系统共享口令;新密码保存哈希,旧明文口令兼容校验。管理员重置使用 `PUT /api/v1/auth/users/{id}/login-password`(别名 `/reset-password`),同时更新两种历史凭据字段并撤销旧会话,不再支持清空密码或读取新密码明文。未设置个人密码时使用配置共享口令(默认 `123ewq`);登录开关关闭时保留旧入口兼容行为,开启后禁止免密进入。详见上方「账号登录、自主注册与单点登录」。
- **敏感词管理 (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/`。
- **按钮管理 (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, openMode, loginParams, appendAuth, authTokenParam, authNameParam, 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 (e.g. `/task/item-aiceh/step-analysis-audience`), `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). Independent-tab clicks toast and do nothing. **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=<JWT>&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`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. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」Line truncated
@@ -96,16 +133,21 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **会话冲突与模型工具流恢复**: when a user submits a new question while the previous turn is still streaming, the backend's `RunManager.create_or_reject` rejects with HTTP 409 (`services.py` returns the Chinese detail「当前对话还在生成回答…」). `core/threads/hooks.ts` detects the conflict and routes it through `confirmOrRejoin`. Run-creation SSE responses expose `Content-Location` plus the SDK `Location` reconnect endpoint; `core/api/api-client.ts` records the last consumed event id, so an in-page rejoin resumes after that cursor instead of replaying tool arguments from `-1`. `write_file` argument updates carried by `messages-tuple` count as business activity. A known active run is probed only after 90 seconds without a business frame; the watchdog hides `lg:stream`, stops only the old browser reader, and rejoins without cancelling the backend run. Clean EOF, transport errors, 409 and watchdog recovery share one single-flight Promise to prevent duplicate joins/finalization. The backend `PatchedChatOpenAI` separately normalizes confirmed cumulative argument snapshots and bounded compatible response shapes while preserving standard deltas; its content-free diagnostics never log prompts, arguments or file bodies.
- **React #185 与流式刷新保护**: every direct LangGraph `useStream` surface (main/agent chat, AI writing, Canvas and Studio notebook) uses shared `STREAM_RENDER_BATCHING = true`. The installed SDK implements numeric `throttle: 16` as a trailing debounce, which can indefinitely postpone UI updates during a continuous fast stream; `true` retains asynchronous next-task batching without requiring 16ms silence. `stream-error-classification.ts` detects `Maximum update depth exceeded` / production `#185` through bounded, cycle-safe wrappers. Main chat filters the SDK's stored error and duplicate submit rejection; `react-ui-error.ts` gives each send at most one automatic UI recovery, then pauses rejoin without cancelling the server run. Token activity must not reset that budget. The top-level `ReactUiErrorBoundary` catches only this render/commit error, shows a static friendly reload panel, and never automatically remounts the failing tree. Other errors are not swallowed. Shared toasts are deduplicated for 30 seconds; diagnostics retain error type, recovery phase, thread/run identifiers and component stack but never message/tool content. Real SDK SSE replay covers partial reasoning and write_file Markdown before generation ends; the browser boundary check triggers a real production React layout-effect loop and requires manual reload to recover. These safeguards do not prove the unknown production component loop is fixed.
- **流式游标作用域**: resumable cursors belong to the current SDK message accumulator, not a module-global map. `createStreamAPIClient` isolates same-reader recovery; newly mounted chats and real thread switches must replay the retained prefix instead of borrowing another reader's consumed position. Clearing replaces the cursor map so late old-reader events cannot pollute the new scope. The shared `getAPIClient` does not rewrite replay-from-start cursors. `stream-cursor-replay.test.ts` uses the real SDK to protect against both duplicate tool arguments on reconnect and missing prefixes on reopening.
- **单智能体任务深链聊天记录(2026-10-19)**: `3qfx` / `rwfx` / `xdfx` 已不再通过 `useTaskThreads` 扫描、过滤通用 `threads_meta` 列表;`TaskFxWorkspace` / `TaskFxIndexResolver` 统一调用 `useTaskDeeplinkThreads` → `GET /api/task-deeplink-threads?task_id=&go_path=`,读取独立表 `task_deeplink_threads` 的 `(task_id, go_path)` 索引,再按 `thread_id` 关联 canonical `threads_meta` 标题/状态(消息与 checkpoint 仍由 LangGraph 管理)。新会话 metadata 写 `taskRecordId`、`taskGoPath`、`thread_type=task_deeplink` 并由 thread-meta 仓储同事务登记;`3qfx/rwfx` 的 `taskRecordId` 是任务 id,`xdfx` 是 URL 原始行动 id(真实任务 id 仍保留在 `metadata.taskId` 维持共享访问语义)。普通聊天记录默认隐藏该 thread type;显式 taskId 查询仍可读取。迁移 `20261019_01` **只建表、不回填旧数据**,因此上线前旧深链会话不会出现在新列表。`/api/open/3qfx/ask` 同样写专用索引。Tests: `packages/harness/tests/test_task_deeplink_threads.py`, `src/core/auth/task-purpose-metadata.test.ts`, `src/core/threads/hidden-chats.test.ts`。
- **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
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations staLine truncated
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent/taskType`; opening text maps `taskType` 1/2/3 to 打击/塑势/防御) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskILine 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`。(2.5) **`?hideChrome=1`** — **仅**去全局 chrome(Header 导航栏 + PageSidebar 侧边栏),**不**强制主题、**不**隔离鉴权(区别于 `embed`:只想要一个干净全屏页面,如「大屏绘制」`/page/strategy/dashboard-agent?taskId=…&hideChrome=1`)。新增 `EmbedSession.hideChrome` 字段 + `readHideChromeParam()` + `isChromeHidden()`(取 `embed || hideChrome` 之并),与 `hideJump` 完全对称;`PageRoutes.tsx` 的 `PageLayout` 把 `chromeHidden`(`isChromeHidden() || readHideChromeParam(location.search)==="on"`)并入 `pageSidebarAllowed`,且其**优先级高于** dashboard-agent 路由原本的强制全局 chrome(`forceChromeForDashboardAgent`)。`hideChrome=0` 可显式关闭。(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 提示。
- **操作日志(Y-Log)上报** — 用户的关键操作(登录登出/查询/查看/增删改/启停/导入导出/问答等)成功后,**前端直连**外部接口 `POST {write_url}`(默认 `https://ch1.b.uat.4.cn/api/y-log/write`)记一条审计日志。核心模块 `src/core/log/`:`y-log.ts`(`writeYLog(params)` + `OptType` 枚举 0/1/3..12;**异步即发即忘、永不 await、永不抛错、失败静默**,自动把完整 url 转相对路径去 ip/host、对象入参/返回自动 `JSON.stringify` + 50k 裁剪、`optResult` 恒 0 只报成功),`y-log-config.ts`(从公开只读 `GET /api/public/config/y-log` 拉 `{enabled, writeUrl}`,模块级缓存 + 去重 + 默认兜底;`App.tsx` `AppProviders` 启动预热 `fetchYLogConfig()`),`y-log-token.ts`(token1 = token 登录的 `?authToken=`,经 [[scoped-storage]] 的 `authStorage*` 存取,嵌入态自动隔离)。**双门控**:仅当①存在 token1(即用 token 登录,`LoginPage` 成功后 `persistYLogToken(authToken)`,登出 `account-settings-page.tsx` 先报后清 token1)且②配置 `enabled && writeUrl` 才上报;否则一律不发。`optDetail` 统一「CM助手-全量-…」(**禁内部框架名**)。埋点**广覆盖**各 API client 的成功路径——原则是「一个用户操作(查看/查询/新增/编辑/删除/启停/导入导出)= 一条日志」,每个 API client 的**单条详情 GET(查看→view 4)/ 列表查询 GET(search 3)/ 新增 POST(create 9)/ 改 PUT·PATCH(edit 5)/ 删 DELETE(delete 6)/ 启停(enabled 11 / disabled 10)/ 导入导出(import 7 / export 8)**都各落一条,仅跳过高频 typeahead/状态轮询/纯 bootstrap config 读。覆盖文件:`strategy-components/api/{sensitive-words,menu-overrides,task-buttons,skill-addresses,light-apps,business-mapping}.ts`、`roundtable-planning/api/{drafts,task-drafts,roundtable-jobs,recommend,chains,multi-agent,dashboard-sessions}.ts`、`core/{agents,scheduled-tasks,knowledge,positions,tags,studio,system-settings,memory,mcp,models,custom-prompts,user-preferences,notifications,recommended-questions,user-qa,curator,admin,thread-shares,roundtable-draft-shares,tool-metrics,llm-metrics,note,artifacts}/api.ts`、`core/api/markdown-docx-export.ts`。**问答(Q&A)改为「一次大模型调用 = 一条日志」**:在共享的 `core/threads/hooks.ts` `useThreadStream` 的 `onLangChainEvent` 里监听 `on_chat_model_end`,每次模型调用结束就上报一条(optType 12,`reqParam` 取本次 input 的最后一条 user 文本 + 模型名,`returnParam` 取本次输出文本)——一个问答轮次里 lead agent / 子代理的**每次** LLM 调用都各记一条,覆盖所有走 `useThreadStream` 的页面(`ChatPage`/`AgentChatPage`/创建智能体/iframe/会商等);`ChatPage`/`AgentChatPage` 的 `onFinish` 仍额外保留一条轮次汇总。后端仅新增公开只读配置:`deerflow/config/y_log_config.py`(`YLogConfig{enabled,write_url}`)挂到 `AppConfig.y_log`,`app/gateway/routers/public_config.py` 加 `GET /api/public/config/y-log`,地址写在 `config.yaml` 的 `y_log` 段(mtime 热重载,前端无需重建);**不做后端代理**。Tests:`tests/test_y_log_config.py`。
### Configuration
普通问答与智能体问答的 `write_file` 流式展示:`namedStreamingWriteFilePath` 统一判断路径是否可用;缺少路径、路径尚未完整或只有占位名时,不生成虚拟文件来自动打开沙箱,而由 `message-group.tsx` 在步骤内用绿色小字展示正文。步骤参数合并 `tool_calls` 与 `tool_call_chunks`,后续收到真实路径时复用原工具调用 ID 恢复正常文件预览;`outputs` 下真实文件的自动打开和沙箱流式预览保持原流程。
沙箱文件正在生成时,ArtifactsProvider 的 `select` / `openStreamedArtifact` 阻止切到其他文件;手动操作提示「当前文件正在生成,请稍等片刻,完成后就可以切换查看其他文件。」,自动选择静默跳过。ChatBox 按选中文件的未完成 write_file 和运行状态注册锁,预览组件同时覆盖流式写入及全文改写;完成、停止或卸载时移除所属锁。同一个文件的虚拟预览和真实路径视为同一目标。
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.
## Detailed Backend Reference
+739
View File
@@ -0,0 +1,739 @@
# DeerFlow 接口卡顿独立诊断脚本使用说明
配套脚本:[`diagnose_deerflow_latency.py`](./diagnose_deerflow_latency.py)
## 0. 最简单的用法(推荐)
把脚本复制到 DeerFlow 所在服务器,然后直接运行:
```bash
python3 diagnose_deerflow_latency.py
```
脚本会显示中文向导。你只需要:
1. 粘贴卡顿接口 URL;
2. 选择普通 GET、登录 POST 或 Agent SSE;
3. 登录/Agent POST 时再提供请求 JSON 文件;
4. 根据提示选择是否需要 Bearer Token 或 Cookie。
5. 确认脚本是否运行在实际部署服务器上,避免把个人电脑的 CPU/内存误认为服务器指标。
脚本会显示 `[1/5]` 到 `[5/5]` 的进度,最后直接输出“**一键判断**”,告诉你当前证据更偏向:
- Nginx/入口代理;
- MySQL/数据库;
- DeerFlow 并发或热点路径;
- CPU、内存、磁盘等服务器资源;
- DNS、TCP、TLS 等网络问题。
如果只是普通 GET,也可以一行执行:
```bash
python3 diagnose_deerflow_latency.py 'https://你的域名/卡顿接口'
```
一行模式只请求目标接口一次,不自动压测、不跟随重定向、不复制请求到本机直连地址。需要登录态时,可使用专门的诊断环境变量:
```bash
export DEERFLOW_DIAG_TOKEN='你的测试 Token'
export DEERFLOW_DIAG_ON_SERVER=1
python3 diagnose_deerflow_latency.py 'https://你的域名/api/skills?sort=time_desc'
unset DEERFLOW_DIAG_TOKEN DEERFLOW_DIAG_ON_SERVER
```
Token 值不会显示或写入报告。不要使用生产管理员的长期 Token。
## 1. 工具定位
这是一个可以单独复制、单独运行的诊断脚本,不是 DeerFlow 后端补丁。
- 不需要重新发布或替换后端。
- 不需要把脚本放进 DeerFlow 源码目录。
- 不需要安装 Python 第三方包。
- 不会修改 Nginx、MySQL、Docker 或 DeerFlow 配置。
- 默认只进行 HTTP 测量和只读状态采集。
- 支持 Python 3.9 及以上版本。
建议把脚本复制到发生卡顿的 Linux 服务器,在卡顿正在发生时运行。只有从服务器本机运行,才能同时采集 CPU、内存、磁盘、连接队列、Nginx、Docker 等现场信息。
> 登录接口和 Agent 接口通常是 POST,请求本身可能创建会话或任务。脚本默认只发送一次,不会自动压测。请使用测试账号和测试数据。
## 2. 能排查什么
脚本从以下层次收集证据:
1. **网络和 HTTPS**
- DNS 解析时间
- TCP 建连时间
- HTTP 代理 CONNECT 时间
- TLS 握手时间
- 请求上传时间
- 请求发完到首响应的等待时间
- 响应体读取时间
2. **Nginx 和系统连接容量**
- 公网入口与 `127.0.0.1:8001` 直连的耗时差异
- 监听队列与 TCP 状态
- Nginx worker 数量、`worker_connections` 和文件句柄限制
- 可选的 Nginx `stub_status`
- 502、503、504、upstream timeout、too many open files 等日志模式
3. **MySQL**
- TCP 建连
- 多次新连接执行 `SELECT 1` 的耗时
- `Threads_connected`、`max_connections` 等状态
- 活动时间较长的会话数量
- 数据锁、元数据锁等待
- performance schema 聚合信息
MySQL 检查仅执行预置的只读查询,不保存 SQL 正文。
4. **DeerFlow 请求排队和热点路径**
- `/api/health`
- `/api/v1/auth/options`
- 登录态下的 `auth/me`、Agents、Skills 接口
- 慢接口执行期间健康接口是否同时变慢
- 可选的管理员活动运行数和模型并发限制
- Agent SSE 首包时间
5. **服务器和容器容量**
- CPU、load、iowait、steal
- 内存、Swap、Linux PSI
- 磁盘忙碌度和剩余空间
- 进程 CPU、RSS、线程数、FD 使用率
- cgroup/Docker CPU 和内存限制
- Docker OOM、重启次数
## 3. 准备脚本
把单个 Python 文件复制到目标服务器:
```bash
scp diagnose_deerflow_latency.py user@server:/tmp/
```
登录服务器后检查 Python:
```bash
python3 --version
python3 /tmp/diagnose_deerflow_latency.py --help
```
脚本可以在任意目录执行。只有使用 `--config` 时才需要知道 DeerFlow 的 `config.yaml` 路径。
## 4. 第一次运行:只测一个接口
```bash
python3 /tmp/diagnose_deerflow_latency.py \
--url 'https://example.com/api/health' \
--count 5 \
--server-checks \
--output /tmp/deerflow-latency.json
```
终端会显示中文摘要,完整脱敏结果写入 `/tmp/deerflow-latency.json`。
Linux 上默认会自动启用服务器检查;显式写上 `--server-checks` 更清楚。普通用户权限不足时,部分 Nginx、Docker 或进程信息会显示为“未完成检查”,但不会导致整个诊断失败。
默认还会对目标 origin 的 `/health`、`/api/health` 和 `/api/langgraph/health` 各进行一次安全 GET 探测。如果不希望产生这些额外请求,增加 `--no-health-baselines`。
## 5. 推荐排查顺序
### 第一步:测公网入口
```bash
python3 /tmp/diagnose_deerflow_latency.py \
--url 'https://example.com/api/skills?sort=time_desc' \
--count 5 \
--server-checks \
--output /tmp/01-public.json
```
### 第二步:同时对比 Gateway 本机直连
确保两条 URL 的路径、查询参数和业务行为一致:
```bash
python3 /tmp/diagnose_deerflow_latency.py \
--url 'https://example.com/api/skills?sort=time_desc' \
--compare-url 'http://127.0.0.1:8001/api/skills?sort=time_desc' \
--count 5 \
--server-checks \
--output /tmp/02-public-vs-direct.json
```
如果请求带 Token、Cookie、任意自定义 header、`--query-env`、请求体,或使用 POST,向不同 origin 发送这些内容前必须明确增加:
```text
--allow-cross-origin-compare
```
该参数意味着你已经确认比较地址可信。POST 登录和 Agent 请求不建议使用 `--compare-url`,应分两次各运行一次,避免重复创建状态。
### 第三步:增加 DeerFlow 基线接口
```bash
python3 /tmp/diagnose_deerflow_latency.py \
--url 'https://example.com/api/skills?sort=time_desc' \
--count 5 \
--deerflow-suite \
--server-checks \
--output /tmp/03-deerflow-suite.json
```
未提供登录凭据时,只检查公开的 health 和 auth/options。提供 Bearer Token 或 Cookie 后,才会增加 `auth/me`、Agents、Skills 等登录态基线。
### 第四步:安全 GET 接口使用干扰探针
下面的命令会在慢接口执行期间,每 0.25 秒探测一次 `/api/health`:
```bash
python3 /tmp/diagnose_deerflow_latency.py \
--url 'https://example.com/api/skills?sort=time_desc' \
--count 5 \
--deerflow-suite \
--interference \
--server-checks \
--output /tmp/04-interference.json
```
如果慢接口执行时 health 也明显变慢,更偏向 Gateway worker、事件循环、CPU 或 IO 被阻塞。如果 health 始终很快,更偏向该请求自身的数据库、模型或下游服务等待。
`--interference` 会额外请求一次目标接口,仅建议用于 GET/HEAD/OPTIONS 等无副作用接口。
### 第五步:检查 MySQL
先做不带账号的 TCP 连通性检查:
```bash
python3 /tmp/diagnose_deerflow_latency.py \
--url 'https://example.com/api/health' \
--server-checks \
--mysql-host '10.0.0.20' \
--mysql-port 3306 \
--output /tmp/05-mysql-tcp.json
```
若需查询连接、锁和 performance schema,请参见“经过认证的 MySQL 只读检查”。
### 第六步:卡顿时再运行一次
间歇性问题恢复后再执行,只能反映恢复后的状态。最好在下面任一现象出现时立即运行:
- 登录等待很久;
- Skills/Agents 页面一直转圈;
- Agent 长时间没有返回 SSE 响应实体字节;
- 出现 429、502、503 或 504;
- 所有页面同时变慢。
## 6. 鉴权接口
### 6.1 Bearer Token
不要把 Token 直接写进命令行。使用环境变量:
```bash
read -rsp 'Bearer Token: ' DF_TOKEN
export DF_TOKEN
echo
python3 /tmp/diagnose_deerflow_latency.py \
--url 'https://example.com/api/skills?sort=time_desc' \
--bearer-env DF_TOKEN \
--count 5 \
--deerflow-suite \
--server-checks \
--output /tmp/skills-auth.json
unset DF_TOKEN
```
Token 值不会写入报告。
### 6.2 Cookie
```bash
read -rsp 'Cookie header value: ' DF_COOKIE
export DF_COOKIE
echo
python3 /tmp/diagnose_deerflow_latency.py \
--url 'https://example.com/protected/path' \
--cookie-env DF_COOKIE \
--count 5 \
--output /tmp/cookie-request.json
unset DF_COOKIE
```
环境变量值应为 Cookie header 的值,例如 `session=...; other=...`,不要包含 `Cookie:` 前缀,也不要把真实值发给其他人。
### 6.3 自定义请求头
普通非敏感头:
```text
--header 'Accept: application/json'
```
敏感头使用环境变量:
```bash
read -rsp 'API key: ' DF_API_KEY
export DF_API_KEY
echo
python3 /tmp/diagnose_deerflow_latency.py \
--url 'https://example.com/path' \
--header-env 'X-API-Key=DF_API_KEY'
unset DF_API_KEY
```
### 6.4 URL 查询参数中的敏感值
不要把 ticket、token、password 等直接写进 URL。使用:
```bash
read -rsp 'Ticket: ' DF_TICKET
export DF_TICKET
echo
python3 /tmp/diagnose_deerflow_latency.py \
--url 'https://example.com/login/callback' \
--query-env 'ticket=DF_TICKET' \
--count 1 \
--output /tmp/ticket-request.json
unset DF_TICKET
```
报告中的 URL 查询参数值会统一脱敏。
## 7. 登录 POST 接口
登录请求可能创建登录会话,所以默认只发送一次。先准备请求体文件,并限制权限:
```bash
umask 077
vi /tmp/login-request.json
chmod 600 /tmp/login-request.json
```
例如请求体可能是:
```json
{
"username": "diagnosis-user",
"password": "请填写测试账号密码"
}
```
实际字段应以你的登录接口为准。运行:
```bash
python3 /tmp/diagnose_deerflow_latency.py \
--url 'https://example.com/api/v1/auth/login' \
--method POST \
--header 'Content-Type: application/json' \
--data-file /tmp/login-request.json \
--count 1 \
--read-mode full \
--first-byte-timeout 60 \
--total-timeout 90 \
--server-checks \
--output /tmp/login-diagnosis.json
```
完成后删除临时请求体:
```bash
rm -f /tmp/login-request.json
```
注意:
- 请求体内容不会写入 JSON 报告。
- 不要为登录 POST 增加 `--interference`。
- 不要同时使用 `--compare-url`,公网和直连应分别执行一次。
- 不要增加 `--count` 或 `--concurrency`,除非确认可以重复登录并明确加上 `--allow-unsafe-replay`。
如果 HTTP 状态很快返回 401/403,这仍然说明链路和服务响应很快;它不代表登录业务验证成功。请结合 `status_counts` 判断。
## 8. Agent SSE 首包诊断
Agent 请求通常是 POST,并返回 `text/event-stream`。使用 `first-byte` 模式,只等待首个响应实体字节,不等待整个 Agent 任务结束:
```bash
read -rsp 'Bearer Token: ' DF_TOKEN
export DF_TOKEN
echo
python3 /tmp/diagnose_deerflow_latency.py \
--url 'https://example.com/实际Agent流式接口' \
--method POST \
--header 'Content-Type: application/json' \
--header 'Accept: text/event-stream' \
--bearer-env DF_TOKEN \
--data-file /tmp/agent-request.json \
--count 1 \
--read-mode first-byte \
--first-byte-timeout 180 \
--total-timeout 240 \
--server-checks \
--output /tmp/agent-first-byte.json
unset DF_TOKEN
```
请使用测试会话。客户端在收到首个响应实体字节后会关闭连接,但服务端任务是否继续运行取决于实际接口实现。
`first-byte` 不解析 SSE 协议边界,因此这个字节不等于一个完整 SSE 事件,也不一定代表模型已经开始生成。它可能来自 SSE 注释、心跳、代理填充,甚至 JSON/HTML 错误正文;必须同时检查 HTTP 状态码和响应 `Content-Type`。
重点看:
- `response_wait` 很高:服务端迟迟没有返回响应头,可能在排队、鉴权、数据库、worker 或上游连接阶段等待;
- 响应头很快,但首个响应实体字节很慢:可能在 Agent、模型并发或模型供应商阶段等待;
- 返回 429:优先检查 DeerFlow 模型并发限制或上游配额;
- 返回 502/503/504:结合 Nginx error log 和本机直连结果。
首个响应实体字节的精确耗时位于 JSON 单样本的 `hops[].timings_ms.first_body_after_upload`;`response_wait` 表示请求上传完成到收到首个响应字节,通常对应响应头开始返回。
## 9. Skills 接口专项诊断
```bash
read -rsp 'Bearer Token: ' DF_TOKEN
export DF_TOKEN
echo
python3 /tmp/diagnose_deerflow_latency.py \
--url 'https://example.com/api/skills?sort=time_desc' \
--compare-url 'http://127.0.0.1:8001/api/skills?sort=time_desc' \
--allow-cross-origin-compare \
--bearer-env DF_TOKEN \
--count 5 \
--deerflow-suite \
--interference \
--server-checks \
--config '/实际路径/config.yaml' \
--output /tmp/skills-diagnosis.json
unset DF_TOKEN
```
该组合可以同时判断:
- 公网/Nginx 是否比 Gateway 直连慢;
- Skills 是否比 health、auth 和 Agents 更慢;
- Skills 执行期间 health 是否也被拖慢;
- 技能目录文件量和仅元数据遍历是否异常;
- CPU、iowait、磁盘是否同时出现压力。
## 10. Nginx 连接数专项诊断
脚本在 Linux 上会尝试执行只读的 `nginx -T`、`ss` 和 `/proc` 检查。如果已经配置仅本机可访问的 `stub_status`,可增加:
```bash
python3 /tmp/diagnose_deerflow_latency.py \
--url 'https://example.com/api/health' \
--server-checks \
--nginx-status-url 'http://127.0.0.1/nginx_status' \
--output /tmp/nginx-diagnosis.json
```
主要证据:
- `active_connections` 接近 `worker_processes × worker_connections`;
- `Recv-Q` 接近监听 backlog;
- 大量 `SYN-RECV`;
- Nginx 日志命中 `worker_connections are not enough`;
- 进程 FD 使用率接近上限;
- 公网明显慢于 `127.0.0.1:8001` 直连。
仅看到大量 ESTABLISHED 或 Waiting 连接,不能单独证明连接数耗尽。SSE 和 keepalive 本身就会保持连接,应结合理论槽位、FD、队列和错误日志判断。
## 11. 经过认证的 MySQL 只读检查
服务器需要安装 `mysql` 命令行客户端。建议为诊断创建权限受限的只读账号,并使用单独的 client 配置文件:
```ini
[client]
host=10.0.0.20
port=3306
user=diagnosis_ro
password=替换为诊断账号密码
database=deerflow
protocol=TCP
ssl-mode=REQUIRED
```
保存后限制权限:
```bash
chmod 600 /secure/path/diagnosis.cnf
```
运行:
```bash
python3 /tmp/diagnose_deerflow_latency.py \
--url 'https://example.com/api/health' \
--server-checks \
--mysql-defaults-file '/secure/path/diagnosis.cnf' \
--mysql-probes 3 \
--output /tmp/mysql-diagnosis.json
```
TCP 认证要求配置 `ssl-mode=REQUIRED`、`VERIFY_CA` 或 `VERIFY_IDENTITY`。如果 MySQL 只允许本机 Unix Socket,可使用:
```ini
[client]
user=diagnosis_ro
password=替换为诊断账号密码
database=deerflow
protocol=SOCKET
socket=/var/run/mysqld/mysqld.sock
```
普通业务库只读权限通常足以执行 `SELECT 1`,但查看完整 processlist、全局状态以及 `performance_schema` 的锁和 digest 聚合可能需要额外的只读可见权限。权限不足时脚本会保留已完成的检查,并把其余项目标记为部分不可用;请查看报告的 `coverage.unavailable` 和 `server.mysql` 错误字段。不要为了消除这些提示授予写权限或管理员权限,应由 DBA 按最小权限补充所需的监控可见性。
也可以把 MySQL URL 放入环境变量:
```bash
read -rsp 'MySQL URL: ' DF_MYSQL_URL
export DF_MYSQL_URL
echo
python3 /tmp/diagnose_deerflow_latency.py \
--url 'https://example.com/api/health' \
--server-checks \
--mysql \
--mysql-url-env DF_MYSQL_URL \
--output /tmp/mysql-diagnosis.json
unset DF_MYSQL_URL
```
TCP URL 必须带 `ssl-mode=REQUIRED`、`VERIFY_CA` 或 `VERIFY_IDENTITY`。不要把数据库密码直接写到命令行。
用户名、密码、数据库名中的特殊字符需要按 URL 规则进行 percent-encoding。
## 12. DeerFlow 活动运行数和并发限制
如果当前账号具有管理员权限,可以读取活动运行数和模型并发配置:
```bash
read -rsp 'Admin JWT: ' DF_ADMIN_TOKEN
export DF_ADMIN_TOKEN
echo
python3 /tmp/diagnose_deerflow_latency.py \
--url 'https://example.com/api/health' \
--admin-token-env DF_ADMIN_TOKEN \
--server-checks \
--output /tmp/concurrency-diagnosis.json
unset DF_ADMIN_TOKEN
```
如果管理员 API 使用另一个可信 origin:
```text
--admin-origin 'https://gateway.example.com' --allow-cross-origin-admin
```
报告只保存活动数量、最长运行时间、模型聚合及并发限制,不保存任务正文。
## 13. 小规模并发退化测试
`--concurrency` 是主动并发请求,会增加生产压力。应先使用单并发建立基线:
```bash
python3 /tmp/diagnose_deerflow_latency.py \
--url 'https://example.com/api/skills?sort=time_desc' \
--count 10 \
--concurrency 1 \
--output /tmp/concurrency-1.json
```
在经过确认的低峰窗口,最多先升到 2:
```bash
python3 /tmp/diagnose_deerflow_latency.py \
--url 'https://example.com/api/skills?sort=time_desc' \
--count 10 \
--concurrency 2 \
--output /tmp/concurrency-2.json
```
不要对登录、创建任务、发送消息等 POST 接口进行并发测试。脚本会拒绝重复或并发执行有副作用的方法,除非显式增加 `--allow-unsafe-replay`。
## 14. 如何读报告
### 14.1 HTTP 时间字段
| 字段 | 含义 | 主要怀疑方向 |
|---|---|---|
| `dns` | DNS 解析 | DNS 服务器、解析链路 |
| `tcp_connect` | TCP 建连 | 网络、防火墙、SYN/backlog、连接压力 |
| `proxy_connect` | HTTP 代理建立隧道 | 前向代理 |
| `tls_handshake` | TLS 握手 | TLS 终止层、证书链、中间设备、CPU |
| `request_upload` | 发送请求头和请求体 | 客户端到服务器上传链路 |
| `response_wait` | 请求发完到收到首个响应字节 | Nginx 上游排队、Gateway、DB、文件扫描、下游服务 |
| `headers_complete_after_upload` | 请求发完到响应头完整 | 响应头返回阶段 |
| `body_read` | 读取响应体 | 大响应、SSE、网络吞吐、代理缓冲 |
| `total` | 单次总耗时 | 整体体验 |
脚本使用新的 HTTP/1.1 连接测量,不等同于浏览器复用 HTTP/2/keepalive 的真实行为。
### 14.2 四类怀疑的判断矩阵
| 观察结果 | 更可能的方向 |
|---|---|
| 公网慢,本机 `127.0.0.1:8001` 快 | Nginx、负载均衡、TLS、入口网络 |
| 公网和直连都慢,health 也同时慢 | Gateway worker/事件循环、CPU、内存、磁盘 IO、整机压力 |
| health 快,但 auth/me、Agents 等多个 DB 路径慢 | MySQL、连接池、锁、数据库磁盘 |
| 只有 Skills 慢,且执行时 health 也慢 | 同步技能目录扫描、慢盘/NFS、单 worker 阻塞 |
| Agent 首包慢,但 health 快 | 模型并发、模型供应商、工具调用或该请求下游 |
| 429 | DeerFlow 并发限制、上游配额或其他限流 |
| 502/503/504 | Nginx 到上游失败、上游超时或服务不可用 |
| TCP 慢且监听队列/SYN-RECV 高 | 连接洪峰、backlog 或处理能力不足 |
| Nginx active 接近理论槽位,FD 也高 | Nginx 连接/文件句柄容量风险 |
| MySQL `Threads_connected` 接近 `max_connections` | DB 连接容量或连接泄漏 |
| MySQL 存在锁等待/长会话 | 锁、长事务或慢 SQL |
| CPU busy/load/PSI 高 | 服务器算力不足或进程热点 |
| iowait/磁盘 util 高 | MySQL、SQLite、技能目录或日志所在存储慢 |
| MemAvailable 很低、Swap/OOM 明显 | 内存不足或容器限制过低 |
单个指标不能直接定根因。脚本的“优先结论”会把多项证据组合起来,并给出下一步建议。
`successful_transport` 只表示 HTTP 传输流程完成。HTTP 401、403、429、500、502 等仍可能计入传输成功,必须同时检查 `status_counts` 和报告中的优先结论。
## 15. 常用参数
| 参数 | 说明 |
|---|---|
| `--url URL` | 目标地址,可重复 |
| `--url-env ENV` | 从环境变量读取目标 URL |
| `--compare-url URL` | 同一请求的另一路径,例如 Gateway 直连 |
| `--method GET/POST` | HTTP 方法 |
| `--bearer-env ENV` | 从环境变量读取 Bearer Token |
| `--cookie-env ENV` | 从环境变量读取 Cookie |
| `--header-env HEADER=ENV` | 从环境变量读取敏感请求头 |
| `--query-env KEY=ENV` | 从环境变量读取查询参数值 |
| `--data-file PATH` | 请求体文件,`-` 表示 stdin |
| `--count N` | 请求次数;GET 默认 5,POST 默认 1 |
| `--concurrency N` | 并发数,默认 1 |
| `--read-mode MODE` | `auto`、`headers`、`first-byte`、`full` |
| `--connect-timeout SEC` | DNS/TCP/TLS 相关连接超时 |
| `--first-byte-timeout SEC` | 等待响应头或首个响应实体字节的超时 |
| `--read-timeout SEC` | 正文空闲读取超时 |
| `--total-timeout SEC` | 单请求硬性总超时 |
| `--deerflow-suite` | 检查 DeerFlow 基线接口 |
| `--interference` | 慢 GET 执行期间持续测 health |
| `--server-checks` | 收集 Linux/Nginx/Docker/日志/数据库状态 |
| `--config PATH` | DeerFlow `config.yaml` 路径 |
| `--log PATH` | 增加一个待扫描日志,可重复 |
| `--nginx-status-url URL` | Nginx stub_status 地址 |
| `--mysql-host HOST` | 只做 MySQL TCP 测量 |
| `--mysql-defaults-file PATH` | 进行 MySQL 认证只读检查 |
| `--admin-token-env ENV` | 管理员只读并发检查 |
| `--output PATH` | JSON 报告路径 |
| `--no-output` | 不生成 JSON 文件 |
| `--json-stdout` | 将完整 JSON 输出到终端 |
查看全部参数:
```bash
python3 /tmp/diagnose_deerflow_latency.py --help
```
## 16. TLS、代理和重定向
- HTTPS 默认校验证书。
- 内部 CA 使用 `--ca-file /path/to/ca.pem`。
- `--insecure` 会关闭证书校验,仅用于明确了解风险的临时诊断,报告会记录安全警告。
- 默认不使用系统 `HTTP_PROXY`/`HTTPS_PROXY`,需要时显式增加 `--use-env-proxy`。
- 也可以使用 `--proxy` 或更安全的 `--proxy-env`。
- 默认不跟随重定向;`--follow-redirects` 只允许同 origin 跳转,跨 origin 会被拒绝。
- 带凭据、环境变量查询值或请求体时,非本机明文 HTTP 会被拒绝。优先使用 HTTPS,不建议使用 `--allow-insecure-http`。
`--allow-unsafe-replay`、`--allow-cross-origin-compare`、`--allow-cross-origin-admin`、`--allow-insecure-http` 和 `--insecure` 都是明确的风险开关,不应放进日常默认命令。
## 17. 日志和权限
脚本会尝试自动发现常见的 Nginx、Gateway 和运行日志,也可以显式提供:
```text
--log /var/log/nginx/error.log --log /path/to/gateway.log
```
报告只保存错误模式的命中次数,不保存完整日志行,避免把业务内容带入报告。
建议先以普通部署用户运行。权限不足时,报告会明确列出缺失项。不要为了方便直接把数据库密码或 Token 写进 `sudo` 命令行;如确需更高权限读取 Nginx/进程信息,应在受控 shell 中重新设置所需环境变量。
## 18. 报告安全
脚本不会保存:
- 请求体和响应体;
- Token、Cookie、密码或敏感请求头值;
- URL 查询参数值;
- MySQL SQL 正文;
- 原始日志行。
报告仍可能包含:
- IP、端口和路径;
- 容器名、模型名、数据库主机名;
- Nginx 和系统容量信息;
- 接口路径和状态码。
把报告发给其他人之前仍应人工检查和脱敏。不要公开上传到互联网。
## 19. 常见问题
### 报告显示“未完成检查”
通常是权限不足、命令不存在或功能未配置。例如:
- 没有 `ss`;
- 当前用户无法运行 `nginx -T`;
- 没有 Docker socket 权限;
- 没有安装 mysql client;
- 没有配置 Nginx stub_status;
- 管理员 Token 权限不足。
这不会使已经采集到的其他证据失效。
### 接口返回 401 或 403
检查 Bearer/Cookie 是否正确,以及接口是否需要特殊请求头。401/403 是 HTTP 响应,不属于 DNS/TCP/TLS 传输失败。
### 公网和直连业务不一致
某些 Nginx 配置依赖原始 `Host`。如果直接访问 `127.0.0.1:8001` 与公网执行的不是同一业务,则两者耗时不能直接比较。可根据实际配置增加非敏感 Host header:
```text
--header 'Host: example.com' --allow-cross-origin-compare
```
仅在确认后端确实需要该 Host 时使用。自定义 `Host` 会同时应用到公网目标和直连目标;不同 origin 的比较存在任意自定义 header 时,脚本要求显式使用 `--allow-cross-origin-compare`。
### 脚本没有抓到问题
间歇性卡顿通常需要在问题发生时运行。可以保留同一套命令,在用户反馈卡顿时立即执行,并同时记录准确时间,之后与 Nginx、Gateway、MySQL 监控对齐。
## 20. 提交结果供分析
建议提供以下信息:
1. 脱敏后的 JSON 报告;
2. 卡顿发生的准确时间和时区;
3. 目标 URL 路径,不需要 Token、Cookie 或密码;
4. 请求方法 GET/POST;
5. 是否经过 Nginx、负载均衡或网关;
6. 后端是否为 Docker 部署;
7. 公网和本机直连是否都慢;
8. 当时大约同时在线或执行 Agent 的人数。
不要提供真实账号密码、Token、Cookie、数据库密码或未经检查的完整业务日志。
@@ -1,6 +1,7 @@
importScripts("config.js", "shared.js");
const ALARM_NAME = "deer-browser-task-poll";
const SERVICE_DISCOVERY_TTL_MS = 10 * 60 * 1000;
const LEGACY_LOCAL_API_BASE_URL = "http://localhost:8001";
const LEGACY_LOCAL_WEB_BASE_URL = "http://localhost:5174";
let pollTimer = null;
@@ -18,6 +19,8 @@ async function getSettings() {
const discovered = await chrome.storage.local.get({
discoveredApiBaseUrl: "",
discoveredWebBaseUrl: "",
lastServiceSyncAt: "",
lastServiceSyncSource: "",
});
if (settings.autoDiscoverServiceUrl) {
settings.apiBaseUrl = discovered.discoveredApiBaseUrl || settings.apiBaseUrl;
@@ -33,6 +36,8 @@ async function getSettings() {
if (!settings.workerId) {
updates.workerId = deerMakeWorkerId();
}
settings.lastServiceSyncAt = discovered.lastServiceSyncAt || "";
settings.lastServiceSyncSource = discovered.lastServiceSyncSource || "";
if (Object.keys(updates).length > 0) {
await chrome.storage.sync.set(updates);
Object.assign(settings, updates);
@@ -176,6 +181,17 @@ async function discoverServiceBases(settings, force = false) {
if (!settings.autoDiscoverServiceUrl && !force) {
return settings;
}
const lastSync = Date.parse(settings.lastServiceSyncAt || "");
const discoveryAge = Date.now() - lastSync;
if (
!force &&
Number.isFinite(lastSync) &&
settings.lastServiceSyncSource &&
discoveryAge >= 0 &&
discoveryAge < SERVICE_DISCOVERY_TTL_MS
) {
return settings;
}
let webBaseUrl = normalizeServiceBase(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl);
let discoveredWebBaseUrl = webBaseUrl;
@@ -9,6 +9,8 @@ const BACKGROUND_PATH = path.join(__dirname, "background.js");
function createHarness() {
let storageChangedListener;
let alarmCreateCount = 0;
const localData = {};
const fetchUrls = [];
const settings = {
apiBaseUrl: "http://localhost:8001",
webBaseUrl: "http://localhost:5174",
@@ -36,8 +38,8 @@ function createHarness() {
},
storage: {
local: {
get: async (defaults) => ({ ...defaults }),
set: async () => {},
get: async (defaults) => ({ ...defaults, ...localData }),
set: async (updates) => Object.assign(localData, updates),
},
session: { get: async () => ({ actSessions: {} }), set: async () => {} },
sync: {
@@ -56,8 +58,13 @@ function createHarness() {
const context = {
chrome,
console,
fetch: async () => {
throw new Error("offline test");
fetch: async (url) => {
fetchUrls.push(String(url));
return {
ok: true,
status: 200,
json: async () => ({ enabled: true }),
};
},
importScripts: () => {},
setTimeout: () => 1,
@@ -79,6 +86,10 @@ function createHarness() {
storageChangedListener(changes, "sync");
},
getAlarmCreateCount: () => alarmCreateCount,
getFetchUrls: () => [...fetchUrls],
getSettings: () => context.getSettings(),
discoverServiceBases: (settings, force = false) =>
context.discoverServiceBases(settings, force),
resetAlarmCreateCount: () => {
alarmCreateCount = 0;
},
@@ -128,3 +139,18 @@ test("service discovery prefers the API paired with the open zncm tab", async ()
].filter((value, index, values) => values.indexOf(value) === index),
);
});
test("automatic service discovery checks status once per ten minutes, not on every task poll", async () => {
const harness = createHarness();
const settings = await harness.getSettings();
settings.autoDiscoverServiceUrl = true;
await harness.discoverServiceBases(settings);
assert.equal(harness.getFetchUrls().length, 1);
await harness.discoverServiceBases(await harness.getSettings());
assert.equal(harness.getFetchUrls().length, 1);
await harness.discoverServiceBases(await harness.getSettings(), true);
assert.equal(harness.getFetchUrls().length, 2);
});
+620
View File
@@ -0,0 +1,620 @@
<!DOCTYPE html>
<!--
社交信息流 · 布局样式复刻 Demo(无品牌标识版)
------------------------------------------------
- 版式/布局参考主流社交平台桌面端信息流(顶栏 + 三栏 + 信息流卡片)
- 不包含任何真实平台的商标 / Logo / 品牌元素,可放心二次修改
- 所有展示数据集中在 <script> 顶部的「数据区」,替换字段即可换数据
- 双击直接用浏览器打开,无任何外部依赖
-->
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>社交信息流 · 布局样式 Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html { color-scheme: light; }
body {
background: #f0f2f5;
color: #050505;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 15px;
}
button { font: inherit; border: 0; background: none; cursor: pointer; color: inherit; }
svg { display: block; }
/* ============ 顶栏 ============ */
.topbar {
position: fixed; inset: 0 0 auto 0; height: 56px; z-index: 100;
background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.1);
display: flex; align-items: center; justify-content: space-between;
padding: 0 16px;
}
.tb-left, .tb-right { display: flex; align-items: center; gap: 8px; min-width: 280px; }
.tb-right { justify-content: flex-end; }
.logo-mark {
width: 40px; height: 40px; border-radius: 50%;
background: #1877f2; color: #fff; display: grid; place-items: center; flex: none;
}
.search-pill {
display: flex; align-items: center; gap: 8px;
background: #f0f2f5; border-radius: 20px; padding: 9px 14px;
width: 240px; color: #65676b;
}
.tb-center { display: flex; gap: 6px; }
.tb-tab {
width: 108px; height: 56px; border-radius: 8px;
display: grid; place-items: center; color: #65676b; position: relative;
}
.tb-tab:hover { background: #f2f2f2; }
.tb-tab.active { color: #1877f2; }
.tb-tab.active::after {
content: ""; position: absolute; left: 12px; right: 12px; bottom: 0;
height: 3px; border-radius: 3px 3px 0 0; background: #1877f2;
}
.icon-btn {
width: 40px; height: 40px; border-radius: 50%;
background: #e4e6eb; display: grid; place-items: center; position: relative;
}
.icon-btn:hover { background: #d8dadf; }
.notif-badge {
position: absolute; top: -3px; right: -3px;
background: #e41e3f; color: #fff; font-size: 11px; font-weight: 600;
line-height: 1; border-radius: 10px; padding: 3px 5px; border: 2px solid #fff;
}
.create-btn {
display: flex; align-items: center; gap: 6px;
background: #e4e6eb; border-radius: 8px; padding: 10px 14px; font-weight: 600;
}
.create-btn:hover { background: #d8dadf; }
/* ============ 三栏布局 ============ */
.layout {
display: flex; justify-content: center; gap: 16px;
max-width: 1240px; margin: 0 auto; padding: 72px 12px 32px;
}
.side {
width: 300px; flex: none;
position: sticky; top: 72px; align-self: flex-start;
max-height: calc(100vh - 80px); overflow-y: auto;
}
.feed { width: 590px; flex: none; }
@media (max-width: 1160px) { .side.right { display: none; } }
@media (max-width: 980px) { .side.left { display: none; } }
/* ============ 左侧导航 ============ */
.side-item {
display: flex; align-items: center; gap: 12px;
padding: 8px 12px; margin: 0 8px; border-radius: 8px;
font-size: 15px; font-weight: 500;
}
.side-item:hover { background: #e4e6eb; }
.side-item .si-ic { width: 36px; height: 36px; flex: none; color: #3a4a5c; display: grid; place-items: center; }
.side-sep { height: 1px; background: #e4e6eb; margin: 10px 16px; }
.side-title { color: #65676b; font-size: 17px; font-weight: 600; margin: 14px 16px 4px; }
.see-more { color: #65676b; }
/* ============ 快拍 ============ */
.stories { display: flex; gap: 8px; overflow-x: auto; padding: 4px 2px 12px; }
.story {
width: 112px; height: 200px; flex: none; border-radius: 12px;
position: relative; overflow: hidden; box-shadow: 0 1px 2px rgba(0,0,0,.15);
}
.story-create {
background: #fff; display: flex; flex-direction: column; justify-content: flex-end;
}
.story-create .sc-img { height: 148px; display: grid; place-items: center; color: #a8b3c4; }
.story-create .sc-label {
padding: 10px 8px 12px; text-align: center; font-size: 13px; font-weight: 600;
border-top: 1px solid #e4e6eb;
}
.story-create .sc-plus {
position: absolute; left: 50%; top: 124px; transform: translateX(-50%);
width: 36px; height: 36px; border-radius: 50%; background: #1877f2; color: #fff;
display: grid; place-items: center; border: 3px solid #fff;
}
.story-meta { position: absolute; left: 8px; right: 8px; bottom: 8px; color: #fff; }
.story-meta .sm-name {
font-size: 13px; font-weight: 600; text-shadow: 0 1px 2px rgba(0,0,0,.6);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.story-meta .avatar { border: 2px solid #fff; margin-bottom: 6px; }
/* ============ 卡片通用 ============ */
.card {
background: #fff; border-radius: 8px;
box-shadow: 0 1px 2px rgba(0,0,0,.1); margin-bottom: 16px;
}
.avatar {
width: 40px; height: 40px; border-radius: 50%; flex: none;
display: grid; place-items: center; color: #fff; font-weight: 600; font-size: 16px;
user-select: none; background: #1877f2;
}
.avatar.sm { width: 32px; height: 32px; font-size: 13px; }
/* ============ 发帖框 ============ */
.composer-top { display: flex; align-items: center; gap: 10px; padding: 12px 16px; }
.composer-input {
flex: 1; background: #f0f2f5; border-radius: 20px; padding: 10px 16px;
color: #65676b; font-size: 16px;
}
.composer-actions { display: flex; border-top: 1px solid #e4e6eb; margin: 2px 12px 0; }
.c-action {
flex: 1; display: flex; justify-content: center; align-items: center; gap: 8px;
padding: 10px; border-radius: 6px; font-weight: 600; color: #65676b;
}
.c-action:hover { background: #f2f2f2; }
.ic-video { color: #f3425f; } .ic-photo { color: #45bd62; } .ic-emoji { color: #f7b928; }
/* ============ 帖子 ============ */
.post-head { display: flex; align-items: center; gap: 10px; padding: 12px 16px 0; }
.ph-meta { flex: 1; min-width: 0; }
.ph-name { font-weight: 600; display: flex; align-items: center; gap: 4px; }
.ph-sub { color: #65676b; font-size: 13px; display: flex; align-items: center; gap: 4px; margin-top: 2px; }
.ph-more { color: #65676b; }
.post-text { padding: 12px 16px; line-height: 1.55; white-space: pre-wrap; }
.post-media { display: grid; gap: 2px; background: #fff; }
.post-media.n1 { grid-template-columns: 1fr; }
.post-media.n2 { grid-template-columns: 1fr 1fr; }
.post-media.n3 { grid-template-columns: 2fr 1fr; }
.post-media.n4 { grid-template-columns: 1fr 1fr; }
.tile { position: relative; overflow: hidden; aspect-ratio: 1.8; background: #dfe7f5; }
.post-media.n3 .tile:first-child { grid-row: span 2; aspect-ratio: auto; }
.tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
.img-ph {
position: absolute; inset: 0; display: grid; place-items: center;
color: rgba(255,255,255,.9);
}
.tile-more {
position: absolute; inset: 0; background: rgba(0,0,0,.45); color: #fff;
display: grid; place-items: center; font-size: 26px; font-weight: 600;
}
.link-card {
margin: 0 16px 12px; border: 1px solid #e4e6eb; border-radius: 8px;
overflow: hidden; display: flex;
}
.link-img { width: 140px; flex: none; min-height: 120px; position: relative; background: #dfe7f5; }
.link-img .img-ph { color: rgba(255,255,255,.9); }
.link-body { padding: 10px 14px; min-width: 0; }
.link-domain { color: #65676b; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
.link-title { font-weight: 600; font-size: 15px; margin: 3px 0 4px; }
.link-desc { color: #65676b; font-size: 13px; line-height: 1.4; }
.stats { display: flex; align-items: center; gap: 6px; padding: 10px 16px 6px; color: #65676b; }
.stats .grow { flex: 1; }
.rx {
width: 20px; height: 20px; border-radius: 50%; background: #fff;
display: grid; place-items: center; box-shadow: 0 0 0 1px rgba(0,0,0,.05);
}
.post-actions { display: flex; border-top: 1px solid #e4e6eb; margin: 4px 12px 4px; }
.p-action {
flex: 1; display: flex; justify-content: center; align-items: center; gap: 8px;
padding: 9px; border-radius: 6px; font-weight: 600; color: #65676b;
}
.p-action:hover { background: #f2f2f2; }
.p-action.liked { color: #1877f2; }
.comments { padding: 2px 16px 12px; }
.comment { display: flex; gap: 8px; margin-top: 8px; }
.c-bubble { background: #f0f2f5; border-radius: 16px; padding: 8px 12px; min-width: 0; }
.c-name { font-weight: 600; font-size: 13px; }
.c-text { font-size: 15px; line-height: 1.45; }
.c-foot { padding-left: 40px; color: #65676b; font-size: 13px; font-weight: 600; display: flex; gap: 12px; margin-top: 2px; }
/* ============ 右侧栏 ============ */
.sponsored { display: flex; gap: 12px; padding: 8px 12px; margin: 0 8px; border-radius: 8px; }
.sponsored:hover { background: #e4e6eb; }
.sq {
width: 96px; height: 96px; flex: none; border-radius: 8px; overflow: hidden;
position: relative; background: #dfe7f5;
}
.sp-title { font-weight: 500; }
.sp-domain { color: #65676b; font-size: 13px; margin-top: 2px; }
.birthday {
display: flex; gap: 10px; padding: 4px 16px; font-size: 14px; line-height: 1.45;
}
.contact-row {
display: flex; align-items: center; gap: 10px;
padding: 6px 12px; margin: 0 8px; border-radius: 8px; font-weight: 500;
}
.contact-row:hover { background: #e4e6eb; }
.contact-av { position: relative; flex: none; }
.online-dot {
position: absolute; right: 0; bottom: 0; width: 10px; height: 10px;
border-radius: 50%; background: #31a24c; border: 2px solid #fff;
}
.contacts-head {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 16px 2px;
}
.contacts-head .ch-actions { display: flex; gap: 8px; color: #65676b; }
</style>
</head>
<body>
<!-- ================= 顶栏 ================= -->
<header class="topbar">
<div class="tb-left">
<div class="logo-mark" title="示例 Logo(替换成你们自己的标识)">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 11.5a8.5 8.5 0 0 1-8.5 8.5c-1.5 0-2.9-.4-4.1-1L3 20l1-5.4A8.5 8.5 0 1 1 21 11.5z"/>
</svg>
</div>
<div class="search-pill">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><line x1="16.5" y1="16.5" x2="21" y2="21"/></svg>
搜索
</div>
</div>
<nav class="tb-center">
<button class="tb-tab active" title="首页">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/></svg>
</button>
<button class="tb-tab" title="视频">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="6" width="13" height="12" rx="2.5"/><path d="M15.5 10.5 21.5 7v10l-6-3.5"/></svg>
</button>
<button class="tb-tab" title="市场">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9.5 5.2 4h13.6L20 9.5"/><path d="M4 9.5V20h16V9.5"/><path d="M10 20v-5.5h4V20"/></svg>
</button>
<button class="tb-tab" title="群组">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8.5" r="3"/><path d="M3 20c0-3.2 2.7-5 6-5s6 1.8 6 5"/><circle cx="17" cy="9.5" r="2.3"/><path d="M16 15.3c2.9.2 5 1.8 5 4.7"/></svg>
</button>
<button class="tb-tab" title="游戏">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="7.5" width="19" height="10" rx="4"/><path d="M7 10.5v4M5 12.5h4"/><circle cx="16" cy="11" r=".6" fill="currentColor"/><circle cx="18.5" cy="13.5" r=".6" fill="currentColor"/></svg>
</button>
</nav>
<div class="tb-right">
<button class="create-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
创建
</button>
<button class="icon-btn" title="菜单">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="5" r="1.7"/><circle cx="12" cy="5" r="1.7"/><circle cx="19" cy="5" r="1.7"/><circle cx="5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="19" cy="12" r="1.7"/><circle cx="5" cy="19" r="1.7"/><circle cx="12" cy="19" r="1.7"/><circle cx="19" cy="19" r="1.7"/></svg>
</button>
<button class="icon-btn" title="消息">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.5 8.5 0 0 1-8.5 8.5c-1.5 0-2.9-.4-4.1-1L3 20l1-5.4A8.5 8.5 0 1 1 21 11.5z"/><path d="M12.8 8 9.3 13h2.6l-.6 3.3 3.5-5.1h-2.6z"/></svg>
</button>
<button class="icon-btn" title="通知">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M18 16v-5a6 6 0 1 0-12 0v5l-1.6 1.8h15.2z"/><path d="M10 20.2a2.1 2.1 0 0 0 4 0"/></svg>
<span class="notif-badge">3</span>
</button>
<div class="avatar" style="background:#45bd62">我</div>
</div>
</header>
<!-- ================= 三栏 ================= -->
<div class="layout">
<!-- ---- 左侧导航 ---- -->
<aside class="side left">
<div class="side-item">
<div class="si-ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8.5" r="3"/><path d="M3 20c0-3.2 2.7-5 6-5s6 1.8 6 5"/><circle cx="17" cy="9.5" r="2.3"/><path d="M16 15.3c2.9.2 5 1.8 5 4.7"/></svg></div>
好友
</div>
<div class="side-item">
<div class="si-ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7.5V12l3 2"/></svg></div>
回忆动态
</div>
<div class="side-item">
<div class="si-ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5.5A2.5 2.5 0 0 1 7.5 3h9a2.5 2.5 0 0 1 2.5 2.5z"/></svg></div>
已保存
</div>
<div class="side-item">
<div class="si-ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8.5" r="3"/><path d="M3 20c0-3.2 2.7-5 6-5s6 1.8 6 5"/><circle cx="17" cy="9.5" r="2.3"/><path d="M16 15.3c2.9.2 5 1.8 5 4.7"/></svg></div>
群组
</div>
<div class="side-item">
<div class="si-ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="6" width="13" height="12" rx="2.5"/><path d="M15.5 10.5 21.5 7v10l-6-3.5"/></svg></div>
视频
</div>
<div class="side-item">
<div class="si-ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9.5 5.2 4h13.6L20 9.5"/><path d="M4 9.5V20h16V9.5"/><path d="M10 20v-5.5h4V20"/></svg></div>
市场
</div>
<div class="side-item">
<div class="si-ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M16 3v4M8 3v4M3 11h18"/></svg></div>
活动
</div>
<div class="side-sep"></div>
<div class="side-title">你的捷径</div>
<div class="side-item"><div class="avatar sm" style="background:#7c5cff">登</div>周末登山小队</div>
<div class="side-item"><div class="avatar sm" style="background:#12b5cb">摄</div>城市摄影交流</div>
<div class="side-item"><div class="avatar sm" style="background:#f3425f">书</div>读书会</div>
<div class="side-item see-more">
<div class="si-ic"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg></div>
查看更多
</div>
</aside>
<!-- ---- 中间信息流 ---- -->
<main class="feed">
<!-- 快拍 -->
<div class="stories" id="stories"></div>
<!-- 发帖框 -->
<div class="card">
<div class="composer-top">
<div class="avatar" style="background:#45bd62">我</div>
<div class="composer-input">有什么新鲜事?</div>
</div>
<div class="composer-actions">
<button class="c-action ic-video">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="6" width="13" height="12" rx="2.5"/><path d="M15.5 10.5 21.5 7v10l-6-3.5"/></svg>
直播视频
</button>
<button class="c-action ic-photo">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 15.5l-5-5-9 8"/></svg>
照片/视频
</button>
<button class="c-action ic-emoji">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M8.5 14.5s1.3 1.8 3.5 1.8 3.5-1.8 3.5-1.8"/><circle cx="9" cy="9.5" r=".6" fill="currentColor"/><circle cx="15" cy="9.5" r=".6" fill="currentColor"/></svg>
感受/活动
</button>
</div>
</div>
<!-- 帖子(由下方数据区渲染) -->
<div id="posts"></div>
</main>
<!-- ---- 右侧栏 ---- -->
<aside class="side right">
<div class="side-title" style="margin-top:4px">赞助内容</div>
<div id="sponsored"></div>
<div class="side-sep"></div>
<div class="birthday">
<span style="font-size:24px">🎂</span>
<div><b>小林</b> 和 <b>赵敏</b> 今天过生日</div>
</div>
<div class="side-sep"></div>
<div class="contacts-head">
<span style="color:#65676b;font-weight:600">联络人</span>
<span class="ch-actions">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><line x1="16.5" y1="16.5" x2="21" y2="21"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="6" width="13" height="12" rx="2.5"/><path d="M15.5 10.5 21.5 7v10l-6-3.5"/></svg>
</span>
</div>
<div id="contacts"></div>
</aside>
</div>
<script>
/* ============================================================
数据区 —— 换数据只改这里
------------------------------------------------------------
FEED 帖子字段:
name 作者名 verified 是否显示认证标
time 发布时间文案 audience "公开" / "好友"
text 正文(\n 换行)
images 配图数组:"" = 灰色占位图;填图片 URL 则显示真实图片
数量 1-4 张,布局自动变化(1 大图 / 2 / 3 / 4 宫格)
link 可选链接卡片 { domain, title, desc }
likes 点赞数 shares 分享数
comments 评论数组 [{ name, text, time, likes }]
============================================================ */
const FEED = [
{
name: "张伟", time: "5 小时", audience: "公开",
text: "连续加班两周终于把项目收尾了,今天准点下班,\n路上顺手拍了一张晚霞。生活还是需要一点仪式感 🙂",
images: [""],
likes: 128, shares: 6,
comments: [
{ name: "赵敏", text: "恭喜收尾!这张晚霞拍得真好", time: "4 小时", likes: 12 },
{ name: "孙鹏", text: "下次组个饭局庆祝一下", time: "3 小时", likes: 3 },
],
},
{
name: "城市天气指南", verified: true, time: "3 小时", audience: "公开",
text: "本周天气提示:周三起冷空气过境,气温下降 6–8℃,早晚温差加大,提醒大家适当添衣。详细预报见评论区链接。",
images: ["", "", ""],
likes: 402, shares: 36,
comments: [
{ name: "吴倩", text: "收到,已经把薄羽绒翻出来了", time: "2 小时", likes: 8 },
],
},
{
name: "王强", time: "9 小时", audience: "好友",
text: "求推荐一条周末适合新手的徒步路线,最好单程 10 公里以内、交通方便的。打算这周就走,谢谢大家!",
images: [],
likes: 17, shares: 0,
comments: [
{ name: "陈晨", text: "西郊森林公园的环线不错,全程约 8 公里,坡度平缓", time: "8 小时", likes: 5 },
{ name: "周婷", text: "记得带够水,最近天气比较干", time: "7 小时", likes: 2 },
],
},
{
name: "陈晨", time: "1 天", audience: "公开",
text: "整理了一篇新手徒步装备清单,有需要的朋友自取~",
images: [],
link: { domain: "example-outdoor.com", title: "2026 秋季徒步装备清单(新手向)", desc: "从鞋服到导航设备,一份可以照着买的入门清单。" },
likes: 54, shares: 9,
comments: [],
},
];
const STORIES = ["赵敏", "孙鹏", "周婷", "吴倩", "林芳"];
const SPONSORED = [
{ title: "云端笔记 Pro", domain: "cloudnote.example.com" },
{ title: "户外装备精选", domain: "outdoor.example.com" },
];
const CONTACTS = ["刘洋", "赵敏", "孙鹏", "周婷", "吴倩", "郑凯", "林芳"];
/* ============================================================
以下为渲染逻辑,一般不用动
============================================================ */
const AV_COLORS = ["#1877f2", "#f3425f", "#45bd62", "#f7b928", "#7c5cff", "#12b5cb"];
const PH_GRADIENTS = [
"linear-gradient(135deg,#a8c5f0,#dfe7f5)",
"linear-gradient(135deg,#f8d3c5,#f7ebe4)",
"linear-gradient(135deg,#c9e5d3,#eaf5ee)",
"linear-gradient(135deg,#e2d8f7,#f0eaf9)",
"linear-gradient(135deg,#f6e3b0,#faf2df)",
];
const STORY_GRADIENTS = [
"linear-gradient(165deg,#5b8def,#8f6ee8)",
"linear-gradient(165deg,#f5a623,#ec5e8f)",
"linear-gradient(165deg,#2ec3a6,#3f8ee8)",
"linear-gradient(165deg,#8f6ee8,#5b8def)",
"linear-gradient(165deg,#ec5e8f,#f5a623)",
];
const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const colorOf = name => AV_COLORS[[...name].reduce((a, c) => a + c.charCodeAt(0), 0) % AV_COLORS.length];
const avatarHtml = (name, cls = "") =>
`<div class="avatar ${cls}" style="background:${colorOf(name)}">${esc(name.slice(0, 1))}</div>`;
const ICONS = {
like: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.3a2 2 0 0 0 2-1.7l1.4-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>',
likeFilled: '<svg width="14" height="14" viewBox="0 0 24 24" fill="#1877f2" stroke="#1877f2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.3a2 2 0 0 0 2-1.7l1.4-9a2 2 0 0 0-2-2.3z"/><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>',
heart: '<svg width="14" height="14" viewBox="0 0 24 24" fill="#f3425f"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21.2l7.8-7.8 1-1a5.5 5.5 0 0 0 0-7.8z"/></svg>',
comment: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>',
share: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98"/></svg>',
globe: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a13.5 13.5 0 0 1 0 18 13.5 13.5 0 0 1 0-18z"/></svg>',
friends: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="9" cy="8.5" r="3"/><path d="M3 20c0-3.2 2.7-5 6-5s6 1.8 6 5"/><circle cx="17" cy="9.5" r="2.3"/></svg>',
dots: '<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>',
mountain: '<svg width="46" height="46" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 15.5l-5-5-9 8"/></svg>',
link: '<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></svg>',
check: '<svg width="14" height="14" viewBox="0 0 24 24" fill="#1877f2"><circle cx="12" cy="12" r="10"/><path d="M17.5 9.5 10.7 16 7 12.3l1.4-1.4 2.3 2.3 5.4-5.2z" fill="#fff"/></svg>',
plus: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
};
const audienceIcon = a => (a === "好友" ? ICONS.friends : ICONS.globe);
const isImg = v => /^(https?:|data:|\.?\/)/.test(v);
function tileHtml(v, i) {
const inner = isImg(v)
? `<img src="${esc(v)}" alt="">`
: `<div class="img-ph" style="background:${PH_GRADIENTS[i % PH_GRADIENTS.length]}">${ICONS.mountain}</div>`;
return `<div class="tile">${inner}</div>`;
}
function mediaHtml(images) {
if (!images || !images.length) return "";
const shown = images.slice(0, 4);
const cls = `n${Math.min(shown.length, 4)}`;
let html = shown.map((v, i) => tileHtml(v, i)).join("");
if (images.length > 4) {
html = html.replace(/<div class="tile">[\s\S]*<\/div>$/, m =>
m + `<div class="tile-more">+${images.length - 3}</div>`);
}
return `<div class="post-media ${cls}">${html}</div>`;
}
function linkCardHtml(link) {
if (!link) return "";
return `
<div class="link-card">
<div class="link-img"><div class="img-ph" style="background:linear-gradient(135deg,#a8c5f0,#dfe7f5)">${ICONS.link}</div></div>
<div class="link-body">
<div class="link-domain">${esc(link.domain)}</div>
<div class="link-title">${esc(link.title)}</div>
<div class="link-desc">${esc(link.desc || "")}</div>
</div>
</div>`;
}
function commentHtml(c) {
return `
<div class="comment">
${avatarHtml(c.name, "sm")}
<div style="min-width:0">
<div class="c-bubble">
<div class="c-name">${esc(c.name)}</div>
<div class="c-text">${esc(c.text)}</div>
</div>
<div class="c-foot">
<span>赞 ${c.likes}</span><span>回复</span><span>${esc(c.time)}</span>
</div>
</div>
</div>`;
}
function postHtml(p) {
const comments = (p.comments || []).map(commentHtml).join("");
const commentCount = p.comments.length;
return `
<article class="card post">
<div class="post-head">
${avatarHtml(p.name)}
<div class="ph-meta">
<div class="ph-name">${esc(p.name)}${p.verified ? ICONS.check : ""}</div>
<div class="ph-sub">${audienceIcon(p.audience)} ${esc(p.time)}</div>
</div>
<button class="ph-more">${ICONS.dots}</button>
</div>
${p.text ? `<div class="post-text">${esc(p.text)}</div>` : ""}
${mediaHtml(p.images)}
${linkCardHtml(p.link)}
<div class="stats">
<span class="rx">${ICONS.likeFilled}</span><span class="rx">${ICONS.heart}</span>
<span class="like-count" data-base="${p.likes}">${p.likes}</span>
<span class="grow"></span>
${commentCount ? `<span>${commentCount} 条评论</span>` : ""}
${p.shares ? `<span>· ${p.shares} 次分享</span>` : ""}
</div>
<div class="post-actions">
<button class="p-action act-like">${ICONS.like}<span>赞</span></button>
<button class="p-action">${ICONS.comment}<span>评论</span></button>
<button class="p-action">${ICONS.share}<span>分享</span></button>
</div>
${comments ? `<div class="comments">${comments}</div>` : ""}
</article>`;
}
function storiesHtml() {
const items = STORIES.map((n, i) => `
<div class="story" style="background:${STORY_GRADIENTS[i % STORY_GRADIENTS.length]}">
<div class="story-meta">
<div class="avatar sm" style="background:${colorOf(n)}">${esc(n.slice(0, 1))}</div>
<div class="sm-name">${esc(n)}</div>
</div>
</div>`).join("");
return `
<div class="story story-create">
<div class="sc-img">${ICONS.mountain}</div>
<div class="sc-plus">${ICONS.plus}</div>
<div class="sc-label">创建快拍</div>
</div>${items}`;
}
function render() {
document.getElementById("posts").innerHTML = FEED.map(postHtml).join("");
document.getElementById("stories").innerHTML = storiesHtml();
document.getElementById("sponsored").innerHTML = SPONSORED.map(s => `
<div class="sponsored">
<div class="sq"><div class="img-ph" style="background:linear-gradient(135deg,#c9d8ef,#e9eff8)">${ICONS.mountain}</div></div>
<div style="min-width:0">
<div class="sp-title">${esc(s.title)}</div>
<div class="sp-domain">${esc(s.domain)}</div>
</div>
</div>`).join("");
document.getElementById("contacts").innerHTML = CONTACTS.map(n => `
<div class="contact-row">
<div class="contact-av">${avatarHtml(n, "sm")}<span class="online-dot"></span></div>
${esc(n)}
</div>`).join("");
}
/* 点赞按钮可点击切换(纯前端演示) */
document.addEventListener("click", e => {
const btn = e.target.closest(".act-like");
if (!btn) return;
const count = btn.closest(".post").querySelector(".like-count");
const base = Number(count.dataset.base);
const liked = btn.classList.toggle("liked");
count.textContent = base + (liked ? 1 : 0);
});
render();
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+76
View File
@@ -0,0 +1,76 @@
# 单机动态在线 Worker
## 范围与入口
管理员「设置 → 执行节点管理」配置 SSH 主机/IP、端口、账号、密码、SSH 主机指纹、源容器、Docker 网络、镜像内 Python/后端目录、节点数量、并发、队列和资源预算。也支持已有 Docker socket 挂载的本机连接,无需填写 SSH 密码。
本实现将 `/api/threads/{id}/runs`、`runs/stream`、`runs/wait` 的普通/智能体问答分发到固定会话所属的在线 Worker。分配后,该会话的状态、历史、停止、重连等 `/api/threads/{id}/…` 请求也转发到同一节点。鉴权及原有任务共享、用户隔离检查仍然执行。搜索、计数和未启用时沿用主服务。
定时任务、任务分析、研究、会商、工作流以及系统内部会话仍由唯一主服务执行;不在在线 Worker 启动它们的扫描、恢复或调度循环。无状态 `/api/runs/*`、专用写作接口等未纳入在线池。后台任务池尚未实现,不应将此版本描述为全业务分布式任务队列。
## 存储与并发
- 业务库与聊天存储独立:保留 MySQL/PostgreSQL 业务库,聊天存储支持 PostgreSQL 或 SQLite。SQLite 必须使用同机共享持久目录(不能单文件挂载或网络文件系统),开启 WAL、30 秒锁等待及跨进程初始化锁;内存存储拒绝开启。
- Worker 的运行事件使用共享业务数据库;聊天检查点沿用已有 PG/SQLite 配置,不迁移聊天记录。
- 控制器私有目录保存加密 SSH 凭证、加密密钥、实例标识和 SQLite 会话归属表。该表只保存路由,不存聊天正文。目录必须独立持久化,不能挂载给 Worker。实例标识和归属表不能单独删除或在旧容器运行时重建。
- Worker 池默认关闭。关闭时不启动控制器轮询、不申请单实例锁、不连接 Docker/SSH,也不创建亲和性或诊断数据库;原部署方式与多进程服务按原样运行。只有管理员启用 Worker 池后,控制器才申请单实例锁并开始管理容器;启用期间主服务需使用单个 Uvicorn 进程。同机同套部署的启用中主服务必须共用控制器卷。
- 执行额度绑定真实运行 Task 的生命周期;HTTP 请求结束或后台返回不提前释放额度。排队有数量和时间上限;超限返回 429/Retry-After。
- 每节点业务库连接池不允许额外溢出;检查点和 LangGraph Store 各有单独的 PG 连接池。系统按 `节点数 ×(业务库池大小 + 2 × PG 池大小)` 校验 Worker 连接预算。该预算须预先扣除主服务及其他应用的连接额度。
- Worker 在新运行开始前检查共享技能扩展配置,启停变更会刷新本进程缓存;技能文件变更按最多五秒的检查间隔刷新目录提示。CPU、模型配置等需要重启的配置仍应先排空 Worker 后更新。
- 会话归属在扩容后保持。缩容节点停止接收新运行,等待执行、排队和代理请求结束后停止,再释放归属。不自动重放失败请求。节点失联时返回 503;可确认进程停止后才允许后续重新分配。
- 重启后的旧运行读取为中断错误,不自动重试带外部副作用的工具。系统保留存储中已完成的检查点,不能承诺从任意工具执行位置无损恢复。
## 部署要求
发布仓库 `deerflow-offline-deployment` 已为标准模板增加控制器独立卷和 SSH/加密依赖清单。必须按该仓库既有流程构建包含全部依赖的离线包并部署新前后端源码。
1. Worker 池关闭时保留原部署进程数即可;启用 Worker 池后主容器使用单个 Uvicorn 进程。固定 `AUTH_JWT_SECRET` 必须在容器环境变量中。
2. 使用用户自定义 Docker 网络,主容器和新 Worker 均在该网络。数据库地址不能指向容器自身的 `localhost`。
3. 挂载全部运行目录、上传文件、技能、配置及必要的源码覆盖目录。Worker 复用其挂载,保留 ExtraHosts/DNS;不继承宿主机端口、Docker socket或特权模式。
4. `DEER_FLOW_WORKER_CONTROL_HOME` 指向独立持久卷,如 `/var/lib/deerflow-worker-control`。该卷会被自动排除,不传给 Worker;控制目录嵌在其他共享卷内时拒绝创建。
5. 当前支持不依赖 Docker socket 的执行沙箱。若业务配置需要 Worker 自行创建 Docker 沙箱,须先接入独立受控沙箱服务,不得直接复制 socket 权限。
6. 设置中默认保持关闭;填写连接参数和测试连接不会启动容器。管理员明确开启后才创建 Worker。标准离线镜像 Python 为 `/opt/venv/bin/python`,工作目录为 `/app/backend`;其他镜像按实际路径填写。
7. SSH 主机指纹可在页面读取并由管理员确认;账号需具备运行 Docker 的权限。不接受未知指纹,也不自动安装服务器软件。
容器使用源容器的精确镜像 ID,执行源码覆盖挂载与主容器相同。升级前数量设为 0,等待节点退出后升级主服务,再恢复数量;不会自动替换仍在执行的旧容器。
## 验证与容量
`tests/test_worker_pool.py` 包含凭证脱敏、模板限制、排队取消/超时、后台任务额度、归属持久化、失联不重放、缩容等待、双主进程拒绝、管理员授权,以及 70 个 HTTP 客户端分配到四个模拟 Worker 并返回 SSE 的回归。
这些是功能与模拟并发测试,不等同于真实模型、PostgreSQL、Docker 和服务器硬件的 70 人容量验收。部署后应在测试环境同时运行普通问答、文件工具和定时任务,观察首字延迟、完成时间、数据库连接数、模型限流和资源峰值;执行中的关闭浏览器、停止生成、节点重启、缩容都要验证。每节点并发数乘以节点数只是顶层运行上限,工具/子智能体仍可能产生额外模型调用,应配置模型服务总额度。
回退:将期望数量改为 0,等待全部节点消失后关闭池。主服务重新处理普通问答,聊天记录和运行目录继续保留。节点不能正常退出时不要直接删除控制器卷或归属表。
## 本次开发验证(2026-10-23)
- Worker 专项、账户鉴权、运行记录回收、执行线程池、完成超时和运行争用相关回归合计 **136 项通过**。
- 70 个模拟 HTTP 客户端进入四个模拟执行节点,分配为 18 / 18 / 17 / 17,全部收到 SSE 结束事件;另测执行额度、超时、取消及后台运行不提前释放额度。这不包含真实模型耗时。
- Python 静态检查、编译检查、改动空白检查通过;Linux 离线发布配置生成/校验通过。
- 新增前端页面及设置入口语法检查通过。全量 TypeScript 检查仍有其他模块错误,不能将前端全量检查标记为通过。
- 119 服务器检测为 32 核 CPU、128806 MB 内存;8008 实例已启动 4 个实际 Docker Worker,每节点 2 CPU / 2048 MB / 4 个同时执行额度;业务库保持 MySQL,既有聊天 SQLite 配置不变。
- 实际模型短问答:同一管理员下 70 个独立会话同时发起,70/70 完成,流式阶段约 59.59 秒;随后 70/70 历史记录读取确认回答已保存。这是短问答突发负载,不代表 70 个不同用户的复杂工具任务容量。
- Linux 上分别用 SQLite 和 PostgreSQL 执行 4 个进程各 25 次检查点/写入/Store 读写验证,各 100 次成功;PG 使用独立验收实例,未迁移业务库。
- 4→0→4 缩扩容与主服务重启后发现原有 4 个节点已验证(未重启整台服务器)。Worker 不启动定时任务调度器。
- 新增服务器探测和资源推荐:页面先读取 SSH 指纹,管理员确认后才发送密码登录;检测 Docker CPU/内存总量和其他容器资源上限,保留至少半台服务器资源,建议最多 4 个起步节点。不是实时空闲内存测量。
- 密码经 Fernet 加密保存在控制器持久卷;页面/API 不返回密码。更换服务器身份后必须重新填写密码,不能复用原服务器密码。配置和密钥卷需一起保留。
- Worker/聊天存储/跨线程数据库/资源推荐专项回归 37 项通过。
### 管理页面验收补充
2026-10-23 23:06 在 119:8008 实际页面完成保存并应用、测试已保存连接、读取服务器资源,三个请求均成功。截取资源推荐与四个就绪节点页面,保存在本次 `.runtime/worker-deploy-119/` 验收目录。前端生产构建成功。
当前运行池使用本机 Docker Socket。SSH 指纹读取已在服务器内网地址实测;SSH 账号密码认证尚需管理员在页面填写实际凭证验证。密码密文持久化、读取不回显、更换主机不复用密码、错误指纹拒绝连接均有测试覆盖。重启验证指主服务重启,未重启宿主机。
## 服务器诊断与并发验收补充(2026-10-24)
管理员诊断页 `/page/workspace/admin/server-diagnostics` 提供按开关采集的宿主机资源、MySQL/PG 连接、HTTP/SSE 分段耗时、主容器和 Worker 状态、日志读取与归档。主后端挂载 Docker Socket 和只读 `/proc`、`/sys`,Worker 不继承这些挂载。主服务日志轮转为 20 MB × 5,Worker 为 10 MB × 3;节点删除前保存有界日志归档。采样和请求摘要保存在控制器私有卷,接口不记录聊天正文、查询参数或鉴权头。
修复 Worker 执行队列阻塞流首次连接:先注册真实运行、建立流,再等待执行额度;保留额度上限和取消、超时语义。新线程默认只写元数据,首次运行再初始化检查点,保留显式初始化兼容参数;历史查询缩短 SQLite 游标持有时间。
本轮相关回归 133 项通过。119 实际 2 节点、4 节点各执行 70 路冷启动和 70 路第二轮,共 280/280 成功,两组各 70 个会话历史和状态回读通过。4 节点冷/热整轮耗时 68.851/46.287 秒,响应头 P95 为 18.141/11.548 秒;2 节点冷/热整轮耗时 115.026/74.151 秒。此为同一管理员的独立客户端短问答,不代表复杂工具容量。验收后恢复原先保存的 2 个节点。
页面实际验证了采集开关、资源和数据库卡片、主服务/Worker/归档日志,浏览器无脚本错误。119 当时 CPU 100%,独立采样 steal 约 24.6%,内存和 MySQL 连接仍有余量;需同时排查宿主机 CPU 争抢。PG/SQLite 兼容性在独立实例验证,业务 MySQL 和原聊天配置不变。
Linux AMD64/Python 3.12 增量交付包和部署说明保存在离线发版仓库;文档 `deploy/linux-docker-offline-deerflow-only/docs/14-Worker池与服务器诊断更新部署.md` 包含 Docker Socket 挂载、更新排空、依赖、资源建议和回退。交付源代码基于本次 119 验收版本,配置与数据不打入包。
@@ -0,0 +1,105 @@
# 本地问答、资料库与技能并发验收
测试日期:2026-09-28 至 2026-09-29 凌晨。环境:Windows、约 8GB 物理内存、Python 3.12、Node 22、Gateway 单进程、SQLite、默认 MiniMax-M2.5。前端 Vite 5174,Gateway 8001。模型并发限流与 Redis 分布式门控未启用。
本次保留并验收了工作区原有的资料库索引、问答收尾、沙箱超时和流式恢复修改,另外修复了压测复现的技能刷新重复校验,以及真实地图问答暴露的内部待办提醒显示问题。测试结果不能等同于 Linux 多进程生产环境容量。
## 版本范围
开始测试时本地为 `3a07c476`,没有 `cognitive-situation-map`。随后拉取远程,发现 `origin/master` 已前进至 `3107f2cc`,包含该技能的初始化及 v2.6.0 精简运行时。保留本地修改后合入远程,合并提交为 `c9effe83`;最终绘图与多用户测试以合入后的代码为准。
旧本地版本里 `page-report-generation` 的地图仅支持中、美、日、台,因此其“不支持菲律宾”的反馈不能用于评价最新的 `cognitive-situation-map`。
## 真实问答
使用独立普通测试账户,阶梯间顺序运行,逐条检查预期测试关键词与结果、SSE `end`、持久化 `success`,结束后删除本轮生成的测试线程。1/3/5/10 路检查正文包含“测试”和数字 4;20 路增强为检查“测试正常”及明确的“2加2等于4”。这属于简单问答验收,不是复杂内容质量评测。
| 同时问答用户数 | 成功 | 首段答案中位耗时 | 流结束中位耗时 | 流结束最慢 |
|---|---:|---:|---:|---:|
| 1 | 1/1 | 5.792s | 6.439s | 6.439s |
| 3 | 3/3 | 2.214s | 3.245s | 3.287s |
| 5 | 5/5 | 2.885s | 3.702s | 4.373s |
| 10 | 10/10 | 2.920s | 4.796s | 5.416s |
| 20 | 20/20 | 4.364s | 10.622s | 11.135s |
累计 39/39 通过。20 用户整批耗时 11.829s,同期 health 12/12 成功、中位 13ms、最大 371ms。这里是一次阶梯短时突发,并未证明百用户持续长任务容量。
取消接口返回 204,58ms 后状态为 `interrupted`,测试线程删除返回 200。同期健康探测 20 次均成功,中位 9ms,最大 3.044s;说明仍存在数秒调度尖峰,不能仅凭平均值宣布无阻塞。
早期多个磁盘基准同时运行时,单路首段答案为 33.853s,日志显示同步 agent 构建 28.344s,health 也有 5s 超时。这批明确有本机资源争用,不纳入稳定容量结论。开发脚本启动的服务进程还曾消失,没有拿到退出或 reload 日志,原因未确认;期间的连接失败剔除。后续使用无热重载的固定进程复测。
## 资料库和知识库
- 资料库/知识库相关回归 186 项通过;资料库新存储 Memory/SQLite 专项另有 8 项通过,覆盖用户隔离、分页、原子换代、并发租约和旧任务围栏。
- 真实测试账户看到 2 个共享知识库;选中的库包含 668 份文档、168 个 Wiki 页面。首次 hybrid 查询 3.58s,返回 5 条。
- 排除合成磁盘负载后,空资料库、知识库目录和 hybrid 查询分别测试 1/4/8 并发,每档 8 次,总计 72/72 HTTP 200,零异常。
| 接口 | 8 并发中位耗时 | 8 并发最大耗时 |
|---|---:|---:|
| 资料库列表(空测试账户) | 165ms | 224ms |
| 知识库目录 | 517ms | 557ms |
| hybrid 检索(相同问题、5条结果) | 426ms | 441ms |
这组重复使用同账号和查询,包含热缓存效果;空账户列表不代表有大量文件的资料库。独立测试 SQLite 另生成了 101 个合成用户、10 万条资料元数据,480 次查询全部成功;其中单用户 5 万条资料关键词查询 P95 为 1.08s、首页 P95 为 130ms,但此轮同时有其他 IO 测试,只作为规模观察值。
资料库索引按 5 分钟过期刷新;进程异常退出时正在同步的租约最多等待 30 分钟恢复。大库关键词查询仍使用多字段包含匹配,不能据当前测试证明生产规模检索始终快速。
## 大技能与并发刷新
文件体积大和小文件数量多分别测试,使用隔离临时目录,不改真实技能内容。
| 场景 | 结果 |
|---|---|
| 真实技能副本,269 文件 / 2.75MiB,稳定缓存 64 并发 | 全成功,约 1.05s;早期混合负载观察值 |
| 同副本改 1 个资产,32 并发,修复前混合负载 | 27 成功、5 个 30s 锁等待超时,复现问题 |
| 独占配对:同副本改 1 个资产,32 并发,旧逻辑 | 32/32 成功,P95 13.700s,最大 14.842s |
| 独占配对:同场景,新逻辑 | 32/32 成功,P95 / 最大 3.963s,P95 降低约 71% |
| 128MiB 资产、34 文件,新逻辑 | 冷初始化 5.54s;变更后 32 并发 P95 0.584s,全成功 |
| 约 160 技能、5120 个小文件(修复前规模观察) | 冷加载执行者约 107s,31/32 等待者超时;热校验约 18s |
修复让同一进程内、相同权限配置的一批重叠请求共享一次刷新及最终校验。等待者仍检查当前缓存身份、权限哈希和能力标记;失败或技能下线时不返回旧视图。新增 8 项回归连同原有 62 项,共 70 项通过。
**剩余风险:** 数千个小文件的单次 Windows 元数据扫描仍昂贵,本次修复没有消除这项成本,也未证明跨进程刷新能合并。不能把 32/64 个技能调用线程等同于 32/64 个真实模型用户。
实际调用 `gongwen-assistant` 读取技能并输出短通知,10.672s 内正常结束,无工具卡住。但答案把要求的 10月8日改成10月15日:通信/技能读取通过,内容日期遵循失败,不能记为整体语义验收通过。
## 绘图与界面
普通问答已实际生成菲律宾地图底图及马尼拉、吕宋北部、巴拉望、棉兰老四个点位,点位弹窗可打开。使用明确标注的虚构数据,未进行时事真实性评估。初次模型额外要求确认消息内地图,回复确认后完成,属于可见的额外交互成本。
这次真实对话发现 `todo_completion_reminder` 被当作用户问题展示。修复后,后端待办提醒带 `hide_from_ui`,前端也按两个历史内部名字过滤聊天和问题导航;真实用户引用 `<system_reminder>` 的文本仍保留。后端 3 项、前端新及关联分组 12 项通过。
最新 `cognitive-situation-map v2.6.0` 已经通过真实问答完整调用:读取技能和菲律宾参考 JSON、写入场景、调用技能自带 `node render.mjs`、检查文件、`present_files` 交付,42.481s 完成,SSE 正常结束并保存 `success`。产物标题为“菲律宾冲突认知态势图(功能验收示意)”,包含 4 个事件、4 个标注、3 个行为体和 2 条连线,明确标注所有事件与等级均为测试数据。输出 `status=ok`、无恢复和警告。模型曾发起两次过宽的目录 glob,被技能访问隔离拒绝后自行改用指定技能目录完成,没有循环卡住。
同一最新技能的直接渲染为 624ms;8 个并发独立渲染进程全部成功,范围 650–949ms。围栏/截断/缺失字段/约 600KiB 场景输入的恢复、非空目录拒绝覆盖、脚本结束标签转义均完成专项检查。真实 Windows LocalSandbox 虚拟路径经 PowerShell 调用 Node 成功(含冷 shell 7.85s)。CSS、JS、菲律宾底图和旗帜资源均返回 200;2.93MB 底图一次请求耗时 208ms。8 个渲染进程不等于 8 个同时生成长内容的模型用户。
**最新 HTML 的视觉验收未通过:** 测试账户能在会话中看到生成文件卡片,文件接口返回 200,源码模式能显示 HTML、正确资源地址和页面结构,但 Codex 内嵌浏览器的预览面板空白;DOM 中 iframe 的 `src` 为 `blob:http://127.0.0.1:5174/...`,可访问树仍为 `about:blank`。普通地图组件的可见性不能替代这个 HTML 的验收,渲染器返回成功也不能证明最终视觉正确。已保留空白预览截图,未将此项计为通过。
进一步在内存中执行真实 `prepareGeneratedHtml`:11,269 字节输入变为 14,597 字节,原有 3 个脚本逐字保留、DOM id 与外链 URL 均不变,新增兼容脚本语法有效。暂未发现生成器或预处理破坏 HTML 的证据;故障停在预览加载/Blob 导航阶段,具体原因未确认,不将其归为已知浏览器限制,也不宣称已修复。
界面还复现了 TDesign 新手引导闲置时的 `reading 'element'` 异常:`current=-1` 时其 resize 监听仍读取不存在的步骤。简洁模式与智能体两处包装组件已改为只有有效步骤才挂载 Guide。浏览器复测了文件面板开关、侧栏切换、手动启动、下一步、跳过,未再出现新的同类异常;完成/跳过的持久化语义保持不变。
## 合并后回归
- 完整 harness:274/274 通过,53.07s;包含前述技能及 Todo 子集,不重复累加。
- 模型并发配置专项:10 项通过;种子技能资源:4 项通过。
- 前端流式恢复专项:9 组共 44 项通过;待办可见性及相关消息分组:12 项通过。
- 全量前端构建和类型检查最初并行运行时,系统空闲内存降到约 17MB,因此主动停止该轮类型检查,改为串行执行;该轮中断不作为代码失败。
- `pnpm build` 通过(10m25s,包含前期资源争用,有大 chunk 警告)。其后新增的两个 Guide 挂载条件已通过单独语法检查和真实浏览器回归;另补齐问题导航抽取后的类型导入,不改变生成代码行为。
- 最终 `pnpm typecheck` 仍未通过,共 73 项原有诊断;与测试前本地 `hang-typecheck.log` 的 75 项逐条比较,减少了消息分组测试的 2 项,没有新增诊断。本轮发现的 `QuestionItem` 类型导入遗漏已经修正,复跑后消失。剩余问题包括 `nextra` 依赖、旧 JSX 类型和既有组件/测试类型,未扩大范围处理,不能标记全量类型检查通过。
## 本地证据和复测入口
详细证据仅保留在 `.runtime/test-results/20260928-local-qa/`,不提交模型凭证、问答日志、测试数据库、构建 ZIP 或技能临时大目录:
- `qa-concurrency-results.json`:逐轮时间、结束事件、答案检查和清理结果。
- `qa-concurrency-20-results.json`、`harness-full.xml`:20 个独立用户问答和完整 harness 回归。
- `knowledge-stable-recheck.json`、`knowledge-library-report.md`:知识库与资料库稳定复测及限制。
- `skills-benchmark-paired.json`、`skills-findings.md`:同机旧/新刷新配对;“旧逻辑”在隔离进程禁用新合并函数回放,不改服务源码。
- `philippines-html.json`、`large-skill.json`:旧页面技能能力缺口与公文技能真实调用。
- `philippines-map-ui.png`:普通地图真实浏览器渲染。
- `cognitive-philippines.json`、`cognitive-skill-probe.json`:最新技能真实完整问答及渲染器专项。
- `cognitive-preview-ui.png`:最新 HTML 在本轮内嵌浏览器出现空白的实际截图。
- `frontend-build.log`、`frontend-typecheck-final.log`:全量构建与最终类型检查。
提交的回归入口包括 `packages/harness/tests/test_skill_view_refresh_coalescing.py`、`test_skill_view_waits.py`、`test_sandbox_command_liveness.py`、`test_run_completion_timeout.py`、`test_todo_reminder_visibility.py`,以及前端 `control-timeout`、`stream-terminal-recovery` 和 `todo-reminder-visibility` 测试。
@@ -0,0 +1,539 @@
# Thread 创建慢与回答结束后 History 等待:优化方案
日期:2026-10-22
配套文档:[完整排查手册](./线程创建与History等待慢-排查手册.md)
## 1. 优化目标与结论
目标:
- 降低 `POST /api/threads` 的 `checkpoint_create` 延迟;
- 降低 SSE 结束后 `/history` 的延迟;
- 避免慢 history 长时间占用全局 saver 锁并阻塞其他 thread;
- 处理 RunManager 内存 registry 持续增长;
- 保留对话 checkpoint 和历史记录,不通过直接删数据解决问题。
建议分四层实施:
| 优先级 | 动作 | 是否重启 | 风险 |
| --- | --- | --- | --- |
| P0 | 基于 EXPLAIN 在线补建 history 组合索引 | 否 | 低;增加少量写放大和磁盘占用 |
| P1 | History 明确主 namespace,缩短 alist 持锁范围并补计时 | 是 | 中;需要 history/subgraph 回归 |
| P1 | 修复 `RunManager.cleanup()` 未调用 | 是 | 低到中;需要重连/终态回归 |
| P2 | 让 PG saver 真正支持并发读写,或拆出独立 history reader | 是 | 中到高;需要并发与一致性测试 |
| P3 | 根据 dead tuples、I/O 和保留策略做维护/归档 | 视操作而定 | 中到高 |
最快的低风险止血方案是:先确认跨 namespace History 执行计划确实有 Sort,然后在线创建 `(thread_id, checkpoint_id DESC)` 索引。该索引只能缩短 SQL 和持锁时间,不能消除 saver 实例级串行。
## 2. P0:在线补建 History 组合索引
### 2.1 适用条件
同时满足以下条件时执行:
1. 线上 `/history` 当前需要跨 `checkpoint_ns` 返回最新 checkpoint;或者短期内不能发布代码来限定 namespace。
2. 慢 thread 的执行计划出现 per-thread Sort、扫描候选行较多或执行时间明显高于限定 `checkpoint_ns=''` 的版本。
3. 已确认目标表 schema;以下示例假设是 `public`。
如果每个 thread 只有极少 checkpoint,EXPLAIN 已是亚毫秒级且无明显扫描/Sort,建索引不会解决应用层 saver lock 排队,应直接转向 P1/P2。
### 2.2 建索引前检查
```sql
SELECT current_database(), current_schema(), current_schemas(true);
SELECT
schemaname,
tablename,
indexname,
indexdef
FROM pg_indexes
WHERE tablename = 'checkpoints'
ORDER BY indexname;
```
确认没有等价的 `(thread_id, checkpoint_id)` 索引。已有单列 `checkpoints_thread_id_idx(thread_id)` 不等价:它可以过滤 thread,但不能直接提供跨 namespace 的 `checkpoint_id` 顺序。
保存变更前基线:
```sql
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT checkpoint_id, checkpoint_ns
FROM public.checkpoints
WHERE thread_id = '<THREAD_ID>'
ORDER BY checkpoint_id DESC
LIMIT 1;
```
### 2.3 在线创建
`CREATE INDEX CONCURRENTLY` 必须作为独立语句执行,不能放在显式事务块中。确认数据库客户端没有自动把整段脚本包在一个事务里。
```sql
SET lock_timeout = '5s';
SET statement_timeout = '0';
CREATE INDEX CONCURRENTLY IF NOT EXISTS
checkpoints_thread_checkpoint_id_desc_idx
ON public.checkpoints (thread_id, checkpoint_id DESC);
RESET lock_timeout;
RESET statement_timeout;
```
然后更新统计:
```sql
ANALYZE public.checkpoints;
```
不要把 `checkpoint` 或 `metadata` JSONB 加进 `INCLUDE`;它们较大,会显著扩大索引且不能解决此排序问题。
### 2.4 验证索引有效
```sql
SELECT
i.relname AS index_name,
x.indisvalid,
x.indisready,
pg_size_pretty(pg_relation_size(i.oid)) AS index_size,
pg_get_indexdef(i.oid) AS indexdef
FROM pg_class t
JOIN pg_index x ON x.indrelid = t.oid
JOIN pg_class i ON i.oid = x.indexrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'checkpoints'
AND i.relname = 'checkpoints_thread_checkpoint_id_desc_idx';
```
必须看到:
```text
indisvalid = true
indisready = true
```
重跑执行计划:
```sql
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT checkpoint_id, checkpoint_ns
FROM public.checkpoints
WHERE thread_id = '<THREAD_ID>'
ORDER BY checkpoint_id DESC
LIMIT 1;
```
预期:
- 使用 `checkpoints_thread_checkpoint_id_desc_idx`;
- 不再出现额外 Sort;
- 扫描行数接近 1;
- buffer read 和实际执行时间明显下降。
### 2.5 上线观察
至少对比变更前后:
- `/history` p50/p95/p99;
- `POST /threads` 的 `checkpoint_create` p50/p95/p99;
- checkpoint INSERT/UPSERT 的平均与最大耗时;
- 新索引大小;
- `pg_stat_user_indexes.idx_scan`;
- checkpoint 写入吞吐与 WAL/磁盘增长。
索引使用统计:
```sql
SELECT
schemaname,
relname,
indexrelname,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
WHERE relname = 'checkpoints'
ORDER BY indexrelname;
```
### 2.6 回滚
如果索引导致不可接受的写放大、磁盘占用,或代码发布后已经不再需要跨 namespace 查询,可在线删除:
```sql
DROP INDEX CONCURRENTLY IF EXISTS
public.checkpoints_thread_checkpoint_id_desc_idx;
```
同样不要放在显式事务块内。
如果在线创建被中断,先查询 `indisvalid/indisready`。若留下 invalid 索引,低峰执行上面的 `DROP INDEX CONCURRENTLY` 后再重新创建,不要假设 `IF NOT EXISTS` 会修复 invalid 索引。
## 3. P1:让普通 History 明确查询主 namespace
### 3.1 为什么这是长期更优方案
普通聊天主图状态使用:
```text
checkpoint_ns = ''
```
`checkpoints` 现有主键:
```text
(thread_id, checkpoint_ns, checkpoint_id)
```
如果 History 的业务语义就是“普通聊天主图历史”,限定 `checkpoint_ns=''` 后,PostgreSQL 可以直接利用现有主键倒序取最新 checkpoint:
```sql
WHERE thread_id = $1
AND checkpoint_ns = ''
ORDER BY checkpoint_id DESC
LIMIT 1
```
这样不需要为同一访问模式长期维护额外索引,也避免误把 subgraph namespace 中较新的 checkpoint 当作主聊天最新状态。
### 3.2 建议代码改动
当前:
```python
config: dict[str, Any] = {"configurable": {"thread_id": thread_id}}
```
建议:
```python
config: dict[str, Any] = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": "",
}
}
```
位置:
```text
offline-backend-20260512/backend/app/gateway/routers/threads.py:1886
```
### 3.3 发布前必须验证
- 普通 lead agent 首轮和多轮历史恢复;
- 自定义 agent 历史恢复;
- subagent/subgraph 事件不应错误覆盖主图历史;
- interrupt/HITL 恢复;
- checkpoint `before` 分页;
- 页面刷新、重新打开会话、断流重连;
- `fetchStateHistory: { limit: 1 }` 返回的 messages、title、thread_data 正确。
如果某些 API 消费者明确要求跨 namespace 历史,不要直接全局改变语义。可以为普通聊天与诊断/调试接口拆分不同路径。
### 3.4 与 P0 索引的关系
- 紧急止血可以先建 P0 索引,再发布 namespace 修复。
- namespace 修复稳定运行一段时间后,通过 `pg_stat_user_indexes` 确认 P0 索引已不再需要,再在线删除。
- 不要在代码上线同一时刻立刻删索引,保留可快速回退窗口。
## 4. P1:缩短 `/history` 持有 saver lock 的时间
### 4.1 当前问题
当前 route 在下面的 `async for` 内处理并序列化每个 checkpoint:
```python
async for checkpoint_tuple in checkpointer.alist(config, limit=body.limit):
# 读取 checkpoint
# 序列化 messages
# 构造 HistoryEntry
```
PostgreSQL saver 的 `alist()` 在 `_cursor()` context 内 fetchall 后逐条 yield。调用方在处理 yielded tuple 时,generator 尚未退出,因此 saver lock 可能覆盖业务层消息序列化时间。
### 4.2 低风险 quick win
先完整、快速消费 bounded 结果,让 generator 退出并释放 saver cursor/lock,再在锁外做响应序列化:
```python
checkpoint_tuples = [
item
async for item in checkpointer.alist(config, limit=body.limit)
]
for checkpoint_tuple in checkpoint_tuples:
# 原来的 HistoryEntry/messages 序列化逻辑
```
当前普通前端请求 `limit=1`,额外内存可以忽略。接口模型允许更大 limit 时,仍需保留现有上限并测试大 checkpoint payload。
该改动不会解除 saver 内部对 SQL/反序列化的串行,但能避免 route 的 Python 序列化继续占着 saver lock。
### 4.3 同时增加 History 分段计时
建议记录无内容的阶段指标:
```text
history_checkpoint_fetch_ms
history_serialize_ms
history_total_ms
thread_id(清洗后)
limit
checkpointer 类型
```
这样可以区分:
- saver lock + SQL + checkpoint load;
- Gateway 消息序列化;
- route 外的鉴权/代理等待。
不要在日志中记录消息正文、checkpoint JSON、数据库 URL 或 token。
## 5. P1:修复 RunManager 内存 registry 增长
### 5.1 当前问题
`RunManager.cleanup()` 已存在:
```text
packages/harness/deerflow/runtime/runs/manager.py:318-324
```
但 worker finally 当前只调:
```python
asyncio.create_task(bridge.cleanup(run_id, delay=60))
```
这只删除 stream bridge 中的流缓存,不删除 `RunManager._runs`。因此一个进程运行越久,`create_or_reject()` 在 manager lock 内扫描的对象越多。
### 5.2 建议改动
在 run 已完成、终态已持久化并发布 SSE end 后,调度 run registry 清理:
```python
asyncio.create_task(bridge.cleanup(run_id, delay=60))
asyncio.create_task(run_manager.cleanup(run_id, delay=300))
```
延迟值可以结合重连窗口调整,但不要无限保留。
### 5.3 必须验证
- 完成后 60 秒内 SSE join/reconnect 行为不变;
- 内存记录删除后,`GET run` 能从持久化 store fallback;
- 已完成 run 的 join 能返回干净终态,而不是 404;
- cancel/rollback 与正在运行的任务不会被提前删除;
- 管理员并发监控只展示真实 active run;
- 进程内 `_runs` 数量在稳定负载下有上限,不随总历史永久增长。
这项修复与数据库 `runs` 保留策略无关,不会删除持久化历史记录。
## 6. P2:解除单 saver 实例的全局串行
### 6.1 不能采用的“优化”
仅提高:
```text
DEER_FLOW_PG_POOL_MAX
```
不会解决问题,因为 saver 在 pool checkout 前先获取实例锁。连接池容量只有在多个可并发 saver 操作真正进入 checkout 时才有意义。
### 6.2 可评估的实现方向
按风险从低到高:
1. 为只读 History 建立独立 saver/reader 实例,与 graph 写入 saver 使用同一个连接池但不同实例锁。
2. 将 History 改为专用、只读、受支持的 PostgreSQL repository,明确复用 LangGraph schema 与序列化协议。
3. 升级到确认支持 pool 并发且兼容当前 schema/serializer 的 checkpointer 版本。
4. 维护受控 patch,使 pool 场景不再用一个实例锁包住所有连接,但必须保留单连接/pipeline 模式所需的互斥。
不要直接删除第三方 saver 的 lock。该 lock 还保护单连接、pipeline、cursor 和 setup 等行为,粗暴移除可能造成事务/cursor 并发错误。
### 6.3 并发回归要求
至少覆盖:
- 一个慢 history 与一个新 thread `aput` 并发;
- history 与 graph `aput_writes` 并发;
- 20 个不同 thread 同时读/写;
- 同一个 thread 的顺序和 checkpoint parent 关系;
- PostgreSQL 重启、断开连接、pool 替换坏连接;
- cancellation 不泄漏 cursor/connection/lock;
- setup migration 只安全执行一次或可幂等执行;
- 回答正文、最终 state 和历史 reopen 一致。
现有回归入口:
```text
offline-backend-20260512/backend/packages/harness/tests/test_thread_operation_timing.py
```
## 7. 前端结束态优化
### 7.1 当前行为
SDK 将结束后的 history refresh 放在 stream 的成功收尾内等待,因此 `/history` 延迟会直接延长 `thread.isLoading`。
30 秒 control/history timeout 只是防止永久挂死,不是性能优化。
### 7.2 可选方向
如果后端已经保证 SSE 最终 state 足够完整,可以评估:
- SSE `end` 到达后立即结束输入区 loading;
- `/history` 改为后台刷新;
- 刷新失败不丢弃已经流式到达的答案;
- 后台 history 成功后再校准 checkpoint id、title、thread_data 和 messages。
不要只把 `fetchStateHistory` 改成 `false`。当前 `onFinish(state, runMeta)` 依赖最终 state,直接关闭会改变回调语义,并可能影响标题、消息状态、listener 和断流恢复。
发布前至少验证:
- 最后一条 assistant message 不闪退、不重复;
- 首轮 thread id 和 URL 更新正确;
- interrupt/HITL 与 resume 正确;
- 页面刷新能从 checkpoint 恢复;
- SSE 丢失终态时仍能通过后台 history 校准;
- 用户不会在后端 run 尚未真正结束时误发下一轮。
因此前端解耦建议放在数据库索引、namespace 和 saver 持锁优化之后。
## 8. runs 表的针对性优化
### 8.1 先确认实际索引
当前 ORM 声明包括:
```text
runs_pkey(run_id)
ix_runs_thread_status(thread_id, status)
ix_runs_created_user_status(created_at, user_id, status)
ix_runs_user_created(user_id, created_at)
ix_runs_thread_created(thread_id, created_at)
ix_runs_created_assistant_user(created_at, assistant_id, user_id)
ix_runs_created_model_user(created_at, model_name, user_id)
```
生产库是否实际存在必须用 `pg_indexes` 验证。
### 8.2 不要为普通 INSERT/PK UPDATE 盲目加索引
普通 run 创建是 INSERT,结束是 `WHERE run_id = ?` 的 UPDATE。已有主键时,额外索引不会让这两个操作更快,反而增加每次 INSERT/UPDATE 的索引维护成本。
只有某个明确查询的 EXPLAIN 证明缺索引时才补建。例如:
- `WHERE thread_id=? ORDER BY created_at DESC LIMIT 100` 缺少 `(thread_id, created_at)`;
- pending 恢复查询成为已确认慢点时,才评估 partial index:
```sql
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_runs_pending_created
ON public.runs (created_at)
WHERE status = 'pending';
```
该 partial index不在普通 `/threads` 或 `/history` 路径上,不能作为本次问题的默认修复。
### 8.3 维护与膨胀
只有 `pg_stat_user_tables` 显示 runs/checkpoint 表死元组高、autovacuum 长期未运行时,才安排:
```sql
VACUUM (ANALYZE) public.runs;
VACUUM (ANALYZE) public.checkpoints;
VACUUM (ANALYZE) public.checkpoint_blobs;
VACUUM (ANALYZE) public.checkpoint_writes;
```
普通 `VACUUM (ANALYZE)` 与 `VACUUM FULL` 不同,但仍会产生 I/O,应低峰执行并监控。不要在线直接使用 `VACUUM FULL`。
autovacuum 参数应根据写入速率、表大小、死元组和数据库资源制定,不在没有现场指标时给所有表套固定值。
## 9. 数据保留和归档
归档不是本次 6 秒问题的第一优先级。确有长期容量压力时,另行设计:
- 明确保留期限与可恢复 SLA;
- 只归档真正终态且超过保留期的 thread;
- `checkpoints`、`checkpoint_blobs`、`checkpoint_writes` 必须按同一批 thread 完整处理;
- 停止相关 writer 或使用经过验证的一致性流程;
- 先 dry-run,导出候选 thread,备份并演练恢复;
- 同库 archive schema 不一定立即回收磁盘;
- 旧会话入口与正文恢复策略要一起设计。
不要只删除 `checkpoints` 主表而留下 blob/write,也不要只按全局时间无视仍活跃 thread。
## 10. 建议实施顺序
### 阶段 A:当天可做
1. 用配套排查文档采集一轮完整证据。
2. 确认 6 秒集中在 `checkpoint_create` 或 `/history`。
3. 对慢 thread 跑两条 EXPLAIN。
4. 如果跨 namespace 查询有 Sort/大量扫描,低峰在线创建 P0 索引。
5. `ANALYZE checkpoints`,重跑 EXPLAIN。
6. 对比变更前后 `/history` 与 `checkpoint_create`。
### 阶段 B:下一次应用发布
1. 确认普通 history 的 namespace 语义并限定 `checkpoint_ns=''`。
2. 先完整消费 `alist`,在 saver lock 释放后序列化响应。
3. 增加 history fetch/serialize 分段计时。
4. 调用 `RunManager.cleanup()`,限制内存 registry 增长。
5. 补单元、集成和并发回归。
### 阶段 C:压测后实施
1. 评估独立 history saver/reader 或兼容升级。
2. 验证多 checkpoint 操作真实并发。
3. 再根据新架构调整 pool 大小。
4. 基于真实容量与保留期制定归档策略。
## 11. 验收标准
不要只看一次“变快了”,至少记录一段稳定流量下的分位数。
| 指标 | 验收方式 |
| --- | --- |
| `/threads` 总耗时 | 浏览器/APM p50、p95、p99 |
| `checkpoint_create` | `Server-Timing`/日志 p50、p95、p99 |
| `/history` 总耗时 | Gateway/APM p50、p95、p99 |
| History SQL | `pg_stat_statements` mean/max,EXPLAIN 实际时间 |
| saver 排队 | 应用分段计时中 lock/fetch 与 serialize 分离后观察 |
| 正确性 | 首轮、多轮、刷新、重连、interrupt、subagent 历史一致 |
| 资源 | PG CPU、I/O、WAL、连接数、新索引大小 |
| 内存 registry | `_runs` 数量不再随累计历史永久增长 |
成功信号:
- History 计划使用目标组合索引,或限定 namespace 后使用现有主键;
- `/history` 不再出现秒级长尾;
- 同期 history 不再让新 thread 的 `checkpoint_create` 出现同等长尾;
- 回答正文结束到 UI 可再次输入之间的间隔显著下降;
- 对话刷新、历史恢复和重连行为无回归。
如果 History SQL 已稳定在毫秒级,但 `checkpoint_create` 仍有秒级长尾,应停止继续堆索引,转向 saver 实例锁和事件循环排队的 P1/P2 优化。
## 12. 变更与回滚清单
```text
[ ] 保存部署 commit、配置和指标基线
[ ] 保存目标 thread 的 EXPLAIN
[ ] 核对 schema 和现有等价索引
[ ] CREATE INDEX CONCURRENTLY(不在事务中)
[ ] 检查 indisvalid/indisready
[ ] ANALYZE checkpoints
[ ] 重跑 EXPLAIN
[ ] 观察 API、PG、WAL、磁盘指标
[ ] 准备 DROP INDEX CONCURRENTLY 回滚语句
[ ] 代码变更补 history/subgraph/reconnect 回归
[ ] 先发布代码并观察,再决定是否删除临时索引
```
@@ -0,0 +1,916 @@
# Thread 创建慢与回答结束后 History 等待:完整排查手册
日期:2026-10-22
适用范围:DeerFlow Gateway、LangGraph `useStream`、PostgreSQL 业务库与 PostgreSQL checkpointer。
## 1. 先说结论
当前代码证据表明:
1. `POST /api/threads` **不查询 `runs` 表**。它查询/写入 `threads_meta`,然后同步写入一个空 checkpoint。
2. 创建 run 时,持久化层主要执行 `INSERT INTO runs (...)`;run 结束时主要执行 `UPDATE runs ... WHERE run_id = ?`。`run_id` 是主键,`runs` 有 5 万行本身通常不足以造成 6 秒延迟。
3. 回答流结束后出现 `POST /api/threads/{thread_id}/history` 是前端 SDK 的正常行为。当前前端配置 `fetchStateHistory: { limit: 1 }`,SDK 会等待这个请求完成后才结束 `isLoading`,因此会出现“回答文字已经显示完,但页面仍在等待中”。
4. `/history` 实际查询 `checkpoints`、`checkpoint_blobs`、`checkpoint_writes`,不是查询 `runs`。
5. 当前 `/history` 只传 `thread_id`,核心查询是:
```sql
WHERE thread_id = $1
ORDER BY checkpoint_id DESC
LIMIT 1
```
`checkpoints` 主键为 `(thread_id, checkpoint_ns, checkpoint_id)`。当不限定 `checkpoint_ns` 时,主键不能直接提供跨 namespace 的 `checkpoint_id` 顺序,可能需要扫描该 thread 的候选行并做 top-N Sort。
6. 当前进程只创建一个全局 `AsyncPostgresSaver`。该 saver 在取得连接池连接之前先获取实例级 `asyncio.Lock`,所以 checkpoint 的 `aget/alist/aput/aput_writes` 在一个 Gateway 进程内实际是串行的。慢 `/history` 会延长持锁时间,并阻塞新 thread 的空 checkpoint 写入。
最符合当前现象的候选链路是:
```text
回答正文已经通过 SSE 到达浏览器
-> 后端完成 run 收尾并发送 SSE end
-> 前端 SDK 同步请求 /history
-> /history 查询或反序列化 checkpoint,期间占用 saver 实例锁
-> SDK 的 isLoading 仍为 true,页面继续显示等待
-> 同期创建新 thread 时,checkpoint_create 等待同一把 saver 锁
-> POST /threads 偶发耗时数秒
```
这只是代码层面的高概率根因。最终必须使用同一次线上复现的 `Server-Timing`、Gateway 日志和 PostgreSQL 活动快照闭环确认。
## 2. 两类 PostgreSQL 数据不要混淆
系统存在两类持久化访问。即使它们使用同一个 PostgreSQL DSN,也有不同的连接池和代码路径。
| 类型 | 主要表 | 主要用途 |
| --- | --- | --- |
| 应用 ORM 数据 | `threads_meta`、`runs` | 会话元数据、run 状态、统计字段 |
| LangGraph checkpointer | `checkpoints`、`checkpoint_blobs`、`checkpoint_writes`、`checkpoint_migrations` | 对话状态、消息、通道值、pending writes |
排查前先记录:
```text
部署 commit:
前端构建版本:
Gateway worker 数:
database.backend:
database PostgreSQL 主机/库名(脱敏):
checkpointer 实际后端:
checkpointer PostgreSQL 主机/库名(脱敏):
langgraph-checkpoint-postgres 版本:
```
注意:
- 仅确认 `database.backend=postgres` 不等于已经确认 `/history` 使用 PostgreSQL checkpointer。
- 当前运行时可能由统一 `database.postgres_url` 提供业务 ORM 与 checkpointer,也可能使用兼容配置中的独立 `checkpointer` 段。
- 若 legacy 配置为 `checkpointer.type=postgres` 但 `use_postgres=false`,checkpointer 会回退 SQLite。
- 当前 live run 与 SSE bridge 是进程内状态,正常部署要求 `GATEWAY_WORKERS=1`。不要用盲目增加 worker 的方式绕过该问题。
## 3. 浏览器看到的完整请求时间线
```mermaid
sequenceDiagram
participant UI as 浏览器 / useStream
participant API as Gateway
participant APPDB as 业务 PostgreSQL
participant CP as Checkpointer PostgreSQL
UI->>API: POST /api/threads
API->>APPDB: SELECT/INSERT threads_meta
API->>CP: UPSERT 初始 checkpoint
API-->>UI: thread_id
UI->>API: POST /api/threads/{id}/runs/stream
API->>APPDB: INSERT runs
API-->>UI: SSE 正文/工具事件
API->>CP: 持续写 checkpoint/blob/write
API->>APPDB: UPDATE runs 终态
API-->>UI: SSE end
UI->>API: POST /api/threads/{id}/history limit=1
API->>CP: 最新 checkpoint + blob/write
API-->>UI: history
Note over UI: history 完成后 isLoading 才结束
```
### 3.1 创建新会话
前端 SDK 在没有 `threadId` 时,会先 `await client.threads.create(...)`,成功后才发起 `runs.stream(...)`。因此 `POST /api/threads` 慢 6 秒,会直接表现为点击发送后 6 秒才开始回答流。
后端调用位置:
- `app/gateway/routers/threads.py:853-927`
- 前端使用点:`frontend-web/src/core/threads/hooks.ts:702-711`
- SDK 等待 thread create:安装包 `dist/react/stream.lgp.js:318-327`
### 3.2 回答结束后的 History
当前前端配置:
```ts
fetchStateHistory: { limit: 1 }
```
SDK 在 SSE 成功结束后执行:
```ts
const lastHead = (await history.mutate(threadId))?.at(0);
```
之后才调用 `onFinish` 并结束当前 stream loading 状态。当前项目的 `onFinish(state, runMeta)` 还显式接收最终 state,所以 SDK 也需要拿到 history state。
相关位置:
- `frontend-web/src/core/threads/hooks.ts:711`
- `frontend-web/src/core/threads/hooks.ts:976-1038`
- SDK `dist/react/stream.lgp.js:379-388`
- 页面最终暴露的 loading:`frontend-web/src/core/threads/hooks.ts:1920`
- 普通控制/history 请求超时保护:`frontend-web/src/core/api/api-client.ts:96-101`,当前为 30 秒
结论:最后看到 `/history` 并不表示模型还在生成;它可能只是 SDK 在做结束后的最终状态刷新。
## 4. 每个阶段实际执行什么 SQL
以下为当前代码和 `langgraph-checkpoint-postgres==3.1.2` 对应的等价 SQL。SQLAlchemy/psycopg 最终的占位符和列顺序可能略有差异,但访问表、过滤条件和索引需求一致。
### 4.1 `POST /api/threads`
调用链:
```text
threads.create_thread
-> ThreadMetaRepository.get
-> ThreadMetaRepository.create(仅新 thread)
-> AsyncPostgresSaver.aput(empty_checkpoint)
```
第一步,幂等查询:
```sql
SELECT ...
FROM threads_meta
WHERE thread_id = $1;
```
`threads_meta.thread_id` 应为主键。
新 thread 写元数据:
```sql
INSERT INTO threads_meta (...)
VALUES (...);
```
同步写入初始 checkpoint:
```sql
INSERT INTO checkpoints (
thread_id,
checkpoint_ns,
checkpoint_id,
parent_checkpoint_id,
checkpoint,
metadata
)
VALUES ($1, '', $2, NULL, $3, $4)
ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id)
DO UPDATE SET
checkpoint = EXCLUDED.checkpoint,
metadata = EXCLUDED.metadata;
```
初始 checkpoint 的 `new_versions={}`,通常不会同时写 `checkpoint_blobs`。
该接口分阶段计时:
| `Server-Timing` 字段 | 实际内容 |
| --- | --- |
| `metadata_lookup` | `threads_meta` 按 `thread_id` 查询 |
| `metadata_create` | 新建 `threads_meta` 并提交事务 |
| `checkpoint_create` | 等待 saver 锁、取得连接、写 checkpoint、提交/同步 pipeline |
| `handler_total` | 进入 route handler 后的总时间 |
关键点:该接口的整条路径不读取或写入 `runs`。
### 4.2 `POST /api/threads/{thread_id}/runs/stream`
调用链:
```text
thread_runs.stream_run
-> services.start_run
-> RunManager.create_or_reject
-> RunRepository.put
-> 创建后台 run_agent task
```
`RunManager.create_or_reject` 先在 Python 进程内的全局 manager lock 下扫描 `_runs.values()`,检查同 thread、同模型以及同用户模型并发。该步骤不是 SQL。
然后持久化 run:
```sql
INSERT INTO runs (
run_id,
thread_id,
assistant_id,
user_id,
status,
model_name,
multitask_strategy,
metadata_json,
kwargs_json,
error,
follow_up_to_run_id,
created_at,
updated_at,
...
)
VALUES (...);
```
随后会查询或更新 `threads_meta`,再启动后台 agent。Gateway 在慢路径输出:
```text
[start-run-timing] thread=... total=...s enforce_access=...s run_create=...s thread_upsert=...s build_payload=...s
```
其中 `run_create` 包含:
- 进程内 `_runs` 扫描;
- `INSERT INTO runs`;
- 数据库 COMMIT。
### 4.3 graph 执行期间
LangGraph 会反复调用:
```sql
INSERT INTO checkpoint_blobs (...)
VALUES (...)
ON CONFLICT (thread_id, checkpoint_ns, channel, version)
DO NOTHING;
```
```sql
INSERT INTO checkpoints (...)
VALUES (...)
ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id)
DO UPDATE SET checkpoint = EXCLUDED.checkpoint,
metadata = EXCLUDED.metadata;
```
```sql
INSERT INTO checkpoint_writes (...)
VALUES (...)
ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
DO UPDATE SET channel = EXCLUDED.channel,
type = EXCLUDED.type,
blob = EXCLUDED.blob;
```
因此 checkpoint 数据量一般会明显大于 runs 数量:一个 run 可以产生多个 checkpoint、blob 和 write。
### 4.4 回答正文结束到 SSE `end`
graph 流结束后,后端仍会执行终态检查与收尾:
1. 第一次读取主 namespace 最新 checkpoint,用于确认 graph 已留下 terminal state:
```sql
SELECT ...
FROM checkpoints
WHERE thread_id = $1
AND checkpoint_ns = ''
ORDER BY checkpoint_id DESC
LIMIT 1;
```
2. 更新 run 状态:
```sql
UPDATE runs
SET status = $2,
updated_at = $3,
error = COALESCE($4, error)
WHERE run_id = $1;
```
3. 更新 run completion/token/message 统计,仍然是:
```sql
UPDATE runs
SET ...
WHERE run_id = $1;
```
4. 收尾阶段再次读取主 namespace 最新 checkpoint,用于同步标题和最终元数据。
5. 更新 `threads_meta` 状态/标题等信息。
6. 最后发布 SSE `end`。
两次 checkpoint 读取都会争用同一个 saver 实例锁。因此实际等待链可能是“正文已经生成 → terminal checkpoint 读取等锁 → completion 更新 → finalize checkpoint 读取再次等锁 → SSE end → 前端 `/history` 再次读 checkpoint”。
`runs` 的上述 UPDATE 按 `run_id` 主键定位。若该阶段慢,应该检查锁等待、连接池、事务提交和死元组,而不是仅凭总行数判断。
后端为终态收尾设置了 10 秒预算,相关日志包括:
```text
RUN_COMPLETION_TIMEOUT run_id=... phase=terminal_checkpoint
RUN_COMPLETION_TIMEOUT run_id=... phase=terminal_status
RUN_COMPLETION_TIMEOUT run_id=... phase=metadata_cleanup
```
### 4.5 `POST /api/threads/{thread_id}/history`
路由当前构造的 config 只有 `thread_id`:
```python
config = {"configurable": {"thread_id": thread_id}}
```
前端当前传 `limit=1`。saver 生成的核心 SQL 形态为:
```sql
SELECT
thread_id,
checkpoint,
checkpoint_ns,
checkpoint_id,
parent_checkpoint_id,
metadata,
(
SELECT array_agg(...)
FROM jsonb_each_text(checkpoint -> 'channel_versions')
JOIN checkpoint_blobs bl
ON bl.thread_id = checkpoints.thread_id
AND bl.checkpoint_ns = checkpoints.checkpoint_ns
AND bl.channel = jsonb_each_text.key
AND bl.version = jsonb_each_text.value
) AS channel_values,
(
SELECT array_agg(...)
FROM checkpoint_writes cw
WHERE cw.thread_id = checkpoints.thread_id
AND cw.checkpoint_ns = checkpoints.checkpoint_ns
AND cw.checkpoint_id = checkpoints.checkpoint_id
) AS pending_writes
FROM checkpoints
WHERE thread_id = $1
ORDER BY checkpoint_id DESC
LIMIT 1;
```
其中 blob/write 的相关子查询可以使用各自组合主键;最值得核对的是最外层 `checkpoints` 的跨 namespace 排序。
## 5. 为什么连接池最大 10 仍可能串行
当前 provider 创建:
```text
AsyncConnectionPool(min_size=1, max_size=10)
-> 一个 AsyncPostgresSaver(pool)
-> 挂到 app.state,供所有 thread/run/history 共用
```
`langgraph-checkpoint-postgres==3.1.2` 的 `_cursor()` 关键实现是:
```python
async with self.lock, get_connection(self.conn) as conn:
```
这意味着:
- saver 先拿实例锁,再去连接池 checkout;
- 同一个 saver 的所有 checkpoint 读写一次只能有一个进入 `_cursor()`;
- 把 `DEER_FLOW_PG_POOL_MAX` 从 10 调到更大,不能解除这个实例锁;
- 等待 saver 锁的 coroutine 尚未进入 PostgreSQL,因此 `pg_stat_activity` 可能只看到一个活动连接,看不到应用中其余等待者;
- `alist()` 在 cursor context 内 `fetchall()` 后逐项 `yield`,调用方处理 yielded tuple 时 generator 尚未退出,实例锁仍可能保持占用。
仓库已有 characterization test:
```text
packages/harness/tests/test_thread_operation_timing.py:204-247
```
它验证了 pool 场景下第二个 saver cursor 会等待第一个 cursor 释放。
## 6. `runs` 5 万行到底会不会慢
### 6.1 数据库中的 5 万条 runs
正常创建/完成路径主要是:
```sql
INSERT INTO runs (...);
UPDATE runs SET ... WHERE run_id = $1;
```
`run_id` 是主键,5 万行对 PostgreSQL 很小。只要:
- `runs_pkey` 存在且有效;
- 没有严重锁等待;
- 死元组和表/索引膨胀不严重;
- 数据库连接与存储 I/O 正常;
就不应仅因为 5 万行产生 6 秒延迟。
### 6.2 进程内 `_runs` 与数据库 `runs` 不是一回事
代码中存在一个独立问题:
- `RunManager.cleanup()` 能从 `_runs` 字典删除已结束记录;
- 当前 worker finally 只调用 `StreamBridge.cleanup()`;
- 全仓当前没有调用 `run_manager.cleanup(...)`;
- 因此同一个 Gateway 进程生命周期内完成的 run 会一直留在 `_runs`;
- 每次 `create_or_reject()` 会在 manager lock 内对 `_runs.values()` 做 1~3 次 O(N) 扫描。
数据库的 5 万条 runs 不会在启动时自动全部加载到 `_runs`。只有当当前进程自身长期运行并累计大量 run 时,这个问题才会放大。它是应该修复的增长性缺陷,但通常不是单独造成 6 秒的首要解释。
## 7. 一次完整的线上复现怎么采集
### 7.1 浏览器 Network
打开 DevTools → Network:
1. 勾选 `Preserve log`。
2. 过滤 `threads`。
3. 从创建新对话开始完整复现一次。
4. 记录以下请求:
| 阶段 | 请求 |
| --- | --- |
| 创建会话 | `POST /api/threads` |
| 问答流 | `POST /api/threads/{thread_id}/runs/stream` |
| 结束刷新 | `POST /api/threads/{thread_id}/history` |
记录:
```text
thread_id:
run_id:
请求开始时间:
HTTP 状态:
Waiting/TTFB:
Content Download:
响应大小:
请求结束时间:
```
另外记录:
- `runs/stream` 最后一个可见正文 chunk 的时间;
- SSE 真正结束时间;
- `/history` 开始和完成时间;
- 页面退出“等待中”的时间;
- `POST /api/threads` 响应头中的 `Server-Timing`。
导出 HAR 前删除 Authorization、Cookie、token 和业务正文。
### 7.2 `POST /threads` 的 `Server-Timing`
示例:
```http
Server-Timing: metadata_lookup;dur=18.20, metadata_create;dur=25.10, checkpoint_create;dur=5880.30, handler_total;dur=5930.10
```
判断:
| 结果 | 优先结论 |
| --- | --- |
| `checkpoint_create` 约等于总耗时 | saver 锁等待、checkpointer 连接获取或 checkpoint PG 写入 |
| `metadata_lookup` 慢 | 业务 PG 的 `threads_meta` 主键查询、连接池或锁 |
| `metadata_create` 慢 | 业务 PG 插入/提交、索引维护或锁 |
| 所有 phase 很快但 `handler_total` 慢 | phase 外逻辑或事件循环调度竞争 |
| `handler_total` 很快但浏览器 TTFB 慢 | 鉴权、中间件、反向代理、进入 handler 前排队 |
| 没有 `Server-Timing` | 线上版本不包含该诊断,或请求没有进入预期 route |
`checkpoint_create` 包含 saver lock 和 pool 等待,不只是 SQL 执行时间。
### 7.3 Gateway 日志
本地脚本部署:
```powershell
rg -n "GATEWAY_OPERATION_TIMING|start-run-timing|RUN_COMPLETION_TIMEOUT|completion metadata exceeded|Failed to finalize run|Checkpointer|pool timeout|OperationalError" .runtime/logs
```
容器部署:
```bash
docker logs --since 15m <gateway-container> 2>&1 \
| rg "GATEWAY_OPERATION_TIMING|start-run-timing|RUN_COMPLETION_TIMEOUT|completion metadata exceeded|Failed to finalize run|Checkpointer|pool timeout|OperationalError"
```
将同一个 `thread_id`、`run_id` 整理成时间线:
```text
T0 POST /threads 开始
T1 thread_create 分阶段日志
T2 run 开始
T3 最后正文事件
T4 run 标记 success/error
T5 SSE end
T6 /history 开始
T7 /history 完成
T8 UI 退出等待
```
## 8. PostgreSQL 只读排查 SQL
以下查询默认当前连接就是目标数据库。若应用使用非 `public` schema,先确认实际 schema,再修改后续 SQL。`<THREAD_ID>` 和 `<RUN_ID>` 必须替换成真实值。
### 8.1 确认 schema、版本和现有索引
```sql
SELECT current_database(), current_schema(), current_schemas(true);
SELECT max(v) AS checkpoint_schema_version
FROM checkpoint_migrations;
SELECT
schemaname,
tablename,
indexname,
indexdef
FROM pg_indexes
WHERE tablename IN (
'runs',
'threads_meta',
'checkpoints',
'checkpoint_blobs',
'checkpoint_writes'
)
ORDER BY tablename, indexname;
```
`langgraph-checkpoint-postgres 3.1.2` 正常 setup 应包含三张 checkpoint 表的组合主键以及:
```text
checkpoints_thread_id_idx
checkpoint_blobs_thread_id_idx
checkpoint_writes_thread_id_idx
```
不要因为模型代码声明了索引就假设生产库一定存在,最终以 `pg_indexes`/`pg_index` 为准。
确认索引是否 valid/ready:
```sql
SELECT
n.nspname AS schema_name,
t.relname AS table_name,
i.relname AS index_name,
x.indisvalid,
x.indisready,
pg_get_indexdef(i.oid) AS indexdef
FROM pg_class t
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_index x ON x.indrelid = t.oid
JOIN pg_class i ON i.oid = x.indexrelid
WHERE t.relname IN (
'runs',
'threads_meta',
'checkpoints',
'checkpoint_blobs',
'checkpoint_writes'
)
ORDER BY t.relname, i.relname;
```
### 8.2 表规模、死元组和维护情况
```sql
SELECT
schemaname,
relname,
n_live_tup,
n_dead_tup,
round(
100.0 * n_dead_tup /
greatest(n_live_tup + n_dead_tup, 1),
2
) AS dead_pct,
last_vacuum,
last_autovacuum,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
WHERE relname IN (
'runs',
'threads_meta',
'checkpoints',
'checkpoint_blobs',
'checkpoint_writes'
)
ORDER BY relname;
```
```sql
SELECT
schemaname,
relname,
pg_size_pretty(
pg_total_relation_size(
format('%I.%I', schemaname, relname)::regclass
)
) AS total_size
FROM pg_stat_user_tables
WHERE relname IN (
'runs',
'threads_meta',
'checkpoints',
'checkpoint_blobs',
'checkpoint_writes'
)
ORDER BY pg_total_relation_size(
format('%I.%I', schemaname, relname)::regclass
) DESC;
```
统计值是估算值,足以用于判断规模和维护异常;不必先执行昂贵的全表 `count(*)`。
### 8.3 故障 thread 的 checkpoint 分布
```sql
SELECT
checkpoint_ns,
count(*) AS checkpoint_rows,
min(checkpoint_id) AS oldest_checkpoint_id,
max(checkpoint_id) AS newest_checkpoint_id
FROM checkpoints
WHERE thread_id = '<THREAD_ID>'
GROUP BY checkpoint_ns
ORDER BY checkpoint_rows DESC;
```
如果单个 thread 有大量 checkpoint 或多个 namespace,当前 `/history` 的跨 namespace 排序更值得关注。
### 8.4 对比 History 与主 namespace 的执行计划
只对一个已知慢 thread 在低峰执行:
```sql
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT checkpoint_id, checkpoint_ns
FROM checkpoints
WHERE thread_id = '<THREAD_ID>'
ORDER BY checkpoint_id DESC
LIMIT 1;
```
对比限定主 namespace:
```sql
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT checkpoint_id, checkpoint_ns
FROM checkpoints
WHERE thread_id = '<THREAD_ID>'
AND checkpoint_ns = ''
ORDER BY checkpoint_id DESC
LIMIT 1;
```
重点看:
- 第一条是否出现 `Sort` / `top-N heapsort`;
- `Rows Removed by Filter`;
- 实际扫描行数;
- shared buffer read/hit;
- 第二条是否使用 `checkpoints_pkey` 的 backward index scan;
- 两条的实际耗时差异。
`EXPLAIN ANALYZE` 会真实执行 SELECT。这里的 SELECT 是只读的,但仍应对一个明确 thread 在低峰执行,不要直接对完整大型历史查询做无界分析。
### 8.5 验证 runs 的关键查询
按 run id:
```sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT run_id, status
FROM runs
WHERE run_id = '<RUN_ID>';
```
预期使用 `runs_pkey`。
按 thread 列表:
```sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT run_id, status, created_at
FROM runs
WHERE thread_id = '<THREAD_ID>'
ORDER BY created_at DESC
LIMIT 100;
```
预期使用 `(thread_id, created_at)` 组合索引,例如 ORM 当前声明的 `ix_runs_thread_created`。如果生产库没有该索引,先核对部署 migration/自动补列逻辑,再决定是否补建;不要建立重复索引。
### 8.6 卡顿正在发生时查看 PostgreSQL 活动
```sql
SELECT
pid,
application_name,
usename,
state,
wait_event_type,
wait_event,
xact_start,
query_start,
now() - query_start AS query_age,
pg_blocking_pids(pid) AS blocking_pids,
left(query, 500) AS query
FROM pg_stat_activity
WHERE datname = current_database()
AND pid <> pg_backend_pid()
AND (state <> 'idle' OR wait_event IS NOT NULL)
ORDER BY query_start;
```
阻塞关系:
```sql
SELECT
blocked.pid AS blocked_pid,
now() - blocked.query_start AS blocked_for,
blocked.wait_event_type,
blocked.wait_event,
left(blocked.query, 300) AS blocked_query,
blocker.pid AS blocker_pid,
blocker.state AS blocker_state,
now() - blocker.xact_start AS blocker_xact_age,
left(blocker.query, 300) AS blocker_query
FROM pg_stat_activity AS blocked
JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS b(pid) ON true
JOIN pg_stat_activity AS blocker ON blocker.pid = b.pid
ORDER BY blocked.query_start;
```
注意:如果请求在 Python saver lock 上等待,它还没有 checkout PostgreSQL 连接,因此 PostgreSQL 里可能看不到对应 blocked session。
### 8.7 如果启用了 `pg_stat_statements`
先确认扩展:
```sql
SELECT extname, extversion
FROM pg_extension
WHERE extname = 'pg_stat_statements';
```
checkpoint 查询:
```sql
SELECT
calls,
round(mean_exec_time::numeric, 2) AS mean_ms,
round(max_exec_time::numeric, 2) AS max_ms,
rows,
shared_blks_hit,
shared_blks_read,
temp_blks_written,
left(query, 700) AS query
FROM pg_stat_statements
WHERE dbid = (
SELECT oid
FROM pg_database
WHERE datname = current_database()
)
AND query ILIKE '%checkpoints%'
ORDER BY max_exec_time DESC
LIMIT 30;
```
runs 查询:
```sql
SELECT
calls,
round(mean_exec_time::numeric, 2) AS mean_ms,
round(max_exec_time::numeric, 2) AS max_ms,
rows,
shared_blks_hit,
shared_blks_read,
temp_blks_written,
left(query, 700) AS query
FROM pg_stat_statements
WHERE dbid = (
SELECT oid
FROM pg_database
WHERE datname = current_database()
)
AND query ILIKE '%runs%'
ORDER BY max_exec_time DESC
LIMIT 30;
```
## 9. 证据如何判定
| 现场证据 | 结论/下一步 |
| --- | --- |
| `/threads` 的 `checkpoint_create` 接近 6 秒 | 优先排查 saver 锁排队或 checkpoint PG 写入 |
| `checkpoint_create` 很慢,但 `pg_stat_activity` 没有相应慢 SQL/锁 | 强烈支持请求仍在进程内 saver lock 上排队 |
| History EXPLAIN 出现大量候选扫描 + Sort | 为 `(thread_id, checkpoint_id DESC)` 建候选索引,或让接口限定 `checkpoint_ns=''` |
| History SQL 自身毫秒级,但接口仍数秒 | 重点查 saver lock wait、反序列化、消息序列化和事件循环阻塞 |
| 最后正文已到但 SSE 未结束,日志出现 `terminal_checkpoint` | run 收尾在 checkpoint 读取处等待 |
| 日志出现 `terminal_status` | `runs` 终态 UPDATE/COMMIT、业务 PG 连接或锁等待 |
| 日志出现 `metadata_cleanup` | journal、completion、标题或 thread 元数据收尾超时 |
| SSE 已结束,只有 `/history` pending | SDK 结束后 history 刷新卡住,不是模型还在生成 |
| `/history` 已 200,页面仍 loading | 再查前端构建版本、状态恢复和渲染逻辑 |
| `runs_pkey` 命中,dead tuples 低,无 DB wait | 5 万 runs 不是该故障的主要原因 |
| `runs` dead tuples 高、autovacuum 长期不工作 | 可能拖慢 UPDATE/COMMIT,但仍不能解释 `/history` 路径 |
| PG 出现 `wait_event_type='Lock'` 和 blocking pid | 已确认数据库锁等待,先处理 blocker |
| PG 出现明显 I/O wait 或大量 shared reads | 检查存储、缓存命中、膨胀及查询计划 |
| checkpoint migration 或必要索引缺失/invalid | 修复 schema/setup,再评估应用层锁 |
## 10. 不建议直接做的操作
- 不要因为有 5 万条 runs 就直接删除 runs。
- 不要在生产直接执行 `VACUUM FULL`,它会对表获取强锁。
- 不要先增加大量重复索引;每个索引都会增加 checkpoint 写放大。
- 不要只增大 `DEER_FLOW_PG_POOL_MAX`;单 saver 的实例锁仍会串行。
- 不要通过增加 Gateway worker 临时绕过;当前 run registry 与 SSE bridge 是进程内状态。
- 不要在未确认 history namespace 语义前直接把所有查询改成 `checkpoint_ns=''`。
- 不要把 checkpoint 归档作为本次 6 秒问题的第一步;归档涉及停写、备份、恢复演练,并不能消除 saver 实例锁。
## 11. 单次事故记录模板
```text
部署 commit:
前端构建版本:
Gateway worker:
database backend/host/db:
checkpointer backend/host/db:
发生时间:
thread_id:
run_id:
POST /threads:
- 浏览器总耗时:
- Server-Timing:
- HTTP 状态:
runs/stream:
- 开始时间:
- 最后正文时间:
- SSE end 时间:
- 是否收到 end:
history:
- 开始时间:
- TTFB:
- 完成/取消时间:
- HTTP 状态:
- 响应大小:
Gateway 日志:
- GATEWAY_OPERATION_TIMING:
- start-run-timing:
- RUN_COMPLETION_TIMEOUT:
- pool/DB 异常:
PostgreSQL:
- activity/wait_event:
- blocking_pids:
- checkpoint migration:
- 索引缺失/invalid:
- live/dead tuples:
- History EXPLAIN:
初步判定:
已经排除:
尚缺证据:
下一步:
```
## 12. 关键代码位置
| 位置 | 内容 |
| --- | --- |
| `offline-backend-20260512/backend/app/gateway/routers/threads.py:853-927` | 创建 thread、分阶段计时、同步写初始 checkpoint |
| `offline-backend-20260512/backend/app/gateway/routers/threads.py:1873-1944` | History config、alist 和消息序列化 |
| `offline-backend-20260512/backend/app/gateway/operation_timing.py:14-60` | `Server-Timing` 和 `GATEWAY_OPERATION_TIMING` |
| `offline-backend-20260512/backend/app/gateway/routers/thread_runs.py:98-133` | run 创建与 SSE 路由 |
| `offline-backend-20260512/backend/app/gateway/services.py:1374-1976` | `start_run` 和 `[start-run-timing]` |
| `offline-backend-20260512/backend/packages/harness/deerflow/persistence/run/sql.py:66-206` | runs INSERT/UPDATE |
| `offline-backend-20260512/backend/packages/harness/deerflow/persistence/run/model.py:14-56` | runs 主键和 ORM 索引声明 |
| `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py:211-324` | `_runs` 扫描与未被调用的 cleanup |
| `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/worker.py:479-501` | run 收尾后仅清理 stream bridge |
| `offline-backend-20260512/backend/packages/harness/deerflow/runtime/checkpointer/async_provider.py:93-123` | PG pool 与单 saver |
| `.venv/Lib/site-packages/langgraph/checkpoint/postgres/base.py:43-150` | checkpoint schema、索引和 SQL |
| `.venv/Lib/site-packages/langgraph/checkpoint/postgres/aio.py:119-179` | alist 查询、fetchall 与持锁 yield |
| `.venv/Lib/site-packages/langgraph/checkpoint/postgres/aio.py:363-403` | saver 实例锁先于 pool checkout |
| `frontend-web/src/core/threads/hooks.ts:702-711` | `fetchStateHistory: { limit: 1 }` |
| `frontend-web/src/core/threads/hooks.ts:976-1038` | onFinish 依赖最终 state |
| `frontend-web/src/core/api/api-client.ts:96-101` | 控制/history 请求 30 秒超时 |
## 13. 推荐排查顺序
按下面顺序执行,通常一次复现就能缩小范围:
1. 浏览器保存 `/threads`、`runs/stream`、`history` 时间线。
2. 查看 `/threads` 的 `Server-Timing`。
3. 用同一 `thread_id/run_id` 对齐 Gateway 日志。
4. 卡顿发生时立即保存 `pg_stat_activity` 和 blocker 快照。
5. 查询实际索引与表统计,不依赖代码声明猜测生产 schema。
6. 对慢 thread 比较两条 History EXPLAIN。
7. 只有证据显示查询计划问题时才加索引;只有 SQL 已很快而接口仍等待时,才优先改 saver/应用锁路径。
@@ -1,6 +1,6 @@
# 技能并发与流式修复:配置、前端包与验收说明
日期:2026-09-27。
日期:2026-09-27(2026-09-28 补充高并发运行配置)。
本说明对应本次提交的修改,包含模型流式兼容与恢复、React #185 错误处理和已完成的最小技能示例,
以及可选的技能视图复用、版本缓存、模型计时日志队列和 Gateway 线程池扩容。
@@ -10,7 +10,7 @@
- **后台代码也要更新**:四个新开关需要新版后台读取。只换前端 dist 或只把字段写进旧版 config.yaml 不会实现后台优化。
- **前端**:使用本说明末尾的 ZIP,里面是完整 `dist/`,不是源码或 node_modules。
- **默认不启用优化**:仓库两份 config.yaml 的四个新开关均为 `false`。只有主动改为 `true` 才启用对应项。
- **仓库运行配置已开启优化**:两份 config.yaml 的四个开关均已显式设为 `true`。但已有服务器的持久化/外挂配置不会被仓库自动覆盖,仍需手工同步。
- 开启线程池开关后可将上限调整为 100,但不提高模型服务商配额、不启用多 worker。
## 2. .env 需要修改哪些项
@@ -54,8 +54,9 @@ DEER_FLOW_ASYNCIO_THREAD_POOL_SIZE=100
## 3. config.yaml 新增配置
在实际加载的 `config.yaml` **顶层**新增一段,不能缩进到 `models`、`sandbox` 或 `skills` 内,
也不要重复定义两个 `runtime_performance`:
在实际加载的 `config.yaml` **顶层**配置这一段,不能缩进到 `models`、`sandbox` 或 `skills` 内,
也不要重复定义两个 `runtime_performance`。下面是程序在字段缺失时的兼容性默认值;
当前仓库的两份可运行配置已使用后文的高并发开启值:
```yaml
runtime_performance:
@@ -92,7 +93,7 @@ runtime_performance:
- 已设置 `DEER_FLOW_CONFIG_PATH` 时,以它指向的文件为准;Docker 内该值必须是容器内可访问路径。
- 本仓库包含 `offline-backend-20260512/config.yaml` 和 `offline-backend-20260512/backend/config.yaml`;
两份都已补齐默认关闭的示例,但服务器要修改的是启动时实际加载/挂载的那一份。
两份已同步为高并发开启值,但服务器要修改的仍是启动时实际加载/挂载的那一份。
- 启动日志 `Loaded AppConfig from ...` 可确认路径,`RUNTIME_PERFORMANCE` 可确认实际开关值。
## 4. 开启后做了什么,没做什么
@@ -112,6 +113,9 @@ runtime_performance:
没有采用会延迟下线的 TTL 缓存;下一次工具调用能发现下线或无效配置并拒绝访问。
已经开始执行的工具不因此被强行中断。
缓存仅用于标准 `LocalSkillStorage`,自定义存储实现/子类保守回到原刷新路径。
同一 Gateway 进程内,仅在多个热命中调用真正重叠时合并为一次元数据校验;
该批调用结束后结果立即丢弃,新调用仍重新校验。启停文件哈希不同的调用不会加入旧校验,
校验异常会对该批调用统一 fail-closed,且不会阻止后续调用重试。
热路径仍有 O(文件数) 元数据校验,慢磁盘/网络挂载仍可能耗时;这不是“所有文件操作零开销”,
也没有把受锁保护的重建变成无锁写入。
@@ -0,0 +1,73 @@
# 问答停在执行命令或读取技能:排查与修复
日期:2026-09-28。
本次检查针对普通问答和智能体问答:页面停在工具步骤,上游模型没有收到下一轮请求。
以下是代码审查和受控回归确认的触发路径;尚未收到具体生产对话 ID 和现场日志,不能据此认定某次现场故障只有一个根因。
## 已修复的等待路径
| 位置 | 原来的触发条件 | 当前行为 |
| --- | --- | --- |
| LocalSandbox 命令执行 | `subprocess.run` 超时仅终止外层 shell,后代仍持有输出管道;Windows 超时收尾继续无期限 `communicate` | 独立进程组、关闭交互输入、临时文件采集输出;超时清理进程树并限制回收等待 |
| 技能视图刷新 | 进程锁、跨进程文件锁和等待并发校验结果均没有统一等待上限 | 等待最多 30 秒后抛出明确工具错误;不删除正在执行的刷新,不使用未经校验的旧视图 |
| AIO 沙箱 | 上次命令未返回时,后续命令或文件操作无限等待同一把锁 | 锁等待 30 秒后失败;不抢占旧请求的锁,不自动重复执行命令 |
| 文件分段读取 | 超长单行先读完整行才裁剪;FIFO/设备文件可能一直等数据 | 达到当前行输出预算即返回;本地读取用非阻塞打开并检查普通文件,远端分段读取同步限制 |
| 前端结束恢复 | 后端已 success/error,但旧 SSE 仍开着,SDK `isLoading` 一直为真;整段 SSE 期间的 `sendInFlight` 又把同一轮结束快照丢掉 | 核对轮次及运行 ID 后清理重连标记、关闭本地 reader、应用结束快照;不因此取消后端或覆盖新一轮 |
| 前端恢复查询 | `/runs`、检查点或历史请求悬挂,占着 single-flight 恢复槽 | 普通 SDK 请求设置 30 秒超时;恢复失败仍保留后续检查机会,SSE 不受此总时长限制 |
| 后端结束收尾 | 图已返回,但检查点检查、状态写入、日志刷新或标题同步永远不返回,导致 `end` 发不出去 | 终态检查、终态状态写入分别有 10 秒预算;后续元数据收尾共享 10 秒预算,独立 finally 发出 `end` |
技能锁超时走原有工具错误处理链,模型可收到失败结果并决定后续操作;不会把“超时”伪装成成功。
元数据收尾超时只发生在图已经结束后,不给整个研究/子智能体执行增加统一短时限。
终态检查点超时会报告运行失败,不能直接当成模型成功。
## 配置和上线
本次需同时更新后端代码和前端构建产物,重启后端后生效。只刷新页面或调线程数不能替代修复。
本次未操作生产服务、未发布前端包;正式离线发版仍使用独立的 `deerflow-offline-deployment` 仓库。
本地命令保留原来的 600 秒默认执行时限,新版允许在实际加载的配置文件中调整:
```yaml
sandbox:
# 合并到已有 sandbox 段中,保留 use、mounts 等既有字段。
command_timeout_seconds: 600
```
该字段只控制 LocalSandbox 命令执行,必须为有限正数。未配置仍为 600 秒;不要把它误认为模型请求超时。
命令输出每个流最多读入 1 MiB 头尾内容,再经过既有工具输出裁剪。
运行中的命令约每 100ms 检查累计捕获输出,超过 16 MiB 请求终止;这是采样护栏,不是磁盘硬配额。
正常退出后的显式后台进程需要自行重定向输出和管理生命周期。
前端已有的无业务事件检查窗口仍为 90 秒;这是启动后台状态核对的时机,不是宣判任务失败的时限。
30 秒控制请求超时不影响合法的长时间 SSE 和工具执行。
## 仍需现场证据区分的情况
- 磁盘/NFS 本身阻塞的刷新执行者仍可能卡在操作系统 I/O;本次限制的是其他调用的等待,不宣称能终止任意内核 I/O。
- 自定义技能脚本自身请求外部服务不返回,仍会等到本地命令时限;建议技能内各 HTTP 请求也设置连接/读取超时。
- 整个图中的第三方异步工具、模型前中间件和检查点操作没有统一的“无事件即取消”规则,避免误杀合法研究任务。
- 检查点回滚是实际数据修改,保持原有完成/取消语义,不把仍在执行的回滚丢到后台后开放下一轮。
- 已提交给数据库的单条写入未必能被驱动撤销;取消和新轮次检查可阻止旧清理继续发起后续写入,不能保证撤回已经执行的 SQL。
再次出现时,记录对话 ID、运行 ID、最后一步和时间,并对照上游请求日志、运行状态及
`RUN_COMPLETION_TIMEOUT run_id=... phase=terminal_checkpoint|terminal_status|metadata_cleanup`。
技能锁超时会包含 `Timed out waiting for skill view ...`;沙箱命令超时会包含 `Command timed out`。
不要仅凭页面最后显示“读取技能”就断定仍在读文件,它也可能是结束消息没有传到浏览器。
## 回归入口
- 后端 `packages/harness/tests/test_skill_view_waits.py`:锁竞争和并发校验超时、失败后恢复。
- 后端 `packages/harness/tests/test_skill_view_cache.py`、`test_skill_view_tool_reuse.py`:既有启停与缓存安全性。
- 后端 `packages/harness/tests/test_sandbox_command_liveness.py`:真实父子进程、输出、非交互输入及特殊文件/长行。
- 后端 `packages/harness/tests/test_run_completion_timeout.py`:挂起的结束收尾、取消、新一轮隔离及正常长图执行。
- 前端 `src/core/api/control-timeout.test.ts`、`src/core/threads/stream-terminal-recovery.test.ts`:控制请求超时及真实 SDK reader 恢复。
- 前端既有 `stream-recovery`、`stream-display`、`stream-reasoning-replay`、`stream-cursor` 测试继续验证断流重连和消息显示。
## 本机验证结果
- 后端 `python -m pytest packages/harness/tests -q`:**263 passed**。旧同步日志断言补了显式测试配置,避免当前启用异步日志时与后台队列竞速;异步日志仍由独立测试覆盖,未改实际运行配置。
- 前端八个相关测试文件打包后用 Node test runner 执行:**31 passed**;新增测试类型修正后其中三个新增用例再次通过。
- 修改的后端代码和测试通过 Ruff;`git diff --check` 通过。
- 完整前端类型检查未通过:**75 个 TypeScript 错误**,均在本次未修改的文件,涉及 `nextra` 缺失、旧 JSX 类型、既有测试类型等。本次修改文件及新增测试无诊断。详细输出保留在 `frontend-web/.runtime/hang-typecheck.log`(本机文件,不作为发版资产)。
- 命令进程树测试在 Windows 使用真实子进程;Linux 进程组分支已审查,但本机未做 Linux 实跑,AIO 未连接实际容器。未用生产模型调用或真实业务技能执行验证。
+831
View File
@@ -0,0 +1,831 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>数据广场 DataFeed · 仿 Facebook 风格数据展示</title>
<style>
:root{
--fb-blue:#1877f2; --fb-blue-dark:#166fe5; --bg:#f0f2f5;
--card:#fff; --text:#1c1e21; --sub:#65676b; --weak:#8a8d91;
--divider:#e4e6eb; --hover:#f0f2f5; --hover2:#e4e6eb;
--green:#31a24c; --red:#e41e3f; --yellow:#f7b928;
--shadow:0 1px 2px rgba(0,0,0,.2);
}
*{box-sizing:border-box;margin:0;padding:0}
html{scrollbar-gutter:stable}
body{
background:var(--bg);color:var(--text);
font-family:"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",Helvetica,Arial,sans-serif;
font-size:15px;-webkit-font-smoothing:antialiased;
}
a{color:inherit;text-decoration:none}
button{font-family:inherit}
/* ============ 顶部导航(Facebook 蓝条) ============ */
.topbar{
position:fixed;top:0;left:0;right:0;height:56px;z-index:100;
background:var(--fb-blue);display:flex;align-items:center;justify-content:space-between;
padding:0 16px;gap:8px;
}
.tb-left{display:flex;align-items:center;gap:10px;flex:1;min-width:0}
.logo{
width:40px;height:40px;border-radius:50%;flex:none;
background:#fff;color:var(--fb-blue);font-size:22px;font-weight:800;
display:grid;place-items:center;cursor:pointer;
}
.search{position:relative}
.search svg{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--sub);pointer-events:none}
.search input{
width:250px;max-width:44vw;height:40px;border:none;border-radius:20px;outline:none;
background:rgba(255,255,255,.96);padding:0 14px 0 38px;font-size:15px;color:var(--text);
}
.search input::placeholder{color:var(--weak)}
.tb-center{display:flex;gap:4px;flex:none}
.tb-nav{
width:48px;height:48px;border-radius:10px;display:grid;place-items:center;
color:#dfe9fb;position:relative;cursor:pointer;
}
.tb-nav svg{width:26px;height:26px}
.tb-nav:hover,.tb-nav.active{background:rgba(255,255,255,.18);color:#fff}
.tb-right{display:flex;align-items:center;gap:8px;flex:1;justify-content:flex-end}
.icon-btn{
width:40px;height:40px;border-radius:50%;position:relative;flex:none;
background:rgba(255,255,255,.18);display:grid;place-items:center;color:#fff;cursor:pointer;
}
.icon-btn:hover{background:rgba(255,255,255,.28)}
.icon-btn svg{width:22px;height:22px}
.badge{
position:absolute;top:-3px;right:-3px;min-width:18px;height:18px;padding:0 4px;
border-radius:9px;background:var(--red);color:#fff;font-size:11px;font-weight:700;
display:grid;place-items:center;border:2px solid var(--fb-blue);
}
.me{
width:40px;height:40px;border-radius:50%;border:none;flex:none;cursor:pointer;
background:linear-gradient(135deg,#42a5f5,#0d47a1);color:#fff;font-weight:600;font-size:15px;
}
/* ============ 三栏布局 ============ */
.layout{
max-width:1284px;margin:0 auto;display:grid;gap:16px;
grid-template-columns:280px minmax(0,680px) 300px;
padding:72px 12px 40px;align-items:start;
}
.side{position:sticky;top:72px;max-height:calc(100vh - 88px);overflow-y:auto;padding:4px 2px}
.side::-webkit-scrollbar{width:8px}
.side::-webkit-scrollbar-thumb{background:#cfd2d6;border-radius:4px}
.side::-webkit-scrollbar-thumb:hover{background:#b6b9be}
/* ---- 左栏 ---- */
.profile-row{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;cursor:pointer}
.profile-row:hover{background:var(--hover)}
.profile-row b{font-size:15px}
.profile-row .sub{font-size:12.5px;color:var(--sub)}
.menu{margin-top:4px}
.mi{
display:flex;align-items:center;gap:12px;padding:7px 10px;border-radius:8px;
font-weight:600;font-size:15px;cursor:pointer;user-select:none;
}
.mi:hover{background:var(--hover)}
.mi .ic{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fb-blue);flex:none}
.mi .ic svg{width:22px;height:22px}
.mi .cnt{
margin-left:auto;background:#d8dadf;color:var(--text);font-size:12px;font-weight:700;
border-radius:10px;padding:1px 8px;
}
.sq{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;font-size:18px;flex:none}
.sq-blue{background:#e7f3ff}.sq-green{background:#e6f7ea}.sq-orange{background:#fff3d6}.sq-purple{background:#f0e9ff}
.hr{border:none;border-top:1px solid var(--divider);margin:10px 12px}
.side-title{font-weight:700;color:var(--weak);font-size:17px;padding:6px 12px 2px}
.side-foot{font-size:12px;color:var(--weak);padding:12px 12px 4px;line-height:1.7}
/* ---- 通用卡片 / 头像 ---- */
.card{background:var(--card);border-radius:8px;box-shadow:var(--shadow)}
.avatar{
width:40px;height:40px;border-radius:50%;display:grid;place-items:center;
color:#fff;font-weight:600;font-size:15px;flex:none;cursor:pointer;
}
.avatar.sm{width:32px;height:32px;font-size:13px}
.av-blue{background:linear-gradient(135deg,#1877f2,#0c56c0)}
.av-green{background:linear-gradient(135deg,#45bd62,#1a7a3a)}
.av-orange{background:linear-gradient(135deg,#f7a325,#d97b06)}
.av-purple{background:linear-gradient(135deg,#8b5cf6,#5b21b6)}
.av-teal{background:linear-gradient(135deg,#14b8a6,#0f766e)}
.sub{color:var(--sub)}
/* ---- 发布器 ---- */
.composer{padding:12px 16px 10px;margin-bottom:16px}
.composer-top{display:flex;gap:10px;align-items:center}
.composer-top input{
flex:1;height:42px;border-radius:21px;border:none;outline:none;
background:var(--hover);padding:0 16px;font-size:15px;color:var(--text);transition:box-shadow .15s,background .15s;
}
.composer-top input:focus{background:#fff;box-shadow:inset 0 0 0 2px var(--fb-blue)}
.composer-actions{display:flex;border-top:1px solid var(--divider);margin-top:10px;padding-top:6px}
.ca{
flex:1;display:flex;align-items:center;justify-content:center;gap:8px;
padding:9px 4px;border-radius:8px;font-weight:600;font-size:14.5px;color:var(--sub);
cursor:pointer;user-select:none;background:none;border:none;
}
.ca:hover{background:var(--hover)}
.ca svg{width:22px;height:22px}
.ca.c-blue{color:var(--fb-blue)}.ca.c-green{color:var(--green)}.ca.c-orange{color:#f0940a}
/* ---- 数据专题(Stories) ---- */
.stories{display:flex;gap:8px;overflow-x:auto;padding:12px;margin-bottom:16px;scrollbar-width:none}
.stories::-webkit-scrollbar{display:none}
.story{
flex:0 0 110px;height:190px;border-radius:14px;position:relative;overflow:hidden;
cursor:pointer;color:#fff;display:flex;flex-direction:column;justify-content:flex-end;
padding:10px;transition:transform .15s;
}
.story:hover{transform:scale(1.02)}
.story .big{position:absolute;top:44px;left:0;right:0;text-align:center;font-size:40px}
.story .av{position:absolute;top:8px;left:8px;width:30px;height:30px;border-radius:50%;border:2px solid rgba(255,255,255,.9)}
.story .nm{font-size:12.5px;font-weight:700;text-shadow:0 1px 3px rgba(0,0,0,.45);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.story.create{background:#fff;align-items:center;justify-content:flex-start;color:var(--text);padding-top:14px}
.story.create:hover{background:var(--hover)}
.story.create .plus{
width:44px;height:44px;border-radius:50%;background:var(--fb-blue);color:#fff;
display:grid;place-items:center;font-size:26px;font-weight:300;margin-bottom:8px;
}
.story.create span{font-size:13px;font-weight:600}
/* ---- 帖子(数据卡片) ---- */
.post{margin-bottom:16px}
.post-hd{display:flex;align-items:center;gap:10px;padding:12px 16px 0}
.post-hd .meta{min-width:0;flex:1}
.post-hd .meta b{font-size:15px;cursor:pointer}
.post-hd .meta b:hover{text-decoration:underline}
.post-hd .meta .sub{font-size:13px;display:flex;align-items:center;gap:4px}
.post-hd .more{color:var(--sub);cursor:pointer;padding:4px 8px;border-radius:50%;font-weight:700}
.post-hd .more:hover{background:var(--hover)}
.post-txt{padding:10px 16px 12px;line-height:1.55;font-size:15px}
.post-txt a{color:var(--fb-blue);font-weight:600}
.chart-wrap{padding:0 16px}
.chart-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;padding:0 16px 6px}
.chart-title{font-size:15px;font-weight:700}
.legend{display:flex;gap:14px;font-size:13px;color:var(--sub);align-items:center;flex-wrap:wrap}
.dot{width:10px;height:10px;border-radius:3px;display:inline-block;margin-right:5px;vertical-align:-1px}
.pills{display:flex;gap:6px}
.pill{
border:none;cursor:pointer;padding:4px 14px;border-radius:20px;
background:var(--hover);color:var(--sub);font-size:13px;font-weight:600;
}
.pill.active{background:#e7f3ff;color:var(--fb-blue)}
.summary{font-size:13px;color:var(--sub);padding:6px 16px 4px}
/* KPI 四宫格 */
.kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;padding:2px 16px 14px}
.kpi{background:#f7f8fa;border:1px solid var(--divider);border-radius:10px;padding:10px 12px}
.kpi .lab{font-size:12.5px;color:var(--sub);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.kpi .num{font-size:20px;font-weight:800;letter-spacing:-.3px;margin:3px 0 2px;white-space:nowrap}
.delta{font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:2px}
.up{color:var(--green)}.down{color:var(--red)}
.spark{width:100%;height:26px;display:block;margin-top:4px}
/* 柱状图 */
.bars{display:flex;align-items:flex-end;gap:14px;height:175px;padding:0 6px;margin-top:4px}
.bcol{flex:1;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;gap:6px}
.bval{font-size:12px;font-weight:700;color:var(--sub)}
.bar{
width:100%;max-width:44px;border-radius:6px 6px 2px 2px;cursor:pointer;
background:linear-gradient(180deg,#4b95f5,#1877f2);transition:filter .15s;
}
.bar:hover{filter:brightness(1.12)}
.bar.top{background:linear-gradient(180deg,#1877f2,#0a4bb3)}
.bname{font-size:12px;color:var(--sub);white-space:nowrap}
/* 环形图 + 图例 */
.split{display:grid;grid-template-columns:210px 1fr;gap:8px;align-items:center;padding:2px 16px 14px}
.donut{width:180px;height:180px;margin:0 auto;display:block}
.lg-row{display:flex;align-items:center;gap:10px;padding:7px 0;font-size:14px;border-bottom:1px dashed #f0f2f5}
.lg-row:last-child{border-bottom:none}
.lg-row .nm{flex:1}
.lg-row .v{font-weight:700}
.lg-row .pc{color:var(--sub);font-size:13px;width:44px;text-align:right}
/* 表格 */
.tbl-wrap{overflow-x:auto}
.tbl{width:100%;border-collapse:collapse;font-size:14px;min-width:440px}
.tbl th{
text-align:left;font-size:13px;font-weight:600;color:var(--sub);
padding:8px 16px;border-bottom:1px solid var(--divider);white-space:nowrap;
}
.tbl td{padding:10px 16px;border-bottom:1px solid #f0f2f5;white-space:nowrap}
.tbl tr:last-child td{border-bottom:none}
.tbl tbody tr:hover{background:#f7f9fc}
.rank{width:22px;height:22px;border-radius:6px;display:grid;place-items:center;font-size:12px;font-weight:800;color:#fff}
.r1{background:#f7b928}.r2{background:#aeb3ba}.r3{background:#d08a4e}.rx{background:#e4e6eb;color:var(--sub)}
/* 互动条 */
.post-stats{display:flex;align-items:center;justify-content:space-between;padding:10px 16px 6px;color:var(--sub);font-size:14.5px}
.react{
width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 0 0 1px var(--divider);
display:inline-grid;place-items:center;font-size:12px;margin-right:5px;vertical-align:-4px;
}
.actions{display:flex;border-top:1px solid var(--divider);padding:4px 6px}
.act{
flex:1;display:flex;align-items:center;justify-content:center;gap:8px;
padding:9px 4px;border-radius:8px;color:var(--sub);font-weight:600;font-size:14.5px;
cursor:pointer;user-select:none;background:none;border:none;
}
.act:hover{background:var(--hover)}
.act svg{width:20px;height:20px}
.act.liked{color:var(--fb-blue)}
/* 评论 */
.comments{padding:2px 16px 14px}
.cmt-more{color:var(--sub);font-size:14px;font-weight:600;padding:4px 0 8px;cursor:pointer}
.cmt-more:hover{text-decoration:underline}
.cmt{display:flex;gap:8px;margin-bottom:10px}
.cmt-bubble{background:var(--hover);border-radius:18px;padding:8px 13px;font-size:14px;line-height:1.45;max-width:100%}
.cmt-name{font-weight:700;font-size:13px;display:block;margin-bottom:1px}
.cmt-meta{font-size:12.5px;color:var(--sub);padding:3px 13px 0;display:flex;gap:8px}
.cmt-meta a{cursor:pointer}
.cmt-meta a:hover{text-decoration:underline;color:var(--text)}
.cmt-input{display:flex;gap:8px;align-items:center;margin-top:4px}
.cmt-input input{
flex:1;height:38px;border-radius:19px;border:none;outline:none;background:var(--hover);
padding:0 14px;font-size:14px;
}
.cmt-input input:focus{background:#fff;box-shadow:inset 0 0 0 2px var(--fb-blue)}
/* ---- 右栏 ---- */
.rc{padding:14px 12px;margin-bottom:16px}
.rc h4{font-size:16.5px;font-weight:700;color:var(--weak);margin:0 4px 8px;letter-spacing:.2px}
.rc-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;cursor:pointer}
.rc-item:hover{background:var(--hover)}
.rc-item .tt{font-size:14px;font-weight:600;line-height:1.3}
.rc-item .dd{font-size:12.5px;color:var(--sub)}
.rc-thumb{
width:96px;height:66px;border-radius:8px;flex:none;display:grid;place-items:center;
font-size:26px;color:#fff;
}
.rc-status{width:9px;height:9px;border-radius:50%;flex:none}
.on{background:var(--green)}.off{background:#bcc0c4}.pulse{background:var(--green);animation:pu 1.4s infinite}
@keyframes pu{0%{box-shadow:0 0 0 0 rgba(49,162,76,.5)}70%{box-shadow:0 0 0 7px rgba(49,162,76,0)}100%{box-shadow:0 0 0 0 rgba(49,162,76,0)}}
.rc-row{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:8px;cursor:pointer}
.rc-row:hover{background:var(--hover)}
.rc-row b{font-size:14.5px;font-weight:600}
.rc-row .sub{font-size:12.5px}
.rc-row .tail{margin-left:auto;font-size:12.5px;color:var(--sub);white-space:nowrap}
/* 空结果 / Toast */
.noresult{display:none;text-align:center;color:var(--sub);padding:40px 0;font-size:15px}
.toast{
position:fixed;left:50%;bottom:28px;transform:translateX(-50%) translateY(90px);
background:#1c1e21;color:#fff;padding:11px 22px;border-radius:10px;font-size:14px;
opacity:0;transition:all .25s;z-index:300;pointer-events:none;box-shadow:0 4px 14px rgba(0,0,0,.3);
}
.toast.show{transform:translateX(-50%);opacity:1}
/* ---- 响应式 ---- */
@media (max-width:1160px){
.layout{grid-template-columns:240px minmax(0,1fr)}
.side-r{display:none}
}
@media (max-width:920px){
.layout{grid-template-columns:minmax(0,1fr);padding-top:64px}
.side-l{display:none}
.tb-center{display:none}
}
@media (max-width:600px){
.kpis{grid-template-columns:repeat(2,1fr)}
.split{grid-template-columns:1fr}
.stories{padding:10px}
.composer-actions .ca span{display:none}
}
</style>
</head>
<body>
<!-- ================= 顶部导航 ================= -->
<header class="topbar">
<div class="tb-left">
<a class="logo" title="DataFeed 数据广场">数</a>
<div class="search">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><line x1="16.5" y1="16.5" x2="21" y2="21"/></svg>
<input id="globalSearch" type="text" placeholder="搜索数据、指标、报表…" autocomplete="off">
</div>
</div>
<nav class="tb-center">
<a class="tb-nav active" title="首页"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3.1 3 10.2V21h6.2v-6h5.6v6H21V10.2z"/></svg></a>
<a class="tb-nav" title="实时看板"><svg viewBox="0 0 24 24" fill="currentColor"><rect x="2.3" y="5" width="19.4" height="14" rx="4" fill="none" stroke="currentColor" stroke-width="2"/><path d="M10 9.1v5.8L15.4 12z"/></svg></a>
<a class="tb-nav" title="数据集市"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 4h3l2.5 11a1.6 1.6 0 0 0 1.6 1.3h8.2a1.6 1.6 0 0 0 1.6-1.3L21 8H6.2"/><circle cx="10" cy="20" r="1.5" fill="currentColor"/><circle cx="17.2" cy="20" r="1.5" fill="currentColor"/></svg></a>
<a class="tb-nav" title="数据小组"><svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="8.6" r="3.3"/><path d="M3 19.2c.5-3.1 3-4.9 6-4.9s5.5 1.8 6 4.9z"/><circle cx="16.9" cy="7.8" r="2.5"/><path d="M15.6 13.7c2.7.2 4.8 1.9 5.3 4.5h-4.4a7 7 0 0 0-.9-4.5z"/></svg></a>
</nav>
<div class="tb-right">
<a class="icon-btn" title="功能菜单"><svg viewBox="0 0 24 24" fill="currentColor"><circle cx="6" cy="6" r="1.9"/><circle cx="12" cy="6" r="1.9"/><circle cx="18" cy="6" r="1.9"/><circle cx="6" cy="12" r="1.9"/><circle cx="12" cy="12" r="1.9"/><circle cx="18" cy="12" r="1.9"/><circle cx="6" cy="18" r="1.9"/><circle cx="12" cy="18" r="1.9"/><circle cx="18" cy="18" r="1.9"/></svg></a>
<a class="icon-btn" title="消息"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3C6.7 3 2.5 6.8 2.5 11.4c0 2.6 1.4 5 3.6 6.6L5.2 22l4.2-2.1c.8.2 1.7.3 2.6.3 5.3 0 9.5-3.8 9.5-8.5S17.3 3 12 3z"/></svg><span class="badge">3</span></a>
<a class="icon-btn" title="通知"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3a5.8 5.8 0 0 0-5.8 5.8v3.6L4.6 15a1 1 0 0 0 .9 1.5h13a1 1 0 0 0 .9-1.5l-1.6-2.6V8.8A5.8 5.8 0 0 0 12 3z"/><path d="M9.8 19a2.3 2.3 0 0 0 4.4 0z"/></svg><span class="badge">5</span></a>
<button class="me" title="张分析 · 数据分析师">张</button>
</div>
</header>
<main class="layout">
<!-- ================= 左栏 ================= -->
<aside class="side side-l">
<div class="profile-row">
<div class="avatar av-blue">张</div>
<div><b>张分析</b><div class="sub">数据分析师 · 增长中心</div></div>
</div>
<nav class="menu">
<a class="mi"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3.5" y="3.5" width="7.5" height="7.5" rx="2"/><rect x="13" y="3.5" width="7.5" height="7.5" rx="2"/><rect x="3.5" y="13" width="7.5" height="7.5" rx="2"/><rect x="13" y="13" width="7.5" height="7.5" rx="2"/></svg></span>数据概览</a>
<a class="mi"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 13h3.2l2-5.2 3.5 9 2.2-7 1.5 3.2H21"/></svg></span>实时监控<span class="cnt">3</span></a>
<a class="mi"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M6 2.5h8L19 8v13.5H6z"/><path d="M14 2.5V8h5M9 12.5h7M9 16h7"/></svg></span>报表中心</a>
<a class="mi"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5.5" rx="7.5" ry="3"/><path d="M4.5 5.5v13c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-13"/><path d="M4.5 12c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3"/></svg></span>数据源管理</a>
<a class="mi"><span class="ic"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.8l2.6 5.6 6.1.7-4.5 4.2 1.2 6-5.4-3-5.4 3 1.2-6L3.3 9.1l6.1-.7z"/></svg></span>我的收藏</a>
<a class="mi"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4.5 6.5h15M9.5 6V4.2A1.2 1.2 0 0 1 10.7 3h2.6a1.2 1.2 0 0 1 1.2 1.2V6"/><path d="M7 6.5l.8 13a1.5 1.5 0 0 0 1.5 1.4h5.4a1.5 1.5 0 0 0 1.5-1.4l.8-13"/></svg></span>回收站</a>
</nav>
<hr class="hr">
<div class="side-title">快捷方式</div>
<nav class="menu">
<a class="mi"><span class="sq sq-blue">🛒</span>电商大促</a>
<a class="mi"><span class="sq sq-green">📈</span>用户增长</a>
<a class="mi"><span class="sq sq-orange">📣</span>营销投放</a>
<a class="mi"><span class="sq sq-purple">🎧</span>客服质量</a>
</nav>
<hr class="hr">
<div class="side-title">我的小组</div>
<nav class="menu">
<a class="mi"><span class="sq sq-blue">👥</span>增长数据组</a>
<a class="mi"><span class="sq sq-green">🧪</span>实验平台组</a>
</nav>
<div class="side-foot">隐私 · 条款 · 广告 · Cookie<br>DataFeed © 2026 · 仿 Facebook 风格演示页</div>
</aside>
<!-- ================= 中间信息流 ================= -->
<section class="feed">
<!-- 发布器(顶部搜索/筛选) -->
<div class="card composer">
<div class="composer-top">
<div class="avatar av-blue">张</div>
<input id="feedFilter" type="text" placeholder="筛选下方数据卡片,如:趋势 / 渠道 / 排行 / KPI…" autocomplete="off">
</div>
<div class="composer-actions">
<button class="ca c-blue" data-toast="演示页面:实时看板未接入"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2.3" y="5" width="19.4" height="14" rx="4"/><path d="M10 9.1v5.8L15.4 12z" fill="currentColor" stroke="none"/></svg><span>实时看板</span></button>
<button class="ca c-green" data-toast="演示页面:导入报表未接入"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="6.5" width="18" height="13.5" rx="3"/><circle cx="12" cy="13" r="3.6"/><path d="M8 6.5 9.3 4h5.4L16 6.5"/></svg><span>导入报表</span></button>
<button class="ca c-orange" data-toast="演示页面:智能分析未接入"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="8.5"/><circle cx="9" cy="10" r="1.1" fill="currentColor" stroke="none"/><circle cx="15" cy="10" r="1.1" fill="currentColor" stroke="none"/><path d="M8.5 14.5c1 1.2 2.1 1.8 3.5 1.8s2.5-.6 3.5-1.8"/></svg><span>智能分析</span></button>
</div>
</div>
<!-- 数据专题(Stories) -->
<div class="card stories" id="stories">
<div class="story create"><div class="plus">+</div><span>创建专题</span></div>
<div class="story" style="background:linear-gradient(165deg,#166fe5,#0a3d91)"><span class="av avatar av-orange">促</span><div class="big">🛒</div><div class="nm">双 11 作战室</div></div>
<div class="story" style="background:linear-gradient(165deg,#f7b928,#dd7d0a)"><span class="av avatar av-blue">渠</span><div class="big">📣</div><div class="nm">渠道复盘</div></div>
<div class="story" style="background:linear-gradient(165deg,#45bd62,#177a37)"><span class="av avatar av-green">像</span><div class="big">👥</div><div class="nm">用户画像</div></div>
<div class="story" style="background:linear-gradient(165deg,#fa383e,#a80f26)"><span class="av avatar av-teal">留</span><div class="big">📈</div><div class="nm">留存专题</div></div>
<div class="story" style="background:linear-gradient(165deg,#8b5cf6,#4c1d95)"><span class="av avatar av-purple">实</span><div class="big">🧪</div><div class="nm">A/B 实验</div></div>
</div>
<!-- 卡片 1:今日核心指标 KPI -->
<article class="card post" data-key="kpi 核心指标 概览 活跃用户 注册 订单 gmv dashboard">
<div class="post-hd">
<div class="avatar av-blue">数</div>
<div class="meta">
<b>数据中心</b>
<div class="sub">12 分钟前 · <svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm-1 5h2v6h-2zm0 8h2v2h-2z" opacity=".9"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm0 18.2c-4.5 0-8.2-3.7-8.2-8.2S7.5 3.8 12 3.8 20.2 7.5 20.2 12 16.5 20.2 12 20.2z"/></svg> 公开</div>
</div>
<span class="more">···</span>
</div>
<div class="post-txt">📊 <b>今日核心指标速览</b>(截至今日 16:00):活跃与 GMV 延续双位数增长,订单量小幅回落,详见 <a>#每日简报</a></div>
<div class="kpis">
<div class="kpi">
<div class="lab">今日活跃用户</div>
<div class="num">42.8 万</div>
<span class="delta up">▲ 12.5%</span>
<svg class="spark" viewBox="0 0 100 28" preserveAspectRatio="none"><polyline points="0,22 14,19 28,21 42,15 56,17 70,11 84,13 100,6" fill="none" stroke="#31a24c" stroke-width="2"/></svg>
</div>
<div class="kpi">
<div class="lab">新增注册</div>
<div class="num">8,642</div>
<span class="delta up">▲ 3.2%</span>
<svg class="spark" viewBox="0 0 100 28" preserveAspectRatio="none"><polyline points="0,20 14,21 28,16 42,17 56,14 70,15 84,9 100,7" fill="none" stroke="#31a24c" stroke-width="2"/></svg>
</div>
<div class="kpi">
<div class="lab">订单量</div>
<div class="num">15,237</div>
<span class="delta down">▼ 2.4%</span>
<svg class="spark" viewBox="0 0 100 28" preserveAspectRatio="none"><polyline points="0,8 14,12 28,10 42,15 56,14 70,18 84,17 100,21" fill="none" stroke="#e41e3f" stroke-width="2"/></svg>
</div>
<div class="kpi">
<div class="lab">GMV(成交总额)</div>
<div class="num">¥382.6 万</div>
<span class="delta up">▲ 8.1%</span>
<svg class="spark" viewBox="0 0 100 28" preserveAspectRatio="none"><polyline points="0,18 14,20 28,16 42,17 56,12 70,14 84,8 100,4" fill="none" stroke="#31a24c" stroke-width="2"/></svg>
</div>
</div>
<div class="post-stats">
<div><span class="react">👍</span><span class="like-num" data-count="1286">1,286</span></div>
<div><span class="cmt-num" data-count="86">86 条评论</span> · <span>42 次分享</span></div>
</div>
<div class="actions">
<button class="act act-like"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.5 21H5.2a1.2 1.2 0 0 1-1.2-1.2v-8.6c0-.7.5-1.2 1.2-1.2h3.3zM9.7 10.2 13 3.5c1.4.2 2.2 1.2 2.2 2.7V9h4.4c1.3 0 2.2 1.1 1.9 2.3l-1.5 6.7c-.3 1.2-1.1 3-2.5 3H9.7z"/></svg>赞</button>
<button class="act act-comment"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3C6.5 3 2 6.9 2 11.7c0 2.8 1.4 5.3 3.7 7L5 22l4.3-2c.9.2 1.8.4 2.7.4 5.5 0 10-3.9 10-8.7S17.5 3 12 3z"/></svg>评论</button>
<button class="act act-share"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M13.5 4.5 21 11l-7.5 6.5v-3.9C8 13.7 5.2 15.6 3.5 19.5c.2-6.7 4-10.5 10-11.4z"/></svg>分享</button>
</div>
<div class="comments">
<div class="cmt-more">查看全部 <span class="cmt-total">86</span> 条评论</div>
<div class="cmt">
<div class="avatar av-orange sm">王</div>
<div>
<div class="cmt-bubble"><span class="cmt-name">运营小王</span>GMV 涨势不错,双 11 前继续冲 🚀</div>
<div class="cmt-meta"><a>赞</a><a>回复</a><span>42 分钟前</span></div>
</div>
</div>
<div class="cmt">
<div class="avatar av-blue sm">数</div>
<div>
<div class="cmt-bubble"><span class="cmt-name">数据中心</span>回复<span style="color:var(--fb-blue);font-weight:600">@运营小王</span>:建议同步关注订单量下滑,可能是支付成功率波动</div>
<div class="cmt-meta"><a>赞</a><a>回复</a><span>35 分钟前</span></div>
</div>
</div>
<div class="cmt-input">
<div class="avatar av-blue sm">张</div>
<input type="text" placeholder="撰写评论…(回车发送)">
</div>
</div>
</article>
<!-- 卡片 2:流量趋势折线图 -->
<article class="card post" data-key="趋势 流量 访问 折线 7天 30天 trend">
<div class="post-hd">
<div class="avatar av-green">增</div>
<div class="meta">
<b>增长分析组</b>
<div class="sub">1 小时前 · 内部可见</div>
</div>
<span class="more">···</span>
</div>
<div class="post-txt">📈 <b>平台访问量趋势</b>:今日访问创近期新高,较昨日同期 +22%,大促预热流量开始释放 <a>#流量监控</a></div>
<div class="chart-head">
<div class="legend">
<span><span class="dot" style="background:#1877f2"></span>今日</span>
<span><span class="dot" style="background:#b6bcc4"></span>昨日</span>
</div>
<div class="pills">
<button class="pill active" data-range="7">近 7 天</button>
<button class="pill" data-range="30">近 30 天</button>
</div>
</div>
<div class="chart-wrap">
<svg id="trendSvg" viewBox="0 0 640 264" style="width:100%;height:auto;display:block"></svg>
</div>
<div class="summary" id="trendSummary">单位:万次访问</div>
<div class="post-stats">
<div><span class="react">👍</span><span class="like-num" data-count="642">642</span></div>
<div><span>38 条评论</span> · <span>17 次分享</span></div>
</div>
<div class="actions">
<button class="act act-like"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.5 21H5.2a1.2 1.2 0 0 1-1.2-1.2v-8.6c0-.7.5-1.2 1.2-1.2h3.3zM9.7 10.2 13 3.5c1.4.2 2.2 1.2 2.2 2.7V9h4.4c1.3 0 2.2 1.1 1.9 2.3l-1.5 6.7c-.3 1.2-1.1 3-2.5 3H9.7z"/></svg>赞</button>
<button class="act act-comment"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3C6.5 3 2 6.9 2 11.7c0 2.8 1.4 5.3 3.7 7L5 22l4.3-2c.9.2 1.8.4 2.7.4 5.5 0 10-3.9 10-8.7S17.5 3 12 3z"/></svg>评论</button>
<button class="act act-share"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M13.5 4.5 21 11l-7.5 6.5v-3.9C8 13.7 5.2 15.6 3.5 19.5c.2-6.7 4-10.5 10-11.4z"/></svg>分享</button>
</div>
</article>
<!-- 卡片 3:渠道转化率柱状图 -->
<article class="card post" data-key="渠道 转化率 柱状 对比 营销 channel bar">
<div class="post-hd">
<div class="avatar av-orange">营</div>
<div class="meta">
<b>营销实验室</b>
<div class="sub">3 小时前 · 公开</div>
</div>
<span class="more">···</span>
</div>
<div class="post-txt">🎯 <b>各渠道转化率对比</b>(本周 vs 上周):付费广告转化率升至 6.2% 领跑各渠道,邮件营销持续垫底,建议调整预算配比 <a>#渠道分析</a></div>
<div class="chart-head"><div class="chart-title">转化率(%)· 本周</div><div class="legend"><span>最深色为本周最优渠道</span></div></div>
<div class="chart-wrap" style="padding-bottom:14px">
<div class="bars">
<div class="bcol"><span class="bval">4.8</span><div class="bar" style="height:62%" title="自然搜索 4.8%"></div><span class="bname">自然搜索</span></div>
<div class="bcol"><span class="bval">6.2</span><div class="bar top" style="height:80%" title="付费广告 6.2%"></div><span class="bname">付费广告</span></div>
<div class="bcol"><span class="bval">3.9</span><div class="bar" style="height:50%" title="社交媒体 3.9%"></div><span class="bname">社交媒体</span></div>
<div class="bcol"><span class="bval">5.5</span><div class="bar" style="height:71%" title="直接访问 5.5%"></div><span class="bname">直接访问</span></div>
<div class="bcol"><span class="bval">2.1</span><div class="bar" style="height:27%" title="邮件营销 2.1%"></div><span class="bname">邮件营销</span></div>
<div class="bcol"><span class="bval">3.4</span><div class="bar" style="height:44%" title="联盟推广 3.4%"></div><span class="bname">联盟推广</span></div>
</div>
</div>
<div class="post-stats">
<div><span class="react">👍</span><span class="like-num" data-count="318">318</span></div>
<div><span>21 条评论</span> · <span>9 次分享</span></div>
</div>
<div class="actions">
<button class="act act-like"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.5 21H5.2a1.2 1.2 0 0 1-1.2-1.2v-8.6c0-.7.5-1.2 1.2-1.2h3.3zM9.7 10.2 13 3.5c1.4.2 2.2 1.2 2.2 2.7V9h4.4c1.3 0 2.2 1.1 1.9 2.3l-1.5 6.7c-.3 1.2-1.1 3-2.5 3H9.7z"/></svg>赞</button>
<button class="act act-comment"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3C6.5 3 2 6.9 2 11.7c0 2.8 1.4 5.3 3.7 7L5 22l4.3-2c.9.2 1.8.4 2.7.4 5.5 0 10-3.9 10-8.7S17.5 3 12 3z"/></svg>评论</button>
<button class="act act-share"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M13.5 4.5 21 11l-7.5 6.5v-3.9C8 13.7 5.2 15.6 3.5 19.5c.2-6.7 4-10.5 10-11.4z"/></svg>分享</button>
</div>
</article>
<!-- 卡片 4:访问来源占比(环形图) -->
<article class="card post" data-key="来源 分布 占比 环形 donut 用户画像">
<div class="post-hd">
<div class="avatar av-teal">像</div>
<div class="meta">
<b>用户画像中心</b>
<div class="sub">5 小时前 · 公开</div>
</div>
<span class="more">···</span>
</div>
<div class="post-txt">🍩 <b>今日访问来源分布</b>:付费广告占比升至 31%(+4pt),自然流量份额被稀释,SEO 侧需要关注 <a>#来源分析</a></div>
<div class="chart-head"><div class="chart-title">访问来源占比 · 今日</div></div>
<div class="split">
<svg class="donut" viewBox="0 0 160 160">
<g transform="rotate(-90 80 80)">
<circle cx="80" cy="80" r="60" fill="none" stroke="#f0f2f5" stroke-width="26"/>
<circle cx="80" cy="80" r="60" fill="none" stroke="#1877f2" stroke-width="26" stroke-dasharray="155.3 377" stroke-dashoffset="0"/>
<circle cx="80" cy="80" r="60" fill="none" stroke="#45bd62" stroke-width="26" stroke-dasharray="113.9 377" stroke-dashoffset="-158.3"/>
<circle cx="80" cy="80" r="60" fill="none" stroke="#f7b928" stroke-width="26" stroke-dasharray="64.9 377" stroke-dashoffset="-275.2"/>
<circle cx="80" cy="80" r="60" fill="none" stroke="#fa383e" stroke-width="26" stroke-dasharray="30.9 377" stroke-dashoffset="-343.1"/>
</g>
<text x="80" y="76" text-anchor="middle" font-size="19" font-weight="700" fill="#1c1e21">128.4万</text>
<text x="80" y="96" text-anchor="middle" font-size="12" fill="#65676b">总访问量</text>
</svg>
<div>
<div class="lg-row"><span class="dot" style="background:#1877f2"></span><span class="nm">自然流量</span><span class="v">53.9 万</span><span class="pc">42%</span></div>
<div class="lg-row"><span class="dot" style="background:#45bd62"></span><span class="nm">付费广告</span><span class="v">39.8 万</span><span class="pc">31%</span></div>
<div class="lg-row"><span class="dot" style="background:#f7b928"></span><span class="nm">社交媒体</span><span class="v">23.1 万</span><span class="pc">18%</span></div>
<div class="lg-row"><span class="dot" style="background:#fa383e"></span><span class="nm">直接访问</span><span class="v">11.6 万</span><span class="pc">9%</span></div>
</div>
</div>
<div class="post-stats">
<div><span class="react">👍</span><span class="like-num" data-count="205">205</span></div>
<div><span>12 条评论</span> · <span>6 次分享</span></div>
</div>
<div class="actions">
<button class="act act-like"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.5 21H5.2a1.2 1.2 0 0 1-1.2-1.2v-8.6c0-.7.5-1.2 1.2-1.2h3.3zM9.7 10.2 13 3.5c1.4.2 2.2 1.2 2.2 2.7V9h4.4c1.3 0 2.2 1.1 1.9 2.3l-1.5 6.7c-.3 1.2-1.1 3-2.5 3H9.7z"/></svg>赞</button>
<button class="act act-comment"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3C6.5 3 2 6.9 2 11.7c0 2.8 1.4 5.3 3.7 7L5 22l4.3-2c.9.2 1.8.4 2.7.4 5.5 0 10-3.9 10-8.7S17.5 3 12 3z"/></svg>评论</button>
<button class="act act-share"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M13.5 4.5 21 11l-7.5 6.5v-3.9C8 13.7 5.2 15.6 3.5 19.5c.2-6.7 4-10.5 10-11.4z"/></svg>分享</button>
</div>
</article>
<!-- 卡片 5:热销商品 TOP5 -->
<article class="card post" data-key="商品 排行 榜单 top 销量 销售额 电商 rank table">
<div class="post-hd">
<div class="avatar av-purple">电</div>
<div class="meta">
<b>电商运营组</b>
<div class="sub">8 小时前 · 公开</div>
</div>
<span class="more">···</span>
</div>
<div class="post-txt">🏆 <b>今日热销商品 TOP5</b>:降噪耳机 Pro 稳居榜首,便携榨汁机环比 +24.8% 增速最快 <a>#电商日报</a></div>
<div class="tbl-wrap" style="padding-bottom:8px">
<table class="tbl">
<thead><tr><th>#</th><th>商品</th><th>销量</th><th>销售额</th><th>环比</th></tr></thead>
<tbody>
<tr><td><span class="rank r1">1</span></td><td><b>无线降噪耳机 Pro</b></td><td>3,286 件</td><td>¥482.6 万</td><td class="up" style="color:var(--green);font-weight:700">▲ 18.2%</td></tr>
<tr><td><span class="rank r2">2</span></td><td><b>智能手表 S6</b></td><td>2,941 件</td><td>¥398.2 万</td><td style="color:var(--green);font-weight:700">▲ 9.6%</td></tr>
<tr><td><span class="rank r3">3</span></td><td><b>便携榨汁机</b></td><td>2,733 件</td><td>¥96.3 万</td><td style="color:var(--green);font-weight:700">▲ 24.8%</td></tr>
<tr><td><span class="rank rx">4</span></td><td><b>机械键盘 K87</b></td><td>2,158 件</td><td>¥172.9 万</td><td style="color:var(--red);font-weight:700">▼ 3.1%</td></tr>
<tr><td><span class="rank rx">5</span></td><td><b>桌面升降支架</b></td><td>1,986 件</td><td>¥119.5 万</td><td style="color:var(--green);font-weight:700">▲ 5.4%</td></tr>
</tbody>
</table>
</div>
<div class="post-stats">
<div><span class="react">❤️</span><span class="like-num" data-count="521">521</span></div>
<div><span>45 条评论</span> · <span>28 次分享</span></div>
</div>
<div class="actions">
<button class="act act-like"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.5 21H5.2a1.2 1.2 0 0 1-1.2-1.2v-8.6c0-.7.5-1.2 1.2-1.2h3.3zM9.7 10.2 13 3.5c1.4.2 2.2 1.2 2.2 2.7V9h4.4c1.3 0 2.2 1.1 1.9 2.3l-1.5 6.7c-.3 1.2-1.1 3-2.5 3H9.7z"/></svg>赞</button>
<button class="act act-comment"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3C6.5 3 2 6.9 2 11.7c0 2.8 1.4 5.3 3.7 7L5 22l4.3-2c.9.2 1.8.4 2.7.4 5.5 0 10-3.9 10-8.7S17.5 3 12 3z"/></svg>评论</button>
<button class="act act-share"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M13.5 4.5 21 11l-7.5 6.5v-3.9C8 13.7 5.2 15.6 3.5 19.5c.2-6.7 4-10.5 10-11.4z"/></svg>分享</button>
</div>
</article>
<div class="noresult card" id="noResult" style="padding:40px 0">🔍 没有匹配的数据卡片,换个关键词试试(如:趋势 / 渠道 / 排行)</div>
</section>
<!-- ================= 右栏 ================= -->
<aside class="side side-r">
<div class="card rc">
<h4>推荐看板</h4>
<div class="rc-item">
<div class="rc-thumb" style="background:linear-gradient(140deg,#166fe5,#0a3d91)">🛒</div>
<div><div class="tt">双 11 作战室</div><div class="dd">datafeed.cn/board/d11</div></div>
</div>
<div class="rc-item">
<div class="rc-thumb" style="background:linear-gradient(140deg,#f7b928,#dd7d0a)">📣</div>
<div><div class="tt">渠道 ROI 分析</div><div class="dd">datafeed.cn/board/roi</div></div>
</div>
</div>
<div class="card rc">
<h4>数据更新计划</h4>
<div class="rc-row"><span class="rc-status on"></span><div><b>T+1 销售宽表</b><div class="sub">每日 02:00 · 已完成</div></div><span class="tail">2h 前</span></div>
<div class="rc-row"><span class="rc-status on"></span><div><b>用户标签计算</b><div class="sub">每日 03:30 · 已完成</div></div><span class="tail">40m 前</span></div>
<div class="rc-row"><span class="rc-status pulse"></span><div><b>实时埋点流</b><div class="sub">持续写入 · 运行中</div></div><span class="tail" style="color:var(--green);font-weight:700">正常</span></div>
<div class="rc-row"><span class="rc-status off"></span><div><b>周报快照</b><div class="sub">每周一 09:00 · 待执行</div></div><span class="tail">2d 后</span></div>
</div>
<div class="card rc">
<h4>值班分析师</h4>
<div class="rc-row"><div class="avatar sm av-green">李</div><div><b>李雷</b><div class="sub">主值班 · 增长数据</div></div></div>
<div class="rc-row"><div class="avatar sm av-blue">韩</div><div><b>韩梅梅</b><div class="sub">副值班 · 电商数据</div></div></div>
<div class="rc-row"><div class="avatar sm av-orange">陈</div><div><b>陈晨</b><div class="sub">渠道数据 · 离线</div></div></div>
</div>
<div class="card rc">
<h4>你的小组</h4>
<div class="rc-row"><div class="avatar sm av-blue">增</div><div><b>增长数据组</b><div class="sub">12 位成员 · 3 条新动态</div></div></div>
<div class="rc-row"><div class="avatar sm av-green">实</div><div><b>实验平台组</b><div class="sub">8 位成员 · 1 条新动态</div></div></div>
</div>
</aside>
</main>
<div class="toast" id="toast"></div>
<script>
/* ================= 折线图(平滑曲线,Catmull-Rom → 贝塞尔) ================= */
const SVGNS = 'http://www.w3.org/2000/svg';
const PAD = {l:44, r:628, t:16, b:222}; // 640 x 264 绘图区
function smoothPath(pts){
if(pts.length < 2) return '';
let d = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
for(let i = 0; i < pts.length - 1; i++){
const p0 = pts[Math.max(0, i-1)], p1 = pts[i], p2 = pts[i+1], p3 = pts[Math.min(pts.length-1, i+2)];
const c1x = p1[0] + (p2[0]-p0[0])/6, c1y = p1[1] + (p2[1]-p0[1])/6;
const c2x = p2[0] - (p3[0]-p1[0])/6, c2y = p2[1] - (p3[1]-p1[1])/6;
d += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;
}
return d;
}
function gen30(){ // 近 30 天(确定性伪随机,演示用)
const labels=[], today=[], yest=[];
for(let i = 29; i >= 0; i--){
const d = new Date(2026, 9, 25 - i);
labels.push(`${d.getMonth()+1}/${d.getDate()}`);
const v = 55 + 16*Math.sin(i/3.4) + 9*Math.cos(i/1.7) + (i%7===6 ? 9 : 0) - (i%7===0 ? 5 : 0);
today.push(Math.round(v*10)/10);
yest.push(Math.round((v - 6 + 4*Math.cos(i/2.2))*10)/10);
}
return {labels, today, yesterday:yest};
}
const DATASETS = {
'7': {labels:['10/19','10/20','10/21','10/22','10/23','10/24','10/25'],
today:[52.4,61.2,58.6,73.1,66.4,81.2,88.5],
yesterday:[48.9,55.3,60.2,64.5,59.1,70.6,72.3]},
'30': gen30()
};
function el(name, attrs){
const n = document.createElementNS(SVGNS, name);
for(const k in attrs) n.setAttribute(k, attrs[k]);
return n;
}
function buildTrend(range){
const ds = DATASETS[range];
const svg = document.getElementById('trendSvg');
svg.innerHTML = '';
const peak = Math.max(...ds.today, ...ds.yesterday);
const step = Math.max(10, Math.ceil(peak/4/5)*5);
const maxT = step*4;
const n = ds.today.length;
const X = i => PAD.l + (PAD.r-PAD.l)*i/(n-1);
const Y = v => PAD.b - (v/maxT)*(PAD.b-PAD.t);
// 渐变
const defs = el('defs');
const grad = el('linearGradient', {id:'gBlue', x1:0, y1:0, x2:0, y2:1});
grad.appendChild(el('stop', {offset:'0%', 'stop-color':'#1877f2', 'stop-opacity':.22}));
grad.appendChild(el('stop', {offset:'100%', 'stop-color':'#1877f2', 'stop-opacity':0}));
defs.appendChild(grad); svg.appendChild(defs);
// 网格 + Y 轴刻度
for(let i = 0; i <= 4; i++){
const v = step*i, y = Y(v);
svg.appendChild(el('line', {x1:PAD.l, x2:PAD.r, y1:y, y2:y, stroke:'#e4e6eb', 'stroke-dasharray': i? '3 5' : '0'}));
const t = el('text', {x:PAD.l-8, y:y+4, 'text-anchor':'end', 'font-size':11, fill:'#8a8d91'});
t.textContent = v; svg.appendChild(t);
}
const pt = arr => arr.map((v,i)=>[X(i), Y(v)]);
const pToday = pt(ds.today), pYest = pt(ds.yesterday);
// 面积(今日)
const area = smoothPath(pToday) + ` L ${pToday[n-1][0]} ${PAD.b} L ${pToday[0][0]} ${PAD.b} Z`;
svg.appendChild(el('path', {d:area, fill:'url(#gBlue)'}));
// 昨日(灰虚线)
svg.appendChild(el('path', {d:smoothPath(pYest), fill:'none', stroke:'#b6bcc4', 'stroke-width':2, 'stroke-dasharray':'5 6'}));
// 今日(蓝)
svg.appendChild(el('path', {d:smoothPath(pToday), fill:'none', stroke:'#1877f2', 'stroke-width':3, 'stroke-linecap':'round'}));
// 端点
svg.appendChild(el('circle', {cx:pToday[n-1][0], cy:pToday[n-1][1], r:4.5, fill:'#1877f2', stroke:'#fff', 'stroke-width':2}));
// X 轴标签 & 悬停提示
const labelStep = Math.max(1, Math.ceil(n/7));
for(let i = 0; i < n; i++){
if(n <= 8){
svg.appendChild(el('circle', {cx:X(i), cy:Y(ds.today[i]), r:2.6, fill:'#1877f2'}));
}
const hit = el('circle', {cx:X(i), cy:Y(ds.today[i]), r:11, fill:'transparent', style:'cursor:pointer'});
const ti = el('title'); ti.textContent = `${ds.labels[i]} · 今日 ${ds.today[i]} 万 / 昨日 ${ds.yesterday[i]} 万`;
hit.appendChild(ti); svg.appendChild(hit);
if(i % labelStep === 0 || i === n-1){
const t = el('text', {x:X(i), y:244, 'text-anchor':'middle', 'font-size':11, fill:'#8a8d91'});
t.textContent = ds.labels[i]; svg.appendChild(t);
}
}
const mx = Math.max(...ds.today), mn = Math.min(...ds.today);
const avg = (ds.today.reduce((a,b)=>a+b,0)/n).toFixed(1);
document.getElementById('trendSummary').textContent =
`单位:万次访问 · 峰值 ${mx} 万 · 谷值 ${mn} 万 · 均值 ${avg} 万`;
}
buildTrend('7');
document.querySelectorAll('.pill[data-range]').forEach(p => {
p.addEventListener('click', () => {
document.querySelectorAll('.pill[data-range]').forEach(x => x.classList.remove('active'));
p.classList.add('active');
buildTrend(p.dataset.range);
});
});
/* ================= 点赞 ================= */
document.querySelectorAll('.act-like').forEach(btn => {
btn.addEventListener('click', () => {
const post = btn.closest('.post');
const num = post.querySelector('.like-num');
const on = btn.classList.toggle('liked');
num.dataset.count = +num.dataset.count + (on ? 1 : -1);
const c = +num.dataset.count;
num.textContent = c >= 1000 ? (c/1000).toFixed(1).replace(/\.0$/,'') + 'k' : c.toLocaleString('zh-CN');
});
});
/* ================= 评论(回车发送) ================= */
const cmtInput = document.querySelector('.cmt-input input');
cmtInput.addEventListener('keydown', e => {
if(e.key !== 'Enter' || !e.target.value.trim()) return;
const post = e.target.closest('.post');
const wrap = e.target.closest('.comments');
const txt = e.target.value.trim();
e.target.value = '';
const div = document.createElement('div');
div.className = 'cmt';
div.innerHTML = `<div class="avatar av-blue sm">张</div>
<div><div class="cmt-bubble"><span class="cmt-name">张分析</span>${txt.replace(/</g,'&lt;')}</div>
<div class="cmt-meta"><a>赞</a><a>回复</a><span>刚刚</span></div></div>`;
wrap.insertBefore(div, e.target.closest('.cmt-input'));
const cn = post.querySelector('.cmt-num'), total = post.querySelector('.cmt-total');
cn.dataset.count = +cn.dataset.count + 1;
cn.textContent = cn.dataset.count + ' 条评论';
total.textContent = cn.dataset.count;
});
document.querySelectorAll('.act-comment').forEach(btn => {
btn.addEventListener('click', () => {
const input = btn.closest('.post').querySelector('.cmt-input input');
if(input){ input.focus(); } else { showToast('该卡片暂未开放评论(演示)'); }
});
});
/* ================= 分享 / 演示按钮 Toast ================= */
const toast = document.getElementById('toast');
let toastTimer;
function showToast(msg){
toast.textContent = msg;
toast.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toast.classList.remove('show'), 1800);
}
document.querySelectorAll('.act-share').forEach(b => b.addEventListener('click', () => showToast('看板链接已复制(演示)')));
document.querySelectorAll('[data-toast]').forEach(b => b.addEventListener('click', () => showToast(b.dataset.toast)));
document.querySelectorAll('.story').forEach(s => s.addEventListener('click', () => showToast('演示页面:数据专题未接入')));
/* ================= 搜索 / 筛选(顶栏 + 发布器共用) ================= */
function filterPosts(q){
q = q.trim().toLowerCase();
let visible = 0;
document.querySelectorAll('.feed .post').forEach(p => {
const hit = !q || (p.dataset.key + ' ' + p.textContent).toLowerCase().includes(q);
p.style.display = hit ? '' : 'none';
if(hit) visible++;
});
document.getElementById('noResult').style.display = visible ? 'none' : 'block';
}
['globalSearch','feedFilter'].forEach(id => {
document.getElementById(id).addEventListener('input', e => {
filterPosts(e.target.value);
if(id === 'globalSearch'){ const f = document.getElementById('feedFilter'); if(f.value !== e.target.value) f.value = e.target.value; }
else { const g = document.getElementById('globalSearch'); if(g.value !== e.target.value) g.value = e.target.value; }
});
});
/* ================= 顶部导航高亮 ================= */
document.querySelectorAll('.tb-nav').forEach(n => {
n.addEventListener('click', () => {
document.querySelectorAll('.tb-nav').forEach(x => x.classList.remove('active'));
n.classList.add('active');
});
});
</script>
</body>
</html>
+1 -1
View File
@@ -36,7 +36,7 @@ VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 我的空间前端
VITE_AIPI_FRONTEND_URL= xxx
VITE_AIPI_FRONTEND_URL= https://knowledgetoolweb.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=https://present.a.uat.4.cn/present/domain
@@ -16,7 +16,7 @@
| 事实 | 位置 |
|------|------|
| Step2 编排循环(leader → 顺序派 special → leader … 直到 `dispatched===[]` 共识 / `MAX_CYCLES=8`)**完全由前端 hook 驱动** | `src/roundtable-planning/hooks/useStep2Orchestration.ts:992-1204`(recommend)、`1228-1439`(chain) |
| Step2 编排循环(leader → special → leader …;到全局 `roundtable_room_limit.max_cycles` 配置轮次进入无工具的总控正文收口)**完全由前端 hook 驱动** | `src/roundtable-planning/hooks/useStep2Orchestration.ts`(recommend / chain / dag) |
| 循环由 `currentStep===2` 的 `useEffect` 启停;离开 Step2 的 cleanup 会 `orchestrationCancelRef.current=true` + `abortAllInFlight()` 中断 | `useStep2Orchestration.ts:1468-1541`(尤其 cleanup `1534-1537`、`1471-1477`) |
| 共识判定:`leaderRes.dispatched.length===0` → `setHasConsensus(true)` | `useStep2Orchestration.ts:1091-1099` |
| 进入 Step3:手动点按钮,前置 `hasConsensus===true` | `Step2Panel.tsx:370-381`、`RoundtablePlanningPage.tsx:586-595` |
@@ -456,7 +456,7 @@ checkpoint,一路跑到 Step3 绘制完成。
只有「查看」进去才能回答(或在弹窗里直接给一个回答框,调用 `resumeJob`)。
3. **取消**:流程图弹窗/Step2 提供「停止后台」→ `cancelJob`,`status→cancelled`,前端可恢复本地编辑。
4. **错误恢复**:`error` 事件展示 detail;允许「重试」= 重新 `startJob`(沿用已建 threadIds)。
5. **MAX_CYCLES / 预算**:后台图内兜底(对齐前端 `MAX_CYCLES=8`),到顶强制收敛并照常进 Step3。
5. **max_cycles / 预算**:后台与前端统一读取全局 `roundtable_room_limit.max_cycles`(默认 8,2..100),一次会商启动时锁定;到达配置轮次调用 `finalize_only=True`,关闭全部工具并要求总控输出最终总结正文。不能把到顶表现成“已停止”,也不能跳过总结直接进入 Step3。
6. **多 worker**:严格遵守 Phase 3 的「状态进 DB+checkpointer、SSE 走 loopback」;部署若多 worker
需 sticky 或全靠 DB 态(推荐后者)。
7. **清理**:`roundtable_jobs` cleanup cron(活跃保护、先删 CP 再删 DB),与 draft 生命周期对齐。
@@ -311,13 +311,13 @@ chain 模式的 Step 2 init **完全复用** `initMultiAgent`:`agentNames = ch
### 阶段 3 · 业务链条选择卡片列表(1.5 天) ✅ 已完成
**目标**:弹窗「业务链条」面板里选链条。新增 `components/BusinessChainPicker.tsx`,替换阶段 2 的占位。
- [x] 进入该 Tab 时 `listChains()` 拉本人链条 + `listAgents()` 取存活 id/名称,渲染**卡片列表**:
- [x] 进入该 Tab 时 `listChains()` 拉本人链条渲染**卡片列表**(席位存活/名称用链条响应自带的读时计算富化字段 `seat.available`/`seat.live_name`,**不再实时拉 `listAgents()`**,见 §11):
- [x] 卡片标题(链条 title)+ 「N 席」徽章 + 选中 ✓;描述行。
- [x] 标题下一排**智能体名 tag**:按链条顺序**从左到右**、不同颜色(`getAvatarTypeFor`→`chainTagClasses`),tag 之间插 `→`(`ArrowRightIcon`)箭头连接(`seats[0]` 最左、先发言)。
- [x] 底部「配置链条」按钮(跳 `roundtable/chains`)。
- [x] 空态:无链条时引导「去配置页面创建第一条业务链条」+ 按钮。
- [x] 选中一条 → 高亮(`ring-primary`);底部「进入研讨」`onConfirm({ agents: 存活席位有序, mode:"chain", chain:{id,title} })`。
- [x] 链条里有 agent 已不存在(被删)→ 该 tag 置灰删除线 + `title` 提示 + 卡片下方 amber 警告;confirm 按**存活席位**过滤;存活 < `CHAIN_MIN_SEATS` 则禁用「进入研讨」并提示。agent 列表加载失败时 `existingIds=null` → 不误判删除。
- [x] 链条里有 agent 已不存在(被删)→ 该 tag 置灰删除线 + `title` 提示 + 卡片下方 amber 警告;confirm 按**存活席位**过滤;存活 < `CHAIN_MIN_SEATS` 则禁用「进入研讨」并提示。存活判定改读 `seat.available`(undefined = 旧后端未标注 → 不误判删除)。
- **验收**:✅ typecheck 0 新增错误(总数仍 76)。卡片渲染标题 + 彩色 tag + ← 箭头;选链 → `onConfirm(mode:"chain")` → `handleConfirmAgents` 按链条顺序 `setSelectedAgents` → Step 2 左侧「参与角色」顺序 = 链条顺序(**按顺序派活在阶段 4**)。**人工 UI 验收待跑**。
- **箭头方向**(已确认):tag 左→右排列、`→`(`ArrowRightIcon`)连接,`seats[0]` 最左、先发言,箭头顺读即链条研讨顺序。
@@ -396,5 +396,42 @@ chain 模式的 Step 2 init **完全复用** `initMultiAgent`:`agentNames = ch
| 后端链条持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/` | 新增 |
| 后端链条路由 | `offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py` | 新增 |
| 后端 store 接线 | `offline-backend-20260512/backend/app/gateway/deps.py`(`app.state.roundtable_chain_store`)+ `app.py` | 改 |
---
## 11. 席位富化:选链不再实时拉智能体列表(2026-10-23)
**背景**:选链全链路(弹窗打开 / 左栏点选 / 深链自动选链)此前每次都额外全量拉
`GET /api/agents`,只为两件事——席位**存活判断**(existingIds)与**最新改名**
(nameById)。慢网下这个大请求严重拖慢选链体验。
**方案**:席位展示所需信息(name 快照本就随链条落库、编辑器保存时刷新)之外,
「存活 + 最新名」由**后端在返回链条时读时计算**:
- `GET /api/roundtable-chains`(list/get/update 等)对每条链每个 seat 附带两个
**只读计算字段**(`ChainSeatResponseSchema`,不落库、create/update 请求 schema
不收,存量 seats JSON 零迁移):
- `available: bool` —— 席位智能体当前对请求用户可见(本人/内置/已发布、非私享、
非管理员隐藏,`agent_store.list_by_ids` 一次批量 SQL)且不是功能型内置
(`roundtable-intent/recommender/coordinator/report` + `roundtable-coordinator-*`
前缀,与前端 `filterRecommendCandidates` 排除集逐字对齐);
- `live_name: string | null` —— 智能体当前名称,查不到为 null。
- best-effort:agent store 不可用时跳过富化(字段缺省),前端按存活处理。
- 前端 `ChainSeat` 增加可选 `available` / `live_name`;`buildChainSeatSelection(chain,
candidates?)` 的 `candidates` 变为可选——不传时存活 = `seat.available !== false`
(undefined = 旧后端 → 视为存活,与旧版「拉取失败不判删」降级语义一致)、名称 =
`live_name ?? name ?? agent_id`。
- 去掉选链路径上的全部 `listAgents()`:`BusinessChainPicker`(打开只剩 1 个
`listChains` 请求)、`BusinessChainListDialog`(列表弹窗)、主页左栏点选
(**零网络请求**)、固定链(8BF 等)与深链自动选链。
- 「智能推荐」Tab 与链条编辑器的智能体池**保留** `listAgents()`——那两处真的需要
完整候选列表。
**兜底**:智能体在选链之后、进 Step 2 之前被删,`/api/multi-agent/init` 的后端
404 硬校验照旧兜底(现状即有,非本次引入)。
**测试**:后端 `tests/test_roundtable_chain_seat_enrichment.py`(纯函数 + schema
不落库 + store 往返);前端 `src/roundtable-planning/lib/business-chain.test.ts`
(`pnpm run test:business-chain`,esbuild 打包后 node:test)。
</content>
</invoke>
@@ -394,8 +394,8 @@ while !cancelled && cycles < MAX_CYCLES:
setConsensusPercentage(prev => min(95, prev + 8))
nextLeaderTask = "各子智能体已就上一轮派活完成交付,请你结合最新结论..."
if cycles >= MAX_CYCLES:
toast("⏱️ 已达到最大研讨轮次,自动停止以避免循环")
if cycles == configuredMaxCycles:
leader(finalizeOnly: true, tools: [], prompt: "直接综合已有交付并输出最终正文")
clearAllStreamingSpeakers()
isOrchestrating = false
@@ -695,7 +695,7 @@ window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开)
### 12.3 调整 Step 2 编排上限
`RoundtablePlanningPage.tsx` 内 `MAX_CYCLES = 8`,超出会 toast「⏱️ 已达到最大研讨轮次,自动停止以避免循环」。若放宽:注意 `step2RoundtableDialogues` 数组会线性增长,超长讨论需要考虑虚拟滚动。
收口轮数来自全局设置 `roundtable_room_limit.max_cycles`(管理员设置弹窗「会商并发与收口」,默认 8、范围 2..100),旧服务端或读取失败时回退 `DEFAULT_MAX_CYCLES=8`。一次编排启动时锁定当时值;到达该轮不是停止,而是切换为 `finalizeOnly` 纯正文收口:总控禁用全部工具,综合已经取得的席位交付并输出最终方案;个别席位失败时如实标明缺口。前后台执行路径必须保持一致。
### 12.4 把 special 改回并行
@@ -1,5 +1,7 @@
# 技能知识归纳与“知识梳理”助手知识库
助手知识库的目录、正文、来源和任务记录读取已提交快照,不等待后台导入的全局写锁,也不重复统计整库内容。导入仍在提交前完成实体及 Wiki 对齐,旧数据在检索、导出时保留兼容对齐。页面和展示权限的读取支持取消及 30 秒超时,失败显示可重试错误,避免一直加载;大目录分页失败后停止自动续页并保留已加载正文。来源列表仅在打开「来源与同步」或「导入 / 导出记录」时请求。
管理员在「技能管理 → 技能知识归纳」中选择一个或多个技能、一个或多个目标,系统按“技能 × 目标”创建后台任务。界面提供总体进度、单项失败重试、实体/关系审核、绑定重归纳、自动同步和两种解绑动作。技能列表通过一次 bindings 查询展示归纳目标,避免逐技能请求。
扫描仅处理知识资料。代码、脚本、锁文件、密钥文件以及 Markdown 代码块不会进入上传、摘要、提示词或知识指纹;ZIP 只读取逐项验证通过的知识成员。自动监听只比较该知识指纹,所以纯代码改动不会产生 stale 状态。
@@ -1,49 +0,0 @@
# 麒麟系统 Chromium ~80 真机回归清单
> 背景:Tailwind v4 依赖 `@layer`(Chrome 99+)/`@property`(85+)/`color-mix`、`oklch`(111+),麒麟系统自带的 Chromium 80 多浏览器上整站样式失效。本分支(`feat/tailwind-v3-legacy-chrome`)完成 v4→v3 降级迁移,产物结构性指标已全部达标(详见提交 1607c7fe / 40b02520 / e54abcc6)。本文档供真机验收使用。
## 一、测试准备
1. 在麒麟机器的浏览器地址栏输入 `chrome://version`,记录完整版本号(重点关注是否 ≥84 —— 低于 84 时 flex `gap` 不支持,各处间距会塌陷,需单独反馈)。
2. 部署本分支 `pnpm build` 产物(或开发机 `pnpm preview` 后用麒麟机器访问)。
3. 先跑一次 `pnpm check:legacy-css`(开发机),确认绿色再发版。
## 二、页面巡检(明/暗两套主题各过一遍)
| 页面 | 路径 | 重点检查点 |
|---|---|---|
| 登录/落地页 | `/` | 布局完整、文字阴影(hero 标题)、无裸奔样式 |
| 问答首页 | `/chats/new` | 整体配色、输入框焦点环(回退为内层 outline)、常用提示词栏 |
| 对话流 | 任意会话 | Markdown 渲染(标题/列表/表格/代码块/引用——streamdown 白名单类)、气泡圆角、流式输出 |
| 智能体 | `/page/workspace/agents` 及详情 | 卡片、Tabs、新建页表单、技能穿梭框 |
| 圆桌会商 | roundtable 页 | 左侧步骤条、消息气泡、流程图面板、暗色兜底配色 |
| 任务深链 | `?taskId=&goPath=3qfx/rwfx/xdfx` | 左侧任务卡、目的树、快捷跳转栏(按钮管理配置项) |
| 轻应用/菜单管理/敏感词 | 各管理页 | Tabs、表格、弹窗 |
| 简洁模式 | 外观设置切换 | 无全局导航栏布局、右侧工具箱、引导高亮框 |
| 换肤 | 浅红/暗色/浅蓝/绿/冰蓝/浅黄 | 六套主题各自的 primary/ring 颜色、半透明档(primary/10 等) |
| 大屏(dashboard-agent) | 战情大屏页 | 时间线、图表、暗色文字覆盖 |
| iframe 嵌入 | `?embed=1&theme=dark-blue` | 去全局 chrome、强制主题生效 |
## 三、已知降级点(预期行为,非 bug)
1. **`:has()` 渐进增强**(Chrome <105 不生效):按钮内含图标时内边距略宽、alert 带图标时首列宽度、item 带描述时对齐微差 —— 视觉细节,不影响功能。
2. **input-group 焦点环回退**:聊天输入框的焦点指示从「整框 3px 环」回退为「内层输入控件 2px outline」;校验态从整框环回退为内层红色阴影 —— 仍有明确指示。
3. **`:where()` 圆桌字号缩放**(Chrome <88 不生效):设置页调字号时圆桌页面小字号文本不跟随缩放,基础字号正常。
4. **textarea 自增高**:`field-sizing` 需要 Chrome 123+,旧浏览器走原生 `rows` 行为(已有多行默认值)。
5. **prefers-color-scheme 单块**:产物中 1 处 lightningcss 内部标记(`--lightningcss-light/dark`),无影响。
## 四、重点风险项(出现问题按此排查)
- **整页无样式** → 确认产物是本分支构建(旧 v4 产物在 80 上必然裸奔);查 `@layer`。
- **暗色主题失效** → 查产物 `.dark .dark\:` 前缀选择器是否存在(`pnpm check:legacy-css` 会拦)。
- **弹层错位(下拉/气泡/选择器)** → radix 弹层依赖 `origin-[var(--radix-...)]`,确认类已生成。
- **控制台语法错误/白屏** → JS 侧:确认构建 `target: chrome80` 且 `polyfills.ts` 在 main chunk 首位;记录报错栈反馈。
- **mermaid 流程图 / 地图(maplibre)**:重型库在 80 上的运行时差异,出现异常截图 + 控制台报错反馈,单独处理。
## 五、发版前自检(开发机)
```bash
pnpm build # 构建绿
pnpm check:legacy-css # 产物守卫:@layer/@property/:is() 为 0,color-mix 全部守护
pnpm typecheck # 58 个存量错误 = master 基线(迁移零新增)
```
+81 -5
View File
@@ -12,12 +12,88 @@
root.classList.add("dark");
} catch (e) {}
})();
// Chrome 102 has no AbortSignal.timeout (Chrome 103+). Install before
// any module so LangGraph SDK control requests can set a deadline.
(function () {
try {
if (typeof AbortSignal === "undefined" || typeof AbortSignal.timeout === "function") {
return;
}
AbortSignal.timeout = function (milliseconds) {
var controller = new AbortController();
var timer = setTimeout(function () {
var reason;
try {
reason = new DOMException("signal timed out", "TimeoutError");
} catch (err) {
reason = new Error("signal timed out");
reason.name = "TimeoutError";
}
try {
controller.abort(reason);
} catch (err) {
controller.abort();
}
}, milliseconds);
controller.signal.addEventListener(
"abort",
function () {
clearTimeout(timer);
},
{ once: true },
);
return controller.signal;
};
} catch (err) {}
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>CM 助手</title>
<script src="/runtime-config.js" data-runtime-config></script>
</head>
<script>
// React 19's development profiler uses the User Timing Level 3
// `performance.measure(name, options)` overload. Firefox 101 only
// implements the legacy string-marker form and otherwise throws a
// DOMException during every scheduled update. Detect that overload
// before React loads and disable its optional profiler when unavailable.
(function () {
try {
var perf = window.performance;
if (!perf || typeof perf.measure !== "function" || typeof perf.now !== "function") return;
var probe = "__deerflow_user_timing_options_probe__";
var now = perf.now();
perf.measure(probe, { start: now, end: now });
if (typeof perf.clearMeasures === "function") perf.clearMeasures(probe);
} catch (e) {
try {
Object.defineProperty(window.performance, "measure", {
value: undefined,
configurable: true,
});
} catch (defineError) {
try {
window.performance.measure = undefined;
} catch (assignmentError) {}
}
}
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script>
// Relative production assets need a directory URL. Normalize a mounted
// prefix such as /web to /web/ before the browser resolves asset URLs.
(function () {
var path = window.location.pathname;
if (path !== "/" && !path.endsWith("/") && !/\.[^/]+$/.test(path)) {
window.history.replaceState(
window.history.state,
"",
path + "/" + window.location.search + window.location.hash,
);
}
})();
</script>
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
<title>CM 助手</title>
<script src="%BASE_URL%runtime-config.js" data-runtime-config></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
+10 -8
View File
@@ -11,13 +11,19 @@
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test:thread-history": "node scripts/test-thread-history.mjs",
"test:input-mode": "node --experimental-strip-types --test src/components/workspace/input-mode.test.ts",
"test:answer-feedback": "node --experimental-strip-types --test src/components/workspace/agents/agent-feedback-visibility.test.ts src/components/workspace/messages/answer-feedback-eligibility.test.ts src/strategy-components/api/answer-feedback-batching.test.ts",
"test:retrieval-debug": "node --experimental-strip-types --test src/core/llmwiki/retrieval-debug.test.ts",
"test:abort-signal": "node --experimental-strip-types --test src/core/compat/abort-signal.test.ts",
"test:business-chain": "npx esbuild src/roundtable-planning/lib/business-chain.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/business-chain.test.mjs && node --test .runtime/unit-tests/business-chain.test.mjs",
"test:auth-entry": "node scripts/test-auth-entry.mjs",
"test:b5-1-theme": "node --experimental-strip-types --test src/core/auth/b5-1-theme.test.mjs",
"test:html-preview": "pnpm exec esbuild src/core/utils/legacy-html-preview.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/legacy-html-preview.test.mjs && node --test .runtime/unit-tests/legacy-html-preview.test.mjs",
"test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts",
"test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
"check:legacy-css": "node scripts/check-legacy-css.mjs"
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
@@ -71,7 +77,6 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"core-js": "3.45.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.21",
"dnd-kit": "^0.0.2",
@@ -114,17 +119,17 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.15",
"@types/file-saver": "^2.0.7",
"@types/gsap": "^3.0.0",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.4",
"autoprefixer": "^10.4.20",
"lightningcss": "^1.30.2",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"tailwindcss": "^4.0.15",
"tw-animate-css": "^1.4.0",
"typescript": "^5.8.2",
"vite": "^6.0.0",
"vite-plugin-source-identifier": "^1.1.2"
@@ -132,8 +137,5 @@
"overrides": {
"@radix-ui/react-slot": "1.3.3"
},
"browserslist": [
"Chrome >= 80"
],
"packageManager": "pnpm@10.26.2"
}
+8 -9
View File
@@ -1,12 +1,11 @@
import tailwindcss from "tailwindcss";
import darkDescendant from "./scripts/postcss-dark-descendant.mjs";
import autoprefixer from "autoprefixer";
import tailwindcss from "@tailwindcss/postcss";
import legacyColorFallback from "./scripts/postcss-legacy-color-fallback.mjs";
import lightningCssLegacyChrome from "./scripts/postcss-lightningcss-chrome109.mjs";
// Tailwind v3:透明度修饰符(bg-primary/10 等)由 tailwind.config.ts 里的
// --legacy-*-rgb 通道变量直接实现,不再需要 v4 时代的 color-mix 回退插件;
// dark-descendant 把暗色变体的 :is(.dark *) 改写为 Chrome 80 可读的后代形式;
// dev 环境面向开发者的现代 Chrome,产物侧的旧浏览器降级(:is()/逻辑属性/
// 媒体查询 range 语法/厂商前缀)由 vite 的 lightningcss targets 负责。
export default {
plugins: [tailwindcss, darkDescendant, autoprefixer],
plugins: [
tailwindcss(),
legacyColorFallback(),
lightningCssLegacyChrome(),
],
};
+5
View File
@@ -155,6 +155,11 @@ window.__CM_RUNTIME_CONFIG__ = {
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
VITE_GITHUB_OAUTH_TOKEN: "",
// 对话历史页「收藏」右侧的「清空」按钮。
// "false" 隐藏;不填、删掉这一项,或填其它值,都显示。
// 确认后会删除当前账号的全部对话,以及该账号下所有智能体的聊天记录。
VITE_CHAT_HISTORY_CLEAR_VISIBLE: "true",
// 是否在问答输入框「模式」菜单显示「极速」:"true" 显示,"false" 隐藏。
// 默认关闭。开启后可直连大模型流式问答,不走智能体工具与技能。
// 部署后改 dist/runtime-config.js 再刷新页面即可,无需重新打包。
@@ -0,0 +1,181 @@
import fs from "node:fs";
import path from "node:path";
import postcss from "postcss";
const unsupportedColor = /(?:color-mix|oklch|oklab|lab|lch)\(/i;
const assetsDir = path.resolve("dist/assets");
if (!fs.existsSync(assetsDir)) {
throw new Error("dist/assets 不存在,请先运行 pnpm build");
}
const cssFiles = fs
.readdirSync(assetsDir)
.filter((file) => file.endsWith(".css"));
if (cssFiles.length === 0) {
throw new Error("dist/assets 中没有 CSS 构建产物");
}
const failures = [];
let modernDeclarations = 0;
const requiredLegacyFallbacks = [
{
file: "src/components/ui/textarea.tsx",
pattern: /rows=\{rows\s*\?\?\s*3\}/,
description: "Textarea needs a native rows fallback",
},
{
file: "src/components/ai-elements/prompt-input.tsx",
pattern: /<InputGroup className="h-auto min-h-16 flex-col items-stretch">/,
description: "Prompt textarea must not rely on :has() for its layout",
},
{
file: "src/core/utils/mermaid.ts",
pattern: /htmlLabels:\s*false[\s\S]*secure:\s*\[[\s\S]*"htmlLabels"/,
description: "Sandbox flowcharts must use native SVG labels in legacy browsers",
},
{
file: "src/core/utils/legacy-html-preview.ts",
pattern: /export function downlevelModernColors[\s\S]*function injectLegacyChartShim/,
description: "Generated HTML charts must downlevel modern CSS colors for Chrome 109",
},
{
file: "src/pages/ChatPage.tsx",
pattern: /chat-landing-composer-offset[\s\S]*chat-landing-content-lift/,
description: "Chat landing layout must not rely on Tailwind's individual translate property",
},
{
file: "src/styles/globals.css",
pattern: /\.chat-landing-composer-offset\s*\{\s*position:\s*relative;\s*top:\s*calc\(96px\s*-\s*50vh\);\s*\}/,
description: "Chat landing composer must offset with top, not Tailwind translate",
},
{
file: "src/styles/globals.css",
pattern:
/\.legacy-dialog-viewport\s*\{[\s\S]*?display:\s*flex;[\s\S]*?align-items:\s*center;[\s\S]*?justify-content:\s*center;/,
description: "Dialog content must be flex-centered without Tailwind translate",
},
{
file: "src/components/ui/dialog.tsx",
pattern: /legacy-dialog-viewport[\s\S]*relative grid/,
description: "Dialog panel must not use fixed + translate centering",
},
{
file: "src/styles/globals.css",
pattern:
/\.legacy-dialog-viewport\s*>\s*\[data-slot="dialog-content"\]\s*\{[\s\S]*?min-height:\s*0;[\s\S]*?max-height:\s*100%;/,
description: "Flex-centered dialogs must keep min-height:0 so inner scroll works",
},
{
file: "src/styles/globals.css",
pattern: /\.legacy-floating-layer\s*\{\s*z-index:\s*300;/,
description: "Portaled select/dropdown layers must sit above dialog overlay z-index 200",
},
{
file: "src/components/workspace/skills/skill-file-preview-dialog.tsx",
pattern: /minHeight:[\s\S]*100vh[\s\S]*maxHeight:[\s\S]*100vh/,
description: "Skill file preview must size with vh, not dvh, for Chrome 102",
},
{
file: "src/core/compat/abort-signal.ts",
pattern: /export function installAbortSignalTimeout[\s\S]*AbortSignal/,
description: "LangGraph SDK timeoutMs needs AbortSignal.timeout for Chrome 102",
},
{
file: "src/core/api/api-client.ts",
pattern: /function installLegacyControlTimeout[\s\S]*AbortSignal\.timeout/,
description: "Chat send must skip AbortSignal.timeout when Chrome 102 lacks it",
},
{
file: "index.html",
pattern: /AbortSignal\.timeout = function/,
description: "Chrome 102 needs AbortSignal.timeout before any module loads",
},
{
file: "src/styles/globals.css",
pattern: /\.workspace-chat-bg,\s*\n\s*\.canvas-writing-bg\s*\{\s*background-color:\s*var\(--background\);/,
description: "Agent chat canvas needs an explicit token background for Chrome 102",
},
{
file: "src/styles/globals.css",
pattern: /@supports not \(translate: 0\)[\s\S]*\.dialog-content-center\s*\{\s*transform:\s*translate\(-50%,\s*-50%\);/,
description: "Dialog centering needs a transform fallback for Chrome 102",
},
];
for (const file of cssFiles) {
const root = postcss.parse(
fs.readFileSync(path.join(assetsDir, file), "utf8"),
{ from: file },
);
root.walkDecls((declaration) => {
if (!unsupportedColor.test(declaration.value)) return;
modernDeclarations += 1;
let ancestor = declaration.parent;
while (ancestor) {
if (
ancestor.type === "atrule" &&
ancestor.name === "supports" &&
unsupportedColor.test(ancestor.params)
) {
return;
}
ancestor = ancestor.parent;
}
let previous = declaration.prev();
while (previous?.type === "comment") previous = previous.prev();
const hasSafeFallback =
!declaration.prop.startsWith("--") &&
previous?.type === "decl" &&
previous.prop === declaration.prop &&
!unsupportedColor.test(previous.value);
if (!hasSafeFallback) {
failures.push(
`${file}:${declaration.source?.start?.line ?? "?"} ${declaration.prop}: ${declaration.value}`,
);
}
});
}
function walk(directory) {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const fullPath = path.join(directory, entry.name);
return entry.isDirectory() ? walk(fullPath) : [fullPath];
});
}
for (const file of walk(path.resolve("src"))) {
if (!/\.[jt]sx?$/.test(file)) continue;
const source = fs.readFileSync(file, "utf8");
if (unsupportedColor.test(source)) {
failures.push(
`${path.relative(process.cwd(), file)} contains a runtime-only modern color function`,
);
}
}
for (const fallback of requiredLegacyFallbacks) {
const source = fs.readFileSync(path.resolve(fallback.file), "utf8");
if (!fallback.pattern.test(source)) {
failures.push(`${fallback.file}: ${fallback.description}`);
}
}
if (failures.length > 0) {
console.error("Legacy Chrome CSS compatibility check failed:\n");
console.error(failures.slice(0, 50).join("\n"));
if (failures.length > 50) {
console.error(`... and ${failures.length - 50} more`);
}
process.exit(1);
}
console.log(
`Legacy Chrome (100+) CSS compatibility check passed (${modernDeclarations} guarded modern declarations).`,
);
-72
View File
@@ -1,72 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import postcss from "postcss";
/**
* 旧浏览器(麒麟 Chromium ~80)CSS 产物守卫:构建后运行,防止回归。
* 迁移后的结构性保证(见 tailwind.config.ts / postcss.config.js):
* - Tailwind v3 输出无 @layer(Chrome 99+)与 @property(Chrome 85+);
* - dark: 变体经 postcss-dark-descendant 改写为 .dark 后代形式,全库 :is( 为 0;
* - 媒体查询 range 语法(Chrome 104+)由 lightningcss 按 target 转译;
* - 手写 CSS 不使用 :is()(复杂参数无法安全降级,源头展开为逗号选择器);
* - color-mix()/oklch() 仅允许出现在 @supports 守护内或带前置回退声明。
* :has()(Chrome 105+)与 :where()(Chrome 88+)按渐进增强放行,不在检查范围。
*/
const unsupportedColor = /(?:color-mix|oklch|oklab|lab|lch)\(/i;
const assetsDir = path.resolve("dist/assets");
if (!fs.existsSync(assetsDir)) {
throw new Error("dist/assets 不存在,请先运行 pnpm build");
}
const cssFiles = fs.readdirSync(assetsDir).filter((f) => f.endsWith(".css"));
if (cssFiles.length === 0) throw new Error("dist/assets 中没有 CSS 构建产物");
const failures = [];
const hardTokens = [
[/@layer\b/, "@layer(Chrome 99+)"],
[/@property\b/, "@property(Chrome 85+)"],
[/:is\(/, ":is()(Chrome 88+)"],
[/\(width\s*>=|\(width\s*<=|\(height\s*>=/, "媒体查询 range 语法(Chrome 104+)"],
];
let guardedColors = 0;
for (const file of cssFiles) {
const raw = fs.readFileSync(path.join(assetsDir, file), "utf8");
for (const [re, label] of hardTokens) {
const hits = raw.match(new RegExp(re.source, "g")) || [];
if (hits.length > 0) failures.push(`${file}: ${hits.length} 处 ${label}`);
}
const root = postcss.parse(raw, { from: file });
root.walkDecls((decl) => {
if (!unsupportedColor.test(decl.value)) return;
guardedColors += 1;
let anc = decl.parent;
while (anc) {
if (anc.type === "atrule" && anc.name === "supports" && unsupportedColor.test(anc.params)) return;
anc = anc.parent;
}
let prev = decl.prev();
while (prev?.type === "comment") prev = prev.prev();
const hasFallback =
!decl.prop.startsWith("--") &&
prev?.type === "decl" &&
prev.prop === decl.prop &&
!unsupportedColor.test(prev.value);
if (!hasFallback) {
failures.push(`${file}: 未守护的现代颜色 ${decl.prop}: ${decl.value.slice(0, 60)}`);
}
});
}
if (failures.length > 0) {
console.error("旧浏览器 CSS 兼容检查失败:\n");
console.error(failures.slice(0, 50).join("\n"));
if (failures.length > 50) console.error(`... 及另外 ${failures.length - 50} 处`);
process.exit(1);
}
console.log(
`旧浏览器(麒麟 Chromium ~80)CSS 兼容检查通过(${guardedColors} 处守护的现代颜色声明)。`,
);
@@ -1,31 +0,0 @@
/**
* 把 Tailwind v3.4 class 模式暗色变体输出的 `X:is(.dark *)` 机械改写为
* `.dark X` 后代选择器,供麒麟 Chromium ~80 使用(无 :is())。
*
* - 特异性不变:`:is(.dark *)` 取最高参数 = (0,1,0),剥离后前置 `.dark `
* 同样 +(0,1,0),两种形式均为 (0,2,0),级联行为一致。
* - `.dark` 挂在 <html> 上、是全局祖先,前置到组合器最左侧语义等价
* (group-hover:dark:* 等组合下同样成立)。
* - 堆叠变体(dark:marker:* 等)生成的 `X *:is(.dark *)::marker` 会被
* 改写为 `.dark X *::marker`,保持合法。
*/
const TOKEN = ":is(.dark *)";
export default function darkDescendant() {
return {
postcssPlugin: "postcss-dark-descendant",
Rule(rule) {
if (!rule.selector.includes(TOKEN)) return;
rule.selector = rule.selector
.split(",")
.map((sel) => {
const trimmed = sel.trim();
if (!trimmed.includes(TOKEN)) return trimmed;
return ".dark " + trimmed.split(TOKEN).join("");
})
.join(", ");
},
};
}
darkDescendant.postcss = true;
@@ -0,0 +1,70 @@
/**
* Tailwind 4 gracefully falls back from color-mix() to the base semantic
* color, but that turns utilities such as bg-primary/5 into an opaque fill on
* Chrome 109. Replace that fallback with an sRGB-channel based alpha color.
* The modern color-mix() declaration remains in its @supports block.
*/
const LEGACY_RGB_TOKENS = new Set([
"accent",
"background",
"border",
"brand-200",
"card",
"destructive",
"foreground",
"input",
"muted",
"muted-foreground",
"primary",
"primary-foreground",
"ring",
"secondary",
"sidebar-accent",
"sidebar-border",
"sidebar-foreground",
"workspace-brand",
]);
const TRANSPARENT_MIX_RE =
/color-mix\(\s*in\s+[\w-]+\s*,\s*var\(--([\w-]+)\)\s+([\d.]+)%\s*,\s*transparent\s*\)/i;
function legacyAlphaColor(value) {
const match = value.match(TRANSPARENT_MIX_RE);
if (!match || !LEGACY_RGB_TOKENS.has(match[1])) return null;
const [, token, percentage] = match;
return `rgb(var(--legacy-${token}-rgb) / ${percentage}%)`;
}
export default function legacyColorFallback() {
return {
postcssPlugin: "postcss-legacy-color-fallback",
OnceExit(root) {
root.walkAtRules("supports", (atRule) => {
if (!atRule.params.includes("color-mix")) return;
atRule.walkDecls((modernDeclaration) => {
const fallbackValue = legacyAlphaColor(modernDeclaration.value);
if (!fallbackValue) return;
// Tailwind emits the old-browser declaration immediately before
// the nested @supports block, in the same utility/variant rule.
let fallbackDeclaration = atRule.prev();
while (fallbackDeclaration?.type === "comment") {
fallbackDeclaration = fallbackDeclaration.prev();
}
if (
fallbackDeclaration?.type === "decl" &&
fallbackDeclaration.prop === modernDeclaration.prop
) {
fallbackDeclaration.value = fallbackValue;
}
});
});
},
};
}
legacyColorFallback.postcss = true;
@@ -0,0 +1,33 @@
import postcss from "postcss";
import { transform } from "lightningcss";
const LEGACY_CHROME = 100 << 16;
/**
* Keep Vite's PostCSS pipeline (required by Tailwind 4), then compile the
* resulting CSS to Chrome 100-compatible syntax for both dev and production.
*/
export default function lightningCssLegacyChrome() {
return {
postcssPlugin: "postcss-lightningcss-legacy-chrome",
OnceExit(root, { result }) {
const filename = root.source?.input.file ?? result.opts.from ?? "style.css";
const output = transform({
filename,
code: Buffer.from(root.toString()),
targets: { chrome: LEGACY_CHROME },
minify: false,
});
const compiled = postcss.parse(output.code.toString(), { from: filename });
root.removeAll();
root.append(compiled.nodes);
for (const warning of output.warnings) {
result.warn(warning.message, { plugin: "lightningcss" });
}
},
};
}
lightningCssLegacyChrome.postcss = true;
+39
View File
@@ -0,0 +1,39 @@
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = fileURLToPath(new URL("../", import.meta.url));
const require = createRequire(import.meta.url);
// Resolve Vite's installed dependency directly: pnpm does not expose a
// transitive esbuild binary in node_modules/.bin, and tests must run offline.
const viteRequire = createRequire(require.resolve("vite/package.json"));
const { build, stop } = viteRequire("esbuild");
const testEntries = {
"login-attempt": "src/core/auth/login-attempt.test.ts",
"login-entry": "src/core/auth/login-entry.test.ts",
"session-validation": "src/core/auth/session-validation.test.ts",
"auth-request-policy": "src/core/api/auth-request-policy.test.ts",
};
const outputDirectory = path.join(root, ".runtime/unit-tests");
try {
await build({
absWorkingDir: root,
entryPoints: testEntries,
outdir: outputDirectory,
outExtension: { ".js": ".mjs" },
bundle: true,
platform: "node",
format: "esm",
});
} finally {
stop();
}
const result = spawnSync(process.execPath, ["--test", ...Object.keys(testEntries).map((name) => path.join(outputDirectory, `${name}.mjs`))], {
cwd: root,
stdio: "inherit",
});
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
@@ -0,0 +1,28 @@
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
// Use Vite's installed bundler, including pnpm layouts without a root esbuild bin.
const require = createRequire(import.meta.url);
const viteRequire = createRequire(require.resolve("vite/package.json"));
const { build } = viteRequire("esbuild");
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const suites = [
"src/core/threads/background-history.test.ts",
"src/core/api/stream-cursor-replay.test.ts",
"src/core/threads/stream-display.test.ts",
"src/core/threads/stream-recovery.test.ts",
"src/core/threads/stream-terminal-recovery.test.ts",
"src/core/threads/stream-reasoning-replay.test.ts",
"src/core/threads/project-metadata.test.ts",
];
const outdir = resolve(root, ".runtime/unit-tests/thread-history");
await build({
absWorkingDir: root, entryPoints: suites, outbase: "src/core", outdir,
bundle: true, platform: "node", format: "esm", define: { "import.meta.env": "{}" }, logLevel: "warning",
});
const files = suites.map((suite) => resolve(outdir, suite.replace("src/core/", "").replace(/\.ts$/, ".js")));
const result = spawnSync(process.execPath, ["--test", ...files], { cwd: root, stdio: "inherit" });
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
+112 -79
View File
@@ -1,15 +1,18 @@
import { useEffect, useMemo } from "react";
import { lazy, Suspense, useEffect, useMemo } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { DesensitizeWordsLoader } from "@/components/desensitize-words-loader";
import { FontScaleApplier } from "@/components/font-scale";
import { QueryClientProvider } from "@/components/query-client-provider";
import { RouteLoadingFallback } from "@/components/route-loading";
import { AuthSyncWatcher } from "@/core/auth/sync";
import { RequireAccount } from "@/core/auth/require-account";
import { I18nProvider } from "@/core/i18n/context";
import { fetchYLogConfig } from "@/core/log/y-log-config";
import { detectLocale } from "@/core/i18n/locale";
import { resolveForcedThemeForPath } from "@/core/page-layout/chats-iframe-route";
import { resolveB51ForcedTheme } from "@/core/auth/b5-1-theme";
import { ChatProvider } from "@/contexts/ChatContext";
import { ThemeProvider } from "@/contexts/ThemeContext";
import {
@@ -25,28 +28,46 @@ import {
import { HashRouter, useLocation } from "react-router-dom";
import LandingPage from "./pages/LandingPage";
import LoginPage from "./pages/LoginPage";
import PageRoutes from "./pages/PageRoutes";
import PublicScheduledTaskPage, {
PublicScheduledTaskFullscreenPage,
} from "./pages/PublicScheduledTaskPage";
import PublicSharePage from "./pages/PublicSharePage";
import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
import EmbedChatPage from "./pages/EmbedChatPage";
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
import SiteStudioPage from "./pages/site-studio/SiteStudioPage";
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
import {
isPositionRoundtableStandaloneMode,
POSITION_ROUNDTABLE_ROUTE,
} from "@/position-roundtable/demo/position-roundtable-demo-config";
import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
import OfficialDeerFlowEntry from "./pages/OfficialDeerFlowEntry";
const LandingPage = lazy(() => import("./pages/LandingPage"));
const LoginPage = lazy(() => import("./pages/LoginPage"));
const PageRoutes = lazy(() => import("./pages/PageRoutes"));
const PublicScheduledTaskPage = lazy(() => import("./pages/PublicScheduledTaskPage"));
const PublicScheduledTaskFullscreenPage = lazy(() =>
import("./pages/PublicScheduledTaskPage").then((module) => ({
default: module.PublicScheduledTaskFullscreenPage,
})),
);
const PublicSharePage = lazy(() => import("./pages/PublicSharePage"));
const PublicLeaderboardPage = lazy(() => import("./pages/PublicLeaderboardPage"));
const PublicRoundtableSharePage = lazy(() => import("./pages/PublicRoundtableSharePage"));
const EmbedChatPage = lazy(() => import("./pages/EmbedChatPage"));
const EmbedRoundtablePage = lazy(() => import("./pages/EmbedRoundtablePage"));
const SiteStudioPage = lazy(() => import("./pages/site-studio/SiteStudioPage"));
const DashboardStandalonePage = lazy(
() => import("@/roundtable-planning/pages/DashboardStandalonePage"),
);
const PositionRoundtablePage = lazy(
() => import("@/position-roundtable/pages/PositionRoundtablePage"),
);
const WeKnoraWikiPageView = lazy(
() => import("@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView"),
);
const ChatRuntime = lazy(() =>
import("@/components/workspace/chat-runtime").then((module) => ({
default: module.ChatRuntime,
})),
);
const NotFoundPage = lazy(() =>
import("./pages/NotFoundPage").then((module) => ({
default: module.NotFoundPage,
})),
);
const OfficialDeerFlowEntry = lazy(() => import("./pages/OfficialDeerFlowEntry"));
const PUBLIC_EMBED_PREFIXES = [
"/share/",
@@ -72,6 +93,10 @@ function AppProviders({ children }: { children: React.ReactNode }) {
void fetchYLogConfig();
}, [positionStandaloneMode]);
const forcedTheme = useMemo(() => {
// b5-1 任务分析深链最高优先:接入方传 ?theme= 用传入值,不传默认深蓝。
// 先于 embed 判定,避免 embed=1 缺省 light 盖掉本页的深蓝默认。
const b51Theme = resolveB51ForcedTheme(location.pathname, location.search);
if (b51Theme) return b51Theme;
// iframe 嵌入模式(外部系统 `&embed=1`)最高优先:按本标签页的嵌入主题强制明/暗。
// 走 next-themes forcedTheme(不写 localStorage)→ 同浏览器另开的正常系统页不受影响。
const embedTheme = resolveForcedEmbedTheme(location.search);
@@ -145,72 +170,80 @@ export function App() {
<Navigate to={POSITION_ROUNDTABLE_ROUTE} replace />
);
const pageElement = positionStandaloneMode ? (
<ChatRuntime>
<PositionRoundtablePage compactChrome />
</ChatRuntime>
<RequireAccount allowLegacyAnonymous>
<ChatRuntime>
<PositionRoundtablePage compactChrome />
</ChatRuntime>
</RequireAccount>
) : (
<PageRoutes />
<RequireAccount><PageRoutes /></RequireAccount>
);
return (
<HashRouter>
<AppProviders>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route
path="/login"
element={positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />}
/>
<Route
path="/login/:username"
element={positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />}
/>
<Route path="/landing" element={<LandingPage />} />
<Route path="/share/:shareCode" element={<PublicSharePage />} />
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
<Route
path="/roundtable-share/:shareCode"
element={<PublicRoundtableSharePage view="landing" />}
/>
<Route
path="/roundtable-share/:shareCode/report"
element={<PublicRoundtableSharePage view="report" />}
/>
<Route
path="/roundtable-share/:shareCode/dashboard"
element={<PublicRoundtableSharePage view="dashboard" />}
/>
<Route
path="/public/scheduled-tasks/:taskId"
element={<PublicScheduledTaskPage />}
/>
<Route
path="/public/scheduled-tasks/:taskId/runs/:runId/fullscreen"
element={<PublicScheduledTaskFullscreenPage />}
/>
<Route
path="/embed/chats/:thread_id"
element={
<ChatRuntime>
<EmbedChatPage />
</ChatRuntime>
}
/>
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
<Route
path="/embed/knowledge/:noteId"
element={<WeKnoraWikiPageView publicEmbed />}
/>
<Route
path="/roundtable-dashboard"
element={<DashboardStandalonePage />}
/>
<Route path="/official/*" element={<OfficialDeerFlowEntry />} />
{/* Web App 站点预览工作室:独立路由 + 独立视觉风格(无主应用 chrome) */}
<Route path="/sites/:threadId/:siteName" element={<SiteStudioPage />} />
<Route path="/page/*" element={pageElement} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
<Suspense fallback={<RouteLoadingFallback />}>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route
path="/login"
element={
positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />
}
/>
<Route
path="/login/:username"
element={
positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />
}
/>
<Route path="/landing" element={<LandingPage />} />
<Route path="/share/:shareCode" element={<PublicSharePage />} />
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
<Route
path="/roundtable-share/:shareCode"
element={<PublicRoundtableSharePage view="landing" />}
/>
<Route
path="/roundtable-share/:shareCode/report"
element={<PublicRoundtableSharePage view="report" />}
/>
<Route
path="/roundtable-share/:shareCode/dashboard"
element={<PublicRoundtableSharePage view="dashboard" />}
/>
<Route
path="/public/scheduled-tasks/:taskId"
element={<PublicScheduledTaskPage />}
/>
<Route
path="/public/scheduled-tasks/:taskId/runs/:runId/fullscreen"
element={<PublicScheduledTaskFullscreenPage />}
/>
<Route
path="/embed/chats/:thread_id"
element={
<ChatRuntime>
<EmbedChatPage />
</ChatRuntime>
}
/>
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
<Route
path="/embed/knowledge/:noteId"
element={<WeKnoraWikiPageView publicEmbed />}
/>
<Route
path="/roundtable-dashboard"
element={<RequireAccount><DashboardStandalonePage /></RequireAccount>}
/>
<Route path="/official/*" element={<RequireAccount><OfficialDeerFlowEntry /></RequireAccount>} />
{/* Web App 站点预览工作室:独立路由 + 独立视觉风格(无主应用 chrome) */}
<Route path="/sites/:threadId/:siteName" element={<RequireAccount><SiteStudioPage /></RequireAccount>} />
<Route path="/page/*" element={pageElement} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</AppProviders>
</HashRouter>
);
@@ -34,14 +34,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { PromptInputFilePart, UploadPolicy } from "@/core/uploads";
import {
DEFAULT_UPLOAD_POLICY,
splitUnsupportedUploadFiles,
useUploadPolicy,
validateChatUploadFiles,
} from "@/core/uploads";
import { isIMEComposing } from "@/lib/ime";
import type { PromptInputFilePart, UploadPolicy } from "@/core/uploads";
import {
DEFAULT_UPLOAD_POLICY,
splitUnsupportedUploadFiles,
useUploadPolicy,
validateChatUploadFiles,
} from "@/core/uploads";
import { imeEnterAction } from "@/lib/ime";
import { cn } from "@/lib/utils";
import type { ChatStatus } from "ai";
import {
@@ -61,11 +61,12 @@ import {
type ChangeEventHandler,
Children,
type ClipboardEventHandler,
type ComponentProps,
createContext,
type FormEvent,
type FormEventHandler,
type HTMLAttributes,
type ComponentProps,
type CompositionEventHandler,
createContext,
type FormEvent,
type FormEventHandler,
type HTMLAttributes,
type KeyboardEventHandler,
type PropsWithChildren,
type ReactNode,
@@ -128,11 +129,11 @@ export const usePromptInputController = () => {
return ctx;
};
// Optional variants (do NOT throw). Useful for dual-mode components and for
// Fast Refresh transitions where a page can briefly retain the previous
// provider context instance.
export const useOptionalPromptInputController = () =>
useContext(PromptInputController);
// Optional variants (do NOT throw). Useful for dual-mode components and for
// Fast Refresh transitions where a page can briefly retain the previous
// provider context instance.
export const useOptionalPromptInputController = () =>
useContext(PromptInputController);
export const useProviderAttachments = () => {
const ctx = useContext(ProviderAttachmentsContext);
@@ -148,94 +149,94 @@ const useOptionalProviderAttachments = () =>
useContext(ProviderAttachmentsContext);
const usePromptInputValidation = () => useContext(PromptInputValidationContext);
export type PromptInputProviderProps = PropsWithChildren<{
initialInput?: string;
uploadPolicy?: UploadPolicy;
}>;
export type PromptInputProviderProps = PropsWithChildren<{
initialInput?: string;
uploadPolicy?: UploadPolicy;
}>;
/**
* Optional global provider that lifts PromptInput state outside of PromptInput.
* If you don't use it, PromptInput stays fully self-managed.
*/
export function PromptInputProvider({
initialInput: initialTextInput = "",
uploadPolicy,
children,
}: PromptInputProviderProps) {
const { data: fetchedUploadPolicy } = useUploadPolicy();
const effectiveUploadPolicy =
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
// ----- textInput state
export function PromptInputProvider({
initialInput: initialTextInput = "",
uploadPolicy,
children,
}: PromptInputProviderProps) {
const { data: fetchedUploadPolicy } = useUploadPolicy();
const effectiveUploadPolicy =
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
// ----- textInput state
const [textInput, setTextInput] = useState(initialTextInput);
const clearInput = useCallback(() => setTextInput(""), []);
// ----- attachments state (global when wrapped)
const [attachmentFiles, setAttachmentFiles] = useState<
(PromptInputFilePart & { id: string })[]
>([]);
const attachmentsRef = useRef(attachmentFiles);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const openRef = useRef<() => void>(() => {});
const add = useCallback((files: File[] | FileList) => {
const incoming = Array.from(files);
>([]);
const attachmentsRef = useRef(attachmentFiles);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const openRef = useRef<() => void>(() => {});
const add = useCallback((files: File[] | FileList) => {
const incoming = Array.from(files);
if (incoming.length === 0) {
return;
}
const current = attachmentsRef.current;
const existing = current.map((item) => ({
name: item.filename ?? "attachment",
size: item.file?.size ?? 0,
type: item.mediaType ?? "",
}));
const validation = validateChatUploadFiles(
incoming,
existing,
effectiveUploadPolicy,
);
for (const message of new Set(
validation.issues.map((issue) => issue.message),
)) {
toast.error(message);
}
if (validation.accepted.length === 0) return;
const next = current.concat(
validation.accepted.map((file) => ({
id: nanoid(),
type: "file" as const,
url: URL.createObjectURL(file),
mediaType: file.type,
filename: file.name,
file,
})),
);
attachmentsRef.current = next;
setAttachmentFiles(next);
}, [effectiveUploadPolicy]);
const remove = useCallback((id: string) => {
const current = attachmentsRef.current;
const found = current.find((f) => f.id === id);
if (found?.url) URL.revokeObjectURL(found.url);
const next = current.filter((f) => f.id !== id);
attachmentsRef.current = next;
setAttachmentFiles(next);
}, []);
const clear = useCallback(() => {
for (const f of attachmentsRef.current) {
if (f.url) {
URL.revokeObjectURL(f.url);
}
}
attachmentsRef.current = [];
setAttachmentFiles([]);
}, []);
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
attachmentsRef.current = attachmentFiles;
return;
}
const current = attachmentsRef.current;
const existing = current.map((item) => ({
name: item.filename ?? "attachment",
size: item.file?.size ?? 0,
type: item.mediaType ?? "",
}));
const validation = validateChatUploadFiles(
incoming,
existing,
effectiveUploadPolicy,
);
for (const message of new Set(
validation.issues.map((issue) => issue.message),
)) {
toast.error(message);
}
if (validation.accepted.length === 0) return;
const next = current.concat(
validation.accepted.map((file) => ({
id: nanoid(),
type: "file" as const,
url: URL.createObjectURL(file),
mediaType: file.type,
filename: file.name,
file,
})),
);
attachmentsRef.current = next;
setAttachmentFiles(next);
}, [effectiveUploadPolicy]);
const remove = useCallback((id: string) => {
const current = attachmentsRef.current;
const found = current.find((f) => f.id === id);
if (found?.url) URL.revokeObjectURL(found.url);
const next = current.filter((f) => f.id !== id);
attachmentsRef.current = next;
setAttachmentFiles(next);
}, []);
const clear = useCallback(() => {
for (const f of attachmentsRef.current) {
if (f.url) {
URL.revokeObjectURL(f.url);
}
}
attachmentsRef.current = [];
setAttachmentFiles([]);
}, []);
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
attachmentsRef.current = attachmentFiles;
// Cleanup blob URLs on unmount to prevent memory leaks
useEffect(() => {
@@ -430,14 +431,14 @@ export function PromptInputAttachments({
return (
<div
className={cn("flex w-full flex-wrap items-center gap-2 p-3", className)}
{...props}
>
{attachments.files.map((file) => (
<div key={file.id} className="max-w-60">
{children(file)}
</div>
))}
</div>
{...props}
>
{attachments.files.map((file) => (
<div key={file.id} className="max-w-60">
{children(file)}
</div>
))}
</div>
);
}
@@ -483,17 +484,17 @@ export type PromptInputProps = Omit<
// Render a hidden input with given name and keep it in sync for native form posts. Default false.
syncHiddenInput?: boolean;
// Minimal constraints
maxFiles?: number;
maxFileSize?: number; // bytes
uploadPolicy?: UploadPolicy;
onError?: (err: {
code:
| "max_files"
| "max_file_size"
| "max_total_size"
| "unsupported_type"
| "accept"
| "unsupported_package";
maxFiles?: number;
maxFileSize?: number; // bytes
uploadPolicy?: UploadPolicy;
onError?: (err: {
code:
| "max_files"
| "max_file_size"
| "max_total_size"
| "unsupported_type"
| "accept"
| "unsupported_package";
message: string;
}) => void;
onSubmit: (
@@ -509,25 +510,25 @@ export const PromptInput = ({
multiple,
globalDrop,
syncHiddenInput,
maxFiles,
maxFileSize,
uploadPolicy,
maxFiles,
maxFileSize,
uploadPolicy,
onError,
onSubmit,
children,
...props
}: PromptInputProps) => {
const { data: fetchedUploadPolicy } = useUploadPolicy();
const effectiveUploadPolicy =
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
const policyAccept = useMemo(
() =>
Object.values(effectiveUploadPolicy.groups)
.flatMap((group) => group.extensions)
.join(","),
[effectiveUploadPolicy],
);
// Try to use a provider controller if present
}: PromptInputProps) => {
const { data: fetchedUploadPolicy } = useUploadPolicy();
const effectiveUploadPolicy =
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
const policyAccept = useMemo(
() =>
Object.values(effectiveUploadPolicy.groups)
.flatMap((group) => group.extensions)
.join(","),
[effectiveUploadPolicy],
);
// Try to use a provider controller if present
const controller = useOptionalPromptInputController();
const usingProvider = !!controller;
@@ -560,11 +561,11 @@ export const PromptInput = ({
.map((s) => s.trim())
.filter(Boolean);
return patterns.some((pattern) => {
if (pattern.startsWith(".")) {
return f.name.toLowerCase().endsWith(pattern.toLowerCase());
}
if (pattern.endsWith("/*")) {
return patterns.some((pattern) => {
if (pattern.startsWith(".")) {
return f.name.toLowerCase().endsWith(pattern.toLowerCase());
}
if (pattern.endsWith("/*")) {
const prefix = pattern.slice(0, -1); // e.g: image/* -> image/
return f.type.startsWith(prefix);
}
@@ -574,72 +575,72 @@ export const PromptInput = ({
[accept],
);
const addLocal = useCallback(
(fileList: File[] | FileList) => {
const incoming = Array.from(fileList);
const accepted = incoming.filter((f) => matchesAccept(f));
if (accepted.length < incoming.length) {
const error = {
code: "accept",
message: "部分文件不符合当前输入框允许的格式。",
} as const;
onError?.(error);
if (!onError) toast.error(error.message);
}
const localPolicy: UploadPolicy = {
...effectiveUploadPolicy,
max_files_per_message:
typeof maxFiles === "number"
? Math.min(effectiveUploadPolicy.max_files_per_message, maxFiles)
: effectiveUploadPolicy.max_files_per_message,
groups: Object.fromEntries(
Object.entries(effectiveUploadPolicy.groups).map(([key, group]) => [
key,
{
...group,
max_bytes:
typeof maxFileSize === "number"
? Math.min(group.max_bytes, maxFileSize)
: group.max_bytes,
},
]),
) as UploadPolicy["groups"],
};
const existing = items.map((item) => ({
name: item.filename ?? "attachment",
size: item.file?.size ?? 0,
type: item.mediaType ?? "",
}));
const validation = validateChatUploadFiles(
accepted,
existing,
localPolicy,
);
for (const issue of Array.from(
new Map(
validation.issues.map((item) => [item.message, item]),
).values(),
)) {
onError?.({ code: issue.code, message: issue.message });
if (!onError) toast.error(issue.message);
}
if (validation.accepted.length === 0) return;
setItems((prev) =>
prev.concat(
validation.accepted.map((file) => ({
id: nanoid(),
type: "file" as const,
url: URL.createObjectURL(file),
mediaType: file.type,
filename: file.name,
file,
})),
),
);
},
[effectiveUploadPolicy, items, matchesAccept, maxFiles, maxFileSize, onError],
);
const addLocal = useCallback(
(fileList: File[] | FileList) => {
const incoming = Array.from(fileList);
const accepted = incoming.filter((f) => matchesAccept(f));
if (accepted.length < incoming.length) {
const error = {
code: "accept",
message: "部分文件不符合当前输入框允许的格式。",
} as const;
onError?.(error);
if (!onError) toast.error(error.message);
}
const localPolicy: UploadPolicy = {
...effectiveUploadPolicy,
max_files_per_message:
typeof maxFiles === "number"
? Math.min(effectiveUploadPolicy.max_files_per_message, maxFiles)
: effectiveUploadPolicy.max_files_per_message,
groups: Object.fromEntries(
Object.entries(effectiveUploadPolicy.groups).map(([key, group]) => [
key,
{
...group,
max_bytes:
typeof maxFileSize === "number"
? Math.min(group.max_bytes, maxFileSize)
: group.max_bytes,
},
]),
) as UploadPolicy["groups"],
};
const existing = items.map((item) => ({
name: item.filename ?? "attachment",
size: item.file?.size ?? 0,
type: item.mediaType ?? "",
}));
const validation = validateChatUploadFiles(
accepted,
existing,
localPolicy,
);
for (const issue of Array.from(
new Map(
validation.issues.map((item) => [item.message, item]),
).values(),
)) {
onError?.({ code: issue.code, message: issue.message });
if (!onError) toast.error(issue.message);
}
if (validation.accepted.length === 0) return;
setItems((prev) =>
prev.concat(
validation.accepted.map((file) => ({
id: nanoid(),
type: "file" as const,
url: URL.createObjectURL(file),
mediaType: file.type,
filename: file.name,
file,
})),
),
);
},
[effectiveUploadPolicy, items, matchesAccept, maxFiles, maxFileSize, onError],
);
const removeLocal = useCallback(
(id: string) =>
@@ -827,11 +828,17 @@ export const PromptInput = ({
event.preventDefault();
const form = event.currentTarget;
// Prefer the live textarea. Firefox 102 applies the chosen candidate to
// the DOM before React state catches up, and a send click in that gap
// would otherwise submit the previous pinyin-less value.
const liveText = form.querySelector<HTMLTextAreaElement>(
"textarea[name='message']",
)?.value;
const text = usingProvider
? controller.textInput.value
? (liveText ?? controller.textInput.value)
: (() => {
const formData = new FormData(form);
return (formData.get("message") as string) || "";
return (formData.get("message") as string) || liveText || "";
})();
// Reset form immediately after capturing text to avoid race condition
@@ -894,7 +901,7 @@ export const PromptInput = ({
const inner = (
<PromptInputValidationContext.Provider value={sanitizeIncomingFiles}>
<input
accept={accept ?? policyAccept}
accept={accept ?? policyAccept}
aria-label="Upload files"
className="hidden"
multiple={multiple}
@@ -909,10 +916,10 @@ export const PromptInput = ({
ref={formRef}
{...props}
>
{/* Do not rely on InputGroup's :has(textarea) selector here: Chrome
versions before 105 ignore :has(), retain h-9, and clip the whole
multi-line composer to a single row. */}
<InputGroup className="prompt-input-group h-auto min-h-16 flex-col items-stretch">
{/* Do not rely on InputGroup's :has() selectors here: Chrome versions
before 105 ignore them, keep the horizontal flex layout, and
squeeze the textarea beside the footer controls. */}
<InputGroup className="h-auto min-h-16 flex-col items-stretch">
{children}
</InputGroup>
</form>
@@ -934,69 +941,107 @@ export const PromptInputBody = ({
className,
...props
}: PromptInputBodyProps) => (
<div
data-slot="prompt-input-body"
className={cn("contents", className)}
{...props}
/>
<div className={cn("contents", className)} {...props} />
);
export type PromptInputTextareaProps = ComponentProps<
typeof InputGroupTextarea
>;
export const PromptInputTextarea = ({
onChange,
onInput,
className,
placeholder = "What would you like to know?",
...props
}: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController();
const attachments = usePromptInputAttachments();
const sanitizeIncomingFiles = usePromptInputValidation();
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const resizeForLegacyChrome = useCallback((textarea: HTMLTextAreaElement) => {
if (
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports("field-sizing", "content")
) {
return;
}
// Chrome 109 ignores field-sizing: content. Preserve the same 4rem–12rem
// autosize range used by min-h-16/max-h-48 instead of leaving the prompt
// stuck at its minimum height.
textarea.style.height = "auto";
const height = Math.max(64, Math.min(textarea.scrollHeight, 192));
textarea.style.height = `${height}px`;
textarea.style.overflowY = textarea.scrollHeight > 192 ? "auto" : "hidden";
}, []);
// onInput does not run for a controlled value filled by a suggested question
// or restored draft. Resize once after every render in legacy Chrome too.
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const frame = window.requestAnimationFrame(() => {
resizeForLegacyChrome(textarea);
});
return () => window.cancelAnimationFrame(frame);
}, [controller?.textInput.value, resizeForLegacyChrome]);
export const PromptInputTextarea = ({
onChange,
onInput,
onKeyDown,
onCompositionStart,
onCompositionEnd,
className,
placeholder = "What would you like to know?",
defaultValue,
...props
}: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController();
const attachments = usePromptInputAttachments();
const sanitizeIncomingFiles = usePromptInputValidation();
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const composingRef = useRef(false);
const compositionEndedAtRef = useRef(0);
const onChangeRef = useRef(onChange);
const onInputRef = useRef(onInput);
const onKeyDownRef = useRef(onKeyDown);
const onCompositionStartRef = useRef(onCompositionStart);
const onCompositionEndRef = useRef(onCompositionEnd);
const setInputRef = useRef(controller?.textInput.setInput);
const attachmentsRef = useRef(attachments);
onChangeRef.current = onChange;
onInputRef.current = onInput;
onKeyDownRef.current = onKeyDown;
onCompositionStartRef.current = onCompositionStart;
onCompositionEndRef.current = onCompositionEnd;
setInputRef.current = controller?.textInput.setInput;
attachmentsRef.current = attachments;
const publishValue = useCallback((value: string) => {
setInputRef.current?.(value);
}, []);
const resizeForLegacyChrome = useCallback((textarea: HTMLTextAreaElement) => {
// Firefox 102 commits an open IME composition as raw pinyin when the
// textarea's height or overflow changes mid-composition.
if (composingRef.current) return;
if (
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports("field-sizing", "content")
) {
return;
}
// Chrome 109 ignores field-sizing: content. Preserve the same 4rem–12rem
// autosize range used by min-h-16/max-h-48 instead of leaving the prompt
// stuck at its minimum height.
textarea.style.height = "auto";
const height = Math.max(64, Math.min(textarea.scrollHeight, 192));
textarea.style.height = `${height}px`;
textarea.style.overflowY = textarea.scrollHeight > 192 ? "auto" : "hidden";
}, []);
// onInput does not run for a controlled value filled by a suggested question
// or restored draft. Resize once after every render in legacy Chrome too.
useEffect(() => {
if (composingRef.current) return;
const textarea = textareaRef.current;
if (!textarea) return;
const frame = window.requestAnimationFrame(() => {
resizeForLegacyChrome(textarea);
});
return () => window.cancelAnimationFrame(frame);
}, [controller?.textInput.value, resizeForLegacyChrome]);
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === "Enter") {
if (isIMEComposing(e, isComposing)) {
const action = imeEnterAction({
isComposing: composingRef.current,
nativeIsComposing: e.nativeEvent.isComposing || e.key === "Process",
keyCode: e.keyCode,
compositionEndedAt: compositionEndedAtRef.current,
now: performance.now(),
});
// Firefox 102 confirms the candidate on this key. preventDefault here
// cancels that confirmation and leaves the pinyin in the box.
if (action === "ignore") {
return;
}
if (e.shiftKey) {
onKeyDownRef.current?.(e);
if (e.defaultPrevented || e.shiftKey) {
return;
}
// The confirming Enter already finished the candidate. Swallow it so
// Firefox does not also insert a newline or send the message.
e.preventDefault();
if (action === "swallow") {
return;
}
// Check if the submit button is disabled before submitting
const form = e.currentTarget.form;
@@ -1008,22 +1053,59 @@ export const PromptInputTextarea = ({
}
form?.requestSubmit();
return;
}
onKeyDownRef.current?.(e);
if (e.defaultPrevented) return;
if (composingRef.current) return;
// Remove last attachment when Backspace is pressed and textarea is empty
if (
e.key === "Backspace" &&
e.currentTarget.value === "" &&
attachments.files.length > 0
attachmentsRef.current.files.length > 0
) {
e.preventDefault();
const lastAttachment = attachments.files.at(-1);
const lastAttachment = attachmentsRef.current.files.at(-1);
if (lastAttachment) {
attachments.remove(lastAttachment.id);
attachmentsRef.current.remove(lastAttachment.id);
}
}
};
const handleCompositionStart: CompositionEventHandler<HTMLTextAreaElement> = (
event,
) => {
composingRef.current = true;
compositionEndedAtRef.current = 0;
onCompositionStartRef.current?.(event);
};
const handleCompositionEnd: CompositionEventHandler<HTMLTextAreaElement> = (
event,
) => {
onCompositionEndRef.current?.(event);
composingRef.current = false;
compositionEndedAtRef.current = performance.now();
const el = event.currentTarget;
// Firefox 102 dispatches compositionend before the committed candidate is
// visible, and before the input event that carries it. Syncing the
// compositionend value here would write the raw pinyin back over 汉字.
window.setTimeout(() => {
if (el.isConnected) publishValue(el.value);
}, 0);
};
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = (event) => {
// Keep the controlled value identical to the DOM, including the pinyin
// still being composed. Otherwise React restores the previous value at
// the end of the input event and Firefox 102 cannot replace it.
publishValue(event.currentTarget.value);
onChangeRef.current?.(event);
};
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {
const items = event.clipboardData?.items;
@@ -1053,35 +1135,28 @@ export const PromptInputTextarea = ({
}
};
const controlledProps = controller
? {
value: controller.textInput.value,
onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
controller.textInput.setInput(e.currentTarget.value);
onChange?.(e);
},
}
: {
onChange,
};
return (
<InputGroupTextarea
className={cn("field-sizing-content max-h-48 min-h-16", className)}
name="message"
ref={textareaRef}
rows={3}
onCompositionEnd={() => setIsComposing(false)}
onCompositionStart={() => setIsComposing(true)}
onKeyDown={handleKeyDown}
onInput={(event) => {
resizeForLegacyChrome(event.currentTarget);
onInput?.(event);
}}
<InputGroupTextarea
{...props}
className={cn("field-sizing-content max-h-48 min-h-16", className)}
name="message"
ref={textareaRef}
rows={3}
onChange={handleChange}
onCompositionEnd={handleCompositionEnd}
onCompositionStart={handleCompositionStart}
onKeyDown={handleKeyDown}
onInput={(event) => {
if (!composingRef.current) {
resizeForLegacyChrome(event.currentTarget);
}
onInputRef.current?.(event);
}}
onPaste={handlePaste}
placeholder={placeholder}
{...props}
{...controlledProps}
{...(controller
? { value: controller.textInput.value }
: { defaultValue })}
/>
);
};
@@ -1199,24 +1274,28 @@ export const PromptInputSubmit = ({
status,
children,
...props
}: PromptInputSubmitProps) => {
let Icon = <ArrowUpIcon className="size-4" />;
if (status === "submitted") {
Icon = <Loader2Icon className="size-4 animate-spin" />;
} else if (status === "streaming") {
Icon = <SquareIcon className="size-4" />;
} else if (status === "error") {
Icon = <XIcon className="size-4" />;
}
return (
<InputGroupButton
aria-label="Submit"
}: PromptInputSubmitProps) => {
let Icon = <ArrowUpIcon className="size-4" />;
let ariaLabel = "发送消息";
if (status === "submitted") {
Icon = <Loader2Icon className="size-4 animate-spin" />;
ariaLabel = "正在发送";
} else if (status === "streaming") {
Icon = <SquareIcon className="size-4" />;
ariaLabel = "停止生成";
} else if (status === "error") {
Icon = <XIcon className="size-4" />;
ariaLabel = "重新发送";
}
return (
<InputGroupButton
aria-label={ariaLabel}
className={cn(
"transition-all duration-300",
status === "streaming" &&
"streaming-stop-button bg-primary/10 text-primary border-primary/25 hover:bg-primary/14",
"transition-all duration-300",
status === "streaming" &&
"animate-breathe-stop bg-primary/10 text-primary border-primary/25 shadow-[0_0_0_0_rgba(217,33,33,0.25)] hover:bg-primary/14",
className,
)}
size={size}
@@ -31,6 +31,11 @@ import { queryClient } from "@/components/query-client-provider";
import { Checkbox } from "@/components/ui/checkbox";
import Empty from "./empty";
import { NetworkActor } from "./network-actor";
import {
normalizeV2UploadSources,
type StudioUploadSource,
type V2UploadResponse,
} from "@/core/studio/document-upload";
// ── 适配层 ─────────────────────────────────────────────────
@@ -86,22 +91,7 @@ interface DocumentUploadDialogContextType {
data?: { query: string; mode: string }
) => Promise<void>;
// 上传成功回调,返回上传的来源信息
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
}
// ── 任务轮询类型 ─────────────────────────────────────────────
interface TaskSource {
task_id: string;
title: string;
status: 'processing' | 'success' | 'error';
source_type: string;
}
interface UploadResponse {
status: string;
uploaded: number;
skipped: number;
sources: TaskSource[];
onUploadComplete?: (sources: StudioUploadSource[]) => void;
}
interface listDataItem {
@@ -224,7 +214,7 @@ export const DocumentUploadDialogProvider: FC<{
) => void | Promise<void>;
searchSpace: { id: number | string;[key: string]: unknown };
onUploadSuccess?: () => void;
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
onUploadComplete?: (sources: StudioUploadSource[]) => void;
}> = ({ children, onClose, searchSpace, onUploadSuccess, onUploadComplete }) => {
const [isOpen, setIsOpen] = useState(false);
const [initialSearchText, setInitialSearchText] = useState("");
@@ -353,27 +343,23 @@ const DocumentUploadPopupContent: FC<{
for (const file of files) {
formData.append("files", file);
}
const res = await fetch(`${backendUrl}/api/v1/documents/fileupload`, {
const res = await fetch(`${backendUrl}/api/v1/documents/fileupload-v2`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (res.ok) {
const data = await res.json() as UploadResponse;
const data = await res.json() as V2UploadResponse;
// 调用上传完成回调,传递来源信息
if (data.sources && data.sources.length > 0) {
onUploadComplete?.(data.sources.map(s => ({
id: undefined, // 后端返回的数据中暂时没有 id
title: s.title,
task_id: s.task_id,
status: s.status,
})));
onUploadComplete?.(normalizeV2UploadSources(data));
}
toast.success(`上传成功 ${data.uploaded || files.length} 个文件`);
// 只关闭上传弹窗
onOpenChange(false);
} else {
toast.error("上传失败,请重试");
const payload = await res.json().catch(() => null) as { detail?: string } | null;
toast.error(payload?.detail || `上传失败(HTTP ${res.status})`);
}
} catch {
toast.error("上传失败,请重试");
@@ -410,7 +396,6 @@ const DocumentUploadPopupContent: FC<{
selectedSearchSources.map((s) => ({
id: undefined,
title: s.title,
task_id: undefined,
status: "success" as const,
}))
);
@@ -614,6 +599,8 @@ const DocumentUploadPopupContent: FC<{
};
const yUserId = getYUserId();
const userToken = getStoredAuth()?.access_token;
const iframeTokenParam = userToken ? `&token=${encodeURIComponent(userToken)}` : "";
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
@@ -687,7 +674,7 @@ const DocumentUploadPopupContent: FC<{
{activeTab === "search" && !isSearching && (
<iframe
className="w-full h-[calc(100vh-200px)]"
src={`${knowesUrl}/fromB1?yUserId=${yUserId}&processType=searchMode&bookChatId=${searchSpaceId}&inputValue=${searchValue}`}
src={`${knowesUrl}/fromB1?yUserId=${yUserId}&processType=searchMode&bookChatId=${searchSpaceId}&inputValue=${searchValue}${iframeTokenParam}`}
/>
)}
@@ -695,7 +682,7 @@ const DocumentUploadPopupContent: FC<{
{activeTab === "Y" && !isSearching && (
<iframe
className="w-full h-[calc(100vh-200px)]"
src={`${knowledgeUrl}/#/sousou?chatType=new-chat&bookChatId=${searchSpaceId}&inputValue=${searchText}`}
src={`${knowledgeUrl}/#/sousou?chatType=new-chat&bookChatId=${searchSpaceId}&inputValue=${searchText}${iframeTokenParam}`}
/>
)}
+34 -18
View File
@@ -1,14 +1,19 @@
"use client";
import { useEffect } from "react";
import { useLayoutEffect } from "react";
import { useLocalSettings } from "@/core/settings";
import {
applyFontScaleToDocument,
normalizeFontScale,
normalizeFontScaleMode,
} from "@/core/ui/page-zoom";
/**
* 基准字号 = 16px,对应《深蓝色 UI 规范》§2.1.2 后台系统正文字号(16px Regular)。
* 用户在「外观 → 字号」里选择的倍数通过 CSS 变量 `--ui-font-scale` 生效:globals.css
* 只把 Tailwind 的 `--text-*` 字号 token 乘以该倍数,**不动间距 token**,所以放大 / 缩小
* 的只有文字本身,padding / margin / 宽高等布局尺寸保持不变,不会把页面挤错位。
* 「仅文字」把倍数写到 `--ui-font-scale`,只放大 Tailwind 字号 token 和正文继承字号。
* 「整页缩放」改在 `<html>` 上设置 CSS zoom,写死的 px 字号、间距和图标一起变;
* 此时 `--ui-font-scale` 固定为 1,避免和 zoom 叠乘。
* `fontScale === 1` 即默认 16px。
*/
export const BASE_FONT_PX = 16;
@@ -21,25 +26,36 @@ export const FONT_SCALE_OPTIONS: { value: number; label: string; px: number }[]
{ value: 1.25, label: "特大", px: 20 },
];
export const FONT_SCALE_MODE_OPTIONS: {
value: "text" | "page";
label: string;
description: string;
}[] = [
{
value: "text",
label: "仅文字",
description: "只放大文字,间距和布局保持原样。",
},
{
value: "page",
label: "整页缩放",
description: "文字、间距、图标和写死的像素一起放大。",
},
];
/**
* 把用户选择的字号倍数写到 <html> 的 `--ui-font-scale` CSS 变量。挂在应用根部,
* 保证刷新后即时生效、全局统一。只设变量、不直接改 font-size,因此布局不受影响。
* 不渲染任何 DOM。
* 把字号设置写到文档根节点。挂在应用根部,刷新后、绘制前生效。不渲染任何 DOM。
*/
export function FontScaleApplier() {
const [settings] = useLocalSettings();
const { fontScale } = settings.display;
const { fontScale, fontScaleMode } = settings.display;
useEffect(() => {
const scale =
typeof fontScale === "number" && Number.isFinite(fontScale) && fontScale > 0
? fontScale
: 1;
const root = document.documentElement;
root.style.setProperty("--ui-font-scale", String(scale));
// 清掉旧实现可能残留的根级 font-size 内联样式(旧版用它做整页缩放)。
root.style.removeProperty("font-size");
}, [fontScale]);
useLayoutEffect(() => {
applyFontScaleToDocument(
normalizeFontScaleMode(fontScaleMode),
normalizeFontScale(fontScale),
);
}, [fontScale, fontScaleMode]);
return null;
}
@@ -1,3 +1,5 @@
import { toCssPx } from "@/core/ui/page-zoom";
import type { TooltipState } from "./graphTypes";
import { getNodeTitle, NODE_KIND_META } from "./graphUtils";
@@ -9,8 +11,8 @@ export function GraphTooltip({ tooltip }: GraphTooltipProps) {
if (!tooltip) return null;
const meta = NODE_KIND_META[tooltip.kind];
// Keep the tooltip inside the viewport.
const left = Math.min(tooltip.x + 14, window.innerWidth - 336);
const top = Math.min(tooltip.y + 14, window.innerHeight - 120);
const left = Math.min(toCssPx(tooltip.x) + 14, window.innerWidth - 336);
const top = Math.min(toCssPx(tooltip.y) + 14, window.innerHeight - 120);
return (
<div className="kg-tooltip" style={{ left, top }}>
+8 -8
View File
@@ -4,19 +4,19 @@
top: 4rem;
z-index: 40;
width: 98px;
height: calc(100vh - 4rem);
height: calc(100vh / var(--df-page-zoom, 1) - 4rem);
flex-shrink: 0;
}
/* `?hideHeader=1`: keep the menu rail, but let it occupy the Header's space. */
.sidebar-contnet--header-hidden {
top: 0;
height: 100vh;
height: calc(100vh / var(--df-page-zoom, 1));
}
.sidebar-contnet--header-hidden .item-nav-content {
height: calc(100vh - 20px);
min-height: calc(100vh - 20px);
height: calc(100vh / var(--df-page-zoom, 1) - 20px);
min-height: calc(100vh / var(--df-page-zoom, 1) - 20px);
}
.sidebar-contents {
@@ -160,8 +160,8 @@
.item-nav-content {
width: 155px;
height: calc(100vh - 4rem - 20px);
min-height: calc(100vh - 4rem - 20px);
height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px);
min-height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px);
background: rgba(3, 38, 80, 0.8);
border: 1px solid;
border-color: rgba(43, 143, 254, 0.6);
@@ -634,7 +634,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
top: 4rem;
z-index: 40;
width: 96px;
height: calc(100vh - 4rem);
height: calc(100vh / var(--df-page-zoom, 1) - 4rem);
display: flex;
flex-direction: row;
background: #ffffff;
@@ -653,7 +653,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
/* `?hideHeader=1`: preserve the light sidebar while removing only Header. */
.light-sidebar--header-hidden {
top: 0;
height: 100vh;
height: calc(100vh / var(--df-page-zoom, 1));
}
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
@@ -194,7 +194,7 @@ export function PageSidebar({
onOpenChange={setNotificationOpen}
/>
<aside
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh/var(--df-page-zoom,1)-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
isDark
? "border-r border-blue-800/30 bg-slate-900"
: "border-r border-red-100 bg-white"
+37 -1
View File
@@ -16,6 +16,8 @@ import {
Settings,
ShieldAlert,
ShieldCheck,
ScrollText,
Timer,
User,
Wrench,
} from "lucide-react";
@@ -268,6 +270,22 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skill-timing")}
>
<Timer className="size-4" />
技能耗时排查
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
>
<ScrollText className="size-4" />
模型收发记录
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => openSettings("encoder")}>
<Cpu className="size-4" />
@@ -482,7 +500,9 @@ function LegacyAccordionSidebar({
/>
<aside
className={`fixed left-0 z-40 flex flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
headerHidden ? "top-0 h-screen" : "top-16 h-[calc(100vh-4rem)]"
headerHidden
? "top-0 h-[calc(100vh/var(--df-page-zoom,1))]"
: "top-16 h-[calc(100vh/var(--df-page-zoom,1)-4rem)]"
} ${
isDark
? "border-r border-blue-800/30 bg-slate-900"
@@ -587,6 +607,22 @@ function LegacyAccordionSidebar({
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skill-timing")}
>
<Timer className="size-4" />
技能耗时排查
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
>
<ScrollText className="size-4" />
模型收发记录
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => openSettings("encoder")}>
<Cpu className="size-4" />
@@ -0,0 +1,16 @@
/** Lightweight, dependency-free fallback used while a route chunk loads. */
export function RouteLoadingFallback() {
return (
<div
className="text-muted-foreground flex size-full min-h-32 items-center justify-center gap-2 bg-background"
role="status"
aria-live="polite"
>
<span
className="border-muted-foreground/30 border-t-foreground size-4 animate-spin rounded-full border-2"
aria-hidden="true"
/>
<span className="text-sm">正在加载页面…</span>
</div>
);
}
+36 -31
View File
@@ -61,7 +61,7 @@ const DialogOverlay = React.forwardRef<
ref={ref}
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"legacy-dialog-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[200] bg-black/50",
className,
)}
{...props}
@@ -71,37 +71,42 @@ const DialogOverlay = React.forwardRef<
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
/** Use when this dialog is opened from a higher-layer surface such as a Drawer. */
overlayClassName?: string;
}
>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
ref={ref}
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
/** Use when this dialog is opened from a higher-layer surface such as a Drawer. */
overlayClassName?: string;
}
>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay className={overlayClassName} />
<div
data-slot="dialog-viewport"
className={cn("legacy-dialog-viewport", overlayClassName)}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
<DialogPrimitive.Content
ref={ref}
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 relative z-[200] grid min-h-0 max-h-full w-full max-w-[calc(100%-2rem)] pointer-events-auto gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</div>
</DialogPortal>
);
});
+270 -270
View File
@@ -1,270 +1,270 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
const DropdownMenuTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
>(({ ...props }, ref) => {
return (
<DropdownMenuPrimitive.Trigger
ref={ref}
data-slot="dropdown-menu-trigger"
{...props}
/>
);
});
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
});
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}
>(({ className, inset, variant = "default", ...props }, ref) => {
return (
<DropdownMenuPrimitive.Item
ref={ref}
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
});
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => {
return (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
});
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => {
return (
<DropdownMenuPrimitive.SubContent
ref={ref}
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
});
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
const DropdownMenuTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
>(({ ...props }, ref) => {
return (
<DropdownMenuPrimitive.Trigger
ref={ref}
data-slot="dropdown-menu-trigger"
{...props}
/>
);
});
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
});
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}
>(({ className, inset, variant = "default", ...props }, ref) => {
return (
<DropdownMenuPrimitive.Item
ref={ref}
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
});
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => {
return (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
});
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => {
return (
<DropdownMenuPrimitive.SubContent
ref={ref}
data-slot="dropdown-menu-sub-content"
className={cn(
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
});
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
+16 -16
View File
@@ -1,16 +1,16 @@
"use client";
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
"use client";
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
const HoverCardTrigger = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Trigger>
@@ -38,7 +38,7 @@ const HoverCardContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-[var(--radix-hover-card-content-transform-origin)] rounded-md border p-4 shadow-md outline-hidden",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
@@ -48,5 +48,5 @@ const HoverCardContent = React.forwardRef<
});
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };
export { HoverCard, HoverCardTrigger, HoverCardContent };
+198 -198
View File
@@ -1,198 +1,198 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}
>(({ className, size = "default", children, ...props }, ref) => {
return (
<SelectPrimitive.Trigger
ref={ref}
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEE2] dark:data-[placeholder]:text-[#566B7F] dark:hover:bg-[#022248] dark:[&_svg:not([class*='text-'])]:text-[#AACEFF] dark:focus-visible:border-[#3b82f6] dark:focus-visible:ring-[#3b82f6]/30",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
});
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "item-aligned", align = "center", ...props }, ref) => {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--radix-select-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
});
SelectContent.displayName = SelectPrimitive.Content.displayName;
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn(
"text-muted-foreground px-2 py-1.5 text-xs dark:text-[#566B7F]",
className,
)}
{...props}
/>
);
}
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => {
return (
<SelectPrimitive.Item
ref={ref}
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"dark:text-[#AACEFF] dark:focus:bg-[#0062D9] dark:focus:text-white dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white dark:[&_svg:not([class*='text-'])]:text-[#AACEFF] dark:focus:[&_svg:not([class*='text-'])]:text-white dark:data-[highlighted]:[&_svg:not([class*='text-'])]:text-white",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
});
SelectItem.displayName = SelectPrimitive.Item.displayName;
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1 dark:text-[#AACEFF]",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1 dark:text-[#AACEFF]",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}
>(({ className, size = "default", children, ...props }, ref) => {
return (
<SelectPrimitive.Trigger
ref={ref}
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEE2] dark:data-[placeholder]:text-[#566B7F] dark:hover:bg-[#022248] dark:[&_svg:not([class*='text-'])]:text-[#AACEFF] dark:focus-visible:border-[#3b82f6] dark:focus-visible:ring-[#3b82f6]/30",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
});
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "item-aligned", align = "center", ...props }, ref) => {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
data-slot="select-content"
className={cn(
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
});
SelectContent.displayName = SelectPrimitive.Content.displayName;
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn(
"text-muted-foreground px-2 py-1.5 text-xs dark:text-[#566B7F]",
className,
)}
{...props}
/>
);
}
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => {
return (
<SelectPrimitive.Item
ref={ref}
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"dark:text-[#AACEFF] dark:focus:bg-[#0062D9] dark:focus:text-white dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white dark:[&_svg:not([class*='text-'])]:text-[#AACEFF] dark:focus:[&_svg:not([class*='text-'])]:text-white dark:data-[highlighted]:[&_svg:not([class*='text-'])]:text-white",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
});
SelectItem.displayName = SelectPrimitive.Item.displayName;
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1 dark:text-[#AACEFF]",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1 dark:text-[#AACEFF]",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
File diff suppressed because it is too large. Load diff
@@ -36,7 +36,7 @@ function ToggleGroup({
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[calc(var(--gap)*0.25rem)] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className,
)}
{...props}
+33 -33
View File
@@ -1,33 +1,33 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
const TooltipTrigger = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>
@@ -54,7 +54,7 @@ const TooltipContent = React.forwardRef<
data-slot="tooltip-content"
sideOffset={sideOffset ?? 4}
className={cn(
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-foreground text-background dark:text-foreground z-50 w-fit origin-[var(--radix-tooltip-content-transform-origin)] rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-foreground text-background dark:text-foreground z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
className,
)}
{...props}
@@ -66,5 +66,5 @@ const TooltipContent = React.forwardRef<
});
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -83,7 +83,7 @@ function UserInfoPanel({ user }: { user: UserListItem }) {
<Badge variant="secondary">普通用户</Badge>
)}
</dd>
<dt className="text-muted-foreground font-medium">问答次数</dt>
<dt className="text-muted-foreground font-medium">近30天问答次数</dt>
<dd className="tabular-nums">{user.qa_count}</dd>
<dt className="text-muted-foreground font-medium">Skills</dt>
<dd className="tabular-nums">{user.skill_count}</dd>
@@ -304,7 +304,7 @@ export function AdminUserHub() {
const [userQuery, setUserQuery] = useState("");
// 活动审计 放到前面 → 默认进入审计标签页。
const [activeTab, setActiveTab] = useState<"audit" | "info" | "memory">("audit");
// 是否按问答次数(降序)排序用户列表。
// 是否按近 30 天问答次数(降序)排序用户列表。
const [sortByQa, setSortByQa] = useState(false);
const filteredUsers = useMemo(() => {
@@ -332,7 +332,7 @@ export function AdminUserHub() {
<div className="border-b px-6 py-4">
<h1 className="text-xl font-semibold">Admin 用户中心</h1>
<p className="text-muted-foreground mt-0.5 text-sm">
管理用户信息、审计历史问答、编辑用户记忆。
管理用户信息、审计近 30 天问答、编辑用户记忆。
</p>
</div>
@@ -357,10 +357,10 @@ export function AdminUserHub() {
? "border-primary/40 bg-primary/5 text-primary"
: "text-muted-foreground hover:bg-muted",
)}
title="按问答次数从高到低排序"
title="按近 30 天问答次数从高到低排序"
>
<ArrowDownWideNarrow className="size-3.5" />
{sortByQa ? "按问答次数排序中" : "按问答次数排序"}
{sortByQa ? "按近30天问答排序中" : "按近30天问答排序"}
</button>
<ScrollArea className="min-h-0 flex-1">
{usersLoading && (
@@ -395,7 +395,7 @@ export function AdminUserHub() {
)}
<span
className="text-muted-foreground shrink-0 tabular-nums text-xs"
title="问答次数"
title="近 30 天问答次数"
>
{u.qa_count ?? 0}
</span>
@@ -27,6 +27,7 @@ import {
type ToolMetric,
type ToolMetricsFilters,
} from "@/core/tool-metrics/api";
import { getAdminQueryDateTimeBounds } from "@/core/admin/query-window";
import { useToolMetrics, useToolMetricsSummary } from "@/core/tool-metrics/hooks";
import { cn } from "@/lib/utils";
import { useTheme } from "@/contexts/ThemeContext";
@@ -68,6 +69,7 @@ function categoryLabel(category?: string | null): string {
export function ToolMetricsPanel() {
const { theme } = useTheme();
const queryDateTimeBounds = getAdminQueryDateTimeBounds();
const [page, setPage] = useState(0);
const [tool, setTool] = useState("");
const [user, setUser] = useState("");
@@ -77,8 +79,8 @@ export function ToolMetricsPanel() {
const [kind, setKind] = useState<KindFilter>("skill");
const [status, setStatus] = useState<StatusFilter>("all");
const [category, setCategory] = useState<string>("all");
const [since, setSince] = useState("");
const [until, setUntil] = useState("");
const [since, setSince] = useState(() => getAdminQueryDateTimeBounds().min);
const [until, setUntil] = useState(() => getAdminQueryDateTimeBounds().max);
const [exporting, setExporting] = useState(false);
const [detailRow, setDetailRow] = useState<ToolMetric | null>(null);
/** 成功 toast:仅暗色模式,浅色走 Sonner 默认 */
@@ -153,14 +155,15 @@ const errorToastDarkOptions = {
};
const resetFilters = () => {
const nextBounds = getAdminQueryDateTimeBounds();
setTool("");
setUser("");
setSkill("");
setKind("skill");
setStatus("all");
setCategory("all");
setSince("");
setUntil("");
setSince(nextBounds.min);
setUntil(nextBounds.max);
setPage(0);
};
@@ -180,7 +183,7 @@ const errorToastDarkOptions = {
<h2 className="text-lg font-semibold">工具调用统计</h2>
<p className="text-muted-foreground mt-1 text-sm">
记录每一次工具 / 技能 / MCP 调用,并对失败进行归类:检索为空、IP
被封、限流、超时、鉴权失败等。后台无条件记录。
被封、限流、超时、鉴权失败等。后台无条件记录,默认仅查询最近 30 天。
</p>
</div>
<Button onClick={handleExport} disabled={exporting}>
@@ -391,8 +394,10 @@ const errorToastDarkOptions = {
<Input
type="datetime-local"
value={since}
min={queryDateTimeBounds.min}
max={until || queryDateTimeBounds.max}
onChange={(e) => {
setSince(e.target.value);
setSince(e.target.value || queryDateTimeBounds.min);
setPage(0);
}}
/>
@@ -402,8 +407,10 @@ const errorToastDarkOptions = {
<Input
type="datetime-local"
value={until}
min={since || queryDateTimeBounds.min}
max={queryDateTimeBounds.max}
onChange={(e) => {
setUntil(e.target.value);
setUntil(e.target.value || queryDateTimeBounds.max);
setPage(0);
}}
/>
@@ -1,8 +1,10 @@
"use client";
import { Loader2 } from "lucide-react";
import { Link } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { SettingsSection } from "@/components/workspace/settings/settings-section";
import { useAllUsers } from "@/core/auth/hooks";
import { useI18n } from "@/core/i18n/hooks";
@@ -17,6 +19,11 @@ export function UserManagementPage() {
title={t.adminUsers.title}
description={t.adminUsers.description}
>
<div className="mb-4 flex justify-end">
<Button variant="outline" asChild>
<Link to="/page/strategy/admin/login-whitelist">管理密码与登录口令</Link>
</Button>
</div>
{isLoading && (
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -4,6 +4,7 @@ import {
BotIcon,
ClockIcon,
CopyIcon,
EyeIcon,
EyeOffIcon,
GlobeIcon,
LockIcon,
@@ -92,6 +93,11 @@ interface AgentCardProps {
/** Click the 发布者 line to filter the gallery to that publisher. Receives the
* owner user_id + its desensitized display label. */
onPublisherClick?: (publisher: { id: string; label: string }) => void;
/** Admin console (智能体管理) only: renders the hide/restore eye button.
* Undefined everywhere else, so the gallery never shows it. */
onToggleHidden?: (next: boolean) => void | Promise<void>;
/** Disable the eye button while the admin toggle request is in flight. */
toggleHiddenPending?: boolean;
}
interface BadgeItem {
@@ -203,7 +209,13 @@ function ClampedBadgeRow({ items }: { items: BadgeItem[] }) {
);
}
export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCardProps) {
export function AgentCard({
agent,
layout = "card",
onPublisherClick,
onToggleHidden,
toggleHiddenPending = false,
}: AgentCardProps) {
const isList = layout === "list";
const { t } = useI18n();
const router = useRouter();
@@ -214,9 +226,11 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
const setPrivate = useSetAgentPrivate();
const toggleFavorite = useToggleAgentFavorite();
const togglePin = useToggleAgentPin();
const { skills, isLoading: isSkillsLoading } = useSkills();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
// The full skill catalog is only needed by the edit dialog. Cards render
// Chinese names from `agent.skill_labels` on the list response.
const { skills, isLoading: isSkillsLoading } = useSkills({ enabled: editOpen });
const [tagPickerOpen, setTagPickerOpen] = useState(false);
const [editName, setEditName] = useState(agent.name);
const [editDescription, setEditDescription] = useState(agent.description ?? "");
@@ -590,6 +604,16 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
私密
</Badge>
)}
{agent.hidden && (
<Badge
variant="outline"
className="border-zinc-500/40 bg-zinc-500/10 text-zinc-600 dark:text-zinc-300 text-xs"
title="已被管理员隐藏:除本管理页外不再展示,但仍可正常使用"
>
<EyeOffIcon className="size-3" />
已隐藏
</Badge>
)}
{agent.featured_order != null && (
<Badge
variant="outline"
@@ -646,15 +670,6 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
</div>
) : null;
// 技能英文名 → 中文名映射,用于把智能体的技能徽章显示为中文(agent.skills 只存英文名)。
const skillNameZhMap = useMemo(() => {
const map = new Map<string, string>();
for (const s of skills) {
if (s.name_zh?.trim()) map.set(s.name, s.name_zh.trim());
}
return map;
}, [skills]);
const skillBadgeItems = [
...(agent.tool_groups ?? []).map((group) => ({
key: `tg:${group}`,
@@ -677,7 +692,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
setDetailSkillName(skill);
}}
>
{skillNameZhMap.get(skill) ?? desensitize(skill)}
{agent.skill_labels?.[skill]?.trim() || desensitize(skill)}
</Badge>
),
})),
@@ -783,6 +798,31 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
<ShieldIcon className="h-3.5 w-3.5" />
</Button>
)}
{onToggleHidden && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
agent.hidden
? "text-zinc-500 hover:text-zinc-600 dark:text-zinc-400"
: "text-zinc-400 hover:text-zinc-500",
)}
onClick={() => void onToggleHidden(!agent.hidden)}
disabled={toggleHiddenPending}
title={
agent.hidden
? "取消隐藏:恢复在广场等列表中展示"
: "隐藏:除本管理页外不再展示,智能体仍可正常使用"
}
>
{agent.hidden ? (
<EyeIcon className="h-3.5 w-3.5" />
) : (
<EyeOffIcon className="h-3.5 w-3.5" />
)}
</Button>
)}
<Button
size="icon"
variant="ghost"
@@ -831,7 +871,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
const cardLayout = (
<Card
className="group flex min-w-0 flex-col transition-shadow hover:shadow-md"
className={cn(
"group flex min-w-0 flex-col transition-shadow hover:shadow-md",
agent.hidden && "opacity-60",
)}
data-agent-guide="agent-card"
>
<CardHeader className="min-w-0 pb-2">
@@ -892,7 +935,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
const listLayout = (
<div
className="group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]"
className={cn(
"group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]",
agent.hidden && "opacity-60",
)}
data-agent-guide="agent-card"
>
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
@@ -960,9 +1006,9 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
已选 {selectedSkills.length} 个
</span>
</div>
<SkillTransferSelector
skills={skills}
selected={selectedSkills}
<SkillTransferSelector
skills={skills}
selected={selectedSkills}
onChange={setSelectedSkills}
isLoading={isSkillsLoading}
onViewDetail={setDetailSkillName}
@@ -172,7 +172,7 @@ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string })
return (
// 配色与 AgentModuleSidebar 一致:浅色白底,深蓝主题半透明深蓝玻璃面板。
<aside className="flex h-full w-[var(--sidebar-width)] shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80">
<aside className="flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80">
{/* 智能体(上半,可收起)。展开时:标题栏 + 来源下拉/名称搜索 + 列表;
收起时只留标题栏,下方「最近的对话」占满剩余空间。 */}
<div
@@ -1,6 +1,6 @@
"use client";
import { ArrowDownUpIcon, BotIcon, Compass, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
import { ArrowDownUpIcon, BotIcon, Compass, Loader2, LockIcon, PlusIcon, RefreshCwIcon, ShieldIcon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -13,7 +13,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
import { getStoredAuth } from "@/core/auth";
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import { useAgentsPaginated, usePrivateAgentsPaginated, useSyncAgentDirectory } from "@/core/agents";
import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useRouteBase } from "@/core/route-base";
@@ -54,6 +54,7 @@ export function AgentGallery({
const sentimentAgentVisible = isSentimentAgentVisible();
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
const isAdmin = getStoredAuth()?.system_role === "admin";
const syncDirectory = useSyncAgentDirectory();
const [viewMode, setViewMode] = useViewMode("agent");
const [activeTab, setActiveTab] = useState<string>("mine");
const [search, setSearch] = useState("");
@@ -460,6 +461,33 @@ export function AgentGallery({
</p>
</div>
<div className="flex items-center gap-2">
{isAdmin && (
<Button
variant="outline"
size="sm"
title="扫描 .deer-flow/agents 目录,把目录里的智能体同步进列表"
disabled={syncDirectory.isPending}
onClick={() => {
void syncDirectory.mutateAsync().then(
(result) => {
toast.success(
result.scanned > 0
? `已扫描并同步 ${result.scanned} 个目录智能体`
: "目录里没有可同步的智能体",
);
},
(err: unknown) => {
toast.error(err instanceof Error ? err.message : "扫描智能体目录失败");
},
);
}}
>
<RefreshCwIcon
className={`mr-1.5 h-4 w-4 ${syncDirectory.isPending ? "animate-spin" : ""}`}
/>
{syncDirectory.isPending ? "扫描中…" : "重新扫描智能体目录"}
</Button>
)}
<Button
variant="outline"
size="sm"
@@ -278,6 +278,7 @@ export function AgentGuide({
autoStart?: boolean;
}) {
const [current, setCurrent] = useState(-1);
const [appDialogOpen, setAppDialogOpen] = useState(false);
const stepsRef = useRef<GuideStep[]>([]);
const start = useCallback(() => {
@@ -317,7 +318,32 @@ export function AgentGuide({
setCurrent(-1);
}, [stage]);
if (!eligible) return null;
// TDesign Guide 遮罩是 z-index 999997+。编辑智能体等业务弹窗只有 z-200,
// 引导若继续高亮页头按钮,会把弹窗连同关闭按钮一起盖住。业务 Dialog 打开时
// 先卸掉引导层,关掉后再从当前步恢复。
useEffect(() => {
if (!eligible) return;
const sync = () => {
setAppDialogOpen(
Boolean(document.querySelector('[data-slot="dialog-content"]')),
);
};
sync();
const observer = new MutationObserver(sync);
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}, [eligible]);
// TDesign 1.18 still handles resize when current=-1 and dereferences an
// undefined step. Keep its listener mounted only during an active guide.
if (
!eligible ||
appDialogOpen ||
current < 0 ||
current >= stepsRef.current.length
) {
return null;
}
return (
<Guide
@@ -53,8 +53,8 @@ export function AgentManageDialog({
open,
onOpenChange,
}: AgentManageDialogProps) {
const { agents, isLoading } = useAgentSelector();
const { agents: allAgents } = useAgents();
const { agents, isLoading } = useAgentSelector({ enabled: open });
const { agents: allAgents } = useAgents({ enabled: open });
const setFeaturedMutation = useSetAgentFeatured();
const reorderMutation = useReorderFeaturedAgents();
const [pickerOpen, setPickerOpen] = useState(false);
@@ -291,7 +291,7 @@ export function AgentModuleSidebar({
// 浅色保持白底。其余颜色已走语义 token 自适配。
<aside
className={cn(
"flex h-full w-[var(--sidebar-width)] shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80",
"flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80",
className,
)}
>
@@ -21,7 +21,7 @@ import {
import { PromptInputButton } from "@/components/ai-elements/prompt-input";
import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog";
import { Tooltip } from "@/components/workspace/tooltip";
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import { useAgent, useAgentSelector } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { desensitize } from "@/lib/desensitize";
@@ -59,22 +59,19 @@ export function AgentSelector({
}: AgentSelectorProps) {
const [open, setOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false);
const { agents, isLoading } = useAgentSelector();
// 弹窗列表只展示管理弹窗里配置的 agent(agents);但「已选中 agent」的名称解析要更宽——
// 侧边栏「常用智能体」可以选中未配置进管理弹窗的 agent(如用户自己的智能体),
// 这些 agent 也要能在胶囊上显示名称,故额外用「我的 / 广场」列表兜底解析(缓存与侧边栏共享)。
const { agents: mineAgents } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
const { agents: squareAgents } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
const { agents, isLoading } = useAgentSelector({ enabled: open });
// Resolve a remembered personal agent with a single detail lookup instead of
// preloading both complete homepage gallery pages.
const { agent: selectedAgentDetail } = useAgent(selectedAgentId, {
enabled: !!selectedAgentId,
retry: false,
});
const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
const selectedAgent = useMemo<Agent | null>(() => {
if (!selectedAgentId) return null;
return (
[...agents, ...mineAgents, ...squareAgents].find(
(a) => a.id === selectedAgentId,
) ?? null
);
}, [agents, mineAgents, squareAgents, selectedAgentId]);
return agents.find((a) => a.id === selectedAgentId) ?? selectedAgentDetail;
}, [agents, selectedAgentDetail, selectedAgentId]);
const handleSelect = (agent: Agent) => {
onAgentChange(agent.id);
@@ -19,7 +19,7 @@ import {
import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { useNavigate } from "react-router-dom";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner";
import { defaultUrlTransform } from "react-markdown";
@@ -66,11 +66,6 @@ import {
DOCUMENT_REWRITE_UNDONE_EVENT,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import {
clearArtifactRewriteFallback,
completeArtifactRewriteFallback,
primeArtifactRewriteFallback,
} from "@/core/document-rewrite/local-fallback";
import {
downloadArtifactFile,
} from "@/core/artifacts/download";
@@ -288,7 +283,7 @@ export function ArtifactFileDetail({
headerLeadingActionBare?: boolean;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
const { artifacts, setOpen, select, setGeneratingArtifact } = useArtifacts();
const { isMock, thread, publicShareCode } = useThread();
const navigate = useNavigate();
// Web App 站点清单(outputs/<site>/site.json)→「站点预览」入口。
@@ -443,16 +438,23 @@ export function ArtifactFileDetail({
presentationId: null,
thinking: "",
});
const documentRewriteActive = documentRewrite.isActive;
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
const displayContent = documentRewriteActive
? documentRewrite.run.draft
: (canEditArtifact || (canUseAiEditor && hasLocalEdits)
? draftContent
: (content ?? ""));
const streamPerfEnabled = isSandboxStreamPerfEnabled();
const isLiveStreaming = writeFileInFlight || documentRewriteActive;
const documentRewriteActive = documentRewrite.isActive;
const generationLockId = useId();
useLayoutEffect(() => {
const owner = `preview:${generationLockId}`;
const generating = (writeFileInFlight && thread.isLoading) || documentRewriteActive;
setGeneratingArtifact(owner, generating ? filepathFromProps : null);
return () => setGeneratingArtifact(owner, null);
}, [documentRewriteActive, filepathFromProps, generationLockId, setGeneratingArtifact, thread.isLoading, writeFileInFlight]);
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
const displayContent = documentRewriteActive
? documentRewrite.run.draft
: (canEditArtifact || (canUseAiEditor && hasLocalEdits)
? draftContent
: (content ?? ""));
const streamPerfEnabled = isSandboxStreamPerfEnabled();
const isLiveStreaming = writeFileInFlight || documentRewriteActive;
const sandboxDisplayText = useMemo(() => {
const raw = displayContent ?? "";
if (disableDesensitize) return raw;
@@ -475,8 +477,8 @@ export function ArtifactFileDetail({
!isArtifactContentLoading;
const isAutoPreviewLanguage =
language === "html" || language === "markdown" || language === "mermaid";
const isPreviewStreaming =
isAutoPreviewLanguage && (writeFileInFlight || documentRewriteActive);
const isPreviewStreaming =
isAutoPreviewLanguage && (writeFileInFlight || documentRewriteActive);
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
if (isImageFile(filepath)) return "IMG";
@@ -501,16 +503,16 @@ export function ArtifactFileDetail({
const [spreadsheetData, setSpreadsheetData] = useState<ArrayBuffer | null>(
null,
);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
useEffect(() => {
if (!documentRewriteActive) return;
setAllowAutoViewSwitch(false);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
setViewMode("code");
}, [documentRewriteActive]);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
useEffect(() => {
if (!documentRewriteActive) return;
setAllowAutoViewSwitch(false);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
setViewMode("code");
}, [documentRewriteActive]);
useEffect(() => {
if (viewMode !== "edit" || canUseAiEditor) return;
@@ -555,13 +557,20 @@ export function ArtifactFileDetail({
presentationId: string,
event: DocumentRewriteStreamEvent,
) => {
if (!rewritePresentation) return;
switch (event.type) {
case "snapshot_fixed":
rewritePresentation.update(presentationId, {
stage: "requirements",
fileName: event.fileName || getFileName(filepath),
originalCharCount: event.originalCharCount,
if (!rewritePresentation) return;
switch (event.type) {
case "job_started":
rewritePresentation.update(presentationId, {
jobId: event.jobId,
...(event.outputPath ? { outputPath: event.outputPath } : {}),
});
break;
case "snapshot_fixed":
rewritePresentation.update(presentationId, {
stage: "requirements",
fileName: event.fileName || getFileName(filepath),
...(event.outputPath ? { outputPath: event.outputPath } : {}),
originalCharCount: event.originalCharCount,
requirementsPlan: "",
});
break;
@@ -622,10 +631,11 @@ export function ArtifactFileDetail({
case "commit_started":
rewritePresentation.update(presentationId, { stage: "committing" });
break;
case "committed":
rewritePresentation.update(presentationId, {
rewrittenContent: event.content,
versionId: event.versionId ?? null,
case "committed":
rewritePresentation.update(presentationId, {
rewrittenContent: event.content,
...(event.outputPath ? { outputPath: event.outputPath } : {}),
versionId: event.versionId ?? null,
committedHash: event.committedHash,
});
break;
@@ -641,9 +651,13 @@ export function ArtifactFileDetail({
case "cancelled":
rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message });
break;
case "error":
console.error("AI 全文改写历史恢复失败:", event.message);
break;
case "error":
rewritePresentation.update(presentationId, {
stage: "error",
error: event.message,
diagnostic: event.diagnostic ?? null,
});
break;
default:
break;
}
@@ -665,10 +679,18 @@ export function ArtifactFileDetail({
const historicalSummary = historicalResult.summary && typeof historicalResult.summary === "object"
? historicalResult.summary as DocumentRewriteSummary
: null;
const historicalPresentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
fileName: getFileName(filepath),
const historicalPresentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
jobId: historical.id,
outputPath: typeof historicalResult.outputPath === "string"
? historicalResult.outputPath
: historical.outputPath,
fileName: getFileName(
typeof historicalResult.outputPath === "string"
? historicalResult.outputPath
: historical.outputPath ?? filepath,
),
instruction: historical.request.instruction,
style: historical.request.style,
modelName: historical.request.modelName,
@@ -689,11 +711,14 @@ export function ArtifactFileDetail({
summary: historicalSummary,
warnings: historicalSummary?.validationWarnings ?? [],
versionId: typeof historicalResult.versionId === "string" ? historicalResult.versionId : null,
error: historicalResult.status === "conflict"
? "文件在改写期间已变化,未覆盖原文件。"
: historical.status === "cancelled"
? "已停止全文改写,原文件未被修改。"
: historical.status === "failed" ? "全文改写未完成,原文件未被修改。" : null,
error: historicalResult.status === "conflict"
? "文件在改写期间已变化,未覆盖原文件。"
: historical.status === "cancelled"
? "已停止全文改写,原文件未被修改。"
: historical.status === "failed"
? historical.errorMessage ?? "全文改写未完成,原文件未被修改。"
: null,
diagnostic: historical.diagnostic ?? null,
});
}
const result = latest.result;
@@ -702,36 +727,25 @@ export function ArtifactFileDetail({
: null;
const originalContent = latest.request.originalContent || draftContent;
const rewrittenContent = typeof result.content === "string" ? result.content : null;
const versionSnapshotFailed = result.versionSnapshotFailed === true;
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
const localFallbackAvailable =
versionSnapshotFailed &&
latest.status === "completed" &&
result.status !== "conflict" &&
committedHash !== null &&
completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent,
committedHash,
});
const presentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
fileName: getFileName(filepath),
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
const outputPath = typeof result.outputPath === "string"
? result.outputPath
: latest.outputPath;
const presentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
jobId: latest.id,
outputPath,
fileName: getFileName(outputPath ?? filepath),
instruction: latest.request.instruction,
style: latest.request.style,
modelName: latest.request.modelName,
generateImages: latest.request.generateImages,
maxGeneratedImages: latest.request.maxGeneratedImages,
originalContent,
rewrittenContent,
committedHash,
localFallbackAvailable,
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
rewrittenContent,
committedHash,
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
});
if (!presentationId || !rewritePresentation) return;
setDocumentRewritePresentationId(presentationId);
@@ -740,32 +754,22 @@ export function ArtifactFileDetail({
rewritePresentation.update(presentationId, {
stage: result.status === "conflict" ? "conflict" : "completed",
summary,
warnings: summary?.validationWarnings ?? [],
versionId: typeof result.versionId === "string" ? result.versionId : null,
committedHash,
localFallbackAvailable,
error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null,
});
if (rewrittenContent && result.status !== "conflict") {
setDraftContent(rewrittenContent);
setHasLocalEdits(versionSnapshotFailed);
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
}
queryClient.setQueriesData(
{ queryKey: ["artifact", filepath, threadId, isMock] },
{ content: rewrittenContent, url: undefined },
);
}
return;
warnings: summary?.validationWarnings ?? [],
versionId: typeof result.versionId === "string" ? result.versionId : null,
committedHash,
error: result.status === "conflict" ? "改写版文件未生成。" : null,
});
return;
}
if (latest.status === "failed" || latest.status === "cancelled") {
rewritePresentation.update(presentationId, {
stage: latest.status === "cancelled" ? "cancelled" : "error",
summary,
});
if (latest.status === "failed" || latest.status === "cancelled") {
rewritePresentation.update(presentationId, {
stage: latest.status === "cancelled" ? "cancelled" : "error",
summary,
error: latest.status === "cancelled"
? "已停止全文改写,原文件未被修改。"
: latest.errorMessage ?? "全文改写未完成,原文件未被修改。",
diagnostic: latest.status === "failed" ? latest.diagnostic ?? null : null,
});
return;
}
@@ -787,35 +791,22 @@ export function ArtifactFileDetail({
signal,
),
onEvent: (event) => updatePresentation(presentationId, event),
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
setDraftContent(rewritten);
setHasLocalEdits(versionSnapshotFailed);
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent,
committedHash,
});
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
}
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
localFallbackAvailable,
});
queryClient.setQueriesData(
{ queryKey: ["artifact", filepath, threadId, isMock] },
{ content: rewritten, url: undefined },
);
},
},
);
onCommitted: (rewritten, { committedHash, outputPath }) => {
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
...(outputPath ? { outputPath } : {}),
});
},
onFailure: ({ error, diagnostic }) => {
rewritePresentation.update(presentationId, {
stage: "error",
error,
diagnostic: diagnostic ?? null,
});
},
},
);
} catch {
// Existing artifacts remain available even if history hydration fails.
}
@@ -938,8 +929,8 @@ export function ArtifactFileDetail({
return;
}
if (language === "html") {
setViewMode(writeFileInFlight || documentRewriteActive ? "code" : "preview");
if (language === "html") {
setViewMode(writeFileInFlight || documentRewriteActive ? "code" : "preview");
return;
}
@@ -949,9 +940,9 @@ export function ArtifactFileDetail({
}
setViewMode("code");
}, [
documentRewriteActive,
isPreviewStreaming,
}, [
documentRewriteActive,
isPreviewStreaming,
isSupportPreview,
language,
writeFileInFlight,
@@ -962,7 +953,7 @@ export function ArtifactFileDetail({
return;
}
if (writeFileInFlight || documentRewriteActive) {
if (writeFileInFlight || documentRewriteActive) {
setViewMode("code");
return;
}
@@ -974,10 +965,10 @@ export function ArtifactFileDetail({
}, 1400);
return () => window.clearTimeout(timeoutId);
}, [
allowAutoViewSwitch,
documentRewriteActive,
isAutoPreviewLanguage,
}, [
allowAutoViewSwitch,
documentRewriteActive,
isAutoPreviewLanguage,
isSupportPreview,
writeFileInFlight,
]);
@@ -1138,16 +1129,17 @@ export function ArtifactFileDetail({
generateImages: boolean;
maxGeneratedImages: number;
}) => {
if (!canEditArtifact) {
toast.error("当前文件暂不支持全文改写。");
return;
}
setDocumentRewriteDialogOpen(false);
setAllowAutoViewSwitch(false);
setAutoScrollEnabled(true);
setViewMode("code");
// Do not make users manually save before using AI rewrite. Persist their
if (!canEditArtifact) {
toast.error("当前文件暂不支持全文改写。");
return;
}
setDocumentRewriteDialogOpen(false);
setAllowAutoViewSwitch(false);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
setViewMode("code");
// Do not make users manually save before using AI rewrite. Persist their
// current draft first so the rewrite starts from exactly what they see,
// and the optimistic hash remains valid on the server.
if (hasLocalEdits) {
@@ -1166,12 +1158,7 @@ export function ArtifactFileDetail({
}
}
primeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent: draftContent,
});
const expectedHash = await sha256Content(draftContent);
const expectedHash = await sha256Content(draftContent);
const presentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
@@ -1203,14 +1190,21 @@ export function ArtifactFileDetail({
expectedHash,
},
{
onEvent: (event) => {
if (!presentationId || !rewritePresentation) return;
switch (event.type) {
case "snapshot_fixed":
rewritePresentation.update(presentationId, {
stage: "requirements",
fileName: event.fileName || getFileName(filepath),
originalCharCount: event.originalCharCount,
onEvent: (event) => {
if (!presentationId || !rewritePresentation) return;
switch (event.type) {
case "job_started":
rewritePresentation.update(presentationId, {
jobId: event.jobId,
...(event.outputPath ? { outputPath: event.outputPath } : {}),
});
break;
case "snapshot_fixed":
rewritePresentation.update(presentationId, {
stage: "requirements",
fileName: event.fileName || getFileName(filepath),
...(event.outputPath ? { outputPath: event.outputPath } : {}),
originalCharCount: event.originalCharCount,
requirementsPlan: "",
});
break;
@@ -1274,9 +1268,11 @@ export function ArtifactFileDetail({
case "commit_started":
rewritePresentation.update(presentationId, { stage: "committing" });
break;
case "committed":
rewritePresentation.update(presentationId, {
versionId: event.versionId ?? null,
case "committed":
rewritePresentation.update(presentationId, {
rewrittenContent: event.content,
...(event.outputPath ? { outputPath: event.outputPath } : {}),
versionId: event.versionId ?? null,
committedHash: event.committedHash,
});
break;
@@ -1286,77 +1282,48 @@ export function ArtifactFileDetail({
versionId: event.versionId ?? null,
});
break;
case "conflict":
rewritePresentation.update(presentationId, {
stage: "conflict",
error: event.message,
});
break;
case "error":
// The sandbox retains the visible draft and exposes manual
// save instead of showing an error state to the user.
console.error("AI 全文改写接口失败:", event.message);
break;
case "conflict":
rewritePresentation.update(presentationId, {
stage: "conflict",
error: event.message,
});
break;
case "cancelled":
rewritePresentation.update(presentationId, {
stage: "cancelled",
error: event.message,
});
break;
case "error":
rewritePresentation.update(presentationId, {
stage: "error",
error: event.message,
diagnostic: event.diagnostic ?? null,
});
break;
default:
break;
}
},
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
setDraftContent(rewritten);
setHasLocalEdits(versionSnapshotFailed);
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent: draftContent,
committedHash,
});
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
localFallbackAvailable,
});
}
queryClient.setQueriesData(
{
queryKey: ["artifact", filepath, threadId, isMock],
},
{ content: rewritten, url: undefined },
);
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
} else {
toast.success("Markdown 全文改写完成,已安全替换原文件。");
}
},
onFailure: ({ content, error }) => {
// The rewrite transport can fail after the server has already
// streamed text (or before the first token on a 404). Keep that
// visible content in the sandbox, make Save actionable, and leave
// diagnostics to the developer console only.
const retainedContent = content || draftContent;
setDraftContent(retainedContent);
setHasLocalEdits(true);
queryClient.setQueriesData(
{
queryKey: ["artifact", filepath, threadId, isMock],
},
{ content: retainedContent, url: undefined },
);
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
stage: "completed",
rewrittenContent: retainedContent,
error: null,
});
}
console.error("AI 全文改写接口失败,已保留沙箱内容供手动保存:", error);
},
onCommitted: (rewritten, { committedHash, outputPath }) => {
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
...(outputPath ? { outputPath } : {}),
});
}
toast.success("AI 改写完成,已生成独立的改写版文件。原文件保持不变。");
},
onFailure: ({ error, diagnostic }) => {
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
stage: "error",
error,
diagnostic: diagnostic ?? null,
});
}
},
},
);
},
@@ -1374,15 +1341,37 @@ export function ArtifactFileDetail({
],
);
const handleCancelDocumentRewrite = useCallback(() => {
documentRewrite.cancel();
const handleCancelDocumentRewrite = useCallback(() => {
documentRewrite.cancel();
if (documentRewritePresentationId && rewritePresentation) {
rewritePresentation.update(documentRewritePresentationId, {
stage: "cancelled",
error: "已停止全文改写,原文件未被修改。",
});
}
}, [documentRewrite, documentRewritePresentationId, rewritePresentation]);
}, [documentRewrite, documentRewritePresentationId, rewritePresentation]);
// The docked conversation composer is outside this artifact pane. Register
// the same cancellation path so its standard square stop button can abort
// both the local SSE reader and the durable rewrite job.
useEffect(() => {
if (
!documentRewriteActive ||
!documentRewritePresentationId ||
!rewritePresentation
) {
return;
}
return rewritePresentation.registerCancellation(
documentRewritePresentationId,
handleCancelDocumentRewrite,
);
}, [
documentRewriteActive,
documentRewritePresentationId,
handleCancelDocumentRewrite,
rewritePresentation,
]);
// 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体
// 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。
@@ -1621,19 +1610,25 @@ export function ArtifactFileDetail({
{canEditArtifact && (
<ArtifactAction
icon={documentRewriteActive ? LoaderIcon : SparklesIcon}
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
tooltip={
documentRewriteActive
? "正在生成临时草稿,原文件尚未被覆盖"
: hasLocalEdits
? "将先保存当前 Markdown 修改,再开始改写"
: "使用 AI 重写当前完整 Markdown"
}
// Keep the entry available after a completed rewrite and when
// the user has local edits. Starting with local edits saves
// them automatically in handleStartDocumentRewrite.
onClick={() => setDocumentRewriteDialogOpen(true)}
/>
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
tooltip={
documentRewriteActive
? "正在生成独立改写版文件,原文件保持不变;点击停止"
: hasLocalEdits
? "将先保存当前 Markdown 修改,再开始改写"
: "使用 AI 重写当前完整 Markdown"
}
// Keep the entry available after a completed rewrite and when
// the user has local edits. Starting with local edits saves
// them automatically in handleStartDocumentRewrite.
onClick={() => {
if (documentRewriteActive) {
handleCancelDocumentRewrite();
return;
}
setDocumentRewriteDialogOpen(true);
}}
/>
)}
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
@@ -1759,27 +1754,6 @@ export function ArtifactFileDetail({
正在改写 Markdown
</div>
)}
{documentRewriteActive && (
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
<LoaderIcon className="size-3.5 animate-spin" />
{documentRewrite.run.stage === "requirements"
? "正在理解改写需求,原文件未被修改"
: documentRewrite.run.stage === "validation"
? "正在校验 Markdown"
: documentRewrite.run.stage === "committing"
? "正在安全替换原文件"
: "正在写入临时草稿 · " +
documentRewrite.run.writtenCharCount.toLocaleString() +
" 字"}
<button
type="button"
className="ml-1 rounded px-1.5 py-0.5 font-medium hover:bg-[#ead9a6]"
onClick={handleCancelDocumentRewrite}
>
停止
</button>
</div>
)}
{showResumeAutoScroll && (
<button
type="button"
@@ -1790,7 +1764,7 @@ export function ArtifactFileDetail({
滑动到底部并继续自动下滑
</button>
)}
{viewMode === "edit" && canUseAiEditor && !documentRewriteActive && (
{viewMode === "edit" && canUseAiEditor && !documentRewriteActive && (
<MarkdownAiEditor
// 编辑用原始内容(与源码模式一致,不脱敏);有 artifacts 保存能力时走头部「保存 Markdown」。
// write-file 虚拟预览(如深度研究)仅本地 draft,切换预览/源码仍可见本次改动。
@@ -1847,16 +1821,16 @@ export function ArtifactFileDetail({
? (displayContent ?? "")
: sandboxDisplayText
}
readonly={!canEditArtifact || documentRewriteActive}
onChange={
canEditArtifact && !documentRewriteActive
readonly={!canEditArtifact || documentRewriteActive}
onChange={
canEditArtifact && !documentRewriteActive
? (value) => {
setDraftContent(value);
setHasLocalEdits(true);
}
: undefined
}
isStreaming={writeFileInFlight || documentRewriteActive}
isStreaming={writeFileInFlight || documentRewriteActive}
streamingReadonlyFallback={!canEditArtifact}
/>
</div>
@@ -7,9 +7,11 @@ import {
useState,
type ReactNode,
} from "react";
import { toast } from "sonner";
import { useSidebarSafe } from "@/components/ui/sidebar";
import { env } from "@/env";
import { resolveWriteFilePath } from "@/core/artifacts/write-file-in-flight";
export function artifactPreviewKey(artifact: string): string {
if (!artifact.startsWith("write-file:")) {
@@ -38,6 +40,8 @@ export interface ArtifactsContextType {
select: (artifact: string, autoSelect?: boolean) => void;
/** Open/update a streamed write without overriding a manual close of that call. */
openStreamedArtifact: (artifact: string, previewKey: string) => boolean;
/** Register the file being generated; remove this owner's lock on completion. */
setGeneratingArtifact: (owner: string, artifact: string | null) => void;
deselect: () => void;
open: boolean;
@@ -86,6 +90,26 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [preOpen, setPreOpen] = useState(false);
const [autoOpen, setAutoOpen] = useState(true);
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
const generatingArtifactsRef = useRef(new Map<string, string>());
const setGeneratingArtifact = useCallback((owner: string, artifact: string | null) => {
if (artifact) generatingArtifactsRef.current.set(owner, artifact);
else generatingArtifactsRef.current.delete(owner);
}, []);
const canSwitchArtifact = useCallback((artifact: string, notify: boolean) => {
for (const generating of generatingArtifactsRef.current.values()) {
if (
artifactPreviewKey(generating) === artifactPreviewKey(artifact) ||
resolveWriteFilePath(generating) === resolveWriteFilePath(artifact)
) continue;
if (notify) {
toast.info("当前文件正在生成,请稍等片刻,完成后就可以切换查看其他文件。", {
id: "artifact-generation-switch-locked",
});
}
return false;
}
return true;
}, []);
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
@@ -129,6 +153,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const select = useCallback(
(artifact: string, autoSelect = false) => {
if (!canSwitchArtifact(artifact, !autoSelect)) return;
const previewKey = artifactPreviewKey(artifact);
selectedArtifactKeyRef.current = previewKey;
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
@@ -140,11 +165,12 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
setAutoSelect((previous) => (previous ? false : previous));
}
},
[sidebar],
[canSwitchArtifact, sidebar],
);
const openStreamedArtifact = useCallback(
(artifact: string, previewKey: string) => {
if (!canSwitchArtifact(artifact, false)) return false;
if (suppressedAutoOpenKeyRef.current === previewKey) {
return false;
}
@@ -180,10 +206,11 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
}
return true;
},
[applyOpen, cancelPendingStreamedOpen, sidebar],
[applyOpen, canSwitchArtifact, cancelPendingStreamedOpen, sidebar],
);
const deselect = useCallback(() => {
generatingArtifactsRef.current.clear();
selectedArtifactKeyRef.current = null;
setSelectedArtifact((previous) => (previous === null ? previous : null));
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
@@ -224,6 +251,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
selectedArtifactKey,
select,
openStreamedArtifact,
setGeneratingArtifact,
deselect,
};
@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
import { SubtasksProvider } from "@/core/tasks/context";
import { useUploadPolicy } from "@/core/uploads";
/**
* Shared providers required by every full chat surface.
*
* Keep this in a small standalone module: importing it from a route must not
* eagerly evaluate the complete WorkspaceRoutes tree and all of its pages.
*/
export function ChatRuntime({ children }: { children: ReactNode }) {
const { data: uploadPolicy } = useUploadPolicy();
return (
<SubtasksProvider>
<DocumentRewritePresentationProvider>
<ArtifactsProvider>
<PromptInputProvider uploadPolicy={uploadPolicy}>
{children}
</PromptInputProvider>
</ArtifactsProvider>
</DocumentRewritePresentationProvider>
</SubtasksProvider>
);
}
@@ -2,7 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FilesIcon, XIcon } from "lucide-react";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
@@ -23,6 +23,7 @@ import {
latestCompletedArtifactMutationKey,
latestInFlightWriteFilePreview,
latestPreparedHtmlArtifact,
isWriteFileInFlight,
} from "@/core/artifacts/write-file-in-flight";
import {
isAutoPreviewableArtifactPath,
@@ -30,7 +31,7 @@ import {
} from "@/core/artifacts/streaming-write-preview";
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
import { getBackendBaseURL } from "@/core/config";
import { resolveRagModeRequested, useLocalSettings } from "@/core/settings";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { useIsMobile } from "@/hooks/use-mobile";
import { filterKnowledgeWikiSources } from "@/core/llmwiki/knowledge-reference";
@@ -152,11 +153,11 @@ const ChatBox: React.FC<{
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const ragModeEnabled = resolveRagModeRequested({
referenceModeEntryEnabled,
ragModeEnabled: settings.display.ragModeEnabled,
knowledgeSelected: knowledgeReferencesEnabled || !!knowledgeReferences,
});
const ragModeEnabled =
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
knowledgeReferencesEnabled ||
!!knowledgeReferences;
// Frontend RAG modes write their own hidden source tool messages. They use
// the existing reference panel, but must not request or write reference
// batches through DeerFlow's backend APIs.
@@ -170,7 +171,14 @@ const ChatBox: React.FC<{
const referencesEnabled = ragModeEnabled || hasFrontendRag;
const shouldFetchBackendReferenceBatches =
referencesEnabled && !hasFrontendRag && !!threadId && threadId !== "new";
const { skills } = useSkills();
const hasThreadMessages = (thread.values.messages?.length ?? 0) > 0;
const { skills } = useSkills({
enabled:
referencesEnabled &&
!!threadId &&
threadId !== "new" &&
(hasThreadMessages || thread.isLoading),
});
const canScanArtifactDirectory =
(artifactDirectoryModeEnabled || !!requestedArtifact || !!preferredArtifact) &&
@@ -252,8 +260,20 @@ const ChatBox: React.FC<{
selectedArtifact,
selectedArtifactKey,
openStreamedArtifact,
setGeneratingArtifact,
} = useArtifacts();
// Keep the lock even when the user closes the preview while writing.
const selectedFileGenerating = Boolean(
selectedArtifact && thread.isLoading &&
isWriteFileInFlight({ messages: thread.messages ?? [] }, selectedArtifact),
);
useLayoutEffect(() => {
const owner = `chat:${threadId}`;
setGeneratingArtifact(owner, selectedFileGenerating ? selectedArtifact : null);
return () => setGeneratingArtifact(owner, null);
}, [selectedArtifact, selectedFileGenerating, setGeneratingArtifact, threadId]);
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
const skillDisplayMap = useMemo(
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
@@ -306,7 +326,7 @@ const ChatBox: React.FC<{
return;
}
openedPreferredArtifactRef.current = preferredKey;
selectArtifact(availablePreferredArtifact);
selectArtifact(availablePreferredArtifact, true);
setArtifactsOpen(true);
setSideTab("document");
// The same canonical report path may be overwritten by a later run while
@@ -574,7 +594,7 @@ const ChatBox: React.FC<{
previewKey,
);
} else {
selectArtifact(automaticTarget);
selectArtifact(automaticTarget, true);
setArtifactsOpen(true);
setSideTab("document");
}
@@ -600,7 +620,7 @@ const ChatBox: React.FC<{
(artifact) => !isWordDocumentFile(artifact),
);
if (firstPreviewableArtifact) {
selectArtifact(firstPreviewableArtifact);
selectArtifact(firstPreviewableArtifact, true);
}
}
}
@@ -623,8 +643,8 @@ const ChatBox: React.FC<{
writingModeEnabled,
]);
// Live sandbox: open as soon as any in-flight write_file has a path or
// the first content bytes — not only when that tool is the last CoT step.
// Live sandbox: open when an in-flight write_file has a usable output path.
// Pathless content stays inline in the CoT, including content-first streams.
// Agentfx (and other agents) often emit write_file + present_files/bash in
// the same turn; the collapsed earlier write would otherwise never auto-open.
const streamingWritePreview = useMemo(
@@ -675,7 +695,7 @@ const ChatBox: React.FC<{
return;
}
if (!selectedArtifact) {
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!);
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!, true);
}
setArtifactsOpen(true);
setSideTab("document");
@@ -802,13 +822,13 @@ const ChatBox: React.FC<{
description="Select an artifact to view its details"
/>
) : (
<div className="flex size-full max-w-[var(--container-width-sm)] flex-col justify-center p-4 pt-8">
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2>
</header>
<main className="min-h-0 grow">
<ArtifactFileList
className="max-w-[var(--container-width-sm)] p-4 pt-12"
className="max-w-(--container-width-sm) p-4 pt-12"
files={artifacts}
threadId={threadId}
/>
@@ -304,7 +304,9 @@ export function CompactModeGuide({
setCurrent(-1);
}, [clearPollTimer, releasePromptDialog]);
if (!eligible) return null;
// TDesign 1.18 still handles resize when current=-1 and dereferences an
// undefined step. Keep its listener mounted only during an active guide.
if (!eligible || current < 0 || current >= stepsRef.current.length) return null;
return (
<Guide
@@ -194,7 +194,7 @@ function ManagePromptsDialog({
const createPrompt = useCreateCustomPrompt();
const updatePrompt = useUpdateCustomPrompt();
const deletePrompt = useDeleteCustomPrompt();
const { agents } = useAgentSelector();
const { agents } = useAgentSelector({ enabled: open });
const preferences = useUserPreferences();
const updatePreferences = useUpdateUserPreferences();
const [editingId, setEditingId] = useState<string | null>(null);
@@ -3,7 +3,6 @@
import { Bot, ChevronRight } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import {
SidebarGroup,
@@ -13,12 +12,10 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useAgentsPaginated } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { useSidebarAgents } from "@/core/agents";
import { desensitize } from "@/lib/desensitize";
/** 常用智能体最多展示个数(需求 1.2 ②)。 */
const MAX_FREQUENT = 3;
/** 「更多」跳转目标:智能体管理页(需求 1.3)。 */
const MORE_HREF = "/page/strategy/agents";
@@ -40,33 +37,7 @@ function agentHomeHref(agentId: string): string {
*/
export function FrequentAgentsList() {
const pathname = usePathname();
const { agents: mine } = useAgentsPaginated({
scope: "mine",
page: 1,
pageSize: 20,
});
const { agents: square } = useAgentsPaginated({
scope: "square",
page: 1,
pageSize: 20,
});
const frequent = useMemo<Agent[]>(() => {
const out: Agent[] = [];
const seen = new Set<string>();
const add = (agent: Agent | undefined) => {
if (!agent || out.length >= MAX_FREQUENT || seen.has(agent.id)) return;
seen.add(agent.id);
out.push(agent);
};
// 1. 置顶组优先(够 3 个则下面我的/广场都不再补)。
[...mine, ...square].filter((a) => a.is_pinned).forEach(add);
// 2. 我的智能体补足。
mine.forEach(add);
// 3. 广场默认推荐补足。
square.forEach(add);
return out;
}, [mine, square]);
const { agents: frequent } = useSidebarAgents();
if (frequent.length === 0) {
return null;
@@ -68,7 +68,7 @@ import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useAgentsPaginated, type Agent } from "@/core/agents";
import { useAgentsPaginated, useSidebarAgents, type Agent } from "@/core/agents";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
import {
@@ -112,9 +112,6 @@ import {
import { agentColor } from "./agent-icon-color";
import { SidebarCollapseButton } from "./workspace-header";
/** 常用智能体最多展示个数。 */
const MAX_FREQUENT = 4;
// 智能体图标配色(按 id 稳定散列、分主题)抽到共享模块 ./agent-icon-color,
// 与 zzJC/zzXD 模块左栏(AgentModuleSidebar)共用,保证彩色卡片配色完全一致。
@@ -156,7 +153,7 @@ function SectionLabel({
);
}
type CommonAgent = Pick<Agent, "id" | "name" | "description">;
type CommonAgent = Pick<Agent, "id" | "name"> & { description?: string };
/** wx 左栏固定的智能体类型;南京-db 广场尚未创建时筛空列表。 */
const WX_AGENT_TYPE_MINE = "mine";
@@ -253,19 +250,9 @@ export function GeneralQaSidebar({
return !v;
});
// ── 常用智能体取数:wx 走类型筛选;其它页面仍是置顶 → 我的 → 广场 ──
const { agents: mine } = useAgentsPaginated({
scope: "mine",
page: 1,
pageSize: 20,
enabled: !wxCommonAgents,
});
const { agents: square } = useAgentsPaginated({
scope: "square",
page: 1,
pageSize: 20,
enabled: !wxCommonAgents,
});
// The ordinary full-page sidebar only needs three id/name rows. WX-specific
// catalog pages keep their richer, filtered result below.
const { agents: sidebarAgents } = useSidebarAgents({ enabled: !wxCommonAgents });
const {
agents: wxAgents,
total: wxTotal,
@@ -283,18 +270,8 @@ export function GeneralQaSidebar({
if (wxCommonAgents) {
return wxFetchEnabled ? wxAgents : [];
}
const out: Agent[] = [];
const seen = new Set<string>();
const add = (agent: Agent | undefined) => {
if (!agent || out.length >= MAX_FREQUENT || seen.has(agent.id)) return;
seen.add(agent.id);
out.push(agent);
};
[...mine, ...square].filter((a) => a.is_pinned).forEach(add);
mine.forEach(add);
square.forEach(add);
return out;
}, [mine, square, wxAgents, wxCommonAgents, wxFetchEnabled]);
return sidebarAgents;
}, [sidebarAgents, wxAgents, wxCommonAgents, wxFetchEnabled]);
const wxEmptyHint =
wxAgentType === WX_AGENT_TYPE_NANJING_DB && !wxSquareId
? "该类型暂无智能体"
@@ -641,7 +618,7 @@ export function GeneralQaSidebar({
: "text-muted-foreground",
)}
>
{agent.description ? desensitize(agent.description) : "智能体助手"}
{wxCommonAgents && agent.description ? desensitize(agent.description) : "智能体助手"}
</span>
</span>
{isActive && (
@@ -1,8 +1,10 @@
"use client";
import type { ChatStatus } from "ai";
import {
import type { ChatStatus } from "ai";
import { toast } from "sonner";
import {
BookMarkedIcon,
BrainCircuitIcon,
CheckIcon,
FileTextIcon,
FolderTreeIcon,
@@ -61,12 +63,21 @@ import {
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import { getBackendBaseURL } from "@/core/config";
import {
isDocumentRewriteActiveStage,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import { useI18n } from "@/core/i18n/hooks";
import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import {
resolveModelReasoningEffort,
type StoredReasoningEffort,
} from "@/core/models/reasoning-effort";
import type { Model } from "@/core/models/types";
import type { AgentThreadContext } from "@/core/threads";
import type { KnowledgeRetrievalMode } from "@/core/threads/knowledge-retrieval-mode";
import { textOfMessage } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -92,7 +103,11 @@ import {
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import { useUserPreferences } from "@/core/user-preferences";
import { useLocalSettings } from "@/core/settings";
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu";
@@ -120,6 +135,7 @@ export function InputBox({
promptPrefix,
agentSelector,
knowledgeSelector,
knowledgeRetrieval,
knowledgeSuggestedQuestions = [],
extraModels = [],
assistantId,
@@ -142,7 +158,7 @@ export function InputBox({
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
reasoning_effort?: StoredReasoningEffort;
writing_mode?: boolean;
};
extraHeader?: React.ReactNode;
@@ -158,7 +174,7 @@ export function InputBox({
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
reasoning_effort?: StoredReasoningEffort;
writing_mode?: boolean;
},
) => void;
@@ -193,6 +209,12 @@ export function InputBox({
};
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
knowledgeSelector?: ReactNode;
/** Per-thread ordinary-knowledge retrieval mode. Omit when unavailable. */
knowledgeRetrieval?: {
mode: KnowledgeRetrievalMode;
smartAvailable: boolean;
onModeChange: (mode: KnowledgeRetrievalMode) => void;
};
/** High-value starter questions derived from the currently selected spaces. */
knowledgeSuggestedQuestions?: string[];
/** Page-scoped model choices implemented outside the DeerFlow model API. */
@@ -212,11 +234,25 @@ export function InputBox({
return singleRoundRagActive ? extraModels : models;
}, [extraModels, models, singleRoundRagActive]);
const { thread, isMock } = useThread();
const rewritePresentation = useDocumentRewritePresentation();
const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null);
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const documentRewriteActive = useMemo(
() =>
rewritePresentation?.forThread(threadId).some((item) =>
isDocumentRewriteActiveStage(item.stage),
) ?? false,
[rewritePresentation, threadId],
);
// Keep normal chat generation authoritative when both states overlap. In
// the usual rewrite-only case this gives the composer the same square stop
// affordance as a normal streamed reply.
const composerStatus: ChatStatus = documentRewriteActive
? "streaming"
: status;
const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false);
@@ -231,6 +267,7 @@ export function InputBox({
const { questions: recommendedQuestions } = useRecommendedQuestions();
const userPreferences = useUserPreferences();
const followupsEnabled = userPreferences.followup_suggestions_enabled;
const showAdminRecommendedQuestions =
!hideAdminRecommendedQuestions &&
knowledgeSuggestedQuestions.length === 0 &&
@@ -249,12 +286,21 @@ export function InputBox({
const currentModel = modelOptions.find((m) => m.name === context.model_name);
const fallbackModel = currentModel ?? modelOptions[0]!;
const supportsThinking = fallbackModel.supports_thinking ?? false;
const supportsReasoningEffort =
fallbackModel.supports_reasoning_effort ?? false;
const nextModelName = fallbackModel.name;
const nextMode = resolveInputMode(context.mode, supportsThinking);
const nextReasoningEffort = resolveModelReasoningEffort(
supportsReasoningEffort,
context.reasoning_effort,
context.model_name !== nextModelName,
fallbackModel.default_reasoning_effort,
);
if (
context.model_name === nextModelName &&
context.mode === nextMode &&
context.reasoning_effort === nextReasoningEffort &&
context.execution_profile === fallbackModel.execution_profile
) {
return;
@@ -264,6 +310,7 @@ export function InputBox({
...context,
model_name: nextModelName,
mode: nextMode,
reasoning_effort: nextReasoningEffort,
execution_profile: fallbackModel.execution_profile,
});
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
@@ -288,6 +335,16 @@ export function InputBox({
[selectedModel],
);
const selectedReasoningEffort = useMemo(
() =>
resolveModelReasoningEffort(
supportReasoningEffort,
context.reasoning_effort,
false,
selectedModel?.default_reasoning_effort,
),
[context.reasoning_effort, selectedModel?.default_reasoning_effort, supportReasoningEffort],
);
const handleModelSelect = useCallback(
(model_name: string) => {
const model = modelOptions.find((m) => m.name === model_name);
@@ -298,7 +355,12 @@ export function InputBox({
...context,
model_name,
mode: resolveInputMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: context.reasoning_effort,
reasoning_effort: resolveModelReasoningEffort(
model.supports_reasoning_effort ?? false,
context.reasoning_effort,
model_name !== context.model_name,
model.default_reasoning_effort,
),
execution_profile: model.execution_profile,
});
setModelDialogOpen(false);
@@ -317,18 +379,22 @@ export function InputBox({
mode,
ordinaryModel?.supports_thinking ?? supportThinking,
),
reasoning_effort:
mode === "ultra"
? "high"
: mode === "pro"
? "medium"
: mode === "thinking"
? "low"
: "minimal",
reasoning_effort: resolveModelReasoningEffort(
ordinaryModel?.supports_reasoning_effort ?? supportReasoningEffort,
context.reasoning_effort,
false,
ordinaryModel?.default_reasoning_effort,
),
execution_profile: undefined,
});
},
[onContextChange, context, models, supportThinking],
[
onContextChange,
context,
models,
supportThinking,
supportReasoningEffort,
],
);
const handleSingleRoundRagSelect = useCallback(() => {
@@ -357,16 +423,6 @@ export function InputBox({
});
}, [context, onContextChange]);
const handleReasoningEffortSelect = useCallback(
(effort: "minimal" | "low" | "medium" | "high") => {
onContextChange?.({
...context,
reasoning_effort: effort,
});
},
[onContextChange, context],
);
const handleWritingModeToggle = useCallback(() => {
onContextChange?.({
...context,
@@ -382,13 +438,19 @@ export function InputBox({
});
}, [artifactDirectoryModeEnabled, setLocalSettings]);
const ragModeEnabled =
referenceModeEntryEnabled && localSettings.display.ragModeEnabled;
const smartKnowledgeRetrievalActive =
knowledgeRetrieval?.mode === "smart_reasoning";
const ragModeLocked =
!smartKnowledgeRetrievalActive && isThreadRagModeLocked(threadId);
const ragModeEnabled =
!smartKnowledgeRetrievalActive &&
(localSettings.display.ragModeEnabled || ragModeLocked);
const handleRagModeToggle = useCallback(() => {
setLocalSettings("display", {
ragModeEnabled: !localSettings.display.ragModeEnabled,
});
}, [localSettings.display.ragModeEnabled, setLocalSettings]);
if (ragModeLocked) {
return;
}
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
const contextCompressionEnabled =
localSettings.display.contextCompressionEnabled ?? false;
@@ -398,21 +460,33 @@ export function InputBox({
});
}, [contextCompressionEnabled, setLocalSettings]);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (status === "streaming") {
onStop?.();
return;
}
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (composerStatus === "streaming") {
toast.info("正在生成中,请等待当前回复完成后再发送。", {
description: "已保留您输入的内容。",
});
// Reject rather than resolve: PromptInput treats a resolved submit as
// accepted and clears its draft. The current message has not been sent.
throw new Error("Cannot submit while a response is streaming.");
}
if (!hasPromptInputPayload(message)) {
return;
}
setFollowups([]);
setFollowupsHidden(false);
setFollowupsLoading(false);
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
// Guard against submitting before the initial model auto-selection
// effect has flushed thread settings to storage/state.
if (resolvedModelName && context.model_name !== resolvedModelName) {
if (
resolvedModelName &&
(context.model_name !== resolvedModelName ||
context.reasoning_effort !== selectedReasoningEffort)
) {
onContextChange?.({
...context,
model_name: resolvedModelName,
@@ -420,6 +494,7 @@ export function InputBox({
context.mode,
selectedModel?.supports_thinking ?? false,
),
reasoning_effort: selectedReasoningEffort,
execution_profile: selectedModel?.execution_profile,
});
setTimeout(() => onSubmit?.(message), 0);
@@ -430,24 +505,54 @@ export function InputBox({
},
[
context,
onContextChange,
onSubmit,
onStop,
resolvedModelName,
onContextChange,
onSubmit,
composerStatus,
resolvedModelName,
ragModeEnabled,
selectedModel?.supports_thinking,
selectedModel?.execution_profile,
status,
selectedReasoningEffort,
threadId,
],
);
const handleStopCurrentGeneration = useCallback(() => {
if (status === "streaming") {
onStop?.();
return;
}
if (!documentRewriteActive || !rewritePresentation) return;
void rewritePresentation.cancelActiveForThread(threadId).then((result) => {
if (!result.cancelled) {
toast.error(result.error ?? "停止全文改写失败,请稍后重试。");
}
});
}, [documentRewriteActive, onStop, rewritePresentation, status, threadId]);
const requestFormSubmit = useCallback(() => {
const form = promptRootRef.current?.querySelector("form");
form?.requestSubmit();
}, []);
const handleFollowupClick = useCallback(
(suggestion: string) => {
if (status === "streaming") {
const fillPromptForEditing = useCallback(
(prompt: string) => {
textInput.setInput(prompt);
setFollowupsHidden(true);
// Wait for the controlled textarea to receive the inserted value before
// focusing it, so the user can review and edit before sending.
setTimeout(() => {
promptRootRef.current
?.querySelector<HTMLTextAreaElement>("textarea[name='message']")
?.focus();
}, 0);
},
[textInput],
);
const handleFollowupClick = useCallback(
(suggestion: string) => {
if (composerStatus === "streaming") {
return;
}
const current = (textInput.value ?? "").trim();
@@ -460,7 +565,7 @@ export function InputBox({
setFollowupsHidden(true);
setTimeout(() => requestFormSubmit(), 0);
},
[requestFormSubmit, status, textInput],
[composerStatus, requestFormSubmit, textInput],
);
const confirmReplaceAndSend = useCallback(() => {
@@ -492,6 +597,7 @@ export function InputBox({
}, [pendingSuggestion, requestFormSubmit, textInput]);
const followupsPresent =
followupsEnabled &&
!disabled &&
!isNewThread &&
!selectedSingleRoundRag &&
@@ -513,11 +619,20 @@ export function InputBox({
return () => followupsPresenceChangeRef.current?.(false);
}, []);
useEffect(() => {
if (followupsEnabled) return;
setFollowups([]);
setFollowupsLoading(false);
setFollowupsHidden(true);
setConfirmOpen(false);
setPendingSuggestion(null);
}, [followupsEnabled]);
useEffect(() => {
const streaming = status === "streaming";
const wasStreaming = wasStreamingRef.current;
wasStreamingRef.current = streaming;
if (!wasStreaming || streaming) {
if (!followupsEnabled || !wasStreaming || streaming) {
return;
}
@@ -569,6 +684,7 @@ export function InputBox({
return (await res.json()) as { suggestions?: string[] };
})
.then((data) => {
if (controller.signal.aborted) return;
const suggestions = (data.suggestions ?? [])
.map((s) => (typeof s === "string" ? s.trim() : ""))
.filter((s) => s.length > 0)
@@ -576,14 +692,19 @@ export function InputBox({
setFollowups(suggestions);
})
.catch(() => {
if (controller.signal.aborted) return;
setFollowups([]);
})
.finally(() => {
if (controller.signal.aborted) return;
setFollowupsLoading(false);
});
return () => controller.abort();
}, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
return () => {
controller.abort();
setFollowupsLoading(false);
};
}, [context.model_name, disabled, followupsEnabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
return (
<div ref={promptRootRef} className="relative flex flex-col gap-6">
@@ -603,9 +724,7 @@ export function InputBox({
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);
fillPromptForEditing(question);
}}
>
{question}
@@ -623,9 +742,7 @@ export function InputBox({
type="button"
className="rounded-full border border-[#e4e4e7] bg-white px-4 py-2 text-sm text-[#18181b] shadow-none transition-colors hover:bg-[#f4f4f5] cursor-pointer dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"
onClick={() => {
textInput.setInput(q.content);
setFollowupsHidden(true);
setTimeout(() => requestFormSubmit(), 0);
fillPromptForEditing(q.content);
}}
>
{desensitize(q.title)}
@@ -697,7 +814,7 @@ export function InputBox({
// 对话态:常规白卡 + 中性边框 + 轻阴影
isNewThread
? "rounded-3xl bg-transparent border-0 shadow-none ring-0"
: "rounded-3xl bg-background border border-border shadow-sm transition-shadow hover:shadow-md focus-within:shadow-md dark:bg-slate-800 dark:border-slate-700",
: "rounded-3xl bg-background border border-border shadow-sm transition-shadow hover:shadow-md focus-within:border-ring/40 focus-within:shadow-md focus-within:ring-1 focus-within:ring-ring/20 dark:bg-slate-800 dark:border-slate-700",
className,
)}
disabled={disabled}
@@ -715,8 +832,8 @@ export function InputBox({
<PromptInputTextarea
className={cn(
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-muted-foreground/60",
isNewThread && "!min-h-[110px]",
compact && "!min-h-[84px] px-3 pt-3 pb-1 text-[14px]",
isNewThread && "min-h-[110px]!",
compact && "min-h-[84px]! px-3 pt-3 pb-1 text-[14px]",
)}
disabled={disabled}
placeholder={t.inputBox.placeholder}
@@ -745,9 +862,9 @@ export function InputBox({
{!frontendRagActive && (
<AddAttachmentsButton
className={cn(
"rounded-full border-0 bg-transparent !px-2.5 shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact &&
"!gap-1.5 rounded-lg !px-2.5 !py-1 text-[11px] font-medium",
"gap-1.5! rounded-lg px-2.5! py-1! text-[11px] font-medium",
)}
/>
)}
@@ -806,35 +923,42 @@ export function InputBox({
<span className="text-xs font-normal">文件</span>
</PromptInputButton>
</Tooltip>}
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
: "参考文献模式已关闭:点击开启"
}
>
<PromptInputButton
type="button"
aria-pressed={ragModeEnabled}
disabled={disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
ragModeEnabled
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: 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={handleRagModeToggle}
>
{ragModeEnabled && (
<CheckIcon className="size-3 text-emerald-700 dark:text-emerald-200" />
)}
<BookMarkedIcon className="size-3" />
<span className="text-xs font-normal">参考文献</span>
</PromptInputButton>
</Tooltip>}
{referenceModeEntryEnabled &&
!frontendRagActive &&
!smartKnowledgeRetrievalActive &&
!hideContextTools && (
<Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
: "参考文献模式已关闭:点击开启"
}
>
<PromptInputButton
type="button"
aria-pressed={ragModeEnabled}
disabled={ragModeLocked || disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact &&
"rounded-lg px-2.5! py-1! text-[11px] font-medium",
ragModeLocked && "cursor-not-allowed opacity-80",
ragModeEnabled
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: 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={handleRagModeToggle}
>
{ragModeEnabled && (
<CheckIcon className="size-3 text-emerald-700 dark:text-emerald-200" />
)}
<BookMarkedIcon className="size-3" />
<span className="text-xs font-normal">参考文献</span>
</PromptInputButton>
</Tooltip>
)}
{!frontendRagActive && <Tooltip
content={
contextCompressionEnabled
@@ -1127,122 +1251,6 @@ export function InputBox({
</DropdownMenuGroup>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
<div className="text-xs font-normal">
{t.inputBox.reasoningEffort}:
{context.reasoning_effort === "minimal" &&
" " + t.inputBox.reasoningEffortMinimal}
{context.reasoning_effort === "low" &&
" " + t.inputBox.reasoningEffortLow}
{context.reasoning_effort === "medium" &&
" " + t.inputBox.reasoningEffortMedium}
{context.reasoning_effort === "high" &&
" " + t.inputBox.reasoningEffortHigh}
</div>
</PromptInputActionMenuTrigger>
<PromptInputActionMenuContent className="w-70">
<DropdownMenuGroup>
<DropdownMenuLabel className="text-muted-foreground text-xs">
{t.inputBox.reasoningEffort}
</DropdownMenuLabel>
<PromptInputActionMenu>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "minimal"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("minimal")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortMinimal}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortMinimalDescription}
</div>
</div>
{context.reasoning_effort === "minimal" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "low"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("low")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortLow}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortLowDescription}
</div>
</div>
{context.reasoning_effort === "low" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "medium" ||
!context.reasoning_effort
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("medium")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortMedium}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortMediumDescription}
</div>
</div>
{context.reasoning_effort === "medium" ||
!context.reasoning_effort ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "high"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("high")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortHigh}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortHighDescription}
</div>
</div>
{context.reasoning_effort === "high" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
</PromptInputActionMenu>
</DropdownMenuGroup>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
)}
</PromptInputTools>
<PromptInputTools className={cn("ml-auto shrink-0 gap-1.5", compact && "gap-2")}>
{!frontendRagActive && agentSelector && (
@@ -1254,6 +1262,83 @@ export function InputBox({
/>
)}
{!frontendRagActive && knowledgeSelector}
{!frontendRagActive && knowledgeRetrieval && (
<DropdownMenu>
<Tooltip
content={
knowledgeRetrieval.mode === "smart_reasoning"
? "智能检索:综合原文与 Wiki 线索,提供更全面的背景和解释"
: "经典检索:细粒度召回原文,优先回答具体事实与细节"
}
>
<DropdownMenuTrigger asChild>
<PromptInputButton
type="button"
disabled={disabled}
aria-label={`普通知识库检索模式:${
knowledgeRetrieval.mode === "smart_reasoning"
? "智能检索"
: "经典检索"
}`}
aria-haspopup="menu"
className={cn(
"rounded-full px-3 shadow-none transition-colors",
knowledgeRetrieval.mode === "smart_reasoning"
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: "border border-transparent bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground",
)}
>
<BrainCircuitIcon className="size-3.5" />
<span className="max-w-[160px] truncate text-xs font-normal">
{knowledgeRetrieval.mode === "smart_reasoning" ? "智能检索" : "经典检索"}
</span>
</PromptInputButton>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent
align="end"
sideOffset={8}
className="w-80 rounded-xl p-1.5"
>
<DropdownMenuLabel className="px-2 pb-1 text-xs text-muted-foreground">
普通知识库检索模式
</DropdownMenuLabel>
<DropdownMenuItem
className="min-h-16 items-start rounded-lg py-2"
onSelect={() => knowledgeRetrieval.onModeChange("classic")}
>
<BookMarkedIcon className="mt-0.5 size-4" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">经典检索(默认)</div>
<div className="mt-0.5 text-xs text-muted-foreground">
细粒度召回知识库原文,优先定位与问题最相关的具体段落,适合查询事实、条款、数字和原文细节
</div>
</div>
{knowledgeRetrieval.mode === "classic" && (
<CheckIcon className="mt-0.5 size-4 text-primary" />
)}
</DropdownMenuItem>
<DropdownMenuItem
className="min-h-16 items-start rounded-lg py-2"
disabled={!knowledgeRetrieval.smartAvailable}
onSelect={() =>
knowledgeRetrieval.onModeChange("smart_reasoning")
}
>
<BrainCircuitIcon className="mt-0.5 size-4" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">智能检索</div>
<div className="mt-0.5 text-xs text-muted-foreground">
综合原文分片与 Wiki 目录、页面和关联线索扩展检索,适合全面梳理背景、关联关系和综合解释
</div>
</div>
{knowledgeRetrieval.mode === "smart_reasoning" && (
<CheckIcon className="mt-0.5 size-4 text-emerald-600" />
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{!frontendRagActive && promptPrefix && (
<Tooltip
content={
@@ -1338,11 +1423,21 @@ export function InputBox({
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
compact &&
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted",
)}
disabled={disabled}
variant="outline"
status={status}
/>
)}
disabled={disabled}
onClick={(event) => {
if (composerStatus !== "streaming") return;
// A deliberate press of the square button stops the stream.
// Prevent the enclosing form from treating it as a new submit.
event.preventDefault();
handleStopCurrentGeneration();
}}
variant="outline"
status={composerStatus}
{...(documentRewriteActive
? { "aria-label": "停止 AI 全文改写" }
: {})}
/>
</PromptInputTools>
</PromptInputFooter>
{!isNewThread && (
@@ -1,16 +1,16 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BookOpen, Database, SearchCheck } from "lucide-react";
import { BookOpen /* , Database, SearchCheck */ } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getStoredAuth } from "@/core/auth";
import { canAccessAssistantKnowledge } from "@/core/llmwiki/assistant-knowledge-access-policy";
import {
useAssistantKnowledgeVisibility,
useUpdateAssistantKnowledgeVisibility,
} from "@/core/system-settings/hooks";
// import { canAccessAssistantKnowledge } from "@/core/llmwiki/assistant-knowledge-access-policy";
// import {
// useAssistantKnowledgeVisibility,
// useUpdateAssistantKnowledgeVisibility,
// } from "@/core/system-settings/hooks";
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import {
loadOrdinaryKnowledgeLanding,
@@ -20,8 +20,14 @@ import {
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
export function useOrdinaryKnowledgeLanding() {
return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 });
export function useOrdinaryKnowledgeLanding(enabled = true) {
return useQuery({
queryKey: LANDING_KEY,
queryFn: ({ signal }) => loadOrdinaryKnowledgeLanding(signal),
enabled,
staleTime: 30_000,
retry: false,
});
}
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
@@ -30,8 +36,9 @@ export function KnowledgeProductNav() {
const location = useLocation();
const navigate = useNavigate();
const client = useQueryClient();
const assistantVisibility = useAssistantKnowledgeVisibility();
const landing = useOrdinaryKnowledgeLanding();
// const assistantVisibility = useAssistantKnowledgeVisibility();
const isAdmin = getStoredAuth()?.system_role === "admin";
const landing = useOrdinaryKnowledgeLanding(isAdmin);
const saveLanding = useMutation({
mutationFn: saveOrdinaryKnowledgeLanding,
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
@@ -39,12 +46,11 @@ export function KnowledgeProductNav() {
});
const runtime = useLlmWikiRuntime();
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
const isAdmin = getStoredAuth()?.system_role === "admin";
const assistantAllowed = canAccessAssistantKnowledge(
isAdmin,
assistantVisibility.isError ? undefined : assistantVisibility.data,
);
const saveAssistantVisibility = useUpdateAssistantKnowledgeVisibility();
// const assistantAllowed = canAccessAssistantKnowledge(
// isAdmin,
// assistantVisibility.isError ? undefined : assistantVisibility.data,
// );
// const saveAssistantVisibility = useUpdateAssistantKnowledgeVisibility();
const retrievalDebug = location.pathname.includes("/knowledge/retrieval-debug");
const assistant = location.pathname.includes("/knowledge/assistant");
const ordinary = !assistant && !retrievalDebug;
@@ -54,6 +60,7 @@ export function KnowledgeProductNav() {
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={ordinary ? "secondary" : "ghost"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
<BookOpen />普通知识库
</Button>
{/* 助手知识库、检索调试暂时隐藏,后续放开时去掉这段注释。
{assistantAllowed && (
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
<Database />助手知识库
@@ -64,6 +71,7 @@ export function KnowledgeProductNav() {
<SearchCheck />检索调试
</Button>
)}
*/}
{isAdmin && (
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
普通知识库默认页面
@@ -71,6 +79,7 @@ export function KnowledgeProductNav() {
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
</Select>
{/* 助手知识库日常展示暂时隐藏,和上面两个入口一起放开。
助手知识库日常展示
<Select
disabled={assistantVisibility.isLoading || saveAssistantVisibility.isPending}
@@ -85,6 +94,7 @@ export function KnowledgeProductNav() {
<SelectTrigger className="h-8 w-52" aria-label="助手知识库日常展示"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="hidden">隐藏(仅管理员可用)</SelectItem><SelectItem value="visible">开放给普通用户</SelectItem></SelectContent>
</Select>
*/}
</div>
)}
</div>
@@ -22,6 +22,8 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { useAssistantKnowledgeAccess } from "@/core/llmwiki/assistant-knowledge-access";
import { ASSISTANT_KNOWLEDGE_UI_VISIBLE } from "@/core/llmwiki/assistant-knowledge-ui";
import { userFacingKnowledgeSuggestedQuestions } from "@/core/llmwiki/knowledge-suggested-questions";
import { cn } from "@/lib/utils";
import {
type AssistantKnowledgeBase,
@@ -46,14 +48,14 @@ export function buildKnowledgeSpaceQuestions(names: string[]): string[] {
const name = cleanNames[0]!;
return [
`请概括「${name}」知识空间的主要内容、适用场景和资料边界。`,
`「${name}」包含哪些核心主题?请按重要性说明并引用来源。`,
`「${name}」包含哪些核心主题?请按重要性说明。`,
`请梳理「${name}」中的关键实体、概念及它们之间的关系。`,
`根据「${name}」,有哪些值得关注的问题、风险或行动建议?`,
];
}
return [
`请综合所选 ${cleanNames.length} 个知识空间,总结共同主题、差异和互补信息。`,
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点,并引用各自来源。`,
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点。`,
"请梳理所选知识空间中的关键实体、概念及跨库关系。",
"所选知识空间中有哪些相互冲突或证据不足的结论?",
];
@@ -426,10 +428,13 @@ export function LlmWikiKnowledgeSelector({
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const assistantAccess = useAssistantKnowledgeAccess({
enabled: onAssistantChange !== undefined,
enabled:
ASSISTANT_KNOWLEDGE_UI_VISIBLE && onAssistantChange !== undefined,
});
const assistantAllowed =
onAssistantChange !== undefined && assistantAccess.canAccess;
ASSISTANT_KNOWLEDGE_UI_VISIBLE &&
onAssistantChange !== undefined &&
assistantAccess.canAccess;
const runtime = useLlmWikiRuntime();
const weknoraEnabled = runtime.data?.provider === "weknora";
const shouldLoadOptions =
@@ -489,7 +494,12 @@ export function LlmWikiKnowledgeSelector({
[selectedAssistantNames, selectedNames],
);
const selectedConfiguredQuestions = useMemo(
() => value.flatMap((id) => rows.find((item) => item.id === id)?.test_questions ?? []),
() =>
userFacingKnowledgeSuggestedQuestions(
value.flatMap(
(id) => rows.find((item) => item.id === id)?.test_questions ?? [],
),
),
[rows, value],
);
const suggestedQuestions = useMemo(
@@ -549,7 +559,11 @@ export function LlmWikiKnowledgeSelector({
value={value}
assistantValue={effectiveAssistantValue}
onChange={onChange}
onAssistantChange={onAssistantChange ?? IGNORE_ASSISTANT_CHANGE}
onAssistantChange={
assistantAllowed
? (onAssistantChange ?? IGNORE_ASSISTANT_CHANGE)
: IGNORE_ASSISTANT_CHANGE
}
search={search}
onSearchChange={setSearch}
loading={loading}
@@ -4,6 +4,7 @@ import {
CheckCircle2Icon,
ChevronDownIcon,
ChevronsDownIcon,
CopyIcon,
FilePenLineIcon,
LightbulbIcon,
Loader2Icon,
@@ -14,6 +15,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ArtifactFileList } from "@/components/workspace/artifacts/artifact-file-list";
import {
Collapsible,
CollapsibleContent,
@@ -22,6 +24,7 @@ import {
import { apiFetch } from "@/core/api/fetch-client";
import { updateArtifactContent } from "@/core/artifacts/api";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { copyTextToClipboard } from "@/core/clipboard";
import {
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
@@ -42,7 +45,7 @@ import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
const REWRITE_STEPS = [
{ key: "snapshot", label: "固定原文版本" },
{ key: "requirements", label: "理解改写要求" },
{ key: "writing", label: "流式重写 Markdown" },
{ key: "writing", label: "生成改写版 Markdown" },
{ key: "illustrations", label: "生成文档配图" },
{ key: "validation", label: "校验文档" },
{ key: "comparison", label: "对比原文并生成说明" },
@@ -221,6 +224,9 @@ export function DocumentRewriteTimeline({
const presentation = useDocumentRewritePresentation();
const [undoing, setUndoing] = useState(false);
const [diffOpen, setDiffOpen] = useState(false);
const diagnosticText = item.diagnostic
? JSON.stringify(item.diagnostic, null, 2)
: "";
const reapplying = Boolean(item.revertedAt);
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
@@ -370,7 +376,7 @@ export function DocumentRewriteTimeline({
: "bg-primary/10 text-primary",
)}
>
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未覆盖原文件") : "进行中"}
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未生成改写版") : "进行中"}
</span>
</div>
<p className="text-muted-foreground mt-1 text-sm">
@@ -440,13 +446,13 @@ export function DocumentRewriteTimeline({
{step.key === "writing" && (
item.writtenCharCount > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs">
正在写入右侧沙箱,已{isNewReportGeneration ? "生成" : "改写"} {item.writtenCharCount.toLocaleString()} 字。
正在{isNewReportGeneration ? "生成新报告" : "生成改写版"},已输出 {item.writtenCharCount.toLocaleString()} 字。
</p>
) : active ? (
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
{isNewReportGeneration
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
: "正在等待模型开始输出;右侧将在首个字符到达后显示草稿。"}
: "正在等待模型开始输出;完成后将生成一份独立的改写版文件。"}
</p>
) : null
)}
@@ -583,9 +589,42 @@ export function DocumentRewriteTimeline({
)}
{item.error && (
<div className="flex items-center gap-2 border-t px-5 py-3 text-sm text-destructive">
<XCircleIcon className="size-4 shrink-0" />
{item.error}
<div className="border-t px-5 py-3 text-sm text-destructive">
<div className="flex items-start gap-2">
<XCircleIcon className="mt-0.5 size-4 shrink-0" />
<span>{item.error}</span>
</div>
{item.diagnostic && (
<Collapsible className="mt-3">
<div className="flex flex-wrap items-center gap-2">
<CollapsibleTrigger asChild>
<Button className="h-8 gap-1.5 px-2.5 text-xs" size="sm" type="button" variant="outline">
<ChevronDownIcon className="size-3.5" />
查看后台诊断
</Button>
</CollapsibleTrigger>
<Button
className="h-8 gap-1.5 px-2.5 text-xs"
onClick={() => {
void copyTextToClipboard(diagnosticText)
.then(() => toast.success("诊断信息已复制"))
.catch(() => toast.error("复制诊断信息失败"));
}}
size="sm"
type="button"
variant="outline"
>
<CopyIcon className="size-3.5" />
复制诊断
</Button>
</div>
<CollapsibleContent>
<pre className="bg-muted/55 text-foreground mt-2 max-h-72 overflow-auto rounded-lg border p-3 font-mono text-xs leading-5 whitespace-pre-wrap break-words">
{diagnosticText}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</div>
)}
<DocumentRewriteDiffDialog
@@ -598,3 +637,36 @@ export function DocumentRewriteTimeline({
</article>
);
}
/** A standalone chat row for a sandbox document-rewrite task. */
export function DocumentRewriteMessage({
item,
threadId,
onOpenArtifact,
onDownloadArtifact,
}: {
item: DocumentRewritePresentation;
threadId: string;
onOpenArtifact?: (filepath: string) => void;
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
}) {
const artifactPath = item.source.kind === "artifact" ? item.outputPath : undefined;
const completed = item.stage === "completed";
return (
<section aria-label={`AI 全文改写:${item.fileName}`} className="w-full space-y-3">
<DocumentRewriteTimeline item={item} />
{completed && artifactPath && (
<ArtifactFileList
fileDescriptions={{
[artifactPath]: "AI 改写版 Markdown 文件",
}}
files={[artifactPath]}
onDownloadArtifact={onDownloadArtifact}
onOpenArtifact={onOpenArtifact}
threadId={threadId}
/>
)}
</section>
);
}
@@ -41,12 +41,12 @@ import {
} from "@/components/ui/sheet";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
isAutoPreviewableArtifactPath,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
namedStreamingWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
import { listStreamingWriteFileCalls } from "@/core/artifacts/parse-partial-json";
import { useI18n } from "@/core/i18n/hooks";
import {
normalizeSearchResults,
@@ -57,9 +57,12 @@ import {
extractReasoningContentFromMessage,
findToolCallResult,
} from "@/core/messages/utils";
import { collectRetrievedFiles } from "@/core/messages/retrieved-files";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { isInternalToolCallName } from "@/core/tools/visibility";
import {
applyInlineCitations,
buildSkillDisplayMap,
@@ -83,6 +86,7 @@ import { SourceCard } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content";
import { SingleRoundRagCitationDrawer } from "./message-list-item";
import { RetrievedFileList } from "./retrieved-file-list";
export function MessageGroup({
className,
@@ -90,14 +94,17 @@ export function MessageGroup({
isLoading = false,
displaySources = [],
ragModeEnabled = false,
threadId,
}: {
className?: string;
messages: Message[];
isLoading?: boolean;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
threadId?: string;
}) {
const { t } = useI18n();
const { data: citationDisplay } = useCitationDisplaySettings();
const [showAbove, setShowAbove] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
@@ -112,7 +119,17 @@ export function MessageGroup({
() => convertToSteps(messages, writingModeEnabled),
[messages, writingModeEnabled],
);
const { skills } = useSkills();
const { skills } = useSkills({
enabled:
messages.length > 0 &&
(ragModeEnabled || citationDisplay?.retrieved_files_enabled === true),
});
const retrievedFiles = useMemo(
() => citationDisplay?.retrieved_files_enabled === true
? collectRetrievedFiles(messages, new Set(skills.map((skill) => skill.name)))
: [],
[messages, skills, citationDisplay?.retrieved_files_enabled],
);
const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
@@ -210,6 +227,7 @@ export function MessageGroup({
{...step}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled}
/>
),
@@ -222,6 +240,7 @@ export function MessageGroup({
isLast={true}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled}
/>
</FlipDisplay>
@@ -271,6 +290,7 @@ export function MessageGroup({
</>
)}
</ChainOfThought>
{threadId && <RetrievedFileList files={retrievedFiles} threadId={threadId} />}
<SingleRoundRagCitationDrawer
source={selectedCitation}
onOpenChange={(open) => {
@@ -336,6 +356,7 @@ function ToolCall({
isLast = false,
isLoading = false,
skillDisplayMap = new Map(),
displaySources = [],
ragModeEnabled = false,
}: {
id?: string;
@@ -346,6 +367,7 @@ function ToolCall({
isLast?: boolean;
isLoading?: boolean;
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
}) {
const { t } = useI18n();
@@ -356,19 +378,42 @@ function ToolCall({
null);
const displayItems =
displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))
? syncDisplaySourceIndexes(
rankDisplaySources(
normalizeDisplaySources(result, displayMatch.display).map(
(item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}),
),
),
displaySources,
)
: [];
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
const isLlmWikiTool =
name === "llmwiki_search" ||
name === "llmwiki_read_wiki_page" ||
name === "llmwiki_browse_wiki_index";
const wikiQuery = isLlmWikiTool
? resolveSearchQuery(args, result) ||
(name === "llmwiki_read_wiki_page"
? normalizeSearchResults(result)[0]?.title ||
(typeof args.wiki_slug === "string" ? args.wiki_slug : "")
: "")
: "";
const wikiSearchLabel = name === "llmwiki_search"
? wikiQuery
? `Wiki 文档检索 "${wikiQuery}"`
: "Wiki 文档检索"
: null;
: name === "llmwiki_read_wiki_page"
? wikiQuery
? `读取 Wiki 页面「${wikiQuery}」`
: "读取 Wiki 页面"
: name === "llmwiki_browse_wiki_index"
? "读取 Wiki 索引与知识库摘要"
: null;
if (displayItems.length > 0) {
return (
<ChainOfThoughtStep
@@ -394,12 +439,17 @@ function ToolCall({
const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
skillName: name,
mode: "citation" as const,
}));
const defaultItems = syncDisplaySourceIndexes(
rankDisplaySources(
normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
skillName: name,
mode: "citation" as const,
})),
),
displaySources,
);
return (
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
<DisplayResultsList results={defaultItems} />
@@ -781,17 +831,10 @@ function ArtifactWriteToolCall({
const rawPath = typeof args.path === "string" ? args.path : "";
const content = typeof args.content === "string" ? args.content : "";
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
const hasUsablePath =
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
const sourcePath = hasUsablePath
? normalizedPath
: name === "write_file" && content.trim()
? provisionalWriteFilePath(content)
: null;
const sourcePath = namedStreamingWriteFilePath(rawPath);
const showInlineContent = name === "write_file" && !sourcePath;
const previewKey = messageId && id ? `${messageId}:${id}` : null;
// Never select a bare provisional name like `未命名文件.md` — the artifacts
// API only serves `/mnt/user-data/...` paths and would 400. Wait until the
// tool call has ids so we can open a write-file: memory preview instead.
// Until a real path arrives, both auto-open and manual clicks stay inline.
const artifactPath =
sourcePath && name === "write_file" && messageId && id
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
@@ -832,7 +875,9 @@ function ArtifactWriteToolCall({
artifacts.setOpen(true);
}}
>
{(normalizedPath || sourcePath) && (
{showInlineContent ? (
content && <InlineWritePreview content={content} />
) : (normalizedPath || sourcePath) && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
</ChainOfThoughtSearchResult>
@@ -841,7 +886,29 @@ function ArtifactWriteToolCall({
);
}
interface GenericCoTStep<T extends string = string> {
function InlineWritePreview({ content }: { content: string }) {
const ref = useRef<HTMLPreElement>(null);
const follow = useRef(true);
useLayoutEffect(() => {
if (follow.current && ref.current) {
ref.current.scrollTop = ref.current.scrollHeight;
}
}, [content]);
return (
<pre
ref={ref}
onScroll={(event) => {
const node = event.currentTarget;
follow.current = node.scrollHeight - node.scrollTop - node.clientHeight < 24;
}}
className="border-border m-0 max-h-64 overflow-auto border-l pl-3 font-mono text-xs leading-6 whitespace-pre-wrap break-words text-emerald-600 dark:text-emerald-400"
>
<code>{content}</code>
</pre>
);
}
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
type: T;
@@ -861,6 +928,10 @@ type CoTStep = CoTReasoningStep | CoTToolCallStep;
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
const steps: CoTStep[] = [];
// Some streaming providers omit tool_call_id on ToolMessages. Keep a
// per-name cursor so parallel searches consume distinct unbound results in
// order instead of every step reusing the first page.
const toolCallNameFallbackIndexes = new Map<string, number>();
for (const message of messages) {
if (message.type === "ai") {
const reasoning = extractReasoningContentFromMessage(message);
@@ -873,14 +944,37 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
};
steps.push(step);
}
for (const tool_call of message.tool_calls ?? []) {
if (tool_call.name === "task") {
const writes = new Map(
listStreamingWriteFileCalls(message).map((write) => [write.id, write]),
);
const chunks = (message as Parameters<typeof listStreamingWriteFileCalls>[0])
.tool_call_chunks;
const toolCalls = [...(message.tool_calls ?? [])];
for (const write of writes.values()) {
if (
!toolCalls.some((call) => call.id === write.id) &&
chunks?.some(
(chunk) => chunk.id === write.id && chunk.name === "write_file",
)
) {
toolCalls.push({
id: write.id,
name: "write_file",
args: { path: write.path, content: write.content },
});
}
}
for (const tool_call of toolCalls) {
if (tool_call.name === "task" || isInternalToolCallName(tool_call.name)) {
continue;
}
const writePath =
typeof (tool_call.args as { path?: unknown } | undefined)?.path === "string"
? (tool_call.args as { path: string }).path
: "";
const write = tool_call.name === "write_file" && tool_call.id
? writes.get(tool_call.id)
: undefined;
const args = write
? { ...tool_call.args, path: write.path, content: write.content }
: tool_call.args;
const writePath = typeof args?.path === "string" ? args.path : "";
if (
(
tool_call.name === "write_file" ||
@@ -898,15 +992,27 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
messageId: message.id,
type: "toolCall",
name: tool_call.name,
args: tool_call.args,
args,
};
const toolCallId = tool_call.id;
if (toolCallId) {
const toolCallResult = findToolCallResult(
let toolCallResult = findToolCallResult(
toolCallId,
messages,
tool_call.name,
{ allowNameFallback: false },
);
if (toolCallResult === undefined) {
const nameKey = tool_call.name.toLowerCase();
const fallbackIndex = toolCallNameFallbackIndexes.get(nameKey) ?? 0;
toolCallResult = findToolCallResult(
toolCallId,
messages,
tool_call.name,
{ allowNameFallback: true, nameFallbackIndex: fallbackIndex },
);
toolCallNameFallbackIndexes.set(nameKey, fallbackIndex + 1);
}
if (toolCallResult !== undefined) {
if (typeof toolCallResult === "string") {
try {
@@ -973,6 +1079,48 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
);
}
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 citationId =
typeof source.raw?.citation_id === "string"
? source.raw.citation_id.trim()
: "";
return citationId ? [[citationId, source.index] as const] : [];
}),
);
return sources.map((source) => {
const citationId =
typeof source.raw?.citation_id === "string"
? source.raw.citation_id.trim()
: "";
const index = citationId ? indexByCitationId.get(citationId) : undefined;
return index ? { ...source, index } : source;
});
}
function SearchResultCard({ item }: { item: SearchResultItem }) {
const [open, setOpen] = useState(false);
const content = item.snippet ?? item.content;
File diff suppressed because it is too large. Load diff
@@ -74,6 +74,18 @@ export function SpeedLegend({
}
export function SpeedPill({ metric }: { metric: MessageMetric }) {
if (metric.isPaused) {
return (
<span
className="inline-flex items-center gap-1.5"
title="本段生成停顿,窗口内无新 token"
>
<GaugeIcon className="text-muted-foreground size-3" />
<span className="text-muted-foreground">暂停</span>
</span>
);
}
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
const { color, label } = classifySpeed(metric.tokensPerSec);
const display =
@@ -85,7 +97,7 @@ export function SpeedPill({ metric }: { metric: MessageMetric }) {
return (
<span
className="inline-flex items-center gap-1.5"
title={`${label} · ${metric.fromUsage ? "按 output_tokens 计算" : "字符估算 ≈ 2 字符 / token"}`}
title={`${label} · ${metric.fromUsage ? "按本段 output_tokens / 本段生成时长" : "字符估算 ≈ 2 字符 / token"}`}
>
<GaugeIcon className="size-3" style={{ color }} />
<span className="font-mono tabular-nums" style={{ color }}>
@@ -57,7 +57,7 @@ export function MultiRoundRagMessageList({
return (
<Conversation className={cn("flex size-full flex-col justify-center", className)}>
<ConversationContent
className="mx-auto w-full max-w-[var(--container-width-lg)] gap-8 pt-10"
className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-10"
>
{messages.map((message, index) => {
const isLastAssistant =
@@ -0,0 +1,207 @@
import { ChevronDownIcon, DownloadIcon, EyeIcon, Loader2Icon } from "lucide-react";
import { useContext, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { downloadArtifactFile } from "@/core/artifacts/download";
import { urlOfArtifact } from "@/core/artifacts/utils";
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import type { RetrievedFile } from "@/core/messages/retrieved-files";
import { getFileIcon } from "@/core/utils/files";
import {
getLlmWikiDocument,
getLlmWikiDocumentPreview,
getLlmWikiSourceContext,
resolveLlmWikiSourceKnowledgeBase,
} from "@/strategy-components/api/llmwiki";
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
import { ThreadContext } from "./context";
import { UploadedFilePreviewDialog } from "./uploaded-file-preview-dialog";
// File links belong to the upstream service. Never attach DeerFlow auth headers.
const fetchExternalFile: typeof fetch = (input, init) =>
fetch(input, { ...init, credentials: "omit" });
export function RetrievedFileList({ files, threadId }: { files: RetrievedFile[]; threadId: string }) {
const { isMock, publicShareCode } = useContext(ThreadContext) ?? {};
const [expanded, setExpanded] = useState(false);
const visible = files.filter((file) => !(publicShareCode && file.kind === "knowledge"));
// Results arrive in retrieval order; keep the newest file outside the fold.
const latest = visible.at(-1);
if (!latest) return null;
const older = visible.slice(0, -1).reverse();
return (
<Collapsible asChild open={expanded} onOpenChange={setExpanded}>
<section aria-label="检索到的文件" className="not-prose mt-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="text-muted-foreground text-xs">检索到的文件 · {visible.length} 个</div>
{older.length > 0 && (
<CollapsibleTrigger asChild>
<Button type="button" size="sm" variant="ghost" className="h-7 gap-1 px-2 text-xs">
{expanded ? "收起" : "展开"}
<ChevronDownIcon className={`size-3.5 transition-transform ${expanded ? "rotate-180" : ""}`} />
</Button>
</CollapsibleTrigger>
)}
</div>
<ul className="grid gap-2">
<RetrievedFileCard
key={latest.key}
file={latest}
threadId={threadId}
isMock={isMock}
publicShareCode={publicShareCode}
/>
</ul>
<CollapsibleContent>
<ul className="grid gap-2">
{older.map((file) => (
<RetrievedFileCard
key={file.key}
file={file}
threadId={threadId}
isMock={isMock}
publicShareCode={publicShareCode}
/>
))}
</ul>
</CollapsibleContent>
</section>
</Collapsible>
);
}
function RetrievedFileCard({ file, threadId, isMock, publicShareCode }: {
file: RetrievedFile;
threadId: string;
isMock?: boolean;
publicShareCode?: string;
}) {
const [previewOpen, setPreviewOpen] = useState(false);
const [preparingPreview, setPreparingPreview] = useState(false);
const [downloading, setDownloading] = useState(false);
const [knowledgeTarget, setKnowledgeTarget] = useState<WeKnoraSourceTarget | null>(null);
const fileUrl = file.kind === "artifact"
? urlOfArtifact({ filepath: file.path.split("/").map(encodeURIComponent).join("/"), threadId, isMock, publicShareCode })
: file.kind === "external" ? file.url : "";
const resolveKnowledgeTarget = async () => {
if (file.kind !== "knowledge") throw new Error("当前文件不是知识库文档");
if (knowledgeTarget) return knowledgeTarget;
const knowledgeBaseId = await resolveLlmWikiSourceKnowledgeBase(file.target.knowledgeBaseId);
const target = { ...file.target, knowledgeBaseId };
setKnowledgeTarget(target);
return target;
};
const preview = async () => {
if (preparingPreview) return;
setPreparingPreview(true);
try {
if (file.kind === "knowledge") await resolveKnowledgeTarget();
setPreviewOpen(true);
} catch (error) {
toast.error(error instanceof Error ? error.message : "文件预览失败");
} finally {
setPreparingPreview(false);
}
};
const download = async () => {
if (downloading || file.kind === "external") return;
setDownloading(true);
try {
if (file.kind === "artifact") {
await downloadArtifactFile({
filepath: file.path.split("/").map(encodeURIComponent).join("/"), threadId, isMock, publicShareCode,
});
} else {
const target = await resolveKnowledgeTarget();
let documentId = target.documentId;
if (!documentId && target.chunkId) {
const context = await getLlmWikiSourceContext(target.knowledgeBaseId, target.chunkId);
documentId = context.document_id || undefined;
}
if (!documentId) throw new Error("当前检索结果没有可下载的文件原件,请先查看预览");
const [blob, document] = await Promise.all([
getLlmWikiDocumentPreview(target.knowledgeBaseId, documentId),
getLlmWikiDocument(target.knowledgeBaseId, documentId).catch(() => null),
]);
const objectUrl = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = objectUrl;
anchor.download = document?.file_name || file.name;
anchor.rel = "noopener";
window.document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "文件下载失败");
} finally {
setDownloading(false);
}
};
return (
<li className="bg-card flex flex-wrap items-center gap-3 rounded-lg border p-3">
{getFileIcon(file.name, "text-muted-foreground size-5 shrink-0")}
<div className="min-w-0 flex-1 basis-40">
<div className="text-sm font-medium break-words">{file.name}</div>
{file.source && <div className="text-muted-foreground mt-0.5 truncate text-xs">{file.source}</div>}
</div>
<div className="flex shrink-0 items-center gap-1">
{file.kind === "external" && file.previewUrl ? (
<Button asChild size="sm" variant="ghost">
<a href={file.previewUrl} target="_blank" rel="noopener noreferrer">
<EyeIcon className="size-4" />预览
</a>
</Button>
) : (
<Button type="button" size="sm" variant="ghost" disabled={preparingPreview} onClick={() => void preview()}>
{preparingPreview ? <Loader2Icon className="size-4 animate-spin" /> : <EyeIcon className="size-4" />}预览
</Button>
)}
{file.kind === "external" ? (
<Button asChild size="sm" variant="ghost">
<a href={file.url} download={file.name} target="_blank" rel="noopener noreferrer">
<DownloadIcon className="size-4" />下载
</a>
</Button>
) : (
<Button type="button" size="sm" variant="ghost" disabled={downloading} onClick={() => void download()}>
{downloading ? <Loader2Icon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}下载
</Button>
)}
</div>
{previewOpen && (file.kind === "knowledge" ? (
<WeKnoraSourceDrawer
open
target={knowledgeTarget || file.target}
title={file.name}
quote={file.quote || ""}
onOpenChange={setPreviewOpen}
/>
) : (
<UploadedFilePreviewDialog
filename={file.name}
fileUrl={fileUrl}
open
onOpenChange={setPreviewOpen}
threadId={file.kind === "artifact" && !publicShareCode ? threadId : undefined}
virtualPath={file.kind === "artifact" ? file.path : undefined}
requestFile={file.kind === "external" ? fetchExternalFile : undefined}
onDownload={file.kind === "artifact" ? download : undefined}
downloading={downloading}
/>
))}
</li>
);
}
@@ -1,79 +1,79 @@
import { Skeleton } from "@/components/ui/skeleton";
const STAGGER_MS = 60;
function SkeletonBar({
className,
style,
originRight,
}: {
className?: string;
style?: React.CSSProperties;
originRight?: boolean;
}) {
return (
<div
className={`animate-skeleton-entrance fill-mode-[forwards] overflow-hidden rounded-md ${originRight ? "origin-[right]" : "origin-[left]"} ${className ?? ""}`}
style={{ opacity: 0, ...style }}
>
<Skeleton className="h-full w-full rounded-md" />
</div>
);
}
export function MessageListSkeleton() {
let index = 0;
return (
<div className="flex w-full max-w-[var(--container-width-lg)] flex-col gap-12 p-8 pt-16">
<div
role="human-message"
className="flex w-[50%] flex-col items-end gap-2 self-end"
>
<SkeletonBar
className="h-6 w-full"
originRight
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-[80%]"
originRight
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
</div>
<div role="assistant-message" className="flex flex-col gap-2">
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-[70%]"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-[60%]"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-[40%]"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
</div>
</div>
);
}
import { Skeleton } from "@/components/ui/skeleton";
const STAGGER_MS = 60;
function SkeletonBar({
className,
style,
originRight,
}: {
className?: string;
style?: React.CSSProperties;
originRight?: boolean;
}) {
return (
<div
className={`animate-skeleton-entrance fill-mode-[forwards] overflow-hidden rounded-md ${originRight ? "origin-[right]" : "origin-[left]"} ${className ?? ""}`}
style={{ opacity: 0, ...style }}
>
<Skeleton className="h-full w-full rounded-md" />
</div>
);
}
export function MessageListSkeleton() {
let index = 0;
return (
<div className="flex w-full max-w-(--container-width-lg) flex-col gap-12 p-8 pt-16">
<div
role="human-message"
className="flex w-[50%] flex-col items-end gap-2 self-end"
>
<SkeletonBar
className="h-6 w-full"
originRight
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-[80%]"
originRight
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
</div>
<div role="assistant-message" className="flex flex-col gap-2">
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-[70%]"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-[60%]"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-[40%]"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
</div>
</div>
);
}
@@ -0,0 +1,406 @@
import assert from "node:assert/strict";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import type { Message } from "@langchain/langgraph-sdk";
import type { KnowledgeRetrievalProgressFrame } from "@/core/threads/smart-knowledge-retrieval";
import { SmartKnowledgeRetrievalTrace } from "./smart-knowledge-retrieval-trace";
const completedFrames: KnowledgeRetrievalProgressFrame[] = [
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
status: "started",
queries: ["成都世运会"],
},
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
status: "completed",
result_count: 3,
elapsed_ms: 420,
},
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_search",
status: "started",
pattern: "火炬|开幕|闭幕",
},
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_search",
status: "completed",
result_count: 4,
elapsed_ms: 1380,
},
];
test("completed and replayed retrieval traces start collapsed with their hit summary visible", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={completedFrames}
isLoading={false}
/>,
);
assert.match(html, /aria-label="展开智能检索过程"/);
assert.match(html, /aria-expanded="false"/);
assert.match(html, /调用知识库 2 次 · 原始文档命中 3 条 · Wiki 命中 4 条/);
assert.match(html, /累计耗时 1\.8 秒/);
assert.doesNotMatch(html, /共\s*\d+\s*轮|第\s*\d+\s*轮检索/);
});
test("keeps a current pending tool call in loading state before its started frame arrives", () => {
const messages = [
{
type: "ai",
id: "ai-pending-wiki",
content: "",
tool_calls: [
{
id: "wiki-pending",
name: "ordinary_wiki_search",
args: { pattern: "数字中国|数据要素" },
},
],
},
] as unknown as Message[];
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={messages}
progressFrames={[]}
isLoading
/>,
);
assert.match(html, /检索中/);
assert.match(html, /正在查 Wiki/);
assert.doesNotMatch(html, /部分步骤失败|部分步骤未完成|已中止/);
});
test("labels and normalizes a visible model-authored regex", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[
{
type: "ai",
id: "ai-regex",
content: "",
tool_calls: [
{
id: "wiki-regex",
name: "ordinary_wiki_search",
args: { pattern: "^# [一-龥]{2,10}篇�" },
},
],
},
] as unknown as Message[]}
progressFrames={[]}
isLoading
/>,
);
assert.match(html, /正则:\^# \\p\{Han\}\{2,10\}篇/);
assert.doesNotMatch(html, /一-龥|�/);
});
test("presents an interrupted historical action as neutral instead of failed", () => {
const messages = [
{
type: "ai",
id: "ai-interrupted-wiki",
content: "",
tool_calls: [
{
id: "wiki-interrupted",
name: "ordinary_wiki_search",
args: { pattern: "数字中国" },
},
],
},
] as unknown as Message[];
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={messages}
progressFrames={[]}
isLoading={false}
/>,
);
assert.match(html, /检索已结束(部分步骤未完成)/);
assert.doesNotMatch(html, /部分步骤失败|部分步骤异常/);
});
test("an in-flight retrieval stays expanded and does not expose a collapse control", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[completedFrames[0]!]}
isLoading
/>,
);
assert.match(html, /data-state="open"/);
assert.match(html, /检索中/);
assert.match(html, /成都世运会/);
assert.doesNotMatch(html, /收起智能检索过程|展开智能检索过程/);
});
test("completed retrieval can be collapsed while the final answer is still streaming", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={completedFrames}
isLoading
/>,
);
assert.match(html, /data-state="open"/);
assert.match(html, /检索完成/);
assert.match(html, /aria-label="收起智能检索过程"/);
assert.match(html, /检索 Wiki/);
assert.doesNotMatch(html, /第\s*\d+\s*轮检索/);
assert.doesNotMatch(html, /检索中/);
});
test("does not expose internal round numbering when retrieval cards are interleaved with skills", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[completedFrames[0]!]}
isLoading
roundOffset={2}
/>,
);
assert.match(html, /检索原始文档/);
assert.doesNotMatch(html, /第\s*\d+\s*轮检索/);
});
test("renders stable retrieval results as right-drawer controls", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_search",
status: "completed",
result_count: 1,
result_items: [
{
kind: "wiki",
knowledge_base_id: "kb-news",
slug: "news/opening",
title: "开幕新闻 Wiki",
preview: "页面摘要",
},
],
},
]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.match(html, /data-retrieval-result-detail="true"/);
assert.match(html, /在右侧查看:开幕新闻 Wiki/);
assert.doesNotMatch(html, /data-retrieval-result-summary="true"/);
});
test("renders a Wiki read as a preview card with elapsed time and right-drawer entry", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "wiki_read_page",
status: "completed",
result_count: 1,
knowledge_base_count: 1,
coverage: [
{ knowledge_base: "数字中国政策目录智能检索远端验收" },
],
elapsed_ms: 1260,
result_items: [
{
kind: "wiki",
knowledge_base_id: "kb-digital-china",
knowledge_base: "数字中国政策目录智能检索远端验收",
slug: "digital-china/timeline",
page_ref: "wiki-page:v1:digital-china-timeline",
title: "数字中国建设时间线",
content_markdown:
"页面正文概览,包含建设目标、关键年份、量化指标和制度保障。",
},
],
},
]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.match(html, /读取结果/);
assert.match(html, /数字中国建设时间线/);
assert.match(html, /页面正文概览,包含建设目标/);
assert.match(html, /右侧查看/);
assert.match(html, /耗时 1\.3 秒/);
assert.match(html, /覆盖 1 个知识库:数字中国政策目录智能检索远端验收/);
assert.doesNotMatch(html, /知识库知识库/);
assert.match(html, /data-retrieval-result-detail="true"/);
});
test("shows all usable document-read results without a generic partial warning", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "document_chunk_read",
status: "completed",
result_count: 20,
partial: true,
warning_codes: ["PROVIDER_CONTEXT_PARTIAL"],
message: "原文上下文读取完成,找到 20 条结果",
result_items: Array.from({ length: 20 }, (_, index) => ({
kind: "raw",
knowledge_base_id: "kb-news",
knowledge_base: "国内新闻",
knowledge_id: "document-long-news",
chunk_id: `chunk-${index + 1}`,
title: `长篇新闻第 ${index + 1} 段`,
content: `原文上下文第 ${index + 1} 段`,
})),
},
]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.equal(
(html.match(/data-retrieval-result-detail="true"/g) ?? []).length,
20,
);
assert.match(html, /长篇新闻第 1 段/);
assert.match(html, /长篇新闻第 20 段/);
assert.doesNotMatch(html, /部分数据源返回警告/);
assert.doesNotMatch(html, /部分完成/);
});
test("keeps historical results without stable ids as non-clickable summaries", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[
{
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
status: "completed",
result_count: 1,
result_items: [
{
kind: "raw",
knowledge_base: "旧知识库",
title: "旧检索结果",
},
],
},
]}
isLoading
/>,
);
assert.match(html, /data-retrieval-result-summary="true"/);
assert.match(html, /该历史检索结果缺少稳定详情标识/);
assert.doesNotMatch(html, /data-retrieval-result-detail="true"/);
});
test("uses the whole turn as the dedupe scope for interleaved retrieval cards", () => {
const firstFrame: KnowledgeRetrievalProgressFrame = {
type: "knowledge_retrieval_progress",
version: 1,
mode: "smart_reasoning",
operation: "knowledge_search",
retrieval_id: "raw-first",
status: "completed",
result_count: 1,
result_items: [
{
kind: "raw",
knowledge_base_id: "kb-news",
knowledge_id: "document-trump",
chunk_id: "chunk-1",
title: "特朗普生平.txt",
content: "唐纳德·特朗普于1946年出生,曾任美国总统并从事商业活动。",
},
],
};
const repeatedFrame: KnowledgeRetrievalProgressFrame = {
...firstFrame,
retrieval_id: "raw-second",
result_items: [
{
kind: "raw",
knowledge_base_id: "kb-news",
knowledge_id: "document-trump",
chunk_id: "chunk-provider-remapped",
title: "特朗普生平.txt",
content: "唐纳德·特朗普于1946年出生,曾任美国总统并从事商业活动。",
},
],
};
const firstHtml = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[firstFrame]}
dedupeScopeMessages={[]}
dedupeScopeProgressFrames={[firstFrame, repeatedFrame]}
isLoading
onOpenResult={() => undefined}
/>,
);
const repeatedHtml = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
progressFrames={[repeatedFrame]}
dedupeScopeMessages={[]}
dedupeScopeProgressFrames={[firstFrame, repeatedFrame]}
isLoading
onOpenResult={() => undefined}
/>,
);
assert.equal(
(firstHtml.match(/data-retrieval-result-detail="true"/g) ?? []).length,
1,
);
assert.equal(
(repeatedHtml.match(/data-retrieval-result-detail="true"/g) ?? []).length,
0,
);
assert.match(repeatedHtml, /本次查询结果均已在前面展示/);
assert.doesNotMatch(repeatedHtml, /命中 1 条/);
});
@@ -0,0 +1,525 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
AlertCircleIcon,
BookOpenTextIcon,
CheckCircle2Icon,
ChevronDownIcon,
CirclePauseIcon,
DatabaseIcon,
Loader2Icon,
PanelRightOpenIcon,
SearchCheckIcon,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
buildSmartKnowledgeRetrievalRounds,
type KnowledgeRetrievalProgressFrame,
type SmartKnowledgeRetrievalAction,
type SmartKnowledgeRetrievalResultItem,
} from "@/core/threads/smart-knowledge-retrieval";
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
import { cn } from "@/lib/utils";
function actionStatusText(action: SmartKnowledgeRetrievalAction): string {
if (action.status === "failed") return "检索失败";
if (action.status === "interrupted") return "已中止";
if (action.status === "running") {
return action.surface === "wiki" ? "正在查 Wiki" : "正在查知识库";
}
if (action.allResultsAlreadyShown) return "均为已展示结果";
const hasUsableResults =
action.resultItems.length > 0 || (action.resultCount ?? 0) > 0;
const partialPrefix = action.partial && !hasUsableResults ? "部分完成 · " : "";
if (action.kind === "search") {
return `${partialPrefix}命中 ${action.resultCount ?? 0} 条`;
}
return typeof action.resultCount === "number"
? `${partialPrefix}读取完成 · ${action.resultCount} 条`
: `${partialPrefix}读取完成`;
}
function formatElapsedTime(elapsedMs: number): string {
const normalized = Math.max(0, elapsedMs);
if (normalized < 1_000) return `${Math.round(normalized)} 毫秒`;
if (normalized < 60_000) {
const seconds = (normalized / 1_000).toFixed(1).replace(/\.0$/, "");
return `${seconds} 秒`;
}
const roundedSeconds = Math.round(normalized / 1_000);
const minutes = Math.floor(roundedSeconds / 60);
const seconds = roundedSeconds % 60;
return seconds > 0 ? `${minutes} 分 ${seconds} 秒` : `${minutes} 分钟`;
}
function channelText(channel: string | undefined): string | null {
if (!channel) return null;
if (channel === "local_wiki_mirror") return "本地 Wiki 镜像";
if (channel === "provider") return "知识库 Wiki";
if (channel === "mixed") return "本地镜像 + 知识库 Wiki";
return channel;
}
function ActionStatusIcon({ action }: { action: SmartKnowledgeRetrievalAction }) {
if (action.status === "running") {
return <Loader2Icon className="size-4 animate-spin text-primary" />;
}
if (action.status === "completed") {
return <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />;
}
if (action.status === "failed") {
return <AlertCircleIcon className="size-4 text-amber-600 dark:text-amber-400" />;
}
return <CirclePauseIcon className="size-4 text-muted-foreground" />;
}
function resultDetailTarget(
item: SmartKnowledgeRetrievalResultItem,
): WeKnoraSourceTarget | null {
if (!item.knowledgeBaseId) return null;
if (item.kind === "wiki" && item.wikiSlug) {
return {
knowledgeBaseId: item.knowledgeBaseId,
wikiSlug: item.wikiSlug,
tab: "wiki",
};
}
if (item.kind === "raw" && (item.chunkId || item.documentId)) {
return {
knowledgeBaseId: item.knowledgeBaseId,
chunkId: item.chunkId,
documentId: item.documentId,
tab: "documents",
};
}
return null;
}
function RetrievalAction({
action,
onOpenResult,
}: {
action: SmartKnowledgeRetrievalAction;
onOpenResult?: (
item: SmartKnowledgeRetrievalResultItem,
target: WeKnoraSourceTarget,
) => void;
}) {
const SurfaceIcon = action.surface === "wiki" ? BookOpenTextIcon : DatabaseIcon;
const channel = channelText(action.channel);
return (
<div
className="rounded-lg border border-border/70 bg-background/75 px-3 py-2.5"
data-retrieval-operation={action.operation}
>
<div className="flex min-w-0 items-start gap-2.5">
<SurfaceIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
<span className="text-sm font-medium text-foreground">{action.label}</span>
<span
className={cn(
"inline-flex items-center gap-1 text-xs",
action.status === "running"
? "text-primary"
: action.status === "completed"
? "text-emerald-700 dark:text-emerald-300"
: action.status === "failed"
? "text-amber-700 dark:text-amber-300"
: "text-muted-foreground",
)}
>
<ActionStatusIcon action={action} />
{actionStatusText(action)}
</span>
</div>
{action.queries.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-muted-foreground">查询</span>
{action.queries.map((query) => (
<span
key={query}
className="max-w-full rounded-md bg-muted px-2 py-0.5 text-foreground/85 break-all"
>
{query}
</span>
))}
</div>
)}
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{typeof action.knowledgeBaseCount === "number" ? (
<span>
覆盖 {action.knowledgeBaseCount} 个知识库
{action.knowledgeBases.length > 0
? `:${action.knowledgeBases.join("、")}`
: ""}
</span>
) : action.knowledgeBases.length > 0 ? (
<span>知识库:{action.knowledgeBases.join("、")}</span>
) : null}
{typeof action.scannedPages === "number" && (
<span>扫描 {action.scannedPages} 个 Wiki 页面</span>
)}
{typeof action.scannedChunks === "number" && (
<span>扫描 {action.scannedChunks} 个原文片段</span>
)}
{action.kind === "read" && typeof action.total === "number" && (
<span>共 {action.total} 个片段</span>
)}
{channel && <span>通道:{channel}</span>}
{typeof action.elapsedMs === "number" && (
<span>耗时 {formatElapsedTime(action.elapsedMs)}</span>
)}
</div>
{action.allResultsAlreadyShown ? (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
本次查询结果均已在前面展示
</p>
) : action.resultItems.length > 0 ? (
<div className="mt-2">
<div className="mb-1.5 text-xs text-muted-foreground">
{action.kind === "read" ? "读取结果" : "查询结果"}
</div>
{action.kind === "read" ? (
<div className="space-y-2">
{action.resultItems.map((item) => {
const target = onOpenResult
? resultDetailTarget(item)
: null;
const preview = item.preview?.trim().slice(0, 600);
const content = (
<>
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate text-sm font-medium text-foreground">
{item.title}
</span>
{target && (
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-primary">
右侧查看
<PanelRightOpenIcon className="size-3.5" />
</span>
)}
</div>
{preview && (
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-xs leading-5 text-muted-foreground">
{preview}
</p>
)}
</>
);
return target ? (
<button
key={item.id}
type="button"
data-retrieval-result-detail
className="block w-full rounded-lg border border-primary/20 bg-primary/[0.045] px-3 py-2.5 text-left transition-colors hover:border-primary/40 hover:bg-primary/[0.075] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
title={`在右侧查看:${item.title}`}
onClick={() => onOpenResult?.(item, target)}
>
{content}
</button>
) : (
<div
key={item.id}
aria-disabled="true"
data-retrieval-result-summary
className="rounded-lg border border-border/70 bg-muted/45 px-3 py-2.5"
title="该历史检索结果缺少稳定详情标识"
>
{content}
</div>
);
})}
</div>
) : (
<div className="flex flex-wrap gap-1.5">
{action.resultItems.map((item) => {
const target = onOpenResult
? resultDetailTarget(item)
: null;
return target ? (
<button
key={item.id}
type="button"
data-retrieval-result-detail
className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-primary/20 bg-primary/[0.055] px-2 py-1 text-left text-xs leading-5 text-foreground transition-colors hover:border-primary/40 hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
title={`在右侧查看:${item.title}`}
onClick={() => onOpenResult?.(item, target)}
>
<span className="truncate">{item.title}</span>
<PanelRightOpenIcon className="size-3.5 shrink-0 text-primary" />
</button>
) : (
<span
key={item.id}
aria-disabled="true"
data-retrieval-result-summary
className="inline-flex max-w-full rounded-md bg-muted px-2 py-1 text-xs leading-5 text-muted-foreground"
title="该历史检索结果缺少稳定详情标识"
>
<span className="truncate">{item.title}</span>
</span>
);
})}
{(action.resultCount ?? 0) > action.resultItems.length && (
<span className="inline-flex items-center px-1 text-xs text-muted-foreground">
另有 {(action.resultCount ?? 0) - action.resultItems.length} 条结果
</span>
)}
</div>
)}
</div>
) : action.resultTitles.length > 0 ? (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
结果摘要:{action.resultTitles.join(";")}
{(action.resultCount ?? 0) > action.resultTitles.length ? ";…" : ""}
</p>
) : null}
{action.message && (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
{action.message}
</p>
)}
{action.error && (
<p className="mt-1.5 text-xs leading-5 text-amber-700 dark:text-amber-300">
{action.error}
</p>
)}
{/* Provider warning codes stay in the structured trace for
diagnostics. The old generic warning was misleading both when
useful evidence existed and when the result set was empty. */}
</div>
</div>
</div>
);
}
export function SmartKnowledgeRetrievalTrace({
messages,
progressFrames = [],
dedupeScopeMessages,
dedupeScopeProgressFrames,
isLoading,
onOpenResult,
}: {
messages: Message[];
progressFrames?: KnowledgeRetrievalProgressFrame[];
/**
* The whole current user-turn group. Interleaved rendering splits one
* retrieval invocation into one component, but result dedupe must still see
* every invocation in the turn.
*/
dedupeScopeMessages?: Message[];
dedupeScopeProgressFrames?: KnowledgeRetrievalProgressFrame[];
isLoading: boolean;
/** @deprecated 仅为兼容历史分段调用,展示层不再暴露检索轮次。 */
roundOffset?: number;
onOpenResult?: (
item: SmartKnowledgeRetrievalResultItem,
target: WeKnoraSourceTarget,
) => void;
}) {
const rounds = useMemo(
() => {
const localRounds = buildSmartKnowledgeRetrievalRounds(
messages,
progressFrames,
isLoading,
);
if (!dedupeScopeMessages) return localRounds;
const globallyDedupedActions = new Map(
buildSmartKnowledgeRetrievalRounds(
dedupeScopeMessages,
dedupeScopeProgressFrames ?? [],
isLoading,
)
.flatMap((round) => round.actions)
.map((action) => [action.id, action] as const),
);
return localRounds.map((round) => ({
...round,
actions: round.actions.map((action) => {
const globalAction = globallyDedupedActions.get(action.id);
return globalAction ? { ...globalAction, round: action.round } : action;
}),
}));
},
[
dedupeScopeMessages,
dedupeScopeProgressFrames,
isLoading,
messages,
progressFrames,
],
);
const actions = rounds.flatMap((round) => round.actions);
// The chat can keep streaming its final answer after retrieval has already
// finished. Only an unfinished retrieval action owns the "检索中" state;
// the broader thread loading flag must not keep the spinner alive or block
// the collapse control during answer generation.
const isActive = actions.some((action) => action.status === "running");
const hasFailure = actions.some((action) => action.status === "failed");
const hasInterrupted = actions.some(
(action) => action.status === "interrupted",
);
const rawHits = actions
.filter((action) => action.surface === "raw" && action.kind === "search")
.reduce(
(total, action) =>
total + (action.allResultsAlreadyShown ? 0 : (action.resultCount ?? 0)),
0,
);
const wikiHits = actions
.filter((action) => action.surface === "wiki" && action.kind === "search")
.reduce(
(total, action) =>
total + (action.allResultsAlreadyShown ? 0 : (action.resultCount ?? 0)),
0,
);
const elapsedActions = actions.filter(
(action) => typeof action.elapsedMs === "number",
);
const cumulativeElapsedMs =
elapsedActions.length > 0
? elapsedActions.reduce(
(total, action) => total + (action.elapsedMs ?? 0),
0,
)
: undefined;
// A live trace opens so newly completed result cards remain visible while
// the final answer starts streaming. Replayed history starts collapsed.
const [isExpanded, setIsExpanded] = useState(
isLoading && actions.length > 0,
);
const wasActive = useRef(isActive);
const wasLoading = useRef(isLoading);
useEffect(() => {
if (!wasActive.current && isActive) {
setIsExpanded(true);
}
if (wasLoading.current && !isLoading) {
setIsExpanded(false);
}
wasActive.current = isActive;
wasLoading.current = isLoading;
}, [isActive, isLoading]);
// An in-flight retrieval always stays visible. A just-completed live trace
// remains open and user-collapsible while the answer streams; replayed
// history starts collapsed and can be reopened for inspection.
const isOpen = isActive || isExpanded;
if (actions.length === 0) return null;
return (
<Collapsible
open={isOpen}
onOpenChange={(open) => {
if (!isActive) setIsExpanded(open);
}}
>
<section
aria-label="智能检索过程"
aria-live={isActive ? "polite" : undefined}
className="w-full rounded-xl border border-primary/20 bg-primary/[0.035] p-3.5 shadow-sm"
data-smart-knowledge-retrieval-trace
>
<div className="flex items-start gap-2.5">
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
{isActive ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SearchCheckIcon className="size-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">智能检索过程</h3>
<div className="flex items-center gap-1.5">
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
isActive
? "bg-primary/10 text-primary"
: hasFailure
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
: hasInterrupted
? "bg-muted text-muted-foreground"
: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
)}
>
{isActive
? "检索中"
: hasFailure
? "已完成(部分步骤失败)"
: hasInterrupted
? "检索已结束(部分步骤未完成)"
: "检索完成"}
</span>
{!isActive && (
<CollapsibleTrigger asChild>
<button
type="button"
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-primary/10 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
aria-label={isOpen ? "收起智能检索过程" : "展开智能检索过程"}
>
<span>{isOpen ? "收起" : "展开"}</span>
<ChevronDownIcon
className={cn(
"size-3.5 transition-transform duration-200",
isOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
)}
</div>
</div>
{!isActive && (
<p className="mt-1 text-xs text-muted-foreground">
调用知识库 {actions.length} 次 · 原始文档命中 {rawHits} 条 · Wiki 命中 {wikiHits} 条
{typeof cumulativeElapsedMs === "number"
? ` · 累计耗时 ${formatElapsedTime(cumulativeElapsedMs)}`
: ""}
</p>
)}
{isActive && (
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
展示实际查询、检索范围与命中摘要,不包含模型内部思考。
</p>
)}
</div>
</div>
<CollapsibleContent className="data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1">
{!isActive && (
<p className="mt-1 text-xs text-muted-foreground">
展示实际查询、检索范围与命中摘要,不包含模型内部思考。
</p>
)}
<div className="mt-3 space-y-3">
{actions.map((action) => (
<RetrievalAction
key={action.id}
action={action}
onOpenResult={onOpenResult}
/>
))}
</div>
</CollapsibleContent>
</section>
</Collapsible>
);
}
@@ -0,0 +1,119 @@
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 {
GENERATION_IDLE_MS,
ROLLING_WINDOW_MS,
charsToTokensPerSec,
messageHasToolCalls,
rollingTokensPerSec,
shouldFreezeGeneration,
usageTokensPerSec,
} from "./speed-metrics.ts";
test("rolling window reports the recent rate while characters grow", () => {
const samples = [
{ at: 0, chars: 0 },
{ at: 500, chars: 100 },
{ at: 1000, chars: 200 },
];
const rate = rollingTokensPerSec(samples, 200, 1000, 1000);
// 200 chars / 1000ms / 2 chars-per-token = 100 tok/s
assert.equal(rate, 100);
});
test("rolling window drops to 0 when every sample is older than the window", () => {
const samples = [
{ at: 0, chars: 0 },
{ at: 200, chars: 80 },
{ at: 400, chars: 160 },
];
const now = 400 + ROLLING_WINDOW_MS + 50;
assert.equal(rollingTokensPerSec(samples, 160, now), 0);
});
test("rolling window decays toward 0 after a mid-stream pause", () => {
const samples = [
{ at: 0, chars: 0 },
{ at: 400, chars: 80 },
];
const live = rollingTokensPerSec(samples, 80, 500, 1000);
const paused = rollingTokensPerSec(samples, 80, 900, 1000);
assert.ok(live > paused);
assert.ok(paused > 0);
assert.equal(rollingTokensPerSec(samples, 80, 400 + 1000 + 1, 1000), 0);
});
test("usageTokensPerSec is output tokens over the segment duration", () => {
assert.equal(usageTokensPerSec(278, 6200), (278 / 6200) * 1000);
assert.equal(usageTokensPerSec(0, 1000), 0);
assert.equal(usageTokensPerSec(10, 0), 0);
});
test("charsToTokensPerSec uses the 2-chars-per-token heuristic", () => {
assert.equal(charsToTokensPerSec(100, 1000), 50);
});
test("shouldFreezeGeneration ends the segment on the next call, tools, idle usage, or run stop", () => {
const base = {
endedAt: null,
isLoading: true,
isLatestGenerating: true,
hasToolCalls: false,
grewThisTick: false,
lastGrewAt: 1000,
now: 1100,
outputTokens: 0,
};
assert.equal(shouldFreezeGeneration(base), false);
assert.equal(
shouldFreezeGeneration({ ...base, isLatestGenerating: false }),
true,
);
assert.equal(shouldFreezeGeneration({ ...base, hasToolCalls: true }), true);
assert.equal(
shouldFreezeGeneration({
...base,
hasToolCalls: true,
grewThisTick: true,
}),
false,
);
assert.equal(
shouldFreezeGeneration({
...base,
outputTokens: 40,
now: 1000 + GENERATION_IDLE_MS,
}),
true,
);
assert.equal(
shouldFreezeGeneration({
...base,
outputTokens: 40,
now: 1000 + GENERATION_IDLE_MS - 1,
}),
false,
);
assert.equal(shouldFreezeGeneration({ ...base, isLoading: false }), true);
assert.equal(shouldFreezeGeneration({ ...base, endedAt: 1000 }), false);
});
test("messageHasToolCalls reads both top-level and additional_kwargs", () => {
assert.equal(messageHasToolCalls({}), false);
assert.equal(
messageHasToolCalls({
tool_calls: [{ id: "1", name: "search" }],
}),
true,
);
assert.equal(
messageHasToolCalls({
additional_kwargs: { tool_calls: [{ id: "1" }] },
}),
true,
);
});
@@ -0,0 +1,108 @@
/**
* Shared tok/s helpers for the chat / roundtable speed pills.
*
* Live rate uses a rolling window with a virtual tail at "now" so a pause
* (no new characters) decays toward 0 instead of freezing on the last two
* samples. Final rate prefers provider `output_tokens` over the segment
* duration, and only after that model-call segment has ended.
*/
export const CHARS_PER_TOKEN = 2;
export const SAMPLE_THROTTLE_MS = 60;
export const ROLLING_WINDOW_MS = 1000;
export const MAX_SPEED_SAMPLES = 256;
/** Idle after last visible growth before we treat this model call as finished. */
export const GENERATION_IDLE_MS = 400;
export type SpeedSample = { at: number; chars: number };
export function estimateTokensFromChars(chars: number): number {
return Math.max(0, chars) / CHARS_PER_TOKEN;
}
export function charsToTokensPerSec(chars: number, elapsedMs: number): number {
if (elapsedMs <= 0) return 0;
return (estimateTokensFromChars(chars) / elapsedMs) * 1000;
}
export function usageTokensPerSec(
outputTokens: number,
durationMs: number,
): number {
if (outputTokens <= 0 || durationMs <= 0) return 0;
return (outputTokens / durationMs) * 1000;
}
/**
* Instantaneous tok/s over the last `windowMs`.
*
* The tail is always `(now, lastChars)`. If every sample is older than the
* window, the result is 0 — a generation pause must not keep the last
* observed rate pinned on screen.
*/
export function rollingTokensPerSec(
samples: SpeedSample[],
lastChars: number,
now: number,
windowMs: number = ROLLING_WINDOW_MS,
): number {
if (samples.length === 0 || windowMs <= 0) return 0;
const windowStart = now - windowMs;
let headIdx = samples.findIndex((sample) => sample.at >= windowStart);
if (headIdx === -1) {
return 0;
}
if (headIdx > 0) {
headIdx -= 1;
}
const head = samples[headIdx];
if (!head) return 0;
const elapsedMs = now - head.at;
if (elapsedMs <= 0) return 0;
return charsToTokensPerSec(Math.max(0, lastChars - head.chars), elapsedMs);
}
export function pushSpeedSample(
samples: SpeedSample[],
at: number,
chars: number,
): void {
samples.push({ at, chars });
if (samples.length > MAX_SPEED_SAMPLES) {
samples.splice(0, samples.length - MAX_SPEED_SAMPLES);
}
}
export function shouldFreezeGeneration(args: {
endedAt: number | null;
isLoading: boolean;
isLatestGenerating: boolean;
hasToolCalls: boolean;
grewThisTick: boolean;
lastGrewAt: number;
now: number;
outputTokens: number;
}): boolean {
if (args.endedAt != null) return false;
if (!args.isLatestGenerating) return true;
if (args.hasToolCalls && !args.grewThisTick) return true;
if (
args.outputTokens > 0 &&
args.now - args.lastGrewAt >= GENERATION_IDLE_MS
) {
return true;
}
return !args.isLoading;
}
export function messageHasToolCalls(message: {
tool_calls?: unknown[] | null;
additional_kwargs?: { tool_calls?: unknown[] } | null;
}): boolean {
const direct = message.tool_calls;
if (Array.isArray(direct) && direct.length > 0) return true;
const extra = message.additional_kwargs?.tool_calls;
return Array.isArray(extra) && extra.length > 0;
}
@@ -158,7 +158,11 @@ function formatBytes(bytes: number): string {
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
* hence the 401s) and expose it as an object URL for inline display.
*/
export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
export function useAuthedObjectUrl(
url: string | null,
enabled: boolean,
requestFile: typeof apiFetch = apiFetch,
) {
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -170,9 +174,10 @@ export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
}
let cancelled = false;
let created: string | null = null;
const controller = new AbortController();
setLoading(true);
setError(null);
apiFetch(url)
requestFile(url, { signal: controller.signal })
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
@@ -191,9 +196,10 @@ export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
});
return () => {
cancelled = true;
controller.abort();
if (created) URL.revokeObjectURL(created);
};
}, [url, enabled]);
}, [url, enabled, requestFile]);
return { objectUrl, loading, error };
}
@@ -213,6 +219,9 @@ export function UploadedFilePreviewDialog({
fileUrl,
threadId,
virtualPath,
requestFile = apiFetch,
onDownload,
downloading = false,
open,
onOpenChange,
}: {
@@ -222,6 +231,11 @@ export function UploadedFilePreviewDialog({
/** Used by the bounded preview endpoint; optional for old/public messages. */
threadId?: string;
virtualPath?: string;
/** External skill links supply an unauthenticated fetch, avoiding token leakage. */
requestFile?: typeof apiFetch;
/** Authenticated original-file download used by retrieved-file cards. */
onDownload?: () => void | Promise<void>;
downloading?: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
@@ -242,7 +256,7 @@ export function UploadedFilePreviewDialog({
// image / pdf → fetch as authed blob and display via object URL.
const blobEnabled = open && (kind === "image" || kind === "pdf");
const { objectUrl, loading: blobLoading, error: blobError } =
useAuthedObjectUrl(fileUrl, blobEnabled);
useAuthedObjectUrl(fileUrl, blobEnabled, requestFile);
// text / office → fetch text (office reads the converted sibling .md).
const textUrls = useMemo(
@@ -279,6 +293,7 @@ export function UploadedFilePreviewDialog({
useEffect(() => {
if (!open || (kind !== "text" && kind !== "office")) return;
let cancelled = false;
const controller = new AbortController();
setTextLoading(true);
setTextError(null);
setText(null);
@@ -293,7 +308,7 @@ export function UploadedFilePreviewDialog({
)}/artifact-preview?path=${encodeURIComponent(
previewPath,
)}&max_chars=${maxPreviewChars}`;
const response = await apiFetch(endpoint);
const response = await apiFetch(endpoint, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const body = (await response.json()) as ArtifactPreviewResponse;
const rawContent = String(body.content ?? "");
@@ -320,7 +335,8 @@ export function UploadedFilePreviewDialog({
let lastError: Error | null = null;
for (const textUrl of textUrls) {
try {
const response = await apiFetch(textUrl, {
const response = await requestFile(textUrl, {
signal: controller.signal,
headers: { Range: `bytes=0-${maxBytes - 1}` },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -356,8 +372,9 @@ export function UploadedFilePreviewDialog({
});
return () => {
cancelled = true;
controller.abort();
};
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId]);
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId, requestFile]);
const renderAsMarkdown =
kind === "office" || ext === "md" || ext === "markdown";
@@ -376,21 +393,36 @@ export function UploadedFilePreviewDialog({
style={{
width: "min(56rem, calc(100vw - 2rem))",
maxWidth: "none",
height: "min(720px, calc(100dvh - 2rem))",
minHeight: "min(420px, calc(100vh - 2rem))",
height: "min(720px, calc(100vh - 2rem))",
maxHeight: "min(720px, calc(100vh - 2rem))",
}}
>
<DialogHeader className="shrink-0 border-b px-5 py-3 pr-12">
<DialogTitle className="truncate text-sm">{filename}</DialogTitle>
<DialogDescription className="mt-0.5 truncate text-xs">
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
>
<DownloadIcon className="size-3" />
{t.common.download}
</a>
{onDownload ? (
<Button
type="button"
size="sm"
variant="ghost"
disabled={downloading}
onClick={() => void onDownload()}
>
<DownloadIcon className="size-3" />
{t.common.download}
</Button>
) : (
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
>
<DownloadIcon className="size-3" />
{t.common.download}
</a>
)}
</DialogDescription>
</DialogHeader>
@@ -399,7 +431,7 @@ export function UploadedFilePreviewDialog({
blobLoading ? (
loadingView
) : blobError || !objectUrl ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
) : (
<div className="flex items-center justify-center">
<img
@@ -413,7 +445,7 @@ export function UploadedFilePreviewDialog({
blobLoading ? (
loadingView
) : blobError || !objectUrl ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
) : (
<iframe
src={objectUrl}
@@ -425,7 +457,7 @@ export function UploadedFilePreviewDialog({
textLoading ? (
loadingView
) : textError ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
) : (
<div className="space-y-3">
{previewMeta &&
@@ -463,7 +495,7 @@ export function UploadedFilePreviewDialog({
</div>
)
) : (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
)}
</div>
</DialogContent>
@@ -471,15 +503,34 @@ export function UploadedFilePreviewDialog({
);
}
function BinaryFallback({ fileUrl, label }: { fileUrl: string; label: string }) {
function BinaryFallback({ fileUrl, label, onDownload, downloading }: {
fileUrl: string;
label: string;
onDownload?: () => void | Promise<void>;
downloading?: boolean;
}) {
return (
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-lg border border-dashed px-4 py-12 text-center text-sm">
<Button asChild size="sm" variant="outline">
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
<p>暂不支持在线预览,请下载原文件查看。</p>
{onDownload ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={downloading}
onClick={() => void onDownload()}
>
<DownloadIcon className="size-3.5" />
{label}
</a>
</Button>
</Button>
) : (
<Button asChild size="sm" variant="outline">
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
<DownloadIcon className="size-3.5" />
{label}
</a>
</Button>
)}
</div>
);
}
@@ -7,29 +7,37 @@ import { extractReasoningContentFromMessage } from "@/core/messages/utils";
import { getUsageMetadata } from "@/core/messages/usage";
import { textOfMessage } from "@/core/threads/utils";
/**
* Two characters ≈ one token. Same heuristic we use elsewhere; gives a stable
* speed indicator for mixed Chinese/English replies without dragging in a
* real tokeniser.
*/
const CHARS_PER_TOKEN = 2;
/** Sample throttle while a message is streaming. */
const SAMPLE_THROTTLE_MS = 60;
import {
GENERATION_IDLE_MS,
SAMPLE_THROTTLE_MS,
charsToTokensPerSec,
messageHasToolCalls,
pushSpeedSample,
rollingTokensPerSec,
shouldFreezeGeneration,
usageTokensPerSec,
type SpeedSample,
} from "./speed-metrics";
/**
* Per-AI-message metric: real-time during streaming, frozen once the run ends.
* Per-AI-message metric: live rolling rate during that model-call segment,
* frozen once the segment ends (not when the whole agent turn ends).
*/
export interface MessageMetric {
/** Rolling tokens/sec, or 0 when we cannot estimate yet. */
tokensPerSec: number;
/**
* Wall-clock duration from when the message first appeared with text to
* either *now* (active) or to when the stream ended (frozen).
* Wall-clock duration of this model-call segment: first visible character
* → last visible character (tool-wait after the call is excluded).
*/
durationMs: number;
/** True while this is the currently streaming message. */
/** True while this message is still generating visible tokens. */
isActive: boolean;
/**
* True while this message is the live generator but the rolling window
* has seen no new characters — the pill should not keep a stale tok/s.
*/
isPaused: boolean;
/**
* Whether the speed value was computed from real usage_metadata output
* tokens (``true``) vs. a char-count fallback (``false``). UI uses this
@@ -45,15 +53,12 @@ export interface MessageMetric {
isTurnFinal: boolean;
}
type Sample = { at: number; chars: number };
type TimingEntry = {
firstSeenAt: number;
endedAt: number | null;
samples: Sample[];
lastGrewAt: number;
samples: SpeedSample[];
lastChars: number;
/** True iff we *have already* observed `usage_metadata.output_tokens`. */
hadUsage: boolean;
};
function isAiMessage(message: Message): boolean {
@@ -82,11 +87,12 @@ function messageText(message: Message): string {
*
* Recording strategy:
* - Whenever a message renders with text, we anchor `firstSeenAt`.
* - On every text-length change we push a sample and recompute the rate.
* - When `isLoading` flips to false we mark *all* still-active messages as
* ended so subsequent renders return a frozen duration.
* - Token totals come from usage_metadata when present (most accurate);
* otherwise we fall back to char-count / 2.
* - On every text-length change we push a sample.
* - The segment freezes when this model call ends (next AI message, tool
* calls, usage + idle, or the run stops) so tool-wait is not in the
* denominator.
* - Live rate is a rolling window that decays to 0 on pause.
* - `usage_metadata.output_tokens` only corrects the frozen final value.
*/
export function useMessageMetrics(
messages: Message[],
@@ -104,7 +110,8 @@ export function useMessageMetrics(
// synchronously inside an effect so React isn't tracking the map identity.
useEffect(() => {
const now = performance.now();
let activeId: string | null = null;
const grewIds = new Set<string>();
let latestId: string | null = null;
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
@@ -118,50 +125,62 @@ export function useMessageMetrics(
entry = {
firstSeenAt: now,
endedAt: null,
lastGrewAt: now,
samples: [{ at: now, chars: text.length }],
lastChars: text.length,
hadUsage: false,
};
timingRef.current.set(id, entry);
}
// Track the most recent AI message as the active one (while streaming).
activeId = id;
latestId = id;
// Sample only when actively streaming OR when content actually grew —
// throttle to keep arrays bounded.
const grew = text.length !== entry.lastChars;
if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
entry.samples.push({ at: now, chars: text.length });
entry.lastChars = text.length;
if (entry.samples.length > 256) {
entry.samples.splice(0, entry.samples.length - 256);
if (grew) {
grewIds.add(id);
entry.lastGrewAt = now;
if (now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
pushSpeedSample(entry.samples, now, text.length);
lastSampleAtRef.current = now;
}
lastSampleAtRef.current = now;
} else if (grew) {
// Still update lastChars so the next render captures it eventually.
entry.lastChars = text.length;
}
if (getUsageMetadata(message)) {
entry.hadUsage = true;
}
}
// When streaming stops, freeze any non-ended entry to the current time.
if (!isLoading) {
for (const entry of timingRef.current.values()) {
if (entry.endedAt === null) {
entry.endedAt = now;
}
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (!message || !isAiMessage(message)) continue;
const id = getMessageId(message, i);
const entry = timingRef.current.get(id);
if (!entry) continue;
const grewThisTick = grewIds.has(id);
if (grewThisTick && entry.endedAt != null && isLoading) {
entry.endedAt = null;
continue;
}
const usage = getUsageMetadata(message);
if (
shouldFreezeGeneration({
endedAt: entry.endedAt,
isLoading,
isLatestGenerating: id === latestId,
hasToolCalls: messageHasToolCalls(message),
grewThisTick,
lastGrewAt: entry.lastGrewAt,
now,
outputTokens: usage?.outputTokens ?? 0,
})
) {
entry.endedAt = entry.lastGrewAt;
}
}
lastActiveIdRef.current = activeId;
lastActiveIdRef.current = latestId;
setTick((v) => v + 1);
}, [messages, isLoading]);
// Drive a slow tick while streaming so live durations refresh smoothly.
// Drive a slow tick while streaming so live durations / pause decay refresh.
useEffect(() => {
if (!isLoading) return;
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
@@ -198,45 +217,36 @@ export function useMessageMetrics(
const entry = timingRef.current.get(id);
if (!entry) continue;
const isActive =
isLoading && id === lastActiveIdRef.current && entry.endedAt === null;
const endedAt = entry.endedAt ?? now;
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
const isActive = isLoading && id === lastActiveIdRef.current;
let tokensPerSec = 0;
let fromUsage = false;
let isPaused = false;
const usage = getUsageMetadata(message);
if (usage && usage.outputTokens > 0 && durationMs > 0) {
tokensPerSec = (usage.outputTokens / durationMs) * 1000;
fromUsage = true;
} else {
// Char-count fallback. Uses the rolling window during streaming so a
// mid-stream snapshot reflects current speed rather than average since
// the start.
if (isActive && entry.samples.length >= 2) {
const samples = entry.samples;
let start = samples.findIndex((s) => s.at >= now - 1500);
if (start === -1) start = samples.length - 2;
if (start < 0) start = 0;
const head = samples[start];
const tail = samples[samples.length - 1];
if (head && tail) {
const elapsedMs = tail.at - head.at;
const chars = Math.max(0, tail.chars - head.chars);
if (elapsedMs > 0) {
tokensPerSec = (chars / elapsedMs) * 1000 / CHARS_PER_TOKEN;
}
}
if (entry.endedAt != null) {
if (usage && usage.outputTokens > 0 && durationMs > 0) {
tokensPerSec = usageTokensPerSec(usage.outputTokens, durationMs);
fromUsage = true;
} else if (durationMs > 0) {
// Finished message without usage data: average over full duration.
const chars = entry.lastChars;
tokensPerSec = (chars / durationMs) * 1000 / CHARS_PER_TOKEN;
tokensPerSec = charsToTokensPerSec(entry.lastChars, durationMs);
}
} else {
tokensPerSec = rollingTokensPerSec(entry.samples, entry.lastChars, now);
isPaused =
isActive &&
tokensPerSec <= 0 &&
now - entry.lastGrewAt >= GENERATION_IDLE_MS;
}
out.set(id, {
tokensPerSec,
durationMs,
isActive,
isPaused,
fromUsage,
isTurnFinal: turnFinalIds.has(id),
});
@@ -0,0 +1,49 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
extractContentFromMessage,
isHiddenFromUIMessage,
stripArtifactTag,
stripUploadedFilesTag,
} from "@/core/messages/utils";
const SUMMARY_MAX_LENGTH = 80;
export type QuestionItem = {
anchorId: string;
summary: string;
};
/** Preserve the raw message index for historical messages without an id. */
export function getQuestionAnchorId(message: Pick<Message, "id">, index: number): string {
return `message-${message.id ?? index}`;
}
function summarizeQuestion(raw: string): string {
let text = stripArtifactTag(stripUploadedFilesTag(raw));
text = text
.replace(/<page_context>[\s\S]*?<\/page_context>/gi, " ")
.replace(/```[\s\S]*?(```|$)/g, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/`([^`]*)`/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*>\s?/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "")
.replace(/(\*\*|__|~~)/g, "")
.replace(/\s+/g, " ")
.trim();
return text.length > SUMMARY_MAX_LENGTH ? `${text.slice(0, SUMMARY_MAX_LENGTH)}…` : text;
}
/** Index only real, visible questions, using the same visibility policy as chat. */
export function buildQuestionItems(messages: Message[]): QuestionItem[] {
const items: QuestionItem[] = [];
messages.forEach((message, index) => {
if (message.type !== "human" || isHiddenFromUIMessage(message)) return;
const summary = summarizeQuestion(extractContentFromMessage(message));
if (summary) items.push({ anchorId: getQuestionAnchorId(message, index), summary });
});
return items;
}
@@ -11,68 +11,23 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
extractContentFromMessage,
stripArtifactTag,
stripUploadedFilesTag,
} from "@/core/messages/utils";
import { cn } from "@/lib/utils";
import { REVEAL_CHAT_HISTORY_EVENT } from "@/core/messages/history-window";
import { buildQuestionItems, type QuestionItem } from "./question-navigator-data";
import { Tooltip } from "./tooltip";
export { getQuestionAnchorId } from "./question-navigator-data";
/** Width of the expanded floating panel, in px. */
const PANEL_WIDTH = 300;
/** Minimum side gutter (beyond the panel itself) required to float the panel. */
const PANEL_GUTTER_MARGIN = 24;
/** Max characters kept in a question summary card. */
const SUMMARY_MAX_LENGTH = 80;
/** Questions needed before the navigator shows up at all. */
const MIN_QUESTIONS = 2;
const COLLAPSE_STORAGE_KEY = "deerflow.question-navigator.collapsed";
/**
* Stable DOM anchor id for a message. Prefers the server-side message id and
* falls back to the render index so messages without ids still get a target
* (requirement: `message-${index}` style fallback).
*/
export function getQuestionAnchorId(
message: Pick<Message, "id">,
index: number,
): string {
return `message-${message.id ?? index}`;
}
/**
* Turn the raw markdown of a user question into a short plain-text summary:
* strips upload/artifact/page-context envelopes and common Markdown syntax,
* collapses whitespace, and truncates with an ellipsis.
*/
function summarizeQuestion(raw: string): string {
let text = stripArtifactTag(stripUploadedFilesTag(raw));
text = text
.replace(/<page_context>[\s\S]*?<\/page_context>/gi, " ")
.replace(/```[\s\S]*?(```|$)/g, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/`([^`]*)`/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*>\s?/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "")
.replace(/(\*\*|__|~~)/g, "")
.replace(/\s+/g, " ")
.trim();
if (text.length > SUMMARY_MAX_LENGTH) {
return `${text.slice(0, SUMMARY_MAX_LENGTH)}…`;
}
return text;
}
type QuestionItem = {
anchorId: string;
summary: string;
};
/** Walk up from a message anchor to the element that actually scrolls. */
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
let node = el?.parentElement ?? null;
@@ -128,16 +83,7 @@ export function QuestionNavigator({
const [sheetOpen, setSheetOpen] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
const questions = useMemo(() => {
const items: QuestionItem[] = [];
messages.forEach((message, index) => {
if (message.type !== "human") return;
const summary = summarizeQuestion(extractContentFromMessage(message));
if (!summary) return;
items.push({ anchorId: getQuestionAnchorId(message, index), summary });
});
return items;
}, [messages]);
const questions = useMemo(() => buildQuestionItems(messages), [messages]);
const visible = questions.length >= MIN_QUESTIONS;
@@ -198,9 +144,17 @@ export function QuestionNavigator({
}, [visible, questions]);
const scrollToQuestion = useCallback((anchorId: string) => {
document
.getElementById(anchorId)
?.scrollIntoView({ behavior: "smooth", block: "start" });
const scroll = () => {
document
.getElementById(anchorId)
?.scrollIntoView({ behavior: "smooth", block: "start" });
};
if (!document.getElementById(anchorId)) {
window.dispatchEvent(new Event(REVEAL_CHAT_HISTORY_EVENT));
requestAnimationFrame(() => requestAnimationFrame(scroll));
} else {
scroll();
}
setActiveId(anchorId);
}, []);
@@ -5,12 +5,18 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { apiFetch } from "@/core/api/fetch-client";
import { clearStoredAuth, getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { authStorageRemove } from "@/core/auth/scoped-storage";
import { EXTERNAL_LOGIN_STORAGE_KEYS } from "@/core/auth/external-login";
import { clearLoginCredentials } from "@/core/auth/login-credentials";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import { clearYLogToken } from "@/core/log/y-log-token";
import { SettingsSection } from "./settings-section";
import { PasswordSettings } from "./password-settings";
function roleLabel(role?: string) {
if (role === "admin") return "管理员";
@@ -54,17 +60,23 @@ export function AccountSettingsPage() {
});
clearStoredAuth();
clearYLogToken();
for (const key of [
"userInfo", "userId", "tools_token", "access_token",
...Object.values(EXTERNAL_LOGIN_STORAGE_KEYS),
]) authStorageRemove(key);
clearLoginCredentials();
// Match the rest of the app's logout flow: blow away the cookie session
// by hitting the gateway logout endpoint and then forcing a full reload
// so any cached query state goes too.
void fetch("/api/v1/auth/logout", { method: "POST", credentials: "include" })
void apiFetch(`${getBackendBaseURL()}/api/v1/auth/logout`, { method: "POST" })
.catch(() => undefined)
.finally(() => {
window.location.assign("/");
window.location.assign(`${window.location.pathname}#/login`);
});
};
return (
<div className="space-y-8">
<SettingsSection title="个人信息" description="当前登录账号的基本信息。">
<div className="flex flex-col gap-4 rounded-xl border bg-white p-5">
<div className="flex items-center gap-3">
@@ -106,6 +118,8 @@ export function AccountSettingsPage() {
</div>
</div>
</SettingsSection>
<PasswordSettings key={auth.user_id} userId={auth.user_id} />
</div>
);
}
@@ -0,0 +1,91 @@
"use client";
import { useEffect, useState } from "react";
import { DatabaseIcon, RefreshCwIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
type ApiReadCacheStatus = {
enabled: boolean;
redis_configured: boolean;
ttl_seconds: number;
max_value_bytes: number;
};
async function loadStatus(): Promise<ApiReadCacheStatus> {
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/api-read-cache`);
const data = await response.json();
if (!response.ok) throw new Error(typeof data.detail === "string" ? data.detail : `读取失败(${response.status})`);
return data as ApiReadCacheStatus;
}
async function saveStatus(enabled: boolean): Promise<ApiReadCacheStatus> {
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/api-read-cache`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
const data = await response.json();
if (!response.ok) throw new Error(typeof data.detail === "string" ? data.detail : `保存失败(${response.status})`);
return data as ApiReadCacheStatus;
}
export function ApiReadCacheSettingsPage() {
const [status, setStatus] = useState<ApiReadCacheStatus>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const refresh = async () => {
setBusy(true);
try {
setStatus(await loadStatus());
setError("");
} catch (err) {
setError(err instanceof Error ? err.message : "读取设置失败");
} finally {
setBusy(false);
}
};
useEffect(() => { void refresh(); }, []);
const toggle = async (enabled: boolean) => {
setBusy(true);
try {
const saved = await saveStatus(enabled);
setStatus(saved);
toast.success(enabled ? "Redis 读取缓存已开启" : "Redis 读取缓存已关闭");
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存设置失败");
} finally {
setBusy(false);
}
};
return (
<section className="space-y-5" data-testid="api-read-cache-settings">
<div>
<h2 className="flex items-center gap-2 text-xl font-semibold"><DatabaseIcon className="size-5 text-primary" />首页接口 Redis 缓存</h2>
<p className="mt-1 text-sm text-muted-foreground">开关保存在 MySQL,服务重启后仍生效。关闭或 Redis 不可用时,接口自动回到 MySQL 查询。</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border p-4">
<div>
<p className="font-medium">启用常用读取接口缓存</p>
<p className="mt-1 text-sm text-muted-foreground">覆盖智能体、技能、个人偏好、常用提示词和推荐问题列表;缓存按用户隔离,并在数据修改后失效。</p>
</div>
<Switch checked={status?.enabled ?? false} disabled={!status || busy} onCheckedChange={(value) => void toggle(value)} aria-label="启用 Redis 读取缓存" />
</div>
{error && <p role="alert" className="rounded-lg border border-destructive/30 p-3 text-sm text-destructive">{error}</p>}
{status && <div className="rounded-xl border p-4 text-sm">
<p>Redis 连接:<strong>{status.redis_configured ? "已配置" : "未配置"}</strong></p>
<p className="mt-1 text-muted-foreground">单条缓存最长保留 {status.ttl_seconds} 秒,超过 {Math.round(status.max_value_bytes / 1024)} KiB 的结果不会写入缓存。</p>
{!status.redis_configured && <p className="mt-3 text-amber-700">当前配置没有可用的 Redis 地址。开启后设置会保存,但请求会继续直接查询 MySQL;请先在服务配置中配置 Redis。</p>}
</div>}
<Button type="button" variant="outline" disabled={busy} onClick={() => void refresh()}><RefreshCwIcon className="size-4" />刷新状态</Button>
</section>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,102 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { AUTH_STATE_CHANGED_EVENT, getStoredAuth } from "@/core/auth";
import {
getAuthenticationSettings,
saveAuthenticationSettings,
type AuthenticationSettings,
} from "@/core/auth/account-settings-api";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { SettingsSection } from "./settings-section";
export function AuthenticationSettingsPage({ onClose }: { onClose?: () => void }) {
const navigate = useNavigate();
const live = useLiveAccount();
const isAdmin = getStoredAuth()?.system_role === "admin";
const queryClient = useQueryClient();
const queryKey = ["authentication-settings", live.userId];
const query = useQuery({ queryKey, queryFn: getAuthenticationSettings, enabled: isAdmin });
const [draft, setDraft] = useState<AuthenticationSettings | null>(null);
const mutation = useMutation({ mutationFn: saveAuthenticationSettings });
const settings = draft ?? query.data;
if (!isAdmin) return null;
const update = (key: keyof AuthenticationSettings, value: boolean) => {
if (!settings) return;
const next = { ...settings, [key]: value };
if (key === "password_login_enabled" && !value) next.registration_enabled = false;
setDraft(next);
};
const save = async () => {
if (!settings || mutation.isPending) return;
try {
const saved = await mutation.mutateAsync(settings);
queryClient.setQueryData(queryKey, saved);
setDraft(null);
void queryClient.invalidateQueries({ queryKey: ["auth-options"] });
window.dispatchEvent(new Event(AUTH_STATE_CHANGED_EVENT));
toast.success("登录与注册设置已保存,即时生效");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const invalid = settings && !settings.password_login_enabled && !settings.sso_enabled;
return (
<SettingsSection title="登录与注册" description="管理登录页面、自主注册与账号在线限制,保存后即时生效。">
{query.isPending ? (
<p className="text-muted-foreground text-sm">正在加载设置…</p>
) : query.isError ? (
<div role="alert" className="space-y-3 text-sm">
<p className="text-destructive">{query.error.message}</p>
<Button variant="outline" onClick={() => void query.refetch()}>重新加载</Button>
</div>
) : settings && (
<div className="space-y-5">
<div className="divide-y rounded-xl border px-5">
<SettingSwitch id="password-login-enabled" title="账号密码登录" description="默认关闭。开启后须登录才能进入系统。未设置个人密码的账号使用现有系统口令。" checked={settings.password_login_enabled} disabled={mutation.isPending} onChange={(value) => update("password_login_enabled", value)} />
<SettingSwitch id="registration-enabled" title="用户自主注册" description="允许用户在注册页创建自己的普通账号。需先开启账号密码登录。" checked={settings.registration_enabled} disabled={mutation.isPending || !settings.password_login_enabled} onChange={(value) => update("registration_enabled", value)} />
<SettingSwitch id="single-session-enabled" title="单点登录" description="默认关闭。开启后同一账号只允许一处在线,新登录会使其他登录会话自动退出。" checked={settings.single_session_enabled ?? false} disabled={mutation.isPending} onChange={(value) => update("single_session_enabled", value)} />
<SettingSwitch id="sso-enabled" title="外部系统免登" description="允许外部系统通过票据、令牌或口令链接登录,与单点登录的在线限制独立。" checked={settings.sso_enabled} disabled={mutation.isPending} onChange={(value) => update("sso_enabled", value)} />
</div>
<p className="text-muted-foreground text-sm">密码与登录口令共用。至少保留账号密码登录或外部系统免登中的一种;关闭自主注册不影响已有账号。</p>
<Button variant="outline" onClick={() => { onClose?.(); navigate("/page/strategy/admin/login-whitelist"); }}>管理用户密码与登录口令</Button>
{invalid && <p role="alert" className="text-destructive text-sm">请至少开启账号密码登录或外部系统免登。</p>}
<div className="flex justify-end">
<Button disabled={!draft || Boolean(invalid) || mutation.isPending} onClick={() => void save()}>{mutation.isPending ? "保存中…" : "保存设置"}</Button>
</div>
</div>
)}
</SettingsSection>
);
}
function SettingSwitch({ id, title, description, checked, disabled, onChange }: {
id: string;
title: string;
description: string;
checked: boolean;
disabled: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<div className="flex items-center justify-between gap-5 py-5">
<div className="space-y-1">
<label htmlFor={id} className="cursor-pointer text-sm font-medium">{title}</label>
<p id={`${id}-description`} className="text-muted-foreground text-sm">{description}</p>
</div>
<Switch id={id} aria-describedby={`${id}-description`} checked={checked} disabled={disabled} onCheckedChange={onChange} />
</div>
);
}
@@ -0,0 +1,240 @@
"use client";
import { ArchiveIcon, DatabaseIcon, RotateCcwIcon, Trash2Icon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { getStoredAuth } from "@/core/auth";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
type ArchiveJob = {
job_id: string;
action: "archive" | "purge" | "rollback";
status: string;
total_threads: number;
processed_threads: number;
failed_threads: number;
current_thread_id?: string | null;
snapshot_at?: string;
processed_through?: string | null;
message?: string;
error?: string | null;
};
type ArchiveMonth = {
month: string;
thread_count: number;
status: string;
copied_threads: number;
deleted_threads: number;
sqlite_bytes: number;
latest_job?: ArchiveJob;
};
type Inventory = {
archive_dir: string;
disk_total_bytes: number;
disk_free_bytes: number;
month_basis: string;
warning: string;
months: ArchiveMonth[];
};
const ACTIVE = new Set(["queued", "running"]);
function formatBytes(bytes: number) {
if (!bytes) return "尚无归档文件";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(unit >= 3 ? 2 : 1)} ${units[unit]}`;
}
function currentLocalMonth() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
function statusLabel(status: string) {
switch (status) {
case "ready": return "归档已校验";
case "deleted": return "PG 已清理";
case "copying": return "正在归档";
case "deleting": return "正在清理 PG";
case "restoring": return "正在回滚";
case "partial": return "部分完成";
case "interrupted": return "已中断,可重新归档";
default: return "尚未归档";
}
}
function statusTone(status: string): "default" | "secondary" | "outline" | "destructive" {
if (status === "deleted" || status === "ready") return "default";
if (status === "partial" || status === "interrupted") return "destructive";
if (ACTIVE.has(status) || ["copying", "deleting", "restoring"].includes(status)) return "secondary";
return "outline";
}
export function ChatArchiveSettingsPage() {
const [inventory, setInventory] = useState<Inventory | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busyMonth, setBusyMonth] = useState<string | null>(null);
const load = useCallback(async (signal?: AbortSignal) => {
try {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/chat-archive/months`, { signal });
if (!response.ok) throw new Error((await response.text()) || `加载失败 (${response.status})`);
setInventory(await response.json() as Inventory);
setError(null);
} catch (cause) {
if (signal?.aborted) return;
setError(cause instanceof Error ? cause.message : "读取归档月份失败");
} finally {
if (!signal?.aborted) setLoading(false);
}
}, []);
useEffect(() => {
const controller = new AbortController();
void load(controller.signal);
return () => controller.abort();
}, [load]);
useEffect(() => {
const activeJobs = inventory?.months
.map((month) => month.latest_job)
.filter((job): job is ArchiveJob => Boolean(job && ACTIVE.has(job.status)));
if (!activeJobs?.length) return;
const timer = window.setInterval(() => {
void Promise.all(activeJobs.map(async (job) => {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/chat-archive/jobs/${job.job_id}`);
if (!response.ok) return null;
return await response.json() as ArchiveJob;
})).then((jobs) => {
const updates = jobs.filter((job): job is ArchiveJob => Boolean(job));
if (!updates.length) return;
setInventory((current) => current ? {
...current,
months: current.months.map((month) => {
const job = updates.find((item) => item.job_id === month.latest_job?.job_id);
return job ? { ...month, latest_job: job } : month;
}),
} : current);
if (updates.some((job) => !ACTIVE.has(job.status))) void load();
}).catch(() => undefined);
}, 2500);
return () => window.clearInterval(timer);
}, [inventory, load]);
const runAction = async (month: ArchiveMonth, action: ArchiveJob["action"]) => {
const labels = { archive: "归档到 SQLite", purge: "删除该月 PostgreSQL 数据", rollback: "回滚到 PostgreSQL" };
if (action !== "archive" && !window.confirm(`确认${labels[action]}?会话记录会保留在月度 SQLite 文件中。`)) return;
setBusyMonth(month.month);
setError(null);
try {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/chat-archive/months/${month.month}/${action === "purge" ? "delete-postgres" : action}`, { method: "POST" });
if (!response.ok) throw new Error((await response.text()) || `操作失败 (${response.status})`);
await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "操作失败");
} finally {
setBusyMonth(null);
}
};
const isAdmin = getStoredAuth()?.system_role === "admin";
if (!isAdmin) return <Alert><AlertTitle>仅管理员可用</AlertTitle><AlertDescription>聊天归档管理需要管理员权限。</AlertDescription></Alert>;
return (
<section className="space-y-5" data-testid="chat-archive-settings">
<div>
<h2 className="text-lg font-semibold">聊天记录月度归档</h2>
<p className="text-muted-foreground mt-1 text-sm">整条会话按创建月份归档(跨月消息一起保留):先复制并校验到按月 SQLite 文件,再单独清理 PostgreSQL;历史会话仍可读取,必要时可回滚。单纯部署或打开此页不会扫描检查点表或改变当前聊天存储;只有管理员启动归档、清理或回滚任务后才会处理数据。</p>
</div>
<Alert>
<DatabaseIcon className="size-4" />
<AlertTitle>归档目录</AlertTitle>
<AlertDescription className="space-y-1 break-all">
<div>{inventory?.archive_dir ?? "正在读取…"}</div>
<div>目标磁盘可用 {inventory ? formatBytes(inventory.disk_free_bytes) : "…"} / {inventory ? formatBytes(inventory.disk_total_bytes) : "…"}。归档过程中保留至少 2 GiB 空间;若不足,任务会暂停并保留 PG 数据。</div>
</AlertDescription>
</Alert>
<Alert>
<AlertTitle>低负载处理方式</AlertTitle>
<AlertDescription>{inventory?.warning ?? "月份按会话创建时间展示;归档任务在后台逐会话处理,页面关闭或服务重启后可继续。"} 删除 PostgreSQL 前会重新校验目标 SQLite 文件中的检查点;清理按会话逐条执行,每条间隔 0.5 秒,运行中的问答会跳过并计入进度,结束后可重新清理。清理后空间可供 PostgreSQL 后续复用;表文件大小不会保证立即缩小,系统不会自动运行可能带来较大 I/O 的 VACUUM FULL。</AlertDescription>
</Alert>
{error && <Alert variant="destructive"><AlertTitle>操作未完成</AlertTitle><AlertDescription className="break-all">{error}</AlertDescription></Alert>}
{loading ? <p className="text-muted-foreground text-sm">正在读取月份…</p> : inventory?.months.length ? (
<div className="space-y-3">
{inventory.months.map((month) => {
const job = month.latest_job;
const active = ACTIVE.has(job?.status ?? "") || ["copying", "deleting", "restoring"].includes(month.status);
const canPurge = ["ready", "deleted", "partial", "interrupted"].includes(month.status) && month.copied_threads > 0;
const canRollback = month.deleted_threads > 0;
return (
<Card key={month.month}>
<CardHeader className="pb-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle className="text-base">{month.month}</CardTitle>
<CardDescription className="mt-1">{month.thread_count.toLocaleString()} 个会话 · SQLite:{formatBytes(month.sqlite_bytes)}</CardDescription>
</div>
<Badge variant={statusTone(month.status)}>{statusLabel(month.status)}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-muted-foreground text-xs">已复制 {month.copied_threads.toLocaleString()} 个 · PG 已清理 {month.deleted_threads.toLocaleString()} 个</p>
{job && <div className="bg-muted/50 rounded-md p-3 text-sm">
<div>{job.message || "任务状态已更新"} {ACTIVE.has(job.status) && `(${job.processed_threads}/${job.total_threads}${job.action === "purge" && job.failed_threads ? `,跳过 ${job.failed_threads}` : ""})`}</div>
{job.action === "archive" && job.snapshot_at && <div className="text-muted-foreground mt-1 text-xs">本次数据快照截至:{new Date(job.snapshot_at).toLocaleString()}</div>}
{job.action === "archive" && job.processed_through && <div className="text-muted-foreground mt-1 text-xs">已处理到会话创建时间:{new Date(job.processed_through).toLocaleString()}</div>}
{ACTIVE.has(job.status) && <div className="mt-2 space-y-1">
<div
role="progressbar"
aria-label={`${month.month} ${job.action === "archive" ? "聊天归档" : job.action === "purge" ? "PostgreSQL 清理" : "聊天回滚"}进度`}
aria-valuemin={0}
aria-valuemax={Math.max(1, job.total_threads)}
aria-valuenow={Math.min(job.processed_threads, job.total_threads)}
className="bg-background h-2 overflow-hidden rounded-full"
>
<div className="bg-primary h-full transition-[width]" style={{ width: `${job.total_threads ? Math.min(100, (job.processed_threads / job.total_threads) * 100) : 100}%` }} />
</div>
<div className="text-muted-foreground text-xs">{job.total_threads ? Math.floor((job.processed_threads / job.total_threads) * 100) : 100}%</div>
</div>}
{job.current_thread_id && <div className="text-muted-foreground mt-1 break-all text-xs">当前会话:{job.current_thread_id}</div>}
{job.error && <div className="text-destructive mt-1 break-all text-xs">{job.error}</div>}
</div>}
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={active || busyMonth === month.month || month.month > currentLocalMonth()} onClick={() => void runAction(month, "archive")}>
<ArchiveIcon className="mr-2 size-4" />{month.status === "interrupted" ? "从头重新归档" : month.month === currentLocalMonth() ? "归档本月快照" : month.copied_threads ? "重新校验归档" : "归档到 SQLite"}
</Button>
<Button size="sm" variant="destructive" disabled={active || !canPurge || busyMonth === month.month} onClick={() => void runAction(month, "purge")}>
<Trash2Icon className="mr-2 size-4" />{month.status === "deleted" ? "再次清理 PG" : "删除已归档 PG 数据"}
</Button>
<Button size="sm" variant="outline" disabled={active || !canRollback || busyMonth === month.month} onClick={() => void runAction(month, "rollback")}>
<RotateCcwIcon className="mr-2 size-4" />回滚到 PostgreSQL
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
) : (
<Alert><AlertTitle>暂无可归档月份</AlertTitle><AlertDescription>业务库中还没有可识别的会话元数据。</AlertDescription></Alert>
)}
</section>
);
}
@@ -0,0 +1,86 @@
"use client";
import { Loader2Icon, ShrinkIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
useConversationContextSettings,
useUpdateConversationContextSettings,
} from "@/core/system-settings/hooks";
export function ContextWindowSettingsPage() {
const { data, isLoading, isError, isFetching, refetch } =
useConversationContextSettings();
const update = useUpdateConversationContextSettings();
const enabled = data?.recent_turn_window_enabled ?? false;
const recentTurns = data?.recent_turns ?? 3;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ recent_turn_window_enabled: checked });
toast.success(
checked
? `近 ${recentTurns} 轮上下文模式已开启`
: `近 ${recentTurns} 轮上下文模式已关闭`,
);
} 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">
<ShrinkIcon 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">仅保留最近 {recentTurns} 轮对话</div>
<p className="text-muted-foreground text-sm">
开启后,主问答引擎的模型请求只携带最近 {recentTurns}
个真实用户回合(含当前轮)及其回复和工具结果,不再调用摘要模型。完整聊天记录仍会保存并显示;若这三轮本身仍超过模型上限,最终安全限制会继续裁剪超长内容。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label={`仅保留最近 ${recentTurns} 轮对话`}
/>
)}
</div>
{isError && (
<div className="flex items-center justify-between gap-3">
<p className="text-destructive text-sm">
无法读取当前上下文模式,本页暂不能修改;服务端会继续保持原设置。
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={isFetching}
onClick={() => void refetch()}
>
{isFetching ? "重新加载中…" : "重新加载"}
</Button>
</div>
)}
<p className="text-muted-foreground text-xs">
开启后优先于聊天框中的“上下文压缩”选项,并从下一次新问答或运行开始生效,无需重启服务。
</p>
</section>
);
}
@@ -0,0 +1,112 @@
"use client";
import { Loader2Icon, SearchIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
useOrdinaryKnowledgeRetrievalSettings,
useUpdateOrdinaryKnowledgeRetrievalSettings,
} from "@/core/system-settings/hooks";
export function KnowledgeRetrievalSettingsPage() {
const { data, isLoading, isError, isFetching, refetch } =
useOrdinaryKnowledgeRetrievalSettings();
const update = useUpdateOrdinaryKnowledgeRetrievalSettings();
const enabled = data?.smart_reasoning_enabled ?? false;
const advancedManagementEnabled =
data?.advanced_management_enabled ?? false;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ smart_reasoning_enabled: checked });
toast.success(
checked ? "普通知识库智能检索已开启" : "普通知识库智能检索已关闭",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleAdvancedManagementChange = async (checked: boolean) => {
try {
await update.mutateAsync({ advanced_management_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">
<SearchIcon 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>
<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">
默认隐藏检索测试、增量同步和知识库数据包导入导出。开启后按原有账号权限显示;
基础知识库管理和查看 Wiki 索引始终可用。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={advancedManagementEnabled}
disabled={isError || update.isPending}
onCheckedChange={handleAdvancedManagementChange}
aria-label="知识库高级管理入口"
/>
)}
</div>
{isError && (
<div className="flex items-center justify-between gap-3">
<p className="text-destructive text-sm">
无法读取当前知识检索设置,客户端按关闭处理,本页暂不能修改。
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={isFetching}
onClick={() => void refetch()}
>
{isFetching ? "重新加载中…" : "重新加载"}
</Button>
</div>
)}
</section>
);
}
@@ -0,0 +1,102 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type FormEvent } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { getStoredAuth, setStoredAuth } from "@/core/auth";
import { changeAccountPassword, getAccountPasswordStatus } from "@/core/auth/account-settings-api";
import { SettingsSection } from "./settings-section";
export function PasswordSettings({ userId }: { userId: string }) {
const queryClient = useQueryClient();
const status = useQuery({
queryKey: ["account-password", userId],
queryFn: getAccountPasswordStatus,
});
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const hasPassword = status.data?.has_password ?? true;
const requiresCurrentPassword = status.data?.requires_current_password ?? true;
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (pending || !status.data) return;
setError("");
if (newPassword !== confirmPassword) {
setError("两次输入的新密码不一致");
return;
}
if (newPassword.length < 8) {
setError("新密码至少需要 8 位,请避免常见弱密码");
return;
}
const auth = getStoredAuth();
if (!auth || auth.user_id !== userId) {
setError("登录账号已变化,请重新打开个人信息");
return;
}
setPending(true);
try {
const session = await changeAccountPassword(requiresCurrentPassword ? currentPassword : "", newPassword);
// Another tab may have changed the active account while the request was pending.
if (getStoredAuth()?.access_token !== auth.access_token) return;
if (session.access_token) {
setStoredAuth({
...auth,
access_token: session.access_token,
token_type: session.token_type ?? auth.token_type,
expires_in: session.expires_in ?? auth.expires_in,
needs_setup: false,
});
}
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
queryClient.setQueryData(["account-password", userId], { ...status.data, has_password: true, requires_current_password: true, uses_default_password: false });
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
toast.success("密码与登录口令已同步更新,其他登录会话已失效");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "密码修改失败");
} finally {
setPending(false);
}
};
return (
<SettingsSection title={hasPassword ? "修改密码" : "设置密码"} description="密码与登录口令共用,修改后原密码和原口令链接失效。">
{status.isPending ? <p className="text-muted-foreground text-sm">正在读取密码状态…</p> : status.isError ? (
<div role="alert" className="space-y-3 text-sm">
<p className="text-destructive">{status.error.message}</p>
<Button variant="outline" onClick={() => void status.refetch()}>重新加载</Button>
</div>
) : (
<form onSubmit={(event) => void submit(event)} className="space-y-4 rounded-xl border p-5">
{!hasPassword && <p className="text-muted-foreground text-sm">此账号尚未设置个人密码,当前使用系统口令。请先输入现有口令,再设置个人密码。</p>}
{requiresCurrentPassword && (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="account-current-password">当前密码 / 口令</label>
<Input id="account-current-password" type="password" autoComplete="current-password" required value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} disabled={pending} />
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="account-new-password">新密码</label>
<Input id="account-new-password" type="password" autoComplete="new-password" required minLength={8} maxLength={255} placeholder="至少 8 位,避免常见弱密码" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} disabled={pending} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="account-confirm-password">确认新密码</label>
<Input id="account-confirm-password" type="password" autoComplete="new-password" required minLength={8} maxLength={255} value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} disabled={pending} />
</div>
{error && <p role="alert" className="text-destructive text-sm">{error}</p>}
<div className="flex justify-end"><Button type="submit" disabled={pending}>{pending ? "保存中…" : "保存密码"}</Button></div>
</form>
)}
</SettingsSection>
);
}
@@ -0,0 +1,232 @@
"use client";
import { Clock3Icon, ListOrderedIcon, Loader2Icon, UsersIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
useRoundtableRoomLimitSettings,
useUpdateRoundtableRoomLimitSettings,
} from "@/core/system-settings/hooks";
import { loadRoundtableRoomSummary } from "@/roundtable-planning/api/rooms";
export function RoundtableRoomSettingsPage() {
const { data, isLoading, isError, isFetching, refetch } =
useRoundtableRoomLimitSettings();
const update = useUpdateRoundtableRoomLimitSettings();
const enabled = data?.enabled ?? true;
const [maxRooms, setMaxRooms] = useState(String(data?.max_rooms ?? 3));
const [maxHoldMinutes, setMaxHoldMinutes] = useState(
String(data?.max_hold_minutes ?? 30),
);
const [maxCycles, setMaxCycles] = useState(String(data?.max_cycles ?? 8));
const [activeCount, setActiveCount] = useState<number | null>(null);
useEffect(() => {
if (data?.max_rooms != null) setMaxRooms(String(data.max_rooms));
}, [data?.max_rooms]);
useEffect(() => {
if (data?.max_hold_minutes != null) {
setMaxHoldMinutes(String(data.max_hold_minutes));
}
}, [data?.max_hold_minutes]);
useEffect(() => {
if (data?.max_cycles != null) setMaxCycles(String(data.max_cycles));
}, [data?.max_cycles]);
useEffect(() => {
let cancelled = false;
const load = () => {
void loadRoundtableRoomSummary()
.then((summary) => {
if (!cancelled) setActiveCount(summary.activeCount);
})
.catch(() => {
if (!cancelled) setActiveCount(null);
});
};
load();
const timer = window.setInterval(load, 15000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, []);
const save = async (next: {
enabled?: boolean;
max_rooms?: number;
max_hold_minutes?: number;
max_cycles?: number;
}) => {
try {
await update.mutateAsync(next);
toast.success("会商运行设置已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const saveMaxRooms = () => {
const parsed = Number(maxRooms);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 20) {
toast.error("房间上限请填写 1 到 20 的整数");
return;
}
void save({ max_rooms: parsed });
};
const saveMaxHoldMinutes = () => {
const parsed = Number(maxHoldMinutes);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 1440) {
toast.error("最长占用时间请填写 1 到 1440 分钟的整数");
return;
}
void save({ max_hold_minutes: parsed });
};
const saveMaxCycles = () => {
const parsed = Number(maxCycles);
if (!Number.isInteger(parsed) || parsed < 2 || parsed > 100) {
toast.error("总控收口轮数请填写 2 到 100 的整数");
return;
}
void save({ max_cycles: parsed });
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<UsersIcon 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={(checked) => void save({ enabled: checked })}
aria-label="启用会商第二步房间上限"
/>
)}
</div>
<div className="flex flex-wrap items-end justify-between gap-4 rounded-lg border p-4">
<label className="space-y-1">
<span className="font-medium">同时进行的房间数</span>
<p className="text-muted-foreground text-sm">默认 3,可改为 1 到 20。保存后立即生效,无需重启。</p>
<input
type="number"
min={1}
max={20}
value={maxRooms}
disabled={isError || update.isPending}
onChange={(event) => setMaxRooms(event.target.value)}
className="border-input bg-background mt-2 h-9 w-24 rounded-md border px-3 text-sm"
aria-label="会商房间上限"
/>
</label>
<Button type="button" size="sm" disabled={isError || update.isPending} onClick={saveMaxRooms}>
保存上限
</Button>
</div>
<div className="flex flex-wrap items-end justify-between gap-4 rounded-lg border p-4">
<label className="space-y-1">
<span className="flex items-center gap-2 font-medium">
<Clock3Icon className="size-4" />
单个房间最长占用时间
</span>
<p className="text-muted-foreground text-sm">
默认 30 分钟,可改为 1 到 1440 分钟。到时会自动停止第二步并释放名额,避免异常任务长期占用。
</p>
<div className="mt-2 flex items-center gap-2">
<input
type="number"
min={1}
max={1440}
value={maxHoldMinutes}
disabled={isError || update.isPending}
onChange={(event) => setMaxHoldMinutes(event.target.value)}
className="border-input bg-background h-9 w-24 rounded-md border px-3 text-sm"
aria-label="会商房间最长占用分钟数"
/>
<span className="text-muted-foreground text-sm">分钟</span>
</div>
</label>
<Button
type="button"
size="sm"
disabled={isError || update.isPending}
onClick={saveMaxHoldMinutes}
>
保存时长
</Button>
</div>
<div className="flex flex-wrap items-end justify-between gap-4 rounded-lg border p-4">
<label className="space-y-1">
<span className="flex items-center gap-2 font-medium">
<ListOrderedIcon className="size-4" />
总控强制收口轮数
</span>
<p className="text-muted-foreground text-sm">
用于总控自由派活模式,默认第 8 轮,可改为第 2 到 100 轮。到达该轮时不会停止会商,而是关闭总控工具并输出最终总结;业务链与 DAG 在既定席位完成后自然收口。保存后从下一次研讨开始生效。
</p>
<div className="mt-2 flex items-center gap-2">
<input
type="number"
min={2}
max={100}
value={maxCycles}
disabled={isError || update.isPending}
onChange={(event) => setMaxCycles(event.target.value)}
className="border-input bg-background h-9 w-24 rounded-md border px-3 text-sm"
aria-label="总控强制收口轮数"
/>
<span className="text-muted-foreground text-sm">轮</span>
</div>
</label>
<Button
type="button"
size="sm"
disabled={isError || update.isPending}
onClick={saveMaxCycles}
>
保存轮数
</Button>
</div>
<p className="text-muted-foreground text-sm">
当前全站占用:{activeCount == null ? "暂时无法读取" : `${activeCount} / ${data?.max_rooms ?? maxRooms}`}
</p>
{isError && (
<div className="flex items-center justify-between gap-3">
<p className="text-destructive text-sm">无法读取会商房间设置,本页暂不能修改。</p>
<Button type="button" variant="outline" size="sm" disabled={isFetching} onClick={() => void refetch()}>
{isFetching ? "重新加载中…" : "重新加载"}
</Button>
</div>
)}
</section>
);
}
@@ -1,6 +1,21 @@
"use client";
import { BellIcon, BrainIcon, CpuIcon, PaletteIcon, SparklesIcon, UploadCloudIcon, UserIcon, WrenchIcon } from "lucide-react";
import {
BellIcon,
BrainIcon,
CpuIcon,
DatabaseIcon,
ArchiveIcon,
FileOutputIcon,
PaletteIcon,
SearchIcon,
ShrinkIcon,
SparklesIcon,
UploadCloudIcon,
UserIcon,
UsersIcon,
WrenchIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
@@ -13,22 +28,38 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { UploadSettingsPage } from "@/components/workspace/settings/upload-settings-page";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
import { ContextWindowSettingsPage } from "@/components/workspace/settings/context-window-settings-page";
import { WorkerPoolSettingsPage } from "@/components/workspace/settings/worker-pool-settings-page";
import { ApiReadCacheSettingsPage } from "@/components/workspace/settings/api-read-cache-settings-page";
import { ChatArchiveSettingsPage } from "@/components/workspace/settings/chat-archive-settings-page";
import { RoundtableRoomSettingsPage } from "@/components/workspace/settings/roundtable-room-settings-page";
import { KnowledgeRetrievalSettingsPage } from "@/components/workspace/settings/knowledge-retrieval-settings-page";
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { UploadSettingsPage } from "@/components/workspace/settings/upload-settings-page";
import { WriteFileCompatibilitySettingsPage } from "@/components/workspace/settings/write-file-compatibility-settings-page";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
import { ServerDiagnosticsPanel } from "@/pages/ServerDiagnosticsPage";
type SettingsSection =
| "account"
| "appearance"
| "memory"
| "uploads"
| "tools"
| "encoder"
| "about";
| "conversation-context"
| "server-diagnostics"
| "api-read-cache"
| "worker-pool"
| "chat-archive"
| "roundtable-rooms"
| "knowledge-retrieval"
| "memory"
| "fileWriting"
| "uploads"
| "tools"
| "encoder"
| "about";
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
defaultSection?: SettingsSection;
@@ -36,8 +67,8 @@ type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
export function SettingsDialog(props: SettingsDialogProps) {
const { defaultSection = "appearance", ...dialogProps } = props;
const { t } = useI18n();
const isAdmin = getStoredAuth()?.system_role === "admin";
const { t } = useI18n();
const isAdmin = getStoredAuth()?.system_role === "admin";
const [activeSection, setActiveSection] =
useState<SettingsSection>(defaultSection);
@@ -63,16 +94,24 @@ export function SettingsDialog(props: SettingsDialogProps) {
// },
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
...(isAdmin
? [
{ id: "memory", label: "问答记忆", icon: BrainIcon },
{ id: "uploads", label: "上传设置", icon: UploadCloudIcon },
]
: []),
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
: []),
...(isAdmin
? [
{ id: "conversation-context", label: "上下文管理", icon: ShrinkIcon },
{ id: "server-diagnostics", label: "服务器诊断", icon: CpuIcon },
{ id: "api-read-cache", label: "Redis 缓存", icon: DatabaseIcon },
{ id: "worker-pool", label: "执行节点管理", icon: CpuIcon },
{ id: "chat-archive", label: "聊天记录归档", icon: ArchiveIcon },
{ id: "roundtable-rooms", label: "会商并发", icon: UsersIcon },
{ id: "knowledge-retrieval", label: "知识检索", icon: SearchIcon },
{ id: "memory", label: "问答记忆", icon: BrainIcon },
{ id: "fileWriting", label: "文件流式兼容", icon: FileOutputIcon },
{ id: "uploads", label: "上传设置", icon: UploadCloudIcon },
]
: []),
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
: []),
{
id: "account",
label: "个人信息",
@@ -84,8 +123,8 @@ export function SettingsDialog(props: SettingsDialogProps) {
t.settings.sections.appearance,
t.settings.sections.memory,
t.settings.sections.tools,
t.settings.sections.about,
isAdmin,
t.settings.sections.about,
isAdmin,
],
);
return (
@@ -131,17 +170,45 @@ export function SettingsDialog(props: SettingsDialogProps) {
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
<div className="min-w-0 space-y-8 p-6">
{activeSection === "account" && <AccountSettingsPage />}
{activeSection === "appearance" && (
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
{activeSection === "server-diagnostics" && isAdmin && (
<ServerDiagnosticsPanel />
)}
{activeSection === "api-read-cache" && isAdmin && (
<ApiReadCacheSettingsPage />
)}
{activeSection === "worker-pool" && isAdmin && (
<WorkerPoolSettingsPage />
)}
{activeSection === "chat-archive" && isAdmin && (
<ChatArchiveSettingsPage />
)}
{activeSection === "appearance" && (
<AppearanceSettingsPage
onClose={() => props.onOpenChange?.(false)}
/>
)}
{activeSection === "conversation-context" && isAdmin && (
<ContextWindowSettingsPage />
)}
{activeSection === "roundtable-rooms" && isAdmin && (
<RoundtableRoomSettingsPage />
)}
{activeSection === "knowledge-retrieval" && isAdmin && (
<KnowledgeRetrievalSettingsPage />
)}
{activeSection === "memory" && isAdmin && (
<MemoryRuntimeSettingsPage />
)}
{activeSection === "fileWriting" && isAdmin && (
<WriteFileCompatibilitySettingsPage />
)}
{activeSection === "uploads" && isAdmin && (
<UploadSettingsPage />
)}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "encoder" && isAdmin && (
<LocalEncoderSettingsCard />
)}
{activeSection === "memory" && isAdmin && (
<MemoryRuntimeSettingsPage />
)}
{activeSection === "uploads" && isAdmin && (
<UploadSettingsPage />
)}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
{activeSection === "about" && <AboutSettingsPage />}
</div>
</ScrollArea>
@@ -558,6 +558,7 @@ function SkillSettingsList({
<TagSearchBar
targetType="skill"
onSearchChange={setSearch}
searchPlaceholder="搜索名称 / 中文名 / 描述 / 详情 / 标签,多关键词空格分隔"
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
@@ -36,8 +36,10 @@ import { SettingsSection } from "./settings-section";
export function ToolSettingsPage() {
const { t } = useI18n();
const { config, isLoading, error } = useMCPConfig();
const isAdmin = getStoredAuth()?.system_role === "admin";
// MCP configuration is an admin-only API. Keep ordinary users from making
// a request that will always return 403, and show an explicit access state.
const { config, isLoading, error } = useMCPConfig(isAdmin);
const isStaticMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
const [editOpen, setEditOpen] = useState(false);
@@ -74,7 +76,14 @@ export function ToolSettingsPage() {
title={t.settings.tools.title}
description={t.settings.tools.description}
>
{isLoading ? (
{!isAdmin ? (
<div
role="status"
className="text-muted-foreground rounded-md border p-6 text-center text-sm"
>
MCP 工具由管理员统一配置和管理。如需调整,请联系管理员。
</div>
) : isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : error ? (
<div className="text-destructive text-sm">Error: {error.message}</div>
@@ -102,19 +111,23 @@ export function ToolSettingsPage() {
</div>
)}
<MCPServerEditDialog
open={editOpen}
onOpenChange={setEditOpen}
mode={editMode}
originalName={editingName}
initialServer={editingServer}
existingNames={existingNames}
/>
{isAdmin && (
<MCPServerEditDialog
open={editOpen}
onOpenChange={setEditOpen}
mode={editMode}
originalName={editingName}
initialServer={editingServer}
existingNames={existingNames}
/>
)}
<DeleteConfirmDialog
target={deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
/>
{isAdmin && (
<DeleteConfirmDialog
target={deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
/>
)}
</SettingsSection>
);
}
@@ -0,0 +1,166 @@
import { Link } from "react-router-dom";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
type Settings = {
enabled: boolean; transport: "ssh" | "socket"; host: string; port: number;
username: string; host_key_sha256: string; source_container: string; network: string;
python_executable: string; working_dir: string;
database_pool_size: number; postgres_pool_size: number; database_connection_budget: number;
desired: number; concurrency: number; queue_limit: number; queue_timeout: number;
cpu: number; memory_mb: number; total_cpu: number; total_memory_mb: number;
password_set: boolean;
};
type Node = { name: string; ready: boolean; active: number; capacity?: number; draining: boolean; error?: string };
type Discovery = { cpus: number; memory_mb: number; note: string; containers: { name: string; networks: string[] }[]; recommendation: Partial<Settings> & { desired: number } };
type Status = { settings: Settings; nodes: Node[]; error: string | null };
async function call<T>(path = "", method = "GET", body?: unknown): Promise<T> {
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/worker-pool${path}`, {
method, headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) throw new Error(typeof data.detail === "string" ? data.detail : `请求失败(${response.status})`);
return data as T;
}
export function WorkerPoolSettingsPage() {
const [status, setStatus] = useState<Status>();
const [draft, setDraft] = useState<Settings>();
const [password, setPassword] = useState("");
const [discovery, setDiscovery] = useState<Discovery>();
const [fingerprint, setFingerprint] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const lastServerPython = useRef<string | undefined>(undefined);
useEffect(() => {
let disposed = false;
const refresh = async () => {
try {
const value = await call<Status>();
if (!disposed) {
const previousPython = lastServerPython.current;
setStatus(value);
setDraft((old) => {
if (!old) return value.settings;
if (previousPython !== undefined && old.python_executable === previousPython) {
return { ...old, python_executable: value.settings.python_executable };
}
return old;
});
lastServerPython.current = value.settings.python_executable;
setError("");
}
} catch (e) { if (!disposed) setError(e instanceof Error ? e.message : "读取失败"); }
};
void refresh();
const timer = setInterval(() => void refresh(), 5000);
return () => { disposed = true; clearInterval(timer); };
}, []);
const update = (key: keyof Settings, value: string | number | boolean) => setDraft((old) => old ? { ...old, [key]: value } : old);
const save = async () => {
if (!draft) return;
setBusy(true);
try {
const { password_set: _passwordSet, ...settings } = draft;
const value = await call<Status>("", "PUT", { ...settings, password: password || null });
setStatus(value); setDraft(value.settings); setPassword("");
toast.success("设置已保存,系统正在调整执行节点");
} catch (e) { toast.error(e instanceof Error ? e.message : "保存失败"); }
finally { setBusy(false); }
};
const inspectServer = async () => {
if (!draft) return;
setBusy(true);
try {
const { password_set: _passwordSet, ...settings } = draft;
const result = await call<Discovery>("/discover", "POST", { ...settings, password: password || null });
setDiscovery(result);
toast.success("服务器资源已读取,请查看推荐配置");
} catch (e) { toast.error(e instanceof Error ? e.message : "检测失败"); }
finally { setBusy(false); }
};
const readFingerprint = async () => {
if (!draft) return;
setBusy(true);
try {
const result = await call<{ fingerprint: string }>("/fingerprint", "POST", { host: draft.host, port: draft.port });
setFingerprint(result.fingerprint);
} catch (e) { toast.error(e instanceof Error ? e.message : "读取失败"); }
finally { setBusy(false); }
};
const test = async () => {
setBusy(true);
try { toast.success((await call<{ message: string }>("/test", "POST")).message); }
catch (e) { toast.error(e instanceof Error ? e.message : "连接失败"); }
finally { setBusy(false); }
};
return <section className="space-y-5">
<Button variant="outline" asChild><Link to="/page/workspace/admin/server-diagnostics" target="_blank" rel="noreferrer">服务器诊断与日志 ↗</Link></Button>
<div><h3 className="text-lg font-semibold">执行节点管理</h3>
<p className="text-muted-foreground text-sm">在同一台 Docker 服务器上动态扩容普通问答和智能体问答。定时任务、会商、研究和工作流继续由主服务统一执行。</p></div>
{error && <p role="alert" className="text-destructive">{error}</p>}
{!draft ? <p>正在读取配置…</p> : <>
<label className="flex items-center gap-3"><Switch checked={draft.enabled} disabled={busy} onCheckedChange={(v) => update("enabled", v)} />启用在线 Worker 池</label>
<label className="block space-y-1"><span>服务器连接方式</span><select className="w-full rounded border bg-background p-2" value={draft.transport} onChange={(e) => update("transport", e.target.value)}>
<option value="ssh">SSH 账号密码(主容器与 Worker 在同一 Docker 网络)</option>
<option value="socket">本机 Docker Socket(需要预先挂载)</option>
</select></label>
<div className="grid gap-4 sm:grid-cols-2">
{draft.transport === "ssh" && <>
{([ ["host", "服务器 IP / 主机名"], ["username", "SSH 账号"], ["host_key_sha256", "SSH 主机密钥指纹(SHA256:…)"] ] as const).map(([key, label]) => <label className="space-y-1" key={key}><span>{label}</span><Input value={draft[key]} onChange={(e) => { update(key, e.target.value); setFingerprint(""); setDiscovery(undefined); }} /></label>)}
<label className="space-y-1"><span>SSH 端口</span><Input type="number" min={1} max={65535} value={draft.port} onChange={(e) => update("port", Number(e.target.value))} /></label>
<label className="space-y-1"><span>SSH 密码{draft.password_set ? "(已设置,留空保留)" : ""}</span><Input type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} /></label>
</>}
{([ ["source_container", "主服务容器名称"], ["network", "Docker 网络名称"], ["python_executable", "镜像内 Python 路径"], ["working_dir", "镜像内后端目录"] ] as const).map(([key, label]) => <label className="space-y-1" key={key}><span>{label}</span><Input value={draft[key]} onChange={(e) => update(key, e.target.value)} /></label>)}
{([ ["desired", "期望 Worker 数量", 0, 32], ["concurrency", "每节点同时执行数", 1, 32], ["queue_limit", "每节点最大排队数", 1, 1024], ["queue_timeout", "最长排队秒数", 1, 300], ["cpu", "每节点 CPU 核数", 0.25, 64], ["memory_mb", "每节点内存 MB", 512, 131072], ["total_cpu", "Worker 总 CPU 预算", 0.25, 512], ["total_memory_mb", "Worker 总内存预算 MB", 512, 1048576] ] as const).map(([key, label, min, max]) => <label className="space-y-1" key={key}><span>{label}</span><Input type="number" min={min} max={max} step={key.includes("cpu") ? 0.25 : 1} value={draft[key]} onChange={(e) => update(key, Number(e.target.value))} /></label>)}
</div>
<div className="rounded border p-4 space-y-3">
<h4 className="font-medium">连接检测与资源推荐</h4>
{draft.transport === "ssh" && <>
<p className="text-sm text-muted-foreground">账号和密码用于登录。主机指纹用来识别服务器,不是密码。先读取并确认指纹,再连接检测。</p>
<Button variant="outline" disabled={busy || !draft.host} onClick={() => void readFingerprint()}>读取服务器指纹</Button>
{fingerprint && <div className="space-y-2"><p className="break-all text-sm">本次读取:{fingerprint}</p>
{draft.host_key_sha256 && draft.host_key_sha256 !== fingerprint && <p className="text-destructive text-sm">指纹与已保存的服务器不同,请核实服务器是否更换。</p>}
<Button variant="outline" disabled={busy} onClick={() => { update("host_key_sha256", fingerprint); setFingerprint(""); }}>确认使用此服务器指纹</Button></div>}
</>}
<Button variant="outline" disabled={busy || (draft.transport === "ssh" && !draft.host_key_sha256)} onClick={() => void inspectServer()}>连接并读取资源</Button>
{discovery && <div className="space-y-3 text-sm">
<p>服务器:{discovery.cpus} 核 CPU · {(discovery.memory_mb / 1024).toFixed(1)} GB 内存</p>
<p>{discovery.note}</p>
<label className="block space-y-1"><span>选择当前系统的主服务容器</span><select className="w-full rounded border bg-background p-2" value={draft.source_container} onChange={(e) => {
const container = discovery.containers.find((c) => c.name === e.target.value);
update("source_container", e.target.value); if (container?.networks.length === 1) update("network", container.networks[0]!);
}}><option value="">请选择</option>{discovery.containers.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}</select></label>
<label className="block space-y-1"><span>主服务与 Worker 共用网络</span><select className="w-full rounded border bg-background p-2" value={draft.network} onChange={(e) => update("network", e.target.value)}><option value="">请选择</option>{discovery.containers.find((c) => c.name === draft.source_container)?.networks.map((n) => <option key={n} value={n}>{n}</option>)}</select></label>
{discovery.recommendation.desired > 0 ? <>
<p>推荐起步:{discovery.recommendation.desired} 个 Worker,每个 {discovery.recommendation.cpu} 核 / {discovery.recommendation.memory_mb} MB,同时执行 {discovery.recommendation.concurrency} 个问答,超出后排队。</p>
<Button variant="outline" disabled={busy || !!status?.nodes.length} onClick={() => setDraft((old) => old ? { ...old, ...discovery.recommendation } : old)}>填入推荐配置</Button>
{!!status?.nodes.length && <p>修改单节点资源前,先将期望数量设为 0,保存并等待现有节点完成任务退出。</p>}
</> : <p className="text-destructive">预留资源后不足以启动节点,请调整服务器资源或其他服务的配额。</p>}
</div>}
<p className="text-sm text-muted-foreground">检测不会创建容器。保存并应用后生效;账号密码加密保存到服务器持久化目录,重启后保留设置并按期望数量恢复节点。</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{([ ["database_pool_size", "每节点业务数据库连接上限", 1, 32], ["postgres_pool_size", "每节点检查点 / Store 各自连接上限", 1, 32], ["database_connection_budget", "全部 Worker 数据库连接预算", 3, 1024] ] as const).map(([key, label, min, max]) => <label className="space-y-1" key={key}><span>{label}</span><Input type="number" min={min} max={max} value={draft[key]} onChange={(e) => update(key, Number(e.target.value))} /></label>)}
</div>
<p className="text-muted-foreground text-sm">Worker 连接额度合计:{draft.desired * (draft.database_pool_size + 2 * draft.postgres_pool_size)}。预算需从数据库总连接数中扣除主服务及其他应用所需连接。</p>
<p className="text-muted-foreground text-sm">先读取指纹、检测资源并填写配置,再启用保存。资源预算需为主服务、数据库及操作系统保留余量。修改单节点配置前,将数量设为 0 并等待节点退出。</p>
<p className="text-muted-foreground text-sm">创建 Worker 前,系统会在所选主容器内验证 Python 3.12+、uvicorn 和 Worker 入口;默认路径不可用时会自动查找并保存可用路径。无需使用宿主机 Python 或联网下载。</p>
<p className="text-muted-foreground text-sm">主机密钥指纹可通过上方按钮读取并确认。账号需要运行 Docker 的权限。本机 Socket 需在主容器挂载 /var/run/docker.sock:/var/run/docker.sock;Worker 不继承该挂载。源容器的数据目录和配置必须已持久化挂载;聊天存储支持 PostgreSQL 或 SQLite,沿用现有配置;SQLite 必须共享本机数据目录。控制器私有目录需要独立持久化,不能与 Worker 共享。</p>
<div className="flex gap-3"><Button disabled={busy || !!error} onClick={() => void save()}>保存并应用</Button><Button variant="outline" disabled={busy || !!error} onClick={() => void test()}>测试已保存的连接</Button></div>
</>}
{status?.error && <p role="alert" className="text-destructive">{status.error}</p>}
<div className="space-y-2"><h4 className="font-medium">执行节点(每 5 秒更新)</h4>
{status?.nodes.length === 0 && <p className="text-muted-foreground text-sm">暂无节点。启用后按期望数量创建。</p>}
{status?.nodes.map((node) => <div className="rounded border p-3 text-sm" key={node.name}><div className="flex justify-between gap-2"><span>{node.name}</span><span>{node.draining ? "停止接单,等待完成" : node.ready ? "就绪" : "等待就绪"}</span></div><p>执行与排队:{node.active} / 同时执行额度:{node.capacity ?? "—"}</p>{node.error && <p className="text-destructive">{node.error}</p>}</div>)}
</div>
</section>;
}
Loaded 100 of 1201 files, more files were not shown because too many files have changed in this diff. Show more