Compare commits

..
818 changed files with 14037 additions and 108991 deletions

No files matched your search

-2
View File
@@ -11,8 +11,6 @@ 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
+4 -36
View File
@@ -85,18 +85,12 @@ offline-backend-20260512/backend/.deer-flow/*
# Per-user skill_evolution overrides — runtime data written by the API
offline-backend-20260512/backend/skill_evolution.json
# Automatic config.yaml backups written by the admin models page on every save
offline-backend-20260512/backend/.model-config-backups/
skills/
hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_large_file_safety.py
!offline-backend-20260512/backend/tests/test_account_authentication.py
!offline-backend-20260512/backend/tests/test_html_runtime_addresses.py
!offline-backend-20260512/backend/tests/test_app_identity.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/
@@ -107,15 +101,7 @@ 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
# Keep project-scoped cross-conversation memory regressions versioned.
!offline-backend-20260512/backend/tests/test_analysis_project_memory.py
@@ -133,10 +119,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
@@ -154,8 +140,6 @@ offline-backend-20260512/backend/tests/*
# Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py
# Keep the model-stream truncation event (persistence + classification) coverage versioned.
!offline-backend-20260512/backend/tests/test_stream_truncation.py
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
# Keep strategy-selection built-in agent seed and safety-contract regressions versioned.
@@ -187,10 +171,6 @@ offline-backend-20260512/backend/tests/*
# Keep database-backed upload-policy, enforcement and bounded-preview regressions versioned.
!offline-backend-20260512/backend/tests/test_upload_policy.py
!offline-backend-20260512/backend/tests/test_uploads_policy_enforcement.py
!offline-backend-20260512/backend/tests/test_upload_responsiveness.py
!offline-backend-20260512/backend/tests/test_conversion_responsiveness.py
!offline-backend-20260512/backend/tests/test_async_agent_entrypoints.py
!offline-backend-20260512/backend/tests/test_async_tool_initialization.py
!offline-backend-20260512/backend/tests/test_artifact_preview.py
# Independent enterprise-research persistence, HTTP and race regressions.
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
@@ -262,23 +242,12 @@ 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_chat_step_limit.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/
@@ -288,4 +257,3 @@ __pycache__/
# misc
.DS_Store
.worker-pool/
@@ -1,64 +0,0 @@
# 业务链选链去掉每次 `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`。
+3 -9
View File
@@ -88,10 +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)
- **智能体对话跳转**: 创建页、智能体管理编辑框和问答页详情面板都可以给智能体配置「对话跳转」链接、打开方式(新窗口 / 当前窗口)和路由参数(登录参数、主题参数,解析方式与应用管理相同)。配置保存在该智能体 `config.yaml` 的 `chat_link`,并镜像到 `agent_extras.chat_link` 供列表读取。留空时智能体管理的「对话」按钮仍进入本系统问答;填了地址后,按钮按解析后的链接跳转。
- **模型流完整性检测 + 截断记录 (stream truncation)**: 模型配置页(`/page/workspace/admin/models`,`ModelManagementPage.tsx`)每张模型卡片新增「流完整性检测」开关 — `PUT /api/admin/models/strict-stream-integrity/{name}` 写 config.yaml 的 `strict_stream_integrity: true/false`(`app/gateway/routers/model_management.py`,仅 OpenAI 兼容 Chat Completions 模型可开;显式 `false` 也会覆盖 qwen3.8-flash-next 的自动开启;卡片显示值 = 显式配置 ?? qwen3.8-flash-next 自动规则,前端 `isAutoStrictIntegrityModel` 复刻后端判定)。开启后上游流未发终止帧就结束时抛可重试 `IncompleteChatStreamError`(`chat_stream_state.py`),LLM 错误中间件按 upstream 重试,避免半截回答静默入库。同时每次流异常结束都由 `patched_openai.py` 的 `_ToolCallStreamDiagnostics._record_truncation_event` 落库 `stream_truncation_events`(分类:`silent_no_terminal` 静默截断 / `incomplete_stream_error` 完整性拦截 / `length_truncated` / `content_filter` / `midstream_error`;write_file 兼容桥故意提前收流与用户取消不计;经 `fire_and_forget_db_write` 从任意 loop/thread 安全写入,store 在 `deerflow/persistence/stream_truncation/`,**每次写入顺手删除 30 天前的行,硬上限一个月**)。查看页 `pages/StreamTruncationLogPage.tsx`(路由 `/page/workspace/admin/stream-truncations`,admin gate 同模型配置页;简洁模式「设置和更多」下拉的「模型截断记录」入口在 `workspace-nav-menu.tsx`),client `core/stream-truncation-log/api.ts`;后端 API `GET /api/admin/stream-truncations`(分页 + 模型/类型过滤)与 `/breakdown`(按模型汇总,`app/gateway/routers/stream_truncations.py`)。测试 `tests/test_stream_truncation.py`、`tests/test_model_management.py`。配套「保留中间正文」(管理员设置弹窗同名分区,`GET/PUT /api/system-settings/retry-keep-partial`,默认开启,关闭即恢复旧行为):①正常路径——原先「消息带可见工具调用就不给正文气泡」的分组规则会把步骤条执行中已流出的正文清掉;现 `groupMessages` 新增 `keepIntermediateText` 选项(MessageList 按开关传入):本轮全部过程正文**合并成一个气泡**挂在步骤条正下方(合成消息剥思考/工具调用,组 id 加 `:keep-text` 后缀避免 React key 冲突),步骤条保持一条不被拆开(正文气泡对工具消息/后续轮次的累积「透明」,`lastOpenGroup`/`lastNonKeepBubbleGroup` 向前跳过),分组完成后把合并气泡**重排到 present-files/subagent/document-rewrite 附属组之后**(正文块与最终回答相邻、不被文件卡片隔开),仅本轮「最终正文消息」(回扫到的最后一条有正文且无可见工具调用的 ai 消息)保留独立气泡;②重试路径——LLM 错误中间件静默重试时,上一次尝试已流出的半截正文从未进 checkpoint、会被前端 values 对账清掉,`core/threads/hooks.ts` 在收到 `llm_retry` 事件时快照在途消息,被丢弃后按原位插回(纯函数 `core/threads/retry-kept-messages.ts`,快照只在本轮流式期间有效,刷新即失)。测试 `tests/test_strategy_project_settings.py` 尾部、`src/core/messages/utils-group.test.ts`、`src/core/threads/retry-kept-messages.test.ts`(`pnpm test:retry-kept`)。
- **知识库深度研究(普通知识库限定问答)**: 每个可读知识库从卡片或详情页进入 `/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`. **素材检索技能池限定白名单**:`researcher_node` 通用检索的候选池 = **ai-writing-researcher 智能体 config.yaml `skills` 白名单里的启用技能**(管理员在智能体管理页增删,内置 `knowledge-base-search` 保底且始终排最前),不再列全部启用技能——300+ 技能部署把选择提示词撑爆导致素材检索不可用;容错兜底 `_fallback_candidate_skills` 同样限定白名单;白名单未配置/加载失败回退仅内置知识库检索。实现 `deerflow/agents/ai_writing/nodes/researcher.py::_load_candidate_skills/_researcher_skill_whitelist`,测试 `tests/test_ai_writing_knowledge_base.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.
- **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答经 Gateway `POST /api/sentiment-agent/stream` 转发外部 AG-UI 网关(不经过 LangGraph),会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`)并随请求传给后台;后台按传入地址转发、关闭 TLS 校验,失败时把完整错误回传。核心模块 `src/core/sentiment-agent/`。
@@ -101,15 +98,12 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **技能上下线 (Skill Availability, owner/admin)**: the global `Skill.enabled` flag is the online/offline switch. Taking a skill offline persists `skills.<name>.enabled=false` in `extensions_config.json` but does **not** delete its directory or remove it from existing agent/workflow configurations; the row remains visible and bringing it online restores those references. Administrators may toggle every skill from 「技能管理」; ordinary users may toggle only their own non-built-in `custom` skills from 「技能 → 技能列表 → 我的」. Offline skills must be absent from every model-facing skill catalogue (`lead` prompt, `search_skills`, `skill_list`, `skill_view`, subagents, AI writing/deep research/roundtable and Workflow run-start validation), from agent skill pickers, and from direct sandbox access: `read_file`/`ls`/`glob`/`grep`/`bash` reject offline, unknown, root-wide and category-wide `/mnt/skills` paths. The backend mutation is `PUT /api/skills/{name}`, authorizes either an administrator or the persisted owner of the target custom skill, patches the raw document so MCP placeholders and unrelated extension fields survive, uses atomic replacement when supported plus a Docker single-file-bind fallback, and refreshes the skill prompt cache. Launchers keep the runtime extensions file in a writable persisted directory. Existing agent/strategy-agent references stay visible as 「已下线」 and may be removed but not newly selected.
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id, or action id when `idKind:"action"` — same selector as url buttons, xdfx only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url **or host** button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
- **HTML 模板管理 (HTML Template Management, admin-only)**: 定时任务「HTML 模板」库的可视化管理——列表/预览(iframe srcDoc sandbox)/在线编辑/上传 .html 覆盖/新建/删除/重置内置,改完**即时生效**(调度器每次执行直接读文件,无需重启)。模板从纯代码注册表升级为**文件型存储**:harness 纯引擎 `deerflow/runtime/scheduler/template_store.py`(`{base_dir}/html-templates/<id>/{template.html,meta.json}`,gitignored),app.py lifespan 把随包 `scheduler/templates/` 的全部内置模板(目前 27 个)seed 进存储——**目录缺失才复制,绝不覆盖管理页/线上修改**;`template_fill.py` 的 `get_template/list_templates/read_template_text` 改为存储优先、随包注册表兜底,写操作后 `invalidate_cache()` 清引擎 lru_cache。**convertible 自动探测**:上传/编辑正文时检测单行 `const DATA={…};` 注入行(当前引擎唯一注入点,`const MANIFEST=` 同理),带注入行即「可转换填充」(定时任务 JSON 转换管线零代码接入),否则「只展示」;数据存 `<script type="application/json">` 元素或 `let DATA`/带空格写法的模板仍探测为只展示。内置模板可编辑/覆盖/重置回随包原版但**不可删除**(防引用它的任务挂掉),自建模板可删。页面 `pages/HtmlTemplatesPage.tsx`(入口**仅官方 DeerFlow 入口**:`OfficialDeerFlowEntry.tsx` 侧栏「模板管理」→ `#/official/workspace/task-analysis/html-templates`,页面 id `html-templates` 的路径判断必须先于 `task-analysis-detail` 的 `/:id` 正则,仅 admin 可见;主应用的简洁/全量模式**不加侧栏入口**,仅保留直链路由 `/page/strategy/admin/html-templates` 作回退,admin-gated),client+hooks `strategy-components/api/html-templates.ts`。后端 `/api/html-templates`(读开放、写仅 admin;`app/gateway/routers/html_templates.py`),旧端点 `GET /api/scheduled-tasks/html-templates` 不变(内部同样读存储),定时任务表单的下拉自动看到新建模板。测试 `tests/test_html_templates_admin.py`。
- **单智能体任务深链聊天记录(2026-10-19)**: `3qfx` / `rwfx` / `xdfx` 的会话历史使用独立 `task_deeplink_threads` 索引表及 `GET /api/task-deeplink-threads?task_id=&go_path=`,前端由 `useTaskDeeplinkThreads` 读取,不再筛选通用聊天列表。LangGraph thread/checkpoint 仍是消息权威数据;专用表仅按 `(task_id, go_path)` 定位 thread。`3qfx/rwfx` 按任务 id,`xdfx` 按 URL 原始行动 id;新 thread 写 `metadata.{taskRecordId,taskGoPath,thread_type:"task_deeplink"}` 并自动登记,通用最近对话默认隐藏。迁移 `20261019_01` 只建表、**不迁移/回填旧数据**。
- **上下文与对话轮数(管理员全局设置)**: 「设置 → 上下文管理」可开启 `system_settings.json -> conversation_context.recent_turn_window_enabled`(默认关闭),并调整 `max_conversation_rounds`(单个普通对话的真实用户提问上限,默认 20,范围 1–50);统一走 `GET/PUT /api/system-settings/conversation-context`,PUT 仅管理员。轮数上限由 `ChatPage` 读取,达到后禁止继续发送并引导新建对话;旧配置缺字段时仍按 20。近 3 轮模式开启后,`RecentTurnsMiddleware` 只改变单次模型请求,保留含当前轮在内的最近 3 个真实用户回合及其 assistant/tool 链;checkpoint 与前端完整聊天记录不删。该模式优先于聊天框「上下文压缩」,且不会调用摘要模型;关闭后沿用原压缩行为。实现 `deerflow/agents/middlewares/recent_turns_middleware.py`,回归 `tests/test_summarization_multiturn.py` / `tests/test_prompt_prefix.py`。
- **输入框选项按窗口隔离(per-tab composer options)**: 输入框工具条的「写作 / 文件 / 参考文献 / 上下文压缩」开关与「模型 / 快速(模式) / 思考强度」选择器原本存 `localStorage`(`deerflow.local-settings`)且 store.ts 监听 `storage` 事件跨窗口同步——一个窗口切换、所有窗口生效。现这 7 个字段拆入 `sessionStorage`(`deerflow.composer-session`):纯函数拆分/合并/解析在 `core/settings/composer-scope.ts`,`core/settings/local.ts` 的 `getLocalSettings`/`saveLocalSettings` 负责路由(全局文档读写时强制剥离这 7 个字段,历史残留值失效)。**每个浏览器窗口独立**——复制链接新开的窗口从默认值开始、互不影响;同一窗口刷新保留;`useLocalSettings` API 与全部使用方(input-box / chat-box / message-list / hooks / IframeChatPage 等)零改动。「选择 Agent」的新对话预选 `deerflow.last-agent-id` 同样改 sessionStorage。其余 LocalSettings(字号 / 布局模式 / composer 宽度等外观偏好)保持浏览器全局并继续跨窗口同步;一次性迁移标志 `deerflow.composer-session.migrated` 把升级前旧值搬进升级后第一个打开的窗口。「经典检索/智能检索」与「知识空间」选择本来就是页面 state + 线程 metadata,天然按窗口隔离,未改动。测试 `pnpm test:composer-session`(`core/settings/composer-scope.test.ts`)。
- **上下文近 3 轮滑窗(管理员全局开关)**: 「设置 → 上下文管理」可开启 `system_settings.json -> conversation_context.recent_turn_window_enabled`(默认关闭;`GET/PUT /api/system-settings/conversation-context`,PUT 仅管理员)。开启后 `RecentTurnsMiddleware` 只改变单次模型请求,保留含当前轮在内的最近 3 个真实用户回合及其 assistant/tool 链;checkpoint 与前端完整聊天记录不删。该模式优先于聊天框「上下文压缩」,且不会调用摘要模型;关闭后沿用原压缩行为。实现 `deerflow/agents/middlewares/recent_turns_middleware.py`,回归 `tests/test_summarization_multiturn.py` / `tests/test_prompt_prefix.py`。
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
- **UI**: Radix UI primitives + TailwindCSS 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/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
- **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
- **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 提示。
+3 -6
View File
@@ -118,10 +118,8 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
- **智能体对话跳转**: 创建页、智能体管理编辑框和问答页详情面板都可以给智能体配置「对话跳转」链接、打开方式(新窗口 / 当前窗口)和路由参数(登录参数、主题参数,解析方式与应用管理相同)。配置保存在该智能体 `config.yaml` 的 `chat_link`,并镜像到 `agent_extras.chat_link` 供列表读取。留空时智能体管理的「对话」按钮仍进入本系统问答;填了地址后,按钮按解析后的链接跳转。
- **普通问答地图可视化**: shared `MarkdownContent` recognizes only dedicated `map-json` fences and otherwise preserves the legacy Markdown/Streamdown path byte-for-byte. `components/workspace/visualizations/` validates the bounded map contract with Zod after recovering common marker/line aliases (`coords`, `from`/`to`, `lng`/`lat`) and dropping unusable lines so markers still render; markers accept per-point `color`; lines are emitted only when the user explicitly asks to connect places, then render as colored SVG arrows that loop along the path (honoring `prefers-reduced-motion`); it lazy-loads MapLibre, and provides loading/error/text fallbacks so malformed or unreachable maps never blank the message. The lead-agent prompt emits `map-json` only when the user explicitly asks for a map; flowcharts and other graphics continue through the existing Markdown/Mermaid path. The raster tile template comes from the single post-build setting `runtime-config.js` → `VITE_MAP_TILE_URL` (Google by default); replace it with the intranet `{z}/{x}/{y}` endpoint for disconnected deployments.
- **HTML 成品离线绘图运行时**: 新生成的 `.html` 源码直接引用 Gateway `/api/public/html-runtime/` 下的浏览器版 `g6.min.js`、`echarts.min.js`、`leaflet.js/css` 和 `deerflow-runtime.js`;文件随后台源码存放于 `vendor/html-runtime/`,不再依赖前端 Vite/node_modules 产物或公网 CDN。设置 → 外观的「HTML 运行地址」保存在 `system_settings.json -> html_runtime`(`GET|PUT /api/system-settings/html-runtime`,管理员可写):`base_url` 是写入新 HTML 的浏览器可访问绝对地址,`map_tile_url` 是地图网页模式读取的瓦片模板。未保存时才回退到旧的 `config.yaml`,最后才用本机默认地址。设置 → 外观的管理员选项「HTML 地图」可选 `echarts`(本地 GeoJSON,`data-df-geo`,默认)或 `leaflet`(地图网页,`data-df-map`);运行时同时保留两种初始化,已生成页面不因切换失效。用户上传的栅格图若要作为页面背景,使用同一 Gateway 源下的签名地址 `/api/public/html-images/{token}` 写入 `background-image`。流程图使用 `data-df-flow` + G6(不用 Mermaid),图表使用 `data-df-chart`。普通问答保持独立:消息列表 `map-json` 继续由前端原逻辑渲染,普通流程图/图表仍走 Markdown/Mermaid。
- **系统名称与 Logo**: 管理员在设置弹窗「系统名称」里填写本系统导航、侧栏和欢迎区使用的名称,并可上传一张 PNG/JPG/WebP Logo。配置在 `system_settings.json -> app_identity`,图片在运行目录 `app-identity/`,接口 `GET/PUT/DELETE /api/system-settings/app-identity`(写仅管理员),图片地址 `GET /api/public/app-identity/logo`。名称留空继续用「CM业务中心」/「CM 助手」;未上传 Logo 继续用各主题静态图片和既有 `runtime-config.js` 覆盖。不改已生成的 HTML。
- **普通问答地图可视化**: shared `MarkdownContent` recognizes only dedicated `map-json` fences and otherwise preserves the legacy Markdown/Streamdown path byte-for-byte. `components/workspace/visualizations/` validates the bounded map contract with Zod, lazy-loads MapLibre, and provides loading/error/text fallbacks so malformed or unreachable maps never blank the message. The lead-agent prompt emits `map-json` only when the user explicitly asks for a map; flowcharts and other graphics continue through the existing Markdown/Mermaid path. The raster tile template comes from the single post-build setting `runtime-config.js` → `VITE_MAP_TILE_URL` (Google by default); replace it with the intranet `{z}/{x}/{y}` endpoint for disconnected deployments.
- **HTML 成品离线绘图运行时**: 新生成的 `.html` 源码直接引用 Gateway `/api/public/html-runtime/` 下的浏览器版 `g6.min.js`、`echarts.min.js`、`leaflet.js/css` 和 `deerflow-runtime.js`;文件随后台源码存放于 `vendor/html-runtime/`,不再依赖前端 Vite/node_modules 产物或公网 CDN。`config.yaml -> html_runtime.base_url` 是写入新 HTML 的浏览器可访问绝对地址,`html_runtime.map_tile_url` 是地图网页模式读取的瓦片模板。设置 → 外观的管理员选项「HTML 地图」可选 `echarts`(本地 GeoJSON,`data-df-geo`,默认)或 `leaflet`(地图网页,`data-df-map`);运行时同时保留两种初始化,已生成页面不因切换失效。用户上传的栅格图若要作为页面背景,使用同一 Gateway 源下的签名地址 `/api/public/html-images/{token}` 写入 `background-image`。流程图使用 `data-df-flow` + G6(不用 Mermaid),图表使用 `data-df-chart`。普通问答保持独立:消息列表 `map-json` 继续由前端原逻辑渲染,普通流程图/图表仍走 Markdown/Mermaid。
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API.
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
- **策略优选**: route `/page/strategy/qa/strategy-selection`, sidebar 「问答管理 → 策略优选」. The standalone `src/strategy-selection/` workbench has two horizontal split stages: Step 1 keeps intent-understanding conversation cards on the left and an editable task draft plus angle selection on the right; Step 2 keeps multiplexed parallel strategy cards and automatic judging on the left while the right stays dedicated to the selected-strategy basket and final Markdown synthesis. It consumes the existing `POST /api/parallel-agents/run/stream` protocol, isolates output by stable agent id, supports expanded per-agent dialogs, user selection, Markdown editing/copy/download, and shared server-side DOCX export. A clearly marked neutral Taiwan-Strait risk-management demo is loaded by default for offline UI acceptance. Administrators can edit the SOUL and skills of the nine built-in functional agents through the page dialog; the backend seeds `strategy-selection-{intent,angle-*,judge,solution}` only when their directories are missing, preserving later admin changes. Pure parsing/SSE tests live in `src/strategy-selection/lib.test.ts`; backend seed tests live in `tests/test_strategy_selection_seed.py`.
@@ -135,12 +133,11 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **会话冲突与模型工具流恢复**: when a user submits a new question while the previous turn is still streaming, the backend's `RunManager.create_or_reject` rejects with HTTP 409 (`services.py` returns the Chinese detail「当前对话还在生成回答…」). `core/threads/hooks.ts` detects the conflict and routes it through `confirmOrRejoin`. Run-creation SSE responses expose `Content-Location` plus the SDK `Location` reconnect endpoint; `core/api/api-client.ts` records the last consumed event id, so an in-page rejoin resumes after that cursor instead of replaying tool arguments from `-1`. `write_file` argument updates carried by `messages-tuple` count as business activity. A known active run is probed only after 90 seconds without a business frame; the watchdog hides `lg:stream`, stops only the old browser reader, and rejoins without cancelling the backend run. Clean EOF, transport errors, 409 and watchdog recovery share one single-flight Promise to prevent duplicate joins/finalization. The backend `PatchedChatOpenAI` separately normalizes confirmed cumulative argument snapshots and bounded compatible response shapes while preserving standard deltas; its content-free diagnostics never log prompts, arguments or file bodies.
- **React #185 与流式刷新保护**: every direct LangGraph `useStream` surface (main/agent chat, AI writing, Canvas and Studio notebook) uses shared `STREAM_RENDER_BATCHING = true`. The installed SDK implements numeric `throttle: 16` as a trailing debounce, which can indefinitely postpone UI updates during a continuous fast stream; `true` retains asynchronous next-task batching without requiring 16ms silence. `stream-error-classification.ts` detects `Maximum update depth exceeded` / production `#185` through bounded, cycle-safe wrappers. Main chat filters the SDK's stored error and duplicate submit rejection; `react-ui-error.ts` gives each send at most one automatic UI recovery, then pauses rejoin without cancelling the server run. Token activity must not reset that budget. The top-level `ReactUiErrorBoundary` catches only this render/commit error, shows a static friendly reload panel, and never automatically remounts the failing tree. Other errors are not swallowed. Shared toasts are deduplicated for 30 seconds; diagnostics retain error type, recovery phase, thread/run identifiers and component stack but never message/tool content. Real SDK SSE replay covers partial reasoning and write_file Markdown before generation ends; the browser boundary check triggers a real production React layout-effect loop and requires manual reload to recover. These safeguards do not prove the unknown production component loop is fixed.
- **流式游标作用域**: resumable cursors belong to the current SDK message accumulator, not a module-global map. `createStreamAPIClient` isolates same-reader recovery; newly mounted chats and real thread switches must replay the retained prefix instead of borrowing another reader's consumed position. Clearing replaces the cursor map so late old-reader events cannot pollute the new scope. The shared `getAPIClient` does not rewrite replay-from-start cursors. `stream-cursor-replay.test.ts` uses the real SDK to protect against both duplicate tool arguments on reconnect and missing prefixes on reopening.
- **单智能体任务深链聊天记录(2026-10-19)**: `3qfx` / `rwfx` / `xdfx` 已不再通过 `useTaskThreads` 扫描、过滤通用 `threads_meta` 列表;`TaskFxWorkspace` / `TaskFxIndexResolver` 统一调用 `useTaskDeeplinkThreads` → `GET /api/task-deeplink-threads?task_id=&go_path=`,读取独立表 `task_deeplink_threads` 的 `(task_id, go_path)` 索引,再按 `thread_id` 关联 canonical `threads_meta` 标题/状态(消息与 checkpoint 仍由 LangGraph 管理)。新会话 metadata 写 `taskRecordId`、`taskGoPath`、`thread_type=task_deeplink` 并由 thread-meta 仓储同事务登记;`3qfx/rwfx` 的 `taskRecordId` 是任务 id,`xdfx` 是 URL 原始行动 id(真实任务 id 仍保留在 `metadata.taskId` 维持共享访问语义)。普通聊天记录默认隐藏该 thread type;显式 taskId 查询仍可读取。迁移 `20261019_01` **只建表、不回填旧数据**,因此上线前旧深链会话不会出现在新列表。`/api/open/3qfx/ask` 同样写专用索引。Tests: `packages/harness/tests/test_task_deeplink_threads.py`, `src/core/auth/task-purpose-metadata.test.ts`, `src/core/threads/hidden-chats.test.ts`。
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent/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
- **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
- **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
@@ -1,739 +0,0 @@
# 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、数据库密码或未经检查的完整业务日志。
+38 -122
View File
@@ -1,13 +1,6 @@
importScripts("config.js", "shared.js");
const ALARM_NAME = "deer-browser-task-poll";
const SERVICE_DISCOVERY_TTL_MS = 10 * 60 * 1000;
const STATUS_CACHE_TTL_MS = 60 * 1000;
const STATUS_RETRY_MS = 5 * 60 * 1000;
const STATUS_MANUAL_MIN_INTERVAL_MS = 30 * 1000;
const statusCache = new Map();
const statusRequests = new Map();
const discoveryRequests = new Map();
const LEGACY_LOCAL_API_BASE_URL = "http://localhost:8001";
const LEGACY_LOCAL_WEB_BASE_URL = "http://localhost:5174";
let pollTimer = null;
@@ -25,9 +18,6 @@ async function getSettings() {
const discovered = await chrome.storage.local.get({
discoveredApiBaseUrl: "",
discoveredWebBaseUrl: "",
lastServiceSyncAt: "",
lastServiceSyncSource: "",
lastServiceSyncContext: "",
});
if (settings.autoDiscoverServiceUrl) {
settings.apiBaseUrl = discovered.discoveredApiBaseUrl || settings.apiBaseUrl;
@@ -43,9 +33,6 @@ async function getSettings() {
if (!settings.workerId) {
updates.workerId = deerMakeWorkerId();
}
settings.lastServiceSyncAt = discovered.lastServiceSyncAt || "";
settings.lastServiceSyncSource = discovered.lastServiceSyncSource || "";
settings.lastServiceSyncContext = discovered.lastServiceSyncContext || "";
if (Object.keys(updates).length > 0) {
await chrome.storage.sync.set(updates);
Object.assign(settings, updates);
@@ -113,77 +100,28 @@ function apiCandidatesForWebBase(webBase, currentApiBase) {
return candidates;
}
async function serviceCacheKey(settings, apiBaseUrl, webBaseUrl = "") {
const cookie = await getCookieToken({ ...settings, apiBaseUrl });
const auth = JSON.stringify([settings.accessToken || "", cookie?.token || ""]);
// Persist only a credential fingerprint with the discovery timestamp. A new
// login or cookie must not reuse another account's cached status/backoff.
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(auth));
const fingerprint = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
return JSON.stringify([normalizeServiceBase(apiBaseUrl), normalizeServiceBase(webBaseUrl), fingerprint]);
}
function statusRetryMs(response) {
const value = response.headers?.get("Retry-After");
if (!value) return STATUS_RETRY_MS;
const seconds = Number(value);
const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(value) - Date.now();
return Number.isFinite(delay) ? Math.max(STATUS_RETRY_MS, Math.min(delay, 60 * 60 * 1000)) : STATUS_RETRY_MS;
}
async function browserContextStatus(apiBaseUrl, settings = {}, force = false) {
const base = normalizeServiceBase(apiBaseUrl);
const key = await serviceCacheKey(settings, base);
const cached = statusCache.get(key);
const now = Date.now();
if (cached && now < (force ? cached.manualRetryAt : cached.expiresAt)) {
return cached.value;
}
if (statusRequests.has(key)) return statusRequests.get(key);
const request = (async () => {
let value = null;
let ttl = STATUS_RETRY_MS;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(`${base}/api/browser-context/status`, {
credentials: "include",
signal: controller.signal,
});
const data = await response.json().catch(() => null);
ttl = statusRetryMs(response);
if (response.status === 403 && data?.detail?.code === "browser_status_disabled") {
value = { enabled: false, statusAccessDisabled: true };
} else if (response.status === 401 || response.status === 403) {
// Authentication still establishes a candidate's API identity. Do not
// mistake the explicit administrative status switch for a login error.
value = { enabled: true, authenticationRequired: true };
} else if (response.ok && data && typeof data.enabled === "boolean") {
value = data;
if (data.enabled) ttl = STATUS_CACHE_TTL_MS;
}
} catch {
// Failed candidates are negative-cached too: an unreachable deployment
// must not cause every open tab's three-second wake-up to probe it again.
} finally {
clearTimeout(timeout);
}
const checkedAt = Date.now();
statusCache.set(key, {
value,
expiresAt: checkedAt + ttl,
manualRetryAt: checkedAt + (ttl === STATUS_CACHE_TTL_MS ? STATUS_MANUAL_MIN_INTERVAL_MS : ttl),
});
if (statusCache.size > 100) statusCache.delete(statusCache.keys().next().value);
return value;
})();
statusRequests.set(key, request);
async function browserContextStatus(apiBaseUrl) {
try {
return await request;
} finally {
statusRequests.delete(key);
const response = await fetch(`${apiBaseUrl}/api/browser-context/status`, {
credentials: "include",
});
// The gateway may enforce authentication globally before this otherwise
// public discovery endpoint is reached. A 401/403 still proves that this
// candidate is the zncm API paired with the open frontend tab.
if (response.status === 401 || response.status === 403) {
return { enabled: true, authenticationRequired: true };
}
if (!response.ok) {
return null;
}
const data = await response.json().catch(() => null);
if (data && typeof data.enabled === "boolean") {
return data;
}
} catch {
return null;
}
return null;
}
function tabLooksLikeZncm(tab) {
@@ -235,37 +173,9 @@ async function findOpenZncmTabs(settings) {
}
async function discoverServiceBases(settings, force = false) {
if (!settings.autoDiscoverServiceUrl && !force) return settings;
const key = await serviceCacheKey(settings, settings.apiBaseUrl, settings.webBaseUrl);
if (discoveryRequests.has(key)) return discoveryRequests.get(key);
const request = discoverServiceBasesOnce(settings, force, key);
discoveryRequests.set(key, request);
try {
return await request;
} finally {
discoveryRequests.delete(key);
}
}
async function discoverServiceBasesOnce(settings, force, contextKey) {
if (!settings.autoDiscoverServiceUrl && !force) {
return settings;
}
const lastSync = Date.parse(settings.lastServiceSyncAt || "");
const discoveryAge = Date.now() - lastSync;
const discoveryTtl = /browser_context_disabled|api_not_verified/.test(settings.lastServiceSyncSource || "")
? STATUS_RETRY_MS
: SERVICE_DISCOVERY_TTL_MS;
if (
!force &&
Number.isFinite(lastSync) &&
settings.lastServiceSyncSource &&
settings.lastServiceSyncContext === contextKey &&
discoveryAge >= 0 &&
discoveryAge < discoveryTtl
) {
return settings;
}
let webBaseUrl = normalizeServiceBase(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl);
let discoveredWebBaseUrl = webBaseUrl;
@@ -282,7 +192,7 @@ async function discoverServiceBasesOnce(settings, force, contextKey) {
let apiBaseUrl = normalizeServiceBase(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
let verified = false;
for (const candidate of apiCandidatesForWebBase(discoveredWebBaseUrl, apiBaseUrl)) {
const status = await browserContextStatus(candidate, settings, force);
const status = await browserContextStatus(candidate);
if (status) {
apiBaseUrl = candidate;
webBaseUrl = discoveredWebBaseUrl;
@@ -291,7 +201,7 @@ async function discoverServiceBasesOnce(settings, force, contextKey) {
break;
}
}
if (!verified) {
if (!verified && force) {
source = `${source}:api_not_verified`;
}
@@ -303,16 +213,22 @@ async function discoverServiceBasesOnce(settings, force, contextKey) {
lastServiceSyncSource: source,
};
next.lastServiceSyncContext = await serviceCacheKey(next, next.apiBaseUrl, next.webBaseUrl);
// Refresh the timestamp even when the address/source did not change. Without
// this write the first expired TTL causes status probes on every later poll.
await chrome.storage.local.set({
discoveredApiBaseUrl: next.apiBaseUrl,
discoveredWebBaseUrl: next.webBaseUrl,
lastServiceSyncAt: next.lastServiceSyncAt,
lastServiceSyncSource: next.lastServiceSyncSource,
lastServiceSyncContext: next.lastServiceSyncContext,
});
if (
force ||
settings.apiBaseUrl !== next.apiBaseUrl ||
settings.webBaseUrl !== next.webBaseUrl ||
settings.lastServiceSyncSource !== next.lastServiceSyncSource
) {
// Discovered endpoints are runtime state, not user-authored preferences.
// Keep them in storage.local so Chrome sync write quotas cannot break task
// polling. The options page may still persist explicit user choices.
await chrome.storage.local.set({
discoveredApiBaseUrl: next.apiBaseUrl,
discoveredWebBaseUrl: next.webBaseUrl,
lastServiceSyncAt: next.lastServiceSyncAt,
lastServiceSyncSource: next.lastServiceSyncSource,
});
}
return next;
}
@@ -3,18 +3,12 @@ const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const vm = require("node:vm");
const { webcrypto } = require("node:crypto");
const BACKGROUND_PATH = path.join(__dirname, "background.js");
function createHarness(options = {}) {
function createHarness() {
let storageChangedListener;
let alarmCreateCount = 0;
let clock = options.now ?? Date.now();
let fetchHandler = options.fetch;
let cookieToken = "";
const localData = options.localData || {};
const fetchUrls = [];
const settings = {
apiBaseUrl: "http://localhost:8001",
webBaseUrl: "http://localhost:5174",
@@ -24,7 +18,6 @@ function createHarness(options = {}) {
taskPollingEnabled: true,
workerId: "test-worker",
taskPollIntervalSeconds: 5,
...options.settings,
};
const chrome = {
@@ -35,7 +28,7 @@ function createHarness(options = {}) {
},
onAlarm: { addListener: () => {} },
},
cookies: { get: async () => cookieToken ? { value: cookieToken } : null },
cookies: { get: async () => null },
runtime: {
onInstalled: { addListener: () => {} },
onMessage: { addListener: () => {} },
@@ -43,8 +36,8 @@ function createHarness(options = {}) {
},
storage: {
local: {
get: async (defaults) => ({ ...defaults, ...localData }),
set: async (updates) => Object.assign(localData, updates),
get: async (defaults) => ({ ...defaults }),
set: async () => {},
},
session: { get: async () => ({ actSessions: {} }), set: async () => {} },
sync: {
@@ -63,26 +56,13 @@ function createHarness(options = {}) {
const context = {
chrome,
console,
fetch: async (url, init) => {
fetchUrls.push(String(url));
if (fetchHandler) return fetchHandler(url, init);
return {
ok: true,
status: 200,
json: async () => ({ enabled: true }),
};
fetch: async () => {
throw new Error("offline test");
},
importScripts: () => {},
setTimeout: () => 1,
clearTimeout: () => {},
URL,
AbortController,
TextEncoder,
crypto: webcrypto,
Date: class extends Date {
constructor(...args) { super(...(args.length ? args : [clock])); }
static now() { return clock; }
},
DEER_CLIPPER_DEFAULTS: settings,
deerNormalizeBaseUrl: (value) => String(value || "").replace(/\/+$/, ""),
deerMakeWorkerId: () => "test-worker",
@@ -99,17 +79,6 @@ function createHarness(options = {}) {
storageChangedListener(changes, "sync");
},
getAlarmCreateCount: () => alarmCreateCount,
getFetchUrls: () => [...fetchUrls],
getSettings: () => context.getSettings(),
setSettings: (updates) => Object.assign(settings, updates),
getLocalData: () => ({ ...localData }),
advanceTime: (ms) => { clock += ms; },
getTime: () => clock,
setFetch: (handler) => { fetchHandler = handler; },
setCookie: (token) => { cookieToken = token; },
status: (url, settings = {}, force = false) => context.browserContextStatus(url, settings, force),
discoverServiceBases: (settings, force = false) =>
context.discoverServiceBases(settings, force),
resetAlarmCreateCount: () => {
alarmCreateCount = 0;
},
@@ -159,120 +128,3 @@ 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, 1);
harness.advanceTime(30_000);
await harness.discoverServiceBases(await harness.getSettings(), true);
assert.equal(harness.getFetchUrls().length, 2);
});
test("unchanged service discovery refreshes its timestamp after TTL expiry", async () => {
const harness = createHarness();
await settle();
harness.setSettings({ autoDiscoverServiceUrl: true });
await harness.discoverServiceBases(await harness.getSettings());
const firstSync = harness.getLocalData().lastServiceSyncAt;
harness.advanceTime(10 * 60_000 + 1);
await harness.discoverServiceBases(await harness.getSettings());
assert.notEqual(harness.getLocalData().lastServiceSyncAt, firstSync);
assert.equal(harness.getFetchUrls().length, 2);
for (let i = 0; i < 20; i++) {
harness.advanceTime(3_000);
await harness.discoverServiceBases(await harness.getSettings());
}
assert.equal(harness.getFetchUrls().length, 2);
});
test("concurrent status callers share a request and successful results expire", async () => {
const harness = createHarness();
const url = "http://example.test";
const results = await Promise.all(Array.from({ length: 30 }, () => harness.status(url)));
assert.ok(results.every((result) => result.enabled));
assert.equal(harness.getFetchUrls().length, 1);
harness.advanceTime(59_000);
await harness.status(`${url}/`);
assert.equal(harness.getFetchUrls().length, 1);
harness.advanceTime(1_001);
await harness.status(url);
assert.equal(harness.getFetchUrls().length, 2);
});
test("disabled status respects Retry-After even for manual calls and later recovers", async () => {
const harness = createHarness({
fetch: async () => ({
ok: false,
status: 403,
headers: { get: () => "300" },
json: async () => ({ detail: { code: "browser_status_disabled" } }),
}),
});
const url = "http://example.test";
const status = await harness.status(url);
assert.equal(status.enabled, false);
assert.equal(status.statusAccessDisabled, true);
assert.equal(status.authenticationRequired, undefined);
harness.advanceTime(299_999);
await harness.status(url, {}, true);
assert.equal(harness.getFetchUrls().length, 1);
harness.setFetch(async () => ({ ok: true, status: 200, json: async () => ({ enabled: true }) }));
harness.advanceTime(1);
assert.equal((await harness.status(url)).enabled, true);
assert.equal(harness.getFetchUrls().length, 2);
});
test("network and server failures back off for five minutes", async () => {
for (const fetch of [
async () => { throw new Error("offline"); },
async () => ({ ok: false, status: 500, json: async () => ({ detail: "unavailable" }) }),
]) {
const harness = createHarness({ fetch });
for (let i = 0; i < 10; i++) assert.equal(await harness.status("http://example.test", {}, true), null);
assert.equal(harness.getFetchUrls().length, 1);
harness.advanceTime(300_000);
assert.equal(await harness.status("http://example.test"), null);
assert.equal(harness.getFetchUrls().length, 2);
}
});
test("status cache separates service addresses, login tokens and cookies", async () => {
const harness = createHarness();
await harness.status("http://one.test", { accessToken: "account-a" });
await harness.status("http://one.test", { accessToken: "account-b" });
await harness.status("http://two.test", { accessToken: "account-b" });
harness.setCookie("new-login");
await harness.status("http://two.test", { accessToken: "account-b" });
assert.equal(harness.getFetchUrls().length, 4);
await harness.status("http://two.test", { accessToken: "account-b" });
assert.equal(harness.getFetchUrls().length, 4);
});
test("discovery survives worker restarts but refreshes after account changes", async () => {
const harness = createHarness();
await settle();
harness.setSettings({ autoDiscoverServiceUrl: true, accessToken: "account-a" });
await harness.discoverServiceBases(await harness.getSettings());
const localData = harness.getLocalData();
assert.ok(!JSON.stringify(localData).includes("account-a"));
const restarted = createHarness({
localData,
now: harness.getTime(),
settings: { autoDiscoverServiceUrl: true, accessToken: "account-a" },
});
await restarted.discoverServiceBases(await restarted.getSettings());
assert.equal(restarted.getFetchUrls().length, 0);
restarted.setSettings({ accessToken: "account-b" });
await restarted.discoverServiceBases(await restarted.getSettings());
assert.equal(restarted.getFetchUrls().length, 1);
});
-620
View File
@@ -1,620 +0,0 @@
<!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
-36
View File
@@ -1,36 +0,0 @@
# 接口性能管理
管理员入口:**设置 → Redis 与性能**。页面集中管理 Redis 读取缓存、浏览器状态接口访问、历史统计数据定时清理。
## 控制项
- Redis 总开关及有效期:1–3600 秒,默认 300 秒。已有管理员设置优先于 YAML 默认值。
- 接口列表:15 组,支持搜索和单独开关。同组路径共用一个开关;总开关关闭时保留每组选择。列表由后端实际接入目录返回。
- 浏览器状态:允许/拒绝 `GET /api/browser-context/status`,保存后生效。为兼容现有插件,升级默认允许;关闭时返回 403,错误码 `browser_status_disabled`,并建议客户端 300 秒后重试。此开关只控制状态查询,不改变 capture/ask/task 接口的原有权限。
- 定时清理:复用原有审计清理调度及持久化暂停标记。关闭时在当前小批次完成后停止;重启保持关闭。清理范围仍是超过 90 天的工具调用、模型调用、已结束运行摘要和排行榜快照,不清理对话消息。页面仅打开时或手动刷新状态,不新增轮询。
## 缓存覆盖
智能体列表、技能列表、展示脱敏词、个人偏好、常用提示词、推荐问题、轻应用列表/侧栏/详情、完整菜单、简洁菜单、任务按钮、业务映射、HTML 模板、模型能力目录、标签及资源标签、浏览器状态。
模型配置仍按当前配置即时组装,缓存的是其数据库能力目录。智能体、技能和推荐问题保留原有可缓存请求条件。用户相关数据保持用户隔离,菜单同时按角色和菜单类型隔离。对话、流式响应和写入操作不缓存。
写入后使相关缓存失效;同键并发未命中请求合并填充。Redis 未配置、超时或不可用时回到原读取逻辑。超出大小限制的结果不缓存。
## 持久化和升级
`api_read_cache_settings` 保存总开关、有效期、逐组配置和浏览器状态开关。迁移 `20261029_01` 增加有效期,`20261029_02` 增加逐组配置、浏览器开关和缓存代次。沿用项目正常数据库迁移流程后再启动新版服务。
每次保存配置都会轮换持久化缓存代次,避免在 Redis 离线时关闭/开启缓存、随后 Redis 恢复或 Gateway 重启后重新命中旧数据。设置写入在进程内串行化,并使用数据库行锁合并更新;其他 Gateway 进程最多约 2 秒刷新设置。
浏览器扩展也应同步更新:修复服务地址不变时探测时间戳未更新的问题,成功状态短暂缓存、同地址鉴权请求合并,禁用/失败至少退避 5 分钟。旧扩展仍可能发送请求,服务端关闭开关会拒绝这些请求。
## 管理员排行榜
移除 `GET /api/admin/users/{user_id}/analytics` 及用户详情展示/调用;排行榜聚合统计和用户管理保留。旧详情 URL 返回 404。
## 回归入口
后端:`test_api_read_cache.py`、`test_api_performance_controls.py`、`test_api_performance_edge_cases.py`、`test_catalog_cache_routes.py`、`test_admin_analytics_removed.py`,以及菜单、模型管理和审计清理现有测试。
浏览器扩展:`node --test browser-extension/deer-web-clipper/background.test.cjs`。
-21
View File
@@ -1,21 +0,0 @@
# 按模型追加全局提示词
管理员打开「设置 → 模型追加提示词」,选择模型,填写提示词并开启开关,点击「保存此模型设置」。每个模型独立保存,关闭开关会保留文本。
设置按模型配置的 `name` 精确匹配,而不是显示名或供应商模型名。所有用户通过平台统一模型入口调用该模型时,都会在系统提示词末尾追加该文本;切换到其他模型不会携带它。未设置的模型保持原有行为。
设置保存在运行目录的 `system_settings.json` 的 `model_prompt_suffixes` 中,默认空、默认关闭。每次模型请求读取最新设置,已经创建的模型实例也能在下一次请求使用新值,无需重启。不会改写用户问题或持久化聊天记录,也不会修改原有智能体提示词文件。提示词用于引导回答方式,无法保证上游模型一定回答。
## 接口与实现
- `GET /api/system-settings/model-prompt-suffixes`:读取全部设置,仅管理员。
- `PUT /api/system-settings/model-prompt-suffixes/{model_name}`:保存单个模型,正文为 `{ "enabled": true, "prompt": "自定义要求" }`,仅管理员;模型名需 URL 编码,支持包含 `/` 的名称。文本最多 20000 字符,启用时不可为空。
- 统一入口 `deerflow.models.factory.create_chat_model` 保留原供应商模型的工具绑定与流式能力;OpenAI 兼容请求在传输适配提示词处理后追加,支持 Chat Completions 和 Responses;其他适配器在输入转换与批量生成入口追加。
- 外部系统自行调用模型、绕过此统一入口的调用不受该设置控制。
- 回归测试:`packages/harness/tests/test_model_prompt_suffix.py`,覆盖权限、持久化、模型隔离、重复追加防护、原始消息不变、即时更新、同步/异步/流式/批量调用及工具绑定。
## 普通问答与智能体问答的末尾顺序验证
两种页面均传递当前输入框的 `model_name`。Lead Agent 按「请求选择 → 智能体默认 → 系统默认」解析实际模型,然后调用同一 `create_chat_model`。普通问答使用通用模板,智能体问答使用其 SOUL.md 模板;主题、知识库范围、项目上下文及运行时中间件继续组装提示词。追加设置在模型请求层处理,因此位于这些内容之后。
回归测试运行两种 Lead Agent 分支的真实 LangGraph 图、模型工厂与 OpenAI 请求序列化,用模拟 HTTP 接口捕获最终请求,覆盖普通/智能体、默认/目标/其他模型、同步响应/流式响应共 12 种组合。测试注入后置系统要求和检索上下文,断言最终系统文本以管理员提示词结尾且只出现一次,用户问题保持原样。另验证 text_bridge、buffered_gateway、artifact_renderer 三种文件传输适配提示词均位于管理员提示词之前。测试不访问真实上游模型。
File diff suppressed because it is too large. Load diff
-76
View File
@@ -1,76 +0,0 @@
# 单机动态在线 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 验收版本,配置与数据不打入包。
@@ -1,539 +0,0 @@
# 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 回归
[ ] 先发布代码并观察,再决定是否删除临时索引
```
@@ -1,916 +0,0 @@
# 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
@@ -1,831 +0,0 @@
<!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= https://knowledgetoolweb.b.uat.4.cn
VITE_AIPI_FRONTEND_URL= xxx
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 …;到全局 `roundtable_room_limit.max_cycles` 配置轮次进入无工具的总控正文收口)**完全由前端 hook 驱动** | `src/roundtable-planning/hooks/useStep2Orchestration.ts`(recommend / chain / dag) |
| Step2 编排循环(leader → 顺序派 special → leader … 直到 `dispatched===[]` 共识 / `MAX_CYCLES=8`)**完全由前端 hook 驱动** | `src/roundtable-planning/hooks/useStep2Orchestration.ts:992-1204`(recommend)、`1228-1439`(chain) |
| 循环由 `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 / 预算**:后台与前端统一读取全局 `roundtable_room_limit.max_cycles`(默认 8,2..100),一次会商启动时锁定;到达配置轮次调用 `finalize_only=True`,关闭全部工具并要求总控输出最终总结正文。不能把到顶表现成“已停止”,也不能跳过总结直接进入 Step3。
5. **MAX_CYCLES / 预算**:后台图内兜底(对齐前端 `MAX_CYCLES=8`),到顶强制收敛并照常进 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()` 拉本人链条渲染**卡片列表**(席位存活/名称用链条响应自带的读时计算富化字段 `seat.available`/`seat.live_name`,**不再实时拉 `listAgents()`**,见 §11):
- [x] 进入该 Tab 时 `listChains()` 拉本人链条 + `listAgents()` 取存活 id/名称,渲染**卡片列表**:
- [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` 则禁用「进入研讨」并提示。存活判定改读 `seat.available`(undefined = 旧后端未标注 → 不误判删除)。
- [x] 链条里有 agent 已不存在(被删)→ 该 tag 置灰删除线 + `title` 提示 + 卡片下方 amber 警告;confirm 按**存活席位**过滤;存活 < `CHAIN_MIN_SEATS` 则禁用「进入研讨」并提示。agent 列表加载失败时 `existingIds=null` → 不误判删除。
- **验收**:✅ typecheck 0 新增错误(总数仍 76)。卡片渲染标题 + 彩色 tag + ← 箭头;选链 → `onConfirm(mode:"chain")` → `handleConfirmAgents` 按链条顺序 `setSelectedAgents` → Step 2 左侧「参与角色」顺序 = 链条顺序(**按顺序派活在阶段 4**)。**人工 UI 验收待跑**。
- **箭头方向**(已确认):tag 左→右排列、`→`(`ArrowRightIcon`)连接,`seats[0]` 最左、先发言,箭头顺读即链条研讨顺序。
@@ -396,42 +396,5 @@ 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 == configuredMaxCycles:
leader(finalizeOnly: true, tools: [], prompt: "直接综合已有交付并输出最终正文")
if cycles >= MAX_CYCLES:
toast("⏱️ 已达到最大研讨轮次,自动停止以避免循环")
clearAllStreamingSpeakers()
isOrchestrating = false
@@ -695,7 +695,7 @@ window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开)
### 12.3 调整 Step 2 编排上限
收口轮数来自全局设置 `roundtable_room_limit.max_cycles`(管理员设置弹窗「会商并发与收口」,默认 8、范围 2..100),旧服务端或读取失败时回退 `DEFAULT_MAX_CYCLES=8`。一次编排启动时锁定当时值;到达该轮不是停止,而是切换为 `finalizeOnly` 纯正文收口:总控禁用全部工具,综合已经取得的席位交付并输出最终方案;个别席位失败时如实标明缺口。前后台执行路径必须保持一致。
`RoundtablePlanningPage.tsx` 内 `MAX_CYCLES = 8`,超出会 toast「⏱️ 已达到最大研讨轮次,自动停止以避免循环」。若放宽:注意 `step2RoundtableDialogues` 数组会线性增长,超长讨论需要考虑虚拟滚动。
### 12.4 把 special 改回并行
+2 -18
View File
@@ -76,25 +76,9 @@
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script>
// Relative production assets need a directory URL. A mounted prefix such
// as /web must resolve against /web/. history.replaceState during parsing
// makes old Firefox abort the document (NS_BINDING_ABORTED) before the
// login bundle runs, so install a base URL instead of rewriting history.
(function () {
try {
var path = window.location.pathname;
if (path === "/" || /\/$/.test(path) || /\.[^/]+$/.test(path)) return;
if (document.querySelector("base")) return;
var base = document.createElement("base");
base.href = path + "/";
document.head.appendChild(base);
} catch (e) {}
})();
</script>
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>CM 助手</title>
<script src="%BASE_URL%runtime-config.js" data-runtime-config></script>
<script src="/runtime-config.js" data-runtime-config></script>
</head>
<body>
<div id="root"></div>
+2 -9
View File
@@ -11,23 +11,16 @@
"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:users-paged": "node scripts/test-users-paged.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:task-entry": "node --experimental-strip-types --test src/core/auth/task-entry.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 src/components/workspace/visualizations/map-style.test.ts",
"test:composer-session": "node --experimental-strip-types --test src/core/settings/composer-scope.test.ts",
"test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts",
"test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs",
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs",
"test:retry-kept": "npx esbuild src/core/threads/retry-kept-messages.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/retry-kept-messages.test.mjs && node --test .runtime/unit-tests/retry-kept-messages.test.mjs"
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
+1 -5
View File
@@ -42,7 +42,7 @@ window.__CM_RUNTIME_CONFIG__ = {
// 地图瓦片地址模板:仅供普通问答消息列表中的交互地图使用。
// 后续接入内网瓦片服务时,只需替换此项并保留 {z}/{x}/{y} 占位符。
VITE_MAP_TILE_URL: "http://119.254.86.251:8023/tiles/{z}/{x}/{y}.png",
VITE_MAP_TILE_URL: "http://119.254.86.251:8023/tiles/{z}/{x}/{y}.png?v=zh-z14-13",
// routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。
// 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。
@@ -112,9 +112,6 @@ window.__CM_RUNTIME_CONFIG__ = {
// A 系统跳转地址:人物、组织、事件等列表项打开链接使用。
VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain",
// B 系统(XWT检索)头像代理;按部署环境设置为浏览器可访问的地址。
VITE_AIPI_MINIO_PROXY_URL: "http://minio-proxy.b.sit.4.cn/facebook-proxy",
// B 系统 knowes 根地址。
VITE_AIPI_KNOWES_URL: "http://ch1.b.uat.4.cn/knowes",
@@ -131,7 +128,6 @@ window.__CM_RUNTIME_CONFIG__ = {
VITE_USE_REAL_DATA: "true",
// 登录深链任务详情是否使用本地 mock:false=真实接口;true=本地 mock。
// 为 true 时同时拦截 xdfx 的 task-action/actionDetail 与 mainnets/narratives-by-name。
VITE_MOCK_COP_TASK_DETAIL: "false",
// 岗位会商完整演示数据:true=启动时幂等写入后端;后端不可用时直接使用前端 JSON 兜底。
+3 -9
View File
@@ -9,19 +9,13 @@ 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 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",
"login-transport": "src/core/auth/login-transport.test.ts",
};
const testNames = ["login-entry", "session-validation"];
const outputDirectory = path.join(root, ".runtime/unit-tests");
try {
await build({
absWorkingDir: root,
entryPoints: testEntries,
entryPoints: testNames.map((name) => `src/core/auth/${name}.test.ts`),
outdir: outputDirectory,
outExtension: { ".js": ".mjs" },
bundle: true,
@@ -32,7 +26,7 @@ try {
stop();
}
const result = spawnSync(process.execPath, ["--test", ...Object.keys(testEntries).map((name) => path.join(outputDirectory, `${name}.mjs`))], {
const result = spawnSync(process.execPath, ["--test", ...testNames.map((name) => path.join(outputDirectory, `${name}.test.mjs`))], {
cwd: root,
stdio: "inherit",
});
@@ -1,28 +0,0 @@
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;
-90
View File
@@ -1,90 +0,0 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import path from "node:path";
import { test } from "node:test";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = fileURLToPath(new URL("../", import.meta.url));
const require = createRequire(import.meta.url);
const viteRequire = createRequire(require.resolve("vite/package.json"));
const { build, stop } = viteRequire("esbuild");
const outfile = path.join(root, ".runtime/unit-tests/users-paged-api.mjs");
try {
await build({
absWorkingDir: root,
entryPoints: ["src/core/auth/api.ts"],
outfile,
bundle: true,
platform: "node",
format: "esm",
plugins: [{
name: "mock-user-api-dependencies",
setup(builder) {
builder.onResolve({ filter: /^@\/core\/(api\/fetch-client|config)$/ }, ({ path }) => ({ path, namespace: "mock" }));
builder.onLoad({ filter: /.*/, namespace: "mock" }, ({ path }) => ({
contents: path.endsWith("config")
? 'export const getBackendBaseURL = () => "https://example.test";'
: 'export const apiFetch = (...args) => globalThis.usersPagedFetch(...args);',
}));
},
}],
});
} finally {
stop();
}
const { listUsersPaged } = await import(pathToFileURL(outfile).href);
const fetchPage = (signal) => listUsersPaged({ q: "alice", limit: 20, offset: 40 }, signal);
test("whitelist requests pagination and search without statistics", async () => {
const users = { items: [{ id: "u1", email: "one@example.test", approved: false }], total: 41, approved_count: 40, limit: 20, offset: 40 };
globalThis.usersPagedFetch = async (url, init) => {
assert.equal(url, "https://example.test/api/v1/auth/users?limit=20&offset=40&include_counts=false&q=alice");
assert.ok(init.signal instanceof AbortSignal);
return Response.json(users);
};
assert.deepEqual(await fetchPage(), users);
});
test("server timeout detail reaches the retry UI", async () => {
globalThis.usersPagedFetch = async () => Response.json({ detail: "用户基础信息查询超时,请稍后重试" }, { status: 503 });
await assert.rejects(fetchPage(), /用户基础信息查询超时/);
});
test("client deadline aborts pending request and clears its timer", async (t) => {
let deadline;
const timer = {};
t.mock.method(globalThis, "setTimeout", (callback, delay) => {
assert.equal(delay, 20_000);
deadline = callback;
return timer;
});
const cleared = t.mock.method(globalThis, "clearTimeout", (value) => assert.equal(value, timer));
globalThis.usersPagedFetch = (_url, { signal }) => new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
});
const pending = fetchPage();
deadline();
await assert.rejects(pending, /用户列表加载超时/);
assert.equal(cleared.mock.callCount(), 1);
});
test("query cancellation propagates without being reported as a timeout", async () => {
const query = new AbortController();
globalThis.usersPagedFetch = (_url, { signal }) => new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
});
const pending = fetchPage(query.signal);
query.abort();
await assert.rejects(pending, { name: "AbortError" });
});
test("non-JSON errors preserve HTTP status", async () => {
globalThis.usersPagedFetch = async () => new Response("unavailable", { status: 502 });
await assert.rejects(fetchPage(), /HTTP 502/);
});
test("already cancelled requests remain cancellation errors", async () => {
const controller = new AbortController();
controller.abort();
globalThis.usersPagedFetch = async (_url, { signal }) => { signal.throwIfAborted(); };
await assert.rejects(fetchPage(controller.signal), { name: "AbortError" });
});
+90 -133
View File
@@ -1,19 +1,16 @@
import { lazy, Suspense, useEffect, useMemo } from "react";
import { useEffect, useMemo } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { DesensitizeWordsLoader } from "@/components/desensitize-words-loader";
import { FontScaleApplier } from "@/components/font-scale";
import { QueryClientProvider } from "@/components/query-client-provider";
import { RouteLoadingFallback } from "@/components/route-loading";
import { Toaster } from "@/components/ui/sonner";
import { AuthSyncWatcher } from "@/core/auth/sync";
import { RequireAccount } from "@/core/auth/require-account";
import { I18nProvider } from "@/core/i18n/context";
import { fetchYLogConfig } from "@/core/log/y-log-config";
import { detectLocale } from "@/core/i18n/locale";
import { resolveForcedThemeForPath } from "@/core/page-layout/chats-iframe-route";
import { resolveB51ForcedTheme } from "@/core/auth/b5-1-theme";
import { ChatProvider } from "@/contexts/ChatContext";
import { ThemeProvider } from "@/contexts/ThemeContext";
import {
@@ -29,47 +26,29 @@ import {
import { HashRouter, useLocation } from "react-router-dom";
import LandingPage from "./pages/LandingPage";
import LoginPage from "./pages/LoginPage";
import RegisterPage from "./pages/RegisterPage";
import PageRoutes from "./pages/PageRoutes";
import PublicScheduledTaskPage, {
PublicScheduledTaskFullscreenPage,
} from "./pages/PublicScheduledTaskPage";
import PublicSharePage from "./pages/PublicSharePage";
import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
import EmbedChatPage from "./pages/EmbedChatPage";
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
import SiteStudioPage from "./pages/site-studio/SiteStudioPage";
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
import {
isPositionRoundtableStandaloneMode,
POSITION_ROUNDTABLE_ROUTE,
} from "@/position-roundtable/demo/position-roundtable-demo-config";
const LandingPage = lazy(() => import("./pages/LandingPage"));
const LoginPage = lazy(() => import("./pages/LoginPage"));
const PageRoutes = lazy(() => import("./pages/PageRoutes"));
const MarkdownDocxEditorPage = lazy(() => import("./pages/MarkdownDocxEditorPage"));
const PublicScheduledTaskPage = lazy(() => import("./pages/PublicScheduledTaskPage"));
const PublicScheduledTaskFullscreenPage = lazy(() =>
import("./pages/PublicScheduledTaskPage").then((module) => ({
default: module.PublicScheduledTaskFullscreenPage,
})),
);
const PublicSharePage = lazy(() => import("./pages/PublicSharePage"));
const PublicLeaderboardPage = lazy(() => import("./pages/PublicLeaderboardPage"));
const PublicRoundtableSharePage = lazy(() => import("./pages/PublicRoundtableSharePage"));
const EmbedChatPage = lazy(() => import("./pages/EmbedChatPage"));
const EmbedRoundtablePage = lazy(() => import("./pages/EmbedRoundtablePage"));
const SiteStudioPage = lazy(() => import("./pages/site-studio/SiteStudioPage"));
const DashboardStandalonePage = lazy(
() => import("@/roundtable-planning/pages/DashboardStandalonePage"),
);
const PositionRoundtablePage = lazy(
() => import("@/position-roundtable/pages/PositionRoundtablePage"),
);
const WeKnoraWikiPageView = lazy(
() => import("@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView"),
);
const ChatRuntime = lazy(() =>
import("@/components/workspace/chat-runtime").then((module) => ({
default: module.ChatRuntime,
})),
);
const NotFoundPage = lazy(() =>
import("./pages/NotFoundPage").then((module) => ({
default: module.NotFoundPage,
})),
);
const OfficialDeerFlowEntry = lazy(() => import("./pages/OfficialDeerFlowEntry"));
import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
import OfficialDeerFlowEntry from "./pages/OfficialDeerFlowEntry";
const PUBLIC_EMBED_PREFIXES = [
"/share/",
@@ -83,7 +62,6 @@ function AppProviders({ children }: { children: React.ReactNode }) {
const location = useLocation();
const publicEmbedTheme = usePublicEmbedTheme();
const positionStandaloneMode = isPositionRoundtableStandaloneMode();
const markdownToolMode = location.pathname.replace(/\/+$/, "") === "/tools/md-to-word";
// iframe 嵌入会话(按标签页 sessionStorage)集中在此同步:任何带 `&embed=1` 的入口(深链登录页
// 或直接深链路由)一进来就启用、`embed=0` 关闭,后续干净路由靠会话维持去 chrome + 强制主题。
useEffect(() => {
@@ -92,14 +70,10 @@ function AppProviders({ children }: { children: React.ReactNode }) {
// 操作日志(Y-Log):启动早期预热上报配置,使 writeYLog 同步读缓存即可工作
// (刷新后 module 缓存清空,token 登录用户也能继续上报)。失败静默。
useEffect(() => {
if (positionStandaloneMode || markdownToolMode) return;
if (positionStandaloneMode) return;
void fetchYLogConfig();
}, [positionStandaloneMode, markdownToolMode]);
}, [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);
@@ -138,17 +112,15 @@ function AppProviders({ children }: { children: React.ReactNode }) {
forcedTheme={forcedTheme}
>
<QueryClientProvider>
<AuthSyncWatcher disabled={positionStandaloneMode || markdownToolMode}>
<AuthSyncWatcher disabled={positionStandaloneMode}>
<I18nProvider initialLocale={locale}>
{markdownToolMode ? children : (
<ThemeProvider>
<ChatProvider>
{!positionStandaloneMode && <DesensitizeWordsLoader />}
<FontScaleApplier />
{children}
</ChatProvider>
</ThemeProvider>
)}
<ThemeProvider>
<ChatProvider>
{!positionStandaloneMode && <DesensitizeWordsLoader />}
<FontScaleApplier />
{children}
</ChatProvider>
</ThemeProvider>
</I18nProvider>
</AuthSyncWatcher>
</QueryClientProvider>
@@ -171,9 +143,6 @@ function RootRedirect() {
export function App() {
const positionStandaloneMode = isPositionRoundtableStandaloneMode();
const standaloneLoginRedirect = (
<Navigate to={POSITION_ROUNDTABLE_ROUTE} replace />
);
const pageElement = positionStandaloneMode ? (
<RequireAccount allowLegacyAnonymous>
<ChatRuntime>
@@ -187,78 +156,66 @@ export function App() {
return (
<HashRouter>
<AppProviders>
<Suspense fallback={<RouteLoadingFallback />}>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route
path="/login"
element={
positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />
}
/>
<Route
path="/login/:username"
element={
positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />
}
/>
<Route path="/landing" element={<LandingPage />} />
{/* Anonymous standalone tool: no workspace navigation or login gate. */}
<Route
path="/tools/md-to-word"
element={
<div className="h-dvh overflow-hidden">
<MarkdownDocxEditorPage />
<Toaster />
</div>
}
/>
<Route path="/share/:shareCode" element={<PublicSharePage />} />
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
<Route
path="/roundtable-share/:shareCode"
element={<PublicRoundtableSharePage view="landing" />}
/>
<Route
path="/roundtable-share/:shareCode/report"
element={<PublicRoundtableSharePage view="report" />}
/>
<Route
path="/roundtable-share/:shareCode/dashboard"
element={<PublicRoundtableSharePage view="dashboard" />}
/>
<Route
path="/public/scheduled-tasks/:taskId"
element={<PublicScheduledTaskPage />}
/>
<Route
path="/public/scheduled-tasks/:taskId/runs/:runId/fullscreen"
element={<PublicScheduledTaskFullscreenPage />}
/>
<Route
path="/embed/chats/:thread_id"
element={
<ChatRuntime>
<EmbedChatPage />
</ChatRuntime>
}
/>
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
<Route
path="/embed/knowledge/:noteId"
element={<WeKnoraWikiPageView publicEmbed />}
/>
<Route
path="/roundtable-dashboard"
element={<RequireAccount><DashboardStandalonePage /></RequireAccount>}
/>
<Route path="/official/*" element={<RequireAccount><OfficialDeerFlowEntry /></RequireAccount>} />
{/* Web App 站点预览工作室:独立路由 + 独立视觉风格(无主应用 chrome) */}
<Route path="/sites/:threadId/:siteName" element={<RequireAccount><SiteStudioPage /></RequireAccount>} />
<Route path="/page/*" element={pageElement} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route
path="/login"
element={<LoginPage />}
/>
<Route
path="/login/:username"
element={<LoginPage />}
/>
<Route
path="/register"
element={<RegisterPage />}
/>
<Route path="/landing" element={<LandingPage />} />
<Route path="/share/:shareCode" element={<PublicSharePage />} />
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
<Route
path="/roundtable-share/:shareCode"
element={<PublicRoundtableSharePage view="landing" />}
/>
<Route
path="/roundtable-share/:shareCode/report"
element={<PublicRoundtableSharePage view="report" />}
/>
<Route
path="/roundtable-share/:shareCode/dashboard"
element={<PublicRoundtableSharePage view="dashboard" />}
/>
<Route
path="/public/scheduled-tasks/:taskId"
element={<PublicScheduledTaskPage />}
/>
<Route
path="/public/scheduled-tasks/:taskId/runs/:runId/fullscreen"
element={<PublicScheduledTaskFullscreenPage />}
/>
<Route
path="/embed/chats/:thread_id"
element={
<ChatRuntime>
<EmbedChatPage />
</ChatRuntime>
}
/>
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
<Route
path="/embed/knowledge/:noteId"
element={<WeKnoraWikiPageView publicEmbed />}
/>
<Route
path="/roundtable-dashboard"
element={<RequireAccount><DashboardStandalonePage /></RequireAccount>}
/>
<Route path="/official/*" element={<RequireAccount><OfficialDeerFlowEntry /></RequireAccount>} />
{/* Web App 站点预览工作室:独立路由 + 独立视觉风格(无主应用 chrome) */}
<Route path="/sites/:threadId/:siteName" element={<RequireAccount><SiteStudioPage /></RequireAccount>} />
<Route path="/page/*" element={pageElement} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</AppProviders>
</HashRouter>
);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

@@ -998,24 +998,11 @@ export const PromptInputTextarea = ({
// Chrome 109 ignores field-sizing: content. Preserve the same 4rem–12rem
// autosize range used by min-h-16/max-h-48 instead of leaving the prompt
// stuck at its minimum height. A custom appearance height replaces that range.
let minBound = 64;
let maxBound = 192;
if (textarea.hasAttribute("data-composer-height")) {
const computed = window.getComputedStyle(textarea);
const minHeight = Number.parseFloat(computed.minHeight);
const maxHeight = Number.parseFloat(computed.maxHeight);
if (Number.isFinite(minHeight)) minBound = minHeight;
if (Number.isFinite(maxHeight)) maxBound = maxHeight;
}
// stuck at its minimum height.
textarea.style.height = "auto";
const height = Math.max(
minBound,
Math.min(textarea.scrollHeight, maxBound),
);
const height = Math.max(64, Math.min(textarea.scrollHeight, 192));
textarea.style.height = `${height}px`;
textarea.style.overflowY =
textarea.scrollHeight > maxBound ? "auto" : "hidden";
textarea.style.overflowY = textarea.scrollHeight > 192 ? "auto" : "hidden";
}, []);
// onInput does not run for a controlled value filled by a suggested question
@@ -1,67 +0,0 @@
/* DialogContent is rendered in a portal. Use the root theme selectors directly
* so deep-blue styles work regardless of Tailwind's variant compilation order. */
:root.dark .document-upload-dialog,
:root[theme-mode="dark"] .document-upload-dialog {
border-color: #2d4473 !important;
background-color: #0c1836 !important;
color: #c5cfe0 !important;
}
:root.dark .document-upload-dialog__search,
:root[theme-mode="dark"] .document-upload-dialog__search {
border-color: #2d4473 !important;
background-color: #122347 !important;
}
:root.dark .document-upload-dialog__search:hover,
:root[theme-mode="dark"] .document-upload-dialog__search:hover {
border-color: #4a6491 !important;
}
:root.dark .document-upload-dialog__search-input,
:root[theme-mode="dark"] .document-upload-dialog__search-input {
color: #c5cfe0 !important;
}
:root.dark .document-upload-dialog__search-input::placeholder,
:root[theme-mode="dark"] .document-upload-dialog__search-input::placeholder,
:root.dark .document-upload-dialog__search-icon,
:root[theme-mode="dark"] .document-upload-dialog__search-icon {
color: #6e88b0 !important;
}
:root.dark .document-upload-dialog__search-button,
:root[theme-mode="dark"] .document-upload-dialog__search-button {
background-color: #1677ff !important;
}
:root.dark .document-upload-dialog__search-button:hover,
:root[theme-mode="dark"] .document-upload-dialog__search-button:hover {
background-color: #0958d9 !important;
}
:root.dark .document-upload-dialog__tabs,
:root[theme-mode="dark"] .document-upload-dialog__tabs {
border-color: #2d4473 !important;
background-color: #122347 !important;
}
:root.dark .document-upload-dialog__tab,
:root[theme-mode="dark"] .document-upload-dialog__tab {
color: #6e88b0 !important;
}
:root.dark .document-upload-dialog__tab:hover,
:root[theme-mode="dark"] .document-upload-dialog__tab:hover {
color: #c5cfe0 !important;
}
:root.dark .document-upload-dialog__tab--active,
:root[theme-mode="dark"] .document-upload-dialog__tab--active {
color: #91caff !important;
}
:root.dark .document-upload-dialog__tab-indicator,
:root[theme-mode="dark"] .document-upload-dialog__tab-indicator {
background-color: #4096ff !important;
}
@@ -27,16 +27,10 @@ import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
import { readRuntimeEnv } from "@/env";
import "./document-upload-popup.css";
import { queryClient } from "@/components/query-client-provider";
import { Checkbox } from "@/components/ui/checkbox";
import Empty from "./empty";
import { NetworkActor } from "./network-actor";
import {
normalizeV2UploadSources,
type StudioUploadSource,
type V2UploadResponse,
} from "@/core/studio/document-upload";
// ── 适配层 ─────────────────────────────────────────────────
@@ -92,7 +86,22 @@ interface DocumentUploadDialogContextType {
data?: { query: string; mode: string }
) => Promise<void>;
// 上传成功回调,返回上传的来源信息
onUploadComplete?: (sources: StudioUploadSource[]) => void;
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
}
// ── 任务轮询类型 ─────────────────────────────────────────────
interface TaskSource {
task_id: string;
title: string;
status: 'processing' | 'success' | 'error';
source_type: string;
}
interface UploadResponse {
status: string;
uploaded: number;
skipped: number;
sources: TaskSource[];
}
interface listDataItem {
@@ -215,7 +224,7 @@ export const DocumentUploadDialogProvider: FC<{
) => void | Promise<void>;
searchSpace: { id: number | string;[key: string]: unknown };
onUploadSuccess?: () => void;
onUploadComplete?: (sources: StudioUploadSource[]) => void;
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
}> = ({ children, onClose, searchSpace, onUploadSuccess, onUploadComplete }) => {
const [isOpen, setIsOpen] = useState(false);
const [initialSearchText, setInitialSearchText] = useState("");
@@ -344,23 +353,27 @@ const DocumentUploadPopupContent: FC<{
for (const file of files) {
formData.append("files", file);
}
const res = await fetch(`${backendUrl}/api/v1/documents/fileupload-v2`, {
const res = await fetch(`${backendUrl}/api/v1/documents/fileupload`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (res.ok) {
const data = await res.json() as V2UploadResponse;
const data = await res.json() as UploadResponse;
// 调用上传完成回调,传递来源信息
if (data.sources && data.sources.length > 0) {
onUploadComplete?.(normalizeV2UploadSources(data));
onUploadComplete?.(data.sources.map(s => ({
id: undefined, // 后端返回的数据中暂时没有 id
title: s.title,
task_id: s.task_id,
status: s.status,
})));
}
toast.success(`上传成功 ${data.uploaded || files.length} 个文件`);
// 只关闭上传弹窗
onOpenChange(false);
} else {
const payload = await res.json().catch(() => null) as { detail?: string } | null;
toast.error(payload?.detail || `上传失败(HTTP ${res.status})`);
toast.error("上传失败,请重试");
}
} catch {
toast.error("上传失败,请重试");
@@ -397,6 +410,7 @@ const DocumentUploadPopupContent: FC<{
selectedSearchSources.map((s) => ({
id: undefined,
title: s.title,
task_id: undefined,
status: "success" as const,
}))
);
@@ -600,13 +614,11 @@ const DocumentUploadPopupContent: FC<{
};
const yUserId = getYUserId();
const notebookToken = getNotebookToken();
const iframeTokenParam = notebookToken ? `&token=${encodeURIComponent(notebookToken)}` : "";
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent
className="document-upload-dialog w-[90vw] max-w-[90vw] sm:max-w-[90vw] h-[90vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground dark:border-[#2d4473] dark:bg-[#0c1836] dark:text-[#c5cfe0] [&>button]:right-3 [&>button]:top-4 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 dark:[&>button]:text-[#c5cfe0]"
className="w-[90vw] max-w-[90vw] sm:max-w-[90vw] h-[90vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 [&>button]:top-4 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
@@ -616,7 +628,7 @@ const DocumentUploadPopupContent: FC<{
<div className="h-full overflow-y-auto">
<div className="px-3 sm:px-12 pt-12 sm:pt-24 pb-6 sm:pb-16">
{/* 搜索框 */}
<div className="document-upload-dialog__search relative rounded-lg border border-neutral-200 bg-white mb-[10px] transition-colors hover:border-neutral-300 dark:border-[#2d4473] dark:bg-[#122347] dark:hover:border-[#4a6491]">
<div className="relative rounded-lg border border-neutral-200 bg-white mb-[10px] transition-colors hover:border-neutral-300">
<Textarea
placeholder="搜索资料..."
value={searchText}
@@ -627,24 +639,24 @@ const DocumentUploadPopupContent: FC<{
searchDataChange();
}
}}
className="document-upload-dialog__search-input max-h-[160px] resize-none border-0 bg-transparent px-10 py-3 pr-20 text-sm text-neutral-700 placeholder:text-neutral-400 focus-visible:ring-0 focus-visible:outline-none dark:text-[#c5cfe0] dark:placeholder:text-[#6e88b0]"
className="max-h-[160px] resize-none border-0 bg-transparent px-10 py-3 pr-20 text-sm text-neutral-700 placeholder:text-neutral-400 focus-visible:ring-0 focus-visible:outline-none"
rows={1}
/>
<div className="document-upload-dialog__search-icon absolute left-3 top-3 text-neutral-400 pointer-events-none dark:text-[#6e88b0]">
<div className="absolute left-3 top-3 text-neutral-400 pointer-events-none">
<Search className="h-4 w-4" strokeWidth={1.5} />
</div>
<Button
size="sm"
onClick={searchDataChange}
disabled={isSearching || !searchText.trim()}
className="document-upload-dialog__search-button absolute right-2 top-2 h-7 rounded-md bg-neutral-900 px-3 text-xs font-medium text-white transition-colors hover:bg-neutral-800 disabled:pointer-events-none disabled:opacity-40 dark:bg-[#1677ff] dark:hover:bg-[#0958d9]"
className="absolute right-2 top-2 h-7 rounded-md bg-neutral-900 px-3 text-xs font-medium text-white transition-colors hover:bg-neutral-800 disabled:pointer-events-none disabled:opacity-40"
>
{isSearching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <span>搜索</span>}
</Button>
</div>
{/* Tab 标签页 */}
<div className="document-upload-dialog__tabs border-b border-gray-200 rounded-t-xl px-4 bg-gray-50 dark:border-[#2d4473] dark:bg-[#122347]">
<div className="border-b border-gray-200 rounded-t-xl px-4 bg-gray-50">
<div className="flex flex-nowrap overflow-x-auto">
{(
[
@@ -658,12 +670,12 @@ const DocumentUploadPopupContent: FC<{
<button
key={key}
onClick={() => void searchActiveChange(key)}
className={`document-upload-dialog__tab ${activeTab === key ? "document-upload-dialog__tab--active" : ""} relative pb-3 px-6 pt-4 text-sm font-medium transition-colors ${activeTab === key ? "text-gray-900 dark:text-[#91caff]" : "text-gray-400 hover:text-gray-600 dark:text-[#6e88b0] dark:hover:text-[#c5cfe0]"
className={`relative pb-3 px-6 pt-4 text-sm font-medium transition-colors ${activeTab === key ? "text-gray-900" : "text-gray-400 hover:text-gray-600"
}`}
>
{label}
{activeTab === key && (
<span className="document-upload-dialog__tab-indicator absolute bottom-0 left-0 right-0 h-0.5 bg-gray-900 rounded-full dark:bg-[#4096ff]" />
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-900 rounded-full" />
)}
</button>
))}
@@ -675,7 +687,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}${iframeTokenParam}`}
src={`${knowesUrl}/fromB1?yUserId=${yUserId}&processType=searchMode&bookChatId=${searchSpaceId}&inputValue=${searchValue}`}
/>
)}
@@ -683,7 +695,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}${iframeTokenParam}`}
src={`${knowledgeUrl}/#/sousou?chatType=new-chat&bookChatId=${searchSpaceId}&inputValue=${searchText}`}
/>
)}
@@ -1,169 +0,0 @@
/* 深蓝主题下,XWT 检索面板使用全局 deep-blue token,与弹窗外壳保持一致。 */
:root.dark .network-actor,
:root[theme-mode="dark"] .network-actor {
color: #c5cfe0;
}
:root.dark .network-actor__filter-surface,
:root[theme-mode="dark"] .network-actor__filter-surface {
background-color: #122347 !important;
}
:root.dark .network-actor__filter-panel,
:root[theme-mode="dark"] .network-actor__filter-panel {
border-color: #2d4473 !important;
background-color: #0f1d3e;
}
:root.dark .network-actor__filter-label,
:root.dark .network-actor__section-title,
:root.dark .network-actor__metric-label,
:root[theme-mode="dark"] .network-actor__filter-label,
:root[theme-mode="dark"] .network-actor__section-title,
:root[theme-mode="dark"] .network-actor__metric-label {
color: #c5cfe0 !important;
}
:root.dark .network-actor__filter-chip,
:root[theme-mode="dark"] .network-actor__filter-chip {
border-color: #2d4473 !important;
background-color: #1a2f5a !important;
color: #c5cfe0 !important;
}
:root.dark .network-actor__filter-chip:hover,
:root[theme-mode="dark"] .network-actor__filter-chip:hover {
border-color: #4a6491 !important;
background-color: #243b68 !important;
}
:root.dark .network-actor__filter-chip[aria-pressed="true"],
:root[theme-mode="dark"] .network-actor__filter-chip[aria-pressed="true"] {
border-color: #1677ff !important;
background-color: #122347 !important;
color: #91caff !important;
}
:root.dark .network-actor__filter-chip:focus-visible,
:root[theme-mode="dark"] .network-actor__filter-chip:focus-visible,
:root.dark .network-actor__sort-button:focus-visible,
:root[theme-mode="dark"] .network-actor__sort-button:focus-visible {
outline: 2px solid #69b1ff;
outline-offset: 2px;
}
:root.dark .network-actor__platform-divider,
:root[theme-mode="dark"] .network-actor__platform-divider,
:root.dark .network-actor__metric-divider,
:root[theme-mode="dark"] .network-actor__metric-divider {
background-color: #4a6491 !important;
}
:root.dark .network-actor__results-surface,
:root[theme-mode="dark"] .network-actor__results-surface {
background-color: #122347 !important;
}
:root.dark .network-actor__results-header,
:root[theme-mode="dark"] .network-actor__results-header {
border-color: #2d4473 !important;
}
:root.dark .network-actor__title-accent,
:root[theme-mode="dark"] .network-actor__title-accent {
background-color: #1677ff !important;
}
:root.dark .network-actor__sort-button,
:root[theme-mode="dark"] .network-actor__sort-button {
border-color: #2d4473 !important;
background-color: #1a2f5a !important;
color: #c5cfe0 !important;
}
:root.dark .network-actor__sort-button:hover,
:root[theme-mode="dark"] .network-actor__sort-button:hover {
border-color: #4a6491 !important;
background-color: #243b68 !important;
}
:root.dark .network-actor__sort-button--active,
:root[theme-mode="dark"] .network-actor__sort-button--active {
border-color: #1677ff !important;
background-color: #122347 !important;
color: #91caff !important;
}
:root.dark .network-actor__sort-button svg path,
:root[theme-mode="dark"] .network-actor__sort-button svg path {
fill: #4a6491;
}
:root.dark .network-actor__sort-button--active svg[data-direction="asc"] path:first-child,
:root[theme-mode="dark"] .network-actor__sort-button--active svg[data-direction="asc"] path:first-child,
:root.dark .network-actor__sort-button--active svg[data-direction="desc"] path:last-child,
:root[theme-mode="dark"] .network-actor__sort-button--active svg[data-direction="desc"] path:last-child {
fill: #4096ff;
}
:root.dark .network-actor__metric-value--selected,
:root[theme-mode="dark"] .network-actor__metric-value--selected {
color: #ffc53d !important;
}
:root.dark .network-actor__metric-value--total,
:root[theme-mode="dark"] .network-actor__metric-value--total,
:root.dark .network-actor__meta-icon,
:root[theme-mode="dark"] .network-actor__meta-icon {
color: #69b1ff !important;
}
:root.dark .network-actor__selection-bar,
:root[theme-mode="dark"] .network-actor__selection-bar {
border-color: #2d4473 !important;
background-color: #1a2f5a !important;
color: #c5cfe0;
}
:root.dark .network-actor__card,
:root[theme-mode="dark"] .network-actor__card {
border-color: #2d4473 !important;
background-color: #1a2f5a !important;
box-shadow: 0 1px 4px rgba(6, 14, 36, 0.3);
color: #c5cfe0;
}
:root.dark .network-actor__card:hover,
:root[theme-mode="dark"] .network-actor__card:hover {
border-color: #4a6491 !important;
box-shadow: 0 4px 12px rgba(6, 14, 36, 0.45);
}
:root.dark .network-actor__card[data-selected="true"],
:root[theme-mode="dark"] .network-actor__card[data-selected="true"] {
border-color: #1677ff !important;
box-shadow: 0 0 0 1px rgba(22, 119, 255, 0.24);
}
:root.dark .network-actor__avatar,
:root[theme-mode="dark"] .network-actor__avatar {
border-color: #2d4473 !important;
background-color: #122347 !important;
}
:root.dark .network-actor__classification,
:root[theme-mode="dark"] .network-actor__classification {
background-color: rgba(245, 34, 45, 0.16) !important;
color: #ff7875 !important;
}
:root.dark .network-actor__tag,
:root[theme-mode="dark"] .network-actor__tag {
background-color: rgba(19, 194, 194, 0.14) !important;
color: #5cdbd3 !important;
}
:root.dark .network-actor input[type="checkbox"],
:root[theme-mode="dark"] .network-actor input[type="checkbox"] {
accent-color: #1677ff;
}
@@ -6,33 +6,12 @@ import {
useState,
} from "react";
import { Textarea } from "@/components/ui/textarea";
import {
CirclePlay,
FileText,
FolderTree,
Globe2,
Loader2,
Network,
Plus,
Tag,
Trash2,
UserRoundCheck,
UsersRound,
} from "lucide-react";
import { Loader2, Plus, Search, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
import { authenticatedFetch } from "@/components/assistant-ui/document-upload-popup";
import { readRuntimeEnv } from "@/env";
import xLogo from "@/assets/platform/x.png";
import facebookLogo from "@/assets/platform/facebook.png";
import redditLogo from "@/assets/platform/reddit.png";
import pttLogo from "@/assets/platform/ptt.png";
import dcardLogo from "@/assets/platform/dcard.png";
import mobile01Logo from "@/assets/platform/mobile01.png";
import threadsLogo from "@/assets/platform/threads.png";
import instagramLogo from "@/assets/platform/ins.png";
import "./network-actor.css";
interface NetworkActorProps {
@@ -43,6 +22,7 @@ interface NetworkActorProps {
interface platformItem {
title: string;
icon: string;
checked: boolean;
}
@@ -51,14 +31,6 @@ interface languageItem {
checked: boolean;
}
interface ActorSearchFilters {
platforms: string[];
languages: string[];
direction: string;
sortBy: string;
sortDirection: "asc" | "desc";
}
interface PoliticsDirItem {
地区: string;
语言: string;
@@ -96,86 +68,6 @@ interface hx_info {
用户画像: 用户画像;
}
// 图片必须经 import 走 Vite 打包才能拼上 base(服务器带路径前缀时 public/ 绝对路径会 404)
const PLATFORM_LOGOS: Record<string, string> = {
x: xLogo,
facebook: facebookLogo,
reddit: redditLogo,
ptt: pttLogo,
dcard: dcardLogo,
mobile01: mobile01Logo,
threads: threadsLogo,
instagram: instagramLogo,
};
// 后端返回的平台名与筛选 chips 的大小写/缩写不完全一致(如 FB、INS、YT、TH),统一归一到上面的 key
const PLATFORM_KEY_RULES: Array<[RegExp, string]> = [
[/instagram|\bins\b|\big\b/, "instagram"],
[/facebook|\bfb\b/, "facebook"],
[/youtube|\byt\b|油管/, "youtube"],
[/threads|\bth\b/, "threads"],
[/mobile01|\bmobile\b/, "mobile01"],
[/dcard/, "dcard"],
[/ptt/, "ptt"],
[/reddit|\bre\b/, "reddit"],
[/twitter|推特/, "x"],
];
function platformLogoKey(platform: string): string {
const key = platform.trim().toLowerCase();
if (PLATFORM_LOGOS[key]) return key;
for (const [pattern, canonical] of PLATFORM_KEY_RULES) {
if (pattern.test(key)) return canonical;
}
return key;
}
function PlatformIcon({ platform, className = "" }: { platform: string; className?: string }) {
const key = platformLogoKey(platform);
const src = PLATFORM_LOGOS[key];
if (src) {
return <img src={src} className={`h-5 w-5 shrink-0 object-contain ${className}`} alt="" aria-hidden="true" />;
}
if (key === "youtube") {
return <CirclePlay className={`h-5 w-5 shrink-0 text-red-600 ${className}`} aria-hidden="true" />;
}
return <Globe2 className={`h-5 w-5 shrink-0 text-slate-500 ${className}`} aria-hidden="true" />;
}
function MetaIcon({
type,
className = "",
}: {
type: "followers" | "person" | "category" | "tags" | "summary" | "network";
className?: string;
}) {
const iconClassName = `network-actor__meta-icon h-4 w-4 shrink-0 ${className}`;
const icons = {
followers: <UsersRound className={iconClassName} aria-hidden="true" />,
person: <UserRoundCheck className={iconClassName} aria-hidden="true" />,
category: <FolderTree className={iconClassName} aria-hidden="true" />,
tags: <Tag className={iconClassName} aria-hidden="true" />,
summary: <FileText className={iconClassName} aria-hidden="true" />,
network: <Network className={iconClassName} aria-hidden="true" />,
};
return icons[type];
}
function SortDirectionIcon({ direction, active }: { direction: "asc" | "desc"; active: boolean }) {
const ascendingColor = active && direction === "asc" ? "#cf1322" : "#cbd5e1";
const descendingColor = active && direction === "desc" ? "#cf1322" : "#cbd5e1";
return (
<svg viewBox="0 0 12 18" data-direction={direction} className="h-5 w-3 shrink-0" aria-hidden="true">
<path d="M6 2 10 7H2L6 2Z" fill={ascendingColor} />
<path d="m6 16-4-5h8l-4 5Z" fill={descendingColor} />
</svg>
);
}
interface UserDataItem {
comment_count: number;
country: string | null;
@@ -222,9 +114,9 @@ interface UserDataItem {
birthday: string | null;
is_virtual: number;
isMedia: number;
hx_info?: hx_info | null;
hx_info: hx_info;
gender: string | null;
agentInfo?: PoliticsDirItem | null;
agentInfo: PoliticsDirItem;
profile_url: string | null;
tagId: string[];
user_name: string;
@@ -260,15 +152,15 @@ interface UserDataItem {
export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkActorProps) {
const [platforms, setPlatforms] = useState<platformItem[]>([
{ title: 'X', checked: false },
{ title: 'Facebook', checked: false },
{ title: 'Reddit', checked: false },
{ title: 'PTT', checked: false },
{ title: 'YouTube', checked: false },
{ title: 'Dcard', checked: false },
{ title: 'Mobile01', checked: false },
{ title: 'Threads', checked: false },
{ title: 'Instagram', checked: false },
{ title: 'X', icon: 'x.png', checked: false },
{ title: 'Facebook', icon: 'facebook.png', checked: false },
{ title: 'Reddit', icon: 'reddit.png', checked: false },
{ title: 'PTT', icon: 'ptt.png', checked: false },
{ title: 'YouTube', icon: 'youtube.png', checked: false },
{ title: 'Dcard', icon: 'dcard.png', checked: false },
{ title: 'Mobile01', icon: 'mobile01.png', checked: false },
{ title: 'Threads', icon: 'threads.png', checked: false },
{ title: 'Instagram', icon: 'ins.png', checked: false },
]);
const platformList = useRef<string[]>([]);
const [languages, setLanguages] = useState<languageItem[]>([
@@ -296,37 +188,25 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
const [directionType, setDirectionType] = useState<string>('TW');
const directionValue = useRef<string>('TW');
const consumerUrl = readRuntimeEnv("VITE_AIPI_CONSUMER_URL") || 'https://ch1.b.uat.4.cn/consumer';
const minioProxyUrl = readRuntimeEnv("VITE_AIPI_MINIO_PROXY_URL") || 'http://minio-proxy.b.sit.4.cn/facebook-proxy';
const actorBaseUrl = readRuntimeEnv("VITE_AIPI_B2ACTOR_URL") || 'http://ty.b.uat.4.cn';
const avatarUrl = (profileImage: string | null) => profileImage
? `${minioProxyUrl}?${new URLSearchParams({ url: profileImage, is_avatar: 'true' })}`
: `${actorBaseUrl}/manage/img/default.png`;
const backendUrl = readRuntimeEnv("VITE_AIPI_BACKENP_URL") || 'http://localhost:8001';
const backendUrl = typeof window !== 'undefined'
? (readRuntimeEnv("VITE_BACKEND_BASE_URL") || 'http://localhost:8001')
: 'http://localhost:8001';
const [selectedSearchSources, setSelectedSearchSources] = useState<UserDataItem[]>([]);
const [isImporting, setIsImporting] = useState(false);
const resolveSearchFilters = (overrides: Partial<ActorSearchFilters> = {}): ActorSearchFilters => ({
platforms: overrides.platforms ?? platformList.current,
languages: overrides.languages ?? languageList.current,
direction: overrides.direction ?? directionValue.current,
sortBy: overrides.sortBy ?? sortTypeValue.current,
sortDirection: overrides.sortDirection ?? sortValue.current as ActorSearchFilters["sortDirection"],
});
const searchChange = async (overrides: Partial<ActorSearchFilters> = {}) => {
const searchChange = async () => {
setIsSearching(true);
const filters = resolveSearchFilters(overrides);
if (searchValue.current === 'network') {
await agentUserSearch(filters);
await agentUserSearch();
} else {
await agentGroupSearch(filters);
await agentGroupSearch();
}
};
const setDirectionChange = async (type: string) => {
setDirectionType(type);
directionValue.current = type;
await searchChange({ direction: type });
await searchChange();
};
const searchTypeChange = async (value: string) => {
@@ -336,128 +216,135 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
};
const allPlatformChange = async () => {
const nextPlatforms = platforms.map((item) => ({ ...item, checked: false }));
setAllPlatforms(true);
setPlatforms(nextPlatforms);
setPlatforms((prev) => prev.map((item) => ({ ...item, checked: false })));
platformList.current = [];
await searchChange({ platforms: [] });
await searchChange();
};
const handleTogglePlatform = async (index: number) => {
const nextPlatforms = platforms.map((item, itemIndex) => (
itemIndex === index ? { ...item, checked: !item.checked } : item
));
const checkedTitles = nextPlatforms.filter((item) => item.checked).map((item) => item.title);
setPlatforms(nextPlatforms);
platformList.current = checkedTitles;
setAllPlatforms(checkedTitles.length === 0);
await searchChange({ platforms: checkedTitles });
setPlatforms((prev) => {
const newPlatforms = prev.map((item, i) =>
i === index ? { ...item, checked: !item.checked } : item
);
const checkedTitles = newPlatforms.filter((item) => item.checked).map((item) => item.title);
platformList.current = checkedTitles;
setAllPlatforms(newPlatforms.every((item) => !item.checked));
return newPlatforms;
});
await searchChange();
};
const allLanguageChange = async () => {
const nextLanguages = languages.map((item) => ({ ...item, checked: false }));
setAllLanguage(true);
setLanguages(nextLanguages);
setLanguages((prev) => prev.map((item) => ({ ...item, checked: false })));
languageList.current = [];
await searchChange({ languages: [] });
await searchChange();
};
const handleToggleLanguage = async (index: number) => {
const nextLanguages = languages.map((item, itemIndex) => (
itemIndex === index ? { ...item, checked: !item.checked } : item
));
const checkedTitles = nextLanguages.filter((item) => item.checked).map((item) => item.title);
setLanguages(nextLanguages);
languageList.current = checkedTitles;
setAllLanguage(checkedTitles.length === 0);
await searchChange({ languages: checkedTitles });
setLanguages((prev) => {
const newItems = prev.map((item, i) =>
i === index ? { ...item, checked: !item.checked } : item
);
const checkedTitles = newItems.filter((item) => item.checked).map((item) => item.title);
languageList.current = checkedTitles;
setAllLanguage(newItems.every((item) => !item.checked));
return newItems;
});
await searchChange();
};
const sortTypeChange = async (type: string) => {
const nextSortDirection = (sortValue.current === 'desc' ? 'asc' : 'desc') as ActorSearchFilters["sortDirection"];
setSortType(type);
sortTypeValue.current = type;
sortValue.current = nextSortDirection;
if (type === 'fans_count') setFansSort(nextSortDirection);
else if (type === '_score') setRelevanceSort(nextSortDirection);
await searchChange({ sortBy: type, sortDirection: nextSortDirection });
sortValue.current = sortValue.current === 'desc' ? 'asc' : 'desc';
if (type === 'fans_count') setFansSort(sortValue.current);
else if (type === '_score') setRelevanceSort(sortValue.current);
await searchChange();
};
const agentUserSearch = async (filters: ActorSearchFilters) => {
const agentUserSearch = async () => {
try {
const response = await authenticatedFetch(`${consumerUrl}/portrait/agentUser`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin' },
body: JSON.stringify({
historyKey: ['党政要员'],
historyKey: [],
historyTitle: '',
keyword: searchText,
language: filters.languages,
language: languageList.current,
page: 1,
limit: 12,
platform: filters.platforms,
politicalStand: filters.direction,
sort: filters.sortBy,
asc: filters.sortDirection,
platform: platformList.current,
politicalStand: directionValue.current,
sort: sortTypeValue.current,
asc: sortValue.current,
typeKeyword: 9,
}),
});
if (!response.ok) return;
const data = await response.json();
const result: UserDataItem[] = Array.isArray(data?.data?.list) ? data.data.list : [];
result.forEach((item) => {
item.profile_image = avatarUrl(item.profile_image);
item.classification = `${item.political_stand ?? ''}-${item.history_key ?? ''}-${item.history_title ?? ''}`;
item.title = item.user_name || item.screen_name || item.user_id;
item.content = item.agentInfo?.['账号描述'] || item.hx_info?.['用户画像']?.['账号描述'] || '';
item.url = `http://ty.b.uat.4.cn/fromB/toDetail?user_id=${item.user_id}&platform=${item.platform}`;
});
setUserList(result);
setSelNetCount(data?.data?.total ?? 0);
setActorCount(data?.data?.zs_total ?? 0);
if (response.ok) {
const data = await response.json();
setIsSearching(false);
const result: UserDataItem[] = data.data.list;
result.forEach((item) => {
item.profile_image = item.profile_image
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
: 'http://ty.b.uat.4.cn/manage/img/default.png';
item.classification = `${item.political_stand}-${item.history_key}-${item.history_title}`;
item.title = item.user_name || item.screen_name || item.user_id;
item.content = item.agentInfo['账号描述'];
item.url = `http://ty.b.uat.4.cn/fromB/toDetail?user_id=${item.user_id}&platform=${item.platform}`;
});
setUserList(result);
setSelNetCount(data.data.total);
setActorCount(data.data.zs_total);
}
} catch (error) {
console.error('Search error:', error);
} finally {
setIsSearching(false);
}
};
const agentGroupSearch = async (filters: ActorSearchFilters) => {
const agentGroupSearch = async () => {
try {
const response = await authenticatedFetch(`${consumerUrl}/portrait/agentGroup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin' },
body: JSON.stringify({
historyKey: ['党政要员'],
historyKey: [],
historyTitle: '',
keyword: searchText,
language: filters.languages,
language: languageList.current,
page: 1,
limit: 12,
platform: filters.platforms,
politicalStand: filters.direction,
sort: filters.sortBy,
asc: filters.sortDirection,
platform: platformList.current,
politicalStand: directionValue.current,
sort: sortTypeValue.current,
asc: sortValue.current,
typeKeyword: 9,
}),
});
if (!response.ok) return;
const data = await response.json();
const result: UserDataItem[] = Array.isArray(data?.data?.data) ? data.data.data : [];
result.forEach((item) => {
item.hobbies = item.groupDiscussTags || [];
item.profile_image = avatarUrl(item.profile_image);
item.classification = `${item.political_stand ?? ''}`;
item.title = item.group_name || item.groupName;
item.content = item.hx_info?.['用户画像']?.['账号描述'] || item.agentInfo?.['账号描述'] || '';
item.url = '';
});
setUserList(result);
setActorCount(data?.data?.total ?? 0);
if (response.ok) {
const data = await response.json();
setIsSearching(false);
const result: UserDataItem[] = data.data.data;
result.forEach((item) => {
item.hobbies = item.groupDiscussTags || [];
item.profile_image = item.profile_image
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
: 'http://ty.b.uat.4.cn/manage/img/default.png';
item.classification = `${item.political_stand}`;
item.title = item.group_name || item.groupName;
if (item.hx_info?.['用户画像']) {
item.content = item.hx_info['用户画像']['账号描述'];
}
item.url = '';
});
setUserList(result);
setActorCount(data.data.total);
}
} catch (error) {
console.error('Search error:', error);
} finally {
setIsSearching(false);
}
};
@@ -524,128 +411,122 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
}, []);
return (
<div className="network-actor">
<div className="network-actor__filter-surface bg-white rounded-[5px] p-[10px]">
<div className="network-actor__filter-panel border border-[#e8e8e8] rounded-[5px] p-[10px]">
<div>
<div className="bg-white rounded-[5px] p-[10px]">
<div className="border border-[#e8e8e8] rounded-[5px] p-[10px]">
<div className="mb-[10px] flex">
<span className="network-actor__filter-label text-[#3D3D3D]">所属平台:</span>
<span className="text-[#3D3D3D]">所属平台:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<button
type="button"
aria-pressed={allPlatforms}
className={`network-actor__filter-chip flex cursor-pointer items-center rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${allPlatforms ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`}
<span
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allPlatforms ? 'platform-active' : ''}`}
onClick={() => void allPlatformChange()}
>全部</button>
>全部</span>
{platforms.map((item, index) => (
<button
type="button"
<span
key={index}
aria-pressed={item.checked}
className={`network-actor__filter-chip flex cursor-pointer items-center rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${item.checked ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`}
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
onClick={() => void handleTogglePlatform(index)}
>
<PlatformIcon platform={item.title} className="h-5 w-5" />
<span className="network-actor__platform-divider mx-2 h-3 w-px bg-[#dbdbdb]" aria-hidden="true"></span>
<span>{item.title}</span>
</button>
<img src={`/platform/${item.icon}`} className="w-[24px]" alt="" />
<span className="w-[2px] h-[12px] ml-[5px] mr-[5px] bg-[#dbdbdb]"></span>
<span className="ml-[5px]">{item.title}</span>
</span>
))}
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="network-actor__filter-label text-[#3D3D3D]">常用语言:</span>
<span className="text-[#3D3D3D]">常用语言:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<button
type="button"
aria-pressed={allLanguage}
className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${allLanguage ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`}
<span
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allLanguage ? 'platform-active' : ''}`}
onClick={() => void allLanguageChange()}
>全部</button>
>全部</span>
{languages.map((item, index) => (
<button
type="button"
<span
key={index}
aria-pressed={item.checked}
className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${item.checked ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`}
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
onClick={() => void handleToggleLanguage(index)}
>
<span>{item.title}</span>
</button>
</span>
))}
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="network-actor__filter-label text-[#3D3D3D]">方向:</span>
<span className="text-[#3D3D3D]">方向:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<button type="button" aria-pressed={directionType === 'TW'} className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${directionType === 'TW' ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`} onClick={() => void setDirectionChange('TW')}>W方向</button>
<button type="button" aria-pressed={directionType === 'US'} className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${directionType === 'US' ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`} onClick={() => void setDirectionChange('US')}>U方向</button>
<button type="button" aria-pressed={directionType === '其他'} className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${directionType === '其他' ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`} onClick={() => void setDirectionChange('其他')}>其他</button>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === 'TW' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('TW')}>W方向</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === 'US' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('US')}>U方向</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === '其他' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('其他')}>其他</span>
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="network-actor__filter-label text-[#3D3D3D]">类型:</span>
<span className="text-[#3D3D3D]">类型:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<button type="button" aria-pressed={searchType === 'network'} className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${searchType === 'network' ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`} onClick={() => void searchTypeChange('network')}>网络XWT</button>
<button type="button" aria-pressed={searchType === 'group'} className={`network-actor__filter-chip cursor-pointer rounded-none border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${searchType === 'group' ? 'border-[#cf1322] bg-[#fff5f5] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:bg-slate-50'}`} onClick={() => void searchTypeChange('group')}>群组XWT</button>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${searchType === 'network' ? 'platform-active' : ''}`} onClick={() => void searchTypeChange('network')}>网络XWT</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${searchType === 'group' ? 'platform-active' : ''}`} onClick={() => void searchTypeChange('group')}>群组XWT</span>
</span>
</span>
</div>
</div>
</div>
<div className="network-actor__results-surface bg-white rounded-[5px] pt-[15px] mt-[10px]">
<div className="network-actor__results-header flex items-center justify-between pb-[10px] mb-[10px] border-b border-b-[#f1f3f5]">
<div className="bg-white rounded-[5px] pt-[15px] mt-[10px]">
<div className="flex items-center justify-between pb-[10px] mb-[10px] border-b border-b-[#f1f3f5]">
<div className="flex items-center ml-[10px]">
<div className="network-actor__title-accent mr-2 h-5 w-1 rounded-full bg-[#cf1322]"></div>
<div className="network-actor__section-title text-[#454859]">用户表格管理</div>
<div className="title-bar mr-[8px]"></div>
<div className="text-[#454859]">用户表格管理</div>
</div>
<div className="flex items-center">
<button
type="button"
aria-label={`按粉丝数排序,当前${fansSort === 'asc' ? '升序' : '降序'}`}
className={`network-actor__sort-button ${sortType === 'fans_count' ? 'network-actor__sort-button--active' : ''} relative flex cursor-pointer items-center rounded-md border px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${sortType === 'fans_count' ? 'border-[#cf1322] bg-[#fffafa] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:border-slate-300 hover:bg-slate-50'}`}
<div
className={`relative flex items-center cursor-pointer border ${sortType === 'fans_count' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
onClick={() => void sortTypeChange('fans_count')}
>
<span className="leading-[16px]">粉丝数排序</span>
<span className="ml-2"><SortDirectionIcon direction={fansSort as "asc" | "desc"} active={sortType === 'fans_count'} /></span>
</button>
<button
type="button"
aria-label={`按相关度排序,当前${relevanceSort === 'asc' ? '升序' : '降序'}`}
className={`network-actor__sort-button ${sortType === '_score' ? 'network-actor__sort-button--active' : ''} relative ml-2 flex cursor-pointer items-center rounded-md border px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cf1322]/40 ${sortType === '_score' ? 'border-[#cf1322] bg-[#fffafa] text-[#cf1322]' : 'border-[#e5e7eb] text-[#3d3d3d] hover:border-slate-300 hover:bg-slate-50'}`}
<div className="ml-[10px]">
<div className={`sort-up ${fansSort === 'asc' ? 'sort-up-active' : ''}`}></div>
<div className={`mt-[4px] sort-down ${fansSort === 'desc' ? 'sort-down-active' : ''}`}></div>
</div>
</div>
<div
className={`relative flex items-center ml-[10px] cursor-pointer border ${sortType === '_score' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
onClick={() => void sortTypeChange('_score')}
>
<span className="leading-[16px]">相关度排序</span>
<span className="ml-2"><SortDirectionIcon direction={relevanceSort as "asc" | "desc"} active={sortType === '_score'} /></span>
</button>
<div className="ml-[10px]">
<div className={`sort-up ${relevanceSort === 'asc' ? 'sort-up-active' : ''}`}></div>
<div className={`mt-[4px] sort-down ${relevanceSort === 'desc' ? 'sort-down-active' : ''}`}></div>
</div>
</div>
{searchType === 'network' && (
<>
<div className="network-actor__metric flex items-center ml-[35px]">
<MetaIcon type="network" className="h-6 w-6 text-[#d97b00]" />
<div className="flex items-center ml-[35px]">
<img className="w-[24px] h-[24px]" src="/icon/group-yellow-logo.svg" alt="" />
<div className="ml-[5px]">
<div className="network-actor__metric-label text-[#454859] leading-[16px]">当前选中网络XWT数</div>
<div className="network-actor__metric-value network-actor__metric-value--selected text-[#d97b00] leading-[16px] mt-[5px]">{selNetCount}</div>
<div className="text-[#454859] leading-[16px]">当前选中网络XWT数</div>
<div className="text-[#d97b00] leading-[16px] mt-[5px]">{selNetCount}</div>
</div>
</div>
<div className="network-actor__metric-divider ml-[5px] mr-[5px] h-[30px] w-[2px] -skew-x-[30deg] bg-[#dbdbdb]"></div>
<div className="ml-[5px] mr-[5px] h-[30px] w-[2px] -skew-x-[30deg] bg-[#dbdbdb]"></div>
</>
)}
<div className={`network-actor__metric flex items-center ${searchType === 'network' ? 'ml-[5px]' : 'ml-[20px]'} mr-[25px]`}>
<MetaIcon type="network" className="h-6 w-6 text-[#cf1322]" />
<div className={`flex items-center ${searchType === 'network' ? 'ml-[5px]' : 'ml-[20px]'} mr-[25px]`}>
<img className="w-[24px] h-[24px]" src="/icon/group-red-logo.svg" alt="" />
<div className="ml-[5px]">
<div className="network-actor__metric-label text-[#454859] leading-[16px]">网络XWT总数</div>
<div className="network-actor__metric-value network-actor__metric-value--total text-[#cf1322] leading-[16px] mt-[5px]">{actorCount}</div>
<div className="text-[#454859] leading-[16px]">网络XWT总数</div>
<div className="text-[#cf1322] leading-[16px] mt-[5px]">{actorCount}</div>
</div>
</div>
</div>
</div>
{selectedSearchSources.length > 0 && (
<div className="network-actor__selection-bar flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
<span className="text-sm text-gray-600">已选中 {selectedSearchSources.length} 项</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={handleBatchDelete} className="h-8 px-3 text-sm">
@@ -670,13 +551,9 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : null}
<div className={`grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4${isSearching ? ' hidden' : ''}`}>
<div className={`grid grid-cols-4 gap-[10px] user-list${isSearching ? ' hidden' : ''}`}>
{userList.map((item, index) => (
<div
key={index}
data-selected={item.checked ?? false}
className={`network-actor__card relative rounded-lg border bg-white p-4 shadow-sm transition-[border-color,box-shadow] duration-200 motion-reduce:transition-none hover:border-[#f1b5ba] hover:shadow-md ${item.checked ? 'border-[#cf1322] ring-1 ring-[#cf1322]/15' : 'border-[#e5e7eb]'}`}
>
<div key={index} className="user-item p-[10px] relative">
<div className="absolute top-[5px] right-[10px]">
<input
type="checkbox"
@@ -686,21 +563,21 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
/>
</div>
<div className="flex items-center mb-[10px] cursor-pointer" onClick={() => linkToB2(item)}>
<img src={item.profile_image ?? ''} className="network-actor__avatar h-[97px] w-[97px] rounded-md border border-[#e5e7eb] bg-slate-50 object-cover" alt="" />
<img src={item.profile_image ?? ''} className="w-[97px] h-[97px] rounded-[5px]" alt="" />
<div className="flex-1 min-w-0 pl-[10px]">
<div className="truncate mb-[10px] text-[18px]">{item.user_name || item.screen_name || item.user_id || item.group_name || item.groupName}</div>
<div className="flex justify-between mb-[10px]">
<div className="flex items-center">
<MetaIcon type="followers" className="mr-[5px] text-[#d97b00]" />
<img src="/icon/group-yellow-logo.svg" className="w-[16px] h-[16px] mr-[5px]" alt="" />
<span>粉丝数:{item.fans_count || '-'}</span>
</div>
<div className="flex items-center">
<PlatformIcon platform={item.platform} className="mr-[5px]" />
<img src={`/platform/${item.platform}.png`} className="w-[25px] mr-[5px]" alt="" />
<span>{item.platform}</span>
</div>
</div>
<div className="flex items-center">
<MetaIcon type="person" className="mr-[5px] text-[#cf1322]" />
<img src="/icon/person-red-logo.svg" className="w-[16px] mr-[5px]" alt="" />
<span>实体人:{item.name}</span>
</div>
</div>
@@ -708,29 +585,29 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
<div className="cursor-pointer" onClick={() => linkToB2(item)}>
<div className="flex items-center mb-[10px]">
<div className="w-[70px] flex items-center">
<MetaIcon type="category" className="mr-[5px] text-[#cf1322]" />
<img src="/icon/classification-logo.png" className="w-[16px] mr-[5px]" alt="" />
<span>分类:</span>
</div>
<div className="flex-1 min-w-0">
<div className="network-actor__classification inline-block h-[28px] pl-[8px] pr-[8px] bg-[#fef6f6] text-[#cf1322]">{item.classification}</div>
<div className="inline-block h-[28px] pl-[8px] pr-[8px] bg-[#fef6f6] text-[#cf1322]">{item.classification}</div>
</div>
</div>
<div className="flex items-center mb-[10px]">
<div className="w-[70px] flex items-center">
<MetaIcon type="tags" className="mr-[5px] text-[#1eb07f]" />
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
<span>标签:</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-[5px]">
{(item.hobbies ?? []).map((hobbie) => (
<div key={hobbie} className="network-actor__tag h-[28px] pl-[8px] pr-[8px] bg-[#f6fdfb] text-[#1eb07f]">{hobbie}</div>
<div key={hobbie} className="h-[28px] pl-[8px] pr-[8px] bg-[#f6fdfb] text-[#1eb07f]">{hobbie}</div>
))}
</div>
</div>
</div>
<div className="flex items-baseline mb-[10px]">
<div className="w-[70px] flex items-center">
<MetaIcon type="summary" className="mr-[5px] text-slate-500" />
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
<span>简介:</span>
</div>
<div className="flex-1 min-w-0">
@@ -38,7 +38,6 @@ export function HtmlPreviewFrame({
return (
<iframe
key={url ?? "html-preview"}
title="HTML 预览"
sandbox="allow-scripts allow-forms"
src={url}
@@ -1,16 +0,0 @@
/** Lightweight, dependency-free fallback used while a route chunk loads. */
export function RouteLoadingFallback() {
return (
<div
className="text-muted-foreground flex size-full min-h-32 items-center justify-center gap-2 bg-background"
role="status"
aria-live="polite"
>
<span
className="border-muted-foreground/30 border-t-foreground size-4 animate-spin rounded-full border-2"
aria-hidden="true"
/>
<span className="text-sm">正在加载页面…</span>
</div>
);
}
@@ -4,7 +4,7 @@ import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { languages } from "@codemirror/language-data";
import { basicLightInit } from "@uiw/codemirror-theme-basic";
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
import CodeMirror from "@uiw/react-codemirror";
import { useTheme } from "next-themes";
import { useMemo } from "react";
@@ -29,13 +29,13 @@ export function MarkdownCodeEditor({
}) {
const { resolvedTheme } = useTheme();
const extensions = useMemo(
() => [markdown({ base: markdownLanguage, codeLanguages: languages }), EditorView.lineWrapping],
() => [markdown({ base: markdownLanguage, codeLanguages: languages })],
[],
);
return (
<div className={cn("grid min-w-0 min-h-[46vh] overflow-hidden rounded-lg border bg-background lg:grid-cols-2", className)}>
<section className="flex min-w-0 min-h-0 flex-col border-b lg:border-r lg:border-b-0">
<div className={cn("grid min-h-[46vh] overflow-hidden rounded-lg border bg-background lg:grid-cols-2", className)}>
<section className="flex min-h-0 flex-col border-b lg:border-r lg:border-b-0">
<div className="flex h-9 shrink-0 items-center border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground">
Markdown 编辑
</div>
@@ -50,14 +50,14 @@ export function MarkdownCodeEditor({
highlightActiveLineGutter: false,
lineNumbers: true,
}}
className="min-w-0 min-h-[42vh] flex-1 overflow-auto font-mono text-sm [&_.cm-editor]:min-h-[42vh] [&_.cm-focused]:outline-none!"
className="min-h-[42vh] flex-1 overflow-auto font-mono text-sm [&_.cm-editor]:min-h-[42vh] [&_.cm-focused]:outline-none!"
/>
</section>
<section className="flex min-w-0 min-h-0 flex-col bg-muted/10">
<section className="flex min-h-0 flex-col bg-muted/10">
<div className="flex h-9 shrink-0 items-center border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground">
实时渲染
</div>
<div className="min-w-0 min-h-[42vh] flex-1 overflow-auto p-4">
<div className="min-h-[42vh] flex-1 overflow-auto p-4">
<MarkdownContent
content={value || "*暂无正文*"}
isLoading={false}
@@ -83,7 +83,7 @@ function UserInfoPanel({ user }: { user: UserListItem }) {
<Badge variant="secondary">普通用户</Badge>
)}
</dd>
<dt className="text-muted-foreground font-medium">近30天问答次数</dt>
<dt className="text-muted-foreground font-medium">问答次数</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="按近 30 天问答次数从高到低排序"
title="按问答次数从高到低排序"
>
<ArrowDownWideNarrow className="size-3.5" />
{sortByQa ? "按近30天问答排序中" : "按近30天问答排序"}
{sortByQa ? "按问答次数排序中" : "按问答次数排序"}
</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="近 30 天问答次数"
title="问答次数"
>
{u.qa_count ?? 0}
</span>
@@ -27,7 +27,6 @@ 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";
@@ -69,7 +68,6 @@ 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("");
@@ -79,8 +77,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(() => getAdminQueryDateTimeBounds().min);
const [until, setUntil] = useState(() => getAdminQueryDateTimeBounds().max);
const [since, setSince] = useState("");
const [until, setUntil] = useState("");
const [exporting, setExporting] = useState(false);
const [detailRow, setDetailRow] = useState<ToolMetric | null>(null);
/** 成功 toast:仅暗色模式,浅色走 Sonner 默认 */
@@ -155,15 +153,14 @@ const errorToastDarkOptions = {
};
const resetFilters = () => {
const nextBounds = getAdminQueryDateTimeBounds();
setTool("");
setUser("");
setSkill("");
setKind("skill");
setStatus("all");
setCategory("all");
setSince(nextBounds.min);
setUntil(nextBounds.max);
setSince("");
setUntil("");
setPage(0);
};
@@ -183,7 +180,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}>
@@ -394,10 +391,8 @@ const errorToastDarkOptions = {
<Input
type="datetime-local"
value={since}
min={queryDateTimeBounds.min}
max={until || queryDateTimeBounds.max}
onChange={(e) => {
setSince(e.target.value || queryDateTimeBounds.min);
setSince(e.target.value);
setPage(0);
}}
/>
@@ -407,10 +402,8 @@ const errorToastDarkOptions = {
<Input
type="datetime-local"
value={until}
min={since || queryDateTimeBounds.min}
max={queryDateTimeBounds.max}
onChange={(e) => {
setUntil(e.target.value || queryDateTimeBounds.max);
setUntil(e.target.value);
setPage(0);
}}
/>
@@ -4,7 +4,6 @@ import {
BotIcon,
ClockIcon,
CopyIcon,
EyeIcon,
EyeOffIcon,
GlobeIcon,
LockIcon,
@@ -60,7 +59,6 @@ import { Textarea } from "@/components/ui/textarea";
import { usePublishSquares } from "@/core/system-settings/hooks";
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
// import { AgentChatLinkFields } from "@/components/workspace/agents/agent-chat-link-fields";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
@@ -77,15 +75,7 @@ import {
useUpdateAgent,
} from "@/core/agents";
import { getAgent } from "@/core/agents/api";
import type { Agent, AgentChatLink, AgentQuestion, UpdateAgentRequest } from "@/core/agents";
import {
agentChatLinksEqual,
canonicalAgentChatLink,
isAllowedAgentChatUrl,
openResolvedAgentChatUrl,
} from "@/core/agents/chat-link";
import { emptyAgentChatLink } from "@/core/agents/types";
import { appendResolvedRouteParams } from "@/strategy-components/lib/light-app";
import type { Agent, AgentQuestion, UpdateAgentRequest } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { useI18n } from "@/core/i18n/hooks";
@@ -102,11 +92,6 @@ 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 {
@@ -218,13 +203,7 @@ function ClampedBadgeRow({ items }: { items: BadgeItem[] }) {
);
}
export function AgentCard({
agent,
layout = "card",
onPublisherClick,
onToggleHidden,
toggleHiddenPending = false,
}: AgentCardProps) {
export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCardProps) {
const isList = layout === "list";
const { t } = useI18n();
const router = useRouter();
@@ -235,11 +214,9 @@ export function AgentCard({
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 ?? "");
@@ -257,12 +234,6 @@ export function AgentCard({
const [editSeatSkillDirective, setEditSeatSkillDirective] = useState(
agent.seat_skill_directive ?? false,
);
const [editModelPromptSuffixEnabled, setEditModelPromptSuffixEnabled] = useState(
agent.model_prompt_suffix_enabled ?? true,
);
const [editChatLink, setEditChatLink] = useState<AgentChatLink>(
() => agent.chat_link ?? emptyAgentChatLink(),
);
const [editSquareId, setEditSquareId] = useState(agent.square_id ?? "");
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
const { data: publishSquares } = usePublishSquares();
@@ -311,8 +282,6 @@ export function AgentCard({
setSelectedSkills(agent.skills ?? []);
setEditCommonQuestions(normalizeAgentQuestions(agent.common_questions));
setEditSeatSkillDirective(agent.seat_skill_directive ?? false);
setEditModelPromptSuffixEnabled(agent.model_prompt_suffix_enabled ?? true);
setEditChatLink(agent.chat_link ?? emptyAgentChatLink());
}, [
agent.common_questions,
agent.description,
@@ -322,8 +291,6 @@ export function AgentCard({
agent.square_id,
agent.skills,
agent.seat_skill_directive,
agent.model_prompt_suffix_enabled,
agent.chat_link,
editOpen,
]);
@@ -339,28 +306,12 @@ export function AgentCard({
setEditCommonQuestions(normalizeAgentQuestions(agentDetail.common_questions));
// 列表响应不带 config 额外字段(seat_skill_directive 为 null),详情才有真值。
setEditSeatSkillDirective(agentDetail.seat_skill_directive ?? false);
setEditModelPromptSuffixEnabled(agentDetail.model_prompt_suffix_enabled ?? true);
setEditChatLink(agentDetail.chat_link ?? emptyAgentChatLink());
} else {
setEditSoul(agent.soul ?? "");
}
}, [agent.soul, agentDetail, editOpen]);
async function handleChat() {
let link = agent.chat_link;
if (link === undefined) {
try {
link = (await getAgent(agent.id)).chat_link ?? null;
} catch {
link = null;
}
}
const target = canonicalAgentChatLink(link);
if (target && isAllowedAgentChatUrl(target.url)) {
const href = appendResolvedRouteParams(target.url, target.route_params);
openResolvedAgentChatUrl(href, target.open_mode, (path) => router.push(path));
return;
}
function handleChat() {
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
}
@@ -383,11 +334,9 @@ export function AgentCard({
// row to carry the source agent's personality into the copy. Fall back
// to whatever the list happened to include if the detail fetch fails.
let soul = agent.soul ?? "";
let chatLink = canonicalAgentChatLink(agent.chat_link);
try {
const detail = await getAgent(agent.id);
soul = detail.soul ?? soul;
chatLink = canonicalAgentChatLink(detail.chat_link);
} catch {
// keep the list fallback
}
@@ -398,7 +347,6 @@ export function AgentCard({
skills: agent.skills ?? [],
model: agent.model ?? null,
tool_groups: agent.tool_groups ?? null,
chat_link: chatLink,
// A copy starts as a private, unpublished draft the owner can refine
// before sharing — never inherit the source's published/private state.
published: false,
@@ -451,24 +399,6 @@ export function AgentCard({
request.common_questions = nextQuestions;
if (editSeatSkillDirective !== ((agentDetail?.seat_skill_directive ?? agent.seat_skill_directive) ?? false))
request.seat_skill_directive = editSeatSkillDirective;
if (editModelPromptSuffixEnabled !== ((agentDetail?.model_prompt_suffix_enabled ?? agent.model_prompt_suffix_enabled) ?? true))
request.model_prompt_suffix_enabled = editModelPromptSuffixEnabled;
const nextChatLink = canonicalAgentChatLink(editChatLink);
if (nextChatLink && !isAllowedAgentChatUrl(nextChatLink.url)) {
toast.error("对话链接只支持 http(s) 地址,或以 /、#/ 开头的站内路径");
return;
}
if (
editChatLink.route_params.some((param) => !param.key.trim()) &&
nextChatLink
) {
toast.error("路由参数键名不可为空");
return;
}
const baselineChatLink = agentDetail?.chat_link ?? agent.chat_link;
if (!agentChatLinksEqual(nextChatLink, baselineChatLink)) {
request.chat_link = nextChatLink;
}
if (Object.keys(request).length === 0) {
setEditOpen(false);
@@ -660,16 +590,6 @@ export function AgentCard({
私密
</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"
@@ -726,6 +646,15 @@ export function AgentCard({
</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}`,
@@ -748,7 +677,7 @@ export function AgentCard({
setDetailSkillName(skill);
}}
>
{agent.skill_labels?.[skill]?.trim() || desensitize(skill)}
{skillNameZhMap.get(skill) ?? desensitize(skill)}
</Badge>
),
})),
@@ -854,31 +783,6 @@ export function AgentCard({
<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"
@@ -918,7 +822,7 @@ export function AgentCard({
<Button
size="sm"
className={cn(isList ? "shrink-0" : "w-full")}
onClick={() => void handleChat()}
onClick={handleChat}
>
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
@@ -927,10 +831,7 @@ export function AgentCard({
const cardLayout = (
<Card
className={cn(
"group flex min-w-0 flex-col transition-shadow hover:shadow-md",
agent.hidden && "opacity-60",
)}
className="group flex min-w-0 flex-col transition-shadow hover:shadow-md"
data-agent-guide="agent-card"
>
<CardHeader className="min-w-0 pb-2">
@@ -991,10 +892,7 @@ export function AgentCard({
const listLayout = (
<div
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",
)}
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]"
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">
@@ -1062,9 +960,9 @@ export function AgentCard({
已选 {selectedSkills.length} 个
</span>
</div>
<SkillTransferSelector
skills={skills}
selected={selectedSkills}
<SkillTransferSelector
skills={skills}
selected={selectedSkills}
onChange={setSelectedSkills}
isLoading={isSkillsLoading}
onViewDetail={setDetailSkillName}
@@ -1096,19 +994,6 @@ export function AgentCard({
onChange={setEditCommonQuestions}
/>
</div>
<div className="flex items-center justify-between gap-3 rounded-md border p-3">
<div>
<p className="text-sm font-medium">使用模型追加提示词</p>
<p className="text-muted-foreground text-xs">
默认开启。关闭后,该智能体的问答不会附加管理员为所选模型设置的全局提示词。
</p>
</div>
<Switch
checked={editModelPromptSuffixEnabled}
onCheckedChange={setEditModelPromptSuffixEnabled}
aria-label="使用模型追加提示词"
/>
</div>
<div className="flex items-center justify-between gap-3 rounded-md border p-3">
<div>
<p className="text-sm font-medium">会商技能识别强化</p>
@@ -1189,7 +1074,6 @@ export function AgentCard({
}}
/>
</div>
{/* <AgentChatLinkFields value={editChatLink} onChange={setEditChatLink} /> */}
</div>
<DialogFooter>
<Button
@@ -1,224 +0,0 @@
"use client";
import { PlusIcon, XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { AgentChatLink, AgentChatRouteParam } from "@/core/agents/types";
import { emptyAgentChatLink } from "@/core/agents/types";
import {
APP_THEME_COLORS,
LOGIN_SOURCE_OPTIONS,
parseThemeMap,
stringifyThemeMap,
} from "@/strategy-components/lib/light-app";
function newParam(): AgentChatRouteParam {
return {
key: "",
paramType: "login",
source: "access_token",
customValue: "",
themeValue: null,
};
}
export function AgentChatLinkFields({
value,
onChange,
}: {
value: AgentChatLink;
onChange: (next: AgentChatLink) => void;
}) {
const link = value ?? emptyAgentChatLink();
function patch(partial: Partial<AgentChatLink>) {
onChange({ ...link, ...partial });
}
function patchParam(index: number, partial: Partial<AgentChatRouteParam>) {
patch({
route_params: link.route_params.map((param, i) =>
i === index ? { ...param, ...partial } : param,
),
});
}
return (
<div className="space-y-3 rounded-md border p-3">
<div>
<p className="text-sm font-medium">对话跳转</p>
<p className="text-muted-foreground text-xs">
配置后,智能体管理里的「对话」按钮会打开这个地址。留空则仍进入本系统问答。
</p>
</div>
<Input
value={link.url}
onChange={(event) => patch({ url: event.currentTarget.value })}
placeholder="https://... ,或以 / 开头的站内路径"
/>
<div className="space-y-1">
<p className="text-muted-foreground text-xs">打开方式</p>
<Select
value={link.open_mode}
onValueChange={(next) => patch({ open_mode: next === "self" ? "self" : "blank" })}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="blank">新窗口</SelectItem>
<SelectItem value="self">当前窗口</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-medium">路由参数</p>
<p className="text-muted-foreground text-xs">
点击「对话」时拼到地址后面。登录参数取当前登录信息,主题参数跟随当前主题。
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => patch({ route_params: [...link.route_params, newParam()] })}
>
<PlusIcon className="h-3.5 w-3.5" />
添加
</Button>
</div>
{link.route_params.length === 0 ? (
<p className="text-muted-foreground rounded-md border border-dashed px-3 py-3 text-center text-xs">
暂无路由参数
</p>
) : (
<div className="space-y-2">
{link.route_params.map((param, index) => {
const themeMap = param.paramType === "theme" ? parseThemeMap(param.themeValue) : {};
return (
<div key={index} className="space-y-2 rounded-md border p-2">
<div className="flex items-center gap-2">
<Input
value={param.key}
onChange={(event) => patchParam(index, { key: event.currentTarget.value })}
placeholder="参数名"
className="h-8 font-mono text-xs"
/>
<Select
value={param.paramType}
onValueChange={(next) =>
patchParam(index, {
paramType: next === "theme" ? "theme" : "login",
source: next === "login" ? param.source || "access_token" : null,
themeValue:
next === "theme"
? param.themeValue || stringifyThemeMap({})
: null,
})
}
>
<SelectTrigger className="h-8 w-[120px] shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="login">登录参数</SelectItem>
<SelectItem value="theme">主题参数</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() =>
patch({
route_params: link.route_params.filter((_, i) => i !== index),
})
}
title="移除参数"
>
<XIcon className="h-4 w-4" />
</Button>
</div>
{param.paramType === "login" && (
<div className="flex items-center gap-2">
<Select
value={param.source || "access_token"}
onValueChange={(next) => patchParam(index, { source: next })}
>
<SelectTrigger className="h-8 min-w-0 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOGIN_SOURCE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{param.source === "other" && (
<Input
value={param.customValue ?? ""}
onChange={(event) =>
patchParam(index, { customValue: event.currentTarget.value })
}
placeholder="自定义值"
className="h-8 w-[140px] text-xs"
/>
)}
</div>
)}
{param.paramType === "theme" && (
<div className="space-y-1.5">
<p className="text-muted-foreground text-[11px]">
各系统主题色跳转时传给目标地址的值
</p>
{APP_THEME_COLORS.map((color) => (
<div key={color.key} className="flex items-center gap-2">
<span className="flex w-[72px] shrink-0 items-center gap-1.5 text-xs">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: color.dot }}
/>
{color.label}
</span>
<Input
value={themeMap[color.key] ?? color.key}
onChange={(event) =>
patchParam(index, {
themeValue: stringifyThemeMap({
...themeMap,
[color.key]: event.currentTarget.value,
}),
})
}
className="h-7 font-mono text-xs"
/>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -7,7 +7,6 @@ import {
DatabaseIcon,
GlobeIcon,
InfoIcon,
// Link2Icon,
Loader2Icon,
LockIcon,
MessageCircleQuestionIcon,
@@ -26,7 +25,6 @@ import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { toast } from "sonner";
// import { AgentChatLinkFields } from "@/components/workspace/agents/agent-chat-link-fields";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -61,16 +59,9 @@ import {
useSetAgentFeatured,
useUpdateAgent,
type Agent,
type AgentChatLink,
type AgentQuestion,
type UpdateAgentRequest,
} from "@/core/agents";
import {
agentChatLinksEqual,
canonicalAgentChatLink,
isAllowedAgentChatUrl,
} from "@/core/agents/chat-link";
import { emptyAgentChatLink } from "@/core/agents/types";
import { getStoredAuth } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { isAgentSkillCandidate } from "@/core/skills";
@@ -90,11 +81,9 @@ interface AgentDraft {
published: boolean;
featured: boolean;
templateName: string;
modelPromptSuffixEnabled: boolean;
/** 会商席位「技能识别强化」开关。 */
seatSkillDirective: boolean;
knowledgeBaseIds: string[];
chatLink: AgentChatLink;
}
function draftFromAgent(agent: Agent): AgentDraft {
@@ -107,10 +96,8 @@ function draftFromAgent(agent: Agent): AgentDraft {
published: agent.published,
featured: agent.featured_order != null,
templateName: agent.template_name ?? "",
modelPromptSuffixEnabled: agent.model_prompt_suffix_enabled ?? true,
seatSkillDirective: agent.seat_skill_directive ?? false,
knowledgeBaseIds: agent.llmwiki_knowledge_base_ids ?? [],
chatLink: agent.chat_link ?? emptyAgentChatLink(),
};
}
@@ -380,22 +367,8 @@ export function AgentDetailPanel({
if (draft.soul !== (agent.soul ?? "")) request.soul = draft.soul;
if (draft.templateName.trim() !== (agent.template_name ?? ""))
request.template_name = draft.templateName.trim();
if (draft.modelPromptSuffixEnabled !== (agent.model_prompt_suffix_enabled ?? true))
request.model_prompt_suffix_enabled = draft.modelPromptSuffixEnabled;
if (draft.seatSkillDirective !== (agent.seat_skill_directive ?? false))
request.seat_skill_directive = draft.seatSkillDirective;
const nextChatLink = canonicalAgentChatLink(draft.chatLink);
if (nextChatLink && !isAllowedAgentChatUrl(nextChatLink.url)) {
toast.error("对话链接只支持 http(s) 地址,或以 /、#/ 开头的站内路径");
return;
}
if (draft.chatLink.route_params.some((param) => !param.key.trim()) && nextChatLink) {
toast.error("路由参数键名不可为空");
return;
}
if (!agentChatLinksEqual(nextChatLink, agent.chat_link)) {
request.chat_link = nextChatLink;
}
if (
JSON.stringify(draft.knowledgeBaseIds) !==
JSON.stringify(agent.llmwiki_knowledge_base_ids ?? [])
@@ -690,13 +663,6 @@ export function AgentDetailPanel({
</div>
{/* 入库模板名 — view mode shows a hint row when configured. */}
{/* {!editing && agent.chat_link?.url?.trim() && (
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-xs">
<Link2Icon className="size-3 shrink-0" />
对话跳转:{agent.chat_link.open_mode === "self" ? "当前窗口" : "新窗口"}
</div>
)} */}
{!editing && agent.template_name?.trim() && (
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-xs">
<UploadCloudIcon className="size-3 shrink-0" />
@@ -736,21 +702,6 @@ export function AgentDetailPanel({
}
variant="panel"
/>
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
<div className="pr-3">
<p className="text-sm font-medium">使用模型追加提示词</p>
<p className="text-muted-foreground text-xs">
默认开启。关闭后,该智能体的问答不会附加管理员为所选模型设置的全局提示词。
</p>
</div>
<Switch
checked={draft.modelPromptSuffixEnabled}
onCheckedChange={(checked) =>
setDraft({ ...draft, modelPromptSuffixEnabled: checked })
}
aria-label="使用模型追加提示词"
/>
</div>
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
<div className="pr-3">
<p className="text-sm font-medium">会商技能识别强化</p>
@@ -997,13 +948,6 @@ export function AgentDetailPanel({
/>
</Section>
{/* {editing && draft && (
<AgentChatLinkFields
value={draft.chatLink}
onChange={(chatLink) => setDraft({ ...draft, chatLink })}
/>
)} */}
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
{belowSkillsSlot}
@@ -1,6 +1,6 @@
"use client";
import { ArrowDownUpIcon, BotIcon, Compass, Loader2, LockIcon, PlusIcon, RefreshCwIcon, ShieldIcon, XIcon } from "lucide-react";
import { ArrowDownUpIcon, BotIcon, Compass, Loader2, LockIcon, PlusIcon, 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, useSyncAgentDirectory } from "@/core/agents";
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useRouteBase } from "@/core/route-base";
@@ -54,7 +54,6 @@ 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("");
@@ -461,33 +460,6 @@ 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({ enabled: open });
const { agents: allAgents } = useAgents({ enabled: open });
const { agents, isLoading } = useAgentSelector();
const { agents: allAgents } = useAgents();
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 { useAgent, useAgentSelector } from "@/core/agents";
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { desensitize } from "@/lib/desensitize";
@@ -59,19 +59,22 @@ export function AgentSelector({
}: AgentSelectorProps) {
const [open, setOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false);
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 { 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 isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
const selectedAgent = useMemo<Agent | null>(() => {
if (!selectedAgentId) return null;
return agents.find((a) => a.id === selectedAgentId) ?? selectedAgentDetail;
}, [agents, selectedAgentDetail, selectedAgentId]);
return (
[...agents, ...mineAgents, ...squareAgents].find(
(a) => a.id === selectedAgentId,
) ?? null
);
}, [agents, mineAgents, squareAgents, selectedAgentId]);
const handleSelect = (agent: Agent) => {
onAgentChange(agent.id);
@@ -362,27 +362,11 @@ export function ArtifactFileDetail({
isMock,
writeFileInFlight,
]);
const isVirtualHtmlArtifact = useMemo(() => {
if (!isWriteFileVirtual) return false;
return (writeFileLanguageHint ?? checkCodeFile(filepath).language) === "html";
}, [filepath, isWriteFileVirtual, writeFileLanguageHint]);
/** write_file 流式阶段走 tool args 文本;图片等二进制产物始终走 artifacts API;
* HTML 写完后也必须切回真实文件,因为后续渲染脚本可能在同一路径覆盖最终数据;
* 圆桌结束后可编辑的 md(editVirtualMarkdown)同样回到真实 API(可加载 + PATCH 保存)。 */
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API;
* 圆桌结束后可编辑的 md(editVirtualMarkdown)亦回到真实 API(可加载 + PATCH 保存)。 */
const useWriteFileStreaming = useMemo(() => {
return (
isWriteFileVirtual &&
!isImageFile(filepath) &&
!editVirtualMarkdown &&
(!isVirtualHtmlArtifact || writeFileInFlight)
);
}, [
filepath,
isWriteFileVirtual,
editVirtualMarkdown,
isVirtualHtmlArtifact,
writeFileInFlight,
]);
return isWriteFileVirtual && !isImageFile(filepath) && !editVirtualMarkdown;
}, [filepath, isWriteFileVirtual, editVirtualMarkdown]);
const { isCodeFile, language } = useMemo(() => {
if (useWriteFileStreaming) {
let nextLanguage =
@@ -414,10 +398,11 @@ export function ArtifactFileDetail({
refetch: refetchArtifactContent,
} = useArtifactContent({
threadId,
// 流式结束后统一用解码后的真实路径走 artifacts API(而非 write-file 虚拟
// URL)。HTML 可能已被后续渲染脚本覆盖;Markdown 保存后的查询缓存也需要
// 命中真实路径,不能继续读取旧的内存 stub。
filepath: useWriteFileStreaming ? filepathFromProps : filepath,
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
// 不会被旧的内存 stub 值覆盖回退。
filepath:
editVirtualMarkdown || !isWriteFileVirtual ? filepath : filepathFromProps,
enabled: isCodeFile && !useWriteFileStreaming,
});
@@ -473,10 +458,9 @@ export function ArtifactFileDetail({
const sandboxDisplayText = useMemo(() => {
const raw = displayContent ?? "";
if (disableDesensitize) return raw;
if (language === "html") return raw;
if (streamPerfEnabled && isLiveStreaming) return raw;
return desensitize(raw);
}, [disableDesensitize, displayContent, isLiveStreaming, language, streamPerfEnabled]);
}, [disableDesensitize, displayContent, isLiveStreaming, streamPerfEnabled]);
const artifactContentPending =
isCodeFile &&
!useWriteFileStreaming &&
@@ -2221,7 +2205,6 @@ export function ArtifactFilePreview({
if (language === "html") {
return (
<iframe
key={htmlPreviewUrl ?? "html-preview"}
className={cn(
"size-full bg-white",
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
@@ -1,28 +0,0 @@
import type { ReactNode } from "react";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
import { SubtasksProvider } from "@/core/tasks/context";
import { useUploadPolicy } from "@/core/uploads";
/**
* Shared providers required by every full chat surface.
*
* Keep this in a small standalone module: importing it from a route must not
* eagerly evaluate the complete WorkspaceRoutes tree and all of its pages.
*/
export function ChatRuntime({ children }: { children: ReactNode }) {
const { data: uploadPolicy } = useUploadPolicy();
return (
<SubtasksProvider>
<DocumentRewritePresentationProvider>
<ArtifactsProvider>
<PromptInputProvider uploadPolicy={uploadPolicy}>
{children}
</PromptInputProvider>
</ArtifactsProvider>
</DocumentRewritePresentationProvider>
</SubtasksProvider>
);
}
@@ -171,14 +171,7 @@ const ChatBox: React.FC<{
const referencesEnabled = ragModeEnabled || hasFrontendRag;
const shouldFetchBackendReferenceBatches =
referencesEnabled && !hasFrontendRag && !!threadId && threadId !== "new";
const hasThreadMessages = (thread.values.messages?.length ?? 0) > 0;
const { skills } = useSkills({
enabled:
referencesEnabled &&
!!threadId &&
threadId !== "new" &&
(hasThreadMessages || thread.isLoading),
});
const { skills } = useSkills();
const canScanArtifactDirectory =
(artifactDirectoryModeEnabled || !!requestedArtifact || !!preferredArtifact) &&
@@ -1,11 +1,12 @@
"use client";
import { useState } from "react";
import { useTheme } from "@/contexts/ThemeContext";
import { AppBrandImage } from "@/core/branding/app-brand-image";
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer";
import { useAppSystemName } from "@/core/system-settings/hooks";
import { getWelcomeBrandAsset } from "@/core/branding/brand-assets";
import { readRuntimeFlag } from "@/env";
import { cn } from "@/lib/utils";
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer";
/**
* 紧凑模式(正常 App)新对话落地页的品牌欢迎块 —— 按 CM-assistant 参考图重画。
@@ -15,8 +16,11 @@ import { cn } from "@/lib/utils";
export function CompactWelcome({ className }: { className?: string }) {
const desensitizeText = useDisplayDesensitizer();
const { theme } = useTheme();
const systemName = useAppSystemName("");
const welcomeTitle = systemName ? `欢迎使用${systemName}` : "欢迎使用CM助手";
const brandTheme = theme;
const welcomeLogo = getWelcomeBrandAsset(brandTheme);
const [failedWelcomeLogo, setFailedWelcomeLogo] = useState<string | null>(null);
const shouldShowWelcomeLogo =
Boolean(welcomeLogo) && failedWelcomeLogo !== welcomeLogo;
const isWelcomeLogoVisible = readRuntimeFlag("VITE_WELCOME_LOGO_VISIBLE");
return (
@@ -26,24 +30,44 @@ export function CompactWelcome({ className }: { className?: string }) {
className,
)}
>
{/* 与全量模式共用同一套系统 Logo;简洁模式仅保留欢迎区的展示容器。 */}
{/* CM logo:浅底圆环 + 主色渐变圆角方块 */}
{/* Controlled by VITE_WELCOME_LOGO_VISIBLE in runtime-config.js. */}
{isWelcomeLogoVisible && (
<div
className="mb-3.5 inline-flex items-center justify-center rounded-full p-3"
style={{
backgroundColor:
"rgb(var(--legacy-workspace-brand-rgb) / 5%)",
border:
"1px solid rgb(var(--legacy-workspace-brand-rgb) / 12%)",
}}
>
<AppBrandImage theme={theme} className="size-16 object-contain" />
</div>
<div
className="mb-3.5 inline-flex items-center justify-center rounded-full p-3"
style={{
backgroundColor:
"rgb(var(--legacy-workspace-brand-rgb) / 5%)",
border:
"1px solid rgb(var(--legacy-workspace-brand-rgb) / 12%)",
}}
>
{shouldShowWelcomeLogo && welcomeLogo ? (
<img
src={welcomeLogo}
alt=""
aria-hidden="true"
className="size-16 object-contain"
onError={() => setFailedWelcomeLogo(welcomeLogo)}
/>
) : (
<div
className="flex size-14 items-center justify-center rounded-2xl shadow-md"
style={{
backgroundImage:
"linear-gradient(to top right, var(--workspace-brand), var(--brand-200))",
}}
>
<span className="text-2xl font-black tracking-wide text-white italic">
{desensitizeText("CM")}
</span>
</div>
)}
</div>
)}
<h1 className="flex items-center justify-center text-2xl font-bold tracking-tight text-foreground/90">
{desensitizeText(welcomeTitle)}
{desensitizeText("欢迎使用CM助手")}
</h1>
<p className="text-muted-foreground mx-auto mt-2 max-w-sm text-xs leading-relaxed">
基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。
@@ -1,43 +0,0 @@
"use client";
import { FileSpreadsheet } from "lucide-react";
import { useCallback } from "react";
import { Button } from "@/components/ui/button";
import { consumerThreadMaterialsDownloadUrl } from "@/core/threads/consumer-thread-values-client";
import { useThread } from "./messages/context";
import { Tooltip } from "./tooltip";
/** Downloads the Consumer-maintained source-material workbook for this chat. */
export function ConsumerMaterialsExportTrigger({
threadId,
}: {
threadId: string;
}) {
const { thread } = useThread();
const downloadUrl = consumerThreadMaterialsDownloadUrl(threadId);
const handleDownload = useCallback(() => {
if (!downloadUrl) return;
window.open(downloadUrl, "_blank", "noopener,noreferrer");
}, [downloadUrl]);
if (thread.messages.length === 0) return null;
return (
<Tooltip
content={downloadUrl ? "导出本对话检索资料" : "未配置资料导出服务"}
>
<Button
type="button"
className="text-muted-foreground hover:text-foreground"
variant="ghost"
disabled={!downloadUrl}
onClick={handleDownload}
>
<FileSpreadsheet />
导出资料
</Button>
</Tooltip>
);
}
@@ -194,7 +194,7 @@ function ManagePromptsDialog({
const createPrompt = useCreateCustomPrompt();
const updatePrompt = useUpdateCustomPrompt();
const deletePrompt = useDeleteCustomPrompt();
const { agents } = useAgentSelector({ enabled: open });
const { agents } = useAgentSelector();
const preferences = useUserPreferences();
const updatePreferences = useUpdateUserPreferences();
const [editingId, setEditingId] = useState<string | null>(null);
@@ -3,6 +3,7 @@
import { Bot, ChevronRight } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import {
SidebarGroup,
@@ -12,10 +13,12 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useSidebarAgents } from "@/core/agents";
import { useAgentsPaginated } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { desensitize } from "@/lib/desensitize";
/** 常用智能体最多展示个数(需求 1.2 ②)。 */
const MAX_FREQUENT = 3;
/** 「更多」跳转目标:智能体管理页(需求 1.3)。 */
const MORE_HREF = "/page/strategy/agents";
@@ -37,7 +40,33 @@ function agentHomeHref(agentId: string): string {
*/
export function FrequentAgentsList() {
const pathname = usePathname();
const { agents: frequent } = useSidebarAgents();
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]);
if (frequent.length === 0) {
return null;
@@ -68,7 +68,7 @@ import {
MyProjectThreadBadge,
MyProjectThreadIcon,
} from "@/components/workspace/my-project-thread-indicator";
import { useAgentsPaginated, useSidebarAgents, type Agent } from "@/core/agents";
import { useAgentsPaginated, type Agent } from "@/core/agents";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
import {
@@ -112,6 +112,9 @@ import {
import { agentColor } from "./agent-icon-color";
import { SidebarCollapseButton } from "./workspace-header";
/** 常用智能体最多展示个数。 */
const MAX_FREQUENT = 4;
// 智能体图标配色(按 id 稳定散列、分主题)抽到共享模块 ./agent-icon-color,
// 与 zzJC/zzXD 模块左栏(AgentModuleSidebar)共用,保证彩色卡片配色完全一致。
@@ -153,7 +156,7 @@ function SectionLabel({
);
}
type CommonAgent = Pick<Agent, "id" | "name"> & { description?: string };
type CommonAgent = Pick<Agent, "id" | "name" | "description">;
/** wx 左栏固定的智能体类型;南京-db 广场尚未创建时筛空列表。 */
const WX_AGENT_TYPE_MINE = "mine";
@@ -250,9 +253,19 @@ export function GeneralQaSidebar({
return !v;
});
// 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 });
// ── 常用智能体取数: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,
});
const {
agents: wxAgents,
total: wxTotal,
@@ -270,8 +283,18 @@ export function GeneralQaSidebar({
if (wxCommonAgents) {
return wxFetchEnabled ? wxAgents : [];
}
return sidebarAgents;
}, [sidebarAgents, wxAgents, wxCommonAgents, wxFetchEnabled]);
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]);
const wxEmptyHint =
wxAgentType === WX_AGENT_TYPE_NANJING_DB && !wxSquareId
? "该类型暂无智能体"
@@ -618,7 +641,7 @@ export function GeneralQaSidebar({
: "text-muted-foreground",
)}
>
{wxCommonAgents && agent.description ? desensitize(agent.description) : "智能体助手"}
{agent.description ? desensitize(agent.description) : "智能体助手"}
</span>
</span>
{isActive && (
@@ -1,10 +1,10 @@
"use client";
import type { ChatStatus } from "ai";
import { toast } from "sonner";
import {
import type { ChatStatus } from "ai";
import { toast } from "sonner";
import {
BookMarkedIcon,
BrainCircuitIcon,
BrainCircuitIcon,
CheckIcon,
FileTextIcon,
FolderTreeIcon,
@@ -26,7 +26,6 @@ import {
useRef,
useState,
type ComponentProps,
type CSSProperties,
type ReactNode,
} from "react";
@@ -64,22 +63,23 @@ import {
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { apiFetch } from "@/core/api";
import { composerMinHeightCss } from "@/core/settings/composer-width";
import { getBackendBaseURL } from "@/core/config";
import {
isDocumentRewriteActiveStage,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import { useI18n } from "@/core/i18n/hooks";
import { getBackendBaseURL } from "@/core/config";
import {
isDocumentRewriteActiveStage,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import { useI18n } from "@/core/i18n/hooks";
import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import {
REASONING_EFFORTS,
resolveModelReasoningEffort,
type ReasoningEffort,
type StoredReasoningEffort,
} from "@/core/models/reasoning-effort";
import type { Model } from "@/core/models/types";
import type { AgentThreadContext } from "@/core/threads";
import type { KnowledgeRetrievalMode } from "@/core/threads/knowledge-retrieval-mode";
import type { KnowledgeRetrievalMode } from "@/core/threads/knowledge-retrieval-mode";
import { textOfMessage } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -105,19 +105,19 @@ import {
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import { useUserPreferences } from "@/core/user-preferences";
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu";
import { resolveInputMode, type InputMode } from "./input-mode";
import { hasPromptInputPayload } from "./input-box-submit";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu";
import { resolveInputMode, type InputMode } from "./input-mode";
import { hasPromptInputPayload } from "./input-box-submit";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
export function InputBox({
className,
@@ -128,23 +128,22 @@ export function InputBox({
extraHeader,
isNewThread,
threadId,
initialValue,
onContextChange,
onFollowupsPresenceChange,
followupsDisplayAllowed = true,
onSubmit,
initialValue,
onContextChange,
onFollowupsPresenceChange,
followupsDisplayAllowed = true,
onSubmit,
onStop,
promptPrefix,
agentSelector,
knowledgeSelector,
knowledgeRetrieval,
knowledgeSuggestedQuestions = [],
knowledgeSelector,
knowledgeRetrieval,
knowledgeSuggestedQuestions = [],
extraModels = [],
assistantId,
hideAdminRecommendedQuestions,
compact,
hideContextTools,
composerHeightPercent,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
@@ -152,14 +151,9 @@ export function InputBox({
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
compact?: boolean;
/** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等
/** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等
* 受控场景用;文件/模型等其他按钮不受影响)。 */
hideContextTools?: boolean;
/**
* Appearance height scale. 100 (or omitted) keeps the built-in min-height.
* Other values scale the landing / compact-landing / conversation base.
*/
composerHeightPercent?: number;
disabled?: boolean;
context: Omit<
AgentThreadContext,
@@ -185,15 +179,15 @@ export function InputBox({
reasoning_effort?: StoredReasoningEffort;
writing_mode?: boolean;
},
) => void;
/**
* Whether generated follow-up questions may be rendered. Hosts with a
* scrollable transcript disable this while the user is reviewing history;
* the generated questions are retained and reappear on returning to bottom.
*/
followupsDisplayAllowed?: boolean;
/** Reports whether follow-ups need layout space, even while scroll-hidden. */
onFollowupsPresenceChange?: (present: boolean) => void;
) => void;
/**
* Whether generated follow-up questions may be rendered. Hosts with a
* scrollable transcript disable this while the user is reviewing history;
* the generated questions are retained and reappear on returning to bottom.
*/
followupsDisplayAllowed?: boolean;
/** Reports whether follow-ups need layout space, even while scroll-hidden. */
onFollowupsPresenceChange?: (present: boolean) => void;
onSubmit?: (message: PromptInputMessage) => void;
onStop?: () => void;
/**
@@ -215,16 +209,19 @@ export function InputBox({
selectedAgentId: string | null;
onAgentChange: (agentId: string | null) => void;
};
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
knowledgeSelector?: ReactNode;
/** Per-thread ordinary-knowledge retrieval mode. Omit when unavailable. */
knowledgeRetrieval?: {
mode: KnowledgeRetrievalMode;
smartAvailable: boolean;
onModeChange: (mode: KnowledgeRetrievalMode) => void;
};
/** High-value starter questions derived from the currently selected spaces. */
knowledgeSuggestedQuestions?: string[];
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
knowledgeSelector?: ReactNode;
/** Per-thread ordinary-knowledge retrieval mode. Omit when unavailable. */
knowledgeRetrieval?: {
mode: KnowledgeRetrievalMode;
smartAvailable: boolean;
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. */
knowledgeSuggestedQuestions?: string[];
/** Page-scoped model choices implemented outside the DeerFlow model API. */
extraModels?: Model[];
}) {
@@ -241,26 +238,26 @@ export function InputBox({
const modelOptions = useMemo(() => {
return singleRoundRagActive ? extraModels : models;
}, [extraModels, models, singleRoundRagActive]);
const { thread, isMock } = useThread();
const rewritePresentation = useDocumentRewritePresentation();
const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null);
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const documentRewriteActive = useMemo(
() =>
rewritePresentation?.forThread(threadId).some((item) =>
isDocumentRewriteActiveStage(item.stage),
) ?? false,
[rewritePresentation, threadId],
);
// Keep normal chat generation authoritative when both states overlap. In
// the usual rewrite-only case this gives the composer the same square stop
// affordance as a normal streamed reply.
const composerStatus: ChatStatus = documentRewriteActive
? "streaming"
: status;
const { thread, isMock } = useThread();
const rewritePresentation = useDocumentRewritePresentation();
const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null);
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const documentRewriteActive = useMemo(
() =>
rewritePresentation?.forThread(threadId).some((item) =>
isDocumentRewriteActiveStage(item.stage),
) ?? false,
[rewritePresentation, threadId],
);
// Keep normal chat generation authoritative when both states overlap. In
// the usual rewrite-only case this gives the composer the same square stop
// affordance as a normal streamed reply.
const composerStatus: ChatStatus = documentRewriteActive
? "streaming"
: status;
const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false);
@@ -276,9 +273,9 @@ export function InputBox({
const { questions: recommendedQuestions } = useRecommendedQuestions();
const userPreferences = useUserPreferences();
const followupsEnabled = userPreferences.followup_suggestions_enabled;
const showAdminRecommendedQuestions =
!hideAdminRecommendedQuestions &&
knowledgeSuggestedQuestions.length === 0 &&
const showAdminRecommendedQuestions =
!hideAdminRecommendedQuestions &&
knowledgeSuggestedQuestions.length === 0 &&
!userPreferences.hide_admin_prompt_prefix &&
recommendedQuestions.length > 0;
@@ -297,12 +294,11 @@ export function InputBox({
const supportsReasoningEffort =
fallbackModel.supports_reasoning_effort ?? false;
const nextModelName = fallbackModel.name;
const nextMode = resolveInputMode(context.mode, supportsThinking);
const nextMode = resolveInputMode(context.mode, supportsThinking);
const nextReasoningEffort = resolveModelReasoningEffort(
supportsReasoningEffort,
context.reasoning_effort,
context.model_name !== nextModelName,
fallbackModel.default_reasoning_effort,
);
if (
@@ -348,11 +344,26 @@ export function InputBox({
resolveModelReasoningEffort(
supportReasoningEffort,
context.reasoning_effort,
false,
selectedModel?.default_reasoning_effort,
),
[context.reasoning_effort, selectedModel?.default_reasoning_effort, supportReasoningEffort],
[context.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);
@@ -362,12 +373,11 @@ export function InputBox({
onContextChange?.({
...context,
model_name,
mode: resolveInputMode(context.mode, model.supports_thinking ?? false),
mode: resolveInputMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: resolveModelReasoningEffort(
model.supports_reasoning_effort ?? false,
context.reasoning_effort,
model_name !== context.model_name,
model.default_reasoning_effort,
),
execution_profile: model.execution_profile,
});
@@ -383,15 +393,13 @@ export function InputBox({
onContextChange?.({
...context,
model_name: ordinaryModel?.name ?? context.model_name,
mode: resolveInputMode(
mode: resolveInputMode(
mode,
ordinaryModel?.supports_thinking ?? supportThinking,
),
reasoning_effort: resolveModelReasoningEffort(
ordinaryModel?.supports_reasoning_effort ?? supportReasoningEffort,
context.reasoning_effort,
false,
ordinaryModel?.default_reasoning_effort,
),
execution_profile: undefined,
});
@@ -416,7 +424,7 @@ export function InputBox({
onContextChange?.({
...context,
model_name: ragModel.name,
mode: resolveInputMode(
mode: resolveInputMode(
context.mode,
ragModel.supports_thinking ?? false,
),
@@ -431,6 +439,16 @@ export function InputBox({
});
}, [context, onContextChange]);
const handleReasoningEffortSelect = useCallback(
(effort: ReasoningEffort) => {
onContextChange?.({
...context,
reasoning_effort: effort,
});
},
[onContextChange, context],
);
const handleWritingModeToggle = useCallback(() => {
onContextChange?.({
...context,
@@ -446,13 +464,13 @@ export function InputBox({
});
}, [artifactDirectoryModeEnabled, setLocalSettings]);
const smartKnowledgeRetrievalActive =
knowledgeRetrieval?.mode === "smart_reasoning";
const ragModeLocked =
!smartKnowledgeRetrievalActive && isThreadRagModeLocked(threadId);
const ragModeEnabled =
!smartKnowledgeRetrievalActive &&
(localSettings.display.ragModeEnabled || ragModeLocked);
const smartKnowledgeRetrievalActive =
knowledgeRetrieval?.mode === "smart_reasoning";
const ragModeLocked =
!smartKnowledgeRetrievalActive && isThreadRagModeLocked(threadId);
const ragModeEnabled =
!smartKnowledgeRetrievalActive &&
(localSettings.display.ragModeEnabled || ragModeLocked);
const handleRagModeToggle = useCallback(() => {
if (ragModeLocked) {
return;
@@ -468,19 +486,19 @@ export function InputBox({
});
}, [contextCompressionEnabled, setLocalSettings]);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (composerStatus === "streaming") {
toast.info("正在生成中,请等待当前回复完成后再发送。", {
description: "已保留您输入的内容。",
});
// Reject rather than resolve: PromptInput treats a resolved submit as
// accepted and clears its draft. The current message has not been sent.
throw new Error("Cannot submit while a response is streaming.");
}
if (!hasPromptInputPayload(message)) {
return;
}
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (composerStatus === "streaming") {
toast.info("正在生成中,请等待当前回复完成后再发送。", {
description: "已保留您输入的内容。",
});
// Reject rather than resolve: PromptInput treats a resolved submit as
// accepted and clears its draft. The current message has not been sent.
throw new Error("Cannot submit while a response is streaming.");
}
if (!hasPromptInputPayload(message)) {
return;
}
setFollowups([]);
setFollowupsHidden(false);
setFollowupsLoading(false);
@@ -498,7 +516,7 @@ export function InputBox({
onContextChange?.({
...context,
model_name: resolvedModelName,
mode: resolveInputMode(
mode: resolveInputMode(
context.mode,
selectedModel?.supports_thinking ?? false,
),
@@ -513,14 +531,15 @@ export function InputBox({
},
[
context,
onContextChange,
onSubmit,
composerStatus,
resolvedModelName,
onContextChange,
onSubmit,
composerStatus,
resolvedModelName,
ragModeEnabled,
selectedModel?.supports_thinking,
selectedModel?.execution_profile,
selectedReasoningEffort,
status,
threadId,
],
);
@@ -543,24 +562,9 @@ 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") {
const handleFollowupClick = useCallback(
(suggestion: string) => {
if (composerStatus === "streaming") {
return;
}
const current = (textInput.value ?? "").trim();
@@ -573,7 +577,7 @@ export function InputBox({
setFollowupsHidden(true);
setTimeout(() => requestFormSubmit(), 0);
},
[composerStatus, requestFormSubmit, textInput],
[composerStatus, requestFormSubmit, textInput],
);
const confirmReplaceAndSend = useCallback(() => {
@@ -604,28 +608,28 @@ export function InputBox({
setTimeout(() => requestFormSubmit(), 0);
}, [pendingSuggestion, requestFormSubmit, textInput]);
const followupsPresent =
const followupsPresent =
followupsEnabled &&
!disabled &&
!isNewThread &&
!selectedSingleRoundRag &&
!followupsHidden &&
(followupsLoading || followups.length > 0);
const showFollowups = followupsDisplayAllowed && followupsPresent;
const followupsPresenceChangeRef = useRef(onFollowupsPresenceChange);
useEffect(() => {
followupsPresenceChangeRef.current = onFollowupsPresenceChange;
}, [onFollowupsPresenceChange]);
useEffect(() => {
followupsPresenceChangeRef.current?.(followupsPresent);
}, [followupsPresent]);
useEffect(() => {
return () => followupsPresenceChangeRef.current?.(false);
}, []);
!disabled &&
!isNewThread &&
!selectedSingleRoundRag &&
!followupsHidden &&
(followupsLoading || followups.length > 0);
const showFollowups = followupsDisplayAllowed && followupsPresent;
const followupsPresenceChangeRef = useRef(onFollowupsPresenceChange);
useEffect(() => {
followupsPresenceChangeRef.current = onFollowupsPresenceChange;
}, [onFollowupsPresenceChange]);
useEffect(() => {
followupsPresenceChangeRef.current?.(followupsPresent);
}, [followupsPresent]);
useEffect(() => {
return () => followupsPresenceChangeRef.current?.(false);
}, []);
useEffect(() => {
if (followupsEnabled) return;
@@ -714,46 +718,37 @@ export function InputBox({
};
}, [context.model_name, disabled, followupsEnabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
const composerMinHeight = composerMinHeightCss(
composerHeightPercent ?? 100,
compact ? "84px" : isNewThread ? "110px" : "4rem",
);
const composerHeightStyle: CSSProperties | undefined = composerMinHeight
? {
minHeight: composerMinHeight,
maxHeight: `max(12rem, calc(${composerMinHeight} + 8rem))`,
}
: undefined;
return (
<div ref={promptRootRef} className="relative flex flex-col gap-6">
{extraHeader && (
{extraHeader && (
<div className="flex items-center justify-center">
{extraHeader}
</div>
)}
{isNewThread && searchParams.get("mode") !== "skill" && knowledgeSuggestedQuestions.length > 0 && (
<div className="px-4 mb-[10px]">
<div className="mb-2 text-center text-xs text-muted-foreground">基于已选知识空间,试试这样问</div>
<div className="flex flex-wrap justify-center gap-2.5">
{knowledgeSuggestedQuestions.slice(0, 4).map((question) => (
<button
key={question}
type="button"
className="max-w-full rounded-full border border-primary/20 bg-primary/5 px-4 py-2 text-left text-sm text-foreground transition-colors hover:bg-primary/10"
onClick={() => {
fillPromptForEditing(question);
}}
>
{question}
</button>
))}
</div>
</div>
)}
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
)}
{isNewThread && searchParams.get("mode") !== "skill" && knowledgeSuggestedQuestions.length > 0 && (
<div className="px-4 mb-[10px]">
<div className="mb-2 text-center text-xs text-muted-foreground">基于已选知识空间,试试这样问</div>
<div className="flex flex-wrap justify-center gap-2.5">
{knowledgeSuggestedQuestions.slice(0, 4).map((question) => (
<button
key={question}
type="button"
className="max-w-full rounded-full border border-primary/20 bg-primary/5 px-4 py-2 text-left text-sm text-foreground transition-colors hover:bg-primary/10"
onClick={() => {
textInput.setInput(question);
setFollowupsHidden(true);
setTimeout(() => requestFormSubmit(), 0);
}}
>
{question}
</button>
))}
</div>
</div>
)}
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
{recommendedQuestions.map((q) => (
<button
@@ -761,7 +756,9 @@ 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={() => {
fillPromptForEditing(q.content);
textInput.setInput(q.content);
setFollowupsHidden(true);
setTimeout(() => requestFormSubmit(), 0);
}}
>
{desensitize(q.title)}
@@ -819,9 +816,6 @@ export function InputBox({
)}
<div className={cn(isNewThread && !compact && "gemini-gradient-border gemini-shadow")}>
<PromptInput
style={
composerMinHeight ? { minHeight: composerMinHeight } : undefined
}
className={cn(
"transition-all duration-300 ease-out",
// 紧凑落地态(CM 参考图):表单作为白卡,内部 input-group 透明无边框,
@@ -854,12 +848,9 @@ export function InputBox({
<PromptInputTextarea
className={cn(
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-muted-foreground/60",
!composerMinHeight && isNewThread && "min-h-[110px]!",
compact && "px-3 pt-3 pb-1 text-[14px]",
compact && !composerMinHeight && "min-h-[84px]!",
isNewThread && "min-h-[110px]!",
compact && "min-h-[84px]! px-3 pt-3 pb-1 text-[14px]",
)}
data-composer-height={composerMinHeight ? "" : undefined}
style={composerHeightStyle}
disabled={disabled}
placeholder={t.inputBox.placeholder}
autoFocus={autoFocus}
@@ -868,12 +859,12 @@ export function InputBox({
</PromptInputBody>
<PromptInputFooter
className={cn(
"flex flex-wrap items-center gap-y-2 border-t-0 px-2 pt-1 pb-1.5",
"flex flex-wrap items-center gap-y-2 border-t-0 px-2 pt-1 pb-1.5",
compact && "gap-2 px-3 pt-1.5 pb-3",
)}
>
<PromptInputTools
className={cn("min-w-0 flex-1 flex-wrap gap-1.5", compact && "gap-2")}
className={cn("min-w-0 flex-1 flex-wrap gap-1.5", compact && "gap-2")}
>
{/* TODO: Add more connectors here
<PromptInputActionMenu>
@@ -893,7 +884,7 @@ export function InputBox({
)}
/>
)}
{!frontendRagActive && !hideContextTools && <Tooltip
{!frontendRagActive && !hideContextTools && <Tooltip
content={
context.writing_mode
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
@@ -948,42 +939,42 @@ export function InputBox({
<span className="text-xs font-normal">文件</span>
</PromptInputButton>
</Tooltip>}
{referenceModeEntryEnabled &&
!frontendRagActive &&
!smartKnowledgeRetrievalActive &&
!hideContextTools && (
<Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
: "参考文献模式已关闭:点击开启"
}
>
<PromptInputButton
type="button"
aria-pressed={ragModeEnabled}
disabled={ragModeLocked || disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact &&
"rounded-lg px-2.5! py-1! text-[11px] font-medium",
ragModeLocked && "cursor-not-allowed opacity-80",
ragModeEnabled
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleRagModeToggle}
>
{ragModeEnabled && (
<CheckIcon className="size-3 text-emerald-700 dark:text-emerald-200" />
)}
<BookMarkedIcon className="size-3" />
<span className="text-xs font-normal">参考文献</span>
</PromptInputButton>
</Tooltip>
)}
{referenceModeEntryEnabled &&
!frontendRagActive &&
!smartKnowledgeRetrievalActive &&
!hideContextTools && (
<Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
: "参考文献模式已关闭:点击开启"
}
>
<PromptInputButton
type="button"
aria-pressed={ragModeEnabled}
disabled={ragModeLocked || disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact &&
"rounded-lg px-2.5! py-1! text-[11px] font-medium",
ragModeLocked && "cursor-not-allowed opacity-80",
ragModeEnabled
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleRagModeToggle}
>
{ragModeEnabled && (
<CheckIcon className="size-3 text-emerald-700 dark:text-emerald-200" />
)}
<BookMarkedIcon className="size-3" />
<span className="text-xs font-normal">参考文献</span>
</PromptInputButton>
</Tooltip>
)}
{!frontendRagActive && <Tooltip
content={
contextCompressionEnabled
@@ -1244,7 +1235,6 @@ export function InputBox({
</PromptInputActionMenuItem>
</>
)}
{/* 多轮 RAG 问答选项暂时下线,需要恢复时取消下面注释即可
<PromptInputActionMenuItem
className={cn(
multiRoundRagActive
@@ -1273,13 +1263,67 @@ export function InputBox({
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
*/}
</PromptInputActionMenu>
</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")}>
<PromptInputTools className={cn("ml-auto shrink-0 gap-1.5", compact && "gap-2")}>
{!frontendRagActive && agentSelector && (
<AgentSelector
selectedAgentId={agentSelector.selectedAgentId}
@@ -1289,83 +1333,100 @@ export function InputBox({
/>
)}
{!frontendRagActive && knowledgeSelector}
{!frontendRagActive && knowledgeRetrieval && (
<DropdownMenu>
<Tooltip
content={
knowledgeRetrieval.mode === "smart_reasoning"
? "智能检索:综合原文与 Wiki 线索,提供更全面的背景和解释"
: "经典检索:细粒度召回原文,优先回答具体事实与细节"
}
>
<DropdownMenuTrigger asChild>
<PromptInputButton
type="button"
disabled={disabled}
aria-label={`普通知识库检索模式:${
knowledgeRetrieval.mode === "smart_reasoning"
? "智能检索"
: "经典检索"
}`}
aria-haspopup="menu"
className={cn(
"rounded-full px-3 shadow-none transition-colors",
knowledgeRetrieval.mode === "smart_reasoning"
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: "border border-transparent bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground",
)}
>
<BrainCircuitIcon className="size-3.5" />
<span className="max-w-[160px] truncate text-xs font-normal">
{knowledgeRetrieval.mode === "smart_reasoning" ? "智能检索" : "经典检索"}
</span>
</PromptInputButton>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent
align="end"
sideOffset={8}
className="w-80 rounded-xl p-1.5"
>
<DropdownMenuLabel className="px-2 pb-1 text-xs text-muted-foreground">
普通知识库检索模式
</DropdownMenuLabel>
<DropdownMenuItem
className="min-h-16 items-start rounded-lg py-2"
onSelect={() => knowledgeRetrieval.onModeChange("classic")}
>
<BookMarkedIcon className="mt-0.5 size-4" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">经典检索(默认)</div>
<div className="mt-0.5 text-xs text-muted-foreground">
细粒度召回知识库原文,优先定位与问题最相关的具体段落,适合查询事实、条款、数字和原文细节
</div>
</div>
{knowledgeRetrieval.mode === "classic" && (
<CheckIcon className="mt-0.5 size-4 text-primary" />
)}
</DropdownMenuItem>
<DropdownMenuItem
className="min-h-16 items-start rounded-lg py-2"
disabled={!knowledgeRetrieval.smartAvailable}
onSelect={() =>
knowledgeRetrieval.onModeChange("smart_reasoning")
}
>
<BrainCircuitIcon className="mt-0.5 size-4" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">智能检索</div>
<div className="mt-0.5 text-xs text-muted-foreground">
综合原文分片与 Wiki 目录、页面和关联线索扩展检索,适合全面梳理背景、关联关系和综合解释
</div>
</div>
{knowledgeRetrieval.mode === "smart_reasoning" && (
<CheckIcon className="mt-0.5 size-4 text-emerald-600" />
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{!frontendRagActive && knowledgeRetrieval && (
<DropdownMenu>
<Tooltip
content={
knowledgeRetrieval.mode === "smart_reasoning"
? "智能检索已开启:直接提问即可,系统会检索原文分片并沿 Wiki 线索扩展背景资料"
: knowledgeRetrieval.smartAvailabilityReason
}
>
<DropdownMenuTrigger asChild>
<PromptInputButton
type="button"
disabled={disabled}
aria-label={`普通知识库检索模式:${
knowledgeRetrieval.mode === "smart_reasoning"
? "智能检索"
: "经典检索"
}`}
aria-haspopup="menu"
className={cn(
"rounded-full px-3 shadow-none transition-colors",
knowledgeRetrieval.mode === "smart_reasoning"
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: "border border-transparent bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground",
)}
>
<BrainCircuitIcon className="size-3.5" />
<span className="max-w-[160px] truncate text-xs font-normal">
{knowledgeRetrieval.mode === "smart_reasoning" ? "智能检索" : "经典检索"}
</span>
</PromptInputButton>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent
align="end"
sideOffset={8}
className="w-80 rounded-xl p-1.5"
>
<DropdownMenuLabel className="px-2 pb-1 text-xs text-muted-foreground">
普通知识库检索模式
</DropdownMenuLabel>
<DropdownMenuItem
className="min-h-16 items-start rounded-lg py-2"
disabled={!knowledgeRetrieval.smartAvailable}
onSelect={() =>
knowledgeRetrieval.onModeChange("smart_reasoning")
}
>
<BrainCircuitIcon className="mt-0.5 size-4" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
智能检索
{knowledgeRetrieval.smartIsDefault ? "(新对话默认)" : ""}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
先检索原文分片,再按命中线索查阅并扩展 Wiki;资料仅作为回答背景
</div>
</div>
{knowledgeRetrieval.mode === "smart_reasoning" && (
<CheckIcon className="mt-0.5 size-4 text-emerald-600" />
)}
</DropdownMenuItem>
<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>
)}
{!frontendRagActive && promptPrefix && (
<Tooltip
content={
@@ -1450,21 +1511,21 @@ export function InputBox({
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
compact &&
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted",
)}
disabled={disabled}
onClick={(event) => {
if (composerStatus !== "streaming") return;
// A deliberate press of the square button stops the stream.
// Prevent the enclosing form from treating it as a new submit.
event.preventDefault();
handleStopCurrentGeneration();
}}
variant="outline"
status={composerStatus}
{...(documentRewriteActive
? { "aria-label": "停止 AI 全文改写" }
: {})}
/>
)}
disabled={disabled}
onClick={(event) => {
if (composerStatus !== "streaming") return;
// A deliberate press of the square button stops the stream.
// Prevent the enclosing form from treating it as a new submit.
event.preventDefault();
handleStopCurrentGeneration();
}}
variant="outline"
status={composerStatus}
{...(documentRewriteActive
? { "aria-label": "停止 AI 全文改写" }
: {})}
/>
</PromptInputTools>
</PromptInputFooter>
{!isNewThread && (
@@ -1,23 +1,102 @@
import { BookOpen } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BookOpen /* , Database, SearchCheck */ } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getStoredAuth } from "@/core/auth";
// import { canAccessAssistantKnowledge } from "@/core/llmwiki/assistant-knowledge-access-policy";
// import {
// useAssistantKnowledgeVisibility,
// useUpdateAssistantKnowledgeVisibility,
// } from "@/core/system-settings/hooks";
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import {
loadOrdinaryKnowledgeLanding,
saveOrdinaryKnowledgeLanding,
type OrdinaryKnowledgeLanding,
} from "@/core/system-settings/api";
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
export function useOrdinaryKnowledgeLanding(enabled = true) {
return useQuery({
queryKey: LANDING_KEY,
queryFn: ({ signal }) => loadOrdinaryKnowledgeLanding(signal),
enabled,
staleTime: 30_000,
retry: false,
});
}
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
export function KnowledgeProductNav() {
const location = useLocation();
const navigate = useNavigate();
const client = useQueryClient();
// const assistantVisibility = useAssistantKnowledgeVisibility();
const isAdmin = getStoredAuth()?.system_role === "admin";
const landing = useOrdinaryKnowledgeLanding(isAdmin);
const saveLanding = useMutation({
mutationFn: saveOrdinaryKnowledgeLanding,
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
onError: (error) => toast.error(error.message),
});
const runtime = useLlmWikiRuntime();
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
const ordinary = !location.pathname.includes("/knowledge/assistant") && !location.pathname.includes("/knowledge/retrieval-debug");
// const assistantAllowed = canAccessAssistantKnowledge(
// isAdmin,
// assistantVisibility.isError ? undefined : assistantVisibility.data,
// );
// const saveAssistantVisibility = useUpdateAssistantKnowledgeVisibility();
const retrievalDebug = location.pathname.includes("/knowledge/retrieval-debug");
const assistant = location.pathname.includes("/knowledge/assistant");
const ordinary = !assistant && !retrievalDebug;
return (
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={ordinary ? "secondary" : "ghost"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
<BookOpen />普通知识库
</Button>
{/* 助手知识库、检索调试暂时隐藏,后续放开时去掉这段注释。
{assistantAllowed && (
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
<Database />助手知识库
</Button>
)}
{isAdmin && (
<Button size="sm" variant={retrievalDebug ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/retrieval-debug")}>
<SearchCheck />检索调试
</Button>
)}
*/}
{isAdmin && (
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
普通知识库默认页面
<Select disabled={landing.isLoading || saveLanding.isPending} value={landing.data ?? "wiki"} onValueChange={(value) => saveLanding.mutate(value as OrdinaryKnowledgeLanding)}>
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
</Select>
{/* 助手知识库日常展示暂时隐藏,和上面两个入口一起放开。
助手知识库日常展示
<Select
disabled={assistantVisibility.isLoading || saveAssistantVisibility.isPending}
value={assistantVisibility.data === true ? "visible" : "hidden"}
onValueChange={(value) =>
saveAssistantVisibility.mutate(value === "visible", {
onSuccess: (visible) => toast.success(visible ? "已向普通用户开放助手知识库" : "已隐藏普通用户的助手知识库"),
onError: (error) => toast.error(error.message),
})
}
>
<SelectTrigger className="h-8 w-52" aria-label="助手知识库日常展示"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="hidden">隐藏(仅管理员可用)</SelectItem><SelectItem value="visible">开放给普通用户</SelectItem></SelectContent>
</Select>
*/}
</div>
)}
</div>
);
}
@@ -32,21 +32,21 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
buildWriteFileVirtualUrl,
isAutoPreviewableArtifactPath,
languageOfStreamingArtifact,
buildWriteFileVirtualUrl,
isAutoPreviewableArtifactPath,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath,
namedStreamingWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
import { listStreamingWriteFileCalls } from "@/core/artifacts/parse-partial-json";
namedStreamingWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
import { listStreamingWriteFileCalls } from "@/core/artifacts/parse-partial-json";
import { useI18n } from "@/core/i18n/hooks";
import {
normalizeSearchResults,
@@ -57,11 +57,11 @@ import {
extractReasoningContentFromMessage,
findToolCallResult,
} from "@/core/messages/utils";
import { collectRetrievedFiles } from "@/core/messages/retrieved-files";
import { collectRetrievedFiles } from "@/core/messages/retrieved-files";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { isInternalToolCallName } from "@/core/tools/visibility";
import {
applyInlineCitations,
@@ -85,26 +85,26 @@ import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content";
import { SingleRoundRagCitationDrawer } from "./message-list-item";
import { RetrievedFileList } from "./retrieved-file-list";
import { SingleRoundRagCitationDrawer } from "./message-list-item";
import { RetrievedFileList } from "./retrieved-file-list";
export function MessageGroup({
className,
messages,
isLoading = false,
displaySources = [],
ragModeEnabled = false,
threadId,
}: {
className?: string;
messages: Message[];
isLoading?: boolean;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
threadId?: string;
export function MessageGroup({
className,
messages,
isLoading = false,
displaySources = [],
ragModeEnabled = false,
threadId,
}: {
className?: string;
messages: Message[];
isLoading?: boolean;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
threadId?: string;
}) {
const { t } = useI18n();
const { data: citationDisplay } = useCitationDisplaySettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const [showAbove, setShowAbove] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
@@ -119,20 +119,16 @@ export function MessageGroup({
() => convertToSteps(messages, writingModeEnabled),
[messages, writingModeEnabled],
);
const { skills } = useSkills({
enabled:
messages.length > 0 &&
(ragModeEnabled || citationDisplay?.retrieved_files_enabled === true),
});
const retrievedFiles = useMemo(
() => citationDisplay?.retrieved_files_enabled === true
? collectRetrievedFiles(messages, new Set(skills.map((skill) => skill.name)))
: [],
[messages, skills, citationDisplay?.retrieved_files_enabled],
);
const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
const { skills } = useSkills();
const retrievedFiles = useMemo(
() => citationDisplay?.retrieved_files_enabled === true
? collectRetrievedFiles(messages, new Set(skills.map((skill) => skill.name)))
: [],
[messages, skills, citationDisplay?.retrieved_files_enabled],
);
const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
);
const lastToolCallStep = useMemo(() => {
const filteredSteps = steps.filter((step) => step.type === "toolCall");
@@ -227,8 +223,8 @@ export function MessageGroup({
{...step}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled}
/>
),
)}
@@ -240,8 +236,8 @@ export function MessageGroup({
isLast={true}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled}
displaySources={displaySources}
ragModeEnabled={ragModeEnabled}
/>
</FlipDisplay>
)}
@@ -290,8 +286,8 @@ export function MessageGroup({
</>
)}
</ChainOfThought>
{threadId && <RetrievedFileList files={retrievedFiles} threadId={threadId} />}
<SingleRoundRagCitationDrawer
{threadId && <RetrievedFileList files={retrievedFiles} threadId={threadId} />}
<SingleRoundRagCitationDrawer
source={selectedCitation}
onOpenChange={(open) => {
if (!open) setSelectedCitation(null);
@@ -356,7 +352,7 @@ function ToolCall({
isLast = false,
isLoading = false,
skillDisplayMap = new Map(),
displaySources = [],
displaySources = [],
ragModeEnabled = false,
}: {
id?: string;
@@ -367,7 +363,7 @@ function ToolCall({
isLast?: boolean;
isLoading?: boolean;
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
displaySources?: RuntimeDisplaySource[];
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
}) {
const { t } = useI18n();
@@ -378,42 +374,42 @@ function ToolCall({
null);
const displayItems =
displayMatch && result !== undefined
? syncDisplaySourceIndexes(
rankDisplaySources(
normalizeDisplaySources(result, displayMatch.display).map(
(item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}),
),
),
displaySources,
)
? syncDisplaySourceIndexes(
rankDisplaySources(
normalizeDisplaySources(result, displayMatch.display).map(
(item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}),
),
),
displaySources,
)
: [];
const isLlmWikiTool =
name === "llmwiki_search" ||
name === "llmwiki_read_wiki_page" ||
name === "llmwiki_browse_wiki_index";
const wikiQuery = isLlmWikiTool
? resolveSearchQuery(args, result) ||
(name === "llmwiki_read_wiki_page"
? normalizeSearchResults(result)[0]?.title ||
(typeof args.wiki_slug === "string" ? args.wiki_slug : "")
: "")
: "";
const isLlmWikiTool =
name === "llmwiki_search" ||
name === "llmwiki_read_wiki_page" ||
name === "llmwiki_browse_wiki_index";
const wikiQuery = isLlmWikiTool
? resolveSearchQuery(args, result) ||
(name === "llmwiki_read_wiki_page"
? normalizeSearchResults(result)[0]?.title ||
(typeof args.wiki_slug === "string" ? args.wiki_slug : "")
: "")
: "";
const wikiSearchLabel = name === "llmwiki_search"
? wikiQuery
? `Wiki 文档检索 "${wikiQuery}"`
: "Wiki 文档检索"
: name === "llmwiki_read_wiki_page"
? wikiQuery
? `读取 Wiki 页面「${wikiQuery}」`
: "读取 Wiki 页面"
: name === "llmwiki_browse_wiki_index"
? "读取 Wiki 索引与知识库摘要"
: null;
: name === "llmwiki_read_wiki_page"
? wikiQuery
? `读取 Wiki 页面「${wikiQuery}」`
: "读取 Wiki 页面"
: name === "llmwiki_browse_wiki_index"
? "读取 Wiki 索引与知识库摘要"
: null;
if (displayItems.length > 0) {
return (
<ChainOfThoughtStep
@@ -439,17 +435,17 @@ function ToolCall({
const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
const defaultItems = syncDisplaySourceIndexes(
rankDisplaySources(
normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
skillName: name,
mode: "citation" as const,
})),
),
displaySources,
);
const defaultItems = syncDisplaySourceIndexes(
rankDisplaySources(
normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
skillName: name,
mode: "citation" as const,
})),
),
displaySources,
);
return (
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
<DisplayResultsList results={defaultItems} />
@@ -831,10 +827,10 @@ function ArtifactWriteToolCall({
const rawPath = typeof args.path === "string" ? args.path : "";
const content = typeof args.content === "string" ? args.content : "";
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
const sourcePath = namedStreamingWriteFilePath(rawPath);
const showInlineContent = name === "write_file" && !sourcePath;
const sourcePath = namedStreamingWriteFilePath(rawPath);
const showInlineContent = name === "write_file" && !sourcePath;
const previewKey = messageId && id ? `${messageId}:${id}` : null;
// Until a real path arrives, both auto-open and manual clicks stay inline.
// Until a real path arrives, both auto-open and manual clicks stay inline.
const artifactPath =
sourcePath && name === "write_file" && messageId && id
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
@@ -846,10 +842,10 @@ function ArtifactWriteToolCall({
!artifactPath ||
!previewKey ||
!isLoading ||
result !== undefined ||
!sourcePath ||
!languageOfStreamingArtifact(sourcePath) ||
!isAutoPreviewableArtifactPath(sourcePath)
result !== undefined ||
!sourcePath ||
!languageOfStreamingArtifact(sourcePath) ||
!isAutoPreviewableArtifactPath(sourcePath)
) {
return;
}
@@ -875,9 +871,9 @@ function ArtifactWriteToolCall({
artifacts.setOpen(true);
}}
>
{showInlineContent ? (
content && <InlineWritePreview content={content} />
) : (normalizedPath || sourcePath) && (
{showInlineContent ? (
content && <InlineWritePreview content={content} />
) : (normalizedPath || sourcePath) && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
</ChainOfThoughtSearchResult>
@@ -886,29 +882,29 @@ function ArtifactWriteToolCall({
);
}
function InlineWritePreview({ content }: { content: string }) {
const ref = useRef<HTMLPreElement>(null);
const follow = useRef(true);
useLayoutEffect(() => {
if (follow.current && ref.current) {
ref.current.scrollTop = ref.current.scrollHeight;
}
}, [content]);
return (
<pre
ref={ref}
onScroll={(event) => {
const node = event.currentTarget;
follow.current = node.scrollHeight - node.scrollTop - node.clientHeight < 24;
}}
className="border-border m-0 max-h-64 overflow-auto border-l pl-3 font-mono text-xs leading-6 whitespace-pre-wrap break-words text-emerald-600 dark:text-emerald-400"
>
<code>{content}</code>
</pre>
);
}
interface GenericCoTStep<T extends string = string> {
function InlineWritePreview({ content }: { content: string }) {
const ref = useRef<HTMLPreElement>(null);
const follow = useRef(true);
useLayoutEffect(() => {
if (follow.current && ref.current) {
ref.current.scrollTop = ref.current.scrollHeight;
}
}, [content]);
return (
<pre
ref={ref}
onScroll={(event) => {
const node = event.currentTarget;
follow.current = node.scrollHeight - node.scrollTop - node.clientHeight < 24;
}}
className="border-border m-0 max-h-64 overflow-auto border-l pl-3 font-mono text-xs leading-6 whitespace-pre-wrap break-words text-emerald-600 dark:text-emerald-400"
>
<code>{content}</code>
</pre>
);
}
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
type: T;
@@ -928,10 +924,10 @@ type CoTStep = CoTReasoningStep | CoTToolCallStep;
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
const steps: CoTStep[] = [];
// Some streaming providers omit tool_call_id on ToolMessages. Keep a
// per-name cursor so parallel searches consume distinct unbound results in
// order instead of every step reusing the first page.
const toolCallNameFallbackIndexes = new Map<string, number>();
// Some streaming providers omit tool_call_id on ToolMessages. Keep a
// per-name cursor so parallel searches consume distinct unbound results in
// order instead of every step reusing the first page.
const toolCallNameFallbackIndexes = new Map<string, number>();
for (const message of messages) {
if (message.type === "ai") {
const reasoning = extractReasoningContentFromMessage(message);
@@ -944,37 +940,37 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
};
steps.push(step);
}
const writes = new Map(
listStreamingWriteFileCalls(message).map((write) => [write.id, write]),
);
const chunks = (message as Parameters<typeof listStreamingWriteFileCalls>[0])
.tool_call_chunks;
const toolCalls = [...(message.tool_calls ?? [])];
for (const write of writes.values()) {
if (
!toolCalls.some((call) => call.id === write.id) &&
chunks?.some(
(chunk) => chunk.id === write.id && chunk.name === "write_file",
)
) {
toolCalls.push({
id: write.id,
name: "write_file",
args: { path: write.path, content: write.content },
});
}
}
for (const tool_call of toolCalls) {
const writes = new Map(
listStreamingWriteFileCalls(message).map((write) => [write.id, write]),
);
const chunks = (message as Parameters<typeof listStreamingWriteFileCalls>[0])
.tool_call_chunks;
const toolCalls = [...(message.tool_calls ?? [])];
for (const write of writes.values()) {
if (
!toolCalls.some((call) => call.id === write.id) &&
chunks?.some(
(chunk) => chunk.id === write.id && chunk.name === "write_file",
)
) {
toolCalls.push({
id: write.id,
name: "write_file",
args: { path: write.path, content: write.content },
});
}
}
for (const tool_call of toolCalls) {
if (tool_call.name === "task" || isInternalToolCallName(tool_call.name)) {
continue;
}
const write = tool_call.name === "write_file" && tool_call.id
? writes.get(tool_call.id)
: undefined;
const args = write
? { ...tool_call.args, path: write.path, content: write.content }
: tool_call.args;
const writePath = typeof args?.path === "string" ? args.path : "";
const write = tool_call.name === "write_file" && tool_call.id
? writes.get(tool_call.id)
: undefined;
const args = write
? { ...tool_call.args, path: write.path, content: write.content }
: tool_call.args;
const writePath = typeof args?.path === "string" ? args.path : "";
if (
(
tool_call.name === "write_file" ||
@@ -992,27 +988,27 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
messageId: message.id,
type: "toolCall",
name: tool_call.name,
args,
args,
};
const toolCallId = tool_call.id;
if (toolCallId) {
let toolCallResult = findToolCallResult(
let toolCallResult = findToolCallResult(
toolCallId,
messages,
tool_call.name,
{ allowNameFallback: false },
{ allowNameFallback: false },
);
if (toolCallResult === undefined) {
const nameKey = tool_call.name.toLowerCase();
const fallbackIndex = toolCallNameFallbackIndexes.get(nameKey) ?? 0;
toolCallResult = findToolCallResult(
toolCallId,
messages,
tool_call.name,
{ allowNameFallback: true, nameFallbackIndex: fallbackIndex },
);
toolCallNameFallbackIndexes.set(nameKey, fallbackIndex + 1);
}
if (toolCallResult === undefined) {
const nameKey = tool_call.name.toLowerCase();
const fallbackIndex = toolCallNameFallbackIndexes.get(nameKey) ?? 0;
toolCallResult = findToolCallResult(
toolCallId,
messages,
tool_call.name,
{ allowNameFallback: true, nameFallbackIndex: fallbackIndex },
);
toolCallNameFallbackIndexes.set(nameKey, fallbackIndex + 1);
}
if (toolCallResult !== undefined) {
if (typeof toolCallResult === "string") {
try {
@@ -1079,48 +1075,48 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
);
}
function rankDisplaySources(
sources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
return sources
.map((source, originalOrder) => ({ source, originalOrder }))
.sort((left, right) => {
const leftScore =
typeof left.source.score === "number" && Number.isFinite(left.source.score)
? left.source.score
: Number.NEGATIVE_INFINITY;
const rightScore =
typeof right.source.score === "number" && Number.isFinite(right.source.score)
? right.source.score
: Number.NEGATIVE_INFINITY;
return rightScore - leftScore || left.originalOrder - right.originalOrder;
})
.map(({ source }, index) => ({ ...source, index: index + 1 }));
}
function syncDisplaySourceIndexes(
sources: RuntimeDisplaySource[],
globalSources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
const indexByCitationId = new Map(
globalSources.flatMap((source) => {
const citationId =
typeof source.raw?.citation_id === "string"
? source.raw.citation_id.trim()
: "";
return citationId ? [[citationId, source.index] as const] : [];
}),
);
return sources.map((source) => {
const citationId =
typeof source.raw?.citation_id === "string"
? source.raw.citation_id.trim()
: "";
const index = citationId ? indexByCitationId.get(citationId) : undefined;
return index ? { ...source, index } : source;
});
}
function rankDisplaySources(
sources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
return sources
.map((source, originalOrder) => ({ source, originalOrder }))
.sort((left, right) => {
const leftScore =
typeof left.source.score === "number" && Number.isFinite(left.source.score)
? left.source.score
: Number.NEGATIVE_INFINITY;
const rightScore =
typeof right.source.score === "number" && Number.isFinite(right.source.score)
? right.source.score
: Number.NEGATIVE_INFINITY;
return rightScore - leftScore || left.originalOrder - right.originalOrder;
})
.map(({ source }, index) => ({ ...source, index: index + 1 }));
}
function syncDisplaySourceIndexes(
sources: RuntimeDisplaySource[],
globalSources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
const indexByCitationId = new Map(
globalSources.flatMap((source) => {
const citationId =
typeof source.raw?.citation_id === "string"
? source.raw.citation_id.trim()
: "";
return citationId ? [[citationId, source.index] as const] : [];
}),
);
return sources.map((source) => {
const citationId =
typeof source.raw?.citation_id === "string"
? source.raw.citation_id.trim()
: "";
const index = citationId ? indexByCitationId.get(citationId) : undefined;
return index ? { ...source, index } : source;
});
}
function SearchResultCard({ item }: { item: SearchResultItem }) {
const [open, setOpen] = useState(false);
const content = item.snippet ?? item.content;
@@ -1158,46 +1154,46 @@ function SearchResultCard({ item }: { item: SearchResultItem }) {
</div>
)}
</button>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
<SheetTitle className="text-base leading-relaxed">
{item.title ?? "检索结果"}
</SheetTitle>
{(sourceLabel || date) && (
<SheetDescription className="flex items-center gap-2 text-xs">
{sourceLabel && <span>{sourceLabel}</span>}
{sourceLabel && date && <span>·</span>}
{date && <span>{date}</span>}
</SheetDescription>
)}
{!sourceLabel && !date && (
<SheetDescription className="sr-only">检索结果详情</SheetDescription>
)}
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6">
<div className="mx-auto max-w-3xl space-y-4">
{content ? (
<article className="text-foreground rounded-xl border bg-background px-4 py-5 text-sm leading-7 whitespace-pre-wrap shadow-sm sm:px-6">
{content}
</article>
) : (
<div className="flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
暂无可展示的检索内容
</div>
)}
{item.url && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{item.url}
</div>
)}
</div>
</div>
</SheetContent>
</Sheet>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
>
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
<SheetTitle className="text-base leading-relaxed">
{item.title ?? "检索结果"}
</SheetTitle>
{(sourceLabel || date) && (
<SheetDescription className="flex items-center gap-2 text-xs">
{sourceLabel && <span>{sourceLabel}</span>}
{sourceLabel && date && <span>·</span>}
{date && <span>{date}</span>}
</SheetDescription>
)}
{!sourceLabel && !date && (
<SheetDescription className="sr-only">检索结果详情</SheetDescription>
)}
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6">
<div className="mx-auto max-w-3xl space-y-4">
{content ? (
<article className="text-foreground rounded-xl border bg-background px-4 py-5 text-sm leading-7 whitespace-pre-wrap shadow-sm sm:px-6">
{content}
</article>
) : (
<div className="flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
暂无可展示的检索内容
</div>
)}
{item.url && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{item.url}
</div>
)}
</div>
</div>
</SheetContent>
</Sheet>
</>
);
}
File diff suppressed because it is too large. Load diff
@@ -55,9 +55,8 @@ test("completed and replayed retrieval traces start collapsed with their hit sum
assert.match(html, /aria-label="展开智能检索过程"/);
assert.match(html, /aria-expanded="false"/);
assert.match(html, /调用知识库 2 次 · 原始文档命中 3 条 · Wiki 命中 4 条/);
assert.match(html, /共 2 轮 · 原始文档命中 3 条 · Wiki 命中 4 条/);
assert.match(html, /累计耗时 1\.8 秒/);
assert.doesNotMatch(html, /共\s*\d+\s*轮|第\s*\d+\s*轮检索/);
});
test("keeps a current pending tool call in loading state before its started frame arrives", () => {
@@ -168,12 +167,11 @@ test("completed retrieval can be collapsed while the final answer is still strea
assert.match(html, /data-state="open"/);
assert.match(html, /检索完成/);
assert.match(html, /aria-label="收起智能检索过程"/);
assert.match(html, /检索 Wiki/);
assert.doesNotMatch(html, /第\s*\d+\s*轮检索/);
assert.match(html, /第 2 轮检索/);
assert.doesNotMatch(html, /检索中/);
});
test("does not expose internal round numbering when retrieval cards are interleaved with skills", () => {
test("keeps global round numbering when retrieval cards are interleaved with skills", () => {
const html = renderToStaticMarkup(
<SmartKnowledgeRetrievalTrace
messages={[]}
@@ -183,8 +181,8 @@ test("does not expose internal round numbering when retrieval cards are interlea
/>,
);
assert.match(html, /检索原始文档/);
assert.doesNotMatch(html, /第\s*\d+\s*轮检索/);
assert.match(html, /第 3 轮检索/);
assert.doesNotMatch(html, /第 1 轮检索/);
});
test("renders stable retrieval results as right-drawer controls", () => {
@@ -401,6 +399,6 @@ test("uses the whole turn as the dedupe scope for interleaved retrieval cards",
(repeatedHtml.match(/data-retrieval-result-detail="true"/g) ?? []).length,
0,
);
assert.match(repeatedHtml, /本次查询结果均已在前面展示/);
assert.match(repeatedHtml, /本轮结果均为已展示结果/);
assert.doesNotMatch(repeatedHtml, /命中 1 条/);
});
@@ -40,9 +40,7 @@ function actionStatusText(action: SmartKnowledgeRetrievalAction): string {
if (action.kind === "search") {
return `${partialPrefix}命中 ${action.resultCount ?? 0} 条`;
}
return typeof action.resultCount === "number"
? `${partialPrefix}读取完成 · ${action.resultCount} 条`
: `${partialPrefix}读取完成`;
return `${partialPrefix}读取完成`;
}
function formatElapsedTime(elapsedMs: number): string {
@@ -182,7 +180,7 @@ function RetrievalAction({
{action.allResultsAlreadyShown ? (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
本次查询结果均已在前面展示
本轮结果均为已展示结果
</p>
) : action.resultItems.length > 0 ? (
<div className="mt-2">
@@ -309,6 +307,7 @@ export function SmartKnowledgeRetrievalTrace({
dedupeScopeMessages,
dedupeScopeProgressFrames,
isLoading,
roundOffset = 0,
onOpenResult,
}: {
messages: Message[];
@@ -321,7 +320,6 @@ export function SmartKnowledgeRetrievalTrace({
dedupeScopeMessages?: Message[];
dedupeScopeProgressFrames?: KnowledgeRetrievalProgressFrame[];
isLoading: boolean;
/** @deprecated 仅为兼容历史分段调用,展示层不再暴露检索轮次。 */
roundOffset?: number;
onOpenResult?: (
item: SmartKnowledgeRetrievalResultItem,
@@ -488,7 +486,7 @@ export function SmartKnowledgeRetrievalTrace({
</div>
{!isActive && (
<p className="mt-1 text-xs text-muted-foreground">
调用知识库 {actions.length} 次 · 原始文档命中 {rawHits} 条 · Wiki 命中 {wikiHits} 条
共 {rounds.length} 轮 · 原始文档命中 {rawHits} 条 · Wiki 命中 {wikiHits} 条
{typeof cumulativeElapsedMs === "number"
? ` · 累计耗时 ${formatElapsedTime(cumulativeElapsedMs)}`
: ""}
@@ -510,13 +508,28 @@ export function SmartKnowledgeRetrievalTrace({
)}
<div className="mt-3 space-y-3">
{actions.map((action) => (
<RetrievalAction
key={action.id}
action={action}
onOpenResult={onOpenResult}
/>
))}
{rounds.map((round) => {
const displayRound = round.round + roundOffset;
return (
<div key={displayRound}>
<div className="mb-1.5 flex items-center gap-2 px-0.5 text-xs font-medium text-muted-foreground">
<span className="flex size-5 items-center justify-center rounded-full bg-muted text-[11px] text-foreground/75">
{displayRound}
</span>
<span>第 {displayRound} 轮检索</span>
</div>
<div className="space-y-2">
{round.actions.map((action) => (
<RetrievalAction
key={action.id}
action={action}
onOpenResult={onOpenResult}
/>
))}
</div>
</div>
);
})}
</div>
</CollapsibleContent>
</section>
@@ -13,8 +13,6 @@ 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";
@@ -144,17 +142,9 @@ export function QuestionNavigator({
}, [visible, questions]);
const scrollToQuestion = useCallback((anchorId: string) => {
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();
}
document
.getElementById(anchorId)
?.scrollIntoView({ behavior: "smooth", block: "start" });
setActiveId(anchorId);
}, []);
@@ -7,9 +7,6 @@ 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";
@@ -60,11 +57,6 @@ 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.
@@ -1,297 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Clock3Icon, DatabaseIcon, GlobeIcon, RefreshCwIcon, SaveIcon, SearchIcon } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { getAuditRetentionStatus, setAuditRetentionPaused, type AuditRetentionStatus } from "@/core/admin/audit-retention";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
type CacheCollection = {
id: string;
label: string;
paths: string[];
scope: string;
enabled: boolean;
ttl_seconds: number;
};
type ApiReadCacheStatus = {
enabled: boolean;
redis_configured: boolean;
ttl_seconds: number;
max_value_bytes: number;
browser_status_enabled: boolean;
collections: CacheCollection[];
};
type ApiReadCacheUpdate = {
enabled?: boolean;
ttl_seconds?: number;
browser_status_enabled?: boolean;
collection_overrides?: Record<string, boolean>;
};
async function requestStatus(update?: ApiReadCacheUpdate): Promise<ApiReadCacheStatus> {
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/api-read-cache`, update ? {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(update),
} : undefined);
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(typeof data.detail === "string" ? data.detail : `${update ? "保存" : "读取"}失败(${response.status})`);
}
return data as ApiReadCacheStatus;
}
function retentionLabel(status: string) {
switch (status) {
case "running": return "正在清理";
case "pausing": return "正在停止";
case "paused": return "已关闭";
case "error": return "本轮异常";
default: return "等待执行";
}
}
function dateTime(value?: string | number | null) {
if (!value) return "暂无";
const date = new Date(typeof value === "number" ? value * 1000 : value);
return Number.isNaN(date.getTime()) ? "暂无" : date.toLocaleString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false });
}
export function ApiReadCacheSettingsPage() {
const [status, setStatus] = useState<ApiReadCacheStatus>();
const [retention, setRetention] = useState<AuditRetentionStatus>();
const [ttlInput, setTtlInput] = useState("300");
const [search, setSearch] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [retentionError, setRetentionError] = useState("");
const refresh = async () => {
setBusy(true);
const [cacheResult, retentionResult] = await Promise.allSettled([
requestStatus(),
getAuditRetentionStatus(),
]);
if (cacheResult.status === "fulfilled") {
setStatus(cacheResult.value);
setTtlInput(String(cacheResult.value.ttl_seconds));
setError("");
} else {
setError(cacheResult.reason instanceof Error ? cacheResult.reason.message : "读取缓存设置失败");
}
if (retentionResult.status === "fulfilled") {
setRetention(retentionResult.value);
setRetentionError("");
} else {
setRetentionError(retentionResult.reason instanceof Error ? retentionResult.reason.message : "读取清理设置失败");
}
setBusy(false);
};
useEffect(() => { void refresh(); }, []);
const save = async (update: ApiReadCacheUpdate, message: string) => {
setBusy(true);
try {
const saved = await requestStatus(update);
setStatus(saved);
if (update.ttl_seconds !== undefined) setTtlInput(String(saved.ttl_seconds));
setError("");
toast.success(message);
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存设置失败");
} finally {
setBusy(false);
}
};
const toggleRetention = async (enabled: boolean) => {
setBusy(true);
try {
const saved = await setAuditRetentionPaused(!enabled);
setRetention(saved);
setRetentionError("");
toast.success(enabled ? "定时清理已开启" : saved.pause_requested ? "已关闭定时清理,当前批次结束后停止" : "定时清理已关闭");
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存清理设置失败");
} finally {
setBusy(false);
}
};
const parsedTtl = Number(ttlInput);
const validTtl = Number.isInteger(parsedTtl) && parsedTtl >= 1 && parsedTtl <= 3600;
const collections = status?.collections ?? [];
const searchText = search.trim().toLowerCase();
const visibleCollections = collections.filter((item) =>
[item.label, item.id, item.scope, ...item.paths].join(" ").toLowerCase().includes(searchText),
);
const enabledCount = collections.filter((item) => item.enabled).length;
return (
<section className="space-y-5" data-testid="api-read-cache-settings" aria-busy={busy}>
<header className="flex flex-wrap items-start justify-between gap-3">
<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">统一管理接口缓存、浏览器状态查询和历史数据定时清理。</p>
</div>
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => void refresh()}>
<RefreshCwIcon className={`size-4 ${busy ? "animate-spin" : ""}`} />刷新状态
</Button>
</header>
{error && <p role="alert" className="rounded-lg border border-destructive/30 p-3 text-sm text-destructive">{error}</p>}
<div className="overflow-hidden rounded-xl border">
<div className="flex items-center justify-between gap-4 bg-muted/30 p-4">
<div>
<h3 className="font-medium">Redis 读取缓存总开关</h3>
<p className="mt-1 text-sm text-muted-foreground">开关和有效期保存在 MySQL,服务重启后仍生效。</p>
</div>
<Switch
checked={status?.enabled ?? false}
disabled={!status || busy}
onCheckedChange={(enabled) => void save({ enabled }, enabled ? "Redis 读取缓存已开启" : "Redis 读取缓存已关闭")}
aria-label="启用 Redis 读取缓存"
/>
</div>
<div className="space-y-3 border-t p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<label htmlFor="api-cache-ttl" className="text-sm font-medium">默认缓存有效期</label>
<p id="api-cache-ttl-help" className="mt-1 text-xs text-muted-foreground">1–3600 秒,默认 300 秒;保存后刷新缓存。</p>
</div>
<div className="flex items-center gap-2">
<Input
id="api-cache-ttl"
type="number"
min={1}
max={3600}
step={1}
value={ttlInput}
disabled={!status || busy}
onChange={(event) => setTtlInput(event.target.value)}
aria-label="Redis 缓存有效期(秒)"
aria-describedby="api-cache-ttl-help"
aria-invalid={!validTtl}
className="w-24"
/>
<span className="text-sm text-muted-foreground">秒</span>
<Button type="button" size="sm" variant="outline" disabled={!status || busy || !validTtl || parsedTtl === status.ttl_seconds} onClick={() => void save({ ttl_seconds: parsedTtl }, "Redis 缓存有效期已保存")}>
<SaveIcon className="size-4" />保存有效期
</Button>
</div>
</div>
{status && <div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-xs text-muted-foreground">
<Badge variant="secondary">Redis 地址{status.redis_configured ? "已配置" : "未配置"}</Badge>
<span>单条结果上限 {Math.round(status.max_value_bytes / 1024)} KiB</span>
<span>关闭或连接失败时自动使用原查询</span>
</div>}
{status && !status.redis_configured && <p className="text-xs text-amber-700 dark:text-amber-400">当前未配置 Redis 地址。可以保存开关,但配置连接后才会使用缓存。</p>}
</div>
</div>
<div className="rounded-xl border p-4" data-testid="browser-status-control">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h3 className="flex items-center gap-2 font-medium"><GlobeIcon className="size-4 text-primary" />允许浏览器状态查询</h3>
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">GET /api/browser-context/status</p>
<p className="mt-2 text-sm text-muted-foreground">控制浏览器插件能否访问状态接口;关闭后,服务器会拒绝该接口的查询。</p>
</div>
<Switch
checked={status?.browser_status_enabled ?? false}
disabled={!status || busy}
onCheckedChange={(enabled) => void save({ browser_status_enabled: enabled }, enabled ? "浏览器状态查询已允许" : "浏览器状态查询已关闭")}
aria-label="允许浏览器状态查询"
/>
</div>
</div>
<div className="space-y-3 rounded-xl border p-4" data-testid="audit-retention-control">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="flex items-center gap-2 font-medium"><Clock3Icon className="size-4 text-primary" />开启历史数据定时清理</h3>
<p className="mt-1 text-sm text-muted-foreground">分批清理超过 {retention?.retention_days ?? 90} 天的工具调用、模型调用、已结束任务摘要和排行榜快照。</p>
</div>
<Switch
checked={retention ? !retention.paused : false}
disabled={!retention || busy}
onCheckedChange={(enabled) => void toggleRetention(enabled)}
aria-label="开启历史数据定时清理"
/>
</div>
<p className="text-xs text-muted-foreground">包含 tool_call_metrics、llm_call_metrics;关闭后在当前小批次结束时停止,关闭状态在服务重启后保留。</p>
{retentionError && <p role="alert" className="text-sm text-destructive">{retentionError}</p>}
{retention && <div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg bg-muted/40 px-3 py-2 text-xs">
<Badge variant={retention.display_status === "error" ? "destructive" : "secondary"}>{retentionLabel(retention.display_status)}</Badge>
<span className="text-muted-foreground">间隔 {Math.round((retention.interval_seconds ?? 900) / 60)} 分钟</span>
<span className="text-muted-foreground">下次执行:{retention.paused ? "已关闭" : dateTime(retention.next_run_at)}</span>
<span className="text-muted-foreground">最近完成:{dateTime(retention.last_sweep?.finished_at)}</span>
</div>}
{retention?.pause_requested && <p role="status" className="text-sm text-amber-700 dark:text-amber-400">关闭请求已保存,正在等待当前批次结束。可点击“刷新状态”查看结果。</p>}
{retention?.last_error && <p role="alert" className="text-sm text-destructive">{retention.last_error}</p>}
</div>
<div className="space-y-3" data-testid="cache-collection-list">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="font-medium">已接入 Redis 的接口</h3>
<p className="mt-1 text-xs text-muted-foreground">共 {collections.length} 组,单项开启 {enabledCount} 组;同组接口共用开关。</p>
</div>
<div className="relative w-full sm:w-56">
<SearchIcon className="pointer-events-none absolute top-2.5 left-3 size-4 text-muted-foreground" />
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索名称或接口地址" aria-label="搜索缓存接口" className="pl-9" />
</div>
</div>
<p className="text-xs text-muted-foreground">每组可独立开关,数据修改后缓存失效。总开关关闭时仍可配置单项,重新开启总开关后生效;各接口实际有效期见列表。</p>
{status && !status.enabled && <p className="rounded-lg bg-muted px-3 py-2 text-sm">总开关已关闭,当前所有接口均使用原查询。</p>}
<div className="overflow-x-auto rounded-xl border">
<table className="w-full table-fixed text-left text-sm">
<thead className="bg-muted/40 text-xs text-muted-foreground">
<tr>
<th className="w-[56%] px-4 py-3 font-medium" scope="col">接口 / 缓存范围</th>
<th className="w-[18%] px-2 py-3 font-medium" scope="col">有效期</th>
<th className="w-[26%] px-4 py-3 text-right font-medium" scope="col">使用 Redis</th>
</tr>
</thead>
<tbody className="divide-y">
{visibleCollections.map((item) => {
const effective = item.enabled && status?.enabled && status.redis_configured;
const description = !item.enabled ? "单项已关闭" : !status?.enabled ? "总开关关闭" : !status.redis_configured ? "待配置连接" : "缓存已启用";
return <tr key={item.id} data-testid={`cache-collection-${item.id}`}>
<td className="px-4 py-3 align-top">
<p className="font-medium">{item.label}</p>
<div className="mt-1 space-y-1 text-[11px] text-muted-foreground">
{item.paths.map((path) => <p key={path} className="break-all font-mono">{path}</p>)}
</div>
<p className="mt-1 text-xs text-muted-foreground">{item.scope}</p>
</td>
<td className="px-2 py-3 align-top text-xs">{item.ttl_seconds} 秒</td>
<td className="px-4 py-3 text-right align-top">
<Switch
checked={item.enabled}
disabled={busy}
onCheckedChange={(enabled) => void save({ collection_overrides: { [item.id]: enabled } }, `${item.label}缓存${enabled ? "已开启" : "已关闭"}`)}
aria-label={`${item.label}使用 Redis 缓存`}
/>
<p className={`mt-1 text-[11px] ${effective ? "text-primary" : "text-muted-foreground"}`}>{description}</p>
</td>
</tr>;
})}
{!visibleCollections.length && <tr><td colSpan={3} className="px-4 py-8 text-center text-muted-foreground">{!status ? error ? "接口列表读取失败,请刷新重试" : "正在读取接口列表…" : searchText ? "没有匹配的接口" : "暂无已接入的接口"}</td></tr>}
</tbody>
</table>
</div>
</div>
</section>
);
}
@@ -1,273 +0,0 @@
"use client";
import { ImageUp, Loader2, RotateCcw } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useTheme } from "@/contexts/ThemeContext";
import {
getHeaderBrandAsset,
getHeaderBrandFallback,
} from "@/core/branding/brand-assets";
import { resolveAppIdentityLogoUrl } from "@/core/system-settings/api";
import {
useAppIdentitySettings,
useDeleteAppIdentitySettings,
useUpdateAppIdentitySettings,
} from "@/core/system-settings/hooks";
import { SettingsSection } from "./settings-section";
const MAX_LOGO_BYTES = 10 * 1024 * 1024;
const ACCEPTED_LOGO_TYPES = new Set([
"image/png",
"image/jpeg",
"image/webp",
]);
export function AppIdentitySettingsPage() {
const { theme } = useTheme();
const staticLogo = getHeaderBrandAsset(theme);
const staticFallback = getHeaderBrandFallback(theme);
const [staticLogoFailed, setStaticLogoFailed] = useState(false);
const { data: identity, isPending } = useAppIdentitySettings();
const updateIdentity = useUpdateAppIdentitySettings();
const deleteIdentity = useDeleteAppIdentitySettings();
const fileInputRef = useRef<HTMLInputElement>(null);
const [systemName, setSystemName] = useState("");
const [pendingLogo, setPendingLogo] = useState<File | null>(null);
const [removeLogo, setRemoveLogo] = useState(false);
const [resetOpen, setResetOpen] = useState(false);
useEffect(() => {
setSystemName(identity?.system_name ?? "");
setPendingLogo(null);
setRemoveLogo(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}, [identity?.system_name, identity?.logo_version, identity?.has_logo]);
const previewUrl = useMemo(
() => (pendingLogo ? URL.createObjectURL(pendingLogo) : undefined),
[pendingLogo],
);
useEffect(
() => () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
},
[previewUrl],
);
const savedLogoUrl = removeLogo
? undefined
: resolveAppIdentityLogoUrl(identity?.logo_url);
const displayedLogoUrl = previewUrl ?? savedLogoUrl;
const normalizedName = systemName.trim().replace(/\s+/g, " ");
const hasChanges =
normalizedName !== (identity?.system_name ?? "") ||
Boolean(pendingLogo) ||
removeLogo;
const customized = Boolean(identity?.system_name || identity?.has_logo);
const busy = updateIdentity.isPending || deleteIdentity.isPending;
const handleLogoChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!ACCEPTED_LOGO_TYPES.has(file.type)) {
toast.error("Logo 仅支持 PNG、JPG 或 WebP 图片");
event.target.value = "";
return;
}
if (file.size > MAX_LOGO_BYTES) {
toast.error("Logo 文件不能超过 10MB");
event.target.value = "";
return;
}
setPendingLogo(file);
setRemoveLogo(false);
};
const handleUseStaticLogo = () => {
setPendingLogo(null);
setRemoveLogo(true);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleSave = async () => {
try {
await updateIdentity.mutateAsync({
systemName: normalizedName,
logo: pendingLogo,
removeLogo,
});
toast.success("系统名称已保存", {
description: "导航、侧栏和欢迎区会使用这里的名称和 Logo。",
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "系统名称保存失败");
}
};
const handleReset = async () => {
try {
await deleteIdentity.mutateAsync();
setResetOpen(false);
toast.success("已恢复默认系统名称和 Logo");
} catch (error) {
toast.error(error instanceof Error ? error.message : "清除系统名称失败");
}
};
return (
<>
<SettingsSection
title="系统名称"
description="修改本系统导航栏、侧栏和欢迎区显示的名称与 Logo。名称留空时继续使用现有名称;没有上传 Logo 时继续使用内置静态图片。这里不会改「生成网页标识」。"
>
<div className="space-y-6">
<div className="space-y-2">
<label htmlFor="app-identity-name" className="text-sm font-medium">
系统名称
</label>
<Input
id="app-identity-name"
value={systemName}
onChange={(event) => setSystemName(event.target.value)}
placeholder="留空则继续使用 CM业务中心 / CM 助手"
maxLength={80}
disabled={isPending || busy}
/>
<div className="flex justify-between gap-4 text-xs text-muted-foreground">
<span>最多 80 个字符。</span>
<span className="tabular-nums">{systemName.length}/80</span>
</div>
</div>
<div className="space-y-3">
<div>
<div className="text-sm font-medium">系统 Logo</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
上传后,导航、侧栏和欢迎区都使用这一张图片。未上传时按当前主题使用内置 Logo。
</p>
</div>
<div className="grid gap-4 rounded-lg border bg-muted/20 p-4 sm:grid-cols-[160px_minmax(0,1fr)]">
<div className="flex h-28 items-center justify-center overflow-hidden rounded-lg border border-dashed bg-background p-3">
{displayedLogoUrl ? (
<img
src={displayedLogoUrl}
alt="系统 Logo 预览"
className="max-h-full max-w-full object-contain"
/>
) : (
<img
src={staticLogoFailed ? staticFallback : staticLogo}
alt="当前静态 Logo"
className="max-h-full max-w-full object-contain"
onError={() => setStaticLogoFailed(true)}
/>
)}
</div>
<div className="flex min-w-0 flex-col justify-center gap-3">
<input
ref={fileInputRef}
id="app-identity-logo"
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
onChange={handleLogoChange}
disabled={isPending || busy}
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
className="h-11"
onClick={() => fileInputRef.current?.click()}
disabled={isPending || busy}
>
<ImageUp aria-hidden />
{displayedLogoUrl ? "更换 Logo" : "上传 Logo"}
</Button>
<Button
type="button"
variant="outline"
className="h-11"
onClick={handleUseStaticLogo}
disabled={
isPending ||
busy ||
(!identity?.has_logo && !pendingLogo) ||
(removeLogo && !pendingLogo)
}
>
使用静态 Logo
</Button>
</div>
{pendingLogo && (
<p className="truncate text-xs text-muted-foreground" title={pendingLogo.name}>
待上传:{pendingLogo.name}
</p>
)}
{removeLogo && (
<p className="text-xs text-muted-foreground">
保存后恢复为内置静态 Logo。
</p>
)}
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-5">
<Button
type="button"
variant="outline"
className="h-11"
onClick={() => setResetOpen(true)}
disabled={!customized || busy}
>
<RotateCcw aria-hidden />
清除配置
</Button>
<Button
type="button"
className="h-11 min-w-28"
onClick={handleSave}
disabled={!hasChanges || isPending || busy}
>
{updateIdentity.isPending && <Loader2 className="animate-spin" aria-hidden />}
{updateIdentity.isPending ? "保存中..." : "保存系统名称"}
</Button>
</div>
</div>
</SettingsSection>
<Dialog open={resetOpen} onOpenChange={setResetOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>清除系统名称和 Logo?</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
清除后,导航和欢迎区恢复为原来的名称,Logo 恢复为内置静态图片。
</p>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setResetOpen(false)}>
取消
</Button>
<Button type="button" onClick={handleReset} disabled={busy}>
清除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -42,22 +42,6 @@ import { isIsolatedSession, setIsolatedSession } from "@/core/embed/embed-sessio
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks";
import { useLocalSettings } from "@/core/settings";
import {
AGENT_COMPOSER_WIDTH_MAX,
AGENT_COMPOSER_WIDTH_MIN,
COMPOSER_HEIGHT_MAX,
COMPOSER_HEIGHT_MIN,
COMPOSER_SCALE_DEFAULT,
COMPOSER_WIDTH_MAX,
COMPOSER_WIDTH_MIN,
isComposerWidthChoice,
normalizeAgentComposerWidthPercent,
normalizeComposerHeightPercent,
normalizeComposerWidthPercent,
resolveAgentComposerWidthPercent,
resolveComposerHeightPercent,
resolveComposerWidthPercent,
} from "@/core/settings/composer-width";
import {
useUpdateUserPreferences,
useUserPreferencesQuery,
@@ -66,16 +50,13 @@ import {
useAppearanceSettings,
useCitationDisplaySettings,
useHtmlMapSettings,
useHtmlRuntimeSettings,
useLayoutMode,
useUpdateAppearanceSettings,
useUpdateCitationDisplaySettings,
useUpdateHtmlMapSettings,
useUpdateHtmlRuntimeSettings,
} from "@/core/system-settings/hooks";
import type { HtmlMapEngine } from "@/core/system-settings/api";
import type {
AppearanceUpdate,
CompactShortcutButton,
CompactShortcutRouteParam,
} from "@/core/system-settings/api";
@@ -117,278 +98,6 @@ function newShortcutParam(): CompactShortcutRouteParam {
};
}
type ScaleAxisConfig = {
min: number;
max: number;
value: number;
normalize: (raw: string) => number;
onChange: (next: number) => void;
onCommit?: (next: number) => void;
};
function ScaleAxis({
title,
ariaLabel,
low,
high,
preview,
axis,
}: {
title: string;
ariaLabel: string;
low: string;
high: string;
preview: "width" | "height";
axis: ScaleAxisConfig;
}) {
const span = Math.max(1, axis.max - axis.min);
const previewPercent = 8 + ((axis.value - axis.min) / span) * 92;
const readValue = (raw: string) => axis.normalize(raw);
return (
<div className="space-y-2">
<div className="text-muted-foreground text-xs">{title}</div>
<input
type="range"
min={axis.min}
max={axis.max}
step={1}
value={axis.value}
aria-label={ariaLabel}
onChange={(event) => axis.onChange(readValue(event.target.value))}
onPointerUp={(event) =>
axis.onCommit?.(readValue(event.currentTarget.value))
}
onKeyUp={(event) =>
axis.onCommit?.(readValue(event.currentTarget.value))
}
className="accent-primary h-2 w-full cursor-pointer"
/>
{preview === "height" ? (
<div className="bg-muted/60 flex h-14 items-end rounded-lg border px-3 py-1.5">
<div
className="bg-background w-full rounded-md border shadow-sm"
style={{ height: `${previewPercent}%` }}
/>
</div>
) : (
<div className="bg-muted/60 flex h-11 items-center rounded-lg border px-3">
<div
className="bg-background h-7 rounded-md border shadow-sm"
style={{ width: `${previewPercent}%` }}
/>
</div>
)}
<div className="text-muted-foreground flex items-center justify-between text-xs">
<span>{low}</span>
<span>{high}</span>
</div>
</div>
);
}
function ComposerSizeCard({
label,
hint,
width,
height,
resetDisabled,
resetLabel = "恢复默认",
onReset,
}: {
label: string;
hint?: string;
width: ScaleAxisConfig;
height: ScaleAxisConfig;
resetDisabled?: boolean;
resetLabel?: string;
onReset: () => void;
}) {
return (
<div className="space-y-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">{label}</div>
{hint ? (
<p className="text-muted-foreground mt-1 text-xs leading-5">{hint}</p>
) : null}
</div>
<ScaleAxis
title="宽度"
ariaLabel={`${label}宽度`}
low="较窄"
high="较宽"
preview="width"
axis={width}
/>
<ScaleAxis
title="高度"
ariaLabel={`${label}高度`}
low="较矮"
high="较高"
preview="height"
axis={height}
/>
<div className="flex justify-center">
<Button
type="button"
variant="outline"
size="sm"
disabled={resetDisabled}
onClick={onReset}
>
{resetLabel}
</Button>
</div>
</div>
);
}
type ComposerDefaultKey =
| "widthFull"
| "heightFull"
| "widthCompact"
| "heightCompact"
| "agentWidth"
| "agentHeight";
const COMPOSER_DEFAULT_FIELDS: Record<ComposerDefaultKey, keyof AppearanceUpdate> = {
widthFull: "default_composer_width_full",
heightFull: "default_composer_height_full",
widthCompact: "default_composer_width_compact",
heightCompact: "default_composer_height_compact",
agentWidth: "default_agent_composer_width",
agentHeight: "default_agent_composer_height",
};
function AdminComposerSizeDefaults({
values,
saving,
onSave,
}: {
values: Record<ComposerDefaultKey, number>;
saving: boolean;
onSave: (patch: AppearanceUpdate) => Promise<void>;
}) {
const [draft, setDraft] = useState<Partial<Record<ComposerDefaultKey, number>>>({});
const shown = {
widthFull: draft.widthFull ?? values.widthFull,
heightFull: draft.heightFull ?? values.heightFull,
widthCompact: draft.widthCompact ?? values.widthCompact,
heightCompact: draft.heightCompact ?? values.heightCompact,
agentWidth: draft.agentWidth ?? values.agentWidth,
agentHeight: draft.agentHeight ?? values.agentHeight,
};
const commit = (key: ComposerDefaultKey, next: number) => {
if (next === values[key]) {
setDraft((prev) => ({ ...prev, [key]: undefined }));
return;
}
const field = COMPOSER_DEFAULT_FIELDS[key];
void onSave({ [field]: next }).finally(() => {
setDraft((prev) => ({ ...prev, [key]: undefined }));
});
};
const axis = (
key: ComposerDefaultKey,
min: number,
max: number,
normalize: (raw: string) => number,
): ScaleAxisConfig => ({
min,
max,
value: shown[key],
normalize,
onChange: (next) => setDraft((prev) => ({ ...prev, [key]: next })),
onCommit: (next) => commit(key, next),
});
const resetCard = (widthKey: ComposerDefaultKey, heightKey: ComposerDefaultKey) => {
setDraft((prev) => ({
...prev,
[widthKey]: COMPOSER_SCALE_DEFAULT,
[heightKey]: COMPOSER_SCALE_DEFAULT,
}));
const widthField = COMPOSER_DEFAULT_FIELDS[widthKey];
const heightField = COMPOSER_DEFAULT_FIELDS[heightKey];
void onSave({
[widthField]: COMPOSER_SCALE_DEFAULT,
[heightField]: COMPOSER_SCALE_DEFAULT,
}).finally(() => {
setDraft((prev) => ({
...prev,
[widthKey]: undefined,
[heightKey]: undefined,
}));
});
};
const cards: Array<{
key: string;
label: string;
hint?: string;
widthKey: ComposerDefaultKey;
heightKey: ComposerDefaultKey;
widthMin: number;
widthMax: number;
normalizeWidth: (raw: string) => number;
}> = [
{
key: "full",
label: "全量模式",
widthKey: "widthFull",
heightKey: "heightFull",
widthMin: COMPOSER_WIDTH_MIN,
widthMax: COMPOSER_WIDTH_MAX,
normalizeWidth: normalizeComposerWidthPercent,
},
{
key: "compact",
label: "简洁模式",
widthKey: "widthCompact",
heightKey: "heightCompact",
widthMin: COMPOSER_WIDTH_MIN,
widthMax: COMPOSER_WIDTH_MAX,
normalizeWidth: normalizeComposerWidthPercent,
},
{
key: "agent",
label: "智能体问答",
hint: "宽度最多铺满输入框所在的中间区域,不会超出两侧面板。",
widthKey: "agentWidth",
heightKey: "agentHeight",
widthMin: AGENT_COMPOSER_WIDTH_MIN,
widthMax: AGENT_COMPOSER_WIDTH_MAX,
normalizeWidth: normalizeAgentComposerWidthPercent,
},
];
return (
<div className="grid gap-3 lg:grid-cols-2">
{cards.map((card) => (
<ComposerSizeCard
key={card.key}
label={card.label}
hint={card.hint}
width={axis(
card.widthKey,
card.widthMin,
card.widthMax,
card.normalizeWidth,
)}
height={axis(
card.heightKey,
COMPOSER_HEIGHT_MIN,
COMPOSER_HEIGHT_MAX,
normalizeComposerHeightPercent,
)}
resetDisabled={
saving ||
(shown[card.widthKey] === COMPOSER_SCALE_DEFAULT &&
shown[card.heightKey] === COMPOSER_SCALE_DEFAULT)
}
onReset={() => resetCard(card.widthKey, card.heightKey)}
/>
))}
</div>
);
}
function newShortcutButton(): CompactShortcutButton {
return {
id: makeShortcutId(),
@@ -487,15 +196,6 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const updateCitationDisplay = useUpdateCitationDisplaySettings();
const { data: htmlMap } = useHtmlMapSettings();
const updateHtmlMap = useUpdateHtmlMapSettings();
const { data: htmlRuntime } = useHtmlRuntimeSettings();
const updateHtmlRuntime = useUpdateHtmlRuntimeSettings();
const [htmlRuntimeDraft, setHtmlRuntimeDraft] = useState<{
base_url: string;
map_tile_url: string;
} | null>(null);
const runtimeBaseUrl = htmlRuntimeDraft?.base_url ?? htmlRuntime?.base_url ?? "";
const runtimeTileUrl =
htmlRuntimeDraft?.map_tile_url ?? htmlRuntime?.map_tile_url ?? "";
const globalDefault = appearance?.default_theme ?? "light";
const globalSkillView = appearance?.default_skill_view ?? "list";
const globalAgentView = appearance?.default_agent_view ?? "card";
@@ -572,14 +272,6 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
}
};
const handleSetDefaultComposerSize = async (patch: AppearanceUpdate) => {
try {
await updateAppearance.mutateAsync(patch);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
}
};
const handleSetDefaultLayoutMode = async (value: "full" | "compact") => {
if (value === globalLayoutMode) return;
try {
@@ -844,160 +536,6 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
</div>
</SettingsSection>
<Separator />
<SettingsSection
title="输入框尺寸"
description="分别调整普通问答在全量模式、简洁模式下的输入框,以及智能体问答的输入框。没有自己调整时,使用管理员设置的系统默认。"
>
<div className="grid gap-3 lg:grid-cols-2">
<ComposerSizeCard
label="全量模式"
resetLabel="跟随系统默认"
resetDisabled={
!isComposerWidthChoice(settings.display.composerWidthFull) &&
!isComposerWidthChoice(settings.display.composerHeightFull)
}
width={{
min: COMPOSER_WIDTH_MIN,
max: COMPOSER_WIDTH_MAX,
value: resolveComposerWidthPercent(
settings.display.composerWidthFull,
appearance?.default_composer_width_full,
),
normalize: normalizeComposerWidthPercent,
onChange: (next) =>
setSettings("display", { composerWidthFull: next }),
}}
height={{
min: COMPOSER_HEIGHT_MIN,
max: COMPOSER_HEIGHT_MAX,
value: resolveComposerHeightPercent(
settings.display.composerHeightFull,
appearance?.default_composer_height_full,
),
normalize: normalizeComposerHeightPercent,
onChange: (next) =>
setSettings("display", { composerHeightFull: next }),
}}
onReset={() =>
setSettings("display", {
composerWidthFull: undefined,
composerHeightFull: undefined,
})
}
/>
<ComposerSizeCard
label="简洁模式"
resetLabel="跟随系统默认"
resetDisabled={
!isComposerWidthChoice(settings.display.composerWidthCompact) &&
!isComposerWidthChoice(settings.display.composerHeightCompact)
}
width={{
min: COMPOSER_WIDTH_MIN,
max: COMPOSER_WIDTH_MAX,
value: resolveComposerWidthPercent(
settings.display.composerWidthCompact,
appearance?.default_composer_width_compact,
),
normalize: normalizeComposerWidthPercent,
onChange: (next) =>
setSettings("display", { composerWidthCompact: next }),
}}
height={{
min: COMPOSER_HEIGHT_MIN,
max: COMPOSER_HEIGHT_MAX,
value: resolveComposerHeightPercent(
settings.display.composerHeightCompact,
appearance?.default_composer_height_compact,
),
normalize: normalizeComposerHeightPercent,
onChange: (next) =>
setSettings("display", { composerHeightCompact: next }),
}}
onReset={() =>
setSettings("display", {
composerWidthCompact: undefined,
composerHeightCompact: undefined,
})
}
/>
<ComposerSizeCard
label="智能体问答"
hint="宽度最多铺满输入框所在的中间区域,不会超出两侧面板。"
resetLabel="跟随系统默认"
resetDisabled={
!isComposerWidthChoice(settings.display.agentComposerWidth) &&
!isComposerWidthChoice(settings.display.agentComposerHeight)
}
width={{
min: AGENT_COMPOSER_WIDTH_MIN,
max: AGENT_COMPOSER_WIDTH_MAX,
value: resolveAgentComposerWidthPercent(
settings.display.agentComposerWidth,
appearance?.default_agent_composer_width,
),
normalize: normalizeAgentComposerWidthPercent,
onChange: (next) =>
setSettings("display", { agentComposerWidth: next }),
}}
height={{
min: COMPOSER_HEIGHT_MIN,
max: COMPOSER_HEIGHT_MAX,
value: resolveComposerHeightPercent(
settings.display.agentComposerHeight,
appearance?.default_agent_composer_height,
),
normalize: normalizeComposerHeightPercent,
onChange: (next) =>
setSettings("display", { agentComposerHeight: next }),
}}
onReset={() =>
setSettings("display", {
agentComposerWidth: undefined,
agentComposerHeight: undefined,
})
}
/>
</div>
</SettingsSection>
{isAdmin && (
<>
<Separator />
<SettingsSection
title="系统默认输入框尺寸(管理员)"
description="给还没有自己调整过输入框尺寸的用户使用。已经调整过的人保持自己的尺寸。"
>
<AdminComposerSizeDefaults
values={{
widthFull: normalizeComposerWidthPercent(
appearance?.default_composer_width_full,
),
heightFull: normalizeComposerHeightPercent(
appearance?.default_composer_height_full,
),
widthCompact: normalizeComposerWidthPercent(
appearance?.default_composer_width_compact,
),
heightCompact: normalizeComposerHeightPercent(
appearance?.default_composer_height_compact,
),
agentWidth: normalizeAgentComposerWidthPercent(
appearance?.default_agent_composer_width,
),
agentHeight: normalizeComposerHeightPercent(
appearance?.default_agent_composer_height,
),
}}
saving={updateAppearance.isPending}
onSave={handleSetDefaultComposerSize}
/>
</SettingsSection>
</>
)}
{isAdmin && (
<>
<Separator />
@@ -1392,7 +930,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
<div>
<div className="text-sm font-medium">HTML 地图</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
管理员配置。生成带地图的 HTML 网页时使用哪种地图。ECharts 使用本地行政区划;地图网页使用 Leaflet 瓦片,瓦片地址在下面的「地图瓦片地址」里填写。下一次生成的页面生效。
管理员配置。生成带地图的 HTML 网页时使用哪种地图。ECharts 使用本地行政区划;地图网页使用 Leaflet 瓦片,地址在后台 config.yaml 的 html_runtime.map_tile_url。下一次生成的页面生效。
</p>
</div>
<Select
@@ -1415,77 +953,6 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
</Select>
</div>
)}
{isAdmin && (
<div className="rounded-lg border p-4">
<div className="text-sm font-medium">HTML 运行地址</div>
<p className="text-muted-foreground mt-1 max-w-2xl text-xs leading-relaxed">
管理员配置。画网页时,图表、流程图、地图脚本和上传图片都从这里加载。请填写用户浏览器真正能够打开的地址。只填写域名时,会自动补上 /api/public/html-runtime;如果前面还有访问前缀,请写成完整地址,例如 https://域名/前缀/api/public/html-runtime。保存后只影响之后新画的网页。
</p>
<label className="mt-3 block text-xs font-medium" htmlFor="html-runtime-base-url">
运行库地址
</label>
<Input
id="html-runtime-base-url"
value={runtimeBaseUrl}
disabled={!htmlRuntime || updateHtmlRuntime.isPending}
onChange={(event) =>
setHtmlRuntimeDraft({
base_url: event.target.value,
map_tile_url: runtimeTileUrl,
})
}
placeholder="https://你的域名/api/public/html-runtime"
className="mt-1 font-mono text-xs"
spellCheck={false}
/>
<label className="mt-3 block text-xs font-medium" htmlFor="html-map-tile-url">
地图瓦片地址
</label>
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">
地图网页使用的瓦片模板,需要包含 {"{z}"}、{"{x}"}、{"{y}"}。浏览器重新打开页面后生效。
</p>
<Input
id="html-map-tile-url"
value={runtimeTileUrl}
disabled={!htmlRuntime || updateHtmlRuntime.isPending}
onChange={(event) =>
setHtmlRuntimeDraft({
base_url: runtimeBaseUrl,
map_tile_url: event.target.value,
})
}
placeholder="https://tiles.example/{z}/{x}/{y}.png"
className="mt-1 font-mono text-xs"
spellCheck={false}
/>
<div className="mt-3 flex justify-end">
<Button
type="button"
size="sm"
disabled={
!htmlRuntime ||
updateHtmlRuntime.isPending ||
(runtimeBaseUrl === htmlRuntime.base_url &&
runtimeTileUrl === htmlRuntime.map_tile_url)
}
onClick={() =>
updateHtmlRuntime.mutate(
{ base_url: runtimeBaseUrl.trim(), map_tile_url: runtimeTileUrl.trim() },
{
onSuccess: () => {
setHtmlRuntimeDraft(null);
toast.success("HTML 运行地址已保存,之后新画的网页会使用这个地址");
},
onError: (error) => toast.error(error.message || "保存失败"),
},
)
}
>
{updateHtmlRuntime.isPending ? "保存中..." : "保存地址"}
</Button>
</div>
</div>
)}
{isAdmin && (
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
@@ -1,253 +0,0 @@
"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";
import {
ACTIVE_ARCHIVE_JOB_STATUSES as ACTIVE,
archiveMonthDisplayStatus,
canPurgeArchivedMonth,
isArchiveMonthActive,
} from "@/core/chat-archive-state";
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[];
};
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 "cancelling": 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 confirmation = action === "purge"
? "确认清理该月已归档的 PostgreSQL 数据?系统会逐会话重新校验 SQLite 备份,只清理校验通过的会话;失败、未归档和正在运行的会话不会清理。"
: "确认回滚到 PostgreSQL?月度 SQLite 文件中的归档记录会保留。";
if (action !== "archive" && !window.confirm(confirmation)) 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 ?? "月份按会话创建时间展示;归档任务在后台逐会话处理,页面关闭不影响执行;中断后可重新执行。"} 单个会话失败会记录错误并继续处理其他会话;部分归档完成后也可清理已校验的 PG 数据,未归档或校验失败的会话会保留。磁盘不足等整体故障仍会停止任务。删除 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 = isArchiveMonthActive(month);
const displayStatus = archiveMonthDisplayStatus(month);
const canPurge = canPurgeArchivedMonth(month);
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(displayStatus)}>{statusLabel(displayStatus)}</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 || "任务状态已更新"}</div>
<div className="text-muted-foreground mt-1 text-xs">
已处理 {job.processed_threads.toLocaleString()} / {job.total_threads.toLocaleString()} 个
{job.failed_threads > 0 && ` · 失败或跳过 ${job.failed_threads.toLocaleString()} 个`}
</div>
{job.failed_threads > 0 && <div className="text-muted-foreground mt-1 text-xs">{ACTIVE.has(job.status) ? "将继续处理其他会话,未完成项可稍后重试。" : "未完成项可重新执行;已有备份不会因单个会话失败而丢弃。"}</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">{ACTIVE.has(job.status) ? "当前会话" : "最后处理会话"}:{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" />{displayStatus === "interrupted" || displayStatus === "partial" ? "重新归档并校验" : 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>
);
}
@@ -1,11 +1,9 @@
"use client";
import { ListOrderedIcon, Loader2Icon, ShrinkIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Loader2Icon, ShrinkIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
useConversationContextSettings,
@@ -18,35 +16,6 @@ export function ContextWindowSettingsPage() {
const update = useUpdateConversationContextSettings();
const enabled = data?.recent_turn_window_enabled ?? false;
const recentTurns = data?.recent_turns ?? 3;
const currentRoundLimit = data?.max_conversation_rounds ?? 20;
const [roundLimit, setRoundLimit] = useState(String(currentRoundLimit));
const [roundLimitTouched, setRoundLimitTouched] = useState(false);
const parsedRoundLimit = Number(roundLimit);
const roundLimitInvalid =
!Number.isInteger(parsedRoundLimit) ||
parsedRoundLimit < 1 ||
parsedRoundLimit > 50;
const useQuestionAsTitle = data?.use_question_as_title ?? false;
const [stepDraft, setStepDraft] = useState<string | null>(null);
const stepValue = stepDraft ?? String(data?.max_run_steps ?? 1000);
const steps = Number(stepValue);
const validSteps = stepValue.trim() !== "" && Number.isInteger(steps) && steps >= 20 && steps <= 10000;
useEffect(() => {
setRoundLimit(String(currentRoundLimit));
setRoundLimitTouched(false);
}, [currentRoundLimit]);
const saveSteps = async () => {
if (!validSteps) return;
try {
await update.mutateAsync({ max_run_steps: steps });
setStepDraft(null);
toast.success("单次问答步骤上限已保存,从下一次问答生效");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleChange = async (checked: boolean) => {
try {
@@ -61,33 +30,6 @@ export function ContextWindowSettingsPage() {
}
};
const saveRoundLimit = async () => {
setRoundLimitTouched(true);
if (roundLimitInvalid) {
toast.error("对话轮数限制请填写 1 到 50 的整数");
return;
}
try {
await update.mutateAsync({ max_conversation_rounds: parsedRoundLimit });
toast.success(`单个对话最多可进行 ${parsedRoundLimit} 轮`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleQuestionTitleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ use_question_as_title: checked });
toast.success(
checked
? "已开启:对话标题使用首个问题的前 20 个字"
: "已关闭:恢复回答后的标题生成",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
@@ -96,7 +38,7 @@ export function ContextWindowSettingsPage() {
上下文管理
</h3>
<p className="text-muted-foreground text-sm">
管理对话历史范围、单个对话轮数、标题生成方式和单次问答步骤上限。设置对所有用户生效,仅管理员可修改。
控制长对话发给模型的历史范围。该模式默认关闭,只有管理员显式开启后才会生效。
</p>
</div>
@@ -120,105 +62,6 @@ export function ContextWindowSettingsPage() {
)}
</div>
<div className="flex flex-wrap items-end justify-between gap-4 rounded-lg border p-4">
<div className="min-w-0 space-y-1">
<label
htmlFor="max-conversation-rounds"
className="flex items-center gap-2 font-medium"
>
<ListOrderedIcon className="size-4" />
单个对话轮数限制
</label>
<p
id="max-conversation-rounds-help"
className="text-muted-foreground text-sm"
>
一轮按一条真实用户提问计算。默认 20,可设置为 1 到 50;达到上限后用户需要新建对话才能继续。
</p>
<div className="mt-2 flex items-center gap-2">
<Input
id="max-conversation-rounds"
type="number"
min={1}
max={50}
inputMode="numeric"
value={roundLimit}
disabled={isLoading || isError || update.isPending}
onChange={(event) => setRoundLimit(event.target.value)}
onBlur={() => setRoundLimitTouched(true)}
aria-invalid={roundLimitTouched && roundLimitInvalid}
aria-describedby="max-conversation-rounds-help max-conversation-rounds-error"
className="w-28"
/>
<span className="text-muted-foreground text-sm">轮</span>
</div>
<p
id="max-conversation-rounds-error"
className={
roundLimitTouched && roundLimitInvalid
? "text-destructive min-h-5 text-xs"
: "text-muted-foreground min-h-5 text-xs"
}
role={roundLimitTouched && roundLimitInvalid ? "alert" : undefined}
>
{roundLimitTouched && roundLimitInvalid
? "请输入 1 到 50 的整数。"
: "保存后,新限制会应用到当前及后续普通对话。"}
</p>
</div>
<Button
type="button"
size="sm"
disabled={
isLoading ||
isError ||
update.isPending ||
(!roundLimitInvalid && parsedRoundLimit === currentRoundLimit)
}
onClick={() => void saveRoundLimit()}
>
{update.isPending ? "保存中…" : "保存轮数"}
</Button>
</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">
开启后,取首个问题的前 20 个字作为最终标题,不足 20 个字则完整保留;回答结束后不再额外调用模型生成标题,也不轮询标题状态,避免影响下一轮提问。关闭后恢复原来的回答后标题生成方式。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={useQuestionAsTitle}
disabled={isError || update.isPending}
onCheckedChange={handleQuestionTitleChange}
aria-label="标题直接使用首个问题"
/>
)}
</div>
<div className="space-y-3 rounded-lg border p-4">
<label htmlFor="max-run-steps" className="font-medium">单次问答步骤上限</label>
<p className="text-muted-foreground text-sm">
控制普通问答和智能体问答每次运行的最大执行步数(20–10000,默认 1000)。步骤包括模型、工具及内部处理,不等于界面显示的步骤条数。提高上限可支持更复杂的任务,也会增加耗时和用量。
</p>
<div className="flex items-center gap-3">
<Input id="max-run-steps" type="number" min={20} max={10000} step={1}
className="w-40" value={stepValue}
disabled={isLoading || isError || update.isPending}
onChange={(event) => setStepDraft(event.target.value)} />
<Button disabled={isLoading || isError || update.isPending || !validSteps}
onClick={() => void saveSteps()}>保存上限</Button>
</div>
{!validSteps && <p className="text-destructive text-sm">请输入 20 到 10000 之间的整数。</p>}
<p className="text-muted-foreground text-sm">
达到上限时保留已有对话内容并提示暂停;用户可发送“继续”,在当前对话中接着处理。
</p>
</div>
{isError && (
<div className="flex items-center justify-between gap-3">
<p className="text-destructive text-sm">
@@ -48,7 +48,7 @@ export function KnowledgeRetrievalSettingsPage() {
知识检索
</h3>
<p className="text-muted-foreground text-sm">
配置普通知识库可用的检索模式。新对话始终默认使用经典检索。
控制普通知识库问答是否默认使用智能检索。该能力默认关闭,经典检索模式始终保留为候选。
</p>
</div>
@@ -56,7 +56,9 @@ export function KnowledgeRetrievalSettingsPage() {
<div className="space-y-1">
<div className="font-medium">普通知识库智能推理检索</div>
<p className="text-muted-foreground text-sm">
开启后,用户可在问答输入框中从经典检索切换到智能检索;经典检索仍位于首项并作为默认模式。
开启后,新对话默认使用智能检索:先召回原文分片,再根据命中线索查阅并扩展
Wiki,把知识库资料作为回答背景。已有对话继续使用各自保存的模式,用户仍可在
输入框的检索模式菜单中切换到经典检索。
</p>
</div>
{isLoading ? (
@@ -107,6 +109,12 @@ export function KnowledgeRetrievalSettingsPage() {
</Button>
</div>
)}
<p className="text-muted-foreground text-xs">
用户只需直接提问,无需填写“先检索什么、再检索什么”的固定模板;系统不会向输入框
注入检索提示词。智能检索将检索资料与模型原有能力结合后回答,不强制生成参考文献编号。
关闭后不会删除历史对话;已保存为智能检索的线程需要切回经典模式后才能继续发送。
</p>
</section>
);
}
@@ -1,110 +0,0 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { useModels } from "@/core/models/hooks";
type ModelPrompt = { enabled: boolean; prompt: string };
const queryKey = ["system-settings", "model-prompt-suffixes"];
const endpoint = () => `${getBackendBaseURL()}/api/system-settings/model-prompt-suffixes`;
async function readResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(typeof body?.detail === "string" ? body.detail : "模型提示词设置请求失败");
}
return response.json() as Promise<T>;
}
export function ModelPromptSettingsPage() {
const client = useQueryClient();
const { models, isLoading: modelsLoading, error: modelsError } = useModels();
const query = useQuery({
queryKey,
queryFn: async () => readResponse<Record<string, ModelPrompt>>(await apiFetch(endpoint())),
});
const [selected, setSelected] = useState("");
const [drafts, setDrafts] = useState<Record<string, ModelPrompt>>({});
const names = [...new Set([...models.map((model) => model.name), ...Object.keys(query.data ?? {})])];
const modelName = selected || names[0] || "";
const saved = query.data?.[modelName] ?? { enabled: false, prompt: "" };
const draft = drafts[modelName] ?? saved;
const mutation = useMutation({
mutationFn: async ({ name, value }: { name: string; value: ModelPrompt }) =>
readResponse<ModelPrompt>(await apiFetch(`${endpoint()}/${encodeURIComponent(name)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(value),
})),
onSuccess: (value, { name }) => {
client.setQueryData<Record<string, ModelPrompt>>(queryKey, (old) => ({ ...old, [name]: value }));
setDrafts((old) => {
const next = { ...old };
delete next[name];
return next;
});
toast.success("已保存,所有用户下一次调用该模型时生效");
},
onError: (error) => toast.error(error.message),
});
const edit = (patch: Partial<ModelPrompt>) => setDrafts((old) => ({ ...old, [modelName]: { ...draft, ...patch } }));
if (query.isLoading || modelsLoading) return <p>正在加载模型提示词设置…</p>;
if (query.isError || modelsError) return (
<div className="space-y-3">
<p role="alert">加载失败,请重试。</p>
<Button onClick={() => { void query.refetch(); void client.invalidateQueries({ queryKey: ["models"] }); }}>重试</Button>
</div>
);
return (
<section className="space-y-5">
<div className="space-y-2">
<h3 className="text-lg font-semibold">模型追加提示词</h3>
<p className="text-muted-foreground text-sm">
为指定模型设置固定提示词,自动追加到系统提示词末尾。仅管理员可修改,对所有用户生效,保存后无需重启。
</p>
<p className="text-muted-foreground text-sm">
适用于平台统一模型入口的问答、智能体及其他模型调用。追加提示词用于引导回答方式,不能保证模型一定回答。
</p>
</div>
{names.length === 0 ? <p>暂无可配置的模型。</p> : <>
<label className="block space-y-2">
<span className="text-sm font-medium">选择模型</span>
<select className="bg-background w-full rounded-md border px-3 py-2" value={modelName}
disabled={mutation.isPending} onChange={(event) => setSelected(event.target.value)}>
{names.map((name) => {
const model = models.find((item) => item.name === name);
return <option key={name} value={name}>{model ? `${model.display_name || name}(${name})` : `${name}(当前未配置)`}</option>;
})}
</select>
</label>
<div className="flex items-center justify-between gap-4 rounded-lg border p-4">
<label htmlFor="model-prompt-enabled" className="text-sm font-medium">启用此模型的追加提示词</label>
<Switch id="model-prompt-enabled" checked={draft.enabled} disabled={mutation.isPending}
onCheckedChange={(enabled) => edit({ enabled })} />
</div>
<label className="block space-y-2">
<span className="text-sm font-medium">追加提示词</span>
<Textarea rows={12} maxLength={20000} value={draft.prompt} disabled={mutation.isPending}
placeholder="填写希望该模型遵循的回答要求"
onChange={(event) => edit({ prompt: event.target.value })} />
</label>
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground text-xs">{draft.prompt.length} / 20000 字符;关闭开关可停用并保留内容。</span>
<Button disabled={mutation.isPending || (draft.enabled && !draft.prompt.trim())}
onClick={() => mutation.mutate({ name: modelName, value: draft })}>
{mutation.isPending ? "保存中…" : "保存此模型设置"}
</Button>
</div>
</>}
</section>
);
}
@@ -1,74 +0,0 @@
"use client";
import { HistoryIcon, Loader2Icon } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import {
useRetryKeepPartialSettings,
useUpdateRetryKeepPartialSettings,
} from "@/core/system-settings/hooks";
export function RetryKeepPartialSettingsPage() {
const { data, isLoading, isError } = useRetryKeepPartialSettings();
const update = useUpdateRetryKeepPartialSettings();
const enabled = data?.enabled ?? true;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ enabled: checked });
toast.success(
checked ? "保留中间正文已开启" : "保留中间正文已关闭",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<HistoryIcon className="size-5" />
保留中间正文
</h3>
<p className="text-muted-foreground text-sm">
步骤条执行过程中模型有时会先吐出一段正文再发起工具调用或开始下一轮思考。
开启后,本轮所有过程正文会合并成一条,集中显示在步骤条下方(步骤条保持
一个整体不被拆开,最终回答紧随其后);大模型响应中断触发后台自动重试时,
重试前已流出的半截回答同样保留。关闭则恢复原有行为——工具调用到达或
重试开始时清空未完成的正文。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">保留步骤条执行中生成的正文</div>
<p className="text-muted-foreground text-sm">
仅影响问答页的显示,不改变后台执行与落库逻辑;重试保留的半截内容
刷新页面后不会保留。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label="自动重试时保留已生成的正文"
/>
)}
</div>
{isError && (
<p className="text-destructive text-sm">
无法读取保留中间正文开关,系统将按开启处理。
</p>
)}
<p className="text-muted-foreground text-xs">
保存后无需重启后台,对下一次触发的自动重试立即生效;正在生成中的回答不会中途切换。
</p>
</section>
);
}
@@ -1,268 +0,0 @@
"use client";
import { Clock3Icon, FileOutputIcon, 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,
useWriteFileCompatibilitySettings,
} 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 globalFileStream = useWriteFileCompatibilitySettings();
const enabled = data?.enabled ?? true;
const fileStreamEnabled = data?.write_file_compatibility_enabled ?? false;
const globalFileStreamEnabled = globalFileStream.data?.enabled ?? false;
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;
write_file_compatibility_enabled?: boolean;
}) => {
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>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="flex items-center gap-2 font-medium">
<FileOutputIcon className="size-4" />
会商文件流式兼容
</div>
<p className="text-muted-foreground text-sm">
会商生成文件并打开沙箱时使用兼容写入。全局「文件流式兼容」开着时,会商自动使用;全局关着时,打开本开关后只有会商使用,普通对话保持原来的写入方式。
</p>
<p className="text-muted-foreground text-xs">
{globalFileStreamEnabled
? "当前会商会使用兼容写入,因为全局开关已开启。"
: fileStreamEnabled
? "当前只有会商使用兼容写入。"
: "当前会商使用各模型原来的写入方式。"}
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={fileStreamEnabled}
disabled={isError || update.isPending}
onCheckedChange={(checked) =>
void save({ write_file_compatibility_enabled: checked })
}
aria-label="会商启用文件流式兼容"
/>
)}
</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,23 +1,19 @@
"use client";
import {
BellIcon,
BrainIcon,
CpuIcon,
DatabaseIcon,
ArchiveIcon,
FileOutputIcon,
HistoryIcon,
PaletteIcon,
import {
BellIcon,
BrainIcon,
CpuIcon,
FileOutputIcon,
PaletteIcon,
SearchIcon,
ShieldCheckIcon,
ShrinkIcon,
SparklesIcon,
TypeIcon,
UploadCloudIcon,
UserIcon,
UsersIcon,
WrenchIcon,
} from "lucide-react";
SparklesIcon,
UploadCloudIcon,
UserIcon,
WrenchIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
@@ -29,45 +25,32 @@ 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 { AppIdentitySettingsPage } from "@/components/workspace/settings/app-identity-settings-page";
import { ContextWindowSettingsPage } from "@/components/workspace/settings/context-window-settings-page";
import { ModelPromptSettingsPage } from "@/components/workspace/settings/model-prompt-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 { RetryKeepPartialSettingsPage } from "@/components/workspace/settings/retry-keep-partial-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";
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";
type SettingsSection =
| "account"
| "authentication"
| "appearance"
| "conversation-context"
| "model-prompts"
| "server-diagnostics"
| "api-read-cache"
| "worker-pool"
| "chat-archive"
| "roundtable-rooms"
| "knowledge-retrieval"
| "appIdentity"
| "memory"
| "fileWriting"
| "retryKeepPartial"
| "uploads"
| "tools"
| "encoder"
| "about";
| "conversation-context"
| "knowledge-retrieval"
| "memory"
| "fileWriting"
| "uploads"
| "tools"
| "encoder"
| "about";
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
defaultSection?: SettingsSection;
@@ -75,8 +58,9 @@ type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
export function SettingsDialog(props: SettingsDialogProps) {
const { defaultSection = "appearance", ...dialogProps } = props;
const { t } = useI18n();
const isAdmin = getStoredAuth()?.system_role === "admin";
const { t } = useI18n();
const live = useLiveAccount();
const isAdmin = getStoredAuth()?.system_role === "admin";
const [activeSection, setActiveSection] =
useState<SettingsSection>(defaultSection);
@@ -102,27 +86,20 @@ 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: "model-prompts", label: "模型追加提示词", icon: SparklesIcon },
{ 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: "appIdentity", label: "系统名称", icon: TypeIcon },
{ id: "memory", label: "问答记忆", icon: BrainIcon },
{ id: "fileWriting", label: "文件流式兼容", icon: FileOutputIcon },
{ id: "retryKeepPartial", label: "保留中间正文", icon: HistoryIcon },
{ id: "uploads", label: "上传设置", icon: UploadCloudIcon },
]
: []),
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
: []),
...(isAdmin
? [
{ id: "conversation-context", label: "上下文管理", icon: ShrinkIcon },
{ id: "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 }]
: []),
{
id: "account",
label: "个人信息",
@@ -134,8 +111,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 (
@@ -181,54 +158,36 @@ 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 === "server-diagnostics" && isAdmin && (
<ServerDiagnosticsPanel />
)}
{activeSection === "api-read-cache" && isAdmin && (
<ApiReadCacheSettingsPage />
)}
{activeSection === "worker-pool" && isAdmin && (
<WorkerPoolSettingsPage />
)}
{activeSection === "chat-archive" && isAdmin && (
<ChatArchiveSettingsPage />
)}
{activeSection === "appearance" && (
<AppearanceSettingsPage
{activeSection === "authentication" && isAdmin && (
<AuthenticationSettingsPage
key={live.userId}
onClose={() => props.onOpenChange?.(false)}
/>
)}
{activeSection === "conversation-context" && isAdmin && (
<ContextWindowSettingsPage />
)}
{activeSection === "model-prompts" && isAdmin && (
<ModelPromptSettingsPage />
)}
{activeSection === "roundtable-rooms" && isAdmin && (
<RoundtableRoomSettingsPage />
)}
{activeSection === "knowledge-retrieval" && isAdmin && (
<KnowledgeRetrievalSettingsPage />
)}
{activeSection === "appIdentity" && isAdmin && (
<AppIdentitySettingsPage />
)}
{activeSection === "memory" && isAdmin && (
<MemoryRuntimeSettingsPage />
)}
{activeSection === "fileWriting" && isAdmin && (
<WriteFileCompatibilitySettingsPage />
)}
{activeSection === "retryKeepPartial" && isAdmin && (
<RetryKeepPartialSettingsPage />
)}
{activeSection === "uploads" && isAdmin && (
<UploadSettingsPage />
)}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "encoder" && isAdmin && (
<LocalEncoderSettingsCard />
{activeSection === "appearance" && (
<AppearanceSettingsPage
onClose={() => props.onOpenChange?.(false)}
/>
)}
{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,7 +558,6 @@ function SkillSettingsList({
<TagSearchBar
targetType="skill"
onSearchChange={setSearch}
searchPlaceholder="搜索名称 / 中文名 / 描述 / 详情 / 标签,多关键词空格分隔"
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
@@ -36,10 +36,8 @@ 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);
@@ -76,14 +74,7 @@ export function ToolSettingsPage() {
title={t.settings.tools.title}
description={t.settings.tools.description}
>
{!isAdmin ? (
<div
role="status"
className="text-muted-foreground rounded-md border p-6 text-center text-sm"
>
MCP 工具由管理员统一配置和管理。如需调整,请联系管理员。
</div>
) : isLoading ? (
{isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : error ? (
<div className="text-destructive text-sm">Error: {error.message}</div>
@@ -111,23 +102,19 @@ export function ToolSettingsPage() {
</div>
)}
{isAdmin && (
<MCPServerEditDialog
open={editOpen}
onOpenChange={setEditOpen}
mode={editMode}
originalName={editingName}
initialServer={editingServer}
existingNames={existingNames}
/>
)}
<MCPServerEditDialog
open={editOpen}
onOpenChange={setEditOpen}
mode={editMode}
originalName={editingName}
initialServer={editingServer}
existingNames={existingNames}
/>
{isAdmin && (
<DeleteConfirmDialog
target={deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
/>
)}
<DeleteConfirmDialog
target={deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
/>
</SettingsSection>
);
}
@@ -1,166 +0,0 @@
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>;
}
@@ -2,7 +2,6 @@ import type { BaseStream } from "@langchain/langgraph-sdk";
import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks";
import { useAppSystemName } from "@/core/system-settings/hooks";
import type { AgentThreadState } from "@/core/threads";
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
@@ -21,7 +20,6 @@ export function ThreadTitle({
thread: BaseStream<AgentThreadState>;
}) {
const { t } = useI18n();
const appName = useAppSystemName(t.pages.appName);
const { isNewThread } = useThreadChat();
const displayTitle = sanitizeThreadTitleForDisplay(thread.values?.title);
@@ -34,15 +32,15 @@ export function ThreadTitle({
_title = t.pages.newChat;
}
if (thread.isThreadLoading) {
document.title = `Loading... - ${appName}`;
document.title = `Loading... - ${t.pages.appName}`;
} else {
document.title = `${_title} - ${appName}`;
document.title = `${_title} - ${t.pages.appName}`;
}
}, [
appName,
isNewThread,
t.pages.newChat,
t.pages.untitled,
t.pages.appName,
thread.isThreadLoading,
thread.values,
displayTitle,
@@ -7,20 +7,6 @@ import { AlertTriangle, MapPinned, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { getMapTileAttribution, getMapTileUrl } from "@/core/utils/map-tile-config";
import {
coordinatesMatch,
curvedLineCoordinates,
flowArrowsForLines,
initialMapCamera,
overlayPadding,
resolveLineColor,
resolveMarkerColor,
shortenProjectedPath,
svgPathFromPoints,
type FlowLine,
type MapCamera,
type MapPoint,
} from "./map-style";
import type { MapSpec } from "./visualization-spec";
import { VisualizationCard } from "./visualization-card";
@@ -47,162 +33,31 @@ function allCoordinates(spec: MapSpec): Array<[number, number]> {
];
}
function bootCamera(camera: MapCamera): { center: MapPoint; zoom: number } {
if (camera.kind === "center") return { center: camera.center, zoom: camera.zoom };
return {
center: [(camera.sw[0] + camera.ne[0]) / 2, (camera.sw[1] + camera.ne[1]) / 2],
zoom: 4,
};
}
function applyMapCamera(map: maplibregl.Map, camera: MapCamera, padding: number) {
if (camera.kind === "center") {
map.jumpTo({ center: camera.center, zoom: camera.zoom });
return;
}
map.fitBounds([camera.sw, camera.ne], { padding, maxZoom: camera.maxZoom, duration: 0 });
}
function prefersReducedMotion(): boolean {
return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
}
function markerColorLookup(spec: MapSpec): Map<string, string> {
const colors = new Map<string, string>();
spec.markers.forEach((marker, index) => {
colors.set(marker.name, resolveMarkerColor(index, marker.color));
});
return colors;
}
function styledLines(spec: MapSpec): FlowLine[] {
const markerColors = markerColorLookup(spec);
return spec.lines.map((line, index) => {
const start = line.coordinates[0];
const startMarker = start
? spec.markers.find((marker) => coordinatesMatch(marker.coordinate, start))
: undefined;
const fromColor = startMarker ? markerColors.get(startMarker.name) : undefined;
return {
coordinates: line.coordinates,
color: resolveLineColor(index, line.color, fromColor),
name: line.name,
arrow: line.arrow,
flow: line.flow,
};
});
}
function escapeHtml(value: string): string {
return value.replace(/[&<>'"]/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"'": "&#39;",
'"': "&quot;",
})[character] ?? character);
}
function projectPoint(map: maplibregl.Map, point: MapPoint): { x: number; y: number } {
const projected = map.project({ lng: point[0], lat: point[1] });
return { x: projected.x, y: projected.y };
}
function paintMapOverlay(
svg: SVGSVGElement,
map: maplibregl.Map,
lines: FlowLine[],
markers: Array<{ coordinate: MapPoint; color: string; name: string }>,
progress: number,
reducedMotion: boolean,
) {
const root = map.getContainer();
const width = root.clientWidth || 1;
const height = root.clientHeight || 1;
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
svg.setAttribute("width", String(width));
svg.setAttribute("height", String(height));
const arrowDefs = lines
.filter((line) => line.arrow !== false)
.map((line) => {
const id = `df-map-arrow-${line.color.slice(1)}`;
return `<marker id="${id}" markerWidth="8" markerHeight="8" refX="6.4" refY="4" orient="auto" markerUnits="userSpaceOnUse"><path d="M0.6,1.1 L7.2,4 L0.6,6.9 Z" fill="${line.color}" stroke="#fff" stroke-width="0.6" stroke-linejoin="round"/></marker>`;
})
.filter((value, index, all) => all.indexOf(value) === index)
.join("");
const paths = lines
.map((line) => {
const projected = curvedLineCoordinates(line.coordinates).map((point) => projectPoint(map, point));
const d = svgPathFromPoints(shortenProjectedPath(projected, 10, 13));
if (!d) return "";
const markerId = `df-map-arrow-${line.color.slice(1)}`;
const end = line.arrow !== false ? ` marker-end="url(#${markerId})"` : "";
const flow =
line.arrow !== false && line.flow !== false && !reducedMotion
? `<path d="${d}" fill="none" stroke="#fff" stroke-width="1.1" stroke-linecap="round" stroke-dasharray="6 10" class="df-map-line-flow"/>`
: "";
return `<path d="${d}" fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.88"/>
<path d="${d}" fill="none" stroke="${line.color}" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"${end}/>
${flow}`;
})
.join("");
const arrows = flowArrowsForLines(lines, progress, { reducedMotion })
.map((arrow) => {
const { x, y } = projectPoint(map, arrow.point);
return `<g transform="translate(${x.toFixed(1)} ${y.toFixed(1)}) rotate(${arrow.bearing.toFixed(1)})"><path d="M0,-4.5 L3.6,4.2 L0,2 L-3.6,4.2 Z" fill="${arrow.color}" stroke="#fff" stroke-width="0.7" stroke-linejoin="round"/></g>`;
})
.join("");
const dots = markers
.map((marker, index) => {
const { x, y } = projectPoint(map, marker.coordinate);
const label = escapeHtml(marker.name);
return `<g class="df-map-marker" data-marker-index="${index}" transform="translate(${x.toFixed(1)} ${y.toFixed(1)})" style="cursor:pointer;pointer-events:auto">
<title>${label}</title>
<circle r="16" fill="transparent"/>
<circle r="8" fill="${marker.color}" stroke="#fff" stroke-width="2.5" paint-order="stroke" style="filter:drop-shadow(0 1px 3px rgba(15,23,42,0.45))"/>
</g>`;
})
.join("");
svg.innerHTML = `<defs>${arrowDefs}</defs>${paths}${arrows}${dots}`;
function defaultCenter(spec: MapSpec): [number, number] {
if (spec.center) return spec.center;
const coordinates = allCoordinates(spec);
if (coordinates.length === 0) return [104.1954, 35.8617];
const total = coordinates.reduce(([lng, lat], coordinate) => [lng + coordinate[0], lat + coordinate[1]], [0, 0]);
return [total[0] / coordinates.length, total[1] / coordinates.length];
}
export default function MapRenderer({ spec }: { spec: MapSpec }) {
const containerRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<SVGSVGElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const [tileFailed, setTileFailed] = useState(false);
const [reloadToken, setReloadToken] = useState(0);
const coordinates = useMemo(() => allCoordinates(spec), [spec]);
const lines = useMemo(() => styledLines(spec), [spec]);
const markerColors = useMemo(
() => spec.markers.map((marker, index) => resolveMarkerColor(index, marker.color)),
[spec],
);
useEffect(() => {
const container = containerRef.current;
const overlay = overlayRef.current;
if (!container) return;
setTileFailed(false);
const camera = initialMapCamera(coordinates, { center: spec.center, zoom: spec.zoom });
const boot = bootCamera(camera);
const overlayMarkers = spec.markers.map((marker, index) => ({
coordinate: marker.coordinate,
color: markerColors[index] ?? resolveMarkerColor(index, marker.color),
name: marker.name,
}));
const map = new maplibregl.Map({
container,
style: createStyle(),
center: boot.center,
zoom: boot.zoom,
center: defaultCenter(spec),
zoom: spec.zoom ?? (coordinates.length > 1 ? 4 : 8),
minZoom: 1,
maxZoom: 20,
attributionControl: { compact: true },
@@ -210,59 +65,18 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
mapRef.current = map;
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
let raf = 0;
let lastPaint = 0;
let alive = true;
let viewReady = false;
let applyingView = false;
let popup: maplibregl.Popup | null = null;
const motionQuery = window.matchMedia?.("(prefers-reduced-motion: reduce)");
let reducedMotion = prefersReducedMotion();
const redrawOverlay = (progress = (performance.now() / 4000) % 1) => {
if (!alive || !overlay) return;
paintMapOverlay(overlay, map, lines, overlayMarkers, reducedMotion ? 0.72 : progress, reducedMotion);
};
const onMove = () => redrawOverlay();
map.on("move", onMove);
map.on("resize", onMove);
const tick = (now: number) => {
if (!alive) return;
const flowing = !reducedMotion && lines.some((line) => line.arrow !== false && line.flow !== false);
if (flowing && now - lastPaint >= 32) {
lastPaint = now;
redrawOverlay((now / 4000) % 1);
}
raf = requestAnimationFrame(tick);
};
const onMotionChange = () => {
reducedMotion = prefersReducedMotion();
redrawOverlay();
};
motionQuery?.addEventListener("change", onMotionChange);
const openMarkerPopup = (index: number) => {
const marker = spec.markers[index];
if (!marker) return;
popup?.remove();
popup = new maplibregl.Popup({ offset: 16, closeButton: true })
.setLngLat(marker.coordinate)
.setHTML(
`<div style="max-width:240px"><strong>${escapeHtml(marker.name)}</strong>${marker.description ? `<p style="margin:6px 0 0">${escapeHtml(marker.description)}</p>` : ""}</div>`,
)
.addTo(map);
};
const onOverlayClick = (event: MouseEvent) => {
const target = event.target instanceof Element ? event.target.closest("[data-marker-index]") : null;
if (!target) return;
event.stopPropagation();
openMarkerPopup(Number(target.getAttribute("data-marker-index")));
};
overlay?.addEventListener("click", onOverlayClick);
const markers: maplibregl.Marker[] = [];
for (const marker of spec.markers) {
const element = document.createElement("button");
element.type = "button";
element.className = "size-5 rounded-full border-2 border-white bg-primary shadow-md focus:outline-none focus:ring-2 focus:ring-primary/50";
element.title = marker.name;
element.setAttribute("aria-label", marker.name);
const popup = new maplibregl.Popup({ offset: 14 }).setHTML(
`<div style="max-width:240px"><strong>${escapeHtml(marker.name)}</strong>${marker.description ? `<p style="margin:6px 0 0">${escapeHtml(marker.description)}</p>` : ""}</div>`,
);
markers.push(new maplibregl.Marker({ element }).setLngLat(marker.coordinate).setPopup(popup).addTo(map));
}
let baseTileLoaded = false;
const onSourceData = (event: maplibregl.MapSourceDataEvent) => {
@@ -277,60 +91,70 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
map.on("sourcedata", onSourceData);
map.on("error", onError);
const applyView = () => {
if (!alive) return false;
map.resize();
const width = container.clientWidth;
const height = container.clientHeight;
if (width < 80 || height < 80) return false;
if (!map.loaded() && !map.isStyleLoaded()) return false;
applyMapCamera(map, camera, overlayPadding(width, height));
redrawOverlay();
return true;
};
const ensureView = () => {
if (!alive || applyingView) return;
if (viewReady) {
map.resize();
redrawOverlay();
return;
map.on("load", () => {
if (spec.lines.length > 0) {
map.addSource("visual-lines", {
type: "geojson",
data: {
type: "FeatureCollection",
features: spec.lines.map((line) => ({
type: "Feature",
properties: { name: line.name ?? "" },
geometry: { type: "LineString", coordinates: line.coordinates },
})),
},
});
map.addLayer({
id: "visual-lines",
type: "line",
source: "visual-lines",
paint: { "line-color": "#2563eb", "line-width": 3, "line-opacity": 0.85 },
});
}
applyingView = true;
const fitted = applyView();
applyingView = false;
if (!fitted) return;
viewReady = true;
map.once("idle", () => {
if (!alive) return;
map.resize();
redrawOverlay();
});
raf = requestAnimationFrame(tick);
};
map.on("load", ensureView);
map.once("idle", ensureView);
const resizeObserver = new ResizeObserver(ensureView);
resizeObserver.observe(container);
requestAnimationFrame(ensureView);
if (spec.polygons.length > 0) {
map.addSource("visual-polygons", {
type: "geojson",
data: {
type: "FeatureCollection",
features: spec.polygons.map((polygon) => ({
type: "Feature",
properties: { name: polygon.name ?? "" },
geometry: { type: "Polygon", coordinates: [[...polygon.coordinates, polygon.coordinates[0]]] },
})),
},
});
map.addLayer({
id: "visual-polygons-fill",
type: "fill",
source: "visual-polygons",
paint: { "fill-color": "#2563eb", "fill-opacity": 0.18 },
});
map.addLayer({
id: "visual-polygons-border",
type: "line",
source: "visual-polygons",
paint: { "line-color": "#2563eb", "line-width": 2 },
});
}
if (!spec.center && coordinates.length > 1) {
const bounds = coordinates.reduce(
(current, coordinate) => current.extend(coordinate),
new maplibregl.LngLatBounds(coordinates[0], coordinates[0]),
);
map.fitBounds(bounds, { padding: 52, maxZoom: 12, duration: 0 });
}
});
return () => {
alive = false;
cancelAnimationFrame(raf);
resizeObserver.disconnect();
motionQuery?.removeEventListener("change", onMotionChange);
overlay?.removeEventListener("click", onOverlayClick);
popup?.remove();
map.off("move", onMove);
map.off("resize", onMove);
map.off("load", ensureView);
map.off("sourcedata", onSourceData);
map.off("error", onError);
markers.forEach((marker) => marker.remove());
mapRef.current = null;
map.remove();
};
}, [coordinates, lines, markerColors, reloadToken, spec]);
}, [coordinates, reloadToken, spec]);
return (
<VisualizationCard>
@@ -341,18 +165,6 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
</div>
<div className="relative">
<div ref={containerRef} className="h-[420px] w-full" role="img" aria-label={`${spec.title},包含 ${spec.markers.length} 个地点`} />
<style>{`
.df-map-line-flow { animation: df-map-line-flow 1.2s linear infinite; }
@keyframes df-map-line-flow { to { stroke-dashoffset: -32; } }
@media (prefers-reduced-motion: reduce) {
.df-map-line-flow { animation: none; }
}
`}</style>
<svg
ref={overlayRef}
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 z-[1] h-[420px] w-full overflow-visible"
/>
{tileFailed ? (
<div className="absolute inset-x-3 top-3 z-10 flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50/95 p-3 text-xs text-amber-900 shadow-sm" role="status">
<AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
@@ -373,46 +185,30 @@ export default function MapRenderer({ spec }: { spec: MapSpec }) {
</div>
) : null}
</div>
{spec.markers.length > 0 || spec.lines.length > 0 ? (
{spec.markers.length > 0 ? (
<details className="border-t px-3 py-2 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">查看地点列表</summary>
{spec.markers.length > 0 ? (
<ul className="mt-2 space-y-1">
{spec.markers.map((marker, index) => (
<li key={`${marker.name}-${index}`} className="flex items-start gap-2">
<span
aria-hidden="true"
className="mt-0.5 inline-block size-2.5 shrink-0 rounded-full border border-black/10"
style={{ background: markerColors[index] }}
/>
<span>
<span className="font-medium text-foreground">{marker.name}</span>
<span>({marker.coordinate[0]},{marker.coordinate[1]})</span>
{marker.description ? <span>:{marker.description}</span> : null}
</span>
</li>
))}
</ul>
) : null}
{spec.lines.length > 0 ? (
<ul className="mt-2 space-y-1">
{lines.map((line, index) => (
<li key={`${line.name ?? "line"}-${index}`} className="flex items-start gap-2">
<span
aria-hidden="true"
className="mt-1 inline-block h-0.5 w-4 shrink-0 rounded-full"
style={{ background: line.color }}
/>
<span>
<span className="font-medium text-foreground">{line.name || `连线 ${index + 1}`}</span>
{line.arrow !== false ? <span>,带箭头{line.flow === false ? "" : "并沿线流动"}</span> : null}
</span>
</li>
))}
</ul>
) : null}
<ul className="mt-2 space-y-1">
{spec.markers.map((marker, index) => (
<li key={`${marker.name}-${index}`}>
<span className="font-medium text-foreground">{marker.name}</span>
<span>({marker.coordinate[0]},{marker.coordinate[1]})</span>
{marker.description ? <span>:{marker.description}</span> : null}
</li>
))}
</ul>
</details>
) : null}
</VisualizationCard>
);
}
function escapeHtml(value: string): string {
return value.replace(/[&<>'"]/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"'": "&#39;",
'"': "&quot;",
})[character] ?? character);
}
@@ -1,116 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-expect-error Node's type-stripping test runner requires the explicit .ts suffix.
import {
curvedLineCoordinates,
flowArrowsForLines,
initialMapCamera,
overlayPadding,
pointAndBearing,
polylineMetrics,
resolveMarkerColor,
shortenProjectedPath,
svgPathFromCoordinates,
} from "./map-style.ts";
test("assigns distinct colors when the model omits them", () => {
assert.notEqual(resolveMarkerColor(0), resolveMarkerColor(1));
assert.equal(resolveMarkerColor(0, "#111111"), "#111111");
});
test("walks a line and wraps flowing arrows", () => {
const metrics = polylineMetrics([
[0, 0],
[10, 0],
]);
assert.ok(metrics);
const start = pointAndBearing(metrics!, 0);
const mid = pointAndBearing(metrics!, 5);
const wrapped = pointAndBearing(metrics!, 12);
assert.deepEqual(start.point, [0, 0]);
assert.equal(start.bearing, 90);
assert.deepEqual(mid.point, [5, 0]);
assert.equal(wrapped.point[0], 2);
assert.equal(wrapped.bearing, 90);
const arrows = flowArrowsForLines(
[{ coordinates: [[0, 0], [10, 0]], color: "#2563eb", arrow: true, flow: true }],
0,
);
assert.equal(arrows.length, 3);
const staticArrow = flowArrowsForLines(
[{ coordinates: [[0, 0], [10, 0]], color: "#2563eb", arrow: true, flow: true }],
0,
{ reducedMotion: true },
);
assert.equal(staticArrow.length, 1);
assert.ok(staticArrow[0]!.point[0] > 5);
});
test("builds an svg path from projected coordinates", () => {
const path = svgPathFromCoordinates(
[
[116.4, 39.9],
[121.47, 31.23],
],
([lng, lat]) => ({ x: lng, y: lat }),
);
assert.equal(path, "M116.4 39.9 L121.5 31.2");
});
test("turns a two-point connection into a bulging arc", () => {
const points = curvedLineCoordinates([
[0, 0],
[10, 0],
]);
assert.ok(points.length > 2);
const mid = points[Math.floor(points.length / 2)]!;
assert.ok(mid[1] > 0.8);
assert.deepEqual(points[0], [0, 0]);
assert.equal(points[points.length - 1]![0], 10);
assert.ok(Math.abs(points[points.length - 1]![1]) < 1e-9);
const arrows = flowArrowsForLines(
[{ coordinates: [[0, 0], [10, 0]], color: "#2563eb", arrow: true, flow: true }],
0.2,
);
assert.ok(arrows[0]!.point[1] > 0);
});
test("shortens a projected path so arrows sit off the markers", () => {
const trimmed = shortenProjectedPath(
[
{ x: 0, y: 0 },
{ x: 100, y: 0 },
],
10,
13,
);
assert.equal(trimmed.length, 2);
assert.ok(trimmed[0]!.x >= 9.9);
assert.ok(trimmed[1]!.x <= 87.1);
});
test("frames Beijing and Shanghai even when the model sent a China-wide camera", () => {
const beijing: [number, number] = [116.4074, 39.9042];
const shanghai: [number, number] = [121.4737, 31.2304];
const camera = initialMapCamera([beijing, shanghai], { center: [104.1954, 35.8617], zoom: 4 });
assert.equal(camera.kind, "bounds");
if (camera.kind !== "bounds") return;
assert.ok(camera.sw[0] <= beijing[0] && camera.sw[1] <= shanghai[1]);
assert.ok(camera.ne[0] >= shanghai[0] && camera.ne[1] >= beijing[1]);
assert.equal(camera.maxZoom, 12);
});
test("centers a single marker instead of using the model overview", () => {
const camera = initialMapCamera([[116.4, 39.9]], { center: [104.2, 35.8], zoom: 4 });
assert.deepEqual(camera, { kind: "center", center: [116.4, 39.9], zoom: 8 });
});
test("keeps padding inside a typical map card", () => {
const padding = overlayPadding(800, 420);
assert.ok(padding >= 32 && padding <= 80);
assert.ok(padding * 2 < 420);
});
@@ -1,294 +0,0 @@
export const MAP_MARKER_PALETTE = [
"#2563eb",
"#dc2626",
"#16a34a",
"#d97706",
"#7c3aed",
"#0891b2",
"#db2777",
"#4f46e5",
] as const;
export type MapPoint = [number, number];
export function resolveMarkerColor(index: number, explicit?: string): string {
return explicit ?? MAP_MARKER_PALETTE[index % MAP_MARKER_PALETTE.length]!;
}
export function resolveLineColor(
index: number,
explicit?: string,
fromMarkerColor?: string,
): string {
return explicit ?? fromMarkerColor ?? MAP_MARKER_PALETTE[index % MAP_MARKER_PALETTE.length]!;
}
export function coordinatesMatch(left: MapPoint, right: MapPoint, epsilon = 1e-4): boolean {
return Math.abs(left[0] - right[0]) <= epsilon && Math.abs(left[1] - right[1]) <= epsilon;
}
export type PolylineMetrics = {
coordinates: MapPoint[];
parts: number[];
total: number;
};
export function polylineMetrics(coordinates: MapPoint[]): PolylineMetrics | null {
if (coordinates.length < 2) return null;
const parts: number[] = [];
let total = 0;
for (let index = 1; index < coordinates.length; index += 1) {
const current = coordinates[index]!;
const previous = coordinates[index - 1]!;
const length = Math.hypot(current[0] - previous[0], current[1] - previous[1]);
parts.push(length);
total += length;
}
if (!(total > 0)) return null;
return { coordinates, parts, total };
}
export function pointAndBearing(
metrics: PolylineMetrics,
distance: number,
): { point: MapPoint; bearing: number } {
const wrapped = ((distance % metrics.total) + metrics.total) % metrics.total;
let remaining = wrapped;
for (let index = 0; index < metrics.parts.length; index += 1) {
const part = metrics.parts[index]!;
const start = metrics.coordinates[index]!;
const end = metrics.coordinates[index + 1]!;
if (remaining > part && index < metrics.parts.length - 1) {
remaining -= part;
continue;
}
const t = part === 0 ? 0 : Math.min(1, remaining / part);
const dlng = end[0] - start[0];
const dlat = end[1] - start[1];
return {
point: [start[0] + dlng * t, start[1] + dlat * t],
bearing: (Math.atan2(dlng, dlat) * 180) / Math.PI,
};
}
const last = metrics.coordinates[metrics.coordinates.length - 1]!;
const prev = metrics.coordinates[metrics.coordinates.length - 2]!;
return {
point: last,
bearing: (Math.atan2(last[0] - prev[0], last[1] - prev[1]) * 180) / Math.PI,
};
}
export type FlowLine = {
coordinates: MapPoint[];
color: string;
name?: string;
arrow?: boolean;
flow?: boolean;
};
export type FlowArrow = {
point: MapPoint;
bearing: number;
color: string;
lineName: string;
};
export const LINE_ARC_BULGE = 0.22;
export const LINE_ARC_SAMPLES = 20;
export function quadraticPoint(start: MapPoint, control: MapPoint, end: MapPoint, t: number): MapPoint {
const u = 1 - t;
return [
u * u * start[0] + 2 * u * t * control[0] + t * t * end[0],
u * u * start[1] + 2 * u * t * control[1] + t * t * end[1],
];
}
export function arcControlPoint(start: MapPoint, end: MapPoint, bulge = LINE_ARC_BULGE): MapPoint {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const length = Math.hypot(dx, dy);
if (!(length > 0)) return start;
const offset = length * bulge;
return [(start[0] + end[0]) / 2 - (dy / length) * offset, (start[1] + end[1]) / 2 + (dx / length) * offset];
}
export function curvedLineCoordinates(coordinates: MapPoint[], samples = LINE_ARC_SAMPLES): MapPoint[] {
if (coordinates.length < 2) return coordinates;
const points: MapPoint[] = [];
for (let index = 1; index < coordinates.length; index += 1) {
const start = coordinates[index - 1]!;
const end = coordinates[index]!;
if (coordinatesMatch(start, end)) {
if (points.length === 0) points.push(start);
continue;
}
const control = arcControlPoint(start, end);
const from = index === 1 ? 0 : 1;
for (let step = from; step <= samples; step += 1) {
points.push(quadraticPoint(start, control, end, step / samples));
}
}
return points.length >= 2 ? points : coordinates;
}
export function flowArrowsForLines(
lines: FlowLine[],
progress: number,
options?: { reducedMotion?: boolean },
): FlowArrow[] {
const arrows: FlowArrow[] = [];
const reducedMotion = options?.reducedMotion === true;
for (const line of lines) {
if (line.arrow === false) continue;
const metrics = polylineMetrics(curvedLineCoordinates(line.coordinates));
if (!metrics) continue;
const animate = line.flow !== false && !reducedMotion;
const count = animate ? 3 : 1;
const offset = animate ? progress : 0.72;
for (let index = 0; index < count; index += 1) {
const distance = ((offset + index / count) % 1) * metrics.total;
const placed = pointAndBearing(metrics, distance);
arrows.push({
...placed,
color: line.color,
lineName: line.name ?? "",
});
}
}
return arrows;
}
export function svgPathFromPoints(points: Array<{ x: number; y: number }>): string {
return points
.map((point, index) => `${index === 0 ? "M" : "L"}${point.x.toFixed(1)} ${point.y.toFixed(1)}`)
.join(" ");
}
export function svgPathFromCoordinates(
coordinates: MapPoint[],
project: (point: MapPoint) => { x: number; y: number },
): string {
return svgPathFromPoints(coordinates.map(project));
}
function projectedPathLength(points: Array<{ x: number; y: number }>): number {
let total = 0;
for (let index = 1; index < points.length; index += 1) {
const current = points[index]!;
const previous = points[index - 1]!;
total += Math.hypot(current.x - previous.x, current.y - previous.y);
}
return total;
}
function trimProjectedPath(
points: Array<{ x: number; y: number }>,
gap: number,
edge: "start" | "end",
): Array<{ x: number; y: number }> {
if (points.length < 2 || gap <= 0) return points;
let remaining = gap;
if (edge === "start") {
let index = 0;
while (index < points.length - 1 && remaining > 0) {
const current = points[index]!;
const next = points[index + 1]!;
const part = Math.hypot(next.x - current.x, next.y - current.y);
if (part <= remaining) {
remaining -= part;
index += 1;
continue;
}
const t = remaining / part;
const cut = { x: current.x + (next.x - current.x) * t, y: current.y + (next.y - current.y) * t };
return [cut, ...points.slice(index + 1)];
}
return points.slice(-1);
}
let index = points.length - 1;
while (index > 0 && remaining > 0) {
const current = points[index]!;
const previous = points[index - 1]!;
const part = Math.hypot(current.x - previous.x, current.y - previous.y);
if (part <= remaining) {
remaining -= part;
index -= 1;
continue;
}
const t = remaining / part;
const cut = { x: current.x + (previous.x - current.x) * t, y: current.y + (previous.y - current.y) * t };
return [...points.slice(0, index), cut];
}
return points.slice(0, 1);
}
export function shortenProjectedPath(
points: Array<{ x: number; y: number }>,
startGap: number,
endGap: number,
): Array<{ x: number; y: number }> {
if (points.length < 2) return points;
const total = projectedPathLength(points);
if (!(total > 0)) return points;
const maxGap = total / 2 - 4;
if (maxGap <= 0) return points;
return trimProjectedPath(
trimProjectedPath(points, Math.min(startGap, maxGap), "start"),
Math.min(endGap, maxGap),
"end",
);
}
export const FALLBACK_MAP_CENTER: MapPoint = [104.1954, 35.8617];
export const SINGLE_POINT_ZOOM = 8;
export const MULTI_POINT_MAX_ZOOM = 12;
export const FALLBACK_MAP_ZOOM = 4;
export type MapCamera =
| { kind: "center"; center: MapPoint; zoom: number }
| { kind: "bounds"; sw: MapPoint; ne: MapPoint; maxZoom: number };
export function boundsFromCoordinates(coordinates: MapPoint[]): { sw: MapPoint; ne: MapPoint } | null {
if (coordinates.length === 0) return null;
let minLng = coordinates[0]![0];
let minLat = coordinates[0]![1];
let maxLng = minLng;
let maxLat = minLat;
for (const [lng, lat] of coordinates) {
minLng = Math.min(minLng, lng);
minLat = Math.min(minLat, lat);
maxLng = Math.max(maxLng, lng);
maxLat = Math.max(maxLat, lat);
}
return { sw: [minLng, minLat], ne: [maxLng, maxLat] };
}
export function isDegenerateBounds(sw: MapPoint, ne: MapPoint, epsilon = 1e-5): boolean {
return Math.abs(ne[0] - sw[0]) <= epsilon && Math.abs(ne[1] - sw[1]) <= epsilon;
}
export function overlayPadding(width: number, height: number): number {
return Math.max(32, Math.min(80, Math.floor(Math.min(width, height) * 0.14)));
}
export function initialMapCamera(
coordinates: MapPoint[],
hint?: { center?: MapPoint; zoom?: number },
): MapCamera {
if (coordinates.length === 0) {
return {
kind: "center",
center: hint?.center ?? FALLBACK_MAP_CENTER,
zoom: hint?.zoom ?? FALLBACK_MAP_ZOOM,
};
}
if (coordinates.length === 1) {
return { kind: "center", center: coordinates[0]!, zoom: hint?.zoom && hint.zoom >= 6 ? hint.zoom : SINGLE_POINT_ZOOM };
}
const bounds = boundsFromCoordinates(coordinates);
if (!bounds || isDegenerateBounds(bounds.sw, bounds.ne)) {
return { kind: "center", center: coordinates[0]!, zoom: SINGLE_POINT_ZOOM };
}
return { kind: "bounds", sw: bounds.sw, ne: bounds.ne, maxZoom: MULTI_POINT_MAX_ZOOM };
}
@@ -2,12 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
// @ts-expect-error Node's type-stripping test runner requires the explicit .ts suffix.
import {
parseMapColor,
parseOptionalBoolean,
parseVisualizationSpec,
splitVisualizationContent,
} from "./visualization-spec.ts";
import { parseVisualizationSpec, splitVisualizationContent } from "./visualization-spec.ts";
test("ordinary markdown remains one unchanged segment", () => {
const content = "普通回答\n\n```ts\nconst value = 1;\n```";
@@ -235,139 +230,6 @@ test("rejects invalid map coordinates", () => {
assert.equal(result.ok, false);
});
test("recovers Beijing-Shanghai lines that omit coordinates", () => {
const fromToByName = parseVisualizationSpec(
"map-json",
JSON.stringify({
title: "北京上海位置地图",
markers: [
{ name: "北京", coordinate: [116.4, 39.9] },
{ name: "上海", coordinate: [121.47, 31.23] },
],
lines: [{ name: "北京-上海", from: "北京", to: "上海" }],
}),
);
assert.equal(fromToByName.ok, true);
assert.equal(fromToByName.ok && fromToByName.spec.type, "map");
if (fromToByName.ok && fromToByName.spec.type === "map") {
assert.equal(fromToByName.spec.markers.length, 2);
assert.deepEqual(fromToByName.spec.lines[0]?.coordinates, [
[116.4, 39.9],
[121.47, 31.23],
]);
}
const singularCoordinate = parseVisualizationSpec(
"map-json",
JSON.stringify({
markers: [
{ name: "北京", coordinate: [116.4, 39.9] },
{ name: "上海", coordinate: [121.47, 31.23] },
],
lines: [{ name: "联络", coordinate: [[116.4, 39.9], [121.47, 31.23]] }],
}),
);
assert.equal(singularCoordinate.ok, true);
if (singularCoordinate.ok && singularCoordinate.spec.type === "map") {
assert.equal(singularCoordinate.spec.lines.length, 1);
}
const echartsCoords = parseVisualizationSpec(
"map-json",
JSON.stringify({
markers: [
{ name: "北京", coordinate: [116.4, 39.9] },
{ name: "上海", coordinate: [121.47, 31.23] },
],
lines: [{ name: "联络", coords: [[116.4, 39.9], [121.47, 31.23]] }],
}),
);
assert.equal(echartsCoords.ok, true);
if (echartsCoords.ok && echartsCoords.spec.type === "map") {
assert.equal(echartsCoords.spec.lines.length, 1);
}
const fromToPoints = parseVisualizationSpec(
"map-json",
JSON.stringify({
markers: [
{ name: "北京", lng: 116.4, lat: 39.9 },
{ name: "上海", longitude: "121.47", latitude: "31.23" },
],
lines: [{ from: [116.4, 39.9], to: [121.47, 31.23] }],
}),
);
assert.equal(fromToPoints.ok, true);
if (fromToPoints.ok && fromToPoints.spec.type === "map") {
assert.equal(fromToPoints.spec.markers.length, 2);
assert.equal(fromToPoints.spec.lines.length, 1);
}
});
test("keeps markers when a line cannot be recovered", () => {
const result = parseVisualizationSpec(
"map-json",
JSON.stringify({
title: "北京上海位置地图",
markers: [
{ name: "北京", coordinate: [116.4, 39.9] },
{ name: "上海", coordinate: [121.47, 31.23] },
],
lines: [{ name: "北京-上海" }],
}),
);
assert.equal(result.ok, true);
if (result.ok && result.spec.type === "map") {
assert.equal(result.spec.markers.length, 2);
assert.equal(result.spec.lines.length, 0);
}
});
test("parses named, Chinese and hex marker colors", () => {
assert.equal(parseMapColor("red"), "#dc2626");
assert.equal(parseMapColor("蓝色"), "#2563eb");
assert.equal(parseMapColor("#0f8"), "#00ff88");
assert.equal(parseMapColor("#2563EB"), "#2563eb");
assert.equal(parseMapColor("javascript:alert(1)"), undefined);
assert.equal(parseOptionalBoolean("是"), true);
assert.equal(parseOptionalBoolean("false"), false);
});
test("keeps marker colors and line arrow/flow flags", () => {
const result = parseVisualizationSpec(
"map-json",
JSON.stringify({
markers: [
{ name: "北京", coordinate: [116.4, 39.9], color: "red" },
{ name: "上海", coordinate: [121.47, 31.23], colour: "#16a34a" },
],
lines: [
{
name: "北京-上海",
from: "北京",
to: "上海",
color: "蓝色",
arrow: "是",
flow: true,
},
],
}),
);
assert.equal(result.ok, true);
if (result.ok && result.spec.type === "map") {
assert.equal(result.spec.markers[0]?.color, "#dc2626");
assert.equal(result.spec.markers[1]?.color, "#16a34a");
assert.equal(result.spec.lines[0]?.color, "#2563eb");
assert.equal(result.spec.lines[0]?.arrow, true);
assert.equal(result.spec.lines[0]?.flow, true);
}
});
test("does not treat a generic json log as a map after dropping invalid lines", () => {
const content = '```json\n{"title":"日志","lines":["error 1","error 2"]}\n```';
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
});
test("flow and chart fences stay on the ordinary Markdown path", () => {
const content = "```flow-json\n{}\n```\n```chart-json\n{}\n```";
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
@@ -10,10 +10,6 @@ const optionalText = z.string().trim().max(2_000).optional();
const longitude = z.number().finite().min(-180).max(180);
const latitude = z.number().finite().min(-90).max(90);
const coordinateSchema = z.tuple([longitude, latitude]);
const mapColorSchema = z
.string()
.regex(/^#[0-9a-f]{6}$/i, "颜色必须是 #rrggbb")
.optional();
const mapSchema = z.object({
type: z.literal("map").default("map"),
@@ -26,7 +22,6 @@ const mapSchema = z.object({
name: safeText,
coordinate: coordinateSchema,
description: optionalText,
color: mapColorSchema,
}),
)
.max(500)
@@ -36,9 +31,6 @@ const mapSchema = z.object({
z.object({
name: z.string().trim().max(120).optional(),
coordinates: z.array(coordinateSchema).min(2).max(2_000),
color: mapColorSchema,
arrow: z.boolean().default(true),
flow: z.boolean().default(true),
}),
)
.max(100)
@@ -48,7 +40,6 @@ const mapSchema = z.object({
z.object({
name: z.string().trim().max(120).optional(),
coordinates: z.array(coordinateSchema).min(3).max(2_000),
color: mapColorSchema,
}),
)
.max(100)
@@ -104,300 +95,13 @@ export type VisualizationParseResult =
| { ok: true; spec: VisualizationSpec }
| { ok: false; error: string };
const NAMED_MAP_COLORS: Record<string, string> = {
red: "#dc2626",
orange: "#ea580c",
amber: "#d97706",
yellow: "#ca8a04",
green: "#16a34a",
emerald: "#059669",
teal: "#0d9488",
cyan: "#0891b2",
blue: "#2563eb",
indigo: "#4f46e5",
violet: "#7c3aed",
purple: "#7c3aed",
pink: "#db2777",
rose: "#e11d48",
gray: "#64748b",
grey: "#64748b",
slate: "#64748b",
black: "#0f172a",
white: "#f8fafc",
红: "#dc2626",
红色: "#dc2626",
橙: "#ea580c",
橙色: "#ea580c",
黄: "#ca8a04",
黄色: "#ca8a04",
绿: "#16a34a",
绿色: "#16a34a",
青: "#0891b2",
青色: "#0891b2",
蓝: "#2563eb",
蓝色: "#2563eb",
紫: "#7c3aed",
紫色: "#7c3aed",
粉: "#db2777",
粉色: "#db2777",
灰: "#64748b",
灰色: "#64748b",
};
const HEX_COLOR = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
export function parseMapColor(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const raw = value.trim();
if (!raw || raw.length > 24) return undefined;
if (/url\s*\(|javascript:|expression\s*\(/i.test(raw)) return undefined;
const named = NAMED_MAP_COLORS[raw.toLowerCase()] ?? NAMED_MAP_COLORS[raw];
if (named) return named;
if (!HEX_COLOR.test(raw)) return undefined;
const hex = raw.slice(1);
if (hex.length === 3) {
return `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`.toLowerCase();
}
return `#${hex.slice(0, 6).toLowerCase()}`;
}
export function parseOptionalBoolean(value: unknown): boolean | undefined {
if (typeof value === "boolean") return value;
if (value === 1 || value === "1") return true;
if (value === 0 || value === "0") return false;
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
if (["true", "yes", "on", "是"].includes(normalized)) return true;
if (["false", "no", "off", "否"].includes(normalized)) return false;
return undefined;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asFiniteNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function parseCoordinate(value: unknown): [number, number] | null {
if (Array.isArray(value) && value.length >= 2) {
const lng = asFiniteNumber(value[0]);
const lat = asFiniteNumber(value[1]);
if (lng != null && lat != null && lng >= -180 && lng <= 180 && lat >= -90 && lat <= 90) {
return [lng, lat];
}
return null;
}
const record = asRecord(value);
if (!record) return null;
const nested =
record.coordinate ?? record.coordinates ?? record.coord ?? record.position ?? record.location;
if (nested !== value && (Array.isArray(nested) || asRecord(nested))) {
const nestedCoordinate = parseCoordinate(nested);
if (nestedCoordinate) return nestedCoordinate;
}
const lng = asFiniteNumber(record.lng ?? record.lon ?? record.longitude ?? record.x);
const lat = asFiniteNumber(record.lat ?? record.latitude ?? record.y);
if (lng != null && lat != null && lng >= -180 && lng <= 180 && lat >= -90 && lat <= 90) {
return [lng, lat];
}
return null;
}
function parseCoordinateList(value: unknown): Array<[number, number]> {
if (!Array.isArray(value) || value.length === 0) return [];
const direct = value
.map(parseCoordinate)
.filter((item): item is [number, number] => item != null);
if (direct.length >= 2) return direct;
if (value.length === 1 && Array.isArray(value[0])) return parseCoordinateList(value[0]);
return direct;
}
function featureName(record: Record<string, unknown>): string | undefined {
const value = record.name ?? record.label ?? record.title ?? record.city ?? record.text;
if (typeof value !== "string") return undefined;
const name = value.trim();
return name.length > 0 ? name : undefined;
}
function featureColor(record: Record<string, unknown>): string | undefined {
return parseMapColor(
record.color ?? record.colour ?? record.fill ?? record.fillColor ?? record.stroke,
);
}
function withOptionalStyle(
record: Record<string, unknown>,
extra?: Record<string, unknown>,
): Record<string, unknown> {
const color = featureColor(record);
return {
...extra,
...(color ? { color } : {}),
};
}
function normalizeMarkers(value: unknown): Array<Record<string, unknown>> {
const items = Array.isArray(value) ? value : value ? [value] : [];
const markers: Array<Record<string, unknown>> = [];
for (const item of items) {
const record = asRecord(item);
if (!record) continue;
const name = featureName(record);
const coordinate = parseCoordinate(item);
if (!name || !coordinate) continue;
markers.push({
name,
coordinate,
...withOptionalStyle(record, {
...(typeof record.description === "string" ? { description: record.description } : {}),
}),
});
}
return markers;
}
function resolvePoint(
value: unknown,
markersByName: Map<string, [number, number]>,
): [number, number] | null {
const coordinate = parseCoordinate(value);
if (coordinate) return coordinate;
if (typeof value === "string") return markersByName.get(value.trim()) ?? null;
const record = asRecord(value);
if (!record) return null;
const name = featureName(record);
return name ? (markersByName.get(name) ?? null) : null;
}
function normalizeLines(
value: unknown,
markersByName: Map<string, [number, number]>,
): Array<Record<string, unknown>> {
const items = Array.isArray(value) ? value : value ? [value] : [];
const lines: Array<Record<string, unknown>> = [];
for (const item of items) {
if (Array.isArray(item)) {
const coordinates = parseCoordinateList(item);
if (coordinates.length >= 2) lines.push({ coordinates });
continue;
}
const record = asRecord(item);
if (!record) continue;
const listed = parseCoordinateList(
record.coordinates ??
record.coordinate ??
record.coords ??
record.path ??
record.points ??
record.positions,
);
const from = resolvePoint(record.from ?? record.start ?? record.source, markersByName);
const to = resolvePoint(record.to ?? record.end ?? record.target, markersByName);
const coordinates = listed.length >= 2 ? listed : from && to ? [from, to] : [];
if (coordinates.length < 2) continue;
const name = featureName(record);
const arrow = parseOptionalBoolean(record.arrow ?? record.arrows ?? record.directed);
const flow = parseOptionalBoolean(record.flow ?? record.animate ?? record.animated);
lines.push({
...(name ? { name } : {}),
coordinates,
...withOptionalStyle(record),
...(arrow != null ? { arrow } : {}),
...(flow != null ? { flow } : {}),
});
}
return lines;
}
function normalizePolygons(value: unknown): Array<Record<string, unknown>> {
const items = Array.isArray(value) ? value : value ? [value] : [];
const polygons: Array<Record<string, unknown>> = [];
for (const item of items) {
if (Array.isArray(item)) {
const coordinates = parseCoordinateList(item);
if (coordinates.length >= 3) polygons.push({ coordinates });
continue;
}
const record = asRecord(item);
if (!record) continue;
const coordinates = parseCoordinateList(
record.coordinates ??
record.coordinate ??
record.coords ??
record.path ??
record.points ??
record.positions,
);
if (coordinates.length < 3) continue;
const name = featureName(record);
polygons.push({
...(name ? { name } : {}),
coordinates,
...withOptionalStyle(record),
});
}
return polygons;
}
function normalizeMapPayload(value: Record<string, unknown>): Record<string, unknown> {
const markers = Object.prototype.hasOwnProperty.call(value, "markers")
? normalizeMarkers(value.markers)
: undefined;
const markersByName = new Map<string, [number, number]>();
for (const marker of markers ?? []) {
const name = typeof marker.name === "string" ? marker.name : "";
const coordinate = marker.coordinate;
if (name && Array.isArray(coordinate) && coordinate.length === 2) {
markersByName.set(name, coordinate as [number, number]);
}
}
const lines = Object.prototype.hasOwnProperty.call(value, "lines")
? normalizeLines(value.lines, markersByName)
: undefined;
const polygons = Object.prototype.hasOwnProperty.call(value, "polygons")
? normalizePolygons(value.polygons)
: undefined;
const center = parseCoordinate(value.center) ?? undefined;
const zoom = asFiniteNumber(value.zoom) ?? undefined;
return {
...value,
type: "map",
...(center ? { center } : {}),
...(zoom != null ? { zoom } : {}),
...(markers ? { markers } : {}),
...(lines ? { lines } : {}),
...(polygons ? { polygons } : {}),
};
}
function mapHasRenderable(record: Record<string, unknown>): boolean {
return (
(Array.isArray(record.markers) && record.markers.length > 0) ||
(Array.isArray(record.lines) && record.lines.length > 0) ||
(Array.isArray(record.polygons) && record.polygons.length > 0) ||
parseCoordinate(record.center) != null
);
}
function normalizeLanguageType(
language: VisualizationLanguage,
value: unknown,
): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const record = { ...(value as Record<string, unknown>) };
if (language === "map-json") {
const normalized = normalizeMapPayload(record);
return mapHasRenderable(normalized) ? normalized : { ...record, type: "map" };
}
if (language === "map-json") record.type = "map";
if (language === "mindmap-json") record.type = "mindmap";
return record;
}
@@ -517,14 +221,18 @@ export type VisualizationSegment =
const FENCE_PATTERN = /(^|\n)```(map-json|mindmap-json|mindmap|json)[ \t]*\r?\n/gi;
function isMapJsonSource(source: string): boolean {
const parsed = parseVisualizationSpec("map-json", source);
if (!parsed.ok || parsed.spec.type !== "map") return false;
return (
parsed.spec.markers.length > 0 ||
parsed.spec.lines.length > 0 ||
parsed.spec.polygons.length > 0 ||
parsed.spec.center != null
let raw: unknown;
try {
raw = JSON.parse(source);
} catch {
return false;
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
const record = raw as Record<string, unknown>;
const hasMapField = ["center", "zoom", "markers", "lines", "polygons"].some(
(field) => Object.prototype.hasOwnProperty.call(record, field),
);
return hasMapField && parseVisualizationSpec("map-json", source).ok;
}
/** Split only our dedicated fences. With no match, the caller keeps the exact legacy renderer path. */
@@ -6,7 +6,6 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useSidebar } from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { useAppSystemName } from "@/core/system-settings/hooks";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@@ -51,13 +50,12 @@ export function SidebarCollapseButton({ className }: { className?: string }) {
export function WorkspaceHeader({ className }: { className?: string }) {
const { t } = useI18n();
const systemName = useAppSystemName(t.pages.appName);
const { state } = useSidebar();
const titleBlock = (
<div className="min-w-0 flex-1">
<div className="text-sidebar-foreground truncate text-sm font-semibold leading-tight tracking-tight">
{systemName}
{t.pages.appName}
</div>
</div>
);
@@ -13,6 +13,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, FolderKanban, Landmark, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
@@ -187,6 +188,7 @@ export function WorkspaceNavChatList({
showCollapseTrigger?: boolean;
compact?: boolean;
}) {
const { t } = useI18n();
const pathname = usePathname();
const navigate = useNavigate();
const { sidebarItems } = useWorkspaceCompactNavItems();
@@ -217,42 +219,38 @@ export function WorkspaceNavChatList({
{compact ? (
// 紧凑模式:新对话占满整行白卡样式 + 右侧收起钮(点击收起整列历史栏)。
<div className="flex items-center gap-1">
{newChatItem && (
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(NEW_CHAT_COMPACT_CLASS, "min-w-0 flex-1")}
>
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
<Plus className="stroke-[2.5] text-[var(--primary)]" />
<span>{newChatItem.label}</span>
</Link>
</SidebarMenuButton>
)}
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(NEW_CHAT_COMPACT_CLASS, "min-w-0 flex-1")}
>
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
<Plus className="stroke-[2.5] text-[var(--primary)]" />
<span>{newChatItem?.label ?? t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
{showCollapseTrigger && (
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
)}
</div>
) : (
<div className="flex items-center gap-1">
{newChatItem && (
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(
NEW_CHAT_CLASS,
showCollapseTrigger && "min-w-0 flex-1",
)}
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(
NEW_CHAT_CLASS,
showCollapseTrigger && "min-w-0 flex-1",
)}
>
<Link
href="/page/workspace/chats/new"
onClick={handleNewChatClick}
>
<Link
href="/page/workspace/chats/new"
onClick={handleNewChatClick}
>
<MessageSquarePlus />
<span>{newChatItem.label}</span>
</Link>
</SidebarMenuButton>
)}
<MessageSquarePlus />
<span>{newChatItem?.label ?? t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
{showCollapseTrigger && (
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
)}
@@ -18,7 +18,6 @@ import {
ScrollTextIcon,
Settings2Icon,
SettingsIcon,
ShieldAlertIcon,
SparklesIcon,
TagsIcon,
TimerIcon,
@@ -318,14 +317,6 @@ export function WorkspaceNavMenu() {
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/stream-truncations")}
>
<ShieldAlertIcon />
模型截断记录
</DropdownMenuItem>
)}
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口:
简洁模式没有全局侧边栏,走工作区地址带上 WorkspaceSidebar;
/page/strategy/admin/menu-management 保留给全量模式侧边栏。 */}
@@ -35,10 +35,11 @@ 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(false);
const { notebooks, isLoading, isError, token } = useStudioNotebooks(open);
const [open, setOpen] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
@@ -64,6 +65,11 @@ 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);
@@ -90,29 +96,12 @@ 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}>
+36
View File
@@ -133,6 +133,20 @@ export interface AdminLeaderboardResponse {
trends: AdminLeaderboardTrendPoint[];
}
export interface AdminUserAnalyticsResponse {
range_days: number;
since: string;
until: string;
user: AdminLeaderboardUser;
overview: AdminLeaderboardOverview;
skills: AdminLeaderboardSkill[];
tools: AdminLeaderboardSkill[];
models: AdminLeaderboardDimension[];
agents: AdminLeaderboardDimension[];
run_statuses: AdminLeaderboardDimension[];
trends: AdminLeaderboardTrendPoint[];
}
export interface AdminLeaderboardReportResponse {
report: string;
generated_at: string;
@@ -280,6 +294,28 @@ export async function refreshTodayLeaderboard(
return result;
}
export async function getUserAnalytics(
userId: string,
range: { since: string; until: string; days?: number },
limit = 20,
): Promise<AdminUserAnalyticsResponse> {
const params = new URLSearchParams({
days: String(range.days ?? 30),
since: range.since,
until: range.until,
limit: String(limit),
});
const url = `${base()}/${encodeURIComponent(userId)}/analytics?${params}`;
const result = await parse<AdminUserAnalyticsResponse>(await apiFetch(url), "Failed to load user analytics");
writeYLog({
optType: OptType.view,
optDetail: "CM助手-全量-管理后台-查看用户分析",
url,
reqParam: { userId, ...range, limit },
});
return result;
}
export async function generateUserLeaderboardReport(
range: { since: string; until: string; days?: number },
limit = 30,
@@ -1,81 +0,0 @@
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),
});
}
+13
View File
@@ -7,6 +7,7 @@ import {
deleteAdminV1Fact,
generateUserLeaderboardReport,
getUserMemory,
getUserAnalytics,
getUserLeaderboard,
refreshTodayLeaderboard,
listUserQa,
@@ -161,6 +162,18 @@ export function useRefreshTodayLeaderboard() {
});
}
export function useUserAnalytics(
userId: string | null,
range: { since: string; until: string; days?: number },
limit = 20,
) {
return useQuery({
queryKey: ["admin-activity", "analytics", userId, range.since, range.until, limit],
queryFn: () => getUserAnalytics(userId!, range, limit),
enabled: !!userId,
});
}
export function useGenerateUserLeaderboardReport() {
return useMutation({
mutationFn: ({
@@ -1,53 +0,0 @@
/**
* 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)),
};
}
+7 -104
View File
@@ -5,7 +5,6 @@ import { OptType, writeYLog } from "@/core/log/y-log";
import type {
Agent,
AgentSelectorResponse,
AgentSidebarItem,
AgentSort,
CreateAgentRequest,
PaginatedAgents,
@@ -101,55 +100,20 @@ export async function listAgentsPaginated(
return result;
}
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}`);
export async function listAllAgentsAdmin(): Promise<Agent[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents?admin=true`);
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[];
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,
};
const data = (await res.json()) as { agents: Agent[] };
writeYLog({
optType: OptType.search,
optDetail: "CM助手-全量-智能体列表",
url,
reqParam: { ...params },
returnParam: result,
url: `/api/agents?admin=true`,
returnParam: data.agents,
});
return result;
return data.agents;
}
export async function getAgent(id: string): Promise<Agent> {
@@ -237,14 +201,6 @@ 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",
@@ -445,39 +401,8 @@ export async function setAgentPrivate(
return updated;
}
/** 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(
export async function setAgentFeatured(
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(
@@ -518,28 +443,6 @@ 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`,
@@ -1,41 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
canonicalAgentChatLink,
isAllowedAgentChatUrl,
} from "./chat-link.ts";
test("allows http(s) and in-app paths only", () => {
assert.equal(isAllowedAgentChatUrl("https://example.test/desk"), true);
assert.equal(isAllowedAgentChatUrl("/page/workspace/agents"), true);
assert.equal(isAllowedAgentChatUrl("#/page/workspace/agents"), true);
assert.equal(isAllowedAgentChatUrl("javascript:alert(1)"), false);
assert.equal(isAllowedAgentChatUrl("//example.test"), false);
assert.equal(isAllowedAgentChatUrl(""), false);
});
test("canonical link drops a blank url and blank param keys", () => {
assert.equal(canonicalAgentChatLink({ url: " ", open_mode: "self", route_params: [] }), null);
const link = canonicalAgentChatLink({
url: " https://example.test/desk ",
open_mode: "self",
route_params: [
{ key: " ", paramType: "login", source: "userName" },
{ key: "userName", paramType: "login", source: "userName" },
],
});
assert.deepEqual(link, {
url: "https://example.test/desk",
open_mode: "self",
route_params: [
{
key: "userName",
paramType: "login",
source: "userName",
customValue: null,
themeValue: null,
},
],
});
});
-113
View File
@@ -1,113 +0,0 @@
const URL_MAX = 2048;
export interface AgentChatLinkDraft {
url?: string | null;
open_mode?: "self" | "blank" | null;
route_params?: Array<{
key?: string | null;
paramType?: string | null;
source?: string | null;
customValue?: string | null;
themeValue?: string | null;
}> | null;
}
export interface CanonicalAgentChatLink {
url: string;
open_mode: "self" | "blank";
route_params: Array<{
key: string;
paramType: "login" | "theme";
source: string | null;
customValue: string | null;
themeValue: string | null;
}>;
}
export function isAllowedAgentChatUrl(url: string): boolean {
const value = url.trim();
if (!value || value.length > URL_MAX) return false;
const lowered = value.toLowerCase();
if (
lowered.startsWith("javascript:") ||
lowered.startsWith("data:") ||
lowered.startsWith("vbscript:") ||
lowered.startsWith("file:")
) {
return false;
}
if (value.startsWith("#/")) return true;
if (value.startsWith("/") && !value.startsWith("//")) return true;
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
/** Drop blank rows and normalize the payload sent to the agent API. Empty url clears the link. */
export function canonicalAgentChatLink(
link: AgentChatLinkDraft | null | undefined,
): CanonicalAgentChatLink | null {
const url = (link?.url ?? "").trim();
if (!url) return null;
const routeParams: CanonicalAgentChatLink["route_params"] = [];
for (const raw of link?.route_params ?? []) {
const key = (raw?.key ?? "").trim();
if (!key) continue;
const paramType = raw?.paramType === "theme" ? "theme" : "login";
routeParams.push({
key,
paramType,
source: paramType === "login" ? (raw?.source || "access_token") : null,
customValue:
paramType === "login" && raw?.source === "other" ? (raw.customValue ?? "") : null,
themeValue: paramType === "theme" ? (raw?.themeValue ?? "") : null,
});
}
return {
url,
open_mode: link?.open_mode === "self" ? "self" : "blank",
route_params: routeParams,
};
}
export function agentChatLinksEqual(
left: AgentChatLinkDraft | null | undefined,
right: AgentChatLinkDraft | null | undefined,
): boolean {
return JSON.stringify(canonicalAgentChatLink(left)) === JSON.stringify(canonicalAgentChatLink(right));
}
/**
* Open a fully resolved chat-link URL.
* Paths starting with `/` or `#/` stay inside this HashRouter app.
*/
export function openResolvedAgentChatUrl(
url: string,
openMode: "self" | "blank",
navigate: (path: string) => void,
): void {
const mode = openMode === "self" ? "self" : "blank";
const internal = url.startsWith("#/")
? url.slice(1)
: url.startsWith("/") && !url.startsWith("//")
? url
: null;
if (internal) {
if (mode === "self") {
navigate(internal);
return;
}
const base = window.location.href.split("#")[0];
const hashPath = internal.startsWith("/") ? internal : `/${internal}`;
window.open(`${base}#${hashPath}`, "_blank", "noopener,noreferrer");
return;
}
if (mode === "self") {
window.location.assign(url);
return;
}
window.open(url, "_blank", "noopener,noreferrer");
}
+43 -104
View File
@@ -13,29 +13,26 @@ import {
getAgent,
getAgentSelector,
listAgents,
listSidebarAgents,
listAgentsPaginated,
listAllAgentsAdminPaginated,
listAllAgentsAdmin,
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. */
@@ -69,16 +66,12 @@ function applyAgentToCache(
mapCachedAgents(queryClient, (a) => (a.id === next.id ? { ...a, ...next } : a));
}
export function useAgents(
params: { search?: string; tagIds?: string[]; enabled?: boolean } = {},
) {
export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
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 };
@@ -144,47 +137,29 @@ export function useAgentsPaginated(params: {
}
/**
* 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.
* 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.
*/
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,
export function useAllAgentsAdmin(enabled: boolean) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", "admin", "all"],
queryFn: () => listAllAgentsAdmin(),
enabled,
staleTime: 30_000,
});
return {
agents: data?.agents ?? [],
total: data?.total ?? 0,
isLoading,
isFetching,
error,
};
return { agents: data ?? [], isLoading, error };
}
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,
});
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,
});
return { agent: data ?? null, isLoading, error };
}
@@ -268,11 +243,10 @@ export function useToggleAgentPin() {
});
}
export function useAgentSelector({ enabled = true }: { enabled?: boolean } = {}) {
export function useAgentSelector() {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", "selector"],
queryFn: () => getAgentSelector(),
enabled,
staleTime: 60_000,
});
return {
@@ -280,34 +254,24 @@ export function useAgentSelector({ enabled = true }: { enabled?: boolean } = {})
isLoading,
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,
};
}
}
/**
* 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"] });
@@ -424,31 +388,6 @@ 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({
-44
View File
@@ -33,26 +33,6 @@ export function normalizeAgentQuestions(
return out;
}
/** 智能体管理「对话」按钮的跳转配置。空链接表示仍进入本系统问答。 */
export interface AgentChatRouteParam {
key: string;
paramType: "login" | "theme";
source?: string | null;
customValue?: string | null;
themeValue?: string | null;
}
export interface AgentChatLink {
url: string;
/** blank = 新窗口,self = 当前窗口。 */
open_mode: "self" | "blank";
route_params: AgentChatRouteParam[];
}
export function emptyAgentChatLink(): AgentChatLink {
return { url: "", open_mode: "blank", route_params: [] };
}
export interface Agent {
id: string;
name: string;
@@ -62,10 +42,6 @@ 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;
@@ -73,13 +49,8 @@ export interface Agent {
updated_at?: string | null;
soul?: string | null;
model?: string | null;
/** Whether this agent uses the administrator-configured suffix for its model. Defaults to true. */
model_prompt_suffix_enabled?: boolean | 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}. */
@@ -93,8 +64,6 @@ export interface Agent {
seat_skill_directive?: boolean | null;
/** DeerFlow LLMWiki mapping ids this agent allows conversations to select. */
llmwiki_knowledge_base_ids?: string[] | null;
/** 智能体管理「对话」按钮的跳转地址。null/缺省 = 进入本系统问答。 */
chat_link?: AgentChatLink | null;
tags?: Tag[];
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
@@ -108,11 +77,6 @@ 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[];
@@ -130,16 +94,12 @@ export interface CreateAgentRequest {
square_id?: string;
skills?: string[] | null;
model?: string | null;
/** Whether this agent uses the administrator-configured suffix for its model. Defaults to true. */
model_prompt_suffix_enabled?: boolean | null;
tool_groups?: string[] | null;
common_questions?: AgentQuestion[] | null;
template_name?: string | null;
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null;
llmwiki_knowledge_base_ids?: string[] | null;
/** 对话跳转;传 null 或空 url 表示清除,仍进入本系统问答。 */
chat_link?: AgentChatLink | null;
}
export interface UpdateAgentRequest {
@@ -151,8 +111,6 @@ export interface UpdateAgentRequest {
square_id?: string | null;
skills?: string[] | null;
model?: string | null;
/** Whether this agent uses the administrator-configured suffix for its model. */
model_prompt_suffix_enabled?: boolean | null;
tool_groups?: string[] | null;
common_questions?: AgentQuestion[] | null;
/** 入库模板名;传空字符串表示清除。 */
@@ -160,6 +118,4 @@ export interface UpdateAgentRequest {
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null;
llmwiki_knowledge_base_ids?: string[] | null;
/** 对话跳转;传 null 或空 url 表示清除,仍进入本系统问答。 */
chat_link?: AgentChatLink | null;
}
Loaded 100 of 818 files, more files were not shown because too many files have changed in this diff. Show more