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
485 changed files with 57001 additions and 10460 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
+23 -4
View File
@@ -91,6 +91,8 @@ hermes-agent-main/
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/
@@ -101,7 +103,13 @@ offline-backend-20260512/backend/tests/*
!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
@@ -121,10 +129,10 @@ 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_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
@@ -244,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/
@@ -259,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`。
+2 -1
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.
@@ -104,7 +105,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **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
- **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 提示。
+1 -1
View File
@@ -138,7 +138,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **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`。
+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,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/应用锁路径。
+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 改回并行
+16 -2
View File
@@ -76,9 +76,23 @@
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<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="/runtime-config.js" data-runtime-config></script>
<script src="%BASE_URL%runtime-config.js" data-runtime-config></script>
</head>
<body>
<div id="root"></div>
+3
View File
@@ -11,11 +11,14 @@
"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",
+8 -3
View File
@@ -9,13 +9,18 @@ const require = createRequire(import.meta.url);
// 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 testNames = ["login-entry", "session-validation"];
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: testNames.map((name) => `src/core/auth/${name}.test.ts`),
entryPoints: testEntries,
outdir: outputDirectory,
outExtension: { ".js": ".mjs" },
bundle: true,
@@ -26,7 +31,7 @@ try {
stop();
}
const result = spawnSync(process.execPath, ["--test", ...testNames.map((name) => path.join(outputDirectory, `${name}.test.mjs`))], {
const result = spawnSync(process.execPath, ["--test", ...Object.keys(testEntries).map((name) => path.join(outputDirectory, `${name}.mjs`))], {
cwd: root,
stdio: "inherit",
});
@@ -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;
+5 -7
View File
@@ -12,6 +12,7 @@ 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 {
@@ -34,7 +35,6 @@ import {
const LandingPage = lazy(() => import("./pages/LandingPage"));
const LoginPage = lazy(() => import("./pages/LoginPage"));
const RegisterPage = lazy(() => import("./pages/RegisterPage"));
const PageRoutes = lazy(() => import("./pages/PageRoutes"));
const PublicScheduledTaskPage = lazy(() => import("./pages/PublicScheduledTaskPage"));
const PublicScheduledTaskFullscreenPage = lazy(() =>
@@ -93,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);
@@ -193,12 +197,6 @@ export function App() {
positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />
}
/>
<Route
path="/register"
element={
positionStandaloneMode ? standaloneLoginRedirect : <RegisterPage />
}
/>
<Route path="/landing" element={<LandingPage />} />
<Route path="/share/:shareCode" element={<PublicSharePage />} />
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
@@ -599,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}>
@@ -672,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}`}
/>
)}
@@ -680,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}`}
/>
)}
@@ -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);
}}
/>
@@ -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}
@@ -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"
@@ -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);
@@ -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);
@@ -171,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) &&
@@ -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 && (
@@ -72,9 +72,7 @@ import { useI18n } from "@/core/i18n/hooks";
import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import {
REASONING_EFFORTS,
resolveModelReasoningEffort,
type ReasoningEffort,
type StoredReasoningEffort,
} from "@/core/models/reasoning-effort";
import type { Model } from "@/core/models/types";
@@ -215,9 +213,6 @@ export function InputBox({
knowledgeRetrieval?: {
mode: KnowledgeRetrievalMode;
smartAvailable: boolean;
smartAvailabilityReason: string;
/** The administrator policy used only when a new conversation is created. */
smartIsDefault: boolean;
onModeChange: (mode: KnowledgeRetrievalMode) => void;
};
/** High-value starter questions derived from the currently selected spaces. */
@@ -299,6 +294,7 @@ export function InputBox({
supportsReasoningEffort,
context.reasoning_effort,
context.model_name !== nextModelName,
fallbackModel.default_reasoning_effort,
);
if (
@@ -344,26 +340,11 @@ export function InputBox({
resolveModelReasoningEffort(
supportReasoningEffort,
context.reasoning_effort,
false,
selectedModel?.default_reasoning_effort,
),
[context.reasoning_effort, supportReasoningEffort],
[context.reasoning_effort, selectedModel?.default_reasoning_effort, supportReasoningEffort],
);
const reasoningEffortIndex = Math.max(
0,
REASONING_EFFORTS.indexOf(selectedReasoningEffort ?? "xhigh"),
);
const reasoningEffortThumbLeft =
reasoningEffortIndex === 0
? "0.5rem"
: reasoningEffortIndex === 1
? "50%"
: "calc(100% - 0.5rem)";
const reasoningEffortLabel =
selectedReasoningEffort === "low"
? t.inputBox.reasoningEffortLow
: selectedReasoningEffort === "medium"
? t.inputBox.reasoningEffortMedium
: t.inputBox.reasoningEffortXHigh;
const handleModelSelect = useCallback(
(model_name: string) => {
const model = modelOptions.find((m) => m.name === model_name);
@@ -378,6 +359,7 @@ export function InputBox({
model.supports_reasoning_effort ?? false,
context.reasoning_effort,
model_name !== context.model_name,
model.default_reasoning_effort,
),
execution_profile: model.execution_profile,
});
@@ -400,6 +382,8 @@ export function InputBox({
reasoning_effort: resolveModelReasoningEffort(
ordinaryModel?.supports_reasoning_effort ?? supportReasoningEffort,
context.reasoning_effort,
false,
ordinaryModel?.default_reasoning_effort,
),
execution_profile: undefined,
});
@@ -439,16 +423,6 @@ export function InputBox({
});
}, [context, onContextChange]);
const handleReasoningEffortSelect = useCallback(
(effort: ReasoningEffort) => {
onContextChange?.({
...context,
reasoning_effort: effort,
});
},
[onContextChange, context],
);
const handleWritingModeToggle = useCallback(() => {
onContextChange?.({
...context,
@@ -539,7 +513,6 @@ export function InputBox({
selectedModel?.supports_thinking,
selectedModel?.execution_profile,
selectedReasoningEffort,
status,
threadId,
],
);
@@ -562,6 +535,21 @@ export function InputBox({
form?.requestSubmit();
}, []);
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") {
@@ -736,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}
@@ -756,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)}
@@ -1267,61 +1251,6 @@ export function InputBox({
</DropdownMenuGroup>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
{!frontendRagActive &&
supportReasoningEffort &&
context.mode !== "flash" && (
<PromptInputActionMenu>
<PromptInputActionMenuTrigger
aria-label={`${t.inputBox.reasoningEffortSelect}: ${reasoningEffortLabel}`}
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="flex items-center gap-1.5 text-xs font-medium">
<SparklesIcon className="size-3" />
<span className="text-foreground">
{reasoningEffortLabel}
</span>
</div>
</PromptInputActionMenuTrigger>
<PromptInputActionMenuContent className="w-56 p-3">
<div className="flex items-center justify-between gap-3 px-1">
<span className="text-xs text-muted-foreground">
{t.inputBox.reasoningEffortSelect}
</span>
<span className="text-sm font-semibold text-violet-600 dark:text-violet-300">
{reasoningEffortLabel}
</span>
</div>
<div className="mt-2 px-1">
<div className="relative h-9 w-full">
<input
type="range"
aria-label={`${t.inputBox.reasoningEffortSelect}: ${selectedModel?.display_name || selectedModel?.name || ""}`}
aria-valuetext={reasoningEffortLabel}
className="peer absolute inset-x-0 -inset-y-1 z-10 h-auto w-full cursor-grab touch-none opacity-0 active:cursor-grabbing"
value={reasoningEffortIndex}
min={0}
max={2}
step={1}
onChange={(event) => {
const effort =
REASONING_EFFORTS[Number(event.currentTarget.value)];
if (effort) handleReasoningEffortSelect(effort);
}}
/>
<div className="pointer-events-none absolute inset-x-2 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-gradient-to-r from-sky-400 via-violet-500 to-fuchsia-500 peer-focus-visible:ring-2 peer-focus-visible:ring-violet-500/70 peer-focus-visible:ring-offset-2" />
<div
className="pointer-events-none absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-violet-600 shadow-md peer-active:scale-110 dark:border-slate-800"
style={{ left: reasoningEffortThumbLeft }}
/>
</div>
</div>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
)}
</PromptInputTools>
<PromptInputTools className={cn("ml-auto shrink-0 gap-1.5", compact && "gap-2")}>
{!frontendRagActive && agentSelector && (
@@ -1338,8 +1267,8 @@ export function InputBox({
<Tooltip
content={
knowledgeRetrieval.mode === "smart_reasoning"
? "智能检索已开启:直接提问即可,系统会检索原文分片并沿 Wiki 线索扩展背景资料"
: knowledgeRetrieval.smartAvailabilityReason
? "智能检索:综合原文与 Wiki 线索,提供更全面的背景和解释"
: "经典检索:细粒度召回原文,优先回答具体事实与细节"
}
>
<DropdownMenuTrigger asChild>
@@ -1374,6 +1303,21 @@ export function InputBox({
<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}
@@ -1383,47 +1327,15 @@ export function InputBox({
>
<BrainCircuitIcon className="mt-0.5 size-4" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
智能检索
{knowledgeRetrieval.smartIsDefault ? "(新对话默认)" : ""}
</div>
<div className="text-sm font-medium">智能检索</div>
<div className="mt-0.5 text-xs text-muted-foreground">
先检索原文分片,再按命中线索查阅并扩展 Wiki;资料仅作为回答背景
综合原文分片与 Wiki 目录、页面和关联线索扩展检索,适合全面梳理背景、关联关系和综合解释
</div>
</div>
{knowledgeRetrieval.mode === "smart_reasoning" && (
<CheckIcon className="mt-0.5 size-4 text-emerald-600" />
)}
</DropdownMenuItem>
<DropdownMenuItem
className="min-h-14 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">
经典检索
{knowledgeRetrieval.smartIsDefault ? "(候选)" : "(新对话默认)"}
</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>
<DropdownMenuSeparator />
<p
className={cn(
"px-2 py-1 text-xs leading-5",
knowledgeRetrieval.smartAvailable
? "text-muted-foreground"
: "text-amber-600 dark:text-amber-300",
)}
>
{knowledgeRetrieval.smartAvailabilityReason}
</p>
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -119,7 +119,11 @@ 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)))
File diff suppressed because it is too large. Load diff
@@ -13,6 +13,8 @@ import {
} from "@/components/ui/sheet";
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";
@@ -142,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);
}, []);
@@ -7,6 +7,9 @@ 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";
@@ -57,6 +60,11 @@ 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.
@@ -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>
);
}
@@ -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>
);
}
@@ -48,7 +48,7 @@ export function KnowledgeRetrievalSettingsPage() {
知识检索
</h3>
<p className="text-muted-foreground text-sm">
控制普通知识库问答是否默认使用智能检索。该能力默认关闭,经典检索模式始终保留为候选。
配置普通知识库可用的检索模式。新对话始终默认使用经典检索。
</p>
</div>
@@ -56,9 +56,7 @@ export function KnowledgeRetrievalSettingsPage() {
<div className="space-y-1">
<div className="font-medium">普通知识库智能推理检索</div>
<p className="text-muted-foreground text-sm">
开启后,新对话默认使用智能检索:先召回原文分片,再根据命中线索查阅并扩展
Wiki,把知识库资料作为回答背景。已有对话继续使用各自保存的模式,用户仍可在
输入框的检索模式菜单中切换到经典检索。
开启后,用户可在问答输入框中从经典检索切换到智能检索;经典检索仍位于首项并作为默认模式。
</p>
</div>
{isLoading ? (
@@ -109,12 +107,6 @@ export function KnowledgeRetrievalSettingsPage() {
</Button>
</div>
)}
<p className="text-muted-foreground text-xs">
用户只需直接提问,无需填写“先检索什么、再检索什么”的固定模板;系统不会向输入框
注入检索提示词。智能检索将检索资料与模型原有能力结合后回答,不强制生成参考文献编号。
关闭后不会删除历史对话;已保存为智能检索的线程需要切回经典模式后才能继续发送。
</p>
</section>
);
}
@@ -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,19 +1,21 @@
"use client";
import {
BellIcon,
BrainIcon,
CpuIcon,
FileOutputIcon,
PaletteIcon,
import {
BellIcon,
BrainIcon,
CpuIcon,
DatabaseIcon,
ArchiveIcon,
FileOutputIcon,
PaletteIcon,
SearchIcon,
ShieldCheckIcon,
ShrinkIcon,
SparklesIcon,
UploadCloudIcon,
UserIcon,
WrenchIcon,
} from "lucide-react";
SparklesIcon,
UploadCloudIcon,
UserIcon,
UsersIcon,
WrenchIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
@@ -25,32 +27,39 @@ import {
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 { AuthenticationSettingsPage } from "@/components/workspace/settings/authentication-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { ContextWindowSettingsPage } from "@/components/workspace/settings/context-window-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 { useLiveAccount } from "@/core/auth/use-live-account";
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"
| "authentication"
| "appearance"
| "conversation-context"
| "knowledge-retrieval"
| "memory"
| "fileWriting"
| "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;
@@ -58,9 +67,8 @@ type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
export function SettingsDialog(props: SettingsDialogProps) {
const { defaultSection = "appearance", ...dialogProps } = props;
const { t } = useI18n();
const live = useLiveAccount();
const isAdmin = getStoredAuth()?.system_role === "admin";
const { t } = useI18n();
const isAdmin = getStoredAuth()?.system_role === "admin";
const [activeSection, setActiveSection] =
useState<SettingsSection>(defaultSection);
@@ -86,20 +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: "conversation-context", label: "上下文管理", icon: ShrinkIcon },
{ id: "knowledge-retrieval", label: "知识检索", icon: SearchIcon },
{ id: "memory", label: "问答记忆", icon: BrainIcon },
{ id: "fileWriting", label: "文件流式兼容", icon: FileOutputIcon },
{ id: "uploads", label: "上传设置", icon: UploadCloudIcon },
{ id: "authentication", label: "登录与注册", icon: ShieldCheckIcon },
]
: []),
{ 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: "个人信息",
@@ -111,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 (
@@ -158,36 +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 === "authentication" && isAdmin && (
<AuthenticationSettingsPage
key={live.userId}
{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 === "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 === "conversation-context" && isAdmin && (
<ContextWindowSettingsPage />
)}
{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 === "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>;
}
@@ -35,11 +35,10 @@ const NAV_ITEM_CLASS =
"dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25 dark:data-[active=true]:text-primary-foreground";
export function WorkspaceNavStudio() {
const { notebooks, isLoading, isError } = useStudioNotebooks();
const { pathname } = useLocation();
const queryClient = useQueryClient();
const [open, setOpen] = useState(true);
const [open, setOpen] = useState(false);
const { notebooks, isLoading, isError, token } = useStudioNotebooks(open);
const [dialogOpen, setDialogOpen] = useState(false);
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
@@ -65,11 +64,6 @@ export function WorkspaceNavStudio() {
}
};
// 加载中、token 失败、空列表都不渲染整段,避免在 token 未确认前先闪一段工作室 UI 再消失。
if (isLoading || isError || notebooks.length === 0) {
return null;
}
const SIDEBAR_PREVIEW_COUNT = 2;
const preview = notebooks.slice(0, SIDEBAR_PREVIEW_COUNT);
@@ -96,12 +90,29 @@ export function WorkspaceNavStudio() {
<SidebarMenuButton
className={cn(NAV_ITEM_CLASS)}
onClick={() => { setDialogOpen(true); setError(""); setName(""); }}
disabled={!token}
>
<Plus className="shrink-0" />
<span>新建空间</span>
</SidebarMenuButton>
</SidebarMenuItem>
{isLoading && (
<SidebarMenuItem>
<div className="px-3 py-2 text-xs text-sidebar-foreground/60">正在连接工作室…</div>
</SidebarMenuItem>
)}
{isError && (
<SidebarMenuItem>
<div className="px-3 py-2 text-xs text-sidebar-foreground/60">工作室暂不可用</div>
</SidebarMenuItem>
)}
{!isLoading && !isError && notebooks.length === 0 && (
<SidebarMenuItem>
<div className="px-3 py-2 text-xs text-sidebar-foreground/60">暂无工作室空间</div>
</SidebarMenuItem>
)}
{/* 空间列表(最多展示 SIDEBAR_PREVIEW_COUNT 条) */}
{preview.map((nb) => (
<SidebarMenuItem key={nb.id}>
@@ -0,0 +1,81 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export type AuditRetentionSource =
| "llm_metrics"
| "tool_metrics"
| "run_summaries"
| "leaderboard_snapshots";
export interface AuditRetentionSweep {
started_at: string;
finished_at: string;
deleted: Partial<Record<AuditRetentionSource, number>>;
stopped_by_admin: boolean;
}
export interface AuditRetentionStatus {
status: "scheduled" | "running" | "paused" | "error" | string;
display_status: "scheduled" | "running" | "paused" | "pausing" | "error" | string;
paused: boolean;
pause_requested: boolean;
current_source: AuditRetentionSource | null;
round: number;
max_rounds: number;
deleted: Partial<Record<AuditRetentionSource, number>>;
retention_days: number;
interval_seconds: number;
startup_delay_seconds: number;
batch_size: number;
max_sweep_seconds: number;
started_at: string | null;
finished_at?: string | null;
next_run_at: number | null;
last_sweep: AuditRetentionSweep | null;
last_error?: string | null;
}
const QUERY_KEY = ["admin-audit-retention"] as const;
const base = () => `${getBackendBaseURL()}/api/admin/audit-retention`;
async function parse<T>(response: Response, action: string): Promise<T> {
if (!response.ok) {
const error = (await response.json().catch(() => ({}))) as { detail?: string };
throw new Error(error.detail ?? `${action}失败 (HTTP ${response.status})`);
}
return response.json() as Promise<T>;
}
export async function getAuditRetentionStatus(): Promise<AuditRetentionStatus> {
return parse(await apiFetch(base()), "读取清理状态");
}
export async function setAuditRetentionPaused(paused: boolean): Promise<AuditRetentionStatus> {
return parse(
await apiFetch(`${base()}/control`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paused }),
}),
paused ? "暂停清理" : "恢复清理",
);
}
export function useAuditRetentionStatus() {
return useQuery({
queryKey: QUERY_KEY,
queryFn: getAuditRetentionStatus,
refetchInterval: (query) => (query.state.data?.paused ? false : 2000),
refetchOnWindowFocus: false,
});
}
export function useSetAuditRetentionPaused() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: setAuditRetentionPaused,
onSuccess: (status) => queryClient.setQueryData(QUERY_KEY, status),
});
}
@@ -0,0 +1,53 @@
/**
* Shared client-side bounds for administrator history queries. The server is
* authoritative, but keeping the controls inside this window avoids sending
* expensive requests that will be clipped anyway.
*/
export const ADMIN_QUERY_WINDOW_DAYS = 30;
function atStartOfLocalDay(date: Date): Date {
const result = new Date(date);
result.setHours(0, 0, 0, 0);
return result;
}
function atEndOfLocalDay(date: Date): Date {
const result = new Date(date);
result.setHours(23, 59, 0, 0);
return result;
}
function pad(value: number): string {
return String(value).padStart(2, "0");
}
/** Format a local date in the value format accepted by `<input type="date">`. */
export function formatLocalDateInput(date: Date): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
/** Format a local datetime in the value format accepted by datetime-local. */
export function formatLocalDateTimeInput(date: Date): string {
return `${formatLocalDateInput(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
export function getAdminQueryDateBounds(now = new Date()) {
const latest = atStartOfLocalDay(now);
const earliest = new Date(latest);
earliest.setDate(earliest.getDate() - (ADMIN_QUERY_WINDOW_DAYS - 1));
return {
earliest,
latest,
min: formatLocalDateInput(earliest),
max: formatLocalDateInput(latest),
};
}
/** Full-day defaults for metrics filters, covering the latest 30 calendar days. */
export function getAdminQueryDateTimeBounds(now = new Date()) {
const { earliest, latest } = getAdminQueryDateBounds(now);
return {
min: formatLocalDateTimeInput(atStartOfLocalDay(earliest)),
max: formatLocalDateTimeInput(atEndOfLocalDay(latest)),
};
}
+104 -7
View File
@@ -5,6 +5,7 @@ import { OptType, writeYLog } from "@/core/log/y-log";
import type {
Agent,
AgentSelectorResponse,
AgentSidebarItem,
AgentSort,
CreateAgentRequest,
PaginatedAgents,
@@ -100,20 +101,55 @@ export async function listAgentsPaginated(
return result;
}
export async function listAllAgentsAdmin(): Promise<Agent[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents?admin=true`);
export interface ListAllAgentsAdminParams {
search?: string;
/** 1-based page index. */
page: number;
pageSize: number;
sort?: AgentSort;
/** Admin console filter: keep only hidden (true) / only visible (false)
* agents. Omit for both. */
hidden?: boolean;
}
/** Admin-only listing with server-side pagination + search — every agent
* (built-in + every user's agents + private-square entries). Caller must be
* ``system_role === "admin"`` or the backend will 403. */
export async function listAllAgentsAdminPaginated(
params: ListAllAgentsAdminParams,
): Promise<PaginatedAgents> {
const qs = new URLSearchParams({ admin: "true" });
if (params.search) qs.set("search", params.search);
qs.set("page", String(params.page));
qs.set("page_size", String(params.pageSize));
if (params.sort) qs.set("sort", params.sort);
if (params.hidden != null) qs.set("hidden", String(params.hidden));
const url = `/api/agents?${qs.toString()}`;
const res = await apiFetch(`${getBackendBaseURL()}${url}`);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to load agents: ${res.statusText}`);
}
const data = (await res.json()) as { agents: Agent[] };
const data = (await res.json()) as {
agents: Agent[];
total?: number;
page?: number;
page_size?: number;
};
const result: PaginatedAgents = {
agents: data.agents,
total: data.total ?? data.agents.length,
page: data.page ?? params.page,
pageSize: data.page_size ?? params.pageSize,
};
writeYLog({
optType: OptType.search,
optDetail: "CM助手-全量-智能体列表",
url: `/api/agents?admin=true`,
returnParam: data.agents,
url,
reqParam: { ...params },
returnParam: result,
});
return data.agents;
return result;
}
export async function getAgent(id: string): Promise<Agent> {
@@ -201,6 +237,14 @@ export async function getAgentSelector(): Promise<AgentSelectorResponse> {
return selector;
}
/** Lightweight top-three names for the workspace sidebar. */
export async function listSidebarAgents(): Promise<AgentSidebarItem[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/sidebar`);
if (!res.ok) throw new Error(`Failed to load sidebar agents: ${res.statusText}`);
const data = (await res.json()) as { agents?: AgentSidebarItem[] };
return Array.isArray(data.agents) ? data.agents : [];
}
export async function favoriteAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
method: "POST",
@@ -401,8 +445,39 @@ export async function setAgentPrivate(
return updated;
}
export async function setAgentFeatured(
/** Admin-only: hide an agent from every non-admin listing (gallery / 我的 /
* 广场 / selector / private square) or restore it. The agent itself stays
* fully usable — only catalog listings drop it. */
export async function setAgentHidden(
id: string,
hidden: boolean,
): Promise<Agent> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents/${id}/hidden`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hidden }),
},
);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(
err.detail ?? `Failed to set agent hidden: ${res.statusText}`,
);
}
const updated = (await res.json()) as Agent;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-智能体-设置隐藏",
url: `/api/agents/${id}/hidden`,
reqParam: { id, hidden },
returnParam: updated,
});
return updated;
}
export async function setAgentFeatured( id: string,
order: number | null,
): Promise<Agent> {
const res = await apiFetch(
@@ -443,6 +518,28 @@ export interface AgentSkillsSyncResult {
removals: AgentSkillsSyncRemoval[];
}
export interface AgentDirectorySyncResult {
scanned: number;
}
export async function syncAgentDirectory(): Promise<AgentDirectorySyncResult> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/sync-directory`, {
method: "POST",
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to rescan agent directory: ${res.statusText}`);
}
const result = (await res.json()) as AgentDirectorySyncResult;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-智能体-扫描目录",
url: `/api/agents/sync-directory`,
returnParam: result,
});
return result;
}
export async function adminSyncAgentSkills(): Promise<AgentSkillsSyncResult> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents/admin/sync-skills`,
+104 -43
View File
@@ -13,26 +13,29 @@ import {
getAgent,
getAgentSelector,
listAgents,
listSidebarAgents,
listAgentsPaginated,
listAllAgentsAdmin,
listAllAgentsAdminPaginated,
listPrivateAgents,
listPrivateAgentsPaginated,
pinAgent,
reorderFeaturedAgents,
setAgentFeatured,
setAgentHidden,
setAgentPrivate,
syncAgentDirectory,
unfavoriteAgent,
unpinAgent,
updateAgent,
} from "./api";
import type {
import type {
Agent,
AgentSort,
CreateAgentRequest,
PaginatedAgents,
UpdateAgentRequest,
} from "./types";
import { mergeConversationAgentOptions } from "./conversation-options";
} from "./types";
import { mergeConversationAgentOptions } from "./conversation-options";
/** A cached agent listing is either a plain ``Agent[]`` (legacy ``useAgents``)
* or the paginated ``{ agents, total, ... }`` envelope. Mutators patch both. */
@@ -66,12 +69,16 @@ function applyAgentToCache(
mapCachedAgents(queryClient, (a) => (a.id === next.id ? { ...a, ...next } : a));
}
export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
export function useAgents(
params: { search?: string; tagIds?: string[]; enabled?: boolean } = {},
) {
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const { data, isLoading, error } = useQuery({
queryKey: ["agents", search, [...tagIds].sort()],
queryFn: () => listAgents({ search, tagIds }),
enabled: params.enabled ?? true,
staleTime: 30_000,
placeholderData: keepPreviousData,
});
return { agents: data ?? [], isLoading, error };
@@ -137,29 +144,47 @@ export function useAgentsPaginated(params: {
}
/**
* Admin-only listing — returns every agent including unpublished agents owned
* by other users and private-square entries. Caller must be ``system_role
* === "admin"`` or the backend will 403.
* Admin-only paginated listing — every agent including unpublished agents
* owned by other users and private-square entries, sliced server-side.
* Caller must be ``system_role === "admin"`` or the backend will 403.
* Sort defaults to ``time_asc`` so the console keeps its historical
* oldest-first order.
*/
export function useAllAgentsAdmin(enabled: boolean) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", "admin", "all"],
queryFn: () => listAllAgentsAdmin(),
enabled,
staleTime: 30_000,
export function useAllAgentsAdminPaginated(params: {
search?: string;
sort?: AgentSort;
page: number;
pageSize: number;
/** Catalog filter: keep only hidden (true) / only visible (false). Omit for both. */
hidden?: boolean;
}) {
const { page, pageSize, hidden } = params;
const search = params.search ?? "";
const sort = params.sort ?? "time_asc";
const { data, isLoading, isFetching, error } = useQuery({
queryKey: ["agents", "admin", "paginated", search, sort, page, pageSize, hidden ?? "any"],
queryFn: () => listAllAgentsAdminPaginated({ search, sort, page, pageSize, hidden }),
placeholderData: keepPreviousData,
});
return { agents: data ?? [], isLoading, error };
return {
agents: data?.agents ?? [],
total: data?.total ?? 0,
isLoading,
isFetching,
error,
};
}
export function useAgent(
id: string | null | undefined,
{ enabled = true }: { enabled?: boolean } = {},
) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", id],
queryFn: () => getAgent(id!),
enabled: enabled && !!id,
});
export function useAgent(
id: string | null | undefined,
{ enabled = true, retry }: { enabled?: boolean; retry?: boolean | number } = {},
) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", id],
queryFn: () => getAgent(id!),
enabled: enabled && !!id,
retry,
});
return { agent: data ?? null, isLoading, error };
}
@@ -243,10 +268,11 @@ export function useToggleAgentPin() {
});
}
export function useAgentSelector() {
export function useAgentSelector({ enabled = true }: { enabled?: boolean } = {}) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", "selector"],
queryFn: () => getAgentSelector(),
enabled,
staleTime: 60_000,
});
return {
@@ -254,24 +280,34 @@ export function useAgentSelector() {
isLoading,
error,
};
}
/**
* Agent catalogue for project/ordinary conversations.
*
* It combines the admin-curated homepage list with every custom agent the
* current user can access. This deliberately does not expose unfeatured
* built-in workflow workers as standalone chat agents.
*/
export function useConversationAgentOptions() {
const featured = useAgentSelector();
const visible = useAgents();
return {
agents: mergeConversationAgentOptions(featured.agents, visible.agents),
isLoading: featured.isLoading || visible.isLoading,
error: featured.error ?? visible.error,
};
}
}
export function useSidebarAgents({ enabled = true }: { enabled?: boolean } = {}) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", "sidebar"],
queryFn: listSidebarAgents,
enabled,
staleTime: 60_000,
});
return { agents: data ?? [], isLoading, error };
}
/**
* Agent catalogue for project/ordinary conversations.
*
* It combines the admin-curated homepage list with every custom agent the
* current user can access. This deliberately does not expose unfeatured
* built-in workflow workers as standalone chat agents.
*/
export function useConversationAgentOptions() {
const featured = useAgentSelector();
const visible = useAgents();
return {
agents: mergeConversationAgentOptions(featured.agents, visible.agents),
isLoading: featured.isLoading || visible.isLoading,
error: featured.error ?? visible.error,
};
}
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
@@ -388,6 +424,31 @@ export function useSetAgentPrivate() {
});
}
/** Admin-only: hide / restore an agent in the catalog. Hidden agents vanish
* from every non-admin listing, so all agent queries are invalidated. */
export function useSetAgentHidden() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, hidden }: { id: string; hidden: boolean }) =>
setAgentHidden(id, hidden),
onSuccess: (data) => {
applyAgentToCache(queryClient, data);
invalidateAgentQueries(queryClient);
},
});
}
/** Admin gallery action: rescan `.deer-flow/agents/` into the catalog. */
export function useSyncAgentDirectory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: syncAgentDirectory,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
export function useAdminSyncAgentSkills() {
const queryClient = useQueryClient();
return useMutation({
+12
View File
@@ -42,6 +42,10 @@ export interface Agent {
owner_username?: string | null;
published: boolean;
is_private: boolean;
/** 管理员目录开关:true = 从所有非管理员列表(广场/我的/首页选择器/私密广场)
* 隐藏,但智能体本身仍可正常使用(既有会话、直接打开都不受影响)。
* 仅管理员智能体管理页(?admin=true)仍会列出。 */
hidden?: boolean;
/** Publish square (发布广场) the agent is filed under. "" = the default square. */
square_id?: string;
builtin: boolean;
@@ -51,6 +55,9 @@ export interface Agent {
model?: string | null;
tool_groups?: string[] | null;
skills?: string[] | null;
/** Chinese display names keyed by skill name. List responses fill this so
* gallery cards can render badges without loading the full skill catalog. */
skill_labels?: Record<string, string> | null;
/** Recommended questions (常用问题) shown on the agent chat home / sidebar.
* New agents store `{title, prompt}`; legacy agents may still return `string[]`.
* Read via {@link normalizeAgentQuestions}. */
@@ -77,6 +84,11 @@ export interface AgentSelectorResponse {
agents: Agent[];
}
export interface AgentSidebarItem {
id: string;
name: string;
}
/** Pagination envelope returned by the server-side paginated agent listings. */
export interface PaginatedAgents {
agents: Agent[];
+12 -1
View File
@@ -16,6 +16,7 @@ import {
type RunStreamCursorStore,
} from "./stream-cursor";
import { sanitizeRunStreamOptions } from "./stream-mode";
import type { RunHistoryObserver } from "../threads/background-history";
// Chrome 102 has AbortController but not AbortSignal.timeout (Chrome 103+).
// The LangGraph client calls it whenever timeoutMs is set, which is exactly
@@ -92,6 +93,7 @@ function streamEventId(event: unknown): string | undefined {
function createCompatibleClient(
isMock?: boolean,
getRunStreamCursors?: () => RunStreamCursorStore,
getHistoryObserver?: () => RunHistoryObserver | undefined,
): LangGraphClient {
const client = new LangGraphClient({
apiUrl: getLangGraphBaseURL(isMock),
@@ -121,6 +123,7 @@ function createCompatibleClient(
const readerCursors = getRunStreamCursors?.();
let runId: string | undefined;
let effectiveThreadId = threadId ?? undefined;
const historyObserver = getHistoryObserver?.()?.(threadId);
const sanitizedPayload = sanitizeRunStreamOptions(payload);
const originalOnRunCreated = sanitizedPayload?.onRunCreated;
const stream = originalRunStream(
@@ -131,12 +134,14 @@ function createCompatibleClient(
onRunCreated(params) {
runId = params.run_id;
effectiveThreadId = params.thread_id ?? effectiveThreadId;
if (effectiveThreadId) historyObserver?.created(effectiveThreadId, params.run_id);
originalOnRunCreated?.(params);
},
},
);
for await (const event of stream) {
historyObserver?.event(event);
if (readerCursors) {
rememberRunStreamCursor(
readerCursors,
@@ -152,6 +157,7 @@ function createCompatibleClient(
const originalJoinStream = client.runs.joinStream.bind(client.runs);
client.runs.joinStream = (async function* (threadId, runId, options) {
const readerCursors = getRunStreamCursors?.();
const historyObserver = getHistoryObserver?.()?.(threadId ?? null, runId);
const sanitizedOptions = sanitizeRunStreamOptions(options);
// The shared client preserves -1. Only a client owned by one message
// accumulator may resume its SDK-initiated reconnectOnMount retry: that
@@ -181,6 +187,7 @@ function createCompatibleClient(
const stream = originalJoinStream(threadId, runId, joinOptions);
for await (const event of stream) {
historyObserver?.event(event);
if (readerCursors) {
rememberRunStreamCursor(
readerCursors,
@@ -203,8 +210,12 @@ function createCompatibleClient(
*/
export function createStreamAPIClient(isMock?: boolean) {
let cursors: RunStreamCursorStore = new Map();
let historyObserver: RunHistoryObserver | undefined;
return {
client: createCompatibleClient(isMock, () => cursors),
client: createCompatibleClient(isMock, () => cursors, () => historyObserver),
setHistoryObserver(observer: RunHistoryObserver | undefined) {
historyObserver = observer;
},
getResumeCursor(threadId: string, runId: string): string | undefined {
return resolveRunStreamResumeCursor(cursors, threadId, runId, undefined);
},
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import test from "node:test";
import { shouldAttachStoredAuthorization } from "./auth-request-policy";
test("public login mutations do not inherit a previously stored account token", () => {
for (const url of [
"https://gateway.invalid/web/api/v1/auth/login/token?token=external",
"/api/v1/auth/login/username",
"/api/v1/auth/login/local",
"/api/v1/auth/register",
"/api/v1/auth/initialize",
]) {
assert.equal(shouldAttachStoredAuthorization(url), false, url);
}
});
test("authenticated account endpoints continue to receive the stored token", () => {
assert.equal(shouldAttachStoredAuthorization("/api/v1/auth/me"), true);
assert.equal(shouldAttachStoredAuthorization("/api/v1/auth/users"), true);
assert.equal(shouldAttachStoredAuthorization("/api/agents"), true);
});
@@ -0,0 +1,11 @@
/** Login mutations are public and must not inherit a previous account's Bearer. */
export function shouldAttachStoredAuthorization(url: string): boolean {
let pathname = url;
try {
pathname = new URL(url, "http://deerflow.invalid").pathname;
} catch {
// Keep relative strings usable even if a caller supplies a malformed URL.
}
const path = pathname.replace(/\/+$/, "");
return !/\/api\/v1\/auth\/(?:login\/(?:local|username|token)|register|initialize)$/.test(path);
}
+6 -3
View File
@@ -3,11 +3,14 @@ import {
getAuthorizationHeaderValue,
getStoredAuth,
} from "@/core/auth";
import { shouldAttachStoredAuthorization } from "./auth-request-policy";
import { withCsrfHeaders } from "./csrf";
function withAuthHeaders(headers?: HeadersInit): Headers {
function withAuthHeaders(input: RequestInfo | URL, headers?: HeadersInit): Headers {
const next = withCsrfHeaders(headers);
const authHeader = getAuthorizationHeaderValue();
const authHeader = shouldAttachStoredAuthorization(requestUrl(input))
? getAuthorizationHeaderValue()
: undefined;
if (authHeader && !next.has("Authorization")) {
next.set("Authorization", authHeader);
}
@@ -21,7 +24,7 @@ function requestUrl(input: RequestInfo | URL): string {
}
export async function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
const headers = withAuthHeaders(init?.headers);
const headers = withAuthHeaders(input, init?.headers);
const requestedAuthorization = headers.get("Authorization");
const response = await fetch(input, {
...init,
@@ -45,15 +45,6 @@ async function accountRequest<T>(path: string, init: RequestInit = {}): Promise<
}
}
export async function fetchAuthenticationOptions(): Promise<AuthenticationOptions> {
const options = await accountRequest<AuthenticationOptions>("/options", { cache: "no-store" });
if (!options || [options.password_login_enabled, options.registration_enabled, options.sso_enabled]
.some((value) => typeof value !== "boolean")) {
throw new Error("无法读取登录设置,请联系管理员或重试");
}
return options;
}
export function loginWithPassword(username: string, password: string): Promise<LoginByUsernameResponse> {
return accountRequest("/login/local", {
method: "POST",
+4 -1
View File
@@ -9,7 +9,7 @@ export interface UserListItem {
system_role: "admin" | "user";
skill_count: number;
agent_count: number;
/** 问答次数:用户提问的次数(一次提问算一次)。 */
/** 近 30 天问答次数:用户提问的次数(一次提问算一次)。 */
qa_count: number;
/** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */
approved: boolean;
@@ -122,6 +122,7 @@ const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve,
export async function loginByToken(
token: string,
isCurrent: () => boolean = () => true,
): Promise<LoginByUsernameResponse> {
// Token-exchange login: the gateway resolves the upstream token to a username
// and signs the matching local account in.
@@ -129,6 +130,7 @@ export async function loginByToken(
let lastError = "Login failed.";
for (let attempt = 1; attempt <= TOKEN_LOGIN_MAX_ATTEMPTS; attempt += 1) {
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
const response = await apiFetch(url, {
method: "POST",
headers: {
@@ -136,6 +138,7 @@ export async function loginByToken(
},
});
if (response.ok) {
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
return response.json() as Promise<LoginByUsernameResponse>;
}
+172
View File
@@ -0,0 +1,172 @@
/**
* b5-1「任务分析」单智能体深链。
*
* 与既有 3qfx/rwfx/xdfx 的区别是:任务详情由接入系统预先写入 DeerFlow 的
* ``/api/task-deeplink-details/b5-1/{id}``,登录后只从本系统读取,不依赖对方的
* 详情接口。智能体默认由「业务链条管理」中的 b5-1 行配置,但深链 URL 可用
* ``?agentId=`` 直接指定(优先于配置行);``?type=`` 业务类型与 taskId 一起决定
* 聊天记录的独立分区。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { clearTaskContext, persistTaskContext } from "@/core/auth/task-purpose";
import { persistB51Theme } from "@/core/auth/b5-1-theme";
import { getBackendBaseURL } from "@/core/config";
import type { TaskDeeplinkTarget } from "@/core/auth/task-deeplink";
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
export const B5_1_GOPATH = "b5-1";
export const B5_1_TASK_BASE = "/page/strategy/task-management/plan-task-b5-1";
export const B5_1_DETAIL_STORAGE_KEY = "task.b5-1.detail";
/** 与后端索引列 task_deeplink_threads.sub_type 同宽。 */
const B51_TASK_TYPE_MAX_LENGTH = 64;
export interface B51TaskDetail {
type: "b5-1";
id: string;
taskName: string;
taskContent: string;
overview: string;
extra: Record<string, unknown>;
/** 深链 ``?type=`` 业务类型。不来自后端详情接口,由前端随快照一并登记。 */
taskType?: string;
}
function asText(value: unknown): string {
return typeof value === "string" ? value : "";
}
function asExtra(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
/** 归一化 ``?type=``:去空白并截到索引列宽,空值返回 ""(无类型分区)。 */
export function normalizeB51TaskType(value: string | null | undefined): string {
return (value ?? "").trim().slice(0, B51_TASK_TYPE_MAX_LENGTH);
}
/** 判断 goPath 是否精确为 b5-1(大小写不敏感)。 */
export function isB51GoPath(goPath: string): boolean {
return goPath.trim().toLowerCase() === B5_1_GOPATH;
}
/** 读取已由接入方写入的任务详情(按 taskId + 业务类型分区取)。
* 404 表示该 (id, type) 尚未同步,不应带空详情进入问答。 */
export async function fetchB51TaskDetail(
taskId: string,
taskType?: string,
): Promise<B51TaskDetail | null> {
const id = taskId.trim();
if (!id) return null;
const normalizedType = normalizeB51TaskType(taskType);
try {
const query = new URLSearchParams();
if (normalizedType) query.set("type", normalizedType);
const suffix = normalizedType ? `?${query.toString()}` : "";
const response = await apiFetch(
`${getBackendBaseURL()}/api/task-deeplink-details/${B5_1_GOPATH}/${encodeURIComponent(id)}${suffix}`,
{ method: "GET" },
);
if (!response.ok) {
console.warn("[b5-1-deeplink] task detail unavailable", { id, normalizedType, status: response.status });
return null;
}
const body = (await response.json().catch(() => null)) as { data?: unknown } | null;
const data = body?.data;
if (!data || typeof data !== "object") return null;
const row = data as Record<string, unknown>;
const detail: B51TaskDetail = {
type: B5_1_GOPATH,
id: asText(row.id).trim() || id,
taskName: asText(row.taskName),
taskContent: asText(row.taskContent),
overview: asText(row.overview),
extra: asExtra(row.extra),
taskType: normalizeB51TaskType(asText(row.taskType)) || normalizedType || undefined,
};
return detail.taskName && detail.taskContent ? detail : null;
} catch (error) {
console.warn("[b5-1-deeplink] task detail request failed", { id, normalizedType, error });
return null;
}
}
export function persistB51TaskDetail(detail: B51TaskDetail): void {
try {
sessionStorage.setItem(B5_1_DETAIL_STORAGE_KEY, JSON.stringify(detail));
} catch {
/* noop */
}
}
export function readB51TaskDetail(): B51TaskDetail | null {
try {
const raw = sessionStorage.getItem(B5_1_DETAIL_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<B51TaskDetail>;
const id = asText(parsed.id).trim();
if (!id) return null;
return {
type: B5_1_GOPATH,
id,
taskName: asText(parsed.taskName),
taskContent: asText(parsed.taskContent),
overview: asText(parsed.overview),
extra: asExtra(parsed.extra),
taskType: normalizeB51TaskType(asText(parsed.taskType)) || undefined,
};
} catch {
return null;
}
}
export function buildB51OpeningText(detail: B51TaskDetail, taskId: string): string {
return [
detail.taskName ? `任务名称:${detail.taskName}` : "",
detail.taskContent ? `任务详情:${detail.taskContent}` : "",
detail.overview ? `任务概述:${detail.overview}` : "",
taskId ? `任务id(task_id)为${taskId}` : "",
]
.filter(Boolean)
.join("\n");
}
/** 「业务链条管理」b5-1 行配置的智能体(旧链接未带 ?agentId= 时的回退)。 */
async function resolveB51MappedAgentId(): Promise<string> {
const mappings = await listBusinessMappings();
return mappings
.find((item) => item.businessCode.trim().toLowerCase() === B5_1_GOPATH)
?.agentId?.trim() ?? "";
}
/**
* 建立任务上下文后进入独立的任务分析工作区。
* 智能体优先取深链 ``?agentId=``,未传时回退配置管理中 b5-1 行;
* ``?type=`` 业务类型随上下文与快照登记,聊天记录按 taskId + type 分区;
* ``?theme=`` 主题色随深链会话登记(不传则由 b5-1-theme 默认深蓝)。
*/
export async function resolveB51DeeplinkTarget(
detail: B51TaskDetail,
taskId: string,
opts?: { agentId?: string; taskType?: string; theme?: string },
): Promise<TaskDeeplinkTarget | null> {
const urlAgentId = (opts?.agentId ?? "").trim();
const agentId = urlAgentId || (await resolveB51MappedAgentId());
if (!agentId) return null;
const taskType = normalizeB51TaskType(opts?.taskType);
persistB51Theme(opts?.theme ?? "");
clearTaskContext();
persistB51TaskDetail(taskType ? { ...detail, taskType } : detail);
persistTaskContext({
taskId,
agentId,
openingText: buildB51OpeningText(detail, taskId),
goPath: B5_1_GOPATH,
...(taskType ? { taskType } : {}),
});
return { path: `${B5_1_TASK_BASE}/agent/${agentId}/chats` };
}
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
B51_DEFAULT_THEME,
persistB51Theme,
resolveB51ForcedTheme,
resolveB51ThemeName,
} from "./b5-1-theme.ts";
const WORKSPACE = "/page/strategy/task-management/plan-task-b5-1/agent/analyst/chats";
test("non-b5-1 routes are untouched", () => {
assert.equal(resolveB51ForcedTheme("/page/workspace/chats/new"), undefined);
assert.equal(resolveB51ForcedTheme("/login"), undefined);
assert.equal(resolveB51ForcedTheme("/login?goPath=3qfx&taskId=1"), undefined);
assert.equal(resolveB51ForcedTheme("/login?goPath=agentfx"), undefined);
});
test("workspace defaults to deep blue (dark) without a theme param", () => {
assert.equal(resolveB51ForcedTheme(WORKSPACE), "dark");
assert.equal(resolveB51ForcedTheme(WORKSPACE, "?threadId=t1"), "dark");
assert.equal(B51_DEFAULT_THEME, "dark-blue");
});
test("supplied theme wins over the default", () => {
assert.equal(resolveB51ForcedTheme(WORKSPACE, "?theme=lightblue"), "light");
assert.equal(resolveB51ForcedTheme(WORKSPACE, "?theme=light"), "light");
assert.equal(resolveB51ForcedTheme(WORKSPACE, "?theme=dark-blue"), "dark");
assert.equal(resolveB51ForcedTheme(WORKSPACE, "?theme=%E6%B7%B1%E8%93%9D"), "dark");
});
test("b5-1 login page is forced too, case-insensitive on goPath", () => {
assert.equal(
resolveB51ForcedTheme("/login", "?taskId=1&goPath=b5-1"),
"dark",
);
assert.equal(
resolveB51ForcedTheme("/login", "?taskId=1&goPath=B5-1&theme=lightblue"),
"light",
);
});
test("theme resolution order falls back to the default without storage", () => {
assert.equal(resolveB51ThemeName(), "dark-blue");
assert.equal(resolveB51ThemeName("?theme=iceblue"), "iceblue");
// persistB51Theme 在无 sessionStorage 的环境(node 测试)里静默跳过,
// 浏览器里则登记后由 resolveB51ThemeName() 优先读 URL、其次读登记值。
persistB51Theme("lightgreen");
assert.equal(resolveB51ThemeName("?theme=dark"), "dark");
});
+71
View File
@@ -0,0 +1,71 @@
/**
* b5-1「任务分析」深链的主题色规则。
*
* 产品约定:接入方不传 ``?theme=`` 时**默认深蓝**(映射为 dark 主题),传了
* (如 ``dark-blue``/``lightblue``…)则用传入值经既有嵌入映射解析明暗。
* 主题随深链会话存 sessionStorage(按标签页),登录跳转/刷新后仍生效,
* 且只在 b5-1 工作区路径与 goPath=b5-1 的登录页强制——离开即恢复用户
* 自己的外观偏好,不写 localStorage、不影响其它页面。
*/
import { resolveEmbedThemeMode } from "../embed/theme-mode.mjs";
export const B51_THEME_STORAGE_KEY = "task.b5-1.theme";
/** 不传 theme 时的缺省主题名(经 resolveEmbedThemeMode 映射为深色)。 */
export const B51_DEFAULT_THEME = "dark-blue";
const B51_WORKSPACE_PATH_PREFIX = "/page/strategy/task-management/plan-task-b5-1";
function readThemeParam(search: string): string {
const value = new URLSearchParams(
search.startsWith("?") ? search.slice(1) : search,
).get("theme");
return (value ?? "").trim();
}
function readGoPathParam(search: string): string {
const value = new URLSearchParams(
search.startsWith("?") ? search.slice(1) : search,
).get("goPath");
return (value ?? "").trim().toLowerCase();
}
/** 深链进入任务分析工作区时登记接入方传入的主题(空串 = 用默认)。 */
export function persistB51Theme(rawTheme: string): void {
try {
sessionStorage.setItem(B51_THEME_STORAGE_KEY, (rawTheme ?? "").trim());
} catch {
/* noop */
}
}
function readStoredB51Theme(): string {
try {
return (sessionStorage.getItem(B51_THEME_STORAGE_KEY) ?? "").trim();
} catch {
return "";
}
}
/** 解析 b5-1 深链应使用的原始主题名:URL 参数 > 登记值 > 默认深蓝。 */
export function resolveB51ThemeName(search?: string): string {
const fromUrl = search === undefined ? "" : readThemeParam(search);
if (fromUrl) return fromUrl;
return readStoredB51Theme() || B51_DEFAULT_THEME;
}
/**
* 路由级强制主题:仅 b5-1 工作区路径与 goPath=b5-1 的登录页返回明/暗,
* 其它路径返回 undefined(不影响既有主题行为)。
*/
export function resolveB51ForcedTheme(
pathname: string,
search = "",
): "light" | "dark" | undefined {
const isWorkspace = pathname.startsWith(B51_WORKSPACE_PATH_PREFIX);
const isB51Login =
(pathname === "/login" || pathname.startsWith("/login/")) &&
readGoPathParam(search) === "b5-1";
if (!isWorkspace && !isB51Login) return undefined;
return resolveEmbedThemeMode(resolveB51ThemeName(search));
}
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import test from "node:test";
import { beginLoginAttempt, holdLoginAttempt } from "./scoped-storage";
class MemoryStorage {
data = new Map<string, string>();
get length() { return this.data.size; }
key(index: number) { return [...this.data.keys()][index] ?? null; }
getItem(key: string) { return this.data.get(key) ?? null; }
setItem(key: string, value: string) { this.data.set(key, value); }
removeItem(key: string) { this.data.delete(key); }
clear() { this.data.clear(); }
}
function browser() {
const localStorage = new MemoryStorage();
const sessionStorage = new MemoryStorage();
const listeners = new Map<string, Set<(event: Event) => void>>();
const window = {
localStorage,
sessionStorage,
addEventListener(type: string, listener: (event: Event) => void) {
const set = listeners.get(type) ?? new Set();
set.add(listener);
listeners.set(type, set);
},
removeEventListener(type: string, listener: (event: Event) => void) {
listeners.get(type)?.delete(listener);
},
dispatchEvent(event: Event) {
listeners.get(event.type)?.forEach((listener) => listener(event));
return true;
},
setTimeout: globalThis.setTimeout.bind(globalThis),
clearTimeout: globalThis.clearTimeout.bind(globalThis),
} as Window & typeof globalThis;
window.self = window;
window.top = window;
Object.defineProperty(globalThis, "window", { value: window, configurable: true });
return { localStorage, sessionStorage, window };
}
test("a newer login invalidates the previous response without clearing the current session", async () => {
const { localStorage } = browser();
localStorage.setItem("deerflow.auth", "admin");
localStorage.setItem("userInfo", "admin-profile");
const old = beginLoginAttempt();
const pending = Promise.resolve().then(() => {
if (old()) localStorage.setItem("deerflow.auth", "old-token-user");
});
const latest = beginLoginAttempt();
localStorage.setItem("deerflow.auth", "new-token-user");
await pending;
assert.equal(old(), false);
assert.equal(latest(), true);
assert.equal(localStorage.getItem("deerflow.auth"), "new-token-user");
assert.equal(localStorage.getItem("userInfo"), "admin-profile");
});
test("another normal tab starting a login invalidates this tab's pending login", () => {
const { localStorage } = browser();
const pending = beginLoginAttempt();
localStorage.setItem("deerflow.login-attempt", "another-tab-login");
assert.equal(pending(), false);
});
test("another window's late account write is put back while this login still owns the attempt", () => {
const { localStorage, window } = browser();
const current = beginLoginAttempt();
localStorage.setItem("deerflow.auth", "admin");
localStorage.setItem("userInfo", "{\"userName\":\"admin\"}");
holdLoginAttempt(current);
localStorage.setItem("deerflow.auth", "old-token-user");
localStorage.setItem("userInfo", "{\"userName\":\"external-user\"}");
window.dispatchEvent({
type: "storage",
key: "deerflow.auth",
newValue: "old-token-user",
storageArea: localStorage,
} as unknown as Event);
assert.equal(localStorage.getItem("deerflow.auth"), "admin");
assert.equal(localStorage.getItem("userInfo"), "{\"userName\":\"admin\"}");
});
test("a newer login in the other window is kept", () => {
const { localStorage, window } = browser();
const first = beginLoginAttempt();
localStorage.setItem("deerflow.auth", "admin");
holdLoginAttempt(first);
beginLoginAttempt();
localStorage.setItem("deerflow.auth", "new-token-user");
window.dispatchEvent({
type: "storage",
key: "deerflow.auth",
newValue: "new-token-user",
storageArea: localStorage,
} as unknown as Event);
assert.equal(localStorage.getItem("deerflow.auth"), "new-token-user");
});
test("storage failure stops the login instead of keeping a stale writer", () => {
const { localStorage } = browser();
localStorage.setItem("deerflow.auth", "old-user");
localStorage.setItem = () => { throw new Error("storage unavailable"); };
assert.throws(() => beginLoginAttempt(), /storage unavailable/);
assert.equal(localStorage.getItem("deerflow.auth"), "old-user");
});
+43 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { accountPageSearch, mergeLoginSearch, shouldStartSso, shouldUseStoredTicket } from "./login-entry";
import { accountPageSearch, claimSsoLoginStart, loginParamsForEntry, mergeLoginSearch, shouldStartSso, shouldUseStoredTicket } from "./login-entry";
const options = { password_login_enabled: true, registration_enabled: true, sso_enabled: true };
@@ -12,6 +12,18 @@ test("password entry never starts a guest login; explicit SSO still works", () =
assert.equal(shouldStartSso({ ...options, password_login_enabled: false }, true, false), true);
});
test("repeated automatic-login effect starts one request; explicit retry can claim the guard again", () => {
const started = { current: false };
assert.equal(claimSsoLoginStart(started), true);
// React StrictMode and dependency-driven effect reruns use the same ref.
assert.equal(claimSsoLoginStart(started), false);
assert.equal(claimSsoLoginStart(started), false);
// LoginPage resets the ref only when the user explicitly retries.
started.current = false;
assert.equal(claimSsoLoginStart(started), true);
});
test("expired sessions wait for an explicit re-login instead of repeatedly kicking the other client", () => {
const legacy = { ...options, password_login_enabled: false };
assert.equal(shouldStartSso(legacy, false, false, true), false);
@@ -19,6 +31,35 @@ test("expired sessions wait for an explicit re-login instead of repeatedly kicki
assert.equal(shouldStartSso(legacy, false, false, false), true);
});
test("username password entry drops an earlier external token left before the hash", () => {
const params = loginParamsForEntry(
"password=123ewq",
"?authToken=old-token&yUserId=old-user&ticket=old-ticket&token=old-tools&access_token=old-access&userId=old-id&theme=dark-blue",
"admin",
);
assert.equal(params.get("password"), "123ewq");
assert.equal(params.get("theme"), "dark-blue");
for (const key of ["authToken", "yUserId", "ticket", "token", "access_token", "userId"]) {
assert.equal(params.get(key), null, key);
}
});
test("identity params inside the password route itself are kept", () => {
const params = loginParamsForEntry(
"password=123ewq&authToken=this-entry&yUserId=this-user",
"?authToken=old-token&yUserId=old-user",
"admin",
);
assert.equal(params.get("authToken"), "this-entry");
assert.equal(params.get("yUserId"), "this-user");
});
test("outer token stays for a bare entry, and a path username only drops it when a password is present", () => {
assert.equal(loginParamsForEntry("", "?authToken=outer", undefined).get("authToken"), "outer");
assert.equal(loginParamsForEntry("", "?authToken=outer", "admin").get("authToken"), "outer");
assert.equal(loginParamsForEntry("", "?authToken=outer&password=123ewq", "admin").get("authToken"), null);
});
test("external login credentials before the hash are read, and route values take precedence", () => {
const params = mergeLoginSearch("?authToken=current&password=&taskId=42", "?authToken=stale&password=old&ticket=t&theme=dark-blue");
assert.equal(params.get("authToken"), "current");
@@ -32,6 +73,7 @@ test("external login credentials before the hash are read, and route values take
test("stored tickets cannot override explicit login links or password entry", () => {
assert.equal(shouldUseStoredTicket(options, false), false);
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false }, true), false);
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false }, false, true), false);
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false }, false), true);
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false, sso_enabled: false }, false), false);
assert.equal(shouldUseStoredTicket(null, false), false);
+34 -1
View File
@@ -11,6 +11,13 @@ export function shouldStartSso(
(explicitSso || !options.password_login_enabled));
}
/** Claim the one automatic SSO login started by a mounted LoginPage. */
export function claimSsoLoginStart(started: { current: boolean }): boolean {
if (started.current) return false;
started.current = true;
return true;
}
/** External callers put login parameters before or after the hash route. */
export function mergeLoginSearch(routeSearch: string, outerSearch: string): URLSearchParams {
const params = new URLSearchParams(outerSearch);
@@ -20,6 +27,31 @@ export function mergeLoginSearch(routeSearch: string, outerSearch: string): URLS
return params;
}
const OUTER_IDENTITY_PARAMS = ["authToken", "yUserId", "ticket", "token", "access_token", "userId"] as const;
/**
* `/login/:username?password=` names the account. Identity params left in front
* of the hash by an earlier external-token entry must not sign that user back
* in. The same keys written inside the hash route are still honored.
*/
export function loginParamsForEntry(
routeSearch: string,
outerSearch: string,
routeUsername?: string | null,
): URLSearchParams {
const params = mergeLoginSearch(routeSearch, outerSearch);
const route = new URLSearchParams(routeSearch);
const username = routeUsername?.trim() ?? "";
const password = route.has("password")
? (route.get("password") ?? "")
: (new URLSearchParams(outerSearch).get("password") ?? "");
if (!username || !password) return params;
for (const key of OUTER_IDENTITY_PARAMS) {
if (!route.has(key)) params.delete(key);
}
return params;
}
export const LOGIN_CREDENTIAL_PARAMS = [
"ticket", "userId", "yUserId", "token", "access_token", "password", "authToken", "routeKey",
] as const;
@@ -28,8 +60,9 @@ export const LOGIN_CREDENTIAL_PARAMS = [
export function shouldUseStoredTicket(
options: AuthenticationOptions | null,
explicitSso: boolean,
hasCurrentSession = false,
): boolean {
return Boolean(options?.sso_enabled && !options.password_login_enabled && !explicitSso);
return Boolean(options?.sso_enabled && !options.password_login_enabled && !explicitSso && !hasCurrentSession);
}
/** Keep the destination/presentation when switching forms; discard credentials. */
+17 -20
View File
@@ -9,7 +9,13 @@ import {
getStoredAuth,
setStoredAuth,
} from "./index";
import { checkAccountAccess, isSameAccountSession, type AccountAccess } from "./session-validation";
import {
checkAccountAccess,
isRecentlyAuthenticatedLogin,
isSameAccountIdentity,
isSameAccountSession,
type AccountAccess,
} from "./session-validation";
type GateState = (Extract<AccountAccess, { status: "allowed" }> & { warning?: string })
| Extract<AccountAccess, { status: "unauthorized" }>
@@ -24,7 +30,6 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<GateState>({ status: "checking" });
const writingVerifiedAccount = useRef(false);
const requestInFlight = useRef(false);
useEffect(() => {
const refresh = () => {
@@ -34,21 +39,11 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
if (!isEmbedActive() && !isIsolatedSession()
&& (event.key === "deerflow.auth" || event.key === null)) refresh();
};
const recheck = () => { if (!requestInFlight.current) refresh(); };
const onVisibility = () => { if (document.visibilityState === "visible") recheck(); };
window.addEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
window.addEventListener("storage", onStorage);
window.addEventListener("focus", recheck);
document.addEventListener("visibilitychange", onVisibility);
// A different browser can revoke this account's session. Also refresh the
// policy so enabling login or single-session mode takes effect in open tabs.
const timer = setInterval(onVisibility, 15_000);
return () => {
window.removeEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
window.removeEventListener("storage", onStorage);
window.removeEventListener("focus", recheck);
document.removeEventListener("visibilitychange", onVisibility);
clearInterval(timer);
};
}, []);
@@ -56,12 +51,16 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
const controller = new AbortController();
const requestedAuth = getStoredAuth();
let disposed = false;
requestInFlight.current = true;
if (isRecentlyAuthenticatedLogin(requestedAuth)) {
setState({ status: "allowed", auth: requestedAuth });
return () => controller.abort();
}
const timeout = setTimeout(() => controller.abort(), 20_000);
// A background recheck must not unmount an active editor on every poll.
// A changed account/token still blocks immediately until its own check ends.
// Keep the protected tree mounted while a renewed token for the same
// account is verified. Unmounting here destroys nested iframes and resets
// an active chat even though the user's identity did not change.
setState((previous) => previous.status === "allowed"
&& JSON.stringify(previous.auth) === JSON.stringify(requestedAuth)
&& isSameAccountIdentity(previous.auth, requestedAuth)
? previous : { status: "checking" });
void checkAccountAccess({
@@ -92,16 +91,14 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
: error instanceof TypeError ? "无法连接服务器,请检查网络后重试"
: error instanceof Error ? error.message : "暂时无法验证登录状态,请稍后重试";
setState((previous) => previous.status === "allowed"
&& JSON.stringify(previous.auth) === JSON.stringify(requestedAuth)
&& isSameAccountIdentity(previous.auth, requestedAuth)
&& isSameAccountSession(getStoredAuth(), requestedAuth)
? { ...previous, warning: message } : { status: "error", message });
}).finally(() => {
clearTimeout(timeout);
if (!disposed) requestInFlight.current = false;
});
return () => {
disposed = true;
requestInFlight.current = false;
controller.abort();
clearTimeout(timeout);
};
@@ -119,7 +116,7 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
onClick={() => setAttempt((value) => value + 1)}>重新验证</button>
</div>;
}
if (state.status === "allowed" && JSON.stringify(state.auth) === JSON.stringify(getStoredAuth())) {
if (state.status === "allowed" && isSameAccountIdentity(state.auth, getStoredAuth())) {
return <div className="contents">
{children}
{state.warning && <div role="alert" className="fixed right-4 bottom-4 z-50 flex max-w-md items-center gap-3 rounded-md border bg-background p-4 text-sm shadow-lg">
@@ -69,6 +69,80 @@ function isProtectedAuthKey(key: string): boolean {
return PROTECTED_AUTH_EXACT_KEYS.includes(key) || key.startsWith(PROTECTED_AUTH_PREFIX);
}
const LOGIN_ATTEMPT_KEY = "deerflow.login-attempt";
/**
* Mark the login that is allowed to write this tab's auth storage.
*
* A newer call, including one from another normal tab, replaces the marker.
* The previous login keeps its already issued session until it is actually
* replaced; callers must consult the returned check immediately before writing
* so a late token or profile response cannot put the old user back.
* Embed and isolated tabs use their own sessionStorage marker and do not
* cancel a normal tab's login.
*/
export function beginLoginAttempt(): () => boolean {
const storage = authStorage();
if (!storage) {
throw new Error("浏览器无法保存登录信息,请允许网站存储后重试。");
}
const attempt = `${Date.now()}-${Math.random()}-${Math.random()}`;
storage.setItem(LOGIN_ATTEMPT_KEY, attempt);
return () => authStorage() === storage && storage.getItem(LOGIN_ATTEMPT_KEY) === attempt;
}
const LOGIN_HOLD_MS = 8_000;
const EXTRA_HELD_KEYS = ["ylog.token1"];
function isHeldAuthKey(key: string): boolean {
return isProtectedAuthKey(key) || EXTRA_HELD_KEYS.includes(key);
}
/**
* Keep the account just written by this login if another window's late response
* puts the previous account back. A newer login replaces the attempt marker and
* this hold then steps aside.
*/
export function holdLoginAttempt(isCurrent: () => boolean): void {
const storage = authStorage();
if (!storage || typeof window === "undefined" || !isCurrent()) return;
const snapshot = new Map<string, string | null>();
for (let i = 0; i < storage.length; i += 1) {
const key = storage.key(i);
if (key && isHeldAuthKey(key)) snapshot.set(key, storage.getItem(key));
}
for (const key of [...PROTECTED_AUTH_EXACT_KEYS, ...EXTRA_HELD_KEYS]) {
if (!snapshot.has(key)) snapshot.set(key, null);
}
const restore = () => {
if (!isCurrent()) return;
for (let i = storage.length - 1; i >= 0; i -= 1) {
const key = storage.key(i);
if (!key || !isHeldAuthKey(key)) continue;
const expected = snapshot.has(key) ? snapshot.get(key) ?? null : null;
if (expected === null) storage.removeItem(key);
else if (storage.getItem(key) !== expected) storage.setItem(key, expected);
}
snapshot.forEach((value, key) => {
if (value !== null && storage.getItem(key) !== value) storage.setItem(key, value);
});
};
const onStorage = (event: StorageEvent) => {
if (!isCurrent()) {
window.removeEventListener("storage", onStorage);
return;
}
if (event.storageArea !== storage || !event.key || !isHeldAuthKey(event.key)) return;
const expected = snapshot.has(event.key) ? snapshot.get(event.key) ?? null : null;
if (event.newValue === expected) return;
restore();
};
window.addEventListener("storage", onStorage);
const timer = window.setTimeout(() => window.removeEventListener("storage", onStorage), LOGIN_HOLD_MS);
(timer as unknown as { unref?: () => void }).unref?.();
}
/**
* 把 localStorage 里的鉴权 / 身份键**快照复制**进 sessionStorage(覆盖式)。
*
@@ -1,7 +1,13 @@
import assert from "node:assert/strict";
import test from "node:test";
import { checkAccountAccess, isSameAccountSession } from "./session-validation";
import {
checkAccountAccess,
isRecentlyAuthenticatedLogin,
isSameAccountIdentity,
isSameAccountSession,
rememberSuccessfulLogin,
} from "./session-validation";
const auth = {
access_token: "browser-token", token_type: "bearer", expires_in: 3600,
@@ -9,63 +15,63 @@ const auth = {
};
const user = { id: "user-1", email: "actual@example.com", system_role: "user", needs_setup: false };
function server(enabled: boolean, me: Response = Response.json(user), singleSession = false) {
function server(me: Response = Response.json(user)) {
const calls: { url: string; init?: RequestInit }[] = [];
const fetcher: typeof fetch = async (input, init) => {
calls.push({ url: String(input), init });
return String(input).endsWith("/options")
? Response.json({ password_login_enabled: enabled, single_session_enabled: singleSession }) : me;
return me;
};
return { calls, fetcher };
}
test("enabled login rejects anonymous accounts including standalone mode before /me", async () => {
const { fetcher, calls } = server(true);
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", allowLegacyAnonymous: true, fetcher }), { status: "unauthorized" });
assert.equal(calls.length, 1);
test("anonymous access is decided locally without fetching login options", async () => {
const { fetcher, calls } = server();
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", fetcher }), { status: "unauthorized" });
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", allowLegacyAnonymous: true, fetcher }), { status: "allowed", auth: null });
assert.equal(calls.length, 0);
});
test("a cached token never grants access before /me validates it", async () => {
const { fetcher, calls } = server(true, new Response(null, { status: 401 }));
const { fetcher, calls } = server(new Response(null, { status: 401 }));
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "https://gateway", fetcher }), { status: "unauthorized", reason: "session-expired" });
assert.equal(calls[1]?.url, "https://gateway/api/v1/auth/me");
assert.equal(new Headers(calls[1]?.init?.headers).get("Authorization"), "Bearer browser-token");
assert.equal(calls[1]?.init?.cache, "no-store");
assert.equal(calls[0]?.url, "https://gateway/api/v1/auth/me");
assert.equal(new Headers(calls[0]?.init?.headers).get("Authorization"), "Bearer browser-token");
assert.equal(calls[0]?.init?.cache, "no-store");
});
test("verified server permissions replace claimed local permissions", async () => {
const { fetcher } = server(true);
const { fetcher } = server();
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), {
status: "allowed", auth: { ...auth, email: user.email, system_role: user.system_role, needs_setup: false },
});
});
test("a token for another account cannot authenticate the cached account", async () => {
const { fetcher } = server(true, Response.json({ ...user, id: "another-user" }));
const { fetcher } = server(Response.json({ ...user, id: "another-user" }));
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), { status: "unauthorized", reason: "session-expired" });
});
test("disabled login preserves legacy sessions and the standalone anonymous entry", async () => {
const { fetcher, calls } = server(false);
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), { status: "allowed", auth });
test("legacy anonymous access does not make a session request", async () => {
const { fetcher, calls } = server();
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", fetcher }), { status: "unauthorized" });
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", allowLegacyAnonymous: true, fetcher }), { status: "allowed", auth: null });
assert.ok(calls.every(({ url }) => url.endsWith("/options")));
assert.equal(calls.length, 0);
});
test("outages and invalid policy responses fail closed without treating the session as invalid", async () => {
const { fetcher } = server(true, new Response(null, { status: 503 }));
test("outages and invalid user responses fail closed without treating the session as invalid", async () => {
const { fetcher } = server(new Response(null, { status: 503 }));
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher }), /无法验证登录/);
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher: async () => Response.json({}) }), /无法读取登录设置/);
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher: async () => Response.json({}) }), /登录信息不完整/);
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher: async () => { throw new TypeError("offline"); } }), /offline/);
});
test("single-session mode detects revoked sessions even with the password page disabled", async () => {
const { fetcher, calls } = server(false, new Response(null, { status: 401 }), true);
test("revoked sessions are detected through the existing session endpoint", async () => {
const { fetcher, calls } = server(new Response(null, { status: 401 }));
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), {
status: "unauthorized", reason: "session-expired",
});
assert.ok(calls.some(({ url }) => url.endsWith("/me")));
assert.equal(calls.length, 1);
assert.ok(calls[0].url.endsWith("/me"));
});
test("late failures cannot clear a renewed token or a different account", () => {
@@ -74,3 +80,33 @@ test("late failures cannot clear a renewed token or a different account", () =>
assert.equal(isSameAccountSession({ ...auth, user_id: "another-user" }, auth), false);
assert.equal(isSameAccountSession(null, auth), false);
});
test("a successful login response avoids a redundant immediate /me round trip only briefly", () => {
const login = { ...auth, access_token: "fresh-login-token" };
const now = 1_000_000;
rememberSuccessfulLogin(login, now);
assert.equal(isRecentlyAuthenticatedLogin(login, now + 1), true);
assert.equal(isRecentlyAuthenticatedLogin({ ...login, user_id: "another-user" }, now + 1), false);
assert.equal(isRecentlyAuthenticatedLogin(login, now + 15_001), false);
});
test("same-account token renewal preserves the mounted account identity", () => {
assert.equal(isSameAccountIdentity({ ...auth }, auth), true);
assert.equal(isSameAccountIdentity({ ...auth, access_token: "renewed" }, auth), true);
assert.equal(
isSameAccountIdentity(
{ ...auth, user_id: "migrated-user", email: "claimed@new-domain.example" },
auth,
),
true,
);
assert.equal(
isSameAccountIdentity(
{ ...auth, access_token: "other", email: "another@example.com" },
auth,
),
false,
);
assert.equal(isSameAccountIdentity(null, null), true);
assert.equal(isSameAccountIdentity(null, auth), false);
});
@@ -1,7 +1,28 @@
import type { LoginByUsernameResponse } from "./index";
import { getAccountIdentityKey, type LoginByUsernameResponse } from "./index";
type StoredAccount = LoginByUsernameResponse | null;
const RECENT_LOGIN_TTL_MS = 15_000;
const recentSuccessfulLogins = new Map<string, { userId: string; expiresAt: number }>();
/** A fresh login response is already server-validated; avoid an immediate duplicate `/me`. */
export function rememberSuccessfulLogin(auth: LoginByUsernameResponse, now = Date.now()): void {
if (!hasAccountSession(auth)) return;
recentSuccessfulLogins.set(auth.access_token, {
userId: auth.user_id,
expiresAt: now + RECENT_LOGIN_TTL_MS,
});
}
export function isRecentlyAuthenticatedLogin(auth: StoredAccount, now = Date.now()): boolean {
if (!hasAccountSession(auth)) return false;
for (const [token, record] of recentSuccessfulLogins) {
if (record.expiresAt <= now) recentSuccessfulLogins.delete(token);
}
const record = recentSuccessfulLogins.get(auth.access_token);
return Boolean(record && record.expiresAt > now && record.userId === auth.user_id);
}
export type AccountAccess =
| { status: "allowed"; auth: StoredAccount }
| { status: "unauthorized"; reason?: "session-expired" };
@@ -11,12 +32,27 @@ export function isSameAccountSession(current: StoredAccount, requested: StoredAc
return current?.access_token === requested?.access_token && current?.user_id === requested?.user_id;
}
/**
* Compare the stable account principal while deliberately ignoring a renewed
* access token. A same-account login in another tab must be revalidated, but
* it must not tear down the already mounted workspace (and its iframe tree)
* while that validation is in flight.
*/
export function isSameAccountIdentity(current: StoredAccount, requested: StoredAccount): boolean {
if (current === requested) return true;
if (!current || !requested) return false;
const currentIdentity = getAccountIdentityKey(current);
const requestedIdentity = getAccountIdentityKey(requested);
return currentIdentity !== null && currentIdentity === requestedIdentity;
}
export function hasAccountSession(auth: StoredAccount): auth is LoginByUsernameResponse {
return Boolean(typeof auth?.user_id === "string" && auth.user_id.trim()
&& typeof auth.access_token === "string" && auth.access_token.trim());
}
/** The server owns both the login policy and the account's current permissions. */
/** Validate an existing session without fetching login options on page entry. */
export async function checkAccountAccess({
auth,
baseURL,
@@ -30,25 +66,10 @@ export async function checkAccountAccess({
allowLegacyAnonymous?: boolean;
fetcher?: typeof fetch;
}): Promise<AccountAccess> {
const optionsResponse = await fetcher(`${baseURL}/api/v1/auth/options`, {
credentials: "include", cache: "no-store", signal,
});
if (!optionsResponse.ok) throw new Error("无法读取登录设置,请稍后重试");
const options: unknown = await optionsResponse.json();
if (!options || typeof options !== "object"
|| !("password_login_enabled" in options)
|| typeof options.password_login_enabled !== "boolean") {
throw new Error("无法读取登录设置,请稍后重试");
if (!hasAccountSession(auth)) {
return allowLegacyAnonymous ? { status: "allowed", auth: null } : { status: "unauthorized" };
}
const singleSessionEnabled = "single_session_enabled" in options && options.single_session_enabled === true;
if (!options.password_login_enabled && !singleSessionEnabled) {
return allowLegacyAnonymous || hasAccountSession(auth)
? { status: "allowed", auth }
: { status: "unauthorized" };
}
if (!hasAccountSession(auth)) return { status: "unauthorized" };
const response = await fetcher(`${baseURL}/api/v1/auth/me`, {
credentials: "include",
cache: "no-store",
+4 -1
View File
@@ -75,7 +75,10 @@ export function AuthSyncWatcher({
id: IDENTITY_TOAST_ID,
description: "当前页面已同步退出状态。",
});
navigate("/login", { replace: true });
// A bare legacy /login entry auto-starts the guest SSO flow. Cross-tab
// logout must stop at the explicit re-login screen instead of silently
// creating a guest session that can overwrite the account in another tab.
navigate("/login?reason=session-expired", { replace: true });
return;
}
+1 -1
View File
@@ -61,7 +61,7 @@ export const TASK_BUSINESSES: {
},
{
code: "3qfx",
name: "三情分析",
name: "三情研判",
goPath: "3qfx",
desc: "精简版工作区(无目的树闸门),通过 ?goPath=3qfx 进入。",
},
+126 -4
View File
@@ -5,7 +5,8 @@
* 流程:
* 1. 调 consumer 接口 ``GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=``,
* 请求头 ``Authorization: Bearer admin``(由 service 拦截器统一注入)。
* 2. 把返回的 data(id/overview/taskDirection/taskName/taskContent)整包存到 sessionStorage。
* 2. 把返回的 data(id/overview/taskDirection/taskName/taskContent/taskType)整包存到 sessionStorage。
* ``taskType`` 是字典码,拼开发送的开场信息时映射为 1 打击 / 2 塑势 / 3 防御。
* 3. 按 goPath 解析出业务码 + 模式:
* - ``3qfx`` / ``rwfx`` / ``xdfx`` → 单智能体问答(用「配置管理」里该业务配置的智能体)
* - ``3qfx-A`` / ``rwfx-A`` / ``xdfx-A`` → 多智能体会商(用该业务配置的业务链条)
@@ -31,6 +32,39 @@ export interface CopTaskDetail {
taskDirection: string;
taskName: string;
taskContent: string;
/**
* 任务类型字典码(cop-task-detail 的 ``taskType``)。
* 1 打击 / 2 塑势 / 3 防御;缺省或无法识别时为空,发送时省略该行。
*/
taskType?: string;
}
/** cop-task-detail ``taskType`` 字典码 → 发给模型的任务类型。 */
const COP_TASK_TYPE_LABELS: Record<string, string> = {
"1": "打击",
"2": "塑势",
"3": "防御",
};
const COP_TASK_TYPE_LABEL_VALUES = new Set(Object.values(COP_TASK_TYPE_LABELS));
/** 把任务类型字典码映射成中文;数字、数字字符串和已是中文的值都接受。无法识别时返回空串。 */
export function mapCopTaskTypeLabel(value: unknown): string {
let key = "";
if (typeof value === "number" && Number.isFinite(value)) {
key = String(value);
} else if (typeof value === "string") {
key = value.trim();
}
if (!key) return "";
if (COP_TASK_TYPE_LABEL_VALUES.has(key)) return key;
return COP_TASK_TYPE_LABELS[key] ?? "";
}
function readTaskTypeCode(value: unknown): string {
if (typeof value === "number" && Number.isFinite(value)) return String(value);
if (typeof value === "string") return value.trim();
return "";
}
/** 任务详情整包暂存的 sessionStorage key(目标页/后续可读取)。 */
@@ -93,6 +127,7 @@ function mockCopTaskDetail(taskId: string): CopTaskDetail {
taskName: "任务01:暑期文旅舆情综合研判",
taskContent:
"围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。",
taskType: "1",
};
}
@@ -104,6 +139,7 @@ export function stubCopTaskDetail(taskId: string): CopTaskDetail {
taskDirection: "",
taskName: "",
taskContent: "",
taskType: "",
};
}
@@ -126,6 +162,7 @@ export async function fetchCopTaskDetail(taskId: string): Promise<CopTaskDetail
taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "",
taskName: typeof d.taskName === "string" ? d.taskName : "",
taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
taskType: readTaskTypeCode(d.taskType),
};
} catch (error) {
console.warn("[task-deeplink] cop-task-detail failed", { taskId, error });
@@ -171,6 +208,34 @@ export function readActionDetail(): ActionDetail | null {
}
}
/**
* 传播网 id 是否可下发给模型。JSON ``null``、缺字段、空串、字面量 ``null`` 都视为不传。
*/
export function normalizeCbwId(value: unknown): string | null {
if (value == null) return null;
if (typeof value === "number") return Number.isFinite(value) ? String(value) : null;
if (typeof value !== "string") return null;
const text = value.trim();
if (!text || text.toLowerCase() === "null" || text.toLowerCase() === "undefined") return null;
return text;
}
/** 传播网名称。空值不下发;没有有效传播网 id 时调用方应整段省略。 */
export function normalizeCbwName(value: unknown): string {
if (typeof value !== "string") return "";
const text = value.trim();
if (!text || text.toLowerCase() === "null" || text.toLowerCase() === "undefined") return "";
return text;
}
/** xdfx 发给模型的传播网两行。``cbwId`` 无效时返回空数组,名称也不带。 */
export function cbwModelLines(cbwId: unknown, cbwName: unknown): string[] {
const id = normalizeCbwId(cbwId);
if (!id) return [];
const name = normalizeCbwName(cbwName);
return [`传播网id(cbw_id)为${id}`, ...(name ? [`传播网名称:${name}`] : [])];
}
/** 行动详情接口 ``{action_detail_url}/{actionId}`` 返回的 ``data`` 字段(xdfx 行动分析用)。 */
export interface ActionDetail {
/** 行动id(深链 ?taskId= 传入的就是它)。 */
@@ -185,6 +250,10 @@ export interface ActionDetail {
actionStatus: number | string;
/** 行动叙事 / 描述。 */
narration: string;
/** 传播网 id。接口为 null / 空时保持 null,开场信息不带传播网。 */
cbwId: string | null;
/** 传播网名称。仅在 ``cbwId`` 有值时随开场信息下发。 */
cbwName: string;
}
/**
@@ -216,6 +285,8 @@ export async function fetchActionDetail(actionId: string): Promise<ActionDetail
actionStatus:
typeof d.actionStatus === "number" || typeof d.actionStatus === "string" ? d.actionStatus : "",
narration: typeof d.narration === "string" ? d.narration : "",
cbwId: normalizeCbwId(d.cbwId),
cbwName: normalizeCbwName(d.cbwName),
};
// 取到即整包暂存:登录解析任务id 时(resolveDeeplinkTaskId→fetchTaskIdFromAction)就会缓存它,
// 之后会商左上角「当前行动」、单智能体行动详情卡都能直接 readActionDetail(),避免重复请求。
@@ -250,13 +321,49 @@ export async function resolveDeeplinkTaskId(goPath: string, rawId: string): Prom
return rawId;
}
/**
* 深链预取:登录**进行中**就把不依赖 DeerFlow 登录态的深链数据并行拉好——
* 深链配置(公开端点)、xdfx 行动详情(含其内部缓存的配置)、任务详情
* (consumer 接口走静态 admin Bearer,与登录无关)。登录成功后登录页的
* 深链解析直接消费这些在途/已完成的结果,省掉「配置→行动→任务详情」
* 的整段登录后串行等待。
*
* 失败语义与登录页原逻辑一致:行动详情失败按原始 id 回退拉任务详情、
* realTaskId 给 ""(调用方回退原始 id);任务详情失败为 null(调用方
* 走 stub)。两个 promise 均不 reject。
*/
export interface TaskDeeplinkPrefetch {
/** 真实任务 id(xdfx:行动→任务;其余原样返回)。 */
realTaskId: Promise<string>;
/** 任务详情(xdfx 已串好「行动详情→任务详情」链;无数据/失败为 null)。 */
taskDetail: Promise<CopTaskDetail | null>;
}
export function prefetchTaskDeeplink(taskId: string, goPath: string): TaskDeeplinkPrefetch {
const parsed = parseGoPath(goPath);
if (parsed?.businessCode === XDFX_BUSINESS_CODE) {
const actionDetail = fetchActionDetail(taskId).catch((error: unknown) => {
console.warn("[task-deeplink] prefetch action detail failed", { taskId, error });
return null;
});
return {
realTaskId: actionDetail.then((ad) => ad?.taskId ?? ""),
taskDetail: actionDetail.then((ad) => fetchCopTaskDetail(ad?.taskId || taskId)),
};
}
return {
realTaskId: Promise.resolve(taskId),
taskDetail: fetchCopTaskDetail(taskId),
};
}
export interface TaskDeeplinkTarget {
path: string;
/** react-router navigate 的 state(多智能体会商用 roundtablePrefill)。 */
state?: Record<string, unknown>;
}
/** xdfx(行动分析)开场信息里要追加的行动信息:行动标题 + 行动内容 + 行动id。 */
/** xdfx(行动分析)开场信息里要追加的行动信息:行动标题 + 行动内容 + 行动id + 传播网。 */
export interface ActionOpeningInfo {
/** 行动id(action_id)。 */
actionId?: string;
@@ -264,20 +371,29 @@ export interface ActionOpeningInfo {
actionName?: string;
/** 行动内容(行动叙事 narration)。 */
narration?: string;
/** 传播网 id。为 null / 空时不写入开场信息,名称一并省略。 */
cbwId?: string | null;
/** 传播网名称。仅在有传播网 id 时写入。 */
cbwName?: string | null;
}
/**
* 用 taskName / taskContent 拼出开场信息文本;末尾附上任务 id(task_id),供后端识别任务。
* ``taskType`` 能映射时插入「任务类型:打击/塑势/防御」。
* xdfx(行动分析)额外附上「行动标题 / 行动内容 / 行动id(action_id)」——传入 action 时追加这几行。
* 传播网 id、传播网名称仅在 ``cbwId`` 有值时追加;为 null 时两行都不写。
*/
function buildOpeningText(detail: CopTaskDetail, taskId: string, action?: ActionOpeningInfo): string {
const taskTypeLabel = mapCopTaskTypeLabel(detail.taskType);
return [
detail.taskName ? `任务名称:${detail.taskName}` : "",
detail.taskContent ? `任务内容:${detail.taskContent}` : "",
taskTypeLabel ? `任务类型:${taskTypeLabel}` : "",
taskId ? `任务id(task_id)为${taskId}` : "",
action?.actionName ? `行动标题:${action.actionName}` : "",
action?.narration ? `行动内容:${action.narration}` : "",
action?.actionId ? `行动id(action_id)为${action.actionId}` : "",
...cbwModelLines(action?.cbwId, action?.cbwName),
]
.filter(Boolean)
.join("\n");
@@ -368,13 +484,19 @@ export async function resolveTaskDeeplinkTarget(
// 开最新历史对话 / 新建。rwfx 在工作区里额外过「目的树闸门」;3qfx/xdfx 仅 1:N 列表,
// 新建对话直接带开场信息自动发出(见 task-purpose / AgentChatPage 的 taskGate / taskBind)。
// xdfx(行动分析):聊天记录按「行动id」划分(partitionByAction),且开场信息追加
// 行动标题 / 行动内容 / action_id(行动详情登录解析任务id 时已缓存,优先读缓存避免重复请求)。
// 行动标题 / 行动内容 / action_id / 传播网(行动详情登录解析任务id 时已缓存,优先读缓存避免重复请求)。
const isXdfx = parsed.businessCode === XDFX_BUSINESS_CODE;
const actionId = rawId || taskId;
let actionInfo: ActionOpeningInfo | undefined;
if (isXdfx) {
const ad = rawId ? (readActionDetail() ?? (await fetchActionDetail(rawId))) : null;
actionInfo = { actionId, actionName: ad?.actionName, narration: ad?.narration };
actionInfo = {
actionId,
actionName: ad?.actionName,
narration: ad?.narration,
cbwId: ad?.cbwId,
cbwName: ad?.cbwName,
};
}
persistTaskContext({
taskId,
+14 -2
View File
@@ -33,7 +33,7 @@ export interface PurposeTreeNode {
children: PurposeTreeNode[];
}
export type TaskDeeplinkGoPath = "3qfx" | "rwfx" | "xdfx";
export type TaskDeeplinkGoPath = "3qfx" | "rwfx" | "xdfx" | "b5-1";
/** 当前任务上下文(深链进入或「新建对话」时暂存,供闸门面板与绑定使用)。 */
export interface TaskContext {
@@ -51,6 +51,12 @@ export interface TaskContext {
partitionByAction?: boolean;
/** 单智能体深链接类型;用于写入专用 taskId + goPath 聊天记录索引。 */
goPath?: TaskDeeplinkGoPath;
/**
* b5-1 深链 ``?type=`` 业务类型。聊天记录在 taskId + goPath 之上再按该类型
* 细分分区(新对话打 `metadata.taskType` 标签、列表按 sub_type 过滤),
* 不同类型的对话互不可见。仅 b5-1 使用,其它深链不设置。
*/
taskType?: string;
}
const TASK_CONTEXT_STORAGE_KEY = "task.rwfxContext";
@@ -369,8 +375,12 @@ export function readTaskContext(): TaskContext | null {
openingText: typeof parsed.openingText === "string" ? parsed.openingText : "",
actionId: typeof parsed.actionId === "string" ? parsed.actionId : undefined,
partitionByAction: parsed.partitionByAction === true,
taskType: typeof parsed.taskType === "string" ? parsed.taskType.trim() : undefined,
goPath:
parsed.goPath === "3qfx" || parsed.goPath === "rwfx" || parsed.goPath === "xdfx"
parsed.goPath === "3qfx" ||
parsed.goPath === "rwfx" ||
parsed.goPath === "xdfx" ||
parsed.goPath === "b5-1"
? parsed.goPath
: undefined,
};
@@ -399,6 +409,8 @@ export function taskThreadMetadata(
? {
taskRecordId,
taskGoPath: resolvedGoPath,
// b5-1 ?type=:同一 taskId 下不同业务类型的聊天记录分区。
...(ctx.taskType ? { taskType: ctx.taskType } : {}),
thread_type: "task_deeplink",
}
: {}),
@@ -70,6 +70,7 @@ function persistUserDetailToken(data: UserDetailTokenPayload): void {
*/
export async function resolveUsernameByOnlineTicket(
ticket: string,
isCurrent: () => boolean = () => true,
): Promise<string> {
const onlineTicket = ticket.trim();
if (!onlineTicket) {
@@ -81,13 +82,23 @@ export async function resolveUsernameByOnlineTicket(
if (!CONSUMER_URL) {
throw new Error("未配置 VITE_AIPI_CONSUMER_URL,无法获取用户详情。");
}
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
persistExternalLoginParams({ ticket: onlineTicket });
const tokenRes = (await requestCrudService.get(
`${AUTH_BASE_URL}/maxkey-mgt-api/sys/getTokenByOnlineTicket`,
{ onlineTicket },
)) as unknown as MaxkeyTokenByTicketResponse;
// 两个接口都只依赖 onlineTicket(MaxKey token 仅供后续请求使用,不参与
// 用户名解析),并行发起,把两轮外部串行往返压缩为一轮。
const [tokenRes, detailRes] = (await Promise.all([
requestCrudService.get(
`${AUTH_BASE_URL}/maxkey-mgt-api/sys/getTokenByOnlineTicket`,
{ onlineTicket },
),
requestCrudService.get(`${CONSUMER_URL}/cop/userDetailToken`, {
ticket: onlineTicket,
}),
])) as unknown as [MaxkeyTokenByTicketResponse, UserDetailTokenResponse];
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
console.log("[wujie] getTokenByOnlineTicket:", tokenRes);
@@ -98,11 +109,6 @@ export async function resolveUsernameByOnlineTicket(
}
persistMaxkeyOnlineToken(tokenRes.data);
const detailRes = (await requestCrudService.get(
`${CONSUMER_URL}/cop/userDetailToken`,
{ ticket: onlineTicket },
)) as unknown as UserDetailTokenResponse;
console.log("[wujie] userDetailToken:", detailRes);
const userData = detailRes.data;
@@ -175,7 +175,9 @@ export async function loginByYUserId(
yUserId: string,
token?: string | null,
access_token?: string | null,
isCurrent: () => boolean = () => true,
): Promise<void> {
if (!isCurrent()) return;
if (token) {
authStorageSet(TOOLS_TOKEN_STORAGE_KEY, token);
}
@@ -199,6 +201,7 @@ export async function loginByYUserId(
// 注意:接口返回的 accessToken 优先;登录 URL 的 access_token 只在接口值为空时兜底。
// The consumer API may return accessToken. URL access_token is only used as
// a fallback when that API token is empty.
if (!isCurrent()) return;
const result = res.data ?? {};
logYUserLogin("response", {
keys: Object.keys(result),
@@ -0,0 +1,166 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Message } from "@langchain/langgraph-sdk";
// @ts-ignore Node's type-stripping runner requires the TypeScript suffix.
import * as research from "./knowledge-research.ts";
const {
canPublishKnowledgeResearch,
knowledgeResearchContinuationSessionId,
knowledgeResearchDraftPatch,
knowledgeResearchLibraryQueryKey,
knowledgeResearchPublicationQueryKey,
knowledgeResearchSessionQueryKey,
knowledgeResearchPrompt,
knowledgeResearchTitle,
latestKnowledgeResearchAnswer,
shouldAutoAdoptFirstAnswer,
shouldHydrateKnowledgeResearchDraft,
} = research;
test("knowledgeResearchTitle normalizes whitespace and bounds the default title", () => {
assert.equal(knowledgeResearchTitle(" 一个\n研究问题 "), "一个 研究问题");
assert.equal(knowledgeResearchTitle("abcdefgh", 5), "abcde…");
});
test("latestKnowledgeResearchAnswer ignores hidden and tool-call messages", () => {
const messages = [
{ id: "human-1", type: "human", content: "问题" },
{ id: "answer-1", type: "ai", content: "# 可保存报告" },
{
id: "tool-call",
type: "ai",
content: "不应保存",
tool_calls: [{ id: "call-1", name: "search", args: {} }],
},
{
id: "hidden-answer",
type: "ai",
content: "隐藏内容",
additional_kwargs: { hide_from_ui: true },
},
{
id: "status-answer",
type: "ai",
content: "上传中或限流状态不应成为正文",
additional_kwargs: { element: "task" },
},
] as Message[];
assert.deepEqual(latestKnowledgeResearchAnswer(messages), {
messageId: "answer-1",
content: "# 可保存报告",
});
});
test("only an empty draft automatically adopts the first answer", () => {
const candidate = { messageId: "answer-1", content: "正文" };
assert.equal(
shouldAutoAdoptFirstAnswer({ draftMarkdown: "", candidate }),
true,
);
assert.equal(
shouldAutoAdoptFirstAnswer({
draftMarkdown: "",
candidate,
answerCount: 2,
}),
false,
);
assert.equal(
shouldAutoAdoptFirstAnswer({ draftMarkdown: "旧正文", candidate }),
false,
);
assert.equal(
shouldAutoAdoptFirstAnswer({
draftMarkdown: "",
draftSourceMessageId: "answer-1",
candidate,
}),
false,
);
});
test("research composer submits text only, without attachments", () => {
assert.deepEqual(knowledgeResearchPrompt(" 继续追问 "), {
text: "继续追问",
files: [],
});
});
test("private conversation continuation is exposed only to its owner", () => {
const sessions = [{ id: "session-one", publication: { id: "book-one" } }];
assert.equal(
knowledgeResearchContinuationSessionId(
{
id: "book-one",
sessionId: "should-not-leak",
ownedByCurrentUser: false,
canContinue: false,
},
sessions,
),
null,
);
assert.equal(
knowledgeResearchContinuationSessionId(
{
id: "book-one",
ownedByCurrentUser: true,
canContinue: true,
},
sessions,
),
"session-one",
);
});
test("publishing requires both knowledge-base and session write access", () => {
assert.equal(canPublishKnowledgeResearch(true, true), true);
assert.equal(canPublishKnowledgeResearch(true, false), false);
assert.equal(canPublishKnowledgeResearch(false, true), false);
});
test("a refetch does not rehydrate and overwrite the active local draft", () => {
assert.equal(shouldHydrateKnowledgeResearchDraft(null, "session-one"), true);
assert.equal(
shouldHydrateKnowledgeResearchDraft("session-one", "session-one"),
false,
);
});
test("publishing a renamed file only patches the draft, not the Q&A title", () => {
assert.deepEqual(
knowledgeResearchDraftPatch({
draftMarkdown: "新正文",
savedDraftMarkdown: "旧正文",
dirty: true,
}),
{ draftMarkdown: "新正文" },
);
assert.equal(
"title" in
(knowledgeResearchDraftPatch({
draftMarkdown: "新正文",
savedDraftMarkdown: "旧正文",
dirty: true,
}) ?? {}),
false,
);
});
test("private research query caches are isolated by live account", () => {
const aliceLibrary = knowledgeResearchLibraryQueryKey("alice", "kb-one");
const bobLibrary = knowledgeResearchLibraryQueryKey("bob", "kb-one");
assert.notDeepEqual(aliceLibrary, bobLibrary);
assert.notDeepEqual(
knowledgeResearchSessionQueryKey("alice", "kb-one", "session-one"),
knowledgeResearchSessionQueryKey("bob", "kb-one", "session-one"),
);
assert.notDeepEqual(
knowledgeResearchPublicationQueryKey("alice", "kb-one", "book-one"),
knowledgeResearchPublicationQueryKey("bob", "kb-one", "book-one"),
);
});
@@ -0,0 +1,171 @@
import type { Message } from "@langchain/langgraph-sdk";
export const KNOWLEDGE_RESEARCH_DIRECTORY = "深度研究";
export const KNOWLEDGE_RESEARCH_ANONYMOUS_SCOPE = "anonymous";
export function knowledgeResearchLibraryQueryKey(
userScope: string,
knowledgeBaseId: string,
) {
return [
"llmwiki",
"knowledge-research",
userScope || KNOWLEDGE_RESEARCH_ANONYMOUS_SCOPE,
knowledgeBaseId,
] as const;
}
export function knowledgeResearchSessionQueryKey(
userScope: string,
knowledgeBaseId: string,
sessionId: string | null,
) {
return [
...knowledgeResearchLibraryQueryKey(userScope, knowledgeBaseId),
"session",
sessionId,
] as const;
}
export function knowledgeResearchPublicationQueryKey(
userScope: string,
knowledgeBaseId: string,
publicationId: string | null,
) {
return [
...knowledgeResearchLibraryQueryKey(userScope, knowledgeBaseId),
"publication",
publicationId,
] as const;
}
export function knowledgeResearchPrompt(text: string) {
return { text: text.trim(), files: [] };
}
export function canPublishKnowledgeResearch(
knowledgeBaseCanWrite: boolean,
sessionCanWrite: boolean,
): boolean {
return knowledgeBaseCanWrite && sessionCanWrite;
}
export function shouldHydrateKnowledgeResearchDraft(
hydratedSessionId: string | null,
nextSessionId: string,
): boolean {
return hydratedSessionId !== nextSessionId;
}
export function knowledgeResearchDraftPatch(input: {
draftMarkdown: string;
savedDraftMarkdown: string;
dirty: boolean;
}): { draftMarkdown: string } | null {
if (!input.dirty && input.draftMarkdown === input.savedDraftMarkdown) {
return null;
}
return { draftMarkdown: input.draftMarkdown };
}
export function knowledgeResearchContinuationSessionId(
publication: {
id: string;
sessionId?: string;
ownedByCurrentUser: boolean;
canContinue: boolean;
},
sessions: Array<{
id: string;
publication?: { id: string } | null;
}>,
): string | null {
if (!publication.ownedByCurrentUser || !publication.canContinue) return null;
if (publication.sessionId) return publication.sessionId;
return (
sessions.find((session) => session.publication?.id === publication.id)?.id ??
null
);
}
export function knowledgeResearchTitle(question: string, maxLength = 60): string {
const normalized = question.replace(/\s+/g, " ").trim();
if (!normalized) return "未命名问答";
return normalized.length > maxLength
? `${normalized.slice(0, maxLength).trimEnd()}…`
: normalized;
}
export type KnowledgeResearchAnswerCandidate = {
messageId: string;
content: string;
};
function isHidden(message: Message): boolean {
const metadata = message.additional_kwargs as
| Record<string, unknown>
| undefined;
return (
metadata?.hide_from_ui === true ||
metadata?.hidden === true ||
metadata?.element === "task"
);
}
function textContent(message: Message): string {
if (typeof message.content === "string") return message.content;
if (!Array.isArray(message.content)) return "";
return message.content
.map((block) => {
if (!block || typeof block !== "object") return "";
const text = (block as { text?: unknown }).text;
return typeof text === "string" ? text : "";
})
.filter(Boolean)
.join("\n");
}
/**
* Return the latest visible, textual assistant answer. Tool-only/internal
* messages are deliberately ignored so they can never become the saved book.
*/
export function latestKnowledgeResearchAnswer(
messages: Message[] | undefined,
): KnowledgeResearchAnswerCandidate | null {
const answers = knowledgeResearchAnswerCandidates(messages);
return answers.at(-1) ?? null;
}
export function knowledgeResearchAnswerCandidates(
messages: Message[] | undefined,
): KnowledgeResearchAnswerCandidate[] {
if (!messages?.length) return [];
const answers: KnowledgeResearchAnswerCandidate[] = [];
for (const message of messages) {
if (
message.type !== "ai" ||
isHidden(message) ||
!message.id ||
(message.tool_calls?.length ?? 0) > 0
) {
continue;
}
const content = textContent(message).trim();
if (content) answers.push({ messageId: message.id, content });
}
return answers;
}
export function shouldAutoAdoptFirstAnswer(input: {
draftMarkdown?: string | null;
draftSourceMessageId?: string | null;
candidate: KnowledgeResearchAnswerCandidate | null;
answerCount?: number;
}): boolean {
return Boolean(
input.candidate &&
(input.answerCount ?? 1) === 1 &&
!input.draftMarkdown?.trim() &&
input.draftSourceMessageId !== input.candidate.messageId,
);
}
+2 -1
View File
@@ -3,10 +3,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { loadMCPConfig, updateMCPConfig } from "./api";
import type { MCPServerConfig } from "./types";
export function useMCPConfig() {
export function useMCPConfig(enabled = true) {
const { data, isLoading, error } = useQuery({
queryKey: ["mcpConfig"],
queryFn: () => loadMCPConfig(),
enabled,
});
return { config: data, isLoading, error };
}
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Message } from "@langchain/langgraph-sdk";
import { sliceMessagesByRecentHumanTurns } from "./history-window.ts";
function human(id: string): Message {
return { id, type: "human", content: id } as Message;
}
function ai(id: string): Message {
return { id, type: "ai", content: id } as Message;
}
test("keeps the newest human turns and their trailing assistant chain", () => {
const messages = [
human("q1"),
ai("a1"),
human("q2"),
ai("a2"),
human("q3"),
ai("a3"),
];
const sliced = sliceMessagesByRecentHumanTurns(messages, 2);
assert.equal(sliced.hiddenHumanTurns, 1);
assert.deepEqual(
sliced.messages.map((message) => message.id),
["q2", "a2", "q3", "a3"],
);
});
test("returns the original list when the window covers every turn", () => {
const messages = [human("q1"), ai("a1")];
const sliced = sliceMessagesByRecentHumanTurns(messages, 12);
assert.equal(sliced.hiddenHumanTurns, 0);
assert.equal(sliced.messages.length, 2);
});
@@ -0,0 +1,42 @@
import type { Message } from "@langchain/langgraph-sdk";
/** First paint only keeps this many user turns (plus the trailing assistant chain). */
export const INITIAL_VISIBLE_HUMAN_TURNS = 12;
export const MORE_VISIBLE_HUMAN_TURNS = 12;
export const REVEAL_CHAT_HISTORY_EVENT = "deerflow:reveal-chat-history";
export function countHumanTurns(messages: readonly Message[]): number {
return messages.reduce(
(count, message) => count + (message.type === "human" ? 1 : 0),
0,
);
}
/**
* Keep the newest ``visibleHumanTurns`` user questions and everything after
* the first of those questions. Earlier turns stay in memory but are not
* rendered until the user asks for them.
*/
export function sliceMessagesByRecentHumanTurns(
messages: readonly Message[],
visibleHumanTurns: number,
): { messages: Message[]; hiddenHumanTurns: number } {
if (visibleHumanTurns <= 0 || messages.length === 0) {
return { messages: [...messages], hiddenHumanTurns: 0 };
}
const humanIndices: number[] = [];
for (let index = 0; index < messages.length; index += 1) {
if (messages[index]?.type === "human") {
humanIndices.push(index);
}
}
if (humanIndices.length <= visibleHumanTurns) {
return { messages: [...messages], hiddenHumanTurns: 0 };
}
const start = humanIndices[humanIndices.length - visibleHumanTurns] ?? 0;
return {
messages: messages.slice(start),
hiddenHumanTurns: humanIndices.length - visibleHumanTurns,
};
}
@@ -16,6 +16,7 @@ export interface ManagedModel {
stream_usage: boolean | null;
supports_thinking: boolean;
supports_reasoning_effort: boolean;
default_reasoning_effort: "low" | "medium" | "xhigh";
supports_vision: boolean;
when_thinking_enabled: Record<string, unknown> | null;
when_thinking_disabled: Record<string, unknown> | null;
@@ -100,6 +101,7 @@ export async function updateManagedModel(name: string, payload: ModelSavePayload
export async function updateManagedModelReasoningEffortCapability(
name: string,
supportsReasoningEffort: boolean,
defaultReasoningEffort: "low" | "medium" | "xhigh",
): Promise<ManagedModel> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/models/capabilities/reasoning-effort/${encodeURIComponent(name)}`,
@@ -108,6 +110,7 @@ export async function updateManagedModelReasoningEffortCapability(
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
supports_reasoning_effort: supportsReasoningEffort,
default_reasoning_effort: defaultReasoningEffort,
}),
},
);
@@ -46,13 +46,16 @@ export function useUpdateManagedModelReasoningEffortCapability() {
mutationFn: ({
name,
supportsReasoningEffort,
defaultReasoningEffort,
}: {
name: string;
supportsReasoningEffort: boolean;
defaultReasoningEffort: "low" | "medium" | "xhigh";
}) =>
updateManagedModelReasoningEffortCapability(
name,
supportsReasoningEffort,
defaultReasoningEffort,
),
onSuccess: async () => {
await Promise.all([
@@ -12,11 +12,13 @@ test("normalizes legacy reasoning effort values", () => {
assert.equal(normalizeReasoningEffort("unknown"), undefined);
});
test("uses xhigh by default and clears effort for unsupported models", () => {
test("uses the shared model setting and clears effort for unsupported models", () => {
assert.equal(resolveModelReasoningEffort(true, undefined), "xhigh");
assert.equal(resolveModelReasoningEffort(true, "low"), "low");
assert.equal(resolveModelReasoningEffort(true, "low"), "xhigh");
assert.equal(resolveModelReasoningEffort(true, "low", true), "xhigh");
assert.equal(resolveModelReasoningEffort(false, "xhigh"), undefined);
assert.equal(resolveModelReasoningEffort(true, undefined, false, "low"), "low");
assert.equal(resolveModelReasoningEffort(true, "xhigh", false, "low"), "low");
});
test("keeps effort independent from mode except flash", () => {
@@ -31,19 +31,17 @@ export function normalizeReasoningEffort(
}
}
/** Resolve the composer value for a selected model. */
/** Resolve the globally configured value for a selected model. */
export function resolveModelReasoningEffort(
supportsReasoningEffort: boolean,
value: unknown,
resetToDefault = false,
_value: unknown,
_resetToDefault = false,
defaultEffort: ReasoningEffort = DEFAULT_REASONING_EFFORT,
): ReasoningEffort | undefined {
if (!supportsReasoningEffort) {
return undefined;
}
if (resetToDefault) {
return DEFAULT_REASONING_EFFORT;
}
return normalizeReasoningEffort(value) ?? DEFAULT_REASONING_EFFORT;
return defaultEffort;
}
/** Flash mode never sends an effort; all other modes use the independent value. */
+1
View File
@@ -6,6 +6,7 @@ export interface Model {
description?: string | null;
supports_thinking?: boolean;
supports_reasoning_effort?: boolean;
default_reasoning_effort?: "low" | "medium" | "xhigh";
/** Execution profiles handled by the frontend instead of DeerFlow. */
execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
}
@@ -34,6 +34,12 @@ test("matches deeplink single-agent workspaces", () => {
),
true,
);
assert.equal(
isHostNestedSurfacePath(
"/page/strategy/task-management/plan-task-b5-1/agent/analyst/chats/new",
),
true,
);
});
test("matches deeplink roundtable and dashboard-agent", () => {
@@ -8,9 +8,9 @@
const TASK_ROUNDTABLE_RE =
/\/task-management\/(?:plan-task-sentiment|plan-task\/plan-task-fx|plan-task\/plan-task-action)\/roundtable(?:\/|$)/;
/** 任务管理下的深链单智能体问答(含 agentfx)。 */
/** 任务管理下的深链单智能体问答(含 agentfx / b5-1 任务分析)。 */
const TASK_AGENT_CHATS_RE =
/\/task-management\/(?:plan-task-sentiment|plan-task\/plan-task-fx|plan-task\/plan-task-action|plan-task-agentfx)\/agent\/[^/]+\/chats/;
/\/task-management\/(?:plan-task-sentiment|plan-task\/plan-task-fx|plan-task\/plan-task-action|plan-task-agentfx|plan-task-b5-1)\/agent\/[^/]+\/chats/;
export function isHostNestedSurfacePath(pathname: string): boolean {
const path = (pathname || "").split("?")[0] || "";
@@ -0,0 +1,26 @@
/**
* 路由 chunk 预取:顶层路由(workspace/strategy/canvas/roundtable/notebook)
* 都是 React.lazy 懒加载 chunk,登录成功跳转后才现场下载会拖慢「进入系统」。
* 登录进行中按目标路径前缀提前 import 同一模块(与 PageRoutes 的 lazy
* import 命中同一 chunk,模块注册表共享),跳转后立即可渲染。
*
* best-effort:失败静默(不影响跳转后的正常按需加载),未知前缀不预取。
* 注意不要把本逻辑挪进 PageRoutes.tsx 再从登录页 import——那会把整个布局
* 模块图静态拖进登录包,适得其反;这里的 import 全是动态的,不增加首屏体积。
*/
type ChunkLoader = () => Promise<unknown>;
const PREFIX_LOADERS: Array<[prefix: string, load: ChunkLoader]> = [
["/page/workspace", () => import("@/pages/WorkspaceRoutes")],
["/page/strategy", () => import("@/pages/StrategyRoutes")],
["/page/canvas", () => import("@/open-canvas/routes/OpenCanvasRoutes")],
["/page/roundtable", () => import("@/pages/RoundtableRoutes")],
["/page/notebook", () => import("@/pages/NotebookRoutes")],
];
export function prefetchRouteChunk(path: string): void {
const entry = PREFIX_LOADERS.find(
([prefix]) => path === prefix || path.startsWith(`${prefix}/`),
);
if (entry) void entry[1]().catch(() => undefined);
}
@@ -108,7 +108,7 @@ export const sidebarMenuItems: MenuItem[] = [
path: "/page/strategy/qa/zzjc",
},
{ id: "qa-zzxd", label: "XD应用", icon: Layout, path: "/page/strategy/qa/zzxd" },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing", aliasPaths: ["/page/notebook/ai-writing"] },
// {
// id: "qa-research-chat",
// label: "课题研究(对话版)",
@@ -333,6 +333,13 @@ export const sidebarMenuItems: MenuItem[] = [
path: "/page/strategy/admin/user-activity",
adminOnly: true,
},
{
id: "mgmt-audit-retention",
label: "审计数据清理",
icon: Database,
path: "/page/strategy/admin/audit-retention",
adminOnly: true,
},
],
},
{
@@ -13,7 +13,7 @@ import { Layout } from "lucide-react";
import { getStoredAuth } from "@/core/auth";
import { desensitize, makeDesensitizer } from "@/lib/desensitize";
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
import { useSidebarLightApps, type LightAppSidebarItem } from "@/strategy-components/api/light-apps";
import { useMenuOverrides } from "@/strategy-components/api/menu-overrides";
import { useSensitiveWords, wordsToMap } from "@/strategy-components/api/sensitive-words";
@@ -25,7 +25,7 @@ import {
type MenuItem,
} from "./sidebar-menu";
function lightAppLeaf(app: LightApp): MenuItem {
function lightAppLeaf(app: LightAppSidebarItem): MenuItem {
return {
id: `light-app-${app.appId}`,
label: app.name,
@@ -35,10 +35,10 @@ function lightAppLeaf(app: LightApp): MenuItem {
}
/** 在菜单树(不可变克隆)中,把 iframe 应用按 mountLocation 注入对应父节点末尾。 */
export function injectLightApps(items: MenuItem[], apps: LightApp[]): MenuItem[] {
export function injectLightApps(items: MenuItem[], apps: LightAppSidebarItem[]): MenuItem[] {
if (apps.length === 0) return items;
const byMount = new Map<string, LightApp[]>();
const byMount = new Map<string, LightAppSidebarItem[]>();
for (const app of apps) {
if (!app.mountLocation) continue;
const list = byMount.get(app.mountLocation) ?? [];
@@ -88,7 +88,7 @@ function useDesensitizeLabel() {
* 全局 {@link desensitize}),避免依赖 {@link replaceDesensitizeMap} 的 effect 回灌时序。
*/
export function useSidebarMenuItems(isAdmin: boolean): MenuItem[] {
const { data: iframeApps = [] } = useLightApps({ appType: "iframe", enabledOnly: true });
const { data: iframeApps = [] } = useSidebarLightApps();
const { data: overrideConfig } = useMenuOverrides();
const overrides = overrideConfig?.overrides ?? [];
const desensitizeLabel = useDesensitizeLabel();
+9 -2
View File
@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getAccountIdentityKey } from "@/core/auth";
import {
addMembers,
@@ -27,7 +28,7 @@ const positionKeys = {
bindings: (id: string) => ["positions", "bindings", id] as const,
members: (id: string) => ["positions", "members", id] as const,
users: ["positions", "users"] as const,
me: ["positions", "me"] as const,
me: (accountKey: string | null) => ["positions", "me", accountKey ?? "anonymous"] as const,
};
export function usePositions() {
@@ -36,7 +37,13 @@ export function usePositions() {
/** The caller's own effective position (explicit, else default). */
export function useMyPosition() {
return useQuery({ queryKey: positionKeys.me, queryFn: getMyPosition });
const accountKey = getAccountIdentityKey();
return useQuery({
queryKey: positionKeys.me(accountKey),
queryFn: getMyPosition,
staleTime: 5 * 60_000,
refetchOnWindowFocus: false,
});
}
export function usePositionUsers() {
+71 -68
View File
@@ -5,10 +5,10 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import {
deleteBuiltinSkill,
deleteCustomSkill,
deleteSkillFile,
import {
deleteBuiltinSkill,
deleteCustomSkill,
deleteSkillFile,
duplicateSkill,
enableSkill,
generateMissingSkillNameZh,
@@ -41,14 +41,17 @@ import { loadAllSkillsAdmin, loadSkills } from ".";
// forever (observed as a "Maximum update depth exceeded" loop on NewAgentPage).
const EMPTY_SKILLS: Skill[] = [];
export function useSkills(params: ListSkillsParams = {}) {
export function useSkills(params: ListSkillsParams & { enabled?: boolean } = {}) {
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const scope = params.scope;
const enabled = params.enabled ?? true;
const { data, isLoading, error } = useQuery({
queryKey: ["skills", search, [...tagIds].sort(), sort, scope ?? "all"],
queryFn: () => loadSkills({ search, tagIds, sort, scope }),
enabled,
staleTime: 30_000,
placeholderData: keepPreviousData,
});
return { skills: data ?? EMPTY_SKILLS, isLoading, error };
@@ -159,32 +162,32 @@ export function useSetSkillDisplay() {
export function useEnableSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
mutationFn: ({
skillName,
enabled,
}: {
skillName: string;
enabled: boolean;
}) => enableSkill(skillName, enabled),
onSuccess: (data, { skillName }) => {
// The availability endpoint intentionally returns the filesystem skill
// without ownership/tag annotations. Patch only the authoritative flag
// so a fast cache update cannot erase richer list/detail metadata.
queryClient.setQueryData<Skill>(["skill", skillName], (current) =>
current ? { ...current, enabled: data.enabled } : current,
);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
Array.isArray(current)
? current.map((item) =>
item.name === skillName
? { ...item, enabled: data.enabled }
: item,
)
: current,
);
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
}) => enableSkill(skillName, enabled),
onSuccess: (data, { skillName }) => {
// The availability endpoint intentionally returns the filesystem skill
// without ownership/tag annotations. Patch only the authoritative flag
// so a fast cache update cannot erase richer list/detail metadata.
queryClient.setQueryData<Skill>(["skill", skillName], (current) =>
current ? { ...current, enabled: data.enabled } : current,
);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
Array.isArray(current)
? current.map((item) =>
item.name === skillName
? { ...item, enabled: data.enabled }
: item,
)
: current,
);
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
@@ -206,17 +209,17 @@ export function useSetSkillAlwaysOn() {
});
}
export function useValidateSkillUpload() {
return useMutation({
mutationFn: ({
file,
targetCategory,
}: {
file: File;
targetCategory?: "public" | "custom";
}) => validateSkillUpload(file, targetCategory),
});
}
export function useValidateSkillUpload() {
return useMutation({
mutationFn: ({
file,
targetCategory,
}: {
file: File;
targetCategory?: "public" | "custom";
}) => validateSkillUpload(file, targetCategory),
});
}
export function useInstallSkillUpload() {
const queryClient = useQueryClient();
@@ -340,7 +343,7 @@ export function useSkillFileContent(skillName: string | null, path: string | nul
});
}
export function useSaveSkillFile() {
export function useSaveSkillFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
@@ -361,33 +364,33 @@ export function useSaveSkillFile() {
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
}
},
});
}
export function useDeleteSkillFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skillName,
path,
}: {
skillName: string;
path: string;
}) => deleteSkillFile(skillName, path),
onSuccess: (_data, { skillName, path }) => {
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
void queryClient.removeQueries({ queryKey: ["skill-file", skillName, path] });
},
});
}
export function useDeleteBuiltinSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteBuiltinSkill,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["admin-skills"] });
},
});
}
});
}
export function useDeleteSkillFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skillName,
path,
}: {
skillName: string;
path: string;
}) => deleteSkillFile(skillName, path),
onSuccess: (_data, { skillName, path }) => {
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
void queryClient.removeQueries({ queryKey: ["skill-file", skillName, path] });
},
});
}
export function useDeleteBuiltinSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteBuiltinSkill,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["admin-skills"] });
},
});
}
+32
View File
@@ -0,0 +1,32 @@
import { desensitize } from "@/lib/desensitize";
import type { Skill } from "./type";
/**
* Tokenized AND-match over a skill's searchable text. Mirrors the backend
* ``GET /api/skills?search=`` matcher so client-side filters (the admin skill
* list) behave exactly like the server-side ones.
*
* Searchable fields: 英文名 name、中文名 name_zh、SKILL.md 描述 description、
* 长文本详情 detail、标签名。空格分隔多个关键词,每个关键词命中任意一个
* 字段即算该关键词命中;所有关键词都命中才保留该技能(AND 语义,跨字段)。
* 匹配在脱敏后的文本上进行,与界面实际展示的内容保持一致。
*/
export function skillMatchesSearch(skill: Skill, query: string): boolean {
const tokens = query
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
if (tokens.length === 0) return true;
const haystacks = [
skill.name ?? "",
skill.name_zh ?? "",
skill.description ?? "",
skill.detail ?? "",
(skill.tags ?? []).map((tag) => tag.name ?? "").join(" "),
].map((text) => desensitize(text).toLowerCase());
return tokens.every((token) =>
haystacks.some((field) => field.includes(token)),
);
}
@@ -734,6 +734,20 @@ export interface ConversationContextUpdate {
recent_turn_window_enabled: boolean;
}
export interface RoundtableRoomLimitSettings {
enabled: boolean;
max_rooms: number;
max_hold_minutes: number;
max_cycles: number;
}
export interface RoundtableRoomLimitUpdate {
enabled?: boolean;
max_rooms?: number;
max_hold_minutes?: number;
max_cycles?: number;
}
export interface OrdinaryKnowledgeRetrievalSettings {
smart_reasoning_enabled: boolean;
advanced_management_enabled: boolean;
@@ -870,6 +884,46 @@ export async function updateConversationContextSettings(
return data;
}
export async function loadRoundtableRoomLimitSettings(): Promise<RoundtableRoomLimitSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/roundtable-room-limit`,
);
if (!response.ok) {
throw new Error(`无法读取会商运行设置(HTTP ${response.status})`);
}
return (await response.json()) as RoundtableRoomLimitSettings;
}
export async function updateRoundtableRoomLimitSettings(
body: RoundtableRoomLimitUpdate,
): Promise<RoundtableRoomLimitSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/roundtable-room-limit`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "保存会商运行设置失败" }));
const message =
typeof detail.detail === "string" ? detail.detail : "保存会商运行设置失败";
throw new Error(message);
}
const data = (await response.json()) as RoundtableRoomLimitSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-会商运行限制",
url: "/api/system-settings/roundtable-room-limit",
reqParam: body,
returnParam: data,
});
return data;
}
export async function loadOrdinaryKnowledgeRetrievalSettings(): Promise<OrdinaryKnowledgeRetrievalSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-retrieval`,
@@ -8,6 +8,7 @@ import {
loadAppearanceSettings,
loadCitationDisplaySettings,
loadConversationContextSettings,
loadRoundtableRoomLimitSettings,
loadHtmlMapSettings,
loadLeaderboardSettings,
loadModelConcurrencySettings,
@@ -25,6 +26,7 @@ import {
updateAppearanceSettings,
updateCitationDisplaySettings,
updateConversationContextSettings,
updateRoundtableRoomLimitSettings,
updateHtmlMapSettings,
updateLeaderboardSettings,
updateModelConcurrencySettings,
@@ -42,6 +44,7 @@ import {
type AppearanceUpdate,
type CitationDisplayUpdate,
type ConversationContextUpdate,
type RoundtableRoomLimitUpdate,
type HtmlMapEngine,
type LeaderboardSettingsUpdate,
type ModelConcurrencyUpdate,
@@ -73,6 +76,7 @@ const WRITE_FILE_COMPATIBILITY_KEY = [
"writeFileCompatibility",
] as const;
const CONVERSATION_CONTEXT_KEY = ["systemSettings", "conversationContext"] as const;
const ROUNDTABLE_ROOM_LIMIT_KEY = ["systemSettings", "roundtableRoomLimit"] as const;
const ORDINARY_KNOWLEDGE_RETRIEVAL_KEY = [
"systemSettings",
"ordinaryKnowledgeRetrieval",
@@ -230,6 +234,27 @@ export function useUpdateConversationContextSettings() {
});
}
export function useRoundtableRoomLimitSettings() {
return useQuery({
queryKey: ROUNDTABLE_ROOM_LIMIT_KEY,
queryFn: () => loadRoundtableRoomLimitSettings(),
staleTime: 10_000,
refetchOnWindowFocus: true,
retry: false,
});
}
export function useUpdateRoundtableRoomLimitSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: RoundtableRoomLimitUpdate) =>
updateRoundtableRoomLimitSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(ROUNDTABLE_ROOM_LIMIT_KEY, data);
},
});
}
export function useOrdinaryKnowledgeRetrievalSettings(enabled = true) {
return useQuery({
queryKey: ORDINARY_KNOWLEDGE_RETRIEVAL_KEY,
@@ -0,0 +1,264 @@
import assert from "node:assert/strict";
import test from "node:test";
import { setTimeout as delay } from "node:timers/promises";
import type { ThreadState } from "@langchain/langgraph-sdk";
import { MessageTupleManager, StreamManager, type EventStreamEvent } from "@langchain/langgraph-sdk/ui";
import { BackgroundHistory } from "./background-history";
import { createStreamAPIClient } from "../api/api-client";
type Values = { messages: Array<{ id: string; type: "human" | "ai"; content: string }>; title?: string; todos?: string[] };
const values = (text: string): Values => ({ messages: [{ id: `human-${text}`, type: "human", content: text }, { id: `ai-${text}`, type: "ai", content: `answer ${text}` }] });
function head(value: Values): ThreadState<Values> {
return { values: value, next: [], tasks: [], metadata: {}, created_at: null, parent_checkpoint: null, checkpoint: { thread_id: "a", checkpoint_ns: "", checkpoint_id: "cp", checkpoint_map: null } };
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function setup(load?: (id: string, signal: AbortSignal) => Promise<ThreadState<Values>[]>) {
let live = values("first");
const applied: Values[] = [];
const finished: Values[] = [];
const errors: unknown[] = [];
const history = new BackgroundHistory<Values>({
load: load ?? (async () => [head(live)]), values: () => live,
hydrated: (state, _scope, notify) => { applied.push(state.values); if (notify) finished.push(state.values); },
syncFailed: (error) => errors.push(error), retryDelayMs: 1, timeoutMs: 30,
});
history.select("a");
const run = (text: string, id = text) => {
live = values(text);
const observer = history.observe("a", id);
observer.event({ event: "values", data: live });
return observer;
};
return { history, run, applied, finished, errors, setLive: (value: Values) => { live = value; } };
}
async function settle(predicate: () => boolean) {
for (let i = 0; i < 100 && !predicate(); i += 1) await delay(2);
assert.ok(predicate(), "Expected async state to settle");
}
test("post-stream mutate finishes immediately while final history is blocked", async () => {
const blocked = deferred<ThreadState<Values>[]>();
let reads = 0;
const fixture = setup(async () => { reads += 1; return blocked.promise; });
fixture.run("one").event({ event: "end", data: null });
const result = await fixture.history.mutate("a");
assert.deepEqual(result[0]?.values, values("one"));
assert.equal(result[0]?.checkpoint.checkpoint_id, null);
assert.equal(fixture.finished.length, 0);
await settle(() => fixture.history.snapshot().isRefreshing);
// SDK finalization may invoke mutate repeatedly. It must not start another
// /state fetch or abort the one already synchronizing this completed turn.
await fixture.history.mutate("a");
await fixture.history.mutate("a");
assert.equal(reads, 1);
blocked.resolve([head({ ...values("one"), todos: ["done"] })]);
await settle(() => fixture.finished.length === 1);
assert.deepEqual(fixture.finished[0]?.todos, ["done"]);
fixture.history.cancel();
});
test("concurrent terminal state refreshes join one request without aborting it", async () => {
const blocked = deferred<ThreadState<Values>[]>();
let reads = 0;
let wasAborted = false;
const fixture = setup(async (_id, signal) => {
reads += 1;
signal.addEventListener("abort", () => { wasAborted = true; }, { once: true });
return blocked.promise;
});
fixture.run("one");
fixture.history.confirmTerminal();
const first = fixture.history.refresh(true);
const second = fixture.history.refresh(true);
assert.equal(first, second);
await settle(() => reads === 1);
assert.equal(wasAborted, false);
blocked.resolve([head(values("one"))]);
await Promise.all([first, second]);
assert.equal(fixture.finished.length, 1);
fixture.history.cancel();
});
test("an old final-history request cannot overwrite the next user turn even if fetch ignores abort", async () => {
const old = deferred<ThreadState<Values>[]>();
const fixture = setup(async () => old.promise);
fixture.run("one").event({ event: "end", data: null });
await fixture.history.mutate("a");
await settle(() => fixture.history.snapshot().isRefreshing);
fixture.run("two");
old.resolve([head(values("one"))]);
await delay(5);
assert.equal(fixture.finished.length, 0);
assert.equal(fixture.applied.length, 0);
assert.equal(fixture.history.snapshot().isRefreshing, false);
await fixture.history.mutate("a");
assert.deepEqual(fixture.history.snapshot().data?.[0]?.values, values("two"));
fixture.history.cancel();
});
test("initial history, route switches, unmount and late SSE callbacks are fenced", async () => {
const old = deferred<ThreadState<Values>[]>();
const fixture = setup(async () => old.promise);
const pending = fixture.history.initial();
const observer = fixture.run("one");
fixture.history.fence();
fixture.history.select("b");
observer.event({ event: "values", data: values("stale") });
observer.event({ event: "end", data: null });
assert.deepEqual(await fixture.history.mutate("a"), []);
old.resolve([head(values("stale"))]);
await pending;
assert.equal(fixture.history.snapshot().threadId, "b");
assert.equal(fixture.history.snapshot().data, undefined);
assert.equal(fixture.applied.length, 0);
fixture.history.cancel();
assert.equal(fixture.history.snapshot().isLoading, false);
});
test("new thread id promotion keeps its in-flight stream", async () => {
const fixture = setup();
fixture.history.select(null);
fixture.history.beginTurn();
const observer = fixture.history.observe("created", "run");
observer.event({ event: "values", data: values("first") });
fixture.history.select("created");
observer.event({ event: "end", data: null });
const result = await fixture.history.mutate("created");
assert.deepEqual(result[0]?.values, values("first"));
fixture.history.cancel();
});
test("EOF without explicit end and error frames require recovery, not final hydration", async () => {
for (const failed of [false, true]) {
let reads = 0;
const fixture = setup(async () => { reads += 1; return []; });
const observer = fixture.run("one");
if (failed) {
observer.event({ event: "error", data: { message: "failure" } });
observer.event({ event: "end", data: null });
}
await fixture.history.mutate("a");
await delay(4);
assert.equal(fixture.history.terminalSeen(), false);
assert.equal(reads, 0);
assert.equal(fixture.finished.length, 0);
fixture.history.cancel();
}
});
test("history failure has a bounded retry and retains the streamed answer", async () => {
let reads = 0;
const fixture = setup(async () => { reads += 1; throw new Error("offline"); });
fixture.run("one").event({ event: "end", data: null });
await fixture.history.mutate("a");
await settle(() => fixture.errors.length === 1);
assert.equal(reads, 2);
assert.deepEqual(fixture.history.snapshot().data?.[0]?.values, values("one"));
assert.equal(fixture.finished.length, 1);
fixture.history.cancel();
});
test("a confirmed terminal recovery retains live values when history fails without an SSE end", async () => {
const fixture = setup(async () => { throw new Error("offline"); });
fixture.run("recovered");
fixture.history.confirmTerminal();
await fixture.history.refresh(true);
assert.deepEqual(fixture.history.snapshot().data?.[0]?.values, values("recovered"));
assert.deepEqual(fixture.finished[0], values("recovered"));
fixture.history.cancel();
});
test("requests that never settle time out; forbidden history is not retried", async () => {
const fixture = setup(async () => new Promise(() => {}));
fixture.run("one").event({ event: "end", data: null });
await fixture.history.mutate("a");
await settle(() => fixture.errors.length === 1);
assert.equal(fixture.history.snapshot().isRefreshing, false);
fixture.history.cancel();
let reads = 0;
const denied = setup(async () => { reads += 1; throw Object.assign(new Error("forbidden"), { status: 403 }); });
denied.run("one").event({ event: "end", data: null });
await denied.history.mutate("a");
await settle(() => denied.errors.length === 1);
assert.equal(reads, 1);
denied.history.cancel();
});
test("a stale same-turn replica snapshot cannot erase newer streamed messages", async () => {
const prefix = values("one");
prefix.messages[1]!.content = "ans";
for (const stale of [values("previous"), prefix]) {
let reads = 0;
const fixture = setup(async () => { reads += 1; return [head(stale)]; });
fixture.run("one").event({ event: "end", data: null });
await fixture.history.mutate("a");
await settle(() => fixture.errors.length === 1);
assert.equal(reads, 2);
assert.deepEqual(fixture.history.snapshot().data?.[0]?.values, values("one"));
fixture.history.cancel();
}
});
test("a root values event wins over a stale SDK getter and subgraph values", async () => {
const fixture = setup();
const observer = fixture.run("one");
observer.event({ event: "values", data: { ...values("final"), todos: ["complete"] } });
observer.event({ event: "values|agent:child", data: values("child") });
const snapshot = await fixture.history.mutate("a");
assert.deepEqual(snapshot[0]?.values, { ...values("final"), todos: ["complete"] });
fixture.history.cancel();
const partial = setup();
const interrupted = partial.run("one");
const early = values("one");
early.messages.pop();
interrupted.event({ event: "values", data: early });
partial.setLive(values("one"));
interrupted.event({ event: "messages", data: [{ id: "ai-one", content: "answer one" }, { langgraph_checkpoint_ns: "" }] });
const eof = await partial.history.mutate("a");
assert.deepEqual(eof[0]?.values, values("one"));
assert.equal(partial.history.terminalSeen(), false);
partial.history.cancel();
});
test("the installed SDK ends loading before background history, through real SSE decoding", async (t) => {
const blocked = deferred<ThreadState<Values>[]>();
const manager = new StreamManager<Values, {}>(new MessageTupleManager(), { throttle: true });
const api = createStreamAPIClient();
const completed: Values[] = [];
const finished: Values[] = [];
const history = new BackgroundHistory<Values>({
load: async () => blocked.promise, values: () => manager.values ?? undefined,
hydrated: (state, _scope, notify) => { if (notify) finished.push(state.values); }, timeoutMs: 500,
});
history.select("a");
api.setHistoryObserver(history.observe);
t.mock.method(globalThis, "fetch", async () => new Response([
`event: metadata\ndata: ${JSON.stringify({ run_id: "run" })}\n\n`,
`event: values\ndata: ${JSON.stringify(values("one"))}\n\n`,
"event: end\ndata: null\n\n",
].join(""), { headers: { "Content-Type": "text/event-stream", "Content-Location": "/threads/a/runs/run" } }));
await manager.start(async () => api.client.runs.stream("a", "lead_agent", { streamMode: ["values"], streamIdleReconnect: 0 }) as AsyncGenerator<EventStreamEvent<Values, Partial<Values>, unknown>>, {
initialValues: { messages: [] }, getMessages: (state) => state.messages,
setMessages: (state, messages) => ({ ...state, messages: messages as Values["messages"] }), callbacks: {},
async onSuccess() {
const final = (await history.mutate("a"))[0];
if (final) completed.push(final.values);
return null;
}, onError(error) { throw error; },
});
assert.equal(manager.isLoading, false);
assert.equal(manager.error, undefined);
assert.deepEqual(history.snapshot().data?.[0]?.values, values("one"));
assert.equal(finished.length, 0);
assert.deepEqual(completed, [values("one")]);
blocked.resolve([head(values("one"))]);
await settle(() => finished.length === 1);
assert.equal(completed.length, 1);
history.cancel();
});
@@ -0,0 +1,295 @@
import type { ThreadState } from "@langchain/langgraph-sdk";
export type HistoryScope = { threadId: string; epoch: number; runId: string | null };
type HistoryView<S> = {
threadId: string | null;
data: ThreadState<S>[] | undefined;
error: unknown;
isLoading: boolean;
isRefreshing: boolean;
};
export type RunHistoryObserver = (threadId: string | null, runId?: string) => {
created: (threadId: string, runId: string) => void;
event: (event: { event: string; data: unknown }) => void;
};
/** One SDK accumulator's history. Every write is fenced, even if abort is ignored. */
export class BackgroundHistory<S extends Record<string, unknown>> {
private epoch = 0;
private runId: string | null = null;
private active = false;
private ended = false;
private failed = false;
private accepting = false;
private liveValues: S | undefined;
private rootValuesAreLatest = false;
private request: AbortController | undefined;
private requestId = 0;
private requestScope: HistoryScope | undefined;
private requestPromise: Promise<void> | undefined;
private terminalRefreshEpoch: number | undefined;
private listeners = new Set<() => void>();
private view: HistoryView<S> = { threadId: null, data: undefined, error: undefined, isLoading: false, isRefreshing: false };
constructor(private options: {
load: (threadId: string, signal: AbortSignal) => Promise<ThreadState<S>[]>;
values: () => S | undefined;
hydrated: (state: ThreadState<S>, scope: HistoryScope, notifyFinish: boolean) => void;
started?: () => void;
syncFailed?: (error: unknown) => void;
timeoutMs?: number;
retryDelayMs?: number;
}) {}
subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
snapshot = () => this.view;
private publish(change: Partial<HistoryView<S>>) {
this.view = { ...this.view, ...change };
this.listeners.forEach((listener) => listener());
}
private invalidate() {
this.epoch += 1;
this.requestId += 1;
this.request?.abort();
this.request = undefined;
this.requestScope = undefined;
this.requestPromise = undefined;
this.terminalRefreshEpoch = undefined;
}
select(threadId: string | null) {
if (this.view.threadId === threadId) return;
// The new thread's first run is already in flight when the router adopts
// its server id. Promotion must not clear the accumulator or fetch history.
if (this.view.threadId === null && this.active && threadId) {
this.publish({ threadId });
return;
}
this.invalidate();
this.active = this.ended = this.failed = false;
this.accepting = false;
this.liveValues = undefined;
this.rootValuesAreLatest = false;
this.runId = null;
this.publish({ threadId, data: undefined, error: undefined, isLoading: Boolean(threadId), isRefreshing: false });
}
beginTurn() {
this.invalidate();
this.active = true;
this.accepting = true;
this.ended = this.failed = false;
this.runId = null;
this.liveValues = undefined;
this.rootValuesAreLatest = false;
this.publish({ isLoading: false, isRefreshing: false, error: undefined });
this.options.started?.();
}
cancel() {
this.invalidate();
this.active = false;
this.accepting = false;
this.publish({ isLoading: false, isRefreshing: false });
}
/** Render-time navigation fence: invalidate async work without notifying React. */
fence() { this.invalidate(); this.active = false; this.accepting = false; }
confirmTerminal() {
if (this.view.threadId) this.capture(this.view.threadId);
this.active = false;
this.ended = true;
}
current(scope: HistoryScope) {
return this.view.threadId === scope.threadId && this.epoch === scope.epoch && this.runId === scope.runId;
}
terminalSeen() { return this.ended && !this.failed; }
observe: RunHistoryObserver = (threadId, runId) => {
if (!threadId || (this.view.threadId !== null && this.view.threadId !== threadId)) {
return { created() {}, event() {} };
}
this.select(threadId);
this.beginTurn();
this.runId = runId ?? null;
const epoch = this.epoch;
const current = () => this.epoch === epoch && this.view.threadId === threadId;
return {
created: (id, run) => { if (current() && id === threadId) this.runId = run; },
event: ({ event, data }) => {
if (!current()) return;
if (event === "metadata" && data && typeof data === "object" && "run_id" in data && typeof data.run_id === "string") this.runId = data.run_id;
if (event === "values" && data && typeof data === "object") {
this.liveValues = { ...this.liveValues, ...data } as S;
this.rootValuesAreLatest = true;
}
if ((event === "messages" || event === "messages-tuple") && Array.isArray(data)) {
const metadata = data[1];
if (!metadata?.langgraph_checkpoint_ns && !metadata?.checkpoint_ns) this.rootValuesAreLatest = false;
}
if (event === "updates" && data && typeof data === "object" && "__interrupt__" in data) {
this.liveValues = { ...this.liveValues, __interrupt__: data.__interrupt__ } as unknown as S;
}
if (event === "error") this.failed = true;
if (event === "end") this.ended = true;
},
};
};
/** SDK's post-stream mutate resolves without any database round trip. */
mutate = async (threadId?: string): Promise<ThreadState<S>[]> => {
const tid = threadId ?? this.view.threadId;
if (!tid || tid !== this.view.threadId) return [];
if (!this.accepting) return this.view.data ?? [];
const state = this.capture(tid);
if (this.terminalSeen()) {
this.active = false;
// The SDK may call mutate more than once while finalizing a stream.
// Accept the streamed snapshot once, then schedule at most one DB sync.
this.accepting = false;
const scope = { threadId: tid, epoch: this.epoch, runId: this.runId };
if (this.terminalRefreshEpoch !== this.epoch) {
this.terminalRefreshEpoch = this.epoch;
// Give the SDK a turn to finish/clear its live values before hydration.
setTimeout(() => { if (this.current(scope)) void this.refresh(true); }, 0);
}
}
return [state];
};
private capture(tid: string): ThreadState<S> {
const current = this.options.values();
const values = { ...current, ...this.liveValues } as S;
// EOF can arrive during token chunks, before the next full values frame.
// Keep the newer SDK message accumulation rather than an earlier root head.
if (!this.rootValuesAreLatest && current && Array.isArray(current.messages) && historyCaughtUp(this.liveValues, current)) {
Object.assign(values, { messages: current.messages });
}
const state: ThreadState<S> = {
values, next: [], tasks: [], metadata: {}, created_at: null, parent_checkpoint: null,
// Never attach an old checkpoint id to new streamed messages.
checkpoint: { thread_id: tid, checkpoint_ns: "", checkpoint_id: null, checkpoint_map: null },
};
this.publish({ data: [state], error: undefined, isLoading: false });
return state;
}
async initial() {
if (!this.active && this.view.threadId && this.view.data === undefined) await this.refresh(false);
}
/** Also used after a terminal run has been confirmed by the recovery probe. */
refresh(notify: boolean): Promise<void> {
const threadId = this.view.threadId;
if (!threadId) return Promise.resolve();
if (notify) this.terminalRefreshEpoch = this.epoch;
const existingScope = this.requestScope;
if (
this.request &&
this.requestPromise &&
!this.request.signal.aborted &&
existingScope?.threadId === threadId &&
existingScope.epoch === this.epoch &&
existingScope.runId === this.runId
) {
return this.requestPromise;
}
// A same-scope refresh joins the in-flight request above. Only a genuinely
// different scope may abort it; duplicate completion callbacks must not
// create a trail of canceled /state requests.
this.request?.abort();
const id = ++this.requestId;
const scope = { threadId, epoch: this.epoch, runId: this.runId };
const controller = new AbortController();
this.request = controller;
this.requestScope = scope;
const current = () => id === this.requestId && this.current(scope) && !controller.signal.aborted;
this.publish({ isLoading: !this.view.data, isRefreshing: Boolean(this.view.data) });
const promise = (async () => {
try {
let data: ThreadState<S>[] | undefined;
for (let attempt = 0; attempt < (notify ? 2 : 1); attempt += 1) {
try {
data = await this.boundedLoad(threadId, controller.signal);
if (notify && !historyCaughtUp(this.view.data?.[0]?.values, data?.[0]?.values)) {
throw new Error("History has not caught up with the completed stream");
}
break;
} catch (error) {
if (!current()) return;
const status = (error as { status?: number })?.status;
if (!notify || attempt > 0 || status === 401 || status === 403 || status === 404) throw error;
await new Promise((resolve) => setTimeout(resolve, this.options.retryDelayMs ?? 500));
if (!current()) return;
}
}
if (!current()) return;
const state = data?.[0];
const previous = this.view.data?.[0];
const head = state ? { ...state, values: { ...previous?.values, ...state.values } as S } : previous;
this.publish({ data: head ? [head] : [], isLoading: false, isRefreshing: false, error: undefined });
if (head && this.current(scope)) this.options.hydrated(head, scope, notify);
} catch (error) {
if (!current()) return;
this.publish({ isLoading: false, isRefreshing: false, error: this.view.data ? undefined : error });
if (notify) {
this.options.syncFailed?.(error);
// Preserve completion-dependent UI when state sync is unavailable.
if (this.current(scope) && this.view.data?.[0]) this.options.hydrated(this.view.data[0], scope, true);
}
}
})();
this.requestPromise = promise;
void promise.then(() => {
if (this.request === controller) {
this.request = undefined;
this.requestScope = undefined;
this.requestPromise = undefined;
}
}, () => {
if (this.request === controller) {
this.request = undefined;
this.requestScope = undefined;
this.requestPromise = undefined;
}
});
return promise;
}
private async boundedLoad(threadId: string, signal: AbortSignal) {
const controller = new AbortController();
let rejectDeadline: (error: Error) => void = () => {};
const deadline = new Promise<never>((_, reject) => { rejectDeadline = reject; });
const abort = () => { controller.abort(); rejectDeadline(new DOMException("History cancelled", "AbortError")); };
signal.addEventListener("abort", abort, { once: true });
const timer = setTimeout(() => {
controller.abort();
rejectDeadline(new DOMException("History sync timed out", "TimeoutError"));
}, this.options.timeoutMs ?? 15_000);
try {
if (signal.aborted) abort();
return await Promise.race([this.options.load(threadId, controller.signal), deadline]);
} finally {
clearTimeout(timer);
signal.removeEventListener("abort", abort);
}
}
}
/** A delayed replica/head must not roll back the just-completed user turn. */
function historyCaughtUp(previous: Record<string, unknown> | undefined, next: Record<string, unknown> | undefined) {
const messages = Array.isArray(previous?.messages) ? previous.messages : [];
const saved = Array.isArray(next?.messages) ? next.messages : [];
let lastHuman = -1;
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i]?.type === "human") { lastHuman = i; break; }
}
if (lastHuman < 0) lastHuman = 0;
let position = -1;
return messages.slice(lastHuman).every((message) => {
if (typeof message?.id !== "string" || message.id.startsWith("opt-")) return true;
const index = saved.findIndex((item, i) => i > position && item?.id === message.id);
if (index < 0) return false;
position = index;
// During streaming, the same AI id can still contain only an early prefix.
// Persisted structured content can legitimately carry richer annotations.
return typeof message.content !== "string" || typeof saved[index].content !== "string"
|| saved[index].content.length >= message.content.length;
});
}
@@ -28,6 +28,16 @@ test("task-analysis seat runs stay out of recent chats", () => {
}),
true,
);
assert.equal(
isHiddenFromRecentChats({
thread_id: "task-analysis-runtime-thread",
metadata: {
task_analysis_task_id: "task-analysis-108",
task_analysis_run_id: "run-1",
},
}),
true,
);
assert.equal(
isHiddenFromRecentChats({
thread_id: "chat-2",
+347 -214
View File
@@ -1,9 +1,9 @@
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
import type { AIMessage, Message, ThreadState } from "@langchain/langgraph-sdk";
import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
import { useStream } from "@langchain/langgraph-sdk/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { QueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
@@ -30,6 +30,7 @@ import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
import type { AgentThread, AgentThreadState } from "./types";
import { BackgroundHistory } from "./background-history";
import { STREAM_RENDER_BATCHING } from "./stream-batching";
import { createReactStreamRecoveryBudget, reportReactUiError } from "./react-ui-error";
import {
@@ -38,13 +39,13 @@ import {
} from "./use-research-report-stream";
import {
collectHumanMessageIds,
createNewConversationDisplayBoundary,
createNewConversationDisplayBoundary,
hasServerEchoedOptimisticHuman,
isolateNewConversationMessages,
isolateNewConversationMessages,
isStaleStreamFinish,
mergeStreamDisplayValues,
shouldDiscardTerminalOverlay,
type NewConversationDisplayBoundary,
type NewConversationDisplayBoundary,
} from "./stream-display";
import {
STREAM_RECOVERY_LIVE_IDLE_MS,
@@ -52,7 +53,6 @@ import {
stopRecoveredRunReader,
shouldReconcileRunStream,
shouldIgnoreReaderRecycleFinish,
shouldVerifyCleanStreamFinish,
type StreamRecoveryResult,
type StreamRecoverySingleFlight,
} from "./stream-recovery";
@@ -62,19 +62,19 @@ import {
isReactMaximumUpdateDepthError,
isTransientStreamTransportError,
} from "./thread-access-error";
import {
beginKnowledgeRetrievalProgress,
bindKnowledgeRetrievalProgressRun,
clearKnowledgeRetrievalProgress,
ingestKnowledgeRetrievalProgress,
} from "./knowledge-retrieval-progress";
import {
beginKnowledgeRetrievalProgress,
bindKnowledgeRetrievalProgressRun,
clearKnowledgeRetrievalProgress,
ingestKnowledgeRetrievalProgress,
} from "./knowledge-retrieval-progress";
import { isHiddenFromRecentChats, shouldHideSystemThreadsFromList } from "./utils";
import {
resolveKnowledgeRagModeEnabled,
withExplicitKnowledgeRetrievalMode,
withKnowledgeRetrievalModeMetadata,
type KnowledgeRetrievalMode,
} from "./knowledge-retrieval-mode";
import {
resolveKnowledgeRagModeEnabled,
withExplicitKnowledgeRetrievalMode,
withKnowledgeRetrievalModeMetadata,
type KnowledgeRetrievalMode,
} from "./knowledge-retrieval-mode";
export type ToolEndEvent = {
name: string;
@@ -86,9 +86,9 @@ export type ToolStartEvent = ToolEndEvent;
export type ThreadStreamOptions = {
threadId?: string | null | undefined;
context: LocalSettings["context"] & {
knowledge_retrieval_mode?: KnowledgeRetrievalMode;
};
context: LocalSettings["context"] & {
knowledge_retrieval_mode?: KnowledgeRetrievalMode;
};
isMock?: boolean;
assistantId?: string;
/**
@@ -103,6 +103,11 @@ export type ThreadStreamOptions = {
/** 额外写到新建 thread 的 metadata(与 agent_id 合并)。
* 任务深链会写 taskId + taskRecordId + taskGoPath,供专用历史索引登记。 */
threadMetadata?: Record<string, unknown>;
/**
* 任务深链接的单智能体运行允许后台恢复继续完成。此时把流式运行的
* 非阻断性提示留在控制台,避免无关 Toast 打断任务工作区的操作。
*/
suppressUserFacingStreamErrors?: boolean;
onStart?: (threadId: string) => void;
onFinish?: (state: AgentThreadState) => void;
onToolStart?: (event: ToolStartEvent) => void;
@@ -131,7 +136,7 @@ type SendMessageOptions = {
};
type ThreadTitleRefreshState = Pick<AgentThreadState, "title"> & {
title_provisional?: boolean;
title_provisional: boolean | null;
};
function normalizeHiddenContextMessages(options?: SendMessageOptions) {
@@ -356,21 +361,19 @@ async function fetchThreadTitleRefreshState(
signal?: AbortSignal,
): Promise<ThreadTitleRefreshState | null> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/state`,
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/title-state`,
{ signal },
);
if (!response.ok) {
return null;
}
const json = (await response.json()) as {
values?: { title?: string; title_provisional?: boolean };
title?: string;
title_provisional?: boolean | null;
};
if (!json.values) {
return null;
}
return {
title: json.values.title ?? "",
title_provisional: json.values.title_provisional,
title: json.title ?? "",
title_provisional: json.title_provisional ?? null,
};
}
@@ -557,6 +560,7 @@ export function useThreadStream({
assistantId,
runOptions,
threadMetadata,
suppressUserFacingStreamErrors = false,
onStart,
onFinish,
onToolStart,
@@ -597,16 +601,16 @@ export function useThreadStream({
const [streamHoldOpen, setStreamHoldOpen] = useState(false);
const sendInFlightRef = useRef(false);
const sendEpochRef = useRef(0);
const activeRetrievalScopeRef = useRef<{
scopeId: string;
threadId: string;
runId: string | null;
} | null>(null);
const createdThreadIdRef = useRef<string | null>(null);
const [newConversationBoundary, setNewConversationBoundary] =
useState<NewConversationDisplayBoundary | null>(
threadId == null ? new Set<string>() : null,
);
const activeRetrievalScopeRef = useRef<{
scopeId: string;
threadId: string;
runId: string | null;
} | null>(null);
const createdThreadIdRef = useRef<string | null>(null);
const [newConversationBoundary, setNewConversationBoundary] =
useState<NewConversationDisplayBoundary | null>(
threadId == null ? new Set<string>() : null,
);
const reactUiRecoveryRef = useRef(createReactStreamRecoveryBudget());
// Filled after useStream; onFinish/onError may read it asynchronously.
const threadStreamRef = useRef<{
@@ -617,6 +621,22 @@ export function useThreadStream({
values?: AgentThreadState;
} | null>(null);
const reportStreamIssue = useCallback(
(message: string, level: "error" | "info" = "error") => {
if (suppressUserFacingStreamErrors) {
const log = level === "error" ? console.error : console.warn;
log("[deer-flow] task-deeplink stream notification suppressed", {
message,
threadId: threadIdRef.current,
runId: activeRunIdRef.current,
});
return;
}
toast[level](message);
},
[suppressUserFacingStreamErrors],
);
const markStreamActivity = useCallback(() => {
lastStreamActivityAtRef.current = Date.now();
streamReconnectAttemptsRef.current = 0;
@@ -629,6 +649,77 @@ export function useThreadStream({
onToolStart,
onToolEnd,
});
const recoveryFinishPendingRef = useRef(false);
const backgroundHistory: BackgroundHistory<AgentThreadState> = useMemo(() => new BackgroundHistory<AgentThreadState>({
load: async (tid, signal) => {
// Latest head only. getState uses aget_tuple; getHistory(limit:1) used
// to walk the checkpoint iterator and, with dual-read, scan legacy DBs.
const state = await streamClient.client.threads.getState<AgentThreadState>(
tid,
undefined,
{ signal },
);
const flat = state as ThreadState<AgentThreadState> & {
checkpoint_id?: string;
parent_checkpoint_id?: string;
};
return [
{
...state,
checkpoint:
state.checkpoint ?? {
thread_id: tid,
checkpoint_ns: "",
checkpoint_id: flat.checkpoint_id ?? null,
checkpoint_map: null,
},
parent_checkpoint:
state.parent_checkpoint ??
(flat.parent_checkpoint_id
? {
thread_id: tid,
checkpoint_ns: "",
checkpoint_id: flat.parent_checkpoint_id,
checkpoint_map: null,
}
: null),
tasks: state.tasks ?? [],
},
];
},
values: () => threadStreamRef.current?.values,
started: () => {
recoveryFinishPendingRef.current = false;
setThreadValueOverrides((prev) => {
const { title, title_provisional } = prev;
return { ...(title !== undefined ? { title } : {}), ...(title_provisional !== undefined ? { title_provisional } : {}) };
});
},
hydrated: (state, scope, notifyFinish) => {
if (!backgroundHistory.current(scope)) return;
setThreadValueOverrides(state.values);
if (notifyFinish && recoveryFinishPendingRef.current) {
recoveryFinishPendingRef.current = false;
listeners.current.onFinish?.(state.values);
}
},
// Keep the final state refresh and its fallback hydration, but don't show
// a user-facing toast when the streamed answer is already available.
syncFailed: () => {},
}), [streamClient, reportStreamIssue]);
streamClient.setHistoryObserver(backgroundHistory.observe);
const historyRouteRef = useRef(threadId ?? null);
if (historyRouteRef.current !== (threadId ?? null)) {
historyRouteRef.current = threadId ?? null;
if (backgroundHistory.snapshot().threadId !== (threadId ?? null)) backgroundHistory.fence();
}
const historyView = useSyncExternalStore(backgroundHistory.subscribe, backgroundHistory.snapshot, backgroundHistory.snapshot);
useEffect(() => {
backgroundHistory.select(threadId ?? null);
void backgroundHistory.initial();
}, [backgroundHistory, threadId]);
useEffect(() => () => backgroundHistory.cancel(), [backgroundHistory]);
// Keep listeners ref updated with latest callbacks
useEffect(() => {
@@ -724,44 +815,45 @@ export function useThreadStream({
? () => runMetadataStorageRef.current!
: false,
fetchStateHistory: { limit: 1 },
thread: { ...historyView, mutate: backgroundHistory.mutate },
onCreated(meta) {
// Assigning the first server thread id does not clear the SDK's live
// accumulator, unlike navigating to a different existing conversation.
streamCursorThreadIdRef.current = meta.thread_id;
createdThreadIdRef.current = meta.thread_id;
createdThreadIdRef.current = meta.thread_id;
handleStreamStart(meta.thread_id);
setOnStreamThreadId(meta.thread_id);
if (meta.run_id) {
activeRunIdRef.current = meta.run_id;
persistActiveRunId(meta.thread_id, meta.run_id);
}
const retrievalScope = activeRetrievalScopeRef.current;
if (retrievalScope) {
const requestedThreadId = retrievalScope.threadId;
retrievalScope.threadId = meta.thread_id;
retrievalScope.runId = meta.run_id ?? null;
beginKnowledgeRetrievalProgress(meta.thread_id, {
scopeId: retrievalScope.scopeId,
runId: retrievalScope.runId,
});
bindKnowledgeRetrievalProgressRun(meta.thread_id, {
scopeId: retrievalScope.scopeId,
runId: retrievalScope.runId,
});
if (requestedThreadId !== meta.thread_id) {
clearKnowledgeRetrievalProgress(requestedThreadId);
}
}
const retrievalScope = activeRetrievalScopeRef.current;
if (retrievalScope) {
const requestedThreadId = retrievalScope.threadId;
retrievalScope.threadId = meta.thread_id;
retrievalScope.runId = meta.run_id ?? null;
beginKnowledgeRetrievalProgress(meta.thread_id, {
scopeId: retrievalScope.scopeId,
runId: retrievalScope.runId,
});
bindKnowledgeRetrievalProgressRun(meta.thread_id, {
scopeId: retrievalScope.scopeId,
runId: retrievalScope.runId,
});
if (requestedThreadId !== meta.thread_id) {
clearKnowledgeRetrievalProgress(requestedThreadId);
}
}
const metadataAgentId = context.agent_id ?? context.agent_name;
const extraMetadata =
threadMetadata && typeof threadMetadata === "object" ? threadMetadata : {};
const metadata = withKnowledgeRetrievalModeMetadata(
{
...(metadataAgentId ? { agent_id: metadataAgentId } : {}),
...extraMetadata,
},
context.knowledge_retrieval_mode,
);
const metadata = withKnowledgeRetrievalModeMetadata(
{
...(metadataAgentId ? { agent_id: metadataAgentId } : {}),
...extraMetadata,
},
context.knowledge_retrieval_mode,
);
if (Object.keys(metadata).length > 0 && !isMock) {
void getAPIClient()
.threads.update(meta.thread_id, { metadata })
@@ -853,32 +945,32 @@ export function useThreadStream({
},
onCustomEvent(event: unknown) {
markStreamActivity();
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
event.type === "knowledge_retrieval_progress"
) {
const tid = threadIdRef.current ?? onStreamThreadId ?? null;
if (tid) {
let retrievalScope = activeRetrievalScopeRef.current;
if (!retrievalScope || retrievalScope.threadId !== tid) {
const runId = activeRunIdRef.current;
retrievalScope = {
scopeId: `recovered:${tid}:${runId ?? sendEpochRef.current}`,
threadId: tid,
runId,
};
activeRetrievalScopeRef.current = retrievalScope;
beginKnowledgeRetrievalProgress(tid, retrievalScope);
}
ingestKnowledgeRetrievalProgress(tid, event, {
scopeId: retrievalScope.scopeId,
runId: retrievalScope.runId,
});
}
return;
}
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
event.type === "knowledge_retrieval_progress"
) {
const tid = threadIdRef.current ?? onStreamThreadId ?? null;
if (tid) {
let retrievalScope = activeRetrievalScopeRef.current;
if (!retrievalScope || retrievalScope.threadId !== tid) {
const runId = activeRunIdRef.current;
retrievalScope = {
scopeId: `recovered:${tid}:${runId ?? sendEpochRef.current}`,
threadId: tid,
runId,
};
activeRetrievalScopeRef.current = retrievalScope;
beginKnowledgeRetrievalProgress(tid, retrievalScope);
}
ingestKnowledgeRetrievalProgress(tid, event, {
scopeId: retrievalScope.scopeId,
runId: retrievalScope.runId,
});
}
return;
}
// 写作模式内联深度研究的进度帧(deep_research_report 工具经
// get_stream_writer 转发):归约进 per-thread 状态,聊天页据此注入
// 进度步骤条与 report.md 流式预览。
@@ -991,13 +1083,14 @@ export function useThreadStream({
);
if (projectMatch || legacyProjectMatch) {
const category = projectMatch?.[1] === "solutions" ? "solution" : "qa";
toast.info("当前项目对话不存在或无权访问,已返回我的项目。");
reportStreamIssue("当前项目对话不存在或无权访问,已返回我的项目。", "info");
navigate(`/page/strategy/qa/projects?type=${category}`, { replace: true });
return;
}
const userLabel = buildCurrentUserLabel();
toast.info(
reportStreamIssue(
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
"info",
);
// Prefer staying inside an agent chat context when the user is on an
// agent thread URL — otherwise fall back to the global new chat.
@@ -1019,21 +1112,23 @@ export function useThreadStream({
if (isActiveRunConflictError(message)) {
setStreamHoldOpen(true);
void confirmOrRejoinRef.current("conflict").then((result) => {
toast.info(
reportStreamIssue(
result === "rejoined"
? "上一轮回答仍在生成,已重新连接当前回复。请等待结束或点“停止”后再提问。"
: friendly,
"info",
);
});
return;
}
toast.info(friendly);
reportStreamIssue(friendly, "info");
return;
}
toast.error(message);
reportStreamIssue(message);
},
onFinish(state, runMeta) {
const finishingRunId = runMeta?.run_id;
if (runMeta?.thread_id && runMeta.thread_id !== backgroundHistory.snapshot().threadId) return;
if (
shouldIgnoreReaderRecycleFinish({
recyclingRunId: streamReaderRecycleRunIdRef.current,
@@ -1043,10 +1138,6 @@ export function useThreadStream({
return;
}
const idleForMs =
lastStreamActivityAtRef.current > 0
? Date.now() - lastStreamActivityAtRef.current
: 0;
const finalizeCleanStream = () => {
if (
isStaleStreamFinish({
@@ -1064,32 +1155,21 @@ export function useThreadStream({
}
activeRunIdRef.current = null;
setStreamHoldOpen(false);
if (state?.values) {
listeners.current.onFinish?.(state.values);
}
// End confirms the streamed snapshot is complete. Deliver this turn's
// business callback before another send can replace its page context;
// background hydration must not repeat or cancel it.
recoveryFinishPendingRef.current = false;
if (state?.values) listeners.current.onFinish?.(state.values);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
};
if (
shouldVerifyCleanStreamFinish({
idleForMs,
hasActiveRun: Boolean(
activeRunIdRef.current &&
(!finishingRunId || activeRunIdRef.current === finishingRunId),
),
})
) {
// A clean EOF after a long business-frame silence can be a proxy that
// closed the socket while the resumable backend run is still alive.
// Verify this rare case; ordinary successful turns still finish with
// no extra /runs request.
if (!backgroundHistory.terminalSeen()) {
// EOF without an explicit end can be a proxy disconnect while the
// resumable backend run is alive. Confirm it before unlocking.
setStreamHoldOpen(true);
void confirmOrRejoinRef
.current("finish")
.then((result) => {
if (result === "unknown") finalizeCleanStream();
})
.catch(finalizeCleanStream);
.catch(() => {});
return;
}
@@ -1154,7 +1234,6 @@ export function useThreadStream({
const terminalRunId = runId;
const applyTerminalState = async (status: string) => {
const finalState = await client.threads.getState(tid);
if (
!isCurrentRecovery() ||
shouldDiscardTerminalOverlay({
@@ -1180,20 +1259,19 @@ export function useThreadStream({
setStreamHoldOpen(false);
streamReconnectAttemptsRef.current = 0;
streamReconnectFailureToastShownRef.current = false;
setThreadValueOverrides(
finalState.values as Partial<AgentThreadState>,
);
// Recovery can have missed the answer's final SSE frames. Its
// completion callback still needs the guarded persisted state.
recoveryFinishPendingRef.current = status === "success";
backgroundHistory.confirmTerminal();
void backgroundHistory.refresh(status === "success");
finalizeResearchReportStream(tid);
if (status === "success") {
setOptimisticMessages([]);
listeners.current.onFinish?.(
finalState.values as AgentThreadState,
);
void queryClient.invalidateQueries({
queryKey: ["threads", "search"],
});
} else {
toast.error(
reportStreamIssue(
"本轮生成未能完成,已同步当前对话内容,可重新发起请求。",
);
}
@@ -1242,8 +1320,8 @@ export function useThreadStream({
? "terminal"
: "unknown";
} catch {
// A failed checkpoint fetch does not erase the known terminal
// run. Keep recovery alive so the next probe can sync its result.
// Reader cleanup can fail independently of the known terminal
// run. Keep recovery alive so a later probe can detach it.
if (isCurrentRecovery()) {
lastStreamActivityAtRef.current = Date.now();
setStreamHoldOpen(true);
@@ -1262,6 +1340,11 @@ export function useThreadStream({
runId = inflight?.run_id ?? null;
if (runId) {
activeRunIdRef.current = runId;
} else {
const lastTerminal = runs.find((item) =>
TERMINAL_RUN_STATUSES.has(item.status),
);
if (lastTerminal) terminalStatus = lastTerminal.status;
}
} catch {
if (!isCurrentRecovery()) return "unknown";
@@ -1274,10 +1357,45 @@ export function useThreadStream({
}
}
if (terminalStatus) {
try {
return (await applyTerminalState(terminalStatus))
? "terminal"
: "unknown";
} catch {
if (isCurrentRecovery()) {
lastStreamActivityAtRef.current = Date.now();
setStreamHoldOpen(true);
}
return "unknown";
}
}
if (!runId) {
setStreamHoldOpen(false);
activeRunIdRef.current = null;
return "unknown";
// The backend confirms there is no in-flight run, but an SDK reader
// can remain locally open after a proxy/SSE disconnect. Detach that
// reader only after removing its reconnect marker so stop() cannot
// accidentally cancel a server run.
try {
const readerStopped = await stopRecoveredRunReader({
isCurrent: isCurrentRecovery,
clearReconnect: () => clearActiveRunId(tid),
stop: () => threadStreamRef.current?.stop(),
});
if (!readerStopped) return "unknown";
activeRunIdRef.current = null;
setStreamHoldOpen(false);
streamReconnectAttemptsRef.current = 0;
backgroundHistory.confirmTerminal();
void backgroundHistory.refresh(false);
return "terminal";
} catch {
if (isCurrentRecovery()) {
lastStreamActivityAtRef.current = Date.now();
setStreamHoldOpen(true);
}
return "unknown";
}
}
// A second UI failure can pause recovery while /runs is in flight.
@@ -1306,7 +1424,7 @@ export function useThreadStream({
) {
if (!streamReconnectFailureToastShownRef.current) {
streamReconnectFailureToastShownRef.current = true;
toast.error(
reportStreamIssue(
"回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果,或点“停止”后重新提问。",
);
}
@@ -1359,7 +1477,7 @@ export function useThreadStream({
) {
if (!streamReconnectFailureToastShownRef.current) {
streamReconnectFailureToastShownRef.current = true;
toast.error(
reportStreamIssue(
"回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果,或点“停止”后重新提问。",
);
}
@@ -1383,7 +1501,14 @@ export function useThreadStream({
reconcile,
);
},
[isMock, onStreamThreadId, queryClient, streamClient],
[
isMock,
onStreamThreadId,
queryClient,
streamClient,
backgroundHistory,
reportStreamIssue,
],
);
confirmOrRejoinRef.current = confirmOrRejoin;
@@ -1440,26 +1565,26 @@ export function useThreadStream({
if (threadId != null) {
// Forward direction: starting or continuing with a real thread.
// No reset needed; the upstream useStream will load history for this id.
if (createdThreadIdRef.current !== threadId) {
// Direct navigation to an existing conversation should render its full
// history. Only a server id created from the current /chats/new
// submission inherits the previous-message boundary.
setNewConversationBoundary(null);
activeRetrievalScopeRef.current = null;
}
if (createdThreadIdRef.current !== threadId) {
// Direct navigation to an existing conversation should render its full
// history. Only a server id created from the current /chats/new
// submission inherits the previous-message boundary.
setNewConversationBoundary(null);
activeRetrievalScopeRef.current = null;
}
setThreadValueOverrides({});
return;
}
const prevThread = threadStreamRef.current;
setNewConversationBoundary(
createNewConversationDisplayBoundary(prevThread?.messages),
);
const previousRetrievalScope = activeRetrievalScopeRef.current;
if (previousRetrievalScope) {
clearKnowledgeRetrievalProgress(previousRetrievalScope.threadId);
}
activeRetrievalScopeRef.current = null;
createdThreadIdRef.current = null;
setNewConversationBoundary(
createNewConversationDisplayBoundary(prevThread?.messages),
);
const previousRetrievalScope = activeRetrievalScopeRef.current;
if (previousRetrievalScope) {
clearKnowledgeRetrievalProgress(previousRetrievalScope.threadId);
}
activeRetrievalScopeRef.current = null;
createdThreadIdRef.current = null;
if (prevThread?.isLoading) {
try {
prevThread.stop();
@@ -1490,6 +1615,7 @@ export function useThreadStream({
}
let cancelled = false;
let pollCount = 0;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const controller = new AbortController();
@@ -1507,7 +1633,7 @@ export function useThreadStream({
setThreadValueOverrides((prev) => ({
...prev,
title: nextState.title,
title_provisional: nextState.title_provisional,
title_provisional: nextState.title_provisional ?? true,
}));
if (nextState.title) {
@@ -1519,7 +1645,7 @@ export function useThreadStream({
values: {
...t.values,
title: nextState.title,
title_provisional: nextState.title_provisional,
title_provisional: nextState.title_provisional ?? true,
},
}
: t,
@@ -1527,7 +1653,7 @@ export function useThreadStream({
);
}
if (nextState.title_provisional !== true) {
if (nextState.title_provisional === false) {
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
return;
}
@@ -1538,6 +1664,12 @@ export function useThreadStream({
await new Promise<void>((resolve) => {
timeoutId = setTimeout(resolve, 2000);
});
pollCount += 1;
if (pollCount >= 30) {
setThreadValueOverrides((prev) => ({ ...prev, title_provisional: false }));
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
return;
}
}
};
@@ -1607,27 +1739,20 @@ export function useThreadStream({
}
sendInFlightRef.current = true;
sendEpochRef.current += 1;
// Custom retrieval progress is scoped to one user turn. Tool-call
// messages remain in the checkpoint and rebuild older traces after this
// live buffer is cleared.
if (threadId) {
const retrievalScope = {
scopeId: `${threadId}:${sendEpochRef.current}:${Date.now()}`,
threadId,
runId: null,
};
activeRetrievalScopeRef.current = retrievalScope;
beginKnowledgeRetrievalProgress(threadId, retrievalScope);
}
// Drop frozen checkpoint messages so the new SSE can paint live tokens.
// Keep title / title_provisional overlays from the prior reconcile.
setThreadValueOverrides((prev) => {
if (!("messages" in prev)) {
return prev;
}
const { messages: _dropped, ...rest } = prev;
return rest;
});
backgroundHistory.select(threadId ?? null);
backgroundHistory.beginTurn();
// Custom retrieval progress is scoped to one user turn. Tool-call
// messages remain in the checkpoint and rebuild older traces after this
// live buffer is cleared.
if (threadId) {
const retrievalScope = {
scopeId: `${threadId}:${sendEpochRef.current}:${Date.now()}`,
threadId,
runId: null,
};
activeRetrievalScopeRef.current = retrievalScope;
beginKnowledgeRetrievalProgress(threadId, retrievalScope);
}
setStreamHoldOpen(false);
const text = message.text.trim();
@@ -1776,25 +1901,25 @@ export function useThreadStream({
}),
);
const mergedContext = withExplicitKnowledgeRetrievalMode({
const mergedContext = withExplicitKnowledgeRetrievalMode({
...context,
...extraContext,
});
// Mode changes are part of the conversation, so persist them before an
// existing thread starts its next run. The Gateway merges this patch
// with task/project/agent metadata, and the run request repeats the
// same value as a fallback if this best-effort PATCH is interrupted.
const persistedThreadId = threadIdRef.current;
if (persistedThreadId && !isMock) {
await getAPIClient().threads
.update(persistedThreadId, {
metadata: withKnowledgeRetrievalModeMetadata(
undefined,
mergedContext.knowledge_retrieval_mode,
),
})
.catch(() => undefined);
}
});
// Mode changes are part of the conversation, so persist them before an
// existing thread starts its next run. The Gateway merges this patch
// with task/project/agent metadata, and the run request repeats the
// same value as a fallback if this best-effort PATCH is interrupted.
const persistedThreadId = threadIdRef.current;
if (persistedThreadId && !isMock) {
await getAPIClient().threads
.update(persistedThreadId, {
metadata: withKnowledgeRetrievalModeMetadata(
undefined,
mergedContext.knowledge_retrieval_mode,
),
})
.catch(() => undefined);
}
const selectedLlmWikiIds = Array.isArray(
mergedContext.llmwiki_knowledge_base_ids,
)
@@ -1805,14 +1930,14 @@ export function useThreadStream({
)
? mergedContext.assistant_knowledge_base_ids
: [];
const ragModeEnabled = resolveKnowledgeRagModeEnabled({
knowledgeRetrievalMode: mergedContext.knowledge_retrieval_mode,
referenceModeEntryEnabled,
localRagModeEnabled: localSettings.display.ragModeEnabled,
threadRagModeLocked: isThreadRagModeLocked(threadId),
ordinaryKnowledgeBaseCount: selectedLlmWikiIds.length,
assistantKnowledgeBaseCount: selectedAssistantKnowledgeIds.length,
});
const ragModeEnabled = resolveKnowledgeRagModeEnabled({
knowledgeRetrievalMode: mergedContext.knowledge_retrieval_mode,
referenceModeEntryEnabled,
localRagModeEnabled: localSettings.display.ragModeEnabled,
threadRagModeLocked: isThreadRagModeLocked(threadId),
ordinaryKnowledgeBaseCount: selectedLlmWikiIds.length,
assistantKnowledgeBaseCount: selectedAssistantKnowledgeIds.length,
});
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
@@ -1860,6 +1985,9 @@ export function useThreadStream({
},
{
threadId: threadId,
// A final history refresh may still be in flight. Continue from
// the server's head, never the prior render's implicit checkpoint.
checkpoint: null,
streamSubgraphs: true,
streamResumable: true,
onDisconnect: runOptions?.onDisconnect,
@@ -1902,10 +2030,11 @@ export function useThreadStream({
setOptimisticMessages([]);
setStreamHoldOpen(true);
const result = await confirmOrRejoin("conflict");
toast.info(
reportStreamIssue(
result === "rejoined"
? "上一轮回答仍在生成,已重新连接当前回复。请等待结束或点“停止”后再提问。"
: friendly,
"info",
);
return;
}
@@ -1937,11 +2066,13 @@ export function useThreadStream({
t.uploads.uploadingFiles,
context,
referenceModeEntryEnabled,
isMock,
isMock,
localSettings.display.ragModeEnabled,
queryClient,
runOptions,
backgroundHistory,
confirmOrRejoin,
reportStreamIssue,
],
);
@@ -1961,6 +2092,7 @@ export function useThreadStream({
} catch {
/* local abort is best-effort */
}
backgroundHistory.cancel();
if (tid && runId) {
try {
await getAPIClient(isMock).runs.cancel(tid, runId);
@@ -1969,7 +2101,7 @@ export function useThreadStream({
}
clearActiveRunId(tid);
}
}, [isMock]);
}, [isMock, backgroundHistory]);
// Merge thread with optimistic messages for display. While a live SSE is
// open, never let a stale getState snapshot's `messages` win — that is what
@@ -1981,18 +2113,18 @@ export function useThreadStream({
thread.isLoading,
) ?? thread.values;
const unisolatedMergedMessages =
const unisolatedMergedMessages =
Array.isArray(mergedValues?.messages)
? (mergedValues.messages as Message[])
: thread.messages;
const mergedMessages = isolateNewConversationMessages(
unisolatedMergedMessages,
newConversationBoundary,
);
const displayMergedValues =
mergedValues && Array.isArray(mergedValues.messages)
? ({ ...mergedValues, messages: mergedMessages } as AgentThreadState)
: mergedValues;
const mergedMessages = isolateNewConversationMessages(
unisolatedMergedMessages,
newConversationBoundary,
);
const displayMergedValues =
mergedValues && Array.isArray(mergedValues.messages)
? ({ ...mergedValues, messages: mergedMessages } as AgentThreadState)
: mergedValues;
const visibleThreadError =
thread.error &&
@@ -2003,14 +2135,15 @@ export function useThreadStream({
const mergedThread = {
...thread,
error: visibleThreadError,
values: displayMergedValues,
values: displayMergedValues,
messages:
optimisticMessages.length > 0
? [...mergedMessages, ...optimisticMessages]
: mergedMessages,
isLoading: thread.isLoading || streamHoldOpen,
isStateSyncing: historyView.isRefreshing,
stop: handleThreadStop,
} as typeof thread;
} as typeof thread & { isStateSyncing: boolean };
// 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。
// 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话
Loaded 100 of 485 files, more files were not shown because too many files have changed in this diff. Show more